diff --git a/docs/specs/language-reference.md b/docs/specs/language-reference.md index 386a8ff..667b8bb 100644 --- a/docs/specs/language-reference.md +++ b/docs/specs/language-reference.md @@ -46,6 +46,18 @@ union Shape { Unions render visually distinct from records: dashed dividers between variants and a `|` pipe prefix on each variant row. +Variants can also pin an explicit numeric discriminant: + +``` +union ErrorCode { + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 +} +``` + +This is useful when the integer value is part of a wire contract, such as protocol error codes or FFI enums. + ### Generic unions ``` @@ -147,10 +159,11 @@ Record = "type" Name Generics? "{" Field* "}" Union = "union" Name Generics? "{" Variant* "}" Alias = "alias" Name Generics? "=" TypeRef Field = Name ":" TypeRef -Variant = Name ("{" Field* "}")? +Variant = Name ("=" Number)? ("{" Field* "}")? TypeRef = Name ("<" TypeRef ("," TypeRef)* ">")? Generics = "<" Name ("," Name)* ">" Name = [A-Za-z_][A-Za-z0-9_]* +Number = "-"? [0-9] ([0-9_]* [0-9])? ``` The grammar is LL(1) with ~6 productions. Newlines and commas both work as separators inside `{ }` blocks. diff --git a/packages/typediagram/README.md b/packages/typediagram/README.md index 7ce8f50..123d48a 100644 --- a/packages/typediagram/README.md +++ b/packages/typediagram/README.md @@ -49,6 +49,16 @@ Three constructs: Generics with ``. Comments with `#`. +Explicit numeric discriminants are supported on union variants: + +```td +union ErrorCode { + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 +} +``` + ## Subpath exports - `typediagram-core` — high-level `parse`, `layout`, `renderSvg` diff --git a/packages/typediagram/scripts/bundle-size.mjs b/packages/typediagram/scripts/bundle-size.mjs index 651a824..a8718c7 100644 --- a/packages/typediagram/scripts/bundle-size.mjs +++ b/packages/typediagram/scripts/bundle-size.mjs @@ -3,13 +3,16 @@ // budget. Uses esbuild to tree-shake and measure the output size. // Budget was 50 KB with 6 converters. Dart + Protobuf converters added // ~8-10 KB each of parser/emitter logic, so the budget was raised to 75 KB. +// Tuple variants, explicit discriminants, and untagged unions pushed the +// minified core slightly higher, and declaration-level target gating added +// parser + emitter filtering overhead, so the current budget is 80 KB. import { build } from "esbuild"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const entry = resolve(here, "..", "src", "index.ts"); -const BUDGET_KB = 75.5; +const BUDGET_KB = 80; const result = await build({ entryPoints: [entry], diff --git a/packages/typediagram/src/converters/csharp.ts b/packages/typediagram/src/converters/csharp.ts index 8bbc306..8e95fa1 100644 --- a/packages/typediagram/src/converters/csharp.ts +++ b/packages/typediagram/src/converters/csharp.ts @@ -8,7 +8,7 @@ // Option <-> T?. Alias <-> `using X = Y;`. import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import type { Model, ResolvedTypeRef } from "../model/types.js"; +import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; import { parseTypeRef } from "./parse-typeref.js"; @@ -325,7 +325,7 @@ const toCSharp = (model: Model): string => { // file scope wherever it appears, so keeping aliases in source order // preserves round-trip order at the cost of the more idiomatic // "usings-at-top" layout. - for (const d of model.decls) { + for (const d of visibleDeclsForTarget(model.decls, "csharp")) { if (d.kind === "record") { lines.push(...emitRecord(d.name, d.fields, d.generics), ""); } else if (d.kind === "union") { diff --git a/packages/typediagram/src/converters/dart.ts b/packages/typediagram/src/converters/dart.ts index afc14dd..1b94735 100644 --- a/packages/typediagram/src/converters/dart.ts +++ b/packages/typediagram/src/converters/dart.ts @@ -8,7 +8,7 @@ // Generics use Dart's first-class type parameters. import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import type { Model, ResolvedTypeRef } from "../model/types.js"; +import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; import { parseTypeRef } from "./parse-typeref.js"; @@ -362,7 +362,7 @@ const emitAlias = (name: string, target: ResolvedTypeRef, generics: string[]): s const toDart = (model: Model): string => { const lines: string[] = []; - for (const d of model.decls) { + for (const d of visibleDeclsForTarget(model.decls, "dart")) { if (d.kind === "record") { lines.push(...emitRecord(d.name, d.fields, d.generics), ""); } else if (d.kind === "union") { diff --git a/packages/typediagram/src/converters/fsharp.ts b/packages/typediagram/src/converters/fsharp.ts index 00b62b8..4ff8227 100644 --- a/packages/typediagram/src/converters/fsharp.ts +++ b/packages/typediagram/src/converters/fsharp.ts @@ -1,7 +1,7 @@ // [CONV-FS] F# <-> typeDiagram bidirectional converter. import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import type { Model, ResolvedTypeRef } from "../model/types.js"; +import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; import { parseTypeRef } from "./parse-typeref.js"; @@ -205,7 +205,7 @@ const mapTdToFs = (t: ResolvedTypeRef): string => { const toFSharp = (model: Model): string => { const lines: string[] = []; - for (const d of model.decls) { + for (const d of visibleDeclsForTarget(model.decls, "fsharp")) { const genericsStr = d.generics.length > 0 ? `<${d.generics.map((g) => `'${g}`).join(", ")}>` : ""; if (d.kind === "record") { diff --git a/packages/typediagram/src/converters/go.ts b/packages/typediagram/src/converters/go.ts index 6c1c082..68148ef 100644 --- a/packages/typediagram/src/converters/go.ts +++ b/packages/typediagram/src/converters/go.ts @@ -9,7 +9,7 @@ // Option <-> *T. Generics use Go 1.18+ type parameters (`[T any]`). import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import type { Model, ResolvedTypeRef } from "../model/types.js"; +import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; import { parseTypeRef } from "./parse-typeref.js"; @@ -358,7 +358,7 @@ const variantStructName = (unionName: string, variantName: string): string => `$ const toGo = (model: Model): string => { const lines: string[] = ["package types", ""]; - for (const d of model.decls) { + for (const d of visibleDeclsForTarget(model.decls, "go")) { if (d.kind === "record") { const gens = goGenericsDecl(d.generics); lines.push(`type ${d.name}${gens} struct {`); diff --git a/packages/typediagram/src/converters/php.ts b/packages/typediagram/src/converters/php.ts index a8ccd10..be81f76 100644 --- a/packages/typediagram/src/converters/php.ts +++ b/packages/typediagram/src/converters/php.ts @@ -1,7 +1,7 @@ // [CONV-PHP] PHP DTO <-> typeDiagram bidirectional converter. import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import type { Model, ResolvedTypeRef } from "../model/types.js"; +import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; import { ModelBuilder, alias, record, union } from "../model/builder.js"; import type { Converter } from "./types.js"; import { parseTypeRef } from "./parse-typeref.js"; @@ -315,7 +315,7 @@ const renderAlias = (name: string, generics: readonly string[], target: Resolved }; const toPhp = (model: Model): string => { - const blocks = model.decls.flatMap((decl) => { + const blocks = visibleDeclsForTarget(model.decls, "php").flatMap((decl) => { if (decl.kind === "record") { return [renderRecord(decl.name, decl.generics, decl.fields)]; } diff --git a/packages/typediagram/src/converters/protobuf.ts b/packages/typediagram/src/converters/protobuf.ts index 98ba88e..22a6c1e 100644 --- a/packages/typediagram/src/converters/protobuf.ts +++ b/packages/typediagram/src/converters/protobuf.ts @@ -17,11 +17,11 @@ // parser prefers over the proto field type, guaranteeing round-trip. import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import type { Model, ResolvedTypeRef } from "../model/types.js"; +import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; import { parseTypeRef, printTypeRef } from "./parse-typeref.js"; -import { extractBalancedBlock, splitTopLevelCommas } from "./brace-lang.js"; +import { extractBalancedBlock } from "./brace-lang.js"; // ── Type mapping ── @@ -434,7 +434,7 @@ const emitUnion = ( const toProto = (model: Model): string => { const lines: string[] = ['syntax = "proto3";', ""]; - for (const d of model.decls) { + for (const d of visibleDeclsForTarget(model.decls, "protobuf")) { if (d.kind === "record") { lines.push(...emitGenericsDirective(d.generics, "")); lines.push(`message ${d.name} {`); @@ -452,11 +452,6 @@ const toProto = (model: Model): string => { return lines.join("\n").replace(/\n+$/, "\n"); }; -// splitTopLevelCommas is used via the shared helper when parsing generic -// argument lists inside `@td-type:` directives. Re-export by using the -// import so tree-shaking doesn't complain. -void splitTopLevelCommas; - export const protobuf: Converter = { language: "protobuf", fromSource: fromProto, diff --git a/packages/typediagram/src/converters/python.ts b/packages/typediagram/src/converters/python.ts index 86705d1..d522b37 100644 --- a/packages/typediagram/src/converters/python.ts +++ b/packages/typediagram/src/converters/python.ts @@ -10,7 +10,7 @@ // convenience emitter for downstream use. import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import type { Model, ResolvedDecl, ResolvedTypeRef } from "../model/types.js"; +import { type Model, type ResolvedDecl, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter, PythonOpts } from "./types.js"; import { parseTypeRef } from "./parse-typeref.js"; @@ -325,7 +325,7 @@ const declUsesAny = (d: ResolvedDecl): boolean => ? d.variants.some((v) => v.fields.some((f) => usesAny(f.type))) : usesAny(d.target); -const modelUsesAny = (model: Model): boolean => model.decls.some(declUsesAny); +const modelUsesAny = (decls: readonly ResolvedDecl[]): boolean => decls.some(declUsesAny); const mapTdToPyDataclass = (t: ResolvedTypeRef): string => { const name = TD_TO_PY[t.name] ?? t.name; @@ -361,44 +361,44 @@ const pydanticFieldSuffix = (t: ResolvedTypeRef): string => ? " = Field(default_factory=dict)" : ""; -const needsDataclassField = (model: Model): boolean => - model.decls.some( +const needsDataclassField = (decls: readonly ResolvedDecl[]): boolean => + decls.some( (d) => (d.kind === "record" && d.fields.some((f) => isList(f.type) || isMap(f.type))) || (d.kind === "union" && d.variants.some((v) => v.fields.some((f) => isList(f.type) || isMap(f.type)))) ); -const hasBareEnum = (model: Model): boolean => - model.decls.some((d) => d.kind === "union" && d.variants.every((v) => v.fields.length === 0)); +const hasBareEnum = (decls: readonly ResolvedDecl[]): boolean => + decls.some((d) => d.kind === "union" && d.variants.every((v) => v.fields.length === 0)); -const hasOption = (model: Model): boolean => - model.decls.some( +const hasOption = (decls: readonly ResolvedDecl[]): boolean => + decls.some( (d) => (d.kind === "record" && d.fields.some((f) => isOption(f.type))) || (d.kind === "union" && d.variants.some((v) => v.fields.some((f) => isOption(f.type)))) || (d.kind === "alias" && isOption(d.target)) ); -const hasGenerics = (model: Model): boolean => model.decls.some((d) => d.generics.length > 0); +const hasGenerics = (decls: readonly ResolvedDecl[]): boolean => decls.some((d) => d.generics.length > 0); -const buildDataclassImports = (model: Model): string[] => { +const buildDataclassImports = (decls: readonly ResolvedDecl[]): string[] => { const lines = ["from __future__ import annotations"]; const dataclassImports = ["dataclass"]; - if (needsDataclassField(model)) { + if (needsDataclassField(decls)) { dataclassImports.push("field"); } lines.push(`from dataclasses import ${dataclassImports.join(", ")}`); - if (hasBareEnum(model)) { + if (hasBareEnum(decls)) { lines.push("from enum import Enum"); } const typingNames: string[] = []; - if (hasOption(model)) { + if (hasOption(decls)) { typingNames.push("Optional"); } - if (modelUsesAny(model)) { + if (modelUsesAny(decls)) { typingNames.push("Any"); } - if (hasGenerics(model)) { + if (hasGenerics(decls)) { typingNames.push("Generic", "TypeVar"); } if (typingNames.length > 0) { @@ -406,7 +406,7 @@ const buildDataclassImports = (model: Model): string[] => { } // Declare the TypeVars used across the model so `Generic[T]` resolves. const typeVars = new Set(); - for (const d of model.decls) { + for (const d of decls) { for (const g of d.generics) { typeVars.add(g); } @@ -420,9 +420,9 @@ const buildDataclassImports = (model: Model): string[] => { return lines; }; -const buildPydanticImports = (model: Model): string[] => { +const buildPydanticImports = (decls: readonly ResolvedDecl[]): string[] => { const lines = ["from __future__ import annotations", "from pydantic import BaseModel"]; - const hasCollections = model.decls.some( + const hasCollections = decls.some( (d) => (d.kind === "record" && d.fields.some((f) => isList(f.type) || isMap(f.type))) || (d.kind === "union" && d.variants.some((v) => v.fields.some((f) => isList(f.type) || isMap(f.type)))) @@ -430,10 +430,10 @@ const buildPydanticImports = (model: Model): string[] => { if (hasCollections) { lines.push("from pydantic import Field"); } - if (hasBareEnum(model)) { + if (hasBareEnum(decls)) { lines.push("from enum import Enum"); } - if (modelUsesAny(model)) { + if (modelUsesAny(decls)) { lines.push("from typing import Any"); } lines.push(""); @@ -487,10 +487,11 @@ const emitBareEnum = (name: string, variants: readonly { name: string }[]): stri const toPython = (model: Model, opts?: PythonOpts): string => { const pydantic = opts?.style === "pydantic"; - const lines: string[] = pydantic ? buildPydanticImports(model) : buildDataclassImports(model); + const decls = visibleDeclsForTarget(model.decls, "python"); + const lines: string[] = pydantic ? buildPydanticImports(decls) : buildDataclassImports(decls); const emitRecord = pydantic ? emitPydanticRecord : emitDataclassRecord; - for (const d of model.decls) { + for (const d of decls) { if (d.kind === "record") { lines.push(...emitRecord(d.name, d.fields, d.generics), ""); } else if (d.kind === "union") { diff --git a/packages/typediagram/src/converters/rust.ts b/packages/typediagram/src/converters/rust.ts index d839b53..6c014f8 100644 --- a/packages/typediagram/src/converters/rust.ts +++ b/packages/typediagram/src/converters/rust.ts @@ -1,7 +1,8 @@ // [CONV-RUST] Rust <-> typeDiagram bidirectional converter. import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import { isTupleVariantFields, type Model, type ResolvedTypeRef } from "../model/types.js"; +import { formatVariantName, withDiscriminant } from "../variant.js"; +import { isTupleVariantFields, type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; import { parseTypeRef } from "./parse-typeref.js"; @@ -155,8 +156,15 @@ const splitRsVariants = (body: string): string[] => { return last.length > 0 ? [...parts, last] : parts; }; +const parseUnitVariant = (line: string) => { + const [rawName, rawDiscriminant] = line.split("=").map((part) => part.trim()); + return rawDiscriminant === undefined + ? { name: line.replace(/,$/, "").trim() } + : { name: rawName ?? line.replace(/,$/, "").trim(), discriminant: rawDiscriminant.replace(/,$/, "").trim() }; +}; + const parseRsVariants = (body: string) => { - const variants: Array<{ name: string; fields: Array<{ name: string; type: string }> }> = []; + const variants: Array<{ name: string; discriminant?: string; fields: Array<{ name: string; type: string }> }> = []; const raw = splitRsVariants(body).filter((s) => s.length > 0 && !s.startsWith("//")); for (const line of raw) { @@ -177,7 +185,7 @@ const parseRsVariants = (body: string) => { const fields = types.map((t, i) => ({ name: `_${String(i)}`, type: mapRsType(t) })); variants.push({ name, fields }); } else { - variants.push({ name: line.replace(/,$/, "").trim(), fields: [] }); + variants.push({ ...parseUnitVariant(line), fields: [] }); } } return variants; @@ -222,10 +230,19 @@ const fromRust = (source: string): Result => { continue; } found = true; - const variants = parseRsVariants(body).map((v) => ({ - name: v.name, - fields: v.fields.map((f) => ({ name: f.name, type: parseTypeRef(f.type) })), - })); + const variants = parseRsVariants(body).map((v) => + withDiscriminant<{ + name: string; + discriminant?: string; + fields: Array<{ name: string; type: ResolvedTypeRef }>; + }>( + { + name: v.name, + fields: v.fields.map((f) => ({ name: f.name, type: parseTypeRef(f.type) })), + }, + v.discriminant + ) + ); builder.add(union(name, variants, rsGenerics(gens))); } @@ -253,8 +270,9 @@ const mapTdToRs = (t: ResolvedTypeRef): string => { const toRust = (model: Model): string => { const lines: string[] = []; + const decls = visibleDeclsForTarget(model.decls, "rust"); - for (const d of model.decls) { + for (const d of decls) { const genericsStr = d.generics.length > 0 ? `<${d.generics.join(", ")}>` : ""; if (d.kind === "record") { @@ -264,10 +282,13 @@ const toRust = (model: Model): string => { } lines.push("}", ""); } else if (d.kind === "union") { + if (d.untagged === true) { + lines.push("#[serde(untagged)]"); + } lines.push(`pub enum ${d.name}${genericsStr} {`); for (const v of d.variants) { if (v.fields.length === 0) { - lines.push(` ${v.name},`); + lines.push(` ${formatVariantName(v.name, v.discriminant)},`); } else if (isTupleVariantFields(v.fields)) { lines.push(` ${v.name}(${v.fields.map((f) => mapTdToRs(f.type)).join(", ")}),`); } else { diff --git a/packages/typediagram/src/converters/typescript.ts b/packages/typediagram/src/converters/typescript.ts index d3d5801..4ab4357 100644 --- a/packages/typediagram/src/converters/typescript.ts +++ b/packages/typediagram/src/converters/typescript.ts @@ -12,7 +12,13 @@ // preserve the original nullability form in the model. import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import type { Model, ResolvedTypeRef } from "../model/types.js"; +import { + isTupleVariantFields, + type Model, + type ResolvedTypeRef, + type ResolvedVariant, + visibleDeclsForTarget, +} from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; import { parseTypeRef } from "./parse-typeref.js"; @@ -269,10 +275,25 @@ const mapTdToTs = (t: ResolvedTypeRef): string => { return t.args.length === 0 ? name : `${name}<${t.args.map(mapTdToTs).join(", ")}>`; }; +const mapUntaggedVariantToTs = (variant: ResolvedVariant): string => { + if (variant.fields.length === 0) { + return "undefined"; + } + if (isTupleVariantFields(variant.fields)) { + if (variant.fields.length === 1) { + const [field] = variant.fields; + return field === undefined ? "undefined" : mapTdToTs(field.type); + } + return `[${variant.fields.map((field) => mapTdToTs(field.type)).join(", ")}]`; + } + return `{ ${variant.fields.map((field) => `${field.name}: ${mapTdToTs(field.type)}`).join("; ")} }`; +}; + const toTypeScript = (model: Model): string => { const lines: string[] = []; + const decls = visibleDeclsForTarget(model.decls, "typescript"); - for (const d of model.decls) { + for (const d of decls) { const genericsStr = d.generics.length > 0 ? `<${d.generics.join(", ")}>` : ""; if (d.kind === "record") { @@ -282,12 +303,15 @@ const toTypeScript = (model: Model): string => { } lines.push("}", ""); } else if (d.kind === "union") { - const variants = d.variants.map((v) => { - if (v.fields.length === 0) { - return `{ kind: "${v.name}" }`; - } - return `{ kind: "${v.name}"; ${v.fields.map((f) => `${f.name}: ${mapTdToTs(f.type)}`).join("; ")} }`; - }); + const variants = + d.untagged === true + ? d.variants.map(mapUntaggedVariantToTs) + : d.variants.map((v) => { + if (v.fields.length === 0) { + return `{ kind: "${v.name}" }`; + } + return `{ kind: "${v.name}"; ${v.fields.map((f) => `${f.name}: ${mapTdToTs(f.type)}`).join("; ")} }`; + }); lines.push(`export type ${d.name}${genericsStr} =`, ` | ${variants.join("\n | ")};`, ""); } else { lines.push(`export type ${d.name}${genericsStr} = ${mapTdToTs(d.target)};`, ""); diff --git a/packages/typediagram/src/index.ts b/packages/typediagram/src/index.ts index 9288189..88975cb 100644 --- a/packages/typediagram/src/index.ts +++ b/packages/typediagram/src/index.ts @@ -1,5 +1,5 @@ import type { Diagnostic } from "./parser/diagnostics.js"; -import { type Result, andThenAsync, err, ok } from "./result.js"; +import { type Result, err, ok } from "./result.js"; import { parse as parseSrc } from "./parser/index.js"; import { buildModel } from "./model/index.js"; import { layout, layoutSync, warmupLayout, isLayoutWarm } from "./layout/index.js"; @@ -107,6 +107,3 @@ export type { export { svg, raw } from "./render-svg/index.js"; export { HOME_PAGE_SAMPLE } from "./sample.js"; - -// keep imports from being tree-shaken away in odd configurations -void andThenAsync; diff --git a/packages/typediagram/src/layout/elk.ts b/packages/typediagram/src/layout/elk.ts index ccd9dc6..4276177 100644 --- a/packages/typediagram/src/layout/elk.ts +++ b/packages/typediagram/src/layout/elk.ts @@ -8,7 +8,8 @@ import { type ResolvedDecl, type ResolvedTypeRef, } from "../model/types.js"; -import { measureBlock, measureText } from "./measure.js"; +import { formatVariantName } from "../variant.js"; +import { measureText } from "./measure.js"; import type { EdgeRoute, LaidOutGraph, LayoutOpts, NodeBox, NodeRow } from "./types.js"; const DEFAULT_FONT_SIZE = 13; @@ -124,12 +125,13 @@ function buildPreNodes(decls: ResolvedDecl[], fontSize: number, padX: number, pa } } else if (d.kind === "union") { for (const v of d.variants) { + const head = formatVariantName(v.name, v.discriminant); const variantHeader = v.fields.length === 0 - ? v.name + ? head : isTupleVariantFields(v.fields) - ? `${v.name}(${v.fields.map((f) => printRefShort(f.type)).join(", ")})` - : `${v.name} { ${v.fields.map((f) => rowText(f.name, f.type)).join(", ")} }`; + ? `${head}(${v.fields.map((f) => printRefShort(f.type)).join(", ")})` + : `${head} { ${v.fields.map((f) => rowText(f.name, f.type)).join(", ")} }`; const m = measureText(variantHeader, fontSize); if (m.w > widest) { widest = m.w; @@ -152,8 +154,6 @@ function buildPreNodes(decls: ResolvedDecl[], fontSize: number, padX: number, pa out.push({ id: d.name, decl: d, header, rows, width, height }); } - // suppress unused-var warning - void measureBlock; return out; } diff --git a/packages/typediagram/src/model/build.ts b/packages/typediagram/src/model/build.ts index 1337598..7685290 100644 --- a/packages/typediagram/src/model/build.ts +++ b/packages/typediagram/src/model/build.ts @@ -1,6 +1,7 @@ import type { AliasDecl, Declaration, Diagram, Field, RecordDecl, TypeRef, UnionDecl, Variant } from "../parser/ast.js"; import { DiagnosticBag, type Diagnostic } from "../parser/diagnostics.js"; import { type Result, err, ok } from "../result.js"; +import { withDiscriminant } from "../variant.js"; import { PRIMITIVES, type Edge, @@ -41,7 +42,7 @@ export function buildModelPartial(ast: Diagram): { model: Model; diagnostics: Di decls.push(resolveDecl(d, declMap, externals, bag)); } - const edges = collectEdges(decls, declMap); + const edges = collectEdges(decls); const model: Model = { decls, @@ -85,6 +86,7 @@ function resolveRecord( name: d.name, generics: [...d.generics], fields: d.fields.map((f) => resolveField(f, d.name, declMap, externals, generics, bag)), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), }; } @@ -99,7 +101,9 @@ function resolveUnion( kind: "union", name: d.name, generics: [...d.generics], + ...(d.untagged === true ? { untagged: true as const } : {}), variants: d.variants.map((v) => resolveVariant(v, d.name, declMap, externals, generics, bag)), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), }; } @@ -115,6 +119,7 @@ function resolveAlias( name: d.name, generics: [...d.generics], target: resolveTypeRef(d.target, d.name, declMap, externals, generics, bag), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), }; } @@ -126,10 +131,13 @@ function resolveVariant( generics: Set, bag: DiagnosticBag ): ResolvedVariant { - return { - name: v.name, - fields: v.fields.map((f) => resolveField(f, ownerName, declMap, externals, generics, bag)), - }; + return withDiscriminant( + { + name: v.name, + fields: v.fields.map((f) => resolveField(f, ownerName, declMap, externals, generics, bag)), + }, + v.discriminant + ); } function resolveField( @@ -203,7 +211,7 @@ function* walkDeclaredRefs(t: ResolvedTypeRef): Generator<{ declName: string; is } } -function collectEdges(decls: ResolvedDecl[], declMap: Map): Edge[] { +function collectEdges(decls: ResolvedDecl[]): Edge[] { const edges: Edge[] = []; const seen = new Set(); @@ -259,8 +267,5 @@ function collectEdges(decls: ResolvedDecl[], declMap: Map): E } } } - - // suppress unused param warning - void declMap; return edges; } diff --git a/packages/typediagram/src/model/builder.ts b/packages/typediagram/src/model/builder.ts index 1ce8fc0..b45f25b 100644 --- a/packages/typediagram/src/model/builder.ts +++ b/packages/typediagram/src/model/builder.ts @@ -1,5 +1,6 @@ import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err, ok } from "../result.js"; +import { withDiscriminant } from "../variant.js"; import { validate } from "./validate.js"; import { PRIMITIVES, @@ -21,9 +22,14 @@ export interface FieldSpec { export interface VariantSpec { name: string; + discriminant?: string; fields?: FieldSpec[]; } +export interface UnionSpec { + untagged?: boolean; +} + /** Build a TypeRef. Resolution is deferred to validate(). */ export function ref(name: string, args: ResolvedTypeRef[] = []): ResolvedTypeRef { return { name, args, resolution: { kind: "external" } }; @@ -33,11 +39,12 @@ export function record(name: string, fields: FieldSpec[], generics: string[] = [ return { kind: "record", name, generics, fields: fields.map(toField) }; } -export function union(name: string, variants: VariantSpec[], generics: string[] = []): ResolvedUnion { +export function union(name: string, variants: VariantSpec[], generics: string[] = [], spec?: UnionSpec): ResolvedUnion { return { kind: "union", name, generics, + ...(spec?.untagged === true ? { untagged: true as const } : {}), variants: variants.map(toVariant), }; } @@ -51,7 +58,13 @@ function toField(f: FieldSpec): ResolvedField { } function toVariant(v: VariantSpec): ResolvedVariant { - return { name: v.name, fields: (v.fields ?? []).map(toField) }; + return withDiscriminant( + { + name: v.name, + fields: (v.fields ?? []).map(toField), + }, + v.discriminant + ); } export class ModelBuilder { @@ -120,10 +133,15 @@ export function resolveResolutions(model: Model): Model { if (d.kind === "union") { return { ...d, - variants: d.variants.map((v) => ({ - name: v.name, - fields: v.fields.map((f) => ({ name: f.name, type: fixRef(f.type, generics, d.name) })), - })), + variants: d.variants.map((v) => + withDiscriminant( + { + name: v.name, + fields: v.fields.map((f) => ({ name: f.name, type: fixRef(f.type, generics, d.name) })), + }, + v.discriminant + ) + ), }; } return { ...d, target: fixRef(d.target, generics, d.name) }; diff --git a/packages/typediagram/src/model/index.ts b/packages/typediagram/src/model/index.ts index 7f42dc8..fef3a8f 100644 --- a/packages/typediagram/src/model/index.ts +++ b/packages/typediagram/src/model/index.ts @@ -1,6 +1,6 @@ export { buildModel, buildModelPartial } from "./build.js"; export { ModelBuilder, alias, record, ref, resolveResolutions, union } from "./builder.js"; -export type { FieldSpec, VariantSpec } from "./builder.js"; +export type { FieldSpec, UnionSpec, VariantSpec } from "./builder.js"; export { fromJSON, toJSON, SCHEMA_VERSION } from "./json.js"; export type { AliasJson, @@ -16,6 +16,7 @@ export { printSource } from "./print.js"; export { validate } from "./validate.js"; export { PRIMITIVES, + type DeclTargeting, type Edge, type EdgeKind, type Model, diff --git a/packages/typediagram/src/model/json.ts b/packages/typediagram/src/model/json.ts index a6d80b5..5980045 100644 --- a/packages/typediagram/src/model/json.ts +++ b/packages/typediagram/src/model/json.ts @@ -1,7 +1,8 @@ import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err, ok } from "../result.js"; +import { withDiscriminant } from "../variant.js"; import { resolveResolutions } from "./builder.js"; -import type { Model, ResolvedDecl, ResolvedTypeRef } from "./types.js"; +import type { DeclTargeting, Model, ResolvedDecl, ResolvedTypeRef, ResolvedVariant } from "./types.js"; export const SCHEMA_VERSION = 1; @@ -17,18 +18,22 @@ export interface RecordJson { name: string; generics: string[]; fields: FieldJson[]; + targeting?: DeclTargeting; } export interface UnionJson { kind: "union"; name: string; generics: string[]; + untagged?: true; variants: VariantJson[]; + targeting?: DeclTargeting; } export interface AliasJson { kind: "alias"; name: string; generics: string[]; target: TypeRefJson; + targeting?: DeclTargeting; } export interface FieldJson { name: string; @@ -36,6 +41,7 @@ export interface FieldJson { } export interface VariantJson { name: string; + discriminant?: string; fields: FieldJson[]; } export interface TypeRefJson { @@ -57,6 +63,7 @@ function declToJson(d: ResolvedDecl): DeclJson { name: d.name, generics: [...d.generics], fields: d.fields.map((f) => ({ name: f.name, type: refToJson(f.type) })), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), }; } if (d.kind === "union") { @@ -64,10 +71,17 @@ function declToJson(d: ResolvedDecl): DeclJson { kind: "union", name: d.name, generics: [...d.generics], - variants: d.variants.map((v) => ({ - name: v.name, - fields: v.fields.map((f) => ({ name: f.name, type: refToJson(f.type) })), - })), + ...(d.untagged === true ? { untagged: true as const } : {}), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), + variants: d.variants.map((v) => + withDiscriminant( + { + name: v.name, + fields: v.fields.map((f) => ({ name: f.name, type: refToJson(f.type) })), + }, + v.discriminant + ) + ), }; } return { @@ -75,6 +89,7 @@ function declToJson(d: ResolvedDecl): DeclJson { name: d.name, generics: [...d.generics], target: refToJson(d.target), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), }; } @@ -134,41 +149,91 @@ function declFromJson(d: unknown): Result { if (!Array.isArray(x.fields)) { return fail("record.fields must be an array"); } + const targeting = targetingFromJson(x.targeting); + if (!targeting.ok) { + return targeting; + } return ok({ kind: "record", name: x.name, generics: x.generics, fields: x.fields.map(fieldFromJson), + ...(targeting.value === undefined ? {} : { targeting: targeting.value }), }); } if (x.kind === "union") { if (!Array.isArray(x.variants)) { return fail("union.variants must be an array"); } + const targeting = targetingFromJson(x.targeting); + if (!targeting.ok) { + return targeting; + } return ok({ kind: "union", name: x.name, generics: x.generics, - variants: x.variants.map((v) => ({ - name: v.name, - fields: v.fields.map(fieldFromJson), - })), + ...(x.untagged === true ? { untagged: true as const } : {}), + variants: x.variants.map((v) => + withDiscriminant( + { + name: v.name, + fields: v.fields.map(fieldFromJson), + }, + v.discriminant + ) + ), + ...(targeting.value === undefined ? {} : { targeting: targeting.value }), }); } if (x.kind === "alias") { if (!x.target) { return fail("alias.target required"); } + const targeting = targetingFromJson(x.targeting); + if (!targeting.ok) { + return targeting; + } return ok({ kind: "alias", name: x.name, generics: x.generics, target: refFromJson(x.target), + ...(targeting.value === undefined ? {} : { targeting: targeting.value }), }); } return fail(`unknown decl kind '${String(x.kind)}'`); } +function targetingFromJson(value: unknown): Result { + if (value === undefined) { + return ok(undefined); + } + const errs: Diagnostic[] = []; + const fail = (msg: string): Result => { + errs.push({ severity: "error", message: msg, line: 0, col: 0, length: 0 }); + return err(errs); + }; + if (typeof value !== "object" || value === null) { + return fail("decl.targeting must be an object"); + } + const targeting = value as { targets?: unknown; skipTargets?: unknown }; + if (targeting.targets !== undefined && !isStringArray(targeting.targets)) { + return fail("decl.targeting.targets must be an array of strings"); + } + if (targeting.skipTargets !== undefined && !isStringArray(targeting.skipTargets)) { + return fail("decl.targeting.skipTargets must be an array of strings"); + } + return ok({ + ...(targeting.targets === undefined ? {} : { targets: targeting.targets }), + ...(targeting.skipTargets === undefined ? {} : { skipTargets: targeting.skipTargets }), + }); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + function fieldFromJson(f: FieldJson): { name: string; type: ResolvedTypeRef } { return { name: f.name, type: refFromJson(f.type) }; } diff --git a/packages/typediagram/src/model/print.ts b/packages/typediagram/src/model/print.ts index 52d131e..a9b2dee 100644 --- a/packages/typediagram/src/model/print.ts +++ b/packages/typediagram/src/model/print.ts @@ -1,14 +1,27 @@ import { isTupleVariantFields, type Model, type ResolvedDecl, type ResolvedTypeRef } from "./types.js"; +import { formatVariantName } from "../variant.js"; export function printSource(model: Model): string { const out: string[] = ["typeDiagram", ""]; for (const d of model.decls) { + out.push(...printTargeting(d)); out.push(printDecl(d)); out.push(""); } return out.join("\n").replace(/\n+$/, "\n"); } +function printTargeting(d: ResolvedDecl): string[] { + const lines: string[] = []; + if (d.targeting?.targets !== undefined) { + lines.push(`@targets(${d.targeting.targets.join(", ")})`); + } + if (d.targeting?.skipTargets !== undefined) { + lines.push(`@skipTargets(${d.targeting.skipTargets.join(", ")})`); + } + return lines; +} + function printDecl(d: ResolvedDecl): string { const generics = d.generics.length === 0 ? "" : `<${d.generics.join(", ")}>`; if (d.kind === "record") { @@ -18,17 +31,18 @@ function printDecl(d: ResolvedDecl): string { if (d.kind === "union") { const variants = d.variants .map((v) => { + const head = formatVariantName(v.name, v.discriminant); if (v.fields.length === 0) { - return ` ${v.name}`; + return ` ${head}`; } if (isTupleVariantFields(v.fields)) { - return ` ${v.name}(${v.fields.map((f) => printRef(f.type)).join(", ")})`; + return ` ${head}(${v.fields.map((f) => printRef(f.type)).join(", ")})`; } const inner = v.fields.map((f) => `${f.name}: ${printRef(f.type)}`).join(", "); - return ` ${v.name} { ${inner} }`; + return ` ${head} { ${inner} }`; }) .join("\n"); - return `union ${d.name}${generics} {\n${variants}\n}`; + return `${d.untagged === true ? "untagged union" : "union"} ${d.name}${generics} {\n${variants}\n}`; } return `alias ${d.name}${generics} = ${printRef(d.target)}`; } diff --git a/packages/typediagram/src/model/types.ts b/packages/typediagram/src/model/types.ts index da6a354..aef12b8 100644 --- a/packages/typediagram/src/model/types.ts +++ b/packages/typediagram/src/model/types.ts @@ -8,18 +8,26 @@ export interface Model { export type ResolvedDecl = ResolvedRecord | ResolvedUnion | ResolvedAlias; +export interface DeclTargeting { + targets?: string[]; + skipTargets?: string[]; +} + export interface ResolvedRecord { kind: "record"; name: string; generics: string[]; fields: ResolvedField[]; + targeting?: DeclTargeting; } export interface ResolvedUnion { kind: "union"; name: string; generics: string[]; + untagged?: true; variants: ResolvedVariant[]; + targeting?: DeclTargeting; } export interface ResolvedAlias { @@ -27,6 +35,7 @@ export interface ResolvedAlias { name: string; generics: string[]; target: ResolvedTypeRef; + targeting?: DeclTargeting; } export interface ResolvedField { @@ -36,6 +45,7 @@ export interface ResolvedField { export interface ResolvedVariant { name: string; + discriminant?: string; fields: ResolvedField[]; } @@ -72,3 +82,18 @@ export interface Edge { label: string; kind: EdgeKind; } + +export function shouldEmitDeclToTarget(decl: { targeting?: DeclTargeting }, target: string): boolean { + const whitelist = decl.targeting?.targets; + if (whitelist !== undefined && whitelist.length > 0 && !whitelist.includes(target)) { + return false; + } + return decl.targeting?.skipTargets?.includes(target) !== true; +} + +export function visibleDeclsForTarget( + decls: readonly T[], + target: string +): T[] { + return decls.filter((decl) => shouldEmitDeclToTarget(decl, target)); +} diff --git a/packages/typediagram/src/parser/ast.ts b/packages/typediagram/src/parser/ast.ts index b516867..b3c4d08 100644 --- a/packages/typediagram/src/parser/ast.ts +++ b/packages/typediagram/src/parser/ast.ts @@ -12,11 +12,17 @@ export interface Diagram { export type Declaration = RecordDecl | UnionDecl | AliasDecl; +export interface DeclTargeting { + targets?: string[]; + skipTargets?: string[]; +} + export interface RecordDecl { kind: "record"; name: string; generics: string[]; fields: Field[]; + targeting?: DeclTargeting; span: Span; } @@ -24,7 +30,9 @@ export interface UnionDecl { kind: "union"; name: string; generics: string[]; + untagged?: true; variants: Variant[]; + targeting?: DeclTargeting; span: Span; } @@ -33,6 +41,7 @@ export interface AliasDecl { name: string; generics: string[]; target: TypeRef; + targeting?: DeclTargeting; span: Span; } @@ -44,6 +53,7 @@ export interface Field { export interface Variant { name: string; + discriminant?: string; fields: Field[]; span: Span; } diff --git a/packages/typediagram/src/parser/index.ts b/packages/typediagram/src/parser/index.ts index 814ce30..2f5791e 100644 --- a/packages/typediagram/src/parser/index.ts +++ b/packages/typediagram/src/parser/index.ts @@ -1,6 +1,17 @@ export { tokenize } from "./lexer.js"; export type { Token, TokenKind } from "./lexer.js"; export { parse, parsePartial, tokenizeResult, tokenizePartial } from "./parser.js"; -export type { AliasDecl, Declaration, Diagram, Field, RecordDecl, Span, TypeRef, UnionDecl, Variant } from "./ast.js"; +export type { + AliasDecl, + DeclTargeting, + Declaration, + Diagram, + Field, + RecordDecl, + Span, + TypeRef, + UnionDecl, + Variant, +} from "./ast.js"; export type { Diagnostic, Severity } from "./diagnostics.js"; export { DiagnosticBag, formatDiagnostic, formatDiagnostics } from "./diagnostics.js"; diff --git a/packages/typediagram/src/parser/lexer.ts b/packages/typediagram/src/parser/lexer.ts index 0fa4f2d..1ac5885 100644 --- a/packages/typediagram/src/parser/lexer.ts +++ b/packages/typediagram/src/parser/lexer.ts @@ -3,15 +3,18 @@ import type { DiagnosticBag } from "./diagnostics.js"; export type TokenKind = | "TypeKw" | "UnionKw" + | "UntaggedKw" | "AliasKw" | "TypeDiagramKw" | "Ident" + | "Number" | "LBrace" | "RBrace" | "LParen" | "RParen" | "LAngle" | "RAngle" + | "At" | "Comma" | "Colon" | "Equals" @@ -30,6 +33,7 @@ export interface Token { const KEYWORDS: Record = { type: "TypeKw", union: "UnionKw", + untagged: "UntaggedKw", alias: "AliasKw", typeDiagram: "TypeDiagramKw", }; @@ -41,6 +45,7 @@ const SINGLE_CHAR: Record = { ")": "RParen", "<": "LAngle", ">": "RAngle", + "@": "At", ",": "Comma", ":": "Colon", "=": "Equals", @@ -54,6 +59,10 @@ function isIdentCont(c: string): boolean { return isIdentStart(c) || (c >= "0" && c <= "9"); } +function isDigit(c: string): boolean { + return c >= "0" && c <= "9"; +} + export function tokenize(source: string, diagnostics: DiagnosticBag): Token[] { const tokens: Token[] = []; let i = 0; @@ -120,6 +129,25 @@ export function tokenize(source: string, diagnostics: DiagnosticBag): Token[] { continue; } + if (isDigit(c) || (c === "-" && isDigit(source.charAt(i + 1)))) { + const startLine = line; + const startCol = col; + const startOffset = i; + let end = i + (c === "-" ? 2 : 1); + while (end < len) { + const next = source.charAt(end); + if (!isDigit(next) && next !== "_") { + break; + } + end++; + } + const value = source.slice(i, end); + emit("Number", value, startLine, startCol, startOffset); + col += end - i; + i = end; + continue; + } + const single = SINGLE_CHAR[c]; if (single !== undefined) { emit(single, c, line, col, i); diff --git a/packages/typediagram/src/parser/parser.ts b/packages/typediagram/src/parser/parser.ts index 6b37aea..1173784 100644 --- a/packages/typediagram/src/parser/parser.ts +++ b/packages/typediagram/src/parser/parser.ts @@ -1,9 +1,21 @@ -import type { AliasDecl, Declaration, Diagram, Field, RecordDecl, Span, TypeRef, UnionDecl, Variant } from "./ast.js"; +import type { + AliasDecl, + DeclTargeting, + Declaration, + Diagram, + Field, + RecordDecl, + Span, + TypeRef, + UnionDecl, + Variant, +} from "./ast.js"; import { DiagnosticBag } from "./diagnostics.js"; import type { Token, TokenKind } from "./lexer.js"; import { tokenize } from "./lexer.js"; import { type Result, err, ok } from "../result.js"; import type { Diagnostic } from "./diagnostics.js"; +import { withDiscriminant } from "../variant.js"; class Cursor { private i = 0; @@ -75,22 +87,81 @@ class Parser { } private parseDeclaration(): Declaration | null { + const targeting = this.parseTargetingAnnotations(); const t = this.cur.peek(); if (t.kind === "TypeKw") { - return this.parseRecord(); + return this.parseRecord(targeting); } if (t.kind === "UnionKw") { - return this.parseUnion(); + return this.parseUnion(false, targeting); + } + if (t.kind === "UntaggedKw") { + const next = this.cur.peek(1); + if (next.kind !== "UnionKw") { + this.diags.error( + `expected 'union' after 'untagged', got ${describe(next)}`, + next.line, + next.col, + next.length || 1 + ); + this.recoverToTopLevel(); + return null; + } + return this.parseUnion(true, targeting); } if (t.kind === "AliasKw") { - return this.parseAlias(); - } - this.diags.error(`expected 'type', 'union', or 'alias', got ${describe(t)}`, t.line, t.col, t.length || 1); + return this.parseAlias(targeting); + } + this.diags.error( + `expected 'type', 'union', 'untagged union', or 'alias', got ${describe(t)}`, + t.line, + t.col, + t.length || 1 + ); this.recoverToTopLevel(); return null; } - private parseRecord(): RecordDecl | null { + private parseTargetingAnnotations(): DeclTargeting | undefined { + let targeting: DeclTargeting | undefined; + while (this.cur.peek().kind === "At") { + this.cur.next(); + const nameTok = this.expect("Ident", "annotation name"); + if (nameTok === null) { + return targeting; + } + if (this.expect("LParen", "'('") === null) { + return targeting; + } + const values: string[] = []; + while (this.cur.peek().kind !== "RParen" && this.cur.peek().kind !== "EOF") { + const valueTok = this.expect("Ident", "target name"); + if (valueTok === null) { + break; + } + values.push(valueTok.value); + if (this.cur.peek().kind === "Comma") { + this.cur.next(); + } else { + break; + } + } + this.expect("RParen", "')'"); + + targeting ??= {}; + if (nameTok.value === "targets") { + targeting.targets = values; + } else if (nameTok.value === "skipTargets") { + targeting.skipTargets = values; + } else { + this.diags.error(`unknown annotation '@${nameTok.value}'`, nameTok.line, nameTok.col - 1, nameTok.length + 1); + } + this.cur.eatNewlines(); + } + return targeting; + } + + private parseRecord(targeting?: DeclTargeting): RecordDecl | null { const kw = this.cur.next(); // TypeKw const nameTok = this.expect("Ident", "type name"); if (nameTok === null) { @@ -109,11 +180,13 @@ class Parser { name: nameTok.value, generics, fields, + ...(targeting === undefined ? {} : { targeting }), span: spanBetween(kw, closeTok ?? kw), }; } - private parseUnion(): UnionDecl | null { + private parseUnion(untagged = false, targeting?: DeclTargeting): UnionDecl | null { + const start = untagged ? this.cur.next() : this.cur.peek(); const kw = this.cur.next(); const nameTok = this.expect("Ident", "union name"); if (nameTok === null) { @@ -131,12 +204,14 @@ class Parser { kind: "union", name: nameTok.value, generics, + ...(untagged ? { untagged: true as const } : {}), variants, - span: spanBetween(kw, closeTok ?? kw), + ...(targeting === undefined ? {} : { targeting }), + span: spanBetween(start, closeTok ?? kw), }; } - private parseAlias(): AliasDecl | null { + private parseAlias(targeting?: DeclTargeting): AliasDecl | null { const kw = this.cur.next(); const nameTok = this.expect("Ident", "alias name"); if (nameTok === null) { @@ -158,6 +233,7 @@ class Parser { name: nameTok.value, generics, target, + ...(targeting === undefined ? {} : { targeting }), span: spanBetween(kw, this.cur.peek()), }; } @@ -246,6 +322,7 @@ class Parser { this.skipToFieldBoundary(); return null; } + const discriminant = this.parseVariantDiscriminant(); let fields: Field[] = []; if (this.cur.peek().kind === "LBrace") { this.cur.next(); @@ -256,11 +333,23 @@ class Parser { fields = this.parseTupleVariantFieldList(); this.expect("RParen", "')'"); } - return { - name: nameTok.value, - fields, - span: spanBetween(nameTok, this.cur.peek()), - }; + return withDiscriminant( + { + name: nameTok.value, + fields, + span: spanBetween(nameTok, this.cur.peek()), + }, + discriminant + ); + } + + private parseVariantDiscriminant(): string | undefined { + if (this.cur.peek().kind !== "Equals") { + return undefined; + } + this.cur.next(); + const valueTok = this.expect("Number", "numeric discriminant"); + return valueTok?.value; } private parseTupleVariantFieldList(): Field[] { diff --git a/packages/typediagram/src/variant.ts b/packages/typediagram/src/variant.ts new file mode 100644 index 0000000..dd84fc1 --- /dev/null +++ b/packages/typediagram/src/variant.ts @@ -0,0 +1,10 @@ +export function withDiscriminant(value: T, discriminant: string | undefined): T { + if (discriminant !== undefined) { + value.discriminant = discriminant; + } + return value; +} + +export function formatVariantName(name: string, discriminant: string | undefined): string { + return discriminant === undefined ? name : `${name} = ${discriminant}`; +} diff --git a/packages/typediagram/test/converters/rust.test.ts b/packages/typediagram/test/converters/rust.test.ts index 1fb3f3a..594542b 100644 --- a/packages/typediagram/test/converters/rust.test.ts +++ b/packages/typediagram/test/converters/rust.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { rust } from "../../src/converters/index.js"; import { parse } from "../../src/parser/index.js"; -import { buildModel } from "../../src/model/index.js"; +import { buildModel, printSource } from "../../src/model/index.js"; import { expectLosslessRoundTrip, unwrap } from "./helpers.js"; describe("[CONV-RUST-FROM-COMPLEX] complex Rust -> typeDiagram", () => { @@ -224,6 +224,27 @@ alias Lookup = Map expect(output).toContain("pub type Lookup = HashMap"); }); + it("preserves explicit numeric discriminants on enum variants", () => { + const td = ` +union ErrorCode { + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 +} +`; + const model = unwrap(buildModel(unwrap(parse(td)))); + const output = rust.toSource(model); + + expect(output).toContain("ParseError = -32700,"); + expect(output).toContain("InvalidRequest = -32600,"); + expect(output).toContain("MethodNotFound = -32601,"); + + const roundTrip = unwrap(rust.fromSource(output)); + expect(printSource(roundTrip)).toContain("ParseError = -32700"); + expect(printSource(roundTrip)).toContain("InvalidRequest = -32600"); + expect(printSource(roundTrip)).toContain("MethodNotFound = -32601"); + }); + it("emits tuple enum variants from tuple-form union syntax", () => { const td = ` union RequestId { @@ -238,6 +259,22 @@ union RequestId { expect(output).toContain("Number(i64)"); expect(output).toContain("String(String)"); }); + + it("emits serde untagged enums from untagged union syntax", () => { + const td = ` +untagged union RequestId { + Number(Int) + String(String) +} +`; + const model = unwrap(buildModel(unwrap(parse(td)))); + const output = rust.toSource(model); + + expect(output).toContain("#[serde(untagged)]"); + expect(output).toContain("pub enum RequestId"); + expect(output).toContain("Number(i64)"); + expect(output).toContain("String(String)"); + }); }); describe("[CONV-RUST-RT] Rust round-trip TD -> Rust -> TD", () => { diff --git a/packages/typediagram/test/converters/typescript.test.ts b/packages/typediagram/test/converters/typescript.test.ts index 4940701..040ee5a 100644 --- a/packages/typediagram/test/converters/typescript.test.ts +++ b/packages/typediagram/test/converters/typescript.test.ts @@ -269,6 +269,78 @@ alias Wrapper = List expect(output.indexOf("ChatRequest")).toBeLessThan(output.indexOf("ToolResult")); expect(output.indexOf("ToolResult")).toBeLessThan(output.indexOf("GenericBox")); }); + + it("emits untagged tuple unions as plain TypeScript unions", () => { + const td = ` +untagged union RequestId { + Number(Int) + String(String) +} +`; + const model = unwrap(buildModel(unwrap(parse(td)))); + const output = typescript.toSource(model); + + expect(output).toContain("export type RequestId ="); + expect(output).toContain(" | number"); + expect(output).toContain(" | string;"); + expect(output).not.toContain('kind: "Number"'); + expect(output).not.toContain('kind: "String"'); + }); + + it("emits remaining untagged payload shapes without discriminator fields", () => { + const td = ` +untagged union Value { + Empty + Pair(Int, String) + Point { x: Int, y: Int } +} +`; + const model = unwrap(buildModel(unwrap(parse(td)))); + const output = typescript.toSource(model); + + expect(output).toContain("export type Value ="); + expect(output).toContain(" | undefined"); + expect(output).toContain(" | [number, string]"); + expect(output).toContain(" | { x: number; y: number };"); + expect(output).not.toContain("kind:"); + }); + + it("[CONV-TS-BUG-27] skips declarations gated away from the typescript target", () => { + const td = ` +@targets(rust) +type JsonRpcError { + code: Int + message: String +} + +type VisibleInTs { + ok: Bool +} +`; + const model = unwrap(buildModel(unwrap(parse(td)))); + const output = typescript.toSource(model); + + expect(output).not.toContain("export interface JsonRpcError"); + expect(output).toContain("export interface VisibleInTs"); + }); + + it("[CONV-TS-BUG-27] supports blacklisting the typescript target", () => { + const td = ` +@skipTargets(typescript, python) +type RustOnlyErrorFrame { + data: String +} + +type SharedFrame { + id: String +} +`; + const model = unwrap(buildModel(unwrap(parse(td)))); + const output = typescript.toSource(model); + + expect(output).not.toContain("export interface RustOnlyErrorFrame"); + expect(output).toContain("export interface SharedFrame"); + }); }); describe("[CONV-TS-RT] TypeScript round-trip TD -> TS -> TD", () => { diff --git a/packages/typediagram/test/lexer.test.ts b/packages/typediagram/test/lexer.test.ts index 63e7caa..3df2f81 100644 --- a/packages/typediagram/test/lexer.test.ts +++ b/packages/typediagram/test/lexer.test.ts @@ -60,9 +60,9 @@ describe("lexer", () => { }); it("emits diagnostic on unexpected char and continues", () => { - const { tokens, diagnostics } = lex("type @ User"); + const { tokens, diagnostics } = lex("type $ User"); expect(diagnostics).toHaveLength(1); - expect(diagnostics[0]?.message).toContain("@"); + expect(diagnostics[0]?.message).toContain("$"); expect(tokens.map((t) => t.kind)).toEqual(["TypeKw", "Ident", "EOF"]); }); diff --git a/packages/typediagram/test/model.test.ts b/packages/typediagram/test/model.test.ts index eb27aba..f13b4b6 100644 --- a/packages/typediagram/test/model.test.ts +++ b/packages/typediagram/test/model.test.ts @@ -13,6 +13,7 @@ import { union, validate, } from "../src/model/index.js"; +import { shouldEmitDeclToTarget, visibleDeclsForTarget } from "../src/model/types.js"; import { CHAT_EXAMPLE, SMALL_EXAMPLE } from "./fixtures.js"; function unwrap(r: { ok: true; value: T } | { ok: false; error: unknown }): T { @@ -214,6 +215,49 @@ describe("model — JSON round-trip", () => { expect(toJSON(back)).toEqual(json); }); + it("preserves untagged unions through printSource and JSON", () => { + const model = unwrap( + buildModel( + unwrap( + parse(` +untagged union RequestId { + Number(Int) + String(String) +} +`) + ) + ) + ); + + const requestId = model.decls.find((decl) => decl.name === "RequestId"); + expect(requestId?.kind).toBe("union"); + expect(requestId?.kind === "union" ? requestId.untagged : undefined).toBe(true); + expect(printSource(model)).toContain("untagged union RequestId"); + + const roundTrip = unwrap(fromJSON(toJSON(model))); + const requestIdRoundTrip = roundTrip.decls.find((decl) => decl.name === "RequestId"); + expect(requestIdRoundTrip?.kind).toBe("union"); + expect(requestIdRoundTrip?.kind === "union" ? requestIdRoundTrip.untagged : undefined).toBe(true); + }); + + it("preserves declaration target gating through printSource and JSON", () => { + const td = ` +@targets(rust) +@skipTargets(typescript) +type JsonRpcError { + code: Int +} +`; + const model = unwrap(buildModel(unwrap(parse(td)))); + expect(printSource(model)).toContain("@targets(rust)"); + expect(printSource(model)).toContain("@skipTargets(typescript)"); + + const roundTrip = unwrap(fromJSON(toJSON(model))); + const decl = roundTrip.decls.find((entry) => entry.name === "JsonRpcError"); + expect(decl?.targeting?.targets).toEqual(["rust"]); + expect(decl?.targeting?.skipTargets).toEqual(["typescript"]); + }); + it("rejects wrong schema version", () => { const r = fromJSON({ version: 99, decls: [] }); expect(r.ok).toBe(false); @@ -331,6 +375,40 @@ describe("model — JSON edge cases", () => { expect(r.ok).toBe(false); }); + it("rejects non-object targeting JSON", () => { + const r = fromJSON({ + version: 1, + decls: [{ kind: "record", name: "X", generics: [], fields: [], targeting: "rust" }], + }); + expect(r.ok).toBe(false); + }); + + it("rejects non-string targeting arrays", () => { + const badTargets = fromJSON({ + version: 1, + decls: [{ kind: "record", name: "X", generics: [], fields: [], targeting: { targets: [1] } }], + }); + const badSkipTargets = fromJSON({ + version: 1, + decls: [{ kind: "record", name: "X", generics: [], fields: [], targeting: { skipTargets: [false] } }], + }); + expect(badTargets.ok).toBe(false); + expect(badSkipTargets.ok).toBe(false); + }); + + it("rejects invalid targeting JSON on unions and aliases", () => { + const badUnion = fromJSON({ + version: 1, + decls: [{ kind: "union", name: "X", generics: [], variants: [], targeting: { targets: [1] } }], + }); + const badAlias = fromJSON({ + version: 1, + decls: [{ kind: "alias", name: "X", generics: [], target: { name: "String", args: [] }, targeting: null }], + }); + expect(badUnion.ok).toBe(false); + expect(badAlias.ok).toBe(false); + }); + it("round-trips alias JSON", () => { const td = `alias Email = String`; const model = unwrap(buildModel(unwrap(parse(td)))); @@ -350,6 +428,45 @@ describe("model — build alias edges", () => { }); }); +describe("model — target visibility helpers", () => { + it("applies whitelist and blacklist rules", () => { + expect(shouldEmitDeclToTarget({ targeting: { targets: ["rust"] } }, "typescript")).toBe(false); + expect(shouldEmitDeclToTarget({ targeting: { skipTargets: ["typescript"] } }, "typescript")).toBe(false); + expect(shouldEmitDeclToTarget({ targeting: { targets: [] } }, "typescript")).toBe(true); + expect(shouldEmitDeclToTarget({}, "typescript")).toBe(true); + }); + + it("filters declarations for a specific target", () => { + const model = unwrap( + buildModel( + unwrap( + parse(` +@targets(rust) +type RustOnly { + code: Int +} + +@skipTargets(typescript) +type AlsoHidden { + code: Int +} + +type Shared { + ok: Bool +} +`) + ) + ) + ); + expect(visibleDeclsForTarget(model.decls, "typescript").map((decl) => decl.name)).toEqual(["Shared"]); + expect(visibleDeclsForTarget(model.decls, "rust").map((decl) => decl.name)).toEqual([ + "RustOnly", + "AlsoHidden", + "Shared", + ]); + }); +}); + describe("model — builder computeEdges for unions with fields", () => { it("creates variantPayload edges via builder", () => { const m = unwrap( diff --git a/packages/typediagram/test/parser.test.ts b/packages/typediagram/test/parser.test.ts index 370f7c5..5c9212e 100644 --- a/packages/typediagram/test/parser.test.ts +++ b/packages/typediagram/test/parser.test.ts @@ -60,6 +60,34 @@ describe("parser — small example", () => { const tri = shape.variants.find((v) => v.name === "Triangle"); expect(tri?.fields.map((f) => f.name)).toEqual(["a", "b", "c"]); }); + + it("parses untagged unions", () => { + const untagged = unwrap( + parse(` +untagged union RequestId { + Number(Int) + String(String) +} +`) + ).decls[0] as UnionDecl; + expect(untagged.kind).toBe("union"); + expect(untagged.untagged).toBe(true); + expect(untagged.variants.map((variant) => variant.name)).toEqual(["Number", "String"]); + }); + + it("parses target annotations on declarations", () => { + const decl = unwrap( + parse(` +@targets(rust) +@skipTargets(typescript, python) +type JsonRpcError { + code: Int +} +`) + ).decls[0] as RecordDecl; + expect(decl.targeting?.targets).toEqual(["rust"]); + expect(decl.targeting?.skipTargets).toEqual(["typescript", "python"]); + }); }); describe("parser — chat example", () => { @@ -147,6 +175,32 @@ describe("parser — error handling", () => { expect(diagnostics.some((d) => d.severity === "error")).toBe(true); }); + it("reports unknown annotations and still parses the following declaration", () => { + const { ast, diagnostics } = parsePartial(` +@unknown(rust) +type Foo { x: Int } +`); + expect(diagnostics.some((d) => d.message.includes("unknown annotation"))).toBe(true); + const foo = ast.decls.find((d) => d.name === "Foo") as RecordDecl | undefined; + expect(foo?.kind).toBe("record"); + }); + + it("reports malformed annotations missing parentheses", () => { + const { diagnostics } = parsePartial(` +@targets +type Foo { x: Int } +`); + expect(diagnostics.some((d) => d.message.includes("expected '('"))).toBe(true); + }); + + it("reports malformed annotation target lists", () => { + const { diagnostics } = parsePartial(` +@targets(@bad) +type Foo { x: Int } +`); + expect(diagnostics.some((d) => d.message.includes("target name"))).toBe(true); + }); + it("recovery on record missing LBrace", () => { const { diagnostics } = parsePartial("type Foo x: Int }"); expect(diagnostics.some((d) => d.severity === "error")).toBe(true); diff --git a/packages/typediagram/test/render.test.ts b/packages/typediagram/test/render.test.ts index 58dcc8d..1ffd404 100644 --- a/packages/typediagram/test/render.test.ts +++ b/packages/typediagram/test/render.test.ts @@ -56,6 +56,22 @@ describe("render — chat example", () => { const out = unwrap(await renderToString(SMALL_EXAMPLE)); expect(out).toMatchSnapshot(); }); + + it("renders explicit union discriminants in the SVG text", async () => { + const out = unwrap( + await renderToString(` +union ErrorCode { + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 +} +`) + ); + + expect(out).toContain("ParseError = -32700"); + expect(out).toContain("InvalidRequest = -32600"); + expect(out).toContain("MethodNotFound = -32601"); + }); }); describe("render — error path", () => { diff --git a/packages/vscode/examples/sample.td b/packages/vscode/examples/sample.td index 1416516..8a774af 100644 --- a/packages/vscode/examples/sample.td +++ b/packages/vscode/examples/sample.td @@ -54,6 +54,12 @@ union UriKind { Api } +union ErrorCode { + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 +} + union Option { Some { value: T } None