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.
This commit is contained in:
2026-04-04 13:30:48 +02:00
parent e3e28a911a
commit f979d48e54
6 changed files with 136 additions and 44 deletions

4
.gitignore vendored
View File

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

View File

@@ -74,7 +74,6 @@
0) 0)
((option-ref options 'list-rules #f) ((option-ref options 'list-rules #f)
;; Ensure rules are loaded
(list-all-rules) (list-all-rules)
0) 0)
@@ -93,6 +92,31 @@
(let* ((config-path (option-ref options 'config #f)) (let* ((config-path (option-ref options 'config #f))
(user-config (load-config config-path)) (user-config (load-config config-path))
(config (merge-configs default-config user-config)) (config (merge-configs default-config user-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))
(paths (if (null? rest) (list ".") rest)) (paths (if (null? rest) (list ".") rest))
(ignore-pats (config-ignore-patterns config)) (ignore-pats (config-ignore-patterns config))
(files (discover-scheme-files paths ignore-pats))) (files (discover-scheme-files paths ignore-pats)))

View File

@@ -6,6 +6,7 @@
(define-module (gulie compiler) (define-module (gulie compiler)
#:use-module (system base compile) #:use-module (system base compile)
#:use-module (ice-9 match)
#:use-module (ice-9 regex) #:use-module (ice-9 regex)
#:use-module (srfi srfi-1) #:use-module (srfi srfi-1)
#:use-module (gulie diagnostic) #:use-module (gulie diagnostic)
@@ -67,13 +68,32 @@ Returns a list of <diagnostic> records."
#:warning-level 3 #:warning-level 3
#:env (make-fresh-user-module)))) #:env (make-fresh-user-module))))
(lambda (key . args) (lambda (key . args)
;; Compilation errors become diagnostics too ;; Format the error message properly.
;; Guile exceptions typically have args: (subr fmt fmt-args data)
(let ((msg (call-with-output-string (let ((msg (call-with-output-string
(lambda (p) (lambda (p)
(display key p) (display key p)
(display ": " p) (display ": " p)
(for-each (lambda (a) (display a p) (display " " p)) (catch #t
args))))) (lambda ()
(match args
((subr fmt fmt-args . _)
(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)))
(_ (for-each (lambda (a)
(display a p) (display " " p))
args))))
(lambda _
;; Fallback: just display the raw args
(for-each (lambda (a)
(display a p) (display " " p))
args)))))))
(set! diagnostics (set! diagnostics
(cons (make-diagnostic file 1 0 'error 'compile-error msg #f) (cons (make-diagnostic file 1 0 'error 'compile-error msg #f)
diagnostics)))))) diagnostics))))))

View File

