diff --git a/docs/content/limitations/type-checking.mdx b/docs/content/limitations/type-checking.mdx index e3dc54aef23..8632882686e 100644 --- a/docs/content/limitations/type-checking.mdx +++ b/docs/content/limitations/type-checking.mdx @@ -4,3 +4,7 @@ Some declarations differ from TypeScript's standard library or `@types/node`: `J A program that passes its project's TypeScript check may still use unsupported operations. A second preflight using the project's declarations distinguishes those sites from ordinary type errors and reports scriptc diagnostics with rewrite hints. Declared APIs without a compiler implementation, such as unsupported console members, produce `SC2020`. + +TypeScript keeps the type of an assignment across a call, so after `x = undefined` a read of `x` is typed `undefined` even when a function called in between assigns `x` again. +scriptc reads the stored value there. +A function without a declared return type that returns such a read is inferred to return `undefined`; scriptc refuses that return with `SC1090`, and a declared return type compiles. diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 2c89dffce90..2949c428a6d 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -588,6 +588,9 @@ export function lowerLambda( // statement branches instead of a forbidden void-valued ternary. let stripped: ts.Expression = bodyExpr; while (ts.isParenthesizedExpression(stripped)) stripped = stripped.expression; + // A body whose value the checker typed `undefined` from a + // narrowing a call may have undone would be dropped here. + lowerer.refuseDroppedStaleReturn(bodyExpr); if (ts.isVoidExpression(stripped) || ts.isConditionalExpression(stripped)) { body = [lowerer.lowerExprStatement(stripped)]; } else { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ecca9ee6a12..5124893625c 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -5429,6 +5429,9 @@ export class Lowerer { "narrowing.effects", "narrowing.capturedMutable", "narrowing.staleBindings", + "narrowing.inherited", + "narrowing.writes", + "narrowing.resolving", "narrowing.identifierWrites", "narrowing.widened", "narrowing.plainContainers", @@ -16716,6 +16719,7 @@ export class Lowerer { private lowerReturnOf(node: ts.Expression, loc: SrcLoc): IrStmt { const expected = this.ctx.returnType; if (expected.kind === "void") { + this.refuseDroppedStaleReturn(node); let e = this.lowerExpr(node); const flattened = this.maybePromiseReturn(node, e, expected); if (flattened) return flattened; @@ -16760,6 +16764,22 @@ export class Lowerer { return { kind: "return", value: this.lowerReturnValue(node), loc }; } + /** A function returns nothing when the checker inferred its return type + * from a read it narrowed to `undefined`. A call may have stored a value + * there since (widenedType restored the read), and returning nothing + * would drop it; the caller's types were inferred from the same + * narrowing, so only a declared return type can carry the value. */ + refuseDroppedStaleReturn(node: ts.Expression): void { + const stale = this.narrowing.restoredNothingRead(node); + if (stale === null) return; + this.unsupported( + "SC1090", + node, + `returning '${ts.getTextOfNode(stale)}' from a function whose return type TypeScript infers as 'undefined' (it types the read by the last assignment in this function, and a call made since can have assigned it from another function)`, + "declare the function's return type, so the result is the value stored at the return", + ); + } + maybeNarrow(expr: IrExpr, node: ts.Node): IrExpr { return maybeNarrow(this, expr, node); } diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts index 577356fc6e0..3bb5e0d4181 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts @@ -153,3 +153,344 @@ formatted({}, []); `); expect(thrownMessages(module, "formatted")).toEqual([]); }); + +/** Whether a function's body reads a binding whose id `matches`. */ +function reads(module: IrModule, fn: string, matches: (id: string) => boolean): boolean { + const found = module.functions.find((f) => f.name === fn || f.name.endsWith(`.${fn}`)); + if (!found) throw new Error(`no function ${fn}`); + let seen = false; + JSON.stringify(found.body, (_key, value: unknown) => { + const node = value as { kind?: string; localId?: string }; + if (node?.kind === "varRef" && node.localId !== undefined && matches(node.localId)) seen = true; + return value; + }); + return seen; +} + +/** Whether a function's body reads the module-level binding `name`. */ +function readsGlobal(module: IrModule, fn: string, name: string): boolean { + return reads(module, fn, (id) => id.endsWith(`.${name}`)); +} + +/** Whether a function's body reads its local `name`. */ +function readsLocal(module: IrModule, fn: string, name: string): boolean { + return reads(module, fn, (id) => id.startsWith(`${name}.`)); +} + +test("a narrowing to undefined that a call may have undone reads the stored value", async () => { + // TypeScript types `start` as `undefined` at both slices. In `stale` the + // closure ran since, so the argument is the binding; in `current` nothing + // ran since the assignment, so the argument is absent. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale(): string { + start = undefined; + set(); + return "abcd".slice(start); +} +function current(): string { + start = undefined; + return "abcd".slice(start); +} +console.log(stale(), current()); +`); + expect(readsGlobal(module, "stale", "start")).toBe(true); + expect(readsGlobal(module, "current", "start")).toBe(false); +}); + +test("a binding typed by flow alone reads as declared once another function may have assigned it", async () => { + // `let text;` is `undefined` to the checker until this function assigns it. + const module = await lower(` +let text; +let other; +function assign(): void { + text = "t"; + other = "o"; +} +console.log("x".padStart(3, other)); +assign(); +console.log("x".padStart(3, text)); +`); + const init = module.functions.find((f) => f.name.startsWith("%init"))!.name; + expect(readsGlobal(module, init, "text")).toBe(true); + expect(readsGlobal(module, init, "other")).toBe(false); +}); + +test("an untyped binding initialized from such a read holds the stored value", async () => { + // The checker infers `copy: undefined` from the narrowing of `start`. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale(): string { + start = undefined; + set(); + const copy = start; + return "abcd".slice(copy); +} +console.log(stale()); +`); + expect(readsLocal(module, "stale", "copy")).toBe(true); +}); + +test("an assignment of the copy's own ends what its initializer left in it", async () => { + const source = (reset: string): string => ` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function copied(clear: boolean) { + start = undefined; + set(); + let copy = start; + ${reset} + return copy; +} +copied(true); +`; + // Every path to the return stores undefined last: the copy is undefined + // there, and the function returns nothing, as the checker infers. + for (const reset of [ + "copy = undefined;", + "if (clear) copy = start; copy = undefined;", + "if (clear) { copy = undefined; } else { copy = undefined; }", + "for (let i = 0; i < 2; i++) { copy = start; } copy = undefined;", + "try { copy = start; } finally { set(); } copy = undefined;", + ]) + await lower(source(reset)); + // Some path keeps the initializer's value, or stores the read again. + const refusal = /SC1090: returning 'copy' from a function whose return type TypeScript infers/; + for (const reset of [ + "", + "if (clear) copy = undefined;", + "copy = undefined; copy = start;", + "clear && (copy = undefined);", + "clear || (copy = undefined);", + "clear ? (copy = undefined) : 0;", + "for (let i = 0; i < 1; i++) { if (clear) break; copy = undefined; }", + "while (clear) { copy = undefined; break; }", + "try { if (clear) throw new Error(); copy = undefined; } catch {}", + "try { set(); } catch { copy = undefined; }", + "block: { if (clear) break block; copy = undefined; }", + "switch (clear) { case true: copy = undefined; }", + "copy = undefined; if (clear) copy = start;", + ]) + await expect(lower(source(reset)), reset).rejects.toThrow(refusal); +}); + +test("a function inferred to return undefined from a stale narrowing is refused", async () => { + const source = (returnType: string): string => ` +let found: string | undefined = undefined; +function search(): void { + found = "hit"; +} +function result()${returnType} { + found = undefined; + search(); + return found; +} +console.log(result()); +`; + await expect(lower(source(""))).rejects.toThrow( + /SC1090: returning 'found' from a function whose return type TypeScript infers as 'undefined'/, + ); + const module = await lower(source(": string | undefined")); + expect(readsGlobal(module, "result", "found")).toBe(true); +}); + +/** The value a function's `return` statement carries: `"absent"` for a + * bare return, `"present"` otherwise. */ +function returnValue(module: IrModule, fn: string): string { + const found = module.functions.find((f) => f.name === fn || f.name.endsWith(`.${fn}`)); + if (!found) throw new Error(`no function ${fn}`); + let value = "none"; + JSON.stringify(found.body, (_key, node: unknown) => { + const stmt = node as { kind?: string; value?: unknown }; + if (stmt?.kind === "return") value = stmt.value === null ? "absent" : "present"; + return node; + }); + return value; +} + +test("an untyped copy the function resets to undefined reads as absent", async () => { + // The checker infers `copy: undefined` from the narrowing of `start`, but + // `copy = undefined` is the write the return sees: there is nothing stale + // left to carry, so the return is absent rather than refused. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale() { + start = undefined; + set(); + let copy = start; + copy = undefined; + return copy; +} +stale(); +`); + expect(returnValue(module, "stale")).toBe("absent"); +}); + +test("a conditional assignment before the copy's reset does not reach its return", async () => { + // `if (flag) copy = start` may not run, and `copy = undefined` after it + // overwrites it when it does: the return still holds nothing. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +let flag = true; +function stale() { + start = undefined; + set(); + let copy = start; + if (flag) copy = start; + copy = undefined; + return copy; +} +stale(); +`); + expect(returnValue(module, "stale")).toBe("absent"); +}); + +test("a reset in the right operand of a logical operator does not end the copy's type", async () => { + // `flag && (...)`: the reset may be skipped, so the copy may still hold the + // stored value and the optional argument reads it. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +let flag = true; +function stale(): string { + start = undefined; + set(); + let copy = start; + flag && (copy = undefined); + return "abcd".slice(copy); +} +console.log(stale()); +`); + expect(readsLocal(module, "stale", "copy")).toBe(true); +}); + +test("an uninitialized var declaration in a loop does not reset the copy on later iterations", async () => { + // A `var` binding is initialized when the function is entered, not each + // time its declaration statement runs: on the second iteration the copy + // still holds the value the first one stored, so the argument reads it. + const loop = (declaration: string): string => ` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale(): string { + start = undefined; + set(); + for (let i = 0; i < 2; i++) { + ${declaration} + if (i === 1) return "abcd".slice(copy); + copy = start; + } + return "none"; +} +console.log(stale()); +`; + const stored = await lower(loop("var copy;")); + expect(readsLocal(stored, "stale", "copy")).toBe(true); + // A block-scoped declaration does initialize its binding each time. + const fresh = await lower(loop("let copy;")); + expect(readsLocal(fresh, "stale", "copy")).toBe(false); +}); + +test("a copy written from itself in a conditional keeps the type it held before the write", async () => { + // `keep ? copy : undefined` stores the value `copy` already held when + // `keep` is true, so the argument still reads the binding. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +let keep = true; +function stale(): string { + start = undefined; + set(); + let copy = start; + copy = keep ? copy : undefined; + return "abcd".slice(copy); +} +console.log(stale()); +`); + expect(readsLocal(module, "stale", "copy")).toBe(true); +}); + +test("an untyped copy assigned the stale read again after a reset is refused", async () => { + await expect( + lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale() { + start = undefined; + set(); + let copy = start; + copy = undefined; + copy = start; + return copy; +} +stale(); +`), + ).rejects.toThrow(/SC1090: returning 'copy'/); +}); + +test("a copy assigned from itself under a condition still holds the stored value", async () => { + // `copy = keep ? copy : undefined` is typed undefined on both arms, and + // one of them is the copy: the write cannot be judged apart from the + // value it reads, so the optional argument reads the copy. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale(keep: boolean): string { + start = undefined; + set(); + let copy = start; + copy = keep ? copy : undefined; + return "abcd".slice(copy); +} +console.log(stale(true), stale(false)); +`); + expect(readsLocal(module, "stale", "copy")).toBe(true); +}); + +test("a var declared without a value in a loop keeps what an earlier iteration stored", async () => { + // `var copy;` stores nothing when it runs: on the second iteration the + // copy holds the first one's value. A `let` is a fresh undefined. + const source = (declaration: string): string => ` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale(): string { + start = undefined; + set(); + let out = ""; + for (let i = 0; i < 2; i++) { + ${declaration} + out += "abcd".slice(copy); + copy = start; + } + return out; +} +console.log(stale()); +`; + expect(readsLocal(await lower(source("var copy;")), "stale", "copy")).toBe(true); + expect(readsLocal(await lower(source("let copy;")), "stale", "copy")).toBe(false); +}); diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index e461cddb938..c172559664a 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -1,5 +1,5 @@ import * as ts from "../ts7/adapter.js"; -import { withoutParenthesesTypeWrappers } from "../outer-expressions.js"; +import { withoutParentheses, withoutParenthesesTypeWrappers } from "../outer-expressions.js"; import { heritageElementTarget } from "../heritage-elements.js"; import { InternalCompilerError } from "../../errors.js"; import { typeEquals } from "../../ir/ir.js"; @@ -51,6 +51,14 @@ export class NarrowingStability { private readonly effects = new Map(); private readonly capturedMutable = new Map(); private readonly staleBindings = new Map(); + private readonly inherited = new Map(); + private readonly writes = new Map(); + /** Bindings whose writes or inherited type are being computed, and how + * often a computation asked for one of them: an answer that depends on + * itself (`copy = keep ? copy : undefined`) is not an answer, so + * nothing derived from it is kept and the write counts as stale. */ + private readonly resolving = new Set(); + private selfReferences = 0; private readonly identifierWrites = new Map>(); private readonly widened = new Map(); private readonly plainContainers = new Map(); @@ -73,15 +81,48 @@ export class NarrowingStability { const cached = this.widened.get(node); if (cached !== undefined) return cached; const position = widePosition(node); + const selfReferences = this.selfReferences; let result: ts.Type | null = null; try { - if (position !== null) result = this.computeWidened(node, position); + result = this.computeWidened(node, position); } finally { - this.widened.set(node, result); + if (this.selfReferences === selfReferences) this.widened.set(node, result); } return result; } + /** A read whose value `expression` can evaluate to (the expression + * itself, an arm of a conditional, an operand of `&&`, `||`, `??`, the + * right side of a comma or an assignment), that the checker types as + * nothing but `undefined` or `null` and widenedType restored to its + * declared type; or null. The checker computed the type of the whole + * expression, and of a function that returns it, from that narrowing, so + * a lowering that discards a value because its type is `undefined` + * consults this first. */ + restoredNothingRead(expression: ts.Expression): ts.Expression | null { + if (this.files === null) return null; + const e = withoutParenthesesTypeWrappers(expression); + if (ts.isIdentifier(e) || ts.isPropertyAccessExpression(e)) { + return this.widenedType(e) !== null && isNothing(this.lowerer.checker.getTypeAtLocation(e)) + ? e + : null; + } + if (ts.isConditionalExpression(e)) + return this.restoredNothingRead(e.whenTrue) ?? this.restoredNothingRead(e.whenFalse); + if (ts.isBinaryExpression(e)) { + switch (e.operatorToken.kind) { + case ts.SyntaxKind.AmpersandAmpersandToken: + case ts.SyntaxKind.BarBarToken: + case ts.SyntaxKind.QuestionQuestionToken: + return this.restoredNothingRead(e.left) ?? this.restoredNothingRead(e.right); + case ts.SyntaxKind.CommaToken: + case ts.SyntaxKind.EqualsToken: + return this.restoredNothingRead(e.right); + } + } + return null; + } + /** The checker narrows this reference below its declared type, and the * narrowing may be stale at runtime: its read must check the stored * value instead of trusting the narrowed type. */ @@ -113,20 +154,47 @@ export class NarrowingStability { private computeWidened( node: ts.PropertyAccessExpression | ts.Identifier, - position: WidePosition, + position: WidePosition | null, ): ts.Type | null { const checker = this.lowerer.checker; + // A narrowing to nothing but `undefined` or `null` is restored in every + // position, so every read reaches the declared-type lookup; the other + // narrowings only in the positions that can take the declared type. + if (position === null && !this.mayReadNothing(node)) return null; let declared: ts.Type | null; if (ts.isPropertyAccessExpression(node)) { declared = this.declaredPropertyType(node); } else { const symbol = this.bindingOf(node); + // `let y = x` with `x` such a read: the checker inferred the binding's + // own type from the narrowing, so `y` is nothing to it as well, and it + // holds what `x` held. + if (symbol && isNothing(checker.getTypeAtLocation(node))) { + // Every assignment of the binding's own that can reach this read + // stores that nothing (`y = undefined; return y`): it ends what an + // earlier initializer or assignment left in the binding, and the + // checker's type is right. + if (this.holdsNothingAt(node, symbol)) return null; + if (isNothing(checker.getTypeOfSymbol(symbol))) return this.inheritedType(symbol); + } declared = symbol && this.mayHoldStaleNarrowing(symbol) ? checker.getTypeOfSymbol(symbol) : null; } if (declared === null) return null; const narrowedTs = checker.getTypeAtLocation(node); if (narrowedTs === declared) return null; + // A narrowing to nothing but `undefined` or `null` has no payload a + // checked read could validate: a lowering that takes the checker's word + // drops the read and continues with "absent" (`text.slice(start)` with + // `start` assigned by a callback slices from 0). When that narrowing may + // be stale the read has its declared type wherever it stands, so its + // consumer lowers for the value that is stored. The declared type of a + // binding the checker types by flow alone (`var re;`, which is + // `undefined` to it until the read's own function assigns it) is `any`, + // as the checker itself answers for a read from a nested function + // (`var re; compile(); scan(re)` passes the expression `compile` stored). + if (isNothing(narrowedTs)) return this.trusted(node) ? null : declared; + if (position === null) return null; const declaredIr = this.lowerer.mapTypeOf(declared); const narrowedIr = this.lowerer.mapTypeOf(narrowedTs); if (declaredIr === null || narrowedIr === null || typeEquals(declaredIr, narrowedIr)) @@ -176,6 +244,270 @@ export class NarrowingStability { return this.trusted(node) ? null : declared; } + /** Whether the checker types this read as nothing but `undefined` or + * `null`. Asked for every identifier and property read outside the wide + * positions; the type is the one `Lowerer.typeOf` asks for next, from + * the checker's per-node memo. */ + private mayReadNothing(node: ts.PropertyAccessExpression | ts.Identifier): boolean { + if (ts.isIdentifier(node) && !isValueIdentifier(node)) return false; + return isNothing(this.lowerer.checker.getTypeAtLocation(node)); + } + + /** The type an untyped binding holds when the checker inferred "nothing" + * for it from reads widenedType restored: the declared type of those + * reads (`let y = x` after a call that may have assigned `x`), `any` + * when they disagree. Null when no such read initializes or assigns it. */ + private inheritedType(symbol: ts.Symbol): ts.Type | null { + const cached = this.inherited.get(symbol); + if (cached !== undefined) return cached; + if (this.resolving.has(symbol)) { + this.selfReferences++; + return null; + } + const selfReferences = this.selfReferences; + const writes = this.writesOf(symbol); + this.resolving.add(symbol); + let result: ts.Type | null = null; + try { + const decl = symbol.valueDeclaration; + if (decl !== undefined && ts.isVariableDeclaration(decl) && decl.type === undefined) { + for (const write of writes ?? []) { + if (write.value === null) continue; + const read = this.restoredNothingRead(write.value); + const restored = read === null ? null : this.widenedType(read); + if (restored === null) continue; + result = + result === null || result === restored ? restored : this.lowerer.checker.getAnyType(); + } + } + } finally { + this.resolving.delete(symbol); + if (this.selfReferences === selfReferences) this.inherited.set(symbol, result); + } + return result; + } + + /** Every write of a variable declared by one plain declaration + * statement: the declaration (`value` is its initializer; a `let` + * without one stores `undefined`, a `var` without one is no write: it + * holds `undefined` from the function's entry), each `=` assignment + * (`value` is its + * right side), and every other write (destructuring, `+=`, `++`, a + * `for...of` target), whose stored value is unknown. `stale` says the + * value can be a read whose narrowing may be stale. Null for any other + * binding (a parameter, a pattern or loop declaration, a redeclared + * `var`): its writes are not all in view. */ + private writesOf(symbol: ts.Symbol): BindingWrite[] | null { + const cached = this.writes.get(symbol); + if (cached !== undefined) return cached; + if (this.resolving.has(symbol)) { + this.selfReferences++; + return null; + } + this.resolving.add(symbol); + let result: BindingWrite[] | null = null; + try { + const decl = symbol.valueDeclaration; + const list = decl?.parent; + if ( + decl !== undefined && + ts.isVariableDeclaration(decl) && + ts.isIdentifier(decl.name) && + (symbol.declarations?.length ?? 1) === 1 && + list !== undefined && + ts.isVariableDeclarationList(list) && + list.parent !== undefined && + ts.isVariableStatement(list.parent) + ) { + const found: BindingWrite[] = []; + // A value whose judgement asked for this binding's own writes + // (it reads the binding, directly or through another copy) is + // taken to be stale. + const stale = (value: ts.Expression): boolean => { + const selfReferences = this.selfReferences; + const found = this.mayBeStale(value) || this.restoredNothingRead(value) !== null; + return found || this.selfReferences !== selfReferences; + }; + // A `var` binding is initialized to `undefined` when the function is + // entered, not when its declaration statement runs: a declaration + // without a value stores nothing there, and a loop around it does + // not reset the binding on later iterations. A block-scoped + // declaration does initialize its binding each time it runs. + const blockScoped = (list.flags & ts.NodeFlags.BlockScoped) !== 0; + if (decl.initializer !== undefined) + found.push({ + at: decl, + value: decl.initializer, + kind: "assigned", + stale: stale(decl.initializer), + }); + else if (blockScoped) found.push({ at: decl, value: null, kind: "declared", stale: false }); + for (const write of this.writesIn(ts.getSourceFileOfNode(decl)).get( + ts.symbolName(symbol), + ) ?? []) { + if (this.lowerer.resolveValueSymbol(write) !== symbol) continue; + const assignment = write.parent; + if ( + ts.isBinaryExpression(assignment) && + assignment.left === write && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) + found.push({ + at: assignment, + value: assignment.right, + kind: "assigned", + stale: stale(assignment.right), + }); + else found.push({ at: write, value: null, kind: "unknown", stale: true }); + } + result = found; + } + } finally { + this.resolving.delete(symbol); + this.writes.set(symbol, result); + } + return result; + } + + /** Whether the binding certainly holds, at `read`, the nothing the + * checker types the read as: every write that can reach the read along + * the function's control flow stores that nothing, spelled as such + * (`undefined`, `null`, `void e`, or a declaration without a value). A + * forward pass over the statements on the way to the read + * with one fact, "the binding may hold something else". A statement + * that is the declaration or an assignment of the binding sets the fact + * from what it stores; a block passes it through its statements; an + * `if` joins its arms; everything else (a loop, `try`, `switch`, a + * label, a short-circuit or conditional expression, a write inside a + * larger expression) can only raise the fact, by any write in it that + * does not store that nothing, wherever in it the write stands. So a + * reset is believed only where it runs on every path, and where the + * pass cannot tell, the read is of the stored value. */ + private holdsNothingAt(read: ts.Identifier, symbol: ts.Symbol): boolean { + // Another function's assignment is not on this function's paths. + if (this.isCapturedMutable(symbol)) return false; + const writes = this.writesOf(symbol); + // Only a write that is stale or unknown gives the binding something + // the checker does not see; without one its type stands as it is. + if (writes === null || !writes.some((write) => write.stale)) return false; + const decl = symbol.valueDeclaration!; + const boundary = flowBoundary(read); + if (flowBoundary(decl) !== boundary) return false; + const checker = this.lowerer.checker; + const expected = nothingFlags(checker.getTypeAtLocation(read)); + const undefinedFlag = ts.TypeFlags.Undefined; + // A write after which the binding holds the nothing the checker + // expects at the read. A JavaScript binding whose inferred type is + // `undefined` keeps that type after `y = null` or `y = "text"`, which + // the checker does not report there: those do not count. + // A `var` is hoisted: it holds `undefined` from the function's entry, + // and its declaration without a value is no write (writesOf). A `let` + // without a value is a fresh `undefined` each time it runs. + const list = decl.parent; + const hoisted = + list !== undefined && + ts.isVariableDeclarationList(list) && + (list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const | ts.NodeFlags.Using)) === 0; + const holdsUndefined = (undefinedFlag & ~expected) === 0; + const settles = (write: BindingWrite): boolean => { + if (write.kind === "declared") return holdsUndefined; + if (write.kind === "unknown" || write.stale || write.value === null) return false; + // Only a value that is nothing by its own spelling: any other + // expression the checker types as nothing (a call, a conditional, a + // read of another binding) may carry a value its type was inferred + // without, the copy's own included (`y = keep ? y : undefined`, + // `y = same(y)`). + if (!spellsNothing(write.value)) return false; + const spelled = withoutParenthesesTypeWrappers(write.value); + if (ts.isIdentifier(spelled)) { + // `undefined` the global, not a binding of the program's. + const declaration = checker.getSymbolAtLocation(spelled)?.valueDeclaration; + if (declaration !== undefined && !ts.getSourceFileOfNode(declaration).isDeclarationFile) + return false; + } + const stored = checker.getTypeAtLocation(write.value); + return isNothing(stored) && (nothingFlags(stored) & ~expected) === 0; + }; + const inside = (node: ts.Node, write: BindingWrite): boolean => contains(node, write.at); + const writesIn = (node: ts.Node): boolean => writes.some((write) => inside(node, write)); + const raises = (node: ts.Node): boolean => + writes.some((write) => inside(node, write) && !settles(write)); + /** The write a statement is, when it is exactly the declaration or an + * assignment of the binding and nothing else writes it. */ + const writeOf = (statement: ts.Node): BindingWrite | null => { + let own: BindingWrite | null = null; + for (const write of writes) { + if (!inside(statement, write)) continue; + if (own !== null) return null; + own = write; + } + if (own === null || own.kind === "unknown") return null; + if (ts.isVariableStatement(statement)) + return own.at.parent?.parent === statement ? own : null; + if (ts.isExpressionStatement(statement)) + return withoutParentheses(statement.expression) === own.at ? own : null; + return null; + }; + /** The fact after `statement` completes, from the fact before it. */ + const after = (statement: ts.Node, may: boolean): boolean => { + if (!writesIn(statement)) return may; + if (ts.isBlock(statement)) { + for (const inner of statement.statements.nodes) may = after(inner, may); + return may; + } + const own = writeOf(statement); + if (own !== null) return !settles(own); + if (ts.isIfStatement(statement)) { + const entered = may || raises(statement.expression); + const then = after(statement.thenStatement, entered); + const otherwise = + statement.elseStatement === undefined ? entered : after(statement.elseStatement, entered); + return then || otherwise; + } + return may || raises(statement); + }; + /** The fact where `read` is evaluated, from the fact on entry to + * `node`, which contains the read. */ + const before = (node: ts.Node, may: boolean): boolean => { + if (ts.isBlock(node) || ts.isSourceFile(node)) { + for (const inner of node.statements.nodes) { + if (contains(inner, read)) return before(inner, may); + may = after(inner, may); + } + return true; + } + if (ts.isIfStatement(node)) { + const entered = may || raises(node.expression); + if (contains(node.thenStatement, read)) return before(node.thenStatement, entered); + if (node.elseStatement !== undefined && contains(node.elseStatement, read)) + return before(node.elseStatement, entered); + return may || raises(node); + } + // Any other construct: whatever it can store reaches the read (a + // loop's later iteration, a `catch` entered from anywhere in its + // `try`, an operand evaluated before or after), and nothing in it + // is believed to settle the binding except inside a block of it + // that holds the read. + const entered = may || raises(node); + let inner: ts.Node | undefined; + ts.forEachChild(node, (child) => { + if (inner === undefined && contains(child, read)) inner = child; + return undefined; + }); + if (inner === undefined || !(ts.isBlock(inner) || ts.isStatement(inner))) return entered; + return before(inner, entered); + }; + const body = ts.isSourceFile(boundary) + ? boundary + : ts.isFunctionLike(boundary) + ? (boundary as ts.FunctionLikeDeclaration).body + : undefined; + if (body === undefined || !contains(body, read)) return false; + // On entry a hoisted binding holds `undefined`; a block-scoped one is + // not readable before its declaration and is taken to hold anything. + return !before(body, hoisted ? !holdsUndefined : true); + } + /** A read in a call or construction argument (directly or inside a literal * argument) reaches the callee's own lowering with the declared type. User * callees coerce any value of their parameter type, and so do the @@ -919,6 +1251,49 @@ const INERT_CONSTRUCTORS = new Set([ "BigUint64Array", ]); +/** One write of a variable: `at` is the declaration, the `=` assignment, or + * for any other write the identifier written. */ +interface BindingWrite { + at: ts.Node; + value: ts.Expression | null; + kind: "declared" | "assigned" | "unknown"; + stale: boolean; +} + +/** `undefined`, `null` or `void e`, through parentheses and assertions. */ +function spellsNothing(value: ts.Expression): boolean { + const e = withoutParenthesesTypeWrappers(value); + return ( + e.kind === ts.SyntaxKind.NullKeyword || + ts.isVoidExpression(e) || + (ts.isIdentifier(e) && e.text === "undefined") + ); +} + +/** Whether `node` is `inner` or contains it. */ +function contains(node: ts.Node, inner: ts.Node): boolean { + for (let n: ts.Node | undefined = inner; n !== undefined; n = n.parent) { + if (n === node) return true; + } + return false; +} + +/** Which of `undefined`, `null` and `void` a type includes. */ +function nothingFlags(type: ts.Type): number { + const nothing = ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void; + if (!type.isUnionType()) return type.flags & nothing; + let flags = 0; + for (const part of ts.constituentTypes(type)) flags |= part.flags & nothing; + return flags; +} + +/** `undefined`, `null`, `void`, or a union of only those. */ +function isNothing(type: ts.Type): boolean { + const nothing = ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void; + if (type.flags & nothing) return true; + return type.isUnionType() && ts.constituentTypes(type).every((part) => part.flags & nothing); +} + /** The receiver whose type widenedType reads for `node`: a property read in * a slot that accepts its declared type (an argument, a return, a typed * binding or literal). Whole-program analyses batch these receivers. */ diff --git a/packages/compiler/src/frontend/lowering/optional-arguments.ts b/packages/compiler/src/frontend/lowering/optional-arguments.ts index 1d5e9ca41de..c5e7c21a4cf 100644 --- a/packages/compiler/src/frontend/lowering/optional-arguments.ts +++ b/packages/compiler/src/frontend/lowering/optional-arguments.ts @@ -15,6 +15,10 @@ export function lowerStaticallyUndefinedArgument( while (ts.isParenthesizedExpression(expr)) expr = expr.expression; if ((lowerer.typeOf(expr).flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) === 0) return null; + // The checker typed the argument `undefined` from a narrowing that a call + // may have undone (`s.slice(done ? start : undefined)` with `start` + // assigned by a callback): the argument carries the stored value. + if (lowerer.narrowing.restoredNothingRead(expr) !== null) return null; // A caller may already have evaluated the entire argument (including a // void operand) before receiver coercion. Do not evaluate it again. const stabilized = lowerer.chainRecvByNode.get(node); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9641-callee-assigned-reads.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9641-callee-assigned-reads.mjs.json new file mode 100644 index 00000000000..898b74a1a82 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9641-callee-assigned-reads.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/9641-callee-assigned-reads.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9642-callee-assigned-reads-commonjs.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9642-callee-assigned-reads-commonjs.cjs.json new file mode 100644 index 00000000000..8b77fac09a5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9642-callee-assigned-reads-commonjs.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/9642-callee-assigned-reads-commonjs.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9643-callee-assigned-reads-typed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9643-callee-assigned-reads-typed.ts.json new file mode 100644 index 00000000000..86be1f0bae5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9643-callee-assigned-reads-typed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/9643-callee-assigned-reads-typed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9645-mustache-templates/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9645-mustache-templates/main.mjs.json new file mode 100644 index 00000000000..a19fb9fe097 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9645-mustache-templates/main.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/9645-mustache-templates/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9646-copy-reset-paths.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9646-copy-reset-paths.mjs.json new file mode 100644 index 00000000000..a1e246657b1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9646-copy-reset-paths.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/9646-copy-reset-paths.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stale-undefined-inferred-return.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stale-undefined-inferred-return.ts.json new file mode 100644 index 00000000000..b7b4539a2e0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stale-undefined-inferred-return.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/stale-undefined-inferred-return.ts" + ], + "diags": [] +} diff --git a/tests/corpus/9641-callee-assigned-reads.mjs b/tests/corpus/9641-callee-assigned-reads.mjs new file mode 100644 index 00000000000..bc777359a2f --- /dev/null +++ b/tests/corpus/9641-callee-assigned-reads.mjs @@ -0,0 +1,276 @@ +// A binding that a function assigns, read after the call. TypeScript types +// such a read by the last assignment it sees in the read's own function +// (`undefined` for `var text;`), and a call does not change that. The read +// is the value the callee stored, wherever it stands: an argument of a +// constructor function's method, of a class method, of a function, of +// `new`, of a library function with an optional parameter, an operand, a +// test, a returned value. + +function show(value) { + return typeof value + " " + String(value); +} +function withDefault(value = "default") { + return typeof value + " " + String(value); +} +function Holder(value) { + this.value = value; +} +Holder.prototype.show = function (value) { + return typeof value + " " + String(value); +}; +class Klass { + constructor(value) { + this.value = value; + } + show(value) { + return typeof value + " " + String(value); + } +} +const holder = new Holder(); +const klass = new Klass(); + +// Module-level bindings, assigned by a hoisted function declaration. +var text; +let count; +var separator = undefined; +var radix = null; +function assign() { + text = "t"; + count = 2; + separator = "-"; + radix = 2; +} +assign(); +console.log("constructor function method:", holder.show(text), "|", new Holder().show(count)); +console.log("class method:", klass.show(text), "|", new Klass().show(count)); +console.log("function:", show(text), "|", withDefault(text), "|", show(separator)); +console.log("new:", show(new Holder(text).value), "|", show(new Klass(count).value)); +console.log("console:", text, count, separator, radix); +console.log("operands:", `${text}/${count}`, "" + text + count, typeof text, typeof count); +console.log("tests:", text === undefined, count == null, text ?? "absent", text || "empty", text ? "yes" : "no"); +console.log("Number, String:", Number(count), String(text), String(count)); +console.log("strings:", "abcd".slice(count), "abab".indexOf("b", count), "x".padStart(3, text)); +console.log("split, join:", JSON.stringify("a-b".split(separator)), [1, 2].join(separator)); +console.log("radix:", parseInt("11", radix), (5).toString(radix), JSON.stringify({ a: 1 }, null, count)); +console.log("arithmetic:", count + 1, count * 2, count > 1, [5, 6, 7][count]); +console.log("positions:", "abcdef".slice(1, count), "abcd".substring(count), "abcd".charAt(count), "abcd".at(count)); +console.log("searches:", "abab".startsWith("ab", count), "abab".lastIndexOf("ab", count), [1, 2, 1].indexOf(1, count)); +console.log("strings:", "ab".repeat(count), "x".concat(text), String.fromCharCode(count + 63), String(count).padEnd(3, text)); + +// The shape of an old-style parser: `var`s of a function that a nested +// function assigns, handed to methods of a constructor function's instance. +function Scanner(string) { + this.tail = string; + this.pos = 0; +} +Scanner.prototype.eos = function eos() { + return this.tail === ""; +}; +Scanner.prototype.scan = function scan(re) { + var match = this.tail.match(re); + if (!match || match.index !== 0) return ""; + var string = match[0]; + this.tail = this.tail.substring(string.length); + this.pos += string.length; + return string; +}; +Scanner.prototype.scanUntil = function scanUntil(re) { + var index = this.tail.search(re), + match; + switch (index) { + case -1: + match = this.tail; + this.tail = ""; + break; + case 0: + match = ""; + break; + default: + match = this.tail.substring(0, index); + this.tail = this.tail.substring(index); + } + this.pos += match.length; + return match; +}; +function parse(template, tags) { + var openingTagRe, closingTagRe; + function compileTags(pair) { + openingTagRe = new RegExp(pair[0] + "\\s*"); + closingTagRe = new RegExp("\\s*" + pair[1]); + } + compileTags(tags); + var scanner = new Scanner(template); + var tokens = []; + while (!scanner.eos()) { + var value = scanner.scanUntil(openingTagRe); + if (value) tokens.push(["text", value, scanner.pos]); + if (!scanner.scan(openingTagRe)) break; + tokens.push(["name", scanner.scanUntil(closingTagRe), scanner.pos]); + if (!scanner.scan(closingTagRe)) break; + } + return tokens; +} +console.log("parse:", JSON.stringify(parse("hi {{who}}, {{ you }}!", ["\\{\\{", "\\}\\}"]))); +console.log("parse:", JSON.stringify(parse("plain", ["<%", "%>"]))); + +// Who assigns: a function expression, an arrow, a method, a constructor +// function's method, a callback a library function calls, a closure handed +// to another function. Each result goes through the method of a +// constructor function's instance and through `slice`. +function callIt(f) { + f(); +} +function assigners() { + var a, b, c, d, e, f; + var na, nb, nc, nd, ne, nf; + var byExpression = function () { + a = "expression"; + na = 1; + }; + const byArrow = () => { + b = "arrow"; + nb = 2; + }; + var byMethod = { + set() { + c = "method"; + nc = 3; + }, + }; + function Setter() {} + Setter.prototype.set = function () { + d = "prototype method"; + nd = 4; + }; + byExpression(); + byArrow(); + byMethod.set(); + new Setter().set(); + [0].forEach(function () { + e = "callback"; + ne = 5; + }); + callIt(function () { + f = "passed closure"; + nf = 6; + }); + return [ + holder.show(a) + " " + "abcdefgh".slice(na), + holder.show(b) + " " + "abcdefgh".slice(nb), + holder.show(c) + " " + "abcdefgh".slice(nc), + holder.show(d) + " " + "abcdefgh".slice(nd), + holder.show(e) + " " + "abcdefgh".slice(ne), + holder.show(f) + " " + "abcdefgh".slice(nf), + ].join("; "); +} +console.log("assigners:", assigners()); + +// A copy of such a binding holds the value too: TypeScript infers the +// copy's type from the same narrowing. +function copies() { + var first, size; + function fill() { + first = "first"; + size = 2; + } + fill(); + var copy = size; + var again = copy; + var either = again ? copy : undefined; + var later; + later = first; + return ["abcd".slice(copy), "abcd".slice(again), "abcd".slice(either), holder.show(later), show(again)].join(" | "); +} +console.log("copies:", copies()); + +// An assignment of the copy's own ends what its initializer left in it: +// `undefined`, `null`, a value of another type; a reset on one path only +// does not, and neither does a reset followed by the read again. +function resets() { + var value, size; + function fill() { + value = "filled"; + size = 2; + } + fill(); + var a = value; + a = undefined; + var b = value; + b = null; + var c = size; + c = "text"; + var d = size; + if (size > 5) d = undefined; + var e = value; + e = undefined; + e = value; + return [holder.show(a), "abcd".slice(a), holder.show(b), holder.show(c), "abcd".slice(d), holder.show(d), holder.show(e)].join(" | "); +} +function resetReturned() { + var value; + function fill() { + value = "filled"; + } + fill(); + var copy = value; + copy = undefined; + return copy; +} +function nullReturned() { + var value; + function fill() { + value = "filled"; + } + fill(); + var copy = value; + copy = null; + return copy; +} +console.log("resets:", resets()); +console.log("reset and returned:", show(resetReturned()), show(nullReturned())); + +// A function called where it stands is part of the enclosing flow: its +// assignment types the read, and stays the value. +function inPlace() { + var n, s; + (function () { + n = 2; + s = "in place"; + })(); + return "abcd".slice(n) + " " + holder.show(s) + " " + "abab".indexOf("b", n); +} +console.log("called in place:", inPlace()); + +// A parameter set to undefined and then assigned by a closure; the value +// returned, stored in a property, thrown, kept in a collection. +function later(value, into) { + value = undefined; + var fill = function () { + value = "filled"; + }; + fill(); + into.property = value; + const map = new Map(); + map.set("key", value); + let caught; + try { + throw value; + } catch (thrown) { + caught = thrown; + } + console.log("stored:", show(into.property), "|", show(map.get("key")), "|", show(caught)); + return value; +} +console.log("returned:", show(later("first", { property: "initial" }))); + +// A binding no call can have changed keeps its narrowing. +function untouched() { + var start; + var size; + function never() { + start = 1; + size = 2; + } + return "abcd".slice(start) + " " + holder.show(size) + " " + typeof never; +} +console.log("before any call:", untouched()); diff --git a/tests/corpus/9642-callee-assigned-reads-commonjs.cjs b/tests/corpus/9642-callee-assigned-reads-commonjs.cjs new file mode 100644 index 00000000000..30d506a45df --- /dev/null +++ b/tests/corpus/9642-callee-assigned-reads-commonjs.cjs @@ -0,0 +1,82 @@ +// CommonJS: module-level state that an initializer function assigns, and +// locals a nested function assigns, read after the call. The read is the +// stored value in every position, including arguments of a constructor +// function's methods and of library functions with optional parameters. +"use strict"; + +var path = require("node:path"); + +function Table(name) { + this.name = name; + this.rows = []; +} +Table.prototype.add = function (key, value) { + this.rows.push(String(key) + "=" + typeof value + ":" + String(value)); + return this; +}; +Table.prototype.describe = function (separator) { + return this.name + "[" + this.rows.join(separator) + "]"; +}; + +var settings; +var limit; +var prefix = undefined; +var fallback = null; +function configure() { + settings = { width: 4, pad: "." }; + limit = 2; + prefix = "p"; + fallback = "f"; +} +configure(); + +function widthOf(value) { + return value.width; +} +function padOf(value) { + return value.pad; +} + +var table = new Table("module"); +table.add("settings", widthOf(settings)).add("limit", limit).add("prefix", prefix).add("fallback", fallback); +console.log(table.describe()); +console.log(table.describe(prefix)); +console.log("abcdef".slice(limit), "abab".lastIndexOf("b", limit), "7".padStart(widthOf(settings), padOf(settings))); +console.log(Number(limit) + 1, String(prefix) + String(fallback), parseInt("101", limit)); +console.log(path.join("a", prefix, fallback), path.basename("dir/file.txt", prefix)); + +function collect(words) { + var longest, total, first; + function visit(word, index) { + if (index === 0) first = word; + if (longest === undefined || word.length > longest.length) longest = word; + total = (total || 0) + word.length; + } + words.forEach(visit); + var local = new Table("collect"); + local.add("longest", longest).add("total", total).add("first", first); + return local.describe("; ") + " " + "0123456789".slice(total) + " " + String(longest) + (longest ? "/" : "?") + String(first); +} +console.log(collect(["bb", "a", "ccc"])); +console.log(collect([])); + +function lazy() { + var cache; + var hits = 0; + function load() { + cache = ["x", "y", "z"]; + } + function get(index) { + if (cache === undefined) load(); + hits = hits + 1; + return cache[index]; + } + var a = get(1); + var b = get(2); + return new Table("lazy").add("a", a).add("b", b).add("hits", hits).describe(a); +} +console.log(lazy()); + +exports.Table = Table; +exports.collect = collect; +console.log(typeof exports.collect, typeof exports.Table); diff --git a/tests/corpus/9643-callee-assigned-reads-typed.ts b/tests/corpus/9643-callee-assigned-reads-typed.ts new file mode 100644 index 00000000000..15388586a5a --- /dev/null +++ b/tests/corpus/9643-callee-assigned-reads-typed.ts @@ -0,0 +1,193 @@ +// A typed binding or property is assigned `undefined` (or `null`), a +// function called afterwards assigns it a value, and it is read. TypeScript +// keeps the type of the last assignment in the read's own function across +// the call, so it types the read `undefined`. The read is the stored value: +// a library function with an optional parameter gets it, not "absent". + +function show(value: unknown): string { + return typeof value + " " + String(value); +} + +// A module-level binding and a closure. +let start: number | undefined = undefined; +let filler: string | undefined = undefined; +let label: string | null = null; +const fill = (): void => { + start = 2; + filler = "-"; + label = "named"; +}; +fill(); +console.log("strings:", "abcdef".slice(start), "abab".indexOf("b", start), "x".padStart(3, filler)); +console.log("arrays:", [1, 2, 3, 4].slice(start).join(), [1, 2].join(filler), [5, 6, 7].includes(7, start)); +console.log("numbers:", Number(start), parseInt("11", start), (5).toString(start), (1.2345).toFixed(start)); +console.log("json:", JSON.stringify({ a: 1 }, null, start), JSON.stringify({ a: 1 }, null, filler)); +console.log("values:", show(start), show(filler), show(label), String(start), `${filler}${start}`); +console.log("tests:", start === undefined, filler ?? "absent", label || "empty", start ? "set" : "unset", typeof label); +console.log("parenthesized:", "abcdef".slice((start)), [1, 2].join((filler))); +console.log("ends:", "abcdef".slice(1, start), [1, 2, 3, 4].slice(1, start).join(), [1, 2, 1].indexOf(1, start)); +console.log("more:", "abab".startsWith("ab", start), (1.5).toExponential(start), [1, 2, 3, 4].fill(0, start).join()); +const copy = start; +const fillerCopy = filler; +console.log("copies:", "abcdef".slice(copy), [1, 2].join(fillerCopy), show(copy), show(fillerCopy)); + +// Locals of a function, assigned by the callback of a library function. +function widest(words: string[]): string { + let width: number | undefined = undefined; + let pad: string | undefined = undefined; + words.forEach((word) => { + if (width === undefined || word.length > width) width = word.length; + pad = word[0]; + }); + return words.map((word) => word.padStart(width ?? 0, pad)).join("|") + " " + "0123456789".slice(width); +} +console.log("widest:", widest(["a", "bcd", "ef"]), "/", widest([])); + +// A parameter set to undefined and assigned by a nested function. +function reset(from?: number, mark?: string): string { + from = undefined; + mark = undefined; + function restore(): void { + from = 1; + mark = "*"; + } + restore(); + return "abcd".slice(from) + " " + "x".padEnd(3, mark) + " " + show(from); +} +console.log("parameter:", reset(3, "?")); + +// Class fields cleared in a method and refilled by another method, a +// function and a closure. +class Cursor { + offset: number | undefined = undefined; + mark: string | undefined = undefined; + refill(): void { + this.offset = 3; + this.mark = "#"; + } + viaMethod(): string { + this.offset = undefined; + this.mark = undefined; + this.refill(); + return "abcdef".slice(this.offset) + " " + [1, 2].join(this.mark) + " " + show(this.offset); + } + viaFunction(): string { + this.offset = undefined; + this.mark = undefined; + refillCursor(this); + return "abcdef".slice(this.offset) + " " + [1, 2].join(this.mark) + " " + Number(this.offset); + } + viaClosure(): string { + this.offset = undefined; + const set = (): void => { + this.offset = 1; + }; + set(); + return "abcdef".slice(this.offset) + " " + "abab".indexOf("b", this.offset); + } +} +function refillCursor(cursor: Cursor): void { + cursor.offset = 4; + cursor.mark = "+"; +} +const cursor = new Cursor(); +console.log("fields:", cursor.viaMethod(), "/", cursor.viaFunction(), "/", cursor.viaClosure()); + +// An object's properties. +const options: { indent: number | undefined; separator: string | undefined } = { + indent: undefined, + separator: undefined, +}; +function choose(): void { + options.indent = 1; + options.separator = ":"; +} +function formatted(): string { + options.indent = undefined; + options.separator = undefined; + choose(); + return JSON.stringify([1], null, options.indent) + " " + ["a", "b"].join(options.separator); +} +console.log("properties:", formatted()); + +// A declared return type carries the stored value; so do a typed binding, +// a collection and a thrown value. +function found(items: string[], wanted: string): string | undefined { + let match: string | undefined = undefined; + items.forEach((item) => { + if (item.startsWith(wanted)) match = item; + }); + return match; +} +function kept(): string { + let value: string | undefined = undefined; + const set = (): void => { + value = "kept"; + }; + set(); + const typed: string | undefined = value; + const list: unknown[] = []; + list.push(value); + const map = new Map(); + map.set("key", value); + let caught: unknown = "nothing"; + try { + throw value; + } catch (thrown) { + caught = thrown; + } + return [show(typed), show(list[0]), show(map.get("key")), show(caught)].join(" | "); +} +console.log("returned:", show(found(["apple", "banana"], "b")), "/", show(found(["apple"], "b"))); +console.log("kept:", kept()); + +// An untyped copy that is assigned `undefined` afterwards holds that: the +// assignment ends what the initializer left in it, and a function that +// returns the reset copy returns nothing, as TypeScript infers. +function resetCopy(): string { + let from: number | undefined = undefined; + const set = (): void => { + from = 2; + }; + set(); + let copy = from; + const kept = "abcd".slice(copy); + copy = undefined; + return kept + " " + "abcd".slice(copy) + " " + show(copy); +} +function resetAndReturn() { + let found; + const search = (): void => { + found = "hit"; + }; + search(); + let copy = found; + const kept = show(copy); + copy = undefined; + console.log("before the reset:", kept); + return copy; +} +resetAndReturn(); +console.log("reset copy:", resetCopy()); + +// A function expression called where it stands is part of the flow. +function inPlace(): string { + let size: number | undefined = undefined; + (() => { + size = 2; + })(); + return "abcd".slice(size); +} +console.log("called in place:", inPlace()); + +// Nothing ran since the assignment: the narrowing holds. +function current(): string { + let from: number | undefined = undefined; + const later = (): void => { + from = 3; + }; + const head = "abcd".slice(from); + later(); + return head + " " + "abcd".slice(from); +} +console.log("before the call:", current()); diff --git a/tests/corpus/9645-mustache-templates/main.mjs b/tests/corpus/9645-mustache-templates/main.mjs new file mode 100644 index 00000000000..e1e8c4e3daa --- /dev/null +++ b/tests/corpus/9645-mustache-templates/main.mjs @@ -0,0 +1,56 @@ +// The published mustache@4.2.0, compiled natively, rendering templates. +// node_modules/mustache holds the package's two entry files, its license and +// its package.json, unmodified; node_modules/@types/mustache@4.2.6 is the +// declaration surface a native build of the package needs. The package is +// pre-class JavaScript: its parser keeps the tag expressions in `var`s that +// a nested function assigns and hands them to methods of its Scanner +// constructor function, and its Context and Writer are constructor +// functions with prototype methods. +import Mustache from "mustache"; + +const view = { + title: "Joe", + calc: function () { + return 2 + 4; + }, + items: [ + { name: "a", price: 1.5 }, + { name: "b", price: 2 }, + ], + empty: [], + html: "&", + nested: { deep: { value: "x" } }, + on: true, + off: false, +}; + +console.log("[" + Mustache.render("plain", {}) + "]"); +console.log(Mustache.render("hi {{who}}", { who: "ada" })); +console.log(Mustache.render("{{title}} spends {{calc}}", view)); +console.log(Mustache.render("{{#items}}{{name}}={{price}};{{/items}}{{^empty}}none{{/empty}}", view)); +console.log(Mustache.render("{{html}} {{{html}}} {{&html}}", view)); +console.log(Mustache.render("{{nested.deep.value}}{{! a comment }}{{missing}}|", view)); +console.log(Mustache.render("{{#on}}on{{/on}}{{#off}}off{{/off}}{{^off}}not off{{/off}}", view)); +console.log(Mustache.render("{{#items}}{{> row}}{{/items}}", view, { row: "[{{name}}]" })); +console.log(Mustache.render("<% title %> {{title}}", view, {}, ["<%", "%>"])); +console.log(Mustache.render("{{=<% %>=}}<% title %> {{title}}", view)); +console.log(Mustache.render("a\n {{#items}}\n - {{name}}\n {{/items}}\nz", view)); +const tokens = Mustache.parse("hi {{who}}{{#a}}x{{/a}}"); +console.log(tokens.length, tokens[0][0], tokens[0][1], tokens[1][0], tokens[1][1], tokens[1][3], tokens[2][0], tokens[2][5]); +console.log(Mustache.escape("&\"`=/")); +console.log(Mustache.name, Mustache.version, Mustache.tags.join(" ")); +try { + Mustache.render("{{#open}}never closed", {}); +} catch (e) { + console.log(e.message); +} +try { + Mustache.render("{{/stray}}", {}); +} catch (e) { + console.log(e.message); +} +try { + Mustache.render(5, {}); +} catch (e) { + console.log(e.name + ": " + e.message); +} diff --git a/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/LICENSE b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.mts b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.mts new file mode 100644 index 00000000000..4742609a9ee --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.mts @@ -0,0 +1,22 @@ +import * as mustache from "./index.js"; + +export { + COMMENT, + EQUAL, + ESCAPED_VALUE, + EscapeFunction, + INVERTED, + OpeningAndClosingTags, + PARTIAL, + PartialLookupFn, + PartialsOrLookupFn, + RAW_VALUE, + RenderOptions, + SECTION, + TemplateCache, + TemplateSpans, + TemplateSpanType, + UNESCAPED_VALUE, +} from "./index.js"; + +export default mustache; diff --git a/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.ts b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.ts new file mode 100644 index 00000000000..58086dc17ff --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.ts @@ -0,0 +1,421 @@ +/** + * The name of the module. + */ +export const name: string; + +/** + * The version of the module. + */ +export const version: string; + +/** + * The default opening and closing tags used while parsing the templates. + * + * Different default tags can be overridden by setting this field. They will have effect on all subsequent + * calls to {@link render `.render()`} or {@link parse `.parse()`}, unless custom tags are given as arguments to those functions. + * + * Default value is `[ "{{", "}}" ]`. + */ +export let tags: OpeningAndClosingTags; + +/** + * Customise the template caching behaviour by either: + * + * disable it completely by setting it to `undefined` + * + * -- or -- + * + * provide a custom cache strategy that satisfies the {@link TemplateCache `TemplateCache`} interface + */ +export let templateCache: TemplateCache | undefined; + +/** + * A simple string scanner that is used by the template parser to find tokens in template strings. + */ +export class Scanner { + string: string; + tail: string; + pos: number; + + /** + * Initializes a new instance of the {@link Scanner `Scanner`} class. + */ + constructor(string: string); + + /** + * Returns `true` if the tail is empty (end of string). + */ + eos(): boolean; + + /** + * Tries to match the given regular expression at the current position. + * + * @param re + * The regex-pattern to match. + * + * @returns + * The matched text if it can match, the empty string otherwise. + */ + scan(re: RegExp): string; + + /** + * Skips all text until the given regular expression can be matched. + * + * @param re + * The regex-pattern to match. + * + * @returns + * Returns the skipped string, which is the entire tail if no match can be made. + */ + scanUntil(re: RegExp): string; +} + +/** + * Represents a rendering context by wrapping a view object and maintaining a reference to the parent context. + */ +export class Context { + view: any; + parent: Context | undefined; + + /** + * Initializes a new instance of the {@link Context `Context`} class. + */ + constructor(view: any, parentContext?: Context); + + /** + * Creates a new context using the given view with this context as the parent. + * + * @param view + * The view to create the new context with. + */ + push(view: any): Context; + + /** + * Returns the value of the given name in this context, traversing up the context hierarchy if the value is absent in this context's view. + * + * @param name + * The name to look up. + */ + lookup(name: string): any; +} + +/** + * A Writer knows how to take a stream of tokens and render them to a {@link String `string`}, given a context. + * + * It also maintains a cache of templates to avoid the need to parse the same template twice. + */ +export class Writer { + /** + * Initializes a new instance of the {@link Writer `Writer`} class. + */ + constructor(); + + /** + * Clears all cached templates in this writer. + */ + clearCache(): void; + + /** + * Parses and caches the given {@link template `template`} and returns the array of tokens that is generated from the parse. + * + * @param template + * The template to parse. + * + * @param tags + * The tags to use. + */ + parse(template: string, tags?: OpeningAndClosingTags): any; + + /** + * High-level method that is used to render the given {@link template `template`} with the given {@link view `view`}. + * + * @param template + * The template to render. + * + * @param view + * The view to render the template with. + * + * @param partials + * Either an object that contains the names and templates of partials that are used in a template + * + * -- or -- + * + * A function that is used to load partial template on the fly that takes a single argument: the name of the partial. + * + * @param config + * The options for the rendering process. + */ + render( + template: string, + view: any | Context, + partials?: PartialsOrLookupFn, + config?: OpeningAndClosingTags | RenderOptions, + ): string; + + /** + * Low-level method that renders the given array of {@link tokens `tokens`} using the given {@link context `context`} and {@link partials `partials`}. + * + * @param tokens + * The tokens to render. + * + * @param context + * The context to use for rendering the tokens. + * + * @param partials + * The partials to use for rendering the tokens. + * + * @param originalTemplate + * An object used to extract the portion of the original template that was contained in a higher-order section. + * + * If the template doesn't use higher-order sections, this argument may be omitted. + * + * @param config + * The options for the rendering process. + */ + renderTokens( + tokens: string[][], + context: Context, + partials?: PartialsOrLookupFn, + originalTemplate?: string, + config?: RenderOptions, + ): string; + + /** + * Renders a section block. + * + * @param token + * The token to render. + * + * @param context + * The context to use for rendering the token. + * + * @param partials + * The partials to use for rendering the token. + * + * @param originalTemplate + * An object used to extract the portion of the original template that was contained in a higher-order section. + * + * @param config + * The options for the rendering process. + */ + renderSection( + token: string[], + context: Context, + partials?: PartialsOrLookupFn, + originalTemplate?: string, + config?: RenderOptions, + ): string; + + /** + * Renders an inverted section block. + * + * @param token + * The token to render. + * + * @param context + * The context to use for rendering the token. + * + * @param partials + * The partials to use for rendering the token. + * + * @param originalTemplate + * An object used to extract the portion of the original template that was contained in a higher-order section. + * + * @param config + * The options for the rendering process. + */ + renderInverted( + token: string[], + context: Context, + partials?: PartialsOrLookupFn, + originalTemplate?: string, + config?: RenderOptions, + ): string; + + /** + * Adds indentation to each line of the given partial. + * + * @param partial + * The partial to indent. + * + * @param indentation + * String containing a combination of spaces and tabs to use as indentation. + * + * @param lineHasNonSpace + * Whether to indent lines that are empty. + */ + indentPartial(partial: string, indentation: string, lineHasNonSpace: boolean): string; + + /** + * Renders a partial. + * + * @param token + * The token to render. + * + * @param context + * The context to use for rendering the token. + * + * @param partials + * The partials to use for rendering the token. + * + * @param config + * The options for the rendering process. + */ + renderPartial( + token: string[], + context: Context, + partials?: PartialsOrLookupFn, + config?: OpeningAndClosingTags | RenderOptions, + ): string; + + /** + * Renders an unescaped value. + * + * @param token + * The token to render. + * + * @param context + * The context to use for rendering the token. + */ + unescapedValue(token: string[], context: Context): string; + + /** + * Renders an escaped value. + * + * @param token + * The token to render. + * + * @param context + * The context to use for rendering the token. + * + * @param config + * The options for the rendering process. + */ + escapedValue( + token: string[], + context: Context, + config?: RenderOptions, + ): string; + + /** + * Renders a raw token. + * + * @param token + * The token to render. + */ + rawValue(token: string[]): string; +} + +/** + * HTML escaping by default, can be overridden by setting Mustache.escape explicitly or providing the `options` + * argument with an `escape` function when invoking {@link render `Mustache.render()`}. + * + * Escaping can be avoided when needed by using `{{{ value }}}` or `{{& value }}` in templates. + * + * @param value + * The value to escape into a string. + */ +export let escape: EscapeFunction; + +/** + * Clears all cached templates in this writer. + */ +export function clearCache(): void; + +/** + * Parses and caches the given template in the default writer and returns the array of tokens it contains. + * + * Doing this ahead of time avoids the need to parse templates on the fly as they are rendered. + * + * @param template + * The template to parse. + * + * @param tags + * The tags to use. + */ +export function parse(template: string, tags?: OpeningAndClosingTags): TemplateSpans; + +/** + * Renders the {@link template `template`} with the given {@link view `view`} and {@link partials `partials`} using the default writer. + * + * @param template + * The template to render. + * + * @param view + * The view to render the template with. + * + * @param partials + * Either an object that contains the names and templates of partials that are used in a template + * + * -- or -- + * + * A function that is used to load partial template on the fly that takes a single argument: the name of the partial. + * + * @param tagsOrOptions + * The delimiter tags to use or options overriding global defaults. + */ +export function render( + template: string, + view: any | Context, + partials?: PartialsOrLookupFn, + tagsOrOptions?: OpeningAndClosingTags | RenderOptions, +): string; + +export type RAW_VALUE = "text"; +export type ESCAPED_VALUE = "name"; +export type UNESCAPED_VALUE = "&"; +export type SECTION = "#"; +export type INVERTED = "^"; +export type COMMENT = "!"; +export type PARTIAL = ">"; +export type EQUAL = "="; + +export type TemplateSpanType = + | RAW_VALUE + | ESCAPED_VALUE + | SECTION + | UNESCAPED_VALUE + | INVERTED + | COMMENT + | PARTIAL + | EQUAL; + +export type TemplateSpans = Array< + | [TemplateSpanType, string, number, number] + | [TemplateSpanType, string, number, number, TemplateSpans, number] + | [TemplateSpanType, string, number, number, string, number, boolean] +>; + +/** + * Function responsible for escaping values from the view into the rendered output when templates + * has `{{ value }}` in them. + */ +export type EscapeFunction = (value: any) => string; + +/** + * An array of two strings, representing the opening and closing tags respectively, to be used in the templates being rendered. + */ +export type OpeningAndClosingTags = [string, string]; + +/** + * Whenever partials are provided, it can either be an object that contains the names and templates of partials that are used in templates + * + * -- or -- + * + * A function that is used to load partial template on the fly that takes a single argument: the name of the partial. + */ +export type PartialsOrLookupFn = Record | PartialLookupFn; +export type PartialLookupFn = (partialName: string) => string | undefined; + +export interface RenderOptions { + escape?: EscapeFunction | undefined; + tags?: OpeningAndClosingTags | undefined; +} + +export interface TemplateCache { + set(cacheKey: string, value: string): void; + get(cacheKey: string): string | undefined; + clear(): void; +} + +export as namespace Mustache; diff --git a/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/package.json b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/package.json new file mode 100644 index 00000000000..10b580700e1 --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/package.json @@ -0,0 +1,45 @@ +{ + "name": "@types/mustache", + "version": "4.2.6", + "description": "TypeScript definitions for mustache", + "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/mustache", + "license": "MIT", + "contributors": [ + { + "name": "Mark Ashley Bell", + "githubUsername": "markashleybell", + "url": "https://github.com/markashleybell" + }, + { + "name": "Phillip Johnsen", + "githubUsername": "phillipj", + "url": "https://github.com/phillipj" + }, + { + "name": "Steve Dignam", + "githubUsername": "sbdchd", + "url": "https://github.com/sbdchd" + } + ], + "main": "", + "types": "index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": { + "import": "./index.d.mts", + "default": "./index.d.ts" + } + } + }, + "repository": { + "type": "git", + "url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git", + "directory": "types/mustache" + }, + "scripts": {}, + "dependencies": {}, + "peerDependencies": {}, + "typesPublisherContentHash": "1bcf802ee7b66f5a4d9ea5510555598f8661648e7874d53fcf00cf14c65a0ebf", + "typeScriptVersion": "5.1" +} \ No newline at end of file diff --git a/tests/corpus/9645-mustache-templates/node_modules/mustache/LICENSE b/tests/corpus/9645-mustache-templates/node_modules/mustache/LICENSE new file mode 100644 index 00000000000..4df7d1aae6f --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/mustache/LICENSE @@ -0,0 +1,11 @@ +The MIT License + +Copyright (c) 2009 Chris Wanstrath (Ruby) +Copyright (c) 2010-2014 Jan Lehnardt (JavaScript) +Copyright (c) 2010-2015 The mustache.js community + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.js b/tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.js new file mode 100644 index 00000000000..62fac4d6326 --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.js @@ -0,0 +1,772 @@ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global = global || self, global.Mustache = factory()); +}(this, (function () { 'use strict'; + + /*! + * mustache.js - Logic-less {{mustache}} templates with JavaScript + * http://github.com/janl/mustache.js + */ + + var objectToString = Object.prototype.toString; + var isArray = Array.isArray || function isArrayPolyfill (object) { + return objectToString.call(object) === '[object Array]'; + }; + + function isFunction (object) { + return typeof object === 'function'; + } + + /** + * More correct typeof string handling array + * which normally returns typeof 'object' + */ + function typeStr (obj) { + return isArray(obj) ? 'array' : typeof obj; + } + + function escapeRegExp (string) { + return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&'); + } + + /** + * Null safe way of checking whether or not an object, + * including its prototype, has a given property + */ + function hasProperty (obj, propName) { + return obj != null && typeof obj === 'object' && (propName in obj); + } + + /** + * Safe way of detecting whether or not the given thing is a primitive and + * whether it has the given property + */ + function primitiveHasOwnProperty (primitive, propName) { + return ( + primitive != null + && typeof primitive !== 'object' + && primitive.hasOwnProperty + && primitive.hasOwnProperty(propName) + ); + } + + // Workaround for https://issues.apache.org/jira/browse/COUCHDB-577 + // See https://github.com/janl/mustache.js/issues/189 + var regExpTest = RegExp.prototype.test; + function testRegExp (re, string) { + return regExpTest.call(re, string); + } + + var nonSpaceRe = /\S/; + function isWhitespace (string) { + return !testRegExp(nonSpaceRe, string); + } + + var entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' + }; + + function escapeHtml (string) { + return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) { + return entityMap[s]; + }); + } + + var whiteRe = /\s*/; + var spaceRe = /\s+/; + var equalsRe = /\s*=/; + var curlyRe = /\s*\}/; + var tagRe = /#|\^|\/|>|\{|&|=|!/; + + /** + * Breaks up the given `template` string into a tree of tokens. If the `tags` + * argument is given here it must be an array with two string values: the + * opening and closing tags used in the template (e.g. [ "<%", "%>" ]). Of + * course, the default is to use mustaches (i.e. mustache.tags). + * + * A token is an array with at least 4 elements. The first element is the + * mustache symbol that was used inside the tag, e.g. "#" or "&". If the tag + * did not contain a symbol (i.e. {{myValue}}) this element is "name". For + * all text that appears outside a symbol this element is "text". + * + * The second element of a token is its "value". For mustache tags this is + * whatever else was inside the tag besides the opening symbol. For text tokens + * this is the text itself. + * + * The third and fourth elements of the token are the start and end indices, + * respectively, of the token in the original template. + * + * Tokens that are the root node of a subtree contain two more elements: 1) an + * array of tokens in the subtree and 2) the index in the original template at + * which the closing tag for that section begins. + * + * Tokens for partials also contain two more elements: 1) a string value of + * indendation prior to that tag and 2) the index of that tag on that line - + * eg a value of 2 indicates the partial is the third tag on this line. + */ + function parseTemplate (template, tags) { + if (!template) + return []; + var lineHasNonSpace = false; + var sections = []; // Stack to hold section tokens + var tokens = []; // Buffer to hold the tokens + var spaces = []; // Indices of whitespace tokens on the current line + var hasTag = false; // Is there a {{tag}} on the current line? + var nonSpace = false; // Is there a non-space char on the current line? + var indentation = ''; // Tracks indentation for tags that use it + var tagIndex = 0; // Stores a count of number of tags encountered on a line + + // Strips all whitespace tokens array for the current line + // if there was a {{#tag}} on it and otherwise only space. + function stripSpace () { + if (hasTag && !nonSpace) { + while (spaces.length) + delete tokens[spaces.pop()]; + } else { + spaces = []; + } + + hasTag = false; + nonSpace = false; + } + + var openingTagRe, closingTagRe, closingCurlyRe; + function compileTags (tagsToCompile) { + if (typeof tagsToCompile === 'string') + tagsToCompile = tagsToCompile.split(spaceRe, 2); + + if (!isArray(tagsToCompile) || tagsToCompile.length !== 2) + throw new Error('Invalid tags: ' + tagsToCompile); + + openingTagRe = new RegExp(escapeRegExp(tagsToCompile[0]) + '\\s*'); + closingTagRe = new RegExp('\\s*' + escapeRegExp(tagsToCompile[1])); + closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tagsToCompile[1])); + } + + compileTags(tags || mustache.tags); + + var scanner = new Scanner(template); + + var start, type, value, chr, token, openSection; + while (!scanner.eos()) { + start = scanner.pos; + + // Match any text between tags. + value = scanner.scanUntil(openingTagRe); + + if (value) { + for (var i = 0, valueLength = value.length; i < valueLength; ++i) { + chr = value.charAt(i); + + if (isWhitespace(chr)) { + spaces.push(tokens.length); + indentation += chr; + } else { + nonSpace = true; + lineHasNonSpace = true; + indentation += ' '; + } + + tokens.push([ 'text', chr, start, start + 1 ]); + start += 1; + + // Check for whitespace on the current line. + if (chr === '\n') { + stripSpace(); + indentation = ''; + tagIndex = 0; + lineHasNonSpace = false; + } + } + } + + // Match the opening tag. + if (!scanner.scan(openingTagRe)) + break; + + hasTag = true; + + // Get the tag type. + type = scanner.scan(tagRe) || 'name'; + scanner.scan(whiteRe); + + // Get the tag value. + if (type === '=') { + value = scanner.scanUntil(equalsRe); + scanner.scan(equalsRe); + scanner.scanUntil(closingTagRe); + } else if (type === '{') { + value = scanner.scanUntil(closingCurlyRe); + scanner.scan(curlyRe); + scanner.scanUntil(closingTagRe); + type = '&'; + } else { + value = scanner.scanUntil(closingTagRe); + } + + // Match the closing tag. + if (!scanner.scan(closingTagRe)) + throw new Error('Unclosed tag at ' + scanner.pos); + + if (type == '>') { + token = [ type, value, start, scanner.pos, indentation, tagIndex, lineHasNonSpace ]; + } else { + token = [ type, value, start, scanner.pos ]; + } + tagIndex++; + tokens.push(token); + + if (type === '#' || type === '^') { + sections.push(token); + } else if (type === '/') { + // Check section nesting. + openSection = sections.pop(); + + if (!openSection) + throw new Error('Unopened section "' + value + '" at ' + start); + + if (openSection[1] !== value) + throw new Error('Unclosed section "' + openSection[1] + '" at ' + start); + } else if (type === 'name' || type === '{' || type === '&') { + nonSpace = true; + } else if (type === '=') { + // Set the tags for the next time around. + compileTags(value); + } + } + + stripSpace(); + + // Make sure there are no open sections when we're done. + openSection = sections.pop(); + + if (openSection) + throw new Error('Unclosed section "' + openSection[1] + '" at ' + scanner.pos); + + return nestTokens(squashTokens(tokens)); + } + + /** + * Combines the values of consecutive text tokens in the given `tokens` array + * to a single token. + */ + function squashTokens (tokens) { + var squashedTokens = []; + + var token, lastToken; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + token = tokens[i]; + + if (token) { + if (token[0] === 'text' && lastToken && lastToken[0] === 'text') { + lastToken[1] += token[1]; + lastToken[3] = token[3]; + } else { + squashedTokens.push(token); + lastToken = token; + } + } + } + + return squashedTokens; + } + + /** + * Forms the given array of `tokens` into a nested tree structure where + * tokens that represent a section have two additional items: 1) an array of + * all tokens that appear in that section and 2) the index in the original + * template that represents the end of that section. + */ + function nestTokens (tokens) { + var nestedTokens = []; + var collector = nestedTokens; + var sections = []; + + var token, section; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + token = tokens[i]; + + switch (token[0]) { + case '#': + case '^': + collector.push(token); + sections.push(token); + collector = token[4] = []; + break; + case '/': + section = sections.pop(); + section[5] = token[2]; + collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens; + break; + default: + collector.push(token); + } + } + + return nestedTokens; + } + + /** + * A simple string scanner that is used by the template parser to find + * tokens in template strings. + */ + function Scanner (string) { + this.string = string; + this.tail = string; + this.pos = 0; + } + + /** + * Returns `true` if the tail is empty (end of string). + */ + Scanner.prototype.eos = function eos () { + return this.tail === ''; + }; + + /** + * Tries to match the given regular expression at the current position. + * Returns the matched text if it can match, the empty string otherwise. + */ + Scanner.prototype.scan = function scan (re) { + var match = this.tail.match(re); + + if (!match || match.index !== 0) + return ''; + + var string = match[0]; + + this.tail = this.tail.substring(string.length); + this.pos += string.length; + + return string; + }; + + /** + * Skips all text until the given regular expression can be matched. Returns + * the skipped string, which is the entire tail if no match can be made. + */ + Scanner.prototype.scanUntil = function scanUntil (re) { + var index = this.tail.search(re), match; + + switch (index) { + case -1: + match = this.tail; + this.tail = ''; + break; + case 0: + match = ''; + break; + default: + match = this.tail.substring(0, index); + this.tail = this.tail.substring(index); + } + + this.pos += match.length; + + return match; + }; + + /** + * Represents a rendering context by wrapping a view object and + * maintaining a reference to the parent context. + */ + function Context (view, parentContext) { + this.view = view; + this.cache = { '.': this.view }; + this.parent = parentContext; + } + + /** + * Creates a new context using the given view with this context + * as the parent. + */ + Context.prototype.push = function push (view) { + return new Context(view, this); + }; + + /** + * Returns the value of the given name in this context, traversing + * up the context hierarchy if the value is absent in this context's view. + */ + Context.prototype.lookup = function lookup (name) { + var cache = this.cache; + + var value; + if (cache.hasOwnProperty(name)) { + value = cache[name]; + } else { + var context = this, intermediateValue, names, index, lookupHit = false; + + while (context) { + if (name.indexOf('.') > 0) { + intermediateValue = context.view; + names = name.split('.'); + index = 0; + + /** + * Using the dot notion path in `name`, we descend through the + * nested objects. + * + * To be certain that the lookup has been successful, we have to + * check if the last object in the path actually has the property + * we are looking for. We store the result in `lookupHit`. + * + * This is specially necessary for when the value has been set to + * `undefined` and we want to avoid looking up parent contexts. + * + * In the case where dot notation is used, we consider the lookup + * to be successful even if the last "object" in the path is + * not actually an object but a primitive (e.g., a string, or an + * integer), because it is sometimes useful to access a property + * of an autoboxed primitive, such as the length of a string. + **/ + while (intermediateValue != null && index < names.length) { + if (index === names.length - 1) + lookupHit = ( + hasProperty(intermediateValue, names[index]) + || primitiveHasOwnProperty(intermediateValue, names[index]) + ); + + intermediateValue = intermediateValue[names[index++]]; + } + } else { + intermediateValue = context.view[name]; + + /** + * Only checking against `hasProperty`, which always returns `false` if + * `context.view` is not an object. Deliberately omitting the check + * against `primitiveHasOwnProperty` if dot notation is not used. + * + * Consider this example: + * ``` + * Mustache.render("The length of a football field is {{#length}}{{length}}{{/length}}.", {length: "100 yards"}) + * ``` + * + * If we were to check also against `primitiveHasOwnProperty`, as we do + * in the dot notation case, then render call would return: + * + * "The length of a football field is 9." + * + * rather than the expected: + * + * "The length of a football field is 100 yards." + **/ + lookupHit = hasProperty(context.view, name); + } + + if (lookupHit) { + value = intermediateValue; + break; + } + + context = context.parent; + } + + cache[name] = value; + } + + if (isFunction(value)) + value = value.call(this.view); + + return value; + }; + + /** + * A Writer knows how to take a stream of tokens and render them to a + * string, given a context. It also maintains a cache of templates to + * avoid the need to parse the same template twice. + */ + function Writer () { + this.templateCache = { + _cache: {}, + set: function set (key, value) { + this._cache[key] = value; + }, + get: function get (key) { + return this._cache[key]; + }, + clear: function clear () { + this._cache = {}; + } + }; + } + + /** + * Clears all cached templates in this writer. + */ + Writer.prototype.clearCache = function clearCache () { + if (typeof this.templateCache !== 'undefined') { + this.templateCache.clear(); + } + }; + + /** + * Parses and caches the given `template` according to the given `tags` or + * `mustache.tags` if `tags` is omitted, and returns the array of tokens + * that is generated from the parse. + */ + Writer.prototype.parse = function parse (template, tags) { + var cache = this.templateCache; + var cacheKey = template + ':' + (tags || mustache.tags).join(':'); + var isCacheEnabled = typeof cache !== 'undefined'; + var tokens = isCacheEnabled ? cache.get(cacheKey) : undefined; + + if (tokens == undefined) { + tokens = parseTemplate(template, tags); + isCacheEnabled && cache.set(cacheKey, tokens); + } + return tokens; + }; + + /** + * High-level method that is used to render the given `template` with + * the given `view`. + * + * The optional `partials` argument may be an object that contains the + * names and templates of partials that are used in the template. It may + * also be a function that is used to load partial templates on the fly + * that takes a single argument: the name of the partial. + * + * If the optional `config` argument is given here, then it should be an + * object with a `tags` attribute or an `escape` attribute or both. + * If an array is passed, then it will be interpreted the same way as + * a `tags` attribute on a `config` object. + * + * The `tags` attribute of a `config` object must be an array with two + * string values: the opening and closing tags used in the template (e.g. + * [ "<%", "%>" ]). The default is to mustache.tags. + * + * The `escape` attribute of a `config` object must be a function which + * accepts a string as input and outputs a safely escaped string. + * If an `escape` function is not provided, then an HTML-safe string + * escaping function is used as the default. + */ + Writer.prototype.render = function render (template, view, partials, config) { + var tags = this.getConfigTags(config); + var tokens = this.parse(template, tags); + var context = (view instanceof Context) ? view : new Context(view, undefined); + return this.renderTokens(tokens, context, partials, template, config); + }; + + /** + * Low-level method that renders the given array of `tokens` using + * the given `context` and `partials`. + * + * Note: The `originalTemplate` is only ever used to extract the portion + * of the original template that was contained in a higher-order section. + * If the template doesn't use higher-order sections, this argument may + * be omitted. + */ + Writer.prototype.renderTokens = function renderTokens (tokens, context, partials, originalTemplate, config) { + var buffer = ''; + + var token, symbol, value; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + value = undefined; + token = tokens[i]; + symbol = token[0]; + + if (symbol === '#') value = this.renderSection(token, context, partials, originalTemplate, config); + else if (symbol === '^') value = this.renderInverted(token, context, partials, originalTemplate, config); + else if (symbol === '>') value = this.renderPartial(token, context, partials, config); + else if (symbol === '&') value = this.unescapedValue(token, context); + else if (symbol === 'name') value = this.escapedValue(token, context, config); + else if (symbol === 'text') value = this.rawValue(token); + + if (value !== undefined) + buffer += value; + } + + return buffer; + }; + + Writer.prototype.renderSection = function renderSection (token, context, partials, originalTemplate, config) { + var self = this; + var buffer = ''; + var value = context.lookup(token[1]); + + // This function is used to render an arbitrary template + // in the current context by higher-order sections. + function subRender (template) { + return self.render(template, context, partials, config); + } + + if (!value) return; + + if (isArray(value)) { + for (var j = 0, valueLength = value.length; j < valueLength; ++j) { + buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate, config); + } + } else if (typeof value === 'object' || typeof value === 'string' || typeof value === 'number') { + buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate, config); + } else if (isFunction(value)) { + if (typeof originalTemplate !== 'string') + throw new Error('Cannot use higher-order sections without the original template'); + + // Extract the portion of the original template that the section contains. + value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender); + + if (value != null) + buffer += value; + } else { + buffer += this.renderTokens(token[4], context, partials, originalTemplate, config); + } + return buffer; + }; + + Writer.prototype.renderInverted = function renderInverted (token, context, partials, originalTemplate, config) { + var value = context.lookup(token[1]); + + // Use JavaScript's definition of falsy. Include empty arrays. + // See https://github.com/janl/mustache.js/issues/186 + if (!value || (isArray(value) && value.length === 0)) + return this.renderTokens(token[4], context, partials, originalTemplate, config); + }; + + Writer.prototype.indentPartial = function indentPartial (partial, indentation, lineHasNonSpace) { + var filteredIndentation = indentation.replace(/[^ \t]/g, ''); + var partialByNl = partial.split('\n'); + for (var i = 0; i < partialByNl.length; i++) { + if (partialByNl[i].length && (i > 0 || !lineHasNonSpace)) { + partialByNl[i] = filteredIndentation + partialByNl[i]; + } + } + return partialByNl.join('\n'); + }; + + Writer.prototype.renderPartial = function renderPartial (token, context, partials, config) { + if (!partials) return; + var tags = this.getConfigTags(config); + + var value = isFunction(partials) ? partials(token[1]) : partials[token[1]]; + if (value != null) { + var lineHasNonSpace = token[6]; + var tagIndex = token[5]; + var indentation = token[4]; + var indentedValue = value; + if (tagIndex == 0 && indentation) { + indentedValue = this.indentPartial(value, indentation, lineHasNonSpace); + } + var tokens = this.parse(indentedValue, tags); + return this.renderTokens(tokens, context, partials, indentedValue, config); + } + }; + + Writer.prototype.unescapedValue = function unescapedValue (token, context) { + var value = context.lookup(token[1]); + if (value != null) + return value; + }; + + Writer.prototype.escapedValue = function escapedValue (token, context, config) { + var escape = this.getConfigEscape(config) || mustache.escape; + var value = context.lookup(token[1]); + if (value != null) + return (typeof value === 'number' && escape === mustache.escape) ? String(value) : escape(value); + }; + + Writer.prototype.rawValue = function rawValue (token) { + return token[1]; + }; + + Writer.prototype.getConfigTags = function getConfigTags (config) { + if (isArray(config)) { + return config; + } + else if (config && typeof config === 'object') { + return config.tags; + } + else { + return undefined; + } + }; + + Writer.prototype.getConfigEscape = function getConfigEscape (config) { + if (config && typeof config === 'object' && !isArray(config)) { + return config.escape; + } + else { + return undefined; + } + }; + + var mustache = { + name: 'mustache.js', + version: '4.2.0', + tags: [ '{{', '}}' ], + clearCache: undefined, + escape: undefined, + parse: undefined, + render: undefined, + Scanner: undefined, + Context: undefined, + Writer: undefined, + /** + * Allows a user to override the default caching strategy, by providing an + * object with set, get and clear methods. This can also be used to disable + * the cache by setting it to the literal `undefined`. + */ + set templateCache (cache) { + defaultWriter.templateCache = cache; + }, + /** + * Gets the default or overridden caching object from the default writer. + */ + get templateCache () { + return defaultWriter.templateCache; + } + }; + + // All high-level mustache.* functions use this writer. + var defaultWriter = new Writer(); + + /** + * Clears all cached templates in the default writer. + */ + mustache.clearCache = function clearCache () { + return defaultWriter.clearCache(); + }; + + /** + * Parses and caches the given template in the default writer and returns the + * array of tokens it contains. Doing this ahead of time avoids the need to + * parse templates on the fly as they are rendered. + */ + mustache.parse = function parse (template, tags) { + return defaultWriter.parse(template, tags); + }; + + /** + * Renders the `template` with the given `view`, `partials`, and `config` + * using the default writer. + */ + mustache.render = function render (template, view, partials, config) { + if (typeof template !== 'string') { + throw new TypeError('Invalid template! Template should be a "string" ' + + 'but "' + typeStr(template) + '" was given as the first ' + + 'argument for mustache#render(template, view, partials)'); + } + + return defaultWriter.render(template, view, partials, config); + }; + + // Export the escaping function so that the user may override it. + // See https://github.com/janl/mustache.js/issues/244 + mustache.escape = escapeHtml; + + // Export these mainly for testing, but also for advanced usage. + mustache.Scanner = Scanner; + mustache.Context = Context; + mustache.Writer = Writer; + + return mustache; + +}))); diff --git a/tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.mjs b/tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.mjs new file mode 100644 index 00000000000..ed0cd6d7162 --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.mjs @@ -0,0 +1,764 @@ +/*! + * mustache.js - Logic-less {{mustache}} templates with JavaScript + * http://github.com/janl/mustache.js + */ + +var objectToString = Object.prototype.toString; +var isArray = Array.isArray || function isArrayPolyfill (object) { + return objectToString.call(object) === '[object Array]'; +}; + +function isFunction (object) { + return typeof object === 'function'; +} + +/** + * More correct typeof string handling array + * which normally returns typeof 'object' + */ +function typeStr (obj) { + return isArray(obj) ? 'array' : typeof obj; +} + +function escapeRegExp (string) { + return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&'); +} + +/** + * Null safe way of checking whether or not an object, + * including its prototype, has a given property + */ +function hasProperty (obj, propName) { + return obj != null && typeof obj === 'object' && (propName in obj); +} + +/** + * Safe way of detecting whether or not the given thing is a primitive and + * whether it has the given property + */ +function primitiveHasOwnProperty (primitive, propName) { + return ( + primitive != null + && typeof primitive !== 'object' + && primitive.hasOwnProperty + && primitive.hasOwnProperty(propName) + ); +} + +// Workaround for https://issues.apache.org/jira/browse/COUCHDB-577 +// See https://github.com/janl/mustache.js/issues/189 +var regExpTest = RegExp.prototype.test; +function testRegExp (re, string) { + return regExpTest.call(re, string); +} + +var nonSpaceRe = /\S/; +function isWhitespace (string) { + return !testRegExp(nonSpaceRe, string); +} + +var entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' +}; + +function escapeHtml (string) { + return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) { + return entityMap[s]; + }); +} + +var whiteRe = /\s*/; +var spaceRe = /\s+/; +var equalsRe = /\s*=/; +var curlyRe = /\s*\}/; +var tagRe = /#|\^|\/|>|\{|&|=|!/; + +/** + * Breaks up the given `template` string into a tree of tokens. If the `tags` + * argument is given here it must be an array with two string values: the + * opening and closing tags used in the template (e.g. [ "<%", "%>" ]). Of + * course, the default is to use mustaches (i.e. mustache.tags). + * + * A token is an array with at least 4 elements. The first element is the + * mustache symbol that was used inside the tag, e.g. "#" or "&". If the tag + * did not contain a symbol (i.e. {{myValue}}) this element is "name". For + * all text that appears outside a symbol this element is "text". + * + * The second element of a token is its "value". For mustache tags this is + * whatever else was inside the tag besides the opening symbol. For text tokens + * this is the text itself. + * + * The third and fourth elements of the token are the start and end indices, + * respectively, of the token in the original template. + * + * Tokens that are the root node of a subtree contain two more elements: 1) an + * array of tokens in the subtree and 2) the index in the original template at + * which the closing tag for that section begins. + * + * Tokens for partials also contain two more elements: 1) a string value of + * indendation prior to that tag and 2) the index of that tag on that line - + * eg a value of 2 indicates the partial is the third tag on this line. + */ +function parseTemplate (template, tags) { + if (!template) + return []; + var lineHasNonSpace = false; + var sections = []; // Stack to hold section tokens + var tokens = []; // Buffer to hold the tokens + var spaces = []; // Indices of whitespace tokens on the current line + var hasTag = false; // Is there a {{tag}} on the current line? + var nonSpace = false; // Is there a non-space char on the current line? + var indentation = ''; // Tracks indentation for tags that use it + var tagIndex = 0; // Stores a count of number of tags encountered on a line + + // Strips all whitespace tokens array for the current line + // if there was a {{#tag}} on it and otherwise only space. + function stripSpace () { + if (hasTag && !nonSpace) { + while (spaces.length) + delete tokens[spaces.pop()]; + } else { + spaces = []; + } + + hasTag = false; + nonSpace = false; + } + + var openingTagRe, closingTagRe, closingCurlyRe; + function compileTags (tagsToCompile) { + if (typeof tagsToCompile === 'string') + tagsToCompile = tagsToCompile.split(spaceRe, 2); + + if (!isArray(tagsToCompile) || tagsToCompile.length !== 2) + throw new Error('Invalid tags: ' + tagsToCompile); + + openingTagRe = new RegExp(escapeRegExp(tagsToCompile[0]) + '\\s*'); + closingTagRe = new RegExp('\\s*' + escapeRegExp(tagsToCompile[1])); + closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tagsToCompile[1])); + } + + compileTags(tags || mustache.tags); + + var scanner = new Scanner(template); + + var start, type, value, chr, token, openSection; + while (!scanner.eos()) { + start = scanner.pos; + + // Match any text between tags. + value = scanner.scanUntil(openingTagRe); + + if (value) { + for (var i = 0, valueLength = value.length; i < valueLength; ++i) { + chr = value.charAt(i); + + if (isWhitespace(chr)) { + spaces.push(tokens.length); + indentation += chr; + } else { + nonSpace = true; + lineHasNonSpace = true; + indentation += ' '; + } + + tokens.push([ 'text', chr, start, start + 1 ]); + start += 1; + + // Check for whitespace on the current line. + if (chr === '\n') { + stripSpace(); + indentation = ''; + tagIndex = 0; + lineHasNonSpace = false; + } + } + } + + // Match the opening tag. + if (!scanner.scan(openingTagRe)) + break; + + hasTag = true; + + // Get the tag type. + type = scanner.scan(tagRe) || 'name'; + scanner.scan(whiteRe); + + // Get the tag value. + if (type === '=') { + value = scanner.scanUntil(equalsRe); + scanner.scan(equalsRe); + scanner.scanUntil(closingTagRe); + } else if (type === '{') { + value = scanner.scanUntil(closingCurlyRe); + scanner.scan(curlyRe); + scanner.scanUntil(closingTagRe); + type = '&'; + } else { + value = scanner.scanUntil(closingTagRe); + } + + // Match the closing tag. + if (!scanner.scan(closingTagRe)) + throw new Error('Unclosed tag at ' + scanner.pos); + + if (type == '>') { + token = [ type, value, start, scanner.pos, indentation, tagIndex, lineHasNonSpace ]; + } else { + token = [ type, value, start, scanner.pos ]; + } + tagIndex++; + tokens.push(token); + + if (type === '#' || type === '^') { + sections.push(token); + } else if (type === '/') { + // Check section nesting. + openSection = sections.pop(); + + if (!openSection) + throw new Error('Unopened section "' + value + '" at ' + start); + + if (openSection[1] !== value) + throw new Error('Unclosed section "' + openSection[1] + '" at ' + start); + } else if (type === 'name' || type === '{' || type === '&') { + nonSpace = true; + } else if (type === '=') { + // Set the tags for the next time around. + compileTags(value); + } + } + + stripSpace(); + + // Make sure there are no open sections when we're done. + openSection = sections.pop(); + + if (openSection) + throw new Error('Unclosed section "' + openSection[1] + '" at ' + scanner.pos); + + return nestTokens(squashTokens(tokens)); +} + +/** + * Combines the values of consecutive text tokens in the given `tokens` array + * to a single token. + */ +function squashTokens (tokens) { + var squashedTokens = []; + + var token, lastToken; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + token = tokens[i]; + + if (token) { + if (token[0] === 'text' && lastToken && lastToken[0] === 'text') { + lastToken[1] += token[1]; + lastToken[3] = token[3]; + } else { + squashedTokens.push(token); + lastToken = token; + } + } + } + + return squashedTokens; +} + +/** + * Forms the given array of `tokens` into a nested tree structure where + * tokens that represent a section have two additional items: 1) an array of + * all tokens that appear in that section and 2) the index in the original + * template that represents the end of that section. + */ +function nestTokens (tokens) { + var nestedTokens = []; + var collector = nestedTokens; + var sections = []; + + var token, section; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + token = tokens[i]; + + switch (token[0]) { + case '#': + case '^': + collector.push(token); + sections.push(token); + collector = token[4] = []; + break; + case '/': + section = sections.pop(); + section[5] = token[2]; + collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens; + break; + default: + collector.push(token); + } + } + + return nestedTokens; +} + +/** + * A simple string scanner that is used by the template parser to find + * tokens in template strings. + */ +function Scanner (string) { + this.string = string; + this.tail = string; + this.pos = 0; +} + +/** + * Returns `true` if the tail is empty (end of string). + */ +Scanner.prototype.eos = function eos () { + return this.tail === ''; +}; + +/** + * Tries to match the given regular expression at the current position. + * Returns the matched text if it can match, the empty string otherwise. + */ +Scanner.prototype.scan = function scan (re) { + var match = this.tail.match(re); + + if (!match || match.index !== 0) + return ''; + + var string = match[0]; + + this.tail = this.tail.substring(string.length); + this.pos += string.length; + + return string; +}; + +/** + * Skips all text until the given regular expression can be matched. Returns + * the skipped string, which is the entire tail if no match can be made. + */ +Scanner.prototype.scanUntil = function scanUntil (re) { + var index = this.tail.search(re), match; + + switch (index) { + case -1: + match = this.tail; + this.tail = ''; + break; + case 0: + match = ''; + break; + default: + match = this.tail.substring(0, index); + this.tail = this.tail.substring(index); + } + + this.pos += match.length; + + return match; +}; + +/** + * Represents a rendering context by wrapping a view object and + * maintaining a reference to the parent context. + */ +function Context (view, parentContext) { + this.view = view; + this.cache = { '.': this.view }; + this.parent = parentContext; +} + +/** + * Creates a new context using the given view with this context + * as the parent. + */ +Context.prototype.push = function push (view) { + return new Context(view, this); +}; + +/** + * Returns the value of the given name in this context, traversing + * up the context hierarchy if the value is absent in this context's view. + */ +Context.prototype.lookup = function lookup (name) { + var cache = this.cache; + + var value; + if (cache.hasOwnProperty(name)) { + value = cache[name]; + } else { + var context = this, intermediateValue, names, index, lookupHit = false; + + while (context) { + if (name.indexOf('.') > 0) { + intermediateValue = context.view; + names = name.split('.'); + index = 0; + + /** + * Using the dot notion path in `name`, we descend through the + * nested objects. + * + * To be certain that the lookup has been successful, we have to + * check if the last object in the path actually has the property + * we are looking for. We store the result in `lookupHit`. + * + * This is specially necessary for when the value has been set to + * `undefined` and we want to avoid looking up parent contexts. + * + * In the case where dot notation is used, we consider the lookup + * to be successful even if the last "object" in the path is + * not actually an object but a primitive (e.g., a string, or an + * integer), because it is sometimes useful to access a property + * of an autoboxed primitive, such as the length of a string. + **/ + while (intermediateValue != null && index < names.length) { + if (index === names.length - 1) + lookupHit = ( + hasProperty(intermediateValue, names[index]) + || primitiveHasOwnProperty(intermediateValue, names[index]) + ); + + intermediateValue = intermediateValue[names[index++]]; + } + } else { + intermediateValue = context.view[name]; + + /** + * Only checking against `hasProperty`, which always returns `false` if + * `context.view` is not an object. Deliberately omitting the check + * against `primitiveHasOwnProperty` if dot notation is not used. + * + * Consider this example: + * ``` + * Mustache.render("The length of a football field is {{#length}}{{length}}{{/length}}.", {length: "100 yards"}) + * ``` + * + * If we were to check also against `primitiveHasOwnProperty`, as we do + * in the dot notation case, then render call would return: + * + * "The length of a football field is 9." + * + * rather than the expected: + * + * "The length of a football field is 100 yards." + **/ + lookupHit = hasProperty(context.view, name); + } + + if (lookupHit) { + value = intermediateValue; + break; + } + + context = context.parent; + } + + cache[name] = value; + } + + if (isFunction(value)) + value = value.call(this.view); + + return value; +}; + +/** + * A Writer knows how to take a stream of tokens and render them to a + * string, given a context. It also maintains a cache of templates to + * avoid the need to parse the same template twice. + */ +function Writer () { + this.templateCache = { + _cache: {}, + set: function set (key, value) { + this._cache[key] = value; + }, + get: function get (key) { + return this._cache[key]; + }, + clear: function clear () { + this._cache = {}; + } + }; +} + +/** + * Clears all cached templates in this writer. + */ +Writer.prototype.clearCache = function clearCache () { + if (typeof this.templateCache !== 'undefined') { + this.templateCache.clear(); + } +}; + +/** + * Parses and caches the given `template` according to the given `tags` or + * `mustache.tags` if `tags` is omitted, and returns the array of tokens + * that is generated from the parse. + */ +Writer.prototype.parse = function parse (template, tags) { + var cache = this.templateCache; + var cacheKey = template + ':' + (tags || mustache.tags).join(':'); + var isCacheEnabled = typeof cache !== 'undefined'; + var tokens = isCacheEnabled ? cache.get(cacheKey) : undefined; + + if (tokens == undefined) { + tokens = parseTemplate(template, tags); + isCacheEnabled && cache.set(cacheKey, tokens); + } + return tokens; +}; + +/** + * High-level method that is used to render the given `template` with + * the given `view`. + * + * The optional `partials` argument may be an object that contains the + * names and templates of partials that are used in the template. It may + * also be a function that is used to load partial templates on the fly + * that takes a single argument: the name of the partial. + * + * If the optional `config` argument is given here, then it should be an + * object with a `tags` attribute or an `escape` attribute or both. + * If an array is passed, then it will be interpreted the same way as + * a `tags` attribute on a `config` object. + * + * The `tags` attribute of a `config` object must be an array with two + * string values: the opening and closing tags used in the template (e.g. + * [ "<%", "%>" ]). The default is to mustache.tags. + * + * The `escape` attribute of a `config` object must be a function which + * accepts a string as input and outputs a safely escaped string. + * If an `escape` function is not provided, then an HTML-safe string + * escaping function is used as the default. + */ +Writer.prototype.render = function render (template, view, partials, config) { + var tags = this.getConfigTags(config); + var tokens = this.parse(template, tags); + var context = (view instanceof Context) ? view : new Context(view, undefined); + return this.renderTokens(tokens, context, partials, template, config); +}; + +/** + * Low-level method that renders the given array of `tokens` using + * the given `context` and `partials`. + * + * Note: The `originalTemplate` is only ever used to extract the portion + * of the original template that was contained in a higher-order section. + * If the template doesn't use higher-order sections, this argument may + * be omitted. + */ +Writer.prototype.renderTokens = function renderTokens (tokens, context, partials, originalTemplate, config) { + var buffer = ''; + + var token, symbol, value; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + value = undefined; + token = tokens[i]; + symbol = token[0]; + + if (symbol === '#') value = this.renderSection(token, context, partials, originalTemplate, config); + else if (symbol === '^') value = this.renderInverted(token, context, partials, originalTemplate, config); + else if (symbol === '>') value = this.renderPartial(token, context, partials, config); + else if (symbol === '&') value = this.unescapedValue(token, context); + else if (symbol === 'name') value = this.escapedValue(token, context, config); + else if (symbol === 'text') value = this.rawValue(token); + + if (value !== undefined) + buffer += value; + } + + return buffer; +}; + +Writer.prototype.renderSection = function renderSection (token, context, partials, originalTemplate, config) { + var self = this; + var buffer = ''; + var value = context.lookup(token[1]); + + // This function is used to render an arbitrary template + // in the current context by higher-order sections. + function subRender (template) { + return self.render(template, context, partials, config); + } + + if (!value) return; + + if (isArray(value)) { + for (var j = 0, valueLength = value.length; j < valueLength; ++j) { + buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate, config); + } + } else if (typeof value === 'object' || typeof value === 'string' || typeof value === 'number') { + buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate, config); + } else if (isFunction(value)) { + if (typeof originalTemplate !== 'string') + throw new Error('Cannot use higher-order sections without the original template'); + + // Extract the portion of the original template that the section contains. + value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender); + + if (value != null) + buffer += value; + } else { + buffer += this.renderTokens(token[4], context, partials, originalTemplate, config); + } + return buffer; +}; + +Writer.prototype.renderInverted = function renderInverted (token, context, partials, originalTemplate, config) { + var value = context.lookup(token[1]); + + // Use JavaScript's definition of falsy. Include empty arrays. + // See https://github.com/janl/mustache.js/issues/186 + if (!value || (isArray(value) && value.length === 0)) + return this.renderTokens(token[4], context, partials, originalTemplate, config); +}; + +Writer.prototype.indentPartial = function indentPartial (partial, indentation, lineHasNonSpace) { + var filteredIndentation = indentation.replace(/[^ \t]/g, ''); + var partialByNl = partial.split('\n'); + for (var i = 0; i < partialByNl.length; i++) { + if (partialByNl[i].length && (i > 0 || !lineHasNonSpace)) { + partialByNl[i] = filteredIndentation + partialByNl[i]; + } + } + return partialByNl.join('\n'); +}; + +Writer.prototype.renderPartial = function renderPartial (token, context, partials, config) { + if (!partials) return; + var tags = this.getConfigTags(config); + + var value = isFunction(partials) ? partials(token[1]) : partials[token[1]]; + if (value != null) { + var lineHasNonSpace = token[6]; + var tagIndex = token[5]; + var indentation = token[4]; + var indentedValue = value; + if (tagIndex == 0 && indentation) { + indentedValue = this.indentPartial(value, indentation, lineHasNonSpace); + } + var tokens = this.parse(indentedValue, tags); + return this.renderTokens(tokens, context, partials, indentedValue, config); + } +}; + +Writer.prototype.unescapedValue = function unescapedValue (token, context) { + var value = context.lookup(token[1]); + if (value != null) + return value; +}; + +Writer.prototype.escapedValue = function escapedValue (token, context, config) { + var escape = this.getConfigEscape(config) || mustache.escape; + var value = context.lookup(token[1]); + if (value != null) + return (typeof value === 'number' && escape === mustache.escape) ? String(value) : escape(value); +}; + +Writer.prototype.rawValue = function rawValue (token) { + return token[1]; +}; + +Writer.prototype.getConfigTags = function getConfigTags (config) { + if (isArray(config)) { + return config; + } + else if (config && typeof config === 'object') { + return config.tags; + } + else { + return undefined; + } +}; + +Writer.prototype.getConfigEscape = function getConfigEscape (config) { + if (config && typeof config === 'object' && !isArray(config)) { + return config.escape; + } + else { + return undefined; + } +}; + +var mustache = { + name: 'mustache.js', + version: '4.2.0', + tags: [ '{{', '}}' ], + clearCache: undefined, + escape: undefined, + parse: undefined, + render: undefined, + Scanner: undefined, + Context: undefined, + Writer: undefined, + /** + * Allows a user to override the default caching strategy, by providing an + * object with set, get and clear methods. This can also be used to disable + * the cache by setting it to the literal `undefined`. + */ + set templateCache (cache) { + defaultWriter.templateCache = cache; + }, + /** + * Gets the default or overridden caching object from the default writer. + */ + get templateCache () { + return defaultWriter.templateCache; + } +}; + +// All high-level mustache.* functions use this writer. +var defaultWriter = new Writer(); + +/** + * Clears all cached templates in the default writer. + */ +mustache.clearCache = function clearCache () { + return defaultWriter.clearCache(); +}; + +/** + * Parses and caches the given template in the default writer and returns the + * array of tokens it contains. Doing this ahead of time avoids the need to + * parse templates on the fly as they are rendered. + */ +mustache.parse = function parse (template, tags) { + return defaultWriter.parse(template, tags); +}; + +/** + * Renders the `template` with the given `view`, `partials`, and `config` + * using the default writer. + */ +mustache.render = function render (template, view, partials, config) { + if (typeof template !== 'string') { + throw new TypeError('Invalid template! Template should be a "string" ' + + 'but "' + typeStr(template) + '" was given as the first ' + + 'argument for mustache#render(template, view, partials)'); + } + + return defaultWriter.render(template, view, partials, config); +}; + +// Export the escaping function so that the user may override it. +// See https://github.com/janl/mustache.js/issues/244 +mustache.escape = escapeHtml; + +// Export these mainly for testing, but also for advanced usage. +mustache.Scanner = Scanner; +mustache.Context = Context; +mustache.Writer = Writer; + +export default mustache; diff --git a/tests/corpus/9645-mustache-templates/node_modules/mustache/package.json b/tests/corpus/9645-mustache-templates/node_modules/mustache/package.json new file mode 100644 index 00000000000..81ce69d7e0d --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/mustache/package.json @@ -0,0 +1,67 @@ +{ + "name": "mustache", + "version": "4.2.0", + "description": "Logic-less {{mustache}} templates with JavaScript", + "author": "mustache.js Authors ", + "homepage": "https://github.com/janl/mustache.js", + "repository": { + "type": "git", + "url": "https://github.com/janl/mustache.js.git" + }, + "keywords": [ + "mustache", + "template", + "templates", + "ejs" + ], + "main": "mustache.js", + "bin": { + "mustache": "./bin/mustache" + }, + "files": [ + "bin/", + "mustache.mjs", + "mustache.min.js", + "wrappers/" + ], + "exports": { + ".": { + "import": "./mustache.mjs", + "require": "./mustache.js" + }, + "./*": "./*" + }, + "volo": { + "url": "https://raw.github.com/janl/mustache.js/{version}/mustache.js" + }, + "scripts": { + "build": "cp mustache.js mustache.mjs && rollup mustache.mjs --file mustache.js --format umd --name Mustache && uglifyjs mustache.js > mustache.min.js", + "test": "npm run test-lint && npm run test-unit", + "test-lint": "eslint mustache.js bin/mustache test/**/*.js", + "test-unit": "mocha --reporter spec test/*-test.js", + "test-render": "mocha --reporter spec test/render-test", + "pre-test-browser": "node test/create-browser-suite.js", + "test-browser": "npm run pre-test-browser && zuul -- test/context-test.js test/parse-test.js test/scanner-test.js test/render-test-browser.js", + "test-browser-local": "npm run pre-test-browser && zuul --local 8080 -- test/context-test.js test/scanner-test.js test/parse-test.js test/render-test-browser.js", + "postversion": "scripts/bump-version-in-source", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "chai": "^3.4.0", + "eslint": "^6.5.1", + "esm": "^3.2.25", + "jshint": "^2.9.5", + "mocha": "^3.0.2", + "puppeteer": "^2.0.0", + "rollup": "^1.26.3", + "uglify-js": "^3.4.6", + "zuul": "^3.11.0", + "zuul-ngrok": "nolanlawson/zuul-ngrok#patch-1" + }, + "greenkeeper": { + "ignore": [ + "eslint" + ] + }, + "license": "MIT" +} diff --git a/tests/corpus/9646-copy-reset-paths.mjs b/tests/corpus/9646-copy-reset-paths.mjs new file mode 100644 index 00000000000..ccedcc9be51 --- /dev/null +++ b/tests/corpus/9646-copy-reset-paths.mjs @@ -0,0 +1,423 @@ +// A copy of a binding that a called function assigned (`var c = text`, +// which TypeScript types `undefined`) and an assignment of the copy's own +// on some or all paths to the read. The read is `undefined` only where +// every path that reaches it stored `undefined` last; on every other path +// it is the value the copy holds. Each shape runs both ways. + +function Holder() {} +Holder.prototype.show = function (value) { + return typeof value + " " + String(value); +}; +const holder = new Holder(); +function result(c, n) { + return holder.show(c) + " / " + "abcd".slice(n); +} + +function conditionalThenReset(assign) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c, n; + if (assign) { + c = text; + n = size; + } + c = undefined; + n = undefined; + return holder.show(c) + " / " + "abcd".slice(n); +} + +function bothArms(first) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + if (first) { + c = undefined; + n = undefined; + } else { + n = undefined; + c = undefined; + } + return holder.show(c) + " / " + "abcd".slice(n); +} + +function oneArm(reset) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + if (reset) { + c = undefined; + n = undefined; + } + return holder.show(c) + " / " + "abcd".slice(n); +} + +function shortCircuit(form, flag) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + var call = flag ? function () {} : undefined; + if (form === "&&") { + flag && (c = undefined); + flag && (n = undefined); + } else if (form === "||") { + flag || (c = undefined); + flag || (n = undefined); + } else if (form === "??") { + (flag ? null : 0) ?? (c = undefined); + (flag ? null : 0) ?? (n = undefined); + } else if (form === "?:") { + flag ? (c = undefined) : 0; + flag ? 0 : (n = undefined); + } else { + call?.((c = undefined), (n = undefined)); + } + return holder.show(c) + " / " + "abcd".slice(n); +} + +function earlyExit(stop) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + for (var i = 0; i < 1; i++) { + if (stop) break; + c = undefined; + n = undefined; + } + return holder.show(c) + " / " + "abcd".slice(n); +} + +function afterReturn(stop) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + if (stop) return holder.show(c) + " / " + "abcd".slice(n); + c = undefined; + n = undefined; + return holder.show(c) + " / " + "abcd".slice(n); +} + +function afterThrow(fail) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + var inside; + try { + if (fail) throw new Error("stop"); + c = undefined; + n = undefined; + inside = holder.show(c) + " / " + "abcd".slice(n); + } catch (e) { + inside = "caught " + holder.show(c) + " / " + "abcd".slice(n); + } + return inside + " then " + holder.show(c) + " / " + "abcd".slice(n); +} + +function loopBody(times) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + var seen = []; + for (var i = 0; i < times; i++) { + c = undefined; + n = undefined; + seen.push(holder.show(c) + " " + "abcd".slice(n)); + c = text; + n = size; + } + return seen.join(", ") + " then " + holder.show(c) + " / " + "abcd".slice(n); +} + +function declaredInLoop(times) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var seen = []; + for (var i = 0; i < times; i++) { + var c; + var n; + let fresh; + let freshSize; + seen.push(holder.show(c) + " " + "abcd".slice(n) + " " + holder.show(fresh) + " " + "abcd".slice(freshSize)); + c = text; + n = size; + fresh = text; + freshSize = size; + } + return seen.join(", "); +} + +function inTry(fail) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + try { + if (fail) throw new Error("stop"); + c = undefined; + n = undefined; + } catch (e) {} + return holder.show(c) + " / " + "abcd".slice(n); +} + +function inCatch(fail) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + try { + if (fail) throw new Error("stop"); + } catch (e) { + c = undefined; + n = undefined; + } + return holder.show(c) + " / " + "abcd".slice(n); +} + +function inFinally(fail) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + try { + try { + if (fail) throw new Error("stop"); + } finally { + c = undefined; + n = undefined; + } + } catch (e) {} + return holder.show(c) + " / " + "abcd".slice(n); +} + +function inSwitch(key) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + switch (key) { + case 1: + c = undefined; + case 2: + n = undefined; + break; + default: + } + return holder.show(c) + " / " + "abcd".slice(n); +} + +function labeled(leave) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + block: { + if (leave) break block; + c = undefined; + n = undefined; + } + return holder.show(c) + " / " + "abcd".slice(n); +} + +function byClosure(call) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + function clear() { + c = undefined; + n = undefined; + } + if (call) clear(); + return holder.show(c) + " / " + "abcd".slice(n); +} + +function destructured(reset) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + var none = [undefined, undefined]; + var pair = { c: text, n: size }; + if (reset) [c, n] = none; + var first = holder.show(c) + " / " + "abcd".slice(n); + c = undefined; + n = undefined; + ({ c, n } = pair); + return first + " then " + holder.show(c) + " / " + "abcd".slice(n); +} + +function otherValues(kind) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + if (kind === "null") { + c = null; + n = null; + } else if (kind === "other") { + c = 7; + n = "1"; + } else { + c = undefined; + n = undefined; + c = text; + n = size; + } + return holder.show(c) + " / " + "abcd".slice(n); +} + +function same(value) { + return value; +} +function throughItself(form, flag) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + if (form !== "??" || flag) fill(); + var c = text; + var n = size; + var otherText = "other"; + var otherSize = 1; + if (form === "?:") { + c = flag ? c : undefined; + n = flag ? n : undefined; + } else if (form === "??") { + c = c ?? otherText; + n = n ?? otherSize; + } else if (form === "||=") { + c ||= otherText; + n ||= otherSize; + } else if (form === "call") { + c = same(c); + n = flag ? same(n) : same(undefined); + } else if (form === "self") { + c = c; + n = flag && n; + } else { + var held = c; + c = n; + n = held; + } + return holder.show(c) + " / " + "abcd".slice(n); +} + +function returned(reset) { + var text; + function fill() { + text = "filled"; + } + fill(); + var c = text; + if (reset) c = undefined; + return c; +} +function returnedAfterReset() { + var text; + function fill() { + text = "filled"; + } + fill(); + var c = text; + c = undefined; + return c; +} + +console.log("conditional, then a reset:", conditionalThenReset(true), "|", conditionalThenReset(false)); +console.log("reset in both arms:", bothArms(true), "|", bothArms(false)); +console.log("reset in one arm:", oneArm(true), "|", oneArm(false)); +for (const form of ["&&", "||", "??", "?:", "?.()"]) { + console.log("reset under " + form + ":", shortCircuit(form, true), "|", shortCircuit(form, false)); +} +console.log("reset after an early exit:", earlyExit(true), "|", earlyExit(false)); +console.log("reset after an early return:", afterReturn(true), "|", afterReturn(false)); +console.log("reset after a throw:", afterThrow(true), "|", afterThrow(false)); +console.log("reset in a loop body:", loopBody(0), "|", loopBody(2)); +console.log("declared in a loop body:", declaredInLoop(1), "|", declaredInLoop(3)); +console.log("reset in try:", inTry(true), "|", inTry(false)); +console.log("reset in catch:", inCatch(true), "|", inCatch(false)); +console.log("reset in finally:", inFinally(true), "|", inFinally(false)); +console.log("reset in a switch:", inSwitch(1), "|", inSwitch(2), "|", inSwitch(3)); +console.log("reset in a labeled block:", labeled(true), "|", labeled(false)); +console.log("reset by a closure:", byClosure(true), "|", byClosure(false)); +console.log("destructuring:", destructured(true), "|", destructured(false)); +console.log("null, another value, the read again:", otherValues("null"), "|", otherValues("other"), "|", otherValues("again")); +for (const form of ["?:", "??", "||=", "call", "self", "swap"]) { + console.log("assigned from itself, " + form + ":", throughItself(form, true), "|", throughItself(form, false)); +} +console.log("returned:", result(returned(true), 1), "|", result(returned(false), 1), "|", result(returnedAfterReset(), 1)); diff --git a/tests/diagnostics/stale-undefined-inferred-return.ts b/tests/diagnostics/stale-undefined-inferred-return.ts new file mode 100644 index 00000000000..fa0d74c005d --- /dev/null +++ b/tests/diagnostics/stale-undefined-inferred-return.ts @@ -0,0 +1,35 @@ +// TypeScript types a read by the last assignment in the read's own function +// and keeps that across a call, so each of these functions is inferred to +// return `undefined` although `search()` assigned `found` since. Returning +// nothing would drop the value, and the callers' types come from the same +// inference: the return needs a declared type. +let found: string | undefined = undefined; +function search(): void { + found = "hit"; +} +function result() { + found = undefined; + search(); + return found; +} +function either(first: boolean) { + found = undefined; + search(); + return first ? found : undefined; +} +class Finder { + hit: string | undefined = undefined; + look(): void { + this.hit = "hit"; + } + result() { + this.hit = undefined; + this.look(); + return this.hit; + } +} +result(); +either(true); +new Finder().result(); +search(); +(() => found)(); diff --git a/tests/harness/__snapshots__/stale-undefined-inferred-return.ts.txt b/tests/harness/__snapshots__/stale-undefined-inferred-return.ts.txt new file mode 100644 index 00000000000..13714c87fb0 --- /dev/null +++ b/tests/harness/__snapshots__/stale-undefined-inferred-return.ts.txt @@ -0,0 +1,35 @@ +stale-undefined-inferred-return.ts:13:10 - error SC1090: returning 'found' from a function whose return type TypeScript infers as 'undefined' (it types the read by the last assignment in this function, and a call made since can have assigned it from another function) is not supported yet + + 12 | search(); + 13 | return found; + | ^~~~~ + 14 | } + + hint: declare the function's return type, so the result is the value stored at the return + +stale-undefined-inferred-return.ts:18:10 - error SC1090: returning 'found' from a function whose return type TypeScript infers as 'undefined' (it types the read by the last assignment in this function, and a call made since can have assigned it from another function) is not supported yet + + 17 | search(); + 18 | return first ? found : undefined; + | ^~~~~~~~~~~~~~~~~~~~~~~~~ + 19 | } + + hint: declare the function's return type, so the result is the value stored at the return + +stale-undefined-inferred-return.ts:28:12 - error SC1090: returning 'this.hit' from a function whose return type TypeScript infers as 'undefined' (it types the read by the last assignment in this function, and a call made since can have assigned it from another function) is not supported yet + + 27 | this.look(); + 28 | return this.hit; + | ^~~~~~~~ + 29 | } + + hint: declare the function's return type, so the result is the value stored at the return + +stale-undefined-inferred-return.ts:35:8 - error SC1090: returning 'found' from a function whose return type TypeScript infers as 'undefined' (it types the read by the last assignment in this function, and a call made since can have assigned it from another function) is not supported yet + + 34 | search(); + 35 | (() => found)(); + | ^~~~~ + 36 | + + hint: declare the function's return type, so the result is the value stored at the return \ No newline at end of file diff --git a/tests/harness/corpus-costs/9641-callee-assigned-reads.mjs.json b/tests/harness/corpus-costs/9641-callee-assigned-reads.mjs.json new file mode 100644 index 00000000000..6097618ae59 --- /dev/null +++ b/tests/harness/corpus-costs/9641-callee-assigned-reads.mjs.json @@ -0,0 +1 @@ +{ "plain": 6400, "san": 9600 } diff --git a/tests/harness/corpus-costs/9642-callee-assigned-reads-commonjs.cjs.json b/tests/harness/corpus-costs/9642-callee-assigned-reads-commonjs.cjs.json new file mode 100644 index 00000000000..f7767dbc162 --- /dev/null +++ b/tests/harness/corpus-costs/9642-callee-assigned-reads-commonjs.cjs.json @@ -0,0 +1 @@ +{ "plain": 1700, "san": 1900 } diff --git a/tests/harness/corpus-costs/9643-callee-assigned-reads-typed.ts.json b/tests/harness/corpus-costs/9643-callee-assigned-reads-typed.ts.json new file mode 100644 index 00000000000..1f8ff72b2c5 --- /dev/null +++ b/tests/harness/corpus-costs/9643-callee-assigned-reads-typed.ts.json @@ -0,0 +1 @@ +{ "plain": 2100, "san": 2000 } diff --git a/tests/harness/corpus-costs/9645-mustache-templates/main.mjs.json b/tests/harness/corpus-costs/9645-mustache-templates/main.mjs.json new file mode 100644 index 00000000000..f0ed6cbc2a0 --- /dev/null +++ b/tests/harness/corpus-costs/9645-mustache-templates/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 4300, "san": 4400 } diff --git a/tests/harness/corpus-costs/9646-copy-reset-paths.mjs.json b/tests/harness/corpus-costs/9646-copy-reset-paths.mjs.json new file mode 100644 index 00000000000..87569618dcc --- /dev/null +++ b/tests/harness/corpus-costs/9646-copy-reset-paths.mjs.json @@ -0,0 +1 @@ +{ "plain": 4000, "san": 6000 }