Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pro-fa/expr-eval",
"version": "6.0.0",
"version": "6.0.1",
"description": "Mathematical expression evaluator",
"keywords": [
"expression",
Expand Down
3 changes: 2 additions & 1 deletion src/language-service/ls-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } {
Expand Down
98 changes: 89 additions & 9 deletions src/language-service/variable-utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, unknown> => 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<string, unknown>)[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.
Expand Down Expand Up @@ -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<string, unknown>;
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 [];
Expand All @@ -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;
Expand Down
82 changes: 82 additions & 0 deletions test/language-service/language-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down