@tikoci/centrs 0.1.9-next.23 → 0.1.9-next.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 +106 -0
- package/GLOSSARY.txt +14 -0
- package/README.md +2 -2
- package/commands/api/AGENTS.md +18 -1
- package/commands/api/README.md +10 -6
- package/commands/api/examples.md +1 -1
- package/commands/execute/README.md +26 -6
- package/commands/execute/examples.md +24 -5
- package/commands/explain/README.md +452 -61
- package/commands/explain/examples.md +191 -41
- package/commands/retrieve/README.md +13 -2
- package/commands/retrieve/examples.md +30 -1
- package/commands/transfer/README.md +8 -4
- package/docs/CLI.md +19 -5
- package/docs/CONSTITUTION.md +14 -9
- package/docs/MATRIX.md +34 -21
- package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
- package/docs/errors/routeros/permission-denied.md +24 -0
- package/docs/errors/transport/edge-auth-failed.md +22 -0
- package/docs/errors/transport/write-outcome-unknown.md +3 -0
- package/docs/errors/usage/invalid-cursor.md +20 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +2 -2
- package/src/api.ts +34 -39
- package/src/cli/explain.ts +206 -47
- package/src/core/envelope.ts +10 -3
- package/src/core/error-catalog.ts +15 -0
- package/src/core/execute-command.ts +53 -0
- package/src/core/fanout.ts +12 -2
- package/src/core/inspect.ts +4 -5
- package/src/core/routeros-errors.ts +25 -0
- package/src/execute-cmr.ts +2 -8
- package/src/execute.ts +48 -20
- package/src/explain/args.ts +115 -10
- package/src/explain/group-faults.ts +162 -29
- package/src/explain/operators.ts +2 -1
- package/src/explain/scope-brace.ts +226 -11
- package/src/explain/segment.ts +136 -1
- package/src/explain/symbols.ts +146 -6
- package/src/explain/values.ts +55 -4
- package/src/explain-live-facets.ts +739 -0
- package/src/explain-live-types.ts +490 -0
- package/src/explain-live.ts +593 -0
- package/src/explain.ts +395 -43
- package/src/index.ts +6 -0
- package/src/protocols/adapter.ts +88 -10
- package/src/protocols/mac-telnet.ts +38 -10
- package/src/protocols/native-api.ts +13 -12
- package/src/retrieve-follow.ts +2 -1
- package/src/retrieve-sample.ts +2 -1
- package/src/retrieve-wait.ts +10 -6
- package/src/retrieve.ts +77 -27
- package/src/transfer.ts +38 -13
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live `--schema` (describe) and `--complete` (cursor completion) for explain
|
|
3
|
+
* (#492). Read-only `/console/inspect` only; the analyzed input never runs.
|
|
4
|
+
*
|
|
5
|
+
* ## Which probe shapes are allowed (CHR 7.12.2 / 7.20.8 / 7.23.8 / 7.24.5)
|
|
6
|
+
*
|
|
7
|
+
* - `child` at any path: names, node kinds, unique-abbreviation resolution.
|
|
8
|
+
* - `syntax` at an ARGUMENT path (`ip,address,add,comment`, `put,message`):
|
|
9
|
+
* declared type text.
|
|
10
|
+
* - `completion` with `input=` (the text before the cursor) at any position,
|
|
11
|
+
* scripting keywords included: the device's own candidates.
|
|
12
|
+
*
|
|
13
|
+
* Never sent: `syntax` or `completion` with `path=` set to a COMMAND
|
|
14
|
+
* (`ip,address,set`). On 7.12.2 and 7.20.8 REST stops answering for about
|
|
15
|
+
* 30 s and native API closes the connection (`commands/api/AGENTS.md`). The
|
|
16
|
+
* restraml crawlers never hit this: they send `syntax` to `arg` nodes only.
|
|
17
|
+
*
|
|
18
|
+
* ## Where the cursor is comes from the device
|
|
19
|
+
*
|
|
20
|
+
* Candidates and their replace offset come from input completion, so a
|
|
21
|
+
* relative path, a block or a `where` clause completes exactly as on the
|
|
22
|
+
* console. The highlight classes of the input before the cursor name the
|
|
23
|
+
* context (path, argument name, variable, value). The command whose argument
|
|
24
|
+
* syntax is read for a value is the offline statement when it resolved,
|
|
25
|
+
* otherwise the `dir`/`cmd` runs the device highlighted.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
extractCompletionNames,
|
|
29
|
+
type InspectCompletionItem,
|
|
30
|
+
pathTokens,
|
|
31
|
+
} from "./core/inspect.ts";
|
|
32
|
+
import { CentrsError } from "./errors.ts";
|
|
33
|
+
import {
|
|
34
|
+
analyzeCoordinates,
|
|
35
|
+
byteToPosition,
|
|
36
|
+
positionToByte,
|
|
37
|
+
runAtByte,
|
|
38
|
+
} from "./explain/coordinates.ts";
|
|
39
|
+
import type {
|
|
40
|
+
ExplainCompletion,
|
|
41
|
+
ExplainCompletionCandidate,
|
|
42
|
+
ExplainEvidence,
|
|
43
|
+
ExplainSchema,
|
|
44
|
+
ExplainSchemaChild,
|
|
45
|
+
ExplainStatement,
|
|
46
|
+
ExplainSubcommand,
|
|
47
|
+
ExplainSyntaxRow,
|
|
48
|
+
} from "./explain.ts";
|
|
49
|
+
import type { LiveProbeContext } from "./explain-live.ts";
|
|
50
|
+
|
|
51
|
+
export interface ExplainLiveFacets {
|
|
52
|
+
schema?: boolean;
|
|
53
|
+
complete?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Default: end of input. A number is an analyzed byte offset (the device's
|
|
56
|
+
* UTF-8 byte offset); an object is an original-document position. Either
|
|
57
|
+
* snaps back to a character boundary.
|
|
58
|
+
*/
|
|
59
|
+
cursor?: number | { line: number; character: number };
|
|
60
|
+
/** Lift the sizing limits up to the safety ceiling. */
|
|
61
|
+
full?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Listed names per list, and expansion probes per describe. */
|
|
65
|
+
export const EXPLAIN_FACET_LIMITS = {
|
|
66
|
+
items: { default: 64, full: 1024 },
|
|
67
|
+
probes: { default: 16, full: 128 },
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
/** Validate a cursor before any connection is made. */
|
|
71
|
+
export function resolveExplainCursor(
|
|
72
|
+
input: string,
|
|
73
|
+
cursor: ExplainLiveFacets["cursor"],
|
|
74
|
+
): number {
|
|
75
|
+
const coordinates = analyzeCoordinates(input);
|
|
76
|
+
const bytes = coordinates.analyzed.length;
|
|
77
|
+
if (cursor === undefined) return bytes;
|
|
78
|
+
let byte: number;
|
|
79
|
+
if (typeof cursor === "number") byte = cursor;
|
|
80
|
+
else {
|
|
81
|
+
try {
|
|
82
|
+
byte = positionToByte(coordinates, cursor.line, cursor.character);
|
|
83
|
+
} catch {
|
|
84
|
+
throw invalidCursor(
|
|
85
|
+
`(line ${cursor.line}, character ${cursor.character})`,
|
|
86
|
+
bytes,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!Number.isInteger(byte) || byte < 0 || byte > bytes)
|
|
91
|
+
throw invalidCursor(String(cursor), bytes);
|
|
92
|
+
return byte === bytes ? byte : runAtByte(coordinates, byte).byteStart;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function invalidCursor(value: string, bytes: number): CentrsError {
|
|
96
|
+
return new CentrsError({
|
|
97
|
+
code: "usage/invalid-cursor",
|
|
98
|
+
summary: `Cursor ${value} is not inside the ${bytes}-byte input.`,
|
|
99
|
+
remediation: `Pass a UTF-8 byte offset from 0 to ${bytes}; omit --cursor to complete at the end of the input.`,
|
|
100
|
+
context: { cursor: value, bytes },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function collectExplainFacets(
|
|
105
|
+
ctx: LiveProbeContext,
|
|
106
|
+
facets: ExplainLiveFacets,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
const cursor = resolveExplainCursor(ctx.input, facets.cursor);
|
|
109
|
+
const limits = {
|
|
110
|
+
items: EXPLAIN_FACET_LIMITS.items[facets.full ? "full" : "default"],
|
|
111
|
+
probes: EXPLAIN_FACET_LIMITS.probes[facets.full ? "full" : "default"],
|
|
112
|
+
};
|
|
113
|
+
if (facets.schema) ctx.data.schema = await describe(ctx, cursor, limits);
|
|
114
|
+
if (facets.complete)
|
|
115
|
+
ctx.data.completion = await complete(ctx, cursor, limits);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type Limits = { items: number; probes: number };
|
|
119
|
+
type ChildRow = { type?: string; name?: string; "node-type"?: string };
|
|
120
|
+
type Reading = ExplainStatement | ExplainSubcommand;
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------- describe
|
|
123
|
+
|
|
124
|
+
async function describe(
|
|
125
|
+
ctx: LiveProbeContext,
|
|
126
|
+
cursor: number,
|
|
127
|
+
limits: Limits,
|
|
128
|
+
): Promise<ExplainSchema> {
|
|
129
|
+
const reading = readingAt(ctx, cursor);
|
|
130
|
+
const tokens = describeTokens(ctx, reading);
|
|
131
|
+
const path = slashPath(tokens ?? []);
|
|
132
|
+
const base = {
|
|
133
|
+
path,
|
|
134
|
+
...statementIdOf(reading),
|
|
135
|
+
counts: {},
|
|
136
|
+
truncated: false,
|
|
137
|
+
next: [] as string[],
|
|
138
|
+
};
|
|
139
|
+
if (tokens === undefined) {
|
|
140
|
+
const ev = ctx.evidence("child", {
|
|
141
|
+
facet: "schema",
|
|
142
|
+
outcome: "skipped",
|
|
143
|
+
reason:
|
|
144
|
+
"The statement at the cursor is not an absolute path or a resolved command.",
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
...base,
|
|
148
|
+
path: "",
|
|
149
|
+
ev: ev.id,
|
|
150
|
+
node: "unknown",
|
|
151
|
+
reason: ev.reason,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const probe = await childProbe(ctx, tokens, "schema");
|
|
155
|
+
const schema: ExplainSchema = { ...base, ev: probe.ev.id, node: "unknown" };
|
|
156
|
+
if (!probe.rows) {
|
|
157
|
+
schema.reason = probe.ev.reason;
|
|
158
|
+
return schema;
|
|
159
|
+
}
|
|
160
|
+
const self = probe.rows.find((row) => row.type === "self");
|
|
161
|
+
const children = probe.rows.filter((row) => row.type === "child");
|
|
162
|
+
const kind = self?.["node-type"];
|
|
163
|
+
if (!self || kind === undefined) {
|
|
164
|
+
schema.reason = "The device reports nothing at this path.";
|
|
165
|
+
return schema;
|
|
166
|
+
}
|
|
167
|
+
if (kind === "arg") {
|
|
168
|
+
schema.node = "argument";
|
|
169
|
+
const syntax = await syntaxProbe(ctx, tokens, "schema");
|
|
170
|
+
if (syntax.rows) schema.syntax = syntax.rows;
|
|
171
|
+
return schema;
|
|
172
|
+
}
|
|
173
|
+
if (kind === "cmd") {
|
|
174
|
+
schema.node = "command";
|
|
175
|
+
const names = childNames(children, (k) => k === "arg");
|
|
176
|
+
schema.counts.arguments = names.length;
|
|
177
|
+
const listed = cut(names, limits.items, schema);
|
|
178
|
+
schema.arguments = listed.map((name) => ({ name }));
|
|
179
|
+
if (listed.length <= limits.probes)
|
|
180
|
+
for (const argument of schema.arguments) {
|
|
181
|
+
const syntax = await syntaxProbe(
|
|
182
|
+
ctx,
|
|
183
|
+
[...tokens, argument.name],
|
|
184
|
+
"schema",
|
|
185
|
+
);
|
|
186
|
+
argument.ev = syntax.ev.id;
|
|
187
|
+
if (syntax.rows) argument.syntax = syntax.rows;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
schema.truncated = true;
|
|
191
|
+
schema.next = listed.slice(0, 3).map((name) => `${path}/${name}`);
|
|
192
|
+
}
|
|
193
|
+
if (tokens.at(-1) === "print" && tokens.length > 1)
|
|
194
|
+
schema.proplist = await proplist(
|
|
195
|
+
ctx,
|
|
196
|
+
tokens.slice(0, -1),
|
|
197
|
+
schema,
|
|
198
|
+
limits,
|
|
199
|
+
);
|
|
200
|
+
return schema;
|
|
201
|
+
}
|
|
202
|
+
schema.node = "menu";
|
|
203
|
+
const verbs = childNames(children, (k) => k === "cmd");
|
|
204
|
+
const menus = childNames(children, (k) => k === "dir" || k === "path");
|
|
205
|
+
schema.counts.verbs = verbs.length;
|
|
206
|
+
schema.counts.menus = menus.length;
|
|
207
|
+
schema.verbs = cut(verbs, limits.items, schema).map((name) => ({ name }));
|
|
208
|
+
schema.menus = cut(menus, limits.items, schema).map((name) => ({ name }));
|
|
209
|
+
const expand = [...schema.verbs, ...schema.menus];
|
|
210
|
+
if (expand.length <= limits.probes)
|
|
211
|
+
for (const child of expand) await expandChild(ctx, tokens, child);
|
|
212
|
+
else {
|
|
213
|
+
schema.truncated = true;
|
|
214
|
+
schema.next = [...schema.verbs, ...schema.menus]
|
|
215
|
+
.slice(0, 3)
|
|
216
|
+
.map((child) => `${path === "/" ? "" : path}/${child.name}`);
|
|
217
|
+
}
|
|
218
|
+
return schema;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** One more `child` per listed name: argument counts for a verb, sizes for a menu. */
|
|
222
|
+
async function expandChild(
|
|
223
|
+
ctx: LiveProbeContext,
|
|
224
|
+
tokens: string[],
|
|
225
|
+
child: ExplainSchemaChild,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
const probe = await childProbe(ctx, [...tokens, child.name], "schema");
|
|
228
|
+
child.ev = probe.ev.id;
|
|
229
|
+
if (!probe.rows) return;
|
|
230
|
+
const rows = probe.rows.filter((row) => row.type === "child");
|
|
231
|
+
const kind = probe.rows.find((row) => row.type === "self")?.["node-type"];
|
|
232
|
+
if (kind === "cmd")
|
|
233
|
+
child.arguments = childNames(rows, (k) => k === "arg").length;
|
|
234
|
+
else {
|
|
235
|
+
child.verbs = childNames(rows, (k) => k === "cmd").length;
|
|
236
|
+
child.menus = childNames(rows, (k) => k === "dir" || k === "path").length;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* `print proplist=` names; a singleton settings menu offers none, so its
|
|
242
|
+
* `get value-name=` set answers instead (CHR 7.24.5: `/ip/dns`, `/system/identity`).
|
|
243
|
+
*/
|
|
244
|
+
async function proplist(
|
|
245
|
+
ctx: LiveProbeContext,
|
|
246
|
+
menu: string[],
|
|
247
|
+
schema: ExplainSchema,
|
|
248
|
+
limits: Limits,
|
|
249
|
+
): Promise<ExplainSchema["proplist"]> {
|
|
250
|
+
let result: ExplainSchema["proplist"];
|
|
251
|
+
for (const [source, verb, input] of [
|
|
252
|
+
["print-proplist", "print", `${slashPath(menu)}/print proplist=`],
|
|
253
|
+
["get-value-name", "get", `${slashPath(menu)}/get value-name=`],
|
|
254
|
+
] as const) {
|
|
255
|
+
const probe = await completionProbe(
|
|
256
|
+
ctx,
|
|
257
|
+
input,
|
|
258
|
+
slashPath([...menu, verb]),
|
|
259
|
+
"schema",
|
|
260
|
+
);
|
|
261
|
+
if (!probe.rows) return result;
|
|
262
|
+
const names = [...new Set(shownNames(probe.rows))].sort();
|
|
263
|
+
result = { ev: probe.ev.id, source, names };
|
|
264
|
+
if (names.length > 0) break;
|
|
265
|
+
}
|
|
266
|
+
if (result) {
|
|
267
|
+
schema.counts.proplist = result.names.length;
|
|
268
|
+
result.names = cut(result.names, limits.items, schema);
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The path `--schema` describes: the resolved statement, or an absolute path as typed. */
|
|
274
|
+
function describeTokens(
|
|
275
|
+
ctx: LiveProbeContext,
|
|
276
|
+
reading: Reading | undefined,
|
|
277
|
+
): string[] | undefined {
|
|
278
|
+
if (reading?.resolution === "resolved")
|
|
279
|
+
return pathTokens(
|
|
280
|
+
reading.kind === "command"
|
|
281
|
+
? `${reading.command.path}/${reading.command.verb}`
|
|
282
|
+
: reading.command.path,
|
|
283
|
+
);
|
|
284
|
+
const text = reading
|
|
285
|
+
? ctx.analyzed.slice(reading.span.start, reading.span.end).trim()
|
|
286
|
+
: ctx.analyzed.trim();
|
|
287
|
+
return /^\/[A-Za-z0-9._/-]*$/.test(text) ? pathTokens(text) : undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------- complete
|
|
291
|
+
|
|
292
|
+
type CursorRole =
|
|
293
|
+
| { context: "path"; start: number }
|
|
294
|
+
| { context: "argument-name"; start: number }
|
|
295
|
+
| { context: "variable"; start: number }
|
|
296
|
+
| { context: "value"; start: number; argument: string; query: boolean }
|
|
297
|
+
| { context: "unsupported"; start: number; reason: string };
|
|
298
|
+
|
|
299
|
+
async function complete(
|
|
300
|
+
ctx: LiveProbeContext,
|
|
301
|
+
cursor: number,
|
|
302
|
+
limits: Limits,
|
|
303
|
+
): Promise<ExplainCompletion> {
|
|
304
|
+
const at = byteToPosition(analyzeCoordinates(ctx.input), cursor);
|
|
305
|
+
const reading = readingAt(ctx, cursor);
|
|
306
|
+
const result: ExplainCompletion = {
|
|
307
|
+
cursor,
|
|
308
|
+
at: { line: at.line, character: at.col },
|
|
309
|
+
...statementIdOf(reading),
|
|
310
|
+
context: "unsupported",
|
|
311
|
+
replace: { start: cursor, end: cursor },
|
|
312
|
+
prefix: "",
|
|
313
|
+
candidates: [],
|
|
314
|
+
count: 0,
|
|
315
|
+
truncated: false,
|
|
316
|
+
};
|
|
317
|
+
const classes = await cursorClasses(ctx, cursor);
|
|
318
|
+
if (!classes) {
|
|
319
|
+
result.reason =
|
|
320
|
+
"No device highlight covers the cursor, so its role is unknown.";
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
const role = cursorRole(ctx.analyzed, classes, cursor);
|
|
324
|
+
result.replace = { start: role.start, end: cursor };
|
|
325
|
+
result.prefix = ctx.analyzed.slice(role.start, cursor);
|
|
326
|
+
if (role.context === "unsupported") {
|
|
327
|
+
result.reason = role.reason;
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
result.context = role.context;
|
|
331
|
+
const command =
|
|
332
|
+
role.context === "path"
|
|
333
|
+
? undefined
|
|
334
|
+
: reading?.resolution === "resolved" && reading.kind === "command"
|
|
335
|
+
? pathTokens(`${reading.command.path}/${reading.command.verb}`)
|
|
336
|
+
: commandPath(ctx.analyzed, classes, role.start);
|
|
337
|
+
if (command) result.path = slashPath(command);
|
|
338
|
+
if (role.context === "value") result.argument = role.argument;
|
|
339
|
+
|
|
340
|
+
const probe = await completionProbe(
|
|
341
|
+
ctx,
|
|
342
|
+
ctx.analyzed.slice(0, cursor),
|
|
343
|
+
result.path ?? "",
|
|
344
|
+
"complete",
|
|
345
|
+
);
|
|
346
|
+
const rows = probe.rows ?? [];
|
|
347
|
+
// Navigation punctuation (`/`, `:`, `..`) is offered everywhere; it names nothing.
|
|
348
|
+
const shown = rows.filter(
|
|
349
|
+
(row) =>
|
|
350
|
+
row["show"] === "true" &&
|
|
351
|
+
row["style"] !== "syntax-meta" &&
|
|
352
|
+
/[A-Za-z0-9]/.test(String(row["completion"] ?? "")),
|
|
353
|
+
);
|
|
354
|
+
const offset = Number(shown[0]?.["offset"]);
|
|
355
|
+
if (Number.isInteger(offset) && offset >= 0 && offset <= cursor) {
|
|
356
|
+
result.replace = { start: offset, end: cursor };
|
|
357
|
+
result.prefix = ctx.analyzed.slice(offset, cursor);
|
|
358
|
+
}
|
|
359
|
+
const candidates = new Map<string, ExplainCompletionCandidate>();
|
|
360
|
+
for (const row of shown) {
|
|
361
|
+
const text = String(row["completion"]).trim();
|
|
362
|
+
const kind: ExplainCompletionCandidate["kind"] =
|
|
363
|
+
role.context === "path"
|
|
364
|
+
? row["style"] === "cmd"
|
|
365
|
+
? "verb"
|
|
366
|
+
: "menu"
|
|
367
|
+
: role.context === "argument-name"
|
|
368
|
+
? "argument"
|
|
369
|
+
: role.context;
|
|
370
|
+
if (!candidates.has(text))
|
|
371
|
+
candidates.set(text, {
|
|
372
|
+
text,
|
|
373
|
+
kind,
|
|
374
|
+
match: text === result.prefix ? "exact" : "partial",
|
|
375
|
+
ev: probe.ev.id,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (
|
|
379
|
+
role.context === "argument-name" &&
|
|
380
|
+
candidates.get(result.prefix)?.match === "exact"
|
|
381
|
+
)
|
|
382
|
+
// The device offers `=` right at the cursor only for a name that takes a value.
|
|
383
|
+
result.takesValue = rows.some(
|
|
384
|
+
(row) =>
|
|
385
|
+
row["style"] === "syntax-meta" &&
|
|
386
|
+
row["completion"] === "=" &&
|
|
387
|
+
Number(row["offset"]) === cursor,
|
|
388
|
+
);
|
|
389
|
+
if (role.context === "value" && command && !role.query) {
|
|
390
|
+
const syntax = await syntaxProbe(
|
|
391
|
+
ctx,
|
|
392
|
+
[...command, role.argument],
|
|
393
|
+
"complete",
|
|
394
|
+
);
|
|
395
|
+
if (syntax.rows) result.syntax = syntax.rows;
|
|
396
|
+
}
|
|
397
|
+
return finish(result, [...candidates.values()], limits, probe.ev);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function finish(
|
|
401
|
+
result: ExplainCompletion,
|
|
402
|
+
candidates: ExplainCompletionCandidate[],
|
|
403
|
+
limits: Limits,
|
|
404
|
+
ev: ExplainEvidence,
|
|
405
|
+
): ExplainCompletion {
|
|
406
|
+
result.count = candidates.length;
|
|
407
|
+
result.truncated = candidates.length > limits.items;
|
|
408
|
+
result.candidates = candidates.slice(0, limits.items);
|
|
409
|
+
if (ev.outcome !== "ok" && ev.outcome !== "empty") result.reason = ev.reason;
|
|
410
|
+
return result;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Highlight classes for the input before the cursor, reusing the full probe at the end. */
|
|
414
|
+
async function cursorClasses(
|
|
415
|
+
ctx: LiveProbeContext,
|
|
416
|
+
cursor: number,
|
|
417
|
+
): Promise<string[] | undefined> {
|
|
418
|
+
if (cursor === ctx.analyzed.length) return ctx.data.live?.highlight?.classes;
|
|
419
|
+
if (cursor === 0) return [];
|
|
420
|
+
const prefix = ctx.analyzed.slice(0, cursor);
|
|
421
|
+
const ev = ctx.evidence("highlight", {
|
|
422
|
+
facet: "complete",
|
|
423
|
+
coverage: { start: 0, end: cursor },
|
|
424
|
+
inputSha256: ctx.sha256(prefix),
|
|
425
|
+
normalization: "sub-per-non-ascii-byte",
|
|
426
|
+
});
|
|
427
|
+
if (cursor > ctx.highlightBytes) {
|
|
428
|
+
ev.outcome = "skipped";
|
|
429
|
+
ev.reason = `The input before the cursor exceeds the ${ctx.highlightBytes}-byte highlight limit.`;
|
|
430
|
+
delete ev.coverage;
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
const rows = await ctx.run(ev, () =>
|
|
434
|
+
ctx.backend.inspect("highlight", "", prefix),
|
|
435
|
+
);
|
|
436
|
+
const row: unknown = Array.isArray(rows) ? rows[0] : undefined;
|
|
437
|
+
const classes =
|
|
438
|
+
row &&
|
|
439
|
+
typeof row === "object" &&
|
|
440
|
+
"highlight" in row &&
|
|
441
|
+
typeof row.highlight === "string"
|
|
442
|
+
? row.highlight.split(",")
|
|
443
|
+
: undefined;
|
|
444
|
+
if (rows !== undefined && classes?.length !== cursor) {
|
|
445
|
+
ev.outcome = "invalid-response";
|
|
446
|
+
ev.reason = "Highlight length does not match the bytes before the cursor.";
|
|
447
|
+
delete ev.coverage;
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
return classes;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// cspell:ignore omm forw
|
|
454
|
+
const WORD = /[A-Za-z0-9._-]/;
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The cursor's role from the device classes (CHR 7.24.5):
|
|
458
|
+
* `dir[/ip/address] cmd[add] error[c] arg[omm]`, `arg[chain] syntax-meta[=] none[forw]`.
|
|
459
|
+
* A word the device cannot resolve there is `obj-inactive` (`dir[:] obj-inactive[lo]`,
|
|
460
|
+
* `cmd[add] none[ ] obj-inactive[zz]`); what precedes it gives its role.
|
|
461
|
+
*/
|
|
462
|
+
export function cursorRole(
|
|
463
|
+
text: string,
|
|
464
|
+
classes: readonly string[],
|
|
465
|
+
cursor: number,
|
|
466
|
+
): CursorRole {
|
|
467
|
+
const before = text[cursor - 1];
|
|
468
|
+
if (before === undefined) return { context: "path", start: 0 };
|
|
469
|
+
if (!/\s/.test(before)) {
|
|
470
|
+
const cls = classes[cursor - 1];
|
|
471
|
+
let start = cursor;
|
|
472
|
+
while (start > 0 && WORD.test(text[start - 1] ?? "")) start--;
|
|
473
|
+
if (cls === "arg") return { context: "argument-name", start };
|
|
474
|
+
if (cls === "dir" || cls === "cmd") return { context: "path", start };
|
|
475
|
+
if (cls?.startsWith("variable-")) return { context: "variable", start };
|
|
476
|
+
const value = valueStart(text, classes, cursor);
|
|
477
|
+
if (value) return { context: "value", ...value };
|
|
478
|
+
if (cls === "obj-inactive") return followingRole(text, classes, start);
|
|
479
|
+
return {
|
|
480
|
+
context: "unsupported",
|
|
481
|
+
start: cursor,
|
|
482
|
+
reason: `The cursor is in a \`${cls ?? "unknown"}\` token; completion covers paths, argument names and argument values.`,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
if (/[\r\n]/.test(before)) return { context: "path", start: cursor };
|
|
486
|
+
if (classes[cursor - 1] === "syntax-obsolete")
|
|
487
|
+
return {
|
|
488
|
+
context: "unsupported",
|
|
489
|
+
start: cursor,
|
|
490
|
+
reason:
|
|
491
|
+
"The cursor follows an argument name and a space (obsolete `name value` spelling); use `name=`.",
|
|
492
|
+
};
|
|
493
|
+
// `where ⎀`: the device marks the query's separating space as syntax.
|
|
494
|
+
if (classes[cursor - 1] === "syntax-meta")
|
|
495
|
+
return { context: "variable", start: cursor };
|
|
496
|
+
return followingRole(text, classes, cursor);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The role of a word starting at `start`, from the token before it. */
|
|
500
|
+
function followingRole(
|
|
501
|
+
text: string,
|
|
502
|
+
classes: readonly string[],
|
|
503
|
+
start: number,
|
|
504
|
+
): CursorRole {
|
|
505
|
+
let last = start - 1;
|
|
506
|
+
while (last >= 0 && /[ \t]/.test(text[last] ?? "")) last--;
|
|
507
|
+
const cls = classes[last];
|
|
508
|
+
if (
|
|
509
|
+
cls === "cmd" ||
|
|
510
|
+
cls === "arg" ||
|
|
511
|
+
(cls === "none" && valueStart(text, classes, last + 1))
|
|
512
|
+
)
|
|
513
|
+
return { context: "argument-name", start };
|
|
514
|
+
if (cls === "dir") return { context: "path", start };
|
|
515
|
+
return {
|
|
516
|
+
context: "unsupported",
|
|
517
|
+
start,
|
|
518
|
+
reason: "The cursor does not follow a path, a command or an argument.",
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* `arg[name] syntax-meta[=] none[value]` ending at `end`. In a `where` clause
|
|
524
|
+
* the name is a `variable-*` (`variable-local[disabled] syntax-meta[=]`).
|
|
525
|
+
*/
|
|
526
|
+
function valueStart(
|
|
527
|
+
text: string,
|
|
528
|
+
classes: readonly string[],
|
|
529
|
+
end: number,
|
|
530
|
+
): { start: number; argument: string; query: boolean } | undefined {
|
|
531
|
+
let equals = end - 1;
|
|
532
|
+
while (equals >= 0 && classes[equals] === "none") equals--;
|
|
533
|
+
if (text[equals] !== "=" || classes[equals] !== "syntax-meta") return;
|
|
534
|
+
const nameClass = classes[equals - 1];
|
|
535
|
+
const query = nameClass?.startsWith("variable-") === true;
|
|
536
|
+
if (nameClass !== "arg" && !query) return;
|
|
537
|
+
let name = equals;
|
|
538
|
+
while (name > 0 && classes[name - 1] === nameClass) name--;
|
|
539
|
+
return { start: equals + 1, argument: text.slice(name, equals), query };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The command (menu tokens plus the verb) from the `dir`/`cmd` runs before
|
|
544
|
+
* `start`. Groups closed before the cursor are skipped; a `{` continues
|
|
545
|
+
* outward only after a menu (`/ip { address …`). Relative runs are accepted at
|
|
546
|
+
* the start of the input only, where the context is the root.
|
|
547
|
+
*/
|
|
548
|
+
function commandPath(
|
|
549
|
+
text: string,
|
|
550
|
+
classes: readonly string[],
|
|
551
|
+
start: number,
|
|
552
|
+
): string[] | undefined {
|
|
553
|
+
const runs: string[] = [];
|
|
554
|
+
let command: string | undefined;
|
|
555
|
+
let depth = 0;
|
|
556
|
+
let i = start - 1;
|
|
557
|
+
for (; i >= 0; i--) {
|
|
558
|
+
const ch = text[i] ?? "";
|
|
559
|
+
const cls = classes[i];
|
|
560
|
+
if (cls === "syntax-meta" && (ch === "]" || ch === ")" || ch === "}")) {
|
|
561
|
+
depth++;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (cls === "syntax-meta" && (ch === "[" || ch === "(" || ch === "{")) {
|
|
565
|
+
if (depth > 0) {
|
|
566
|
+
depth--;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (ch !== "{" || runs.length === 0 || command === undefined) return;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (depth > 0) continue;
|
|
573
|
+
if (ch === ";" || ch === "\n" || ch === "\r") return;
|
|
574
|
+
if (cls === "cmd") {
|
|
575
|
+
if (command !== undefined || runs.length > 0) return;
|
|
576
|
+
let s = i;
|
|
577
|
+
while (s > 0 && classes[s - 1] === "cmd") s--;
|
|
578
|
+
command = text.slice(s, i + 1);
|
|
579
|
+
i = s;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (cls === "dir") {
|
|
583
|
+
let s = i;
|
|
584
|
+
while (s > 0 && classes[s - 1] === "dir") s--;
|
|
585
|
+
const run = text.slice(s, i + 1);
|
|
586
|
+
runs.unshift(run);
|
|
587
|
+
i = s;
|
|
588
|
+
if (run.startsWith("/") || run.startsWith(":")) break;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (command === undefined) return;
|
|
592
|
+
const absolute = runs[0]?.startsWith("/") || runs[0]?.startsWith(":");
|
|
593
|
+
if (!absolute && i >= 0) return;
|
|
594
|
+
return [
|
|
595
|
+
...runs.flatMap((run) => run.split(/[/\s:]+/).filter(Boolean)),
|
|
596
|
+
command,
|
|
597
|
+
];
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ---------------------------------------------------------------- probes
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The innermost statement or `[…]` substitution at the cursor. Trailing
|
|
604
|
+
* spaces belong to the reading before them (`/ip/address add ⎀`).
|
|
605
|
+
*/
|
|
606
|
+
function readingAt(ctx: LiveProbeContext, cursor: number): Reading | undefined {
|
|
607
|
+
let found: Reading | undefined;
|
|
608
|
+
const { statements, subcommands } = ctx.data.structure;
|
|
609
|
+
for (const reading of [...statements, ...subcommands]) {
|
|
610
|
+
const { start, end } = reading.span;
|
|
611
|
+
const inside =
|
|
612
|
+
start <= cursor &&
|
|
613
|
+
(cursor <= end || /^[ \t]*$/.test(ctx.analyzed.slice(end, cursor)));
|
|
614
|
+
if (inside && (!found || start >= found.span.start)) found = reading;
|
|
615
|
+
}
|
|
616
|
+
return found;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function statementIdOf(reading: Reading | undefined): { statementId?: string } {
|
|
620
|
+
if (!reading) return {};
|
|
621
|
+
return { statementId: "owner" in reading ? reading.owner : reading.id };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function childProbe(
|
|
625
|
+
ctx: LiveProbeContext,
|
|
626
|
+
tokens: string[],
|
|
627
|
+
facet: "schema" | "complete",
|
|
628
|
+
): Promise<{ ev: ExplainEvidence; rows?: ChildRow[] }> {
|
|
629
|
+
const ev = ctx.evidence("child", { facet, path: slashPath(tokens) });
|
|
630
|
+
const rows = await ctx.run(ev, async () => {
|
|
631
|
+
try {
|
|
632
|
+
return await ctx.backend.inspect("child", tokens.join(","));
|
|
633
|
+
} catch (error) {
|
|
634
|
+
// REST answers an unknown path with []; native API traps.
|
|
635
|
+
if (
|
|
636
|
+
error instanceof CentrsError &&
|
|
637
|
+
(error.code === "routeros/unknown-path" ||
|
|
638
|
+
error.code === "routeros/api-trap")
|
|
639
|
+
)
|
|
640
|
+
return [];
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
return { ev, rows: objectRows(ev, rows) as ChildRow[] | undefined };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Argument paths only: a command path hangs REST on 7.12.2 and 7.20.8. */
|
|
648
|
+
async function syntaxProbe(
|
|
649
|
+
ctx: LiveProbeContext,
|
|
650
|
+
tokens: string[],
|
|
651
|
+
facet: "schema" | "complete",
|
|
652
|
+
): Promise<{ ev: ExplainEvidence; rows?: ExplainSyntaxRow[] }> {
|
|
653
|
+
const ev = ctx.evidence("syntax", { facet, path: slashPath(tokens) });
|
|
654
|
+
const rows = objectRows(
|
|
655
|
+
ev,
|
|
656
|
+
await ctx.run(ev, () => ctx.backend.inspect("syntax", tokens.join(","))),
|
|
657
|
+
);
|
|
658
|
+
return {
|
|
659
|
+
ev,
|
|
660
|
+
rows: rows
|
|
661
|
+
?.map((row) => ({
|
|
662
|
+
symbol: String(row["symbol"] ?? ""),
|
|
663
|
+
kind: String(row["symbol-type"] ?? ""),
|
|
664
|
+
text: String(row["text"] ?? ""),
|
|
665
|
+
depth: Number(row["nested"] ?? 0),
|
|
666
|
+
}))
|
|
667
|
+
// A flag's only row is blank (`detail`, `disabled`); it says nothing.
|
|
668
|
+
.filter((row) => row.symbol !== "" || row.text !== ""),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Input-based only; `path=` completion at a command hangs REST on 7.12.2. */
|
|
673
|
+
async function completionProbe(
|
|
674
|
+
ctx: LiveProbeContext,
|
|
675
|
+
input: string,
|
|
676
|
+
path: string,
|
|
677
|
+
facet: "schema" | "complete",
|
|
678
|
+
): Promise<{ ev: ExplainEvidence; rows?: Record<string, unknown>[] }> {
|
|
679
|
+
const ev = ctx.evidence("completion", {
|
|
680
|
+
facet,
|
|
681
|
+
...(path ? { path } : {}),
|
|
682
|
+
inputSha256: ctx.sha256(input),
|
|
683
|
+
normalization: "sub-per-non-ascii-byte",
|
|
684
|
+
});
|
|
685
|
+
return {
|
|
686
|
+
ev,
|
|
687
|
+
rows: objectRows(
|
|
688
|
+
ev,
|
|
689
|
+
await ctx.run(ev, () => ctx.backend.inspect("completion", "", input)),
|
|
690
|
+
),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function objectRows(
|
|
695
|
+
ev: ExplainEvidence,
|
|
696
|
+
rows: unknown[] | undefined,
|
|
697
|
+
): Record<string, unknown>[] | undefined {
|
|
698
|
+
if (rows === undefined) return undefined;
|
|
699
|
+
if (
|
|
700
|
+
!Array.isArray(rows) ||
|
|
701
|
+
rows.some((row) => row === null || typeof row !== "object")
|
|
702
|
+
) {
|
|
703
|
+
ev.outcome = "invalid-response";
|
|
704
|
+
ev.reason = "Response is not an array of rows.";
|
|
705
|
+
return undefined;
|
|
706
|
+
}
|
|
707
|
+
if (rows.length === 0) ev.outcome = "empty";
|
|
708
|
+
return rows as Record<string, unknown>[];
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function shownNames(rows: readonly Record<string, unknown>[]): string[] {
|
|
712
|
+
return extractCompletionNames(rows as readonly InspectCompletionItem[]);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function childNames(
|
|
716
|
+
rows: readonly ChildRow[],
|
|
717
|
+
kind: (nodeType: string | undefined) => boolean,
|
|
718
|
+
): string[] {
|
|
719
|
+
return [
|
|
720
|
+
...new Set(
|
|
721
|
+
rows
|
|
722
|
+
.filter((row) => kind(row["node-type"]))
|
|
723
|
+
.map((row) => row.name)
|
|
724
|
+
.filter(
|
|
725
|
+
(name): name is string => typeof name === "string" && name.length > 0,
|
|
726
|
+
),
|
|
727
|
+
),
|
|
728
|
+
];
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function cut(names: string[], limit: number, schema: ExplainSchema): string[] {
|
|
732
|
+
if (names.length <= limit) return names;
|
|
733
|
+
schema.truncated = true;
|
|
734
|
+
return names.slice(0, limit);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function slashPath(tokens: readonly string[]): string {
|
|
738
|
+
return `/${tokens.join("/")}`;
|
|
739
|
+
}
|