diff options
Diffstat (limited to '@linaria/packages/shaker/src')
-rw-r--r-- | @linaria/packages/shaker/src/DepsGraph.ts | 149 | ||||
-rw-r--r-- | @linaria/packages/shaker/src/GraphBuilderState.ts | 45 | ||||
-rw-r--r-- | @linaria/packages/shaker/src/Visitors.ts | 87 | ||||
-rw-r--r-- | @linaria/packages/shaker/src/dumpNode.ts | 63 | ||||
-rw-r--r-- | @linaria/packages/shaker/src/graphBuilder.ts | 192 | ||||
-rw-r--r-- | @linaria/packages/shaker/src/identifierHandlers.ts | 130 | ||||
-rw-r--r-- | @linaria/packages/shaker/src/index.ts | 65 | ||||
-rw-r--r-- | @linaria/packages/shaker/src/langs/core.ts | 653 | ||||
-rw-r--r-- | @linaria/packages/shaker/src/scope.ts | 210 | ||||
-rw-r--r-- | @linaria/packages/shaker/src/shaker.ts | 127 | ||||
-rw-r--r-- | @linaria/packages/shaker/src/types.ts | 22 |
11 files changed, 1743 insertions, 0 deletions
diff --git a/@linaria/packages/shaker/src/DepsGraph.ts b/@linaria/packages/shaker/src/DepsGraph.ts new file mode 100644 index 0000000..45fbe21 --- /dev/null +++ b/@linaria/packages/shaker/src/DepsGraph.ts @@ -0,0 +1,149 @@ +import { types as t } from '@babel/core'; +import ScopeManager, { PromisedNode, resolveNode } from './scope'; + +type Action = (this: DepsGraph, a: t.Node, b: t.Node) => void; + +function addEdge(this: DepsGraph, a: t.Node, b: t.Node) { + if (this.dependencies.has(a) && this.dependencies.get(a)!.has(b)) { + // edge has been already added∂ƒ + return; + } + + this.edges.push([a, b]); + if (this.dependencies.has(a)) { + this.dependencies.get(a)!.add(b); + } else { + this.dependencies.set(a, new Set([b])); + } + + if (this.dependents.has(b)) { + this.dependents.get(b)!.add(a); + } else { + this.dependents.set(b, new Set([a])); + } +} + +export default class DepsGraph { + public readonly imports: Map<string, (t.Identifier | t.StringLiteral)[]> = + new Map(); + public readonly importAliases: Map<t.Identifier, string> = new Map(); + public readonly importTypes: Map< + string, + 'wildcard' | 'default' | 'reexport' + > = new Map(); + public readonly reexports: Array<t.Identifier> = []; + + protected readonly parents: WeakMap<t.Node, t.Node> = new WeakMap(); + protected readonly edges: Array<[t.Node, t.Node]> = []; + protected readonly exports: Map<string, t.Node> = new Map(); + protected readonly dependencies: Map<t.Node, Set<t.Node>> = new Map(); + protected readonly dependents: Map<t.Node, Set<t.Node>> = new Map(); + + private actionQueue: Array< + [Action, t.Node | PromisedNode, t.Node | PromisedNode] + > = []; + + private processQueue() { + if (this.actionQueue.length === 0) { + return; + } + + for (const [action, a, b] of this.actionQueue) { + const resolvedA = resolveNode(a); + const resolvedB = resolveNode(b); + if (resolvedA && resolvedB) { + action.call(this, resolvedA, resolvedB); + } + } + + this.actionQueue = []; + } + + private getAllReferences(id: string): (t.Identifier | t.MemberExpression)[] { + const [, name] = id.split(':'); + const declaration = this.scope.getDeclaration(id)!; + const allReferences: (t.Identifier | t.MemberExpression)[] = [ + ...Array.from(this.dependencies.get(declaration) || []), + ...Array.from(this.dependents.get(declaration) || []), + ].filter((i) => t.isIdentifier(i) && i.name === name) as t.Identifier[]; + allReferences.push(declaration); + return allReferences; + } + + constructor(protected scope: ScopeManager) {} + + addEdge(dependent: t.Node | PromisedNode, dependency: t.Node | PromisedNode) { + this.actionQueue.push([addEdge, dependent, dependency]); + } + + addExport(name: string, node: t.Node) { + this.exports.set(name, node); + } + + addParent(node: t.Node, parent: t.Node) { + this.parents.set(node, parent); + } + + getParent(node: t.Node): t.Node | undefined { + return this.parents.get(node); + } + + getDependenciesByBinding(id: string) { + this.processQueue(); + const allReferences = this.getAllReferences(id); + const dependencies = []; + for (let [a, b] of this.edges) { + if (t.isIdentifier(a) && allReferences.includes(a)) { + dependencies.push(b); + } + } + + return dependencies; + } + + getDependentsByBinding(id: string) { + this.processQueue(); + const allReferences = this.getAllReferences(id); + const dependents = []; + for (let [a, b] of this.edges) { + if (t.isIdentifier(b) && allReferences.includes(b)) { + dependents.push(a); + } + } + + return dependents; + } + + findDependencies(like: Object) { + this.processQueue(); + return this.edges + .filter(([a]) => t.shallowEqual(a, like)) + .map(([, b]) => b); + } + + findDependents(like: object) { + this.processQueue(); + return this.edges + .filter(([, b]) => t.shallowEqual(b, like)) + .map(([a]) => a); + } + + getDependencies(nodes: t.Node[]) { + this.processQueue(); + return nodes.reduce( + (acc, node) => acc.concat(Array.from(this.dependencies.get(node) || [])), + [] as t.Node[] + ); + } + + getLeaf(name: string): t.Node | undefined { + return this.exports.get(name); + } + + getLeaves(only: string[] | null): Array<t.Node | undefined> { + this.processQueue(); + return only + ? only.map((name) => this.getLeaf(name)) + : Array.from(this.exports.values()); + } +} diff --git a/@linaria/packages/shaker/src/GraphBuilderState.ts b/@linaria/packages/shaker/src/GraphBuilderState.ts new file mode 100644 index 0000000..ba70ca2 --- /dev/null +++ b/@linaria/packages/shaker/src/GraphBuilderState.ts @@ -0,0 +1,45 @@ +import type { Node, VisitorKeys } from '@babel/types'; +import ScopeManager from './scope'; +import DepsGraph from './DepsGraph'; + +export type OnVisitCallback = (n: Node) => void; + +export default abstract class GraphBuilderState { + public readonly scope = new ScopeManager(); + public readonly graph = new DepsGraph(this.scope); + public readonly meta = new Map<string, any>(); + + protected callbacks: OnVisitCallback[] = []; + + /* + * For expressions like `{ foo: bar }` we need to now context + * + * const obj = { foo: bar }; + * Here context is `expression`, `bar` is a variable which depends from its declaration. + * + * const { foo: bar } = obj; + * Here context is `pattern` and `bar` is a variable declaration itself. + */ + public readonly context: Array<'expression' | 'lval'> = []; + + public readonly fnStack: Node[] = []; + + public onVisit(callback: OnVisitCallback) { + this.callbacks.push(callback); + return () => { + this.callbacks = this.callbacks.filter((c) => c !== callback); + }; + } + + abstract baseVisit<TNode extends Node>( + node: TNode, + ignoreDeps?: boolean + ): void; + + abstract visit<TNode extends Node, TParent extends Node>( + node: TNode, + parent: TParent | null, + parentKey: VisitorKeys[TParent['type']] | null, + listIdx?: number | null + ): void; +} diff --git a/@linaria/packages/shaker/src/Visitors.ts b/@linaria/packages/shaker/src/Visitors.ts new file mode 100644 index 0000000..0228d4a --- /dev/null +++ b/@linaria/packages/shaker/src/Visitors.ts @@ -0,0 +1,87 @@ +import { types as t } from '@babel/core'; +import type { Identifier, Node, VisitorKeys } from '@babel/types'; +import { warn } from '@linaria/logger'; +import { peek } from '@linaria/babel-preset'; +import GraphBuilderState from './GraphBuilderState'; +import identifierHandlers from './identifierHandlers'; +import type { Visitor, Visitors } from './types'; + +import { visitors as core } from './langs/core'; + +const visitors: Visitors = { + Identifier<TParent extends Node>( + this: GraphBuilderState, + node: Identifier, + parent: TParent | null, + parentKey: VisitorKeys[TParent['type']] | null, + listIdx: number | null = null + ) { + if (!parent || !parentKey) { + return; + } + + const handler = identifierHandlers[`${parent.type}:${parentKey}`]; + + if (typeof handler === 'function') { + handler(this, node, parent, parentKey, listIdx); + return; + } + + if (handler === 'keep') { + return; + } + + if (handler === 'declare') { + const kindOfDeclaration = this.meta.get('kind-of-declaration'); + this.scope.declare(node, kindOfDeclaration === 'var', null); + return; + } + + if (handler === 'refer') { + const declaration = this.scope.addReference(node); + // Let's check that it's not a global variable + if (declaration) { + // usage of a variable depends on its declaration + this.graph.addEdge(node, declaration); + + const context = peek(this.context); + if (context === 'lval') { + // This is an identifier in the left side of an assignment expression and a variable value depends on that. + this.graph.addEdge(declaration, node); + } + } + + return; + } + + /* + * There is an unhandled identifier. + * This case should be added to ./identifierHandlers.ts + */ + warn( + 'evaluator:shaker', + 'Unhandled identifier', + node.name, + parent.type, + parentKey, + listIdx + ); + }, + + ...core, +}; + +const isKeyOfVisitors = (type: string): type is keyof Visitors => + type in visitors; + +export function getVisitors<TNode extends Node>(node: TNode): Visitor<TNode>[] { + const aliases = t.ALIAS_KEYS[node.type] || []; + const aliasVisitors = aliases + .map((type) => (isKeyOfVisitors(type) ? visitors[type] : null)) + .filter((i) => i) as Visitor<TNode>[]; + return [...aliasVisitors, visitors[node.type] as Visitor<TNode>].filter( + (v) => v + ); +} + +export default visitors; diff --git a/@linaria/packages/shaker/src/dumpNode.ts b/@linaria/packages/shaker/src/dumpNode.ts new file mode 100644 index 0000000..f6d1c84 --- /dev/null +++ b/@linaria/packages/shaker/src/dumpNode.ts @@ -0,0 +1,63 @@ +import { types as t } from '@babel/core'; +import type { + BinaryExpression, + Identifier, + Node, + NumericLiteral, + StringLiteral, +} from '@babel/types'; + +type Hooks = { + [key: string]: (node: any) => string | number; +}; + +const hooks: Hooks = { + Identifier: (node: Identifier) => node.name, + BinaryExpression: (node: BinaryExpression) => node.operator, + NumericLiteral: (node: NumericLiteral) => node.value, + StringLiteral: (node: StringLiteral) => node.value, +}; + +function isNode(obj: any): obj is Node { + return !!obj; +} + +export default function dumpNode<T extends Node>( + node: T, + alive: Set<Node> | null = null, + level = 0, + idx: number | null = null +) { + let result = level === 0 ? '\n' : ''; + const prefix = + level === 0 + ? '' + : `${'| '.repeat(level - 1)}${idx === null ? '|' : idx}${ + (idx || 0) < 10 ? '=' : '' + }`; + + const { type } = node; + result += `${prefix}${type}${type in hooks ? ` ${hooks[type](node)}` : ''}`; + + if (alive) { + result += alive.has(node) ? ' ✅' : ' ❌'; + } + + result += '\n'; + const keys = t.VISITOR_KEYS[type] as Array<keyof T>; + for (const key of keys) { + const subNode = node[key]; + + result += `${'| '.repeat(level)}|-${key}\n`; + if (Array.isArray(subNode)) { + for (let i = 0; i < subNode.length; i++) { + const child = subNode[i]; + if (child) result += dumpNode(child, alive, level + 2, i); + } + } else if (isNode(subNode)) { + result += dumpNode(subNode, alive, level + 2); + } + } + + return result; +} diff --git a/@linaria/packages/shaker/src/graphBuilder.ts b/@linaria/packages/shaker/src/graphBuilder.ts new file mode 100644 index 0000000..6b167e8 --- /dev/null +++ b/@linaria/packages/shaker/src/graphBuilder.ts @@ -0,0 +1,192 @@ +import { types as t } from '@babel/core'; +import type { AssignmentExpression, Node, VisitorKeys } from '@babel/types'; +import { isNode, getVisitorKeys } from '@linaria/babel-preset'; +import DepsGraph from './DepsGraph'; +import GraphBuilderState from './GraphBuilderState'; +import { getVisitors } from './Visitors'; +import type { VisitorAction } from './types'; +import ScopeManager from './scope'; + +const isVoid = (node: Node): boolean => + t.isUnaryExpression(node) && node.operator === 'void'; + +class GraphBuilder extends GraphBuilderState { + static build(root: Node): DepsGraph { + return new GraphBuilder(root).graph; + } + + constructor(rootNode: Node) { + super(); + + this.visit(rootNode, null, null, null); + } + + private isExportsIdentifier(node: Node) { + if ( + t.isIdentifier(node) && + this.scope.getDeclaration(node) === ScopeManager.globalExportsIdentifier + ) { + return true; + } + + return ( + t.isMemberExpression(node) && + t.isIdentifier(node.property) && + node.property.name === 'exports' && + t.isIdentifier(node.object) && + this.scope.getDeclaration(node.object) === + ScopeManager.globalModuleIdentifier + ); + } + + private isExportsAssigment(node: Node): node is AssignmentExpression { + if ( + node && + t.isAssignmentExpression(node) && + t.isMemberExpression(node.left) + ) { + if (this.isExportsIdentifier(node.left)) { + // This is a default export like `module.exports = 42` + return true; + } + + if (this.isExportsIdentifier(node.left.object)) { + // This is a named export like `module.exports.a = 42` or `exports.a = 42` + return true; + } + } + + return false; + } + + /* + * Implements a default behaviour for AST-nodes: + * • visits every child; + * • if the current node is an Expression node, adds all its children as dependencies. + * + * eg. BinaryExpression has children `left` and `right`, + * both of them are required for evaluating the value of the expression + */ + baseVisit<TNode extends Node>(node: TNode, ignoreDeps = false) { + const dependencies = []; + const isExpression = t.isExpression(node); + const keys = getVisitorKeys(node); + for (const key of keys) { + // Ignore all types + if (key === 'typeArguments' || key === 'typeParameters') { + continue; + } + + const subNode = node[key as keyof TNode]; + + if (Array.isArray(subNode)) { + for (let i = 0; i < subNode.length; i++) { + const child = subNode[i]; + if (child && this.visit(child, node, key, i) !== 'ignore') { + dependencies.push(child); + } + } + } else if ( + isNode(subNode) && + this.visit(subNode, node, key) !== 'ignore' + ) { + dependencies.push(subNode); + } + } + + if (isExpression && !ignoreDeps) { + dependencies.forEach((dep) => this.graph.addEdge(node, dep)); + } + + this.callbacks.forEach((callback) => callback(node)); + } + + visit<TNode extends Node, TParent extends Node>( + node: TNode, + parent: TParent | null, + parentKey: VisitorKeys[TParent['type']] | null, + listIdx: number | null = null + ): VisitorAction { + if (parent) { + this.graph.addParent(node, parent); + } + + if ( + this.isExportsAssigment(node) && + !this.isExportsAssigment(node.right) && + !isVoid(node.right) + ) { + if ( + t.isMemberExpression(node.left) && + (t.isIdentifier(node.left.property) || + t.isStringLiteral(node.left.property)) + ) { + if ( + t.isIdentifier(node.left.object) && + node.left.object.name === 'module' + ) { + // It's a batch or default export + if (t.isObjectExpression(node.right)) { + // Batch export is a very particular case. + // Each property of the assigned object is independent named export. + // We also need to specify all dependencies and call `visit` for every value. + this.visit(node.left, node, 'left'); + node.right.properties.forEach((prop) => { + if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) { + this.visit(prop.value, prop, 'value'); + this.graph.addExport(prop.key.name, prop); + this.graph.addEdge(prop, node.right); + this.graph.addEdge(prop, prop.key); + this.graph.addEdge(prop.key, prop.value); + } + }); + + this.graph.addEdge(node.right, node); + this.graph.addEdge(node, node.left); + + // We have done all the required work, so stop here + return; + } else { + this.graph.addExport('default', node); + } + } else { + // it can be either `exports.name` or `exports["name"]` + const nameNode = node.left.property; + this.graph.addExport( + t.isStringLiteral(nameNode) ? nameNode.value : nameNode.name, + node + ); + } + } + } + + const isScopable = t.isScopable(node); + const isFunction = t.isFunction(node); + + if (isScopable) this.scope.new(t.isProgram(node) || t.isFunction(node)); + if (isFunction) this.fnStack.push(node); + + const visitors = getVisitors(node); + let action: VisitorAction; + if (visitors.length > 0) { + let visitor; + while (!action && (visitor = visitors.shift())) { + action = visitor.call(this, node, parent, parentKey, listIdx); + } + } else { + this.baseVisit(node); + } + + if (parent && action !== 'ignore') { + // Node always depends on its parent + this.graph.addEdge(node, parent); + } + + if (isFunction) this.fnStack.pop(); + if (isScopable) this.scope.dispose(); + + return action; + } +} + +export default GraphBuilder.build; diff --git a/@linaria/packages/shaker/src/identifierHandlers.ts b/@linaria/packages/shaker/src/identifierHandlers.ts new file mode 100644 index 0000000..bd3c919 --- /dev/null +++ b/@linaria/packages/shaker/src/identifierHandlers.ts @@ -0,0 +1,130 @@ +import { types as t } from '@babel/core'; +import type { Aliases, Identifier, Node, VisitorKeys } from '@babel/types'; +import { peek } from '@linaria/babel-preset'; +import GraphBuilderState from './GraphBuilderState'; +import type { IdentifierHandlerType, NodeType } from './types'; +import { identifierHandlers as core } from './langs/core'; +import ScopeManager from './scope'; + +type HandlerFn = <TParent extends Node = Node>( + builder: GraphBuilderState, + node: Identifier, + parent: TParent, + parentKey: VisitorKeys[TParent['type']], + listIdx: number | null +) => void; + +type Handler = IdentifierHandlerType | HandlerFn; + +const handlers: { + [key: string]: Handler; +} = {}; + +function isAlias(type: NodeType): type is keyof Aliases { + return type in t.FLIPPED_ALIAS_KEYS; +} + +export function defineHandler( + typeOrAlias: NodeType, + field: string, + handler: Handler +) { + const types = isAlias(typeOrAlias) + ? t.FLIPPED_ALIAS_KEYS[typeOrAlias] + : [typeOrAlias]; + types.forEach((type: string) => { + handlers[`${type}:${field}`] = handler; + }); +} + +export function batchDefineHandlers( + typesAndFields: [NodeType, ...string[]][], + handler: IdentifierHandlerType +) { + typesAndFields.forEach(([type, ...fields]) => + fields.forEach((field) => defineHandler(type, field, handler)) + ); +} + +batchDefineHandlers([...core.declare], 'declare'); + +batchDefineHandlers([...core.keep], 'keep'); + +batchDefineHandlers([...core.refer], 'refer'); + +/* + * Special case for FunctionDeclaration + * Function id should be defined in the parent scope + */ +defineHandler( + 'FunctionDeclaration', + 'id', + (builder: GraphBuilderState, node: Identifier) => { + builder.scope.declare(node, false, null, 1); + } +); + +/* + * Special handler for [obj.member = 42] = [1] in different contexts + */ +const memberExpressionObjectHandler = ( + builder: GraphBuilderState, + node: Identifier +) => { + const context = peek(builder.context); + const declaration = builder.scope.addReference(node); + if (declaration) { + builder.graph.addEdge(node, declaration); + + if (context === 'lval') { + // One exception here: we shake exports, + // so `exports` does not depend on its members' assignments. + if ( + declaration !== ScopeManager.globalExportsIdentifier && + declaration !== ScopeManager.globalModuleIdentifier + ) { + builder.graph.addEdge(declaration, node); + } + } + } +}; + +defineHandler('MemberExpression', 'object', memberExpressionObjectHandler); +defineHandler( + 'OptionalMemberExpression', + 'object', + memberExpressionObjectHandler +); + +/* + * Special handler for obj.member and obj[member] + */ +const memberExpressionPropertyHandler = ( + builder: GraphBuilderState, + node: Identifier, + parent: Node +) => { + if (t.isMemberExpression(parent) && parent.computed) { + const declaration = builder.scope.addReference(node); + // Let's check that it's not a global variable + if (declaration) { + // usage of a variable depends on its declaration + builder.graph.addEdge(node, declaration); + + const context = peek(builder.context); + if (context === 'lval') { + // This is an identifier in the left side of an assignment expression and a variable value depends on that. + builder.graph.addEdge(declaration, node); + } + } + } +}; + +defineHandler('MemberExpression', 'property', memberExpressionPropertyHandler); +defineHandler( + 'OptionalMemberExpression', + 'property', + memberExpressionPropertyHandler +); + +export default handlers; diff --git a/@linaria/packages/shaker/src/index.ts b/@linaria/packages/shaker/src/index.ts new file mode 100644 index 0000000..f71353b --- /dev/null +++ b/@linaria/packages/shaker/src/index.ts @@ -0,0 +1,65 @@ +import generator from '@babel/generator'; +import { transformSync } from '@babel/core'; +import type { Program } from '@babel/types'; +import { debug } from '@linaria/logger'; +import type { Evaluator, StrictOptions } from '@linaria/babel-preset'; +import { buildOptions } from '@linaria/babel-preset'; +import shake from './shaker'; + +function prepareForShake( + filename: string, + options: StrictOptions, + code: string +): Program { + const transformOptions = buildOptions(filename, options); + + transformOptions.ast = true; + transformOptions.presets!.unshift([ + require.resolve('@babel/preset-env'), + { + targets: 'ie 11', + // we need this plugin so we list it explicitly, explanation in `packages/extractor/src/index` + include: ['@babel/plugin-transform-template-literals'], + }, + ]); + transformOptions.presets!.unshift([ + require.resolve('@linaria/preeval'), + options, + ]); + transformOptions.plugins!.unshift( + require.resolve('babel-plugin-transform-react-remove-prop-types') + ); + transformOptions.plugins!.unshift([ + require.resolve('@babel/plugin-transform-runtime'), + { useESModules: false }, + ]); + + debug( + 'evaluator:shaker:transform', + `Transform ${filename} with options ${JSON.stringify( + transformOptions, + null, + 2 + )}` + ); + const transformed = transformSync(code, transformOptions); + + if (transformed === null || !transformed.ast) { + throw new Error(`${filename} cannot be transformed`); + } + + return transformed.ast.program; +} + +const shaker: Evaluator = (filename, options, text, only = null) => { + const [shaken, imports] = shake( + prepareForShake(filename, options, text), + only + ); + + debug('evaluator:shaker:generate', `Generate shaken source code ${filename}`); + const { code: shakenCode } = generator(shaken!); + return [shakenCode, imports]; +}; + +export default shaker; diff --git a/@linaria/packages/shaker/src/langs/core.ts b/@linaria/packages/shaker/src/langs/core.ts new file mode 100644 index 0000000..c0368bb --- /dev/null +++ b/@linaria/packages/shaker/src/langs/core.ts @@ -0,0 +1,653 @@ +import { types as t } from '@babel/core'; +import type { + AssignmentExpression, + Block, + CallExpression, + Directive, + ExpressionStatement, + ForInStatement, + ForStatement, + Function, + Identifier, + IfStatement, + MemberExpression, + Node, + ObjectExpression, + SequenceExpression, + SwitchCase, + SwitchStatement, + Terminatorless, + TryStatement, + VariableDeclaration, + VariableDeclarator, + WhileStatement, +} from '@babel/types'; + +import { peek } from '@linaria/babel-preset'; +import type { IdentifierHandlers, Visitors } from '../types'; +import GraphBuilderState from '../GraphBuilderState'; +import ScopeManager from '../scope'; +import DepsGraph from '../DepsGraph'; + +function isIdentifier( + node: Node, + name?: string | string[] +): node is Identifier { + return ( + t.isIdentifier(node) && + (name === undefined || + (Array.isArray(name) ? name.includes(node.name) : node.name === name)) + ); +} + +type SideEffect = [ + { + callee?: (child: CallExpression['callee']) => boolean; + arguments?: (child: CallExpression['arguments']) => boolean; + }, + (node: CallExpression, state: GraphBuilderState) => void +]; + +const sideEffects: SideEffect[] = [ + [ + // if the first argument of forEach is required, mark forEach as required + { + callee: (node) => + t.isMemberExpression(node) && + t.isIdentifier(node.property) && + node.property.name === 'forEach', + }, + (node, state) => state.graph.addEdge(node.arguments[0], node), + ], +]; + +function getCallee(node: CallExpression): Node { + if ( + t.isSequenceExpression(node.callee) && + node.callee.expressions.length === 2 + ) { + const [first, second] = node.callee.expressions; + if (t.isNumericLiteral(first) && first.value === 0) { + return second; + } + } + + return node.callee; +} + +function findWildcardReexportStatement( + node: t.CallExpression, + identifierName: string, + graph: DepsGraph +): t.Statement | null { + if (!t.isIdentifier(node.callee) || node.callee.name !== 'require') + return null; + + const declarator = graph.getParent(node); + if (!t.isVariableDeclarator(declarator)) return null; + + const declaration = graph.getParent(declarator); + if (!t.isVariableDeclaration(declaration)) return null; + + const program = graph.getParent(declaration); + if (!t.isProgram(program)) return null; + + // Our node is a correct export + // Let's check that we have something that looks like transpiled re-export + return ( + program.body.find((statement) => { + /* + * We are looking for `Object.keys(_bar).forEach(…)` + */ + + if (!t.isExpressionStatement(statement)) return false; + + const expression = statement.expression; + if (!t.isCallExpression(expression)) return false; + + const callee = expression.callee; + if (!t.isMemberExpression(callee)) return false; + + const { object, property } = callee; + + if (!isIdentifier(property, 'forEach')) return false; + + if (!t.isCallExpression(object)) return false; + + // `object` should be `Object.keys` + if ( + !t.isMemberExpression(object.callee) || + !isIdentifier(object.callee.object, 'Object') || + !isIdentifier(object.callee.property, 'keys') + ) + return false; + + // + const [argument] = object.arguments; + return isIdentifier(argument, identifierName); + }) ?? null + ); +} + +/* + * Returns nodes which are implicitly affected by specified node + */ +function getAffectedNodes(node: Node, state: GraphBuilderState): Node[] { + // FIXME: this method should be generalized + const callee = t.isCallExpression(node) ? getCallee(node) : null; + if ( + t.isCallExpression(node) && + t.isMemberExpression(callee) && + isIdentifier(callee.object, 'Object') && + isIdentifier(callee.property, [ + 'assign', + 'defineProperty', + 'defineProperties', + 'freeze', + 'observe', + ]) + ) { + const [obj, property] = node.arguments; + if (!t.isIdentifier(obj)) { + return []; + } + + if ( + state.scope.getDeclaration(obj) !== ScopeManager.globalExportsIdentifier + ) { + return [node.arguments[0]]; + } + + if (t.isStringLiteral(property)) { + if (property.value === '__esModule') { + return [node.arguments[0]]; + } + + state.graph.addExport(property.value, node); + } + } + + return []; +} + +export const visitors: Visitors = { + /* + * FunctionDeclaration | FunctionExpression | ObjectMethod | ArrowFunctionExpression | ClassMethod | ClassPrivateMethod; + * Functions can be either a statement or an expression. + * That's why we need to disable default dependency resolving strategy for expressions by passing `ignoreDeps` flag. + * Every function must have a body. Without a body, it becomes invalid. + * In general, a body depends on parameters of a function. + * In real life, some of the parameters can be omitted, but it's not trivial to implement that type of tree shaking. + */ + Function(this: GraphBuilderState, node: Function) { + const unsubscribe = this.onVisit((descendant) => + this.graph.addEdge(node, descendant) + ); + this.baseVisit(node, true); // ignoreDeps=true prevents default dependency resolving + unsubscribe(); + + this.graph.addEdge(node, node.body); + + node.params.forEach((param) => this.graph.addEdge(node.body, param)); + if ( + t.isFunctionExpression(node) && + node.id !== null && + node.id !== undefined + ) { + // keep function name in expressions like `const a = function a();` + this.graph.addEdge(node, node.id); + } + }, + + /* + * ExpressionStatement + */ + ExpressionStatement(this: GraphBuilderState, node: ExpressionStatement) { + this.baseVisit(node); + + this.graph.addEdge(node, node.expression); + this.graph.addEdge(node.expression, node); + }, + + /* + * BlockStatement | Program + * The same situation as in ExpressionStatement: if one of the expressions is required, the block itself is also required. + * Whereas a block doesn't depend on its children. + * Example: + * 1. let c; + * 2. { // BlockStatement begin + * 3. let a = 1; + * 4. let b = 2; + * 5. a++; + * 6. a = c; + * 7. } // BlockStatement end + * + * If we want to evaluate the value of `c`, we need to evaluate lines 1, 3, 5 and 6, + * but we don't need line 4, even though it's a child of the block. + */ + Block(this: GraphBuilderState, node: Block) { + this.baseVisit(node); + + if (t.isProgram(node)) { + const exportsDeclaration = this.scope.getDeclaration('global:exports')!; + this.graph.addEdge(node, exportsDeclaration); + node.directives.forEach((directive) => + this.graph.addEdge(node, directive) + ); + } + }, + + Directive(this: GraphBuilderState, node: Directive) { + this.baseVisit(node); + this.graph.addEdge(node, node.value); + }, + + /* + * TryStatement + * try { /* block *\/ } catch() {/* handler *\/} finalize {/* finalizer *\/} + * `handler` and `finalizer` do not make sense without `block` + * `block` depends on the whole node. + */ + TryStatement(this: GraphBuilderState, node: TryStatement) { + this.baseVisit(node); + [node.handler, node.finalizer].forEach((statement) => { + if (statement) { + this.graph.addEdge(node.block, statement); + this.graph.addEdge(statement, node.block); + } + }); + }, + + IfStatement(this: GraphBuilderState, node: IfStatement) { + this.baseVisit(node); + this.graph.addEdge(node, node.consequent); + this.graph.addEdge(node, node.test); + }, + + /* + * WhileStatement + * Pretty simple behaviour here: + * • if body is required, the statement is required + * • if the statement is required, the condition is also required. + */ + WhileStatement(this: GraphBuilderState, node: WhileStatement) { + this.baseVisit(node); + this.graph.addEdge(node, node.test); + }, + + SwitchCase(this: GraphBuilderState, node: SwitchCase) { + this.baseVisit(node); + node.consequent.forEach((statement) => this.graph.addEdge(statement, node)); + if (node.test) { + this.graph.addEdge(node, node.test); + } + }, + + SwitchStatement(this: GraphBuilderState, node: SwitchStatement) { + this.baseVisit(node); + node.cases.forEach((c) => this.graph.addEdge(c, node)); + this.graph.addEdge(node, node.discriminant); + }, + + ForStatement(this: GraphBuilderState, node: ForStatement) { + this.baseVisit(node); + + [node.init, node.test, node.update, node.body].forEach((child) => { + if (child) { + this.graph.addEdge(node, child); + } + }); + }, + + /* + * ForInStatement + * for (const k in o) { body } + */ + ForInStatement(this: GraphBuilderState, node: ForInStatement) { + this.baseVisit(node); + + if (node.body) { + this.graph.addEdge(node, node.body); + this.graph.addEdge(node.body, node.left); + } + + this.graph.addEdge(node.left, node.right); + }, + + /* + * BreakStatement | ContinueStatement | ReturnStatement | ThrowStatement | YieldExpression | AwaitExpression + * All these nodes are required to evaluate the value of a function in which they are defined. + * Also, the value of these nodes depends on the argument if it is presented. + */ + Terminatorless(this: GraphBuilderState, node: Terminatorless) { + this.baseVisit(node); + + if ( + !(t.isBreakStatement(node) || t.isContinueStatement(node)) && + node.argument + ) { + this.graph.addEdge(node, node.argument); + } + + const closestFunctionNode = peek(this.fnStack); + this.graph.addEdge(closestFunctionNode, node); + }, + + /* + * ObjectExpression + * Objects are… complicated. Especially because similarly looking code can be either an expression or a pattern. + * In this case we work with an expression like: + * const obj = { + * method() {}, // ObjectMethod + * property: "value", // ObjectProperty + * ...rest, // SpreadElement + * } + */ + ObjectExpression(this: GraphBuilderState, node: ObjectExpression) { + this.context.push('expression'); + this.baseVisit(node); + node.properties.forEach((prop) => { + this.graph.addEdge(node, prop); + if (t.isObjectMethod(prop)) { + this.graph.addEdge(prop, prop.key); + this.graph.addEdge(prop, prop.body); + } else if (t.isObjectProperty(prop)) { + this.graph.addEdge(prop, prop.key); + this.graph.addEdge(prop, prop.value); + } else if (t.isSpreadElement(prop)) { + this.graph.addEdge(prop, prop.argument); + } + }); + this.context.pop(); + }, + + /* + * MemberExpression + * It's about a simple expression like `obj.foo` or `obj['foo']`. + * In addition to default behaviour (an expression depends on all its children), + * we add a backward dependency from an object to a node for processing member + * expressions in assignments. + * + * Example: + * let obj = { a: 1 }; + * obj.b = 2; + * + * If we try to evaluate `obj` without backward dependency, + * `obj.b = 2` will be cut and we will get just `{ a: 1 }`. + */ + MemberExpression(this: GraphBuilderState, node: MemberExpression) { + this.baseVisit(node); + + if ( + isIdentifier(node.object, 'exports') && + this.scope.getDeclaration(node.object) === + ScopeManager.globalExportsIdentifier + ) { + // We treat `exports.something` and `exports['something']` as identifiers in the global scope + this.graph.addEdge(node, node.object); + this.graph.addEdge(node, node.property); + + const isLVal = peek(this.context) === 'lval'; + if (isLVal) { + this.scope.declare(node, false); + } else { + const declaration = this.scope.addReference(node); + this.graph.addEdge(node, declaration); + } + + return; + } + + if ( + t.isIdentifier(node.object) && + ((t.isIdentifier(node.property) && !node.computed) || + t.isStringLiteral(node.property)) + ) { + // It's simple `foo.bar` or `foo["bar"]` expression. Is it a usage of a required library? + const declaration = this.scope.getDeclaration(node.object); + if ( + t.isIdentifier(declaration) && + this.graph.importAliases.has(declaration) + ) { + // It is. We can remember what exactly we use from it. + const source = this.graph.importAliases.get(declaration)!; + this.graph.imports.get(source)!.push(node.property); + } + } + }, + + /* + * AssignmentExpression + * `a = b`, `{ ...rest } = obj`, `obj.a = 3`, etc. + * It's not a declaration, it's just an assignment, but it affects + * the value of declared variable if the variable it mentioned in the left part. + * So, we apply some context-magic here in order to catch reference of variables in the left part. + * We switch the context to `lval` and continue traversing through the left branch. + * If we then meet some identifier, we mark it as a dependency of its declaration. + */ + AssignmentExpression(this: GraphBuilderState, node: AssignmentExpression) { + this.context.push('lval'); + this.visit<AssignmentExpression['left'], AssignmentExpression>( + node.left, + node, + 'left' + ); + this.context.pop(); + + this.visit(node.right, node, 'right'); + + // The value of an expression depends on the left part. + this.graph.addEdge(node, node.left); + + // The left part of an assignment depends on the right part. + this.graph.addEdge(node.left, node.right); + }, + + /* + * VariableDeclarator + * It would be pretty simple if it weren't used to declare variables from other modules. + */ + VariableDeclarator(this: GraphBuilderState, node: VariableDeclarator) { + /* + * declared is used for detecting external dependencies in cases like + * const { a, b, c } = require('module'); + * + * We are remembering all declared variables in order to use it later in CallExpression visitor + */ + const declared: Array<[Identifier, Identifier | null]> = []; + this.meta.set('declared', declared); + const unregister = this.scope.addDeclareHandler((identifier, from) => + declared.push([identifier, from]) + ); + this.baseVisit(node); + this.meta.delete('declared'); + unregister(); + + if (node.init) { + // If there is an initialization part, the identifier depends on it. + this.graph.addEdge(node.id, node.init); + } + + // If a statement is required itself, an id is also required + this.graph.addEdge(node, node.id); + }, + + /* + * VariableDeclaration + * It's just a wrapper for group of VariableDeclarator. + * If one of the declarators is required, the wrapper itself is also required. + */ + VariableDeclaration(this: GraphBuilderState, node: VariableDeclaration) { + this.meta.set('kind-of-declaration', node.kind); + this.baseVisit(node); + node.declarations.forEach((declaration) => + this.graph.addEdge(declaration, node) + ); + this.meta.delete('kind-of-declaration'); + }, + + /* + * CallExpression + * Do you remember that we have already mentioned it in VariableDeclarator? + * It is a simple expression with default behaviour unless it is a `require`. + * + * Another tricky use case here is functions with side effects (e.g. `Object.defineProperty`). + */ + CallExpression( + this: GraphBuilderState, + node: CallExpression, + parent: Node | null + ) { + this.baseVisit(node); + + if (t.isIdentifier(node.callee) && node.callee.name === 'require') { + // It looks like a module import … + const scopeId = this.scope.whereIsDeclared(node.callee); + if (scopeId && scopeId !== 'global') { + // … but it is just a user defined function + return; + } + + const [firstArg] = node.arguments; + if (!t.isStringLiteral(firstArg)) { + // dynamic import? Maybe someday we can do something about it + return; + } + + const { value: source } = firstArg; + const declared = this.meta.get('declared') as Array< + [Identifier, Identifier | null] + >; + if (!declared) { + // This is a standalone `require` + return; + } + + // Define all declared variables as external dependencies. + declared.forEach(([local, _imported]) => + // FIXME: var slugify = require('../slugify').default; + { + if (!this.graph.imports.has(source)) { + this.graph.imports.set(source, []); + } + + if ( + parent && + t.isMemberExpression(parent) && + t.isIdentifier(parent.property) + ) { + // An imported function is specified right here. + // eg. require('../slugify').default + this.graph.imports.get(source)!.push(parent.property); + } else { + if ( + t.isCallExpression(parent) && + t.isIdentifier(parent.callee) && + typeof parent.callee.name === 'string' + ) { + if (parent.callee.name.startsWith('_interopRequireDefault')) { + this.graph.importTypes.set(source, 'default'); + } else if ( + parent.callee.name.startsWith('_interopRequireWildcard') + ) { + this.graph.importTypes.set(source, 'wildcard'); + } else { + // What I've missed? + } + } + + // Do we know the type of import? + if (!this.graph.importTypes.has(source)) { + // Is it a wildcard reexport? Let's check. + const statement = findWildcardReexportStatement( + node, + local.name, + this.graph + ); + if (statement) { + this.graph.addEdge(local, statement); + this.graph.reexports.push(local); + this.graph.importTypes.set(source, 'reexport'); + } + } + + // The whole namespace was imported. We will know later, what exactly we need. + // eg. const slugify = require('../slugify'); + this.graph.importAliases.set(local, source); + } + } + ); + + return; + } + + sideEffects.forEach(([conditions, callback]) => { + if ( + (conditions.callee && !conditions.callee(node.callee)) || + (conditions.arguments && !conditions.arguments(node.arguments)) + ) { + return; + } + + return callback(node, this); + }); + + getAffectedNodes(node, this).forEach((affectedNode) => { + this.graph.addEdge(affectedNode, node); + if (t.isIdentifier(affectedNode)) { + this.graph.addEdge( + this.scope.getDeclaration(affectedNode)!, + affectedNode + ); + } + }); + }, + + /* + * SequenceExpression + * It is a special case of expression in which the value of the whole + * expression depends only on the last subexpression in the list. + * The rest of the subexpressions can be omitted if they don't have dependent nodes. + * + * Example: + * const a = (1, 2, b = 3, 4, b + 2); // `a` will be equal 5 + */ + SequenceExpression(this: GraphBuilderState, node: SequenceExpression) { + // Sequence value depends on only last expression in the list + this.baseVisit(node, true); + if (node.expressions.length > 0) { + this.graph.addEdge(node, node.expressions[node.expressions.length - 1]); + } + }, +}; + +export const identifierHandlers: IdentifierHandlers = { + declare: [ + ['CatchClause', 'param'], + ['Function', 'params'], + ['FunctionExpression', 'id'], + ['RestElement', 'argument'], + ['ThrowStatement', 'argument'], + ['VariableDeclarator', 'id'], + ], + keep: [['ObjectProperty', 'key']], + refer: [ + ['ArrayExpression', 'elements'], + ['AssignmentExpression', 'left', 'right'], + ['BinaryExpression', 'left', 'right'], + ['CallExpression', 'arguments', 'callee'], + ['ConditionalExpression', 'test', 'consequent', 'alternate'], + ['ForInStatement', 'right'], + ['Function', 'body'], + ['IfStatement', 'test'], + ['LogicalExpression', 'left', 'right'], + ['NewExpression', 'arguments', 'callee'], + ['ObjectProperty', 'value'], + ['ReturnStatement', 'argument'], + ['SequenceExpression', 'expressions'], + ['SwitchStatement', 'discriminant'], + ['UnaryExpression', 'argument'], + ['UpdateExpression', 'argument'], + ['VariableDeclarator', 'init'], + ], +}; diff --git a/@linaria/packages/shaker/src/scope.ts b/@linaria/packages/shaker/src/scope.ts new file mode 100644 index 0000000..2b496b0 --- /dev/null +++ b/@linaria/packages/shaker/src/scope.ts @@ -0,0 +1,210 @@ +import { types as t } from '@babel/core'; +import invariant from 'ts-invariant'; + +type Scope = Map<string, Set<t.Identifier | t.MemberExpression>>; + +export type ScopeId = number | 'global' | 'exports'; +export type DeclareHandler = ( + identifier: t.Identifier, + from: t.Identifier | null +) => void; + +const ResolvedNode = Symbol('ResolvedNode'); +const functionScopes = new WeakSet<Scope>(); + +export class PromisedNode<T = t.Node> { + static is<TNode>(obj: any): obj is PromisedNode<TNode> { + return obj && ResolvedNode in obj; + } + + [ResolvedNode]: T | undefined; + + get identifier(): T | undefined { + return this[ResolvedNode]; + } +} + +export const resolveNode = <T = t.Node>( + obj: T | PromisedNode<T> | undefined +): T | undefined => (PromisedNode.is<T>(obj) ? obj.identifier : obj); + +const getExportName = (node: t.Node): string => { + invariant( + t.isMemberExpression(node), + `getExportName expects MemberExpression but received ${node.type}` + ); + + const { object, property } = node; + invariant( + t.isIdentifier(object) && object.name === 'exports', + `getExportName expects a member expression with 'exports'` + ); + invariant( + t.isIdentifier(property) || t.isStringLiteral(property), + `getExportName supports only identifiers and literals as names of exported values` + ); + + const name = t.isIdentifier(property) ? property.name : property.value; + return `exports.${name}`; +}; + +const scopeIds = new WeakMap<Scope, ScopeId>(); +const getId = (scope: Scope, identifier: t.Identifier | string): string => { + const scopeId = scopeIds.get(scope); + return `${scopeId}:${ + typeof identifier === 'string' ? identifier : identifier.name + }`; +}; + +export default class ScopeManager { + public static globalExportsIdentifier = t.identifier('exports'); + public static globalModuleIdentifier = t.identifier('module'); + private nextId = 0; + private readonly stack: Array<Scope> = []; + private readonly map: Map<ScopeId, Scope> = new Map(); + private readonly handlers: Map<ScopeId, Array<DeclareHandler>> = new Map(); + private readonly declarations: Map< + string, + t.Identifier | t.MemberExpression | PromisedNode<t.Identifier> + > = new Map(); + + private get global(): Scope { + return this.map.get('global')!; + } + + constructor() { + this.new(true, 'global'); + this.declare(ScopeManager.globalExportsIdentifier, false); + this.declare(ScopeManager.globalModuleIdentifier, false); + } + + new(isFunction: boolean, scopeId: ScopeId = this.nextId++): Scope { + const scope: Scope = new Map(); + if (isFunction) { + functionScopes.add(scope); + } + + scopeIds.set(scope, scopeId); + this.map.set(scopeId, scope); + this.handlers.set(scopeId, []); + this.stack.unshift(scope); + return scope; + } + + dispose(): Scope | undefined { + const disposed = this.stack.shift(); + if (disposed) { + this.map.delete(scopeIds.get(disposed)!); + } + + return disposed; + } + + declare( + identifierOrMemberExpression: t.Identifier | t.MemberExpression, + isHoistable: boolean, + from: t.Identifier | null = null, + stack = 0 + ): void { + if (t.isMemberExpression(identifierOrMemberExpression)) { + // declare receives MemberExpression only if it's `exports.something` expression + const memberExp = identifierOrMemberExpression; + const name = getExportName(memberExp); + if (!this.global.has(name)) { + this.global.set(name, new Set()); + } + + // There can be a few `export.foo = …` statements, but we need only the last one + this.declarations.set(getId(this.global, name), memberExp); + this.global.get(name)!.add(memberExp); + return; + } + + const identifier = identifierOrMemberExpression; + const idName = identifier.name; + const scope = this.stack + .slice(stack) + .find((s) => !isHoistable || functionScopes.has(s))!; + if (this.global.has(idName)) { + // It's probably a declaration of a previous referenced identifier + // Let's use naïve implementation of hoisting + const promise = this.declarations.get( + getId(this.global, identifier) + )! as PromisedNode<t.Identifier>; + promise[ResolvedNode] = identifier; + scope.set( + idName, + new Set([identifier, ...Array.from(this.global.get(idName)!)]) + ); + this.global.delete(idName); + } else { + scope.set(idName, new Set([identifier])); + } + + this.declarations.set(getId(scope, identifier), identifier); + const handlers = this.handlers.get(scopeIds.get(scope)!)!; + handlers.forEach((handler) => handler(identifier, from)); + } + + addReference( + identifierOrMemberExpression: t.Identifier | t.MemberExpression + ): t.Identifier | t.MemberExpression | PromisedNode { + const name = t.isIdentifier(identifierOrMemberExpression) + ? identifierOrMemberExpression.name + : getExportName(identifierOrMemberExpression); + const scope = this.stack.find((s) => s.has(name)) ?? this.global; + const id = getId(scope, name); + if (scope === this.global && !scope.has(name)) { + scope.set(name, new Set()); + this.declarations.set(id, new PromisedNode()); + } + + scope.get(name)!.add(identifierOrMemberExpression); + return this.declarations.get(id)!; + } + + whereIsDeclared(identifier: t.Identifier): ScopeId | undefined { + const name = identifier.name; + const scope = this.stack.find( + (s) => s.has(name) && s.get(name)!.has(identifier) + ); + if (scope) { + return scopeIds.get(scope); + } + + if (this.global.has(name)) { + return 'global'; + } + + return undefined; + } + + getDeclaration( + identifierOrMemberExpOrName: t.Identifier | t.MemberExpression | string + ): t.Identifier | t.MemberExpression | undefined { + let name: string; + if (typeof identifierOrMemberExpOrName === 'string') { + name = identifierOrMemberExpOrName; + } else if (t.isMemberExpression(identifierOrMemberExpOrName)) { + name = getId(this.global, getExportName(identifierOrMemberExpOrName)); + } else { + const scopeId = this.whereIsDeclared(identifierOrMemberExpOrName); + if (scopeId === undefined) { + return undefined; + } + + name = getId(this.map.get(scopeId)!, identifierOrMemberExpOrName); + } + + return resolveNode(this.declarations.get(name)); + } + + addDeclareHandler(handler: DeclareHandler): () => void { + const scopeId = scopeIds.get(this.stack[0])!; + this.handlers.get(scopeId)!.push(handler); + return () => { + const handlers = this.handlers.get(scopeId)!.filter((h) => h !== handler); + this.handlers.set(scopeId, handlers); + }; + } +} diff --git a/@linaria/packages/shaker/src/shaker.ts b/@linaria/packages/shaker/src/shaker.ts new file mode 100644 index 0000000..f2584f3 --- /dev/null +++ b/@linaria/packages/shaker/src/shaker.ts @@ -0,0 +1,127 @@ +import type { Node, Program } from '@babel/types'; +import generator from '@babel/generator'; +import { debug } from '@linaria/logger'; +import { isNode, getVisitorKeys } from '@linaria/babel-preset'; +import build from './graphBuilder'; +import dumpNode from './dumpNode'; + +/* + * Returns new tree without dead nodes + */ +function shakeNode<TNode extends Node>(node: TNode, alive: Set<Node>): Node { + const keys = getVisitorKeys(node) as Array<keyof TNode>; + const changes: Partial<TNode> = {}; + const isNodeAlive = (n: Node) => alive.has(n); + + for (const key of keys) { + const subNode = node[key]; + + if (Array.isArray(subNode)) { + const list: any = []; + let hasChanges = false; + for (let i = 0; i < subNode.length; i++) { + const child = subNode[i]; + const isAlive = isNodeAlive(child); + hasChanges = hasChanges || !isAlive; + if (child && isAlive) { + const shaken = shakeNode(child, alive); + if (shaken) { + list.push(shaken); + } + + hasChanges = hasChanges || shaken !== child; + } + } + if (hasChanges) { + changes[key] = list; + } + } else if (isNode(subNode)) { + if (isNodeAlive(subNode)) { + const shaken = shakeNode(subNode, alive); + if (shaken && shaken !== subNode) { + changes[key] = shaken as any; + } + } else { + changes[key] = undefined; + } + } + } + + return Object.keys(changes).length ? { ...node, ...changes } : node; +} + +/* + * Gets AST and a list of nodes for evaluation + * Removes unrelated “dead” code. + * Adds to the end of module export of array of evaluated values or evaluation errors. + * Returns new AST and an array of external dependencies. + */ +export default function shake( + rootPath: Program, + exports: string[] | null +): [Program, Map<string, string[]>] { + debug( + 'evaluator:shaker:shake', + () => + `source (exports: ${(exports || []).join(', ')}):\n${ + generator(rootPath).code + }` + ); + + const depsGraph = build(rootPath); + const alive = new Set<Node>(); + const reexports: string[] = []; + let deps = (exports ?? []) + .map((token) => { + const node = depsGraph.getLeaf(token); + if (node) return [node]; + // We have some unknown token. Do we have `export * from …` in that file? + if (depsGraph.reexports.length === 0) { + return []; + } + + // If so, mark all re-exported files as required + reexports.push(token); + return [...depsGraph.reexports]; + }) + .reduce<Node[]>((acc, el) => { + acc.push(...el); + return acc; + }, []); + while (deps.length > 0) { + // Mark all dependencies as alive + deps.forEach((d) => alive.add(d)); + + // Collect new dependencies of dependencies + deps = depsGraph.getDependencies(deps).filter((d) => !alive.has(d)); + } + + const shaken = shakeNode(rootPath, alive) as Program; + /* + * If we want to know what is really happen with our code tree, + * we can print formatted tree here by setting env variable LINARIA_LOG=debug + */ + debug('evaluator:shaker:shake', () => dumpNode(rootPath, alive)); + + const imports = new Map<string, string[]>(); + for (let [source, members] of depsGraph.imports.entries()) { + const importType = depsGraph.importTypes.get(source); + const defaultMembers = importType === 'wildcard' ? ['*'] : []; + const aliveMembers = new Set( + members + .filter((i) => alive.has(i)) + .map((i) => (i.type === 'Identifier' ? i.name : i.value)) + ); + + if (importType === 'reexport') { + reexports.forEach((token) => aliveMembers.add(token)); + } + + imports.set( + source, + aliveMembers.size > 0 ? Array.from(aliveMembers) : defaultMembers + ); + } + + return [shaken, imports]; +} diff --git a/@linaria/packages/shaker/src/types.ts b/@linaria/packages/shaker/src/types.ts new file mode 100644 index 0000000..582e014 --- /dev/null +++ b/@linaria/packages/shaker/src/types.ts @@ -0,0 +1,22 @@ +import type { Aliases, Node, VisitorKeys } from '@babel/types'; + +export type NodeOfType<T> = Extract<Node, { type: T }>; + +export type NodeType = Node['type'] | keyof Aliases; + +export type VisitorAction = 'ignore' | void; + +export type Visitor<TNode extends Node> = <TParent extends Node>( + node: TNode, + parent: TParent | null, + parentKey: VisitorKeys[TParent['type']] | null, + listIdx: number | null +) => VisitorAction; + +export type Visitors = { [TMethod in NodeType]?: Visitor<NodeOfType<TMethod>> }; + +export type IdentifierHandlerType = 'declare' | 'keep' | 'refer'; + +export type IdentifierHandlers = { + [key in IdentifierHandlerType]: [NodeType, ...string[]][]; +}; |