# Inspiration: Existing Lisp Linters, Formatters & Static Analysers Survey of reference tools in `./refs/` — what they do, how they work, and what we can steal for a Guile linter/formatter. --- ## Table of Contents | Tool | Ecosystem | Type | Language | |------|-----------|------|----------| | [Eastwood](#eastwood) | Clojure | Linter (bug-finder) | Clojure/JVM | | [fmt](#fmt) | Racket | Formatter | Racket | | [Kibit](#kibit) | Clojure | Linter (idiom suggester) | Clojure | | [Mallet](#mallet) | Common Lisp | Linter + formatter + fixer | Common Lisp | | [OCICL Lint](#ocicl-lint) | Common Lisp | Linter + fixer | Common Lisp | | [racket-review](#racket-review) | Racket | Linter | Racket | | [SBLint](#sblint) | Common Lisp (SBCL) | Compiler-driven linter | Common Lisp | --- ## Eastwood **Repo:** `refs/eastwood/` — Clojure linter (v1.4.3, by Jonas Enlund) ### What it does A **bug-finding linter** for Clojure. Focuses on detecting actual errors (wrong arity, undefined vars, misplaced docstrings) rather than enforcing style. Achieves high accuracy by using the same compilation infrastructure as the Clojure compiler itself. ### How it works ``` File discovery (tools.namespace) → Topological sort by :require/:use deps → For each namespace: Parse → Macroexpand → AST (tools.analyzer.jvm) → eval → Run linter functions over AST nodes → Filter warnings by config → Report ``` Key: uses `tools.analyzer.jvm/analyze+eval` — it actually **compiles and evaluates** source code to build an AST. This gives compiler-grade accuracy but means it can only lint code that successfully loads. ### Architecture - **`lint.clj`** — Central coordinator: linter registry, namespace ordering, main analysis loop - **`analyze-ns.clj`** — AST generation via tools.analyzer - **`passes.clj`** — Custom analysis passes (reflection validation, def-name propagation) - **`linters/*.clj`** — Individual linter implementations (~8 files) - **`reporting-callbacks.clj`** — Output formatters (multimethod dispatch) - **`util.clj`** — Config loading, AST walking, warning filtering ### Rules (25+) | Category | Examples | |----------|----------| | Arity | `:wrong-arity` — function called with wrong arg count | | Definitions | `:def-in-def`, `:redefd-vars`, `:misplaced-docstrings` | | Unused | `:unused-private-vars`, `:unused-fn-args`, `:unused-locals`, `:unused-namespaces` | | Suspicious | `:constant-test`, `:suspicious-expression`, `:suspicious-test` | | Style | `:unlimited-use`, `:non-dynamic-earmuffs`, `:local-shadows-var` | | Interop | `:reflection`, `:boxed-math`, `:performance` | | Types | `:wrong-tag`, `:deprecations` | ### Configuration Rules are suppressed via **Clojure code** (not YAML/JSON): ```clojure (disable-warning {:linter :suspicious-expression :for-macro 'clojure.core/let :if-inside-macroexpansion-of #{'clojure.core/when-first} :within-depth 6 :reason "False positive from when-first expansion"}) ``` Builtin config files ship for `clojure.core`, contrib libs, and popular third-party libraries. Users add their own via `:config-files` option. ### What we can learn - **Macroexpansion-aware suppression** — Can distinguish user code from macro-generated code; suppression rules can target specific macro expansions. Critical for any Lisp linter. - **Topological namespace ordering** — Analyse dependencies before dependents. Relevant if we want cross-module analysis. - **Linter registry pattern** — Each linter is a map `{:name :fn :enabled-by-default :url}`. Simple, extensible. - **Warning filtering pipeline** — Raw warnings → handle result → remove ignored faults → remove excluded kinds → filter by config → final warnings. Clean composable chain. - **Metadata preservation through AST transforms** — Custom `postwalk` that preserves metadata. Essential for accurate source locations. --- ## fmt **Repo:** `refs/fmt/` — Racket code formatter (v0.0.3, by Sorawee Porncharoenwase) ### What it does An **extensible code formatter** for Racket. Reads source, reformats according to style conventions using **cost-based optimal layout selection**. Supports custom formatting rules via pluggable formatter maps. ### How it works Clean **4-stage pipeline**: ``` Source string → [1] Tokenize (syntax-color/module-lexer) → [2] Read/Parse → tree of node/atom/wrapper structs → [3] Realign (fix sexp-comments, quotes) → [4] Pretty-print (pretty-expressive library, cost-based) → Formatted string ``` The pretty-printer uses the **Wadler/Leijen optimal layout algorithm** via the `pretty-expressive` library. It evaluates multiple layout alternatives and selects the one with the lowest cost vector. ### Architecture - **`tokenize.rkt`** (72 lines) — Lexer wrapper around Racket's `syntax-color` - **`read.rkt`** (135 lines) — Token stream → tree IR; preserves comments - **`realign.rkt`** (75 lines) — Post-process sexp-comments and quote prefixes - **`conventions.rkt`** (640 lines) — **All formatting rules** for 100+ Racket forms - **`core.rkt`** (167 lines) — `define-pretty` DSL, AST-to-document conversion - **`main.rkt`** (115 lines) — Public API, cost factory, entry point - **`params.rkt`** (38 lines) — Configuration parameters (width, indent, etc.) - **`raco.rkt`** (148 lines) — CLI interface (`raco fmt`) ### Formatting rules (100+) Rules are organised by form type in `conventions.rkt`: | Category | Forms | |----------|-------| | Control flow | `if`, `when`, `unless`, `cond`, `case-lambda` | | Definitions | `define`, `define-syntax`, `lambda`, `define/contract` | | Bindings | `let`, `let*`, `letrec`, `parameterize`, `with-handlers` | | Loops | `for`, `for/list`, `for/fold`, `for/hash` (15+ variants) | | Modules | `module`, `begin`, `class`, `interface` | | Macros | `syntax-rules`, `match`, `syntax-parse`, `syntax-case` | | Imports | `require`, `provide` — vertically stacked | ### Configuration **Pluggable formatter maps** — a function `(string? → procedure?)`: ```racket ;; .fmt.rkt (define (the-formatter-map s) (case s [("my-form") (format-uniform-body/helper 4)] [else #f])) ; delegate to standard ``` Formatter maps compose via `compose-formatter-map` (chain of responsibility). **Runtime parameters:** | Parameter | Default | Purpose | |-----------|---------|---------| | `current-width` | 102 | Page width limit | | `current-limit` | 120 | Computation width limit | | `current-max-blank-lines` | 1 | Max consecutive blank lines | | `current-indent` | 0 | Extra indentation | ### Cost-based layout selection The pretty-printer evaluates layout alternatives using a **3-dimensional cost vector** `[badness, height, characters]`: - **Badness** — Quadratic penalty for exceeding page width - **Height** — Number of lines used - **Characters** — Total character count (tiebreaker) This means the formatter provably selects the **optimal layout** within the configured width, not just the first one that fits. ### What we can learn - **Cost-based layout is the gold standard** for formatter quality. Worth investing in an optimal pretty-printer (Wadler/Leijen family) rather than ad-hoc heuristics. - **Staged pipeline** (tokenize → parse → realign → pretty-print) is clean, testable, and easy to reason about. Each stage has well-defined I/O. - **Form-specific formatting rules** (`define-pretty` DSL) — each Scheme special form gets a dedicated formatter. Extensible via user-provided maps. - **Comment preservation as metadata** — Comments are attached to AST nodes, not discarded. Essential for a practical formatter. - **Pattern-based extraction** — `match/extract` identifies which elements can stay inline vs. must be on separate lines. Smart structural analysis. - **Memoisation via weak hash tables** — Performance optimisation for AST traversal without memory leaks. - **Config file convention** — `.fmt.rkt` in project root, auto-discovered. We should do similar (`.gulie.scm` or similar). --- ## Kibit **Repo:** `refs/kibit/` — Clojure idiom suggester (v0.1.11, by Jonas Enlund) ### What it does A **static code analyser** that identifies non-idiomatic Clojure code and suggests more idiomatic replacements. Example: `(if x y nil)` → `(when x y)`. Supports auto-replacement via `--replace` flag. **Status:** Maintenance mode. Authors recommend **Splint** as successor (faster, more extensible). ### How it works ``` Source file → Parse with edamame (side-effect-free reader) → Extract S-expressions → Tree walk (depth-first via clojure.walk/prewalk) → Match each node against rules (core.logic unification) → Simplify (iterative rewriting until fixpoint) → Report or Replace (via rewrite-clj zippers) ``` The key insight: rules are expressed as **logic programming patterns** using `clojure.core.logic`. Pattern variables (`?x`, `?y`) unify against arbitrary subexpressions. ### Architecture - **`core.clj`** (33 lines) — Core simplification logic (tiny!) - **`check.clj`** (204 lines) — Public API for checking expressions/files - **`check/reader.clj`** (189 lines) — Source parsing with alias tracking - **`rules.clj`** (39 lines) — Rule aggregation and indexing - **`rules/*.clj`** (~153 lines) — Rule definitions by category - **`reporters.clj`** (59 lines) — Output formatters (text, markdown) - **`replace.clj`** (134 lines) — Auto-replacement via rewrite-clj zippers - **`driver.clj`** (144 lines) — CLI entry point, file discovery Total: ~1,105 lines. Remarkably compact. ### Rules (~60) Rules are defined via the `defrules` macro: ```clojure (defrules rules ;; Control structures [(if ?x ?y nil) (when ?x ?y)] [(if ?x nil ?y) (when-not ?x ?y)] [(if (not ?x) ?y ?z) (if-not ?x ?y ?z)] [(do ?x) ?x] ;; Arithmetic [(+ ?x 1) (inc ?x)] [(- ?x 1) (dec ?x)] ;; Collections [(not (empty? ?x)) (seq ?x)] [(into [] ?coll) (vec ?coll)] ;; Equality [(= ?x nil) (nil? ?x)] [(= 0 ?x) (zero? ?x)]) ``` Categories: **control structures**, **arithmetic**, **collections**, **equality**, **miscellaneous** (string ops, Java interop, threading macros). ### Auto-replacement Uses **rewrite-clj zippers** — functional tree navigation that preserves whitespace, comments, and formatting when applying replacements. Navigate to the target node, swap it, regenerate text. ### What we can learn - **Logic programming for pattern matching** is beautifully expressive for "suggest X instead of Y" rules. `core.logic` unification makes patterns concise and bidirectional. We could use Guile's pattern matching or even a miniKanren implementation. - **Rule-as-data pattern** — Rules are just vectors `[pattern replacement]`. Easy to add, easy to test, easy for users to contribute. - **Iterative rewriting to fixpoint** — Apply rules until nothing changes. Catches nested patterns that only become apparent after an inner rewrite. - **Zipper-based source rewriting** — Preserves formatting/comments when applying fixes. Critical for auto-fix functionality. - **Side-effect-free parsing** — Using edamame instead of `clojure.core/read` avoids executing reader macros. Important for security and for analysing code with unknown dependencies. - **Guard-based filtering** — Composable predicates that decide whether to report a suggestion. Users can plug in custom guards. - **Two resolution modes** — `:toplevel` (entire defn) vs `:subform` (individual expressions). Different granularity for different use cases. --- ## Mallet **Repo:** `refs/mallet/` — Common Lisp linter + formatter + fixer (~15,800 LOC) ### What it does A **production-grade linter** for Common Lisp with 40+ rules across 7 categories, auto-fixing, a powerful configuration system (presets with inheritance), and multiple suppression mechanisms. Targets SBCL. ### How it works **Three-phase pipeline:** ``` File content → [1] Tokenize (hand-written tokenizer, preserves all tokens incl. comments) → [2] Parse (Eclector reader with parse-result protocol → forms with precise positions) → [3] Rule checking (text rules, token rules, form rules) → Suppression filtering → Auto-fix & formatting → Report ``` **Critical design decision:** Symbols are stored as **strings**, not interned. This means the parser never needs to resolve packages — safe to analyse code with unknown dependencies. ### Architecture | Module | Lines | Purpose | |--------|-------|---------| | `main.lisp` | ~600 | CLI parsing, entry point | | `engine.lisp` | ~900 | Linting orchestration, suppression filtering | | `config.lisp` | ~1,200 | Config files, presets, path-specific overrides | | `parser/reader.lisp` | ~800 | Eclector integration, position tracking | | `parser/tokenizer.lisp` | ~200 | Hand-written tokenizer | | `suppression.lisp` | ~600 | Suppression state management | | `formatter.lisp` | ~400 | Output formatters (text, JSON, line) | | `fixer.lisp` | ~300 | Auto-fix application | | `rules/` | ~5,500 | 40+ individual rule implementations | ### Rules (40+) | Category | Rules | Examples | |----------|-------|----------| | Correctness | 2 | `ecase` with `otherwise`, missing `otherwise` | | Suspicious | 5 | Runtime `eval`, symbol interning, `ignore-errors` | | Practice | 6 | Avoid `:use` in `defpackage`, one package per file | | Cleanliness | 4 | Unused variables, unused loop vars, unused imports | | Style | 5 | `when`/`unless` vs `if` without else, needless `let*` | | Format | 6 | Line length, trailing whitespace, tabs, blank lines | | Metrics | 3 | Function length, cyclomatic complexity, comment ratio | | ASDF | 8 | Component strings, redundant prefixes, secondary systems | | Naming | 4 | `*special*` and `+constant+` conventions | | Documentation | 4 | Missing docstrings (functions, packages, variables) | Rules are **classes** inheriting from a base `rule` class with generic methods: ```lisp (defclass if-without-else-rule (base:rule) () (:default-initargs :name :missing-else :severity :warning :category :style :type :form)) (defmethod base:check-form ((rule if-without-else-rule) form file) ...) ``` ### Configuration system **Layered presets with inheritance:** ```lisp (:mallet-config (:extends :strict) (:ignore "**/vendor/**") (:enable :cyclomatic-complexity :max 15) (:disable :function-length) (:set-severity :metrics :info) (:for-paths ("tests") (:enable :line-length :max 120) (:disable :unused-variables))) ``` Built-in presets: `:default`, `:strict`, `:all`, `:none`. Precedence: CLI flags > config file > preset inheritance > built-in defaults. ### Suppression mechanisms (3 levels) 1. **Declarations** — `#+mallet (declaim (mallet:suppress-next :rule-name))` 2. **Inline comments** — `; mallet:suppress rule-name` 3. **Region-based** — `; mallet:disable rule-name` / `; mallet:enable rule-name` 4. **Stale suppression detection** — Warns when suppressions don't match any violation ### Auto-fix Fixes are collected, sorted bottom-to-top (to preserve line numbers), and applied in a single pass. Fix types: `:replace-line`, `:delete-range`, `:delete-lines`, `:replace-form`. ### What we can learn - **Symbols as strings** is a crucial insight for Lisp linters. Avoids package/module resolution entirely. We should do the same for Guile — parse symbols without interning them. - **Eclector-style parse-result protocol** — Every sub-expression gets precise line/column info. Invest in this early; it's the foundation of accurate error reporting. - **Three rule types** (text, token, form) — Clean separation. Text rules don't need parsing, token rules don't need a full AST, form rules get the full tree. Efficient and composable. - **Preset inheritance with path-specific overrides** — Powerful configuration that scales from solo projects to monorepos. `:for-paths` is particularly useful (different rules for `src/` vs `tests/`). - **Multiple suppression mechanisms** — Comment-based, declaration-based, region-based. Users need all three for real-world use. - **Stale suppression detection** — Prevents suppression comments from accumulating after the underlying issue is fixed. Brilliant. - **Rule metaclass pattern** — Base class + generic methods scales cleanly to 40+ rules. Each rule is self-contained with its own severity, category, and check method. - **Bottom-to-top fix application** — Simple trick that avoids line number invalidation when applying multiple fixes to the same file. --- ## OCICL Lint **Repo:** `refs/ocicl/` — Common Lisp linter (part of the OCICL package manager) ### What it does A **129-rule linter with auto-fix** for Common Lisp, integrated into the OCICL package manager as a subcommand (`ocicl lint`). Supports dry-run mode, per-line suppression, and `.ocicl-lint.conf` configuration. ### How it works **Three-pass analysis:** ``` File content → [Pass 1] Line-based rules (text-level: whitespace, tabs, line length) → [Pass 2] AST-based rules (via rewrite-cl zippers: naming, bindings, packages) → [Pass 3] Single-pass visitor rules (pattern matching: 50+ checks in one traversal) → Suppression filtering (per-line ; lint:suppress comments) → Auto-fix (via fixer registry) → Report ``` ### Architecture ``` lint/ ├── linter.lisp — Main orchestrator, issue aggregation, output formatting ├── config.lisp — .ocicl-lint.conf parsing ├── parsing.lisp — rewrite-cl wrapper (zipper API) ├── fixer.lisp — Auto-fix infrastructure with RCS/backup support ├── main.lisp — CLI entry point ├── rules/ │ ├── line-based.lisp — Text-level rules (9 rules) │ ├── ast.lisp — AST-based rules (naming, lambda lists, bindings) │ └── single-pass.lisp — Pattern matching rules (50+ in one walk) └── fixes/ ├── whitespace.lisp — Formatting fixes └── style.lisp — Style rule fixes ``` ### Rules (129) | Category | Count | Examples | |----------|-------|---------| | Formatting | 9 | Trailing whitespace, tabs, line length, blank lines | | File structure | 3 | SPDX headers, package declarations, reader errors | | Naming | 6 | Underscores, `*special*` style, `+constant+` style, vague names | | Boolean/conditionals | 18 | `(IF test T NIL)` → `test`, `(WHEN (NOT x) ...)` → `(UNLESS x ...)` | | Logic simplification | 12 | Flatten nested `AND`/`OR`, redundant conditions | | Arithmetic | 4 | `(+ x 1)` → `(1+ x)`, `(= x 0)` → `(zerop x)` | | List operations | 13 | `FIRST`/`REST` vs `CAR`/`CDR`, `(cons x nil)` → `(list x)` | | Comparison | 5 | `EQL` vs `EQ`, string equality, membership testing | | Sequence operations | 6 | `-IF-NOT` variants, `ASSOC` patterns | | Advanced/safety | 26 | Library suggestions, destructive ops on constants | ### Configuration INI-style `.ocicl-lint.conf`: ```ini max-line-length = 180 suppress-rules = rule1, rule2, rule3 suggest-libraries = alexandria, uiop, serapeum ``` Per-line suppression: ```lisp (some-code) ; lint:suppress rule-name1 rule-name2 (other-code) ; lint:suppress ;; suppress ALL rules on this line ``` ### Fixer registry ```lisp (register-fixer "rule-name" #'fixer-function) ``` Fixers are decoupled from rule detection. Each fixer takes `(content issue)` and returns modified content or NIL. Supports RCS backup before modification. ### What we can learn - **Single-pass visitor for pattern rules** — 50+ pattern checks in one tree traversal. Much faster than running each rule separately. Good model for performance-sensitive linting. - **Quote awareness** — Detects quoted contexts (`'x`, `quote`, backtick) to avoid false positives inside macro templates. We'll need the same for Guile. - **Fixer registry pattern** — Decouples detection from fixing. Easy to add auto-fix for a rule without touching the rule itself. - **Library suggestion rules** — "You could use `(alexandria:when-let ...)` instead of this pattern." Interesting category that could work for Guile (SRFI suggestions, etc.). - **Three-pass architecture** — Line-based first (fastest, no parsing needed), then AST, then pattern matching. Each pass adds cost; skip what you don't need. --- ## racket-review **Repo:** `refs/racket-review/` — Racket linter (v0.2, by Bogdan Popa) ### What it does A **surface-level linter** for Racket modules. Intentionally does NOT expand macros — analyses syntax only, optimised for **speed**. Designed for tight editor integration (ships with Flycheck for Emacs). ### How it works ``` File → read-syntax (Racket's built-in reader) → Validate as module form (#lang) → Walk syntax tree via syntax-parse → Track scopes, bindings, provides, usages → Report problems ``` The entire rule system is built on Racket's `syntax/parse` — pattern matching on syntax objects with guard conditions and side effects. ### Architecture Remarkably compact: | File | Lines | Purpose | |------|-------|---------| | `lint.rkt` | 1,130 | **All linting rules** + semantic tracking | | `problem.rkt` | 26 | Problem data structure | | `cli.rkt` | 25 | CLI interface | | `ext.rkt` | 59 | Extension mechanism | ### Semantic tracking Maintains multiple **parameter-based state machines**: - **Scope stack** — Hierarchical scope with parent links, binding hash at each level - **Binding info** — Per-identifier: syntax object, usage count, check flag, related identifiers - **Provide tracking** — What's explicitly `provide`d vs `all-defined-out` - **Punted bindings** — Forward references resolved when definition is encountered - **Savepoints** — Save/restore state for tentative matching in complex patterns ### Rules **Errors (23 patterns):** - Identifier already defined in same scope - `if` missing else branch - `let`/`for` missing body - `case` clauses not quoted literals - Wrong match fallthrough pattern (`_` not `else`) - Provided but not defined **Warnings (17+ patterns):** - Identifier never used - Brackets: `let` bindings should use `[]`, not `()` - Requires not sorted (for-syntax first, then alphabetical) - Cond without else clause - Nested if (flatten to cond) - `racket/contract` → use `racket/contract/base` ### Suppression ```racket #|review: ignore|# ;; Ignore entire file ;; noqa ;; Ignore this line ;; review: ignore ;; Ignore this line ``` ### Extension mechanism Plugins register via Racket's package system: ```racket (define review-exts '((module-path predicate-proc lint-proc))) ``` Extensions receive a `current-reviewer` parameter with API: `recur`, `track-error!`, `track-warning!`, `track-binding!`, `push-scope!`, `pop-scope!`, `save!`, `undo!`. ### What we can learn - **Surface-level analysis is fast and useful** — No macro expansion means instant feedback. Catches the majority of real mistakes. Good default for editor integration; deeper analysis can be opt-in. - **syntax-parse as rule DSL** — Pattern matching on syntax objects is a natural fit for Lisp linters. Guile has `syntax-case` and `match` which serve a similar role. - **Scope tracking with punted bindings** — Handles forward references in a single pass. Elegant solution for `letrec`-style bindings and mutual recursion. - **Savepoints for tentative matching** — Save/restore state when the parser enters a complex branch. If the branch fails, roll back. Useful for `cond`, `match`, etc. - **Plugin API via reviewer parameter** — Extensions get a well-defined API surface. Clean contract between core and plugins. - **Snapshot-based testing** — 134 test files with `.rkt`/`.rkt.out` pairs. Lint a file, compare output to expected. Simple, maintainable, high coverage. - **Bracket style enforcement** — Racket uses `[]` for bindings, `()` for application. Guile doesn't have this, but we could enforce consistent bracket usage or other parenthesis conventions. --- ## SBLint **Repo:** `refs/sblint/` — SBCL compiler-driven linter (~650 LOC) ### What it does A **compiler-assisted linter** for Common Lisp. Doesn't implement its own rules — instead, it **compiles code through SBCL** and surfaces all compiler diagnostics (errors, warnings, style notes) with proper file locations. ### How it works ``` Source code → Resolve ASDF dependencies (topological sort) → Load dependencies via Quicklisp → Compile project via SBCL (handler-bind captures conditions) → Extract file/position from compiler internals (Swank protocol) → Convert byte offset → line:column → Deduplicate and report ``` No custom parser. No AST. Just the compiler. ### Architecture | File | Lines | Purpose | |------|-------|---------| | `run-lint.lisp` | 277 | Core logic: lint file/system/directory | | `compiler-aux.lisp` | 33 | SBCL introspection bridge | | `asdf.lisp` | 153 | Dependency resolution graph | | `file-position.lisp` | 18 | Byte offset → line:column conversion | | `quicklisp.lisp` | 41 | Auto-install missing dependencies | | `sblint.ros` | — | CLI entry point (Roswell script) | ### What it catches Whatever SBCL catches: - Undefined variables and functions - Type mismatches (with SBCL's type inference) - Style warnings (ANSI compliance, naming) - Reader/syntax errors - Dead code paths - Unused declarations Filters out: redefinition warnings, Quicklisp dependency warnings, SBCL contrib warnings. ### What we can learn - **Leverage the host compiler** — Guile itself has `compile` and can produce warnings. We should capture Guile's own compiler diagnostics (undefined variables, unused imports, etc.) as a baseline — it's "free" accuracy. - **Condition-based error collection** — CL's condition system (≈ Guile's exception/handler system) lets you catch errors without stopping compilation. `handler-bind` continues execution after catching. Guile's `with-exception-handler` can do the same. - **Dependency-aware compilation** — Load dependencies first, then compile project. Catches "symbol not found" errors that surface-level analysis misses. - **Deduplication** — Multiple compilation passes can report the same issue. Hash table dedup is simple and effective. - **Minimal is viable** — 650 LOC total. A compiler-driven linter layer could be our first deliverable, augmented with custom rules later. --- ## Cross-cutting themes ### Parsing strategies | Strategy | Used by | Pros | Cons | |----------|---------|------|------| | Host compiler | SBLint, Eastwood | Maximum accuracy, type checking | Requires loading code, slow | | Custom reader with positions | Mallet, fmt | Full control, no side effects | Must maintain parser | | Language's built-in reader | racket-review | Free, well-tested | May lack position info | | Side-effect-free reader lib | Kibit (edamame) | Safe, preserves metadata | External dependency | | Zipper-based AST | OCICL (rewrite-cl) | Preserves formatting for fixes | Complex API | **For Guile:** We should explore whether `(ice-9 read)` or Guile's reader provides sufficient source location info. If not, a custom reader (or a reader wrapper that annotates with positions) is needed. Guile's `read-syntax` (if available) or source properties on read forms could be the answer. ### Rule definition patterns | Pattern | Used by | Character | |---------|---------|-----------| | Logic programming (unification) | Kibit | Elegant, concise; slow | | OOP classes + generic methods | Mallet | Scales well, self-contained rules | | Registry maps | Eastwood | Simple, data-driven | | Syntax-parse patterns | racket-review, fmt | Natural for Lisps | | Single-pass visitor | OCICL | High performance | | Compiler conditions | SBLint | Zero-effort, limited scope | **For Guile:** A combination seems right — `match`/`syntax-case` patterns for the rule DSL (natural in Scheme), with a registry for rule metadata (name, severity, category, enabled-by-default). ### Configuration patterns | Feature | Mallet | OCICL | Eastwood | Kibit | racket-review | fmt | |---------|--------|-------|----------|-------|---------------|-----| | Config file | `.mallet.lisp` | `.ocicl-lint.conf` | Clojure maps | `project.clj` | - | `.fmt.rkt` | | Presets | Yes (4) | - | - | - | - | - | | Preset inheritance | Yes | - | - | - | - | - | | Path-specific rules | Yes | - | - | - | - | - | | Inline suppression | Yes (3 mechanisms) | Yes | Yes | - | Yes | - | | Stale suppression detection | Yes | - | - | - | - | - | | CLI override | Yes | Yes | Yes | Yes | - | Yes | **For Guile:** Mallet's configuration system is the most sophisticated and worth emulating — presets, inheritance, path-specific overrides, and stale suppression detection. ### Auto-fix patterns | Tool | Fix mechanism | Preserves formatting? | |------|--------------|----------------------| | Kibit | rewrite-clj zippers | Yes | | Mallet | Bottom-to-top line replacement | Partial | | OCICL | Fixer registry + zipper AST | Yes | **For Guile:** Zipper-based AST manipulation (or Guile's SXML tools) for formatting-preserving fixes. The fixer registry pattern (OCICL) keeps rule detection and fixing decoupled. ### Output formats All tools support at minimum: `file:line:column: severity: message` Additional formats: JSON (Mallet), Markdown (Kibit), line-only for CI (Mallet). --- ## Feature wishlist for gulie Based on this survey, the features worth cherry-picking: ### Must-have (core) 1. **Guile compiler diagnostics** — Capture Guile's own warnings as baseline (SBLint approach) 2. **Custom reader with source positions** — Every form, subform, and token gets line:column 3. **Staged pipeline** — Text rules → token rules → form rules (Mallet/OCICL) 4. **Pattern-based rule DSL** — Using Guile's `match` or `syntax-case` (Kibit/racket-review inspiration) 5. **Rule registry** — `{name, severity, category, enabled-by-default, check-fn}` (Eastwood) 6. **Standard output format** — `file:line:column: severity: rule: message` 7. **Inline suppression** — `; gulie:suppress rule-name` (Mallet/OCICL) ### Should-have (v1) 8. **Config file** — `.gulie.scm` with presets and rule enable/disable (Mallet) 9. **Auto-fix infrastructure** — Fixer registry, bottom-to-top application (OCICL/Mallet) 10. **Idiom suggestions** — Pattern → replacement rules (Kibit style) 11. **Unused binding detection** — Scope tracking with forward reference handling (racket-review) 12. **Quote/unquote awareness** — Don't lint inside quoted forms (OCICL) 13. **Snapshot-based testing** — `.scm`/`.expected` pairs (racket-review) ### Nice-to-have (v2+) 14. **Code formatter** — Cost-based optimal layout (fmt) 15. **Pluggable formatter maps** — Per-form formatting rules (fmt) 16. **Path-specific rule overrides** — Different rules for `src/` vs `tests/` (Mallet) 17. **Stale suppression detection** (Mallet) 18. **Editor integration** — Flycheck/flymake for Emacs (racket-review) 19. **Macroexpansion-aware analysis** — Suppress false positives from macro output (Eastwood) 20. **Cyclomatic complexity and other metrics** (Mallet)