Skip to content

Commit 640446d

Browse files
authored
Support for Symbols, Outline, Code Folding in .gts files (#876)
* wip gts-gjs language service plugin * symbols kinda working * basic symbol test * add template symbols to gts * add volar html service for template symbolx * eslint
1 parent bb2c7cd commit 640446d

File tree

11 files changed

+512
-68
lines changed

11 files changed

+512
-68
lines changed

ARCHITECTURE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,13 @@ Glint implements two VirtualCodes in order to provide Language Tooling for moder
5757
- Ideally every Ember app will migrate to .gts but we still need to support classic components until that time comes
5858
- The backing .ts file is already type-checkable on its own, but for type-checking an .hbs file:
5959
- This Virtual Code will combine the .hbs+.ts and generate a singular type-checkable TS file in a similar mapping structure as `VirtualGtsCode` mentioned above
60+
Any VirtualCode, whether implemented by Glint or Vue tooling, essentially takes a language that embeds other languages (.gts is TS with embedded Glimmer, .vue has `<script>` + `<template>` + `<style>` tags, etc.) and produces a tree of embedded codes where the "leaves" of that tree are simpler, single-language codes (that don't include any other embedded languages). These single-language "leaves" can then be used as inputs for a variety of Language Services (see below), either ones already provided by Volar or custom ones implemented by Glint.
6061

61-
Any VirtualCode, whether implemented by Glint or Vue tooling, essentially takes a language that embeds other languages (.gts is TS with embedded Glimmer, .vue has `<script>` +`<template>`+`<style>` tags, etc), and produces a tree of embedded codes where the "leaves" of that tree are simpler, single-language codes (that don't include any other embedded languages). These single-language "leaves" can then be used as inputs for a variety of Language Services, either ones already provided by Volar or custom ones implemented by Glint.
62+
## Core Primitive: Language Service
6263

63-
Language Services operate on singular languages, e.g. a Language Service could be used to implement code completions in HTML, or to provide formatting in CSS, etc. Volar makes it possible/easy for Language Services to operate on the single-language "leaves" of your VirtualCode tree, and then, using the source maps that were built up as part of the VirtualCode implementation, reverse-source map those transformations, diagnostics, code hints, etc, back to the source file.
64+
Language Services operate on singular languages; for example, a Language Service could be used to implement code completions in HTML or provide formatting in CSS. Volar makes it possible and easy for Language Services to operate on the single-language "leaves" of your VirtualCode tree, and then, using the source maps that were built up as part of the VirtualCode implementation, reverse-source map those transformations, diagnostics, code hints, etc., back to the source file.
6465

65-
NOTE: at the time of writing, I don't believe Glint is currently making use of Language Services due to the fact that Glint primarily has been concerned with providing TS functionality to Ember/Glimmer templates, and most of that logic has been shifted towards the Glint TS Plugin (described below). If it is decided to enhance Glint functionality, or perhaps merge it into Ember Language Server (something that has been discussed), then likely more of the Language Service framework/infrastructure will be used.
66+
Volar.js maintains a number of [shared Language Plugins](https://github.com/volarjs/services/tree/master/packages) that can be consumed by many different language tooling systems (Vue, MDX, Astro, Glint, etc.). For example, while Glint uses a TS Plugin for diagnostics, there are some TS features that still require using the Volar TS LanguageService. For instance, to provide Symbols (which drive the Outline panel in VSCode, among other things), Glint uses a lightweight syntax-only TS Language Service provided by Volar.
6667

6768
## Glint V2 Moves Type-Checking Features out from Language Server and into TS Plugin
6869

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"silent-error": "^1.1.1",
5555
"uuid": "^8.3.2",
5656
"volar-service-typescript": "volar-2.4",
57+
"volar-service-html": "volar-2.4",
5758
"vscode-languageserver-protocol": "^3.17.5",
5859
"vscode-languageserver-textdocument": "^1.0.5",
5960
"vscode-uri": "^3.0.8",
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import type { LanguageServiceContext, LanguageServicePlugin } from '@volar/language-service';
2+
import { VirtualGtsCode } from '../volar/gts-virtual-code.js';
3+
import type * as vscode from 'vscode-languageserver-protocol';
4+
import type { TextDocument } from 'vscode-languageserver-textdocument';
5+
import { URI } from 'vscode-uri';
6+
7+
/**
8+
* This is a LanguageServicePlugin that provides language features for the top-level .gts/.gjs files.
9+
* For now, this just provides document symbols for `<template>` tags, which are a language
10+
* construct specific to .gts/.gjs files. Note that .gts/.gjs files will have TypeScript symbols
11+
* provided by our syntactic TS LanguageServicePlugin configured elsewhere, and these will be
12+
* combined with the symbols provided here.
13+
*/
14+
export function create(): LanguageServicePlugin {
15+
return {
16+
name: 'gts-gjs',
17+
capabilities: {
18+
documentSymbolProvider: true,
19+
},
20+
create(context) {
21+
return {
22+
provideDocumentSymbols(document) {
23+
return worker(document, context, (root) => {
24+
const result: vscode.DocumentSymbol[] = [];
25+
const { transformedModule } = root;
26+
27+
if (transformedModule) {
28+
const templateSymbols = transformedModule.templateSymbols();
29+
for (const templateSymbol of templateSymbols) {
30+
result.push({
31+
name: 'template',
32+
kind: 2 satisfies typeof vscode.SymbolKind.Module,
33+
range: {
34+
start: document.positionAt(templateSymbol.start),
35+
end: document.positionAt(templateSymbol.end),
36+
},
37+
selectionRange: {
38+
start: document.positionAt(templateSymbol.start),
39+
end: document.positionAt(templateSymbol.startTagEnd),
40+
},
41+
});
42+
}
43+
}
44+
45+
return result;
46+
});
47+
},
48+
49+
// TODO: this is copied from Vue; this might be the proper way for surfacing Glimmer syntax parsing errors to the top-level .gts file.
50+
// provideDiagnostics(document, token) {
51+
// return worker(document, context, async (root) => {
52+
// const { vueSfc, sfc } = root;
53+
// if (!vueSfc) {
54+
// return;
55+
// }
56+
57+
// const originalResult = await htmlServiceInstance.provideDiagnostics?.(document, token);
58+
// const sfcErrors: vscode.Diagnostic[] = [];
59+
// const { template } = sfc;
60+
61+
// const { startTagEnd = Infinity, endTagStart = -Infinity } = template ?? {};
62+
63+
// for (const error of vueSfc.errors) {
64+
// if ('code' in error) {
65+
// const start = error.loc?.start.offset ?? 0;
66+
// const end = error.loc?.end.offset ?? 0;
67+
// if (end < startTagEnd || start >= endTagStart) {
68+
// sfcErrors.push({
69+
// range: {
70+
// start: document.positionAt(start),
71+
// end: document.positionAt(end),
72+
// },
73+
// severity: 1 satisfies typeof vscode.DiagnosticSeverity.Error,
74+
// code: error.code,
75+
// source: 'vue',
76+
// message: error.message,
77+
// });
78+
// }
79+
// }
80+
// }
81+
82+
// return [...(originalResult ?? []), ...sfcErrors];
83+
// });
84+
// },
85+
86+
// TODO: this is copied from Vue; this might be a good place to implement auto-completing
87+
// the `<template>` tag and other top-level concerns for .gts files
88+
89+
// async provideCompletionItems(document, position, context, token) {
90+
// const result = await htmlServiceInstance.provideCompletionItems?.(
91+
// document,
92+
// position,
93+
// context,
94+
// token,
95+
// );
96+
// if (!result) {
97+
// return;
98+
// }
99+
// result.items = result.items.filter(
100+
// (item) =>
101+
// item.label !== '!DOCTYPE' && item.label !== 'Custom Blocks' && item.label !== 'data-',
102+
// );
103+
104+
// const tags = sfcDataProvider?.provideTags();
105+
106+
// const scriptLangs = getLangs('script');
107+
// const scriptItems = result.items.filter(
108+
// (item) => item.label === 'script' || item.label === 'script setup',
109+
// );
110+
// for (const scriptItem of scriptItems) {
111+
// scriptItem.kind = 17 satisfies typeof vscode.CompletionItemKind.File;
112+
// scriptItem.detail = '.js';
113+
// for (const lang of scriptLangs) {
114+
// result.items.push({
115+
// ...scriptItem,
116+
// detail: `.${lang}`,
117+
// kind: 17 satisfies typeof vscode.CompletionItemKind.File,
118+
// label: scriptItem.label + ' lang="' + lang + '"',
119+
// textEdit: scriptItem.textEdit
120+
// ? {
121+
// ...scriptItem.textEdit,
122+
// newText: scriptItem.textEdit.newText + ' lang="' + lang + '"',
123+
// }
124+
// : undefined,
125+
// });
126+
// }
127+
// }
128+
129+
// const styleLangs = getLangs('style');
130+
// const styleItem = result.items.find((item) => item.label === 'style');
131+
// if (styleItem) {
132+
// styleItem.kind = 17 satisfies typeof vscode.CompletionItemKind.File;
133+
// styleItem.detail = '.css';
134+
// for (const lang of styleLangs) {
135+
// result.items.push(
136+
// getStyleCompletionItem(styleItem, lang),
137+
// getStyleCompletionItem(styleItem, lang, 'scoped'),
138+
// getStyleCompletionItem(styleItem, lang, 'module'),
139+
// );
140+
// }
141+
// }
142+
143+
// const templateLangs = getLangs('template');
144+
// const templateItem = result.items.find((item) => item.label === 'template');
145+
// if (templateItem) {
146+
// templateItem.kind = 17 satisfies typeof vscode.CompletionItemKind.File;
147+
// templateItem.detail = '.html';
148+
// for (const lang of templateLangs) {
149+
// if (lang === 'html') {
150+
// continue;
151+
// }
152+
// result.items.push({
153+
// ...templateItem,
154+
// kind: 17 satisfies typeof vscode.CompletionItemKind.File,
155+
// detail: `.${lang}`,
156+
// label: templateItem.label + ' lang="' + lang + '"',
157+
// textEdit: templateItem.textEdit
158+
// ? {
159+
// ...templateItem.textEdit,
160+
// newText: templateItem.textEdit.newText + ' lang="' + lang + '"',
161+
// }
162+
// : undefined,
163+
// });
164+
// }
165+
// }
166+
// return result;
167+
168+
// function getLangs(label: string) {
169+
// return (
170+
// tags
171+
// ?.find((tag) => tag.name === label)
172+
// ?.attributes.find((attr) => attr.name === 'lang')
173+
// ?.values?.map(({ name }) => name) ?? []
174+
// );
175+
// }
176+
// },
177+
};
178+
},
179+
};
180+
181+
function worker<T>(
182+
document: TextDocument,
183+
context: LanguageServiceContext,
184+
callback: (root: VirtualGtsCode) => T,
185+
): T | undefined {
186+
if (document.languageId !== 'glimmer-ts' && document.languageId !== 'glimmer-js') {
187+
return;
188+
}
189+
const uri = URI.parse(document.uri);
190+
const decoded = context.decodeEmbeddedDocumentUri(uri);
191+
const sourceScript = decoded && context.language.scripts.get(decoded[0]);
192+
const root = sourceScript?.generated?.root;
193+
if (root instanceof VirtualGtsCode) {
194+
return callback(root);
195+
}
196+
}
197+
}

packages/core/src/transform/template/transformed-module.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export type SourceFile = {
4444
contents: string;
4545
};
4646

47+
export type TemplateSymbol = {
48+
start: number;
49+
end: number;
50+
startTagEnd: number;
51+
};
52+
4753
/**
4854
* This class represents the result of transforming a TypeScript
4955
* module with one or more embedded HBS templates. It contains
@@ -61,7 +67,7 @@ export default class TransformedModule {
6167
public readonly transformedContents: string,
6268
public readonly errors: ReadonlyArray<TransformError>,
6369
public readonly directives: ReadonlyArray<Directive>,
64-
private readonly correlatedSpans: Array<CorrelatedSpan>,
70+
public readonly correlatedSpans: Array<CorrelatedSpan>,
6571
) {}
6672

6773
public toDebugString(): string {
@@ -334,11 +340,31 @@ export default class TransformedModule {
334340
// TODO: audit usage of `codeFeatures.all` here: https://github.com/typed-ember/glint/issues/769
335341
// There are cases where we need to be disabling certain features to prevent, e.g., navigation
336342
// that targets an "in-between" piece of generated code.
337-
push(span.originalStart, span.transformedStart, span.originalLength, codeFeatures.all);
343+
push(span.originalStart, span.transformedStart, span.originalLength, {
344+
...codeFeatures.all,
345+
346+
// This enables symbol/outline info for the transformed TS to appear in for the .gts file.
347+
structure: true,
348+
});
338349
}
339350
}
340351
});
341352

342353
return codeMappings;
343354
}
355+
356+
templateSymbols(): Array<TemplateSymbol> {
357+
const result: Array<TemplateSymbol> = [];
358+
this.correlatedSpans.forEach((span) => {
359+
if (span.glimmerAstMapping) {
360+
// This is a template span
361+
result.push({
362+
start: span.originalStart,
363+
end: span.originalStart + span.originalLength,
364+
startTagEnd: span.originalStart + span.originalLength,
365+
});
366+
}
367+
});
368+
return result;
369+
}
344370
}

packages/core/src/volar/gts-virtual-code.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export class VirtualGtsCode implements VirtualCode {
145145
// .gts file has embedded templates, so lets generate a new embedded code
146146
// that contains the transformed TS code.
147147
const mappings = transformedModule.toVolarMappings();
148-
this.embeddedCodes = [
148+
const embeddedCodes = [
149149
{
150150
embeddedCodes: [],
151151
id: 'ts',
@@ -155,6 +155,49 @@ export class VirtualGtsCode implements VirtualCode {
155155
directives: transformedModule.directives,
156156
},
157157
];
158+
159+
// Add an embedded code for each <template> tag in the .gts file
160+
// so that the HTML Language Service can kick in and provide symbols
161+
// and other functionality.
162+
transformedModule.correlatedSpans.forEach((span, index) => {
163+
if (!span.glimmerAstMapping) {
164+
return;
165+
}
166+
167+
const openTemplateTagLength = 10; // "<template>"
168+
const closeTemplateTagLength = 11; // "</template>"
169+
170+
embeddedCodes.push({
171+
embeddedCodes: [],
172+
id: `template_html_${index}`,
173+
languageId: 'html', // technically this is 'handlebars' but 'html' causes the HTML Language Service to kick in
174+
mappings: [
175+
{
176+
sourceOffsets: [span.originalStart + openTemplateTagLength],
177+
generatedOffsets: [0],
178+
lengths: [span.originalLength - openTemplateTagLength - closeTemplateTagLength],
179+
180+
data: {
181+
completion: true,
182+
format: true,
183+
navigation: true,
184+
semantic: true,
185+
structure: true,
186+
verification: false,
187+
} satisfies CodeInformation,
188+
},
189+
],
190+
snapshot: new ScriptSnapshot(
191+
contents.slice(
192+
span.originalStart + openTemplateTagLength,
193+
span.originalStart + span.originalLength - closeTemplateTagLength,
194+
),
195+
),
196+
directives: [],
197+
});
198+
});
199+
200+
this.embeddedCodes = embeddedCodes;
158201
} else {
159202
// Null transformed module means there's no embedded HBS templates,
160203
// so just return a full "no-op" mapping from source to transformed.

0 commit comments

Comments
 (0)