Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/macros/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
96 changes: 96 additions & 0 deletions packages/macros/src/babel/app-ember-satisfies.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | false>();
/**
* 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<t.CallExpression>, 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;
}
}
4 changes: 4 additions & 0 deletions packages/macros/src/babel/evaluate-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
}
Expand Down
1 change: 1 addition & 0 deletions packages/macros/src/babel/macros-babel-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export default function main(context: typeof Babel): unknown {
ReferencedIdentifier(path: NodePath<t.Identifier>, state: State) {
for (let candidate of [
'dependencySatisfies',
'appEmberSatisfies',
'moduleExists',
'getConfig',
'getOwnConfig',
Expand Down
35 changes: 35 additions & 0 deletions packages/macros/src/glimmer/app-ember-satisfies.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions packages/macros/src/glimmer/ast-transform.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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));
}
},
},
};
Expand Down
8 changes: 8 additions & 0 deletions packages/macros/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -92,6 +96,10 @@ export interface EmbroiderMacrosRegistry {
Args: { Positional: Parameters<typeof dependencySatisfies> };
Return: ReturnType<typeof dependencySatisfies>;
}>;
macroAppEmberSatisfies: HelperLike<{
Args: { Positional: Parameters<typeof appEmberSatisfies> };
Return: ReturnType<typeof appEmberSatisfies>;
}>;
macroMaybeAttrs: HelperLike<{
Args: { Positional: [predicate: boolean, ...bareAttrs: unknown[]] };
Return: void;
Expand Down
171 changes: 171 additions & 0 deletions packages/macros/tests/babel/app-ember-satisfies.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
},
});
});
Loading
Loading