Skip to content

Commit ba38ced

Browse files
feat: graplix tagged string literal support for .ts, .tsx files
1 parent cdb49e1 commit ba38ced

5 files changed

Lines changed: 370 additions & 6 deletions

File tree

.changeset/four-spiders-take.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"graplix-vscode-extension": minor
3+
---
4+
5+
feat: graplix tagged string literal support for .ts, .tsx files

packages/vscode-extension/package.json

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,28 @@
3333
"build": "tsdown && vsce package --no-dependencies",
3434
"watch": "tsdown --watch"
3535
},
36+
"activationEvents": [
37+
"onLanguage:graplix",
38+
"onLanguage:typescript",
39+
"onLanguage:typescriptreact"
40+
],
3641
"contributes": {
3742
"grammars": [
3843
{
3944
"language": "graplix",
4045
"scopeName": "source.graplix",
4146
"path": "./syntaxes/graplix.tmLanguage.json"
47+
},
48+
{
49+
"scopeName": "graplix.injection",
50+
"path": "./syntaxes/graplix-template.injection.json",
51+
"injectTo": [
52+
"source.ts",
53+
"source.tsx"
54+
],
55+
"embeddedLanguages": {
56+
"meta.embedded.inline.graplix": "graplix"
57+
}
4258
}
4359
],
4460
"languages": [
@@ -62,13 +78,13 @@
6278
"vscode-languageserver": "^9.0.1"
6379
},
6480
"devDependencies": {
65-
"@types/vscode": "^1.109.0",
81+
"@types/vscode": "^1.80.0",
6682
"@vscode/vsce": "^3.7.1",
6783
"tsdown": "^0.20.3",
6884
"typescript": "^5.9.3",
6985
"vscode": "^1.1.37"
7086
},
7187
"engines": {
72-
"vscode": "^1.109.0"
88+
"vscode": "^1.80.0"
7389
}
7490
}

packages/vscode-extension/src/extension.ts

