From f0de9a8b5d577221d3180c9e135d5cbda8a337c3 Mon Sep 17 00:00:00 2001 From: "Alex Lam S.L" Date: Mon, 3 May 2021 03:08:29 +0100 Subject: [PATCH] support optional chaining operator (#4899) --- lib/ast.js | 22 ++-- lib/compress.js | 86 ++++++++++---- lib/mozilla-ast.js | 7 +- lib/output.js | 12 +- lib/parse.js | 46 +++++--- test/compress/optional-chains.js | 196 +++++++++++++++++++++++++++++++ test/input/invalid/assign_4.js | 2 +- test/input/invalid/assign_5.js | 1 + test/mocha/cli.js | 24 +++- test/release/rollup-ts.sh | 2 +- test/release/sucrase.sh | 2 +- test/ufuzz/index.js | 24 +++- 12 files changed, 353 insertions(+), 71 deletions(-) create mode 100644 test/compress/optional-chains.js create mode 100644 test/input/invalid/assign_5.js diff --git a/lib/ast.js b/lib/ast.js index 22942beba3..71b57f2f36 100644 --- a/lib/ast.js +++ b/lib/ast.js @@ -261,9 +261,9 @@ var AST_BlockScope = DEFNODE("BlockScope", "enclosed functions make_def parent_s $documentation: "Base class for all statements introducing a lexical scope", $propdoc: { enclosed: "[SymbolDef*/S] a list of all symbol definitions that are accessed from this scope or any subscopes", - functions: "[Object/S] like `variables`, but only lists function declarations", + functions: "[Dictionary/S] like `variables`, but only lists function declarations", parent_scope: "[AST_Scope?/S] link to the parent scope", - variables: "[Object/S] a map of name ---> SymbolDef for all variables/functions defined in this scope", + variables: "[Dictionary/S] a map of name ---> SymbolDef for all variables/functions defined in this scope", }, clone: function(deep) { var node = this._clone(deep); @@ -506,7 +506,7 @@ var AST_Scope = DEFNODE("Scope", "uses_eval uses_with", { var AST_Toplevel = DEFNODE("Toplevel", "globals", { $documentation: "The toplevel scope", $propdoc: { - globals: "[Object/S] a map of name ---> SymbolDef for all undeclared names", + globals: "[Dictionary/S] a map of name ---> SymbolDef for all undeclared names", }, wrap: function(name) { var body = this.body; @@ -1293,11 +1293,13 @@ function must_be_expressions(node, prop, allow_spread, allow_hole) { }); } -var AST_Call = DEFNODE("Call", "expression args pure", { +var AST_Call = DEFNODE("Call", "args expression optional pure", { $documentation: "A function call expression", $propdoc: { + args: "[AST_Node*] array of arguments", expression: "[AST_Node] expression to invoke as function", - args: "[AST_Node*] array of arguments" + optional: "[boolean] whether the expression is optional chaining", + pure: "[string/S] marker for side-effect-free call expression", }, walk: function(visitor) { var node = this; @@ -1315,7 +1317,10 @@ var AST_Call = DEFNODE("Call", "expression args pure", { }); var AST_New = DEFNODE("New", null, { - $documentation: "An object instantiation. Derives from a function call since it has exactly the same properties" + $documentation: "An object instantiation. Derives from a function call since it has exactly the same properties", + _validate: function() { + if (this.optional) throw new Error("optional must be false"); + }, }, AST_Call); var AST_Sequence = DEFNODE("Sequence", "expressions", { @@ -1337,11 +1342,12 @@ var AST_Sequence = DEFNODE("Sequence", "expressions", { }, }); -var AST_PropAccess = DEFNODE("PropAccess", "expression property", { +var AST_PropAccess = DEFNODE("PropAccess", "expression optional property", { $documentation: "Base class for property access expressions, i.e. `a.foo` or `a[\"foo\"]`", $propdoc: { expression: "[AST_Node] the “container” expression", - property: "[AST_Node|string] the property to access. For AST_Dot this is always a plain string, while for AST_Sub it's an arbitrary AST_Node" + optional: "[boolean] whether the expression is optional chaining", + property: "[AST_Node|string] the property to access. For AST_Dot this is always a plain string, while for AST_Sub it's an arbitrary AST_Node", }, getProperty: function() { var p = this.property; diff --git a/lib/compress.js b/lib/compress.js index b1971f8a0c..d58b68bcef 100644 --- a/lib/compress.js +++ b/lib/compress.js @@ -82,6 +82,7 @@ function Compressor(options, false_by_default) { merge_vars : !false_by_default, negate_iife : !false_by_default, objects : !false_by_default, + optional_chains : !false_by_default, passes : 1, properties : !false_by_default, pure_funcs : null, @@ -698,9 +699,7 @@ merge(Compressor.prototype, { if (save) fixed = function() { return make_node(AST_Sub, node, { expression: save(), - property: make_node(AST_Number, node, { - value: index - }) + property: make_node(AST_Number, node, { value: index }), }); }; node.walk(scanner); @@ -958,15 +957,18 @@ merge(Compressor.prototype, { exp.walk(tw); if (iife) delete exp.reduce_vars; return true; - } else if (exp instanceof AST_SymbolRef) { + } + if (exp instanceof AST_SymbolRef) { var def = exp.definition(); if (this.TYPE == "Call" && tw.in_boolean_context()) def.bool_fn++; - if (!(def.fixed instanceof AST_LambdaDefinition)) return; - var defun = mark_defun(tw, def); - if (!defun) return; - descend(); - defun.walk(tw); - return true; + if (def.fixed instanceof AST_LambdaDefinition) { + var defun = mark_defun(tw, def); + if (defun) { + descend(); + defun.walk(tw); + return true; + } + } } else if (this.TYPE == "Call" && exp instanceof AST_Assign && exp.operator == "=" @@ -974,6 +976,14 @@ merge(Compressor.prototype, { && tw.in_boolean_context()) { exp.left.definition().bool_fn++; } + if (!this.optional) return; + exp.walk(tw); + push(tw); + this.args.forEach(function(arg) { + arg.walk(tw); + }); + pop(tw); + return true; }); def(AST_Class, function(tw, descend, compressor) { var node = this; @@ -1143,6 +1153,14 @@ merge(Compressor.prototype, { walk_defuns(tw, fn); return true; }); + def(AST_Sub, function(tw) { + if (!this.optional) return; + this.expression.walk(tw); + push(tw); + this.property.walk(tw); + pop(tw); + return true; + }); def(AST_Switch, function(tw, descend, compressor) { this.variables.each(function(def) { reset_def(tw, compressor, def); @@ -2068,9 +2086,11 @@ merge(Compressor.prototype, { function in_conditional(node, parent) { if (parent instanceof AST_Assign) return parent.left !== node && lazy_op[parent.operator.slice(0, -1)]; if (parent instanceof AST_Binary) return parent.left !== node && lazy_op[parent.operator]; + if (parent instanceof AST_Call) return parent.optional && parent.expression !== node; if (parent instanceof AST_Case) return parent.expression !== node; if (parent instanceof AST_Conditional) return parent.condition !== node; - return parent instanceof AST_If && parent.condition !== node; + if (parent instanceof AST_If) return parent.condition !== node; + if (parent instanceof AST_Sub) return parent.optional && parent.expression !== node; } function is_last_node(node, parent) { @@ -2132,7 +2152,8 @@ merge(Compressor.prototype, { var exp = node.expression; return side_effects || exp instanceof AST_SymbolRef && is_arguments(exp.definition()) - || !value_def && (in_try || !lhs_local) && exp.may_throw_on_access(compressor); + || !value_def && (in_try || !lhs_local) + && !node.optional && exp.may_throw_on_access(compressor); } if (node instanceof AST_Spread) return true; if (node instanceof AST_SymbolRef) { @@ -4983,7 +5004,7 @@ merge(Compressor.prototype, { return any(this.properties, compressor); }); def(AST_Dot, function(compressor) { - return this.expression.may_throw_on_access(compressor) + return !this.optional && this.expression.may_throw_on_access(compressor) || this.expression.has_side_effects(compressor); }); def(AST_EmptyStatement, return_false); @@ -5014,7 +5035,7 @@ merge(Compressor.prototype, { return this.body.has_side_effects(compressor); }); def(AST_Sub, function(compressor) { - return this.expression.may_throw_on_access(compressor) + return !this.optional && this.expression.may_throw_on_access(compressor) || this.expression.has_side_effects(compressor) || this.property.has_side_effects(compressor); }); @@ -5102,7 +5123,7 @@ merge(Compressor.prototype, { return any(this.definitions, compressor); }); def(AST_Dot, function(compressor) { - return this.expression.may_throw_on_access(compressor) + return !this.optional && this.expression.may_throw_on_access(compressor) || this.expression.may_throw(compressor); }); def(AST_If, function(compressor) { @@ -5130,7 +5151,7 @@ merge(Compressor.prototype, { return this.body.may_throw(compressor); }); def(AST_Sub, function(compressor) { - return this.expression.may_throw_on_access(compressor) + return !this.optional && this.expression.may_throw_on_access(compressor) || this.expression.may_throw(compressor) || this.property.may_throw(compressor); }); @@ -7592,7 +7613,7 @@ merge(Compressor.prototype, { def(AST_Constant, return_null); def(AST_Dot, function(compressor, first_in_statement) { var expr = this.expression; - if (expr.may_throw_on_access(compressor)) return this; + if (!this.optional && expr.may_throw_on_access(compressor)) return this; return expr.drop_side_effect_free(compressor, first_in_statement); }); def(AST_Function, function(compressor) { @@ -8510,6 +8531,18 @@ merge(Compressor.prototype, { OPT(AST_Const, varify); OPT(AST_Let, varify); + function trim_optional_chain(self, compressor) { + if (!compressor.option("optional_chains")) return; + if (!self.optional) return; + var expr = self.expression; + var ev = expr.evaluate(compressor, true); + if (ev == null) return make_node(AST_UnaryPrefix, self, { + operator: "void", + expression: expr, + }).optimize(compressor); + if (!(ev instanceof AST_Node)) self.optional = false; + } + function lift_sequence_in_expression(node, compressor) { var exp = node.expression; if (!(exp instanceof AST_Sequence)) return node; @@ -8616,6 +8649,8 @@ merge(Compressor.prototype, { OPT(AST_Call, function(self, compressor) { var exp = self.expression; + var terminated = trim_optional_chain(self, compressor); + if (terminated) return terminated; if (compressor.option("sequences")) { if (exp instanceof AST_PropAccess) { var seq = lift_sequence_in_expression(exp, compressor); @@ -8828,7 +8863,7 @@ merge(Compressor.prototype, { return make_node(AST_Call, self, { expression: make_node(AST_Dot, exp, { expression: exp.expression, - property: "call" + property: "call", }), args: args }).optimize(compressor); @@ -11370,6 +11405,8 @@ merge(Compressor.prototype, { OPT(AST_Sub, function(self, compressor) { var expr = self.expression; var prop = self.property; + var terminated = trim_optional_chain(self, compressor); + if (terminated) return terminated; if (compressor.option("properties")) { var key = prop.evaluate(compressor); if (key !== prop) { @@ -11388,8 +11425,9 @@ merge(Compressor.prototype, { if (is_identifier_string(property) && property.length <= prop.print_to_string().length + 1) { return make_node(AST_Dot, self, { + optional: self.optional, expression: expr, - property: property + property: property, }).optimize(compressor); } } @@ -11496,12 +11534,8 @@ merge(Compressor.prototype, { values.push(retValue); return make_sequence(self, values).optimize(compressor); } else return make_node(AST_Sub, self, { - expression: make_node(AST_Array, expr, { - elements: values - }), - property: make_node(AST_Number, prop, { - value: index - }) + expression: make_node(AST_Array, expr, { elements: values }), + property: make_node(AST_Number, prop, { value: index }), }); } } @@ -11597,6 +11631,8 @@ merge(Compressor.prototype, { } var parent = compressor.parent(); if (is_lhs(compressor.self(), parent)) return self; + var terminated = trim_optional_chain(self, compressor); + if (terminated) return terminated; if (compressor.option("sequences") && parent.TYPE != "Call" && !(parent instanceof AST_ForEnumeration && parent.init === self)) { diff --git a/lib/mozilla-ast.js b/lib/mozilla-ast.js index bfc6ac3126..9e5e411ffc 100644 --- a/lib/mozilla-ast.js +++ b/lib/mozilla-ast.js @@ -274,6 +274,7 @@ return new (M.computed ? AST_Sub : AST_Dot)({ start: my_start_token(M), end: my_end_token(M), + optional: M.optional, expression: from_moz(M.object), property: M.computed ? from_moz(M.property) : M.property.name, }); @@ -554,6 +555,9 @@ node.end.parens.push(my_end_token(M)); return node; }, + ChainExpression: function(M) { + return from_moz(M.expression); + }, }; MOZ_TO_ME.UpdateExpression = @@ -593,7 +597,7 @@ map("AssignmentPattern", AST_DefaultValue, "left>name, right>value"); map("ConditionalExpression", AST_Conditional, "test>condition, consequent>consequent, alternate>alternative"); map("NewExpression", AST_New, "callee>expression, arguments@args, pure=pure"); - map("CallExpression", AST_Call, "callee>expression, arguments@args, pure=pure"); + map("CallExpression", AST_Call, "callee>expression, arguments@args, optional=optional, pure=pure"); map("SequenceExpression", AST_Sequence, "expressions@expressions"); map("SpreadElement", AST_Spread, "argument>expression"); map("ObjectExpression", AST_Object, "properties@properties"); @@ -868,6 +872,7 @@ type: "MemberExpression", object: to_moz(M.expression), computed: computed, + optional: M.optional, property: computed ? to_moz(M.property) : { type: "Identifier", name: M.property, diff --git a/lib/output.js b/lib/output.js index 75ccca2d98..6272264fa4 100644 --- a/lib/output.js +++ b/lib/output.js @@ -1473,6 +1473,7 @@ function OutputStream(options) { var self = this; print_annotation(self, output); self.expression.print(output); + if (self.optional) output.print("?."); print_call_args(self, output); }); DEFPRINT(AST_New, function(output) { @@ -1501,22 +1502,23 @@ function OutputStream(options) { expr.print(output); var prop = self.property; if (output.option("ie8") && RESERVED_WORDS[prop]) { - output.print("["); + output.print(self.optional ? "?.[" : "["); output.add_mapping(self.end); output.print_string(prop); output.print("]"); } else { if (expr instanceof AST_Number && !/[ex.)]/i.test(output.last())) output.print("."); - output.print("."); + output.print(self.optional ? "?." : "."); // the name after dot would be mapped about here. output.add_mapping(self.end); output.print_name(prop); } }); DEFPRINT(AST_Sub, function(output) { - this.expression.print(output); - output.print("["); - this.property.print(output); + var self = this; + self.expression.print(output); + output.print(self.optional ? "?.[" : "["); + self.property.print(output); output.print("]"); }); DEFPRINT(AST_Spread, function(output) { diff --git a/lib/parse.js b/lib/parse.js index dd8e70e93f..2930039a7b 100644 --- a/lib/parse.js +++ b/lib/parse.js @@ -2245,44 +2245,52 @@ function parse($TEXT, options) { }); } - var subscripts = function(expr, allow_calls) { + var subscripts = function(expr, allow_calls, optional) { var start = expr.start; - if (is("punc", ".")) { - next(); - return subscripts(new AST_Dot({ - start : start, - expression : expr, - property : as_name(), - end : prev() - }), allow_calls); - } if (is("punc", "[")) { next(); var prop = expression(); expect("]"); return subscripts(new AST_Sub({ - start : start, - expression : expr, - property : prop, - end : prev() + start: start, + optional: optional, + expression: expr, + property: prop, + end: prev(), }), allow_calls); } if (allow_calls && is("punc", "(")) { next(); var call = new AST_Call({ - start : start, - expression : expr, - args : expr_list(")", !options.strict), - end : prev() + start: start, + optional: optional, + expression: expr, + args: expr_list(")", !options.strict), + end: prev(), }); return subscripts(call, true); } + if (optional || is("punc", ".")) { + if (!optional) next(); + return subscripts(new AST_Dot({ + start: start, + optional: optional, + expression: expr, + property: as_name(), + end: prev(), + }), allow_calls); + } if (is("punc", "`")) { var tmpl = template(expr); tmpl.start = expr.start; tmpl.end = prev(); return subscripts(tmpl, allow_calls); } + if (is("operator", "?") && is_token(peek(), "punc", ".")) { + next(); + next(); + return subscripts(expr, allow_calls, true); + } if (expr instanceof AST_Call && !expr.pure) { var start = expr.start; var comments = start.comments_before; @@ -2405,7 +2413,7 @@ function parse($TEXT, options) { }; function is_assignable(expr) { - return expr instanceof AST_PropAccess || expr instanceof AST_SymbolRef; + return expr instanceof AST_PropAccess && !expr.optional || expr instanceof AST_SymbolRef; } function to_destructured(node) { diff --git a/test/compress/optional-chains.js b/test/compress/optional-chains.js new file mode 100644 index 0000000000..f55136ec14 --- /dev/null +++ b/test/compress/optional-chains.js @@ -0,0 +1,196 @@ +call: { + input: { + console.log?.(undefined?.(console.log("FAIL"))); + } + expect_exact: 'console.log?.((void 0)?.(console.log("FAIL")));' + expect_stdout: "undefined" + node_version: ">=14" +} + +dot: { + input: { + console?.log((void 0)?.p); + } + expect_exact: "console?.log((void 0)?.p);" + expect_stdout: "undefined" + node_version: ">=14" +} + +dot_in: { + input: { + var o = { in: 42 }; + console.log(o.in, o?.in); + } + expect_exact: "var o={in:42};console.log(o.in,o?.in);" + expect_stdout: "42 42" + node_version: ">=14" +} + +sub: { + input: { + console?.["log"](null?.[console.log("FAIL")]); + } + expect_exact: 'console?.["log"](null?.[console.log("FAIL")]);' + expect_stdout: "undefined" + node_version: ">=14" +} + +ternary_decimal: { + input: { + null ? .42 : console.log("PASS"); + } + expect_exact: 'null?.42:console.log("PASS");' + expect_stdout: "PASS" +} + +collapse_vars_1: { + options = { + collapse_vars: true, + } + input: { + var a; + A = 42; + a?.[42]; + console.log(typeof A); + } + expect: { + var a; + A = 42; + a?.[42]; + console.log(typeof A); + } + expect_stdout: "number" + node_version: ">=14" +} + +collapse_vars_2: { + options = { + collapse_vars: true, + } + input: { + var a; + A = 42; + a?.(42); + console.log(typeof A); + } + expect: { + var a; + A = 42; + a?.(42); + console.log(typeof A); + } + expect_stdout: "number" + node_version: ">=14" +} + +properties: { + options = { + evaluate: true, + properties: true, + } + input: { + var a; + console.log(a?.["FAIL"]); + } + expect: { + var a; + console.log(a?.FAIL); + } + expect_stdout: "undefined" + node_version: ">=14" +} + +reduce_vars_1: { + options = { + evaluate: true, + reduce_vars: true, + toplevel: true, + } + input: { + var a = 1; + null?.[a = 0]; + console.log(a ? "PASS" : "FAIL"); + } + expect: { + var a = 1; + null?.[a = 0]; + console.log(a ? "PASS" : "FAIL"); + } + expect_stdout: "PASS" + node_version: ">=14" +} + +reduce_vars_2: { + options = { + evaluate: true, + reduce_vars: true, + toplevel: true, + } + input: { + var a = 1; + null?.(a = 0); + console.log(a ? "PASS" : "FAIL"); + } + expect: { + var a = 1; + null?.(a = 0); + console.log(a ? "PASS" : "FAIL"); + } + expect_stdout: "PASS" + node_version: ">=14" +} + +side_effects: { + options = { + side_effects: true, + } + input: { + var a; + a?.[a = "FAIL"]; + console.log(a); + } + expect: { + var a; + a?.[a = "FAIL"]; + console.log(a); + } + expect_stdout: "undefined" + node_version: ">=14" +} + +trim_1: { + options = { + evaluate: true, + optional_chains: true, + reduce_vars: true, + unsafe: true, + } + input: { + (function(a, b) { + console?.log?.(a?.p, b?.[console.log("FAIL")]); + })?.({ p: "PASS" }); + } + expect: { + (function(a, b) { + console?.log?.(a.p, void 0); + })({ p: "PASS" }); + } + expect_stdout: "PASS undefined" + node_version: ">=14" +} + +trim_2: { + options = { + evaluate: true, + optional_chains: true, + side_effects: true, + } + input: { + (void console.log("PASS"))?.[console.log("FAIL")]; + } + expect: { + console.log("PASS"); + } + expect_stdout: "PASS" + node_version: ">=14" +} diff --git a/test/input/invalid/assign_4.js b/test/input/invalid/assign_4.js index d4d6b11358..787a0f3ed9 100644 --- a/test/input/invalid/assign_4.js +++ b/test/input/invalid/assign_4.js @@ -1 +1 @@ -++null +console.log(4 || (null = 4)); diff --git a/test/input/invalid/assign_5.js b/test/input/invalid/assign_5.js new file mode 100644 index 0000000000..1a850dd08e --- /dev/null +++ b/test/input/invalid/assign_5.js @@ -0,0 +1 @@ +console.log(5 || ([]?.length ^= 5)); diff --git a/test/mocha/cli.js b/test/mocha/cli.js index 5b84eba6bb..150af5a31a 100644 --- a/test/mocha/cli.js +++ b/test/mocha/cli.js @@ -427,16 +427,30 @@ describe("bin/uglifyjs", function() { done(); }); }); - it("Should throw syntax error (++null)", function(done) { + it("Should throw syntax error (null = 4)", function(done) { var command = uglifyjscmd + " test/input/invalid/assign_4.js"; exec(command, function(err, stdout, stderr) { assert.ok(err); assert.strictEqual(stdout, ""); assert.strictEqual(stderr.split(/\n/).slice(0, 4).join("\n"), [ - "Parse error at test/input/invalid/assign_4.js:1,0", - "++null", - "^", - "ERROR: Invalid use of ++ operator", + "Parse error at test/input/invalid/assign_4.js:1,23", + "console.log(4 || (null = 4));", + " ^", + "ERROR: Invalid assignment", + ].join("\n")); + done(); + }); + }); + it("Should throw syntax error ([]?.length ^= 5)", function(done) { + var command = uglifyjscmd + " test/input/invalid/assign_5.js"; + exec(command, function(err, stdout, stderr) { + assert.ok(err); + assert.strictEqual(stdout, ""); + assert.strictEqual(stderr.split(/\n/).slice(0, 4).join("\n"), [ + "Parse error at test/input/invalid/assign_5.js:1,29", + "console.log(5 || ([]?.length ^= 5));", + " ^", + "ERROR: Invalid assignment", ].join("\n")); done(); }); diff --git a/test/release/rollup-ts.sh b/test/release/rollup-ts.sh index fa7c56f547..a70adb197c 100755 --- a/test/release/rollup-ts.sh +++ b/test/release/rollup-ts.sh @@ -11,7 +11,7 @@ minify_in_situ() { do echo "$i" CODE=`cat "$i"` - node_modules/.bin/esbuild --loader=ts --target=es2019 > "$i" < "$i" < "$i" < "$i" <= 0 && ' + name + createArgs(recurmax, stmtDepth, canThrow)); + var args = createArgs(recurmax, stmtDepth, canThrow); + var call = "typeof " + name + ' == "function" && --_calls_ >= 0 && ' + name + args; + if (canThrow) { + if (SUPPORT.optional_chaining && args[0] != "`" && rng(50) == 0) { + call = name + "?." + args; + } else if (rng(20) == 0) { + call = name + args; + } + } + return mayDefer(call); } _createExpression.N = p; return _createExpression(recurmax, noComma, stmtDepth, canThrow);