diff --git a/packages/core/__tests__/cli/build-watch.test.ts b/packages/core/__tests__/cli/build-watch.test.ts
index 3e631e037..0b85713de 100644
--- a/packages/core/__tests__/cli/build-watch.test.ts
+++ b/packages/core/__tests__/cli/build-watch.test.ts
@@ -209,7 +209,7 @@ describe('CLI: watched build mode typechecking', () => {
await watch.terminate();
});
- test.skip('reports on errors introduced after removing a glint-nocheck directive', async () => {
+ test('reports on errors introduced after removing a glint-nocheck directive', async () => {
let code = stripIndent`
import '@glint/environment-ember-template-imports';
import Component from '@glimmer/component';
@@ -460,7 +460,12 @@ describe('CLI: watched build mode typechecking', () => {
stripped.indexOf('index.gts'),
stripped.lastIndexOf(`~~~${os.EOL}`) + 3,
);
- expect(error).toMatchInlineSnapshot(`""`);
+ expect(error).toMatchInlineSnapshot(`
+ "index.gts:15:5 - error TS2554: Expected 0 arguments, but got 1.
+
+ 15
+ ~~~~~~~~~~~~~~~~"
+ `);
await pauseForTSBuffering();
@@ -488,7 +493,12 @@ describe('CLI: watched build mode typechecking', () => {
stripped.indexOf('index.gts'),
stripped.lastIndexOf(`~~~${os.EOL}`) + 3,
);
- expect(error).toMatchInlineSnapshot(`""`);
+ expect(error).toMatchInlineSnapshot(`
+ "index.gts:3:28 - error TS2554: Expected 0 arguments, but got 1.
+
+ 3 const A = Hello! ;
+ ~~~~~~~~~~~~~~~~"
+ `);
await pauseForTSBuffering();
diff --git a/packages/core/__tests__/cli/build.test.ts b/packages/core/__tests__/cli/build.test.ts
index 2e003d5b2..fc8ea8865 100644
--- a/packages/core/__tests__/cli/build.test.ts
+++ b/packages/core/__tests__/cli/build.test.ts
@@ -614,7 +614,7 @@ describe('CLI: single-pass build mode typechecking', () => {
});
});
- describe.skip('for a type error covered by `@glint-nocheck`', () => {
+ describe('for a type error covered by `@glint-nocheck`', () => {
beforeEach(async () => {
let aCode = stripIndent`
import C from '@glint-test/c';
diff --git a/packages/core/__tests__/cli/check.test.ts b/packages/core/__tests__/cli/check.test.ts
index 4e2b2b69f..cfec96906 100644
--- a/packages/core/__tests__/cli/check.test.ts
+++ b/packages/core/__tests__/cli/check.test.ts
@@ -176,6 +176,116 @@ describe('CLI: single-pass typechecking', () => {
`);
});
+ test('ignores @glint-ignored errors', async () => {
+ let code = stripIndent`
+ import Component from '@glimmer/component';
+
+ type ApplicationArgs = {
+ version: string;
+ };
+
+ export default class Application extends Component<{ Args: ApplicationArgs }> {
+ private startupTime = new Date().toISOString();
+
+
+ Welcome to app v{{@version}}.
+
+ {{! @glint-ignore 'unknown property' }}
+ The current time is {{this.startupTimeError}}.
+
+ {{! @glint-ignore 'if this were expect-error this would be an unused expect-error' }}
+ The current time is {{this.startupTime}}.
+
+ }
+ `;
+
+ project.write('index.gts', code);
+
+ let checkResult = await project.check({ reject: false });
+
+ expect(checkResult.exitCode).toBe(0);
+
+ expect(stripAnsi(checkResult.stdout)).toMatchInlineSnapshot(`
+ ""
+ `);
+ });
+
+ test('reports unused @glint-expect-error', async () => {
+ let code = stripIndent`
+ import Component from '@glimmer/component';
+
+ type ApplicationArgs = {
+ version: string;
+ };
+
+ export default class Application extends Component<{ Args: ApplicationArgs }> {
+ private startupTime = new Date().toISOString();
+
+
+ Welcome to app v{{@version}}.
+ {{! @glint-expect-error 'no error here' }}
+ The current time is {{this.startupTime}}.
+
+ }
+ `;
+
+ project.write('index.gts', code);
+
+ let checkResult = await project.check({ reject: false });
+
+ expect(checkResult.exitCode).not.toBe(0);
+
+ expect(stripAnsi(checkResult.stdout)).toMatchInlineSnapshot(`
+ "index.gts:12:5 - error TS2578: Unused '@ts-expect-error' directive.
+
+ 12 {{! @glint-expect-error 'no error here' }}
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+
+ Found 1 error in index.gts:12
+ "
+ `);
+ });
+
+ test('does not report errors skipped by @glint-expect-error', async () => {
+ let code = stripIndent`
+ import Component from '@glimmer/component';
+
+ type ApplicationArgs = {
+ version: string;
+ };
+
+ export default class Application extends Component<{ Args: ApplicationArgs }> {
+ private startupTime = new Date().toISOString();
+
+
+ Welcome to app v{{@version}}.
+ {{! @glint-expect-error 'skipping this error' }}
+ The current time is {{this.startupTimee}}.
+
+ {{this.otherError}}
+
+ }
+ `;
+
+ project.write('index.gts', code);
+
+ let checkResult = await project.check({ reject: false });
+
+ expect(checkResult.exitCode).not.toBe(0);
+
+ expect(stripAnsi(checkResult.stdout)).toMatchInlineSnapshot(`
+ "index.gts:15:12 - error TS2339: Property 'otherError' does not exist on type 'Application'.
+
+ 15 {{this.otherError}}
+ ~~~~~~~~~~
+
+
+ Found 1 error in index.gts:15
+ "
+ `);
+ });
+
test.skip('reports diagnostics for a companion template type error', async () => {
project.setGlintConfig({ environment: 'ember-loose' });
diff --git a/packages/core/__tests__/language-server/diagnostics.test.ts b/packages/core/__tests__/language-server/diagnostics.test.ts
index 9e4f92953..17e2ac786 100644
--- a/packages/core/__tests__/language-server/diagnostics.test.ts
+++ b/packages/core/__tests__/language-server/diagnostics.test.ts
@@ -356,6 +356,141 @@ describe('Language Server: Diagnostics', () => {
});
test('honors @glint-ignore and @glint-expect-error', async () => {
+ let componentA = stripIndent`
+ import Component from '@glimmer/component';
+
+ export default class ComponentA extends Component {
+
+ {{! @glint-expect-error }}
+ Welcome to app _code_v{{@version}}_/code_.
+
+ }
+ `;
+
+ let componentB = stripIndent`
+ import Component from '@glimmer/component';
+
+ export default class ComponentB extends Component {
+ public startupTime = new Date().toISOString();
+
+
+ {{! @glint-ignore: this looks like a typo but for some reason it isn't }}
+ The current time is {{this.startupTimee}}.
+
+ }
+ `;
+
+ let server = await project.startLanguageServer();
+
+ project.write('component-a.gts', componentA);
+ project.write('component-b.gts', componentB);
+
+ const docA = await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+ let diagnostics = await server.sendDocumentDiagnosticRequest(docA.uri);
+
+ expect(diagnostics.items).toEqual([]);
+
+ const docB = await server.openTextDocument(project.filePath('component-b.gts'), 'glimmer-ts');
+ diagnostics = await server.sendDocumentDiagnosticRequest(docB.uri);
+ expect(diagnostics.items).toEqual([]);
+
+ await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+ await server.replaceTextDocument(
+ project.fileURI('component-a.gts'),
+ componentA.replace('{{! @glint-expect-error }}', ''),
+ );
+
+ expect(
+ (await server.sendDocumentDiagnosticRequest(project.fileURI('component-b.gts'))).items,
+ ).toEqual([]);
+ expect((await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts'))).items)
+ .toMatchInlineSnapshot(`
+ [
+ {
+ "code": 2339,
+ "data": {
+ "documentUri": "volar-embedded-content://URI_ENCODED_PATH_TO/FILE",
+ "isFormat": false,
+ "original": {},
+ "pluginIndex": 0,
+ "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/component-a.gts",
+ "version": 1,
+ },
+ "message": "Property 'version' does not exist on type '{}'.",
+ "range": {
+ "end": {
+ "character": 36,
+ "line": 5,
+ },
+ "start": {
+ "character": 29,
+ "line": 5,
+ },
+ },
+ "severity": 1,
+ "source": "glint",
+ },
+ ]
+ `);
+
+ await server.replaceTextDocument(project.fileURI('component-a.gts'), componentA);
+
+ expect(
+ (await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts'))).items,
+ ).toEqual([]);
+ expect(
+ (await server.sendDocumentDiagnosticRequest(project.fileURI('component-b.gts'))).items,
+ ).toEqual([]);
+
+ await server.replaceTextDocument(
+ project.fileURI('component-a.gts'),
+ componentA.replace('{{@version}}', ''),
+ );
+
+ expect(
+ (await server.sendDocumentDiagnosticRequest(project.fileURI('component-b.gts'))).items,
+ ).toEqual([]);
+
+ expect(
+ (await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts'))).items.length,
+ ).toEqual(1);
+
+ expect(await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts')))
+ .toMatchInlineSnapshot(`
+ {
+ "items": [
+ {
+ "code": 2578,
+ "data": {
+ "documentUri": "volar-embedded-content://URI_ENCODED_PATH_TO/FILE",
+ "isFormat": false,
+ "original": {},
+ "pluginIndex": 0,
+ "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/component-a.gts",
+ "version": 3,
+ },
+ "message": "Unused '@ts-expect-error' directive.",
+ "range": {
+ "end": {
+ "character": 30,
+ "line": 4,
+ },
+ "start": {
+ "character": 4,
+ "line": 4,
+ },
+ },
+ "severity": 1,
+ "source": "glint",
+ },
+ ],
+ "kind": "full",
+ }
+ `);
+ });
+
+ // Regression / breaking change since Glint 2
+ test.skip('@glint-ignore and @glint-expect-error skip over simple element declarations', async () => {
let componentA = stripIndent`
import Component from '@glimmer/component';
@@ -455,22 +590,12 @@ describe('Language Server: Diagnostics', () => {
(await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts'))).items.length,
).toEqual(1);
- // TODO: the start range for this is not quite right; specifically the start range seems
- // seems to go all the way to the end of the opening `` tag, e.g.
- // `[HERE]`. This causes excess red suiggles, and this goes away if there
- // are any other HTML elements preceding the glint-expect-error directive. I'm not
- // sure the root cause of this, but it may go away if/when we refactor a few things about
- // Volar's mapping logic.
- //
- // Tracking this issue here:
- //
- // https://github.com/typed-ember/glint/issues/796
expect(await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts')))
.toMatchInlineSnapshot(`
{
"items": [
{
- "code": 0,
+ "code": 2578,
"data": {
"documentUri": "volar-embedded-content://URI_ENCODED_PATH_TO/FILE",
"isFormat": false,
@@ -479,19 +604,506 @@ describe('Language Server: Diagnostics', () => {
"uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/component-a.gts",
"version": 3,
},
- "message": "Unused '@glint-expect-error' directive.",
+ "message": "Unused '@ts-expect-error' directive.",
"range": {
"end": {
"character": 30,
"line": 4,
},
"start": {
- "character": 12,
- "line": 3,
+ "character": 4,
+ "line": 4,
+ },
+ },
+ "severity": 1,
+ "source": "glint",
+ },
+ ],
+ "kind": "full",
+ }
+ `);
+ });
+
+ test('@glint-expect-error - unknown component reference', async () => {
+ let componentA = stripIndent`
+ import Component from '@glimmer/component';
+
+ export default class ComponentA extends Component {
+
+ {{! @glint-expect-error }}
+
+ {{this.unknownReference}}
+
+
+ }
+ `;
+
+ let server = await project.startLanguageServer();
+
+ project.write('component-a.gts', componentA);
+
+ const docA = await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+ let diagnostics = await server.sendDocumentDiagnosticRequest(docA.uri);
+
+ expect(diagnostics.items.length).toEqual(1);
+ });
+
+ test('@glint-expect-error - unknown component reference', async () => {
+ let componentA = stripIndent`
+ import Component from '@glimmer/component';
+
+ export default class ComponentA extends Component {
+
+ {{! @glint-expect-error }}
+
+ {{this.unknownReference}}
+
+
+ }
+ `;
+
+ let server = await project.startLanguageServer();
+
+ project.write('component-a.gts', componentA);
+
+ const docA = await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+ let diagnostics = await server.sendDocumentDiagnosticRequest(docA.uri);
+
+ expect(diagnostics.items.length).toEqual(1);
+ });
+
+ test('passing args to vanilla Component should be an error', async () => {
+ let componentA = stripIndent`
+ import Component from '@glimmer/component';
+
+ export default class ComponentA extends Component {
+
+
+
+ }
+ `;
+
+ let server = await project.startLanguageServer();
+
+ project.write('component-a.gts', componentA);
+
+ await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+
+ expect(await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts')))
+ .toMatchInlineSnapshot(`
+ {
+ "items": [
+ {
+ "code": 2554,
+ "data": {
+ "documentUri": "volar-embedded-content://URI_ENCODED_PATH_TO/FILE",
+ "isFormat": false,
+ "original": {},
+ "pluginIndex": 0,
+ "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/component-a.gts",
+ "version": 0,
+ },
+ "message": "Expected 0 arguments, but got 1.",
+ "range": {
+ "end": {
+ "character": 21,
+ "line": 5,
+ },
+ "start": {
+ "character": 4,
+ "line": 4,
+ },
+ },
+ "severity": 1,
+ "source": "glint",
+ },
+ ],
+ "kind": "full",
+ }
+ `);
+ });
+
+ test('passing args to vanilla Component should be an error -- suppressed with @glint-expect-error', async () => {
+ let componentA = stripIndent`
+ import Component from '@glimmer/component';
+
+ export default class ComponentA extends Component {
+
+ {{! @glint-expect-error }}
+
+
+ }
+ `;
+
+ let server = await project.startLanguageServer();
+
+ project.write('component-a.gts', componentA);
+
+ await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+
+ expect(await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts')))
+ .toMatchInlineSnapshot(`
+ {
+ "items": [],
+ "kind": "full",
+ }
+ `);
+ });
+
+ test('passing no args to a Component with args should be an error', async () => {
+ let componentA = stripIndent`
+ import Component from '@glimmer/component';
+
+ interface GreetingSignature {
+ Args: { target: string };
+ }
+
+ class Greeting extends Component {
+
+ {{@target}}
+
+ }
+
+ export default class extends Component {
+
+
+
+ }
+ `;
+
+ let server = await project.startLanguageServer();
+
+ project.write('component-a.gts', componentA);
+
+ await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+
+ expect(await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts')))
+ .toMatchInlineSnapshot(`
+ {
+ "items": [
+ {
+ "code": 2554,
+ "data": {
+ "documentUri": "volar-embedded-content://URI_ENCODED_PATH_TO/FILE",
+ "isFormat": false,
+ "original": {},
+ "pluginIndex": 0,
+ "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/component-a.gts",
+ "version": 0,
+ },
+ "message": "Expected 1 arguments, but got 0.",
+ "range": {
+ "end": {
+ "character": 16,
+ "line": 14,
+ },
+ "start": {
+ "character": 4,
+ "line": 14,
+ },
+ },
+ "relatedInformation": [
+ {
+ "location": {
+ "range": {
+ "end": {
+ "character": 48,
+ "line": 23,
+ },
+ "start": {
+ "character": 4,
+ "line": 23,
+ },
+ },
+ "uri": "file:///PATH_TO_MODULE/@glint/environment-ember-template-imports/-private/dsl/index.d.ts",
+ },
+ "message": "Arguments for the rest parameter 'args' were not provided.",
+ },
+ ],
+ "severity": 1,
+ "source": "glint",
+ },
+ ],
+ "kind": "full",
+ }
+ `);
+ });
+
+ test('glint-expect error on plain element does not consume errors within body');
+ test('glint-expect error on component invocation does not consume errors within body');
+
+ test('passing no args to a Component with args should be an error -- suppressed with @glint-expect-error', async () => {
+ let componentA = stripIndent`
+ import Component from '@glimmer/component';
+
+ interface GreetingSignature {
+ Args: { target: string };
+ }
+
+ class Greeting extends Component {
+
+ {{@target}}
+
+ }
+
+ export default class extends Component {
+
+ {{! @glint-expect-error }}
+
+
+ }
+ `;
+
+ let server = await project.startLanguageServer();
+
+ project.write('component-a.gts', componentA);
+
+ await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+
+ expect(await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts')))
+ .toMatchInlineSnapshot(`
+ {
+ "items": [],
+ "kind": "full",
+ }
+ `);
+ });
+
+ test('passing wrong arg name to a Component should be an error', async () => {
+ let componentA = stripIndent`
+ import Component from '@glimmer/component';
+
+ interface GreetingSignature {
+ Args: { target: string };
+ }
+
+ class Greeting extends Component {
+
+ {{@target}}
+
+ }
+
+ export default class extends Component {
+
+
+
+ }
+ `;
+
+ let server = await project.startLanguageServer();
+
+ project.write('component-a.gts', componentA);
+
+ await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+
+ expect(await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts')))
+ .toMatchInlineSnapshot(`
+ {
+ "items": [
+ {
+ "code": 2561,
+ "data": {
+ "documentUri": "volar-embedded-content://URI_ENCODED_PATH_TO/FILE",
+ "isFormat": false,
+ "original": {},
+ "pluginIndex": 0,
+ "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/component-a.gts",
+ "version": 0,
+ },
+ "message": "Object literal may only specify known properties, but 'target2' does not exist in type 'NamedArgs<{ target: string; }>'. Did you mean to write 'target'?",
+ "range": {
+ "end": {
+ "character": 22,
+ "line": 14,
+ },
+ "start": {
+ "character": 15,
+ "line": 14,
+ },
+ },
+ "severity": 1,
+ "source": "glint",
+ },
+ ],
+ "kind": "full",
+ }
+ `);
+ });
+
+ test('passing wrong arg name to a Component should be an error -- suppressed with top-level @glint-expect-error', async () => {
+ /**
+ * The specified/desired behavior here is difficult to implement due to the complexities and assymmetries between
+ * the expected behavior of `{{! @glint-expect-error}}` within a template and the transformed/generated
+ * `// @ts-expect-error` that is produced. The region of code covered by `glint-expect-error` might be a complex
+ * component invocation that includes attributes, modifiers, and/or comments, which can themselves be individually
+ * guarded by inline `{{! @glint-expect-error}}` directives within the element open tag.
+ *
+ * The end result of this is that there are cases where the top-level `{{! @glint-expect-error}}` preceding a
+ * component invocation might also cover and area of effect overlapping those of inline directives, and keeping
+ * these areas of effect totally separate is not possible.
+ */
+
+ let componentA = stripIndent`
+ import Component from '@glimmer/component';
+
+ interface GreetingSignature {
+ Args: { target: string };
+ }
+
+ class Greeting extends Component {
+
+ {{@target}}
+
+ }
+
+ export default class extends Component {
+
+ {{! @glint-expect-error }}
+
+
+ }
+ `;
+
+ let server = await project.startLanguageServer();
+
+ project.write('component-a.gts', componentA);
+
+ await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+
+ expect(await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts')))
+ .toMatchInlineSnapshot(`
+ {
+ "items": [],
+ "kind": "full",
+ }
+ `);
+ });
+
+ test('passing wrong arg name to a Component should be an error -- suppressed with inline @glint-expect-error with element open tag', async () => {
+ let componentA = stripIndent`
+ import Component from '@glimmer/component';
+
+ interface GreetingSignature {
+ Args: { target: string };
+ }
+
+ class Greeting extends Component {
+
+ {{@target}}
+
+ }
+
+ export default class extends Component {
+
+
+
+ }
+ `;
+
+ let server = await project.startLanguageServer();
+
+ project.write('component-a.gts', componentA);
+
+ await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+
+ expect(await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts')))
+ .toMatchInlineSnapshot(`
+ {
+ "items": [],
+ "kind": "full",
+ }
+ `);
+ });
+
+ test('passing wrong arg name to a Component should be an error followed by passing the correct arg name -- suppressed with inline @glint-expect-error with element open tag', async () => {
+ let componentA = stripIndent`
+ import Component from '@glimmer/component';
+
+ interface GreetingSignature {
+ Args: { target: string };
+ }
+
+ class Greeting extends Component {
+
+ {{@target}}
+
+ }
+
+ export default class extends Component {
+
+
+
+ }
+ `;
+
+ let server = await project.startLanguageServer();
+
+ project.write('component-a.gts', componentA);
+
+ await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+
+ expect(await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts')))
+ .toMatchInlineSnapshot(`
+ {
+ "items": [],
+ "kind": "full",
+ }
+ `);
+ });
+
+ test('@glint-expect-error - open element tag inline directive', async () => {
+ let componentA = stripIndent`
+ import Component from '@glimmer/component';
+
+ export default class ComponentA extends Component {
+
+
+
+ }
+ `;
+
+ let server = await project.startLanguageServer();
+
+ project.write('component-a.gts', componentA);
+
+ const docA = await server.openTextDocument(project.filePath('component-a.gts'), 'glimmer-ts');
+
+ expect(await server.sendDocumentDiagnosticRequest(project.fileURI('component-a.gts')))
+ .toMatchInlineSnapshot(`
+ {
+ "items": [
+ {
+ "code": 2554,
+ "data": {
+ "documentUri": "volar-embedded-content://URI_ENCODED_PATH_TO/FILE",
+ "isFormat": false,
+ "original": {},
+ "pluginIndex": 0,
+ "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/component-a.gts",
+ "version": 0,
+ },
+ "message": "Expected 0 arguments, but got 1.",
+ "range": {
+ "end": {
+ "character": 34,
+ "line": 6,
+ },
+ "start": {
+ "character": 4,
+ "line": 4,
},
},
"severity": 1,
- "source": "disregard.gts",
+ "source": "glint",
},
],
"kind": "full",
diff --git a/packages/core/__tests__/language-server/hover.test.ts b/packages/core/__tests__/language-server/hover.test.ts
index dd61bd5c3..9dca2bc92 100644
--- a/packages/core/__tests__/language-server/hover.test.ts
+++ b/packages/core/__tests__/language-server/hover.test.ts
@@ -155,6 +155,12 @@ describe('Language Server: Hover', () => {
"kind": "markdown",
"value": "\`\`\`typescript
const index: number
+ \`\`\`
+
+ ---
+
+ \`\`\`typescript
+ const index: number
\`\`\`",
},
"range": {
@@ -182,6 +188,12 @@ describe('Language Server: Hover', () => {
"kind": "markdown",
"value": "\`\`\`typescript
const item: string
+ \`\`\`
+
+ ---
+
+ \`\`\`typescript
+ const item: string
\`\`\`",
},
"range": {
diff --git a/packages/core/__tests__/language-server/references.test.ts b/packages/core/__tests__/language-server/references.test.ts
index ec2fb8e18..e826ff9fe 100644
--- a/packages/core/__tests__/language-server/references.test.ts
+++ b/packages/core/__tests__/language-server/references.test.ts
@@ -168,7 +168,7 @@ describe('Language Server: References', () => {
{
uri: project.fileURI('index.gts'),
range: {
- start: { line: 5, character: 14 },
+ start: { line: 5, character: 15 },
end: { line: 5, character: 21 },
},
},
diff --git a/packages/core/__tests__/transform/template-to-typescript.test.ts b/packages/core/__tests__/transform/template-to-typescript.test.ts
index 64c9f5f84..47b6a0287 100644
--- a/packages/core/__tests__/transform/template-to-typescript.test.ts
+++ b/packages/core/__tests__/transform/template-to-typescript.test.ts
@@ -63,123 +63,7 @@ describe('Transform: rewriteTemplate', () => {
});
describe('directives', () => {
- test('in a top-level mustache', () => {
- let template = stripIndent`
- {{! @glint-ignore: this is fine }}
-
- {{hello}}
-
- `;
-
- let { result, errors } = templateToTypescript(template, { typesModule: '@glint/template' });
-
- expect(errors).toEqual([]);
- expect(result?.directives).toEqual([
- {
- kind: 'ignore',
- location: {
- start: template.indexOf('{{!'),
- end: template.indexOf('fine }}') + 'fine }}'.length,
- },
- areaOfEffect: {
- start: template.indexOf('') + '|bar|>'.length + 1,
- },
- },
- ]);
- });
-
- test('in an element bobdy', () => {
- let template = stripIndent`
-
- {{hello}}
-
- `;
-
- let { result, errors } = templateToTypescript(template, { typesModule: '@glint/template' });
-
- expect(errors).toEqual([]);
- expect(result?.directives).toEqual([
- {
- kind: 'ignore',
- location: {
- start: template.indexOf('{{!'),
- end: template.indexOf('fine }}') + 'fine }}'.length,
- },
- areaOfEffect: {
- start: template.indexOf(' @arg='),
- end: template.indexOf('"hi"') + '"hi"'.length + 1,
- },
- },
- ]);
- });
-
- test('nocheck', () => {
- let template = stripIndent`
- {{! @glint-nocheck: don't check this whole template }}
-
- {{foo-bar}}
- {{this.baz}}
- `;
-
- let { result, errors } = templateToTypescript(template, { typesModule: '@glint/template' });
-
- expect(errors).toEqual([]);
- expect(result?.directives).toEqual([
- {
- kind: 'ignore',
- location: {
- start: 0,
- end: template.indexOf('template }}') + 'template }}'.length,
- },
- areaOfEffect: {
- start: 0,
- end: template.length - 1,
- },
- },
- ]);
- expect(templateBody(template)).toMatchInlineSnapshot(`
- "// @glint-nocheck
- {
- const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(__glintDSL__.Globals["Foo"])());
- __glintY__;
- }
- __glintDSL__.emitContent(__glintDSL__.resolveOrReturn(__glintDSL__.Globals["foo-bar"])());
- __glintDSL__.emitContent(__glintDSL__.resolveOrReturn(__glintRef__.this.baz)());"
- `);
- });
-
- test('expect-error', () => {
- let template = stripIndent`
- {{! @glint-expect-error: this is fine }}
-
- {{hello}}
-
- `;
-
- let { result, errors } = templateToTypescript(template, { typesModule: '@glint/template' });
-
- expect(errors).toEqual([]);
- expect(result?.directives).toEqual([
- {
- kind: 'expect-error',
- location: {
- start: template.indexOf('{{!'),
- end: template.indexOf('fine }}') + 'fine }}'.length,
- },
- areaOfEffect: {
- start: template.indexOf('') + '|bar|>'.length + 1,
- },
- },
- ]);
- });
-
- test('unknown type', () => {
+ test('unknown directies are reported as errors', () => {
let template = stripIndent`
{{! @glint-check }}
@@ -767,7 +651,8 @@ describe('Transform: rewriteTemplate', () => {
expect(templateBody(template, { globals: [] })).toMatchInlineSnapshot(`
"{
- const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(Foo)({ bar: __glintDSL__.resolve(helper)({ param: true , ...__glintDSL__.NamedArgsMarker }), ...__glintDSL__.NamedArgsMarker }));
+ const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(Foo)({
+ bar: __glintDSL__.resolve(helper)({ param: true , ...__glintDSL__.NamedArgsMarker }), ...__glintDSL__.NamedArgsMarker }));
__glintY__;
}"
`);
@@ -815,7 +700,8 @@ describe('Transform: rewriteTemplate', () => {
expect(templateBody(template, { globals: [] })).toMatchInlineSnapshot(`
"{
- const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(Greet)({ message: __glintRef__.args.arg, ...__glintDSL__.NamedArgsMarker }));
+ const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(Greet)({
+ message: __glintRef__.args.arg, ...__glintDSL__.NamedArgsMarker }));
__glintY__;
}"
`);
@@ -826,7 +712,8 @@ describe('Transform: rewriteTemplate', () => {
expect(templateBody(template, { globals: ['foo'] })).toMatchInlineSnapshot(`
"{
- const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(Greet)({ message: __glintDSL__.resolveOrReturn(__glintDSL__.Globals["foo"])(), ...__glintDSL__.NamedArgsMarker }));
+ const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(Greet)({
+ message: __glintDSL__.resolveOrReturn(__glintDSL__.Globals["foo"])(), ...__glintDSL__.NamedArgsMarker }));
__glintY__;
}"
`);
@@ -837,7 +724,8 @@ describe('Transform: rewriteTemplate', () => {
expect(templateBody(template, { globals: [] })).toMatchInlineSnapshot(`
"{
- const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(Greet)({ message: foo, ...__glintDSL__.NamedArgsMarker }));
+ const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(Greet)({
+ message: foo, ...__glintDSL__.NamedArgsMarker }));
__glintY__;
}"
`);
@@ -852,7 +740,8 @@ describe('Transform: rewriteTemplate', () => {
{
const [bar] = __glintY__.blockParams["default"];
{
- const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(Greet)({ message: bar, ...__glintDSL__.NamedArgsMarker }));
+ const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(Greet)({
+ message: bar, ...__glintDSL__.NamedArgsMarker }));
__glintY__;
}
}
@@ -1095,7 +984,8 @@ describe('Transform: rewriteTemplate', () => {
expect(templateBody(template)).toMatchInlineSnapshot(`
"{
- const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(__glintDSL__.Globals["Foo"])({ bar: "hello", ...__glintDSL__.NamedArgsMarker }));
+ const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(__glintDSL__.Globals["Foo"])({
+ bar: "hello", ...__glintDSL__.NamedArgsMarker }));
__glintY__;
}"
`);
@@ -1116,7 +1006,6 @@ describe('Transform: rewriteTemplate', () => {
const [bar] = __glintY__.blockParams["default"];
__glintDSL__.emitContent(__glintDSL__.resolveOrReturn(bar)());
}
- __glintDSL__.Globals["Foo"];
}"
`);
});
@@ -1146,7 +1035,6 @@ describe('Transform: rewriteTemplate', () => {
{
const [] = __glintY__.blockParams["default"];
}
- div;
}
}
__glintDSL__.Globals["let"];
@@ -1159,7 +1047,8 @@ describe('Transform: rewriteTemplate', () => {
expect(templateBody(template, { globals: [] })).toMatchInlineSnapshot(`
"{
- const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(foo?.bar)({ arg: "hello", ...__glintDSL__.NamedArgsMarker }));
+ const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(foo?.bar)({
+ arg: "hello", ...__glintDSL__.NamedArgsMarker }));
__glintY__;
}"
`);
@@ -1170,7 +1059,8 @@ describe('Transform: rewriteTemplate', () => {
expect(templateBody(template)).toMatchInlineSnapshot(`
"{
- const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(__glintRef__.args.foo)({ arg: "hello", ...__glintDSL__.NamedArgsMarker }));
+ const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(__glintRef__.args.foo)({
+ arg: "hello", ...__glintDSL__.NamedArgsMarker }));
__glintY__;
}"
`);
@@ -1181,7 +1071,8 @@ describe('Transform: rewriteTemplate', () => {
expect(templateBody(template)).toMatchInlineSnapshot(`
"{
- const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(__glintRef__.this.foo)({ arg: "hello", ...__glintDSL__.NamedArgsMarker }));
+ const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(__glintRef__.this.foo)({
+ arg: "hello", ...__glintDSL__.NamedArgsMarker }));
__glintY__;
}"
`);
@@ -1216,10 +1107,8 @@ describe('Transform: rewriteTemplate', () => {
{
const [] = __glintY__.blockParams["default"];
}
- b?.contents;
}
}
- __glintDSL__.Globals["Foo"];
}"
`);
});
@@ -1229,7 +1118,8 @@ describe('Transform: rewriteTemplate', () => {
expect(templateBody(template, { globals: [] })).toMatchInlineSnapshot(`
"{
- const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(Foo)({ arg: \`\${__glintDSL__.resolveOrReturn(baz)()}\`, ...__glintDSL__.NamedArgsMarker }));
+ const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(Foo)({
+ arg: \`\${__glintDSL__.resolveOrReturn(baz)()}\`, ...__glintDSL__.NamedArgsMarker }));
__glintY__;
}"
`);
@@ -1255,7 +1145,6 @@ describe('Transform: rewriteTemplate', () => {
});
}
}
- __glintDSL__.Globals["Foo"];
}"
`);
});
@@ -1275,7 +1164,6 @@ describe('Transform: rewriteTemplate', () => {
const [__switch] = __glintY__.blockParams["default"];
__glintDSL__.emitContent(__glintDSL__.resolveOrReturn(__switch)());
}
- __glintDSL__.Globals["Foo"];
}"
`);
});
diff --git a/packages/core/src/transform/template/code-features.ts b/packages/core/src/transform/template/code-features.ts
new file mode 100644
index 000000000..a54f1b1be
--- /dev/null
+++ b/packages/core/src/transform/template/code-features.ts
@@ -0,0 +1,82 @@
+import { CodeInformation } from '@volar/language-server/node.js';
+
+/**
+ * Based on https://github.com/machty/vue-language-tools/blob/deff856aa8c15f691544e646519215a15aacbef1/packages/language-core/lib/codegen/codeFeatures.ts
+ *
+ * This file exports a "code features" object which provides a useful shorthand/shortcut
+ * for specifying a nubmer of Volar CodeInformation configuration options in the code
+ * we use for mapping between source code (e.g. .gts files) and transformed (i.e. type-checkable TS) code.
+ *
+ * Volar uses the CodeInformation we pass along with each mapping to determine what sort of language
+ * features should be activated/processed for a given span of code.
+ */
+const raw = {
+ all: {
+ verification: true,
+ completion: true,
+ semantic: true,
+ navigation: true,
+ },
+ none: {},
+ verification: {
+ verification: true,
+ },
+ completion: {
+ completion: true,
+ },
+ additionalCompletion: {
+ completion: { isAdditional: true },
+ },
+ withoutCompletion: {
+ verification: true,
+ semantic: true,
+ navigation: true,
+ },
+ navigation: {
+ navigation: true,
+ },
+ navigationWithoutRename: {
+ navigation: { shouldRename: () => false },
+ },
+ navigationAndCompletion: {
+ navigation: true,
+ completion: true,
+ },
+ navigationAndAdditionalCompletion: {
+ navigation: true,
+ completion: { isAdditional: true },
+ },
+ navigationAndVerification: {
+ navigation: true,
+ verification: true,
+ },
+ withoutNavigation: {
+ verification: true,
+ completion: true,
+ semantic: true,
+ },
+ withoutHighlight: {
+ semantic: { shouldHighlight: () => false },
+ verification: true,
+ navigation: true,
+ completion: true,
+ },
+ withoutHighlightAndNavigation: {
+ semantic: { shouldHighlight: () => false },
+ verification: true,
+ completion: true,
+ },
+ withoutHighlightAndCompletion: {
+ semantic: { shouldHighlight: () => false },
+ verification: true,
+ navigation: true,
+ },
+ withoutHighlightAndCompletionAndNavigation: {
+ semantic: { shouldHighlight: () => false },
+ verification: true,
+ },
+} satisfies Record;
+
+export const codeFeatures = raw as {
+ [K in keyof typeof raw]: CodeInformation;
+};
diff --git a/packages/core/src/transform/template/glimmer-ast-mapping-tree.ts b/packages/core/src/transform/template/glimmer-ast-mapping-tree.ts
index ba3f0f025..b6f46d8ec 100644
--- a/packages/core/src/transform/template/glimmer-ast-mapping-tree.ts
+++ b/packages/core/src/transform/template/glimmer-ast-mapping-tree.ts
@@ -1,6 +1,7 @@
import { AST } from '@glimmer/syntax';
import { Range } from './transformed-module.js';
import { Identifier } from './map-template-contents.js';
+import { CodeInformation } from '@volar/language-server/node.js';
export type MappingSource = AST.Node | TemplateEmbedding | TextContent | Identifier | ParseError;
@@ -56,6 +57,7 @@ export default class GlimmerASTMappingTree {
public originalRange: Range,
public children: Array = [],
public sourceNode: MappingSource,
+ public codeInformation?: CodeInformation,
) {
children.forEach((child) => (child.parent = this));
}
diff --git a/packages/core/src/transform/template/inlining/companion-file.ts b/packages/core/src/transform/template/inlining/companion-file.ts
index 3ea14bcef..1d72fe284 100644
--- a/packages/core/src/transform/template/inlining/companion-file.ts
+++ b/packages/core/src/transform/template/inlining/companion-file.ts
@@ -90,15 +90,6 @@ export function calculateCompanionTemplateSpans(
);
if (transformedTemplate.result) {
- directives.push(
- ...transformedTemplate.result.directives.map(({ kind, location, areaOfEffect }) => ({
- kind,
- location,
- areaOfEffect,
- source: template,
- })),
- );
-
partialSpans.push(
{
originalFile: template,
diff --git a/packages/core/src/transform/template/inlining/tagged-strings.ts b/packages/core/src/transform/template/inlining/tagged-strings.ts
index f4ead45c2..5f3353596 100644
--- a/packages/core/src/transform/template/inlining/tagged-strings.ts
+++ b/packages/core/src/transform/template/inlining/tagged-strings.ts
@@ -91,15 +91,6 @@ export function calculateTaggedTemplateSpans(
}
if (transformedTemplate.result) {
- for (let { kind, location, areaOfEffect } of transformedTemplate.result.directives) {
- directives.push({
- kind: kind,
- source: script,
- location: addOffset(location, templateLocation.start),
- areaOfEffect: addOffset(areaOfEffect, templateLocation.start),
- });
- }
-
partialSpans.push({
originalFile: script,
originalStart: templateLocation.start,
diff --git a/packages/core/src/transform/template/map-template-contents.ts b/packages/core/src/transform/template/map-template-contents.ts
index 546a6703f..76abd35ea 100644
--- a/packages/core/src/transform/template/map-template-contents.ts
+++ b/packages/core/src/transform/template/map-template-contents.ts
@@ -5,6 +5,8 @@ import GlimmerASTMappingTree, {
} from './glimmer-ast-mapping-tree.js';
import { Directive, DirectiveKind, Range } from './transformed-module.js';
import { assert } from '../util.js';
+import { CodeInformation } from '@volar/language-server/node.js';
+import { codeFeatures } from './code-features.js';
/**
* @glimmer/syntax parses identifiers as strings. Aside from meaning
@@ -28,62 +30,65 @@ export type Mapper = {
rangeForNode: (node: AST.Node) => Range;
/**
- * Given a 0-based line number, returns the corresponding start and
- * end offsets for that line.
+ * Captures the existence of a directive specified by the given source
+ * node and affecting the given range of text.
*/
- rangeForLine: (line: number) => Range;
-
- record: {
- /**
- * Captures the existence of a directive specified by the given source
- * node and affecting the given range of text.
- */
- directive: (type: DirectiveKind, location: Range, areaOfEffect: Range) => void;
-
- /**
- * Records an error at the given location.
- */
- error: (message: string, location: Range) => void;
- };
+ directive: (
+ commentNode: AST.CommentStatement | AST.MustacheCommentStatement,
+ type: DirectiveKind,
+ ) => void;
- emit: {
- /** Emit a newline in the transformed source */
- newline(): void;
-
- /** Increase the indent level for future emitted content */
- indent(): void;
-
- /** Decrease the indent level for future emitted content */
- dedent(): void;
-
- /** Append the given raw text to the transformed source */
- text(value: string): void;
-
- /**
- * Append the given raw text to the transformed source, creating
- * a 0-length mapping for it in the output.
- */
- synthetic(value: string): void;
-
- /**
- * Essentially the inverse of `emit.synthetic`, this notes the
- * presence of a template AST node at a given location while not
- * emitting anything in the resulting TS translation.
- */
- nothing(node: AST.Node, source?: MappingSource): void;
-
- /**
- * Append the given value to the transformed source, mapping
- * that span back to the given offset in the original source.
- */
- identifier(value: string, hbsOffset: number, hbsLength?: number): void;
-
- /**
- * Map all content emitted in the given callback to the span
- * corresponding to the given AST node in the original source.
- */
- forNode(node: AST.Node, callback: () => void): void;
- };
+ // directiveTerminatingExpression: (location: Range) => void;
+
+ /**
+ * Records an error at the given location.
+ */
+ error: (message: string, location: Range) => void;
+
+ /** Emit a newline in the transformed source */
+ newline(): void;
+
+ /** Increase the indent level for future emitted content */
+ indent(): void;
+
+ /** Decrease the indent level for future emitted content */
+ dedent(): void;
+
+ /** Append the given raw text to the transformed source */
+ text(value: string): void;
+
+ /**
+ * Append the given raw text to the transformed source, creating
+ * a 0-length mapping for it in the output.
+ */
+ synthetic(value: string): void;
+
+ /**
+ * Essentially the inverse of `emit.synthetic`, this notes the
+ * presence of a template AST node at a given location while not
+ * emitting anything in the resulting TS translation.
+ */
+ nothing(node: AST.Node, source?: MappingSource): void;
+
+ /**
+ * Append the given value to the transformed source, mapping
+ * that span back to the given offset in the original source.
+ */
+ identifier(value: string, hbsOffset: number, hbsLength?: number): void;
+
+ /**
+ * Map all content emitted in the given callback to the span
+ * corresponding to the given AST node in the original source.
+ */
+ forNode(node: AST.Node, callback: () => void, codeFeaturesForNode?: CodeInformation): void;
+
+ /**
+ * This needs to be called after any node that "consumes" a `glint-expect-error` directive.
+ * This essentially marks the end of the area of effect for the directive; this helps us
+ * filter out the "unused ts-expect-error" placeholder diagnostic if, in fact, an error
+ * diagnostic was reported within the directive's area of effect.
+ */
+ terminateDirectiveAreaOfEffect(endStr: string): void;
};
type LocalDirective = Omit;
@@ -159,19 +164,28 @@ export function mapTemplateContents(
errors.push({ message, location });
}
- let rangeForNode = buildRangeForNode(lineOffsets);
- let rangeForLine = (line: number): Range => ({
- start: lineOffsets[line],
- end: lineOffsets[line + 1] ?? template.length,
- });
-
let segmentsStack: string[][] = [[]];
let mappingsStack: GlimmerASTMappingTree[][] = [[]];
let indent = '';
let offset = 0;
- let needsIndent = false;
let directives: Array = [];
+ const codeFeaturesProxy = new Proxy(codeFeatures, {
+ get(target, key: keyof typeof codeFeatures) {
+ const data = target[key];
+ return resolveCodeFeatures(data);
+ },
+ });
+
+ let ignoreErrors = false;
+ let isNoCheckDirectivePresent = false;
+ let expectErrorToken:
+ | {
+ numErrors: number;
+ commentNode: AST.CommentStatement | AST.MustacheCommentStatement;
+ }
+ | undefined;
+
// Associates all content emitted during the given callback with the
// given range in the template source and corresponding AST node.
// If an exception is thrown while executing the callback, the error
@@ -182,6 +196,7 @@ export function mapTemplateContents(
source: MappingSource,
allowEmpty: boolean,
callback: () => void,
+ codeFeaturesForNode?: CodeInformation,
): void => {
let start = offset;
let mappings: GlimmerASTMappingTree[] = [];
@@ -205,21 +220,66 @@ export function mapTemplateContents(
let end = offset;
let tsRange = { start, end };
- mappingsStack[0].push(new GlimmerASTMappingTree(tsRange, hbsRange, mappings, source));
+ mappingsStack[0].push(
+ new GlimmerASTMappingTree(
+ tsRange,
+ hbsRange,
+ mappings,
+ source,
+ codeFeaturesForNode ?? codeFeaturesProxy.all,
+ ),
+ );
segmentsStack[0].push(...segments);
}
};
- let record = {
- error(message: string, location: Range) {
- errors.push({ message, location });
- },
- directive(kind: DirectiveKind, location: Range, areaOfEffect: Range) {
- directives.push({ kind, location, areaOfEffect });
- },
- };
+ /**
+ * This function is used by the codeFeaturesProxy about to conditionally enhance/augment
+ * the `CodeInformation` object that we pass along with each mapping.
+ *
+ * In particular we use it in our implementation of `glint-expect-error` directives, wherein,
+ * depending on whether an error diagnostic was reported by TS in a span of code, we need to
+ * conditionally filter out the "unused ts-expect-error" placeholder diagnostic that we emit.
+ */
+ function resolveCodeFeatures(features: CodeInformation): CodeInformation {
+ if (features.verification) {
+ // If this code span requests verification (e.g. TS type-checking), then
+ // we potentially need to decorate the `verification` value that we pass
+ // back to Volar, in case we have active `glint-ignore/expect-error` directives
+ // in active effect.
+
+ if (ignoreErrors) {
+ // We are currently in a region of code covered by a @glint-ignore directive, so don't
+ // even bother performing any type-checking: override verification (i.e. type-checking) to false
+ // for this mapping (note that the whole generated TS file will be type-checked but any
+ // diagnostics in this region will be suppressed by Volar)
+ return {
+ ...features,
+ verification: false,
+ };
+ }
+
+ if (expectErrorToken) {
+ // We are currently in a region of code covered by a @glint-expect-error directive. We need to
+ // keep track of the number of errors encountered within this region so that we can know whether
+ // we will need to propagate an "unused ts-expect-error" diagnostic back to the original
+ // .gts file or not.
+ const token = expectErrorToken;
+ return {
+ ...features,
+ verification: {
+ shouldReport: () => {
+ token.numErrors++;
+ return false;
+ },
+ },
+ };
+ }
+ }
+ return features;
+ }
- let emit = {
+ let mapper: Mapper = {
indent() {
indent += ' ';
},
@@ -229,51 +289,148 @@ export function mapTemplateContents(
newline() {
offset += 1;
segmentsStack[0].push('\n');
-
- // This was disabled in Volar-ized glint, as it messes up an otherwise usable
- // TS diagnostic boundary. It appears to be cosmetic only, and hidden away in the
- // Intermediate Representation, so I've commented it ou.
- // needsIndent = true;
},
text(value: string) {
- if (needsIndent) {
- offset += indent.length;
- segmentsStack[0].push(indent);
- needsIndent = false;
- }
-
offset += value.length;
segmentsStack[0].push(value);
},
synthetic(value: string) {
if (value.length) {
- emit.identifier(value, 0, 0);
+ mapper.identifier(value, 0, 0);
}
},
nothing(node: AST.Node, source: MappingSource = node) {
- captureMapping(rangeForNode(node), source, true, () => {});
+ captureMapping(mapper.rangeForNode(node), source, true, () => {});
},
identifier(value: string, hbsOffset: number, hbsLength = value.length) {
- // If there's a pending indent, flush that so it's not included in
- // the range mapping for the identifier we're about to emit
- if (needsIndent) {
- emit.text('');
- }
-
let hbsRange = { start: hbsOffset, end: hbsOffset + hbsLength };
let source = new Identifier(value);
- captureMapping(hbsRange, source, true, () => emit.text(value));
+ captureMapping(hbsRange, source, true, () => mapper.text(value));
},
- forNode(node: AST.Node, callback: () => void) {
- captureMapping(rangeForNode(node), node, false, callback);
+ forNode(node: AST.Node, callback: () => void, codeFeaturesForNode?: CodeInformation) {
+ captureMapping(mapper.rangeForNode(node), node, false, callback, codeFeaturesForNode);
},
+
+ error(message: string, location: Range) {
+ errors.push({ message, location });
+ },
+
+ directive(
+ commentNode: AST.CommentStatement | AST.MustacheCommentStatement,
+ kind: DirectiveKind,
+ ) {
+ if (kind === 'expect-error') {
+ if (!expectErrorToken) {
+ mapper.text(`// @glint-expect-error BEGIN AREA_OF_EFFECT`);
+ mapper.newline();
+ // } else {
+ // let a = 'wat';
+ }
+
+ expectErrorToken = {
+ numErrors: 0,
+ commentNode,
+ };
+ }
+
+ if (kind === 'ignore') {
+ ignoreErrors = true;
+ mapper.text(`// @glint-ignore BEGIN AREA_OF_EFFECT`);
+ mapper.newline();
+ }
+
+ if (kind === 'nocheck') {
+ ignoreErrors = true;
+ isNoCheckDirectivePresent = true;
+ mapper.text(`// @glint-nocheck`);
+ mapper.newline();
+ }
+
+ directives.push({ kind });
+ },
+
+ terminateDirectiveAreaOfEffect(endStr: string) {
+ if (expectErrorToken) {
+ // There is an active "@glint-expect-error" directive whose
+ // are of effect we need to terminate.
+ //
+ // There is a somewhat delicate order in which everything below needs to happen,
+ // but here is an outline:
+ //
+ // 1. Volar will call the `shouldReport` function of the `verification` object
+ // of the `CodeInformation` object that we pass along with each mapping for
+ // every diagnostic reported by TS within the transformed region of code.
+ //
+ // 2. This callback's main job is to return a boolean indicating whether we
+ // should propagate TS diagnostics within the transformed region of code
+ // back to search (e.g. the original .gts file). But in addition to that we are somewhat
+ // hackishly using `shouldReport` to track the number of errors encountered
+ // within the directive's area of effect so that we can later determine
+ // whether to filter out the "unused ts-expect-error" placeholder diagnostic
+ // that we emit below.
+ //
+ // 3. The first `shouldReport` that gets called by Volar is in `resolveCodeFeatures`;
+ // this implementation of `shouldReport` increments `numErrors` for each diagnostic
+ // found in the region.
+ //
+ // 4. The second `shouldReport` that gets called is below: we emit a
+ // `// @ts-expect-error GLINT_PLACEHOLDER` diagnostic that is always triggering
+ // within the transformed code, and we use `shouldReport` to decide whether
+ // to filter out that diagnostic or not.
+ //
+ // This approach was taken from Vue tooling; it is complicated but it solves the problem
+ // of keeping the code transform static while keeping all of the dynamic/stateful
+ // error tracking and filtering logic in `shouldReport` callbacks.
+ const token = expectErrorToken;
+
+ mapper.newline();
+
+ // 1. Emit a ts-expect-error this is guaranteed to trigger within the generated TS code
+ // because we immediately follow it up with an empty semi-colon statement.
+ // 2. Map it back to the original `{{ @glint-expect-error }}` comment node in the source.
+ mapper.forNode(
+ token.commentNode,
+ () => {
+ mapper.text(`// @ts-expect-error GLINT_PLACEHOLDER`);
+ },
+ {
+ verification: {
+ // If no diagnostic errors were encountered within the area of effect,
+ // then filter out the "unused ts-expect-error" diagnostic triggered by our
+ // placeholder @ts-expect-error
+ shouldReport: () => token.numErrors === 0,
+ },
+ },
+ );
+
+ // Make the above placeholder diagnostic trigger an "unused ts-expect-error" diagnostic
+ // by introducing an error-less empty semi-colon statement.
+ mapper.newline();
+ mapper.text(';');
+ mapper.newline();
+
+ expectErrorToken = undefined;
+
+ mapper.text(`// @glint-expect-error END AREA_OF_EFFECT for ${endStr}`);
+ mapper.newline();
+ }
+
+ if (ignoreErrors && !isNoCheckDirectivePresent) {
+ ignoreErrors = false;
+ mapper.text(`// @glint-ignore END AREA_OF_EFFECT for ${endStr}`);
+ mapper.newline();
+ }
+ },
+
+ rangeForNode: buildRangeForNode(lineOffsets),
};
- callback(ast, { emit, record, rangeForLine, rangeForNode });
+ callback(ast, mapper);
assert(segmentsStack.length === 1);
let code = segmentsStack[0].join('');
+
let mapping = new GlimmerASTMappingTree(
{ start: 0, end: code.length },
{
@@ -282,6 +439,7 @@ export function mapTemplateContents(
},
mappingsStack[0],
new TemplateEmbedding(),
+ codeFeaturesProxy.all,
);
return { errors, result: { code, directives, mapping } };
diff --git a/packages/core/src/transform/template/template-to-typescript.ts b/packages/core/src/transform/template/template-to-typescript.ts
index 214c0ab65..64409e88c 100644
--- a/packages/core/src/transform/template/template-to-typescript.ts
+++ b/packages/core/src/transform/template/template-to-typescript.ts
@@ -4,6 +4,8 @@ import { EmbeddingSyntax, mapTemplateContents, RewriteResult } from './map-templ
import ScopeStack from './scope-stack.js';
import { GlintEmitMetadata, GlintSpecialForm } from '@glint/core/config-types';
import { TextContent } from './glimmer-ast-mapping-tree.js';
+import { Directive } from './transformed-module.js';
+import { DirectiveKind } from './transformed-module.js';
const SPLATTRIBUTES = '...attributes';
@@ -40,7 +42,7 @@ export function templateToTypescript(
let template = `${''.padEnd(prefix.length)}${originalTemplate}${''.padEnd(suffix.length)}`;
return mapTemplateContents(originalTemplate, { embeddingSyntax }, (ast, mapper) => {
- let { emit, record, rangeForLine, rangeForNode } = mapper;
+ let { rangeForNode } = mapper;
let scope = new ScopeStack([]);
emitTemplateBoilerplate(() => {
@@ -49,6 +51,10 @@ export function templateToTypescript(
}
});
+ // Ensure any "@glint-expect-error" directives at the end of the template
+ // trigger "unused @glint-expect-error" diagnostics.
+ mapper.terminateDirectiveAreaOfEffect('endOfTemplate');
+
return;
function emitTopLevelStatement(node: AST.TopLevelStatement): void {
@@ -80,49 +86,49 @@ export function templateToTypescript(
function emitTemplateBoilerplate(emitBody: () => void): void {
if (meta?.prepend) {
- emit.text(meta.prepend);
+ mapper.text(meta.prepend);
}
if (useJsDoc) {
- emit.text(`(/** @type {typeof import("${typesModule}")} */ ({}))`);
+ mapper.text(`(/** @type {typeof import("${typesModule}")} */ ({}))`);
} else {
- emit.text(`({} as typeof import("${typesModule}"))`);
+ mapper.text(`({} as typeof import("${typesModule}"))`);
}
if (backingValue) {
- emit.text(`.templateForBackingValue(${backingValue}, function(__glintRef__`);
+ mapper.text(`.templateForBackingValue(${backingValue}, function(__glintRef__`);
} else {
- emit.text(`.templateExpression(function(__glintRef__`);
+ mapper.text(`.templateExpression(function(__glintRef__`);
}
if (useJsDoc) {
- emit.text(`, /** @type {typeof import("${typesModule}")} */ __glintDSL__) {`);
+ mapper.text(`, /** @type {typeof import("${typesModule}")} */ __glintDSL__) {`);
} else {
- emit.text(`, __glintDSL__: typeof import("${typesModule}")) {`);
+ mapper.text(`, __glintDSL__: typeof import("${typesModule}")) {`);
}
- emit.newline();
- emit.indent();
+ mapper.newline();
+ mapper.indent();
for (let line of preamble) {
- emit.text(line);
- emit.newline();
+ mapper.text(line);
+ mapper.newline();
}
if (ast) {
- emit.forNode(ast, emitBody);
+ mapper.forNode(ast, emitBody);
}
// Ensure the context and lib variables are always consumed to prevent
// an unused variable warning
- emit.text('__glintRef__; __glintDSL__;');
- emit.newline();
+ mapper.text('__glintRef__; __glintDSL__;');
+ mapper.newline();
- emit.dedent();
- emit.text('})');
+ mapper.dedent();
+ mapper.text('})');
if (meta?.append) {
- emit.text(meta.append);
+ mapper.text(meta.append);
}
}
@@ -130,30 +136,44 @@ export function templateToTypescript(
// We don't need to emit any code for text nodes, but we want to track
// where they are so we know NOT to try and suggest global completions
// in "text space" where it wouldn't make sense.
- emit.nothing(node, new TextContent());
+ mapper.nothing(node, new TextContent());
}
function emitComment(node: AST.MustacheCommentStatement | AST.CommentStatement): void {
let text = node.value.trim();
- let match = /^@glint-([a-z-]+)/i.exec(text);
+ const directiveRegex = /^@glint-([a-z-]+)/i;
+ let match = directiveRegex.exec(text);
if (!match) {
- return emit.nothing(node);
+ return mapper.nothing(node);
}
+ // here
+ emitDirective(match, node);
+ }
+
+ function emitDirective(
+ match: RegExpExecArray,
+ node: AST.CommentStatement | AST.MustacheCommentStatement,
+ ): void {
let kind = match[1];
let location = rangeForNode(node);
if (kind === 'ignore' || kind === 'expect-error') {
- record.directive(kind, location, rangeForLine(node.loc.endPosition.line + 1));
+ // Push to the directives array on the record
+ mapper.directive(node, kind);
} else if (kind === 'nocheck') {
- record.directive('ignore', location, { start: 0, end: template.length - 1 });
+ // Push to the directives array on the record
+ mapper.directive(node, 'nocheck');
} else {
- record.error(`Unknown directive @glint-${kind}`, location);
+ // Push an error on the record
+ mapper.error(`Unknown directive @glint-${kind}`, location);
}
+ }
- emit.forNode(node, () => {
- emit.text(`// @glint-${kind}`);
- emit.newline();
- });
+ function emitTopLevelMustacheStatement(node: AST.MustacheStatement): void {
+ emitMustacheStatement(node, 'top-level');
+ mapper.text(';');
+ mapper.newline();
+ mapper.terminateDirectiveAreaOfEffect('topLevelMustacheStatement');
}
// Captures the context in which a given invocation (i.e. a mustache or
@@ -162,21 +182,15 @@ export function templateToTypescript(
// evaluates a helper or returns it also depends on the location it's in.
type InvokePosition = 'top-level' | 'attr' | 'arg' | 'concat' | 'sexpr';
- function emitTopLevelMustacheStatement(node: AST.MustacheStatement): void {
- emitMustacheStatement(node, 'top-level');
- emit.text(';');
- emit.newline();
- }
-
function emitSpecialFormExpression(
formInfo: SpecialFormInfo,
node: AST.MustacheStatement | AST.SubExpression,
position: InvokePosition,
): void {
if (formInfo.requiresConsumption) {
- emit.text('(__glintDSL__.noop(');
+ mapper.text('(__glintDSL__.noop(');
emitExpression(node.path);
- emit.text('), ');
+ mapper.text('), ');
}
switch (formInfo.form) {
@@ -219,12 +233,12 @@ export function templateToTypescript(
break;
default:
- record.error(`${formInfo.name} is not valid in inline form`, rangeForNode(node));
- emit.text('undefined');
+ mapper.error(`${formInfo.name} is not valid in inline form`, rangeForNode(node));
+ mapper.text('undefined');
}
if (formInfo.requiresConsumption) {
- emit.text(')');
+ mapper.text(')');
}
}
@@ -233,7 +247,7 @@ export function templateToTypescript(
node: AST.MustacheStatement | AST.SubExpression,
position: InvokePosition,
): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
assert(
node.params.length >= 1,
() => `{{${formInfo.name}}} requires at least one positional argument`,
@@ -249,7 +263,7 @@ export function templateToTypescript(
);
if (position === 'top-level') {
- emit.text('__glintDSL__.emitContent(');
+ mapper.text('__glintDSL__.emitContent(');
}
// Treat the first argument to a bind-invokable expression (`{{component}}`,
@@ -260,16 +274,16 @@ export function templateToTypescript(
// invokable is the source of record for its own type and we don't want inference
// from the `resolveForBind` call to be affected by other (potentially incorrect)
// parameter types.
- emit.text('__glintDSL__.resolve(');
+ mapper.text('__glintDSL__.resolve(');
emitExpression(node.path);
- emit.text(')((() => __glintDSL__.resolveForBind(');
+ mapper.text(')((() => __glintDSL__.resolveForBind(');
emitExpression(node.params[0]);
- emit.text('))(), ');
+ mapper.text('))(), ');
emitArgs(node.params.slice(1), node.hash);
- emit.text(')');
+ mapper.text(')');
if (position === 'top-level') {
- emit.text(')');
+ mapper.text(')');
}
});
}
@@ -278,33 +292,33 @@ export function templateToTypescript(
formInfo: SpecialFormInfo,
node: AST.MustacheStatement | AST.SubExpression,
): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
assert(
node.params.length === 0,
() => `{{${formInfo.name}}} only accepts named parameters`,
);
if (!node.hash.pairs.length) {
- emit.text('{}');
+ mapper.text('{}');
return;
}
- emit.text('({');
- emit.indent();
- emit.newline();
+ mapper.text('({');
+ mapper.indent();
+ mapper.newline();
let start = template.indexOf('hash', rangeForNode(node).start) + 4;
for (let pair of node.hash.pairs) {
start = template.indexOf(pair.key, start);
emitHashKey(pair.key, start);
- emit.text(': ');
+ mapper.text(': ');
emitExpression(pair.value);
- emit.text(',');
- emit.newline();
+ mapper.text(',');
+ mapper.newline();
}
- emit.dedent();
- emit.text('})');
+ mapper.dedent();
+ mapper.text('})');
});
}
@@ -312,23 +326,23 @@ export function templateToTypescript(
formInfo: SpecialFormInfo,
node: AST.MustacheStatement | AST.SubExpression,
): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
assert(
node.hash.pairs.length === 0,
() => `{{${formInfo.name}}} only accepts positional parameters`,
);
- emit.text('[');
+ mapper.text('[');
for (let [index, param] of node.params.entries()) {
emitExpression(param);
if (index < node.params.length - 1) {
- emit.text(', ');
+ mapper.text(', ');
}
}
- emit.text(']');
+ mapper.text(']');
});
}
@@ -336,25 +350,25 @@ export function templateToTypescript(
formInfo: SpecialFormInfo,
node: AST.MustacheStatement | AST.SubExpression,
): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
assert(
node.params.length >= 2,
() => `{{${formInfo.name}}} requires at least two parameters`,
);
- emit.text('(');
+ mapper.text('(');
emitExpression(node.params[0]);
- emit.text(') ? (');
+ mapper.text(') ? (');
emitExpression(node.params[1]);
- emit.text(') : (');
+ mapper.text(') : (');
if (node.params[2]) {
emitExpression(node.params[2]);
} else {
- emit.text('undefined');
+ mapper.text('undefined');
}
- emit.text(')');
+ mapper.text(')');
});
}
@@ -362,25 +376,25 @@ export function templateToTypescript(
formInfo: SpecialFormInfo,
node: AST.MustacheStatement | AST.SubExpression,
): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
assert(
node.params.length >= 2,
() => `{{${formInfo.name}}} requires at least two parameters`,
);
- emit.text('!(');
+ mapper.text('!(');
emitExpression(node.params[0]);
- emit.text(') ? (');
+ mapper.text(') ? (');
emitExpression(node.params[1]);
- emit.text(') : (');
+ mapper.text(') : (');
if (node.params[2]) {
emitExpression(node.params[2]);
} else {
- emit.text('undefined');
+ mapper.text('undefined');
}
- emit.text(')');
+ mapper.text(')');
});
}
@@ -388,7 +402,7 @@ export function templateToTypescript(
formInfo: SpecialFormInfo,
node: AST.MustacheStatement | AST.SubExpression,
): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
assert(
node.hash.pairs.length === 0,
() => `{{${formInfo.name}}} only accepts positional parameters`,
@@ -400,11 +414,11 @@ export function templateToTypescript(
const [left, right] = node.params;
- emit.text('(');
+ mapper.text('(');
emitExpression(left);
- emit.text(` ${formInfo.form} `);
+ mapper.text(` ${formInfo.form} `);
emitExpression(right);
- emit.text(')');
+ mapper.text(')');
});
}
@@ -412,7 +426,7 @@ export function templateToTypescript(
formInfo: SpecialFormInfo,
node: AST.MustacheStatement | AST.SubExpression,
): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
assert(
node.hash.pairs.length === 0,
() => `{{${formInfo.name}}} only accepts positional parameters`,
@@ -422,15 +436,15 @@ export function templateToTypescript(
() => `{{${formInfo.name}}} requires at least two parameters`,
);
- emit.text('(');
+ mapper.text('(');
for (const [index, param] of node.params.entries()) {
emitExpression(param);
if (index < node.params.length - 1) {
- emit.text(` ${formInfo.form} `);
+ mapper.text(` ${formInfo.form} `);
}
}
- emit.text(')');
+ mapper.text(')');
});
}
@@ -438,7 +452,7 @@ export function templateToTypescript(
formInfo: SpecialFormInfo,
node: AST.MustacheStatement | AST.SubExpression,
): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
assert(
node.hash.pairs.length === 0,
() => `{{${formInfo.name}}} only accepts positional parameters`,
@@ -450,7 +464,7 @@ export function templateToTypescript(
const [param] = node.params;
- emit.text(formInfo.form);
+ mapper.text(formInfo.form);
emitExpression(param);
});
}
@@ -513,26 +527,26 @@ export function templateToTypescript(
}
function emitConcatStatement(node: AST.ConcatStatement): void {
- emit.forNode(node, () => {
- emit.text('`');
+ mapper.forNode(node, () => {
+ mapper.text('`');
for (let part of node.parts) {
if (part.type === 'MustacheStatement') {
- emit.text('$' + '{');
+ mapper.text('$' + '{');
emitMustacheStatement(part, 'concat');
- emit.text('}');
+ mapper.text('}');
}
}
- emit.text('`');
+ mapper.text('`');
});
}
function emitIdentifierReference(name: string, hbsOffset: number): void {
if (treatAsGlobal(name)) {
- emit.text('__glintDSL__.Globals["');
- emit.identifier(JSON.stringify(name).slice(1, -1), hbsOffset, name.length);
- emit.text('"]');
+ mapper.text('__glintDSL__.Globals["');
+ mapper.identifier(JSON.stringify(name).slice(1, -1), hbsOffset, name.length);
+ mapper.text('"]');
} else {
- emit.identifier(makeJSSafe(name), hbsOffset, name.length);
+ mapper.identifier(makeJSSafe(name), hbsOffset, name.length);
}
}
@@ -580,65 +594,164 @@ export function templateToTypescript(
}
}
+ /**
+ * Given an ElementNode, return an array of attributes, args, and modifiers
+ * in the order they appear in the element open tag, and filter out any
+ * comments, while also detecting any directives (e.g. `@glint-expect-error`)
+ * and assigning them to the next non-comment/non-directive piece.
+ *
+ * This is useful for implementing logic for supporting `@glint-expect-error` (and similar)
+ * directives that appear inline within the opening tag of an element, e.g.
+ *
+ *
+ */
+ function assignDirectivesToElementOpenTagPieces(
+ node: AST.ElementNode,
+ ): WeakMap {
+ let pieces = [...node.attributes, ...node.modifiers, ...node.comments].sort(
+ (a, b) => a.loc.getStart().offset! - b.loc.getStart().offset!,
+ );
+
+ let activeDirective: DirectiveKind | null = null;
+
+ const result: WeakMap = new WeakMap();
+
+ for (let piece of pieces) {
+ if (piece.type === 'MustacheCommentStatement') {
+ // this needs to categorize directives. But which while do we do that in? this file is template-to-typescript
+ // activeDirective = piece.value.includes('@glint-expect-error') ? 'expect-error' : null;
+
+ const directiveRegex = /^@glint-([a-z-]+)/i;
+ let text = piece.value.trim();
+ let match = directiveRegex.exec(text);
+ if (!match) {
+ // Just a comment, not a directive. Skip.
+ continue;
+ }
+
+ let directive = match[1];
+ switch (directive) {
+ case 'expect-error':
+ activeDirective = 'expect-error';
+ break;
+ case 'ignore':
+ activeDirective = 'ignore';
+ break;
+ default:
+ // TODO: should this be an error?
+ continue;
+ }
+ } else if (piece.type === 'ElementModifierStatement' || piece.type === 'AttrNode') {
+ if (activeDirective) {
+ // Assign the directive to this modifier.
+ result.set(piece, activeDirective);
+ activeDirective = null;
+ }
+ } else {
+ throw new Error('Unknown piece type');
+ }
+ }
+
+ return result;
+ }
+
function emitComponent(node: AST.ElementNode): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
let { start, path, kind } = tagNameToPathContents(node);
- for (let comment of node.comments) {
- emitComment(comment);
- }
+ const directivesWeakMap = assignDirectivesToElementOpenTagPieces(node);
- emit.text('{');
- emit.newline();
- emit.indent();
+ mapper.text('{');
+ mapper.newline();
+ mapper.indent();
- emit.text('const __glintY__ = __glintDSL__.emitComponent(__glintDSL__.resolve(');
- emitPathContents(path, start, kind);
- emit.text(')(');
+ // Resolve the component and stash into the `__glintY__` variable for later invocation.
+ mapper.text('const __glintY__ = __glintDSL__.emitComponent(');
+
+ // Error boundary: "Expected 1 arguments, but got 0." e.g. when invoking ``
+ mapper.forNode(node, () => {
+ mapper.text('__glintDSL__.resolve(');
+ emitPathContents(path, start, kind);
+ mapper.text(')');
+ });
+
+ // "Call" the component, optionally passing args if they are provided in the template.
+ mapper.text('(');
let dataAttrs = node.attributes.filter(({ name }) => name.startsWith('@'));
if (dataAttrs.length) {
- emit.text('{ ');
-
- for (let attr of dataAttrs) {
- emit.forNode(attr, () => {
- start = template.indexOf(attr.name, start + 1);
- emitHashKey(attr.name.slice(1), start + 1);
- emit.text(': ');
-
- switch (attr.value.type) {
- case 'TextNode':
- emit.text(JSON.stringify(attr.value.chars));
- break;
- case 'ConcatStatement':
- emitConcatStatement(attr.value);
- break;
- case 'MustacheStatement':
- emitMustacheStatement(attr.value, 'arg');
- break;
- default:
- unreachable(attr.value);
- }
- });
-
- start = rangeForNode(attr.value).end;
- emit.text(', ');
- }
+ // Error boundary: "Expected 0 arguments, but got 1." e.g. when invoking ``
+ mapper.forNode(node, () => {
+ mapper.text('{ ');
+
+ for (let attr of dataAttrs) {
+ mapper.forNode(attr, () => {
+ mapper.newline();
+
+ // If this attribute has an inline directive, emit the corresponding `@ts-...` directive.
+ const directive = directivesWeakMap.get(attr);
+ if (directive) {
+ mapper.text(`// @ts-${directive}`);
+ mapper.newline();
+ }
+
+ start = template.indexOf(attr.name, start + 1);
+ emitHashKey(attr.name.slice(1), start + 1);
+ mapper.text(': ');
+
+ switch (attr.value.type) {
+ case 'TextNode':
+ mapper.text(JSON.stringify(attr.value.chars));
+ break;
+ case 'ConcatStatement':
+ emitConcatStatement(attr.value);
+ break;
+ case 'MustacheStatement':
+ emitMustacheStatement(attr.value, 'arg');
+ break;
+ default:
+ unreachable(attr.value);
+ }
+ });
+
+ start = rangeForNode(attr.value).end;
+ mapper.text(', ');
+ }
- emit.text('...__glintDSL__.NamedArgsMarker }');
+ mapper.text('...__glintDSL__.NamedArgsMarker }');
+ });
}
- emit.text('));');
- emit.newline();
+ mapper.text('));');
+ mapper.newline();
+
+ emitAttributesAndModifiers(node, directivesWeakMap);
- emitAttributesAndModifiers(node);
+ // terminate @glint-expect-error directives after opening tag; any
+ // diagnostics due to attributes or modifiers are covered by the directive
+ mapper.terminateDirectiveAreaOfEffect('emitComponent - end of opening tag');
if (!node.selfClosing) {
let blocks = determineBlockChildren(node);
if (blocks.type === 'named') {
for (const child of blocks.children) {
if (child.type === 'CommentStatement' || child.type === 'MustacheCommentStatement') {
- emitComment(child);
+ /**
+ * TODO: figure out what needs to be reinstate here for glint-expect-error, e.g.
+ *
+ *
+ * {{!@glint-expect-error this component isn't typed to provide block params but definitely does }}
+ * <:footer as |footerArgs|>
+ * {{footerArgs.something}}
+ *
+ *
+ */
+ // emitComment(child);
continue;
}
@@ -647,7 +760,7 @@ export function templateToTypescript(
let blockParamsStart = template.indexOf('|', childStart);
let name = child.tag.slice(1);
- emit.forNode(child, () =>
+ mapper.forNode(child, () =>
emitBlockContents(
name,
nameStart,
@@ -667,17 +780,11 @@ export function templateToTypescript(
blocks.children,
);
}
-
- // Emit `ComponentName;` to represent the closing tag, so we have
- // an anchor for things like symbol renames.
- emitPathContents(path, template.lastIndexOf(node.tag, rangeForNode(node).end), kind);
- emit.text(';');
- emit.newline();
}
- emit.dedent();
- emit.text('}');
- emit.newline();
+ mapper.dedent();
+ mapper.text('}');
+ mapper.newline();
});
}
@@ -721,10 +828,10 @@ export function templateToTypescript(
type: 'named',
children: node.children.filter(
// Filter out ignorable content between named blocks
- (child): child is NamedBlockChild =>
- child.type === 'ElementNode' ||
- child.type === 'CommentStatement' ||
- child.type === 'MustacheCommentStatement',
+ (child): child is NamedBlockChild => child.type === 'ElementNode',
+ // ||
+ // child.type === 'CommentStatement' ||
+ // child.type === 'MustacheCommentStatement',
),
};
} else {
@@ -733,7 +840,7 @@ export function templateToTypescript(
// those nodes
for (let child of node.children) {
if (!isNamedBlock(child)) {
- emit.forNode(child, () =>
+ mapper.forNode(child, () =>
assert(
isAllowedAmongNamedBlocks(child),
'Named blocks may not be mixed with other content',
@@ -747,81 +854,94 @@ export function templateToTypescript(
}
function emitPlainElement(node: AST.ElementNode): void {
- emit.forNode(node, () => {
- for (let comment of node.comments) {
- emitComment(comment);
- }
+ mapper.forNode(node, () => {
+ const directivesWeakMap = assignDirectivesToElementOpenTagPieces(node);
+
+ mapper.text('{');
+ mapper.newline();
+ mapper.indent();
- emit.text('{');
- emit.newline();
- emit.indent();
+ mapper.text('const __glintY__ = __glintDSL__.emitElement(');
+ mapper.text(JSON.stringify(node.tag));
+ mapper.text(');');
+ mapper.newline();
- emit.text('const __glintY__ = __glintDSL__.emitElement(');
- emit.text(JSON.stringify(node.tag));
- emit.text(');');
- emit.newline();
+ emitAttributesAndModifiers(node, directivesWeakMap);
- emitAttributesAndModifiers(node);
+ // terminate @glint-expect-error directives after opening tag; any
+ // diagnostics due to attributes or modifiers are covered by the directive
+ mapper.terminateDirectiveAreaOfEffect('emitPlainElement - end of opening tag');
for (let child of node.children) {
emitTopLevelStatement(child);
}
- emit.dedent();
- emit.text('}');
- emit.newline();
+ mapper.dedent();
+ mapper.text('}');
+ mapper.newline();
});
}
- function emitAttributesAndModifiers(node: AST.ElementNode): void {
+ function emitAttributesAndModifiers(
+ node: AST.ElementNode,
+ directivesWeakMap: WeakMap,
+ ): void {
let nonArgAttributes = node.attributes.filter((attr) => !attr.name.startsWith('@'));
if (!nonArgAttributes.length && !node.modifiers.length) {
// Avoid unused-symbol diagnostics
- emit.text('__glintY__;');
- emit.newline();
+ // TODO: With Volar you can simply disable `verification` on the CodeInformation
+ // to prevent diagnostics from mapping back upwards. Perhaps we should use that
+ // instead of preserving these empty statements/expressions.
+ mapper.text('__glintY__;');
+ mapper.newline();
} else {
emitSplattributes(node);
- emitPlainAttributes(node);
- emitModifiers(node);
+ emitPlainAttributes(node, directivesWeakMap);
+ emitModifiers(node, directivesWeakMap);
}
}
- function emitPlainAttributes(node: AST.ElementNode): void {
+ function emitPlainAttributes(
+ node: AST.ElementNode,
+ directivesWeakMap: WeakMap,
+ ): void {
let attributes = node.attributes.filter(
(attr) => !attr.name.startsWith('@') && attr.name !== SPLATTRIBUTES,
);
if (!attributes.length) return;
- emit.text('__glintDSL__.applyAttributes(__glintY__.element, {');
- emit.newline();
- emit.indent();
+ mapper.text('__glintDSL__.applyAttributes(__glintY__.element, {');
+ mapper.newline();
+ mapper.indent();
let start = template.indexOf(node.tag, rangeForNode(node).start) + node.tag.length;
for (let attr of attributes) {
- emit.forNode(attr, () => {
+ mapper.forNode(attr, () => {
start = template.indexOf(attr.name, start + 1);
emitHashKey(attr.name, start);
- emit.text(': ');
+ mapper.text(': ');
if (attr.value.type === 'MustacheStatement') {
emitMustacheStatement(attr.value, 'attr');
} else if (attr.value.type === 'ConcatStatement') {
emitConcatStatement(attr.value);
} else {
- emit.text(JSON.stringify(attr.value.chars));
+ mapper.text(JSON.stringify(attr.value.chars));
}
- emit.text(',');
- emit.newline();
+ mapper.text(',');
+ mapper.newline();
});
+
+ mapper.terminateDirectiveAreaOfEffect('emitPlainAttributes');
}
- emit.dedent();
- emit.text('});');
- emit.newline();
+ mapper.dedent();
+ mapper.text('});');
+ mapper.newline();
}
function emitSplattributes(node: AST.ElementNode): void {
@@ -833,23 +953,32 @@ export function templateToTypescript(
'`...attributes` cannot accept a value',
);
- emit.forNode(splattributes, () => {
- emit.text('__glintDSL__.applySplattributes(__glintRef__.element, __glintY__.element);');
+ mapper.forNode(splattributes, () => {
+ mapper.text('__glintDSL__.applySplattributes(__glintRef__.element, __glintY__.element);');
});
- emit.newline();
+ mapper.newline();
+
+ mapper.terminateDirectiveAreaOfEffect('emitSplattributes');
}
- function emitModifiers(node: AST.ElementNode): void {
+ function emitModifiers(
+ node: AST.ElementNode,
+ directivesWeakMap: WeakMap,
+ ): void {
for (let modifier of node.modifiers) {
- emit.forNode(modifier, () => {
- emit.text('__glintDSL__.applyModifier(__glintDSL__.resolve(');
+ mapper.forNode(modifier, () => {
+ // TODO: implement for modifiers
+ // const directive = directivesWeakMap.get(modifier);
+ mapper.text('__glintDSL__.applyModifier(__glintDSL__.resolve(');
emitExpression(modifier.path);
- emit.text(')(__glintY__.element, ');
+ mapper.text(')(__glintY__.element, ');
emitArgs(modifier.params, modifier.hash);
- emit.text('));');
- emit.newline();
+ mapper.text('));');
+ mapper.newline();
});
+
+ mapper.terminateDirectiveAreaOfEffect('emitModifiers');
}
}
@@ -870,7 +999,7 @@ export function templateToTypescript(
return;
}
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
// If a mustache has parameters, we know it must be an invocation; if
// not, it depends on where it appears. In arg position, it's always
// passed directly as a value; otherwise it's invoked if it's a
@@ -879,9 +1008,10 @@ export function templateToTypescript(
if (!hasParams && position === 'arg' && !isGlobal(node.path)) {
emitExpression(node.path);
} else if (position === 'top-level') {
- emit.text('__glintDSL__.emitContent(');
+ // e.g. top-level mustache `{{someValue}}`
+ mapper.text('__glintDSL__.emitContent(');
emitResolve(node, hasParams ? 'resolve' : 'resolveOrReturn');
- emit.text(')');
+ mapper.text(')');
} else {
emitResolve(node, hasParams ? 'resolve' : 'resolveOrReturn');
}
@@ -902,7 +1032,7 @@ export function templateToTypescript(
node: AST.MustacheStatement | AST.SubExpression,
position: InvokePosition,
): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
assert(
position === 'top-level',
() => `{{${formInfo.name}}} may only appear as a top-level statement`,
@@ -922,27 +1052,27 @@ export function templateToTypescript(
to = 'else';
}
- emit.text('__glintDSL__.yieldToBlock(__glintRef__, ');
- emit.text(JSON.stringify(to));
- emit.text(')(');
+ mapper.text('__glintDSL__.yieldToBlock(__glintRef__, ');
+ mapper.text(JSON.stringify(to));
+ mapper.text(')(');
for (let [index, param] of node.params.entries()) {
if (index) {
- emit.text(', ');
+ mapper.text(', ');
}
emitExpression(param);
}
- emit.text(')');
+ mapper.text(')');
});
}
function emitSpecialFormStatement(formInfo: SpecialFormInfo, node: AST.BlockStatement): void {
if (formInfo.requiresConsumption) {
emitExpression(node.path);
- emit.text(';');
- emit.newline();
+ mapper.text(';');
+ mapper.newline();
}
switch (formInfo.form) {
@@ -955,7 +1085,7 @@ export function templateToTypescript(
break;
case 'bind-invokable':
- record.error(
+ mapper.error(
`The {{${formInfo.name}}} helper can't be used directly in block form under Glint. ` +
`Consider first binding the result to a variable, e.g. '{{#let (${formInfo.name} ...) as |...|}}' ` +
`and then using the bound value.`,
@@ -964,75 +1094,75 @@ export function templateToTypescript(
break;
default:
- record.error(`${formInfo.name} is not valid in block form`, rangeForNode(node.path));
+ mapper.error(`${formInfo.name} is not valid in block form`, rangeForNode(node.path));
}
}
function emitIfStatement(formInfo: SpecialFormInfo, node: AST.BlockStatement): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
assert(
node.params.length === 1,
() => `{{#${formInfo.name}}} requires exactly one condition`,
);
- emit.text('if (');
+ mapper.text('if (');
emitExpression(node.params[0]);
- emit.text(') {');
- emit.newline();
- emit.indent();
+ mapper.text(') {');
+ mapper.newline();
+ mapper.indent();
for (let statement of node.program.body) {
emitTopLevelStatement(statement);
}
if (node.inverse) {
- emit.dedent();
- emit.text('} else {');
- emit.indent();
- emit.newline();
+ mapper.dedent();
+ mapper.text('} else {');
+ mapper.indent();
+ mapper.newline();
for (let statement of node.inverse.body) {
emitTopLevelStatement(statement);
}
}
- emit.dedent();
- emit.text('}');
- emit.newline();
+ mapper.dedent();
+ mapper.text('}');
+ mapper.newline();
});
}
function emitUnlessStatement(formInfo: SpecialFormInfo, node: AST.BlockStatement): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
assert(
node.params.length === 1,
() => `{{#${formInfo.name}}} requires exactly one condition`,
);
- emit.text('if (!(');
+ mapper.text('if (!(');
emitExpression(node.params[0]);
- emit.text(')) {');
- emit.newline();
- emit.indent();
+ mapper.text(')) {');
+ mapper.newline();
+ mapper.indent();
for (let statement of node.program.body) {
emitTopLevelStatement(statement);
}
if (node.inverse) {
- emit.dedent();
- emit.text('} else {');
- emit.indent();
- emit.newline();
+ mapper.dedent();
+ mapper.text('} else {');
+ mapper.indent();
+ mapper.newline();
for (let statement of node.inverse.body) {
emitTopLevelStatement(statement);
}
}
- emit.dedent();
- emit.text('}');
- emit.newline();
+ mapper.dedent();
+ mapper.text('}');
+ mapper.newline();
});
}
@@ -1043,15 +1173,15 @@ export function templateToTypescript(
return;
}
- emit.forNode(node, () => {
- emit.text('{');
- emit.newline();
- emit.indent();
+ mapper.forNode(node, () => {
+ mapper.text('{');
+ mapper.newline();
+ mapper.indent();
- emit.text('const __glintY__ = __glintDSL__.emitComponent(');
+ mapper.text('const __glintY__ = __glintDSL__.emitComponent(');
emitResolve(node, 'resolve');
- emit.text(');');
- emit.newline();
+ mapper.text(');');
+ mapper.newline();
emitBlock('default', node.program);
@@ -1065,15 +1195,15 @@ export function templateToTypescript(
if (node.path.type === 'PathExpression') {
let start = template.lastIndexOf(node.path.original, rangeForNode(node).end);
emitPathContents(node.path.parts, start, determinePathKind(node.path));
- emit.text(';');
- emit.newline();
+ mapper.text(';');
+ mapper.newline();
}
- emit.dedent();
- emit.text('}');
+ mapper.dedent();
+ mapper.text('}');
});
- emit.newline();
+ mapper.newline();
}
function emitBlock(name: string, node: AST.Block): void {
@@ -1099,32 +1229,32 @@ export function templateToTypescript(
scope.push(blockParams);
- emit.text('{');
- emit.newline();
- emit.indent();
+ mapper.text('{');
+ mapper.newline();
+ mapper.indent();
- emit.text('const [');
+ mapper.text('const [');
let start = blockParamsOffset;
for (let [index, param] of blockParams.entries()) {
- if (index) emit.text(', ');
+ if (index) mapper.text(', ');
start = template.indexOf(param, start);
- emit.identifier(makeJSSafe(param), start, param.length);
+ mapper.identifier(makeJSSafe(param), start, param.length);
}
- emit.text('] = __glintY__.blockParams');
+ mapper.text('] = __glintY__.blockParams');
emitPropertyAccesss(name, { offset: nameOffset, synthetic: true });
- emit.text(';');
- emit.newline();
+ mapper.text(';');
+ mapper.newline();
for (let statement of children) {
emitTopLevelStatement(statement);
}
- emit.dedent();
- emit.text('}');
- emit.newline();
+ mapper.dedent();
+ mapper.text('}');
+ mapper.newline();
scope.pop();
}
@@ -1135,7 +1265,7 @@ export function templateToTypescript(
return;
}
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
emitResolve(node, 'resolve');
});
}
@@ -1152,14 +1282,14 @@ export function templateToTypescript(
// we convert to Volar mappings, we can create a boundary around
// e.g. "__glintDSL__.resolveOrReturn(expectsAtLeastOneArg)()", which is required because
// this is where TS might generate a diagnostic error.
- emit.forNode(node, () => {
- emit.text('__glintDSL__.');
- emit.text(resolveType);
- emit.text('(');
+ mapper.forNode(node, () => {
+ mapper.text('__glintDSL__.');
+ mapper.text(resolveType);
+ mapper.text('(');
emitExpression(node.path);
- emit.text(')(');
+ mapper.text(')(');
emitArgs(node.params, node.hash);
- emit.text(')');
+ mapper.text(')');
});
}
@@ -1167,7 +1297,7 @@ export function templateToTypescript(
// Emit positional args
for (let [index, param] of positional.entries()) {
if (index) {
- emit.text(', ');
+ mapper.text(', ');
}
emitExpression(param);
@@ -1176,29 +1306,29 @@ export function templateToTypescript(
// Emit named args
if (named.pairs.length) {
if (positional.length) {
- emit.text(', ');
+ mapper.text(', ');
}
// TS diagnostic error boundary
- emit.forNode(named, () => {
- emit.text('{ ');
+ mapper.forNode(named, () => {
+ mapper.text('{ ');
let { start } = rangeForNode(named);
for (let [index, pair] of named.pairs.entries()) {
start = template.indexOf(pair.key, start);
emitHashKey(pair.key, start);
- emit.text(': ');
+ mapper.text(': ');
emitExpression(pair.value);
if (index === named.pairs.length - 1) {
- emit.text(' ');
+ mapper.text(' ');
}
start = rangeForNode(pair.value).end;
- emit.text(', ');
+ mapper.text(', ');
}
- emit.text('...__glintDSL__.NamedArgsMarker }');
+ mapper.text('...__glintDSL__.NamedArgsMarker }');
});
}
}
@@ -1206,7 +1336,7 @@ export function templateToTypescript(
type PathKind = 'this' | 'arg' | 'free';
function emitPath(node: AST.PathExpression): void {
- emit.forNode(node, () => {
+ mapper.forNode(node, () => {
let { start } = rangeForNode(node);
emitPathContents(node.parts, start, determinePathKind(node));
});
@@ -1219,11 +1349,11 @@ export function templateToTypescript(
function emitPathContents(parts: string[], start: number, kind: PathKind): void {
if (kind === 'this') {
let thisStart = template.indexOf('this', start);
- emit.text('__glintRef__.');
- emit.identifier('this', thisStart);
+ mapper.text('__glintRef__.');
+ mapper.identifier('this', thisStart);
start = template.indexOf('.', thisStart) + 1;
} else if (kind === 'arg') {
- emit.text('__glintRef__.args');
+ mapper.text('__glintRef__.args');
start = template.indexOf('@', start) + 1;
}
@@ -1264,40 +1394,40 @@ export function templateToTypescript(
// `noPropertyAccessFromIndexSignature`. Emitting `{{foo.bar}}` property accesses, however,
// should use `.` notation for exactly the same reason.
if (!synthetic && isSafeKey(name)) {
- emit.text(optional ? '?.' : '.');
+ mapper.text(optional ? '?.' : '.');
if (offset) {
- emit.identifier(name, offset);
+ mapper.identifier(name, offset);
} else {
- emit.text(name);
+ mapper.text(name);
}
} else {
- emit.text(optional ? '?.[' : '[');
+ mapper.text(optional ? '?.[' : '[');
if (offset) {
emitIdentifierString(name, offset);
} else {
- emit.text(JSON.stringify(name));
+ mapper.text(JSON.stringify(name));
}
- emit.text(']');
+ mapper.text(']');
}
}
function emitHashKey(name: string, start: number): void {
if (isSafeKey(name)) {
- emit.identifier(name, start);
+ mapper.identifier(name, start);
} else {
emitIdentifierString(name, start);
}
}
function emitIdentifierString(name: string, start: number): void {
- emit.text('"');
- emit.identifier(JSON.stringify(name).slice(1, -1), start, name.length);
- emit.text('"');
+ mapper.text('"');
+ mapper.identifier(JSON.stringify(name).slice(1, -1), start, name.length);
+ mapper.text('"');
}
function emitLiteral(node: AST.Literal): void {
- emit.forNode(node, () =>
- emit.text(node.value === undefined ? 'undefined' : JSON.stringify(node.value)),
+ mapper.forNode(node, () =>
+ mapper.text(node.value === undefined ? 'undefined' : JSON.stringify(node.value)),
);
}
diff --git a/packages/core/src/transform/template/transformed-module.ts b/packages/core/src/transform/template/transformed-module.ts
index 22c496ff0..663c19f69 100644
--- a/packages/core/src/transform/template/transformed-module.ts
+++ b/packages/core/src/transform/template/transformed-module.ts
@@ -1,6 +1,7 @@
import GlimmerASTMappingTree from './glimmer-ast-mapping-tree.js';
import { assert } from '../util.js';
import { CodeInformation, CodeMapping } from '@volar/language-core';
+import { codeFeatures } from './code-features.js';
export type Range = { start: number; end: number };
export type RangeWithMapping = Range & { mapping?: GlimmerASTMappingTree };
@@ -25,12 +26,10 @@ export type CorrelatedSpan = {
glimmerAstMapping?: GlimmerASTMappingTree;
};
-export type DirectiveKind = 'ignore' | 'expect-error';
+export type DirectiveKind = 'ignore' | 'expect-error' | 'nocheck';
export type Directive = {
kind: DirectiveKind;
source: SourceFile;
- location: Range;
- areaOfEffect: Range;
};
export type TransformError = {
@@ -238,32 +237,38 @@ export default class TransformedModule {
* - `[[ZEROLEN-A]]__glintDSL__.emitContent(__glintDSL__.resolveOrReturn([[expectsAtLeastOneArg]])());[[ZEROLEN-B]]`
*/
public toVolarMappings(filenameFilter?: string): CodeMapping[] {
- const sourceOffsets: number[] = [];
- const generatedOffsets: number[] = [];
- const lengths: number[] = [];
-
- const push = (sourceOffset: number, generatedOffset: number, length: number): void => {
- if (sourceOffsets.length > 0) {
- // TODO: these assertions are firing for certain files/transformations, which means
- // we're emitting unsorted mappings, which means volar has to fall back to an inefficient
- // source mapping algorithm rather than using binary search:
- // https://github.com/volarjs/volar.js/blob/3798f27684f5c671f06bf7a19e32bc489e652e14/packages/source-map/lib/translateOffset.ts#L18
- //
- // The fix for this is probably somewhere in the `template-to-typescript.ts` file, but I
- // don't have a sense for how complicated that'll be.
- // assert(
- // sourceOffset >= sourceOffsets[sourceOffsets.length - 1],
- // 'Source offsets should be monotonically increasing',
- // );
- // assert(
- // generatedOffset >= generatedOffsets[generatedOffsets.length - 1],
- // 'Generated offsets should be monotonically increasing',
- // );
- }
-
- sourceOffsets.push(sourceOffset);
- generatedOffsets.push(generatedOffset);
- lengths.push(length);
+ const codeMappings: CodeMapping[] = [];
+
+ const push = (
+ sourceOffset: number,
+ generatedOffset: number,
+ length: number,
+ codeInformation: CodeInformation | undefined,
+ ): void => {
+ // if (sourceOffsets.length > 0) {
+ // TODO: these assertions are firing for certain files/transformations, which means
+ // we're emitting unsorted mappings, which means volar has to fall back to an inefficient
+ // source mapping algorithm rather than using binary search:
+ // https://github.com/volarjs/volar.js/blob/3798f27684f5c671f06bf7a19e32bc489e652e14/packages/source-map/lib/translateOffset.ts#L18
+ //
+ // The fix for this is probably somewhere in the `template-to-typescript.ts` file, but I
+ // don't have a sense for how complicated that'll be.
+ // assert(
+ // sourceOffset >= sourceOffsets[sourceOffsets.length - 1],
+ // 'Source offsets should be monotonically increasing',
+ // );
+ // assert(
+ // generatedOffset >= generatedOffsets[generatedOffsets.length - 1],
+ // 'Generated offsets should be monotonically increasing',
+ // );
+ // }
+
+ codeMappings.push({
+ sourceOffsets: [sourceOffset],
+ generatedOffsets: [generatedOffset],
+ lengths: [length],
+ data: codeInformation || {},
+ });
};
let recurse = (span: CorrelatedSpan, mapping: GlimmerASTMappingTree): void => {
@@ -281,22 +286,22 @@ export default class TransformedModule {
if (hbsLength === tsLength) {
// (Hacky?) assumption: because TS and HBS span lengths are equivalent,
// then this is a simple leafmost mapping, e.g. `{{this.[foo]}}` -> `this.[foo]`
- push(hbsStart, tsStart, hbsLength);
+ push(hbsStart, tsStart, hbsLength, mapping.codeInformation);
} else {
// Disregard the "null zone" mappings, i.e. cases where TS code maps to empty HBS code
if (hbsLength > 0 && tsLength > 0) {
- push(hbsStart, tsStart, 0);
- push(hbsEnd, tsEnd, 0);
+ push(hbsStart, tsStart, 0, mapping.codeInformation);
+ push(hbsEnd, tsEnd, 0, mapping.codeInformation);
}
}
} else {
- push(hbsStart, tsStart, 0);
+ push(hbsStart, tsStart, 0, mapping.codeInformation);
mapping.children.forEach((child) => {
recurse(span, child);
});
- push(hbsEnd, tsEnd, 0);
+ push(hbsEnd, tsEnd, 0, mapping.codeInformation);
}
};
@@ -311,8 +316,10 @@ export default class TransformedModule {
// contents of a companion .hbs file in loose mode)
recurse(span, span.glimmerAstMapping);
} else {
- // this span is untransformed TS content. Because there's no
- // transformation, we expect these to be the same length (in fact, they
+ // This span contains untransformed TS content (because it comes
+ // from a region of source code that is already TS, e.g. the top of a .gts file,
+ // outside of any `` tags). Because there's no transformation,
+ // we expect these to be the same length (in fact, they
// should be the same string entirely)
// This assertion seemed valid when parsing .gts files with extracted hbs in tags,
@@ -324,31 +331,14 @@ export default class TransformedModule {
// );
if (span.originalLength === span.transformedLength) {
- push(span.originalStart, span.transformedStart, span.originalLength);
+ // TODO: audit usage of `codeFeatures.all` here: https://github.com/typed-ember/glint/issues/769
+ // There are cases where we need to be disabling certain features to prevent, e.g., navigation
+ // that targets an "in-between" piece of generated code.
+ push(span.originalStart, span.transformedStart, span.originalLength, codeFeatures.all);
}
}
});
- // TODO: in order to fix/address Issue https://github.com/typed-ember/glint/issues/769,
- // we will need to split up this array into multiple CodeMappings, each with a different
- // CodeInformation object. Specifically, everything but `verification` should be false or
- // omitted for any mappings that represent regions of generated code that don't exist in the source.
- // Otherwise there is risk of code completions and other things happening in the wrong place.
- return [
- {
- sourceOffsets,
- generatedOffsets,
- lengths,
-
- data: {
- completion: true,
- format: false,
- navigation: true,
- semantic: true,
- structure: true,
- verification: true,
- } satisfies CodeInformation,
- },
- ];
+ return codeMappings;
}
}
diff --git a/packages/core/src/volar/language-server.ts b/packages/core/src/volar/language-server.ts
index 36cc404da..95e9054cc 100644
--- a/packages/core/src/volar/language-server.ts
+++ b/packages/core/src/volar/language-server.ts
@@ -1,28 +1,15 @@
#!/usr/bin/env node
import {
- LanguageServiceContext,
- LanguageServicePlugin,
- LanguageServicePluginInstance,
createConnection,
createServer,
createTypeScriptProject,
} from '@volar/language-server/node.js';
-import { create as createTypeScriptServicePlugins } from 'volar-service-typescript';
import { createEmberLanguagePlugin } from './ember-language-plugin.js';
-import { assert } from '../transform/util.js';
import { ConfigLoader } from '../config/loader.js';
import ts from 'typescript';
-import type { TextDocument } from 'vscode-languageserver-textdocument';
-import * as vscode from 'vscode-languageserver-protocol';
-import { URI } from 'vscode-uri';
-import { VirtualGtsCode } from './gts-virtual-code.js';
-import { augmentDiagnostic } from '../transform/diagnostics/augmentation.js';
-import GlimmerASTMappingTree from '../transform/template/glimmer-ast-mapping-tree.js';
-import { Directive, TransformedModule } from '../transform/index.js';
-import { Range } from '../transform/template/transformed-module.js';
-import { offsetToPosition } from '../language-server/util/position.js';
import { Disposable } from '@volar/language-service';
+import { createTypescriptLanguageServicePlugin } from './typescript-language-service-plugin.js';
const connection = createConnection();
const server = createServer(connection);
@@ -77,40 +64,10 @@ connection.onInitialize((parameters) => {
},
};
});
- return server.initialize(
- parameters,
- project,
- // Return the service plugins required/used by our language server. Service plugins provide
- // functionality for a single file/language type. For example, we use Volar's TypeScript service
- // for type-checking our .gts/.gjs files, but .gts/.gjs files are actually two separate languages
- // (TS + Handlebars) combined into one, but we can use the TS language service because the only
- // scripts we pass to the TS service for type-checking is transformed Intermediate Representation (IR)
- // TypeScript code with all tags converted to type-checkable TS.
- createTypeScriptServicePlugins(ts).map((plugin) => {
- if (plugin.name === 'typescript-semantic') {
- // Extend the default TS service with Glint-specific customizations.
- // Similar approach as:
- // https://github.com/withastro/language-tools/blob/main/packages/language-server/src/plugins/typescript/index.ts#L14
- return {
- ...plugin,
- create(context): LanguageServicePluginInstance {
- const typeScriptPlugin = plugin.create(context);
+ const languageServicePlugins = createTypescriptLanguageServicePlugin(ts);
- return {
- ...typeScriptPlugin,
- async provideDiagnostics(document: TextDocument, token: vscode.CancellationToken) {
- const diagnostics = await typeScriptPlugin.provideDiagnostics!(document, token);
- return filterAndAugmentDiagnostics(context, document, diagnostics);
- },
- };
- },
- };
- } else {
- return plugin;
- }
- }),
- );
+ return server.initialize(parameters, project, languageServicePlugins);
function updateFileWatcher(): void {
const newExtensions = EXTENSIONS.filter((ext) => !watchingExtensions.has(ext));
@@ -126,141 +83,6 @@ connection.onInitialize((parameters) => {
}
});
-function filterAndAugmentDiagnostics(
- context: LanguageServiceContext,
- document: TextDocument,
- diagnostics: vscode.Diagnostic[] | null | undefined,
-): vscode.Diagnostic[] | null {
- if (!diagnostics) {
- // This can fail if .gts file fails to parse. Maybe other use cases too?
- return null;
- }
-
- // Lazily fetch and cache the VirtualCode -- this might be a premature optimization
- // after the code went through enough changes, so maybe safe to simplify in the future.
- let cachedVirtualCode: VirtualGtsCode | null | undefined = undefined;
- const fetchVirtualCode = (): VirtualGtsCode | null => {
- if (typeof cachedVirtualCode === 'undefined') {
- cachedVirtualCode = null;
-
- const decoded = context.decodeEmbeddedDocumentUri(URI.parse(document.uri));
- if (decoded) {
- const script = context.language.scripts.get(decoded[0]);
- const scriptRoot = script?.generated?.root;
- if (scriptRoot instanceof VirtualGtsCode) {
- cachedVirtualCode = scriptRoot;
- }
- }
- }
-
- return cachedVirtualCode;
- };
-
- const mappingForDiagnostic = (diagnostic: vscode.Diagnostic): GlimmerASTMappingTree | null => {
- const transformedModule = fetchVirtualCode()?.transformedModule;
-
- if (!transformedModule) {
- return null;
- }
-
- const range = diagnostic.range;
- const start = document.offsetAt(range.start);
- const end = document.offsetAt(range.end);
- const rangeWithMappingAndSource = transformedModule.getOriginalRange(start, end);
- return rangeWithMappingAndSource.mapping || null;
- };
-
- const allDiagnostics: vscode.Diagnostic[] = [];
-
- const augmentedDiagnostics = diagnostics.map((diagnostic) => {
- diagnostic = {
- ...diagnostic,
- source: 'glint',
- };
-
- return augmentDiagnostic(diagnostic as any, mappingForDiagnostic);
- });
-
- let unusedExpectErrors = new Set();
- const transformedModule = fetchVirtualCode()?.transformedModule;
- if (transformedModule) {
- transformedModule.directives.forEach((directive) => {
- if (directive.kind === 'expect-error') {
- unusedExpectErrors.add(directive);
- }
- });
- }
-
- augmentedDiagnostics.forEach((diagnostic) => {
- // `diagnostic` is a TS-generated Diagnostic for the transformed TS file (i.e.
- // the Intermediate Representation of the .gts file where all embedded
- // templates are converted to TS).
- //
- // We need to determine whether the TS diagnostic is within the area of effect
- // for a `{{! @glint-expect-error }}` or `{{! @glint-ignore }}` directive in the
- // original untransformed .gts file.
- //
- // In order to do that, we need to translate the directive's area of effect
- // into its mapping .ts equivalent, OR we take the TS diagnostic's range and
- // find the corresponding directive in the transformedModule.
-
- const diagnosticStart = document.offsetAt(diagnostic.range.start);
- let appliedDirective: Directive | undefined = undefined;
-
- if (transformedModule) {
- let originalGtsDiagnosticStart = transformedModule?.getOriginalOffset(diagnosticStart);
-
- appliedDirective = transformedModule?.directives.find((directive) => {
- return (
- // TODO: when would the filename ever be different? uncomment and fix?
- // directive.source.filename === diagnostic.file.fileName &&
- directive.areaOfEffect.start <= originalGtsDiagnosticStart.offset &&
- directive.areaOfEffect.end > originalGtsDiagnosticStart.offset
- );
- });
- }
-
- if (appliedDirective) {
- unusedExpectErrors.delete(appliedDirective);
- } else {
- allDiagnostics.push(diagnostic);
- }
- });
-
- if (transformedModule) {
- for (let directive of unusedExpectErrors) {
- const transformedStartOffset = transformedModule.getTransformedOffset(
- directive.source.filename,
- directive.location.start,
- );
-
- // Hacky, but `// @glint-expect-error\n` is the TS transformed representation of `{{!@glint-expect-error}}`,
- // and its length is 23 characters, and we can use that number to calculate the end position in the transformed file.
- //
- // It would be less hacky if we could use:
- //
- // transformedModule.getTransformedOffset(directive.source.filename, directive.location.end)
- //
- // But for unknown reasons (perhaps related to how Volar wants us to use 0-length boundary mappings
- // to map unequally-sized regions to each other?), this ends up returning the same value as `directive.location.start`.
- const transformedEndOffset = transformedStartOffset + 23;
-
- allDiagnostics.push({
- message: `Unused '@glint-expect-error' directive.`,
- range: vscode.Range.create(
- offsetToPosition(transformedModule.transformedContents, transformedStartOffset),
- offsetToPosition(transformedModule.transformedContents, transformedEndOffset),
- ),
- severity: vscode.DiagnosticSeverity.Error,
- code: 0,
- source: directive.source.filename, // not sure if this is right
- });
- }
- }
-
- return allDiagnostics;
-}
-
/**
* Invoked when client has sent `initialized` notification.
*/
diff --git a/packages/core/src/volar/typescript-language-service-plugin.ts b/packages/core/src/volar/typescript-language-service-plugin.ts
new file mode 100644
index 000000000..4054dbb77
--- /dev/null
+++ b/packages/core/src/volar/typescript-language-service-plugin.ts
@@ -0,0 +1,101 @@
+import {
+ LanguageServicePlugin,
+ LanguageServicePluginInstance,
+ LanguageServiceContext,
+} from '@volar/language-service';
+import { create as createTypeScriptServices } from 'volar-service-typescript';
+import * as vscode from 'vscode-languageserver-protocol';
+import type { TextDocument } from 'vscode-languageserver-textdocument';
+import { URI } from 'vscode-uri';
+import { augmentDiagnostic } from '../transform/diagnostics/augmentation.js';
+import GlimmerASTMappingTree from '../transform/template/glimmer-ast-mapping-tree.js';
+import { VirtualGtsCode } from './gts-virtual-code.js';
+
+// Return the service plugins required/used by our language server. Service plugins provide
+// functionality for a single file/language type. For example, we use Volar's TypeScript service
+// for type-checking our .gts/.gjs files, but .gts/.gjs files are actually two separate languages
+// (TS + Handlebars) combined into one, but we can use the TS language service because the only
+// scripts we pass to the TS service for type-checking is transformed Intermediate Representation (IR)
+// TypeScript code with all tags converted to type-checkable TS.
+export function createTypescriptLanguageServicePlugin(
+ ts: typeof import('typescript'),
+): LanguageServicePlugin[] {
+ return createTypeScriptServices(ts).map((plugin) => {
+ if (plugin.name === 'typescript-semantic') {
+ // Extend the default TS service with Glint-specific customizations.
+ // Similar approach as:
+ // https://github.com/withastro/language-tools/blob/main/packages/language-server/src/plugins/typescript/index.ts#L14
+ return {
+ ...plugin,
+ create(context): LanguageServicePluginInstance {
+ const typeScriptPlugin = plugin.create(context);
+
+ return {
+ ...typeScriptPlugin,
+ async provideDiagnostics(document: TextDocument, token: vscode.CancellationToken) {
+ const diagnostics = await typeScriptPlugin.provideDiagnostics!(document, token);
+ return filterAndAugmentDiagnostics(context, document, diagnostics);
+ },
+ };
+ },
+ };
+ } else {
+ return plugin;
+ }
+ });
+}
+function filterAndAugmentDiagnostics(
+ context: LanguageServiceContext,
+ document: TextDocument,
+ diagnostics: vscode.Diagnostic[] | null | undefined,
+): vscode.Diagnostic[] | null {
+ if (!diagnostics) {
+ // This can fail if .gts file fails to parse. Maybe other use cases too?
+ return null;
+ }
+
+ // Lazily fetch and cache the VirtualCode -- this might be a premature optimization
+ // after the code went through enough changes, so maybe safe to simplify in the future.
+ let cachedVirtualCode: VirtualGtsCode | null | undefined = undefined;
+ const fetchVirtualCode = (): VirtualGtsCode | null => {
+ if (typeof cachedVirtualCode === 'undefined') {
+ cachedVirtualCode = null;
+
+ const decoded = context.decodeEmbeddedDocumentUri(URI.parse(document.uri));
+ if (decoded) {
+ const script = context.language.scripts.get(decoded[0]);
+ const scriptRoot = script?.generated?.root;
+ if (scriptRoot instanceof VirtualGtsCode) {
+ cachedVirtualCode = scriptRoot;
+ }
+ }
+ }
+
+ return cachedVirtualCode;
+ };
+
+ const mappingForDiagnostic = (diagnostic: vscode.Diagnostic): GlimmerASTMappingTree | null => {
+ const transformedModule = fetchVirtualCode()?.transformedModule;
+
+ if (!transformedModule) {
+ return null;
+ }
+
+ const range = diagnostic.range;
+ const start = document.offsetAt(range.start);
+ const end = document.offsetAt(range.end);
+ const rangeWithMappingAndSource = transformedModule.getOriginalRange(start, end);
+ return rangeWithMappingAndSource.mapping || null;
+ };
+
+ const augmentedDiagnostics = diagnostics.map((diagnostic) => {
+ diagnostic = {
+ ...diagnostic,
+ source: 'glint',
+ };
+
+ return augmentDiagnostic(diagnostic as any, mappingForDiagnostic);
+ });
+
+ return augmentedDiagnostics;
+}
diff --git a/test-packages/test-utils/src/project.ts b/test-packages/test-utils/src/project.ts
index e714a9be4..1959aae67 100644
--- a/test-packages/test-utils/src/project.ts
+++ b/test-packages/test-utils/src/project.ts
@@ -16,10 +16,14 @@ import { WorkspaceSymbolRequest, WorkspaceSymbolParams } from '@volar/language-s
const require = createRequire(import.meta.url);
const dirname = path.dirname(fileURLToPath(import.meta.url));
-const pathToTemplatePackage = pathUtils.normalizeFilePath(
- path.resolve(dirname, '../../../packages/template'),
+const fileUriToTemplatePackage = pathUtils.filePathToUri(
+ pathUtils.normalizeFilePath(path.resolve(dirname, '../../../packages/template')),
+);
+const fileUriToEmberTemplateImportsPackage = pathUtils.filePathToUri(
+ pathUtils.normalizeFilePath(
+ path.resolve(dirname, '../../../packages/environment-ember-template-imports'),
+ ),
);
-const fileUriToTemplatePackage = pathUtils.filePathToUri(pathToTemplatePackage);
const ROOT = pathUtils.normalizeFilePath(path.resolve(dirname, '../../ephemeral'));
// You'd think this would exist, but... no? Accordingly, supply a minimal
@@ -171,7 +175,11 @@ export class Project {
)
.replaceAll(`"${this.filePath('.')}`, '"/path/to/EPHEMERAL_TEST_PROJECT')
.replaceAll(`"${this.fileURI('.')}`, '"file:///path/to/EPHEMERAL_TEST_PROJECT')
- .replaceAll(fileUriToTemplatePackage, 'file:///PATH_TO_MODULE/@glint/template');
+ .replaceAll(fileUriToTemplatePackage, 'file:///PATH_TO_MODULE/@glint/template')
+ .replaceAll(
+ fileUriToEmberTemplateImportsPackage,
+ 'file:///PATH_TO_MODULE/@glint/environment-ember-template-imports',
+ );
return JSON.parse(normalized);
}