814 lines
31 KiB
Markdown
814 lines
31 KiB
Markdown
# 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)
|