Skip to content

Commit

Permalink
feat(vscode-pv-handlebars-language-server): add ui auto-complitation …
Browse files Browse the repository at this point in the history
…capacity for kluntje components

extend vscode plugin's capibility, to suggest css classes for uiElement and ui propertie names for
uiEvents based on the component hbs file
  • Loading branch information
mbehzad committed Jun 20, 2024
1 parent cbc539c commit e176ca3
Show file tree
Hide file tree
Showing 11 changed files with 266 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ export function activate(context: ExtensionContext): void {

// Options to control the language client
const clientOptions: LanguageClientOptions = {
// Register the server for Handlebars documents
documentSelector: [{ scheme: "file", language: "handlebars" }],
// Register the server for Handlebars documents and Typescript
documentSelector: [
{ scheme: "file", language: "handlebars" },
{ scheme: "file", language: "typescript" },
],
};

// Create the language client and start the client.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "es2019",
"lib": ["ES2019"],
"target": "ESNext",
"lib": ["ESNext"],
"module": "commonjs",
"moduleResolution": "node",
"outDir": "out",
Expand Down
9 changes: 8 additions & 1 deletion packages/vscode-pv-handlebars-language-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"vscode": "^1.43.0"
},
"activationEvents": [
"onLanguage:handlebars"
"onLanguage:handlebars",
"onLanguage:typescript"
],
"main": "./client/out/extension",
"contributes": {
Expand Down Expand Up @@ -62,6 +63,12 @@
"type": "boolean",
"default": true,
"description": "Show ui and event info for html elements used by the kluntje custom elements in the hbs files."
},
"P!VHandlebarsLanguageServer.provideUiCompletionInTypescript": {
"scope": "window",
"type": "boolean",
"default": true,
"description": "For @klutnje componets, suggest the css classes and ui names in the @uiElement and @uiEvent."
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface Settings {
provideCssClassCompletion: boolean;
validateHandlebars: boolean;
showUIAndEvents: boolean;
provideUiCompletionInTypescript: boolean;
}

// singleton class to return users extension settings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { TextDocument } from "vscode-languageserver-textdocument";
import globby = require("globby");
import { getCustomElementsUIAndEvents } from "./customElementDefinitionProvider";
import { getFilePath } from "./helpers";
import rgx from "./rgx";

const classAndTagRegEx = () => /<(?<tagName>[a-zA-Z0-9_-]+)[^>]*?(class="(?<className>[^"]*))?"/g;

/**
* creates an object that can be consumed by onCodeLens request handler (see vscode's `CodeLens` interface for more info)
Expand Down Expand Up @@ -54,7 +54,7 @@ export async function codelensProvider(textDocument: TextDocument) {
if (!selectors) return null;

const content = textDocument.getText();
const regex = classAndTagRegEx();
const regex = new RegExp(rgx.hbs.classNamesAndTags(), "g");
let matches;

while ((matches = regex.exec(content)) !== null) {
Expand Down
116 changes: 108 additions & 8 deletions packages/vscode-pv-handlebars-language-server/server/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as yamlFront from "yaml-front-matter";
import { URI } from "vscode-uri";
import type { Position, TextDocumentIdentifier } from "vscode-languageserver/node";
import type { TextDocument } from "vscode-languageserver-textdocument";
import rgx from "./rgx";

interface PVConfig {
hbsHelperSrc?: string;
Expand Down Expand Up @@ -55,15 +56,25 @@ export function getFilePath(document: TextDocument | TextDocumentIdentifier): st
return toUnixPath(fsPath);
}

export function isHandlebarsFile(uri: string) {
return uri.endsWith(".hbs");
}

export function isTypescriptFile(uri: string) {
return uri.endsWith(".ts");
}

/**
* returns true if the .hbs file's path seams to belong to a repo with the p!v fe archetype structure
* @param {string} templatePath - absolute path (with unix separators) to a hbs file
* @returns {boolean}
*/
export function isPVArchetype(templatePath: string): boolean {
return templatePath.includes("/frontend/src/components")
|| templatePath.includes("/frontend/src/pages")
|| templatePath.includes("/frontend/src/layouts");
return (
templatePath.includes("/frontend/src/components") ||
templatePath.includes("/frontend/src/pages") ||
templatePath.includes("/frontend/src/layouts")
);
}

