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..8cabe4bc 100644 --- a/src/language-service/language-service.documentation.ts +++ b/src/language-service/language-service.documentation.ts @@ -1,60 +1,228 @@ // 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; + isVariadic?: boolean; +} + +export interface FunctionDoc { + name: string; + description: string; + params?: FunctionParamDoc[]; +} + +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.' } + ] + }, + /** + * 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.', - 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 new file mode 100644 index 00000000..273f8dcb --- /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) { + 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) { + 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) { + 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..607817e9 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -2,312 +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, - TokenStream + TOP, + TNUMBER, + TSTRING, + TPAREN, + TBRACKET, + TCOMMA, + TNAME, + TSEMICOLON, + TKEYWORD, + TBRACE, + Token } 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 { TextDocument } from 'vscode-languageserver-textdocument' -import {BUILTIN_FUNCTION_DOCS, BUILTIN_KEYWORD_DOCS, DEFAULT_CONSTANT_DOCS} from './language-service.documentation'; - -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 isWordChar(ch: string): boolean { - return /[A-Za-z0-9_$]/.test(ch); -} - -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; - } - let start = i; - while (start > 0 && isWordChar(text[start - 1])) { - start--; - } - return {start, prefix: text.slice(start, position)}; -} - -function makeTokenStream(parser: Parser, text: string): TokenStream { - return new TokenStream(parser, text); -} +import { Parser } from '../parsing/parser'; +import type { + 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 { + valueTypeName, + extractPathPrefix, + makeTokenStream, + iterateTokens +} from './ls-utils'; +import { pathVariableCompletions, tryVariableHoverUsingSpans } from './variable-utils'; -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 as any).index as number; - 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; +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 spans; -} -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[] { - // 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])); + return 'name'; + } } - - function allConstants(): string[] { - return parser.consts ? Object.keys(parser.consts) : []; + } + + 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; } + 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 + }; - 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.includes(String(t.value))) { - return 'function'; - } - - return 'name'; - } - } - } + const all: CompletionItem[] = [ + ...functionCompletions(rangeFull), + ...constantCompletions(rangeFull), + ...keywordCompletions(rangeFull), + ...pathVariableCompletions(variables, prefix, rangePartial) + ]; - 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}(…)`; - } + return prefix.includes('.') ? all : filterByPrefix(all, prefix); + } - 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 getHover(params: GetHoverParams): HoverV2 { + const { textDocument, position, variables } = params; + const text = textDocument.getText(); - 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 - })); - } + // Build spans once and reuse + const ts = makeTokenStream(parser, text); + const spans = iterateTokens(ts); - function functionCompletions(): CompletionItem[] { - return allFunctions().map(name => ({ - label: name, - kind: CompletionItemKind.Function, - detail: buildFunctionDetail(name), - documentation: buildFunctionDoc(name), - insertText: `${name}()` - })); + const variableHover = tryVariableHoverUsingSpans(textDocument, position, variables, spans); + if (variableHover) { + return variableHover; } - function constantCompletions(): CompletionItem[] { - return allConstants().map(name => ({ - label: name, - kind: CompletionItemKind.Constant, - detail: valueTypeName(parser.consts[name]), - documentation: constantDocs[name] - })); - } + // Fallback to token-based hover - function keywordCompletions(): CompletionItem[] { - return (parser.keywords || []).map(keyword => ({ - label: keyword, - kind: CompletionItemKind.Keyword, - detail: 'keyword', - documentation: BUILTIN_KEYWORD_DOCS[keyword] - })); + const offset = textDocument.offsetAt(position); + const span = spans.find(s => offset >= s.start && offset <= s.end); + if (!span) { + return { contents: { kind: MarkupKind.PlainText, value: '' } }; } - function filterByPrefix(items: CompletionItem[], prefix: string): CompletionItem[] { - if (!prefix) { - return items; - } - const lower = prefix.toLowerCase(); - return items.filter(i => i.label.toLowerCase().startsWith(lower)); + 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 }; + } } - function getCompletions(params: GetCompletionsParams): CompletionItem[] { - const { textDocument, variables, position } = params; - const text = textDocument.getText(); - const pos = textDocument.offsetAt(position); - - const {start, prefix} = extractPrefix(text, pos); - - // 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 - } - - const all: CompletionItem[] = [ - ...functionCompletions(), - ...constantCompletions(), - ...keywordCompletions(), - ...variableCompletions(variables) - ]; - - return filterByPrefix(all, prefix); + // 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 }; } - function getHover(params: GetHoverParams): Hover { - const { textDocument, position, variables } = params; - const text = textDocument.getText(); - 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: ''}; - } - - 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 range: Range = { start: textDocument.positionAt(span.start), end: textDocument.positionAt(span.end) }; - const value = doc ? `**${detail}**\n\n${doc}` : detail; - 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 }; - } - } - - // 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}; - } - - // 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: ''}; + // 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 }; } - 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 as any - })); - } + return { contents: { kind: MarkupKind.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 + }; - return { - getCompletions, - getHover, - getHighlighting - }; } diff --git a/src/language-service/language-service.types.ts b/src/language-service/language-service.types.ts index 29d017d9..af29ef17 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. @@ -25,7 +25,6 @@ export interface LanguageServiceApi { getHighlighting(textDocument: TextDocument): HighlightToken[]; } - export interface HighlightToken { type: 'number' | 'string' | 'name' | 'keyword' | 'operator' | 'function' | 'punctuation'; start: number; @@ -50,3 +49,7 @@ export interface GetHoverParams { position: Position; variables?: Values; } + +export interface HoverV2 extends Hover { + contents: MarkupContent; // Type narrowing since we know we are not going to return deprecated content +} diff --git a/src/language-service/ls-utils.ts b/src/language-service/ls-utils.ts new file mode 100644 index 00000000..f626fb36 --- /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/variable-utils.ts b/src/language-service/variable-utils.ts new file mode 100644 index 00000000..0b5e7e72 --- /dev/null +++ b/src/language-service/variable-utils.ts @@ -0,0 +1,286 @@ +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 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; + }); + + if (hitIndex < 0) { + return undefined; + } + + const token = spans[hitIndex].token; + + if (isNameToken(token)) { + return hitIndex; + } + + 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; + } + } + + 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 } | 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; + + return { parts, firstIndex }; +} + +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'; + }; + + let node: Value = vars; + + for (const part of parts) { + if (!isPlainObject(node)) { + return node; // Just return the value if it's not an object + } + if (!Object.prototype.hasOwnProperty.call(node, part)) { + return undefined; + } + node = (node as ValueObject)[part]; + } + + 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[key] as Value; + child.value = val; + + if (VarTrie.isValueObject(val)) { + walk(val, child); + } + } + }; + + 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; + } + } + + 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 span = spans[nameIndex]; + const range: Range = { + start: textDocument.positionAt(span.start), + end: textDocument.positionAt(span.end) + }; + + const fullPath = partsUpToHovered.join('.'); + + return { + contents: { + kind: MarkupKind.Markdown, + value: + `${fullPath}: Variable (${valueTypeName(value)})` + + `\n\n**Value Preview**\n\n${toTruncatedJsonString(value)}` + }, + range + }; +} + +/** + * 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 []; + } + + const trie = new VarTrie(); + trie.buildFromValues(vars as Record); + + const lastDot = prefix.lastIndexOf('.'); + const endsWithDot = prefix.endsWith('.'); + + 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 []; + } + + 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, + insertText: key, + textEdit: rangePartial ? { range: rangePartial, newText: key } : undefined + }); + } + + return items; +} 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'); }); }); 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); + }); +}); 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'); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c9864880..e70d8df3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,7 +15,11 @@ 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', + 'samples/**', + 'eslint.config.js' ], thresholds: { statements: 80,