diff --git a/.editorconfig b/.editorconfig index 70746bc..0878863 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,4 +6,5 @@ end_of_line = lf charset = utf-8 insert_final_newline = true trim_trailing_whitespace = true - +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8cbff0a..8c136e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pro-fa/expr-eval", - "version": "6.0.0", + "version": "6.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pro-fa/expr-eval", - "version": "6.0.0", + "version": "6.0.1", "license": "MIT", "dependencies": { "vscode-languageserver-textdocument": "^1.0.12" diff --git a/package.json b/package.json index 3e4fd81..696e768 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pro-fa/expr-eval", - "version": "6.0.0", + "version": "6.0.1", "description": "Mathematical expression evaluator", "keywords": [ "expression", diff --git a/src/language-service/ls-utils.ts b/src/language-service/ls-utils.ts index f626fb3..bcab320 100644 --- a/src/language-service/ls-utils.ts +++ b/src/language-service/ls-utils.ts @@ -19,7 +19,8 @@ export function valueTypeName(value: Value): string { } export function isPathChar(ch: string): boolean { - return /[A-Za-z0-9_$.]/.test(ch); + // Include square brackets to keep array selectors within the detected prefix + return /[A-Za-z0-9_$.\[\]]/.test(ch); } export function extractPathPrefix(text: string, position: number): { start: number; prefix: string } { diff --git a/src/language-service/variable-utils.ts b/src/language-service/variable-utils.ts index 0b5e7e7..31609fe 100644 --- a/src/language-service/variable-utils.ts +++ b/src/language-service/variable-utils.ts @@ -1,5 +1,5 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; -import { Position, Range, MarkupKind, CompletionItem, CompletionItemKind } from 'vscode-languageserver-types'; +import { Position, Range, MarkupKind, CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver-types'; import { Values, Value, ValueObject } from '../types'; import { TNAME, Token } from '../parsing'; import { HoverV2 } from './language-service.types'; @@ -159,6 +159,72 @@ class VarTrie { } } +/** + * Resolve value by a mixed dot/bracket path like foo[0][1].bar starting from a given root. + * For arrays, when an index is accessed, we treat it as the element shape and use the first element if present. + */ +function resolveValueByBracketPath(root: unknown, path: string): unknown { + const isObj = (v: unknown): v is Record => v !== null && typeof v === 'object'; + let node: unknown = root as unknown; + if (!path) return node; + const segments = path.split('.'); + for (const seg of segments) { + if (!isObj(node)) return undefined; + // parse leading name and bracket chains + const i = seg.indexOf('['); + const name = i >= 0 ? seg.slice(0, i) : seg; + let rest = i >= 0 ? seg.slice(i) : ''; + if (name) { + node = (node as Record)[name]; + } + // walk bracket chains, treat any index as the element shape (use first element) + while (rest.startsWith('[')) { + const closeIdx = rest.indexOf(']'); + if (closeIdx < 0) break; // malformed, stop here + rest = rest.slice(closeIdx + 1); + if (Array.isArray(node)) { + node = node.length > 0 ? node[0] : undefined; + } else { + node = undefined; + } + } + } + return node; +} + +/** + * Pushes standard key completion and (if applicable) an array selector snippet completion. + */ +function pushVarKeyCompletions( + items: CompletionItem[], + key: string, + label: string, + detail: string, + val: unknown, + rangePartial?: Range +): void { + // Regular key/variable completion + items.push({ + label, + kind: CompletionItemKind.Variable, + detail, + insertText: key, + textEdit: rangePartial ? { range: rangePartial, newText: key } : undefined + }); + + // If the value is an array, suggest selector snippet as an extra item + if (Array.isArray(val)) { + const snippet = key + '[${1}]'; + items.push({ + label: `${label}[]`, + kind: CompletionItemKind.Variable, + detail: 'array', + insertTextFormat: InsertTextFormat.Snippet, + textEdit: rangePartial ? { range: rangePartial, newText: snippet } : undefined + }); + } +} + /** * Tries to resolve a variable hover using spans. * @param textDocument The document containing the variable name. @@ -257,6 +323,27 @@ export function pathVariableCompletions(vars: Values | undefined, prefix: string const partial = endsWithDot ? '' : prefix.slice(lastDot + 1); const lowerPartial = partial.toLowerCase(); + // If there are bracket selectors anywhere in the basePath, use bracket-aware resolution + if (basePath.includes('[')) { + const baseValue = resolveValueByBracketPath(vars, basePath); + const items: CompletionItem[] = []; + + // If the baseValue is an object, offer its keys + if (baseValue && typeof baseValue === 'object' && !Array.isArray(baseValue)) { + const obj = baseValue as Record; + for (const key of Object.keys(obj)) { + if (partial && !key.toLowerCase().startsWith(lowerPartial)) continue; + const fullLabel = basePath ? `${basePath}.${key}` : key; + const val = obj[key] as Value; + const detail = valueTypeName(val); + pushVarKeyCompletions(items, key, fullLabel, detail, val, rangePartial); + } + } + + return items; + } + + // Dot-only path: use trie for speed and existing behavior const baseNode = trie.search(baseParts); if (!baseNode) { return []; @@ -272,14 +359,7 @@ export function pathVariableCompletions(vars: Values | undefined, prefix: string 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 - }); + pushVarKeyCompletions(items, key, label, detail, child.value, rangePartial); } return items; diff --git a/test/language-service/language-service.ts b/test/language-service/language-service.ts index 8787b9f..27bf590 100644 --- a/test/language-service/language-service.ts +++ b/test/language-service/language-service.ts @@ -205,6 +205,88 @@ describe('Language Service', () => { expect(completions.find(c => c.label === 'boolVar')?.detail).toBe('boolean'); expect(completions.find(c => c.label === 'nullVar')?.detail).toBe('null'); }); + + it('should suggest array selector when variable is an array', () => { + const text = 'arr'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + + const completions = ls.getCompletions({ + textDocument: doc, + variables: { + arr: [10, 20, 30] + }, + position: { line: 0, character: 3 } + }); + + const arrayItem = completions.find(c => c.label === 'arr[]'); + expect(arrayItem).toBeDefined(); + + // Insert only the selector + expect(arrayItem?.insertTextFormat).toBe(2); // Snippet + expect(arrayItem?.textEdit?.newText).toContain('arr['); + }); + + it('should autocomplete children after indexed array access', () => { + const text = 'arr[0].'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + + const completions = ls.getCompletions({ + textDocument: doc, + variables: { + arr: [ + { foo: 1, bar: 2 } + ] + }, + position: { line: 0, character: text.length } + }); + + expect(completions.length).toBeGreaterThan(0); + + const fooItem = completions.find(c => c.label === 'arr[0].foo'); + expect(fooItem).toBeDefined(); + expect(fooItem?.insertText).toBe('foo'); + }); + + it('should support multi-dimensional array selectors', () => { + const text = 'matrix[0][1].'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + + const completions = ls.getCompletions({ + textDocument: doc, + variables: { + matrix: [ + [ + { value: 42 } + ] + ] + }, + position: { line: 0, character: text.length } + }); + + const valueItem = completions.find(c => c.label === 'matrix[0][1].value'); + expect(valueItem).toBeDefined(); + expect(valueItem?.insertText).toBe('value'); + }); + + it('should place cursor inside array brackets', () => { + const text = 'arr'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + + const completions = ls.getCompletions({ + textDocument: doc, + variables: { + arr: [1, 2, 3] + }, + position: { line: 0, character: 3 } + }); + + const arrayItem = completions.find(c => c.label === 'arr[]'); + const newText = arrayItem?.textEdit?.newText as string | undefined; + + expect(newText).toContain('['); + expect(newText).toContain(']'); + expect(newText).toContain('${1}'); + }); }); describe('getHover', () => {