From 3da982e286d426444667db05e725a535c1713019 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Sun, 3 May 2026 13:40:12 +0500 Subject: [PATCH 01/13] test: cover explicit union discriminants in Rust conversion --- .../typediagram/test/converters/rust.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/typediagram/test/converters/rust.test.ts b/packages/typediagram/test/converters/rust.test.ts index 18f4bbf..61081ef 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", () => { @@ -223,6 +223,27 @@ alias Lookup = Map expect(output).toContain("pub type Email = String"); 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"); + }); }); describe("[CONV-RUST-RT] Rust round-trip TD -> Rust -> TD", () => { From 09202a84fac8e958455ceb1a31b7c439fe625f67 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Sun, 3 May 2026 13:41:39 +0500 Subject: [PATCH 02/13] feat: support explicit numeric discriminants on union variants --- packages/typediagram/src/converters/rust.ts | 15 ++++++++++--- packages/typediagram/src/model/build.ts | 1 + packages/typediagram/src/model/builder.ts | 8 ++++++- packages/typediagram/src/model/json.ts | 3 +++ packages/typediagram/src/model/print.ts | 5 +++-- packages/typediagram/src/model/types.ts | 1 + packages/typediagram/src/parser/ast.ts | 1 + packages/typediagram/src/parser/lexer.ts | 24 +++++++++++++++++++++ packages/typediagram/src/parser/parser.ts | 11 ++++++++++ 9 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/typediagram/src/converters/rust.ts b/packages/typediagram/src/converters/rust.ts index 86f384f..be5c2af 100644 --- a/packages/typediagram/src/converters/rust.ts +++ b/packages/typediagram/src/converters/rust.ts @@ -155,8 +155,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 +184,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; @@ -224,6 +231,7 @@ const fromRust = (source: string): Result => { found = true; const variants = parseRsVariants(body).map((v) => ({ name: v.name, + ...(v.discriminant === undefined ? {} : { discriminant: v.discriminant }), fields: v.fields.map((f) => ({ name: f.name, type: parseTypeRef(f.type) })), })); builder.add(union(name, variants, rsGenerics(gens))); @@ -267,7 +275,8 @@ const toRust = (model: Model): string => { lines.push(`pub enum ${d.name}${genericsStr} {`); for (const v of d.variants) { if (v.fields.length === 0) { - lines.push(` ${v.name},`); + const discriminant = v.discriminant === undefined ? "" : ` = ${v.discriminant}`; + lines.push(` ${v.name}${discriminant},`); } else { lines.push(` ${v.name} { ${v.fields.map((f) => `${f.name}: ${mapTdToRs(f.type)}`).join(", ")} },`); } diff --git a/packages/typediagram/src/model/build.ts b/packages/typediagram/src/model/build.ts index 1337598..70259b3 100644 --- a/packages/typediagram/src/model/build.ts +++ b/packages/typediagram/src/model/build.ts @@ -128,6 +128,7 @@ function resolveVariant( ): ResolvedVariant { return { name: v.name, + ...(v.discriminant === undefined ? {} : { discriminant: v.discriminant }), fields: v.fields.map((f) => resolveField(f, ownerName, declMap, externals, generics, bag)), }; } diff --git a/packages/typediagram/src/model/builder.ts b/packages/typediagram/src/model/builder.ts index 1ce8fc0..fe46910 100644 --- a/packages/typediagram/src/model/builder.ts +++ b/packages/typediagram/src/model/builder.ts @@ -21,6 +21,7 @@ export interface FieldSpec { export interface VariantSpec { name: string; + discriminant?: string; fields?: FieldSpec[]; } @@ -51,7 +52,11 @@ function toField(f: FieldSpec): ResolvedField { } function toVariant(v: VariantSpec): ResolvedVariant { - return { name: v.name, fields: (v.fields ?? []).map(toField) }; + return { + name: v.name, + ...(v.discriminant === undefined ? {} : { discriminant: v.discriminant }), + fields: (v.fields ?? []).map(toField), + }; } export class ModelBuilder { @@ -122,6 +127,7 @@ export function resolveResolutions(model: Model): Model { ...d, variants: d.variants.map((v) => ({ name: v.name, + ...(v.discriminant === undefined ? {} : { discriminant: v.discriminant }), fields: v.fields.map((f) => ({ name: f.name, type: fixRef(f.type, generics, d.name) })), })), }; diff --git a/packages/typediagram/src/model/json.ts b/packages/typediagram/src/model/json.ts index a6d80b5..4f5dac7 100644 --- a/packages/typediagram/src/model/json.ts +++ b/packages/typediagram/src/model/json.ts @@ -36,6 +36,7 @@ export interface FieldJson { } export interface VariantJson { name: string; + discriminant?: string; fields: FieldJson[]; } export interface TypeRefJson { @@ -66,6 +67,7 @@ function declToJson(d: ResolvedDecl): DeclJson { generics: [...d.generics], variants: d.variants.map((v) => ({ name: v.name, + ...(v.discriminant === undefined ? {} : { discriminant: v.discriminant }), fields: v.fields.map((f) => ({ name: f.name, type: refToJson(f.type) })), })), }; @@ -151,6 +153,7 @@ function declFromJson(d: unknown): Result { generics: x.generics, variants: x.variants.map((v) => ({ name: v.name, + ...(v.discriminant === undefined ? {} : { discriminant: v.discriminant }), fields: v.fields.map(fieldFromJson), })), }); diff --git a/packages/typediagram/src/model/print.ts b/packages/typediagram/src/model/print.ts index 8fe0db5..914dec4 100644 --- a/packages/typediagram/src/model/print.ts +++ b/packages/typediagram/src/model/print.ts @@ -18,11 +18,12 @@ function printDecl(d: ResolvedDecl): string { if (d.kind === "union") { const variants = d.variants .map((v) => { + const discriminant = v.discriminant === undefined ? "" : ` = ${v.discriminant}`; if (v.fields.length === 0) { - return ` ${v.name}`; + return ` ${v.name}${discriminant}`; } const inner = v.fields.map((f) => `${f.name}: ${printRef(f.type)}`).join(", "); - return ` ${v.name} { ${inner} }`; + return ` ${v.name}${discriminant} { ${inner} }`; }) .join("\n"); return `union ${d.name}${generics} {\n${variants}\n}`; diff --git a/packages/typediagram/src/model/types.ts b/packages/typediagram/src/model/types.ts index d3844ea..08f829b 100644 --- a/packages/typediagram/src/model/types.ts +++ b/packages/typediagram/src/model/types.ts @@ -36,6 +36,7 @@ export interface ResolvedField { export interface ResolvedVariant { name: string; + discriminant?: string; fields: ResolvedField[]; } diff --git a/packages/typediagram/src/parser/ast.ts b/packages/typediagram/src/parser/ast.ts index b516867..030c14a 100644 --- a/packages/typediagram/src/parser/ast.ts +++ b/packages/typediagram/src/parser/ast.ts @@ -44,6 +44,7 @@ export interface Field { export interface Variant { name: string; + discriminant?: string; fields: Field[]; span: Span; } diff --git a/packages/typediagram/src/parser/lexer.ts b/packages/typediagram/src/parser/lexer.ts index 8ab0b4f..b398f2e 100644 --- a/packages/typediagram/src/parser/lexer.ts +++ b/packages/typediagram/src/parser/lexer.ts @@ -6,6 +6,7 @@ export type TokenKind = | "AliasKw" | "TypeDiagramKw" | "Ident" + | "Number" | "LBrace" | "RBrace" | "LAngle" @@ -50,6 +51,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; @@ -116,6 +121,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 22dc752..3fa1a08 100644 --- a/packages/typediagram/src/parser/parser.ts +++ b/packages/typediagram/src/parser/parser.ts @@ -246,6 +246,7 @@ class Parser { this.skipToFieldBoundary(); return null; } + const discriminant = this.parseVariantDiscriminant(); let fields: Field[] = []; if (this.cur.peek().kind === "LBrace") { this.cur.next(); @@ -254,11 +255,21 @@ class Parser { } return { name: nameTok.value, + ...(discriminant === undefined ? {} : { discriminant }), fields, span: spanBetween(nameTok, this.cur.peek()), }; } + 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 parseTypeRef(): TypeRef | null { const nameTok = this.expect("Ident", "type name"); if (nameTok === null) { From 7520bd888aa72b7db4779ee3e5aad82db14e728a Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Sun, 3 May 2026 13:41:57 +0500 Subject: [PATCH 03/13] fix: render union discriminants in diagram rows --- packages/typediagram/src/layout/elk.ts | 8 ++++++-- packages/typediagram/test/render.test.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/typediagram/src/layout/elk.ts b/packages/typediagram/src/layout/elk.ts index 92ffe46..a7d6a6f 100644 --- a/packages/typediagram/src/layout/elk.ts +++ b/packages/typediagram/src/layout/elk.ts @@ -80,6 +80,10 @@ function rowText(name: string, type: ResolvedTypeRef): string { return `${name}: ${printRefShort(type)}`; } +function variantText(name: string, discriminant?: string): string { + return discriminant === undefined ? name : `${name} = ${discriminant}`; +} + function printRefShort(t: ResolvedTypeRef): string { if (t.args.length === 0) { return t.name; @@ -118,8 +122,8 @@ function buildPreNodes(decls: ResolvedDecl[], fontSize: number, padX: number, pa } } else if (d.kind === "union") { for (const v of d.variants) { - const variantHeader = - v.fields.length === 0 ? v.name : `${v.name} { ${v.fields.map((f) => rowText(f.name, f.type)).join(", ")} }`; + const head = variantText(v.name, v.discriminant); + const variantHeader = v.fields.length === 0 ? head : `${head} { ${v.fields.map((f) => rowText(f.name, f.type)).join(", ")} }`; const m = measureText(variantHeader, fontSize); if (m.w > widest) { widest = m.w; 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", () => { From f2fbf9ea49bcf9bcbf3550f05c1f464454c9fc4a Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Sun, 3 May 2026 13:42:08 +0500 Subject: [PATCH 04/13] docs: add explicit discriminant examples --- docs/specs/language-reference.md | 15 ++++++++++++++- packages/typediagram/README.md | 10 ++++++++++ packages/vscode/examples/sample.td | 6 ++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/specs/language-reference.md b/docs/specs/language-reference.md index e13ca21..afbea5a 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 ``` @@ -133,10 +145,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/vscode/examples/sample.td b/packages/vscode/examples/sample.td index ab0d1c2..687bee5 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 From f6fad712eecc685ac696995e0a8996cb801c3f36 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Sun, 3 May 2026 13:42:50 +0500 Subject: [PATCH 05/13] fmt --- packages/typediagram/src/layout/elk.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/typediagram/src/layout/elk.ts b/packages/typediagram/src/layout/elk.ts index a7d6a6f..e0e596d 100644 --- a/packages/typediagram/src/layout/elk.ts +++ b/packages/typediagram/src/layout/elk.ts @@ -123,7 +123,8 @@ function buildPreNodes(decls: ResolvedDecl[], fontSize: number, padX: number, pa } else if (d.kind === "union") { for (const v of d.variants) { const head = variantText(v.name, v.discriminant); - const variantHeader = v.fields.length === 0 ? head : `${head} { ${v.fields.map((f) => rowText(f.name, f.type)).join(", ")} }`; + const variantHeader = + v.fields.length === 0 ? head : `${head} { ${v.fields.map((f) => rowText(f.name, f.type)).join(", ")} }`; const m = measureText(variantHeader, fontSize); if (m.w > widest) { widest = m.w; From a3b0200b981410962f94ccc124491a0f4b403e7b Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Sun, 3 May 2026 17:29:31 +0500 Subject: [PATCH 06/13] ci ready --- packages/typediagram/scripts/bundle-size.mjs | 2 +- .../typediagram/src/converters/protobuf.ts | 7 +---- packages/typediagram/src/converters/rust.ts | 22 ++++++++----- packages/typediagram/src/index.ts | 5 +-- packages/typediagram/src/layout/elk.ts | 11 ++----- packages/typediagram/src/model/build.ts | 20 ++++++------ packages/typediagram/src/model/builder.ts | 27 ++++++++++------ packages/typediagram/src/model/json.ts | 31 ++++++++++++------- packages/typediagram/src/model/print.ts | 7 +++-- packages/typediagram/src/parser/parser.ts | 15 +++++---- packages/typediagram/src/variant.ts | 10 ++++++ 11 files changed, 91 insertions(+), 66 deletions(-) create mode 100644 packages/typediagram/src/variant.ts diff --git a/packages/typediagram/scripts/bundle-size.mjs b/packages/typediagram/scripts/bundle-size.mjs index 5935744..651a824 100644 --- a/packages/typediagram/scripts/bundle-size.mjs +++ b/packages/typediagram/scripts/bundle-size.mjs @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const entry = resolve(here, "..", "src", "index.ts"); -const BUDGET_KB = 75; +const BUDGET_KB = 75.5; const result = await build({ entryPoints: [entry], diff --git a/packages/typediagram/src/converters/protobuf.ts b/packages/typediagram/src/converters/protobuf.ts index 98ba88e..7af419d 100644 --- a/packages/typediagram/src/converters/protobuf.ts +++ b/packages/typediagram/src/converters/protobuf.ts @@ -21,7 +21,7 @@ import type { Model, ResolvedTypeRef } 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 ── @@ -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/rust.ts b/packages/typediagram/src/converters/rust.ts index be5c2af..196a6dd 100644 --- a/packages/typediagram/src/converters/rust.ts +++ b/packages/typediagram/src/converters/rust.ts @@ -1,6 +1,7 @@ // [CONV-RUST] Rust <-> typeDiagram bidirectional converter. import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; +import { formatVariantName, withDiscriminant } from "../variant.js"; import type { Model, ResolvedTypeRef } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; @@ -229,11 +230,19 @@ const fromRust = (source: string): Result => { continue; } found = true; - const variants = parseRsVariants(body).map((v) => ({ - name: v.name, - ...(v.discriminant === undefined ? {} : { discriminant: v.discriminant }), - 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))); } @@ -275,8 +284,7 @@ const toRust = (model: Model): string => { lines.push(`pub enum ${d.name}${genericsStr} {`); for (const v of d.variants) { if (v.fields.length === 0) { - const discriminant = v.discriminant === undefined ? "" : ` = ${v.discriminant}`; - lines.push(` ${v.name}${discriminant},`); + lines.push(` ${formatVariantName(v.name, v.discriminant)},`); } else { lines.push(` ${v.name} { ${v.fields.map((f) => `${f.name}: ${mapTdToRs(f.type)}`).join(", ")} },`); } 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 e0e596d..e6bfdb1 100644 --- a/packages/typediagram/src/layout/elk.ts +++ b/packages/typediagram/src/layout/elk.ts @@ -2,7 +2,8 @@ import ELK from "elkjs/lib/elk.bundled.js"; import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err, ok } from "../result.js"; import type { Edge, Model, ResolvedDecl, 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; @@ -80,10 +81,6 @@ function rowText(name: string, type: ResolvedTypeRef): string { return `${name}: ${printRefShort(type)}`; } -function variantText(name: string, discriminant?: string): string { - return discriminant === undefined ? name : `${name} = ${discriminant}`; -} - function printRefShort(t: ResolvedTypeRef): string { if (t.args.length === 0) { return t.name; @@ -122,7 +119,7 @@ function buildPreNodes(decls: ResolvedDecl[], fontSize: number, padX: number, pa } } else if (d.kind === "union") { for (const v of d.variants) { - const head = variantText(v.name, v.discriminant); + const head = formatVariantName(v.name, v.discriminant); const variantHeader = v.fields.length === 0 ? head : `${head} { ${v.fields.map((f) => rowText(f.name, f.type)).join(", ")} }`; const m = measureText(variantHeader, fontSize); @@ -147,8 +144,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 70259b3..f4df286 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, @@ -126,11 +127,13 @@ function resolveVariant( generics: Set, bag: DiagnosticBag ): ResolvedVariant { - return { - name: v.name, - ...(v.discriminant === undefined ? {} : { discriminant: v.discriminant }), - 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( @@ -204,7 +207,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(); @@ -260,8 +263,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 fe46910..6f4deda 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, @@ -52,11 +53,13 @@ function toField(f: FieldSpec): ResolvedField { } function toVariant(v: VariantSpec): ResolvedVariant { - return { - name: v.name, - ...(v.discriminant === undefined ? {} : { discriminant: v.discriminant }), - fields: (v.fields ?? []).map(toField), - }; + return withDiscriminant( + { + name: v.name, + fields: (v.fields ?? []).map(toField), + }, + v.discriminant + ); } export class ModelBuilder { @@ -125,11 +128,15 @@ export function resolveResolutions(model: Model): Model { if (d.kind === "union") { return { ...d, - variants: d.variants.map((v) => ({ - name: v.name, - ...(v.discriminant === undefined ? {} : { discriminant: v.discriminant }), - 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/json.ts b/packages/typediagram/src/model/json.ts index 4f5dac7..7adbebb 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 { Model, ResolvedDecl, ResolvedTypeRef, ResolvedVariant } from "./types.js"; export const SCHEMA_VERSION = 1; @@ -65,11 +66,15 @@ function declToJson(d: ResolvedDecl): DeclJson { kind: "union", name: d.name, generics: [...d.generics], - variants: d.variants.map((v) => ({ - name: v.name, - ...(v.discriminant === undefined ? {} : { discriminant: v.discriminant }), - fields: v.fields.map((f) => ({ name: f.name, type: refToJson(f.type) })), - })), + variants: d.variants.map((v) => + withDiscriminant( + { + name: v.name, + fields: v.fields.map((f) => ({ name: f.name, type: refToJson(f.type) })), + }, + v.discriminant + ) + ), }; } return { @@ -151,11 +156,15 @@ function declFromJson(d: unknown): Result { kind: "union", name: x.name, generics: x.generics, - variants: x.variants.map((v) => ({ - name: v.name, - ...(v.discriminant === undefined ? {} : { discriminant: v.discriminant }), - fields: v.fields.map(fieldFromJson), - })), + variants: x.variants.map((v) => + withDiscriminant( + { + name: v.name, + fields: v.fields.map(fieldFromJson), + }, + v.discriminant + ) + ), }); } if (x.kind === "alias") { diff --git a/packages/typediagram/src/model/print.ts b/packages/typediagram/src/model/print.ts index 914dec4..aeff195 100644 --- a/packages/typediagram/src/model/print.ts +++ b/packages/typediagram/src/model/print.ts @@ -1,4 +1,5 @@ import type { Model, ResolvedDecl, ResolvedTypeRef } from "./types.js"; +import { formatVariantName } from "../variant.js"; export function printSource(model: Model): string { const out: string[] = ["typeDiagram", ""]; @@ -18,12 +19,12 @@ function printDecl(d: ResolvedDecl): string { if (d.kind === "union") { const variants = d.variants .map((v) => { - const discriminant = v.discriminant === undefined ? "" : ` = ${v.discriminant}`; + const head = formatVariantName(v.name, v.discriminant); if (v.fields.length === 0) { - return ` ${v.name}${discriminant}`; + return ` ${head}`; } const inner = v.fields.map((f) => `${f.name}: ${printRef(f.type)}`).join(", "); - return ` ${v.name}${discriminant} { ${inner} }`; + return ` ${head} { ${inner} }`; }) .join("\n"); return `union ${d.name}${generics} {\n${variants}\n}`; diff --git a/packages/typediagram/src/parser/parser.ts b/packages/typediagram/src/parser/parser.ts index 3fa1a08..634459d 100644 --- a/packages/typediagram/src/parser/parser.ts +++ b/packages/typediagram/src/parser/parser.ts @@ -4,6 +4,7 @@ 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; @@ -253,12 +254,14 @@ class Parser { fields = this.parseFieldList(); this.expect("RBrace", "'}'"); } - return { - name: nameTok.value, - ...(discriminant === undefined ? {} : { discriminant }), - fields, - span: spanBetween(nameTok, this.cur.peek()), - }; + return withDiscriminant( + { + name: nameTok.value, + fields, + span: spanBetween(nameTok, this.cur.peek()), + }, + discriminant + ); } private parseVariantDiscriminant(): string | undefined { 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}`; +} From 2d5b58d0c1fde0df069555daf2e122999894e972 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Mon, 4 May 2026 22:49:22 +0500 Subject: [PATCH 07/13] test: add coverage for untagged unions --- .../typediagram/test/converters/rust.test.ts | 16 +++++++++ .../test/converters/typescript.test.ts | 35 +++++++++++++++++++ packages/typediagram/test/model.test.ts | 25 +++++++++++++ packages/typediagram/test/parser.test.ts | 14 ++++++++ 4 files changed, 90 insertions(+) diff --git a/packages/typediagram/test/converters/rust.test.ts b/packages/typediagram/test/converters/rust.test.ts index 48bffeb..594542b 100644 --- a/packages/typediagram/test/converters/rust.test.ts +++ b/packages/typediagram/test/converters/rust.test.ts @@ -259,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..1a1889b 100644 --- a/packages/typediagram/test/converters/typescript.test.ts +++ b/packages/typediagram/test/converters/typescript.test.ts @@ -269,6 +269,41 @@ 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:"); + }); }); describe("[CONV-TS-RT] TypeScript round-trip TD -> TS -> TD", () => { diff --git a/packages/typediagram/test/model.test.ts b/packages/typediagram/test/model.test.ts index eb27aba..a6b88eb 100644 --- a/packages/typediagram/test/model.test.ts +++ b/packages/typediagram/test/model.test.ts @@ -214,6 +214,31 @@ 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("rejects wrong schema version", () => { const r = fromJSON({ version: 99, decls: [] }); expect(r.ok).toBe(false); diff --git a/packages/typediagram/test/parser.test.ts b/packages/typediagram/test/parser.test.ts index 370f7c5..857ecac 100644 --- a/packages/typediagram/test/parser.test.ts +++ b/packages/typediagram/test/parser.test.ts @@ -60,6 +60,20 @@ 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"]); + }); }); describe("parser — chat example", () => { From 8bdf21b48302e9cb8ae8a907a6ce3d646ad5f187 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Mon, 4 May 2026 22:49:54 +0500 Subject: [PATCH 08/13] feat: add untagged union support to DSL and converters --- packages/typediagram/src/converters/rust.ts | 3 ++ .../typediagram/src/converters/typescript.ts | 31 ++++++++++++++----- packages/typediagram/src/model/build.ts | 1 + packages/typediagram/src/model/builder.ts | 7 ++++- packages/typediagram/src/model/index.ts | 2 +- packages/typediagram/src/model/json.ts | 3 ++ packages/typediagram/src/model/print.ts | 2 +- packages/typediagram/src/model/types.ts | 1 + packages/typediagram/src/parser/ast.ts | 1 + packages/typediagram/src/parser/lexer.ts | 2 ++ packages/typediagram/src/parser/parser.ts | 27 ++++++++++++++-- 11 files changed, 67 insertions(+), 13 deletions(-) diff --git a/packages/typediagram/src/converters/rust.ts b/packages/typediagram/src/converters/rust.ts index 323343a..c86fe5e 100644 --- a/packages/typediagram/src/converters/rust.ts +++ b/packages/typediagram/src/converters/rust.ts @@ -281,6 +281,9 @@ 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) { diff --git a/packages/typediagram/src/converters/typescript.ts b/packages/typediagram/src/converters/typescript.ts index d3d5801..9ebe8b2 100644 --- a/packages/typediagram/src/converters/typescript.ts +++ b/packages/typediagram/src/converters/typescript.ts @@ -12,7 +12,7 @@ // 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 } 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,6 +269,20 @@ 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[] = []; @@ -282,12 +296,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/model/build.ts b/packages/typediagram/src/model/build.ts index f4df286..67677a7 100644 --- a/packages/typediagram/src/model/build.ts +++ b/packages/typediagram/src/model/build.ts @@ -100,6 +100,7 @@ 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)), }; } diff --git a/packages/typediagram/src/model/builder.ts b/packages/typediagram/src/model/builder.ts index 6f4deda..b45f25b 100644 --- a/packages/typediagram/src/model/builder.ts +++ b/packages/typediagram/src/model/builder.ts @@ -26,6 +26,10 @@ export interface VariantSpec { 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" } }; @@ -35,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), }; } diff --git a/packages/typediagram/src/model/index.ts b/packages/typediagram/src/model/index.ts index 7f42dc8..8872306 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, diff --git a/packages/typediagram/src/model/json.ts b/packages/typediagram/src/model/json.ts index 7adbebb..96e49db 100644 --- a/packages/typediagram/src/model/json.ts +++ b/packages/typediagram/src/model/json.ts @@ -23,6 +23,7 @@ export interface UnionJson { kind: "union"; name: string; generics: string[]; + untagged?: true; variants: VariantJson[]; } export interface AliasJson { @@ -66,6 +67,7 @@ function declToJson(d: ResolvedDecl): DeclJson { kind: "union", name: d.name, generics: [...d.generics], + ...(d.untagged === true ? { untagged: true as const } : {}), variants: d.variants.map((v) => withDiscriminant( { @@ -156,6 +158,7 @@ function declFromJson(d: unknown): Result { kind: "union", name: x.name, generics: x.generics, + ...(x.untagged === true ? { untagged: true as const } : {}), variants: x.variants.map((v) => withDiscriminant( { diff --git a/packages/typediagram/src/model/print.ts b/packages/typediagram/src/model/print.ts index 8945cb0..7e7b835 100644 --- a/packages/typediagram/src/model/print.ts +++ b/packages/typediagram/src/model/print.ts @@ -30,7 +30,7 @@ function printDecl(d: ResolvedDecl): string { 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 58ecdd4..403cf0a 100644 --- a/packages/typediagram/src/model/types.ts +++ b/packages/typediagram/src/model/types.ts @@ -19,6 +19,7 @@ export interface ResolvedUnion { kind: "union"; name: string; generics: string[]; + untagged?: true; variants: ResolvedVariant[]; } diff --git a/packages/typediagram/src/parser/ast.ts b/packages/typediagram/src/parser/ast.ts index 030c14a..c54f381 100644 --- a/packages/typediagram/src/parser/ast.ts +++ b/packages/typediagram/src/parser/ast.ts @@ -24,6 +24,7 @@ export interface UnionDecl { kind: "union"; name: string; generics: string[]; + untagged?: true; variants: Variant[]; span: Span; } diff --git a/packages/typediagram/src/parser/lexer.ts b/packages/typediagram/src/parser/lexer.ts index 0d3d689..6f1dc8b 100644 --- a/packages/typediagram/src/parser/lexer.ts +++ b/packages/typediagram/src/parser/lexer.ts @@ -3,6 +3,7 @@ import type { DiagnosticBag } from "./diagnostics.js"; export type TokenKind = | "TypeKw" | "UnionKw" + | "UntaggedKw" | "AliasKw" | "TypeDiagramKw" | "Ident" @@ -31,6 +32,7 @@ export interface Token { const KEYWORDS: Record = { type: "TypeKw", union: "UnionKw", + untagged: "UntaggedKw", alias: "AliasKw", typeDiagram: "TypeDiagramKw", }; diff --git a/packages/typediagram/src/parser/parser.ts b/packages/typediagram/src/parser/parser.ts index 3604e01..6b5cded 100644 --- a/packages/typediagram/src/parser/parser.ts +++ b/packages/typediagram/src/parser/parser.ts @@ -83,10 +83,29 @@ class Parser { if (t.kind === "UnionKw") { return this.parseUnion(); } + 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); + } 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); + this.diags.error( + `expected 'type', 'union', 'untagged union', or 'alias', got ${describe(t)}`, + t.line, + t.col, + t.length || 1 + ); this.recoverToTopLevel(); return null; } @@ -114,7 +133,8 @@ class Parser { }; } - private parseUnion(): UnionDecl | null { + private parseUnion(untagged = false): 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) { @@ -132,8 +152,9 @@ class Parser { kind: "union", name: nameTok.value, generics, + ...(untagged ? { untagged: true as const } : {}), variants, - span: spanBetween(kw, closeTok ?? kw), + span: spanBetween(start, closeTok ?? kw), }; } From beda1ffe51cad10fcbd0aa0b14af6360390aad75 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Mon, 4 May 2026 22:50:02 +0500 Subject: [PATCH 09/13] ci: raise typediagram bundle size budget to 77 KB --- packages/typediagram/scripts/bundle-size.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/typediagram/scripts/bundle-size.mjs b/packages/typediagram/scripts/bundle-size.mjs index 7cee824..79eeeb1 100644 --- a/packages/typediagram/scripts/bundle-size.mjs +++ b/packages/typediagram/scripts/bundle-size.mjs @@ -3,15 +3,15 @@ // 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 plus explicit discriminants pushed the minified core slightly -// higher, so the current budget is 76.5 KB. +// Tuple variants, explicit discriminants, and untagged unions pushed the +// minified core slightly higher, so the current budget is 77 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 = 76.5; +const BUDGET_KB = 77; const result = await build({ entryPoints: [entry], From 666921c5841cbb271d5cca7f7c89e3877629bb7d Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Mon, 4 May 2026 23:38:08 +0500 Subject: [PATCH 10/13] test(typediagram): add failing coverage for per-target declaration gating --- .../test/converters/typescript.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/typediagram/test/converters/typescript.test.ts b/packages/typediagram/test/converters/typescript.test.ts index 1a1889b..040ee5a 100644 --- a/packages/typediagram/test/converters/typescript.test.ts +++ b/packages/typediagram/test/converters/typescript.test.ts @@ -304,6 +304,43 @@ untagged union Value { 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", () => { From f7a4552f111da93a1045dc332075a53c5c82f165 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Mon, 4 May 2026 23:39:13 +0500 Subject: [PATCH 11/13] feat(typediagram): support @targets and @skipTargets on declarations --- packages/typediagram/src/converters/csharp.ts | 4 +- packages/typediagram/src/converters/dart.ts | 4 +- packages/typediagram/src/converters/fsharp.ts | 4 +- packages/typediagram/src/converters/go.ts | 4 +- packages/typediagram/src/converters/php.ts | 4 +- .../typediagram/src/converters/protobuf.ts | 4 +- packages/typediagram/src/converters/python.ts | 45 ++++----- packages/typediagram/src/converters/rust.ts | 5 +- .../typediagram/src/converters/typescript.ts | 5 +- packages/typediagram/src/model/build.ts | 3 + packages/typediagram/src/model/index.ts | 3 + packages/typediagram/src/model/json.ts | 52 ++++++++++- packages/typediagram/src/model/print.ts | 12 +++ packages/typediagram/src/model/types.ts | 21 +++++ packages/typediagram/src/parser/ast.ts | 8 ++ packages/typediagram/src/parser/index.ts | 2 +- packages/typediagram/src/parser/lexer.ts | 2 + packages/typediagram/src/parser/parser.ts | 59 ++++++++++-- packages/typediagram/test/lexer.test.ts | 4 +- packages/typediagram/test/model.test.ts | 93 +++++++++++++++++++ packages/typediagram/test/parser.test.ts | 40 ++++++++ 21 files changed, 328 insertions(+), 50 deletions(-) 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 7af419d..22a6c1e 100644 --- a/packages/typediagram/src/converters/protobuf.ts +++ b/packages/typediagram/src/converters/protobuf.ts @@ -17,7 +17,7 @@ // 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"; @@ -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} {`); 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 c86fe5e..6c014f8 100644 --- a/packages/typediagram/src/converters/rust.ts +++ b/packages/typediagram/src/converters/rust.ts @@ -2,7 +2,7 @@ import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; import { formatVariantName, withDiscriminant } from "../variant.js"; -import { isTupleVariantFields, type Model, type ResolvedTypeRef } from "../model/types.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"; @@ -270,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") { diff --git a/packages/typediagram/src/converters/typescript.ts b/packages/typediagram/src/converters/typescript.ts index 9ebe8b2..7e98b31 100644 --- a/packages/typediagram/src/converters/typescript.ts +++ b/packages/typediagram/src/converters/typescript.ts @@ -12,7 +12,7 @@ // preserve the original nullability form in the model. import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import { isTupleVariantFields, type Model, type ResolvedTypeRef, type ResolvedVariant } 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"; @@ -285,8 +285,9 @@ const mapUntaggedVariantToTs = (variant: ResolvedVariant): string => { 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") { diff --git a/packages/typediagram/src/model/build.ts b/packages/typediagram/src/model/build.ts index 67677a7..7685290 100644 --- a/packages/typediagram/src/model/build.ts +++ b/packages/typediagram/src/model/build.ts @@ -86,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 } }), }; } @@ -102,6 +103,7 @@ function resolveUnion( 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 } }), }; } @@ -117,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 } }), }; } diff --git a/packages/typediagram/src/model/index.ts b/packages/typediagram/src/model/index.ts index 8872306..02303f1 100644 --- a/packages/typediagram/src/model/index.ts +++ b/packages/typediagram/src/model/index.ts @@ -16,6 +16,7 @@ export { printSource } from "./print.js"; export { validate } from "./validate.js"; export { PRIMITIVES, + type DeclTargeting, type Edge, type EdgeKind, type Model, @@ -27,4 +28,6 @@ export { type ResolvedTypeRef, type ResolvedUnion, type ResolvedVariant, + shouldEmitDeclToTarget, + visibleDeclsForTarget, } from "./types.js"; diff --git a/packages/typediagram/src/model/json.ts b/packages/typediagram/src/model/json.ts index 96e49db..5980045 100644 --- a/packages/typediagram/src/model/json.ts +++ b/packages/typediagram/src/model/json.ts @@ -2,7 +2,7 @@ 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, ResolvedVariant } from "./types.js"; +import type { DeclTargeting, Model, ResolvedDecl, ResolvedTypeRef, ResolvedVariant } from "./types.js"; export const SCHEMA_VERSION = 1; @@ -18,6 +18,7 @@ export interface RecordJson { name: string; generics: string[]; fields: FieldJson[]; + targeting?: DeclTargeting; } export interface UnionJson { kind: "union"; @@ -25,12 +26,14 @@ export interface UnionJson { 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; @@ -60,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") { @@ -68,6 +72,7 @@ function declToJson(d: ResolvedDecl): DeclJson { name: d.name, generics: [...d.generics], ...(d.untagged === true ? { untagged: true as const } : {}), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), variants: d.variants.map((v) => withDiscriminant( { @@ -84,6 +89,7 @@ function declToJson(d: ResolvedDecl): DeclJson { name: d.name, generics: [...d.generics], target: refToJson(d.target), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), }; } @@ -143,17 +149,26 @@ 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, @@ -168,22 +183,57 @@ function declFromJson(d: unknown): Result { 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 7e7b835..a9b2dee 100644 --- a/packages/typediagram/src/model/print.ts +++ b/packages/typediagram/src/model/print.ts @@ -4,12 +4,24 @@ 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") { diff --git a/packages/typediagram/src/model/types.ts b/packages/typediagram/src/model/types.ts index 403cf0a..ae98193 100644 --- a/packages/typediagram/src/model/types.ts +++ b/packages/typediagram/src/model/types.ts @@ -8,11 +8,17 @@ 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 { @@ -21,6 +27,7 @@ export interface ResolvedUnion { generics: string[]; untagged?: true; variants: ResolvedVariant[]; + targeting?: DeclTargeting; } export interface ResolvedAlias { @@ -28,6 +35,7 @@ export interface ResolvedAlias { name: string; generics: string[]; target: ResolvedTypeRef; + targeting?: DeclTargeting; } export interface ResolvedField { @@ -74,3 +82,16 @@ 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; + } + const blacklist = decl.targeting?.skipTargets; + return !(blacklist !== undefined && blacklist.includes(target)); +} + +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 c54f381..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; } @@ -26,6 +32,7 @@ export interface UnionDecl { generics: string[]; untagged?: true; variants: Variant[]; + targeting?: DeclTargeting; span: Span; } @@ -34,6 +41,7 @@ export interface AliasDecl { name: string; generics: string[]; target: TypeRef; + targeting?: DeclTargeting; span: Span; } diff --git a/packages/typediagram/src/parser/index.ts b/packages/typediagram/src/parser/index.ts index 814ce30..114a4c9 100644 --- a/packages/typediagram/src/parser/index.ts +++ b/packages/typediagram/src/parser/index.ts @@ -1,6 +1,6 @@ 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 6f1dc8b..1ac5885 100644 --- a/packages/typediagram/src/parser/lexer.ts +++ b/packages/typediagram/src/parser/lexer.ts @@ -14,6 +14,7 @@ export type TokenKind = | "RParen" | "LAngle" | "RAngle" + | "At" | "Comma" | "Colon" | "Equals" @@ -44,6 +45,7 @@ const SINGLE_CHAR: Record = { ")": "RParen", "<": "LAngle", ">": "RAngle", + "@": "At", ",": "Comma", ":": "Colon", "=": "Equals", diff --git a/packages/typediagram/src/parser/parser.ts b/packages/typediagram/src/parser/parser.ts index 6b5cded..2db1d2c 100644 --- a/packages/typediagram/src/parser/parser.ts +++ b/packages/typediagram/src/parser/parser.ts @@ -1,4 +1,4 @@ -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"; @@ -76,12 +76,13 @@ 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); @@ -95,10 +96,10 @@ class Parser { this.recoverToTopLevel(); return null; } - return this.parseUnion(true); + return this.parseUnion(true, targeting); } if (t.kind === "AliasKw") { - return this.parseAlias(); + return this.parseAlias(targeting); } this.diags.error( `expected 'type', 'union', 'untagged union', or 'alias', got ${describe(t)}`, @@ -110,7 +111,46 @@ class Parser { 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) { @@ -129,11 +169,12 @@ class Parser { name: nameTok.value, generics, fields, + ...(targeting === undefined ? {} : { targeting }), span: spanBetween(kw, closeTok ?? kw), }; } - private parseUnion(untagged = false): 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"); @@ -154,11 +195,12 @@ class Parser { generics, ...(untagged ? { untagged: true as const } : {}), variants, + ...(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) { @@ -180,6 +222,7 @@ class Parser { name: nameTok.value, generics, target, + ...(targeting === undefined ? {} : { targeting }), span: spanBetween(kw, this.cur.peek()), }; } 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 a6b88eb..51701a6 100644 --- a/packages/typediagram/test/model.test.ts +++ b/packages/typediagram/test/model.test.ts @@ -9,9 +9,11 @@ import { printSource, record, ref, + shouldEmitDeclToTarget, toJSON, union, validate, + visibleDeclsForTarget, } from "../src/model/index.js"; import { CHAT_EXAMPLE, SMALL_EXAMPLE } from "./fixtures.js"; @@ -239,6 +241,24 @@ untagged union RequestId { 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); @@ -356,6 +376,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)))); @@ -375,6 +429,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 857ecac..5c9212e 100644 --- a/packages/typediagram/test/parser.test.ts +++ b/packages/typediagram/test/parser.test.ts @@ -74,6 +74,20 @@ untagged union RequestId { 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", () => { @@ -161,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); From 4b009b622b5f8a79ec971f5e6ca70a4b140cd4e8 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Tue, 5 May 2026 05:26:39 +0500 Subject: [PATCH 12/13] lint --- packages/typediagram/src/converters/typescript.ts | 8 +++++++- packages/typediagram/src/model/index.ts | 2 -- packages/typediagram/src/model/types.ts | 8 +++++--- packages/typediagram/src/parser/index.ts | 13 ++++++++++++- packages/typediagram/src/parser/parser.ts | 13 ++++++++++++- packages/typediagram/test/model.test.ts | 3 +-- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/typediagram/src/converters/typescript.ts b/packages/typediagram/src/converters/typescript.ts index 7e98b31..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 { isTupleVariantFields, type Model, type ResolvedTypeRef, type ResolvedVariant, visibleDeclsForTarget } 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"; diff --git a/packages/typediagram/src/model/index.ts b/packages/typediagram/src/model/index.ts index 02303f1..fef3a8f 100644 --- a/packages/typediagram/src/model/index.ts +++ b/packages/typediagram/src/model/index.ts @@ -28,6 +28,4 @@ export { type ResolvedTypeRef, type ResolvedUnion, type ResolvedVariant, - shouldEmitDeclToTarget, - visibleDeclsForTarget, } from "./types.js"; diff --git a/packages/typediagram/src/model/types.ts b/packages/typediagram/src/model/types.ts index ae98193..aef12b8 100644 --- a/packages/typediagram/src/model/types.ts +++ b/packages/typediagram/src/model/types.ts @@ -88,10 +88,12 @@ export function shouldEmitDeclToTarget(decl: { targeting?: DeclTargeting }, targ if (whitelist !== undefined && whitelist.length > 0 && !whitelist.includes(target)) { return false; } - const blacklist = decl.targeting?.skipTargets; - return !(blacklist !== undefined && blacklist.includes(target)); + return decl.targeting?.skipTargets?.includes(target) !== true; } -export function visibleDeclsForTarget(decls: readonly T[], target: string): T[] { +export function visibleDeclsForTarget( + decls: readonly T[], + target: string +): T[] { return decls.filter((decl) => shouldEmitDeclToTarget(decl, target)); } diff --git a/packages/typediagram/src/parser/index.ts b/packages/typediagram/src/parser/index.ts index 114a4c9..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, DeclTargeting, 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/parser.ts b/packages/typediagram/src/parser/parser.ts index 2db1d2c..1173784 100644 --- a/packages/typediagram/src/parser/parser.ts +++ b/packages/typediagram/src/parser/parser.ts @@ -1,4 +1,15 @@ -import type { AliasDecl, DeclTargeting, 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"; diff --git a/packages/typediagram/test/model.test.ts b/packages/typediagram/test/model.test.ts index 51701a6..f13b4b6 100644 --- a/packages/typediagram/test/model.test.ts +++ b/packages/typediagram/test/model.test.ts @@ -9,12 +9,11 @@ import { printSource, record, ref, - shouldEmitDeclToTarget, toJSON, union, validate, - visibleDeclsForTarget, } 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 { From b07a49ad7095674ed74d06142a02ba289e3168a0 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Tue, 5 May 2026 05:26:50 +0500 Subject: [PATCH 13/13] increase bundle size --- packages/typediagram/scripts/bundle-size.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/typediagram/scripts/bundle-size.mjs b/packages/typediagram/scripts/bundle-size.mjs index 79eeeb1..a8718c7 100644 --- a/packages/typediagram/scripts/bundle-size.mjs +++ b/packages/typediagram/scripts/bundle-size.mjs @@ -4,14 +4,15 @@ // 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, so the current budget is 77 KB. +// 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 = 77; +const BUDGET_KB = 80; const result = await build({ entryPoints: [entry],