clean up some lint documentation

This commit is contained in:
XeroOl 2025-07-12 16:02:21 -05:00
parent a54745c581
commit d219cfffcb
2 changed files with 65 additions and 50 deletions

View File

@ -1,46 +1,62 @@
# How to add a new lint
## Creating a new lint
To start, you can set up all the plumbing:
1. Go into `src/fennel-ls/lint.fnl` and create a new function.
2. At the bottom of `src/fennel-ls/lint.fnl`, add an if statement in the
`check` function.
* Choose which `each` loop to put your function in, so your lint can be
applied to right thing.
* add `(if checks.<your-check> (table.insert diagnostics (<your-check> self file <the rest of the args>)))`
3. Enable your lint! In `src/fennel-ls/config.fnl`, find the
`default-configuration` variable, and turn your check on by default.
Go into `src/fennel-ls/lint.fnl` and create a new call to add-lint.
## Writing your lint
Now, the fun part: writing your lint function.
The goal is to check whether the given arguments should emit a warning, and
what message to show. The current loops in `check` go over every:
what message to show. You can request that your lint is called for every
* function-call (Every time the user calls a function)
* special-call (Every time the user calls a special)
* macro-call (Every time the user calls a macro)
* definition (Every time a new variable is bound)
* call (Every time the user calls a function or a special. Macros don't count.)
* lexical (Every source-tracked AST node in the whole file, before macro expansion.
Symbols, lists, tables, and varargs will be here; numbers, strings, booleans,
and nil will not.)
* reference (Every time an identifier is referring to something in scope)
More loops might have been added since I wrote this document.
More types might have been added since I wrote this document.
### Input arguments
All lints give you `server` and `file`. They're mostly useful to pass to other
functions.
* `server` is the table that represents the language server. It carries metadata
and stuff around. You probably don't need to use it directly.
* `file` is an object that represents a .fnl file. It has some useful fields.
Check out what fields it has by looking at the end of `compiler.fnl`.
All lints receive a `server` and `file`. These values are mostly useful to
pass to other functions.
* `server` is the table that represents the language server. It carries
metadata and stuff around. You probably don't need to use it directly.
* `file` is an object that represents a fennel source file. It has some
useful fields. Check out what fields it has by looking at the end of
`compiler.fnl`.
`file.lexical` stores the value `true` for every single list, table, or symbol
that appears in the original file AST, but not things generated via
macroexpansion. Make sure that the AST you're checking is inside of
`file.lexical`; otherwise, your lint may not be actionable or relevant, because
the user won't be able to see or edit the code your lint is warning about.
The next arguments depend on which type the lint is in:
The next arguments depend on which loop the lint is in:
#### If your lint is linting definitions:
#### "Call" type lints. (aka combinations aka compound forms aka lists):
There are three call types: `function-call`, `special-call`, and `macro-call`.
* `ast` is the AST of the call. it will be a list.
* `macroexpanded` will be the AST generated from the expansion of the macro,
if the call was invoking a macro.
For example, if I had the code
```fnl
(let [(x y) (values 1 2)]
(print (+ 1 x y)))
```
and I created a `function-call` lint, My lint would would be called once
with `ast` as `(print (+ 1 x y))`. If I created a `special-call` lint,
my lint would be called with `ast` as `(let [(x y) (values 1 2)] (print (+ 1 x y)))`,
with `(values 1 2)`, and with `(+ 1 x y)`.
#### "Reference" type lints
References are any time a symbol is referring to a local or global variable.
* `symbol` is the symbol that's referring to something.
For example, in the code
```fnl
(let [x 10]
(print x))
```
`let` and `x` on line 1 are **not** references. `let` is a special, and `x` is
introducing a new binding, not referring to existing ones.
`print` and `x` on line 2 **are** references, and so a `reference` type
lint would be called for `print` and for `x`.
#### "Definition" type lints
* `symbol` is the symbol being bound. It is just a regular fennel sym.
* `definition` is a table full of information about what is being bound:
* `definition.binding` is the symbol again.
@ -55,6 +71,9 @@ The next arguments depend on which loop the lint is in:
* `definition.var?` is a boolean, which tells if the `symbol` is introduced
as a variable.
#### "Other" type lints
Don't write these. :)
For example, if I write the code `(var x 1000)`, the definition will be:
```fnl
{:definition 1000 :binding `x :var? true}
@ -66,18 +85,11 @@ the definitions will be:
{:definition `(my-expression)
:binding `x
:multival 1
:referenced-by {:symbol `x.myfield :target @1 :ref-type "read"}}
:referenced-by {:symbol `x.myfield :ref-type "read"}}
;; for y
{:definition `(my-expression) :binding `y :multival 2 :keys [:foo :bar]}
```
#### If your lint is linting calls (to functions or specials, not macros)
* `head` is the symbol that is being called. It is the same as `(. call 1)`.
* `call` is the list that represents the call.
#### If your lint is linting lexical nodes
* `ast` is the AST node
### Output:
Your lint function should return `nil` if there's nothing to report, or
return a diagnostic object representing your lint message.
@ -87,18 +99,18 @@ The return value should have these fields:
* `range`: make these with `message.ast->range` to get the range for a list or
symbol or table, or with `message.multisym->range` to get the range of a
specific segment of a multisym. Try to report specifically on which piece of
AST is wrong. If its the entire call, give the range of the call. If its a
specific argument, give the range of that argument if possible, and the call
if not. Remember that we can't get the range of things like numbers and
strings, because they don't have tracking info.
AST is wrong. If its an entire list, give the range of the list. If a
specific argument is problematic, give the range of that argument if possible,
and the call if not. `message.ast->range` will not fail on lists, symbols, or
tables, but it may fail on other AST items. (by returning `nil`)
* `message`: this is the message your lint will produce. Try to make it
specific and helpful as possible.
specific and helpful as possible; it doesn't have to be the same every time
the lint is triggered.
* `severity`: hardcode this to `message.severity.WARN`. ERROR is for compiler
errors, and WARN is for lints.
* `code`: Please use a new number counting up from 301 for each lint. The codes
are nice to have so that the message of the lint can change without breaking
tests that only check for the presence or absence of a lint.
* `codeDescription`: Use the name of your function.
* `fix`: Optional. If there's a way to address this programmatically, you can
add a "fix" field with the code to generate a quickfix. See the other lints
for examples.
### Testing:
I will think about this later. :) For now see examples in `test/lint.fnl`.

View File

@ -5,11 +5,14 @@ There are only two functions exposed here:
* `read` receives and parses a message from the client.
* `write` serializes and sends a message to the client.
It's probably not compliant yet, because serialization of [] and {} is the same.
Luckily, I'm testing with Neovim, so I can pretend these problems don't exist for now."
dkjson defaults to emitting [] when given an empty table, so we need to be
sure our object-like tables have at least one key, or we apply metatable magic
on the empty table to tell dkjson to serialize as {}."
(local {: encode : decode} (require :dkjson))
(local http-separator
(local header-separator
;; Something in windows replaces \n with \r\n,
;; so we have to leave the \r's out
(if (string.match package.config "^\\")
"\n\n"
"\r\n\r\n"))
@ -55,7 +58,7 @@ Returns a table with the message if it succeeded, or a string with the parse err
(λ write [out msg]
"Serializes and writes a JSON-RPC message to the given output stream"
(let [content (encode msg)
msg-stringified (.. "Content-Length: " (length content) http-separator content)]
msg-stringified (.. "Content-Length: " (length content) header-separator content)]
(out:write msg-stringified)
(when out.flush
(out:flush))))