/**
Expand Down Expand Up @@ -149,7 +160,10 @@ export async function getCustomHelperFiles(componentsRootPath: string): Promise<
return helperPaths.map(filePath => ({ path: filePath, name: path.basename(filePath, ".js") }));
}

interface LayoutFiles {lsg?: {[name: string]: string}, pages?: {[name: string]: string}}
interface LayoutFiles {
lsg?: { [name: string]: string };
pages?: { [name: string]: string };
}

// list of hbs files which are used in assemble-lite as layouts for lsg components or pages
export async function getLayoutFiles(componentsRootPath: string): Promise<LayoutFiles | null> {
Expand All @@ -158,7 +172,7 @@ export async function getLayoutFiles(componentsRootPath: string): Promise<Layout

if (pvConfig === null) return null;

const layouts: Array<{name: "lsg" | "pages", dir: string | undefined}> = [
const layouts: Array<{ name: "lsg" | "pages"; dir: string | undefined }> = [
{
name: "lsg",
dir: pvConfig.lsgTemplatesSrc,
Expand All @@ -171,9 +185,9 @@ export async function getLayoutFiles(componentsRootPath: string): Promise<Layout

const layoutFiles: LayoutFiles = {};

for (const {name, dir} of layouts) {
for (const { name, dir } of layouts) {
if (dir) {
const pageLayoutsGlob = toUnixPath(path.join(frontendRootPath , dir, "/**/*.hbs"));
const pageLayoutsGlob = toUnixPath(path.join(frontendRootPath, dir, "/**/*.hbs"));
const layouts = await globby(pageLayoutsGlob);
layoutFiles[name] = Object.fromEntries(layouts.map(filePath => [path.basename(filePath, ".hbs"), filePath]));
}
Expand Down Expand Up @@ -258,7 +272,7 @@ export function isPartialParameter(text: string): boolean {
* @returns {string}
*/
export async function getHbsContent(filePath: string): Promise<string> {
const fileContent = await readFile(filePath, { encoding: "utf-8" });
const fileContent = await getFileContent(filePath);
const hbsCode = yamlFront.loadFront(fileContent).__content.trim();
return hbsCode;
}
Expand All @@ -281,3 +295,89 @@ export function getCurrentSymbolsName(document: TextDocument, position: Position

return symbolName;
}

// returns all the css classes in for the hbs template of the given ts file
// and the hbs partials it is referencing directly or indirectly
export async function getCssClasses(filePath: string) {
const dir = path.dirname(filePath);
const name = basename(filePath);

const cssClasses = [];
// assuming the markup example of the custom element defined in foo-bar.ts is in foo-bar.hbs
const hbsTemplate = (await globby(`${dir}/**/${name}.hbs`))[0];
if (!hbsTemplate) return [];

const templates = await getNestedTemplates(hbsTemplate);

for (const hbsFile of templates) {
const fileContent = hbsFile.fileContent;
const regex = new RegExp(rgx.hbs.classNamesAndTags(), "g");
let matches;
while ((matches = regex.exec(fileContent)) !== null) {
if (!matches.groups!.className) continue;

const contentBefore = fileContent.substring(0, matches.index);
const line = contentBefore.split("\n").length - 1;
const character = matches.index - contentBefore.lastIndexOf("\n");
let className = matches.groups!.className;
className = className.replace(/{{.*?}}/g, "");
const classes = className
.split(" ")
.map(c => c.trim())
.filter(c => c !== "");
cssClasses.push(
...classes.map(clss => ({
className: clss,
location: {
filePath,
line,
character,
},
})),
);
}
}

return cssClasses;
}

// returns a list of all partials used in the given hbs content
export async function getReferencedHbsPartialNames(fileContent: string) {
return Array.from(fileContent.matchAll(rgx.hbs.partials())).map(match => match.groups!.partial);
}

// returns a list of hbs files that are directly or indirectly via partial referenced in the given file(s)
export async function getNestedTemplates(hbsTemplate: string) {
const visited: string[] = [];
async function searchRecursive(filePaths: string | string[]) {
const results: Array<{ filePath: string; fileContent: string }> = [];

if (Array.isArray(filePaths)) {
for (const filePath of filePaths) {
results.push(...(await searchRecursive(filePath)));
}
} else if (!visited.includes(filePaths)) {
visited.push(filePaths);
const fileContent = await getFileContent(filePaths);
results.push({ filePath: filePaths, fileContent });

let componentsRootPath;
try {
componentsRootPath = getComponentsRootPath(filePaths);
} catch (_err) {
// err e.g. in case of it not having p!v folder structure
return results;
}

const partials = await getReferencedHbsPartialNames(fileContent);
for (const partial of partials) {
const partialPaths = await globby(`${componentsRootPath}/**/${partial}.hbs`);
results.push(...(await searchRecursive(partialPaths)));
}
}

return results;
}

return searchRecursive(hbsTemplate);
}
18 changes: 18 additions & 0 deletions packages/vscode-pv-handlebars-language-server/server/src/rgx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export default {
ts: {
// `@uiElements(".optional` | `@uiElement("`
endsWithUiDecoratorSelector: () => /@uiElements?\("[^"]*\.?$/,
// `@uiElements(".selector") ctas`
uiDecoratorPropertyName: () => /@uiElements?\(.+?\)\s*(?<ui>[_$a-zA-Z0-9]+)/,
// `@uiEvent("optional` | `@uiEvent<T>("`
endsWithEventDecoratorElementName: () => /@uiEvent(\<.*\>)?\("[^"]*$/,
// @eventListener({ ... target: "optional
endsWithEventListenerDecoratorTarget: () => /@eventListener\({[^}]*target:\s*"[^"]*$/,
},
hbs: {
// `<some-tag class="className {{#if foo}}className2{{/if}}"`
classNamesAndTags: () => /<(?<tagName>[a-zA-Z0-9_-]+)[^>]*?(class="(?<className>[^"]*))?"/,
// {{#> some-partial
partials: () => /{{#?>\s*(?<partial>[-_a-zA-Z0-9]+)/g,
},
};
43 changes: 28 additions & 15 deletions packages/vscode-pv-handlebars-language-server/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import DiagnosticProvide from "./diagnosticProvider";
import { definitionProvider } from "./definitionProvider";
import { completionProvider } from "./completionProvider";
import { hoverProvider } from "./hoverProvider";
import { getFilePath } from "./helpers";
import { getFilePath, isHandlebarsFile, isTypescriptFile } from "./helpers";
import { codelensProvider } from "./codelensProvider";
import { tsCompletionProvider } from "./tsCompletionProvider";

// Create a connection for the server, using Node's IPC as a transport.
// Also include all preview / proposed LSP features.
Expand Down Expand Up @@ -75,28 +76,35 @@ connection.onDidChangeConfiguration(_change => {

// update diagnostics info for the open files
openDocuments.forEach(async document => {
const settings = await SettingsService.getDocumentSettings(document.uri);
if (settings.validateHandlebars) DiagnosticProvide.setDiagnostics(document);
else DiagnosticProvide.unsetDiagnostics(document);
if (isHandlebarsFile(document.uri)) {
const settings = await SettingsService.getDocumentSettings(document.uri);
if (settings.validateHandlebars) DiagnosticProvide.setDiagnostics(document);
else DiagnosticProvide.unsetDiagnostics(document);
}
});
});

// This handler provides the initial list of the completion items.
connection.onCompletion(async (textDocumentPosition: TextDocumentPositionParams): Promise<CompletionItem[] | null> => {
const document = documents.get(textDocumentPosition.textDocument.uri);

if (document) {
const filePath = getFilePath(document);
if (filePath) return completionProvider(document, textDocumentPosition.position, filePath);
}
if (!document) return null;

const filePath = getFilePath(document);
const settings = await SettingsService.getDocumentSettings(document.uri);

if (isHandlebarsFile(filePath)) return completionProvider(document, textDocumentPosition.position, filePath);
else if (isTypescriptFile(filePath) && settings.provideUiCompletionInTypescript)
return tsCompletionProvider(document, textDocumentPosition.position, filePath);

return null;
});

connection.onHover(async ({ textDocument, position }) => {
const document = documents.get(textDocument.uri);
const settings = await SettingsService.getDocumentSettings(textDocument.uri);

if (document && settings.showHoverInfo) return hoverProvider(document, position);
if (document && settings.showHoverInfo && isHandlebarsFile(textDocument.uri)) return hoverProvider(document, position);

return null;
});
Expand All @@ -106,7 +114,7 @@ connection.onDefinition(({ textDocument, position }) => {

if (document) {
const filePath = getFilePath(document);
if (filePath) return definitionProvider(document, position, filePath);
if (isHandlebarsFile(filePath)) return definitionProvider(document, position, filePath);
}

return null;
Expand All @@ -116,21 +124,26 @@ connection.onCodeLens(async ({ textDocument }) => {
const document = documents.get(textDocument.uri);
const settings = await SettingsService.getDocumentSettings(textDocument.uri);

if (document && settings.showUIAndEvents) return codelensProvider(document);
if (document && settings.showUIAndEvents && isHandlebarsFile(textDocument.uri)) return codelensProvider(document);
});

// is called when the file is first opened and every time it is modified
// only supporting push diagnostics
documents.onDidChangeContent(change => {
const document = change.document;
DiagnosticProvide.setDiagnostics(document);
openDocuments.add(document);
if (isHandlebarsFile(document.uri)) {
DiagnosticProvide.setDiagnostics(document);
openDocuments.add(document);
}
});

// a document has closed: clear all diagnostics
documents.onDidClose(event => {
DiagnosticProvide.unsetDiagnostics(event.document);
openDocuments.delete(event.document);
const document = event.document;
if (isHandlebarsFile(document.uri)) {
DiagnosticProvide.unsetDiagnostics(document);
openDocuments.delete(document);
}
});

// Make the text document manager listen on the connection
Expand Down
Loading

0 comments on commit e176ca3

Please sign in to comment.