diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 9ce7bce..2b30b3d 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -274,13 +274,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']) @@ -587,6 +605,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 ad62392..d828e48 100644 --- a/src/compiler/tests/compiler.test.ts +++ b/src/compiler/tests/compiler.test.ts @@ -313,3 +313,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 3c85400..a930ce4 100644 --- a/src/parser/operatorTokenizer.ts +++ b/src/parser/operatorTokenizer.ts @@ -11,12 +11,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 a4f26e2..918cc68 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 } +@external tokens operatorTokenizer from "./operatorTokenizer" { Star, Slash, Plus, Minus, And, Or, Eq, EqEq, Neq, Lt, Lte, Gt, Gte, Modulo, PlusEq, MinusEq, StarEq, SlashEq, ModuloEq, NullishCoalesce, NullishEq } @tokens { @precedence { Number Regex } @@ -44,6 +44,7 @@ null { @specialize[@name=Null] } pipe @left, or @left, and @left, + nullish @left, comparison @left, multiplicative @left, additive @left, @@ -160,7 +161,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 { @@ -176,7 +178,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 04cb710..554ba2a 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -19,48 +19,50 @@ export const StarEq = 17, SlashEq = 18, ModuloEq = 19, - Identifier = 20, - AssignableIdentifier = 21, - Word = 22, - IdentifierBeforeDot = 23, - Do = 24, - Comment = 25, - Program = 26, - PipeExpr = 27, - WhileExpr = 29, - keyword = 70, - ConditionalOp = 31, - ParenExpr = 32, - IfExpr = 33, - FunctionCall = 35, - DotGet = 36, - Number = 37, - PositionalArg = 38, - FunctionDef = 39, - Params = 40, - NamedParam = 41, - NamedArgPrefix = 42, - String = 43, - StringFragment = 44, - Interpolation = 45, - EscapeSeq = 46, - Boolean = 47, - Null = 48, - colon = 49, - CatchExpr = 50, - Block = 52, - FinallyExpr = 53, - Underscore = 56, - NamedArg = 57, - ElseIfExpr = 58, - ElseExpr = 60, - FunctionCallOrIdentifier = 61, - BinOp = 62, - Regex = 63, - Dict = 64, - Array = 65, - FunctionCallWithBlock = 66, - TryExpr = 67, - Throw = 69, - CompoundAssign = 71, - Assign = 72 + NullishCoalesce = 20, + NullishEq = 21, + Identifier = 22, + AssignableIdentifier = 23, + Word = 24, + IdentifierBeforeDot = 25, + Do = 26, + Comment = 27, + Program = 28, + PipeExpr = 29, + WhileExpr = 31, + keyword = 72, + ConditionalOp = 33, + ParenExpr = 34, + IfExpr = 35, + FunctionCall = 37, + DotGet = 38, + Number = 39, + PositionalArg = 40, + FunctionDef = 41, + Params = 42, + NamedParam = 43, + NamedArgPrefix = 44, + String = 45, + StringFragment = 46, + Interpolation = 47, + EscapeSeq = 48, + Boolean = 49, + Null = 50, + colon = 51, + CatchExpr = 52, + Block = 54, + FinallyExpr = 55, + Underscore = 58, + NamedArg = 59, + ElseIfExpr = 60, + ElseExpr = 62, + FunctionCallOrIdentifier = 63, + BinOp = 64, + Regex = 65, + Dict = 66, + Array = 67, + FunctionCallWithBlock = 68, + TryExpr = 69, + Throw = 71, + CompoundAssign = 73, + Assign = 74 diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index 64892e8..57bc209 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:60, if:68, null:96, catch:102, finally:108, end:110, else:118, try:136, throw:140} +const spec_Identifier = {__proto__:null,while:64, if:72, null:100, catch:106, finally:112, end:114, else:122, try:140, throw:144} export const parser = LRParser.deserialize({ version: 14, - states: "9UQYQbOOO!dOpO'#DQO!iOSO'#DXO$_QcO'#DkO&rQcO'#EYOOQ`'#Eh'#EhO'uQRO'#DlO)[QcO'#EWO)lQbO'#C|OOQa'#Dn'#DnO+nQbO'#DoOOQa'#EY'#EYO+uQcO'#EYO+|QcO'#EXO,yQcO'#EWO-TQRO'#DuOOQ`'#EW'#EWO-iQbO'#EWO-pQQO'#EVOOQ`'#EV'#EVOOQ`'#Dw'#DwQYQbOOO-{QbO'#DTO.WQbO'#C}O.{QbO'#CyO/pQQO'#DqO.{QbO'#DsO/uObO,59lO0QQbO'#DZO0YQWO'#D[OOOO'#E`'#E`OOOO'#D|'#D|O0nOSO,59sOOQa,59s,59sOOQ`'#DS'#DSO0|QbO'#DgOOQ`'#E^'#E^OOQ`'#Dy'#DyO1WQbO,59kOOQa'#EX'#EXO.{QbO,5:WO.{QbO,5:WO.{QbO,5:WO.{QbO,59gO.{QbO,59gO.{QbO,59gO2QQRO,59hO2^QQO,59hO2fQQO,59hO2qQRO,59hO3[QRO,59hO3jQQO'#CwOOQ`'#EP'#EPO3oQbO,5:ZO3vQQO,5:YOOQa,5:Z,5:ZO4RQbO,5:ZO)lQbO,5:bO)lQbO,5:aO4]QbO,5:[O4dQbO,59cOOQ`,5:q,5:qO)lQbO'#DxOOQ`-E7u-E7uOOQ`'#Dz'#DzO5OQbO'#DUO5ZQbO'#DVOOQO'#D{'#D{O5RQQO'#DUO5iQQO,59oO5nQcO'#EXO5uQRO'#E[O6lQRO'#E[OOQO'#E['#E[O6sQQO,59iO6xQRO,59eO7PQRO,59eO4]QbO,5:]O7[QcO,5:_O8dQcO,5:_O8tQcO,5:_OOQa1G/W1G/WOOOO,59u,59uOOOO,59v,59vOOOO-E7z-E7zOOQa1G/_1G/_OOQ`,5:R,5:ROOQ`-E7w-E7wOOQa1G/r1G/rO9sQcO1G/rO9}QcO1G/rO:XQcO1G/rOOQa1G/R1G/ROVQbO'#DbO>hQbO'#DbO>{QbO1G/vOOQ`-E7v-E7vOOQ`,5:d,5:dOOQ`-E7x-E7xO?WQQO,59pOOQO,59q,59qOOQO-E7y-E7yO?`QbO1G/ZO4]QbO1G/TO4]QbO1G/PO?gQbO1G/wO?rQQO7+%`OOQa7+%`7+%`O?}QbO7+%aOOQa7+%a7+%aOOQO-E8O-E8OOOQ`-E8P-E8POOQ`'#D}'#D}O@XQQO'#D}O@aQbO'#EgOOQ`,59|,59|O@tQbO'#D`O@yQQO'#DcOOQ`7+%b7+%bOAOQbO7+%bOATQbO7+%bOA]QbO7+$uOAkQbO7+$uOA{QbO7+$oOBTQbO7+$kOOQ`7+%c7+%cOBYQbO7+%cOB_QbO7+%cOOQa<hAN>hOOQ`AN={AN={OCuQbOAN={OCzQbOAN={OOQ`-E7|-E7|OOQ`AN=uAN=uODSQbOAN=uO.WQbO,5:SO4]QbO,5:UOOQ`AN>iAN>iOOQ`7+%Q7+%QOOQ`G23gG23gODXQbOG23gPD^QbO'#DhOOQ`G23aG23aODcQQO1G/nOOQ`1G/p1G/pOOQ`LD)RLD)RO4]QbO7+%YOOQ`<WQcO1G/TO=`QcO1G/TOOQa1G/U1G/UOOQ`-E8P-E8PO>nQQO1G/vOOQa1G/w1G/wO>yQbO1G/wOOQO'#ES'#ESO>nQQO1G/vOOQa1G/v1G/vOOQ`'#ET'#ETO>yQbO1G/wO?TQbO1G0OO?oQbO1G/}O@ZQbO'#DdO@lQbO'#DdOAPQbO1G/xOOQ`-E7x-E7xOOQ`,5:f,5:fOOQ`-E7z-E7zOA[QQO,59rOOQO,59s,59sOOQO-E7{-E7{OAdQbO1G/]O4rQbO1G/VO4rQbO1G/ROAkQbO1G/yOAvQQO7+%bOOQa7+%b7+%bOBRQbO7+%cOOQa7+%c7+%cOOQO-E8Q-E8QOOQ`-E8R-E8ROOQ`'#EP'#EPOB]QQO'#EPOBeQbO'#EiOOQ`,5:O,5:OOBxQbO'#DbOB}QQO'#DeOOQ`7+%d7+%dOCSQbO7+%dOCXQbO7+%dOCaQbO7+$wOCoQbO7+$wODPQbO7+$qODXQbO7+$mOOQ`7+%e7+%eOD^QbO7+%eODcQbO7+%eOOQa<jAN>jOOQ`AN=}AN=}OEyQbOAN=}OFOQbOAN=}OOQ`-E8O-E8OOOQ`AN=wAN=wOFWQbOAN=wO.jQbO,5:UO4rQbO,5:WOOQ`AN>kAN>kOOQ`7+%S7+%SOOQ`G23iG23iOF]QbOG23iPFbQbO'#DjOOQ`G23cG23cOFgQQO1G/pOOQ`1G/r1G/rOOQ`LD)TLD)TO4rQbO7+%[OOQ`<q#c#f,Y#f#g?n#g#h,Y#h#i@k#i#o,Y#o#p#{#p#qBo#q;'S#{;'S;=`$d<%l~#{~O#{~~CYS$QU|SOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qU|S!xYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[U|S#YQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZiY|SOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mSiYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O#T~~'aO#R~U'hU|S!}QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RU|S#]QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jW|SOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZY|SuQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OW|SOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oW|SuQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^^|SOt#{uw#{x}#{}!O,Y!O!Q#{!Q![)S![!_#{!_!`-T!`#O#{#P#T#{#T#o,Y#o;'S#{;'S;=`$d<%lO#{U,_[|SOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#o,Y#o;'S#{;'S;=`$d<%lO#{U-[UzQ|SOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U-sW|SOt#{uw#{x!P#{!P!Q.]!Q#O#{#P;'S#{;'S;=`$d<%lO#{U.b^|SOY/^YZ#{Zt/^tu0auw/^wx0ax!P/^!P!Q#{!Q!}/^!}#O5S#O#P2o#P;'S/^;'S;=`6T<%lO/^U/e^|S!aQOY/^YZ#{Zt/^tu0auw/^wx0ax!P/^!P!Q3U!Q!}/^!}#O5S#O#P2o#P;'S/^;'S;=`6T<%lO/^Q0fX!aQOY0aZ!P0a!P!Q1R!Q!}0a!}#O1p#O#P2o#P;'S0a;'S;=`3O<%lO0aQ1UP!P!Q1XQ1^U!aQ#Z#[1X#]#^1X#a#b1X#g#h1X#i#j1X#m#n1XQ1sVOY1pZ#O1p#O#P2Y#P#Q0a#Q;'S1p;'S;=`2i<%lO1pQ2]SOY1pZ;'S1p;'S;=`2i<%lO1pQ2lP;=`<%l1pQ2rSOY0aZ;'S0a;'S;=`3O<%lO0aQ3RP;=`<%l0aU3ZW|SOt#{uw#{x!P#{!P!Q3s!Q#O#{#P;'S#{;'S;=`$d<%lO#{U3zb|S!aQOt#{uw#{x#O#{#P#Z#{#Z#[3s#[#]#{#]#^3s#^#a#{#a#b3s#b#g#{#g#h3s#h#i#{#i#j3s#j#m#{#m#n3s#n;'S#{;'S;=`$d<%lO#{U5X[|SOY5SYZ#{Zt5Stu1puw5Swx1px#O5S#O#P2Y#P#Q/^#Q;'S5S;'S;=`5}<%lO5SU6QP;=`<%l5SU6WP;=`<%l/^U6bU|S!RQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6{W#_Q|SOt#{uw#{x!_#{!_!`7e!`#O#{#P;'S#{;'S;=`$d<%lO#{U7jV|SOt#{uw#{x#O#{#P#Q8P#Q;'S#{;'S;=`$d<%lO#{U8WU#^Q|SOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~8oO#U~U8vU#`Q|SOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U9aU|S!YQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U9x]|SOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#U:q#U#o,Y#o;'S#{;'S;=`$d<%lO#{U:v^|SOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#`,Y#`#a;r#a#o,Y#o;'S#{;'S;=`$d<%lO#{U;w^|SOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#g,Y#g#hx[#VW|SOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#o,Y#o;'S#{;'S;=`$d<%lO#{^?u[#XW|SOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#o,Y#o;'S#{;'S;=`$d<%lO#{^@r^#WW|SOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#f,Y#f#gAn#g#o,Y#o;'S#{;'S;=`$d<%lO#{UAs^|SOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#i,Y#i#j (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 20, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], - tokenPrec: 1634 + tokenData: "C_~R|OX#{XY$jYZ%TZp#{pq$jqs#{st%ntu'Vuw#{wx'[xy'ayz'zz{#{{|(e|}#{}!O+X!O!P#{!P!Q-n!Q![)S![!]6Z!]!^%T!^!}#{!}#O6t#O#P8j#P#Q8o#Q#R#{#R#S9Y#S#T#{#T#Y,Y#Y#Z9s#Z#b,Y#b#c>q#c#f,Y#f#g?n#g#h,Y#h#i@k#i#o,Y#o#p#{#p#qBo#q;'S#{;'S;=`$d<%l~#{~O#{~~CYS$QU!OSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qU!OS!zYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[U!OS#[QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZkY!OSOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mSkYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O#V~~'aO#T~U'hU!OS#PQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RU!OS#_QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jW!OSOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZY!OSwQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OW!OSOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oW!OSwQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^^!OSOt#{uw#{x}#{}!O,Y!O!Q#{!Q![)S![!_#{!_!`-T!`#O#{#P#T#{#T#o,Y#o;'S#{;'S;=`$d<%lO#{U,_[!OSOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#o,Y#o;'S#{;'S;=`$d<%lO#{U-[U|Q!OSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U-sW!OSOt#{uw#{x!P#{!P!Q.]!Q#O#{#P;'S#{;'S;=`$d<%lO#{U.b^!OSOY/^YZ#{Zt/^tu0auw/^wx0ax!P/^!P!Q#{!Q!}/^!}#O5S#O#P2o#P;'S/^;'S;=`6T<%lO/^U/e^!OS!cQOY/^YZ#{Zt/^tu0auw/^wx0ax!P/^!P!Q3U!Q!}/^!}#O5S#O#P2o#P;'S/^;'S;=`6T<%lO/^Q0fX!cQOY0aZ!P0a!P!Q1R!Q!}0a!}#O1p#O#P2o#P;'S0a;'S;=`3O<%lO0aQ1UP!P!Q1XQ1^U!cQ#Z#[1X#]#^1X#a#b1X#g#h1X#i#j1X#m#n1XQ1sVOY1pZ#O1p#O#P2Y#P#Q0a#Q;'S1p;'S;=`2i<%lO1pQ2]SOY1pZ;'S1p;'S;=`2i<%lO1pQ2lP;=`<%l1pQ2rSOY0aZ;'S0a;'S;=`3O<%lO0aQ3RP;=`<%l0aU3ZW!OSOt#{uw#{x!P#{!P!Q3s!Q#O#{#P;'S#{;'S;=`$d<%lO#{U3zb!OS!cQOt#{uw#{x#O#{#P#Z#{#Z#[3s#[#]#{#]#^3s#^#a#{#a#b3s#b#g#{#g#h3s#h#i#{#i#j3s#j#m#{#m#n3s#n;'S#{;'S;=`$d<%lO#{U5X[!OSOY5SYZ#{Zt5Stu1puw5Swx1px#O5S#O#P2Y#P#Q/^#Q;'S5S;'S;=`5}<%lO5SU6QP;=`<%l5SU6WP;=`<%l/^U6bU!OS!TQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6{W#aQ!OSOt#{uw#{x!_#{!_!`7e!`#O#{#P;'S#{;'S;=`$d<%lO#{U7jV!OSOt#{uw#{x#O#{#P#Q8P#Q;'S#{;'S;=`$d<%lO#{U8WU#`Q!OSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~8oO#W~U8vU#bQ!OSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U9aU!OS![QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U9x]!OSOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#U:q#U#o,Y#o;'S#{;'S;=`$d<%lO#{U:v^!OSOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#`,Y#`#a;r#a#o,Y#o;'S#{;'S;=`$d<%lO#{U;w^!OSOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#g,Y#g#hx[#XW!OSOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#o,Y#o;'S#{;'S;=`$d<%lO#{^?u[#ZW!OSOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#o,Y#o;'S#{;'S;=`$d<%lO#{^@r^#YW!OSOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#f,Y#f#gAn#g#o,Y#o;'S#{;'S;=`$d<%lO#{UAs^!OSOt#{uw#{x}#{}!O,Y!O!_#{!_!`-T!`#O#{#P#T#{#T#i,Y#i#j (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 22, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], + tokenPrec: 1730 }) diff --git a/src/parser/tests/basics.test.ts b/src/parser/tests/basics.test.ts index 3d0bb4d..7ac74d4 100644 --- a/src/parser/tests/basics.test.ts +++ b/src/parser/tests/basics.test.ts @@ -595,6 +595,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 (