Compare commits
12 Commits
1e85dd224a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f085882a4a | |||
| 8488cab0ac | |||
| bd89fb476e | |||
| 6dc717186e | |||
| 78f3f7e6d3 | |||
| 412814ff72 | |||
| f5d5919943 | |||
| 1734ebe381 | |||
| f979d48e54 | |||
| e3e28a911a | |||
| f512e0aced | |||
| 98fbba03cb |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -10,5 +10,9 @@ devenv.local.yaml
|
||||
# pre-commit
|
||||
.pre-commit-config.yaml
|
||||
|
||||
# Agents
|
||||
/.pi/
|
||||
|
||||
# Temporary
|
||||
.gulie.sexp
|
||||
/refs/
|
||||
|
||||
25
LICENSE
Normal file
25
LICENSE
Normal 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.
|
||||
@@ -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/).
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
353
gulie/engine.scm
353
gulie/engine.scm
@@ -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?"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
38
test/test-cli.scm
Normal 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")
|
||||
@@ -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
99
test/test-config.scm
Normal 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
351
test/test-engine.scm
Normal 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")
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user