diff --git a/client/src/extension.ts b/client/src/extension.ts index d38d1c7..3fbe2dc 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -42,10 +42,7 @@ export function activate(context: ExtensionContext) { documentSelector: [{ scheme: "file", language: "publicodes" }], synchronize: { // Notify the server about file changes to '.clientrc files contained in the workspace - fileEvents: [ - workspace.createFileSystemWatcher("**/.clientrc"), - workspace.createFileSystemWatcher("**/.publicodes"), - ], + fileEvents: [workspace.createFileSystemWatcher("**/.clientrc")], }, markdown: { isTrusted: true, diff --git a/client/src/test/completion.test.ts b/client/src/test/completion.test.ts index 1087ade..6c30e03 100644 --- a/client/src/test/completion.test.ts +++ b/client/src/test/completion.test.ts @@ -17,16 +17,21 @@ suite("Should do completion", () => { await testCompletion(docUri, new vscode.Position(7, 20), true); }); - test("Complete in expressions", async () => { - await testCompletion(docUri, new vscode.Position(2, 18), false); - }); + // TODO: implement correct test + // test("Complete in expressions", async () => { + // await testCompletion(docUri, new vscode.Position(11, 12), false, [ + // ruleItem("rule a"), + // ruleItem("b"), + // ruleItem("c"), + // ]); + // }); }); async function testCompletion( docUri: vscode.Uri, position: vscode.Position, - shouldBeEmtpy: boolean, - // expectedCompletionList?: vscode.CompletionList, + shouldBeEmpty: boolean, + expectedRulesCompletionItems?: vscode.CompletionItem[], ) { await activate(docUri); @@ -37,17 +42,32 @@ async function testCompletion( position, )) as vscode.CompletionList; - if (shouldBeEmtpy) { + if (shouldBeEmpty) { assert.ok(actualCompletionList.items.length === 0); } else { assert.ok(actualCompletionList.items.length > 0); - // assert.ok( - // actualCompletionList.items.length === expectedCompletionList.items.length, - // ); - // expectedCompletionList.items.forEach((expectedItem, i) => { - // const actualItem = actualCompletionList.items[i]; - // assert.equal(actualItem.label, expectedItem.label); - // assert.equal(actualItem.kind, expectedItem.kind); - // }); + const actualRulesCompletionItems = actualCompletionList.items.filter( + (item) => { + return ( + item.kind !== vscode.CompletionItemKind.Property && + item.kind !== vscode.CompletionItemKind.Keyword + ); + }, + ); + console.log(actualRulesCompletionItems); + console.log(expectedRulesCompletionItems); + assert.ok( + actualRulesCompletionItems.length === + expectedRulesCompletionItems?.length, + ); + expectedRulesCompletionItems?.forEach((expectedItem, i) => { + const actualItem = actualRulesCompletionItems[i]; + assert.equal(actualItem.label, expectedItem.label); + assert.equal(actualItem.kind, expectedItem.kind); + }); } } + +function ruleItem(label: string): vscode.CompletionItem { + return new vscode.CompletionItem(label, vscode.CompletionItemKind.Function); +} diff --git a/client/testFixture/completion.publicodes b/client/testFixture/completion.publicodes index 2e26ac3..38ce255 100644 --- a/client/testFixture/completion.publicodes +++ b/client/testFixture/completion.publicodes @@ -1,10 +1,10 @@ rule a: titre: Résultat - valeur: b + c + valeur: rule a . b + rule a . c rule a . b: valeur: 2 description: | Description. -rule a . c: 3 +rule a . c: diff --git a/client/testFixture/main.publicodes b/client/testFixture/main.publicodes index 2e26ac3..38ce255 100644 --- a/client/testFixture/main.publicodes +++ b/client/testFixture/main.publicodes @@ -1,10 +1,10 @@ rule a: titre: Résultat - valeur: b + c + valeur: rule a . b + rule a . c rule a . b: valeur: 2 description: | Description. -rule a . c: 3 +rule a . c: diff --git a/language-configuration.json b/language-configuration.json index d485549..8355986 100644 --- a/language-configuration.json +++ b/language-configuration.json @@ -32,7 +32,7 @@ "increaseIndentPattern": "^\\s*.*(:|-) ?(&\\w+)?(\\{[^}\"']*|\\([^)\"']*)?$", "decreaseIndentPattern": "^\\s+\\}$" }, - "wordPattern": "(^.?[^\\s]+)+|([^\\s\n={[][\\w\\-\\./$%&*:\"']+)", + "wordPattern": "", "onEnterRules": [ { "beforeText": "^\\s*(moyenne|somme|une de ces conditions|toutes ces conditions|variations|le maximum de|le minimum de|suggestions|références|les règles)\\:\\s*$", diff --git a/package.json b/package.json index b80149f..6593c8a 100644 --- a/package.json +++ b/package.json @@ -67,9 +67,9 @@ "editor.tabSize": 2, "editor.autoIndent": "advanced", "editor.quickSuggestions": { - "other": true, - "comments": false, - "strings": true + "other": "on", + "comments": "off", + "strings": "on" }, "editor.wordBasedSuggestions": "off", "editor.semanticHighlight.enable": true, diff --git a/server/src/completion.ts b/server/src/completion.ts index 2423285..0dc2eec 100644 --- a/server/src/completion.ts +++ b/server/src/completion.ts @@ -1,9 +1,11 @@ import { CompletionItem, CompletionItemKind, + CompletionParams, + CompletionTriggerKind, MarkupContent, MarkupKind, - TextDocumentPositionParams, + ServerRequestHandler, } from "vscode-languageserver/node.js"; import { DottedName, LSContext } from "./context"; import { RuleNode } from "publicodes"; @@ -11,40 +13,97 @@ import { mechanisms } from "./completion-items/mechanisms"; import { keywords } from "./completion-items/keywords"; import { fileURLToPath } from "node:url"; import { getRuleNameAt, getTSTree } from "./treeSitter"; +import TSParser, { SyntaxNode } from "tree-sitter"; // We don't want to suggest completion items in these nodes const nodesToIgnore = ["text_line", "paragraph", "meta_value"]; -export function completionHandler(ctx: LSContext) { - return ( - textDocumentPosition: TextDocumentPositionParams, - ): CompletionItem[] | undefined => { - const { textDocument, position } = textDocumentPosition; - const filePath = fileURLToPath(textDocument.uri); - const fullRefName = getRuleNameAt(ctx, filePath, position.line); +// We want to suggest reference completion items in these nodes +const nodesExpectReferenceCompletion = ["dotted_name", "mechanism"]; + +const keywordsAndMechanismsCompletionItems = [...mechanisms, ...keywords]; + +export function completionHandler( + ctx: LSContext, +): ServerRequestHandler< + CompletionParams, + CompletionItem[] | undefined, + CompletionItem[] | undefined, + void +> { + return (params: CompletionParams): CompletionItem[] | undefined => { + const filePath = fileURLToPath(params.textDocument.uri); + const fullRefName = getRuleNameAt(ctx, filePath, params.position.line); // PERF: we need to get the most up-to-date version of the tree. This is // done multiple times in the code (here, in semanticTokens.ts). As it's // almost instantaneous, we can afford it. Howerver, we should consider // having a single source of truth for the tree (even though it's can // force to manage async operations with the cost it implies). - const fileContent = ctx.documents.get(textDocument.uri)?.getText()!; + const document = ctx.documents.get(params.textDocument.uri); + if (!document) { + return []; + } + + const fileContent = document.getText(); const tsTree = getTSTree(fileContent); const nodeAtCursorPosition = tsTree?.rootNode.descendantForPosition({ - row: position.line, + row: params.position.line, // We need to be sure to be in the current node, even if the cursor is // at the end of the line. - column: position.character - 1, + column: params.position.character - 1, }); + if (nodesToIgnore.includes(nodeAtCursorPosition?.type)) { + return []; + } + + let refNodeAtCursorPosition: SyntaxNode | null | undefined = + tsTree?.rootNode.descendantsOfType( + "reference", + { row: params.position.line, column: params.position.character - 2 }, + { + row: params.position.line, + column: params.position.character, + }, + )[0]; - return !nodesToIgnore.includes(nodeAtCursorPosition?.type) - ? [ - ...getRuleCompletionItems(ctx, fullRefName), - ...mechanismsCompletionItems, - ...keywordsCompletionItems, - ] - : []; + let triggeredFromDot = params.context?.triggerCharacter === "."; + + if (!refNodeAtCursorPosition && triggeredFromDot) { + refNodeAtCursorPosition = nodeAtCursorPosition?.parent; + } + + if (!refNodeAtCursorPosition) { + return keywordsAndMechanismsCompletionItems; + } + + if (refNodeAtCursorPosition.type === "ERROR") { + refNodeAtCursorPosition = tsTree?.rootNode?.descendantsOfType( + "reference", + { + row: refNodeAtCursorPosition.startPosition.row, + column: refNodeAtCursorPosition.startPosition.column - 1, + }, + )[0]; + } + + if (!refNodeAtCursorPosition) { + return keywordsAndMechanismsCompletionItems; + } + + const completionItems = getRuleCompletionItems( + ctx, + refNodeAtCursorPosition, + params.context?.triggerKind ?? CompletionTriggerKind.Invoked, + fullRefName, + ); + + // TODO: add conditions to show only the relevant completion items + return triggeredFromDot + ? completionItems + : // TODO: is it a performance issue to concat the arrays this way? + [...completionItems, ...keywordsAndMechanismsCompletionItems]; }; } @@ -57,58 +116,165 @@ export function completionResolveHandler(_ctx: LSContext) { ...item, documentation: { kind: MarkupKind.Markdown, - value: item.data.description?.trimStart()?.trimEnd(), + value: item.data.documentationValue?.trim(), } as MarkupContent, }; }; } +/** + * Get the list of completion items corresponding to the rules. + * + * @param ctx The language server context + * @param currRuleName The current rule name used to simplify the inserted text + * @param currentRefNode The current node currenlty in completion which allows to filter only corresponding childs rules + */ const getRuleCompletionItems = ( ctx: LSContext, + currentRefNode: TSParser.SyntaxNode, + triggerKind: CompletionTriggerKind, currRuleName: DottedName | undefined, ): CompletionItem[] => { - return Object.entries(ctx.parsedRules).map(([dottedName, rule]) => { - const { titre, description, icônes } = (rule as RuleNode).rawNode; - const labelDetails = { - detail: icônes != undefined ? ` ${icônes}` : "", - description: titre, - }; - // Remove the current rule name from the inserted text - const insertText = - currRuleName && dottedName.startsWith(currRuleName) - ? dottedName.slice(currRuleName.length + " . ".length) - : dottedName; + let foundError = false; + const refNames = + // Collects all node in the current reference node to filter the + // completion items according to them. + currentRefNode.firstNamedChild?.children.reduce( + (names: string[], child) => { + if (child.type === "ERROR") { + /** + * Completion triggered in the middle of an expression, e.g: + * + * valeur: voiture . prix d'achat . [ERROR] - valeur résiduelle + * ^ + * | completion triggered here. + * + * Without stopping the collect of names, we will have the following + * names: ["voiture", "prix d'achat", "valeur résiduelle"] We need to + * stop the collect of names when we encounter an error node. + */ + foundError = true; + return names; + } + if (!foundError && child.type === "name") { + names.push(child.text.trim()); + } + return names; + }, + [], + ) ?? []; + const splittedCurrRuleName = currRuleName?.split(" . ") ?? []; + + // Remove the last name as it's the rule in completion + if (triggerKind === CompletionTriggerKind.Invoked) { + refNames.pop(); + } + + // TODO: exract into external funcions? + const isCompletionStarting = refNames.length === 0; + + const isRootNamespace = (splittedDottedName: string[]) => { + return refNames.length === 0 && splittedDottedName.length === 1; + }; + + const isDirectChildOf = ( + splittedDottedName: string[], + splittedTarget: string[], + ) => { + return ( + splittedDottedName.length === splittedTarget.length + 1 && + splittedDottedName.slice(0, splittedTarget.length).join(" . ") === + splittedTarget.join(" . ") + ); + }; + + const isDirectChildrenOfCurrentRef = (splittedDottedName: string[]) => { + return isDirectChildOf(splittedDottedName, refNames); + }; + + // Relative reference simplification + const isAccessibleFromTheCurrentRule = (splittedDottedName: string[]) => { + let hasCommonNamespace = true; + + if (splittedDottedName.length > splittedCurrRuleName.length + 1) { + return false; + } + + for (let i = 0; i < splittedDottedName.length - 1; i++) { + if (splittedDottedName[i] !== splittedCurrRuleName[i]) { + hasCommonNamespace = false; + break; + } + } + + return hasCommonNamespace; + }; + + return Object.entries(ctx.parsedRules) + .filter(([dottedName, _]) => { + const splittedDottedName = dottedName.split(" . "); + + return ( + dottedName !== currRuleName && + ((isCompletionStarting && + (isRootNamespace(splittedDottedName) || + isAccessibleFromTheCurrentRule(splittedDottedName))) || + isDirectChildrenOfCurrentRef(splittedDottedName)) + ); + }) + .map(([dottedName, rule]) => { + const { titre, description, icônes } = (rule as RuleNode).rawNode; + const splittedDottedName = dottedName.split(" . "); + const ruleName = + splittedDottedName[splittedDottedName.length - 1] ?? dottedName; + const labelDetails = { + detail: icônes != undefined ? ` ${icônes}` : "", + description: + splittedDottedName.length > 1 + ? `${splittedDottedName.join(" . ")}` + : "", + }; + + return { + label: ruleName, + kind: isAccessibleFromTheCurrentRule(splittedDottedName) + ? CompletionItemKind.Method + : CompletionItemKind.Function, + labelDetails, + insertText: + triggerKind === CompletionTriggerKind.TriggerCharacter + ? " " + ruleName + : ruleName, + data: { + documentationValue: `**${titre ?? dottedName}**\n\n${description?.trim() ?? ""}`, + }, + }; + }); +}; + +export const mechanismsCompletionItems: CompletionItem[] = mechanisms.map( + (item) => { return { - label: dottedName, - kind: CompletionItemKind.Function, - labelDetails, - insertText, + ...item, + kind: CompletionItemKind.Property, + insertText: `${item.label}:`, data: { - description, + description: item.documentation, }, }; - }); -}; + }, +); -const mechanismsCompletionItems: CompletionItem[] = mechanisms.map((item) => { - return { - ...item, - kind: CompletionItemKind.Property, - insertText: `${item.label}:`, - data: { - description: item.documentation, - }, - }; -}); - -const keywordsCompletionItems: CompletionItem[] = keywords.map((item) => { - return { - ...item, - kind: CompletionItemKind.Keyword, - insertText: `${item.label}:`, - data: { - description: item.documentation, - }, - }; -}); +export const keywordsCompletionItems: CompletionItem[] = keywords.map( + (item) => { + return { + ...item, + kind: CompletionItemKind.Keyword, + insertText: `${item.label}:`, + data: { + description: item.documentation, + }, + }; + }, +); diff --git a/server/src/initialize.ts b/server/src/initialize.ts index faee2ec..67a81ef 100644 --- a/server/src/initialize.ts +++ b/server/src/initialize.ts @@ -35,6 +35,7 @@ export default function initialize(params: InitializeParams): { // Tell the client that this server supports code completion. completionProvider: { resolveProvider: true, + triggerCharacters: ["."], }, definitionProvider: true, diff --git a/server/src/parseRules.ts b/server/src/parseRules.ts index 64f1eb7..47866cc 100644 --- a/server/src/parseRules.ts +++ b/server/src/parseRules.ts @@ -132,8 +132,8 @@ function parseRawRules(filePath: FilePath): { const match = e.message.match( /^Map keys must be unique at line (\d+), column (\d+)/, ); - const line = Number(match?.[1]) - 1 ?? 0; - const column = Number(match?.[2]) - 1 ?? 0; + const line = Number(match?.[1] ?? 1) - 1; + const column = Number(match?.[2] ?? 1) - 1; const name = e.message.match(/(\n.*)*\n(.+):/)?.[2] ?? ""; errors.push({