From 7eb2049c51d01387b2430b1a1cd885bb5ed86bd8 Mon Sep 17 00:00:00 2001 From: Melvin van Bree Date: Mon, 22 Dec 2025 14:48:52 +0100 Subject: [PATCH 01/12] Implemented better auto-completion for vars and functions --- index.ts | 17 +- src/language-service/index.ts | 2 +- .../language-service.documentation.ts | 253 +++++++++++--- .../language-service.models.ts | 57 ++++ src/language-service/language-service.ts | 310 +++++++++++++----- .../language-service.types.ts | 8 +- 6 files changed, 506 insertions(+), 141 deletions(-) create mode 100644 src/language-service/language-service.models.ts diff --git a/index.ts b/index.ts index 15f9a082..b02e33b4 100644 --- a/index.ts +++ b/index.ts @@ -11,7 +11,7 @@ import { Expression } from './src/core/expression.js'; import { Parser } from './src/parsing/parser.js'; -import { createLanguageService } from "./src/language-service"; +import { createLanguageService } from './src/language-service/index.js'; // Re-export types for public API export type { @@ -38,8 +38,13 @@ export { FunctionError } from './src/types/errors.js'; -export { - Expression, - Parser, - createLanguageService -}; +export type { + LanguageServiceApi, + HoverV2, + GetCompletionsParams, + GetHoverParams, + HighlightToken, + LanguageServiceOptions +} from "./src/language-service/index.js"; + +export { createLanguageService, Expression, Parser }; diff --git a/src/language-service/index.ts b/src/language-service/index.ts index 0a9c9ee9..8e246d47 100644 --- a/src/language-service/index.ts +++ b/src/language-service/index.ts @@ -3,5 +3,5 @@ * Provides intellisense details and type information for expression evaluation */ +export * from './language-service.types.js'; export * from './language-service.js'; -export type * from './language-service.types.js'; diff --git a/src/language-service/language-service.documentation.ts b/src/language-service/language-service.documentation.ts index 8437b422..b84657a1 100644 --- a/src/language-service/language-service.documentation.ts +++ b/src/language-service/language-service.documentation.ts @@ -1,48 +1,219 @@ // Built-in lightweight docs for known functions and keywords -export const BUILTIN_FUNCTION_DOCS: Record = { - random: 'random(n): Get a random number in the range [0, n). If n is zero or missing, defaults to 1.', - fac: 'fac(n): Factorial of n. Deprecated; prefer the ! operator.', - min: 'min(a, b, …): Smallest number in the list.', - max: 'max(a, b, …): Largest number in the list.', - hypot: 'hypot(a, b): Hypotenuse √(a² + b²).', - pyt: 'pyt(a, b): Alias for hypot(a, b).', - pow: 'pow(x, y): Equivalent to x^y.', - atan2: 'atan2(y, x): Arc tangent of x/y.', - roundTo: 'roundTo(x, n): Round x to n decimal places.', - map: 'map(f, a): Array map; returns [f(x,i) for x of a].', - fold: 'fold(f, y, a): Array reduce; y = f(y, x, i) for each x of a.', - filter: 'filter(f, a): Array filter.', - indexOf: 'indexOf(x, a): First index of x in a (array/string), -1 if not found.', - join: 'join(sep, a): Join array a with separator sep.', - if: 'if(c, a, b): c ? a : b (both branches evaluate).', - json: 'json(x): Returns JSON string for x.', - sum: 'sum(a): Sum of all elements in a.', - // String functions - stringLength: 'stringLength(str): Returns the length of a string.', - isEmpty: 'isEmpty(str): Returns true if the string is empty (length === 0).', - contains: 'contains(str, substring): Returns true if str contains substring.', - startsWith: 'startsWith(str, substring): Returns true if str starts with substring.', - endsWith: 'endsWith(str, substring): Returns true if str ends with substring.', - searchCount: 'searchCount(str, substring): Counts non-overlapping occurrences of substring in str.', - trim: 'trim(str): Removes whitespace from both ends of a string.', - toUpper: 'toUpper(str): Converts a string to uppercase.', - toLower: 'toLower(str): Converts a string to lowercase.', - toTitle: 'toTitle(str): Converts a string to title case (first letter of each word capitalized).', - split: 'split(str, delimiter): Splits a string by a delimiter, returns an array.', - repeat: 'repeat(str, n): Repeats a string n times.', - reverse: 'reverse(str): Reverses a string.', - left: 'left(str, n): Returns the leftmost n characters from a string.', - right: 'right(str, n): Returns the rightmost n characters from a string.', - replace: 'replace(str, old, new): Replaces all occurrences of old with new in str.', - replaceFirst: 'replaceFirst(str, old, new): Replaces the first occurrence of old with new in str.', - naturalSort: 'naturalSort(arr): Sorts an array of strings using natural sort order (alphanumeric aware).', - toNumber: 'toNumber(str): Converts a string to a number.', - toBoolean: 'toBoolean(str): Converts a string to a boolean (recognizes true/false, yes/no, on/off, 1/0).', - padLeft: 'padLeft(str, length, padStr?): Pads a string on the left to reach the target length.', - padRight: 'padRight(str, length, padStr?): Pads a string on the right to reach the target length.', +export interface FunctionParamDoc { + name: string; + description: string; + optional?: boolean; +} + +export interface FunctionDoc { + name: string; + description: string; + params?: FunctionParamDoc[]; + isVariadic?: boolean; +} + +export const BUILTIN_FUNCTION_DOCS: Record = { + random: { + name: 'random', + description: 'Get a random number in the range [0, n). Defaults to 1 if n is missing or zero.', + params: [ + {name: 'n', description: 'Upper bound (exclusive).', optional: true}, + ], + }, + fac: { + name: 'fac', + description: 'Factorial of n. Deprecated; prefer the ! operator.', + params: [ + {name: 'n', description: 'Non-negative integer.'}, + ], + }, + min: { + name: 'min', + description: 'Smallest number in the list.', + isVariadic: true, + params: [ + {name: 'values', description: 'Numbers to compare.'}, + ], + }, + max: { + name: 'max', + description: 'Largest number in the list.', + isVariadic: true, + params: [ + {name: 'values', description: 'Numbers to compare.'}, + ], + }, + hypot: { + name: 'hypot', + description: 'Hypotenuse √(a² + b²).', + params: [ + {name: 'a', description: 'First side.'}, + {name: 'b', description: 'Second side.'}, + ], + }, + pyt: { + name: 'pyt', + description: 'Alias for hypot(a, b).', + params: [ + {name: 'a', description: 'First side.'}, + {name: 'b', description: 'Second side.'}, + ], + }, + pow: { + name: 'pow', + description: 'Raise x to the power of y.', + params: [ + {name: 'x', description: 'Base.'}, + {name: 'y', description: 'Exponent.'}, + ], + }, + atan2: { + name: 'atan2', + description: 'Arc tangent of y / x.', + params: [ + {name: 'y', description: 'Y coordinate.'}, + {name: 'x', description: 'X coordinate.'}, + ], + }, + roundTo: { + name: 'roundTo', + description: 'Round x to n decimal places.', + params: [ + {name: 'x', description: 'Number to round.'}, + {name: 'n', description: 'Number of decimal places.'}, + ], + }, + map: { + name: 'map', + description: 'Apply function f to each element of array a.', + params: [ + {name: 'f', description: 'Mapping function (value, index).'}, + {name: 'a', description: 'Input array.'}, + ], + }, + fold: { + name: 'fold', + description: 'Reduce array a using function f, starting with accumulator y.', + params: [ + {name: 'f', description: 'Reducer function. Eg: `f(acc, x, i) = acc + x`.'}, + {name: 'y', description: 'Initial accumulator value.'}, + {name: 'a', description: 'Input array.'}, + ], + }, + filter: { + name: 'filter', + description: 'Filter array a using predicate f.', + params: [ + {name: 'f', description: 'Filter function. Eg:`f(x) = x % 2 == 0`'}, + {name: 'a', description: 'Input array.'}, + ], + }, + indexOf: { + name: 'indexOf', + description: 'First index of x in a (array or string), or -1 if not found.', + params: [ + {name: 'x', description: 'Value to search for.'}, + {name: 'a', description: 'Array or string to search.'}, + ], + }, + join: { + name: 'join', + description: 'Join array a using separator sep.', + params: [ + {name: 'sep', description: 'Separator string.'}, + {name: 'a', description: 'Array to join.'}, + ], + }, + if: { + name: 'if', + description: 'Conditional expression: condition ? trueValue : falseValue (both branches evaluate).', + params: [ + {name: 'condition', description: 'A boolean condition.'}, + {name: 'trueValue', description: 'Value if condition is true.'}, + {name: 'falseValue', description: 'Value if condition is false.'}, + ], + }, + json: { + name: 'json', + description: 'Return JSON string representation of x.', + params: [ + {name: 'x', description: 'Value to stringify.'}, + ], + }, + sum: { + name: 'sum', + description: 'Sum of all elements in an array.', + params: [ + {name: 'a', description: 'Array of numbers.'}, + ], + }, + /** + * String functions + */ + stringLength: { + name: 'stringLength', + description: 'Return the length of a string.', + params: [{name: 'str', description: 'Input string.'}], + }, + isEmpty: { + name: 'isEmpty', + description: 'Return true if the string is empty.', + params: [{name: 'str', description: 'Input string.'}], + }, + contains: { + name: 'contains', + description: 'Return true if str contains substring.', + params: [ + {name: 'str', description: 'Input string.'}, + {name: 'substring', description: 'Substring to search for.'}, + ], + }, + startsWith: { + name: 'startsWith', + description: 'Return true if str starts with substring.', + params: [ + {name: 'str', description: 'Input string.'}, + {name: 'substring', description: 'Prefix to check.'}, + ], + }, + endsWith: { + name: 'endsWith', + description: 'Return true if str ends with substring.', + params: [ + {name: 'str', description: 'Input string.'}, + {name: 'substring', description: 'Suffix to check.'}, + ], + }, + split: { + name: 'split', + description: 'Split string by delimiter into an array.', + params: [ + {name: 'str', description: 'Input string.'}, + {name: 'delimiter', description: 'Delimiter string.'}, + ], + }, + padLeft: { + name: 'padLeft', + description: 'Pad string on the left to reach target length.', + params: [ + {name: 'str', description: 'Input string.'}, + {name: 'length', description: 'Target length.'}, + {name: 'padStr', description: 'Padding string.', optional: true}, + ], + }, + padRight: { + name: 'padRight', + description: 'Pad string on the right to reach target length.', + params: [ + {name: 'str', description: 'Input string.'}, + {name: 'length', description: 'Target length.'}, + {name: 'padStr', description: 'Padding string.', optional: true}, + ], + }, }; + export const BUILTIN_KEYWORD_DOCS: Record = { undefined: 'Represents an undefined value.', case: 'Start of a case-when-then-else-end block.', diff --git a/src/language-service/language-service.models.ts b/src/language-service/language-service.models.ts new file mode 100644 index 00000000..ec104e66 --- /dev/null +++ b/src/language-service/language-service.models.ts @@ -0,0 +1,57 @@ +import {Parser} from "../parsing/parser"; +import {BUILTIN_FUNCTION_DOCS, FunctionDoc} from "./language-service.documentation"; + +export class FunctionDetails { + private readonly builtInFunctionDoc : FunctionDoc | undefined; + + constructor(private readonly parser: Parser, public readonly name: string) { + this.builtInFunctionDoc = BUILTIN_FUNCTION_DOCS[this.name] || undefined; + } + + private arity(){ + if(this.builtInFunctionDoc && !this.builtInFunctionDoc.isVariadic){ + return this.builtInFunctionDoc.params?.length; + } + + const f: unknown = (this.parser.functions && this.parser.functions[this.name]) || (this.parser.unaryOps && this.parser.unaryOps[this.name]); + return typeof f === 'function' ? f.length : undefined; + } + + public docs(){ + if(this.builtInFunctionDoc){ + const description = this.builtInFunctionDoc.description || ''; + + const params = this.builtInFunctionDoc.params || []; + + return`**${this.details()}**\n\n${description}\n\n*Parameters:*\n` + params.map((paramDoc) => `* \`${paramDoc.name}\`: ${paramDoc.description}`).join('\n'); + } + + // Provide a generic doc for unary operators if not documented + if (this.parser.unaryOps && this.parser.unaryOps[this.name]) { + return `${this.name} x: unary operator`; + } + + return undefined; + } + + public details(){ + if(this.builtInFunctionDoc && !this.builtInFunctionDoc.isVariadic){ + const name = this.builtInFunctionDoc.name || this.name; + const params = this.builtInFunctionDoc.params || []; + return `${name}(${params.map((paramDoc) => `${paramDoc.name}`).join(', ')})`; + } + + const arity = this.arity(); + return arity != null ? `${this.name}(${Array.from({length: arity}).map((_, i) => 'arg' + (i + 1)).join(', ')})` : `${this.name}(…)`; + } + + public completionText(){ + if(this.builtInFunctionDoc && !this.builtInFunctionDoc.isVariadic){ + const params = this.builtInFunctionDoc.params || []; + return `${this.name}(${params.map((paramDoc, i) => `\${${i+1}:${paramDoc.name}}`).join(', ')})`; + } + + const arity = this.arity(); + return arity != null ? `${this.name}(${Array.from({length: arity}).map((_, i) => `\${${i+1}}`).join(', ')})` : `${this.name}(…)`; + } +} diff --git a/src/language-service/language-service.ts b/src/language-service/language-service.ts index a1840cc3..b500e4b6 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -17,12 +17,56 @@ import { TokenStream } from '../parsing'; import {Parser} from '../parsing/parser'; -import type {Values, Value} from '../types'; -import type { HighlightToken, LanguageServiceOptions, GetCompletionsParams, GetHoverParams, LanguageServiceApi } from './language-service.types'; -import type { CompletionItem, Hover, Range } from 'vscode-languageserver-types' -import { CompletionItemKind, MarkupKind } from 'vscode-languageserver-types' +import {Values, Value, ValueObject} from '../types'; +import type { HighlightToken, LanguageServiceOptions, GetCompletionsParams, GetHoverParams, LanguageServiceApi, HoverV2 } from './language-service.types'; +import type {CompletionItem, Range, Position} from 'vscode-languageserver-types' +import { CompletionItemKind, MarkupKind, InsertTextFormat } from 'vscode-languageserver-types' import { TextDocument } from 'vscode-languageserver-textdocument' -import {BUILTIN_FUNCTION_DOCS, BUILTIN_KEYWORD_DOCS, DEFAULT_CONSTANT_DOCS} from './language-service.documentation'; +import { BUILTIN_KEYWORD_DOCS, DEFAULT_CONSTANT_DOCS} from './language-service.documentation'; +import { FunctionDetails } from "./language-service.models"; + +class VarTrieNode { + children: Record = {}; + value: Value | undefined = undefined; +} + +class VarTrie { + root: VarTrieNode = new VarTrieNode(); + + private static isValueObject(v: Value): v is ValueObject { + return v !== null && typeof v === 'object' && !Array.isArray(v); + } + + buildFromValues(vars: Values): void { + const walk = (obj: ValueObject, node: VarTrieNode) => { + for (const key of Object.keys(obj)) { + if (!node.children[key]) { + node.children[key] = new VarTrieNode(); + } + const child = node.children[key]; + child.value = obj[key]; + const val = obj[key]; + if (VarTrie.isValueObject(val)) { + walk(val, child); + } + } + }; + walk(vars as ValueObject, this.root); + } + + search(path: string[]): VarTrieNode | undefined { + let node: VarTrieNode | undefined = this.root; + for (const seg of path) { + node = node?.children[seg]; + if (!node) { + return undefined; + } + } + return node; + } +} + + function valueTypeName(value: Value): string { const t = typeof value; @@ -40,23 +84,60 @@ function valueTypeName(value: Value): string { } } -function isWordChar(ch: string): boolean { - return /[A-Za-z0-9_$]/.test(ch); -} +function pathVariableCompletions(vars: Values | undefined, prefix: string, rangePartial?: Range): CompletionItem[] { + if (!vars) { + return []; + } + + const trie = new VarTrie(); + trie.buildFromValues(vars); + + const lastDot = prefix.lastIndexOf('.'); + const endsWithDot = lastDot === prefix.length - 1; -function extractPrefix(text: string, position: number): { start: number; prefix: string } { - let i = Math.max(0, Math.min(position, text.length)); - // If the cursor is right after a word char, keep it included - if (i > 0 && !isWordChar(text[i]) && isWordChar(text[i - 1])) { - i = i - 1; + const baseParts = (endsWithDot + ? prefix.slice(0, -1) + : lastDot >= 0 ? prefix.slice(0, lastDot) : '' + ) + .split('.') + .filter(Boolean); + + const partial = endsWithDot ? '' : prefix.slice(lastDot + 1); + const lowerPartial = partial.toLowerCase(); + + const baseNode = trie.search(baseParts); + if (!baseNode) { + return []; } + + return Object.entries(baseNode.children) + .filter(([k]) => !partial || k.toLowerCase().startsWith(lowerPartial)) + .map(([key, child]) => ({ + label: (baseParts.length ? baseParts.concat(key) : [key]).join('.'), + kind: CompletionItemKind.Variable, + detail: child.value !== undefined ? valueTypeName(child.value) : 'object', + insertText: key, + textEdit: rangePartial ? { range: rangePartial, newText: key } : undefined, + documentation: undefined, + })); +} + +function isPathChar(ch: string): boolean { + return /[A-Za-z0-9_$.]/.test(ch); +} + +function extractPathPrefix(text: string, position: number): { start: number; prefix: string } { + const i = Math.max(0, Math.min(position, text.length)); let start = i; - while (start > 0 && isWordChar(text[start - 1])) { + + while (start > 0 && isPathChar(text[start - 1])) { start--; } - return {start, prefix: text.slice(start, position)}; + + return { start, prefix: text.slice(start, i) }; } + function makeTokenStream(parser: Parser, text: string): TokenStream { return new TokenStream(parser, text); } @@ -68,7 +149,7 @@ function iterateTokens(ts: TokenStream, untilPos?: number): { token: Token; star if (t.type === TEOF) { break; } - const start = (t as any).index as number; + const start = t.index; const end = ts.pos; // pos advanced to end of current token in TokenStream spans.push({token: t, start, end}); if (untilPos != null && end >= untilPos) { @@ -79,24 +160,56 @@ function iterateTokens(ts: TokenStream, untilPos?: number): { token: Token; star return spans; } +function toTruncatedJsonString( + value: unknown, + maxLines = 3, + maxWidth = 50, +): string { + let text: string; + + try { + text = JSON.stringify(value, null, 2); + } catch { + return ''; + } + + if (!text) { + return ''; + } + + let lines: string[] = []; + + for(let i = 0, lineAmount = 0; i < text.length && lineAmount < maxLines; i += maxWidth, lineAmount++) { + lines.push(text.slice(i, i + maxWidth)); + } + + const maxChars = maxLines * maxWidth; + const exceededMaxLength = text.length > maxChars; + return exceededMaxLength ? lines.join('\n\n') + '...' : lines.join('\n\n'); +} + + export function createLanguageService(options: LanguageServiceOptions | undefined = undefined): LanguageServiceApi { // Build a parser instance to access keywords/operators/functions/consts const parser = new Parser({ operators: options?.operators, }); - const functionDocs = {...BUILTIN_FUNCTION_DOCS}; const constantDocs = { ...DEFAULT_CONSTANT_DOCS, } as Record; - function allFunctions(): string[] { + function allFunctions(): FunctionDetails[] { // Parser exposes built-in functions on parser.functions const definedFunctions = parser.functions ? Object.keys(parser.functions) : []; // Unary operators can also be used like functions with parens: sin(x), abs(x), ... const unary = parser.unaryOps ? Object.keys(parser.unaryOps) : []; // Merge, prefer functions map descriptions where available - return Array.from(new Set([...definedFunctions, ...unary])); + const rawFunctions = Array.from(new Set([...definedFunctions, ...unary])); + + + + return rawFunctions.map(name => new FunctionDetails(parser, name)); } function allConstants(): string[] { @@ -123,7 +236,7 @@ export function createLanguageService(options: LanguageServiceOptions | undefine default: { // If not matches, check if it's a function or an identifier const functions = allFunctions(); - if (t.type === TNAME && functions.includes(String(t.value))) { + if (t.type === TNAME && functions.find((f: FunctionDetails) => f.name == String(t.value))) { return 'function'; } @@ -132,62 +245,34 @@ export function createLanguageService(options: LanguageServiceOptions | undefine } } - function buildFunctionDetail(name: string): string { - // Attempt to infer arity from the actual function length if present - const f: any = (parser.functions && parser.functions[name]) || (parser.unaryOps && parser.unaryOps[name]); - const arity = typeof f === 'function' ? f.length : undefined; - return arity != null ? `${name}(${Array.from({length: arity}).map((_, i) => 'arg' + (i + 1)).join(', ')})` : `${name}(…)`; - } - - function buildFunctionDoc(name: string): string | undefined { - const doc = functionDocs[name]; - if (doc) { - return doc; - } - // Provide a generic doc for unary operators if not documented - if (parser.unaryOps && parser.unaryOps[name]) { - return `${name} x: unary operator`; - } - return undefined; - } - - function variableCompletions(vars?: Values): CompletionItem[] { - if (!vars) { - return []; - } - return Object.keys(vars).map(k => ({ - label: k, - kind: CompletionItemKind.Variable, - detail: valueTypeName(vars[k]), - documentation: undefined - })); - } - - function functionCompletions(): CompletionItem[] { - return allFunctions().map(name => ({ - label: name, + function functionCompletions(rangeFull: Range): CompletionItem[] { + return allFunctions().map(func => ({ + label: func.name, kind: CompletionItemKind.Function, - detail: buildFunctionDetail(name), - documentation: buildFunctionDoc(name), - insertText: `${name}()` + detail: func.details(), + documentation: func.docs(), + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range: rangeFull, newText: func.completionText() }, })); } - function constantCompletions(): CompletionItem[] { + function constantCompletions(rangeFull: Range): CompletionItem[] { return allConstants().map(name => ({ label: name, kind: CompletionItemKind.Constant, detail: valueTypeName(parser.consts[name]), - documentation: constantDocs[name] + documentation: constantDocs[name], + textEdit: { range: rangeFull, newText: name }, })); } - function keywordCompletions(): CompletionItem[] { + function keywordCompletions(rangeFull: Range): CompletionItem[] { return (parser.keywords || []).map(keyword => ({ label: keyword, kind: CompletionItemKind.Keyword, detail: 'keyword', - documentation: BUILTIN_KEYWORD_DOCS[keyword] + documentation: BUILTIN_KEYWORD_DOCS[keyword], + textEdit: { range: rangeFull, newText: keyword }, })); } @@ -202,57 +287,98 @@ export function createLanguageService(options: LanguageServiceOptions | undefine function getCompletions(params: GetCompletionsParams): CompletionItem[] { const { textDocument, variables, position } = params; const text = textDocument.getText(); - const pos = textDocument.offsetAt(position); + const offsetPosition = textDocument.offsetAt(position); - const {start, prefix} = extractPrefix(text, pos); + const {start, prefix} = extractPathPrefix(text, offsetPosition); - // Very light context: if immediately after a dot, do not suggest globals (future work could inspect previous name) - if (start > 0 && text[start - 1] === '.') { - return []; // object member completions out of scope for now - } + // Build ranges for replacement + const rangeFull: Range = { start: textDocument.positionAt(start), end: position }; + const lastDot = prefix.lastIndexOf('.'); + const partial = lastDot >= 0 ? prefix.slice(lastDot + 1) : prefix; + const replaceStartOffset = + start + (prefix.length - partial.length); + const rangePartial: Range = { + start: textDocument.positionAt(replaceStartOffset), + end: position + }; const all: CompletionItem[] = [ - ...functionCompletions(), - ...constantCompletions(), - ...keywordCompletions(), - ...variableCompletions(variables) + ...functionCompletions(rangeFull), + ...constantCompletions(rangeFull), + ...keywordCompletions(rangeFull), + ...pathVariableCompletions(variables, prefix, rangePartial), ]; - return filterByPrefix(all, prefix); + return prefix.includes('.') ? all : filterByPrefix(all, prefix); } - function getHover(params: GetHoverParams): Hover { + function tryVariableHover(textDocument: TextDocument, position: Position, variables?: Values): HoverV2 | undefined { + // If we don't have variables, we can't provide a hover for a variable path + if (!variables) { + return undefined; + } + const text = textDocument.getText(); + const offset = textDocument.offsetAt(position); + // Find start of the path (walk backwards over path chars) + const { start } = extractPathPrefix(text, offset); + // Find end of the path (walk forwards over path chars) + let end = offset; + while (end < text.length && isPathChar(text[end])) { + end++; + } + if (end <= start) { + return undefined; + } + + const fullPath = text.slice(start, end); + const parts = fullPath.split('.').filter(Boolean); + if (parts.length === 0) { + return undefined; + } + + const trie = new VarTrie(); + trie.buildFromValues(variables); + const node = trie.search(parts); + if (!node) { + return undefined; + } + + const range: Range = { start: textDocument.positionAt(start), end: textDocument.positionAt(end) }; + const nodeValue = node.value; + return { + contents: { kind: MarkupKind.Markdown, value: `${fullPath}: ${nodeValue !== undefined ? `Variable (${valueTypeName(nodeValue)})` : 'object'}\n\n**Value Preview**\n\n${toTruncatedJsonString(nodeValue)}` }, + range + }; + } + + function getHover(params: GetHoverParams): HoverV2 { const { textDocument, position, variables } = params; const text = textDocument.getText(); + + const variableHover = tryVariableHover(textDocument, position, variables); + if (variableHover) { + return variableHover; + } + + // Fallback to token-based hover const ts = makeTokenStream(parser, text); const spans = iterateTokens(ts); const offset = textDocument.offsetAt(position); const span = spans.find(s => offset >= s.start && offset <= s.end); if (!span) { - return {contents: ''}; + return {contents: { kind: "plaintext", value: '' }}; } const token = span.token; const label = String(token.value); if (token.type === TNAME || token.type === TKEYWORD) { - // Variable hover - if (variables && Object.prototype.hasOwnProperty.call(variables, label)) { - const variable = variables[label]; - const range: Range = { start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end) }; - return { - contents: { kind: MarkupKind.PlainText, value: `${label}: ${valueTypeName(variable)}` }, - range - }; - } - // Function hover - if (allFunctions().includes(label)) { - const detail = buildFunctionDetail(label); - const doc = buildFunctionDoc(label); + const func = allFunctions().find(f => f.name === label); + if (func) { const range: Range = { start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end) }; - const value = doc ? `**${detail}**\n\n${doc}` : detail; + const value = func.docs() ?? func.details(); return { contents: { kind: MarkupKind.Markdown, value }, range @@ -287,10 +413,10 @@ export function createLanguageService(options: LanguageServiceOptions | undefine // Numbers/strings if (token.type === TNUMBER || token.type === TSTRING) { const range: Range = { start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end) }; - return {contents: { kind: MarkupKind.PlainText, value: `${valueTypeName(token.value as any)}` }, range}; + return {contents: { kind: MarkupKind.PlainText, value: `${valueTypeName(token.value)}` }, range}; } - return {contents: ''}; + return { contents: { kind: "plaintext", value: '' }}; } function getHighlighting(textDocument: TextDocument): HighlightToken[] { @@ -301,7 +427,7 @@ export function createLanguageService(options: LanguageServiceOptions | undefine type: tokenKindToHighlight(span.token), start: span.start, end: span.end, - value: span.token.value as any + value: span.token.value })); } @@ -310,4 +436,6 @@ export function createLanguageService(options: LanguageServiceOptions | undefine getHover, getHighlighting }; + + } diff --git a/src/language-service/language-service.types.ts b/src/language-service/language-service.types.ts index 29d017d9..576a9c3d 100644 --- a/src/language-service/language-service.types.ts +++ b/src/language-service/language-service.types.ts @@ -1,5 +1,5 @@ import type { Values } from '../types'; -import type { Position, Hover, CompletionItem } from 'vscode-languageserver-types'; +import type { Position, Hover, CompletionItem, MarkupContent } from 'vscode-languageserver-types'; import type { TextDocument } from 'vscode-languageserver-textdocument'; /** @@ -16,7 +16,7 @@ export interface LanguageServiceApi { * Returns a hover message for the given position in the document. * @param params - Parameters for the hover request */ - getHover(params: GetHoverParams): Hover; + getHover(params: GetHoverParams): HoverV2; /** * Returns a list of syntax highlighting tokens for the given text document. @@ -50,3 +50,7 @@ export interface GetHoverParams { position: Position; variables?: Values; } + +export interface HoverV2 extends Hover { + contents: MarkupContent; +} From a6f3535577c4eb849ecbe721f72c36f2f8437356 Mon Sep 17 00:00:00 2001 From: Melvin van Bree Date: Mon, 22 Dec 2025 15:08:57 +0100 Subject: [PATCH 02/12] Re-organized and fixed existing tests --- src/language-service/completions.variables.ts | 35 +++ src/language-service/language-service.ts | 252 ++++-------------- src/language-service/ls-utils.ts | 73 +++++ src/language-service/var-trie.ts | 40 +++ test/language-service/language-service.ts | 20 +- 5 files changed, 222 insertions(+), 198 deletions(-) create mode 100644 src/language-service/completions.variables.ts create mode 100644 src/language-service/ls-utils.ts create mode 100644 src/language-service/var-trie.ts diff --git a/src/language-service/completions.variables.ts b/src/language-service/completions.variables.ts new file mode 100644 index 00000000..0b571513 --- /dev/null +++ b/src/language-service/completions.variables.ts @@ -0,0 +1,35 @@ +import { Values } from '../types'; +import { CompletionItem, CompletionItemKind, Range } from 'vscode-languageserver-types'; +import { VarTrie } from './var-trie'; +import { valueTypeName } from './ls-utils'; + +export function pathVariableCompletions(vars: Values | undefined, prefix: string, rangePartial?: Range): CompletionItem[] { + if (!vars) return []; + + const trie = new VarTrie(); + trie.buildFromValues(vars as Record); + + const lastDot = prefix.lastIndexOf('.'); + const endsWithDot = lastDot === prefix.length - 1; + + const baseParts = (endsWithDot ? prefix.slice(0, -1) : lastDot >= 0 ? prefix.slice(0, lastDot) : '') + .split('.') + .filter(Boolean); + + const partial = endsWithDot ? '' : prefix.slice(lastDot + 1); + const lowerPartial = partial.toLowerCase(); + + const baseNode = trie.search(baseParts); + if (!baseNode) return []; + + return Object.entries(baseNode.children) + .filter(([k]) => !partial || k.toLowerCase().startsWith(lowerPartial)) + .map(([key, child]) => ({ + label: (baseParts.length ? baseParts.concat(key) : [key]).join('.'), + kind: CompletionItemKind.Variable, + detail: child.value !== undefined ? valueTypeName(child.value) : 'object', + insertText: key, + textEdit: rangePartial ? { range: rangePartial, newText: key } : undefined, + documentation: undefined, + } as CompletionItem)); +} diff --git a/src/language-service/language-service.ts b/src/language-service/language-service.ts index b500e4b6..df46a867 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -14,180 +14,32 @@ import { TKEYWORD, TBRACE, Token, - TokenStream } from '../parsing'; import {Parser} from '../parsing/parser'; -import {Values, Value, ValueObject} from '../types'; -import type { HighlightToken, LanguageServiceOptions, GetCompletionsParams, GetHoverParams, LanguageServiceApi, HoverV2 } from './language-service.types'; +import {Values} from '../types'; +import type { + HighlightToken, + LanguageServiceOptions, + GetCompletionsParams, + GetHoverParams, + LanguageServiceApi, + HoverV2 +} from './language-service.types'; import type {CompletionItem, Range, Position} from 'vscode-languageserver-types' -import { CompletionItemKind, MarkupKind, InsertTextFormat } from 'vscode-languageserver-types' -import { TextDocument } from 'vscode-languageserver-textdocument' -import { BUILTIN_KEYWORD_DOCS, DEFAULT_CONSTANT_DOCS} from './language-service.documentation'; -import { FunctionDetails } from "./language-service.models"; - -class VarTrieNode { - children: Record = {}; - value: Value | undefined = undefined; -} - -class VarTrie { - root: VarTrieNode = new VarTrieNode(); - - private static isValueObject(v: Value): v is ValueObject { - return v !== null && typeof v === 'object' && !Array.isArray(v); - } - - buildFromValues(vars: Values): void { - const walk = (obj: ValueObject, node: VarTrieNode) => { - for (const key of Object.keys(obj)) { - if (!node.children[key]) { - node.children[key] = new VarTrieNode(); - } - const child = node.children[key]; - child.value = obj[key]; - const val = obj[key]; - if (VarTrie.isValueObject(val)) { - walk(val, child); - } - } - }; - walk(vars as ValueObject, this.root); - } - - search(path: string[]): VarTrieNode | undefined { - let node: VarTrieNode | undefined = this.root; - for (const seg of path) { - node = node?.children[seg]; - if (!node) { - return undefined; - } - } - return node; - } -} - - - -function valueTypeName(value: Value): string { - const t = typeof value; - switch (true) { - case value === null: - return 'null'; - case Array.isArray(value): - return 'array'; - case t === 'function': - return 'function'; - case t === 'object': - return 'object'; - default: - return t; // number, string, boolean, undefined - } -} - -function pathVariableCompletions(vars: Values | undefined, prefix: string, rangePartial?: Range): CompletionItem[] { - if (!vars) { - return []; - } - - const trie = new VarTrie(); - trie.buildFromValues(vars); - - const lastDot = prefix.lastIndexOf('.'); - const endsWithDot = lastDot === prefix.length - 1; - - const baseParts = (endsWithDot - ? prefix.slice(0, -1) - : lastDot >= 0 ? prefix.slice(0, lastDot) : '' - ) - .split('.') - .filter(Boolean); - - const partial = endsWithDot ? '' : prefix.slice(lastDot + 1); - const lowerPartial = partial.toLowerCase(); - - const baseNode = trie.search(baseParts); - if (!baseNode) { - return []; - } - - return Object.entries(baseNode.children) - .filter(([k]) => !partial || k.toLowerCase().startsWith(lowerPartial)) - .map(([key, child]) => ({ - label: (baseParts.length ? baseParts.concat(key) : [key]).join('.'), - kind: CompletionItemKind.Variable, - detail: child.value !== undefined ? valueTypeName(child.value) : 'object', - insertText: key, - textEdit: rangePartial ? { range: rangePartial, newText: key } : undefined, - documentation: undefined, - })); -} - -function isPathChar(ch: string): boolean { - return /[A-Za-z0-9_$.]/.test(ch); -} - -function extractPathPrefix(text: string, position: number): { start: number; prefix: string } { - const i = Math.max(0, Math.min(position, text.length)); - let start = i; - - while (start > 0 && isPathChar(text[start - 1])) { - start--; - } - - return { start, prefix: text.slice(start, i) }; -} - - -function makeTokenStream(parser: Parser, text: string): TokenStream { - return new TokenStream(parser, text); -} - -function iterateTokens(ts: TokenStream, untilPos?: number): { token: Token; start: number; end: number }[] { - const spans: { token: Token; start: number; end: number }[] = []; - while (true) { - const t = ts.next(); - if (t.type === TEOF) { - break; - } - const start = t.index; - const end = ts.pos; // pos advanced to end of current token in TokenStream - spans.push({token: t, start, end}); - if (untilPos != null && end >= untilPos) { - // We can stop early if we reached the position - break; - } - } - return spans; -} - -function toTruncatedJsonString( - value: unknown, - maxLines = 3, - maxWidth = 50, -): string { - let text: string; - - try { - text = JSON.stringify(value, null, 2); - } catch { - return ''; - } - - if (!text) { - return ''; - } - - let lines: string[] = []; - - for(let i = 0, lineAmount = 0; i < text.length && lineAmount < maxLines; i += maxWidth, lineAmount++) { - lines.push(text.slice(i, i + maxWidth)); - } - - const maxChars = maxLines * maxWidth; - const exceededMaxLength = text.length > maxChars; - return exceededMaxLength ? lines.join('\n\n') + '...' : lines.join('\n\n'); -} - +import {CompletionItemKind, MarkupKind, InsertTextFormat} from 'vscode-languageserver-types' +import {TextDocument} from 'vscode-languageserver-textdocument' +import {BUILTIN_KEYWORD_DOCS, DEFAULT_CONSTANT_DOCS} from './language-service.documentation'; +import {FunctionDetails} from "./language-service.models"; +import {VarTrie} from './var-trie'; +import { + valueTypeName, + isPathChar, + extractPathPrefix, + toTruncatedJsonString, + makeTokenStream, + iterateTokens +} from './ls-utils'; +import {pathVariableCompletions} from './completions.variables'; export function createLanguageService(options: LanguageServiceOptions | undefined = undefined): LanguageServiceApi { // Build a parser instance to access keywords/operators/functions/consts @@ -208,7 +60,6 @@ export function createLanguageService(options: LanguageServiceOptions | undefine const rawFunctions = Array.from(new Set([...definedFunctions, ...unary])); - return rawFunctions.map(name => new FunctionDetails(parser, name)); } @@ -252,7 +103,7 @@ export function createLanguageService(options: LanguageServiceOptions | undefine detail: func.details(), documentation: func.docs(), insertTextFormat: InsertTextFormat.Snippet, - textEdit: { range: rangeFull, newText: func.completionText() }, + textEdit: {range: rangeFull, newText: func.completionText()}, })); } @@ -262,7 +113,7 @@ export function createLanguageService(options: LanguageServiceOptions | undefine kind: CompletionItemKind.Constant, detail: valueTypeName(parser.consts[name]), documentation: constantDocs[name], - textEdit: { range: rangeFull, newText: name }, + textEdit: {range: rangeFull, newText: name}, })); } @@ -272,7 +123,7 @@ export function createLanguageService(options: LanguageServiceOptions | undefine kind: CompletionItemKind.Keyword, detail: 'keyword', documentation: BUILTIN_KEYWORD_DOCS[keyword], - textEdit: { range: rangeFull, newText: keyword }, + textEdit: {range: rangeFull, newText: keyword}, })); } @@ -285,14 +136,14 @@ export function createLanguageService(options: LanguageServiceOptions | undefine } function getCompletions(params: GetCompletionsParams): CompletionItem[] { - const { textDocument, variables, position } = params; + const {textDocument, variables, position} = params; const text = textDocument.getText(); const offsetPosition = textDocument.offsetAt(position); const {start, prefix} = extractPathPrefix(text, offsetPosition); // Build ranges for replacement - const rangeFull: Range = { start: textDocument.positionAt(start), end: position }; + const rangeFull: Range = {start: textDocument.positionAt(start), end: position}; const lastDot = prefix.lastIndexOf('.'); const partial = lastDot >= 0 ? prefix.slice(lastDot + 1) : prefix; const replaceStartOffset = @@ -320,7 +171,7 @@ export function createLanguageService(options: LanguageServiceOptions | undefine const text = textDocument.getText(); const offset = textDocument.offsetAt(position); // Find start of the path (walk backwards over path chars) - const { start } = extractPathPrefix(text, offset); + const {start} = extractPathPrefix(text, offset); // Find end of the path (walk forwards over path chars) let end = offset; while (end < text.length && isPathChar(text[end])) { @@ -343,16 +194,19 @@ export function createLanguageService(options: LanguageServiceOptions | undefine return undefined; } - const range: Range = { start: textDocument.positionAt(start), end: textDocument.positionAt(end) }; + const range: Range = {start: textDocument.positionAt(start), end: textDocument.positionAt(end)}; const nodeValue = node.value; return { - contents: { kind: MarkupKind.Markdown, value: `${fullPath}: ${nodeValue !== undefined ? `Variable (${valueTypeName(nodeValue)})` : 'object'}\n\n**Value Preview**\n\n${toTruncatedJsonString(nodeValue)}` }, + contents: { + kind: MarkupKind.Markdown, + value: `${fullPath}: ${nodeValue !== undefined ? `Variable (${valueTypeName(nodeValue)})` : 'object'}\n\n**Value Preview**\n\n${toTruncatedJsonString(nodeValue)}` + }, range }; } function getHover(params: GetHoverParams): HoverV2 { - const { textDocument, position, variables } = params; + const {textDocument, position, variables} = params; const text = textDocument.getText(); const variableHover = tryVariableHover(textDocument, position, variables); @@ -367,7 +221,7 @@ export function createLanguageService(options: LanguageServiceOptions | undefine const offset = textDocument.offsetAt(position); const span = spans.find(s => offset >= s.start && offset <= s.end); if (!span) { - return {contents: { kind: "plaintext", value: '' }}; + return {contents: {kind: "plaintext", value: ''}}; } const token = span.token; @@ -377,10 +231,13 @@ export function createLanguageService(options: LanguageServiceOptions | undefine // Function hover const func = allFunctions().find(f => f.name === label); if (func) { - const range: Range = { start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end) }; + const range: Range = { + start: textDocument.positionAt(span.start), + end: textDocument.positionAt(span.end) + }; const value = func.docs() ?? func.details(); return { - contents: { kind: MarkupKind.Markdown, value }, + contents: {kind: MarkupKind.Markdown, value}, range }; } @@ -389,9 +246,15 @@ export function createLanguageService(options: LanguageServiceOptions | undefine if (allConstants().includes(label)) { const v = parser.consts[label]; const doc = constantDocs[label]; - const range: Range = { start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end) }; + const range: Range = { + start: textDocument.positionAt(span.start), + end: textDocument.positionAt(span.end) + }; return { - contents: { kind: MarkupKind.PlainText, value: `${label}: ${valueTypeName(v)}${doc ? `\n\n${doc}` : ''}` }, + contents: { + kind: MarkupKind.PlainText, + value: `${label}: ${valueTypeName(v)}${doc ? `\n\n${doc}` : ''}` + }, range }; } @@ -399,24 +262,27 @@ export function createLanguageService(options: LanguageServiceOptions | undefine // Keyword hover if (token.type === TKEYWORD) { const doc = BUILTIN_KEYWORD_DOCS[label]; - const range: Range = { start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end) }; - return { contents: { kind: MarkupKind.PlainText, value: doc || 'keyword' }, range }; + const range: Range = { + start: textDocument.positionAt(span.start), + end: textDocument.positionAt(span.end) + }; + return {contents: {kind: MarkupKind.PlainText, value: doc || 'keyword'}, range}; } } // Operators: show a simple label if (token.type === TOP) { - const range: Range = { start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end) }; - return {contents: { kind: MarkupKind.PlainText, value: `operator: ${label}` }, range}; + const range: Range = {start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end)}; + return {contents: {kind: MarkupKind.PlainText, value: `operator: ${label}`}, range}; } // Numbers/strings if (token.type === TNUMBER || token.type === TSTRING) { - const range: Range = { start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end) }; - return {contents: { kind: MarkupKind.PlainText, value: `${valueTypeName(token.value)}` }, range}; + const range: Range = {start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end)}; + return {contents: {kind: MarkupKind.PlainText, value: `${valueTypeName(token.value)}`}, range}; } - return { contents: { kind: "plaintext", value: '' }}; + return {contents: {kind: "plaintext", value: ''}}; } function getHighlighting(textDocument: TextDocument): HighlightToken[] { diff --git a/src/language-service/ls-utils.ts b/src/language-service/ls-utils.ts new file mode 100644 index 00000000..d3ff5937 --- /dev/null +++ b/src/language-service/ls-utils.ts @@ -0,0 +1,73 @@ +import { Value } from '../types'; +import {Parser} from "../parsing/parser"; +import {TEOF, Token, TokenStream} from "../parsing"; + +export function valueTypeName(value: Value): string { + const t = typeof value; + switch (true) { + case value === null: + return 'null'; + case Array.isArray(value): + return 'array'; + case t === 'function': + return 'function'; + case t === 'object': + return 'object'; + default: + return t as string; + } +} + +export function isPathChar(ch: string): boolean { + return /[A-Za-z0-9_$.]/.test(ch); +} + +export function extractPathPrefix(text: string, position: number): { start: number; prefix: string } { + const i = Math.max(0, Math.min(position, text.length)); + let start = i; + while (start > 0 && isPathChar(text[start - 1])) { + start--; + } + return { start, prefix: text.slice(start, i) }; +} + +export function toTruncatedJsonString(value: unknown, maxLines = 3, maxWidth = 50): string { + let text: string; + try { + text = JSON.stringify(value, null, 2) as string; + } catch { + return ''; + } + if (!text) { + return ''; + } + const lines: string[] = []; + for (let i = 0, lineAmount = 0; i < text.length && lineAmount < maxLines; i += maxWidth, lineAmount++) { + lines.push(text.slice(i, i + maxWidth)); + } + const maxChars = maxLines * maxWidth; + const exceededMaxLength = text.length > maxChars; + return exceededMaxLength ? lines.join('\n\n') + '...' : lines.join('\n\n'); +} + +export function makeTokenStream(parser: Parser, text: string): TokenStream { + return new TokenStream(parser, text); +} + +export function iterateTokens(ts: TokenStream, untilPos?: number): { token: Token; start: number; end: number }[] { + const spans: { token: Token; start: number; end: number }[] = []; + while (true) { + const t = ts.next(); + if (t.type === TEOF) { + break; + } + const start = t.index; + const end = ts.pos; // pos advanced to end of current token in TokenStream + spans.push({token: t, start, end}); + if (untilPos != null && end >= untilPos) { + // We can stop early if we reached the position + break; + } + } + return spans; +} diff --git a/src/language-service/var-trie.ts b/src/language-service/var-trie.ts new file mode 100644 index 00000000..590c4756 --- /dev/null +++ b/src/language-service/var-trie.ts @@ -0,0 +1,40 @@ +import { Value, ValueObject } from '../types'; + +export class VarTrieNode { + children: Record = {}; + value: Value | undefined = undefined; +} + +export class VarTrie { + root: VarTrieNode = new VarTrieNode(); + + private static isValueObject(v: Value): v is ValueObject { + return v !== null && typeof v === 'object' && !Array.isArray(v); + } + + buildFromValues(vars: Record): void { + const walk = (obj: Record, node: VarTrieNode) => { + for (const key of Object.keys(obj)) { + if (!node.children[key]) { + node.children[key] = new VarTrieNode(); + } + const child = node.children[key]; + const val = (obj as any)[key]; + child.value = val as Value; + if (VarTrie.isValueObject(val as Value)) { + walk(val as Record, child); + } + } + }; + walk(vars as Record, this.root); + } + + search(path: string[]): VarTrieNode | undefined { + let node: VarTrieNode | undefined = this.root; + for (const seg of path) { + node = node?.children[seg]; + if (!node) return undefined; + } + return node; + } +} diff --git a/test/language-service/language-service.ts b/test/language-service/language-service.ts index 88190471..33396c0c 100644 --- a/test/language-service/language-service.ts +++ b/test/language-service/language-service.ts @@ -121,7 +121,7 @@ describe('Language Service', () => { expect(labels).toContain('case'); }); - it('should return empty when after a dot', () => { + it('should suggest children after a dot', () => { const text = 'foo.'; const doc = TextDocument.create('file://test', 'plaintext', 1, text); const completions = ls.getCompletions({ @@ -129,7 +129,10 @@ describe('Language Service', () => { variables: { foo: { bar: 1 } }, position: { line: 0, character: 4 } }); - expect(completions).toHaveLength(0); + expect(completions.length).toBeGreaterThan(0); + const item = completions.find(c => c.label === 'foo.bar'); + expect(item).toBeDefined(); + expect(item?.insertText).toBe('bar'); }); it('should provide completion items with proper kind and detail', () => { @@ -145,7 +148,12 @@ describe('Language Service', () => { expect(sinFunc).toBeDefined(); expect(sinFunc?.kind).toBe(CompletionItemKind.Function); expect(sinFunc?.detail).toBeDefined(); - expect(sinFunc?.insertText).toBe('sin()'); + expect(sinFunc?.insertTextFormat).toBe(2); + // newText is provided via textEdit as a snippet with placeholders + const newText = (sinFunc as any)?.textEdit?.newText as string | undefined; + expect(typeof newText).toBe('string'); + expect(newText).toContain('sin('); + expect(newText).toContain('${1'); }); it('should provide variable completions with correct kind', () => { @@ -339,7 +347,7 @@ describe('Language Service', () => { } }); - it('should show plain text content for variables', () => { + it('should show markdown content for variables', () => { const text = 'foo'; const doc = TextDocument.create('file://test', 'plaintext', 1, text); const hover = ls.getHover({ @@ -349,7 +357,9 @@ describe('Language Service', () => { }); const contents = hover.contents as any; - expect(contents.kind).toBe(MarkupKind.PlainText); + expect(contents.kind).toBe(MarkupKind.Markdown); + const value = getContentsValue(hover.contents); + expect(value).toContain('Value Preview'); }); }); From 01d60189984e948340cc14b5390340ab49df2c69 Mon Sep 17 00:00:00 2001 From: Melvin van Bree Date: Mon, 22 Dec 2025 15:16:15 +0100 Subject: [PATCH 03/12] Added tests --- test/language-service/ls-utils.spec.ts | 51 ++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 test/language-service/ls-utils.spec.ts diff --git a/test/language-service/ls-utils.spec.ts b/test/language-service/ls-utils.spec.ts new file mode 100644 index 00000000..b7c0880f --- /dev/null +++ b/test/language-service/ls-utils.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { extractPathPrefix, isPathChar, toTruncatedJsonString, makeTokenStream, iterateTokens } from '../../src/language-service/ls-utils'; +import { Parser } from '../../src/parsing/parser'; + +describe('ls-utils', () => { + it('extractPathPrefix finds start and prefix across $ and dots', () => { + const text = ' let $foo.bar_baz = 1'; + const pos = text.indexOf('z') + 1; // position right after last path char + const { start, prefix } = extractPathPrefix(text, pos); + expect(prefix).toBe('$foo.bar_baz'); + expect(text.slice(start, pos)).toBe('$foo.bar_baz'); + }); + + it('isPathChar allows A-Z, a-z, 0-9, _, $, . and rejects others', () => { + for (const ch of ['A', 'z', '0', '_', '$', '.']) { + expect(isPathChar(ch)).toBe(true); + } + for (const ch of ['-', ' ', '\n', '+', '(', ')']) { + expect(isPathChar(ch)).toBe(false); + } + }); + + it('toTruncatedJsonString returns for undefined', () => { + const s = toTruncatedJsonString(undefined); + expect(s).toBe(''); + }); + + it('toTruncatedJsonString returns for circular objects', () => { + const a: any = {}; + a.self = a; + const s = toTruncatedJsonString(a); + expect(s).toBe(''); + }); + + it('toTruncatedJsonString truncates to maxLines*maxWidth and appends ellipsis', () => { + const long = { text: 'x'.repeat(200) }; + const out = toTruncatedJsonString(long, 2, 10); + expect(out.endsWith('...')).toBe(true); + const parts = out.split('\n\n'); + expect(parts.length).toBe(2); + }); + + it('iterateTokens can stop early with untilPos', () => { + const parser = new Parser(); + const text = '1 + 2 * 3'; + const ts = makeTokenStream(parser, text); + const early = iterateTokens(ts, 2); // somewhere after first token + expect(early.length).toBeGreaterThan(0); + expect(early.length).toBeLessThan(5); + }); +}); From 742053e7bf882bbafd7dba6bfcecfb5266707f92 Mon Sep 17 00:00:00 2001 From: Melvin van Bree Date: Tue, 23 Dec 2025 10:33:42 +0100 Subject: [PATCH 04/12] Organized and simplified Also added support for partial variable hovers --- src/language-service/completions.variables.ts | 35 --- .../language-service.documentation.ts | 8 +- .../language-service.models.ts | 6 +- src/language-service/language-service.ts | 58 +--- src/language-service/var-trie.ts | 40 --- src/language-service/variable-utils.ts | 249 ++++++++++++++++++ 6 files changed, 262 insertions(+), 134 deletions(-) delete mode 100644 src/language-service/completions.variables.ts delete mode 100644 src/language-service/var-trie.ts create mode 100644 src/language-service/variable-utils.ts diff --git a/src/language-service/completions.variables.ts b/src/language-service/completions.variables.ts deleted file mode 100644 index 0b571513..00000000 --- a/src/language-service/completions.variables.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Values } from '../types'; -import { CompletionItem, CompletionItemKind, Range } from 'vscode-languageserver-types'; -import { VarTrie } from './var-trie'; -import { valueTypeName } from './ls-utils'; - -export function pathVariableCompletions(vars: Values | undefined, prefix: string, rangePartial?: Range): CompletionItem[] { - if (!vars) return []; - - const trie = new VarTrie(); - trie.buildFromValues(vars as Record); - - const lastDot = prefix.lastIndexOf('.'); - const endsWithDot = lastDot === prefix.length - 1; - - const baseParts = (endsWithDot ? prefix.slice(0, -1) : lastDot >= 0 ? prefix.slice(0, lastDot) : '') - .split('.') - .filter(Boolean); - - const partial = endsWithDot ? '' : prefix.slice(lastDot + 1); - const lowerPartial = partial.toLowerCase(); - - const baseNode = trie.search(baseParts); - if (!baseNode) return []; - - return Object.entries(baseNode.children) - .filter(([k]) => !partial || k.toLowerCase().startsWith(lowerPartial)) - .map(([key, child]) => ({ - label: (baseParts.length ? baseParts.concat(key) : [key]).join('.'), - kind: CompletionItemKind.Variable, - detail: child.value !== undefined ? valueTypeName(child.value) : 'object', - insertText: key, - textEdit: rangePartial ? { range: rangePartial, newText: key } : undefined, - documentation: undefined, - } as CompletionItem)); -} diff --git a/src/language-service/language-service.documentation.ts b/src/language-service/language-service.documentation.ts index b84657a1..89828e20 100644 --- a/src/language-service/language-service.documentation.ts +++ b/src/language-service/language-service.documentation.ts @@ -4,13 +4,13 @@ export interface FunctionParamDoc { name: string; description: string; optional?: boolean; + isVariadic?: boolean; } export interface FunctionDoc { name: string; description: string; params?: FunctionParamDoc[]; - isVariadic?: boolean; } export const BUILTIN_FUNCTION_DOCS: Record = { @@ -31,17 +31,15 @@ export const BUILTIN_FUNCTION_DOCS: Record = { min: { name: 'min', description: 'Smallest number in the list.', - isVariadic: true, params: [ - {name: 'values', description: 'Numbers to compare.'}, + {name: 'values', description: 'Numbers to compare.', isVariadic: true}, ], }, max: { name: 'max', description: 'Largest number in the list.', - isVariadic: true, params: [ - {name: 'values', description: 'Numbers to compare.'}, + {name: 'values', description: 'Numbers to compare.', isVariadic: true}, ], }, hypot: { diff --git a/src/language-service/language-service.models.ts b/src/language-service/language-service.models.ts index ec104e66..9f7d9ffe 100644 --- a/src/language-service/language-service.models.ts +++ b/src/language-service/language-service.models.ts @@ -9,7 +9,7 @@ export class FunctionDetails { } private arity(){ - if(this.builtInFunctionDoc && !this.builtInFunctionDoc.isVariadic){ + if(this.builtInFunctionDoc){ return this.builtInFunctionDoc.params?.length; } @@ -35,7 +35,7 @@ export class FunctionDetails { } public details(){ - if(this.builtInFunctionDoc && !this.builtInFunctionDoc.isVariadic){ + if(this.builtInFunctionDoc){ const name = this.builtInFunctionDoc.name || this.name; const params = this.builtInFunctionDoc.params || []; return `${name}(${params.map((paramDoc) => `${paramDoc.name}`).join(', ')})`; @@ -46,7 +46,7 @@ export class FunctionDetails { } public completionText(){ - if(this.builtInFunctionDoc && !this.builtInFunctionDoc.isVariadic){ + if(this.builtInFunctionDoc){ const params = this.builtInFunctionDoc.params || []; return `${this.name}(${params.map((paramDoc, i) => `\${${i+1}:${paramDoc.name}}`).join(', ')})`; } diff --git a/src/language-service/language-service.ts b/src/language-service/language-service.ts index df46a867..944d2c41 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -16,7 +16,6 @@ import { Token, } from '../parsing'; import {Parser} from '../parsing/parser'; -import {Values} from '../types'; import type { HighlightToken, LanguageServiceOptions, @@ -25,21 +24,18 @@ import type { LanguageServiceApi, HoverV2 } from './language-service.types'; -import type {CompletionItem, Range, Position} from 'vscode-languageserver-types' +import type {CompletionItem, Range} from 'vscode-languageserver-types' import {CompletionItemKind, MarkupKind, InsertTextFormat} from 'vscode-languageserver-types' import {TextDocument} from 'vscode-languageserver-textdocument' import {BUILTIN_KEYWORD_DOCS, DEFAULT_CONSTANT_DOCS} from './language-service.documentation'; import {FunctionDetails} from "./language-service.models"; -import {VarTrie} from './var-trie'; import { valueTypeName, - isPathChar, extractPathPrefix, - toTruncatedJsonString, makeTokenStream, iterateTokens } from './ls-utils'; -import {pathVariableCompletions} from './completions.variables'; +import {pathVariableCompletions, tryVariableHoverUsingSpans} from './variable-utils'; export function createLanguageService(options: LanguageServiceOptions | undefined = undefined): LanguageServiceApi { // Build a parser instance to access keywords/operators/functions/consts @@ -163,60 +159,20 @@ export function createLanguageService(options: LanguageServiceOptions | undefine return prefix.includes('.') ? all : filterByPrefix(all, prefix); } - function tryVariableHover(textDocument: TextDocument, position: Position, variables?: Values): HoverV2 | undefined { - // If we don't have variables, we can't provide a hover for a variable path - if (!variables) { - return undefined; - } - const text = textDocument.getText(); - const offset = textDocument.offsetAt(position); - // Find start of the path (walk backwards over path chars) - const {start} = extractPathPrefix(text, offset); - // Find end of the path (walk forwards over path chars) - let end = offset; - while (end < text.length && isPathChar(text[end])) { - end++; - } - if (end <= start) { - return undefined; - } - - const fullPath = text.slice(start, end); - const parts = fullPath.split('.').filter(Boolean); - if (parts.length === 0) { - return undefined; - } - - const trie = new VarTrie(); - trie.buildFromValues(variables); - const node = trie.search(parts); - if (!node) { - return undefined; - } - - const range: Range = {start: textDocument.positionAt(start), end: textDocument.positionAt(end)}; - const nodeValue = node.value; - return { - contents: { - kind: MarkupKind.Markdown, - value: `${fullPath}: ${nodeValue !== undefined ? `Variable (${valueTypeName(nodeValue)})` : 'object'}\n\n**Value Preview**\n\n${toTruncatedJsonString(nodeValue)}` - }, - range - }; - } - function getHover(params: GetHoverParams): HoverV2 { const {textDocument, position, variables} = params; const text = textDocument.getText(); - const variableHover = tryVariableHover(textDocument, position, variables); + // Build spans once and reuse + const ts = makeTokenStream(parser, text); + const spans = iterateTokens(ts); + + const variableHover = tryVariableHoverUsingSpans(textDocument, position, variables, spans); if (variableHover) { return variableHover; } // Fallback to token-based hover - const ts = makeTokenStream(parser, text); - const spans = iterateTokens(ts); const offset = textDocument.offsetAt(position); const span = spans.find(s => offset >= s.start && offset <= s.end); diff --git a/src/language-service/var-trie.ts b/src/language-service/var-trie.ts deleted file mode 100644 index 590c4756..00000000 --- a/src/language-service/var-trie.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Value, ValueObject } from '../types'; - -export class VarTrieNode { - children: Record = {}; - value: Value | undefined = undefined; -} - -export class VarTrie { - root: VarTrieNode = new VarTrieNode(); - - private static isValueObject(v: Value): v is ValueObject { - return v !== null && typeof v === 'object' && !Array.isArray(v); - } - - buildFromValues(vars: Record): void { - const walk = (obj: Record, node: VarTrieNode) => { - for (const key of Object.keys(obj)) { - if (!node.children[key]) { - node.children[key] = new VarTrieNode(); - } - const child = node.children[key]; - const val = (obj as any)[key]; - child.value = val as Value; - if (VarTrie.isValueObject(val as Value)) { - walk(val as Record, child); - } - } - }; - walk(vars as Record, this.root); - } - - search(path: string[]): VarTrieNode | undefined { - let node: VarTrieNode | undefined = this.root; - for (const seg of path) { - node = node?.children[seg]; - if (!node) return undefined; - } - return node; - } -} diff --git a/src/language-service/variable-utils.ts b/src/language-service/variable-utils.ts new file mode 100644 index 00000000..70d70f89 --- /dev/null +++ b/src/language-service/variable-utils.ts @@ -0,0 +1,249 @@ +import { TextDocument } from 'vscode-languageserver-textdocument'; +import {Position, Range, MarkupKind, CompletionItem, CompletionItemKind} from 'vscode-languageserver-types'; +import { Values, Value, ValueObject } from '../types'; +import { TNAME, Token } from '../parsing'; +import { HoverV2 } from './language-service.types'; +import { toTruncatedJsonString, valueTypeName } from './ls-utils'; + +type Span = { token: Token; start: number; end: number }; + +function isNameToken(t: Token): boolean { + return t.type === TNAME; +} + +function isDotToken(t: Token): boolean { + return String(t.value) === '.'; +} + +function findNameIndexAt(spans: Span[], offset: number): number | undefined { + const i = spans.findIndex(s => { + return offset >= s.start && offset <= s.end; + }); + + if (i < 0) { + return undefined; + } + + if (isNameToken(spans[i].token)) { + return i; + } + + if (isDotToken(spans[i].token)) { + if (i + 1 < spans.length && isNameToken(spans[i + 1].token)) { + return i + 1; + } + if (i - 1 >= 0 && isNameToken(spans[i - 1].token)) { + return i - 1; + } + } + + return undefined; +} + +/** + * Extracts the path from a span array, starting from the center index. + * @param spans Token spans. + * @param cursorIndex The index of the center token. + */ +function extractPathFromSpans( + spans: Span[], + cursorIndex: number +): { parts: string[]; firstIndex: number; lastIndex: number } | undefined { + if (!isNameToken(spans[cursorIndex].token)) { + return undefined; + } + + const leftParts: string[] = []; + let index = cursorIndex - 1; + + while (index - 1 >= 0) { + const dot = spans[index]; + const name = spans[index - 1]; + + if (!isDotToken(dot.token) || !isNameToken(name.token)) { + break; + } + + leftParts.unshift(String(name.token.value)); + index -= 2; + } + + const center = String(spans[cursorIndex].token.value); + + const parts = [...leftParts, center]; + const firstIndex = cursorIndex - leftParts.length * 2; + const lastIndex = cursorIndex; + + return { parts, firstIndex, lastIndex }; +} + +function resolveValueAtPath(vars: Values | undefined, parts: string[]): Value | undefined { + if (!vars) { + return undefined; + } + + let node: Value = vars; + + for (const segment of parts) { + if (node && typeof node === 'object' && !Array.isArray(node) && segment in (node as any)) { + node = (node as any)[segment]; + } else { + return undefined; + } + } + + return node; +} + + class VarTrieNode { + children: Record = {}; + value: Value | undefined = undefined; +} + + class VarTrie { + root: VarTrieNode = new VarTrieNode(); + + private static isValueObject(v: Value): v is ValueObject { + return v !== null && typeof v === 'object' && !Array.isArray(v); + } + + buildFromValues(vars: Record): void { + const walk = (obj: Record, node: VarTrieNode) => { + for (const key of Object.keys(obj)) { + if (!node.children[key]) { + node.children[key] = new VarTrieNode(); + } + + const child = node.children[key]; + const val = (obj as any)[key]; + + child.value = val as Value; + + if (VarTrie.isValueObject(val as Value)) { + walk(val as Record, child); + } + } + }; + + walk(vars as Record, this.root); + } + + search(path: string[]): VarTrieNode | undefined { + let node: VarTrieNode | undefined = this.root; + + for (const seg of path) { + if (node) { + node = node.children[seg]; + } + + if (!node) { + return undefined; + } + } + + return node; + } +} + +/** + * Tries to resolve a variable hover using spans. + * @param textDocument The document containing the variable name. + * @param position The current position of the cursor. + * @param variables The variables to resolve the hover against. + * @param spans The spans of the document. + * @returns A hover with the variable name and its value, or undefined if the variable cannot be resolved. + * @privateRemarks Resolves everything to the left of the cursor. Hovering over a variable in the middle of a path will resolve it up until that point. + */ +export function tryVariableHoverUsingSpans( + textDocument: TextDocument, + position: Position, + variables: Values | undefined, + spans: Span[], +): HoverV2 | undefined { + if (!variables) { + return undefined; + } + + if (spans.length === 0) { + return undefined; + } + + const offset = textDocument.offsetAt(position); + const nameIndex = findNameIndexAt(spans, offset); + + if (nameIndex == null) { + return undefined; + } + + const extracted = extractPathFromSpans(spans, nameIndex); + + if (!extracted) { + return undefined; + } + + const { parts, firstIndex } = extracted; + + if (parts.length === 0) { + return undefined; + } + + const leftCount = Math.max(0, Math.floor((nameIndex - firstIndex) / 2)); + const partsUpToHovered = parts.slice(0, leftCount + 1); + + const value = resolveValueAtPath(variables, partsUpToHovered); + + if (value === undefined) { + return undefined; + } + + const start = spans[nameIndex].start; + const end = spans[nameIndex].end; + + const range: Range = { + start: textDocument.positionAt(start), + end: textDocument.positionAt(end), + }; + + const fullPath = partsUpToHovered.join('.'); + + return { + contents: { + kind: MarkupKind.Markdown, + value: + `${fullPath}: Variable (${valueTypeName(value)})` + + `\n\n**Value Preview**\n\n${toTruncatedJsonString(value)}`, + }, + range, + }; +} + +export function pathVariableCompletions(vars: Values | undefined, prefix: string, rangePartial?: Range): CompletionItem[] { + if (!vars) return []; + + const trie = new VarTrie(); + trie.buildFromValues(vars as Record); + + const lastDot = prefix.lastIndexOf('.'); + const endsWithDot = lastDot === prefix.length - 1; + + const baseParts = (endsWithDot ? prefix.slice(0, -1) : lastDot >= 0 ? prefix.slice(0, lastDot) : '') + .split('.') + .filter(Boolean); + + const partial = endsWithDot ? '' : prefix.slice(lastDot + 1); + const lowerPartial = partial.toLowerCase(); + + const baseNode = trie.search(baseParts); + if (!baseNode) return []; + + return Object.entries(baseNode.children) + .filter(([k]) => !partial || k.toLowerCase().startsWith(lowerPartial)) + .map(([key, child]) => ({ + label: (baseParts.length ? baseParts.concat(key) : [key]).join('.'), + kind: CompletionItemKind.Variable, + detail: child.value !== undefined ? valueTypeName(child.value) : 'object', + insertText: key, + textEdit: rangePartial ? { range: rangePartial, newText: key } : undefined, + documentation: undefined, + } as CompletionItem)); +} From af5b8d8eb37689d36543d550774b9d2e49d31675 Mon Sep 17 00:00:00 2001 From: Melvin van Bree Date: Tue, 23 Dec 2025 11:03:17 +0100 Subject: [PATCH 05/12] Further simplification --- .../language-service.types.ts | 2 +- src/language-service/variable-utils.ts | 138 +++++++++++------- 2 files changed, 86 insertions(+), 54 deletions(-) diff --git a/src/language-service/language-service.types.ts b/src/language-service/language-service.types.ts index 576a9c3d..77000950 100644 --- a/src/language-service/language-service.types.ts +++ b/src/language-service/language-service.types.ts @@ -52,5 +52,5 @@ export interface GetHoverParams { } export interface HoverV2 extends Hover { - contents: MarkupContent; + contents: MarkupContent; // Type narrowing since we know we are not going to return deprecated content } diff --git a/src/language-service/variable-utils.ts b/src/language-service/variable-utils.ts index 70d70f89..86309059 100644 --- a/src/language-service/variable-utils.ts +++ b/src/language-service/variable-utils.ts @@ -1,9 +1,9 @@ -import { TextDocument } from 'vscode-languageserver-textdocument'; +import {TextDocument} from 'vscode-languageserver-textdocument'; import {Position, Range, MarkupKind, CompletionItem, CompletionItemKind} from 'vscode-languageserver-types'; -import { Values, Value, ValueObject } from '../types'; -import { TNAME, Token } from '../parsing'; -import { HoverV2 } from './language-service.types'; -import { toTruncatedJsonString, valueTypeName } from './ls-utils'; +import {Values, Value, ValueObject} from '../types'; +import {TNAME, Token} from '../parsing'; +import {HoverV2} from './language-service.types'; +import {toTruncatedJsonString, valueTypeName} from './ls-utils'; type Span = { token: Token; start: number; end: number }; @@ -12,28 +12,33 @@ function isNameToken(t: Token): boolean { } function isDotToken(t: Token): boolean { - return String(t.value) === '.'; + return t.value === '.'; } function findNameIndexAt(spans: Span[], offset: number): number | undefined { - const i = spans.findIndex(s => { + const hitIndex = spans.findIndex(s => { return offset >= s.start && offset <= s.end; }); - if (i < 0) { + if (hitIndex < 0) { return undefined; } - if (isNameToken(spans[i].token)) { - return i; + const token = spans[hitIndex].token; + + if (isNameToken(token)) { + return hitIndex; } - if (isDotToken(spans[i].token)) { - if (i + 1 < spans.length && isNameToken(spans[i + 1].token)) { - return i + 1; + if (isDotToken(token)) { + const right = hitIndex + 1; + if (isNameToken(spans[right]?.token)) { + return right; } - if (i - 1 >= 0 && isNameToken(spans[i - 1].token)) { - return i - 1; + + const left = hitIndex - 1; + if (isNameToken(spans[left]?.token)) { + return left; } } @@ -48,7 +53,7 @@ function findNameIndexAt(spans: Span[], offset: number): number | undefined { function extractPathFromSpans( spans: Span[], cursorIndex: number -): { parts: string[]; firstIndex: number; lastIndex: number } | undefined { +): { parts: string[]; firstIndex: number } | undefined { if (!isNameToken(spans[cursorIndex].token)) { return undefined; } @@ -69,12 +74,11 @@ function extractPathFromSpans( } const center = String(spans[cursorIndex].token.value); - const parts = [...leftParts, center]; + const firstIndex = cursorIndex - leftParts.length * 2; - const lastIndex = cursorIndex; - return { parts, firstIndex, lastIndex }; + return {parts, firstIndex}; } function resolveValueAtPath(vars: Values | undefined, parts: string[]): Value | undefined { @@ -82,25 +86,31 @@ function resolveValueAtPath(vars: Values | undefined, parts: string[]): Value | return undefined; } + const isPlainObject = (v: unknown): v is Record => { + return v !== null && typeof v === 'object' && !Array.isArray(v); + }; + let node: Value = vars; for (const segment of parts) { - if (node && typeof node === 'object' && !Array.isArray(node) && segment in (node as any)) { - node = (node as any)[segment]; - } else { + if (!isPlainObject(node)) { return undefined; } + if (!Object.prototype.hasOwnProperty.call(node, segment)) { + return undefined; + } + node = (node as ValueObject)[segment]; } return node; } - class VarTrieNode { +class VarTrieNode { children: Record = {}; value: Value | undefined = undefined; } - class VarTrie { +class VarTrie { root: VarTrieNode = new VarTrieNode(); private static isValueObject(v: Value): v is ValueObject { @@ -115,27 +125,26 @@ function resolveValueAtPath(vars: Values | undefined, parts: string[]): Value | } const child = node.children[key]; - const val = (obj as any)[key]; - - child.value = val as Value; + const val = obj[key] as Value; + child.value = val; - if (VarTrie.isValueObject(val as Value)) { - walk(val as Record, child); + if (VarTrie.isValueObject(val)) { + walk(val, child); } } }; - walk(vars as Record, this.root); + walk(vars, this.root); } search(path: string[]): VarTrieNode | undefined { let node: VarTrieNode | undefined = this.root; for (const seg of path) { - if (node) { - node = node.children[seg]; + if (!node) { + return undefined; } - + node = node.children[seg]; if (!node) { return undefined; } @@ -181,7 +190,7 @@ export function tryVariableHoverUsingSpans( return undefined; } - const { parts, firstIndex } = extracted; + const {parts, firstIndex} = extracted; if (parts.length === 0) { return undefined; @@ -196,12 +205,10 @@ export function tryVariableHoverUsingSpans( return undefined; } - const start = spans[nameIndex].start; - const end = spans[nameIndex].end; - + const span = spans[nameIndex]; const range: Range = { - start: textDocument.positionAt(start), - end: textDocument.positionAt(end), + start: textDocument.positionAt(span.start), + end: textDocument.positionAt(span.end), }; const fullPath = partsUpToHovered.join('.'); @@ -217,33 +224,58 @@ export function tryVariableHoverUsingSpans( }; } +/** + * Returns a list of completions for variables in the given path. + * @param vars The variables to complete against. + * @param prefix The prefix of the variable name, including the dot. + * @param rangePartial The range of the variable name, excluding the dot. Used to replace the variable name in the completion item. + * @returns An array of completion items. + */ export function pathVariableCompletions(vars: Values | undefined, prefix: string, rangePartial?: Range): CompletionItem[] { - if (!vars) return []; + if (!vars) { + return []; + } const trie = new VarTrie(); trie.buildFromValues(vars as Record); const lastDot = prefix.lastIndexOf('.'); - const endsWithDot = lastDot === prefix.length - 1; + const endsWithDot = prefix.endsWith('.'); - const baseParts = (endsWithDot ? prefix.slice(0, -1) : lastDot >= 0 ? prefix.slice(0, lastDot) : '') - .split('.') - .filter(Boolean); + const basePath = endsWithDot + ? prefix.slice(0, -1) + : lastDot >= 0 + ? prefix.slice(0, lastDot) + : ''; + const baseParts = basePath ? basePath.split('.') : []; const partial = endsWithDot ? '' : prefix.slice(lastDot + 1); const lowerPartial = partial.toLowerCase(); const baseNode = trie.search(baseParts); - if (!baseNode) return []; + if (!baseNode) { + return []; + } - return Object.entries(baseNode.children) - .filter(([k]) => !partial || k.toLowerCase().startsWith(lowerPartial)) - .map(([key, child]) => ({ - label: (baseParts.length ? baseParts.concat(key) : [key]).join('.'), + const items: CompletionItem[] = []; + + for (const key of Object.keys(baseNode.children)) { + if (partial && !key.toLowerCase().startsWith(lowerPartial)) { + continue; + } + + const child = baseNode.children[key]; + const label = [...baseParts, key].join('.'); + const detail = child.value !== undefined ? valueTypeName(child.value) : 'object'; + + items.push({ + label, kind: CompletionItemKind.Variable, - detail: child.value !== undefined ? valueTypeName(child.value) : 'object', + detail, insertText: key, - textEdit: rangePartial ? { range: rangePartial, newText: key } : undefined, - documentation: undefined, - } as CompletionItem)); + textEdit: rangePartial ? {range: rangePartial, newText: key} : undefined, + }); + } + + return items; } From e7620f5297c8971e9c742c9d1f4e858e68189753 Mon Sep 17 00:00:00 2001 From: Melvin van Bree Date: Tue, 23 Dec 2025 11:03:25 +0100 Subject: [PATCH 06/12] Ran lint fix --- .../language-service.documentation.ts | 407 ++++++++-------- .../language-service.models.ts | 78 +-- src/language-service/language-service.ts | 459 +++++++++--------- .../language-service.types.ts | 1 - src/language-service/ls-utils.ts | 106 ++-- src/language-service/variable-utils.ts | 358 +++++++------- 6 files changed, 702 insertions(+), 707 deletions(-) diff --git a/src/language-service/language-service.documentation.ts b/src/language-service/language-service.documentation.ts index 89828e20..8cabe4bc 100644 --- a/src/language-service/language-service.documentation.ts +++ b/src/language-service/language-service.documentation.ts @@ -14,216 +14,215 @@ export interface FunctionDoc { } export const BUILTIN_FUNCTION_DOCS: Record = { - random: { - name: 'random', - description: 'Get a random number in the range [0, n). Defaults to 1 if n is missing or zero.', - params: [ - {name: 'n', description: 'Upper bound (exclusive).', optional: true}, - ], - }, - fac: { - name: 'fac', - description: 'Factorial of n. Deprecated; prefer the ! operator.', - params: [ - {name: 'n', description: 'Non-negative integer.'}, - ], - }, - min: { - name: 'min', - description: 'Smallest number in the list.', - params: [ - {name: 'values', description: 'Numbers to compare.', isVariadic: true}, - ], - }, - max: { - name: 'max', - description: 'Largest number in the list.', - params: [ - {name: 'values', description: 'Numbers to compare.', isVariadic: true}, - ], - }, - hypot: { - name: 'hypot', - description: 'Hypotenuse √(a² + b²).', - params: [ - {name: 'a', description: 'First side.'}, - {name: 'b', description: 'Second side.'}, - ], - }, - pyt: { - name: 'pyt', - description: 'Alias for hypot(a, b).', - params: [ - {name: 'a', description: 'First side.'}, - {name: 'b', description: 'Second side.'}, - ], - }, - pow: { - name: 'pow', - description: 'Raise x to the power of y.', - params: [ - {name: 'x', description: 'Base.'}, - {name: 'y', description: 'Exponent.'}, - ], - }, - atan2: { - name: 'atan2', - description: 'Arc tangent of y / x.', - params: [ - {name: 'y', description: 'Y coordinate.'}, - {name: 'x', description: 'X coordinate.'}, - ], - }, - roundTo: { - name: 'roundTo', - description: 'Round x to n decimal places.', - params: [ - {name: 'x', description: 'Number to round.'}, - {name: 'n', description: 'Number of decimal places.'}, - ], - }, - map: { - name: 'map', - description: 'Apply function f to each element of array a.', - params: [ - {name: 'f', description: 'Mapping function (value, index).'}, - {name: 'a', description: 'Input array.'}, - ], - }, - fold: { - name: 'fold', - description: 'Reduce array a using function f, starting with accumulator y.', - params: [ - {name: 'f', description: 'Reducer function. Eg: `f(acc, x, i) = acc + x`.'}, - {name: 'y', description: 'Initial accumulator value.'}, - {name: 'a', description: 'Input array.'}, - ], - }, - filter: { - name: 'filter', - description: 'Filter array a using predicate f.', - params: [ - {name: 'f', description: 'Filter function. Eg:`f(x) = x % 2 == 0`'}, - {name: 'a', description: 'Input array.'}, - ], - }, - indexOf: { - name: 'indexOf', - description: 'First index of x in a (array or string), or -1 if not found.', - params: [ - {name: 'x', description: 'Value to search for.'}, - {name: 'a', description: 'Array or string to search.'}, - ], - }, - join: { - name: 'join', - description: 'Join array a using separator sep.', - params: [ - {name: 'sep', description: 'Separator string.'}, - {name: 'a', description: 'Array to join.'}, - ], - }, - if: { - name: 'if', - description: 'Conditional expression: condition ? trueValue : falseValue (both branches evaluate).', - params: [ - {name: 'condition', description: 'A boolean condition.'}, - {name: 'trueValue', description: 'Value if condition is true.'}, - {name: 'falseValue', description: 'Value if condition is false.'}, - ], - }, - json: { - name: 'json', - description: 'Return JSON string representation of x.', - params: [ - {name: 'x', description: 'Value to stringify.'}, - ], - }, - sum: { - name: 'sum', - description: 'Sum of all elements in an array.', - params: [ - {name: 'a', description: 'Array of numbers.'}, - ], - }, - /** + random: { + name: 'random', + description: 'Get a random number in the range [0, n). Defaults to 1 if n is missing or zero.', + params: [ + { name: 'n', description: 'Upper bound (exclusive).', optional: true } + ] + }, + fac: { + name: 'fac', + description: 'Factorial of n. Deprecated; prefer the ! operator.', + params: [ + { name: 'n', description: 'Non-negative integer.' } + ] + }, + min: { + name: 'min', + description: 'Smallest number in the list.', + params: [ + { name: 'values', description: 'Numbers to compare.', isVariadic: true } + ] + }, + max: { + name: 'max', + description: 'Largest number in the list.', + params: [ + { name: 'values', description: 'Numbers to compare.', isVariadic: true } + ] + }, + hypot: { + name: 'hypot', + description: 'Hypotenuse √(a² + b²).', + params: [ + { name: 'a', description: 'First side.' }, + { name: 'b', description: 'Second side.' } + ] + }, + pyt: { + name: 'pyt', + description: 'Alias for hypot(a, b).', + params: [ + { name: 'a', description: 'First side.' }, + { name: 'b', description: 'Second side.' } + ] + }, + pow: { + name: 'pow', + description: 'Raise x to the power of y.', + params: [ + { name: 'x', description: 'Base.' }, + { name: 'y', description: 'Exponent.' } + ] + }, + atan2: { + name: 'atan2', + description: 'Arc tangent of y / x.', + params: [ + { name: 'y', description: 'Y coordinate.' }, + { name: 'x', description: 'X coordinate.' } + ] + }, + roundTo: { + name: 'roundTo', + description: 'Round x to n decimal places.', + params: [ + { name: 'x', description: 'Number to round.' }, + { name: 'n', description: 'Number of decimal places.' } + ] + }, + map: { + name: 'map', + description: 'Apply function f to each element of array a.', + params: [ + { name: 'f', description: 'Mapping function (value, index).' }, + { name: 'a', description: 'Input array.' } + ] + }, + fold: { + name: 'fold', + description: 'Reduce array a using function f, starting with accumulator y.', + params: [ + { name: 'f', description: 'Reducer function. Eg: `f(acc, x, i) = acc + x`.' }, + { name: 'y', description: 'Initial accumulator value.' }, + { name: 'a', description: 'Input array.' } + ] + }, + filter: { + name: 'filter', + description: 'Filter array a using predicate f.', + params: [ + { name: 'f', description: 'Filter function. Eg:`f(x) = x % 2 == 0`' }, + { name: 'a', description: 'Input array.' } + ] + }, + indexOf: { + name: 'indexOf', + description: 'First index of x in a (array or string), or -1 if not found.', + params: [ + { name: 'x', description: 'Value to search for.' }, + { name: 'a', description: 'Array or string to search.' } + ] + }, + join: { + name: 'join', + description: 'Join array a using separator sep.', + params: [ + { name: 'sep', description: 'Separator string.' }, + { name: 'a', description: 'Array to join.' } + ] + }, + if: { + name: 'if', + description: 'Conditional expression: condition ? trueValue : falseValue (both branches evaluate).', + params: [ + { name: 'condition', description: 'A boolean condition.' }, + { name: 'trueValue', description: 'Value if condition is true.' }, + { name: 'falseValue', description: 'Value if condition is false.' } + ] + }, + json: { + name: 'json', + description: 'Return JSON string representation of x.', + params: [ + { name: 'x', description: 'Value to stringify.' } + ] + }, + sum: { + name: 'sum', + description: 'Sum of all elements in an array.', + params: [ + { name: 'a', description: 'Array of numbers.' } + ] + }, + /** * String functions */ - stringLength: { - name: 'stringLength', - description: 'Return the length of a string.', - params: [{name: 'str', description: 'Input string.'}], - }, - isEmpty: { - name: 'isEmpty', - description: 'Return true if the string is empty.', - params: [{name: 'str', description: 'Input string.'}], - }, - contains: { - name: 'contains', - description: 'Return true if str contains substring.', - params: [ - {name: 'str', description: 'Input string.'}, - {name: 'substring', description: 'Substring to search for.'}, - ], - }, - startsWith: { - name: 'startsWith', - description: 'Return true if str starts with substring.', - params: [ - {name: 'str', description: 'Input string.'}, - {name: 'substring', description: 'Prefix to check.'}, - ], - }, - endsWith: { - name: 'endsWith', - description: 'Return true if str ends with substring.', - params: [ - {name: 'str', description: 'Input string.'}, - {name: 'substring', description: 'Suffix to check.'}, - ], - }, - split: { - name: 'split', - description: 'Split string by delimiter into an array.', - params: [ - {name: 'str', description: 'Input string.'}, - {name: 'delimiter', description: 'Delimiter string.'}, - ], - }, - padLeft: { - name: 'padLeft', - description: 'Pad string on the left to reach target length.', - params: [ - {name: 'str', description: 'Input string.'}, - {name: 'length', description: 'Target length.'}, - {name: 'padStr', description: 'Padding string.', optional: true}, - ], - }, - padRight: { - name: 'padRight', - description: 'Pad string on the right to reach target length.', - params: [ - {name: 'str', description: 'Input string.'}, - {name: 'length', description: 'Target length.'}, - {name: 'padStr', description: 'Padding string.', optional: true}, - ], - }, + stringLength: { + name: 'stringLength', + description: 'Return the length of a string.', + params: [{ name: 'str', description: 'Input string.' }] + }, + isEmpty: { + name: 'isEmpty', + description: 'Return true if the string is empty.', + params: [{ name: 'str', description: 'Input string.' }] + }, + contains: { + name: 'contains', + description: 'Return true if str contains substring.', + params: [ + { name: 'str', description: 'Input string.' }, + { name: 'substring', description: 'Substring to search for.' } + ] + }, + startsWith: { + name: 'startsWith', + description: 'Return true if str starts with substring.', + params: [ + { name: 'str', description: 'Input string.' }, + { name: 'substring', description: 'Prefix to check.' } + ] + }, + endsWith: { + name: 'endsWith', + description: 'Return true if str ends with substring.', + params: [ + { name: 'str', description: 'Input string.' }, + { name: 'substring', description: 'Suffix to check.' } + ] + }, + split: { + name: 'split', + description: 'Split string by delimiter into an array.', + params: [ + { name: 'str', description: 'Input string.' }, + { name: 'delimiter', description: 'Delimiter string.' } + ] + }, + padLeft: { + name: 'padLeft', + description: 'Pad string on the left to reach target length.', + params: [ + { name: 'str', description: 'Input string.' }, + { name: 'length', description: 'Target length.' }, + { name: 'padStr', description: 'Padding string.', optional: true } + ] + }, + padRight: { + name: 'padRight', + description: 'Pad string on the right to reach target length.', + params: [ + { name: 'str', description: 'Input string.' }, + { name: 'length', description: 'Target length.' }, + { name: 'padStr', description: 'Padding string.', optional: true } + ] + } }; - export const BUILTIN_KEYWORD_DOCS: Record = { - undefined: 'Represents an undefined value.', - case: 'Start of a case-when-then-else-end block.', - when: 'Case branch condition.', - then: 'Then branch result.', - else: 'Else branch result.', - end: 'End of case block.' + undefined: 'Represents an undefined value.', + case: 'Start of a case-when-then-else-end block.', + when: 'Case branch condition.', + then: 'Then branch result.', + else: 'Else branch result.', + end: 'End of case block.' }; export const DEFAULT_CONSTANT_DOCS: Record = { - E: 'Math.E', - PI: 'Math.PI', - true: 'Logical true', - false: 'Logical false' + E: 'Math.E', + PI: 'Math.PI', + true: 'Logical true', + false: 'Logical false' }; diff --git a/src/language-service/language-service.models.ts b/src/language-service/language-service.models.ts index 9f7d9ffe..273f8dcb 100644 --- a/src/language-service/language-service.models.ts +++ b/src/language-service/language-service.models.ts @@ -1,57 +1,57 @@ -import {Parser} from "../parsing/parser"; -import {BUILTIN_FUNCTION_DOCS, FunctionDoc} from "./language-service.documentation"; +import { Parser } from '../parsing/parser'; +import { BUILTIN_FUNCTION_DOCS, FunctionDoc } from './language-service.documentation'; export class FunctionDetails { - private readonly builtInFunctionDoc : FunctionDoc | undefined; + private readonly builtInFunctionDoc : FunctionDoc | undefined; - constructor(private readonly parser: Parser, public readonly name: string) { - this.builtInFunctionDoc = BUILTIN_FUNCTION_DOCS[this.name] || undefined; - } - - private arity(){ - if(this.builtInFunctionDoc){ - return this.builtInFunctionDoc.params?.length; - } + constructor(private readonly parser: Parser, public readonly name: string) { + this.builtInFunctionDoc = BUILTIN_FUNCTION_DOCS[this.name] || undefined; + } - const f: unknown = (this.parser.functions && this.parser.functions[this.name]) || (this.parser.unaryOps && this.parser.unaryOps[this.name]); - return typeof f === 'function' ? f.length : undefined; + private arity() { + if (this.builtInFunctionDoc) { + return this.builtInFunctionDoc.params?.length; } - public docs(){ - if(this.builtInFunctionDoc){ - const description = this.builtInFunctionDoc.description || ''; + const f: unknown = (this.parser.functions && this.parser.functions[this.name]) || (this.parser.unaryOps && this.parser.unaryOps[this.name]); + return typeof f === 'function' ? f.length : undefined; + } - const params = this.builtInFunctionDoc.params || []; + public docs() { + if (this.builtInFunctionDoc) { + const description = this.builtInFunctionDoc.description || ''; - return`**${this.details()}**\n\n${description}\n\n*Parameters:*\n` + params.map((paramDoc) => `* \`${paramDoc.name}\`: ${paramDoc.description}`).join('\n'); - } + const params = this.builtInFunctionDoc.params || []; - // Provide a generic doc for unary operators if not documented - if (this.parser.unaryOps && this.parser.unaryOps[this.name]) { - return `${this.name} x: unary operator`; - } + return `**${this.details()}**\n\n${description}\n\n*Parameters:*\n` + params.map((paramDoc) => `* \`${paramDoc.name}\`: ${paramDoc.description}`).join('\n'); + } - return undefined; + // Provide a generic doc for unary operators if not documented + if (this.parser.unaryOps && this.parser.unaryOps[this.name]) { + return `${this.name} x: unary operator`; } - public details(){ - if(this.builtInFunctionDoc){ - const name = this.builtInFunctionDoc.name || this.name; - const params = this.builtInFunctionDoc.params || []; - return `${name}(${params.map((paramDoc) => `${paramDoc.name}`).join(', ')})`; - } + return undefined; + } - const arity = this.arity(); - return arity != null ? `${this.name}(${Array.from({length: arity}).map((_, i) => 'arg' + (i + 1)).join(', ')})` : `${this.name}(…)`; + public details() { + if (this.builtInFunctionDoc) { + const name = this.builtInFunctionDoc.name || this.name; + const params = this.builtInFunctionDoc.params || []; + return `${name}(${params.map((paramDoc) => `${paramDoc.name}`).join(', ')})`; } - public completionText(){ - if(this.builtInFunctionDoc){ - const params = this.builtInFunctionDoc.params || []; - return `${this.name}(${params.map((paramDoc, i) => `\${${i+1}:${paramDoc.name}}`).join(', ')})`; - } + const arity = this.arity(); + return arity != null ? `${this.name}(${Array.from({ length: arity }).map((_, i) => 'arg' + (i + 1)).join(', ')})` : `${this.name}(…)`; + } - const arity = this.arity(); - return arity != null ? `${this.name}(${Array.from({length: arity}).map((_, i) => `\${${i+1}}`).join(', ')})` : `${this.name}(…)`; + public completionText() { + if (this.builtInFunctionDoc) { + const params = this.builtInFunctionDoc.params || []; + return `${this.name}(${params.map((paramDoc, i) => `\${${i + 1}:${paramDoc.name}}`).join(', ')})`; } + + const arity = this.arity(); + return arity != null ? `${this.name}(${Array.from({ length: arity }).map((_, i) => `\${${i + 1}}`).join(', ')})` : `${this.name}(…)`; + } } diff --git a/src/language-service/language-service.ts b/src/language-service/language-service.ts index 944d2c41..7b1655d3 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -2,262 +2,259 @@ // Provides: completions, hover, and syntax highlighting using the existing tokenizer import { - TEOF, - TOP, - TNUMBER, - TSTRING, - TPAREN, - TBRACKET, - TCOMMA, - TNAME, - TSEMICOLON, - TKEYWORD, - TBRACE, - Token, + TOP, + TNUMBER, + TSTRING, + TPAREN, + TBRACKET, + TCOMMA, + TNAME, + TSEMICOLON, + TKEYWORD, + TBRACE, + Token } from '../parsing'; -import {Parser} from '../parsing/parser'; +import { Parser } from '../parsing/parser'; import type { - HighlightToken, - LanguageServiceOptions, - GetCompletionsParams, - GetHoverParams, - LanguageServiceApi, - HoverV2 + HighlightToken, + LanguageServiceOptions, + GetCompletionsParams, + GetHoverParams, + LanguageServiceApi, + HoverV2 } from './language-service.types'; -import type {CompletionItem, Range} from 'vscode-languageserver-types' -import {CompletionItemKind, MarkupKind, InsertTextFormat} from 'vscode-languageserver-types' -import {TextDocument} from 'vscode-languageserver-textdocument' -import {BUILTIN_KEYWORD_DOCS, DEFAULT_CONSTANT_DOCS} from './language-service.documentation'; -import {FunctionDetails} from "./language-service.models"; +import type { CompletionItem, Range } from 'vscode-languageserver-types'; +import { CompletionItemKind, MarkupKind, InsertTextFormat } from 'vscode-languageserver-types'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { BUILTIN_KEYWORD_DOCS, DEFAULT_CONSTANT_DOCS } from './language-service.documentation'; +import { FunctionDetails } from './language-service.models'; import { - valueTypeName, - extractPathPrefix, - makeTokenStream, - iterateTokens + valueTypeName, + extractPathPrefix, + makeTokenStream, + iterateTokens } from './ls-utils'; -import {pathVariableCompletions, tryVariableHoverUsingSpans} from './variable-utils'; +import { pathVariableCompletions, tryVariableHoverUsingSpans } from './variable-utils'; export function createLanguageService(options: LanguageServiceOptions | undefined = undefined): LanguageServiceApi { - // Build a parser instance to access keywords/operators/functions/consts - const parser = new Parser({ - operators: options?.operators, - }); - - const constantDocs = { - ...DEFAULT_CONSTANT_DOCS, - } as Record; - - function allFunctions(): FunctionDetails[] { - // Parser exposes built-in functions on parser.functions - const definedFunctions = parser.functions ? Object.keys(parser.functions) : []; - // Unary operators can also be used like functions with parens: sin(x), abs(x), ... - const unary = parser.unaryOps ? Object.keys(parser.unaryOps) : []; - // Merge, prefer functions map descriptions where available - const rawFunctions = Array.from(new Set([...definedFunctions, ...unary])); - - - return rawFunctions.map(name => new FunctionDetails(parser, name)); - } - - function allConstants(): string[] { - return parser.consts ? Object.keys(parser.consts) : []; - } - - function tokenKindToHighlight(t: Token): HighlightToken['type'] { - switch (t.type) { - case TNUMBER: - return 'number'; - case TSTRING: - return 'string'; - case TKEYWORD: - return 'keyword'; - case TOP: - return 'operator'; - case TPAREN: - case TBRACE: - case TBRACKET: - case TCOMMA: - case TSEMICOLON: - return 'punctuation'; - case TNAME: - default: { - // If not matches, check if it's a function or an identifier - const functions = allFunctions(); - if (t.type === TNAME && functions.find((f: FunctionDetails) => f.name == String(t.value))) { - return 'function'; - } - - return 'name'; - } + // Build a parser instance to access keywords/operators/functions/consts + const parser = new Parser({ + operators: options?.operators + }); + + const constantDocs = { + ...DEFAULT_CONSTANT_DOCS + } as Record; + + function allFunctions(): FunctionDetails[] { + // Parser exposes built-in functions on parser.functions + const definedFunctions = parser.functions ? Object.keys(parser.functions) : []; + // Unary operators can also be used like functions with parens: sin(x), abs(x), ... + const unary = parser.unaryOps ? Object.keys(parser.unaryOps) : []; + // Merge, prefer functions map descriptions where available + const rawFunctions = Array.from(new Set([...definedFunctions, ...unary])); + + return rawFunctions.map(name => new FunctionDetails(parser, name)); + } + + function allConstants(): string[] { + return parser.consts ? Object.keys(parser.consts) : []; + } + + function tokenKindToHighlight(t: Token): HighlightToken['type'] { + switch (t.type) { + case TNUMBER: + return 'number'; + case TSTRING: + return 'string'; + case TKEYWORD: + return 'keyword'; + case TOP: + return 'operator'; + case TPAREN: + case TBRACE: + case TBRACKET: + case TCOMMA: + case TSEMICOLON: + return 'punctuation'; + case TNAME: + default: { + // If not matches, check if it's a function or an identifier + const functions = allFunctions(); + if (t.type === TNAME && functions.find((f: FunctionDetails) => f.name == String(t.value))) { + return 'function'; } - } - function functionCompletions(rangeFull: Range): CompletionItem[] { - return allFunctions().map(func => ({ - label: func.name, - kind: CompletionItemKind.Function, - detail: func.details(), - documentation: func.docs(), - insertTextFormat: InsertTextFormat.Snippet, - textEdit: {range: rangeFull, newText: func.completionText()}, - })); + return 'name'; + } } - - function constantCompletions(rangeFull: Range): CompletionItem[] { - return allConstants().map(name => ({ - label: name, - kind: CompletionItemKind.Constant, - detail: valueTypeName(parser.consts[name]), - documentation: constantDocs[name], - textEdit: {range: rangeFull, newText: name}, - })); + } + + function functionCompletions(rangeFull: Range): CompletionItem[] { + return allFunctions().map(func => ({ + label: func.name, + kind: CompletionItemKind.Function, + detail: func.details(), + documentation: func.docs(), + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range: rangeFull, newText: func.completionText() } + })); + } + + function constantCompletions(rangeFull: Range): CompletionItem[] { + return allConstants().map(name => ({ + label: name, + kind: CompletionItemKind.Constant, + detail: valueTypeName(parser.consts[name]), + documentation: constantDocs[name], + textEdit: { range: rangeFull, newText: name } + })); + } + + function keywordCompletions(rangeFull: Range): CompletionItem[] { + return (parser.keywords || []).map(keyword => ({ + label: keyword, + kind: CompletionItemKind.Keyword, + detail: 'keyword', + documentation: BUILTIN_KEYWORD_DOCS[keyword], + textEdit: { range: rangeFull, newText: keyword } + })); + } + + function filterByPrefix(items: CompletionItem[], prefix: string): CompletionItem[] { + if (!prefix) { + return items; } - - function keywordCompletions(rangeFull: Range): CompletionItem[] { - return (parser.keywords || []).map(keyword => ({ - label: keyword, - kind: CompletionItemKind.Keyword, - detail: 'keyword', - documentation: BUILTIN_KEYWORD_DOCS[keyword], - textEdit: {range: rangeFull, newText: keyword}, - })); - } - - function filterByPrefix(items: CompletionItem[], prefix: string): CompletionItem[] { - if (!prefix) { - return items; - } - const lower = prefix.toLowerCase(); - return items.filter(i => i.label.toLowerCase().startsWith(lower)); - } - - function getCompletions(params: GetCompletionsParams): CompletionItem[] { - const {textDocument, variables, position} = params; - const text = textDocument.getText(); - const offsetPosition = textDocument.offsetAt(position); - - const {start, prefix} = extractPathPrefix(text, offsetPosition); - - // Build ranges for replacement - const rangeFull: Range = {start: textDocument.positionAt(start), end: position}; - const lastDot = prefix.lastIndexOf('.'); - const partial = lastDot >= 0 ? prefix.slice(lastDot + 1) : prefix; - const replaceStartOffset = + const lower = prefix.toLowerCase(); + return items.filter(i => i.label.toLowerCase().startsWith(lower)); + } + + function getCompletions(params: GetCompletionsParams): CompletionItem[] { + const { textDocument, variables, position } = params; + const text = textDocument.getText(); + const offsetPosition = textDocument.offsetAt(position); + + const { start, prefix } = extractPathPrefix(text, offsetPosition); + + // Build ranges for replacement + const rangeFull: Range = { start: textDocument.positionAt(start), end: position }; + const lastDot = prefix.lastIndexOf('.'); + const partial = lastDot >= 0 ? prefix.slice(lastDot + 1) : prefix; + const replaceStartOffset = start + (prefix.length - partial.length); - const rangePartial: Range = { - start: textDocument.positionAt(replaceStartOffset), - end: position - }; - - const all: CompletionItem[] = [ - ...functionCompletions(rangeFull), - ...constantCompletions(rangeFull), - ...keywordCompletions(rangeFull), - ...pathVariableCompletions(variables, prefix, rangePartial), - ]; - - return prefix.includes('.') ? all : filterByPrefix(all, prefix); - } + const rangePartial: Range = { + start: textDocument.positionAt(replaceStartOffset), + end: position + }; - function getHover(params: GetHoverParams): HoverV2 { - const {textDocument, position, variables} = params; - const text = textDocument.getText(); + const all: CompletionItem[] = [ + ...functionCompletions(rangeFull), + ...constantCompletions(rangeFull), + ...keywordCompletions(rangeFull), + ...pathVariableCompletions(variables, prefix, rangePartial) + ]; - // Build spans once and reuse - const ts = makeTokenStream(parser, text); - const spans = iterateTokens(ts); + return prefix.includes('.') ? all : filterByPrefix(all, prefix); + } - const variableHover = tryVariableHoverUsingSpans(textDocument, position, variables, spans); - if (variableHover) { - return variableHover; - } + function getHover(params: GetHoverParams): HoverV2 { + const { textDocument, position, variables } = params; + const text = textDocument.getText(); - // Fallback to token-based hover + // Build spans once and reuse + const ts = makeTokenStream(parser, text); + const spans = iterateTokens(ts); - const offset = textDocument.offsetAt(position); - const span = spans.find(s => offset >= s.start && offset <= s.end); - if (!span) { - return {contents: {kind: "plaintext", value: ''}}; - } + const variableHover = tryVariableHoverUsingSpans(textDocument, position, variables, spans); + if (variableHover) { + return variableHover; + } - const token = span.token; - const label = String(token.value); - - if (token.type === TNAME || token.type === TKEYWORD) { - // Function hover - const func = allFunctions().find(f => f.name === label); - if (func) { - const range: Range = { - start: textDocument.positionAt(span.start), - end: textDocument.positionAt(span.end) - }; - const value = func.docs() ?? func.details(); - return { - contents: {kind: MarkupKind.Markdown, value}, - range - }; - } - - // Constant hover - if (allConstants().includes(label)) { - const v = parser.consts[label]; - const doc = constantDocs[label]; - const range: Range = { - start: textDocument.positionAt(span.start), - end: textDocument.positionAt(span.end) - }; - return { - contents: { - kind: MarkupKind.PlainText, - value: `${label}: ${valueTypeName(v)}${doc ? `\n\n${doc}` : ''}` - }, - range - }; - } - - // Keyword hover - if (token.type === TKEYWORD) { - const doc = BUILTIN_KEYWORD_DOCS[label]; - const range: Range = { - start: textDocument.positionAt(span.start), - end: textDocument.positionAt(span.end) - }; - return {contents: {kind: MarkupKind.PlainText, value: doc || 'keyword'}, range}; - } - } + // Fallback to token-based hover - // Operators: show a simple label - if (token.type === TOP) { - const range: Range = {start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end)}; - return {contents: {kind: MarkupKind.PlainText, value: `operator: ${label}`}, range}; - } + const offset = textDocument.offsetAt(position); + const span = spans.find(s => offset >= s.start && offset <= s.end); + if (!span) { + return { contents: { kind: 'plaintext', value: '' } }; + } - // Numbers/strings - if (token.type === TNUMBER || token.type === TSTRING) { - const range: Range = {start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end)}; - return {contents: {kind: MarkupKind.PlainText, value: `${valueTypeName(token.value)}`}, range}; - } + const token = span.token; + const label = String(token.value); - return {contents: {kind: "plaintext", value: ''}}; + if (token.type === TNAME || token.type === TKEYWORD) { + // Function hover + const func = allFunctions().find(f => f.name === label); + if (func) { + const range: Range = { + start: textDocument.positionAt(span.start), + end: textDocument.positionAt(span.end) + }; + const value = func.docs() ?? func.details(); + return { + contents: { kind: MarkupKind.Markdown, value }, + range + }; + } + + // Constant hover + if (allConstants().includes(label)) { + const v = parser.consts[label]; + const doc = constantDocs[label]; + const range: Range = { + start: textDocument.positionAt(span.start), + end: textDocument.positionAt(span.end) + }; + return { + contents: { + kind: MarkupKind.PlainText, + value: `${label}: ${valueTypeName(v)}${doc ? `\n\n${doc}` : ''}` + }, + range + }; + } + + // Keyword hover + if (token.type === TKEYWORD) { + const doc = BUILTIN_KEYWORD_DOCS[label]; + const range: Range = { + start: textDocument.positionAt(span.start), + end: textDocument.positionAt(span.end) + }; + return { contents: { kind: MarkupKind.PlainText, value: doc || 'keyword' }, range }; + } } - function getHighlighting(textDocument: TextDocument): HighlightToken[] { - const text = textDocument.getText(); - const tokenStream = makeTokenStream(parser, text); - const spans = iterateTokens(tokenStream); - return spans.map(span => ({ - type: tokenKindToHighlight(span.token), - start: span.start, - end: span.end, - value: span.token.value - })); + // Operators: show a simple label + if (token.type === TOP) { + const range: Range = { start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end) }; + return { contents: { kind: MarkupKind.PlainText, value: `operator: ${label}` }, range }; } - return { - getCompletions, - getHover, - getHighlighting - }; + // Numbers/strings + if (token.type === TNUMBER || token.type === TSTRING) { + const range: Range = { start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end) }; + return { contents: { kind: MarkupKind.PlainText, value: `${valueTypeName(token.value)}` }, range }; + } + return { contents: { kind: 'plaintext', value: '' } }; + } + + function getHighlighting(textDocument: TextDocument): HighlightToken[] { + const text = textDocument.getText(); + const tokenStream = makeTokenStream(parser, text); + const spans = iterateTokens(tokenStream); + return spans.map(span => ({ + type: tokenKindToHighlight(span.token), + start: span.start, + end: span.end, + value: span.token.value + })); + } + + return { + getCompletions, + getHover, + getHighlighting + }; } diff --git a/src/language-service/language-service.types.ts b/src/language-service/language-service.types.ts index 77000950..af29ef17 100644 --- a/src/language-service/language-service.types.ts +++ b/src/language-service/language-service.types.ts @@ -25,7 +25,6 @@ export interface LanguageServiceApi { getHighlighting(textDocument: TextDocument): HighlightToken[]; } - export interface HighlightToken { type: 'number' | 'string' | 'name' | 'keyword' | 'operator' | 'function' | 'punctuation'; start: number; diff --git a/src/language-service/ls-utils.ts b/src/language-service/ls-utils.ts index d3ff5937..f626fb36 100644 --- a/src/language-service/ls-utils.ts +++ b/src/language-service/ls-utils.ts @@ -1,73 +1,73 @@ import { Value } from '../types'; -import {Parser} from "../parsing/parser"; -import {TEOF, Token, TokenStream} from "../parsing"; +import { Parser } from '../parsing/parser'; +import { TEOF, Token, TokenStream } from '../parsing'; export function valueTypeName(value: Value): string { - const t = typeof value; - switch (true) { - case value === null: - return 'null'; - case Array.isArray(value): - return 'array'; - case t === 'function': - return 'function'; - case t === 'object': - return 'object'; - default: - return t as string; - } + const t = typeof value; + switch (true) { + case value === null: + return 'null'; + case Array.isArray(value): + return 'array'; + case t === 'function': + return 'function'; + case t === 'object': + return 'object'; + default: + return t as string; + } } export function isPathChar(ch: string): boolean { - return /[A-Za-z0-9_$.]/.test(ch); + return /[A-Za-z0-9_$.]/.test(ch); } export function extractPathPrefix(text: string, position: number): { start: number; prefix: string } { - const i = Math.max(0, Math.min(position, text.length)); - let start = i; - while (start > 0 && isPathChar(text[start - 1])) { - start--; - } - return { start, prefix: text.slice(start, i) }; + const i = Math.max(0, Math.min(position, text.length)); + let start = i; + while (start > 0 && isPathChar(text[start - 1])) { + start--; + } + return { start, prefix: text.slice(start, i) }; } export function toTruncatedJsonString(value: unknown, maxLines = 3, maxWidth = 50): string { - let text: string; - try { - text = JSON.stringify(value, null, 2) as string; - } catch { - return ''; - } - if (!text) { - return ''; - } - const lines: string[] = []; - for (let i = 0, lineAmount = 0; i < text.length && lineAmount < maxLines; i += maxWidth, lineAmount++) { - lines.push(text.slice(i, i + maxWidth)); - } - const maxChars = maxLines * maxWidth; - const exceededMaxLength = text.length > maxChars; - return exceededMaxLength ? lines.join('\n\n') + '...' : lines.join('\n\n'); + let text: string; + try { + text = JSON.stringify(value, null, 2) as string; + } catch { + return ''; + } + if (!text) { + return ''; + } + const lines: string[] = []; + for (let i = 0, lineAmount = 0; i < text.length && lineAmount < maxLines; i += maxWidth, lineAmount++) { + lines.push(text.slice(i, i + maxWidth)); + } + const maxChars = maxLines * maxWidth; + const exceededMaxLength = text.length > maxChars; + return exceededMaxLength ? lines.join('\n\n') + '...' : lines.join('\n\n'); } export function makeTokenStream(parser: Parser, text: string): TokenStream { - return new TokenStream(parser, text); + return new TokenStream(parser, text); } export function iterateTokens(ts: TokenStream, untilPos?: number): { token: Token; start: number; end: number }[] { - const spans: { token: Token; start: number; end: number }[] = []; - while (true) { - const t = ts.next(); - if (t.type === TEOF) { - break; - } - const start = t.index; - const end = ts.pos; // pos advanced to end of current token in TokenStream - spans.push({token: t, start, end}); - if (untilPos != null && end >= untilPos) { - // We can stop early if we reached the position - break; - } + const spans: { token: Token; start: number; end: number }[] = []; + while (true) { + const t = ts.next(); + if (t.type === TEOF) { + break; + } + const start = t.index; + const end = ts.pos; // pos advanced to end of current token in TokenStream + spans.push({ token: t, start, end }); + if (untilPos != null && end >= untilPos) { + // We can stop early if we reached the position + break; } - return spans; + } + return spans; } diff --git a/src/language-service/variable-utils.ts b/src/language-service/variable-utils.ts index 86309059..ccf7053e 100644 --- a/src/language-service/variable-utils.ts +++ b/src/language-service/variable-utils.ts @@ -1,48 +1,48 @@ -import {TextDocument} from 'vscode-languageserver-textdocument'; -import {Position, Range, MarkupKind, CompletionItem, CompletionItemKind} from 'vscode-languageserver-types'; -import {Values, Value, ValueObject} from '../types'; -import {TNAME, Token} from '../parsing'; -import {HoverV2} from './language-service.types'; -import {toTruncatedJsonString, valueTypeName} from './ls-utils'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { Position, Range, MarkupKind, CompletionItem, CompletionItemKind } from 'vscode-languageserver-types'; +import { Values, Value, ValueObject } from '../types'; +import { TNAME, Token } from '../parsing'; +import { HoverV2 } from './language-service.types'; +import { toTruncatedJsonString, valueTypeName } from './ls-utils'; type Span = { token: Token; start: number; end: number }; function isNameToken(t: Token): boolean { - return t.type === TNAME; + return t.type === TNAME; } function isDotToken(t: Token): boolean { - return t.value === '.'; + return t.value === '.'; } function findNameIndexAt(spans: Span[], offset: number): number | undefined { - const hitIndex = spans.findIndex(s => { - return offset >= s.start && offset <= s.end; - }); + const hitIndex = spans.findIndex(s => { + return offset >= s.start && offset <= s.end; + }); - if (hitIndex < 0) { - return undefined; - } + if (hitIndex < 0) { + return undefined; + } - const token = spans[hitIndex].token; + const token = spans[hitIndex].token; - if (isNameToken(token)) { - return hitIndex; - } + if (isNameToken(token)) { + return hitIndex; + } - if (isDotToken(token)) { - const right = hitIndex + 1; - if (isNameToken(spans[right]?.token)) { - return right; - } + if (isDotToken(token)) { + const right = hitIndex + 1; + if (isNameToken(spans[right]?.token)) { + return right; + } - const left = hitIndex - 1; - if (isNameToken(spans[left]?.token)) { - return left; - } + const left = hitIndex - 1; + if (isNameToken(spans[left]?.token)) { + return left; } + } - return undefined; + return undefined; } /** @@ -51,107 +51,107 @@ function findNameIndexAt(spans: Span[], offset: number): number | undefined { * @param cursorIndex The index of the center token. */ function extractPathFromSpans( - spans: Span[], - cursorIndex: number + spans: Span[], + cursorIndex: number ): { parts: string[]; firstIndex: number } | undefined { - if (!isNameToken(spans[cursorIndex].token)) { - return undefined; - } - - const leftParts: string[] = []; - let index = cursorIndex - 1; + if (!isNameToken(spans[cursorIndex].token)) { + return undefined; + } - while (index - 1 >= 0) { - const dot = spans[index]; - const name = spans[index - 1]; + const leftParts: string[] = []; + let index = cursorIndex - 1; - if (!isDotToken(dot.token) || !isNameToken(name.token)) { - break; - } + while (index - 1 >= 0) { + const dot = spans[index]; + const name = spans[index - 1]; - leftParts.unshift(String(name.token.value)); - index -= 2; + if (!isDotToken(dot.token) || !isNameToken(name.token)) { + break; } - const center = String(spans[cursorIndex].token.value); - const parts = [...leftParts, center]; + leftParts.unshift(String(name.token.value)); + index -= 2; + } + + const center = String(spans[cursorIndex].token.value); + const parts = [...leftParts, center]; - const firstIndex = cursorIndex - leftParts.length * 2; + const firstIndex = cursorIndex - leftParts.length * 2; - return {parts, firstIndex}; + return { parts, firstIndex }; } function resolveValueAtPath(vars: Values | undefined, parts: string[]): Value | undefined { - if (!vars) { - return undefined; - } + if (!vars) { + return undefined; + } - const isPlainObject = (v: unknown): v is Record => { - return v !== null && typeof v === 'object' && !Array.isArray(v); - }; + const isPlainObject = (v: unknown): v is Record => { + return v !== null && typeof v === 'object' && !Array.isArray(v); + }; - let node: Value = vars; + let node: Value = vars; - for (const segment of parts) { - if (!isPlainObject(node)) { - return undefined; - } - if (!Object.prototype.hasOwnProperty.call(node, segment)) { - return undefined; - } - node = (node as ValueObject)[segment]; + for (const segment of parts) { + if (!isPlainObject(node)) { + return undefined; } + if (!Object.prototype.hasOwnProperty.call(node, segment)) { + return undefined; + } + node = (node as ValueObject)[segment]; + } - return node; + return node; } class VarTrieNode { - children: Record = {}; - value: Value | undefined = undefined; + children: Record = {}; + value: Value | undefined = undefined; } class VarTrie { - root: VarTrieNode = new VarTrieNode(); + root: VarTrieNode = new VarTrieNode(); - private static isValueObject(v: Value): v is ValueObject { - return v !== null && typeof v === 'object' && !Array.isArray(v); - } + private static isValueObject(v: Value): v is ValueObject { + return v !== null && typeof v === 'object' && !Array.isArray(v); + } - buildFromValues(vars: Record): void { - const walk = (obj: Record, node: VarTrieNode) => { - for (const key of Object.keys(obj)) { - if (!node.children[key]) { - node.children[key] = new VarTrieNode(); - } + buildFromValues(vars: Record): void { + const walk = (obj: Record, node: VarTrieNode) => { + for (const key of Object.keys(obj)) { + if (!node.children[key]) { + node.children[key] = new VarTrieNode(); + } - const child = node.children[key]; - const val = obj[key] as Value; - child.value = val; + const child = node.children[key]; + const val = obj[key] as Value; + child.value = val; - if (VarTrie.isValueObject(val)) { - walk(val, child); - } - } - }; + if (VarTrie.isValueObject(val)) { + walk(val, child); + } + } + }; - walk(vars, this.root); - } + walk(vars, this.root); + } - search(path: string[]): VarTrieNode | undefined { - let node: VarTrieNode | undefined = this.root; - - for (const seg of path) { - if (!node) { - return undefined; - } - node = node.children[seg]; - if (!node) { - return undefined; - } - } + search(path: string[]): VarTrieNode | undefined { + let node: VarTrieNode | undefined = this.root; - return node; + for (const seg of path) { + if (!node) { + return undefined; + } + node = node.children[seg]; + if (!node) { + return undefined; + } } + + return node; + } } /** @@ -164,64 +164,64 @@ class VarTrie { * @privateRemarks Resolves everything to the left of the cursor. Hovering over a variable in the middle of a path will resolve it up until that point. */ export function tryVariableHoverUsingSpans( - textDocument: TextDocument, - position: Position, - variables: Values | undefined, - spans: Span[], + textDocument: TextDocument, + position: Position, + variables: Values | undefined, + spans: Span[] ): HoverV2 | undefined { - if (!variables) { - return undefined; - } + if (!variables) { + return undefined; + } - if (spans.length === 0) { - return undefined; - } + if (spans.length === 0) { + return undefined; + } - const offset = textDocument.offsetAt(position); - const nameIndex = findNameIndexAt(spans, offset); + const offset = textDocument.offsetAt(position); + const nameIndex = findNameIndexAt(spans, offset); - if (nameIndex == null) { - return undefined; - } + if (nameIndex == null) { + return undefined; + } - const extracted = extractPathFromSpans(spans, nameIndex); + const extracted = extractPathFromSpans(spans, nameIndex); - if (!extracted) { - return undefined; - } + if (!extracted) { + return undefined; + } - const {parts, firstIndex} = extracted; + const { parts, firstIndex } = extracted; - if (parts.length === 0) { - return undefined; - } + if (parts.length === 0) { + return undefined; + } - const leftCount = Math.max(0, Math.floor((nameIndex - firstIndex) / 2)); - const partsUpToHovered = parts.slice(0, leftCount + 1); + const leftCount = Math.max(0, Math.floor((nameIndex - firstIndex) / 2)); + const partsUpToHovered = parts.slice(0, leftCount + 1); - const value = resolveValueAtPath(variables, partsUpToHovered); + const value = resolveValueAtPath(variables, partsUpToHovered); - if (value === undefined) { - return undefined; - } + if (value === undefined) { + return undefined; + } - const span = spans[nameIndex]; - const range: Range = { - start: textDocument.positionAt(span.start), - end: textDocument.positionAt(span.end), - }; + const span = spans[nameIndex]; + const range: Range = { + start: textDocument.positionAt(span.start), + end: textDocument.positionAt(span.end) + }; - const fullPath = partsUpToHovered.join('.'); + const fullPath = partsUpToHovered.join('.'); - return { - contents: { - kind: MarkupKind.Markdown, - value: + return { + contents: { + kind: MarkupKind.Markdown, + value: `${fullPath}: Variable (${valueTypeName(value)})` + - `\n\n**Value Preview**\n\n${toTruncatedJsonString(value)}`, - }, - range, - }; + `\n\n**Value Preview**\n\n${toTruncatedJsonString(value)}` + }, + range + }; } /** @@ -232,50 +232,50 @@ export function tryVariableHoverUsingSpans( * @returns An array of completion items. */ export function pathVariableCompletions(vars: Values | undefined, prefix: string, rangePartial?: Range): CompletionItem[] { - if (!vars) { - return []; - } - - const trie = new VarTrie(); - trie.buildFromValues(vars as Record); + if (!vars) { + return []; + } - const lastDot = prefix.lastIndexOf('.'); - const endsWithDot = prefix.endsWith('.'); + const trie = new VarTrie(); + trie.buildFromValues(vars as Record); - const basePath = endsWithDot - ? prefix.slice(0, -1) - : lastDot >= 0 - ? prefix.slice(0, lastDot) - : ''; + const lastDot = prefix.lastIndexOf('.'); + const endsWithDot = prefix.endsWith('.'); - const baseParts = basePath ? basePath.split('.') : []; - const partial = endsWithDot ? '' : prefix.slice(lastDot + 1); - const lowerPartial = partial.toLowerCase(); + const basePath = endsWithDot + ? prefix.slice(0, -1) + : lastDot >= 0 + ? prefix.slice(0, lastDot) + : ''; - const baseNode = trie.search(baseParts); - if (!baseNode) { - return []; - } + const baseParts = basePath ? basePath.split('.') : []; + const partial = endsWithDot ? '' : prefix.slice(lastDot + 1); + const lowerPartial = partial.toLowerCase(); - const items: CompletionItem[] = []; + const baseNode = trie.search(baseParts); + if (!baseNode) { + return []; + } - for (const key of Object.keys(baseNode.children)) { - if (partial && !key.toLowerCase().startsWith(lowerPartial)) { - continue; - } + const items: CompletionItem[] = []; - const child = baseNode.children[key]; - const label = [...baseParts, key].join('.'); - const detail = child.value !== undefined ? valueTypeName(child.value) : 'object'; - - items.push({ - label, - kind: CompletionItemKind.Variable, - detail, - insertText: key, - textEdit: rangePartial ? {range: rangePartial, newText: key} : undefined, - }); + for (const key of Object.keys(baseNode.children)) { + if (partial && !key.toLowerCase().startsWith(lowerPartial)) { + continue; } - return items; + const child = baseNode.children[key]; + const label = [...baseParts, key].join('.'); + const detail = child.value !== undefined ? valueTypeName(child.value) : 'object'; + + items.push({ + label, + kind: CompletionItemKind.Variable, + detail, + insertText: key, + textEdit: rangePartial ? { range: rangePartial, newText: key } : undefined + }); + } + + return items; } From e44791cdf46365fd501abe2d5caf6f2ce656d9bf Mon Sep 17 00:00:00 2001 From: Melvin van Bree Date: Tue, 23 Dec 2025 11:10:29 +0100 Subject: [PATCH 07/12] Added test coverage --- test/language-service/variable-utils.spec.ts | 136 +++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 test/language-service/variable-utils.spec.ts diff --git a/test/language-service/variable-utils.spec.ts b/test/language-service/variable-utils.spec.ts new file mode 100644 index 00000000..cb6a570d --- /dev/null +++ b/test/language-service/variable-utils.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { pathVariableCompletions, tryVariableHoverUsingSpans } from '../../src/language-service/variable-utils'; +import { makeTokenStream, iterateTokens } from '../../src/language-service/ls-utils'; +import { Parser } from '../../src/parsing/parser'; + +describe('variable-utils', () => { + describe('pathVariableCompletions', () => { + it('returns empty when vars undefined', () => { + const items = pathVariableCompletions(undefined, 'a'); + expect(items).toEqual([]); + }); + + it('returns empty when base path missing', () => { + const items = pathVariableCompletions({ a: { b: 1 } }, 'x.'); + expect(items).toEqual([]); + }); + + it('lists children when ends with dot and sets insertText and detail', () => { + const items = pathVariableCompletions({ foo: { bar: 1, Baz: 'x' } }, 'foo.'); + const labels = items.map(i => i.label); + expect(labels).toContain('foo.bar'); + expect(labels).toContain('foo.Baz'); + const bar = items.find(i => i.label === 'foo.bar'); + expect(bar?.insertText).toBe('bar'); + expect(bar?.detail).toBe('number'); + }); + + it('filters by partial (case-insensitive startsWith)', () => { + const items = pathVariableCompletions({ user: { first: 'a', last: 'b' } }, 'user.f'); + expect(items.length).toBe(1); + expect(items[0].label).toBe('user.first'); + const items2 = pathVariableCompletions({ user: { First: 'a', last: 'b' } }, 'user.f'); + expect(items2.length).toBe(1); + expect(items2[0].label).toBe('user.First'); + }); + + it('applies rangePartial in textEdit when provided', () => { + const rangePartial = { + start: { line: 0, character: 5 }, + end: { line: 0, character: 6 } + }; + const items = pathVariableCompletions({ user: { age: 21 } }, 'user.a', rangePartial); + expect(items[0].textEdit).toBeDefined(); + expect((items[0].textEdit as any).range).toEqual(rangePartial); + expect((items[0].textEdit as any).newText).toBe('age'); + }); + }); + + describe('tryVariableHoverUsingSpans', () => { + const parser = new Parser(); + + it('returns undefined without variables', () => { + const doc = TextDocument.create('file://test', 'plaintext', 1, 'x'); + const spans = iterateTokens(makeTokenStream(parser, 'x')); + const hover = tryVariableHoverUsingSpans(doc, { line: 0, character: 0 }, undefined, spans); + expect(hover).toBeUndefined(); + }); + + it('hovers last segment value', () => { + const text = '$test.person.age'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const spans = iterateTokens(makeTokenStream(parser, text)); + const pos = { line: 0, character: text.length }; // after age + const hover = tryVariableHoverUsingSpans(doc, pos, { $test: { person: { age: 21 } } }, spans)!; + expect(hover).toBeDefined(); + const value = (hover.contents as any).value as string; + expect(value).toContain('age'); + expect(value).toContain('number'); + expect(value).toContain('Value Preview'); + }); + + it('hovers middle segment object', () => { + const text = '$test.person.age'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const spans = iterateTokens(makeTokenStream(parser, text)); + const charIndex = text.indexOf('person') + 1; + const pos = { line: 0, character: charIndex }; + const hover = tryVariableHoverUsingSpans(doc, pos, { $test: { person: { age: 21, name: 'x' } } }, spans)!; + const value = (hover.contents as any).value as string; + expect(value).toContain('$test.person'); + expect(value).toContain('object'); + }); + + it('returns undefined when not on a name or dot', () => { + const text = ' $x'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const spans = iterateTokens(makeTokenStream(parser, text)); + const hover = tryVariableHoverUsingSpans(doc, { line: 0, character: 0 }, { $x: 1 }, spans); + expect(hover).toBeUndefined(); + }); + + + it('on trailing dot resolves to the left name', () => { + const text = '$a.'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const spans = iterateTokens(makeTokenStream(parser, text)); + const hover = tryVariableHoverUsingSpans(doc, { line: 0, character: text.length - 1 }, { $a: { b: 1 } }, spans)!; + const value = (hover.contents as any).value as string; + expect(value).toContain('$a'); + expect(value).toContain('object'); + }); + + it('returns undefined when path segment not found', () => { + const text = '$a.missing'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const spans = iterateTokens(makeTokenStream(parser, text)); + const pos = { line: 0, character: text.indexOf('missing') + 1 }; + const hover = tryVariableHoverUsingSpans(doc, pos, { $a: {} }, spans); + expect(hover).toBeUndefined(); + }); + + it('range equals hovered segment span', () => { + const text = '$user.name'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const spans = iterateTokens(makeTokenStream(parser, text)); + const nameStart = text.indexOf('name'); + const pos = { line: 0, character: nameStart + 1 }; + const hover = tryVariableHoverUsingSpans(doc, pos, { $user: { name: 'Ann' } }, spans)!; + expect(hover.range).toBeDefined(); + const range = hover.range!; + expect(range.start.line).toBe(0); + expect(range.end.line).toBe(0); + // The hovered token span should cover exactly 'name' + expect(range.end.character - range.start.character).toBe(4); + }); + }); + + describe('pathVariableCompletions edge cases', () => { + it('uses detail "object" when child.value is undefined', () => { + const items = pathVariableCompletions({ a: { b: undefined as any } }, 'a.'); + const b = items.find(i => i.label === 'a.b'); + expect(b?.detail).toBe('object'); + }); + }); +}); From 74ecc22315e8a3224206d129ee9ece8cc2a4ccb9 Mon Sep 17 00:00:00 2001 From: Melvin van Bree Date: Wed, 24 Dec 2025 08:52:13 +0100 Subject: [PATCH 08/12] Removed property that was not required --- vitest.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index c9864880..9c713dcb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,7 +15,9 @@ export default defineConfig({ '**/*.d.ts', 'tree-shake-test.mjs', 'vite.config.ts', - 'vitest.config.ts' + 'vitest.config.ts', + // type-only file — exclude from coverage + 'src/language-service/language-service.types.ts' ], thresholds: { statements: 80, From eee854adf3d1c3bb7d42b018e7ea44b9e62c8d9b Mon Sep 17 00:00:00 2001 From: Melvin van Bree Date: Wed, 24 Dec 2025 08:52:24 +0100 Subject: [PATCH 09/12] Added comment --- src/language-service/variable-utils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/language-service/variable-utils.ts b/src/language-service/variable-utils.ts index ccf7053e..32ef193d 100644 --- a/src/language-service/variable-utils.ts +++ b/src/language-service/variable-utils.ts @@ -15,6 +15,11 @@ function isDotToken(t: Token): boolean { return t.value === '.'; } +/** + * Finds the name index at the given offset. will look to the right and left of the offset, and return the first name token found. + * @param spans The token spans. + * @param offset The offset to search for. + */ function findNameIndexAt(spans: Span[], offset: number): number | undefined { const hitIndex = spans.findIndex(s => { return offset >= s.start && offset <= s.end; From 6a04d1c22b11ee78ac006f09f1f3be142186e3a9 Mon Sep 17 00:00:00 2001 From: Melvin van Bree Date: Wed, 24 Dec 2025 08:56:49 +0100 Subject: [PATCH 10/12] Added samples to exclude --- vitest.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index 9c713dcb..067a2c16 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,7 +17,8 @@ export default defineConfig({ 'vite.config.ts', 'vitest.config.ts', // type-only file — exclude from coverage - 'src/language-service/language-service.types.ts' + 'src/language-service/language-service.types.ts', + 'samples/**' ], thresholds: { statements: 80, From b9bf9204f708ed9c82832015c371b1d365058225 Mon Sep 17 00:00:00 2001 From: Melvin van Bree Date: Wed, 24 Dec 2025 09:00:04 +0100 Subject: [PATCH 11/12] Added eslint config to exclude --- vitest.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index 067a2c16..e70d8df3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,7 +18,8 @@ export default defineConfig({ 'vitest.config.ts', // type-only file — exclude from coverage 'src/language-service/language-service.types.ts', - 'samples/**' + 'samples/**', + 'eslint.config.js' ], thresholds: { statements: 80, From 2625bf49296156a2c1d5c3890d2cbe8204a13f2e Mon Sep 17 00:00:00 2001 From: Melvin van Bree Date: Mon, 29 Dec 2025 14:49:14 +0100 Subject: [PATCH 12/12] Processed feedback --- src/language-service/language-service.ts | 4 ++-- src/language-service/variable-utils.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/language-service/language-service.ts b/src/language-service/language-service.ts index 7b1655d3..607817e9 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -175,7 +175,7 @@ export function createLanguageService(options: LanguageServiceOptions | undefine const offset = textDocument.offsetAt(position); const span = spans.find(s => offset >= s.start && offset <= s.end); if (!span) { - return { contents: { kind: 'plaintext', value: '' } }; + return { contents: { kind: MarkupKind.PlainText, value: '' } }; } const token = span.token; @@ -236,7 +236,7 @@ export function createLanguageService(options: LanguageServiceOptions | undefine return { contents: { kind: MarkupKind.PlainText, value: `${valueTypeName(token.value)}` }, range }; } - return { contents: { kind: 'plaintext', value: '' } }; + return { contents: { kind: MarkupKind.PlainText, value: '' } }; } function getHighlighting(textDocument: TextDocument): HighlightToken[] { diff --git a/src/language-service/variable-utils.ts b/src/language-service/variable-utils.ts index 32ef193d..0b5e7e72 100644 --- a/src/language-service/variable-utils.ts +++ b/src/language-service/variable-utils.ts @@ -86,25 +86,25 @@ function extractPathFromSpans( return { parts, firstIndex }; } -function resolveValueAtPath(vars: Values | undefined, parts: string[]): Value | undefined { +function resolveValueAtPath(vars: Values | undefined, parts: string[]): Value { if (!vars) { return undefined; } const isPlainObject = (v: unknown): v is Record => { - return v !== null && typeof v === 'object' && !Array.isArray(v); + return v !== null && typeof v === 'object'; }; let node: Value = vars; - for (const segment of parts) { + for (const part of parts) { if (!isPlainObject(node)) { - return undefined; + return node; // Just return the value if it's not an object } - if (!Object.prototype.hasOwnProperty.call(node, segment)) { + if (!Object.prototype.hasOwnProperty.call(node, part)) { return undefined; } - node = (node as ValueObject)[segment]; + node = (node as ValueObject)[part]; } return node;