diff --git a/.chronus/changes/copilot-fix-html-program-viewer-crash-2026-5-8-19-28-50.md b/.chronus/changes/copilot-fix-html-program-viewer-crash-2026-5-8-19-28-50.md new file mode 100644 index 00000000000..4197ab2f74f --- /dev/null +++ b/.chronus/changes/copilot-fix-html-program-viewer-crash-2026-5-8-19-28-50.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/html-program-viewer" +--- + +Fix html program viewer crash when rendering search results with missing type kind. \ No newline at end of file diff --git a/packages/compiler/src/core/diagnostics.ts b/packages/compiler/src/core/diagnostics.ts index 180661c70d7..e7a2c441f87 100644 --- a/packages/compiler/src/core/diagnostics.ts +++ b/packages/compiler/src/core/diagnostics.ts @@ -16,6 +16,7 @@ import { SyntaxKind, Type, TypeSpecDiagnosticTarget, + Value, } from "./types.js"; export type WriteLine = (text?: string) => void; @@ -51,12 +52,12 @@ export function getRelatedLocations(diagnostic: Diagnostic): RelatedSourceLocati * - For template instance targets: returns the node of the template declaration * - For symbols: returns the first declaration node (or symbol source for using symbols) * - For AST nodes: returns the node itself - * - For types: returns the node associated with the type + * - For entities: returns the most relevant node associated with the entity * * @param target The diagnostic target to extract a node from. Can be a template instance, * symbol, AST node, or type. - * @returns The AST node associated with the target, or undefined if the target is a type - * or symbol that doesn't have an associated node. + * @returns The AST node associated with the target, or undefined if the target + * doesn't have an associated node. */ export function getNodeForTarget(target: TypeSpecDiagnosticTarget): Node | undefined { if (!("kind" in target) && !("entityKind" in target)) { @@ -71,6 +72,23 @@ export function getNodeForTarget(target: TypeSpecDiagnosticTarget): Node | undef } return target.declarations[0]; + } else if ("entityKind" in target) { + switch (target.entityKind) { + case "Type": + return target.node; + case "Value": + return getValueNode(target) ?? target.type.node; + case "MixedParameterConstraint": + // Prefer the explicit union expression node when present, otherwise fall back + // to a side of the constraint that has a source node. Type is preferred + // over valueType to keep location behavior stable for mixed constraints + // that include both branches. + return target.node ?? target.type?.node ?? target.valueType?.node; + case "Indeterminate": + return target.type.node; + default: + return undefined; + } } else if ("kind" in target && typeof target.kind === "number") { // node return target as Node; @@ -80,6 +98,20 @@ export function getNodeForTarget(target: TypeSpecDiagnosticTarget): Node | undef } } +function getValueNode(value: Value): Node | undefined { + // Only compound values and function values carry their own syntax node. + // Primitive values (string/number/boolean/null/enum/scalar literal values) + // are represented by their resolved value/type and don't retain a direct node. + switch (value.valueKind) { + case "ObjectValue": + case "ArrayValue": + case "Function": + return value.node; + default: + return undefined; + } +} + export interface SourceLocationOptions { /** * If trying to resolve the location of a type with an ID, show the location of the ID node instead of the entire type. diff --git a/packages/compiler/test/core/diagnostics.test.ts b/packages/compiler/test/core/diagnostics.test.ts index 179b04b8f3d..c55b96d6e29 100644 --- a/packages/compiler/test/core/diagnostics.test.ts +++ b/packages/compiler/test/core/diagnostics.test.ts @@ -3,6 +3,8 @@ import { describe, it } from "vitest"; import { SourceLocationOptions, getSourceLocation } from "../../src/index.js"; import { extractSquiggles } from "../../src/testing/source-utils.js"; import { Tester } from "../tester.js"; +import { getNodeForTarget } from "../../src/core/diagnostics.js"; +import { SyntaxKind } from "../../src/core/types.js"; describe("compiler: diagnostics", () => { async function expectLocationMatch(code: string, options: SourceLocationOptions = {}) { @@ -34,4 +36,82 @@ describe("compiler: diagnostics", () => { { locateId: true }, )); }); + + describe("getNodeForTarget", () => { + const mockSyntaxKindA = SyntaxKind.ModelStatement; + const mockSyntaxKindB = SyntaxKind.ScalarStatement; + const mockSyntaxKindC = SyntaxKind.NamespaceStatement; + + it("returns function value node when available", () => { + const valueNode = { kind: mockSyntaxKindA } as any; + const typeNode = { kind: mockSyntaxKindB } as any; + + const target = { + entityKind: "Value", + valueKind: "Function", + node: valueNode, + type: { kind: "FunctionType", node: typeNode }, + } as any; + + strictEqual(getNodeForTarget(target), valueNode); + }); + + it("falls back to value type node when value has no node", () => { + const typeNode = { kind: mockSyntaxKindC } as any; + + const target = { + entityKind: "Value", + valueKind: "StringValue", + type: { kind: "String", node: typeNode }, + } as any; + + strictEqual(getNodeForTarget(target), typeNode); + }); + + it("returns object value node when available", () => { + const valueNode = { kind: mockSyntaxKindB } as any; + + const target = { + entityKind: "Value", + valueKind: "ObjectValue", + node: valueNode, + type: { kind: "Model", node: undefined }, + } as any; + + strictEqual(getNodeForTarget(target), valueNode); + }); + + it("resolves mixed parameter constraint target in priority order", () => { + const explicitNode = { kind: mockSyntaxKindA } as any; + const typeNode = { kind: mockSyntaxKindB } as any; + + strictEqual( + getNodeForTarget({ + entityKind: "MixedParameterConstraint", + node: explicitNode, + type: { kind: "Model", node: typeNode }, + } as any), + explicitNode, + ); + + strictEqual( + getNodeForTarget({ + entityKind: "MixedParameterConstraint", + type: { kind: "Model", node: typeNode }, + } as any), + typeNode, + ); + }); + + it("resolves indeterminate target to underlying type node", () => { + const typeNode = { kind: mockSyntaxKindC } as any; + + const target = { + entityKind: "Indeterminate", + type: { kind: "String", node: typeNode }, + } as any; + + strictEqual(getNodeForTarget(target), typeNode); + }); + }); }); diff --git a/packages/html-program-viewer/src/react/tree-navigation.test.tsx b/packages/html-program-viewer/src/react/tree-navigation.test.tsx new file mode 100644 index 00000000000..afdd402c088 --- /dev/null +++ b/packages/html-program-viewer/src/react/tree-navigation.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import type { Type } from "@typespec/compiler"; +import { describe, expect, it } from "vitest"; +import { NodeIcon } from "./tree-navigation.js"; + +describe("NodeIcon", () => { + it("falls back when type kind is missing", () => { + render( + , + ); + + expect(screen.getByText("?")).toBeDefined(); + }); +}); diff --git a/packages/html-program-viewer/src/react/tree-navigation.tsx b/packages/html-program-viewer/src/react/tree-navigation.tsx index e0c0eefa9f7..2934d86b60a 100644 --- a/packages/html-program-viewer/src/react/tree-navigation.tsx +++ b/packages/html-program-viewer/src/react/tree-navigation.tsx @@ -21,8 +21,10 @@ export const TreeNavigation = (_: TreeNavigationProps) => { export const NodeIcon = ({ node }: { node: TypeGraphNode }) => { switch (node.kind) { - case "type": - return {node.type.kind[0]}; + case "type": { + const kindPrefix = node.type?.kind?.[0] ?? "?"; + return {kindPrefix}; + } case "list": return ; }