Compare commits

...

12 Commits

Author SHA1 Message Date
f085882a4a Clean up compiler self-lint warnings 2026-04-04 16:42:51 +02:00
8488cab0ac Make surface rules token-aware 2026-04-04 16:11:46 +02:00
bd89fb476e Improve diagnostics and Guile corpus handling 2026-04-04 15:54:51 +02:00
6dc717186e Expand test suite: 84 → 139 tests covering all bugfixes
New test files:
- test-config.scm (15 tests): find-config directory walking,
  load-config with explicit/missing/auto-discovery paths,
  merge-configs override semantics, generate-template pretty-print
  output validity.

- test-engine.scm (40 tests): filter-rules-by-config with
  enable/disable/combined, severity>=? helper, lint-file with
  --pass surface (no compile errors leak), severity filtering
  (info excluded at warning level), rule disabling via config.

  Fix mode tests: trailing-whitespace fix, comment-semicolons
  fix (single ; → ;;), no-tabs fix (8-space expansion),
  tab-stop alignment, blank-lines deletion, fix composition
  (tabs + trailing whitespace on same line), idempotency
  (second pass changes nothing), multi-pass convergence
  (3-way conflict: tabs + trailing + comment on same line
  converges in 2 passes).

  Compile-error formatting: no raw ~S/~A format specifiers
  in error messages, module name present in output.

  Integration: lint-files with %fix mode applies fixes and
  returns 0 unfixed count.
2026-04-04 14:14:30 +02:00
78f3f7e6d3 Auto-fix all surface issues: tabs, blank-lines, fix composition
New auto-fixes:
- no-tabs: expand tabs to 8-space tab stops, also trim trailing
  whitespace to avoid conflicts with trailing-whitespace fix
- blank-lines: delete excess blank lines via delete-line fix type.
  First excess line emits a warning; subsequent excess lines emit
  info-level 'blank-lines-fixup' diagnostics (hidden at --severity
  warning) with delete-line fixes.

Fix engine improvements:
- apply-fixes-to-file now supports delete-line (sentinel-based skip)
- When multiple replace-line fixes target the same line (e.g. both
  trailing-whitespace and no-tabs), the replacement with the greatest
  edit distance from the original is chosen, avoiding conflicts where
  one fix would overwrite another.

Result on Guix tree (1336 files):
  gulie --pass surface --config .gulie.sexp --fix refs/guix/
  → Fixed 2007 issues.
  → 0 remaining warnings.
  → Single pass, idempotent, files parse correctly.
2026-04-04 14:08:44 +02:00
412814ff72 Implement --fix mode and add Guix-tuned config
--fix mode:
- apply-fixes-to-file collects fixable diagnostics, applies
  replace-line fixes bottom-up via a vector, writes file back
- lint-files in fix mode applies fixes then reports only unfixed
  diagnostics; prints fix count to stderr
- Idempotent: second run finds nothing to fix

Fix records added:
- trailing-whitespace already had fix records
- comment-semicolons now produces fix records for both
  single-; on own line (→ ;;) and ;;;+ inline (→ ;;)

Guix config (refs/guix/.gulie.sexp):
- Surface pass only (semantic pass useless without Guix on load path)
- line-length disabled (URLs, hashes, descriptions)
- Remaining rules: trailing-whitespace, no-tabs, comment-semicolons,
  blank-lines

On Guix tree: 2005 findings → 594 after --fix (1411 auto-fixed).
Remaining: 523 no-tabs (real tabs) + 71 blank-lines (real).
2026-04-04 14:02:18 +02:00
f5d5919943 Parallelize file linting with n-par-map
Use Guile's n-par-map from (ice-9 threads) to lint files in parallel,
one thread per CPU core. Results are collected per-file then output
sequentially to maintain deterministic ordering.

Performance on Guix tree (1336 files, 12 cores):

  Before (sequential)     After (parallel)
  full:    21s             full:    14s   (1.5x)
  surface: 15s             surface:  8s   (1.9x)
  line:    14s             line:     7s   (2.0x)
2026-04-04 13:51:25 +02:00
1734ebe381 Skip tokenizer/CST parsing when no CST rules are active
Check filter-rules-by-config before invoking the tokenizer in
lint-file. When all CST rules are disabled (e.g. --disable
comment-semicolons), the expensive tokenize+parse-cst pass is
skipped entirely.

On the Guix tree (1336 files), this saves ~2s on surface-only
runs (15.6s → 13.7s). The savings are more pronounced on
projects with fewer monster files.
2026-04-04 13:44:31 +02:00
f979d48e54 Fix 8 bugs found by running gulie on the Guix source tree
Bug 1: Config auto-discovery — load-config now walks CWD and parent
directories for .gulie.sexp when no --config is given, matching the
documented behavior. Added find-config helper.

Bug 2: Compile-error messages garbled — the catch handler now properly
destructures Guile exception args (subr fmt fmt-args . _) and uses
apply+format instead of raw display, producing readable messages like
"no code for module (guix licenses)" instead of
"#f no code for module ~S ((guix licenses)) #f".

Bug 3: --init template unreadable — generate-template now uses
pretty-print instead of write, producing properly indented output.

Bug 4: CLI options silently ignored — --pass, --disable, --rule, and
--severity are now wired up in main and threaded into config as %pass,
augmented disable list, enable list, and %min-severity respectively.

Bug 5: enable/disable config keys never consulted — added
filter-rules-by-config, used in run-line-rules and run-cst-rules.

Bug 6: blank-lines rule spammed one diagnostic per excess line —
changed trigger to fire once per group at the boundary.

Bug 7: read-file-to-string slow — replaced line-by-line accumulation
with get-string-all.

Bug 8: No way to skip semantic pass — lint-file now respects %pass
config key (surface/semantic/all) and %min-severity for filtering.
2026-04-04 13:30:48 +02:00
e3e28a911a docs: License 2026-04-02 00:26:05 +02:00
f512e0aced style: Dog fooding 2026-04-02 00:20:24 +02:00
98fbba03cb build: Line length default 2026-04-02 00:20:09 +02:00
18 changed files with 1378 additions and 168 deletions

4
.gitignore vendored
View File

@@ -10,5 +10,9 @@ devenv.local.yaml
# pre-commit
.pre-commit-config.yaml
# Agents
/.pi/
# Temporary
.gulie.sexp
/refs/

25
LICENSE Normal file
View File

