Skip to content

Commit 93e47f1

Browse files
ChocoMelvinMelvin van BreeSander Toonen
authored
Enables array completion with bracket notation (#9)
* Added tests the auto completion should adhere to # Conflicts: # test/language-service/language-service.ts * Array completion working * Formatted * Reverted spacing between braces * Fixed properly with linter fix instead * Raised package version * Update lock file --------- Co-authored-by: Melvin van Bree <m.vanbree@pro-fa.com> Co-authored-by: Sander Toonen <s.toonen@pro-fa.com>
1 parent 2b3a3bf commit 93e47f1

6 files changed

Lines changed: 178 additions & 14 deletions

File tree

.editorconfig

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ end_of_line = lf
66
charset = utf-8
77
insert_final_newline = true
88
trim_trailing_whitespace = true
9-
9+
indent_style = space
10+
indent_size = 2

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pro-fa/expr-eval",
3-
"version": "6.0.0",
3+
"version": "6.0.1",
44
"description": "Mathematical expression evaluator",
55
"keywords": [
66
"expression",

src/language-service/ls-utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export function valueTypeName(value: Value): string {
1919
}
2020

2121
export function isPathChar(ch: string): boolean {
22-
return /[A-Za-z0-9_$.]/.test(ch);
22+
// Include square brackets to keep array selectors within the detected prefix
23+
return /[A-Za-z0-9_$.\[\]]/.test(ch);
2324
}
2425

2526
export function extractPathPrefix(text: string, position: number): { start: number; prefix: string } {

src/language-service/variable-utils.ts

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { TextDocument } from 'vscode-languageserver-textdocument';
2-
import { Position, Range, MarkupKind, CompletionItem, CompletionItemKind } from 'vscode-languageserver-types';
2+
import { Position, Range, MarkupKind, CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver-types';
33
import { Values, Value, ValueObject } from '../types';
44
import { TNAME, Token } from '../parsing';
55
import { HoverV2 } from './language-service.types';
@@ -159,6 +159,72 @@ class VarTrie {
159159
}
160160
}
161161

162+
/**
163+
* Resolve value by a mixed dot/bracket path like foo[0][1].bar starting from a given root.
164+
* For arrays, when an index is accessed, we treat it as the element shape and use the first element if present.
165+
*/
166+
function resolveValueByBracketPath(root: unknown, path: string): unknown {
167+
const isObj = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object';
168+
let node: unknown = root as unknown;
169+
if (!path) return node;
170+
const segments = path.split('.');
171+
for (const seg of segments) {
172+
if (!isObj(node)) return undefined;
173+
// parse leading name and bracket chains
174+
const i = seg.indexOf('[');
175+
const name = i >= 0 ? seg.slice(0, i) : seg;
176+
let rest = i >= 0 ? seg.slice(i) : '';
177+
if (name) {
178+
node = (node as Record<string, unknown>)[name];
179+
}
180+
// walk bracket chains, treat any index as the element shape (use first element)
181+
while (rest.startsWith('[')) {
182+
const closeIdx = rest.indexOf(']');
183+
if (closeIdx < 0) break; // malformed, stop here
184+
rest = rest.slice(closeIdx + 1);
185+
if (Array.isArray(node)) {
186+
node = node.length > 0 ? node[0] : undefined;
187+
} else {
188+
node = undefined;
189+
}
190+
}
191+
}
192+
return node;
193+
}
194+
195+
/**
196+
* Pushes standard key completion and (if applicable) an array selector snippet completion.
197+
*/
198+
function pushVarKeyCompletions(
199+
items: CompletionItem[],
200+
key: string,
201+
label: string,
202+
detail: string,
203+
val: unknown,
204+
rangePartial?: Range
205+
): void {
206+
// Regular key/variable completion
207+
items.push({
208+
label,
209+
kind: CompletionItemKind.Variable,
210+
detail,
211+
insertText: key,
212+
textEdit: rangePartial ? { range: rangePartial, newText: key } : undefined
213+
});
214+
215+
// If the value is an array, suggest selector snippet as an extra item
216+
if (Array.isArray(val)) {
217+
const snippet = key + '[${1}]';
218+
items.push({
219+
label: `${label}[]`,
220+
kind: CompletionItemKind.Variable,
221+
detail: 'array',
222+
insertTextFormat: InsertTextFormat.Snippet,
223+
textEdit: rangePartial ? { range: rangePartial, newText: snippet } : undefined
224+
});
225+
}
226+
}
227+
162228
/**
163229
* Tries to resolve a variable hover using spans.
164230
* @param textDocument The document containing the variable name.
@@ -257,6 +323,27 @@ export function pathVariableCompletions(vars: Values | undefined, prefix: string
257323
const partial = endsWithDot ? '' : prefix.slice(lastDot + 1);
258324
const lowerPartial = partial.toLowerCase();
259325

326+
// If there are bracket selectors anywhere in the basePath, use bracket-aware resolution
327+
if (basePath.includes('[')) {
328+
const baseValue = resolveValueByBracketPath(vars, basePath);
329+
const items: CompletionItem[] = [];
330+
331+
// If the baseValue is an object, offer its keys
332+
if (baseValue && typeof baseValue === 'object' && !Array.isArray(baseValue)) {
333+
const obj = baseValue as Record<string, unknown>;
334+
for (const key of Object.keys(obj)) {
335+
if (partial && !key.toLowerCase().startsWith(lowerPartial)) continue;
336+
const fullLabel = basePath ? `${basePath}.${key}` : key;
337+
const val = obj[key] as Value;
338+
const detail = valueTypeName(val);
339+
pushVarKeyCompletions(items, key, fullLabel, detail, val, rangePartial);
340+
}
341+
}
342+
343+
return items;
344+
}
345+
346+
// Dot-only path: use trie for speed and existing behavior
260347
const baseNode = trie.search(baseParts);
261348
if (!baseNode) {
262349
return [];
@@ -272,14 +359,7 @@ export function pathVariableCompletions(vars: Values | undefined, prefix: string
272359
const child = baseNode.children[key];
273360
const label = [...baseParts, key].join('.');
274361
const detail = child.value !== undefined ? valueTypeName(child.value) : 'object';
275-
276-
items.push({
277-
label,
278-
kind: CompletionItemKind.Variable,
279-
detail,
280-
insertText: key,
281-
textEdit: rangePartial ? { range: rangePartial, newText: key } : undefined
282-
});
362+
pushVarKeyCompletions(items, key, label, detail, child.value, rangePartial);
283363
}
284364

285365
return items;

test/language-service/language-service.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,88 @@ describe('Language Service', () => {
205205
expect(completions.find(c => c.label === 'boolVar')?.detail).toBe('boolean');
206206
expect(completions.find(c => c.label === 'nullVar')?.detail).toBe('null');
207207
});
208+
209+
it('should suggest array selector when variable is an array', () => {
210+
const text = 'arr';
211+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
212+
213+
const completions = ls.getCompletions({
214+
textDocument: doc,
215+
variables: {
216+
arr: [10, 20, 30]
217+
},
218+
position: { line: 0, character: 3 }
219+
});
220+
221+
const arrayItem = completions.find(c => c.label === 'arr[]');
222+
expect(arrayItem).toBeDefined();
223+
224+
// Insert only the selector
225+
expect(arrayItem?.insertTextFormat).toBe(2); // Snippet
226+
expect(arrayItem?.textEdit?.newText).toContain('arr[');
227+
});
228+
229+
it('should autocomplete children after indexed array access', () => {
230+
const text = 'arr[0].';
231+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
232+
233+
const completions = ls.getCompletions({
234+
textDocument: doc,
235+
variables: {
236+
arr: [
237+
{ foo: 1, bar: 2 }
238+
]
239+
},
240+
position: { line: 0, character: text.length }
241+
});
242+
243+
expect(completions.length).toBeGreaterThan(0);
244+
245+
const fooItem = completions.find(c => c.label === 'arr[0].foo');
246+
expect(fooItem).toBeDefined();
247+
expect(fooItem?.insertText).toBe('foo');
248+
});
249+
250+
it('should support multi-dimensional array selectors', () => {
251+
const text = 'matrix[0][1].';
252+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
253+
254+
const completions = ls.getCompletions({
255+
textDocument: doc,
256+
variables: {
257+
matrix: [
258+
[
259+
{ value: 42 }
260+
]
261+
]
262+
},
263+
position: { line: 0, character: text.length }
264+
});
265+
266+
const valueItem = completions.find(c => c.label === 'matrix[0][1].value');
267+
expect(valueItem).toBeDefined();
268+
expect(valueItem?.insertText).toBe('value');
269+
});
270+
271+
it('should place cursor inside array brackets', () => {
272+
const text = 'arr';
273+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
274+
275+
const completions = ls.getCompletions({
276+
textDocument: doc,
277+
variables: {
278+
arr: [1, 2, 3]
279+
},
280+
position: { line: 0, character: 3 }
281+
});
282+
283+
const arrayItem = completions.find(c => c.label === 'arr[]');
284+
const newText = arrayItem?.textEdit?.newText as string | undefined;
285+
286+
expect(newText).toContain('[');
287+
expect(newText).toContain(']');
288+
expect(newText).toContain('${1}');
289+
});
208290
});
209291

210292
describe('getHover', () => {

0 commit comments

Comments
 (0)