Add --fix command to CLI.

If the user declines a fix, it will stop fixing the file it's looking
at and go on to the next one.

If they accept the fix, we have to re-run diagnostics, because fixes
can change offsets and line numbers.

Add tests that invoke the `fennel-ls` executable as a subprocess.
This commit is contained in:
Phil Hagelberg 2025-10-09 08:01:54 -07:00
parent bf17771bab
commit 2b79a0955f
5 changed files with 87 additions and 31 deletions

View File

@ -1,6 +1,8 @@
# Changelog # Changelog
### Features ### Features
* Add `--fix` command-line argument to automatically apply lint fixes
* Add `:legacy-multival` and `legacy-multival-case` lints, disabled by default
* Code action "Expand macro" lets you see what a macro expands to * Code action "Expand macro" lets you see what a macro expands to
* Macro expansion is shown when hovering over a macro * Macro expansion is shown when hovering over a macro
* Added support for pull diagnostics, which should help with out of date lints * Added support for pull diagnostics, which should help with out of date lints

View File

@ -131,3 +131,11 @@ fennel-ls --lint my-file.fnl f2.fnl # prints diagnostics for the files given
This will analyze the given files and print out all compiler errors and lints, This will analyze the given files and print out all compiler errors and lints,
without launching a server. A successful exit code indicates no problems found. without launching a server. A successful exit code indicates no problems found.
You can also automatically apply fixes from lints that have them:
```sh
fennel-ls --fix my-file.fnl f2.fnl # applies fixes
my-file.fnl:3:6: warning: unnecessary unary +
Apply fix? [Y/n] Unwrap the expression
```

View File

