diff --git a/README.md b/README.md index 905d4a2..94ed4a9 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ typeDiagram is a tiny, language-neutral DSL for describing **algebraic data types** — records, tagged unions, generics, aliases. From one `.td` file, you get: -- **Source code** in TypeScript, Python, Rust, Go, and C# — DTOs, data classes, discriminated unions, pattern-matchable enums — generated from the same definition, always in sync. +- **Source code** in TypeScript, Python, Rust, Go, C#, and PHP — DTOs, data classes, discriminated unions, pattern-matchable enums — generated from the same definition, always in sync. - **SVG diagrams** with automatic orthogonal layout — no dragging, no fiddling, versionable in git. -- **Round-trip conversion** from existing TypeScript/Python/Rust/Go/C# back to the DSL, so you can retrofit an existing codebase. +- **Round-trip conversion** from existing TypeScript/Python/Rust/Go/C#/PHP back to the DSL, so you can retrofit an existing codebase. This is not a diagramming tool dressed up with a text input like Mermaid or PlantUML. typeDiagram is a **shared schema for your data model** — the diagram is a side effect, not the goal. The primary output is code, in as many languages as you need, kept strictly in sync by construction. diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 5af49ba..9d236a5 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -6,14 +6,14 @@ "packages/typediagram": { "statements": 95, "branches": 90.76, - "functions": 98.5, + "functions": 98.57, "lines": 95 }, "packages/cli": { - "statements": 96.71, + "statements": 96.72, "branches": 97.16, "functions": 99, - "lines": 96.71 + "lines": 96.72 }, "packages/web": { "statements": 95.43, diff --git a/package-lock.json b/package-lock.json index 11bdc3a..c8bae5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -329,7 +329,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=12" } @@ -347,7 +346,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -365,7 +363,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -383,7 +380,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -401,7 +397,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=12" } @@ -419,7 +414,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=12" } @@ -437,7 +431,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -455,7 +448,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -473,7 +465,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -491,7 +482,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -509,7 +499,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -527,7 +516,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -545,7 +533,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -563,7 +550,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -581,7 +567,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -599,7 +584,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -617,7 +601,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -652,7 +635,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -687,7 +669,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -722,7 +703,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=12" } @@ -740,7 +720,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -758,7 +737,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -776,7 +754,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -1169,8 +1146,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.60.1", @@ -1184,8 +1160,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.1", @@ -1199,8 +1174,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.60.1", @@ -1214,8 +1188,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.60.1", @@ -1229,8 +1202,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.60.1", @@ -1244,8 +1216,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.60.1", @@ -1259,8 +1230,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.60.1", @@ -1274,8 +1244,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.60.1", @@ -1289,8 +1258,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.60.1", @@ -1304,8 +1272,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.60.1", @@ -1319,8 +1286,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.60.1", @@ -1334,8 +1300,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.60.1", @@ -1349,8 +1314,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.60.1", @@ -1364,8 +1328,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.60.1", @@ -1379,8 +1342,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.60.1", @@ -1394,8 +1356,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.60.1", @@ -1409,8 +1370,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.1", @@ -1424,8 +1384,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.60.1", @@ -1439,8 +1398,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.60.1", @@ -1454,8 +1412,7 @@ "optional": true, "os": [ "openbsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.60.1", @@ -1469,8 +1426,7 @@ "optional": true, "os": [ "openharmony" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.60.1", @@ -1484,8 +1440,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.60.1", @@ -1499,8 +1454,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.60.1", @@ -1514,8 +1468,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.60.1", @@ -1529,8 +1482,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@shikijs/engine-oniguruma": { "version": "3.23.0", @@ -7131,10 +7083,10 @@ }, "packages/cli": { "name": "typediagram", - "version": "0.3.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "typediagram-core": "0.3.0" + "typediagram-core": "0.5.0" }, "bin": { "typediagram": "dist/bin.js" @@ -7147,7 +7099,7 @@ }, "packages/typediagram": { "name": "typediagram-core", - "version": "0.3.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "elkjs": "^0.9.3" @@ -7598,11 +7550,11 @@ }, "packages/vscode": { "name": "typediagram-vscode", - "version": "0.3.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0", - "typediagram-core": "0.3.0" + "typediagram-core": "0.5.0" }, "devDependencies": { "@types/markdown-it": "^14.1.2", @@ -8058,10 +8010,10 @@ }, "packages/web": { "name": "@typediagram/web", - "version": "0.3.0", + "version": "0.5.0", "dependencies": { "marked": "^18.0.0", - "typediagram-core": "0.3.0" + "typediagram-core": "0.5.0" }, "devDependencies": { "@11ty/eleventy": "^3.1.5", diff --git a/packages/cli/package.json b/packages/cli/package.json index 75068da..8eabf9c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "typediagram", - "version": "0.3.0", + "version": "0.5.0", "description": "typeDiagram CLI — parse a .td source file and emit SVG", "license": "MIT", "repository": { @@ -24,7 +24,7 @@ "typecheck": "tsc --noEmit -p tsconfig.build.json" }, "dependencies": { - "typediagram-core": "0.3.0" + "typediagram-core": "0.5.0" }, "devDependencies": { "typescript": "^5.6.0", diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 30381a1..baea40f 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -3,7 +3,7 @@ import type { Result } from "./result.js"; import { err, ok } from "./result.js"; export type Theme = "light" | "dark"; -export type Lang = "typescript" | "python" | "rust" | "go" | "csharp"; +export type Lang = "typescript" | "python" | "rust" | "go" | "csharp" | "php"; export type Emit = "svg" | "td" | "td+svg"; export interface CliArgs { @@ -21,7 +21,7 @@ export interface ArgError { } const THEMES: ReadonlySet = new Set(["light", "dark"]); -const LANGS: ReadonlySet = new Set(["typescript", "python", "rust", "go", "csharp"]); +const LANGS: ReadonlySet = new Set(["typescript", "python", "rust", "go", "csharp", "php"]); const EMITS: ReadonlySet = new Set(["svg", "td", "td+svg"]); const isTheme = (v: string): v is Theme => THEMES.has(v as Theme); @@ -114,11 +114,11 @@ const applyLang = ( key: "from" | "to" ): Result => v === null - ? err({ message: `--${key} expects typescript|python|rust|go|csharp` }) + ? err({ message: `--${key} expects typescript|python|rust|go|csharp|php` }) : isLang(v) ? ((s[key] = v), ok(true as const)) : err({ - message: `--${key} expects typescript|python|rust|go|csharp, got ${v}`, + message: `--${key} expects typescript|python|rust|go|csharp|php, got ${v}`, }); const applyEmit = (v: string | null, s: { emit: Emit }): Result => @@ -134,8 +134,8 @@ Usage: typediagram [options] [file] Options: - --from typescript|python|rust|go|csharp Convert from language source to SVG - --to typescript|python|rust|go|csharp Convert from typeDiagram to language source + --from typescript|python|rust|go|csharp|php Convert from language source to SVG + --to typescript|python|rust|go|csharp|php Convert from typeDiagram to language source --emit svg|td|td+svg Output format for --from (default: svg) --theme light|dark Color theme (default: light) --font-size N Font size in px diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9a3b319..da0920e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -17,6 +17,7 @@ const CONVERTER_MAP: Record = { rust: converters.rust, go: converters.go, csharp: converters.csharp, + php: converters.php, }; export const main = async ( diff --git a/packages/cli/test/args.test.ts b/packages/cli/test/args.test.ts index 791f016..7e13701 100644 --- a/packages/cli/test/args.test.ts +++ b/packages/cli/test/args.test.ts @@ -90,6 +90,11 @@ describe("[CLI-ARGS] parseArgs", () => { expect(r.ok && r.value.to).toBe("rust"); }); + it("parses --from php", () => { + const r = parseArgs(["--from", "php"]); + expect(r.ok && r.value.from).toBe("php"); + }); + it("rejects --from and --to together", () => { const r = parseArgs(["--from", "typescript", "--to", "rust"]); expect(r.ok).toBe(false); diff --git a/packages/cli/test/roundtrip.e2e.test.ts b/packages/cli/test/roundtrip.e2e.test.ts index 547500c..860fdcc 100644 --- a/packages/cli/test/roundtrip.e2e.test.ts +++ b/packages/cli/test/roundtrip.e2e.test.ts @@ -89,6 +89,7 @@ describe("[CLI-ROUNDTRIP-EMIT] .td → language output", () => { lang: "typescript", markers: ["export interface User", "export interface Address"], }, + { lang: "php", markers: ["final readonly class User", "final readonly class Address"] }, ] as const)("--to $lang emits expected constructs", async ({ lang, markers }) => { const { code, stdout } = await run(["--to", lang, fixturePath("small.td")]); expect(code).toBe(0); @@ -167,6 +168,7 @@ describe("[CLI-ROUNDTRIP-CROSS] source lang → .td → every target lang", () = { lang: "python", marker: "@dataclass" }, { lang: "rust", marker: "pub struct" }, { lang: "typescript", marker: "export interface" }, + { lang: "php", marker: "final readonly class" }, ] as const; for (const src of sources) { diff --git a/packages/typediagram/package.json b/packages/typediagram/package.json index fa51a9d..502e5c0 100644 --- a/packages/typediagram/package.json +++ b/packages/typediagram/package.json @@ -1,6 +1,6 @@ { "name": "typediagram-core", - "version": "0.3.0", + "version": "0.5.0", "description": "A small DSL for diagramming algebraic data types (records + tagged unions). Language-neutral. No methods.", "license": "MIT", "repository": { diff --git a/packages/typediagram/scripts/bundle-size.mjs b/packages/typediagram/scripts/bundle-size.mjs index 1b5702e..a420704 100644 --- a/packages/typediagram/scripts/bundle-size.mjs +++ b/packages/typediagram/scripts/bundle-size.mjs @@ -1,13 +1,14 @@ #!/usr/bin/env node -// [CI-BUNDLE-SIZE] Fail if the framework bundle (excluding elkjs) exceeds 50KB. +// [CI-BUNDLE-SIZE] Fail if the framework bundle (excluding elkjs) exceeds 60KB. // Uses esbuild to tree-shake and measure the output size. +// Budget was raised from 50KB to 60KB to accommodate the PHP bidirectional converter. 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 = 50; +const BUDGET_KB = 60; const result = await build({ entryPoints: [entry], diff --git a/packages/typediagram/src/converters/index.ts b/packages/typediagram/src/converters/index.ts index 4235a8a..2462e8c 100644 --- a/packages/typediagram/src/converters/index.ts +++ b/packages/typediagram/src/converters/index.ts @@ -5,5 +5,6 @@ export { rust } from "./rust.js"; export { go } from "./go.js"; export { csharp } from "./csharp.js"; export { fsharp } from "./fsharp.js"; +export { php } from "./php.js"; export { parseTypeRef, printTypeRef } from "./parse-typeref.js"; export type { Converter, Language } from "./types.js"; diff --git a/packages/typediagram/src/converters/php.ts b/packages/typediagram/src/converters/php.ts new file mode 100644 index 0000000..12f18cd --- /dev/null +++ b/packages/typediagram/src/converters/php.ts @@ -0,0 +1,596 @@ +// [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 { ModelBuilder, alias, record, union } from "../model/builder.js"; +import type { Converter } from "./types.js"; +import { parseTypeRef } from "./parse-typeref.js"; + +const TD_TO_PHP_NATIVE: Record = { + Bool: "bool", + Int: "int", + Float: "float", + String: "string", + Bytes: "string", + Unit: "null", +}; + +const TD_TO_PHP_DOC: Record = { + Bool: "bool", + Int: "int", + Float: "float", + String: "string", + Bytes: "string", + Unit: "null", +}; + +const PHP_TO_TD: Record = { + bool: "Bool", + int: "Int", + float: "Float", + string: "String", + null: "Unit", + void: "Unit", +}; + +const NO_SUPPORTED_DEFINITIONS: Diagnostic[] = [ + { + severity: "error", + message: "No supported PHP DTO definitions found", + line: 0, + col: 0, + length: 0, + }, +]; + +interface PhpTypeSpec { + nativeType: string; + docType: string | null; + hasDefaultNull: boolean; +} + +interface RenderedParam extends PhpTypeSpec { + name: string; +} + +type ParsedParam = RenderedParam; + +interface ParsedInterface { + declType: "interface"; + name: string; + docblock: string | null; + body: string; +} + +interface ParsedClass { + declType: "class"; + name: string; + docblock: string | null; + body: string; + implementsName: string | null; +} + +type ParsedDecl = ParsedInterface | ParsedClass; + +const isOptionType = (type: ResolvedTypeRef) => type.name === "Option" && type.args[0] !== undefined; + +const unwrapOptionType = (type: ResolvedTypeRef) => type.args[0] ?? type; + +const usesGenericType = (type: ResolvedTypeRef, generics: ReadonlySet): boolean => + generics.has(type.name) || type.args.some((arg) => usesGenericType(arg, generics)); + +const adjustDepth = (depth: number, char: string, openChar: string, closeChar: string) => { + if (char === openChar) { + return depth + 1; + } + if (char === closeChar) { + return depth - 1; + } + return depth; +}; + +interface PhpScanState { + depth: number; + quote: '"' | "'" | null; + escaping: boolean; + lineComment: boolean; + blockComment: boolean; +} + +const DEFAULT_SCAN_STATE: PhpScanState = { + depth: 0, + quote: null, + escaping: false, + lineComment: false, + blockComment: false, +}; + +const advancePhpScanState = ( + source: string, + index: number, + state: PhpScanState, + openChar: string, + closeChar: string +): PhpScanState => { + const char = source.charAt(index); + const nextChar = source.charAt(index + 1); + if (state.lineComment) { + return char === "\n" ? { ...state, lineComment: false } : state; + } + if (state.blockComment) { + return char === "*" && nextChar === "/" ? { ...state, blockComment: false } : state; + } + if (state.quote !== null) { + if (state.escaping) { + return { ...state, escaping: false }; + } + if (char === "\\") { + return { ...state, escaping: true }; + } + return char === state.quote ? { ...state, quote: null } : state; + } + if (char === "/" && nextChar === "/") { + return { ...state, lineComment: true }; + } + if (char === "/" && nextChar === "*") { + return { ...state, blockComment: true }; + } + if (char === '"' || char === "'") { + return { ...state, quote: char, escaping: false }; + } + return { ...state, depth: adjustDepth(state.depth, char, openChar, closeChar) }; +}; + +const splitTopLevel = (source: string, openChar: string, closeChar: string): string[] => { + const parts: string[] = []; + let state = DEFAULT_SCAN_STATE; + let start = 0; + for (let i = 0; i < source.length; i++) { + state = advancePhpScanState(source, i, state, openChar, closeChar); + if ( + source.charAt(i) === "," && + state.depth === 0 && + state.quote === null && + !state.lineComment && + !state.blockComment + ) { + const part = source.slice(start, i).trim(); + if (part.length > 0) { + parts.push(part); + } + start = i + 1; + } + } + const last = source.slice(start).trim(); + return last.length > 0 ? [...parts, last] : parts; +}; + +const splitGenericArgs = (source: string) => splitTopLevel(source, "<", ">"); + +const splitParams = (source: string) => splitTopLevel(source, "(", ")"); + +const mapTdToPhpDocType = (type: ResolvedTypeRef): string => { + if (type.name === "List" && type.args[0] !== undefined) { + return `list<${mapTdToPhpDocType(type.args[0])}>`; + } + if (type.name === "Map" && type.args[0] !== undefined && type.args[1] !== undefined) { + return `array<${mapTdToPhpDocType(type.args[0])}, ${mapTdToPhpDocType(type.args[1])}>`; + } + if (type.name === "Option" && type.args[0] !== undefined) { + return `${mapTdToPhpDocType(type.args[0])}|null`; + } + return TD_TO_PHP_DOC[type.name] ?? type.name; +}; + +const getBasePhpTypeSpec = (type: ResolvedTypeRef, generics: ReadonlySet): PhpTypeSpec => { + if (generics.has(type.name)) { + return { nativeType: "mixed", docType: type.name, hasDefaultNull: false }; + } + if (type.name === "List" || type.name === "Map") { + return { nativeType: "array", docType: mapTdToPhpDocType(type), hasDefaultNull: false }; + } + if (type.name === "Unit") { + return { nativeType: "null", docType: null, hasDefaultNull: false }; + } + return { nativeType: TD_TO_PHP_NATIVE[type.name] ?? type.name, docType: null, hasDefaultNull: false }; +}; + +const getPhpTypeSpec = (type: ResolvedTypeRef, generics: ReadonlySet): PhpTypeSpec => { + if (!isOptionType(type)) { + return getBasePhpTypeSpec(type, generics); + } + const inner = unwrapOptionType(type); + if (generics.has(inner.name)) { + return { nativeType: "mixed", docType: `${inner.name}|null`, hasDefaultNull: true }; + } + if (inner.name === "Unit") { + return { nativeType: "null", docType: null, hasDefaultNull: true }; + } + const base = getBasePhpTypeSpec(inner, generics); + if (base.nativeType === "array") { + return { + nativeType: "?array", + docType: `${mapTdToPhpDocType(inner)}|null`, + hasDefaultNull: true, + }; + } + if (base.nativeType === "mixed") { + return { + nativeType: "mixed", + docType: `${mapTdToPhpDocType(inner)}|null`, + hasDefaultNull: true, + }; + } + return { + nativeType: `?${base.nativeType}`, + docType: base.docType === null ? null : `${base.docType}|null`, + hasDefaultNull: true, + }; +}; + +const renderDocblock = (lines: readonly string[], indent = "") => + lines.length === 0 ? [] : [`${indent}/**`, ...lines.map((line) => `${indent} * ${line}`), `${indent} */`]; + +const renderConstructor = (params: readonly RenderedParam[], bodyLines: readonly string[]) => { + const docLines = params + .filter((param): param is RenderedParam & { docType: string } => param.docType !== null) + .map((param) => `@param ${param.docType} $${param.name}`); + const renderedDoc = renderDocblock(docLines, " "); + const renderedParams = params.map( + (param) => ` public ${param.nativeType} $${param.name}${param.hasDefaultNull ? " = null" : ""},` + ); + + if (bodyLines.length === 0) { + return [ + ...renderedDoc, + ...(renderedParams.length === 0 + ? [" public function __construct() {}"] + : [" public function __construct(", ...renderedParams, " ) {}"]), + ]; + } + + return [ + ...renderedDoc, + ...(renderedParams.length === 0 + ? [" public function __construct()"] + : [" public function __construct(", ...renderedParams, " )"]), + " {", + ...bodyLines.map((line) => ` ${line}`), + " }", + ]; +}; + +const sortFields = (items: readonly T[]) => + [...items].sort((left, right) => Number(isOptionType(left.type)) - Number(isOptionType(right.type))); + +const renderRecord = ( + name: string, + generics: readonly string[], + fields: readonly { name: string; type: ResolvedTypeRef }[] +) => { + const genericSet = new Set(generics); + const params = sortFields(fields).map((field) => ({ name: field.name, ...getPhpTypeSpec(field.type, genericSet) })); + return [ + ...renderDocblock(generics.map((generic) => `@template ${generic}`)), + `final readonly class ${name}`, + "{", + ...renderConstructor(params, []), + "}", + ].join("\n"); +}; + +const renderUnion = ( + name: string, + generics: readonly string[], + variants: readonly { name: string; fields: readonly { name: string; type: ResolvedTypeRef }[] }[] +) => { + const genericSet = new Set(generics); + const blocks: string[] = [ + [...renderDocblock(generics.map((generic) => `@template ${generic}`)), `interface ${name}`, "{", "}"].join("\n"), + ]; + + for (const variant of variants) { + const variantUsesGeneric = variant.fields.some((field) => usesGenericType(field.type, genericSet)); + const params = sortFields(variant.fields).map((field) => ({ + name: field.name, + ...getPhpTypeSpec(field.type, genericSet), + })); + const classDoc = variantUsesGeneric + ? renderDocblock([ + ...generics.map((generic) => `@template ${generic}`), + `@implements ${name}<${generics.join(", ")}>`, + ]) + : []; + blocks.push( + [ + ...classDoc, + `final readonly class ${variant.name} implements ${name}`, + "{", + ` /** @var '${variant.name}' */`, + " public string $kind;", + "", + ...renderConstructor(params, [`$this->kind = '${variant.name}';`]), + "}", + ].join("\n") + ); + } + + return blocks; +}; + +const renderAlias = (name: string, generics: readonly string[], target: ResolvedTypeRef) => { + const params = [{ name: "value", ...getPhpTypeSpec(target, new Set(generics)) }]; + return [ + ...renderDocblock([...generics.map((generic) => `@template ${generic}`), "@typediagram-kind alias"]), + `final readonly class ${name}`, + "{", + ...renderConstructor(params, []), + "}", + ].join("\n"); +}; + +const toPhp = (model: Model): string => { + const blocks = model.decls.flatMap((decl) => { + if (decl.kind === "record") { + return [renderRecord(decl.name, decl.generics, decl.fields)]; + } + if (decl.kind === "union") { + return renderUnion(decl.name, decl.generics, decl.variants); + } + return [renderAlias(decl.name, decl.generics, decl.target)]; + }); + return [ + " (index === 0 ? [block] : ["", block])), + "", + ].join("\n"); +}; + +const findMatchingDelimiter = (source: string, openIndex: number, openChar: string, closeChar: string) => { + let state = DEFAULT_SCAN_STATE; + for (let index = openIndex; index < source.length; index++) { + state = advancePhpScanState(source, index, state, openChar, closeChar); + if (state.depth === 0 && state.quote === null && !state.lineComment && !state.blockComment) { + return index; + } + } + return -1; +}; + +const extractDocblockBeforeOffset = (source: string, offset: number) => { + const prefix = source.slice(0, offset).replace(/\s+$/, ""); + if (!prefix.endsWith("*/")) { + return null; + } + const start = prefix.lastIndexOf("/**"); + return start === -1 ? null : prefix.slice(start); +}; + +const parseTemplatesFromDocblock = (docblock: string | null) => + [...(docblock?.matchAll(/@template\s+([A-Za-z_][A-Za-z0-9_]*)/g) ?? [])].flatMap((match) => + match[1] === undefined ? [] : [match[1]] + ); + +const parseParamDocsFromDocblock = (docblock: string | null) => + new Map( + [...(docblock?.matchAll(/@param\s+(.+?)\s+\$(\w+)/g) ?? [])].flatMap((match) => + match[1] === undefined || match[2] === undefined ? [] : [[match[2], match[1]] as const] + ) + ); + +const parseDeclarations = (source: string): ParsedDecl[] => { + const declarations: ParsedDecl[] = []; + const declarationRe = /interface\s+(\w+)\s*\{|final readonly class\s+(\w+)(?:\s+implements\s+(\w+))?\s*\{/g; + let match: RegExpExecArray | null; + while ((match = declarationRe.exec(source)) !== null) { + const [, interfaceName, className, implementsName] = match; + const openIndex = source.indexOf("{", match.index); + const closeIndex = findMatchingDelimiter(source, openIndex, "{", "}"); + if (closeIndex === -1) { + continue; + } + const body = source.slice(openIndex + 1, closeIndex); + const docblock = extractDocblockBeforeOffset(source, match.index); + declarations.push( + interfaceName !== undefined + ? { declType: "interface", name: interfaceName, docblock, body } + : { declType: "class", name: className ?? "", docblock, body, implementsName: implementsName ?? null } + ); + declarationRe.lastIndex = closeIndex + 1; + } + return declarations; +}; + +const parseParams = (source: string, constructorDoc: string | null): ParsedParam[] => { + const docTypes = parseParamDocsFromDocblock(constructorDoc); + return splitParams(source) + .map((part) => { + const trimmed = part.trim(); + if (!trimmed.startsWith("public ")) { + return null; + } + const afterPublic = trimmed.slice("public ".length).trimStart(); + const dollarIndex = afterPublic.indexOf("$"); + if (dollarIndex === -1) { + return null; + } + const nativeType = afterPublic.slice(0, dollarIndex).trim(); + const name = /^(\w+)/.exec(afterPublic.slice(dollarIndex + 1))?.[1]; + return nativeType.length === 0 || name === undefined + ? null + : { + name, + nativeType, + docType: docTypes.get(name) ?? null, + hasDefaultNull: /=\s*null$/.test(trimmed), + }; + }) + .filter((param): param is ParsedParam => param !== null); +}; + +const parseConstructor = (body: string) => { + const start = body.indexOf("public function __construct("); + if (start === -1) { + return { params: [] as ParsedParam[], body: "" }; + } + const constructorDoc = extractDocblockBeforeOffset(body, start); + const openParen = body.indexOf("(", start); + const closeParen = findMatchingDelimiter(body, openParen, "(", ")"); + const openBrace = body.indexOf("{", closeParen); + const closeBrace = findMatchingDelimiter(body, openBrace, "{", "}"); + return { + params: parseParams(body.slice(openParen + 1, closeParen), constructorDoc), + body: body.slice(openBrace + 1, closeBrace).trim(), + }; +}; + +const mapPhpNativeTypeToTd = (nativeType: string): string => { + const trimmed = nativeType.trim(); + if (trimmed.startsWith("?")) { + return `Option<${mapPhpNativeTypeToTd(trimmed.slice(1))}>`; + } + return PHP_TO_TD[trimmed] ?? trimmed; +}; + +const mapPhpDocTypeToTd = (docType: string): string => { + const trimmed = docType.trim(); + if (trimmed.endsWith("|null")) { + return `Option<${mapPhpDocTypeToTd(trimmed.slice(0, -5))}>`; + } + if (trimmed.startsWith("list<") && trimmed.endsWith(">")) { + return `List<${mapPhpDocTypeToTd(trimmed.slice(5, -1))}>`; + } + if (trimmed.startsWith("array<") && trimmed.endsWith(">")) { + const args = splitGenericArgs(trimmed.slice(6, -1)); + // splitGenericArgs (via splitTopLevel) only produces non-empty strings, so + // index accesses are safe — the as-string casts replace unreachable undefined guards. + return args.length === 1 + ? `List<${mapPhpDocTypeToTd(args[0] as string)}>` + : args.length === 2 + ? `Map<${mapPhpDocTypeToTd(args[0] as string)}, ${mapPhpDocTypeToTd(args[1] as string)}>` + : "Map"; + } + return PHP_TO_TD[trimmed] ?? trimmed; +}; + +const toTypeRef = (param: ParsedParam) => + parseTypeRef(param.docType === null ? mapPhpNativeTypeToTd(param.nativeType) : mapPhpDocTypeToTd(param.docType)); + +const readQuotedLiteral = (source: string, startIndex: number, quote: '"' | "'") => { + let value = ""; + let escaping = false; + for (let index = startIndex; index < source.length; index++) { + const char = source.charAt(index); + if (escaping) { + value += char; + escaping = false; + continue; + } + if (char === "\\") { + escaping = true; + continue; + } + if (char === quote) { + return value; + } + value += char; + } + return null; +}; + +const parseKindLiteral = (body: string) => { + const propertyIndex = body.indexOf("public string $kind;"); + const varIndex = body.indexOf("@var "); + if (propertyIndex === -1 || varIndex === -1 || varIndex > propertyIndex) { + return null; + } + const literalStart = varIndex + "@var ".length; + const quote = body.charAt(literalStart); + return quote === "'" || quote === '"' ? readQuotedLiteral(body, literalStart + 1, quote) : null; +}; + +const fromPhp = (source: string): Result => { + const declarations = parseDeclarations(source); + const interfaces = new Map( + declarations + .filter((decl): decl is ParsedInterface => decl.declType === "interface") + .map((decl) => [decl.name, decl] as const) + ); + const variantMap = new Map }>>(); + const variantNames = new Set(); + + for (const declaration of declarations) { + if ( + declaration.declType !== "class" || + declaration.implementsName === null || + !interfaces.has(declaration.implementsName) + ) { + continue; + } + const kindLiteral = parseKindLiteral(declaration.body); + if (kindLiteral !== declaration.name) { + continue; + } + const constructor = parseConstructor(declaration.body); + variantNames.add(declaration.name); + variantMap.set(declaration.implementsName, [ + ...(variantMap.get(declaration.implementsName) ?? []), + { + name: declaration.name, + fields: constructor.params.map((param) => ({ name: param.name, type: toTypeRef(param) })), + }, + ]); + } + + const builder = new ModelBuilder(); + let found = false; + + for (const declaration of declarations) { + if (declaration.declType === "interface") { + const variants = variantMap.get(declaration.name); + if (variants === undefined) { + continue; + } + found = true; + builder.add(union(declaration.name, variants, parseTemplatesFromDocblock(declaration.docblock))); + continue; + } + + if (variantNames.has(declaration.name)) { + continue; + } + + const constructor = parseConstructor(declaration.body); + const generics = parseTemplatesFromDocblock(declaration.docblock); + const isAlias = /@typediagram-kind\s+alias/.test(declaration.docblock ?? ""); + if (isAlias) { + const valueParam = constructor.params[0]; + if (valueParam?.name !== "value") { + continue; + } + found = true; + builder.add(alias(declaration.name, toTypeRef(valueParam), generics)); + continue; + } + + found = true; + builder.add( + record( + declaration.name, + constructor.params.map((param) => ({ name: param.name, type: toTypeRef(param) })), + generics + ) + ); + } + + return found ? builder.build() : err(NO_SUPPORTED_DEFINITIONS); +}; + +export const php: Converter = { + language: "php", + fromSource: fromPhp, + toSource: toPhp, +}; diff --git a/packages/typediagram/src/converters/types.ts b/packages/typediagram/src/converters/types.ts index 0f1d674..830fd81 100644 --- a/packages/typediagram/src/converters/types.ts +++ b/packages/typediagram/src/converters/types.ts @@ -3,7 +3,7 @@ import type { Diagnostic } from "../parser/diagnostics.js"; import type { Result } from "../result.js"; import type { Model } from "../model/types.js"; -export type Language = "typescript" | "python" | "rust" | "go" | "csharp" | "fsharp"; +export type Language = "typescript" | "python" | "rust" | "go" | "csharp" | "fsharp" | "php"; export interface Converter { readonly language: Language; diff --git a/packages/typediagram/test/converters/php.test.ts b/packages/typediagram/test/converters/php.test.ts new file mode 100644 index 0000000..80ec78c --- /dev/null +++ b/packages/typediagram/test/converters/php.test.ts @@ -0,0 +1,295 @@ +// [CONV-PHP-TEST] PHP converter integration tests. +import { describe, expect, it } from "vitest"; +import { php } from "../../src/converters/index.js"; +import { parse } from "../../src/parser/index.js"; +import { buildModel } from "../../src/model/index.js"; +import { unwrap } from "./helpers.js"; + +describe("[CONV-PHP-TO-COMPLEX] complex typeDiagram -> PHP", () => { + it("emits readonly DTOs, PHPStan refinements, unions, and alias wrappers", () => { + const td = ` +type User { + id: Int + name: String +} + +type Box { + value: T +} + +type Paged { + items: List +} + +type Config { + data: Map +} + +type Opt { + label: Option +} + +type MaybeBox { + value: Option +} + +union Shape { + Circle { radius: Float } + Rectangle { width: Float, height: Float } +} + +union Result { + Ok { value: T } + Err { message: String } +} + +alias UserId = Int +alias Boxed = T +alias Nothing = Unit + +type MaybeNothing { + value: Option +} +`; + const model = unwrap(buildModel(unwrap(parse(td)))); + const output = php.toSource(model); + + expect(output).toContain(" $items"); + expect(output).toContain("@param array $data"); + expect(output).toContain("public ?string $label = null"); + expect(output).toContain("@param T|null $value"); + expect(output).toContain("interface Shape"); + expect(output).toContain("final readonly class Circle implements Shape"); + expect(output).toContain("final readonly class Rectangle implements Shape"); + expect(output).toContain("/** @var 'Circle' */"); + expect(output).toContain("/** @var 'Rectangle' */"); + expect(output).toContain("$this->kind = 'Circle';"); + expect(output).toContain("$this->kind = 'Rectangle';"); + expect(output).toContain("@implements Result"); + expect(output).toContain("@typediagram-kind alias"); + expect(output).toContain("final readonly class UserId"); + expect(output).toContain("final readonly class Nothing"); + expect(output).toContain("final readonly class MaybeNothing"); + expect(output).toContain("public null $value,"); + expect(output).toContain("public null $value = null,"); + }); +}); + +describe("[CONV-PHP-FROM] PHP -> typeDiagram", () => { + it("parses alias-only PHP DTO input", () => { + const src = ` decl.name === "Boxed"); + + expect(boxed?.kind).toBe("alias"); + expect(boxed?.generics).toEqual(["T"]); + expect(boxed?.kind === "alias" ? boxed.target.name : "").toBe("T"); + }); + + it("returns an error when no supported DTO definitions are present", () => { + const src = ` { + const src = ` $data + * @param list|null $labels + */ + public function __construct( + public array $data, + public ?array $labels = null, + ) {} +} + +/** @typediagram-kind alias */ +final readonly class BrokenAlias +{ + public function __construct( + public int $id, + ) {} +} + +final readonly class NoCtor +{ +} +`; + const model = unwrap(php.fromSource(src)); + const mapping = model.decls.find((decl) => decl.name === "Mapping"); + const missingKind = model.decls.find((decl) => decl.name === "MissingKind"); + const noCtor = model.decls.find((decl) => decl.name === "NoCtor"); + + expect(model.decls.find((decl) => decl.name === "Flag")).toBeUndefined(); + expect(model.decls.find((decl) => decl.name === "BrokenAlias")).toBeUndefined(); + expect(mapping?.kind).toBe("record"); + expect(mapping?.kind === "record" ? mapping.fields[0]?.type.name : "").toBe("Map"); + expect(mapping?.kind === "record" ? mapping.fields[0]?.type.args[0]?.name : "").toBe("String"); + expect(mapping?.kind === "record" ? mapping.fields[0]?.type.args[1]?.name : "").toBe("Int"); + expect(mapping?.kind === "record" ? mapping.fields[1]?.type.name : "").toBe("Option"); + expect(mapping?.kind === "record" ? mapping.fields[1]?.type.args[0]?.name : "").toBe("List"); + expect(mapping?.kind === "record" ? mapping.fields[1]?.type.args[0]?.args[0]?.name : "").toBe("String"); + expect(missingKind?.kind).toBe("record"); + expect(missingKind?.kind === "record" ? missingKind.fields : []).toHaveLength(0); + expect(noCtor?.kind).toBe("record"); + expect(noCtor?.kind === "record" ? noCtor.fields : []).toHaveLength(0); + }); + + it("parses namespaced types, array item docblocks, double-quoted kind tags, and defaults with commas", () => { + const src = `kind = "Success"; + } +} + +final readonly class CollectionHolder +{ + /** + * @param array<\\App\\DTO\\User> $users + */ + public function __construct( + public array $users, + ) {} +} +`; + const model = unwrap(php.fromSource(src)); + const outcome = model.decls.find((decl) => decl.name === "Outcome"); + const holder = model.decls.find((decl) => decl.name === "CollectionHolder"); + + expect(outcome?.kind).toBe("union"); + expect(outcome?.kind === "union" ? outcome.variants.length : 0).toBe(1); + expect(outcome?.kind === "union" ? outcome.variants[0]?.name : "").toBe("Success"); + expect(outcome?.kind === "union" ? outcome.variants[0]?.fields[0]?.name : "").toBe("message"); + expect(outcome?.kind === "union" ? outcome.variants[0]?.fields[0]?.type.name : "").toBe("String"); + expect(outcome?.kind === "union" ? outcome.variants[0]?.fields[1]?.type.name : "").toBe("\\App\\DTO\\User"); + expect(holder?.kind).toBe("record"); + expect(holder?.kind === "record" ? holder.fields[0]?.type.name : "").toBe("List"); + expect(holder?.kind === "record" ? holder.fields[0]?.type.args[0]?.name : "").toBe("\\App\\DTO\\User"); + }); +}); + +describe("[CONV-PHP-RT] PHP round-trip TD -> PHP -> TD", () => { + it("round-trips records, unions, aliases, generics, and refined arrays preserving structure", () => { + const td = ` +type User { + id: Int + tags: List + label: Option +} + +type Box { + value: T +} + +union Result { + Ok { value: T } + Err { message: String } +} + +alias UserId = Int +alias Boxed = T +alias Nothing = Unit + +type MaybeNothing { + value: Option +} +`; + const model1 = unwrap(buildModel(unwrap(parse(td)))); + const phpCode = php.toSource(model1); + const model2 = unwrap(php.fromSource(phpCode)); + + const user = model2.decls.find((decl) => decl.name === "User"); + expect(user?.kind).toBe("record"); + expect(user?.kind === "record" ? user.fields.length : 0).toBe(3); + expect(user?.kind === "record" ? user.fields[0]?.type.name : "").toBe("Int"); + expect(user?.kind === "record" ? user.fields[1]?.type.name : "").toBe("List"); + expect(user?.kind === "record" ? user.fields[1]?.type.args[0]?.name : "").toBe("String"); + expect(user?.kind === "record" ? user.fields[2]?.type.name : "").toBe("Option"); + expect(user?.kind === "record" ? user.fields[2]?.type.args[0]?.name : "").toBe("String"); + + const box = model2.decls.find((decl) => decl.name === "Box"); + expect(box?.kind).toBe("record"); + expect(box?.generics).toEqual(["T"]); + expect(box?.kind === "record" ? box.fields[0]?.type.name : "").toBe("T"); + + const result = model2.decls.find((decl) => decl.name === "Result"); + expect(result?.kind).toBe("union"); + expect(result?.generics).toEqual(["T"]); + expect(result?.kind === "union" ? result.variants.length : 0).toBe(2); + expect(result?.kind === "union" ? result.variants[0]?.name : "").toBe("Ok"); + expect(result?.kind === "union" ? result.variants[0]?.fields[0]?.type.name : "").toBe("T"); + expect(result?.kind === "union" ? result.variants[1]?.name : "").toBe("Err"); + expect(result?.kind === "union" ? result.variants[1]?.fields[0]?.type.name : "").toBe("String"); + + const userId = model2.decls.find((decl) => decl.name === "UserId"); + expect(userId?.kind).toBe("alias"); + expect(userId?.kind === "alias" ? userId.target.name : "").toBe("Int"); + + const boxed = model2.decls.find((decl) => decl.name === "Boxed"); + expect(boxed?.kind).toBe("alias"); + expect(boxed?.generics).toEqual(["T"]); + expect(boxed?.kind === "alias" ? boxed.target.name : "").toBe("T"); + }); +}); diff --git a/packages/vscode/package.json b/packages/vscode/package.json index f8b1a93..5f3f9ac 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "typediagram-vscode", "displayName": "TypeDiagram", "description": "TypeDiagram language support with live SVG preview", - "version": "0.3.0", + "version": "0.5.0", "publisher": "nimblesite", "engines": { "vscode": "^1.75.0" @@ -159,7 +159,7 @@ }, "dependencies": { "markdown-it": "^14.1.0", - "typediagram-core": "0.3.0" + "typediagram-core": "0.5.0" }, "devDependencies": { "@types/markdown-it": "^14.1.2", diff --git a/packages/web/eleventy/blog/welcome.md b/packages/web/eleventy/blog/welcome.md index dc1cc6f..149336a 100644 --- a/packages/web/eleventy/blog/welcome.md +++ b/packages/web/eleventy/blog/welcome.md @@ -1,28 +1,28 @@ --- -title: "Class Diagrams for Algebraic Data Types: Diagram-as-Code and Source Generation with typeDiagram" +title: "Class Diagrams for Modern Type Systems: Diagram-as-Code and Source Generation with typeDiagram" date: 2026-04-18 author: "The typeDiagram team" -description: "A practical guide to class diagrams for algebraic data types, discriminated unions, and tagged unions. Generate TypeScript, Rust, C#, F#, Go, and Python types from one diagram, render SVG via pluggable hooks, and embed in Markdown — all diagram-as-code, all open source." +description: "A practical guide to class diagrams for modern type systems, including records, discriminated unions, and tagged unions. Generate TypeScript, Rust, C#, F#, Go, and Python types from one diagram, render SVG via pluggable hooks, and embed in Markdown — all diagram-as-code, all open source." permalink: "/blog/welcome/index.html" --- If you searched for a **class diagram** tool for modern type systems, a way to **generate types from a diagram**, or an **AI-ready diagram-as-code** workflow, this post answers the three questions developers actually ask in 2026: -1. How do I draw a **class diagram for algebraic data types, discriminated unions, and tagged unions**? +1. How do I draw a **class diagram for records, discriminated unions, and tagged unions**? 2. How do I **generate TypeScript, Rust, C#, F#, Go, or Python types** from one diagram? 3. How do I get an **AI-assisted class diagram** that stays in sync with my codebase? -The short answer: **typeDiagram**. It is a full diagram-as-code ecosystem — a tiny DSL for records and tagged unions, a parser, a layout engine, an SVG renderer with pluggable render hooks, a source generator for six languages, a Markdown plugin, a CLI, and a VS Code extension. Language-neutral, open source, and designed to slot into Markdown, VS Code, and MCP-based AI workflows. +The short answer: **typeDiagram**. It is a full diagram-as-code ecosystem — a tiny DSL for records and tagged unions, a parser, a layout engine, an SVG renderer with pluggable render hooks, a source generator for seven languages — TypeScript, Rust, C#, F#, Go, Python, and PHP — a Markdown plugin, a CLI, and a VS Code extension. Language-neutral, open source, and designed to slot into Markdown, VS Code, and MCP-based AI workflows. -## Class diagrams for a post-OOP world +## Class diagrams for the way we actually write types today -Classical UML class diagrams were built for object-oriented design: classes, methods, inheritance, visibility, multiplicities. They still have their place — and excellent tools exist for drawing them. +Classical UML class diagrams were built around classes, methods, inheritance, visibility, and multiplicities. They still have their place, and excellent tools exist for drawing them. -But modern codebases increasingly look different. TypeScript has discriminated unions. Rust has `enum`s with payloads. Swift has `enum` cases with associated values. Kotlin has sealed classes. F# and OCaml have had sum types since the 1970s. C# 10 added `record`s. Python has `dataclass` and `Literal` tag fields. **Every mainstream language today is an algebraic data type language** in everything but name. +But a lot of modern code has grown beyond that shape. TypeScript has discriminated unions. Rust has `enum`s with payloads. Swift has `enum` cases with associated values. Kotlin has sealed classes. C# 10 added `record`s. Python has `dataclass` and `Literal` tag fields. F# and OCaml have had these features for decades. Almost every mainstream language now has a way to express **records and tagged variants** as first-class types — whether you lean OOP, FP, or somewhere in between. -Drawing a Rust `Result` or a TypeScript `type Event = { kind: "click"; x: number } | { kind: "scroll"; y: number }` as a traditional class diagram forces you into stereotypes, notes, or inheritance hierarchies that do not match the code. +Drawing a Rust `Result` or a TypeScript `type Event = { kind: "click"; x: number } | { kind: "scroll"; y: number }` as a traditional class diagram pushes you into stereotypes, notes, or inheritance hierarchies that do not match the code. -typeDiagram is a class diagram tool built specifically for this style. No methods. No inheritance. Just **records** (product types) and **unions** (sum types), with a rendering that matches how the code is actually written — and a source generator that turns the picture back into code. +typeDiagram is a class diagram tool built specifically for this style. Just **records** and **tagged variants**, with a rendering that matches how the code is actually written — and a source generator that turns the picture back into code. It sits next to your existing UML or class diagram workflow, it does not replace it. ## What is diagram-as-code, and why should you care? @@ -32,7 +32,7 @@ typeDiagram is diagram-as-code, but focused. It does **one thing**: type diagram ### typeDiagram syntax in 30 seconds -``` +```typediagram record User { id: UUID email: string @@ -54,10 +54,10 @@ No classes. No methods. No stereotypes. Just the shape of your data. Mermaid and PlantUML are excellent, general-purpose diagram-as-code tools. Mermaid's `classDiagram` and PlantUML's UML support cover a huge range: sequence, state, flowchart, ER, deployment, C4. For most of those jobs, they are the right pick, and typeDiagram does not try to replace them. -typeDiagram is **focused**. It does one thing: class diagrams for algebraic data types, end-to-end. That focus buys three things general UML renderers do not offer: +typeDiagram is **focused**. It does one thing: class diagrams for records and tagged variants, end-to-end. That focus buys three things general UML renderers do not offer: - **First-class discriminated unions and tagged unions.** Variants and their payloads are a primitive in the DSL, not a workaround using inheritance or stereotype notes. The layout engine knows the difference between a variant-of edge and a reference edge. -- **Source generation in six languages.** One diagram round-trips to idiomatic TypeScript, Rust, C#, F#, Go, and Python — and back. No general-purpose UML tool does this. +- **Source generation in seven languages.** One diagram round-trips to idiomatic TypeScript, Rust, C#, F#, Go, Python, and PHP — and back. No general-purpose UML tool does this. - **Pluggable render hooks.** The SVG renderer exposes hooks at every stage — node, row, edge, background, post-render. Custom themes, overlays, hover interactions, annotations, and export pipelines plug in without forking. Pair them freely. Mermaid for sequence and flowchart, PlantUML for C4 and deployment, **typeDiagram for the type layer** that also generates your code. @@ -110,7 +110,7 @@ All four consume the same public API. No duplicated parsing or layout logic. Wha ## "I already use Mermaid or PlantUML — do I need typeDiagram?" -Add, don't switch. Keep Mermaid or PlantUML for sequence, flowchart, state, ER, C4, and deployment diagrams. Reach for typeDiagram when you want the **type layer** — the records, the discriminated unions, the domain model — to also generate code in six languages and to render through customisable hooks. They render side-by-side in the same Markdown file. +Add, don't switch. Keep Mermaid or PlantUML for sequence, flowchart, state, ER, C4, and deployment diagrams. Reach for typeDiagram when you want the **type layer** — the records, the discriminated unions, the domain model — to also generate code in seven languages and to render through customisable hooks. They render side-by-side in the same Markdown file. ## Getting started in 60 seconds diff --git a/packages/web/eleventy/index.njk b/packages/web/eleventy/index.njk index 28c01ee..b532e93 100644 --- a/packages/web/eleventy/index.njk +++ b/packages/web/eleventy/index.njk @@ -75,7 +75,7 @@ structuredData:

