luff 0.2.1

Print files with formatting
Documentation
# 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