# WASM Module
**Purpose**: Platform-independent entry point for running luff's formatting pipeline over in-memory file data, targeting `wasm32-unknown-unknown` (browser/JS) and `wasm32-wasip1` (server-side WASI).
**Core constraint**: No threads, no filesystem, no subprocesses, no clipboard. Operates purely on owned data passed in by the caller.
## File Responsibilities
### mod.rs
- **Module root**: Feature-gated via `#[cfg(feature = "wasm")]` at the crate level
- **Re-exports**: `VirtualFile`, `WasmProcessor`, `ProcessorOptions`, `ProcessorOptionsBuilder`, `ProcessResult`, `WasmError`, `OutputFormat`
- **Conditional compilation**: `test_strategies` module only under `#[cfg(test)]`
- **Planned**: `wasm-bindgen` bindings re-export when that sub-feature is active
### virtual_fs.rs
- **VirtualFile**: `{ path: String, content: String }` — private fields enforce validation invariant
- **Validation gate**: `new()` and custom `Deserialize` both reject empty paths, null bytes, control chars (except `\t`), paths > 4096 bytes
- **`new_unchecked`**: Bypasses validation for internal/test use
- **Helper methods**: `file_name()`, `extension()`, `is_dotfile()`, `normalized_path()` (strips `./`)
- **Extension semantics**: Matches `std::path::Path::extension` — `.gitignore` → `None`, `Makefile.` → `None`
- **Ordering**: Derived lexicographic on `(path, content)` for deterministic sort
### processor.rs
- **WasmProcessor**: Stateless processor holding compiled `ProcessorOptions`
- **Self-contained renderers**: Markdown and tree formatters live in `render.rs` (not delegated to CLI printers) to avoid coupling to `io::Write` / terminal concerns
- **CountingWriter**: Internal `fmt::Write` adapter that tracks bytes and enforces `max_output_bytes`, with overflow flag to distinguish truncation from write errors
- **No I/O, no threads, no timeouts**: Pure computation
### render.rs
- **Markdown renderer**: Variable-length backtick fences (CommonMark §4.5), file-extension-based language hints
- **Tree renderer**: `BTreeMap<String, TreeNode>` with recursive box-drawing (`├──`, `└──`, `│`)
- **`min_fence_len`**: Scans content for longest backtick run, returns max(run+1, 3); `pub(super)` visibility (render detail, not module API)
- **Defense-in-depth**: `insert_path` handles edge cases (empty components, leading slashes) that `validate_path` now rejects, since `new_unchecked` can bypass validation
### options.rs
- **ProcessorOptions**: WASM-safe config — no `PathBuf`, no filesystem deps
- **Builder pattern**: `ProcessorOptionsBuilder` with fluent infallible setters; `build()` is the single fallible step (glob compilation)
- **Default**: Markdown format, no ignores, no dotfiles, no limits, root label `"."`
### error.rs
- **WasmError**: Slim `thiserror` enum — no `miette`, no terminal rendering
- **Variants**: `InvalidPath`, `PatternError`, `OutputTooLarge { size, max }`, `Fmt`, `Processing`
- **Conversions**: `From<globset::Error>` → `PatternError`, `From<fmt::Error>` → `Fmt`
### test_strategies.rs
- **`#[cfg(test)]` only**: Shared `proptest` strategies for the WASM module
- **Strategies**: `valid_path()`, `dotfile_path()`, `file_content()`, `virtual_file()`, `virtual_files(max)`, `extension()`, `processor_options()`
### bindings.rs (feature = "wasm-bindgen") — NOT YET IMPLEMENTED
- Planned: `#[wasm_bindgen]` entry points deserializing `JsValue` via `serde-wasm-bindgen`, delegating to `WasmProcessor`
## Critical Types
```rust
VirtualFile {
path: String, // Private, validated on construction/deserialization
content: String, // Arbitrary UTF-8
}
ProcessorOptions {
output_format: OutputFormat,
ignore_extensions: Vec<String>, // Without leading dot
ignore_globs: GlobSet, // Compiled, used for matching
ignore_glob_strings: Vec<String>, // Raw, kept for round-tripping
include_dotfiles: bool,
max_files: Option<usize>,
max_output_bytes: Option<usize>,
root_label: String,
}
WasmProcessor { options: ProcessorOptions }
// Cheap to clone (GlobSet uses internal Arc)
ProcessResult {
files_processed: usize, // Included in output
files_skipped: usize, // Excluded by filter rules
files_truncated: usize, // Passed filters but cut by max_files
output_bytes: usize,
}
```
## Critical Methods
```rust
VirtualFile::new(path, content) -> Result<Self>
// Validates path: non-empty, no null bytes, no control chars, ≤4096 bytes
WasmProcessor::process(files, &mut sink) -> Result<ProcessResult>
// Core pipeline: collect → filter → sort → truncate → format → write
// sink: impl fmt::Write (not io::Write)
WasmProcessor::process_to_string(files) -> Result<String>
// Convenience: pre-allocates ~1.5× estimated output size
ProcessorOptions::builder() -> ProcessorOptionsBuilder
// Fluent builder. All setters infallible; build() compiles globs.
```
## Processing Pipeline
```
IntoIterator<Item = VirtualFile>
→ Collect into Vec
→ Filter (in-place retain): // filter before sort = fewer elements to sort
1. Dotfile check (any component starts with '.')
2. Extension check (case-insensitive)
3. Glob check (compiled GlobSet)
→ Sort by (path, content) // deterministic output
→ Truncate at max_files // tracked separately as files_truncated
→ Format through CountingWriter → impl fmt::Write
→ Return ProcessResult
```
## Data Flow
```
Caller (JS/Rust) → Vec<VirtualFile> → WasmProcessor::process()
↓
Filter → Sort → Truncate
↓
Markdown or Tree renderer
↓
CountingWriter → impl fmt::Write
↓
ProcessResult { processed, skipped, truncated, bytes }
```
## Key Invariants
- VirtualFile.path is always validated (non-empty, no null bytes, no control chars)
- Custom `Deserialize` enforces same validation as `new()` — untrusted input cannot bypass
- Output is deterministic: same input always produces identical output (sort + deterministic BTreeMap)
- `CountingWriter` overflow flag distinguishes "output too large" from genuine write failure
- `files_processed + files_skipped + files_truncated == total_input` (conservation invariant, property-tested)
- `files_skipped` counts only filter exclusions; `files_truncated` counts only `max_files` cuts
- Processor never panics on arbitrary input (property-tested with proptest)
## Security Model
- **No filesystem**: paths are display labels only — no traversal risk
- **Path validation**: rejects null bytes, control chars, oversized paths
- **Resource limits**: `max_files` + `max_output_bytes` cap memory usage
- **Glob safety**: `globset` uses DFA-based matching (linear time, no ReDoS)
- **Untrusted deserialization**: custom `Deserialize` validates before constructing
## Performance Characteristics
- **Peak memory**: ~2× total input size (input Vec + formatted output String)
- **Sort**: In-place on `Vec<VirtualFile>` (derived `Ord`)
- **Filter**: In-place `retain` before sort (no allocation, fewer elements to sort)
- **Pre-allocation**: `process_to_string` estimates ~1.5× output size
- **GlobSet**: Compiled once at `build()`, amortized O(1) per match
- **No per-file allocations**: Content written directly to sink
## Feature Flag Design
```toml
wasm = [] # Pure-Rust processor + virtual FS, no JS deps
wasm-bindgen = ["wasm", …] # Adds #[wasm_bindgen] JS bindings (planned)
cli = [...] # Not mutually exclusive with wasm
```
`wasm` and `cli` can coexist. Only the binary target requires `cli`.
## Extension Points
- **`wasm-bindgen` bindings**: `bindings.rs` — JS-facing entry points, first priority
- **Streaming output**: Accept callback `fn(chunk: &str)` for incremental rendering
- **Binary file detection**: Null-byte heuristic on first 8KB of content
- **Custom formatters**: Accept `fn(&VirtualFile, &mut impl fmt::Write)` for user-defined formats
- **Printer convergence**: If CLI printers adopt `fmt::Write`, WASM module can delegate instead of maintaining parallel renderers