@ -1,17 +1,19 @@
(require :fennel) (require :fennel)
(local dispatch (require :fennel-ls.dispatch)) (local dispatch (require :fennel-ls.dispatch))
(local json-rpc (require :fennel-ls.json-rpc)) (local json-rpc (require :fennel-ls.json-rpc))
(local lint (require :fennel-ls.lint))
(local utils (require :fennel-ls.utils))
(local files (require :fennel-ls.files)) (local files (require :fennel-ls.files))
(local {: severity->string &as message} (require :fennel-ls.message)) (local {: severity->string &as message} (require :fennel-ls.message))
(fn print-diagnostic [filename msg range ?severity] (fn print-diagnostic [filename {:message msg : range : severity}]
(print (: "%s:%s:%s: %s: %s" :format (print (: "%s:%s:%s: %s: %s" :format
filename filename
;; LSP line numbers are zero-indexed, but Emacs and Vim both use ;; LSP line numbers are zero-indexed, but Emacs and Vim both use
;; 1-indexing for this. ;; 1-indexing for this.
(if (= range message.unknown-range) "?" (+ range.start.line 1)) (if (= range message.unknown-range) "?" (+ range.start.line 1))
(if (= range message.unknown-range) "?" range.start.character) (if (= range message.unknown-range) "?" range.start.character)
(or (. severity->string ?severity) "?") (or (. severity->string severity) "?")
msg))) msg)))
(fn initialize [server] (fn initialize [server]
@ -26,11 +28,10 @@
{:method "window/showMessage" :params {: message}} {:method "window/showMessage" :params {: message}}
(print "WARN:" message))))) (print "WARN:" message)))))
(λ lint [filenames] (λ lint-files [filenames]
"non-interactive mode that gets executed from CLI with --lint. "non-interactive mode that gets executed from CLI with --lint.
runs lints on each file, then formats and prints them" runs lints on each file, then formats and prints them"
(let [lint (require :fennel-ls.lint) (let [server (doto {} initialize)]
server (doto {} initialize)]
(var should-err? false) (var should-err? false)
(each [_ filename (ipairs filenames)] (each [_ filename (ipairs filenames)]
(let [uri (case filename (let [uri (case filename
@ -38,12 +39,39 @@
_ (.. "file://" filename)) _ (.. "file://" filename))
file (files.get-by-uri server uri)] file (files.get-by-uri server uri)]
(lint.add-lint-diagnostics server file) (lint.add-lint-diagnostics server file)
(each [_ {: message : range : severity} (ipairs file.diagnostics)] (each [_ diagnostic (ipairs file.diagnostics)]
(set should-err? true) (set should-err? true)
(print-diagnostic filename message range severity)))) (print-diagnostic filename diagnostic))))
(when should-err? (when should-err?
(os.exit 1)))) (os.exit 1))))
(λ apply-changes [server filename changes]
(let [contents (with-open [f (io.open filename)] (f:read :*a))
new (utils.apply-edits contents changes server.position-encoding)]
(with-open [f (io.open filename :w)]
(f:write new))))
(λ confirm [msg]
(io.write msg)
(case (io.read) (where (or "" "y" "Y" "yes")) true))
(λ fix-files [filenames --yes]
(let [server (doto {} initialize)]
(each [_ filename (ipairs filenames)]
(let [uri (case filename
"-" :stdin
_ (.. "file://" filename))
file (files.get-by-uri server uri)]
(lint.add-lint-diagnostics server file)
(case [(next file.diagnostics)]
[_ {: fix &as diagnostic}]
(let [{: title : changes} (fix)
query (: "Apply fix? [Y/n] %s " :format title)]
(print-diagnostic filename diagnostic)
(when (or --yes (confirm query))
(apply-changes server filename changes)
(fix-files [filename] --yes))))))))
(λ main-loop [in out] (λ main-loop [in out]
(local send (partial json-rpc.write out)) (local send (partial json-rpc.write out))
(local server {}) (local server {})
@ -56,22 +84,26 @@
Run fennel-ls, the Fennel language server and linter. Run fennel-ls, the Fennel language server and linter.
--lint FILES : Run the linter on the provided files --lint FILES : Run the linter on the provided files
a single dash (-) can be used to read from stdin a single dash (-) can be used to read from stdin
--server : Start the language server (stdio mode only) --fix [-y] FILES : Run suggested fixes from linters on files
optional, this is the default with no arguments --server : Start the language server (stdio mode only)
optional, this is the default with no arguments
--help : Display this text --help : Display this text
--version : Show version") --version : Show version")
(λ main [] (λ main []
(case arg (case arg
(where (or ["-h"] ["--help"])) (print help) (where (or ["-h"] ["--help"])) (print help)
(where (or ["-v"] ["--version"])) (print version) (where (or ["-v"] ["--version"])) (print version)
;; (where (or ["-l" & filenames] ["--lint" & filenames])) (lint filenames) ;; compile error in fennel <= 1.5.4 ;; (where (or ["-l" & filenames] ["--lint" & filenames])) (lint filenames) ;; compile error in fennel <= 1.5.4
(where [--lint & filenames] (or (= --lint "--lint") (= --lint "-l"))) (lint filenames) (where [--lint & filenames] (or (= --lint "--lint") (= --lint "-l"))) (lint-files filenames)
(where (or ["--server"] [nil])) (main-loop (io.input) (where (or ["--server"] [nil])) (main-loop (io.input)
(io.output)) (io.output))
["--fix" "-y" & filenames] (fix-files filenames true)
["--fix" "--yes" & filenames] (fix-files filenames true)
["--fix" & filenames] (fix-files filenames false)
_args (do (io.stderr:write help) _args (do (io.stderr:write help)
(io.stderr:write "\n") (io.stderr:write "\n")
(os.exit 1)))) (os.exit 1))))

View File

@ -139,9 +139,7 @@ These functions are all pure functions, which makes me happy."
text))) text)))
(λ apply-edits [initial-text edits encoding] (λ apply-edits [initial-text edits encoding]
"Takes a list of Language-Server-Protocol `TextEdit` or `AnnotatedTextEdit` and applies them to a piece of text. "Takes a list of Language-Server-Protocol `TextEdit` or `AnnotatedTextEdit` and applies them to a piece of text."
WARNING: this is only used in the test code, not in the real language server"
(accumulate (accumulate
[contents initial-text [contents initial-text
_ edit (ipairs edits)] _ edit (ipairs edits)]

View File

@ -1,19 +1,35 @@
(local faith (require :faith)) (local faith (require :faith))
;; TODO refactor fennel-ls.check to avoid running end-to-end ;; alas, no io.popen2
;; (but keep/write at least some end-to-end tests) (macro with-temp-form [[filename form
(fn test-lint [] out cmd] & body]
(let [input-file-name (os.tmpname)] `(let [,filename (os.tmpname)]
(doto (io.open input-file-name :w) (doto (assert (io.open ,filename :w))
(: :write "(local x 1)") (: :write ,(view form))
(: :close)) (: :close))
(let [output-file (io.popen (.. "./fennel-ls --lint " (let [pipe# (io.popen (.. ,cmd " " ,filename))
input-file-name) ,out (pipe#:read :*a)]
:r) ,body
contents (output-file:read :*a)] (os.remove ,filename))
(os.remove input-file-name)
(faith.= (.. input-file-name ":1:7: warning: unused definition: x\n")
contents))
nil)) nil))
{: test-lint} (fn test-lint []
(with-temp-form [f (local x 1)
out "./fennel-ls --lint"]
(faith.= (.. f ":1:7: warning: unused definition: x\n") out)))
(fn test-fix []
(with-temp-form [f (local x 1)
out "./fennel-ls --fix --yes"]
(faith.= "(local _x 1)" (with-open [file (assert (io.open f))]
(file:read :*a)) out))
(with-temp-form [f [(let [(a b) (values 1 (+ 2))] (+ a b))
[(do (print "done doing all the fun things we did!"))]]
out "./fennel-ls --fix --yes"]
(faith.= "[(let [[a b] [(values 1 2)]] (+ a b))
[(print \"done doing all the fun things we did!\")]]"
(with-open [file (assert (io.open f))]
(file:read :*a)) out)))
{: test-lint
: test-fix}