Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

cargo teksilo-fmt — formatter for teksu! blocks

rustfmt treats macro bodies as opaque token streams and won't descend into a teksu!(...) invocation, so the DSL inside is hand-formatted by default. cargo teksilo-fmt fills the gap: it walks Rust source files, finds every teksu! invocation, reformats the body, and writes the file back in place. Source outside teksu! blocks is byte-for-byte unchanged — cargo fmt still owns Rust formatting.

For the surface language the formatter normalizes, see teksu-macro-reference.md.


Installation

The crate ships in this workspace under crates/cargo-teksilo-fmt. Install it with:

cargo install --path crates/cargo-teksilo-fmt

cargo install puts the binary in ~/.cargo/bin/cargo-teksilo-fmt, which Cargo picks up as the cargo teksilo-fmt subcommand from any directory. Verify with:

cargo teksilo-fmt --version

To uninstall: cargo uninstall cargo-teksilo-fmt.


Usage

cargo teksilo-fmt [OPTIONS] [paths...]

OPTIONS:
    --check                 Read-only; exit 1 if any file would change
    --quiet, -q             Suppress per-file output
    --help, -h              Print help
    --version, -V           Print version

POSITIONAL:
    paths                   Files or directories to format. Directories
                            are walked recursively for *.rs, skipping
                            target/ and hidden directories. Defaults to
                            the current directory.

Examples:

cargo teksilo-fmt                              # format from CWD
cargo teksilo-fmt --check                      # CI mode: exit 1 if dirty
cargo teksilo-fmt examples/widget_catalog      # format one example
cargo teksilo-fmt src/main.rs src/build.rs     # format specific files

Files containing no teksu! token are skipped before parsing — there's no measurable cost on a workspace where most modules don't use the DSL.

Writes are atomic: the formatted output goes into a sibling NamedTempFile and is renamed into place. An interrupted run never leaves a truncated source file on disk.


What gets normalized

The formatter rewrites the shape of teksu! bodies. Rust expressions inside (property values, positional args, closure bodies, escape exprs, rust { … } blocks) are spliced verbatim from source — the formatter does not reformat Rust.

Layout rules (v1):

  • 4-space indent per nesting level.
  • Elements with a non-empty body span multiple lines: Type(args) {\n <items>\n}.
  • Elements with no body or with an empty {} body emit on one line without braces: Divider { }Divider.
  • One body item per line. Properties keep name: value shape.
  • Property order is preserved verbatim. The macro lowering reorders handler properties (on_tap, cursor, …) to the end of the chain at compile time; that's a lowering concern, not the formatter's, so what you wrote is what you get back.
  • The ctx => <element> preamble joins to the root element on the same line: teksu!(ctx =>\n VStack { … }) becomes teksu!(ctx => VStack { … }).
  • Continuation lines are aligned to where the user already had the body's outermost } in source — the closing brace of the reformatted output lands at the same column.

Trivia preservation

Comments and blank lines between body items survive a reformat.

#![allow(unused)]
fn main() {
// before
teksu!(ctx =>
    VStack {
        spacing: 12.0
        // user-added section header
        Button(lit!("Save"))

        Button(lit!("Cancel"))
    }
)

// after — unchanged
teksu!(ctx =>
    VStack {
        spacing: 12.0
        // user-added section header
        Button(lit!("Save"))

        Button(lit!("Cancel"))
    }
)
}

How it works: syn::ParseStream discards comments before they reach the IR, so the formatter runs a separate pass over the original TokenStream using proc_macro2::Span::byte_range() to record the inter-token gaps in source. The pretty-printer then drains that table by byte offset, emitting each comment / blank-line marker at the right indent level before the next body item.

Multiple consecutive blank lines collapse to a single blank line.

Comments inside Rust expressions (e.g. Button(/* note */ lit!("ok"))) are preserved automatically because expression values are sliced verbatim from source — they ride along with the rest of the slice.


CI integration

Use --check to fail the build when any file would be reformatted:

# .github/workflows/lint.yml (or equivalent)
- name: teksilo-fmt
  run: cargo teksilo-fmt --check

--check is read-only, prints Would reformat: <path> for each dirty file, and exits 1 if any.

For a pre-commit hook, run without --check:

# .git/hooks/pre-commit
cargo teksilo-fmt --quiet
git add -u

