Add documentSymbol support

Implements LSP documentSymbol request to provide a list of all symbols
defined in the current document. Clients use it to show an outline and
allow to jump to the symbol's definition.
This commit is contained in:
Michele Campeotto 2025-10-16 17:18:52 +02:00 committed by XeroOl
parent 53426c0a3e
commit 9ea6a0965b
10 changed files with 242 additions and 27 deletions

View File

@ -1,6 +1,7 @@
# Changelog
### Features
* Add `documentSymbol` support, for jumping to symbols definitions in the current document
* 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

View File

@ -38,7 +38,7 @@ find the definition `10`, but if `opts.stop-early?` is set, it would find
{:binding z :definition y}, referring to the `(local z y)` binding.
"
(local {: sym? : list? : sequence? : varg?} (require :fennel))
(local {: sym? : multi-sym? : list? : sequence? : varg?} (require :fennel))
(local {: get-ast-info &as utils} (require :fennel-ls.utils))
(local files (require :fennel-ls.files))
(local docs (require :fennel-ls.docs))
@ -301,6 +301,29 @@ initialization-opts: {:stack ?list[ast]
(fcollect [i 1 (length parents)]
(. parents (- (length parents) i -1)))))
(λ find-document-symbols [server file]
"Find all the symbols defined in the file
returns a sequential table of tables containing each symbol and its definition."
(compiler.compile server file)
(let [symbols []]
(each [symbol definition (pairs file.definitions)]
(when (and (or (sym? symbol) (multi-sym? symbol))
definition.binding
(. file.lexical symbol) ; exclude gensyms
(not (= (tostring symbol) "_")))
(table.insert symbols {: symbol : definition}))
; definitions doesn't have multi-syms so we get them out of fields
(when definition.fields
(each [_field-name field-definition (pairs definition.fields)]
(when (and field-definition.binding
(or (sym? field-definition.binding)
(multi-sym? field-definition.binding)))
(table.insert symbols
{:symbol field-definition.binding
:definition field-definition})))))
symbols))
(λ find-nearest-call [server file byte]
"Find the nearest call
@ -334,6 +357,7 @@ returns the called symbol and the number of the argument closest to byte"
(search server file symbol {:stop-early? true} {:byte ?byte})))
{: find-symbol
: find-document-symbols
: find-nearest-call
: find-nearest-definition
: find-definition

View File

@ -43,7 +43,7 @@ Every time the client sends a message, it gets handled by a function in the corr
;; :implementationProvider nil
:referencesProvider {:workDoneProgress false}
:documentHighlightProvider {:workDoneProgress false}
;; :documentSymbolProvider nil
:documentSymbolProvider {:workDoneProgress false}
:codeActionProvider {:workDoneProgress false}
;; :codeLensProvider nil
;; :documentLinkProvider nil
@ -159,6 +159,11 @@ Every time the client sends a message, it gets handled by a function in the corr
:range (message.ast->range server file symbol)})
(catch _ nil))))
(λ requests.textDocument/documentSymbol [server _send {:textDocument {: uri}}]
(let [file (files.get-by-uri server uri)
symbols (analyzer.find-document-symbols server file)]
(message.document-symbol-format server file symbols)))
(set {:textDocument/completion requests.textDocument/completion
:completionItem/resolve requests.completionItem/resolve}
(require :fennel-ls.completion))

View File

@ -43,6 +43,12 @@ LSP json objects."
:WARN :warning
_ (string.lower k))))
(local symbol-kind
{:File 1 :Module 2 :Namespace 3 :Package 4 :Class 5 :Method 6 :Property 7
:Field 8 :Constructor 9 :Enum 10 :Interface 11 :Function 12 :Variable 13
:Constant 14 :String 15 :Number 16 :Boolean 17 :Array 18 :Object 19 :Key 20
:Null 21 :EnumMember 22 :Struct 23 :Event 24 :Operator 25 :TypeParameter 26})
(λ create-error [code message ?id ?data]
{:jsonrpc "2.0"
:id ?id
@ -124,6 +130,35 @@ LSP json objects."
{:type (. severity msg-type)
: message}))
(λ definition->symbol-kind [definition]
(let [def definition.definition]
(if (fennel.list? def)
(let [head (. def 1)]
(if (or (fennel.sym? head :fn)
(fennel.sym? head :lambda)
(fennel.sym? head :λ))
symbol-kind.Function
symbol-kind.Variable))
symbol-kind.Variable)))
(λ document-symbol-format [server file symbols]
(let [symbols (icollect [_ {: symbol : definition} (ipairs symbols)]
(let [name (tostring symbol)
kind (definition->symbol-kind definition)
range (ast->range server file definition.binding)]
(when range
{: name
: kind
: range
:selectionRange range})))]
; the spec doesn't define an order, and not all clients sort the results
(table.sort symbols
(fn [a b]
(or (< a.range.start.line b.range.start.line)
(and (= a.range.start.line b.range.start.line)
(< a.range.start.character b.range.start.character)))))
symbols))
{: create-notification
: create-request
: create-response
@ -137,4 +172,6 @@ LSP json objects."
: severity
: severity->string
: show-message
: unknown-range}
: unknown-range
: document-symbol-format
: symbol-kind}

