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/html-program-viewer"
---

Fix html program viewer crash when rendering search results with missing type kind.
38 changes: 35 additions & 3 deletions packages/compiler/src/core/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
SyntaxKind,
Type,
TypeSpecDiagnosticTarget,
Value,
} from "./types.js";

export type WriteLine = (text?: string) => void;
Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
Expand All @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions packages/compiler/test/core/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -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);
});
});
});
22 changes: 22 additions & 0 deletions packages/html-program-viewer/src/react/tree-navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<NodeIcon
node={{
kind: "type",
id: "$.broken",
name: "broken",
type: { kind: undefined } as unknown as Type,
children: [],
}}
/>,
);

expect(screen.getByText("?")).toBeDefined();
});
});
6 changes: 4 additions & 2 deletions packages/html-program-viewer/src/react/tree-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ export const TreeNavigation = (_: TreeNavigationProps) => {

export const NodeIcon = ({ node }: { node: TypeGraphNode }) => {
switch (node.kind) {
case "type":
return <span className={style["type-kind-icon"]}>{node.type.kind[0]}</span>;
case "type": {
const kindPrefix = node.type?.kind?.[0] ?? "?";
return <span className={style["type-kind-icon"]}>{kindPrefix}</span>;
}
case "list":
return <AppsListRegular />;
}
Expand Down
Loading