diff options
Diffstat (limited to '.config/vis')
26 files changed, 4954 insertions, 0 deletions
diff --git a/.config/vis/plugins/vis-autoclose/LICENSE b/.config/vis/plugins/vis-autoclose/LICENSE new file mode 100644 index 0000000..797cc80 --- /dev/null +++ b/.config/vis/plugins/vis-autoclose/LICENSE @@ -0,0 +1,11 @@ +Copyright 2026 luxanna + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/.config/vis/plugins/vis-autoclose/README.md b/.config/vis/plugins/vis-autoclose/README.md new file mode 100644 index 0000000..37a2dde --- /dev/null +++ b/.config/vis/plugins/vis-autoclose/README.md @@ -0,0 +1,3 @@ +# vis-autoclose + +A plugin for [vis](https://git.sr.ht/~martanne/vis) which automatically closes opened brackets and also removes them when they are empty, when removing the opening bracket (using <Backspace>). Inspired by autoclose.vim.
\ No newline at end of file diff --git a/.config/vis/plugins/vis-autoclose/init.lua b/.config/vis/plugins/vis-autoclose/init.lua new file mode 100644 index 0000000..22b0039 --- /dev/null +++ b/.config/vis/plugins/vis-autoclose/init.lua @@ -0,0 +1,76 @@ +require('vis') + +local brackets = { + ["("] = "()", + ["{"] = "{}", + ["["] = "[]", + ["<"] = "<>", + ["'"] = "''", + ['"'] = '""', + ['`'] = '``', +}; + +local closed = { + [")"] = "", + ["}"] = "", + ["]"] = "", + [">"] = "", + ["'"] = "", + ['"'] = "", + ["`"] = "", +}; + +local remove = { + ["()"] = "", + ["{}"] = "", + ["[]"] = "", + ["<>"] = "", + ["''"] = "", + ['""'] = "", + ['``'] = "", +}; + +function is_bracket_or_empty(char) + return char == ' ' or + char == '\n' or + brackets[char] ~= nil or + closed[char] ~= nil +end + +vis.events.subscribe(vis.events.INPUT, function(key) + local file = vis.win.file + local cursor = vis.win.selection.pos + local next = file:content(cursor, 1) + -- closed bracket already exists, skip + if key == next and closed[next] ~= nil then + vis:feedkeys('<Right>') + return true + end + + local result = brackets[key] + if result ~= nil and is_bracket_or_empty(next) then + vis:insert(result) + vis:feedkeys('<Left>') + return true + end + return false +end) + +vis:map(vis.modes.INSERT, "<Backspace>", function(keys) + local cursor = vis.win.selection.pos + local file = vis.win.file + local prev = file:content(cursor-1, 2) + + -- Workaround until https://github.com/martanne/vis/issues/739 is solved + vis:feedkeys("<Left>") + -- Check if whole bracket pair is to be removed + if prev ~= nil then + local result = remove[prev] + if result ~= nil then + vis:feedkeys("<Delete>") + end + end + + vis:feedkeys("<Delete>") + return 1 +end, "Removes the previous character (and closed brackets, if empty)") diff --git a/.config/vis/plugins/vis-colorizer/LICENSE b/.config/vis/plugins/vis-colorizer/LICENSE new file mode 100644 index 0000000..77a589d --- /dev/null +++ b/.config/vis/plugins/vis-colorizer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Thim Cederlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.config/vis/plugins/vis-colorizer/README.md b/.config/vis/plugins/vis-colorizer/README.md new file mode 100644 index 0000000..89ca09f --- /dev/null +++ b/.config/vis/plugins/vis-colorizer/README.md @@ -0,0 +1,17 @@ +# vis-colorizer +Highlights hex colors. Created for the [vis editor](https://github.com/martanne/vis). + +Inspired by [chrisbra/Colorizer](https://github.com/chrisbra/Colorizer). + + + +## Usage + +Clone the repo to `~/.config/vis/plugins/`. + +Append the following line to your `visrc.lua`: + + local colorizer = require('plugins/vis-colorizer') + colorizer.three = false -- (optional) diables three digit hex colors + colorizer.six = true -- (enabled by default) six digit hex colors + diff --git a/.config/vis/plugins/vis-colorizer/init.lua b/.config/vis/plugins/vis-colorizer/init.lua new file mode 100644 index 0000000..9fa3a6a --- /dev/null +++ b/.config/vis/plugins/vis-colorizer/init.lua @@ -0,0 +1,100 @@ +local M = { + text_colors = { + dark = "#000000", + light = "#ffffff" + }, + -- Highlight six digit hex color codes + six = true, + -- Highlight three digit hex color hexcodes + three = false, +} + +local styleIdStack = {} + +M.styleIdIterator = function() + local i = 0 + local MAX_STYLE_ID = 64 -- UI_STYLE_LEXER_MAX = 64 + return function() + i = i + 1 + if i <= MAX_STYLE_ID then + return i + end + return nil + end +end + +M.initStyleIds = function() + styleIdStack = {} + for i in M.styleIdIterator() do + table.insert(styleIdStack, i) + end +end + +M.initStyleIds() + +M.extract_hex_colors = function(input_string) + local pattern = "#([0-9a-fA-F]+)" + local matches = {} + + local init = 1 + local match_end = 0 + while true do + match_start, match_end, hex = string.find(input_string, pattern, init + match_end) + -- TODO: add better validation + if match_start == nil or hex == nil then + break + end + if (hex:len() == 3 and M.three) or (hex:len() == 6 and M.six) then + table.insert(matches, { + starts = match_start, + ends = match_end, + hex = hex, + }) + end + end + + return matches +end + +M.draw = function(win, colors) + local offset = win.viewport['bytes'].start + for i, color in ipairs(colors) do + local fg = M.text_colors.light + + if color.hex:len() == 3 then + color.hex = string.gsub(color.hex, + "([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])", + "%1%1%2%2%3%3") + end + + local r = tonumber(color.hex:sub(1, 2), 16) + local g = tonumber(color.hex:sub(3, 4), 16) + local b = tonumber(color.hex:sub(5, 6), 16) + + if (r * 30) + (g * 59) + (b * 11) > 12000 then + fg = M.text_colors.dark + end + + local id = table.remove(styleIdStack) + + local style = "fore:" .. fg .. ",back:#" .. color.hex + if not win:style_define(id, style) then + break + end + win:style(id, color.starts - 1 + offset, color.ends - 1 + offset) + table.insert(styleIdStack, id) + if color.ends >= win.viewport['bytes'].finish then + break + end + end +end + +M.on_higlight = function(win) + local content = win.file:content(win.viewport['bytes']) + local hex_colors = M.extract_hex_colors(content) + M.draw(win, hex_colors) +end + +vis.events.subscribe(vis.events.WIN_HIGHLIGHT, M.on_higlight) + +return M diff --git a/.config/vis/plugins/vis-colorizer/screenshot.png b/.config/vis/plugins/vis-colorizer/screenshot.png Binary files differnew file mode 100644 index 0000000..718f08d --- /dev/null +++ b/.config/vis/plugins/vis-colorizer/screenshot.png diff --git a/.config/vis/plugins/vis-lspc/.editorconfig b/.config/vis/plugins/vis-lspc/.editorconfig new file mode 100644 index 0000000..b0f7f65 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/.editorconfig @@ -0,0 +1,16 @@ +# http://editorconfig.org + +root = true + +[*.lua] +indent_style = space +indent_size = 2 + +[.gitlab-ci.yml] +indent_style = space +indent_size = 2 + +[tools/*] +indent_style = space +indent_size = 4 +binary_next_line = true diff --git a/.config/vis/plugins/vis-lspc/.gitlab-ci.yml b/.config/vis/plugins/vis-lspc/.gitlab-ci.yml new file mode 100644 index 0000000..c04bd02 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/.gitlab-ci.yml @@ -0,0 +1,20 @@ +image: muhq/lua-dev:0.2 + +stages: + - check + - test + +check-luacheck: + stage: check + script: + - make check-luacheck + +check-format: + stage: check + script: + - make check-format + +test: + stage: test + script: + - make test diff --git a/.config/vis/plugins/vis-lspc/.lua-format b/.config/vis/plugins/vis-lspc/.lua-format new file mode 100644 index 0000000..43ff46c --- /dev/null +++ b/.config/vis/plugins/vis-lspc/.lua-format @@ -0,0 +1,27 @@ +column_limit: 100 +indent_width: 2 +use_tab: false +spaces_before_call: 1 +keep_simple_control_block_one_line: false +keep_simple_function_one_line: false +align_args: true +break_after_functioncall_lp: false +break_before_functioncall_rp: false +spaces_inside_functioncall_parens: false +spaces_inside_functiondef_parens: false +align_parameter: true +chop_down_parameter: false +break_after_functiondef_lp: false +break_before_functiondef_rp: false +align_table_field: true +break_after_table_lb: true +break_before_table_rb: true +chop_down_table: true +chop_down_kv_table: true +table_sep: "," +extra_sep_at_table_end: true +column_table_limit: 80 +spaces_inside_table_braces: false +break_after_operator: true +double_quote_to_single_quote: true +spaces_around_equals_in_field: true diff --git a/.config/vis/plugins/vis-lspc/LICENSE b/.config/vis/plugins/vis-lspc/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/.config/vis/plugins/vis-lspc/Makefile b/.config/vis/plugins/vis-lspc/Makefile new file mode 100644 index 0000000..08666fc --- /dev/null +++ b/.config/vis/plugins/vis-lspc/Makefile @@ -0,0 +1,19 @@ +.PHONY: check format check-luacheck check-format test + +LUA_FILES := $(shell find -name "*.lua" -not -path "./json.lua" -and -not -path "./tests/*") + +TEST_FILES := $(shell find -name "*_test.lua") + +check: check-luacheck check-format + +check-luacheck: + luacheck --globals=vis -- $(LUA_FILES) + +check-format: + set -e; for lf in $(LUA_FILES); do tools/check-format "$${lf}"; done + +format: + lua-format -i $(LUA_FILES) + +test: + set -e; for tf in $(TEST_FILES); do "$$tf"; done diff --git a/.config/vis/plugins/vis-lspc/README.md b/.config/vis/plugins/vis-lspc/README.md new file mode 100644 index 0000000..6486522 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/README.md @@ -0,0 +1,262 @@ +# `vis-lspc` + +A language server protocol client for the [`vis` editor](https://github.com/martanne/vis). + +## What's working + +`vis-lspc` currently supports: +* `textDocument/completion` +* `textDocument/declaration` +* `textDocument/definition` +* `textDocument/references` +* `textDocument/typeDefinition` +* `textDocument/implementation` +* `textDocument/hover` +* `textDocument/rename` +* `textDocument/formatting` +* `textDocument/documentSymbol` *Experimental* +* `Diagnostics` + +## What's not working + +Everything else. + +To my knowledge there is currently no good way to detect file changes via the Lua API. +But this is essential to support [Text Synchronization](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textSynchronization) which is required by the +LSP protocol. + +A dirty workaround we currently use is to send the whole file content in a `textDocument/didChange` +method call before calling any other method. +If someone can come up with an idea how to solve this I would appreciate contributions. + +Communicating with language-servers via other channels than stdin/stdout. + +Currently, only a handful of language servers are configured by default. +Their configuration can be found in [`supported_servers.lua`](https://gitlab.com/muhq/vis-lspc/-/blob/main/supported-servers.lua). + +## Requirements + +* `vis` must offer the `communicate` Lua API + * The API included in `vis` >= 0.9 is supported on the main branch + * For legacy support using the [first API draft patches](https://github.com/martanne/vis/pull/675) use the v0.2.x branch +* The language server you want to use. [Microsoft's list of implementations](https://microsoft.github.io/language-server-protocol/implementors/servers/) +* Optional: the JSON implementation of your choice + * must provide `encode` and `decode` methods + * `vis-lspc` tries to find a suitable JSON implementation using those candidates: + * `json` + * `cjson` + * `dkjson` + * bundled fallback (no utf8 support) + +## Installation + +1. Clone this repository into your `vis` plugins directory +2. Load the plugin in your `visrc.lua` with `require('plugins/vis-lspc')` + +Alternatively, a plugin manager like [vis-plug](https://github.com/erf/vis-plug) can be used to install `vis-lspc`. + +## Usage + +`vis-lspc` provides some default key bindings: + +### Default Bindings + + Normal mode: + <F2> - start a language server for win.syntax + <F3> - open win.file with a running language server + <C-]> | <gd> - jump to the definition of the symbol under the main cursor + <gD> - jump to declaration + <gd> - jump to definition + <gi> - jump to implementation + <gr> - show references + < D> - jump to type definition + <C-t> - go back in the jump history + < e> - show diagnostics of current line + < n> - jump to the next available diagnostic + < N> - jump to the previous available diagnostic + <K> - hover over current position + Normal and Insert mode: + <C- > - get completions + < o> - open the symbol navigation window + +#### Navigation Window + + Normal mode: + <p> - scroll to the selected symbol + <Enter> - jump to the selected symbol (switch to the code window) + <q> - close the navigaion window + +### Available commands + + # language-server management: + lspc-start-server [syntax] - start a language server for syntax or win.syntax + lspc-stop-server [syntax] - stop the language server for syntax or win.syntax + + # file registration: + lspc-open - register the file in the current window + lspc-close - unregister the file in the current window + + # navigation commands (they all operate on the symbol under the main cursor): + lspc-completion - syntax completion + lspc-references [e | vsplit | hsplit] - select and open a reference + lspc-declaration [e | vsplit | hsplit] - select and open a declaration + lspc-definition [e | vsplit | hsplit] - open the definition + lspc-typeDeclaration [e | vsplit | hsplit] - select and open a type declaration + lspc-implementation [e | vsplit | hsplit] - I actually have no idea what this does + + lspc-back - navigate back in the goto history + + # workspace edits + lspc-rename <new name> - rename the identifier under the cursor to <new name> + lspc-format - format the file in the current window + + # development support + lspc-hover - hover over the current line + lspc-show-diagnostics - show the available diagnostics of the current line + lspc-next-diagnostic - jump to the next available diagnostic + lspc-prev-diagnostic - jump to the previous available diagnostic + + # navigation windows + lspc-navigate-symbols - navigate a file by its symbols in a seperate window + lspc-navwin-scroll - scroll to the selected symbol + lspc-navwin-jump - jump to the selected symbol + lspc-navwin-close - close the navigation window + +### Available configuration options + +The module table returned by `require('plugins/vis-lspc')` can be used to configure +some aspects of `vis-lspc`. + +Available options are: + +* `name = 'vis-lspc'` - the name `vis-lspc` introduces itself to a language server +* `logging = false` - enable logging only useful for debugging `vis-lspc` +* `log_file = nil` - nil, filename, or function returning a filename + * If `log_file` is `nil` `vis-lspc` will create a new file in `$XDG_DATA_HOME/vis-lspc` +* `autostart = true` - try to start a language server in WIN_OPEN +* `menu_cmd = 'fzf' or 'vis-menu'` - program to prompt for user choices +* `confirm_cmd = 'vis-menu'` - program to prompt for user confirmation +* `autoconfirm_edits = false` - apply workspaceEdits without user confirmation +* `ls_map` - a table mapping `vis` syntax names to language server configurations +* `highlight_diagnostics = 'line'` - highlight the `range` or `line`number of available diagnostics +* `diagnostic_style_id = nil` - vis style id used to highlight diagnostics, win.STYLE_LEXER_MAX is used by default +* `diagnostic_styles = { error = 'back:red', warning = 'back:yellow', information = 'back:yellow', hint = 'back:yellow', }` - styles used to highlight different diagnostics +* `workspace_edit_remember_cursor = true` - restore the primary cursor position after a workspaceEdit +* `message_level = 3` - the level of shown messages retrieved via `window/showMessage` notifications +* `show_message = 'message'` - how to present information. `'message'`: use `vis:message`; `'open'`: use a new window supporting syntax highlighting. +* `universal_root_globs = {}` - Globs to consider as workspace root for any language server (e.g. `*.git` or `*.hg`). +* `fallback_dirname_as_root = false` - If set to true a file's directory is used as workspace root if no explicit root was found. +* `navwin_symbols_format = '%s[%.1s] %s\n'` - The format string used to display a symbol in the symbol navigation window. +* `navwin_layout = 'vr'` - Layout where the navigation window will be placed (`v[r|l]|h[t|b]`: vertical right or left; horizontal top or bottom). + +#### Configure your own Language Server + +If `vis-lspc` has no language server configuration for your desired language or server +you have to create a language server configuration and insert it into the `ls_map` +table. +Please have a look at #2 and share your configuration with everyone else. + +A language server configuration is a Lua table containing at least a `name` field +which is used to manage the language server and a `cmd` field which is used to +start the language server. + +**Note:** the language server must communicate with `vis-lspc` via stdio. +Your language server probably supports stdio but maybe requires a [special +command line flag](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#implementationConsiderations). + +Additional fields are: + +* `settings` - a table of arbitrary possibly nested data. It is sent in a `workspace/didChangeConfiguration` to the language server after initialization. It is also used to lookup configuration for the `workspace/configuratio` method call. +* `init_options` - table of arbitrary possibly nested data. It will be sent to the server as `initializationOptions` in the parameters of the `initialize` method call. +* `formatting_options` - table of configuration data as found in [the LSP specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_formatting). `tabSize` and `insertSpaces` are required. + +**Example:** The language server configuration entry in the `ls_map` for `lua-language-server` + +```lua +ls_map.lua = { + name = 'lua-language-server', + cmd = 'lua-language-server', + settings = { + Lua = {diagnostics = {globals = {'vis'}}, telemetry = {enable = false}}, + }, + formatting_options = {tabSize = 2, insertSpaces = true}, +}, +``` + +Language servers configured in `vis-lspc` can be found in `supported_servers.lua`. + +#### Language Server Settings + +Language servers can be configured with different mechanisms, all merged together to form the *effective* settings for a language server instance. +There are three kinds of settings: + +1. *Global settings* specified by the user in its `vis` configuration in a language server configuration's `settings` member. +2. *Project local* settings stored in a `.vis-lspc-settings.json` file along your regular project files. +3. Settings stored by `vis-lspc` in the `settings.json` file. + The *client local* user settings are stored for each language server and each file path. + Settings for a more specific file path override settings defined for a parent directory. + +The *project* and *client local* settings are indexed by the name of the language server. + +All relevant settings are merged to form the *effective* settings passed to the language server. +More specific settings take precedence before more general ones. + +### Workspace Detection + +During server initialization an URI to the root of the workspace (a folder opened by the editor) can be passed to the server. +Workspaces are used to implement certain project wide [features](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol). + +Since vis has no sense of folders and I think it is the job of each individual language server to detect the root of language idiomatic projects, workspace root detection is only activated for certain language servers by default. + +If you want to use some universal criteria to detect project roots, like always using a file's directory or considering all source-control repositories as projects you can use the configuration options `universal_root_globs` and `fallback_dirname_as_root`. + +Additionally, you can configure globs to detect a project's root for each language server using the `roots` member in its `ls_map` table entry. + +For example, `roots = {'compile_commands.json', '.clangd'}` is used to detect the project root for clangd. + +### Events + +vis-lspc extends vis' event system with its own set of events: + +* `lspc.event.LS_INITIALIZED` - emitted after sending the `initialized` notification +* `lspc.event.LS_DID_OPEN` - emitted after sending the `textDocument/didOpen` notification + +All events receive the language server as first argument. + +### Extensibility + +The returned module table also includes functions you can use in your own `vis` +configuration. + +#### `lspc.lspc_open` + +Navigate between or in files, while remembering the current position in a runtime history. + +```lua +lspc_open(win, path, line, col, cmd) +``` + + - `win` - a window in which to open the file + - `path` - the path to the file to open + - `line` - the line to open. (`nil` for no position within the file). + - `col` - same as `line`, but for the column. + - `cmd` - `vis` command to open the file. (`e` or `o`, see `vis` commands) + +#### `lspc.get_running_ls` + +Get a running language server table. + +```lua +get_running_ls(win, explicit_syntax) +``` + + - `win` - a window in which the language server is running + - `explicit_syntax` - syntax of the language server if it differs from `win.syntax` + +## License + +All code except otherwise noted is licensed under the term of GPL-3. +See the LICENSE file for more details. +Our fallback JSON implementation in `json.lua` is NOT licensed under GPL-3. +It is taken from [here](https://gist.github.com/tylerneylon/59f4bcf316be525b30ab) +and is put into public domain by [Tyler Neylon](https://github.com/tylerneylon). diff --git a/.config/vis/plugins/vis-lspc/bindings.lua b/.config/vis/plugins/vis-lspc/bindings.lua new file mode 100644 index 0000000..7859ebb --- /dev/null +++ b/.config/vis/plugins/vis-lspc/bindings.lua @@ -0,0 +1,87 @@ +-- Copyright (c) 2021 Florian Fischer. All rights reserved. +-- +-- This file is part of vis-lspc. +-- +-- vis-lspc is free software: you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation, either version 3 of the License, or (at your option) any later +-- version. +-- +-- vis-lspc is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- vis-lspc found in the LICENSE file. If not, see <https://www.gnu.org/licenses/>. +-- +-- vis-lspc default bindings +vis:map(vis.modes.NORMAL, '<F2>', function() + vis:command('lspc-start-server') +end, 'lspc: start lsp server') + +vis:map(vis.modes.NORMAL, '<F3>', function() + vis:command('lspc-open') +end, 'lspc: open current file') + +vis:map(vis.modes.NORMAL, '<C-]>', function() + vis:command('lspc-definition') +end, 'lspc: jump to definition') + +vis:map(vis.modes.NORMAL, '<C-t>', function() + vis:command('lspc-back') +end, 'lspc: go back position stack') + +vis:map(vis.modes.NORMAL, '<C- >', function() + vis:command('lspc-completion') +end, 'lspc: completion') + +vis:map(vis.modes.INSERT, '<C- >', function() + vis:command('lspc-completion') + vis.mode = vis.modes.INSERT +end, 'lspc: completion') + +-- bindings inspired by nvim +-- https://github.com/neovim/nvim-lspconfig +vis:map(vis.modes.NORMAL, 'gD', function() + vis:command('lspc-declaration') +end, 'lspc: jump to declaration') + +vis:map(vis.modes.NORMAL, 'gd', function() + vis:command('lspc-definition') +end, 'lspc: jump to definition') + +vis:map(vis.modes.NORMAL, 'gi', function() + vis:command('lspc-implementation') +end, 'lspc: jump to implementation') + +vis:map(vis.modes.NORMAL, 'gr', function() + vis:command('lspc-references') +end, 'lspc: show references') + +vis:map(vis.modes.NORMAL, ' D', function() + vis:command('lspc-typeDefinition') +end, 'lspc: jump to type definition') + +vis:map(vis.modes.NORMAL, ' e', function() + vis:command('lspc-show-diagnostics') +end, 'lspc: show diagnostic of current line') + +vis:map(vis.modes.NORMAL, ' n', function() + vis:command('lspc-next-diagnostic') +end, 'lspc: jump to the next available diagnostic') + +vis:map(vis.modes.NORMAL, ' N', function() + vis:command('lspc-prev-diagnostic') +end, 'lspc: jump to the previous available diagnostic') + +vis:map(vis.modes.NORMAL, 'K', function() + vis:command('lspc-hover') +end, 'lspc: hover over current position') + +vis:map(vis.modes.NORMAL, '<C-K>', function() + vis:command('lspc-signature-help') +end, 'lspc: signature help') + +vis:map(vis.modes.NORMAL, ' o', function() + vis:command('lspc-navigate-symbols') +end, 'lspc: symbol navigation window') diff --git a/.config/vis/plugins/vis-lspc/init.lua b/.config/vis/plugins/vis-lspc/init.lua new file mode 100644 index 0000000..52f8d08 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/init.lua @@ -0,0 +1,2128 @@ +-- Copyright (c) 2021-2024 Florian Fischer. All rights reserved. +-- +-- This file is part of vis-lspc. +-- +-- vis-lspc is free software: you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation, either version 3 of the License, or (at your option) any later +-- version. +-- +-- vis-lspc is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- vis-lspc found in the LICENSE file. If not, see <https://www.gnu.org/licenses/>. +-- +-- We require vis compiled with the communicate patch +if not vis.communicate then + vis:info('LSPC Error: language server support requires vis communicate patch') + return {} +end + +local source_str = debug.getinfo(1, 'S').source:sub(2) +local source_path = source_str:match('(.*/)') + +-- state of our language server client +local lspc = dofile(source_path .. 'lspc.lua') + +-- initialise the logging system +lspc.logger = dofile(source_path .. 'log.lua').lazyNew('lspc', lspc) + +local parser = dofile(source_path .. 'parser.lua') + +-- initialise the util module +local util = dofile(source_path .. 'util.lua').init(lspc) + +-- initialise the settings module +local settings = dofile(source_path .. 'settings.lua').init(lspc) + +-- load a suitable json module +lspc.json = dofile(source_path .. 'json.lua') + +local jsonrpc = {} +jsonrpc.error_codes = { + -- json rpc errors + ParseError = -32700, + InvalidRequest = -32600, + MethodNotFound = -32601, + InvalidParams = -32602, + InternalError = -32603, + + ServerNotInitialized = -32002, + UnknownErrorCode = -32001, + + -- lsp errors + ContentModified = -32801, + RequestCancelled = -32800, +} + +-- get vis's pid to pass it to the language servers +local vis_pid +do + local vis_proc_file = io.open('/proc/self/stat', 'r') + if vis_proc_file then + vis_pid = vis_proc_file:read('*n') + vis_proc_file:close() + + else -- fallback if /proc/self/stat + local out = util.capture_cmd('sh -c "echo $PPID"') + vis_pid = tonumber(out) + end +end +assert(vis_pid) + +-- mapping function between vis lexer names and LSP languageIds +local function syntax_to_languageId(syntax) + -- LuaFormatter off + local map = { + ansi_c = 'c', + javascript = 'jsx', + typescript = 'tsx', + } + -- LuaFormatter on + + return map[syntax] or syntax +end + +-- map of known language servers per syntax +lspc.ls_map = dofile(source_path .. 'supported-servers.lua') + +--- Return the name of the language server for this syntax +-- @param syntax the syntax the language server should support +-- @return the name of the languauge server configured for syntax +local function get_ls_name_for_syntax(syntax) + local ls_def = lspc.ls_map[syntax] + if not ls_def then + return nil, 'No language server available for ' .. syntax + end + return ls_def.name +end + +-- Document position code +-- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentPositionParams + +-- We use the following position/location/file related types in vis-lspc: +-- pos - like in vis a 0-based byte offset into the file. +-- path - posix path used by vis +-- uri - file uri used by LSP + +-- lsp_position - 0-based tuple (line, character) +-- lsp_document_position - aka. LSP TextDocumentPosition, tuple of (uri, lsp_position) + +-- vis_selection - 1-based tuple (line, cul) (character) +-- Can be used with with Selection:to +-- vis_document_position - 1-based tuple of (path, line, cul) + +-- vis_range - tuple of 0-based byte offsets (finish, start) +-- lsp_range - aka. Range, tuple of two lsp_positions (start, end) + +-- There exist helper function to convert from one type into another +-- aswell as helper to retrieve the current primary selection from a vis.window + +local function path_to_uri(path) + return 'file://' .. path +end + +-- uri decode logic taken from +-- https://stackoverflow.com/questions/20405985/lua-decodeuri-luvit +local uri_decode_table = {} +for i = 0, 255 do + uri_decode_table[string.format('%02x', i)] = string.char(i) + uri_decode_table[string.format('%02X', i)] = string.char(i) +end + +local function decode_uri(s) + return (s:gsub('%%(%x%x)', uri_decode_table)) +end + +local function uri_to_path(uri) + return decode_uri(uri:gsub('file://', '')) +end + +-- get the vis_selection from current primary selection +local function get_selection(win) + return {line = win.selection.line, col = win.selection.col} +end + +-- convert lsp_position to vis_selection +local function lsp_pos_to_vis_sel(pos) + return {line = pos.line + 1, col = pos.character + 1} +end + +-- convert vis_selection to lsp_position +local function vis_sel_to_lsp_pos(pos) + return {line = pos.line - 1, character = pos.col - 1} +end + +-- convert our vis_document_position to lsp_document_position aka. TextDocumentPosition +local function vis_doc_pos_to_lsp(doc_pos) + return { + textDocument = {uri = path_to_uri(doc_pos.file)}, + position = vis_sel_to_lsp_pos({line = doc_pos.line, col = doc_pos.col}), + } +end + +-- convert lsp_document_position to vis_document_position +local function lsp_doc_pos_to_vis(doc_pos) + local pos = lsp_pos_to_vis_sel(doc_pos.position) + return { + file = uri_to_path(doc_pos.textDocument.uri), + line = pos.line, + col = pos.col, + } +end + +-- get document position of the main curser +local function vis_get_doc_pos(win) + return { + file = win.file.path, + line = win.selection.line, + col = win.selection.col, + } +end + +--- Convert a lsp_range to a vis_range +-- @param file the file in which the range lies +-- @param lsp_range the LSP range that should be converted +-- @return the according vis range +local function lsp_range_to_vis_range(file, lsp_range) + local start = lsp_pos_to_vis_sel(lsp_range.start) + local finish = lsp_pos_to_vis_sel(lsp_range['end']) + + local positions = util.vis_sorted_selections_to_pos(file, {start, finish}) + local start_pos = positions[1] + local finish_pos = positions[2] + + return {start = start_pos, finish = finish_pos} +end + +--- Check if a lsp_position lies before another. +-- @param p1 first lsp_position to compare +-- @param p2 second lsp_position to compare +-- @return true if p1 lies before p2 +local function lsp_pos_before(p1, p2) + return p1.line < p2.line or (p1.line == p2.line and p1.character < p2.character) +end + +--- Check if a lsp_range starts before another. +-- The ranges may overlap since only their start positions are compared. +-- @param r1 first lsp_range to compare +-- @param r2 second lsp_range to compare +-- @return true if r1 is starts before r2 +local function lsp_range_starts_before(r1, r2) + return lsp_pos_before(r1.start, r2.start) +end + +-- concatenate all numeric values in choices and pass it on stdin to lspc.menu_cmd +local function lspc_select(choices) + local menu_input = '' + local i = 0 + for _, c in ipairs(choices) do + i = i + 1 + menu_input = menu_input .. c .. '\n' + end + + -- select the only possible choice + if i < 2 then + return choices[1] + end + + local fullscreen = lspc.menu_cmd == 'fzf' + local status, output = util.vis_pipe(menu_input, lspc.menu_cmd, fullscreen) + + local choice = nil + if status == 0 then + -- trim newline from selection + if output:sub(-1) == '\n' then + choice = output:sub(1, -2) + else + choice = output + end + end + + vis:redraw() + return choice +end + +local function lspc_select_location(locations) + -- Collect all paths with a list of their locations so we + -- can sort the locations before calling file_line_iterator_to_n + local collected = {} + + for _, location in ipairs(locations) do + local path = uri_to_path(location.uri or location.targetUri) + local range = location.range or location.targetSelectionRange + local position = lsp_pos_to_vis_sel(range.start) + + if collected[path] == nil then + table.insert(collected, path) + collected[path] = {} + end + + table.insert(collected[path], { + ['location'] = location, + ['position'] = position, + }) + end + + local choices = {} + local cwd_components = util.capture_cmd('pwd') + -- Strip trailing newline + cwd_components = util.split_path_into_components(cwd_components:sub(1, #cwd_components - 1)) + + for _, path in ipairs(collected) do + -- Sort positions + table.sort(collected[path], function(a, b) + return a['position'].line < b['position'].line + end) + + local rel_path = util.get_relative_path(cwd_components, path) + -- Use the already open file if present to get accurate line content for references + local line_iter + if lspc.open_files[path] ~= nil then + line_iter = function(n) + if n == -1 then + return nil + end + + return lspc.open_files[path].file.lines[n] + end + else + line_iter = util.file_line_iterator_to_n(path) + end + + for _, val in ipairs(collected[path]) do + local position = val['position'] + local location = val['location'] + + local choice = rel_path .. ':' .. ('%.0f'):format(position.line) .. ':' .. + ('%.0f'):format(position.col) .. ':' .. (line_iter(position.line) or '') + table.insert(choices, choice) + choices[choice] = location + end + + -- close the iterator + line_iter(-1) + end + + -- select a location + local choice = lspc_select(choices) + if not choice then + return nil + end + + return choices[choice] +end + +-- get a user confirmation +-- return true if user selected yes, false otherwise +local function lspc_confirm(prompt) + local choices = 'no\nyes' + + local cmd = lspc.confirm_cmd + + if prompt then + cmd = cmd .. ' -p \'' .. prompt .. '\'' + end + + lspc:log('get confirmation using: ' .. cmd) + + local choice = nil + local status, output = util.vis_pipe(choices, cmd) + if status == 0 then + -- trim newline from selection + if output:sub(-1) == '\n' then + choice = output:sub(1, -2) + else + choice = output + end + end + + vis:redraw() + return choice == 'yes' +end + +local function vis_open_file(file, cmd) + vis:command(('%s %s'):format(cmd, file:gsub('[\\\t "\']', '\\%1'):gsub('\n', '\\n'))) +end + +-- open a doc_pos using the vis command <cmd> +local function vis_open_doc_pos(doc_pos, cmd, win) + if win and win ~= vis.win then + vis.win = win + end + assert(cmd) + if vis.win.file.path ~= doc_pos.file then + if vis.win.file.modified and cmd == 'e' then + if lspc_confirm('Save currently open file:') then + vis:command('w') + else + vis:info('Not opening new file, current file has unsaved changes') + return + end + end + vis_open_file(doc_pos.file, cmd) + if doc_pos.line then + vis.win.selection:to(doc_pos.line, doc_pos.col or 0) + end + vis:command('lspc-open') + else + vis.win.selection:to(doc_pos.line, doc_pos.col) + end +end + +-- Support jumping between document positions +-- Stack of edited document positions +local doc_pos_history = {} + +local function vis_push_doc_pos(win) + local old_doc_pos = vis_get_doc_pos(win) + table.insert(doc_pos_history, old_doc_pos) +end + +-- open a new doc_pos remembering the old if it is replaced +local function vis_open_new_doc_pos(doc_pos, cmd, win) + win = win or vis.win + if cmd == 'e' then + vis_push_doc_pos(win) + end + + vis_open_doc_pos(doc_pos, cmd, win) +end + +lspc.open_file = function(win, path, line, col, cmd) + vis_open_new_doc_pos({file = path, line = line, col = col}, cmd or 'e', win) +end + +local function vis_pop_doc_pos(win) + local last_doc_pos = table.remove(doc_pos_history) + if not last_doc_pos then + return 'Document history is empty' + end + + vis_open_doc_pos(last_doc_pos, 'e', win) +end + +-- apply a textEdit received from the language server +local function vis_apply_textEdit(win, file, textEdit) + assert(win.file == file) + + local range = lsp_range_to_vis_range(file, textEdit.range) + + file:delete(range) + file:insert(range.start, textEdit.newText) + + win.selection.anchored = false + win.selection.pos = range.start + string.len(textEdit.newText) + + win:draw() +end + +-- apply a list of textEdits received from the language server +local function vis_apply_textEdits(win, file, textEdits) + assert(win.file == file) + + local edits = {} + for _, textEdit in ipairs(textEdits) do + local range = lsp_range_to_vis_range(file, textEdit.range) + table.insert(edits, { + mark = file:mark_set(range.start), + len = range.finish - range.start, + newText = textEdit.newText, + }) + end + for _, edit in ipairs(edits) do + local pos = file:mark_get(edit.mark) + file:delete(pos, edit.len) + file:insert(pos, edit.newText) + end + win:draw() +end + +--- Close the message window +-- TODO: close the dedicated lspc message window +local function lspc_close_message_win() + if lspc.show_message == 'message' then + vis:message('') + vis:command('q') + end +end + +--- Present a message to the user +-- TODO: use a dedicated lspc message window +local function lspc_show_message(msg, hdr, syntax) + local current_win = vis.win + + if lspc.show_message == 'message' then + local to_show = (hdr or '') .. msg .. '\n' + vis:message(to_show) + vis.win.selection = vis.win.file.size - #to_show + vis:feedkeys('zt') + + elseif lspc.show_message == 'open' then + vis:command('open') + if syntax then + vis:command('set syntax ' .. syntax) + end + + vis.win.file:insert(0, msg) + vis.win.selection.pos = 0 + else + lspc:err('invalid message configuration "' .. lspc.show_message .. '".') + end + + -- reset the focus to the current window + vis.win = current_win +end + +-- apply a WorkspaceEdit received from the language server +local function vis_apply_workspaceEdit(_, _, workspaceEdit) + local file_edits = workspaceEdit.changes + assert(file_edits or workspaceEdit.documentChanges) + + -- try to convert NOT SUPPORTED TextDocumentEdit[] + -- We do not announce support for versioned DocumentChanges in our + -- client capabilities, but some LSP servers ignore our capabilities + -- sending them anyway. + if not file_edits then + file_edits = {} + for _, edit in ipairs(workspaceEdit.documentChanges) do + file_edits[edit.textDocument.uri] = edit.edits + end + end + + if not lspc.autoconfirm_edits then + -- generate change summary + local summary = '--- workspace edit summary ---\n' + for uri, edits in pairs(file_edits) do + local path = uri_to_path(uri) + summary = summary .. path .. ':\n' + for i, edit in ipairs(edits) do + summary = summary .. '\t' .. i .. '.: ' .. lspc.json.encode(edit) .. '\n' + end + end + + lspc_show_message(summary) + vis:redraw() + + -- get user confirmation + local confirmation = lspc_confirm('apply changes:') + + -- close summary window + lspc_close_message_win() + + if not confirmation then + return + end + end + + -- apply changes to open files + for uri, edits in pairs(file_edits) do + local path = uri_to_path(uri) + + -- search all open windows for this uri + local win_with_file + for win in vis:windows() do + if win.file and win.file.path == path then + win_with_file = win + break + end + end + + -- The file is not currently opened -> open it + local opened + if not win_with_file then + vis_open_file(path, 'o') + win_with_file = vis.win + opened = true + end + + -- Remember the current primary cursor position + local old_pos = win_with_file.selection.pos + + for _, edit in ipairs(edits) do + vis_apply_textEdit(win_with_file, win_with_file.file, edit) + end + + -- Restore the remembered primary cursor position + if lspc.workspace_edit_remember_cursor then + win_with_file.selection.pos = old_pos + end + + -- save changes and close the opened window + if opened then + vis:command('wq') + end + end +end + +-- translate file line number to the relative row the line is displayed in the view of a window +-- returns an integer relative to the window if line is in view (starting at 1) +-- returns nil otherwise +local function file_lineno_to_viewport_lineno(win, file_lineno) + -- The line is not in the current viewport + if file_lineno < win.viewport.lines.start or file_lineno > win.viewport.lines.finish then + return nil + end + + -- The line is in the viewport and there is no wrapped line + if win.viewport.lines.finish - win.viewport.lines.start == win.viewport.height then + return file_lineno - win.viewport.lines.start + else -- Determine the position in the viewport considering possible prior wrapped lines + local view_lineno = 0 + for n = win.viewport.lines.start, file_lineno do + view_lineno = view_lineno + 1 + -- Wrapped lines shift our displayed line down + local line_len = #win.file.lines[n] + if not win.options.expandtab then + line_len = util.visual_chars_in_line(win, win.file.lines[n], line_len) + end + + if line_len >= win.viewport.width then + view_lineno = view_lineno + math.floor(line_len / win.viewport.width) + end + end + return view_lineno + end +end + +local function lspc_highlight_server_diagnostics(win, server_diagnostics, style) + if not style then + style = lspc.diagnostic_style_id or win.STYLE_LEXER_MAX + end + + local level_mapping = { + [1] = lspc.diagnostic_styles.error, + [2] = lspc.diagnostic_styles.warning, + [3] = lspc.diagnostic_styles.information, + [4] = lspc.diagnostic_styles.hint, + } + + for _, diagnostic in ipairs(server_diagnostics) do + local diagnostic_style = level_mapping[diagnostic.severity] or level_mapping[1] + assert(win:style_define(style, diagnostic_style)) + + if lspc.highlight_diagnostics == 'range' then + local range = diagnostic.vis_range + + -- LSP ranges use an exclusive finish + local finish = range.finish - 1 + + -- make sure to highlight only ranges which actually contain the diagnostic + if diagnostic.content == win.file:content(range) then + win:style(style, range.start, finish) + end + + elseif lspc.highlight_diagnostics == 'line' then + if not win.style_pos then + lspc:err('Vis build does not support style_pos') + return + end + + local start_line = diagnostic.range.start.line + local end_line = diagnostic.range['end'].line + for line = start_line, end_line, 1 do + local row = file_lineno_to_viewport_lineno(win, line) + if row then + -- Heuristic how many cells need to be styled + -- (at least one plus the decimal places of the line number). + for i = 0, #('' .. line) do + win:style_pos(style, i, row) + end + end + end + end + end +end + +local function lspc_highlight_diagnostics(win, diagnostics, style) + for _, server_diagnostics in pairs(diagnostics) do + lspc_highlight_server_diagnostics(win, server_diagnostics, style) + end +end + +--- LanguageServer class metatable +local LanguageServer = {} + +--- send a RPC message to the language server +-- @param req The request to send +function LanguageServer:rpc(req) + req.jsonrpc = '2.0' + + local content_part = lspc.json.encode(req) + local content_len = string.len(content_part) + + local header_part = 'Content-Length: ' .. tostring(content_len) + local msg = header_part .. '\r\n\r\n' .. content_part + lspc:log('LSPC Sending -> ' .. self.name .. ': ' .. msg) + + self.fd:write(msg) + self.fd:flush() +end + +--- Send a RPC notification to the language server +-- @param name the name of the notification +-- @param params the parameters to send +function LanguageServer:send_notification(name, params) + self:rpc({method = name, params = params}) +end + +--- Send a textDocument/didChange notification to the language server +-- @param the vis file object which changed +function LanguageServer:send_did_change(file) + lspc:log('send didChange') + local new_version = assert(lspc.open_files[file.path]).version + 1 + lspc.open_files[file.path].version = new_version + + local document = {uri = path_to_uri(file.path), version = new_version} + local changes = {{text = file:content(0, file.size)}} + local params = {textDocument = document, contentChanges = changes} + self:send_notification('textDocument/didChange', params) +end + +function LanguageServer:request_diagnostics(win) + -- check if the language server has a provider for this method + if self.capabilities['diagnosticProvider'] then + local params = {textDocument = {uri = path_to_uri(win.file.path)}} + self:call_method('textDocument/diagnostic', params, win, params.textDocument) + end +end + +--- Send a rpc method call to the language server. +-- @param method name of remote procedure to call +-- @param params the parameter passed to the remote procedure +-- @param win the related vis window object +-- @param ctx a opaque context value stored with the request +function LanguageServer:call_method(method, params, win, ctx) + local id = self.id + self.id = self.id + 1 + + local req = {id = id, method = method, params = params} + self.inflight[id] = req + + self:rpc(req) + -- remember the current window to apply the effects of a + -- method call in the original window + self.inflight[id].win = win + + -- remember the user provided ctx value + -- ctx can be used to remember arbitrary data from method invocation till + -- method response handling + -- The goto-location methods remember in ctx how to open the location + self.inflight[id].ctx = ctx +end + +--- Call textDocument/<method> of the server +-- We send a didChange notification upfront to make sure the server sees our +-- current state. This is not ideal since we are sending more data than needed +-- and the server has less time to parse the new file content and do its work +-- resulting in longer stalls after method invocation. +-- @param method the name of LSP textDocument method +-- @param params the parameters of the method call +-- @param win the related vis window object +-- @param ctx a opaque context value stored with the request +function LanguageServer:call_text_document_method(method, params, win, ctx) + self:send_did_change(win.file) + self:call_method('textDocument/' .. method, params, win, ctx) + self:request_diagnostics(win) +end + +--- Call the textDocument/symbol method of the server if provided +function LanguageServer:request_symbols(win, ctx) + if self.capabilities['documentSymbolProvider'] then + local params = {textDocument = {uri = path_to_uri(win.file.path)}} + self:call_text_document_method('documentSymbol', params, win, ctx) + end +end + +local function lspc_handle_goto_method_response(req, result) + if not result or type(result) ~= 'table' or next(result) == nil then + lspc:warn(req.method .. ' found no results') + return + end + + local location + -- result actually a list of results + if type(result) == 'table' then + location = lspc_select_location(result) + if not location then + return + end + else + location = result + end + assert(location) + + -- location is a Location + local lsp_doc_pos + if location.uri then + lspc:log('Handle location: ' .. lspc.json.encode(location)) + lsp_doc_pos = { + textDocument = {uri = location.uri}, + position = { + line = location.range.start.line, + character = location.range.start.character, + }, + } + -- location is a LocationLink + elseif location.targetUri then + lspc:log('Handle locationLink: ' .. lspc.json.encode(location)) + lsp_doc_pos = { + textDocument = {uri = location.targetUri}, + position = { + line = location.targetSelectionRange.start.line, + character = location.targetSelectionRange.start.character, + }, + } + else + lspc:warn('Unknown location type: ' .. lspc.json.encode(location)) + end + + local doc_pos = lsp_doc_pos_to_vis(lsp_doc_pos) + vis_open_new_doc_pos(doc_pos, req.ctx, req.win) +end + +local function lspc_handle_completion_method_response(win, result, old_pos) + if not result or type(result) ~= 'table' or not result.items then + lspc:warn('no completion available') + return + end + + local completions = result + if result.isIncomplete ~= nil then + completions = result.items + end + + local choices = {} + for _, completion in ipairs(completions) do + table.insert(choices, completion.label) + choices[completion.label] = completion + end + + -- select a completion + local choice = lspc_select(choices) + if not choice then + return + end + + local completion = choices[choice] + + if completion.textEdit then + vis_apply_textEdit(win, win.file, completion.textEdit) + return + end + + if completion.insertText or completion.label then + -- Does our current state correspont to the state when the completion method + -- was called. + -- Otherwise we don't have a good way to apply the 'insertText' completion + if win.selection.pos ~= old_pos then + lspc:warn('can not apply textInsert because the cursor position changed') + end + + local new_word = completion.insertText or completion.label + local old_word_range = win.file:text_object_word(old_pos) + local old_word = win.file:content(old_word_range) + + lspc:log(string.format('Completion old_pos=%d, old_range={start=%d, finish=%d}, old_word=%s', + old_pos, old_word_range.start, old_word_range.finish, + old_word:gsub('\n', '\\n'))) + + -- update old_word_range and old_word and return if old_word is a prefix of the completion + local function does_completion_apply_to_pos(pos) + old_word_range = win.file:text_object_word(pos) + old_word = win.file:content(old_word_range) + + local is_prefix = new_word:sub(1, string.len(old_word)) == old_word + return is_prefix + end + + -- search for a possible completion token which we should replace with this insertText + local matches = does_completion_apply_to_pos(old_pos) + if not matches then + lspc:log('Cursor looks like its not on the completion token') + + -- try the common case the cursor is behind its completion token: fooba┃ + local next_pos_candidate = old_pos - 1 + matches = does_completion_apply_to_pos(next_pos_candidate) + if matches then + old_pos = next_pos_candidate + end + end + + local completion_start + -- we found a completion token -> replace it + if matches then + lspc:log('replace the token: ' .. old_word .. ' we found being a prefix of the completion') + win.file:delete(old_word_range) + completion_start = old_word_range.start + else + completion_start = old_pos + end + -- apply insertText + win.file:insert(completion_start, new_word) + win.selection.pos = completion_start + string.len(new_word) + win:draw() + return + end + + -- neither insertText nor textEdit where present + lspc:err('Unsupported completion') +end + +local function lspc_handle_hover_method_response(win, result, old_pos) + if not result or type(result) ~= 'table' or not result.contents then + lspc:warn('no hover available') + return + end + + local sel = util.vis_pos_to_sel(win, old_pos) + + local hover_header = + '--- hover: ' .. (win.file.path or '') .. ': ' .. sel.line .. ', ' .. sel.col .. ' ---\n' + local hover_msg = '' + -- The most common markup kind in LSP is markdown + local markup_kind = 'markdown' + + -- result is MarkedString[] + if type(result.contents) == 'table' and #result.contents > 0 then + lspc:log('hover returned list of length ' .. #result.contents) + + for i, marked_string in ipairs(result.contents) do + if i == 1 then + hover_msg = marked_string.value or marked_string + else + hover_msg = hover_msg .. '\n---\n' .. (marked_string.value or marked_string) + end + end + else -- result is either MarkedString or MarkupContent + hover_msg = result.contents.value or result.contents + if result.contents.kind and result.contents.kind == 'plaintext' then + markup_kind = 'text' + end + end + lspc_show_message(hover_msg, hover_header, markup_kind) +end + +local function lspc_handle_signature_help_method_response(win, result, call_pos) + if not result or type(result) ~= 'table' or not result.signatures or #result.signatures == 0 then + lspc:warn('no signature help available') + return + end + + local signatures = result.signatures + + local sel = util.vis_pos_to_sel(win, call_pos) + local help_header = '--- signature help: ' .. (win.file.path or '') .. ': ' .. sel.line .. ', ' .. + sel.col .. ' ---\n' + + -- local help_msg = lspc.json.encode(result) + local help_msg = '' + for _, signature in ipairs(signatures) do + local sig_msg = signature.label + if signature.documentation then + local doc = signature.documentation.value or signature.documentation + sig_msg = sig_msg .. '\n\tdocumentation: ' .. doc + end + help_msg = help_msg .. '\n' .. sig_msg + end + -- strip first new line from the message + help_msg = help_msg:sub(2) + lspc_show_message(help_msg, help_header) +end + +local function lspc_handle_rename_method_response(win, result) + -- result must always be valid because otherwise we would caught the error + -- in LanguageServer:handle_method_response + vis_apply_workspaceEdit(win, win.file, result) +end + +local function lspc_handle_formatting_method_response(win, result) + -- The result of textDocument/formatting is defined as TextEdit[] | null + if result then + vis_apply_textEdits(win, win.file, result) + end +end + +local lspc_handle_publish_diagnostics + +local function lspc_handle_diagnostic_method_response(ls, result, ctx) + if result then + lspc_handle_publish_diagnostics(ls, ctx.uri, result.items) + end +end + +local function lspc_handle_initialize_response(ls, result) + ls.initialized = true + ls.capabilities = result.capabilities + + local params = {} + setmetatable(params, {__jsontype = 'object'}) + ls:send_notification('initialized', params) + + -- According to nvim-lspconfig sendig the lsp server settings shortly after + -- initialization is an undocumented convention. + -- See https://github.com/neovim/nvim-lspconfig/blob/ed88435764d8b00442e66d39ec3d9c360e560783/CONTRIBUTING.md + lspc:log('Loading settings for ' .. ls.name) + ls:send_default_settings() + + vis.events.emit(lspc.events.LS_INITIALIZED, ls) +end + +local function lspc_handle_symbol_method_response(result, ctx) + if ctx.callback and type(ctx.callback) == 'function' then + ctx.callback(result, ctx) + end +end + +--- Send didChangeConfiguration notification with the default settings +function LanguageServer:send_default_settings() + -- Use the rootUri or the open file's path as scope for the initial settings + local scope = self.rootUri or vis.win.file and vis.win.file.path + local effective_ls_settings = settings.effective_settings(self, nil, scope) + if next(effective_ls_settings) then + self:send_notification('workspace/didChangeConfiguration', { + settings = effective_ls_settings, + }) + end +end + +--- Dispatch method response from the server +-- @param method_response the response send from the server +-- @param req the request causing this response +function LanguageServer:handle_method_response(method_response, req) + local win = req.win + + local method = req.method + + local err = method_response.error + if err then + local err_msg = err.message + local err_code = err.code + lspc:err(err_msg .. ' (' .. err_code .. ') occurred during ' .. method) + -- Don't try to handle error responses any further + return + end + + local result = method_response.result + + -- LuaFormatter off + if method == 'textDocument/definition' or + method == 'textDocument/declaration' or + method == 'textDocument/typeDefinition' or + method == 'textDocument/implementation' or + method == 'textDocument/references' then + -- LuaFormatter on + lspc_handle_goto_method_response(req, result) + + elseif method == 'initialize' then + lspc_handle_initialize_response(self, result) + + elseif method == 'textDocument/completion' then + lspc_handle_completion_method_response(win, result, req.ctx) + + elseif method == 'textDocument/hover' then + lspc_handle_hover_method_response(win, result, req.ctx) + + elseif method == 'textDocument/signatureHelp' then + lspc_handle_signature_help_method_response(win, result, req.ctx) + + elseif method == 'textDocument/rename' then + lspc_handle_rename_method_response(win, result) + + elseif method == 'textDocument/formatting' then + lspc_handle_formatting_method_response(win, result) + + elseif method == 'textDocument/diagnostic' then + lspc_handle_diagnostic_method_response(self, result, req.ctx) + + elseif method == 'textDocument/documentSymbol' then + lspc_handle_symbol_method_response(result, req.ctx) + + elseif method == 'shutdown' then + self:send_notification('exit') + self.fd:close() + + -- remove the ls from lspc.running + for ls_name, rls in pairs(lspc.running) do + if self == rls then + lspc.running[ls_name] = nil + break + end + end + else + lspc:warn('received unknown method ' .. method) + end + + self.inflight[method_response.id] = nil +end + +--- Handle a workspace/configuration request from a server +-- @param params the parameters send with the request +-- @param response the response we are about to send +local function lspc_handle_workspace_configuration_call(ls, params, response) + local results = {} + for _, item in ipairs(params.items) do + local scope = item.scopeUri and uri_to_path(item.scopeUri) + local effective_settings = settings.effective_settings(ls, item.section, scope) + -- If we can not provide any settings LSP requires that null is present in the result array. + if next(effective_settings) then + table.insert(results, effective_settings) + else + table.insert(results, nil) + end + end + response.result = results +end + +--- Handle a method call from the server +-- @param method_call the received method call +function LanguageServer:handle_method_call(method_call) + local method = method_call.method + local response = {id = method_call.id} + if method == 'workspace/configuration' then + lspc_handle_workspace_configuration_call(self, method_call.params, response) + else + lspc:log('Unknown method call ' .. method) + response['error'] = { + code = jsonrpc.error_codes.MethodNotFound, + message = method .. ' not implemented', + } + end + self:rpc(response) +end + +-- save the diagnostics received for a file uri +lspc_handle_publish_diagnostics = function(ls, uri, diagnostics) + local file_path = uri_to_path(uri) + local file = lspc.open_files[file_path] + if file then + for _, diagnostic in ipairs(diagnostics) do + -- We convert the lsp_range to a vis_range here to do it only once. + -- It's an expensive operation that involves counting all newlines. + diagnostic.vis_range = lsp_range_to_vis_range(file.file, diagnostic.range) + + -- In some instances the range defined by the diagnostic starts + -- and ends at the same position. Highlight the exact position. + if diagnostic.vis_range.finish == diagnostic.vis_range.start then + -- We fake a one char range to retrieve its content. + -- In highlight_diagnostics we inconditionally decrement finish anyway. + diagnostic.vis_range.finish = diagnostic.vis_range.finish + 1 + end + + -- Remember the content of the diagnostic to only highlight it if the content + -- did not change + diagnostic.content = vis.win.file:content(diagnostic.vis_range) + end + + file.diagnostics[ls] = diagnostics + + lspc:log('remembered ' .. #diagnostics .. ' diagnostics for ' .. file_path) + else + lspc:log('Diagnostics for not opened file' .. file_path) + end +end + +local lsp_message_types = {'Error', 'Warning', 'Info', 'Log'} +-- show a message from the server in the UI +local function lspc_handle_show_message(show_message_params) + if show_message_params.type > lspc.message_level then + return + end + + vis:message('--- language server message ---') + local level = lsp_message_types[show_message_params.type] or 'Unknown' + vis:message(level .. ': ' .. show_message_params.message) +end + +--- Handle a notification received from the server +-- @param notification the received notification +function LanguageServer:handle_notification(notification) + local method = notification.method + if method == 'textDocument/publishDiagnostics' then + lspc_handle_publish_diagnostics(self, notification.params.uri, notification.params.diagnostics) + elseif method == 'window/showMessage' then + lspc_handle_show_message(notification.params) + end +end + +--- Dispatch between a method call and a message response +-- Those are distinquiable because for a message response we have a req +-- remembered in the inflight table +-- @param method the method message received from the server +function LanguageServer:handle_method(method) + local req = self.inflight[method.id] + if req and not method.method then + self:handle_method_response(method, req) + else + self:handle_method_call(method) + end +end + +--- Dispatch between a method call/response and a notification from the server +-- @param msg the message received from the server +function LanguageServer:handle_msg(msg) + if msg.id then + self:handle_method(msg) + else + self:handle_notification(msg) + end +end + +-- Parse the data send by the language server +-- Note the chunks received may not end with the end of a message. +-- In the worst case a data chunk contains two partial messages on at the beginning +-- and one at the end +function LanguageServer:recv_data(data) + local err = self.parser:add(data) + if err then + lspc:err(err) + return + end + + local msgs = self.parser:get_msgs() + if not msgs then + return + end + + for _, msg in ipairs(msgs) do + local resp = lspc.json.decode(msg) + self:handle_msg(resp) + end +end + +--- Return a running language server +-- @param win the window for which the language server might be running +-- @param explicit_syntax the syntax if different from win.syntax +-- @return a running language server or nil and an error message +function lspc.get_running_ls(win, explicit_syntax) + local ls + local syntax = explicit_syntax or (win and win.syntax) + -- try to use the first language server managing the current file + if not syntax then + if win and win.file and lspc.open_files[win.file.path] and + next(lspc.open_files[win.file.path].language_servers) then + ls = next(lspc.open_files[win.file.path].language_servers) + + else -- there is no language server with this file open and we have no syntax to guess + return nil, 'No syntax provided and no server is running' + end + + else -- Use the syntax to guess the language server + local ls_name, err = get_ls_name_for_syntax(syntax) + if err then + return nil, err + end + + ls = lspc.running[ls_name] + if not ls then + return nil, 'No language server running for ' .. syntax + end + end + + return ls +end + +--- Return a running and initialized language server +-- @param win the window for which the language server might be running +-- @param explicit_syntax the syntax if different from win.syntax +-- @return a running and initialized language server or nil and an error message +local function lspc_get_usable_ls(win, explicit_syntax) + local ls, err = lspc.get_running_ls(win, explicit_syntax) + + if err then + return nil, err + end + + assert(ls) + if not ls.initialized then + return nil, 'Language server ' .. ls.name .. ' not initialized yet. Please try again' + end + + return ls +end + +local function lspc_new_file_handle(file) + return {file = file, version = 0, diagnostics = {}, language_servers = {}} +end + +--- Detect if a file is already opened by the language server +-- @param file the vis file object to check +-- @return true if the file is already opened by the language server +function LanguageServer:is_file_opened(file) + return lspc.open_files[file.path] and lspc.open_files[file.path].language_servers[self] +end + +-- close the file if associated with the language server +local function lspc_close(ls, file) + if not ls:is_file_opened(file) then + return (file.path or '[No Name]') .. ' not open in ' .. ls.name + end + ls:send_notification('textDocument/didClose', { + textDocument = {uri = path_to_uri(file.path)}, + }) + lspc.open_files[file.path].language_servers[ls] = nil + if not next(lspc.open_files[file.path].language_servers) then + lspc.open_files[file.path] = nil + end +end + +-- register a file as open with a language server and setup close and save event handlers +-- A file must be opened before any textDocument functions can be used with it. +local function lspc_open(ls, win, file) + -- already opened + if ls:is_file_opened(file) then + return file.path .. ' already open in ' .. ls.name + end + + local lspc_file_handle = lspc.open_files[file.path] or lspc_new_file_handle(file) + lspc_file_handle.language_servers[ls] = true + lspc.open_files[file.path] = lspc_file_handle + + local params = { + textDocument = { + uri = 'file://' .. file.path, + languageId = syntax_to_languageId(win.syntax), + version = 0, + text = file:content(0, file.size), + }, + } + + ls:send_notification('textDocument/didOpen', params) + + vis.events.emit(lspc.events.LS_DID_OPEN, ls, file) +end + +--- Initiate the shutdown of the language server +-- Sending the exit notification and closing the file handle are done in +-- the shutdown response handler. +function LanguageServer:shutdown() + self:call_method('shutdown') +end + +--- Find the root project URI for a specific file +-- @param ls the language server +-- @param file_path the path to the file to find the root project of +-- @return the URI of the root project or nil if none was found +local function find_root_uri(ls, file_path) + local globs = '' + + local roots = ls.roots + if roots then + for _, glob in ipairs(roots) do + globs = globs .. glob .. '\n' + end + end + + if lspc.universal_root_globs then + for _, glob in ipairs(lspc.universal_root_globs) do + globs = globs .. glob .. '\n' + end + end + + local root_path = util.find_upwards(globs, file_path) + if not root_path and lspc.fallback_dirname_as_root then + root_path = util.dirname(file_path) + end + return root_path and path_to_uri(root_path) +end + +local function ls_start(ls, init_options) + ls.fd = vis:communicate(ls.name, 'exec ' .. ls.conf.cmd) + + -- detect the workspace root + ls.rootUri = vis.win.file and find_root_uri(ls, vis.win.file.path) + + -- register the response handler + vis.events.subscribe(vis.events.PROCESS_RESPONSE, function(name, event, code, msg) + if name ~= ls.name then + return + end + + if event == 'EXIT' or event == 'SIGNAL' then + if event == 'EXIT' then + vis:info('language server exited with: ' .. code) + else + vis:info('language server received signal: ' .. code) + end + + lspc.running[ls.name] = nil + return + end + + lspc:log(ls.name .. ' response(' .. event .. '): ' .. msg) + if event == 'STDERR' then + return + end + + ls:recv_data(msg) + end) + + local params = { + processId = vis_pid, + clientInfo = {name = lspc.name, version = lspc.version}, + rootUri = ls.rootUri or lspc.json.null, + capabilities = lspc.client_capabilites, + } + + if init_options then + params.initializationOptions = init_options + end + + ls:call_method('initialize', params) +end + +function LanguageServer.new(ls_conf) + local ls = { + name = ls_conf.name, + conf = ls_conf, + + initialized = false, + id = 0, + inflight = {}, + parser = parser.new(), + capabilities = {}, + } + setmetatable(ls, {__index = LanguageServer}) + + return ls +end + +local function lspc_start_server(syntax) + local ls_conf = lspc.ls_map[syntax] + if not ls_conf then + return nil, 'No language server available for ' .. syntax + end + + local exe = ls_conf.cmd:gmatch('%S+')() + if not os.execute('type ' .. exe .. '>/dev/null 2>/dev/null') then + -- remove the configured language server + lspc.ls_map[syntax] = nil + local msg = string.format('Language server for %s configured but %s not found', syntax, exe) + -- the warning will be visual if the language server was automatically startet + -- if the user tried to start teh server manually they will see msg as error + lspc:warn(msg) + return nil, msg + end + + if lspc.running[ls_conf.name] then + return nil, 'Already a language server running for ' .. syntax + end + + local ls = LanguageServer.new(ls_conf) + lspc.running[ls_conf.name] = ls + ls_start(ls, ls_conf.init_options) + + return ls +end + +-- generic stub implementation for all textDocument methods taking +-- a textDocumentPositionParams parameter +local function lspc_method_doc_pos(ls, method, win, argv, additional_params) + -- check if the language server has a provider for this method + if not ls.capabilities[method .. 'Provider'] then + return 'language server ' .. ls.name .. ' does not provide ' .. method + end + + if not ls:is_file_opened(win.file) then + lspc_open(ls, win, win.file) + end + + local params = vis_doc_pos_to_lsp(vis_get_doc_pos(win)) + if additional_params then + for k, v in pairs(additional_params) do + params[k] = v + end + end + + ls:call_text_document_method(method, params, win, argv) +end + +local lspc_goto_location_methods = { + declaration = function(ls, win, open_cmd) + return lspc_method_doc_pos(ls, 'declaration', win, open_cmd) + end, + definition = function(ls, win, open_cmd) + return lspc_method_doc_pos(ls, 'definition', win, open_cmd) + end, + typeDefinition = function(ls, win, open_cmd) + return lspc_method_doc_pos(ls, 'typeDefinition', win, open_cmd) + end, + implementation = function(ls, win, open_cmd) + return lspc_method_doc_pos(ls, 'implementation', win, open_cmd) + end, + references = function(ls, win, open_cmd) + return lspc_method_doc_pos(ls, 'references', win, open_cmd, + {context = {includeDeclaration = false}}) + end, +} + +local function has_diagnostics(file) + if not file or not file.diagnostics then + return false + end + + -- detect if at least one server has published diagnostics + for _, d in pairs(file.diagnostics) do + if #d then + return true + end + end + + return false +end + +local function lspc_goto_next_diagnostic(win, reverse) + if not lspc.open_files[win.file.path] then + vis:command('lspc-open') + end + + local open_file = lspc.open_files[win.file.path] + + if not has_diagnostics(open_file) then + return (win.file.path or 'window') .. ' has no available diagnostics' + end + + -- merge diagnostics + -- TODO: come up with more efficient algorithm + local diagnostics = {} + for _, server_diagnostics in pairs(open_file.diagnostics) do + for _, diagnostic in ipairs(server_diagnostics) do + table.insert(diagnostics, diagnostic) + end + end + -- sort the merged diagnostics + table.sort(diagnostics, function(d1, d2) + return lsp_range_starts_before(d1.range, d2.range) + end) + + local sel = get_selection(win) + + local previous_diagnostic + for _, diagnostic in ipairs(diagnostics) do + local start = lsp_pos_to_vis_sel(diagnostic.range.start) + local fin = lsp_pos_to_vis_sel(diagnostic.range['end']) + + -- reverse + if reverse and + (start.line > sel.line or + (start.line == sel.line and (start.col >= sel.col or sel.col <= fin.col))) then + + -- wrap around + if not previous_diagnostic then + previous_diagnostic = lsp_pos_to_vis_sel(diagnostics[#diagnostics].range.start) + end + + win.selection:to(previous_diagnostic.line, previous_diagnostic.col) + return + end + + -- forward + if start.line > sel.line or (start.line == sel.line and start.col > sel.col) then + win.selection:to(start.line, start.col) + return + end + + previous_diagnostic = start + end + + -- wrap around + if #diagnostics > 0 then + local first = lsp_pos_to_vis_sel(diagnostics[1].range.start) + win.selection:to(first.line, first.col) + end +end + +local function lspc_show_diagnostic(win, line) + if not lspc.open_files[win.file.path] then + vis:command('lspc-open') + end + local file = lspc.open_files[win.file.path] + + if not has_diagnostics(file) then + return win.file.path .. ' has no diagnostics available' + end + local diagnostics = file.diagnostics + + line = line or get_selection(win).line + lspc:log('Show diagnostics for ' .. line) + local diagnostics_to_show = {} + for ls, server_diagnostics in pairs(diagnostics) do + for _, diagnostic in ipairs(server_diagnostics) do + local start = lsp_pos_to_vis_sel(diagnostic.range.start) + if start.line == line then + diagnostic.start = start + diagnostic.server = ls.name + table.insert(diagnostics_to_show, diagnostic) + end + end + end + + local diagnostics_fmt = '%s: %d:%d %s:%s\n' + local diagnostics_msg = '' + for _, diagnostic in ipairs(diagnostics_to_show) do + diagnostics_msg = diagnostics_msg .. + string.format(diagnostics_fmt, diagnostic.server, diagnostic.start.line, + diagnostic.start.col, diagnostic.code or 'diagnostic', + diagnostic.message) + end + + if diagnostics_msg ~= '' then + lspc_show_message(diagnostics_msg) + else + lspc:warn('No diagnostics available for line: ' .. line) + end +end + +local lsp_symbol_kinds = { + 'File', + 'Module', + 'Namespace', + 'Package', + 'Class', + 'Method', + 'Property', + 'Field', + 'Constructor', + 'Enum', + 'Interface', + 'Function', + 'Variable', + 'Constant', + 'String', + 'Number', + 'Boolean', + 'Array', + 'Object', + 'Key', + 'Null', + 'EnumMember', + 'Struct', + 'Event', + 'Operator', + 'TypeParameter', +} + +local function recursive_bfs_symbols_flatten(symbols, indent) + local ranges = {} + local str = '' + for _, s in ipairs(symbols) do + local range = s.selectionRange or s.location.range + table.insert(ranges, range) + str = str .. string.format(lspc.navwin_symbol_format, indent, lsp_symbol_kinds[s.kind], s.name, + range.start.line + 1, range.start.character + 1) + -- Remember DocumentSymbol.selectionRange or SymbolInformation.location.range + if s.children then + local cstr, cranges = recursive_bfs_symbols_flatten(s.children, indent .. ' ') + str = str .. cstr + for _, v in ipairs(cranges) do + table.insert(ranges, v) + end + end + end + return str, ranges +end + +--- Scroll the code window to the definition of the symbol selected in the navigation window. +local function navwin_scroll_codewin(self, switch_win) + local line = self.navwin.selection.line + -- No valid selection in the navigation window + if line > #self.ranges then + return + end + local sel = lsp_pos_to_vis_sel(self.ranges[line].start) + + -- Set the selection in the code window to the selected symbol + self.codewin.selection:to(sel.line, sel.col) + + if switch_win then + vis.win = self.codewin + end +end + +--- Instantiate a new navigation context from the received symbols. +local function lspc_navwin_from_symbols(symbols, ctx) + local symbols_outline, ranges = recursive_bfs_symbols_flatten(symbols, '') + + local old_layout = vis.ui.layout + if lspc.navwin_layout:sub(1, 1) == 'v' then + vis.ui.layout = vis.ui.layouts.VERTICAL + elseif lspc.navwin_layout:sub(1, 1) == 'h' then + vis.ui.layout = vis.ui.layouts.HORIZONTAL + end + + local navwin + local codewin + -- The right or bottom (old) window is our navigation window + if lspc.navwin_layout:sub(2) == 'r' or lspc.navwin_layout:sub(2) == 'b' then + vis:command('o ' .. ctx.win.file.path) + navwin = ctx.win + navwin.file = '' + navwin.file:delete(0, navwin.file.size) + codewin = vis.win + -- The new (old) window is our navigation window + else + vis:command('o') + navwin = vis.win + codewin = ctx.win + end + + -- Remove syntax highlighting from the symbols + navwin:set_syntax(nil) + + assert(navwin) + assert(codewin) + ctx = { + navwin = navwin, + codewin = codewin, + ranges = ranges, + old_layout = old_layout, + } + + lspc.navwins[navwin] = ctx + lspc.navwins[codewin] = ctx + + -- Prepare window to be our symbol navigation window + navwin.file:insert(0, symbols_outline) + navwin:draw() + + navwin:map(vis.modes.NORMAL, '<Enter>', function() + navwin_scroll_codewin(ctx, true) + end, 'jump to the selected symbol') + navwin:map(vis.modes.NORMAL, 'p', function() + navwin_scroll_codewin(ctx, false) + end, 'scroll to the selected symbol') + navwin:map(vis.modes.NORMAL, 'q', function() + navwin:close(true) + end, 'close this window') + + -- Jump to navigation window + vis.win = navwin + + -- Remove the event handlers. + local nav_win_close_handler + nav_win_close_handler = function(win) + -- Not our windows + if win ~= navwin and win ~= codewin then + return true + -- The code windows was closed, therefore we can also close the navigation window + elseif win == codewin then + lspc:log('Code window closed. Force close the navigation window.') + navwin:close(true) + return + end + -- reset vis' layout + vis.ui.layout = lspc.navwins[codewin].old_layout + + -- remove the navigation window + lspc.navwins[navwin] = nil + lspc.navwins[codewin] = nil + lspc:log('unsubscribe navwin event handler') + vis.events.unsubscribe(vis.events.WIN_CLOSE, nav_win_close_handler) + return true + end + + vis.events.subscribe(vis.events.WIN_CLOSE, nav_win_close_handler) +end + +local function lspc_nav_symbols(ls, win) + local ctx = {win = win, callback = lspc_navwin_from_symbols} + ls:request_symbols(win, ctx) +end + +-- vis-lspc commands + +vis:command_register('lspc-back', function() + local err = vis_pop_doc_pos() + if err then + lspc:err(err) + end +end) + +for name, func in pairs(lspc_goto_location_methods) do + vis:command_register('lspc-' .. name, function(argv, _, win) + local ls, err = lspc_get_usable_ls(win, argv[1]) + if err then + lspc:err(err) + return + end + assert(ls) + + -- vis cmd how to open the new location + -- 'e' (default): in same window + -- 'vsplit': in a vertical split window + -- 'hsplit': in a horizontal split window + local open_cmd = argv[1] or 'e' + err = func(ls, win, open_cmd) + if err then + lspc:err(err) + end + end) +end + +vis:command_register('lspc-hover', function(argv, _, win) + local ls, err = lspc_get_usable_ls(win, argv[1]) + if err then + lspc:err(err) + return + end + assert(ls) + + -- remember the position where hover was called + err = lspc_method_doc_pos(ls, 'hover', win, win.selection.pos) + if err then + lspc:err(err) + end +end) + +vis:command_register('lspc-signature-help', function(argv, _, win) + local ls, err = lspc_get_usable_ls(win, argv[1]) + if err then + lspc:err(err) + return + end + assert(ls) + + -- remember the position where signatureHelp was called + err = lspc_method_doc_pos(ls, 'signatureHelp', win, win.selection.pos) + if err then + lspc:err(err) + end +end) + +vis:command_register('lspc-rename', function(argv, _, win) + local new_name = argv[1] + if not new_name then + lspc:err('lspc-rename usage: <new name> [syntax]') + return + end + + local ls, err = lspc_get_usable_ls(win, argv[2]) + if err then + lspc:err(err) + return + end + assert(ls) + + -- check if the language server has a provider for this method + if not ls.capabilities['renameProvider'] then + lspc:err('language server ' .. ls.name .. ' does not provide rename') + return + end + + if not ls:is_file_opened(win.file.path) then + lspc_open(ls, win, win.file) + end + + local params = vis_doc_pos_to_lsp(vis_get_doc_pos(win)) + params.newName = new_name + + ls:call_text_document_method('rename', params, win) +end) + +vis:command_register('lspc-format', function(argv, _, win) + local ls, err = lspc_get_usable_ls(win, argv[1]) + if err then + lspc:err(err) + return + end + assert(ls) + + -- check if the language server has a provider for this method + if not ls.capabilities['documentFormattingProvider'] then + lspc:err('language server ' .. ls.name .. ' does not provide formatting') + return + end + + if not lspc.open_files[win.file.path] then + lspc_open(ls, win, win.file) + end + + local params = { + textDocument = {uri = path_to_uri(win.file.path)}, + options = ls.conf.formatting_options, + } + if params.options == nil then + params.options = { + tabSize = win.options.tabwidth, + insertSpaces = win.options.expandtab, + } + end + + ls:call_text_document_method('formatting', params, win) +end) + +vis:command_register('lspc-diagnostic', function(argv, _, win) + local ls, err = lspc_get_usable_ls(win, argv[1]) + if err then + lspc:err(err) + return + end + assert(ls) + + -- check if the language server has a provider for this method + if not ls.capabilities['diagnosticProvider'] then + lspc:err('language server ' .. ls.name .. ' does not provide document diagnostics') + return + end + + if not lspc.open_files[win.file.path] then + lspc_open(ls, win, win.file) + end + + ls:request_diagnostics(win) +end) + +vis:command_register('lspc-completion', function(argv, _, win) + local ls, err = lspc_get_usable_ls(win, argv[1]) + if err then + lspc:err(err) + return + end + assert(ls) + + -- remember the position where completions were requested + -- to apply insertText completions + err = lspc_method_doc_pos(ls, 'completion', win, win.selection.pos) + if err then + lspc:err(err) + end +end) + +vis:command_register('lspc-start-server', function(argv, _, win) + local syntax = argv[1] or win.syntax + if not syntax then + lspc:err('no language specified') + end + + local _, err = lspc_start_server(syntax) + if err then + lspc:err(err) + end +end) + +vis:command_register('lspc-shutdown-server', function(argv, _, win) + local ls, err = lspc_get_usable_ls(win, argv[1]) + if err then + lspc:err('no language server running: ' .. err) + return + end + assert(ls) + + ls:shutdown() +end) + +vis:command_register('lspc-close', function(argv, _, win) + local ls, err = lspc_get_usable_ls(win, argv[1]) + if err then + lspc:err(err) + return + end + assert(ls) + + lspc_close(ls, win.file) +end) + +vis:command_register('lspc-open', function(argv, _, win) + local ls, err = lspc_get_usable_ls(win, argv[1]) + if err then + lspc:err(err) + return + end + assert(ls) + + lspc_open(ls, win, win.file) +end) + +local function _lspc_next_diagnostic(win, reverse) + local err = lspc_goto_next_diagnostic(win, reverse) + if err then + lspc:err(err) + end +end + +vis:command_register('lspc-next-diagnostic', function(_, _, win) + _lspc_next_diagnostic(win, false) +end) + +vis:command_register('lspc-prev-diagnostic', function(_, _, win) + _lspc_next_diagnostic(win, true) +end) + +vis:command_register('lspc-show-diagnostics', function(argv, _, win) + local err = lspc_show_diagnostic(win, argv[1]) + if err then + lspc:err(err) + end +end) + +vis:command_register('lspc-navigate-symbols', function(argv, _, win) + local ls, err = lspc_get_usable_ls(win, argv[1]) + if err then + lspc:err(err) + return + end + assert(ls) + if not ls:is_file_opened(win.file.path) then + lspc_open(ls, win, win.file) + end + if lspc.navwins[win] then + lspc.navwins[win].navwin:close(true) + else + lspc_nav_symbols(ls, win) + end +end, 'toggle the symbol navigation') + +vis:command_register('lspc-navwin-jump', function(_, _, win) + local navwin = lspc.navwins[win] + if not navwin then + lspc:err('no associated navigation window') + else + navwin_scroll_codewin(navwin, true) + end +end, 'jump to the selected symbol') + +vis:command_register('lspc-navwin-scroll', function(_, _, win) + local navwin = lspc.navwins[win] + if not navwin then + lspc:err('no associated navigation window') + else + navwin_scroll_codewin(navwin, false) + end +end, 'scroll the code window to the selected symbol') + +vis:command_register('lspc-navwin-close', function(_, _, win) + local navwin = lspc.navwins[win] + if not navwin then + lspc:err('no associated navigation window') + else + navwin.navwin:close(true) + end +end, 'close the navigation window') + +-- vis-lspc event hooks +vis.events.subscribe(vis.events.WIN_OPEN, function(win) + if lspc.autostart and win.syntax then + lspc_start_server(win.syntax) + end +end) + +local function highlight_event() + local win = vis.win + if not win or not win.file then + return + end + + local open_file = lspc.open_files[win.file.path] + if open_file and open_file.diagnostics and lspc.highlight_diagnostics then + lspc_highlight_diagnostics(win, open_file.diagnostics) + end +end + +vis.events.subscribe(vis.events.WIN_HIGHLIGHT, highlight_event) +vis.events.subscribe(vis.events.UI_DRAW, highlight_event) + +vis.events.subscribe(vis.events.FILE_OPEN, function(file) + local win = vis.win + -- the only window we can access is not our window + if not win or win.file ~= file then + return + end + + local ls = lspc_get_usable_ls(win) + if not ls then + return + end + + lspc_open(ls, win, file) +end) + +vis.events.subscribe(vis.events.FILE_SAVE_POST, function(file, path) + if not vis.win or vis.win.file ~= file then + return + end + + local file_handle = lspc.open_files[path] + if not file_handle then + return + end + + for ls in pairs(file_handle.language_servers) do + ls:send_did_change(file) + -- the server is interested in didSave notifications + if ls.capabilities.textDocumentSync and type(ls.capabilities.textDocumentSync) == 'table' and + ls.capabilities.textDocumentSync.save then + local did_save_params = {textDocument = {uri = path_to_uri(file.path)}} + ls:send_notification('textDocument/didSave', did_save_params) + end + ls:request_diagnostics(vis.win) + end +end) + +vis.events.subscribe(vis.events.FILE_CLOSE, function(closed_file) + local file_handle = lspc.open_files[closed_file.path] + if not file_handle then + return + end + + for ls in pairs(file_handle.language_servers) do + lspc_close(ls, closed_file) + end +end) + +vis.events.subscribe(lspc.events.LS_INITIALIZED, function(ls) + if vis.win and vis.win.file and lspc_get_usable_ls(vis.win) == ls then + lspc_open(ls, vis.win, vis.win.file) + end +end) + +vis.events.subscribe(vis.events.QUIT, function() + for _, ls in pairs(lspc.running) do + -- attempt to gracefully shutdown the language server + ls:shutdown() + -- close the fd handle to terminate the subprocess because + -- a potential method response from the server will not be read after the + -- QUIT event + ls.fd:close() + end +end) + +vis:option_register('lspc-highlight-diagnostics', 'string', function(value) + lspc.highlight_diagnostics = value + return true +end, 'How should lspc highlight available diagnostics') + +vis:option_register('lspc-menu-cmd', 'string', function(value) + lspc.menu_cmd = value + return true +end, 'External tool vis-lspc uses to present choices in a menu') + +vis:option_register('lspc-confirm-cmd', 'string', function(value) + lspc.confirm_cmd = value + return true +end, 'External tool vis-lspc uses to ask the user for confirmation') + +vis:option_register('lspc-message-level', 'number', function(value) + lspc.message_level = value + return true +end, 'Message level to show in UI (for server messages)') + +vis:option_register('lspc-diagnostic-style-error', 'string', function(value) + lspc.diagnostic_styles.error = value +end, 'Style for diagnostic errors') + +vis:option_register('lspc-diagnostic-style-warning', 'string', function(value) + lspc.diagnostic_styles.warning = value +end, 'Style for diagnostic warnings') + +vis:option_register('lspc-diagnostic-style-information', 'string', function(value) + lspc.diagnostic_styles.information = value +end, 'Style for diagnostic information') + +vis:option_register('lspc-diagnostic-style-hint', 'string', function(value) + lspc.diagnostic_styles.hint = value +end, 'Style for diagnostic hints') + +dofile(source_path .. 'bindings.lua') +return lspc diff --git a/.config/vis/plugins/vis-lspc/json.lua b/.config/vis/plugins/vis-lspc/json.lua new file mode 100644 index 0000000..876a889 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/json.lua @@ -0,0 +1,216 @@ +--- Find or provide a suitable json module for vis-lspc. +-- +-- @module json +local json = {} + +--- Json modules to use if they are included in LUA_PATH. +local json_impls = {'json', 'cjson', 'dkjson'} + +-- find a suitable json implementation +for _, json_impl in ipairs(json_impls) do + if vis:module_exist(json_impl) then + json = require(json_impl) + if not json.encode or not json.decode then + json = nil + end + + -- found a usable json implementation + if json then + return json + end + end +end + +--[[ json.lua + +A compact pure-Lua JSON library. +The main functions are: json.encode, json.decode. + +## json.encode: + +This expects the following to be true of any tables being encoded: + * They only have string or number keys. Number keys must be represented as + strings in json; this is part of the json spec. + * They are not recursive. Such a structure cannot be specified in json. + +A Lua table is considered to be an array if and only if its set of keys is a +consecutive sequence of positive integers starting at 1. Arrays are encoded like +so: `[2, 3, false, "hi"]`. Any other type of Lua table is encoded as a json +object, encoded like so: `{"key1": 2, "key2": false}`. + +Because the Lua nil value cannot be a key, and as a table value is considerd +equivalent to a missing key, there is no way to express the json "null" value in +a Lua table. The json.null table can be used to encode the json "null" value. +{key=json.null} will be encoded to `{"key":null}`. + +An empty Lua table, {}, could be considered either a json object or array - +it's an ambiguous edge case. We choose to treat this as an object as it is the +more general type. + +To be clear, none of the above considerations is a limitation of this code. +Rather, it is what we get when we completely observe the json specification for +as arbitrary a Lua object as json is capable of expressing. + +## json.decode: + +This function parses json, with the exception that it does not pay attention to +\u-escaped unicode code points in strings. + +It is difficult for Lua to return null as a value. In order to prevent the loss +of keys with a null value in a json string, this function uses the one-off +table value json.null (which is just an empty table) to indicate null values. +This way you can check if a value is null with the conditional +`val == json.null`. + +If you have control over the data and are using Lua, I would recommend just +avoiding null values in your data to begin with. + +--]] + +-- Internal functions. + +local function kind_of(obj) + if obj == json.null then + return 'nil' + end + if type(obj) ~= 'table' then return type(obj) end + local i = 1 + for _ in pairs(obj) do + if obj[i] ~= nil then i = i + 1 else return 'table' end + end + if i == 1 then return 'table' else return 'array' end +end + +local function escape_str(s) + local in_char = {'\\', '"', '/', '\b', '\f', '\n', '\r', '\t'} + local out_char = {'\\', '"', '/', 'b', 'f', 'n', 'r', 't'} + for i, c in ipairs(in_char) do + s = s:gsub(c, '\\' .. out_char[i]) + end + return s +end + +-- Returns pos, did_find; there are two cases: +-- 1. Delimiter found: pos = pos after leading space + delim; did_find = true. +-- 2. Delimiter not found: pos = pos after leading space; did_find = false. +-- This throws an error if err_if_missing is true and the delim is not found. +local function skip_delim(str, pos, delim, err_if_missing) + pos = pos + #str:match('^%s*', pos) + if str:sub(pos, pos) ~= delim then + if err_if_missing then + error('Expected ' .. delim .. ' near position ' .. pos) + end + return pos, false + end + return pos + 1, true +end + +-- Expects the given pos to be the first character after the opening quote. +-- Returns val, pos; the returned pos is after the closing quote character. +local function parse_str_val(str, pos, val) + val = val or '' + local early_end_error = 'End of input found while parsing string.' + if pos > #str then error(early_end_error) end + local c = str:sub(pos, pos) + if c == '"' then return val, pos + 1 end + if c ~= '\\' then return parse_str_val(str, pos + 1, val .. c) end + -- We must have a \ character. + local esc_map = {b = '\b', f = '\f', n = '\n', r = '\r', t = '\t'} + local nextc = str:sub(pos + 1, pos + 1) + if not nextc then error(early_end_error) end + return parse_str_val(str, pos + 2, val .. (esc_map[nextc] or nextc)) +end + +-- Returns val, pos; the returned pos is after the number's final character. +local function parse_num_val(str, pos) + local num_str = str:match('^-?%d+%.?%d*[eE]?[+-]?%d*', pos) + local val = tonumber(num_str) + if not val then error('Error parsing number at position ' .. pos .. '.') end + return val, pos + #num_str +end + + +-- Public values and functions. + +function json.encode(obj, as_key) + local s = {} -- We'll build the string as an array of strings to be concatenated. + local kind = kind_of(obj) -- This is 'array' if it's an array or type(obj) otherwise. + if kind == 'array' then + if as_key then error('Can\'t encode array as key.') end + s[#s + 1] = '[' + for i, val in ipairs(obj) do + if i > 1 then s[#s + 1] = ', ' end + s[#s + 1] = json.encode(val) + end + s[#s + 1] = ']' + elseif kind == 'table' then + if as_key then error('Can\'t encode table as key.') end + s[#s + 1] = '{' + for k, v in pairs(obj) do + if #s > 1 then s[#s + 1] = ', ' end + s[#s + 1] = json.encode(k, true) + s[#s + 1] = ':' + s[#s + 1] = json.encode(v) + end + s[#s + 1] = '}' + elseif kind == 'string' then + return '"' .. escape_str(obj) .. '"' + elseif kind == 'number' then + if as_key then return '"' .. tostring(obj) .. '"' end + return tostring(obj) + elseif kind == 'boolean' then + return tostring(obj) + elseif kind == 'nil' then + return 'null' + else + error('Unjsonifiable type: ' .. kind .. '.') + end + return table.concat(s) +end + +json.null = {} -- This is a one-off table to represent the null value. + +function json.decode(str, pos, end_delim) + pos = pos or 1 + if pos > #str then error('Reached unexpected end of input.') end + local pos = pos + #str:match('^%s*', pos) -- Skip whitespace. + local first = str:sub(pos, pos) + if first == '{' then -- Parse an object. + local obj, key, delim_found = {}, true, true + pos = pos + 1 + while true do + key, pos = json.decode(str, pos, '}') + if key == nil then return obj, pos end + if not delim_found then error('Comma missing between object items.') end + pos = skip_delim(str, pos, ':', true) -- true -> error if missing. + obj[key], pos = json.decode(str, pos) + pos, delim_found = skip_delim(str, pos, ',') + end + elseif first == '[' then -- Parse an array. + local arr, val, delim_found = {}, true, true + pos = pos + 1 + while true do + val, pos = json.decode(str, pos, ']') + if val == nil then return arr, pos end + if not delim_found then error('Comma missing between array items.') end + arr[#arr + 1] = val + pos, delim_found = skip_delim(str, pos, ',') + end + elseif first == '"' then -- Parse a string. + return parse_str_val(str, pos + 1) + elseif first == '-' or first:match('%d') then -- Parse a number. + return parse_num_val(str, pos) + elseif first == end_delim then -- End of an object or array. + return nil, pos + 1 + else -- Parse true, false, or null. + local literals = {['true'] = true, ['false'] = false, ['null'] = json.null} + for lit_str, lit_val in pairs(literals) do + local lit_end = pos + #lit_str - 1 + if str:sub(pos, lit_end) == lit_str then return lit_val, lit_end + 1 end + end + local pos_info_str = 'position ' .. pos .. ': ' .. str:sub(pos, pos + 10) + error('Invalid json syntax starting at ' .. pos_info_str) + end +end + +return json diff --git a/.config/vis/plugins/vis-lspc/log.lua b/.config/vis/plugins/vis-lspc/log.lua new file mode 100644 index 0000000..a41b819 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/log.lua @@ -0,0 +1,93 @@ +--- Simple logging module for vis-lspc. +-- @module log +-- @author Florian Fischer +-- @license GPL-3 +-- @copyright 2024 Florian Fischer +local log = {} + +--- Logger class metatable. +local Logger = {} +function Logger:log(msg) + self.log_fd:write(msg) + self.log_fd:write('\n') + self.log_fd:flush() +end +function Logger:close() + self.log_fd:close() +end + +--- Dummy logger class metatable using NOP log functions. +local DummyLogger = {} +function DummyLogger.log() +end +function DummyLogger.close() +end + +--- Logger that initialises itself on first use +local LazyInitLogger = {} +function LazyInitLogger:log(first_msg) + self.logger = log.new(self.name, self.conf.logging, self.conf.log_file) + self.log = function(lazyLogger, msg) + lazyLogger.logger:log(msg) + end + self.logger:log(first_msg) +end +function LazyInitLogger:close() + self.logger:close() +end + +--- Create a new lazily initializing logger +-- +-- The created logger will initialize itself using log.new on first use. +-- The logging and log_file fields of the conf table are passed to log.new. +-- @param name The name of the logger +-- @param conf Table containing logging and log_file members +function log.lazyNew(name, conf) + local logger = {name = name, conf = conf} + setmetatable(logger, {__index = LazyInitLogger}) + return logger +end + +--- Create a new named logger +-- @param name The name of the logger +-- @param logging Is logging enabled +-- @param log_file File path to the log file to use +function log.new(name, logging, log_file) + local logger = {name = name, log_fd = nil} + + if not logging then + setmetatable(logger, {__index = DummyLogger}) + return logger + end + + setmetatable(logger, {__index = Logger}) + + -- open the default log file in $XDG_DATA_HOME/vis-lspc + if not log_file then + local xdg_data = os.getenv('XDG_DATA_HOME') or os.getenv('HOME') .. '/.local/share' + local log_dir = xdg_data .. '/vis-lspc' + + -- ensure the direcoty exists + os.execute('mkdir -p ' .. log_dir) + + -- log file format: {timestamp}-{basename-cwd}.log + local log_file_fmt = log_dir .. '/%s-%s.log' + local timestamp = os.date('%Y-%m-%dT%H:%M:%S') + local proc = assert(io.popen('basename "${PWD}"', 'r')) + local basename_cwd = assert(proc:read('*a')):match('^%s*(.-)%s*$') + local success, _, status = proc:close() + if not success then + local err = 'getting the basename of CWD failed with exit code: ' .. status + vis:info('LSPC Error: ' .. err) + end + log_file = log_file_fmt:format(timestamp, basename_cwd) + + elseif type(log_file) == 'function' then + log_file = log_file() + end + + logger.log_fd = assert(io.open(log_file, 'w')) + return logger +end + +return log diff --git a/.config/vis/plugins/vis-lspc/lspc.lua b/.config/vis/plugins/vis-lspc/lspc.lua new file mode 100644 index 0000000..389961d --- /dev/null +++ b/.config/vis/plugins/vis-lspc/lspc.lua @@ -0,0 +1,160 @@ +--- State and methods of the language server client. +-- This module table is returned when requiring the vis-lspc plugin. +-- @module lspc +-- @author Florian Fischer +-- @license GPL-3 +-- @copyright Florian Fischer 2021-2024 +--- Initial state of the client. +-- This includes the default configuration that can be modified in +-- your visrc.lua file. +local lspc = { + -- mapping language server names to their state tables + running = {}, + open_files = {}, + name = 'vis-lspc', + version = '0.1.8', + -- write log messages to lspc.log_file + logging = false, + log_file = nil, + -- automatically start a language server when a new window is opened + autostart = true, + -- program used to let the user make choices + -- The available choices are passed to <menu_cmd> on stdin separated by '\n' + menu_cmd = 'vis-menu -l 10', + -- program used to ask the user for confirmation + confirm_cmd = 'vis-menu', + -- apply workspaceEdits without confirmation + autoconfirm_edits = false, + + -- should diagnostics be highlighted if available + highlight_diagnostics = 'line', + -- style id used by lspc to register the style used to highlight diagnostics + -- by default win.STYLE_LEXER_MAX is used (the last style id available for the lexer styles). See vis/ui.h. + diagnostic_style_id = nil, + -- styles used by lspc to highlight the diagnostic range + -- must be set by the user + diagnostic_styles = { + error = 'fore:red,italics,reverse', + warning = 'fore:yellow,italics,reverse', + information = 'fore:yellow,italics,reverse', + hint = 'fore:yellow,italics,reverse', + }, + + -- restore the position of the primary curser after applying a workspace edit + workspace_edit_remember_cursor = true, + + -- message level to show in the UI when receiving messages from the server + -- Error = 1, Warning = 2, Info = 3, Log = 4 + message_level = 3, + + -- How to present messages to the user. + -- 'message': use vis:message; 'open': use a new split window allowing for syntax highlighting + show_message = 'message', + + -- Globs that are considered to be workspace roots (e.g. ".git" or ".hg") + universal_root_globs = {}, + + -- Should a file's directory be used as workspace root if no explicit root was found. + fallback_dirname_as_root = false, + + -- Format string how the symbols are presented in the navigation window. + -- The following arguments are passed to the string.format function: + -- indentation, symbol kind, name, line, column + navwin_symbol_format = '%s[%.1s] %s\n', -- short version + -- navwin_symbol_format = '%s[%s] %s %d:%d\n', -- verbose version + + -- The layout where the navigation window will be placed. + -- The following layout identifiers are supported: + -- 'vr' -- DEFAULT: vertical right + -- 'vl' -- vertical left + -- 'ht' -- horizontal top + -- 'hb' -- horizontak bottom + navwin_layout = 'vr', + + -- active navigation windows + navwins = {}, + + -- events + events = { + LS_INITIALIZED = 'LspcEvent::LS_INITIALIZED', + LS_DID_OPEN = 'LspcEvent::LS_DID_OPEN', + }, +} + +-- check if fzf is available and use fzf instead of vis-menu per default +if os.execute('type fzf >/dev/null 2>/dev/null') then + lspc.menu_cmd = 'fzf' +end + +local supported_markup_kind = {'markdown'} + +local goto_methods_capabilities = { + linkSupport = true, + dynamicRegistration = false, +} + +--- ClientCapabilities we tell the language server when calling "initialize". +local client_capabilites = { + workspace = { + configuration = true, + didChangeConfiguration = {dynamicRegistration = false}, + }, + textDocument = { + synchronization = {dynamicRegistration = false, didSave = true}, + -- ask the server to send us only markdown completionItems + completion = { + dynamicRegistration = false, + completionItem = {documentationFormat = supported_markup_kind}, + }, + -- ask the server to send us only markdown hover results + hover = {dynamicRegistration = false, contentFormat = supported_markup_kind}, + -- ask the server to send us only markdown signatureHelp results + signatureHelp = { + dynamicRegistration = false, + signatureInformation = {documentationFormat = supported_markup_kind}, + }, + declaration = {dynamicRegistration = false, linkSupport = true}, + definition = goto_methods_capabilities, + publishDiagnostics = {relatedInformation = false}, + diagnostic = {dynamicRegistration = false}, + typeDefinition = goto_methods_capabilities, + implementation = goto_methods_capabilities, + references = {dynamicRegistration = false}, + rename = { + dynamicRegistration = false, + prepareSupport = false, + honorsChangeAnnotations = false, + }, + }, + window = {workDoneProgress = false, showDocument = {support = false}}, +} + +lspc.client_capabilites = client_capabilites + +local Lspc = {} + +--- Log a message. +-- @string: the message to log +function Lspc:log(msg) + self.logger:log(msg) +end + +--- Present a warning to the user. +-- @string: the warning message +function Lspc:warn(msg) + local warning = 'LSPC Warning: ' .. msg + self.logger:log(warning) + vis:info(warning) +end + +--- Present an error to the user. +-- @string: the error message +function Lspc:err(msg) + local warning = 'LSPC Error: ' .. msg + self.logger:log(warning) + vis:info(warning) +end + +setmetatable(lspc, {__index = Lspc}) + +return lspc diff --git a/.config/vis/plugins/vis-lspc/parser.lua b/.config/vis/plugins/vis-lspc/parser.lua new file mode 100644 index 0000000..0c9fc0a --- /dev/null +++ b/.config/vis/plugins/vis-lspc/parser.lua @@ -0,0 +1,76 @@ +--- Stateful parser for the data send by a language server. +-- Note the chunks received may not end with the end of a message. +-- In the worst case a data chunk contains two partial messages one +-- at the beginning and one at the end. +-- @module parser +-- @author Florian Fischer +-- @license GPL-3 +-- @copyright Florian Fischer 2021-2024 +local parser = {} + +local Parser = {} + +function parser.new() + local self = {exp_len = nil, data = '', msgs = {}} + + setmetatable(self, {__index = Parser}) + return self +end + +function Parser:add(data) + self.data = self.data .. data + + -- we have not seen a complete header in data yet + if not self.exp_len then + -- search for the end of a header + -- LSP message format: header\r\n(header\r\n)?\r\nbody' + local header_end, content_start = self.data:find('\r\n\r\n') + + -- header is not complete yet -> save data and wait for more + if not header_end then + return + end + + -- got a complete header + local header = self.data:sub(1, header_end) + + -- extract content length from the header + local length = header:match('Content%-Length: %d+') + self.exp_len = tonumber(length:match('%d+')) + if not self.exp_len then + return 'invalid header in data: ' .. self.data + end + + -- consume header from data + self.data = self.data:sub(content_start + 1) + end + + local data_avail = string.len(self.data) + -- we have no complete message yet -> await more data + if self.exp_len > data_avail then + return + end + + local complete_msg = self.data:sub(1, self.exp_len) + table.insert(self.msgs, complete_msg) + + -- consume complete_msg from data + self.data = self.data:sub(self.exp_len + 1) + + local leftover = data_avail - self.exp_len + + -- reset exp_len to search for a new header + self.exp_len = nil + + if leftover > 0 then + return self:add('') + end +end + +function Parser:get_msgs() + local msgs = self.msgs + self.msgs = {} + return msgs +end + +return parser diff --git a/.config/vis/plugins/vis-lspc/parser_test.lua b/.config/vis/plugins/vis-lspc/parser_test.lua new file mode 100755 index 0000000..1b5e4b1 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/parser_test.lua @@ -0,0 +1,141 @@ +#!/usr/bin/env lua5.4 + +local parser = require('parser') + +local function build_msg(body) + return 'Content-Length: ' .. tostring(string.len(body)) .. '\r\n\r\n' .. body +end + +local lunatest = require('lunatest') + +-- Actual LSP message in the wild. +function test_msg_with_content_type() -- luacheck: ignore 111 + local msg = 'Content-Type: application/vscode-jsonrpc; charset=utf8\r\n' .. + 'Content-Length: 1996\r\n\r\n' .. + [[{"jsonrpc":"2.0","id":0,"result":{"capabilities":{"textDocumentSync":1,"completionProvider":{"triggerCharacters":[":",">","$","[","@","(","'","\"","\\"],"resolveProvider":true},"hoverProvider":true,"signatureHelpProvider":{"triggerCharacters":["(",",","@"]},"definitionProvider":true,"typeDefinitionProvider":true,"implementationProvider":true,"referencesProvider":true,"documentHighlightProvider":true,"documentSymbolProvider":true,"codeActionProvider":{"codeActionKinds":["refactor.class.simplify","quickfix.import_class","quickfix.promote_constructor","quickfix.remove_unused_imports","quickfix.promote_constructor_public","quickfix.complete_constructor","quickfix.fill.object","quickfix.here_doc_provider","quickfix.complete_constructor_public","refactor.extract.constant","refactor.extract.method","quickfix.generate_member","quickfix.create_class","quickfix.add_missing_return_types","quickfix.add_missing_params","quickfix.add_missing_docblocks_return","quickfix.fix_namespace_class_name","refactor.extract.expression","quickfix.fill.matchArms","quickfix.correct_variable_name","quickfix.create_unresolable_class","quickfix.generate_decorator","refactor","quickfix.generate_mutators","quickfix.add_missing_properties","quickfix.implement_contracts","quickfix.add_missing_class_generic","quickfix.generate_accessors","quickfix.override_method"]},"workspaceSymbolProvider":true,"renameProvider":{"prepareProvider":true},"selectionRangeProvider":true,"executeCommandProvider":{"commands":["name_import","transform","create_class","generate_member","extract_method","replace_qualifier_with_import","extract_constant","generate_accessors","generate_mutators","import_all_unresolved_names","extract_expression","generate_decorator","override_method"]},"inlineValueProvider":true,"workspace":{"fileOperations":{"willRename":{"filters":[{"pattern":{"glob":"**\/*.php"}}]}}},"experimental":{"xevaluatableExpressionProvider":true}},"serverInfo":{"name":"phpactor\/phpactor","version":"dev-master"}}}]] -- luacheck: ignore 631 + + local p = parser.new() + local err = p:add(msg) + lunatest.assert_nil(err) + + local msgs = p:get_msgs() + lunatest.assert_table(msgs) + lunatest.assert_len(1, msgs) + lunatest.assert_not_nil(msgs[1]) +end + +function test_complete_msg() -- luacheck: ignore 111 + local msg = build_msg('foo') + local p = parser.new() + local err = p:add(msg) + lunatest.assert_nil(err) + + local msgs = p:get_msgs() + lunatest.assert_table(msgs) + lunatest.assert_len(1, msgs) + lunatest.assert_equal(msgs[1], 'foo') +end + +function test_two_complete_msgs() -- luacheck: ignore 111 + local p = parser.new() + local data = build_msg('foo') + local err = p:add(data) + lunatest.assert_nil(err) + + local msgs = p:get_msgs() + lunatest.assert_table(msgs) + lunatest.assert_len(1, msgs) + lunatest.assert_equal(msgs[1], 'foo') + + data = build_msg('bar') + err = p:add(data) + lunatest.assert_nil(err) + msgs = p:get_msgs() + lunatest.assert_table(msgs) + lunatest.assert_len(1, msgs) + lunatest.assert_equal(msgs[1], 'bar') +end + +function test_two_complete_msgs_at_once() -- luacheck: ignore 111 + local data = build_msg('foo') .. build_msg('bar') + local p = parser.new() + local err = p:add(data) + lunatest.assert_nil(err) + + local msgs = p:get_msgs() + lunatest.assert_table(msgs) + lunatest.assert_len(2, msgs) + lunatest.assert_equal(msgs[1], 'foo') + lunatest.assert_equal(msgs[2], 'bar') +end + +function test_split_msg() -- luacheck: ignore 111 + local msg = build_msg('foo') + local part1 = msg:sub(1, -3) + local part2 = msg:sub(-2) + local p = parser.new() + local err = p:add(part1) + lunatest.assert_nil(err) + + err = p:add(part2) + lunatest.assert_nil(err) + + local msgs = p:get_msgs() + lunatest.assert_table(msgs) + lunatest.assert_len(1, msgs) + lunatest.assert_equal(msgs[1], 'foo') +end + +function test_complete_and_split_msg() -- luacheck: ignore 111 + local msg = build_msg('foo') .. build_msg('bar') + local part1 = msg:sub(1, -3) + local part2 = msg:sub(-2) + local p = parser.new() + local err = p:add(part1) + lunatest.assert_nil(err) + + err = p:add(part2) + lunatest.assert_nil(err) + + local msgs = p:get_msgs() + lunatest.assert_table(msgs) + lunatest.assert_len(2, msgs) + lunatest.assert_equal(msgs[1], 'foo') + lunatest.assert_equal(msgs[2], 'bar') +end + +function test_split_hdr() -- luacheck: ignore 111 + local msg = build_msg('foo') + local part1 = msg:sub(1, 3) + local part2 = msg:sub(4) + local p = parser.new() + local err = p:add(part1) + lunatest.assert_nil(err) + + err = p:add(part2) + lunatest.assert_nil(err) + + local msgs = p:get_msgs() + lunatest.assert_table(msgs) + lunatest.assert_len(1, msgs) + lunatest.assert_equal(msgs[1], 'foo') +end + +function test_split_hdr_body_sep() -- luacheck: ignore 111 + local msg = build_msg('foo') + local part1 = msg:sub(1, 19) + local part2 = msg:sub(20) + local p = parser.new() + local err = p:add(part1) + lunatest.assert_nil(err) + + err = p:add(part2) + lunatest.assert_nil(err) + + local msgs = p:get_msgs() + lunatest.assert_table(msgs) + lunatest.assert_len(1, msgs) + lunatest.assert_equal(msgs[1], 'foo') +end + +lunatest.run() diff --git a/.config/vis/plugins/vis-lspc/settings.lua b/.config/vis/plugins/vis-lspc/settings.lua new file mode 100644 index 0000000..ed753d4 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/settings.lua @@ -0,0 +1,206 @@ +--- Collect all effective settings. +-- There are three kinds of settings: +-- +-- 1. Global settings explicitly set by the user in its vis configuration in the +-- language server configuration's `settings` member. +-- +-- 2. Project specific settings stored in .vis-lspc-settings.json files. +-- +-- 3. Settings stored by vis-lspc in its settings.json file. +-- The user settings are stored for each language server and each file path. +-- Settings for a more specific file path override settings defined for +-- a parent directory. +-- +-- All settings are organized in sections which are their top-level organization. +-- Commonly a language server expects its settings to be stored in a section with +-- its name. +-- Additionally, settings can be scoped which correspond with the local file +-- system. The scoped settings are merged with more specific ones taking priority. +-- +-- TODO: Implement commands to change the client settings and store them +-- permanently. +-- +-- @module settings +-- @author Florian Fischer +-- @license GPL-3 +-- @copyright Florian Fischer 2024 +local settings = {} + +local util + +local lspc + +--- Initialize the settings module +-- @param the lspc module table +-- @return the settings module table +function settings.init(lspc_) + lspc = lspc_ + local source_str = debug.getinfo(1, 'S').source:sub(2) + local source_path = source_str:match('(.*/)') + + util = dofile(source_path .. 'util.lua').init(lspc) + + return settings +end + +--- Read a JSON settings file from disk +-- @param settings_path the path to the settings file +-- @return the settings table +local function read_settings(settings_path) + local loaded_settings = {} + local settings_file = io.open(settings_path) + if settings_file then + loaded_settings = lspc.json.decode(settings_file:read('*a')) + settings_file:close() + lspc:log('read settings from ' .. settings_path .. ': ' .. lspc.json.encode(loaded_settings)) + end + return loaded_settings +end + +--- Read the user's local settings file +-- +-- Local user settings are stored at $XDG_CONFIG_HOME/vis-lspc/settings.json. +-- @return the user's local settings table +local function read_local_settings() + local xdg_conf_dir = os.getenv('XDG_CONFIG_HOME') or os.getenv('HOME') .. '/.config' + local settings_path = xdg_conf_dir .. '/vis-lspc/settings.json' + return read_settings(settings_path) +end + +--- Get a specific section from a settings table +-- @param tbl the settings table +-- @param section the dot-separated section name +-- @return the settings table for the requested section +function settings.get_section(tbl, section) + local t = tbl + -- iterate the dot-separated section components + for k in section:gmatch('[^.]+') do + if not t then + break + end + t = t[k] + end + + return t or {} +end + +--- Merge settings along the scope of their file path +-- +-- The settings are merged from the top most directory to the +-- actual file path. +-- @param path_specific_settings the path specific settings table +-- @param project_root path to the project +-- @return a table containing the effective settings +local function merge_path_specific(path_specific_settings, project_root) + local merged_settings = {} + local path = '' + local file_path_components = util.split_path_into_components(project_root) + + for _, comp in ipairs(file_path_components) do + path = path .. '/' .. comp + if path_specific_settings[path] then + merged_settings = util.table.merge(merged_settings, path_specific_settings[path]) + end + end + + return merged_settings +end + +--- Load the client local settings. +-- @param section the section of the settings +-- @param scope the scope of the local settings +-- @return a table containing the local settings +function settings.local_settings(section, scope) + local local_settings = read_local_settings() + local local_ls_settings = local_settings[section] + if not local_ls_settings then + return {} + end + return merge_path_specific(local_ls_settings, scope) +end + +--- Load the project local settings. +-- @param section the section of the settings +-- @param scope the scope of the project local settings +-- @return a table containing the local settings +function settings.project_local_settings(section, scope) + local merged_settings = {} + local settings_name = '.vis-lspc-settings.json' + + while true do + local settings_dir = util.find_upwards(settings_name .. '\n', scope) + if not settings_dir or settings_dir == '/' then + return merged_settings + end + + scope = settings_dir + + local settings_path = settings_dir .. '/' .. settings_name + lspc:log('settings: found project specific settings at "' .. settings_path) + local ls_settings = read_settings(settings_path) or {} + if section then + ls_settings = ls_settings[section] or {} + end + -- The settings found later have less priority than the settings found earlier + merged_settings = util.table.merge(ls_settings, merged_settings) + + end +end + +--- Get the effective settings for a language server and an active file +-- @param section the section name of settings +-- @param scope the scope of where the settings should have effect +-- @return a table containing the effective settings +function settings.effective_settings(ls, section, scope) + lspc:log('get effective settings (' .. tostring(section) .. ', ' .. tostring(scope) .. ') for ' .. + ls.name) + local effective_settings = {} + if ls.conf.settings then + lspc:log('global settings ' .. lspc.json.encode(ls.conf.settings)) + effective_settings = util.table.deep_copy(ls.conf.settings) + if section then + effective_settings = settings.get_section(effective_settings, section) + end + lspc:log('-> ' .. lspc.json.encode(effective_settings)) + end + + if scope then + local project_local_settings = settings.project_local_settings(section, scope) + lspc:log('project local settings ' .. lspc.json.encode(project_local_settings)) + util.table.merge(effective_settings, project_local_settings) + lspc:log('-> ' .. lspc.json.encode(effective_settings)) + end + + local local_settings = settings.local_settings(section, scope) + lspc:log('local settings ' .. lspc.json.encode(local_settings)) + util.table.merge(effective_settings, local_settings) + + lspc:log('-> settings ' .. lspc.json.encode(effective_settings)) + return effective_settings +end + +vis:command_register('lspc-settings-reload', function(argv) + local ls, err = lspc.get_running_ls(vis.win, argv[1]) + if err then + lspc:err(err) + return + end + assert(ls) + + ls:send_default_settings() +end, 'reload language server settings') + +vis:command_register('lspc-settings-show', function(argv) + local ls, err = lspc.get_running_ls(vis.win, argv[1]) + if err then + lspc:err(err) + return + end + assert(ls) + + local scope = ls.rootUri or vis.win.file and vis.win.file.path + local effective_ls_settings = settings.effective_settings(ls, nil, scope) + vis:message(lspc.json.encode(effective_ls_settings)) +end, 'show the language server settings') + +return settings diff --git a/.config/vis/plugins/vis-lspc/supported-servers.lua b/.config/vis/plugins/vis-lspc/supported-servers.lua new file mode 100644 index 0000000..0cfe1fd --- /dev/null +++ b/.config/vis/plugins/vis-lspc/supported-servers.lua @@ -0,0 +1,121 @@ +-- Copyright (c) 2022 Florian Fischer. All rights reserved. +-- +-- This file is part of vis-lspc. +-- +-- vis-lspc is free software: you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation, either version 3 of the License, or (at your option) any later +-- version. +-- +-- vis-lspc is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- vis-lspc found in the LICENSE file. If not, see <https://www.gnu.org/licenses/>. +-- +-- List of supported and preconfigured language server implementations +local source_str = debug.getinfo(1, 'S').source:sub(2) +local source_path = source_str:match('(.*/)') + +local lspc = dofile(source_path .. 'lspc.lua') + +local clangd = { + name = 'clangd', + cmd = 'clangd', + roots = {'compile_commands.json', '.clangd'}, +} +local typescript = { + name = 'typescript', + cmd = 'typescript-language-server --stdio', + roots = {'package.json', 'tsconfig.json', 'jsconfig.json'}, +} + +return { + c = clangd, + cpp = clangd, + ansi_c = clangd, + -- pylsp (python-lsp-server) language server configuration + -- https://github.com/python-lsp/python-lsp-server + python = { + name = 'python-lsp-server', + cmd = 'pylsp', + roots = {'requirements.txt', 'setup.py'}, + }, + -- lua (lua-language-server) language server configuration + -- https://github.com/sumneko/lua-language-server + lua = { + name = 'lua-language-server', + cmd = 'lua-language-server', + settings = { + Lua = {diagnostics = {globals = {'vis'}}, telemetry = {enable = false}}, + }, + }, + -- typescript (typescript-language-server) language server configuration + -- https://github.com/typescript-language-server/typescript-language-server + javascript = typescript, + typescript = typescript, + -- dart language server configuration + -- https://github.com/dart-lang/sdk/blob/master/pkg/analysis_server/tool/lsp_spec/README.md + dart = { + name = 'dart', + cmd = 'dart language-server --client-id vis-lspc --client-version ' .. lspc.version, + roots = {'pubspec.yaml'}, + }, + -- haskell (haskell-language-server) + -- https://github.com/haskell/haskell-language-server + haskell = { + name = 'haskell', + cmd = 'haskell-language-server-wrapper --lsp', + roots = {'hie.yaml', 'cabal.project', 'Setup.hs', 'stack.yaml', '*.cabal'}, + }, + + -- ocaml (ocaml-language-server) + -- https://github.com/ocaml/ocaml-lsp + caml = { + name = 'ocaml', + cmd = 'ocamllsp', + roots = { + 'dune-workspace', + 'dune-project', + 'Makefile', + 'opam', + '*.opam', + 'esy.json', + 'dune', + }, + }, + + -- go (gopls) + -- https://github.com/golang/tools/tree/master/gopls + go = {name = 'go', cmd = 'gopls', roots = {'Gopkg.toml', 'go.mod'}}, + + -- bash (bash-language-server) + -- https://github.com/bash-lsp/bash-language-server + bash = {name = 'bash-language-server', cmd = 'bash-language-server start'}, + + -- html (html-language-server) + -- https://github.com/hrsh7th/vscode-langservers-extracted + html = { + name = 'html-language-server', + cmd = 'vscode-html-language-server --stdio', + }, + + -- css (css-language-server) + -- https://github.com/hrsh7th/vscode-langservers-extracted + css = { + name = 'css-language-server', + cmd = 'vscode-css-language-server --stdio', + }, + + -- json (json-language-server) + -- https://github.com/hrsh7th/vscode-langservers-extracted + json = { + name = 'json-language-server', + cmd = 'vscode-json-language-server --stdio', + }, + + -- rust (rust-analyzer) + -- https://github.com/rust-lang/rust-analyzer + rust = {name = 'rust', cmd = 'rust-analyzer', roots = {'Cargo.toml'}}, +} diff --git a/.config/vis/plugins/vis-lspc/tools/check-format b/.config/vis/plugins/vis-lspc/tools/check-format new file mode 100755 index 0000000..392d023 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/tools/check-format @@ -0,0 +1,8 @@ +#!/bin/sh + +LUA_FILE=$1 +lua-format "${LUA_FILE}" > "${LUA_FILE}.fmt" +diff "${LUA_FILE}" "${LUA_FILE}.fmt" +RET=$? +rm "${LUA_FILE}.fmt" +exit ${RET} diff --git a/.config/vis/plugins/vis-lspc/tools/find-upwards b/.config/vis/plugins/vis-lspc/tools/find-upwards new file mode 100755 index 0000000..85dc722 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/tools/find-upwards @@ -0,0 +1,26 @@ +#!/bin/sh + +set -eu + +if [ ! "${1:-}" ]; then + exit 1 +fi + +BASEDIR="$(dirname "$1")" + +while read -r glob; do + cd "$BASEDIR" + + while [ "$PWD" != "/" ]; do + set -- "$glob" + + if [ -e "$1" ]; then + echo "$PWD" + exit 0 + fi + + cd .. + done +done + +exit 1 diff --git a/.config/vis/plugins/vis-lspc/util.lua b/.config/vis/plugins/vis-lspc/util.lua new file mode 100644 index 0000000..265c272 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/util.lua @@ -0,0 +1,370 @@ +--- Module containing simple utility functions for vis-lspc/ +-- @module util +-- @author Florian Fischer +-- @author git-bruh <prathamIN@proton.me> +-- @license GPL-3 +-- @copyright 2024-2025 Florian Fischer +-- @copyright 2024 git-bruh <prathamIN@proton.me> +local util = {} + +local source_str = debug.getinfo(1, 'S').source:sub(2) +local source_path = source_str:match('(.*/)') + +local lspc + +function util.init(lspc_) + lspc = lspc_ + return util +end + +--- Execute a command and capture its output. +-- @param cmd the command to execute +-- @return the output of the command written to stdout +function util.capture_cmd(cmd) + local p = assert(io.popen(cmd, 'r')) + local s = assert(p:read('*a')) + local success, _, status = p:close() + if not success then + local err = cmd .. ' failed with exit code: ' .. status + lspc:err(err) + end + return s +end + +local vis_supports_pipe_buf = pcall(vis.pipe, vis, 'foo', 'true', false) + +--- Wrapper for the two vis:pipe variants. +-- If vis does not support vis:pipe(input, cmd), prefix the command +-- with a printf call piping the result to the original command. +-- @param input The input to pipe to the command +-- @param cmd The external command to pipe the input to +function util.vis_pipe(input, cmd, fullscreen) + if vis_supports_pipe_buf then + return vis:pipe(input, cmd, fullscreen or false) + end + + local escaped_input = input:gsub('\'', '\'"\'"\'') + cmd = 'printf %s \'' .. escaped_input .. '\' | ' .. cmd + return vis:pipe(vis.win.file, {start = 0, finish = 0}, cmd) +end + +--- Split a path into its components +-- @param path the path to split into components +-- @return a table containing the path components +function util.split_path_into_components(path) + local components = {} + + if #path == 1 then + return nil + end + + -- Skip the initial '/' + local start_idx = 2 + + while true do + local slash = path:find('/', start_idx + 1) + + if slash == nil then + table.insert(components, path:sub(start_idx, #path)) + return components + else + table.insert(components, path:sub(start_idx, slash - 1)) + start_idx = slash + 1 + end + end +end + +--- Get a path relative to the current working directory +-- @param cwd_components Table of the path components of the CWD +-- @param absolute_path_or_components absolute path or table of its path components +-- @return the relative path +function util.get_relative_path(cwd_components, absolute_path_or_components) + local absolute_components + if type(absolute_path_or_components) == 'string' then + absolute_components = util.split_path_into_components(absolute_path_or_components) + else + absolute_components = absolute_path_or_components + end + + for idx = 1, #cwd_components do + local cwd = cwd_components[idx] + local absolute = absolute_components[idx] + + if cwd ~= absolute then + local dir = '' + + -- Atleast the first component must match for us to convert + -- it to a relative path + if idx ~= 1 then + for _ = idx, #cwd_components do + dir = dir .. '..' .. '/' + end + + -- Skip trailing '/' + dir = dir:sub(1, #dir - 1) + end + + for i = idx, #absolute_components do + dir = dir .. '/' .. absolute_components[i] + end + + return dir + end + end + + -- cwd shorter than absolute path + local dir = '' + + for i = #cwd_components + 1, #absolute_components do + dir = dir .. '/' .. absolute_components[i] + end + + -- Skip leading '/' + return dir:sub(2) +end + +--- Strip the last component from a pathname +-- @param the pathname +-- @return the pathname up to the last '/' +function util.dirname(name) + if name == '.' or name == '..' or name == '/' then + return name + end + + -- strip a trailing path separator + if name:sub(#name, #name) == '/' then + name = name:sub(1, #name - 1) + end + + local dirname = name:match('(.*)[/]') + -- There was no path separator in name. + if not dirname then + return '.' + end + + -- The name started with the root dir. + if dirname == '' then + return '/' + end + + return dirname +end + +--- Create an iterator yielding the nth line of a file +-- +-- @param path The path to the file +function util.file_line_iterator_to_n(path) + local file = assert(io.open(path, 'r')) + local lines = file:lines() + local last_line = nil + local last_n = 1 + + return function(n) + if n == -1 then + file:close() + return nil + end + + if n < last_n then + -- We might have multiple references on the same line, so we can + -- get called again with the previous line number + if (n + 1) == last_n then + return last_line + end + + return nil + end + + for line in lines do + if n == last_n then + last_n = last_n + 1 + last_line = line + + return line + end + + last_n = last_n + 1 + end + + -- Iterator exhausted + return nil + end +end + +--- Find file based on globs in the parent file system tree +-- @param globs a new line separated string of file globs +-- @param start the starting path +function util.find_upwards(globs, start) + local status, out = util.vis_pipe(globs, '\'' .. source_path:gsub('\'', '\'\\\'\'') .. + '/tools/find-upwards\' "' .. start .. '"') + + if status ~= 0 or out == nil then + return nil + end + + -- Skip trailing newline + return out:sub(1, #out - 1) +end + +-- get the vis_selection from current primary selection +local function get_selection(win) + return {line = win.selection.line, col = win.selection.col} +end + +--- Calculate the 0-based byte offsets from multiple sorted selections +-- @param file the file to calculate the positions in +-- @param sorted_selections a table of sorted vis_selections +-- @return a table of positions +function util.vis_sorted_selections_to_pos(file, sorted_selections) + local positions = {} + if file.pos_by_linecol then + for _, sel in ipairs(sorted_selections) do + table.insert(positions, file:pos_by_linecol(sel.line, sel.col)) + end + return positions + end + + local line_count = 0 + local pos = 0 + local sel_i = 1 + local sel = sorted_selections[sel_i] + for line in file:lines_iterator() do + line_count = line_count + 1 + while line_count == sel.line do + table.insert(positions, pos + (sel.col - 1)) + sel_i = sel_i + 1 + -- no more selections to convert + if sel_i > #sorted_selections then + break + end + sel = sorted_selections[sel_i] + end + + pos = pos + #line + 1 + end + + -- Some language servers (pylsp) send ranges including the first character after the last line. + -- e.g. [{"line":1, "col":1}, {"line":#lines+1, "col":1}] + -- But this selection can not be handled by iterating all lines. + -- Special case ranges ensing after the last line. + -- Additionaly the start and end can overlap. + -- Gopls sends the range [{"line":1, "col":1}, {"line":1, "col":1}] when editing an empty file. + while sel and sel.line == line_count + 1 and sel.col == 1 do + table.insert(positions, pos) + sel_i = sel_i + 1 + sel = sorted_selections[sel_i] + end + return positions +end + +local function vis_pos_before(p1, p2) + return p1.line < p2.line or (p1.line == p2.line and p1.col < p2.col) +end + +--- Calculate the 0-based byte offsets from multiple selections +-- @param file the file to calculate the positions in +-- @param selections a table of selections +-- @return a table of positions +function util.vis_selections_to_pos(file, selections) + table.sort(selections, vis_pos_before) + return util.vis_sorted_selections_to_pos(file, selections) +end + +--- Get the line and column from a 0-based byte offset +-- ATTENTION: the fallback version of this function modifies the primary +-- selection so it is not safe to call it for example during WIN_HIGHLIGHT events +-- @param pos the 0-based byte offset into the file +-- @return the 1-based line number +-- @return the 1-based column +function util.vis_pos_to_sel(win, pos) + if win.file.linecol_by_pos then + local lineno, col = win.file:linecol_by_pos(pos) + return {line = lineno, col = col} + end + + local old_selection = get_selection(win) + -- move primary selection + win.selection.pos = pos + local sel = get_selection(win) + -- restore old primary selection + win.selection:to(old_selection.line, old_selection.col) + return sel +end + +--- Count the visual characters in a line +-- This is useful to detect wrapped lines including tabs which may add a +-- unspecified amount of white space to a line depending on their position and +-- the used tabwidth. +-- @param win the window containing the line +-- @param line the line to count +-- @param nchars the number of characters in the line +-- @return the number of visual characters +util.visual_chars_in_line = function(win, line, nchars) + -- fast string iteration inspired by: + -- https://stackoverflow.com/a/49222705 + local l = {string.byte(line, 1, nchars)} + local line_len = 0 + for i = 1, nchars do + local c = l[i] -- Note: produces char codes instead of chars. + if c == 9 then -- '\t' + local chars_to_tab_stop = win.options.tabwidth - (line_len % win.options.tabwidth) + line_len = line_len + chars_to_tab_stop + else + line_len = line_len + 1 + end + end + return line_len +end + +--- Utility table function +util.table = {} + +--- Recursively copy a table and all its members. +-- @param tbl the table to copy +-- @return a deep copy of the table +function util.table.deep_copy(tbl) + local cpy = {} + for k, v in pairs(tbl) do + if type(v) ~= 'table' then + cpy[k] = v + else + cpy[k] = util.table.deep_copy(v) + end + end + + for i, v in ipairs(tbl) do + if type(v) ~= 'table' then + cpy[i] = v + else + cpy[i] = util.table.deep_copy(v) + end + end + return cpy +end + +--- Recursively merge two tables. +-- This method modifies the weak table, if this is not intended make sure you +-- use a deep copy of the weak table. +-- Named members of the strong table override members of the weak one. +-- Numbered members get inserted into the weak table. +-- @param weak table which's members are potentially overridden +-- @param strong table which's members override ones from the weak table +-- @return the merged table +function util.table.merge(weak, strong) + for k, v in pairs(strong) do + if type(v) ~= 'table' then + if tonumber(k) then -- append numeric members + table.insert(weak, v) + else -- potentially override the weak member + weak[k] = v + end + + else + weak[k] = util.table.merge(weak[k] or {}, v) + end + end + + return weak +end + +return util diff --git a/.config/vis/plugins/vis-lspc/util_test.lua b/.config/vis/plugins/vis-lspc/util_test.lua new file mode 100755 index 0000000..63393b1 --- /dev/null +++ b/.config/vis/plugins/vis-lspc/util_test.lua @@ -0,0 +1,76 @@ +#!/usr/bin/env lua5.4 + +-- mock vis global +vis = {} -- luacheck: ignore 111 +local util = require('util') + +local lunatest = require('lunatest') + +function test_dirname() -- luacheck: ignore 111 + lunatest.assert_equal('/usr', util.dirname('/usr/lib')) + lunatest.assert_equal('/', util.dirname('/usr/')) + lunatest.assert_equal('.', util.dirname('usr')) + lunatest.assert_equal('.', util.dirname('.')) + lunatest.assert_equal('..', util.dirname('..')) + lunatest.assert_equal('/', util.dirname('/')) +end + +function test_visual_chars_in_line() -- luacheck: ignore 111 + local win = {options = {tabwidth = 4}} -- win mock + local s = '\tfo' -- visual chars == 6 + lunatest.assert_equal(util.visual_chars_in_line(win, s, #s), 6) + + s = 'f\tfo' -- visual chars == 6 + lunatest.assert_equal(util.visual_chars_in_line(win, s, #s), 6) + + s = 'fo\tfo' -- visual chars == 6 + lunatest.assert_equal(util.visual_chars_in_line(win, s, #s), 6) + + s = 'foo\tfo' -- visual chars == 6 + lunatest.assert_equal(util.visual_chars_in_line(win, s, #s), 6) +end + +function test_table_deep_copy() -- luacheck: ignore 111 + local t = {1, 2, 3, foo = {4, 5, bar = 'bar'}} + local cpy = util.table.deep_copy(t) + + lunatest.assert_table(cpy) + lunatest.assert_len(3, cpy) + lunatest.assert_table(cpy.foo) + lunatest.assert_len(2, cpy.foo) + for i, v in ipairs(t) do + lunatest.assert_equal(v, cpy[i]) + end + + t[2] = 12 + lunatest.assert_not_equal(t[2], cpy[2]) + t.foo[2] = 13 + lunatest.assert_not_equal(t.foo[2], cpy.foo[2]) +end + +function test_table_merge() -- luacheck: ignore 111 + local t = {1, 2, foo = {bar = {nose = 'nose'}}} + local t2 = {3, foo = {4, 5, bar = {bar = 'bar'}}} + lunatest.assert_table(t) + lunatest.assert_table(t2) + + util.table.merge(t, t2) + + lunatest.assert_table(t) + lunatest.assert_len(3, t) + + lunatest.assert_equal(1, t[1]) + lunatest.assert_equal(2, t[2]) + lunatest.assert_equal(3, t[3]) + + lunatest.assert_table(t.foo) + lunatest.assert_len(2, t.foo) + + lunatest.assert_equal(t[3], t2[1]) + lunatest.assert_equal(t[2], 2) + + lunatest.assert_equal(t.foo.bar.bar, 'bar') + lunatest.assert_equal(t.foo.bar.nose, 'nose') +end + +lunatest.run() |
