UNPKG

@glint/core

Version:

A CLI for performing typechecking on Glimmer templates

229 lines 10.2 kB
import * as path from 'node:path'; import { assert } from '../util.js'; import TransformedModule from './transformed-module.js'; import { calculateTaggedTemplateSpans } from './inlining/tagged-strings.js'; import { calculateCompanionTemplateSpans } from './inlining/companion-file.js'; /** * 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. * * May return `null` if an unrecoverable parse error occurs or if there is * no transformation to be done. */ export function rewriteModule(ts, { script, template }, environment) { let { errors, directives, partialSpans } = calculateCorrelatedSpans(ts, script, template, environment); if (!partialSpans.length && !errors.length) { return null; } let sparseSpans = completeCorrelatedSpans(partialSpans); let { contents, correlatedSpans } = calculateTransformedSource(script, sparseSpans); globalThis.GLINT_DEBUG_IR?.(script.filename, contents); return new TransformedModule(contents, errors, directives, correlatedSpans); } /** * Locates any embedded templates in the given AST and returns a corresponding * `PartialReplacedSpan` for each, as well as any errors encountered. These * spans are then used in `rewriteModule` above to calculate the full set of * source-to-source location information as well as the final transformed source * string. */ function calculateCorrelatedSpans(ts, script, template, environment) { let directives = []; let errors = []; let partialSpans = []; let { ast, emitMetadata, error } = parseScript(ts, script, environment); if (error) { if (typeof error === 'string') { errors.push({ message: error, location: { start: 0, end: script.contents.length - 1 }, source: script, }); } else if ('isContentTagError' in error && error.isContentTagError) { // these lines exclude the line with the error, because // adding the column offset will get us on to the line with the error let lines = script.contents.split('\n').slice(0, error.line); let start = lines.reduce((sum, line) => sum + line.length, 0) + error.column - 1; let end = start + 1; errors.push({ isContentTagError: true, // we have to show the "help" because content-tag has different line numbers // than we are able to discern ourselves. message: error.message + '\n\n' + error.help, source: script, location: { start, end, }, }); } // We've hit a parsing error, so we need to immediately return as the parsed // document must be correct before we can continue. return { errors, directives, partialSpans }; } ts.transform(ast, [ (context) => function visit(node) { if (ts.isTaggedTemplateExpression(node)) { let meta = emitMetadata.get(node); let result = calculateTaggedTemplateSpans(ts, node, meta, script, environment); directives.push(...result.directives); errors.push(...result.errors); partialSpans.push(...result.partialSpans); } else if (ts.isModuleDeclaration(node)) { // don't traverse into declare module return node; } return ts.visitEachChild(node, visit, context); }, ]); if (template) { let result = calculateCompanionTemplateSpans(ts, ast, script, template, environment); directives.push(...result.directives); errors.push(...result.errors); partialSpans.push(...result.partialSpans); } return { errors, directives, partialSpans }; } function parseScript(ts, script, environment) { let { filename, contents } = script; let extension = path.extname(filename); let emitMetadata = new WeakMap(); let setEmitMetadata = (node, data) => void emitMetadata.set(node, Object.assign(emitMetadata.get(node) ?? {}, data)); let { preprocess, transform } = environment.getConfigForExtension(extension) ?? {}; let original = { contents, data: { templateLocations: [] } }; let preprocessed = original; let error; try { preprocessed = preprocess?.(contents, filename) ?? original; } catch (e) { error = parseError(e, filename); } let ast = ts.createSourceFile(filename, // contents will be transformed and placeholder'd content // or, in the event of a parse error, the original content // in which case, TS will report a ton of errors about some goofy syntax. // We'll want to ignore all of that and only display our parsing error from content-tag. preprocessed.contents, ts.ScriptTarget.Latest, true // setParentNodes ); // Only transform if we don't have a parse error if (!error && transform) { let { transformed } = ts.transform(ast, [ (context) => transform(preprocessed.data, { ts, context, setEmitMetadata }), ]); assert(transformed.length === 1 && ts.isSourceFile(transformed[0])); ast = transformed[0]; } return { ast, emitMetadata, error }; } function parseError(e, filename) { if (typeof e === 'object' && e !== null) { // Parse Errors from the rust parser if ('source_code' in e) { // We remove the blank links in the error because swc // pads errors with a leading and trailing blank line. // the error is typically meant for the terminal, so making it // stand out a bit more is a good, but that's more a presentation // concern than just pure error information (which is what we need). // @ts-expect-error object / property narrowing isn't available until TS 5.1 let lines = e.source_code.split('\n').filter(Boolean); // Example: // ' × Unexpected eof' // ' ╭─[/home/nullvoxpopuli/Development/OpenSource/glint/test-packages/ts-template-imports-app/src/index.gts:6:1]' // ' 6 │ ' // ' 7 │ export const X = <tem' // ' ╰────' let raw = lines.join('\n'); let message = lines[0].replace('×', '').trim(); let info = lines[1]; // a filename may have numbers in it, so we want to remove the filename // before regex searching for numbers at the end of this line let strippedInfo = info.replace(filename, ''); let matches = [...strippedInfo.matchAll(/\d+/g)]; let line = parseInt(matches[0][0], 10); let column = parseInt(matches[1][0], 10); // The help omits the original file name, because TS will provide that. let help = lines.slice(2).join('\n'); return { isContentTagError: true, raw, message, line, column, file: filename, help, }; } } return `${e}`; } /** * 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(originalFile, sparseSpans) { let correlatedSpans = []; let originalOffset = 0; let transformedOffset = 0; for (let span of sparseSpans) { let interstitial = originalFile.contents.slice(originalOffset, span.insertionPoint); if (interstitial.length) { correlatedSpans.push({ originalFile, originalStart: originalOffset, originalLength: interstitial.length, insertionPoint: originalOffset, transformedStart: transformedOffset, transformedLength: interstitial.length, transformedSource: interstitial, }); } correlatedSpans.push(span); transformedOffset += interstitial.length + span.transformedLength; originalOffset += interstitial.length + (span.originalFile === originalFile ? span.originalLength : 0); } let trailingContent = originalFile.contents.slice(originalOffset); correlatedSpans.push({ originalFile, originalStart: originalOffset, originalLength: trailingContent.length + 1, insertionPoint: originalOffset, transformedStart: transformedOffset, transformedLength: trailingContent.length + 1, transformedSource: trailingContent, }); return { contents: correlatedSpans.map((span) => span.transformedSource).join(''), correlatedSpans, }; } /** * Given an array of `PartialCorrelatedSpan`s for a file, calculates * their `transformedLength` and `transformedStart` values, resulting * in full `ReplacedSpan`s. */ function completeCorrelatedSpans(partialSpans) { let replacedSpans = []; for (let i = 0; i < partialSpans.length; i++) { let current = partialSpans[i]; let transformedLength = current.transformedSource.length; let transformedStart = current.insertionPoint; if (i > 0) { let previous = replacedSpans[i - 1]; transformedStart = previous.transformedStart + previous.transformedSource.length + (current.insertionPoint - previous.insertionPoint - previous.originalLength); } replacedSpans.push({ ...current, transformedStart, transformedLength }); } return replacedSpans; } //# sourceMappingURL=rewrite-module.js.map