diff --git a/packages/macros/package.json b/packages/macros/package.json index 8669f69ff..5cd94562c 100644 --- a/packages/macros/package.json +++ b/packages/macros/package.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@babel/core": "^7.14.5", + "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-modules-amd": "^7.19.6", "@babel/traverse": "^7.14.5", "@embroider/core": "workspace:*", diff --git a/packages/macros/src/babel/app-ember-satisfies.ts b/packages/macros/src/babel/app-ember-satisfies.ts new file mode 100644 index 000000000..88700f39c --- /dev/null +++ b/packages/macros/src/babel/app-ember-satisfies.ts @@ -0,0 +1,96 @@ +import type { NodePath } from '@babel/traverse'; +import type { types as t } from '@babel/core'; +import type State from './state'; +import { satisfies, coerce } from 'semver'; +import error from './error'; +import { assertArray } from './evaluate-json'; +import { pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; +import { dirname } from 'node:path'; +import findUp from 'find-up'; + +const packageName = 'ember-source'; +const CACHE = new Map(); +/** + * NOTE: Since there will only ever be one app ember version, we can cache the result of looking it up. + * (partly to save disk i/o) + */ +function getAppEmberVersion(state: State): string | false { + let appRoot = state.packageCache.appRoot; + + if (CACHE.has(appRoot)) { + return CACHE.get(appRoot)!; + } + + let root = state.packageCache.get(appRoot); + + if (!root?.hasDependency(packageName)) { + CACHE.set(appRoot, false); + return false; + } + + /** + * This version can, and often is a range (^6.4.0), + * and using a range for the first parameter of satisfies will cause a failure to always occur. + * So we must resolve the actual version on disk. + */ + let resolvedInfo = state.packageCache.resolve(packageName, root); + let version = resolvedInfo.version; + /** + * But, if the version is "clean", we can avoid a disk hit + * (which is helpful for corporate machines which intercept every disk i/o behavior) + */ + let cleanedVersion = String(coerce(version, { includePrerelease: true })); + + /** + * these are the same, so we don't need to ask the disk what was installed + */ + if (cleanedVersion === version) { + CACHE.set(appRoot, version); + return version; + } + + const appURL = pathToFileURL(appRoot); + const require = createRequire(appURL); + const emberSourceEntry = require.resolve(packageName, { + paths: [appRoot], + }); + const emberSourceManifestPath = findUp.sync('package.json', { cwd: dirname(emberSourceEntry) }); + + if (!emberSourceManifestPath) { + throw new Error(`We resolved an ember-source package, but could not find its package.json`); + } + const emberSourceManifest = require(emberSourceManifestPath); + + CACHE.set(appRoot, emberSourceManifest.version); + return emberSourceManifest.version; +} + +export default function appEmberSatisfies(path: NodePath, state: State): boolean { + if (path.node.arguments.length !== 1) { + throw error(path, `appEmberSatisfies takes exactly one argument, you passed ${path.node.arguments.length}`); + } + const [range] = path.node.arguments; + if (range.type !== 'StringLiteral') { + throw error( + assertArray(path.get('arguments'))[0], + `the only argument to appEmberSatisfies must be a string literal` + ); + } + try { + let appEmberVersion = getAppEmberVersion(state); + + if (!appEmberVersion) { + return false; + } + + return satisfies(appEmberVersion, range.value, { + includePrerelease: true, + }); + } catch (err) { + if (err.code !== 'MODULE_NOT_FOUND') { + throw err; + } + return false; + } +} diff --git a/packages/macros/src/babel/evaluate-json.ts b/packages/macros/src/babel/evaluate-json.ts index e7f5d6667..1fb9f1e3c 100644 --- a/packages/macros/src/babel/evaluate-json.ts +++ b/packages/macros/src/babel/evaluate-json.ts @@ -3,6 +3,7 @@ import type * as Babel from '@babel/core'; import type { types as t } from '@babel/core'; import type State from './state'; import dependencySatisfies from './dependency-satisfies'; +import appEmberSatisfies from './app-ember-satisfies'; import moduleExists from './module-exists'; import getConfig from './get-config'; import assertNever from 'assert-never'; @@ -385,6 +386,9 @@ export class Evaluator { return { confident: false }; } let callee = path.get('callee'); + if (callee.referencesImport('@embroider/macros', 'appEmberSatisfies')) { + return { confident: true, value: appEmberSatisfies(path, this.state), hasRuntimeImplementation: false }; + } if (callee.referencesImport('@embroider/macros', 'dependencySatisfies')) { return { confident: true, value: dependencySatisfies(path, this.state), hasRuntimeImplementation: false }; } diff --git a/packages/macros/src/babel/macros-babel-plugin.ts b/packages/macros/src/babel/macros-babel-plugin.ts index 65388cb20..42710a577 100644 --- a/packages/macros/src/babel/macros-babel-plugin.ts +++ b/packages/macros/src/babel/macros-babel-plugin.ts @@ -198,6 +198,7 @@ export default function main(context: typeof Babel): unknown { ReferencedIdentifier(path: NodePath, state: State) { for (let candidate of [ 'dependencySatisfies', + 'appEmberSatisfies', 'moduleExists', 'getConfig', 'getOwnConfig', diff --git a/packages/macros/src/glimmer/app-ember-satisfies.ts b/packages/macros/src/glimmer/app-ember-satisfies.ts new file mode 100644 index 000000000..83352aafb --- /dev/null +++ b/packages/macros/src/glimmer/app-ember-satisfies.ts @@ -0,0 +1,35 @@ +import { satisfies } from 'semver'; +import type { RewrittenPackageCache } from '@embroider/shared-internals'; + +const packageName = 'ember-source'; + +export default function appEmberSatisfies(node: any, packageCache: RewrittenPackageCache) { + if (node.params.length !== 1) { + throw new Error(`macroAppEmberSatisfies requires only one argument, you passed ${node.params.length}`); + } + + if (!node.params.every((p: any) => p.type === 'StringLiteral')) { + throw new Error(`all arguments to macroAppEmberSatisfies must be string literals`); + } + + let root = packageCache.get(packageCache.appRoot); + let range = node.params[0].value; + + if (!root?.hasDependency(packageName)) { + return false; + } + + let pkg; + try { + pkg = packageCache.resolve(packageName, root); + } catch (err) { + // it's not an error if we can't resolve it, we just don't satisfy it. + } + + if (pkg) { + return satisfies(pkg.version, range, { + includePrerelease: true, + }); + } + return false; +} diff --git a/packages/macros/src/glimmer/ast-transform.ts b/packages/macros/src/glimmer/ast-transform.ts index c13341834..eeb397af6 100644 --- a/packages/macros/src/glimmer/ast-transform.ts +++ b/packages/macros/src/glimmer/ast-transform.ts @@ -1,5 +1,6 @@ import literal from './literal'; import getConfig from './get-config'; +import appEmberSatisfies from './app-ember-satisfies'; import dependencySatisfies from './dependency-satisfies'; import { maybeAttrs } from './macro-maybe-attrs'; import { @@ -106,6 +107,15 @@ export function makeFirstTransform(opts: FirstTransformParams) { } return staticValue; } + if (node.path.original === 'macroAppEmberSatisfies') { + const staticValue = literal(appEmberSatisfies(node, packageCache), env.syntax.builders); + // If this is a macro expression by itself, then turn it into a macroCondition for the second pass to prune. + // Otherwise assume it's being composed with another macro and evaluate it as a literal + if (walker.parent.node.path.original === 'if') { + return env.syntax.builders.sexpr('macroCondition', [staticValue]); + } + return staticValue; + } }, MustacheStatement(node: any) { if (node.path.type !== 'PathExpression') { @@ -136,6 +146,9 @@ export function makeFirstTransform(opts: FirstTransformParams) { literal(dependencySatisfies(node, opts.packageRoot, moduleName, packageCache), env.syntax.builders) ); } + if (node.path.original === 'macroAppEmberSatisfies') { + return env.syntax.builders.mustache(literal(appEmberSatisfies(node, packageCache), env.syntax.builders)); + } }, }, }; diff --git a/packages/macros/src/index.ts b/packages/macros/src/index.ts index 7fde9f946..7129bc1ec 100644 --- a/packages/macros/src/index.ts +++ b/packages/macros/src/index.ts @@ -20,6 +20,10 @@ export function dependencySatisfies(packageName: string, semverRange: string): b throw new Oops(packageName, semverRange); } +export function appEmberSatisfies(semverRange: string): boolean { + throw new Oops(semverRange); +} + export function macroCondition(predicate: boolean): boolean { throw new Oops(predicate); } @@ -92,6 +96,10 @@ export interface EmbroiderMacrosRegistry { Args: { Positional: Parameters }; Return: ReturnType; }>; + macroAppEmberSatisfies: HelperLike<{ + Args: { Positional: Parameters }; + Return: ReturnType; + }>; macroMaybeAttrs: HelperLike<{ Args: { Positional: [predicate: boolean, ...bareAttrs: unknown[]] }; Return: void; diff --git a/packages/macros/tests/babel/app-ember-satisfies.test.ts b/packages/macros/tests/babel/app-ember-satisfies.test.ts new file mode 100644 index 000000000..8e675dad9 --- /dev/null +++ b/packages/macros/tests/babel/app-ember-satisfies.test.ts @@ -0,0 +1,171 @@ +import { allBabelVersions, runDefault } from '@embroider/test-support'; +import { Project } from 'scenario-tester'; +import { join, dirname } from 'node:path'; +import { buildMacros } from '../../src/babel'; + +const ROOT = process.cwd(); + +export function baseV2Addon() { + return Project.fromDir(dirname(require.resolve('../../../../tests/v2-addon-template/package.json')), { + linkDeps: true, + }); +} + +export function fakeEmber(version: string) { + const project = baseV2Addon(); + + project.name = 'ember-source'; + project.version = version; + + return project; +} + +describe(`appEmberSatisfies`, function () { + let project: Project; + + beforeEach(() => { + project = new Project('test-app'); + }); + + afterEach(() => { + project?.dispose(); + process.chdir(ROOT); + }); + + allBabelVersions({ + includePresetsTests: true, + babelConfig() { + project.write(); + + let config = buildMacros({ + dir: project.baseDir, + }); + + return { + filename: join(project.baseDir, 'sample.js'), + plugins: config.babelMacros, + }; + }, + + createTests(transform) { + test('is satisfied (app specifies exact version)', () => { + project.addDependency('ember-source', '4.11.0'); + let code = transform(` + import { appEmberSatisfies } from '@embroider/macros'; + export default function() { + return appEmberSatisfies('^4.11.0'); + } + `); + expect(runDefault(code)).toBe(true); + }); + + test('is satisfied (app specifies caret version)', () => { + project.addDependency(fakeEmber('4.12.0')); + project.pkg.dependencies ||= {}; + project.pkg.dependencies['ember-source'] = '^4.11.0'; + + let code = transform(` + import { appEmberSatisfies } from '@embroider/macros'; + export default function() { + return appEmberSatisfies('^4.11.0'); + } + `); + expect(runDefault(code)).toBe(true); + }); + + test('is not satisfied', () => { + project.addDependency('ember-source', '2.9.0'); + let code = transform(` + import { appEmberSatisfies } from '@embroider/macros'; + export default function() { + return appEmberSatisfies('^10.0.0'); + } + `); + expect(runDefault(code)).toBe(false); + }); + + test('is not present', () => { + let code = transform(` + import { appEmberSatisfies } from '@embroider/macros'; + export default function() { + return appEmberSatisfies('^10.0.0'); + } + `); + expect(runDefault(code)).toBe(false); + }); + + test('import gets removed', () => { + let code = transform(` + import { appEmberSatisfies } from '@embroider/macros'; + export default function() { + return appEmberSatisfies('1'); + } + `); + expect(code).not.toMatch(/appEmberSatisfies/); + }); + + test('entire import statement gets removed', () => { + let code = transform(` + import { appEmberSatisfies } from '@embroider/macros'; + export default function() { + return appEmberSatisfies('*'); + } + `); + expect(code).not.toMatch(/appEmberSatisfies/); + expect(code).not.toMatch(/@embroider\/macros/); + }); + + test('unused import gets removed', () => { + let code = transform(` + import { appEmberSatisfies } from '@embroider/macros'; + export default function() { + return 1; + } + `); + expect(code).not.toMatch(/appEmberSatisfies/); + expect(code).not.toMatch(/@embroider\/macros/); + }); + + test('non call error', () => { + expect(() => { + transform(` + import { appEmberSatisfies } from '@embroider/macros'; + let x = appEmberSatisfies; + `); + }).toThrow(/You can only use appEmberSatisfies as a function call/); + }); + + test('args length error', () => { + expect(() => { + transform(` + import { appEmberSatisfies } from '@embroider/macros'; + appEmberSatisfies('foo', 'bar', 'baz'); + `); + }).toThrow(/appEmberSatisfies takes exactly one argument, you passed 3/); + }); + + test('non literal arg error', () => { + expect(() => { + transform(` + import { appEmberSatisfies } from '@embroider/macros'; + let range = '*'; + appEmberSatisfies(range); + `); + }).toThrow(/the only argument to appEmberSatisfies must be a string literal/); + }); + + test('it considers prereleases (otherwise within the range) as allowed', () => { + project.addDependency('ember-source', '1.1.0-beta.1'); + let code = transform( + ` + import { appEmberSatisfies } from '@embroider/macros'; + export default function() { + return appEmberSatisfies('^1.0.0'); + } + ` + ); + expect(runDefault(code)).toBe(true); + }); + }, + }); +}); diff --git a/packages/macros/tests/glimmer/app-ember-satisfies.test.ts b/packages/macros/tests/glimmer/app-ember-satisfies.test.ts new file mode 100644 index 000000000..4f2953c31 --- /dev/null +++ b/packages/macros/tests/glimmer/app-ember-satisfies.test.ts @@ -0,0 +1,88 @@ +import { Project, templateTests } from './helpers'; +import { join } from 'path'; + +describe('app ember dependency satisfies (prerelease)', () => { + let project: Project; + let filename: string; + + beforeAll(async () => { + project = new Project('app'); + project.addDependency('ember-source', '1.2.0-beta.1'); + await project.write(); + filename = join(project.baseDir, 'sample.js'); + }); + + afterAll(() => { + project?.dispose(); + }); + + templateTests(originalTransform => { + function transform(text: string): Promise { + return originalTransform(text, { filename, appRoot: project.baseDir }); + } + + test('it considers prereleases (otherwise within the range) as allowed', async () => { + let result = await transform(`{{macroAppEmberSatisfies '^1.0.0'}}`); + expect(result).toEqual('{{true}}'); + }); + }); +}); + +describe('app ember dependency satisfies (released)', () => { + let project: Project; + let filename: string; + + beforeAll(async () => { + project = new Project('app'); + project.addDependency('ember-source', '2.9.1'); + await project.write(); + filename = join(project.baseDir, 'sample.js'); + }); + + afterAll(() => { + project?.dispose(); + }); + + templateTests(originalTransform => { + function transform(text: string): Promise { + return originalTransform(text, { filename, appRoot: project.baseDir }); + } + + test('in content position', async () => { + let result = await transform(`{{macroAppEmberSatisfies '^2.8.0'}}`); + expect(result).toEqual('{{true}}'); + }); + + test('in subexpression position', async () => { + let result = await transform(``); + expect(result).toMatch(/@a=\{\{true\}\}/); + }); + + test('in branch', async () => { + let result = await transform(`{{#if (macroAppEmberSatisfies '^2.8.0')}}red{{else}}blue{{/if}}`); + expect(result).toEqual('red'); + }); + + test('emits false for out-of-range package', async () => { + let result = await transform(`{{macroAppEmberSatisfies '^10.0.0'}}`); + expect(result).toEqual('{{false}}'); + }); + + test('emits false for missing package', async () => { + let result = await transform(`{{macroAppEmberSatisfies '^10.0.0'}}`); + expect(result).toEqual('{{false}}'); + }); + + test('args length error', async () => { + await expect(async () => { + await transform(`{{macroAppEmberSatisfies 'not-a-real-dep' 'another'}}`); + }).rejects.toThrow(/macroAppEmberSatisfies requires only one argument, you passed 2/); + }); + + test('non literal arg error', async () => { + await expect(async () => { + await transform(`{{macroAppEmberSatisfies someDep }}`); + }).rejects.toThrow(/all arguments to macroAppEmberSatisfies must be string literals/); + }); + }); +}); diff --git a/packages/macros/tests/glimmer/dependency-satisfies.test.ts b/packages/macros/tests/glimmer/dependency-satisfies.test.ts index cde62714c..38ab8b8d2 100644 --- a/packages/macros/tests/glimmer/dependency-satisfies.test.ts +++ b/packages/macros/tests/glimmer/dependency-satisfies.test.ts @@ -1,4 +1,3 @@ -import type { TemplateTransformOptions } from './helpers'; import { Project, templateTests } from './helpers'; import { join } from 'path'; @@ -18,7 +17,7 @@ describe('dependency satisfies', () => { project?.dispose(); }); - templateTests((transform: (code: string, options?: TemplateTransformOptions) => Promise) => { + templateTests(transform => { test('in content position', async () => { let result = await transform(`{{macroDependencySatisfies 'qunit' '^2.8.0'}}`, { filename }); expect(result).toEqual('{{true}}'); diff --git a/packages/macros/tests/glimmer/fail-build.test.ts b/packages/macros/tests/glimmer/fail-build.test.ts index 7426ac80c..d7eb32645 100644 --- a/packages/macros/tests/glimmer/fail-build.test.ts +++ b/packages/macros/tests/glimmer/fail-build.test.ts @@ -2,9 +2,15 @@ import { templateTests } from './helpers'; import type { MacrosConfig } from '../../src/node'; describe(`macroFailBuild`, function () { - templateTests(function (transform: (code: string) => Promise, config: MacrosConfig) { - config.setOwnConfig(__filename, { failureMessage: 'I said so' }); - config.finalize(); + templateTests(function (originalTransform) { + function configure(config: MacrosConfig) { + config.setOwnConfig(__filename, { failureMessage: 'I said so' }); + config.finalize(); + } + + async function transform(text: string): Promise { + return originalTransform(text, { configure }); + } test('it can fail the build, content position', async () => { await expect(async () => { diff --git a/packages/macros/tests/glimmer/get-config.test.ts b/packages/macros/tests/glimmer/get-config.test.ts index 878e35bce..2a1090a1f 100644 --- a/packages/macros/tests/glimmer/get-config.test.ts +++ b/packages/macros/tests/glimmer/get-config.test.ts @@ -1,22 +1,28 @@ -import { templateTests } from './helpers'; import type { MacrosConfig } from '../../src/node'; +import { templateTests } from './helpers'; describe(`macroGetConfig`, function () { - templateTests(function (transform: (code: string) => Promise, config: MacrosConfig) { - config.setOwnConfig(__filename, { - mode: 'amazing', - count: 42, - inner: { - items: [{ name: 'Arthur', awesome: true }], - description: null, - }, - }); + templateTests(function (originalTransform) { + function configure(config: MacrosConfig) { + config.setOwnConfig(__filename, { + mode: 'amazing', + count: 42, + inner: { + items: [{ name: 'Arthur', awesome: true }], + description: null, + }, + }); - config.setConfig(__filename, 'scenario-tester', { - color: 'orange', - }); + config.setConfig(__filename, 'scenario-tester', { + color: 'orange', + }); + + config.finalize(); + } - config.finalize(); + async function transform(text: string): Promise { + return originalTransform(text, { configure }); + } test('macroGetOwnConfig in content position', async function () { let code = await transform(`{{macroGetOwnConfig "mode"}}`); diff --git a/packages/macros/tests/glimmer/helpers.ts b/packages/macros/tests/glimmer/helpers.ts index 3a053fa10..716be5d92 100644 --- a/packages/macros/tests/glimmer/helpers.ts +++ b/packages/macros/tests/glimmer/helpers.ts @@ -10,19 +10,41 @@ const compilerPath = emberTemplateCompiler().path; export { Project }; -type CreateTestsWithConfig = (transform: (templateContents: string) => Promise, config: MacrosConfig) => void; -type CreateTests = (transform: (templateContents: string) => Promise) => void; +type CreateTests = ( + transform: (templateContents: string, options?: TemplateTransformOptions) => Promise +) => void; export interface TemplateTransformOptions { + /** + * The path to the source we are transforming + */ filename?: string; -} + /** + * Customize the app root for this macros run. + * Defaults to the `@embroider/macros` package directory + */ + appRoot?: string; -export function templateTests(createTests: CreateTestsWithConfig | CreateTests) { - let { plugins, setConfig } = MacrosConfig.transforms(); - let config = MacrosConfig.for({}, resolve(__dirname, '..', '..')); - setConfig(config); + /** + * Allow further customization of the macros config before finalization and invocation of the transform callback. + * + * If this option is passed you must call `config.finalize()` yourself in this callback. + */ + configure?: (config: MacrosConfig) => void; +} +export function templateTests(createTests: CreateTests) { let transform = async (templateContents: string, options: TemplateTransformOptions = {}) => { + let { plugins, setConfig } = MacrosConfig.transforms(); + let config = MacrosConfig.for({}, options.appRoot ?? resolve(__dirname, '..', '..')); + setConfig(config); + + if (options.configure) { + options.configure(config); + } else { + config.finalize(); + } + let filename = options.filename ?? join(__dirname, 'sample.hbs'); let etcOptions: EtcOptions = { @@ -70,10 +92,6 @@ export function templateTests(createTests: CreateTestsWithConfig | CreateTests) ); return hbs ?? `no hbs found`; }; - if (createTests.length === 2) { - (createTests as CreateTestsWithConfig)(transform, config); - } else { - config.finalize(); - (createTests as CreateTests)(transform); - } + + createTests(transform); } diff --git a/packages/macros/tests/glimmer/macro-condition.test.ts b/packages/macros/tests/glimmer/macro-condition.test.ts index a53637d30..2504759b1 100644 --- a/packages/macros/tests/glimmer/macro-condition.test.ts +++ b/packages/macros/tests/glimmer/macro-condition.test.ts @@ -1,6 +1,5 @@ import { Project } from 'scenario-tester'; import { join } from 'path'; -import type { TemplateTransformOptions } from './helpers'; import { templateTests } from './helpers'; describe(`macroCondition`, function () { @@ -10,7 +9,7 @@ describe(`macroCondition`, function () { project?.dispose(); }); - templateTests(function (transform: (code: string, opts?: TemplateTransformOptions) => Promise) { + templateTests(function (transform) { test('leaves regular if-block untouched', async function () { let code = await transform(`{{#if this.error}}red{{else}}blue{{/if}}`); expect(code).toEqual(`{{#if this.error}}red{{else}}blue{{/if}}`); diff --git a/packages/macros/tests/runtime.test.ts b/packages/macros/tests/runtime.test.ts index 3f8508d76..0f73180b2 100644 --- a/packages/macros/tests/runtime.test.ts +++ b/packages/macros/tests/runtime.test.ts @@ -1,4 +1,5 @@ import { + appEmberSatisfies, dependencySatisfies, macroCondition, each, @@ -18,6 +19,11 @@ describe(`type-only exports`, function () { expect(dependencySatisfies).toThrow(ERROR_REGEX); }); + test('appEmberSatisfies exists', function () { + expect(appEmberSatisfies).toBeDefined(); + expect(appEmberSatisfies).toThrow(ERROR_REGEX); + }); + test('macroCondition exists', function () { expect(macroCondition).toBeDefined(); expect(macroCondition).toThrow(ERROR_REGEX); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d905a573..f15558608 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -603,6 +603,9 @@ importers: '@babel/core': specifier: ^7.14.5 version: 7.28.4 + '@babel/plugin-transform-class-properties': + specifier: ^7.27.1 + version: 7.27.1(@babel/core@7.28.4) '@babel/plugin-transform-modules-amd': specifier: ^7.19.6 version: 7.27.1(@babel/core@7.28.4)