diff --git a/packages/target-angular/README.md b/packages/target-angular/README.md index 8d87e2e44..c1a1c9015 100644 --- a/packages/target-angular/README.md +++ b/packages/target-angular/README.md @@ -14,14 +14,30 @@ options need; the component. - **Types.** The source's type declarations the output reads (its inputs' and outputs' types, the setup's annotations and code) and every exported one, copied as written and exported as the - source exports them. An input is typed by its member's type, never by the props type, so a - props type the source does not export is left out (it would be declared and never used). + source exports them. An input is typed by its member's type, so a props type the source does + not export is left out (it would be declared and never used), unless an input reads a member + from it (below). +- **Imported types** (ADR-0067). The types the source imports, by name, where the output reads + them: from a `.ts` module or a package as written, `import type { BadgeProps } from "./types"`, + and from a child's output, `import Field, { type FieldProps } from "./field"`. The output of the + module's main component also writes the module's type exports, in source order among its + types (ADR-0068): `export type { Tone } from "./types"`, and an exported type no component + reaches. - **Inputs.** Every prop is a public signal input, whether the template reads it or not (a prop is the component's API, and Angular reports a value for an undeclared input): `input.required()` when required, `input()` when optional, and `input(default, { transform })` with a default, whose transform is `(value) => (value === undefined ? default : value)`: an explicit `undefined` takes the - default, as JavaScript's destructuring does, and `null` stays a value. + default, as JavaScript's destructuring does, and `null` stays a value. A member another + declaration declares (inherited, intersected, picked or imported) is written in that file's + names, so its input reads it from the props type by indexed access (ADR-0067): + `input.required()`, and with a default + `input, BadgeProps["tone"]>(…)`. +- **Generic components** (ADR-0068). A generic component is a generic class, + `export default class List`, its constraints and defaults as written, + whose type parameters ngtsc infers from a consumer's inputs. A select's `v-model` of a state of + such a type takes the chosen value through the template's own cast, + `this.value.set($any(valueField.value))`, since a template writes no `as`. - **Outputs.** Every event `defineEmits` declares is a public `output()` of its name (ADR-0012), or, for a name angular-eslint's `no-output-native` knows as a native DOM event, `uf` and the name in PascalCase (`change` → `ufChange`, ADR-0056), which the native event bubbling out of the @@ -118,7 +134,7 @@ annotation, or the type TypeScript gives the `let` where the value's text makes `note ?? "none"` and `a ? "x" : "y"`, `.length`, the string, number and array methods whose result the receiver fixes, the pure globals' results, and reads of a prop or of a member whose type is known so); otherwise, as a last resort, a private method computes the value, and the field -is typed by it (`ReturnType`), since no type oracle exists before M5. +is typed by it (`ReturnType`), since a target reads no type oracle. A local function code passes as a value is an arrow field, declared before every other field of the setup: a field initialiser runs while the class constructs, in field order, and an initial @@ -281,7 +297,8 @@ spreads; ADR-0065) and composition's cells that ADR-0056 decides: `named-slot`, `contextual-root`, unsupported (UF4001, errors), `child-root-scope` and `sheet-order`, unsupported (UF4003 and UF4002, info), and `generic-slot-props`, unsupported (UF4004, info): slot directives cannot infer a component's type parameter, so a scoped slot's context takes its -constraint (ADR-0068). Where an unsupported cell is reported, the +constraint (ADR-0068), or `any` for a parameter without one, as ngtsc types a generic context it +cannot infer. Where an unsupported cell is reported, the output still compiles: default-slot presence reads `true`, and the forwarded default slot re-projects ``. diff --git a/packages/target-angular/src/composition.ts b/packages/target-angular/src/composition.ts index 1330279e3..b05f8f704 100644 --- a/packages/target-angular/src/composition.ts +++ b/packages/target-angular/src/composition.ts @@ -12,10 +12,12 @@ import type { SlotDeclaration, SlotFill, Span, + TypeParameter, UfComponent, } from "@unframework/ir"; import type { Plan } from "./plan.ts"; +import { atConstraints } from "./types.ts"; /** A slot of the component's own that a directive selects: a named one, or one with props. */ export interface SlotDirective { @@ -255,12 +257,14 @@ export function childEvent(plan: Plan, local: string, event: string): ApiEvent { /** * A slot directive's code (ADR-0056): the selector its consumers' `` names and, for * a scoped slot, the context guard that types their `let-` variables as the slot's props, by - * name and whole (`$implicit`). + * name and whole (`$implicit`). A directive cannot infer its component's type parameters, so + * the props read each one as its constraint (`generic-slot-props`, UF4004, ADR-0068). */ export function slotDirectiveCode( component: string, directive: SlotDirective, decorator: string, + typeParameters: readonly TypeParameter[] = [], ): string { const { slot, className, exported, attribute, context } = directive; const selector = `@${decorator}({ selector: "ng-template[${attribute}]" })`; @@ -270,7 +274,7 @@ export function slotDirectiveCode( ...(context ? [ `/** What a template of \`${component}\`'s \`${slot.name}\` slot reads: the slot's props. */`, - `export type ${context} = ${slot.props!.code}${aliases(slot.props!.code)};`, + `export type ${context} = ${atConstraints(`${slot.props!.code}${aliases(slot.props!.code)}`, typeParameters)};`, "", ] : []), diff --git a/packages/target-angular/src/index.ts b/packages/target-angular/src/index.ts index 94ffb4ded..c28a5b662 100644 --- a/packages/target-angular/src/index.ts +++ b/packages/target-angular/src/index.ts @@ -1,4 +1,5 @@ import { + addTypeImports, angularDialect, codeNames, codeKind, @@ -17,6 +18,7 @@ import { scopeRoots, sourceNames, typeDeclarationCode, + typeExportsCode, typeNames, } from "@unframework/codegen"; import type { @@ -27,7 +29,7 @@ import type { Target, } from "@unframework/codegen"; import { codeOf, functionsOf, walk } from "@unframework/ir"; -import type { Span, UfComponent, UfModule } from "@unframework/ir"; +import type { Span, TypeText, UfComponent, UfModule } from "@unframework/ir"; import { firstUse, @@ -402,11 +404,13 @@ function emitComponent(component: UfComponent, context: EmitContext): OutputFile { accessibility: "protected", readonly: true }, ), ]; + const declaration = js.classDeclaration(className, [], [...members, ...(hostMember ?? [])], { + implements: interfaces, + }); + declaration.typeParameters = typeParameters(component, placeholders); const statements = exportDeclaration( className, - js.classDeclaration(className, [], [...members, ...(hostMember ?? [])], { - implements: interfaces, - }), + declaration, exportsOf(component.name, module.exports), ); const types = declaredTypes(closure, typeRoots(component, module)); @@ -438,17 +442,30 @@ function emitComponent(component: UfComponent, context: EmitContext): OutputFile ...(refObject === undefined ? [] : [refObjectCode(refObject)]), ...[...models.helpers, ...models.support].map(({ code }) => `${code}\n`), ...composition.slots.map((slot) => - slotDirectiveCode(component.name, slot, imports.add("@angular/core", "Directive")), + slotDirectiveCode( + component.name, + slot, + imports.add("@angular/core", "Directive"), + component.typeParameters, + ), ), ...(scope ? [scopeDirectiveCode(scope, imports)] : []), ]; if (models.helpers.some(({ code }) => code.includes("WritableSignal"))) { imports.add("@angular/core", "WritableSignal", { type: true }); } + // The types it imports, by name, from where the source imports them, a child's from its + // output (`./field`), and in the main component's file the module's type exports, in source + // order among the types it declares (ADR-0067, ADR-0068). + addTypeImports(component, module, imports, kebabCase); + const typeCode = [ + ...types.map((type) => ({ code: typeDeclarationCode(type), span: type.span })), + ...typeExportsCode(component, module, kebabCase), + ].toSorted((a, b) => a.span.start - b.span.start); const contents = placeholders.print(() => [ printProgram(js.program(imports.toDeclarations())), - ...types.map((declaration) => `${typeDeclarationCode(declaration)}\n`), + ...typeCode.map(({ code }) => `${code}\n`), ...(nextId === undefined ? [] : [`let ${nextId} = 0;\n`]), ...declarations, ...directives.map( @@ -664,9 +681,11 @@ function providers( } /** - * The names of the types the output reads: its inputs' and outputs' types, and every name the - * setup's code, annotations and functions read (`ref`, `as Item`, `(item: Item)`). The - * props type itself is never printed: an input is typed by its member. + * The names of the types the output reads: its inputs' and outputs' types, its type parameters' + * constraints and defaults, and every name the setup's code, annotations and functions read + * (`ref`, `as Item`, `(item: Item)`). An input is typed by its member, so the props type + * itself is printed only where another declaration declares a member, which an input reads from + * it by indexed access (`propType`). */ function typeRoots(component: UfComponent, module: UfModule): Set { const names = new Set(); @@ -674,7 +693,17 @@ function typeRoots(component: UfComponent, module: UfModule): Set { for (const name of found) names.add(name); }; const declared = new Set(module.types.map(({ name }) => name)); - for (const prop of component.props) add(typeNames(prop.type)); + for (const prop of component.props) { + add( + typeNames( + prop.declaredBy && component.propsParameter ? component.propsParameter.type : prop.type, + ), + ); + } + for (const parameter of component.typeParameters ?? []) { + if (parameter.constraint) add(typeNames(parameter.constraint)); + if (parameter.default) add(typeNames(parameter.default)); + } for (const event of component.emits?.events ?? []) { for (const parameter of event.parameters) add(typeNames(parameter.type)); } @@ -689,6 +718,31 @@ function typeRoots(component: UfComponent, module: UfModule): Set { return new Set([...names].filter((name) => declared.has(name))); } +/** + * A generic component's type parameters, `class List` (ADR-0068): a + * generic class, whose parameters ngtsc infers from a consumer's inputs, its constraints and + * defaults copied as written. `null` for a component without them. + */ +function typeParameters( + component: UfComponent, + placeholders: Placeholders, +): ReturnType["typeParameters"] { + if (!component.typeParameters?.length) return null; + const type = (text: TypeText | undefined) => (text ? placeholders.type(text.code) : null); + const params = component.typeParameters.map((parameter) => ({ + type: "TSTypeParameter" as const, + name: js.bindingIdentifier(parameter.name), + constraint: type(parameter.constraint), + default: type(parameter.default), + in: false, + out: false, + const: false, + start: 0, + end: 0, + })); + return { type: "TSTypeParameterDeclaration", params, start: 0, end: 0 }; +} + /** * A printed class with a blank line before each of its methods, as Angular code sets them apart * from the fields and from each other (oxfmt keeps a blank line, and adds none). Read before the diff --git a/packages/target-angular/src/members.ts b/packages/target-angular/src/members.ts index 14f011534..b53ee204d 100644 --- a/packages/target-angular/src/members.ts +++ b/packages/target-angular/src/members.ts @@ -18,7 +18,7 @@ import { rewriteCode, } from "@unframework/codegen"; import type { Placeholders, RewriteRules } from "@unframework/codegen"; -import type { Code, Prop, TypeDeclaration } from "@unframework/ir"; +import type { Code, Prop, TypeDeclaration, UfComponent } from "@unframework/ir"; import { keyOf } from "./composition.ts"; import type { SlotDirective } from "./composition.ts"; @@ -31,7 +31,7 @@ import { bindingById, formOf, slotQuery, unreachable } from "./plan.ts"; import type { Plan } from "./plan.ts"; import { outputType, tidied } from "./rules.ts"; import { parenthesised, valueType } from "./seeded.ts"; -import { withoutUndefined } from "./types.ts"; +import { defaultedType, propType } from "./types.ts"; type Member = ReturnType | ReturnType; @@ -102,7 +102,7 @@ export function classMembers(context: MemberContext): { ? ("protected" as const) : ("private" as const); const members: Member[] = component.props.map((prop) => - inputMember(prop, core("input"), placeholders, context.declarations), + inputMember(prop, component, core("input"), placeholders, context.declarations), ); if (context.fallthrough) { // The consumer's `class` and `style`, which the root merges (ADR-0056): inputs, so a static @@ -653,11 +653,13 @@ function statements( } /** - * A prop as a signal input, typed by its member's type as written: + * A prop as a signal input, typed by its member's type as written, or read from the props type + * where another declaration declares it (`propType`, ADR-0067): * - required: `readonly label = input.required();` * - optional without a default: `readonly count = input();`, whose value is * `number | undefined` (Angular's own spelling of an optional input); - * - with a default: `readonly tone = input("info", { transform })`, whose + * - with a default: `readonly tone = input("info", { transform })`, or + * `input, ButtonProps["tone"]>` by indexed access, whose * transform turns `undefined` into the default. A destructured default applies to an absent * prop and to an explicit `undefined` alike, while Angular's initial value covers only the * first (`setInput(name, undefined)` sets `undefined`). `null` is a value, as in JavaScript, @@ -665,20 +667,22 @@ function statements( */ function inputMember( prop: Prop, + component: UfComponent, input: string, placeholders: Placeholders, declarations: readonly TypeDeclaration[], ): Member { const callee = js.identifier(input); - const { type, default: initial } = prop; + const { default: initial } = prop; + const type = propType(prop, component); if (!prop.optional) { const required = js.memberExpression(callee, "required"); - return readonlyField(prop, js.callExpression(required, [], [placeholders.type(type.code)])); + return readonlyField(prop, js.callExpression(required, [], [placeholders.type(type)])); } if (initial === undefined) { - return readonlyField(prop, js.callExpression(callee, [], [placeholders.type(type.code)])); + return readonlyField(prop, js.callExpression(callee, [], [placeholders.type(type)])); } - const value = withoutUndefined(type.code, declarations); + const value = defaultedType(prop, component, declarations); const transform = js.arrowFunction( [js.bindingIdentifier("value")], js.conditionalExpression( @@ -694,7 +698,10 @@ function inputMember( [placeholders.expression(initial.code), js.objectExpression([["transform", transform]])], [ placeholders.type(value), - js.unionType([placeholders.type(value), js.keywordType("undefined")]), + // An indexed access of an optional member admits `undefined` already. + prop.declaredBy && type !== prop.type.code + ? placeholders.type(type) + : js.unionType([placeholders.type(value), js.keywordType("undefined")]), ], ), ); diff --git a/packages/target-angular/src/models.ts b/packages/target-angular/src/models.ts index 12466ef7e..91c3e2f1f 100644 --- a/packages/target-angular/src/models.ts +++ b/packages/target-angular/src/models.ts @@ -8,9 +8,17 @@ import { bindingForms, mergesWholeStyle, rewriteExpression, + typeNames, } from "@unframework/codegen"; import type { PrintedAttribute } from "@unframework/codegen"; -import type { Attribute, ElementNode, EventAttribute, ModelAttribute } from "@unframework/ir"; +import type { + Attribute, + BindingId, + ElementNode, + EventAttribute, + ModelAttribute, + UfComponent, +} from "@unframework/ir"; import { bindingById } from "./plan.ts"; import type { Plan } from "./plan.ts"; @@ -259,9 +267,13 @@ export function planModels(plan: Plan): Models { // reads it from; in the browser it stays unset, as Vue leaves it. const multiple = model.control === "select-multiple"; const name = field(); + // The chosen option's value is one of the options, which a generic component's consumer + // types: a state of its type parameter (`defineModel`) takes it as `T`, through the + // template's own cast, since a template writes no `as` (ADR-0068). + const chosen = multiple ? `${helper("selectedValues")}(${name})` : `${name}.value`; listen( "change", - `${signal}.set(${multiple ? `${helper("selectedValues")}(${name})` : `${name}.value`})`, + `${signal}.set(${generic(plan.component, reference.binding) ? `$any(${chosen})` : chosen})`, ); server ??= claimVariable(plan, "onServer"); if (variable !== undefined) printed.unshift({ name: `#${variable}`, text: `#${variable}` }); @@ -365,6 +377,15 @@ function optionValue( return `${optionText()}(${['""', ...parts].join(" + ")})`; } +/** Whether a binding's declared type names a type parameter of its component (ADR-0068). */ +function generic(component: UfComponent, binding: BindingId): boolean { + const parameters = new Set(component.typeParameters?.map(({ name }) => name)); + if (!parameters.size) return false; + const item = component.setup.find((each) => "binding" in each && each.binding === binding); + const type = item && "type" in item ? item.type : undefined; + return type !== undefined && [...typeNames(type)].some((name) => parameters.has(name)); +} + /** A template variable's name, free beside the class's members and the lists' variables. */ function claimVariable(plan: Plan, base: string): string { let name = plan.members.claim(base); diff --git a/packages/target-angular/src/seeded.ts b/packages/target-angular/src/seeded.ts index a4b299626..140431609 100644 --- a/packages/target-angular/src/seeded.ts +++ b/packages/target-angular/src/seeded.ts @@ -5,14 +5,14 @@ // `note ?? "none"`, `a ? "x" : "y"`), `.length`, the string, number and array methods whose // result the receiver fixes (`title.trim()`), the pure globals' (`Math.max(…)`), and reads of a // prop or of a member whose own type is known so. Otherwise none, and the class types the field -// by a method that computes the initial value (./members.ts). There is no type oracle before M5 -// (plan §9). +// by a method that computes the initial value (./members.ts). A target reads no type oracle: +// shapes reach the analyser alone (ADR-0067). import { parseExpression } from "@unframework/codegen"; import type { BindingId, Code, TypeDeclaration } from "@unframework/ir"; import { bindingById } from "./plan.ts"; import type { Plan } from "./plan.ts"; -import { arrayElement, withoutNullish, withoutUndefined } from "./types.ts"; +import { arrayElement, defaultedType, propType, withoutNullish } from "./types.ts"; interface Node { type: string; @@ -334,11 +334,12 @@ function bindingType( if (binding.kind === "prop") { const prop = component.props.find((each) => each.binding === id); if (prop === undefined) return undefined; - if (!prop.optional) return prop.type.code; + const type = propType(prop, component); + if (!prop.optional) return type; // The input's value: a default leaves `undefined` out (./members.ts `inputMember`). return prop.default === undefined - ? `${parenthesised(prop.type.code)} | undefined` - : withoutUndefined(prop.type.code, declarations); + ? `${parenthesised(type)} | undefined` + : defaultedType(prop, component, declarations); } const item = component.setup.find((each) => "binding" in each && each.binding === id); switch (item?.kind) { diff --git a/packages/target-angular/src/types.ts b/packages/target-angular/src/types.ts index 637c93c80..3c513ffc5 100644 --- a/packages/target-angular/src/types.ts +++ b/packages/target-angular/src/types.ts @@ -3,7 +3,7 @@ // the author wrote it; it is parsed only to find the names it refers to and the `undefined` an // input with a default leaves out of its value type. import { parseExpressionSource, parseStatementsSource } from "@unframework/codegen"; -import type { TypeDeclaration } from "@unframework/ir"; +import type { Prop, TypeDeclaration, TypeParameter, UfComponent } from "@unframework/ir"; interface TypeNode { type: string; @@ -110,11 +110,12 @@ function declarationReferences(declaration: TypeDeclaration): Set { /** * The type declarations a component's file declares, in source order: every exported one of - * its closure (`component.types`), which consumers type their props with (M5), and every other - * one the output reads (`roots`: an input's or an output's type, the setup's annotations and - * code) or a declared one refers to. An Angular component types each input by its member's - * type, never by the props type itself, so a props interface the source does not export would be - * declared and never used, which the L5 baseline rejects (`no-unused-vars`). + * its closure (`component.types`), which consumers type their props with (ADR-0068), and every + * other one the output reads (`roots`: an input's or an output's type, the setup's annotations + * and code) or a declared one refers to. An Angular component types each input by its member's + * type, so a props interface the source does not export would be declared and never used, which + * the L5 baseline rejects (`no-unused-vars`), unless an input reads a member from it by indexed + * access ({@link propType}). */ export function declaredTypes( closure: readonly TypeDeclaration[], @@ -138,7 +139,8 @@ export function declaredTypes( /** * A type without `undefined`: the value type of an input with a default, which the input's * transform never lets through (ADR-0034). A union's `undefined` members are left out as - * written; a reference to a local alias that admits `undefined` becomes `Exclude<…, undefined>`. + * written; a reference to a local alias that admits `undefined`, or an indexed access, becomes + * `Exclude<…, undefined>`. */ export function withoutUndefined(code: string, declarations: readonly TypeDeclaration[]): string { return without(code, declarations, false); @@ -146,7 +148,8 @@ export function withoutUndefined(code: string, declarations: readonly TypeDeclar /** * A type without `undefined` and `null`, as `??` and `!` leave it: a union's nullish members are - * left out as written; a reference to a local alias that admits either becomes `NonNullable<…>`. + * left out as written; a reference to a local alias that admits either, or an indexed access, + * becomes `NonNullable<…>`. */ export function withoutNullish(code: string, declarations: readonly TypeDeclaration[]): string { return without(code, declarations, true); @@ -164,6 +167,10 @@ function without(code: string, declarations: readonly TypeDeclaration[], nullish const admits = (type: TypeNode, seen: ReadonlySet): boolean => { if (left(type)) return true; switch (type.type) { + // An indexed access, a member read from the props type (`BadgeProps["note"]`, `propType`) + // or one the author writes (`Item["id"]`): opaque here, so it may admit either. + case "TSIndexedAccessType": + return true; case "TSUnionType": return (type.types as TypeNode[]).some((member) => admits(member, seen)); case "TSTypeReference": { @@ -212,3 +219,149 @@ export function arrayElement(code: string): string | undefined { } return undefined; } + +/** + * The type an input is declared with (ADR-0067). A member the props type declares keeps its + * text as written. A member another declaration declares (an inherited, intersected, picked or + * imported one, `Prop.declaredBy`) is written in that file's names, which the component's module + * may not have, so it is read from the props type by indexed access, `ButtonProps["tone"]`: the + * output imports or declares the props type's names, and the utilities over it still apply. + */ +export function propType(prop: Prop, component: UfComponent): string { + const props = component.propsParameter?.type.code; + if (!prop.declaredBy || props === undefined) return prop.type.code; + const type = parseType(props); + const object = + type.type === "TSTypeReference" || type.type === "TSTypeLiteral" + ? props.trim() + : `(${props.trim()})`; + return `${object}[${JSON.stringify(prop.name)}]`; +} + +/** + * The value type of an input with a default, which its transform never lets be `undefined` + * (ADR-0034): a member's own text without `undefined` ({@link withoutUndefined}), or an indexed + * access without it, `Exclude`, since an optional member's + * indexed access admits `undefined`. + */ +export function defaultedType( + prop: Prop, + component: UfComponent, + declarations: readonly TypeDeclaration[], +): string { + const type = propType(prop, component); + return prop.declaredBy && type !== prop.type.code + ? `Exclude<${type}, undefined>` + : withoutUndefined(type, declarations); +} + +/** + * A type with each of a component's type parameters read as its constraint: the type of a scoped + * slot's context, which a slot directive cannot infer, since ngtsc infers a component's type + * parameters from its inputs alone (`generic-slot-props`, UF4004, ADR-0068). A parameter without + * one reads as `any`, as ngtsc types a generic context it cannot infer + * (`TcbGenericContextBehavior.FallbackToAny`): as `unknown`, a fill could read none of its + * members. A constraint that names another parameter reads it the same way. + */ +export function atConstraints(code: string, parameters: readonly TypeParameter[]): string { + if (!parameters.length) return code; + const byName = new Map(parameters.map((parameter) => [parameter.name, parameter])); + const read = (text: string, seen: ReadonlySet): string => { + const found: { start: number; end: number; name: string }[] = []; + const visit = (value: unknown, shadowed: ReadonlySet): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item, shadowed); + return; + } + if (!isTypeNode(value)) return; + const name = value.typeName as { type?: string; name?: unknown } | undefined; + if ( + value.type === "TSTypeReference" && + name?.type === "Identifier" && + typeof name.name === "string" && + byName.has(name.name) && + !shadowed.has(name.name) && + !value.typeArguments + ) { + found.push({ start: value.start, end: value.end, name: name.name }); + return; + } + if (value.type === "TSConditionalType") { + // An `infer U` declares `U` in the extends clause and the true branch only. + const inferred = new Set([...shadowed, ...inferredNames(value.extendsType)]); + visit(value.checkType, shadowed); + visit(value.extendsType, inferred); + visit(value.trueType, inferred); + visit(value.falseType, shadowed); + return; + } + // A function, method or constructor type's own parameters, and a mapped type's key, + // shadow a component's parameter of the same name. + const own = [...declaredNames(value)]; + const inner = own.length ? new Set([...shadowed, ...own]) : shadowed; + for (const [key, child] of Object.entries(value)) { + if (key !== "type" && typeof child === "object") visit(child, inner); + } + }; + visit(parseType(text), new Set()); + let result = text; + for (const { start, end, name } of found.toSorted((a, b) => b.start - a.start)) { + const constraint = byName.get(name)!.constraint?.code; + const replaced = + constraint === undefined || seen.has(name) + ? "any" + : grouped(read(constraint, new Set([...seen, name]))); + result = `${result.slice(0, start - PREFIX.length)}${replaced}${result.slice(end - PREFIX.length)}`; + } + return result; + }; + return read(code, new Set()); +} + +/** The type parameters a node declares for its own subtree: `(x: T) => T`, `[K in …]`. */ +function declaredNames(node: TypeNode): string[] { + if (node.type === "TSMappedType") { + const key = node.key as { name?: unknown } | undefined; + return typeof key?.name === "string" ? [key.name] : []; + } + const declaration = node.typeParameters as + | { type?: string; params?: { name?: { name?: unknown } }[] } + | null + | undefined; + if (declaration?.type !== "TSTypeParameterDeclaration") return []; + return (declaration.params ?? []) + .map((parameter) => parameter.name?.name) + .filter((name): name is string => typeof name === "string"); +} + +/** The names a conditional type's extends clause declares with `infer`. */ +function inferredNames(value: unknown): string[] { + if (Array.isArray(value)) return value.flatMap(inferredNames); + if (!isTypeNode(value)) return []; + if (value.type === "TSInferType") { + const name = (value.typeParameter as { name?: { name?: unknown } } | undefined)?.name?.name; + return typeof name === "string" ? [name] : []; + } + return Object.entries(value).flatMap(([key, child]) => + key !== "type" && typeof child === "object" ? inferredNames(child) : [], + ); +} + +/** A type in parentheses where an operator or a suffix (`[]`, `[K]`) would bind into it. */ +function grouped(type: string): string { + const text = type.trim(); + return enclosed(text) || !/[|&]|=>|\bextends\b|^(?:new|keyof|readonly|typeof|infer)\b/.test(text) + ? text + : `(${text})`; +} + +/** Whether a type's text is one parenthesised type, `("a" | "b")`, which its parser drops. */ +function enclosed(text: string): boolean { + if (!text.startsWith("(")) return false; + let depth = 0; + for (let index = 0; index < text.length; index += 1) { + if (text[index] === "(") depth += 1; + else if (text[index] === ")" && --depth === 0) return index === text.length - 1; + } + return false; +} diff --git a/packages/target-angular/test/emit.test.ts b/packages/target-angular/test/emit.test.ts index a4064dcbb..5e90cb92a 100644 --- a/packages/target-angular/test/emit.test.ts +++ b/packages/target-angular/test/emit.test.ts @@ -13,6 +13,7 @@ import type { ElementNode, UfModule } from "@unframework/ir"; import { afterAll, describe, expect, it } from "vitest"; import target from "../src/index.ts"; +import { toolchain } from "../src/toolchain/index.ts"; import { createHost, strictOptions } from "../src/toolchain/ngtsc.ts"; import { loadCompiler } from "../src/toolchain/tools.ts"; import { @@ -317,6 +318,14 @@ function classBody(contents: string): string[] { return body ? body[1]!.split("\n").map((line) => line.trim()) : []; } +/** The messages of Angular's L3 check (ngtsc) on an emitted file, alone in a scratch directory. */ +async function l3Errors(file: { path: string; contents: string }): Promise { + const path = join(scratchDir(), file.path); + const results = await toolchain.frameworkCompile([{ path, contents: file.contents }], context); + const { errors, warnings } = results.get(path)!; + return [...errors, ...warnings].map(({ code, message }) => `${code}: ${message}`); +} + /** The lines of an emitted file's template, without their indentation, formatted or not. */ function templateLines(contents: string): string[] { return /template: `([\s\S]*)`,?\n/ @@ -572,6 +581,160 @@ export default function Field({ value }: input) { }); }); +describe("cross-file types and generics (ADR-0067, ADR-0068)", () => { + it("reads a member another declaration declares from the props type, by indexed access", async () => { + const file = await formatted(` +interface Labelled { + label: string; + tone?: "info" | "danger"; +} + +interface BadgeProps extends Labelled { + size?: "s" | "l"; +} + +export default function Badge({ label, tone = "info", size }: BadgeProps) { + return {label}; +} +`); + const body = classBody(file.contents); + expect(body).toContain('readonly label = input.required();'); + expect(body).toContain( + 'readonly tone = input, BadgeProps["tone"]>("info", {', + ); + // A member the props type declares keeps its text. + expect(body).toContain('readonly size = input<"s" | "l">();'); + // The props type is declared, since the inputs read it, with what it extends. + expect([...file.contents.matchAll(/^interface (\w+)/gm)].map((match) => match[1])).toEqual([ + "Labelled", + "BadgeProps", + ]); + }); + + it("leaves the nullish out of a member read by indexed access, as `??` and `!` do", async () => { + const file = await formatted(` +interface Labelled { + note?: string; +} + +interface Marked { + mark: string | null; +} + +export default function Badge({ label, note, mark }: Labelled & Marked & { label: string }) { + let shown = note ?? "none"; + let first = mark!; + function shout() { + shown = shown.toUpperCase(); + first = first.trim(); + } + return ; +} +`); + // An inline props type is read in parentheses, so the access applies to all of it. + expect(file.contents).toContain( + 'readonly note = input<(Labelled & Marked & { label: string })["note"]>();', + ); + expect(file.contents).toContain( + 'private shown!: NonNullable<(Labelled & Marked & { label: string })["note"]> | string;', + ); + expect(file.contents).toContain( + 'private first!: NonNullable<(Labelled & Marked & { label: string })["mark"]>;', + ); + expect(await l3Errors(file)).toEqual([]); + }); + + it("declares the types a type parameter's constraint and default name, and nothing else does", async () => { + const file = await formatted(` +interface Item { + id: string; +} + +interface Fallback { + id: string; + label: string; +} + +export default function List({ items }: { items: T[] }) { + return
    {items.map((item) =>
  • {item.id}
  • )}
; +} +`); + expect(file.contents).toContain("export default class List {"); + expect([...file.contents.matchAll(/^interface (\w+)/gm)].map((match) => match[1])).toEqual([ + "Item", + "Fallback", + ]); + expect(await l3Errors(file)).toEqual([]); + }); + + it("declares a generic component's class with its type parameters as written", async () => { + const file = await formatted(` +export interface TableProps { + rows: T[]; +} + +export default function Table({ rows }: TableProps) { + return
    {rows.map((row) =>
  • {row.id}
  • )}
; +} +`); + expect(file.contents).toContain("export default class Table {"); + expect(file.contents).toContain("readonly rows = input.required();"); + }); + + it("types a generic slot's context at the constraints, which its directive cannot infer", async () => { + const file = await formatted(` +import { defineSlots } from "unframework"; +import type { Element } from "unframework"; + +export default function Options({ options, extra }: { options: T[]; extra: U }) { + const slots = defineSlots<{ option?(props: { option: T; extra: U }): Element }>(); + return
    {options.map((option) =>
  • {slots.option?.({ option, extra })}
  • )}
; +} +`); + expect(file.contents).toContain( + "export type OptionsOptionContext = { option: { value: string }; extra: any };", + ); + expect(file.contents).toContain( + "export default class Options {", + ); + }); + + it("writes a select's choice into a model of a type parameter through the template's cast", async () => { + const file = await formatted(` +import { defineModel } from "unframework"; + +export default function Select({ options }: { options: readonly T[] }) { + const value = defineModel("value", { required: true }); + return ( + + ); +} +`); + expect(file.contents).toContain('(change)="this.value.set($any(valueField.value))"'); + }); + + it("exports a type no component reaches, in source order among the declared ones", async () => { + const file = await formatted(` +export type Tone = "primary" | "secondary"; + +export type Size = "small" | "large"; + +export interface ButtonProps { + tone?: Tone; +} + +export default function Button({ tone = "primary" }: ButtonProps) { + return ; +} +`); + expect( + [...file.contents.matchAll(/^export (?:interface|type) (\w+)/gm)].map((m) => m[1]), + ).toEqual(["Tone", "Size", "ButtonProps"]); + }); +}); + describe("template (ADR-0035)", () => { // Angular narrows a template variable as TypeScript narrows a local, never a signal call // (TS2532 on \`owner() && owner().name\`): output.test.ts proves this source type-checks. diff --git a/packages/target-angular/test/helpers.ts b/packages/target-angular/test/helpers.ts index a7e347f93..7a5cf058d 100644 --- a/packages/target-angular/test/helpers.ts +++ b/packages/target-angular/test/helpers.ts @@ -49,11 +49,9 @@ function filesUnder(directory: string): string[] { .toSorted(); } -/** The committed Angular golden outputs of the integration corpus, but its types lane's. */ +/** The committed Angular golden outputs of the integration corpus. */ export function goldenFiles(): string[] { - return filesUnder(casesDir).filter( - (path) => /\/__output__\/angular\/[^/]+\.ts$/.test(path) && !TYPES_LANE.test(path), - ); + return filesUnder(casesDir).filter((path) => /\/__output__\/angular\/[^/]+\.ts$/.test(path)); } /** @@ -76,7 +74,7 @@ export function corpusSources(): { name: string; file: string; source: string }[ !COMPOSITION.test(name) && !STYLED_COMPOSITION.test(name) && !BINDING_COMPOSITION.test(name) && - !TYPES_LANE.test(`${name}/`), + !TYPE_MODULES.test(name), ); } @@ -96,11 +94,12 @@ const STYLED_COMPOSITION = const BINDING_COMPOSITION = /^bindings\/(?:style-expression|style-spread)$/; /** - * M5's types and generics cases (ADR-0067, ADR-0068), which Angular emits from its types lane, layer - * 23 of M5 (UXF-465): until then `harness/quarantine/angular.ts` lists their failing cells, and the lane - * brings them back here. + * The types and generics cases whose sources import types from a module beside them, or render + * a child (ADR-0067, ADR-0068): `compile()` resolves both through its oracle and resolver, and + * copies the modules into the output tree, which `lower` does not. The corpus's toolchain project + * checks their Angular outputs. */ -const TYPES_LANE = /(?:^|\/)(?:types|generics|diagnostics\/generic-slot-props)\//; +const TYPE_MODULES = /^types\/(?!exported-types$)|^generics\/(?:scoped-slot|select)$/; /** * The areas of M3's composition cases, which Angular reports (UF1002) until its M3 lane diff --git a/packages/target-angular/test/types.test.ts b/packages/target-angular/test/types.test.ts index 8a214010f..1945e9f4a 100644 --- a/packages/target-angular/test/types.test.ts +++ b/packages/target-angular/test/types.test.ts @@ -5,7 +5,7 @@ import { createTypeDeclaration, span } from "@unframework/ir"; import type { TypeDeclaration } from "@unframework/ir"; import { describe, expect, it } from "vitest"; -import { declaredTypes, withoutUndefined } from "../src/types.ts"; +import { atConstraints, declaredTypes, withoutUndefined } from "../src/types.ts"; function declaration(name: string, code: string, exported = false): TypeDeclaration { return createTypeDeclaration(name, exported, code, span(0, code.length)); @@ -58,3 +58,48 @@ describe("declaredTypes", () => { expect(names(declaredTypes(closure, []))).toEqual(["Box"]); }); }); + +describe("atConstraints", () => { + const parameter = (name: string, constraint?: string) => ({ + name, + ...(constraint === undefined ? {} : { constraint: { code: constraint, span: span(0, 0) } }), + span: span(0, 0), + }); + + it("reads each type parameter as its constraint, and one without as `any` (ADR-0068)", () => { + const parameters = [parameter("T", "{ value: string }"), parameter("U")]; + expect(atConstraints("{ option: T; extra: U[] }", parameters)).toBe( + "{ option: { value: string }; extra: any[] }", + ); + }); + + it("groups a constraint an operator or a suffix would bind into, and reads one through another", () => { + const parameters = [parameter("T", '"a" | "b"'), parameter("U", "T")]; + expect(atConstraints("{ items: T[]; first: U }", parameters)).toBe( + '{ items: ("a" | "b")[]; first: ("a" | "b") }', + ); + }); + + it("leaves a type without the parameters, and a reference with type arguments, as written", () => { + expect(atConstraints("{ item: T }", [])).toBe("{ item: T }"); + expect(atConstraints("{ item: Box; other: T.Member }", [parameter("Box")])).toBe( + "{ item: Box; other: T.Member }", + ); + }); + + it("leaves a name that a nested type declares for itself", () => { + const parameters = [parameter("T", "{ id: string }"), parameter("K")]; + expect(atConstraints("{ render: (x: T) => T; item: T }", parameters)).toBe( + "{ render: (x: T) => T; item: { id: string } }", + ); + expect(atConstraints("{ m(x: T): void; n: new () => T }", parameters)).toBe( + "{ m(x: T): void; n: new () => T }", + ); + expect(atConstraints("{ [K in keyof T]: T[K] }", parameters)).toBe( + "{ [K in keyof { id: string }]: { id: string }[K] }", + ); + expect(atConstraints("T extends Array ? K : K", parameters)).toBe( + "{ id: string } extends Array ? K : any", + ); + }); +}); diff --git a/tests/integration/cases/diagnostics/generic-slot-props/__output__/angular/options.ts b/tests/integration/cases/diagnostics/generic-slot-props/__output__/angular/options.ts index 901a71e8d..9aa50d84e 100644 --- a/tests/integration/cases/diagnostics/generic-slot-props/__output__/angular/options.ts +++ b/tests/integration/cases/diagnostics/generic-slot-props/__output__/angular/options.ts @@ -12,7 +12,7 @@ import { } from "@angular/core"; /** What a template of `Options`'s `option` slot reads: the slot's props. */ -export type OptionsOptionContext = { option: T }; +export type OptionsOptionContext = { option: { value: string } }; /** A template of `Options`'s `option` slot, its `let-` variables typed (ADR-0056). */ @Directive({ selector: "ng-template[ufOptionsOption]" }) @@ -72,7 +72,7 @@ export class ScopeAttributes implements OnInit { `, }) -export default class Options { +export default class Options { readonly options = input.required(); readonly class = input(); readonly style = input>(); diff --git a/tests/integration/cases/generics/constrained/__output__/angular/table.ts b/tests/integration/cases/generics/constrained/__output__/angular/table.ts index 042ee6ebe..a5377e1cd 100644 --- a/tests/integration/cases/generics/constrained/__output__/angular/table.ts +++ b/tests/integration/cases/generics/constrained/__output__/angular/table.ts @@ -59,7 +59,7 @@ export class ScopeAttributes implements OnInit { `, }) -export default class Table { +export default class Table { readonly caption = input.required(); readonly rows = input.required(); readonly class = input(); diff --git a/tests/integration/cases/generics/list/__output__/angular/list.ts b/tests/integration/cases/generics/list/__output__/angular/list.ts index d495c7ab7..d84a747a9 100644 --- a/tests/integration/cases/generics/list/__output__/angular/list.ts +++ b/tests/integration/cases/generics/list/__output__/angular/list.ts @@ -60,7 +60,7 @@ export class ScopeAttributes implements OnInit { `, }) -export default class List { +export default class List { readonly label = input.required(); readonly items = input.required(); readonly selected = input(); diff --git a/tests/integration/cases/generics/scoped-slot/__output__/angular/picker.ts b/tests/integration/cases/generics/scoped-slot/__output__/angular/picker.ts index dd8020741..7a060f028 100644 --- a/tests/integration/cases/generics/scoped-slot/__output__/angular/picker.ts +++ b/tests/integration/cases/generics/scoped-slot/__output__/angular/picker.ts @@ -16,7 +16,7 @@ export interface PickerProps { } /** What a template of `Picker`'s `item` slot reads: the slot's props. */ -export type PickerItemContext = { item: T }; +export type PickerItemContext = { item: any }; /** A template of `Picker`'s `item` slot, its `let-` variables typed (ADR-0056). */ @Directive({ selector: "ng-template[ufPickerItem]" }) @@ -76,7 +76,7 @@ export class ScopeAttributes implements OnInit { `, }) -export default class Picker { +export default class Picker { readonly items = input.required(); readonly class = input(); readonly style = input>(); diff --git a/tests/integration/cases/generics/select/__output__/angular/select.ts b/tests/integration/cases/generics/select/__output__/angular/select.ts index 17519af9c..1368ccb97 100644 --- a/tests/integration/cases/generics/select/__output__/angular/select.ts +++ b/tests/integration/cases/generics/select/__output__/angular/select.ts @@ -55,14 +55,14 @@ export class ScopeAttributes implements OnInit { [ufScope]="this.host" >{{ label }}&ngsp; `, }) -export default class Select { +export default class Select { readonly label = input.required(); readonly options = input.required(); readonly class = input(); diff --git a/tests/integration/cases/types/child-type-import/__output__/angular/form.ts b/tests/integration/cases/types/child-type-import/__output__/angular/form.ts index e859ef782..b7c271b3f 100644 --- a/tests/integration/cases/types/child-type-import/__output__/angular/form.ts +++ b/tests/integration/cases/types/child-type-import/__output__/angular/form.ts @@ -1,4 +1,4 @@ -import Field from "./field"; +import Field, { type FieldProps } from "./field"; import { Component, Directive, diff --git a/tests/integration/cases/types/exported-types/__output__/angular/button.ts b/tests/integration/cases/types/exported-types/__output__/angular/button.ts index cdde86b31..4a05d78d8 100644 --- a/tests/integration/cases/types/exported-types/__output__/angular/button.ts +++ b/tests/integration/cases/types/exported-types/__output__/angular/button.ts @@ -10,6 +10,8 @@ import { export type ButtonTone = "primary" | "secondary"; +export type ButtonSize = "small" | "large"; + export interface ButtonProps { label: string; tone?: ButtonTone; diff --git a/tests/integration/cases/types/extends-chain/__output__/angular/button.ts b/tests/integration/cases/types/extends-chain/__output__/angular/button.ts index 06668a999..6e1bb619d 100644 --- a/tests/integration/cases/types/extends-chain/__output__/angular/button.ts +++ b/tests/integration/cases/types/extends-chain/__output__/angular/button.ts @@ -1,3 +1,4 @@ +import type { ButtonProps } from "./types"; import { Component, Directive, @@ -53,11 +54,12 @@ export class ScopeAttributes implements OnInit { `, }) export default class Button { - readonly disabled = input(false, { - transform: (value) => (value === undefined ? false : value), - }); - readonly label = input.required(); - readonly id = input.required(); + readonly disabled = input, ButtonProps["disabled"]>( + false, + { transform: (value) => (value === undefined ? false : value) }, + ); + readonly label = input.required(); + readonly id = input.required(); readonly class = input(); readonly style = input>(); protected readonly host = inject>(ElementRef).nativeElement; diff --git a/tests/integration/cases/types/imported-interface/__output__/angular/badge.ts b/tests/integration/cases/types/imported-interface/__output__/angular/badge.ts index 77930b73e..05aa87ff6 100644 --- a/tests/integration/cases/types/imported-interface/__output__/angular/badge.ts +++ b/tests/integration/cases/types/imported-interface/__output__/angular/badge.ts @@ -1,3 +1,4 @@ +import type { BadgeProps } from "./types"; import { Component, Directive, @@ -49,8 +50,8 @@ export class ScopeAttributes implements OnInit { `, }) export default class Badge { - readonly label = input.required(); - readonly tone = input<"info" | "danger", "info" | "danger" | undefined>("info", { + readonly label = input.required(); + readonly tone = input, BadgeProps["tone"]>("info", { transform: (value) => (value === undefined ? "info" : value), }); readonly class = input(); diff --git a/tests/integration/cases/types/intersection-props/__output__/angular/chip.ts b/tests/integration/cases/types/intersection-props/__output__/angular/chip.ts index 16fe80cd1..dff502d76 100644 --- a/tests/integration/cases/types/intersection-props/__output__/angular/chip.ts +++ b/tests/integration/cases/types/intersection-props/__output__/angular/chip.ts @@ -1,3 +1,4 @@ +import type { Sized, Toned } from "./types"; import { Component, Directive, @@ -8,6 +9,8 @@ import { input, } from "@angular/core"; +type ChipProps = Sized & Toned & { label: string }; + /** The scope attributes taken off each host, for the roots its component renders later. */ const scopes = new WeakMap(); @@ -50,8 +53,8 @@ export class ScopeAttributes implements OnInit { `, }) export default class Chip { - readonly size = input.required<"small" | "large">(); - readonly tone = input<"neutral" | "accent", "neutral" | "accent" | undefined>("neutral", { + readonly size = input.required(); + readonly tone = input, ChipProps["tone"]>("neutral", { transform: (value) => (value === undefined ? "neutral" : value), }); readonly label = input.required(); diff --git a/tests/integration/cases/types/package-types/__output__/angular/status.ts b/tests/integration/cases/types/package-types/__output__/angular/status.ts index 13ae5b8fe..a589dff12 100644 --- a/tests/integration/cases/types/package-types/__output__/angular/status.ts +++ b/tests/integration/cases/types/package-types/__output__/angular/status.ts @@ -1,3 +1,4 @@ +import type { Labelled } from "./types"; import { Component, Directive, @@ -7,6 +8,9 @@ import { inject, input, } from "@angular/core"; +import type { TimeInterval } from "rxjs"; + +type StatusProps = TimeInterval & Labelled; /** The scope attributes taken off each host, for the roots its component renders later. */ const scopes = new WeakMap(); @@ -50,9 +54,9 @@ export class ScopeAttributes implements OnInit { `, }) export default class Status { - readonly value = input.required(); - readonly interval = input.required(); - readonly label = input.required