From 5d5f66bfc971472bdfd66cd69709fad1dc17d8bb Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Thu, 9 Jan 2025 11:37:30 -0500 Subject: [PATCH 1/4] wip beginning codemod render tests support --- packages/template-tag-codemod/src/cli.ts | 6 ++++++ packages/template-tag-codemod/src/index.ts | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/template-tag-codemod/src/cli.ts b/packages/template-tag-codemod/src/cli.ts index b91585e83..b781ffc61 100644 --- a/packages/template-tag-codemod/src/cli.ts +++ b/packages/template-tag-codemod/src/cli.ts @@ -37,6 +37,12 @@ yargs(process.argv.slice(2)) default: optionsWithDefaults().components, describe: `Controls which component files we will convert to template tag. Provide a list of globs.`, }) + .option('inlineTemplates', { + array: true, + type: 'string', + default: optionsWithDefaults().inlineTemplates, + describe: `Controls the files in which we will search for inline templates to replace with template tags. Provide a list of globs.`, + }) .option('defaultFormat', { type: 'string', default: optionsWithDefaults().defaultFormat, diff --git a/packages/template-tag-codemod/src/index.ts b/packages/template-tag-codemod/src/index.ts index 15abc7b5c..991ba8a78 100644 --- a/packages/template-tag-codemod/src/index.ts +++ b/packages/template-tag-codemod/src/index.ts @@ -33,6 +33,10 @@ export interface Options { // list of globs of the components we should convert components?: string[]; + // list of globs for JS/TS files that we will check for rendering tests to + // update to template-tag + renderTests?: string[]; + // when a .js or .ts file already exists, we necessarily convert to .gjs or // .gts respectively. But when only an .hbs file exists, we have a choice of // default. @@ -57,6 +61,7 @@ export function optionsWithDefaults(options?: Options): OptionsWithDefaults { nativeRouteTemplates: true, routeTemplates: ['app/templates/**/*.hbs'], components: ['app/components/**/*.{js,ts,hbs}'], + renderTests: ['tests/**/*.{js,ts}'], defaultFormat: 'gjs', routeTemplateSignature: `{ Args: { model: unknown, controller: unknown } }`, templateOnlyComponentSignature: `{ Args: {} }`, @@ -475,10 +480,19 @@ function renderScopeImports(scope: MetaResult['scope']) { .join('\n'); } +export async function processRenderTests(opts: OptionsWithDefaults): Promise { + for (let pattern of opts.renderTests) { + for (let filename of globSync(pattern)) { + console.log(`todo: process render tests ${filename}`); + } + } +} + export async function run(partialOpts: Options) { let opts = optionsWithDefaults(partialOpts); await ensureAppSetup(); await ensurePrebuild(); await processRouteTemplates(opts); await processComponents(opts); + await processRenderTests(opts); } From 229ac1c9324f8da524b74b696eeab99e9d140946 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 15 Jan 2025 18:18:23 -0500 Subject: [PATCH 2/4] progress --- packages/template-tag-codemod/package.json | 7 ++ packages/template-tag-codemod/src/cli.ts | 6 +- .../template-tag-codemod/src/extract-meta.ts | 17 ++++- .../src/identify-render-tests.ts | 74 ++++++++++++++++++ packages/template-tag-codemod/src/index.ts | 75 +++++++++++++++---- pnpm-lock.yaml | 50 ++++++++++++- 6 files changed, 206 insertions(+), 23 deletions(-) create mode 100644 packages/template-tag-codemod/src/identify-render-tests.ts diff --git a/packages/template-tag-codemod/package.json b/packages/template-tag-codemod/package.json index 7d3e2b035..a17768ff0 100644 --- a/packages/template-tag-codemod/package.json +++ b/packages/template-tag-codemod/package.json @@ -24,7 +24,9 @@ ], "scripts": {}, "dependencies": { + "@babel/code-frame": "^7.26.2", "@babel/core": "^7.26.0", + "@babel/generator": "^7.26.5", "@babel/plugin-syntax-decorators": "^7.25.9", "@babel/plugin-syntax-typescript": "^7.25.9", "@embroider/compat": "workspace:^*", @@ -32,16 +34,21 @@ "@embroider/reverse-exports": "workspace:^*", "@types/babel__core": "^7.20.5", "@types/yargs": "^17.0.3", + "babel-import-util": "^3.0.0", "babel-plugin-ember-template-compilation": "^2.3.0", "broccoli": "^3.5.2", "console-ui": "^3.1.2", "ember-cli": "^6.0.1", "glob": "^11.0.0", + "lodash": "^4.17.21", "yargs": "^17.0.1" }, "devDependencies": { "@glimmer/syntax": "^0.84.3", + "@types/babel__code-frame": "^7.0.6", + "@types/babel__generator": "^7.6.8", "@types/glob": "^8.1.0", + "@types/lodash": "^4.17.14", "@types/node": "^22.9.3", "typescript": "^5.4.5" }, diff --git a/packages/template-tag-codemod/src/cli.ts b/packages/template-tag-codemod/src/cli.ts index b781ffc61..68176d0d1 100644 --- a/packages/template-tag-codemod/src/cli.ts +++ b/packages/template-tag-codemod/src/cli.ts @@ -37,11 +37,11 @@ yargs(process.argv.slice(2)) default: optionsWithDefaults().components, describe: `Controls which component files we will convert to template tag. Provide a list of globs.`, }) - .option('inlineTemplates', { + .option('renderTests', { array: true, type: 'string', - default: optionsWithDefaults().inlineTemplates, - describe: `Controls the files in which we will search for inline templates to replace with template tags. Provide a list of globs.`, + default: optionsWithDefaults().renderTests, + describe: `Controls the files in which we will search for rendering tests to convert to template tags. Provide a list of globs.`, }) .option('defaultFormat', { type: 'string', diff --git a/packages/template-tag-codemod/src/extract-meta.ts b/packages/template-tag-codemod/src/extract-meta.ts index 1ba8fdf3e..46ebb590f 100644 --- a/packages/template-tag-codemod/src/extract-meta.ts +++ b/packages/template-tag-codemod/src/extract-meta.ts @@ -1,4 +1,5 @@ import type * as Babel from '@babel/core'; +import { transformAsync } from '@babel/core'; export interface MetaResult { templateSource: string; @@ -12,7 +13,7 @@ export interface MetaResult { >; } -export interface ExtractMetaOpts { +interface ExtractMetaOpts { result: MetaResult | undefined; } @@ -21,7 +22,7 @@ export interface ExtractMetaOpts { necessary that it covers every possible way of expressing a template in javascript */ -export default function extractMetaPlugin(_babel: typeof Babel): Babel.PluginObj<{ opts: ExtractMetaOpts }> { +function extractMetaPlugin(_babel: typeof Babel): Babel.PluginObj<{ opts: ExtractMetaOpts }> { return { visitor: { CallExpression(path, state) { @@ -102,3 +103,15 @@ export default function extractMetaPlugin(_babel: typeof Babel): Babel.PluginObj }, }; } + +export async function extractMeta(source: string, filename: string) { + const meta: ExtractMetaOpts = { result: undefined }; + await transformAsync(source, { + filename, + plugins: [[extractMetaPlugin, meta]], + }); + if (!meta.result) { + throw new Error(`failed to extract metadata while processing ${filename}`); + } + return meta.result; +} diff --git a/packages/template-tag-codemod/src/identify-render-tests.ts b/packages/template-tag-codemod/src/identify-render-tests.ts new file mode 100644 index 000000000..e937fcd94 --- /dev/null +++ b/packages/template-tag-codemod/src/identify-render-tests.ts @@ -0,0 +1,74 @@ +import { type NodePath, parseAsync, traverse, type types } from '@babel/core'; +import { createRequire } from 'module'; +import codeFrame from '@babel/code-frame'; + +const require = createRequire(import.meta.url); +const { codeFrameColumns } = codeFrame; + +export interface RenderTest { + node: types.Node; + startIndex: number; + endIndex: number; + templateContent: string; +} + +export async function identifyRenderTests( + source: string, + filename: string +): Promise<{ renderTests: RenderTest[]; parsed: types.File }> { + let renderTests: RenderTest[] = []; + let parsed = await parseAsync(source, { + filename, + plugins: [ + [require.resolve('@babel/plugin-syntax-decorators'), { legacy: true }], + require.resolve('@babel/plugin-syntax-typescript'), + ], + }); + + if (!parsed) { + throw new Error(`bug, unexpected output from babel parseAsync`); + } + + function fail(node: types.Node, message: string) { + let m = `[${filename}] ${message}`; + if (node.loc) { + m = m + '\n' + codeFrameColumns(source, node.loc); + } + return new Error(m); + } + + traverse(parsed, { + CallExpression(path) { + if (path.get('callee').referencesImport('@ember/test-helpers', 'render')) { + let [arg0] = path.get('arguments'); + if (arg0.isTaggedTemplateExpression()) { + let tag = arg0.get('tag'); + if (isLooseHBS(tag)) { + let loc = arg0.node.loc; + if (!loc) { + throw new Error(`bug: no locations provided by babel`); + } + renderTests.push({ + node: arg0.node, + startIndex: loc.start.index, + endIndex: loc.end.index, + templateContent: arg0.node.quasi.quasis[0].value.raw, + }); + } + } else { + throw fail(arg0.node, `unsupported syntax in rendering test (${arg0.type})`); + } + } + }, + }); + return { renderTests, parsed }; +} + +function isLooseHBS(path: NodePath) { + if (path.isReferencedIdentifier()) { + if (path.referencesImport('ember-cli-htmlbars', 'hbs')) { + return true; + } + } + return false; +} diff --git a/packages/template-tag-codemod/src/index.ts b/packages/template-tag-codemod/src/index.ts index 991ba8a78..f12f815ae 100644 --- a/packages/template-tag-codemod/src/index.ts +++ b/packages/template-tag-codemod/src/index.ts @@ -1,18 +1,25 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; import { globSync } from 'glob'; import core, { type Package } from '@embroider/core'; -import { parseAsync, transformAsync, type types } from '@babel/core'; +import { traverse, parseAsync, transformAsync, type types } from '@babel/core'; +import * as babel from '@babel/core'; import templateCompilation, { type Options as EtcOptions } from 'babel-plugin-ember-template-compilation'; import { createRequire } from 'module'; -import extractMeta, { type ExtractMetaOpts, type MetaResult } from './extract-meta.js'; +import { extractMeta, type MetaResult } from './extract-meta.js'; import reverseExports from '@embroider/reverse-exports'; import { dirname } from 'path'; import type { ResolverTransformOptions } from '@embroider/compat'; import { routeTemplateTransform } from './route-template-transform.js'; +import { identifyRenderTests } from './identify-render-tests.js'; +import { ImportUtil } from 'babel-import-util'; +import lodash from 'lodash'; +import type * as babelGenerator from '@babel/generator'; const { explicitRelative, hbsToJS, ResolverLoader } = core; const { externalName } = reverseExports; +const { cloneDeep } = lodash; const require = createRequire(import.meta.url); +const { default: generate } = require('@babel/generator') as typeof babelGenerator; export interface Options { // when true, imports for other files in the same project will use relative @@ -117,10 +124,10 @@ const resolutions = new Map { - let src = readFileSync(filename, 'utf8'); let strictSource = (await transformAsync(hbsToJS(src), { filename, plugins: [ @@ -146,20 +153,13 @@ export async function inspectContents( ], }))!.code!; - const meta: ExtractMetaOpts = { result: undefined }; - await transformAsync(strictSource, { - filename, - plugins: [[extractMeta, meta]], - }); - if (!meta.result) { - throw new Error(`failed to extract metadata while processing ${filename}`); - } - let { templateSource, scope } = await resolveImports(filename, meta.result, opts); + const meta = await extractMeta(strictSource, filename); + let { templateSource, scope } = await resolveImports(filename, meta, opts); return { templateSource, scope }; } export async function processRouteTemplate(filename: string, opts: OptionsWithDefaults): Promise { - let { templateSource, scope } = await inspectContents(filename, true, opts); + let { templateSource, scope } = await inspectContents(filename, readFileSync(filename, 'utf8'), true, opts); let outSource: string[] = []; @@ -266,7 +266,7 @@ export async function processComponent( jsPath: string | undefined, opts: OptionsWithDefaults ): Promise { - let { templateSource, scope } = await inspectContents(hbsPath, false, opts); + let { templateSource, scope } = await inspectContents(hbsPath, readFileSync(hbsPath, 'utf8'), false, opts); if (jsPath) { let src = await renderJsComponent(templateSource, scope, jsPath, opts); @@ -483,11 +483,56 @@ function renderScopeImports(scope: MetaResult['scope']) { export async function processRenderTests(opts: OptionsWithDefaults): Promise { for (let pattern of opts.renderTests) { for (let filename of globSync(pattern)) { - console.log(`todo: process render tests ${filename}`); + await processRenderTest(filename, opts); } } } +export async function processRenderTest(filename: string, opts: OptionsWithDefaults): Promise { + let src = readFileSync(filename, 'utf8'); + let { parsed, renderTests } = await identifyRenderTests(src, filename); + if (renderTests.length === 0) { + return; + } + + let inspectedTests = await Promise.all( + renderTests.map(async target => { + let { templateSource, scope } = await inspectContents(filename, target.templateContent, false, opts); + return { templateSource, scope, node: target.node }; + }) + ); + + let original = cloneDeep(parsed); + let importUtil: ImportUtil; + + traverse(parsed, { + Program(path) { + importUtil = new ImportUtil(babel, path); + }, + TaggedTemplateExpression(path) { + let matched = inspectedTests.find(t => t.node === path.node); + if (matched) { + for (let [inTemplateName, { imported, module }] of matched.scope) { + let name = importUtil.import(path, module, imported, inTemplateName); + if (name.name !== inTemplateName) { + throw new Error('unimplemented'); + } + } + } + }, + }); + let imports = parsed.program.body + .filter(b => b.type === 'ImportDeclaration') + .map(d => generate(d).code) + .join('\n'); + console.log(imports); + + // NEXT: identity source ranges in `original` of all the import statements. + // Replace them all with our new imports above. + // + // Then do the string insertion of the actual template tags as well. +} + export async function run(partialOpts: Options) { let opts = optionsWithDefaults(partialOpts); await ensureAppSetup(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1caf5ceb6..63dfda57b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -817,9 +817,15 @@ importers: packages/template-tag-codemod: dependencies: + '@babel/code-frame': + specifier: ^7.26.2 + version: 7.26.2 '@babel/core': specifier: ^7.26.0 version: 7.26.0 + '@babel/generator': + specifier: ^7.26.5 + version: 7.26.5 '@babel/plugin-syntax-decorators': specifier: ^7.25.9 version: 7.25.9(@babel/core@7.26.0) @@ -841,6 +847,9 @@ importers: '@types/yargs': specifier: ^17.0.3 version: 17.0.33 + babel-import-util: + specifier: ^3.0.0 + version: 3.0.0 babel-plugin-ember-template-compilation: specifier: ^2.3.0 version: 2.3.0 @@ -856,6 +865,9 @@ importers: glob: specifier: ^11.0.0 version: 11.0.0 + lodash: + specifier: ^4.17.21 + version: 4.17.21 yargs: specifier: ^17.0.1 version: 17.7.2 @@ -863,9 +875,18 @@ importers: '@glimmer/syntax': specifier: ^0.84.3 version: 0.84.3 + '@types/babel__code-frame': + specifier: ^7.0.6 + version: 7.0.6 + '@types/babel__generator': + specifier: ^7.6.8 + version: 7.6.8 '@types/glob': specifier: ^8.1.0 version: 8.1.0 + '@types/lodash': + specifier: ^4.17.14 + version: 4.17.14 '@types/node': specifier: ^22.9.3 version: 22.10.5 @@ -2504,6 +2525,16 @@ packages: '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 + /@babel/generator@7.26.5: + resolution: {integrity: sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.1.0 + /@babel/helper-annotate-as-pure@7.25.9: resolution: {integrity: sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==} engines: {node: '>=6.9.0'} @@ -2767,6 +2798,13 @@ packages: dependencies: '@babel/types': 7.26.3 + /@babel/parser@7.26.5: + resolution: {integrity: sha512-SRJ4jYmXRqV1/Xc+TIVG84WjHBXKlxO9sHQnA2Pf12QQEAp1LOh6kDzNHXcUnbH1QI0FDoPPVOt+vyUDucxpaw==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.26.5 + /@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9(@babel/core@7.26.0): resolution: {integrity: sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==} engines: {node: '>=6.9.0'} @@ -4131,6 +4169,13 @@ packages: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + /@babel/types@7.26.5: + resolution: {integrity: sha512-L6mZmwFDK6Cjh1nRCLXpa6no13ZIioJDz7mdkzHv399pThrTa/k0nUlNaenOeh2kWu/iaOQYElEpKPUswUa9Vg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + /@bcoe/v8-coverage@0.2.3: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true @@ -7113,7 +7158,7 @@ packages: '@glimmer/component': 1.1.2(@babel/core@7.26.0) '@glint/template': 1.5.1 ember-cli-htmlbars: 6.3.0 - ember-modifier: 4.2.0(@babel/core@7.26.0)(ember-source@5.12.0) + ember-modifier: 4.2.0(@babel/core@7.26.0)(ember-source@5.3.0) dev: true /@glint/environment-ember-template-imports@1.5.1(@glint/environment-ember-loose@1.5.1)(@glint/template@1.5.1): @@ -8429,7 +8474,6 @@ packages: /@types/babel__code-frame@7.0.6: resolution: {integrity: sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==} - dev: false /@types/babel__core@7.20.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -20597,7 +20641,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@babel/core': 7.26.0 - '@babel/generator': 7.26.3 + '@babel/generator': 7.26.5 '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0) '@babel/types': 7.26.3 From 82b9484198c413f8caccfbd7c3844b8674637535 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Thu, 16 Jan 2025 14:32:36 -0500 Subject: [PATCH 3/4] progress on rendering test codemod --- packages/template-tag-codemod/package.json | 2 - packages/template-tag-codemod/src/index.ts | 104 +++++++++++++++++---- pnpm-lock.yaml | 8 +- 3 files changed, 87 insertions(+), 27 deletions(-) diff --git a/packages/template-tag-codemod/package.json b/packages/template-tag-codemod/package.json index a17768ff0..d5ce10e5c 100644 --- a/packages/template-tag-codemod/package.json +++ b/packages/template-tag-codemod/package.json @@ -40,7 +40,6 @@ "console-ui": "^3.1.2", "ember-cli": "^6.0.1", "glob": "^11.0.0", - "lodash": "^4.17.21", "yargs": "^17.0.1" }, "devDependencies": { @@ -48,7 +47,6 @@ "@types/babel__code-frame": "^7.0.6", "@types/babel__generator": "^7.6.8", "@types/glob": "^8.1.0", - "@types/lodash": "^4.17.14", "@types/node": "^22.9.3", "typescript": "^5.4.5" }, diff --git a/packages/template-tag-codemod/src/index.ts b/packages/template-tag-codemod/src/index.ts index f12f815ae..d7c34e916 100644 --- a/packages/template-tag-codemod/src/index.ts +++ b/packages/template-tag-codemod/src/index.ts @@ -10,16 +10,13 @@ import reverseExports from '@embroider/reverse-exports'; import { dirname } from 'path'; import type { ResolverTransformOptions } from '@embroider/compat'; import { routeTemplateTransform } from './route-template-transform.js'; -import { identifyRenderTests } from './identify-render-tests.js'; +import { identifyRenderTests, type RenderTest } from './identify-render-tests.js'; import { ImportUtil } from 'babel-import-util'; -import lodash from 'lodash'; -import type * as babelGenerator from '@babel/generator'; const { explicitRelative, hbsToJS, ResolverLoader } = core; const { externalName } = reverseExports; -const { cloneDeep } = lodash; const require = createRequire(import.meta.url); -const { default: generate } = require('@babel/generator') as typeof babelGenerator; +const { default: generate } = require('@babel/generator'); export interface Options { // when true, imports for other files in the same project will use relative @@ -122,12 +119,17 @@ export async function processRouteTemplates(opts: OptionsWithDefaults) { const resolverLoader = new ResolverLoader(process.cwd()); const resolutions = new Map(); +export interface InspectedTemplate { + templateSource: string; + scope: MetaResult['scope']; +} + export async function inspectContents( filename: string, src: string, isRouteTemplate: boolean, opts: OptionsWithDefaults -): Promise<{ templateSource: string; scope: MetaResult['scope'] }> { +): Promise { let strictSource = (await transformAsync(hbsToJS(src), { filename, plugins: [ @@ -488,6 +490,19 @@ export async function processRenderTests(opts: OptionsWithDefaults): Promise { + return await Promise.all( + renderTests.map(async target => { + let { templateSource, scope } = await inspectContents(filename, target.templateContent, false, opts); + return { ...target, templateSource, scope }; + }) + ); +} + export async function processRenderTest(filename: string, opts: OptionsWithDefaults): Promise { let src = readFileSync(filename, 'utf8'); let { parsed, renderTests } = await identifyRenderTests(src, filename); @@ -495,14 +510,23 @@ export async function processRenderTest(filename: string, opts: OptionsWithDefau return; } - let inspectedTests = await Promise.all( - renderTests.map(async target => { - let { templateSource, scope } = await inspectContents(filename, target.templateContent, false, opts); - return { templateSource, scope, node: target.node }; - }) - ); + let edits = deleteImports(parsed); + let inspectedTests = await inspectTests(filename, renderTests, opts); + edits.unshift({ start: 0, end: 0, replacement: mergeImports(parsed, inspectedTests) }); + for (let test of inspectedTests) { + edits.push({ + start: test.startIndex, + end: test.endIndex, + replacement: '', + }); + } + let newSrc = applyEdits(src, edits); + writeFileSync(filename.replace(/\.js$/, '.gjs').replace(/\.ts$/, '.gts'), newSrc); + unlinkSync(filename); + console.log(`render test: ${filename} `); +} - let original = cloneDeep(parsed); +function mergeImports(parsed: types.File, inspectedTests: (InspectedTemplate & { node: types.Node })[]) { let importUtil: ImportUtil; traverse(parsed, { @@ -525,12 +549,56 @@ export async function processRenderTest(filename: string, opts: OptionsWithDefau .filter(b => b.type === 'ImportDeclaration') .map(d => generate(d).code) .join('\n'); - console.log(imports); + return imports; +} + +function deleteImports(parsed: types.File): Edit[] { + let edits: Edit[] = []; + traverse(parsed, { + ImportDeclaration(path) { + let loc = path.node.loc; + if (!loc) { + throw new Error(`bug: babel not producing source locations`); + } + edits.push({ + start: loc.start.index, + end: loc.end.index, + replacement: null, + }); + }, + }); + return edits; +} + +interface Edit { + start: number; + end: number; + replacement: string | null; +} - // NEXT: identity source ranges in `original` of all the import statements. - // Replace them all with our new imports above. - // - // Then do the string insertion of the actual template tags as well. +function applyEdits(source: string, edits: { start: number; end: number; replacement: string | null }[]): string { + let cursor = 0; + let output: string[] = []; + let previousDeletion = false; + for (let { start, end, replacement } of edits) { + if (start > cursor) { + let interEditContent = source.slice(cursor, start); + if (previousDeletion && replacement === null && /^\s*$/.test(interEditContent)) { + // drop whitespace in between two other deletions + } else { + output.push(interEditContent); + } + } + if (replacement === null) { + previousDeletion = true; + } else { + previousDeletion = false; + output.push(replacement); + } + cursor = end; + } + output.push(source.slice(cursor)); + return output.join(''); } export async function run(partialOpts: Options) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63dfda57b..bbbe6ca6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -865,9 +865,6 @@ importers: glob: specifier: ^11.0.0 version: 11.0.0 - lodash: - specifier: ^4.17.21 - version: 4.17.21 yargs: specifier: ^17.0.1 version: 17.7.2 @@ -884,9 +881,6 @@ importers: '@types/glob': specifier: ^8.1.0 version: 8.1.0 - '@types/lodash': - specifier: ^4.17.14 - version: 4.17.14 '@types/node': specifier: ^22.9.3 version: 22.10.5 @@ -7158,7 +7152,7 @@ packages: '@glimmer/component': 1.1.2(@babel/core@7.26.0) '@glint/template': 1.5.1 ember-cli-htmlbars: 6.3.0 - ember-modifier: 4.2.0(@babel/core@7.26.0)(ember-source@5.3.0) + ember-modifier: 4.2.0(@babel/core@7.26.0)(ember-source@5.12.0) dev: true /@glint/environment-ember-template-imports@1.5.1(@glint/environment-ember-loose@1.5.1)(@glint/template@1.5.1): From f1bc0439eb771793688ac315690c6a5cce584114 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Fri, 17 Jan 2025 16:21:03 -0500 Subject: [PATCH 4/4] adding nativeLexicalThis=false mode --- packages/template-tag-codemod/src/cli.ts | 5 +++ .../src/identify-render-tests.ts | 16 ++++++++ packages/template-tag-codemod/src/index.ts | 39 +++++++++++++++---- ...transform.ts => replace-this-transform.ts} | 5 ++- 4 files changed, 56 insertions(+), 9 deletions(-) rename packages/template-tag-codemod/src/{route-template-transform.ts => replace-this-transform.ts} (61%) diff --git a/packages/template-tag-codemod/src/cli.ts b/packages/template-tag-codemod/src/cli.ts index 68176d0d1..40dea02f6 100644 --- a/packages/template-tag-codemod/src/cli.ts +++ b/packages/template-tag-codemod/src/cli.ts @@ -25,6 +25,11 @@ yargs(process.argv.slice(2)) type: 'boolean', describe: `When true, assume we can use template-tag directly in route files (requires ember-source >= 6.3.0-beta.3). When false, assume we can use the ember-route-template addon instead.`, }) + .option('nativeLexicalThis', { + default: optionsWithDefaults().nativeLexicalThis, + type: 'boolean', + describe: `When true, assume that Ember supports accessing the lexically-scoped "this" from template-tags that are used as expressions (requires ember-source >= TODO). When false, introduce a new local variable to make "this" accessible.`, + }) .option('routeTemplates', { array: true, type: 'string', diff --git a/packages/template-tag-codemod/src/identify-render-tests.ts b/packages/template-tag-codemod/src/identify-render-tests.ts index e937fcd94..f2e86457d 100644 --- a/packages/template-tag-codemod/src/identify-render-tests.ts +++ b/packages/template-tag-codemod/src/identify-render-tests.ts @@ -10,6 +10,8 @@ export interface RenderTest { startIndex: number; endIndex: number; templateContent: string; + statementStart: number; + availableBinding: string; } export async function identifyRenderTests( @@ -48,11 +50,25 @@ export async function identifyRenderTests( if (!loc) { throw new Error(`bug: no locations provided by babel`); } + + let counter = 0; + let availableBinding = 'self'; + while (path.scope.getBinding(availableBinding)) { + availableBinding = `self${counter++}`; + } + + let statementCandidate: NodePath = path; + while (!statementCandidate.isStatement()) { + statementCandidate = statementCandidate.parentPath; + } + renderTests.push({ node: arg0.node, startIndex: loc.start.index, endIndex: loc.end.index, templateContent: arg0.node.quasi.quasis[0].value.raw, + statementStart: statementCandidate.node.loc!.start.index, + availableBinding, }); } } else { diff --git a/packages/template-tag-codemod/src/index.ts b/packages/template-tag-codemod/src/index.ts index d7c34e916..63f16c206 100644 --- a/packages/template-tag-codemod/src/index.ts +++ b/packages/template-tag-codemod/src/index.ts @@ -9,7 +9,7 @@ import { extractMeta, type MetaResult } from './extract-meta.js'; import reverseExports from '@embroider/reverse-exports'; import { dirname } from 'path'; import type { ResolverTransformOptions } from '@embroider/compat'; -import { routeTemplateTransform } from './route-template-transform.js'; +import { replaceThisTransform } from './replace-this-transform.js'; import { identifyRenderTests, type RenderTest } from './identify-render-tests.js'; import { ImportUtil } from 'babel-import-util'; @@ -31,6 +31,13 @@ export interface Options { // Otherwise, assume we're using the ember-route-template addon. nativeRouteTemplates?: boolean; + // when true, assume we can use + // https://github.com/emberjs/babel-plugin-ember-template-compilation/pull/67. + // This is mostly useful when codemodding rendering tests, which often access + // `{{this.stuff}}` in a template. When false, polyfill that behavior by + // introducing a new local variable. + nativeLexicalThis?: boolean; + // list of globs of the route templates we should convert. routeTemplates?: string[]; @@ -63,6 +70,7 @@ export function optionsWithDefaults(options?: Options): OptionsWithDefaults { relativeLocalPaths: true, extensions: ['.gts', '.gjs', '.ts', '.js', '.hbs'], nativeRouteTemplates: true, + nativeLexicalThis: true, routeTemplates: ['app/templates/**/*.hbs'], components: ['app/components/**/*.{js,ts,hbs}'], renderTests: ['tests/**/*.{js,ts}'], @@ -122,14 +130,16 @@ const resolutions = new Map { + let replaced = { didReplace: false }; let strictSource = (await transformAsync(hbsToJS(src), { filename, plugins: [ @@ -148,7 +158,7 @@ export async function inspectContents( }, } satisfies ResolverTransformOptions, ], - ...(isRouteTemplate ? [routeTemplateTransform()] : []), + ...(replaceThisWith ? [replaceThisTransform(replaceThisWith, replaced)] : []), ], } satisfies EtcOptions, ], @@ -157,11 +167,11 @@ export async function inspectContents( const meta = await extractMeta(strictSource, filename); let { templateSource, scope } = await resolveImports(filename, meta, opts); - return { templateSource, scope }; + return { templateSource, scope, replacedThisWith: replaced.didReplace ? replaceThisWith : false }; } export async function processRouteTemplate(filename: string, opts: OptionsWithDefaults): Promise { - let { templateSource, scope } = await inspectContents(filename, readFileSync(filename, 'utf8'), true, opts); + let { templateSource, scope } = await inspectContents(filename, readFileSync(filename, 'utf8'), '@controller', opts); let outSource: string[] = []; @@ -497,8 +507,13 @@ async function inspectTests( ): Promise<(InspectedTemplate & RenderTest)[]> { return await Promise.all( renderTests.map(async target => { - let { templateSource, scope } = await inspectContents(filename, target.templateContent, false, opts); - return { ...target, templateSource, scope }; + let { templateSource, scope, replacedThisWith } = await inspectContents( + filename, + target.templateContent, + opts.nativeLexicalThis ? false : target.availableBinding, + opts + ); + return { ...target, templateSource, scope, replacedThisWith }; }) ); } @@ -514,6 +529,15 @@ export async function processRenderTest(filename: string, opts: OptionsWithDefau let inspectedTests = await inspectTests(filename, renderTests, opts); edits.unshift({ start: 0, end: 0, replacement: mergeImports(parsed, inspectedTests) }); for (let test of inspectedTests) { + if (!opts.nativeLexicalThis) { + if (test.replacedThisWith) { + edits.push({ + start: test.statementStart, + end: test.statementStart, + replacement: `const ${test.replacedThisWith} = this;\n`, + }); + } + } edits.push({ start: test.startIndex, end: test.endIndex, @@ -532,6 +556,7 @@ function mergeImports(parsed: types.File, inspectedTests: (InspectedTemplate & { traverse(parsed, { Program(path) { importUtil = new ImportUtil(babel, path); + importUtil.removeImport('ember-cli-htmlbars', 'hbs'); }, TaggedTemplateExpression(path) { let matched = inspectedTests.find(t => t.node === path.node); diff --git a/packages/template-tag-codemod/src/route-template-transform.ts b/packages/template-tag-codemod/src/replace-this-transform.ts similarity index 61% rename from packages/template-tag-codemod/src/route-template-transform.ts rename to packages/template-tag-codemod/src/replace-this-transform.ts index 5df1117b5..e93cad808 100644 --- a/packages/template-tag-codemod/src/route-template-transform.ts +++ b/packages/template-tag-codemod/src/replace-this-transform.ts @@ -1,13 +1,14 @@ import type { ASTPluginBuilder } from '@glimmer/syntax'; -export function routeTemplateTransform(): ASTPluginBuilder { +export function replaceThisTransform(replacement: string, result: { didReplace: boolean }): ASTPluginBuilder { const transform: ASTPluginBuilder = ({ syntax: { builders } }) => { return { name: 'template-tag-codemod-route-template', visitor: { PathExpression(node) { if (node.head.type === 'ThisHead') { - return builders.path(`@controller.${node.tail.join('.')}`); + result.didReplace = true; + return builders.path(`${replacement}.${node.tail.join('.')}`); } }, },