@@ -5,7 +5,9 @@
(define-module (gulie config) (define-module (gulie config)
#:use-module (srfi srfi-1) #:use-module (srfi srfi-1)
#:use-module (ice-9 pretty-print)
#:export (default-config #:export (default-config
find-config
load-config load-config
merge-configs merge-configs
config-ref config-ref
@@ -90,14 +92,25 @@
(define (config-indent-rules config) (define (config-indent-rules config)
(or (config-ref config 'indent-rules) '())) (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) (define (load-config path)
"Load a .gulie.sexp config file at PATH. Returns an alist." "Load a .gulie.sexp config file at PATH. Returns an alist.
(if (and path (file-exists? path)) If PATH is #f, auto-discover by searching CWD and parent directories."
(call-with-input-file path (let ((resolved (or path (find-config (getcwd)))))
(if (and resolved (file-exists? resolved))
(call-with-input-file resolved
(lambda (port) (lambda (port)
(let ((data (read port))) (let ((data (read port)))
(if (list? data) data '())))) (if (list? data) data '()))))
'())) '())))
(define (merge-configs base override) (define (merge-configs base override)
"Merge OVERRIDE config on top of BASE. Override wins for scalar values; "Merge OVERRIDE config on top of BASE. Override wins for scalar values;
@@ -115,5 +128,5 @@ lists are replaced, not appended."
(display ";;; gulie configuration\n" port) (display ";;; gulie configuration\n" port)
(display ";;; Place this file as .gulie.sexp in your project root.\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) (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)) (newline port))

View File

@@ -6,7 +6,7 @@
(define-module (gulie engine) (define-module (gulie engine)
#:use-module (ice-9 ftw) #:use-module (ice-9 ftw)
#:use-module (ice-9 rdelim) #:use-module (ice-9 textual-ports)
#:use-module (srfi srfi-1) #:use-module (srfi srfi-1)
#:use-module (gulie diagnostic) #:use-module (gulie diagnostic)
#:use-module (gulie rule) #:use-module (gulie rule)
@@ -17,17 +17,37 @@
(define (read-file-to-string path) (define (read-file-to-string path)
"Read entire file at PATH into a string." "Read entire file at PATH into a string."
(call-with-input-file path (call-with-input-file path get-string-all))
(lambda (port)
(let lp ((acc '()))
(let ((line (read-line port)))
(if (eof-object? line)
(string-join (reverse acc) "\n")
(lp (cons line acc))))))))
(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 (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 (run-line-rules file lines config) (define (run-line-rules file lines config)
"Run all line-type rules against LINES. Returns list of diagnostics." "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 '()) (diagnostics '())
(consecutive-blanks 0)) (consecutive-blanks 0))
(when (not (null? line-rules)) (when (not (null? line-rules))
@@ -52,7 +72,7 @@
(define (run-cst-rules file cst config) (define (run-cst-rules file cst config)
"Run all cst-type rules against CST. Returns list of diagnostics." "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) (if (null? cst-rules)
'() '()
(append-map (append-map
@@ -61,15 +81,19 @@
cst-rules)))) cst-rules))))
(define (lint-file file config) (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)) (let* ((text (read-file-to-string file))
(lines (string-split text #\newline)) (lines (string-split text #\newline))
(pass (or (assq-ref config '%pass) 'all))
(min-severity (or (assq-ref config '%min-severity) 'info))
(diagnostics '())) (diagnostics '()))
;; Pass 1: line-based surface rules ;; Pass 1: line-based surface rules
(when (memq pass '(all surface))
(set! diagnostics (append (run-line-rules file lines config) (set! diagnostics (append (run-line-rules file lines config)
diagnostics)) diagnostics))
;; Pass 1b: CST rules (if tokenizer is loaded) ;; Pass 1b: CST rules (if tokenizer is loaded)
;; Dynamically check if tokenizer module is available
(let ((tok-mod (resolve-module '(gulie tokenizer) #:ensure #f))) (let ((tok-mod (resolve-module '(gulie tokenizer) #:ensure #f)))
(when tok-mod (when tok-mod
(let ((tokenize (module-ref tok-mod 'tokenize)) (let ((tokenize (module-ref tok-mod 'tokenize))
@@ -79,16 +103,23 @@
(tokens (tokenize text file)) (tokens (tokenize text file))
(cst (parse-cst tokens))) (cst (parse-cst tokens)))
(set! diagnostics (append (run-cst-rules file cst config) (set! diagnostics (append (run-cst-rules file cst config)
diagnostics))))))) diagnostics))))))))
;; Pass 2: semantic rules (if compiler module is loaded) ;; Pass 2: semantic rules (if compiler module is loaded)
(when (memq pass '(all semantic))
(let ((comp-mod (resolve-module '(gulie compiler) #:ensure #f))) (let ((comp-mod (resolve-module '(gulie compiler) #:ensure #f)))
(when comp-mod (when comp-mod
(let ((compile-and-capture (module-ref comp-mod 'compile-and-capture-warnings))) (let ((compile-and-capture (module-ref comp-mod 'compile-and-capture-warnings)))
(set! diagnostics (append (compile-and-capture file text config) (set! diagnostics (append (compile-and-capture file text config)
diagnostics))))) diagnostics))))))
;; Filter suppressions ;; Filter suppressions
(let ((suppressions (parse-suppressions text))) (let ((suppressions (parse-suppressions text)))
(set! diagnostics (filter-suppressions diagnostics suppressions))) (set! diagnostics (filter-suppressions diagnostics suppressions)))
;; 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 by location
(sort diagnostics diagnostic<?))) (sort diagnostics diagnostic<?)))

View File

@@ -83,9 +83,9 @@
(let ((max-blanks (or (assq-ref config 'max-blank-lines) 2)) (let ((max-blanks (or (assq-ref config 'max-blank-lines) 2))
(consecutive (or (assq-ref config '%consecutive-blanks) 0))) (consecutive (or (assq-ref config '%consecutive-blanks) 0)))
(if (and (string-every char-whitespace? line-text) (if (and (string-every char-whitespace? line-text)
(> consecutive max-blanks)) (= consecutive (1+ max-blanks)))
(list (make-diagnostic (list (make-diagnostic
file line-num 0 file (- line-num max-blanks) 0
'warning 'blank-lines 'warning 'blank-lines
(format #f "more than ~a consecutive blank lines" max-blanks) (format #f "more than ~a consecutive blank lines" max-blanks)
#f)) #f))