Files
gulie/docs/INSPIRATION.md
2026-04-01 23:35:50 +02:00

31 KiB

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 Clojure Linter (bug-finder) Clojure/JVM
fmt Racket Formatter Racket
Kibit Clojure Linter (idiom suggester) Clojure
Mallet Common Lisp Linter + formatter + fixer Common Lisp
OCICL Lint Common Lisp Linter + fixer Common Lisp
racket-review Racket Linter Racket
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):

(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?):

;; .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 extractionmatch/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:

(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:

(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:

(: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:

max-line-length = 180
suppress-rules = rule1, rule2, rule3
suggest-libraries = alexandria, uiop, serapeum

Per-line suppression:

(some-code) ; lint:suppress rule-name1 rule-name2
(other-code) ; lint:suppress  ;; suppress ALL rules on this line

Fixer registry

(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 provided 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

#|review: ignore|#   ;; Ignore entire file
;; noqa              ;; Ignore this line
;; review: ignore    ;; Ignore this line

Extension mechanism

Plugins register via Racket's package system:

(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 formatfile:line:column: severity: rule: message
  7. Inline suppression; gulie:suppress rule-name (Mallet/OCICL)

Should-have (v1)

  1. Config file.gulie.scm with presets and rule enable/disable (Mallet)
  2. Auto-fix infrastructure — Fixer registry, bottom-to-top application (OCICL/Mallet)
  3. Idiom suggestions — Pattern → replacement rules (Kibit style)
  4. Unused binding detection — Scope tracking with forward reference handling (racket-review)
  5. Quote/unquote awareness — Don't lint inside quoted forms (OCICL)
  6. Snapshot-based testing.scm/.expected pairs (racket-review)

Nice-to-have (v2+)

  1. Code formatter — Cost-based optimal layout (fmt)
  2. Pluggable formatter maps — Per-form formatting rules (fmt)
  3. Path-specific rule overrides — Different rules for src/ vs tests/ (Mallet)
  4. Stale suppression detection (Mallet)
  5. Editor integration — Flycheck/flymake for Emacs (racket-review)
  6. Macroexpansion-aware analysis — Suppress false positives from macro output (Eastwood)
  7. Cyclomatic complexity and other metrics (Mallet)