From fe257c061a59b291f0c4304fe8a1c3e9d729bff2 Mon Sep 17 00:00:00 2001 From: XeroOl Date: Wed, 12 Jul 2023 23:30:59 -0500 Subject: [PATCH] Add primitive support for renaming --- TODO.md | 10 ++++------ src/fennel-ls/handlers.fnl | 35 ++++++++++++++++++++++----------- src/fennel-ls/language.fnl | 6 +++++- src/fennel-ls/message.fnl | 15 ++++++++++++++ src/fennel-ls/utils.fnl | 15 +++++++++++++- test/client.fnl | 10 +++++++++- test/diagnostic-test.fnl | 4 ++-- test/init.fnl | 1 + test/references-test.fnl | 6 +++--- test/rename-test.fnl | 40 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 117 insertions(+), 25 deletions(-) create mode 100644 test/rename-test.fnl diff --git a/TODO.md b/TODO.md index bb2296d..149eaf8 100644 --- a/TODO.md +++ b/TODO.md @@ -7,10 +7,8 @@ My current goal is to work on completions a little bit more. Here is my feature wishlist. I don't expect to ever get all of this done, but these are the sort of enhancements I am thinking about. - [X] Able to connect to a client -- [ ] Support for UTF-8 characters that aren't just plain ASCII. (especially `λ`) (perhaps just tell the IDE that I want to communicate with utf-8 offsets) -- [ ] Settings to configure lua / fennel path, allowed globals, etc -- [ ] Supporting builds for anything other than arch linux -- [ ] Testing/support/instructions for any clients: (LSP is supposed to be editor-agnostic, but that's only if you're able to actually follow the spec, and I'm not sure that fennel-ls is compliant) +- [X] Support for UTF-8 characters that aren't just plain ASCII. (especially `λ`) (perhaps just tell the IDE that I want to communicate with utf-8 offsets) +- [ ] People have tried fennel-ls in: - [X] Neovim (This project isn't a neovim plugin, but there are instructions on how to inform neovim of the fennel-ls binary once you build it.) - [X] emacs - [X] helix @@ -86,8 +84,8 @@ Here is my feature wishlist. I don't expect to ever get all of this done, but th - [ ] lua version - [ ] allowed global list - [X] enable/disable various linters -- [ ] rename - - [ ] local symbols +- [X] rename + - [X] local symbols - [ ] module fields (may affect code behavior, may modify other files) - [ ] arbitrary fields (may affect code behavior, may modify other files) - [ ] formatting with fnlfmt diff --git a/src/fennel-ls/handlers.fnl b/src/fennel-ls/handlers.fnl index 6d89dec..66bee87 100644 --- a/src/fennel-ls/handlers.fnl +++ b/src/fennel-ls/handlers.fnl @@ -40,7 +40,7 @@ Every time the client sends a message, it gets handled by a function in the corr ;; :documentFormattingProvider nil ;; :documentRangeFormattingProvider nil ;; :documentOnTypeFormattingProvider nil - ;; :renameProvider nil + :renameProvider {:workDoneProgress false} ;; :foldingRangeProvider nil ;; :executeCommandProvider nil ;; :selectionRangeProvider nil @@ -89,17 +89,13 @@ Every time the client sends a message, it gets handled by a function in the corr byte (utils.position->byte file.text position self.position-encoding)] (case-try (language.find-symbol file.ast byte) symbol - (if (. file.definitions symbol) - (values (. file.definitions symbol) file) - (language.search-main self file symbol {:stop-early? true} byte)) - ({: referenced-by &as definition} result-file) - (let [result - (icollect [_ symbol (ipairs referenced-by)] - ;; TODO I currently assume all references are in the same file - (message.range-and-uri self result-file symbol))] + (language.find-nearest-definition self file symbol byte) + (where (definition def-file) (not= definition.referenced-by nil)) + (let [result (icollect [_ symbol (ipairs definition.referenced-by)] + (message.range-and-uri self def-file symbol))] (if ?include-declaration? (table.insert result - (message.range-and-uri self result-file definition.binding))) + (message.range-and-uri self def-file definition.binding))) ;; TODO don't include duplicates result) @@ -138,7 +134,6 @@ Every time the client sends a message, it gets handled by a function in the corr def (formatter.completion-item-format name def) _ {:label name})) - (λ scope-completion [self file byte ?symbol parents] (let [scope (or (accumulate [result nil _ parent (ipairs parents) @@ -182,6 +177,24 @@ Every time the client sends a message, it gets handled by a function in the corr (where (or nil [_ nil])) (scope-completion self file byte ?symbol parents) [_a _b &as split] (field-completion self file ?symbol split)))) +(λ requests.textDocument/rename [self send {: position :textDocument {: uri} :newName new-name}] + (let [file (state.get-by-uri self uri) + byte (utils.position->byte file.text position self.position-encoding)] + (case-try (language.find-symbol file.ast byte) + symbol + (language.find-nearest-definition self file symbol symbol.bytestart) + ;; TODO we are assuming that every reference is in the same file + (where (definition def-file) (not= definition.referenced-by nil)) + (let [usages (icollect [_ symbol (ipairs definition.referenced-by) + &into [{:range (message.multisym->range self def-file definition.binding 1) + :newText new-name}]] + {:newText new-name + :range (message.multisym->range self def-file symbol 1)})] + ;; NOTE: I don't care about encoding here because we just need the relative positions + (table.sort usages #(> (utils.position->byte def-file.text $1.range.start :utf-8) + (utils.position->byte def-file.text $2.range.start :utf-8))) + {:changes {def-file.uri usages}}) + (catch _ nil)))) (λ 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 6b3ebf8..13beac2 100644 --- a/src/fennel-ls/language.fnl +++ b/src/fennel-ls/language.fnl @@ -218,7 +218,6 @@ Returns: (recurse key byte) (contains? value byte) (recurse value byte))))))) - (values (accumulate [result nil i top-level-form (ipairs ast) &until result] (if (contains? top-level-form byte) @@ -226,8 +225,13 @@ Returns: (fcollect [i 1 (length parents)] (. parents (- (length parents) i -1))))) +(λ find-nearest-definition [self file symbol ?byte] + (if (. file.definitions symbol) + (values (. file.definitions symbol) file) + (search-main self file symbol {:stop-early? true} ?byte))) {: find-symbol + : find-nearest-definition : search-main : search-assignment : search-name-and-scope diff --git a/src/fennel-ls/message.fnl b/src/fennel-ls/message.fnl index f00fbc4..32dd137 100644 --- a/src/fennel-ls/message.fnl +++ b/src/fennel-ls/message.fnl @@ -59,6 +59,20 @@ to look to fix this in the future." {:start (utils.byte->position file.text bytestart self.position-encoding) :end (utils.byte->position file.text (+ byteend 1) self.position-encoding)})) +(λ multisym->range [self file ast n] + (let [spl (utils.multi-sym-split ast)] + (case (values (utils.get-ast-info ast :bytestart) + (utils.get-ast-info ast :byteend)) + (bytestart byteend) + (let [bytesubstart (faccumulate [b bytestart + i 1 (- n 1)] + (+ b (length (. spl i)) 1)) + bytesubend (faccumulate [b byteend + i (+ n 1) (length spl)] + (- b (length (. spl i)) 1))] + {:start (utils.byte->position file.text bytesubstart self.position-encoding) + :end (utils.byte->position file.text (+ bytesubend 1) self.position-encoding)})))) + (λ range-and-uri [self {: uri &as file} ?ast] "if possible, returns the location of a symbol" (case (ast->range self file ?ast) @@ -75,6 +89,7 @@ to look to fix this in the future." : create-response : create-error : ast->range + : multisym->range : range-and-uri : diagnostics : severity} diff --git a/src/fennel-ls/utils.fnl b/src/fennel-ls/utils.fnl index 98d161a..754512a 100644 --- a/src/fennel-ls/utils.fnl +++ b/src/fennel-ls/utils.fnl @@ -119,10 +119,22 @@ These functions are all pure functions, which makes me happy." ;; Handle a change {:range {: start : end} : text} (replace contents start end text encoding) - ;; A replacment of the entire body + ;; A replacement of the entire body {: text} 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" + (accumulate + [contents initial-text + _ edit (ipairs edits)] + (case edit + ;; Handle a change + {:range {: start : end} : newText} + (replace contents start end newText encoding)))) + (λ get-ast-info [?ast info] ;; find a given key of info from an AST object (or (?. (getmetatable ?ast) info) @@ -151,6 +163,7 @@ These functions are all pure functions, which makes me happy." : byte->position : position->byte : apply-changes + : apply-edits : multi-sym-split : get-ast-info : type=} diff --git a/test/client.fnl b/test/client.fnl index 010db0c..71e5e24 100644 --- a/test/client.fnl +++ b/test/client.fnl @@ -75,12 +75,20 @@ :textDocument {:uri file} :context {:includeDeclaration (not (not ?includeDeclaration))}}))) +(fn rename [self file line character newName] + (dispatch.handle* self.server + (message.create-request (next-id! self) :textDocument/rename + {:position {: line : character} + :textDocument {:uri file} + : newName}))) + (set mt.__index {: open-file! : completion : definition : hover - : references}) + : references + : rename}) {: create-client : ROOT-URI diff --git a/test/diagnostic-test.fnl b/test/diagnostic-test.fnl index 3a1e33f..701c44f 100644 --- a/test/diagnostic-test.fnl +++ b/test/diagnostic-test.fnl @@ -57,8 +57,8 @@ (is-matching responses [{:params {:diagnostics - [{:range {:start {:character a :line b} - :end {:character c :line d}}}]}}] + [{:range {:start {:character 0 :line 0} + :end {:character 7 :line 0}}}]}}] "diagnostics should always have a range"))) (it "gives more than one error" diff --git a/test/init.fnl b/test/init.fnl index b2c88e6..254770d 100644 --- a/test/init.fnl +++ b/test/init.fnl @@ -6,6 +6,7 @@ (require :test.json-rpc-test) (require :test.misc-test) (require :test.references-test) +(require :test.rename-test) (require :test.settings-test) (require :test.string-processing-test) diff --git a/test/references-test.fnl b/test/references-test.fnl index ebb5939..390a2c7 100644 --- a/test/references-test.fnl +++ b/test/references-test.fnl @@ -40,9 +40,9 @@ (check-references "(fn x []) x" 0 10 [{:uri filename :range (range 0 10 0 11)}])) - ; (it "finds a reference from fn" - ; (check-references "(fn x []) x" 0 4 - ; [{:uri filename :range (range 0 10 0 11)}])) + (it "finds a reference from fn" + (check-references "(fn x []) x" 0 4 + [{:uri filename :range (range 0 10 0 11)}])) (it "doesn't crash here" (check-references "(let [x nil] x.y)" 0 14 diff --git a/test/rename-test.fnl b/test/rename-test.fnl new file mode 100644 index 0000000..f7ae131 --- /dev/null +++ b/test/rename-test.fnl @@ -0,0 +1,40 @@ +(import-macros {: is-matching : is-casing : describe : it : before-each} :test) +(local utils (require :fennel-ls.utils)) +(local is (require :test.is)) + +(local {: view} (require :fennel)) +(local {: ROOT-URI + : create-client} (require :test.client)) + +(local filename (.. ROOT-URI "/imaginary-file.fnl")) + +(fn check-rename [body line col new-name new-body] + (let [client (doto (create-client) + (: :open-file! filename body)) + [{: result}] (client:rename filename line col new-name) + changes (. result.changes filename) + body (. client.server.files filename :text)] + (is.equal + (utils.apply-edits body changes client.server.position-encoding) + new-body))) + +(describe "rename" + (it "renames a variable" + (check-rename "(let [old-name 100] old-name)" 0 9 :new-name + "(let [new-name 100] new-name)")) + + (it "renames a variable 2" + (check-rename "(let [old-name 100] (print old-name) (print old-name))" 0 9 :new-name!! + "(let [new-name!! 100] (print new-name!!) (print new-name!!))")) + + (it "renames a multisym" + (check-rename "(let [old-name {:field 10}] old-name.field)" 0 9 :new-name + "(let [new-name {:field 10}] new-name.field)") + (check-rename "(let [old-name {:field 10}] old-name.field)" 0 30 :new-name + "(let [new-name {:field 10}] new-name.field)")) + + (it "renames from destructure/args" + (check-rename "(fn [{: x}] x)" 0 8 :foo "(fn [{: foo}] foo)") + (check-rename "(fn [{:x x}] x)" 0 9 :foo "(fn [{:x foo}] foo)"))) + +