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
### 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
* Macro expansion is shown when hovering over a macro
* 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,
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)
(local dispatch (require :fennel-ls.dispatch))
(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 {: 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
filename
;; LSP line numbers are zero-indexed, but Emacs and Vim both use
;; 1-indexing for this.
(if (= range message.unknown-range) "?" (+ range.start.line 1))
(if (= range message.unknown-range) "?" range.start.character)
(or (. severity->string ?severity) "?")
(or (. severity->string severity) "?")
msg)))
(fn initialize [server]
@ -26,11 +28,10 @@
{:method "window/showMessage" :params {: message}}
(print "WARN:" message)))))
(λ lint [filenames]
(λ lint-files [filenames]
"non-interactive mode that gets executed from CLI with --lint.
runs lints on each file, then formats and prints them"
(let [lint (require :fennel-ls.lint)
server (doto {} initialize)]
(let [server (doto {} initialize)]
(var should-err? false)
(each [_ filename (ipairs filenames)]
(let [uri (case filename
@ -38,12 +39,39 @@
_ (.. "file://" filename))
file (files.get-by-uri server uri)]
(lint.add-lint-diagnostics server file)
(each [_ {: message : range : severity} (ipairs file.diagnostics)]
(each [_ diagnostic (ipairs file.diagnostics)]
(set should-err? true)
(print-diagnostic filename message range severity))))
(print-diagnostic filename diagnostic))))
(when should-err?
(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]
(local send (partial json-rpc.write out))
(local server {})
@ -56,22 +84,26 @@
Run fennel-ls, the Fennel language server and linter.
--lint FILES : Run the linter on the provided files
a single dash (-) can be used to read from stdin
--server : Start the language server (stdio mode only)
optional, this is the default with no arguments
--lint FILES : Run the linter on the provided files
a single dash (-) can be used to read from stdin
--fix [-y] FILES : Run suggested fixes from linters on files
--server : Start the language server (stdio mode only)
optional, this is the default with no arguments
--help : Display this text
--version : Show version")
--help : Display this text
--version : Show version")
(λ main []
(case arg
(where (or ["-h"] ["--help"])) (print help)
(where (or ["-v"] ["--version"])) (print version)
;; (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)
(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)
(io.stderr:write "\n")
(os.exit 1))))

View File

@ -139,9 +139,7 @@ These functions are all pure functions, which makes me happy."
text)))
(λ apply-edits [initial-text edits encoding]
"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"
"Takes a list of Language-Server-Protocol `TextEdit` or `AnnotatedTextEdit` and applies them to a piece of text."
(accumulate
[contents initial-text
_ edit (ipairs edits)]

View File

@ -1,19 +1,35 @@
(local faith (require :faith))
;; TODO refactor fennel-ls.check to avoid running end-to-end
;; (but keep/write at least some end-to-end tests)
(fn test-lint []
(let [input-file-name (os.tmpname)]
(doto (io.open input-file-name :w)
(: :write "(local x 1)")
;; alas, no io.popen2
(macro with-temp-form [[filename form
out cmd] & body]
`(let [,filename (os.tmpname)]
(doto (assert (io.open ,filename :w))
(: :write ,(view form))
(: :close))
(let [output-file (io.popen (.. "./fennel-ls --lint "
input-file-name)
:r)
contents (output-file:read :*a)]
(os.remove input-file-name)
(faith.= (.. input-file-name ":1:7: warning: unused definition: x\n")
contents))
(let [pipe# (io.popen (.. ,cmd " " ,filename))
,out (pipe#:read :*a)]
,body
(os.remove ,filename))
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}