@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.
- package/dist/expressions/expressions.json +277 -0
- package/dist/expressions/index.d.ts +24 -0
- package/dist/expressions/index.js +2 -0
- package/dist/gcodes/gcodes.json +5821 -0
- package/dist/gcodes/index.d.ts +60 -0
- package/dist/gcodes/index.js +17 -0
- package/dist/gcodes/local-variables.d.ts +26 -0
- package/dist/gcodes/local-variables.js +62 -0
- package/dist/gcodes/search.d.ts +17 -0
- package/dist/gcodes/search.js +589 -0
- package/dist/monaco-gcode.js +22 -9
- package/dist/monaco-menu.d.ts +3 -0
- package/dist/monaco-menu.js +32 -0
- package/dist/monaco-stm32.d.ts +3 -0
- package/dist/monaco-stm32.js +81 -0
- package/dist/objectmodel/deprecations.d.ts +12 -0
- package/dist/objectmodel/deprecations.js +42 -0
- package/dist/objectmodel/enums.d.ts +4 -0
- package/dist/objectmodel/enums.js +17 -0
- package/dist/objectmodel/machine-context.d.ts +33 -0
- package/dist/objectmodel/machine-context.js +30 -0
- package/dist/objectmodel/paths.d.ts +3 -0
- package/dist/objectmodel/paths.js +5 -0
- package/dist/providers.d.ts +55 -0
- package/dist/providers.js +1581 -0
- package/dist/register.d.ts +13 -0
- package/dist/register.js +73 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|