diff --git a/Parsing/AST.cs b/Parsing/AST.cs index 1855348a..a5dac16c 100644 --- a/Parsing/AST.cs +++ b/Parsing/AST.cs @@ -195,8 +195,11 @@ public record ArrowFunction(Token? Name, List? TypeParams, string? Th // through the function display class so the hoisted arrow reads them live, not a stale snapshot. public bool IsLiftedForwarder { get; init; } } - // Template literal - public record TemplateLiteral(List Strings, List Expressions) : Expr; + // Template literal. InvalidEscapeLines carries the source line of each part whose cooked value + // had an invalid escape sequence (`\xtraordinary`, `\u{hello}`, ...) — a real syntax error for an + // untagged template (TS1125), but recoverable: the parser substitutes an empty string for that + // part rather than aborting the whole file, and the checker reports it as a normal diagnostic. + public record TemplateLiteral(List Strings, List Expressions, List? InvalidEscapeLines = null) : Expr; // Tagged template literal: tag`template ${expr}` public record TaggedTemplateLiteral( Expr Tag, // The tag function expression @@ -534,6 +537,7 @@ public record ImportSpecifier(Token Imported, Token? LocalName, bool IsTypeOnly /// Re-export source: export { x } from './file' /// True for 'export default' /// CommonJS export assignment: export = expr + /// Namespace re-export alias: export * as ns from './file' public record Export( Token Keyword, Stmt? Declaration, @@ -541,7 +545,8 @@ public record Export( Expr? DefaultExpr, string? FromModulePath, bool IsDefaultExport, - Expr? ExportAssignment = null + Expr? ExportAssignment = null, + Token? NamespaceExportName = null ) : Stmt; /// diff --git a/Parsing/Parser.Declarations.cs b/Parsing/Parser.Declarations.cs index 959bddfa..3f05ecfc 100644 --- a/Parsing/Parser.Declarations.cs +++ b/Parsing/Parser.Declarations.cs @@ -5,8 +5,9 @@ public partial class Parser private Stmt Declaration() { // Module declarations - must be at top level - // Note: import followed by ( is dynamic import (expression), not static import (statement) - if (Check(TokenType.IMPORT) && PeekNext().Type != TokenType.LEFT_PAREN) + // Note: import followed by ( is dynamic import, and import followed by . is import.meta — + // both expressions, not a static import declaration. + if (Check(TokenType.IMPORT) && PeekNext().Type != TokenType.LEFT_PAREN && PeekNext().Type != TokenType.DOT) { Advance(); // consume IMPORT // Detect import alias: import X = Namespace.Member @@ -892,6 +893,19 @@ private Stmt ParseDeclareModuleMember() { Token exportKeyword = Previous(); + // export { x, y as z } [from './module'] — e.g. `declare global { export { globalThis as global } }` + if (Match(TokenType.LEFT_BRACE)) + { + var namedExports = ParseExportSpecifiers(); + string? fromPath = null; + if (Match(TokenType.FROM)) + { + fromPath = (string)Consume(TokenType.STRING, "Expect module path.").Literal!; + } + ConsumeSemicolon("Expect ';' after export."); + return new Stmt.Export(exportKeyword, null, namedExports, null, fromPath, IsDefaultExport: false); + } + // export interface Foo { } if (Match(TokenType.INTERFACE)) { diff --git a/Parsing/Parser.Expressions.cs b/Parsing/Parser.Expressions.cs index 5ca4d795..a0426822 100644 --- a/Parsing/Parser.Expressions.cs +++ b/Parsing/Parser.Expressions.cs @@ -116,6 +116,10 @@ private Expr DispatchAssignmentTarget( { switch (target) { + // Parens don't change whether an expression is a valid reference — `(a.b) = v`, + // `(a.b) &&= v`, etc. are all valid; unwrap (recursively, for `((a.b))`) before dispatch. + case Expr.Grouping grouping: + return DispatchAssignmentTarget(grouping.Expression, value, errorMessage, onVariable, onGet, onGetIndex, onGetPrivate); case Expr.Variable variable: if (_isStrictMode && (variable.Name.Lexeme == "eval" || variable.Name.Lexeme == "arguments")) throw new Exception("SyntaxError: Unexpected eval or arguments in strict mode"); @@ -1015,8 +1019,14 @@ private Expr Primary() // async function expression: async function [name]() {} or async function*() {} // async arrow function: async () => {} or async (x) => x - if (Match(TokenType.ASYNC)) + // `async` is also a valid plain identifier (e.g. a destructured binding named `async`, + // `if (async)`) when it isn't immediately followed by one of the tokens above — only + // commit to the async-function forms when one of those follows. + if (Check(TokenType.ASYNC) && + (PeekNext().Type == TokenType.FUNCTION || PeekNext().Type == TokenType.LESS || PeekNext().Type == TokenType.LEFT_PAREN)) { + Advance(); // consume 'async' + // `async function ...` is an async function expression — defer to the shared // FunctionExpression parser (which also handles the `*` for async generators), // mirroring the statement-level `async function` declaration path. @@ -1036,6 +1046,12 @@ private Expr Primary() if (arrowFunc != null) return arrowFunc; throw new Exception("Parse Error: Expected arrow function after 'async ('."); } + if (Check(TokenType.ASYNC)) + { + // Not followed by a function-introducing token — a bare reference to an identifier + // named `async`. + return new Expr.Variable(Advance()); + } if (Match(TokenType.LEFT_PAREN)) { @@ -1053,10 +1069,12 @@ private Expr Primary() if (Match(TokenType.TEMPLATE_FULL)) { var value = (TemplateStringValue)Previous().Literal!; - // For untagged templates, cooked must not be null (invalid escapes are errors) + // For untagged templates, an invalid escape (`\xtraordinary`, `\u{hello}`, ...) is a real + // syntax error (TS1125) — but recoverable, not a reason to abort the whole file. Substitute + // an empty string and let the checker report it against this line. if (value.Cooked == null) { - throw new Exception("Parse Error: Invalid escape sequence in template literal."); + return new Expr.TemplateLiteral([""], [], [Previous().Line]); } return new Expr.TemplateLiteral([value.Cooked], []); } @@ -1203,12 +1221,21 @@ private Expr.ArrowFunction ParseObjectMethodShorthand(bool isAsync = false, bool private Expr ParseTemplateLiteral() { var headValue = (TemplateStringValue)Previous().Literal!; - // For untagged templates, cooked must not be null - if (headValue.Cooked == null) + List strings = []; + List? invalidEscapeLines = null; + void AddPart(TemplateStringValue value, int line) { - throw new Exception("Parse Error: Invalid escape sequence in template literal."); + if (value.Cooked == null) + { + strings.Add(""); + (invalidEscapeLines ??= []).Add(line); + } + else + { + strings.Add(value.Cooked); + } } - List strings = [headValue.Cooked]; + AddPart(headValue, Previous().Line); List expressions = []; // Parse first expression @@ -1217,25 +1244,15 @@ private Expr ParseTemplateLiteral() // Parse middle parts while (Match(TokenType.TEMPLATE_MIDDLE)) { - var midValue = (TemplateStringValue)Previous().Literal!; - if (midValue.Cooked == null) - { - throw new Exception("Parse Error: Invalid escape sequence in template literal."); - } - strings.Add(midValue.Cooked); + AddPart((TemplateStringValue)Previous().Literal!, Previous().Line); expressions.Add(Expression()); } // Expect tail Consume(TokenType.TEMPLATE_TAIL, "Expect end of template literal."); - var tailValue = (TemplateStringValue)Previous().Literal!; - if (tailValue.Cooked == null) - { - throw new Exception("Parse Error: Invalid escape sequence in template literal."); - } - strings.Add(tailValue.Cooked); + AddPart((TemplateStringValue)Previous().Literal!, Previous().Line); - return new Expr.TemplateLiteral(strings, expressions); + return new Expr.TemplateLiteral(strings, expressions, invalidEscapeLines); } private Expr ParseTaggedTemplateLiteral(Expr tag) diff --git a/Parsing/Parser.Modules.cs b/Parsing/Parser.Modules.cs index 32b36294..5a09fce3 100644 --- a/Parsing/Parser.Modules.cs +++ b/Parsing/Parser.Modules.cs @@ -293,16 +293,23 @@ void RejectDecorators() return new Stmt.Export(keyword, null, namedExports, null, fromPath, IsDefaultExport: false); } - // export * from './module' (re-export all) + // export * from './module' (re-export all) or + // export * as ns from './module' (re-export the whole module as a named namespace) if (Match(TokenType.STAR)) { RejectDecorators(); + Token? namespaceExportName = null; + if (Match(TokenType.AS)) + { + namespaceExportName = ConsumeIdentifierName("Expect namespace name after 'as'."); + } Consume(TokenType.FROM, "Expect 'from' after '*'."); string fromPath = (string)Consume(TokenType.STRING, "Expect module path.").Literal!; ConsumeSemicolon("Expect ';' after export."); // Represent as export with null named exports and a fromPath (meaning all) - return new Stmt.Export(keyword, null, null, null, fromPath, IsDefaultExport: false); + return new Stmt.Export(keyword, null, null, null, fromPath, IsDefaultExport: false, + NamespaceExportName: namespaceExportName); } // export import X = Namespace.Member (re-export alias) diff --git a/Parsing/Parser.Types.cs b/Parsing/Parser.Types.cs index 99357360..8aff8b71 100644 --- a/Parsing/Parser.Types.cs +++ b/Parsing/Parser.Types.cs @@ -1065,14 +1065,10 @@ private string ParseInlineObjectType() members.Add(member); } - // Handle separator - can be semicolon or comma, or nothing before closing brace - if (!Check(TokenType.RIGHT_BRACE)) - { - if (!Match(TokenType.SEMICOLON) && !Match(TokenType.COMMA)) - { - throw new Exception("Expect ';' or ',' between object type members."); - } - } + // Separator: ';', ',', or ASI (ok on a newline, before '}', or at EOF) — same rule as + // interface members (`bar(): { baz: T }` followed by `baz: T` on the next line needs no + // explicit separator between them). + ConsumeInterfaceMemberSeparator(); } Consume(TokenType.RIGHT_BRACE, "Expect '}' after object type."); diff --git a/STATUS.md b/STATUS.md index fd2c6354..95e505a1 100644 --- a/STATUS.md +++ b/STATUS.md @@ -507,16 +507,16 @@ The committed subset spans `types/typeRelationships/{assignmentCompatibility,sub | Bucket | Count | Share | |---|---:|---:| -| `Pass` | 203 | 68.6% | -| `Fail` | 68 | 23.0% | -| `ParseError` | 7 | 2.4% | +| `Pass` | 207 | 69.9% | +| `Fail` | 71 | 24.0% | +| `ParseError` | 0 | 0.0% | | `Skipped` (multi-file 8 / lib-drift 6 / directive 3) | 17 | 5.7% | | `TypeCheckError` | 1 | 0.3% | | **Total** | **296** | | -The parser is no longer a significant bottleneck (an earlier sweep took the original subset's `ParseError` count from 57 → 24; a follow-up Track-1 round on the `symbolProperty*`/`symbolType*` cluster — computed well-known-symbol names in *value/class* position, which the [#99](https://github.com/nickna/SharpTS/issues/99) Phase-A parser work had only hardened for *type* position — took it 24 → 7): ambient `declare` of non-class declarations, `declare function`, generic/this/conditional/mapped/indexed-access/constructor/leading-operator types, `module Foo {}` namespaces, call/construct/index signatures, keyword & string/numeric property names, function-type and arrow optional/rest parameters, computed-key class fields/interface members/object-type members without an explicit type annotation (implicit `any`), and more. Negative-test matching itself was unlocked by propagating canonical `TSnnnn` codes through the type-checker's error-recovery path ([#125](https://github.com/nickna/SharpTS/issues/125)), which also added a `strictNullChecks` option (default on; the runner follows each test's `@strict`/`@strictNullChecks` directive). The residual 7 `ParseError`s are a scattered long tail (tagged-template escape sequences, `declare global` export forms, `import.meta`, `export * as ns`, logical-assignment edge cases, async-arrow-in-shared-memory) with no single shared root cause. +The parser is no longer a bottleneck at all for this subset: an earlier sweep took the original subset's `ParseError` count from 57 → 24; a Track-1 round on the `symbolProperty*`/`symbolType*` cluster — computed well-known-symbol names in *value/class* position, which the [#99](https://github.com/nickna/SharpTS/issues/99) Phase-A parser work had only hardened for *type* position — took it 24 → 7; a follow-up round cleared the remaining 7-test long tail (7 unrelated, independently-scoped gaps: object-type-member ASI, parenthesized logical-assignment targets, `async` used as a plain identifier, `import.meta` as a statement, `export {}` inside `declare global`, `export * as ns from`, and untagged-template invalid-escape sequences recovering as a `TS1125` diagnostic instead of aborting the parse) — **`ParseError` is now 0 for the committed subset.** Other parser work along the way: ambient `declare` of non-class declarations, `declare function`, generic/this/conditional/mapped/indexed-access/constructor/leading-operator types, `module Foo {}` namespaces, call/construct/index signatures, keyword & string/numeric property names, function-type and arrow optional/rest parameters, computed-key class fields/interface members/object-type members without an explicit type annotation (implicit `any`), and more. Negative-test matching itself was unlocked by propagating canonical `TSnnnn` codes through the type-checker's error-recovery path ([#125](https://github.com/nickna/SharpTS/issues/125)), which also added a `strictNullChecks` option (default on; the runner follows each test's `@strict`/`@strictNullChecks` directive). -The dominant bucket is `Fail` — tests that parse and reach the type checker but whose diagnostic set doesn't yet match `tsc`'s. A [#99](https://github.com/nickna/SharpTS/issues/99) measurement spike over the lib-sensitive folders decomposed these: of the original 66 lib-folder Fails, ~17 were **spurious** (SharpTS emitted an error `tsc` didn't; cleared by Track-1 — object literals/interfaces now model each computed well-known-symbol member as its own named member instead of collapsing them into one merged symbol index signature, closing the false positives on `Symbol.iterator`/`Symbol.toStringTag`/`Symbol.toPrimitive` reads+writes, string-index-signature compatibility, and `declare const x: unique symbol`), a long tail are individual checker gaps (assignability, symbol arithmetic, computed-name constraints), and only ~6 are explicit lib-version drift (`TS2550`/`TS2583`/`TS2585`). **The finding: loading `tsc`'s `lib.*.d.ts` is _not_ the current pass-rate lever — checker breadth and parser gaps dominate the divergence.** Loading `lib.*.d.ts` ([#99](https://github.com/nickna/SharpTS/issues/99)) remains valuable as an *enabler* (it removes the "`Pass` = coincidence" asterisk since globals resolve to `any` today, unblocks DOM/JSX wholesale, and is a prerequisite for per-node types/symbols in [#88](https://github.com/nickna/SharpTS/issues/88)), but the near-term levers are checker correctness (structural class-to-class assignability [#129](https://github.com/nickna/SharpTS/issues/129); cross-statement CFA narrowing for compound logical assignment) and clearing the remaining scattered `ParseError`/`Fail` long tail. +`Fail` is the largest non-`Pass` bucket — tests that parse and reach the type checker but whose diagnostic set doesn't yet match `tsc`'s. A [#99](https://github.com/nickna/SharpTS/issues/99) measurement spike over the lib-sensitive folders decomposed these: of the original 66 lib-folder Fails, ~17 were **spurious** (SharpTS emitted an error `tsc` didn't; cleared by Track-1 — object literals/interfaces now model each computed well-known-symbol member as its own named member instead of collapsing them into one merged symbol index signature, closing the false positives on `Symbol.iterator`/`Symbol.toStringTag`/`Symbol.toPrimitive` reads+writes, string-index-signature compatibility, and `declare const x: unique symbol`), a long tail are individual checker gaps (assignability, symbol arithmetic, computed-name constraints), and only ~6 are explicit lib-version drift (`TS2550`/`TS2583`/`TS2585`). **The finding: loading `tsc`'s `lib.*.d.ts` is _not_ the current pass-rate lever — checker breadth and parser gaps dominate the divergence.** Loading `lib.*.d.ts` ([#99](https://github.com/nickna/SharpTS/issues/99)) remains valuable as an *enabler* (it removes the "`Pass` = coincidence" asterisk since globals resolve to `any` today, unblocks DOM/JSX wholesale, and is a prerequisite for per-node types/symbols in [#88](https://github.com/nickna/SharpTS/issues/88)), but the near-term levers are checker correctness (structural class-to-class assignability [#129](https://github.com/nickna/SharpTS/issues/129); cross-statement CFA narrowing for compound logical assignment) and clearing the remaining `Fail`-bucket long tail. `Skipped` includes: - **Multi-file tests** (`Skipped:multi-file-deferred`) — cross-file resolution into the runner is follow-up work. diff --git a/SharpTS.TypeScriptConformance/baselines/interpreted.txt b/SharpTS.TypeScriptConformance/baselines/interpreted.txt index 19875a6b..0d9fe2f1 100644 --- a/SharpTS.TypeScriptConformance/baselines/interpreted.txt +++ b/SharpTS.TypeScriptConformance/baselines/interpreted.txt @@ -20,14 +20,14 @@ tests/cases/conformance/es2017/useSharedArrayBuffer4.ts Pass tests/cases/conformance/es2017/useSharedArrayBuffer5.ts Pass tests/cases/conformance/es2017/useSharedArrayBuffer6.ts Skipped:lib-drift tests/cases/conformance/es2018/es2018IntlAPIs.ts Pass -tests/cases/conformance/es2018/invalidTaggedTemplateEscapeSequences.ts ParseError +tests/cases/conformance/es2018/invalidTaggedTemplateEscapeSequences.ts Pass tests/cases/conformance/es2018/usePromiseFinally.ts Pass tests/cases/conformance/es2018/useRegexpGroups.ts Pass tests/cases/conformance/es2019/allowUnescapedParagraphAndLineSeparatorsInStringLiteral.ts Pass tests/cases/conformance/es2019/globalThisAmbientModules.ts Skipped:lib-drift tests/cases/conformance/es2019/globalThisBlockscopedProperties.ts Skipped:lib-drift tests/cases/conformance/es2019/globalThisCollision.ts Skipped:directive:allowjs -tests/cases/conformance/es2019/globalThisGlobalExportAsGlobal.ts ParseError +tests/cases/conformance/es2019/globalThisGlobalExportAsGlobal.ts Fail tests/cases/conformance/es2019/globalThisPropertyAssignment.ts Skipped:directive:allowjs tests/cases/conformance/es2019/globalThisReadonlyProperties.ts Fail tests/cases/conformance/es2019/globalThisTypeIndexAccess.ts Pass @@ -35,7 +35,7 @@ tests/cases/conformance/es2019/globalThisUnknown.ts Skipped:lib-drift tests/cases/conformance/es2019/globalThisUnknownNoImplicitAny.ts Fail tests/cases/conformance/es2019/globalThisVarDeclaration.ts Skipped:directive:allowjs tests/cases/conformance/es2019/importMeta/importMeta.ts Skipped:multi-file-deferred -tests/cases/conformance/es2019/importMeta/importMetaNarrowing.ts ParseError +tests/cases/conformance/es2019/importMeta/importMetaNarrowing.ts Fail tests/cases/conformance/es2020/bigintMissingES2019.ts Pass tests/cases/conformance/es2020/bigintMissingES2020.ts Pass tests/cases/conformance/es2020/bigintMissingESNext.ts Pass @@ -50,14 +50,14 @@ tests/cases/conformance/es2020/modules/exportAsNamespace4.ts Skipped:multi-file- tests/cases/conformance/es2020/modules/exportAsNamespace5.ts Skipped:multi-file-deferred tests/cases/conformance/es2020/modules/exportAsNamespace_exportAssignment.ts Skipped:multi-file-deferred tests/cases/conformance/es2020/modules/exportAsNamespace_missingEmitHelpers.ts Skipped:multi-file-deferred -tests/cases/conformance/es2020/modules/exportAsNamespace_nonExistent.ts ParseError +tests/cases/conformance/es2020/modules/exportAsNamespace_nonExistent.ts Fail tests/cases/conformance/es2021/es2021LocalesObjectArgument.ts Pass tests/cases/conformance/es2021/intlDateTimeFormatRangeES2021.ts Pass tests/cases/conformance/es2021/logicalAssignment/logicalAssignment1.ts Pass tests/cases/conformance/es2021/logicalAssignment/logicalAssignment10.ts Pass -tests/cases/conformance/es2021/logicalAssignment/logicalAssignment2.ts ParseError -tests/cases/conformance/es2021/logicalAssignment/logicalAssignment3.ts ParseError -tests/cases/conformance/es2021/logicalAssignment/logicalAssignment4.ts Fail +tests/cases/conformance/es2021/logicalAssignment/logicalAssignment2.ts Pass +tests/cases/conformance/es2021/logicalAssignment/logicalAssignment3.ts Pass +tests/cases/conformance/es2021/logicalAssignment/logicalAssignment4.ts Pass tests/cases/conformance/es2021/logicalAssignment/logicalAssignment5.ts Fail tests/cases/conformance/es2021/logicalAssignment/logicalAssignment6.ts Fail tests/cases/conformance/es2021/logicalAssignment/logicalAssignment7.ts Fail @@ -65,7 +65,7 @@ tests/cases/conformance/es2021/logicalAssignment/logicalAssignment8.ts Pass tests/cases/conformance/es2021/logicalAssignment/logicalAssignment9.ts Pass tests/cases/conformance/es2022/es2022IntlAPIs.ts Pass tests/cases/conformance/es2022/es2022LocalesObjectArgument.ts Pass -tests/cases/conformance/es2022/es2022SharedMemory.ts ParseError +tests/cases/conformance/es2022/es2022SharedMemory.ts Pass tests/cases/conformance/es2023/intlNumberFormatES2023.ts Pass tests/cases/conformance/es2023/intlNumberFormatES5UseGrouping.ts Fail tests/cases/conformance/es6/Symbols/symbolDeclarationEmit1.ts Pass @@ -163,7 +163,7 @@ tests/cases/conformance/es6/Symbols/symbolType6.ts Fail tests/cases/conformance/es6/Symbols/symbolType7.ts Fail tests/cases/conformance/es6/Symbols/symbolType8.ts Fail tests/cases/conformance/es6/Symbols/symbolType9.ts Fail -tests/cases/conformance/esnext/logicalAssignment/logicalAssignment11.ts Fail +tests/cases/conformance/esnext/logicalAssignment/logicalAssignment11.ts Pass tests/cases/conformance/types/conditional/conditionalTypes1.ts Pass tests/cases/conformance/types/conditional/conditionalTypes2.ts Pass tests/cases/conformance/types/conditional/conditionalTypesExcessProperties.ts Pass diff --git a/TypeSystem/TypeChecker.Compatibility.TypeGuards.cs b/TypeSystem/TypeChecker.Compatibility.TypeGuards.cs index 8469a0f9..e1ea4889 100644 --- a/TypeSystem/TypeChecker.Compatibility.TypeGuards.cs +++ b/TypeSystem/TypeChecker.Compatibility.TypeGuards.cs @@ -934,6 +934,16 @@ void CollectNarrowings(Expr expr) return; } + // `if (x OP= y)` — a compound logical assignment used as a condition. CheckExpr already + // ran (VisitIf checks the condition before narrowing analysis), so LookupVariable(x) + // reflects the post-assignment type CheckLogicalAssign installed; split THAT for the + // guard, same as a bare `if (x)`. + if (expr is Expr.LogicalAssign logicalAssign) + { + narrowings.AddRange(AnalyzeLogicalAssignGuard(logicalAssign)); + return; + } + // Try to get a single type guard from this expression var guard = AnalyzePathTypeGuard(expr); if (guard.Path != null && guard.NarrowedType != null && guard.ExcludedType != null) @@ -956,6 +966,35 @@ void CollectNarrowings(Expr expr) return narrowings; } + /// + /// Type-guard analysis for `if (x OP= y)`. The LHS narrows the same way a bare `if (x)` would, + /// using its post-assignment type (already installed in the environment by CheckLogicalAssign + /// by the time this runs). For `&&=` specifically, a truthy overall result additionally + /// guarantees the RHS was evaluated (the LHS was truthy) AND the RHS's own value was truthy — + /// `&&=` only assigns/returns the RHS when the LHS is truthy, so a truthy overall result can't + /// come from a falsy RHS. If the RHS is a bare identifier, narrow it too (`||=`/`??=` don't get + /// this: their truthy branch is also reachable via an already-truthy/non-nullish LHS that never + /// touched the RHS at all, so nothing can be said about the RHS's truthiness there). + /// + private List<(Narrowing.NarrowingPath Path, TypeInfo NarrowedType, TypeInfo ExcludedType)> AnalyzeLogicalAssignGuard( + Expr.LogicalAssign logical) + { + var result = new List<(Narrowing.NarrowingPath, TypeInfo, TypeInfo)>(); + + var lhsType = LookupVariable(logical.Name); + var lhsPath = new Narrowing.NarrowingPath.Variable(logical.Name.Lexeme); + result.Add((lhsPath, NarrowLogicalTruthy(lhsType), NarrowLogicalFalsy(lhsType))); + + if (logical.Operator.Type == TokenType.AND_AND_EQUAL && logical.Value is Expr.Variable rhsVar) + { + var rhsType = LookupVariable(rhsVar.Name); + result.Add((new Narrowing.NarrowingPath.Variable(rhsVar.Name.Lexeme), + NarrowLogicalTruthy(rhsType), NarrowLogicalFalsy(rhsType))); + } + + return result; + } + /// /// Unions two narrowed types, flattening nested unions and deduplicating. /// Never contributes nothing (it's the empty union). diff --git a/TypeSystem/TypeChecker.Expressions.cs b/TypeSystem/TypeChecker.Expressions.cs index 0ded04f8..79c4e1a8 100644 --- a/TypeSystem/TypeChecker.Expressions.cs +++ b/TypeSystem/TypeChecker.Expressions.cs @@ -569,6 +569,17 @@ private TypeInfo CheckTemplateLiteral(Expr.TemplateLiteral template) { CheckExpr(expr); } + // An untagged template with an invalid escape sequence (`\xtraordinary`, `\u{hello}`, ...) is + // a real syntax error — the parser recovers instead of aborting the whole file (a tagged + // template's raw form tolerates it fine), so it surfaces here as a normal diagnostic. + if (template.InvalidEscapeLines != null) + { + foreach (var line in template.InvalidEscapeLines) + { + RecordTypeError(new TypeCheckException( + "Hexadecimal digit expected.", line: line, tsCode: "TS1125")); + } + } // Template literals always result in string return new TypeInfo.String(); } diff --git a/TypeSystem/TypeChecker.Operators.cs b/TypeSystem/TypeChecker.Operators.cs index 916e3066..56988741 100644 --- a/TypeSystem/TypeChecker.Operators.cs +++ b/TypeSystem/TypeChecker.Operators.cs @@ -425,7 +425,25 @@ private TypeInfo CheckCompoundSetIndex(Expr.CompoundSetIndex compound) private TypeInfo CheckLogicalAssign(Expr.LogicalAssign logical) { TypeInfo varType = LookupVariable(logical.Name); - TypeInfo valueType = CheckExpr(logical.Value); + + // `&&=` only evaluates (and assigns) its RHS when the LHS is truthy — narrow the LHS to + // its truthy constituents while checking the RHS, mirroring plain `&&`'s CheckLogical. + // Without this, `thing &&= thing.original` spuriously flags `thing` as possibly + // undefined/null on the very read that `&&=`'s short-circuit already guarantees is safe. + TypeInfo valueType; + if (logical.Operator.Type == TokenType.AND_AND_EQUAL) + { + var narrowedEnv = new TypeEnvironment(_environment); + narrowedEnv.Define(logical.Name.Lexeme, NarrowLogicalTruthy(varType)); + using (new EnvironmentScope(this, narrowedEnv)) + { + valueType = CheckExpr(logical.Value); + } + } + else + { + valueType = CheckExpr(logical.Value); + } // Invalidate any narrowings affected by this assignment var assignedPath = new Narrowing.NarrowingPath.Variable(logical.Name.Lexeme); @@ -447,11 +465,32 @@ private TypeInfo CheckLogicalAssign(Expr.LogicalAssign logical) _ => varType, }; - if (narrowedVar is TypeInfo.Never) return valueType; - if (narrowedVar is TypeInfo.Any || valueType is TypeInfo.Any) return new TypeInfo.Any(); - if (IsCompatible(narrowedVar, valueType)) return valueType; - if (IsCompatible(valueType, narrowedVar)) return narrowedVar; - return new TypeInfo.Union([narrowedVar, valueType]); + // Picks the WIDER of the two candidate types when one subsumes the other (so a value that + // could only ever be the narrower candidate doesn't lose the other candidate's possibilities); + // IsCompatible(expected, actual) is true when actual fits inside expected, so the branch that + // fires names the wider (subsuming) type directly — not its own first argument's counterpart. + TypeInfo resultType; + if (narrowedVar is TypeInfo.Never) resultType = valueType; + else if (narrowedVar is TypeInfo.Any || valueType is TypeInfo.Any) resultType = new TypeInfo.Any(); + else if (IsCompatible(narrowedVar, valueType)) resultType = narrowedVar; + else if (IsCompatible(valueType, narrowedVar)) resultType = valueType; + else resultType = new TypeInfo.Union([narrowedVar, valueType]); + + // Post-assignment narrowing: the variable's new value is exactly `resultType`, so + // subsequent reads in the same scope see it narrowed — mirrors CheckAssign's + // environment update for a plain `x = v` (fixes `results ||= []; results.push(...)`, + // which needs the narrowing to persist past the statement, not just within the + // expression itself). + if (IsDeclaredTypeTracked(logical.Name.Lexeme)) + { + var declaredType = GetDeclaredType(logical.Name.Lexeme) ?? varType; + if (NarrowToDeclaredSlot(declaredType, resultType) is { } narrowedSlot) + { + _environment.Define(logical.Name.Lexeme, narrowedSlot); + } + } + + return resultType; } /// Truthy narrowing of a type: drops the definitely-falsy constituents