@@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2026, Steffen Beyer <steffen@beyer.io>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,4 +1,4 @@
# gulie
# Gulie the GUile LInt Exorcist
A linter, static analyser, and formatter for [Guile Scheme](https://www.gnu.org/software/guile/).

View File

@@ -7,7 +7,6 @@
#:use-module (gulie config)
#:use-module (gulie engine)
#:use-module (gulie rule)
#:use-module (gulie diagnostic)
#:export (main))
(define version "0.1.0")
@@ -75,7 +74,6 @@
0)
((option-ref options 'list-rules #f)
;; Ensure rules are loaded
(list-all-rules)
0)
@@ -94,6 +92,42 @@
(let* ((config-path (option-ref options 'config #f))
(user-config (load-config config-path))
(config (merge-configs default-config user-config))
;; Wire up --fix
(config (if (option-ref options 'fix #f)
(cons '(%fix . #t) config)
config))
;; Wire up --pass
(pass-str (option-ref options 'pass #f))
(config (if pass-str
(cons (cons '%pass (string->symbol pass-str)) config)
config))
;; Wire up --disable (add to existing disable list)
(cli-disable (option-ref options 'disable #f))
(config (if cli-disable
(let* ((current (or (assq-ref config 'disable) '()))
(new (cons (string->symbol cli-disable) current)))
(cons (cons 'disable new) config))
config))
;; Wire up --rule (single-rule mode: set enable to just that rule)
(cli-rule (option-ref options 'rule #f))
(config (if cli-rule
(cons (cons 'enable (list (string->symbol cli-rule)))
config)
config))
;; Wire up --severity
(cli-severity (option-ref options 'severity #f))
(config (if cli-severity
(cons (cons '%min-severity
(string->symbol cli-severity))
config)
config))
;; Wire up --output
(cli-output (option-ref options 'output #f))
(config (if cli-output
(cons (cons '%output
(string->symbol cli-output))
config)
config))
(paths (if (null? rest) (list ".") rest))
(ignore-pats (config-ignore-patterns config))
(files (discover-scheme-files paths ignore-pats)))

View File

@@ -6,9 +6,7 @@
(define-module (gulie compiler)
#:use-module (system base compile)
#:use-module (system base message)
#:use-module (ice-9 regex)
#:use-module (ice-9 match)
#:use-module (srfi srfi-1)
#:use-module (gulie diagnostic)
#:export (compile-and-capture-warnings))
@@ -18,21 +16,30 @@
(define *warning-re*
(make-regexp "^;;; ([^:]+):([0-9]+):([0-9]+): warning: (.+)$"))
(define (same-file? a b)
(or (string=? a b)
(let ((ca (false-if-exception (canonicalize-path a)))
(cb (false-if-exception (canonicalize-path b))))
(and ca cb (string=? ca cb)))))
(define (parse-warning-line text file)
"Parse a warning line from Guile's compiler output into a <diagnostic>."
"Parse a warning line from Guile's compiler output into a <diagnostic>.
Filters out warnings attributed to files other than FILE."
(let ((m (regexp-exec *warning-re* text)))
(if m
(let ((wfile (match:substring m 1))
(wline (string->number (match:substring m 2)))
(wcol (string->number (match:substring m 3)))
(wmsg (match:substring m 4)))
(make-diagnostic
(if (string=? wfile "<unknown-location>") file wfile)
wline wcol
'warning
(classify-warning wmsg)
wmsg
#f))
(let* ((wfile (match:substring m 1))
(wline (string->number (match:substring m 2)))
(wcol (string->number (match:substring m 3)))
(wmsg (match:substring m 4))
(resolved-file (if (string=? wfile "<unknown-location>") file wfile)))
(and (same-file? resolved-file file)
(make-diagnostic
resolved-file
wline wcol
'warning
(classify-warning wmsg)
wmsg
#f)))
#f)))
(define (classify-warning msg)
@@ -52,6 +59,91 @@
((string-contains msg "cannot be meaningfully") 'bad-case-datum)
(else 'compiler-warning)))
(define (display-raw-exception-args port args)
(for-each (lambda (a)
(display a port)
(display " " port))
args))
(define (format-exception-message key args)
"Format compiler exception KEY and ARGS into a readable string."
(call-with-output-string
(lambda (p)
(display key p)
(display ": " p)
(let ((subr (and (pair? args) (car args)))
(fmt (and (pair? args)
(pair? (cdr args))
(cadr args)))
(fmt-args (and (pair? args)
(pair? (cdr args))
(pair? (cddr args))
(caddr args))))
(catch #t
(lambda ()
(if fmt
(begin
(when subr
(display subr p)
(display ": " p))
(if fmt-args
(apply format p fmt
(if (list? fmt-args)
fmt-args
(list fmt-args)))
(display fmt p)))
(display-raw-exception-args p args)))
(lambda _
;; Fallback: just display the raw args
(display-raw-exception-args p args)))))))
(define (source-location-ref loc key . default)
(let ((pair (and (list? loc) (assq key loc))))
(if pair
(cdr pair)
(if (null? default) #f (car default)))))
(define (find-source-location args)
(find (lambda (arg)
(and (list? arg)
(every pair? arg)
(or (assq 'line arg)
(assq 'column arg)
(assq 'filename arg))))
args))
(define (extract-location-from-args args file)
(let ((loc (find-source-location args)))
(and loc
(let* ((loc-file (or (source-location-ref loc 'filename #f) file))
(loc-line (source-location-ref loc 'line #f))
(loc-col (source-location-ref loc 'column #f)))
(and (same-file? loc-file file)
(list loc-file
(if (number? loc-line) (1+ loc-line) 1)
(if (number? loc-col) (max 0 loc-col) 0)))))))
(define (extract-location-from-message msg file)
(let* ((anchor (string-append file ":"))
(pos (string-contains msg anchor)))
(and pos
(let* ((located (substring msg pos))
(m (regexp-exec (make-regexp "^([^:]+):([0-9]+):([0-9]+): (.+)$")
located)))
(and m
(let* ((loc-file (match:substring m 1))
(resolved-file (if (string=? loc-file "<unknown-location>")
file
loc-file))
(loc-line (string->number (match:substring m 2)))
(loc-col-1 (string->number (match:substring m 3)))
(clean-msg (match:substring m 4)))
(and (same-file? resolved-file file)
(list resolved-file
loc-line
(max 0 (1- loc-col-1))
clean-msg))))))))
(define (compile-and-capture-warnings file text config)
"Compile TEXT (as if from FILE) and capture all compiler warnings.
Returns a list of <diagnostic> records."
@@ -69,15 +161,19 @@ Returns a list of <diagnostic> records."
#:warning-level 3
#:env (make-fresh-user-module))))
(lambda (key . args)
;; Compilation errors become diagnostics too
(let ((msg (call-with-output-string
(lambda (p)
(display key p)
(display ": " p)
(for-each (lambda (a) (display a p) (display " " p))
args)))))
(let* ((raw-msg (format-exception-message key args))
(located-msg (extract-location-from-message raw-msg file))
(located-args (extract-location-from-args args file))
(location (or located-args located-msg))
(diag-file (if location (car location) file))
(diag-line (if location (cadr location) 1))
(diag-col (if location (caddr location) 0))
(msg (if (and located-msg (= (length located-msg) 4))
(list-ref located-msg 3)
raw-msg)))
(set! diagnostics
(cons (make-diagnostic file 1 0 'error 'compile-error msg #f)
(cons (make-diagnostic diag-file diag-line diag-col
'error 'compile-error msg #f)
diagnostics))))))
;; Parse captured warnings
(let* ((output (get-output-string warning-output))

View File

@@ -4,9 +4,10 @@
;;; merges with built-in defaults, and provides config accessors.
(define-module (gulie config)
#:use-module (ice-9 rdelim)
#:use-module (srfi srfi-1)
#:use-module (ice-9 pretty-print)
#:export (default-config
find-config
load-config
merge-configs
config-ref
@@ -20,7 +21,7 @@
generate-template))
(define default-config
'((line-length . 80)
'((line-length . 118)
(indent . 2)
(max-blank-lines . 2)
(enable . ())
@@ -71,7 +72,7 @@
(if (null? default) #f (car default)))))
(define (config-line-length config)
(or (config-ref config 'line-length) 80))
(or (config-ref config 'line-length) 118))
(define (config-indent-width config)
(or (config-ref config 'indent) 2))
@@ -91,14 +92,25 @@
(define (config-indent-rules config)
(or (config-ref config 'indent-rules) '()))
(define (find-config start-dir)
"Search START-DIR and its ancestors for .gulie.sexp. Returns the
path if found, or #f."
(let lp ((dir start-dir))
(let ((candidate (string-append dir "/.gulie.sexp")))
(cond
((file-exists? candidate) candidate)
((string=? dir "/") #f)
(else (lp (dirname dir)))))))
(define (load-config path)
"Load a .gulie.sexp config file at PATH. Returns an alist."
(if (and path (file-exists? path))
(call-with-input-file path
(lambda (port)
(let ((data (read port)))
(if (list? data) data '()))))
'()))
"Load a .gulie.sexp config file at PATH. Returns an alist.
If PATH is #f, auto-discover by searching CWD and parent directories."
(let ((resolved (or path (find-config (getcwd)))))
(if (and resolved (file-exists? resolved))
(call-with-input-file resolved
(lambda (port)
(let ((data (read port)))
(if (list? data) data '()))))
'())))
(define (merge-configs base override)
"Merge OVERRIDE config on top of BASE. Override wins for scalar values;
@@ -111,19 +123,10 @@ lists are replaced, not appended."
(lp (assq-set! (list-copy result) (car pair) (cdr pair))
(cdr pairs))))))
(define (find-config-file start-dir)
"Search upward from START-DIR for .gulie.sexp. Returns path or #f."
(let lp ((dir start-dir))
(let ((candidate (string-append dir "/.gulie.sexp")))
(cond
((file-exists? candidate) candidate)
((string=? dir "/") #f)
(else (lp (dirname dir)))))))
(define (generate-template port)
"Write a template .gulie.sexp to PORT."
(display ";;; gulie configuration\n" port)
(display ";;; Place this file as .gulie.sexp in your project root.\n" port)
(display ";;; All fields are optional — defaults are shown below.\n\n" port)
(write default-config port)
(pretty-print default-config port #:width 60 #:max-expr-width 50)
(newline port))

View File

@@ -5,7 +5,6 @@
(define-module (gulie cst)
#:use-module (srfi srfi-9)
#:use-module (srfi srfi-1)
#:use-module (srfi srfi-11)
#:use-module (gulie tokenizer)
#:export (<cst-node>

View File

@@ -83,9 +83,116 @@
(diagnostic-rule diag)
(diagnostic-message diag)))
(define (format-diagnostics diags port)
"Write all diagnostics to PORT, sorted by location."
(for-each (lambda (d)
(display (format-diagnostic d) port)
(newline port))
(sort diags diagnostic<?)))
(define (normalize-output-format output-format)
(cond
((or (not output-format)
(eq? output-format 'standard)
(and (string? output-format)
(string=? output-format "standard")))
'standard)
((or (eq? output-format 'compact)
(and (string? output-format)
(string=? output-format "compact")))
'compact)
((or (eq? output-format 'json)
(and (string? output-format)
(string=? output-format "json")))
'json)
(else 'standard)))
(define (format-diagnostic-compact diag)
(format #f "~a:~a:~a: ~a: ~a"
(diagnostic-file diag)
(diagnostic-line diag)
(diagnostic-column diag)
(diagnostic-rule diag)
(diagnostic-message diag)))
(define (write-json-string port str)
(display #\" port)
(string-for-each
(lambda (ch)
(cond
((char=? ch #\\) (display "\\\\" port))
((char=? ch #\") (display "\\\"" port))
((char=? ch #\newline) (display "\\n" port))
((char=? ch #\return) (display "\\r" port))
((char=? ch #\tab) (display "\\t" port))
((char=? ch #\page) (display "\\f" port))
((char=? ch #\backspace) (display "\\b" port))
((< (char->integer ch) 32)
(format port "\\u~4,'0x" (char->integer ch)))
(else (display ch port))))
str)
(display #\" port))
(define (write-json-fix port fix)
(if fix
(begin
(display "{" port)
(display "\"type\":" port)
(write-json-string port (symbol->string (fix-type fix)))
(display ",\"line\":" port)
(display (fix-line fix) port)
(display ",\"column\":" port)
(display (fix-column fix) port)
(display ",\"endLine\":" port)
(display (fix-end-line fix) port)
(display ",\"endColumn\":" port)
(display (fix-end-column fix) port)
(display ",\"replacement\":" port)
(if (fix-replacement fix)
(write-json-string port (fix-replacement fix))
(display "null" port))
(display "}" port))
(display "null" port)))
(define (write-json-diagnostic port diag)
(display "{" port)
(display "\"file\":" port)
(write-json-string port (diagnostic-file diag))
(display ",\"line\":" port)
(display (diagnostic-line diag) port)
(display ",\"column\":" port)
(display (diagnostic-column diag) port)
(display ",\"severity\":" port)
(write-json-string port (severity->string (diagnostic-severity diag)))
(display ",\"rule\":" port)
(write-json-string port (symbol->string (diagnostic-rule diag)))
(display ",\"message\":" port)
(write-json-string port (diagnostic-message diag))
(display ",\"fix\":" port)
(write-json-fix port (diagnostic-fix diag))
(display "}" port))
(define (format-diagnostics diags port . maybe-format)
"Write all diagnostics to PORT.
OUTPUT-FORMAT may be standard, compact, or json."
(let* ((output-format (normalize-output-format
(if (null? maybe-format)
'standard
(car maybe-format))))
(sorted (sort diags diagnostic<?)))
(case output-format
((json)
(display "[" port)
(let lp ((remaining sorted) (first? #t))
(when (pair? remaining)
(if first?
(write-json-diagnostic port (car remaining))
(begin
(display "," port)
(write-json-diagnostic port (car remaining))))
(lp (cdr remaining) #f)))
(display "]" port)
(newline port))
((compact)
(for-each (lambda (d)
(display (format-diagnostic-compact d) port)
(newline port))
sorted))
(else
(for-each (lambda (d)
(display (format-diagnostic d) port)
(newline port))
sorted)))))

View File

@@ -6,12 +6,12 @@
(define-module (gulie engine)
#:use-module (ice-9 ftw)
#:use-module (ice-9 rdelim)
#:use-module (ice-9 regex)
#:use-module (ice-9 textual-ports)
#:use-module (ice-9 threads)
#:use-module (srfi srfi-1)
#:use-module (gulie tokenizer)
#:use-module (gulie diagnostic)
#:use-module (gulie rule)
#:use-module (gulie config)
#:use-module (gulie suppression)
#:export (lint-file
lint-files
@@ -19,30 +19,145 @@
(define (read-file-to-string path)
"Read entire file at PATH into a string."
(call-with-input-file path
(lambda (port)
(let lp ((acc '()))
(let ((line (read-line port)))
(if (eof-object? line)
(string-join (reverse acc) "\n")
(lp (cons line acc))))))))
(call-with-input-file path get-string-all))
(define (run-line-rules file lines config)
(define (severity-rank sev)
"Return a numeric rank for severity: error=3, warning=2, info=1."
(case sev
((error) 3)
((warning) 2)
((info) 1)
(else 0)))
(define (severity>=? sev min-sev)
"Is SEV at least as severe as MIN-SEV?"
(>= (severity-rank sev) (severity-rank min-sev)))
(define (rule-family-name rule-name)
"Map internal fixup rule names back to their user-facing family name."
(let* ((name (symbol->string rule-name))
(suffix "-fixup")
(suffix-len (string-length suffix))
(name-len (string-length name)))
(if (and (>= name-len suffix-len)
(string=? (substring name (- name-len suffix-len)) suffix))
(string->symbol (substring name 0 (- name-len suffix-len)))
rule-name)))
(define (filter-rules-by-config rules config)
"Filter RULES according to enable/disable lists in CONFIG.
If enable is non-empty, only those rules are kept.
Then any rules in disable are removed."
(let ((enabled (or (assq-ref config 'enable) '()))
(disabled (or (assq-ref config 'disable) '())))
(let ((rules (if (null? enabled)
rules
(filter (lambda (r)
(memq (rule-name r) enabled))
rules))))
(if (null? disabled)
rules
(filter (lambda (r)
(not (memq (rule-name r) disabled)))
rules)))))
(define (filter-diagnostics-by-config diagnostics config)
"Filter DIAGNOSTICS by config enable/disable lists.
This applies to semantic diagnostics as well as surface diagnostics."
(let ((enabled (or (assq-ref config 'enable) '()))
(disabled (or (assq-ref config 'disable) '())))
(let ((diagnostics (if (null? enabled)
diagnostics
(filter (lambda (d)
(memq (rule-family-name (diagnostic-rule d))
enabled))
diagnostics))))
(if (null? disabled)
diagnostics
(filter (lambda (d)
(not (memq (rule-family-name (diagnostic-rule d))
disabled)))
diagnostics)))))
(define (page-break-line? line-text)
"Is LINE-TEXT a form-feed page separator line?"
(and (> (string-length line-text) 0)
(let lp ((i 0) (saw-page? #f))
(if (= i (string-length line-text))
saw-page?
(let ((ch (string-ref line-text i)))
(cond
((char=? ch #\page)
(lp (1+ i) #t))
((or (char=? ch #\space)
(char=? ch #\tab))
(lp (1+ i) saw-page?))
(else #f)))))))
(define (surface-blank-line? line-text)
"Should LINE-TEXT count as a blank line for surface rules?"
(and (or (string-null? line-text)
(string-every char-whitespace? line-text))
(not (page-break-line? line-text))))
(define (build-line-token-segments tokens line-count)
"Build a vector mapping line numbers to token segments.
Each segment is a list: (TYPE START-COL END-COL TEXT)."
(let ((segments (make-vector (1+ line-count) '())))
(define (add-segment! line type start end text)
(when (and (<= 1 line line-count)
(< start end))
(vector-set! segments line
(cons (list type start end text)
(vector-ref segments line)))))
(for-each
(lambda (tok)
(let ((type (token-type tok))
(text (token-text tok)))
(let ((len (string-length text)))
(let lp ((i 0)
(line (token-line tok))
(col (token-column tok))
(segment-start-i 0)
(segment-start-col (token-column tok)))
(if (= i len)
(when (< segment-start-i i)
(add-segment! line type segment-start-col col
(substring text segment-start-i i)))
(let ((ch (string-ref text i)))
(if (char=? ch #\newline)
(begin
(when (< segment-start-i i)
(add-segment! line type segment-start-col col
(substring text segment-start-i i)))
(lp (1+ i) (1+ line) 0 (1+ i) 0))
(lp (1+ i) line (1+ col)
segment-start-i segment-start-col))))))))
tokens)
(let lp ((i 1))
(when (<= i line-count)
(vector-set! segments i (reverse (vector-ref segments i)))
(lp (1+ i))))
segments))
(define (run-line-rules file lines config line-token-segments)
"Run all line-type rules against LINES. Returns list of diagnostics."
(let ((line-rules (rules-of-type 'line))
(let ((line-rules (filter-rules-by-config (rules-of-type 'line) config))
(diagnostics '())
(consecutive-blanks 0))
(when (not (null? line-rules))
(let lp ((remaining lines) (line-num 1))
(when (not (null? remaining))
(let* ((line-text (car remaining))
(is-blank (or (string-null? line-text)
(string-every char-whitespace? line-text))))
(is-blank (surface-blank-line? line-text))
(line-segments (if (and line-token-segments
(< line-num (vector-length line-token-segments)))
(vector-ref line-token-segments line-num)
'())))
(set! consecutive-blanks
(if is-blank (1+ consecutive-blanks) 0))
(let ((augmented-config
(cons (cons '%consecutive-blanks consecutive-blanks)
config)))
(cons (cons '%line-token-segments line-segments)
(cons (cons '%consecutive-blanks consecutive-blanks)
config))))
(for-each
(lambda (rule)
(let ((results ((rule-check-proc rule)
@@ -54,7 +169,7 @@
(define (run-cst-rules file cst config)
"Run all cst-type rules against CST. Returns list of diagnostics."
(let ((cst-rules (rules-of-type 'cst)))
(let ((cst-rules (filter-rules-by-config (rules-of-type 'cst) config)))
(if (null? cst-rules)
'()
(append-map
@@ -63,47 +178,195 @@
cst-rules))))
(define (lint-file file config)
"Lint a single FILE with CONFIG. Returns a sorted list of diagnostics."
"Lint a single FILE with CONFIG. Returns a sorted list of diagnostics.
CONFIG may contain '%pass with value 'surface, 'semantic, or 'all (default)
and '%min-severity with value 'error, 'warning, or 'info (default)."
(let* ((text (read-file-to-string file))
(lines (string-split text #\newline))
(pass (or (assq-ref config '%pass) 'all))
(min-severity (or (assq-ref config '%min-severity) 'info))
(tok-mod (and (memq pass '(all surface))
(resolve-module '(gulie tokenizer) #:ensure #f)))
(tokenize (and tok-mod (module-ref tok-mod 'tokenize)))
(tokens (and tokenize (tokenize text file)))
(line-token-segments (and tokens
(build-line-token-segments tokens (length lines))))
(diagnostics '()))
;; Pass 1: line-based surface rules
(set! diagnostics (append (run-line-rules file lines config)
diagnostics))
;; Pass 1b: CST rules (if tokenizer is loaded)
;; Dynamically check if tokenizer module is available
(let ((tok-mod (resolve-module '(gulie tokenizer) #:ensure #f)))
(when tok-mod
(let ((tokenize (module-ref tok-mod 'tokenize))
(cst-mod (resolve-module '(gulie cst) #:ensure #f)))
(when cst-mod
(let* ((parse-cst (module-ref cst-mod 'parse-cst))
(tokens (tokenize text file))
(cst (parse-cst tokens)))
(set! diagnostics (append (run-cst-rules file cst config)
diagnostics)))))))
(when (memq pass '(all surface))
(set! diagnostics (append (run-line-rules file lines config line-token-segments)
diagnostics))
;; Pass 1b: CST rules — only parse if there are active CST rules
(let ((cst-rules (filter-rules-by-config (rules-of-type 'cst) config)))
(when (and (not (null? cst-rules)) tokens)
(let ((cst-mod (resolve-module '(gulie cst) #:ensure #f)))
(when cst-mod
(let* ((parse-cst (module-ref cst-mod 'parse-cst))
(cst (parse-cst tokens)))
(set! diagnostics
(append (run-cst-rules file cst config)
diagnostics))))))))
;; Pass 2: semantic rules (if compiler module is loaded)
(let ((comp-mod (resolve-module '(gulie compiler) #:ensure #f)))
(when comp-mod
(let ((compile-and-capture (module-ref comp-mod 'compile-and-capture-warnings)))
(set! diagnostics (append (compile-and-capture file text config)
diagnostics)))))
(when (memq pass '(all semantic))
(let ((comp-mod (resolve-module '(gulie compiler) #:ensure #f)))
(when comp-mod
(let ((compile-and-capture (module-ref comp-mod 'compile-and-capture-warnings)))
(set! diagnostics (append (compile-and-capture file text config)
diagnostics))))))
;; Filter suppressions
(let ((suppressions (parse-suppressions text)))
(set! diagnostics (filter-suppressions diagnostics suppressions)))
;; Filter by enable/disable config at the diagnostic level too,
;; so semantic diagnostics respect rule selection.
(set! diagnostics (filter-diagnostics-by-config diagnostics config))
;; Filter by minimum severity
(when (not (eq? min-severity 'info))
(set! diagnostics
(filter (lambda (d)
(severity>=? (diagnostic-severity d) min-severity))
diagnostics)))
;; Sort by location
(sort diagnostics diagnostic<?)))
(define (apply-fixes-to-file file diagnostics)
"Apply all fixable diagnostics to FILE. Returns count of fixes applied.
Supports fix types: replace-line, delete-line.
When multiple replace-line fixes target the same line, the replacement
with the greatest edit distance from the original is chosen."
(let* ((fixable (filter diagnostic-fix diagnostics))
(fixes (map (lambda (d) (cons (diagnostic-line d) (diagnostic-fix d)))
fixable))
(deleted (cons 'deleted '()))) ;; unique sentinel
(if (null? fixes)
0
(let* ((text (read-file-to-string file))
(lines (list->vector (string-split text #\newline)))
(count 0))
;; Group replace-line fixes by line number; for each line keep
;; the replacement that differs most from the original line.
;; delete-line fixes are kept as-is.
(let* ((sorted (sort fixes (lambda (a b) (> (car a) (car b)))))
(deduped
(let lp ((rest sorted) (acc '()) (seen (list)))
(if (null? rest)
(reverse acc)
(let* ((pair (car rest))
(ln (car pair))
(fix (cdr pair)))
(cond
;; delete-line always kept
((eq? (fix-type fix) 'delete-line)
(lp (cdr rest) (cons pair acc) seen))
;; replace-line: skip if we already have a
;; replace for this line
((memv ln seen)
(lp (cdr rest) acc seen))
(else
;; Find the best replacement for this line
;; among all fixes targeting it
(let* ((idx (1- ln))
(orig (if (and (>= idx 0)
(< idx (vector-length lines)))
(vector-ref lines idx)
""))
(same-line (filter (lambda (p)
(and (= (car p) ln)
(eq? (fix-type (cdr p))
'replace-line)))
sorted))
;; Pick the replacement most different
;; from the original (largest diff count)
(best (let pick ((candidates same-line)
(best-fix fix)
(best-diff 0))
(if (null? candidates)
best-fix
(let* ((f (cdar candidates))
(r (fix-replacement f))
(d (abs (- (string-length orig)
(string-length r)))))
(if (> d best-diff)
(pick (cdr candidates) f d)
(pick (cdr candidates)
best-fix best-diff)))))))
(lp (cdr rest)
(cons (cons ln best) acc)
(cons ln seen))))))))))
;; Apply deduplicated fixes
(for-each
(lambda (fix-pair)
(let* ((line-num (car fix-pair))
(fix (cdr fix-pair))
(idx (1- line-num)))
(when (and (>= idx 0) (< idx (vector-length lines))
(not (eq? (vector-ref lines idx) deleted)))
(case (fix-type fix)
((replace-line)
(vector-set! lines idx (fix-replacement fix))
(set! count (1+ count)))
((delete-line)
(vector-set! lines idx deleted)
(set! count (1+ count)))))))
deduped))
;; Write back, skipping deleted lines
(when (> count 0)
(call-with-output-file file
(lambda (port)
(let ((len (vector-length lines))
(first #t))
(let lp ((i 0))
(when (< i len)
(let ((line (vector-ref lines i)))
(unless (eq? line deleted)
(unless first (newline port))
(display line port)
(set! first #f)))
(lp (1+ i))))))))
count))))
(define (parallel-safe-pass? pass)
"Can PASS be linted safely in parallel?
The semantic pass uses compiler state and warning ports that are not
currently thread-safe, so only surface-only runs use parallel workers."
(eq? pass 'surface))
(define (lint-files files config)
"Lint multiple FILES. Returns total diagnostic count."
(let ((total 0))
"Lint multiple FILES. Returns total diagnostic count.
Surface-only runs use n-par-map; semantic runs are processed sequentially
for deterministic compiler diagnostics.
When '%fix is #t in config, apply auto-fixes and report unfixed."
(let* ((pass (or (assq-ref config '%pass) 'all))
(output-format (or (assq-ref config '%output) 'standard))
(fix-mode? (assq-ref config '%fix))
(lint-one (lambda (file)
(cons file (lint-file file config))))
(results (if (parallel-safe-pass? pass)
(let ((ncpus (max 1 (total-processor-count))))
(n-par-map ncpus lint-one files))
(map lint-one files)))
(reported-diags '())
(total-diags 0)
(total-fixed 0))
(for-each
(lambda (file)
(let ((diags (lint-file file config)))
(set! total (+ total (length diags)))
(format-diagnostics diags (current-output-port))))
files)
total))
(lambda (result)
(let* ((file (car result))
(diags (cdr result)))
(if fix-mode?
;; In fix mode: apply fixes, then report only unfixed diagnostics
(let* ((fixed-count (apply-fixes-to-file file diags))
(unfixed (filter (lambda (d) (not (diagnostic-fix d))) diags)))
(set! total-fixed (+ total-fixed fixed-count))
(set! total-diags (+ total-diags (length unfixed)))
(set! reported-diags (append unfixed reported-diags)))
;; Normal mode: report everything
(begin
(set! total-diags (+ total-diags (length diags)))
(set! reported-diags (append diags reported-diags))))))
results)
(format-diagnostics reported-diags (current-output-port) output-format)
(when (and fix-mode? (> total-fixed 0))
(format (current-error-port) "Fixed ~a issue~a.~%"
total-fixed (if (= total-fixed 1) "" "s")))
total-diags))
(define (scheme-file? path)
"Is PATH a Scheme source file?"

View File

@@ -7,6 +7,7 @@
;;; ;;;; — file headers
(define-module (gulie rules comments)
#:use-module (srfi srfi-1)
#:use-module (gulie rule)
#:use-module (gulie diagnostic))
@@ -26,46 +27,75 @@
((char-whitespace? (string-ref line-text i)) (lp (1+ i)))
(else #f))))
(define (line-comment-segment config)
(find (lambda (seg)
(eq? (car seg) 'line-comment))
(or (assq-ref config '%line-token-segments) '())))
(register-rule!
(make-rule
'comment-semicolons
"Check comment semicolon count follows conventions"
'info 'style 'line
(lambda (file line-num line-text config)
(let ((pos (string-index line-text #\;)))
(if (not pos)
'()
;; Check if the semicolon is inside a string (rough heuristic:
;; count quotes before the semicolon position)
(let ((quotes-before (let lp ((i 0) (count 0) (in-escape #f))
(cond
((>= i pos) count)
((and (not in-escape) (char=? (string-ref line-text i) #\\))
(lp (1+ i) count #t))
((and (not in-escape) (char=? (string-ref line-text i) #\"))
(lp (1+ i) (1+ count) #f))
(else (lp (1+ i) count #f))))))
(if (odd? quotes-before)
;; Inside a string — not a real comment
(define (check-comment pos comment-text)
(let* ((semis (count-semicolons comment-text))
(own-line? (comment-only-line? line-text pos)))
(cond
;; Inline comment (after code) should use single ;
;; But we don't enforce this strictly — just flag ;;; or more inline
((and (not own-line?) (>= semis 3))
(let ((fixed (string-append
(substring line-text 0 pos)
";;"
(substring line-text (+ pos semis)))))
(list (make-diagnostic
file line-num pos
'info 'comment-semicolons
"inline comments should use ; or ;; not ;;;"
(make-fix 'replace-line line-num 0
line-num (string-length line-text)
fixed)))))
;; Own-line comment with single ; (should be ;;)
((and own-line? (= semis 1) (> (string-length comment-text) 1)
(not (char=? (string-ref comment-text 1) #\!)))
(let ((fixed (string-append
(substring line-text 0 pos)
";;"
(substring line-text (1+ pos)))))
(list (make-diagnostic
file line-num pos
'info 'comment-semicolons
"line comments should use ;; not ;"
(make-fix 'replace-line line-num 0
line-num (string-length line-text)
fixed)))))
(else '()))))
(let* ((segments (or (assq-ref config '%line-token-segments) '()))
(segment (line-comment-segment config))
(pos (and segment (cadr segment)))
(comment-text (and segment (list-ref segment 3))))
(cond
(segment
(check-comment pos comment-text))
((pair? segments)
'())
(else
;; Fallback when token context is unavailable.
(let ((pos (string-index line-text #\;)))
(if (not pos)
'()
(let* ((semis (count-semicolons (substring line-text pos)))
(own-line? (comment-only-line? line-text pos)))
(cond
;; Inline comment (after code) should use single ;
;; But we don't enforce this strictly — just flag ;;; or more inline
((and (not own-line?) (>= semis 3))
(list (make-diagnostic
file line-num pos
'info 'comment-semicolons
"inline comments should use ; or ;; not ;;;"
#f)))
;; Own-line comment with single ; (should be ;;)
((and own-line? (= semis 1) (> (string-length line-text) (1+ pos))
(not (char=? (string-ref line-text (1+ pos)) #\!)))
(list (make-diagnostic
file line-num pos
'info 'comment-semicolons
"line comments should use ;; not ;"
#f)))
(else '()))))))))
;; Check if the semicolon is inside a string (rough heuristic:
;; count quotes before the semicolon position)
(let ((quotes-before (let lp ((i 0) (count 0) (in-escape #f))
(cond
((>= i pos) count)
((and (not in-escape) (char=? (string-ref line-text i) #\\))
(lp (1+ i) count #t))
((and (not in-escape) (char=? (string-ref line-text i) #\"))
(lp (1+ i) (1+ count) #f))
(else (lp (1+ i) count #f))))))
(if (odd? quotes-before)
'()
(check-comment pos (substring line-text pos))))))))))
#f))

View File

@@ -4,9 +4,113 @@
;;; just the file path, line number, and line content.
(define-module (gulie rules surface)
#:use-module (srfi srfi-1)
#:use-module (gulie rule)
#:use-module (gulie diagnostic))
(define (page-break-line? line-text)
"Is LINE-TEXT a form-feed page separator line?"
(and (> (string-length line-text) 0)
(let lp ((i 0) (saw-page? #f))
(if (= i (string-length line-text))
saw-page?
(let ((ch (string-ref line-text i)))
(cond
((char=? ch #\page)
(lp (1+ i) #t))
((or (char=? ch #\space)
(char=? ch #\tab))
(lp (1+ i) saw-page?))
(else #f)))))))
(define (surface-blank-line? line-text)
"Should LINE-TEXT count as a blank line for surface rules?"
(and (or (string-null? line-text)
(string-every char-whitespace? line-text))
(not (page-break-line? line-text))))
(define (advance-column col ch)
(if (char=? ch #\tab)
(+ col (- 8 (modulo col 8)))
(1+ col)))
(define (line-token-segments config)
(or (assq-ref config '%line-token-segments) '()))
(define (segment-type seg) (list-ref seg 0))
(define (segment-start seg) (list-ref seg 1))
(define (segment-end seg) (list-ref seg 2))
(define (segment-covers-column? seg col)
(and (<= (segment-start seg) col)
(< col (segment-end seg))))
(define (excluded-tab-segment? seg)
(memq (segment-type seg) '(string line-comment block-comment)))
(define (tab-in-excluded-segment? col segments)
(any (lambda (seg)
(and (excluded-tab-segment? seg)
(segment-covers-column? seg col)))
segments))
(define (find-tab-in-code-fallback line-text)
"Return the column of the first tab outside strings and comments, or #f."
(let ((len (string-length line-text)))
(let lp ((i 0) (in-string? #f) (escaped? #f))
(if (= i len)
#f
(let ((ch (string-ref line-text i)))
(cond
(in-string?
(cond
(escaped?
(lp (1+ i) #t #f))
((char=? ch #\\)
(lp (1+ i) #t #t))
((char=? ch #\")
(lp (1+ i) #f #f))
(else
(lp (1+ i) #t #f))))
((char=? ch #\;)
#f)
((char=? ch #\")
(lp (1+ i) #t #f))
((char=? ch #\tab)
i)
(else
(lp (1+ i) #f #f))))))))
(define (find-tab-in-code line-text config)
(let ((segments (line-token-segments config)))
(if (null? segments)
(find-tab-in-code-fallback line-text)
(let lp ((i 0))
(cond
((= i (string-length line-text)) #f)
((and (char=? (string-ref line-text i) #\tab)
(not (tab-in-excluded-segment? i segments)))
i)
(else (lp (1+ i))))))))
(define (expand-tabs-in-code line-text config)
"Expand tabs outside strings and comments, preserving other tabs as-is."
(let ((segments (line-token-segments config))
(len (string-length line-text)))
(let lp ((i 0) (col 0) (acc '()))
(if (= i len)
(list->string (reverse acc))
(let ((ch (string-ref line-text i)))
(cond
((and (char=? ch #\tab)
(not (tab-in-excluded-segment? i segments)))
(let ((spaces (- 8 (modulo col 8))))
(lp (1+ i) (+ col spaces)
(append (make-list spaces #\space) acc))))
(else
(lp (1+ i) (advance-column col ch)
(cons ch acc)))))))))
;;; trailing-whitespace — trailing spaces or tabs at end of line
(register-rule!
@@ -15,24 +119,26 @@
"Line has trailing whitespace"
'warning 'format 'line
(lambda (file line-num line-text config)
(let ((trimmed (string-trim-right line-text)))
(if (and (not (string=? line-text trimmed))
(> (string-length line-text) 0))
(list (make-diagnostic
file line-num
(string-length trimmed)
'warning 'trailing-whitespace
"trailing whitespace"
(make-fix 'replace-line line-num 0
line-num (string-length line-text)
trimmed)))
'())))
(if (page-break-line? line-text)
'()
(let ((trimmed (string-trim-right line-text)))
(if (and (not (string=? line-text trimmed))
(> (string-length line-text) 0))
(list (make-diagnostic
file line-num
(string-length trimmed)
'warning 'trailing-whitespace
"trailing whitespace"
(make-fix 'replace-line line-num 0
line-num (string-length line-text)
trimmed)))
'()))))
#f))
;;; line-length — line exceeds maximum width
(define (config-max-line-length config)
(or (assq-ref config 'line-length) 80))
(or (assq-ref config 'line-length) 118))
(register-rule!
(make-rule
@@ -59,13 +165,16 @@
"Tab character found in source"
'warning 'format 'line
(lambda (file line-num line-text config)
(let ((pos (string-index line-text #\tab)))
(let ((pos (find-tab-in-code line-text config)))
(if pos
(list (make-diagnostic
file line-num pos
'warning 'no-tabs
"tab character found; use spaces for indentation"
#f))
(let ((fixed (string-trim-right (expand-tabs-in-code line-text config))))
(list (make-diagnostic
file line-num pos
'warning 'no-tabs
"tab character found; use spaces for indentation"
(make-fix 'replace-line line-num 0
line-num (string-length line-text)
fixed))))
'())))
#f))
@@ -82,12 +191,22 @@
;; the blank-line counting logic.
(let ((max-blanks (or (assq-ref config 'max-blank-lines) 2))
(consecutive (or (assq-ref config '%consecutive-blanks) 0)))
(if (and (string-every char-whitespace? line-text)
(> consecutive max-blanks))
(list (make-diagnostic
file line-num 0
'warning 'blank-lines
(format #f "more than ~a consecutive blank lines" max-blanks)
#f))
'())))
(cond
;; First excess blank line: emit warning + delete fix
((and (surface-blank-line? line-text)
(= consecutive (1+ max-blanks)))
(list (make-diagnostic
file (- line-num max-blanks) 0
'warning 'blank-lines
(format #f "more than ~a consecutive blank lines" max-blanks)
(make-fix 'delete-line line-num 0 line-num 0 ""))))
;; Subsequent excess blanks: fix-only (not displayed)
((and (surface-blank-line? line-text)
(> consecutive (1+ max-blanks)))
(list (make-diagnostic
file line-num 0
'info 'blank-lines-fixup
"excess blank line"
(make-fix 'delete-line line-num 0 line-num 0 ""))))
(else '()))))
#f))

View File

@@ -6,7 +6,6 @@
(define-module (gulie suppression)
#:use-module (ice-9 regex)
#:use-module (ice-9 rdelim)
#:use-module (srfi srfi-1)
#:use-module (gulie diagnostic)
#:export (parse-suppressions
@@ -42,8 +41,7 @@
(define (parse-suppressions text)
"Parse suppression directives from source TEXT.
Returns a list of (line rules kind) entries."
(let ((lines (string-split text #\newline))
(result '()))
(let ((lines (string-split text #\newline)))
(let lp ((lines lines) (line-num 1) (acc '()))
(if (null? lines)
(reverse acc)

View File

@@ -31,4 +31,7 @@
(load-test "test-cst.scm")
(load-test "test-rules.scm")
(load-test "test-suppression.scm")
(load-test "test-compiler.scm"))
(load-test "test-compiler.scm")
(load-test "test-config.scm")
(load-test "test-engine.scm")
(load-test "test-cli.scm"))

38
test/test-cli.scm Normal file
View File

@@ -0,0 +1,38 @@
;;; Tests for (gulie cli)
(use-modules (srfi srfi-64)
(ice-9 textual-ports)
(gulie cli)
(gulie rules surface)
(gulie rules comments))
(test-begin "cli")
(test-group "output-json"
(let ((tmp (string-append (tmpnam) ".scm"))
(cfg (string-append (tmpnam) ".sexp")))
(call-with-output-file tmp
(lambda (p) (display "(define x 42) \n" p)))
(call-with-output-file cfg
(lambda (p) (display "()\n" p)))
(let* ((code #f)
(output (call-with-output-string
(lambda (port)
(parameterize ((current-output-port port))
(set! code
(main (list "gulie"
"--pass" "surface"
"--config" cfg
"--output" "json"
tmp))))))))
(test-equal "exit code reports findings" 1 code)
(test-assert "json array output"
(string-prefix? "[" output))
(test-assert "contains json rule"
(string-contains output "\"rule\":\"trailing-whitespace\""))
(test-assert "does not use standard formatter"
(not (string-contains output ": warning: trailing-whitespace:"))))
(delete-file tmp)
(delete-file cfg)))
(test-end "cli")

View File

@@ -47,10 +47,29 @@
(test-group "syntax-error"
(let ((diags (compile-and-capture-warnings
"test.scm"
"(define (foo x) (+ x"
"(define (foo x)\n (+ x"
'())))
(test-assert "catches syntax error"
(any (lambda (d) (eq? (diagnostic-severity d) 'error))
diags))))
diags))
(let ((err (find (lambda (d) (eq? (diagnostic-severity d) 'error))
diags)))
(test-assert "read error has useful line"
(> (diagnostic-line err) 1))
(test-assert "read error has useful message"
(not (string-contains (diagnostic-message err) "test.scm:"))))))
(test-group "syntax-error-source-location"
(let* ((diags (compile-and-capture-warnings
"test.scm"
"(define-syntax foo\n (syntax-rules (...)\n ((_ x) x)))\n"
'()))
(err (find (lambda (d) (eq? (diagnostic-severity d) 'error))
diags)))
(test-assert "has compile error" err)
(test-equal "syntax error line from source props" 2
(diagnostic-line err))
(test-equal "syntax error column from source props" 2
(diagnostic-column err))))
(test-end "compiler")

99
test/test-config.scm Normal file
View File

@@ -0,0 +1,99 @@
;;; Tests for (gulie config) — config loading, auto-discovery, template
(use-modules (srfi srfi-64)
(ice-9 textual-ports)
(gulie config))
(test-begin "config")
;;; find-config — walk up directories
(test-group "find-config"
;; Create a temp directory tree with a config in the grandparent
(let* ((tmp (tmpnam))
(child (string-append tmp "/a/b/c")))
(system* "mkdir" "-p" child)
(call-with-output-file (string-append tmp "/.gulie.sexp")
(lambda (p) (display "((line-length . 42))" p)))
(test-equal "finds config in ancestor"
(string-append tmp "/.gulie.sexp")
(find-config child))
(test-equal "finds config in same dir"
(string-append tmp "/.gulie.sexp")
(find-config tmp))
;; No config above /tmp
(let ((no-config (string-append tmp "/a/b")))
;; Config is in tmp, so searching from tmp/a/b should still find it
(test-assert "finds config from intermediate dir"
(string? (find-config no-config))))
;; Clean up
(system* "rm" "-rf" tmp)))
(test-group "find-config-returns-false"
;; A directory guaranteed to have no .gulie.sexp above it
(let ((tmp (tmpnam)))
(system* "mkdir" "-p" tmp)
(test-equal "returns #f when no config found"
#f
(find-config tmp))
(system* "rm" "-rf" tmp)))
;;; load-config — explicit path and auto-discovery
(test-group "load-config-explicit-path"
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display "((line-length . 99) (indent . 4))" p)))
(let ((cfg (load-config tmp)))
(test-equal "reads line-length" 99 (assq-ref cfg 'line-length))
(test-equal "reads indent" 4 (assq-ref cfg 'indent)))
(delete-file tmp)))
(test-group "load-config-missing-path"
(test-equal "missing explicit path returns empty"
'()
(load-config "/nonexistent/path/.gulie.sexp")))
(test-group "load-config-auto-discovery"
;; When path is #f, load-config should auto-discover
;; We can't easily test this without controlling CWD, so just
;; verify it doesn't crash with #f
(test-assert "does not crash with #f path"
(list? (load-config #f))))
;;; merge-configs
(test-group "merge-configs"
(let ((base '((line-length . 80) (indent . 2) (enable)))
(override '((line-length . 120) (enable foo bar))))
(let ((merged (merge-configs base override)))
(test-equal "override scalar" 120 (assq-ref merged 'line-length))
(test-equal "base value preserved" 2 (assq-ref merged 'indent))
(test-equal "override list" '(foo bar) (assq-ref merged 'enable)))))
;;; generate-template
(test-group "generate-template"
(let* ((output (call-with-output-string generate-template))
(lines (string-split output #\newline)))
(test-assert "starts with comment"
(string-prefix? ";;;" (car lines)))
(test-assert "contains multiple lines (pretty-printed)"
(> (length lines) 5))
;; Should be valid sexp — skip comment lines, parse the rest
(let ((sexp-text (call-with-output-string
(lambda (p)
(for-each (lambda (line)
(unless (string-prefix? ";;;" line)
(display line p)
(newline p)))
lines)))))
(let ((data (call-with-input-string sexp-text read)))
(test-assert "output is valid alist" (list? data))
(test-assert "contains line-length" (assq 'line-length data))))))
(test-end "config")

351
test/test-engine.scm Normal file
View File

@@ -0,0 +1,351 @@
;;; Tests for (gulie engine) — rule filtering, pass control, severity, fix mode
(use-modules (srfi srfi-64)
(srfi srfi-1)
(ice-9 textual-ports)
(gulie engine)
(gulie rule)
(gulie config)
(gulie diagnostic)
(gulie rules surface)
(gulie rules comments))
(test-begin "engine")
;;; filter-rules-by-config
(test-group "filter-rules-by-config"
(let ((all-rules (all-rules)))
;; With empty enable/disable, all rules pass through
(test-equal "empty config keeps all rules"
(length all-rules)
(length ((@@ (gulie engine) filter-rules-by-config)
all-rules '())))
;; Enable only one rule
(let ((filtered ((@@ (gulie engine) filter-rules-by-config)
all-rules '((enable trailing-whitespace)))))
(test-equal "enable filters to one" 1 (length filtered))
(test-eq "correct rule kept" 'trailing-whitespace
(rule-name (car filtered))))
;; Disable one rule
(let* ((before (length all-rules))
(filtered ((@@ (gulie engine) filter-rules-by-config)
all-rules '((disable trailing-whitespace)))))
(test-equal "disable removes one" (1- before) (length filtered))
(test-assert "disabled rule absent"
(not (find (lambda (r) (eq? (rule-name r) 'trailing-whitespace))
filtered))))
;; Enable + disable
(let ((filtered ((@@ (gulie engine) filter-rules-by-config)
all-rules '((enable trailing-whitespace no-tabs)
(disable no-tabs)))))
(test-equal "enable then disable" 1 (length filtered))
(test-eq "only non-disabled remains" 'trailing-whitespace
(rule-name (car filtered))))))
;;; severity filtering
(test-group "severity-helpers"
(test-assert "error >= warning"
((@@ (gulie engine) severity>=?) 'error 'warning))
(test-assert "warning >= warning"
((@@ (gulie engine) severity>=?) 'warning 'warning))
(test-assert "warning >= info"
((@@ (gulie engine) severity>=?) 'warning 'info))
(test-assert "info not >= warning"
(not ((@@ (gulie engine) severity>=?) 'info 'warning))))
(test-group "lint-files-execution-strategy"
(test-assert "surface pass can run in parallel"
((@@ (gulie engine) parallel-safe-pass?) 'surface))
(test-assert "semantic pass runs sequentially"
(not ((@@ (gulie engine) parallel-safe-pass?) 'semantic)))
(test-assert "all pass runs sequentially"
(not ((@@ (gulie engine) parallel-safe-pass?) 'all))))
;;; lint-file with pass control
(test-group "lint-file-pass-surface"
;; Create a temp file with issues
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display "(define x 42) \n" p))) ;; trailing whitespace
(let ((diags (lint-file tmp '((%pass . surface)))))
(test-assert "finds trailing whitespace"
(any (lambda (d) (eq? (diagnostic-rule d) 'trailing-whitespace))
diags))
;; Should NOT have compile-error since semantic pass is skipped
(test-assert "no compile errors in surface mode"
(not (any (lambda (d) (eq? (diagnostic-rule d) 'compile-error))
diags))))
(delete-file tmp)))
(test-group "lint-file-severity-filter"
(let ((tmp (tmpnam)))
;; Single ; on own line (info) + trailing whitespace (warning)
(call-with-output-file tmp
(lambda (p) (display " ; comment \n" p)))
(let ((all-diags (lint-file tmp '((%pass . surface))))
(warn-diags (lint-file tmp '((%pass . surface)
(%min-severity . warning)))))
(test-assert "all includes info"
(any (lambda (d) (eq? (diagnostic-severity d) 'info)) all-diags))
(test-assert "warning filter excludes info"
(not (any (lambda (d) (eq? (diagnostic-severity d) 'info))
warn-diags)))
(test-assert "warning filter keeps warnings"
(any (lambda (d) (eq? (diagnostic-severity d) 'warning))
warn-diags)))
(delete-file tmp)))
(test-group "lint-file-disable-rule"
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display "(define x 42) \n" p)))
(let ((diags (lint-file tmp '((%pass . surface)
(disable trailing-whitespace)))))
(test-assert "disabled rule produces no diagnostics"
(not (any (lambda (d) (eq? (diagnostic-rule d) 'trailing-whitespace))
diags))))
(delete-file tmp)))
(test-group "lint-file-disable-semantic-rule"
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display "(define x y)\n" p)))
(let ((diags (lint-file tmp '((%pass . semantic)
(disable unbound-variable)))))
(test-assert "disabled semantic rule produces no diagnostics"
(not (any (lambda (d) (eq? (diagnostic-rule d) 'unbound-variable))
diags))))
(delete-file tmp)))
(test-group "lint-file-page-break-separators"
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p)
(display "(define x 1)\n\n" p)
(display (string #\page) p)
(display "\n\n(define y 2)\n" p)))
(let ((diags (lint-file tmp '((%pass . surface)
(max-blank-lines . 1)))))
(test-assert "page break does not create blank-lines warning"
(not (any (lambda (d) (eq? (diagnostic-rule d) 'blank-lines))
diags)))
(test-assert "page break does not trigger trailing-whitespace"
(not (any (lambda (d) (eq? (diagnostic-rule d) 'trailing-whitespace))
diags))))
(delete-file tmp)))
(test-group "lint-file-token-aware-no-tabs"
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p)
(display "#| block\tcomment |#\n" p)))
(let ((diags (lint-file tmp '((%pass . surface)))))
(test-assert "tab in block comment ignored"
(not (any (lambda (d) (eq? (diagnostic-rule d) 'no-tabs))
diags))))
(delete-file tmp)))
(test-group "lint-file-token-aware-comment-semantics"
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p)
(display "(display \"hello\n; not a comment\nworld\")\n" p)))
(let ((diags (lint-file tmp '((%pass . surface)))))
(test-assert "semicolon inside multiline string ignored"
(not (any (lambda (d) (eq? (diagnostic-rule d) 'comment-semicolons))
diags))))
(delete-file tmp)))
;;; Fix mode
(test-group "fix-trailing-whitespace"
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display "(define x 42) \n(define y 99)\n" p)))
(let ((diags (lint-file tmp '((%pass . surface)))))
((@@ (gulie engine) apply-fixes-to-file) tmp diags))
(let ((result (call-with-input-file tmp get-string-all)))
(test-assert "trailing whitespace removed"
(not (string-contains result "42) ")))
(test-assert "content preserved"
(string-contains result "(define x 42)")))
(delete-file tmp)))
(test-group "fix-comment-semicolons"
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display " ; bad comment\n ;; good comment\n" p)))
(let ((diags (lint-file tmp '((%pass . surface)))))
((@@ (gulie engine) apply-fixes-to-file) tmp diags))
(let ((result (call-with-input-file tmp get-string-all)))
(test-assert "single ; fixed to ;;"
(string-contains result " ;; bad comment"))
(test-assert "double ;; preserved"
(string-contains result " ;; good comment")))
(delete-file tmp)))
(test-group "fix-no-tabs"
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display "\t(define x 1)\n" p)))
(let ((diags (lint-file tmp '((%pass . surface)))))
((@@ (gulie engine) apply-fixes-to-file) tmp diags))
(let ((result (call-with-input-file tmp get-string-all)))
(test-assert "tab replaced with spaces"
(not (string-index result #\tab)))
(test-assert "8-space expansion"
(string-prefix? " (define x 1)" result)))
(delete-file tmp)))
(test-group "fix-tabs-with-alignment"
;; Tab at column 6 should expand to 2 spaces (next tab stop at 8)
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display "abcdef\tg\n" p)))
(let ((diags (lint-file tmp '((%pass . surface)))))
((@@ (gulie engine) apply-fixes-to-file) tmp diags))
(let ((result (call-with-input-file tmp get-string-all)))
(test-assert "tab expanded"
(not (string-index result #\tab)))
(test-assert "correct expansion"
(string-contains result "abcdef g")))
(delete-file tmp)))
(test-group "fix-blank-lines"
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display "(define x 1)\n\n\n\n\n(define y 2)\n" p)))
(let ((diags (lint-file tmp '((%pass . surface)
(max-blank-lines . 2)))))
((@@ (gulie engine) apply-fixes-to-file) tmp diags))
(let* ((result (call-with-input-file tmp get-string-all))
(lines (string-split result #\newline)))
;; Should have: (define x 1), 2 blanks, (define y 2) = 5 lines
;; Original: (define x 1), 4 blanks, (define y 2) = 7 lines (incl final)
(test-assert "excess blank lines removed"
(<= (length lines) 5)))
(delete-file tmp)))
(test-group "fix-composition-tabs-and-trailing"
;; Line with BOTH tabs and trailing whitespace
;; Should fix both in a single pass
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display "\t(define x 1) \n" p)))
(let ((diags (lint-file tmp '((%pass . surface)))))
((@@ (gulie engine) apply-fixes-to-file) tmp diags))
(let ((result (call-with-input-file tmp get-string-all)))
(test-assert "no tabs remain"
(not (string-index result #\tab)))
(test-assert "no trailing whitespace"
(not (string-suffix? " " (car (string-split result #\newline)))))
(test-assert "content preserved"
(string-contains result "(define x 1)")))
(delete-file tmp)))
(test-group "fix-idempotent"
;; Running fix twice should produce the same result.
;; Note: when multiple fix types target the same line (tabs + trailing
;; whitespace + comment semicolons), convergence may take 2 passes.
;; This test uses a case that converges in one pass.
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display "(define x 1) \n\n\n\n\n(define y 2)\n" p)))
;; First fix
(let ((diags (lint-file tmp '((%pass . surface)
(max-blank-lines . 1)))))
((@@ (gulie engine) apply-fixes-to-file) tmp diags))
(let ((after-first (call-with-input-file tmp get-string-all)))
;; Second fix — should change nothing
(let ((diags (lint-file tmp '((%pass . surface)
(max-blank-lines . 1)))))
((@@ (gulie engine) apply-fixes-to-file) tmp diags))
(let ((after-second (call-with-input-file tmp get-string-all)))
(test-equal "second fix is idempotent" after-first after-second)))
(delete-file tmp)))
(test-group "fix-multi-pass-convergence"
;; Line with tabs + trailing whitespace + single semicolon:
;; may need 2 fix passes since 3 fixes compete for the same line.
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display "\t; comment \n" p)))
;; Pass 1
(let ((diags (lint-file tmp '((%pass . surface)))))
((@@ (gulie engine) apply-fixes-to-file) tmp diags))
;; Pass 2
(let ((diags (lint-file tmp '((%pass . surface)))))
((@@ (gulie engine) apply-fixes-to-file) tmp diags))
(let ((result (call-with-input-file tmp get-string-all)))
(test-assert "tabs removed after 2 passes"
(not (string-index result #\tab)))
(test-assert "trailing whitespace removed"
(not (string-contains result " \n")))
(test-assert "semicolon fixed to ;;"
(string-contains result ";; comment")))
(delete-file tmp)))
;;; Compile-error message formatting
(test-group "compile-error-formatting"
;; Compile a file that references a non-existent module
(let ((diags ((@@ (gulie compiler) compile-and-capture-warnings)
"test.scm"
"(use-modules (nonexistent module))\n(define x 1)\n"
'())))
(let ((errors (filter (lambda (d) (eq? (diagnostic-severity d) 'error))
diags)))
(test-assert "has compile error" (not (null? errors)))
(let ((msg (diagnostic-message (car errors))))
(test-assert "no raw format specifiers in message"
(not (string-contains msg "~S")))
(test-assert "no raw format specifiers ~A"
(not (string-contains msg "~A")))
(test-assert "contains module name"
(string-contains msg "nonexistent"))))))
;;; Integration: lint-files output and fix mode
(test-group "lint-files-json-output"
(let ((tmp (tmpnam))
(count #f))
(call-with-output-file tmp
(lambda (p) (display "(define x 42) \n" p)))
(let ((output (call-with-output-string
(lambda (port)
(parameterize ((current-output-port port))
(set! count
(lint-files (list tmp)
'((%pass . surface)
(%output . json)))))))))
(test-equal "count preserved" 1 count)
(test-assert "json array output" (string-prefix? "[" output))
(test-assert "contains json rule"
(string-contains output "\"rule\":\"trailing-whitespace\""))
(test-assert "does not use standard formatter"
(not (string-contains output ": warning: trailing-whitespace:"))))
(delete-file tmp)))
(test-group "lint-files-fix-mode"
(let ((tmp (tmpnam)))
(call-with-output-file tmp
(lambda (p) (display "(define x 42) \n ; comment\n" p)))
(let ((count (lint-files (list tmp)
'((%pass . surface) (%fix . #t)))))
;; After fix, only unfixable diagnostics should be counted
(test-equal "no unfixable issues" 0 count))
;; Verify file was actually modified
(let ((result (call-with-input-file tmp get-string-all)))
(test-assert "trailing whitespace fixed"
(not (string-contains result "42) ")))
(test-assert "comment fixed"
(string-contains result ";; comment")))
(delete-file tmp)))
(test-end "engine")

View File

@@ -27,7 +27,11 @@
(test-equal "empty line no diagnostic"
'()
((rule-check-proc rule) "f.scm" 1 "" '()))))
((rule-check-proc rule) "f.scm" 1 "" '()))
(test-equal "page-break separator line ok"
'()
((rule-check-proc rule) "f.scm" 1 (string #\page) '()))))
(test-group "line-length"
(let ((rule (find-rule 'line-length)))
@@ -37,7 +41,7 @@
'()
((rule-check-proc rule) "f.scm" 1 "(define x 42)" '()))
(let* ((long-line (make-string 81 #\x))
(let* ((long-line (make-string 119 #\x))
(diags ((rule-check-proc rule) "f.scm" 1 long-line '())))
(test-equal "long line detected" 1 (length diags)))
@@ -55,7 +59,25 @@
((rule-check-proc rule) "f.scm" 1 " (define x 1)" '()))
(let ((diags ((rule-check-proc rule) "f.scm" 1 "\t(define x 1)" '())))
(test-equal "tab detected" 1 (length diags)))))
(test-equal "tab detected" 1 (length diags)))
(test-equal "tabs in comments ignored"
'()
((rule-check-proc rule) "f.scm" 1
(string-append ";;" (string #\tab) " comment")
'()))
(test-equal "tabs after inline comment ignored"
'()
((rule-check-proc rule) "f.scm" 1
(string-append "(define x 1) ;" (string #\tab) " comment")
'()))
(test-equal "tabs in strings ignored"
'()
((rule-check-proc rule) "f.scm" 1
(string-append "(display \"" (string #\tab) "\")")
'()))))
(test-group "blank-lines"
(let ((rule (find-rule 'blank-lines)))