From e18d7440fc943dc47ffad038b419ed90075b658a Mon Sep 17 00:00:00 2001 From: mbehzad Date: Fri, 21 Jun 2024 15:54:45 +0200 Subject: [PATCH] feat(vscode-pv-handlebars-language-server): add goto definition support for ui prop/selectors in TS --- .../package.json | 4 +- .../server/src/SettingsService.ts | 8 +-- .../server/src/codelensProvider.ts | 23 +++++--- .../server/src/helpers.ts | 58 +++++++++++++------ .../server/src/rgx.ts | 6 +- .../server/src/server.ts | 28 ++++----- .../server/src/tsDefinitionProvider.ts | 52 +++++++++++++++++ 7 files changed, 133 insertions(+), 46 deletions(-) create mode 100644 packages/vscode-pv-handlebars-language-server/server/src/tsDefinitionProvider.ts diff --git a/packages/vscode-pv-handlebars-language-server/package.json b/packages/vscode-pv-handlebars-language-server/package.json index b535aaf..5c66fb4 100644 --- a/packages/vscode-pv-handlebars-language-server/package.json +++ b/packages/vscode-pv-handlebars-language-server/package.json @@ -58,13 +58,13 @@ "default": true, "description": "Marks any parse issue in the handlebars files." }, - "P!VHandlebarsLanguageServer.showUIAndEvents": { + "P!VHandlebarsLanguageServer.showUiAndEvents": { "scope": "window", "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": { + "P!VHandlebarsLanguageServer.provideUiSupportInTypescript": { "scope": "window", "type": "boolean", "default": true, diff --git a/packages/vscode-pv-handlebars-language-server/server/src/SettingsService.ts b/packages/vscode-pv-handlebars-language-server/server/src/SettingsService.ts index eab7869..e854a61 100644 --- a/packages/vscode-pv-handlebars-language-server/server/src/SettingsService.ts +++ b/packages/vscode-pv-handlebars-language-server/server/src/SettingsService.ts @@ -6,12 +6,12 @@ interface Settings { provideCssClassGoToDefinition: boolean; provideCssClassCompletion: boolean; validateHandlebars: boolean; - showUIAndEvents: boolean; - provideUiCompletionInTypescript: boolean; + showUiAndEvents: boolean; + provideUiSupportInTypescript: boolean; } // singleton class to return users extension settings -export default new class SettingsService { +export default new (class SettingsService { connection?: Connection; // cache the settings of all open documents @@ -49,4 +49,4 @@ export default new class SettingsService { // Reset all cached document settings this.documentSettings.clear(); } -}(); +})(); diff --git a/packages/vscode-pv-handlebars-language-server/server/src/codelensProvider.ts b/packages/vscode-pv-handlebars-language-server/server/src/codelensProvider.ts index 61b85b1..b4c3bc3 100644 --- a/packages/vscode-pv-handlebars-language-server/server/src/codelensProvider.ts +++ b/packages/vscode-pv-handlebars-language-server/server/src/codelensProvider.ts @@ -6,7 +6,6 @@ import { getCustomElementsUIAndEvents } from "./customElementDefinitionProvider" import { getFilePath } from "./helpers"; import rgx from "./rgx"; - /** * creates an object that can be consumed by onCodeLens request handler (see vscode's `CodeLens` interface for more info) * @@ -54,16 +53,26 @@ export async function codelensProvider(textDocument: TextDocument) { if (!selectors) return null; const content = textDocument.getText(); - const regex = new RegExp(rgx.hbs.classNamesAndTags(), "g"); - let matches; + // supporting ui selector being a css class, or a tag name for now + const matches = [ + ...content.matchAll(new RegExp(rgx.hbs.tags(), "g")), + ...content.matchAll(new RegExp(rgx.hbs.classNames(), "g")), + ]; - while ((matches = regex.exec(content)) !== null) { + for (const match of matches) { for (const [selector, item] of Object.entries(selectors)) { const classMatch = - selector.startsWith(".") && matches.groups!.className?.split(" ").includes(selector.replace(/^./, "")); - const tagMatch = matches.groups!.tagName === selector; + selector.startsWith(".") && + match + // remove handlebar expressions + .groups!.className?.replaceAll(/{{.*?}}/g, "") + // split each css class + .split(" ") + // check if one is the same as the ui selector + .includes(selector.replace(/^./, "")); + const tagMatch = match.groups!.tagName === selector; if (classMatch || tagMatch) { - const line = content.substring(0, matches.index).split("\n").length - 1; + const line = content.substring(0, match.index).split("\n").length - 1; if (item.ui) { codeLenses.push( diff --git a/packages/vscode-pv-handlebars-language-server/server/src/helpers.ts b/packages/vscode-pv-handlebars-language-server/server/src/helpers.ts index 908fd39..99841ea 100644 --- a/packages/vscode-pv-handlebars-language-server/server/src/helpers.ts +++ b/packages/vscode-pv-handlebars-language-server/server/src/helpers.ts @@ -311,29 +311,33 @@ export async function getCssClasses(filePath: string) { 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; + const matches = Array.from(fileContent.matchAll(new RegExp(rgx.hbs.classNames(), "g"))); + for (const match of matches) { + let className = match.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, - }, - })), + ...classes.map(clss => { + const start = getPositionAt(fileContent, match!.index + match![0].indexOf(clss)); + return { + className: clss, + location: { + filePath: hbsFile.filePath, + range: { + start: start, + end: { + line: start.line, + character: start.character + clss.length, + }, + }, + }, + }; + }), ); } } @@ -358,6 +362,7 @@ export async function getNestedTemplates(hbsTemplate: string) { } } else if (!visited.includes(filePaths)) { visited.push(filePaths); + const fileContent = await getFileContent(filePaths); results.push({ filePath: filePaths, fileContent }); @@ -381,3 +386,22 @@ export async function getNestedTemplates(hbsTemplate: string) { return searchRecursive(hbsTemplate); } + +/** + * Converts a zero-based offset to a position (line, character). + * + * @param {text} text + * @param {number} offset + * @returns {line: number, character: number} + */ +export function getPositionAt(text: string, offset: number) { + const textBefore = text.substring(0, offset); + const lines = textBefore.split("\n"); + const line = lines.length - 1; + const character = lines.at(-1)!.length; + + return { + line, + character, + }; +} diff --git a/packages/vscode-pv-handlebars-language-server/server/src/rgx.ts b/packages/vscode-pv-handlebars-language-server/server/src/rgx.ts index 0938af2..84bbad1 100644 --- a/packages/vscode-pv-handlebars-language-server/server/src/rgx.ts +++ b/packages/vscode-pv-handlebars-language-server/server/src/rgx.ts @@ -10,8 +10,10 @@ export default { endsWithEventListenerDecoratorTarget: () => /@eventListener\({[^}]*target:\s*"[^"]*$/, }, hbs: { - // ` /<(?[a-zA-Z0-9_-]+)[^>]*?(class="(?[^"]*))?"/, + // ` /<(?[a-zA-Z0-9_-]+)/, + // ` /<(?[a-zA-Z0-9_-]+)[^>]*?class="(?[^"]*)"/, // {{#> some-partial partials: () => /{{#?>\s*(?[-_a-zA-Z0-9]+)/g, }, diff --git a/packages/vscode-pv-handlebars-language-server/server/src/server.ts b/packages/vscode-pv-handlebars-language-server/server/src/server.ts index c6c10be..b906629 100644 --- a/packages/vscode-pv-handlebars-language-server/server/src/server.ts +++ b/packages/vscode-pv-handlebars-language-server/server/src/server.ts @@ -18,6 +18,7 @@ import { hoverProvider } from "./hoverProvider"; import { getFilePath, isHandlebarsFile, isTypescriptFile } from "./helpers"; import { codelensProvider } from "./codelensProvider"; import { tsCompletionProvider } from "./tsCompletionProvider"; +import { tsDefinitionProvider } from "./tsDefinitionProvider"; // Create a connection for the server, using Node's IPC as a transport. // Also include all preview / proposed LSP features. @@ -86,45 +87,44 @@ connection.onDidChangeConfiguration(_change => { // This handler provides the initial list of the completion items. connection.onCompletion(async (textDocumentPosition: TextDocumentPositionParams): Promise => { - const document = documents.get(textDocumentPosition.textDocument.uri); - - if (!document) return null; + const document = documents.get(textDocumentPosition.textDocument.uri)!; 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) + else if (isTypescriptFile(filePath) && settings.provideUiSupportInTypescript) return tsCompletionProvider(document, textDocumentPosition.position, filePath); return null; }); connection.onHover(async ({ textDocument, position }) => { - const document = documents.get(textDocument.uri); + const document = documents.get(textDocument.uri)!; const settings = await SettingsService.getDocumentSettings(textDocument.uri); - if (document && settings.showHoverInfo && isHandlebarsFile(textDocument.uri)) return hoverProvider(document, position); + if (settings.showHoverInfo && isHandlebarsFile(textDocument.uri)) return hoverProvider(document, position); return null; }); -connection.onDefinition(({ textDocument, position }) => { - const document = documents.get(textDocument.uri); +connection.onDefinition(async ({ textDocument, position }) => { + const document = documents.get(textDocument.uri)!; + const settings = await SettingsService.getDocumentSettings(document.uri); + const filePath = getFilePath(document); - if (document) { - const filePath = getFilePath(document); - if (isHandlebarsFile(filePath)) return definitionProvider(document, position, filePath); - } + if (isHandlebarsFile(filePath)) return definitionProvider(document, position, filePath); + else if (isTypescriptFile(filePath) && settings.provideUiSupportInTypescript) + return tsDefinitionProvider(document, position, filePath); return null; }); connection.onCodeLens(async ({ textDocument }) => { - const document = documents.get(textDocument.uri); + const document = documents.get(textDocument.uri)!; const settings = await SettingsService.getDocumentSettings(textDocument.uri); - if (document && settings.showUIAndEvents && isHandlebarsFile(textDocument.uri)) return codelensProvider(document); + if (settings.showUiAndEvents && isHandlebarsFile(textDocument.uri)) return codelensProvider(document); }); // is called when the file is first opened and every time it is modified diff --git a/packages/vscode-pv-handlebars-language-server/server/src/tsDefinitionProvider.ts b/packages/vscode-pv-handlebars-language-server/server/src/tsDefinitionProvider.ts new file mode 100644 index 0000000..1c77461 --- /dev/null +++ b/packages/vscode-pv-handlebars-language-server/server/src/tsDefinitionProvider.ts @@ -0,0 +1,52 @@ +import { Location, Position } from "vscode-languageserver/node"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { URI } from "vscode-uri"; +import { isPVArchetype, getCurrentSymbolsName, getCssClasses, getPositionAt } from "./helpers"; +import rgx from "./rgx"; + +export async function tsDefinitionProvider( + document: TextDocument, + position: Position, + filePath: string, +): Promise { + // simple check if it is a component ala p!v archetype + if (!isPVArchetype(filePath)) return null; + + const offset = document.offsetAt(position); + const originalText = document.getText(); + const textBefore = originalText.slice(0, offset); + + const symbolName = getCurrentSymbolsName(document, position); + // `.some-selector` and not `uiProp` + const isCssClassSelector = /\.[a-zA-Z_-]+$/.test(textBefore); + + // `@uiElement("` and `@uiElements("` or `// @eventListener({ ... target: ".class` + if ( + rgx.ts.endsWithUiDecoratorSelector().test(textBefore) || + (rgx.ts.endsWithEventListenerDecoratorTarget().test(textBefore) && isCssClassSelector) + ) { + const cssClasses = await getCssClasses(filePath); + return cssClasses + .filter(cssClass => cssClass.className === symbolName) + .map(cssClass => Location.create(URI.file(cssClass.location.filePath).toString(), cssClass.location.range)); + } + // `@uiEvent("uiProp` or `@eventListener({ ... target: "uiProp` + else if ( + rgx.ts.endsWithEventDecoratorElementName().test(textBefore) || + (rgx.ts.endsWithEventListenerDecoratorTarget().test(textBefore) && !isCssClassSelector) + ) { + const uiMatch = originalText.match(new RegExp(`@uiElements?\\(.+?\\)\\s*${symbolName}`)); + if (uiMatch) { + const deco = uiMatch[0].match(/@uiElements?\(.+?\)\s*/)![0]; + const start = getPositionAt(originalText, uiMatch.index! + deco.length); + return Location.create(document.uri, { + start, + end: { + line: start.line, + character: start.character + symbolName.length, + }, + }); + } + } + return null; +}