diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..0d80126 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "too-many-cooks": { + "url": "http://localhost:4040/mcp" + } + } +} diff --git a/Cargo.lock b/Cargo.lock index 5e8c9d4..695956f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -317,6 +317,25 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "same-file" version = "1.0.6" @@ -392,6 +411,8 @@ version = "0.0.0" dependencies = [ "criterion", "prost", + "rmp-serde", + "serde", ] [[package]] diff --git a/Claude.md b/Claude.md index 96004b2..d2383e0 100644 --- a/Claude.md +++ b/Claude.md @@ -7,11 +7,8 @@ Read this file in full. Rules below are NON-NEGOTIABLE — violations are reject ⚠️ **TOKEN ECONOMICS DISCIPLINE.** Check file size first. `Grep` over `Read`. Use `offset`/`limit`. Smallest diff that solves the problem. Delete dead code, unused imports, stale comments. Call out irrelevant context before proceeding. Bloat degrades reasoning. ⚠️ -⚠️ DON'T ASK THE USER QUESTIONS!!! - -- DO YOUR JOB and don't say things like "Open questions before I start" -- USE YOUR JUDGEMENT! ⚠️ - ⚠️ NEVER KILL ANY VSCODE PROCESS ⚠️ +⚠️ ACT AUTONOMOUSLY. DON'T ASK THE USER QUESTIONS. USE YOUR JUDGEMENT. ⚠️ +⚠️ NEVER KILL ANY VSCODE PROCESS ⚠️ ## Project Overview diff --git a/README.md b/README.md index 0969b26..1500873 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, C#, F#, Dart, PHP, and Protobuf — DTOs, data classes, discriminated unions, pattern-matchable enums — generated from the same definition, always in sync. +- **Source code** in Typeshed, TypeScript, Python, Rust, Go, C#, F#, Dart, PHP, and Protobuf — DTOs, data classes, free functions, discriminated unions, and enums — converted through one model. - **SVG diagrams** with automatic orthogonal layout — no dragging, no fiddling, versionable in git. -- **Round-trip conversion** from existing TypeScript/Python/Rust/Go/C#/F#/Dart/PHP/Protobuf back to the DSL, so you can retrofit an existing codebase. +- **Round-trip conversion** from existing Typeshed/TypeScript/Python/Rust/Go/C#/F#/Dart/PHP/Protobuf 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. @@ -79,6 +79,8 @@ Three constructs: `type` (records), `union` (tagged sum types), `alias` (newtype ```sh typediagram schema.td > diagram.svg # DSL → SVG typediagram --from typescript types.ts > diagram.svg # TS → SVG +typediagram --from typeshed --emit td module.pyi # .pyi → typeDiagram +typediagram-typeshed /path/to/typeshed generated/ # complete checkout typediagram --to rust schema.td > types.rs # DSL → Rust ``` diff --git a/coverage-thresholds.json b/coverage-thresholds.json index eee580d..d9664d4 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -4,10 +4,10 @@ "default_threshold": 90, "projects": { "packages/typediagram": { - "statements": 97.05, + "statements": 97.22, "branches": 91.05, "functions": 98.8, - "lines": 96.97 + "lines": 97.18 }, "packages/cli": { "statements": 99, @@ -18,12 +18,12 @@ "packages/web": { "statements": 97.7878787878788, "branches": 96.46835443037975, - "functions": 98.05660377358491, + "functions": 99, "lines": 97.7012987012987 }, "packages/vscode": { "statements": 99, - "branches": 98.41, + "branches": 99, "functions": 99, "lines": 99 } diff --git a/crates/tdbin/Cargo.toml b/crates/tdbin/Cargo.toml index 465428a..edca41c 100644 --- a/crates/tdbin/Cargo.toml +++ b/crates/tdbin/Cargo.toml @@ -13,12 +13,17 @@ publish = false [lints] workspace = true -# prost is the COMPETITOR baseline for the honest size/speed benchmark, pulled in -# only for the `size_gate` test and the `bench` example. It is a DEV-dependency so -# the shipped runtime stays zero-dep ([TDBIN-BENCH-CORPUS], [TDBIN-BENCH-GATE]). +# prost and rmp-serde are the COMPETITOR baselines for the honest size/speed +# benchmark, pulled in only for the `size_gate` test and the `bench` example. +# They are DEV-dependencies so the shipped runtime stays zero-dep +# ([TDBIN-BENCH-CORPUS], [TDBIN-BENCH-GATE]). The prost mirror structs carry +# `serde` derives so the SAME 1:1 fixture values feed the MessagePack baseline +# (`rmp-serde`) with no third struct set. [dev-dependencies] criterion = { version = "0.8.2", default-features = false, features = ["cargo_bench_support"] } prost = "0.14" +serde = { version = "1", features = ["derive"] } +rmp-serde = "1" # The TDBIN-vs-Protobuf speed benchmark, run via `cargo run --release --example bench`. [[example]] diff --git a/crates/tdbin/benches/gate.rs b/crates/tdbin/benches/gate.rs index d68d482..0397854 100644 --- a/crates/tdbin/benches/gate.rs +++ b/crates/tdbin/benches/gate.rs @@ -14,6 +14,8 @@ use std::time::Duration; use bench_corpus::{batches, corpus, documents, events}; use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; use prost::Message; +use serde::de::DeserializeOwned; +use serde::Serialize; use tdbin::{Struct, TdBin}; /// Return a TDBIN encoded message or terminate the benchmark process. @@ -55,22 +57,35 @@ fn pb_bytes(value: &P) -> Vec { } } +/// Return a self-describing `MessagePack` (struct-as-map) message, or terminate. +fn mp_bytes(value: &P) -> Vec { + match rmp_serde::to_vec_named(value) { + Ok(bytes) => bytes, + Err(error) => { + eprintln!("msgpack encode failed before benchmark: {error:?}"); + std::process::exit(1); + } + } +} + /// Benchmark all encode/decode operations for one paired corpus fixture. fn bench_fixture(c: &mut Criterion, label: &str, td: &T, pb: &P) where T: Struct + TdBin, - P: Message + Default, + P: Message + Default + Serialize + DeserializeOwned, { let bare = td_bytes(td); let framed = td_framed_bytes(td, false); let packed_framed = td_framed_bytes(td, true); let protobuf = pb_bytes(pb); + let msgpack = mp_bytes(pb); println!( - "[{label}] sizes: tdbin_bare={} tdbin_framed={} tdbin_packed_framed={} protobuf={}", + "[{label}] sizes: tdbin_bare={} tdbin_framed={} tdbin_packed_framed={} protobuf={} msgpack={}", bare.len(), framed.len(), packed_framed.len(), - protobuf.len() + protobuf.len(), + msgpack.len() ); let mut group = c.benchmark_group(format!("tdbin_vs_protobuf/{label}")); @@ -105,6 +120,9 @@ where }); }, ); + let _ = group.bench_with_input(BenchmarkId::new("msgpack_encode", label), pb, |b, value| { + b.iter(|| rmp_serde::to_vec_named(black_box(value))); + }); let _ = group.bench_with_input( BenchmarkId::new("tdbin_decode_bare", label), &bare, @@ -133,6 +151,13 @@ where b.iter(|| P::decode(black_box(bytes.as_slice()))); }, ); + let _ = group.bench_with_input( + BenchmarkId::new("msgpack_decode", label), + &msgpack, + |b, bytes| { + b.iter(|| rmp_serde::from_slice::

(black_box(bytes.as_slice()))); + }, + ); group.finish(); } diff --git a/crates/tdbin/examples/bench_data.rs b/crates/tdbin/examples/bench_data.rs index cdb023f..8dcf6e9 100644 --- a/crates/tdbin/examples/bench_data.rs +++ b/crates/tdbin/examples/bench_data.rs @@ -25,17 +25,19 @@ fn fixture( ) -> Result where T: Struct + TdBin, - P: Message, + P: Message + serde::Serialize, { let bare = td.to_bytes()?; let framed = td.to_framed_bytes(None)?; let packed = td.to_packed_framed_bytes(None)?; + let msgpack = rmp_serde::to_vec_named(pb)?; Ok(format!( - "{{\"name\":\"{name}\",\"shape\":\"{shape}\",\"corpus\":{corpus},\"logical_items\":{items},\"tdbin_bare\":{},\"tdbin_framed\":{},\"tdbin_packed_framed\":{},\"protobuf\":{}}}", + "{{\"name\":\"{name}\",\"shape\":\"{shape}\",\"corpus\":{corpus},\"logical_items\":{items},\"tdbin_bare\":{},\"tdbin_framed\":{},\"tdbin_packed_framed\":{},\"protobuf\":{},\"msgpack\":{}}}", bare.len(), framed.len(), packed.len(), - pb.encoded_len() + pb.encoded_len(), + msgpack.len() )) } diff --git a/crates/tdbin/src/intblock.rs b/crates/tdbin/src/intblock.rs index 255d03e..134c21a 100644 --- a/crates/tdbin/src/intblock.rs +++ b/crates/tdbin/src/intblock.rs @@ -48,7 +48,7 @@ pub(crate) fn encode(values: &[i64]) -> Result, EncodeError> { out.extend_from_slice(&first.to_le_bytes()); out.extend_from_slice(&floor.to_le_bytes()); out.push(u8::try_from(width).map_err(|_| EncodeError::LimitExceeded)?); - pack_bits(&deltas, floor, width, &mut out); + pack_deltas(&deltas, floor, width, &mut out); Ok(out) } @@ -91,18 +91,44 @@ pub(crate) fn decode(block: &[u8]) -> Result, DecodeError> { return Err(DecodeError::MalformedColumn); } let mut out = Vec::with_capacity(count); - let mut acc = i64::from_le_bytes(first.to_le_bytes()); + let acc = i64::from_le_bytes(first.to_le_bytes()); out.push(acc); + match width { + 0 => fill_constant(&mut out, acc, unzigzag(floor), count), + _ => read_deltas(&mut out, block, acc, floor, width, count), + } + Ok(out) +} + +/// Accumulate the remaining values from a constant `step` delta: a width of +/// zero packs no bits at all, so monotonic columns skip the bit reader. +fn fill_constant(out: &mut Vec, mut acc: i64, step: i64, count: usize) { + for _ in 1..count { + acc = acc.wrapping_add(step); + out.push(acc); + } +} + +/// Accumulate the remaining values from the packed delta stream. +fn read_deltas( + out: &mut Vec, + block: &[u8], + mut acc: i64, + floor: u64, + width: u32, + count: usize, +) { let mut bits = BitReader::new(block.get(HEADER_BYTES..).unwrap_or_default()); for _ in 1..count { let delta = unzigzag(bits.read(width).wrapping_add(floor)); acc = acc.wrapping_add(delta); out.push(acc); } - Ok(out) } -/// Zigzagged wrapping deltas between consecutive values. +/// Zigzagged wrapping deltas between consecutive values, materialized once: +/// the floor scan, width scan, and packing pass all reuse this buffer instead +/// of re-deriving deltas per pass. fn zigzag_deltas(values: &[i64]) -> Vec { values .windows(2) @@ -114,8 +140,14 @@ fn zigzag_deltas(values: &[i64]) -> Vec { .collect() } -/// Append `width`-bit values (relative to `floor`) as a little-endian stream. -fn pack_bits(deltas: &[u64], floor: u64, width: u32, out: &mut Vec) { +/// Append `width`-bit deltas (relative to `floor`) as a little-endian stream. +/// +/// Keep the byte-at-a-time pusher: a manual 64-bit-accumulator variant that +/// flushed whole words measured 20-30% SLOWER on the metric and contact +/// corpora (Apple Silicon, criterion vs pinned baseline) — LLVM optimizes +/// this shape better than the hand-rolled word flush with its +/// data-dependent flush branch. +fn pack_deltas(deltas: &[u64], floor: u64, width: u32, out: &mut Vec) { let mut acc = 0_u64; let mut filled = 0_u32; for delta in deltas { @@ -134,9 +166,13 @@ fn pack_bits(deltas: &[u64], floor: u64, width: u32, out: &mut Vec) { } } } - if filled > 0 { - out.push(u8::try_from(acc & 0xFF).unwrap_or(0)); - } + flush_partial(acc, filled, out); +} + +/// Flush the trailing partial word: only the bytes carrying `filled` bits. +fn flush_partial(acc: u64, filled: u32, out: &mut Vec) { + let bytes = usize::try_from(filled.div_ceil(8)).unwrap_or(8); + out.extend_from_slice(acc.to_le_bytes().get(..bytes).unwrap_or_default()); } /// Shift right by up to 64 bits (a 64-bit shift yields zero). @@ -161,25 +197,43 @@ impl<'a> BitReader<'a> { Self { bytes, bit: 0 } } - /// Read the next `width` bits (little-endian), zero-padded past the end. + /// Read the next `width` bits (little-endian), zero-padded past the end: + /// one unaligned word load covers every width up to 57 bits, and a second + /// load stitches the rare wide read that crosses the first word's end. fn read(&mut self, width: u32) -> u64 { - let mut value = 0_u64; - let mut got = 0_u32; - while got < width { - let byte_index = self.bit / 8; - let bit_offset = u32::try_from(self.bit % 8).unwrap_or(0); - let available = 8_u32.wrapping_sub(bit_offset); - let take = available.min(width.wrapping_sub(got)); - let byte = u64::from(self.bytes.get(byte_index).copied().unwrap_or(0)); - let mask = mask_of(take); - value |= (byte.wrapping_shr(bit_offset) & mask).wrapping_shl(got); - got = got.wrapping_add(take); - self.bit = self.bit.wrapping_add(usize::try_from(take).unwrap_or(0)); - } + let index = self.bit / 8; + let shift = u32::try_from(self.bit % 8).unwrap_or(0); + let have = WORD_BITS.wrapping_sub(shift); + let lo = load_padded(self.bytes, index).wrapping_shr(shift); + let value = if width <= have { + lo & mask_of(width) + } else { + let hi = load_padded(self.bytes, index.wrapping_add(8)); + (lo | hi.wrapping_shl(have)) & mask_of(width) + }; + self.bit = self.bit.wrapping_add(usize::try_from(width).unwrap_or(0)); value } } +/// Load the little-endian word at byte `index`, zero-padded past the end. +fn load_padded(bytes: &[u8], index: usize) -> u64 { + bytes + .get(index..index.wrapping_add(8)) + .and_then(|slice| <[u8; 8]>::try_from(slice).ok()) + .map_or_else(|| tail_word(bytes, index), u64::from_le_bytes) +} + +/// Little-endian fold of the partial tail starting at `index`. +fn tail_word(bytes: &[u8], index: usize) -> u64 { + bytes + .get(index..) + .unwrap_or_default() + .iter() + .rev() + .fold(0_u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte)) +} + /// Read a little-endian u64 at `offset`. fn read_u64(block: &[u8], offset: usize) -> Result { block diff --git a/crates/tdbin/src/pack.rs b/crates/tdbin/src/pack.rs index 6101bc2..ee31a70 100644 --- a/crates/tdbin/src/pack.rs +++ b/crates/tdbin/src/pack.rs @@ -1,13 +1,14 @@ //! Cap'n Proto word packing for TDBIN bodies ([TDBIN-PACK]). //! //! Hot-path shape ([TDBIN-PACK-WORD], [TDBIN-PACK-RUNS]): whole words are -//! classified with a branch-free SWAR nonzero-byte mask, both directions -//! write through cursors into preallocated buffers (no per-byte growth -//! checks), zero runs cost two bytes to emit and a cursor bump to consume -//! (the output arrives pre-zeroed), and sparse words touch only their -//! nonzero bytes. Output is byte-identical to the reference algorithm: a -//! zero run starts on an all-zero word, a dense run starts on an all-nonzero -//! word and extends over words with at least seven nonzero bytes. +//! classified with a branch-free SWAR nonzero-byte mask, the encoder appends +//! constant-size chunks onto a capacity-reserved vector (no zero-fill pass, +//! no per-byte stores), the decoder writes through cursors into a pre-zeroed +//! buffer, zero runs cost two bytes to emit and a cursor bump to consume, +//! and sparse words touch only their nonzero bytes. Output is byte-identical +//! to the reference algorithm: a zero run starts on an all-zero word, a +//! dense run starts on an all-nonzero word and extends over words with at +//! least seven nonzero bytes. use crate::error::{DecodeError, EncodeError}; @@ -57,17 +58,11 @@ pub fn encode_into(body: &[u8], out: &mut Vec) -> Result<(), EncodeError> { .is_multiple_of(WORD_BYTES) .then_some(()) .ok_or(EncodeError::BadLength)?; - let base = out.len(); - let worst = base - .checked_add(encode_capacity(body.len())?) - .ok_or(EncodeError::LimitExceeded)?; - out.resize(worst, 0); + out.reserve(encode_capacity(body.len())?); let mut offset = 0; - let mut cursor = base; while let Some(word) = read_word(body, offset) { - (offset, cursor) = encode_word(body, offset, word, out, cursor)?; + offset = encode_word(body, offset, word, out)?; } - out.truncate(cursor); Ok(()) } @@ -106,8 +101,9 @@ fn grow_chunk(out: &mut Vec, written: usize) -> bool { } /// Decode fast elements into the pre-zeroed slice until the headroom or the -/// input window runs out; the sparse path is fully inlined with no per-word -/// slice construction, so the loop is bounded by the tag-load/popcount chain. +/// input window runs out; `cursor <= fast_end` guarantees a full window at +/// every iteration, so the per-element helpers read whole words unchecked by +/// construction. The caller guarantees `ELEMENT_ROOM` headroom. fn decode_chunk( packed: &[u8], mut cursor: usize, @@ -119,39 +115,65 @@ fn decode_chunk( let dst = out.as_mut_slice(); while cursor <= fast_end && written <= limit { let tag = packed.get(cursor).copied().unwrap_or(0); - if tag == ZERO_RUN_TAG || tag == DENSE_RUN_TAG { - let Some(window) = window_at(packed, cursor) else { - break; - }; - (cursor, written) = decode_fast_element(packed, window, cursor, dst, written)?; - } else { - let end = cursor.wrapping_add(9); - let src = packed - .get(cursor.wrapping_add(1)..end) - .and_then(|slice| <[u8; WORD_BYTES]>::try_from(slice).ok()) - .ok_or(DecodeError::PackedTruncated)?; - let word = expand_word(tag, u64::from_le_bytes(src)); - if let Some(cell) = dst.get_mut(written..written.wrapping_add(WORD_BYTES)) { - cell.copy_from_slice(&word.to_le_bytes()); - } - let taken = usize::try_from(tag.count_ones()).unwrap_or(WORD_BYTES); - cursor = cursor.wrapping_add(taken.wrapping_add(1)); - written = written.wrapping_add(WORD_BYTES); - } + (cursor, written) = match tag { + ZERO_RUN_TAG => decode_fast_zero(packed, cursor, written)?, + DENSE_RUN_TAG => decode_fast_dense(packed, cursor, dst, written)?, + sparse => decode_fast_sparse(sparse, packed, cursor, dst, written)?, + }; } Ok((cursor, written)) } +/// Skip a zero-word run in the fast path: the output is pre-zeroed, so the +/// run costs one count load and two cursor bumps. +fn decode_fast_zero( + packed: &[u8], + cursor: usize, + written: usize, +) -> Result<(usize, usize), DecodeError> { + let extra = usize::from(packed.get(cursor.wrapping_add(1)).copied().unwrap_or(0)); + let bytes = extra + .wrapping_add(1) + .checked_mul(WORD_BYTES) + .ok_or(DecodeError::LimitExceeded)?; + Ok((advance(cursor, 2)?, advance(written, bytes)?)) +} + +/// Expand one sparse word in the fast path with unconditional whole-word +/// reads and writes. +fn decode_fast_sparse( + tag: u8, + packed: &[u8], + cursor: usize, + dst: &mut [u8], + written: usize, +) -> Result<(usize, usize), DecodeError> { + let src = packed + .get(cursor.wrapping_add(1)..cursor.wrapping_add(9)) + .and_then(|slice| <[u8; WORD_BYTES]>::try_from(slice).ok()) + .ok_or(DecodeError::PackedTruncated)?; + let word = expand_word(tag, u64::from_le_bytes(src)); + if let Some(cell) = dst.get_mut(written..written.wrapping_add(WORD_BYTES)) { + cell.copy_from_slice(&word.to_le_bytes()); + } + let taken = usize::try_from(tag.count_ones()).unwrap_or(WORD_BYTES); + Ok(( + advance(cursor, taken.wrapping_add(1))?, + advance(written, WORD_BYTES)?, + )) +} + /// Branchless sparse expansion: a constant eight-lane pass selects each -/// output byte from the compacted source, so the loop never carries a -/// data-dependent branch (the variable-trip alternative mispredicts once per -/// word on mixed tags). +/// output byte from the compacted source. The serial shift-chain beats a +/// prefix-popcount lane-independent variant here: expansions of consecutive +/// words are already independent, so the CPU overlaps their chains, and this +/// shape is fewer ops per lane (measured on Apple Silicon). fn expand_word(tag: u8, src: u64) -> u64 { let mut word = 0_u64; let mut rest = src; let mut lane = 0_u32; while lane < 8 { - let take = (u64::from(tag) >> lane) & 1; + let take = u64::from(tag).wrapping_shr(lane) & 1; word |= (rest & 0xFF) .wrapping_mul(take) .wrapping_shl(lane.wrapping_mul(8)); @@ -169,39 +191,30 @@ fn encode_capacity(body_len: usize) -> Result { .ok_or(EncodeError::LimitExceeded) } -/// Encode one word, returning the next input offset and output cursor. +/// Encode one word, appending to `out` and returning the next input offset. fn encode_word( body: &[u8], offset: usize, word: u64, - out: &mut [u8], - cursor: usize, -) -> Result<(usize, usize), EncodeError> { + out: &mut Vec, +) -> Result { match tag_of(word) { - ZERO_RUN_TAG => encode_zero_run(body, offset, out, cursor), - DENSE_RUN_TAG => encode_dense_run(body, offset, word, out, cursor), - sparse => encode_sparse_word(offset, word, sparse, out, cursor), + ZERO_RUN_TAG => encode_zero_run(body, offset, out), + DENSE_RUN_TAG => encode_dense_run(body, offset, word, out), + sparse => encode_sparse_word(offset, word, sparse, out), } } /// Encode a run of all-zero words. -fn encode_zero_run( - body: &[u8], - offset: usize, - out: &mut [u8], - cursor: usize, -) -> Result<(usize, usize), EncodeError> { +fn encode_zero_run(body: &[u8], offset: usize, out: &mut Vec) -> Result { let first_extra = advance_encode(offset, WORD_BYTES)?; let extra = run_extras(body, first_extra, |word| word == 0)?; let count = u8::try_from(extra).map_err(|_| EncodeError::LimitExceeded)?; - store(out, cursor, &[ZERO_RUN_TAG, count])?; + out.extend_from_slice(&[ZERO_RUN_TAG, count]); let extra_bytes = extra .checked_mul(WORD_BYTES) .ok_or(EncodeError::LimitExceeded)?; - Ok(( - advance_encode(first_extra, extra_bytes)?, - advance_encode(cursor, 2)?, - )) + advance_encode(first_extra, extra_bytes) } /// Encode a dense passthrough run beginning with `word`. @@ -209,56 +222,60 @@ fn encode_dense_run( body: &[u8], offset: usize, word: u64, - out: &mut [u8], - cursor: usize, -) -> Result<(usize, usize), EncodeError> { + out: &mut Vec, +) -> Result { let start = advance_encode(offset, WORD_BYTES)?; let extra = run_extras(body, start, |word| { tag_of(word).count_ones() >= DENSE_NONZERO_BYTES })?; let count = u8::try_from(extra).map_err(|_| EncodeError::LimitExceeded)?; - store(out, cursor, &[DENSE_RUN_TAG])?; - store(out, advance_encode(cursor, 1)?, &word.to_le_bytes())?; - store(out, advance_encode(cursor, 9)?, &[count])?; let extra_bytes = extra .checked_mul(WORD_BYTES) .ok_or(EncodeError::LimitExceeded)?; let end = advance_encode(start, extra_bytes)?; let raw = body.get(start..end).ok_or(EncodeError::LimitExceeded)?; - store(out, advance_encode(cursor, FAST_WINDOW)?, raw)?; - Ok(( - end, - advance_encode(cursor, FAST_WINDOW.wrapping_add(raw.len()))?, - )) + out.extend_from_slice(&[DENSE_RUN_TAG]); + out.extend_from_slice(&word.to_le_bytes()); + out.extend_from_slice(&[count]); + out.extend_from_slice(raw); + Ok(end) } -/// Encode a sparse word: the tag byte then only its nonzero bytes. +/// Encode a sparse word: the tag byte then only its nonzero bytes, gathered +/// into one register. The whole 9-byte buffer is appended as one +/// constant-size copy (which the compiler inlines, unlike a variable-length +/// copy that lowers to a `memcpy` call per word) and the garbage tail is +/// truncated off — a sparse tag has 1-7 set bits, so the tag byte survives. fn encode_sparse_word( offset: usize, word: u64, tag: u8, - out: &mut [u8], - cursor: usize, -) -> Result<(usize, usize), EncodeError> { - let end = advance_encode(cursor, 9)?; - let dst = out.get_mut(cursor..end).ok_or(EncodeError::LimitExceeded)?; - if let Some(cell) = dst.first_mut() { - *cell = tag; - } - let mut len = 1_usize; + out: &mut Vec, +) -> Result { + let mut buf = [0_u8; 9]; + let (head, tail) = buf.split_first_mut().ok_or(EncodeError::LimitExceeded)?; + *head = tag; + tail.copy_from_slice(&compact_word(word, tag).to_le_bytes()); + out.extend_from_slice(&buf); + let garbage = 8_usize + .wrapping_sub(usize::try_from(tag.count_ones()).map_err(|_| EncodeError::LimitExceeded)?); + out.truncate(out.len().wrapping_sub(garbage)); + advance_encode(offset, WORD_BYTES) +} + +/// Gather the nonzero bytes of `word` (selected by `tag`) into the low lanes +/// of one register, in ascending lane order. +fn compact_word(word: u64, tag: u8) -> u64 { + let mut packed = 0_u64; + let mut shift = 0_u32; let mut bits = tag; while bits != 0 { let lane = bits.trailing_zeros(); - if let Some(cell) = dst.get_mut(len) { - *cell = extract_lane(word, lane); - } - len = len.wrapping_add(1); + packed |= (word.wrapping_shr(lane.wrapping_mul(8)) & 0xFF).wrapping_shl(shift); + shift = shift.wrapping_add(8); bits &= bits.wrapping_sub(1); } - Ok(( - advance_encode(offset, WORD_BYTES)?, - advance_encode(cursor, len)?, - )) + packed } /// Count extra words matching `predicate`, capped by the one-byte run count. @@ -281,57 +298,20 @@ fn run_extras( Ok(count) } -/// Decode one element with unconditional whole-word reads: zero runs bump the -/// cursor over pre-zeroed output, dense runs bulk-copy, sparse words write -/// only their nonzero bytes. The caller guarantees `ELEMENT_ROOM` headroom. -fn decode_fast_element( - packed: &[u8], - window: &[u8], - cursor: usize, - dst: &mut [u8], - written: usize, -) -> Result<(usize, usize), DecodeError> { - let tag = window.first().copied().unwrap_or(0); - match tag { - ZERO_RUN_TAG => { - let extra = usize::from(window.get(1).copied().unwrap_or(0)); - let bytes = extra - .wrapping_add(1) - .checked_mul(WORD_BYTES) - .ok_or(DecodeError::LimitExceeded)?; - Ok((advance(cursor, 2)?, advance(written, bytes)?)) - } - DENSE_RUN_TAG => decode_fast_dense(packed, window, cursor, dst, written), - sparse => { - let src = window - .get(1..1 + WORD_BYTES) - .and_then(|slice| <[u8; WORD_BYTES]>::try_from(slice).ok()) - .ok_or(DecodeError::PackedTruncated)?; - let word = expand_word(sparse, u64::from_le_bytes(src)); - copy_at(dst, written, &word.to_le_bytes())?; - let taken = - usize::try_from(sparse.count_ones()).map_err(|_| DecodeError::LimitExceeded)?; - Ok(( - advance(cursor, taken.wrapping_add(1))?, - advance(written, WORD_BYTES)?, - )) - } - } -} - /// Decode a dense passthrough run in the fast path. fn decode_fast_dense( packed: &[u8], - window: &[u8], cursor: usize, dst: &mut [u8], written: usize, ) -> Result<(usize, usize), DecodeError> { - let extra = usize::from(window.get(9).copied().unwrap_or(0)); + let extra = usize::from(packed.get(cursor.wrapping_add(9)).copied().unwrap_or(0)); let raw_bytes = extra .checked_mul(WORD_BYTES) .ok_or(DecodeError::LimitExceeded)?; - let word = window.get(1..9).ok_or(DecodeError::PackedTruncated)?; + let word = packed + .get(cursor.wrapping_add(1)..cursor.wrapping_add(9)) + .ok_or(DecodeError::PackedTruncated)?; copy_at(dst, written, word)?; let raw_start = advance(cursor, FAST_WINDOW)?; let raw_end = advance(raw_start, raw_bytes)?; @@ -434,22 +414,6 @@ fn tag_of(word: u64) -> u8 { u8::try_from(lanes.wrapping_mul(LANE_GATHER).wrapping_shr(56)).unwrap_or(0) } -/// Extract byte `lane` (0-7) of a word. -fn extract_lane(word: u64, lane: u32) -> u8 { - u8::try_from(word.wrapping_shr(lane.wrapping_mul(8)) & 0xFF).unwrap_or(0) -} - -/// Copy `src` into `dst` at `offset` (encode-side store). -fn store(dst: &mut [u8], offset: usize, src: &[u8]) -> Result<(), EncodeError> { - let end = offset - .checked_add(src.len()) - .ok_or(EncodeError::LimitExceeded)?; - dst.get_mut(offset..end) - .ok_or(EncodeError::LimitExceeded)? - .copy_from_slice(src); - Ok(()) -} - /// Append bytes while enforcing the unpacked output cap. fn append_bytes(out: &mut Vec, bytes: &[u8]) -> Result<(), DecodeError> { checked_output_len(out.len(), bytes.len()).map(|_| ())?; diff --git a/crates/tdbin/src/reader_lists.rs b/crates/tdbin/src/reader_lists.rs index d6bd268..4950556 100644 --- a/crates/tdbin/src/reader_lists.rs +++ b/crates/tdbin/src/reader_lists.rs @@ -408,13 +408,12 @@ impl<'a> Reader<'a> { } } -/// Append the requested low bits from one packed Bool word. +/// Append the requested low bits from one packed Bool word: an exact-size +/// range extend, so the reserve and per-element capacity checks vanish and +/// each lane's shift is independent of the previous one. fn append_bool_word(out: &mut Vec, word: u64, count: usize) { - let mut mask = 1_u64; - for _ in 0..count { - out.push(word & mask != 0); - mask = mask.rotate_left(1); - } + let take = u32::try_from(count).unwrap_or(64).min(64); + out.extend((0..take).map(|bit| word.wrapping_shr(bit) & 1 != 0)); } /// Convert an exact 8-byte chunk to an array (total for `chunks_exact` output). diff --git a/crates/tdbin/src/writer.rs b/crates/tdbin/src/writer.rs index dfafff1..ed1f810 100644 --- a/crates/tdbin/src/writer.rs +++ b/crates/tdbin/src/writer.rs @@ -18,6 +18,9 @@ use crate::{Struct, MAX_DEPTH}; const MAX_WORDS: usize = 1 << 26; /// Initial arena capacity: covers small messages with one allocation. const INITIAL_CAPACITY: usize = 256; +/// Capacity knee above which arena growth falls back from ×8 to ×4, bounding +/// worst-case retained overshoot for large messages at the pre-knee level. +const GROWTH_KNEE_BYTES: usize = 1 << 22; /// Accumulates message body words while encoding a value tree. #[derive(Debug)] @@ -145,10 +148,17 @@ impl Writer { } /// Grow capacity ahead of `end` aggressively so bulk encodes do not pay - /// repeated doubling copies. + /// repeated doubling copies: ×8 below the knee keeps growth-copy traffic + /// under a seventh of the final body, and ×4 above it caps the retained + /// overshoot for large messages at the historical bound. fn grow_for(&mut self, end: usize) { if end > self.body.capacity() { - let ahead = end.max(self.body.capacity().wrapping_mul(4)); + let factor = if self.body.capacity() < GROWTH_KNEE_BYTES { + 8 + } else { + 4 + }; + let ahead = end.max(self.body.capacity().wrapping_mul(factor)); self.body.reserve(ahead.saturating_sub(self.body.len())); } } @@ -303,7 +313,9 @@ impl Writer { self.set(ptr_word, ptr) } - /// Pack bits little-endian into already-reserved words at `start`. + /// Pack bits little-endian into already-reserved words at `start`: one + /// 64-bit accumulator flush per word instead of a bounds-checked + /// read-modify-write per bit. pub(crate) fn pack_bits( &mut self, start: usize, @@ -317,12 +329,23 @@ impl Writer { .body .get_mut(byte_start..) .ok_or(EncodeError::LimitExceeded)?; - for (i, value) in values.enumerate() { - let cell = dst.get_mut(i / 8).ok_or(EncodeError::LimitExceeded)?; - let mask = 1_u8.wrapping_shl(u32::try_from(i % 8).unwrap_or(0)); - *cell |= mask & u8::from(value).wrapping_neg(); + let mut words = dst.chunks_exact_mut(WORD_BYTES); + let (mut acc, mut filled) = (0_u64, 0_u32); + for value in values { + acc |= u64::from(value).wrapping_shl(filled); + filled = filled.wrapping_add(1); + (acc, filled) = match filled { + 64 => { + flush_bit_word(&mut words, acc)?; + (0, 0) + } + _ => (acc, filled), + }; + } + match filled { + 0 => Ok(()), + _ => flush_bit_word(&mut words, acc), } - Ok(()) } /// Run one nested struct write with the pointer-depth budget decremented. @@ -338,6 +361,17 @@ impl Writer { } } +/// Write one packed bit word into the next reserved 8-byte chunk. +fn flush_bit_word( + words: &mut core::slice::ChunksExactMut<'_, u8>, + acc: u64, +) -> Result<(), EncodeError> { + words + .next() + .ok_or(EncodeError::LimitExceeded) + .map(|chunk| chunk.copy_from_slice(&acc.to_le_bytes())) +} + /// Read a word back out of a mutable cell. fn word_of(cell: &[u8]) -> Result { <[u8; WORD_BYTES]>::try_from(cell) diff --git a/crates/tdbin/tests/support/batch_corpus.rs b/crates/tdbin/tests/support/batch_corpus.rs index 60e9820..5a878fa 100644 --- a/crates/tdbin/tests/support/batch_corpus.rs +++ b/crates/tdbin/tests/support/batch_corpus.rs @@ -13,7 +13,7 @@ pub const PERSON_COUNT: usize = 512; pub const CONTACT_COUNT: usize = 2_048; /// Protobuf record-heavy batch. -#[derive(Clone, PartialEq, Message)] +#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Message)] pub struct PbPersonBatch { /// Repeated records. #[prost(message, repeated, tag = "1")] @@ -21,7 +21,7 @@ pub struct PbPersonBatch { } /// Protobuf union-heavy batch. -#[derive(Clone, PartialEq, Message)] +#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Message)] pub struct PbContactBatch { /// Repeated oneof envelopes. #[prost(message, repeated, tag = "1")] @@ -29,7 +29,7 @@ pub struct PbContactBatch { } /// Protobuf message envelope required around each repeated oneof value. -#[derive(Clone, PartialEq, Message)] +#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Message)] pub struct PbContactEnvelope { /// Contact payload. #[prost(oneof = "PbContact", tags = "1, 2")] @@ -37,7 +37,7 @@ pub struct PbContactEnvelope { } /// Protobuf mirror of the generated Contact union. -#[derive(Clone, PartialEq, prost::Oneof)] +#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Oneof)] pub enum PbContact { /// Email contact. #[prost(message, tag = "1")] diff --git a/crates/tdbin/tests/support/bench_corpus.rs b/crates/tdbin/tests/support/bench_corpus.rs index b5c2ba6..85f538d 100644 --- a/crates/tdbin/tests/support/bench_corpus.rs +++ b/crates/tdbin/tests/support/bench_corpus.rs @@ -83,7 +83,7 @@ pub mod corpus { /// size and speed comparison is fair. pub mod pb { /// The `Address` message: `string street = 1; int64 zip = 2;`. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct Address { /// The street line. #[prost(string, tag = "1")] @@ -94,7 +94,7 @@ pub mod corpus { } /// The `EmailContact` message: `string addr = 1;`. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct EmailContact { /// The email address. #[prost(string, tag = "1")] @@ -102,7 +102,7 @@ pub mod corpus { } /// The `PhoneContact` message: `int64 number = 1; int64 country = 2;`. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct PhoneContact { /// The subscriber number. #[prost(int64, tag = "1")] @@ -113,7 +113,7 @@ pub mod corpus { } /// The `contact` oneof: `EmailContact email = 7 | PhoneContact phone = 8`. - #[derive(Clone, PartialEq, prost::Oneof)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Oneof)] pub enum Contact { /// The email-contact variant. #[prost(message, tag = "7")] @@ -124,7 +124,7 @@ pub mod corpus { } /// The top-level `Person` message mirroring the TDBIN `Person` record. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct Person { /// The full name. #[prost(string, tag = "1")] @@ -150,7 +150,7 @@ pub mod corpus { } /// A metric batch with list-heavy, fixed-width data. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchMetricBatch { /// Batch identifier. #[prost(string, tag = "1")] @@ -176,7 +176,7 @@ pub mod corpus { } /// A named repeated-double metric column. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchMetricColumn { /// Column name. #[prost(string, tag = "1")] diff --git a/crates/tdbin/tests/support/document_corpus.rs b/crates/tdbin/tests/support/document_corpus.rs index f82290d..e0c77c7 100644 --- a/crates/tdbin/tests/support/document_corpus.rs +++ b/crates/tdbin/tests/support/document_corpus.rs @@ -14,7 +14,7 @@ const META_COUNT: usize = 16; /// Protobuf mirror types for the document fixture. pub mod pb { /// Protobuf diagram document. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchDocument { /// Stable document identifier. #[prost(string, tag = "1")] @@ -40,7 +40,7 @@ pub mod pb { } /// Protobuf diagram node. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchNode { /// Stable node identifier. #[prost(string, tag = "1")] @@ -72,7 +72,7 @@ pub mod pb { } /// Protobuf diagram edge. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchEdge { /// Stable edge identifier. #[prost(string, tag = "1")] @@ -95,7 +95,7 @@ pub mod pb { } /// Protobuf style rule. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchStyle { /// Selector expression. #[prost(string, tag = "1")] @@ -115,7 +115,7 @@ pub mod pb { } /// Protobuf metadata key/value pair. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchMeta { /// Metadata key. #[prost(string, tag = "1")] diff --git a/crates/tdbin/tests/support/event_corpus.rs b/crates/tdbin/tests/support/event_corpus.rs index 4056336..6d40576 100644 --- a/crates/tdbin/tests/support/event_corpus.rs +++ b/crates/tdbin/tests/support/event_corpus.rs @@ -14,7 +14,7 @@ pub mod pb { use super::documents; /// Protobuf event-stream envelope. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchEventBatch { /// Ordered diagram events. #[prost(message, repeated, tag = "1")] @@ -22,7 +22,7 @@ pub mod pb { } /// Protobuf envelope for one event union. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchEventEnvelope { /// Event payload. #[prost(oneof = "BenchEvent", tags = "1, 2, 3, 4, 5, 6")] @@ -30,7 +30,7 @@ pub mod pb { } /// Protobuf event oneof. - #[derive(Clone, PartialEq, prost::Oneof)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Oneof)] pub enum BenchEvent { /// A node was created. #[prost(message, tag = "1")] @@ -53,7 +53,7 @@ pub mod pb { } /// Protobuf node-created payload. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchNodeCreated { /// Owning document identifier. #[prost(string, tag = "1")] @@ -64,7 +64,7 @@ pub mod pb { } /// Protobuf node-moved payload. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchNodeMoved { /// Owning document identifier. #[prost(string, tag = "1")] @@ -81,7 +81,7 @@ pub mod pb { } /// Protobuf edge-added payload. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchEdgeAdded { /// Owning document identifier. #[prost(string, tag = "1")] @@ -92,7 +92,7 @@ pub mod pb { } /// Protobuf selection-changed payload. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchSelectionChanged { /// Owning document identifier. #[prost(string, tag = "1")] @@ -106,7 +106,7 @@ pub mod pb { } /// Protobuf view-changed payload. - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchViewChanged { /// Owning document identifier. #[prost(string, tag = "1")] @@ -123,7 +123,7 @@ pub mod pb { } /// Protobuf empty heartbeat payload. - #[derive(Clone, Copy, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, prost::Message)] pub struct BenchHeartbeat {} } diff --git a/docs/reports/tdbin-bench-data.json b/docs/reports/tdbin-bench-data.json index a31c216..74d7ef2 100644 --- a/docs/reports/tdbin-bench-data.json +++ b/docs/reports/tdbin-bench-data.json @@ -1,6 +1,6 @@ { "format_version": 1, - "generated_at": "2026-07-11T15:07:08.285Z", + "generated_at": "2026-07-13T23:32:23.773Z", "commands": ["cargo bench -p tdbin --bench gate -- --noplot", "node scripts/tdbin-bench-report.mjs"], "corpus_schemas": ["docs/benchmarks/tdbin-corpus.td", "docs/benchmarks/tdbin-corpus.proto"], "gate": { @@ -16,9 +16,9 @@ "cpu": "Apple M4 Max", "logical_cpus": 14, "memory_bytes": 38654705664, - "rustc": "rustc 1.96.0 (ac68faa20 2026-05-25)", - "cargo": "cargo 1.96.0 (30a34c682 2026-05-25)", - "dependencies": "tdbin v0.0.0 (/Users/christianfindlay/Documents/Code/typeDiagram/crates/tdbin)\n[dev-dependencies]\n├── criterion v0.8.2\n└── prost v0.14.3" + "rustc": "rustc 1.97.0 (2d8144b78 2026-07-07)", + "cargo": "cargo 1.97.0 (c980f4866 2026-06-30)", + "dependencies": "tdbin v0.0.0 (/Users/christianfindlay/Documents/Code/typeDiagram/crates/tdbin)\n[dev-dependencies]\n├── criterion v0.8.2\n├── prost v0.14.3\n├── rmp-serde v1.3.1\n└── serde v1.0.228" }, "sizes": { "format_version": 1, @@ -31,7 +31,8 @@ "tdbin_bare": 160, "tdbin_framed": 172, "tdbin_packed_framed": 109, - "protobuf": 79 + "protobuf": 79, + "msgpack": 142 }, { "name": "without_address", @@ -41,7 +42,8 @@ "tdbin_bare": 112, "tdbin_framed": 124, "tdbin_packed_framed": 54, - "protobuf": 31 + "protobuf": 31, + "msgpack": 100 }, { "name": "metric_batch", @@ -51,7 +53,8 @@ "tdbin_bare": 43776, "tdbin_framed": 43788, "tdbin_packed_framed": 23045, - "protobuf": 84149 + "protobuf": 84149, + "msgpack": 90440 }, { "name": "person_batch", @@ -61,7 +64,8 @@ "tdbin_bare": 22976, "tdbin_framed": 22988, "tdbin_packed_framed": 20071, - "protobuf": 29184 + "protobuf": 29184, + "msgpack": 61963 }, { "name": "contact_batch", @@ -71,7 +75,8 @@ "tdbin_bare": 23144, "tdbin_framed": 23156, "tdbin_packed_framed": 22317, - "protobuf": 35221 + "protobuf": 35221, + "msgpack": 80162 }, { "name": "diagram_document", @@ -81,7 +86,8 @@ "tdbin_bare": 45160, "tdbin_framed": 45172, "tdbin_packed_framed": 37868, - "protobuf": 50788 + "protobuf": 50788, + "msgpack": 77410 }, { "name": "event_batch", @@ -91,7 +97,8 @@ "tdbin_bare": 116360, "tdbin_framed": 116372, "tdbin_packed_framed": 102861, - "protobuf": 131744 + "protobuf": 131744, + "msgpack": 230620 } ] }, @@ -99,450 +106,562 @@ { "fixture": "with_address", "operation": "tdbin_encode_bare", - "median_ns": 83.0986024519383, - "confidence_interval_ns": [82.75906918294152, 83.3815792987034], + "median_ns": 80.66253398954024, + "confidence_interval_ns": [80.28346371269271, 81.26352559645052], "sample_count": 50, - "sampled_time_ns": 5010966247 + "sampled_time_ns": 4931599211 }, { "fixture": "with_address", "operation": "tdbin_encode_framed", - "median_ns": 109.3599955037348, - "confidence_interval_ns": [101.10398443996914, 111.63251257218099], + "median_ns": 86.21141242420813, + "confidence_interval_ns": [85.8729337249524, 86.99114180135571], "sample_count": 50, - "sampled_time_ns": 6167315255 + "sampled_time_ns": 5039075251 }, { "fixture": "with_address", "operation": "tdbin_encode_packed_framed", - "median_ns": 198.64182683798805, - "confidence_interval_ns": [196.79967216953517, 207.63834509272823], + "median_ns": 175.18662289905606, + "confidence_interval_ns": [173.6160157941058, 176.25406192750097], "sample_count": 50, - "sampled_time_ns": 4688622750 + "sampled_time_ns": 4964207711 }, { "fixture": "with_address", "operation": "protobuf_encode", - "median_ns": 69.4070397719205, - "confidence_interval_ns": [64.09463081445617, 73.29276867542798], + "median_ns": 46.82040448307977, + "confidence_interval_ns": [46.59288984683352, 47.057366656795814], "sample_count": 50, - "sampled_time_ns": 4853818290 + "sampled_time_ns": 4994310456 + }, + { + "fixture": "with_address", + "operation": "msgpack_encode", + "median_ns": 240.49730175385997, + "confidence_interval_ns": [240.01858527251403, 241.7074734755742], + "sample_count": 50, + "sampled_time_ns": 4989358003 }, { "fixture": "with_address", "operation": "tdbin_decode_bare", - "median_ns": 154.30256338383225, - "confidence_interval_ns": [151.27704125756728, 157.0964401015884], + "median_ns": 152.29744153338362, + "confidence_interval_ns": [151.4458415610709, 152.90562522349728], "sample_count": 50, - "sampled_time_ns": 5247387792 + "sampled_time_ns": 5030072208 }, { "fixture": "with_address", "operation": "tdbin_decode_framed", - "median_ns": 939.8466969498579, - "confidence_interval_ns": [908.5379492439257, 966.4722433975369], + "median_ns": 157.3196474099544, + "confidence_interval_ns": [155.85379955221916, 161.80085687475307], "sample_count": 50, - "sampled_time_ns": 7711428377 + "sampled_time_ns": 5089967627 }, { "fixture": "with_address", "operation": "tdbin_decode_packed_framed", - "median_ns": 3605.618066451447, - "confidence_interval_ns": [3548.2055785694556, 3652.6665439258645], + "median_ns": 587.5673992143045, + "confidence_interval_ns": [580.0440643571532, 594.537930398345], "sample_count": 50, - "sampled_time_ns": 5589697585 + "sampled_time_ns": 5106050623 }, { "fixture": "with_address", "operation": "protobuf_decode", - "median_ns": 921.2747494756468, - "confidence_interval_ns": [913.1263968042108, 939.4358635102728], + "median_ns": 150.49882485315788, + "confidence_interval_ns": [149.9105977422589, 151.64202968347988], + "sample_count": 50, + "sampled_time_ns": 5005128292 + }, + { + "fixture": "with_address", + "operation": "msgpack_decode", + "median_ns": 192.3886321309173, + "confidence_interval_ns": [191.474710163947, 193.24566098715732], "sample_count": 50, - "sampled_time_ns": 5038537211 + "sampled_time_ns": 4969318670 }, { "fixture": "without_address", "operation": "tdbin_encode_bare", - "median_ns": 338.23941935625714, - "confidence_interval_ns": [335.94558211992916, 341.9764862369185], + "median_ns": 59.77926347146385, + "confidence_interval_ns": [59.39425679687683, 60.18328135812989], "sample_count": 50, - "sampled_time_ns": 4902567339 + "sampled_time_ns": 4945151337 }, { "fixture": "without_address", "operation": "tdbin_encode_framed", - "median_ns": 400.05962862713375, - "confidence_interval_ns": [396.69831002150937, 402.9557669363937], + "median_ns": 63.80137492555723, + "confidence_interval_ns": [63.37494250546698, 64.16062978108576], "sample_count": 50, - "sampled_time_ns": 5493679418 + "sampled_time_ns": 4943136116 }, { "fixture": "without_address", "operation": "tdbin_encode_packed_framed", - "median_ns": 764.9362829090513, - "confidence_interval_ns": [754.5268784450246, 777.8599521207768], + "median_ns": 136.46756403621833, + "confidence_interval_ns": [135.27236502492664, 136.99058228932182], "sample_count": 50, - "sampled_time_ns": 2607578164 + "sampled_time_ns": 4916981874 }, { "fixture": "without_address", "operation": "protobuf_encode", - "median_ns": 35.643756724257955, - "confidence_interval_ns": [35.34580951704713, 36.04477387841564], + "median_ns": 32.86292578472172, + "confidence_interval_ns": [32.725095755584995, 32.977483175502], "sample_count": 50, - "sampled_time_ns": 5172749164 + "sampled_time_ns": 4996045202 + }, + { + "fixture": "without_address", + "operation": "msgpack_encode", + "median_ns": 227.40270535766098, + "confidence_interval_ns": [226.84134997969778, 228.35130484160644], + "sample_count": 50, + "sampled_time_ns": 5037188670 }, { "fixture": "without_address", "operation": "tdbin_decode_bare", - "median_ns": 73.63715672425766, - "confidence_interval_ns": [73.28936054166395, 74.39476561989946], + "median_ns": 67.71712839602927, + "confidence_interval_ns": [67.2218761174968, 67.94533424077434], "sample_count": 50, - "sampled_time_ns": 5770456038 + "sampled_time_ns": 4996015209 }, { "fixture": "without_address", "operation": "tdbin_decode_framed", - "median_ns": 76.62628666396307, - "confidence_interval_ns": [76.28170920091168, 76.97378832520158], + "median_ns": 76.84652405551515, + "confidence_interval_ns": [73.48044180749059, 77.69475199095902], "sample_count": 50, - "sampled_time_ns": 5716749832 + "sampled_time_ns": 5384606170 }, { "fixture": "without_address", "operation": "tdbin_decode_packed_framed", - "median_ns": 542.176390275313, - "confidence_interval_ns": [537.789443896877, 545.6730102040816], + "median_ns": 500.5722547038896, + "confidence_interval_ns": [489.1262688257622, 507.77569486959715], "sample_count": 50, - "sampled_time_ns": 5451650873 + "sampled_time_ns": 5152076337 }, { "fixture": "without_address", "operation": "protobuf_decode", - "median_ns": 53.270764638974285, - "confidence_interval_ns": [52.774499120830136, 54.62112858742235], + "median_ns": 50.05978659800866, + "confidence_interval_ns": [49.74005818731422, 50.21508152439319], + "sample_count": 50, + "sampled_time_ns": 4993445744 + }, + { + "fixture": "without_address", + "operation": "msgpack_decode", + "median_ns": 108.74915044756817, + "confidence_interval_ns": [107.47195618629236, 109.72661493707173], "sample_count": 50, - "sampled_time_ns": 5653604124 + "sampled_time_ns": 4989619371 }, { "fixture": "metric_batch", "operation": "tdbin_encode_bare", - "median_ns": 12304.579206968829, - "confidence_interval_ns": [12034.351204594741, 12986.859614434008], + "median_ns": 7262.825498310811, + "confidence_interval_ns": [6474.2469594594595, 7471.000564671815], "sample_count": 50, - "sampled_time_ns": 3765785380 + "sampled_time_ns": 5385509836 }, { "fixture": "metric_batch", "operation": "tdbin_encode_framed", - "median_ns": 14973.70605269598, - "confidence_interval_ns": [14362.956805293006, 15266.240419615773], + "median_ns": 6557.218875647044, + "confidence_interval_ns": [6336.129977395236, 6871.107198748044], "sample_count": 50, - "sampled_time_ns": 4256507585 + "sampled_time_ns": 5386488206 }, { "fixture": "metric_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 27741.34569382736, - "confidence_interval_ns": [24611.30361188019, 29641.257145924064], + "median_ns": 20227.95032517095, + "confidence_interval_ns": [19324.312640071716, 20609.79381443299], "sample_count": 50, - "sampled_time_ns": 4639950838 + "sampled_time_ns": 4909780709 }, { "fixture": "metric_batch", "operation": "protobuf_encode", - "median_ns": 50695.82419075859, - "confidence_interval_ns": [45812.643214688826, 56102.63138832998], + "median_ns": 45615.628562995444, + "confidence_interval_ns": [44868.34673659674, 45992.02636363637], "sample_count": 50, - "sampled_time_ns": 5412158586 + "sampled_time_ns": 5047081373 + }, + { + "fixture": "metric_batch", + "operation": "msgpack_encode", + "median_ns": 43618.68538407405, + "confidence_interval_ns": [43455.15967202503, 43703.353937728934], + "sample_count": 50, + "sampled_time_ns": 5067377420 }, { "fixture": "metric_batch", "operation": "tdbin_decode_bare", - "median_ns": 7712.441381987577, - "confidence_interval_ns": [7535.392364793213, 7846.435093167702], + "median_ns": 5223.054576352292, + "confidence_interval_ns": [5215.887511170688, 5239.024338991629], "sample_count": 50, - "sampled_time_ns": 3110157084 + "sampled_time_ns": 4970291120 }, { "fixture": "metric_batch", "operation": "tdbin_decode_framed", - "median_ns": 7950.836157946074, - "confidence_interval_ns": [7797.901587003475, 8153.567186053687], + "median_ns": 5197.743615877652, + "confidence_interval_ns": [5181.858177809277, 5208.409101377815], "sample_count": 50, - "sampled_time_ns": 4824275454 + "sampled_time_ns": 4964629704 }, { "fixture": "metric_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 37962.75155400156, - "confidence_interval_ns": [37782.57575757576, 38310.655483405484], + "median_ns": 28040.77421140011, + "confidence_interval_ns": [27998.788724122744, 28085.080848288402], "sample_count": 50, - "sampled_time_ns": 5710549457 + "sampled_time_ns": 4979641376 }, { "fixture": "metric_batch", "operation": "protobuf_decode", - "median_ns": 35866.98133657133, - "confidence_interval_ns": [35315.62229357798, 36267.435779816515], + "median_ns": 29664.01679524088, + "confidence_interval_ns": [29391.749119097956, 29918.981427648578], + "sample_count": 50, + "sampled_time_ns": 4924024207 + }, + { + "fixture": "metric_batch", + "operation": "msgpack_decode", + "median_ns": 52304.24770021645, + "confidence_interval_ns": [51517.22301836095, 53082.47749924494], "sample_count": 50, - "sampled_time_ns": 4988323041 + "sampled_time_ns": 5165279712 }, { "fixture": "person_batch", "operation": "tdbin_encode_bare", - "median_ns": 11811.259324532171, - "confidence_interval_ns": [11727.91182913472, 11857.022682529383], + "median_ns": 9836.756108796577, + "confidence_interval_ns": [9785.976368159205, 9880.072850035536], "sample_count": 50, - "sampled_time_ns": 5002386459 + "sampled_time_ns": 5078211832 }, { "fixture": "person_batch", "operation": "tdbin_encode_framed", - "median_ns": 11857.785178035178, - "confidence_interval_ns": [11793.738811982714, 11876.218218218219], + "median_ns": 9728.463140777594, + "confidence_interval_ns": [9710.947925116267, 9767.3027829602], "sample_count": 50, - "sampled_time_ns": 4998342748 + "sampled_time_ns": 5005247458 }, { "fixture": "person_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 15541.067667958656, - "confidence_interval_ns": [15265.019379844962, 15892.26976744186], + "median_ns": 13544.83958122256, + "confidence_interval_ns": [13456.615691489362, 13572.030141843972], "sample_count": 50, - "sampled_time_ns": 5124612873 + "sampled_time_ns": 5019038915 }, { "fixture": "person_batch", "operation": "protobuf_encode", - "median_ns": 18246.74396654719, - "confidence_interval_ns": [18085.814536340855, 18350.320477843918], + "median_ns": 17986.273732121488, + "confidence_interval_ns": [17813.4601025828, 18161.928230122325], + "sample_count": 50, + "sampled_time_ns": 5005869541 + }, + { + "fixture": "person_batch", + "operation": "msgpack_encode", + "median_ns": 48368.5060298103, + "confidence_interval_ns": [48262.618808788946, 48658.4828469884], "sample_count": 50, - "sampled_time_ns": 5069842789 + "sampled_time_ns": 5005912204 }, { "fixture": "person_batch", "operation": "tdbin_decode_bare", - "median_ns": 36733.65223791067, - "confidence_interval_ns": [36446.825531914896, 37009.23423423423], + "median_ns": 35363.96979774053, + "confidence_interval_ns": [35191.16377372628, 35485.20992063492], "sample_count": 50, - "sampled_time_ns": 5009308170 + "sampled_time_ns": 5062145252 }, { "fixture": "person_batch", "operation": "tdbin_decode_framed", - "median_ns": 37817.89528566519, - "confidence_interval_ns": [37126.017136329014, 38522.627846534655], + "median_ns": 35157.26087516088, + "confidence_interval_ns": [35062.52638352638, 35251.60875160875], "sample_count": 50, - "sampled_time_ns": 4937394337 + "sampled_time_ns": 5005411708 }, { "fixture": "person_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 41742.64619883041, - "confidence_interval_ns": [41256.072874493926, 42369.14766917293], + "median_ns": 39553.59654786502, + "confidence_interval_ns": [39371.85197308601, 39679.46464646464], "sample_count": 50, - "sampled_time_ns": 5122980873 + "sampled_time_ns": 5006200831 }, { "fixture": "person_batch", "operation": "protobuf_decode", - "median_ns": 57691.02573529412, - "confidence_interval_ns": [57264.09488795518, 58092.244884910484], + "median_ns": 55844.0400928297, + "confidence_interval_ns": [55677.940017728746, 56068.63791079812], "sample_count": 50, - "sampled_time_ns": 5044088503 + "sampled_time_ns": 5059321294 + }, + { + "fixture": "person_batch", + "operation": "msgpack_decode", + "median_ns": 87034.53538868538, + "confidence_interval_ns": [86460.22407407407, 87670.98518518519], + "sample_count": 50, + "sampled_time_ns": 5000367085 }, { "fixture": "contact_batch", "operation": "tdbin_encode_bare", - "median_ns": 9627.04700894493, - "confidence_interval_ns": [9605.446515892421, 9661.16333876664], + "median_ns": 9181.736340280793, + "confidence_interval_ns": [9147.127758077226, 9261.556855791961], "sample_count": 50, - "sampled_time_ns": 5017694914 + "sampled_time_ns": 4972711332 }, { "fixture": "contact_batch", "operation": "tdbin_encode_framed", - "median_ns": 9653.37920771757, - "confidence_interval_ns": [9611.229788467112, 9720.221674876848], + "median_ns": 9168.753318060531, + "confidence_interval_ns": [9129.980223783503, 9217.280384397965], "sample_count": 50, - "sampled_time_ns": 5011654586 + "sampled_time_ns": 4990231165 }, { "fixture": "contact_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 12925.319602272728, - "confidence_interval_ns": [12866.41525974026, 13029.128030303029], + "median_ns": 11956.549558235114, + "confidence_interval_ns": [11901.623839009288, 12001.212693498452], "sample_count": 50, - "sampled_time_ns": 5076227668 + "sampled_time_ns": 4967010837 }, { "fixture": "contact_batch", "operation": "protobuf_encode", - "median_ns": 28364.751068519512, - "confidence_interval_ns": [28008.61768707483, 28740.990767735668], + "median_ns": 24760.563060989643, + "confidence_interval_ns": [24619.580096027934, 25031.38193768257], + "sample_count": 50, + "sampled_time_ns": 5012321380 + }, + { + "fixture": "contact_batch", + "operation": "msgpack_encode", + "median_ns": 67893.01915708813, + "confidence_interval_ns": [67626.29503714052, 68132.59280272655], "sample_count": 50, - "sampled_time_ns": 5413163040 + "sampled_time_ns": 5017045296 }, { "fixture": "contact_batch", "operation": "tdbin_decode_bare", - "median_ns": 36763.935706018514, - "confidence_interval_ns": [36436.13937621832, 38004.730273752015], + "median_ns": 30450.98590281717, + "confidence_interval_ns": [30255.75421300977, 30601.00986610289], "sample_count": 50, - "sampled_time_ns": 5239417333 + "sampled_time_ns": 5001396623 }, { "fixture": "contact_batch", "operation": "tdbin_decode_framed", - "median_ns": 43522.073933998836, - "confidence_interval_ns": [42990.50632911392, 45359.17721518988], + "median_ns": 30350.757751937985, + "confidence_interval_ns": [30203.361981799797, 30509.20016149871], "sample_count": 50, - "sampled_time_ns": 4709271376 + "sampled_time_ns": 4993096208 }, { "fixture": "contact_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 40589.56235239541, - "confidence_interval_ns": [38987.891636141634, 41945.72649572649], + "median_ns": 32193.801587756505, + "confidence_interval_ns": [32059.536910235904, 32292.73381147541], "sample_count": 50, - "sampled_time_ns": 4278261001 + "sampled_time_ns": 4995878914 }, { "fixture": "contact_batch", "operation": "protobuf_decode", - "median_ns": 72148.43694196429, - "confidence_interval_ns": [69175.54523809523, 74153.18263888889], + "median_ns": 59680.88003482025, + "confidence_interval_ns": [59468.39898989899, 59851.06319444445], "sample_count": 50, - "sampled_time_ns": 4708885204 + "sampled_time_ns": 5013238585 + }, + { + "fixture": "contact_batch", + "operation": "msgpack_decode", + "median_ns": 92548.30163013242, + "confidence_interval_ns": [92047.74630021142, 93057.6511627907], + "sample_count": 50, + "sampled_time_ns": 5098326167 }, { "fixture": "diagram_document", "operation": "tdbin_encode_bare", - "median_ns": 16468.413178034374, - "confidence_interval_ns": [15267.550766410459, 17475.167218925613], + "median_ns": 10234.118684383202, + "confidence_interval_ns": [10185.908914163509, 10302.862378901722], "sample_count": 50, - "sampled_time_ns": 3973093918 + "sampled_time_ns": 4987085163 }, { "fixture": "diagram_document", "operation": "tdbin_encode_framed", - "median_ns": 13958.201267482516, - "confidence_interval_ns": [13514.264891562685, 15376.681784105258], + "median_ns": 10258.766919747148, + "confidence_interval_ns": [10222.591756807162, 10348.165361183637], "sample_count": 50, - "sampled_time_ns": 5476900039 + "sampled_time_ns": 5037162586 }, { "fixture": "diagram_document", "operation": "tdbin_encode_packed_framed", - "median_ns": 24274.09638554217, - "confidence_interval_ns": [23245.29059093517, 24469.05464716007], + "median_ns": 17134.32821637427, + "confidence_interval_ns": [17066.328728070177, 17208.192645074225], "sample_count": 50, - "sampled_time_ns": 5682131288 + "sampled_time_ns": 5009037000 }, { "fixture": "diagram_document", "operation": "protobuf_encode", - "median_ns": 23293.14121825226, - "confidence_interval_ns": [21948.859618104667, 24437.80198019802], + "median_ns": 21698.624122405374, + "confidence_interval_ns": [21502.352869352868, 22149.385655512677], + "sample_count": 50, + "sampled_time_ns": 5082264999 + }, + { + "fixture": "diagram_document", + "operation": "msgpack_encode", + "median_ns": 57092.554445750095, + "confidence_interval_ns": [56544.90993955518, 57765.89040585554], "sample_count": 50, - "sampled_time_ns": 2832264585 + "sampled_time_ns": 4993632793 }, { "fixture": "diagram_document", "operation": "tdbin_decode_bare", - "median_ns": 74797.71759259258, - "confidence_interval_ns": [74476.04962962963, 75054.47592592593], + "median_ns": 70279.19221735882, + "confidence_interval_ns": [69859.14327485379, 70709.99362041468], "sample_count": 50, - "sampled_time_ns": 5183134501 + "sampled_time_ns": 5130112539 }, { "fixture": "diagram_document", "operation": "tdbin_decode_framed", - "median_ns": 75938.81051463718, - "confidence_interval_ns": [75538.61046511628, 76333.85859973359], + "median_ns": 69248.28776144565, + "confidence_interval_ns": [69010.89122807018, 69658.3820662768], "sample_count": 50, - "sampled_time_ns": 5024830496 + "sampled_time_ns": 5066609711 }, { "fixture": "diagram_document", "operation": "tdbin_decode_packed_framed", - "median_ns": 84772.29507844402, - "confidence_interval_ns": [84151.99468085106, 85929.28039513677], + "median_ns": 78909.65864942528, + "confidence_interval_ns": [78556.875, 79335.08380952381], "sample_count": 50, - "sampled_time_ns": 5079075627 + "sampled_time_ns": 5040853416 }, { "fixture": "diagram_document", "operation": "protobuf_decode", - "median_ns": 120168.45172634271, - "confidence_interval_ns": [119515.8756684492, 120872.32037815126], + "median_ns": 109277.37806372548, + "confidence_interval_ns": [108927.45368867244, 109704.62148238925], "sample_count": 50, - "sampled_time_ns": 5248374578 + "sampled_time_ns": 5019651169 + }, + { + "fixture": "diagram_document", + "operation": "msgpack_decode", + "median_ns": 130848.12651515153, + "confidence_interval_ns": [130323.23333333334, 131115.14227642276], + "sample_count": 50, + "sampled_time_ns": 4999292417 }, { "fixture": "event_batch", "operation": "tdbin_encode_bare", - "median_ns": 40854.41593242901, - "confidence_interval_ns": [40522.44836317135, 41923.52321004159], + "median_ns": 33937.42363147605, + "confidence_interval_ns": [33888.73922413793, 34061.57183908046], "sample_count": 50, - "sampled_time_ns": 5159725958 + "sampled_time_ns": 5030536122 }, { "fixture": "event_batch", "operation": "tdbin_encode_framed", - "median_ns": 39913.562568681315, - "confidence_interval_ns": [39642.669137761244, 40059.578889860146], + "median_ns": 34150.02697410486, + "confidence_interval_ns": [34000.292482368444, 34333.80434782609], "sample_count": 50, - "sampled_time_ns": 5226986670 + "sampled_time_ns": 5031903248 }, { "fixture": "event_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 55970.928275599545, - "confidence_interval_ns": [55781.71908310115, 56181.00070422535], + "median_ns": 50871.72338646202, + "confidence_interval_ns": [50690.85237657965, 50989.538095238095], "sample_count": 50, - "sampled_time_ns": 5066256920 + "sampled_time_ns": 5007372207 }, { "fixture": "event_batch", "operation": "protobuf_encode", - "median_ns": 79863.94954248366, - "confidence_interval_ns": [78118.58975141887, 81755.60055555555], + "median_ns": 69852.26488095238, + "confidence_interval_ns": [69613.09280331155, 70240.35714285714], + "sample_count": 50, + "sampled_time_ns": 4992936368 + }, + { + "fixture": "event_batch", + "operation": "msgpack_encode", + "median_ns": 139986.91543044266, + "confidence_interval_ns": [139563.791392994, 140438.26666666666], "sample_count": 50, - "sampled_time_ns": 5017193506 + "sampled_time_ns": 5178803415 }, { "fixture": "event_batch", "operation": "tdbin_decode_bare", - "median_ns": 202750.00983436854, - "confidence_interval_ns": [201498.01509872242, 203393.86091269844], + "median_ns": 194820.50873390283, + "confidence_interval_ns": [193145.94513662142, 196017.0559006211], "sample_count": 50, - "sampled_time_ns": 5454116957 + "sampled_time_ns": 5595398164 }, { "fixture": "event_batch", "operation": "tdbin_decode_framed", - "median_ns": 205236.58258928574, - "confidence_interval_ns": [200798.04421768707, 206521.79277244495], + "median_ns": 193618.71053109807, + "confidence_interval_ns": [191110.4300800264, 194724.60869565216], "sample_count": 50, - "sampled_time_ns": 5581533544 + "sampled_time_ns": 5537602877 }, { "fixture": "event_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 227074.67709305946, - "confidence_interval_ns": [224506.687654321, 229686.21978557506], + "median_ns": 194652.43428912782, + "confidence_interval_ns": [194144.90426587302, 195372.6884920635], "sample_count": 50, - "sampled_time_ns": 5164226039 + "sampled_time_ns": 5223632668 }, { "fixture": "event_batch", "operation": "protobuf_decode", - "median_ns": 303783.7773556231, - "confidence_interval_ns": [297643.63354037266, 308790.9420849421], + "median_ns": 275240.9422003284, + "confidence_interval_ns": [273705.10376344086, 275895.8333333333], + "sample_count": 50, + "sampled_time_ns": 5232959459 + }, + { + "fixture": "event_batch", + "operation": "msgpack_decode", + "median_ns": 372343.65, + "confidence_interval_ns": [371662.7594451003, 373583.36363636365], "sample_count": 50, - "sampled_time_ns": 5401235540 + "sampled_time_ns": 5230549243 } ] } diff --git a/docs/reports/tdbin-bench-report.md b/docs/reports/tdbin-bench-report.md index b3c893a..bc57852 100644 --- a/docs/reports/tdbin-bench-report.md +++ b/docs/reports/tdbin-bench-report.md @@ -3,9 +3,9 @@ > GENERATED FILE. Source: `scripts/tdbin-bench-report.mjs` and `docs/reports/tdbin-bench-data.json`. > Every value and verdict is computed from machine-readable Criterion and encoder output. No benchmark result is entered manually. -Generated: 2026-07-11T15:07:08.285Z +Generated: 2026-07-13T23:32:23.773Z -Raw data SHA-256: `052fbaa3e7fd70eadcd3216b89d3f37a3828d933b1d44de79d18798b16e040fa` +Raw data SHA-256: `46c48e4a8ae7f959b364576d3592682bd96f1257dd266fa8810cbb15c18fb388` ## Result @@ -15,6 +15,20 @@ Qualifying modes: `with_address` = none, `without_address` = none, `metric_batch The release gate ([TDBIN-BENCH-GATE]) requires, for every corpus entry — the committed realistic schemas in `docs/benchmarks/tdbin-corpus.{td,proto}` (record-heavy document, union-heavy event stream, list-heavy dataset) — that at least one self-describing production wire mode (framed, or packed framed; the frame's PACKED flag makes the two interchangeable to every decoder) beats Protobuf on size and by 1.50x on both encode and decode simultaneously. Both modes are always measured and published below. Stress rows (marked) are reported against the identical bar; the tiny single-message rows carry a fixed 12-byte frame plus pointer-per-string overhead that no fixed-layout format recovers at sub-100-byte payloads (research §2.2), so they are not corpus entries. +## Size and Speed + +The headline comparison — one row per test, all three self-describing formats side by side. TDBIN is its **framed** production mode; MessagePack is struct-as-map (via `rmp-serde`). Sizes are bytes; serialize is the full ADT→binary conversion, deserialize the full binary→ADT conversion. Lower is better everywhere. + +| Test | typeDiagram Size | Protobuf Size | MessagePack Size | typeDiagram Serialize | Protobuf Serialize | MessagePack Serialize | typeDiagram Deserialize | Protobuf Deserialize | MessagePack Deserialize | +| ------------------ | ---------------: | ------------: | ---------------: | --------------------: | -----------------: | --------------------: | ----------------------: | -------------------: | ----------------------: | +| `with_address` | 172 | 79 | 142 | 86.21 ns | 46.82 ns | 240.50 ns | 157.32 ns | 150.50 ns | 192.39 ns | +| `without_address` | 124 | 31 | 100 | 63.80 ns | 32.86 ns | 227.40 ns | 76.85 ns | 50.06 ns | 108.75 ns | +| `metric_batch` | 43,788 | 84,149 | 90,440 | 6.557 us | 45.616 us | 43.619 us | 5.198 us | 29.664 us | 52.304 us | +| `person_batch` | 22,988 | 29,184 | 61,963 | 9.728 us | 17.986 us | 48.369 us | 35.157 us | 55.844 us | 87.035 us | +| `contact_batch` | 23,156 | 35,221 | 80,162 | 9.169 us | 24.761 us | 67.893 us | 30.351 us | 59.681 us | 92.548 us | +| `diagram_document` | 45,172 | 50,788 | 77,410 | 10.259 us | 21.699 us | 57.093 us | 69.248 us | 109.277 us | 130.848 us | +| `event_batch` | 116,372 | 131,744 | 230,620 | 34.150 us | 69.852 us | 139.987 us | 193.619 us | 275.241 us | 372.344 us | + ## Environment | Field | Value | @@ -23,8 +37,8 @@ The release gate ([TDBIN-BENCH-GATE]) requires, for every corpus entry — the c | CPU | Apple M4 Max | | Logical CPUs | 14 | | Memory | 36.0 GiB | -| Rust | rustc 1.96.0 (ac68faa20 2026-05-25) | -| Cargo | cargo 1.96.0 (30a34c682 2026-05-25) | +| Rust | rustc 1.97.0 (2d8144b78 2026-07-07) | +| Cargo | cargo 1.97.0 (c980f4866 2026-06-30) | Dependency tree: @@ -32,83 +46,101 @@ Dependency tree: tdbin v0.0.0 (/Users/christianfindlay/Documents/Code/typeDiagram/crates/tdbin) [dev-dependencies] ├── criterion v0.8.2 -└── prost v0.14.3 +├── prost v0.14.3 +├── rmp-serde v1.3.1 +└── serde v1.0.228 ``` ## Encoded Size All sizes are bytes. Percentage columns are relative to Protobuf; negative is smaller. -| Fixture | Shape | Role | Items | TDBIN bare | TDBIN framed | TDBIN packed framed | Protobuf | Framed delta | Packed delta | -| ------------------ | ----------------------------- | ------ | ----: | ---------: | -----------: | ------------------: | -------: | -----------: | -----------: | -| `with_address` | tiny nested record and union | stress | 1 | 160 | 172 | 109 | 79 | 117.7% | 38.0% | -| `without_address` | tiny sparse record and union | stress | 1 | 112 | 124 | 54 | 31 | 300.0% | 74.2% | -| `metric_batch` | list-heavy telemetry | corpus | 4,096 | 43,776 | 43,788 | 23,045 | 84,149 | -48.0% | -72.6% | -| `person_batch` | repeated records | stress | 512 | 22,976 | 22,988 | 20,071 | 29,184 | -21.2% | -31.2% | -| `contact_batch` | repeated unions | stress | 2,048 | 23,144 | 23,156 | 22,317 | 35,221 | -34.3% | -36.6% | -| `diagram_document` | record-heavy diagram document | corpus | 768 | 45,160 | 45,172 | 37,868 | 50,788 | -11.1% | -25.4% | -| `event_batch` | union-heavy event stream | corpus | 2,048 | 116,360 | 116,372 | 102,861 | 131,744 | -11.7% | -21.9% | +| Fixture | Shape | Role | Items | TDBIN bare | TDBIN framed | TDBIN packed framed | Protobuf | MessagePack | Framed delta | Packed delta | +| ------------------ | ----------------------------- | ------ | ----: | ---------: | -----------: | ------------------: | -------: | ----------: | -----------: | -----------: | +| `with_address` | tiny nested record and union | stress | 1 | 160 | 172 | 109 | 79 | 142 | 117.7% | 38.0% | +| `without_address` | tiny sparse record and union | stress | 1 | 112 | 124 | 54 | 31 | 100 | 300.0% | 74.2% | +| `metric_batch` | list-heavy telemetry | corpus | 4,096 | 43,776 | 43,788 | 23,045 | 84,149 | 90,440 | -48.0% | -72.6% | +| `person_batch` | repeated records | stress | 512 | 22,976 | 22,988 | 20,071 | 29,184 | 61,963 | -21.2% | -31.2% | +| `contact_batch` | repeated unions | stress | 2,048 | 23,144 | 23,156 | 22,317 | 35,221 | 80,162 | -34.3% | -36.6% | +| `diagram_document` | record-heavy diagram document | corpus | 768 | 45,160 | 45,172 | 37,868 | 50,788 | 77,410 | -11.1% | -25.4% | +| `event_batch` | union-heavy event stream | corpus | 2,048 | 116,360 | 116,372 | 102,861 | 131,744 | 230,620 | -11.7% | -21.9% | ## Criterion Medians +Each row is one **individual** operation — a complete serialize _or_ deserialize, not a round-trip. The operation name encodes the direction (`encode` = ADT→binary, `decode` = binary→ADT) and the wire mode (`bare`, `framed`, or `packed_framed` for TDBIN). "Median" is the per-call time (what to compare); "Sampled time" is only Criterion's total measurement budget for that row. Sum a fixture's `encode` and `decode` rows to get the round-trip totals above. + | Fixture | Operation | Samples | Sampled time | Median | CI lower | CI upper | | ------------------ | ---------------------------- | ------: | -----------: | ---------: | ---------: | ---------: | -| `with_address` | `tdbin_encode_bare` | 50 | 5010.966 ms | 83.10 ns | 82.76 ns | 83.38 ns | -| `with_address` | `tdbin_encode_framed` | 50 | 6167.315 ms | 109.36 ns | 101.10 ns | 111.63 ns | -| `with_address` | `tdbin_encode_packed_framed` | 50 | 4688.623 ms | 198.64 ns | 196.80 ns | 207.64 ns | -| `with_address` | `protobuf_encode` | 50 | 4853.818 ms | 69.41 ns | 64.09 ns | 73.29 ns | -| `with_address` | `tdbin_decode_bare` | 50 | 5247.388 ms | 154.30 ns | 151.28 ns | 157.10 ns | -| `with_address` | `tdbin_decode_framed` | 50 | 7711.428 ms | 939.85 ns | 908.54 ns | 966.47 ns | -| `with_address` | `tdbin_decode_packed_framed` | 50 | 5589.698 ms | 3.606 us | 3.548 us | 3.653 us | -| `with_address` | `protobuf_decode` | 50 | 5038.537 ms | 921.27 ns | 913.13 ns | 939.44 ns | -| `without_address` | `tdbin_encode_bare` | 50 | 4902.567 ms | 338.24 ns | 335.95 ns | 341.98 ns | -| `without_address` | `tdbin_encode_framed` | 50 | 5493.679 ms | 400.06 ns | 396.70 ns | 402.96 ns | -| `without_address` | `tdbin_encode_packed_framed` | 50 | 2607.578 ms | 764.94 ns | 754.53 ns | 777.86 ns | -| `without_address` | `protobuf_encode` | 50 | 5172.749 ms | 35.64 ns | 35.35 ns | 36.04 ns | -| `without_address` | `tdbin_decode_bare` | 50 | 5770.456 ms | 73.64 ns | 73.29 ns | 74.39 ns | -| `without_address` | `tdbin_decode_framed` | 50 | 5716.750 ms | 76.63 ns | 76.28 ns | 76.97 ns | -| `without_address` | `tdbin_decode_packed_framed` | 50 | 5451.651 ms | 542.18 ns | 537.79 ns | 545.67 ns | -| `without_address` | `protobuf_decode` | 50 | 5653.604 ms | 53.27 ns | 52.77 ns | 54.62 ns | -| `metric_batch` | `tdbin_encode_bare` | 50 | 3765.785 ms | 12.305 us | 12.034 us | 12.987 us | -| `metric_batch` | `tdbin_encode_framed` | 50 | 4256.508 ms | 14.974 us | 14.363 us | 15.266 us | -| `metric_batch` | `tdbin_encode_packed_framed` | 50 | 4639.951 ms | 27.741 us | 24.611 us | 29.641 us | -| `metric_batch` | `protobuf_encode` | 50 | 5412.159 ms | 50.696 us | 45.813 us | 56.103 us | -| `metric_batch` | `tdbin_decode_bare` | 50 | 3110.157 ms | 7.712 us | 7.535 us | 7.846 us | -| `metric_batch` | `tdbin_decode_framed` | 50 | 4824.275 ms | 7.951 us | 7.798 us | 8.154 us | -| `metric_batch` | `tdbin_decode_packed_framed` | 50 | 5710.549 ms | 37.963 us | 37.783 us | 38.311 us | -| `metric_batch` | `protobuf_decode` | 50 | 4988.323 ms | 35.867 us | 35.316 us | 36.267 us | -| `person_batch` | `tdbin_encode_bare` | 50 | 5002.386 ms | 11.811 us | 11.728 us | 11.857 us | -| `person_batch` | `tdbin_encode_framed` | 50 | 4998.343 ms | 11.858 us | 11.794 us | 11.876 us | -| `person_batch` | `tdbin_encode_packed_framed` | 50 | 5124.613 ms | 15.541 us | 15.265 us | 15.892 us | -| `person_batch` | `protobuf_encode` | 50 | 5069.843 ms | 18.247 us | 18.086 us | 18.350 us | -| `person_batch` | `tdbin_decode_bare` | 50 | 5009.308 ms | 36.734 us | 36.447 us | 37.009 us | -| `person_batch` | `tdbin_decode_framed` | 50 | 4937.394 ms | 37.818 us | 37.126 us | 38.523 us | -| `person_batch` | `tdbin_decode_packed_framed` | 50 | 5122.981 ms | 41.743 us | 41.256 us | 42.369 us | -| `person_batch` | `protobuf_decode` | 50 | 5044.089 ms | 57.691 us | 57.264 us | 58.092 us | -| `contact_batch` | `tdbin_encode_bare` | 50 | 5017.695 ms | 9.627 us | 9.605 us | 9.661 us | -| `contact_batch` | `tdbin_encode_framed` | 50 | 5011.655 ms | 9.653 us | 9.611 us | 9.720 us | -| `contact_batch` | `tdbin_encode_packed_framed` | 50 | 5076.228 ms | 12.925 us | 12.866 us | 13.029 us | -| `contact_batch` | `protobuf_encode` | 50 | 5413.163 ms | 28.365 us | 28.009 us | 28.741 us | -| `contact_batch` | `tdbin_decode_bare` | 50 | 5239.417 ms | 36.764 us | 36.436 us | 38.005 us | -| `contact_batch` | `tdbin_decode_framed` | 50 | 4709.271 ms | 43.522 us | 42.991 us | 45.359 us | -| `contact_batch` | `tdbin_decode_packed_framed` | 50 | 4278.261 ms | 40.590 us | 38.988 us | 41.946 us | -| `contact_batch` | `protobuf_decode` | 50 | 4708.885 ms | 72.148 us | 69.176 us | 74.153 us | -| `diagram_document` | `tdbin_encode_bare` | 50 | 3973.094 ms | 16.468 us | 15.268 us | 17.475 us | -| `diagram_document` | `tdbin_encode_framed` | 50 | 5476.900 ms | 13.958 us | 13.514 us | 15.377 us | -| `diagram_document` | `tdbin_encode_packed_framed` | 50 | 5682.131 ms | 24.274 us | 23.245 us | 24.469 us | -| `diagram_document` | `protobuf_encode` | 50 | 2832.265 ms | 23.293 us | 21.949 us | 24.438 us | -| `diagram_document` | `tdbin_decode_bare` | 50 | 5183.135 ms | 74.798 us | 74.476 us | 75.054 us | -| `diagram_document` | `tdbin_decode_framed` | 50 | 5024.830 ms | 75.939 us | 75.539 us | 76.334 us | -| `diagram_document` | `tdbin_decode_packed_framed` | 50 | 5079.076 ms | 84.772 us | 84.152 us | 85.929 us | -| `diagram_document` | `protobuf_decode` | 50 | 5248.375 ms | 120.168 us | 119.516 us | 120.872 us | -| `event_batch` | `tdbin_encode_bare` | 50 | 5159.726 ms | 40.854 us | 40.522 us | 41.924 us | -| `event_batch` | `tdbin_encode_framed` | 50 | 5226.987 ms | 39.914 us | 39.643 us | 40.060 us | -| `event_batch` | `tdbin_encode_packed_framed` | 50 | 5066.257 ms | 55.971 us | 55.782 us | 56.181 us | -| `event_batch` | `protobuf_encode` | 50 | 5017.194 ms | 79.864 us | 78.119 us | 81.756 us | -| `event_batch` | `tdbin_decode_bare` | 50 | 5454.117 ms | 202.750 us | 201.498 us | 203.394 us | -| `event_batch` | `tdbin_decode_framed` | 50 | 5581.534 ms | 205.237 us | 200.798 us | 206.522 us | -| `event_batch` | `tdbin_decode_packed_framed` | 50 | 5164.226 ms | 227.075 us | 224.507 us | 229.686 us | -| `event_batch` | `protobuf_decode` | 50 | 5401.236 ms | 303.784 us | 297.644 us | 308.791 us | +| `with_address` | `tdbin_encode_bare` | 50 | 4931.599 ms | 80.66 ns | 80.28 ns | 81.26 ns | +| `with_address` | `tdbin_encode_framed` | 50 | 5039.075 ms | 86.21 ns | 85.87 ns | 86.99 ns | +| `with_address` | `tdbin_encode_packed_framed` | 50 | 4964.208 ms | 175.19 ns | 173.62 ns | 176.25 ns | +| `with_address` | `protobuf_encode` | 50 | 4994.310 ms | 46.82 ns | 46.59 ns | 47.06 ns | +| `with_address` | `msgpack_encode` | 50 | 4989.358 ms | 240.50 ns | 240.02 ns | 241.71 ns | +| `with_address` | `tdbin_decode_bare` | 50 | 5030.072 ms | 152.30 ns | 151.45 ns | 152.91 ns | +| `with_address` | `tdbin_decode_framed` | 50 | 5089.968 ms | 157.32 ns | 155.85 ns | 161.80 ns | +| `with_address` | `tdbin_decode_packed_framed` | 50 | 5106.051 ms | 587.57 ns | 580.04 ns | 594.54 ns | +| `with_address` | `protobuf_decode` | 50 | 5005.128 ms | 150.50 ns | 149.91 ns | 151.64 ns | +| `with_address` | `msgpack_decode` | 50 | 4969.319 ms | 192.39 ns | 191.47 ns | 193.25 ns | +| `without_address` | `tdbin_encode_bare` | 50 | 4945.151 ms | 59.78 ns | 59.39 ns | 60.18 ns | +| `without_address` | `tdbin_encode_framed` | 50 | 4943.136 ms | 63.80 ns | 63.37 ns | 64.16 ns | +| `without_address` | `tdbin_encode_packed_framed` | 50 | 4916.982 ms | 136.47 ns | 135.27 ns | 136.99 ns | +| `without_address` | `protobuf_encode` | 50 | 4996.045 ms | 32.86 ns | 32.73 ns | 32.98 ns | +| `without_address` | `msgpack_encode` | 50 | 5037.189 ms | 227.40 ns | 226.84 ns | 228.35 ns | +| `without_address` | `tdbin_decode_bare` | 50 | 4996.015 ms | 67.72 ns | 67.22 ns | 67.95 ns | +| `without_address` | `tdbin_decode_framed` | 50 | 5384.606 ms | 76.85 ns | 73.48 ns | 77.69 ns | +| `without_address` | `tdbin_decode_packed_framed` | 50 | 5152.076 ms | 500.57 ns | 489.13 ns | 507.78 ns | +| `without_address` | `protobuf_decode` | 50 | 4993.446 ms | 50.06 ns | 49.74 ns | 50.22 ns | +| `without_address` | `msgpack_decode` | 50 | 4989.619 ms | 108.75 ns | 107.47 ns | 109.73 ns | +| `metric_batch` | `tdbin_encode_bare` | 50 | 5385.510 ms | 7.263 us | 6.474 us | 7.471 us | +| `metric_batch` | `tdbin_encode_framed` | 50 | 5386.488 ms | 6.557 us | 6.336 us | 6.871 us | +| `metric_batch` | `tdbin_encode_packed_framed` | 50 | 4909.781 ms | 20.228 us | 19.324 us | 20.610 us | +| `metric_batch` | `protobuf_encode` | 50 | 5047.081 ms | 45.616 us | 44.868 us | 45.992 us | +| `metric_batch` | `msgpack_encode` | 50 | 5067.377 ms | 43.619 us | 43.455 us | 43.703 us | +| `metric_batch` | `tdbin_decode_bare` | 50 | 4970.291 ms | 5.223 us | 5.216 us | 5.239 us | +| `metric_batch` | `tdbin_decode_framed` | 50 | 4964.630 ms | 5.198 us | 5.182 us | 5.208 us | +| `metric_batch` | `tdbin_decode_packed_framed` | 50 | 4979.641 ms | 28.041 us | 27.999 us | 28.085 us | +| `metric_batch` | `protobuf_decode` | 50 | 4924.024 ms | 29.664 us | 29.392 us | 29.919 us | +| `metric_batch` | `msgpack_decode` | 50 | 5165.280 ms | 52.304 us | 51.517 us | 53.082 us | +| `person_batch` | `tdbin_encode_bare` | 50 | 5078.212 ms | 9.837 us | 9.786 us | 9.880 us | +| `person_batch` | `tdbin_encode_framed` | 50 | 5005.247 ms | 9.728 us | 9.711 us | 9.767 us | +| `person_batch` | `tdbin_encode_packed_framed` | 50 | 5019.039 ms | 13.545 us | 13.457 us | 13.572 us | +| `person_batch` | `protobuf_encode` | 50 | 5005.870 ms | 17.986 us | 17.813 us | 18.162 us | +| `person_batch` | `msgpack_encode` | 50 | 5005.912 ms | 48.369 us | 48.263 us | 48.658 us | +| `person_batch` | `tdbin_decode_bare` | 50 | 5062.145 ms | 35.364 us | 35.191 us | 35.485 us | +| `person_batch` | `tdbin_decode_framed` | 50 | 5005.412 ms | 35.157 us | 35.063 us | 35.252 us | +| `person_batch` | `tdbin_decode_packed_framed` | 50 | 5006.201 ms | 39.554 us | 39.372 us | 39.679 us | +| `person_batch` | `protobuf_decode` | 50 | 5059.321 ms | 55.844 us | 55.678 us | 56.069 us | +| `person_batch` | `msgpack_decode` | 50 | 5000.367 ms | 87.035 us | 86.460 us | 87.671 us | +| `contact_batch` | `tdbin_encode_bare` | 50 | 4972.711 ms | 9.182 us | 9.147 us | 9.262 us | +| `contact_batch` | `tdbin_encode_framed` | 50 | 4990.231 ms | 9.169 us | 9.130 us | 9.217 us | +| `contact_batch` | `tdbin_encode_packed_framed` | 50 | 4967.011 ms | 11.957 us | 11.902 us | 12.001 us | +| `contact_batch` | `protobuf_encode` | 50 | 5012.321 ms | 24.761 us | 24.620 us | 25.031 us | +| `contact_batch` | `msgpack_encode` | 50 | 5017.045 ms | 67.893 us | 67.626 us | 68.133 us | +| `contact_batch` | `tdbin_decode_bare` | 50 | 5001.397 ms | 30.451 us | 30.256 us | 30.601 us | +| `contact_batch` | `tdbin_decode_framed` | 50 | 4993.096 ms | 30.351 us | 30.203 us | 30.509 us | +| `contact_batch` | `tdbin_decode_packed_framed` | 50 | 4995.879 ms | 32.194 us | 32.060 us | 32.293 us | +| `contact_batch` | `protobuf_decode` | 50 | 5013.239 ms | 59.681 us | 59.468 us | 59.851 us | +| `contact_batch` | `msgpack_decode` | 50 | 5098.326 ms | 92.548 us | 92.048 us | 93.058 us | +| `diagram_document` | `tdbin_encode_bare` | 50 | 4987.085 ms | 10.234 us | 10.186 us | 10.303 us | +| `diagram_document` | `tdbin_encode_framed` | 50 | 5037.163 ms | 10.259 us | 10.223 us | 10.348 us | +| `diagram_document` | `tdbin_encode_packed_framed` | 50 | 5009.037 ms | 17.134 us | 17.066 us | 17.208 us | +| `diagram_document` | `protobuf_encode` | 50 | 5082.265 ms | 21.699 us | 21.502 us | 22.149 us | +| `diagram_document` | `msgpack_encode` | 50 | 4993.633 ms | 57.093 us | 56.545 us | 57.766 us | +| `diagram_document` | `tdbin_decode_bare` | 50 | 5130.113 ms | 70.279 us | 69.859 us | 70.710 us | +| `diagram_document` | `tdbin_decode_framed` | 50 | 5066.610 ms | 69.248 us | 69.011 us | 69.658 us | +| `diagram_document` | `tdbin_decode_packed_framed` | 50 | 5040.853 ms | 78.910 us | 78.557 us | 79.335 us | +| `diagram_document` | `protobuf_decode` | 50 | 5019.651 ms | 109.277 us | 108.927 us | 109.705 us | +| `diagram_document` | `msgpack_decode` | 50 | 4999.292 ms | 130.848 us | 130.323 us | 131.115 us | +| `event_batch` | `tdbin_encode_bare` | 50 | 5030.536 ms | 33.937 us | 33.889 us | 34.062 us | +| `event_batch` | `tdbin_encode_framed` | 50 | 5031.903 ms | 34.150 us | 34.000 us | 34.334 us | +| `event_batch` | `tdbin_encode_packed_framed` | 50 | 5007.372 ms | 50.872 us | 50.691 us | 50.990 us | +| `event_batch` | `protobuf_encode` | 50 | 4992.936 ms | 69.852 us | 69.613 us | 70.240 us | +| `event_batch` | `msgpack_encode` | 50 | 5178.803 ms | 139.987 us | 139.564 us | 140.438 us | +| `event_batch` | `tdbin_decode_bare` | 50 | 5595.398 ms | 194.821 us | 193.146 us | 196.017 us | +| `event_batch` | `tdbin_decode_framed` | 50 | 5537.603 ms | 193.619 us | 191.110 us | 194.725 us | +| `event_batch` | `tdbin_decode_packed_framed` | 50 | 5223.633 ms | 194.652 us | 194.145 us | 195.373 us | +| `event_batch` | `protobuf_decode` | 50 | 5232.959 ms | 275.241 us | 273.705 us | 275.896 us | +| `event_batch` | `msgpack_decode` | 50 | 5230.549 ms | 372.344 us | 371.663 us | 373.583 us | ## Same-Mode Comparison @@ -116,29 +148,29 @@ Ratios are Protobuf median / TDBIN median; values above 1.00x favor TDBIN. The g | Fixture | TDBIN mode | Size winner | Encode ratio | Decode ratio | Gate | | ------------------ | ------------- | ----------- | -----------: | -----------: | ---- | -| `with_address` | bare | Protobuf | 0.84x | 5.97x | FAIL | -| `with_address` | framed | Protobuf | 0.63x | 0.98x | FAIL | -| `with_address` | packed framed | Protobuf | 0.35x | 0.26x | FAIL | -| `without_address` | bare | Protobuf | 0.11x | 0.72x | FAIL | -| `without_address` | framed | Protobuf | 0.09x | 0.70x | FAIL | -| `without_address` | packed framed | Protobuf | 0.05x | 0.10x | FAIL | -| `metric_batch` | bare | TDBIN | 4.12x | 4.65x | PASS | -| `metric_batch` | framed | TDBIN | 3.39x | 4.51x | PASS | -| `metric_batch` | packed framed | TDBIN | 1.83x | 0.94x | FAIL | -| `person_batch` | bare | TDBIN | 1.54x | 1.57x | PASS | -| `person_batch` | framed | TDBIN | 1.54x | 1.53x | PASS | -| `person_batch` | packed framed | TDBIN | 1.17x | 1.38x | FAIL | -| `contact_batch` | bare | TDBIN | 2.95x | 1.96x | PASS | -| `contact_batch` | framed | TDBIN | 2.94x | 1.66x | PASS | -| `contact_batch` | packed framed | TDBIN | 2.19x | 1.78x | PASS | -| `diagram_document` | bare | TDBIN | 1.41x | 1.61x | FAIL | -| `diagram_document` | framed | TDBIN | 1.67x | 1.58x | PASS | -| `diagram_document` | packed framed | TDBIN | 0.96x | 1.42x | FAIL | -| `event_batch` | bare | TDBIN | 1.95x | 1.50x | FAIL | -| `event_batch` | framed | TDBIN | 2.00x | 1.48x | FAIL | -| `event_batch` | packed framed | TDBIN | 1.43x | 1.34x | FAIL | - -Passing fixture/mode combinations: 8 of 21. +| `with_address` | bare | Protobuf | 0.58x | 0.99x | FAIL | +| `with_address` | framed | Protobuf | 0.54x | 0.96x | FAIL | +| `with_address` | packed framed | Protobuf | 0.27x | 0.26x | FAIL | +| `without_address` | bare | Protobuf | 0.55x | 0.74x | FAIL | +| `without_address` | framed | Protobuf | 0.52x | 0.65x | FAIL | +| `without_address` | packed framed | Protobuf | 0.24x | 0.10x | FAIL | +| `metric_batch` | bare | TDBIN | 6.28x | 5.68x | PASS | +| `metric_batch` | framed | TDBIN | 6.96x | 5.71x | PASS | +| `metric_batch` | packed framed | TDBIN | 2.26x | 1.06x | FAIL | +| `person_batch` | bare | TDBIN | 1.83x | 1.58x | PASS | +| `person_batch` | framed | TDBIN | 1.85x | 1.59x | PASS | +| `person_batch` | packed framed | TDBIN | 1.33x | 1.41x | FAIL | +| `contact_batch` | bare | TDBIN | 2.70x | 1.96x | PASS | +| `contact_batch` | framed | TDBIN | 2.70x | 1.97x | PASS | +| `contact_batch` | packed framed | TDBIN | 2.07x | 1.85x | PASS | +| `diagram_document` | bare | TDBIN | 2.12x | 1.55x | PASS | +| `diagram_document` | framed | TDBIN | 2.12x | 1.58x | PASS | +| `diagram_document` | packed framed | TDBIN | 1.27x | 1.38x | FAIL | +| `event_batch` | bare | TDBIN | 2.06x | 1.41x | FAIL | +| `event_batch` | framed | TDBIN | 2.05x | 1.42x | FAIL | +| `event_batch` | packed framed | TDBIN | 1.37x | 1.41x | FAIL | + +Passing fixture/mode combinations: 9 of 21. This secondary table exposes unpacked tradeoffs; it does not replace the packed-framed specification gate above. diff --git a/docs/shared/intro.md b/docs/shared/intro.md index e6dfcdc..cc8f824 100644 --- a/docs/shared/intro.md +++ b/docs/shared/intro.md @@ -3,10 +3,10 @@ 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, C#, F#, Dart, PHP, and Protobuf — 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. +- **A visual type editor** with direct field editing, draggable nodes, relationship drawing, pan, zoom, auto-layout, and SVG export — backed by source you can version in git. - **Round-trip conversion** from existing TypeScript/Python/Rust/Go/C#/F#/Dart/PHP/Protobuf 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. +typeDiagram is a **shared schema for your data model with a first-class visual canvas**. The editor and source are two views of the same typed document, so every visual change stays ready for code generation in as many languages as you need. ### Why this matters diff --git a/docs/specs/api.md b/docs/specs/api.md index 912d047..01b621f 100644 --- a/docs/specs/api.md +++ b/docs/specs/api.md @@ -173,6 +173,7 @@ import { converters } from "typediagram-core"; // Parse other languages const model = converters.typescript.fromSource(tsCode); const model = converters.python.fromSource(pyCode); +const model = converters.typeshed.fromSource(pyiCode); const model = converters.rust.fromSource(rsCode); const model = converters.go.fromSource(goCode); const model = converters.csharp.fromSource(csCode); @@ -184,6 +185,7 @@ const model = converters.protobuf.fromSource(protoCode); // Emit other languages const tsCode = converters.typescript.toSource(model); const pyCode = converters.python.toSource(model); +const pyiCode = converters.typeshed.toSource(model); const rsCode = converters.rust.toSource(model); const goCode = converters.go.toSource(model); const csCode = converters.csharp.toSource(model); diff --git a/docs/specs/cli.md b/docs/specs/cli.md index d7f3db1..7ba30b2 100644 --- a/docs/specs/cli.md +++ b/docs/specs/cli.md @@ -14,22 +14,47 @@ npx typediagram schema.td > diagram.svg ``` typediagram [options] [file] +typediagram --config FILE [--watch] ``` If `file` is omitted, reads from stdin. Output goes to stdout. Errors go to stderr with exit code 1. ## Options -| Flag | Value | Description | -| -------------- | ------------------------------------------------------------------- | ------------------------------------------- | -| `--from` | `typescript\|python\|rust\|go\|csharp\|fsharp\|dart\|protobuf\|php` | Convert from language source to SVG | -| `--to` | `typescript\|python\|rust\|go\|csharp\|fsharp\|dart\|protobuf\|php` | Convert from typeDiagram to language source | -| `--theme` | `light\|dark` | Color theme (default: `light`) | -| `--font-size` | number | Font size in pixels | -| `-h`, `--help` | | Show help | +| Flag | Value | Description | +| -------------- | ----------------------------------------------------------------------------- | ------------------------------------------------- | +| `--config` | JSON file | Generate every configured language output | +| `--watch` | | Regenerate configured outputs after `.td` changes | +| `--from` | `typeshed\|typescript\|python\|rust\|go\|csharp\|fsharp\|dart\|protobuf\|php` | Convert from language source to SVG | +| `--to` | `typescript\|python\|rust\|go\|csharp\|fsharp\|dart\|protobuf\|php` | Convert from typeDiagram to language source | +| `--theme` | `light\|dark` | Color theme (default: `light`) | +| `--font-size` | number | Font size in pixels | +| `-h`, `--help` | | Show help | `--from` and `--to` are mutually exclusive. +## Configured generation and watch mode + +Paths are relative to the config file. Each `outputs` key selects one converter from the supported language registry: + +```json +{ + "source": "schemas/person.td", + "watch": true, + "outputs": { + "typescript": "frontend/src/generated/person.ts", + "rust": "backend/src/generated/person.rs", + "python": "services/generated/person.py" + } +} +``` + +```sh +typediagram --config typediagram.json +``` + +`"watch": true` performs initial generation and then watches the source. `--watch` enables the same behavior from the command line when the config omits it. Each valid save regenerates every selected language. Invalid source reports diagnostics without overwriting the last good generated files; the watcher recovers automatically after the next valid save. + ## Examples ### Render typeDiagram to SVG @@ -57,6 +82,12 @@ typediagram --from typescript types.ts > diagram.svg # Python dataclasses → SVG typediagram --from python models.py > diagram.svg +# One Typeshed stub → typeDiagram source +typediagram --from typeshed --emit td stdlib/dataclasses.pyi > dataclasses.td + +# Complete Typeshed checkout → mirrored .td tree +typediagram-typeshed /path/to/typeshed /path/to/generated-typeshed + # Rust structs/enums → SVG typediagram --from rust types.rs > diagram.svg diff --git a/docs/specs/converters.md b/docs/specs/converters.md index b00e56d..26da450 100644 --- a/docs/specs/converters.md +++ b/docs/specs/converters.md @@ -1,6 +1,6 @@ # Language Converters -typeDiagram includes bidirectional converters for **TypeScript**, **Python**, **Rust**, **Go**, **C#**, **F#**, **Dart**, **PHP**, and **Protobuf**. Each converter can parse existing type definitions into a typeDiagram model, and emit type definitions from a model. All nine converters losslessly round-trip the canonical home-page sample (`TD → lang → TD` is byte-for-byte identical). +typeDiagram includes converters for **Typeshed**, **TypeScript**, **Python**, **Rust**, **Go**, **C#**, **F#**, **Dart**, **PHP**, and **Protobuf**. The nine data-language converters losslessly round-trip the canonical home-page sample; Typeshed additionally imports module functions and overloads. See [Typeshed to typeDiagram](typeshed-conversion.md). ## How it works @@ -81,6 +81,10 @@ Emits `export interface` for records, discriminated unions with a `kind` field f Emits `@dataclass` for records, `str, Enum` for unions with no payloads, separate dataclass per variant + type alias for unions with payloads. +## Typeshed + +The dedicated `typeshed` converter parses `.pyi` syntax with a Python concrete syntax tree. It imports ordinary classes, protocols, dataclasses, TypedDicts, NamedTuples, enums, aliases, top-level functions, async functions, conditional declarations, and overloads. Class methods are intentionally excluded. Use `typediagram-typeshed` to mirror a complete checkout into `.td` files. + ## Rust ### What maps @@ -214,6 +218,7 @@ if (result.ok) { // Available converters converters.typescript; converters.python; +converters.typeshed; converters.rust; converters.go; converters.csharp; diff --git a/docs/specs/getting-started.md b/docs/specs/getting-started.md index 40065e0..b629883 100644 --- a/docs/specs/getting-started.md +++ b/docs/specs/getting-started.md @@ -29,8 +29,9 @@ echo 'type User { name: String }' | typediagram > diagram.svg # From a file typediagram schema.td > diagram.svg -# From existing TypeScript/Python/Rust/Go/C#/F#/Dart/PHP/Protobuf code +# From existing Typeshed/TypeScript/Python/Rust/Go/C#/F#/Dart/PHP/Protobuf code typediagram --from typescript types.ts > diagram.svg +typediagram --from typeshed --emit td module.pyi > module.td ``` ## Your first diagram @@ -70,7 +71,7 @@ You'll see three connected boxes: `User` links to `Option` (via the `email` f | **Web playground** | Browser-based editor with syntax highlighting, live preview, pan/zoom, and a Hooks tab with live presets | | **CLI** | `typediagram` binary — pipe source in, SVG out | | **VS Code extension** | Live `.td` preview, markdown preview rendering, **Export to PDF** with vector diagrams | -| **Converters** | Bidirectional: TypeScript, Python, Rust, Go, C#, F#, Dart, PHP, Protobuf ↔ typeDiagram | +| **Converters** | Typeshed, TypeScript, Python, Rust, Go, C#, F#, Dart, PHP, Protobuf ↔ typeDiagram | | **Node.js API** | `renderToString()`, `parse()`, converter APIs, **render hooks** for SVG customisation | ## Markdown → PDF (VS Code) diff --git a/docs/specs/language-reference.md b/docs/specs/language-reference.md index 5700abb..324cd61 100644 --- a/docs/specs/language-reference.md +++ b/docs/specs/language-reference.md @@ -1,6 +1,6 @@ # Language Reference -typeDiagram has three constructs: **type** (records), **union** (tagged sum types), and **alias** (newtypes). That's it. +typeDiagram has four constructs: **type** (records), **union** (tagged sum types), **alias** (newtypes), and **function** (free-function/service signatures). It has no class methods. ## Records (`type`) @@ -96,6 +96,19 @@ alias UserId = Uuid alias Callback = Option ``` +## Functions (`function`) + +Functions carry typed parameters, return types, optional generics, async state, and overloads. See [Typeshed to typeDiagram](typeshed-conversion.md) for the full import pipeline. + +```typediagram +function fetch(request: Request, fallback: Option) -> Response + +function read { + (path: String) -> Bytes + async (path: String, timeout: Float) -> Bytes +} +``` + ## Built-in types These primitive types are always available (no declaration needed): @@ -171,10 +184,14 @@ References to undeclared types (like `CountryCode` or any name not declared in t ``` Diagram = ("typeDiagram")? Declaration* -Declaration = Record | Union | Alias +Declaration = Record | Union | Alias | Function Record = "type" Name Generics? "{" Field* "}" Union = "union" Name Generics? "{" Variant* "}" Alias = "alias" Name Generics? "=" TypeRef +Function = ("async")? "function" Name Generics? Signature + | "function" Name Generics? "{" Signature* "}" +Signature = ("async")? "(" Parameter* ")" "->" TypeRef +Parameter = Name ":" TypeRef Field = Name ":" TypeRef Variant = Name ("=" Number)? ("{" Field* "}")? TypeRef = Name ("<" TypeRef ("," TypeRef)* ">")? diff --git a/docs/specs/multi-language-pipeline.md b/docs/specs/multi-language-pipeline.md index 4cd70b2..fd4dcac 100644 --- a/docs/specs/multi-language-pipeline.md +++ b/docs/specs/multi-language-pipeline.md @@ -2,6 +2,25 @@ Configure typeDiagram as the single source of truth for DTOs in a polyglot project. This guide uses a TypeScript frontend + Rust backend, but the pattern works for any combination of languages. +## Configure generation once + +Create `typediagram.json` at the repository root. Paths are resolved relative to this file: + +```json +{ + "source": "schemas/user.td", + "watch": true, + "outputs": { + "typescript": "frontend/src/generated/user.ts", + "rust": "backend/src/generated/user.rs" + } +} +``` + +Run `typediagram --config typediagram.json`. It generates both outputs immediately and then watches `schemas/user.td`. Every valid edit regenerates both files. Invalid edits report diagnostics while preserving the last good generated code, and the next valid edit recovers without restarting the watcher. + +For one-shot CI generation, omit `"watch": true`. For an ad hoc development watcher, use `typediagram --config typediagram.json --watch`. + ## Project layout ``` diff --git a/docs/specs/tdbin-benchmarks.md b/docs/specs/tdbin-benchmarks.md new file mode 100644 index 0000000..9938c68 --- /dev/null +++ b/docs/specs/tdbin-benchmarks.md @@ -0,0 +1,58 @@ +# TDBIN Benchmarks + +TDBIN is typeDiagram's compact binary codec for algebraic data types, measured here against **Protocol Buffers** and **MessagePack**. Every number below is data-derived — produced by [`scripts/tdbin-bench-report.mjs`](https://github.com/Nimblesite/typeDiagram/blob/main/scripts/tdbin-bench-report.mjs) from Criterion timings and exact encoder output — and regenerates on each benchmark run. + +> **Scope: these figures are for the Rust implementation only.** Both the encoded sizes and the speeds are measured against the Rust `tdbin` codec crate ([`crates/tdbin`](https://github.com/Nimblesite/typeDiagram/blob/main/crates/tdbin)), the Rust `prost` Protobuf encoder, and the Rust `rmp-serde` MessagePack encoder. The TDBIN **wire format** and its byte sizes are language-neutral, but serialize/deserialize **speeds** depend on each language's implementation — typeDiagram's other codec targets, and other Protobuf/MessagePack libraries, will differ, sometimes substantially. + +## Size and Speed + +One row per test. Sizes are exact encoded bytes; "Serialize" is the whole ADT→binary conversion and "Deserialize" the whole binary→ADT conversion, each a Criterion median. typeDiagram is its **framed** production wire mode; MessagePack is struct-as-map (via `rmp-serde`). Lower is better in every column. + +| Test | typeDiagram Size | Protobuf Size | MessagePack Size | typeDiagram Serialize | Protobuf Serialize | MessagePack Serialize | typeDiagram Deserialize | Protobuf Deserialize | MessagePack Deserialize | +| ------------------ | ---------------: | ------------: | ---------------: | --------------------: | -----------------: | --------------------: | ----------------------: | -------------------: | ----------------------: | +| `with_address` | 172 | 79 | 142 | 86.21 ns | 46.82 ns | 240.50 ns | 157.32 ns | 150.50 ns | 192.39 ns | +| `without_address` | 124 | 31 | 100 | 63.80 ns | 32.86 ns | 227.40 ns | 76.85 ns | 50.06 ns | 108.75 ns | +| `metric_batch` | 43,788 | 84,149 | 90,440 | 6.557 us | 45.616 us | 43.619 us | 5.198 us | 29.664 us | 52.304 us | +| `person_batch` | 22,988 | 29,184 | 61,963 | 9.728 us | 17.986 us | 48.369 us | 35.157 us | 55.844 us | 87.035 us | +| `contact_batch` | 23,156 | 35,221 | 80,162 | 9.169 us | 24.761 us | 67.893 us | 30.351 us | 59.681 us | 92.548 us | +| `diagram_document` | 45,172 | 50,788 | 77,410 | 10.259 us | 21.699 us | 57.093 us | 69.248 us | 109.277 us | 130.848 us | +| `event_batch` | 116,372 | 131,744 | 230,620 | 34.150 us | 69.852 us | 139.987 us | 193.619 us | 275.241 us | 372.344 us | + +## What the numbers show + +The following are read directly off the table above — each is a comparison of the measured values, nothing more: + +- **`with_address`** (tiny nested record and union, 1 item): smallest encoding is **Protobuf**; fastest round-trip is **Protobuf**. typeDiagram's encoding is 0.5× the size of Protobuf and 0.8× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). +- **`without_address`** (tiny sparse record and union, 1 item): smallest encoding is **Protobuf**; fastest round-trip is **Protobuf**. typeDiagram's encoding is 0.3× the size of Protobuf and 0.8× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). +- **`metric_batch`** (list-heavy telemetry, 4,096 items): smallest encoding is **typeDiagram**; fastest round-trip is **typeDiagram**. typeDiagram's encoding is 1.9× the size of Protobuf and 2.1× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). +- **`person_batch`** (repeated records, 512 items): smallest encoding is **typeDiagram**; fastest round-trip is **typeDiagram**. typeDiagram's encoding is 1.3× the size of Protobuf and 2.7× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). +- **`contact_batch`** (repeated unions, 2,048 items): smallest encoding is **typeDiagram**; fastest round-trip is **typeDiagram**. typeDiagram's encoding is 1.5× the size of Protobuf and 3.5× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). +- **`diagram_document`** (record-heavy diagram document, 768 items): smallest encoding is **typeDiagram**; fastest round-trip is **typeDiagram**. typeDiagram's encoding is 1.1× the size of Protobuf and 1.7× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). +- **`event_batch`** (union-heavy event stream, 2,048 items): smallest encoding is **typeDiagram**; fastest round-trip is **typeDiagram**. typeDiagram's encoding is 1.1× the size of Protobuf and 2× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). + +## Methodology + +- **Rust implementations.** Every timing is for a Rust codec: typeDiagram's [`tdbin`](https://github.com/Nimblesite/typeDiagram/blob/main/crates/tdbin) crate, Protobuf via `prost`, and MessagePack via `rmp-serde`. Encoded sizes are a property of the wire format and hold across languages; speeds do not — typeDiagram's other language targets and other Protobuf/MessagePack libraries will produce different timings. +- **Same values, three encoders.** Every test builds one logical value and feeds the identical value to the typeDiagram codec, to a hand-written Protobuf mirror (`prost`), and — via `serde` derives on that same mirror — to MessagePack (`rmp-serde`). No format receives a different input. See the fixtures in [`crates/tdbin/tests/support/bench_corpus.rs`](https://github.com/Nimblesite/typeDiagram/blob/main/crates/tdbin/tests/support/bench_corpus.rs). +- **Self-describing modes only.** typeDiagram _framed_, Protobuf, and MessagePack _struct-as-map_ all carry enough structure to be decoded without an external schema, so the comparison is like-for-like. +- **Sizes are exact byte counts** emitted by each encoder (see [`crates/tdbin/examples/bench_data.rs`](https://github.com/Nimblesite/typeDiagram/blob/main/crates/tdbin/examples/bench_data.rs)) — not estimates. +- **Timings are Criterion medians** over 50 samples per operation; each measured value flows through `black_box` so the optimizer cannot elide the work. The benchmark harness is [`crates/tdbin/benches/gate.rs`](https://github.com/Nimblesite/typeDiagram/blob/main/crates/tdbin/benches/gate.rs). +- **Corpus schemas** are committed at [`docs/benchmarks/tdbin-corpus.td`](https://github.com/Nimblesite/typeDiagram/blob/main/docs/benchmarks/tdbin-corpus.td) and [`docs/benchmarks/tdbin-corpus.proto`](https://github.com/Nimblesite/typeDiagram/blob/main/docs/benchmarks/tdbin-corpus.proto). + +## Test machine + +| Field | Value | +| ------------ | ----------------------------------- | +| Platform | darwin 25.5.0 (arm64) | +| CPU | Apple M4 Max | +| Logical CPUs | 14 | +| Memory | 36.0 GiB | +| Rust | rustc 1.97.0 (2d8144b78 2026-07-07) | +| Cargo | cargo 1.97.0 (c980f4866 2026-06-30) | + +## Reproduce + +Run the benchmark, then regenerate this page: + +- `cargo bench -p tdbin --bench gate -- --noplot` +- `node scripts/tdbin-bench-report.mjs` diff --git a/docs/specs/typeshed-conversion.md b/docs/specs/typeshed-conversion.md new file mode 100644 index 0000000..b647aa4 --- /dev/null +++ b/docs/specs/typeshed-conversion.md @@ -0,0 +1,123 @@ +# Typeshed to typeDiagram + +`[TYPESHED-*]` defines how [python/typeshed](https://github.com/python/typeshed) `.pyi` files become typeDiagram models, DSL files, and SVG diagrams. The importer is available in the library, the normal CLI, the bulk CLI, and the web converter. + +## Quick start + +Convert one stub: + +```sh +typediagram --from typeshed --emit td stdlib/dataclasses.pyi > dataclasses.td +typediagram --from typeshed stdlib/dataclasses.pyi > dataclasses.svg +``` + +Convert a complete typeshed checkout while preserving its `stdlib/` and `stubs/` directory structure: + +```sh +typediagram-typeshed /path/to/typeshed /path/to/generated-typeshed +``` + +Every `.pyi` containing declarations produces a matching `.td`; import-only, constant-only, and re-export-only modules are reported and skipped. Output files are written atomically. + +Programmatic conversion uses the same public converter registry as every other language: + +```ts +import { converters, model } from "typediagram-core"; + +const analyzed = converters.typeshed.analyzeSource(stubSource); +if (analyzed.ok) { + const td = model.printSource(analyzed.value.model); + const { declarationsConverted, methodsSkipped } = analyzed.value.stats; +} +``` + +`converters.typeshed.fromSource(source)` returns only the model when conversion statistics are not needed. `converters.typeshed.toSource(model)` emits a `.pyi` representation. + +## Conversion pipeline + +`[TYPESHED-AST]` parses Python with `@lezer/python`, a browser-safe Python grammar. Conversion never uses regular expressions to interpret structured stub syntax. + +```text +.pyi source + → Python concrete syntax tree + → module declaration extraction + → type annotation normalization + → overload/class merge and generic-arity normalization + → resolved typeDiagram Model + → .td / JSON / layout / SVG +``` + +The extractor walks module declarations and declarations inside module-level `if`/`elif`/`else` version and platform gates. Conditional definitions with the same name are merged. It does not descend from a class into its methods. + +## Declaration mapping + +| Typeshed syntax | typeDiagram result | +| ----------------------------------------------------- | ------------------------------------------- | +| ordinary `class` / `Protocol` | `type`, with annotated class fields | +| `@dataclass class` | `type`, with dataclass fields | +| `TypedDict` / `NamedTuple` class | `type`, with declared fields | +| `Enum`, `IntEnum`, `StrEnum`, `Flag`, `IntFlag` | `union`, with assignment names as variants | +| module `def` / `async def` | `function` | +| repeated `@overload def name` | one `function name` with several signatures | +| `name: TypeAlias = T` | `alias name = T` | +| `type Name[T] = T` | generic `alias Name = T` | +| inferable legacy alias / `NewType` / `TypeAliasType` | `alias` | +| class method, property, static method, or constructor | deliberately omitted | +| module constant or `TypeVar` declaration | omitted | + +`[TYPESHED-DATACLASS]` does not depend on executing decorators. A dataclass is read structurally from its syntax tree, so frozen, slotted, generic, and decorator-call forms work in the browser. `ClassVar` members are not instance data and are omitted. + +`[TYPESHED-METHODS]` is the boundary between data and behavior: module functions are retained; functions nested in classes are counted in `methodsSkipped` and never become fields or function declarations. This applies equally to dataclasses, protocols, TypedDicts, NamedTuples, enums, and ordinary classes. + +## Function markup + +`[DSL-FUNCTION]` adds free functions to the typeDiagram grammar. One signature uses a compact declaration: + +```typediagram +function fetch(request: Request, limit: Int) -> Response +async function refresh(request: Request) -> Response +``` + +Overloads share one node: + +```typediagram +function open { + (path: String) -> Bytes + (descriptor: Int, mode: String) -> Bytes + async (path: String, timeout: Float) -> Bytes +} +``` + +Functions participate in parsing, model resolution, automatic parameter/return edges, JSON round-tripping, source printing, layout, SVG rendering, and render hooks. Existing data-language emitters continue to emit records/unions/aliases only; the typeshed emitter preserves function signatures as `.pyi` declarations. + +## Type normalization + +Python primitives and common containers map directly: `str → String`, `int → Int`, `float → Float`, `bool → Bool`, `bytes → Bytes`, `list → List`, `dict/Mapping → Map`, and `T | None → Option`. Qualified names use their final component. + +The importer preserves unfamiliar typing constructs as external type references, so `Callable`, `Protocol`, `LiteralString`, `TypeGuard`, and package-specific types still appear in the diagram. `Annotated`, `Final`, `Required`, `NotRequired`, and `ReadOnly` unwrap to their payload type; `ClassVar` fields are dropped; literal values reduce to their scalar type where a literal cannot be represented by a typeDiagram type reference. + +Python permits special and variadic generic arities that typeDiagram does not. `[TYPESHED-ARITY]` computes the largest observed arity for every declaration, adds synthetic generic parameters when required, and pads shorter references with `Any`. This keeps every emitted `.td` valid and round-trippable without discarding the referenced declaration. + +## Full-corpus verification + +`[TYPESHED-CORPUS]` audits an external checkout without making network access part of normal CI: + +```sh +cd packages/typediagram +npm run test:typeshed -- /path/to/typeshed +``` + +The gate parses each `.pyi`, converts every eligible declaration, prints `.td`, parses it again, rebuilds the model, and fails if any eligible file does not round-trip. On the typeshed `main` archive tested on 2026-07-19: + +| Measure | Result | +| ------------------------------------------ | ---------------------: | +| `.pyi` files scanned | 5,214 | +| files with convertible declarations | 4,508 | +| declaration-free files | 706 | +| eligible files converted and round-tripped | 4,508 / 4,508 (100%) | +| declaration statements converted | 40,467 / 40,467 (100%) | +| merged model declarations emitted | 38,666 | +| class methods deliberately skipped | 53,468 | +| syntax/conversion failures | 0 | + +Normal unit/integration coverage uses realistic dataclass, TypedDict, NamedTuple, enum, alias, conditional, overload, async, malformed-source, and empty-source cases. The bulk CLI has a filesystem E2E test. Playwright drives the public web converter, pastes a typeshed dataclass with a method plus a module function, and asserts that the generated DSL and SVG retain the data/function while excluding the method. diff --git a/package-lock.json b/package-lock.json index dc60c74..9f35249 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,10 @@ ], "devDependencies": { "@eslint/js": "^10.0.1", - "@vitest/coverage-v8": "^4.1.9", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^10.6.0", - "prettier": "^3.9.4", - "typescript-eslint": "^8.62.1" + "prettier": "^3.9.5", + "typescript-eslint": "^8.63.0" } }, "node_modules/@11ty/dependency-tree": { @@ -633,6 +633,41 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -680,9 +715,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.138.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", - "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -717,9 +752,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", - "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -735,9 +770,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", - "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -753,9 +788,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", - "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -771,9 +806,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", - "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -789,9 +824,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", - "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -807,9 +842,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", - "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -825,9 +860,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", - "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -843,9 +878,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", - "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -861,9 +896,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", - "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -879,9 +914,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", - "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -897,9 +932,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", - "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -915,9 +950,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", - "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -933,9 +968,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", - "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -953,9 +988,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", - "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -971,9 +1006,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", - "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -1218,9 +1253,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", - "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "dependencies": { @@ -1251,13 +1286,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/vscode": { - "version": "1.125.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.125.0.tgz", - "integrity": "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -1276,17 +1304,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", - "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/type-utils": "8.62.1", - "@typescript-eslint/utils": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1299,7 +1327,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.62.1", + "@typescript-eslint/parser": "^8.63.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1315,16 +1343,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", - "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3" }, "engines": { @@ -1340,14 +1368,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", - "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.1", - "@typescript-eslint/types": "^8.62.1", + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "engines": { @@ -1362,14 +1390,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", - "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1" + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1380,9 +1408,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", - "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", "dev": true, "license": "MIT", "engines": { @@ -1397,15 +1425,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", - "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1422,9 +1450,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", - "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", "dev": true, "license": "MIT", "engines": { @@ -1436,16 +1464,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", - "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.62.1", - "@typescript-eslint/tsconfig-utils": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1464,16 +1492,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", - "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1" + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1488,13 +1516,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", - "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1505,15 +1533,355 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", - "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1527,8 +1895,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.9", - "vitest": "4.1.9" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1537,16 +1905,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1555,9 +1923,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1568,13 +1936,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -1582,14 +1950,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1598,9 +1966,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -1608,13 +1976,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4256,9 +4624,9 @@ } }, "node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.6.tgz", + "integrity": "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -4936,9 +5304,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -5107,9 +5475,9 @@ } }, "node_modules/prettier": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", - "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "dev": true, "license": "MIT", "bin": { @@ -5262,13 +5630,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", - "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.138.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5278,21 +5646,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.4", - "@rolldown/binding-darwin-arm64": "1.1.4", - "@rolldown/binding-darwin-x64": "1.1.4", - "@rolldown/binding-freebsd-x64": "1.1.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", - "@rolldown/binding-linux-arm64-gnu": "1.1.4", - "@rolldown/binding-linux-arm64-musl": "1.1.4", - "@rolldown/binding-linux-ppc64-gnu": "1.1.4", - "@rolldown/binding-linux-s390x-gnu": "1.1.4", - "@rolldown/binding-linux-x64-gnu": "1.1.4", - "@rolldown/binding-linux-x64-musl": "1.1.4", - "@rolldown/binding-openharmony-arm64": "1.1.4", - "@rolldown/binding-wasm32-wasi": "1.1.4", - "@rolldown/binding-win32-arm64-msvc": "1.1.4", - "@rolldown/binding-win32-x64-msvc": "1.1.4" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/rxjs": { @@ -5521,9 +5889,9 @@ } }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -5805,9 +6173,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.22.5", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.5.tgz", - "integrity": "sha512-F7JnSfPl5ASt6LqwWyUQ3T8BwN3q0eQEbFMYa2iRWaVQmmudo0d7fRmwM4O002gsvW1bs0yBYioutsAjqLJMvQ==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", "dev": true, "license": "MIT", "dependencies": { @@ -6305,17 +6673,17 @@ "link": true }, "node_modules/typedoc": { - "version": "0.28.19", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.19.tgz", - "integrity": "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==", + "version": "0.28.20", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz", + "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", - "markdown-it": "^14.1.1", + "markdown-it": "^14.3.0", "minimatch": "^10.2.5", - "yaml": "^2.8.3" + "yaml": "^2.9.0" }, "bin": { "typedoc": "bin/typedoc" @@ -6342,6 +6710,22 @@ } }, "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-compiler": { + "name": "typescript", "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", @@ -6356,16 +6740,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", - "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.62.1", - "@typescript-eslint/parser": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/utils": "8.62.1" + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6456,19 +6840,19 @@ "license": "MIT" }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6496,12 +6880,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -6546,13 +6930,13 @@ } }, "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -6573,16 +6957,16 @@ } }, "node_modules/vitest/node_modules/vite": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", - "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", + "picomatch": "^4.0.5", "postcss": "^8.5.16", - "rolldown": "~1.1.3", + "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -6855,9 +7239,9 @@ } }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "bin": { @@ -6984,9 +7368,44 @@ "typediagram": "dist/bin.js" }, "devDependencies": { - "@types/node": "^26.1.0", - "typescript": "^6.0.3", - "vitest": "^4.1.9" + "@types/node": "^26.1.1", + "typescript": "^7.0.2", + "vitest": "^4.1.10" + } + }, + "packages/cli/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "packages/typediagram": { @@ -6994,14 +7413,16 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { + "@lezer/python": "^1.1.19", "elkjs": "^0.11.1" }, "devDependencies": { - "@types/node": "^26.1.0", + "@types/node": "^26.1.1", "esbuild": "^0.28.1", "happy-dom": "^20.10.6", - "typescript": "^6.0.3", - "vitest": "^4.1.9" + "typescript": "^7.0.2", + "typescript-compiler": "npm:typescript@6.0.3", + "vitest": "^4.1.10" } }, "packages/typediagram/node_modules/@esbuild/aix-ppc64": { @@ -7437,6 +7858,41 @@ "@esbuild/win32-x64": "0.28.1" } }, + "packages/typediagram/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "packages/vscode": { "name": "typediagram-vscode", "version": "0.0.0-dev", @@ -7448,16 +7904,16 @@ "devDependencies": { "@types/markdown-it": "^14.1.2", "@types/mocha": "^10.0.10", - "@types/node": "^26.1.0", + "@types/node": "^26.1.1", "@types/pdfkit": "^0.17.6", - "@types/vscode": "^1.99.0", + "@types/vscode": "1.99.1", "@vscode/test-electron": "^3.0.0", "esbuild": "^0.28.1", "mocha": "^11.7.6", "pdfkit": "^0.19.1", "svg-to-pdfkit": "^0.1.8", - "typescript": "^6.0.3", - "vitest": "^4.1.9", + "typescript": "^7.0.2", + "vitest": "^4.1.10", "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.1.0" }, @@ -7856,6 +8312,13 @@ "node": ">=18" } }, + "packages/vscode/node_modules/@types/vscode": { + "version": "1.99.1", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.1.tgz", + "integrity": "sha512-cQlqxHZ040ta6ovZXnXRxs3fJiTmlurkIWOfZVcLSZPcm9J4ikFpXuB7gihofGn5ng+kDVma5EmJIclfk0trPQ==", + "dev": true, + "license": "MIT" + }, "packages/vscode/node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -7898,11 +8361,46 @@ "@esbuild/win32-x64": "0.28.1" } }, + "packages/vscode/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "packages/web": { "name": "@typediagram/web", "version": "0.0.0-dev", "dependencies": { - "marked": "^18.0.0", + "marked": "^18.0.6", "typediagram-core": "0.0.0-dev" }, "devDependencies": { @@ -7913,25 +8411,60 @@ "happy-dom": "^20.10.6", "monocart-coverage-reports": "^2.12.12", "prismjs": "^1.30.0", - "tsx": "^4.22.5", - "typedoc": "^0.28.19", + "tsx": "^4.23.0", + "typedoc": "^0.28.20", "typedoc-plugin-markdown": "^4.12.0", - "typescript": "^6.0.3", - "vite": "^8.1.3", - "vitest": "^4.1.9" + "typescript": "^7.0.2", + "vite": "^8.1.4", + "vitest": "^4.1.10" + } + }, + "packages/web/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "packages/web/node_modules/vite": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", - "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", + "picomatch": "^4.0.5", "postcss": "^8.5.16", - "rolldown": "~1.1.3", + "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/package.json b/package.json index 67cdc8a..c9d2414 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@vitest/coverage-v8": "^4.1.9", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^10.6.0", - "prettier": "^3.9.4", - "typescript-eslint": "^8.62.1" + "prettier": "^3.9.5", + "typescript-eslint": "^8.63.0" } } diff --git a/packages/cli/README.md b/packages/cli/README.md index 513e2e2..731e652 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -22,6 +22,9 @@ typediagram --from typescript types.ts > diagram.svg # DSL → source language typediagram --to rust schema.td > types.rs +# One schema → every configured language, regenerated on save +typediagram --config typediagram.json --watch + # Read from stdin cat schema.td | typediagram > diagram.svg ``` @@ -30,6 +33,8 @@ cat schema.td | typediagram > diagram.svg | Flag | Values | Default | | --------------- | ----------------------------------------------------------------------------------- | ------- | +| `--config FILE` | JSON source/output manifest | — | +| `--watch` | watch the configured `.td` source | config | | `--from ` | `typescript`, `python`, `rust`, `go`, `csharp`, `fsharp`, `dart`, `protobuf`, `php` | — | | `--to ` | `typescript`, `python`, `rust`, `go`, `csharp`, `fsharp`, `dart`, `protobuf`, `php` | — | | `--emit ` | `svg`, `td`, `td+svg` (for `--from`) | `svg` | @@ -39,6 +44,21 @@ cat schema.td | typediagram > diagram.svg If no file is given, stdin is read. Output goes to stdout; errors go to stderr. +Config paths are relative to the config file: + +```json +{ + "source": "schemas/user.td", + "watch": true, + "outputs": { + "typescript": "frontend/src/generated/user.ts", + "rust": "backend/src/generated/user.rs" + } +} +``` + +Watch mode retains generated files when an edit is invalid and regenerates all selected languages after the source becomes valid again. + ## Example ```sh diff --git a/packages/cli/package.json b/packages/cli/package.json index 6c22f55..80f1169 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,7 +10,8 @@ }, "type": "module", "bin": { - "typediagram": "./dist/bin.js" + "typediagram": "./dist/bin.js", + "typediagram-typeshed": "./dist/typeshed-bin.js" }, "files": [ "dist", @@ -27,8 +28,8 @@ "typediagram-core": "0.0.0-dev" }, "devDependencies": { - "typescript": "^6.0.3", - "vitest": "^4.1.9", - "@types/node": "^26.1.0" + "typescript": "^7.0.2", + "vitest": "^4.1.10", + "@types/node": "^26.1.1" } } diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 335756e..cc6e3a9 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -15,6 +15,8 @@ const TDBIN_COMMANDS: ReadonlySet = new Set(["encode export interface CliArgs { readonly file: string | null; + readonly config: string | null; + readonly watch: boolean; readonly tdbinCommand: TdbinCommand | null; readonly theme: Theme; readonly fontSize: number | null; @@ -47,6 +49,8 @@ const parseFontSize = (v: string): Result => { export const parseArgs = (argv: readonly string[]): Result => { const state = { file: null as string | null, + config: null as string | null, + watch: false, tdbinCommand: null as TdbinCommand | null, theme: "light" as Theme, fontSize: null as number | null, @@ -71,13 +75,19 @@ export const parseArgs = (argv: readonly string[]): Result => } cur = it.next(); } - return state.from !== null && state.to !== null - ? err({ message: "--from and --to are mutually exclusive" }) - : state.tdbinCommand !== null && (state.from !== null || state.to !== null) - ? err({ message: "tdbin commands cannot be combined with --from or --to" }) - : state.json && !state.version - ? err({ message: "--json requires --version" }) - : ok(state); + return state.watch && state.config === null + ? err({ message: "--watch requires --config" }) + : state.config !== null && state.file !== null + ? err({ message: "--config cannot be combined with a positional file" }) + : state.config !== null && (state.from !== null || state.to !== null || state.tdbinCommand !== null) + ? err({ message: "--config cannot be combined with --from, --to, or a tdbin command" }) + : state.from !== null && state.to !== null + ? err({ message: "--from and --to are mutually exclusive" }) + : state.tdbinCommand !== null && (state.from !== null || state.to !== null) + ? err({ message: "tdbin commands cannot be combined with --from or --to" }) + : state.json && !state.version + ? err({ message: "--json requires --version" }) + : ok(state); }; const applyArg = ( @@ -85,6 +95,8 @@ const applyArg = ( next: () => string | null, s: { file: string | null; + config: string | null; + watch: boolean; tdbinCommand: TdbinCommand | null; theme: Theme; fontSize: number | null; @@ -102,23 +114,30 @@ const applyArg = ( ? ((s.version = true), ok(true as const)) : a === "--json" ? ((s.json = true), ok(true as const)) - : a === "--theme" - ? applyTheme(next(), s) - : a === "--font-size" - ? applyFontSize(next(), s) - : a === "--from" - ? applyLang(next(), s, "from") - : a === "--to" - ? applyLang(next(), s, "to") - : a === "--emit" - ? applyEmit(next(), s) - : a.startsWith("-") - ? err({ message: `unknown flag: ${a}` }) - : s.tdbinCommand === null && s.file === null && isTdbinCommand(a) - ? ((s.tdbinCommand = a), ok(true as const)) - : s.file !== null - ? err({ message: `unexpected positional arg: ${a}` }) - : ((s.file = a), ok(true as const)); + : a === "--config" + ? applyConfig(next(), s) + : a === "--watch" + ? ((s.watch = true), ok(true as const)) + : a === "--theme" + ? applyTheme(next(), s) + : a === "--font-size" + ? applyFontSize(next(), s) + : a === "--from" + ? applyLang(next(), s, "from") + : a === "--to" + ? applyLang(next(), s, "to") + : a === "--emit" + ? applyEmit(next(), s) + : a.startsWith("-") + ? err({ message: `unknown flag: ${a}` }) + : s.tdbinCommand === null && s.file === null && isTdbinCommand(a) + ? ((s.tdbinCommand = a), ok(true as const)) + : s.file !== null + ? err({ message: `unexpected positional arg: ${a}` }) + : ((s.file = a), ok(true as const)); + +const applyConfig = (v: string | null, s: { config: string | null }): Result => + v === null ? err({ message: "--config expects a file path" }) : ((s.config = v), ok(true as const)); const applyTheme = (v: string | null, s: { theme: Theme }): Result => v === null @@ -159,11 +178,14 @@ export const HELP_TEXT = `typediagram — render typeDiagram DSL to SVG, or conv Usage: typediagram [options] [file] + typediagram --config FILE [--watch] typediagram encode [file] typediagram decode [file] typediagram verify [file] Options: + --config FILE Generate configured language outputs from one .td source + --watch Regenerate configured outputs whenever the .td source changes --from LANG Convert from language source to SVG --to LANG Convert from typeDiagram to language source --emit svg|td|td+svg Output format for --from (default: svg) @@ -179,5 +201,6 @@ SVG (or language source with --to) is written to stdout. With --emit td, outputs the intermediate typeDiagram source. With --emit td+svg, outputs typeDiagram source then a --- separator then SVG. TDBIN encode emits a generated Rust ADT+codec module; decode emits codec impls for generated Rust ADTs; verify validates schema support. +Config paths are resolved relative to FILE. Invalid watched edits retain the last generated outputs and recover on the next valid edit. Errors go to stderr; exit code 1 on failure. `; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 77d8a39..9ebaa73 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -10,6 +10,7 @@ import { } from "typediagram-core"; import { emitRustCodec, generateRustModule } from "typediagram-core/converters/rust-tdbin"; import { HELP_TEXT, parseArgs, type CliArgs } from "./args.js"; +import { runGenerationConfig } from "./generation-config.js"; import { readSource } from "./io.js"; import { versionJson, versionText } from "./version.js"; @@ -18,7 +19,8 @@ type TdModelResult = { readonly ok: true; readonly value: modelLayer.Model } | { export const main = async ( argv: readonly string[], stdout: NodeJS.WritableStream, - stderr: NodeJS.WritableStream + stderr: NodeJS.WritableStream, + signal?: AbortSignal ): Promise => { const argsResult = parseArgs(argv); return !argsResult.ok @@ -27,13 +29,15 @@ export const main = async ( ? (stdout.write(HELP_TEXT), 0) : argsResult.value.version ? versionFlow(argsResult.value, stdout, stderr) - : argsResult.value.tdbinCommand !== null - ? tdbinFlow(argsResult.value, stdout, stderr) - : argsResult.value.from !== null - ? fromLangFlow(argsResult.value, stdout, stderr) - : argsResult.value.to !== null - ? toLangFlow(argsResult.value, stdout, stderr) - : renderFlow(argsResult.value, stdout, stderr); + : argsResult.value.config !== null + ? runGenerationConfig(argsResult.value.config, argsResult.value.watch, stdout, stderr, signal) + : argsResult.value.tdbinCommand !== null + ? tdbinFlow(argsResult.value, stdout, stderr) + : argsResult.value.from !== null + ? fromLangFlow(argsResult.value, stdout, stderr) + : argsResult.value.to !== null + ? toLangFlow(argsResult.value, stdout, stderr) + : renderFlow(argsResult.value, stdout, stderr); }; /** [SWR-VERSION-CLI-OUTPUT] --version: print from package metadata and exit. No runtime, no network. */ diff --git a/packages/cli/src/generation-config.ts b/packages/cli/src/generation-config.ts new file mode 100644 index 0000000..a3a67ee --- /dev/null +++ b/packages/cli/src/generation-config.ts @@ -0,0 +1,270 @@ +// [CLI-CONFIG-GENERATE] Configured one-schema-to-many-language generation and watching. +import { watch, type FSWatcher } from "node:fs"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; +import { converters, model as modelLayer, parser } from "typediagram-core"; +import { err, errorMessage, ok, type Result } from "./result.js"; + +type GenerationError = { readonly message: string }; +type OutputTarget = { readonly language: converters.Language; readonly path: string }; +type GeneratedOutput = OutputTarget & { readonly content: string }; +type GenerationConfig = { + readonly source: string; + readonly outputs: readonly OutputTarget[]; + readonly watch: boolean; +}; + +const recordValue = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const languageValue = (value: string): value is converters.Language => + converters.LANGUAGES.some((language) => language === value); + +const readText = async (path: string): Promise> => { + try { + return ok(await readFile(path, "utf8")); + } catch (error) { + return err({ message: `cannot read ${path}: ${errorMessage(error)}` }); + } +}; + +const parseJson = (text: string, path: string): Result => { + try { + const value: unknown = JSON.parse(text); + return ok(value); + } catch (error) { + return err({ message: `cannot parse ${path}: ${errorMessage(error)}` }); + } +}; + +const parseOutput = ([language, path]: [string, unknown]): Result => + !languageValue(language) + ? err({ message: `unsupported output language '${language}'` }) + : typeof path !== "string" || path.trim().length === 0 + ? err({ message: `output '${language}' expects a non-empty file path` }) + : ok({ language, path }); + +const collectResults = (results: readonly Result[]) => { + const values: T[] = []; + for (const result of results) { + switch (result.ok) { + case false: + return result; + case true: + values.push(result.value); + break; + } + } + return ok(values); +}; + +const parseOutputs = (value: unknown) => { + const results = recordValue(value) ? Object.entries(value).map(parseOutput) : []; + return !recordValue(value) + ? err({ message: "config.outputs must be an object keyed by language" }) + : results.length === 0 + ? err({ message: "config.outputs must select at least one language" }) + : collectResults(results); +}; + +const parseWatch = (value: unknown) => + value === undefined || typeof value === "boolean" + ? ok(value === true) + : err({ message: "config.watch must be a boolean" }); + +const resolveConfig = (path: string, source: string, outputs: readonly OutputTarget[], watchEnabled: boolean) => { + const root = dirname(resolve(path)); + const resolvedSource = resolve(root, source); + const resolvedOutputs = outputs.map((output) => ({ ...output, path: resolve(root, output.path) })); + const collision = resolvedOutputs.find((output) => output.path === resolvedSource); + return collision === undefined + ? ok({ source: resolvedSource, outputs: resolvedOutputs, watch: watchEnabled }) + : err({ message: `output '${collision.language}' cannot overwrite the .td source` }); +}; + +const parseConfig = (value: unknown, path: string): Result => { + const config = recordValue(value) ? value : undefined; + const source = config?.source; + const outputs = parseOutputs(config?.outputs); + const watchEnabled = parseWatch(config?.watch); + return config === undefined + ? err({ message: "generation config must be a JSON object" }) + : typeof source !== "string" || source.trim().length === 0 + ? err({ message: "config.source must be a non-empty .td file path" }) + : !source.endsWith(".td") + ? err({ message: "config.source must point to a .td file" }) + : !outputs.ok + ? outputs + : !watchEnabled.ok + ? watchEnabled + : resolveConfig(path, source, outputs.value, watchEnabled.value); +}; + +const loadConfig = async (path: string) => { + const text = await readText(path); + const json = text.ok ? parseJson(text.value, path) : text; + return json.ok ? parseConfig(json.value, path) : json; +}; + +const modelFromSource = (source: string) => { + const parsed = parser.parse(source); + const built = parsed.ok ? modelLayer.buildModel(parsed.value) : parsed; + return built.ok ? built : err({ message: parser.formatDiagnostics([...built.error]) }); +}; + +const generateOutput = (model: modelLayer.Model, output: OutputTarget) => { + const diagnostics = modelLayer.validateForCodegen(model, output.language); + return diagnostics.length === 0 + ? ok({ ...output, content: converters.byLanguage[output.language].toSource(model) }) + : err({ message: parser.formatDiagnostics(diagnostics) }); +}; + +const generateAll = async (config: GenerationConfig) => { + const source = await readText(config.source); + const model = source.ok ? modelFromSource(source.value) : source; + return model.ok ? collectResults(config.outputs.map((output) => generateOutput(model.value, output))) : model; +}; + +const temporaryOutputPath = (path: string) => + resolve(dirname(path), `.${basename(path)}.${String(process.pid)}.typediagram.tmp`); + +const writeOutput = async (output: GeneratedOutput): Promise> => { + try { + await mkdir(dirname(output.path), { recursive: true }); + const temporary = temporaryOutputPath(output.path); + await writeFile(temporary, output.content, "utf8"); + await rename(temporary, output.path); + return ok(output); + } catch (error) { + return err({ message: `cannot write ${output.path}: ${errorMessage(error)}` }); + } +}; + +const writeAll = async (outputs: readonly GeneratedOutput[]) => { + const results = await Promise.all(outputs.map(writeOutput)); + return collectResults(results); +}; + +const reportError = (error: GenerationError, stderr: NodeJS.WritableStream) => { + stderr.write(`${error.message.trimEnd()}\n`); + return false; +}; + +const reportGenerated = (outputs: readonly GeneratedOutput[], stdout: NodeJS.WritableStream) => { + outputs.forEach((output) => { + stdout.write(`generated ${output.language} -> ${output.path}\n`); + }); + return true; +}; + +const generateOnce = async (config: GenerationConfig, stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream) => { + const generated = await generateAll(config); + const written = generated.ok ? await writeAll(generated.value) : generated; + return written.ok ? reportGenerated(written.value, stdout) : reportError(written.error, stderr); +}; + +const processAbortSignal = () => { + const controller = new AbortController(); + const abort = () => { + controller.abort(); + }; + process.once("SIGINT", abort); + process.once("SIGTERM", abort); + controller.signal.addEventListener("abort", () => { + process.removeListener("SIGINT", abort); + process.removeListener("SIGTERM", abort); + }); + return controller.signal; +}; + +const openWatcher = (source: string, regenerate: () => void): Result => { + try { + const name = basename(source); + return ok( + watch(dirname(source), (_event, changed) => { + const relevant = changed === null || changed === name; + switch (relevant) { + case true: + regenerate(); + break; + } + }) + ); + } catch (error) { + return err({ message: `cannot watch ${source}: ${errorMessage(error)}` }); + } +}; + +const watchOutcome = (reason: unknown) => + reason instanceof Error + ? { code: 1, stdout: "", stderr: `watch failed: ${errorMessage(reason)}\n` } + : { code: 0, stdout: "watch stopped\n", stderr: "" }; + +const finishWatcher = async ( + watcher: FSWatcher, + pending: () => Promise, + reason: unknown, + stdout: NodeJS.WritableStream, + stderr: NodeJS.WritableStream +) => { + const outcome = watchOutcome(reason); + watcher.close(); + await pending(); + stderr.write(outcome.stderr); + stdout.write(outcome.stdout); + return outcome.code; +}; + +const waitForWatcher = ( + watcher: FSWatcher, + pending: () => Promise, + signal: AbortSignal, + stdout: NodeJS.WritableStream, + stderr: NodeJS.WritableStream +) => + new Promise((resolveStopped) => { + const finish = (reason: unknown) => { + void finishWatcher(watcher, pending, reason, stdout, stderr).then(resolveStopped); + }; + watcher.once("error", finish); + switch (signal.aborted) { + case true: + finish(undefined); + break; + case false: + signal.addEventListener("abort", finish, { once: true }); + break; + } + }); + +const watchConfig = async ( + config: GenerationConfig, + stdout: NodeJS.WritableStream, + stderr: NodeJS.WritableStream, + signal?: AbortSignal +) => { + let pending = Promise.resolve(); + const regenerate = () => { + pending = pending.then(async () => { + await generateOnce(config, stdout, stderr); + }); + }; + const watcher = openWatcher(config.source, regenerate); + stdout.write(watcher.ok ? `watching ${config.source}\n` : ""); + return watcher.ok + ? waitForWatcher(watcher.value, () => pending, signal ?? processAbortSignal(), stdout, stderr) + : (reportError(watcher.error, stderr), 1); +}; + +export const runGenerationConfig = async ( + path: string, + forceWatch: boolean, + stdout: NodeJS.WritableStream, + stderr: NodeJS.WritableStream, + signal?: AbortSignal +) => { + const loaded = await loadConfig(path); + const initial = loaded.ok ? await generateOnce(loaded.value, stdout, stderr) : reportError(loaded.error, stderr); + const watching = loaded.ok && (forceWatch || loaded.value.watch); + return !loaded.ok || !watching ? (initial ? 0 : 1) : watchConfig(loaded.value, stdout, stderr, signal); +}; diff --git a/packages/cli/src/result.ts b/packages/cli/src/result.ts index 209b0f8..5e78a83 100644 --- a/packages/cli/src/result.ts +++ b/packages/cli/src/result.ts @@ -3,3 +3,4 @@ export type Result = { readonly ok: true; readonly value: T } | { readonly export const ok = (value: T): Result => ({ ok: true, value }); export const err = (error: E): Result => ({ ok: false, error }); +export const errorMessage = (error: unknown) => String(error); diff --git a/packages/cli/src/typeshed-bin.ts b/packages/cli/src/typeshed-bin.ts new file mode 100644 index 0000000..312e684 --- /dev/null +++ b/packages/cli/src/typeshed-bin.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env node +// [TYPESHED-BULK-BIN] Dedicated typeshed repository converter entry point. +import { typeshedMain } from "./typeshed-cli.js"; + +void typeshedMain(process.argv.slice(2), process.stdout, process.stderr).then((code) => process.exit(code)); diff --git a/packages/cli/src/typeshed-cli.ts b/packages/cli/src/typeshed-cli.ts new file mode 100644 index 0000000..6481df1 --- /dev/null +++ b/packages/cli/src/typeshed-cli.ts @@ -0,0 +1,112 @@ +// [TYPESHED-BULK] Mirror a typeshed checkout into one .td file per non-empty .pyi. +import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import { converters, model } from "typediagram-core"; +import { err, errorMessage, ok, type Result } from "./result.js"; + +interface BulkError { + readonly message: string; +} + +interface FileOutcome { + readonly kind: "converted" | "empty"; + readonly declarations: number; +} + +const pyiPaths = (tree: string, names: string[]) => + names.filter((name) => name.endsWith(".pyi")).map((name) => join(tree, name)); + +const stubFiles = async (root: string): Promise> => { + try { + const stdlib = join(root, "stdlib"); + const stubs = join(root, "stubs"); + const [stdlibEntries, stubEntries] = await Promise.all([ + readdir(stdlib, { recursive: true }), + readdir(stubs, { recursive: true }), + ]); + return ok([...pyiPaths(stdlib, stdlibEntries), ...pyiPaths(stubs, stubEntries)]); + } catch (error) { + return err({ message: `cannot scan typeshed root ${root}: ${errorMessage(error)}` }); + } +}; + +const outputPath = (sourceRoot: string, outputRoot: string, source: string) => { + const path = relative(sourceRoot, source); + return join(outputRoot, `${path.slice(0, -4)}.td`); +}; + +const atomicWrite = async (path: string, content: string): Promise> => { + try { + const parent = dirname(path); + await mkdir(parent, { recursive: true }); + const temporaryRoot = await mkdtemp(join(parent, ".typediagram-")); + try { + const temporary = join(temporaryRoot, "output.td"); + await writeFile(temporary, content, "utf8"); + await rename(temporary, path); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } + return ok(true); + } catch (error) { + return err({ message: `cannot write ${path}: ${errorMessage(error)}` }); + } +}; + +const convertFile = async ( + sourceRoot: string, + outputRoot: string, + path: string +): Promise> => { + try { + const analyzed = converters.typeshed.analyzeSource(await readFile(path, "utf8")); + if (!analyzed.ok && analyzed.error.every((diagnostic) => diagnostic.message === "No typeshed declarations found")) { + return ok({ kind: "empty", declarations: 0 }); + } + if (!analyzed.ok) { + return err({ message: `${path}: ${analyzed.error.map((diagnostic) => diagnostic.message).join("; ")}` }); + } + const written = await atomicWrite( + outputPath(sourceRoot, outputRoot, path), + model.printSource(analyzed.value.model) + ); + return written.ok ? ok({ kind: "converted", declarations: analyzed.value.model.decls.length }) : written; + } catch (error) { + return err({ message: `cannot convert ${path}: ${errorMessage(error)}` }); + } +}; + +const convertAll = async (sourceRoot: string, outputRoot: string, files: string[]) => { + const outcomes: FileOutcome[] = []; + for (const file of files) { + const converted = await convertFile(sourceRoot, outputRoot, file); + if (!converted.ok) { + return converted; + } + outcomes.push(converted.value); + } + return ok(outcomes); +}; + +const summary = (outcomes: FileOutcome[]) => { + const converted = outcomes.filter((outcome) => outcome.kind === "converted").length; + const empty = outcomes.length - converted; + const declarations = outcomes.reduce((total, outcome) => total + outcome.declarations, 0); + return `converted ${String(converted)} typeshed files (${String(declarations)} declarations); skipped ${String(empty)} files without declarations\n`; +}; + +export const typeshedMain = async ( + argv: readonly string[], + stdout: NodeJS.WritableStream, + stderr: NodeJS.WritableStream +) => { + const sourceRoot = argv[0] === undefined ? undefined : resolve(argv[0]); + const outputRoot = argv[1] === undefined ? undefined : resolve(argv[1]); + if (sourceRoot === undefined || outputRoot === undefined) { + stderr.write("usage: typediagram-typeshed \n"); + return 1; + } + const files = await stubFiles(sourceRoot); + const converted = files.ok ? await convertAll(sourceRoot, outputRoot, files.value) : files; + return converted.ok ? (stdout.write(summary(converted.value)), 0) : (stderr.write(`${converted.error.message}\n`), 1); +}; diff --git a/packages/cli/test/__snapshots__/alias-chain.svg b/packages/cli/test/__snapshots__/alias-chain.svg index 5cc1edf..c6ef753 100644 --- a/packages/cli/test/__snapshots__/alias-chain.svg +++ b/packages/cli/test/__snapshots__/alias-chain.svg @@ -1,45 +1,41 @@ - + + + + - - - - - - - alias Email + + + + + + + + + alias Email - -= String + = String - - - - - - - alias UserEmail + + + + + + alias UserEmail - -= Email + = Email - - - - - - - alias AdminEmail + + + + + + alias AdminEmail - -= UserEmail + = UserEmail - - - - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/all-external.svg b/packages/cli/test/__snapshots__/all-external.svg index 9d292cc..d2e759c 100644 --- a/packages/cli/test/__snapshots__/all-external.svg +++ b/packages/cli/test/__snapshots__/all-external.svg @@ -1,28 +1,26 @@ - + + + + - - - - - - - HttpRequest + + + + + + + + HttpRequest - -url: URL - -method: HttpMethod - -headers: Map<String, String> - -body: Option<Bytes> - -timeout: Duration + url: URL +method: HttpMethod +headers: Map<String, String> +body: Option<Bytes> +timeout: Duration - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/all-primitives.svg b/packages/cli/test/__snapshots__/all-primitives.svg index c2ea64e..a147a0d 100644 --- a/packages/cli/test/__snapshots__/all-primitives.svg +++ b/packages/cli/test/__snapshots__/all-primitives.svg @@ -1,30 +1,27 @@ - + + + + - - - - - - - AllPrimitives + + + + + + + + AllPrimitives - -b: Bool - -i: Int - -f: Float - -s: String - -by: Bytes - -u: Unit + b: Bool +i: Int +f: Float +s: String +by: Bytes +u: Unit - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/chat-model-render.test.svg b/packages/cli/test/__snapshots__/chat-model-render.test.svg index 8723f2d..a4b5238 100644 --- a/packages/cli/test/__snapshots__/chat-model-render.test.svg +++ b/packages/cli/test/__snapshots__/chat-model-render.test.svg @@ -1,173 +1,128 @@ - + + + + - - - - - - - ChatRequest + +tool_results +tool_results +tool_results +tool_results +content +List.items +Text.value +Uri.value +kind +media_type + + + + + + ChatRequest - -message: String - -session_id: String - -tool_results: Option<List<ToolResult>> + message: String +session_id: String +tool_results: Option<List<ToolResult>> - - - - - - - ChatTurnInput + + + + + + ChatTurnInput - -config: AgentConfig - -user_message: String - -tool_results: Option<List<ToolResult>> - -session_id: String + config: AgentConfig +user_message: String +tool_results: Option<List<ToolResult>> +session_id: String - - - - - - - ToolResult + + + + + + ToolResult - -tool_call_id: String - -name: String - -content: ToolResultContent - -ok: Bool + tool_call_id: String +name: String +content: ToolResultContent +ok: Bool - - - - - - - union ToolResultContent - ONE OF - - -◇ None - -◇ Scalar { value: String } - -◇ Dict { entries: Map<String, String> } - -◇ List { items: List<ContentItem> } + + + + + + union ToolResultContent + ONE OF + + ◇ None +◇ Scalar { value: String } +◇ Dict { entries: Map<String, String> } +◇ List { items: List<ContentItem> } - - - - - - - union ContentItem - ONE OF - - -◇ Text { value: TextPart } - -◇ Uri { value: UriPart } - -◇ Scalar { value: String } + + + + + + union ContentItem + ONE OF + + ◇ Text { value: TextPart } +◇ Uri { value: UriPart } +◇ Scalar { value: String } - - - - - - - TextPart + + + + + + TextPart - -text: String + text: String - - - - - - - UriPart + + + + + + UriPart - -url: String - -kind: UriKind - -media_type: Option<String> + url: String +kind: UriKind +media_type: Option<String> - - - - - - - union UriKind - ONE OF - - -◇ Image - -◇ Audio - -◇ Video - -◇ Document - -◇ Web - -◇ Api + + + + + + union UriKind + ONE OF + + ◇ Image +◇ Audio +◇ Video +◇ Document +◇ Web +◇ Api - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - -tool_results - -tool_results - -tool_results - -tool_results - -content - -List.items - -Text.value - -Uri.value - -kind - -media_type \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/deep-generics.svg b/packages/cli/test/__snapshots__/deep-generics.svg index b26488b..7ddecdd 100644 --- a/packages/cli/test/__snapshots__/deep-generics.svg +++ b/packages/cli/test/__snapshots__/deep-generics.svg @@ -1,50 +1,44 @@ - + + + + - - - - - - - Config + +rules +rules + + + + + + Config - -rules: Map<String, List<Option<Rule>>> + rules: Map<String, List<Option<Rule>>> - - - - - - - Rule + + + + + + Rule - -name: String - -priority: Int + name: String +priority: Int - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - -rules - -rules \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/empty-diagram.svg b/packages/cli/test/__snapshots__/empty-diagram.svg index 9dea812..02b5235 100644 --- a/packages/cli/test/__snapshots__/empty-diagram.svg +++ b/packages/cli/test/__snapshots__/empty-diagram.svg @@ -1,10 +1,14 @@ - + + + + + \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/long-names.svg b/packages/cli/test/__snapshots__/long-names.svg index 4bb1b05..e7e2bab 100644 --- a/packages/cli/test/__snapshots__/long-names.svg +++ b/packages/cli/test/__snapshots__/long-names.svg @@ -1,22 +1,23 @@ - + + + + - - - - - - - VeryLongTypeNameThatShouldNotBreakLayout + + + + + + + + VeryLongTypeNameThatShouldNotBreakLayout - -this_is_an_extremely_long_field_name_that_tests_rendering: String - -short: Int + this_is_an_extremely_long_field_name_that_tests_rendering: String +short: Int - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/many-nodes.svg b/packages/cli/test/__snapshots__/many-nodes.svg index 2307747..ff5b3f7 100644 --- a/packages/cli/test/__snapshots__/many-nodes.svg +++ b/packages/cli/test/__snapshots__/many-nodes.svg @@ -1,97 +1,81 @@ - + + + + - - - - - - - A + +x +x +x +x +x +x + + + + + + A - -x: B + x: B - - - - - - - B + + + + + + B - -x: C + x: C - - - - - - - C + + + + + + C - -x: D + x: D - - - - - - - D + + + + + + D - -x: E + x: E - - - - - - - E + + + + + + E - -x: F + x: F - - - - - - - F + + + + + + F - -x: G + x: G - - - - - - - G + + + + + + G - -x: String + x: String - -x - -x - -x - -x - -x - -x \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/mixed-union.svg b/packages/cli/test/__snapshots__/mixed-union.svg index 8627e7c..3d6c748 100644 --- a/packages/cli/test/__snapshots__/mixed-union.svg +++ b/packages/cli/test/__snapshots__/mixed-union.svg @@ -1,29 +1,27 @@ - + + + + - - - - - - - union Event - ONE OF - - -◇ Click { x: Int, y: Int } - -◇ KeyPress { key: String, modifiers: List<String> } - -◇ Scroll { deltaX: Float, deltaY: Float } - -◇ Focus - -◇ Blur - + + + + + + + union Event + ONE OF + + ◇ Click { x: Int, y: Int } +◇ KeyPress { key: String, modifiers: List<String> } +◇ Scroll { deltaX: Float, deltaY: Float } +◇ Focus +◇ Blur + \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/multi-generics.svg b/packages/cli/test/__snapshots__/multi-generics.svg index 2bf6b29..4236053 100644 --- a/packages/cli/test/__snapshots__/multi-generics.svg +++ b/packages/cli/test/__snapshots__/multi-generics.svg @@ -1,50 +1,45 @@ - + + + + - - - - - - - Pair<A, B> + + + + + + + + Pair<A, B> - -first: A - -second: B + first: A +second: B - - - - - - - union Either<L, R> - ONE OF - - -◇ Left { value: L } - -◇ Right { value: R } + + + + + + union Either<L, R> + ONE OF + + ◇ Left { value: L } +◇ Right { value: R } - - - - - - - union Result<T, E> - ONE OF - - -◇ Ok { value: T } - -◇ Err { error: E } + + + + + + union Result<T, E> + ONE OF + + ◇ Ok { value: T } +◇ Err { error: E } - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/self-ref.svg b/packages/cli/test/__snapshots__/self-ref.svg index 31f90f5..0220b28 100644 --- a/packages/cli/test/__snapshots__/self-ref.svg +++ b/packages/cli/test/__snapshots__/self-ref.svg @@ -1,23 +1,23 @@ - + + + + - - - - - - - TreeNode + +children + + + + + + TreeNode - -value: String - -children: List<TreeNode> + value: String +children: List<TreeNode> - -children \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/single-alias.svg b/packages/cli/test/__snapshots__/single-alias.svg index 3a4dd4b..b8404eb 100644 --- a/packages/cli/test/__snapshots__/single-alias.svg +++ b/packages/cli/test/__snapshots__/single-alias.svg @@ -1,20 +1,22 @@ - + + + + - - - - - - - alias UserId + + + + + + + + alias UserId - -= String + = String - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/single-record.svg b/packages/cli/test/__snapshots__/single-record.svg index 073141a..a77c701 100644 --- a/packages/cli/test/__snapshots__/single-record.svg +++ b/packages/cli/test/__snapshots__/single-record.svg @@ -1,22 +1,23 @@ - + + + + - - - - - - - Point + + + + + + + + Point - -x: Float - -y: Float + x: Float +y: Float - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/single-union.svg b/packages/cli/test/__snapshots__/single-union.svg index 0f20372..125ce62 100644 --- a/packages/cli/test/__snapshots__/single-union.svg +++ b/packages/cli/test/__snapshots__/single-union.svg @@ -1,27 +1,26 @@ - + + + + - - - - - - - union Direction - ONE OF - - -◇ North - -◇ South - -◇ East - -◇ West - + + + + + + + union Direction + ONE OF + + ◇ North +◇ South +◇ East +◇ West + \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/small-example.svg b/packages/cli/test/__snapshots__/small-example.svg index c338eac..d496ea6 100644 --- a/packages/cli/test/__snapshots__/small-example.svg +++ b/packages/cli/test/__snapshots__/small-example.svg @@ -1,89 +1,71 @@ - + + + + - - - - - - - User + +email +email +address + + + + + + User - -id: UUID - -name: String - -email: Option<Email> - -roles: List<Role> - -address: Address + id: UUID +name: String +email: Option<Email> +roles: List<Role> +address: Address - - - - - - - Address + + + + + + Address - -line1: String - -city: String - -country: CountryCode + line1: String +city: String +country: CountryCode - - - - - - - union Shape - ONE OF - - -◇ Circle { radius: Float } - -◇ Square { side: Float } - -◇ Triangle { a: Float, b: Float, c: Float } + + + + + + union Shape + ONE OF + + ◇ Circle { radius: Float } +◇ Square { side: Float } +◇ Triangle { a: Float, b: Float, c: Float } - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - - - - - - - alias Email + + + + + + alias Email - -= String + = String - -email - -email - -address \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/union-refs-union.svg b/packages/cli/test/__snapshots__/union-refs-union.svg index 6d4bf26..752d420 100644 --- a/packages/cli/test/__snapshots__/union-refs-union.svg +++ b/packages/cli/test/__snapshots__/union-refs-union.svg @@ -1,40 +1,36 @@ - + + + + - - - - - - - union Outer - ONE OF - - -◇ Leaf { value: String } - -◇ Nested { inner: Inner } + +Nested.inner + + + + + + union Outer + ONE OF + + ◇ Leaf { value: String } +◇ Nested { inner: Inner } - - - - - - - union Inner - ONE OF - - -◇ A - -◇ B { data: Int } - -◇ C { label: String, count: Int } + + + + + + union Inner + ONE OF + + ◇ A +◇ B { data: Int } +◇ C { label: String, count: Int } - -Nested.inner \ No newline at end of file diff --git a/packages/cli/test/args.test.ts b/packages/cli/test/args.test.ts index d272a4c..6eaced8 100644 --- a/packages/cli/test/args.test.ts +++ b/packages/cli/test/args.test.ts @@ -11,6 +11,8 @@ describe("[CLI-ARGS] parseArgs", () => { } expect(r.value).toEqual({ file: null, + config: null, + watch: false, tdbinCommand: null, theme: "light", fontSize: null, @@ -153,6 +155,19 @@ describe("[CLI-ARGS] parseArgs", () => { expect(r.ok && r.value.emit).toBe("td+svg"); }); + it("parses config watch mode and rejects every conflicting invocation", () => { + const configured = parseArgs(["--config", "typediagram.json", "--watch"]); + expect(configured.ok).toBe(true); + expect(configured.ok && configured.value.config).toBe("typediagram.json"); + expect(configured.ok && configured.value.watch).toBe(true); + expect(parseArgs(["--watch"]).ok).toBe(false); + expect(parseArgs(["--config"]).ok).toBe(false); + expect(parseArgs(["--config", "td.json", "schema.td"]).ok).toBe(false); + expect(parseArgs(["--config", "td.json", "--to", "rust"]).ok).toBe(false); + expect(parseArgs(["--config", "td.json", "--from", "rust"]).ok).toBe(false); + expect(parseArgs(["--config", "td.json", "encode"]).ok).toBe(false); + }); + it("defaults --emit to svg", () => { const r = parseArgs([]); expect(r.ok && r.value.emit).toBe("svg"); diff --git a/packages/cli/test/config-generation.e2e.test.ts b/packages/cli/test/config-generation.e2e.test.ts new file mode 100644 index 0000000..a313a87 --- /dev/null +++ b/packages/cli/test/config-generation.e2e.test.ts @@ -0,0 +1,222 @@ +// [CLI-CONFIG-GENERATE-TEST] One config drives atomic multi-language generation +// and repeated source-file regeneration through the real CLI entry point. +import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { main } from "../src/cli.js"; +import { makeStream, run } from "./helpers.js"; + +const source = (fields: string) => `typeDiagram + +type Person { +${fields} +} +`; + +const runConfig = async (root: string, name: string, value: unknown) => { + const path = join(root, name); + await writeFile(path, typeof value === "string" ? value : JSON.stringify(value), "utf8"); + return run(["--config", path]); +}; + +const expectFailure = (result: Awaited>, message: string) => { + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(message); +}; + +const expectGeneratedFields = async (typeScriptPath: string, rustPath: string, tsField: string, rustField: string) => { + expect(await readFile(typeScriptPath, "utf8")).toContain(tsField); + expect(await readFile(rustPath, "utf8")).toContain(rustField); +}; + +describe("[CLI-CONFIG-GENERATE] configured ADT generation", () => { + it("generates every selected language and regenerates after valid, invalid, and recovered edits", async () => { + const root = await mkdtemp(join(tmpdir(), "td-config-generate-")); + const configPath = join(root, "typediagram.json"); + const sourcePath = join(root, "schemas", "person.td"); + const typeScriptPath = join(root, "generated", "web", "person.ts"); + const rustPath = join(root, "generated", "server", "person.rs"); + await mkdir(join(root, "schemas"), { recursive: true }); + await writeFile(sourcePath, source(" id: Int"), "utf8"); + await writeFile( + configPath, + JSON.stringify({ + source: "schemas/person.td", + outputs: { + typescript: "generated/web/person.ts", + rust: "generated/server/person.rs", + }, + }), + "utf8" + ); + + const initial = await run(["--config", configPath]); + expect(initial.code).toBe(0); + expect(initial.stderr).toBe(""); + expect(initial.stdout).toContain(`generated typescript -> ${typeScriptPath}`); + expect(initial.stdout).toContain(`generated rust -> ${rustPath}`); + expect(await readFile(typeScriptPath, "utf8")).toContain("export interface Person"); + expect(await readFile(typeScriptPath, "utf8")).toContain("id: number"); + expect(await readFile(rustPath, "utf8")).toContain("pub struct Person"); + expect(await readFile(rustPath, "utf8")).toContain("pub id: i64"); + + await writeFile( + configPath, + JSON.stringify({ + source: "schemas/person.td", + watch: true, + outputs: { + typescript: "generated/web/person.ts", + rust: "generated/server/person.rs", + }, + }), + "utf8" + ); + + const out = makeStream(); + const err = makeStream(); + const controller = new AbortController(); + const watched = main(["--config", configPath], out.stream, err.stream, controller.signal); + await vi.waitFor(() => { + expect(out.text()).toContain(`watching ${sourcePath}`); + }); + await writeFile(sourcePath, source(" id: Int\n name: String"), "utf8"); + await vi.waitFor(() => expectGeneratedFields(typeScriptPath, rustPath, "name: string", "pub name: String")); + const lastGoodTypeScript = await readFile(typeScriptPath, "utf8"); + const lastGoodRust = await readFile(rustPath, "utf8"); + + await writeFile(sourcePath, `${source(" id: Int\n name: String")}active`, "utf8"); + await vi.waitFor(() => { + expect(err.text()).toContain("expected 'type', 'union', 'untagged union', 'alias', or 'function'"); + }); + expect(await readFile(typeScriptPath, "utf8")).toBe(lastGoodTypeScript); + expect(await readFile(rustPath, "utf8")).toBe(lastGoodRust); + expect(lastGoodTypeScript).not.toContain("active: boolean"); + expect(lastGoodRust).not.toContain("pub active: bool"); + + await writeFile(sourcePath, source(" id: Int\n name: String\n active: Bool"), "utf8"); + await vi.waitFor(() => expectGeneratedFields(typeScriptPath, rustPath, "active: boolean", "pub active: bool")); + controller.abort(); + expect(await watched).toBe(0); + expect(out.text().split("generated typescript ->").length - 1).toBeGreaterThanOrEqual(3); + expect(out.text().split("generated rust ->").length - 1).toBeGreaterThanOrEqual(3); + expect(out.text()).toContain("watch stopped"); + expect(await readFile(typeScriptPath, "utf8")).not.toContain("active\n"); + expect(await readFile(rustPath, "utf8")).not.toContain("active\n"); + expect((await readdir(join(root, "generated", "web"))).filter((name) => name.endsWith(".tmp"))).toEqual([]); + expect((await readdir(join(root, "generated", "server"))).filter((name) => name.endsWith(".tmp"))).toEqual([]); + + const invalidConfigPath = join(root, "invalid.json"); + await writeFile( + invalidConfigPath, + JSON.stringify({ source: "schemas/person.td", outputs: { swift: "generated/person.swift" } }), + "utf8" + ); + const invalidConfig = await run(["--config", invalidConfigPath]); + expectFailure(invalidConfig, "unsupported output language 'swift'"); + + const invalidCases: ReadonlyArray = [ + ["array.json", [], "generation config must be a JSON object"], + ["missing-source.json", { outputs: { typescript: "generated/person.ts" } }, "config.source"], + [ + "wrong-extension.json", + { source: "schemas/person.txt", outputs: { typescript: "generated/person.ts" } }, + "must point to a .td file", + ], + ["outputs-array.json", { source: "schemas/person.td", outputs: [] }, "config.outputs must be an object"], + ["outputs-empty.json", { source: "schemas/person.td", outputs: {} }, "select at least one language"], + [ + "output-path-empty.json", + { source: "schemas/person.td", outputs: { typescript: "", rust: "generated/person.rs" } }, + "expects a non-empty file path", + ], + [ + "watch-invalid.json", + { source: "schemas/person.td", watch: "yes", outputs: { typescript: "generated/person.ts" } }, + "config.watch must be a boolean", + ], + [ + "source-collision.json", + { source: "schemas/person.td", outputs: { typescript: "schemas/person.td" } }, + "cannot overwrite the .td source", + ], + ["malformed.json", "{", "cannot parse"], + ]; + for (const [name, value, message] of invalidCases) { + expectFailure(await runConfig(root, name, value), message); + } + + const absentConfig = await run(["--config", join(root, "absent.json")]); + expectFailure(absentConfig, "cannot read"); + const absentSource = await runConfig(root, "absent-source.json", { + source: "schemas/absent.td", + outputs: { typescript: "generated/absent.ts" }, + }); + expectFailure(absentSource, "cannot read"); + + await writeFile(sourcePath, source(" mystery: Mystery"), "utf8"); + const codegenFailure = await runConfig(root, "codegen-failure.json", { + source: "schemas/person.td", + outputs: { typescript: "generated/person.ts", rust: "generated/person.rs" }, + }); + expectFailure(codegenFailure, "unknown type 'Mystery'"); + + await writeFile(sourcePath, "type Person { id: Int }\ntype Person { name: String }\n", "utf8"); + const modelFailure = await runConfig(root, "model-failure.json", { + source: "schemas/person.td", + outputs: { typescript: "generated/person.ts" }, + }); + expectFailure(modelFailure, "duplicate declaration 'Person'"); + + await writeFile(sourcePath, source(" id: Int"), "utf8"); + const outputDirectory = join(root, "generated", "directory-output"); + await mkdir(outputDirectory, { recursive: true }); + const writeFailure = await runConfig(root, "write-failure.json", { + source: "schemas/person.td", + outputs: { typescript: "generated/directory-output" }, + }); + expectFailure(writeFailure, "cannot write"); + + const watchFailure = await runConfig(root, "watch-failure.json", { + source: "missing/person.td", + watch: true, + outputs: { typescript: "generated/missing.ts" }, + }); + expectFailure(watchFailure, "cannot watch"); + + await writeFile( + configPath, + JSON.stringify({ + source: "schemas/person.td", + watch: true, + outputs: { typescript: "generated/pre-aborted.ts" }, + }), + "utf8" + ); + const preAborted = new AbortController(); + preAborted.abort(); + const preAbortedOut = makeStream(); + const preAbortedErr = makeStream(); + expect(await main(["--config", configPath], preAbortedOut.stream, preAbortedErr.stream, preAborted.signal)).toBe(0); + expect(preAbortedOut.text()).toContain("watching"); + expect(preAbortedOut.text()).toContain("watch stopped"); + expect(preAbortedErr.text()).toBe(""); + + const signalOut = makeStream(); + const signalErr = makeStream(); + const originalSignals = new Set(process.listeners("SIGINT")); + const processWatched = main(["--config", configPath], signalOut.stream, signalErr.stream); + await vi.waitFor(() => { + expect(signalOut.text()).toContain("watching"); + }); + const stop = process.listeners("SIGINT").find((listener) => !originalSignals.has(listener)); + expect(stop).toBeDefined(); + stop?.(); + expect(await processWatched).toBe(0); + expect(signalOut.text()).toContain("watch stopped"); + expect(signalErr.text()).toBe(""); + expect(process.listeners("SIGINT")).toEqual([...originalSignals]); + }); +}); diff --git a/packages/cli/test/typeshed.e2e.test.ts b/packages/cli/test/typeshed.e2e.test.ts new file mode 100644 index 0000000..785f194 --- /dev/null +++ b/packages/cli/test/typeshed.e2e.test.ts @@ -0,0 +1,83 @@ +// [TYPESHED-BULK-TEST] Black-box repository conversion through the public CLI function. +import { mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { typeshedMain } from "../src/typeshed-cli.js"; +import { makeStream } from "./helpers.js"; + +const createStubRoot = async (source: string) => { + const root = await mkdtemp(join(tmpdir(), "typediagram-typeshed-invalid-")); + await Promise.all([ + mkdir(join(root, "stdlib"), { recursive: true }), + mkdir(join(root, "stubs"), { recursive: true }), + ]); + await writeFile(join(root, "stdlib", "sample.pyi"), source, "utf8"); + return root; +}; + +describe("[TYPESHED-BULK] typeshed repository conversion", () => { + it("mirrors stdlib and third-party stubs, skips empty modules, and retains no methods", async () => { + const root = await mkdtemp(join(tmpdir(), "typediagram-typeshed-source-")); + const output = await mkdtemp(join(tmpdir(), "typediagram-typeshed-output-")); + await mkdir(join(root, "stdlib"), { recursive: true }); + await mkdir(join(root, "stubs", "package", "package"), { recursive: true }); + await writeFile( + join(root, "stdlib", "sample.pyi"), + "class Payload:\n value: str\n def encode(self) -> bytes: ...\n\ndef fetch(payload: Payload) -> bytes: ...\n", + "utf8" + ); + await writeFile(join(root, "stdlib", "empty.pyi"), "from sample import Payload\n", "utf8"); + await writeFile( + join(root, "stubs", "package", "package", "__init__.pyi"), + "from typing import TypedDict\nclass Config(TypedDict):\n enabled: bool\n", + "utf8" + ); + + const stdout = makeStream(); + const stderr = makeStream(); + const code = await typeshedMain([root, output], stdout.stream, stderr.stream); + const stdlib = await readFile(join(output, "stdlib", "sample.td"), "utf8"); + const thirdParty = await readFile(join(output, "stubs", "package", "package", "__init__.td"), "utf8"); + expect(code).toBe(0); + expect(stderr.text()).toBe(""); + expect(stdout.text()).toBe("converted 2 typeshed files (3 declarations); skipped 1 files without declarations\n"); + expect(stdlib).toContain("type Payload"); + expect(stdlib).toContain("function fetch(payload: Payload) -> Bytes"); + expect(stdlib).not.toContain("encode"); + expect(thirdParty).toContain("type Config"); + expect(await readdir(join(output, "stdlib"))).toEqual(["sample.td"]); + }); + + it("reports missing arguments and invalid roots without partial success", async () => { + const stdout = makeStream(); + const stderr = makeStream(); + expect(await typeshedMain([], stdout.stream, stderr.stream)).toBe(1); + expect(stderr.text()).toContain("usage: typediagram-typeshed"); + const missingOutputError = makeStream(); + expect(await typeshedMain(["/typeshed"], stdout.stream, missingOutputError.stream)).toBe(1); + expect(missingOutputError.text()).toContain("usage: typediagram-typeshed"); + const missingError = makeStream(); + expect(await typeshedMain(["/missing/typeshed", "/tmp/output"], stdout.stream, missingError.stream)).toBe(1); + expect(missingError.text()).toContain("cannot scan typeshed root"); + const invalidRoot = await createStubRoot("def broken(: ..."); + const invalidOutput = await mkdtemp(join(tmpdir(), "typediagram-typeshed-invalid-output-")); + const invalidError = makeStream(); + expect(await typeshedMain([invalidRoot, invalidOutput], stdout.stream, invalidError.stream)).toBe(1); + expect(invalidError.text()).toContain("Invalid typeshed/Python stub syntax"); + const validRoot = await createStubRoot("class Valid:\n value: str\n"); + const blockedOutput = join(await mkdtemp(join(tmpdir(), "typediagram-typeshed-blocked-")), "output"); + await writeFile(blockedOutput, "not a directory", "utf8"); + const writeError = makeStream(); + expect(await typeshedMain([validRoot, blockedOutput], stdout.stream, writeError.stream)).toBe(1); + expect(writeError.text()).toContain("cannot write"); + const unreadableRoot = await mkdtemp(join(tmpdir(), "typediagram-typeshed-unreadable-")); + await Promise.all([ + mkdir(join(unreadableRoot, "stdlib", "directory.pyi"), { recursive: true }), + mkdir(join(unreadableRoot, "stubs"), { recursive: true }), + ]); + const readError = makeStream(); + expect(await typeshedMain([unreadableRoot, invalidOutput], stdout.stream, readError.stream)).toBe(1); + expect(readError.text()).toContain("cannot convert"); + }); +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index dfc20ac..53fef4a 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -3,5 +3,5 @@ import { createVitestConfig } from "../../scripts/vitest-config-base"; export default createVitestConfig({ configDir: __dirname, project: "packages/cli", - exclude: ["src/bin.ts"], + exclude: ["src/bin.ts", "src/typeshed-bin.ts"], }); diff --git a/packages/typediagram/package.json b/packages/typediagram/package.json index b6dcb0c..0e8a734 100644 --- a/packages/typediagram/package.json +++ b/packages/typediagram/package.json @@ -36,6 +36,10 @@ "types": "./dist/render-svg/index.d.ts", "import": "./dist/render-svg/index.js" }, + "./editor": { + "types": "./dist/editor/index.d.ts", + "import": "./dist/editor/index.js" + }, "./converters/rust-tdbin": { "types": "./dist/converters/rust-tdbin.d.ts", "import": "./dist/converters/rust-tdbin.js" @@ -57,6 +61,7 @@ "scripts": { "build": "tsc -p tsconfig.build.json", "test": "vitest run --coverage", + "test:typeshed": "npm run build && node scripts/typeshed-corpus.mjs", "test:watch": "vitest", "typecheck": "tsc --noEmit -p tsconfig.build.json", "check-banned-deps": "node scripts/check-banned-deps.mjs", @@ -64,13 +69,15 @@ "check": "npm run check-banned-deps && npm run typecheck && npm run test" }, "dependencies": { + "@lezer/python": "^1.1.19", "elkjs": "^0.11.1" }, "devDependencies": { + "@types/node": "^26.1.1", "esbuild": "^0.28.1", - "typescript": "^6.0.3", - "vitest": "^4.1.9", - "@types/node": "^26.1.0", - "happy-dom": "^20.10.6" + "happy-dom": "^20.10.6", + "typescript": "^7.0.2", + "typescript-compiler": "npm:typescript@6.0.3", + "vitest": "^4.1.10" } } diff --git a/packages/typediagram/scripts/bundle-size.mjs b/packages/typediagram/scripts/bundle-size.mjs index 17f179c..9520167 100644 --- a/packages/typediagram/scripts/bundle-size.mjs +++ b/packages/typediagram/scripts/bundle-size.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -// [CI-BUNDLE-SIZE] Fail if the framework bundle (excluding elkjs) exceeds the -// budget. Uses esbuild to tree-shake and measure the output size. +// [CI-BUNDLE-SIZE] Fail if framework-owned code (excluding heavyweight runtime +// engines supplied as package dependencies) exceeds the budget. // 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 @@ -8,21 +8,22 @@ // parser + emitter filtering overhead, which took the budget to 80 KB. // Semantic scalars (DateTime/Uuid/Decimal) across all 9 converters plus // codegen unknown-type validation [MODEL-CODEGEN-UNKNOWN] measure ~81 KB, -// so the current budget is 84 KB. +// so that budget was 84 KB. The Typeshed AST adapter, function declarations, +// and emitters add ~8 KB; @lezer/python remains an external runtime engine. 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 = 84; +const BUDGET_KB = 93; const result = await build({ entryPoints: [entry], bundle: true, format: "esm", platform: "node", - external: ["elkjs", "elkjs/*"], + external: ["elkjs", "elkjs/*", "@lezer/python", "@lezer/python/*"], write: false, minify: true, metafile: true, @@ -32,7 +33,7 @@ const bytes = result.outputFiles.reduce((sum, f) => sum + f.contents.length, 0); const kb = bytes / 1024; const rounded = Math.round(kb * 100) / 100; -console.log(`bundle size (excl. elkjs): ${rounded} KB`); +console.log(`bundle size (excl. elkjs/@lezer): ${rounded} KB`); kb > BUDGET_KB ? (console.error(`OVER BUDGET: ${rounded} KB > ${BUDGET_KB} KB`), process.exit(1)) diff --git a/packages/typediagram/scripts/typeshed-corpus.mjs b/packages/typediagram/scripts/typeshed-corpus.mjs new file mode 100644 index 0000000..07d6cad --- /dev/null +++ b/packages/typediagram/scripts/typeshed-corpus.mjs @@ -0,0 +1,58 @@ +// [TYPESHED-CORPUS] Reproducible full-checkout conversion and DSL round-trip gate. +import { readFile, readdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { typeshed } from "../dist/converters/typeshed.js"; +import { buildModel } from "../dist/model/build.js"; +import { printSource } from "../dist/model/print.js"; +import { parse } from "../dist/parser/parser.js"; + +const listStubs = async (root) => { + const trees = [join(root, "stdlib"), join(root, "stubs")]; + const entries = await Promise.all(trees.map((tree) => readdir(tree, { recursive: true }))); + return entries.flatMap((names, index) => + names.filter((name) => name.endsWith(".pyi")).map((name) => join(trees[index], name)) + ); +}; + +const roundTrips = (model) => { + const parsed = parse(printSource(model)); + return parsed.ok && buildModel(parsed.value).ok; +}; + +const auditFile = async (path) => { + const analyzed = typeshed.analyzeSource(await readFile(path, "utf8")); + const empty = + !analyzed.ok && analyzed.error.every((diagnostic) => diagnostic.message === "No typeshed declarations found"); + return !analyzed.ok + ? { kind: empty ? "empty" : "error", declarations: 0, methods: 0, roundTrip: empty } + : { + kind: "eligible", + declarations: analyzed.value.stats.declarationsConverted, + methods: analyzed.value.stats.methodsSkipped, + roundTrip: roundTrips(analyzed.value.model), + }; +}; + +const buildReport = (outcomes) => { + const eligible = outcomes.filter((outcome) => outcome.kind === "eligible"); + const roundTrips = eligible.filter((outcome) => outcome.roundTrip).length; + return { + files: outcomes.length, + eligibleFiles: eligible.length, + emptyFiles: outcomes.filter((outcome) => outcome.kind === "empty").length, + errorFiles: outcomes.filter((outcome) => outcome.kind === "error").length, + roundTrips, + eligibleFileCoverage: eligible.length === 0 ? 0 : roundTrips / eligible.length, + declarationsConverted: eligible.reduce((total, outcome) => total + outcome.declarations, 0), + methodsSkipped: eligible.reduce((total, outcome) => total + outcome.methods, 0), + }; +}; + +const rootArg = process.argv[2]; +const root = rootArg === undefined ? undefined : resolve(rootArg); +const outcomes = root === undefined ? undefined : await Promise.all((await listStubs(root)).map(auditFile)); +const report = outcomes === undefined ? undefined : buildReport(outcomes); +process.stdout.write( + report === undefined ? "usage: npm run test:typeshed -- \n" : `${JSON.stringify(report, null, 2)}\n` +); +process.exitCode = report !== undefined && report.errorFiles === 0 && report.eligibleFileCoverage === 1 ? 0 : 1; diff --git a/packages/typediagram/src/converters/emit-decls.ts b/packages/typediagram/src/converters/emit-decls.ts index 45a31ba..ae42443 100644 --- a/packages/typediagram/src/converters/emit-decls.ts +++ b/packages/typediagram/src/converters/emit-decls.ts @@ -10,7 +10,7 @@ import { type ResolvedAlias, type ResolvedRecord, type ResolvedUnion, - visibleDeclsForTarget, + visibleDataDeclsForTarget, } from "../model/types.js"; import type { Language } from "./types.js"; @@ -29,7 +29,7 @@ export interface EmitDeclsOptions { } const emitOne = (d: Model["decls"][number], emit: DeclEmitters): string[] => - d.kind === "record" ? emit.record(d) : d.kind === "union" ? emit.union(d) : emit.alias(d); + d.kind === "record" ? emit.record(d) : d.kind === "union" ? emit.union(d) : d.kind === "alias" ? emit.alias(d) : []; /** * Walk `model`'s decls visible to `language`, emit each via `emit` (a blank @@ -42,7 +42,7 @@ export const emitDecls = ( options: EmitDeclsOptions = {} ): string => { const lines = [...(options.prelude ?? [])]; - for (const d of visibleDeclsForTarget(model.decls, language)) { + for (const d of visibleDataDeclsForTarget(model.decls, language)) { lines.push(...emitOne(d, emit), ""); } const joined = lines.join("\n"); diff --git a/packages/typediagram/src/converters/fsharp.ts b/packages/typediagram/src/converters/fsharp.ts index 25af9c3..c2639e4 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, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; +import { type Model, type ResolvedTypeRef, visibleDataDeclsForTarget } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; import { mapBuiltinName, parseTypeRef, splitGenericArgs } from "./parse-typeref.js"; @@ -177,7 +177,7 @@ const mapTdToFs = (t: ResolvedTypeRef): string => { const toFSharp = (model: Model): string => { const lines: string[] = []; - for (const d of visibleDeclsForTarget(model.decls, "fsharp")) { + for (const d of visibleDataDeclsForTarget(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 133d769..72c1aad 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 { modelReferencesType, type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; +import { modelReferencesType, type Model, type ResolvedTypeRef, visibleDataDeclsForTarget } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; import { mapBuiltinName, parseTypeRef, resolveFieldTypes } from "./parse-typeref.js"; @@ -309,7 +309,7 @@ const goGenericsInstance = (generics: string[]): string => (generics.length === const variantStructName = (unionName: string, variantName: string): string => `${unionName}${variantName}`; const toGo = (model: Model): string => { - const visible = visibleDeclsForTarget(model.decls, "go"); + const visible = visibleDataDeclsForTarget(model.decls, "go"); const lines: string[] = ["package types", ""]; if (modelReferencesType(visible, "DateTime")) { lines.push('import "time"', ""); diff --git a/packages/typediagram/src/converters/index.ts b/packages/typediagram/src/converters/index.ts index 7c5ac01..a11c46d 100644 --- a/packages/typediagram/src/converters/index.ts +++ b/packages/typediagram/src/converters/index.ts @@ -1,6 +1,7 @@ // [CONV] Barrel export for all language converters. import { typescript } from "./typescript.js"; import { python } from "./python.js"; +import { typeshed } from "./typeshed.js"; import { rust } from "./rust.js"; import { go } from "./go.js"; import { csharp } from "./csharp.js"; @@ -10,7 +11,9 @@ import { protobuf } from "./protobuf.js"; import { php } from "./php.js"; import type { Converter, Language } from "./types.js"; -export { typescript, python, rust, go, csharp, fsharp, dart, protobuf, php }; +export { typescript, python, typeshed, rust, go, csharp, fsharp, dart, protobuf, php }; +export type { TypeshedAnalysis, TypeshedConverter } from "./typeshed.js"; +export type { TypeshedStats } from "./typeshed-decls.js"; export { parseTypeRef, printTypeRef } from "./parse-typeref.js"; export type { Converter, Language } from "./types.js"; @@ -20,6 +23,7 @@ export type { Converter, Language } from "./types.js"; export const byLanguage: Record = { typescript, python, + typeshed, rust, go, csharp, diff --git a/packages/typediagram/src/converters/php.ts b/packages/typediagram/src/converters/php.ts index 6a43aab..333d126 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, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; +import { type Model, type ResolvedTypeRef, visibleDataDeclsForTarget } from "../model/types.js"; import { ModelBuilder, alias, record, union } from "../model/builder.js"; import type { Converter } from "./types.js"; import { mapBuiltinName, parseTypeRef } from "./parse-typeref.js"; @@ -311,7 +311,7 @@ const renderAlias = (name: string, generics: readonly string[], target: Resolved }; const toPhp = (model: Model): string => { - const blocks = visibleDeclsForTarget(model.decls, "php").flatMap((decl) => { + const blocks = visibleDataDeclsForTarget(model.decls, "php").flatMap((decl) => { if (decl.kind === "record") { return [renderRecord(decl.name, decl.generics, decl.fields)]; } diff --git a/packages/typediagram/src/converters/python.ts b/packages/typediagram/src/converters/python.ts index d62a4cf..e8499a3 100644 --- a/packages/typediagram/src/converters/python.ts +++ b/packages/typediagram/src/converters/python.ts @@ -12,7 +12,7 @@ import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; import { modelReferencesType, - visibleDeclsForTarget, + visibleDataDeclsForTarget, type Model, type ResolvedDecl, type ResolvedTypeRef, @@ -464,7 +464,7 @@ const emitBareEnum = (name: string, variants: readonly { name: string }[]): stri const toPython = (model: Model, opts?: PythonOpts): string => { const pydantic = opts?.style === "pydantic"; - const decls = visibleDeclsForTarget(model.decls, "python"); + const decls = visibleDataDeclsForTarget(model.decls, "python"); const lines: string[] = pydantic ? buildPydanticImports(decls) : buildDataclassImports(decls); const emitRecord = pydantic ? emitPydanticRecord : emitDataclassRecord; diff --git a/packages/typediagram/src/converters/rust-tdbin.ts b/packages/typediagram/src/converters/rust-tdbin.ts index 12a8470..8f6c220 100644 --- a/packages/typediagram/src/converters/rust-tdbin.ts +++ b/packages/typediagram/src/converters/rust-tdbin.ts @@ -10,7 +10,7 @@ // columnar emission in rust-tdbin-columnar.ts / rust-tdbin-columns.ts, and // the layout manifest/hash in rust-tdbin-hash.ts. import type { Diagnostic } from "../parser/diagnostics.js"; -import { type Model, type ResolvedDecl, visibleDeclsForTarget } from "../model/types.js"; +import { type Model, type ResolvedDataDecl, type ResolvedDecl, visibleDataDeclsForTarget } from "../model/types.js"; import { emitRustDecl } from "./rust.js"; import { err, ok, type Result } from "../result.js"; import { classifyRecord, classifyUnion, diag, type Layout } from "./rust-tdbin-plan.js"; @@ -98,7 +98,7 @@ const declBlocks = (ctx: EmitCtx, d: ResolvedDecl): Result => { const layout: Layout = options?.layout ?? 1; - const visible = visibleDeclsForTarget(model.decls, "rust"); + const visible = visibleDataDeclsForTarget(model.decls, "rust"); const ctx: EmitCtx = { decls: model.decls, layout, @@ -118,7 +118,7 @@ export const emitRustCodec = (model: Model, options?: RustCodecOptions): Result< /** Records derive `Default` so required pointer fields can decode null as the * schema default ([TDBIN-PTR-NULL]); unions get a generated `impl Default`. */ -const deriveFor = (d: ResolvedDecl): string => +const deriveFor = (d: ResolvedDataDecl): string => d.kind === "record" ? "#[derive(Debug, Clone, PartialEq, Default)]\n" : d.kind === "union" @@ -127,7 +127,7 @@ const deriveFor = (d: ResolvedDecl): string => /** Emit one ADT type with its doc comment first, then the derive, then the body * (from the shared Rust converter) — the order rustc/clippy expect. */ -const emitTypeWithDocs = (d: ResolvedDecl): string => { +const emitTypeWithDocs = (d: ResolvedDataDecl): string => { const [doc, ...rest] = emitRustDecl(d, true); return `${doc ?? ""}\n${deriveFor(d)}${rest.join("\n")}`; }; @@ -141,6 +141,6 @@ export const generateRustModule = (model: Model, options?: RustCodecOptions): Re if (!codec.ok) { return codec; } - const types = visibleDeclsForTarget(model.decls, "rust").map(emitTypeWithDocs).join("\n"); + const types = visibleDataDeclsForTarget(model.decls, "rust").map(emitTypeWithDocs).join("\n"); return ok(`${types}\n${codec.value}\n`); }; diff --git a/packages/typediagram/src/converters/rust.ts b/packages/typediagram/src/converters/rust.ts index 8ba1427..0f5e569 100644 --- a/packages/typediagram/src/converters/rust.ts +++ b/packages/typediagram/src/converters/rust.ts @@ -5,11 +5,11 @@ import { formatVariantName, withDiscriminant } from "../variant.js"; import { isTupleVariantFields, type Model, - type ResolvedDecl, + type ResolvedDataDecl, type ResolvedField, type ResolvedTypeRef, type ResolvedVariant, - visibleDeclsForTarget, + visibleDataDeclsForTarget, } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; @@ -295,7 +295,7 @@ const emitRustVariant = (v: ResolvedVariant, docs: boolean): string => { /** [CONV-RUST-DECL] Emit one Rust type declaration (no derives). Shared by the * type converter and the TDBIN codec generator so neither duplicates it. With * `docs`, prepends `///` comments so the output is `missing_docs`-clean. */ -export const emitRustDecl = (d: ResolvedDecl, docs = false): string[] => { +export const emitRustDecl = (d: ResolvedDataDecl, docs = false): string[] => { const genericsStr = d.generics.length > 0 ? `<${d.generics.join(", ")}>` : ""; const lead = docs ? [rustDoc("", d.name, d.kind)] : []; const field = (f: ResolvedField): string => @@ -320,7 +320,7 @@ export const emitRustDecl = (d: ResolvedDecl, docs = false): string[] => { }; const toRust = (model: Model): string => - visibleDeclsForTarget(model.decls, "rust") + visibleDataDeclsForTarget(model.decls, "rust") .flatMap((d) => emitRustDecl(d)) .join("\n"); diff --git a/packages/typediagram/src/converters/types.ts b/packages/typediagram/src/converters/types.ts index dcf97cc..d5f788c 100644 --- a/packages/typediagram/src/converters/types.ts +++ b/packages/typediagram/src/converters/types.ts @@ -3,7 +3,8 @@ 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" | "dart" | "protobuf" | "php"; +export type Language = + "typescript" | "python" | "typeshed" | "rust" | "go" | "csharp" | "fsharp" | "dart" | "protobuf" | "php"; export interface PythonOpts { readonly style?: "dataclass" | "pydantic"; diff --git a/packages/typediagram/src/converters/typescript.ts b/packages/typediagram/src/converters/typescript.ts index 0c709b4..32f3c22 100644 --- a/packages/typediagram/src/converters/typescript.ts +++ b/packages/typediagram/src/converters/typescript.ts @@ -17,7 +17,7 @@ import { type Model, type ResolvedTypeRef, type ResolvedVariant, - visibleDeclsForTarget, + visibleDataDeclsForTarget, } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; @@ -279,7 +279,7 @@ const mapUntaggedVariantToTs = (variant: ResolvedVariant): string => { const toTypeScript = (model: Model): string => { const lines: string[] = []; - const decls = visibleDeclsForTarget(model.decls, "typescript"); + const decls = visibleDataDeclsForTarget(model.decls, "typescript"); for (const d of decls) { const genericsStr = d.generics.length > 0 ? `<${d.generics.join(", ")}>` : ""; diff --git a/packages/typediagram/src/converters/typeshed-decls.ts b/packages/typediagram/src/converters/typeshed-decls.ts new file mode 100644 index 0000000..d4c9927 --- /dev/null +++ b/packages/typediagram/src/converters/typeshed-decls.ts @@ -0,0 +1,325 @@ +// [TYPESHED-DECLS] Extract module declarations from the Python syntax tree. +import type { Diagnostic } from "../parser/diagnostics.js"; +import { type Result, err, ok } from "../result.js"; +import { + ModelBuilder, + alias, + functionDecl, + ref, + record, + union, + type FieldSpec, + type FunctionSignatureSpec, +} from "../model/builder.js"; +import { + walkDeclRefs, + type Model, + type ResolvedDecl, + type ResolvedFunctionSignature, + type ResolvedTypeRef, +} from "../model/types.js"; +import { isClassVar, pythonTypeRef } from "./typeshed-type-ref.js"; +import { + childOf, + childrenOf, + classMembers, + definitionOf, + descendantNames, + firstErrorNode, + firstNamedChild, + isTypeDiagramKeyword, + moduleNodes, + namedChildrenOf, + parsePythonStub, + safeTypeName, + textOf, + type PythonNode, +} from "./typeshed-tree.js"; + +export interface TypeshedStats { + declarationsSeen: number; + declarationsConverted: number; + methodsSkipped: number; +} + +export interface TypeshedAnalysis { + model: Model; + stats: TypeshedStats; +} + +interface Candidate { + decl: ResolvedDecl; + offset: number; +} + +export const analyzeTypeshedSource = (source: string): Result => { + const tree = parsePythonStub(source); + const parseError = firstErrorNode(tree.topNode); + if (parseError !== undefined) { + return err([syntaxDiagnostic(source, parseError)]); + } + const extracted = extractCandidates(moduleNodes(tree.topNode), source); + if (extracted.candidates.length === 0) { + return err([emptyDiagnostic()]); + } + const builder = new ModelBuilder(); + const decls = mergeCandidates(extracted.candidates).map((candidate) => candidate.decl); + normalizeDeclaredArities(decls).forEach((decl) => builder.add(decl)); + const built = builder.build(); + return built.ok ? ok({ model: built.value, stats: extracted.stats }) : built; +}; + +const extractCandidates = (nodes: PythonNode[], source: string) => { + const candidates = nodes.flatMap((node) => candidateFor(node, source)); + const classes = nodes.map(definitionOf).filter((node): node is PythonNode => node?.name === "ClassDefinition"); + const methodsSkipped = classes.reduce((total, node) => total + countMethods(node), 0); + const stats = { declarationsSeen: candidates.length, declarationsConverted: candidates.length, methodsSkipped }; + return { candidates, stats }; +}; + +const candidateFor = (node: PythonNode, source: string): Candidate[] => { + const definition = definitionOf(node); + return definition?.name === "ClassDefinition" + ? [classCandidate(definition, source)] + : definition?.name === "FunctionDefinition" + ? [functionCandidate(definition, source)] + : node.name === "TypeDefinition" + ? [typeAliasCandidate(node, source)] + : node.name === "AssignStatement" + ? assignmentCandidate(node, source) + : []; +}; + +const classCandidate = (node: PythonNode, source: string): Candidate => { + const name = definitionName(node, source); + const generics = definitionGenerics(node, source); + const bases = classBases(node, source); + const decl = bases.some(isEnumBase) + ? union(name, enumVariants(node, source), generics) + : record(name, classFields(node, source), generics); + return { decl, offset: node.from }; +}; + +const definitionName = (node: PythonNode, source: string) => + safeTypeName(textOf(source, childrenOf(node).find((child) => child.name === "VariableName") ?? node)); + +const definitionGenerics = (node: PythonNode, source: string) => { + const modern = childOf(node, "TypeParamList"); + if (modern !== undefined) { + return genericNames(modern, source); + } + const genericBase = classBases(node, source).find((base) => base.name === "Generic"); + return genericBase?.args.map((arg) => arg.name) ?? []; +}; + +const genericNames = (node: PythonNode, source: string) => + childrenOf(node) + .filter((child) => child.name === "TypeParam") + .flatMap((param) => descendantNames(param, "VariableName").slice(0, 1)) + .map((name) => textOf(source, name)); + +const classBases = (node: PythonNode, source: string) => { + const args = childOf(node, "ArgList"); + return args === undefined ? [] : namedChildrenOf(args).map((child) => pythonTypeRef(child, source)); +}; + +const isEnumBase = (base: ResolvedTypeRef) => ["Enum", "IntEnum", "StrEnum", "Flag", "IntFlag"].includes(base.name); + +const classFields = (node: PythonNode, source: string) => + classMembers(node).flatMap((member) => (member.name === "AssignStatement" ? annotatedField(member, source) : [])); + +const annotatedField = (node: PythonNode, source: string): FieldSpec[] => { + const name = childrenOf(node).find((child) => child.name === "VariableName"); + const type = childOf(node, "TypeDef"); + return name === undefined || type === undefined || isClassVar(firstNamedChild(type), source) + ? [] + : [{ name: safeMemberName(textOf(source, name)), type: pythonTypeRef(type, source) }]; +}; + +const enumVariants = (node: PythonNode, source: string) => + classMembers(node).flatMap((member) => { + const name = member.name === "AssignStatement" ? childOf(member, "VariableName") : undefined; + return name === undefined || textOf(source, name).startsWith("_") + ? [] + : [{ name: safeMemberName(textOf(source, name)), fields: [] }]; + }); + +const functionCandidate = (node: PythonNode, source: string): Candidate => ({ + decl: functionDecl(definitionName(node, source), [functionSignature(node, source)], definitionGenerics(node, source)), + offset: node.from, +}); + +const functionSignature = (node: PythonNode, source: string): FunctionSignatureSpec => ({ + params: functionParams(childOf(node, "ParamList"), source), + returns: pythonTypeRef(childOf(node, "TypeDef"), source), + ...(childrenOf(node).some((child) => child.name === "async") ? { async: true } : {}), +}); + +const functionParams = (params: PythonNode | undefined, source: string) => { + const children = params === undefined ? [] : childrenOf(params); + return children.flatMap((child, index) => paramFor(children, child, index, source)); +}; + +const paramFor = (siblings: PythonNode[], node: PythonNode, index: number, source: string): FieldSpec[] => { + if (node.name !== "VariableName") { + return []; + } + const typeNode = siblings.at(index + 1)?.name === "TypeDef" ? siblings.at(index + 1) : undefined; + const type = pythonTypeRef(typeNode, source); + const marker = siblings.at(index - 1)?.name; + const wrapped = marker === "**" ? mapParam(type) : marker === "*" ? listParam(type) : type; + return [{ name: safeMemberName(textOf(source, node)), type: wrapped }]; +}; + +const listParam = (type: ResolvedTypeRef) => ({ ...type, name: "List", args: [type] }); + +const mapParam = (type: ResolvedTypeRef) => ({ ...type, name: "Map", args: [ref("String"), type] }); + +const typeAliasCandidate = (node: PythonNode, source: string): Candidate => { + const named = namedChildrenOf(node); + const name = named.find((child) => child.name === "VariableName"); + const target = named.at(-1); + const generics = childOf(node, "TypeParamList"); + return { + decl: alias( + safeTypeName(textOf(source, name ?? node)), + pythonTypeRef(target, source), + generics === undefined ? [] : genericNames(generics, source) + ), + offset: node.from, + }; +}; + +const assignmentCandidate = (node: PythonNode, source: string): Candidate[] => { + const name = childOf(node, "VariableName"); + const target = assignmentTarget(node); + const annotation = childOf(node, "TypeDef"); + const explicit = pythonTypeRef(annotation, source).name === "TypeAlias"; + return name === undefined || target === undefined || (!explicit && !isImplicitAlias(name, target, source)) + ? [] + : [{ decl: alias(safeTypeName(textOf(source, name)), pythonTypeRef(target, source)), offset: node.from }]; +}; + +const assignmentTarget = (node: PythonNode) => { + const children = childrenOf(node); + const assign = children.findIndex((child) => child.name === "AssignOp"); + return children.slice(assign + 1).find((child) => !child.type.isAnonymous); +}; + +const isImplicitAlias = (name: PythonNode, target: PythonNode, source: string) => { + const value = textOf(source, name); + const allCaps = value === value.toUpperCase() && value !== value.toLowerCase(); + const callee = target.name === "CallExpression" ? textOf(source, firstNamedChild(target) ?? target) : ""; + const typeShape = ["VariableName", "MemberExpression", "BinaryExpression", "String"].includes(target.name); + return !allCaps && (typeShape || callee.endsWith("NewType") || callee.endsWith("TypeAliasType")); +}; + +const countMethods = (node: PythonNode) => + classMembers(node).filter((member) => definitionOf(member)?.name === "FunctionDefinition").length; + +const safeMemberName = (name: string) => (isTypeDiagramKeyword(name) ? `${name}_` : name); + +const mergeCandidates = (candidates: Candidate[]) => { + const merged = new Map(); + candidates + .sort((left, right) => left.offset - right.offset) + .forEach((candidate) => { + mergeCandidate(merged, candidate); + }); + return [...merged.values()]; +}; + +const mergeCandidate = (merged: Map, candidate: Candidate) => { + const current = merged.get(candidate.decl.name); + merged.set( + candidate.decl.name, + current === undefined ? candidate : { ...current, decl: mergeDecl(current.decl, candidate.decl) } + ); +}; + +const mergeDecl = (left: ResolvedDecl, right: ResolvedDecl): ResolvedDecl => + left.kind === "function" && right.kind === "function" + ? { ...left, signatures: uniqueSignatures([...left.signatures, ...right.signatures]) } + : left.kind === "record" && right.kind === "record" + ? { ...left, fields: uniqueNamed([...left.fields, ...right.fields]) } + : left.kind === "union" && right.kind === "union" + ? { ...left, variants: uniqueNamed([...left.variants, ...right.variants]) } + : left; + +const uniqueNamed = (values: T[]) => + values.filter((value, index) => values.findIndex((candidate) => candidate.name === value.name) === index); + +const uniqueSignatures = (values: ResolvedFunctionSignature[]) => + values.filter( + (value, index) => values.findIndex((candidate) => signatureKey(candidate) === signatureKey(value)) === index + ); + +const signatureKey = (signature: ResolvedFunctionSignature) => + `${signature.async === true ? "async" : "sync"}|${signature.params.map((param) => `${param.name}:${refKey(param.type)}`).join(",")}|${refKey(signature.returns)}`; + +const refKey = (type: ResolvedTypeRef): string => `${type.name}<${type.args.map(refKey).join(",")}>`; + +const normalizeDeclaredArities = (decls: ResolvedDecl[]) => { + const arities = new Map(decls.map((decl) => [decl.name, decl.generics.length])); + decls.forEach((decl) => { + walkDeclRefs(decl, (type) => { + recordArity(arities, type); + }); + }); + decls.forEach((decl) => { + extendGenerics(decl, arities.get(decl.name) ?? decl.generics.length); + }); + decls.forEach((decl) => { + walkDeclRefs(decl, (type) => { + padDeclaredRef(type, arities); + }); + }); + return decls; +}; + +const recordArity = (arities: Map, type: ResolvedTypeRef) => { + const current = arities.get(type.name); + switch (current) { + case undefined: + break; + default: + arities.set(type.name, Math.max(current, type.args.length)); + } +}; + +const extendGenerics = (decl: ResolvedDecl, arity: number) => { + while (decl.generics.length < arity) { + decl.generics.push(uniqueGeneric(decl.generics, decl.generics.length + 1)); + } +}; + +const uniqueGeneric = (generics: string[], index: number): string => { + const candidate = `_T${String(index)}`; + return generics.includes(candidate) ? uniqueGeneric(generics, index + 1) : candidate; +}; + +const padDeclaredRef = (type: ResolvedTypeRef, arities: ReadonlyMap) => { + const arity = arities.get(type.name) ?? type.args.length; + while (type.args.length < arity) { + type.args.push(ref("Any")); + } +}; + +const syntaxDiagnostic = (source: string, node: PythonNode): Diagnostic => { + const lines = source.slice(0, node.from).split("\n"); + return { + severity: "error", + message: "Invalid typeshed/Python stub syntax", + line: lines.length, + col: (lines.at(-1)?.length ?? 0) + 1, + length: Math.max(1, node.to - node.from), + }; +}; + +const emptyDiagnostic = (): Diagnostic => ({ + severity: "error", + message: "No typeshed declarations found", + line: 0, + col: 0, + length: 0, +}); diff --git a/packages/typediagram/src/converters/typeshed-tree.ts b/packages/typediagram/src/converters/typeshed-tree.ts new file mode 100644 index 0000000..4b64a87 --- /dev/null +++ b/packages/typediagram/src/converters/typeshed-tree.ts @@ -0,0 +1,76 @@ +// [TYPESHED-AST] Browser-safe Python syntax-tree helpers; no source regex parsing. +import { parser as pythonParser } from "@lezer/python"; + +export type PythonNode = ReturnType["topNode"]; + +export const parsePythonStub = (source: string) => pythonParser.parse(source); + +const TYPE_DIAGRAM_KEYWORDS = new Set(["type", "union", "untagged", "alias", "function", "async", "typeDiagram"]); + +export const isTypeDiagramKeyword = (name: string) => TYPE_DIAGRAM_KEYWORDS.has(name); + +export const safeTypeName = (name: string) => + isTypeDiagramKeyword(name) ? `${name.charAt(0).toUpperCase()}${name.slice(1)}` : name; + +export const childrenOf = (node: PythonNode) => { + const children: PythonNode[] = []; + for (let child = node.firstChild; child !== null; child = child.nextSibling) { + children.push(child); + } + return children; +}; + +const SYNTAX_NODES = new Set([":", ",", ".", "(", ")", "[", "]", "{", "}"]); + +export const namedChildrenOf = (node: PythonNode) => + childrenOf(node).filter((child) => !child.type.isAnonymous && !SYNTAX_NODES.has(child.name)); + +export const childOf = (node: PythonNode, name: string) => childrenOf(node).find((child) => child.name === name); + +export const textOf = (source: string, node: PythonNode) => source.slice(node.from, node.to); + +export const firstNamedChild = (node: PythonNode) => namedChildrenOf(node)[0]; + +export const descendantNames = (node: PythonNode, name: string) => { + const found: PythonNode[] = []; + walkNode(node, (candidate) => { + found.push(...(candidate.name === name ? [candidate] : [])); + }); + return found; +}; + +export const walkNode = (node: PythonNode, visit: (node: PythonNode) => void) => { + visit(node); + childrenOf(node).forEach((child) => { + walkNode(child, visit); + }); +}; + +export const firstErrorNode = (node: PythonNode) => { + let error: PythonNode | undefined; + walkNode(node, (candidate) => { + error = error ?? (candidate.type.isError ? candidate : undefined); + }); + return error; +}; + +export const definitionOf = (node: PythonNode) => + node.name === "DecoratedStatement" + ? namedChildrenOf(node).find((child) => child.name === "ClassDefinition" || child.name === "FunctionDefinition") + : node; + +const moduleBodyNodes = (node: PythonNode): PythonNode[] => + node.name === "IfStatement" + ? childrenOf(node) + .filter((child) => child.name === "Body") + .flatMap((body) => childrenOf(body).flatMap(moduleBodyNodes)) + : [node]; + +export const moduleNodes = (root: PythonNode) => childrenOf(root).flatMap(moduleBodyNodes); + +const classBodyNodes = (node: PythonNode): PythonNode[] => { + const body = childOf(node, "Body"); + return body === undefined ? [] : childrenOf(body).flatMap(moduleBodyNodes); +}; + +export const classMembers = (node: PythonNode) => classBodyNodes(node); diff --git a/packages/typediagram/src/converters/typeshed-type-ref.ts b/packages/typediagram/src/converters/typeshed-type-ref.ts new file mode 100644 index 0000000..9d0e73f --- /dev/null +++ b/packages/typediagram/src/converters/typeshed-type-ref.ts @@ -0,0 +1,167 @@ +// [TYPESHED-TYPES] Python annotation AST -> unresolved typeDiagram TypeRef. +import { ref } from "../model/builder.js"; +import type { ResolvedTypeRef } from "../model/types.js"; +import { + childrenOf, + firstNamedChild, + namedChildrenOf, + safeTypeName, + textOf, + type PythonNode, +} from "./typeshed-tree.js"; + +const NAME_MAP: Readonly> = { + bool: "Bool", + int: "Int", + float: "Float", + complex: "Float", + str: "String", + bytes: "Bytes", + bytearray: "Bytes", + None: "Unit", + list: "List", + List: "List", + dict: "Map", + Dict: "Map", + Mapping: "Map", + Optional: "Option", + Any: "Any", + type: "Type", +}; + +const WRAPPERS = new Set(["Annotated", "ClassVar", "Final", "Required", "NotRequired", "ReadOnly"]); + +export const pythonTypeRef = (node: PythonNode | undefined, source: string): ResolvedTypeRef => + node === undefined + ? ref("Any") + : node.name === "TypeDef" + ? pythonTypeRef(firstNamedChild(node), source) + : typeRefForNode(node, source); + +const typeRefForNode = (node: PythonNode, source: string): ResolvedTypeRef => { + switch (node.name) { + case "VariableName": + case "PropertyName": + return nameRef(textOf(source, node)); + case "None": + return ref("Unit"); + case "String": + return forwardRef(textOf(source, node)); + case "Number": + return ref("Int"); + case "Boolean": + return ref("Bool"); + case "MemberExpression": + return memberRef(node, source); + case "BinaryExpression": + return unionRef(node, source); + case "TupleExpression": + case "ListExpression": + return ref( + "List", + namedChildrenOf(node).map((child) => pythonTypeRef(child, source)) + ); + case "ArrayExpression": + return ref( + "Args", + namedChildrenOf(node).map((child) => pythonTypeRef(child, source)) + ); + case "Ellipsis": + return ref("Any"); + case "UnaryExpression": + return ref("Int"); + case "CallExpression": + return callRef(node, source); + default: + return fallbackRef(node, source); + } +}; + +const nameRef = (name: string) => { + const last = lastSegment(name); + const mapped = Object.hasOwn(NAME_MAP, last) ? NAME_MAP[last] : undefined; + return ref(mapped ?? safeTypeName(isIdentifier(last) ? last : "Any")); +}; + +const lastSegment = (name: string) => name.split(".").at(-1) ?? name; + +const isIdentifier = (name: string) => { + const first = name.charAt(0); + const starts = isLetter(first) || first === "_"; + return ( + starts && Array.from(name.slice(1)).every((char) => isLetter(char) || char === "_" || (char >= "0" && char <= "9")) + ); +}; + +const isLetter = (char: string) => (char >= "A" && char <= "Z") || (char >= "a" && char <= "z"); + +const forwardRef = (quoted: string) => { + const first = quoted.charAt(0); + const unquoted = first === "'" || first === '"' ? quoted.slice(1, -1) : quoted; + return nameRef(unquoted); +}; + +const memberRef = (node: PythonNode, source: string) => { + const children = childrenOf(node); + const bracket = children.findIndex((child) => child.name === "["); + return bracket < 0 ? nameRef(textOf(source, node)) : genericRef(children, bracket, source); +}; + +const genericRef = (children: PythonNode[], bracket: number, source: string) => { + const base = pythonTypeRef(children[0], source).name; + const args = children + .slice(bracket + 1) + .filter(isTypeChild) + .map((child) => pythonTypeRef(child, source)); + return normalizeGeneric(base, args); +}; + +const isTypeChild = (node: PythonNode) => + !node.type.isAnonymous && !["Comment", "[", "]", ",", "(", ")"].includes(node.name); + +const normalizeGeneric = (base: string, args: ResolvedTypeRef[]) => + WRAPPERS.has(base) && args[0] !== undefined + ? args[0] + : base === "Literal" + ? (args[0] ?? ref("Any")) + : base === "Union" + ? unionFromRefs(args) + : ref(Object.hasOwn(NAME_MAP, base) ? (NAME_MAP[base] ?? base) : safeTypeName(base), args); + +const unionRef = (node: PythonNode, source: string) => + unionFromRefs( + namedChildrenOf(node) + .filter((child) => child.name !== "BitOp") + .map((child) => pythonTypeRef(child, source)) + ); + +const unionFromRefs = (refs: ResolvedTypeRef[]) => { + const values = refs.flatMap((item) => (item.name === "Union" ? item.args : [item])); + const nonUnit = values.filter((item) => item.name !== "Unit"); + const value = nonUnit.length === 1 ? nonUnit[0] : ref("Union", nonUnit); + return nonUnit.length < values.length && value !== undefined ? ref("Option", [value]) : (value ?? ref("Any")); +}; + +const callRef = (node: PythonNode, source: string) => { + const named = namedChildrenOf(node); + const callee = pythonTypeRef(named[0], source).name; + const args = named.slice(1).flatMap((child) => namedChildrenOf(child)); + const typeArg = callee === "NewType" ? args[1] : callee === "TypeAliasType" ? args[1] : undefined; + return typeArg === undefined ? ref("Any") : pythonTypeRef(typeArg, source); +}; + +const fallbackRef = (node: PythonNode, source: string) => { + const child = firstNamedChild(node); + return child === undefined ? nameRef(textOf(source, node)) : pythonTypeRef(child, source); +}; + +export const isClassVar = (node: PythonNode | undefined, source: string): boolean => { + if (node === undefined) { + return false; + } + if (node.name === "TypeDef") { + return isClassVar(firstNamedChild(node), source); + } + const first = node.name === "MemberExpression" ? childrenOf(node)[0] : node; + return first !== undefined && pythonTypeRef(first, source).name === "ClassVar"; +}; diff --git a/packages/typediagram/src/converters/typeshed.ts b/packages/typediagram/src/converters/typeshed.ts new file mode 100644 index 0000000..4d0e084 --- /dev/null +++ b/packages/typediagram/src/converters/typeshed.ts @@ -0,0 +1,98 @@ +// [TYPESHED-CONVERT] Bidirectional .pyi/typeDiagram converter. +import type { Model, ResolvedDecl, ResolvedFunctionSignature, ResolvedTypeRef } from "../model/types.js"; +import { walkDeclRefs } from "../model/types.js"; +import { visibleDeclsForTarget } from "../model/types.js"; +import type { Converter } from "./types.js"; +import { analyzeTypeshedSource, type TypeshedAnalysis } from "./typeshed-decls.js"; + +export interface TypeshedConverter extends Converter { + analyzeSource: typeof analyzeTypeshedSource; +} + +const TD_TO_PYI: Readonly> = { + Bool: "bool", + Int: "int", + Float: "float", + String: "str", + Bytes: "bytes", + Unit: "None", + List: "list", + Map: "dict", + Option: "Optional", +}; + +const printRef = (type: ResolvedTypeRef): string => { + const name = TD_TO_PYI[type.name] ?? type.name; + return type.args.length === 0 ? name : `${name}[${type.args.map(printRef).join(", ")}]`; +}; + +const generics = (decl: ResolvedDecl) => (decl.generics.length === 0 ? "" : `[${decl.generics.join(", ")}]`); + +const emitRecord = (decl: Extract) => { + const body = + decl.fields.length === 0 ? [" ..."] : decl.fields.map((field) => ` ${field.name}: ${printRef(field.type)}`); + return [`class ${decl.name}${generics(decl)}:`, ...body]; +}; + +const emitUnion = (decl: Extract) => [ + `class ${decl.name}(Enum):`, + ...(decl.variants.length === 0 ? [" ..."] : decl.variants.map((variant) => ` ${variant.name} = ...`)), +]; + +const printSignature = (name: string, signature: ResolvedFunctionSignature) => { + const params = signature.params.map((param) => `${param.name}: ${printRef(param.type)}`).join(", "); + return `${signature.async === true ? "async " : ""}def ${name}(${params}) -> ${printRef(signature.returns)}: ...`; +}; + +const emitFunction = (decl: Extract) => + decl.signatures.flatMap((signature) => [ + ...(decl.signatures.length > 1 ? ["@overload"] : []), + printSignature(decl.name, signature), + ]); + +const emitDecl = (decl: ResolvedDecl) => + decl.kind === "record" + ? emitRecord(decl) + : decl.kind === "union" + ? emitUnion(decl) + : decl.kind === "alias" + ? [`type ${decl.name}${generics(decl)} = ${printRef(decl.target)}`] + : emitFunction(decl); + +const importsFor = (decls: readonly ResolvedDecl[]) => { + const names = [ + ...(decls.some((decl) => decl.kind === "union") ? ["from enum import Enum"] : []), + ...(decls.some((decl) => decl.kind === "function" && decl.signatures.length > 1) + ? ["from typing import overload"] + : []), + ...(decls.some((decl) => usesType(decl, "Option")) ? ["from typing import Optional"] : []), + ]; + return names.length === 0 ? [] : [...names, ""]; +}; + +const usesType = (decl: ResolvedDecl, name: string) => { + let found = false; + const visit = (type: ResolvedTypeRef) => { + found = found || type.name === name; + type.args.forEach(visit); + }; + walkDeclRefs(decl, visit); + return found; +}; + +const toTypeshed = (model: Model) => { + const decls = visibleDeclsForTarget(model.decls, "typeshed"); + return [...importsFor(decls), ...decls.flatMap((decl) => [...emitDecl(decl), ""])].join("\n"); +}; + +export type { TypeshedAnalysis }; + +export const typeshed: TypeshedConverter = { + language: "typeshed", + analyzeSource: analyzeTypeshedSource, + fromSource: (source) => { + const analyzed = analyzeTypeshedSource(source); + return analyzed.ok ? { ok: true, value: analyzed.value.model } : analyzed; + }, + toSource: toTypeshed, +}; diff --git a/packages/typediagram/src/editor/controls.ts b/packages/typediagram/src/editor/controls.ts new file mode 100644 index 0000000..cc9f8cf --- /dev/null +++ b/packages/typediagram/src/editor/controls.ts @@ -0,0 +1,163 @@ +// [EDITOR-CONTROLS] Shared compact canvas controls, semantic legend, and inspector shell. +import type { DeclarationKind } from "./source-editor.js"; + +export type CanvasActions = { + addNode: (kind: DeclarationKind) => void; + zoomIn: () => void; + zoomOut: () => void; + reset: () => void; + fit: () => void; + clearLayout: () => void; + exportSvg: () => void; +}; + +export type CanvasChrome = { + inspector: HTMLElement; + inspectorBody: HTMLElement; + inspectorKind: HTMLElement; + toast: HTMLElement; + setZoom: (scale: number) => void; +}; + +const button = (label: string, title: string, action: () => void) => { + const element = document.createElement("button"); + element.type = "button"; + element.className = "td-canvas-button"; + element.textContent = label; + element.title = title; + element.setAttribute("aria-label", title); + element.setAttribute("data-td-interactive", "true"); + element.addEventListener("click", action); + return element; +}; + +const separator = () => { + const element = document.createElement("span"); + element.className = "td-canvas-separator"; + element.setAttribute("aria-hidden", "true"); + return element; +}; + +export const closeIcon = () => { + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + icon.setAttribute("viewBox", "0 0 16 16"); + icon.setAttribute("aria-hidden", "true"); + icon.setAttribute("focusable", "false"); + path.setAttribute("d", "M4 4l8 8M12 4l-8 8"); + icon.appendChild(path); + return icon; +}; + +const nodeKindButton = (kind: DeclarationKind, label: string, actions: CanvasActions, creator: HTMLElement) => { + const current = button(label, `Add ${kind} type`, () => { + actions.addNode(kind); + creator.hidden = true; + }); + current.classList.add("td-node-kind-button"); + return current; +}; + +const createNodeCreator = (container: HTMLElement, actions: CanvasActions) => { + const creator = document.createElement("div"); + creator.className = "td-node-creator"; + creator.hidden = true; + creator.setAttribute("data-td-interactive", "true"); + creator.setAttribute("aria-label", "Choose type kind"); + creator.append( + nodeKindButton("record", "Record", actions, creator), + nodeKindButton("union", "Union", actions, creator), + nodeKindButton("alias", "Alias", actions, creator) + ); + container.appendChild(creator); + return creator; +}; + +const createToolbar = (container: HTMLElement, actions: CanvasActions, creator: HTMLElement) => { + const toolbar = document.createElement("div"); + const zoom = button("100%", "Current zoom", () => undefined); + const addType = button("+ Type", "Add type", () => { + creator.hidden = creator.hidden === false; + }); + toolbar.className = "td-canvas-toolbar"; + toolbar.setAttribute("role", "toolbar"); + toolbar.setAttribute("aria-label", "Canvas controls"); + zoom.classList.add("td-zoom-value"); + addType.classList.add("td-add-node"); + toolbar.append( + addType, + separator(), + button("−", "Zoom out", actions.zoomOut), + zoom, + button("+", "Zoom in", actions.zoomIn), + separator(), + button("Fit", "Fit diagram to view", actions.fit), + button("1:1", "Reset canvas", actions.reset), + button("Auto", "Restore automatic layout", actions.clearLayout), + separator(), + button("SVG", "Export SVG", actions.exportSvg) + ); + container.appendChild(toolbar); + return zoom; +}; + +const legendItem = (label: string, modifier: string) => { + const item = document.createElement("span"); + const swatch = document.createElement("i"); + item.className = "td-legend-item"; + swatch.className = `td-legend-swatch ${modifier}`.trim(); + item.append(swatch, document.createTextNode(label)); + return item; +}; + +const createLegend = (container: HTMLElement) => { + const legend = document.createElement("div"); + legend.className = "td-canvas-legend"; + legend.setAttribute("aria-label", "Diagram legend"); + legend.append(legendItem("Type", ""), legendItem("Union", "td-legend-union"), legendItem("Alias", "td-legend-alias")); + container.appendChild(legend); +}; + +const createInspector = (container: HTMLElement) => { + const inspector = document.createElement("aside"); + const head = document.createElement("header"); + const kind = document.createElement("span"); + const close = button("×", "Close properties", () => (inspector.hidden = true)); + const body = document.createElement("div"); + inspector.className = "td-inspector"; + inspector.hidden = true; + inspector.setAttribute("data-td-interactive", "true"); + head.className = "td-inspector-head"; + kind.className = "td-inspector-kind"; + close.className = "td-icon-button td-inspector-close"; + close.replaceChildren(closeIcon()); + body.className = "td-inspector-body"; + head.append(kind, close); + inspector.append(head, body); + container.appendChild(inspector); + return { inspector, body, kind }; +}; + +const createToast = (container: HTMLElement) => { + const toast = document.createElement("output"); + toast.className = "td-editor-toast"; + toast.hidden = true; + toast.setAttribute("aria-live", "polite"); + container.appendChild(toast); + return toast; +}; + +export const createCanvasChrome = (container: HTMLElement, actions: CanvasActions): CanvasChrome => { + const creator = createNodeCreator(container, actions); + const zoom = createToolbar(container, actions, creator); + const { inspector, body, kind } = createInspector(container); + const toast = createToast(container); + createLegend(container); + return { + inspector, + inspectorBody: body, + inspectorKind: kind, + toast, + setZoom: (scale) => (zoom.textContent = `${String(Math.round(scale * 100))}%`), + }; +}; diff --git a/packages/typediagram/src/editor/effects.ts b/packages/typediagram/src/editor/effects.ts new file mode 100644 index 0000000..2729dac --- /dev/null +++ b/packages/typediagram/src/editor/effects.ts @@ -0,0 +1,19 @@ +// [EDITOR-EFFECTS] Explicit conditional effects without statement-level conditionals. +export const runWhen = (condition: boolean, effect: () => void) => { + switch (condition) { + case true: + effect(); + break; + default: + break; + } +}; + +export const runWhenDefined = (value: T | undefined, effect: (current: T) => void) => { + switch (value) { + case undefined: + break; + default: + effect(value); + } +}; diff --git a/packages/typediagram/src/editor/index.ts b/packages/typediagram/src/editor/index.ts new file mode 100644 index 0000000..0844ad8 --- /dev/null +++ b/packages/typediagram/src/editor/index.ts @@ -0,0 +1,15 @@ +export { + addDeclaration, + addRow, + connectDeclarations, + editRow, + removeDeclaration, + removeRow, + renameDeclaration, + type DeclarationKind, + type EditorFailure, + type RowPatch, +} from "./source-editor.js"; +export { createVisualEditor, type NodePosition, type VisualEditor, type VisualEditorOptions } from "./visual-editor.js"; +export { createViewport, setViewportContent, type ViewportControls, type ViewportState } from "./viewport.js"; +export { installVisualEditorStyles, VISUAL_EDITOR_CSS } from "./styles.js"; diff --git a/packages/typediagram/src/editor/inspector.ts b/packages/typediagram/src/editor/inspector.ts new file mode 100644 index 0000000..81b519b --- /dev/null +++ b/packages/typediagram/src/editor/inspector.ts @@ -0,0 +1,160 @@ +import type { Result } from "../result.js"; +import { + addRow, + editRow, + removeDeclaration, + removeRow, + renameDeclaration, + type EditorFailure, +} from "./source-editor.js"; +import { closeIcon, type CanvasChrome } from "./controls.js"; +import { runWhen } from "./effects.js"; +import type { VisualEditorOptions } from "./visual-editor.js"; + +export type EditorRow = { name: string; type: string }; +export type EditorNode = { name: string; kind: "record" | "union" | "alias"; rows: EditorRow[] }; +type RowContext = { + node: EditorNode; + getSource: () => string; + mutate: (result: Result) => void; +}; + +const showFailure = (chrome: CanvasChrome, failure: EditorFailure) => { + chrome.toast.textContent = failure.message; + chrome.toast.hidden = false; + globalThis.setTimeout(() => { + chrome.toast.hidden = true; + }, 2600); +}; + +export const applyMutation = ( + result: Result, + options: VisualEditorOptions, + chrome: CanvasChrome +) => { + switch (result.ok) { + case true: + options.onSourceChange(result.value); + break; + case false: + showFailure(chrome, result.error); + } +}; + +const field = (labelText: string, value: string, onChange: (value: string) => void) => { + const wrap = document.createElement("label"); + const label = document.createElement("span"); + const input = document.createElement("input"); + label.textContent = labelText; + input.value = value; + input.setAttribute("data-td-interactive", "true"); + input.addEventListener("change", () => { + onChange(input.value); + }); + wrap.append(label, input); + return wrap; +}; + +const removeButton = (action: () => void) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "td-icon-button td-inspector-remove"; + button.title = "Remove row"; + button.setAttribute("aria-label", "Remove row"); + button.setAttribute("data-td-interactive", "true"); + button.appendChild(closeIcon()); + button.addEventListener("click", action); + return button; +}; + +const removeControls = ( + node: EditorNode, + index: number, + getSource: () => string, + mutate: (result: Result) => void +) => + node.kind === "alias" + ? [] + : [ + removeButton(() => { + mutate(removeRow(getSource(), node.name, index)); + }), + ]; + +const inspectorRow = (row: EditorRow, index: number, context: RowContext) => { + const { node, getSource, mutate } = context; + const element = document.createElement("div"); + element.className = `td-inspector-row${node.kind === "union" ? " td-inspector-row--union" : ""}`; + element.append( + field(node.kind === "union" ? "Variant" : "Field", row.name, (name) => { + mutate(editRow(getSource(), node.name, index, { name })); + }), + field(node.kind === "union" ? "Payload" : "Type", row.type, (type) => { + mutate(editRow(getSource(), node.name, index, { type })); + }), + ...removeControls(node, index, getSource, mutate) + ); + return element; +}; + +const addButton = (action: () => void) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "td-inspector-add"; + button.textContent = "+ Add row"; + button.addEventListener("click", action); + return button; +}; + +const appendAddButton = ( + node: EditorNode, + chrome: CanvasChrome, + options: VisualEditorOptions, + mutate: (result: Result) => void +) => { + runWhen(node.kind !== "alias", () => { + chrome.inspectorBody.appendChild( + addButton(() => { + mutate(addRow(options.getSource(), node.name)); + }) + ); + }); +}; + +const appendDeleteButton = ( + node: EditorNode, + chrome: CanvasChrome, + options: VisualEditorOptions, + mutate: (result: Result) => void +) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "td-inspector-delete"; + button.textContent = "Delete type"; + button.setAttribute("aria-label", `Delete ${node.name}`); + button.addEventListener("click", () => { + const result = removeDeclaration(options.getSource(), node.name); + mutate(result); + runWhen(result.ok, () => { + chrome.inspector.hidden = true; + }); + }); + chrome.inspectorBody.appendChild(button); +}; + +export const renderInspector = (node: EditorNode, chrome: CanvasChrome, options: VisualEditorOptions) => { + const mutate = (result: Result) => { + applyMutation(result, options, chrome); + }; + const context = { node, getSource: options.getSource, mutate }; + chrome.inspectorKind.textContent = node.kind; + chrome.inspectorBody.replaceChildren( + field("Declaration", node.name, (name) => { + mutate(renameDeclaration(options.getSource(), node.name, name)); + }), + ...node.rows.map((row, index) => inspectorRow(row, index, context)) + ); + appendAddButton(node, chrome, options, mutate); + appendDeleteButton(node, chrome, options, mutate); + chrome.inspector.hidden = false; +}; diff --git a/packages/typediagram/src/editor/source-editor.ts b/packages/typediagram/src/editor/source-editor.ts new file mode 100644 index 0000000..eb14f1c --- /dev/null +++ b/packages/typediagram/src/editor/source-editor.ts @@ -0,0 +1,286 @@ +// [EDITOR-SOURCE] Model-backed source mutations used by every visual-editor host. +import { parse, formatDiagnostics } from "../parser/index.js"; +import type { TypeRef } from "../parser/ast.js"; +import { buildModel } from "../model/build.js"; +import { printSource } from "../model/print.js"; +import { walkDeclRefs, type Model, type ResolvedDecl, type ResolvedTypeRef } from "../model/types.js"; +import { err, ok } from "../result.js"; +import { runWhen, runWhenDefined } from "./effects.js"; + +export type EditorFailure = { message: string }; +export type RowPatch = { name?: string; type?: string }; +export type DeclarationKind = Exclude; + +const editorModel = (source: string) => { + const parsed = parse(source); + const built = parsed.ok ? buildModel(parsed.value) : undefined; + return !parsed.ok + ? err({ message: formatDiagnostics([...parsed.error]) }) + : built?.ok === true + ? ok(built.value) + : err({ message: formatDiagnostics([...(built?.error ?? [])]) }); +}; + +const modelOrFailure = (source: string) => { + const built = editorModel(source); + return built.ok ? ok(built.value) : err({ message: built.error.message }); +}; + +const findDecl = (model: Model, name: string) => model.decls.find((decl) => decl.name === name); + +const missingDecl = (name: string) => err({ message: `Unknown declaration '${name}'` }); + +const sourceResult = (model: Model, decl: ResolvedDecl | undefined, name: string) => { + const source = printSource(model); + const validated = modelOrFailure(source); + return decl === undefined ? missingDecl(name) : validated.ok ? ok(source) : validated; +}; + +const unresolvedRef = (ref: TypeRef): ResolvedTypeRef => ({ + name: ref.name, + args: ref.args.map(unresolvedRef), + resolution: { kind: "external" }, +}); + +const parseTypeRef = (source: string) => { + const parsed = parse(`typeDiagram\ntype __EditorProbe { value: ${source} }`); + const decl = parsed.ok ? parsed.value.decls[0] : undefined; + const field = decl?.kind === "record" ? decl.fields[0] : undefined; + return field === undefined + ? err({ + message: parsed.ok ? `Invalid type '${source}'` : formatDiagnostics([...parsed.error]), + }) + : ok(unresolvedRef(field.type)); +}; + +const renameRef = (ref: ResolvedTypeRef, before: string, after: string) => { + ref.name = ref.name === before ? after : ref.name; + ref.args.forEach((arg) => { + renameRef(arg, before, after); + }); + ref.resolution = + ref.resolution.kind === "declared" && ref.resolution.declName === before + ? { kind: "declared", declName: after } + : ref.resolution; +}; + +const visitRefs = (decl: ResolvedDecl, visit: (ref: ResolvedTypeRef) => void) => { + walkDeclRefs(decl, visit); +}; + +const uniqueName = (names: readonly string[], base: string) => + Array.from({ length: names.length + 1 }, (_, index) => (index === 0 ? base : `${base}${String(index + 1)}`)).find( + (name) => !names.includes(name) + ) ?? base; + +const stringRef = (): ResolvedTypeRef => ({ + name: "String", + args: [], + resolution: { kind: "primitive" }, +}); + +const newDeclaration = (model: Model, kind: DeclarationKind): ResolvedDecl => { + const names = model.decls.map((decl) => decl.name); + switch (kind) { + case "record": + return { + kind, + name: uniqueName(names, "NewRecord"), + generics: [], + fields: [{ name: "field", type: stringRef() }], + }; + case "union": + return { + kind, + name: uniqueName(names, "NewUnion"), + generics: [], + variants: [{ name: "Variant", fields: [] }], + }; + case "alias": + return { + kind, + name: uniqueName(names, "NewAlias"), + generics: [], + target: stringRef(), + }; + } +}; + +export const addDeclaration = (source: string, kind: DeclarationKind) => { + const result = modelOrFailure(source); + const model = result.ok ? result.value : undefined; + runWhenDefined(model, (current) => { + current.decls.push(newDeclaration(current, kind)); + }); + return result.ok ? ok(printSource(result.value)) : result; +}; + +export const removeDeclaration = (source: string, name: string) => { + const result = modelOrFailure(source); + const model = result.ok ? result.value : undefined; + const index = model?.decls.findIndex((decl) => decl.name === name) ?? -1; + runWhenDefined(model !== undefined && index >= 0 ? model : undefined, (current) => { + current.decls.splice(index, 1); + }); + return !result.ok ? result : index < 0 ? missingDecl(name) : ok(printSource(result.value)); +}; + +export const renameDeclaration = (source: string, before: string, after: string) => { + const result = modelOrFailure(source); + const decl = result.ok ? findDecl(result.value, before) : undefined; + const valid = after.trim().length > 0; + return !result.ok + ? result + : decl === undefined || !valid + ? missingDecl(before) + : renameInModel(result.value, decl, before, after.trim()); +}; + +const renameInModel = (model: Model, decl: ResolvedDecl, before: string, after: string) => { + decl.name = after; + model.decls.forEach((candidate) => { + visitRefs(candidate, (ref) => { + renameRef(ref, before, after); + }); + }); + return ok(printSource(model)); +}; + +const replaceRowType = (decl: ResolvedDecl, rowIndex: number, type: ResolvedTypeRef) => { + const row = decl.kind === "record" ? decl.fields[rowIndex] : undefined; + const variant = decl.kind === "union" ? decl.variants[rowIndex] : undefined; + runWhenDefined(row, (current) => { + current.type = type; + }); + runWhen(decl.kind === "alias", () => { + switch (decl.kind) { + case "alias": + decl.target = type; + break; + } + }); + runWhenDefined(variant, (current) => { + replaceVariantType(current, type); + }); +}; + +const replaceVariantType = ( + variant: Extract["variants"][number], + type: ResolvedTypeRef +) => { + switch (variant.fields.length) { + case 0: + variant.fields.push({ name: "_0", type }); + break; + default: + variant.fields[0] = { name: variant.fields[0]?.name ?? "_0", type }; + } +}; + +const renameRow = (decl: ResolvedDecl, rowIndex: number, name: string) => { + const row = decl.kind === "record" ? decl.fields[rowIndex] : undefined; + const variant = decl.kind === "union" ? decl.variants[rowIndex] : undefined; + runWhenDefined(row, (current) => { + current.name = name; + }); + runWhenDefined(variant, (current) => { + current.name = name; + }); +}; + +const applyRowPatch = (decl: ResolvedDecl, rowIndex: number, patch: RowPatch) => { + runWhenDefined(patch.name, (name) => { + renameRow(decl, rowIndex, name.trim()); + }); + return patch.type === undefined ? ok(undefined) : applyTypePatch(decl, rowIndex, patch.type); +}; + +const applyTypePatch = (decl: ResolvedDecl, rowIndex: number, typeSource: string) => { + const parsed = parseTypeRef(typeSource.trim()); + switch (parsed.ok) { + case true: + replaceRowType(decl, rowIndex, parsed.value); + break; + } + return parsed; +}; + +export const editRow = (source: string, declName: string, rowIndex: number, patch: RowPatch) => { + const result = modelOrFailure(source); + const decl = result.ok ? findDecl(result.value, declName) : undefined; + const edited = decl === undefined ? missingDecl(declName) : applyRowPatch(decl, rowIndex, patch); + return !result.ok ? result : !edited.ok ? edited : sourceResult(result.value, decl, declName); +}; + +const anyRef = (): ResolvedTypeRef => ({ name: "Any", args: [], resolution: { kind: "external" } }); + +const targetRef = (name: string, genericCount = 0): ResolvedTypeRef => ({ + name, + args: Array.from({ length: genericCount }, anyRef), + resolution: { kind: "declared", declName: name }, +}); + +const fieldNameFor = (target: string) => `${target.slice(0, 1).toLowerCase()}${target.slice(1)}`; + +const appendConnection = (decl: ResolvedDecl, target: string, genericCount: number) => { + const type = targetRef(target, genericCount); + switch (decl.kind) { + case "record": + decl.fields.push({ name: fieldNameFor(target), type }); + break; + case "union": + decl.variants.push({ name: target, fields: [{ name: "_0", type }] }); + break; + case "alias": + decl.target = type; + } +}; + +export const connectDeclarations = (source: string, from: string, rowIndex: number, target: string) => { + const result = modelOrFailure(source); + const decl = result.ok ? findDecl(result.value, from) : undefined; + const targetDecl = result.ok ? findDecl(result.value, target) : undefined; + const exists = decl !== undefined && targetDecl !== undefined; + const genericCount = targetDecl?.generics.length ?? 0; + runWhenDefined(exists && rowIndex < 0 ? decl : undefined, (current) => { + appendConnection(current, target, genericCount); + }); + runWhenDefined(exists && rowIndex >= 0 ? decl : undefined, (current) => { + replaceRowType(current, rowIndex, targetRef(target, genericCount)); + }); + return !result.ok ? result : exists ? ok(printSource(result.value)) : missingDecl(decl === undefined ? from : target); +}; + +const newRecordRow = (names: readonly string[]) => ({ name: uniqueName(names, "field"), type: stringRef() }); + +const newUnionVariant = (names: readonly string[]) => ({ name: uniqueName(names, "Variant"), fields: [] }); + +export const addRow = (source: string, declName: string) => { + const result = modelOrFailure(source); + const decl = result.ok ? findDecl(result.value, declName) : undefined; + runWhenDefined(decl, (current) => { + switch (current.kind) { + case "record": + current.fields.push(newRecordRow(current.fields.map((field) => field.name))); + break; + case "union": + current.variants.push(newUnionVariant(current.variants.map((variant) => variant.name))); + } + }); + return !result.ok ? result : sourceResult(result.value, decl, declName); +}; + +export const removeRow = (source: string, declName: string, rowIndex: number) => { + const result = modelOrFailure(source); + const decl = result.ok ? findDecl(result.value, declName) : undefined; + runWhenDefined(decl, (current) => { + switch (current.kind) { + case "record": + current.fields.splice(rowIndex, 1); + break; + case "union": + current.variants.splice(rowIndex, 1); + } + }); + return !result.ok ? result : sourceResult(result.value, decl, declName); +}; diff --git a/packages/typediagram/src/editor/styles.ts b/packages/typediagram/src/editor/styles.ts new file mode 100644 index 0000000..b5860f6 --- /dev/null +++ b/packages/typediagram/src/editor/styles.ts @@ -0,0 +1,42 @@ +// [EDITOR-DESIGN] Shared Architectural Blueprint canvas styles for web + VS Code. +import { runWhen } from "./effects.js"; +export const VISUAL_EDITOR_CSS = ` +.td-visual-editor{--td-bg:#0b1326;--td-low:#131b2e;--td-high:#222a3d;--td-highest:#2d3449;--td-bright:#31394d;--td-text:#dae2fd;--td-muted:#87929a;--td-primary:#8ed5ff;--td-secondary:#ddb7ff;--td-tertiary:#45e3ce;position:relative;overflow:hidden;background-color:var(--td-bg);background-image:linear-gradient(rgba(142,213,255,.032) 1px,transparent 1px),linear-gradient(90deg,rgba(142,213,255,.032) 1px,transparent 1px),radial-gradient(circle,rgba(142,213,255,.12) 1px,transparent 1.5px);background-size:40px 40px,40px 40px,8px 8px;color:var(--td-text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;touch-action:none;cursor:grab} +.td-visual-editor.td-is-panning{cursor:grabbing} +.td-visual-editor .viewport-wrapper{position:absolute;inset:0 auto auto 0;will-change:transform} +.td-visual-editor svg{display:block;max-width:none;overflow:visible;user-select:none} +.td-visual-editor [data-decl]{cursor:move;filter:url(#td-ambient-shadow);transition:filter 140ms ease} +.td-visual-editor [data-decl]:hover{filter:url(#td-hover-shadow)} +.td-visual-editor [data-decl].td-selected>rect:first-of-type{stroke:var(--td-primary);stroke-width:1.5;stroke-opacity:.6} +.td-visual-editor .td-port{fill:var(--td-highest);stroke:var(--td-primary);stroke-width:1.5;opacity:0;cursor:crosshair;transition:opacity 120ms ease,fill 120ms ease,r 120ms ease} +.td-visual-editor [data-decl]:hover>.td-port,.td-visual-editor [data-decl].td-selected>.td-port{opacity:1} +.td-visual-editor .td-port:hover{fill:var(--td-primary);r:6} +.td-visual-editor .td-target-port{stroke:var(--td-tertiary)} +.td-visual-editor .td-connection-preview{fill:none;stroke:var(--td-tertiary);stroke-width:2;stroke-dasharray:5 4;pointer-events:none} +.td-canvas-toolbar{position:absolute;z-index:8;left:18px;bottom:18px;display:flex;align-items:center;gap:3px;padding:5px;background:rgba(34,42,61,.78);backdrop-filter:blur(12px);box-shadow:0 20px 40px rgba(0,0,0,.4);border-radius:6px} +.td-canvas-button{height:34px;min-width:34px;padding:0 9px;border:0;border-radius:3px;background:transparent;color:var(--td-text);font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer} +.td-canvas-button:hover,.td-canvas-button[aria-pressed=true]{background:var(--td-bright);color:var(--td-primary)} +.td-canvas-button:focus-visible,.td-icon-button:focus-visible,.td-node-kind-button:focus-visible,.td-inspector-delete:focus-visible{outline:0;border-bottom:2px solid var(--td-primary)} +.td-zoom-value{min-width:50px;color:var(--td-muted);pointer-events:none} +.td-canvas-separator{width:1px;height:20px;margin:0 3px;background:rgba(135,146,154,.25)} +.td-node-creator{position:absolute;z-index:9;left:18px;bottom:64px;display:grid;grid-template-columns:repeat(3,1fr);gap:4px;padding:6px;background:rgba(34,42,61,.92);backdrop-filter:blur(12px);box-shadow:0 20px 40px rgba(0,0,0,.4);border-radius:4px}.td-node-creator[hidden]{display:none}.td-node-kind-button{height:36px;padding:0 13px;border:0;border-radius:3px;background:var(--td-highest);color:var(--td-text);font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}.td-node-kind-button:hover{background:var(--td-bright);color:var(--td-primary)} +.td-canvas-legend{position:absolute;z-index:7;right:18px;bottom:18px;display:flex;gap:14px;padding:9px 12px;background:rgba(34,42,61,.72);backdrop-filter:blur(12px);box-shadow:0 20px 40px rgba(0,0,0,.32);border-radius:3px;color:var(--td-muted);font-size:10px;letter-spacing:.06em;text-transform:uppercase} +.td-legend-item{display:flex;align-items:center;gap:6px}.td-legend-swatch{width:3px;height:13px;background:var(--td-primary)}.td-legend-union{background:var(--td-secondary)}.td-legend-alias{background:var(--td-tertiary)} +.td-inspector{position:absolute;z-index:10;top:18px;right:18px;width:min(320px,calc(100% - 36px));max-height:calc(100% - 92px);overflow:auto;padding:16px;background:rgba(34,42,61,.88);backdrop-filter:blur(12px);box-shadow:0 20px 40px rgba(0,0,0,.4);border-radius:3px;color:var(--td-text)} +.td-inspector[hidden]{display:none}.td-inspector-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}.td-inspector-kind{color:var(--td-primary);font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase}.td-icon-button{display:inline-grid;place-items:center;width:32px;height:32px;padding:0;border:1px solid rgba(135,146,154,.22);border-radius:4px;background:rgba(19,27,46,.72);color:var(--td-muted);cursor:pointer;transition:background 120ms ease,color 120ms ease,border-color 120ms ease}.td-icon-button svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round}.td-icon-button:hover{border-color:rgba(142,213,255,.38);background:var(--td-bright);color:var(--td-text)}.td-inspector-close{flex:0 0 auto} +.td-inspector label{display:block;margin:10px 0 5px;color:var(--td-muted);font-size:10px;letter-spacing:.08em;text-transform:uppercase}.td-inspector input{box-sizing:border-box;width:100%;height:34px;padding:0 9px;border:0;border-bottom:2px solid transparent;border-radius:2px;background:#131b2e;color:var(--td-text);font:12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:0}.td-inspector input:focus{border-bottom-color:var(--td-primary)} +.td-inspector-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.2fr) 34px;gap:7px;align-items:end}.td-inspector-row--union{grid-template-columns:minmax(0,1fr) minmax(0,1.2fr) 34px}.td-inspector-remove{width:34px;height:34px}.td-inspector-remove:hover{border-color:rgba(255,180,171,.38);color:#ffb4ab}.td-inspector-add{width:100%;height:34px;margin-top:12px;border:0;border-radius:3px;background:#174966;color:var(--td-primary);font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}.td-inspector-add:hover{background:#205c7d}.td-inspector-delete{width:100%;height:34px;margin-top:8px;border:0;border-radius:3px;background:rgba(255,180,171,.08);color:#ffb4ab;font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}.td-inspector-delete:hover{background:rgba(255,180,171,.16)} +.td-inspector-row label{margin-bottom:0} +.td-editor-toast{position:absolute;z-index:12;top:18px;left:50%;transform:translateX(-50%);padding:9px 12px;border-radius:3px;background:rgba(49,57,77,.9);box-shadow:0 14px 32px rgba(0,0,0,.32);color:#ffb4ab;font-size:11px;pointer-events:none} +@media(max-width:700px){.td-canvas-legend{display:none}.td-inspector{top:10px;right:10px}.td-canvas-toolbar{left:10px;bottom:10px}.td-node-creator{left:10px;bottom:56px}.td-canvas-button{min-width:32px;padding:0 7px}} +`; + +export const installVisualEditorStyles = (doc: Document = document) => { + const current = doc.querySelector("style[data-td-visual-editor]"); + const style = current ?? doc.createElement("style"); + style.setAttribute("data-td-visual-editor", "true"); + style.textContent = VISUAL_EDITOR_CSS; + runWhen(current === null, () => { + doc.head.appendChild(style); + }); +}; diff --git a/packages/typediagram/src/editor/viewport.ts b/packages/typediagram/src/editor/viewport.ts new file mode 100644 index 0000000..b89e7c7 --- /dev/null +++ b/packages/typediagram/src/editor/viewport.ts @@ -0,0 +1,163 @@ +// [EDITOR-VIEWPORT] Shared infinite-canvas pan, zoom, fit, and content surface. +import { runWhen, runWhenDefined } from "./effects.js"; + +export type ViewportState = { scale: number; translateX: number; translateY: number }; +export type ViewportControls = ViewportState & { + wrapper: HTMLElement; + reset: () => void; + zoomIn: () => void; + zoomOut: () => void; + fit: () => void; +}; + +const ZOOM_MIN = 0.1; +const ZOOM_MAX = 5; +const ZOOM_FACTOR = 1.12; +const FIT_PADDING = 56; +const viewports = new WeakMap(); + +const clamp = (value: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, value)); + +const renderTransform = (wrapper: HTMLElement, state: ViewportState) => { + wrapper.style.transform = `translate(${String(state.translateX)}px, ${String(state.translateY)}px) scale(${String(state.scale)})`; + wrapper.dispatchEvent(new CustomEvent("td:viewport", { detail: { ...state } })); +}; + +const zoomAt = (state: ViewportState, wrapper: HTMLElement, x: number, y: number, scale: number) => { + const ratio = scale / state.scale; + state.translateX = x - ratio * (x - state.translateX); + state.translateY = y - ratio * (y - state.translateY); + state.scale = scale; + renderTransform(wrapper, state); +}; + +const fitScale = (cw: number, ch: number, sw: number, sh: number) => { + const hasSize = cw > 0 && ch > 0 && sw > 0 && sh > 0; + return hasSize ? Math.min((cw - FIT_PADDING * 2) / sw, (ch - FIT_PADDING * 2) / sh, 2) : 1; +}; + +const fitSvg = (container: HTMLElement, wrapper: HTMLElement, state: ViewportState) => { + const svg = wrapper.querySelector("svg"); + const hasSvg = svg instanceof SVGSVGElement; + const sw = hasSvg ? svg.width.baseVal.value : 0; + const sh = hasSvg ? svg.height.baseVal.value : 0; + state.scale = hasSvg ? fitScale(container.clientWidth, container.clientHeight, sw, sh) : 1; + state.translateX = hasSvg ? (container.clientWidth - sw * state.scale) / 2 : 0; + state.translateY = hasSvg ? (container.clientHeight - sh * state.scale) / 2 : 0; + renderTransform(wrapper, state); +}; + +const interactiveTarget = (target: EventTarget | null) => + target instanceof Element && target.closest("[data-td-interactive], [data-decl], a, button, input") !== null; + +const wheelFactor = (deltaY: number) => { + const magnitude = Math.min(Math.abs(deltaY), 100) / 100; + return ZOOM_FACTOR ** (deltaY > 0 ? -magnitude : magnitude); +}; + +const installWheel = (container: HTMLElement, state: ViewportState, wrapper: HTMLElement) => { + container.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const rect = container.getBoundingClientRect(); + zoomAt( + state, + wrapper, + event.clientX - rect.left, + event.clientY - rect.top, + clamp(state.scale * wheelFactor(event.deltaY)) + ); + }, + { passive: false } + ); +}; + +const installPan = (container: HTMLElement, state: ViewportState, wrapper: HTMLElement) => { + let start: { x: number; y: number; tx: number; ty: number } | undefined; + container.addEventListener("pointerdown", (event) => { + start = interactiveTarget(event.target) + ? undefined + : { x: event.clientX, y: event.clientY, tx: state.translateX, ty: state.translateY }; + runWhenDefined(start, () => { + container.setPointerCapture(event.pointerId); + }); + container.classList.toggle("td-is-panning", start !== undefined); + }); + container.addEventListener("pointermove", (event) => { + runWhenDefined(start, (current) => { + state.translateX = current.tx + event.clientX - current.x; + state.translateY = current.ty + event.clientY - current.y; + renderTransform(wrapper, state); + }); + }); + const stop = () => { + start = undefined; + container.classList.remove("td-is-panning"); + }; + container.addEventListener("pointerup", stop); + container.addEventListener("pointercancel", stop); +}; + +const makeWrapper = (container: HTMLElement) => { + const existing = container.querySelector(":scope > .viewport-wrapper"); + const wrapper = existing ?? document.createElement("div"); + wrapper.className = "viewport-wrapper"; + wrapper.style.transformOrigin = "0 0"; + runWhen(existing === null, () => { + container.appendChild(wrapper); + }); + return wrapper; +}; + +export const createViewport = (container: HTMLElement): ViewportControls => { + const state: ViewportState = { scale: 1, translateX: 0, translateY: 0 }; + const wrapper = makeWrapper(container); + const center = () => ({ x: container.clientWidth / 2, y: container.clientHeight / 2 }); + const reset = () => { + Object.assign(state, { scale: 1, translateX: 0, translateY: 0 }); + renderTransform(wrapper, state); + }; + const zoom = (factor: number) => { + zoomAt(state, wrapper, center().x, center().y, clamp(state.scale * factor)); + }; + installWheel(container, state, wrapper); + installPan(container, state, wrapper); + container.style.cursor = "grab"; + const controls: ViewportControls = { + wrapper, + reset, + zoomIn: () => { + zoom(ZOOM_FACTOR); + }, + zoomOut: () => { + zoom(1 / ZOOM_FACTOR); + }, + fit: () => { + fitSvg(container, wrapper, state); + }, + get scale() { + return state.scale; + }, + get translateX() { + return state.translateX; + }, + get translateY() { + return state.translateY; + }, + }; + viewports.set(container, controls); + return controls; +}; + +export const setViewportContent = (container: HTMLElement, html: string) => { + const wrapper = container.querySelector(":scope > .viewport-wrapper"); + const target = wrapper ?? container; + const shouldFit = wrapper?.dataset.fitted !== "true"; + target.innerHTML = html; + runWhen(wrapper !== null && shouldFit, () => { + wrapper?.setAttribute("data-fitted", "true"); + viewports.get(container)?.fit(); + }); + return shouldFit; +}; diff --git a/packages/typediagram/src/editor/visual-editor.ts b/packages/typediagram/src/editor/visual-editor.ts new file mode 100644 index 0000000..3b823cb --- /dev/null +++ b/packages/typediagram/src/editor/visual-editor.ts @@ -0,0 +1,496 @@ +// [EDITOR-CANVAS] Direct manipulation for rendered typeDiagram SVGs. +import { parse } from "../parser/index.js"; +import { buildModel } from "../model/build.js"; +import type { ResolvedDataDecl, ResolvedTypeRef } from "../model/types.js"; +import { addDeclaration, connectDeclarations } from "./source-editor.js"; +import { createCanvasChrome, type CanvasChrome } from "./controls.js"; +import { installVisualEditorStyles } from "./styles.js"; +import { createViewport, setViewportContent, type ViewportControls } from "./viewport.js"; +import { runWhen, runWhenDefined } from "./effects.js"; +import { applyMutation, renderInspector, type EditorNode, type EditorRow } from "./inspector.js"; + +export type NodePosition = { x: number; y: number }; +export type VisualEditorOptions = { + getSource: () => string; + onSourceChange: (source: string) => void; + initialPositions?: Readonly>; + onPositionsChange?: (positions: Readonly>) => void; +}; +export type VisualEditor = ViewportControls & { setContent: (html: string) => void; refresh: () => void }; + +type DragState = { name: string; startX: number; startY: number; origin: NodePosition; moved: boolean }; +type ConnectState = { name: string; rowIndex: number; path: SVGPathElement }; +type EditorState = { + selected: string | undefined; + drag: DragState | undefined; + connect: ConnectState | undefined; + suppressClick: boolean; + positions: Map; + polylines: WeakMap>; +}; +type EditorContext = { + state: EditorState; + options: VisualEditorOptions; + chrome: CanvasChrome; + viewport: ViewportControls; +}; +type NodeContext = EditorContext & { svg: SVGSVGElement }; +type TrackingContext = NodeContext; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const snap = (value: number) => Math.round(value / 8) * 8; +const refText = (ref: ResolvedTypeRef): string => + ref.args.length === 0 ? ref.name : `${ref.name}<${ref.args.map(refText).join(", ")}>`; + +const declRows = (decl: ResolvedDataDecl): EditorRow[] => { + switch (decl.kind) { + case "record": + return decl.fields.map((field) => ({ name: field.name, type: refText(field.type) })); + case "union": + return decl.variants.map((variant) => ({ + name: variant.name, + type: variant.fields[0]?.type ? refText(variant.fields[0].type) : "", + })); + case "alias": + return [{ name: "target", type: refText(decl.target) }]; + } +}; + +const inspectNode = (source: string, name: string): EditorNode | undefined => { + const parsed = parse(source); + const built = parsed.ok ? buildModel(parsed.value) : undefined; + const decl = built?.ok === true ? built.value.decls.find((candidate) => candidate.name === name) : undefined; + return decl === undefined || decl.kind === "function" + ? undefined + : { name: decl.name, kind: decl.kind, rows: declRows(decl) }; +}; + +const positionRecord = (positions: Map) => Object.fromEntries(positions.entries()); + +const applyNodePosition = (node: SVGGElement, position: NodePosition) => { + node.setAttribute("transform", `translate(${String(position.x)} ${String(position.y)})`); + node.dataset.editorX = String(position.x); + node.dataset.editorY = String(position.y); +}; + +const basePoints = (polyline: SVGPolylineElement) => + Array.from(polyline.points).map((point) => ({ x: point.x, y: point.y })); + +const shiftedPoints = (points: NodePosition[], source: NodePosition, target: NodePosition) => + points.map((point, index) => { + const offset = + index < Math.min(2, points.length / 2) ? source : index >= points.length - 2 ? target : { x: 0, y: 0 }; + return { x: point.x + offset.x, y: point.y + offset.y }; + }); + +const updateEdge = (edge: SVGGElement, state: EditorState) => { + const polyline = edge.querySelector("polyline"); + const line = polyline instanceof SVGPolylineElement ? polyline : undefined; + const source = state.positions.get(edge.dataset.source ?? "") ?? { x: 0, y: 0 }; + const target = state.positions.get(edge.dataset.target ?? "") ?? { x: 0, y: 0 }; + runWhenDefined(line, (current) => { + const original = state.polylines.get(current) ?? basePoints(current); + state.polylines.set(current, original); + current.setAttribute( + "points", + shiftedPoints(original, source, target) + .map((point) => `${String(point.x)},${String(point.y)}`) + .join(" ") + ); + }); +}; + +const updateEdges = (svg: SVGSVGElement, state: EditorState) => { + svg.querySelectorAll("g[data-edge]").forEach((edge) => { + updateEdge(edge, state); + }); +}; + +const circle = (x: number, y: number, className: string, rowIndex: number) => { + const port = document.createElementNS(SVG_NS, "circle"); + port.setAttribute("cx", String(x)); + port.setAttribute("cy", String(y)); + port.setAttribute("r", "4.5"); + port.setAttribute("class", `td-port ${className}`.trim()); + port.setAttribute("data-td-interactive", "true"); + port.dataset.rowIndex = String(rowIndex); + return port; +}; + +const numberData = (element: SVGGElement, key: string) => Number(element.dataset[key] ?? "0"); + +const decoratePorts = (node: SVGGElement) => { + const x = numberData(node, "x"); + const y = numberData(node, "y"); + const width = numberData(node, "width"); + const height = numberData(node, "height"); + const declarationPort = Array.from( + node.ownerSVGElement?.querySelectorAll("g[data-decl] > text") ?? [] + ).some((text) => text.textContent.includes("<")) + ? [circle(x + width, y + 16, "td-source-port", -1)] + : []; + node.querySelectorAll(":scope > .td-port").forEach((port) => { + port.remove(); + }); + node.append(circle(x, y + height / 2, "td-target-port", -2), ...declarationPort); + node.querySelectorAll("g[data-row-index]").forEach((row) => { + const rowY = numberData(row, "rowY"); + const rowHeight = numberData(row, "rowHeight"); + node.append(circle(x + width, rowY + rowHeight / 2, "td-source-port", Number(row.dataset.rowIndex ?? "-1"))); + }); +}; + +const svgPoint = (svg: SVGSVGElement, clientX: number, clientY: number) => { + const point = svg.createSVGPoint(); + point.x = clientX; + point.y = clientY; + const matrix = svg.getScreenCTM()?.inverse(); + const local = matrix === undefined ? point : point.matrixTransform(matrix); + return { x: local.x, y: local.y }; +}; + +const previewPath = (svg: SVGSVGElement, start: NodePosition) => { + const path = document.createElementNS(SVG_NS, "path"); + path.classList.add("td-connection-preview"); + path.dataset.startX = String(start.x); + path.dataset.startY = String(start.y); + svg.appendChild(path); + return path; +}; + +const curve = (start: NodePosition, end: NodePosition) => { + const bend = Math.max(48, Math.abs(end.x - start.x) * 0.45); + return `M ${String(start.x)} ${String(start.y)} C ${String(start.x + bend)} ${String(start.y)}, ${String(end.x - bend)} ${String(end.y)}, ${String(end.x)} ${String(end.y)}`; +}; + +const startConnection = (event: PointerEvent, node: SVGGElement, svg: SVGSVGElement, state: EditorState) => { + const target = event.target instanceof SVGCircleElement ? event.target : undefined; + const sourcePort = target?.classList.contains("td-source-port") === true; + const start = sourcePort ? svgPoint(svg, event.clientX, event.clientY) : undefined; + state.connect = + start === undefined + ? undefined + : { + name: node.dataset.decl ?? "", + rowIndex: Number(target?.dataset.rowIndex ?? "-1"), + path: previewPath(svg, start), + }; + runWhen(sourcePort, () => { + event.preventDefault(); + event.stopPropagation(); + }); + return sourcePort; +}; + +const selectNode = (svg: SVGSVGElement, node: SVGGElement, state: EditorState) => { + state.selected = node.dataset.decl; + svg + .querySelectorAll("[data-decl]") + .forEach((candidate) => candidate.classList.toggle("td-selected", candidate === node)); +}; + +const startNodeDrag = (event: PointerEvent, node: SVGGElement, state: EditorState) => { + const name = node.dataset.decl ?? ""; + state.drag = { + name, + startX: event.clientX, + startY: event.clientY, + origin: state.positions.get(name) ?? { x: 0, y: 0 }, + moved: false, + }; + event.stopPropagation(); +}; + +const focusEditor = (svg: SVGSVGElement) => { + const editor = svg.closest(".td-visual-editor") ?? undefined; + runWhenDefined(editor, (current) => { + current.focus(); + }); +}; + +const handleNodePointerDown = (event: PointerEvent, node: SVGGElement, context: NodeContext) => { + const { svg, state, chrome } = context; + const connected = startConnection(event, node, svg, state); + selectNode(svg, node, state); + runWhen(!connected, () => { + startNodeDrag(event, node, state); + }); + chrome.inspector.hidden = true; +}; + +const handleNodeClick = (event: MouseEvent, node: SVGGElement, context: NodeContext) => { + const { state, options, chrome } = context; + const port = event.target instanceof Element && event.target.closest(".td-port") !== null; + const suppressed = state.suppressClick || state.drag?.moved === true; + const name = node.dataset.decl ?? ""; + const detail = port || suppressed ? undefined : inspectNode(options.getSource(), name); + runWhenDefined(detail, (current) => { + renderInspector(current, chrome, options); + }); + focusEditor(context.svg); + state.drag = undefined; + state.suppressClick = false; +}; + +const installNode = (node: SVGGElement, context: NodeContext) => { + const name = node.dataset.decl ?? ""; + applyNodePosition(node, context.state.positions.get(name) ?? { x: 0, y: 0 }); + decoratePorts(node); + node.addEventListener("pointerdown", (event) => { + handleNodePointerDown(event, node, context); + }); + node.addEventListener("pointerup", (event) => { + finishPointerGesture(event, context); + }); + node.addEventListener("click", (event) => { + handleNodeClick(event, node, context); + }); +}; + +const moveDrag = (event: PointerEvent, svg: SVGSVGElement, state: EditorState, viewport: ViewportControls) => { + const drag = state.drag; + runWhenDefined(drag, (current) => { + const node = svg.querySelector(`[data-decl="${CSS.escape(current.name)}"]`) ?? undefined; + const position = { + x: snap(current.origin.x + (event.clientX - current.startX) / viewport.scale), + y: snap(current.origin.y + (event.clientY - current.startY) / viewport.scale), + }; + current.moved = + current.moved || Math.abs(event.clientX - current.startX) > 2 || Math.abs(event.clientY - current.startY) > 2; + runWhenDefined(node, (element) => { + applyDragPosition(element, position, current.name, svg, state); + }); + }); +}; + +const applyDragPosition = ( + node: SVGGElement, + position: NodePosition, + name: string, + svg: SVGSVGElement, + state: EditorState +) => { + state.positions.set(name, position); + applyNodePosition(node, position); + updateEdges(svg, state); +}; + +const moveConnection = (event: PointerEvent, svg: SVGSVGElement, state: EditorState) => { + const connection = state.connect; + runWhenDefined(connection, (current) => { + const start = { x: Number(current.path.dataset.startX), y: Number(current.path.dataset.startY) }; + const end = svgPoint(svg, event.clientX, event.clientY); + current.path.setAttribute("d", curve(start, end)); + }); +}; + +const finishConnection = ( + event: PointerEvent, + state: EditorState, + options: VisualEditorOptions, + chrome: CanvasChrome +) => { + const connection = state.connect; + const eventTarget = event.target instanceof Element ? event.target.closest("[data-decl]") : null; + const target = + eventTarget ?? document.elementFromPoint(event.clientX, event.clientY)?.closest("[data-decl]"); + const targetName = target?.dataset.decl; + runWhenDefined(connection, (current) => { + state.selected = undefined; + chrome.inspector.hidden = true; + const destination = targetName === current.name ? undefined : targetName; + runWhenDefined(destination, (name) => { + applyMutation(connectDeclarations(options.getSource(), current.name, current.rowIndex, name), options, chrome); + }); + }); + connection?.path.remove(); + state.connect = undefined; +}; + +const finishPointerGesture = (event: PointerEvent, context: TrackingContext) => { + const { state, options, chrome } = context; + const drag = state.drag; + runWhen(drag?.moved === true, () => { + options.onPositionsChange?.(positionRecord(state.positions)); + focusEditor(context.svg); + }); + const detail = drag?.moved === false ? inspectNode(options.getSource(), drag.name) : undefined; + runWhenDefined(detail, (current) => { + renderInspector(current, chrome, options); + }); + runWhen(drag?.moved === true, () => { + state.suppressClick = true; + }); + runWhen(drag?.moved === false, () => { + state.suppressClick = false; + }); + state.drag = undefined; + finishConnection(event, state, options, chrome); +}; + +const cancelPointerGesture = (state: EditorState) => { + state.connect?.path.remove(); + state.connect = undefined; + state.drag = undefined; +}; + +const installPointerTracking = (container: HTMLElement, context: TrackingContext) => { + const { svg, state, viewport } = context; + svg.addEventListener("pointermove", (event) => { + moveDrag(event, svg, state, viewport); + moveConnection(event, svg, state); + }); + svg.addEventListener("pointerup", (event) => { + finishPointerGesture(event, context); + }); + container.addEventListener("pointercancel", () => { + cancelPointerGesture(state); + }); +}; + +const exportSvg = (wrapper: HTMLElement) => { + const svg = wrapper.querySelector("svg"); + const url = + svg instanceof SVGSVGElement + ? URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(svg)], { type: "image/svg+xml" })) + : undefined; + const anchor = url === undefined ? undefined : document.createElement("a"); + runWhenDefined(anchor, (current) => { + Object.assign(current, { href: url, download: "type-diagram.svg", hidden: true }); + document.body.append(current); + }); + anchor?.click(); + anchor?.remove(); + runWhenDefined(url, (current) => { + URL.revokeObjectURL(current); + }); +}; + +const installKeys = (container: HTMLElement, viewport: ViewportControls, chrome: CanvasChrome) => { + container.tabIndex = 0; + container.ownerDocument.addEventListener("keydown", (event) => { + const editing = event.target instanceof HTMLInputElement; + const inside = event.target instanceof Element && container.contains(event.target); + runWhen(inside && !editing && event.key === "+", viewport.zoomIn); + runWhen(inside && !editing && event.key === "-", viewport.zoomOut); + runWhen(inside && !editing && event.key === "0", viewport.reset); + runWhen(inside && !editing && event.key.toLowerCase() === "f", viewport.fit); + runWhen(event.key === "Escape", () => { + chrome.inspector.hidden = true; + }); + }); +}; + +const createEditorState = (options: VisualEditorOptions): EditorState => ({ + selected: undefined, + drag: undefined, + connect: undefined, + suppressClick: false, + positions: new Map(Object.entries(options.initialPositions ?? {})), + polylines: new WeakMap(), +}); + +const trackSource = (options: VisualEditorOptions) => { + let source = options.getSource(); + return { + options: { + ...options, + getSource: () => source, + onSourceChange: (next: string) => { + source = next; + options.onSourceChange(next); + }, + }, + sync: () => { + source = options.getSource(); + }, + }; +}; + +const installSvg = (svg: SVGSVGElement, context: EditorContext, container: HTMLElement) => { + const tracking = { ...context, svg }; + svg.querySelectorAll("g[data-decl]").forEach((node) => { + installNode(node, tracking); + }); + updateEdges(svg, context.state); + installPointerTracking(container, tracking); +}; + +const refreshEditor = (container: HTMLElement, context: EditorContext) => { + const { viewport } = context; + const svg = viewport.wrapper.querySelector("svg") ?? undefined; + runWhenDefined(svg, (current) => { + installSvg(current, context, container); + }); + const { selected } = context.state; + const detail = selected === undefined ? undefined : inspectNode(context.options.getSource(), selected); + runWhenDefined(detail, (current) => { + renderInspector(current, context.chrome, context.options); + }); +}; + +const clearLayout = (state: EditorState, options: VisualEditorOptions, viewport: ViewportControls) => { + state.positions.clear(); + options.onPositionsChange?.({}); + const svg = viewport.wrapper.querySelector("svg"); + svg?.querySelectorAll("g[data-decl]").forEach((node) => { + applyNodePosition(node, { x: 0, y: 0 }); + }); + runWhenDefined(svg ?? undefined, (current) => { + updateEdges(current, state); + }); +}; + +const createEditorChrome = ( + container: HTMLElement, + viewport: ViewportControls, + state: EditorState, + options: VisualEditorOptions +) => { + const chrome = createCanvasChrome(container, { + addNode: (kind) => { + runWhenDefined(chrome, (current) => { + applyMutation(addDeclaration(options.getSource(), kind), options, current); + }); + }, + ...viewport, + clearLayout: () => { + clearLayout(state, options, viewport); + }, + exportSvg: () => { + exportSvg(viewport.wrapper); + }, + }); + return chrome; +}; + +const installEditorEvents = (container: HTMLElement, viewport: ViewportControls, chrome: CanvasChrome) => { + viewport.wrapper.addEventListener("td:viewport", () => { + chrome.setZoom(viewport.scale); + }); + installKeys(container, viewport, chrome); +}; + +const editorRefresh = (container: HTMLElement, context: EditorContext) => () => { + refreshEditor(container, context); +}; + +export const createVisualEditor = (container: HTMLElement, options: VisualEditorOptions) => { + installVisualEditorStyles(container.ownerDocument); + container.classList.add("td-visual-editor"); + const tracked = trackSource(options); + const viewport = createViewport(container); + const state = createEditorState(tracked.options); + const chrome = createEditorChrome(container, viewport, state, tracked.options); + const context: EditorContext = { state, options: tracked.options, chrome, viewport }; + const refresh = editorRefresh(container, context); + installEditorEvents(container, viewport, chrome); + return Object.assign(viewport, { + refresh, + setContent: (html: string) => { + tracked.sync(); + setViewportContent(container, html); + refresh(); + }, + }); +}; diff --git a/packages/typediagram/src/layout/elk.ts b/packages/typediagram/src/layout/elk.ts index 12099b2..45611b7 100644 --- a/packages/typediagram/src/layout/elk.ts +++ b/packages/typediagram/src/layout/elk.ts @@ -6,6 +6,7 @@ import { type Edge, type Model, type ResolvedDecl, + type ResolvedFunctionSignature, type ResolvedTypeRef, } from "../model/types.js"; import { formatVariantName } from "../variant.js"; @@ -96,10 +97,15 @@ function printRefShort(t: ResolvedTypeRef): string { function declHeader(d: ResolvedDecl): string { const generics = d.generics.length === 0 ? "" : `<${d.generics.join(", ")}>`; - const tag = d.kind === "record" ? "" : d.kind === "union" ? "union " : "alias "; + const tag = d.kind === "record" ? "" : d.kind === "union" ? "union " : d.kind === "alias" ? "alias " : "function "; return `${tag}${d.name}${generics}`; } +function functionRow(signature: ResolvedFunctionSignature): string { + const params = signature.params.map((param) => rowText(param.name, param.type)).join(", "); + return `${signature.async === true ? "async " : ""}(${params}) → ${printRefShort(signature.returns)}`; +} + function buildPreNodes(decls: ResolvedDecl[], fontSize: number, padX: number, padY: number): PreNode[] { const rowH = fontSize * 1.4 + padY * 0.5; const headerH = fontSize * 1.4 + HEADER_PAD_Y * 2; @@ -139,7 +145,7 @@ function buildPreNodes(decls: ResolvedDecl[], fontSize: number, padX: number, pa rows.push({ text: variantHeader, y, height: rowH }); y += rowH; } - } else { + } else if (d.kind === "alias") { const text = `= ${printRefShort(d.target)}`; const m = measureText(text, fontSize); if (m.w > widest) { @@ -147,6 +153,14 @@ function buildPreNodes(decls: ResolvedDecl[], fontSize: number, padX: number, pa } rows.push({ text, y, height: rowH }); y += rowH; + } else { + for (const signature of d.signatures) { + const text = functionRow(signature); + const m = measureText(text, fontSize); + widest = Math.max(widest, m.w); + rows.push({ text, y, height: rowH }); + y += rowH; + } } const width = Math.ceil(widest + padX * 2); diff --git a/packages/typediagram/src/layout/types.ts b/packages/typediagram/src/layout/types.ts index 086cb12..5740439 100644 --- a/packages/typediagram/src/layout/types.ts +++ b/packages/typediagram/src/layout/types.ts @@ -4,7 +4,7 @@ export interface NodeBox { /** Decl name. */ declName: string; /** Decl kind for renderer dispatch. */ - declKind: "record" | "union" | "alias"; + declKind: "record" | "union" | "alias" | "function"; /** Pixel position (top-left). */ x: number; y: number; @@ -35,7 +35,7 @@ export interface EdgeRoute { points: Array<{ x: number; y: number }>; /** Display label, may be empty. */ label: string; - kind: "field" | "variantPayload" | "genericArg"; + kind: "field" | "variantPayload" | "genericArg" | "parameter" | "return"; } export interface LaidOutGraph { diff --git a/packages/typediagram/src/model/build.ts b/packages/typediagram/src/model/build.ts index 26de6ac..e038628 100644 --- a/packages/typediagram/src/model/build.ts +++ b/packages/typediagram/src/model/build.ts @@ -1,4 +1,15 @@ -import type { AliasDecl, Declaration, Diagram, Field, RecordDecl, TypeRef, UnionDecl, Variant } from "../parser/ast.js"; +import type { + AliasDecl, + Declaration, + Diagram, + Field, + FunctionDecl, + FunctionSignature, + 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"; @@ -9,6 +20,8 @@ import { type ResolvedAlias, type ResolvedDecl, type ResolvedField, + type ResolvedFunction, + type ResolvedFunctionSignature, type ResolvedRecord, type ResolvedRefKind, type ResolvedTypeRef, @@ -71,7 +84,40 @@ function resolveDecl( if (d.kind === "union") { return resolveUnion(d, declMap, externals, generics, bag); } - return resolveAlias(d, declMap, externals, generics, bag); + return d.kind === "alias" + ? resolveAlias(d, declMap, externals, generics, bag) + : resolveFunction(d, declMap, externals, generics, bag); +} + +function resolveFunction( + d: FunctionDecl, + declMap: Map, + externals: Set, + generics: Set, + bag: DiagnosticBag +): ResolvedFunction { + return { + kind: "function", + name: d.name, + generics: [...d.generics], + signatures: d.signatures.map((signature) => resolveSignature(signature, d.name, declMap, externals, generics, bag)), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), + }; +} + +function resolveSignature( + signature: FunctionSignature, + ownerName: string, + declMap: Map, + externals: Set, + generics: Set, + bag: DiagnosticBag +): ResolvedFunctionSignature { + return { + params: signature.params.map((param) => resolveField(param, ownerName, declMap, externals, generics, bag)), + returns: resolveTypeRef(signature.returns, ownerName, declMap, externals, generics, bag), + ...(signature.async === true ? { async: true as const } : {}), + }; } function resolveRecord( diff --git a/packages/typediagram/src/model/builder.ts b/packages/typediagram/src/model/builder.ts index d546048..af4f967 100644 --- a/packages/typediagram/src/model/builder.ts +++ b/packages/typediagram/src/model/builder.ts @@ -9,6 +9,8 @@ import { type ResolvedAlias, type ResolvedDecl, type ResolvedField, + type ResolvedFunction, + type ResolvedFunctionSignature, type ResolvedRecord, type ResolvedRefKind, type ResolvedTypeRef, @@ -31,6 +33,12 @@ export interface UnionSpec { untagged?: boolean; } +export interface FunctionSignatureSpec { + params: FieldSpec[]; + returns: ResolvedTypeRef; + async?: boolean; +} + /** Build a TypeRef. Resolution is deferred to validate(). */ export function ref(name: string, args: ResolvedTypeRef[] = []): ResolvedTypeRef { return { name, args, resolution: { kind: "external" } }; @@ -54,6 +62,22 @@ export function alias(name: string, target: ResolvedTypeRef, generics: string[] return { kind: "alias", name, generics, target }; } +export function functionDecl( + name: string, + signatures: FunctionSignatureSpec[], + generics: string[] = [] +): ResolvedFunction { + return { kind: "function", name, generics, signatures: signatures.map(toFunctionSignature) }; +} + +function toFunctionSignature(signature: FunctionSignatureSpec): ResolvedFunctionSignature { + return { + params: signature.params.map(toField), + returns: signature.returns, + ...(signature.async === true ? { async: true as const } : {}), + }; +} + function toField(f: FieldSpec): ResolvedField { return { name: f.name, type: f.type }; } @@ -146,7 +170,17 @@ export function resolveResolutions(model: Model): Model { ), }; } - return { ...d, target: fixRef(d.target, generics, d.name) }; + if (d.kind === "alias") { + return { ...d, target: fixRef(d.target, generics, d.name) }; + } + return { + ...d, + signatures: d.signatures.map((signature) => ({ + ...signature, + params: signature.params.map((param) => ({ ...param, type: fixRef(param.type, generics, d.name) })), + returns: fixRef(signature.returns, generics, d.name), + })), + }; }); // rebuild edges from the resolved decls diff --git a/packages/typediagram/src/model/edges.ts b/packages/typediagram/src/model/edges.ts index a434777..fcf5ea3 100644 --- a/packages/typediagram/src/model/edges.ts +++ b/packages/typediagram/src/model/edges.ts @@ -1,4 +1,12 @@ -import type { Edge, ResolvedAlias, ResolvedDecl, ResolvedRecord, ResolvedTypeRef, ResolvedUnion } from "./types.js"; +import type { + Edge, + ResolvedAlias, + ResolvedDecl, + ResolvedFunction, + ResolvedRecord, + ResolvedTypeRef, + ResolvedUnion, +} from "./types.js"; interface DeclaredRef { declName: string; @@ -67,8 +75,38 @@ function aliasEdges(d: ResolvedAlias): Edge[] { })); } +function functionEdges(d: ResolvedFunction): Edge[] { + return d.signatures.flatMap((signature, row) => [ + ...signature.params.flatMap((param) => functionRefEdges(d.name, row, param.name, "parameter", param.type)), + ...functionRefEdges(d.name, row, "return", "return", signature.returns), + ]); +} + +function functionRefEdges( + sourceDeclName: string, + sourceRowIndex: number, + label: string, + kind: "parameter" | "return", + type: ResolvedTypeRef +): Edge[] { + return [...walkDeclaredRefs(type)].map((ref) => ({ + sourceDeclName, + sourceRowIndex, + sourceVariantFieldIndex: null, + targetDeclName: ref.declName, + label, + kind: ref.isHead ? kind : ("genericArg" as const), + })); +} + function declEdges(d: ResolvedDecl): Edge[] { - return d.kind === "record" ? recordEdges(d) : d.kind === "union" ? unionEdges(d) : aliasEdges(d); + return d.kind === "record" + ? recordEdges(d) + : d.kind === "union" + ? unionEdges(d) + : d.kind === "alias" + ? aliasEdges(d) + : functionEdges(d); } /** Collect the deduplicated edge set for a resolved decl list. Shared by build.ts and builder.ts. */ diff --git a/packages/typediagram/src/model/index.ts b/packages/typediagram/src/model/index.ts index 59068f7..a4f9401 100644 --- a/packages/typediagram/src/model/index.ts +++ b/packages/typediagram/src/model/index.ts @@ -1,11 +1,13 @@ export { buildModel, buildModelPartial } from "./build.js"; -export { ModelBuilder, alias, record, ref, resolveResolutions, union } from "./builder.js"; -export type { FieldSpec, UnionSpec, VariantSpec } from "./builder.js"; +export { ModelBuilder, alias, functionDecl, record, ref, resolveResolutions, union } from "./builder.js"; +export type { FieldSpec, FunctionSignatureSpec, UnionSpec, VariantSpec } from "./builder.js"; export { fromJSON, toJSON, SCHEMA_VERSION } from "./json.js"; export type { AliasJson, DeclJson, FieldJson, + FunctionJson, + FunctionSignatureJson, ModelJson, RecordJson, TypeRefJson, @@ -25,7 +27,10 @@ export { type Model, type ResolvedAlias, type ResolvedDecl, + type ResolvedDataDecl, type ResolvedField, + type ResolvedFunction, + type ResolvedFunctionSignature, type ResolvedRecord, type ResolvedRefKind, type ResolvedTypeRef, diff --git a/packages/typediagram/src/model/json.ts b/packages/typediagram/src/model/json.ts index e2cbe0e..26d6414 100644 --- a/packages/typediagram/src/model/json.ts +++ b/packages/typediagram/src/model/json.ts @@ -8,6 +8,8 @@ import type { ResolvedAlias, ResolvedDecl, ResolvedField, + ResolvedFunction, + ResolvedFunctionSignature, ResolvedRecord, ResolvedTypeRef, ResolvedUnion, @@ -21,7 +23,7 @@ export interface ModelJson { decls: DeclJson[]; } -export type DeclJson = RecordJson | UnionJson | AliasJson; +export type DeclJson = RecordJson | UnionJson | AliasJson | FunctionJson; /** [MODEL-JSON-SHAPE] JSON decls mirror the resolved model minus every `resolution` * field: strip it recursively from the type refs and reuse the resolved shapes. */ @@ -31,6 +33,11 @@ export type VariantJson = Omit & { fields: FieldJson[ export type RecordJson = Omit & { fields: FieldJson[] }; export type UnionJson = Omit & { variants: VariantJson[] }; export type AliasJson = Omit & { target: TypeRefJson }; +export type FunctionSignatureJson = Omit & { + params: FieldJson[]; + returns: TypeRefJson; +}; +export type FunctionJson = Omit & { signatures: FunctionSignatureJson[] }; export function toJSON(model: Model): ModelJson { return { @@ -67,13 +74,25 @@ function declToJson(d: ResolvedDecl): DeclJson { ), }; } - return { - kind: "alias", - name: d.name, - generics: [...d.generics], - target: refToJson(d.target), - ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), - }; + return d.kind === "alias" + ? { + kind: "alias", + name: d.name, + generics: [...d.generics], + target: refToJson(d.target), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), + } + : { + kind: "function", + name: d.name, + generics: [...d.generics], + signatures: d.signatures.map((signature) => ({ + params: signature.params.map((param) => ({ name: param.name, type: refToJson(param.type) })), + returns: refToJson(signature.returns), + ...(signature.async === true ? { async: true as const } : {}), + })), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), + }; } function refToJson(t: ResolvedTypeRef): TypeRefJson { @@ -120,7 +139,7 @@ function declFromJson(d: unknown): Result { if (typeof d !== "object" || d === null) { return fail("decl must be an object"); } - const x = d as Partial & Record; + const x = d as Partial & Record; if (typeof x.name !== "string") { return fail("decl.name must be a string"); } @@ -185,6 +204,22 @@ function declFromJson(d: unknown): Result { ...(targeting.value === undefined ? {} : { targeting: targeting.value }), }); } + if (x.kind === "function") { + if (!Array.isArray(x.signatures)) { + return fail("function.signatures must be an array"); + } + const targeting = targetingFromJson(x.targeting); + if (!targeting.ok) { + return targeting; + } + return ok({ + kind: "function", + name: x.name, + generics: x.generics, + signatures: x.signatures.map(signatureFromJson), + ...(targeting.value === undefined ? {} : { targeting: targeting.value }), + }); + } return fail(`unknown decl kind '${String(x.kind)}'`); } @@ -221,6 +256,14 @@ function fieldFromJson(f: FieldJson): { name: string; type: ResolvedTypeRef } { return { name: f.name, type: refFromJson(f.type) }; } +function signatureFromJson(signature: FunctionSignatureJson): ResolvedFunctionSignature { + return { + params: signature.params.map(fieldFromJson), + returns: refFromJson(signature.returns), + ...(signature.async === true ? { async: true as const } : {}), + }; +} + function refFromJson(t: TypeRefJson): ResolvedTypeRef { return { name: t.name, diff --git a/packages/typediagram/src/model/print.ts b/packages/typediagram/src/model/print.ts index df9842a..818fad3 100644 --- a/packages/typediagram/src/model/print.ts +++ b/packages/typediagram/src/model/print.ts @@ -1,4 +1,10 @@ -import { isTupleVariantFields, type Model, type ResolvedDecl, type ResolvedTypeRef } from "./types.js"; +import { + isTupleVariantFields, + type Model, + type ResolvedDecl, + type ResolvedFunctionSignature, + type ResolvedTypeRef, +} from "./types.js"; import { formatVariantName } from "../variant.js"; export function printSource(model: Model): string { @@ -53,7 +59,19 @@ function printDecl(d: ResolvedDecl): string { .join("\n"); return `${d.untagged === true ? "untagged union" : "union"} ${d.name}${generics} {\n${variants}\n}`; } - return `alias ${d.name}${generics} = ${printRef(d.target)}`; + if (d.kind === "alias") { + return `alias ${d.name}${generics} = ${printRef(d.target)}`; + } + const signatures = d.signatures.map(printSignature); + const async = signatures[0]?.startsWith("async ") === true ? "async " : ""; + return signatures.length === 1 + ? `${async}function ${d.name}${generics}${signatures[0]?.replace(/^async /, "") ?? ""}` + : `function ${d.name}${generics} {\n${signatures.map((signature) => ` ${signature}`).join("\n")}\n}`; +} + +function printSignature(signature: ResolvedFunctionSignature): string { + const params = signature.params.map((param) => `${param.name}: ${printRef(param.type)}`).join(", "); + return `${signature.async === true ? "async " : ""}(${params}) -> ${printRef(signature.returns)}`; } function printRef(t: ResolvedTypeRef): string { diff --git a/packages/typediagram/src/model/types.ts b/packages/typediagram/src/model/types.ts index 570c7fb..b5cb551 100644 --- a/packages/typediagram/src/model/types.ts +++ b/packages/typediagram/src/model/types.ts @@ -21,7 +21,8 @@ export interface Model { externals: string[]; } -export type ResolvedDecl = ResolvedRecord | ResolvedUnion | ResolvedAlias; +export type ResolvedDecl = ResolvedRecord | ResolvedUnion | ResolvedAlias | ResolvedFunction; +export type ResolvedDataDecl = Exclude; export interface DeclTargeting { targets?: string[]; @@ -53,6 +54,21 @@ export interface ResolvedAlias { targeting?: DeclTargeting; } +/** [DSL-FUNCTION] A free function; overloads share one diagram node. */ +export interface ResolvedFunction { + kind: "function"; + name: string; + generics: string[]; + signatures: ResolvedFunctionSignature[]; + targeting?: DeclTargeting; +} + +export interface ResolvedFunctionSignature { + params: ResolvedField[]; + returns: ResolvedTypeRef; + async?: true; +} + export interface ResolvedField { name: string; type: ResolvedTypeRef; @@ -82,7 +98,7 @@ export type ResolvedRefKind = | { kind: "typeParam"; owner: string } | { kind: "external" }; -export type EdgeKind = "field" | "variantPayload" | "genericArg"; +export type EdgeKind = "field" | "variantPayload" | "genericArg" | "parameter" | "return"; export interface Edge { /** Decl that owns the source row. */ @@ -113,6 +129,11 @@ export function visibleDeclsForTarget( return decls.filter((decl) => shouldEmitDeclToTarget(decl, target)); } +/** Data-only target emitters intentionally omit free-function service contracts. */ +export function visibleDataDeclsForTarget(decls: readonly ResolvedDecl[], target: string): ResolvedDataDecl[] { + return visibleDeclsForTarget(decls, target).filter((decl): decl is ResolvedDataDecl => decl.kind !== "function"); +} + /** Visit every type ref in a decl, recursing into nested generic args. */ export function walkDeclRefs(d: ResolvedDecl, visit: (t: ResolvedTypeRef) => void): void { if (d.kind === "record") { @@ -125,8 +146,15 @@ export function walkDeclRefs(d: ResolvedDecl, visit: (t: ResolvedTypeRef) => voi walkRef(f.type, visit); } } - } else { + } else if (d.kind === "alias") { walkRef(d.target, visit); + } else { + for (const signature of d.signatures) { + for (const param of signature.params) { + walkRef(param.type, visit); + } + walkRef(signature.returns, visit); + } } } diff --git a/packages/typediagram/src/parser/ast.ts b/packages/typediagram/src/parser/ast.ts index b3c4d08..7a275d9 100644 --- a/packages/typediagram/src/parser/ast.ts +++ b/packages/typediagram/src/parser/ast.ts @@ -10,7 +10,7 @@ export interface Diagram { span: Span; } -export type Declaration = RecordDecl | UnionDecl | AliasDecl; +export type Declaration = RecordDecl | UnionDecl | AliasDecl | FunctionDecl; export interface DeclTargeting { targets?: string[]; @@ -45,6 +45,23 @@ export interface AliasDecl { span: Span; } +/** [DSL-FUNCTION] A named free function with one or more overload signatures. */ +export interface FunctionDecl { + kind: "function"; + name: string; + generics: string[]; + signatures: FunctionSignature[]; + targeting?: DeclTargeting; + span: Span; +} + +export interface FunctionSignature { + params: Field[]; + returns: TypeRef; + async?: true; + span: Span; +} + export interface Field { name: string; type: TypeRef; diff --git a/packages/typediagram/src/parser/function.ts b/packages/typediagram/src/parser/function.ts new file mode 100644 index 0000000..df40a42 --- /dev/null +++ b/packages/typediagram/src/parser/function.ts @@ -0,0 +1,109 @@ +// [DSL-FUNCTION] Parser for free-function signatures and overload blocks. +import type { DeclTargeting, Field, FunctionDecl, FunctionSignature } from "./ast.js"; +import type { DiagnosticBag } from "./diagnostics.js"; +import type { Token } from "./lexer.js"; +import { + expectToken, + parseCommaSeparated, + parseGenericParams, + parseTypeRef, + spanBetween, + type TokenCursor, +} from "./parse-common.js"; + +interface FunctionHead { + start: Token; + name: Token; + generics: string[]; + async: boolean; +} + +export const parseFunction = ( + cur: TokenCursor, + diags: DiagnosticBag, + targeting?: DeclTargeting +): FunctionDecl | null => { + const head = parseHead(cur, diags); + if (head === null) { + return null; + } + const signatures = cur.peek().kind === "LBrace" ? parseOverloads(cur, diags) : oneSignature(cur, diags, head.async); + return signatures === null ? null : makeFunction(head, signatures, targeting, cur.peek()); +}; + +const parseHead = (cur: TokenCursor, diags: DiagnosticBag): FunctionHead | null => { + const start = cur.peek(); + const async = cur.eat("AsyncKw") !== null; + const keyword = expectToken(cur, diags, "FunctionKw", "'function'"); + const name = expectToken(cur, diags, "Ident", "function name"); + return keyword === null || name === null ? null : { start, name, generics: parseGenericParams(cur, diags), async }; +}; + +const oneSignature = (cur: TokenCursor, diags: DiagnosticBag, async: boolean) => { + const signature = parseSignature(cur, diags, async); + return signature === null ? null : [signature]; +}; + +const parseOverloads = (cur: TokenCursor, diags: DiagnosticBag) => { + cur.next(); + const signatures: FunctionSignature[] = []; + cur.eatNewlines(); + while (cur.peek().kind !== "RBrace" && cur.peek().kind !== "EOF") { + const async = cur.eat("AsyncKw") !== null; + const signature = parseSignature(cur, diags, async); + switch (signature) { + case null: + recoverSignature(cur); + break; + default: + signatures.push(signature); + } + cur.eat("Comma"); + cur.eatNewlines(); + } + expectToken(cur, diags, "RBrace", "'}'"); + return signatures; +}; + +const parseSignature = (cur: TokenCursor, diags: DiagnosticBag, async: boolean): FunctionSignature | null => { + const start = expectToken(cur, diags, "LParen", "'('"); + if (start === null) { + return null; + } + const params = parseCommaSeparated(cur, "RParen", () => parseParam(cur, diags)); + const close = expectToken(cur, diags, "RParen", "')'"); + const arrow = expectToken(cur, diags, "Arrow", "'->'"); + const returns = parseTypeRef(cur, diags); + return close === null || arrow === null || returns === null + ? null + : { params, returns, ...(async ? { async: true as const } : {}), span: spanBetween(start, cur.peek()) }; +}; + +const parseParam = (cur: TokenCursor, diags: DiagnosticBag): Field | null => { + const name = expectToken(cur, diags, "Ident", "parameter name"); + const colon = expectToken(cur, diags, "Colon", "':'"); + const type = parseTypeRef(cur, diags); + return name === null || colon === null || type === null + ? null + : { name: name.value, type, span: spanBetween(name, cur.peek()) }; +}; + +const recoverSignature = (cur: TokenCursor) => { + while (!["Newline", "RBrace", "EOF"].includes(cur.peek().kind)) { + cur.next(); + } +}; + +const makeFunction = ( + head: FunctionHead, + signatures: FunctionSignature[], + targeting: DeclTargeting | undefined, + end: Token +): FunctionDecl => ({ + kind: "function", + name: head.name.value, + generics: head.generics, + signatures, + ...(targeting === undefined ? {} : { targeting }), + span: spanBetween(head.start, end), +}); diff --git a/packages/typediagram/src/parser/lexer.ts b/packages/typediagram/src/parser/lexer.ts index 1ac5885..8bb7a81 100644 --- a/packages/typediagram/src/parser/lexer.ts +++ b/packages/typediagram/src/parser/lexer.ts @@ -5,6 +5,8 @@ export type TokenKind = | "UnionKw" | "UntaggedKw" | "AliasKw" + | "FunctionKw" + | "AsyncKw" | "TypeDiagramKw" | "Ident" | "Number" @@ -18,6 +20,7 @@ export type TokenKind = | "Comma" | "Colon" | "Equals" + | "Arrow" | "Newline" | "EOF"; @@ -35,6 +38,8 @@ const KEYWORDS: Record = { union: "UnionKw", untagged: "UntaggedKw", alias: "AliasKw", + function: "FunctionKw", + async: "AsyncKw", typeDiagram: "TypeDiagramKw", }; @@ -122,13 +127,21 @@ export function tokenize(source: string, diagnostics: DiagnosticBag): Token[] { end++; } const value = source.slice(i, end); - const kind = KEYWORDS[value] ?? "Ident"; + const keyword = KEYWORDS[value]; + const kind = typeof keyword === "string" ? keyword : "Ident"; emit(kind, value, startLine, startCol, startOffset); col += end - i; i = end; continue; } + if (c === "-" && source.charAt(i + 1) === ">") { + emit("Arrow", "->", line, col, i); + i += 2; + col += 2; + continue; + } + if (isDigit(c) || (c === "-" && isDigit(source.charAt(i + 1)))) { const startLine = line; const startCol = col; diff --git a/packages/typediagram/src/parser/parse-common.ts b/packages/typediagram/src/parser/parse-common.ts new file mode 100644 index 0000000..6a733b3 --- /dev/null +++ b/packages/typediagram/src/parser/parse-common.ts @@ -0,0 +1,72 @@ +// [PARSER-COMMON] Shared token parsers used by data and function declarations. +import type { Span, TypeRef } from "./ast.js"; +import type { DiagnosticBag } from "./diagnostics.js"; +import type { Token, TokenKind } from "./lexer.js"; + +export interface TokenCursor { + peek(offset?: number): Token; + next(): Token; + eat(kind: TokenKind): Token | null; + eatNewlines(): void; +} + +export const expectToken = (cur: TokenCursor, diags: DiagnosticBag, kind: TokenKind, what: string) => { + const token = cur.peek(); + return token.kind === kind + ? cur.next() + : (diags.error(`expected ${what}, got ${describeToken(token)}`, token.line, token.col, token.length || 1), null); +}; + +export const parseCommaSeparated = (cur: TokenCursor, closer: TokenKind, parseItem: () => T | null) => { + const items: T[] = []; + while (cur.peek().kind !== closer && cur.peek().kind !== "EOF") { + const item = parseItem(); + items.push(...(item === null ? [] : [item])); + if (item === null || cur.peek().kind !== "Comma") { + break; + } + cur.next(); + cur.eatNewlines(); + } + return items; +}; + +export const parseGenericParams = (cur: TokenCursor, diags: DiagnosticBag) => { + if (cur.peek().kind !== "LAngle") { + return []; + } + cur.next(); + const names = parseCommaSeparated( + cur, + "RAngle", + () => expectToken(cur, diags, "Ident", "generic parameter name")?.value ?? null + ); + expectToken(cur, diags, "RAngle", "'>'"); + return names; +}; + +export const parseTypeRef = (cur: TokenCursor, diags: DiagnosticBag): TypeRef | null => { + const name = expectToken(cur, diags, "Ident", "type name"); + if (name === null) { + return null; + } + const args = cur.peek().kind === "LAngle" ? parseTypeArgs(cur, diags) : []; + return { name: name.value, args, span: spanBetween(name, cur.peek()) }; +}; + +const parseTypeArgs = (cur: TokenCursor, diags: DiagnosticBag) => { + cur.next(); + const args = parseCommaSeparated(cur, "RAngle", () => parseTypeRef(cur, diags)); + expectToken(cur, diags, "RAngle", "'>'"); + return args; +}; + +export const spanBetween = (start: Token, end: Token): Span => ({ + line: start.line, + col: start.col, + offset: start.offset, + length: Math.max(0, end.offset + end.length - start.offset), +}); + +export const describeToken = (token: Token) => + token.kind === "EOF" ? "end of input" : token.kind === "Newline" ? "newline" : `${token.kind} "${token.value}"`; diff --git a/packages/typediagram/src/parser/parser.ts b/packages/typediagram/src/parser/parser.ts index 06e24ea..f30f30a 100644 --- a/packages/typediagram/src/parser/parser.ts +++ b/packages/typediagram/src/parser/parser.ts @@ -5,7 +5,6 @@ import type { Diagram, Field, RecordDecl, - Span, TypeRef, UnionDecl, Variant, @@ -16,6 +15,15 @@ import { tokenize } from "./lexer.js"; import { type Result, err, ok } from "../result.js"; import type { Diagnostic } from "./diagnostics.js"; import { withDiscriminant } from "../variant.js"; +import { parseFunction } from "./function.js"; +import { + describeToken, + expectToken, + parseCommaSeparated, + parseGenericParams, + parseTypeRef, + spanBetween, +} from "./parse-common.js"; /** The keyword, name token, and generic parameters shared by every decl form. */ interface DeclHeader { @@ -105,14 +113,20 @@ class Parser { if (t.kind === "UntaggedKw") { const next = this.cur.peek(1); if (next.kind !== "UnionKw") { - return this.errorAndRecover(`expected 'union' after 'untagged', got ${describe(next)}`, next); + return this.errorAndRecover(`expected 'union' after 'untagged', got ${describeToken(next)}`, next); } return this.parseUnion(true, targeting); } if (t.kind === "AliasKw") { return this.parseAlias(targeting); } - return this.errorAndRecover(`expected 'type', 'union', 'untagged union', or 'alias', got ${describe(t)}`, t); + if (t.kind === "FunctionKw" || t.kind === "AsyncKw") { + return parseFunction(this.cur, this.diags, targeting); + } + return this.errorAndRecover( + `expected 'type', 'union', 'untagged union', 'alias', or 'function', got ${describeToken(t)}`, + t + ); } /** Emit an error diagnostic anchored at `tok`, recover to the next decl, return null. */ @@ -229,33 +243,11 @@ class Parser { } private parseCommaSeparated(closer: TokenKind, parseItem: () => T | null): T[] { - const items: T[] = []; - while (this.cur.peek().kind !== closer && this.cur.peek().kind !== "EOF") { - const item = parseItem(); - if (item === null) { - break; - } - items.push(item); - if (this.cur.peek().kind === "Comma") { - this.cur.next(); - } else { - break; - } - } - return items; + return parseCommaSeparated(this.cur, closer, parseItem); } private parseGenericParams(): string[] { - if (this.cur.peek().kind !== "LAngle") { - return []; - } - this.cur.next(); - const names = this.parseCommaSeparated( - "RAngle", - () => this.expect("Ident", "generic parameter name")?.value ?? null - ); - this.expect("RAngle", "'>'"); - return names; + return parseGenericParams(this.cur, this.diags); } private parseBraceList(parseItem: () => T | null): T[] { @@ -367,30 +359,11 @@ class Parser { } private parseTypeRef(): TypeRef | null { - const nameTok = this.expect("Ident", "type name"); - if (nameTok === null) { - return null; - } - let args: TypeRef[] = []; - if (this.cur.peek().kind === "LAngle") { - this.cur.next(); - args = this.parseCommaSeparated("RAngle", () => this.parseTypeRef()); - this.expect("RAngle", "'>'"); - } - return { - name: nameTok.value, - args, - span: spanBetween(nameTok, this.cur.peek()), - }; + return parseTypeRef(this.cur, this.diags); } private expect(kind: TokenKind, what: string): Token | null { - const t = this.cur.peek(); - if (t.kind === kind) { - return this.cur.next(); - } - this.diags.error(`expected ${what}, got ${describe(t)}`, t.line, t.col, t.length || 1); - return null; + return expectToken(this.cur, this.diags, kind, what); } private eatSeparator(): void { @@ -426,7 +399,10 @@ class Parser { return; } depth--; - } else if (depth === 0 && (t.kind === "TypeKw" || t.kind === "UnionKw" || t.kind === "AliasKw")) { + } else if ( + depth === 0 && + (t.kind === "TypeKw" || t.kind === "UnionKw" || t.kind === "AliasKw" || t.kind === "FunctionKw") + ) { return; } this.cur.next(); @@ -434,25 +410,6 @@ class Parser { } } -function spanBetween(a: Token, b: Token): Span { - return { - line: a.line, - col: a.col, - offset: a.offset, - length: Math.max(0, b.offset + b.length - a.offset), - }; -} - -function describe(t: Token): string { - if (t.kind === "EOF") { - return "end of input"; - } - if (t.kind === "Newline") { - return "newline"; - } - return `${t.kind} "${t.value}"`; -} - export function parsePartial(source: string): { ast: Diagram; diagnostics: Diagnostic[] } { const bag = new DiagnosticBag(); const tokens = tokenize(source, bag); diff --git a/packages/typediagram/src/render-svg/render.ts b/packages/typediagram/src/render-svg/render.ts index 40e4132..d4abb33 100644 --- a/packages/typediagram/src/render-svg/render.ts +++ b/packages/typediagram/src/render-svg/render.ts @@ -49,8 +49,8 @@ export function renderSvg(graph: LaidOutGraph, opts: SvgOpts = {}): string { const body = svg` ${defs} ${background} -${nodes} ${edges} +${nodes} `; return applyPostHook(body, ctx).value; } @@ -59,10 +59,8 @@ ${edges} function renderBackgroundLine(ctx: RenderCtx): SafeSvg { const arg: BackgroundCtx = { ...baseCtx(ctx), width: ctx.width, height: ctx.height }; const out = invokeSimpleHook(ctx.hooks?.background, arg, "background", ctx.hooks?.onError); - if (out === undefined) { - return raw(""); - } - return svg`\n${out}`; + const background = svg``; + return out === undefined ? svg`\n${background}` : svg`\n${background}\n${out}`; } function buildRenderCtx(graph: LaidOutGraph, opts: SvgOpts): RenderCtx { @@ -96,7 +94,10 @@ function renderDefs(ctx: RenderCtx): SafeSvg { function defaultDefs(theme: Theme): SafeSvg { return svg` - `; + + + + `; } function applyPostHook(body: SafeSvg, ctx: RenderCtx): SafeSvg { @@ -114,7 +115,11 @@ function applyPostHook(body: SafeSvg, ctx: RenderCtx): SafeSvg { } function accentFor(declKind: NodeBox["declKind"], theme: Theme): string { - return declKind === "union" ? theme.unionAccent : declKind === "alias" ? theme.aliasAccent : theme.recordAccent; + return declKind === "union" + ? theme.unionAccent + : declKind === "alias" || declKind === "function" + ? theme.aliasAccent + : theme.recordAccent; } interface NodeGeometry { @@ -194,12 +199,11 @@ function renderDefaultNode( const badge = geo.isUnion ? renderUnionBadge(geo.x, geo.y + geo.nameHeaderH, UNION_BADGE_H, n.width, ctx.theme, ctx.fontSize) : raw(""); - return svg` - - - - - + return svg` + + + + ${raw(escapeText(n.header))} ${badge} ${rows} @@ -253,7 +257,7 @@ function renderDefaultRow(n: NodeBox, r: NodeRow, ctx: RenderCtx, geo: NodeGeome const prefix = isUnion ? "\u25c7 " : ""; const divider = svg``; const text = svg`${raw(escapeText(prefix + r.text))}`; - return raw(`${divider.value}\n${text.value}`); + return svg`${divider}${text}`; } function renderUnionBadge( @@ -308,12 +312,12 @@ function buildEdgeCtx(e: EdgeRoute, ctx: RenderCtx): EdgeCtx { function renderDefaultEdge(e: EdgeRoute, ctx: RenderCtx, edgeCtx: EdgeCtx): SafeSvg { const points = edgeCtx.points.map((p) => `${String(p.x)},${String(p.y)}`).join(" "); const dash = edgeCtx.dashArray !== undefined ? raw(`stroke-dasharray="${edgeCtx.dashArray}"`) : raw(""); - const polyline = svg``; + const polyline = svg``; const label = e.label === "" ? raw("") : svg`${raw(escapeText(e.label))}`; - return raw(`${polyline.value}\n${label.value}`); + return svg`${polyline}${label}`; } function midpoint(points: ReadonlyArray<{ x: number; y: number }>): { x: number; y: number } { diff --git a/packages/typediagram/src/render-svg/theme.ts b/packages/typediagram/src/render-svg/theme.ts index 7fe80b5..cb4f41f 100644 --- a/packages/typediagram/src/render-svg/theme.ts +++ b/packages/typediagram/src/render-svg/theme.ts @@ -21,38 +21,38 @@ export interface Theme { } export const LIGHT: Theme = { - bg: "#ffffff", + bg: "#f6f8fc", nodeFill: "#ffffff", - nodeStroke: "#3b3f46", - headerFill: "#eef1f5", - headerText: "#1a1d22", - unionHeaderFill: "#f3eefa", - unionBadgeText: "#7a4cc7", - rowText: "#1a1d22", - rowDivider: "#dadde2", - edgeStroke: "#5b6068", - edgeText: "#3b3f46", - unionAccent: "#7a4cc7", - aliasAccent: "#0a7a5e", - recordAccent: "#1f6feb", + nodeStroke: "#c5ced8", + headerFill: "#e6ecf4", + headerText: "#0b1326", + unionHeaderFill: "#eee5f8", + unionBadgeText: "#7a3fd1", + rowText: "#26384d", + rowDivider: "#d8e0eb", + edgeStroke: "#60758a", + edgeText: "#3a4a5c", + unionAccent: "#7a3fd1", + aliasAccent: "#0f8a7a", + recordAccent: "#0c7bb8", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", }; export const DARK: Theme = { - bg: "#1c1f24", - nodeFill: "#252931", - nodeStroke: "#9da3ac", - headerFill: "#2f343d", - headerText: "#f1f3f6", - unionHeaderFill: "#2d2640", - unionBadgeText: "#b48cf2", - rowText: "#e4e7ea", - rowDivider: "#3b3f46", - edgeStroke: "#a8aeb6", - edgeText: "#d4d7dc", - unionAccent: "#b48cf2", - aliasAccent: "#4cd1a6", - recordAccent: "#79b8ff", + bg: "#0b1326", + nodeFill: "#222a3d", + nodeStroke: "#3e484f", + headerFill: "#2d3449", + headerText: "#dae2fd", + unionHeaderFill: "#342a49", + unionBadgeText: "#ddb7ff", + rowText: "#bdc8d1", + rowDivider: "#31394d", + edgeStroke: "#87929a", + edgeText: "#bdc8d1", + unionAccent: "#ddb7ff", + aliasAccent: "#45e3ce", + recordAccent: "#8ed5ff", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", }; diff --git a/packages/typediagram/test/__snapshots__/render.test.ts.snap b/packages/typediagram/test/__snapshots__/render.test.ts.snap index e280bef..c7b5829 100644 --- a/packages/typediagram/test/__snapshots__/render.test.ts.snap +++ b/packages/typediagram/test/__snapshots__/render.test.ts.snap @@ -4,175 +4,130 @@ exports[`render — chat example > snapshot — chat example SVG (default theme) " - + + + + - - - - - - - ChatRequest + +tool_results +tool_results +tool_results +tool_results +content +List.items +Text.value +Uri.value +kind +media_type + + + + + + ChatRequest - -message: String - -session_id: String - -tool_results: Option<List<ToolResult>> + message: String +session_id: String +tool_results: Option<List<ToolResult>> - - - - - - - ChatTurnInput + + + + + + ChatTurnInput - -config: AgentConfig - -user_message: String - -tool_results: Option<List<ToolResult>> - -session_id: String + config: AgentConfig +user_message: String +tool_results: Option<List<ToolResult>> +session_id: String - - - - - - - ToolResult + + + + + + ToolResult - -tool_call_id: String - -name: String - -content: ToolResultContent - -ok: Bool + tool_call_id: String +name: String +content: ToolResultContent +ok: Bool - - - - - - - union ToolResultContent - ONE OF - - -◇ None - -◇ Scalar { value: String } - -◇ Dict { entries: Map<String, String> } - -◇ List { items: List<ContentItem> } + + + + + + union ToolResultContent + ONE OF + + ◇ None +◇ Scalar { value: String } +◇ Dict { entries: Map<String, String> } +◇ List { items: List<ContentItem> } - - - - - - - union ContentItem - ONE OF - - -◇ Text { value: TextPart } - -◇ Uri { value: UriPart } - -◇ Scalar { value: String } + + + + + + union ContentItem + ONE OF + + ◇ Text { value: TextPart } +◇ Uri { value: UriPart } +◇ Scalar { value: String } - - - - - - - TextPart + + + + + + TextPart - -text: String + text: String - - - - - - - UriPart + + + + + + UriPart - -url: String - -kind: UriKind - -media_type: Option<String> + url: String +kind: UriKind +media_type: Option<String> - - - - - - - union UriKind - ONE OF - - -◇ Image - -◇ Audio - -◇ Video - -◇ Document - -◇ Web - -◇ Api + + + + + + union UriKind + ONE OF + + ◇ Image +◇ Audio +◇ Video +◇ Document +◇ Web +◇ Api - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - -tool_results - -tool_results - -tool_results - -tool_results - -content - -List.items - -Text.value - -Uri.value - -kind - -media_type " `; @@ -180,90 +135,72 @@ exports[`render — chat example > snapshot — small example SVG (default theme " - + + + + - - - - - - - User + +email +email +address + + + + + + User - -id: UUID - -name: String - -email: Option<Email> - -roles: List<Role> - -address: Address + id: UUID +name: String +email: Option<Email> +roles: List<Role> +address: Address - - - - - - - Address + + + + + + Address - -line1: String - -city: String - -country: CountryCode + line1: String +city: String +country: CountryCode - - - - - - - union Shape - ONE OF - - -◇ Circle { radius: Float } - -◇ Square { side: Float } - -◇ Triangle { a: Float, b: Float, c: Float } + + + + + + union Shape + ONE OF + + ◇ Circle { radius: Float } +◇ Square { side: Float } +◇ Triangle { a: Float, b: Float, c: Float } - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - - - - - - - alias Email + + + + + + alias Email - -= String + = String - -email - -email - -address " `; diff --git a/packages/typediagram/test/converters/typescript-tdbin.test.ts b/packages/typediagram/test/converters/typescript-tdbin.test.ts index c1cf6dd..b60c206 100644 --- a/packages/typediagram/test/converters/typescript-tdbin.test.ts +++ b/packages/typediagram/test/converters/typescript-tdbin.test.ts @@ -2,7 +2,7 @@ // StructCodec objects over the runtime in `src/tdbin`, with layout baked into // the generated code ([TDBIN-FUTURE-TS], [TDBIN-REC-ALLOC]). import { runInNewContext } from "node:vm"; -import ts from "typescript"; +import * as ts from "typescript-compiler"; import { describe, expect, it } from "vitest"; import { emitRustCodec } from "../../src/converters/rust-tdbin.js"; import { emitTypeScriptCodec, generateTypeScriptModule } from "../../src/converters/typescript-tdbin.js"; diff --git a/packages/typediagram/test/converters/typeshed.test.ts b/packages/typediagram/test/converters/typeshed.test.ts new file mode 100644 index 0000000..450680a --- /dev/null +++ b/packages/typediagram/test/converters/typeshed.test.ts @@ -0,0 +1,234 @@ +// [TYPESHED-CONVERT-TEST] Realistic .pyi constructs -> typeDiagram model. +import { describe, expect, it } from "vitest"; +import { typeshed } from "../../src/converters/index.js"; +import { printSource } from "../../src/model/index.js"; +import { unwrap } from "../helpers.js"; +import { modelFromTd } from "../helpers.js"; + +const STUB_SOURCE = ` +from dataclasses import dataclass +from enum import IntEnum +from typing import ClassVar, Generic, NamedTuple, Protocol, TypeAlias, TypedDict, TypeVar, overload + +T = TypeVar("T") + +class Service(Protocol): + endpoint: str + def connect(self, timeout: float = ...) -> bool: ... + +@dataclass(frozen=True) +class Payload(Generic[T]): + value: T + tags: list[str] + cache: ClassVar[dict[str, T]] + def encode(self) -> bytes: ... + +class Movie(TypedDict, total=False): + title: str + year: int + +class Coordinates(NamedTuple): + x: float + y: float + +class Status(IntEnum): + READY = 1 + DONE = 2 + +PathList: TypeAlias = list[str] | None +type Lookup[K, V] = dict[K, V] + +@overload +def read(path: str) -> bytes: ... +@overload +def read(path: bytes, limit: int = ...) -> bytes: ... + +if sys.version_info >= (3, 11): + async def refresh(payload: Payload[str]) -> Movie: ... +`; + +describe("[TYPESHED-CONVERT] typeshed -> typeDiagram", () => { + it("retains classes, dataclass fields, aliases, module functions, and overloads while excluding methods", () => { + const analyzed = unwrap(typeshed.analyzeSource(STUB_SOURCE)); + const { model, stats } = analyzed; + expect(stats).toEqual({ declarationsSeen: 10, declarationsConverted: 10, methodsSkipped: 2 }); + expect(model.decls.map((decl) => decl.name)).toEqual([ + "Service", + "Payload", + "Movie", + "Coordinates", + "Status", + "PathList", + "Lookup", + "read", + "refresh", + ]); + + const service = model.decls.find((decl) => decl.name === "Service"); + const payload = model.decls.find((decl) => decl.name === "Payload"); + const status = model.decls.find((decl) => decl.name === "Status"); + const read = model.decls.find((decl) => decl.name === "read"); + expect(service?.kind === "record" ? service.fields.map((field) => field.name) : []).toEqual(["endpoint"]); + expect(payload?.kind === "record" ? payload.generics : []).toEqual(["T"]); + expect(payload?.kind === "record" ? payload.fields.map((field) => field.name) : []).toEqual(["value", "tags"]); + expect(status?.kind === "union" ? status.variants.map((variant) => variant.name) : []).toEqual(["READY", "DONE"]); + expect(read?.kind === "function" ? read.signatures : []).toHaveLength(2); + expect(read?.kind === "function" ? read.signatures[1]?.params[1]?.type.name : undefined).toBe("Int"); + expect(model.decls.some((decl) => decl.name === "connect" || decl.name === "encode")).toBe(false); + + const td = printSource(model); + expect(td).toContain("type Payload"); + expect(td).toContain("alias PathList = Option>"); + expect(td).toContain("alias Lookup = Map"); + expect(td).toContain("function read {"); + expect(td).toContain("async function refresh(payload: Payload) -> Movie"); + expect(unwrap(typeshed.fromSource(STUB_SOURCE)).decls).toHaveLength(9); + }); + + it("reports malformed stubs and source with no type declarations as Result errors", () => { + expect(typeshed.fromSource("def broken(: ...").ok).toBe(false); + expect(typeshed.fromSource("VALUE = 42\n").ok).toBe(false); + }); + + it("normalizes advanced annotations, variadics, aliases, conditional duplicates, keywords, and generic arity", () => { + const source = ` +import typing +from typing import Annotated, Callable, ClassVar, Final, Generic, Literal, Mapping, NewType, TypeAlias, TypeVar, Union +from typing_extensions import TypeAliasType + +_T2 = TypeVar("_T2") + +if sys.platform == "win32": + class Box(Generic[_T2]): + first: _T2 + type: type[object] + ignored: ClassVar[str] +else: + class Box(Generic[_T2]): + second: str + +class KeywordNames: + alias: str + function: bytes + constant = 3 + +class Mode(Flag): + _PRIVATE = 0 + READ = 1 + +AliasNew = NewType("AliasNew", int) +AliasOther = TypeAliasType("AliasOther", str) +ForwardAlias = "Box" +UPPER = str +factory_value = Factory() +Explicit: TypeAlias = Union[int, str] +type Pair[A, B] = tuple[A, B] + +def use(box: Box, expanded: Box[int, str], *args: str, **kwargs: int) -> None: ... +def untyped(value) -> typing.Any: ... + +class Exotic: + qualified: typing.Any + nothing: None + truth: Literal[True] + negative: Literal[-1] + optional: int | None + choice: int | str | None + callback: Callable[[str, int], bytes] + variadic: Callable[..., Any] + tupled: tuple[str, ...] + wrapped: Annotated[str, "metadata"] + mapping: Mapping[str, int] + raw: bytearray + complex_forward: "list[str]" + generated: Factory() +`; + const { model, stats } = unwrap(typeshed.analyzeSource(source)); + const box = model.decls.find((decl) => decl.name === "Box"); + const keywords = model.decls.find((decl) => decl.name === "KeywordNames"); + const mode = model.decls.find((decl) => decl.name === "Mode"); + const use = model.decls.find((decl) => decl.name === "use"); + const exotic = model.decls.find((decl) => decl.name === "Exotic"); + expect(stats.declarationsSeen).toBe(12); + expect(box?.kind === "record" ? box.generics : []).toEqual(["_T2", "_T3"]); + expect(box?.kind === "record" ? box.fields.map((field) => field.name) : []).toEqual(["first", "type_", "second"]); + expect(keywords?.kind === "record" ? keywords.fields.map((field) => field.name) : []).toEqual([ + "alias_", + "function_", + ]); + expect(mode?.kind === "union" ? mode.variants.map((variant) => variant.name) : []).toEqual(["READ"]); + expect(use?.kind === "function" ? use.signatures[0]?.params.map((param) => param.type.name) : []).toEqual([ + "Box", + "Box", + "List", + "Map", + ]); + expect(use?.kind === "function" ? use.signatures[0]?.params[0]?.type.args.map((arg) => arg.name) : []).toEqual([ + "Any", + "Any", + ]); + expect(model.decls.some((decl) => decl.name === "UPPER" || decl.name === "factory_value")).toBe(false); + expect(model.decls.find((decl) => decl.name === "AliasNew")?.kind).toBe("alias"); + expect(model.decls.find((decl) => decl.name === "AliasOther")?.kind).toBe("alias"); + expect(model.decls.find((decl) => decl.name === "ForwardAlias")?.kind).toBe("alias"); + expect(exotic?.kind === "record" ? exotic.fields.map((field) => field.type.name) : []).toEqual([ + "Any", + "Unit", + "Bool", + "Int", + "Option", + "Option", + "Callable", + "Callable", + "tuple", + "String", + "Map", + "Bytes", + "Any", + "Any", + ]); + expect(printSource(model)).toContain("function use(box: Box"); + }); + + it("emits complete .pyi source for records, unions, aliases, functions, overloads, async, and imports", () => { + const model = modelFromTd(` +type Empty {} +type Box { value: T } +union EmptyState {} +union State { Ready Busy } +alias Name = String +function ping(value: Int) -> Bool +function fetch { + (name: String) -> Bytes + async (name: String, fallback: Option) -> Bytes +} +@skipTargets(typeshed) +type Hidden { value: String } +`); + const pyi = typeshed.toSource(model); + expect(pyi).toContain("from enum import Enum"); + expect(pyi).toContain("from typing import overload"); + expect(pyi).toContain("from typing import Optional"); + expect(pyi).toContain("class Empty:\n ..."); + expect(pyi).toContain("class Box[T]:\n value: T"); + expect(pyi).toContain("class EmptyState(Enum):\n ..."); + expect(pyi).toContain("class State(Enum):\n Ready = ...\n Busy = ..."); + expect(pyi).toContain("type Name = str"); + expect(pyi).toContain("def ping(value: int) -> bool: ..."); + expect(pyi).toContain("@overload\ndef fetch(name: str) -> bytes: ..."); + expect(pyi).toContain("async def fetch(name: str, fallback: Optional[bytes]) -> bytes: ..."); + expect(pyi).not.toContain("Hidden"); + expect(unwrap(typeshed.fromSource(pyi)).decls.map((decl) => decl.name)).toEqual([ + "Empty", + "Box", + "EmptyState", + "State", + "Name", + "ping", + "fetch", + ]); + + const noImports = typeshed.toSource(modelFromTd("type One { value: String }")); + expect(noImports.startsWith("class One:")).toBe(true); + }); +}); diff --git a/packages/typediagram/test/editor.test.ts b/packages/typediagram/test/editor.test.ts new file mode 100644 index 0000000..7f4efc1 --- /dev/null +++ b/packages/typediagram/test/editor.test.ts @@ -0,0 +1,157 @@ +// [EDITOR-SOURCE-TEST] Whole-model editing scenario matching visual-canvas actions. +import { describe, expect, it } from "vitest"; +import { + addDeclaration, + addRow, + connectDeclarations, + editRow, + removeDeclaration, + removeRow, + renameDeclaration, +} from "../src/editor/index.js"; +import { parse } from "../src/parser/index.js"; +import { buildModel } from "../src/model/index.js"; + +const SOURCE = `typeDiagram + +type User { + id: Int + email: String +} + +type Team { + members: List +} + +union Result { + Ok + Error { message: String } +} + +alias Owner = User +`; + +const value = (result: ReturnType) => { + expect(result.ok).toBe(true); + return result.ok ? result.value : ""; +}; + +describe("[EDITOR-SOURCE] visual operations preserve a valid, connected type model", () => { + it("renames declarations and edits/adds/removes/connects record, union, and alias rows", () => { + const renamed = value(renameDeclaration(SOURCE, "User", "Person")); + expect(renamed).toContain("type Person"); + expect(renamed).toContain("List"); + expect(renamed).toContain("alias Owner = Person"); + + const fieldEdited = value(editRow(renamed, "Person", 1, { name: "primary_email", type: "Option" })); + expect(fieldEdited).toContain("primary_email: Option"); + + const rowAdded = value(addRow(fieldEdited, "Person")); + expect(rowAdded).toContain("field: String"); + const rowRemoved = value(removeRow(rowAdded, "Person", 0)); + expect(rowRemoved).not.toContain("id: Int"); + + const recordConnected = value(connectDeclarations(rowRemoved, "Person", -1, "Team")); + expect(recordConnected).toContain("team: Team"); + const rowConnected = value(connectDeclarations(recordConnected, "Person", 0, "Team")); + expect(rowConnected).toContain("primary_email: Team"); + + const unionConnected = value(connectDeclarations(rowConnected, "Result", 0, "Person")); + expect(unionConnected).toContain("Ok(Person)"); + const aliasConnected = value(connectDeclarations(unionConnected, "Owner", -1, "Team")); + expect(aliasConnected).toContain("alias Owner = Team"); + + const parsed = parse(aliasConnected); + const model = parsed.ok ? buildModel(parsed.value) : parsed; + expect(parsed.ok).toBe(true); + expect(model.ok).toBe(true); + expect(model.ok ? model.value.edges.some((edge) => edge.targetDeclName === "Team") : false).toBe(true); + }); + + it("returns specific failures without corrupting source", () => { + const missing = renameDeclaration(SOURCE, "Missing", "Other"); + const invalidType = editRow(SOURCE, "User", 0, { type: "List<" }); + expect(missing).toEqual({ ok: false, error: { message: "Unknown declaration 'Missing'" } }); + expect(invalidType.ok).toBe(false); + expect(invalidType.ok ? "" : invalidType.error.message).toContain("expected"); + }); + + it("handles every declaration kind plus malformed and missing edit targets", () => { + const invalidSource = renameDeclaration("@@@", "User", "Person"); + const invalidModel = renameDeclaration("type A {}\ntype A {}", "A", "B"); + const emptyName = renameDeclaration(SOURCE, "User", " "); + expect(invalidSource.ok).toBe(false); + expect(invalidModel.ok).toBe(false); + expect(emptyName.ok).toBe(false); + + const unionNamed = value(editRow(SOURCE, "Result", 0, { name: "Success" })); + const unionTyped = value(editRow(unionNamed, "Result", 0, { type: "List" })); + expect(unionTyped).toContain("Success(List)"); + const aliasTyped = value(editRow(unionTyped, "Owner", 0, { type: "Map" })); + expect(aliasTyped).toContain("alias Owner = Map"); + + const unionAdded = value(addRow(aliasTyped, "Result")); + expect(unionAdded).toContain("Variant"); + const unionRemoved = value(removeRow(unionAdded, "Result", 2)); + expect(unionRemoved).not.toContain("Variant"); + expect(value(addRow(unionRemoved, "Owner"))).toBe(unionRemoved); + expect(value(removeRow(unionRemoved, "Owner", 0))).toBe(unionRemoved); + + const unionConnected = value(connectDeclarations(unionRemoved, "Result", -1, "Team")); + expect(unionConnected).toContain("Team(Team)"); + const unchangedRow = value(editRow(unionConnected, "User", 99, { name: "missing", type: "String" })); + expect(unchangedRow).not.toContain("missing:"); + + expect(editRow(SOURCE, "Missing", 0, { name: "x" }).ok).toBe(false); + expect(addRow(SOURCE, "Missing").ok).toBe(false); + expect(removeRow(SOURCE, "Missing", 0).ok).toBe(false); + expect(connectDeclarations(SOURCE, "Missing", -1, "User").ok).toBe(false); + expect(connectDeclarations(SOURCE, "User", -1, "Missing").ok).toBe(false); + }); + + it("adds uniquely named ADTs and removes only the selected declaration", () => { + const record = value(addDeclaration(SOURCE, "record")); + const secondRecord = value(addDeclaration(record, "record")); + const union = value(addDeclaration(secondRecord, "union")); + const alias = value(addDeclaration(union, "alias")); + expect(alias).toContain("type NewRecord"); + expect(alias).toContain("type NewRecord2"); + expect(alias).toContain("union NewUnion"); + expect(alias).toContain("alias NewAlias = String"); + + const removed = value(removeDeclaration(alias, "User")); + expect(removed).not.toContain("type User {"); + expect(removed).toContain("members: List"); + expect(removed).toContain("alias Owner = User"); + expect(removeDeclaration(SOURCE, "Missing")).toEqual({ + ok: false, + error: { message: "Unknown declaration 'Missing'" }, + }); + expect(addDeclaration("@@@", "record").ok).toBe(false); + expect(removeDeclaration("@@@", "User").ok).toBe(false); + }); + + it("connects to a generic node without committing an unrecoverable bare generic reference", () => { + const genericSource = `${SOURCE}\nunion Option {\n Some { value: T }\n None\n}\n`; + const connected = connectDeclarations(genericSource, "User", -1, "Option"); + expect(connected.ok).toBe(true); + const next = connected.ok ? connected.value : ""; + expect(next).toContain("option: Option"); + const parsed = parse(next); + expect(parsed.ok).toBe(true); + const model = parsed.ok ? buildModel(parsed.value) : parsed; + expect(model.ok).toBe(true); + expect(model.ok ? model.value.edges.some((edge) => edge.targetDeclName === "Option") : false).toBe(true); + }); + + it("rejects a bare generic field edit before it can replace the live diagram", () => { + const genericSource = `${SOURCE}\nunion Option {\n Some { value: T }\n None\n}\n`; + const edited = editRow(genericSource, "User", 0, { type: "Option" }); + expect(edited.ok).toBe(false); + expect(edited.ok ? edited.value : edited.error.message).toContain("takes 1 type argument(s), got 0"); + expect(genericSource).toContain("id: Int"); + expect(genericSource).not.toContain("id: Option"); + const originalModel = parse(genericSource); + expect(originalModel.ok).toBe(true); + }); +}); diff --git a/packages/typediagram/test/function.integration.test.ts b/packages/typediagram/test/function.integration.test.ts new file mode 100644 index 0000000..6288230 --- /dev/null +++ b/packages/typediagram/test/function.integration.test.ts @@ -0,0 +1,80 @@ +// [DSL-FUNCTION-TEST] Function declarations survive every public model/render layer. +import { describe, expect, it } from "vitest"; +import { renderToString } from "../src/index.js"; +import { buildModel, fromJSON, printSource, toJSON } from "../src/model/index.js"; +import { parse } from "../src/parser/index.js"; +import { unwrap } from "./helpers.js"; + +describe("[DSL-FUNCTION] function declarations", () => { + it("parses, resolves, prints, serializes, lays out, and renders signatures and overloads", async () => { + const source = `typeDiagram + +type Request { id: Int } +type Response { body: Bytes } + +function submit(request: Request, fallback: Option) -> Response + +function read { + (request: Request) -> Bytes + async (request: Request, limit: Int) -> Response +} +`; + const ast = unwrap(parse(source)); + expect(ast.decls.map((decl) => decl.kind)).toEqual(["record", "record", "function", "function"]); + + const model = unwrap(buildModel(ast)); + const submit = model.decls.find((decl) => decl.name === "submit"); + const read = model.decls.find((decl) => decl.name === "read"); + expect(submit?.kind).toBe("function"); + expect(submit?.generics).toEqual(["T"]); + expect(submit?.kind === "function" ? submit.signatures[0]?.params.map((param) => param.name) : []).toEqual([ + "request", + "fallback", + ]); + expect(read?.kind === "function" ? read.signatures : []).toHaveLength(2); + expect(read?.kind === "function" ? read.signatures[1]?.async : undefined).toBe(true); + expect(model.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sourceDeclName: "submit", targetDeclName: "Request", kind: "parameter" }), + expect.objectContaining({ sourceDeclName: "submit", targetDeclName: "Response", kind: "return" }), + expect.objectContaining({ sourceDeclName: "read", targetDeclName: "Response", kind: "return" }), + ]) + ); + + const printed = printSource(model); + expect(printed).toContain("function submit(request: Request, fallback: Option) -> Response"); + expect(printed).toContain("function read {\n (request: Request) -> Bytes"); + expect(printed).toContain(" async (request: Request, limit: Int) -> Response"); + expect(toJSON(unwrap(fromJSON(toJSON(model))))).toEqual(toJSON(model)); + + const svg = unwrap(await renderToString(printed)); + expect(svg).toContain('data-decl="submit"'); + expect(svg).toContain('data-kind="function"'); + expect(svg).toContain("function submit<T>"); + expect(svg).toContain("request: Request"); + expect(svg).toContain("async (request: Request, limit: Int) → Response"); + }); + + it("returns diagnostics and recovers across malformed function heads, parameters, returns, and overload rows", () => { + const malformed = [ + "async type Nope {}", + "function", + "function missing(", + "function missingArrow(value: Int) String", + "function bad { nope\n (value Int) -> Unit\n () -> Unit }\ntype Recovered { ok: Bool }", + ]; + const results = malformed.map((source) => parse(source)); + expect(results.every((result) => !result.ok)).toBe(true); + expect( + results.flatMap((result) => (result.ok ? [] : result.error)).map((diagnostic) => diagnostic.message) + ).toEqual( + expect.arrayContaining([ + expect.stringContaining("expected 'function'"), + expect.stringContaining("function name"), + expect.stringContaining("expected ')'"), + expect.stringContaining("expected '->'"), + expect.stringContaining("expected '('"), + ]) + ); + }); +}); diff --git a/packages/typediagram/test/render.test.ts b/packages/typediagram/test/render.test.ts index a935489..0abd93d 100644 --- a/packages/typediagram/test/render.test.ts +++ b/packages/typediagram/test/render.test.ts @@ -31,7 +31,9 @@ describe("render — small example", () => { const light = unwrap(await renderToString(SMALL_EXAMPLE, { theme: "light" })); const dark = unwrap(await renderToString(SMALL_EXAMPLE, { theme: "dark" })); expect(light).not.toBe(dark); - expect(dark).toContain("#252931"); + expect(dark).toContain("#222a3d"); + expect(dark).toContain('id="td-grid"'); + expect(dark).toContain('id="td-ambient-shadow"'); }); }); diff --git a/packages/typediagram/test/visual-editor.test.ts b/packages/typediagram/test/visual-editor.test.ts new file mode 100644 index 0000000..a59fe35 --- /dev/null +++ b/packages/typediagram/test/visual-editor.test.ts @@ -0,0 +1,486 @@ +// @vitest-environment happy-dom +// [EDITOR-CANVAS-TEST] Whole visual editor in a browser DOM: render, edit, drag, connect, zoom, persist. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToString } from "../src/index.js"; +import { + createVisualEditor, + createViewport, + installVisualEditorStyles, + setViewportContent, + VISUAL_EDITOR_CSS, +} from "../src/editor/index.js"; + +const SOURCE = `typeDiagram + +type Account { + owner: Profile + active: Bool +} + +type Profile { + name: String +} + +union State { + Loading + Ready { account: Account } +} + +alias Owner = Profile +`; + +const size = (element: HTMLElement, width = 900, height = 640) => { + Object.defineProperty(element, "clientWidth", { configurable: true, value: width }); + Object.defineProperty(element, "clientHeight", { configurable: true, value: height }); +}; + +const pointer = (type: string, x: number, y: number) => + new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 1, clientX: x, clientY: y }); + +const clickNode = (node: SVGGElement | null | undefined, x: number, y: number) => { + node?.dispatchEvent(pointer("pointerdown", x, y)); + node?.ownerSVGElement?.dispatchEvent(pointer("pointerup", x, y)); +}; + +const render = async (source: string) => { + const result = await renderToString(source, { theme: "dark" }); + expect(result.ok).toBe(true); + return result.ok ? result.value : ""; +}; + +describe("[EDITOR-CANVAS] shared web and VS Code interaction runtime", () => { + beforeEach(() => { + document.head.replaceChildren(); + document.body.replaceChildren(); + vi.restoreAllMocks(); + }); + + it("supports the full workflow without unsafe declaration-level connection handles", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + let source = SOURCE; + const changes: string[] = []; + const positions: Array>> = []; + const editor = createVisualEditor(container, { + getSource: () => source, + onSourceChange: (next) => { + source = next; + changes.push(next); + }, + initialPositions: { Profile: { x: 16, y: 8 } }, + onPositionsChange: (next) => positions.push(next), + }); + + editor.setContent(await render(source)); + expect(document.querySelector("style[data-td-visual-editor]")?.textContent).toBe(VISUAL_EDITOR_CSS); + expect(container.classList.contains("td-visual-editor")).toBe(true); + expect(container.querySelectorAll(".td-canvas-toolbar .td-canvas-button")).toHaveLength(8); + expect(container.querySelectorAll(".td-node-creator .td-node-kind-button")).toHaveLength(3); + expect(container.querySelectorAll(".td-legend-item")).toHaveLength(3); + expect(container.querySelector('[data-decl="Profile"]')?.getAttribute("transform")).toBe("translate(16 8)"); + expect(container.querySelectorAll(".td-port").length).toBeGreaterThan(8); + expect(container.querySelectorAll('.td-source-port[data-row-index="-1"]')).toHaveLength(0); + expect(container.querySelector('[data-decl="Account"] .td-source-port[data-row-index="-1"]')).toBeNull(); + expect(container.querySelector('[data-decl="State"] .td-source-port[data-row-index="-1"]')).toBeNull(); + expect(container.querySelector('[data-decl="Owner"] .td-source-port[data-row-index="-1"]')).toBeNull(); + installVisualEditorStyles(document); + expect(document.querySelectorAll("style[data-td-visual-editor]")).toHaveLength(1); + + clickNode(container.querySelector('[data-decl="State"]'), 50, 50); + expect(container.querySelector(".td-inspector-kind")?.textContent).toBe("union"); + clickNode(container.querySelector('[data-decl="Owner"]'), 50, 50); + expect(container.querySelector(".td-inspector-kind")?.textContent).toBe("alias"); + + const account = container.querySelector('[data-decl="Account"]'); + const svg = container.querySelector("svg"); + expect(account).not.toBeNull(); + expect(svg).not.toBeNull(); + account?.dispatchEvent(pointer("pointerdown", 100, 100)); + svg?.dispatchEvent(pointer("pointermove", 164, 148)); + svg?.dispatchEvent(pointer("pointerup", 164, 148)); + expect(account?.getAttribute("transform")).toBe("translate(56 40)"); + expect(positions.at(-1)?.Account).toEqual({ x: 56, y: 40 }); + expect(container.querySelector(".td-inspector")?.hasAttribute("hidden")).toBe(true); + account?.dispatchEvent(pointer("pointerdown", 164, 148)); + svg?.dispatchEvent(pointer("pointerup", 164, 148)); + expect(container.querySelector(".td-inspector")?.hasAttribute("hidden")).toBe(false); + + const nameInput = container.querySelector(".td-inspector input"); + expect(nameInput?.value).toBe("Account"); + switch (nameInput) { + case null: + break; + default: + nameInput.value = "Workspace"; + nameInput.dispatchEvent(new Event("change")); + } + expect(source).toContain("type Workspace"); + expect(source).toContain("account: Workspace"); + editor.setContent(await render(source)); + + const workspace = container.querySelector('[data-decl="Workspace"]'); + clickNode(workspace, 80, 80); + const firstRowInputs = container.querySelectorAll(".td-inspector-row input"); + const firstName = firstRowInputs[0]; + const firstType = firstRowInputs[1]; + switch (firstName) { + case undefined: + break; + default: + firstName.value = "member"; + firstName.dispatchEvent(new Event("change")); + } + switch (firstType) { + case undefined: + break; + default: + firstType.value = "Option"; + firstType.dispatchEvent(new Event("change")); + } + expect(source).toContain("member: Option"); + container.querySelector(".td-inspector-add")?.click(); + expect(source).toContain("field: String"); + editor.setContent(await render(source)); + + clickNode(container.querySelector('[data-decl="Workspace"]'), 80, 80); + container.querySelectorAll(".td-inspector-remove").item(2).click(); + expect(source).not.toContain("field: String"); + editor.setContent(await render(source)); + + const port = container.querySelector( + '[data-decl="Workspace"] .td-source-port[data-row-index="0"]' + ); + const target = container.querySelector('[data-decl="Profile"]'); + const connectionSvg = container.querySelector("svg"); + switch (connectionSvg) { + case null: + break; + default: + vi.spyOn(connectionSvg, "getScreenCTM").mockReturnValue(null); + } + const elementFromPoint = vi.spyOn(document, "elementFromPoint").mockReturnValue(target); + port?.dispatchEvent(pointer("pointerdown", 200, 140)); + connectionSvg?.dispatchEvent(pointer("pointermove", 400, 200)); + expect(container.querySelector(".td-connection-preview")?.getAttribute("d")).toContain(" C "); + connectionSvg?.dispatchEvent(pointer("pointerup", 400, 200)); + expect(source).toContain("member: Profile"); + expect(elementFromPoint).toHaveBeenCalled(); + editor.setContent(await render(source)); + expect(container.querySelector(".td-inspector")?.hidden).toBe(true); + expect(container.querySelectorAll(".td-selected")).toHaveLength(0); + + const wrapper = container.querySelector(".viewport-wrapper"); + const beforeZoom = wrapper?.style.transform; + container.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 200, clientY: 200, deltaY: -1 }) + ); + expect(wrapper?.style.transform).not.toBe(beforeZoom); + container.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 200, clientY: 200, deltaY: 1 }) + ); + container.querySelector('[aria-label="Zoom in"]')?.click(); + container.querySelector('[aria-label="Zoom out"]')?.click(); + container.querySelector('[aria-label="Fit diagram to view"]')?.click(); + container.querySelector('[aria-label="Reset canvas"]')?.click(); + expect(wrapper?.style.transform).toBe("translate(0px, 0px) scale(1)"); + + const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:test"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { + this.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + let exportFilename = ""; + const captureExport = (event: Event) => { + exportFilename = event.target instanceof HTMLAnchorElement ? event.target.download : exportFilename; + }; + document.addEventListener("click", captureExport, true); + container.querySelector('[aria-label="Export SVG"]')?.click(); + document.removeEventListener("click", captureExport, true); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:test"); + expect(click).toHaveBeenCalledTimes(1); + expect(exportFilename).toBe("type-diagram.svg"); + + container.querySelector('[aria-label="Restore automatic layout"]')?.click(); + expect(positions.at(-1)).toEqual({}); + container.dispatchEvent(new KeyboardEvent("keydown", { key: "+", bubbles: true })); + container.dispatchEvent(new KeyboardEvent("keydown", { key: "-", bubbles: true })); + container.dispatchEvent(new KeyboardEvent("keydown", { key: "f", bubbles: true })); + container.dispatchEvent(new KeyboardEvent("keydown", { key: "0", bubbles: true })); + container.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(container.querySelector(".td-inspector")?.hidden).toBe(true); + expect(changes.length).toBeGreaterThanOrEqual(5); + }); + + it("handles bare viewports, invalid edits, cancelled gestures, and missing SVG exports", async () => { + const bare = document.createElement("div"); + size(bare, 0, 0); + document.body.appendChild(bare); + setViewportContent(bare, "

diagnostic

"); + expect(bare.innerHTML).toBe("

diagnostic

"); + + const viewport = createViewport(bare); + viewport.zoomIn(); + viewport.zoomOut(); + viewport.fit(); + viewport.reset(); + expect(viewport.scale).toBe(1); + bare.dispatchEvent(pointer("pointermove", 20, 20)); + const ignored = document.createElement("button"); + bare.appendChild(ignored); + ignored.dispatchEvent(pointer("pointerdown", 0, 0)); + bare.dispatchEvent(pointer("pointermove", 100, 100)); + bare.dispatchEvent(pointer("pointerdown", 0, 0)); + bare.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true })); + expect(bare.classList.contains("td-is-panning")).toBe(false); + + let source = SOURCE; + const editor = createVisualEditor(bare, { + getSource: () => source, + onSourceChange: (next) => (source = next), + }); + editor.setContent(await render(source)); + const account = bare.querySelector('[data-decl="Account"]'); + const svg = bare.querySelector("svg"); + account?.dispatchEvent(pointer("pointerdown", 20, 20)); + svg?.dispatchEvent(pointer("pointerup", 20, 20)); + const typeInput = bare.querySelectorAll(".td-inspector-row input").item(1); + typeInput.value = "List<"; + typeInput.dispatchEvent(new Event("change")); + expect(bare.querySelector(".td-editor-toast")?.hidden).toBe(false); + bare.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true })); + + editor.setContent("
bad source
"); + const createObjectURL = vi.spyOn(URL, "createObjectURL"); + bare.querySelector('[aria-label="Export SVG"]')?.click(); + expect(createObjectURL).not.toHaveBeenCalled(); + expect(bare.querySelector(".viewport-wrapper")?.textContent).toContain("bad source"); + }); + + it("keeps the inspector off the canvas while a node drag is in progress", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + const editor = createVisualEditor(container, { + getSource: () => SOURCE, + onSourceChange: vi.fn(), + }); + editor.setContent(await render(SOURCE)); + const account = container.querySelector('[data-decl="Account"]'); + const svg = container.querySelector("svg"); + const inspector = container.querySelector(".td-inspector"); + + expect(inspector?.hidden).toBe(true); + account?.dispatchEvent(pointer("pointerdown", 100, 100)); + expect(inspector?.hidden).toBe(true); + svg?.dispatchEvent(pointer("pointermove", 164, 148)); + expect(inspector?.hidden).toBe(true); + expect(account?.getAttribute("transform")).toBe("translate(56 40)"); + svg?.dispatchEvent(pointer("pointerup", 164, 148)); + expect(inspector?.hidden).toBe(true); + account?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(inspector?.hidden).toBe(true); + + account?.dispatchEvent(pointer("pointerdown", 164, 148)); + svg?.dispatchEvent(pointer("pointerup", 164, 148)); + account?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(inspector?.hidden).toBe(false); + expect(container.querySelector(".td-inspector input")?.value).toBe("Account"); + }); + + it("zooms proportionally for trackpad deltas without slowing toolbar controls", () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + const viewport = createViewport(container); + + container.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 200, clientY: 200, deltaY: -1 }) + ); + expect(viewport.scale).toBeGreaterThan(1); + expect(viewport.scale).toBeLessThan(1.01); + + viewport.reset(); + container.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 200, clientY: 200, deltaY: -100 }) + ); + expect(viewport.scale).toBeCloseTo(1.12, 5); + + viewport.reset(); + viewport.zoomIn(); + expect(viewport.scale).toBeCloseTo(1.12, 5); + viewport.zoomOut(); + expect(viewport.scale).toBeCloseTo(1, 5); + }); + + it("renders close and remove actions as accessible icon buttons", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + const editor = createVisualEditor(container, { + getSource: () => SOURCE, + onSourceChange: vi.fn(), + }); + editor.setContent(await render(SOURCE)); + const account = container.querySelector('[data-decl="Account"]'); + const svg = container.querySelector("svg"); + account?.dispatchEvent(pointer("pointerdown", 100, 100)); + svg?.dispatchEvent(pointer("pointerup", 100, 100)); + + const close = container.querySelector(".td-inspector-close"); + const removes = [...container.querySelectorAll(".td-inspector-remove")]; + expect(close?.type).toBe("button"); + expect(close?.classList.contains("td-icon-button")).toBe(true); + expect(close?.getAttribute("aria-label")).toBe("Close properties"); + expect(close?.querySelector('svg[aria-hidden="true"]')).not.toBeNull(); + expect(close?.textContent).toBe(""); + expect(removes).toHaveLength(2); + removes.forEach((button) => { + expect(button.type).toBe("button"); + expect(button.classList.contains("td-icon-button")).toBe(true); + expect(button.getAttribute("aria-label")).toBe("Remove row"); + expect(button.querySelector('svg[aria-hidden="true"]')).not.toBeNull(); + expect(button.textContent).toBe(""); + }); + expect(VISUAL_EDITOR_CSS).toContain(".td-icon-button{display:inline-grid"); + expect(VISUAL_EDITOR_CSS).toContain("border:1px solid"); + expect(VISUAL_EDITOR_CSS).toContain(".td-icon-button:focus-visible"); + }); + + it("aligns every record row close button with its input edge", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + const editor = createVisualEditor(container, { + getSource: () => SOURCE, + onSourceChange: vi.fn(), + }); + editor.setContent(await render(SOURCE)); + clickNode(container.querySelector('[data-decl="Account"]'), 100, 100); + + const row = container.querySelector(".td-inspector-row"); + const label = row?.querySelector("label"); + const input = label?.querySelector("input"); + const close = row?.querySelector(".td-inspector-remove"); + expect(getComputedStyle(row as HTMLElement).alignItems).toBe("end"); + expect(getComputedStyle(input as HTMLInputElement).boxSizing).toBe("border-box"); + expect(getComputedStyle(label as HTMLLabelElement).marginBottom).toBe("0px"); + expect(getComputedStyle(close as HTMLButtonElement).height).toBe( + getComputedStyle(input as HTMLInputElement).height + ); + expect(getComputedStyle(close as HTMLButtonElement).width).toBe(getComputedStyle(input as HTMLInputElement).height); + }); + + it("adds every ADT node kind and deletes a selected node from the diagram source", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + let source = SOURCE; + const editor = createVisualEditor(container, { + getSource: () => source, + onSourceChange: (next) => { + source = next; + }, + }); + editor.setContent(await render(source)); + + const addType = container.querySelector('[aria-label="Add type"]'); + expect(addType).not.toBeNull(); + addType?.click(); + const creator = container.querySelector(".td-node-creator"); + expect(creator?.hidden).toBe(false); + expect(creator?.querySelectorAll("button")).toHaveLength(3); + expect(creator?.textContent).toContain("Record"); + expect(creator?.textContent).toContain("Union"); + expect(creator?.textContent).toContain("Alias"); + + container.querySelector('[aria-label="Add record type"]')?.click(); + expect(source).toContain("type NewRecord"); + expect(source).toContain("field: String"); + addType?.click(); + container.querySelector('[aria-label="Add union type"]')?.click(); + expect(source).toContain("union NewUnion"); + expect(source).toContain("Variant"); + addType?.click(); + container.querySelector('[aria-label="Add alias type"]')?.click(); + expect(source).toContain("alias NewAlias = String"); + + editor.setContent(await render(source)); + expect(container.querySelector('[data-decl="NewRecord"]')).not.toBeNull(); + expect(container.querySelector('[data-decl="NewUnion"]')).not.toBeNull(); + expect(container.querySelector('[data-decl="NewAlias"]')).not.toBeNull(); + const account = container.querySelector('[data-decl="Account"]'); + const svg = container.querySelector("svg"); + account?.dispatchEvent(pointer("pointerdown", 100, 100)); + svg?.dispatchEvent(pointer("pointerup", 100, 100)); + const deleteType = container.querySelector(".td-inspector-delete"); + expect(deleteType?.textContent).toContain("Delete type"); + expect(deleteType?.getAttribute("aria-label")).toBe("Delete Account"); + deleteType?.click(); + expect(source).not.toContain("type Account {"); + expect(source).toContain("account: Account"); + + editor.setContent(await render(source)); + expect(container.querySelector('[data-decl="Account"]')).toBeNull(); + expect(container.querySelectorAll('[data-edge][data-target="Account"]')).toHaveLength(0); + }); + + it("assigns distinct node identities when creation outruns the host source round-trip", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + const changes: string[] = []; + const editor = createVisualEditor(container, { + getSource: () => SOURCE, + onSourceChange: (next) => changes.push(next), + }); + editor.setContent(await render(SOURCE)); + + const addType = container.querySelector('[aria-label="Add type"]'); + const addRecord = () => { + addType?.click(); + expect(container.querySelector(".td-node-creator")?.hidden).toBe(false); + container.querySelector('[aria-label="Add record type"]')?.click(); + }; + + addRecord(); + addRecord(); + + expect(changes).toHaveLength(2); + expect(changes[0]).toContain("type NewRecord {"); + expect(changes[0]).toContain("field: String"); + expect(changes[1]).toContain("type NewRecord2 {"); + expect(changes[1]).not.toBe(changes[0]); + expect(await render(changes[0] ?? "")).toContain('data-decl="NewRecord"'); + expect(await render(changes[1] ?? "")).toContain('data-decl="NewRecord2"'); + }); + + it("assigns a unique field name whenever Add row is clicked on a record", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + let source = SOURCE; + const changes: string[] = []; + const editor = createVisualEditor(container, { + getSource: () => source, + onSourceChange: (next) => { + source = next; + changes.push(next); + }, + }); + editor.setContent(await render(source)); + + clickNode(container.querySelector('[data-decl="Account"]'), 80, 80); + const addRow = container.querySelector(".td-inspector-add"); + expect(addRow?.textContent).toContain("Add row"); + addRow?.click(); + addRow?.click(); + + expect(changes).toHaveLength(2); + expect(source.match(/^ {2}field: String$/gm)).toHaveLength(1); + expect(source.match(/^ {2}field2: String$/gm)).toHaveLength(1); + expect(source).toContain("active: Bool\n field: String\n field2: String"); + expect(await render(source)).toContain('data-decl="Account"'); + }); +}); diff --git a/packages/vscode/package.json b/packages/vscode/package.json index ff12394..4bca33f 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -164,16 +164,16 @@ "devDependencies": { "@types/markdown-it": "^14.1.2", "@types/mocha": "^10.0.10", - "@types/node": "^26.1.0", + "@types/node": "^26.1.1", "@types/pdfkit": "^0.17.6", - "@types/vscode": "^1.99.0", + "@types/vscode": "1.99.1", "@vscode/test-electron": "^3.0.0", "esbuild": "^0.28.1", "mocha": "^11.7.6", "pdfkit": "^0.19.1", "svg-to-pdfkit": "^0.1.8", - "typescript": "^6.0.3", - "vitest": "^4.1.9", + "typescript": "^7.0.2", + "vitest": "^4.1.10", "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.1.0" } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index b12395a..e09a708 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,10 +1,11 @@ // [VSCODE-EXT] Extension entry point — registers preview command, wires editor events. import * as vscode from "vscode"; import { warmupSyncRender, isSyncRenderReady } from "typediagram-core"; -import { openPreview } from "./preview-panel.js"; +import { consumeEditorSource, openPreview, requestVisualEditorInteractions } from "./preview-panel.js"; import { typediagramMarkdownItPlugin, type MarkdownIt, setPluginLogger } from "./markdown-it-plugin.js"; import { getLogger, initLogger } from "./logger.js"; import { exportPdf, type ExportPdfDeps } from "./export-pdf.js"; +import { VISUAL_EDITOR_INTERACTION_COMMAND } from "./webview/interaction-protocol.js"; let extendCallCount = 0; @@ -63,7 +64,9 @@ export const activate = async (context: vscode.ExtensionContext) => { if (doc.languageId !== "typediagram") { return; } - openPreview(context, doc, panels); + openPreview(context, doc, panels, vscode.ViewColumn.Beside, () => { + diagramOnly.delete(doc.uri.toString()); + }); }; const cmd = vscode.commands.registerCommand("typediagram.preview", () => { @@ -73,6 +76,15 @@ export const activate = async (context: vscode.ExtensionContext) => { } }); + // [VSCODE-VISUAL-EDITOR-STATUS] Command-only black-box seam for extension-host E2E. + const editorStatus = vscode.commands.registerCommand("typediagram.editorStatus", () => ({ + visualEditor: true, + openPanels: panels.size, + })); + const editorInteractions = vscode.commands.registerCommand(VISUAL_EDITOR_INTERACTION_COMMAND, () => + requestVisualEditorInteractions(panels) + ); + // [VSCODE-OPEN-AS-DIAGRAM] Open a .td file directly as a diagram from the explorer context menu — no source editor. const openAsDiagram = vscode.commands.registerCommand("typediagram.openAsDiagram", async (uri?: vscode.Uri) => { const target = uri ?? vscode.window.activeTextEditor?.document.uri; @@ -88,7 +100,9 @@ export const activate = async (context: vscode.ExtensionContext) => { if (sourceTabs.length > 0) { await vscode.window.tabGroups.close(sourceTabs, false); } - openPreview(context, doc, panels, vscode.ViewColumn.Active); + openPreview(context, doc, panels, vscode.ViewColumn.Active, () => { + diagramOnly.delete(key); + }); }); // [VSCODE-AUTOPREVIEW] Auto-open preview beside the editor whenever a .td doc is shown without one. @@ -132,13 +146,25 @@ export const activate = async (context: vscode.ExtensionContext) => { return; } const panel = panels.get(doc.uri.toString()); - panel?.webview.postMessage({ kind: "update", source: doc.getText() }); + const source = doc.getText(); + switch (panel) { + case undefined: + break; + default: + switch (consumeEditorSource(panel, source)) { + case true: + break; + case false: + void panel.webview.postMessage({ kind: "update", source }); + } + } }); const onClose = vscode.workspace.onDidCloseTextDocument((doc) => { const key = doc.uri.toString(); - panels.delete(key); - diagramOnly.delete(key); + if (!panels.has(key)) { + diagramOnly.delete(key); + } }); // [PDF] Export a markdown file to PDF next to the source. No prompts. @@ -172,7 +198,18 @@ export const activate = async (context: vscode.ExtensionContext) => { await exportPdf(target, { theme }, deps); }); - context.subscriptions.push(cmd, openAsDiagram, exportPdfCmd, onOpen, onActive, onVisible, onChange, onClose); + context.subscriptions.push( + cmd, + editorStatus, + editorInteractions, + openAsDiagram, + exportPdfCmd, + onOpen, + onActive, + onVisible, + onChange, + onClose + ); if (!isSyncRenderReady()) { const startedAt = Date.now(); diff --git a/packages/vscode/src/preview-panel.ts b/packages/vscode/src/preview-panel.ts index 18175c0..c9c9a4d 100644 --- a/packages/vscode/src/preview-panel.ts +++ b/packages/vscode/src/preview-panel.ts @@ -1,31 +1,156 @@ // [VSCODE-PREVIEW-PANEL] Creates/reveals a webview panel for a .td document. import * as vscode from "vscode"; import { webviewHtml } from "./webview-html.js"; +import { + visualEditorReady, + visualInteractionResponse, + type VisualInteractionResult, +} from "./webview/interaction-protocol.js"; + +type EditorMessage = { kind: "edit"; source: string }; +type OpenSourceMessage = { kind: "open-source" }; +const pendingEdits = new WeakMap>(); +const pendingSources = new WeakMap(); +const panelReady = new WeakMap>(); +let interactionRequestId = 0; + +const readySignal = () => { + let resolve!: () => void; + const promise = new Promise((current) => { + resolve = current; + }); + return { promise, resolve }; +}; + +const webviewMessage = (message: unknown): EditorMessage | OpenSourceMessage | undefined => { + // Safe: the webview payload is narrowed before either property is consumed. + const value = typeof message === "object" && message !== null ? (message as Record) : {}; + switch (value.kind) { + case "edit": + return typeof value.source === "string" ? { kind: "edit", source: value.source } : undefined; + case "open-source": + return { kind: "open-source" }; + default: + return undefined; + } +}; + +const sourceEnd = (source: string) => { + const line = source.split("\n").length - 1; + const character = source.length - source.lastIndexOf("\n") - 1; + return new vscode.Position(line, character); +}; + +const applyEditorSource = async (doc: vscode.TextDocument, previous: string, source: string) => { + const edit = new vscode.WorkspaceEdit(); + edit.replace(doc.uri, new vscode.Range(new vscode.Position(0, 0), sourceEnd(previous)), source); + await vscode.workspace.applyEdit(edit); +}; + +export const consumeEditorSource = (panel: vscode.WebviewPanel, source: string) => { + const sources = pendingSources.get(panel) ?? []; + const index = sources.indexOf(source); + switch (index >= 0) { + case true: + sources.splice(index, 1); + break; + } + return index >= 0; +}; + +const queueEditorSource = (panel: vscode.WebviewPanel, doc: vscode.TextDocument, source: string) => { + const sources = pendingSources.get(panel) ?? []; + const current = sources.at(-1) ?? doc.getText(); + pendingSources.set(panel, [...sources, source]); + const previous = pendingEdits.get(panel) ?? Promise.resolve(); + const next = previous.then(async () => { + await applyEditorSource(doc, current, source); + consumeEditorSource(panel, source); + }); + pendingEdits.set(panel, next); +}; + +const requestPanelInteractions = async (panel: vscode.WebviewPanel, requestId: string) => { + await panelReady.get(panel); + return new Promise((resolve) => { + const subscription = panel.webview.onDidReceiveMessage((message: unknown) => { + const result = visualInteractionResponse(message, requestId); + switch (result) { + case undefined: + break; + default: + subscription.dispose(); + void (pendingEdits.get(panel) ?? Promise.resolve()).then(() => { + resolve(result); + }); + } + }); + void panel.webview.postMessage({ kind: "test-visual-interactions", requestId }); + }); +}; + +export const requestVisualEditorInteractions = (panels: Map) => { + const panel = [...panels.values()][0]; + interactionRequestId += 1; + return panel === undefined + ? Promise.resolve({ passed: [], sourceUpdated: false, evidence: {} }) + : requestPanelInteractions(panel, String(interactionRequestId)); +}; export const openPreview = ( context: vscode.ExtensionContext, doc: vscode.TextDocument, panels: Map, - column: vscode.ViewColumn = vscode.ViewColumn.Beside + column: vscode.ViewColumn, + onDispose: () => void ) => { const key = doc.uri.toString(); const existing = panels.get(key); - if (existing) { - existing.reveal(column); - return; + switch (existing) { + case undefined: + break; + default: + existing.reveal(column); + return; } const panel = vscode.window.createWebviewPanel("typediagram.preview", `Preview: ${fileName(doc)}`, column, { enableScripts: true, + retainContextWhenHidden: true, localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, "dist", "webview")], }); const scriptUri = panel.webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, "dist", "webview", "main.js")); + const ready = readySignal(); + panelReady.set(panel, ready.promise); + + panel.webview.onDidReceiveMessage((message: unknown) => { + switch (visualEditorReady(message)) { + case true: + ready.resolve(); + break; + } + const editor = webviewMessage(message); + switch (editor?.kind) { + case "edit": + queueEditorSource(panel, doc, editor.source); + break; + case "open-source": + void vscode.window.showTextDocument(doc, { preview: false }); + break; + } + }); panel.webview.html = webviewHtml(panel.webview.cspSource, scriptUri, doc.getText()); - panel.onDidDispose(() => panels.delete(key)); + panel.onDidDispose(() => { + panels.delete(key); + onDispose(); + }); panels.set(key, panel); }; -const fileName = (doc: vscode.TextDocument) => doc.uri.path.split("/").pop() ?? "untitled.td"; +const fileName = (doc: vscode.TextDocument) => { + const name = doc.uri.path.split("/").pop(); + return name === undefined || name === "" ? "untitled.td" : name; +}; diff --git a/packages/vscode/src/webview-html.ts b/packages/vscode/src/webview-html.ts index 8d4a8f4..fc31da3 100644 --- a/packages/vscode/src/webview-html.ts +++ b/packages/vscode/src/webview-html.ts @@ -16,14 +16,24 @@ export const webviewHtml = (cspSource: string, scriptUri: vscode.Uri, initialSou content="default-src 'none'; script-src ${cspSource}; style-src 'unsafe-inline';"> -
-

+  
+ `; diff --git a/packages/vscode/src/webview/interaction-protocol.ts b/packages/vscode/src/webview/interaction-protocol.ts new file mode 100644 index 0000000..f0986d2 --- /dev/null +++ b/packages/vscode/src/webview/interaction-protocol.ts @@ -0,0 +1,81 @@ +// [VSCODE-VSIX-INTERACTION-PROTOCOL] Command-only black-box bridge used by the packaged VSIX E2E suite. +export const VISUAL_EDITOR_INTERACTION_COMMAND = "typediagram.testVisualEditorInteractions"; + +export const VISUAL_INTERACTION_PASSES = [ + "canvas-chrome", + "invalid-edit", + "record-edit", + "union-edit", + "alias-edit", + "add-remove", + "icon-buttons", + "node-add-delete", + "drag-snap-persist", + "zoom-in-out", + "trackpad-zoom", + "fit-reset-pan", + "draw-relationship", + "generic-relationship-recovery", + "auto-layout", + "export-svg", + "close-and-escape", +] as const; + +export type VisualInteractionEvidence = Record; +export type VisualInteractionResult = { + passed: string[]; + sourceUpdated: boolean; + evidence: VisualInteractionEvidence; +}; +export type VisualInteractionRequest = { kind: "test-visual-interactions"; requestId: string }; +export type VisualEditorReady = { kind: "visual-editor-ready" }; +export type VisualInteractionResponse = { + kind: "visual-interactions-result"; + requestId: string; + result: VisualInteractionResult; +}; + +const messageRecord = (message: unknown) => { + // Safe: message properties are read only after the object/null check. + return typeof message === "object" && message !== null ? (message as Record) : {}; +}; + +const evidenceRecord = (message: unknown) => { + const raw = messageRecord(message); + const evidence: VisualInteractionEvidence = {}; + Object.entries(raw).forEach(([key, value]) => { + switch (typeof value) { + case "string": + case "number": + case "boolean": + evidence[key] = value; + break; + } + }); + return evidence; +}; + +export const visualInteractionRequest = (message: unknown): VisualInteractionRequest | undefined => { + const value = messageRecord(message); + return value.kind === "test-visual-interactions" && typeof value.requestId === "string" + ? { kind: "test-visual-interactions", requestId: value.requestId } + : undefined; +}; + +export const visualEditorReady = (message: unknown): message is VisualEditorReady => + messageRecord(message).kind === "visual-editor-ready"; + +export const visualInteractionResponse = (message: unknown, requestId: string): VisualInteractionResult | undefined => { + const value = messageRecord(message); + const raw = messageRecord(value.result); + const evidence = evidenceRecord(raw.evidence); + const passed = Array.isArray(raw.passed) + ? raw.passed.filter((item): item is string => typeof item === "string") + : undefined; + const valid = + value.kind === "visual-interactions-result" && + value.requestId === requestId && + passed !== undefined && + typeof raw.sourceUpdated === "boolean"; + return valid ? { passed, sourceUpdated: raw.sourceUpdated === true, evidence } : undefined; +}; diff --git a/packages/vscode/src/webview/interaction-test.ts b/packages/vscode/src/webview/interaction-test.ts new file mode 100644 index 0000000..bd1009a --- /dev/null +++ b/packages/vscode/src/webview/interaction-test.ts @@ -0,0 +1,498 @@ +// [VSCODE-VSIX-INTERACTIONS] Drives the real packaged webview through its user-facing DOM controls. +import type { VisualInteractionEvidence, VisualInteractionResult } from "./interaction-protocol.js"; + +export type VisualInteractionContext = { + preview: HTMLElement; + getSource: () => string; + settle: () => Promise; + getState: () => unknown; +}; + +type Audit = { context: VisualInteractionContext; passed: string[]; evidence: VisualInteractionEvidence }; +type Point = { x: number; y: number }; +const mark = (audit: Audit, name: string, passed: boolean) => (passed ? audit.passed.push(name) : undefined); +const pointer = (type: string, point: Point, buttons: number) => + new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 19, + pointerType: "mouse", + isPrimary: true, + buttons, + clientX: point.x, + clientY: point.y, + }); +const button = (audit: Audit, label: string) => { + const value = audit.context.preview.querySelector(`[aria-label="${label}"]`); + return value instanceof HTMLButtonElement ? value : undefined; +}; +const input = (audit: Audit, index: number) => { + const value = audit.context.preview.querySelectorAll(".td-inspector input").item(index); + return value instanceof HTMLInputElement ? value : undefined; +}; +const declaration = (audit: Audit, name: string) => { + const value = audit.context.preview.querySelector(`[data-decl="${name}"]`); + return value instanceof SVGGElement ? value : undefined; +}; +const selectDeclaration = (audit: Audit, name: string) => { + const node = declaration(audit, name); + const svg = node?.ownerSVGElement ?? undefined; + const point = { x: node?.getBoundingClientRect().x ?? 20, y: node?.getBoundingClientRect().y ?? 20 }; + node?.dispatchEvent(pointer("pointerdown", point, 1)); + svg?.dispatchEvent(pointer("pointerup", point, 0)); + return node; +}; +const changeInput = async (audit: Audit, index: number, value: string) => { + const current = input(audit, index); + current?.focus(); + Object.assign(current ?? {}, { value }); + current?.dispatchEvent(new Event("change", { bubbles: true })); + await audit.context.settle(); + return current !== undefined; +}; +const canvasChrome = (audit: Audit) => { + const { preview } = audit.context; + const toolbarButtons = preview.querySelectorAll(".td-canvas-toolbar .td-canvas-button").length; + const legendItems = preview.querySelectorAll(".td-canvas-legend .td-legend-item").length; + const grid = preview.querySelector("svg #td-grid") !== null; + const shadow = preview.querySelector("svg #td-ambient-shadow") !== null; + const nodeCount = preview.querySelectorAll("svg [data-decl]").length; + const ports = preview.querySelectorAll("svg .td-port").length; + const nodeKinds = ["record", "union", "alias"].every( + (kind) => preview.querySelectorAll(`svg [data-kind="${kind}"]`).length > 0 + ); + const toolbarLabel = preview.querySelector(".td-canvas-toolbar")?.getAttribute("aria-label") === "Canvas controls"; + const legendText = preview.querySelector(".td-canvas-legend")?.textContent.replace(/\s/g, "") === "TypeUnionAlias"; + const chromeInitiallyClosed = + preview.querySelector(".td-node-creator")?.hidden === true && + preview.querySelector(".td-inspector")?.hidden === true; + Object.assign(audit.evidence, { + toolbarButtons, + legendItems, + grid, + shadow, + nodeCount, + ports, + nodeKinds, + toolbarLabel, + legendText, + chromeInitiallyClosed, + }); + const toolbar = toolbarButtons === 8; + const legend = legendItems === 3; + const rendered = grid && shadow && nodeCount > 10 && ports > 30; + mark( + audit, + "canvas-chrome", + toolbar && legend && rendered && nodeKinds && toolbarLabel && legendText && chromeInitiallyClosed + ); +}; +const recordEdits = async (audit: Audit) => { + selectDeclaration(audit, "ChatRequest"); + const recordKind = audit.context.preview.querySelector(".td-inspector-kind")?.textContent === "record"; + const recordRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + const recordSelected = audit.context.preview.querySelectorAll(".td-selected").length === 1; + await changeInput(audit, 0, "ConversationRequest"); + const renamedNode = declaration(audit, "ConversationRequest") !== undefined; + const oldNodeGone = declaration(audit, "ChatRequest") === undefined; + selectDeclaration(audit, "ConversationRequest"); + await changeInput(audit, 1, "prompt"); + selectDeclaration(audit, "ConversationRequest"); + const beforeInvalid = audit.context.getSource(); + await changeInput(audit, 2, "List<"); + const invalid = audit.context.getSource() === beforeInvalid && !audit.context.getSource().includes("prompt: List<"); + const invalidToast = audit.context.preview.querySelector(".td-editor-toast:not([hidden])") !== null; + Object.assign(audit.evidence, { invalidRejected: invalid, invalidToast, recordKind, recordRows, recordSelected }); + mark(audit, "invalid-edit", invalid && invalidToast); + selectDeclaration(audit, "ConversationRequest"); + await changeInput(audit, 2, "Option"); + const recordRenamed = audit.context.getSource().includes("type ConversationRequest"); + const recordFieldEdited = audit.context.getSource().includes("prompt: Option"); + const recordRendered = + declaration(audit, "ConversationRequest")?.textContent.includes("prompt: Option") === true; + const recordEdge = + audit.context.preview.querySelector('[data-edge][data-source="ConversationRequest"][data-target="Option"]') !== + null; + Object.assign(audit.evidence, { + recordRenamed, + recordFieldEdited, + renamedNode, + oldNodeGone, + recordRendered, + recordEdge, + }); + mark( + audit, + "record-edit", + recordRenamed && + recordFieldEdited && + renamedNode && + oldNodeGone && + recordRendered && + recordEdge && + recordKind && + recordRows === 3 && + recordSelected + ); +}; +const unionEdit = async (audit: Audit) => { + selectDeclaration(audit, "ToolResultContent"); + const unionKind = audit.context.preview.querySelector(".td-inspector-kind")?.textContent === "union"; + const unionRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + await changeInput(audit, 3, "ScalarValue"); + selectDeclaration(audit, "ToolResultContent"); + await changeInput(audit, 4, "TextPart"); + const unionVariantRenamed = audit.context.getSource().includes("ScalarValue {"); + const unionPayloadEdited = audit.context.getSource().includes("ScalarValue { value: TextPart }"); + const unionRendered = declaration(audit, "ToolResultContent")?.textContent.includes("ScalarValue") === true; + const unionEdge = + audit.context.preview.querySelector('[data-edge][data-source="ToolResultContent"][data-target="TextPart"]') !== + null; + Object.assign(audit.evidence, { + unionVariantRenamed, + unionPayloadEdited, + unionKind, + unionRows, + unionRendered, + unionEdge, + }); + mark( + audit, + "union-edit", + unionVariantRenamed && unionPayloadEdited && unionKind && unionRows === 4 && unionRendered && unionEdge + ); +}; +const aliasEdit = async (audit: Audit) => { + selectDeclaration(audit, "Email"); + const aliasKind = audit.context.preview.querySelector(".td-inspector-kind")?.textContent === "alias"; + const aliasRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + const aliasHasNoRowControls = + audit.context.preview.querySelector(".td-inspector-add") === null && + audit.context.preview.querySelector(".td-inspector-remove") === null; + await changeInput(audit, 2, "Option"); + const aliasTargetEdited = audit.context.getSource().includes("alias Email = Option"); + const aliasRendered = declaration(audit, "Email")?.textContent.includes("Option") === true; + const aliasEdge = + audit.context.preview.querySelector('[data-edge][data-source="Email"][data-target="Option"]') !== null; + Object.assign(audit.evidence, { + aliasTargetEdited, + aliasKind, + aliasRows, + aliasHasNoRowControls, + aliasRendered, + aliasEdge, + }); + mark( + audit, + "alias-edit", + aliasTargetEdited && aliasKind && aliasRows === 1 && aliasHasNoRowControls && aliasRendered && aliasEdge + ); +}; +const addRemove = async (audit: Audit) => { + selectDeclaration(audit, "ConversationRequest"); + const add = audit.context.preview.querySelector(".td-inspector-add"); + const sourceBefore = audit.context.getSource(); + const beforeRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + const beforePorts = declaration(audit, "ConversationRequest")?.querySelectorAll(".td-source-port").length ?? 0; + (add instanceof HTMLButtonElement ? add : undefined)?.click(); + await audit.context.settle(); + const added = audit.context.getSource().includes("field: String"); + selectDeclaration(audit, "ConversationRequest"); + const afterAddRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + const afterAddPorts = declaration(audit, "ConversationRequest")?.querySelectorAll(".td-source-port").length ?? 0; + const lastInputs = audit.context.preview.querySelectorAll(".td-inspector-row:last-of-type input"); + const defaultRow = lastInputs.item(0).value === "field" && lastInputs.item(1).value === "String"; + const removes = audit.context.preview.querySelectorAll(".td-inspector-remove"); + const remove = removes.item(removes.length - 1); + (remove instanceof HTMLButtonElement ? remove : undefined)?.click(); + await audit.context.settle(); + selectDeclaration(audit, "ConversationRequest"); + const afterRemoveRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + const removed = !audit.context.getSource().includes("field: String"); + const afterRemovePorts = declaration(audit, "ConversationRequest")?.querySelectorAll(".td-source-port").length ?? 0; + const sourceRestored = audit.context.getSource() === sourceBefore; + Object.assign(audit.evidence, { + beforeRows, + afterAddRows, + afterRemoveRows, + rowAdded: added, + rowRemoved: removed, + beforePorts, + afterAddPorts, + afterRemovePorts, + defaultRow, + sourceRestored, + }); + mark( + audit, + "add-remove", + added && + removed && + defaultRow && + sourceRestored && + afterAddRows === beforeRows + 1 && + afterRemoveRows === beforeRows && + afterAddPorts === beforePorts + 1 && + afterRemovePorts === beforePorts + ); +}; +const iconButtons = (audit: Audit) => { + selectDeclaration(audit, "ConversationRequest"); + const close = button(audit, "Close properties"); + const removes = [...audit.context.preview.querySelectorAll(".td-inspector-remove")]; + const closeIcon = close !== undefined && close.querySelector('svg[aria-hidden="true"]') !== null; + const removeIconCount = removes.filter((current) => current.querySelector('svg[aria-hidden="true"]') !== null).length; + const removeButtonsLabelled = removes.every((current) => current.getAttribute("aria-label") === "Remove row"); + Object.assign(audit.evidence, { closeIcon, removeIconCount, removeButtonsLabelled }); + mark(audit, "icon-buttons", closeIcon && removeIconCount === removes.length && removeButtonsLabelled); +}; +const addNode = async (audit: Audit, label: string) => { + button(audit, "Add type")?.click(); + button(audit, label)?.click(); + await audit.context.settle(); +}; +const addDeleteNodes = async (audit: Audit) => { + button(audit, "Add type")?.click(); + const creatorButtons = audit.context.preview.querySelectorAll(".td-node-creator button").length; + button(audit, "Add type")?.click(); + await addNode(audit, "Add record type"); + await addNode(audit, "Add union type"); + await addNode(audit, "Add alias type"); + const recordAdded = declaration(audit, "NewRecord") !== undefined; + const unionAdded = declaration(audit, "NewUnion") !== undefined; + const aliasAdded = declaration(audit, "NewAlias") !== undefined; + selectDeclaration(audit, "NewRecord"); + button(audit, "Delete NewRecord")?.click(); + await audit.context.settle(); + const recordDeleted = + declaration(audit, "NewRecord") === undefined && !audit.context.getSource().includes("type NewRecord"); + Object.assign(audit.evidence, { creatorButtons, recordAdded, unionAdded, aliasAdded, recordDeleted }); + mark(audit, "node-add-delete", creatorButtons === 3 && recordAdded && unionAdded && aliasAdded && recordDeleted); +}; + +const dragNode = (audit: Audit) => { + const node = declaration(audit, "ConversationRequest"); + const svg = node?.ownerSVGElement ?? undefined; + const box = node?.getBoundingClientRect(); + const start = { x: (box?.x ?? 20) + 8, y: (box?.y ?? 20) + 8 }; + const end = { x: start.x + 64, y: start.y + 40 }; + node?.dispatchEvent(pointer("pointerdown", start, 1)); + const inspector = audit.context.preview.querySelector(".td-inspector"); + const inspectorHiddenOnDragStart = inspector?.hidden === true; + svg?.dispatchEvent(pointer("pointermove", end, 1)); + const inspectorHiddenOnDragMove = inspector?.hidden === true; + svg?.dispatchEvent(pointer("pointerup", end, 0)); + const inspectorHiddenOnDragEnd = inspector?.hidden === true; + const x = Number(node?.dataset.editorX); + const y = Number(node?.dataset.editorY); + const persisted = JSON.stringify(audit.context.getState()).includes("ConversationRequest"); + const dragSnapped = x > 0 && y > 0 && x % 8 === 0 && y % 8 === 0; + Object.assign(audit.evidence, { + dragX: x, + dragY: y, + dragSnapped, + layoutPersisted: persisted, + inspectorHiddenOnDragStart, + inspectorHiddenOnDragMove, + inspectorHiddenOnDragEnd, + }); + mark( + audit, + "drag-snap-persist", + dragSnapped && persisted && inspectorHiddenOnDragStart && inspectorHiddenOnDragMove && inspectorHiddenOnDragEnd + ); +}; + +const zoom = (audit: Audit) => { + const wrapper = audit.context.preview.querySelector(".viewport-wrapper"); + const before = wrapper?.style.transform; + button(audit, "Zoom in")?.click(); + const afterIn = wrapper?.style.transform; + button(audit, "Zoom out")?.click(); + const afterOut = wrapper?.style.transform; + Object.assign(audit.evidence, { zoomBefore: before ?? "", zoomAfterIn: afterIn ?? "", zoomAfterOut: afterOut ?? "" }); + mark(audit, "zoom-in-out", before !== afterIn && afterIn !== afterOut); +}; + +const scaleFrom = (transform: string) => Number(transform.match(/scale\(([^)]+)\)/)?.[1] ?? "0"); + +const trackpadZoom = (audit: Audit) => { + button(audit, "Reset canvas")?.click(); + audit.context.preview.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 200, clientY: 200, deltaY: -1 }) + ); + const transform = audit.context.preview.querySelector(".viewport-wrapper")?.style.transform ?? ""; + const scale = scaleFrom(transform); + button(audit, "Reset canvas")?.click(); + audit.evidence.trackpadScale = scale; + mark(audit, "trackpad-zoom", scale > 1 && scale < 1.01); +}; + +const pan = (audit: Audit) => { + const start = { x: 12, y: 18 }; + const end = { x: 62, y: 53 }; + audit.context.preview.dispatchEvent(pointer("pointerdown", start, 1)); + audit.context.preview.dispatchEvent(pointer("pointermove", end, 1)); + audit.context.preview.dispatchEvent(pointer("pointerup", end, 0)); + return audit.context.preview.querySelector(".viewport-wrapper")?.style.transform ?? ""; +}; + +const fitResetPan = (audit: Audit) => { + button(audit, "Fit diagram to view")?.click(); + const fit = audit.context.preview.querySelector(".viewport-wrapper")?.style.transform ?? ""; + button(audit, "Reset canvas")?.click(); + const reset = audit.context.preview.querySelector(".viewport-wrapper")?.style.transform ?? ""; + const panned = pan(audit); + button(audit, "Reset canvas")?.click(); + Object.assign(audit.evidence, { fitTransform: fit, resetTransform: reset, panTransform: panned }); + mark( + audit, + "fit-reset-pan", + fit.includes("scale(") && reset.includes("translate(0px, 0px) scale(1)") && panned.includes("translate(50px, 35px)") + ); +}; + +const dragConnection = async (audit: Audit, sourceName: string, rowIndex: number, targetName: string) => { + const port = audit.context.preview.querySelector( + `[data-decl="${sourceName}"] .td-source-port[data-row-index="${String(rowIndex)}"]` + ); + const target = audit.context.preview.querySelector(`[data-decl="${targetName}"] .td-target-port`); + const svg = port instanceof SVGCircleElement ? port.ownerSVGElement : undefined; + const sourceBox = port?.getBoundingClientRect(); + const targetBox = target?.getBoundingClientRect(); + const start = { x: (sourceBox?.x ?? 0) + 2, y: (sourceBox?.y ?? 0) + 2 }; + const end = { x: (targetBox?.x ?? 0) + 2, y: (targetBox?.y ?? 0) + 2 }; + port?.dispatchEvent(pointer("pointerdown", start, 1)); + svg?.dispatchEvent(pointer("pointermove", end, 1)); + const connectionPreview = + audit.context.preview.querySelector(".td-connection-preview")?.getAttribute("d")?.includes(" C ") === true; + target?.dispatchEvent(pointer("pointerup", end, 0)); + await audit.context.settle(); + return connectionPreview; +}; + +const drawRelationship = async (audit: Audit) => { + selectDeclaration(audit, "ConversationRequest"); + const connectionPreview = await dragConnection(audit, "ConversationRequest", 0, "TextPart"); + const relationshipSource = audit.context.getSource().includes("prompt: TextPart"); + const relationshipRendered = + declaration(audit, "ConversationRequest")?.textContent.includes("prompt: TextPart") === true; + const relationshipEdge = + audit.context.preview.querySelector('[data-edge][data-source="ConversationRequest"][data-target="TextPart"]') !== + null; + const relationshipClosed = audit.context.preview.querySelector(".td-connection-preview") === null; + Object.assign(audit.evidence, { + connectionPreview, + relationshipSource, + relationshipRendered, + relationshipEdge, + relationshipClosed, + }); + mark( + audit, + "draw-relationship", + connectionPreview && relationshipSource && relationshipRendered && relationshipEdge && relationshipClosed + ); +}; + +const genericRelationshipRecovery = async (audit: Audit) => { + const connectionPreview = await dragConnection(audit, "ToolResult", -1, "Option"); + const genericSource = audit.context.getSource().includes("option: Option"); + const genericTargetRendered = declaration(audit, "Option") !== undefined; + const genericEdge = + audit.context.preview.querySelector('[data-edge][data-source="ToolResult"][data-target="Option"]') !== null; + const genericRendered = declaration(audit, "ToolResult")?.textContent.includes("option: Option") === true; + const fatalErrorHidden = audit.context.preview.ownerDocument.getElementById("error-panel")?.hidden === true; + const recoveryActions = audit.context.preview.ownerDocument.querySelectorAll("#error-panel .error-action").length; + Object.assign(audit.evidence, { + genericSource, + genericTargetRendered, + genericEdge, + genericRendered, + fatalErrorHidden, + recoveryActions, + }); + mark( + audit, + "generic-relationship-recovery", + connectionPreview && + genericSource && + genericTargetRendered && + genericEdge && + genericRendered && + fatalErrorHidden && + recoveryActions === 2 + ); +}; + +const autoLayout = (audit: Audit) => { + button(audit, "Restore automatic layout")?.click(); + const node = declaration(audit, "ConversationRequest"); + const emptyState = JSON.stringify(audit.context.getState()).includes('"positions":{}'); + const autoX = Number(node?.dataset.editorX); + const autoY = Number(node?.dataset.editorY); + Object.assign(audit.evidence, { autoX, autoY, autoStateEmpty: emptyState }); + mark(audit, "auto-layout", autoX === 0 && autoY === 0 && emptyState); +}; + +const exportSvg = (audit: Audit) => { + let download = ""; + const capture = (event: Event) => { + download = event.target instanceof HTMLAnchorElement ? event.target.download : download; + event.preventDefault(); + }; + document.addEventListener("click", capture, true); + button(audit, "Export SVG")?.click(); + document.removeEventListener("click", capture, true); + audit.evidence.exportFilename = download; + mark(audit, "export-svg", download === "type-diagram.svg"); +}; + +const closeAndEscape = (audit: Audit) => { + selectDeclaration(audit, "ConversationRequest"); + button(audit, "Close properties")?.click(); + const inspector = audit.context.preview.querySelector(".td-inspector"); + const closed = inspector?.hidden === true; + selectDeclaration(audit, "ConversationRequest"); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + const escaped = inspector?.hidden === true; + Object.assign(audit.evidence, { inspectorClosed: closed, inspectorEscaped: escaped }); + mark(audit, "close-and-escape", closed && escaped); +}; + +const editingInteractions = async (audit: Audit) => { + await recordEdits(audit); + await unionEdit(audit); + await aliasEdit(audit); + await addRemove(audit); + iconButtons(audit); + await addDeleteNodes(audit); +}; + +const canvasInteractions = async (audit: Audit) => { + dragNode(audit); + zoom(audit); + trackpadZoom(audit); + fitResetPan(audit); + await drawRelationship(audit); + await genericRelationshipRecovery(audit); + autoLayout(audit); + exportSvg(audit); + closeAndEscape(audit); +}; + +export const runVisualEditorInteractions = async ( + context: VisualInteractionContext +): Promise => { + const audit: Audit = { context, passed: [], evidence: {} }; + canvasChrome(audit); + await editingInteractions(audit); + await canvasInteractions(audit); + const source = context.getSource(); + return { + passed: audit.passed, + sourceUpdated: + source.includes("ConversationRequest") && source.includes("prompt: TextPart") && source.includes("Option"), + evidence: audit.evidence, + }; +}; diff --git a/packages/vscode/src/webview/main.ts b/packages/vscode/src/webview/main.ts index 915a004..cfa8d62 100644 --- a/packages/vscode/src/webview/main.ts +++ b/packages/vscode/src/webview/main.ts @@ -1,38 +1,153 @@ -// [VSCODE-WEBVIEW] Webview script — renders .td source to SVG using typediagram. -// Reuses the same renderToString API as the web playground and CLI. +// [VSCODE-WEBVIEW] Shared visual editor with a real workspace-edit bridge. import { parser, renderToString } from "typediagram-core"; +import { createVisualEditor, type NodePosition } from "typediagram-core/editor"; +import { runVisualEditorInteractions } from "./interaction-test.js"; +import { visualInteractionRequest } from "./interaction-protocol.js"; type UpdateMessage = { kind: "update"; source: string }; +type WebviewState = { positions?: Readonly> }; +type RecoveryElements = { + errorPanel: HTMLElement; + error: HTMLElement; + restore: HTMLButtonElement; + openSource: HTMLButtonElement; +}; +type RenderResult = Awaited>; +type VisualEditor = ReturnType; -acquireVsCodeApi(); -const previewEl = document.getElementById("preview"); -const errorEl = document.getElementById("error"); -if (previewEl === null || errorEl === null) { - throw new Error("[VSCODE-WEBVIEW] missing preview or error element"); -} +const vscode = acquireVsCodeApi(); + +const detectTheme = () => (document.body.classList.contains("vscode-light") ? ("light" as const) : ("dark" as const)); + +const initialSource = () => document.querySelector("script[data-source]")?.getAttribute("data-source") ?? ""; + +const persistedState = (): WebviewState => { + const state = vscode.getState(); + // Safe: VS Code owns this state object and positions are used only for numeric transforms. + return typeof state === "object" && state !== null ? state : {}; +}; -const detectTheme = (): "light" | "dark" => - document.body.getAttribute("data-vscode-theme-kind")?.includes("dark") === true ? "dark" : "light"; +const isUpdate = (message: unknown): message is UpdateMessage => { + // Safe: message properties are narrowed before use. + const value = typeof message === "object" && message !== null ? (message as Record) : {}; + return value.kind === "update" && typeof value.source === "string"; +}; + +const applyRenderResult = ( + result: RenderResult, + source: string, + visual: VisualEditor, + elements: RecoveryElements, + previous: string | undefined +) => { + elements.errorPanel.hidden = result.ok; + elements.error.textContent = result.ok ? "" : parser.formatDiagnostics([...result.error]); + elements.restore.disabled = !result.ok && previous === undefined; + switch (result.ok) { + case true: + visual.setContent(result.value); + break; + } + return result.ok ? source : previous; +}; -const renderSource = async (source: string) => { - const result = await renderToString(source, { theme: detectTheme() }); - previewEl.innerHTML = result.ok ? result.value : ""; - errorEl.textContent = result.ok ? "" : parser.formatDiagnostics([...result.error]); +const recoveryElements = () => { + const errorPanel = document.getElementById("error-panel"); + const error = document.getElementById("error"); + const restore = document.getElementById("restore-valid-source"); + const openSource = document.getElementById("open-source"); + const complete = + errorPanel !== null && + error !== null && + restore instanceof HTMLButtonElement && + openSource instanceof HTMLButtonElement; + return complete ? { errorPanel, error, restore, openSource } : undefined; }; -// Initial render from data attribute -const scriptTag = document.querySelector("script[data-source]"); -const initial = - scriptTag - ?.getAttribute("data-source") - ?.replace(/"/g, '"') - .replace(/>/g, ">") - .replace(/</g, "<") - .replace(/&/g, "&") ?? ""; - -void renderSource(initial); - -// Live updates from extension host -window.addEventListener("message", (e: MessageEvent) => { - void renderSource(e.data.source); -}); +const boot = (preview: HTMLElement, elements: RecoveryElements) => { + let source = initialSource(); + let lastValidSource: string | undefined; + let version = 0; + let settled = Promise.resolve(); + const visual = createVisualEditor(preview, { + getSource: () => source, + onSourceChange: (next) => { + source = next; + vscode.postMessage({ kind: "edit", source: next }); + void render(); + }, + initialPositions: persistedState().positions ?? {}, + onPositionsChange: (positions) => { + vscode.setState({ positions }); + }, + }); + const render = () => { + const current = ++version; + settled = renderToString(source, { theme: detectTheme() }).then((result) => { + switch (current === version) { + case true: + lastValidSource = applyRenderResult(result, source, visual, elements, lastValidSource); + break; + } + }); + return settled; + }; + window.addEventListener("message", (event: MessageEvent) => { + switch (event.origin === window.location.origin) { + case false: + return; + } + const update = isUpdate(event.data) ? event.data : undefined; + switch (update) { + case undefined: + break; + default: + source = update.source; + void render(); + } + const request = visualInteractionRequest(event.data); + switch (request) { + case undefined: + break; + default: + void runVisualEditorInteractions({ + preview, + getSource: () => source, + settle: () => settled, + getState: persistedState, + }).then((result) => { + vscode.postMessage({ kind: "visual-interactions-result", requestId: request.requestId, result }); + }); + } + }); + elements.restore.addEventListener("click", () => { + switch (lastValidSource) { + case undefined: + break; + default: + source = lastValidSource; + vscode.postMessage({ kind: "edit", source }); + void render(); + } + }); + elements.openSource.addEventListener("click", () => { + vscode.postMessage({ kind: "open-source" }); + }); + void render().then(() => { + vscode.postMessage({ kind: "visual-editor-ready" }); + }); +}; + +const preview = document.getElementById("preview"); +const elements = recoveryElements(); +switch (preview) { + case null: + break; + default: + switch (elements) { + case undefined: + break; + default: + boot(preview, elements); + } +} diff --git a/packages/vscode/test/electron/harness/extension.cjs b/packages/vscode/test/electron/harness/extension.cjs new file mode 100644 index 0000000..153dea6 --- /dev/null +++ b/packages/vscode/test/electron/harness/extension.cjs @@ -0,0 +1,3 @@ +// [VSCODE-VSIX-HARNESS] Minimal development extension; the product extension is installed from the VSIX. +exports.activate = () => undefined; +exports.deactivate = () => undefined; diff --git a/packages/vscode/test/electron/harness/package.json b/packages/vscode/test/electron/harness/package.json new file mode 100644 index 0000000..77a3d7f --- /dev/null +++ b/packages/vscode/test/electron/harness/package.json @@ -0,0 +1,13 @@ +{ + "name": "typediagram-vsix-e2e-harness", + "displayName": "TypeDiagram VSIX E2E Harness", + "publisher": "typediagram-tests", + "version": "0.0.0", + "engines": { + "vscode": "^1.99.0" + }, + "main": "./extension.cjs", + "activationEvents": [ + "*" + ] +} diff --git a/packages/vscode/test/electron/macos-launcher.sh b/packages/vscode/test/electron/macos-launcher.sh new file mode 100755 index 0000000..ed0e062 --- /dev/null +++ b/packages/vscode/test/electron/macos-launcher.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# [VSCODE-E2E-DARWIN-LAUNCHER] Start the GUI binary without inherited extension-host mode. +unset ELECTRON_RUN_AS_NODE +unset VSCODE_CODE_CACHE_PATH VSCODE_CRASH_REPORTER_PROCESS_TYPE VSCODE_CWD +unset VSCODE_ESM_ENTRYPOINT VSCODE_HANDLES_UNCAUGHT_ERRORS VSCODE_IPC_HOOK VSCODE_NLS_CONFIG VSCODE_PID +exec "${TYPEDIAGRAM_VSCODE_APP_PATH:?}/Contents/MacOS/Code" "$@" diff --git a/packages/vscode/test/electron/runner.mjs b/packages/vscode/test/electron/runner.mjs index 60ffe65..fbccd3b 100644 --- a/packages/vscode/test/electron/runner.mjs +++ b/packages/vscode/test/electron/runner.mjs @@ -4,20 +4,31 @@ // Kept as plain ESM (.mjs) so we don't need a TS runtime step for the launcher. import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { readdirSync, existsSync } from "node:fs"; +import { readdirSync, existsSync, rmSync, mkdirSync, statSync } from "node:fs"; import { spawnSync } from "node:child_process"; -import { runTests, resolveCliArgsFromVSCodeExecutablePath, downloadAndUnzipVSCode } from "@vscode/test-electron"; +import { runTests, downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } from "@vscode/test-electron"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PKG_ROOT = resolve(__dirname, "../.."); const REPO_ROOT = resolve(PKG_ROOT, "../.."); +const RUN_ID = `${String(process.pid)}-${String(Date.now())}`; +const RESULT_PATH = resolve(PKG_ROOT, `.vscode-test/electron-result-${RUN_ID}.ok`); +const HARNESS_PATH = resolve(__dirname, "harness"); +const DARWIN_LAUNCHER = resolve(__dirname, "macos-launcher.sh"); +const PROFILE_PATH = + process.platform === "darwin" + ? resolve("/tmp", `td-vsix-${RUN_ID}`) + : resolve(PKG_ROOT, `.vscode-test/vsix-profile-${RUN_ID}`); +const USER_DATA_PATH = resolve(PROFILE_PATH, "user-data"); +const EXTENSIONS_PATH = resolve(PROFILE_PATH, "extensions"); function findLatestVsix() { const files = readdirSync(REPO_ROOT).filter((f) => /^typediagram-.*\.vsix$/.test(f)); if (files.length === 0) { throw new Error("no .vsix found in repo root — run `npm run -w packages/vscode package` first"); } - return resolve(REPO_ROOT, files.sort().at(-1)); + const artifacts = files.map((file) => resolve(REPO_ROOT, file)); + return artifacts.sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs)[0]; } // [ELECTRON-DARWIN-ARM64-LIMITATION] On Apple Silicon, @vscode/test-electron's @@ -34,27 +45,42 @@ function checkPlatform() { } } -async function main() { - checkPlatform(); - if (!existsSync(resolve(REPO_ROOT, "packages/typediagram/dist/index.js"))) { - const r = spawnSync("npm", ["run", "-w", "typediagram-core", "build"], { - cwd: REPO_ROOT, - stdio: "inherit", - shell: process.platform === "win32", - }); - if (r.status !== 0) throw new Error("core build failed"); - } +function runNpm(args, failure) { + const result = spawnSync("npm", args, { + cwd: REPO_ROOT, + stdio: "inherit", + shell: process.platform === "win32", + }); + if (result.status !== 0) throw new Error(`${failure} (status ${result.status})`); +} - if (!readdirSync(REPO_ROOT).some((f) => /^typediagram-.*\.vsix$/.test(f))) { - const pkg = spawnSync("npm", ["run", "-w", "packages/vscode", "package"], { - cwd: REPO_ROOT, - stdio: "inherit", - shell: process.platform === "win32", - }); - if (pkg.status !== 0) throw new Error("vsix packaging failed"); - } +function installVsix(vscodeExecutablePath, configuredCli, appPath, vsixPath) { + const appCli = appPath === undefined ? undefined : resolve(appPath, "Contents/Resources/app/bin/code"); + const cli = configuredCli + ? [vscodeExecutablePath] + : appCli === undefined + ? resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath) + : [appCli]; + const result = spawnSync( + cli[0], + [ + ...cli.slice(1), + `--user-data-dir=${USER_DATA_PATH}`, + `--extensions-dir=${EXTENSIONS_PATH}`, + "--install-extension", + vsixPath, + "--force", + ], + { cwd: REPO_ROOT, stdio: "inherit", shell: process.platform === "win32" } + ); + if (result.status !== 0) throw new Error(`VSIX installation failed (status ${result.status})`); +} - void findLatestVsix; +async function main() { + checkPlatform(); + runNpm(["run", "-w", "typediagram-core", "build"], "core build failed"); + runNpm(["run", "-w", "packages/vscode", "package"], "VSIX packaging failed"); + const vsixPath = findLatestVsix(); const extensionTestsPath = resolve(__dirname, "suite/index.cjs"); // [ELECTRON-DARWIN-WORKAROUND] Pre-download VS Code so we get a concrete executable @@ -62,16 +88,32 @@ async function main() { // on Apple Silicon's "Electron" binary fails because we need the "code" entrypoint. // By calling downloadAndUnzipVSCode() + passing the executable path explicitly, // @vscode/test-electron routes arguments through the correct launcher. - const vscodeExecutablePath = await downloadAndUnzipVSCode(); - const [cli, ...args] = resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath); - void cli; - void args; + const appPath = process.env["TYPEDIAGRAM_VSCODE_APP_PATH"]; + const configuredExecutable = process.env["TYPEDIAGRAM_VSCODE_EXECUTABLE_PATH"]; + const configuredCli = configuredExecutable?.endsWith("/bin/code") === true; + const vscodeExecutablePath = + appPath === undefined ? (configuredExecutable ?? (await downloadAndUnzipVSCode())) : DARWIN_LAUNCHER; + rmSync(PROFILE_PATH, { recursive: true, force: true }); + mkdirSync(USER_DATA_PATH, { recursive: true }); + mkdirSync(EXTENSIONS_PATH, { recursive: true }); + installVsix(configuredExecutable ?? vscodeExecutablePath, configuredCli, appPath, vsixPath); + const profileArgs = [`--user-data-dir=${USER_DATA_PATH}`, `--extensions-dir=${EXTENSIONS_PATH}`]; + const launchArgs = + appPath === undefined ? (configuredCli ? ["--wait", "--new-window", ...profileArgs] : profileArgs) : profileArgs; + rmSync(RESULT_PATH, { force: true }); const exitCode = await runTests({ vscodeExecutablePath, - extensionDevelopmentPath: PKG_ROOT, + extensionDevelopmentPath: HARNESS_PATH, extensionTestsPath, + extensionTestsEnv: { TYPEDIAGRAM_ELECTRON_RESULT_PATH: RESULT_PATH }, + ...(launchArgs === undefined ? {} : { launchArgs }), }); + existsSync(RESULT_PATH) + ? undefined + : (() => { + throw new Error("VS Code exited without completing the Electron suite"); + })(); process.exit(exitCode); } diff --git a/packages/vscode/test/electron/suite/extension.spec.cjs b/packages/vscode/test/electron/suite/extension.spec.cjs index 562a09b..d56080f 100644 --- a/packages/vscode/test/electron/suite/extension.spec.cjs +++ b/packages/vscode/test/electron/suite/extension.spec.cjs @@ -2,23 +2,173 @@ // expected contributions, and the markdown injection grammar applies to typediagram // fences inside a real .md file opened in the editor. const assert = require("node:assert"); +const { readFileSync } = require("node:fs"); const path = require("node:path"); const vscode = require("vscode"); suite("typediagram extension inside a real VS Code", () => { - // When loaded via extensionDevelopmentPath the id is publisher., - // which in the monorepo is "nimblesite.typediagram-vscode". A packaged VSIX - // rewrites it to "nimblesite.typediagram". We accept either. - const candidateIds = ["nimblesite.typediagram", "nimblesite.typediagram-vscode"]; - const findExt = () => candidateIds.map((id) => vscode.extensions.getExtension(id)).find(Boolean); + const extensionId = "nimblesite.typediagram"; + const findExt = () => vscode.extensions.getExtension(extensionId); + const samplePath = path.resolve(__dirname, "../../../examples/sample.td"); + const sampleSource = readFileSync(samplePath, "utf8"); + let visualDoc; test("extension is installed and activatable", async () => { const ext = findExt(); - assert.ok(ext, `none of ${candidateIds.join(", ")} found`); + assert.ok(ext, `${extensionId} was not installed from the freshly packaged VSIX`); + assert.strictEqual(ext.packageJSON.name, "typediagram"); + assert.ok(ext.extensionPath.includes("td-vsix-"), `unexpected profile path: ${ext.extensionPath}`); + assert.ok( + ext.extensionPath.endsWith("extensions/nimblesite.typediagram-0.0.0-dev"), + `unexpected extension path: ${ext.extensionPath}` + ); await ext.activate(); assert.strictEqual(ext.isActive, true); }); + test("opens an isolated sample copy as a visual editor and reports the live panel through commands", async () => { + visualDoc = await vscode.workspace.openTextDocument({ language: "typediagram", content: sampleSource }); + await vscode.window.showTextDocument(visualDoc); + await vscode.commands.executeCommand("typediagram.preview"); + const status = await vscode.commands.executeCommand("typediagram.editorStatus"); + assert.deepStrictEqual(status, { visualEditor: true, openPanels: 1 }); + }); + + test("runs every main canvas interaction inside the packaged VSIX webview", async () => { + const doc = + visualDoc ?? (await vscode.workspace.openTextDocument({ language: "typediagram", content: sampleSource })); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("typediagram.preview"); + const result = await vscode.commands.executeCommand("typediagram.testVisualEditorInteractions"); + assert.deepStrictEqual( + result.passed, + [ + "canvas-chrome", + "invalid-edit", + "record-edit", + "union-edit", + "alias-edit", + "add-remove", + "icon-buttons", + "node-add-delete", + "drag-snap-persist", + "zoom-in-out", + "trackpad-zoom", + "fit-reset-pan", + "draw-relationship", + "generic-relationship-recovery", + "auto-layout", + "export-svg", + "close-and-escape", + ], + JSON.stringify(result, null, 2) + ); + assert.strictEqual(result.sourceUpdated, true); + + const e = result.evidence; + assert.strictEqual(e.toolbarButtons, 8); + assert.strictEqual(e.legendItems, 3); + assert.strictEqual(e.grid, true); + assert.strictEqual(e.shadow, true); + assert.ok(e.nodeCount >= 10); + assert.ok(e.ports > 30); + assert.strictEqual(e.nodeKinds, true); + assert.strictEqual(e.toolbarLabel, true); + assert.strictEqual(e.legendText, true); + assert.strictEqual(e.chromeInitiallyClosed, true); + assert.strictEqual(e.recordRenamed, true); + assert.strictEqual(e.recordFieldEdited, true); + assert.strictEqual(e.recordKind, true); + assert.strictEqual(e.recordRows, 3); + assert.strictEqual(e.recordSelected, true); + assert.strictEqual(e.renamedNode, true); + assert.strictEqual(e.oldNodeGone, true); + assert.strictEqual(e.recordRendered, true); + assert.strictEqual(e.recordEdge, true); + assert.strictEqual(e.invalidRejected, true); + assert.strictEqual(e.invalidToast, true); + assert.strictEqual(e.unionVariantRenamed, true); + assert.strictEqual(e.unionPayloadEdited, true); + assert.strictEqual(e.unionKind, true); + assert.strictEqual(e.unionRows, 4); + assert.strictEqual(e.unionRendered, true); + assert.strictEqual(e.unionEdge, true); + assert.strictEqual(e.aliasTargetEdited, true); + assert.strictEqual(e.aliasKind, true); + assert.strictEqual(e.aliasRows, 1); + assert.strictEqual(e.aliasHasNoRowControls, true); + assert.strictEqual(e.aliasRendered, true); + assert.strictEqual(e.aliasEdge, true); + assert.strictEqual(e.rowAdded, true); + assert.strictEqual(e.rowRemoved, true); + assert.strictEqual(e.afterAddRows, e.beforeRows + 1); + assert.strictEqual(e.afterRemoveRows, e.beforeRows); + assert.strictEqual(e.afterAddPorts, e.beforePorts + 1); + assert.strictEqual(e.afterRemovePorts, e.beforePorts); + assert.strictEqual(e.defaultRow, true); + assert.strictEqual(e.sourceRestored, true); + assert.strictEqual(e.closeIcon, true); + assert.ok(e.removeIconCount >= 1); + assert.strictEqual(e.removeButtonsLabelled, true); + assert.strictEqual(e.creatorButtons, 3); + assert.strictEqual(e.recordAdded, true); + assert.strictEqual(e.unionAdded, true); + assert.strictEqual(e.aliasAdded, true); + assert.strictEqual(e.recordDeleted, true); + assert.ok(e.dragX > 0); + assert.ok(e.dragY > 0); + assert.strictEqual(e.dragX % 8, 0); + assert.strictEqual(e.dragY % 8, 0); + assert.strictEqual(e.dragSnapped, true); + assert.strictEqual(e.layoutPersisted, true); + assert.strictEqual(e.inspectorHiddenOnDragStart, true); + assert.strictEqual(e.inspectorHiddenOnDragMove, true); + assert.strictEqual(e.inspectorHiddenOnDragEnd, true); + assert.notStrictEqual(e.zoomBefore, e.zoomAfterIn); + assert.notStrictEqual(e.zoomAfterIn, e.zoomAfterOut); + assert.ok(e.trackpadScale > 1); + assert.ok(e.trackpadScale < 1.01); + assert.match(e.fitTransform, /scale\(/); + assert.match(e.resetTransform, /translate\(0px, 0px\) scale\(1\)/); + assert.match(e.panTransform, /translate\(50px, 35px\) scale\(1\)/); + assert.strictEqual(e.connectionPreview, true); + assert.strictEqual(e.relationshipSource, true); + assert.strictEqual(e.relationshipRendered, true); + assert.strictEqual(e.relationshipEdge, true); + assert.strictEqual(e.relationshipClosed, true); + assert.strictEqual(e.genericSource, true); + assert.strictEqual(e.genericTargetRendered, true); + assert.strictEqual(e.genericEdge, true); + assert.strictEqual(e.genericRendered, true); + assert.strictEqual(e.fatalErrorHidden, true); + assert.strictEqual(e.recoveryActions, 2); + assert.strictEqual(e.autoX, 0); + assert.strictEqual(e.autoY, 0); + assert.strictEqual(e.autoStateEmpty, true); + assert.strictEqual(e.exportFilename, "type-diagram.svg"); + assert.strictEqual(e.inspectorClosed, true); + assert.strictEqual(e.inspectorEscaped, true); + + assert.match(doc.getText(), /type ConversationRequest/); + assert.match(doc.getText(), /prompt: TextPart/); + assert.match(doc.getText(), /option: Option/); + assert.match(doc.getText(), /ScalarValue \{ value: TextPart \}/); + assert.match(doc.getText(), /alias Email = Option/); + assert.match(doc.getText(), /union NewUnion/); + assert.match(doc.getText(), /alias NewAlias = String/); + assert.doesNotMatch(doc.getText(), /type NewRecord/); + assert.doesNotMatch(doc.getText(), /field: String/); + assert.doesNotMatch(doc.getText(), /prompt: List/g) ?? []).length, 1); + assert.deepStrictEqual(await vscode.commands.executeCommand("typediagram.editorStatus"), { + visualEditor: true, + openPanels: 1, + }); + assert.strictEqual(readFileSync(samplePath, "utf8"), sampleSource); + }); + test("package.json declares markdown injection grammar and markdown-it plugin", () => { const ext = findExt(); assert.ok(ext); diff --git a/packages/vscode/test/electron/suite/index.cjs b/packages/vscode/test/electron/suite/index.cjs index add9f2a..7fd4322 100644 --- a/packages/vscode/test/electron/suite/index.cjs +++ b/packages/vscode/test/electron/suite/index.cjs @@ -1,11 +1,14 @@ // [VSCODE-E2E-SUITE] Mocha entry point executed inside the extension host by // @vscode/test-electron. Discovers and runs test files under this directory. const path = require("node:path"); +const { writeFileSync } = require("node:fs"); const Mocha = require("mocha"); const { glob } = require("glob"); +const resultPath = + process.env.TYPEDIAGRAM_ELECTRON_RESULT_PATH ?? path.resolve(__dirname, "../../../.vscode-test/electron-result.ok"); function run() { - const mocha = new Mocha({ ui: "bdd", color: true, timeout: 30_000 }); + const mocha = new Mocha({ ui: "tdd", color: true, timeout: 30_000 }); const testsRoot = path.resolve(__dirname); return new Promise((resolve, reject) => { @@ -16,7 +19,10 @@ function run() { } mocha.run((failures) => { if (failures > 0) reject(new Error(`${failures} tests failed.`)); - else resolve(); + else { + writeFileSync(resultPath, "passed\n"); + resolve(); + } }); }) .catch(reject); diff --git a/packages/vscode/test/extension.test.ts b/packages/vscode/test/extension.test.ts index fa5d3f8..cec1c99 100644 --- a/packages/vscode/test/extension.test.ts +++ b/packages/vscode/test/extension.test.ts @@ -21,6 +21,9 @@ describe("[VSCODE-EXT] activate", () => { mock.mockPanel.webview.html = ""; mock.mockPanel._disposeCb = undefined; mock.mockPanel._preserveFocus = false; + mock.mockPanel.webview._messageHandlers = []; + mock.workspace._lastEdit = undefined; + mock.workspace.applyEdit.mockClear(); mock.commands._handler = undefined; mock.workspace._changeCb = undefined; mock.workspace._closeCb = undefined; @@ -39,6 +42,11 @@ describe("[VSCODE-EXT] activate", () => { const { ctx } = await activateExtension(); expect(mock.commands.registerCommand).toHaveBeenCalledWith("typediagram.preview", expect.any(Function)); expect(mock.commands.registerCommand).toHaveBeenCalledWith("typediagram.openAsDiagram", expect.any(Function)); + expect(mock.commands.registerCommand).toHaveBeenCalledWith("typediagram.editorStatus", expect.any(Function)); + expect(mock.commands.registerCommand).toHaveBeenCalledWith( + "typediagram.testVisualEditorInteractions", + expect.any(Function) + ); // 7 original disposables + 1 Output Channel added by initLogger. // The Output Channel may already have been added in a prior test (lazy ensureChannel), // so we assert >= 7. Either way, both commands must be present. @@ -46,6 +54,47 @@ describe("[VSCODE-EXT] activate", () => { expect(mock.window.createOutputChannel).toHaveBeenCalledWith("TypeDiagram"); }); + it("reports visual-editor readiness and open panels through the command API", async () => { + await activateExtension(); + mock.window.activeTextEditor = { document: makeDoc("type Ready { x: Int }") }; + mock.commands._handler?.(); + const status = mock.commands._handlers.get("typediagram.editorStatus")?.(); + expect(status).toEqual({ visualEditor: true, openPanels: 1 }); + }); + + it("returns dense visual-interaction evidence from the live webview command bridge", async () => { + await activateExtension(); + const empty = await mock.commands._handlers.get("typediagram.testVisualEditorInteractions")?.(); + expect(empty).toEqual({ passed: [], sourceUpdated: false, evidence: {} }); + + mock.window.activeTextEditor = { document: makeDoc("type Ready { x: Int }") }; + mock.commands._handler?.(); + const expected = { + passed: ["canvas-chrome", "record-edit"], + sourceUpdated: true, + evidence: { toolbarButtons: 7, recordRenamed: true }, + }; + let postedRequestId = ""; + mock.mockPanel.webview.postMessage.mockImplementationOnce((message: unknown) => { + // Safe: the command bridge always posts an object containing a generated requestId. + const request = message as { requestId: string }; + postedRequestId = request.requestId; + queueMicrotask(() => { + mock.mockPanel.webview._messageHandlers.forEach((handler) => { + handler({ kind: "visual-interactions-result", requestId: request.requestId, result: expected }); + }); + }); + return Promise.resolve(true); + }); + const result = await mock.commands._handlers.get("typediagram.testVisualEditorInteractions")?.(); + expect(result).toEqual(expected); + expect(mock.mockPanel.webview.postMessage).toHaveBeenCalledWith({ + kind: "test-visual-interactions", + requestId: postedRequestId, + }); + expect(postedRequestId).toMatch(/^\d+$/); + }); + it("preview command does nothing without active typediagram editor", async () => { await activateExtension(); mock.window.activeTextEditor = undefined; @@ -90,22 +139,179 @@ describe("[VSCODE-EXT] activate", () => { }); }); + it("applies visual-editor source messages to the real text document", async () => { + await activateExtension(); + const doc = makeDoc("typeDiagram\ntype X { a: Int }"); + mock.window.activeTextEditor = { document: doc }; + mock.commands._handler?.(); + const handler = mock.mockPanel.webview._messageHandlers.at(-1); + handler?.({ kind: "edit", source: "typeDiagram\ntype X { a: String }\n" }); + await vi.waitFor(() => { + expect(mock.workspace.applyEdit).toHaveBeenCalledTimes(1); + }); + expect(mock.workspace._lastEdit?.replace).toHaveBeenCalledWith( + doc.uri, + expect.any(mock.Range), + "typeDiagram\ntype X { a: String }\n" + ); + }); + + it("replaces the entire prior visual source when a field relationship follows node creation", async () => { + const initial = `type AuditEvent { + id: Uuid + createdAt: DateTime + amount: Decimal + parent: Option + history: List +} +`; + const added = `typeDiagram + +${initial.trimEnd()} + +type NewRecord { + field: String +} +`; + const connected = added.replace("field: String", "field: AuditEvent"); + const endOf = (source: string) => { + const lines = source.split("\n"); + return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 }; + }; + const doc = { ...makeDoc(initial), positionAt: () => endOf(initial) }; + mock.window.activeTextEditor = { document: doc }; + await activateExtension(); + mock.commands._handler?.(); + + const handler = mock.mockPanel.webview._messageHandlers.at(-1); + handler?.({ kind: "edit", source: added }); + handler?.({ kind: "edit", source: connected }); + await vi.waitFor(() => { + expect(mock.workspace.applyEdit).toHaveBeenCalledTimes(2); + }); + + const secondEdit = mock.workspace.applyEdit.mock.calls[1]?.[0] as mock.WorkspaceEdit; + const replacement = secondEdit.replace.mock.calls[0]; + const range = replacement?.[1] as mock.Range; + expect(replacement?.[2]).toBe(connected); + expect(range.start).toEqual(new mock.Position(0, 0)); + expect(range.end).toEqual(endOf(added)); + expect(range.end).not.toEqual(endOf(initial)); + }); + + it("undoes an invalid visual edit without retaining or duplicating its diagnostics", async () => { + const valid = "typeDiagram\ntype Person {\n age: Int\n active: Bool\n}\n"; + const invalid = `${valid}age\nactive\n`; + const endOf = (source: string) => { + const lines = source.split("\n"); + return new mock.Position(lines.length - 1, lines.at(-1)?.length ?? 0); + }; + const doc = { ...makeDoc(valid), positionAt: () => endOf(valid) }; + mock.window.activeTextEditor = { document: doc }; + await activateExtension(); + mock.commands._handler?.(); + + const handler = mock.mockPanel.webview._messageHandlers.at(-1); + handler?.({ kind: "edit", source: invalid }); + handler?.({ kind: "edit", source: valid }); + await vi.waitFor(() => { + expect(mock.workspace.applyEdit).toHaveBeenCalledTimes(2); + }); + + const invalidEdit = mock.workspace.applyEdit.mock.calls[0]?.[0] as mock.WorkspaceEdit; + const recoveryEdit = mock.workspace.applyEdit.mock.calls[1]?.[0] as mock.WorkspaceEdit; + const invalidReplacement = invalidEdit.replace.mock.calls[0]; + const recoveryReplacement = recoveryEdit.replace.mock.calls[0]; + const recoveryRange = recoveryReplacement?.[1] as mock.Range; + expect(invalidReplacement?.[2]).toBe(invalid); + expect(recoveryReplacement?.[2]).toBe(valid); + expect(recoveryRange.start).toEqual(new mock.Position(0, 0)); + expect(recoveryRange.end).toEqual(endOf(invalid)); + expect(recoveryRange.end).not.toEqual(endOf(valid)); + }); + + it("consumes visual-edit echoes without dropping genuine external document changes", async () => { + await activateExtension(); + const doc = makeDoc("typeDiagram\ntype X { a: Int }"); + const first = "typeDiagram\ntype X { a: String }\n"; + const second = "typeDiagram\ntype X { a: Text }\n"; + mock.window.activeTextEditor = { document: doc }; + mock.commands._handler?.(); + mock.mockPanel.webview.postMessage.mockClear(); + mock.workspace.applyEdit + .mockImplementationOnce(() => { + mock.workspace._changeCb?.({ document: makeDoc(first) }); + return Promise.resolve(true); + }) + .mockImplementationOnce(() => { + mock.workspace._changeCb?.({ document: makeDoc(second) }); + return Promise.resolve(true); + }); + const handler = mock.mockPanel.webview._messageHandlers.at(-1); + handler?.({ kind: "edit", source: first }); + handler?.({ kind: "edit", source: second }); + await vi.waitFor(() => { + expect(mock.workspace.applyEdit).toHaveBeenCalledTimes(2); + }); + expect(mock.mockPanel.webview.postMessage).not.toHaveBeenCalled(); + mock.workspace._changeCb?.({ document: makeDoc("typeDiagram\ntype X { a: External }\n") }); + expect(mock.mockPanel.webview.postMessage).toHaveBeenCalledOnce(); + expect(mock.mockPanel.webview.postMessage).toHaveBeenCalledWith({ + kind: "update", + source: "typeDiagram\ntype X { a: External }\n", + }); + }); + + it("opens the source document as an escape hatch from an invalid visual edit", async () => { + await activateExtension(); + const doc = makeDoc("typeDiagram\ntype X { a: Int }"); + mock.window.activeTextEditor = { document: doc }; + mock.commands._handler?.(); + const handler = mock.mockPanel.webview._messageHandlers.at(-1); + handler?.({ kind: "open-source" }); + await vi.waitFor(() => { + expect(mock.window.showTextDocument).toHaveBeenCalledWith(doc, { preview: false }); + }); + expect(mock.workspace.applyEdit).not.toHaveBeenCalled(); + expect(mock.mockPanel.dispose).not.toHaveBeenCalled(); + }); + + it("ignores malformed webview edits and labels pathless documents", async () => { + await activateExtension(); + const doc = makeDoc("typeDiagram\ntype X { a: Int }"); + doc.uri.path = ""; + mock.window.activeTextEditor = { document: doc }; + mock.commands._handler?.(); + const handler = mock.mockPanel.webview._messageHandlers.at(-1); + handler?.(null); + handler?.("edit"); + handler?.({ kind: "edit", source: 42 }); + expect(mock.workspace.applyEdit).not.toHaveBeenCalled(); + expect(mock.window.createWebviewPanel).toHaveBeenCalledWith( + "typediagram.preview", + "Preview: untitled.td", + mock.ViewColumn.Beside, + expect.any(Object) + ); + }); + it("ignores changes to non-typediagram documents", async () => { await activateExtension(); mock.workspace._changeCb?.({ document: makeDoc("x", "json") }); expect(mock.mockPanel.webview.postMessage).not.toHaveBeenCalled(); }); - it("cleans up panel reference on document close", async () => { + it("keeps a live panel registered when only its hidden text document closes", async () => { await activateExtension(); const doc = makeDoc("type Y { b: String }"); mock.window.activeTextEditor = { document: doc }; mock.commands._handler?.(); mock.workspace._closeCb?.(doc); - // After close, next open should create fresh panel mock.window.createWebviewPanel.mockClear(); + mock.mockPanel.reveal.mockClear(); mock.commands._handler?.(); - expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1); + expect(mock.window.createWebviewPanel).not.toHaveBeenCalled(); + expect(mock.mockPanel.reveal).toHaveBeenCalledTimes(1); }); it("deactivate is a no-op", async () => { @@ -235,6 +441,29 @@ describe("[VSCODE-EXT] activate", () => { expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1); }); + it("[VSCODE-EDITOR-TAB] tab-committed edit keeps exactly one live diagram when its hidden document reopens", async () => { + await activateExtension(); + const doc = makeDoc("typeDiagram\nunion Content { Scalar(String) }"); + mock.workspace._openTextDocResult = doc; + const openAsDiagram = mock.commands._handlers.get("typediagram.openAsDiagram"); + + await openAsDiagram?.(doc.uri); + expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1); + expect(mock.mockPanel._disposeCb).toBeTypeOf("function"); + + mock.workspace._closeCb?.(doc); + const webviewHandler = mock.mockPanel.webview._messageHandlers.at(-1); + webviewHandler?.({ kind: "edit", source: "typeDiagram\nunion Content { Scalar(Int) }\n" }); + await vi.waitFor(() => { + expect(mock.workspace.applyEdit).toHaveBeenCalledTimes(1); + }); + mock.workspace._openCb?.(doc); + + expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1); + expect(mock.mockPanel.dispose).not.toHaveBeenCalled(); + expect(mock.window.tabGroups.close).not.toHaveBeenCalled(); + }); + it("[VSCODE-OPEN-AS-DIAGRAM] openAsDiagram does nothing without URI or active editor", async () => { await activateExtension(); const handler = mock.commands._handlers.get("typediagram.openAsDiagram"); diff --git a/packages/vscode/test/helpers.ts b/packages/vscode/test/helpers.ts index 1b32f48..e8b6298 100644 --- a/packages/vscode/test/helpers.ts +++ b/packages/vscode/test/helpers.ts @@ -12,6 +12,7 @@ export const makeDoc = (text: string, langId = "typediagram", scheme = "file") = toString: () => `${scheme}:///test/${langId}.td`, }, getText: () => text, + positionAt: (offset: number) => ({ line: 0, character: offset }), languageId: langId, }); diff --git a/packages/vscode/test/vscode-mock.ts b/packages/vscode/test/vscode-mock.ts index 3bfee79..e7e48ea 100644 --- a/packages/vscode/test/vscode-mock.ts +++ b/packages/vscode/test/vscode-mock.ts @@ -18,12 +18,15 @@ export const mockPanel = { // renderHtmlToPdf succeeds in tests. printToPDF: vi.fn(() => Promise.resolve(FAKE_PDF)), onDidReceiveMessage: vi.fn((handler: (msg: unknown) => void) => { + mockPanel.webview._messageHandlers.push(handler); // Fire a synthetic load-ready message so the extension's awaited promise resolves. queueMicrotask(() => { handler({ kind: "td-print-ready" }); + handler({ kind: "visual-editor-ready" }); }); return { dispose: vi.fn() }; }), + _messageHandlers: [] as Array<(msg: unknown) => void>, }, reveal: vi.fn(), onDidDispose: vi.fn((cb: () => void) => { @@ -41,6 +44,27 @@ export class TabInputText { constructor(public uri: { toString: () => string }) {} } +export class Position { + constructor( + public line: number, + public character: number + ) {} +} + +export class Range { + constructor( + public start: Position, + public end: Position + ) {} +} + +export class WorkspaceEdit { + replace = vi.fn(); + constructor() { + workspace._lastEdit = this; + } +} + type Tab = { input: unknown }; type TabGroup = { tabs: Tab[] }; @@ -59,6 +83,7 @@ export const window = { visibleTextEditors: [] as { document: unknown }[], createWebviewPanel: vi.fn(() => mockPanel), createOutputChannel: vi.fn(() => mockOutputChannel), + showTextDocument: vi.fn((_doc: unknown, _options?: unknown) => Promise.resolve(undefined)), showInformationMessage: vi.fn((..._args: unknown[]) => Promise.resolve(undefined)), showErrorMessage: vi.fn((_msg: string) => Promise.resolve(undefined)), tabGroups: { @@ -100,6 +125,8 @@ export const workspace = { get: (_key: string, defaultValue?: T) => defaultValue, })), openTextDocument: vi.fn((_uri: unknown) => Promise.resolve(workspace._openTextDocResult)), + applyEdit: vi.fn((_edit: unknown) => Promise.resolve(true)), + _lastEdit: undefined as WorkspaceEdit | undefined, _openTextDocResult: undefined as unknown, onDidOpenTextDocument: vi.fn((cb: (doc: unknown) => void) => { workspace._openCb = cb; diff --git a/packages/vscode/test/webview-html.test.ts b/packages/vscode/test/webview-html.test.ts index b3443a2..338a74e 100644 --- a/packages/vscode/test/webview-html.test.ts +++ b/packages/vscode/test/webview-html.test.ts @@ -1,5 +1,7 @@ // [VSCODE-WEBVIEW-HTML-TEST] Tests for webview HTML generation. +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { parser, renderToString } from "typediagram-core"; import { webviewHtml } from "../src/webview-html.js"; describe("[VSCODE-WEBVIEW-HTML] webviewHtml", () => { @@ -38,5 +40,29 @@ describe("[VSCODE-WEBVIEW-HTML] webviewHtml", () => { const html = webviewHtml(csp, scriptUri, ""); expect(html).toContain('id="preview"'); expect(html).toContain('id="error"'); + expect(html).toContain('aria-label="Visual type editor"'); + expect(html).toContain("width: 100%; height: 100%"); + }); + + it("keeps an invalid visual edit recoverable from the error overlay", () => { + const html = webviewHtml(csp, scriptUri, "typeDiagram\ntype Safe { value: String }"); + expect(html).toContain('id="error-panel"'); + expect(html).toContain('id="restore-valid-source"'); + expect(html).toContain("Undo invalid edit"); + expect(html).toContain('aria-label="Undo invalid edit"'); + expect(html).toContain('id="open-source"'); + expect(html).toContain("Open source"); + }); + + it("ships a renderable sample for the visual editor recovery workflow", async () => { + const source = readFileSync(new URL("../examples/sample.td", import.meta.url), "utf8"); + const parsed = parser.parse(source); + const rendered = await renderToString(source); + expect(parsed.ok).toBe(true); + expect(parsed.ok ? parsed.value.decls.length : 0).toBeGreaterThan(8); + expect(rendered.ok).toBe(true); + expect(rendered.ok ? rendered.value : "").toContain(""); + expect(source).toContain("alias Email = String"); }); }); diff --git a/packages/web/e2e/converter.spec.ts b/packages/web/e2e/converter.spec.ts index da31c41..c5bf0c2 100644 --- a/packages/web/e2e/converter.spec.ts +++ b/packages/web/e2e/converter.spec.ts @@ -55,10 +55,10 @@ test.describe("[WEB-CONVERTER]", () => { test("initial layout: tabs, labels, sample text, panels", async ({ page }) => { const labels = await page.$$eval(".conv-lang-tab", (tabs) => tabs.map((t) => t.textContent)); - for (const name of ["TypeScript", "Rust", "Python", "Go", "C#", "F#", "Dart", "Protobuf", "PHP"]) { + for (const name of ["TypeScript", "Rust", "Python", "Typeshed", "Go", "C#", "F#", "Dart", "Protobuf", "PHP"]) { expect(labels).toContain(name); } - expect(labels.length).toBe(9); + expect(labels.length).toBe(10); expect(await page.$eval(".conv-lang-tab--active", (el) => el.textContent)).toBe("TypeScript"); expect(await page.$eval("#conv-left-label", (el) => el.textContent)).toBe("typediagram"); expect(await page.$eval("#conv-right-label", (el) => el.textContent)).toBe("typescript"); @@ -121,6 +121,32 @@ test.describe("[WEB-CONVERTER]", () => { expect(preview).not.toContain("No Rust type definitions found"); }); + test("Typeshed + flip imports dataclasses and module functions but excludes methods", async ({ page }) => { + await page.locator('[data-lang="typeshed"]').click(); + await waitForTdCode(page, "class ChatRequest"); + await page.locator("#conv-flip").click(); + await waitForEditorContains(page, "class ChatRequest"); + await page.locator("#conv-editor").fill(`from dataclasses import dataclass + +@dataclass +class Payload: + value: str + def encode(self) -> bytes: ... + +def fetch(payload: Payload) -> bytes: ... +`); + await waitForTdCode(page, "function fetch"); + + const td = await page.$eval("#conv-td code", (el) => el.textContent); + const preview = await page.$eval("#conv-preview", (el) => el.innerHTML); + expect(td).toContain("type Payload"); + expect(td).toContain("value: String"); + expect(td).toContain("function fetch(payload: Payload) -> Bytes"); + expect(td).not.toContain("encode"); + expect(preview).toContain('data-kind="function"'); + expect(preview).toContain('data-decl="Payload"'); + }); + test("flipping back restores the last known TD source", async ({ page }) => { const original = await page.$eval("#conv-editor", (el) => (el as HTMLTextAreaElement).value); await page.locator("#conv-flip").click(); diff --git a/packages/web/e2e/visual-editor.spec.ts b/packages/web/e2e/visual-editor.spec.ts new file mode 100644 index 0000000..4c62211 --- /dev/null +++ b/packages/web/e2e/visual-editor.spec.ts @@ -0,0 +1,442 @@ +// [WEB-VISUAL-EDITOR-E2E] Whole-app canvas editing in a real browser. +import { expect, test } from "./support/coverage-fixture.js"; +import type { Locator, Page } from "@playwright/test"; + +type Point = { x: number; y: number }; + +const dispatchPointerDrag = async (source: Locator, from: Point, to: Point, releaseSelector?: string) => { + return source.evaluate( + (element, points) => { + const svg = (element as SVGElement).ownerSVGElement; + const inspector = element.ownerDocument.querySelector(".td-inspector"); + const pointer = (type: string, point: Point, buttons: number) => + new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 7, + pointerType: "mouse", + isPrimary: true, + buttons, + clientX: point.x, + clientY: point.y, + }); + element.dispatchEvent(pointer("pointerdown", points.from, 1)); + const hiddenOnDown = inspector?.hidden === true; + svg?.dispatchEvent(pointer("pointermove", points.to, 1)); + const hiddenOnMove = inspector?.hidden === true; + const release = + points.releaseSelector === undefined ? svg : element.ownerDocument.querySelector(points.releaseSelector); + release?.dispatchEvent(pointer("pointerup", points.to, 0)); + return { hiddenOnDown, hiddenOnMove, hiddenOnUp: inspector?.hidden === true }; + }, + { from, to, releaseSelector } + ); +}; + +const openEditor = async (page: Page) => { + await page.goto("/"); + await page.evaluate(() => { + localStorage.clear(); + }); + await page.reload(); + await page.waitForSelector('#preview svg [data-decl="ChatRequest"]'); +}; + +test.describe("[WEB-VISUAL-EDITOR]", () => { + test("edits types directly while dragging nodes, drawing relations, zooming, and preserving layout", async ({ + page, + }) => { + await openEditor(page); + await expect(page.locator("#preview.td-visual-editor")).toHaveCount(1); + await expect(page.locator(".td-canvas-toolbar .td-canvas-button")).toHaveCount(8); + await expect(page.locator(".td-canvas-legend .td-legend-item")).toHaveCount(3); + await expect(page.locator("#preview svg #td-grid")).toHaveCount(1); + await expect(page.locator("#preview svg #td-ambient-shadow")).toHaveCount(1); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(11); + expect(await page.locator("#preview .td-port").count()).toBeGreaterThan(40); + await expect(page.locator(".td-node-creator")).toBeHidden(); + await expect(page.locator(".td-inspector")).toBeHidden(); + await expect(page.locator(".td-canvas-toolbar")).toHaveAttribute("role", "toolbar"); + await expect(page.locator(".td-canvas-toolbar")).toHaveAttribute("aria-label", "Canvas controls"); + await expect(page.locator(".td-canvas-legend")).toHaveAttribute("aria-label", "Diagram legend"); + await expect(page.locator(".td-canvas-legend .td-legend-item")).toHaveText(["Type", "Union", "Alias"]); + const initialZoomPercent = Number((await page.locator(".td-zoom-value").textContent())?.replace("%", "") ?? "0"); + expect(initialZoomPercent).toBeGreaterThan(0); + expect(initialZoomPercent).toBeLessThan(100); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute("style", /scale\(/); + + const canvasBackground = await page + .locator("#preview") + .evaluate((element) => getComputedStyle(element).backgroundImage); + expect(canvasBackground).toContain("linear-gradient"); + expect(canvasBackground).toContain("radial-gradient"); + + const request = page.locator('[data-decl="ChatRequest"]'); + await request.click(); + await expect(request).toHaveClass(/td-selected/); + await expect(page.locator(".td-inspector")).toBeVisible(); + await expect(page.locator(".td-inspector-kind")).toHaveText("record"); + await expect(page.locator(".td-inspector-row")).toHaveCount(3); + await expect(page.locator(".td-inspector input").first()).toHaveValue("ChatRequest"); + await expect(page.getByRole("button", { name: "Delete ChatRequest" })).toHaveText("Delete type"); + await expect(page.locator("#preview .td-selected")).toHaveCount(1); + await expect(page.getByRole("button", { name: "Close properties" }).locator('svg[aria-hidden="true"]')).toHaveCount( + 1 + ); + + const declarationInput = page.locator(".td-inspector input").first(); + await declarationInput.fill("ConversationRequest"); + await declarationInput.blur(); + await expect(page.locator("#editor")).toHaveValue(/type ConversationRequest/); + await expect(page.locator('[data-decl="ConversationRequest"]')).toHaveCount(1); + await expect(page.locator('[data-decl="ChatRequest"]')).toHaveCount(0); + await expect(page.locator("#editor")).not.toHaveValue(/type ChatRequest/); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(11); + await expect + .poll(() => page.evaluate(() => localStorage.getItem("td-playground-source"))) + .toContain("type ConversationRequest"); + + await page.locator('[data-decl="ConversationRequest"]').click(); + const firstFieldName = page.locator(".td-inspector-row").first().locator("input").first(); + await firstFieldName.fill("prompt"); + await firstFieldName.blur(); + const invalidType = page.locator(".td-inspector-row").first().locator("input").nth(1); + await invalidType.fill("List<"); + await invalidType.blur(); + await expect(page.locator(".td-editor-toast")).toBeVisible(); + await expect(page.locator(".td-editor-toast")).not.toBeEmpty(); + await expect(invalidType).toHaveValue("List<"); + await expect(page.locator("#editor")).not.toHaveValue(/prompt: List"); + await validType.blur(); + await expect(page.locator("#editor")).toHaveValue(/prompt: Option/); + await expect(page.locator(".td-inspector-row").first().locator("input").first()).toHaveValue("prompt"); + await expect(page.locator(".td-inspector-row").first().locator("input").nth(1)).toHaveValue("Option"); + await expect(page.locator('[data-edge][data-source="ConversationRequest"][data-target="Option"]')).toHaveCount(1); + + const sourceBeforeTab = await page.locator("#editor").inputValue(); + await page.locator(".td-inspector-row").first().locator("input").first().focus(); + await page.keyboard.press("Tab"); + await expect(page.locator(".td-inspector-row").first().locator("input").nth(1)).toBeFocused(); + await expect(page.locator("#preview.td-visual-editor")).toHaveCount(1); + await expect(page.locator("#editor")).toHaveValue(sourceBeforeTab); + + await page.getByRole("button", { name: "Close properties" }).click(); + await page.locator('[data-decl="ToolResultContent"]').click(); + await expect(page.locator(".td-inspector-kind")).toHaveText("union"); + await expect(page.locator(".td-inspector-row")).toHaveCount(4); + await expect(page.locator(".td-inspector-add")).toHaveText("+ Add row"); + await expect(page.locator(".td-inspector-delete")).toHaveAttribute("aria-label", "Delete ToolResultContent"); + const scalarRow = page.locator(".td-inspector-row").nth(1); + await scalarRow.locator("input").first().fill("ScalarValue"); + await scalarRow.locator("input").first().blur(); + await page.locator(".td-inspector-row").nth(1).locator("input").nth(1).fill("TextPart"); + await page.locator(".td-inspector-row").nth(1).locator("input").nth(1).blur(); + await expect(page.locator("#editor")).toHaveValue(/ScalarValue \{ value: TextPart \}/); + await expect(page.locator('[data-decl="ToolResultContent"]')).toContainText("ScalarValue"); + await expect(page.locator('[data-decl="ToolResultContent"]')).not.toContainText("Scalar { value: String }"); + await expect(page.locator('[data-edge][data-source="ToolResultContent"][data-target="TextPart"]')).toHaveCount(1); + const unionRowsBeforeAdd = await page.locator(".td-inspector-row").count(); + await page.locator(".td-inspector-add").click(); + await expect(page.locator(".td-inspector-row")).toHaveCount(unionRowsBeforeAdd + 1); + await expect(page.locator(".td-inspector-row").last().locator("input").first()).toHaveValue("Variant"); + await expect(page.locator(".td-inspector-row").last().locator("input").nth(1)).toHaveValue(""); + await expect(page.locator("#editor")).toHaveValue(/ {2}Variant\n/); + await page.locator(".td-inspector-row").last().locator("input").first().fill("Cancelled"); + await page.locator(".td-inspector-row").last().locator("input").first().blur(); + await expect(page.locator("#editor")).toHaveValue(/ {2}Cancelled\n/); + await expect(page.locator('[data-decl="ToolResultContent"]')).toContainText("Cancelled"); + await page.locator(".td-inspector-row").last().locator(".td-inspector-remove").click(); + await expect(page.locator(".td-inspector-row")).toHaveCount(unionRowsBeforeAdd); + await expect(page.locator("#editor")).not.toHaveValue(/ {2}Cancelled\n/); + + await page.getByRole("button", { name: "Close properties" }).click(); + await page.locator('[data-decl="Email"]').click(); + await expect(page.locator(".td-inspector-kind")).toHaveText("alias"); + await expect(page.locator(".td-inspector-row")).toHaveCount(1); + await expect(page.locator(".td-inspector-add")).toHaveCount(0); + await expect(page.locator(".td-inspector-remove")).toHaveCount(0); + await page.locator(".td-inspector-row input").nth(1).fill("Option"); + await page.locator(".td-inspector-row input").nth(1).blur(); + await expect(page.locator("#editor")).toHaveValue(/alias Email = Option/); + await expect(page.locator('[data-decl="Email"]')).toContainText("Option"); + await expect(page.locator('[data-edge][data-source="Email"][data-target="Option"]')).toHaveCount(1); + + await page.getByRole("button", { name: "Close properties" }).click(); + await page.locator('[data-decl="ConversationRequest"]').click(); + const rows = page.locator(".td-inspector-row"); + const beforeAdd = await rows.count(); + const portsBeforeAdd = await page.locator('[data-decl="ConversationRequest"] .td-source-port').count(); + await page.locator(".td-inspector-add").click(); + await expect(page.locator("#editor")).toHaveValue(/field: String/); + await expect(rows).toHaveCount(beforeAdd + 1); + await expect(rows.last().locator("input").first()).toHaveValue("field"); + await expect(rows.last().locator("input").nth(1)).toHaveValue("String"); + await expect(page.locator('[data-decl="ConversationRequest"] .td-source-port')).toHaveCount(portsBeforeAdd + 1); + const beforeRemove = await rows.count(); + await expect(rows.last().locator('.td-inspector-remove svg[aria-hidden="true"]')).toHaveCount(1); + await expect(rows.last().locator(".td-inspector-remove")).toHaveAttribute("aria-label", "Remove row"); + await rows.last().locator(".td-inspector-remove").click(); + await expect(page.locator(".td-inspector-row")).toHaveCount(beforeRemove - 1); + await expect(page.locator("#editor")).not.toHaveValue(/field: String/); + await expect(page.locator('[data-decl="ConversationRequest"] .td-source-port')).toHaveCount(portsBeforeAdd); + + const addType = page.getByRole("button", { name: "Add type" }); + await addType.click(); + await expect(page.locator(".td-node-creator")).toBeVisible(); + await expect(page.locator(".td-node-creator button")).toHaveCount(3); + await addType.click(); + await expect(page.locator(".td-node-creator")).toBeHidden(); + await addType.click(); + await expect(page.locator(".td-node-creator")).toBeVisible(); + await page.getByRole("button", { name: "Add record type" }).click(); + await expect(page.locator("#editor")).toHaveValue(/type NewRecord/); + await expect(page.locator("#editor")).toHaveValue(/type NewRecord \{\n {2}field: String\n\}/); + await expect(page.locator('[data-decl="NewRecord"]')).toHaveCount(1); + await expect(page.locator(".td-node-creator")).toBeHidden(); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(12); + await addType.click(); + await page.getByRole("button", { name: "Add union type" }).click(); + await expect(page.locator("#editor")).toHaveValue(/union NewUnion/); + await expect(page.locator("#editor")).toHaveValue(/union NewUnion \{\n {2}Variant\n\}/); + await expect(page.locator('[data-decl="NewUnion"]')).toHaveCount(1); + await expect(page.locator(".td-node-creator")).toBeHidden(); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(13); + await addType.click(); + await page.getByRole("button", { name: "Add alias type" }).click(); + await expect(page.locator("#editor")).toHaveValue(/alias NewAlias = String/); + await expect(page.locator('[data-decl="NewAlias"]')).toHaveCount(1); + await expect(page.locator(".td-node-creator")).toBeHidden(); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(14); + await page.getByRole("button", { name: "Close properties" }).click(); + await page.locator('[data-decl="NewRecord"]').click(); + await expect(page.locator(".td-inspector input").first()).toHaveValue("NewRecord"); + await expect(page.locator(".td-inspector-row input").first()).toHaveValue("field"); + await page.locator(".td-inspector-row input").first().fill("part"); + await page.locator(".td-inspector-row input").first().blur(); + await page.locator(".td-inspector-row input").nth(1).fill("TextPart"); + await page.locator(".td-inspector-row input").nth(1).blur(); + await expect(page.locator("#editor")).toHaveValue(/type NewRecord \{\n {2}part: TextPart\n\}/); + await expect(page.locator('[data-edge][data-source="NewRecord"][data-target="TextPart"]')).toHaveCount(1); + await expect(page.getByRole("button", { name: "Delete NewRecord" })).toHaveText("Delete type"); + await page.getByRole("button", { name: "Delete NewRecord" }).click(); + await expect(page.locator("#editor")).not.toHaveValue(/type NewRecord/); + await expect(page.locator('[data-decl="NewRecord"]')).toHaveCount(0); + await expect(page.locator(".td-inspector")).toBeHidden(); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(13); + + await page.locator('[data-decl="NewUnion"]').click(); + await expect(page.locator(".td-inspector-kind")).toHaveText("union"); + await page.locator(".td-inspector-row input").first().fill("Ready"); + await page.locator(".td-inspector-row input").first().blur(); + await page.locator(".td-inspector-row input").nth(1).fill("TextPart"); + await page.locator(".td-inspector-row input").nth(1).blur(); + await expect(page.locator("#editor")).toHaveValue(/union NewUnion \{\n {2}Ready\(TextPart\)\n\}/); + await expect(page.locator('[data-edge][data-source="NewUnion"][data-target="TextPart"]')).toHaveCount(1); + await expect(page.getByRole("button", { name: "Delete NewUnion" })).toHaveText("Delete type"); + await page.getByRole("button", { name: "Delete NewUnion" }).click(); + await expect(page.locator("#editor")).not.toHaveValue(/union NewUnion/); + await expect(page.locator('[data-decl="NewUnion"]')).toHaveCount(0); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(12); + + await page.locator('[data-decl="NewAlias"]').click(); + await expect(page.locator(".td-inspector-kind")).toHaveText("alias"); + await page.locator(".td-inspector-row input").nth(1).fill("TextPart"); + await page.locator(".td-inspector-row input").nth(1).blur(); + await expect(page.locator("#editor")).toHaveValue(/alias NewAlias = TextPart/); + await expect(page.locator('[data-edge][data-source="NewAlias"][data-target="TextPart"]')).toHaveCount(1); + await expect(page.getByRole("button", { name: "Delete NewAlias" })).toHaveText("Delete type"); + await page.getByRole("button", { name: "Delete NewAlias" }).click(); + await expect(page.locator("#editor")).not.toHaveValue(/alias NewAlias/); + await expect(page.locator('[data-decl="NewAlias"]')).toHaveCount(0); + await expect(page.locator(".td-inspector")).toBeHidden(); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(11); + + const movable = page.locator('[data-decl="ConversationRequest"]'); + const movableBox = await movable.boundingBox(); + expect(movableBox).not.toBeNull(); + const centerX = (movableBox?.x ?? 0) + (movableBox?.width ?? 0) / 2; + const centerY = (movableBox?.y ?? 0) + 12; + const dragInspector = await dispatchPointerDrag( + movable, + { x: centerX, y: centerY }, + { x: centerX + 64, y: centerY + 40 } + ); + expect(dragInspector).toEqual({ hiddenOnDown: true, hiddenOnMove: true, hiddenOnUp: true }); + const movedPosition = await movable.evaluate((element) => ({ + x: Number((element as SVGGElement).dataset.editorX), + y: Number((element as SVGGElement).dataset.editorY), + })); + expect(movedPosition.x).toBeGreaterThan(0); + expect(movedPosition.y).toBeGreaterThan(0); + expect(movedPosition.x % 8).toBe(0); + expect(movedPosition.y % 8).toBe(0); + await expect(movable).toHaveAttribute( + "transform", + `translate(${String(movedPosition.x)} ${String(movedPosition.y)})` + ); + await expect(page.locator(".td-inspector")).toBeHidden(); + await expect(page.locator("#preview .td-selected")).toHaveCount(1); + await expect(movable).toHaveClass(/td-selected/); + await expect + .poll(() => page.evaluate(() => localStorage.getItem("td-playground-positions"))) + .toContain("ConversationRequest"); + + const persistedSource = await page.locator("#editor").inputValue(); + await page.reload(); + await page.waitForSelector('[data-decl="ConversationRequest"]'); + await expect(page.locator("#editor")).toHaveValue(persistedSource); + await expect(page.locator('[data-decl="ConversationRequest"]')).toHaveAttribute( + "transform", + `translate(${String(movedPosition.x)} ${String(movedPosition.y)})` + ); + await expect(page.locator('[data-decl="ChatRequest"]')).toHaveCount(0); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(11); + await expect(page.locator(".td-inspector")).toBeHidden(); + + const transformBeforeZoom = await page.locator("#preview .viewport-wrapper").getAttribute("style"); + const zoomBeforeButton = Number((await page.locator(".td-zoom-value").textContent())?.replace("%", "") ?? "0"); + await page.getByRole("button", { name: "Zoom in" }).click(); + const transformAfterZoom = await page.locator("#preview .viewport-wrapper").getAttribute("style"); + const zoomAfterButton = Number((await page.locator(".td-zoom-value").textContent())?.replace("%", "") ?? "0"); + expect(transformAfterZoom).not.toBe(transformBeforeZoom); + expect(zoomAfterButton).toBeGreaterThan(zoomBeforeButton); + await page.getByRole("button", { name: "Zoom out" }).click(); + await expect(page.locator(".td-zoom-value")).toHaveText(`${String(zoomBeforeButton)}%`); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute("style", transformBeforeZoom ?? ""); + await page.getByRole("button", { name: "Fit diagram to view" }).click(); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute("style", /scale\(/); + await expect(page.locator(".td-zoom-value")).not.toHaveText("100%"); + + await page.getByRole("button", { name: "Reset canvas" }).click(); + await expect(page.locator(".td-zoom-value")).toHaveText("100%"); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute( + "style", + /translate\(0px, 0px\) scale\(1\)/ + ); + await page.locator("#preview").dispatchEvent("wheel", { + deltaY: -1, + clientX: 200, + clientY: 200, + }); + const trackpadScale = await page.locator("#preview .viewport-wrapper").evaluate((element) => { + const match = element.style.transform.match(/scale\(([^)]+)\)/); + return Number(match?.[1] ?? "0"); + }); + expect(trackpadScale).toBeGreaterThan(1); + expect(trackpadScale).toBeLessThan(1.01); + await page.getByRole("button", { name: "Reset canvas" }).click(); + await page.locator("#preview").evaluate((element) => { + const pointer = (type: string, x: number, y: number, buttons: number) => + new PointerEvent(type, { bubbles: true, pointerId: 11, clientX: x, clientY: y, buttons }); + element.dispatchEvent(pointer("pointerdown", 12, 18, 1)); + element.dispatchEvent(pointer("pointermove", 62, 53, 1)); + element.dispatchEvent(pointer("pointerup", 62, 53, 0)); + }); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute( + "style", + /translate\(50px, 35px\) scale\(1\)/ + ); + await page.getByRole("button", { name: "Reset canvas" }).click(); + await page.locator("#preview").focus(); + await page.locator("#preview").dispatchEvent("keydown", { key: "+", bubbles: true }); + await expect(page.locator(".td-zoom-value")).toHaveText("112%"); + await page.locator("#preview").dispatchEvent("keydown", { key: "-", bubbles: true }); + await expect(page.locator(".td-zoom-value")).toHaveText("100%"); + await page.locator("#preview").dispatchEvent("keydown", { key: "f", bubbles: true }); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute("style", /scale\(/); + await page.locator("#preview").dispatchEvent("keydown", { key: "0", bubbles: true }); + await expect(page.locator(".td-zoom-value")).toHaveText("100%"); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute( + "style", + /translate\(0px, 0px\) scale\(1\)/ + ); + + const sourcePort = page.locator('[data-decl="ConversationRequest"] .td-source-port[data-row-index="0"]'); + const target = page.locator('[data-decl="AgentConfig"] .td-target-port'); + const sourceBox = await sourcePort.boundingBox(); + const targetBox = await target.boundingBox(); + expect(sourceBox).not.toBeNull(); + expect(targetBox).not.toBeNull(); + await dispatchPointerDrag( + sourcePort, + { x: (sourceBox?.x ?? 0) + 2, y: (sourceBox?.y ?? 0) + 2 }, + { + x: (targetBox?.x ?? 0) + (targetBox?.width ?? 0) / 2, + y: (targetBox?.y ?? 0) + (targetBox?.height ?? 0) / 2, + }, + '[data-decl="AgentConfig"] .td-target-port' + ); + await expect(page.locator("#editor")).toHaveValue(/prompt: AgentConfig/); + await expect(page.locator('[data-edge][data-source="ConversationRequest"][data-target="AgentConfig"]')).toHaveCount( + 1 + ); + await expect(page.locator('[data-decl="ConversationRequest"]')).toContainText("prompt: AgentConfig"); + await expect(page.locator(".td-connection-preview")).toHaveCount(0); + await expect(page.locator("#preview .td-selected")).toHaveCount(0); + await expect(page.locator(".td-inspector")).toBeHidden(); + + const genericPort = page.locator('[data-decl="ToolResult"] .td-source-port[data-row-index="-1"]'); + const genericTarget = page.locator('[data-decl="Option"] .td-target-port'); + const genericPortBox = await genericPort.boundingBox(); + const genericTargetBox = await genericTarget.boundingBox(); + expect(genericPortBox).not.toBeNull(); + expect(genericTargetBox).not.toBeNull(); + await dispatchPointerDrag( + genericPort, + { x: (genericPortBox?.x ?? 0) + 2, y: (genericPortBox?.y ?? 0) + 2 }, + { + x: (genericTargetBox?.x ?? 0) + (genericTargetBox?.width ?? 0) / 2, + y: (genericTargetBox?.y ?? 0) + (genericTargetBox?.height ?? 0) / 2, + }, + '[data-decl="Option"] .td-target-port' + ); + await expect(page.locator("#editor")).toHaveValue(/option: Option/); + await expect(page.locator('[data-decl="Option"]')).toHaveCount(1); + await expect(page.locator('[data-edge][data-source="ToolResult"][data-target="Option"]')).toHaveCount(1); + await expect(page.locator('[data-decl="ToolResult"]')).toContainText("option: Option"); + await expect(page.locator("#preview > .viewport-wrapper > svg")).toHaveCount(1); + await expect(page.locator(".td-connection-preview")).toHaveCount(0); + await expect(page.locator("#preview .td-selected")).toHaveCount(0); + await expect(page.locator(".td-inspector")).toBeHidden(); + await expect(page.locator(".td-editor-toast")).toBeHidden(); + + await page.getByRole("button", { name: "Restore automatic layout" }).click(); + await expect.poll(() => page.evaluate(() => localStorage.getItem("td-playground-positions"))).toBe("{}"); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(11); + expect( + await page.locator('[data-decl="ConversationRequest"]').evaluate((element) => ({ + x: Number((element as SVGGElement).dataset.editorX), + y: Number((element as SVGGElement).dataset.editorY), + })) + ).toEqual({ x: 0, y: 0 }); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export SVG" }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("type-diagram.svg"); + expect(await download.failure()).toBeNull(); + + await page.locator('[data-decl="ConversationRequest"]').click(); + await expect(page.locator(".td-inspector")).toBeVisible(); + await expect(page.locator(".td-inspector input").first()).toHaveValue("ConversationRequest"); + await page.getByRole("button", { name: "Close properties" }).click(); + await expect(page.locator(".td-inspector")).toBeHidden(); + await page.locator('[data-decl="ConversationRequest"]').click(); + await expect(page.locator("#preview")).toBeFocused(); + await expect(page.locator(".td-inspector")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.locator(".td-inspector")).toBeHidden(); + await expect(page.locator("#preview.td-visual-editor")).toHaveCount(1); + await expect(page.locator("#editor")).toHaveValue(/type ConversationRequest/); + await expect(page.locator("#editor")).toHaveValue(/prompt: AgentConfig/); + await expect(page.locator("#editor")).toHaveValue(/ScalarValue \{ value: TextPart \}/); + await expect(page.locator("#editor")).toHaveValue(/alias Email = Option/); + await expect(page.locator("#editor")).toHaveValue(/option: Option/); + await expect(page.locator("#editor")).not.toHaveValue(/type NewRecord/); + await expect(page.locator("#editor")).not.toHaveValue(/union NewUnion/); + await expect(page.locator("#editor")).not.toHaveValue(/alias NewAlias/); + await expect(page.locator("#editor")).not.toHaveValue(/prompt: List p.split(sep).join("/"); -type DocEntry = { slug: string; label: string; title: string; html: string; isTopLevel: boolean }; +// [WEB-DOCS-NAV] `group` nests an entry under a collapsible parent in the docs +// sidebar; entries without a group render at the top level in listed order. +type DocEntry = { + slug: string; + label: string; + title: string; + html: string; + isTopLevel: boolean; + group?: string; +}; -const handwritten: ReadonlyArray<{ slug: string; label: string }> = [ +const handwritten: ReadonlyArray<{ slug: string; label: string; group?: string }> = [ { slug: "getting-started", label: "Getting Started" }, { slug: "language-reference", label: "Language Reference" }, { slug: "cli", label: "CLI" }, { slug: "multi-language-pipeline", label: "Multi-Language Pipeline" }, { slug: "converters", label: "Converters" }, + { slug: "typeshed-conversion", label: "Typeshed Conversion" }, { slug: "render-hooks", label: "Render Hooks" }, - { slug: "tdbin", label: "TDBIN Binary Codec" }, - { slug: "tdbin-wire-format", label: "TDBIN Wire Format" }, - { slug: "tdbin-rust-api", label: "TDBIN Rust API" }, - { slug: "tdbin-future-typescript", label: "TDBIN TypeScript Roadmap" }, - { slug: "tdbin-future-reader", label: "TDBIN Reader Roadmap" }, + { slug: "tdbin", label: "TDBIN Binary Codec", group: "TDBIN" }, + { slug: "tdbin-benchmarks", label: "Benchmarks", group: "TDBIN" }, + { slug: "tdbin-wire-format", label: "Wire Format", group: "TDBIN" }, + { slug: "tdbin-rust-api", label: "Rust API", group: "TDBIN" }, + { slug: "tdbin-future-typescript", label: "TypeScript Roadmap", group: "TDBIN" }, + { slug: "tdbin-future-reader", label: "Reader Roadmap", group: "TDBIN" }, { slug: "api", label: "Node.js API" }, ]; @@ -38,11 +49,15 @@ const introEntry: DocEntry = { html: mdToHtml(`# Introduction\n\n${SHARED_INTRO_MD}`), }; -const loadHandwritten = (slug: string, label: string): DocEntry => ({ +const docTitle = (label: string, group?: string) => + group === undefined || label.startsWith(group) ? label : `${group} ${label}`; + +const loadHandwritten = (slug: string, label: string, group?: string): DocEntry => ({ slug, label, - title: label, + title: docTitle(label, group), isTopLevel: true, + ...(group === undefined ? {} : { group }), html: mdToHtml(readFileSync(resolve(DOCS_DIR, `${slug}.md`), "utf-8")), }); @@ -83,4 +98,4 @@ const loadApiEntries = (): DocEntry[] => { }); }; -export default [introEntry, ...handwritten.map((d) => loadHandwritten(d.slug, d.label)), ...loadApiEntries()]; +export default [introEntry, ...handwritten.map((d) => loadHandwritten(d.slug, d.label, d.group)), ...loadApiEntries()]; diff --git a/packages/web/eleventy/_includes/docs-nav-groups.njk b/packages/web/eleventy/_includes/docs-nav-groups.njk index 666fb11..619ebd2 100644 --- a/packages/web/eleventy/_includes/docs-nav-groups.njk +++ b/packages/web/eleventy/_includes/docs-nav-groups.njk @@ -1,8 +1,27 @@ {% set currentDocSlug = activeSlug | default("") %} +{# [WEB-DOCS-NAV] Hierarchical sidebar: ungrouped docs render at the top level; + docs sharing a `group` collapse under one
parent, opened when the + active page lives inside it. #}
    {% for d in docs %} {% if "api/" not in d.slug %} -
  • {{ d.label }}
  • + {% if not d.group %} +
  • {{ d.label }}
  • + {% elif d.group and (docs | selectattr("group", "equalto", d.group) | first).slug == d.slug %} + {% set groupDocs = docs | selectattr("group", "equalto", d.group) | list %} + {% set groupActive = false %} + {% for g in groupDocs %}{% if g.slug == currentDocSlug %}{% set groupActive = true %}{% endif %}{% endfor %} +
  • + + {{ d.group }} + +
+ + {% endif %} {% endif %} {% endfor %} diff --git a/packages/web/eleventy/converter.njk b/packages/web/eleventy/converter.njk index c89efcf..df96eb9 100644 --- a/packages/web/eleventy/converter.njk +++ b/packages/web/eleventy/converter.njk @@ -1,7 +1,7 @@ --- layout: base.njk -title: Convert TypeScript, Python, Rust, Go, C#, F#, Dart, PHP & Protobuf to Diagrams — typeDiagram -description: Paste TypeScript, Rust, Python, Go, C#, F#, Dart, PHP, or Protobuf code and instantly convert it to typeDiagram DSL plus an SVG diagram. Free online converter, no signup. +title: Convert Typeshed, Python, TypeScript, Rust & More to Diagrams — typeDiagram +description: Paste Typeshed stubs, Python, TypeScript, Rust, Go, C#, F#, Dart, PHP, or Protobuf code and instantly convert it to typeDiagram DSL plus an SVG diagram. permalink: /converter.html scriptSrc: /src/converter-main.ts bodyClass: converter-body @@ -17,7 +17,7 @@ structuredData: "@type": "WebApplication" name: "typeDiagram Converter" url: "https://typediagram.dev/converter.html" - description: "Convert TypeScript, Python, Rust, Go, C#, F#, Dart, PHP, or Protobuf type definitions into typeDiagram DSL and an SVG diagram." + description: "Convert Typeshed stubs, Python, TypeScript, Rust, Go, C#, F#, Dart, PHP, or Protobuf definitions into typeDiagram DSL and an SVG diagram." applicationCategory: "DeveloperApplication" operatingSystem: "Any (browser)" offers: @@ -29,7 +29,7 @@ structuredData:

Convert any language to a type diagram.

-

Paste TypeScript, Rust, Python, Go, C#, F#, Dart, PHP, or Protobuf — get typeDiagram source and SVG instantly.

+

Paste Typeshed stubs, TypeScript, Rust, Python, Go, C#, F#, Dart, PHP, or Protobuf — get typeDiagram source and SVG instantly.

diff --git a/packages/web/eleventy/docs-index.njk b/packages/web/eleventy/docs-index.njk index a960aa7..2a30c54 100644 --- a/packages/web/eleventy/docs-index.njk +++ b/packages/web/eleventy/docs-index.njk @@ -2,7 +2,7 @@ layout: base.njk permalink: /docs/index.html title: "typeDiagram Docs — DSL, Code Generation, TDBIN, and API" -description: "typeDiagram documentation: getting started, language reference, CLI, multi-language code generation, TDBIN binary serialization, converters, render hooks, and Node.js API." +description: "typeDiagram documentation: getting started, Typeshed conversion, language reference, CLI, multi-language code generation, TDBIN, converters, render hooks, and Node.js API." stylesHref: /src/styles.css navLogoLink: / navPlaygroundHref: /#playground @@ -13,7 +13,7 @@ structuredData: "@context": "https://schema.org" "@type": "TechArticle" headline: "typeDiagram Documentation" - description: "typeDiagram documentation: getting started, language reference, CLI, multi-language code generation, TDBIN binary serialization, converters, render hooks, and Node.js API." + description: "typeDiagram documentation: getting started, Typeshed conversion, language reference, CLI, multi-language code generation, TDBIN, converters, render hooks, and Node.js API." url: "https://typediagram.dev/docs/" inLanguage: "en" --- diff --git a/packages/web/eleventy/index.njk b/packages/web/eleventy/index.njk index 4b11be8..ba26ac4 100644 --- a/packages/web/eleventy/index.njk +++ b/packages/web/eleventy/index.njk @@ -45,7 +45,7 @@ structuredData:

Type-safe diagrams and source code from one schema.

Define your domain once. Generate types for TypeScript, Python, Rust, Go, C#, F#, Dart, PHP, and Protobuf — - and an auto-laid-out SVG diagram — from the same source. + and a direct-manipulation visual type canvas — from the same source.

@@ -90,10 +90,10 @@ structuredData:
schema -

SVG diagrams

+

Visual type editor

- Automatic orthogonal layout — no dragging, no fiddling. Versionable in git and rendered the same on - every machine. + Drag types, edit fields in place, draw relationships, pan, zoom, and auto-layout. Every change remains + a clean, versionable .td schema.

@@ -115,10 +115,10 @@ structuredData:
hub -

A shared schema, not a diagramming tool

+

A canvas backed by a real schema

- Unlike Mermaid or PlantUML, 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. + The beautiful canvas and the source model are one document. Visual edits produce typed source, ready + to generate code in every supported language.

@@ -140,10 +140,10 @@ structuredData:
VS Code Extension -

Live preview in your editor.

+

A visual type canvas in your editor.

- Syntax highlighting, hover docs, and a side-by-side SVG preview that updates as you type. Available free - on the Visual Studio Marketplace. + Drag nodes, edit fields, draw relationships, and navigate the infinite canvas beside syntax-highlighted + source. Every edit stays synchronized. Available free on the Visual Studio Marketplace.

Engineered for Scale -

Complex diagrams, simplified by code.

+

Complex type systems, shaped visually.

- Stop fighting with drag-and-drop tools. typeDiagram treats your architecture as a living document, - versionable and strictly typed. + Sketch relationships as freely as a whiteboard while typeDiagram keeps the architecture versionable, + generated, and strictly typed.

Get started diff --git a/packages/web/package.json b/packages/web/package.json index 3ea5c8f..637e78d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -21,7 +21,7 @@ "test": "npm run test:unit && npm run test:e2e && npm run test:merge" }, "dependencies": { - "marked": "^18.0.0", + "marked": "^18.0.6", "typediagram-core": "0.0.0-dev" }, "devDependencies": { @@ -32,11 +32,11 @@ "happy-dom": "^20.10.6", "monocart-coverage-reports": "^2.12.12", "prismjs": "^1.30.0", - "tsx": "^4.22.5", - "typedoc": "^0.28.19", + "tsx": "^4.23.0", + "typedoc": "^0.28.20", "typedoc-plugin-markdown": "^4.12.0", - "typescript": "^6.0.3", - "vite": "^8.1.3", - "vitest": "^4.1.9" + "typescript": "^7.0.2", + "vite": "^8.1.4", + "vitest": "^4.1.10" } } diff --git a/packages/web/src/converter-highlight.ts b/packages/web/src/converter-highlight.ts index db62d52..50e65f7 100644 --- a/packages/web/src/converter-highlight.ts +++ b/packages/web/src/converter-highlight.ts @@ -141,12 +141,14 @@ const PROTOBUF_RULES = table( /[<>{}:;,=()[\]]/g ); -type SupportedLang = "typescript" | "rust" | "python" | "go" | "csharp" | "fsharp" | "dart" | "protobuf" | "php"; +type SupportedLang = + "typescript" | "rust" | "python" | "typeshed" | "go" | "csharp" | "fsharp" | "dart" | "protobuf" | "php"; const LANG_RULES: Record = { typescript: TYPESCRIPT_RULES, rust: RUST_RULES, python: PYTHON_RULES, + typeshed: PYTHON_RULES, go: GO_RULES, csharp: CSHARP_RULES, fsharp: FSHARP_RULES, diff --git a/packages/web/src/converter-render.ts b/packages/web/src/converter-render.ts index 603600b..90b2182 100644 --- a/packages/web/src/converter-render.ts +++ b/packages/web/src/converter-render.ts @@ -1,7 +1,8 @@ // [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" | "dart" | "protobuf" | "php"; +export type SupportedLang = + "typescript" | "python" | "typeshed" | "rust" | "go" | "csharp" | "fsharp" | "dart" | "protobuf" | "php"; const getTheme = () => window.matchMedia("(prefers-color-scheme: dark)").matches ? ("dark" as const) : ("light" as const); @@ -18,6 +19,7 @@ const loadConverterPipeline = async () => { const converterMap = { typescript: core.converters.typescript, python: core.converters.python, + typeshed: core.converters.typeshed, rust: core.converters.rust, go: core.converters.go, csharp: core.converters.csharp, diff --git a/packages/web/src/converter.ts b/packages/web/src/converter.ts index 46779eb..4de10f1 100644 --- a/packages/web/src/converter.ts +++ b/packages/web/src/converter.ts @@ -15,6 +15,7 @@ const LANG_LABELS: Record = { typescript: "TypeScript", rust: "Rust", python: "Python", + typeshed: "Typeshed", go: "Go", csharp: "C#", fsharp: "F#", @@ -27,6 +28,7 @@ const LANGUAGES: readonly SupportedLang[] = [ "typescript", "rust", "python", + "typeshed", "go", "csharp", "fsharp", diff --git a/packages/web/src/playground.ts b/packages/web/src/playground.ts index a247c8f..279f136 100644 --- a/packages/web/src/playground.ts +++ b/packages/web/src/playground.ts @@ -7,14 +7,13 @@ import { debounce } from "./debounce.js"; import { renderPane } from "./render-pane.js"; import { initSplitter } from "./splitter.js"; -import { createViewport, setViewportContent } from "./viewport.js"; import { initHighlight } from "./highlight.js"; import { initEditorZoom } from "./editor-zoom.js"; -import { createZoomControls } from "./zoom-controls.js"; import { evalHooks } from "./eval-hooks.js"; import { PRESETS, togglePresetInCode, presetsInCode, type PresetId } from "./hook-presets.js"; import { initJsHighlight } from "./highlight-js.js"; import { HOME_PAGE_SAMPLE } from "typediagram-core"; +import { createVisualEditor, type NodePosition } from "typediagram-core/editor"; const INITIAL_SOURCE = HOME_PAGE_SAMPLE; @@ -126,6 +125,18 @@ const activateTab = (tabId: "source" | "hooks", refs: ReturnType> => { + const stored = localStorage.getItem(STORAGE_POSITIONS_KEY); + try { + const parsed: unknown = JSON.parse(stored ?? "{}"); + // Safe: position values are consumed only as numeric SVG translations. + return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; + } catch { + return {}; + } +}; export const mountPlayground = (container: HTMLElement) => { const refs = buildDom(container); @@ -142,8 +153,6 @@ export const mountPlayground = (container: HTMLElement) => { editorWrap, } = refs; initSplitter(container, splitter); - const vp = createViewport(preview); - createZoomControls(preview, vp); const savedSource = localStorage.getItem(STORAGE_SOURCE_KEY); const savedHooks = localStorage.getItem(STORAGE_HOOKS_KEY); @@ -157,6 +166,19 @@ export const mountPlayground = (container: HTMLElement) => { console.error(`[playground] ${where} render failed`, err); }; + const visualEditor = createVisualEditor(preview, { + getSource: () => editor.value, + onSourceChange: (source) => { + editor.value = source; + localStorage.setItem(STORAGE_SOURCE_KEY, source); + editor.dispatchEvent(new Event("input", { bubbles: true })); + }, + initialPositions: savedPositions(), + onPositionsChange: (positions) => { + localStorage.setItem(STORAGE_POSITIONS_KEY, JSON.stringify(positions)); + }, + }); + buildPresetButtons( hooksToolbar, () => hooksEditor.value, @@ -187,7 +209,7 @@ export const mountPlayground = (container: HTMLElement) => { hooksBadge.textContent = ""; } const html = await renderPane(editor.value, evaluated.hooks); - setViewportContent(preview, html); + visualEditor.setContent(html); }; const debounced = debounce(() => { run().catch(logRenderFailure("debounced")); diff --git a/packages/web/src/render-pane.ts b/packages/web/src/render-pane.ts index d346f37..5e34f77 100644 --- a/packages/web/src/render-pane.ts +++ b/packages/web/src/render-pane.ts @@ -2,8 +2,14 @@ // Lazy-loads `typediagram` so the main chunk stays free of framework + ELK weight. import type { RenderHooks } from "typediagram-core"; -const getTheme = () => - window.matchMedia("(prefers-color-scheme: dark)").matches ? ("dark" as const) : ("light" as const); +const getTheme = () => { + const selected = document.documentElement.dataset.theme; + return selected === "dark" || selected === "light" + ? selected + : window.matchMedia("(prefers-color-scheme: dark)").matches + ? ("dark" as const) + : ("light" as const); +}; export const renderPane = async (source: string, hooks?: RenderHooks): Promise => { const { parser, renderToString } = await import("typediagram-core"); diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index b1cecb6..cb06e31 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -752,7 +752,7 @@ pre[class*="language-"] { flex: 1; overflow: hidden; padding: 0; - background: var(--td-surface); + background-color: var(--td-surface); position: relative; } @@ -1188,8 +1188,9 @@ body { flex-direction: column; gap: 2px; } - -.docs-nav a { +/* [WEB-DOCS-NAV] Nav links and collapsible group summaries share one base. */ +.docs-nav a, +.docs-nav-group summary { display: block; padding: var(--td-space-sm) var(--td-space-lg); font-family: var(--td-font-mono); @@ -1197,25 +1198,46 @@ body { font-weight: 500; color: #64748b; text-decoration: none; + cursor: pointer; + list-style: none; border-radius: var(--td-radius-sm); border-left: 4px solid transparent; transition: background var(--td-ease), color var(--td-ease); } - -.docs-nav a:hover { +.docs-nav a:hover, +.docs-nav-group summary:hover { background: rgba(34, 42, 61, 0.5); color: var(--td-on-surface-variant); } - .docs-nav a.active { background: var(--td-surface-high); color: var(--td-primary); border-left-color: var(--td-primary); font-weight: 700; } - +.docs-nav-group summary::-webkit-details-marker { + display: none; +} +.docs-nav-group summary::before { + content: "▸"; + display: inline-block; + width: 1em; + margin-right: 0.25em; + font-size: 0.8em; + transition: transform var(--td-ease); +} +.docs-nav-group details[open] > summary::before { + transform: rotate(90deg); +} +.docs-nav-group summary.active-group { + color: var(--td-on-surface-variant); + font-weight: 700; +} +.docs-nav-sub { + margin-left: var(--td-space-lg); +} .docs-nav-heading { font-family: var(--td-font-mono); font-size: var(--td-size-label); @@ -1229,18 +1251,15 @@ body { border-top: 1px solid var(--td-surface-high); margin-bottom: var(--td-space-lg); } - .docs-mobile-nav { display: none; } - .docs-content { padding: var(--td-space-3xl) var(--td-space-4xl); line-height: 1.7; color: var(--td-on-surface); background: var(--td-surface); } - .docs-content h1 { font-family: var(--td-font-headline); font-size: clamp(2rem, 4vw, 3rem); @@ -1250,7 +1269,6 @@ body { margin-bottom: var(--td-space-xl); color: var(--td-on-surface); } - .docs-content h2 { font-family: var(--td-font-headline); font-size: 1.5rem; @@ -1262,11 +1280,9 @@ body { border-left: 4px solid var(--td-tertiary); color: var(--td-on-surface); } - .docs-content h2:nth-of-type(even) { border-left-color: var(--td-primary); } - .docs-content h3 { font-family: var(--td-font-headline); font-size: 1.15rem; @@ -1275,19 +1291,16 @@ body { margin-bottom: var(--td-space-sm); color: var(--td-on-surface); } - .docs-content > p:first-of-type { font-size: 1.05rem; color: var(--td-on-surface-variant); line-height: 1.7; max-width: 640px; } - .docs-content p { margin-bottom: var(--td-space-lg); color: var(--td-on-surface-variant); } - .docs-content code { font-family: var(--td-font-mono); font-size: 0.85em; @@ -1296,7 +1309,6 @@ body { padding: 2px 6px; border-radius: 4px; } - .docs-content pre { background: #060e20; border: 1px solid var(--td-surface-high); @@ -1307,14 +1319,12 @@ body { line-height: 1.6; font-family: var(--td-font-mono); } - .docs-content pre code { background: none; color: var(--td-on-surface); padding: 0; font-size: var(--td-size-code); } - .docs-content table { width: 100%; border-collapse: collapse; @@ -1324,14 +1334,12 @@ body { border-radius: var(--td-radius-sm); overflow: hidden; } - .docs-content th, .docs-content td { text-align: left; padding: var(--td-space-md) var(--td-space-lg); border-bottom: 1px solid var(--td-surface-highest); } - .docs-content th { font-family: var(--td-font-mono); font-size: var(--td-size-label); @@ -1341,29 +1349,24 @@ body { color: var(--td-primary); background: var(--td-surface-container); } - .docs-content ul, .docs-content ol { padding-left: var(--td-space-xl); margin-bottom: var(--td-space-lg); color: var(--td-on-surface-variant); } - .docs-content li { margin-bottom: var(--td-space-sm); } - .docs-content strong { font-weight: 600; color: var(--td-on-surface); } - .docs-content a { color: var(--td-primary); text-decoration: none; transition: color var(--td-ease); } - .docs-content a:hover { color: var(--td-tertiary); text-decoration: underline; diff --git a/packages/web/src/viewport.ts b/packages/web/src/viewport.ts index 1e03d71..ed748a4 100644 --- a/packages/web/src/viewport.ts +++ b/packages/web/src/viewport.ts @@ -1,188 +1,2 @@ -// [WEB-VIEWPORT] Pan + zoom for the SVG preview container. -// Applies CSS transform to a wrapper div inside #preview. - -export type ViewportState = { - scale: number; - translateX: number; - translateY: number; -}; - -const ZOOM_MIN = 0.1; -const ZOOM_MAX = 5; -const ZOOM_STEP = 0.1; - -const clampScale = (s: number): number => (s < ZOOM_MIN ? ZOOM_MIN : s > ZOOM_MAX ? ZOOM_MAX : s); - -export type ViewportControls = ViewportState & { - reset: () => void; - zoomIn: () => void; - zoomOut: () => void; - fit: () => void; -}; - -export const createViewport = (container: HTMLElement): ViewportControls => { - const state: ViewportState = { scale: 1, translateX: 0, translateY: 0 }; - - const wrapper = document.createElement("div"); - wrapper.className = "viewport-wrapper"; - wrapper.style.transformOrigin = "0 0"; - container.appendChild(wrapper); - - const apply = () => { - wrapper.style.transform = `translate(${String(state.translateX)}px, ${String(state.translateY)}px) scale(${String(state.scale)})`; - }; - - // --- Zoom (wheel) --- - container.addEventListener( - "wheel", - (e) => { - e.preventDefault(); - const rect = container.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - - const prevScale = state.scale; - const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; - state.scale = clampScale(prevScale + delta * prevScale); - - const ratio = state.scale / prevScale; - state.translateX = mx - ratio * (mx - state.translateX); - state.translateY = my - ratio * (my - state.translateY); - apply(); - }, - { passive: false } - ); - - // --- Pan (pointer drag) --- - let dragging = false; - let startX = 0; - let startY = 0; - let startTx = 0; - let startTy = 0; - - container.addEventListener("pointerdown", (e) => { - const target = e.target as HTMLElement; - const isInteractive = target.tagName === "A" || target.tagName === "BUTTON"; - if (isInteractive) { - return; - } - dragging = true; - startX = e.clientX; - startY = e.clientY; - startTx = state.translateX; - startTy = state.translateY; - if (typeof container.setPointerCapture === "function") { - container.setPointerCapture(e.pointerId); - } - container.style.cursor = "grabbing"; - }); - - container.addEventListener("pointermove", (e) => { - if (!dragging) { - return; - } - state.translateX = startTx + (e.clientX - startX); - state.translateY = startTy + (e.clientY - startY); - apply(); - }); - - const stopDrag = () => { - dragging = false; - container.style.cursor = "grab"; - }; - container.addEventListener("pointerup", stopDrag); - container.addEventListener("pointercancel", stopDrag); - - container.style.cursor = "grab"; - - const reset = () => { - state.scale = 1; - state.translateX = 0; - state.translateY = 0; - apply(); - }; - - const zoomIn = () => { - const rect = container.getBoundingClientRect(); - const cx = rect.width / 2; - const cy = rect.height / 2; - const prevScale = state.scale; - state.scale = clampScale(prevScale + ZOOM_STEP * prevScale); - const ratio = state.scale / prevScale; - state.translateX = cx - ratio * (cx - state.translateX); - state.translateY = cy - ratio * (cy - state.translateY); - apply(); - }; - - const zoomOut = () => { - const rect = container.getBoundingClientRect(); - const cx = rect.width / 2; - const cy = rect.height / 2; - const prevScale = state.scale; - state.scale = clampScale(prevScale - ZOOM_STEP * prevScale); - const ratio = state.scale / prevScale; - state.translateX = cx - ratio * (cx - state.translateX); - state.translateY = cy - ratio * (cy - state.translateY); - apply(); - }; - - const fit = () => { - const svg = wrapper.querySelector("svg"); - if (svg instanceof SVGSVGElement) { - fitSvg(container, wrapper, svg); - } else { - reset(); - } - }; - - return { - ...state, - reset, - zoomIn, - zoomOut, - fit, - get scale() { - return state.scale; - }, - }; -}; - -/** - * [WEB-VIEWPORT-PRESERVE] Move rendered content into the viewport wrapper. - * Fit-to-container runs ONLY on the first ever render — once the user has - * interacted (or any non-identity transform is set) we leave it alone so - * their pan/zoom survives subsequent re-renders. - */ -export const setViewportContent = (container: HTMLElement, html: string) => { - const wrapper = container.querySelector(".viewport-wrapper"); - const target = wrapper ?? container; - const priorTransform = wrapper?.style.transform ?? ""; - target.innerHTML = html; - - const svg = target.querySelector("svg"); - const hasWrapper = wrapper instanceof HTMLElement; - if (svg === null || !hasWrapper) { - return; - } - if (priorTransform === "") { - fitSvg(container, wrapper, svg); - return; - } - wrapper.style.transform = priorTransform; -}; - -const FIT_PADDING = 16; - -const fitSvg = (container: HTMLElement, wrapper: HTMLElement, svg: SVGSVGElement) => { - const cw = container.clientWidth; - const ch = container.clientHeight; - const sw = svg.width.baseVal.value; - const sh = svg.height.baseVal.value; - - const noSize = sw === 0 || sh === 0 || cw === 0 || ch === 0; - const scale = noSize ? 1 : Math.min((cw - FIT_PADDING * 2) / sw, (ch - FIT_PADDING * 2) / sh, 2); - - const tx = (cw - sw * scale) / 2; - const ty = (ch - sh * scale) / 2; - wrapper.style.transform = `translate(${String(tx)}px, ${String(ty)}px) scale(${String(scale)})`; -}; +// [WEB-VIEWPORT] The web host consumes the framework's shared canvas behavior. +export { createViewport, setViewportContent, type ViewportControls, type ViewportState } from "typediagram-core/editor"; diff --git a/packages/web/test/__snapshots__/alias-chain.svg b/packages/web/test/__snapshots__/alias-chain.svg index 5cc1edf..c6ef753 100644 --- a/packages/web/test/__snapshots__/alias-chain.svg +++ b/packages/web/test/__snapshots__/alias-chain.svg @@ -1,45 +1,41 @@ - + + + + - - - - - - - alias Email + + + + + + + + + alias Email - -= String + = String - - - - - - - alias UserEmail + + + + + + alias UserEmail - -= Email + = Email - - - - - - - alias AdminEmail + + + + + + alias AdminEmail - -= UserEmail + = UserEmail - - - - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/all-external.svg b/packages/web/test/__snapshots__/all-external.svg index 9d292cc..d2e759c 100644 --- a/packages/web/test/__snapshots__/all-external.svg +++ b/packages/web/test/__snapshots__/all-external.svg @@ -1,28 +1,26 @@ - + + + + - - - - - - - HttpRequest + + + + + + + + HttpRequest - -url: URL - -method: HttpMethod - -headers: Map<String, String> - -body: Option<Bytes> - -timeout: Duration + url: URL +method: HttpMethod +headers: Map<String, String> +body: Option<Bytes> +timeout: Duration - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/all-primitives.svg b/packages/web/test/__snapshots__/all-primitives.svg index c2ea64e..a147a0d 100644 --- a/packages/web/test/__snapshots__/all-primitives.svg +++ b/packages/web/test/__snapshots__/all-primitives.svg @@ -1,30 +1,27 @@ - + + + + - - - - - - - AllPrimitives + + + + + + + + AllPrimitives - -b: Bool - -i: Int - -f: Float - -s: String - -by: Bytes - -u: Unit + b: Bool +i: Int +f: Float +s: String +by: Bytes +u: Unit - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/chat-model-render.test.svg b/packages/web/test/__snapshots__/chat-model-render.test.svg index 8723f2d..a4b5238 100644 --- a/packages/web/test/__snapshots__/chat-model-render.test.svg +++ b/packages/web/test/__snapshots__/chat-model-render.test.svg @@ -1,173 +1,128 @@ - + + + + - - - - - - - ChatRequest + +tool_results +tool_results +tool_results +tool_results +content +List.items +Text.value +Uri.value +kind +media_type + + + + + + ChatRequest - -message: String - -session_id: String - -tool_results: Option<List<ToolResult>> + message: String +session_id: String +tool_results: Option<List<ToolResult>> - - - - - - - ChatTurnInput + + + + + + ChatTurnInput - -config: AgentConfig - -user_message: String - -tool_results: Option<List<ToolResult>> - -session_id: String + config: AgentConfig +user_message: String +tool_results: Option<List<ToolResult>> +session_id: String - - - - - - - ToolResult + + + + + + ToolResult - -tool_call_id: String - -name: String - -content: ToolResultContent - -ok: Bool + tool_call_id: String +name: String +content: ToolResultContent +ok: Bool - - - - - - - union ToolResultContent - ONE OF - - -◇ None - -◇ Scalar { value: String } - -◇ Dict { entries: Map<String, String> } - -◇ List { items: List<ContentItem> } + + + + + + union ToolResultContent + ONE OF + + ◇ None +◇ Scalar { value: String } +◇ Dict { entries: Map<String, String> } +◇ List { items: List<ContentItem> } - - - - - - - union ContentItem - ONE OF - - -◇ Text { value: TextPart } - -◇ Uri { value: UriPart } - -◇ Scalar { value: String } + + + + + + union ContentItem + ONE OF + + ◇ Text { value: TextPart } +◇ Uri { value: UriPart } +◇ Scalar { value: String } - - - - - - - TextPart + + + + + + TextPart - -text: String + text: String - - - - - - - UriPart + + + + + + UriPart - -url: String - -kind: UriKind - -media_type: Option<String> + url: String +kind: UriKind +media_type: Option<String> - - - - - - - union UriKind - ONE OF - - -◇ Image - -◇ Audio - -◇ Video - -◇ Document - -◇ Web - -◇ Api + + + + + + union UriKind + ONE OF + + ◇ Image +◇ Audio +◇ Video +◇ Document +◇ Web +◇ Api - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - -tool_results - -tool_results - -tool_results - -tool_results - -content - -List.items - -Text.value - -Uri.value - -kind - -media_type \ No newline at end of file diff --git a/packages/web/test/__snapshots__/deep-generics.svg b/packages/web/test/__snapshots__/deep-generics.svg index b26488b..7ddecdd 100644 --- a/packages/web/test/__snapshots__/deep-generics.svg +++ b/packages/web/test/__snapshots__/deep-generics.svg @@ -1,50 +1,44 @@ - + + + + - - - - - - - Config + +rules +rules + + + + + + Config - -rules: Map<String, List<Option<Rule>>> + rules: Map<String, List<Option<Rule>>> - - - - - - - Rule + + + + + + Rule - -name: String - -priority: Int + name: String +priority: Int - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - -rules - -rules \ No newline at end of file diff --git a/packages/web/test/__snapshots__/empty-diagram.svg b/packages/web/test/__snapshots__/empty-diagram.svg index 9dea812..02b5235 100644 --- a/packages/web/test/__snapshots__/empty-diagram.svg +++ b/packages/web/test/__snapshots__/empty-diagram.svg @@ -1,10 +1,14 @@ - + + + + + \ No newline at end of file diff --git a/packages/web/test/__snapshots__/long-names.svg b/packages/web/test/__snapshots__/long-names.svg index 4bb1b05..e7e2bab 100644 --- a/packages/web/test/__snapshots__/long-names.svg +++ b/packages/web/test/__snapshots__/long-names.svg @@ -1,22 +1,23 @@ - + + + + - - - - - - - VeryLongTypeNameThatShouldNotBreakLayout + + + + + + + + VeryLongTypeNameThatShouldNotBreakLayout - -this_is_an_extremely_long_field_name_that_tests_rendering: String - -short: Int + this_is_an_extremely_long_field_name_that_tests_rendering: String +short: Int - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/many-nodes.svg b/packages/web/test/__snapshots__/many-nodes.svg index 2307747..ff5b3f7 100644 --- a/packages/web/test/__snapshots__/many-nodes.svg +++ b/packages/web/test/__snapshots__/many-nodes.svg @@ -1,97 +1,81 @@ - + + + + - - - - - - - A + +x +x +x +x +x +x + + + + + + A - -x: B + x: B - - - - - - - B + + + + + + B - -x: C + x: C - - - - - - - C + + + + + + C - -x: D + x: D - - - - - - - D + + + + + + D - -x: E + x: E - - - - - - - E + + + + + + E - -x: F + x: F - - - - - - - F + + + + + + F - -x: G + x: G - - - - - - - G + + + + + + G - -x: String + x: String - -x - -x - -x - -x - -x - -x \ No newline at end of file diff --git a/packages/web/test/__snapshots__/mixed-union.svg b/packages/web/test/__snapshots__/mixed-union.svg index 8627e7c..3d6c748 100644 --- a/packages/web/test/__snapshots__/mixed-union.svg +++ b/packages/web/test/__snapshots__/mixed-union.svg @@ -1,29 +1,27 @@ - + + + + - - - - - - - union Event - ONE OF - - -◇ Click { x: Int, y: Int } - -◇ KeyPress { key: String, modifiers: List<String> } - -◇ Scroll { deltaX: Float, deltaY: Float } - -◇ Focus - -◇ Blur - + + + + + + + union Event + ONE OF + + ◇ Click { x: Int, y: Int } +◇ KeyPress { key: String, modifiers: List<String> } +◇ Scroll { deltaX: Float, deltaY: Float } +◇ Focus +◇ Blur + \ No newline at end of file diff --git a/packages/web/test/__snapshots__/multi-generics.svg b/packages/web/test/__snapshots__/multi-generics.svg index 2bf6b29..4236053 100644 --- a/packages/web/test/__snapshots__/multi-generics.svg +++ b/packages/web/test/__snapshots__/multi-generics.svg @@ -1,50 +1,45 @@ - + + + + - - - - - - - Pair<A, B> + + + + + + + + Pair<A, B> - -first: A - -second: B + first: A +second: B - - - - - - - union Either<L, R> - ONE OF - - -◇ Left { value: L } - -◇ Right { value: R } + + + + + + union Either<L, R> + ONE OF + + ◇ Left { value: L } +◇ Right { value: R } - - - - - - - union Result<T, E> - ONE OF - - -◇ Ok { value: T } - -◇ Err { error: E } + + + + + + union Result<T, E> + ONE OF + + ◇ Ok { value: T } +◇ Err { error: E } - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/self-ref.svg b/packages/web/test/__snapshots__/self-ref.svg index 31f90f5..0220b28 100644 --- a/packages/web/test/__snapshots__/self-ref.svg +++ b/packages/web/test/__snapshots__/self-ref.svg @@ -1,23 +1,23 @@ - + + + + - - - - - - - TreeNode + +children + + + + + + TreeNode - -value: String - -children: List<TreeNode> + value: String +children: List<TreeNode> - -children \ No newline at end of file diff --git a/packages/web/test/__snapshots__/single-alias.svg b/packages/web/test/__snapshots__/single-alias.svg index 3a4dd4b..b8404eb 100644 --- a/packages/web/test/__snapshots__/single-alias.svg +++ b/packages/web/test/__snapshots__/single-alias.svg @@ -1,20 +1,22 @@ - + + + + - - - - - - - alias UserId + + + + + + + + alias UserId - -= String + = String - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/single-record.svg b/packages/web/test/__snapshots__/single-record.svg index 073141a..a77c701 100644 --- a/packages/web/test/__snapshots__/single-record.svg +++ b/packages/web/test/__snapshots__/single-record.svg @@ -1,22 +1,23 @@ - + + + + - - - - - - - Point + + + + + + + + Point - -x: Float - -y: Float + x: Float +y: Float - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/single-union.svg b/packages/web/test/__snapshots__/single-union.svg index 0f20372..125ce62 100644 --- a/packages/web/test/__snapshots__/single-union.svg +++ b/packages/web/test/__snapshots__/single-union.svg @@ -1,27 +1,26 @@ - + + + + - - - - - - - union Direction - ONE OF - - -◇ North - -◇ South - -◇ East - -◇ West - + + + + + + + union Direction + ONE OF + + ◇ North +◇ South +◇ East +◇ West + \ No newline at end of file diff --git a/packages/web/test/__snapshots__/small-example.svg b/packages/web/test/__snapshots__/small-example.svg index c338eac..d496ea6 100644 --- a/packages/web/test/__snapshots__/small-example.svg +++ b/packages/web/test/__snapshots__/small-example.svg @@ -1,89 +1,71 @@ - + + + + - - - - - - - User + +email +email +address + + + + + + User - -id: UUID - -name: String - -email: Option<Email> - -roles: List<Role> - -address: Address + id: UUID +name: String +email: Option<Email> +roles: List<Role> +address: Address - - - - - - - Address + + + + + + Address - -line1: String - -city: String - -country: CountryCode + line1: String +city: String +country: CountryCode - - - - - - - union Shape - ONE OF - - -◇ Circle { radius: Float } - -◇ Square { side: Float } - -◇ Triangle { a: Float, b: Float, c: Float } + + + + + + union Shape + ONE OF + + ◇ Circle { radius: Float } +◇ Square { side: Float } +◇ Triangle { a: Float, b: Float, c: Float } - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - - - - - - - alias Email + + + + + + alias Email - -= String + = String - -email - -email - -address \ No newline at end of file diff --git a/packages/web/test/__snapshots__/union-refs-union.svg b/packages/web/test/__snapshots__/union-refs-union.svg index 6d4bf26..752d420 100644 --- a/packages/web/test/__snapshots__/union-refs-union.svg +++ b/packages/web/test/__snapshots__/union-refs-union.svg @@ -1,40 +1,36 @@ - + + + + - - - - - - - union Outer - ONE OF - - -◇ Leaf { value: String } - -◇ Nested { inner: Inner } + +Nested.inner + + + + + + union Outer + ONE OF + + ◇ Leaf { value: String } +◇ Nested { inner: Inner } - - - - - - - union Inner - ONE OF - - -◇ A - -◇ B { data: Int } - -◇ C { label: String, count: Int } + + + + + + union Inner + ONE OF + + ◇ A +◇ B { data: Int } +◇ C { label: String, count: Int } - -Nested.inner \ No newline at end of file diff --git a/packages/web/test/converter-render.test.ts b/packages/web/test/converter-render.test.ts index d4a30a1..f86124b 100644 --- a/packages/web/test/converter-render.test.ts +++ b/packages/web/test/converter-render.test.ts @@ -15,6 +15,7 @@ vi.mock("typediagram-core", () => ({ converters: { typescript: { fromSource: mockFromSource, toSource: mockToSource, language: "typescript" }, python: { fromSource: mockFromSource, toSource: mockToSource, language: "python" }, + typeshed: { fromSource: mockFromSource, toSource: mockToSource, language: "typeshed" }, rust: { fromSource: mockFromSource, toSource: mockToSource, language: "rust" }, go: { fromSource: mockFromSource, toSource: mockToSource, language: "go" }, csharp: { fromSource: mockFromSource, toSource: mockToSource, language: "csharp" }, diff --git a/packages/web/test/render-pane.test.ts b/packages/web/test/render-pane.test.ts index 2fb164d..f95357d 100644 --- a/packages/web/test/render-pane.test.ts +++ b/packages/web/test/render-pane.test.ts @@ -33,7 +33,7 @@ describe("[WEB-RENDER-PANE] renderPane", () => { const html = await renderPane(SMALL); expect(html).toMatch(/^]/); // dark theme uses dark node fill color - expect(html).toContain("#252931"); + expect(html).toContain("#222a3d"); spy.mockRestore(); }); }); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9d71475 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool] + +[tool.basilisk] + +[tool.basilisk.rule-tags] +basilisk = "error" diff --git a/scripts/tdbin-bench-report.mjs b/scripts/tdbin-bench-report.mjs index 118c611..fc8e432 100644 --- a/scripts/tdbin-bench-report.mjs +++ b/scripts/tdbin-bench-report.mjs @@ -15,10 +15,12 @@ const operations = [ "tdbin_encode_framed", "tdbin_encode_packed_framed", "protobuf_encode", + "msgpack_encode", "tdbin_decode_bare", "tdbin_decode_framed", "tdbin_decode_packed_framed", "protobuf_decode", + "msgpack_decode", ]; const run = (command, args) => execFileSync(command, args, { cwd: root, encoding: "utf8" }).trim(); @@ -96,7 +98,7 @@ const winner = (value, baseline) => (value < baseline ? "TDBIN" : value > baseli const sizeRows = sizes.fixtures .map( (row) => - `| \`${row.name}\` | ${row.shape} | ${row.corpus ? "corpus" : "stress"} | ${row.logical_items.toLocaleString()} | ${row.tdbin_bare.toLocaleString()} | ${row.tdbin_framed.toLocaleString()} | ${row.tdbin_packed_framed.toLocaleString()} | ${row.protobuf.toLocaleString()} | ${percent(row.tdbin_framed, row.protobuf)} | ${percent(row.tdbin_packed_framed, row.protobuf)} |` + `| \`${row.name}\` | ${row.shape} | ${row.corpus ? "corpus" : "stress"} | ${row.logical_items.toLocaleString()} | ${row.tdbin_bare.toLocaleString()} | ${row.tdbin_framed.toLocaleString()} | ${row.tdbin_packed_framed.toLocaleString()} | ${row.protobuf.toLocaleString()} | ${row.msgpack.toLocaleString()} | ${percent(row.tdbin_framed, row.protobuf)} | ${percent(row.tdbin_packed_framed, row.protobuf)} |` ) .join("\n"); @@ -123,6 +125,33 @@ const modeRows = sizes.fixtures ) .join("\n"); +// [TDBIN-BENCH-PIVOT] The three self-describing formats compared like-for-like. +// `tdbin` uses the framed wire mode — the production peer of msgpack +// (struct-as-map) and Protobuf. The detailed multi-mode tables below retain +// TDBIN bare/framed/packed and every Criterion statistic. +const median = (fixture, operation) => estimate(fixture, operation).median_ns; +const protocols = [ + { + label: "tdbin (framed)", + size: (f) => f.tdbin_framed, + encode: "tdbin_encode_framed", + decode: "tdbin_decode_framed", + }, + { label: "protobuf", size: (f) => f.protobuf, encode: "protobuf_encode", decode: "protobuf_decode" }, + { label: "msgpack", size: (f) => f.msgpack, encode: "msgpack_encode", decode: "msgpack_decode" }, +]; +// [TDBIN-BENCH-ROUNDTRIP] One row per test: all three formats' sizes, then all +// three formats' serialize/deserialize speeds, straight from the measured data. +const roundTripRows = sizes.fixtures + .map((fixture) => { + const size = (p) => p.size(fixture).toLocaleString(); + const enc = (p) => duration(median(fixture.name, p.encode)); + const dec = (p) => duration(median(fixture.name, p.decode)); + const [td, pb, mp] = protocols; + return `| \`${fixture.name}\` | ${size(td)} | ${size(pb)} | ${size(mp)} | ${enc(td)} | ${enc(pb)} | ${enc(mp)} | ${dec(td)} | ${dec(pb)} | ${dec(mp)} |`; + }) + .join("\n"); + const passCount = modeRows.split("\n").filter((row) => row.endsWith(" PASS |")).length; const modeQualifies = (fixture, bytes, encodeOp, decodeOp) => bytes <= fixture.protobuf && @@ -165,6 +194,14 @@ Qualifying modes: ${releaseResults.map((row) => "`" + row.fixture + "` = " + row The release gate ([TDBIN-BENCH-GATE]) requires, for every corpus entry — the committed realistic schemas in \`docs/benchmarks/tdbin-corpus.{td,proto}\` (record-heavy document, union-heavy event stream, list-heavy dataset) — that at least one self-describing production wire mode (framed, or packed framed; the frame's PACKED flag makes the two interchangeable to every decoder) beats Protobuf on size and by ${data.gate.encode_speed_ratio_min.toFixed(2)}x on both encode and decode simultaneously. Both modes are always measured and published below. Stress rows (marked) are reported against the identical bar; the tiny single-message rows carry a fixed 12-byte frame plus pointer-per-string overhead that no fixed-layout format recovers at sub-100-byte payloads (research §2.2), so they are not corpus entries. +## Size and Speed + +The headline comparison — one row per test, all three self-describing formats side by side. TDBIN is its **framed** production mode; MessagePack is struct-as-map (via \`rmp-serde\`). Sizes are bytes; serialize is the full ADT→binary conversion, deserialize the full binary→ADT conversion. Lower is better everywhere. + +| Test | typeDiagram Size | Protobuf Size | MessagePack Size | typeDiagram Serialize | Protobuf Serialize | MessagePack Serialize | typeDiagram Deserialize | Protobuf Deserialize | MessagePack Deserialize | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +${roundTripRows} + ## Environment | Field | Value | @@ -186,12 +223,14 @@ ${data.environment.dependencies} All sizes are bytes. Percentage columns are relative to Protobuf; negative is smaller. -| Fixture | Shape | Role | Items | TDBIN bare | TDBIN framed | TDBIN packed framed | Protobuf | Framed delta | Packed delta | -| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Fixture | Shape | Role | Items | TDBIN bare | TDBIN framed | TDBIN packed framed | Protobuf | MessagePack | Framed delta | Packed delta | +| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ${sizeRows} ## Criterion Medians +Each row is one **individual** operation — a complete serialize *or* deserialize, not a round-trip. The operation name encodes the direction (\`encode\` = ADT→binary, \`decode\` = binary→ADT) and the wire mode (\`bare\`, \`framed\`, or \`packed_framed\` for TDBIN). "Median" is the per-call time (what to compare); "Sampled time" is only Criterion's total measurement budget for that row. Sum a fixture's \`encode\` and \`decode\` rows to get the round-trip totals above. + | Fixture | Operation | Samples | Sampled time | Median | CI lower | CI upper | | --- | --- | ---: | ---: | ---: | ---: | ---: | ${timingRows} @@ -218,4 +257,100 @@ ${data.corpus_schemas.map((path) => `- \`${path}\``).join("\n")} `; writeFileSync(reportPath, await format(report, { ...prettierOptions, filepath: reportPath })); -process.stdout.write(`wrote ${dataPath}\nwrote ${reportPath}\n`); + +// [TDBIN-BENCH-WEBSITE] Simplified, reader-facing benchmark page for the docs +// site. Same measured JSON as the full report — never hand-typed — but reduced +// to the committed corpus workloads: a plain three-way comparison of the raw +// measured size and whole-operation speed for each format, with no deltas or +// ratios. Serialize = the full ADT→binary encode; deserialize = the full +// binary→ADT decode; round-trip = the two summed (encode + decode). +const websitePath = join(root, "docs/specs/tdbin-benchmarks.md"); +const repo = "https://github.com/Nimblesite/typeDiagram"; +const ref = "blob/main"; + +// [TDBIN-BENCH-WEBSITE-FACTS] Story lines derived strictly from the measured +// data — every claim is a comparison the table already prints, never a guess. +const round1 = (x) => Math.round(x * 10) / 10; +const compareSize = (fixture) => ({ + tdVsPb: round1(fixture.protobuf / fixture.tdbin_framed), + tdVsMp: round1(fixture.msgpack / fixture.tdbin_framed), +}); +const roundTrip = (fixture, p) => median(fixture.name, p.encode) + median(fixture.name, p.decode); +const [td, pb, mp] = protocols; +const factLines = sizes.fixtures.map((fixture) => { + const s = compareSize(fixture); + const rtTd = roundTrip(fixture, td); + const rtPb = roundTrip(fixture, pb); + const rtMp = roundTrip(fixture, mp); + const smallest = [ + ["typeDiagram", fixture.tdbin_framed], + ["Protobuf", fixture.protobuf], + ["MessagePack", fixture.msgpack], + ].sort((a, b) => a[1] - b[1])[0][0]; + const fastest = [ + ["typeDiagram", rtTd], + ["Protobuf", rtPb], + ["MessagePack", rtMp], + ].sort((a, b) => a[1] - b[1])[0][0]; + const items = `${fixture.logical_items.toLocaleString()} item${fixture.logical_items === 1 ? "" : "s"}`; + return `- **\`${fixture.name}\`** (${fixture.shape}, ${items}): smallest encoding is **${smallest}**; fastest round-trip is **${fastest}**. typeDiagram's encoding is ${s.tdVsPb}× the size of Protobuf and ${s.tdVsMp}× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger).`; +}); + +const mainRows = sizes.fixtures + .map((fixture) => { + const size = (p) => p.size(fixture).toLocaleString(); + const enc = (p) => duration(median(fixture.name, p.encode)); + const dec = (p) => duration(median(fixture.name, p.decode)); + return `| \`${fixture.name}\` | ${size(td)} | ${size(pb)} | ${size(mp)} | ${enc(td)} | ${enc(pb)} | ${enc(mp)} | ${dec(td)} | ${dec(pb)} | ${dec(mp)} |`; + }) + .join("\n"); + +const website = `# TDBIN Benchmarks + +TDBIN is typeDiagram's compact binary codec for algebraic data types, measured here against **Protocol Buffers** and **MessagePack**. Every number below is data-derived — produced by [\`scripts/tdbin-bench-report.mjs\`](${repo}/${ref}/scripts/tdbin-bench-report.mjs) from Criterion timings and exact encoder output — and regenerates on each benchmark run. + +> **Scope: these figures are for the Rust implementation only.** Both the encoded sizes and the speeds are measured against the Rust \`tdbin\` codec crate ([\`crates/tdbin\`](${repo}/${ref}/crates/tdbin)), the Rust \`prost\` Protobuf encoder, and the Rust \`rmp-serde\` MessagePack encoder. The TDBIN **wire format** and its byte sizes are language-neutral, but serialize/deserialize **speeds** depend on each language's implementation — typeDiagram's other codec targets, and other Protobuf/MessagePack libraries, will differ, sometimes substantially. + +## Size and Speed + +One row per test. Sizes are exact encoded bytes; "Serialize" is the whole ADT→binary conversion and "Deserialize" the whole binary→ADT conversion, each a Criterion median. typeDiagram is its **framed** production wire mode; MessagePack is struct-as-map (via \`rmp-serde\`). Lower is better in every column. + +| Test | typeDiagram Size | Protobuf Size | MessagePack Size | typeDiagram Serialize | Protobuf Serialize | MessagePack Serialize | typeDiagram Deserialize | Protobuf Deserialize | MessagePack Deserialize | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +${mainRows} + +## What the numbers show + +The following are read directly off the table above — each is a comparison of the measured values, nothing more: + +${factLines.join("\n")} + +## Methodology + +- **Rust implementations.** Every timing is for a Rust codec: typeDiagram's [\`tdbin\`](${repo}/${ref}/crates/tdbin) crate, Protobuf via \`prost\`, and MessagePack via \`rmp-serde\`. Encoded sizes are a property of the wire format and hold across languages; speeds do not — typeDiagram's other language targets and other Protobuf/MessagePack libraries will produce different timings. +- **Same values, three encoders.** Every test builds one logical value and feeds the identical value to the typeDiagram codec, to a hand-written Protobuf mirror (\`prost\`), and — via \`serde\` derives on that same mirror — to MessagePack (\`rmp-serde\`). No format receives a different input. See the fixtures in [\`crates/tdbin/tests/support/bench_corpus.rs\`](${repo}/${ref}/crates/tdbin/tests/support/bench_corpus.rs). +- **Self-describing modes only.** typeDiagram *framed*, Protobuf, and MessagePack *struct-as-map* all carry enough structure to be decoded without an external schema, so the comparison is like-for-like. +- **Sizes are exact byte counts** emitted by each encoder (see [\`crates/tdbin/examples/bench_data.rs\`](${repo}/${ref}/crates/tdbin/examples/bench_data.rs)) — not estimates. +- **Timings are Criterion medians** over ${data.benchmarks[0]?.sample_count ?? 50} samples per operation; each measured value flows through \`black_box\` so the optimizer cannot elide the work. The benchmark harness is [\`crates/tdbin/benches/gate.rs\`](${repo}/${ref}/crates/tdbin/benches/gate.rs). +- **Corpus schemas** are committed at [\`docs/benchmarks/tdbin-corpus.td\`](${repo}/${ref}/docs/benchmarks/tdbin-corpus.td) and [\`docs/benchmarks/tdbin-corpus.proto\`](${repo}/${ref}/docs/benchmarks/tdbin-corpus.proto). + +## Test machine + +| Field | Value | +| --- | --- | +| Platform | ${data.environment.platform} ${data.environment.release} (${data.environment.architecture}) | +| CPU | ${data.environment.cpu} | +| Logical CPUs | ${data.environment.logical_cpus} | +| Memory | ${(data.environment.memory_bytes / 1_073_741_824).toFixed(1)} GiB | +| Rust | ${data.environment.rustc} | +| Cargo | ${data.environment.cargo} | + +## Reproduce + +Run the benchmark, then regenerate this page: + +${data.commands.map((command) => `- \`${command}\``).join("\n")} +`; + +writeFileSync(websitePath, await format(website, { ...prettierOptions, filepath: websitePath })); +process.stdout.write(`wrote ${dataPath}\nwrote ${reportPath}\nwrote ${websitePath}\n`);