From 5ebeb75f6f6b09822af9dff281fad145d44425d4 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Sat, 19 Oct 2019 14:25:50 +0200 Subject: [PATCH 01/21] clarify expansion within importSync --- packages/macros/tests/babel/import-sync.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/macros/tests/babel/import-sync.test.ts b/packages/macros/tests/babel/import-sync.test.ts index e2d84233a..3a68273cf 100644 --- a/packages/macros/tests/babel/import-sync.test.ts +++ b/packages/macros/tests/babel/import-sync.test.ts @@ -30,5 +30,12 @@ describe('importSync', function() { `); expect(code).toMatch(/window\.require\(['"]foo['"]\)/); }); + test('importSync accepts a macro-expanded argument', () => { + let code = transform(` + import { importSync, getOwnConfig } from '@embroider/macros'; + importSync(getOwnConfig().target); + `); + expect(code).toMatch(/require\(['"]my-plugin['"]\)/); + }); }); }); From 0e1b6bae8e90d644f136df7d7d40e678f5f4cdb7 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Sat, 19 Oct 2019 15:04:05 +0200 Subject: [PATCH 02/21] test coverage for importSync with expanded argument --- packages/macros/tests/babel/import-sync.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/macros/tests/babel/import-sync.test.ts b/packages/macros/tests/babel/import-sync.test.ts index 3a68273cf..8c7da1be2 100644 --- a/packages/macros/tests/babel/import-sync.test.ts +++ b/packages/macros/tests/babel/import-sync.test.ts @@ -1,7 +1,10 @@ import { allBabelVersions } from './helpers'; +import { MacrosConfig } from '../..'; describe('importSync', function() { - allBabelVersions(function createTests(transform: (code: string) => string) { + allBabelVersions(function createTests(transform: (code: string) => string, config: MacrosConfig) { + config.setOwnConfig(__filename, { target: 'my-plugin' }); + test('importSync becomes require', () => { let code = transform(` import { importSync } from '@embroider/macros'; From f341909c04a4bdccd4a170699b35161180c1e368 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Sat, 19 Oct 2019 22:22:08 +0100 Subject: [PATCH 03/21] much progress on macroCondition --- .vscode/launch.json | 3 +- packages/macros/src/babel/evaluate-json.ts | 23 +- packages/macros/src/babel/macro-condition.ts | 32 +++ packages/macros/src/babel/macro-if.ts | 55 ---- .../macros/src/babel/macros-babel-plugin.ts | 68 +++-- packages/macros/src/babel/state.ts | 2 - .../tests/babel/macro-condition.test.ts | 205 +++++++++++++++ packages/macros/tests/babel/macro-if.test.ts | 237 ------------------ 8 files changed, 286 insertions(+), 339 deletions(-) create mode 100644 packages/macros/src/babel/macro-condition.ts delete mode 100644 packages/macros/src/babel/macro-if.ts create mode 100644 packages/macros/tests/babel/macro-condition.test.ts delete mode 100644 packages/macros/tests/babel/macro-if.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 18663bda8..bef8be43f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -26,7 +26,8 @@ "name": "Run jest tests", "program": "${workspaceFolder}/node_modules/.bin/jest", "cwd": "${workspaceFolder}/packages/macros", - "args": ["--runInBand", "--testPathPattern", "tests/babel/dependency-satisfies.test.js"] + "args": ["--runInBand", "--testPathPattern", "tests/babel/macro-condition.test.js"], + "outputCapture": "std" }, { "type": "node", diff --git a/packages/macros/src/babel/evaluate-json.ts b/packages/macros/src/babel/evaluate-json.ts index 68ef82f23..2d165be3f 100644 --- a/packages/macros/src/babel/evaluate-json.ts +++ b/packages/macros/src/babel/evaluate-json.ts @@ -12,11 +12,22 @@ function evaluateKey(path: NodePath, visitor: BoundVisitor): { confident: boolea return { confident: false, value: undefined }; } -export default function evaluateJSON(path: NodePath, visitor: BoundVisitor): { confident: boolean; value: any } { +export default function evaluate(path: NodePath, visitor: BoundVisitor) { + let builtIn = path.evaluate(); + if (builtIn.confident) { + return builtIn; + } + + // we can go further than babel's evaluate() because we know that we're + // typically used on JSON, not full Javascript. + return evaluateJSON(path, visitor); +} + +export function evaluateJSON(path: NodePath, visitor: BoundVisitor): { confident: boolean; value: any } { if (path.isMemberExpression()) { let property = evaluateKey(assertNotArray(path.get('property')), visitor); if (property.confident) { - let object = evaluateJSON(path.get('object'), visitor); + let object = evaluate(path.get('object'), visitor); if (object.confident) { return { confident: true, value: object.value[property.value] }; } @@ -41,8 +52,8 @@ export default function evaluateJSON(path: NodePath, visitor: BoundVisitor): { c if (path.isObjectExpression()) { let props = assertArray(path.get('properties')).map(p => [ - evaluateJSON(assertNotArray(p.get('key')), visitor), - evaluateJSON(assertNotArray(p.get('value')), visitor), + evaluate(assertNotArray(p.get('key')), visitor), + evaluate(assertNotArray(p.get('value')), visitor), ]); let result: any = {}; for (let [k, v] of props) { @@ -56,7 +67,7 @@ export default function evaluateJSON(path: NodePath, visitor: BoundVisitor): { c if (path.isArrayExpression()) { let elements = path.get('elements').map(element => { - return evaluateJSON(element as NodePath, visitor); + return evaluate(element as NodePath, visitor); }); if (elements.every(element => element.confident)) { return { confident: true, value: elements.map(element => element.value) }; @@ -65,7 +76,7 @@ export default function evaluateJSON(path: NodePath, visitor: BoundVisitor): { c if (path.isCallExpression()) { visitor.CallExpression(path); - return evaluateJSON(path, visitor); + return evaluate(path, visitor); } return { confident: false, value: undefined }; diff --git a/packages/macros/src/babel/macro-condition.ts b/packages/macros/src/babel/macro-condition.ts new file mode 100644 index 000000000..91f1a7f0c --- /dev/null +++ b/packages/macros/src/babel/macro-condition.ts @@ -0,0 +1,32 @@ +import { NodePath } from '@babel/traverse'; +import evaluate from './evaluate-json'; +import { IfStatement, ConditionalExpression, CallExpression } from '@babel/types'; +import error from './error'; +import { BoundVisitor } from './visitor'; + +export default function macroCondition( + conditionalPath: NodePath, + calleePath: NodePath, + visitor: BoundVisitor +) { + let args = calleePath.get('arguments'); + if (args.length !== 1) { + throw error(conditionalPath, `macroCondition accepts exactly one argument, you passed ${args.length}`); + } + + let [predicatePath] = args; + let predicate = evaluate(predicatePath, visitor); + if (!predicate.confident) { + throw error(args[0], `the first argument to macroCondition must be statically known`); + } + + let consequent = conditionalPath.get('consequent'); + let alternate = conditionalPath.get('alternate'); + + let kept = predicate.value ? consequent.node : alternate.node; + if (kept) { + conditionalPath.replaceWith(kept); + } else { + conditionalPath.remove(); + } +} diff --git a/packages/macros/src/babel/macro-if.ts b/packages/macros/src/babel/macro-if.ts deleted file mode 100644 index adb1f043d..000000000 --- a/packages/macros/src/babel/macro-if.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { NodePath } from '@babel/traverse'; -import State from './state'; -import evaluateJSON from './evaluate-json'; -import { callExpression, CallExpression } from '@babel/types'; -import error from './error'; -import { BoundVisitor } from './visitor'; - -export default function macroIf(path: NodePath, state: State, visitor: BoundVisitor) { - let args = path.get('arguments'); - if (args.length !== 2 && args.length !== 3) { - throw error(path, `macroIf takes two or three arguments, you passed ${args.length}`); - } - - let [predicatePath, consequent, alternate] = args; - let predicate = evaluate(predicatePath, visitor); - if (!predicate.confident) { - throw error(args[0], `the first argument to macroIf must be statically known`); - } - - if (!consequent.isArrowFunctionExpression()) { - throw error(args[1], `The second argument to macroIf must be an arrow function expression.`); - } - - if (alternate && !alternate.isArrowFunctionExpression()) { - throw error(args[2], `The third argument to macroIf must be an arrow function expression.`); - } - - state.removed.push(path.get('callee')); - let [kept, dropped] = predicate.value ? [consequent, alternate] : [alternate, consequent]; - if (kept) { - let body = kept.get('body'); - if (body.type === 'BlockStatement') { - path.replaceWith(callExpression(kept.node, [])); - } else { - path.replaceWith(body); - } - } else { - path.remove(); - } - - if (dropped) { - state.removed.push(dropped); - } -} - -function evaluate(path: NodePath, visitor: BoundVisitor) { - let builtIn = path.evaluate(); - if (builtIn.confident) { - return builtIn; - } - - // we can go further than babel's evaluate() because we know that we're - // typically used on JSON, not full Javascript. - return evaluateJSON(path, visitor); -} diff --git a/packages/macros/src/babel/macros-babel-plugin.ts b/packages/macros/src/babel/macros-babel-plugin.ts index 0d98a721b..3f8bc1576 100644 --- a/packages/macros/src/babel/macros-babel-plugin.ts +++ b/packages/macros/src/babel/macros-babel-plugin.ts @@ -1,10 +1,17 @@ import { NodePath } from '@babel/traverse'; -import { ImportDeclaration, CallExpression, Identifier, memberExpression, identifier } from '@babel/types'; +import { + CallExpression, + Identifier, + memberExpression, + identifier, + IfStatement, + ConditionalExpression, +} from '@babel/types'; import { PackageCache } from '@embroider/core'; import State, { sourceFile } from './state'; import dependencySatisfies from './dependency-satisfies'; import getConfig from './get-config'; -import macroIf from './macro-if'; +import macroCondition from './macro-condition'; import error from './error'; import failBuild from './fail-build'; import { bindState } from './visitor'; @@ -15,16 +22,30 @@ export default function main() { let visitor = { Program: { enter(_: NodePath, state: State) { - state.removed = []; - state.pendingTasks = []; state.generatedRequires = new Set(); }, - exit(path: NodePath, state: State) { - state.pendingTasks.forEach(task => task()); - pruneRemovedImports(state); + exit(path: NodePath) { pruneMacroImports(path); }, }, + IfStatement(path: NodePath, state: State) { + let test = path.get('test'); + if (test.isCallExpression()) { + let callee = test.get('callee'); + if (callee.referencesImport('@embroider/macros', 'macroCondition')) { + macroCondition(path, test, bindState(visitor, state)); + } + } + }, + ConditionalExpression(path: NodePath, state: State) { + let test = path.get('test'); + if (test.isCallExpression()) { + let callee = test.get('callee'); + if (callee.referencesImport('@embroider/macros', 'macroCondition')) { + macroCondition(path, test, bindState(visitor, state)); + } + } + }, CallExpression(path: NodePath, state: State) { let callee = path.get('callee'); if (callee.referencesImport('@embroider/macros', 'dependencySatisfies')) { @@ -36,9 +57,6 @@ export default function main() { if (callee.referencesImport('@embroider/macros', 'getOwnConfig')) { getConfig(path, state, packageCache, true); } - if (callee.referencesImport('@embroider/macros', 'macroIf')) { - macroIf(path, state, bindState(visitor, state)); - } if (callee.referencesImport('@embroider/macros', 'failBuild')) { failBuild(path, bindState(visitor, state)); } @@ -58,8 +76,8 @@ export default function main() { if (path.referencesImport('@embroider/macros', 'getOwnConfig')) { throw error(path, `You can only use getOwnConfig as a function call`); } - if (path.referencesImport('@embroider/macros', 'macroIf')) { - throw error(path, `You can only use macroIf as a function call`); + if (path.referencesImport('@embroider/macros', 'macroCondition')) { + throw error(path, `macroCondition can only be used as the predicate of an if statement or ternary expression`); } if (path.referencesImport('@embroider/macros', 'failBuild')) { throw error(path, `You can only use failBuild as a function call`); @@ -97,32 +115,6 @@ export default function main() { return { visitor }; } -function wasRemoved(path: NodePath, state: State) { - return state.removed.includes(path) || Boolean(path.findParent(p => state.removed.includes(p))); -} - -// This removes imports that are only referred to from within code blocks that -// we killed. -function pruneRemovedImports(state: State) { - if (state.removed.length === 0) { - return; - } - let moduleScope = state.removed[0].findParent(path => path.type === 'Program').scope; - for (let name of Object.keys(moduleScope.bindings)) { - let binding = moduleScope.bindings[name]; - let bindingPath = binding.path; - if (bindingPath.isImportSpecifier() || bindingPath.isImportDefaultSpecifier()) { - if (binding.referencePaths.length > 0 && binding.referencePaths.every(path => wasRemoved(path, state))) { - bindingPath.remove(); - let importPath = bindingPath.parentPath as NodePath; - if (importPath.get('specifiers').length === 0) { - importPath.remove(); - } - } - } - } -} - // This removes imports from "@embroider/macros" itself, because we have no // runtime behavior at all. function pruneMacroImports(path: NodePath) { diff --git a/packages/macros/src/babel/state.ts b/packages/macros/src/babel/state.ts index e5b4421f3..a1d2b29ae 100644 --- a/packages/macros/src/babel/state.ts +++ b/packages/macros/src/babel/state.ts @@ -1,8 +1,6 @@ import { NodePath, Node } from '@babel/traverse'; export default interface State { - removed: NodePath[]; - pendingTasks: (() => void)[]; generatedRequires: Set; opts: { userConfigs: { diff --git a/packages/macros/tests/babel/macro-condition.test.ts b/packages/macros/tests/babel/macro-condition.test.ts new file mode 100644 index 000000000..3536de48e --- /dev/null +++ b/packages/macros/tests/babel/macro-condition.test.ts @@ -0,0 +1,205 @@ +import { allBabelVersions, runDefault } from './helpers'; +import { MacrosConfig } from '../..'; + +describe('macroCondition', function() { + allBabelVersions(function createTests(transform: (code: string) => string, config: MacrosConfig) { + config.setConfig(__filename, 'qunit', { items: [{ approved: true, other: null, size: 2.3 }] }); + + test('if selects consequent, drops alternate', () => { + let code = transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + if (macroCondition(true)) { + return 'alpha'; + } else { + return 'beta'; + } + } + `); + expect(runDefault(code)).toBe('alpha'); + expect(code).not.toMatch(/beta/); + expect(code).not.toMatch(/macroCondition/); + expect(code).not.toMatch(/if/); + expect(code).not.toMatch(/@embroider\/macros/); + }); + + test('non-block if selects consequent', () => { + let code = transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + if (macroCondition(true)) + return 'alpha'; + } + `); + expect(runDefault(code)).toBe('alpha'); + expect(code).not.toMatch(/beta/); + expect(code).not.toMatch(/macroCondition/); + expect(code).not.toMatch(/if/); + expect(code).not.toMatch(/@embroider\/macros/); + }); + + test('if selects alternate, drops consequent', () => { + let code = transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + if (macroCondition(false)) { + return 'alpha'; + } else { + return 'beta'; + } + } + `); + expect(runDefault(code)).toBe('beta'); + expect(code).not.toMatch(/alpha/); + expect(code).not.toMatch(/macroCondition/); + expect(code).not.toMatch(/if/); + expect(code).not.toMatch(/@embroider\/macros/); + }); + + test('ternary selects consequent, drops alternate', () => { + let code = transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + return macroCondition(true) ? 'alpha' : 'beta'; + } + `); + expect(runDefault(code)).toBe('alpha'); + expect(code).not.toMatch(/beta/); + expect(code).not.toMatch(/macroCondition/); + expect(code).not.toMatch(/\?/); + expect(code).not.toMatch(/@embroider\/macros/); + }); + + test('ternary selects alternate, drops consequent', () => { + let code = transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + return macroCondition(false) ? 'alpha' : 'beta'; + } + `); + expect(runDefault(code)).toBe('beta'); + expect(code).not.toMatch(/alpha/); + expect(code).not.toMatch(/macroCondition/); + expect(code).not.toMatch(/\?/); + expect(code).not.toMatch(/@embroider\/macros/); + }); + + test('if selects consequent, no alternate', () => { + let code = transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + if (macroCondition(true)) { + return 'alpha'; + } + } + `); + expect(runDefault(code)).toBe('alpha'); + expect(code).not.toMatch(/macroCondition/); + expect(code).not.toMatch(/@embroider\/macros/); + }); + + test('if drops consequent, no alternate', () => { + let code = transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + if (macroCondition(false)) { + return 'alpha'; + } + } + `); + expect(runDefault(code)).toBe(undefined); + }); + + test('non-static predicate refuses to build', () => { + expect(() => { + transform(` + import { macroCondition } from '@embroider/macros'; + import other from 'other'; + export default function() { + return macroCondition(other) ? 1 : 2; + } + `); + }).toThrow(/the first argument to macroCondition must be statically known/); + }); + + test('wrong arity refuses to build', () => { + expect(() => { + transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + return macroCondition() ? 1 : 2; + } + `); + }).toThrow(/macroCondition accepts exactly one argument, you passed 0/); + }); + + test('usage inside expression refuses to build', () => { + expect(() => { + transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + return macroCondition(true); + } + `); + }).toThrow(/macroCondition can only be used as the predicate of an if statement or ternary expression/); + }); + + test('composes with other macros using ternary', () => { + let code = transform(` + import { macroCondition, dependencySatisfies } from '@embroider/macros'; + export default function() { + return macroCondition(dependencySatisfies('qunit', '*')) ? 'alpha' : 'beta'; + } + `); + expect(runDefault(code)).toBe('alpha'); + expect(code).not.toMatch(/beta/); + }); + + test('composes with other macros using if', () => { + let code = transform(` + import { macroCondition, dependencySatisfies } from '@embroider/macros'; + export default function() { + let qunit; + if (macroCondition(dependencySatisfies('qunit', '*'))) { + qunit = 'found'; + } else { + qunit = 'not found'; + } + let notARealPackage; + if (macroCondition(dependencySatisfies('not-a-real-package', '*'))) { + notARealPackage = 'found'; + } else { + notARealPackage = 'not found'; + } + return { qunit, notARealPackage }; + } + `); + expect(runDefault(code)).toEqual({ qunit: 'found', notARealPackage: 'not found' }); + expect(code).not.toMatch(/beta/); + }); + + test('can evaluate boolean expressions', () => { + let code = transform(` + import { macroCondition, dependencySatisfies } from '@embroider/macros'; + export default function() { + return macroCondition((2 > 1) && dependencySatisfies('qunit', '*')) ? 'alpha' : 'beta'; + } + `); + expect(runDefault(code)).toBe('alpha'); + expect(code).not.toMatch(/beta/); + }); + + test('can see booleans inside getConfig', () => { + let code = transform(` + import { macroCondition, getConfig } from '@embroider/macros'; + export default function() { + // this deliberately chains three kinds of property access syntax: by + // identifier, by numeric index, and by string literal. + return macroCondition(getConfig('qunit').items[0]["other"]) ? 'alpha' : 'beta'; + } + `); + expect(runDefault(code)).toBe('beta'); + expect(code).not.toMatch(/alpha/); + }); + }); +}); diff --git a/packages/macros/tests/babel/macro-if.test.ts b/packages/macros/tests/babel/macro-if.test.ts deleted file mode 100644 index 8e29fa675..000000000 --- a/packages/macros/tests/babel/macro-if.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { allBabelVersions, runDefault } from './helpers'; -import { MacrosConfig } from '../..'; - -describe('macroIf', function() { - allBabelVersions(function createTests(transform: (code: string) => string, config: MacrosConfig) { - config.setConfig(__filename, 'qunit', { items: [{ approved: true, other: null, size: 2.3 }] }); - - test('select consequent, drop alternate', () => { - let code = transform(` - import { macroIf } from '@embroider/macros'; - export default function() { - return macroIf(true, () => 'alpha', () => 'beta'); - } - `); - expect(runDefault(code)).toBe('alpha'); - expect(code).not.toMatch(/beta/); - expect(code).not.toMatch(/macroIf/); - expect(code).not.toMatch(/@embroider\/macros/); - }); - - test('select consequent, drop alternate', () => { - let code = transform(` - import { macroIf } from '@embroider/macros'; - export default function() { - return macroIf(false, () => 'alpha', () => 'beta'); - } - `); - expect(runDefault(code)).toBe('beta'); - expect(code).not.toMatch(/alpha/); - expect(code).not.toMatch(/macroIf/); - expect(code).not.toMatch(/@embroider\/macros/); - }); - - test('works with block forms', () => { - let code = transform(` - import { macroIf } from '@embroider/macros'; - export default function() { - return macroIf(false, () => { return 'alpha'; }, () => { return 'beta'; }); - } - `); - expect(runDefault(code)).toBe('beta'); - expect(code).not.toMatch(/alpha/); - }); - - test('block lifting', () => { - let code = transform(` - import { macroIf } from '@embroider/macros'; - export default function() { - let value = macroIf(true, () => { - let value = 1; - return value + 1; - }); - return value; - } - `); - expect(runDefault(code)).toBe(2); - }); - - test('preserves this when using single-expression arrows', () => { - let code = transform(` - import { macroIf } from '@embroider/macros'; - - class Example { - constructor() { - this.name = 'Quint'; - } - method() { - return macroIf(true, () => this.name, () => 'Other'); - } - } - - export default function() { - return new Example().method(); - } - `); - expect(runDefault(code)).toBe('Quint'); - }); - - test('preserves this when using block arrows', () => { - let code = transform(` - import { macroIf } from '@embroider/macros'; - - class Example { - constructor() { - this.name = 'Quint'; - } - method() { - return macroIf(true, () => { return this.name;}, () => { return 'Other'; }); - } - } - - export default function() { - return new Example().method(); - } - `); - expect(runDefault(code)).toBe('Quint'); - }); - - test('select consequent, no alternate', () => { - let code = transform(` - import { macroIf } from '@embroider/macros'; - export default function() { - return macroIf(true, () => 'alpha'); - } - `); - expect(runDefault(code)).toBe('alpha'); - expect(code).not.toMatch(/macroIf/); - expect(code).not.toMatch(/@embroider\/macros/); - }); - - test('drop consequent, no alternate', () => { - let code = transform(` - import { macroIf } from '@embroider/macros'; - export default function() { - return macroIf(false, () => 'alpha'); - } - `); - expect(runDefault(code)).toBe(undefined); - }); - - test('drops imports that are only used in the unused branch', () => { - let code = transform(` - import { macroIf } from '@embroider/macros'; - import a from 'module-a'; - import b from 'module-b'; - import c from 'module-c'; - export default function() { - return macroIf(true, () => a, () => b); - } - `); - expect(code).toMatch(/module-a/); - expect(code).not.toMatch(/module-b/); - }); - - test('non-static predicate refuses to build', () => { - expect(() => { - transform(` - import { macroIf } from '@embroider/macros'; - import other from 'other'; - export default function() { - return macroIf(other, () => a, () => b); - } - `); - }).toThrow(/the first argument to macroIf must be statically known/); - }); - - test('leaves unrelated unused imports alone', () => { - let code = transform(` - import { macroIf } from '@embroider/macros'; - import a from 'module-a'; - import b from 'module-b'; - import c from 'module-c'; - export default function() { - return macroIf(true, () => a, () => b); - } - `); - expect(code).toMatch(/module-c/); - }); - - test('leaves unrelated used imports alone', () => { - let code = transform(` - import { macroIf } from '@embroider/macros'; - import a from 'module-a'; - import b from 'module-b'; - import c from 'module-c'; - export default function() { - c(); - return macroIf(true, () => a, () => b); - } - `); - expect(code).toMatch(/module-c/); - }); - - test('composes with other macros', () => { - let code = transform(` - import { macroIf, dependencySatisfies } from '@embroider/macros'; - export default function() { - return macroIf(dependencySatisfies('qunit', '*'), () => 'alpha', () => 'beta'); - } - `); - expect(runDefault(code)).toBe('alpha'); - expect(code).not.toMatch(/beta/); - }); - - test('composes with self', () => { - let code = transform(` - import { macroIf, dependencySatisfies } from '@embroider/macros'; - export default function() { - return macroIf(dependencySatisfies('qunit', '*'), () => { - return macroIf( - dependencySatisfies('not-a-real-dep', '*'), - () => 'gamma', - () => 'alpha' - ); - }, () => 'beta'); - } - `); - expect(runDefault(code)).toBe('alpha'); - expect(code).not.toMatch(/beta/); - expect(code).not.toMatch(/gamma/); - }); - - test('can see booleans inside getConfig', () => { - let code = transform(` - import { macroIf, getConfig } from '@embroider/macros'; - export default function() { - // this deliberately chains three kinds of property access syntax: by - // identifier, by numeric index, and by string literal. - return macroIf(getConfig('qunit').items[0]["approved"], () => 'alpha', () => 'beta'); - } - `); - expect(runDefault(code)).toBe('alpha'); - expect(code).not.toMatch(/beta/); - }); - - test(`direct export of macroIf`, () => { - let code = transform(` - import { dependencySatisfies, macroIf } from '@embroider/macros'; - - function a() { - return 'a'; - } - - function b() { - return 'b'; - } - - export default macroIf( - false, - () => a, - () => b, - ); - `); - expect(runDefault(code)).toBe('b'); - }); - }); -}); From d786d63e0111fb5f951e7079fa027fcfc2119c29 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Sat, 19 Oct 2019 23:02:07 +0100 Subject: [PATCH 04/21] more good progress on macroCondition --- packages/macros/src/babel/evaluate-json.ts | 2 + packages/macros/src/babel/macro-condition.ts | 12 +++--- .../macros/src/babel/macros-babel-plugin.ts | 38 ++++++++++--------- packages/macros/src/babel/state.ts | 5 +++ 4 files changed, 34 insertions(+), 23 deletions(-) diff --git a/packages/macros/src/babel/evaluate-json.ts b/packages/macros/src/babel/evaluate-json.ts index 2d165be3f..523b47500 100644 --- a/packages/macros/src/babel/evaluate-json.ts +++ b/packages/macros/src/babel/evaluate-json.ts @@ -74,6 +74,8 @@ export function evaluateJSON(path: NodePath, visitor: BoundVisitor): { confident } } + // TODO: this can go away now that we handle conditional nodes on the exit + // side if (path.isCallExpression()) { visitor.CallExpression(path); return evaluate(path, visitor); diff --git a/packages/macros/src/babel/macro-condition.ts b/packages/macros/src/babel/macro-condition.ts index 91f1a7f0c..d38c5d933 100644 --- a/packages/macros/src/babel/macro-condition.ts +++ b/packages/macros/src/babel/macro-condition.ts @@ -4,12 +4,12 @@ import { IfStatement, ConditionalExpression, CallExpression } from '@babel/types import error from './error'; import { BoundVisitor } from './visitor'; -export default function macroCondition( - conditionalPath: NodePath, - calleePath: NodePath, - visitor: BoundVisitor -) { - let args = calleePath.get('arguments'); +export type MacroConditionPath = NodePath & { + get(test: 'test'): NodePath; +}; + +export default function macroCondition(conditionalPath: MacroConditionPath, visitor: BoundVisitor) { + let args = conditionalPath.get('test').get('arguments'); if (args.length !== 1) { throw error(conditionalPath, `macroCondition accepts exactly one argument, you passed ${args.length}`); } diff --git a/packages/macros/src/babel/macros-babel-plugin.ts b/packages/macros/src/babel/macros-babel-plugin.ts index 3f8bc1576..da0df36fa 100644 --- a/packages/macros/src/babel/macros-babel-plugin.ts +++ b/packages/macros/src/babel/macros-babel-plugin.ts @@ -11,7 +11,7 @@ import { PackageCache } from '@embroider/core'; import State, { sourceFile } from './state'; import dependencySatisfies from './dependency-satisfies'; import getConfig from './get-config'; -import macroCondition from './macro-condition'; +import macroCondition, { MacroConditionPath } from './macro-condition'; import error from './error'; import failBuild from './fail-build'; import { bindState } from './visitor'; @@ -23,28 +23,32 @@ export default function main() { Program: { enter(_: NodePath, state: State) { state.generatedRequires = new Set(); + state.pendingConditionals = new Set(); + state.pendingConditions = new Set(); }, exit(path: NodePath) { pruneMacroImports(path); }, }, - IfStatement(path: NodePath, state: State) { - let test = path.get('test'); - if (test.isCallExpression()) { - let callee = test.get('callee'); - if (callee.referencesImport('@embroider/macros', 'macroCondition')) { - macroCondition(path, test, bindState(visitor, state)); + 'IfStatement|ConditionalExpression': { + enter(path: NodePath, state: State) { + let test = path.get('test'); + if (test.isCallExpression()) { + let callee = test.get('callee'); + if (callee.referencesImport('@embroider/macros', 'macroCondition')) { + state.pendingConditionals.add(path as MacroConditionPath); + state.pendingConditions.add(callee.node); + } } - } - }, - ConditionalExpression(path: NodePath, state: State) { - let test = path.get('test'); - if (test.isCallExpression()) { - let callee = test.get('callee'); - if (callee.referencesImport('@embroider/macros', 'macroCondition')) { - macroCondition(path, test, bindState(visitor, state)); + }, + exit(path: NodePath, state: State) { + let candidate = path as MacroConditionPath; + if (state.pendingConditionals.has(candidate)) { + state.pendingConditionals.delete(candidate); + state.pendingConditions.delete(candidate.get('test').node); + macroCondition(candidate, bindState(visitor, state)); } - } + }, }, CallExpression(path: NodePath, state: State) { let callee = path.get('callee'); @@ -76,7 +80,7 @@ export default function main() { if (path.referencesImport('@embroider/macros', 'getOwnConfig')) { throw error(path, `You can only use getOwnConfig as a function call`); } - if (path.referencesImport('@embroider/macros', 'macroCondition')) { + if (path.referencesImport('@embroider/macros', 'macroCondition') && !state.pendingConditions.has(path.node)) { throw error(path, `macroCondition can only be used as the predicate of an if statement or ternary expression`); } if (path.referencesImport('@embroider/macros', 'failBuild')) { diff --git a/packages/macros/src/babel/state.ts b/packages/macros/src/babel/state.ts index a1d2b29ae..531addf0e 100644 --- a/packages/macros/src/babel/state.ts +++ b/packages/macros/src/babel/state.ts @@ -1,7 +1,12 @@ import { NodePath, Node } from '@babel/traverse'; +import { MacroConditionPath } from './macro-condition'; export default interface State { generatedRequires: Set; + + pendingConditionals: Set; + pendingConditions: Set; + opts: { userConfigs: { [pkgRoot: string]: unknown; From a0cf433b83f32412f754fa92554a6cdf8bcda294 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Sun, 20 Oct 2019 03:06:35 +0100 Subject: [PATCH 05/21] [wip] --- packages/macros/src/babel/macros-babel-plugin.ts | 4 ++-- packages/macros/src/babel/state.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/macros/src/babel/macros-babel-plugin.ts b/packages/macros/src/babel/macros-babel-plugin.ts index da0df36fa..da4eebadc 100644 --- a/packages/macros/src/babel/macros-babel-plugin.ts +++ b/packages/macros/src/babel/macros-babel-plugin.ts @@ -23,7 +23,7 @@ export default function main() { Program: { enter(_: NodePath, state: State) { state.generatedRequires = new Set(); - state.pendingConditionals = new Set(); + state.pendingConditionals = []; state.pendingConditions = new Set(); }, exit(path: NodePath) { @@ -36,7 +36,7 @@ export default function main() { if (test.isCallExpression()) { let callee = test.get('callee'); if (callee.referencesImport('@embroider/macros', 'macroCondition')) { - state.pendingConditionals.add(path as MacroConditionPath); + state.pendingConditionals.unshift(path as MacroConditionPath); state.pendingConditions.add(callee.node); } } diff --git a/packages/macros/src/babel/state.ts b/packages/macros/src/babel/state.ts index 531addf0e..1a35bed95 100644 --- a/packages/macros/src/babel/state.ts +++ b/packages/macros/src/babel/state.ts @@ -4,7 +4,7 @@ import { MacroConditionPath } from './macro-condition'; export default interface State { generatedRequires: Set; - pendingConditionals: Set; + pendingConditionals: { conditionalPath: MacroConditionPath, waiting: []; pendingConditions: Set; opts: { From cced06052a78abb3d54c57cf89ab1bb8e0f706b4 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Mon, 21 Oct 2019 07:05:46 -0400 Subject: [PATCH 06/21] more progress --- packages/macros/src/babel/evaluate-json.ts | 2 +- packages/macros/src/babel/fail-build.ts | 23 +++++--- packages/macros/src/babel/macro-condition.ts | 19 ++++++- .../macros/src/babel/macros-babel-plugin.ts | 55 ++++++++----------- packages/macros/src/babel/state.ts | 7 +-- .../macros/tests/babel/fail-build.test.ts | 12 ++-- 6 files changed, 64 insertions(+), 54 deletions(-) diff --git a/packages/macros/src/babel/evaluate-json.ts b/packages/macros/src/babel/evaluate-json.ts index 523b47500..f20953f6c 100644 --- a/packages/macros/src/babel/evaluate-json.ts +++ b/packages/macros/src/babel/evaluate-json.ts @@ -23,7 +23,7 @@ export default function evaluate(path: NodePath, visitor: BoundVisitor) { return evaluateJSON(path, visitor); } -export function evaluateJSON(path: NodePath, visitor: BoundVisitor): { confident: boolean; value: any } { +function evaluateJSON(path: NodePath, visitor: BoundVisitor): { confident: boolean; value: any } { if (path.isMemberExpression()) { let property = evaluateKey(assertNotArray(path.get('property')), visitor); if (property.confident) { diff --git a/packages/macros/src/babel/fail-build.ts b/packages/macros/src/babel/fail-build.ts index d46e39db3..21afb1302 100644 --- a/packages/macros/src/babel/fail-build.ts +++ b/packages/macros/src/babel/fail-build.ts @@ -1,11 +1,12 @@ import { NodePath } from '@babel/traverse'; -import evaluateJSON from './evaluate-json'; +import evaluate from './evaluate-json'; import { CallExpression } from '@babel/types'; import error from './error'; import { BoundVisitor } from './visitor'; import { format } from 'util'; +import State from './state'; -export default function failBuild(path: NodePath, visitor: BoundVisitor) { +export default function failBuild(path: NodePath, state: State, visitor: BoundVisitor) { let args = path.get('arguments'); if (args.length < 1) { throw error(path, `failBuild needs at least one argument`); @@ -18,14 +19,18 @@ export default function failBuild(path: NodePath, visitor: Bound } } + state.jobs.push(() => { + if (!wasRemoved(path, state)) { + emitError(path, argValues); + } + }); +} + +function emitError(path: NodePath, argValues: { value: any }[]) { let [message, ...rest] = argValues; - throw new Error(format(`failBuild: ${message.value}`, ...rest.map(r => r.value))); + throw error(path, format(`failBuild: ${message.value}`, ...rest.map(r => r.value))); } -function evaluate(path: NodePath, visitor: BoundVisitor) { - let builtIn = path.evaluate(); - if (builtIn.confident) { - return builtIn; - } - return evaluateJSON(path, visitor); +function wasRemoved(path: NodePath, state: State) { + return state.removed.has(path.node) || Boolean(path.findParent(p => state.removed.has(p.node))); } diff --git a/packages/macros/src/babel/macro-condition.ts b/packages/macros/src/babel/macro-condition.ts index d38c5d933..533bc7606 100644 --- a/packages/macros/src/babel/macro-condition.ts +++ b/packages/macros/src/babel/macro-condition.ts @@ -3,12 +3,24 @@ import evaluate from './evaluate-json'; import { IfStatement, ConditionalExpression, CallExpression } from '@babel/types'; import error from './error'; import { BoundVisitor } from './visitor'; +import State from './state'; export type MacroConditionPath = NodePath & { get(test: 'test'): NodePath; }; -export default function macroCondition(conditionalPath: MacroConditionPath, visitor: BoundVisitor) { +export function isMacroConditionPath(path: NodePath): path is MacroConditionPath { + let test = path.get('test'); + if (test.isCallExpression()) { + let callee = test.get('callee'); + if (callee.referencesImport('@embroider/macros', 'macroCondition')) { + return true; + } + } + return false; +} + +export default function macroCondition(conditionalPath: MacroConditionPath, state: State, visitor: BoundVisitor) { let args = conditionalPath.get('test').get('arguments'); if (args.length !== 1) { throw error(conditionalPath, `macroCondition accepts exactly one argument, you passed ${args.length}`); @@ -23,10 +35,13 @@ export default function macroCondition(conditionalPath: MacroConditionPath, visi let consequent = conditionalPath.get('consequent'); let alternate = conditionalPath.get('alternate'); - let kept = predicate.value ? consequent.node : alternate.node; + let [kept, removed] = predicate.value ? [consequent.node, alternate.node] : [alternate.node, consequent.node]; if (kept) { conditionalPath.replaceWith(kept); } else { conditionalPath.remove(); } + if (removed) { + state.removed.add(removed); + } } diff --git a/packages/macros/src/babel/macros-babel-plugin.ts b/packages/macros/src/babel/macros-babel-plugin.ts index da4eebadc..55542ea7c 100644 --- a/packages/macros/src/babel/macros-babel-plugin.ts +++ b/packages/macros/src/babel/macros-babel-plugin.ts @@ -11,7 +11,7 @@ import { PackageCache } from '@embroider/core'; import State, { sourceFile } from './state'; import dependencySatisfies from './dependency-satisfies'; import getConfig from './get-config'; -import macroCondition, { MacroConditionPath } from './macro-condition'; +import macroCondition, { isMacroConditionPath } from './macro-condition'; import error from './error'; import failBuild from './fail-build'; import { bindState } from './visitor'; @@ -23,46 +23,46 @@ export default function main() { Program: { enter(_: NodePath, state: State) { state.generatedRequires = new Set(); - state.pendingConditionals = []; - state.pendingConditions = new Set(); + state.jobs = []; + state.removed = new Set(); + state.calledIdentifiers = new Set(); }, - exit(path: NodePath) { + exit(path: NodePath, state: State) { pruneMacroImports(path); + for (let handler of state.jobs) { + handler(); + } }, }, 'IfStatement|ConditionalExpression': { enter(path: NodePath, state: State) { - let test = path.get('test'); - if (test.isCallExpression()) { - let callee = test.get('callee'); - if (callee.referencesImport('@embroider/macros', 'macroCondition')) { - state.pendingConditionals.unshift(path as MacroConditionPath); - state.pendingConditions.add(callee.node); - } + if (isMacroConditionPath(path)) { + state.calledIdentifiers.add(path.get('test').get('callee').node); } }, exit(path: NodePath, state: State) { - let candidate = path as MacroConditionPath; - if (state.pendingConditionals.has(candidate)) { - state.pendingConditionals.delete(candidate); - state.pendingConditions.delete(candidate.get('test').node); - macroCondition(candidate, bindState(visitor, state)); + if (isMacroConditionPath(path)) { + macroCondition(path, state, bindState(visitor, state)); } }, }, CallExpression(path: NodePath, state: State) { let callee = path.get('callee'); if (callee.referencesImport('@embroider/macros', 'dependencySatisfies')) { + state.calledIdentifiers.add(callee.node); dependencySatisfies(path, state, packageCache); } if (callee.referencesImport('@embroider/macros', 'getConfig')) { + state.calledIdentifiers.add(callee.node); getConfig(path, state, packageCache, false); } if (callee.referencesImport('@embroider/macros', 'getOwnConfig')) { + state.calledIdentifiers.add(callee.node); getConfig(path, state, packageCache, true); } if (callee.referencesImport('@embroider/macros', 'failBuild')) { - failBuild(path, bindState(visitor, state)); + state.calledIdentifiers.add(callee.node); + failBuild(path, state, bindState(visitor, state)); } if (callee.referencesImport('@embroider/macros', 'importSync')) { let r = identifier('require'); @@ -71,24 +71,15 @@ export default function main() { } }, ReferencedIdentifier(path: NodePath, state: State) { - if (path.referencesImport('@embroider/macros', 'dependencySatisfies')) { - throw error(path, `You can only use dependencySatisfies as a function call`); - } - if (path.referencesImport('@embroider/macros', 'getConfig')) { - throw error(path, `You can only use getConfig as a function call`); - } - if (path.referencesImport('@embroider/macros', 'getOwnConfig')) { - throw error(path, `You can only use getOwnConfig as a function call`); + for (let candidate of ['dependencySatisfies', 'getConfig', 'getOwnConfig', 'failBuild', 'importSync']) { + if (path.referencesImport('@embroider/macros', candidate) && !state.calledIdentifiers.has(path.node)) { + throw error(path, `You can only use ${candidate} as a function call`); + } } - if (path.referencesImport('@embroider/macros', 'macroCondition') && !state.pendingConditions.has(path.node)) { + + if (path.referencesImport('@embroider/macros', 'macroCondition') && !state.calledIdentifiers.has(path.node)) { throw error(path, `macroCondition can only be used as the predicate of an if statement or ternary expression`); } - if (path.referencesImport('@embroider/macros', 'failBuild')) { - throw error(path, `You can only use failBuild as a function call`); - } - if (path.referencesImport('@embroider/macros', 'importSync')) { - throw error(path, `You can only use importSync as a function call`); - } if (state.opts.owningPackageRoot) { // there is only an owningPackageRoot when we are running inside a diff --git a/packages/macros/src/babel/state.ts b/packages/macros/src/babel/state.ts index 1a35bed95..7ccf2084c 100644 --- a/packages/macros/src/babel/state.ts +++ b/packages/macros/src/babel/state.ts @@ -1,11 +1,10 @@ import { NodePath, Node } from '@babel/traverse'; -import { MacroConditionPath } from './macro-condition'; export default interface State { generatedRequires: Set; - - pendingConditionals: { conditionalPath: MacroConditionPath, waiting: []; - pendingConditions: Set; + removed: Set; + calledIdentifiers: Set; + jobs: (() => void)[]; opts: { userConfigs: { diff --git a/packages/macros/tests/babel/fail-build.test.ts b/packages/macros/tests/babel/fail-build.test.ts index c95d8f392..26a5ade4d 100644 --- a/packages/macros/tests/babel/fail-build.test.ts +++ b/packages/macros/tests/babel/fail-build.test.ts @@ -25,13 +25,13 @@ describe(`fail build macro`, function() { test('it does not fail the build when its inside a dead branch', () => { let code = transform(` - import { macroIf, failBuild } from '@embroider/macros'; + import { macroCondition, failBuild } from '@embroider/macros'; export default function() { - return macroIf( - true, - () => 'it works', - () => failBuild('not supposed to happen') - ); + if (macroCondition(true)) { + return 'it works'; + } else { + failBuild('not supposed to happen'); + } } `); expect(runDefault(code)).toEqual('it works'); From 503a642c036d73070ff1d6b3d61b72799b1c448f Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Mon, 21 Oct 2019 07:27:21 -0400 Subject: [PATCH 07/21] better folding in getConfig --- packages/macros/src/babel/get-config.ts | 36 +++++++++++++++++-- .../macros/src/babel/macros-babel-plugin.ts | 4 +-- .../macros/tests/babel/get-config.test.ts | 35 +++++++++++++++--- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/packages/macros/src/babel/get-config.ts b/packages/macros/src/babel/get-config.ts index 9d06b8807..230d89ea9 100644 --- a/packages/macros/src/babel/get-config.ts +++ b/packages/macros/src/babel/get-config.ts @@ -1,14 +1,16 @@ import { NodePath } from '@babel/traverse'; -import { identifier, File, ExpressionStatement, CallExpression } from '@babel/types'; +import { identifier, File, ExpressionStatement, CallExpression, Expression } from '@babel/types'; import { parse } from '@babel/core'; import State, { sourceFile } from './state'; import { PackageCache, Package } from '@embroider/core'; import error from './error'; -import { assertArray } from './evaluate-json'; +import evaluate, { assertArray } from './evaluate-json'; +import { BoundVisitor } from './visitor'; export default function getConfig( path: NodePath, state: State, + visitor: BoundVisitor, packageCache: PackageCache, own: boolean ) { @@ -33,7 +35,8 @@ export default function getConfig( if (pkg) { config = state.opts.userConfigs[pkg.root]; } - path.replaceWith(literalConfig(config)); + let collapsed = collapse(path, config, visitor); + collapsed.path.replaceWith(literalConfig(collapsed.config)); } function targetPackage(fromPath: string, packageName: string | undefined, packageCache: PackageCache): Package | null { @@ -60,3 +63,30 @@ function literalConfig(config: unknown | undefined) { let expression = statement.expression as CallExpression; return expression.arguments[0]; } + +function collapse(path: NodePath, config: any, visitor: BoundVisitor) { + while (true) { + let parentPath = path.parentPath; + if (parentPath.isMemberExpression()) { + if (parentPath.get('object').node === path.node) { + let property = parentPath.get('property') as NodePath; + if (parentPath.node.computed) { + let evalProperty = evaluate(property, visitor); + if (evalProperty.confident) { + config = config[evalProperty.value]; + path = parentPath; + continue; + } + } else { + if (property.isIdentifier()) { + config = config[property.node.name]; + path = parentPath; + continue; + } + } + } + } + break; + } + return { path, config }; +} diff --git a/packages/macros/src/babel/macros-babel-plugin.ts b/packages/macros/src/babel/macros-babel-plugin.ts index 55542ea7c..1c8907633 100644 --- a/packages/macros/src/babel/macros-babel-plugin.ts +++ b/packages/macros/src/babel/macros-babel-plugin.ts @@ -54,11 +54,11 @@ export default function main() { } if (callee.referencesImport('@embroider/macros', 'getConfig')) { state.calledIdentifiers.add(callee.node); - getConfig(path, state, packageCache, false); + getConfig(path, state, bindState(visitor, state), packageCache, false); } if (callee.referencesImport('@embroider/macros', 'getOwnConfig')) { state.calledIdentifiers.add(callee.node); - getConfig(path, state, packageCache, true); + getConfig(path, state, bindState(visitor, state), packageCache, true); } if (callee.referencesImport('@embroider/macros', 'failBuild')) { state.calledIdentifiers.add(callee.node); diff --git a/packages/macros/tests/babel/get-config.test.ts b/packages/macros/tests/babel/get-config.test.ts index 9924be7af..0ecef2e2d 100644 --- a/packages/macros/tests/babel/get-config.test.ts +++ b/packages/macros/tests/babel/get-config.test.ts @@ -3,7 +3,12 @@ import { MacrosConfig } from '../..'; describe(`getConfig`, function() { allBabelVersions(function(transform: (code: string) => string, config: MacrosConfig) { - config.setOwnConfig(__filename, { beverage: 'coffee' }); + config.setOwnConfig(__filename, { + beverage: 'coffee', + }); + config.setConfig(__filename, '@babel/traverse', { + sizes: [{ name: 'small', oz: 4 }, { name: 'medium', oz: 8 }], + }); config.setConfig(__filename, '@babel/core', [1, 2, 3]); test(`returns correct value for own package's config`, () => { @@ -46,14 +51,34 @@ describe(`getConfig`, function() { expect(runDefault(code)).toBe(undefined); }); - test('import gets removed', () => { + test(`collapses property access`, () => { + let code = transform(` + import { getOwnConfig } from '@embroider/macros'; + export default function() { + return doSomething(getOwnConfig().beverage); + } + `); + expect(code).toMatch(/doSomething\(["']coffee["']\)/); + }); + + test(`collapses computed property access`, () => { let code = transform(` - import { dependencySatisfies } from '@embroider/macros'; + import { getOwnConfig } from '@embroider/macros'; + export default function() { + return doSomething(getOwnConfig()["beverage"]); + } + `); + expect(code).toMatch(/doSomething\(["']coffee["']\)/); + }); + + test(`collapses chained property access`, () => { + let code = transform(` + import { getConfig } from '@embroider/macros'; export default function() { - return dependencySatisfies('not-a-real-dep', '1'); + return doSomething(getConfig('@babel/traverse').sizes[1].oz); } `); - expect(code).not.toMatch(/dependencySatisfies/); + expect(code).toMatch(/doSomething\(8\)/); }); }); }); From f8b060cabf5988a1682a74f8b83f9a67d1fd6c05 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Mon, 21 Oct 2019 07:33:07 -0400 Subject: [PATCH 08/21] refactoring away recursive visitor pattern --- packages/macros/src/babel/evaluate-json.ts | 28 +++++++------------ packages/macros/src/babel/fail-build.ts | 19 ++++++------- packages/macros/src/babel/get-config.ts | 8 ++---- packages/macros/src/babel/macro-condition.ts | 5 ++-- .../macros/src/babel/macros-babel-plugin.ts | 9 +++--- packages/macros/src/babel/visitor.ts | 19 ------------- 6 files changed, 28 insertions(+), 60 deletions(-) delete mode 100644 packages/macros/src/babel/visitor.ts diff --git a/packages/macros/src/babel/evaluate-json.ts b/packages/macros/src/babel/evaluate-json.ts index f20953f6c..7f7c65df8 100644 --- a/packages/macros/src/babel/evaluate-json.ts +++ b/packages/macros/src/babel/evaluate-json.ts @@ -1,8 +1,7 @@ import { NodePath } from '@babel/traverse'; -import { BoundVisitor } from './visitor'; -function evaluateKey(path: NodePath, visitor: BoundVisitor): { confident: boolean; value: any } { - let first = evaluateJSON(path, visitor); +function evaluateKey(path: NodePath): { confident: boolean; value: any } { + let first = evaluateJSON(path); if (first.confident) { return first; } @@ -12,7 +11,7 @@ function evaluateKey(path: NodePath, visitor: BoundVisitor): { confident: boolea return { confident: false, value: undefined }; } -export default function evaluate(path: NodePath, visitor: BoundVisitor) { +export default function evaluate(path: NodePath) { let builtIn = path.evaluate(); if (builtIn.confident) { return builtIn; @@ -20,14 +19,14 @@ export default function evaluate(path: NodePath, visitor: BoundVisitor) { // we can go further than babel's evaluate() because we know that we're // typically used on JSON, not full Javascript. - return evaluateJSON(path, visitor); + return evaluateJSON(path); } -function evaluateJSON(path: NodePath, visitor: BoundVisitor): { confident: boolean; value: any } { +function evaluateJSON(path: NodePath): { confident: boolean; value: any } { if (path.isMemberExpression()) { - let property = evaluateKey(assertNotArray(path.get('property')), visitor); + let property = evaluateKey(assertNotArray(path.get('property'))); if (property.confident) { - let object = evaluate(path.get('object'), visitor); + let object = evaluate(path.get('object')); if (object.confident) { return { confident: true, value: object.value[property.value] }; } @@ -52,8 +51,8 @@ function evaluateJSON(path: NodePath, visitor: BoundVisitor): { confident: boole if (path.isObjectExpression()) { let props = assertArray(path.get('properties')).map(p => [ - evaluate(assertNotArray(p.get('key')), visitor), - evaluate(assertNotArray(p.get('value')), visitor), + evaluate(assertNotArray(p.get('key'))), + evaluate(assertNotArray(p.get('value'))), ]); let result: any = {}; for (let [k, v] of props) { @@ -67,20 +66,13 @@ function evaluateJSON(path: NodePath, visitor: BoundVisitor): { confident: boole if (path.isArrayExpression()) { let elements = path.get('elements').map(element => { - return evaluate(element as NodePath, visitor); + return evaluate(element as NodePath); }); if (elements.every(element => element.confident)) { return { confident: true, value: elements.map(element => element.value) }; } } - // TODO: this can go away now that we handle conditional nodes on the exit - // side - if (path.isCallExpression()) { - visitor.CallExpression(path); - return evaluate(path, visitor); - } - return { confident: false, value: undefined }; } diff --git a/packages/macros/src/babel/fail-build.ts b/packages/macros/src/babel/fail-build.ts index 21afb1302..5ca788afb 100644 --- a/packages/macros/src/babel/fail-build.ts +++ b/packages/macros/src/babel/fail-build.ts @@ -2,31 +2,30 @@ import { NodePath } from '@babel/traverse'; import evaluate from './evaluate-json'; import { CallExpression } from '@babel/types'; import error from './error'; -import { BoundVisitor } from './visitor'; import { format } from 'util'; import State from './state'; -export default function failBuild(path: NodePath, state: State, visitor: BoundVisitor) { +export default function failBuild(path: NodePath, state: State) { let args = path.get('arguments'); if (args.length < 1) { throw error(path, `failBuild needs at least one argument`); } - let argValues = args.map(a => evaluate(a, visitor)); - for (let i = 0; i < argValues.length; i++) { - if (!argValues[i].confident) { - throw error(args[i], `the arguments to failBuild must be statically known`); + state.jobs.push(() => { + let argValues = args.map(a => evaluate(a)); + for (let i = 0; i < argValues.length; i++) { + if (!argValues[i].confident) { + throw error(args[i], `the arguments to failBuild must be statically known`); + } } - } - state.jobs.push(() => { if (!wasRemoved(path, state)) { - emitError(path, argValues); + maybeEmitError(path, argValues); } }); } -function emitError(path: NodePath, argValues: { value: any }[]) { +function maybeEmitError(path: NodePath, argValues: { value: any }[]) { let [message, ...rest] = argValues; throw error(path, format(`failBuild: ${message.value}`, ...rest.map(r => r.value))); } diff --git a/packages/macros/src/babel/get-config.ts b/packages/macros/src/babel/get-config.ts index 230d89ea9..1d08c24c0 100644 --- a/packages/macros/src/babel/get-config.ts +++ b/packages/macros/src/babel/get-config.ts @@ -5,12 +5,10 @@ import State, { sourceFile } from './state'; import { PackageCache, Package } from '@embroider/core'; import error from './error'; import evaluate, { assertArray } from './evaluate-json'; -import { BoundVisitor } from './visitor'; export default function getConfig( path: NodePath, state: State, - visitor: BoundVisitor, packageCache: PackageCache, own: boolean ) { @@ -35,7 +33,7 @@ export default function getConfig( if (pkg) { config = state.opts.userConfigs[pkg.root]; } - let collapsed = collapse(path, config, visitor); + let collapsed = collapse(path, config); collapsed.path.replaceWith(literalConfig(collapsed.config)); } @@ -64,14 +62,14 @@ function literalConfig(config: unknown | undefined) { return expression.arguments[0]; } -function collapse(path: NodePath, config: any, visitor: BoundVisitor) { +function collapse(path: NodePath, config: any) { while (true) { let parentPath = path.parentPath; if (parentPath.isMemberExpression()) { if (parentPath.get('object').node === path.node) { let property = parentPath.get('property') as NodePath; if (parentPath.node.computed) { - let evalProperty = evaluate(property, visitor); + let evalProperty = evaluate(property); if (evalProperty.confident) { config = config[evalProperty.value]; path = parentPath; diff --git a/packages/macros/src/babel/macro-condition.ts b/packages/macros/src/babel/macro-condition.ts index 533bc7606..cb48e6d3b 100644 --- a/packages/macros/src/babel/macro-condition.ts +++ b/packages/macros/src/babel/macro-condition.ts @@ -2,7 +2,6 @@ import { NodePath } from '@babel/traverse'; import evaluate from './evaluate-json'; import { IfStatement, ConditionalExpression, CallExpression } from '@babel/types'; import error from './error'; -import { BoundVisitor } from './visitor'; import State from './state'; export type MacroConditionPath = NodePath & { @@ -20,14 +19,14 @@ export function isMacroConditionPath(path: NodePath, state: State) { if (isMacroConditionPath(path)) { - macroCondition(path, state, bindState(visitor, state)); + macroCondition(path, state); } }, }, @@ -54,15 +53,15 @@ export default function main() { } if (callee.referencesImport('@embroider/macros', 'getConfig')) { state.calledIdentifiers.add(callee.node); - getConfig(path, state, bindState(visitor, state), packageCache, false); + getConfig(path, state, packageCache, false); } if (callee.referencesImport('@embroider/macros', 'getOwnConfig')) { state.calledIdentifiers.add(callee.node); - getConfig(path, state, bindState(visitor, state), packageCache, true); + getConfig(path, state, packageCache, true); } if (callee.referencesImport('@embroider/macros', 'failBuild')) { state.calledIdentifiers.add(callee.node); - failBuild(path, state, bindState(visitor, state)); + failBuild(path, state); } if (callee.referencesImport('@embroider/macros', 'importSync')) { let r = identifier('require'); diff --git a/packages/macros/src/babel/visitor.ts b/packages/macros/src/babel/visitor.ts deleted file mode 100644 index 0fdd1da75..000000000 --- a/packages/macros/src/babel/visitor.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NodePath } from '@babel/traverse'; -import { CallExpression } from '@babel/types'; -import State from './state'; - -export interface Visitor { - CallExpression: (node: NodePath, state: State) => void; -} - -export interface BoundVisitor extends Visitor { - CallExpression: (node: NodePath) => void; -} - -export function bindState(visitor: Visitor, state: State): BoundVisitor { - return { - CallExpression(node: NodePath) { - return visitor.CallExpression(node, state); - }, - }; -} From 0aec0f1c030038ea4f630594be8e05135a8a70d9 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Mon, 21 Oct 2019 09:22:45 -0400 Subject: [PATCH 09/21] removing out of date docs --- packages/macros/README.md | 155 +------------------------------------- 1 file changed, 4 insertions(+), 151 deletions(-) diff --git a/packages/macros/README.md b/packages/macros/README.md index f0aba7ca8..83ad581f9 100644 --- a/packages/macros/README.md +++ b/packages/macros/README.md @@ -10,160 +10,13 @@ The [Embroider package spec](../../SPEC.md) proposes fixing this by making Ember This package works in both Embroider builds and normal ember-cli builds, so that addon authors can switch to this newer pattern without disruption. -## Javascript macros +## The Javascript Macros -- `getOwnConfig()`: a macro that returns arbitrary JSON-serializable configuration that was sent to your package. See "Setting Configuration" for how to get configuration in. +TODO: update this to match latest version of RFC 507 as soon as that is implemented. - Assuming a config of `{ flavor: 'chocolate' }`, this code: +## The Template Macros - ```js - import { getOwnConfig } from '@embroider/macros'; - console.log(getOwnConfig().flavor); - ``` - - Compiles to: - - ```js - console.log({ flavor: 'chocolate' }.flavor); - ``` - -- `getConfig(packageName)`: like `getOwnConfig`, but will retrieve the configuration that was sent to another package. We will resolve which one based on node_modules resolution rules from your package. - -- `dependencySatisfies(packagename, semverRange)`: a macro that compiles to a boolean literal. It will be true if the given package can be resolved (via normal node_modules resolution rules) and meets the stated semver requirement. The package version will be `semver.coerce()`'d first, such that nonstandard versions like "3.9.0-beta.0" will appropriately satisfy constraints like "> 3.8". - - Assuming you have `ember-source` 3.9.0 available, this code: - - ```js - import { dependencySatisfies } from '@embroider/macros'; - let hasNativeArrayHelper = dependencySatisfies('ember-source', '>=3.8.0'); - ``` - - Compiles to: - - ```js - let hasNativeArrayHelper = true; - ``` - -* `macroIf(predicate, consequent, alternate)`: a compile time conditional. Lets you choose between two blocks of code and only include one of them. Critically, it will also strip import statements that are used only inside the dead block. The predicate is usually one of the other macros. - - This code: - - ```js - import { dependencySatisfies, macroIf } from '@embroider/macros'; - import OldComponent from './old-component'; - import NewComponent from './new-component'; - export default macroIf(dependencySatisfies('ember-source', '>=3.8.0'), () => NewComponent, () => OldComponent); - ``` - - Will compile to either this: - - ```js - import NewComponent from './new-component'; - export default NewComponent; - ``` - - Or this: - - ```js - import OldComponent from './old-component'; - export default OldComponent; - ``` - -* `failBuild(message, ...params)`: cause a compile-time build failure. Generally only useful if you put it inside a `macroIf`. All the arguments must be statically analyzable, and they get passed to Node's standard `utils.format()`. - - ```js - import { macroIf, failBuild, dependencySatisfies } from '@embroider/macros'; - macroIf( - dependencySatisfies('ember-source', '>=3.8.0'), - () => true, - () => failBuild('You need to have ember-source >= 3.8.0') - ); - ``` - -* `importSync(moduleSpecifier)`: exactly like standard ECMA `import()` except instead of returning `Promise` it returns `Module`. Under Emroider this is interpreted at build-time. Under classic ember-cli it is interpreted at runtime. This exists to provide synchronous & dynamic import. That's not a think ECMA supports, but it's a thing Ember historically has done, so we sometimes need this macro to bridge the worlds. - -## Template macros - -These are analogous to the Javascript macros, although here (because we don't import them) they are all prefixed with "macro". - -- `macroGetOwnConfig`: works like a helper that pulls values out of your config. For example, assuming you have the config: - - ```json - { - "items": [{ "score": 42 }] - } - ``` - - Then: - - ```hbs - - {{! ⬆️compiles to ⬇️ }} - - ``` - - If you don't pass any keys, you can get the whole thing (although this makes your template bigger, so use keys when you can): - - ```hbs - - {{! ⬆️compiles to ⬇️ }} - - ``` - -* `macroGetConfig`: similar to `macroGetOwnConfig`, but takes the name of another package and gets that package's config. We will locate the other package following node_modules rules from your package. Additional extra arguments are treated as property keys just like in the previous examples. - - ```hbs - - ``` - -* `macroDependencySatisfies` - - ```hbs - - {{! ⬆️compiles to ⬇️ }} - - ``` - -* `macroIf`: Like Ember's own `if`, this can be used in both block form and expresion form. The block form looks like: - - ```hbs - {{#macroIf (macroGetOwnConfig "shouldUseThing") }} - - {{else}} - - {{/macroIf}} - - {{! ⬆️compiles to ⬇️ }} - - ``` - - The expression form looks like: - - ```hbs -
- {{! ⬆️compiles to ⬇️ }} -
- ``` - -- `macroMaybeAttrs`: This macro allows you to include or strip HTML attributes themselves (not just change their values). It works like an element modifier: - - ```hbs -
- {{! ⬆️compiles to either this ⬇️ }} -
- {{! or this ⬇️ }} -
- ``` - -- `macroFailBuild`: cause a compile-time build failure. Generally only useful if you put it inside a `macroIf`. All the arguments must be statically analyzable, and they get passed to Node's standard `utils.format()`. - - ```hbs - {{#macroIf (dependencySatisfies "important-thing" ">= 1.0")}} - - {{else}} - {{macroFailBuild "You need to have import-thing >= 1.0"}} - {{/macroIf}} - ``` +TODO: update this to match latest version of RFC 507 as soon as that is implemented. ## Setting Configuration: from an Ember app From cfb50bb0afcbc4d44d81ee33b371c3330bb12f98 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Tue, 22 Oct 2019 15:29:56 -0400 Subject: [PATCH 10/21] implemented javascript macro `each` --- packages/macros/package.json | 1 + packages/macros/src/babel/each.ts | 87 +++++++++++++++++++ .../macros/src/babel/macros-babel-plugin.ts | 73 +++++++++++----- packages/macros/src/babel/state.ts | 2 + packages/macros/src/index.ts | 8 +- packages/macros/tests/babel/each.test.ts | 66 ++++++++++++++ 6 files changed, 212 insertions(+), 25 deletions(-) create mode 100644 packages/macros/src/babel/each.ts create mode 100644 packages/macros/tests/babel/each.test.ts diff --git a/packages/macros/package.json b/packages/macros/package.json index d31b5168c..b0903f3f0 100644 --- a/packages/macros/package.json +++ b/packages/macros/package.json @@ -36,6 +36,7 @@ "@babel/traverse": "^7.2.4", "@babel/types": "^7.3.2", "@embroider/core": "0.7.1", + "lodash": "^4.17.10", "resolve": "^1.8.1", "semver": "^5.6.0" }, diff --git a/packages/macros/src/babel/each.ts b/packages/macros/src/babel/each.ts new file mode 100644 index 000000000..0f1884225 --- /dev/null +++ b/packages/macros/src/babel/each.ts @@ -0,0 +1,87 @@ +import { NodePath } from '@babel/traverse'; +import evaluate from './evaluate-json'; +import { parse } from '@babel/core'; +import { + CallExpression, + ForOfStatement, + identifier, + File, + ExpressionStatement, + Identifier, + callExpression, + Expression, +} from '@babel/types'; +import error from './error'; +import cloneDeep from 'lodash/cloneDeep'; +import State from './state'; + +export type EachPath = NodePath & { + get(right: 'right'): NodePath; +}; + +export function isEachPath(path: NodePath): path is EachPath { + let right = path.get('right'); + if (right.isCallExpression()) { + let callee = right.get('callee'); + if (callee.referencesImport('@embroider/macros', 'each')) { + return true; + } + } + return false; +} + +export function prepareEachPath(path: EachPath, state: State) { + let args = path.get('right').get('arguments'); + if (args.length !== 1) { + throw error(path, `the each() macro accepts exactly one argument, you passed ${args.length}`); + } + + let left = path.get('left'); + if (!left.isVariableDeclaration() || left.get('declarations').length !== 1) { + throw error(left, `the each() macro doesn't support this syntax`); + } + + let body = path.get('body'); + let varName = (left.get('declarations')[0].get('id') as NodePath).node.name; + let nameRefs = body.scope.getBinding(varName)!.referencePaths; + + state.pendingEachMacros.push({ + body: path.get('body'), + nameRefs, + arg: args[0] as NodePath, + }); + + path.replaceWith(callExpression(identifier('_eachMacroPlaceholder_'), [args[0].node])); +} + +export function finishEachPath(path: NodePath, state: State) { + let resumed = state.pendingEachMacros.pop()!; + let [arrayPath] = path.get('arguments'); + let array = evaluate(arrayPath); + if (!array.confident) { + throw error(resumed.arg, `the argument to the each() macro must be statically known`); + } + + if (!Array.isArray(array.value)) { + throw error(resumed.arg, `the argument to the each() macro must be an array`); + } + + for (let element of array.value) { + let literalElement = asLiteral(element); + for (let target of resumed.nameRefs) { + target.replaceWith(literalElement); + } + path.insertBefore(cloneDeep(resumed.body.node)); + } + path.remove(); +} + +function asLiteral(value: unknown | undefined) { + if (typeof value === 'undefined') { + return identifier('undefined'); + } + let ast = parse(`a(${JSON.stringify(value)})`, {}) as File; + let statement = ast.program.body[0] as ExpressionStatement; + let expression = statement.expression as CallExpression; + return expression.arguments[0]; +} diff --git a/packages/macros/src/babel/macros-babel-plugin.ts b/packages/macros/src/babel/macros-babel-plugin.ts index 46f65852d..e2ef11066 100644 --- a/packages/macros/src/babel/macros-babel-plugin.ts +++ b/packages/macros/src/babel/macros-babel-plugin.ts @@ -6,12 +6,15 @@ import { identifier, IfStatement, ConditionalExpression, + ForOfStatement, } from '@babel/types'; import { PackageCache } from '@embroider/core'; import State, { sourceFile } from './state'; import dependencySatisfies from './dependency-satisfies'; import getConfig from './get-config'; import macroCondition, { isMacroConditionPath } from './macro-condition'; +import { isEachPath, prepareEachPath, finishEachPath } from './each'; + import error from './error'; import failBuild from './fail-build'; @@ -25,6 +28,7 @@ export default function main() { state.jobs = []; state.removed = new Set(); state.calledIdentifiers = new Set(); + state.pendingEachMacros = []; }, exit(path: NodePath, state: State) { pruneMacroImports(path); @@ -45,29 +49,45 @@ export default function main() { } }, }, - CallExpression(path: NodePath, state: State) { - let callee = path.get('callee'); - if (callee.referencesImport('@embroider/macros', 'dependencySatisfies')) { - state.calledIdentifiers.add(callee.node); - dependencySatisfies(path, state, packageCache); - } - if (callee.referencesImport('@embroider/macros', 'getConfig')) { - state.calledIdentifiers.add(callee.node); - getConfig(path, state, packageCache, false); - } - if (callee.referencesImport('@embroider/macros', 'getOwnConfig')) { - state.calledIdentifiers.add(callee.node); - getConfig(path, state, packageCache, true); - } - if (callee.referencesImport('@embroider/macros', 'failBuild')) { - state.calledIdentifiers.add(callee.node); - failBuild(path, state); - } - if (callee.referencesImport('@embroider/macros', 'importSync')) { - let r = identifier('require'); - state.generatedRequires.add(r); - callee.replaceWith(r); - } + ForOfStatement: { + enter(path: NodePath, state: State) { + if (isEachPath(path)) { + state.calledIdentifiers.add(path.get('right').get('callee').node); + prepareEachPath(path, state); + } + }, + }, + CallExpression: { + enter(path: NodePath, state: State) { + let callee = path.get('callee'); + if (callee.referencesImport('@embroider/macros', 'dependencySatisfies')) { + state.calledIdentifiers.add(callee.node); + dependencySatisfies(path, state, packageCache); + } + if (callee.referencesImport('@embroider/macros', 'getConfig')) { + state.calledIdentifiers.add(callee.node); + getConfig(path, state, packageCache, false); + } + if (callee.referencesImport('@embroider/macros', 'getOwnConfig')) { + state.calledIdentifiers.add(callee.node); + getConfig(path, state, packageCache, true); + } + if (callee.referencesImport('@embroider/macros', 'failBuild')) { + state.calledIdentifiers.add(callee.node); + failBuild(path, state); + } + if (callee.referencesImport('@embroider/macros', 'importSync')) { + let r = identifier('require'); + state.generatedRequires.add(r); + callee.replaceWith(r); + } + }, + exit(path: NodePath, state: State) { + let callee = path.get('callee'); + if (callee.isIdentifier() && callee.node.name === '_eachMacroPlaceholder_') { + finishEachPath(path, state); + } + }, }, ReferencedIdentifier(path: NodePath, state: State) { for (let candidate of ['dependencySatisfies', 'getConfig', 'getOwnConfig', 'failBuild', 'importSync']) { @@ -80,6 +100,13 @@ export default function main() { throw error(path, `macroCondition can only be used as the predicate of an if statement or ternary expression`); } + if (path.referencesImport('@embroider/macros', 'each') && !state.calledIdentifiers.has(path.node)) { + throw error( + path, + `the each() macro can only be used within a for ... of statement, like: for (let x of each(thing)){}` + ); + } + if (state.opts.owningPackageRoot) { // there is only an owningPackageRoot when we are running inside a // classic ember-cli build. In the embroider stage3 build, there is no diff --git a/packages/macros/src/babel/state.ts b/packages/macros/src/babel/state.ts index 7ccf2084c..d464cba86 100644 --- a/packages/macros/src/babel/state.ts +++ b/packages/macros/src/babel/state.ts @@ -1,10 +1,12 @@ import { NodePath, Node } from '@babel/traverse'; +import { Statement, Expression } from '@babel/types'; export default interface State { generatedRequires: Set; removed: Set; calledIdentifiers: Set; jobs: (() => void)[]; + pendingEachMacros: { body: NodePath; nameRefs: NodePath[]; arg: NodePath }[]; opts: { userConfigs: { diff --git a/packages/macros/src/index.ts b/packages/macros/src/index.ts index 450587ac4..fad5eafe5 100644 --- a/packages/macros/src/index.ts +++ b/packages/macros/src/index.ts @@ -8,8 +8,12 @@ export function dependencySatisfies(packageName: string, semverRange: string): b throw new Oops(packageName, semverRange); } -export function macroIf(predicate: boolean, consequent: () => void, alternate: () => void) { - throw new Oops(predicate, consequent, alternate); +export function macroCondition(predicate: boolean) { + throw new Oops(predicate); +} + +export function each(array: T[]): T[] { + throw new Oops(array); } export function getConfig(packageName: string): T { diff --git a/packages/macros/tests/babel/each.test.ts b/packages/macros/tests/babel/each.test.ts new file mode 100644 index 000000000..337f52e9a --- /dev/null +++ b/packages/macros/tests/babel/each.test.ts @@ -0,0 +1,66 @@ +import { allBabelVersions } from './helpers'; +import { MacrosConfig } from '../..'; + +describe('each', function() { + allBabelVersions(function createTests(transform: (code: string) => string, config: MacrosConfig) { + config.setOwnConfig(__filename, { plugins: ['alpha', 'beta'], flavor: 'chocolate' }); + + test('plugins example unrolls correctly', () => { + let code = transform(` + import { each, getOwnConfig, importSync } from '@embroider/macros'; + let plugins = []; + for (let plugin of each(getOwnConfig().plugins)) { + plugins.push(importSync(plugin)); + } + `); + expect(code).toMatch(/plugins\.push\(require\(["']beta['"]\)\)/); + expect(code).toMatch(/plugins\.push\(require\(["']alpha['"]\)\)/); + expect(code).not.toMatch(/for/); + }); + + test('non-static array causes build error', () => { + expect(() => { + transform(` + import { each } from '@embroider/macros'; + for (let plugin of each(doSomething())) {} + `); + }).toThrow(/the argument to the each\(\) macro must be statically known/); + }); + + test('static non-array causes build error', () => { + expect(() => { + transform(` + import { each, getOwnConfig } from '@embroider/macros'; + for (let plugin of each(getOwnConfig().flavor)) {} + `); + }).toThrow(/the argument to the each\(\) macro must be an array/); + }); + + test('wrong arity', () => { + expect(() => { + transform(` + import { each } from '@embroider/macros'; + for (let plugin of each(1,2,3)) {} + `); + }).toThrow(/the each\(\) macro accepts exactly one argument, you passed 3/); + }); + + test('non function call', () => { + expect(() => { + transform(` + import { each } from '@embroider/macros'; + let x = each; + `); + }).toThrow(/the each\(\) macro can only be used within a for \.\.\. of statement/); + }); + + test('non for-of usage', () => { + expect(() => { + transform(` + import { each } from '@embroider/macros'; + each(1,2,3) + `); + }).toThrow(/the each\(\) macro can only be used within a for \.\.\. of statement/); + }); + }); +}); From cdeb3f8fffc03532199f184b670cfeb53820ca97 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Fri, 10 Jan 2020 21:40:16 -0500 Subject: [PATCH 11/21] implemented the compile-time moduleExists --- .../macros/src/babel/macros-babel-plugin.ts | 14 ++- packages/macros/src/babel/module-exists.ts | 27 ++++++ .../macros/tests/babel/module-exists.test.ts | 94 +++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 packages/macros/src/babel/module-exists.ts create mode 100644 packages/macros/tests/babel/module-exists.test.ts diff --git a/packages/macros/src/babel/macros-babel-plugin.ts b/packages/macros/src/babel/macros-babel-plugin.ts index e2ef11066..8f61baa80 100644 --- a/packages/macros/src/babel/macros-babel-plugin.ts +++ b/packages/macros/src/babel/macros-babel-plugin.ts @@ -11,6 +11,7 @@ import { import { PackageCache } from '@embroider/core'; import State, { sourceFile } from './state'; import dependencySatisfies from './dependency-satisfies'; +import moduleExists from './module-exists'; import getConfig from './get-config'; import macroCondition, { isMacroConditionPath } from './macro-condition'; import { isEachPath, prepareEachPath, finishEachPath } from './each'; @@ -64,6 +65,10 @@ export default function main() { state.calledIdentifiers.add(callee.node); dependencySatisfies(path, state, packageCache); } + if (callee.referencesImport('@embroider/macros', 'moduleExists')) { + state.calledIdentifiers.add(callee.node); + moduleExists(path, state, packageCache); + } if (callee.referencesImport('@embroider/macros', 'getConfig')) { state.calledIdentifiers.add(callee.node); getConfig(path, state, packageCache, false); @@ -90,7 +95,14 @@ export default function main() { }, }, ReferencedIdentifier(path: NodePath, state: State) { - for (let candidate of ['dependencySatisfies', 'getConfig', 'getOwnConfig', 'failBuild', 'importSync']) { + for (let candidate of [ + 'dependencySatisfies', + 'moduleExists', + 'getConfig', + 'getOwnConfig', + 'failBuild', + 'importSync', + ]) { if (path.referencesImport('@embroider/macros', candidate) && !state.calledIdentifiers.has(path.node)) { throw error(path, `You can only use ${candidate} as a function call`); } diff --git a/packages/macros/src/babel/module-exists.ts b/packages/macros/src/babel/module-exists.ts new file mode 100644 index 000000000..702fe552a --- /dev/null +++ b/packages/macros/src/babel/module-exists.ts @@ -0,0 +1,27 @@ +import { NodePath } from '@babel/traverse'; +import { booleanLiteral, CallExpression } from '@babel/types'; +import State, { sourceFile } from './state'; +import error from './error'; +import { assertArray } from './evaluate-json'; +import resolve from 'resolve'; +import { dirname } from 'path'; + +export default function moduleExists(path: NodePath, state: State) { + if (path.node.arguments.length !== 1) { + throw error(path, `moduleExists takes exactly one argument, you passed ${path.node.arguments.length}`); + } + let [moduleSpecifier] = path.node.arguments; + if (moduleSpecifier.type !== 'StringLiteral') { + throw error(assertArray(path.get('arguments'))[0], `the first argument to moduleExists must be a string literal`); + } + let sourceFileName = sourceFile(path, state); + try { + resolve.sync(moduleSpecifier.value, { basedir: dirname(sourceFileName) }); + path.replaceWith(booleanLiteral(true)); + } catch (err) { + if (err.code !== 'MODULE_NOT_FOUND') { + throw err; + } + path.replaceWith(booleanLiteral(false)); + } +} diff --git a/packages/macros/tests/babel/module-exists.test.ts b/packages/macros/tests/babel/module-exists.test.ts new file mode 100644 index 000000000..25ea8cfc1 --- /dev/null +++ b/packages/macros/tests/babel/module-exists.test.ts @@ -0,0 +1,94 @@ +import { allBabelVersions, runDefault } from './helpers'; + +describe(`moduleExists`, function() { + allBabelVersions(function(transform: (code: string) => string) { + test('package import is satisfied', () => { + let code = transform(` + import { moduleExists } from '@embroider/macros'; + export default function() { + return moduleExists('qunit/src/cli/run'); + } + `); + expect(runDefault(code)).toBe(true); + }); + + test('package import is not satisfied', () => { + let code = transform(` + import { moduleExists } from '@embroider/macros'; + export default function() { + return moduleExists('qunit/not/a/real/thing'); + } + `); + expect(runDefault(code)).toBe(false); + }); + + test('relative import is satisfied', () => { + let code = transform(` + import { moduleExists } from '@embroider/macros'; + export default function() { + return moduleExists('./dependency-satisfies.test'); + } + `); + expect(runDefault(code)).toBe(true); + }); + + test('relative import is not satisfied', () => { + let code = transform(` + import { moduleExists } from '@embroider/macros'; + export default function() { + return moduleExists('./nope'); + } + `); + expect(runDefault(code)).toBe(false); + }); + + test('package not present', () => { + let code = transform(` + import { moduleExists } from '@embroider/macros'; + export default function() { + return moduleExists('not-a-real-dep'); + } + `); + expect(runDefault(code)).toBe(false); + }); + + test('import gets removed', () => { + let code = transform(` + import { moduleExists } from '@embroider/macros'; + export default function() { + return moduleExists('not-a-real-dep'); + } + `); + expect(code).not.toMatch(/moduleExists/); + expect(code).not.toMatch(/@embroider\/macros/); + }); + + test('non call error', () => { + expect(() => { + transform(` + import { moduleExists } from '@embroider/macros'; + let x = moduleExists; + `); + }).toThrow(/You can only use moduleExists as a function call/); + }); + + test('args length error', () => { + expect(() => { + transform(` + import { moduleExists } from '@embroider/macros'; + moduleExists('foo', 'bar'); + `); + }).toThrow(/moduleExists takes exactly one argument, you passed 2/); + }); + + test('non literal arg error', () => { + expect(() => { + transform(` + import { moduleExists } from '@embroider/macros'; + let name = 'qunit'; + moduleExists(name); + `); + }).toThrow(/the first argument to moduleExists must be a string literal/); + }); + }); +}); From e49ec014b25806064f5e44e3562de8a06954f2c9 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Fri, 10 Jan 2020 21:40:27 -0500 Subject: [PATCH 12/21] don't swallow unrelated errors --- packages/macros/src/babel/dependency-satisfies.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/macros/src/babel/dependency-satisfies.ts b/packages/macros/src/babel/dependency-satisfies.ts index cfe61a2e3..0642c6cad 100644 --- a/packages/macros/src/babel/dependency-satisfies.ts +++ b/packages/macros/src/babel/dependency-satisfies.ts @@ -33,6 +33,9 @@ export default function dependencySatisfies(path: NodePath, stat let version = packageCache.resolve(packageName.value, us).version; path.replaceWith(booleanLiteral(satisfies(version, range.value))); } catch (err) { + if (err.code !== 'MODULE_NOT_FOUND') { + throw err; + } path.replaceWith(booleanLiteral(false)); } } From 665b70d397cf6603c1d64e3d0474af57d7c06307 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Sat, 11 Jan 2020 08:31:29 -0800 Subject: [PATCH 13/21] fixing leftover extra arg type error --- packages/macros/src/babel/macros-babel-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/macros/src/babel/macros-babel-plugin.ts b/packages/macros/src/babel/macros-babel-plugin.ts index 8f61baa80..a9672a434 100644 --- a/packages/macros/src/babel/macros-babel-plugin.ts +++ b/packages/macros/src/babel/macros-babel-plugin.ts @@ -67,7 +67,7 @@ export default function main() { } if (callee.referencesImport('@embroider/macros', 'moduleExists')) { state.calledIdentifiers.add(callee.node); - moduleExists(path, state, packageCache); + moduleExists(path, state); } if (callee.referencesImport('@embroider/macros', 'getConfig')) { state.calledIdentifiers.add(callee.node); From 60a20e89c58e5e0f2ad8a4cdfe2ff6f223a69997 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Mon, 13 Jan 2020 13:34:14 -0800 Subject: [PATCH 14/21] added type for importSync --- packages/macros/src/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/macros/src/index.ts b/packages/macros/src/index.ts index fad5eafe5..d66b29633 100644 --- a/packages/macros/src/index.ts +++ b/packages/macros/src/index.ts @@ -16,6 +16,13 @@ export function each(array: T[]): T[] { throw new Oops(array); } +// We would prefer to write: +// export function importSync(specifier: T): typeof import(T) { +// but TS doesn't seem to support that at present. +export function importSync(specifier: string): unknown { + throw new Oops(specifier); +} + export function getConfig(packageName: string): T { throw new Oops(packageName); } From f65040af25ee4f906c5c0443324f39a9fd2a977b Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Tue, 25 Feb 2020 16:43:23 -0500 Subject: [PATCH 15/21] cleaning up test setup to follow changes on master --- .vscode/launch.json | 4 ++-- packages/macros/src/macros-config.ts | 2 ++ packages/macros/tests/babel/each.test.ts | 1 + packages/macros/tests/babel/helpers.ts | 2 +- packages/macros/tests/babel/import-sync.test.ts | 1 + packages/macros/tests/babel/macro-condition.test.ts | 1 + packages/macros/tests/glimmer/helpers.ts | 11 ++++++++--- packages/macros/tests/glimmer/macro-if.test.ts | 5 +---- 8 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index b2e62467b..4c2443299 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -26,8 +26,8 @@ "request": "launch", "name": "Run jest tests", "program": "${workspaceFolder}/node_modules/.bin/jest", - "cwd": "${workspaceFolder}/packages/core", - "args": ["--runInBand", "--testPathPattern", "tests/template-colocation-plugin.test.js"] + "cwd": "${workspaceFolder}/packages/macros", + "args": ["--runInBand", "--testPathPattern", "tests/glimmer/macro-if.test.js"] }, { "type": "node", diff --git a/packages/macros/src/macros-config.ts b/packages/macros/src/macros-config.ts index 9ff321a57..a9ec23932 100644 --- a/packages/macros/src/macros-config.ts +++ b/packages/macros/src/macros-config.ts @@ -52,6 +52,8 @@ export default class MacrosConfig { return config; } + private constructor() {} + private _configWritable = true; private configs: Map = new Map(); private mergers: Map = new Map(); diff --git a/packages/macros/tests/babel/each.test.ts b/packages/macros/tests/babel/each.test.ts index 337f52e9a..bb078b8a2 100644 --- a/packages/macros/tests/babel/each.test.ts +++ b/packages/macros/tests/babel/each.test.ts @@ -4,6 +4,7 @@ import { MacrosConfig } from '../..'; describe('each', function() { allBabelVersions(function createTests(transform: (code: string) => string, config: MacrosConfig) { config.setOwnConfig(__filename, { plugins: ['alpha', 'beta'], flavor: 'chocolate' }); + config.finalize(); test('plugins example unrolls correctly', () => { let code = transform(` diff --git a/packages/macros/tests/babel/helpers.ts b/packages/macros/tests/babel/helpers.ts index d3f3aca99..345feba1d 100644 --- a/packages/macros/tests/babel/helpers.ts +++ b/packages/macros/tests/babel/helpers.ts @@ -21,7 +21,7 @@ export function allBabelVersions(createTests: CreateTests | CreateTestsWithConfi }, createTests(transform) { - config = new MacrosConfig(); + config = MacrosConfig.for({}); if (createTests.length === 1) { // The caller will not be using `config`, so we finalize it for them. config.finalize(); diff --git a/packages/macros/tests/babel/import-sync.test.ts b/packages/macros/tests/babel/import-sync.test.ts index 8c7da1be2..fa22bb455 100644 --- a/packages/macros/tests/babel/import-sync.test.ts +++ b/packages/macros/tests/babel/import-sync.test.ts @@ -4,6 +4,7 @@ import { MacrosConfig } from '../..'; describe('importSync', function() { allBabelVersions(function createTests(transform: (code: string) => string, config: MacrosConfig) { config.setOwnConfig(__filename, { target: 'my-plugin' }); + config.finalize(); test('importSync becomes require', () => { let code = transform(` diff --git a/packages/macros/tests/babel/macro-condition.test.ts b/packages/macros/tests/babel/macro-condition.test.ts index 3536de48e..eedf310f9 100644 --- a/packages/macros/tests/babel/macro-condition.test.ts +++ b/packages/macros/tests/babel/macro-condition.test.ts @@ -4,6 +4,7 @@ import { MacrosConfig } from '../..'; describe('macroCondition', function() { allBabelVersions(function createTests(transform: (code: string) => string, config: MacrosConfig) { config.setConfig(__filename, 'qunit', { items: [{ approved: true, other: null, size: 2.3 }] }); + config.finalize(); test('if selects consequent, drops alternate', () => { let code = transform(` diff --git a/packages/macros/tests/glimmer/helpers.ts b/packages/macros/tests/glimmer/helpers.ts index 22f550853..0c3638fa8 100644 --- a/packages/macros/tests/glimmer/helpers.ts +++ b/packages/macros/tests/glimmer/helpers.ts @@ -5,10 +5,10 @@ import { join } from 'path'; const compilerPath = emberTemplateCompilerPath(); export function templateTests( - createTests: (transform: (templateContents: string) => string, config: MacrosConfig) => void + createTests: (transform: (templateContents: string) => string, config?: MacrosConfig) => void ) { let { plugins, setConfig } = MacrosConfig.astPlugins(); - let config = new MacrosConfig(); + let config = MacrosConfig.for({}); setConfig(config); let compiler = new TemplateCompiler({ compilerPath, @@ -20,5 +20,10 @@ export function templateTests( let transform = (templateContents: string) => { return compiler.applyTransforms(join(__dirname, 'sample.hbs'), templateContents); }; - createTests(transform, config); + if (createTests.length === 2) { + createTests(transform, config); + } else { + config.finalize(); + createTests(transform); + } } diff --git a/packages/macros/tests/glimmer/macro-if.test.ts b/packages/macros/tests/glimmer/macro-if.test.ts index dfee08e28..26de80957 100644 --- a/packages/macros/tests/glimmer/macro-if.test.ts +++ b/packages/macros/tests/glimmer/macro-if.test.ts @@ -1,10 +1,7 @@ import { templateTests } from './helpers'; -import { MacrosConfig } from '../..'; describe(`macroIf`, function() { - templateTests(function(transform: (code: string) => string, config: MacrosConfig) { - config.setOwnConfig(__filename, { failureMessage: 'I said so' }); - + templateTests(function(transform: (code: string) => string) { test('macroIf in content position when true', function() { let code = transform(`{{#macroIf true}}red{{else}}blue{{/macroIf}}`); expect(code).toMatch(/red/); From 060ed2528626ed7476b3fc777fe7ecd15aff7fad Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Tue, 25 Feb 2020 16:49:06 -0500 Subject: [PATCH 16/21] type fixup --- .../macros/tests/glimmer/dependency-satisfies.test.ts | 2 +- packages/macros/tests/glimmer/helpers.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/macros/tests/glimmer/dependency-satisfies.test.ts b/packages/macros/tests/glimmer/dependency-satisfies.test.ts index f26a52279..66e59639a 100644 --- a/packages/macros/tests/glimmer/dependency-satisfies.test.ts +++ b/packages/macros/tests/glimmer/dependency-satisfies.test.ts @@ -1,7 +1,7 @@ import { templateTests } from './helpers'; describe('dependency satisfies', () => { - templateTests(transform => { + templateTests((transform: (code: string) => string) => { test('in content position', () => { let result = transform(`{{macroDependencySatisfies 'qunit' '^2.8.0'}}`); expect(result).toEqual('{{true}}'); diff --git a/packages/macros/tests/glimmer/helpers.ts b/packages/macros/tests/glimmer/helpers.ts index 0c3638fa8..1c4a737aa 100644 --- a/packages/macros/tests/glimmer/helpers.ts +++ b/packages/macros/tests/glimmer/helpers.ts @@ -4,9 +4,10 @@ import { MacrosConfig } from '../..'; import { join } from 'path'; const compilerPath = emberTemplateCompilerPath(); -export function templateTests( - createTests: (transform: (templateContents: string) => string, config?: MacrosConfig) => void -) { +type CreateTestsWithConfig = (transform: (templateContents: string) => string, config: MacrosConfig) => void; +type CreateTests = (transform: (templateContents: string) => string) => void; + +export function templateTests(createTests: CreateTestsWithConfig | CreateTests) { let { plugins, setConfig } = MacrosConfig.astPlugins(); let config = MacrosConfig.for({}); setConfig(config); @@ -21,9 +22,9 @@ export function templateTests( return compiler.applyTransforms(join(__dirname, 'sample.hbs'), templateContents); }; if (createTests.length === 2) { - createTests(transform, config); + (createTests as CreateTestsWithConfig)(transform, config); } else { config.finalize(); - createTests(transform); + (createTests as CreateTests)(transform); } } From 168da9242ab71a03b6595342eca95a32b3c15cd9 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Tue, 25 Feb 2020 23:48:14 -0500 Subject: [PATCH 17/21] avoid a type conflict --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 80b5b8c9e..f5c2de74d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,7 +52,7 @@ "filesize": "^4.1.2", "fs-extra": "^7.0.1", "fs-tree-diff": "^2.0.0", - "handlebars": "^4.3.0", + "handlebars": "^4.4.2", "js-string-escape": "^1.0.1", "jsdom": "^12.0.0", "json-stable-stringify": "^1.0.1", From 8fc1a39df4df929ee3430d1c6372694bb4164578 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Tue, 25 Feb 2020 23:48:51 -0500 Subject: [PATCH 18/21] improving macro test harness and adding some skipped tests for nullish coalescing --- packages/macros/package.json | 1 + packages/macros/src/babel/get-config.ts | 30 +++++++++---------- .../macros/tests/babel/get-config.test.ts | 25 ++++++++++++++-- packages/macros/tests/babel/helpers.ts | 14 +++++---- test-packages/support/index.ts | 22 ++++++++++---- yarn.lock | 13 +------- 6 files changed, 65 insertions(+), 40 deletions(-) diff --git a/packages/macros/package.json b/packages/macros/package.json index 0c335a1d4..825614c01 100644 --- a/packages/macros/package.json +++ b/packages/macros/package.json @@ -19,6 +19,7 @@ "src/**/*.js.map" ], "devDependencies": { + "@babel/plugin-proposal-optional-chaining": "^7.8.3", "@babel/plugin-transform-modules-commonjs": "^7.2.0", "@embroider/test-support": "0.13.0", "@types/babel__core": "^7.0.4", diff --git a/packages/macros/src/babel/get-config.ts b/packages/macros/src/babel/get-config.ts index 1d08c24c0..5379c7d8a 100644 --- a/packages/macros/src/babel/get-config.ts +++ b/packages/macros/src/babel/get-config.ts @@ -65,22 +65,20 @@ function literalConfig(config: unknown | undefined) { function collapse(path: NodePath, config: any) { while (true) { let parentPath = path.parentPath; - if (parentPath.isMemberExpression()) { - if (parentPath.get('object').node === path.node) { - let property = parentPath.get('property') as NodePath; - if (parentPath.node.computed) { - let evalProperty = evaluate(property); - if (evalProperty.confident) { - config = config[evalProperty.value]; - path = parentPath; - continue; - } - } else { - if (property.isIdentifier()) { - config = config[property.node.name]; - path = parentPath; - continue; - } + if (parentPath.isMemberExpression() && parentPath.get('object').node === path.node) { + let property = parentPath.get('property') as NodePath; + if (parentPath.node.computed) { + let evalProperty = evaluate(property); + if (evalProperty.confident) { + config = config[evalProperty.value]; + path = parentPath; + continue; + } + } else { + if (property.isIdentifier()) { + config = config[property.node.name]; + path = parentPath; + continue; } } } diff --git a/packages/macros/tests/babel/get-config.test.ts b/packages/macros/tests/babel/get-config.test.ts index 3d25874cc..befcd613b 100644 --- a/packages/macros/tests/babel/get-config.test.ts +++ b/packages/macros/tests/babel/get-config.test.ts @@ -1,8 +1,7 @@ import { allBabelVersions, runDefault } from './helpers'; -import { MacrosConfig } from '../..'; describe(`getConfig`, function() { - allBabelVersions(function(transform: (code: string) => string, config: MacrosConfig) { + allBabelVersions(function(transform, config) { config.setOwnConfig(__filename, { beverage: 'coffee', }); @@ -81,5 +80,27 @@ describe(`getConfig`, function() { `); expect(code).toMatch(/doSomething\(8\)/); }); + + if (transform.babelMajorVersion === 7) { + test.skip(`collapses nullish coalescing, not null case`, () => { + let code = transform(` + import { getConfig } from '@embroider/macros'; + export default function() { + return doSomething(getConfig('@babel/traverse')?.sizes?.[1]?.oz); + } + `); + expect(code).toMatch(/doSomething\(8\)/); + }); + + test.skip(`collapses nullish coalescing, nullish case`, () => { + let code = transform(` + import { getConfig } from '@embroider/macros'; + export default function() { + return doSomething(getConfig('not-a-real-package')?.sizes?.[1]?.oz); + } + `); + expect(code).toMatch(/doSomething\(undefined\)/); + }); + } }); }); diff --git a/packages/macros/tests/babel/helpers.ts b/packages/macros/tests/babel/helpers.ts index 345feba1d..ddbc3344b 100644 --- a/packages/macros/tests/babel/helpers.ts +++ b/packages/macros/tests/babel/helpers.ts @@ -1,23 +1,27 @@ import { MacrosConfig } from '../..'; import { join } from 'path'; -import { allBabelVersions as allBabel, runDefault } from '@embroider/test-support'; +import { allBabelVersions as allBabel, runDefault, Transform } from '@embroider/test-support'; import 'qunit'; export { runDefault }; -type CreateTestsWithConfig = (transform: (code: string) => string, config: MacrosConfig) => void; -type CreateTests = (transform: (code: string) => string) => void; +type CreateTestsWithConfig = (transform: Transform, config: MacrosConfig) => void; +type CreateTests = (transform: Transform) => void; export function allBabelVersions(createTests: CreateTests | CreateTestsWithConfig) { let config: MacrosConfig; allBabel({ includePresetsTests: true, - babelConfig() { - return { + babelConfig(majorVersion: 6 | 7) { + let b = { filename: join(__dirname, 'sample.js'), presets: [], plugins: [config.babelPluginConfig()], }; + if (majorVersion === 7) { + b.plugins.push(require.resolve('@babel/plugin-proposal-optional-chaining')); + } + return b; }, createTests(transform) { diff --git a/test-packages/support/index.ts b/test-packages/support/index.ts index 9f51a3cda..559123923 100644 --- a/test-packages/support/index.ts +++ b/test-packages/support/index.ts @@ -28,17 +28,23 @@ function presetsFor(major: 6 | 7) { ]; } +export interface Transform { + (code: string): string; + babelMajorVersion: 6 | 7; + usingPresets: boolean; +} + export function allBabelVersions(params: { babelConfig(major: 6): Options6; babelConfig(major: 7): Options7; - createTests(transform: (code: string) => string): void; + createTests(transform: Transform): void; includePresetsTests?: boolean; }) { let _describe = typeof QUnit !== 'undefined' ? (QUnit.module as any) : describe; function versions(usePresets: boolean) { _describe('babel6', function() { - params.createTests(function(code: string) { + function transform(code: string) { let options6: Options6 = params.babelConfig(6); if (!options6.filename) { options6.filename = 'sample.js'; @@ -48,11 +54,14 @@ export function allBabelVersions(params: { } return transform6(code, options6).code!; - }); + } + transform.babelMajorVersion = 6 as 6; + transform.usingPresets = usePresets; + params.createTests(transform); }); _describe('babel7', function() { - params.createTests(function(code: string) { + function transform(code: string) { let options7: Options7 = params.babelConfig(7); if (!options7.filename) { options7.filename = 'sample.js'; @@ -61,7 +70,10 @@ export function allBabelVersions(params: { options7.presets = presetsFor(7); } return transform7(code, options7)!.code!; - }); + } + transform.babelMajorVersion = 7 as 7; + transform.usingPresets = usePresets; + params.createTests(transform); }); } diff --git a/yarn.lock b/yarn.lock index e609c3d3c..9167fc5c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9614,18 +9614,7 @@ handlebars@^4.0.11, handlebars@^4.0.13, handlebars@^4.0.4, handlebars@^4.0.6, ha optionalDependencies: uglify-js "^3.1.4" -handlebars@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.3.0.tgz#427391b584626c9c9c6ffb7d1fb90aa9789221cc" - integrity sha512-7XlnO8yBXOdi7AzowjZssQr47Ctidqm7GbgARapOaqSN9HQhlClnOkR9HieGauIT3A8MBC6u9wPCXs97PCYpWg== - dependencies: - neo-async "^2.6.0" - optimist "^0.6.1" - source-map "^0.6.1" - optionalDependencies: - uglify-js "^3.1.4" - -handlebars@^4.3.1: +handlebars@^4.3.1, handlebars@^4.4.2: version "4.7.3" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.3.tgz#8ece2797826886cf8082d1726ff21d2a022550ee" integrity sha512-SRGwSYuNfx8DwHD/6InAPzD6RgeruWLT+B8e8a7gGs8FWgHzlExpTFMEq2IA6QpAfOClpKHy6+8IqTjeBCu6Kg== From 95aaaac064a44605bf142c53cc0868243a770132 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 26 Feb 2020 00:19:50 -0500 Subject: [PATCH 19/21] typescript upgrade This is as far as we can go until some upstream things get fixed --- .vscode/launch.json | 2 +- packages/compat/package.json | 2 +- packages/core/package.json | 2 +- packages/macros/package.json | 2 +- packages/webpack/package.json | 2 +- packages/webpack/src/html-placeholder.ts | 2 +- yarn.lock | 8 ++++---- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 4c2443299..529239a00 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,7 +27,7 @@ "name": "Run jest tests", "program": "${workspaceFolder}/node_modules/.bin/jest", "cwd": "${workspaceFolder}/packages/macros", - "args": ["--runInBand", "--testPathPattern", "tests/glimmer/macro-if.test.js"] + "args": ["--runInBand", "--testPathPattern", "tests/babel/get-config.test.js"] }, { "type": "node", diff --git a/packages/compat/package.json b/packages/compat/package.json index ba7263332..512be48d8 100644 --- a/packages/compat/package.json +++ b/packages/compat/package.json @@ -35,7 +35,7 @@ "@types/strip-bom": "^3.0.0", "ember-cli-htmlbars-inline-precompile": "^2.1.0", "qunit": "^2.8.0", - "typescript": "~3.2.0" + "typescript": "~3.4.0" }, "dependencies": { "@babel/core": "^7.2.2", diff --git a/packages/core/package.json b/packages/core/package.json index f5c2de74d..851890b4d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,7 +33,7 @@ "fixturify": "^1.2.0", "qunit": "^2.8.0", "tmp": "^0.1.0", - "typescript": "~3.2.0" + "typescript": "~3.4.0" }, "dependencies": { "@babel/core": "^7.2.2", diff --git a/packages/macros/package.json b/packages/macros/package.json index 825614c01..9099b74d4 100644 --- a/packages/macros/package.json +++ b/packages/macros/package.json @@ -30,7 +30,7 @@ "@types/qunit": "^2.5.3", "@types/resolve": "^0.0.8", "qunit": "^2.8.0", - "typescript": "~3.2.0" + "typescript": "~3.4.0" }, "dependencies": { "@babel/core": "^7.2.2", diff --git a/packages/webpack/package.json b/packages/webpack/package.json index e2c8aa91b..75867734e 100644 --- a/packages/webpack/package.json +++ b/packages/webpack/package.json @@ -21,7 +21,7 @@ "@types/mini-css-extract-plugin": "^0.2.0", "@types/node": "^10.5.2", "@types/webpack": "^4.4.17", - "typescript": "~3.2.0" + "typescript": "~3.4.0" }, "dependencies": { "@babel/core": "^7.2.2", diff --git a/packages/webpack/src/html-placeholder.ts b/packages/webpack/src/html-placeholder.ts index c279985cb..e3cf93b2b 100644 --- a/packages/webpack/src/html-placeholder.ts +++ b/packages/webpack/src/html-placeholder.ts @@ -80,5 +80,5 @@ interface InDOMNode extends Node { // an html node that definitely has a next sibling. interface StartNode extends InDOMNode { - nextSibling: InDOMNode; + nextSibling: InDOMNode & ChildNode; } diff --git a/yarn.lock b/yarn.lock index 9167fc5c8..015db6757 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15979,10 +15979,10 @@ typescript-memoize@^1.0.0-alpha.3: dependencies: core-js "2.4.1" -typescript@~3.2.0: - version "3.2.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.2.4.tgz#c585cb952912263d915b462726ce244ba510ef3d" - integrity sha512-0RNDbSdEokBeEAkgNbxJ+BLwSManFy9TeXz8uW+48j/xhEXv1ePME60olyzw2XzUqUBNAYFeJadIqAgNqIACwg== +typescript@~3.4.0: + version "3.4.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.4.5.tgz#2d2618d10bb566572b8d7aad5180d84257d70a99" + integrity sha512-YycBxUb49UUhdNMU5aJ7z5Ej2XGmaIBL0x34vZ82fn3hGvD+bgrMrVDpatgz2f7YxUMJxMkbWxJZeAvDxVe7Vw== uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6" From c5674b251a577976e54138d40ea102fe7758833b Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 26 Feb 2020 00:46:16 -0500 Subject: [PATCH 20/21] implement nullish coalescing for getConfig and getOwnConfig --- packages/macros/package.json | 1 - packages/macros/src/babel/get-config.ts | 28 ++++++++++++++++++- .../macros/tests/babel/get-config.test.ts | 5 ++-- packages/macros/tests/babel/helpers.ts | 8 ++---- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/macros/package.json b/packages/macros/package.json index 9099b74d4..5f197016f 100644 --- a/packages/macros/package.json +++ b/packages/macros/package.json @@ -19,7 +19,6 @@ "src/**/*.js.map" ], "devDependencies": { - "@babel/plugin-proposal-optional-chaining": "^7.8.3", "@babel/plugin-transform-modules-commonjs": "^7.2.0", "@embroider/test-support": "0.13.0", "@types/babel__core": "^7.0.4", diff --git a/packages/macros/src/babel/get-config.ts b/packages/macros/src/babel/get-config.ts index 5379c7d8a..fa38c2cf3 100644 --- a/packages/macros/src/babel/get-config.ts +++ b/packages/macros/src/babel/get-config.ts @@ -1,5 +1,12 @@ import { NodePath } from '@babel/traverse'; -import { identifier, File, ExpressionStatement, CallExpression, Expression } from '@babel/types'; +import { + identifier, + File, + ExpressionStatement, + CallExpression, + Expression, + OptionalMemberExpression, +} from '@babel/types'; import { parse } from '@babel/core'; import State, { sourceFile } from './state'; import { PackageCache, Package } from '@embroider/core'; @@ -81,6 +88,25 @@ function collapse(path: NodePath, config: any) { continue; } } + } else if (parentPath.node.type === 'OptionalMemberExpression') { + let castParentPath = parentPath as NodePath; + if (castParentPath.get('object').node === path.node) { + let property = castParentPath.get('property') as NodePath; + if (castParentPath.node.computed) { + let evalProperty = evaluate(property); + if (evalProperty.confident) { + config = config == null ? config : config[evalProperty.value]; + path = castParentPath; + continue; + } + } else { + if (property.isIdentifier()) { + config = config == null ? config : config[property.node.name]; + path = castParentPath; + continue; + } + } + } } break; } diff --git a/packages/macros/tests/babel/get-config.test.ts b/packages/macros/tests/babel/get-config.test.ts index befcd613b..5d4cff7c1 100644 --- a/packages/macros/tests/babel/get-config.test.ts +++ b/packages/macros/tests/babel/get-config.test.ts @@ -81,8 +81,9 @@ describe(`getConfig`, function() { expect(code).toMatch(/doSomething\(8\)/); }); + // babel 6 doesn't parse nullish coalescing if (transform.babelMajorVersion === 7) { - test.skip(`collapses nullish coalescing, not null case`, () => { + test(`collapses nullish coalescing, not null case`, () => { let code = transform(` import { getConfig } from '@embroider/macros'; export default function() { @@ -92,7 +93,7 @@ describe(`getConfig`, function() { expect(code).toMatch(/doSomething\(8\)/); }); - test.skip(`collapses nullish coalescing, nullish case`, () => { + test(`collapses nullish coalescing, nullish case`, () => { let code = transform(` import { getConfig } from '@embroider/macros'; export default function() { diff --git a/packages/macros/tests/babel/helpers.ts b/packages/macros/tests/babel/helpers.ts index ddbc3344b..4fa1d1edf 100644 --- a/packages/macros/tests/babel/helpers.ts +++ b/packages/macros/tests/babel/helpers.ts @@ -12,16 +12,12 @@ export function allBabelVersions(createTests: CreateTests | CreateTestsWithConfi let config: MacrosConfig; allBabel({ includePresetsTests: true, - babelConfig(majorVersion: 6 | 7) { - let b = { + babelConfig() { + return { filename: join(__dirname, 'sample.js'), presets: [], plugins: [config.babelPluginConfig()], }; - if (majorVersion === 7) { - b.plugins.push(require.resolve('@babel/plugin-proposal-optional-chaining')); - } - return b; }, createTests(transform) { From f07ecfeb0001d6acb37a7d0558d7b13430307975 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 26 Feb 2020 16:43:56 -0500 Subject: [PATCH 21/21] test coverage for else-if folding --- packages/macros/README.md | 35 ++++----- .../tests/babel/macro-condition.test.ts | 72 +++++++++++++++++++ 2 files changed, 86 insertions(+), 21 deletions(-) diff --git a/packages/macros/README.md b/packages/macros/README.md index e57b436f1..ae0c38fe7 100644 --- a/packages/macros/README.md +++ b/packages/macros/README.md @@ -10,13 +10,20 @@ The [Embroider package spec](../../SPEC.md) proposes fixing this by making Ember This package works in both Embroider builds and normal ember-cli builds, so that addon authors can switch to this newer pattern without disruption. -## The Javascript Macros +## Examples -TODO: update this to match latest version of RFC 507 as soon as that is implemented. +```js +import { dependencySatisfies, macroCondition, failBuild } from '@embroider/macros'; -## The Template Macros +if (macroCondition(dependencySatisfies('ember-data', '^3.0.0'))) { +} else +``` + +## The Macros -TODO: update this to match latest version of RFC 507 as soon as that is implemented. +### dependencySatisfies + +Tests whether a given dependency is present and satisfies the given semver range. ## Setting Configuration: from an Ember app @@ -36,9 +43,9 @@ TODO: update this to match latest version of RFC 507 as soon as that is implemen setConfig: { 'some-dependency': { // config for some-dependency - } - } - } + }, + }, + }, }); ``` @@ -64,17 +71,3 @@ TODO: update this to match latest version of RFC 507 as soon as that is implemen }, }; ``` - -## Setting Configuration: Low Level API - -Configuration is stored per NPM package, based off their true on-disk locations. So it's possible to configure two independent copies of the same package when they're being consumed by different subsets of the total NPM dependency graph. - -Configuration gets set during the build process, from within Node. - -The entrypoints to the low level API are: - -- `import { MacrosConfig } from '@embroider/macros'`: constructs the shared global object that stores config. It has methods for setting configuration and for retrieving the necessary Babel and HTMLBars plugins that will implment the config. See `macros-config.ts` for details. - -``` - -``` diff --git a/packages/macros/tests/babel/macro-condition.test.ts b/packages/macros/tests/babel/macro-condition.test.ts index eedf310f9..e2e2dccfc 100644 --- a/packages/macros/tests/babel/macro-condition.test.ts +++ b/packages/macros/tests/babel/macro-condition.test.ts @@ -111,6 +111,78 @@ describe('macroCondition', function() { expect(runDefault(code)).toBe(undefined); }); + test('else if consequent', () => { + let code = transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + if (macroCondition(false)) { + return 'alpha'; + } else if (macroCondition(true)) { + return 'beta'; + } else { + return 'gamma'; + } + } + `); + expect(runDefault(code)).toBe('beta'); + expect(code).not.toMatch(/alpha/); + expect(code).not.toMatch(/gamma/); + }); + + test('else if alternate', () => { + let code = transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + if (macroCondition(false)) { + return 'alpha'; + } else if (macroCondition(false)) { + return 'beta'; + } else { + return 'gamma'; + } + } + `); + expect(runDefault(code)).toBe('gamma'); + expect(code).not.toMatch(/alpha/); + expect(code).not.toMatch(/beta/); + }); + + test('else if with indeterminate predecessor, alternate', () => { + let code = transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + if (window.x) { + return 'alpha'; + } else if (macroCondition(false)) { + return 'beta'; + } else { + return 'gamma'; + } + } + `); + expect(code).toMatch(/alpha/); + expect(code).not.toMatch(/beta/); + expect(code).toMatch(/gamma/); + }); + + test('else if with indeterminate predecessor, consequent', () => { + let code = transform(` + import { macroCondition } from '@embroider/macros'; + export default function() { + if (window.x) { + return 'alpha'; + } else if (macroCondition(true)) { + return 'beta'; + } else { + return 'gamma'; + } + } + `); + expect(code).toMatch(/alpha/); + expect(code).toMatch(/beta/); + expect(code).not.toMatch(/gamma/); + }); + test('non-static predicate refuses to build', () => { expect(() => { transform(`