@dforge-core/metadata 0.0.22 → 0.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +201 -0
- package/README.md +45 -0
- package/dist/actions-DvYTrCbP.d.ts +60 -0
- package/dist/dsl/index.d.ts +251 -0
- package/dist/dsl/index.js +1426 -0
- package/dist/dsl/index.js.map +1 -0
- package/dist/index.d.ts +26 -62
- package/dist/index.js +17 -8
- package/dist/index.js.map +1 -1
- package/package.json +10 -4
- package/schemas/jobs.schema.json +2 -2
- package/src/dsl/builtins.ts +210 -0
- package/src/dsl/check.ts +813 -0
- package/src/dsl/index.ts +37 -0
- package/src/dsl/lexer.ts +394 -0
- package/src/dsl/parse.ts +713 -0
- package/src/dsl/types.ts +98 -0
- package/src/index.ts +1 -0
- package/src/traits.ts +49 -9
- package/src/types.ts +15 -0
package/src/dsl/check.ts
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
// Static checks for an action DSL body (`logic/actions/*.dsl`).
|
|
2
|
+
//
|
|
3
|
+
// Every rule here mirrors something the platform only tells you at pack or
|
|
4
|
+
// install time — a slow, tenant-bound round trip. The point is to move that
|
|
5
|
+
// feedback to where the script is written, and to the validator that runs
|
|
6
|
+
// before a pack, from one implementation rather than two.
|
|
7
|
+
//
|
|
8
|
+
// The rules that need to know about the module take it through `DslContext`,
|
|
9
|
+
// which is small on purpose: a module code, the action, and the columns of the
|
|
10
|
+
// entity behind the record context. Anything a host can't resolve it simply
|
|
11
|
+
// omits, and the rules that depend on it stand down. See ./types.
|
|
12
|
+
|
|
13
|
+
import { BUILTIN_BY_NAME } from "./builtins";
|
|
14
|
+
import { CONTROL_KEYWORDS, type Token } from "./lexer";
|
|
15
|
+
import {
|
|
16
|
+
type BlockKind,
|
|
17
|
+
type DslDocument,
|
|
18
|
+
type Span,
|
|
19
|
+
blockLabelAt,
|
|
20
|
+
parseDsl,
|
|
21
|
+
} from "./parse";
|
|
22
|
+
import type { ColumnLookup, DslContext, DslIssue, DslSeverity } from "./types";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check a DSL body. Returns [] for a clean script.
|
|
26
|
+
*
|
|
27
|
+
* Issues come back in source-feature order (field reads, params, execution
|
|
28
|
+
* mode, block spelling, built-ins, top-level returns, inline assignments)
|
|
29
|
+
* rather than sorted by position — a host that wants them in file order can
|
|
30
|
+
* sort on `start`.
|
|
31
|
+
*/
|
|
32
|
+
export function checkDsl(text: string, ctx: DslContext = {}): DslIssue[] {
|
|
33
|
+
const parsed = parseDsl(text);
|
|
34
|
+
const out: DslIssue[] = [];
|
|
35
|
+
const positionAt = lineIndexer(text);
|
|
36
|
+
|
|
37
|
+
const add = (
|
|
38
|
+
span: Span,
|
|
39
|
+
message: string,
|
|
40
|
+
severity: DslSeverity,
|
|
41
|
+
rule: string,
|
|
42
|
+
) => {
|
|
43
|
+
const { line, column } = positionAt(span.start);
|
|
44
|
+
out.push({
|
|
45
|
+
rule,
|
|
46
|
+
severity,
|
|
47
|
+
message,
|
|
48
|
+
start: span.start,
|
|
49
|
+
end: span.end,
|
|
50
|
+
line,
|
|
51
|
+
column,
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const action = ctx.action;
|
|
56
|
+
const entity = ctx.currentEntity ?? null;
|
|
57
|
+
const isBatch = (action?.executionMode ?? "single") === "batch";
|
|
58
|
+
const hasColumn = columnMatcher(entity?.columns);
|
|
59
|
+
|
|
60
|
+
// ── structure ────────────────────────────────────────────────────
|
|
61
|
+
// ActionDslCompiler.ParseBlocks reads the headers before anything else, so
|
|
62
|
+
// a file that loses a block here loses it wholesale: the body still looks
|
|
63
|
+
// like code, and nothing runs.
|
|
64
|
+
if (text.trim() === "") {
|
|
65
|
+
add(
|
|
66
|
+
{ start: 0, end: text.length },
|
|
67
|
+
"A DSL body needs at least an execute: block — an empty file installs an action that does nothing.",
|
|
68
|
+
"error",
|
|
69
|
+
"dsl/empty-script",
|
|
70
|
+
);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A file with no headers at all has no label to point at, and a zero-width
|
|
75
|
+
// span renders as a caret before the first character — so fall back to the
|
|
76
|
+
// first token, which is where the reader is already looking.
|
|
77
|
+
const firstToken = parsed.tokens[0];
|
|
78
|
+
const firstLabel = parsed.blocks[0]?.label ??
|
|
79
|
+
(firstToken ? { start: firstToken.start, end: firstToken.end } : { start: 0, end: text.length });
|
|
80
|
+
if (!parsed.blocks.some((b) => b.kind === "execute")) {
|
|
81
|
+
add(
|
|
82
|
+
firstLabel,
|
|
83
|
+
"No execute: block — it is the only required block, and the compiler runs nothing without it.",
|
|
84
|
+
"error",
|
|
85
|
+
"dsl/missing-execute",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// `execute:` is matched as `(.*?)\z`, and its lookahead lists nothing — so it
|
|
90
|
+
// runs to the end of the file and takes every header below it with it. The
|
|
91
|
+
// block's own regex still finds it and it still runs; what breaks is
|
|
92
|
+
// execute:, whose body now holds a header line and a formula or a param
|
|
93
|
+
// list, parsed as JavaScript.
|
|
94
|
+
const executeBlock = parsed.blocks.find((b) => b.kind === "execute");
|
|
95
|
+
if (executeBlock) {
|
|
96
|
+
for (let i = 0; i < parsed.tokens.length; i++) {
|
|
97
|
+
const token = parsed.tokens[i]!;
|
|
98
|
+
if (token.start <= executeBlock.label.start) continue;
|
|
99
|
+
if (token.character !== 0) continue;
|
|
100
|
+
const label = blockLabelAt(parsed.tokens, i);
|
|
101
|
+
if (!label) continue;
|
|
102
|
+
// A second execute: cannot be moved above the first. findBlocks
|
|
103
|
+
// stops at the first one, so dsl/duplicate-block never sees this
|
|
104
|
+
// and the wording has to come from here.
|
|
105
|
+
add(
|
|
106
|
+
label.span,
|
|
107
|
+
label.kind === "execute"
|
|
108
|
+
? `"execute:" is declared more than once — the first one's body runs to the end of the file, so this header and everything under it are swallowed into it and parsed as JavaScript. Merge the two into one execute: block.`
|
|
109
|
+
: `"${label.kind}:" is below execute:, whose body runs to the end of the file — the header and everything under it are swallowed into the execute: script and parsed as JavaScript. Move it above execute:.`,
|
|
110
|
+
"error",
|
|
111
|
+
"dsl/block-after-execute",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const seenBlocks = new Set<string>();
|
|
117
|
+
for (let i = 0; i < parsed.blocks.length; i++) {
|
|
118
|
+
const block = parsed.blocks[i]!;
|
|
119
|
+
if (seenBlocks.has(block.kind)) {
|
|
120
|
+
add(
|
|
121
|
+
block.label,
|
|
122
|
+
`"${block.kind}:" is declared more than once — the compiler keeps one and silently drops the other.`,
|
|
123
|
+
"error",
|
|
124
|
+
"dsl/duplicate-block",
|
|
125
|
+
);
|
|
126
|
+
// No block's own lookahead lists itself, so the order rule below
|
|
127
|
+
// would read a duplicate as misplaced and tell the author to move
|
|
128
|
+
// it before itself.
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
seenBlocks.add(block.kind);
|
|
132
|
+
|
|
133
|
+
// Only the block immediately above can swallow this one: a block body
|
|
134
|
+
// ends at the first header its own lookahead lists, and the nearest
|
|
135
|
+
// header below it is this one. execute: is left to the rule above, which
|
|
136
|
+
// says the same thing about it in better words.
|
|
137
|
+
const prev = parsed.blocks[i - 1];
|
|
138
|
+
if (prev && prev.kind !== "execute" && !BLOCK_STOPS[prev.kind].has(block.kind)) {
|
|
139
|
+
add(
|
|
140
|
+
block.label,
|
|
141
|
+
`"${block.kind}:" must come before "${prev.kind}:" — the compiler reads ${prev.kind}: up to the next header it recognises, and ${block.kind}: is not one of them, so this header and its body end up inside the ${prev.kind}: body.`,
|
|
142
|
+
"error",
|
|
143
|
+
"dsl/block-order",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── record fields ────────────────────────────────────────────────
|
|
149
|
+
for (const ref of parsed.fieldRefs) {
|
|
150
|
+
// Neither block is rewritten by TransformLine, so brackets in them are
|
|
151
|
+
// data rather than field reads: `schema:` is embedded as a JS object
|
|
152
|
+
// literal, and `params:` is read by RxParamDecl — whose trailing
|
|
153
|
+
// key=value pairs take a bracketed option list, `options=[cash]`.
|
|
154
|
+
if (ref.block === "schema" || ref.block === "params") continue;
|
|
155
|
+
|
|
156
|
+
// Single-hop is the DSL's own rewrite (`RxRefNav`), so the rule belongs
|
|
157
|
+
// to the blocks that go through it. canExecute: reaches the formula
|
|
158
|
+
// engine verbatim, and a formula walks a chain of any depth —
|
|
159
|
+
// `[vehicle].[depot].[city_name]` resolves there and is tested.
|
|
160
|
+
const isDslBlock = ref.block === "execute" || ref.block === "onBeforeStart";
|
|
161
|
+
// The compiler rewrites the first hop and then fails on a surviving
|
|
162
|
+
// `.[` — so it is the bracket, not the hop count, that marks a second
|
|
163
|
+
// reference. `[vehicle].plate.length` compiles to
|
|
164
|
+
// `__ref_vehicle.get('plate').length`: one hop, then JavaScript.
|
|
165
|
+
const secondRef = ref.navigation.findIndex((h, idx) => idx > 0 && h.bracketed);
|
|
166
|
+
if (isDslBlock && secondRef > 0) {
|
|
167
|
+
const first = ref.navigation[secondRef]!;
|
|
168
|
+
const last = ref.navigation[ref.navigation.length - 1]!;
|
|
169
|
+
add(
|
|
170
|
+
{ start: first.span.start, end: last.span.end },
|
|
171
|
+
`Ref navigation is single-hop in the DSL. Use getRecord() or select() to reach past "${ref.navigation[0]!.name}".`,
|
|
172
|
+
"error",
|
|
173
|
+
"dsl/multi-hop-nav",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Batch execute: applies no `[field]` rewrite at all, so `[true]` stays
|
|
178
|
+
// the literal it is — and a keyword is never a column name anyway.
|
|
179
|
+
if (isBatch && ref.block === "execute" && LITERAL_NAMES.has(ref.name))
|
|
180
|
+
continue;
|
|
181
|
+
|
|
182
|
+
if (!entity) continue;
|
|
183
|
+
if (entity.columns.size === 0) continue;
|
|
184
|
+
if (hasColumn(ref.name)) continue;
|
|
185
|
+
// `var ids = [orderId]` is an array literal, not a field read. Only the
|
|
186
|
+
// name tells the two apart, so a name bound in this script stands down —
|
|
187
|
+
// the same fail-open the entity checks above use.
|
|
188
|
+
//
|
|
189
|
+
// Only the bare bracket is ambiguous. `r[stauts]` and
|
|
190
|
+
// `records[0][stauts]` name a column outright — the compiler rewrites
|
|
191
|
+
// each to `.get('stauts')` with the literal name — so a same-named
|
|
192
|
+
// local says nothing about them.
|
|
193
|
+
if (ref.isCurrentRecord && parsed.locals.has(ref.name)) continue;
|
|
194
|
+
|
|
195
|
+
add(
|
|
196
|
+
ref.span,
|
|
197
|
+
`"${ref.name}" is not a column on ${entity.qualified}. [field] names are checked at pack time — this is a compile error, not an empty read.`,
|
|
198
|
+
"error",
|
|
199
|
+
"dsl/unknown-column",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// `old[field]` names columns of the same entity — RxOldAccess feeds them
|
|
204
|
+
// into the very same check — so a typo there is a compile error too.
|
|
205
|
+
if (entity && entity.columns.size > 0) {
|
|
206
|
+
for (const ref of parsed.globalRefs) {
|
|
207
|
+
if (ref.global !== "old") continue;
|
|
208
|
+
if (ref.block !== "execute" && ref.block !== "onBeforeStart") continue;
|
|
209
|
+
if (hasColumn(ref.property)) continue;
|
|
210
|
+
add(
|
|
211
|
+
ref.span,
|
|
212
|
+
`"${ref.property}" is not a column on ${entity.qualified}. old[field] reads the pre-change value of a column, and the name is checked at pack time.`,
|
|
213
|
+
"error",
|
|
214
|
+
"dsl/unknown-column",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── params ───────────────────────────────────────────────────────
|
|
220
|
+
// `ValidateParamUsages` compares with OrdinalIgnoreCase, so `params[Qty]`
|
|
221
|
+
// against a declared `qty` compiles — flagging it would be a false error.
|
|
222
|
+
const declared = new Set(parsed.params.map((p) => p.name.toLowerCase()));
|
|
223
|
+
const hasParamsBlock = parsed.blocks.some((b) => b.kind === "params");
|
|
224
|
+
for (const ref of parsed.globalRefs) {
|
|
225
|
+
if (ref.global !== "params") continue;
|
|
226
|
+
if (!hasParamsBlock || declared.has(ref.property.toLowerCase())) continue;
|
|
227
|
+
add(
|
|
228
|
+
ref.span,
|
|
229
|
+
`"${ref.property}" is not declared in the params: block.`,
|
|
230
|
+
"error",
|
|
231
|
+
"dsl/unknown-param",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── execution mode ───────────────────────────────────────────────
|
|
236
|
+
if (action) {
|
|
237
|
+
const mode = action.executionMode ?? "single";
|
|
238
|
+
// `recordsRefs` covers both `records[x]` and the bare `for x in records`,
|
|
239
|
+
// and comes from the lexer, so the word inside a string or a comment
|
|
240
|
+
// does not count — nor does a script that binds its own `records`,
|
|
241
|
+
// which shadows the batch global and says nothing about the mode.
|
|
242
|
+
const recordsSpan = parsed.locals.has("records")
|
|
243
|
+
? undefined
|
|
244
|
+
: parsed.recordsRefs[0];
|
|
245
|
+
// execute: alone. `onBeforeStart:` is compiled by CompilePerRecordBlock
|
|
246
|
+
// whatever the mode — "always per-record (no batch form)" — so a bare
|
|
247
|
+
// field is exactly how it is meant to be written there.
|
|
248
|
+
//
|
|
249
|
+
// Batch mode never applies the `[field]` rewrite at all, which is why
|
|
250
|
+
// `var ids = [id]` survives as the array literal it looks like. Same
|
|
251
|
+
// disambiguation as the column rule: a name bound in this script is a
|
|
252
|
+
// local, not a column.
|
|
253
|
+
const bareFields = parsed.fieldRefs.filter(
|
|
254
|
+
(r) =>
|
|
255
|
+
r.block === "execute" &&
|
|
256
|
+
r.isCurrentRecord &&
|
|
257
|
+
!parsed.locals.has(r.name) &&
|
|
258
|
+
!LITERAL_NAMES.has(r.name),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
if (mode === "batch") {
|
|
262
|
+
// Every one of them: an editor underlines what it is told to, and
|
|
263
|
+
// reporting only the first hides the rest behind a re-check.
|
|
264
|
+
for (const field of bareFields) {
|
|
265
|
+
add(
|
|
266
|
+
field.outerSpan,
|
|
267
|
+
`Action "${action.code}" runs in batch mode, which has no current record. Iterate instead: for x in records { x[${field.name}] }.`,
|
|
268
|
+
"error",
|
|
269
|
+
"dsl/batch-bare-field",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (mode !== "batch" && recordsSpan) {
|
|
274
|
+
add(
|
|
275
|
+
recordsSpan,
|
|
276
|
+
`"records" only exists in batch mode; action "${action.code}" runs in "${mode}" mode.`,
|
|
277
|
+
"warning",
|
|
278
|
+
"dsl/records-outside-batch",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── block-specific spelling ──────────────────────────────────────
|
|
284
|
+
// canExecute is parsed by the formula engine, execute by the JavaScript
|
|
285
|
+
// compiler. They share more vocabulary than the docs suggest — NULL and
|
|
286
|
+
// lowercase booleans work in both, and shipped modules rely on that — so
|
|
287
|
+
// only the operators that genuinely have no formula spelling are flagged.
|
|
288
|
+
for (const token of parsed.tokens) {
|
|
289
|
+
if (blockOf(parsed, token.start) !== "canExecute") continue;
|
|
290
|
+
if (token.text !== "&&" && token.text !== "||") continue;
|
|
291
|
+
add(
|
|
292
|
+
{ start: token.start, end: token.end },
|
|
293
|
+
`canExecute is a formula, not JavaScript — write ${token.text === "&&" ? "AND" : "OR"}.`,
|
|
294
|
+
"warning",
|
|
295
|
+
"dsl/js-in-formula",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── built-in misuse ──────────────────────────────────────────────
|
|
300
|
+
for (const call of parsed.calls) {
|
|
301
|
+
if (call.name === "userId") {
|
|
302
|
+
add(
|
|
303
|
+
call.nameSpan,
|
|
304
|
+
"'userId' is a value, not a function — write userId without parentheses, or currentUserId(). " +
|
|
305
|
+
"The compiler's bare-identifier rewrite fires either way, so userId() reaches Jint as a call against a number.",
|
|
306
|
+
"error",
|
|
307
|
+
"dsl/user-id-call",
|
|
308
|
+
);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const builtin = BUILTIN_BY_NAME.get(call.name);
|
|
313
|
+
if (!builtin || builtin.entityArg === undefined) continue;
|
|
314
|
+
const arg = call.firstStringArg;
|
|
315
|
+
if (!arg) continue;
|
|
316
|
+
|
|
317
|
+
// A hyphenated module code can't be schema-qualified, so the rule is
|
|
318
|
+
// skipped there rather than reported as unfixable. Neither can a code
|
|
319
|
+
// half of which is spliced in at run time be read here.
|
|
320
|
+
if (arg.interpolated) continue;
|
|
321
|
+
if (arg.value.includes(".") || ctx.moduleCode?.includes("-")) continue;
|
|
322
|
+
add(
|
|
323
|
+
arg.span,
|
|
324
|
+
`Qualify the entity code as "${ctx.moduleCode ? `${ctx.moduleCode}.` : "<module>."}${arg.value}" — entity_cd is only unique per module, so a bare code can resolve to another module's entity.`,
|
|
325
|
+
"info",
|
|
326
|
+
"dsl/unqualified-entity",
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── indented headers, and returns outside a function ─────────────
|
|
331
|
+
// An indented `params:` inside an object literal is an ordinary key, which
|
|
332
|
+
// is the whole reason the compiler anchors headers at column 0 rather than
|
|
333
|
+
// trusting the line — so that rule reads brace depth.
|
|
334
|
+
//
|
|
335
|
+
// `return` reads function scope instead. Both blocks compile to a bare
|
|
336
|
+
// script, and Esprima's ParseScript rejects a return outside a function
|
|
337
|
+
// wherever it sits: `if (x) { return }` and `if (x) return` fail exactly as
|
|
338
|
+
// a return on its own line does.
|
|
339
|
+
let depth = 0;
|
|
340
|
+
const functionDepths: number[] = [];
|
|
341
|
+
// Index of the `(` that the most recent `)` closed. Tracked with a stack
|
|
342
|
+
// because the nearest open paren is not the matching one: in `if (now()) {`
|
|
343
|
+
// it is `now`'s, and in `function f(x = (1)) {` it is the default value's.
|
|
344
|
+
const parenStack: number[] = [];
|
|
345
|
+
let matchingParenOpen = -1;
|
|
346
|
+
for (let i = 0; i < parsed.tokens.length; i++) {
|
|
347
|
+
const token = parsed.tokens[i]!;
|
|
348
|
+
|
|
349
|
+
if (token.text === "(") parenStack.push(i);
|
|
350
|
+
else if (token.text === ")") matchingParenOpen = parenStack.pop() ?? -1;
|
|
351
|
+
|
|
352
|
+
if (token.text === "{") {
|
|
353
|
+
depth++;
|
|
354
|
+
if (opensFunctionBody(parsed.tokens, i, matchingParenOpen))
|
|
355
|
+
functionDepths.push(depth);
|
|
356
|
+
} else if (token.text === "}") {
|
|
357
|
+
if (functionDepths[functionDepths.length - 1] === depth)
|
|
358
|
+
functionDepths.pop();
|
|
359
|
+
depth = Math.max(0, depth - 1);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (depth === 0 && token.character > 0) {
|
|
363
|
+
const label = blockLabelAt(parsed.tokens, i);
|
|
364
|
+
// `execute: number` inside params: declares a param called execute.
|
|
365
|
+
// RxParamDecl takes any `\w+`, so a block name is a legal param
|
|
366
|
+
// name — and a declaration the parser already read is not a header.
|
|
367
|
+
const isParamDecl = parsed.params.some(
|
|
368
|
+
(p) => p.nameSpan.start === token.start,
|
|
369
|
+
);
|
|
370
|
+
if (label && !isParamDecl) {
|
|
371
|
+
add(
|
|
372
|
+
label.span,
|
|
373
|
+
`Block headers start at column 0. Indented, "${label.kind}:" is body text — the compiler never opens the block, and it runs as if empty.`,
|
|
374
|
+
"error",
|
|
375
|
+
"dsl/indented-block-header",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (token.kind !== "ident" || token.text !== "return") continue;
|
|
381
|
+
// `return` is a reserved word, so it is a statement unless it is being
|
|
382
|
+
// used as a name: `result.return` reads a property, `{ return: 1 }`
|
|
383
|
+
// declares a key and `{ return() { … } }` declares a method. All three
|
|
384
|
+
// are legal JavaScript.
|
|
385
|
+
if (parsed.tokens[i - 1]?.text === ".") continue;
|
|
386
|
+
if (parsed.tokens[i + 1]?.text === ":") continue;
|
|
387
|
+
if (isMethodName(parsed.tokens, i)) continue;
|
|
388
|
+
if (functionDepths.length > 0) continue;
|
|
389
|
+
const block = blockOf(parsed, token.start);
|
|
390
|
+
if (block !== "execute" && block !== "onBeforeStart") continue;
|
|
391
|
+
add(
|
|
392
|
+
{ start: token.start, end: token.end },
|
|
393
|
+
`A return outside a function is a syntax error — ${block}: compiles to a bare script. Use exit(message, level) to stop early and keep the changes made so far.`,
|
|
394
|
+
"error",
|
|
395
|
+
"dsl/top-level-return",
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── formula-only functions in a JavaScript block ─────────────────
|
|
400
|
+
// execute: and onBeforeStart: compile to a bare script and run on Jint;
|
|
401
|
+
// the rest of the DSL is the formula engine. Install rejects these with
|
|
402
|
+
// "'TODAY' is not defined".
|
|
403
|
+
for (const call of parsed.calls) {
|
|
404
|
+
const replacement = FORMULA_ONLY.get(call.name);
|
|
405
|
+
if (!replacement) continue;
|
|
406
|
+
if (call.block !== "execute" && call.block !== "onBeforeStart") continue;
|
|
407
|
+
add(
|
|
408
|
+
call.nameSpan,
|
|
409
|
+
`${call.name}() is formula-only — in ${call.block}: it is undefined and install fails with "'${call.name}' is not defined". Write ${replacement}.`,
|
|
410
|
+
"error",
|
|
411
|
+
"dsl/formula-only-function",
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Token position by start offset — the two rules below both need to read
|
|
416
|
+
// what sits next to a call, and a scan per call is a scan of the file.
|
|
417
|
+
const tokenAt = new Map(parsed.tokens.map((t, i) => [t.start, i]));
|
|
418
|
+
|
|
419
|
+
// ── SQL placeholders are @name, not :name ────────────────────────
|
|
420
|
+
for (const call of parsed.calls) {
|
|
421
|
+
if (call.name !== "query" && call.name !== "callProc") continue;
|
|
422
|
+
const arg = call.firstStringArg;
|
|
423
|
+
if (!arg) continue; // a variable or a built string — nothing to read
|
|
424
|
+
|
|
425
|
+
// `::text` is a Postgres cast and `a:b` inside a literal is not a
|
|
426
|
+
// placeholder either, so require a non-word, non-colon character before.
|
|
427
|
+
// Quoted data and comments are not SQL to bind against at all —
|
|
428
|
+
// `select ':draft' as status` names no placeholder.
|
|
429
|
+
//
|
|
430
|
+
// The leading character is captured rather than looked behind: lookbehind
|
|
431
|
+
// is ES2018, and an unsupported one is a SyntaxError for the whole module
|
|
432
|
+
// at parse time, not a failure of this rule.
|
|
433
|
+
const bad = /(^|[^:\w]):([a-z][a-z0-9_]*)/i.exec(stripSqlNoise(arg.value));
|
|
434
|
+
if (bad) {
|
|
435
|
+
const name = bad[2];
|
|
436
|
+
add(
|
|
437
|
+
arg.span,
|
|
438
|
+
`SQL binds @name, not :name — rewrite ':${name}' as '@${name}' and pass { ${name}: value } as the params argument.`,
|
|
439
|
+
"error",
|
|
440
|
+
"dsl/sql-placeholder",
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// A `${…}` hole is the splice itself; a `+` after the literal is the
|
|
445
|
+
// same splice written the other way.
|
|
446
|
+
if (arg.interpolated || parsed.tokens[arg.endIndex + 1]?.text === "+") {
|
|
447
|
+
const built = arg.interpolated ? "${…} interpolation" : "concatenation";
|
|
448
|
+
add(
|
|
449
|
+
arg.span,
|
|
450
|
+
// callProc's first argument is the procedure name, and an
|
|
451
|
+
// identifier cannot be bound — the @placeholder advice cannot
|
|
452
|
+
// be followed there.
|
|
453
|
+
call.name === "callProc"
|
|
454
|
+
? `Procedure name built by ${built} — a name is an identifier, so it cannot be bound as a @placeholder. Choose between fixed names in the script rather than splicing a value into one.`
|
|
455
|
+
: `SQL built by ${built} — pass @placeholders and a params object instead, so values are bound rather than spliced into the statement.`,
|
|
456
|
+
"warning",
|
|
457
|
+
"dsl/sql-concat",
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── unknown host functions ───────────────────────────────────────
|
|
463
|
+
// A warning, not an error: the catalog tracks the host's built-ins by hand,
|
|
464
|
+
// so a name missing from it is more likely our gap than the author's typo.
|
|
465
|
+
//
|
|
466
|
+
// JavaScript blocks only. The catalog is Jint's host surface; canExecute:
|
|
467
|
+
// is the formula engine, with a vocabulary of its own — and its infix
|
|
468
|
+
// operators bring parentheses with them, so `… AND (x OR y)` reads as a
|
|
469
|
+
// call to AND here and is no such thing.
|
|
470
|
+
const declaredFunctions = new Set<string>();
|
|
471
|
+
for (let i = 0; i < parsed.tokens.length; i++) {
|
|
472
|
+
if (parsed.tokens[i]!.text !== "function") continue;
|
|
473
|
+
const name = parsed.tokens[i + 1];
|
|
474
|
+
if (name?.kind === "ident") declaredFunctions.add(name.text);
|
|
475
|
+
}
|
|
476
|
+
// `parsed.calls` is every `name(` in the body, so a function's own
|
|
477
|
+
// declaration head looks like a call to it: `function f(x)` and the method
|
|
478
|
+
// shorthand `f(x) { … }` alike. Neither is an invocation of anything.
|
|
479
|
+
const reportedUnknown = new Set<string>();
|
|
480
|
+
for (const call of parsed.calls) {
|
|
481
|
+
if (call.block !== "execute" && call.block !== "onBeforeStart") continue;
|
|
482
|
+
const name = call.name;
|
|
483
|
+
const idx = tokenAt.get(call.nameSpan.start);
|
|
484
|
+
if (idx !== undefined) {
|
|
485
|
+
// `new Foo()` names a constructor, not a host function — the
|
|
486
|
+
// catalog never held one and has nothing to say about it.
|
|
487
|
+
const prev = parsed.tokens[idx - 1]?.text;
|
|
488
|
+
if (prev === "function" || prev === "new") continue;
|
|
489
|
+
if (isMethodName(parsed.tokens, idx)) continue;
|
|
490
|
+
}
|
|
491
|
+
if (
|
|
492
|
+
BUILTIN_BY_NAME.has(name) ||
|
|
493
|
+
CONTROL_KEYWORDS.has(name) ||
|
|
494
|
+
KEYWORDS_BEFORE_PAREN.has(name) ||
|
|
495
|
+
JS_GLOBALS.has(name) ||
|
|
496
|
+
FORMULA_ONLY.has(name) ||
|
|
497
|
+
// Its own rule reports this one, with better wording.
|
|
498
|
+
name === "userId" ||
|
|
499
|
+
declaredFunctions.has(name) ||
|
|
500
|
+
parsed.locals.has(name) ||
|
|
501
|
+
reportedUnknown.has(name)
|
|
502
|
+
) {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
reportedUnknown.add(name);
|
|
506
|
+
add(
|
|
507
|
+
call.nameSpan,
|
|
508
|
+
`"${name}()" is not a DSL host function, and nothing in this script declares it. Check it against the built-in catalog.`,
|
|
509
|
+
"warning",
|
|
510
|
+
"dsl/unknown-builtin",
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── a job's action has no current record ─────────────────────────
|
|
515
|
+
if (ctx.action?.viaJob) {
|
|
516
|
+
// `JobRegistrar.RequiresRecordContext` reads the compiled script for
|
|
517
|
+
// three markers, not one: `__r.` from `[field]`, `__old.` from the
|
|
518
|
+
// trigger-only `old[field]`, and `__records.` from the batch record set.
|
|
519
|
+
// All three are rejected, so all three are reported.
|
|
520
|
+
const inScript = (block: BlockKind | null) =>
|
|
521
|
+
block === "execute" || block === "onBeforeStart";
|
|
522
|
+
const bound: Array<{ span: Span; read: string }> = [];
|
|
523
|
+
|
|
524
|
+
// Same disambiguation the column rule makes, on the same terms: `[id]`
|
|
525
|
+
// is an array literal when the script binds `id`, and `[true]` is one
|
|
526
|
+
// in batch execute:, where no `[field]` rewrite runs to claim it.
|
|
527
|
+
for (const ref of parsed.fieldRefs) {
|
|
528
|
+
if (!inScript(ref.block)) continue;
|
|
529
|
+
if (isBatch && ref.block === "execute" && LITERAL_NAMES.has(ref.name))
|
|
530
|
+
continue;
|
|
531
|
+
if (ref.isCurrentRecord && parsed.locals.has(ref.name)) continue;
|
|
532
|
+
bound.push({ span: ref.outerSpan, read: `[${ref.name}]` });
|
|
533
|
+
}
|
|
534
|
+
for (const ref of parsed.globalRefs) {
|
|
535
|
+
if (ref.global !== "old" || !inScript(ref.block)) continue;
|
|
536
|
+
bound.push({ span: ref.span, read: `old[${ref.property}]` });
|
|
537
|
+
}
|
|
538
|
+
for (const span of parsed.recordsRefs) {
|
|
539
|
+
if (!inScript(blockOf(parsed, span.start))) continue;
|
|
540
|
+
bound.push({ span, read: "records" });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const first = bound.sort((a, b) => a.span.start - b.span.start)[0];
|
|
544
|
+
if (first) {
|
|
545
|
+
add(
|
|
546
|
+
first.span,
|
|
547
|
+
`Action "${ctx.action.code}" is invoked by a scheduled job, which runs as the system user with no record bound — ${first.read} has nothing to read. Fetch rows with select() or query() instead.`,
|
|
548
|
+
"error",
|
|
549
|
+
"dsl/job-record-context",
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
out.push(...inlineAssignmentIssues(parsed, positionAt));
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Column matching is case-insensitive at the other end — `EntityColumnLookup`
|
|
560
|
+
* hands the compiler an OrdinalIgnoreCase set — but a host passes us whatever
|
|
561
|
+
* `Set` or `Map` it already holds, and a mixed-case column name is legal.
|
|
562
|
+
*
|
|
563
|
+
* So fold both sides: the names once per check when the lookup can enumerate
|
|
564
|
+
* them (`Set` and `Map` both can), and the reference on the way in. A lookup
|
|
565
|
+
* that can do neither falls back to trying the reference in lower case, which
|
|
566
|
+
* is all that is left to try.
|
|
567
|
+
*/
|
|
568
|
+
function columnMatcher(columns?: ColumnLookup): (name: string) => boolean {
|
|
569
|
+
if (!columns) return () => true;
|
|
570
|
+
if (typeof columns.keys !== "function")
|
|
571
|
+
return (name) => columns.has(name) || columns.has(name.toLowerCase());
|
|
572
|
+
|
|
573
|
+
const folded = new Set<string>();
|
|
574
|
+
for (const key of columns.keys()) folded.add(key.toLowerCase());
|
|
575
|
+
return (name) => folded.has(name.toLowerCase());
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Whether the name at `i` heads a method shorthand — `return() { … }`, whose
|
|
580
|
+
* parameter list is followed by a body. `return (x + 1)` is a statement with a
|
|
581
|
+
* parenthesized expression and has no brace after the closing paren, so the
|
|
582
|
+
* two are told apart by what follows the parens rather than by what opens them.
|
|
583
|
+
*/
|
|
584
|
+
function isMethodName(tokens: Token[], i: number): boolean {
|
|
585
|
+
if (tokens[i + 1]?.text !== "(") return false;
|
|
586
|
+
let depth = 0;
|
|
587
|
+
for (let j = i + 1; j < tokens.length; j++) {
|
|
588
|
+
const text = tokens[j]!.text;
|
|
589
|
+
if (text === "(") depth++;
|
|
590
|
+
else if (text === ")") {
|
|
591
|
+
depth--;
|
|
592
|
+
if (depth === 0) return tokens[j + 1]?.text === "{";
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Whether the `{` at `i` opens a function body — where a `return` is legal.
|
|
600
|
+
*
|
|
601
|
+
* `=> {` is one. So is `) {` when the name in front of the matching `(` is not
|
|
602
|
+
* a control keyword: that covers `function (x) {`, `function f(x) {` and the
|
|
603
|
+
* `f() {` method shorthand, while leaving `if (x) {` and `for (…) {` alone.
|
|
604
|
+
*/
|
|
605
|
+
function opensFunctionBody(
|
|
606
|
+
tokens: Token[],
|
|
607
|
+
i: number,
|
|
608
|
+
matchingParenOpen: number,
|
|
609
|
+
): boolean {
|
|
610
|
+
const prev = tokens[i - 1];
|
|
611
|
+
if (prev?.text === "=>") return true;
|
|
612
|
+
if (prev?.text !== ")" || matchingParenOpen < 0) return false;
|
|
613
|
+
const head = tokens[matchingParenOpen - 1];
|
|
614
|
+
return head?.kind === "ident" && !CONTROL_KEYWORDS.has(head.text);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The headers each block's regex in `ParseBlocks` stops at — its body runs to
|
|
619
|
+
* the first of these below it, or to the end of the file.
|
|
620
|
+
*
|
|
621
|
+
* The sets are not the same, and none of them is "every other block": a block
|
|
622
|
+
* followed by one its own set omits swallows that block's text. `schema:`, the
|
|
623
|
+
* only one every other set lists, may therefore sit anywhere before `execute:`.
|
|
624
|
+
*/
|
|
625
|
+
const BLOCK_STOPS: Record<BlockKind, ReadonlySet<BlockKind>> = {
|
|
626
|
+
params: new Set(["schema", "canExecute", "onBeforeStart", "execute"]),
|
|
627
|
+
canExecute: new Set(["schema", "onBeforeStart", "execute"]),
|
|
628
|
+
schema: new Set(["params", "canExecute", "onBeforeStart", "execute"]),
|
|
629
|
+
onBeforeStart: new Set(["schema", "execute"]),
|
|
630
|
+
execute: new Set(),
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Formula-engine functions that are undefined in a JavaScript block, mapped to
|
|
635
|
+
* the spelling that works there.
|
|
636
|
+
*
|
|
637
|
+
* A Map, not an object: the key is a name read out of the script, and on an
|
|
638
|
+
* object `toString` and `constructor` would answer from the prototype — a
|
|
639
|
+
* declared `function toString()` would be reported as formula-only, with the
|
|
640
|
+
* native function's source offered as the replacement.
|
|
641
|
+
*/
|
|
642
|
+
const FORMULA_ONLY = new Map<string, string>([
|
|
643
|
+
["TODAY", "now()"],
|
|
644
|
+
["NOW", "now()"],
|
|
645
|
+
["CURRENT_USER_ID", "currentUserId()"],
|
|
646
|
+
]);
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Keywords a `(` may legally follow. `parsed.calls` is every `name(` in the
|
|
650
|
+
* body, so these arrive looking like calls — `return (x)`, `typeof (v)` and
|
|
651
|
+
* `for (x of (xs))` included — and are not calls to anything.
|
|
652
|
+
*/
|
|
653
|
+
const KEYWORDS_BEFORE_PAREN = new Set([
|
|
654
|
+
"function", "return", "typeof", "instanceof", "new", "delete", "void",
|
|
655
|
+
"in", "of", "do", "else", "try", "throw", "yield", "await",
|
|
656
|
+
]);
|
|
657
|
+
|
|
658
|
+
/** Globals Jint exposes. Real calls, and none of them ours to report. */
|
|
659
|
+
const JS_GLOBALS = new Set([
|
|
660
|
+
"parseInt", "parseFloat", "isNaN", "isFinite", "encodeURI", "decodeURI",
|
|
661
|
+
"encodeURIComponent", "decodeURIComponent", "String", "Number", "Boolean",
|
|
662
|
+
"Array", "Object", "Date", "Math", "JSON", "RegExp", "Error", "Set", "Map",
|
|
663
|
+
"WeakSet", "WeakMap", "Promise", "Symbol", "BigInt",
|
|
664
|
+
]);
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Keyword literals, which `[…]` can only be holding as array elements. Both
|
|
668
|
+
* spellings: the DSL takes SQL-style `NULL`/`TRUE`/`FALSE` as well as the
|
|
669
|
+
* JavaScript ones, and rewrites them to the latter.
|
|
670
|
+
*/
|
|
671
|
+
const LITERAL_NAMES = new Set([
|
|
672
|
+
"true",
|
|
673
|
+
"false",
|
|
674
|
+
"null",
|
|
675
|
+
"TRUE",
|
|
676
|
+
"FALSE",
|
|
677
|
+
"NULL",
|
|
678
|
+
]);
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* SQL with its string literals and comments blanked out, so a scan for
|
|
682
|
+
* placeholders reads only the statement. Same length in, same length out.
|
|
683
|
+
*
|
|
684
|
+
* Quotes double to escape themselves in SQL (`'it''s'`), which is why this
|
|
685
|
+
* cannot be a regex the way the JavaScript lexer's strings can.
|
|
686
|
+
*/
|
|
687
|
+
function stripSqlNoise(sql: string): string {
|
|
688
|
+
let out = "";
|
|
689
|
+
let i = 0;
|
|
690
|
+
while (i < sql.length) {
|
|
691
|
+
const c = sql[i]!;
|
|
692
|
+
|
|
693
|
+
if (c === "'" || c === '"') {
|
|
694
|
+
const quote = c;
|
|
695
|
+
out += " ";
|
|
696
|
+
i++;
|
|
697
|
+
while (i < sql.length) {
|
|
698
|
+
if (sql[i] === quote && sql[i + 1] === quote) {
|
|
699
|
+
out += " ";
|
|
700
|
+
i += 2;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (sql[i] === quote) break;
|
|
704
|
+
out += sql[i] === "\n" ? "\n" : " ";
|
|
705
|
+
i++;
|
|
706
|
+
}
|
|
707
|
+
if (i < sql.length) {
|
|
708
|
+
out += " ";
|
|
709
|
+
i++;
|
|
710
|
+
}
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (c === "-" && sql[i + 1] === "-") {
|
|
715
|
+
while (i < sql.length && sql[i] !== "\n") {
|
|
716
|
+
out += " ";
|
|
717
|
+
i++;
|
|
718
|
+
}
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (c === "/" && sql[i + 1] === "*") {
|
|
723
|
+
out += " ";
|
|
724
|
+
i += 2;
|
|
725
|
+
while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) {
|
|
726
|
+
out += sql[i] === "\n" ? "\n" : " ";
|
|
727
|
+
i++;
|
|
728
|
+
}
|
|
729
|
+
if (i < sql.length) {
|
|
730
|
+
out += " ";
|
|
731
|
+
i += 2;
|
|
732
|
+
}
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
out += c;
|
|
737
|
+
i++;
|
|
738
|
+
}
|
|
739
|
+
return out;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function blockOf(parsed: DslDocument, offset: number) {
|
|
743
|
+
return (
|
|
744
|
+
parsed.blocks.find((b) => offset >= b.body.start && offset < b.body.end)
|
|
745
|
+
?.kind ?? null
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* `if (…) { [field] = x }` on a single line mis-compiles — the assignment is
|
|
751
|
+
* rewritten in place and the setter call is lost. One assignment per line.
|
|
752
|
+
*/
|
|
753
|
+
function inlineAssignmentIssues(
|
|
754
|
+
parsed: DslDocument,
|
|
755
|
+
positionAt: (offset: number) => { line: number; column: number },
|
|
756
|
+
): DslIssue[] {
|
|
757
|
+
const out: DslIssue[] = [];
|
|
758
|
+
for (const ref of parsed.fieldRefs) {
|
|
759
|
+
// `ref.endIndex` is where the parser stopped: a `.prop` hop is 2 tokens
|
|
760
|
+
// but a `.[prop]` hop is 4, so the count cannot be derived here.
|
|
761
|
+
const assign = parsed.tokens[ref.endIndex + 1];
|
|
762
|
+
if (!assign || assign.kind !== "punct" || assign.text !== "=") continue;
|
|
763
|
+
|
|
764
|
+
// Walk back over this line only, looking for a brace that opened here.
|
|
765
|
+
const line = parsed.tokens[ref.startIndex]!.line;
|
|
766
|
+
let openedHere = false;
|
|
767
|
+
for (let i = ref.startIndex - 1; i >= 0; i--) {
|
|
768
|
+
const t = parsed.tokens[i]!;
|
|
769
|
+
if (t.line !== line) break;
|
|
770
|
+
if (t.text === "{") {
|
|
771
|
+
openedHere = true;
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (!openedHere) continue;
|
|
776
|
+
|
|
777
|
+
const pos = positionAt(ref.outerSpan.start);
|
|
778
|
+
out.push({
|
|
779
|
+
rule: "dsl/inline-assignment",
|
|
780
|
+
severity: "warning",
|
|
781
|
+
message:
|
|
782
|
+
"An assignment to a record field on the same line as the brace that opens its block mis-compiles. Put the assignment on its own line.",
|
|
783
|
+
start: ref.outerSpan.start,
|
|
784
|
+
end: ref.outerSpan.end,
|
|
785
|
+
line: pos.line,
|
|
786
|
+
column: pos.column,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
return out;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Offset → 1-indexed line/column, over a line-start table built once per check.
|
|
794
|
+
* Scanning the prefix per issue is O(n) each, and a body with many issues is
|
|
795
|
+
* exactly the one where that shows.
|
|
796
|
+
*/
|
|
797
|
+
function lineIndexer(text: string): (offset: number) => { line: number; column: number } {
|
|
798
|
+
const starts = [0];
|
|
799
|
+
for (let i = 0; i < text.length; i++) {
|
|
800
|
+
if (text[i] === "\n") starts.push(i + 1);
|
|
801
|
+
}
|
|
802
|
+
return (offset: number) => {
|
|
803
|
+
// Binary search for the last line start at or before `offset`.
|
|
804
|
+
let lo = 0;
|
|
805
|
+
let hi = starts.length - 1;
|
|
806
|
+
while (lo < hi) {
|
|
807
|
+
const mid = (lo + hi + 1) >> 1;
|
|
808
|
+
if (starts[mid]! <= offset) lo = mid;
|
|
809
|
+
else hi = mid - 1;
|
|
810
|
+
}
|
|
811
|
+
return { line: lo + 1, column: offset - starts[lo]! + 1 };
|
|
812
|
+
};
|
|
813
|
+
}
|