diff --git a/.vscode/launch.json b/.vscode/launch.json index b2e62467b..529239a00 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/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 80b5b8c9e..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", @@ -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", diff --git a/packages/macros/README.md b/packages/macros/README.md index dc33156e0..ae0c38fe7 100644 --- a/packages/macros/README.md +++ b/packages/macros/README.md @@ -10,160 +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. -## Javascript macros +## Examples -- `getOwnConfig()`: a macro that returns arbitrary JSON-serializable configuration that was sent to your package. See "Setting Configuration" for how to get configuration in. +```js +import { dependencySatisfies, macroCondition, failBuild } from '@embroider/macros'; - Assuming a config of `{ flavor: 'chocolate' }`, this code: - - ```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: +if (macroCondition(dependencySatisfies('ember-data', '^3.0.0'))) { +} else +``` - ```hbs -
- {{! ⬆️compiles to either this ⬇️ }} -
- {{! or this ⬇️ }} -
- ``` +## The Macros -- `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()`. +### dependencySatisfies - ```hbs - {{#macroIf (dependencySatisfies "important-thing" ">= 1.0")}} - - {{else}} - {{macroFailBuild "You need to have import-thing >= 1.0"}} - {{/macroIf}} - ``` +Tests whether a given dependency is present and satisfies the given semver range. ## Setting Configuration: from an Ember app @@ -183,9 +43,9 @@ These are analogous to the Javascript macros, although here (because we don't im setConfig: { 'some-dependency': { // config for some-dependency - } - } - } + }, + }, + }, }); ``` @@ -211,17 +71,3 @@ These are analogous to the Javascript macros, although here (because we don't im }, }; ``` - -## 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/package.json b/packages/macros/package.json index 6ae2a1363..5f197016f 100644 --- a/packages/macros/package.json +++ b/packages/macros/package.json @@ -29,13 +29,14 @@ "@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", "@babel/traverse": "^7.2.4", "@babel/types": "^7.3.2", "@embroider/core": "0.13.0", + "lodash": "^4.17.10", "resolve": "^1.8.1", "semver": "^5.6.0" }, 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)); } } 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/evaluate-json.ts b/packages/macros/src/babel/evaluate-json.ts index 68ef82f23..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,11 +11,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) { + 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); +} + +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 = evaluateJSON(path.get('object'), visitor); + let object = evaluate(path.get('object')); if (object.confident) { return { confident: true, value: object.value[property.value] }; } @@ -41,8 +51,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'))), + evaluate(assertNotArray(p.get('value'))), ]); let result: any = {}; for (let [k, v] of props) { @@ -56,18 +66,13 @@ 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); }); if (elements.every(element => element.confident)) { return { confident: true, value: elements.map(element => element.value) }; } } - if (path.isCallExpression()) { - visitor.CallExpression(path); - return evaluateJSON(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 d46e39db3..5ca788afb 100644 --- a/packages/macros/src/babel/fail-build.ts +++ b/packages/macros/src/babel/fail-build.ts @@ -1,31 +1,35 @@ 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) { 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`); + } } - } + if (!wasRemoved(path, state)) { + maybeEmitError(path, argValues); + } + }); +} + +function maybeEmitError(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/get-config.ts b/packages/macros/src/babel/get-config.ts index 9d06b8807..fa38c2cf3 100644 --- a/packages/macros/src/babel/get-config.ts +++ b/packages/macros/src/babel/get-config.ts @@ -1,10 +1,17 @@ import { NodePath } from '@babel/traverse'; -import { identifier, File, ExpressionStatement, CallExpression } 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'; import error from './error'; -import { assertArray } from './evaluate-json'; +import evaluate, { assertArray } from './evaluate-json'; export default function getConfig( path: NodePath, @@ -33,7 +40,8 @@ export default function getConfig( if (pkg) { config = state.opts.userConfigs[pkg.root]; } - path.replaceWith(literalConfig(config)); + let collapsed = collapse(path, config); + collapsed.path.replaceWith(literalConfig(collapsed.config)); } function targetPackage(fromPath: string, packageName: string | undefined, packageCache: PackageCache): Package | null { @@ -60,3 +68,47 @@ function literalConfig(config: unknown | undefined) { let expression = statement.expression as CallExpression; return expression.arguments[0]; } + +function collapse(path: NodePath, config: any) { + while (true) { + let parentPath = path.parentPath; + 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; + } + } + } 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; + } + return { path, config }; +} diff --git a/packages/macros/src/babel/macro-condition.ts b/packages/macros/src/babel/macro-condition.ts new file mode 100644 index 000000000..cb48e6d3b --- /dev/null +++ b/packages/macros/src/babel/macro-condition.ts @@ -0,0 +1,46 @@ +import { NodePath } from '@babel/traverse'; +import evaluate from './evaluate-json'; +import { IfStatement, ConditionalExpression, CallExpression } from '@babel/types'; +import error from './error'; +import State from './state'; + +export type MacroConditionPath = NodePath & { + get(test: 'test'): NodePath; +}; + +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) { + let args = conditionalPath.get('test').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); + 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, 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/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..a9672a434 100644 --- a/packages/macros/src/babel/macros-babel-plugin.ts +++ b/packages/macros/src/babel/macros-babel-plugin.ts @@ -1,13 +1,23 @@ import { NodePath } from '@babel/traverse'; -import { ImportDeclaration, CallExpression, Identifier, memberExpression, identifier } from '@babel/types'; +import { + CallExpression, + Identifier, + memberExpression, + identifier, + IfStatement, + ConditionalExpression, + ForOfStatement, +} from '@babel/types'; 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 macroIf from './macro-if'; +import macroCondition, { isMacroConditionPath } from './macro-condition'; +import { isEachPath, prepareEachPath, finishEachPath } from './each'; + import error from './error'; import failBuild from './fail-build'; -import { bindState } from './visitor'; const packageCache = PackageCache.shared('embroider-stage3'); @@ -15,57 +25,98 @@ export default function main() { let visitor = { Program: { enter(_: NodePath, state: State) { - state.removed = []; - state.pendingTasks = []; state.generatedRequires = new Set(); + state.jobs = []; + state.removed = new Set(); + state.calledIdentifiers = new Set(); + state.pendingEachMacros = []; }, exit(path: NodePath, state: State) { - state.pendingTasks.forEach(task => task()); - pruneRemovedImports(state); pruneMacroImports(path); + for (let handler of state.jobs) { + handler(); + } }, }, - CallExpression(path: NodePath, state: State) { - let callee = path.get('callee'); - if (callee.referencesImport('@embroider/macros', 'dependencySatisfies')) { - dependencySatisfies(path, state, packageCache); - } - if (callee.referencesImport('@embroider/macros', 'getConfig')) { - getConfig(path, state, packageCache, false); - } - 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)); - } - if (callee.referencesImport('@embroider/macros', 'importSync')) { - let r = identifier('require'); - state.generatedRequires.add(r); - callee.replaceWith(r); - } + 'IfStatement|ConditionalExpression': { + enter(path: NodePath, state: State) { + if (isMacroConditionPath(path)) { + state.calledIdentifiers.add(path.get('test').get('callee').node); + } + }, + exit(path: NodePath, state: State) { + if (isMacroConditionPath(path)) { + macroCondition(path, state); + } + }, + }, + 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', 'moduleExists')) { + state.calledIdentifiers.add(callee.node); + moduleExists(path, state); + } + 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) { - 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`); - } - if (path.referencesImport('@embroider/macros', 'macroIf')) { - throw error(path, `You can only use macroIf as a function call`); + 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`); + } } - if (path.referencesImport('@embroider/macros', 'failBuild')) { - throw error(path, `You can only use failBuild as a function call`); + + 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', 'importSync')) { - throw error(path, `You can only use importSync as a function call`); + + 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) { @@ -97,32 +148,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/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/src/babel/state.ts b/packages/macros/src/babel/state.ts index e5b4421f3..d464cba86 100644 --- a/packages/macros/src/babel/state.ts +++ b/packages/macros/src/babel/state.ts @@ -1,9 +1,13 @@ import { NodePath, Node } from '@babel/traverse'; +import { Statement, Expression } from '@babel/types'; export default interface State { - removed: NodePath[]; - pendingTasks: (() => void)[]; generatedRequires: Set; + removed: Set; + calledIdentifiers: Set; + jobs: (() => void)[]; + pendingEachMacros: { body: NodePath; nameRefs: NodePath[]; arg: NodePath }[]; + opts: { userConfigs: { [pkgRoot: string]: unknown; 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); - }, - }; -} diff --git a/packages/macros/src/index.ts b/packages/macros/src/index.ts index 450587ac4..d66b29633 100644 --- a/packages/macros/src/index.ts +++ b/packages/macros/src/index.ts @@ -8,8 +8,19 @@ 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); +} + +// 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 { 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 new file mode 100644 index 000000000..bb078b8a2 --- /dev/null +++ b/packages/macros/tests/babel/each.test.ts @@ -0,0 +1,67 @@ +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' }); + config.finalize(); + + 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/); + }); + }); +}); diff --git a/packages/macros/tests/babel/fail-build.test.ts b/packages/macros/tests/babel/fail-build.test.ts index 1d3dacd39..83820d613 100644 --- a/packages/macros/tests/babel/fail-build.test.ts +++ b/packages/macros/tests/babel/fail-build.test.ts @@ -26,13 +26,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'); diff --git a/packages/macros/tests/babel/get-config.test.ts b/packages/macros/tests/babel/get-config.test.ts index 5979e218d..5d4cff7c1 100644 --- a/packages/macros/tests/babel/get-config.test.ts +++ b/packages/macros/tests/babel/get-config.test.ts @@ -1,9 +1,13 @@ import { allBabelVersions, runDefault } from './helpers'; -import { MacrosConfig } from '../..'; describe(`getConfig`, function() { - allBabelVersions(function(transform: (code: string) => string, config: MacrosConfig) { - config.setOwnConfig(__filename, { beverage: 'coffee' }); + allBabelVersions(function(transform, config) { + 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]); config.finalize(); @@ -47,14 +51,57 @@ 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 dependencySatisfies('not-a-real-dep', '1'); + return doSomething(getOwnConfig()["beverage"]); } `); - expect(code).not.toMatch(/dependencySatisfies/); + expect(code).toMatch(/doSomething\(["']coffee["']\)/); }); + + test(`collapses chained property access`, () => { + let code = transform(` + import { getConfig } from '@embroider/macros'; + export default function() { + return doSomething(getConfig('@babel/traverse').sizes[1].oz); + } + `); + expect(code).toMatch(/doSomething\(8\)/); + }); + + // babel 6 doesn't parse nullish coalescing + if (transform.babelMajorVersion === 7) { + test(`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(`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 d3f3aca99..4fa1d1edf 100644 --- a/packages/macros/tests/babel/helpers.ts +++ b/packages/macros/tests/babel/helpers.ts @@ -1,12 +1,12 @@ 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; @@ -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 e2d84233a..fa22bb455 100644 --- a/packages/macros/tests/babel/import-sync.test.ts +++ b/packages/macros/tests/babel/import-sync.test.ts @@ -1,7 +1,11 @@ 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' }); + config.finalize(); + test('importSync becomes require', () => { let code = transform(` import { importSync } from '@embroider/macros'; @@ -30,5 +34,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['"]\)/); + }); }); }); 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..e2e2dccfc --- /dev/null +++ b/packages/macros/tests/babel/macro-condition.test.ts @@ -0,0 +1,278 @@ +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 }] }); + config.finalize(); + + 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('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(` + 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 54196d78e..000000000 --- a/packages/macros/tests/babel/macro-if.test.ts +++ /dev/null @@ -1,238 +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 }] }); - config.finalize(); - - 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'); - }); - }); -}); 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/); + }); + }); +}); 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 22f550853..1c4a737aa 100644 --- a/packages/macros/tests/glimmer/helpers.ts +++ b/packages/macros/tests/glimmer/helpers.ts @@ -4,11 +4,12 @@ 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 = new MacrosConfig(); + let config = MacrosConfig.for({}); setConfig(config); let compiler = new TemplateCompiler({ compilerPath, @@ -20,5 +21,10 @@ export function templateTests( let transform = (templateContents: string) => { return compiler.applyTransforms(join(__dirname, 'sample.hbs'), templateContents); }; - createTests(transform, config); + if (createTests.length === 2) { + (createTests as CreateTestsWithConfig)(transform, config); + } else { + config.finalize(); + (createTests as 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/); 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/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..015db6757 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== @@ -15990,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"