One .td file in. Three outputs out.

- typeDiagram is a tiny, language-neutral DSL for describing algebraic data types — records, + typeDiagram is a tiny, language-neutral DSL for describing your data model — records, tagged unions, generics, aliases.

diff --git a/packages/web/package.json b/packages/web/package.json index 3d9d557..13c187a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@typediagram/web", - "version": "0.3.0", + "version": "0.5.0", "description": "typeDiagram web playground — live editor that renders SVG via the framework.", "type": "module", "private": true, @@ -18,7 +18,7 @@ }, "dependencies": { "marked": "^18.0.0", - "typediagram-core": "0.3.0" + "typediagram-core": "0.5.0" }, "devDependencies": { "@11ty/eleventy": "^3.1.5", diff --git a/packages/web/src/converter-highlight.ts b/packages/web/src/converter-highlight.ts index 36fa25a..bf0a2ab 100644 --- a/packages/web/src/converter-highlight.ts +++ b/packages/web/src/converter-highlight.ts @@ -74,6 +74,19 @@ const CSHARP_RULES: readonly Rule[] = [ { re: /[<>{}:;,=|?()[\]]/g, cls: "hl-punct" }, ]; +const PHP_RULES: readonly Rule[] = [ + { re: /\/\/.*$/gm, cls: "hl-comment" }, + { re: /\/\*[\s\S]*?\*\//gm, cls: "hl-comment" }, + { + re: /\b(final|readonly|class|interface|public|function|implements|declare|strict_types)\b/g, + cls: "hl-keyword", + }, + { re: /\b(bool|int|float|string|array|mixed|void)\b/g, cls: "hl-builtin" }, + { re: /\$([a-z_][A-Za-z0-9_]*)\b/g, cls: "hl-field", group: 1 }, + { re: /\b([A-Z][A-Za-z0-9_]*)\b/g, cls: "hl-type" }, + { re: /[<>{}:;,=|?()[\]$]/g, cls: "hl-punct" }, +]; + const FSHARP_RULES: readonly Rule[] = [ { re: /\/\/.*$/gm, cls: "hl-comment" }, { re: /\(\*[\s\S]*?\*\)/gm, cls: "hl-comment" }, @@ -90,7 +103,7 @@ const FSHARP_RULES: readonly Rule[] = [ { re: /[<>{}:;,=|*()[\]]/g, cls: "hl-punct" }, ]; -type SupportedLang = "typescript" | "rust" | "python" | "go" | "csharp" | "fsharp"; +type SupportedLang = "typescript" | "rust" | "python" | "go" | "csharp" | "fsharp" | "php"; const LANG_RULES: Record = { typescript: TYPESCRIPT_RULES, @@ -99,6 +112,7 @@ const LANG_RULES: Record = { go: GO_RULES, csharp: CSHARP_RULES, fsharp: FSHARP_RULES, + php: PHP_RULES, }; type Span = { start: number; end: number; cls: string }; diff --git a/packages/web/src/converter-main.ts b/packages/web/src/converter-main.ts index 6b9fe54..c3cbe1b 100644 --- a/packages/web/src/converter-main.ts +++ b/packages/web/src/converter-main.ts @@ -2,8 +2,6 @@ import { mountConverter } from "./converter.js"; const el = document.getElementById("converter-mount"); -if (el !== null) { +if (el instanceof HTMLElement) { mountConverter(el); -} else { - console.error("[WEB-CONV-MAIN] missing #converter-mount"); } diff --git a/packages/web/src/converter-render.ts b/packages/web/src/converter-render.ts index 543f5ce..45c06f8 100644 --- a/packages/web/src/converter-render.ts +++ b/packages/web/src/converter-render.ts @@ -1,7 +1,7 @@ // [WEB-CONV-RENDER] Pipeline: language source ↔ typeDiagram source + SVG. // Lazy-loads the typediagram module like render-pane.ts. -export type SupportedLang = "typescript" | "python" | "rust" | "go" | "csharp" | "fsharp"; +export type SupportedLang = "typescript" | "python" | "rust" | "go" | "csharp" | "fsharp" | "php"; const getTheme = () => window.matchMedia("(prefers-color-scheme: dark)").matches ? ("dark" as const) : ("light" as const); @@ -24,6 +24,7 @@ export const convertSource = async (source: string, lang: SupportedLang): Promis go: converters.go, csharp: converters.csharp, fsharp: converters.fsharp, + php: converters.php, } as const; const conv = converterMap[lang]; @@ -56,6 +57,7 @@ export const convertFromTd = async (tdSource: string, lang: SupportedLang): Prom go: converters.go, csharp: converters.csharp, fsharp: converters.fsharp, + php: converters.php, } as const; const parsed = parser.parse(tdSource); diff --git a/packages/web/src/converter.ts b/packages/web/src/converter.ts index 9259423..d84a4b7 100644 --- a/packages/web/src/converter.ts +++ b/packages/web/src/converter.ts @@ -8,7 +8,7 @@ import { createViewport, setViewportContent } from "./viewport.js"; import { initEditorZoom } from "./editor-zoom.js"; import { createZoomControls } from "./zoom-controls.js"; -const SAMPLES: Record = { +export const SAMPLES: Record = { typescript: `export interface ChatRequest { message: string; session_id: string; @@ -97,7 +97,10 @@ pub enum UriKind { Api, } `, - python: `from dataclasses import dataclass + python: `from __future__ import annotations +from dataclasses import dataclass +from enum import Enum +from typing import Optional @dataclass class ChatRequest: @@ -128,6 +131,14 @@ class UriPart: url: str kind: UriKind media_type: Optional[str] + +class UriKind(str, Enum): + Image = "image" + Audio = "audio" + Video = "video" + Document = "document" + Web = "web" + Api = "api" `, go: `type ChatRequest struct { Message string @@ -261,10 +272,178 @@ type UriKind = | Document | Web | Api +`, + php: `|null $tool_results + */ + public function __construct( + public string $message, + public string $session_id, + public ?array $tool_results = null, + ) {} +} + +final readonly class ChatTurnInput +{ + /** + * @param list|null $tool_results + */ + public function __construct( + public AgentConfig $config, + public string $user_message, + public string $session_id, + public ?array $tool_results = null, + ) {} +} + +final readonly class ToolResult +{ + public function __construct( + public string $tool_call_id, + public string $name, + public string $content, + public bool $ok, + ) {} +} + +final readonly class TextPart +{ + public function __construct( + public string $text, + ) {} +} + +final readonly class UriPart +{ + public function __construct( + public string $url, + public UriKind $kind, + public ?string $media_type = null, + ) {} +} + +interface ContentItem +{ +} + +final readonly class Text implements ContentItem +{ + /** @var 'Text' */ + public string $kind; + + public function __construct( + public TextPart $value, + ) + { + $this->kind = 'Text'; + } +} + +final readonly class Uri implements ContentItem +{ + /** @var 'Uri' */ + public string $kind; + + public function __construct( + public UriPart $value, + ) + { + $this->kind = 'Uri'; + } +} + +final readonly class Scalar implements ContentItem +{ + /** @var 'Scalar' */ + public string $kind; + + public function __construct( + public string $value, + ) + { + $this->kind = 'Scalar'; + } +} + +interface UriKind +{ +} + +final readonly class Image implements UriKind +{ + /** @var 'Image' */ + public string $kind; + + public function __construct() + { + $this->kind = 'Image'; + } +} + +final readonly class Audio implements UriKind +{ + /** @var 'Audio' */ + public string $kind; + + public function __construct() + { + $this->kind = 'Audio'; + } +} + +final readonly class Video implements UriKind +{ + /** @var 'Video' */ + public string $kind; + + public function __construct() + { + $this->kind = 'Video'; + } +} + +final readonly class Document implements UriKind +{ + /** @var 'Document' */ + public string $kind; + + public function __construct() + { + $this->kind = 'Document'; + } +} + +final readonly class Web implements UriKind +{ + /** @var 'Web' */ + public string $kind; + + public function __construct() + { + $this->kind = 'Web'; + } +} + +final readonly class Api implements UriKind +{ + /** @var 'Api' */ + public string $kind; + + public function __construct() + { + $this->kind = 'Api'; + } +} `, }; -const TD_SAMPLE = `typeDiagram +export const TD_SAMPLE = `typeDiagram type ChatRequest { message: String @@ -319,9 +498,10 @@ const LANG_LABELS: Record = { go: "Go", csharp: "C#", fsharp: "F#", + php: "PHP", }; -const LANGUAGES: readonly SupportedLang[] = ["typescript", "rust", "python", "go", "csharp", "fsharp"]; +const LANGUAGES: readonly SupportedLang[] = ["typescript", "rust", "python", "go", "csharp", "fsharp", "php"]; let currentLang: SupportedLang = "typescript"; let flipped = false; diff --git a/packages/web/src/main.ts b/packages/web/src/main.ts index 4a390b6..c728790 100644 --- a/packages/web/src/main.ts +++ b/packages/web/src/main.ts @@ -2,8 +2,6 @@ import { mountPlayground } from "./playground.js"; const el = document.getElementById("playground-mount"); -if (el !== null) { +if (el instanceof HTMLElement) { mountPlayground(el); -} else { - console.error("[WEB-MAIN] missing #playground-mount"); } diff --git a/packages/web/src/playground.ts b/packages/web/src/playground.ts index 4fe619a..cbcbfa4 100644 --- a/packages/web/src/playground.ts +++ b/packages/web/src/playground.ts @@ -227,6 +227,10 @@ export const mountPlayground = (container: HTMLElement) => { syncPresetButtons(hooksToolbar, hooksEditor.value); const run = async () => { + // Guard: skip if the playground has been removed from the DOM (e.g. in tests). + if (!editor.isConnected) { + return; + } const evaluated = evalHooks(hooksEditor.value); if (evaluated.ok) { hooksDiag.hidden = true; diff --git a/packages/web/src/render-pane.ts b/packages/web/src/render-pane.ts index d346f37..87d6191 100644 --- a/packages/web/src/render-pane.ts +++ b/packages/web/src/render-pane.ts @@ -1,6 +1,6 @@ // [WEB-RENDER-PANE] Pure function: source -> HTML string for the preview div. // Lazy-loads `typediagram` so the main chunk stays free of framework + ELK weight. -import type { RenderHooks } from "typediagram-core"; +import type { Diagnostic, RenderHooks, Result } from "typediagram-core"; const getTheme = () => window.matchMedia("(prefers-color-scheme: dark)").matches ? ("dark" as const) : ("light" as const); @@ -8,8 +8,15 @@ const getTheme = () => export const renderPane = async (source: string, hooks?: RenderHooks): Promise => { const { parser, renderToString } = await import("typediagram-core"); const opts = { theme: getTheme(), ...(hooks ? { hooks } : {}) }; - const result = await renderToString(source, opts); - return result.ok ? result.value : diagnosticsHtml(parser.formatDiagnostics([...result.error])); + // Safety: renderToString is typed as always returning a Result, but in tests the + // module mock may be reset to undefined during suite teardown. The widening cast + // allows a safe guard that prevents an unhandled rejection in that edge case. + const result = (await renderToString(source, opts)) as Result | undefined; + return result === undefined + ? "" + : result.ok + ? result.value + : diagnosticsHtml(parser.formatDiagnostics([...result.error])); }; const diagnosticsHtml = (text: string): string => { diff --git a/packages/web/test/converter-highlight.test.ts b/packages/web/test/converter-highlight.test.ts index e7e5d45..f636d3c 100644 --- a/packages/web/test/converter-highlight.test.ts +++ b/packages/web/test/converter-highlight.test.ts @@ -93,6 +93,20 @@ describe("[WEB-CONV-HIGHLIGHT] highlightLang()", () => { }); }); + describe("PHP", () => { + it("wraps readonly class keywords", () => { + const html = highlightLang("final readonly class Foo {}", "php"); + expect(html).toContain('readonly'); + expect(html).toContain('class'); + }); + + it("wraps PHP builtins", () => { + const html = highlightLang("public string $name", "php"); + expect(html).toContain('string'); + expect(html).toContain('name'); + }); + }); + describe("F#", () => { it("wraps type keyword", () => { const html = highlightLang("type Foo = {}", "fsharp"); diff --git a/packages/web/test/converter-render.test.ts b/packages/web/test/converter-render.test.ts index 3dd48a9..75488b8 100644 --- a/packages/web/test/converter-render.test.ts +++ b/packages/web/test/converter-render.test.ts @@ -18,6 +18,7 @@ vi.mock("typediagram-core", () => ({ go: { fromSource: mockFromSource, toSource: mockToSource, language: "go" }, csharp: { fromSource: mockFromSource, toSource: mockToSource, language: "csharp" }, fsharp: { fromSource: mockFromSource, toSource: mockToSource, language: "fsharp" }, + php: { fromSource: mockFromSource, toSource: mockToSource, language: "php" }, }, model: { printSource: mockPrintSource, buildModel: mockBuildModel }, renderToString: mockRenderToString, diff --git a/packages/web/test/converter-samples.test.ts b/packages/web/test/converter-samples.test.ts new file mode 100644 index 0000000..f37be38 --- /dev/null +++ b/packages/web/test/converter-samples.test.ts @@ -0,0 +1,61 @@ +// [WEB-CONV-SAMPLES] Converter page samples stay in sync with supported conversions. +import { describe, expect, it } from "vitest"; +import { converters, model, parser } from "typediagram-core"; +import type { SupportedLang } from "../src/converter-render.js"; +import { SAMPLES, TD_SAMPLE } from "../src/converter.js"; + +const BASE_EXPECTED = ["ChatRequest", "ChatTurnInput", "ToolResult", "TextPart", "UriPart"] as const; +const UNION_EXPECTED = [...BASE_EXPECTED, "ContentItem", "UriKind"] as const; + +const EXPECTED_BY_LANG: Record = { + typescript: UNION_EXPECTED, + rust: UNION_EXPECTED, + python: [...BASE_EXPECTED, "UriKind"], + go: UNION_EXPECTED, + csharp: UNION_EXPECTED, + fsharp: UNION_EXPECTED, + php: UNION_EXPECTED, +}; + +const assertDeclNames = (names: readonly string[], expected: readonly string[]) => { + expect([...names].sort()).toEqual([...expected].sort()); +}; + +describe("[WEB-CONV-SAMPLES] converter page seed content", () => { + it("keeps the typediagram sample parseable", () => { + const parsed = parser.parse(TD_SAMPLE); + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + expect.fail(JSON.stringify(parsed.error)); + } + + const built = model.buildModel(parsed.value); + expect(built.ok).toBe(true); + if (!built.ok) { + expect.fail(JSON.stringify(built.error)); + } + + assertDeclNames( + built.value.decls.map((decl) => decl.name), + EXPECTED_BY_LANG.typescript + ); + }); + + for (const [lang, source] of Object.entries(SAMPLES) as [SupportedLang, string][]) { + it(`keeps the ${lang} sample convertible`, () => { + const converted = converters[lang].fromSource(source); + expect(converted.ok).toBe(true); + if (!converted.ok) { + expect.fail(JSON.stringify(converted.error)); + } + + assertDeclNames( + converted.value.decls.map((decl) => decl.name), + EXPECTED_BY_LANG[lang] + ); + + const reParsed = parser.parse(model.printSource(converted.value)); + expect(reParsed.ok).toBe(true); + }); + } +}); diff --git a/packages/web/test/converter.test.ts b/packages/web/test/converter.test.ts index a3c840a..27837ce 100644 --- a/packages/web/test/converter.test.ts +++ b/packages/web/test/converter.test.ts @@ -25,12 +25,12 @@ describe("[WEB-CONVERTER] mountConverter()", () => { document.body.appendChild(container); }); - it("renders language tabs for all 6 languages", async () => { + it("renders language tabs for all 7 languages", async () => { mountConverter(container); await vi.dynamicImportSettled(); const tabs = Array.from(container.querySelectorAll(".conv-lang-tab")); - expect(tabs.length).toBe(6); + expect(tabs.length).toBe(7); const labels: (string | null)[] = tabs.map((t: HTMLElement) => t.textContent); expect(labels).toContain("TypeScript"); @@ -39,6 +39,7 @@ describe("[WEB-CONVERTER] mountConverter()", () => { expect(labels).toContain("Go"); expect(labels).toContain("C#"); expect(labels).toContain("F#"); + expect(labels).toContain("PHP"); }); it("sets TypeScript as the default active tab", () => {