diff --git a/.chronus/changes/spread-source-2024-3-18-18-19-45.md b/.chronus/changes/spread-source-2024-3-18-18-19-45.md new file mode 100644 index 0000000000..fef1e45bfd --- /dev/null +++ b/.chronus/changes/spread-source-2024-3-18-18-19-45.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/compiler" +--- + +[API] Add new `sourceModels` property to model diff --git a/.chronus/changes/spread-source-2024-3-18-18-49-3.md b/.chronus/changes/spread-source-2024-3-18-18-49-3.md new file mode 100644 index 0000000000..0a1edab54f --- /dev/null +++ b/.chronus/changes/spread-source-2024-3-18-18-49-3.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/html-program-viewer" +--- + +Add `sourceModels` property to model view diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 82d063c86a..ab97ef8fb3 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -1585,6 +1585,7 @@ export function createChecker(program: Program): Checker { properties: properties, decorators: [], derivedModels: [], + sourceModels: [], }); const indexers: ModelIndexer[] = []; @@ -1617,6 +1618,7 @@ export function createChecker(program: Program): Checker { } } for (const [_, option] of modelOptions) { + intersection.sourceModels.push({ usage: "intersection", model: option }); const allProps = walkPropertiesInherited(option); for (const prop of allProps) { if (properties.has(prop.name)) { @@ -2746,6 +2748,7 @@ export function createChecker(program: Program): Checker { properties: createRekeyableMap(), namespace: getParentNamespaceType(node), decorators, + sourceModels: [], derivedModels: [], }); linkType(links, type, mapper); @@ -2753,6 +2756,7 @@ export function createChecker(program: Program): Checker { if (isBase) { type.sourceModel = isBase; + type.sourceModels.push({ usage: "is", model: isBase }); // copy decorators decorators.push(...isBase.decorators); if (isBase.indexer) { @@ -2840,6 +2844,7 @@ export function createChecker(program: Program): Checker { namespace: getParentNamespaceType(node), decorators: [], derivedModels: [], + sourceModels: [], }); checkModelProperties(node, properties, type, mapper); return finishType(type); @@ -2920,6 +2925,7 @@ export function createChecker(program: Program): Checker { parentModel, mapper ); + if (additionalIndexer) { if (spreadIndexers) { spreadIndexers.push(additionalIndexer); @@ -3452,6 +3458,8 @@ export function createChecker(program: Program): Checker { ); } + parentModel.sourceModels.push({ usage: "spread", model: targetType }); + const props: ModelProperty[] = []; // copy each property for (const prop of walkPropertiesInherited(targetType)) { @@ -4842,6 +4850,7 @@ export function createChecker(program: Program): Checker { decorators: [], properties: createRekeyableMap(), derivedModels: [], + sourceModels: [], }); for (const propNode of node.properties) { @@ -6165,6 +6174,7 @@ export function filterModelProperties( properties, decorators: [], derivedModels: [], + sourceModels: [{ usage: "spread", model }], }); for (const property of walkPropertiesInherited(model)) { diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 0f88c1e76e..9048d8f73b 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -265,6 +265,11 @@ export interface Model extends BaseType, DecoratedType, TemplatedTypeBase { */ sourceModel?: Model; + /** + * Models that were used to build this model. This include any model referenced in `model is`, `...` or when intersecting models. + */ + sourceModels: SourceModel[]; + /** * Late-bound symbol of this model type. * @internal @@ -272,6 +277,18 @@ export interface Model extends BaseType, DecoratedType, TemplatedTypeBase { symbol?: Sym; } +export interface SourceModel { + /** + * How was this model used. + * - is: `model A is B` + * - spread: `model A {...B}` + * - intersection: `alias A = B & C` + */ + readonly usage: "is" | "spread" | "intersection"; + /** Source model */ + readonly model: Model; +} + export interface ModelProperty extends BaseType, DecoratedType { kind: "ModelProperty"; node: diff --git a/packages/compiler/test/checker/intersections.test.ts b/packages/compiler/test/checker/intersections.test.ts index 94a9dcad27..7210f2ccf1 100644 --- a/packages/compiler/test/checker/intersections.test.ts +++ b/packages/compiler/test/checker/intersections.test.ts @@ -1,6 +1,6 @@ import { ok, strictEqual } from "assert"; -import { beforeEach, describe, it } from "vitest"; -import { Model } from "../../src/core/index.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Model, ModelProperty } from "../../src/core/index.js"; import { BasicTestRunner, createTestHost, @@ -18,17 +18,38 @@ describe("compiler: intersections", () => { }); it("intersect 2 models", async () => { - const { Foo } = (await runner.compile(` - @test model Foo { - prop: {a: string} & {b: string}; + const { prop } = (await runner.compile(` + model Foo { + @test prop: {a: string} & {b: string}; } - `)) as { Foo: Model }; + `)) as { prop: ModelProperty }; - const prop = Foo.properties.get("prop")!.type as Model; - strictEqual(prop.kind, "Model"); - strictEqual(prop.properties.size, 2); - ok(prop.properties.has("a")); - ok(prop.properties.has("b")); + const propType = prop.type; + strictEqual(propType.kind, "Model"); + strictEqual(propType.properties.size, 2); + ok(propType.properties.has("a")); + ok(propType.properties.has("b")); + }); + + it("keeps reference to source model in sourceModels", async () => { + const { A, B, prop } = (await runner.compile(` + @test model A { one: string } + @test model B { two: string } + model Foo { + @test prop: A & B; + } + `)) as { + A: Model; + B: Model; + prop: ModelProperty; + }; + const intersection = prop.type; + strictEqual(intersection.kind, "Model"); + expect(intersection.sourceModels).toHaveLength(2); + strictEqual(intersection.sourceModels[0].model, A); + strictEqual(intersection.sourceModels[0].usage, "intersection"); + strictEqual(intersection.sourceModels[1].model, B); + strictEqual(intersection.sourceModels[1].usage, "intersection"); }); it("intersection type belong to namespace it is declared in", async () => { diff --git a/packages/compiler/test/checker/model.test.ts b/packages/compiler/test/checker/model.test.ts index 5cdaefcd88..af978cd9b1 100644 --- a/packages/compiler/test/checker/model.test.ts +++ b/packages/compiler/test/checker/model.test.ts @@ -1,5 +1,5 @@ import { deepStrictEqual, match, ok, strictEqual } from "assert"; -import { beforeEach, describe, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { isTemplateDeclaration } from "../../src/core/type-utils.js"; import { Model, ModelProperty, Type } from "../../src/core/types.js"; import { Operation, getDoc, isArrayModelType, isRecordModelType } from "../../src/index.js"; @@ -565,11 +565,10 @@ describe("compiler: models", () => { }); }); - it("keeps reference to source model", async () => { + it("keeps reference to source model in sourceModel", async () => { testHost.addTypeSpecFile( "main.tsp", ` - import "./dec.js"; @test model A { } @test model B is A { }; ` @@ -578,6 +577,20 @@ describe("compiler: models", () => { strictEqual(B.sourceModel, A); }); + it("keeps reference to source model in sourceModels", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + @test model A { } + @test model B is A { }; + ` + ); + const { A, B } = (await testHost.compile("main.tsp")) as { A: Model; B: Model }; + expect(B.sourceModels).toHaveLength(1); + strictEqual(B.sourceModels[0].model, A); + strictEqual(B.sourceModels[0].usage, "is"); + }); + it("copies decorators", async () => { testHost.addTypeSpecFile( "main.tsp", @@ -851,6 +864,23 @@ describe("compiler: models", () => { strictEqual(getDoc(testHost.program, Base.properties.get("one")!), "base doc"); }); + it("keeps reference to source model in sourceModels", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + @test model A { one: string } + @test model B { two: string } + @test model C {...A, ...B} + ` + ); + const { A, B, C } = (await testHost.compile("main.tsp")) as { A: Model; B: Model; C: Model }; + expect(C.sourceModels).toHaveLength(2); + strictEqual(C.sourceModels[0].model, A); + strictEqual(C.sourceModels[0].usage, "spread"); + strictEqual(C.sourceModels[1].model, B); + strictEqual(C.sourceModels[1].usage, "spread"); + }); + it("can spread a Record", async () => { testHost.addTypeSpecFile( "main.tsp", diff --git a/packages/html-program-viewer/src/ui.tsx b/packages/html-program-viewer/src/ui.tsx index 943fb26d5e..031436ae99 100644 --- a/packages/html-program-viewer/src/ui.tsx +++ b/packages/html-program-viewer/src/ui.tsx @@ -255,6 +255,7 @@ const ModelUI: FunctionComponent<{ type: Model }> = ({ type }) => { derivedModels: "ref", properties: "nested", sourceModel: "ref", + sourceModels: "value", }} /> );