Skip to content

Commit 44e5e41

Browse files
fix(compiler): correct type names for declaration expressions
Anonymous declarations used in expression position rendered with a stray namespace prefix (e.g. `Ns.` for enum/scalar, `Ns.{ x: string }` for keyword-form model). Render them inline and un-prefixed, mirroring union expression naming. Also extract a single shared `isDeclarationInExpressionPosition` helper used by both the binder and checker so the two position predicates cannot drift, and add regression tests (type names, keyword-form union as `|` operand, template parameter referenced inside an expression declaration).
1 parent f18990d commit 44e5e41

5 files changed

Lines changed: 98 additions & 32 deletions

File tree

packages/compiler/src/core/binder.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { mutate } from "../utils/misc.js";
22
import { compilerAssert } from "./diagnostics.js";
33
import { getLocationContext } from "./helpers/location-context.js";
4+
import { isDeclarationInExpressionPosition } from "./helpers/syntax-utils.js";
45
import { visitChildren } from "./parser.js";
56
import type { Program } from "./program.js";
67
import {
@@ -397,12 +398,7 @@ export function createBinder(program: Program): Binder {
397398
* position. Anonymous declarations are always in expression position.
398399
*/
399400
function isDeclarationStatementPosition(node: Node): boolean {
400-
const parent = node.parent;
401-
return (
402-
parent?.kind === SyntaxKind.NamespaceStatement ||
403-
parent?.kind === SyntaxKind.TypeSpecScript ||
404-
parent?.kind === SyntaxKind.JsSourceFile
405-
);
401+
return !isDeclarationInExpressionPosition(node);
406402
}
407403

408404
function bindModelStatement(node: ModelStatementNode) {

packages/compiler/src/core/checker.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { validateInheritanceDiscriminatedUnions } from "./helpers/discriminator-
2121
import { getLocationContext } from "./helpers/location-context.js";
2222
import { explainStringTemplateNotSerializable } from "./helpers/string-template-utils.js";
2323
import {
24+
isDeclarationInExpressionPosition,
2425
printIdentifier,
2526
printMemberExpressionPath,
2627
typeReferenceToString,
@@ -5006,22 +5007,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
50065007
}
50075008
}
50085009

5009-
/**
5010-
* Determine whether a declaration node (model/enum/union/scalar) appears in
5011-
* expression position (e.g. as the value of an alias or a property type) rather
5012-
* than as a top-level statement in a namespace or file. Anonymous declarations
5013-
* (without an `id`) are always in expression position.
5014-
*/
5015-
function isDeclarationInExpressionPosition(
5016-
node: ModelStatementNode | EnumStatementNode | UnionStatementNode | ScalarStatementNode,
5017-
): boolean {
5018-
const parent = node.parent;
5019-
return (
5020-
parent === undefined ||
5021-
(parent.kind !== SyntaxKind.NamespaceStatement && parent.kind !== SyntaxKind.TypeSpecScript)
5022-
);
5023-
}
5024-
50255010
/**
50265011
* A declaration used in expression position is anonymous and cannot be referenced or
50275012
* instantiated, so template parameters on it are meaningless. Report a diagnostic when present.

packages/compiler/src/core/helpers/syntax-utils.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,31 @@
11
import { CharCode, isIdentifierContinue, isIdentifierStart, utf16CodeUnits } from "../charcode.js";
22
import { isModifier, Keywords, ReservedKeywords } from "../scanner.js";
3-
import { IdentifierNode, MemberExpressionNode, SyntaxKind, TypeReferenceNode } from "../types.js";
3+
import {
4+
IdentifierNode,
5+
MemberExpressionNode,
6+
Node,
7+
SyntaxKind,
8+
TypeReferenceNode,
9+
} from "../types.js";
10+
11+
/**
12+
* Determine whether a declaration node (model/enum/union/scalar) appears in expression
13+
* position (e.g. as an alias value or a property type) rather than as a top-level
14+
* statement directly under a namespace or source file. Anonymous declarations (used as
15+
* expressions) are always in expression position.
16+
*
17+
* This is the single source of truth shared by the binder and checker so the two cannot
18+
* drift apart.
19+
*/
20+
export function isDeclarationInExpressionPosition(node: Node): boolean {
21+
const parent = node.parent;
22+
return (
23+
parent === undefined ||
24+
(parent.kind !== SyntaxKind.NamespaceStatement &&
25+
parent.kind !== SyntaxKind.TypeSpecScript &&
26+
parent.kind !== SyntaxKind.JsSourceFile)
27+
);
28+
}
429

530
/**
631
* Print a string as a TypeSpec identifier. If the string is a valid identifier, return it as is otherwise wrap it into backticks.

packages/compiler/src/core/helpers/type-name-utils.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,18 +162,31 @@ function getNamespacePrefix(type: Namespace | undefined, options?: TypeNameOptio
162162
}
163163

164164
function getEnumName(e: Enum, options: TypeNameOptions | undefined): string {
165-
return `${getNamespacePrefix(e.namespace, options)}${getIdentifierName(e.name, options)}`;
165+
// An enum used in expression position is anonymous; render its members inline
166+
// instead of a (namespace-prefixed) name.
167+
if (e.name === "") {
168+
return `{ ${[...e.members.values()].map((m) => m.name).join(", ")} }`;
169+
}
170+
const nsPrefix = e.expression ? "" : getNamespacePrefix(e.namespace, options);
171+
return `${nsPrefix}${getIdentifierName(e.name, options)}`;
166172
}
167173

168174
function getScalarName(scalar: Scalar, options: TypeNameOptions | undefined): string {
169-
return `${getNamespacePrefix(scalar.namespace, options)}${getIdentifierName(
170-
scalar.name,
171-
options,
172-
)}`;
175+
// A scalar used in expression position is anonymous; render what it extends
176+
// (there is no inline literal syntax for it) instead of a namespace-only name.
177+
if (scalar.name === "") {
178+
return scalar.baseScalar
179+
? `scalar extends ${getTypeName(scalar.baseScalar, options)}`
180+
: "scalar";
181+
}
182+
const nsPrefix = scalar.expression ? "" : getNamespacePrefix(scalar.namespace, options);
183+
return `${nsPrefix}${getIdentifierName(scalar.name, options)}`;
173184
}
174185

175186
function getModelName(model: Model, options: TypeNameOptions | undefined) {
176-
const nsPrefix = getNamespacePrefix(model.namespace, options);
187+
// Declarations used in expression position are anonymous and not addressable, so
188+
// they should not be namespace-qualified (mirrors union expression naming).
189+
const nsPrefix = model.expression ? "" : getNamespacePrefix(model.namespace, options);
177190
if (model.name === "" && model.properties.size === 0) {
178191
return "{}";
179192
}

packages/compiler/test/checker/declaration-expressions.test.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,23 @@ describe("union", () => {
6868
expect(type.variants.has("bar")).toBe(true);
6969
});
7070

71+
it("keeps its members when used as a `|` operand instead of being flattened", async () => {
72+
// Regression: a keyword-form union is `expression: true`; it must not be flattened
73+
// into the parent `|` union (which would silently drop colliding named variants).
74+
const { Foo } = await Tester.compile(t.code`
75+
model ${t.model("Foo")} {
76+
value: union { a: "a1", b: "b1" } | union { a: "a2", c: "c1" };
77+
}
78+
`);
79+
const type = Foo.properties.get("value")!.type as Union;
80+
expect(type.kind).toBe("Union");
81+
expect(type.variants.size).toBe(2);
82+
for (const variant of type.variants.values()) {
83+
expect((variant.type as Union).kind).toBe("Union");
84+
expect((variant.type as Union).variants.size).toBe(2);
85+
}
86+
});
87+
7188
it("is not registered in the namespace", async () => {
7289
const { program } = await Tester.compile(`
7390
namespace Ns;
@@ -246,6 +263,21 @@ describe("usage contexts", () => {
246263
expect((Foo.properties.get("m")!.type as Model).expression).toBe(true);
247264
});
248265

266+
it("can reference an enclosing template parameter", async () => {
267+
const { Bar } = await Tester.compile(t.code`
268+
model Wrapper<T> {
269+
nested: model { item: T };
270+
}
271+
model ${t.model("Bar")} {
272+
w: Wrapper<int32>;
273+
}
274+
`);
275+
const wrapper = Bar.properties.get("w")!.type as Model;
276+
const nested = wrapper.properties.get("nested")!.type as Model;
277+
expect(nested.expression).toBe(true);
278+
expect((nested.properties.get("item")!.type as Scalar).name).toBe("int32");
279+
});
280+
249281
it("can be used as an operation return type", async () => {
250282
const { test } = await Tester.compile(t.code`
251283
op ${t.op("test")}(): enum { a, b };
@@ -313,14 +345,29 @@ describe("usage contexts", () => {
313345
});
314346

315347
describe("type name", () => {
316-
it("renders an anonymous expression with an empty name", async () => {
348+
it("renders anonymous expressions inline and is not namespace-qualified", async () => {
317349
const { Foo } = await Tester.compile(t.code`
350+
namespace Ns;
351+
model ${t.model("Foo")} {
352+
modelProp: model { x: string };
353+
enumProp: enum { a, b };
354+
scalarProp: scalar extends string;
355+
unionProp: union { string, int32 };
356+
}
357+
`);
358+
expect(getTypeName(Foo.properties.get("modelProp")!.type)).toBe("{ x: string }");
359+
expect(getTypeName(Foo.properties.get("enumProp")!.type)).toBe("{ a, b }");
360+
expect(getTypeName(Foo.properties.get("scalarProp")!.type)).toBe("scalar extends string");
361+
expect(getTypeName(Foo.properties.get("unionProp")!.type)).toBe("string | int32");
362+
});
363+
364+
it("renders a named expression by its name without a namespace prefix", async () => {
365+
const { Foo } = await Tester.compile(t.code`
366+
namespace Ns;
318367
model ${t.model("Foo")} {
319-
anon: enum { a, b };
320368
named: enum Color { red };
321369
}
322370
`);
323-
expect(getTypeName(Foo.properties.get("anon")!.type)).toBe("");
324371
expect(getTypeName(Foo.properties.get("named")!.type)).toBe("Color");
325372
});
326373
});

0 commit comments

Comments
 (0)