Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/compiler"
---

Fix `Expected type.` internal compiler error when a string template interpolates a function call that references a template parameter on a template declaration (e.g. `@doc("${myFn(T)}") model Crud<T extends Reflection.Model> {}`). The deferred function call now defers the whole template, which is evaluated at instantiation.
61 changes: 36 additions & 25 deletions packages/compiler/src/core/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ import {
StringTemplateMiddleNode,
StringTemplateSpan,
StringTemplateSpanLiteral,
StringTemplateSpanNode,
StringTemplateSpanValue,
StringTemplateTailNode,
StringValue,
Expand Down Expand Up @@ -4677,31 +4678,42 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
): IndeterminateEntity | StringValue | null {
let hasType = false;
let hasValue = false;
const spanTypeOrValues = node.spans.map(
(span) => [span, checkNode(ctx, span.expression)] as const,
);
const spanTypeOrValues: (readonly [
StringTemplateSpanNode,
Type | Value | IndeterminateEntity,
])[] = [];
for (const span of node.spans) {
const typeOrValue = checkNode(ctx, span.expression);
// A null span is the value-world equivalent of `errorType`: the expression couldn't
// produce a usable value (e.g. an already-reported error, or a function call that can't
// be evaluated yet in a template declaration). Like `checkArrayValue`/`checkObjectValue`,
// propagate the null up so the whole template resolves to null instead of fabricating a
// partial string. It is re-evaluated against the real value when the template is instantiated.
if (typeOrValue === null) {
return null;
}
spanTypeOrValues.push([span, typeOrValue]);
}

for (const [_, typeOrValue] of spanTypeOrValues) {
if (typeOrValue !== null) {
if (isValue(typeOrValue)) {
hasValue = true;
} else if (
"kind" in typeOrValue &&
(typeOrValue.kind === "TemplateParameter" ||
typeOrValue.kind === "TemplateParameterAccess")
) {
if (typeOrValue.constraint) {
if (typeOrValue.constraint.valueType) {
hasValue = true;
}
if (typeOrValue.constraint.type) {
hasType = true;
}
} else {
if (isValue(typeOrValue)) {
hasValue = true;
} else if (
"kind" in typeOrValue &&
(typeOrValue.kind === "TemplateParameter" || typeOrValue.kind === "TemplateParameterAccess")
) {
if (typeOrValue.constraint) {
if (typeOrValue.constraint.valueType) {
hasValue = true;
}
if (typeOrValue.constraint.type) {
hasType = true;
}
} else {
hasType = true;
}
} else {
hasType = true;
}
}

Expand All @@ -4719,12 +4731,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
let str = node.head.value;
for (const [span, typeOrValue] of spanTypeOrValues) {
if (
typeOrValue !== null &&
(!("kind" in typeOrValue) ||
(typeOrValue.kind !== "TemplateParameter" &&
typeOrValue.kind !== "TemplateParameterAccess"))
!("kind" in typeOrValue) ||
(typeOrValue.kind !== "TemplateParameter" &&
typeOrValue.kind !== "TemplateParameterAccess")
) {
compilerAssert(typeOrValue !== null && isValue(typeOrValue), "Expected value.");
compilerAssert(isValue(typeOrValue), "Expected value.");
str += stringifyValueForTemplate(typeOrValue);
}
str += span.literal.value;
Expand All @@ -4737,7 +4748,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
const spans: StringTemplateSpan[] = [createTemplateSpanLiteral(node.head)];

for (const [span, typeOrValue] of spanTypeOrValues) {
compilerAssert(typeOrValue !== null && !isValue(typeOrValue), "Expected type.");
compilerAssert(!isValue(typeOrValue), "Expected type.");

const type = typeOrValue.entityKind === "Indeterminate" ? typeOrValue.type : typeOrValue;
const spanValue = createTemplateSpanValue(span.expression, type);
Expand Down
45 changes: 44 additions & 1 deletion packages/compiler/test/checker/string-template.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { strictEqual } from "assert";
import { describe, it } from "vitest";
import { StringTemplate } from "../../src/index.js";
import { getDoc, StringTemplate } from "../../src/index.js";
import {
expectDiagnosticEmpty,
expectDiagnostics,
extractSquiggles,
mockFile,
t,
} from "../../src/testing/index.js";
import { Tester } from "../tester.js";
Expand Down Expand Up @@ -164,3 +165,45 @@ it("emit error if interpolating template access that mixes values and types", as
end,
});
});

describe("interpolating a function call referencing a template parameter", () => {
const fnTester = Tester.files({
"fn.js": mockFile.js({
$functions: {
"": {
getName: (_ctx: unknown, type: { name?: string }) => type?.name ?? "deferred",
},
},
}),
})
.import("./fn.js")
.using("TypeSpec.Reflection");

it("does not crash when used on a template declaration", async () => {
const diagnostics = await fnTester.diagnose(`
#suppress "experimental-feature" "test"
extern fn getName(type: unknown): valueof string;

@doc("\${getName(T)}")
model Crud<T extends Model> {}
`);
expectDiagnosticEmpty(diagnostics);
});

it("resolves the function call when the template is instantiated", async () => {
const { p, program } = await fnTester.compile(t.code`
#suppress "experimental-feature" "test"
extern fn getName(type: unknown): valueof string;

@doc("\${getName(T)}")
model Crud<T extends Model> {}

model Foo {}

model Holder {
${t.modelProperty("p")}: Crud<Foo>;
}
`);
strictEqual(getDoc(program, p.type), "Foo");
});
});
Loading