View File

@ -1,18 +1,8 @@
(local faith (require :faith))
(local {: create-client} (require :test.utils))
(local {: create-client : range-comparator} (require :test.utils))
(local {: null} (require :dkjson))
(local {: view} (require :fennel))
(fn range-comparator [a b]
(or (< a.range.start.line b.range.start.line)
(and (= a.range.start.line b.range.start.line)
(or (< a.range.start.character b.range.start.character)
(and (= a.range.start.character b.range.start.character)
(or (< a.range.end.line b.range.end.line)
(and (= a.range.end.line b.range.end.line)
(or (< a.range.end.character b.range.end.character)
(= a.range.end.character b.range.end.character)))))))))
(fn check [file-contents]
(let [{: client : uri : cursor : highlights} (create-client file-contents)
[response] (client:document-highlight uri cursor)]

146
test/document-symbol.fnl Normal file
View File

@ -0,0 +1,146 @@
(local faith (require :faith))
(local {: create-client : range-comparator} (require :test.utils))
(local {: null} (require :dkjson))
(fn check [file-contents expected-symbols]
(let [{: client : uri} (create-client file-contents)
[response] (client:document-symbol uri)]
(if (not= null response.result)
(do
(table.sort response.result range-comparator)
(faith.= expected-symbols response.result))
(faith.= expected-symbols []))))
(fn test-simple-locals []
(check "(local x 10)"
[{:name "x"
:kind 13
:range {:start {:line 0 :character 7}
:end {:line 0 :character 8}}
:selectionRange {:start {:line 0 :character 7}
:end {:line 0 :character 8}}}])
(check "(local y 20)
(local z 30)"
[{:name "y"
:kind 13
:range {:start {:line 0 :character 7}
:end {:line 0 :character 8}}
:selectionRange {:start {:line 0 :character 7}
:end {:line 0 :character 8}}}
{:name "z"
:kind 13
:range {:start {:line 1 :character 17}
:end {:line 1 :character 18}}
:selectionRange {:start {:line 1 :character 17}
:end {:line 1 :character 18}}}])
nil)
(fn test-functions []
(check "(fn my-func [])"
[{:name "my-func"
:kind 12
:range {:start {:line 0 :character 4}
:end {:line 0 :character 11}}
:selectionRange {:start {:line 0 :character 4}
:end {:line 0 :character 11}}}])
(check "(λ another-fn [x] x)"
[{:name "another-fn"
:kind 12
:range {:start {:line 0 :character 4}
:end {:line 0 :character 14}}
:selectionRange {:start {:line 0 :character 4}
:end {:line 0 :character 14}}}
{:name "x"
:kind 13
:range {:start {:line 0 :character 16}
:end {:line 0 :character 17}}
:selectionRange {:start {:line 0 :character 16}
:end {:line 0 :character 17}}}])
nil)
(fn test-mixed []
(check "(local x 10)
(fn my-func [y] (+ x y))"
[{:name "x"
:kind 13
:range {:start {:line 0 :character 7}
:end {:line 0 :character 8}}
:selectionRange {:start {:line 0 :character 7}
:end {:line 0 :character 8}}}
{:name "my-func"
:kind 12
:range {:start {:line 1 :character 14}
:end {:line 1 :character 21}}
:selectionRange {:start {:line 1 :character 14}
:end {:line 1 :character 21}}}
{:name "y"
:kind 13
:range {:start {:line 1 :character 23}
:end {:line 1 :character 24}}
:selectionRange {:start {:line 1 :character 23}
:end {:line 1 :character 24}}}])
nil)
(fn test-multi-sym-functions []
(check "(local M {})
(fn M.my-func [x] x)"
[{:name "M"
:kind 13
:range {:start {:line 0 :character 7}
:end {:line 0 :character 8}}
:selectionRange {:start {:line 0 :character 7}
:end {:line 0 :character 8}}}
{:name "M.my-func"
:kind 12
:range {:start {:line 1 :character 14}
:end {:line 1 :character 23}}
:selectionRange {:start {:line 1 :character 14}
:end {:line 1 :character 23}}}
{:name "x"
:kind 13
:range {:start {:line 1 :character 25}
:end {:line 1 :character 26}}
:selectionRange {:start {:line 1 :character 25}
:end {:line 1 :character 26}}}])
(check "(local module {})
(fn module.func1 [])
(fn module.func2 [a b])"
[{:name "module"
:kind 13
:range {:start {:line 0 :character 7}
:end {:line 0 :character 13}}
:selectionRange {:start {:line 0 :character 7}
:end {:line 0 :character 13}}}
{:name "module.func1"
:kind 12
:range {:start {:line 1 :character 14}
:end {:line 1 :character 26}}
:selectionRange {:start {:line 1 :character 14}
:end {:line 1 :character 26}}}
{:name "module.func2"
:kind 12
:range {:start {:line 2 :character 14}
:end {:line 2 :character 26}}
:selectionRange {:start {:line 2 :character 14}
:end {:line 2 :character 26}}}
{:name "a"
:kind 13
:range {:start {:line 2 :character 28}
:end {:line 2 :character 29}}
:selectionRange {:start {:line 2 :character 28}
:end {:line 2 :character 29}}}
{:name "b"
:kind 13
:range {:start {:line 2 :character 30}
:end {:line 2 :character 31}}
:selectionRange {:start {:line 2 :character 30}
:end {:line 2 :character 31}}}])
nil)
{: test-simple-locals
: test-functions
: test-mixed
: test-multi-sym-functions}

View File

@ -22,6 +22,7 @@
:test.completion
:test.references
:test.document-highlight
:test.document-symbol
:test.signature-help
:test.lint
:test.code-action

View File

@ -1,20 +1,8 @@
(local faith (require :faith))
(local {: create-client} (require :test.utils))
(local {: create-client : location-comparator} (require :test.utils))
(local {: null} (require :dkjson))
(local {: view} (require :fennel))
(fn location-comparator [a b]
(or (< a.uri b.uri)
(and (= a.uri b.uri)
(or (< a.range.start.line b.range.start.line)
(and (= a.range.start.line b.range.start.line)
(or (< a.range.start.character b.range.start.character)
(and (= a.range.start.character b.range.start.character)
(or (< a.range.end.line b.range.end.line)
(and (= a.range.end.line b.range.end.line)
(or (< a.range.end.character b.range.end.character)
(= a.range.end.character b.range.end.character)))))))))))
(fn check [file-contents]
(let [{: client : uri : cursor : locations} (create-client file-contents)
[response] (client:references uri cursor)]

View File

@ -60,6 +60,11 @@
{: position
:textDocument {:uri file}})))
(fn document-symbol [self file]
(dispatch.handle* self.server
(message.create-request (next-id! self) :textDocument/documentSymbol
{:textDocument {:uri file}})))
(fn signature-help [self file position]
(dispatch.handle*
self.server
@ -105,6 +110,7 @@
: hover
: references
: document-highlight
: document-symbol
: signature-help
: rename
: code-action

View File

@ -104,7 +104,24 @@
(fn position-past-end-of-text [text ?encoding]
(utils.byte->position text (+ (length text) 1) (or ?encoding default-encoding)))
(fn range-comparator [a b]
(or (< a.range.start.line b.range.start.line)
(and (= a.range.start.line b.range.start.line)
(or (< a.range.start.character b.range.start.character)
(and (= a.range.start.character b.range.start.character)
(or (< a.range.end.line b.range.end.line)
(and (= a.range.end.line b.range.end.line)
(or (< a.range.end.character b.range.end.character)
(= a.range.end.character b.range.end.character)))))))))
(fn location-comparator [a b]
(or (< a.uri b.uri)
(and (= a.uri b.uri)
(range-comparator a b))))
{: create-client
: position-past-end-of-text
: parse-markup
: range-comparator
: location-comparator
: NIL}