yet another lint refactor

This commit is contained in:
XeroOl 2025-07-11 16:25:54 -05:00
parent 7b215e15e8
commit b4d2683c33
2 changed files with 293 additions and 264 deletions

View File

@ -11,6 +11,7 @@ There are no global settings. They're all stored in the `server` object.
(local files (require :fennel-ls.files))
(local docs (require :fennel-ls.docs))
(local utils (require :fennel-ls.utils))
(local lint (require :fennel-ls.lint))
(local option-mt {})
(fn option [default-value] (doto [default-value] (setmetatable option-mt)))
@ -24,19 +25,8 @@ There are no global settings. They're all stored in the `server` object.
"src/?.fnl" "src/?/init-macros.fnl"
"src/?/init.fnl"] ";"))
:lua-version (option "lua54")
:lints {:unused-definition (option true)
:unknown-module-field (option true)
:unnecessary-method (option true)
:unnecessary-tset (option true)
:unnecessary-do (option true)
:redundant-do (option true)
:match-should-case (option true)
:bad-unpack (option true)
:var-never-set (option true)
:op-with-no-arguments (option true)
:multival-in-middle-of-call (option true)
:no-decreasing-comparison (option false)
:empty-let (option true)}
:lints (collect [_ lint (ipairs lint.list)]
lint.name (option lint.enabled))
:libraries (option {})
:extra-globals (option "")})

View File