Lines changed: 297 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as path from "node:path";
2-
import type { ExtensionContext } from "vscode";
2+
import { parse } from "@graplix/language";
3+
import * as vscode from "vscode";
34
import {
45
LanguageClient,
56
type LanguageClientOptions,
@@ -8,11 +9,271 @@ import {
89
} from "vscode-languageclient/node";
910

1011
let client: LanguageClient;
12+
const validationTimers = new Map<string, ReturnType<typeof setTimeout>>();
13+
let outputChannel: vscode.OutputChannel | undefined;
14+
15+
interface GraplixTemplate {
16+
readonly startOffset: number;
17+
readonly endOffset: number;
18+
readonly content: string;
19+
readonly hasInterpolation: boolean;
20+
}
21+
22+
function extractGraplixTemplates(text: string): readonly GraplixTemplate[] {
23+
const templates: GraplixTemplate[] = [];
24+
const tag = "graplix";
25+
26+
const isIdentifierCharacter = (char: string | undefined): boolean => {
27+
return char !== undefined && /[A-Za-z0-9_$]/.test(char);
28+
};
29+
30+
const skipWhitespaceAndComments = (startIndex: number): number => {
31+
let index = startIndex;
32+
33+
while (index < text.length) {
34+
const char = text[index];
35+
const next = text[index + 1];
36+
37+
if (char === " " || char === "\t" || char === "\r" || char === "\n") {
38+
index += 1;
39+
continue;
40+
}
41+
42+
if (char === "/" && next === "/") {
43+
index += 2;
44+
while (index < text.length && text[index] !== "\n") {
45+
index += 1;
46+
}
47+
continue;
48+
}
49+
50+
if (char === "/" && next === "*") {
51+
index += 2;
52+
while (index + 1 < text.length) {
53+
if (text[index] === "*" && text[index + 1] === "/") {
54+
index += 2;
55+
break;
56+
}
57+
index += 1;
58+
}
59+
continue;
60+
}
61+
62+
break;
63+
}
64+
65+
return index;
66+
};
67+
68+
let searchIndex = 0;
69+
70+
while (searchIndex < text.length) {
71+
const matchIndex = text.indexOf(tag, searchIndex);
72+
if (matchIndex < 0) {
73+
break;
74+
}
75+
76+
const previous = text[matchIndex - 1];
77+
const nextAfterTag = text[matchIndex + tag.length];
78+
if (
79+
isIdentifierCharacter(previous) ||
80+
isIdentifierCharacter(nextAfterTag)
81+
) {
82+
searchIndex = matchIndex + tag.length;
83+
continue;
84+
}
85+
86+
const templateDelimiterIndex = skipWhitespaceAndComments(
87+
matchIndex + tag.length,
88+
);
89+
if (text[templateDelimiterIndex] !== "`") {
90+
searchIndex = matchIndex + tag.length;
91+
continue;
92+
}
93+
94+
const templateStart = templateDelimiterIndex + 1;
95+
let cursor = templateStart;
96+
let hasInterpolation = false;
97+
98+
while (cursor < text.length) {
99+
const char = text[cursor];
100+
101+
if (char === "\\") {
102+
cursor += 2;
103+
continue;
104+
}
105+
106+
if (char === "$" && text[cursor + 1] === "{") {
107+
hasInterpolation = true;
108+
cursor += 2;
109+
let interpolationDepth = 1;
110+
111+
while (cursor < text.length && interpolationDepth > 0) {
112+
const interpolationChar = text[cursor];
113+
if (interpolationChar === "\\") {
114+
cursor += 2;
115+
continue;
116+
}
117+
if (interpolationChar === "{") {
118+
interpolationDepth += 1;
119+
} else if (interpolationChar === "}") {
120+
interpolationDepth -= 1;
121+
}
122+
cursor += 1;
123+
}
124+
125+
continue;
126+
}
127+
128+
if (char === "`") {
129+
templates.push({
130+
startOffset: templateStart,
131+
endOffset: cursor,
132+
content: text.slice(templateStart, cursor),
133+
hasInterpolation,
134+
});
135+
break;
136+
}
137+
138+
cursor += 1;
139+
}
140+
141+
searchIndex = cursor + 1;
142+
}
143+
144+
return templates;
145+
}
146+
147+
function severityToVscode(
148+
severity: number | undefined,
149+
): vscode.DiagnosticSeverity {
150+
if (severity === 1) {
151+
return vscode.DiagnosticSeverity.Error;
152+
}
153+
if (severity === 2) {
154+
return vscode.DiagnosticSeverity.Warning;
155+
}
156+
if (severity === 3) {
157+
return vscode.DiagnosticSeverity.Information;
158+
}
159+
if (severity === 4) {
160+
return vscode.DiagnosticSeverity.Hint;
161+
}
162+
163+
return vscode.DiagnosticSeverity.Error;
164+
}
165+
166+
async function validateGraplixTemplates(
167+
document: vscode.TextDocument,
168+
collection: vscode.DiagnosticCollection,
169+
): Promise<void> {
170+
if (
171+
document.languageId !== "typescript" &&
172+
document.languageId !== "typescriptreact"
173+
) {
174+
collection.delete(document.uri);
175+
return;
176+
}
177+
178+
const version = document.version;
179+
const templates = extractGraplixTemplates(document.getText());
180+
const diagnostics: vscode.Diagnostic[] = [];
181+
182+
for (const [index, template] of templates.entries()) {
183+
const startPosition = document.positionAt(template.startOffset);
184+
185+
if (template.hasInterpolation) {
186+
const interpolationDiagnostic = new vscode.Diagnostic(
187+
new vscode.Range(
188+
startPosition,
189+
document.positionAt(template.endOffset),
190+
),
191+
"graplix tagged template with interpolation is not validated.",
192+
vscode.DiagnosticSeverity.Warning,
193+
);
194+
interpolationDiagnostic.source = "graplix";
195+
diagnostics.push(interpolationDiagnostic);
196+
continue;
197+
}
198+
199+
const ext = "graplix";
200+
const filename = `template-${encodeURIComponent(document.uri.toString())}-${index}.${ext}`;
201+
202+
const graplixDocument = await parse(template.content, {
203+
documentUri: `memory://graplix/${filename}`,
204+
validation: true,
205+
});
206+
207+
for (const issue of graplixDocument.diagnostics ?? []) {
208+
const issueRange = issue.range;
209+
const range =
210+
issueRange === undefined
211+
? new vscode.Range(startPosition, startPosition)
212+
: new vscode.Range(
213+
new vscode.Position(
214+
startPosition.line + issueRange.start.line,
215+
issueRange.start.line === 0
216+
? startPosition.character + issueRange.start.character
217+
: issueRange.start.character,
218+
),
219+
new vscode.Position(
220+
startPosition.line + issueRange.end.line,
221+
issueRange.end.line === 0
222+
? startPosition.character + issueRange.end.character
223+
: issueRange.end.character,
224+
),
225+
);
226+
227+
const diagnostic = new vscode.Diagnostic(
228+
range,
229+
issue.message,
230+
severityToVscode(issue.severity),
231+
);
232+
diagnostic.source = "graplix";
233+
diagnostics.push(diagnostic);
234+
}
235+
}
236+
237+
if (version !== document.version) {
238+
return;
239+
}
240+
241+
collection.set(document.uri, diagnostics);
242+
}
243+
244+
function scheduleTemplateValidation(
245+
document: vscode.TextDocument,
246+
collection: vscode.DiagnosticCollection,
247+
): void {
248+
const key = document.uri.toString();
249+
const pending = validationTimers.get(key);
250+
if (pending !== undefined) {
251+
clearTimeout(pending);
252+
}
253+
254+
validationTimers.set(
255+
key,
256+
setTimeout(async () => {
257+
validationTimers.delete(key);
258+
try {
259+
await validateGraplixTemplates(document, collection);
260+
} catch (error) {
261+
const message = error instanceof Error ? error.message : String(error);
262+
outputChannel?.appendLine(
263+
`[graplix-template] validation failed for ${document.uri.toString()}: ${message}`,
264+
);
265+
}
266+
}, 120),
267+
);
268+
}
11269

12270
/**
13271
* Activates the Graplix VS Code extension and starts the language client.
14272
*/
15-
export function activate(context: ExtensionContext) {
273+
export function activate(context: vscode.ExtensionContext) {
274+
outputChannel = vscode.window.createOutputChannel("Graplix");
275+
context.subscriptions.push(outputChannel);
276+
16277
// The server is implemented in node
17278
const serverModule = context.asAbsolutePath(
18279
path.join("dist", "language-server.cjs"),
@@ -52,6 +313,40 @@ export function activate(context: ExtensionContext) {
52313

53314
// Start the client. This will also launch the server
54315
client.start();
316+
317+
const diagnostics =
318+
vscode.languages.createDiagnosticCollection("graplix-template");
319+
context.subscriptions.push(diagnostics);
320+
321+
for (const document of vscode.workspace.textDocuments) {
322+
scheduleTemplateValidation(document, diagnostics);
323+
}
324+
325+
context.subscriptions.push(
326+
vscode.workspace.onDidOpenTextDocument((document) => {
327+
scheduleTemplateValidation(document, diagnostics);
328+
}),
329+
vscode.workspace.onDidChangeTextDocument((event) => {
330+
scheduleTemplateValidation(event.document, diagnostics);
331+
}),
332+
vscode.workspace.onDidCloseTextDocument((document) => {
333+
diagnostics.delete(document.uri);
334+
const key = document.uri.toString();
335+
const pending = validationTimers.get(key);
336+
if (pending !== undefined) {
337+
clearTimeout(pending);
338+
validationTimers.delete(key);
339+
}
340+
}),
341+
vscode.workspace.onDidSaveTextDocument((document) => {
342+
scheduleTemplateValidation(document, diagnostics);
343+
}),
344+
vscode.window.onDidChangeActiveTextEditor((editor) => {
345+
if (editor !== undefined) {
346+
scheduleTemplateValidation(editor.document, diagnostics);
347+
}
348+
}),
349+
);
55350
}
56351

57352
/**

0 commit comments

Comments
 (0)