Library API

The CLI is a thin wrapper around the teksilo-fmt library crate. Editor integrations can call into it directly:

#![allow(unused)]
fn main() {
use teksilo_fmt::{format_block, format_file, FmtConfig, FmtError};

// Format a single teksu! body string (the contents inside the macro parens):
let cfg = FmtConfig::default();
let formatted = format_block("ctx => VStack { spacing: 8.0 }", &cfg)?;

// Format every teksu! invocation in a Rust file's source:
let new_source = format_file(&source_text, &cfg)?;
}

Both functions are pure: they take a &str and return a String. There's no I/O at the library level. format_file detects the host file's line ending convention (LF or CRLF) and applies it to every newline the formatter emits, so a CRLF file round-trips as CRLF.

FmtConfig is empty in v1 — every invocation produces canonical output. Style knobs may be added later; defaults will not change for existing invocations.


Editor integration (LSP)

The teksilo-fmt-lsp crate ships a minimal Language Server Protocol server that wraps the formatter. Install:

cargo install --path crates/teksilo-fmt-lsp

The server speaks JSON-RPC over stdio and advertises a single capability: documentFormattingProvider. Wire it into your editor as a secondary formatter for Rust files (rust-analyzer stays your primary).

Helix

# ~/.config/helix/languages.toml
[language-server.teksilo-fmt-lsp]
command = "teksilo-fmt-lsp"

[[language]]
name = "rust"
language-servers = [{ name = "rust-analyzer" }, { name = "teksilo-fmt-lsp" }]

VS Code

VS Code needs an extension to register an LSP server. See the dedicated walkthrough in teksilo-fmt-vscode.md — it covers two paths: a five-minute Run on Save hook (no LSP) and a small custom extension that registers teksilo-fmt-lsp for Rust documents.

Neovim (nvim-lspconfig)

local configs = require('lspconfig.configs')
configs.teksilo_fmt_lsp = {
  default_config = {
    cmd = { 'teksilo-fmt-lsp' },
    filetypes = { 'rust' },
    root_dir = require('lspconfig.util').root_pattern('Cargo.toml'),
    settings = {},
  },
}
require('lspconfig').teksilo_fmt_lsp.setup{}

Behavior

  • On every textDocument/formatting request, the server runs teksilo_fmt::format_file on the buffer contents and returns either an empty edit list (already canonical) or a single full-document TextEdit (entire buffer replaced with formatted output).
  • A parse error in the host file or in any teksu! body is treated as "leave it alone" — the server returns empty edits, mirroring how rustfmt behaves on save when Rust source is mid-edit.
  • Document sync is full (mode 1): every change resends the whole text. Cheaper than maintaining incremental-diff state for a format-only server.

Architecture

Four crates, layered:

  • teksilo-parse — parser and IR for the teksu! DSL. Extracted from teksilo-macros so non-proc-macro consumers (the formatter, future linters, editor tooling) can build on the same grammar without depending on a proc-macro = true crate. The proc-macro crate now depends on it.
  • teksilo-fmt — pure formatter library. Pretty-printer, byte-range trivia scanner, host-file teksu!-macro visitor, LF/CRLF detection.
  • cargo-teksilo-fmt — CLI binary. File walker, in-place rewriter with atomic writes, --check mode.
  • teksilo-fmt-lsp — LSP server binary. Hand-rolled JSON-RPC over stdio (no tokio); thin wrapper around format_file.

The CLI and LSP have no teksu! grammar knowledge — all parsing and printing happens in the library crates.


Limitations (v1)

  • Closure / multi-line expression bodies inside property values use uniform dedent + reindent. The output is round-trip stable (format(format(x)) == format(x)) but brace alignment inside a closure body may drift one level from what hand-formatting would produce. Hand-fix as needed; the alignment doesn't affect parsing.
  • Empty bodies normalize to bodyless form: Divider { } becomes Divider. The two are semantically identical to the macro; the formatter picks one.
  • ctx => line-break collapse: a ctx => preamble joins to the root element on the same line, even if the user wrote them across lines. Multi-line preambles aren't a documented form anywhere; the formatter standardizes on the joined shape.
  • No configurable rules. Indent width, brace style, and reflow thresholds are fixed. Configurability may come in a later version through FmtConfig.