@duet3d/monacotokens 3.7.0-alpha.2 → 3.7.0-alpha.3

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