Skip to content

Commit ff1cc34

Browse files
author
Sander Toonen
committed
Provide more quick fixes
1 parent 5022766 commit ff1cc34

16 files changed

Lines changed: 601 additions & 61 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ const expr = parser.parse('2 * x + 1');
8181
expr.evaluate({ x: 3 }); // 7
8282
expr.evaluate({ x: 10 }); // 21
8383

84+
// Or pass a resolver directly as the first argument
85+
expr.evaluate((name) => name === 'x' ? { value: 3 } : undefined); // 7
86+
8487
// Rich expression language
8588
parser.evaluate('user.name ?? "Anonymous"', { user: {} }); // "Anonymous"
8689
parser.evaluate('CASE WHEN score >= 90 THEN "A" WHEN score >= 80 THEN "B" ELSE "C" END', { score: 85 }); // "B"

docs/expression.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
`parser.parse(str)` returns an `Expression` object. `Expression`s are similar to JavaScript functions, i.e. they can be "called" with variables bound to passed-in values.
66

77
## evaluate(variables?: object, resolver?: VariableResolver)
8+
## evaluate(resolver: VariableResolver)
89

910
Evaluate the expression, with variables bound to the values in `{variables}`. Each variable in the expression is bound to the corresponding member of the `variables` object. If there are unbound variables, `evaluate` will throw an exception.
1011

@@ -18,13 +19,20 @@ console.log(expr.evaluate({ x: 3 })); // 8
1819

1920
The optional `resolver` argument is a per-call variable resolver. It has the same shape as `parser.resolve``(name) => { alias } | { value } | undefined` — but applies only to the current `evaluate()` call, so a single parsed `Expression` can be evaluated multiple times against different data sources without mutating parser state. The per-call `resolver` is consulted before `parser.resolve`; the `variables` object still takes precedence over both.
2021

