diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 74ed78d..2cb076b 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -303,13 +303,31 @@ export class Compiler { const { identifier, operator, right } = getCompoundAssignmentParts(node) const identifierName = input.slice(identifier.from, identifier.to) const instructions: ProgramItem[] = [] + const opValue = input.slice(operator.from, operator.to) - // will throw if undefined - instructions.push(['LOAD', identifierName]) + // Special handling for ??= since it needs conditional evaluation + if (opValue === '??=') { + instructions.push(['LOAD', identifierName]) + const rightInstructions = this.#compileNode(right, input) + + instructions.push(['DUP']) + instructions.push(['PUSH', null]) + instructions.push(['NEQ']) + instructions.push(['JUMP_IF_TRUE', rightInstructions.length + 1]) + instructions.push(['POP']) + instructions.push(...rightInstructions) + + instructions.push(['DUP']) + instructions.push(['STORE', identifierName]) + + return instructions + } + + // Standard compound assignments: evaluate both sides, then operate + instructions.push(['LOAD', identifierName]) // will throw if undefined instructions.push(...this.#compileNode(right, input)) - const opValue = input.slice(operator.from, operator.to) switch (opValue) { case '+=': instructions.push(['ADD']) @@ -638,6 +656,18 @@ export class Compiler { break + case '??': + // Nullish coalescing: return left if not null, else right + instructions.push(...leftInstructions) + instructions.push(['DUP']) + instructions.push(['PUSH', null]) + instructions.push(['NEQ']) + instructions.push(['JUMP_IF_TRUE', rightInstructions.length + 1]) + instructions.push(['POP']) + instructions.push(...rightInstructions) + + break + default: throw new CompilerError(`Unsupported conditional operator: ${opValue}`, op.from, op.to) } diff --git a/src/compiler/tests/compiler.test.ts b/src/compiler/tests/compiler.test.ts index 184475f..1965e89 100644 --- a/src/compiler/tests/compiler.test.ts +++ b/src/compiler/tests/compiler.test.ts @@ -333,3 +333,118 @@ describe('default params', () => { ).toEvaluateTo({ name: 'Jon', age: 21 }) }) }) + +describe('Nullish coalescing operator (??)', () => { + test('returns left side when not null', () => { + expect('5 ?? 10').toEvaluateTo(5) + }) + + test('returns right side when left is null', () => { + expect('null ?? 10').toEvaluateTo(10) + }) + + test('returns left side when left is false', () => { + expect('false ?? 10').toEvaluateTo(false) + }) + + test('returns left side when left is 0', () => { + expect('0 ?? 10').toEvaluateTo(0) + }) + + test('returns left side when left is empty string', () => { + expect(`'' ?? 'default'`).toEvaluateTo('') + }) + + test('chains left to right', () => { + expect('null ?? null ?? 42').toEvaluateTo(42) + expect('null ?? 10 ?? 20').toEvaluateTo(10) + }) + + test('short-circuits evaluation', () => { + const throwError = () => { throw new Error('Should not evaluate') } + expect('5 ?? throw-error').toEvaluateTo(5, { 'throw-error': throwError }) + }) + + test('works with variables', () => { + expect('x = null; x ?? 5').toEvaluateTo(5) + expect('y = 3; y ?? 5').toEvaluateTo(3) + }) + + test('works with function calls', () => { + const getValue = () => null + const getDefault = () => 42 + // Note: identifiers without parentheses refer to the function, not call it + // Use explicit call syntax to invoke the function + expect('(get-value) ?? (get-default)').toEvaluateTo(42, { + 'get-value': getValue, + 'get-default': getDefault + }) + }) +}) + +describe('Nullish coalescing assignment (??=)', () => { + test('assigns when variable is null', () => { + expect('x = null; x ??= 5; x').toEvaluateTo(5) + }) + + test('does not assign when variable is not null', () => { + expect('x = 3; x ??= 10; x').toEvaluateTo(3) + }) + + test('does not assign when variable is false', () => { + expect('x = false; x ??= true; x').toEvaluateTo(false) + }) + + test('does not assign when variable is 0', () => { + expect('x = 0; x ??= 100; x').toEvaluateTo(0) + }) + + test('does not assign when variable is empty string', () => { + expect(`x = ''; x ??= 'default'; x`).toEvaluateTo('') + }) + + test('returns the final value', () => { + expect('x = null; x ??= 5').toEvaluateTo(5) + expect('y = 3; y ??= 10').toEvaluateTo(3) + }) + + test('short-circuits evaluation when not null', () => { + const throwError = () => { throw new Error('Should not evaluate') } + expect('x = 5; x ??= throw-error; x').toEvaluateTo(5, { 'throw-error': throwError }) + }) + + test('works with expressions', () => { + expect('x = null; x ??= 2 + 3; x').toEvaluateTo(5) + }) + + test('works with function calls', () => { + const getDefault = () => 42 + expect('x = null; x ??= (get-default); x').toEvaluateTo(42, { 'get-default': getDefault }) + }) + + test('throws when variable is undefined', () => { + expect(() => expect('undefined-var ??= 5').toEvaluateTo(null)).toThrow() + }) +}) + +describe('Compound assignment operators', () => { + test('+=', () => { + expect('x = 5; x += 3; x').toEvaluateTo(8) + }) + + test('-=', () => { + expect('x = 10; x -= 4; x').toEvaluateTo(6) + }) + + test('*=', () => { + expect('x = 3; x *= 4; x').toEvaluateTo(12) + }) + + test('/=', () => { + expect('x = 20; x /= 5; x').toEvaluateTo(4) + }) + + test('%=', () => { + expect('x = 10; x %= 3; x').toEvaluateTo(1) + }) +}) diff --git a/src/parser/operatorTokenizer.ts b/src/parser/operatorTokenizer.ts index c909543..1ef7a94 100644 --- a/src/parser/operatorTokenizer.ts +++ b/src/parser/operatorTokenizer.ts @@ -17,12 +17,16 @@ const operators: Array = [ { str: '==', tokenName: 'EqEq' }, // Compound assignment operators (must come before single-char operators) + { str: '??=', tokenName: 'NullishEq' }, { str: '+=', tokenName: 'PlusEq' }, { str: '-=', tokenName: 'MinusEq' }, { str: '*=', tokenName: 'StarEq' }, { str: '/=', tokenName: 'SlashEq' }, { str: '%=', tokenName: 'ModuloEq' }, + // Nullish coalescing (must come before it could be mistaken for other tokens) + { str: '??', tokenName: 'NullishCoalesce' }, + // Single-char operators { str: '*', tokenName: 'Star' }, { str: '=', tokenName: 'Eq' }, diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index 965c206..f4ac3e7 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -6,7 +6,7 @@ @top Program { item* } -@external tokens operatorTokenizer from "./operatorTokenizer" { Star, Slash, Plus, Minus, And, Or, Eq, EqEq, Neq, Lt, Lte, Gt, Gte, Modulo, PlusEq, MinusEq, StarEq, SlashEq, ModuloEq, Band, Bor, Bxor, Shl, Shr, Ushr } +@external tokens operatorTokenizer from "./operatorTokenizer" { Star, Slash, Plus, Minus, And, Or, Eq, EqEq, Neq, Lt, Lte, Gt, Gte, Modulo, PlusEq, MinusEq, StarEq, SlashEq, ModuloEq, Band, Bor, Bxor, Shl, Shr, Ushr, NullishCoalesce, NullishEq } @tokens { @precedence { Number Regex } @@ -48,6 +48,7 @@ null { @specialize[@name=Null] } pipe @left, or @left, and @left, + nullish @left, comparison @left, multiplicative @left, additive @left, @@ -166,7 +167,8 @@ ConditionalOp { expression !comparison Gt expression | expression !comparison Gte expression | (expression | ConditionalOp) !and And (expression | ConditionalOp) | - (expression | ConditionalOp) !or Or (expression | ConditionalOp) + (expression | ConditionalOp) !or Or (expression | ConditionalOp) | + (expression | ConditionalOp) !nullish NullishCoalesce (expression | ConditionalOp) } Params { @@ -182,7 +184,7 @@ Assign { } CompoundAssign { - AssignableIdentifier (PlusEq | MinusEq | StarEq | SlashEq | ModuloEq) consumeToTerminator + AssignableIdentifier (PlusEq | MinusEq | StarEq | SlashEq | ModuloEq | NullishEq) consumeToTerminator } BinOp { diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts index da329d7..3da47bb 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -25,49 +25,51 @@ export const Shl = 23, Shr = 24, Ushr = 25, - Identifier = 26, - AssignableIdentifier = 27, - Word = 28, - IdentifierBeforeDot = 29, - Do = 30, - Comment = 31, - Program = 32, - PipeExpr = 33, - WhileExpr = 35, - keyword = 77, - ConditionalOp = 37, - ParenExpr = 38, - FunctionCallWithNewlines = 39, - DotGet = 40, - Number = 41, - PositionalArg = 42, - FunctionDef = 43, - Params = 44, - NamedParam = 45, - NamedArgPrefix = 46, - String = 47, - StringFragment = 48, - Interpolation = 49, - EscapeSeq = 50, - Boolean = 51, - Null = 52, - colon = 53, - CatchExpr = 54, - Block = 56, - FinallyExpr = 57, - Underscore = 60, - NamedArg = 61, - IfExpr = 62, - FunctionCall = 64, - ElseIfExpr = 65, - ElseExpr = 67, - FunctionCallOrIdentifier = 68, - BinOp = 69, - Regex = 70, - Dict = 71, - Array = 72, - FunctionCallWithBlock = 73, - TryExpr = 74, - Throw = 76, - CompoundAssign = 78, - Assign = 79 + NullishCoalesce = 26, + NullishEq = 27, + Identifier = 28, + AssignableIdentifier = 29, + Word = 30, + IdentifierBeforeDot = 31, + Do = 32, + Comment = 33, + Program = 34, + PipeExpr = 35, + WhileExpr = 37, + keyword = 79, + ConditionalOp = 39, + ParenExpr = 40, + FunctionCallWithNewlines = 41, + DotGet = 42, + Number = 43, + PositionalArg = 44, + FunctionDef = 45, + Params = 46, + NamedParam = 47, + NamedArgPrefix = 48, + String = 49, + StringFragment = 50, + Interpolation = 51, + EscapeSeq = 52, + Boolean = 53, + Null = 54, + colon = 55, + CatchExpr = 56, + Block = 58, + FinallyExpr = 59, + Underscore = 62, + NamedArg = 63, + IfExpr = 64, + FunctionCall = 66, + ElseIfExpr = 67, + ElseExpr = 69, + FunctionCallOrIdentifier = 70, + BinOp = 71, + Regex = 72, + Dict = 73, + Array = 74, + FunctionCallWithBlock = 75, + TryExpr = 76, + Throw = 78, + CompoundAssign = 80, + Assign = 81 diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index 419523f..901d667 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -4,24 +4,24 @@ import {operatorTokenizer} from "./operatorTokenizer" import {tokenizer, specializeKeyword} from "./tokenizer" import {trackScope} from "./parserScopeContext" import {highlighting} from "./highlight" -const spec_Identifier = {__proto__:null,while:72, null:104, catch:110, finally:116, end:118, if:126, else:132, try:150, throw:154} +const spec_Identifier = {__proto__:null,while:76, null:108, catch:114, finally:120, end:122, if:130, else:136, try:154, throw:158} export const parser = LRParser.deserialize({ version: 14, - states: "PQcO,5:fO>mQcO,5:fOOQa1G/[1G/[OOOO,59y,59yOOOO,59z,59zOOOO-E8R-E8ROOQa1G/c1G/cOOQ`,5:V,5:VOOQ`-E8U-E8UOOQa1G/y1G/yO@fQcO1G/yO@pQcO1G/yOBOQcO1G/yOBYQcO1G/yOBgQcO1G/yOOQa1G/X1G/XOCuQcO1G/XOC|QcO1G/XODTQcO1G/XOOQ`-E8O-E8OOD[QRO1G/YODfQQO1G/YODkQQO1G/YODsQQO1G/YOEOQRO1G/YOEVQRO1G/YOEhQbO,59oOErQQO1G/YOOQa1G/Y1G/YOEzQQO1G/{OOQa1G/|1G/|OFVQbO1G/|OOQO'#EY'#EYOEzQQO1G/{OOQa1G/{1G/{OOQ`'#EZ'#EZOFVQbO1G/|OFaQbO1G0TOF{QbO1G0SOGgQbO'#DfOGxQbO'#DfOH]QbO1G/}OOQ`-E7}-E7}OOQ`,5:k,5:kOOQ`-E8P-E8POHhQQO,59tOOQO,59u,59uOOQO-E8Q-E8QOHpQbO1G/_O9PQbO1G/rO9PQbO1G/VOHwQbO1G0OOISQQO7+$tOOQa7+$t7+$tOI[QQO1G/ZOIdQQO7+%gOOQa7+%g7+%gOIoQbO7+%hOOQa7+%h7+%hOOQO-E8W-E8WOOQ`-E8X-E8XOOQ`'#EU'#EUOIyQQO'#EUOJRQbO'#EnOOQ`,5:Q,5:QOJfQbO'#DdOJkQQO'#DgOOQ`7+%i7+%iOJpQbO7+%iOJuQbO7+%iOJ}QbO7+$yOK]QbO7+$yOKmQbO7+%^OKuQbO7+$qOOQ`7+%j7+%jOKzQbO7+%jOLPQbO7+%jOOQa<oAN>oOOQ`AN>PAN>PONZQbOAN>PON`QbOAN>POOQ`-E8V-E8VOOQ`AN>dAN>dONhQbOAN>dO1qQbO,5:ZO9PQbO,5:]OOQ`AN>pAN>pPEhQbO'#EQOOQ`7+%U7+%UOOQ`G23kG23kONmQbOG23kPMmQbO'#DoOOQ`G24OG24OONrQQO1G/uOOQ`1G/w1G/wOOQ`LD)VLD)VO9PQbO7+%aOOQ`<vQcO,5:hO?dQcO,5:hOOQa1G/^1G/^OOOO,59{,59{OOOO,59|,59|OOOO-E8T-E8TOOQa1G/e1G/eOOQ`,5:X,5:XOOQ`-E8W-E8WOOQa1G/{1G/{OA`QcO1G/{OAjQcO1G/{OBxQcO1G/{OCSQcO1G/{OCaQcO1G/{OOQa1G/Z1G/ZODrQcO1G/ZODyQcO1G/ZOEQQcO1G/ZOFPQcO1G/ZOEXQcO1G/ZOOQ`-E8Q-E8QOFgQRO1G/[OFqQQO1G/[OFvQQO1G/[OGOQQO1G/[OGZQRO1G/[OGbQRO1G/[OGiQbO,59qOGsQQO1G/[OOQa1G/[1G/[OG{QQO1G/}OOQa1G0O1G0OOHWQbO1G0OOOQO'#E['#E[OG{QQO1G/}OOQa1G/}1G/}OOQ`'#E]'#E]OHWQbO1G0OOHbQbO1G0VOH|QbO1G0UOIhQbO'#DhOIyQbO'#DhOJ^QbO1G0POOQ`-E8P-E8POOQ`,5:m,5:mOOQ`-E8R-E8ROJiQQO,59vOOQO,59w,59wOOQO-E8S-E8SOJqQbO1G/aO9jQbO1G/tO9jQbO1G/XOJxQbO1G0QOKTQQO7+$vOOQa7+$v7+$vOK]QQO1G/]OKeQQO7+%iOOQa7+%i7+%iOKpQbO7+%jOOQa7+%j7+%jOOQO-E8Y-E8YOOQ`-E8Z-E8ZOOQ`'#EW'#EWOKzQQO'#EWOLSQbO'#EpOOQ`,5:S,5:SOLgQbO'#DfOLlQQO'#DiOOQ`7+%k7+%kOLqQbO7+%kOLvQbO7+%kOMOQbO7+${OM^QbO7+${OMnQbO7+%`OMvQbO7+$sOOQ`7+%l7+%lOM{QbO7+%lONQQbO7+%lOOQa<qAN>qOOQ`AN>RAN>RO!![QbOAN>RO!!aQbOAN>ROOQ`-E8X-E8XOOQ`AN>fAN>fO!!iQbOAN>fO2TQbO,5:]O9jQbO,5:_OOQ`AN>rAN>rPGiQbO'#ESOOQ`7+%W7+%WOOQ`G23mG23mO!!nQbOG23mP! nQbO'#DqOOQ`G24QG24QO!!sQQO1G/wOOQ`1G/y1G/yOOQ`LD)XLD)XO9jQbO7+%cOOQ`<UOT}OU!OOj!POt!pa#Y!pa#k!pa!Z!pa!^!pa!_!pa#g!pa!f!pa~O^xOR!iiS!iid!iie!iif!iig!iih!iii!iit!ii#Y!ii#k!ii#g!ii!Z!ii!^!ii!_!ii!f!ii~OP!iiQ!ii~P@XOPyOQyO~P@XOPyOQyOd!iie!iif!iig!iih!iii!iit!ii#Y!ii#k!ii#g!ii!Z!ii!^!ii!_!ii!f!ii~OR!iiS!ii~PAtORzOSzO^xO~PAtORzOSzO~PAtOW|OX|OY|OZ|O[|O]|OTwijwitwi#Ywi#kwi#gwi!Xwi!Zwi!^wi!_wi!fwi~OU!OO~PCkOU!OO~PC}OUwi~PCkOT}OU!OOjwitwi#Ywi#kwi#gwi!Xwi!Zwi!^wi!_wi!fwi~OW|OX|OY|OZ|O[|O]|O~PEXO#Y!QO#g$QO~P*RO#g$QO~O#g$QOt#UX~O!X!cO#g$QOt#UX~O#g$QO~P.WO#g$QO~P7WOpfO!`rO~P,kO#Y!QO#g$QO~O!QsO#Y#kO#j$TO~O#Y#nO#j$VO~P2xOt!fO#Y!si#k!si!Z!si!^!si!_!si#g!si!f!si~Ot!fO#Y!ri#k!ri!Z!ri!^!ri!_!ri#g!ri!f!ri~Ot!fO!Z![X!^![X!_![X!f![X~O#Y$YO!Z#dP!^#dP!_#dP!f#dP~P8cO!Z$^O!^$_O!_$`O~O!Q!jO!X!Oa~O#Y$dO~P8cO!Z$^O!^$_O!_$gO~O#Y!QO#g$jO~O#Y!QO#gyi~O!QsO#Y#kO#j$mO~O#Y#nO#j$nO~P2xOt!fO#Y$oO~O#Y$YO!Z#dX!^#dX!_#dX!f#dX~P8cOl$qO~O!X$rO~O!_$sO~O!^$_O!_$sO~Ot!fO!Z$^O!^$_O!_$uO~O#Y$YO!Z#dP!^#dP!_#dP~P8cO!_$|O!f${O~O!_%OO~O!_%PO~O!^$_O!_%PO~OpfO!`rO#gyq~P,kO#Y!QO#gyq~O!X%UO~O!_%WO~O!_%XO~O!^$_O!_%XO~O!Z$^O!^$_O!_%XO~O!_%]O!f${O~O!X%`O!c%_O~O!_%]O~O!_%aO~OpfO!`rO#gyy~P,kO!_%dO~O!^$_O!_%dO~O!_%gO~O!_%jO~O!X%kO~O{!j~", + goto: "8f#gPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP#hP$RP$h%f&t&zP(U(b)[)_P)eP*l*lPPP*pP*|+fPPP+|#hP,f-PP-T-Z-pP.g/k$R$RP$RP$R$R0q0w1T1w1}2X2_2f2l2v2|3WPPP3b3f4Z6PPPP7ZP7kPPPPP7o7u7{r`Oe!a!b!c!f!u#s#{#|#}$[$d$r%U%`%kQ!WWR#a!Rw`OWe!R!a!b!c!f!u#s#{#|#}$[$d$r%U%`%kr^Oe!a!b!c!f!u#s#{#|#}$[$d$r%U%`%kQ!ZWS!og%_Q!thQ!xjQ#W!OQ#Y}Q#]!PR#d!RvSOeg!a!b!c!f!u#s#{#|#}$[$d$r%U%_%`%k!WZRSYhjsvxyz{|}!O!P!S!T!]!`!n#e#j#o$U$k%S%bS!TW!RQ!ykR!zlQ!VWR#`!RrROe!a!b!c!f!u#s#{#|#}$[$d$r%U%`%k!WwRSYhjsvxyz{|}!O!P!S!T!]!`!n#e#j#o$U$k%S%bS!SW!RT!ng%_etRSv!S!T!n#e$k%S%br`Oe!a!b!c!f!u#s#{#|#}$[$d$r%U%`%kdrRSv!S!T!n#e$k%S%bQ!WWQ#OsR#a!RR!mfX!kf!i!l#x#SZORSWYeghjsvxyz{|}!O!P!R!S!T!]!`!a!b!c!f!n!u#e#j#o#s#{#|#}$U$[$d$k$r%S%U%_%`%b%kR#y!jTnQpQ$b#tQ$i$OQ$w$cR%Z$xQ#t!cQ$O!uQ$e#|Q$f#}Q%V$rQ%c%UQ%i%`R%l%kQ$a#tQ$h$OQ$t$bQ$v$cQ%Q$iS%Y$w$xR%e%ZdtRSv!S!T!n#e$k%S%bQ!^YQ#h!]X#k!^#h#l$SvTOWe!R!a!b!c!f!u#s#{#|#}$[$d$r%U%`%kT!qg%_T$y$e$zQ$}$eR%^$zwTOWe!R!a!b!c!f!u#s#{#|#}$[$d$r%U%`%krVOe!a!b!c!f!u#s#{#|#}$[$d$r%U%`%kQ!UWQ!wjQ#QyQ#TzQ#V{R#_!R#TZORSWYeghjsvxyz{|}!O!P!R!S!T!]!`!a!b!c!f!n!u#e#j#o#s#{#|#}$U$[$d$k$r%S%U%_%`%b%k![ZRSYghjsvxyz{|}!O!P!S!T!]!`!n#e#j#o$U$k%S%_%bw[OWe!R!a!b!c!f!u#s#{#|#}$[$d$r%U%`%kQeOR!ge^!db![#p#q#r$Z$cR#u!dQ!RWQ!]Y`#^!R!]#e#f$P$k%S%bS#e!S!TS#f!U!ZS$P#_#dQ$k$RR%S$lQ!ifR#w!iQ!lfQ#x!iT#z!l#xQpQR!|pS$[#s$dR$p$[Q$l$RR%T$lYvRS!S!T!nR#PvQ$z$eR%[$zQ#l!^Q$S#hT$W#l$SQ#o!`Q$U#jT$X#o$UTdOeSbOeS![W!RQ#p!aQ#q!b`#r!c!u#|#}$r%U%`%kQ#v!fU$Z#s$[$dR$c#{vUOWe!R!a!b!c!f!u#s#{#|#}$[$d$r%U%`%kdrRSv!S!T!n#e$k%S%bQ!`YS!pg%_Q!shQ!vjQ#OsQ#QxQ#RyQ#SzQ#U{Q#W|Q#X}Q#Z!OQ#[!PQ#j!]X#n!`#j#o$Ur]Oe!a!b!c!f!u#s#{#|#}$[$d$r%U%`%k![wRSYghjsvxyz{|}!O!P!S!T!]!`!n#e#j#o$U$k%S%_%bQ!YWR#c!R[uRSv!S!T!nQ$R#eV%R$k%S%bToQpQ$]#sR$x$dQ!rgR%h%_raOe!a!b!c!f!u#s#{#|#}$[$d$r%U%`%kQ!XWR#b!R", + nodeNames: "⚠ Star Slash Plus Minus And Or Eq EqEq Neq Lt Lte Gt Gte Modulo PlusEq MinusEq StarEq SlashEq ModuloEq Band Bor Bxor Shl Shr Ushr NullishCoalesce NullishEq Identifier AssignableIdentifier Word IdentifierBeforeDot Do Comment Program PipeExpr operator WhileExpr keyword ConditionalOp ParenExpr FunctionCall DotGet Number PositionalArg FunctionDef Params NamedParam NamedArgPrefix String StringFragment Interpolation EscapeSeq Boolean Null colon CatchExpr keyword Block FinallyExpr keyword keyword Underscore NamedArg IfExpr keyword FunctionCall ElseIfExpr keyword ElseExpr FunctionCallOrIdentifier BinOp Regex Dict Array FunctionCallWithBlock TryExpr keyword Throw keyword CompoundAssign Assign", + maxTerm: 119, context: trackScope, nodeProps: [ - ["closedBy", 53,"end"] + ["closedBy", 55,"end"] ], propSources: [highlighting], - skippedNodes: [0,31], + skippedNodes: [0,33], repeatNodeCount: 12, - tokenData: "IS~R}OX$OXY$mYZ%WZp$Opq$mqs$Ost%qtu'Yuw$Owx'_xy'dyz'}z{$O{|(h|}$O}!O(h!O!P$O!P!Q0o!Q!R)Y!R![+w![!]9[!]!^%W!^!}$O!}#O9u#O#P;k#P#Q;p#Q#R$O#R#S`#Z#be_!QSOt$Ouw$Ox}$O}!O (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 26, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], - tokenPrec: 2109 + tokenData: "IS~R}OX$OXY$mYZ%WZp$Opq$mqs$Ost%qtu'Yuw$Owx'_xy'dyz'}z{$O{|(h|}$O}!O(h!O!P$O!P!Q0o!Q!R)Y!R![+w![!]9[!]!^%W!^!}$O!}#O9u#O#P;k#P#Q;p#Q#R$O#R#S`#Z#be_!SSOt$Ouw$Ox}$O}!O (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 28, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], + tokenPrec: 2202 }) diff --git a/src/parser/tests/basics.test.ts b/src/parser/tests/basics.test.ts index c941140..28731f9 100644 --- a/src/parser/tests/basics.test.ts +++ b/src/parser/tests/basics.test.ts @@ -707,6 +707,87 @@ describe('CompoundAssign', () => { PositionalArg Number 3`) }) + + test('parses ??= operator', () => { + expect('x ??= 5').toMatchTree(` + CompoundAssign + AssignableIdentifier x + NullishEq ??= + Number 5`) + }) + + test('parses ??= with expression', () => { + expect('config ??= get-default').toMatchTree(` + CompoundAssign + AssignableIdentifier config + NullishEq ??= + FunctionCallOrIdentifier + Identifier get-default`) + }) +}) + +describe('Nullish coalescing operator', () => { + test('? can still end an identifier', () => { + expect('what?').toMatchTree(` + FunctionCallOrIdentifier + Identifier what?`) + }) + + test('?? can still end an identifier', () => { + expect('what??').toMatchTree(` + FunctionCallOrIdentifier + Identifier what??`) + }) + + test('?? can still be in a word', () => { + expect('what??the').toMatchTree(` + FunctionCallOrIdentifier + Identifier what??the`) + }) + + test('?? can still start a word', () => { + expect('??what??the').toMatchTree(` + Word ??what??the`) + }) + + test('parses ?? operator', () => { + expect('x ?? 5').toMatchTree(` + ConditionalOp + Identifier x + NullishCoalesce ?? + Number 5`) + }) + + test('parses chained ?? operators', () => { + expect('a ?? b ?? c').toMatchTree(` + ConditionalOp + ConditionalOp + Identifier a + NullishCoalesce ?? + Identifier b + NullishCoalesce ?? + Identifier c`) + }) + + test('parses ?? with expressions', () => { + expect('get-value ?? default-value').toMatchTree(` + ConditionalOp + Identifier get-value + NullishCoalesce ?? + Identifier default-value`) + }) + + test('parses ?? with parenthesized function call', () => { + expect('get-value ?? (default 10)').toMatchTree(` + ConditionalOp + Identifier get-value + NullishCoalesce ?? + ParenExpr + FunctionCall + Identifier default + PositionalArg + Number 10`) + }) }) describe('DotGet whitespace sensitivity', () => { diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 3f5ca6c..d18a515 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -193,6 +193,15 @@ const chooseIdentifierToken = (input: InputStream, stack: Stack): number => { const nextCh = getFullCodePoint(input, peekPos) const nextCh2 = getFullCodePoint(input, peekPos + 1) + const nextCh3 = getFullCodePoint(input, peekPos + 2) + + // Check for ??= (three-character compound operator) + if (nextCh === 63 /* ? */ && nextCh2 === 63 /* ? */ && nextCh3 === 61 /* = */) { + const charAfterOp = getFullCodePoint(input, peekPos + 3) + if (isWhiteSpace(charAfterOp) || charAfterOp === -1 /* EOF */) { + return AssignableIdentifier + } + } // Check for compound assignment operators: +=, -=, *=, /=, %= if (