@ -18,11 +18,32 @@ the `file.diagnostics` field, filling it with diagnostics."
(set self.fix nil)
(setmetatable {: self : fix} diagnostic-mt)))
(local lints {:definition []
:reference []
:macro-call []
:function-call []
:special-call []
:file []})
(local all-lints [])
(fn add-lint [code lint]
(set lint.name code)
(table.insert all-lints lint)
(if (= (type lint.type) :table)
(each [_ t (ipairs lint.type)]
(table.insert (. lints t) lint))
(table.insert (. lints lint.type) lint)))
(fn could-be-rewritten-as-sym? [str]
(and (= :string (type str)) (not (str:find "^%d"))
(not (str:find "[^!$%*+/0-9<=>?A-Z\\^_a-z|\128-\255-]"))))
(λ unused-definition [server file symbol definition]
(add-lint :unused-definition
{:type :definition
:enabled true
:impl (λ [server file symbol definition]
"local variable that is defined but not used"
(if (not (or (= "_" (: (tostring symbol) :sub 1 1))
(= "_" (: (tostring symbol) :sub -1 -1))
@ -38,7 +59,7 @@ the `file.diagnostics` field, filling it with diagnostics."
:code :unused-definition
:fix #{:title (.. "Replace " (tostring symbol) " with _" (tostring symbol))
:changes [{:range (message.ast->range server file symbol)
:newText (.. "_" (tostring symbol))}]}})))
:newText (.. "_" (tostring symbol))}]}})))})
;; this is way too specific; it's also safe to do this inside an `if` or `case`
(fn in-or? [calls symbol]
@ -62,7 +83,10 @@ the `file.diagnostics` field, filling it with diagnostics."
:severity message.severity.WARN
:code :unknown-module-field}))))
(λ unknown-module-field [server file]
(add-lint :unknown-module-field
{:type :file
:enabled true
:impl (λ [server file]
"any multisym whose definition can't be found through a (require) call"
(icollect [symbol (pairs file.references) &into file.diagnostics]
(if (. (utils.multi-sym-split symbol) 2)
@ -72,9 +96,12 @@ the `file.diagnostics` field, filling it with diagnostics."
(if binding.keys
(module-field-helper server file symbol binding.definition
(fcollect [i (length binding.keys) 1 -1]
(. binding.keys i))))))
(. binding.keys i))))))})
(λ unnecessary-method [server file colon call]
(add-lint :unnecessary-method
{:type :special-call
:enabled true
:impl (λ [server file colon call]
"a call to the : builtin that could just be a multisym"
(if (and (sym? colon ":")
(sym? (. call 2))
@ -85,9 +112,12 @@ the `file.diagnostics` field, filling it with diagnostics."
:message (.. "unnecessary : call: use (" (tostring (. call 2))
":" method ")")
:severity message.severity.WARN
:code :unnecessary-method}))))
:code :unnecessary-method}))))})
(λ unnecessary-tset [server file head call]
(add-lint :unnecessary-tset
{:type :special-call
:enabled true
:impl (λ [server file head call]
(λ all-syms? [call start end]
(faccumulate [syms true
i start end]
@ -113,9 +143,12 @@ the `file.diagnostics` field, filling it with diagnostics."
:code :unnecessary-tset
:fix #{:title "Replace tset with set"
:changes [{:range (message.ast->range server file call)
:newText (make-new-text call)}]}})))
:newText (make-new-text call)}]}})))})
(λ unnecessary-do-values [server file head call]
(add-lint :unnecessary-do-values
{:type :special-call
:enabled true
:impl (λ [server file head call]
(if (and (or (sym? head :do) (sym? head :values))
(= nil (. call 3)) (. file.lexical call))
(diagnostic {:range (message.ast->range server file call)
@ -124,18 +157,21 @@ the `file.diagnostics` field, filling it with diagnostics."
:code :unnecessary-do-values
:fix #{:title "Unwrap the expression"
:changes [{:range (message.ast->range server file call)
:newText (view (. call 2))}]}})))
:newText (view (. call 2))}]}})))})
(local implicit-do-forms (collect [form {: body-form?} (pairs (fennel.syntax))]
(values form body-form?)))
(λ redundant-do [server file head call]
(add-lint :redundant-do
{:type :special-call
:enabled true
:impl (λ [server file head call]
(let [last-body (. call (length call))]
(if (and (. implicit-do-forms (tostring head))
(. file.lexical call)
(list? last-body)
(sym? (. last-body 1) :do)
(not (unnecessary-do-values server file head call))) ;; we don't want two lints to trigger for same call
(not (and (sym? head :do) (= 3 (length call))))) ;; we don't want two lints to trigger for same call
(diagnostic {:range (message.ast->range server file last-body)
:message "redundant do"
:severity message.severity.WARN
@ -145,9 +181,12 @@ the `file.diagnostics` field, filling it with diagnostics."
:newText (table.concat
(fcollect [i 2 (length last-body)]
(view (. last-body i)))
" ")}]}}))))
" ")}]}}))))})
(λ bad-unpack [server file op call]
(add-lint :bad-unpack
{:type :special-call
:enabled true
:impl (λ [server file op call]
"an unpack call leading into an operator"
(let [last-item (. call (length call))]
(if (and (op? op)
@ -174,9 +213,12 @@ the `file.diagnostics` field, filling it with diagnostics."
(sym? op ".."))
#{:title "Replace with a call to table.concat"
:changes [{:range (message.ast->range server file (if (= 2 (length call)) call last-item))
:newText (.. "(table.concat " (view (. last-item 2)) ")")}]})}))))
:newText (.. "(table.concat " (view (. last-item 2)) ")")}]})}))))})
(λ var-never-set [server file symbol definition]
(add-lint :var-not-set
{:type :definition
:enabled true
:impl (λ [server file symbol definition]
(if (and definition.var? (not definition.var-set) (. file.lexical symbol))
;; we can't provide a quickfix for this because the hooks don't give us
;; the full AST of the call to var; just the LHS/RHS
@ -184,10 +226,14 @@ the `file.diagnostics` field, filling it with diagnostics."
:message (.. "var is never set: " (tostring symbol)
" Consider using (local) instead of (var)")
:severity message.severity.WARN
:code :var-never-set})))
:code :var-never-set})))})
(local op-identity-value {:+ 0 :* 1 :and true :or false :band -1 :bor 0 :.. ""})
(λ op-with-no-arguments [server file op call]
(add-lint :op-with-no-arguments
{:type :special-call
:enabled true
:impl (λ [server file op call]
"A call like (+) that could be replaced with a literal"
(let [identity (. op-identity-value (tostring op))]
(if (and (op? op)
@ -201,9 +247,12 @@ the `file.diagnostics` field, filling it with diagnostics."
:code :op-with-no-arguments
:fix #{:title (.. "Replace (" (tostring op) ") with " (view identity))
:changes [{:range (message.ast->range server file call)
:newText (view identity)}]}}))))
:newText (view identity)}]}}))))})
(λ no-decreasing-comparison [server file op call]
(add-lint :no-decreasing-comparison
{:type :special-call
:enabled false
:impl (λ [server file op call]
(if (or (sym? op :>) (sym? op :>=))
(diagnostic
{:range (message.ast->range server file call)
@ -216,7 +265,7 @@ the `file.diagnostics` field, filling it with diagnostics."
reversed (fcollect [i (length call) 2 -1
&into (list (sym new))]
(. call i))]
(view reversed))}]}})))
(view reversed))}]}})))})
(λ match-reference? [ast references]
(if (sym? ast) (?. references ast :target)
@ -224,7 +273,10 @@ the `file.diagnostics` field, filling it with diagnostics."
(accumulate [ref false _ subast (pairs ast) &until ref]
(match-reference? subast references))))
(λ match-should-case [server {: references &as file} ast]
(add-lint :match-should-case
{:type :macro-call
:enabled true
:impl (λ [server {: references &as file} ast]
(when (and (list? ast)
(sym? (. ast 1) :match)
(not (faccumulate [ref false i 3 (length ast) 2 &until ref]
@ -235,12 +287,17 @@ the `file.diagnostics` field, filling it with diagnostics."
:code :match-should-case
:fix #{:title "Replace match with case"
:changes [{:range (message.ast->range server file (. ast 1))
:newText "case"}]}})))
:newText "case"}]}})))})
(λ multival-in-middle-of-call [server file fun call arg index]
(add-lint :inline-unpack
{:type [:function-call :special-call]
:enabled true
:impl (λ [server file fun call]
"generally, values and unpack are signs that the user is trying to do
something with multiple values. However, multiple values will get
\"adjusted\" to one value if they don't come at the end of the call."
(faccumulate [f nil index 2 (length call) &until f]
(let [arg (. call index)]
(if (and (not (and (special? fun) (not (op? fun))))
(not= index (length call))
(list? arg)
@ -252,9 +309,12 @@ the `file.diagnostics` field, filling it with diagnostics."
:message (.. "bad " (tostring (. arg 1))
" call: only the first value of the multival will be used")
:severity message.severity.WARN
:code :inline-unpack}))
:code :inline-unpack}))))})
(λ empty-let [server file _ call]
(add-lint :empty-let
{:type :special-call
:enabled true
:impl (λ [server file _ call]
(case call
(where [let* binding]
(. file.lexical call)
@ -269,57 +329,36 @@ the `file.diagnostics` field, filling it with diagnostics."
:changes [(let [{: start} (message.ast->range server file let*)
{: end} (message.ast->range server file binding)]
{:range {: start : end}
:newText "do"})]}})))
:newText "do"})]}})))})
(λ add-lint-diagnostics [server file]
"fill up the file.diagnostics table with linting things"
(let [lints server.configuration.lints
diagnostics file.diagnostics]
;; definition lints
(each [_ lint (ipairs lints.file)]
(when (. server.configuration.lints lint.name)
(lint.impl server file)))
(each [symbol definition (pairs file.definitions)]
(when lints.unused-definition
(table.insert diagnostics (unused-definition server file symbol definition)))
(when lints.var-never-set
(table.insert diagnostics (var-never-set server file symbol definition))))
(when (. file.lexical symbol)
(each [_ lint (ipairs lints.definition)]
(when (. server.configuration.lints lint.name)
(table.insert file.diagnostics (lint.impl server file symbol definition))))))
(each [symbol (pairs file.references)]
(when (. file.lexical symbol)
(each [_ lint (ipairs lints.reference)]
(when (. server.configuration.lints lint.name)
(table.insert file.diagnostics (lint.impl server file symbol))))))
(each [[head &as ast] (pairs file.calls)]
(when (and (. file.lexical ast) (not= nil head))
(each [_ lint (ipairs (if (special? head)
lints.special-call
lints.function-call))]
(when (and (. server.configuration.lints lint.name)
(or (not lint.target) (sym? head lint.target)))
(table.insert file.diagnostics (lint.impl server file head ast))))))
(each [[head &as ast] macroexpanded (pairs file.macro-calls)]
(when (. file.lexical ast)
(each [_ lint (ipairs lints.macro-call)]
(when (and (. server.configuration.lints lint.name)
(or (not lint.target) (sym? head lint.target)))
(table.insert file.diagnostics (lint.impl server file ast macroexpanded)))))))
;; call lints
;; all non-macro calls. This only covers specials and function calls.
(each [[head &as call] (pairs file.calls)]
(when head
(when (or lints.bad-unpack lints.inline-unpack)
(table.insert diagnostics (bad-unpack server file head call)))
(when lints.unnecessary-method
(table.insert diagnostics (unnecessary-method server file head call)))
(when lints.unnecessary-do
(table.insert diagnostics (unnecessary-do-values server file head call)))
(when lints.unnecessary-tset
(table.insert diagnostics (unnecessary-tset server file head call)))
(when lints.redundant-do
(table.insert diagnostics (redundant-do server file head call)))
(when lints.op-with-no-arguments
(table.insert diagnostics (op-with-no-arguments server file head call)))
(when lints.no-decreasing-comparison
(table.insert diagnostics (no-decreasing-comparison server file head call)))
(when lints.empty-let
(table.insert diagnostics (empty-let server file head call)))
;; argument lints
;; every argument to a special or a function call
;; TODO: This may be changed to run for function calls, but not special calls.
;; I'll wait till we have more lints in here to see if it needs to change.
(for [index 2 (length call)]
(let [arg (. call index)]
(when lints.multival-in-middle-of-call
(table.insert diagnostics
(multival-in-middle-of-call server file head call
arg index)))))))
(each [ast (pairs file.lexical)]
(when lints.match-should-case
(table.insert diagnostics (match-should-case server file ast))))
(when lints.unknown-module-field
(unknown-module-field server file))))
{: add-lint-diagnostics}
{: add-lint-diagnostics
:list all-lints}