From 22b4235073b9dda693b37ed7e670ab50bc2ae39e Mon Sep 17 00:00:00 2001 From: XeroOl Date: Sun, 14 Aug 2022 22:12:55 -0500 Subject: [PATCH] Adds support for hovering over symbols --- README.md | 4 +- src/fennel-ls/compiler.fnl | 7 +- src/fennel-ls/formatter.fnl | 50 ++++++++++ src/fennel-ls/handlers.fnl | 25 ++++- src/fennel-ls/language.fnl | 92 ++++++++++--------- src/fennel-ls/utils.fnl | 6 +- test/goto-definition-test.fnl | 34 +++---- test/hover-test.fnl | 44 +++++++++ test/init.fnl | 1 + .../{example.fnl => goto-definition.fnl} | 2 +- test/test-project/hover.fnl | 33 +++++++ 11 files changed, 228 insertions(+), 70 deletions(-) create mode 100644 src/fennel-ls/formatter.fnl create mode 100644 test/hover-test.fnl rename test/test-project/{example.fnl => goto-definition.fnl} (95%) create mode 100644 test/test-project/hover.fnl diff --git a/README.md b/README.md index a340f3b..c9e58c5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Features / To Do List / Things I would enjoy patches for: - [ ] Go-to-definition - [ ] directly on require statements - [X] for definitions in the same file - - [ ] for definitions in other files + - [X] for definitions in other files - [X] follows multisyms through table constructor - [ ] follows multisyms through mutations (difficult) - [ ] for methods/metamethods (difficult, in the general case may require type annotations or some insane global type inference logic) @@ -23,7 +23,7 @@ Features / To Do List / Things I would enjoy patches for: - [ ] including in macro files - [ ] Reports linting issues - [ ] Completion Suggestions -- [ ] Hover over a symbol for documentation +- [X] Hover over a symbol for documentation - [ ] Signature help - [ ] Go-to-references on definition sites - [ ] integration with fnlfmt diff --git a/src/fennel-ls/compiler.fnl b/src/fennel-ls/compiler.fnl index de02db7..29a22b1 100644 --- a/src/fennel-ls/compiler.fnl +++ b/src/fennel-ls/compiler.fnl @@ -59,8 +59,9 @@ (let [definition {: binding : ?definition - :?keys (fcollect [i 1 (length keys)] - (. keys i))}] + :?keys (if (not= 0 (length keys)) + (fcollect [i 1 (length keys)] + (. keys i)))}] (tset (. definitions-by-scope scope) (tostring binding) definition) (tset definitions binding definition)) (= :table (type binding)) @@ -77,7 +78,7 @@ (and (fennel.sym? name) (not (multisym? name)) ;; not dealing with multisym for now (fennel.sequence? args))) - (tset (. definitions-by-scope scope) ;; !!! parent or child? + (tset (. definitions-by-scope scope) ;; !!! TODO somehow insert into child scope (tostring name) {:binding name :?definition ast}))) diff --git a/src/fennel-ls/formatter.fnl b/src/fennel-ls/formatter.fnl new file mode 100644 index 0000000..0c04c9f --- /dev/null +++ b/src/fennel-ls/formatter.fnl @@ -0,0 +1,50 @@ +(local {: sym + : sym? + : view + : list} (require :fennel)) +(local {: type=} (require :fennel-ls.utils)) + + +(local -fn- (sym :fn)) +(local -varg- (sym :...)) + +(λ code-block [str] + (.. "```fnl\n" str "\n```")) + +(local width 80) +(fn fn-format [name args docstring] + (.. "(fn" + (if name (.. " " (tostring name)) "") + (.. " " (view args {:one-line? true :prefer-colon? true})) + " ...)" + (if docstring (.. "\n" docstring) ""))) + +(λ hover-format [result] + (code-block + (match result.?definition + ;; name + docstring + (where [-fn- name args docstring body] + (and (sym? name) + (type= args :table) + (type= docstring :string))) + (fn-format name args docstring) + ;; docstring + (where [-fn- args docstring body] + (and (type= args :table) + (type= docstring :string))) + (fn-format nil args docstring) + ;; name + (where [-fn- name args] + (and (sym? name) + (type= args :table))) + (fn-format name args nil) + ;; none + (where [-fn- args] + (and (type= args :table))) + (fn-format nil args nil) + ?anything-else + (if result.?keys + (view result.?keys) + (view ?anything-else {:prefer-colon? true}))))) + +{: hover-format} diff --git a/src/fennel-ls/handlers.fnl b/src/fennel-ls/handlers.fnl index 89febcc..c63bdbb 100644 --- a/src/fennel-ls/handlers.fnl +++ b/src/fennel-ls/handlers.fnl @@ -8,6 +8,8 @@ Every time the client sends a message, it gets handled by a function in the corr (local message (require :fennel-ls.message)) (local state (require :fennel-ls.state)) (local language (require :fennel-ls.language)) +(local formatter (require :fennel-ls.formatter)) + (local requests []) (local notifications []) @@ -16,7 +18,7 @@ Every time the client sends a message, it gets handled by a function in the corr {:textDocumentSync 1 ;; FIXME: upgrade to 2 ;; :notebookDocumentSync nil ;; :completionProvider nil - ;; :hoverProvider nil + :hoverProvider {:workDoneProgress false} ;; :signatureHelpProvider nil ;; :declarationProvider nil :definitionProvider {:workDoneProgress false}}) @@ -63,9 +65,24 @@ Every time the client sends a message, it gets handled by a function in the corr (local byte (pos->byte file.text position.line position.character)) (match (language.find-symbol file.ast byte) symbol - (match (language.search-main self file symbol []) - (definition result-file) ;; curse you, magical match rules - (message.range-and-uri definition result-file)))) + (match (language.search-main self file symbol) + (result result-file) ;; curse you, magical match rules + (message.range-and-uri + (or result.binding result.?definition) + result-file)))) + +(λ requests.textDocument/hover [self send {: position :textDocument {: uri}}] + (local file (state.get-by-uri self uri)) + (local byte (pos->byte file.text position.line position.character)) + (match (language.find-symbol file.ast byte) + symbol + (match (language.search-main self file symbol) + result + {:contents + {:kind + "markdown" + :value + (formatter.hover-format result)}}))) (λ notifications.textDocument/didChange [self send {: contentChanges :textDocument {: uri}}] (local file (state.get-by-uri self uri)) diff --git a/src/fennel-ls/language.fnl b/src/fennel-ls/language.fnl index 9723b35..b226541 100644 --- a/src/fennel-ls/language.fnl +++ b/src/fennel-ls/language.fnl @@ -8,50 +8,53 @@ (local sym? fennel.sym?) (local list? fennel.list?) -(var - (search-assignment - search-symbol - search) - nil) +(local -require- (fennel.sym :require)) +(local -dot- (fennel.sym :.)) -(set search-assignment - (λ search-assignment [self file {: binding : ?definition : ?keys} stack] - (if (= 0 (length stack)) - (values binding file) ;; BASE CASE!! - (do - (if ?keys - (fcollect [i (length ?keys) 1 -1 &into stack] - (. ?keys i))) - (search self file ?definition stack))))) +(var search nil) ;; all of the search functions are mutually recursive -(set search-symbol - (λ search-symbol [self file symbol stack] - (let [split (utils.multi-sym-split symbol)] +(λ search-assignment [self file {: binding : ?definition : ?keys &as assignment} stack] + (if (= 0 (length stack)) + (values assignment file) ;; BASE CASE!! + (do + (if ?keys + (fcollect [i (length ?keys) 1 -1 &into stack] + (. ?keys i))) + (search self file ?definition stack)))) + +(λ search-symbol [self file symbol stack] + (let [split (utils.multi-sym-split symbol)] + (fcollect [i (length split) 2 -1 &into stack] + (. split i))) ;; TODO test coverage for this line + (match (. file.references symbol) + to (search-assignment self file to stack))) + +(λ search-table [self file tbl stack] + (if (. tbl (. stack (length stack))) + (search self file (. tbl (table.remove stack)) stack) + (= 0 (length stack)) + (values {:?definition tbl} file) ;; BASE CASE !! + nil)) ;; BASE CASE Give up + +(λ search-list [self file call stack] + (match call + [-require- mod] + (let [newfile (state.get-by-module self mod) + newitem (. newfile.ast (length newfile.ast))] + (search self newfile newitem stack)) + [-dot- & split] + (do (fcollect [i (length split) 2 -1 &into stack] - (. split i))) ;; TODO test coverage for this line - (match (. file.references symbol) - to (search-assignment self file to stack)))) - + (. split i)) + (search self file (. split 1) stack)))) (set search (λ search [self file item stack] - (if - (fennelutils.table? item) - (if (. item (. stack (length stack))) - (search self file (. item (table.remove stack)) stack) - (= 0 (length stack)) - (values item file) ;; BASE CASE !! - nil) ;; BASE CASE Give up - (sym? item) - (search-symbol self file item stack) - ;; TODO - ;; functioncall (continue searching in body with parameters bound) - (match item - [-require- mod] - (let [newfile (state.get-by-module self mod) - newitem (. newfile.ast (length newfile.ast))] - (search self newfile newitem stack)) - _ (error (.. "I don't know what to do with " (fennel.view item))))))) + (if (fennelutils.table? item) (search-table self file item stack) + (sym? item) (search-symbol self file item stack) + (list? item) (search-list self file item stack) + (= 0 (length stack)) {:?definition item} ;; BASE CASE !! + (error (.. "I don't know what to do with " (fennel.view item)))))) (λ search-main [self file symbol] ;; TODO partial byting, go to different defitition sites depending on which section of the symbol the trigger happens on @@ -67,7 +70,12 @@ (ref _) (search-assignment self file ref stack) (_ def) - (search self file def.?definition stack))) + (do + (if def.?keys + (fcollect [i (length def.?keys) 1 -1 &into stack] + (. def.?keys i))) + (search self file def.?definition stack)))) + ;; (search self file def.?definition stack)))) (λ past? [?ast byte] ;; check if a byte is past an ast object @@ -96,9 +104,8 @@ (+ 1 (get-ast-info ?ast :byteend)))))) (λ find-symbol [ast byte ?recursively-called] - (if (not= :table (type ast)) - nil - (does-not-contain? ast byte) + (if (or (not= :table (type ast)) + (does-not-contain? ast byte)) nil (and (sym? ast) (contains? ast byte)) ast @@ -121,5 +128,4 @@ (find-symbol v byte true))))) {: find-symbol - : search-symbol : search-main} diff --git a/src/fennel-ls/utils.fnl b/src/fennel-ls/utils.fnl index e05d510..4f1e180 100644 --- a/src/fennel-ls/utils.fnl +++ b/src/fennel-ls/utils.fnl @@ -84,10 +84,14 @@ These functions are all pure functions, which makes me happy." (icollect [word (: (.. sym ".") :gmatch "(.-)[%.:]")] word)) +(λ type= [val typ] + (= (type val) typ)) + {: uri->path : path->uri : pos->byte : byte->pos : apply-changes : multi-sym-split - : get-ast-info} + : get-ast-info + : type=} diff --git a/test/goto-definition-test.fnl b/test/goto-definition-test.fnl index 3451036..4285517 100644 --- a/test/goto-definition-test.fnl +++ b/test/goto-definition-test.fnl @@ -26,47 +26,49 @@ (.. "expected position: " start-line " " start-col " " end-line " " end-col)))) (it "can go to a fn" - (check "example.fnl" 9 3 "example.fnl" 4 4 4 7)) + (check :goto-definition.fnl 9 3 :goto-definition.fnl 4 4 4 7)) (it "can go to a local" - (check "example.fnl" 7 17 "example.fnl" 6 9 6 10)) + (check :goto-definition.fnl 7 17 :goto-definition.fnl 6 9 6 10)) (it "can go to a function argument" - (check "example.fnl" 5 9 "example.fnl" 4 9 4 10)) + (check :goto-definition.fnl 5 9 :goto-definition.fnl 4 9 4 10)) (it "can handle variables shadowed with let" - (check "example.fnl" 14 10 "example.fnl" 13 6 13 9)) + (check :goto-definition.fnl 14 10 :goto-definition.fnl 13 6 13 9)) (it "can sort out the unification rule with match (variable unified)" - (check "example.fnl" 19 12 "example.fnl" 17 8 17 9)) + (check :goto-definition.fnl 19 12 :goto-definition.fnl 17 8 17 9)) (it "can sort out the unification rule with match (variable introduced)" - (check "example.fnl" 20 13 "example.fnl" 20 9 20 10)) + (check :goto-definition.fnl 20 13 :goto-definition.fnl 20 9 20 10)) (it "can go to a destructured local" - (check "example.fnl" 21 9 "example.fnl" 16 13 16 16)) + (check :goto-definition.fnl 21 9 :goto-definition.fnl 16 13 16 16)) (it "can go to a function inside a table" - (check "example.fnl" 28 6 "example.fnl" 4 4 4 7)) + (check :goto-definition.fnl 28 6 :goto-definition.fnl 4 4 4 7)) - ;; (it "can go to a field inside of a table") + (it "can go to a field inside of a table literal" + (check :goto-definition.fnl 35 19 :goto-definition.fnl 34 20 34 35)) (it "can go to a function in another file when accessed by multisym" - (check "example.fnl" 7 7 "foo.fnl" 2 4 2 13)) + (check :goto-definition.fnl 7 7 :foo.fnl 2 4 2 13)) (it "goes further if you go to definition on a binding" - (check "example.fnl" 31 12 "example.fnl" 23 4 23 5)) - + (check :goto-definition.fnl 31 12 :goto-definition.fnl 23 4 23 5)) ;; (it "can go to a destructured function argument") - ;; it can go up and down destructuring - (it "can trace a variable that was introduced with destructuring assignment" - (check "example.fnl" 38 15 "example.fnl" 33 7 33 13))) + (it "can go up and down destructuring" + (check :goto-definition.fnl 38 15 :goto-definition.fnl 33 7 33 13)) + + (it "can go up and down field accesses" + (check :goto-definition.fnl 45 15 :goto-definition.fnl 40 7 40 13))) ;; (it "works directly on a require/include (require XXX))" - ;; (check "example.fnl" 1 5 "bar.fnl" 0 0 0 0)) + ;; (check :goto-definition.fnl 1 5 :bar.fnl 0 0 0 0))) ;; (it "can go to a reference that occurs in a macro") ;; (it "doesn't have ghost definitions from the same byte ranges as the macro files it's using") diff --git a/test/hover-test.fnl b/test/hover-test.fnl new file mode 100644 index 0000000..ae754c2 --- /dev/null +++ b/test/hover-test.fnl @@ -0,0 +1,44 @@ +(import-macros {: is-matching : describe : it : before-each} :test.macros) +(local is (require :luassert)) + +(local fennel (require :fennel)) +(local {: ROOT-URI + : setup-server} (require :test.util)) + +(local dispatch (require :fennel-ls.dispatch)) +(local message (require :fennel-ls.message)) + +(describe "hover" + + (fn check [request-file line char response-string] + (local state (doto [] setup-server)) + (let [message (dispatch.handle* state + (message.create-request 2 "textDocument/hover" + {:position {:character char :line line} + :textDocument {:uri (.. ROOT-URI "/" request-file)}}))] + (is-matching + message + [{:jsonrpc "2.0" :id 2 + :result + {:contents + {:kind "markdown" + :value response-string}}}] + (.. "expected response: " (fennel.view response-string))))) + + (it "hovers over a function" + (check "hover.fnl" 6 6 "```fnl\n(fn my-function [arg1 arg2 arg3] ...)\n```")) + + (it "hovers over a literal number" + (check "hover.fnl" 6 16 "```fnl\n300\n```")) + + (it "hovers over a literal string" + (check "hover.fnl" 6 19 "```fnl\n\"some text\"\n```")) + + (it "hovers over a field number" + (check "hover.fnl" 9 20 "```fnl\n10\n```")) + + (it "hovers over a field string" + (check "hover.fnl" 9 30 "```fnl\n:colon-string\n```")) + + (it "hovers over a literal nil" + (check "hover.fnl" 12 9 "```fnl\nnil\n```"))) diff --git a/test/init.fnl b/test/init.fnl index d6ec54e..f9b2626 100644 --- a/test/init.fnl +++ b/test/init.fnl @@ -4,4 +4,5 @@ (require :test.string-processing-test) (require :test.lsp-test) (require :test.goto-definition-test) +(require :test.hover-test) (require :test.misc-test) diff --git a/test/test-project/example.fnl b/test/test-project/goto-definition.fnl similarity index 95% rename from test/test-project/example.fnl rename to test/test-project/goto-definition.fnl index c1edd5c..019568d 100644 --- a/test/test-project/example.fnl +++ b/test/test-project/goto-definition.fnl @@ -42,5 +42,5 @@ (local deep {:a {:b {:field findme}}}) (local shallow deep.a.b) (local mixed [{:key [5 {:foo shallow}]}]) -(local funny (. mixed 1 :key 2 foo)) +(local funny (. mixed 1 :key 2 :foo)) (print funny.field) diff --git a/test/test-project/hover.fnl b/test/test-project/hover.fnl new file mode 100644 index 0000000..7039490 --- /dev/null +++ b/test/test-project/hover.fnl @@ -0,0 +1,33 @@ +(fn my-function [arg1 arg2 arg3] + (let [result nil] + result)) + +(local foo 300) +(let [bar "some text"] + (my-function foo bar 3)) + +(local foo {:field1 10 :field2 :colon-string}) +(my-function foo.field1 foo.field2) + +(local empty nil) +(print empty) + +(fn sd [] "short docstring" + nil) +(fn ld [arg1] + "long docstring + +This function has a long docstring, and returns nil. +The docstring has newlines and markdown and stuff in it. + +```fnl +(ld 100 100) ;; ==> nil +``` + +@arg arg1 is ignored +@arg arg2 is ignored. +@returns nil" + (let [result nil] + result)) + +(ld (sd))