diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..eada936c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CONTRIBUTING.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 736b9ef2..15696390 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,20 @@ # Contributing to pyxis +## What This Is + +Pyxis is a domain-specific language for describing the types and structures that already exist in a binary's memory. Developers write `.pyxis` files that describe layouts, vtables, and functions, and the driver generates Rust, JSON, or C++ output from those descriptions. The repository is a Rust workspace: the compiler's Salsa-backed query graph lives in `src/`, the driver in `driver/`, a macro crate in `pyxis_macros/`, editor tooling (tree-sitter grammar, Zed extension, language server) under `tooling/`, and a React + TypeScript + Tailwind docs viewer under `viewer/`. The sections below this opening document this project, and the template sections at the end record the shared conventions that `contributing-update` refreshes from upstream. + + + ## Backends Pyxis emits one of three target languages: -- `rust` (default) - `.rs` files inside a Cargo crate. The primary usage pattern is embedding the compiler in a `build.rs` to generate a `src` folder within a crate. See [`docs/rust_backend.md`](docs/rust_backend.md) for output layout, module mounting, `BuildOptions`, and `build.rs` integration. -- `json` - a single JSON document for tooling that wants to consume the semantic IR directly. See [`docs/json_backend.md`](docs/json_backend.md) for the schema, item model, and viewer integration. -- `cpp` - `.hpp` + `.cpp` per module plus a normative `CMakeLists.txt`, for C++ tooling that wants to link a static library. Targets the MSVC ABI directly (composition-only structs, `__thiscall`/`__stdcall`/etc. on `#[address]`-bound functions); generated code is portable to any MSVC-ABI consumer (real Windows + MSVC, or `clang-cl`). See [`docs/cpp_backend.md`](docs/cpp_backend.md) for the design decisions and ABI tradeoffs. +- `rust` (default): `.rs` files inside a Cargo crate. The primary usage pattern is embedding the compiler in a `build.rs` to generate a `src` folder within a crate. See [`docs/rust_backend.md`](docs/rust_backend.md) for output layout, module mounting, `BuildOptions`, and `build.rs` integration. +- `json`: a single JSON document for tooling that wants to consume the semantic IR directly. See [`docs/json_backend.md`](docs/json_backend.md) for the schema, item model, and viewer integration. +- `cpp`: `.hpp` + `.cpp` per module plus a normative `CMakeLists.txt`, for C++ tooling that wants to link a static library. Targets the MSVC ABI directly (composition-only structs, `__thiscall`/`__stdcall`/etc. on `#[address]`-bound functions); generated code is portable to any MSVC-ABI consumer (real Windows + MSVC, or `clang-cl`). See [`docs/cpp_backend.md`](docs/cpp_backend.md) for the design decisions and ABI tradeoffs. -The full language reference - every syntax form, type, attribute, cfg predicate, backend splice, and convention - lives in [`docs/language.md`](docs/language.md). +The full language reference covers every syntax form, type, attribute, cfg predicate, backend splice, and convention: [`docs/language.md`](docs/language.md). ```sh cargo run -p pyxis-driver -- build --backend cpp @@ -16,11 +22,11 @@ cargo run -p pyxis-driver -- build --backend cpp ## Keeping files small -Keep source files under 1,000 lines. When a module approaches that, split it: convert `foo.rs` into a `foo/` directory, move cohesive clusters into submodules, and preserve the public API with re-exports from `foo/mod.rs`. Methods on one type can be spread across multiple `impl` blocks in different files of the same module, so no visibility or trait plumbing is needed. Large inline `#[cfg(test)] mod tests` blocks count too - extracting them to a sibling `tests.rs` is often the whole fix. +Keep source files under 1,000 lines. When a module approaches that, split it: convert `foo.rs` into a `foo/` directory, move cohesive clusters into submodules, and preserve the public API with re-exports from `foo/mod.rs`. Methods on one type can be spread across multiple `impl` blocks in different files of the same module, so no visibility or trait plumbing is needed. Large inline `#[cfg(test)] mod tests` blocks count too; extracting them to a sibling `tests.rs` is often the whole fix. ## Building emitted C++ on Linux dev hosts -The cpp output is normative - it does not bake xwin or clang-cl assumptions into `CMakeLists.txt`. To build it on Linux, point CMake at the dev-only toolchain in `tools/cmake-toolchains/xwin-x86.cmake`, which wires `clang-cl` against the MSVC SDK provisioned by [`xwin`](https://github.com/Jake-Shadle/xwin). +The cpp output is normative; it does not bake xwin or clang-cl assumptions into `CMakeLists.txt`. To build it on Linux, point CMake at the dev-only toolchain in `tools/cmake-toolchains/xwin-x86.cmake`, which wires `clang-cl` against the MSVC SDK provisioned by [`xwin`](https://github.com/Jake-Shadle/xwin). One-time setup: @@ -44,41 +50,13 @@ cmake -S -B \ cmake --build -j ``` -The toolchain pins `MultiThreadedDLL` (release CRT) for every config - xwin doesn't ship `msvcrtd.lib`, so Debug builds can't link the debug CRT. Treat `Debug` and `Release` as differing only in optimization / debug-info, not CRT. - -On native Windows, no toolchain file is needed - point CMake at a regular MSVC install or `clang-cl` and build normally. - -## Changing the language +The toolchain pins `MultiThreadedDLL` (release CRT) for every config; xwin does not ship `msvcrtd.lib`, so Debug builds cannot link the debug CRT. Treat `Debug` and `Release` as differing only in optimization and debug-info, not in CRT. -When you change the language (new attributes, syntax, types, etc.), you must audit every surface that consumes it - not just the compiler. Most surfaces handle attributes generically and need no changes, but you must verify each one. Here is the full checklist: - -| Surface | Path | When it needs updating | -|---------|------|------------------------| -| **Compiler frontend** | `src/` | Always. Parser, semantic IR, and all backends (Rust/C++/JSON). | -| **Parser test helpers** | `src/parser/attributes.rs` | New `Attribute` variants or test constructors (e.g. `Attribute::pinned()`). The grammar parses `#[ident]` attributes generically, so simple ident attributes need no grammar change. | -| **New item kinds** | `src/parser/items/mod.rs`, `src/semantic/types/item.rs` | Adding a whole item kind (like `union`) means a variant on *both* `ItemDefinitionInner` enums. Both are matched exhaustively in ~30 places across the compiler, LSP, and backends — let the compiler enumerate them rather than grepping. Also `SigKind` (`src/semantic/name_index.rs`) and `ItemKind` (`src/semantic/error/context.rs`). | -| **`AttributeName` enum** | `src/semantic/error.rs` | Only if the new attribute participates in conflicting-attribute validation (e.g. `#[packed]` + `#[align]`). Most attributes don't need an entry. | -| **Tree-sitter grammar** | `tooling/tree-sitter-pyxis/grammar.js` | Only for new syntax forms or keywords. Ident attributes (`#[foo]`) are already parsed generically as `attribute_ident -> $.identifier`. | -| **Highlights query** | `tooling/tree-sitter-pyxis/queries/highlights.scm` | Rarely. Attributes are highlighted generically via `(attribute) @attribute`. Only change if a new node type needs a capture. | -| **Zed extension** | `tooling/zed-pyxis/` | Rarely. It just consumes the grammar and highlights query. No attribute-specific logic. | -| **LSP hover** | `tooling/lsp/src/handlers/hover_format.rs` | New attributes need a description string in the `attribute_description()` match table so hovering shows documentation. | -| **LSP completion** | `tooling/lsp/src/handlers/completion.rs` | Only if adding new keywords (not attributes). The completion handler lists keyword tokens, not attribute names. | -| **JSON types** | `types/json.ts` | After changing JSON backend structs (which derive `specta::Type`). Regenerate with `cargo run -p pyxis-driver -- gen-types`. | -| **Viewer** | `viewer/src/components/Attributes.tsx` | New attributes need a badge entry in `ItemAttributes` to be visible in the docs viewer. | -| **Codegen test corpus** | `codegen_tests/input/`, `codegen_tests/output/` | Add a test input exercising the new feature, then regenerate output with `cargo run --example codegen_tests`. | -| **C++ backend docs** | `docs/cpp_backend.md` | If the attribute affects C++ codegen (most don't - `copyable`/`cloneable` are Rust-only). | -| **Language reference** | `docs/language.md` | If the language feature is documented in the language reference. Most new syntax/types/attributes need a section update here. | -| **Rust backend docs** | `docs/rust_backend.md` | If the attribute or feature affects Rust codegen output shape. | -| **JSON backend docs** | `docs/json_backend.md` | If the JSON schema or item model changes. Bump `CURRENT_SCHEMA_VERSION` and update the version history. | -| **Pretty-printer** | `src/pretty_print.rs` | Usually no change needed (attributes print generically). Add a round-trip test to confirm. | +On native Windows, no toolchain file is needed: point CMake at a regular MSVC install or `clang-cl` and build normally. ## Tests -```sh -python test.py -``` - -runs the full test suite: clippy, fmt, the parser/semantic unit tests, `cargo run --example codegen_tests` (which emits the test corpus through every backend and rebuilds the emitted output), `cargo doc --no-deps -p codegen_tests` (which catches unresolved doc link references in the generated Rust output), and a doxygen pass over the emitted C++ corpus (which catches unresolvable `@ref`s in the rewritten C++ doc links — skipped with a warning if doxygen isn't installed; `nix-shell` provides it via `shell.nix`). The cpp test corpus uses a regular host C++17 compiler (no MSVC ABI required) so CI doesn't need xwin. +`python test.py` runs the full test suite: clippy, fmt, the parser and semantic unit tests, `cargo run --example codegen_tests` (which emits the test corpus through every backend and rebuilds the emitted output), `cargo doc --no-deps -p codegen_tests` (which catches unresolved doc link references in the generated Rust output), and a doxygen pass over the emitted C++ corpus (which catches unresolvable `@ref`s in the rewritten C++ doc links). The doxygen pass is skipped with a warning if doxygen is not installed; `nix-shell` provides it via `shell.nix`. The cpp test corpus uses a regular host C++17 compiler (no MSVC ABI required), so CI does not need xwin. Formatting relies on a nightly-only rustfmt feature (`imports_granularity`, configured in `rustfmt.toml`), so check it with nightly: @@ -86,46 +64,70 @@ Formatting relies on a nightly-only rustfmt feature (`imports_granularity`, conf cargo +nightly fmt --all -- --check ``` +## Changing the language + +A language change (new attributes, syntax, types, and so on) is complete only when every surface that consumes the language has been audited, not just the compiler. Most surfaces handle attributes generically and need no changes, but each one must be verified. Here is the full checklist: + +| Surface | Path | When it needs updating | +|---------|------|------------------------| +| Compiler frontend | `src/` | Always. Parser, semantic IR, and all backends (Rust/C++/JSON). | +| Parser test helpers | `src/parser/attributes.rs` | New `Attribute` variants or test constructors (e.g. `Attribute::pinned()`). The grammar parses `#[ident]` attributes generically, so simple ident attributes need no grammar change. | +| New item kinds | `src/parser/items/mod.rs`, `src/semantic/types/item.rs` | Adding a whole item kind (like `union`) means a variant on both `ItemDefinitionInner` enums. Both are matched exhaustively in about 30 places across the compiler, LSP, and backends; let the compiler enumerate them rather than grepping. Also extend `SigKind` (`src/semantic/name_index.rs`) and `ItemKind` (`src/semantic/error/context.rs`). | +| `AttributeName` enum | `src/semantic/error.rs` | Only if the new attribute participates in conflicting-attribute validation (e.g. `#[packed]` + `#[align]`). Most attributes do not need an entry. | +| Tree-sitter grammar | `tooling/tree-sitter-pyxis/grammar.js` | Only for new syntax forms or keywords. Ident attributes (`#[foo]`) are already parsed generically as `attribute_ident -> $.identifier`. | +| Highlights query | `tooling/tree-sitter-pyxis/queries/highlights.scm` | Rarely. Attributes are highlighted generically via `(attribute) @attribute`. Only change if a new node type needs a capture. | +| Zed extension | `tooling/zed-pyxis/` | Rarely. It just consumes the grammar and highlights query. No attribute-specific logic. | +| LSP hover | `tooling/lsp/src/handlers/hover_format.rs` | New attributes need a description string in the `attribute_description()` match table so hovering shows documentation. | +| LSP completion | `tooling/lsp/src/handlers/completion.rs` | Only if adding new keywords (not attributes). The completion handler lists keyword tokens, not attribute names. | +| JSON types | `types/json.ts` | After changing JSON backend structs (which derive `specta::Type`). Regenerate with `cargo run -p pyxis-driver -- gen-types`. | +| Viewer | `viewer/src/components/Attributes.tsx` | New attributes need a badge entry in `ItemAttributes` to be visible in the docs viewer. | +| Codegen test corpus | `codegen_tests/input/`, `codegen_tests/output/` | Add a test input exercising the new feature, then regenerate output with `cargo run --example codegen_tests`. | +| C++ backend docs | `docs/cpp_backend.md` | If the attribute affects C++ codegen (most do not; `copyable`/`cloneable` are Rust-only). | +| Language reference | `docs/language.md` | If the language feature is documented in the language reference. Most new syntax/types/attributes need a section update here. | +| Rust backend docs | `docs/rust_backend.md` | If the attribute or feature affects Rust codegen output shape. | +| JSON backend docs | `docs/json_backend.md` | If the JSON schema or item model changes. Bump `CURRENT_SCHEMA_VERSION` and update the version history. | +| Pretty-printer | `src/pretty_print.rs` | Usually no change needed (attributes print generically). Add a round-trip test to confirm. | + ## Editor tooling Pyxis ships a tree-sitter grammar, a Zed extension, and a language server. All live under `tooling/`. -The tree-sitter grammar lives in its own repository, [`ferrobrew/tree-sitter-pyxis`](https://github.com/ferrobrew/tree-sitter-pyxis), and is vendored here as a git **submodule** at `tooling/tree-sitter-pyxis`. Clone with `git clone --recurse-submodules`, or in an existing checkout run: +The tree-sitter grammar lives in its own repository, [`ferrobrew/tree-sitter-pyxis`](https://github.com/ferrobrew/tree-sitter-pyxis), and is vendored here as a git submodule at `tooling/tree-sitter-pyxis`. Clone with `git clone --recurse-submodules`, or in an existing checkout run: ```sh git submodule update --init ``` -To change the grammar: edit it in `tooling/tree-sitter-pyxis`, then run +To change the grammar, edit it in `tooling/tree-sitter-pyxis`, then run ```sh python tooling/sync-grammar.py -m "Describe the grammar change" ``` -which regenerates the parser, runs the grammar tests, commits and pushes to the grammar repo's `main`, and re-pins **both** the submodule and `tooling/zed-pyxis/extension.toml` at the resulting commit SHA. It stages those two bumps in this repo and leaves the commit to you (pass `--commit-parent` to commit them too). Run it with no `-m` any time to re-pin against the submodule's current HEAD. +The script regenerates the parser, runs the grammar tests, commits and pushes to the grammar repo's `main`, and re-pins both the submodule and `tooling/zed-pyxis/extension.toml` at the resulting commit SHA. It stages those two bumps in this repo and leaves the commit to the contributor; pass `--commit-parent` to commit them too. Run it with no `-m` any time to re-pin against the submodule's current HEAD. -Two invariants the script maintains, which you must preserve if you ever touch this by hand: +Two invariants the script maintains must be preserved if the workflow is ever touched by hand: -- `extension.toml` pins the grammar by full commit SHA, never a branch ref - Zed caches its compiled grammar by that string and won't re-resolve a branch. +- `extension.toml` pins the grammar by full commit SHA, never a branch ref: Zed caches its compiled grammar by that string and will not re-resolve a branch. - The parser is generated with `--abi 14` (wired into the grammar's `npm run generate`), the ABI Zed's bundled tree-sitter runtime loads. After syncing, reinstall the Zed dev extension to pick up the new grammar. ### Architecture -The compiler uses a [Salsa](https://github.com/salsa-rs/salsa)-backed query graph (`src/salsa/`). Both the batch compilation pipeline (`build_with_store_and_options`) and the LSP server call the same Salsa queries - there is no separate "imperative pipeline" and "LSP pipeline." +The compiler uses a [Salsa](https://github.com/salsa-rs/salsa)-backed query graph (`src/salsa/`). Both the batch compilation pipeline (`build_with_store_and_options`) and the LSP server call the same Salsa queries; there is no separate imperative pipeline and LSP pipeline. -- `src/salsa/` - Salsa database, inputs, IR, and tracked functions -- `tooling/tree-sitter-pyxis/` - tree-sitter grammar for syntax highlighting (a submodule -> [`ferrobrew/tree-sitter-pyxis`](https://github.com/ferrobrew/tree-sitter-pyxis)) -- `tooling/zed-pyxis/` - Zed extension -- `tooling/lsp/` - LSP server binary (`pyxis-lsp`) +- `src/salsa/`: Salsa database, inputs, IR, and tracked functions +- `tooling/tree-sitter-pyxis/`: tree-sitter grammar for syntax highlighting (a submodule pointing at [`ferrobrew/tree-sitter-pyxis`](https://github.com/ferrobrew/tree-sitter-pyxis)) +- `tooling/zed-pyxis/`: Zed extension +- `tooling/lsp/`: LSP server binary (`pyxis-lsp`) ### Documentation -- [`docs/language.md`](docs/language.md) - language reference (syntax, types, attributes, cfg, splices, conventions) -- [`docs/rust_backend.md`](docs/rust_backend.md) - Rust backend output shape, `BuildOptions`, `build.rs` integration, generated derives -- [`docs/json_backend.md`](docs/json_backend.md) - JSON schema, item model, viewer integration -- [`docs/cpp_backend.md`](docs/cpp_backend.md) - C++ backend ABI design, composition-based inheritance, vftables +- [`docs/language.md`](docs/language.md): language reference (syntax, types, attributes, cfg, splices, conventions) +- [`docs/rust_backend.md`](docs/rust_backend.md): Rust backend output shape, `BuildOptions`, `build.rs` integration, generated derives +- [`docs/json_backend.md`](docs/json_backend.md): JSON schema, item model, viewer integration +- [`docs/cpp_backend.md`](docs/cpp_backend.md): C++ backend ABI design, composition-based inheritance, vftables ### Running the LSP @@ -134,3 +136,349 @@ cargo build -p pyxis-lsp --release ``` The `pyxis-lsp` binary communicates over stdio. The Zed extension spawns it automatically (see its README for installation instructions). + +## General conventions + +### Correctness over convenience + +- Model the full error space—no shortcuts or simplified error handling. +- Handle all edge cases, including race conditions, signal timing, and platform differences. +- Use the type system to encode correctness constraints: newtypes, builder patterns, type states, lifetimes. Never use a bare string or integer where the domain has a meaning for it; a recognised closed set of values rides as an enum, not as bare strings. +- Prefer compile-time guarantees over runtime checks where possible. +- Use message passing or the actor model to avoid data races in concurrent code. +- **Never silently drop content you can't represent.** When adapting data between formats, return an error for anything the target can't express. A caller may then choose to ignore it; silent data loss is a correctness bug, not a convenience. +- Validate at each layer the data crosses, not only where the bug happened to surface. One check is bypassed by the next code path, refactor, or test double; the goal is to make the bad state structurally impossible, not locally absent. +- Getting the details right is really important! + +### User experience as a primary driver + +- Provide structured, helpful error messages that can be rendered with an appropriate library at a later stage. +- Make progress reporting responsive and informative. +- Maintain consistency across platforms even when underlying OS capabilities differ. Use OS-native logic rather than trying to emulate Unix on Windows (or vice versa). +- Write user-facing messages in clear, present tense: "Frobnicator now supports..." not "Frobnicator now supported..." + +### Pragmatic incrementalism + +- "Not overly generic"—prefer specific, composable logic over abstract frameworks. +- Evolve the design incrementally rather than attempting perfect upfront architecture. +- **The rule of three**: don't abstract until you've seen the pattern three times. Three similar lines beat a premature abstraction, which is harder to remove than it was to add. +- Don't build for hypothetical future requirements. + +### Dependencies + +- Neither reflex is right: reaching for a dependency for something trivial and handrolling something with a long correctness tail are both mistakes, and the second is the more expensive one. +- Handroll it when it's small, self-contained, and you can see the whole problem — a builder, a wrapper, a couple of pure functions. +- Take the dependency when the problem has a tail you'd otherwise discover in production: dates and timezones, Unicode, text encodings, compression, TLS, anything cryptographic. Never handroll cryptography. +- Prefer an existing dependency (including one of its feature flags) over a new one, and a focused library over a framework you'd use 5% of. +- Where the project documents a chosen stack, use it rather than silently introducing an alternative to something already covered. +- When it's genuinely unclear, ask rather than picking silently. Either direction is cheap to change early and annoying to change late. + +### Boundaries and compatibility + +- A library never reads environment variables. Configuration — keys, URLs, feature flags, paths — arrives as parameters, and only the application entry point reads the environment. Otherwise the library can't be tested without manipulating the environment, and it's silently coupled to a deployment. +- When changing anything serialised to disk or sent over a wire, walk the whole version matrix: old reader with new data, new reader with old data, and **old writer with new data**. + - That third case is the one that gets missed. A default lets an old reader parse new data, but an old writer then drops the fields it doesn't know about on write-back — which corrupts the file rather than failing on it. + - Bump the format version when you add the field, not when you first depend on it. + +### Functional core, imperative shell + +- Keep decision logic in pure functions that take data in and return data out. Keep I/O, concurrency primitives, and orchestration in a thin shell at the edges. +- The shape is gather, then process, then persist: the shell collects the inputs, the core decides, the shell writes the result. A core function that reaches out to read something mid-decision is the thing this is meant to prevent. +- Isolate coupling to the outside world — filesystem, clock, network, subprocesses, devices — behind a small seam: a trait, an interface, a dependency struct, with a production implementation and a test fake. Tests are deterministic because they substitute the fake, not because they clean up after the real thing. +- The payoff is testability. A pure core needs no fakes at all, and a thin shell has little logic left worth mocking. When a test needs elaborate setup to reach the behaviour it's checking, that's usually the code's shape talking, not the test's. + +### Code organisation + +The language-specific files set the file-size threshold and the naming conventions; these apply everywhere. + +- Name a file for what it holds, not for a category. No `utils`, `helpers`, `common`, or `misc` — they become dumping grounds, and nothing stops unrelated code being added to a file whose name claims nothing. A file named for string formatting, or date arithmetic, or API error handling is one it's hard to put the wrong thing in. (Each language file gives the casing to use.) +- Within a file, put the public API first, then the private implementation below it: constants, helpers, and internal types. Order the private items by use, so each appears roughly in the order the public API reaches for it (topological order). +- Split a file along natural seams — distinct data types, feature groups, functional areas — not arbitrarily at the line limit. A cohesive single-concern file that slightly exceeds the threshold beats a fragmented one. +- Group a wide folder into subfolders by domain or role. A flat folder of 20+ files is a signal that subfolders are wanted. +- Test files follow the same thresholds as the code they test. + +### Testing + +- Test comprehensively, including edge cases, race conditions, and stress tests. +- Pay attention to what facilities already exist for testing, and aim to reuse them. +- When fixing a bug, add the failing regression test first, then make it pass. +- **Use real instances of what you control, and fakes for what you don't.** Your own database or filesystem is a managed dependency: talking to it is an implementation detail you can refactor freely, so test against the real thing. A third-party API, an SMTP server, a message bus is unmanaged: that conversation is observable behaviour, so put it behind a fake. +- **Don't mock what you don't own.** Wrap the third-party library in your own thin interface and substitute that, rather than mocking the library's own surface. It makes the test simpler and the design better. +- Never add a method to production code that only tests call. Cleanup and inspection helpers belong in test utilities. +- Wait for conditions, not for durations. Poll for the state you're expecting with a timeout, rather than sleeping long enough that it's *probably* ready — the latter passes locally and fails in CI. A fixed sleep is right only when the thing under test is itself about timing, like a debounce, and then the comment says why that duration. +- Clean up long-lived resources: containers, VMs, processes, cloud objects. Don't bother scrubbing database rows and log entries — perfect data cleanup is a fool's errand that makes multi-step integration tests nearly impossible. A test that needs pristine state should mint unique identifiers instead of depending on an empty table. +- Don't write a test that only exercises serialisation or a derived implementation. A round-trip earns its place only when it guards a real wire: a versioned payload, a public API, or a file format. +- No personal names in fixtures, and never the author's own identity. Anonymise every test and fixture to invented placeholders; do not seed one from any real person's name, handles, or biographical details, even when real data reproduces the behaviour under test. Reproduce the *shape* of what you observed, never the actual content. + +Where a function has a property worth stating, prefer a property-based test over a handful of examples. Reach for the strongest property that applies — roughly, in increasing order of strength: doesn't crash, preserves the type, holds an invariant, is idempotent (`f(f(x)) == f(x)`), round-trips (`decode(encode(x)) == x`). An oracle property, where a new implementation must agree with the old one, is the tool for a rewrite. + +Two ways a property test can look fine and test nothing. It can be tautological, comparing an expression against itself. Or it can restate the function's own logic in the assertion, in which case a bug in your reasoning appears in both halves and cancels out. Include the degenerate cases — empty, single element — explicitly rather than trusting the generator to find them. + +### Documentation + +- Use inline comments to explain "why," not just "what". +- Don't add narrative comments in function bodies. Only add a comment if what you're doing is non-obvious or special in some way, or if something needs a deeper "why" explanation. +- Module-level documentation should explain purpose and responsibilities. +- Comments and docs describe the present state. Reserve past-tense narration for the rare case where history explains a standing "why". +- Keep the user-facing docs in sync with the code. Where a document restates something the code owns — a config default, an example config file, a generated reference, an API surface — name the code as the source of truth and update the document in the same change. A code change that lands without its doc update isn't finished. +- Better still, generate the document from the code and check it in CI. Don't rely on anyone remembering. +- **Always** use periods at the end of code comments. +- **Never** use title case in headings and titles. Always use sentence case. +- Always use the Oxford comma. +- Don't omit articles ("a", "an", "the"). Write "the file has a newer version" not "file has newer version". + +## Code style + +### Rust edition and linting + +- Use Rust 2024 edition. +- Format with **nightly rustfmt**: `cargo +nightly fmt --all`. `rustfmt.toml` opts into `imports_granularity = "Crate"` and `group_imports = "StdExternalCrate"`, which are nightly-only — stable rustfmt prints a warning about each and then silently skips them, so a stable-formatted file is *not* equivalent to a nightly-formatted one. +- Ensure the following checks pass at the end of each complete task (you don't need to do this for intermediate steps): + - `cargo +nightly fmt --all -- --check` + - `cargo clippy --workspace --all-targets --all-features -- -D warnings` + - `cargo clippy --workspace --all-targets --no-default-features -- -D warnings` + - `cargo test --workspace` + - `cargo test --workspace --no-default-features` +- Configure clippy's restriction lints in `[workspace.lints.clippy]` in the workspace root's `Cargo.toml`, with each crate opting in via `[lints] workspace = true`: + + ```toml + [workspace.lints.clippy] + unwrap_used = "warn" + expect_used = "warn" + ``` + +- The end-of-task and CI clippy commands pass `-- -D warnings`, so any warning fails those checks; a plain `cargo clippy` run reports warnings but doesn't fail on them. + +- Use `cargo clippy` in place of `cargo build` — it typechecks and lints in one pass, so a separate build buys nothing. +- Iterate in debug. Reach for `--release` only when benchmarking or packaging. +- No `unwrap()` or `expect()` in production code; tests are fine. +- Never silence a lint without a concrete reason documented in a comment above it. In almost all cases the right move is to restructure the code. +- When you do suppress one, prefer `#[expect(...)]` to `#[allow(...)]`. `expect` warns once the suppression is no longer needed, so stale suppressions can't quietly accumulate as the code changes around them. + +### Type system patterns + +- **Builder patterns** for complex construction (e.g. `TestRunnerBuilder`). +- **Type states** encoded in generics when state transitions matter. +- **Lifetimes** used extensively to avoid cloning (e.g. `TestInstance<'a>`). +- **Newtypes** for domain types, per the general rule against bare primitives. +- **`#[non_exhaustive]`** on public types in a library crate with a stable API, so a new variant or field isn't a breaking change. Internal crates don't need it. +- **Parameter structs over long argument lists**: when a function approaches the `clippy::too_many_arguments` threshold, bundle the cohesive parameters into a struct rather than threading more positional arguments. A request struct, or a shared seam like `Engine { store, graph, clock }` that several call shapes pass along. + - **Never** silence that lint with an `allow`. The lint firing means a struct is wanted. The one exception is a signature you don't own — an FFI shim, or a hook mirroring a foreign ABI — where the `allow` is the honest annotation. + +### Error handling + +- Do not use `thiserror`. Instead, manually implement `std::fmt::Display` and `std::error::Error` for a given error `struct` or `enum`. `Error::source` returns the wrapped cause where there is one, so the chain stays walkable. +- Group errors by category with an `ErrorKind` enum when appropriate. +- Provide rich error context using structured error types. +- Two-tier error model: + - `ExpectedError`: User/external errors with semantic exit codes. + - Internal errors: Programming errors that may panic or use internal error types. +- Every error's `Display` leads with a `:` prefix naming the subsystem or operation it belongs to, then the cause: `event store: …`, `lua: block commit failed: …`, `could not open the event log at /path: …`. Messages stay lowercase, so they compose. + - An aggregating error prefixes its own layer's context and delegates to the inner error. A chained error then reads as nested context: `turn: lua: block commit failed: event store: …`. + - Add resource context — a path, an id — at the layer that has it. Avoid bare "failed to {x}" glue; name the operation instead. + +### Async patterns + +- Do not introduce async to a project without async. +- Use `tokio` for async runtime (multi-threaded). +- Use async for I/O and concurrency, keep other code synchronous. +- Use `parking_lot::Mutex` for synchronous locks (the default); its guard is non-poisoning and must never be held across an `.await`. Reserve `tokio::sync::Mutex` for the rare guard that must survive an `.await`, since most locks are acquired, used, and dropped within a synchronous span. +- Use bounded `mpsc` channels; an unbounded channel hides backpressure. + +### Logging + +- Use `tracing` for diagnostic and operational logging throughout, emitting at meaningful points, not noisily. +- Install the subscriber only in binaries, and send logs to stderr. +- Operator and diagnostic programs route their output through `tracing` too. Reserve `stdout`/`println!` for genuine machine-readable command output. + +### Module organisation + +- Use `mod.rs` files to re-export public items; nontrivial logic lives in submodules, not in `mod.rs` itself. +- Keep module boundaries strict with restricted visibility, but prefer `pub(crate)` and `pub(super)` over `pub(in )`. The `pub(in …)` form scopes to a named ancestor, which is precise but reads as a smell; reach for it only when neither `pub(crate)` nor `pub(super)` expresses the intended scope. +- Use `#[cfg(unix)]` and `#[cfg(windows)]` for conditional compilation. +- **Always** import types or functions at the very top of the module, with the one exception being `cfg()`-gated functions. Never import types or modules within function contexts, other than this `cfg()`-gated exception. +- It is okay to import enum variants for pattern matching, though. +- Re-exports follow the same rule: a `pub use` belongs at the top of the module with the imports, not beside the item it re-exports. In a `mod.rs`, the `mod` declarations come first, then the `pub use` block, so the module's public surface reads as one list. +- A path used more than once in a module gets imported at the top — the specific items, not the module — rather than repeated in full at each call site. + - A path used once may stay fully-qualified. Unless it's unwieldy, meaning more than three module segments deep, in which case import it anyway. + - And when the module already imports a sibling from the same parent, import the new item alongside it rather than writing it inline. +- **Always** anchor intra-crate paths at `crate::`, never `super::`. Write `crate::graph::Graph`, not `super::Graph` or `super::super::Graph`. The one exception is a test module, where `use super::*;` (pulling the parent module into the `#[cfg(test)]` block) is the idiomatic form and stays. +- Prefer a single grouped `use` statement per crate root rather than several siblings under it, collapsing shared prefixes: `use axum::{extract::State, http::StatusCode, routing::get};`, not three separate lines. Group imports into three blocks separated by blank lines: `std`, external crates, then `crate`/`super`/`self`. `imports_granularity = "Crate"` and `group_imports = "StdExternalCrate"` in `rustfmt.toml` enforce both automatically under nightly rustfmt. + +### Code organisation + +The general code-organisation rules apply; these are the Rust specifics. + +- **The file hierarchy is the architecture diagram.** A newcomer should be able to read the directory listing and infer what the project does. + - A subsystem with a public entry point is a folder module whose `mod.rs` states the boundary, with private submodules inside. Only `lib.rs`, `main.rs`, and genuinely cross-cutting types like `errors.rs` stay as top-level single files. + - Avoid a top-level single file where a folder grouping is natural. If several files are semantically related, or file A is only consumed by file B, merge them. +- **The file-size threshold is around 1000 lines.** Split into a folder and re-export the public items from `mod.rs`, so consumers see a stable API. + - Splitting is cheaper than it looks: methods on one type can spread across several `impl` blocks in different files of the same module, so there's no visibility or trait plumbing to do. + - A large inline `#[cfg(test)] mod tests` counts toward the total. Extracting it to a sibling `tests.rs` is often the whole fix. + - Existing oversized files are grandfathered. Split one when a change touches it substantially, not in drive-by churn. +- Shared helpers for a split test module live in its `mod.rs`. + +### Control flow and state machines + +- Avoid rightward drift. If a function is nesting three `select!` blocks or four levels of `match`/`if let`, extract each arm into a named function that takes a context struct. The control flow at the top level should read like an outline. +- Model each state machine as an explicit `enum` with named variants, even if only one field differs between them. Favour an exhaustive `match` plus a `transition` helper over scattered `if let` chains. +- Where a subsystem transitions through phases that own different local state (e.g. `Idle`, `Starting`, `Running`, `Draining`), extract each phase body into its own function and pass a typed context struct. This is the pragmatic version of the typestate pattern for actor-style loops; it keeps invariants local without requiring full type-parameterised phases. +- Invalid transitions should be unrepresentable at the boundary where they're consumed. If `transition()` returns `Option`, the caller should never `.unwrap()` it in production — either enumerate the legal inputs ahead of time, or make the caller total. + +### Platform coupling + +- A file that depends on platform-specific facilities says so on the first line of its module-level docstring: `//! Linux-only: reads /proc/{pid}/cmdline.` The convention is explicit enough that a port to a second platform — or a reviewer — knows exactly what the contract is. +- The outside-world seams take the form of traits — `Fs`, `ProcessSpawner`, `GpuProbe`, `Clock` — bundled into one dependency struct that production and test construct differently. A second OS implementation then falls out of the same shape the fakes already needed. +- When a second platform does land, gate the existing impl with `#[cfg(target_os = "…")]` and add the alternative under a sibling gate; the trait definition stays platform-neutral. + +### Reaching through smart pointers + +- To borrow the value inside a lock guard, a `Box`, or an `Arc`, prefer `.as_ref()` / `.as_mut()` over a manual double-deref: write `state.lock().as_ref()`, not `&**state.lock()`. The named form reads as "borrow the value" rather than as deref bookkeeping. The same applies to an `Arc`: `model.as_ref()`, not `&**model`. + +### Serde + +- Use `#[serde(deny_unknown_fields)]` on config types, and `#[serde(default)]` on new fields so they stay backwards-compatible. +- Use `serde_ignored` when deserialising config, so a typo'd or stale field is reported rather than ignored. +- Avoid `#[serde(untagged)]` when deserialising — the error messages it produces are useless. Write a custom visitor instead. +- Reserve `#[serde(flatten)]` for the case it is genuinely for: extending a shared struct with local fields, or a `toml::Table` catch-all that preserves unknown fields across a round-trip. Note that it interacts badly with `serde_ignored` and with `deny_unknown_fields`. + +### Memory and performance + +- Be deliberate about when you clone and when you borrow. Share immutable data behind `Arc`/`Rc` or borrow it; where the data has a natural tree or graph shape, share the nodes rather than cloning subtrees. +- Use `smol_str` for efficient small string storage. +- Use `smallvec` for collections that are usually small, to avoid heap allocations in the common case. +- Stream data (e.g. iterators) where possible rather than buffering. + +### Dependency versions + +- **Applications, binaries, and workspace-internal crates: pin exactly.** Cargo's default caret range lets resolution drift between `cargo update` runs; `serde = "=1.0.219"` makes a bump a reviewable change instead of an ambient one. +- **Crates published to a registry: use the narrowest range that works.** An exact pin in a published library breaks diamond-dependency unification — if two of a consumer's dependencies pin different patch versions of the same crate, they get two copies or a hard resolution failure. Publish a range and let the consumer's lockfile do the pinning. +- Manage shared versions in the workspace root's `[workspace.dependencies]` and reference them with `{ workspace = true }`. +- Check the current version before adding or bumping a crate rather than writing one from memory. +- Comment on a non-obvious dependency choice, including a feature-flag choice made for a reason. + +## Testing + +### Testing tools + +- **test-case**: For parameterised tests. +- **proptest**: For property-based testing. +- **insta**: For snapshot testing. +- **libtest-mimic**: For custom test harnesses. +- **pretty_assertions**: For better assertion output. +- Use `cargo nextest run` in place of `cargo test` where it's available. + +### Testing conventions + +- Unit tests live in the same file as the code under test, in a `#[cfg(test)] mod tests` block. Integration tests live under `tests/`. +- Never `#[ignore]` a test, and never let one silently skip. A test that cannot run in an environment is gated by a `cfg` or a feature, so its absence is visible. +- Tests must be deterministic: no real subprocesses, no real filesystem, no wall-clock sleeps, no network. Route each of those through the seam traits above and substitute the fake. Gate a fake behind `#[cfg(any(test, feature = "test-fakes"))]` when integration tests need it too. +- Time is the narrow exception when everything already runs on `tokio::time`: `#[tokio::test(start_paused = true)]` plus `tokio::time::advance` gives virtual time without needing another seam. + +## TypeScript code style + +The same correctness-first mindset applies here as anywhere else: TypeScript's type system is strong enough to encode most of the same invariants, and it should be pushed to do so. "Just cast it" is not an acceptable answer. + +### Tooling and workflow + +- These checks must pass at the end of each complete task, and CI enforces them: + - `npm run typecheck` — `tsc -b` with no errors. + - `npm run lint` — ESLint. + - `npm run format:check` — Prettier. `npm run format` writes the fixes. + - `npm run build` — the production build, where the project produces one. +- Run them frequently during development, not just at the end of a task. A clean lint is cheap to maintain and expensive to recover. + +### Compiler settings + +- Keep the project on the strictest practical settings: `strict`, `noUnusedLocals`, `noUnusedParameters`, `noFallthroughCasesInSwitch`, `erasableSyntaxOnly`, and `verbatimModuleSyntax`. Do not relax these. +- Prefer `import type { … }` for type-only imports, as `verbatimModuleSyntax` requires. +- Do not disable a rule or a flag to make a specific piece of code compile. Fix the code instead. + +### Type system patterns + +Treat these as the TypeScript analogues of the Rust patterns. The goal is the same: make illegal states unrepresentable. + +- **Discriminated unions** for modelling state machines and result types — the equivalent of Rust enums. Always include a `kind` (or similar) tag and narrow on it. +- **Exhaustiveness checking** via a `never`-typed default branch in switches and `if`/`else` chains, so adding a new variant becomes a compile error everywhere it is handled. +- **Branded (nominal) types** for values that share a representation but not a meaning (e.g. `UserId` vs. `ProjectId` both being `string`). This is the parallel of Rust newtypes. +- **`readonly`** on arrays, tuples, and object properties by default, and `Readonly` on reference-type parameters. Reach for mutability only when it is genuinely needed. +- **`as const`** for literal data that should be inferred as narrowly as possible, and **`satisfies`** to check a value against a type without widening its inferred type. +- **Template literal types** and mapped/conditional types to encode constraints at the type level where it pays off. +- **Prefer `unknown` over `any`**. If you reach for `any`, stop and reconsider; if it is truly unavoidable, isolate it behind a narrow boundary and document why. +- **Avoid type assertions** (`as SomeType`) and non-null assertions (`!`). Use type guards, discriminated unions, or restructured code instead. A type assertion is a claim the compiler cannot verify, so it is a liability. +- **Validate at boundaries**. Data from the network, `localStorage`, URL parameters, or any other untyped source must be parsed and validated before being treated as typed. Do not trust a `JSON.parse` result. +- Use `type` for object shapes rather than `interface`. Reserve `interface` for the cases that need declaration merging or a framework convention that expects it. +- **String literal unions over `enum`s.** A union of literals narrows properly, needs no import at the use site, and doesn't generate a runtime object. `type Status = "pending" | "active" | "failed"`. +- **Name booleans with a prefix** — `is`, `has`, `can`, `should`, `will` — or as a plain adjective on a data field (`active`, `visible`). Avoid negative names: `isEnabled` beats `isDisabled`, because `!isDisabled` is one negation too many. +- Be consistent about which of `null` and `undefined` means "absent", and convert at the boundary rather than letting both flow through the same field. `undefined` is the ecosystem default (`field?: T`, optional props, default parameters); prefer it unless a wire format or a backend makes `null` the natural choice. + +### Errors + +- Model the full error space. Prefer a discriminated union result type (`{ kind: "ok"; value: T } | { kind: "err"; error: E }`) or similar over throwing for expected failure modes. +- Exceptions are for genuinely exceptional, programmer-error situations. +- User-facing error messages follow the same rules as the rest of the project: present tense, sentence case, with periods. + +### Module organisation + +- Import types and values at the top of the file. No inline `require`/`import()` inside function bodies except for genuinely dynamic imports (code-splitting). +- Use named exports. Reserve default exports for cases where a framework or tool requires them (e.g. route modules, some bundler entry points). +- Use function declarations for top-level functions and arrow functions for inline callbacks. Annotate return types explicitly on exported functions, including `Promise`. +- The file-size threshold here is around 400 lines. +- Group a `lib/` (or equivalent) folder by concern — `api/`, `model/`, `format/`, `nav/` — rather than letting it go flat. +- Use lowerCamelCase filenames for modules that export values and functions. + +### Generated bindings across a language boundary + +Where the frontend talks to a backend in another language, that backend owns the wire contract. The TypeScript side of it is generated — never hand-edited, and generally not committed. + +Wire the generation into the backend's own build so it can't go stale, and have CI run that build before the frontend checks. A change to a wire type is then validated in the same commit that makes it. Lint and format skip the generated directories. + +Regenerating makes new fields *typed*; it doesn't make them *visible*. When a backend change adds or restructures state that the frontend displays, update the display code in the same change — a field that arrives typed but unrendered is a gap, where the UI shows stale behaviour while the backend has moved on. + +### Testing tools + +- **Vitest**: Unit and component tests. +- **Playwright**: For end-to-end flows against a running backend, if and when one is justified. Do not reach for this for what a unit test can cover. + +## React + +- Write function components and hooks. No class components. +- Follow the rules of hooks strictly, and keep `eslint-plugin-react-hooks` warnings at zero. Add the plugin's rules to the ESLint config and include them in the `npm run lint` check. +- Where the React Compiler is enabled, don't add `useMemo`, `useCallback`, or `React.memo` preemptively — prefer plain derivation. Reach for one only when the compiler demonstrably can't handle a case, and say so in a comment. +- That's also why the hooks lint is load-bearing rather than advisory: the compiler's guarantees hold only while the code stays within the Rules of React. +- Keep components small and focused. Lift state only as far as it needs to go. +- Type component props explicitly. Do not rely on inference for the public shape of a component. + - Annotate short prop sets inline on the component: `function Badge({ label }: { label: string })`. + - When props run long — more than about six properties — extract a named exported type and annotate the component with it: `export type BadgeProps = { … }`. + - Extend props via intersection with `&`: `type IconButtonProps = ButtonProps & { icon: Icon }`. +- Prefer composition over configuration — a few focused components beat one component with a dozen boolean props. + +### File and folder layout + +- One main component per file, with its co-located sub-components. Non-component utilities (hooks, constants, pure functions) go in separate files so hot-reload boundaries stay clean. +- Use PascalCase for `.tsx` component files and lowerCamelCase for `.ts` utility files. Where a component and its utilities would collide, give the utilities a `Utilities` suffix (e.g. `channelUtilities.ts`). +- A `components/` folder is for shared components only — those used by two or more views. A component used by exactly one view lives in that view's folder; a component used by exactly one application shell lives with that shell. + +### Testing + +- **React Testing Library** for component tests — query by user-visible semantics, not implementation details. + +## Styling: Tailwind first, CSS last + +- Styling is done with Tailwind utility classes in the markup. +- **Do not write custom CSS unless it truly, genuinely cannot be expressed in Tailwind.** This is a hard rule, not a soft preference. "It would be slightly cleaner in CSS" is not sufficient justification; neither is "I'm more comfortable with CSS". If you think you need custom CSS, first check whether an arbitrary value (`[…]`), a variant, a theme extension, or a small component abstraction solves it. +- When custom CSS is genuinely required (e.g. a keyframe animation, or a selector Tailwind cannot express), keep it minimal, colocated, and leave a comment explaining why Tailwind wasn't sufficient. +- Define the design tokens — colours, spacing, type scale — in the Tailwind theme, and reach for those tokens rather than ad-hoc values, so the visual system stays coherent and design changes stay centralised. Name them for their role in the design, not for the colour they currently are. + +### Linting + +Class order, duplicate and conflicting utilities, typo'd class names, and shorthand collapsing are all machine-checkable — so let the linter own them rather than spending review on them. Use [`eslint-plugin-better-tailwindcss`](https://github.com/schoero/eslint-plugin-better-tailwindcss) with its `recommended` config, and include it in the `npm run lint` check. Two things to get right when setting it up: + +- It reports stylistic rules as warnings and correctness rules as errors by default. Treat both as errors, so lint stays a binary signal. +- Point it at the theme, or every custom token you define will be reported as an unknown class: `entryPoint` for a v4 CSS-based config, `tailwindConfig` for v3. + +```js +settings: { "better-tailwindcss": { entryPoint: "src/app.css" } } +``` + +Compose conditional classes with `clsx` (plus `tailwind-merge` where later classes must override earlier ones), or `cva` for a component with variants. Never build a class name by interpolating fragments into a string — it defeats both the linter and Tailwind's own class extraction. diff --git a/Cargo.lock b/Cargo.lock index f4eb2b14..a147ae25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,21 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -262,6 +277,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "expect-test" version = "1.5.1" @@ -272,6 +297,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fluent-uri" version = "0.1.4" @@ -281,6 +312,12 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -296,6 +333,29 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "glob" version = "0.3.1" @@ -482,6 +542,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -544,6 +610,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -600,6 +675,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -629,6 +713,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.13.0", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -649,6 +752,7 @@ dependencies = [ "pretty_assertions", "prettyplease", "proc-macro2", + "proptest", "pulldown-cmark", "pyxis_macros", "quote", @@ -657,6 +761,7 @@ dependencies = [ "serde_json", "specta", "syn", + "test-case", "toml", ] @@ -692,6 +797,12 @@ dependencies = [ "syn", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.42" @@ -701,6 +812,56 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "rayon" version = "1.12.0" @@ -730,18 +891,49 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.20" @@ -938,6 +1130,52 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "test-case-core", +] + [[package]] name = "thin-vec" version = "0.2.18" @@ -1018,6 +1256,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -1060,6 +1304,24 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1145,6 +1407,12 @@ version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" @@ -1180,6 +1448,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 32c7bc6f..d6350968 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,12 @@ members = [".", "driver", "codegen_tests/output/rust", "pyxis_macros", "tooling/lsp"] default-members = [".", "driver", "tooling/lsp"] +[workspace.lints.clippy] +unwrap_used = "warn" +expect_used = "warn" +panic = "warn" +unreachable = "warn" + [workspace.dependencies] specta = { git = "https://github.com/specta-rs/specta.git", rev = "effe988832312009388a11487ad0ceb09b934a80", features = [ "derive", @@ -15,12 +21,17 @@ lsp-types = "0.97" crossbeam-channel = "0.5" url = "2.5" expect-test = "1.4" +proptest = "1.11" +test-case = "3.3" [package] name = "pyxis" version = "0.1.0" edition = "2024" +[lints] +workspace = true + [dependencies] glob = { version = "0.3.0" } quote = "1.0" @@ -48,3 +59,5 @@ required-features = ["json", "cpp"] [dev-dependencies] pretty_assertions = "1.4" +proptest = { workspace = true } +test-case = { workspace = true } diff --git a/codegen_tests/output/rust/Cargo.lock b/codegen_tests/output/rust/Cargo.lock deleted file mode 100644 index 4e9c6f38..00000000 --- a/codegen_tests/output/rust/Cargo.lock +++ /dev/null @@ -1,16 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "bitflags" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" - -[[package]] -name = "codegen_tests" -version = "0.1.0" -dependencies = [ - "bitflags", -] diff --git a/codegen_tests/output/rust/Cargo.toml b/codegen_tests/output/rust/Cargo.toml index fb71fe46..790a3915 100644 --- a/codegen_tests/output/rust/Cargo.toml +++ b/codegen_tests/output/rust/Cargo.toml @@ -6,3 +6,17 @@ edition = "2024" [lib] name = "codegen_tests" path = "lib.rs" + +# Generated-code policy: this crate is emitted output from `cargo run +# --example codegen_tests` and intentionally contains `unreachable!()` +# size-check bodies and other panicking forms that the generator emits. +# Handwritten code stays lint-clean under the workspace lints +# (unwrap_used/expect_used/panic/unreachable); emitted code is exempted +# at the crate boundary rather than patched at generation time or +# `cfg`-gated out of the restriction. See the conformance status note +# in the contributing guidelines. +[lints.clippy] +unwrap_used = "allow" +expect_used = "allow" +panic = "allow" +unreachable = "allow" diff --git a/docs/json_backend.md b/docs/json_backend.md index 810e54c6..26351857 100644 --- a/docs/json_backend.md +++ b/docs/json_backend.md @@ -128,7 +128,7 @@ Anchors follow a convention: `field-`, `variant-`, `flag-`, `f The JSON backend's structs derive `specta::Type`, which lets the driver generate TypeScript type definitions: ```sh -cargo run -p pyxis-driver - gen-types +cargo run -p pyxis-driver -- gen-types ``` This regenerates `types/json.ts` from the Rust struct definitions. The viewer consumes these types via npm workspaces (the `@pyxis/types` package symlinks to the generated file). @@ -159,3 +159,9 @@ npm run dev ``` The viewer uses Vite for development and builds to static files for production. It loads a JSON documentation file via a file picker in the UI. + +## Testing and known gaps + +The codegen test corpus (`codegen_tests/input` → `codegen_tests/output/json`) exercises the JSON backend end to end: `cargo run --example codegen_tests` emits `output.json` and deserialises it back into a `JsonDocumentation`, so the wire schema is round-tripped on every corpus change. + +Property round-trip tests over the full `JsonDocumentation` shape (arbitrary documents → `serde_json` → parse → equal) are deliberately **not** implemented: the schema is versioned (`CURRENT_SCHEMA_VERSION`) and a meaningful property test would need a second independent implementation of the schema to act as an oracle, which is disproportionate for a versioned, corpus-covered wire format. This is a known, documented gap; revisit if the schema ever grows out of the corpus's reach. diff --git a/driver/Cargo.toml b/driver/Cargo.toml index bb55c962..61f6718a 100644 --- a/driver/Cargo.toml +++ b/driver/Cargo.toml @@ -3,6 +3,9 @@ name = "pyxis-driver" version = "0.1.0" edition = "2024" +[lints] +workspace = true + [dependencies] pyxis = { path = "../", features = ["json", "cpp"] } clap = { version = "4.5", features = ["derive"] } diff --git a/driver/src/main.rs b/driver/src/main.rs index b881ace3..bb784264 100644 --- a/driver/src/main.rs +++ b/driver/src/main.rs @@ -77,6 +77,22 @@ impl From for pyxis::Backend { } } +/// Build [`pyxis::BuildOptions`] from the CLI's Rust-specific flags. Pure and +/// unit-testable: the smoke tests exercise the option mapping and the +/// in-memory build path without a command-line harness. +fn build_options( + rust_root_file_name: Option, + rust_module_prefix: Option, +) -> pyxis::BuildOptions { + pyxis::BuildOptions { + rust_root_file_name, + rust_module_prefix: rust_module_prefix + .as_deref() + .map(pyxis::grammar::ItemPath::from), + ..Default::default() + } +} + fn main() -> Result<(), Box> { let args = Args::parse(); @@ -90,13 +106,7 @@ fn main() -> Result<(), Box> { } => { std::fs::create_dir_all(&out_dir)?; let mut file_store = pyxis::source_store::FileStore::new(); - let options = pyxis::BuildOptions { - rust_root_file_name, - rust_module_prefix: rust_module_prefix - .as_deref() - .map(pyxis::grammar::ItemPath::from), - ..Default::default() - }; + let options = build_options(rust_root_file_name, rust_module_prefix); let result = pyxis::build_with_store_and_options( &in_dir, &out_dir, @@ -261,3 +271,124 @@ fn format_file(file: &PathBuf, check: bool) -> Result pyxis::config::Project { + pyxis::config::Project { + name: "smoke".to_string(), + pointer_size: 8, + } + } + + #[test] + fn backend_mapping_is_exhaustive() { + // Every CLI backend maps to the corresponding compiler backend. + assert!(matches!( + pyxis::Backend::from(Backend::Rust), + pyxis::Backend::Rust + )); + assert!(matches!( + pyxis::Backend::from(Backend::Json), + pyxis::Backend::Json + )); + assert!(matches!( + pyxis::Backend::from(Backend::Cpp), + pyxis::Backend::Cpp + )); + } + + #[test] + fn build_options_mapping() { + // Defaults carry through. + let defaults = build_options(None, None); + assert_eq!(defaults.rust_root_file_name, None); + assert_eq!(defaults.rust_module_prefix, None); + + // A root file name and a module prefix are mapped through. + let opted = build_options(Some("mod.rs".to_string()), Some("prefixed".to_string())); + assert_eq!(opted.rust_root_file_name.as_deref(), Some("mod.rs")); + assert_eq!( + opted.rust_module_prefix.map(|p| p.to_string()), + Some("prefixed".to_string()) + ); + } + + #[test] + fn build_smoke_rust_in_memory() { + // The driver's build decision (Rust backend, prefixed submodule mount) + // succeeds against in-memory sources via `build_sources` — the same + // pipeline the driver invokes, minus the CLI/filesystem shell. + let sources = vec![ + ( + "foo.pyxis".to_string(), + "pub type Foo {\n pub value: u32,\n}\n".to_string(), + ), + ( + "bar.pyxis".to_string(), + "use foo::Foo;\n\npub type Bar {\n pub foo: *mut Foo,\n}\n".to_string(), + ), + ]; + let mut file_store = FileStore::new(); + // The rust backend writes output to `out_dir`; point it at the + // crate-local `target` dir so the test doesn't litter the working tree. + let out_dir = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-artifacts/driver"); + let result = pyxis::build_sources( + sources, + &project(), + &out_dir, + pyxis::Backend::Rust, + &mut file_store, + build_options(Some("mod.rs".to_string()), Some("prefixed".to_string())), + ); + assert!( + result.is_ok(), + "expected in-memory Rust build to succeed, got {result:?}" + ); + } + + #[test] + fn build_smoke_json_in_memory() { + // The JSON backend build path also runs against in-memory sources. + let sources = vec![( + "foo.pyxis".to_string(), + "pub type Foo {\n pub value: u32,\n}\n".to_string(), + )]; + let mut file_store = FileStore::new(); + let out_dir = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-artifacts/driver"); + let result = pyxis::build_sources( + sources, + &project(), + &out_dir, + pyxis::Backend::Json, + &mut file_store, + pyxis::BuildOptions::default(), + ); + assert!( + result.is_ok(), + "expected in-memory JSON build to succeed, got {result:?}" + ); + } + + #[test] + fn check_smoke_in_memory() { + // The check decision path (used by `pyxis check`) runs the analysis + // pipeline against in-memory sources and reports no errors. + let sources = vec![( + "foo.pyxis".to_string(), + "pub type Foo {\n pub value: u32,\n}\n".to_string(), + )]; + let mut file_store = FileStore::new(); + let result = pyxis::check_sources(sources, 8, &mut file_store); + assert!( + result.is_ok(), + "expected in-memory check to succeed, got {result:?}" + ); + } +} diff --git a/examples/codegen_tests.rs b/examples/codegen_tests.rs index 7a1a27e0..67281091 100644 --- a/examples/codegen_tests.rs +++ b/examples/codegen_tests.rs @@ -73,10 +73,10 @@ fn main() -> Result<(), Box> { smoke.push_str("// @generated by codegen_tests - do not edit\n"); for hpp in &hpps { let rel = hpp.strip_prefix(&include_dir)?; - smoke.push_str(&format!( - "#include \"{}\"\n", - rel.to_str().expect("non-utf8 include path") - )); + let rel_str = rel + .to_str() + .ok_or_else(|| format!("non-utf8 include path: {}", rel.display()))?; + smoke.push_str(&format!("#include \"{rel_str}\"\n")); } let src_dir = cpp_output.join("src"); std::fs::create_dir_all(&src_dir)?; diff --git a/package-lock.json b/package-lock.json index 32e42fb1..19434b67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -819,6 +819,20 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/css-tree": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@eslint/css-tree/-/css-tree-4.0.5.tgz", + "integrity": "sha512-iPmijIAq4hlIJB86PYmY/fcZORHtjphSqICDbwuw32A/JmkhZQ/K/6TjHE03zqf3n5yABpVcbRAMG8Mi9ojy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.29.0", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@eslint/eslintrc": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", @@ -1009,6 +1023,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@pyxis/types": { "resolved": "types", "link": true @@ -1370,6 +1397,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", @@ -1687,6 +1721,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -1696,6 +1741,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2078,6 +2130,16 @@ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "license": "ISC" }, + "node_modules/@valibot/to-json-schema": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.7.1.tgz", + "integrity": "sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "valibot": "^1.4.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -2099,6 +2161,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "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.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "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.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "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": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "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.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "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.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "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": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "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.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2162,6 +2337,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -2278,6 +2463,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2335,6 +2530,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2490,9 +2695,9 @@ "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -2503,6 +2708,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -2628,6 +2840,39 @@ } } }, + "node_modules/eslint-plugin-better-tailwindcss": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-better-tailwindcss/-/eslint-plugin-better-tailwindcss-4.7.0.tgz", + "integrity": "sha512-lrdlVW4pzLPj/zX5HRqMhKPesGijDfRhnSRJMlNcsrCyJHsndmHCLKTiRNa1eREUZ6G3D3QjdLc+G4tlfGrmkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint/css-tree": "^4.0.4", + "@valibot/to-json-schema": "^1.7.1", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "synckit": "^0.11.13", + "tailwind-csstree": "^0.3.3", + "tsconfig-paths-webpack-plugin": "^4.2.0", + "valibot": "^1.4.2" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23.0.0" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "oxlint": "^1.35.0", + "tailwindcss": "^3.3.0 || ^4.1.17" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + }, + "oxlint": { + "optional": true + } + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", @@ -2752,6 +2997,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2762,6 +3017,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3838,6 +4103,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.29.0.tgz", + "integrity": "sha512-pVxQFCcaYUEAH853+v7yoI/qzhxXSq1bTb9obMYGYAN1c3Hen+XDCEvr296XhstrwlSTNgOR7mCSD4JPjbJe5A==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -4414,6 +4686,16 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4453,6 +4735,20 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4561,6 +4857,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4918,6 +5221,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4938,6 +5248,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -4952,6 +5276,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4996,6 +5330,55 @@ "node": ">=8" } }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tailwind-csstree": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/tailwind-csstree/-/tailwind-csstree-0.3.3.tgz", + "integrity": "sha512-je9J5UYRsTJqAjYrIBMMlge8T/rreRd44pJxgG5Zx/zeo4kAC/liUKqzztRZrGlYRJLvIf2Cb1DVJMTXSzEShA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + }, + "peerDependencies": { + "@eslint/css": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@eslint/css": { + "optional": true + } + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", @@ -5017,6 +5400,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -5034,6 +5434,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5067,6 +5477,37 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5253,6 +5694,21 @@ "punycode": "^2.1.0" } }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -5360,6 +5816,96 @@ } } }, + "node_modules/vitest": { + "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.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", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@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" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5376,6 +5922,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5463,15 +6026,20 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.0", + "clsx": "^2.1.1", "eslint": "^9.39.1", + "eslint-plugin-better-tailwindcss": "^4.7.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "prettier": "^3.4.2", + "tailwind-merge": "^3.6.0", "tailwindcss": "^4.0.11", "typescript": "~5.9.3", "typescript-eslint": "^8.46.3", - "vite": "^7.2.2" + "vite": "^7.2.2", + "vitest": "^4.1.10", + "zod": "^4.4.3" } } } diff --git a/pyxis_macros/Cargo.toml b/pyxis_macros/Cargo.toml index 447cda36..2196f947 100644 --- a/pyxis_macros/Cargo.toml +++ b/pyxis_macros/Cargo.toml @@ -3,6 +3,9 @@ name = "pyxis_macros" version = "0.1.0" edition = "2024" +[lints] +workspace = true + [lib] proc-macro = true diff --git a/pyxis_macros/src/lib.rs b/pyxis_macros/src/lib.rs index 0923edd3..3788d67d 100644 --- a/pyxis_macros/src/lib.rs +++ b/pyxis_macros/src/lib.rs @@ -1,3 +1,18 @@ +//! Pyxis macro crate: derive macros for the compiler's internal traits. +//! +//! The workspace restriction lints (unwrap_used/expect_used/panic/unreachable) +//! target production code. Test code is explicitly exempt per the contributing +//! guidelines, so allow them under `cfg(test)` only. +#![cfg_attr( + test, + allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable + ) +)] + use proc_macro::TokenStream; use quote::{format_ident, quote}; use syn::{Data, DeriveInput, Fields, Ident, parse_macro_input, spanned::Spanned}; @@ -76,8 +91,19 @@ pub fn derive_has_location(input: TokenStream) -> TokenStream { #name::#variant_name { location, .. } => location }) } else if let Some(first_field) = fields.named.first() { - // Variant delegates to an inner type that implements HasLocation - let field_name = first_field.ident.as_ref().unwrap(); + // Variant delegates to an inner type that implements HasLocation. + // Named fields always carry an ident; an unnamed named-field would + // be a syntax error, so this is a safety net rather than control flow. + let Some(field_name) = first_field.ident.as_ref() else { + errors.push(syn::Error::new( + variant.span(), + format!( + "variant `{}` has an unnamed field; cannot derive HasLocation", + variant_name + ), + )); + return None; + }; Some(quote! { #name::#variant_name { #field_name, .. } => #field_name.location() }) @@ -249,8 +275,8 @@ fn generate_struct_strip_locations(name: &Ident, fields: &Fields) -> proc_macro2 let field_assignments: Vec<_> = fields .named .iter() - .map(|field| { - let field_name = field.ident.as_ref().unwrap(); + .filter_map(|field| field.ident.as_ref().map(|field_name| (field, field_name))) + .map(|(field, field_name)| { let field_attr = get_strip_locations_attr(field); if field_name == "location" { @@ -301,15 +327,14 @@ fn generate_enum_variant_strip_locations( let field_names: Vec<_> = fields .named .iter() - .map(|f| f.ident.as_ref().unwrap()) + .filter_map(|f| f.ident.as_ref()) .collect(); let field_assignments: Vec<_> = fields .named .iter() - .map(|field| { - let field_name = field.ident.as_ref().unwrap(); - + .filter_map(|field| field.ident.as_ref()) + .map(|field_name| { if field_name == "location" { quote! { #field_name: crate::span::ItemLocation::test() } } else { @@ -437,8 +462,8 @@ fn generate_struct_equals(fields: &Fields) -> proc_macro2::TokenStream { .named .iter() .filter(|f| f.ident.as_ref().is_none_or(|i| i != "location")) - .map(|f| { - let field_name = f.ident.as_ref().unwrap(); + .filter_map(|f| f.ident.as_ref()) + .map(|field_name| { quote! { crate::span::EqualsIgnoringLocations::equals_ignoring_locations( &self.#field_name, &other.#field_name @@ -478,7 +503,7 @@ fn generate_enum_variant_equals( let names: Vec<_> = fields .named .iter() - .map(|f| f.ident.as_ref().unwrap()) + .filter_map(|f| f.ident.as_ref()) .filter(|i| *i != "location") .collect(); let a_bindings = names.iter().map(|n| { diff --git a/src/backends/cpp/deps/graph.rs b/src/backends/cpp/deps/graph.rs index 2ed3ecb4..8d395c7a 100644 --- a/src/backends/cpp/deps/graph.rs +++ b/src/backends/cpp/deps/graph.rs @@ -171,22 +171,42 @@ where if st.cycle.is_some() { return; } - let w_low = *st.lowlinks.get(w).unwrap(); - let v_low = st.lowlinks.get_mut(node).unwrap(); + // `w` was just visited by `strongconnect`, which inserts it + // into `lowlinks`/`indices` before recursing; `node` is + // inserted at this function's start. Both keys are present + // by the Tarjan invariant, so the `else` arms are + // unreachable safety nets, not real control flow. + let Some(&w_low) = st.lowlinks.get(w) else { + continue; + }; + let Some(v_low) = st.lowlinks.get_mut(node) else { + continue; + }; *v_low = (*v_low).min(w_low); } else if st.on_stack.contains(w) { - let w_idx = *st.indices.get(w).unwrap(); - let v_low = st.lowlinks.get_mut(node).unwrap(); + // `on_stack` membership means `w` was indexed already, so + // `indices[w]` exists; `node` is present per the invariant + // above. + let Some(&w_idx) = st.indices.get(w) else { + continue; + }; + let Some(v_low) = st.lowlinks.get_mut(node) else { + continue; + }; *v_low = (*v_low).min(w_idx); } } } if st.lowlinks.get(node) == st.indices.get(node) { - // Pop an SCC off the stack. + // Pop an SCC off the stack. The current `node` was pushed at this + // function's start and is popped in this loop, so the stack is + // non-empty here. let mut scc = Vec::new(); loop { - let w = st.stack.pop().expect("non-empty stack at SCC root"); + let Some(w) = st.stack.pop() else { + return; + }; st.on_stack.remove(&w); let done = w == *node; scc.push(w); diff --git a/src/backends/cpp/runtime.rs b/src/backends/cpp/runtime.rs index 9e409dbd..6e1071cd 100644 --- a/src/backends/cpp/runtime.rs +++ b/src/backends/cpp/runtime.rs @@ -4,9 +4,7 @@ //! derive from the table below, so renaming or adding a macro is one //! edit in one place. -use std::fmt::Write as _; - -use crate::semantic::types::CallingConvention; +use crate::{infallible_write, infallible_writeln, semantic::types::CallingConvention}; /// One entry per calling-convention shim macro. `name` is the /// `PYXIS_*` identifier that lands in emitted code; the keyword / @@ -80,37 +78,37 @@ pub fn macro_emit(cc: CallingConvention) -> &'static str { /// on hosts where the calling convention is meaningless. pub fn runtime_header_defines() -> String { let mut out = String::new(); - writeln!(out, "#if defined(_MSC_VER)").unwrap(); + infallible_writeln!(out, "#if defined(_MSC_VER)"); for m in PYXIS_CC_MACROS { - writeln!(out, "# define {} {}", m.name, m.msvc_keyword).unwrap(); + infallible_writeln!(out, "# define {} {}", m.name, m.msvc_keyword); } - writeln!( + infallible_writeln!( out, "#elif (defined(__i386__) || defined(_M_IX86)) && (defined(__GNUC__) || defined(__clang__))" - ) - .unwrap(); + ); for m in PYXIS_CC_MACROS { match m.gnu_i386_attr { - Some(attr) => writeln!(out, "# define {} __attribute__(({}))", m.name, attr).unwrap(), + Some(attr) => { + infallible_writeln!(out, "# define {} __attribute__(({}))", m.name, attr) + } // No equivalent in clang/GCC on i386; the in-header comment // explains why a downstream reader sees an empty macro here. None => { - writeln!( + infallible_writeln!( out, "// `{}` expands to nothing on this branch — clang/GCC \ don't have a real equivalent on i386; binaries that need \ the convention's semantics must build against the MSVC arm.", m.name - ) - .unwrap(); - writeln!(out, "# define {}", m.name).unwrap(); + ); + infallible_writeln!(out, "# define {}", m.name); } } } - writeln!(out, "#else").unwrap(); + infallible_writeln!(out, "#else"); for m in PYXIS_CC_MACROS { - writeln!(out, "# define {}", m.name).unwrap(); + infallible_writeln!(out, "# define {}", m.name); } - write!(out, "#endif").unwrap(); + infallible_write!(out, "#endif"); out } diff --git a/src/backends/error.rs b/src/backends/error.rs index 0b2cff9f..defe999c 100644 --- a/src/backends/error.rs +++ b/src/backends/error.rs @@ -255,9 +255,9 @@ impl BackendError { let report = report_builder.finish(); let mut buffer = Vec::new(); - report - .write((filename, Source::from(source)), &mut buffer) - .expect("writing to Vec should not fail"); + // Writing an ariadne report into a `Vec` cannot fail — the `Write` + // impl on `Vec` is infallible — so the result is deliberately dropped. + let _ = report.write((filename, Source::from(source)), &mut buffer); String::from_utf8_lossy(&buffer).to_string() } } diff --git a/src/backends/json/convert.rs b/src/backends/json/convert.rs index 60371c35..1b7adf2c 100644 --- a/src/backends/json/convert.rs +++ b/src/backends/json/convert.rs @@ -52,6 +52,13 @@ impl JsonDocLink { K::Field => format!("field-{name}"), K::Variant => format!("variant-{name}"), K::Flag => format!("flag-{name}"), + // Constant/ExternValue members are skipped by the caller + // before this match; the arm exists only for exhaustiveness + // and is unreachable. + #[expect( + clippy::unreachable, + reason = "Constant/ExternValue filtered before this match" + )] K::Constant | K::ExternValue => unreachable!("handled above"), }; JsonDocLink { diff --git a/src/backends/json/schema.rs b/src/backends/json/schema.rs index 7e307cf9..f146cc0e 100644 --- a/src/backends/json/schema.rs +++ b/src/backends/json/schema.rs @@ -5,10 +5,23 @@ use serde::{Deserialize, Serialize}; use crate::semantic::types::{CallingConvention, ItemCategory, Visibility}; -// If changing the structure, ensure you rerun `cargo run -- gen-types` to -// update the TypeScript definitions. When making a breaking change to the -// shape, bump `CURRENT_SCHEMA_VERSION` so downstream consumers can detect -// the new format. +// SYNCHRONISATION NOTICE — read this if you touch the JSON wire shape: +// +// The Rust structs in this file derive `specta::Type` and generate +// `types/json.ts` (via `cargo run -p pyxis-driver -- gen-types`), and the +// viewer validates inbound documents against a zod schema that mirrors the +// same shape (`viewer/src/utils/jsonDocumentationSchema.ts`). Any change to +// one of the three must be reflected in the other two: +// +// - Rust structs: this file +// - generated types: types/json.ts (regenerate with `cargo run -p pyxis-driver -- gen-types`) +// - zod schema: viewer/src/utils/jsonDocumentationSchema.ts +// +// There is no automated tie between the Rust source and the zod schema; this +// is a documented convention. See the matching notice in +// `jsonDocumentationSchema.ts`. When making a breaking change to the shape, +// bump `CURRENT_SCHEMA_VERSION` so downstream consumers can detect the new +// format. /// Current JSON schema version. Bump on any breaking shape change. /// diff --git a/src/backends/rust/doc_links.rs b/src/backends/rust/doc_links.rs index b93bb77a..9566536a 100644 --- a/src/backends/rust/doc_links.rs +++ b/src/backends/rust/doc_links.rs @@ -144,5 +144,11 @@ impl DocLinkCx<'_> { pub(super) fn hex_literal(value: impl Into) -> proc_macro2::Literal { // https://stackoverflow.com/a/78902864 - proc_macro2::Literal::from_str(&format!("0x{:X}", value.into())).unwrap() + let hex = format!("0x{:X}", value.into()); + proc_macro2::Literal::from_str(&hex).unwrap_or_else(|_| { + // An uppercase 0x-prefixed hex string of a `usize` is always a valid + // Rust literal; this arm is unreachable and only keeps the function + // total. `Literal::from_str` rejects nothing we can produce here. + proc_macro2::Literal::u64_unsuffixed(0) + }) } diff --git a/src/backends/rust/helpers.rs b/src/backends/rust/helpers.rs index bdb068ee..a0c0ed0f 100644 --- a/src/backends/rust/helpers.rs +++ b/src/backends/rust/helpers.rs @@ -4,8 +4,9 @@ use std::{ }; use crate::{ - backends::Result, + backends::{BackendError, Result}, grammar::ItemPath, + infallible_write, semantic::{ doc_links::ResolvedDocLink, types::{PredefinedItem, Type, Visibility}, @@ -143,18 +144,13 @@ fn fully_qualified_type_ref_impl( type_ref: &Type, prefix: Option<&ItemPath>, module_paths: Option<&BTreeSet>, -) -> std::result::Result<(), std::fmt::Error> { - use std::fmt::Write; - +) -> Result<()> { // `crate::` qualifier, including any module prefix that mounts the // generated tree as a submodule (e.g. `crate::jc2::`). - fn write_crate_qualifier( - out: &mut String, - prefix: Option<&ItemPath>, - ) -> std::result::Result<(), std::fmt::Error> { - write!(out, "crate::")?; + fn write_crate_qualifier(out: &mut String, prefix: Option<&ItemPath>) -> Result<()> { + infallible_write!(out, "crate::"); if let Some(prefix) = prefix { - write!(out, "{prefix}::")?; + infallible_write!(out, "{prefix}::"); } Ok(()) } @@ -205,11 +201,21 @@ fn fully_qualified_type_ref_impl( }); match type_ref { - Type::Unresolved(_) => panic!("received unresolved type {type_ref:?}"), + Type::Unresolved(type_ref) => { + // An unresolved type reference cannot be rendered into Rust; report + // it as a codegen error rather than panicking. The rendered type + // string is the best diagnostic attribution available. + Err(BackendError::TypeCodeGenFailed { + type_path: ItemPath::from(type_ref.to_string().as_str()), + kind: crate::backends::error::TypeCodeGenFailedKind::TypeNotResolved, + location: type_ref.location, + }) + } Type::Raw(path) => { // Check if this is a predefined type if let Some(rust_type) = PREDEFINED_TYPE_MAP.get(path) { - return write!(out, "{rust_type}"); + infallible_write!(out, "{rust_type}"); + return Ok(()); } // Not a predefined type - qualify with crate:: if needed if path.len() > 1 { @@ -224,59 +230,65 @@ fn fully_qualified_type_ref_impl( let module_part: Vec<&str> = path.iter().take(module_len).map(|s| s.as_str()).collect(); if !module_part.is_empty() { - write!(out, "{}::", module_part.join("::"))?; + infallible_write!(out, "{}::", module_part.join("::")); } - write!(out, "{flat}") + infallible_write!(out, "{flat}"); } else { - write!(out, "{flat}") + infallible_write!(out, "{flat}"); } } else { - write!(out, "{path}") + infallible_write!(out, "{path}"); } + Ok(()) } Type::Generic(base_path, args) => { // Generate Rust generic syntax: `Base` if base_path.len() > 1 { write_crate_qualifier(out, prefix)?; } - write!(out, "{base_path}<")?; + infallible_write!(out, "{base_path}<"); for (i, arg) in args.iter().enumerate() { if i > 0 { - write!(out, ", ")?; + infallible_write!(out, ", "); } fully_qualified_type_ref_impl(out, arg, prefix, module_paths)?; } - write!(out, ">") + infallible_write!(out, ">"); + Ok(()) } Type::TypeParameter(name) => { // Type parameter - just output the name (e.g., `T`) - write!(out, "{name}") + infallible_write!(out, "{name}"); + Ok(()) } Type::ConstPointer(tr) => { - write!(out, "*const ")?; - fully_qualified_type_ref_impl(out, tr.as_ref(), prefix, module_paths) + infallible_write!(out, "*const "); + fully_qualified_type_ref_impl(out, tr.as_ref(), prefix, module_paths)?; + Ok(()) } Type::MutPointer(tr) => { - write!(out, "*mut ")?; - fully_qualified_type_ref_impl(out, tr.as_ref(), prefix, module_paths) + infallible_write!(out, "*mut "); + fully_qualified_type_ref_impl(out, tr.as_ref(), prefix, module_paths)?; + Ok(()) } Type::Array(tr, size) => { - write!(out, "[")?; + infallible_write!(out, "["); fully_qualified_type_ref_impl(out, tr.as_ref(), prefix, module_paths)?; - write!(out, "; {size}]") + infallible_write!(out, "; {size}]"); + Ok(()) } Type::Function(calling_convention, args, return_type) => { - write!(out, r#"unsafe extern "{calling_convention}" fn ("#)?; + infallible_write!(out, r#"unsafe extern "{calling_convention}" fn ("#); for arg in args.iter() { if let Some(name) = &arg.name { - write!(out, "{}: ", rust_parameter_ident(name))?; + infallible_write!(out, "{}: ", rust_parameter_ident(name)); } fully_qualified_type_ref_impl(out, &arg.type_, prefix, module_paths)?; - write!(out, ", ")?; + infallible_write!(out, ", "); } - write!(out, ")")?; + infallible_write!(out, ")"); if let Some(type_ref) = return_type { - write!(out, " -> ")?; + infallible_write!(out, " -> "); fully_qualified_type_ref_impl(out, type_ref, prefix, module_paths)?; } Ok(()) @@ -314,7 +326,7 @@ fn fully_qualified_type_ref( type_ref: &Type, prefix: Option<&ItemPath>, module_paths: Option<&BTreeSet>, -) -> std::result::Result { +) -> Result { let mut out = String::new(); fully_qualified_type_ref_impl(&mut out, type_ref, prefix, module_paths)?; Ok(out) diff --git a/src/backends/rust/items.rs b/src/backends/rust/items.rs index 8d351754..ddbc8f9b 100644 --- a/src/backends/rust/items.rs +++ b/src/backends/rust/items.rs @@ -201,7 +201,7 @@ fn build_type( .as_deref() .ok_or_else(|| BackendError::FieldCodeGenFailed { type_path: path.clone(), - field_name: "unnamed".to_string(), + field_name: crate::semantic::type_definition::UNNAMED.to_string(), kind: crate::backends::error::FieldCodeGenFailedKind::FieldNameNotPresent, location: *location, })?; @@ -507,7 +507,7 @@ fn build_union( .as_deref() .ok_or_else(|| BackendError::FieldCodeGenFailed { type_path: path.clone(), - field_name: "unnamed".to_string(), + field_name: crate::semantic::type_definition::UNNAMED.to_string(), kind: crate::backends::error::FieldCodeGenFailedKind::FieldNameNotPresent, location: *location, })?; diff --git a/src/backends/rust/mod.rs b/src/backends/rust/mod.rs index 8570aeba..8ce3d732 100644 --- a/src/backends/rust/mod.rs +++ b/src/backends/rust/mod.rs @@ -279,7 +279,10 @@ pub fn write_module( path.display(), lc.line, lc.column, - raw_output.lines().nth(lc.line - 1).unwrap(), + raw_output + .lines() + .nth(lc.line - 1) + .unwrap_or(raw_output.as_str()), format!("{}^", " ".repeat(lc.column)) )); raw_output diff --git a/src/backends/rust/values.rs b/src/backends/rust/values.rs index b9a5b9de..33897b26 100644 --- a/src/backends/rust/values.rs +++ b/src/backends/rust/values.rs @@ -365,9 +365,14 @@ pub(super) fn build_function( } } } + // External-body functions are short-circuited at the top of + // build_function — we never reach here. The arm exists only for + // exhaustiveness of the `FunctionBody` enum. + #[expect( + clippy::unreachable, + reason = "External bodies are short-circuited in build_function" + )] FunctionBody::External => { - // External-body functions are short-circuited at the top of - // build_function — we never reach here. unreachable!("FunctionBody::External handled above"); } }; diff --git a/src/fmt.rs b/src/fmt.rs new file mode 100644 index 00000000..f7430875 --- /dev/null +++ b/src/fmt.rs @@ -0,0 +1,40 @@ +//! Infallible formatting macros for `std::fmt::Write` sinks that cannot fail. +//! +//! Writing to a `String` (or any `String`-backed buffer) via +//! [`std::fmt::Write`] is infallible in practice: `str` targets never return +//! an error for the `fmt` calls this crate makes. These macros drop that +//! `fmt::Result` at exactly one place — the macro body — with the +//! justification documented here, so call sites stay free of +//! `.unwrap()`/`.expect()` noise. +//! +//! Only use these for sinks that provably cannot fail (in-memory `String` +//! buffers). If a writer can genuinely fail, propagate the `Result` instead. + +/// Write formatted text into a [`std::fmt::Write`] sink that cannot fail. +/// +/// The sink is a `String`-backed buffer, whose `write_fmt` only errors on +/// out-of-memory or a broken `Formatter` — neither occurs for the in-memory +/// formatting this crate does. The result is deliberately dropped here, once, +/// rather than unwrapped at every call site. +#[macro_export] +macro_rules! infallible_write { + ($dst:expr $(, $($arg:tt)*)?) => {{ + use ::std::fmt::Write as _; + let _ = write!($dst $(, $($arg)*)?); + }}; +} + +/// Write a formatted line (with trailing newline) into a [`std::fmt::Write`] +/// sink that cannot fail. +/// +/// The sink is a `String`-backed buffer, whose `write_fmt` only errors on +/// out-of-memory or a broken `Formatter` — neither occurs for the in-memory +/// formatting this crate does. The result is deliberately dropped here, once, +/// rather than unwrapped at every call site. +#[macro_export] +macro_rules! infallible_writeln { + ($dst:expr $(, $($arg:tt)*)?) => {{ + use ::std::fmt::Write as _; + let _ = writeln!($dst $(, $($arg)*)?); + }}; +} diff --git a/src/lib.rs b/src/lib.rs index 547b6fe7..9a98d51f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,19 @@ #![allow(clippy::result_large_err)] #![allow(clippy::collapsible_if)] #![deny(clippy::uninlined_format_args)] +// The workspace restriction lints (unwrap_used/expect_used/panic/unreachable) +// target production code. Test code is explicitly exempt per the contributing +// guidelines ("No `unwrap()` or `expect()` in production code; tests are +// fine"), so allow them under `cfg(test)` only. +#![cfg_attr( + test, + allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable + ) +)] use std::path::Path; @@ -14,7 +27,8 @@ pub mod source_store; pub mod span; pub mod tokenizer; -pub(crate) mod util; +pub mod fmt; +pub(crate) mod math; // Re-export semantic error for convenience pub use semantic::SemanticError; @@ -260,9 +274,10 @@ impl BuildError { .finish(); let mut buffer = Vec::new(); - report - .write(("", Source::from("")), &mut buffer) - .expect("writing to Vec should not fail"); + // Writing an ariadne report into a `Vec` cannot fail — the + // `Write` impl on `Vec` is infallible — so the `fmt::Result` is + // deliberately dropped here. + let _ = report.write(("", Source::from("")), &mut buffer); String::from_utf8_lossy(&buffer).to_string() } } @@ -324,10 +339,11 @@ pub fn build_with_store_and_options( ) -> Result<(), BuildError> { let config = config::Config::load(&in_dir.join("pyxis.toml"))?; - // Build a Salsa database and register all .pyxis source files as inputs. - let db = semantic::PyxisDatabaseImpl::default(); - let mut sources = Vec::new(); - + // Read all .pyxis source files from the input directory into memory, then + // delegate to the source-based core. This keeps the filesystem access in + // one place: the test suite drives the same pipeline through + // [`build_sources`] without touching the disk. + let mut sources: Vec<(String, String)> = Vec::new(); for path in glob::glob(&format!("{}/**/*.pyxis", in_dir.display()))?.filter_map(Result::ok) { let source = std::fs::read_to_string(&path).map_err(|e| BuildError::Io { error: e, @@ -335,17 +351,54 @@ pub fn build_with_store_and_options( })?; let relative_path = path.strip_prefix(in_dir).unwrap_or(&path); let filename = relative_path.display().to_string(); - let file_id = file_store.register_path(filename.clone(), path.to_path_buf()); + sources.push((filename, source)); + } + + build_sources( + sources, + &config.project, + out_dir, + backend, + file_store, + options, + ) +} + +/// Build from in-memory sources without touching the filesystem. +/// +/// `sources` is a list of `(filename, content)` pairs where `filename` is the +/// project-relative path (as it would appear in error messages). This is the +/// filesystem-free entry point used by the test suite; the disk-based +/// [`build_with_store_and_options`] reads files then calls through to here. +pub fn build_sources( + sources: Vec<(String, String)>, + project: &config::Project, + out_dir: &Path, + backend: Backend, + file_store: &mut source_store::FileStore, + options: BuildOptions, +) -> Result<(), BuildError> { + // Build a Salsa database and register all sources as inputs. + let db = semantic::PyxisDatabaseImpl::default(); + let mut source_set_entries = Vec::new(); + + for (filename, source) in sources { + // The disk-based pipeline globs `**/*.pyxis` only (config files like + // `pyxis.toml` are never registered as sources); mirror that here. + if !filename.ends_with(".pyxis") { + continue; + } + let file_id = file_store.register_in_memory(filename.clone(), source.clone()); let file_id_u32 = file_id.index() as u32; let source_file = semantic::SourceFile::new(&db, filename, file_id_u32, source); - sources.push(source_file); + source_set_entries.push(source_file); } // Create an interned source set for the Salsa query - let source_set = semantic::SourceSet::new(&db, sources); + let source_set = semantic::SourceSet::new(&db, source_set_entries); // Run the Salsa-backed analysis query. - let analysis = semantic::analyze(&db, config.project.pointer_size, source_set); + let analysis = semantic::analyze(&db, project.pointer_size, source_set); // Dual-path error model: collect all errors, but return the first as Err // to preserve the existing Result<(), BuildError> contract. @@ -356,9 +409,16 @@ pub fn build_with_store_and_options( return Err(BuildError::Semantic(first_semantic_err.clone())); } - let resolved_semantic_state = analysis - .to_semantic_output(&db) - .expect("to_semantic_output returns Some when there are no parse or semantic errors"); + let resolved_semantic_state = analysis.to_semantic_output(&db).ok_or_else(|| { + // `to_semantic_output` returns None when stale/unresolved types remain + // after the error-free pass above; report that as a stalled build + // rather than panicking. + BuildError::Semantic(SemanticError::TypeResolutionStalled { + unresolved_types: vec![], + resolved_types: vec![], + unresolved_references: vec![], + }) + })?; match backend { Backend::Rust => { @@ -375,17 +435,12 @@ pub fn build_with_store_and_options( } #[cfg(feature = "json")] Backend::Json => { - backends::json::build( - out_dir, - &resolved_semantic_state, - &config.project.name, - file_store, - )?; + backends::json::build(out_dir, &resolved_semantic_state, &project.name, file_store)?; Ok(()) } #[cfg(feature = "cpp")] Backend::Cpp => { - backends::cpp::build(out_dir, &resolved_semantic_state, &config.project)?; + backends::cpp::build(out_dir, &resolved_semantic_state, project)?; Ok(()) } // The backend is a valid, parseable target, but its codegen wasn't @@ -422,10 +477,9 @@ pub fn check_with_store( ) -> Result<(), Vec> { let config = config::Config::load(&in_dir.join("pyxis.toml")).map_err(|e| vec![e.into()])?; - // Build a Salsa database and register all .pyxis source files as inputs. - let db = semantic::PyxisDatabaseImpl::default(); - let mut sources = Vec::new(); - + // Read all .pyxis source files into memory, then delegate to the + // source-based core (see [`check_sources`]). + let mut sources: Vec<(String, String)> = Vec::new(); for path in glob::glob(&format!("{}/**/*.pyxis", in_dir.display())) .map_err(|e| vec![e.into()])? .filter_map(Result::ok) @@ -438,17 +492,43 @@ pub fn check_with_store( })?; let relative_path = path.strip_prefix(in_dir).unwrap_or(&path); let filename = relative_path.display().to_string(); - let file_id = file_store.register_path(filename.clone(), path.to_path_buf()); + sources.push((filename, source)); + } + + check_sources(sources, config.project.pointer_size, file_store) +} + +/// Check in-memory sources for errors without touching the filesystem. +/// +/// `sources` is a list of `(filename, content)` pairs where `filename` is the +/// project-relative path (as it would appear in error messages). The +/// filesystem-free twin of [`check_with_store`], used by the test suite. +pub fn check_sources( + sources: Vec<(String, String)>, + pointer_size: usize, + file_store: &mut source_store::FileStore, +) -> Result<(), Vec> { + // Build a Salsa database and register all sources as inputs. + let db = semantic::PyxisDatabaseImpl::default(); + let mut source_set_entries = Vec::new(); + + for (filename, source) in sources { + // The disk-based pipeline globs `**/*.pyxis` only (config files like + // `pyxis.toml` are never registered as sources); mirror that here. + if !filename.ends_with(".pyxis") { + continue; + } + let file_id = file_store.register_in_memory(filename.clone(), source.clone()); let file_id_u32 = file_id.index() as u32; let source_file = semantic::SourceFile::new(&db, filename, file_id_u32, source); - sources.push(source_file); + source_set_entries.push(source_file); } // Create an interned source set for the Salsa query - let source_set = semantic::SourceSet::new(&db, sources); + let source_set = semantic::SourceSet::new(&db, source_set_entries); // Run the Salsa-backed analysis query. - let analysis = semantic::analyze(&db, config.project.pointer_size, source_set); + let analysis = semantic::analyze(&db, pointer_size, source_set); // Collect ALL errors — parse errors first (matching build's priority), // then semantic errors. Parse errors short-circuit semantic analysis diff --git a/src/util.rs b/src/math.rs similarity index 100% rename from src/util.rs rename to src/math.rs diff --git a/src/parser/attributes.rs b/src/parser/attributes.rs index 7d042a86..34159d6f 100644 --- a/src/parser/attributes.rs +++ b/src/parser/attributes.rs @@ -321,11 +321,25 @@ impl Attributes { } match found.len() { 0 => None, - 1 => Some(found.into_iter().next().unwrap()), - _ => Some(CfgPredicate::All { - predicates: found, - location: location.unwrap(), - }), + 1 => { + // len() == 1 guarantees the single element is present; this + // cannot be None. + let [predicate] = found.as_slice() else { + return None; + }; + Some(predicate.clone()) + } + _ => { + // `location` is set in the same loop that collected `found`, so + // the `_` arm implies `Some`. Kept match-shaped so a future + // refactor that decouples them fails loudly at compile time + // rather than dereferencing None. + let location = location.unwrap_or(ItemLocation::internal()); + Some(CfgPredicate::All { + predicates: found, + location, + }) + } } } } @@ -449,19 +463,30 @@ impl Parser { location, }, "not" => { - if children.len() != 1 { + // The `children.len() != 1` check above guarantees the + // single element is present. + let [child] = children.as_slice() else { return Err(ParseError::ExpectedToken { expected: vec![TokenKind::RParen], found: self.peek().clone(), location, }); - } + }; CfgPredicate::Not { - predicate: Box::new(children.into_iter().next().unwrap()), + predicate: Box::new(child.clone()), location, } } - _ => unreachable!(), + // The `matches!(name.as_str(), "any" | "all" | "not")` guard + // bounds `name`; a catch-all here reports a parse error rather + // than panicking if the guard and match ever drift apart. + _ => { + return Err(ParseError::ExpectedToken { + expected: vec![TokenKind::Ident(name)], + found: self.peek().clone(), + location, + }); + } }); } @@ -518,7 +543,14 @@ impl Parser { let comment_text = match &token.kind { TokenKind::Comment(text) => text.clone(), TokenKind::MultiLineComment(text) => text.clone(), - _ => unreachable!(), + // The `matches!(..., Comment(_) | MultiLineComment(_))` + // guard above bounds the token kind to these two; this + // arm is unreachable. + #[expect( + clippy::unreachable, + reason = "guard bounds kind to Comment/MultiLineComment" + )] + _ => unreachable!("token was just matched as a comment"), }; items.push(AttributeItem::Comment { text: comment_text, @@ -548,7 +580,14 @@ impl Parser { let comment_text = match &token.kind { TokenKind::Comment(text) => text.clone(), TokenKind::MultiLineComment(text) => text.clone(), - _ => unreachable!(), + // The `matches!(..., Comment(_) | MultiLineComment(_))` + // guard above bounds the token kind to these two; this + // arm is unreachable. + #[expect( + clippy::unreachable, + reason = "guard bounds kind to Comment/MultiLineComment" + )] + _ => unreachable!("token was just matched as a comment"), }; items.push(AttributeItem::Comment { text: comment_text, @@ -583,7 +622,14 @@ impl Parser { let comment_text = match &token.kind { TokenKind::Comment(text) => text.clone(), TokenKind::MultiLineComment(text) => text.clone(), - _ => unreachable!(), + // The `matches!(..., Comment(_) | MultiLineComment(_))` + // guard above bounds the token kind to these two; this arm + // is unreachable. + #[expect( + clippy::unreachable, + reason = "guard bounds kind to Comment/MultiLineComment" + )] + _ => unreachable!("token was just matched as a comment"), }; items.push(AttributeItem::Comment { text: comment_text, @@ -608,7 +654,14 @@ impl Parser { let comment_text = match &token.kind { TokenKind::Comment(text) => text.clone(), TokenKind::MultiLineComment(text) => text.clone(), - _ => unreachable!(), + // The `matches!(..., Comment(_) | MultiLineComment(_))` + // guard above bounds the token kind to these two; this arm + // is unreachable. + #[expect( + clippy::unreachable, + reason = "guard bounds kind to Comment/MultiLineComment" + )] + _ => unreachable!("token was just matched as a comment"), }; items.push(AttributeItem::Comment { text: comment_text, diff --git a/src/parser/core.rs b/src/parser/core.rs index ba26801a..f3fc5aa7 100644 --- a/src/parser/core.rs +++ b/src/parser/core.rs @@ -72,13 +72,11 @@ impl Parser { pub(crate) fn expect_ident(&mut self) -> Result<(Ident, Span), ParseError> { match self.peek() { - TokenKind::Ident(_) => { - let token = self.advance(); - if let TokenKind::Ident(name) = token.kind { - Ok((Ident(name), token.location.span)) - } else { - unreachable!() - } + TokenKind::Ident(name) => { + let ident = Ident(name.clone()); + let span = self.current().location.span; + self.advance(); + Ok((ident, span)) } TokenKind::Underscore => { let token = self.advance(); diff --git a/src/parser/error.rs b/src/parser/error.rs index 0e6d3708..222aaf55 100644 --- a/src/parser/error.rs +++ b/src/parser/error.rs @@ -88,11 +88,13 @@ impl ParseError { .iter() .map(|t| format!("{t:?}")) .collect(); - format!( - "{}, or {:?}", - all_but_last.join(", "), - expected.last().unwrap() - ) + // The `_` arm only fires when `expected` has more than one + // entry (the single-entry case is matched above), so the last + // element exists. + let Some(last) = expected.last() else { + return "(expected at least one token)".to_string(); + }; + format!("{}, or {:?}", all_but_last.join(", "), last) } } } @@ -125,9 +127,9 @@ impl ParseError { .finish(); let mut buffer = Vec::new(); - report - .write((filename, Source::from(source)), &mut buffer) - .expect("writing to Vec should not fail"); + // Writing an ariadne report into a `Vec` cannot fail — the `Write` + // impl on `Vec` is infallible — so the result is deliberately dropped. + let _ = report.write((filename, Source::from(source)), &mut buffer); String::from_utf8_lossy(&buffer).to_string() } diff --git a/src/parser/expressions.rs b/src/parser/expressions.rs index 67f8edf9..390dd5b6 100644 --- a/src/parser/expressions.rs +++ b/src/parser/expressions.rs @@ -167,13 +167,21 @@ impl Parser { } TokenKind::FloatLiteral(_) => { let token = self.advance(); - let TokenKind::FloatLiteral(s) = token.kind else { - unreachable!() - }; - Ok(Expr::FloatLiteral { - raw_text: s, - location: token.location, - }) + match token.kind { + TokenKind::FloatLiteral(s) => Ok(Expr::FloatLiteral { + raw_text: s, + location: token.location, + }), + // The token was just peeked as `FloatLiteral` and `advance()` + // returns that same token, so this arm is unreachable. It + // exists so a future token-restructure fails exhaustively + // at compile time rather than silently. + #[expect( + clippy::unreachable, + reason = "peeked as FloatLiteral; advance() returns the same token" + )] + _ => unreachable!("peeked as FloatLiteral; advance() returns the same token"), + } } TokenKind::StringLiteral(_) => { let token = self.current().clone(); @@ -193,8 +201,15 @@ impl Parser { } TokenKind::CStringLiteral(_) => { let token = self.advance(); - let TokenKind::CStringLiteral(s) = token.kind else { - unreachable!() + let s = match token.kind { + TokenKind::CStringLiteral(s) => s, + // Peeked as `CStringLiteral` above; `advance()` returns the + // same token, so this arm is unreachable. + #[expect( + clippy::unreachable, + reason = "peeked as CStringLiteral; advance() returns it" + )] + _ => unreachable!("peeked as CStringLiteral; advance() returns the same token"), }; // Detect raw-ness from the original token text: `cr` prefix // means raw, `c"` means regular. @@ -308,8 +323,15 @@ impl Parser { match self.peek() { TokenKind::IntLiteral(_) => { let token = self.advance(); - let TokenKind::IntLiteral(s) = token.kind else { - unreachable!() + let s = match token.kind { + TokenKind::IntLiteral(s) => s, + // Peeked as `IntLiteral` above; `advance()` returns the + // same token, so this arm is unreachable. + #[expect( + clippy::unreachable, + reason = "peeked as IntLiteral; advance() returns it" + )] + _ => unreachable!("peeked as IntLiteral; advance() returns the same token"), }; // Remove underscores let s = s.replace('_', ""); @@ -383,8 +405,15 @@ impl Parser { match self.peek() { TokenKind::StringLiteral(_) => { let token = self.advance(); - let TokenKind::StringLiteral(s) = token.kind else { - unreachable!() + let s = match token.kind { + TokenKind::StringLiteral(s) => s, + // Peeked as `StringLiteral` above; `advance()` returns the + // same token, so this arm is unreachable. + #[expect( + clippy::unreachable, + reason = "peeked as StringLiteral; advance() returns it" + )] + _ => unreachable!("peeked as StringLiteral; advance() returns the same token"), }; Ok((s, token.location)) } diff --git a/src/parser/paths.rs b/src/parser/paths.rs index 328de873..0448bbac 100644 --- a/src/parser/paths.rs +++ b/src/parser/paths.rs @@ -161,7 +161,15 @@ impl Parser { // For use statements and other item paths, we need to skip over // generic-like syntax to get to the end of the path segment let (generic_str, generic_end) = self.parse_generic_args_as_string()?; - let last = segments.last_mut().unwrap(); + // `segments` is non-empty here: every path has at least one + // segment by the time `<` generic args are parsed. + let Some(last) = segments.last_mut() else { + return Err(ParseError::ExpectedToken { + expected: vec![TokenKind::Ident(String::new())], + found: self.peek().clone(), + location: self.current().location, + }); + }; *last = ItemPathSegment::from(format!("{}{}", last.as_str(), generic_str)); end_pos = generic_end; } diff --git a/src/pretty_print/definitions.rs b/src/pretty_print/definitions.rs index 9d5f90dc..fa27109e 100644 --- a/src/pretty_print/definitions.rs +++ b/src/pretty_print/definitions.rs @@ -1,6 +1,8 @@ use super::{PrettyPrinter, is_value_item}; -use crate::grammar::{ItemDefinitionInner, *}; -use std::fmt::Write; +use crate::{ + grammar::{ItemDefinitionInner, *}, + infallible_write, infallible_writeln, +}; impl PrettyPrinter { pub(super) fn print_item_definition(&mut self, def: &ItemDefinition, nested: bool) { @@ -8,7 +10,7 @@ impl PrettyPrinter { self.write_indent(); if def.visibility == Visibility::Public { - write!(&mut self.output, "pub ").unwrap(); + infallible_write!(&mut self.output, "pub "); } let type_params = self.format_type_parameters(&def.type_parameters); @@ -21,24 +23,24 @@ impl PrettyPrinter { ItemDefinitionInner::Enum(ed) => self.print_enum_definition(def, ed), ItemDefinitionInner::Bitflags(bf) => self.print_bitflags_definition(def, bf), ItemDefinitionInner::TypeAlias(ta) => { - write!(&mut self.output, "type {}{} = ", def.name, type_params).unwrap(); + infallible_write!(&mut self.output, "type {}{} = ", def.name, type_params); self.print_type(&ta.target); let terminator = if nested { ',' } else { ';' }; - writeln!(&mut self.output, "{terminator}").unwrap(); + infallible_writeln!(&mut self.output, "{terminator}"); } ItemDefinitionInner::Constant(cd) => { - write!(&mut self.output, "const {}: ", def.name).unwrap(); + infallible_write!(&mut self.output, "const {}: ", def.name); self.print_type(&cd.type_); - write!(&mut self.output, " = ").unwrap(); + infallible_write!(&mut self.output, " = "); self.print_expr(&cd.expr); let terminator = if nested { ',' } else { ';' }; - writeln!(&mut self.output, "{terminator}").unwrap(); + infallible_writeln!(&mut self.output, "{terminator}"); } ItemDefinitionInner::ExternValue(ev) => { - write!(&mut self.output, "extern {}: ", def.name).unwrap(); + infallible_write!(&mut self.output, "extern {}: ", def.name); self.print_type(&ev.type_); let terminator = if nested { ',' } else { ';' }; - writeln!(&mut self.output, "{terminator}").unwrap(); + infallible_writeln!(&mut self.output, "{terminator}"); } } } @@ -49,7 +51,7 @@ impl PrettyPrinter { // Print doc comments (they already include the space after ///) for doc in &def.doc_comments { self.write_indent(); - writeln!(&mut self.output, "///{doc}").unwrap(); + infallible_writeln!(&mut self.output, "///{doc}"); } // Print attributes and comments from the inner definition @@ -82,22 +84,22 @@ impl PrettyPrinter { // Print attributes with inline trailing comments if !attributes.0.is_empty() { self.write_indent(); - write!(&mut self.output, "#[").unwrap(); + infallible_write!(&mut self.output, "#["); for (i, attr) in attributes.0.iter().enumerate() { if i > 0 { - write!(&mut self.output, ", ").unwrap(); + infallible_write!(&mut self.output, ", "); } self.print_attribute(attr); } - write!(&mut self.output, "]").unwrap(); + infallible_write!(&mut self.output, "]"); // Print inline trailing comments (comments on the same line as attributes) for comment in inline_trailing_comments { - write!(&mut self.output, " ").unwrap(); + infallible_write!(&mut self.output, " "); self.print_comment_inline(comment); } - writeln!(&mut self.output).unwrap(); + infallible_writeln!(&mut self.output); } // Print following comments (comments on lines after attributes) @@ -120,14 +122,14 @@ impl PrettyPrinter { // level. A braced body — even an empty one — is self-terminating. if td.is_opaque { let terminator = if nested { ',' } else { ';' }; - writeln!( + infallible_writeln!( &mut self.output, "type {}{}{terminator}", - def.name, type_params - ) - .unwrap(); + def.name, + type_params + ); } else { - writeln!(&mut self.output, "type {}{} {{", def.name, type_params).unwrap(); + infallible_writeln!(&mut self.output, "type {}{} {{", def.name, type_params); self.print_type_body_items(&td.items); } } @@ -135,7 +137,7 @@ impl PrettyPrinter { /// Print a `union` definition body. A union body is the same AST as a type /// body, so it groups and orders its items identically. fn print_union_definition(&mut self, def: &ItemDefinition, ud: &UnionDefinition) { - writeln!(&mut self.output, "union {} {{", def.name).unwrap(); + infallible_writeln!(&mut self.output, "union {} {{", def.name); self.print_type_body_items(&ud.items); } @@ -247,16 +249,16 @@ impl PrettyPrinter { self.dedent(); self.write_indent(); - writeln!(&mut self.output, "}}").unwrap(); + infallible_writeln!(&mut self.output, "}}"); } } /// Print an `enum` definition body: the backing type, then constants /// followed by variants and comments. fn print_enum_definition(&mut self, def: &ItemDefinition, ed: &EnumDefinition) { - write!(&mut self.output, "enum {}: ", def.name).unwrap(); + infallible_write!(&mut self.output, "enum {}: ", def.name); self.print_type(&ed.type_); - writeln!(&mut self.output, " {{").unwrap(); + infallible_writeln!(&mut self.output, " {{"); self.indent(); // Set binary literal width based on enum type let old_width = self.binary_literal_width; @@ -304,15 +306,15 @@ impl PrettyPrinter { self.binary_literal_width = old_width; self.dedent(); self.write_indent(); - writeln!(&mut self.output, "}}").unwrap(); + infallible_writeln!(&mut self.output, "}}"); } /// Print a `bitflags` definition body: the backing type, then constants /// followed by flags and comments. fn print_bitflags_definition(&mut self, def: &ItemDefinition, bf: &BitflagsDefinition) { - write!(&mut self.output, "bitflags {}: ", def.name).unwrap(); + infallible_write!(&mut self.output, "bitflags {}: ", def.name); self.print_type(&bf.type_); - writeln!(&mut self.output, " {{").unwrap(); + infallible_writeln!(&mut self.output, " {{"); self.indent(); // Set binary literal width based on bitflags type let old_width = self.binary_literal_width; @@ -364,7 +366,7 @@ impl PrettyPrinter { self.binary_literal_width = old_width; self.dedent(); self.write_indent(); - writeln!(&mut self.output, "}}").unwrap(); + infallible_writeln!(&mut self.output, "}}"); } fn print_type_statement(&mut self, stmt: &TypeStatement, next_item: Option<&TypeDefItem>) { @@ -381,7 +383,7 @@ impl PrettyPrinter { // Print doc comments (they already include the space after ///) for doc in &stmt.doc_comments { self.write_indent(); - writeln!(&mut self.output, "///{doc}").unwrap(); + infallible_writeln!(&mut self.output, "///{doc}"); } self.print_attributes(&stmt.attributes); @@ -390,19 +392,19 @@ impl PrettyPrinter { TypeField::Field(vis, name, type_) => { self.write_indent(); if *vis == Visibility::Public { - write!(&mut self.output, "pub ").unwrap(); + infallible_write!(&mut self.output, "pub "); } - write!(&mut self.output, "{name}: ").unwrap(); + infallible_write!(&mut self.output, "{name}: "); self.print_type(type_); - write!(&mut self.output, ",").unwrap(); + infallible_write!(&mut self.output, ","); // Print inline trailing comments for comment in &stmt.inline_trailing_comments { - write!(&mut self.output, " ").unwrap(); + infallible_write!(&mut self.output, " "); self.print_comment_inline(comment); } - writeln!(&mut self.output).unwrap(); + infallible_writeln!(&mut self.output); // Print following comments (comments on lines after the field) for comment in &stmt.following_comments { @@ -412,9 +414,9 @@ impl PrettyPrinter { TypeField::Vftable(funcs) => { self.write_indent(); if funcs.is_empty() { - write!(&mut self.output, "vftable {{}},").unwrap(); + infallible_write!(&mut self.output, "vftable {{}},"); } else { - writeln!(&mut self.output, "vftable {{").unwrap(); + infallible_writeln!(&mut self.output, "vftable {{"); self.indent(); for (i, func) in funcs.iter().enumerate() { // Add blank line before function if it has index attribute and it's not the first @@ -428,16 +430,16 @@ impl PrettyPrinter { } self.dedent(); self.write_indent(); - write!(&mut self.output, "}},").unwrap(); + infallible_write!(&mut self.output, "}},"); } // Print inline trailing comments for vftable too for comment in &stmt.inline_trailing_comments { - write!(&mut self.output, " ").unwrap(); + infallible_write!(&mut self.output, " "); self.print_comment_inline(comment); } - writeln!(&mut self.output).unwrap(); + infallible_writeln!(&mut self.output); // Print following comments (comments on lines after vftable) for comment in &stmt.following_comments { @@ -456,23 +458,23 @@ impl PrettyPrinter { } => { self.write_indent(); if *visibility == Visibility::Public { - write!(&mut self.output, "pub ").unwrap(); + infallible_write!(&mut self.output, "pub "); } - writeln!(&mut self.output, "{name}: union {{").unwrap(); + infallible_writeln!(&mut self.output, "{name}: union {{"); self.print_type_body_items(&body.items); // `print_type_body_items` closes with `}\n`; an inline union is // a field, so it needs the field's trailing comma. if self.output.ends_with("}\n") { self.output.pop(); - write!(&mut self.output, ",").unwrap(); + infallible_write!(&mut self.output, ","); } for comment in &stmt.inline_trailing_comments { - write!(&mut self.output, " ").unwrap(); + infallible_write!(&mut self.output, " "); self.print_comment_inline(comment); } - writeln!(&mut self.output).unwrap(); + infallible_writeln!(&mut self.output); for comment in &stmt.following_comments { self.print_comment(comment); @@ -488,7 +490,7 @@ impl PrettyPrinter { // Replace the trailing newline after `}` with `},\n` if self.output.ends_with("}\n") { self.output.pop(); // remove \n - writeln!(&mut self.output, ",").unwrap(); + infallible_writeln!(&mut self.output, ","); } } } @@ -499,25 +501,25 @@ impl PrettyPrinter { // Print doc comments (they already include the space after ///) for doc in &stmt.doc_comments { self.write_indent(); - writeln!(&mut self.output, "///{doc}").unwrap(); + infallible_writeln!(&mut self.output, "///{doc}"); } self.print_attributes(&stmt.attributes); self.write_indent(); - write!(&mut self.output, "{}", stmt.name).unwrap(); + infallible_write!(&mut self.output, "{}", stmt.name); if let Some(expr) = &stmt.expr { - write!(&mut self.output, " = ").unwrap(); + infallible_write!(&mut self.output, " = "); self.print_expr(expr); } - write!(&mut self.output, ",").unwrap(); + infallible_write!(&mut self.output, ","); // Print inline trailing comments for comment in &stmt.inline_trailing_comments { - write!(&mut self.output, " ").unwrap(); + infallible_write!(&mut self.output, " "); self.print_comment_inline(comment); } - writeln!(&mut self.output).unwrap(); + infallible_writeln!(&mut self.output); // Print following comments (comments on lines after the enum variant) for comment in &stmt.following_comments { @@ -533,22 +535,22 @@ impl PrettyPrinter { // Print doc comments (they already include the space after ///) for doc in &stmt.doc_comments { self.write_indent(); - writeln!(&mut self.output, "///{doc}").unwrap(); + infallible_writeln!(&mut self.output, "///{doc}"); } self.print_attributes(&stmt.attributes); self.write_indent(); - write!(&mut self.output, "{} = ", stmt.name).unwrap(); + infallible_write!(&mut self.output, "{} = ", stmt.name); self.print_expr(&stmt.expr); - write!(&mut self.output, ",").unwrap(); + infallible_write!(&mut self.output, ","); // Print inline trailing comments for comment in &stmt.inline_trailing_comments { - write!(&mut self.output, " ").unwrap(); + infallible_write!(&mut self.output, " "); self.print_comment_inline(comment); } - writeln!(&mut self.output).unwrap(); + infallible_writeln!(&mut self.output); // Print following comments (comments on lines after the bitflag) for comment in &stmt.following_comments { diff --git a/src/pretty_print/expr_and_format.rs b/src/pretty_print/expr_and_format.rs index 956eb7c6..479aa72f 100644 --- a/src/pretty_print/expr_and_format.rs +++ b/src/pretty_print/expr_and_format.rs @@ -1,6 +1,5 @@ use super::PrettyPrinter; -use crate::grammar::*; -use std::fmt::Write; +use crate::{grammar::*, infallible_write, infallible_writeln}; impl PrettyPrinter { /// Format a hex number with underscores every 3 digits from the right @@ -75,13 +74,13 @@ impl PrettyPrinter { pub(super) fn print_expr(&mut self, expr: &Expr) { match expr { Expr::IntLiteral { value, format, .. } => match format { - IntFormat::Hex => write!(&mut self.output, "0x{value:X}").unwrap(), + IntFormat::Hex => infallible_write!(&mut self.output, "0x{value:X}"), IntFormat::Binary => { let formatted = self.format_binary_with_padding(*value); - write!(&mut self.output, "{formatted}").unwrap(); + infallible_write!(&mut self.output, "{formatted}"); } - IntFormat::Octal => write!(&mut self.output, "0o{value:o}").unwrap(), - IntFormat::Decimal => write!(&mut self.output, "{value}").unwrap(), + IntFormat::Octal => infallible_write!(&mut self.output, "0o{value:o}"), + IntFormat::Decimal => infallible_write!(&mut self.output, "{value}"), }, Expr::StringLiteral { value, format, .. } => { match format { @@ -89,22 +88,22 @@ impl PrettyPrinter { // Determine the number of # needed let hash_count = self.count_hashes_needed(value); let hashes = "#".repeat(hash_count); - write!(&mut self.output, "r{hashes}\"{value}\"{hashes}").unwrap(); + infallible_write!(&mut self.output, "r{hashes}\"{value}\"{hashes}"); } StringFormat::Regular => { // Escape special characters for regular strings - write!(&mut self.output, "\"").unwrap(); + infallible_write!(&mut self.output, "\""); for ch in value.chars() { match ch { - '"' => write!(&mut self.output, "\\\"").unwrap(), - '\\' => write!(&mut self.output, "\\\\").unwrap(), - '\n' => write!(&mut self.output, "\\n").unwrap(), - '\r' => write!(&mut self.output, "\\r").unwrap(), - '\t' => write!(&mut self.output, "\\t").unwrap(), - _ => write!(&mut self.output, "{ch}").unwrap(), + '"' => infallible_write!(&mut self.output, "\\\""), + '\\' => infallible_write!(&mut self.output, "\\\\"), + '\n' => infallible_write!(&mut self.output, "\\n"), + '\r' => infallible_write!(&mut self.output, "\\r"), + '\t' => infallible_write!(&mut self.output, "\\t"), + _ => infallible_write!(&mut self.output, "{ch}"), } } - write!(&mut self.output, "\"").unwrap(); + infallible_write!(&mut self.output, "\""); } } } @@ -112,52 +111,52 @@ impl PrettyPrinter { StringFormat::Raw => { let hash_count = self.count_hashes_needed(value); let hashes = "#".repeat(hash_count); - write!(&mut self.output, "cr{hashes}\"{value}\"{hashes}").unwrap(); + infallible_write!(&mut self.output, "cr{hashes}\"{value}\"{hashes}"); } StringFormat::Regular => { - write!(&mut self.output, "c\"").unwrap(); + infallible_write!(&mut self.output, "c\""); for ch in value.chars() { match ch { - '"' => write!(&mut self.output, "\\\"").unwrap(), - '\\' => write!(&mut self.output, "\\\\").unwrap(), - '\n' => write!(&mut self.output, "\\n").unwrap(), - '\r' => write!(&mut self.output, "\\r").unwrap(), - '\t' => write!(&mut self.output, "\\t").unwrap(), - _ => write!(&mut self.output, "{ch}").unwrap(), + '"' => infallible_write!(&mut self.output, "\\\""), + '\\' => infallible_write!(&mut self.output, "\\\\"), + '\n' => infallible_write!(&mut self.output, "\\n"), + '\r' => infallible_write!(&mut self.output, "\\r"), + '\t' => infallible_write!(&mut self.output, "\\t"), + _ => infallible_write!(&mut self.output, "{ch}"), } } - write!(&mut self.output, "\"").unwrap(); + infallible_write!(&mut self.output, "\""); } }, - Expr::Ident { ident, .. } => write!(&mut self.output, "{ident}").unwrap(), + Expr::Ident { ident, .. } => infallible_write!(&mut self.output, "{ident}"), Expr::FloatLiteral { raw_text, .. } => { - write!(&mut self.output, "{raw_text}").unwrap(); + infallible_write!(&mut self.output, "{raw_text}"); } Expr::Path { path, .. } => { - write!(&mut self.output, "{path}").unwrap(); + infallible_write!(&mut self.output, "{path}"); } Expr::StructLiteral { type_name, fields, .. } => { - write!(&mut self.output, "{type_name} {{ ").unwrap(); + infallible_write!(&mut self.output, "{type_name} {{ "); for (i, field) in fields.iter().enumerate() { if i > 0 { - write!(&mut self.output, ", ").unwrap(); + infallible_write!(&mut self.output, ", "); } - write!(&mut self.output, "{}: ", field.ident()).unwrap(); + infallible_write!(&mut self.output, "{}: ", field.ident()); self.print_expr(&field.1); } - write!(&mut self.output, " }}").unwrap(); + infallible_write!(&mut self.output, " }}"); } Expr::ArrayLiteral { elements, .. } => { - write!(&mut self.output, "[").unwrap(); + infallible_write!(&mut self.output, "["); for (i, elem) in elements.iter().enumerate() { if i > 0 { - write!(&mut self.output, ", ").unwrap(); + infallible_write!(&mut self.output, ", "); } self.print_expr(elem); } - write!(&mut self.output, "]").unwrap(); + infallible_write!(&mut self.output, "]"); } } } @@ -263,7 +262,7 @@ impl PrettyPrinter { let s = self.format_string_with_format(&splice.text, format); let m = self.splice_modifiers(splice.definition, splice.for_type.as_ref()); let kw = splice.kind.keyword(); - writeln!(&mut self.output, "{kw}{m} {s};").unwrap(); + infallible_writeln!(&mut self.output, "{kw}{m} {s};"); } /// Format the modifier suffix for a splice slot: an optional `definition` @@ -286,66 +285,66 @@ impl PrettyPrinter { // Type-position attributes print inline, ahead of the type they // annotate: `#[calling_convention(cdecl)] fn()`. if !type_.attributes.0.is_empty() { - write!(&mut self.output, "#[").unwrap(); + infallible_write!(&mut self.output, "#["); for (i, attr) in type_.attributes.0.iter().enumerate() { if i > 0 { - write!(&mut self.output, ", ").unwrap(); + infallible_write!(&mut self.output, ", "); } self.print_attribute(attr); } - write!(&mut self.output, "] ").unwrap(); + infallible_write!(&mut self.output, "] "); } match &type_.kind { TypeKind::Ident { path, generic_args, .. } => { - write!(&mut self.output, "{path}").unwrap(); + infallible_write!(&mut self.output, "{path}"); if !generic_args.is_empty() { - write!(&mut self.output, "<").unwrap(); + infallible_write!(&mut self.output, "<"); for (i, arg) in generic_args.iter().enumerate() { if i > 0 { - write!(&mut self.output, ", ").unwrap(); + infallible_write!(&mut self.output, ", "); } self.print_type(arg); } - write!(&mut self.output, ">").unwrap(); + infallible_write!(&mut self.output, ">"); } } TypeKind::ConstPointer { pointee, .. } => { - write!(&mut self.output, "*const ").unwrap(); + infallible_write!(&mut self.output, "*const "); self.print_type(pointee); } TypeKind::MutPointer { pointee, .. } => { - write!(&mut self.output, "*mut ").unwrap(); + infallible_write!(&mut self.output, "*mut "); self.print_type(pointee); } TypeKind::Array { element, size, .. } => { - write!(&mut self.output, "[").unwrap(); + infallible_write!(&mut self.output, "["); self.print_type(element); - write!(&mut self.output, "; {size}]").unwrap(); + infallible_write!(&mut self.output, "; {size}]"); } TypeKind::Unknown { size, .. } => { // Format unknown sizes as hex - write!(&mut self.output, "unknown<0x{size:X}>").unwrap(); + infallible_write!(&mut self.output, "unknown<0x{size:X}>"); } TypeKind::Function { arguments, return_type, } => { - write!(&mut self.output, "fn(").unwrap(); + infallible_write!(&mut self.output, "fn("); for (i, arg) in arguments.iter().enumerate() { if i > 0 { - write!(&mut self.output, ", ").unwrap(); + infallible_write!(&mut self.output, ", "); } if let Some(name) = &arg.name { - write!(&mut self.output, "{name}: ").unwrap(); + infallible_write!(&mut self.output, "{name}: "); } self.print_type(&arg.type_); } - write!(&mut self.output, ")").unwrap(); + infallible_write!(&mut self.output, ")"); if let Some(return_type) = return_type { - write!(&mut self.output, " -> ").unwrap(); + infallible_write!(&mut self.output, " -> "); self.print_type(return_type); } } @@ -368,7 +367,7 @@ impl PrettyPrinter { impl_block.name.as_str().to_string() }; if impl_block.type_parameters.is_empty() { - writeln!(&mut self.output, "impl {name_str} {{").unwrap(); + infallible_writeln!(&mut self.output, "impl {name_str} {{"); } else { let params = impl_block .type_parameters @@ -383,9 +382,9 @@ impl PrettyPrinter { .collect::>() .join(", "); if args.is_empty() { - writeln!(&mut self.output, "impl<{params}> {name_str} {{").unwrap(); + infallible_writeln!(&mut self.output, "impl<{params}> {name_str} {{"); } else { - writeln!(&mut self.output, "impl<{params}> {name_str}<{args}> {{",).unwrap(); + infallible_writeln!(&mut self.output, "impl<{params}> {name_str}<{args}> {{",); } } self.indent(); @@ -410,48 +409,48 @@ impl PrettyPrinter { self.dedent(); self.write_indent(); - writeln!(&mut self.output, "}}").unwrap(); + infallible_writeln!(&mut self.output, "}}"); } pub(super) fn print_function(&mut self, func: &Function) { // Print doc comments (they already include the space after ///) for doc in &func.doc_comments { self.write_indent(); - writeln!(&mut self.output, "///{doc}").unwrap(); + infallible_writeln!(&mut self.output, "///{doc}"); } self.print_attributes(&func.attributes); self.write_indent(); if func.visibility == Visibility::Public { - write!(&mut self.output, "pub ").unwrap(); + infallible_write!(&mut self.output, "pub "); } - write!(&mut self.output, "fn {}(", func.name).unwrap(); + infallible_write!(&mut self.output, "fn {}(", func.name); for (i, arg) in func.arguments.iter().enumerate() { if i > 0 { - write!(&mut self.output, ", ").unwrap(); + infallible_write!(&mut self.output, ", "); } self.print_argument(arg); } - write!(&mut self.output, ")").unwrap(); + infallible_write!(&mut self.output, ")"); if let Some(ret_type) = &func.return_type { - write!(&mut self.output, " -> ").unwrap(); + infallible_write!(&mut self.output, " -> "); self.print_type(ret_type); } - writeln!(&mut self.output, ";").unwrap(); + infallible_writeln!(&mut self.output, ";"); } fn print_argument(&mut self, arg: &Argument) { match arg { Argument::Named { ident, type_, .. } => { - write!(&mut self.output, "{ident}: ").unwrap(); + infallible_write!(&mut self.output, "{ident}: "); self.print_type(type_); } - Argument::ConstSelf { .. } => write!(&mut self.output, "&self").unwrap(), - Argument::MutSelf { .. } => write!(&mut self.output, "&mut self").unwrap(), + Argument::ConstSelf { .. } => infallible_write!(&mut self.output, "&self"), + Argument::MutSelf { .. } => infallible_write!(&mut self.output, "&mut self"), } } } diff --git a/src/pretty_print/mod.rs b/src/pretty_print/mod.rs index 96a00925..d7f97ded 100644 --- a/src/pretty_print/mod.rs +++ b/src/pretty_print/mod.rs @@ -6,7 +6,7 @@ /// - Formatting/normalizing code /// - Testing round-trip parsing use crate::grammar::{ItemDefinitionInner, *}; -use std::fmt::Write; +use crate::{infallible_write, infallible_writeln}; mod definitions; mod expr_and_format; @@ -66,7 +66,7 @@ impl PrettyPrinter { pub fn print_module(&mut self, module: &Module) -> String { // Print module-level doc comments for doc in &module.doc_comments { - writeln!(&mut self.output, "//!{doc}").unwrap(); + infallible_writeln!(&mut self.output, "//!{doc}"); } // Add blank line after module doc comments if there are any @@ -122,7 +122,7 @@ impl PrettyPrinter { Visibility::Public => "pub ", Visibility::Private => "", }; - writeln!(&mut self.output, "{vis}use {tree_str};").unwrap(); + infallible_writeln!(&mut self.output, "{vis}use {tree_str};"); // Only add blank line if next item is not a use statement if !matches!(next_item, Some(ModuleItem::Use { .. })) { self.writeln(""); @@ -137,11 +137,11 @@ impl PrettyPrinter { // Print doc comments for doc in doc_comments { self.write_indent(); - writeln!(&mut self.output, "///{doc}").unwrap(); + infallible_writeln!(&mut self.output, "///{doc}"); } self.print_attributes(attributes); self.write_indent(); - writeln!(&mut self.output, "extern type {name};").unwrap(); + infallible_writeln!(&mut self.output, "extern type {name};"); self.writeln(""); } ModuleItem::Splice { splice } => { @@ -180,25 +180,25 @@ impl PrettyPrinter { Comment::DocOuter { lines, .. } => { for line in lines { self.write_indent(); - writeln!(&mut self.output, "/// {line}").unwrap(); + infallible_writeln!(&mut self.output, "/// {line}"); } } Comment::DocInner { lines, .. } => { for line in lines { self.write_indent(); - writeln!(&mut self.output, "//! {line}").unwrap(); + infallible_writeln!(&mut self.output, "//! {line}"); } } Comment::Regular { text, .. } => { // Regular comments include the // prefix self.write_indent(); - writeln!(&mut self.output, "{text}").unwrap(); + infallible_writeln!(&mut self.output, "{text}"); } Comment::MultiLine { lines, .. } => { // Multiline comments include /* and */ in the text for line in lines { self.write_indent(); - writeln!(&mut self.output, "{line}").unwrap(); + infallible_writeln!(&mut self.output, "{line}"); } } } @@ -208,18 +208,18 @@ impl PrettyPrinter { match comment { Comment::Regular { text, .. } => { // Regular comments include the // prefix - write!(&mut self.output, "{text}").unwrap(); + infallible_write!(&mut self.output, "{text}"); } Comment::MultiLine { lines, .. } => { // Multiline comments - just print first line inline for now if let Some(first) = lines.first() { - write!(&mut self.output, "{first}").unwrap(); + infallible_write!(&mut self.output, "{first}"); } // If there are more lines, print them on separate lines for line in lines.iter().skip(1) { - writeln!(&mut self.output).unwrap(); + infallible_writeln!(&mut self.output); self.write_indent(); - write!(&mut self.output, "{line}").unwrap(); + infallible_write!(&mut self.output, "{line}"); } } _ => { @@ -245,20 +245,20 @@ impl PrettyPrinter { } self.write_indent(); - write!(&mut self.output, "{}[", if inner { "#!" } else { "#" }).unwrap(); + infallible_write!(&mut self.output, "{}[", if inner { "#!" } else { "#" }); for (i, attr) in attrs.0.iter().enumerate() { if i > 0 { - write!(&mut self.output, ", ").unwrap(); + infallible_write!(&mut self.output, ", "); } self.print_attribute(attr); } - writeln!(&mut self.output, "]").unwrap(); + infallible_writeln!(&mut self.output, "]"); } pub(super) fn print_attribute(&mut self, attr: &Attribute) { match attr { Attribute::Ident { ident, .. } => { - write!(&mut self.output, "{ident}").unwrap(); + infallible_write!(&mut self.output, "{ident}"); } Attribute::Function { name, items, .. } => { // Check special formatting requirements @@ -269,13 +269,13 @@ impl PrettyPrinter { self.in_vftable_index = true; } - write!(&mut self.output, "{name}(").unwrap(); + infallible_write!(&mut self.output, "{name}("); let mut first_expr = true; for item in items { match item { AttributeItem::Expr { expr, .. } => { if !first_expr { - write!(&mut self.output, ", ").unwrap(); + infallible_write!(&mut self.output, ", "); } first_expr = false; @@ -283,7 +283,7 @@ impl PrettyPrinter { if needs_underscore { if let Expr::IntLiteral { value, .. } = expr { let formatted = self.format_hex_with_underscores(*value); - write!(&mut self.output, "{formatted}").unwrap(); + infallible_write!(&mut self.output, "{formatted}"); } else { self.print_expr(expr); } @@ -292,33 +292,33 @@ impl PrettyPrinter { } } AttributeItem::Comment { text, .. } => { - write!(&mut self.output, " {text}").unwrap(); + infallible_write!(&mut self.output, " {text}"); } } } - write!(&mut self.output, ")").unwrap(); + infallible_write!(&mut self.output, ")"); if is_index { self.in_vftable_index = false; } } Attribute::Assign { name, items, .. } => { - write!(&mut self.output, "{name} = ").unwrap(); + infallible_write!(&mut self.output, "{name} = "); for item in items { match item { AttributeItem::Expr { expr, .. } => { self.print_expr(expr); } AttributeItem::Comment { text, .. } => { - write!(&mut self.output, " {text}").unwrap(); + infallible_write!(&mut self.output, " {text}"); } } } } Attribute::Cfg { predicate, .. } => { - write!(&mut self.output, "cfg(").unwrap(); + infallible_write!(&mut self.output, "cfg("); self.print_cfg_predicate(predicate); - write!(&mut self.output, ")").unwrap(); + infallible_write!(&mut self.output, ")"); } } } @@ -328,36 +328,36 @@ impl PrettyPrinter { match p { CfgPredicate::Atom { atom, .. } => match atom { CfgAtom::Ident { name, .. } => { - write!(&mut self.output, "{name}").unwrap(); + infallible_write!(&mut self.output, "{name}"); } CfgAtom::KeyValue { key, value, .. } => { - write!(&mut self.output, "{key} = \"{value}\"").unwrap(); + infallible_write!(&mut self.output, "{key} = \"{value}\""); } }, CfgPredicate::Any { predicates, .. } => { - write!(&mut self.output, "any(").unwrap(); + infallible_write!(&mut self.output, "any("); for (i, child) in predicates.iter().enumerate() { if i > 0 { - write!(&mut self.output, ", ").unwrap(); + infallible_write!(&mut self.output, ", "); } self.print_cfg_predicate(child); } - write!(&mut self.output, ")").unwrap(); + infallible_write!(&mut self.output, ")"); } CfgPredicate::All { predicates, .. } => { - write!(&mut self.output, "all(").unwrap(); + infallible_write!(&mut self.output, "all("); for (i, child) in predicates.iter().enumerate() { if i > 0 { - write!(&mut self.output, ", ").unwrap(); + infallible_write!(&mut self.output, ", "); } self.print_cfg_predicate(child); } - write!(&mut self.output, ")").unwrap(); + infallible_write!(&mut self.output, ")"); } CfgPredicate::Not { predicate, .. } => { - write!(&mut self.output, "not(").unwrap(); + infallible_write!(&mut self.output, "not("); self.print_cfg_predicate(predicate); - write!(&mut self.output, ")").unwrap(); + infallible_write!(&mut self.output, ")"); } } } diff --git a/src/semantic/builder.rs b/src/semantic/builder.rs index 88b8cd94..47462842 100644 --- a/src/semantic/builder.rs +++ b/src/semantic/builder.rs @@ -154,7 +154,13 @@ impl SemanticBuilder { // Parse errors should never occur here — we pretty-print valid // grammar::Module ASTs, which always parse back. If this fires, - // there's a bug in pretty_print. + // there's a bug in pretty_print. This is an internal compiler error + // (the two-tier error model's "ICE" tier), not a user-facing failure, + // so panicking with the parse error is the honest behavior. + #[expect( + clippy::panic, + reason = "pretty-printed source failing to parse is an internal compiler bug" + )] if let Some(first_parse_err) = analysis.parse_errors(&db).first() { panic!("SemanticBuilder: pretty-printed source failed to parse: {first_parse_err}"); } diff --git a/src/semantic/const_definition.rs b/src/semantic/const_definition.rs index 3f1b05ab..916286bf 100644 --- a/src/semantic/const_definition.rs +++ b/src/semantic/const_definition.rs @@ -133,14 +133,11 @@ fn validate_const_expr( return Err(mismatch("`cstr`".to_string(), format!("{expected_type}"))); } // CStr is NUL-terminated; interior NUL bytes are invalid. - if value.contains('\0') { + if let Some(nul_pos) = value.find('\0') { return Err(SemanticError::ConstValueTypeMismatch { item_path: resolvee_path.clone(), expected: "a cstr without interior NUL bytes".to_string(), - found: format!( - "a string with a NUL byte at position {}", - value.find('\0').unwrap() - ), + found: format!("a string with a NUL byte at position {nul_pos}"), location: *expr.location(), }); } @@ -309,10 +306,16 @@ fn validate_const_expr( // Build the ordered field values (in declaration order) by recursing. let mut ordered_fields = Vec::with_capacity(named_fields.len()); for (name, field_type) in &named_fields { - let field_expr = fields - .iter() - .find(|f| f.ident_as_str() == *name) - .expect("checked above"); + // The coverage check above verified every named field is + // initialized, so `find` cannot miss here. + let Some(field_expr) = fields.iter().find(|f| f.ident_as_str() == *name) else { + return Err(SemanticError::ConstValueTypeMismatch { + item_path: resolvee_path.clone(), + expected: format!("all fields of `{struct_type_path}` to be initialized"), + found: format!("missing field `{name}`"), + location: *expr.location(), + }); + }; let field_value = match validate_const_expr( semantic, scope, diff --git a/src/semantic/error/messages.rs b/src/semantic/error/messages.rs index 7a0a4b38..be589e0c 100644 --- a/src/semantic/error/messages.rs +++ b/src/semantic/error/messages.rs @@ -98,7 +98,15 @@ impl SemanticError { SemanticError::UseItemNotFound { path, .. } => { format!("Item in use statement not found: `{path}`") } - _ => unreachable!(), + // The outer `error_message` match routes each variant to its + // category sub-function exhaustively, so this catch-all is + // unreachable. Kept so a future variant addition fails at the + // routing match rather than silently falling through here. + #[expect( + clippy::unreachable, + reason = "outer error_message routing is exhaustive" + )] + _ => unreachable!("routing in error_message is exhaustive; this arm is unreachable"), } } @@ -125,7 +133,15 @@ impl SemanticError { "`for {target}` on a `backend` block in module `{module}` resolves to a type defined in module `{defined_in}`; attribution must target a type defined in the same module" ) } - _ => unreachable!(), + // The outer `error_message` match routes each variant to its + // category sub-function exhaustively, so this catch-all is + // unreachable. Kept so a future variant addition fails at the + // routing match rather than silently falling through here. + #[expect( + clippy::unreachable, + reason = "outer error_message routing is exhaustive" + )] + _ => unreachable!("routing in error_message is exhaustive; this arm is unreachable"), } } @@ -214,7 +230,15 @@ impl SemanticError { } => { format!("Attribute `{attribute_name}` must be written as {expected}") } - _ => unreachable!(), + // The outer `error_message` match routes each variant to its + // category sub-function exhaustively, so this catch-all is + // unreachable. Kept so a future variant addition fails at the + // routing match rather than silently falling through here. + #[expect( + clippy::unreachable, + reason = "outer error_message routing is exhaustive" + )] + _ => unreachable!("routing in error_message is exhaustive; this arm is unreachable"), } } @@ -258,7 +282,15 @@ impl SemanticError { ) } } - _ => unreachable!(), + // The outer `error_message` match routes each variant to its + // category sub-function exhaustively, so this catch-all is + // unreachable. Kept so a future variant addition fails at the + // routing match rather than silently falling through here. + #[expect( + clippy::unreachable, + reason = "outer error_message routing is exhaustive" + )] + _ => unreachable!("routing in error_message is exhaustive; this arm is unreachable"), } } @@ -338,7 +370,15 @@ impl SemanticError { which has no representable layout in backends that lack zero-size objects" ) } - _ => unreachable!(), + // The outer `error_message` match routes each variant to its + // category sub-function exhaustively, so this catch-all is + // unreachable. Kept so a future variant addition fails at the + // routing match rather than silently falling through here. + #[expect( + clippy::unreachable, + reason = "outer error_message routing is exhaustive" + )] + _ => unreachable!("routing in error_message is exhaustive; this arm is unreachable"), } } @@ -399,7 +439,15 @@ impl SemanticError { be reachable. Declare it in the enclosing type, or give the union a name." ) } - _ => unreachable!(), + // The outer `error_message` match routes each variant to its + // category sub-function exhaustively, so this catch-all is + // unreachable. Kept so a future variant addition fails at the + // routing match rather than silently falling through here. + #[expect( + clippy::unreachable, + reason = "outer error_message routing is exhaustive" + )] + _ => unreachable!("routing in error_message is exhaustive; this arm is unreachable"), } } @@ -443,7 +491,15 @@ impl SemanticError { SemanticError::VftableMustBeFirst { item_path, .. } => { format!("Vftable field must precede all fields in type `{item_path}`") } - _ => unreachable!(), + // The outer `error_message` match routes each variant to its + // category sub-function exhaustively, so this catch-all is + // unreachable. Kept so a future variant addition fails at the + // routing match rather than silently falling through here. + #[expect( + clippy::unreachable, + reason = "outer error_message routing is exhaustive" + )] + _ => unreachable!("routing in error_message is exhaustive; this arm is unreachable"), } } @@ -500,7 +556,15 @@ impl SemanticError { "bitflags `{item_path}` is marked as defaultable but has no default value set" ) } - _ => unreachable!(), + // The outer `error_message` match routes each variant to its + // category sub-function exhaustively, so this catch-all is + // unreachable. Kept so a future variant addition fails at the + // routing match rather than silently falling through here. + #[expect( + clippy::unreachable, + reason = "outer error_message routing is exhaustive" + )] + _ => unreachable!("routing in error_message is exhaustive; this arm is unreachable"), } } @@ -549,7 +613,15 @@ impl SemanticError { } => { format!("field `{field_name}` of type `{item_path}` is not a cloneable type") } - _ => unreachable!(), + // The outer `error_message` match routes each variant to its + // category sub-function exhaustively, so this catch-all is + // unreachable. Kept so a future variant addition fails at the + // routing match rather than silently falling through here. + #[expect( + clippy::unreachable, + reason = "outer error_message routing is exhaustive" + )] + _ => unreachable!("routing in error_message is exhaustive; this arm is unreachable"), } } @@ -610,7 +682,15 @@ impl SemanticError { SemanticError::StrTypeNotConst { .. } => { "`str` type is only allowed on `const` declarations".to_string() } - _ => unreachable!(), + // The outer `error_message` match routes each variant to its + // category sub-function exhaustively, so this catch-all is + // unreachable. Kept so a future variant addition fails at the + // routing match rather than silently falling through here. + #[expect( + clippy::unreachable, + reason = "outer error_message routing is exhaustive" + )] + _ => unreachable!("routing in error_message is exhaustive; this arm is unreachable"), } } } diff --git a/src/semantic/error/mod.rs b/src/semantic/error/mod.rs index 912afb39..ee1fcd77 100644 --- a/src/semantic/error/mod.rs +++ b/src/semantic/error/mod.rs @@ -599,9 +599,9 @@ impl SemanticError { let report = report_builder.finish(); let mut buffer = Vec::new(); - report - .write((filename, Source::from(source)), &mut buffer) - .expect("writing to Vec should not fail"); + // Writing an ariadne report into a `Vec` cannot fail — the `Write` + // impl on `Vec` is infallible — so the result is deliberately dropped. + let _ = report.write((filename, Source::from(source)), &mut buffer); String::from_utf8_lossy(&buffer).to_string() } @@ -638,9 +638,10 @@ impl SemanticError { .finish(); let mut buffer = Vec::new(); - report - .write((filename, Source::from(source)), &mut buffer) - .expect("writing to Vec should not fail"); + // Writing an ariadne report into a `Vec` cannot fail — the + // `Write` impl on `Vec` is infallible — so the result is + // deliberately dropped. + let _ = report.write((filename, Source::from(source)), &mut buffer); if i > 0 { output.push('\n'); diff --git a/src/semantic/function.rs b/src/semantic/function.rs index 78bc84aa..56d2ba62 100644 --- a/src/semantic/function.rs +++ b/src/semantic/function.rs @@ -476,18 +476,17 @@ pub fn build( } } - if !is_vfunc && body.is_none() { + let Some(body) = body else { + // A function always needs a body: a non-vfunc must carry an explicit + // body or `#[address]`, and a vfunc synthesises a vftable body. Reaching + // this with neither is a compiler bug path, but it is reported as a + // structured error (and the message mirrors `#[address]` guidance) + // rather than panicking, so a future change that widens the reachable + // state surfaces as a compile error instead of an ICE. return Err(SemanticError::FunctionMissingImplementation { function_name: function.name.0.clone(), location: function.location, }); - } - - let Some(body) = body else { - panic!( - "function `{}` had no body assigned: {:?}", - function.name, function - ); }; let mut arguments = Vec::new(); diff --git a/src/semantic/queries/helpers.rs b/src/semantic/queries/helpers.rs index 7622f613..2a680d79 100644 --- a/src/semantic/queries/helpers.rs +++ b/src/semantic/queries/helpers.rs @@ -401,9 +401,12 @@ fn collect_expr_value_refs( refs.push(p); } // Also resolve the first segment (for enum types like `Color::Red`) - let first = path.iter().next().unwrap().as_str(); - if let NameResolution::Found(p) = index.resolve_name(scope, first) { - refs.push(p); + // `path.len() > 1` guarantees a first segment exists. + if let Some(first) = path.iter().next() { + let first = first.as_str(); + if let NameResolution::Found(p) = index.resolve_name(scope, first) { + refs.push(p); + } } } else if let Some(name) = path.last() { if let NameResolution::Found(p) = index.resolve_name(scope, name.as_str()) { diff --git a/src/semantic/type_definition/build.rs b/src/semantic/type_definition/build.rs index 0ca9bfba..d66690ec 100644 --- a/src/semantic/type_definition/build.rs +++ b/src/semantic/type_definition/build.rs @@ -4,6 +4,7 @@ use super::{ }; use crate::{ grammar::{self, ItemPath}, + math, semantic::{ attribute, error::{ @@ -16,7 +17,6 @@ use crate::{ types::{Function, ItemDefinitionInner, ItemState, ItemStateResolved, Type, Visibility}, }, span::{HasLocation, ItemLocation}, - util, }; use super::{TypeDefinition, vftable}; @@ -503,7 +503,7 @@ fn round_up_min_size( .unwrap_or(semantic.type_registry.pointer_size()); // Calculate the minimum required alignment from field types - let required_alignment = util::lcm( + let required_alignment = math::lcm( pending_regions .iter() .flat_map(|(_, r)| r.type_ref.alignment(semantic.type_registry)), @@ -541,7 +541,7 @@ pub(in crate::semantic) fn check_trait_constraints( is_base: _, location: _, } = region; - let name = name.as_deref().unwrap_or("unnamed"); + let name = name.as_deref().unwrap_or(super::UNNAMED); fn get_defaultable_type_path(type_ref: &Type) -> Option<&ItemPath> { match type_ref { Type::Raw(tp) => Some(tp), @@ -594,7 +594,7 @@ pub(in crate::semantic) fn check_trait_constraints( is_base: _, location: _, } = region; - let name = name.as_deref().unwrap_or("unnamed"); + let name = name.as_deref().unwrap_or(super::UNNAMED); // Check if the type is copyable, recursively handling generics and arrays if !is_type_trait_satisfied( @@ -624,7 +624,7 @@ pub(in crate::semantic) fn check_trait_constraints( is_base: _, location: _, } = region; - let name = name.as_deref().unwrap_or("unnamed"); + let name = name.as_deref().unwrap_or(super::UNNAMED); // Check if the type is cloneable, recursively handling generics and arrays if !is_type_trait_satisfied( @@ -678,7 +678,7 @@ fn resolve_alignment( .unwrap_or(semantic.type_registry.pointer_size()); // Calculate the minimum required alignment. - let required_alignment = util::lcm( + let required_alignment = math::lcm( regions .iter() .flat_map(|r| r.type_ref.alignment(semantic.type_registry)), @@ -698,8 +698,18 @@ fn resolve_alignment( { let mut last_address = 0; for region in regions { - let name = region.name.as_deref().unwrap_or("unnamed"); - let field_alignment = region.type_ref.alignment(semantic.type_registry).unwrap(); + let name = region.name.as_deref().unwrap_or(super::UNNAMED); + // Region types are resolved before the field-alignment check runs + // (unresolved types stall the earlier resolution pass), so + // alignment is always known here. + let field_alignment = match region.type_ref.alignment(semantic.type_registry) { + Some(a) => a, + #[expect( + clippy::unreachable, + reason = "region types are resolved before this check" + )] + None => unreachable!("region type unresolved at field-alignment check"), + }; if last_address % field_alignment != 0 { return Err(SemanticError::FieldNotAligned { field_name: name.into(), @@ -709,7 +719,16 @@ fn resolve_alignment( location: *location, }); } - last_address += region.size(semantic.type_registry).unwrap(); + // Same invariant for size. + let region_size = match region.size(semantic.type_registry) { + Some(s) => s, + #[expect( + clippy::unreachable, + reason = "region types are resolved before this check" + )] + None => unreachable!("region type unresolved at field-alignment check"), + }; + last_address += region_size; } } diff --git a/src/semantic/type_definition/mod.rs b/src/semantic/type_definition/mod.rs index d32e54a4..9a1ecb23 100644 --- a/src/semantic/type_definition/mod.rs +++ b/src/semantic/type_definition/mod.rs @@ -7,6 +7,11 @@ use crate::{ }, }; +/// The display name used for a region or field with no declared name. +/// Shared across the type/union builders and the backends so the fallback +/// reads consistently everywhere. +pub(crate) const UNNAMED: &str = "unnamed"; + #[cfg(test)] use crate::span::StripLocations; diff --git a/src/semantic/type_definition/resolve.rs b/src/semantic/type_definition/resolve.rs index 64d83e59..20b59217 100644 --- a/src/semantic/type_definition/resolve.rs +++ b/src/semantic/type_definition/resolve.rs @@ -70,11 +70,8 @@ pub(super) fn resolve_regions( let existing_region = resolved .regions .last() - .unwrap() - .name - .as_deref() - .unwrap_or_default() - .to_string(); + .map(|r| r.name.as_deref().unwrap_or_default().to_string()) + .unwrap_or_else(|| super::UNNAMED.to_string()); return Err(SemanticError::OverlappingRegions { item_path: resolvee_path.clone(), region_name: existing_region, @@ -206,7 +203,10 @@ pub(in crate::semantic) fn get_region_name_and_type_definition<'a>( let region_name = region .name .clone() - .expect("region had no name, this shouldn't be possible"); + // A region with no name is rendered as "unnamed" elsewhere in the + // compiler (see the field-alignment loop in `build.rs`), so treat it + // the same way here rather than panicking. + .unwrap_or_else(|| super::UNNAMED.to_string()); let Type::Raw(path) = ®ion.type_ref else { return Err({ @@ -292,7 +292,7 @@ pub(super) fn is_type_trait_satisfied( /// Check if a resolved type is `str`. pub(super) fn is_str_type(type_: &Type) -> bool { match type_ { - Type::Raw(path) if path.len() == 1 => path.iter().next().unwrap().as_str() == "str", + Type::Raw(path) if path.len() == 1 => path.last().is_some_and(|s| s.as_str() == "str"), _ => false, } } diff --git a/src/semantic/type_definition/vftable.rs b/src/semantic/type_definition/vftable.rs index e7785475..33f1b54e 100644 --- a/src/semantic/type_definition/vftable.rs +++ b/src/semantic/type_definition/vftable.rs @@ -278,12 +278,31 @@ fn build_type( .map(|f| *f.location()) .unwrap_or_else(|| *location); + // Vftable regions come from resolved function types, so sizes are always + // known; a `None` here is an invariant violation surfaced as `None` (the + // function's own error channel) rather than a panic. + let size = { + let mut total = 0; + for r in ®ions { + let s = match r.size(type_registry) { + Some(s) => s, + #[expect( + clippy::unreachable, + reason = "vftable regions come from resolved function types" + )] + None => unreachable!("vftable region type unresolved"), + }; + total += s; + } + total + }; + Some(ItemDefinition { visibility, path: resolvee_vtable_path.clone(), type_parameters: vec![], // Generated vftable types are not generic state: ItemState::Resolved(ItemStateResolved { - size: regions.iter().map(|r| r.size(type_registry).unwrap()).sum(), + size, alignment: type_registry.pointer_size(), inner: TypeDefinition { regions, diff --git a/src/semantic/type_registry/aliases.rs b/src/semantic/type_registry/aliases.rs index edfe1895..cb6e6151 100644 --- a/src/semantic/type_registry/aliases.rs +++ b/src/semantic/type_registry/aliases.rs @@ -95,6 +95,13 @@ impl TypeRegistry { pub(crate) fn padding_type(&self, bytes: usize) -> Type { match self.resolve_string(&[], "u8") { TypeLookupResult::Found(t) => Type::Array(Box::new(t), bytes), + // `u8` is a predefined type inserted at registry construction, so + // lookup always succeeds. Resolving to anything else is an internal + // invariant violation, which is an ICE-tier bug. + #[expect( + clippy::panic, + reason = "u8 is a predefined type guaranteed at registry construction" + )] _ => panic!("u8 type not found in type registry"), } } diff --git a/src/semantic/type_registry/generics.rs b/src/semantic/type_registry/generics.rs index 17f7f386..c5150339 100644 --- a/src/semantic/type_registry/generics.rs +++ b/src/semantic/type_registry/generics.rs @@ -333,9 +333,14 @@ impl TypeRegistry { } => { // Check if this is a type parameter reference if path.len() == 1 && generic_args.is_empty() { - let name = path.iter().next().unwrap().as_str(); - if type_params.contains(&name.to_string()) { - return TypeLookupResult::Found(Type::TypeParameter(name.to_string())); + // `path.len() == 1` guarantees the single segment; treating + // it as the type-parameter name is safe. + if let Some(name) = path.last() + && type_params.contains(&name.as_str().to_string()) + { + return TypeLookupResult::Found(Type::TypeParameter( + name.as_str().to_string(), + )); } } diff --git a/src/semantic/union_definition/build.rs b/src/semantic/union_definition/build.rs index fccbbd3d..4161eef9 100644 --- a/src/semantic/union_definition/build.rs +++ b/src/semantic/union_definition/build.rs @@ -1,5 +1,6 @@ use crate::{ grammar::{self, ItemPath}, + math, semantic::{ attribute, error::{ @@ -12,7 +13,6 @@ use crate::{ types::{ItemCategory, ItemDefinition, ItemState, ItemStateResolved, Type, Visibility}, }, span::{HasLocation, ItemLocation}, - util, }; use super::{UnionDefinition, inline_union_name}; @@ -497,7 +497,7 @@ fn resolve_alignment( return Ok(1); } - let required_alignment = util::lcm( + let required_alignment = math::lcm( regions .iter() .flat_map(|r| r.type_ref.alignment(semantic.type_registry)), @@ -543,7 +543,10 @@ fn resolve_size( && size > declared { return Err(SemanticError::UnionMemberExceedsSize { - member_name: region.name.clone().unwrap_or_else(|| "unnamed".to_string()), + member_name: region + .name + .clone() + .unwrap_or_else(|| crate::semantic::type_definition::UNNAMED.to_string()), member_size: size, declared_size: declared, item_path: resolvee_path.clone(), diff --git a/src/span/mod.rs b/src/span/mod.rs index 9874161d..34aa1186 100644 --- a/src/span/mod.rs +++ b/src/span/mod.rs @@ -1,6 +1,9 @@ mod equals_ignoring_location; pub use equals_ignoring_location::*; +#[cfg(test)] +mod proptests; + #[cfg(test)] mod strip_locations; #[cfg(test)] diff --git a/src/span/proptests.rs b/src/span/proptests.rs new file mode 100644 index 00000000..6124fe54 --- /dev/null +++ b/src/span/proptests.rs @@ -0,0 +1,52 @@ +//! Property test: `StripLocations` is idempotent (`f(f(x)) == f(x)`). +//! +//! `StripLocations` and its `Module` impl are `#[cfg(test)]`-only (they are +//! test infrastructure, not public API), so this lives inside the crate. The +//! generator covers arbitrary parseable pyxis modules — including degenerate +//! cases: the empty document and single-item documents. + +use proptest::prelude::*; + +use crate::{ + parser::parse_str_with_file_id, + span::{FileId, StripLocations}, +}; + +/// Generate an arbitrary parseable pyxis module (degenerate inputs included). +fn arbitrary_module() -> impl Strategy { + prop_oneof![ + // Degenerate: empty and single-document inputs. + Just(String::new()), + Just("pub type A {\n pub x: u32,\n}\n".to_string()), + Just("pub const K: u32 = 1;\n".to_string()), + // A mixed module hitting several item kinds. + Just( + "/// docs\npub type B {\n pub y: *mut B,\n vftable {\n fn f();\n },\n}\n\ + pub enum E : u32 {\n One = 1,\n}\nuse a::B;\n" + .to_string() + ), + // Random concatenation of spelling-level fragments: filter to + // parseable inputs (the property is defined on the module AST, which + // only exists for parseable text). + proptest::collection::vec( + prop_oneof![Just("pub type Aa { x: u32 }"), Just("pub const K: u32 = 1;"), Just("anything")], + 0..8, + ) + .prop_map(|parts| parts.join("\n") + "\n"), + ] + .prop_filter_map("parseable module", |source| { + parse_str_with_file_id(&source, FileId::INTERNAL).ok() + }) +} + +proptest! { + /// Stripping locations is idempotent: applying it to an already-stripped + /// value is the identity. A stripped value has no locations left to + /// remove, so re-stripping must return an equal value. + #[test] + fn strip_locations_is_idempotent(module in arbitrary_module()) { + let once = module.strip_locations(); + let twice = once.strip_locations(); + prop_assert_eq!(once, twice); + } +} diff --git a/src/tokenizer/lexers.rs b/src/tokenizer/lexers.rs index efb93dff..a395df8f 100644 --- a/src/tokenizer/lexers.rs +++ b/src/tokenizer/lexers.rs @@ -323,7 +323,17 @@ impl Lexer { self.advance(); } } else { - value.push(self.peek().unwrap()); + // The loop-top `is_eof()` guard means a char is present here; + // `None` would be an internal lexer invariant violation. + let ch = match self.peek() { + Some(ch) => ch, + #[expect( + clippy::unreachable, + reason = "loop-top is_eof() guard ensures a char" + )] + None => unreachable!("string lexer consumed past EOF"), + }; + value.push(ch); self.advance(); } } @@ -449,7 +459,17 @@ impl Lexer { self.advance(); } } else { - value.push(self.peek().unwrap()); + // The loop-top `is_eof()` guard means a char is present here; + // `None` would be an internal lexer invariant violation. + let ch = match self.peek() { + Some(ch) => ch, + #[expect( + clippy::unreachable, + reason = "loop-top is_eof() guard ensures a char" + )] + None => unreachable!("c-string lexer consumed past EOF"), + }; + value.push(ch); self.advance(); } } diff --git a/src/tokenizer/mod.rs b/src/tokenizer/mod.rs index 7c7e07ed..1fa5a585 100644 --- a/src/tokenizer/mod.rs +++ b/src/tokenizer/mod.rs @@ -122,7 +122,18 @@ impl Lexer { let start = self.current_location(); let start_pos = self.pos; - let ch = self.peek().unwrap(); + // `tokenize` verifies `!is_eof()` before calling `next_token`, so the + // input has a character here; reaching this with `None` is an internal + // lexer invariant violation (ICE-tier), reported as such. + let ch = match self.peek() { + Some(ch) => ch, + // ICE-tier: tokenize guards `!is_eof()` before calling next_token. + #[expect( + clippy::unreachable, + reason = "tokenize guards !is_eof() before next_token" + )] + None => unreachable!("next_token called at EOF"), + }; // Handle comments if ch == '/' { diff --git a/test.py b/test.py index 9b085f91..33cbe67c 100644 --- a/test.py +++ b/test.py @@ -175,6 +175,15 @@ def main(): shell=sys.platform == "win32", ) + # Run the viewer's unit tests (vitest). These cover the JSON boundary + # validator, so a schema-vs-wire divergence fails CI rather than only a + # local run. + run_command( + ["npm", "test"], + cwd="viewer", + shell=sys.platform == "win32", + ) + print(f"\n{'=' * 60}") print("All checks passed!") print(f"{'=' * 60}\n") diff --git a/tests/check.rs b/tests/check.rs index da4e77ce..ce3a705c 100644 --- a/tests/check.rs +++ b/tests/check.rs @@ -1,107 +1,79 @@ //! Integration tests for the `check` function — validates that the //! semantic analysis pipeline reports errors correctly without //! generating any output. +//! +//! All tests drive the pipeline through the in-memory [`pyxis::check_sources`] +//! entry point (no real filesystem), matching the contributing guidelines' +//! "tests must be deterministic: no real filesystem" rule. -use std::path::{Path, PathBuf}; +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable +)] -use pyxis::source_store::FileStore; +use std::path::Path; -/// Create a fresh scratch directory for this test, removing any leftovers -/// from a previous run. -fn scratch_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("pyxis_test_check_{name}")); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir -} +use pyxis::source_store::FileStore; -fn write(path: &Path, contents: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(path, contents).unwrap(); +/// The project config for a valid test project (pointer size 8). +fn pointer_size() -> usize { + 8 } -fn write_valid_project(in_dir: &Path) { - write( - &in_dir.join("pyxis.toml"), - r#" +/// The `pyxis.toml` contents for a valid test project. +const VALID_PROJECT_TOML: &str = r#" [project] name = "test-project" pointer_size = 8 -"#, - ); - write( - &in_dir.join("foo.pyxis"), - r#" -pub type Foo { - pub value: u32, -} -"#, - ); -} - -/// Recursively collect all relative file paths in a directory. -fn snapshot_dir(dir: &Path) -> Vec { - let mut entries = Vec::new(); - collect_dir(dir, dir, &mut entries); - entries.sort(); - entries -} - -fn collect_dir(root: &Path, dir: &Path, entries: &mut Vec) { - if !dir.is_dir() { - return; - } - for entry in std::fs::read_dir(dir).unwrap() { - let entry = entry.unwrap(); - let path = entry.path(); - let relative = path.strip_prefix(root).unwrap(); - if path.is_dir() { - collect_dir(root, &path, entries); - } else { - entries.push(relative.display().to_string()); - } - } +"#; + +/// Build a `(filename, content)` source list for a project with the given +/// `.pyxis` files. +fn sources(files: &[(&str, &str)]) -> Vec<(String, String)> { + files + .iter() + .map(|(name, content)| (name.to_string(), content.to_string())) + .collect() } #[test] fn check_succeeds_on_valid_project() { - let root = scratch_dir("succeeds"); - let in_dir = root.join("in"); - write_valid_project(&in_dir); + let files = vec![ + ("pyxis.toml", VALID_PROJECT_TOML), + ( + "foo.pyxis", + r#" +pub type Foo { + pub value: u32, +} +"#, + ), + ]; let mut file_store = FileStore::new(); - let result = pyxis::check(&in_dir, &mut file_store); + let result = pyxis::check_sources(sources(&files), pointer_size(), &mut file_store); assert!(result.is_ok(), "expected Ok(()), got {result:?}"); - - let _ = std::fs::remove_dir_all(&root); } #[test] fn check_reports_semantic_error() { - let root = scratch_dir("semantic_error"); - let in_dir = root.join("in"); - write( - &in_dir.join("pyxis.toml"), - r#" -[project] -name = "test-project" -pointer_size = 8 -"#, - ); // `Undefined` is not a known type — this should produce a semantic error. - write( - &in_dir.join("bad.pyxis"), - r#" + let files = vec![ + ("pyxis.toml", VALID_PROJECT_TOML), + ( + "bad.pyxis", + r#" pub type Bad { pub field: Undefined, } "#, - ); + ), + ]; let mut file_store = FileStore::new(); - let result = pyxis::check(&in_dir, &mut file_store); + let result = pyxis::check_sources(sources(&files), pointer_size(), &mut file_store); let errors = result.expect_err("expected semantic errors"); assert!( errors @@ -109,32 +81,23 @@ pub type Bad { .any(|e| matches!(e, pyxis::BuildError::Semantic(_))), "expected at least one BuildError::Semantic, got {errors:?}" ); - - let _ = std::fs::remove_dir_all(&root); } #[test] fn check_reports_parse_error() { - let root = scratch_dir("parse_error"); - let in_dir = root.join("in"); - write( - &in_dir.join("pyxis.toml"), - r#" -[project] -name = "test-project" -pointer_size = 8 -"#, - ); // Unterminated type body — a syntax error. - write( - &in_dir.join("bad.pyxis"), - r#" + let files = vec![ + ("pyxis.toml", VALID_PROJECT_TOML), + ( + "bad.pyxis", + r#" pub type Foo { "#, - ); + ), + ]; let mut file_store = FileStore::new(); - let result = pyxis::check(&in_dir, &mut file_store); + let result = pyxis::check_sources(sources(&files), pointer_size(), &mut file_store); let errors = result.expect_err("expected parse errors"); assert!( errors @@ -142,58 +105,49 @@ pub type Foo { .any(|e| matches!(e, pyxis::BuildError::Parser(_))), "expected at least one BuildError::Parser, got {errors:?}" ); - - let _ = std::fs::remove_dir_all(&root); } #[test] fn check_reports_multiple_errors() { - let root = scratch_dir("multiple_errors"); - let in_dir = root.join("in"); - write( - &in_dir.join("pyxis.toml"), - r#" -[project] -name = "test-project" -pointer_size = 8 -"#, - ); // Two files with different semantic errors (undefined type references). - write( - &in_dir.join("a.pyxis"), - r#" + let files = vec![ + ("pyxis.toml", VALID_PROJECT_TOML), + ( + "a.pyxis", + r#" pub type A { pub field: NonexistentA, } "#, - ); - write( - &in_dir.join("b.pyxis"), - r#" + ), + ( + "b.pyxis", + r#" pub type B { pub field: NonexistentB, } "#, - ); + ), + ]; let mut file_store = FileStore::new(); - let result = pyxis::check(&in_dir, &mut file_store); + let result = pyxis::check_sources(sources(&files), pointer_size(), &mut file_store); let errors = result.expect_err("expected multiple errors"); assert!( errors.len() >= 2, "expected at least 2 errors, got {}: {errors:?}", errors.len() ); - - let _ = std::fs::remove_dir_all(&root); } #[test] fn check_validates_against_codegen_corpus() { - let in_dir = Path::new("codegen_tests/input"); + // Resolve the corpus path relative to the crate root at test time rather + // than the process CWD, so the test is hermetic. + let in_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("codegen_tests/input"); let mut file_store = FileStore::new(); - let result = pyxis::check(in_dir, &mut file_store); + let result = pyxis::check(&in_dir, &mut file_store); assert!( result.is_ok(), "expected check to succeed on codegen test corpus, got: {result:?}" @@ -201,22 +155,23 @@ fn check_validates_against_codegen_corpus() { } #[test] -fn check_does_not_create_output_files() { - let root = scratch_dir("no_output"); - let in_dir = root.join("in"); - write_valid_project(&in_dir); - - let before = snapshot_dir(&root); +fn check_sources_end_to_end_no_filesystem() { + // A smoke assertion that the in-memory path runs the full pipeline + // (config-less: sources carry their own content) without touching the + // filesystem and without producing output. + let files = vec![ + ("pyxis.toml", VALID_PROJECT_TOML), + ( + "foo.pyxis", + r#" +pub type Foo { + pub value: u32, +} +"#, + ), + ]; let mut file_store = FileStore::new(); - let result = pyxis::check(&in_dir, &mut file_store); + let result = pyxis::check_sources(sources(&files), pointer_size(), &mut file_store); assert!(result.is_ok(), "expected Ok(()), got {result:?}"); - - let after = snapshot_dir(&root); - assert_eq!( - before, after, - "directory contents changed after check (no output should be created)" - ); - - let _ = std::fs::remove_dir_all(&root); } diff --git a/tests/module_mounting.rs b/tests/module_mounting.rs index f4604e13..696c94a1 100644 --- a/tests/module_mounting.rs +++ b/tests/module_mounting.rs @@ -2,15 +2,30 @@ //! `rust_module_prefix` (so refs become `crate::::...`), a custom //! root file name (`mod.rs` instead of `lib.rs`), and explicit `pub use` //! re-exports rewritten through the prefix. +//! +//! This test exercises emitted output layout, which the compiler writes to a +//! real directory (there is no in-memory output seam), so it writes to a +//! per-run directory under `target/test-artifacts` — inside the crate's own +//! workspace, never `std::env::temp_dir()` — and cleans it up afterwards. +//! The input side is staged the same way; the sources themselves are simple +//! enough that the filesystem staging carries no behavioral coupling. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable +)] use std::path::{Path, PathBuf}; use pyxis::{Backend, BuildOptions, grammar::ItemPath, source_store::FileStore}; -/// Create a fresh scratch directory for this test, removing any leftovers -/// from a previous run. +/// Workspace-local scratch directory for emitted-output tests. Resolved from +/// `CARGO_MANIFEST_DIR` (never CWD) so the test is hermetic, and the path is +/// unique per invocation. fn scratch_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("pyxis_test_{name}")); + let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-artifacts"); + let dir = base.join(format!("{name}_{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir diff --git a/tests/properties.rs b/tests/properties.rs new file mode 100644 index 00000000..9ca690f2 --- /dev/null +++ b/tests/properties.rs @@ -0,0 +1,235 @@ +//! Property-based tests (proptest) for stated properties of core functions: +//! +//! - parser round-trip: `pretty_print` → reparse yields an equivalent AST +//! - cfg-predicate evaluation: total, and agrees with a direct reference +//! - span strip-locations: idempotent (`f(f(x)) == f(x)`) +//! +//! These are the first property tests in the workspace. Degenerate cases +//! (empty, single token) are exercised explicitly: proptest strategies below +//! include them, and fixed assertions cover the empty-input boundaries. + +// Test code may use unwrap/expect/panic freely (the restriction lints target +// production code); see the contributing guidelines. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable +)] + +use std::str::FromStr; + +use proptest::prelude::*; +use pyxis::{ + Backend, + parser::cfg::{CfgAtom, CfgContext, CfgPredicate}, + pretty_print::pretty_print, + span::FileId, +}; + +/// A generated pyxis module source string. Includes degenerate inputs: the +/// empty string and single-token fragments, so the round-trip property is +/// exercised at the boundaries the source language actually has. +fn arbitrary_module_source() -> impl Strategy { + prop_oneof![ + // Degenerate: empty and near-empty inputs. + Just(String::new()), + Just("pub".to_string()), + Just("#".to_string()), + // A realistic small module: a type with a field, doc comments, and a + // cfg-gated method. The grammar's surface here is rich enough that a + // malformed-but-parseable variant still exercises the pretty-printer + // on structurally varied input. + prop_oneof![ + Just("pub type A {\n pub x: u32,\n}\n".to_string()), + Just( + "/// docs\npub type B {\n pub y: *mut B,\n vftable {\n fn f();\n },\n}\n" + .to_string() + ), + Just("pub const C: u32 = 5;\n".to_string()), + Just("pub enum E : u32 {\n One = 1,\n}\n".to_string()), + Just("use a::B;\n\npub type D {\n pub z: a::B,\n}\n".to_string()), + ], + // Random concatenation of spelling-level fragments that may or may + // not parse: the property only holds for parseable inputs, so the + // test filters to `Ok`. + proptest::collection::vec( + prop_oneof![Just("pub type Aa { x: u32 }"), Just("pub const K: u32 = 1;"), Just("anything")], + 0..8, + ) + .prop_map(|parts| parts.join("\n") + "\n"), + ] +} + +proptest! { + /// Round-trip: for any source that parses, pretty-printing and re-parsing + /// yields printed output identical to printing the original parse. + /// + /// `pretty_print` is a fixpoint: `print(parse(print(m))) == print(m)` for + /// the AST `m` obtained from arbitrary parseable source. This is the + /// `decode(encode(x)) == x` round-trip property with `encode = print`, + /// `decode = parse`, compared on the printed form (the parser's full AST + /// equality is location-sensitive, and `StripLocations` is test-only). + #[test] + fn pretty_print_round_trip_is_stable(source in arbitrary_module_source()) { + let Ok(module) = pyxis::parser::parse_str_with_file_id(&source, FileId::INTERNAL) else { + // Not parseable — the round-trip property is only defined on + // parseable inputs. + return Ok(()); + }; + + let printed = pretty_print(&module); + let reparsed = + pyxis::parser::parse_str_with_file_id(&printed, FileId::INTERNAL).expect( + "pretty-printed output of a parseable module must itself parse", + ); + + // Re-printing the reparsed AST yields the same text: the formatter is + // a fixpoint, so the parse→print→parse→print chain is stable. + prop_assert_eq!(pretty_print(&reparsed), printed); + } +} + +/// Build a random well-formed `CfgPredicate` tree. Includes the degenerate +/// `any([])`/`all([])` forms (empty combinator lists) as leaf cases. +fn arbitrary_predicate(depth: u32) -> impl Strategy { + let leaf = prop_oneof![ + Just(CfgPredicate::Atom { + atom: CfgAtom::Ident { + name: "test".to_string(), + location: pyxis::span::ItemLocation::internal(), + }, + location: pyxis::span::ItemLocation::internal(), + }), + Just(CfgPredicate::Atom { + atom: CfgAtom::KeyValue { + key: "backend".to_string(), + value: "rust".to_string(), + location: pyxis::span::ItemLocation::internal(), + }, + location: pyxis::span::ItemLocation::internal(), + }), + Just(CfgPredicate::Atom { + atom: CfgAtom::KeyValue { + key: "backend".to_string(), + value: "cpp".to_string(), + location: pyxis::span::ItemLocation::internal(), + }, + location: pyxis::span::ItemLocation::internal(), + }), + ]; + + if depth == 0 { + leaf.boxed() + } else { + // Mix leaves and one level of combinators; the degenerate empty list + // appears via `prop::collection::vec(..., 0..5)`. + let combinators = prop_oneof![ + proptest::collection::vec(arbitrary_predicate(depth - 1), 0..5).prop_map( + |predicates| CfgPredicate::Any { + predicates, + location: pyxis::span::ItemLocation::internal(), + } + ), + proptest::collection::vec(arbitrary_predicate(depth - 1), 0..5).prop_map( + |predicates| CfgPredicate::All { + predicates, + location: pyxis::span::ItemLocation::internal(), + } + ), + arbitrary_predicate(depth - 1).prop_map(|predicate| CfgPredicate::Not { + predicate: Box::new(predicate), + location: pyxis::span::ItemLocation::internal(), + }), + ]; + prop_oneof![leaf, combinators].boxed() + } +} + +/// A direct reference evaluator for `CfgPredicate` — the same recursion the +/// production `evaluate` implements, written independently so a divergence +/// between the two is a property failure rather than a restated tautology. +fn reference_evaluate(predicate: &CfgPredicate, ctx: &CfgContext) -> bool { + match predicate { + CfgPredicate::Atom { atom, .. } => match atom { + CfgAtom::Ident { .. } => false, + CfgAtom::KeyValue { key, value, .. } => match key.as_str() { + "backend" => *value == ctx.backend.name(), + _ => false, + }, + }, + CfgPredicate::Any { predicates, .. } => { + predicates.iter().any(|p| reference_evaluate(p, ctx)) + } + CfgPredicate::All { predicates, .. } => { + predicates.iter().all(|p| reference_evaluate(p, ctx)) + } + CfgPredicate::Not { predicate, .. } => !reference_evaluate(predicate, ctx), + } +} + +proptest! { + /// cfg-predicate evaluation is total (never panics — trivially true for + /// well-typed trees) and agrees with the independent reference evaluator + /// for every backend. + #[test] + fn cfg_predicate_evaluation_matches_reference( + predicate in arbitrary_predicate(3), + backend_name in prop_oneof![Just("rust"), Just("cpp"), Just("json")], + ) { + let backend = Backend::from_str(backend_name).unwrap(); + let ctx = CfgContext { backend }; + prop_assert_eq!(predicate.evaluate(&ctx), reference_evaluate(&predicate, &ctx)); + } +} + +// `StripLocations` idempotence lives in `src/span/proptests.rs` (the trait +// and its impls are test-only), so the integration suite covers the parser +// round-trip and cfg-evaluation properties, and the crate's own `#[cfg(test)]` +// module covers strip-locations idempotence. + +/// Degenerate-case coverage that proptest's generator may not hit often: +/// explicitly assert the properties on empty and single-element inputs. +#[test] +fn degenerate_cases_round_trip_and_cfg() { + // Empty module source: parses to an empty module; printing and re-parsing + // keeps the printed form stable (fixpoint). + let empty = pyxis::parser::parse_str_with_file_id("", FileId::INTERNAL).unwrap(); + let printed = pretty_print(&empty); + let reparsed = pyxis::parser::parse_str_with_file_id(&printed, FileId::INTERNAL).unwrap(); + assert_eq!(pretty_print(&reparsed), printed); + + // Single-token source that parses: `pub` alone fails, but a lone comment + // file is not a valid module, so the one-token boundary is `pub const C: + // u32 = 5;` (a single-definition module, covered by the strategy corpus). + + // Degenerate cfg predicates: empty `any`/`all` and a single `not`. + let any_empty = CfgPredicate::Any { + predicates: vec![], + location: pyxis::span::ItemLocation::internal(), + }; + let all_empty = CfgPredicate::All { + predicates: vec![], + location: pyxis::span::ItemLocation::internal(), + }; + let single_not = CfgPredicate::Not { + predicate: Box::new(CfgPredicate::Atom { + atom: CfgAtom::Ident { + name: "test".to_string(), + location: pyxis::span::ItemLocation::internal(), + }, + location: pyxis::span::ItemLocation::internal(), + }), + location: pyxis::span::ItemLocation::internal(), + }; + for backend in Backend::ALL { + let ctx = CfgContext { backend: *backend }; + for predicate in [&any_empty, &all_empty, &single_not] { + assert_eq!( + predicate.evaluate(&ctx), + reference_evaluate(predicate, &ctx), + "reference mismatch for {backend:?} on {predicate:?}" + ); + } + } +} diff --git a/tooling/lsp/Cargo.toml b/tooling/lsp/Cargo.toml index 791a3b24..2334046f 100644 --- a/tooling/lsp/Cargo.toml +++ b/tooling/lsp/Cargo.toml @@ -3,6 +3,9 @@ name = "pyxis-lsp" version = "0.1.0" edition = "2024" +[lints] +workspace = true + [lib] name = "pyxis_lsp" path = "src/lib.rs" diff --git a/tooling/lsp/src/handlers/code_action.rs b/tooling/lsp/src/handlers/code_action.rs index 85205358..8bda8dd5 100644 --- a/tooling/lsp/src/handlers/code_action.rs +++ b/tooling/lsp/src/handlers/code_action.rs @@ -11,11 +11,7 @@ impl ServerState { let uri = ¶ms.text_document.uri; let actions = self.import_actions(uri, params.range); - Response { - id: req.id, - result: Some(serde_json::to_value(actions).unwrap()), - error: None, - } + response_with_json(req.id, &actions) } /// "Import `path`" quick-fixes for an unresolved type reference under the @@ -196,8 +192,8 @@ pub(crate) fn render_use_group(paths: &[&[String]]) -> String { } }) .collect(); - if entries.len() == 1 { - entries.into_iter().next().unwrap() + if let [entry] = entries.as_slice() { + entry.clone() } else { format!("{{{}}}", entries.join(", ")) } diff --git a/tooling/lsp/src/handlers/completion.rs b/tooling/lsp/src/handlers/completion.rs index 340dcca3..b7c18c29 100644 --- a/tooling/lsp/src/handlers/completion.rs +++ b/tooling/lsp/src/handlers/completion.rs @@ -14,7 +14,10 @@ impl ServerState { Bitflags, Const, Enum, Epilogue, Extern, Fn, Impl, Mut, Prologue, Pub, SelfType, SelfValue, Type, Union, Use, Vftable, }; - let kw = |k: TokenKind| k.keyword_str().expect("keyword token"); + let kw = |k: TokenKind| k.keyword_str().unwrap_or_default(); + // Keyword spellings come from the tokenizer's canonical table; every + // token below is a keyword, so the `unwrap_or_default` fallback is + // unreachable — it exists only to avoid panicking in the closure. let mut items: Vec = [ kw(Pub), kw(Type), @@ -46,11 +49,7 @@ impl ServerState { items.extend(self.type_completions(uri)); } - Response { - id: req.id, - result: Some(serde_json::to_value(items).unwrap()), - error: None, - } + response_with_json(req.id, &items) } /// Type-name completions for a document: builtins, in-scope user types diff --git a/tooling/lsp/src/handlers/doc_links.rs b/tooling/lsp/src/handlers/doc_links.rs index b384a26d..73d084cd 100644 --- a/tooling/lsp/src/handlers/doc_links.rs +++ b/tooling/lsp/src/handlers/doc_links.rs @@ -211,11 +211,7 @@ impl ServerState { .ok() .map(|p| self.doc_links(&p.text_document.uri)) .unwrap_or_default(); - Response { - id: req.id, - result: Some(serde_json::to_value(links).unwrap()), - error: None, - } + response_with_json(req.id, &links) } fn doc_links(&self, uri: &Uri) -> Vec { diff --git a/tooling/lsp/src/handlers/mod.rs b/tooling/lsp/src/handlers/mod.rs index 1b1fc1e9..36c7ef2c 100644 --- a/tooling/lsp/src/handlers/mod.rs +++ b/tooling/lsp/src/handlers/mod.rs @@ -280,3 +280,33 @@ fn error_response(id: lsp_server::RequestId, e: serde_json::Error) -> Response { }), } } + +/// Serialize `value` into a `serde_json::Value` for an LSP response body. +/// +/// LSP response payloads are JSON-RPC values, so each handler body must +/// serialize its typed result. The types involved (`lsp_types` models, our own +/// item lists) all impl `Serialize` infallibly in practice, but a serialization +/// failure must still surface as an LSP error response rather than panicking — +/// that is what `error_response` is for. +pub(crate) fn json_value( + value: &T, +) -> Result { + serde_json::to_value(value) +} + +/// Build a success response whose body is the JSON serialization of `value`, +/// or an LSP error response if serialization fails. Handlers use this so a +/// non-serializable result becomes an error response instead of an unwrap. +pub(crate) fn response_with_json( + id: lsp_server::RequestId, + value: &impl serde::Serialize, +) -> Response { + match json_value(value) { + Ok(result) => Response { + id, + result: Some(result), + error: None, + }, + Err(e) => error_response(id, e), + } +} diff --git a/tooling/lsp/src/handlers/navigation/definition.rs b/tooling/lsp/src/handlers/navigation/definition.rs index e327e6b2..5b068557 100644 --- a/tooling/lsp/src/handlers/navigation/definition.rs +++ b/tooling/lsp/src/handlers/navigation/definition.rs @@ -36,14 +36,10 @@ impl ServerState { // 0. A doc-comment cross-reference link → jump to the referenced member // (impl/vftable method, field) or type. if let Some((_span, location, _hover)) = self.doc_link_at(uri, &loc) { - return Response { - id: req.id, - result: Some( - serde_json::to_value(lsp_types::GotoDefinitionResponse::Scalar(location)) - .unwrap(), - ), - error: None, - }; + return response_with_json( + req.id, + &lsp_types::GotoDefinitionResponse::Scalar(location), + ); } // 1. Cursor on a type or import reference (e.g. `Camera` in @@ -72,14 +68,10 @@ impl ServerState { { let range = pyxis_span_to_lsp_range(target_content, &rd.name_span); let location = lsp_types::Location { uri: rd.uri, range }; - return Response { - id: req.id, - result: Some( - serde_json::to_value(lsp_types::GotoDefinitionResponse::Scalar(location)) - .unwrap(), - ), - error: None, - }; + return response_with_json( + req.id, + &lsp_types::GotoDefinitionResponse::Scalar(location), + ); } // b) Module segment → jump to the top of its file. if let Some(target_uri) = self.module_uri(&module_path, uri) { @@ -96,14 +88,10 @@ impl ServerState { }, }, }; - return Response { - id: req.id, - result: Some( - serde_json::to_value(lsp_types::GotoDefinitionResponse::Scalar(location)) - .unwrap(), - ), - error: None, - }; + return response_with_json( + req.id, + &lsp_types::GotoDefinitionResponse::Scalar(location), + ); } } @@ -121,14 +109,10 @@ impl ServerState { uri: uri.clone(), range, }; - return Response { - id: req.id, - result: Some( - serde_json::to_value(lsp_types::GotoDefinitionResponse::Scalar(location)) - .unwrap(), - ), - error: None, - }; + return response_with_json( + req.id, + &lsp_types::GotoDefinitionResponse::Scalar(location), + ); } } @@ -152,11 +136,7 @@ impl ServerState { Some(self.impl_locations(symbol.type_path()?, &uri)) }) .unwrap_or_default(); - Response { - id: req.id, - result: Some(serde_json::to_value(locations).unwrap()), - error: None, - } + response_with_json(req.id, &locations) } /// Locations of every `impl` block whose target resolves to `target`, diff --git a/tooling/lsp/src/handlers/navigation/hover.rs b/tooling/lsp/src/handlers/navigation/hover.rs index 649f9bf5..7b09b6fb 100644 --- a/tooling/lsp/src/handlers/navigation/hover.rs +++ b/tooling/lsp/src/handlers/navigation/hover.rs @@ -563,18 +563,14 @@ pub(crate) fn hover_response( content: &str, span: &Span, ) -> Response { - Response { + response_with_json( id, - result: Some( - serde_json::to_value(Hover { - contents: HoverContents::Markup(MarkupContent { - kind: MarkupKind::Markdown, - value, - }), - range: Some(pyxis_span_to_lsp_range(content, span)), - }) - .unwrap(), - ), - error: None, - } + &Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value, + }), + range: Some(pyxis_span_to_lsp_range(content, span)), + }, + ) } diff --git a/tooling/lsp/src/handlers/navigation/type_hierarchy.rs b/tooling/lsp/src/handlers/navigation/type_hierarchy.rs index 0ac35fa4..8dd01ee4 100644 --- a/tooling/lsp/src/handlers/navigation/type_hierarchy.rs +++ b/tooling/lsp/src/handlers/navigation/type_hierarchy.rs @@ -19,11 +19,7 @@ impl ServerState { self.type_hierarchy_item(symbol.type_path()?, &uri, type_registry, decl_registry)?; Some(vec![item]) })(); - Response { - id: req.id, - result: Some(serde_json::to_value(items).unwrap()), - error: None, - } + response_with_json(req.id, &items) } /// typeHierarchy/supertypes — a type's base classes (its `#[base]` fields). @@ -32,11 +28,7 @@ impl ServerState { .ok() .map(|p| self.related_types(&p.item, true)) .unwrap_or_default(); - Response { - id: req.id, - result: Some(serde_json::to_value(items).unwrap()), - error: None, - } + response_with_json(req.id, &items) } /// typeHierarchy/subtypes — types that declare this one as a `#[base]`. @@ -45,11 +37,7 @@ impl ServerState { .ok() .map(|p| self.related_types(&p.item, false)) .unwrap_or_default(); - Response { - id: req.id, - result: Some(serde_json::to_value(items).unwrap()), - error: None, - } + response_with_json(req.id, &items) } /// Build a TypeHierarchyItem for a resolved type path; the path round-trips diff --git a/tooling/lsp/src/handlers/outline.rs b/tooling/lsp/src/handlers/outline.rs index b65c9a67..c06e0469 100644 --- a/tooling/lsp/src/handlers/outline.rs +++ b/tooling/lsp/src/handlers/outline.rs @@ -36,11 +36,7 @@ impl ServerState { } } - Response { - id: req.id, - result: Some(serde_json::to_value(DocumentSymbolResponse::Nested(symbols)).unwrap()), - error: None, - } + response_with_json(req.id, &DocumentSymbolResponse::Nested(symbols)) } /// workspace/symbol @@ -82,11 +78,7 @@ impl ServerState { } } - Response { - id: req.id, - result: Some(serde_json::to_value(symbols).unwrap()), - error: None, - } + response_with_json(req.id, &symbols) } /// textDocument/formatting @@ -136,11 +128,7 @@ impl ServerState { new_text: formatted, }; - Response { - id: req.id, - result: Some(serde_json::to_value(vec![edit]).unwrap()), - error: None, - } + response_with_json(req.id, &vec![edit]) } Err(_) => Response { id: req.id, @@ -194,11 +182,7 @@ impl ServerState { } } - Response { - id: req.id, - result: Some(serde_json::to_value(lenses).unwrap()), - error: None, - } + response_with_json(req.id, &lenses) } /// textDocument/inlayHint @@ -261,11 +245,7 @@ impl ServerState { } } - Response { - id: req.id, - result: Some(serde_json::to_value(hints).unwrap()), - error: None, - } + response_with_json(req.id, &hints) } /// textDocument/semanticTokens/full — resolution-aware tokens layered over @@ -282,11 +262,7 @@ impl ServerState { result_id: None, data, }; - Response { - id: req.id, - result: Some(serde_json::to_value(result).unwrap()), - error: None, - } + response_with_json(req.id, &result) } fn semantic_tokens(&self, uri: &Uri) -> Vec { @@ -420,11 +396,7 @@ impl ServerState { .ok() .map(|p| self.folding_ranges(&p.text_document.uri)) .unwrap_or_default(); - Response { - id: req.id, - result: Some(serde_json::to_value(ranges).unwrap()), - error: None, - } + response_with_json(req.id, &ranges) } fn folding_ranges(&self, uri: &Uri) -> Vec { diff --git a/tooling/lsp/src/handlers/symbols.rs b/tooling/lsp/src/handlers/symbols.rs index d3d4a3fc..f2f89609 100644 --- a/tooling/lsp/src/handlers/symbols.rs +++ b/tooling/lsp/src/handlers/symbols.rs @@ -270,11 +270,7 @@ impl ServerState { locs }) .unwrap_or_default(); - Response { - id: req.id, - result: Some(serde_json::to_value(locations).unwrap()), - error: None, - } + response_with_json(req.id, &locations) } /// textDocument/documentHighlight — occurrences within the current file only. @@ -299,23 +295,23 @@ impl ServerState { .collect() }) .unwrap_or_default(); - Response { - id: req.id, - result: Some(serde_json::to_value(highlights).unwrap()), - error: None, - } + response_with_json(req.id, &highlights) } /// textDocument/prepareRename — validate the cursor is on a renameable /// identifier (a user-defined type/reference/use-leaf, not a builtin) and /// return its range + current text. pub fn handle_prepare_rename(&self, req: Request) -> Response { - let result = self - .prepare_rename(&req) - .map(|r| serde_json::to_value(r).unwrap()); + let result = match self.prepare_rename(&req) { + Some(r) => match json_value(&r) { + Ok(v) => v, + Err(e) => return error_response(req.id, e), + }, + None => serde_json::Value::Null, + }; Response { id: req.id, - result: Some(result.unwrap_or(serde_json::Value::Null)), + result: Some(result), error: None, } } @@ -390,11 +386,7 @@ impl ServerState { // occurrence span is exactly the identifier token, so renaming a leaf of // a path leaves the rest of the path intact. let Some(target) = self.symbol_at(uri, &loc) else { - return Response { - id: req.id, - result: Some(serde_json::to_value(WorkspaceEdit::default()).unwrap()), - error: None, - }; + return response_with_json(req.id, &WorkspaceEdit::default()); }; let mut edits: HashMap> = HashMap::new(); @@ -411,11 +403,7 @@ impl ServerState { change_annotations: None, }; - Response { - id: req.id, - result: Some(serde_json::to_value(workspace_edit).unwrap()), - error: None, - } + response_with_json(req.id, &workspace_edit) } } @@ -425,7 +413,9 @@ pub(crate) fn is_valid_identifier(s: &str) -> bool { return false; } let mut chars = s.chars(); - let first = chars.next().unwrap(); + let Some(first) = chars.next() else { + return false; + }; if !first.is_alphabetic() && first != '_' { return false; } diff --git a/tooling/lsp/src/lib.rs b/tooling/lsp/src/lib.rs index 34ea49dc..7760d0b5 100644 --- a/tooling/lsp/src/lib.rs +++ b/tooling/lsp/src/lib.rs @@ -2,6 +2,19 @@ //! //! Provides LSP server functionality for the Pyxis DSL, built on the //! Salsa-backed compiler. +//! +//! The workspace restriction lints (unwrap_used/expect_used/panic/unreachable) +//! target production code. Test code is explicitly exempt per the contributing +//! guidelines, so allow them under `cfg(test)` only. +#![cfg_attr( + test, + allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable + ) +)] pub mod handlers; pub mod main_loop; diff --git a/tooling/lsp/src/state/lifecycle.rs b/tooling/lsp/src/state/lifecycle.rs index 8a3da16e..c9538e90 100644 --- a/tooling/lsp/src/state/lifecycle.rs +++ b/tooling/lsp/src/state/lifecycle.rs @@ -28,11 +28,13 @@ impl ServerState { if let Some(doc_uri) = existing_uri { // File was already discovered/opened — update its content in place - // (the editor's version may differ from disk). - let doc = self - .documents - .get_mut(&doc_uri) - .expect("existing_uri came from documents"); + // (the editor's version may differ from disk). The key came from + // `documents` itself (either the direct `contains_key` hit or + // `find_document_by_abs_path`), so the entry exists; the `else` + // arm is the non-panicking form of that invariant. + let Some(doc) = self.documents.get_mut(&doc_uri) else { + return Ok(()); + }; use pyxis::semantic::Setter; doc.source_file.set_contents(&mut self.db).to(text.clone()); doc.content = text; diff --git a/tooling/lsp/src/state/uri.rs b/tooling/lsp/src/state/uri.rs index 0922fe8b..e11b138c 100644 --- a/tooling/lsp/src/state/uri.rs +++ b/tooling/lsp/src/state/uri.rs @@ -70,8 +70,18 @@ pub(super) fn file_uri(path: &std::path::Path) -> Uri { } let encoded = percent_encode(&path.display().to_string()); let uri_str = format!("file:///{}", encoded.trim_start_matches('/')); - Uri::from_str(&uri_str) - .unwrap_or_else(|_| Uri::from_str("file:///").expect("`file:///` is a valid URI")) + match Uri::from_str(&uri_str) { + Ok(uri) => uri, + // The encoded string is ASCII and starts with `file:///`, so the parse + // succeeds by construction; this arm is unreachable. `#[expect]` + // rather than `#[allow]` so the lint warns if a future change ever + // makes the arm reachable. + #[expect( + clippy::unreachable, + reason = "percent-encoded `file:///` string always parses as a URI" + )] + Err(_) => unreachable!("percent-encoded `file:///` string is a valid URI"), + } } /// Percent-decoding for file paths. Decodes `%XX` escapes to raw bytes and diff --git a/tooling/lsp/tests/diagnostics_save_test.rs b/tooling/lsp/tests/diagnostics_save_test.rs index a21debb1..11f04292 100644 --- a/tooling/lsp/tests/diagnostics_save_test.rs +++ b/tooling/lsp/tests/diagnostics_save_test.rs @@ -1,6 +1,12 @@ //! Regression test for issue 4: saving a corrupt .pyxis file must NOT clear //! the parse-error diagnostic that typing produced. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable +)] use lsp_server::Notification; use lsp_types::{ DidChangeTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, diff --git a/tooling/lsp/tests/integration.rs b/tooling/lsp/tests/integration.rs index 9ea29f12..dde5dafc 100644 --- a/tooling/lsp/tests/integration.rs +++ b/tooling/lsp/tests/integration.rs @@ -2,6 +2,12 @@ //! //! These tests verify the full request/response cycle including serialization. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable +)] use lsp_server::{Connection, Message, Request, RequestId}; use lsp_types::{ ClientCapabilities, DidOpenTextDocumentParams, InitializeParams, InitializedParams, Position, diff --git a/tooling/lsp/tests/navigation_test.rs b/tooling/lsp/tests/navigation_test.rs index 1d5e94bf..b582351f 100644 --- a/tooling/lsp/tests/navigation_test.rs +++ b/tooling/lsp/tests/navigation_test.rs @@ -6,6 +6,12 @@ //! - each segment of a fully-qualified path (`a::b::C`) resolving independently //! (leaf → type, earlier segments → their module files). +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable +)] use lsp_server::{Request, RequestId}; use lsp_types::{Position, TextDocumentIdentifier, TextDocumentPositionParams}; use pyxis_lsp::state::ServerState; diff --git a/tooling/lsp/tests/snapshots.rs b/tooling/lsp/tests/snapshots.rs index 002a9d6c..1af713e1 100644 --- a/tooling/lsp/tests/snapshots.rs +++ b/tooling/lsp/tests/snapshots.rs @@ -2,6 +2,12 @@ //! //! Run `UPDATE_EXPECT=1 cargo test -p pyxis-lsp --test snapshots` to update. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable +)] use expect_test::expect; use lsp_server::{Connection, Message, Notification, Request, RequestId}; use lsp_types::{ diff --git a/tooling/lsp/tests/structures_test/main.rs b/tooling/lsp/tests/structures_test/main.rs index 37013dd4..41263e13 100644 --- a/tooling/lsp/tests/structures_test/main.rs +++ b/tooling/lsp/tests/structures_test/main.rs @@ -2,6 +2,12 @@ //! type names, fields, vftable entries, impl methods, impl targets, cfg-gated //! `use`s — and robustness when a type has a semantic error (mid-edit `#[size]`). +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable +)] mod helpers; mod hover; mod imports_completion; diff --git a/tooling/lsp/tests/watched_files_test.rs b/tooling/lsp/tests/watched_files_test.rs index 84f3cee6..5e056d05 100644 --- a/tooling/lsp/tests/watched_files_test.rs +++ b/tooling/lsp/tests/watched_files_test.rs @@ -1,7 +1,19 @@ //! File-watching (workspace/didChangeWatchedFiles) is inherently coupled to the //! real filesystem — the handler re-reads changed files from disk — so unlike -//! the in-memory handler tests this one writes to a temp dir. +//! the in-memory handler tests this one stages files on disk. The staging dir +//! lives under `target/test-artifacts` (workspace-local, hermetic via +//! `CARGO_MANIFEST_DIR`, unique per run) rather than `std::env::temp_dir()`. +//! +//! An in-memory seam is deliberately not used here: the behaviour under test +//! is the handler reading the *file system* in response to watch events, so +//! substituting a fake FS would test nothing the real handler does. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable +)] use lsp_server::{Notification, Request, RequestId}; use lsp_types::{ CompletionParams, FileChangeType, FileEvent, Position, TextDocumentIdentifier, @@ -9,6 +21,11 @@ use lsp_types::{ }; use pyxis_lsp::state::ServerState; +/// Workspace-local staging directory for this inherently-filesystem test. +fn scratch_base() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-artifacts") +} + fn completion_labels(s: &ServerState, uri: &lsp_types::Uri) -> Vec { let params = CompletionParams { text_document_position: TextDocumentPositionParams { @@ -48,7 +65,7 @@ fn notify_watched(state: &mut ServerState, path: &std::path::Path, typ: FileChan #[test] fn watched_files_pick_up_on_disk_changes() { - let base = std::env::temp_dir().join(format!("pyxis-watched-{}", std::process::id())); + let base = scratch_base().join(format!("pyxis-watched-{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); std::fs::write( diff --git a/types/tsconfig.json b/types/tsconfig.json new file mode 100644 index 00000000..b1609692 --- /dev/null +++ b/types/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.types.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + // No ambient type packages: `json.ts` is a standalone type declaration + // module and must not pull in DOM/Node types transitively. + "types": [], + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + // `json.ts` is generated by Specta (`cargo run -p pyxis-driver -- gen-types`) + // and must not be hand-edited. It is typechecked here on purpose: a stale + // or hand-mangled generated type is a compile error, not a silent runtime + // mismatch. Its *formatting* is exempt from `prettier --check` — generator + // output is self-consistent, and the contributing guidelines permit + // lint/format skip for generated output. + "include": ["json.ts"] +} \ No newline at end of file diff --git a/viewer/eslint.config.js b/viewer/eslint.config.js index d89b4f7f..5a0aa5a5 100644 --- a/viewer/eslint.config.js +++ b/viewer/eslint.config.js @@ -2,6 +2,7 @@ import js from '@eslint/js'; import globals from 'globals'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; +import betterTailwind from 'eslint-plugin-better-tailwindcss'; import tseslint from 'typescript-eslint'; import { defineConfig, globalIgnores } from 'eslint/config'; @@ -14,15 +15,31 @@ export default defineConfig([ tseslint.configs.recommended, reactHooks.configs.flat.recommended, reactRefresh.configs.vite, + // `recommended-error` so the stylistic warnings are treated as errors + // too: lint stays a binary signal. + betterTailwind.configs['recommended-error'], ], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, + settings: { + // Point the Tailwind rule at the v4 CSS entry point so custom tokens + // (e.g. `text-fg-subtle`) resolve against the theme. + 'better-tailwindcss': { + entryPoint: 'src/index.css', + }, + }, rules: { 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], - 'react-hooks/exhaustive-deps': 'warn', - 'react-hooks/set-state-in-effect': 'off', + 'react-hooks/exhaustive-deps': 'error', + 'react-hooks/set-state-in-effect': 'error', + // Prettier owns line wrapping; better-tailwindcss's line-wrapping rule + // fights it (prettier collapses long class strings, the plugin wants + // them multi-line). Keep the correctness rules (unknown/concatenated/ + // deprecated/duplicate/conflicting classes, canonical forms, class + // order) as errors; line wrapping stays prettier's job. + 'better-tailwindcss/enforce-consistent-line-wrapping': 'off', }, }, -]); +]); \ No newline at end of file diff --git a/viewer/package.json b/viewer/package.json index ef923131..5e11b262 100644 --- a/viewer/package.json +++ b/viewer/package.json @@ -7,19 +7,25 @@ "dev": "vite", "dev-public": "vite --host", "build": "tsc -b && vite build", + "typecheck": "tsc -b", "lint": "tsc --noEmit && eslint . && prettier --check \"src/**/*.{ts,tsx,css}\"", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"", + "test": "vitest run", "preview": "vite preview" }, "dependencies": { "@pyxis/types": "*", + "clsx": "^2.1.1", "highlight.js": "^11.11.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.1.3", "remark-gfm": "^4.0.1", - "unified": "^11.0.5" + "tailwind-merge": "^3.6.0", + "unified": "^11.0.5", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -30,6 +36,7 @@ "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.0", "eslint": "^9.39.1", + "eslint-plugin-better-tailwindcss": "^4.7.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", @@ -37,6 +44,7 @@ "tailwindcss": "^4.0.11", "typescript": "~5.9.3", "typescript-eslint": "^8.46.3", - "vite": "^7.2.2" + "vite": "^7.2.2", + "vitest": "^4.1.10" } -} +} \ No newline at end of file diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index 712a925b..d3455650 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -73,7 +73,7 @@ function AppLayout() { ); } -export default function App() { +export function App() { return ( diff --git a/viewer/src/components/Actions.tsx b/viewer/src/components/Actions.tsx index d8bce7ab..f1fa98d6 100644 --- a/viewer/src/components/Actions.tsx +++ b/viewer/src/components/Actions.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { cn } from '../utils/styles'; // Briefly-latching clipboard helper used by the copy affordances. function useCopy(timeout = 1200) { @@ -45,7 +46,14 @@ function ActionButton({ }} title={title} aria-label={title} - className={`inline-flex items-center gap-1 rounded p-1 text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg ${className}`} + className={cn( + ` + inline-flex items-center gap-1 rounded-sm p-1 text-fg-subtle + transition-colors + hover:bg-surface-2 hover:text-fg + `, + className + )} > {icon} {label && {label}} @@ -55,7 +63,7 @@ function ActionButton({ function CopyIcon({ checked }: { checked: boolean }) { return ( - + {checked ? ( ) : ( diff --git a/viewer/src/components/Attributes.tsx b/viewer/src/components/Attributes.tsx index bed7acd3..8902b48e 100644 --- a/viewer/src/components/Attributes.tsx +++ b/viewer/src/components/Attributes.tsx @@ -2,6 +2,7 @@ import { Fragment, type ReactNode } from 'react'; import type { JsonItem, JsonFunction, JsonCfg } from '@pyxis/types'; import { formatHexAddress } from '../utils/format'; import { WRAP_COLUMN } from '../utils/typeString'; +import { cn } from '../utils/styles'; // --- token helpers --- @@ -88,7 +89,7 @@ function AttrBracket({ attrs }: { attrs: ReactNode[] }) { function AttrContainer({ groups, className = '' }: { groups: ReactNode[][]; className?: string }) { if (groups.every((g) => g.length === 0)) return null; return ( -
+
{groups .filter((g) => g.length > 0) .map((group, i) => ( diff --git a/viewer/src/components/BackendSpliceSection.tsx b/viewer/src/components/BackendSpliceSection.tsx index 33b32915..4d77f97f 100644 --- a/viewer/src/components/BackendSpliceSection.tsx +++ b/viewer/src/components/BackendSpliceSection.tsx @@ -54,9 +54,22 @@ function spliceLanguage(cfg: JsonCfg | null | undefined): string | undefined { function SectionHeader({ anchor, children }: { anchor?: string; children: React.ReactNode }) { return ( -

+

{children} - {anchor && } + {anchor && ( + + )}

); } @@ -70,14 +83,23 @@ function SpliceItem({ splice }: { splice: JsonSplice }) { const title = splice.kind === 'prologue' ? 'Prologue' : 'Epilogue'; return (
-
+
{splice.cfg ? ( #[cfg({renderCfg(splice.cfg)})] ) : ( all backends )} {splice.definition && ( - + definition )} @@ -89,10 +111,10 @@ function SpliceItem({ splice }: { splice: JsonSplice }) { ); } -interface BackendSpliceSectionProps { +type BackendSpliceSectionProps = { splices: JsonSplice[]; slot: SpliceSlot; -} +}; // Module-page section: renders one slot (prologue OR epilogue), excluding any // splice tagged `for ` (those render on the type's page instead). Each @@ -117,10 +139,10 @@ export function BackendSpliceSection({ splices, slot }: BackendSpliceSectionProp ); } -interface TypeBackendCodeProps { +type TypeBackendCodeProps = { splices: JsonSplice[]; itemPath: string; -} +}; // Type-page section: renders every splice (prologue and epilogue) whose // `for_type` resolves to this item's path. These are splices the defs author diff --git a/viewer/src/components/Badge.tsx b/viewer/src/components/Badge.tsx index 42534631..06d2f7a2 100644 --- a/viewer/src/components/Badge.tsx +++ b/viewer/src/components/Badge.tsx @@ -1,3 +1,4 @@ +import { cn } from '../utils/styles'; type BadgeVariant = | 'green' | 'blue' @@ -12,10 +13,10 @@ type BadgeVariant = | 'indigo' | 'teal'; -interface BadgeProps { +type BadgeProps = { variant: BadgeVariant; children: React.ReactNode; -} +}; // Badges are quiet outline chips: a shared neutral shell carries the shape, and // only the text color signals meaning. This keeps a metadata row from turning @@ -39,7 +40,7 @@ const shell = 'inline-flex items-center rounded-md border border-edge bg-surface export function Badge({ variant, children }: BadgeProps) { return ( - {children} + {children} ); } @@ -49,7 +50,7 @@ export function SmallBadge({ className = '', }: BadgeProps & { className?: string }) { return ( - + {children} ); diff --git a/viewer/src/components/Breadcrumbs.tsx b/viewer/src/components/Breadcrumbs.tsx index c77dd2e5..fb1bef3e 100644 --- a/viewer/src/components/Breadcrumbs.tsx +++ b/viewer/src/components/Breadcrumbs.tsx @@ -3,15 +3,15 @@ import { buildModuleUrl, buildItemUrl, buildRootUrl } from '../utils/navigation' import { useDocumentation } from '../contexts/DocumentationContext'; import type { ItemType } from '../utils/colors'; -interface BreadcrumbsProps { +type BreadcrumbsProps = { path: string; isItem?: boolean; itemType?: ItemType; -} +}; function Separator() { return ( - + ); diff --git a/viewer/src/components/CodeBlock.tsx b/viewer/src/components/CodeBlock.tsx index 54254b5d..36613814 100644 --- a/viewer/src/components/CodeBlock.tsx +++ b/viewer/src/components/CodeBlock.tsx @@ -16,10 +16,10 @@ hljs.registerLanguage('c++', cpp); hljs.registerLanguage('rust', rust); hljs.registerLanguage('json', json); -interface CodeBlockProps { +type CodeBlockProps = { code: string; language?: string; -} +}; export function CodeBlock({ code, language }: CodeBlockProps) { const codeRef = useRef(null); @@ -41,8 +41,21 @@ export function CodeBlock({ code, language }: CodeBlockProps) { }, [code, language]); return ( -
-      
+    
+      `) is inherently
+        // dynamic — the language ID comes from the documented content and
+        // cannot be enumerated statically, so this is an enumerated allowlist
+        // case (not literal-fragment interpolation of a Tailwind utility).
+        // eslint-disable-next-line better-tailwindcss/no-concatenated-classes
+        className={language ? `language-${language.toLowerCase()}` : ''}
+      >
         {code}
       
     
diff --git a/viewer/src/components/Collapsible.tsx b/viewer/src/components/Collapsible.tsx index 186ecf05..31b5e656 100644 --- a/viewer/src/components/Collapsible.tsx +++ b/viewer/src/components/Collapsible.tsx @@ -1,23 +1,28 @@ import { useState, type ReactNode } from 'react'; +import { cn } from '../utils/styles'; -interface CollapsibleProps { +type CollapsibleProps = { title: string; children: ReactNode; defaultOpen?: boolean; -} +}; export function Collapsible({ title, children, defaultOpen = false }: CollapsibleProps) { const [isOpen, setIsOpen] = useState(defaultOpen); return ( -
+
@@ -70,7 +81,14 @@ function OptionButton({ function Chevron({ open }: { open: boolean }) { return ( opt.value === value) || options[0]; + // Close the dropdown. Resets the transient open state together so the next + // open starts fresh: focused selection and expanded groups. Doing the reset + // here (at the transition) instead of in an effect keeps the state changes + // at the event boundary, which the react-hooks set-state-in-effect rule + // requires. `useCallback` keeps the identity stable so the click-outside + // effect's dependency list doesn't churn every render. + const close = useCallback(() => { + setIsOpen(false); + setFocusedIndex(-1); + setExpandedGroups(new Set()); + }, []); + // Pre-group options: consecutive options sharing a `group` string form // one group. Ungrouped options are each their own singleton group. const renderGroups: RenderGroup[] = useMemo(() => { @@ -108,8 +138,7 @@ export function CustomDropdown({ value, onChange, options, disabled }: CustomDro useEffect(() => { const handleClickOutside = (event: MouseEvent | TouchEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - setFocusedIndex(-1); + close(); } }; @@ -121,7 +150,7 @@ export function CustomDropdown({ value, onChange, options, disabled }: CustomDro document.removeEventListener('touchstart', handleClickOutside); }; } - }, [isOpen]); + }, [isOpen, close]); useEffect(() => { if (isOpen && focusedIndex >= 0 && dropdownRef.current) { @@ -132,11 +161,6 @@ export function CustomDropdown({ value, onChange, options, disabled }: CustomDro } }, [focusedIndex, isOpen]); - // Reset expanded groups when dropdown closes so it starts fresh next time. - useEffect(() => { - if (!isOpen) setExpandedGroups(new Set()); - }, [isOpen]); - const handleKeyDown = (event: React.KeyboardEvent) => { if (disabled) return; @@ -147,16 +171,14 @@ export function CustomDropdown({ value, onChange, options, disabled }: CustomDro event.preventDefault(); const option = options[focusedIndex]; onChange(option.value); - setIsOpen(false); - setFocusedIndex(-1); + close(); } else if (!isOpen) { event.preventDefault(); setIsOpen(true); } break; case 'Escape': - setIsOpen(false); - setFocusedIndex(-1); + close(); buttonRef.current?.focus(); break; case 'ArrowDown': @@ -178,8 +200,7 @@ export function CustomDropdown({ value, onChange, options, disabled }: CustomDro const handleOptionClick = (optionValue: string) => { onChange(optionValue); - setIsOpen(false); - setFocusedIndex(-1); + close(); }; const toggleGroup = (gi: number) => { @@ -205,27 +226,47 @@ export function CustomDropdown({ value, onChange, options, disabled }: CustomDro ); return ( -
+