diff --git a/packages/cli/src/transform-manager.ts b/packages/cli/src/transform-manager.ts index 7ce7470bb..a6572032a 100644 --- a/packages/cli/src/transform-manager.ts +++ b/packages/cli/src/transform-manager.ts @@ -22,37 +22,32 @@ export default class TransformManager { let file = diagnostic.file; let transformedModule = file && this.transformedModules.get(file.fileName); if (diagnostic.code && file && transformedModule) { - let sourceFile = this.ts.createSourceFile( - file.fileName, - transformedModule.originalSource, - file.languageVersion - ); - - diagnostic = rewriteDiagnostic(diagnostic, transformedModule, sourceFile); + diagnostic = rewriteDiagnostic(this.ts, diagnostic, transformedModule); } return this.ts.formatDiagnosticsWithColorAndContext([diagnostic], this.formatDiagnosticHost); } public readFile(filename: string, encoding?: string): string | undefined { - let source = this.ts.sys.readFile(filename, encoding); + let contents = this.ts.sys.readFile(filename, encoding); let config = this.glintConfig; if ( - source && + contents && filename.endsWith('.ts') && !filename.endsWith('.d.ts') && config.includesFile(filename) && - config.environment.moduleMayHaveTagImports(source) + config.environment.moduleMayHaveTagImports(contents) ) { - let transformedModule = rewriteModule(filename, source, config.environment); + let script = { filename, contents }; + let transformedModule = rewriteModule({ script }, config.environment); if (transformedModule) { this.transformedModules.set(filename, transformedModule); - return transformedModule.transformedSource; + return transformedModule.transformedContents; } } - return source; + return contents; } private readonly formatDiagnosticHost: ts.FormatDiagnosticsHost = { @@ -62,20 +57,14 @@ export default class TransformManager { }; private buildDiagnostics(transformedModule: TransformedModule): Array { - if (!transformedModule.errors.length) { - return []; - } - - let sourceFile = this.ts.createSourceFile( - transformedModule.filename, - transformedModule.originalSource, - this.ts.ScriptTarget.ESNext - ); - return transformedModule.errors.map((error) => ({ category: this.ts.DiagnosticCategory.Error, code: 0, - file: sourceFile, + file: this.ts.createSourceFile( + error.source.filename, + error.source.contents, + this.ts.ScriptTarget.Latest + ), start: error.location.start, length: error.location.end - error.location.start, messageText: `[glint] ${error.message}`, diff --git a/packages/transform/__tests__/debug.test.ts b/packages/transform/__tests__/debug.test.ts index 420e67b82..73072cb77 100644 --- a/packages/transform/__tests__/debug.test.ts +++ b/packages/transform/__tests__/debug.test.ts @@ -4,103 +4,106 @@ import { GlintEnvironment } from '@glint/config'; describe('Debug utilities', () => { test('TransformedModule#toDebugString', () => { - let code = stripIndent` - import Component, { hbs } from '@glimmerx/component'; + let script = { + filename: 'test.ts', + contents: stripIndent` + import Component, { hbs } from '@glimmerx/component'; - export default class MyComponent extends Component { - private bar = 'hi'; + export default class MyComponent extends Component { + private bar = 'hi'; - static template = hbs\` - - \`; - } + static template = hbs\` + + \`; + } - class HelperComponent extends Component<{ foo: string }> { - static template = hbs\` - Hello, {{@foo}} - \`; - } - `; + class HelperComponent extends Component<{ foo: string }> { + static template = hbs\` + Hello, {{@foo}} + \`; + } + `, + }; - let transformedModule = rewriteModule('test.ts', code, GlintEnvironment.load('glimmerx')); + let transformedModule = rewriteModule({ script }, GlintEnvironment.load('glimmerx')); expect(transformedModule?.toDebugString()).toMatchInlineSnapshot(` - "TransformedModule test.ts + "TransformedModule | Mapping: Template | hbs(0:50): hbs\`\\\\n \\\\n \` | ts(0:277): (() => {\\\\n hbs;\\\\n let χ!: typeof import(\\"@glint/environment-glimmerx/types\\");\\\\n return χ.template(function(𝚪: import(\\"@glint/environment-glimmerx/types\\").ResolveContext) {\\\\n χ.invokeBlock(χ.resolve(HelperComponent)({ foo: 𝚪.this.bar }), {});\\\\n 𝚪;\\\\n });\\\\n})() - | + | | | Mapping: Identifier - | | hbs(0:0): + | | hbs(0:0): | | ts(170:181): MyComponent - | | + | | | | Mapping: ElementNode | | hbs(9:46): | | ts(186:259): χ.invokeBlock(χ.resolve(HelperComponent)({ foo: 𝚪.this.bar }), {}); - | | + | | | | | Mapping: ElementNode | | | hbs(9:46): | | | ts(186:259): χ.invokeBlock(χ.resolve(HelperComponent)({ foo: 𝚪.this.bar }), {}); - | | | + | | | | | | | Mapping: Identifier | | | | hbs(10:25): HelperComponent | | | | ts(214:229): HelperComponent - | | | | + | | | | | | | | Mapping: AttrNode | | | | hbs(26:43): @foo={{this.bar}} | | | | ts(233:249): foo: 𝚪.this.bar - | | | | + | | | | | | | | | Mapping: Identifier | | | | | hbs(27:30): foo | | | | | ts(233:236): foo - | | | | | + | | | | | | | | | | Mapping: MustacheStatement | | | | | hbs(31:43): {{this.bar}} | | | | | ts(238:249): 𝚪.this.bar - | | | | | + | | | | | | | | | | | Mapping: PathExpression | | | | | | hbs(33:41): this.bar | | | | | | ts(238:249): 𝚪.this.bar - | | | | | | + | | | | | | | | | | | | | Mapping: Identifier | | | | | | | hbs(33:37): this | | | | | | | ts(241:245): this - | | | | | | | + | | | | | | | | | | | | | | Mapping: Identifier | | | | | | | hbs(38:41): bar | | | | | | | ts(246:249): bar - | | | | | | | - | | | | | | - | | | | | - | | | | - | | | - | | - | + | | | | | | | + | | | | | | + | | | | | + | | | | + | | | + | | + | | Mapping: Template | hbs(0:28): hbs\`\\\\n Hello, {{@foo}}\\\\n \` | ts(0:262): (() => {\\\\n hbs;\\\\n let χ!: typeof import(\\"@glint/environment-glimmerx/types\\");\\\\n return χ.template(function(𝚪: import(\\"@glint/environment-glimmerx/types\\").ResolveContext) {\\\\n χ.invokeEmit(χ.resolveOrReturn(𝚪.args.foo)({}));\\\\n 𝚪;\\\\n });\\\\n})() - | + | | | Mapping: Identifier - | | hbs(0:0): + | | hbs(0:0): | | ts(170:185): HelperComponent - | | + | | | | Mapping: MustacheStatement | | hbs(16:24): {{@foo}} | | ts(190:242): χ.invokeEmit(χ.resolveOrReturn(𝚪.args.foo)({})) - | | + | | | | | Mapping: PathExpression | | | hbs(18:22): @foo | | | ts(225:236): 𝚪.args.foo - | | | + | | | | | | | Mapping: Identifier | | | | hbs(19:22): foo | | | | ts(233:236): foo - | | | | - | | | - | | - | " + | | | | + | | | + | | + |" `); }); }); diff --git a/packages/transform/__tests__/offset-mapping.test.ts b/packages/transform/__tests__/offset-mapping.test.ts index 0f8f63237..14b82a6f9 100644 --- a/packages/transform/__tests__/offset-mapping.test.ts +++ b/packages/transform/__tests__/offset-mapping.test.ts @@ -1,6 +1,6 @@ import { rewriteModule, TransformedModule, rewriteDiagnostic } from '../src'; import { stripIndent } from 'common-tags'; -import { Range } from '../src/transformed-module'; +import { Range, SourceFile } from '../src/transformed-module'; import ts from 'typescript'; import { assert } from '../src/util'; import { GlintEnvironment } from '@glint/config'; @@ -8,16 +8,18 @@ import { GlintEnvironment } from '@glint/config'; const glimmerxEnvironment = GlintEnvironment.load('glimmerx'); describe('Source-to-source offset mapping', () => { + type RewrittenTestModule = { script: SourceFile; transformedModule: TransformedModule }; + function rewriteTestModule({ contents, identifiersInScope = [], }: { contents: string; identifiersInScope?: string[]; - }): TransformedModule { - let result = rewriteModule( - 'test.ts', - stripIndent` + }): RewrittenTestModule { + let script = { + filename: 'test.ts', + contents: stripIndent` import Component, { hbs } from '@glimmerx/component'; import { ${identifiersInScope.join(', ')} } from 'dummy'; @@ -27,14 +29,14 @@ describe('Source-to-source offset mapping', () => { \`; } `, - glimmerxEnvironment - ); + }; - if (!result) { + let transformedModule = rewriteModule({ script }, glimmerxEnvironment); + if (!transformedModule) { throw new Error('Expected module to have rewritten contents'); } - return result; + return { script, transformedModule }; } function findOccurrence(haystack: string, needle: string, occurrence: number): number { @@ -50,16 +52,17 @@ describe('Source-to-source offset mapping', () => { } function expectTokenMapping( - transformedModule: TransformedModule, + rewrittenTestModule: RewrittenTestModule, originalToken: string, { transformedToken = originalToken, occurrence = 0 } = {} ): void { - let { originalSource, transformedSource } = transformedModule; + let originalSource = rewrittenTestModule.script.contents; + let transformedContents = rewrittenTestModule.transformedModule.transformedContents; let originalOffset = findOccurrence(originalSource, originalToken, occurrence); - let transformedOffset = findOccurrence(transformedSource, transformedToken, occurrence); + let transformedOffset = findOccurrence(transformedContents, transformedToken, occurrence); expectRangeMapping( - transformedModule, + rewrittenTestModule, { start: originalOffset, end: originalOffset + originalToken.length, @@ -72,18 +75,21 @@ describe('Source-to-source offset mapping', () => { } function expectRangeMapping( - transformedModule: TransformedModule, + rewrittenTestModule: RewrittenTestModule, originalRange: Range, transformedRange: Range ): void { - expect(transformedModule.getOriginalOffset(transformedRange.start)).toEqual( - originalRange.start - ); - expect(transformedModule.getTransformedOffset(originalRange.start)).toEqual( + let { transformedModule, script: original } = rewrittenTestModule; + expect(transformedModule.getOriginalOffset(transformedRange.start)).toEqual({ + offset: originalRange.start, + source: rewrittenTestModule.script, + }); + expect(transformedModule.getTransformedOffset(original.filename, originalRange.start)).toEqual( transformedRange.start ); let calculatedTransformedRange = transformedModule.getTransformedRange( + original.filename, originalRange.start, originalRange.end ); @@ -96,6 +102,7 @@ describe('Source-to-source offset mapping', () => { transformedRange.end ); + expect(calculatedOriginalRange.source).toBe(rewrittenTestModule.script); expect(calculatedOriginalRange.start).toEqual(originalRange.start); expect(calculatedOriginalRange.end).toEqual(originalRange.end); } @@ -294,9 +301,9 @@ describe('Source-to-source offset mapping', () => { }); describe('spans outside of mapped segments', () => { - const rewritten = rewriteModule( - 'test.ts', - stripIndent` + const source = { + filename: 'test.ts', + contents: stripIndent` import Component, { hbs } from '@glimmerx/component'; // start @@ -309,39 +316,42 @@ describe('Source-to-source offset mapping', () => { static template = hbs\`Hello, world!\`; } `, - glimmerxEnvironment - )!; + }; + + const rewritten = rewriteModule({ script: source }, glimmerxEnvironment)!; test('bounds that cross a rewritten span', () => { - let originalStart = rewritten.originalSource.indexOf('// start'); - let originalEnd = rewritten.originalSource.indexOf('// end'); + let originalStart = source.contents.indexOf('// start'); + let originalEnd = source.contents.indexOf('// end'); - let transformedStart = rewritten.transformedSource.indexOf('// start'); - let transformedEnd = rewritten.transformedSource.indexOf('// end'); + let transformedStart = rewritten.transformedContents.indexOf('// start'); + let transformedEnd = rewritten.transformedContents.indexOf('// end'); expect(rewritten.getOriginalRange(transformedStart, transformedEnd)).toEqual({ start: originalStart, end: originalEnd, + source, }); - expect(rewritten.getTransformedRange(originalStart, originalEnd)).toEqual({ + expect(rewritten.getTransformedRange(source.filename, originalStart, originalEnd)).toEqual({ start: transformedStart, end: transformedEnd, }); }); test('full file bounds', () => { - let originalEnd = rewritten.originalSource.length - 1; - let transformedEnd = rewritten.transformedSource.length - 1; + let originalEnd = source.contents.length - 1; + let transformedEnd = rewritten.transformedContents.length - 1; - expect(rewritten.getOriginalOffset(transformedEnd)).toEqual(originalEnd); + expect(rewritten.getOriginalOffset(transformedEnd)).toEqual({ source, offset: originalEnd }); expect(rewritten.getOriginalRange(0, transformedEnd)).toEqual({ start: 0, end: originalEnd, + source, }); - expect(rewritten.getTransformedOffset(originalEnd)).toEqual(transformedEnd); - expect(rewritten.getTransformedRange(0, originalEnd)).toEqual({ + expect(rewritten.getTransformedOffset(source.filename, originalEnd)).toEqual(transformedEnd); + expect(rewritten.getTransformedRange(source.filename, 0, originalEnd)).toEqual({ start: 0, end: transformedEnd, }); @@ -350,20 +360,22 @@ describe('Source-to-source offset mapping', () => { }); describe('Diagnostic offset mapping', () => { - const originalSourceFile = { fileName: 'original' } as ts.SourceFile; - const transformedSourceFile = { fileName: 'transformed' } as ts.SourceFile; - const source = stripIndent` - import Component, { hbs } from '@glimmerx/component'; - export default class MyComponent extends Component { - static template = hbs\` - {{#each foo as |bar|}} - {{concat bar}} - {{/each}} - \`; - } - `; + const transformedContentsFile = { fileName: 'transformed' } as ts.SourceFile; + const source = { + filename: 'test.ts', + contents: stripIndent` + import Component, { hbs } from '@glimmerx/component'; + export default class MyComponent extends Component { + static template = hbs\` + {{#each foo as |bar|}} + {{concat bar}} + {{/each}} + \`; + } + `, + }; - const transformedModule = rewriteModule('test.ts', source, glimmerxEnvironment); + const transformedModule = rewriteModule({ script: source }, glimmerxEnvironment); assert(transformedModule); test('without related information', () => { @@ -375,19 +387,18 @@ describe('Diagnostic offset mapping', () => { category, code, messageText, - file: transformedSourceFile, - start: transformedModule.transformedSource.indexOf('"foo"'), + file: transformedContentsFile, + start: transformedModule.transformedContents.indexOf('"foo"'), length: 5, }; - let rewritten = rewriteDiagnostic(original, transformedModule, originalSourceFile); + let rewritten = rewriteDiagnostic(ts, original, transformedModule); - expect(rewritten).toEqual({ + expect(rewritten).toMatchObject({ category, code, messageText, - file: originalSourceFile, - start: source.indexOf('foo'), + start: source.contents.indexOf('foo'), length: 3, }); }); @@ -401,38 +412,36 @@ describe('Diagnostic offset mapping', () => { let original: ts.DiagnosticWithLocation = { category, code, - file: transformedSourceFile, - start: transformedModule.transformedSource.indexOf(', bar') + 2, + file: transformedContentsFile, + start: transformedModule.transformedContents.indexOf(', bar') + 2, length: 3, messageText, relatedInformation: [ { category, code, - file: transformedSourceFile, + file: transformedContentsFile, messageText: relatedMessageText, - start: transformedModule.transformedSource.indexOf('(bar)') + 1, + start: transformedModule.transformedContents.indexOf('(bar)') + 1, length: 3, }, ], }; - let rewritten = rewriteDiagnostic(original, transformedModule, originalSourceFile); + let rewritten = rewriteDiagnostic(ts, original, transformedModule); - expect(rewritten).toEqual({ + expect(rewritten).toMatchObject({ category, code, messageText, - file: originalSourceFile, - start: source.indexOf(' bar') + 1, + start: source.contents.indexOf(' bar') + 1, length: 3, relatedInformation: [ { category, code, - file: originalSourceFile, messageText: relatedMessageText, - start: source.indexOf('|bar|') + 1, + start: source.contents.indexOf('|bar|') + 1, length: 3, }, ], diff --git a/packages/transform/__tests__/rewrite.test.ts b/packages/transform/__tests__/rewrite.test.ts index 427c166af..77aeaff97 100644 --- a/packages/transform/__tests__/rewrite.test.ts +++ b/packages/transform/__tests__/rewrite.test.ts @@ -6,17 +6,20 @@ const glimmerxEnvironment = GlintEnvironment.load('glimmerx'); describe('rewriteModule', () => { test('with a simple class', () => { - let code = stripIndent` - import Component, { hbs } from '@glimmerx/component'; - export default class MyComponent extends Component { - static template = hbs\`\`; - } - `; + let script = { + filename: 'test.ts', + contents: stripIndent` + import Component, { hbs } from '@glimmerx/component'; + export default class MyComponent extends Component { + static template = hbs\`\`; + } + `, + }; - let transformedModule = rewriteModule('test.ts', code, glimmerxEnvironment); + let transformedModule = rewriteModule({ script }, glimmerxEnvironment); expect(transformedModule?.errors).toEqual([]); - expect(transformedModule?.transformedSource).toMatchInlineSnapshot(` + expect(transformedModule?.transformedContents).toMatchInlineSnapshot(` "import Component, { hbs } from '@glimmerx/component'; export default class MyComponent extends Component { static template = (() => { @@ -31,17 +34,20 @@ describe('rewriteModule', () => { }); test('with a class with type parameters', () => { - let code = stripIndent` - import Component, { hbs } from '@glimmerx/component'; - export default class MyComponent extends Component<{ value: K }> { - static template = hbs\`\`; - } - `; + let script = { + filename: 'test.ts', + contents: stripIndent` + import Component, { hbs } from '@glimmerx/component'; + export default class MyComponent extends Component<{ value: K }> { + static template = hbs\`\`; + } + `, + }; - let transformedModule = rewriteModule('test.ts', code, glimmerxEnvironment); + let transformedModule = rewriteModule({ script }, glimmerxEnvironment); expect(transformedModule?.errors).toEqual([]); - expect(transformedModule?.transformedSource).toMatchInlineSnapshot(` + expect(transformedModule?.transformedContents).toMatchInlineSnapshot(` "import Component, { hbs } from '@glimmerx/component'; export default class MyComponent extends Component<{ value: K }> { static template = (() => { @@ -56,26 +62,30 @@ describe('rewriteModule', () => { }); test('with an anonymous class', () => { - let code = stripIndent` - import Component, { hbs } from '@glimmerx/component'; - export default class extends Component { - static template = hbs\`\`; - } - `; + let script = { + filename: 'test.ts', + contents: stripIndent` + import Component, { hbs } from '@glimmerx/component'; + export default class extends Component { + static template = hbs\`\`; + } + `, + }; - let transformedModule = rewriteModule('test.ts', code, glimmerxEnvironment); + let transformedModule = rewriteModule({ script }, glimmerxEnvironment); expect(transformedModule?.errors).toEqual([ { message: 'Classes containing templates must have a name', + source: script, location: { - start: code.indexOf('hbs`'), - end: code.lastIndexOf('`') + 1, + start: script.contents.indexOf('hbs`'), + end: script.contents.lastIndexOf('`') + 1, }, }, ]); - expect(transformedModule?.transformedSource).toMatchInlineSnapshot(` + expect(transformedModule?.transformedContents).toMatchInlineSnapshot(` "import Component, { hbs } from '@glimmerx/component'; export default class extends Component { static template = (() => { diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index aabff853b..5936e4aeb 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -5,30 +5,33 @@ import type ts from 'typescript'; import { GlintEnvironment } from '@glint/config'; import { templateToTypescript } from './template-to-typescript'; import { assert } from './util'; -import TransformedModule, { ReplacedSpan, TransformError } from './transformed-module'; +import TransformedModule, { + CorrelatedSpan, + TransformError, + SourceFile, +} from './transformed-module'; export { TransformedModule }; const debug = logger('@glint/compile:mapping'); -type PartialReplacedSpan = Omit; +type PartialCorrelatedSpan = Omit; /** * Given a TypeScript diagnostic object from a module that was rewritten - * by `rewriteModule`, as well as the resulting `TransformedModule` and - * the original un-transformed `SourceFile`, returns a rewritten version - * of that diagnostic that maps to the corresponding location in the - * original source file. + * by `rewriteModule`, as well as the resulting `TransformedModule`, returns + * a rewritten version of that diagnostic that maps to the corresponding + * location in the original source file. */ export function rewriteDiagnostic( + tsImpl: typeof ts, transformedDiagnostic: ts.DiagnosticWithLocation | ts.DiagnosticRelatedInformation, - transformedModule: TransformedModule, - originalSourceFile: ts.SourceFile + transformedModule: TransformedModule ): ts.DiagnosticWithLocation { assert(transformedDiagnostic.start); assert(transformedDiagnostic.length); - let { start, end } = transformedModule.getOriginalRange( + let { start, end, source } = transformedModule.getOriginalRange( transformedDiagnostic.start, transformedDiagnostic.start + transformedDiagnostic.length ); @@ -38,12 +41,12 @@ export function rewriteDiagnostic( ...transformedDiagnostic, start, length, - file: originalSourceFile, + file: tsImpl.createSourceFile(source.filename, source.contents, tsImpl.ScriptTarget.Latest), }; if ('relatedInformation' in transformedDiagnostic && transformedDiagnostic.relatedInformation) { diagnostic.relatedInformation = transformedDiagnostic.relatedInformation.map((relatedInfo) => - rewriteDiagnostic(relatedInfo, transformedModule, originalSourceFile) + rewriteDiagnostic(tsImpl, relatedInfo, transformedModule) ); } @@ -51,22 +54,36 @@ export function rewriteDiagnostic( } /** - * Given the name of a module and its text, returns a `TransformedModule` - * representing that module with any inline templates rewritten into - * equivalent TypeScript using `@glint/template`. + * Input to the process of rewriting a template, containing one or both of: + * script: the backing JS/TS module for a component, which may contain + * embedded templates depending on the environment + * template: a standalone template file + */ +export type RewriteInput = + | { script: SourceFile } + | { template: SourceFile } + | { template: SourceFile; script: SourceFile }; + +/** + * Given the script and/or template that together comprise a component module, + * returns a `TransformedModule` representing the combined result, with the + * template(s), either alongside or inline, rewritten into equivalent TypeScript + * in terms of the active glint environment's exported types. * - * Returns `null` if the given module can't be parsed as TypeScript, or - * if it has no embedded templates. + * May return `null` if an unrecoverable parse error occurs or if there is + * no transformation to be done. */ export function rewriteModule( - filename: string, - source: string, + input: RewriteInput, environment: GlintEnvironment ): TransformedModule | null { - let ast: t.File | t.Program | null = null; + // TODO: handle template-only components + if (!('script' in input)) return null; + + let scriptAST: t.File | t.Program | null = null; try { - ast = parseSync(source, { - filename, + scriptAST = parseSync(input.script.contents, { + filename: input.script.filename, code: false, presets: [require.resolve('@babel/preset-typescript')], plugins: [[require.resolve('@babel/plugin-proposal-decorators'), { legacy: true }]], @@ -75,19 +92,27 @@ export function rewriteModule( // If parsing fails for any reason, we simply return null } - if (!ast) { + if (!scriptAST) { return null; } - let { errors, partialSpans } = calculateSpansForTaggedTemplates(ast, environment); + // TODO: `calculateSpansForTaggedTemplates` should probably become just + // `calculateCorrelatedSpans` and also handle inlining the companion template + // if present. + let { errors, partialSpans } = calculateSpansForTaggedTemplates( + input.script, + scriptAST, + environment + ); + if (!partialSpans.length && !errors.length) { return null; } - let fullSpans = calculateFullReplacedSpans(partialSpans); - let transformedSource = calculateTransformedSource(source, fullSpans); + let sparseSpans = completeCorrelatedSpans(partialSpans); + let { contents, correlatedSpans } = calculateTransformedSource(input.script, sparseSpans); - return new TransformedModule(filename, source, transformedSource, errors, fullSpans); + return new TransformedModule(contents, errors, correlatedSpans); } /** @@ -98,11 +123,12 @@ export function rewriteModule( * string. */ function calculateSpansForTaggedTemplates( + source: SourceFile, ast: t.File | t.Program, environment: GlintEnvironment -): { errors: Array; partialSpans: Array } { +): { errors: Array; partialSpans: Array } { let errors: Array = []; - let partialSpans: Array = []; + let partialSpans: Array = []; traverse(ast, { TaggedTemplateExpression(path) { @@ -136,6 +162,7 @@ function calculateSpansForTaggedTemplates( if (!contextType) { errors.push({ + source, message: 'Classes containing templates must have a name', location: { start: path.node.start, @@ -147,6 +174,7 @@ function calculateSpansForTaggedTemplates( for (let { message, location } of transformedTemplate.errors) { if (location) { errors.push({ + source, message, location: { start: path.node.start + location.start, @@ -158,6 +186,7 @@ function calculateSpansForTaggedTemplates( assert(path.node.tag.end, 'Missing location info'); errors.push({ + source, message, location: { start: path.node.tag.start, @@ -174,6 +203,7 @@ function calculateSpansForTaggedTemplates( } partialSpans.push({ + originalFile: source, originalStart: path.node.start, originalLength: path.node.end - path.node.start, transformedSource: code, @@ -201,31 +231,64 @@ function determineTypesPathForTag( } /** - * Given a `ReplacedSpan` array and the original source for a module, - * returns the resulting full transformed source string for that module. + * Given a sparse `CorrelatedSpan` array and the original source for a module, + * returns the resulting full transformed source string for that module, as + * well as a filled-in array of correlated spans that includes chunks of the + * original source that were not transformed. */ -function calculateTransformedSource(originalSource: string, spans: ReplacedSpan[]): string { - let segments = []; - let totalOffset = 0; - - for (let replacedSpan of spans) { - segments.push(originalSource.slice(totalOffset, replacedSpan.originalStart)); - segments.push(replacedSpan.transformedSource); - totalOffset = replacedSpan.originalStart + replacedSpan.originalLength; +function calculateTransformedSource( + originalFile: SourceFile, + sparseSpans: Array +): { contents: string; correlatedSpans: Array } { + let correlatedSpans: Array = []; + let originalOffset = 0; + let transformedOffset = 0; + + for (let span of sparseSpans) { + let interstitial = originalFile.contents.slice(originalOffset, span.originalStart); + + correlatedSpans.push({ + originalFile, + originalStart: originalOffset, + originalLength: interstitial.length, + transformedStart: transformedOffset, + transformedLength: interstitial.length, + transformedSource: interstitial, + }); + + correlatedSpans.push(span); + + transformedOffset += interstitial.length + span.transformedLength; + originalOffset += + interstitial.length + (span.originalFile === originalFile ? span.originalLength : 0); } - segments.push(originalSource.slice(totalOffset)); + let trailingContent = originalFile.contents.slice(originalOffset); - return segments.join(''); + correlatedSpans.push({ + originalFile, + originalStart: originalOffset, + originalLength: trailingContent.length + 1, + transformedStart: transformedOffset, + transformedLength: trailingContent.length + 1, + transformedSource: trailingContent, + }); + + return { + contents: correlatedSpans.map((span) => span.transformedSource).join(''), + correlatedSpans, + }; } /** - * Given an array of `PartialReplacedSpan`s for a file, calculates + * Given an array of `PartialCorrelatedSpan`s for a file, calculates * their `transformedLength` and `transformedStart` values, resulting * in full `ReplacedSpan`s. */ -function calculateFullReplacedSpans(partialSpans: Array): Array { - let replacedSpans: Array = []; +function completeCorrelatedSpans( + partialSpans: Array +): Array { + let replacedSpans: Array = []; for (let i = 0; i < partialSpans.length; i++) { let current = partialSpans[i]; diff --git a/packages/transform/src/mapping-tree.ts b/packages/transform/src/mapping-tree.ts index 5618a88d5..e21afbac1 100644 --- a/packages/transform/src/mapping-tree.ts +++ b/packages/transform/src/mapping-tree.ts @@ -98,6 +98,6 @@ export default class MappingTree { lines.push(indent); } - return lines.join('\n'); + return lines.map((line) => line.trimEnd()).join('\n'); } } diff --git a/packages/transform/src/transformed-module.ts b/packages/transform/src/transformed-module.ts index 792db4fec..24af1dd51 100644 --- a/packages/transform/src/transformed-module.ts +++ b/packages/transform/src/transformed-module.ts @@ -1,19 +1,35 @@ import MappingTree from './mapping-tree'; +import { assert } from './util'; export type Range = { start: number; end: number }; export type RangeWithMapping = Range & { mapping?: MappingTree }; -export type ReplacedSpan = { +export type RangeWithMappingAndSource = RangeWithMapping & { source: SourceFile }; +export type CorrelatedSpan = { + /** Where this span of content originated */ + originalFile: SourceFile; + /** The offset where this content began in its original source */ originalStart: number; + /** The length of this span's content in its original source */ originalLength: number; + /** The contents of this span in the transformed output */ + transformedSource: string; + /** The offset in the transformed output of this span */ transformedStart: number; + /** The length of this span in the transformed output */ transformedLength: number; - transformedSource: string; - mapping: MappingTree; + /** A mapping of offsets within this span between its original and transformed versions */ + mapping?: MappingTree; }; export type TransformError = { message: string; location: Range; + source: SourceFile; +}; + +export type SourceFile = { + filename: string; + contents: string; }; /** @@ -28,73 +44,85 @@ export type TransformError = { */ export default class TransformedModule { public constructor( - public readonly filename: string, - public readonly originalSource: string, - public readonly transformedSource: string, + public readonly transformedContents: string, public readonly errors: ReadonlyArray, - private readonly replacedSpans: Array + private readonly correlatedSpans: Array ) {} public toDebugString(): string { - let mappingStrings = this.replacedSpans.map((span) => - span.mapping.toDebugString( - this.originalSource.slice(span.originalStart, span.originalStart + span.originalLength), + let mappingStrings = this.correlatedSpans.map((span) => + span.mapping?.toDebugString( + span.originalFile.contents.slice( + span.originalStart, + span.originalStart + span.originalLength + ), span.transformedSource ) ); - return `TransformedModule ${this.filename}\n\n${mappingStrings.join('\n\n')}`; + return `TransformedModule\n\n${mappingStrings.filter(Boolean).join('\n\n')}`; } - public getOriginalOffset(transformedOffset: number): number { - return this.getOriginalRange(transformedOffset, transformedOffset).start; + public getOriginalOffset(transformedOffset: number): { source?: SourceFile; offset: number } { + let { start, source } = this.getOriginalRange(transformedOffset, transformedOffset); + return { source, offset: start }; } - public getTransformedOffset(originalOffset: number): number { - return this.getTransformedRange(originalOffset, originalOffset).start; + public getTransformedOffset(originalFileName: string, originalOffset: number): number { + return this.getTransformedRange(originalFileName, originalOffset, originalOffset).start; } - public getOriginalRange(transformedStart: number, transformedEnd: number): RangeWithMapping { + public getOriginalRange( + transformedStart: number, + transformedEnd: number + ): RangeWithMappingAndSource { let startInfo = this.determineOriginalOffsetAndSpan(transformedStart); let endInfo = this.determineOriginalOffsetAndSpan(transformedEnd); + assert(startInfo.correlatedSpan.originalFile === endInfo.correlatedSpan.originalFile); + + let source = startInfo.correlatedSpan.originalFile; let start = startInfo.originalOffset; let end = endInfo.originalOffset; - if (startInfo.replacedSpan && startInfo.replacedSpan === endInfo.replacedSpan) { - let { replacedSpan } = startInfo; - let mapping = replacedSpan?.mapping?.narrowestMappingForTransformedRange({ - start: start - replacedSpan.originalStart, - end: end - replacedSpan.originalStart, + if (startInfo.correlatedSpan === endInfo.correlatedSpan) { + let { correlatedSpan } = startInfo; + let mapping = correlatedSpan.mapping?.narrowestMappingForTransformedRange({ + start: start - correlatedSpan.originalStart, + end: end - correlatedSpan.originalStart, }); - if (replacedSpan && mapping) { - let start = replacedSpan.originalStart + mapping.originalRange.start; - let end = replacedSpan.originalStart + mapping.originalRange.end; - return { mapping, start, end }; + if (mapping) { + let start = correlatedSpan.originalStart + mapping.originalRange.start; + let end = correlatedSpan.originalStart + mapping.originalRange.end; + return { mapping, start, end, source }; } } - return { start, end }; + return { start, end, source }; } - public getTransformedRange(originalStart: number, originalEnd: number): RangeWithMapping { - let startInfo = this.determineTransformedOffsetAndSpan(originalStart); - let endInfo = this.determineTransformedOffsetAndSpan(originalEnd); + public getTransformedRange( + originalFileName: string, + originalStart: number, + originalEnd: number + ): RangeWithMapping { + let startInfo = this.determineTransformedOffsetAndSpan(originalFileName, originalStart); + let endInfo = this.determineTransformedOffsetAndSpan(originalFileName, originalEnd); let start = startInfo.transformedOffset; let end = endInfo.transformedOffset; - if (startInfo.replacedSpan && startInfo.replacedSpan === endInfo.replacedSpan) { - let { replacedSpan } = startInfo; - let mapping = replacedSpan?.mapping?.narrowestMappingForOriginalRange({ - start: start - replacedSpan.transformedStart, - end: end - replacedSpan.transformedStart, + if (startInfo.correlatedSpan && startInfo.correlatedSpan === endInfo.correlatedSpan) { + let { correlatedSpan } = startInfo; + let mapping = correlatedSpan.mapping?.narrowestMappingForOriginalRange({ + start: start - correlatedSpan.transformedStart, + end: end - correlatedSpan.transformedStart, }); - if (replacedSpan && mapping) { - let start = replacedSpan.transformedStart + mapping.transformedRange.start; - let end = replacedSpan.transformedStart + mapping.transformedRange.end; + if (mapping) { + let start = correlatedSpan.transformedStart + mapping.transformedRange.start; + let end = correlatedSpan.transformedStart + mapping.transformedRange.end; return { mapping, start, end }; } } @@ -104,35 +132,39 @@ export default class TransformedModule { private determineOriginalOffsetAndSpan( transformedOffset: number - ): { originalOffset: number; replacedSpan?: ReplacedSpan } { - let originalOffset = transformedOffset; - for (let span of this.replacedSpans) { + ): { originalOffset: number; correlatedSpan: CorrelatedSpan } { + for (let span of this.correlatedSpans) { if ( - originalOffset >= span.originalStart && - originalOffset <= span.originalStart + span.transformedSource.length + transformedOffset >= span.transformedStart && + transformedOffset < span.transformedStart + span.transformedLength ) { - return { originalOffset, replacedSpan: span }; - } else if (originalOffset > span.originalStart) { - originalOffset -= span.transformedSource.length - span.originalLength; + return { + originalOffset: transformedOffset - span.transformedStart + span.originalStart, + correlatedSpan: span, + }; } } - return { originalOffset }; + + assert(false, 'Internal error: offset out of bounds'); } private determineTransformedOffsetAndSpan( + originalFileName: string, originalOffset: number - ): { transformedOffset: number; replacedSpan?: ReplacedSpan } { - let transformedOffset = originalOffset; - for (let span of this.replacedSpans) { + ): { transformedOffset: number; correlatedSpan: CorrelatedSpan } { + for (let span of this.correlatedSpans) { if ( + span.originalFile.filename === originalFileName && originalOffset >= span.originalStart && - originalOffset <= span.originalStart + span.originalLength + originalOffset < span.originalStart + span.originalLength ) { - return { transformedOffset, replacedSpan: span }; - } else if (originalOffset > span.originalStart) { - transformedOffset += span.transformedSource.length - span.originalLength; + return { + transformedOffset: originalOffset - span.originalStart + span.transformedStart, + correlatedSpan: span, + }; } } - return { transformedOffset }; + + assert(false, 'Internal error: offset out of bounds'); } } diff --git a/packages/tsserver-plugin/src/language-service.ts b/packages/tsserver-plugin/src/language-service.ts index ce4f2e583..d537c1a7f 100644 --- a/packages/tsserver-plugin/src/language-service.ts +++ b/packages/tsserver-plugin/src/language-service.ts @@ -77,13 +77,10 @@ export default class GlintLanguageService implements Partial public getSemanticDiagnostics(fileName: string): ts.Diagnostic[] { const info = this.getTransformInfoForOriginalPath(fileName); - const originalSourceFile = this.ls.getProgram()?.getSourceFile(fileName); - if (info && originalSourceFile) { + if (info) { return this.ls .getSemanticDiagnostics(info.transformedPath) - .map((diagnostic) => - rewriteDiagnostic(diagnostic, info.transformedModule, originalSourceFile) - ); + .map((diagnostic) => rewriteDiagnostic(this.ts, diagnostic, info.transformedModule)); } return this.ls.getSemanticDiagnostics(fileName); @@ -91,13 +88,10 @@ export default class GlintLanguageService implements Partial public getSuggestionDiagnostics(fileName: string): ts.DiagnosticWithLocation[] { const info = this.getTransformInfoForOriginalPath(fileName); - const originalSourceFile = this.ls.getProgram()?.getSourceFile(fileName); - if (info && originalSourceFile) { + if (info) { return this.ls .getSuggestionDiagnostics(info.transformedPath) - .map((diagnostic) => - rewriteDiagnostic(diagnostic, info.transformedModule, originalSourceFile) - ); + .map((diagnostic) => rewriteDiagnostic(this.ts, diagnostic, info.transformedModule)); } return this.ls.getSuggestionDiagnostics(fileName); @@ -131,7 +125,7 @@ export default class GlintLanguageService implements Partial let info = this.getTransformInfoForOriginalPath(fileName); let result: ts.WithMetadata | undefined; if (info) { - let range = info.transformedModule.getTransformedRange(offset, offset); + let range = info.transformedModule.getTransformedRange(info.originalPath, offset, offset); // If we're within a freeform text area in the template, don't attempt to autocomplete at all let containingNode = range.mapping?.sourceNode; @@ -180,7 +174,10 @@ export default class GlintLanguageService implements Partial ): ts.CompletionEntryDetails | undefined { let info = this.getTransformInfoForOriginalPath(fileName); if (info) { - let transformedOffset = info.transformedModule.getTransformedOffset(offset); + let transformedOffset = info.transformedModule.getTransformedOffset( + info.originalPath, + offset + ); let details = this.ls.getCompletionEntryDetails( info.transformedPath, transformedOffset, @@ -218,7 +215,10 @@ export default class GlintLanguageService implements Partial ): ts.Symbol | undefined { let info = this.getTransformInfoForOriginalPath(fileName); if (info) { - let transformedOffset = info.transformedModule.getTransformedOffset(offset); + let transformedOffset = info.transformedModule.getTransformedOffset( + info.originalPath, + offset + ); return this.ls.getCompletionEntrySymbol( info.transformedPath, transformedOffset, @@ -234,7 +234,10 @@ export default class GlintLanguageService implements Partial let info = this.getTransformInfoForOriginalPath(fileName); let quickInfo: ts.QuickInfo | undefined; if (info) { - let transformedOffset = info.transformedModule.getTransformedOffset(offset); + let transformedOffset = info.transformedModule.getTransformedOffset( + info.originalPath, + offset + ); quickInfo = this.ls.getQuickInfoAtPosition(info.transformedPath, transformedOffset); if (quickInfo) { @@ -270,7 +273,10 @@ export default class GlintLanguageService implements Partial ): ts.RenameInfo { let info = this.getTransformInfoForOriginalPath(fileName); if (info) { - let transformedOffset = info.transformedModule.getTransformedOffset(offset); + let transformedOffset = info.transformedModule.getTransformedOffset( + info.originalPath, + offset + ); let result = this.ls.getRenameInfo(info.transformedPath, transformedOffset, options); this.logger.log('getRenameInfo result before', result); if (result.canRename) { @@ -340,15 +346,18 @@ export default class GlintLanguageService implements Partial if (isTransformablePath(def.fileName)) { let info = this.getTransformInfoForOriginalPath(def.fileName); if (info) { - let transformedOffset = info.transformedModule.getTransformedOffset(def.textSpan.start); + let transformedOffset = info.transformedModule.getTransformedOffset( + info.originalPath, + def.textSpan.start + ); let result = callback(info.transformedPath, transformedOffset) ?? []; results.push(...result); } } else if (isTransformedPath(def.fileName)) { let info = this.getTransformInfoForTransformedPath(def.fileName); if (info) { - let originalOffset = info.transformedModule.getOriginalOffset(def.textSpan.start); - let result = callback(info.originalPath, originalOffset) ?? []; + let original = info.transformedModule.getOriginalOffset(def.textSpan.start); + let result = callback(info.originalPath, original.offset) ?? []; results.push(...result); } } @@ -374,7 +383,8 @@ export default class GlintLanguageService implements Partial let info = this.getTransformInfoForOriginalPath(fileName); let result: readonly ts.DefinitionInfo[] | undefined; if (info) { - let transformedPosition = info.transformedModule.getTransformedOffset(offset) + 1; + let transformedPosition = + info.transformedModule.getTransformedOffset(info.originalPath, offset) + 1; result = this.ls.getDefinitionAtPosition(info.transformedPath, transformedPosition); } else { result = this.ls.getDefinitionAtPosition(fileName, offset); @@ -392,7 +402,10 @@ export default class GlintLanguageService implements Partial let info = this.getTransformInfoForOriginalPath(fileName); let result: ts.DefinitionInfoAndBoundSpan | undefined; if (info) { - let transformedOffset = info.transformedModule.getTransformedOffset(offset); + let transformedOffset = info.transformedModule.getTransformedOffset( + info.originalPath, + offset + ); result = this.ls.getDefinitionAndBoundSpan(info.transformedPath, transformedOffset); } else { result = this.ls.getDefinitionAndBoundSpan(fileName, offset); diff --git a/packages/tsserver-plugin/src/virtual-module-manager.ts b/packages/tsserver-plugin/src/virtual-module-manager.ts index 3a55ffa45..9f18b8c59 100644 --- a/packages/tsserver-plugin/src/virtual-module-manager.ts +++ b/packages/tsserver-plugin/src/virtual-module-manager.ts @@ -45,16 +45,17 @@ export default class VirtualModuleManager { let glintEnvironment = configuredProject.config.environment; let snapshot = originalScriptInfo?.getSnapshot(); let length = snapshot?.getLength() ?? 0; - let content = snapshot?.getText(0, length) ?? this.sysReadFile(originalPath, encoding); + let contents = snapshot?.getText(0, length) ?? this.sysReadFile(originalPath, encoding); - if (!content || !glintEnvironment.moduleMayHaveTagImports(content)) { - return content; + if (!contents || !glintEnvironment.moduleMayHaveTagImports(contents)) { + return contents; } - let transformedModule = rewriteModule(originalPath, content, glintEnvironment); + let script = { filename: originalPath, contents }; + let transformedModule = rewriteModule({ script }, glintEnvironment); if (transformedModule && originalScriptInfo) { this.transformedModules.set(originalScriptInfo, transformedModule); - return transformedModule.transformedSource; + return transformedModule.transformedContents; } if (originalScriptInfo) {