Skip to content

Commit 71fd69a

Browse files
committed
Add support for MQL embedded in YAML source tag
1 parent 97a8280 commit 71fd69a

File tree

3 files changed

+374
-3
lines changed

3 files changed

+374
-3
lines changed

.claude/settings.local.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(npm run lint)",
5+
"Bash(npx prettier:*)"
6+
]
7+
}
8+
}

src/embeddedMQL.ts

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
/**
2+
* Embedded MQL support for YAML documents
3+
*
4+
* This module provides language server support for MQL code embedded within YAML files
5+
* (specifically within `source: |` blocks). It handles document masking, position routing,
6+
* and feature provider implementations for embedded MQL regions.
7+
*/
8+
9+
import * as vscode from "vscode";
10+
import {
11+
LanguageClient,
12+
DocumentHighlightKind as LSPDocumentHighlightKind,
13+
Middleware,
14+
} from "vscode-languageclient/node";
15+
16+
export interface EmbeddedMQLRegion {
17+
startLine: number;
18+
endLine: number;
19+
}
20+
21+
interface EmbeddedMQLDocumentCache {
22+
version: number;
23+
region: EmbeddedMQLRegion | undefined; // Only one region per file
24+
maskedText: string;
25+
}
26+
27+
// Cache for computed regions and masked documents for embedded MQL in YAML
28+
const embeddedMQLDocumentCache = new Map<string, EmbeddedMQLDocumentCache>();
29+
30+
// Track which YAML documents have been opened on the MQL server for embedded MQL
31+
const embeddedMQLOpenedYAMLDocuments = new Set<string>();
32+
33+
// Reference to the language client (set during setup)
34+
let client: LanguageClient;
35+
let languageID: string;
36+
37+
/**
38+
* Detects the single MQL region in a YAML document.
39+
* Looks for a block scalar under the 'source: |' key.
40+
* Returns undefined if no MQL region is found.
41+
*/
42+
function detectMQLRegion(text: string): EmbeddedMQLRegion | undefined {
43+
const lines = text.split("\n");
44+
45+
for (let i = 0; i < lines.length; i++) {
46+
const line = lines[i];
47+
48+
// Look for block scalar headers like "source: |" or "source: >"
49+
const match = line.match(/^(\s*)source:\s*[|>][-+]?\s*$/);
50+
if (!match) {
51+
continue;
52+
}
53+
54+
const keyIndent = match[1].length;
55+
const contentIndent = keyIndent + 2; // YAML requires at least 1 space, typically 2
56+
57+
// Find the start of the content (next line)
58+
const startLine = i + 1;
59+
if (startLine >= lines.length) {
60+
return undefined;
61+
}
62+
63+
// Find the end of the block (first line that's not indented enough)
64+
let endLine = startLine;
65+
66+
for (let j = startLine; j < lines.length; j++) {
67+
const contentLine = lines[j];
68+
69+
// Empty lines are part of the block
70+
if (contentLine.trim() === "") {
71+
endLine = j;
72+
continue;
73+
}
74+
75+
// Count leading spaces
76+
const leadingSpaces = contentLine.match(/^\s*/)?.[0].length ?? 0;
77+
78+
// If not indented enough, block ends
79+
if (leadingSpaces < contentIndent) {
80+
break;
81+
}
82+
83+
// This line is part of the block
84+
endLine = j;
85+
}
86+
87+
// Return the region if it has content
88+
if (endLine >= startLine) {
89+
return {
90+
startLine,
91+
endLine,
92+
};
93+
}
94+
}
95+
96+
return undefined;
97+
}
98+
99+
/**
100+
* Creates a masked version of the document where only the MQL region contains text.
101+
* All other lines are replaced with empty strings.
102+
* This preserves line count and UTF-16 positions.
103+
*/
104+
function maskDocument(
105+
text: string,
106+
region: EmbeddedMQLRegion | undefined,
107+
): string {
108+
if (!region) {
109+
return text
110+
.split("\n")
111+
.map(() => "")
112+
.join("\n");
113+
}
114+
115+
const lines = text.split("\n");
116+
const maskedLines = lines.map(() => "");
117+
118+
// Restore original text for lines inside the MQL region
119+
for (
120+
let lineNum = region.startLine;
121+
lineNum <= region.endLine && lineNum < lines.length;
122+
lineNum++
123+
) {
124+
maskedLines[lineNum] = lines[lineNum];
125+
}
126+
127+
return maskedLines.join("\n");
128+
}
129+
130+
/**
131+
* Checks if a position is inside the embedded MQL region
132+
*/
133+
function isInsideEmbeddedMQLRegion(
134+
position: vscode.Position,
135+
region: EmbeddedMQLRegion | undefined,
136+
): boolean {
137+
if (!region) {
138+
return false;
139+
}
140+
return position.line >= region.startLine && position.line <= region.endLine;
141+
}
142+
143+
/**
144+
* Checks if a document and optional position should allow MQL language server requests.
145+
*
146+
* For non-YAML documents: always returns true (no restrictions)
147+
* For YAML documents without position (document-level): returns true only if MQL region exists
148+
* For YAML documents with position: returns true only if position is inside MQL region
149+
*/
150+
function isInsideValidMQLRegion(
151+
document: vscode.TextDocument,
152+
position?: vscode.Position,
153+
): boolean {
154+
// Non-YAML documents are always valid (no MQL region restrictions)
155+
if (document.languageId !== "yaml") {
156+
return true;
157+
}
158+
159+
const cache = getEmbeddedMQLCachedDocumentData(document);
160+
161+
// Document-level request: check if region exists
162+
if (position === undefined) {
163+
return cache.region !== undefined;
164+
}
165+
166+
// Position-based request: check if position is in region
167+
return isInsideEmbeddedMQLRegion(position, cache.region);
168+
}
169+
170+
/**
171+
* Gets or computes cached document data for embedded MQL
172+
*/
173+
function getEmbeddedMQLCachedDocumentData(
174+
document: vscode.TextDocument,
175+
): EmbeddedMQLDocumentCache {
176+
const uri = document.uri.toString();
177+
const cached = embeddedMQLDocumentCache.get(uri);
178+
179+
if (cached && cached.version === document.version) {
180+
return cached;
181+
}
182+
183+
// Compute fresh data
184+
const text = document.getText();
185+
const region = detectMQLRegion(text);
186+
const maskedText = maskDocument(text, region);
187+
188+
const cache: EmbeddedMQLDocumentCache = {
189+
version: document.version,
190+
region,
191+
maskedText,
192+
};
193+
194+
embeddedMQLDocumentCache.set(uri, cache);
195+
return cache;
196+
}
197+
198+
/**
199+
* Creates middleware for the LanguageClient to handle embedded MQL in YAML.
200+
* This middleware can intercept document lifecycle events and formatting requests to mask away YAML content,
201+
* and force formatting changes to be re-indented properly within the YAML block.
202+
*/
203+
export function createEmbeddedMQLMiddleware(): Middleware {
204+
return {
205+
// Document synchronization middleware masks away YAML content
206+
didOpen: async (document, next) => {
207+
if (document.languageId !== "yaml") {
208+
return await next(document);
209+
}
210+
211+
const uri = document.uri.toString();
212+
if (embeddedMQLOpenedYAMLDocuments.has(uri)) {
213+
return;
214+
}
215+
216+
const cache = getEmbeddedMQLCachedDocumentData(document);
217+
if (!cache.region) {
218+
return;
219+
}
220+
221+
await client.sendNotification("textDocument/didOpen", {
222+
textDocument: {
223+
uri: uri,
224+
languageId: languageID,
225+
version: document.version,
226+
text: cache.maskedText,
227+
},
228+
});
229+
230+
embeddedMQLOpenedYAMLDocuments.add(uri);
231+
},
232+
didChange: async (event, next) => {
233+
if (event.document.languageId !== "yaml") {
234+
return await next(event);
235+
}
236+
237+
const document = event.document;
238+
const uri = document.uri.toString();
239+
const cache = getEmbeddedMQLCachedDocumentData(document);
240+
241+
// If no MQL region and never opened, do nothing
242+
if (!cache.region && !embeddedMQLOpenedYAMLDocuments.has(uri)) {
243+
return;
244+
}
245+
246+
// If not yet opened, send didOpen first. This would happen if the MQL region was added in this change,
247+
// because `source: |` wasn't present on the initial open.
248+
if (!embeddedMQLOpenedYAMLDocuments.has(uri)) {
249+
if (cache.region) {
250+
// Manually send didOpen with masked content. We can't modify `document` to pass to `next`,
251+
// so we have to manually send the notification.
252+
await client.sendNotification("textDocument/didOpen", {
253+
textDocument: {
254+
uri: uri,
255+
languageId: languageID,
256+
version: document.version,
257+
text: cache.maskedText,
258+
},
259+
});
260+
embeddedMQLOpenedYAMLDocuments.add(uri);
261+
}
262+
return;
263+
}
264+
265+
// Manually send didChange with masked content. We can't modify `document` to pass to `next`,
266+
// so we have to manually send the notification.
267+
await client.sendNotification("textDocument/didChange", {
268+
textDocument: {
269+
uri: uri,
270+
version: document.version,
271+
},
272+
contentChanges: [
273+
{
274+
text: cache.maskedText,
275+
},
276+
],
277+
});
278+
},
279+
didClose: async (document, next) => {
280+
if (document.languageId === "yaml") {
281+
// Let standard close happen, then clean up local state
282+
await next(document);
283+
const uri = document.uri.toString();
284+
embeddedMQLOpenedYAMLDocuments.delete(uri);
285+
embeddedMQLDocumentCache.delete(uri);
286+
return;
287+
}
288+
289+
return await next(document);
290+
},
291+
292+
provideDocumentFormattingEdits: async (document, options, token, next) => {
293+
if (!isInsideValidMQLRegion(document)) {
294+
// No formatting requests are performed outside MQL regions. Other extensions can handle these requests.
295+
return undefined;
296+
}
297+
298+
// Get formatting from MQL server
299+
const edits = await next(document, options, token);
300+
if (!edits || edits.length === 0) {
301+
return edits;
302+
}
303+
304+
// For masked YAML documents, the server will autoformat everything to line: 1, column: 1.
305+
// We need to unmask and re-indent the formatted code for YAML block indentation
306+
if (document.languageId === "yaml") {
307+
const cache = getEmbeddedMQLCachedDocumentData(document);
308+
const region = cache.region!;
309+
310+
return edits.map((edit) => {
311+
const sourceLine = document.lineAt(region.startLine - 1);
312+
const sourceIndent = sourceLine.text.match(/^(\s*)/)?.[0] || "";
313+
const blockIndent = sourceIndent + " ";
314+
315+
const formattedLines = edit.newText.split("\n");
316+
const reindentedText = formattedLines
317+
.map((line: string) =>
318+
line.trim() === "" ? "" : blockIndent + line,
319+
)
320+
.join("\n");
321+
322+
return new vscode.TextEdit(edit.range, reindentedText);
323+
});
324+
}
325+
326+
return edits;
327+
},
328+
};
329+
}
330+
331+
/**
332+
* Sets up middleware for the LSP server/client. Full MQL documents are handled normally,
333+
* but embedded MQL in YAML documents are masked and synced appropriately.
334+
*/
335+
export function setupEmbeddedMQL(
336+
languageClient: LanguageClient,
337+
mqlLanguageID: string,
338+
): void {
339+
client = languageClient;
340+
languageID = mqlLanguageID;
341+
}
342+
343+
/**
344+
* Cleans up embedded MQL resources
345+
*/
346+
export function cleanupEmbeddedMQL(): void {
347+
embeddedMQLDocumentCache.clear();
348+
embeddedMQLOpenedYAMLDocuments.clear();
349+
}

0 commit comments

Comments
 (0)