@duet3d/monacotokens 3.6.0 → 3.6.2

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.
@@ -0,0 +1,1581 @@
1
+ import { gcodeData, findGcode } from "./gcodes";
2
+ import { expressionData } from "./expressions";
3
+ import { getMachineContext } from "./objectmodel/machine-context";
4
+ import { getLocalVariables } from "./gcodes/local-variables";
5
+ import { getMemberDeprecation, getPathDeprecation } from "./objectmodel/deprecations";
6
+ import { getEnumValuesForPath } from "./objectmodel/enums";
7
+ // Re-export the runtime-context helpers so consumers (Vue DWC, React DuetWebUI, ...) can install a context
8
+ // without adding a separate import path
9
+ export { getMachineContext, onMachineContextChange } from "./objectmodel/machine-context";
10
+ /**
11
+ * Find the enclosing function call (if any) for the cursor position. Walks back from the end of `beforeCursor`
12
+ * keeping track of paren depth so that `max(a, min(b,|` correctly reports `min` with argIndex 1, not `max`.
13
+ * Skips string content. Returns null if the cursor is not inside a function call.
14
+ */
15
+ function findEnclosingFunctionCall(beforeCursor) {
16
+ let depth = 0;
17
+ let commas = 0;
18
+ let inString = false;
19
+ for (let i = beforeCursor.length - 1; i >= 0; i--) {
20
+ const ch = beforeCursor[i];
21
+ if (inString) {
22
+ if (ch === "\"") {
23
+ inString = false;
24
+ }
25
+ continue;
26
+ }
27
+ if (ch === "\"") {
28
+ inString = true;
29
+ }
30
+ else if (ch === ")") {
31
+ depth++;
32
+ }
33
+ else if (ch === "(") {
34
+ if (depth === 0) {
35
+ // Walk back from i to capture the function identifier
36
+ let j = i - 1;
37
+ while (j >= 0 && /[A-Za-z0-9_]/.test(beforeCursor[j])) {
38
+ j--;
39
+ }
40
+ const name = beforeCursor.substring(j + 1, i);
41
+ if (!name) {
42
+ return null;
43
+ }
44
+ return { name, argIndex: commas };
45
+ }
46
+ depth--;
47
+ }
48
+ else if (ch === "," && depth === 0) {
49
+ commas++;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ /** Parse a syntax label like `atan2(y, x)` into its function name and parameter labels. */
55
+ function parseFunctionSyntax(syntax) {
56
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*(.*?)\s*\)\s*$/.exec(syntax);
57
+ if (!m) {
58
+ return { name: syntax, params: [] };
59
+ }
60
+ const params = m[2].length === 0 ? [] : m[2].split(/\s*,\s*/);
61
+ return { name: m[1], params };
62
+ }
63
+ /**
64
+ * Resolve a dotted/indexed path like `move.axes[0]` against the currently connected machine's object model
65
+ * and local-variable scanner output. Supports the RRF scope prefixes `var` (local declarations), `global`
66
+ * (local + runtime) and any top-level object-model key. Unknown paths return `null`.
67
+ */
68
+ function resolveExpressionPath(path, model) {
69
+ const tokens = [];
70
+ const tokRe = /\.?([A-Za-z_][\w]*)|\[(\d+)\]/g;
71
+ let m;
72
+ while ((m = tokRe.exec(path)) !== null) {
73
+ tokens.push(m[1] !== undefined ? m[1] : Number(m[2]));
74
+ }
75
+ if (tokens.length === 0) {
76
+ return null;
77
+ }
78
+ const root = String(tokens[0]);
79
+ const ctx = getMachineContext();
80
+ const local = getLocalVariables(model);
81
+ let current;
82
+ if (root === "var") {
83
+ // `var.<name>` resolves to a placeholder object carrying the locally-declared names; the value isn't
84
+ // known statically but listing keys is enough for completion
85
+ current = Object.fromEntries([...local.vars].map(v => [v, null]));
86
+ }
87
+ else if (root === "global") {
88
+ const runtime = ctx?.model ? ctx.model.global : null;
89
+ const merged = {};
90
+ for (const n of local.globals) {
91
+ merged[n] = null;
92
+ }
93
+ if (runtime && typeof runtime === "object") {
94
+ if (typeof runtime.keys === "function" && typeof runtime[Symbol.iterator] === "function") {
95
+ for (const k of runtime.keys()) {
96
+ merged[String(k)] = runtime.get ? runtime.get(k) : null;
97
+ }
98
+ }
99
+ else {
100
+ Object.assign(merged, runtime);
101
+ }
102
+ }
103
+ current = merged;
104
+ }
105
+ else if (root === "param") {
106
+ current = null;
107
+ }
108
+ else {
109
+ current = ctx?.model ? ctx.model[root] : null;
110
+ }
111
+ for (let i = 1; i < tokens.length && current != null; i++) {
112
+ const key = tokens[i];
113
+ if (typeof key === "number" && Array.isArray(current)) {
114
+ current = current[key];
115
+ }
116
+ else if (typeof current === "object") {
117
+ current = current[key];
118
+ }
119
+ else {
120
+ current = null;
121
+ }
122
+ }
123
+ return current;
124
+ }
125
+ /**
126
+ * Enumerate public member names of a value for completion. Arrays have no dotted members in RRF's expression
127
+ * syntax - their length is obtained via the `#` prefix operator (e.g. `#move.axes`), and elements via `[n]`.
128
+ * Plain objects return their enumerable own keys.
129
+ */
130
+ function listMemberKeys(value) {
131
+ if (value == null || Array.isArray(value)) {
132
+ return [];
133
+ }
134
+ if (typeof value === "object") {
135
+ return Object.keys(value).filter(k => !k.startsWith("_"));
136
+ }
137
+ return [];
138
+ }
139
+ /**
140
+ * Flatten a machine object-model snapshot into a list of dotted paths (with `[0]` placeholders for arrays).
141
+ * Walks the entire reachable subtree; arrays contribute a single representative `[0]` entry so the list
142
+ * doesn't explode on machines with many tools/axes. Cycles are guarded via a visited WeakSet.
143
+ */
144
+ export function flattenObjectModel(root) {
145
+ if (!root || typeof root !== "object") {
146
+ return [];
147
+ }
148
+ const paths = [];
149
+ const visited = new WeakSet();
150
+ const walk = (value, prefix) => {
151
+ if (value == null || typeof value !== "object" || visited.has(value)) {
152
+ return;
153
+ }
154
+ visited.add(value);
155
+ if (Array.isArray(value)) {
156
+ if (value.length > 0) {
157
+ walk(value[0], `${prefix}[0]`);
158
+ }
159
+ return;
160
+ }
161
+ for (const key of Object.keys(value)) {
162
+ if (key.startsWith("_")) {
163
+ continue;
164
+ }
165
+ const child = value[key];
166
+ const path = prefix ? `${prefix}.${key}` : key;
167
+ paths.push(path);
168
+ if (child !== null && typeof child === "object") {
169
+ walk(child, path);
170
+ }
171
+ }
172
+ };
173
+ walk(root, "");
174
+ return paths;
175
+ }
176
+ /** Walk `beforeCursor` and report whether the cursor sits inside an unclosed `"..."` string literal. */
177
+ function isInsideStringLiteral(beforeCursor) {
178
+ let inString = false;
179
+ for (let i = 0; i < beforeCursor.length; i++) {
180
+ if (beforeCursor[i] === "\"") {
181
+ inString = !inString;
182
+ }
183
+ }
184
+ return inString;
185
+ }
186
+ /** Walk `beforeCursor` and report whether the cursor sits past an unescaped `;` line comment marker. */
187
+ function isInsideLineComment(beforeCursor) {
188
+ let inString = false;
189
+ for (let i = 0; i < beforeCursor.length; i++) {
190
+ const ch = beforeCursor[i];
191
+ if (inString) {
192
+ if (ch === "\"") {
193
+ inString = false;
194
+ }
195
+ continue;
196
+ }
197
+ if (ch === "\"") {
198
+ inString = true;
199
+ }
200
+ else if (ch === ";") {
201
+ return true;
202
+ }
203
+ }
204
+ return false;
205
+ }
206
+ /**
207
+ * Detect whether the cursor sits inside an RRF expression context:
208
+ * - inside a balanced-but-still-open `{ ... }` span, OR
209
+ * - after an `=` on a `set|var|global` line (whole line is expression territory), OR
210
+ * - after `if|elif|while` (condition is an expression).
211
+ */
212
+ export function isInsideExpression(beforeCursor) {
213
+ // Count unmatched `{` up to cursor - quick check first
214
+ let depth = 0;
215
+ let inString = false;
216
+ for (let i = 0; i < beforeCursor.length; i++) {
217
+ const ch = beforeCursor[i];
218
+ if (inString) {
219
+ if (ch === "\"") {
220
+ inString = false;
221
+ }
222
+ continue;
223
+ }
224
+ if (ch === "\"") {
225
+ inString = true;
226
+ }
227
+ else if (ch === "{") {
228
+ depth++;
229
+ }
230
+ else if (ch === "}" && depth > 0) {
231
+ depth--;
232
+ }
233
+ }
234
+ if (depth > 0) {
235
+ return true;
236
+ }
237
+ // Expression-carrying meta keywords. We require plain whitespace (not `\s+\S`) so the condition is treated
238
+ // as expression territory even when the cursor / hovered word sits exactly at the first token after the
239
+ // keyword - e.g. hovering `fileexists` in `if fileexists(...)` inspects beforeCursor `"if "` with nothing
240
+ // past the space, which a `\S` anchor would reject
241
+ if (/^\s*(if|elif|while)\s/.test(beforeCursor)) {
242
+ return true;
243
+ }
244
+ if (/^\s*(set|var|global)\s+[A-Za-z_.][A-Za-z0-9_.]*\s*=/.test(beforeCursor)) {
245
+ return true;
246
+ }
247
+ if (/^\s*(echo|abort)\s+/.test(beforeCursor)) {
248
+ return true;
249
+ }
250
+ return false;
251
+ }
252
+ /**
253
+ * RRF meta-language keywords surfaced in line-start completion. Mirror of the values used by the monaco-gcode tokenizer.
254
+ */
255
+ /** `keyword` is what the user types, `syntax` is the signature shown in the hints tooltip, `description` explains it. */
256
+ const metaKeywords = [
257
+ { keyword: "if", syntax: "if <condition>", description: "Conditional block" },
258
+ { keyword: "elif", syntax: "elif <condition>", description: "Else-if branch of a preceding if block" },
259
+ { keyword: "else", syntax: "else", description: "Else branch of a preceding if block" },
260
+ { keyword: "while", syntax: "while <condition>", description: "Loop block" },
261
+ { keyword: "break", syntax: "break", description: "Exit the enclosing while loop" },
262
+ { keyword: "continue", syntax: "continue", description: "Skip to the next iteration of the enclosing while loop" },
263
+ { keyword: "set", syntax: "set <name> = <expression>", description: "Assign a value to an existing variable" },
264
+ { keyword: "var", syntax: "var <name> = <expression>", description: "Declare a local variable" },
265
+ { keyword: "global", syntax: "global <name> = <expression>", description: "Declare a global variable" },
266
+ { keyword: "abort", syntax: "abort [<message>]", description: "Abort the running macro / queued moves with an optional message" },
267
+ { keyword: "echo", syntax: "echo <expression>", description: "Print an expression to the response channel" }
268
+ ];
269
+ /**
270
+ * Find the closest G/M/T-code to the left of `column` on the given line.
271
+ * Returns the matched code (e.g. "G1") and the column where it starts, or null.
272
+ *
273
+ * A bare `T` is only treated as its own code when it's the first code on the line; otherwise it's a parameter
274
+ * letter of the preceding command (e.g. `M104 T1` - the T belongs to M104, not a separate `T` code).
275
+ */
276
+ export function findCodeAtCursor(line, column) {
277
+ // Local regex so there's no shared lastIndex state to reset between calls. Matches G/M codes with their
278
+ // numeric suffix (e.g. G1, G38.2, M104) or a bare T. Anything after T (tool number, sign, expression) is
279
+ // treated as T's unprecedentedParameter
280
+ const codeRegex = /([GM]\d+(?:\.\d+)?|T(?![A-Za-z]))/g;
281
+ let result = null;
282
+ let haveGMmatch = false;
283
+ let m;
284
+ while ((m = codeRegex.exec(line)) !== null) {
285
+ const start = m.index + 1;
286
+ if (start > column) {
287
+ break;
288
+ }
289
+ const code = m[1];
290
+ if (code === "T" && haveGMmatch) {
291
+ // A standalone `T` following another G/M command on the same line is a parameter of that command
292
+ continue;
293
+ }
294
+ if (code[0] !== "T") {
295
+ haveGMmatch = true;
296
+ }
297
+ result = { code, startColumn: start };
298
+ }
299
+ return result;
300
+ }
301
+ /**
302
+ * Describe a dotted/bracketed identifier chain under the given 1-based cursor column, returning the segment that
303
+ * the cursor sits on, the prefix from the chain start through that segment, and a normalised form with `[N]` -> `[]`
304
+ * (matching the convention used by the deprecations/enums sidecars and by DuetAPI.xml lookups).
305
+ *
306
+ * Example: hovering `pressureAdvance` in `move.extruders[0].pressureAdvance` returns
307
+ * { prefix: "move.extruders[0].pressureAdvance", normalized: "move.extruders[].pressureAdvance", ... }
308
+ *
309
+ * Returns null if the cursor is not inside a chain that contains at least one `.` or `[n]` step.
310
+ */
311
+ function findObjectModelHover(line, column) {
312
+ const chainRegex = /[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*|\[\d+\])+/g;
313
+ let match;
314
+ while ((match = chainRegex.exec(line)) !== null) {
315
+ const chainStart = match.index + 1;
316
+ const chainEnd = chainStart + match[0].length;
317
+ if (column < chainStart || column > chainEnd) {
318
+ continue;
319
+ }
320
+ const chain = match[0];
321
+ let i = 0;
322
+ let segStart = 0;
323
+ while (i < chain.length) {
324
+ let j = i;
325
+ while (j < chain.length && /\w/.test(chain[j])) {
326
+ j++;
327
+ }
328
+ // Attached `[...]` subscripts stay with their preceding identifier
329
+ while (j < chain.length && chain[j] === "[") {
330
+ while (j < chain.length && chain[j] !== "]") {
331
+ j++;
332
+ }
333
+ if (j < chain.length) {
334
+ j++;
335
+ }
336
+ }
337
+ const segStartColumn = chainStart + segStart;
338
+ const segEndColumn = chainStart + j;
339
+ if (column >= segStartColumn && column <= segEndColumn) {
340
+ const prefix = chain.substring(0, j);
341
+ return {
342
+ prefix,
343
+ normalized: prefix.replace(/\[\d+\]/g, "[]"),
344
+ segStartColumn,
345
+ segEndColumn
346
+ };
347
+ }
348
+ if (chain[j] === ".") {
349
+ j++;
350
+ }
351
+ i = j;
352
+ segStart = i;
353
+ }
354
+ return null;
355
+ }
356
+ return null;
357
+ }
358
+ /**
359
+ * Compute the 1-based column range (inclusive-exclusive) covered by a parameter-letter token plus its value
360
+ * on the given line. `letterColZero` is the 0-based index of the parameter letter itself. The range starts at
361
+ * the letter and extends to cover whatever follows it:
362
+ * - a `{...}` expression (brace-balanced, so nested `{a + {b}}` stays intact), OR
363
+ * - a `"..."` quoted string (until the matching quote, inclusive), OR
364
+ * - a numeric/array token made of digits, sign, decimal point, or `:` (preserves IP-like values and
365
+ * colon-separated arrays such as `E100:200:300`).
366
+ * When the letter isn't followed by any of these (e.g. bare `X` in `M84 X Y Z`), the range covers just the
367
+ * letter itself.
368
+ */
369
+ function findParameterValueRange(line, letterColZero) {
370
+ const startCol = letterColZero + 1;
371
+ let i = letterColZero + 1;
372
+ if (i < line.length) {
373
+ const ch = line[i];
374
+ if (ch === "{") {
375
+ let depth = 1;
376
+ i++;
377
+ while (i < line.length && depth > 0) {
378
+ if (line[i] === "{") {
379
+ depth++;
380
+ }
381
+ else if (line[i] === "}") {
382
+ depth--;
383
+ }
384
+ i++;
385
+ }
386
+ }
387
+ else if (ch === "\"") {
388
+ i++;
389
+ while (i < line.length && line[i] !== "\"") {
390
+ i++;
391
+ }
392
+ if (i < line.length) {
393
+ i++;
394
+ }
395
+ }
396
+ else {
397
+ // Walk a numeric / colon-list value. Allow `e`/`E` (scientific notation) only when the preceding
398
+ // char is a digit or dot, so `C7.06e-8` is one token; also allow `+`/`-` right after `e`/`E` so
399
+ // the exponent sign is consumed even though a bare `-` would otherwise end a sibling param
400
+ while (i < line.length) {
401
+ const ch2 = line[i];
402
+ if (/[0-9.:]/.test(ch2)) {
403
+ i++;
404
+ }
405
+ else if (ch2 === "+" || ch2 === "-") {
406
+ const prev = i > 0 ? line[i - 1] : "";
407
+ if (i === letterColZero + 1 || prev === "e" || prev === "E") {
408
+ i++;
409
+ }
410
+ else {
411
+ break;
412
+ }
413
+ }
414
+ else if (ch2 === "e" || ch2 === "E") {
415
+ const prev = i > 0 ? line[i - 1] : "";
416
+ if (/[0-9.]/.test(prev)) {
417
+ i++;
418
+ }
419
+ else {
420
+ break;
421
+ }
422
+ }
423
+ else {
424
+ break;
425
+ }
426
+ }
427
+ }
428
+ }
429
+ return { startCol, endCol: i + 1 };
430
+ }
431
+ /**
432
+ * Walk a gcode line and find which parameter letter (if any) "owns" the cursor column via its expanded value
433
+ * range - so hovering a `100` / `{global.x}` / `"foo.g"` / `1:2:3` segment still resolves to the parameter it
434
+ * belongs to, not just the bare letter. Honours `"..."` strings, balanced `{...}` expressions, and `;`
435
+ * line-comments so letters inside those aren't mistaken for parameter tokens.
436
+ *
437
+ * `codeEndColZero` is the 0-based position right after the code identifier; `cursorColOne` is the hover's
438
+ * 1-based column. Returns the enclosing parameter letter + its 0-based start column, or null if the cursor
439
+ * isn't inside any parameter's range.
440
+ */
441
+ function findParameterAtCursor(line, codeEndColZero, cursorColOne) {
442
+ let inString = false;
443
+ let braceDepth = 0;
444
+ let i = codeEndColZero;
445
+ while (i < line.length) {
446
+ const ch = line[i];
447
+ if (inString) {
448
+ if (ch === "\"") {
449
+ inString = false;
450
+ }
451
+ i++;
452
+ continue;
453
+ }
454
+ if (ch === "\"") {
455
+ inString = true;
456
+ i++;
457
+ continue;
458
+ }
459
+ if (ch === "{") {
460
+ braceDepth++;
461
+ i++;
462
+ continue;
463
+ }
464
+ if (ch === "}") {
465
+ if (braceDepth > 0) {
466
+ braceDepth--;
467
+ }
468
+ i++;
469
+ continue;
470
+ }
471
+ if (braceDepth > 0) {
472
+ i++;
473
+ continue;
474
+ }
475
+ if (ch === ";") {
476
+ return null;
477
+ }
478
+ if (/[A-Za-z]/.test(ch)) {
479
+ const prev = i > 0 ? line[i - 1] : "";
480
+ const next = i + 1 < line.length ? line[i + 1] : "";
481
+ if (!/[A-Za-z]/.test(prev) && !/[A-Za-z]/.test(next)) {
482
+ const valRange = findParameterValueRange(line, i);
483
+ if (cursorColOne >= valRange.startCol && cursorColOne < valRange.endCol) {
484
+ return { letterColZero: i, letter: ch };
485
+ }
486
+ i = valRange.endCol - 1;
487
+ continue;
488
+ }
489
+ }
490
+ i++;
491
+ }
492
+ return null;
493
+ }
494
+ /**
495
+ * Compute the 1-based column range (inclusive-exclusive) covered by the unprecedented-parameter segment of a
496
+ * G/M/T-code on the given line. `codeEndColZero` is the 0-based index of the character immediately following
497
+ * the code identifier (i.e. `enclosing.startColumn + code.length - 1`).
498
+ *
499
+ * The segment starts at the first non-whitespace char after the code and extends up to (but not including):
500
+ * - the start of the first isolated parameter letter outside `"..."` strings and `{...}` expressions,
501
+ * - a `;` line-comment marker, or
502
+ * - end-of-line.
503
+ * Trailing whitespace is trimmed. Returns null if the segment would be empty (no direct value typed yet).
504
+ */
505
+ function findUnprecedentedParameterRange(line, codeEndColZero) {
506
+ let i = codeEndColZero;
507
+ while (i < line.length && /\s/.test(line[i])) {
508
+ i++;
509
+ }
510
+ if (i >= line.length) {
511
+ return null;
512
+ }
513
+ const startCol = i + 1;
514
+ let endColZero = line.length;
515
+ let inString = false;
516
+ let braceDepth = 0;
517
+ for (let j = i; j < line.length; j++) {
518
+ const ch = line[j];
519
+ if (inString) {
520
+ if (ch === "\"") {
521
+ inString = false;
522
+ }
523
+ continue;
524
+ }
525
+ if (ch === "\"") {
526
+ inString = true;
527
+ continue;
528
+ }
529
+ if (ch === "{") {
530
+ braceDepth++;
531
+ continue;
532
+ }
533
+ if (ch === "}") {
534
+ if (braceDepth > 0) {
535
+ braceDepth--;
536
+ }
537
+ continue;
538
+ }
539
+ if (braceDepth > 0) {
540
+ continue;
541
+ }
542
+ if (ch === ";") {
543
+ endColZero = j;
544
+ break;
545
+ }
546
+ if (/[A-Za-z]/.test(ch)) {
547
+ const prev = j > 0 ? line[j - 1] : "";
548
+ const next = j + 1 < line.length ? line[j + 1] : "";
549
+ if (!/[A-Za-z]/.test(prev) && !/[A-Za-z]/.test(next)) {
550
+ endColZero = j;
551
+ break;
552
+ }
553
+ }
554
+ }
555
+ while (endColZero > i && /\s/.test(line[endColZero - 1])) {
556
+ endColZero--;
557
+ }
558
+ if (endColZero <= i) {
559
+ return null;
560
+ }
561
+ return { startCol, endCol: endColZero + 1 };
562
+ }
563
+ /** VSCode-style warning colour used for deprecation notices (matches the editorWarning.foreground token).
564
+ * Monaco's markdown sanitizer only accepts `style` with a trailing semicolon and a restricted set of properties. */
565
+ const deprecatedHtml = (message) => `<span style="color:#cca700;">⚠ <b>Deprecated:</b> ${message}</span>`;
566
+ const deprecatedInlineHtml = "<span style=\"color:#cca700;\"><i>(deprecated)</i></span>";
567
+ /**
568
+ * Build a Markdown documentation block for a code (used by both completion and hover).
569
+ */
570
+ function buildCodeDoc(code) {
571
+ const info = findGcode(code);
572
+ if (!info) {
573
+ return "";
574
+ }
575
+ let md = `**${info.code}** - ${info.summary}`;
576
+ if (info.deprecated) {
577
+ md += `\n\n${deprecatedHtml(info.deprecated)}`;
578
+ }
579
+ // Unprefixed (unprecedentedParameter) slot. Rendered as "Parameter" rather than the raw label (e.g. `"<message>"`)
580
+ // because the user can pass a literal, a quoted string, or an expression, and the literal notation
581
+ // misleads readers into thinking they must quote. The prose description already makes the intent clear
582
+ if (info.unprecedentedParameter) {
583
+ md += `\n\n**Parameter** - ${info.unprecedentedParameter.description}`;
584
+ }
585
+ if (info.parameters.length > 0) {
586
+ md += "\n\nParameters:";
587
+ // Non-deprecated parameters first, deprecated ones at the end - keeps the active list uncluttered
588
+ const sorted = info.parameters.slice().sort((a, b) => (a.deprecated ? 1 : 0) - (b.deprecated ? 1 : 0));
589
+ for (const p of sorted) {
590
+ const tag = p.deprecated ? ` ${deprecatedInlineHtml}` : "";
591
+ md += `\n- **${p.letter}** - ${p.description}${tag}`;
592
+ }
593
+ }
594
+ return md;
595
+ }
596
+ /**
597
+ * Build a Markdown documentation block for a single parameter: description, optional value enumeration and a
598
+ * deprecation notice last (parameter-level if set, otherwise inherited from the surrounding code).
599
+ */
600
+ function buildParameterDoc(info, p) {
601
+ let md = `**${info.code} ${p.letter}** - ${p.description}`;
602
+ if (p.values && p.values.length > 0) {
603
+ md += "\n\n**Values:**";
604
+ for (const v of p.values) {
605
+ md += `\n- \`${v.value}\` - ${v.description}`;
606
+ }
607
+ }
608
+ const deprecationNote = p.deprecated ?? info.deprecated;
609
+ if (deprecationNote) {
610
+ md += `\n\n${deprecatedHtml(deprecationNote)}`;
611
+ }
612
+ return md;
613
+ }
614
+ /** Wrap a Markdown string as a Monaco IMarkdownString with HTML support enabled (needed for the coloured deprecation notice). */
615
+ function md(value) {
616
+ return { value, supportHtml: true };
617
+ }
618
+ let suggestWidgetStyleInstalled = false;
619
+ /**
620
+ * Ensure the Monaco suggest-widget is wide enough to display the full summary column without truncation.
621
+ * Installed once globally the first time a language is registered.
622
+ */
623
+ function installSuggestWidgetWidth() {
624
+ if (suggestWidgetStyleInstalled || typeof document === "undefined") {
625
+ return;
626
+ }
627
+ const style = document.createElement("style");
628
+ // Cap the widened widget at 90vw so narrow screens (phones) aren't forced to overflow horizontally
629
+ // Also relax Monaco's built-in max-height on the parameter-hints widget so our summary doc (which lists
630
+ // all parameters when no parameter is active) can grow vertically instead of getting an internal scrollbar
631
+ style.textContent = [
632
+ ".monaco-editor .suggest-widget { min-width: min(600px, 90vw); }",
633
+ ".monaco-editor .suggest-widget .monaco-list { min-width: min(600px, 90vw); }",
634
+ ".monaco-editor .parameter-hints-widget { max-width: min(600px, 90vw) !important; }",
635
+ ".monaco-editor .parameter-hints-widget > .phwrapper { max-width: min(600px, 90vw) !important; }",
636
+ // Hover widget: widen to match the suggest-widget (90vw cap so phones don't overflow), and cap height
637
+ // at 50vh so Monaco's positioning math always finds a fit either above or below the cursor. Without
638
+ // a height cap it can compute a height that doesn't fit above when hovering near the top of the file,
639
+ // leaving the tooltip clipped at negative Y. Long docs (M106, M950) scroll internally, which is the
640
+ // right trade-off - forcing inner containers to ignore the computed max-height makes the widget grow
641
+ // past its reserved slot and overlap the source line, hiding the cursor
642
+ ".monaco-editor .monaco-hover, .monaco-editor-hover { max-width: min(600px, 90vw) !important; max-height: 50vh !important; }",
643
+ ".monaco-editor .monaco-hover .hover-contents, .monaco-editor-hover .hover-contents { overflow-wrap: break-word; }",
644
+ // Pin every box in the hover to the same integer pixel height (19 px - matches Monaco's intended
645
+ // `1.35714 * 14` line-height, just rounded). Monaco's default ratio resolves to 18.99996 px at 14 px
646
+ // font; inline phrasing elements (<code>, <strong>, ...) have their own intrinsic metrics that
647
+ // resolve to different fractions (e.g. 16.6667 px for <strong>); and <li> picks up another fraction
648
+ // from the browser's em-based padding (~17.4167 px). Each fraction cascades back into the row height
649
+ // and triggers a phantom scrollbar (M550, M569 D, M918, ...). Forcing integer line-height + sized
650
+ // inline blocks + an explicit <li> height kills the rounding mismatch at every level. Also mirror
651
+ // the implicit left gutter (from the <ul> bullet indent) on the right so text doesn't butt up against
652
+ // the tooltip edge
653
+ ".monaco-editor .monaco-hover .monaco-hover-content, .monaco-editor-hover .monaco-hover-content { line-height: 19px !important; box-sizing: border-box; overflow-x: hidden; }",
654
+ // Add right-side padding only when the hover renders more than a single line (multi-paragraph
655
+ // content, or a bullet/numbered list). Single-line hovers (just one `<p>`) don't need it and the
656
+ // extra gutter would look off-balance against the natural left margin
657
+ ".monaco-editor .monaco-hover .monaco-hover-content:has(p + p, ul, ol), .monaco-editor-hover .monaco-hover-content:has(p + p, ul, ol) { padding-right: 12px; }",
658
+ // Pin <li> to a clean 19 px integer floor (matches the parent's pinned line-height). Browser default
659
+ // padding/margin gives a fractional ~17.4167 px row that, in lists with many items, accumulates into a
660
+ // half-pixel overflow at the bottom and brings back the phantom scrollbar. min-height (not height) so
661
+ // long parameter descriptions that wrap can grow vertically instead of overlapping the next item
662
+ ".monaco-editor .monaco-hover li, .monaco-editor-hover li { min-height: 19px; box-sizing: border-box; }"
663
+ ].join(" ");
664
+ document.head.appendChild(style);
665
+ suggestWidgetStyleInstalled = true;
666
+ }
667
+ /**
668
+ * Register Duet-specific completion and hover providers for a language id.
669
+ */
670
+ export function registerProvidersFor(monacoInstance, languageId) {
671
+ installSuggestWidgetWidth();
672
+ const disposables = [];
673
+ // Completion: codes when typing G/M/T at line start, parameter letters after a known code
674
+ disposables.push(monacoInstance.languages.registerCompletionItemProvider(languageId, {
675
+ triggerCharacters: ["G", "M", "T", "g", "m", "t", " ", "{", ".", "=", "!", "\""],
676
+ provideCompletionItems: (model, position, context) => {
677
+ const lineContent = model.getLineContent(position.lineNumber);
678
+ const beforeCursor = lineContent.substring(0, position.column - 1);
679
+ // A manual Ctrl+Space (Invoke) always shows the list; an auto-trigger (TriggerCharacter or
680
+ // TriggerForIncompleteCompletions) is allowed to skip in expression mode if we're mid-identifier
681
+ const isManualInvoke = context?.triggerKind === monacoInstance.languages.CompletionTriggerKind.Invoke;
682
+ const insideExpression = isInsideExpression(beforeCursor);
683
+ const insideString = isInsideStringLiteral(beforeCursor);
684
+ // Expression context - suggest RRF functions, constants, scope prefixes and object-model namespaces
685
+ if (insideExpression) {
686
+ // Enum / string-literal comparison: `<om path> == ` or `<om path> != ` (optional `"` already typed).
687
+ // Suggest the valid values for that path and nothing else, so the user isn't distracted by the
688
+ // general vocabulary. Runs ahead of the member-access branch so typed paths don't fall through
689
+ const eqMatch = /([A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*|\[\d+\])*)\s*(?:==|!=)\s*"?([A-Za-z_][\w]*)?$/.exec(beforeCursor);
690
+ if (eqMatch) {
691
+ const values = getEnumValuesForPath(eqMatch[1]);
692
+ if (values) {
693
+ const wordInfo = model.getWordUntilPosition(position);
694
+ const range = {
695
+ startLineNumber: position.lineNumber,
696
+ endLineNumber: position.lineNumber,
697
+ startColumn: wordInfo.startColumn,
698
+ endColumn: wordInfo.endColumn
699
+ };
700
+ const suggestions = values.map(v => ({
701
+ label: `"${v}"`,
702
+ kind: monacoInstance.languages.CompletionItemKind.EnumMember,
703
+ // Insert surrounding quotes only if the user hasn't typed an opening quote already
704
+ insertText: beforeCursor.endsWith("\"") ? v + "\"" : `"${v}"`,
705
+ range
706
+ }));
707
+ return { suggestions };
708
+ }
709
+ }
710
+ }
711
+ // Outside the eqMatch above, no completions should fire when the cursor sits inside a `"..."`
712
+ // string literal - the user is typing prose, not code. Without this, stray `!` / `"` triggers
713
+ // inside echo strings or a code's S"..." parameter still produce suggestions (for M118's
714
+ // remaining parameter letters etc) because the post-eqMatch branches don't know about strings
715
+ if (insideString) {
716
+ return { suggestions: [] };
717
+ }
718
+ if (insideExpression) {
719
+ // Member-access chain (e.g. `move.axes[0].` or `global.myvar.`). Walk the object-model from the chain root
720
+ // and list the value's keys at the current path. Evaluated first so the auto-trigger gate below doesn't
721
+ // suppress this case (the `.` itself wouldn't pass it)
722
+ const chainMatch = /([A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*|\[\d+\])*)\.(\w*)$/.exec(beforeCursor);
723
+ if (chainMatch) {
724
+ const wordInfo = model.getWordUntilPosition(position);
725
+ const range = {
726
+ startLineNumber: position.lineNumber,
727
+ endLineNumber: position.lineNumber,
728
+ startColumn: wordInfo.startColumn,
729
+ endColumn: wordInfo.endColumn
730
+ };
731
+ const value = resolveExpressionPath(chainMatch[1], model);
732
+ const suggestions = [];
733
+ for (const name of listMemberKeys(value)) {
734
+ const deprecation = getMemberDeprecation(chainMatch[1], name);
735
+ // Using the structured label form puts the `deprecated - <reason>` string in the description
736
+ // column (right-aligned dim text) so it's visible on every row, not only the highlighted one
737
+ const deprecationLabel = deprecation === null
738
+ ? undefined
739
+ : deprecation.length > 0 ? `deprecated - ${deprecation}` : "deprecated";
740
+ suggestions.push({
741
+ label: deprecationLabel !== undefined ? { label: name, description: deprecationLabel } : name,
742
+ kind: monacoInstance.languages.CompletionItemKind.Variable,
743
+ insertText: name,
744
+ range,
745
+ tags: deprecation !== null ? [monacoInstance.languages.CompletionItemTag.Deprecated] : undefined,
746
+ detail: deprecationLabel
747
+ });
748
+ }
749
+ return { suggestions };
750
+ }
751
+ // Auto-trigger only at the start of a new expression fragment or immediately after an operator -
752
+ // otherwise the popup would fire on every keystroke mid-identifier. Manual Ctrl+Space bypasses this
753
+ // so the user can still summon the list whenever they want
754
+ if (!isManualInvoke) {
755
+ const wordStart = model.getWordUntilPosition(position).startColumn;
756
+ const beforeWord = lineContent.substring(0, wordStart - 1).trimEnd();
757
+ const lastChar = beforeWord.charAt(beforeWord.length - 1);
758
+ // `(` and `,` are intentionally excluded: inside function calls the signature-help tooltip is the
759
+ // relevant cue, not the general function/constant list. Users can still Ctrl+Space manually
760
+ const autoAllowed = beforeWord.length === 0 || "+-*/%&|^~!<>=?:{".indexOf(lastChar) >= 0;
761
+ if (!autoAllowed) {
762
+ return { suggestions: [] };
763
+ }
764
+ }
765
+ const wordInfo = model.getWordUntilPosition(position);
766
+ const range = {
767
+ startLineNumber: position.lineNumber,
768
+ endLineNumber: position.lineNumber,
769
+ startColumn: wordInfo.startColumn,
770
+ endColumn: wordInfo.endColumn
771
+ };
772
+ const suggestions = [];
773
+ for (const f of expressionData.functions) {
774
+ suggestions.push({
775
+ label: { label: f.name, description: f.syntax },
776
+ kind: monacoInstance.languages.CompletionItemKind.Function,
777
+ detail: f.syntax,
778
+ documentation: md(`**${f.syntax}** - ${f.description}`),
779
+ insertText: f.name,
780
+ range
781
+ });
782
+ }
783
+ for (const c of expressionData.constants) {
784
+ suggestions.push({
785
+ label: { label: c.name, description: c.description },
786
+ kind: monacoInstance.languages.CompletionItemKind.Constant,
787
+ detail: c.description,
788
+ documentation: md(`**${c.name}** - ${c.description}`),
789
+ insertText: c.name,
790
+ range
791
+ });
792
+ }
793
+ for (const s of expressionData.scopes) {
794
+ suggestions.push({
795
+ label: { label: s.name, description: s.description },
796
+ kind: monacoInstance.languages.CompletionItemKind.Module,
797
+ detail: s.description,
798
+ documentation: md(`**${s.name}** - ${s.description}`),
799
+ insertText: s.name,
800
+ range
801
+ });
802
+ }
803
+ for (const ns of expressionData.objectModel) {
804
+ // No description here - sub-keys don't carry any either (see comment in listMemberKeys),
805
+ // so keep the top level consistent rather than teasing docs the deeper levels can't match
806
+ suggestions.push({
807
+ label: ns.name,
808
+ kind: monacoInstance.languages.CompletionItemKind.Module,
809
+ insertText: ns.name,
810
+ range
811
+ });
812
+ }
813
+ return { suggestions };
814
+ }
815
+ // At the start of a (possibly indented) line: suggest codes and meta keywords
816
+ if (/^\s*([a-zA-Z]\w*)?$/.test(beforeCursor)) {
817
+ const wordInfo = model.getWordUntilPosition(position);
818
+ const range = {
819
+ startLineNumber: position.lineNumber,
820
+ endLineNumber: position.lineNumber,
821
+ startColumn: wordInfo.startColumn,
822
+ endColumn: wordInfo.endColumn
823
+ };
824
+ const triggerHints = { id: "editor.action.triggerParameterHints", title: "Trigger Parameter Hints" };
825
+ const suggestions = gcodeData.map(info => ({
826
+ label: { label: info.code, description: info.summary },
827
+ kind: monacoInstance.languages.CompletionItemKind.Function,
828
+ detail: info.summary,
829
+ documentation: md(buildCodeDoc(info.code)),
830
+ insertText: info.code,
831
+ range,
832
+ // Open the signature-help tooltip as soon as the user picks a code that has parameters or a direct value
833
+ command: (info.parameters.length > 0 || info.unprecedentedParameter) ? triggerHints : undefined,
834
+ tags: info.deprecated ? [monacoInstance.languages.CompletionItemTag.Deprecated] : undefined
835
+ }));
836
+ for (const k of metaKeywords) {
837
+ suggestions.push({
838
+ label: { label: k.keyword, description: k.description },
839
+ kind: monacoInstance.languages.CompletionItemKind.Keyword,
840
+ detail: k.description,
841
+ documentation: md(`**${k.keyword}** - ${k.description}`),
842
+ insertText: k.keyword,
843
+ range
844
+ });
845
+ }
846
+ return { suggestions };
847
+ }
848
+ // Inside a code call: suggest parameter letters (excluding ones already present on the line)
849
+ const code = findCodeAtCursor(lineContent, position.column - 1);
850
+ if (code) {
851
+ const info = findGcode(code.code);
852
+ if (info && info.parameters.length > 0) {
853
+ const wordInfo = model.getWordUntilPosition(position);
854
+ const range = {
855
+ startLineNumber: position.lineNumber,
856
+ endLineNumber: position.lineNumber,
857
+ startColumn: wordInfo.startColumn,
858
+ endColumn: wordInfo.endColumn
859
+ };
860
+ // Collect isolated parameter letters already on the line so we don't suggest duplicates
861
+ // The letter directly at the cursor position is also considered "used": if the user has
862
+ // just typed it, Monaco should show nothing (so the widget auto-closes) rather than list
863
+ // the very letter that was just typed as the only match
864
+ const fullTail = lineContent.substring(code.startColumn - 1 + code.code.length);
865
+ const used = new Set();
866
+ const re = /(?<![A-Za-z])[A-Za-z](?![A-Za-z])/g;
867
+ let m;
868
+ while ((m = re.exec(fullTail)) !== null) {
869
+ used.add(m[0].toUpperCase());
870
+ }
871
+ const triggerHints = { id: "editor.action.triggerParameterHints", title: "Trigger Parameter Hints" };
872
+ const suggestions = info.parameters
873
+ .filter(p => !used.has(p.letter.toUpperCase()))
874
+ .map(p => ({
875
+ label: { label: p.letter, description: p.description },
876
+ kind: monacoInstance.languages.CompletionItemKind.Property,
877
+ detail: p.description,
878
+ documentation: md(buildParameterDoc(info, p)),
879
+ insertText: p.letter,
880
+ range,
881
+ // Re-open the signature help tooltip after accepting a parameter letter (Monaco otherwise
882
+ // closes parameter hints when any completion item is accepted without a command)
883
+ command: triggerHints,
884
+ tags: p.deprecated ? [monacoInstance.languages.CompletionItemTag.Deprecated] : undefined
885
+ }));
886
+ // isIncomplete forces Monaco to re-call the provider on every keystroke instead of filtering a
887
+ // cached list. That way once the user types the only remaining parameter letter the `used` set
888
+ // has just caught it, we return an empty list, and Monaco closes the widget
889
+ return { suggestions, incomplete: true };
890
+ }
891
+ }
892
+ return { suggestions: [] };
893
+ }
894
+ }));
895
+ // Signature help: floating tooltip enumerating all parameters of the current code, similar to console.log() in VSCode
896
+ disposables.push(monacoInstance.languages.registerSignatureHelpProvider(languageId, {
897
+ signatureHelpTriggerCharacters: [" ", "(", ","],
898
+ // Re-evaluate on every character that signals "parameter value finished" so dismissal fires immediately
899
+ signatureHelpRetriggerCharacters: [" ", "\t", "}", "\"", ";", "(", ",", ")"],
900
+ provideSignatureHelp: (model, position) => {
901
+ const lineContent = model.getLineContent(position.lineNumber);
902
+ const beforeCursor = lineContent.substring(0, position.column - 1);
903
+ // Cursor inside a `"..."` string literal: no signature help applies (the user is typing prose,
904
+ // not a parameter token), so bail to keep the tooltip from following the caret into strings
905
+ if (isInsideStringLiteral(beforeCursor)) {
906
+ return null;
907
+ }
908
+ // Cursor past a `;` on the same line: we're inside a line comment (e.g. after bksp joins a line
909
+ // onto a previous commented line like "M106 P1 S255 ; note"). No signature help applies there,
910
+ // and without this guard Monaco would keep the previous parameter's tooltip floating over prose.
911
+ if (isInsideLineComment(beforeCursor)) {
912
+ return null;
913
+ }
914
+ // In an expression context the only meaningful signature help is the enclosing function call -
915
+ // suppress the outer command/keyword tooltip so it doesn't keep flashing while the user types values
916
+ const fnCall = findEnclosingFunctionCall(beforeCursor);
917
+ if (isInsideExpression(beforeCursor) && !fnCall) {
918
+ return null;
919
+ }
920
+ // Function call inside an expression (e.g. `sin(|` or `atan2(y,|`) takes precedence
921
+ if (fnCall) {
922
+ const fn = expressionData.functions.find(f => f.name === fnCall.name);
923
+ if (fn) {
924
+ const parsed = parseFunctionSyntax(fn.syntax);
925
+ const params = parsed.params.map(p => ({
926
+ label: p,
927
+ documentation: md(`**${p}** - argument of **${fn.syntax}**`)
928
+ }));
929
+ return {
930
+ value: {
931
+ signatures: [{
932
+ label: fn.syntax,
933
+ documentation: md(`**${fn.syntax}** - ${fn.description}`),
934
+ parameters: params
935
+ }],
936
+ activeSignature: 0,
937
+ activeParameter: Math.min(fnCall.argIndex, Math.max(0, params.length - 1))
938
+ },
939
+ dispose: () => { }
940
+ };
941
+ }
942
+ }
943
+ // Keywords take precedence when the line starts with one (e.g. `if`, `while`, `set`)
944
+ const keywordMatch = /^\s*([a-z]+)(\s|$)/.exec(lineContent);
945
+ if (keywordMatch) {
946
+ const keyword = metaKeywords.find(k => k.keyword === keywordMatch[1]);
947
+ if (keyword) {
948
+ return {
949
+ value: {
950
+ signatures: [{
951
+ label: keyword.syntax,
952
+ documentation: md(`**${keyword.keyword}** - ${keyword.description}`),
953
+ parameters: []
954
+ }],
955
+ activeSignature: 0,
956
+ activeParameter: -1
957
+ },
958
+ dispose: () => { }
959
+ };
960
+ }
961
+ }
962
+ const code = findCodeAtCursor(lineContent, position.column - 1);
963
+ if (!code) {
964
+ return null;
965
+ }
966
+ const info = findGcode(code.code);
967
+ if (!info || (info.parameters.length === 0 && !info.unprecedentedParameter)) {
968
+ return null;
969
+ }
970
+ // Dismiss the tooltip once the user has finished typing a parameter value: trailing whitespace,
971
+ // a closing `}` of a balanced expression, or a closing `"` of a balanced string. Keep it visible
972
+ // right after the bare code (no value typed yet) so the full signature is offered
973
+ const tailForDismissal = beforeCursor.substring(code.startColumn - 1 + code.code.length);
974
+ const lastChar = beforeCursor.charAt(beforeCursor.length - 1);
975
+ if (/\S/.test(tailForDismissal)) {
976
+ if (lastChar === " " || lastChar === "\t") {
977
+ return null;
978
+ }
979
+ if (lastChar === "}" && (tailForDismissal.match(/\{/g) || []).length === (tailForDismissal.match(/\}/g) || []).length) {
980
+ return null;
981
+ }
982
+ if (lastChar === "\"" && ((tailForDismissal.match(/"/g) || []).length % 2) === 0) {
983
+ return null;
984
+ }
985
+ }
986
+ // Build a signature like "T Parameter P R" or "G1 X Y Z E F" with one slot per argument so Monaco
987
+ // can highlight the active one. The unprecedented-parameter slot is labelled "Parameter" rather
988
+ // than the literal notation from the dataset (e.g. `"<message>"`) since the user can pass a
989
+ // literal, a quoted string, or an expression - the literal-looking label misleads readers
990
+ let label = info.code;
991
+ const parameters = [];
992
+ if (info.unprecedentedParameter) {
993
+ const start = label.length + 1;
994
+ label += " Parameter";
995
+ let doc = `**Parameter** - ${info.unprecedentedParameter.description}`;
996
+ if (info.deprecated) {
997
+ doc += `\n\n${deprecatedHtml(info.deprecated)}`;
998
+ }
999
+ parameters.push({
1000
+ label: [start, label.length],
1001
+ documentation: md(doc)
1002
+ });
1003
+ }
1004
+ for (const p of info.parameters) {
1005
+ const start = label.length + 1;
1006
+ label += " " + p.letter;
1007
+ parameters.push({
1008
+ label: [start, label.length],
1009
+ documentation: md(buildParameterDoc(info, p))
1010
+ });
1011
+ }
1012
+ // Active parameter: the last isolated single letter between the code and the cursor (e.g. the H in "G1 X10 H1");
1013
+ // for codes with a unprecedentedParameter, sit on slot 0 while the user is typing that value (no parameter letter typed yet)
1014
+ const tail = tailForDismissal;
1015
+ let activeParameter = -1;
1016
+ const seen = tail.match(/(?<![A-Za-z])[A-Za-z](?![A-Za-z])/g);
1017
+ const unprecedentedOffset = info.unprecedentedParameter ? 1 : 0;
1018
+ if (seen && seen.length > 0) {
1019
+ const last = seen[seen.length - 1].toUpperCase();
1020
+ const idx = info.parameters.findIndex(p => p.letter.toUpperCase() === last);
1021
+ if (idx >= 0) {
1022
+ activeParameter = idx + unprecedentedOffset;
1023
+ }
1024
+ else {
1025
+ // User typed a letter that isn't a documented parameter for this code - hide the popup
1026
+ // rather than falling back to the generic summary view, which would be misleading
1027
+ return null;
1028
+ }
1029
+ }
1030
+ else if (info.unprecedentedParameter) {
1031
+ activeParameter = 0;
1032
+ }
1033
+ // When no parameter is active yet, show "<code> - <summary>" as the top line. The labelled parameter
1034
+ // signature "M203 X Y Z E I" only reappears once the user is typing a parameter. The doc panel
1035
+ // renders, in order: optional multi-line description, deprecation notice, parameter list. The
1036
+ // one-line summary is already shown in the label so it isn't repeated in the doc
1037
+ if (activeParameter < 0) {
1038
+ const docParts = [];
1039
+ if (info.deprecated) {
1040
+ docParts.push(deprecatedHtml(info.deprecated));
1041
+ }
1042
+ if (info.description) {
1043
+ docParts.push(info.description);
1044
+ }
1045
+ if (info.unprecedentedParameter) {
1046
+ docParts.push(`**Parameter** - ${info.unprecedentedParameter.description}`);
1047
+ }
1048
+ if (info.parameters.length > 0) {
1049
+ let params = "Parameters:";
1050
+ // Non-deprecated parameters first, deprecated ones at the end
1051
+ const sorted = info.parameters.slice().sort((a, b) => (a.deprecated ? 1 : 0) - (b.deprecated ? 1 : 0));
1052
+ for (const p of sorted) {
1053
+ const tag = p.deprecated ? ` ${deprecatedInlineHtml}` : "";
1054
+ params += `\n- **${p.letter}** - ${p.description}${tag}`;
1055
+ }
1056
+ docParts.push(params);
1057
+ }
1058
+ return {
1059
+ value: {
1060
+ signatures: [{
1061
+ label: `${info.code} - ${info.summary}`,
1062
+ documentation: docParts.length > 0 ? md(docParts.join("\n\n")) : undefined,
1063
+ parameters: []
1064
+ }],
1065
+ activeSignature: 0,
1066
+ activeParameter: -1
1067
+ },
1068
+ dispose: () => { }
1069
+ };
1070
+ }
1071
+ return {
1072
+ value: {
1073
+ signatures: [{
1074
+ label,
1075
+ documentation: undefined,
1076
+ parameters
1077
+ }],
1078
+ activeSignature: 0,
1079
+ activeParameter
1080
+ },
1081
+ dispose: () => { }
1082
+ };
1083
+ }
1084
+ }));
1085
+ // Hover: show code summary or parameter description under the cursor
1086
+ disposables.push(monacoInstance.languages.registerHoverProvider(languageId, {
1087
+ provideHover: async (model, position) => {
1088
+ const lineContent = model.getLineContent(position.lineNumber);
1089
+ const word = model.getWordAtPosition(position);
1090
+ if (!word) {
1091
+ // No identifier-shaped word under the cursor (e.g. cursor on `*` in `M586 C"*"`, on `?` etc.).
1092
+ // Try to resolve the cursor as a parameter value position via findParameterAtCursor; if it
1093
+ // sits inside a known parameter's value range, show that parameter's doc
1094
+ const enclosingCode = findCodeAtCursor(lineContent, position.column);
1095
+ if (enclosingCode) {
1096
+ const info = findGcode(enclosingCode.code);
1097
+ const paramAtCursor = info ? findParameterAtCursor(lineContent, enclosingCode.startColumn + enclosingCode.code.length - 1, position.column) : null;
1098
+ if (info && paramAtCursor) {
1099
+ const param = info.parameters.find(p => p.letter.toUpperCase() === paramAtCursor.letter.toUpperCase());
1100
+ if (param) {
1101
+ const valueRange = findParameterValueRange(lineContent, paramAtCursor.letterColZero);
1102
+ return {
1103
+ range: new monacoInstance.Range(position.lineNumber, valueRange.startCol, position.lineNumber, valueRange.endCol),
1104
+ contents: [md(buildParameterDoc(info, param))]
1105
+ };
1106
+ }
1107
+ }
1108
+ }
1109
+ return null;
1110
+ }
1111
+ // Note: we deliberately do NOT bail when the cursor is inside a `"..."` string. The string is
1112
+ // usually the value of a parameter (e.g. `M308 P"temp0"`, `M308 Y"thermistor"`) and we want to
1113
+ // show the parameter's hover info while the cursor sits on the value. The narrower check that
1114
+ // prevents `"M104 done"` style false matches lives down at the wordIsCode branch
1115
+ const beforeWordForString = lineContent.substring(0, word.startColumn - 1);
1116
+ const insideString = isInsideStringLiteral(beforeWordForString);
1117
+ // Suppress hover inside `;` line-comments - the tokeniser colours them as comments but the hover
1118
+ // provider runs independently and would otherwise match G/M-code letters that appear in comment text
1119
+ const semi = lineContent.indexOf(";");
1120
+ if (semi >= 0 && word.startColumn - 1 >= semi) {
1121
+ return null;
1122
+ }
1123
+ // Determine up-front whether the word sits inside an expression context - used to route hover between
1124
+ // the gcode-parameter flavour (outside expressions) and the function/constant flavour (inside)
1125
+ const beforeWord = lineContent.substring(0, word.startColumn - 1);
1126
+ const insideExpression = isInsideExpression(beforeWord);
1127
+ // Determine what the word actually is: findCodeAtCursor tells us the nearest code at or before the
1128
+ // given column. Passing `word.startColumn` (inclusive) rather than `word.startColumn - 1` means the
1129
+ // word itself is considered - so `M84` alone resolves to code=M84 startColumn=1, and the check below
1130
+ // can tell it's the code (not a parameter of some earlier code). For `M104 T0` hovering T0, the
1131
+ // lookup still returns M104 because `findCodeAtCursor`'s T-rule treats a bare T after G/M as that
1132
+ // code's parameter, letting us test T-as-parameter before T-as-code
1133
+ const enclosing = !insideExpression ? findCodeAtCursor(lineContent, word.startColumn) : null;
1134
+ const wordIsCode = enclosing && enclosing.startColumn === word.startColumn && /^[A-Za-z]/.test(word.word);
1135
+ const firstLetter = word.word[0];
1136
+ // Hover on a parameter letter belonging to the nearest preceding code. Monaco's default word regex
1137
+ // bundles the letter with its trailing value (e.g. `S0` / `X10.5` / `E20` / the `T0` in `M104 T0`)
1138
+ // into one word, so we check the leading letter rather than requiring a bare single-letter word
1139
+ // Codes with a unprecedentedParameter (M117's message, T's tool number, ...) can also expose text in an
1140
+ // unprefixed slot between the code and the first parameter letter. If the hovered word isn't a
1141
+ // recognised parameter letter AND the cursor sits in the direct-value segment, fall through to
1142
+ // the unprecedentedParameter hover instead of returning nothing
1143
+ if (enclosing && !wordIsCode) {
1144
+ const info = findGcode(enclosing.code);
1145
+ // First try: cursor sits anywhere inside a parameter's expanded value range (e.g. on the
1146
+ // `100` of `S100`, inside `{global.x}` of `E{global.x}`, inside `"foo.g"` of `P"foo.g"`, or
1147
+ // inside `1:2:3` of `E1:2:3`). This covers hovers that don't land on the letter itself
1148
+ const paramAtCursor = findParameterAtCursor(lineContent, enclosing.startColumn + enclosing.code.length - 1, position.column);
1149
+ if (info && paramAtCursor) {
1150
+ const param = info.parameters.find(p => p.letter.toUpperCase() === paramAtCursor.letter.toUpperCase());
1151
+ if (param) {
1152
+ const valueRange = findParameterValueRange(lineContent, paramAtCursor.letterColZero);
1153
+ return {
1154
+ range: new monacoInstance.Range(position.lineNumber, valueRange.startCol, position.lineNumber, valueRange.endCol),
1155
+ contents: [md(buildParameterDoc(info, param))]
1156
+ };
1157
+ }
1158
+ }
1159
+ // Fallback: hovered word starts with a parameter letter (e.g. bare `X` in `M84 X Y Z`, where
1160
+ // the letter has no value after it so findParameterAtCursor may stop before reaching it)
1161
+ if (info && /^[A-Za-z]/.test(word.word)) {
1162
+ const param = info.parameters.find(p => p.letter.toUpperCase() === firstLetter.toUpperCase());
1163
+ if (param) {
1164
+ const valueRange = findParameterValueRange(lineContent, word.startColumn - 1);
1165
+ return {
1166
+ range: new monacoInstance.Range(position.lineNumber, valueRange.startCol, position.lineNumber, valueRange.endCol),
1167
+ contents: [md(buildParameterDoc(info, param))]
1168
+ };
1169
+ }
1170
+ }
1171
+ }
1172
+ // Hover on an unprecedented-parameter argument: M117's `"Hello World"`, T's `0`, or any expression
1173
+ // `{...}` / quoted string passed in the same slot. Fires when the hovered word sits past the code
1174
+ // but strictly before any parameter-letter token on the line, so hovering `P1` of `T0 P1` still
1175
+ // routes through the param branch above. The hover's range expands from the first non-whitespace
1176
+ // char after the code to the start of the first param letter (or end-of-line / start of comment),
1177
+ // so the tooltip stays visible while the cursor moves anywhere inside the expression - useful for
1178
+ // multi-token values like `{global.tool}` or `"Hello World"`
1179
+ if (enclosing && !wordIsCode) {
1180
+ const info = findGcode(enclosing.code);
1181
+ if (info?.unprecedentedParameter) {
1182
+ const segment = findUnprecedentedParameterRange(lineContent, enclosing.startColumn + enclosing.code.length - 1);
1183
+ if (segment && word.startColumn >= segment.startCol && word.endColumn <= segment.endCol) {
1184
+ let doc = `**Parameter** - ${info.unprecedentedParameter.description}`;
1185
+ if (info.deprecated) {
1186
+ doc += `\n\n${deprecatedHtml(info.deprecated)}`;
1187
+ }
1188
+ return {
1189
+ range: new monacoInstance.Range(position.lineNumber, segment.startCol, position.lineNumber, segment.endCol),
1190
+ contents: [md(doc)]
1191
+ };
1192
+ }
1193
+ }
1194
+ }
1195
+ // Hover on a G/M/T-code itself. For T we always feed the bare "T" identifier into `buildCodeDoc`
1196
+ // regardless of any trailing tool number baked into the word ("T", "T0", "T1", ...) since the data
1197
+ // entry keys off the single letter and treats the number as an unprecedentedParameter. When the
1198
+ // code carries an unprecedentedParameter we also extend the hover range to cover any signed int
1199
+ // or `{...}` expression that follows, so hovering `T-1` or `T{global.tool}` highlights the whole
1200
+ // token rather than just `T`
1201
+ // Exception: M911's P parameter holds a string of G-code to run on power-loss (e.g.
1202
+ // `M911 ... P"M913 X0 Y0 G91 G1 Z3"`) so codes nested inside its quoted value SHOULD resolve to
1203
+ // their hover info. To detect this, find the column where the current string opens and look up
1204
+ // the code that precedes it. Other in-string matches (e.g. `"M104 done"` in M118's S parameter)
1205
+ // stay suppressed
1206
+ let openQuoteCol = -1;
1207
+ let inStr = false;
1208
+ for (let i = 0; i < beforeWordForString.length; i++) {
1209
+ if (beforeWordForString[i] === "\"") {
1210
+ if (!inStr) {
1211
+ inStr = true;
1212
+ openQuoteCol = i + 1;
1213
+ }
1214
+ else {
1215
+ inStr = false;
1216
+ openQuoteCol = -1;
1217
+ }
1218
+ }
1219
+ }
1220
+ const outerCode = openQuoteCol > 0 ? findCodeAtCursor(lineContent, openQuoteCol) : null;
1221
+ const isPowerLossString = insideString && outerCode?.code === "M911";
1222
+ const wordCodeSuppressedByString = insideString && !isPowerLossString;
1223
+ if (wordIsCode && !wordCodeSuppressedByString) {
1224
+ const canonical = enclosing.code;
1225
+ const doc = buildCodeDoc(canonical);
1226
+ if (doc) {
1227
+ const info = findGcode(canonical);
1228
+ let endCol = word.endColumn;
1229
+ if (info?.unprecedentedParameter) {
1230
+ const segment = findUnprecedentedParameterRange(lineContent, enclosing.startColumn + enclosing.code.length - 1);
1231
+ if (segment) {
1232
+ endCol = Math.max(endCol, segment.endCol);
1233
+ }
1234
+ }
1235
+ return {
1236
+ range: new monacoInstance.Range(position.lineNumber, word.startColumn, position.lineNumber, endCol),
1237
+ contents: [md(doc)]
1238
+ };
1239
+ }
1240
+ }
1241
+ // Hover on a built-in expression function (sin, abs, vector, ...) or constant (pi, iterations, ...)
1242
+ // when the cursor is inside an expression context. Checked before the OM chain so `sin` alone (no
1243
+ // `.` / `[n]`) is covered; `fans[0].max` stays on the OM chain path since that match wins anyway
1244
+ if (insideExpression) {
1245
+ const fn = expressionData.functions.find(f => f.name === word.word);
1246
+ if (fn) {
1247
+ return {
1248
+ range: new monacoInstance.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn),
1249
+ contents: [md(`**${fn.syntax}**\n\n${fn.description}`)]
1250
+ };
1251
+ }
1252
+ const constant = expressionData.constants.find(c => c.name === word.word);
1253
+ if (constant) {
1254
+ return {
1255
+ range: new monacoInstance.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn),
1256
+ contents: [md(`**${constant.name}**\n\n${constant.description}`)]
1257
+ };
1258
+ }
1259
+ }
1260
+ // Hover on an object-model path segment: look up the description via the machine context's
1261
+ // optional `getObjectModelDescription` callback (DWC wires this to DuetAPI.xml). Even without a
1262
+ // callback we surface the @deprecated note if one applies to the hovered prefix
1263
+ const omHover = findObjectModelHover(lineContent, position.column);
1264
+ if (omHover) {
1265
+ const ctx = getMachineContext();
1266
+ const description = ctx?.getObjectModelDescription
1267
+ ? await Promise.resolve(ctx.getObjectModelDescription(omHover.normalized))
1268
+ : null;
1269
+ const deprecation = getPathDeprecation(omHover.prefix);
1270
+ if (description || deprecation !== null) {
1271
+ let body = `\`${omHover.normalized}\``;
1272
+ if (description) {
1273
+ body += `\n\n${description}`;
1274
+ }
1275
+ if (deprecation !== null) {
1276
+ body += `\n\n${deprecatedHtml(deprecation || "This field is deprecated")}`;
1277
+ }
1278
+ return {
1279
+ range: new monacoInstance.Range(position.lineNumber, omHover.segStartColumn, position.lineNumber, omHover.segEndColumn),
1280
+ contents: [md(body)]
1281
+ };
1282
+ }
1283
+ }
1284
+ return null;
1285
+ }
1286
+ }));
1287
+ return disposables;
1288
+ }
1289
+ /**
1290
+ * Register Duet completion and hover providers for both gcode-fdm and gcode-cnc languages.
1291
+ */
1292
+ export function registerDuetProviders(monacoInstance) {
1293
+ return [
1294
+ ...registerProvidersFor(monacoInstance, "gcode-fdm"),
1295
+ ...registerProvidersFor(monacoInstance, "gcode-cnc")
1296
+ ];
1297
+ }
1298
+ /**
1299
+ * Attach a per-editor cursor-position watcher that closes the signature-help tooltip immediately when the cursor
1300
+ * moves to a position where our provider would return null (between parameters, before the code, on a different line).
1301
+ * Monaco only re-invokes the signature-help provider on content changes, so this bridges arrow-key / click movement.
1302
+ * Call this once per editor right after `monaco.editor.create(...)`.
1303
+ */
1304
+ export function attachGcodeSignatureHelpWatcher(editor) {
1305
+ // Close parameter hints when the suggest widget transitions from hidden to visible, so the two popups don't
1306
+ // overlap while the user is typing. We only react on the visible-edge and skip the action if parameter hints
1307
+ // is currently open because that means Monaco just invoked it (e.g. after Enter on a completion item) and we
1308
+ // would otherwise swallow it
1309
+ const editorDom = editor.getDomNode();
1310
+ const isWidgetVisible = (sel) => {
1311
+ if (!editorDom) {
1312
+ return false;
1313
+ }
1314
+ const w = editorDom.querySelector(sel);
1315
+ return !!(w && !w.classList.contains("hidden") && getComputedStyle(w).display !== "none");
1316
+ };
1317
+ // Shared helper: decides, based on cursor position, whether parameter hints should be dismissed, opened, or left alone
1318
+ function reevaluateHints() {
1319
+ const model = editor.getModel();
1320
+ const position = editor.getPosition();
1321
+ if (!model || !position) {
1322
+ return;
1323
+ }
1324
+ const lineContent = model.getLineContent(position.lineNumber);
1325
+ const beforeCursor = lineContent.substring(0, position.column - 1);
1326
+ const code = findCodeAtCursor(lineContent, position.column - 1);
1327
+ const keywordMatch = /^\s*([a-z]+)(\s|$)/.exec(lineContent);
1328
+ const onKeyword = !!(keywordMatch && metaKeywords.some(k => k.keyword === keywordMatch[1]));
1329
+ const insideExpression = isInsideExpression(beforeCursor);
1330
+ const insideFunctionCall = insideExpression && findEnclosingFunctionCall(beforeCursor) !== null;
1331
+ let shouldDismiss = false;
1332
+ if (!code && !onKeyword) {
1333
+ shouldDismiss = true;
1334
+ }
1335
+ else if (code) {
1336
+ const info = findGcode(code.code);
1337
+ if (!info || (info.parameters.length === 0 && !info.unprecedentedParameter)) {
1338
+ shouldDismiss = true;
1339
+ }
1340
+ else {
1341
+ const tail = beforeCursor.substring(code.startColumn - 1 + code.code.length);
1342
+ const lastChar = beforeCursor.charAt(beforeCursor.length - 1);
1343
+ if (/\S/.test(tail)) {
1344
+ if (lastChar === " " || lastChar === "\t") {
1345
+ shouldDismiss = true;
1346
+ }
1347
+ else if (lastChar === "}" && (tail.match(/\{/g) || []).length === (tail.match(/\}/g) || []).length) {
1348
+ shouldDismiss = true;
1349
+ }
1350
+ else if (lastChar === "\"" && ((tail.match(/"/g) || []).length % 2) === 0) {
1351
+ shouldDismiss = true;
1352
+ }
1353
+ }
1354
+ }
1355
+ }
1356
+ // In an expression but not inside a function call, hide the tooltip - the user is typing values, not a code/parameter
1357
+ if (insideExpression && !insideFunctionCall) {
1358
+ editor.trigger("gcode", "closeParameterHints", null);
1359
+ }
1360
+ else if (shouldDismiss) {
1361
+ editor.trigger("gcode", "closeParameterHints", null);
1362
+ }
1363
+ else if ((insideFunctionCall || code) && !isWidgetVisible(".suggest-widget")) {
1364
+ // Cursor landed on/inside a known code or function call - open parameter hints so the summary is visible
1365
+ // Keywords are intentionally omitted so the user has to invoke via Ctrl+Space to see the expression syntax;
1366
+ // otherwise the tooltip would keep popping up while editing `if|while|elif` conditions. We also skip when
1367
+ // the suggest widget is visible so the two popups don't overlap; once suggest closes the mutation observer
1368
+ // calls us again to catch up
1369
+ editor.trigger("gcode", "editor.action.triggerParameterHints", null);
1370
+ }
1371
+ }
1372
+ let observer = null;
1373
+ if (editorDom && typeof MutationObserver !== "undefined") {
1374
+ let lastSuggestVisible = isWidgetVisible(".suggest-widget");
1375
+ observer = new MutationObserver(() => {
1376
+ const suggestVisible = isWidgetVisible(".suggest-widget");
1377
+ if (suggestVisible && !lastSuggestVisible) {
1378
+ editor.trigger("gcode", "closeParameterHints", null);
1379
+ }
1380
+ else if (!suggestVisible && lastSuggestVisible) {
1381
+ // Suggest just closed - parameter hints may need to open now if the cursor is parked on a code
1382
+ // (e.g. the user typed the only remaining parameter letter, which dismissed the suggest list
1383
+ // but didn't move the cursor to produce another onDidChangeCursorPosition event)
1384
+ reevaluateHints();
1385
+ }
1386
+ lastSuggestVisible = suggestVisible;
1387
+ });
1388
+ observer.observe(editorDom, { subtree: true, attributes: true, attributeFilter: ["class", "style"] });
1389
+ }
1390
+ const cursorDisposable = editor.onDidChangeCursorPosition(reevaluateHints);
1391
+ return {
1392
+ dispose: () => {
1393
+ cursorDisposable.dispose();
1394
+ observer?.disconnect();
1395
+ }
1396
+ };
1397
+ }
1398
+ /**
1399
+ * Apply a strikethrough decoration (class `duet-deprecated-code`) to every occurrence of a deprecated G/M/T-code
1400
+ * (e.g. `M557`) and to every deprecated parameter letter belonging to any G/M/T-code on that line (e.g. the `S` in
1401
+ * `M84 S`). Re-runs on every content change; hover tooltip carries the deprecation reason.
1402
+ * Call once per editor; the returned IDisposable removes the listener and clears the decorations.
1403
+ */
1404
+ export function attachGcodeDeprecationDecorations(editor) {
1405
+ // Codes with `deprecated` flag: the code identifier itself gets struck through
1406
+ const deprecatedCodes = gcodeData.filter(g => !!g.deprecated);
1407
+ const deprecatedCodeAlternation = deprecatedCodes.length > 0
1408
+ ? deprecatedCodes.map(g => g.code.replace(/[.\\$^*+?()[\]{}|]/g, "\\$&")).join("|")
1409
+ : null;
1410
+ const deprecatedCodeRegex = deprecatedCodeAlternation
1411
+ ? new RegExp("(?:^|[^\\w])(" + deprecatedCodeAlternation + ")(?=$|[^\\w])", "g")
1412
+ : null;
1413
+ return attachDecorationsFromModelScan(editor, model => {
1414
+ // Every G/M/T code on a line, so we can locate the segment that may hold deprecated parameter letters
1415
+ // Mirrors the primary `codeRegex` / `findCodeAtCursor` T-rule: a bare `T` following another G/M on the
1416
+ // same line is treated as that preceding code's parameter, not as a new code
1417
+ const anyCodeRegex = /([GM]\d+(?:\.\d+)?|T(?![A-Za-z]))/g;
1418
+ const paramLetterRegex = /(^|[\s])([A-Za-z])(?=[\s]|$|[-+0-9.\"'{])/g;
1419
+ const newDecorations = [];
1420
+ for (let lineNumber = 1; lineNumber <= model.getLineCount(); lineNumber++) {
1421
+ const text = model.getLineContent(lineNumber);
1422
+ // Strip the `;` line-comment so we never mark letters inside comments
1423
+ const semi = text.indexOf(";");
1424
+ const effective = semi >= 0 ? text.substring(0, semi) : text;
1425
+ // Deprecated codes themselves
1426
+ if (deprecatedCodeRegex) {
1427
+ deprecatedCodeRegex.lastIndex = 0;
1428
+ let m;
1429
+ while ((m = deprecatedCodeRegex.exec(text)) !== null) {
1430
+ const code = m[1];
1431
+ const startColumn = m.index + (m[0].length - code.length) + 1;
1432
+ const endColumn = startColumn + code.length;
1433
+ newDecorations.push({
1434
+ range: { startLineNumber: lineNumber, endLineNumber: lineNumber, startColumn, endColumn },
1435
+ options: { inlineClassName: "duet-deprecated-code" }
1436
+ });
1437
+ }
1438
+ }
1439
+ // Deprecated parameter letters: enumerate all codes on the line, then scan their trailing segment
1440
+ const codeOccurrences = [];
1441
+ let haveGMmatch = false;
1442
+ anyCodeRegex.lastIndex = 0;
1443
+ let cm;
1444
+ while ((cm = anyCodeRegex.exec(effective)) !== null) {
1445
+ const code = cm[1];
1446
+ if (code === "T" && haveGMmatch) {
1447
+ continue;
1448
+ }
1449
+ if (code[0] !== "T") {
1450
+ haveGMmatch = true;
1451
+ }
1452
+ codeOccurrences.push({
1453
+ code,
1454
+ startColumn: cm.index + 1,
1455
+ endColumn: cm.index + 1 + code.length
1456
+ });
1457
+ }
1458
+ for (let i = 0; i < codeOccurrences.length; i++) {
1459
+ const occ = codeOccurrences[i];
1460
+ const canonical = occ.code[0].toUpperCase() + occ.code.substring(1);
1461
+ const info = findGcode(canonical);
1462
+ if (!info) {
1463
+ continue;
1464
+ }
1465
+ const deprecatedParams = info.parameters.filter(p => !!p.deprecated);
1466
+ if (deprecatedParams.length === 0) {
1467
+ continue;
1468
+ }
1469
+ const segStart = occ.endColumn - 1;
1470
+ const segEnd = i + 1 < codeOccurrences.length ? codeOccurrences[i + 1].startColumn - 1 : effective.length;
1471
+ const segText = effective.substring(segStart, segEnd);
1472
+ paramLetterRegex.lastIndex = 0;
1473
+ let pm;
1474
+ while ((pm = paramLetterRegex.exec(segText)) !== null) {
1475
+ const letter = pm[2].toUpperCase();
1476
+ const param = deprecatedParams.find(p => p.letter.toUpperCase() === letter);
1477
+ if (!param) {
1478
+ continue;
1479
+ }
1480
+ const absColumn = segStart + pm.index + pm[1].length + 1;
1481
+ newDecorations.push({
1482
+ range: { startLineNumber: lineNumber, endLineNumber: lineNumber, startColumn: absColumn, endColumn: absColumn + 1 },
1483
+ options: { inlineClassName: "duet-deprecated-code" }
1484
+ });
1485
+ }
1486
+ }
1487
+ }
1488
+ return newDecorations;
1489
+ });
1490
+ }
1491
+ /**
1492
+ * Shared lifecycle skeleton for per-editor decoration attachers: runs `compute(model)` up front, re-runs it
1493
+ * on every content change and on model switches, and clears the decorations on disposal. Installs the
1494
+ * shared strikethrough CSS once. Extracted from the G-code and object-model deprecation attachers, which
1495
+ * both wanted the same scaffolding.
1496
+ */
1497
+ function attachDecorationsFromModelScan(editor, compute) {
1498
+ installDeprecatedCodeStyle();
1499
+ let decorations = [];
1500
+ let pendingTimer = null;
1501
+ let disposed = false;
1502
+ const refresh = () => {
1503
+ if (disposed) {
1504
+ return;
1505
+ }
1506
+ const model = editor.getModel();
1507
+ if (!model) {
1508
+ return;
1509
+ }
1510
+ decorations = editor.deltaDecorations(decorations, compute(model));
1511
+ };
1512
+ // Defer to a microtask so we don't call deltaDecorations from inside Monaco's own edit cycle
1513
+ // Without this, every `onDidChangeModelContent` callback that mutates decorations triggers Monaco's
1514
+ // "Invoking deltaDecorations recursively could lead to leaking decorations" warning
1515
+ const scheduleRefresh = () => {
1516
+ if (pendingTimer !== null) {
1517
+ return;
1518
+ }
1519
+ pendingTimer = setTimeout(() => {
1520
+ pendingTimer = null;
1521
+ refresh();
1522
+ }, 0);
1523
+ };
1524
+ refresh();
1525
+ const modelListener = editor.onDidChangeModelContent(scheduleRefresh);
1526
+ const modelSwitchListener = editor.onDidChangeModel(scheduleRefresh);
1527
+ return {
1528
+ dispose: () => {
1529
+ disposed = true;
1530
+ if (pendingTimer !== null) {
1531
+ clearTimeout(pendingTimer);
1532
+ pendingTimer = null;
1533
+ }
1534
+ modelListener.dispose();
1535
+ modelSwitchListener.dispose();
1536
+ editor.deltaDecorations(decorations, []);
1537
+ }
1538
+ };
1539
+ }
1540
+ let deprecatedCodeStyleInstalled = false;
1541
+ function installDeprecatedCodeStyle() {
1542
+ if (deprecatedCodeStyleInstalled || typeof document === "undefined") {
1543
+ return;
1544
+ }
1545
+ const style = document.createElement("style");
1546
+ style.textContent = ".duet-deprecated-code { text-decoration: line-through; }";
1547
+ document.head.appendChild(style);
1548
+ deprecatedCodeStyleInstalled = true;
1549
+ }
1550
+ /**
1551
+ * Strike-through deprecated object-model paths that appear in the editor. Matches dotted chains like
1552
+ * `move.extruders[0].pressureAdvance`, normalises the bracket indices to `[]`, and highlights the chain if
1553
+ * the normalised path is present in the deprecations map shipped by @duet3d/objectmodel. Re-runs on every
1554
+ * content change; hover tooltip carries the deprecation reason.
1555
+ */
1556
+ export function attachObjectModelDeprecationDecorations(editor) {
1557
+ // Identifier chain with at least one `.` or `[n]` step. Non-greedy on boundaries so adjacent text
1558
+ // (e.g. trailing brackets / punctuation) isn't consumed
1559
+ const chainRegex = /[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*|\[\d+\])+/g;
1560
+ return attachDecorationsFromModelScan(editor, model => {
1561
+ const newDecorations = [];
1562
+ for (let lineNumber = 1; lineNumber <= model.getLineCount(); lineNumber++) {
1563
+ const text = model.getLineContent(lineNumber);
1564
+ chainRegex.lastIndex = 0;
1565
+ let m;
1566
+ while ((m = chainRegex.exec(text)) !== null) {
1567
+ const deprecation = getPathDeprecation(m[0]);
1568
+ if (deprecation === null) {
1569
+ continue;
1570
+ }
1571
+ const startColumn = m.index + 1;
1572
+ const endColumn = startColumn + m[0].length;
1573
+ newDecorations.push({
1574
+ range: { startLineNumber: lineNumber, endLineNumber: lineNumber, startColumn, endColumn },
1575
+ options: { inlineClassName: "duet-deprecated-code" }
1576
+ });
1577
+ }
1578
+ }
1579
+ return newDecorations;
1580
+ });
1581
+ }