22+
When no `variables` are needed, the resolver can be passed directly as the first argument — `evaluate` detects whether the first argument is an object or a function and dispatches accordingly.
23+
2124
```js
2225
const parser = new Parser();
2326
const expr = parser.parse('$user.name');
2427

2528
const resolveAlice = (name) => name === '$user' ? { value: { name: 'Alice' } } : undefined;
2629
const resolveBob = (name) => name === '$user' ? { value: { name: 'Bob' } } : undefined;
2730

31+
// Resolver as first argument
32+
expr.evaluate(resolveAlice); // 'Alice'
33+
expr.evaluate(resolveBob); // 'Bob'
34+
35+
// Equivalent, with an explicit empty values object
2836
expr.evaluate({}, resolveAlice); // 'Alice'
2937
expr.evaluate({}, resolveBob); // 'Bob'
3038
```

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/expreszo",
3-
"version": "0.3.0",
3+
"version": "0.5.0",
44
"description": "Mathematical expression evaluator",
55
"keywords": [
66
"expression",

src/core/expression.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ export class Expression {
125125
/**
126126
* Evaluates the expression with the given variable values.
127127
*
128+
* Accepts either a `values` object, a `resolver` function, or both. When a
129+
* function is passed as the first argument it is treated as the resolver and
130+
* no `values` object is used.
131+
*
128132
* @param values - Object containing variable values
129133
* @param resolver - Optional per-call variable resolver. Tried before the parser-level
130134
* resolver (if any) when a variable is not present in `values`. Lets a single parsed
@@ -138,29 +142,47 @@ export class Expression {
138142
* const expr = parser.parse('2 + 3 * x');
139143
* const result = expr.evaluate({ x: 4 }); // Returns 14
140144
*
141-
* // Per-call resolver
145+
* // Per-call resolver only
142146
* const expr2 = parser.parse('$a + $b');
143-
* const result2 = expr2.evaluate({}, (t) =>
147+
* const result2 = expr2.evaluate((t) =>
148+
* t.startsWith('$') ? { value: lookup(t.substring(1)) } : undefined
149+
* );
150+
*
151+
* // Values and resolver
152+
* const result3 = expr2.evaluate({}, (t) =>
144153
* t.startsWith('$') ? { value: lookup(t.substring(1)) } : undefined
145154
* );
146155
* ```
147156
*/
148-
evaluate(values?: ReadonlyValues, resolver?: VariableResolver): Value | Promise<Value> {
157+
evaluate(resolver: VariableResolver): Value | Promise<Value>;
158+
evaluate(values?: ReadonlyValues, resolver?: VariableResolver): Value | Promise<Value>;
159+
evaluate(
160+
valuesOrResolver?: ReadonlyValues | VariableResolver,
161+
resolver?: VariableResolver
162+
): Value | Promise<Value> {
163+
let values: ReadonlyValues | undefined;
164+
let effectiveResolver: VariableResolver | undefined;
165+
if (typeof valuesOrResolver === 'function') {
166+
effectiveResolver = valuesOrResolver;
167+
} else {
168+
values = valuesOrResolver;
169+
effectiveResolver = resolver;
170+
}
149171
const safeValues = (values || {}) as Record<string, Value>;
150172

151173
if (this.isAsync === undefined) {
152174
this.isAsync = containsAsyncCall(this.#root, { functions: this.functions });
153175
}
154176
if (this.isAsync) {
155-
return evaluateAstAsync(this.#root, this, safeValues, resolver);
177+
return evaluateAstAsync(this.#root, this, safeValues, effectiveResolver);
156178
}
157179

158180
try {
159-
return evaluateAstSync(this.#root, this, safeValues, resolver);
181+
return evaluateAstSync(this.#root, this, safeValues, effectiveResolver);
160182
} catch (err) {
161183
if (err instanceof AsyncRequiredError) {
162184
this.isAsync = true;
163-
return evaluateAstAsync(this.#root, this, safeValues, resolver);
185+
return evaluateAstAsync(this.#root, this, safeValues, effectiveResolver);
164186
}
165187
throw err;
166188
}

src/functions/array/operations.ts

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -76,59 +76,59 @@ export function fold(arg1: Function | any[] | undefined, arg2: any, arg3: Functi
7676
}, init);
7777
}
7878

79-
export function indexOf(haystack: string | any[] | undefined, target: any): number | undefined {
80-
if (haystack === undefined) {
79+
export function indexOf(arg1: any, arg2: any): number | undefined {
80+
// Support both indexOf(arrayOrString, target) and indexOf(target, arrayOrString) for backwards compatibility
81+
const arg1IsCollection = Array.isArray(arg1) || typeof arg1 === 'string';
82+
const arg2IsCollection = Array.isArray(arg2) || typeof arg2 === 'string';
83+
84+
let haystack: string | any[];
85+
let target: any;
86+
87+
if (arg1IsCollection) {
88+
// collection-first (preferred): indexOf(haystack, target)
89+
haystack = arg1;
90+
target = arg2;
91+
} else if (arg2IsCollection && arg1 !== undefined) {
92+
// target-first (legacy): indexOf(target, haystack)
93+
target = arg1;
94+
haystack = arg2;
95+
} else if (arg1 === undefined) {
8196
return undefined;
82-
}
83-
if (!(Array.isArray(haystack) || typeof haystack === 'string')) {
97+
} else {
8498
throw new Error(
85-
`indexOf(arrayOrString, target) expects a string or array as first argument, got ${getTypeName(haystack)}.\n` +
99+
`indexOf(arrayOrString, target) expects a string or array as first argument, got ${getTypeName(arg1)}.\n` +
86100
'Example: indexOf(["a", "b", "c"], "b") or indexOf("hello", "o")'
87101
);
88102
}
89103

90104
return haystack.indexOf(target);
91105
}
92106

93-
export function indexOfLegacy(target: any, s: string | any[] | undefined): number | undefined {
94-
if (s === undefined) {
95-
return undefined;
96-
}
97-
if (!(Array.isArray(s) || typeof s === 'string')) {
98-
throw new Error(
99-
`indexOf(target, arrayOrString) expects a string or array as second argument, got ${getTypeName(s)}.\n` +
100-
'Example: indexOf("b", ["a", "b", "c"]) or indexOf("o", "hello")'
101-
);
102-
}
103-
104-
return s.indexOf(target);
105-
}
107+
export function join(arg1: any, arg2: any): string | undefined {
108+
// Support both join(array, separator) and join(separator, array) for backwards compatibility
109+
let a: any[] | undefined;
110+
let sep: string | undefined;
106111

107-
export function join(a: any[] | undefined, sep: string | undefined): string | undefined {
108-
if (a === undefined || sep === undefined) {
112+
if (Array.isArray(arg1) && (typeof arg2 === 'string' || arg2 === undefined)) {
113+
// array-first (preferred): join(array, separator)
114+
a = arg1;
115+
sep = arg2;
116+
} else if (Array.isArray(arg2) && (typeof arg1 === 'string' || arg1 === undefined)) {
117+
// separator-first (legacy): join(separator, array)
118+
sep = arg1;
119+
a = arg2;
120+
} else if (arg1 === undefined || arg2 === undefined) {
109121
return undefined;
110-
}
111-
if (!Array.isArray(a)) {
122+
} else {
112123
throw new Error(
113-
`join(array, separator) expects an array as first argument, got ${getTypeName(a)}.\n` +
124+
`join(array, separator) expects an array as first argument, got ${getTypeName(arg1)}.\n` +
114125
'Example: join(["a", "b", "c"], ", ")'
115126
);
116127
}
117128

118-
return a.join(sep);
119-
}
120-
121-
export function joinLegacy(sep: string | undefined, a: any[] | undefined): string | undefined {
122-
if (sep === undefined || a === undefined) {
129+
if (a === undefined || sep === undefined) {
123130
return undefined;
124131
}
125-
if (!Array.isArray(a)) {
126-
throw new Error(
127-
`join(separator, array) expects an array as second argument, got ${getTypeName(a)}.\n` +
128-
'Example: join(", ", ["a", "b", "c"])'
129-
);
130-
}
131-
132132
return a.join(sep);
133133
}
134134

src/language-service/code-actions.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import type { TextDocument } from 'vscode-languageserver-textdocument';
2-
import type { CodeAction, Diagnostic } from 'vscode-languageserver-types';
2+
import type { CodeAction, Diagnostic, TextEdit } from 'vscode-languageserver-types';
33
import { CodeActionKind } from 'vscode-languageserver-types';
44
import type { Parser } from '../parsing/parser';
55
import { closestMatch } from './shared/levenshtein.js';
66
import { buildKnownNames } from './shared/known-names.js';
77
import { FunctionDetails } from './language-service.models.js';
88
import type { GetCodeActionsParams } from './language-service.types.js';
9+
import type { LegacyArgOrderFixData } from './legacy-arg-order.js';
10+
import { spanToRange } from './shared/positions.js';
911

1012
/**
1113
* Count the number of top-level comma-separated arguments in the raw text
@@ -126,6 +128,57 @@ function didYouMeanQuickFix(
126128
};
127129
}
128130

131+
function isLegacyArgOrderFixData(data: unknown): data is LegacyArgOrderFixData {
132+
if (typeof data !== 'object' || data === null) return false;
133+
const d = data as Record<string, unknown>;
134+
return (
135+
d.kind === 'legacy-arg-order' &&
136+
typeof d.functionName === 'string' &&
137+
Array.isArray(d.argSpans) &&
138+
Array.isArray(d.swapPairs)
139+
);
140+
}
141+
142+
function reorderArgsQuickFix(
143+
doc: TextDocument,
144+
diagnostic: Diagnostic
145+
): CodeAction | null {
146+
const data = diagnostic.data;
147+
if (!isLegacyArgOrderFixData(data)) return null;
148+
149+
const text = doc.getText();
150+
const edits: TextEdit[] = [];
151+
// Each swap pair substitutes the two argument slices at their own ranges.
152+
// Emitting per-slot edits (rather than rewriting the whole call) keeps
153+
// surrounding whitespace/comments intact.
154+
for (const [i, j] of data.swapPairs) {
155+
const a = data.argSpans[i];
156+
const b = data.argSpans[j];
157+
if (!a || !b) return null;
158+
edits.push({
159+
range: spanToRange(doc, a),
160+
newText: text.slice(b.start, b.end)
161+
});
162+
edits.push({
163+
range: spanToRange(doc, b),
164+
newText: text.slice(a.start, a.end)
165+
});
166+
}
167+
if (edits.length === 0) return null;
168+
169+
return {
170+
title: `Reorder arguments of '${data.functionName}' to preferred order`,
171+
kind: CodeActionKind.QuickFix,
172+
diagnostics: [diagnostic],
173+
isPreferred: true,
174+
edit: {
175+
changes: {
176+
[doc.uri]: edits
177+
}
178+
}
179+
};
180+
}
181+
129182
export function getCodeActions(
130183
params: GetCodeActionsParams,
131184
parser: Parser,
@@ -141,6 +194,9 @@ export function getCodeActions(
141194
} else if (diagnostic.code === 'unknown-ident') {
142195
const action = didYouMeanQuickFix(params.textDocument, diagnostic, knownNames);
143196
if (action) out.push(action);
197+
} else if (diagnostic.code === 'legacy-arg-order') {
198+
const action = reorderArgsQuickFix(params.textDocument, diagnostic);
199+
if (action) out.push(action);
144200
}
145201
}
146202

src/language-service/language-service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import { getSignatureHelp as computeSignatureHelp } from './signature-help';
7777
import { encodeSemanticTokens } from './semantic-tokens';
7878
import { getUnknownIdentDiagnostics } from './unknown-ident';
7979
import { getTypeMismatchDiagnostics } from './type-check';
80+
import { getLegacyArgOrderDiagnostics } from './legacy-arg-order';
8081
import { getCodeActions as computeCodeActions } from './code-actions';
8182
import { format as computeFormat } from './formatter';
8283

@@ -421,6 +422,10 @@ export function createLanguageService(options: LanguageServiceOptions | undefine
421422
// Type-mismatch diagnostics — literals-only, always on
422423
diagnostics.push(...getTypeMismatchDiagnostics(textDocument, parseCache));
423424

425+
// Legacy argument-order diagnostics — suggests reordering to the
426+
// preferred (collection-first) form for dual-order built-ins
427+
diagnostics.push(...getLegacyArgOrderDiagnostics(textDocument, parseCache));
428+
424429
return diagnostics;
425430
}
426431

src/language-service/language-service.types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,10 @@ export interface LanguageServiceApi {
8787

8888
/**
8989
* Returns LSP CodeAction quick fixes for the diagnostics in the supplied
90-
* context. Currently handles `arity-too-few` (inserts placeholder args)
91-
* and `unknown-ident` (Levenshtein "did you mean" suggestions).
90+
* context. Currently handles `arity-too-few` (inserts placeholder args),
91+
* `unknown-ident` (Levenshtein "did you mean" suggestions) and
92+
* `legacy-arg-order` (reorders dual-order built-in calls to the preferred
93+
* collection-first form).
9294
*/
9395
getCodeActions(params: GetCodeActionsParams): CodeAction[];
9496

0 commit comments

Comments
 (0)