From 4523e67e8d767d1680e1e50db96522934a105b10 Mon Sep 17 00:00:00 2001 From: Chris Pecunies Date: Fri, 15 Nov 2024 20:47:27 -0800 Subject: [PATCH] init --- .github/CODEOWNERS | 2 + .github/FUNDING.yml | 11 + .github/ISSUE_TEMPLATE/bug_report.yml | 76 + .github/ISSUE_TEMPLATE/config.yml | 4 + .github/ISSUE_TEMPLATE/feature_request.yml | 37 + .github/workflows/luarocks.yml | 29 + .gitignore | 12 + LICENSE | 674 ++++++++ Makefile | 5 + README.md | 67 + doc/dorm.md | 1 + lua/dorm/config/init.lua | 94 ++ lua/dorm/init.lua | 149 ++ lua/dorm/mod/autocmd/module.lua | 369 ++++ lua/dorm/mod/base/module.lua | 40 + lua/dorm/mod/calendar/module.lua | 196 +++ .../mod/calendar/views/monthly/module.lua | 1162 +++++++++++++ .../mod/calendar/views/monthly/module.lua-E | 1162 +++++++++++++ lua/dorm/mod/cmd/commands/return/module.lua | 59 + lua/dorm/mod/cmd/module.lua | 499 ++++++ lua/dorm/mod/code/module.lua | 526 ++++++ lua/dorm/mod/conceal/base/module.lua | 8 + lua/dorm/mod/conceal/diamond/module.lua | 24 + lua/dorm/mod/conceal/module.lua | 1497 +++++++++++++++++ lua/dorm/mod/hl/module.lua | 634 +++++++ lua/dorm/mod/init.lua | 841 +++++++++ lua/dorm/mod/keys/module.lua | 388 +++++ lua/dorm/mod/keys/module.lua-E | 388 +++++ lua/dorm/mod/link/module.lua | 95 ++ lua/dorm/mod/notes/module.lua | 506 ++++++ lua/dorm/mod/store/module.lua | 101 ++ lua/dorm/mod/time/module.lua | 476 ++++++ lua/dorm/mod/time/module.lua-E | 476 ++++++ lua/dorm/mod/todo/module.lua | 230 +++ lua/dorm/mod/treesitter/module.lua | 991 +++++++++++ lua/dorm/mod/ui/module.lua | 428 +++++ lua/dorm/mod/ui/selection_popup/module.lua | 538 ++++++ lua/dorm/mod/ui/text_popup/module.lua | 82 + lua/dorm/mod/workspace/module.lua | 556 ++++++ lua/dorm/mod/workspace/tests.lua | 41 + lua/dorm/mod/workspace/utils/module.lua | 109 ++ lua/dorm/util/callback.lua | 44 + lua/dorm/util/health.lua | 132 ++ lua/dorm/util/init.lua | 237 +++ lua/dorm/util/log.lua | 163 ++ .../_extensions/dorm/finders/browse.lua | 0 .../_extensions/dorm/finders/files.lua | 0 .../_extensions/dorm/finders/grep.lua | 0 .../_extensions/dorm/finders/init.lua | 0 .../_extensions/dorm/finders/root.lua | 0 lua/telescope/_extensions/dorm/init.lua | 63 + lua/telescope/_extensions/dorm/util/init.lua | 9 + 52 files changed, 14231 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/workflows/luarocks.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 doc/dorm.md create mode 100644 lua/dorm/config/init.lua create mode 100644 lua/dorm/init.lua create mode 100644 lua/dorm/mod/autocmd/module.lua create mode 100644 lua/dorm/mod/base/module.lua create mode 100644 lua/dorm/mod/calendar/module.lua create mode 100644 lua/dorm/mod/calendar/views/monthly/module.lua create mode 100644 lua/dorm/mod/calendar/views/monthly/module.lua-E create mode 100644 lua/dorm/mod/cmd/commands/return/module.lua create mode 100644 lua/dorm/mod/cmd/module.lua create mode 100644 lua/dorm/mod/code/module.lua create mode 100644 lua/dorm/mod/conceal/base/module.lua create mode 100644 lua/dorm/mod/conceal/diamond/module.lua create mode 100644 lua/dorm/mod/conceal/module.lua create mode 100644 lua/dorm/mod/hl/module.lua create mode 100644 lua/dorm/mod/init.lua create mode 100644 lua/dorm/mod/keys/module.lua create mode 100644 lua/dorm/mod/keys/module.lua-E create mode 100644 lua/dorm/mod/link/module.lua create mode 100644 lua/dorm/mod/notes/module.lua create mode 100644 lua/dorm/mod/store/module.lua create mode 100644 lua/dorm/mod/time/module.lua create mode 100644 lua/dorm/mod/time/module.lua-E create mode 100644 lua/dorm/mod/todo/module.lua create mode 100644 lua/dorm/mod/treesitter/module.lua create mode 100644 lua/dorm/mod/ui/module.lua create mode 100644 lua/dorm/mod/ui/selection_popup/module.lua create mode 100644 lua/dorm/mod/ui/text_popup/module.lua create mode 100644 lua/dorm/mod/workspace/module.lua create mode 100644 lua/dorm/mod/workspace/tests.lua create mode 100644 lua/dorm/mod/workspace/utils/module.lua create mode 100644 lua/dorm/util/callback.lua create mode 100644 lua/dorm/util/health.lua create mode 100644 lua/dorm/util/init.lua create mode 100644 lua/dorm/util/log.lua create mode 100644 lua/telescope/_extensions/dorm/finders/browse.lua create mode 100644 lua/telescope/_extensions/dorm/finders/files.lua create mode 100644 lua/telescope/_extensions/dorm/finders/grep.lua create mode 100644 lua/telescope/_extensions/dorm/finders/init.lua create mode 100644 lua/telescope/_extensions/dorm/finders/root.lua create mode 100644 lua/telescope/_extensions/dorm/init.lua create mode 100644 lua/telescope/_extensions/dorm/util/init.lua diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..bbb29c5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# global rule +* @clpi diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..547125b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,11 @@ +# These are supported funding model platforms + +open_collective: # Replace with a single Open Collective username +github: ["clpi"] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username + +custom: ["clp.is"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..f1ea5df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,76 @@ +name: Bug Report +description: Report a problem in Neorg +labels: [bug] +body: + + - type: checkboxes + id: faq-prerequisite + attributes: + label: Prerequisites + options: + - label: I am using the latest stable release of Neovim + required: true + - label: I am using the latest version of the plugin + required: true + + - type: input + attributes: + label: "Neovim Version" + description: "`nvim --version`:" + validations: + required: true + + - type: textarea + attributes: + label: "Neorg setup" + description: | + - Copy your entire "require("neorg").setup" function + validations: + required: true + + - type: textarea + attributes: + label: "Actual behavior" + description: "A description of actual behavior. Extra points if it includes images or videos." + validations: + required: true + + - type: textarea + attributes: + label: "Expected behavior" + description: "A description of the behavior you expected." + validations: + required: true + + - type: textarea + attributes: + label: "Steps to reproduce" + description: "Please describe how we can reproduce the issue." + validations: + required: true + + - type: textarea + attributes: + label: "Potentially conflicting plugins" + description: "Other plugins you are using which you think could potentially be conflicting with neorg." + + - type: textarea + attributes: + label: "Other information" + description: "Other information that could be helpful with debugging." + + - type: dropdown + id: help + attributes: + label: "Help" + description: "Would you be able to resolve this issue by submitting a pull request?" + options: + - "Yes" + - "Yes, but I don't know how to start. I would need guidance (check question below)" + - "No" + + - type: textarea + attributes: + label: "Implementation help" + description: "If you selected yes in the last question please specify what you would need help with in order to resolve the issue." + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e78a6b8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,4 @@ +contact_links: + - name: Ask a question + url: https://github.com/vhyrro/neorg/discussions + about: If you need help with configuration or something else diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..98d5e9b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,37 @@ +name: Feature request +description: Request a feature for Neorg +labels: [feature] +body: + + - type: checkboxes + id: issue-prerequisite + attributes: + label: Issues + options: + - label: I have checked [existing issues](https://github.com/vhyrro/neorg/issues?q=is%3Aissue) and there are no existing ones with the same request. + required: true + + - type: textarea + attributes: + label: "Feature description" + validations: + required: true + + - type: dropdown + id: help + attributes: + label: "Help" + description: "Would you be able to implement this by submitting a pull request?" + options: + - "Yes" + - "Yes, but I don't know how to start. I would need guidance" + - "No" + validations: + required: true + + - type: textarea + attributes: + label: "Implementation help" + description: "If you selected yes in the last question please specify in detail what you would need help with in order to implement this." + validations: + required: false diff --git a/.github/workflows/luarocks.yml b/.github/workflows/luarocks.yml new file mode 100644 index 0000000..336af7b --- /dev/null +++ b/.github/workflows/luarocks.yml @@ -0,0 +1,29 @@ +name: Push to Luarocks + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + luarocks-upload: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # Required to count the commits + - name: Get Version + run: echo "LUAROCKS_VERSION=$(git describe --abbrev=0 --tags)" >> $GITHUB_ENV + - name: LuaRocks Upload + uses: nvim-neorocks/luarocks-tag-release@v5 + env: + LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }} + with: + version: ${{ env.LUAROCKS_VERSION }} + test_interpreters: "" + dependencies: | + nvim-nio ~> 1.7 + lua-utils.nvim == 1.0.2 + plenary.nvim == 0.1.4 + nui.nvim == 0.3.0 + pathlib.nvim ~> 2.2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e13091d --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/wiki/ +/luarocks +/lua_modules +/.luarocks +/.luarc.json +test.norg + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5788c89 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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 store 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8108eeb --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +clean: + fd --glob '*-E' -x rm + +test: + echo "" diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b74c33 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# dorm - a familiar, organized future for neovim + + ![Neovim](https://img.shields.io/badge/Neovim%200.10+-brightgreen?style=for-the-badge) + ![License](https://img.shields.io/badge/license-GPL%20v3-brightgreen?style=for-the-badge) + ![Usage](https://dotfyle.com/plugins/nvim-dorm/dorm/shield?style=for-the-badge) + +## About + +- **dorm** is a plugin meant to bring the awesome extensibility of emacs [org-mode] or [neorg] without needing to switch from the gold standard [markdown], or from the best editor [neovim]. + +- we want to be able to take notes like developers, without shutting ourselves out of the entire ecosystem built around markdown. + +- it's a work in progress with an initial project structure based on the structure of neorg, and will be updated regularly + +## Install + +### lazy.nvim + +```lua +{ + "clpi/dorm.lua", + lazy = false, + version = false, + config = true, +} +``` +--- + +### plug.vim + +```vim +Plug "clpi/dorm.lua", { + \ "branch" : "main", + \ "do" : ":lua require('dorm').setup()" + \ } +``` +--- +### packer.nvim + +```lua +use { + "clp", + rocks = { + "lua-utils.nvim", + "nvim-nio", + "nui.nvim", + "plenary.nvim", + "pathlib.nvim" + }, + tag = "*", + config = function() + require("dorm").setup() + end, +} +``` + +## Usage + +check back! + +## Credits + +thank you and keep updated! + + + + diff --git a/doc/dorm.md b/doc/dorm.md new file mode 100644 index 0000000..f9bacba --- /dev/null +++ b/doc/dorm.md @@ -0,0 +1 @@ +# The dorm diff --git a/lua/dorm/config/init.lua b/lua/dorm/config/init.lua new file mode 100644 index 0000000..e7a159d --- /dev/null +++ b/lua/dorm/config/init.lua @@ -0,0 +1,94 @@ +--- @brief [[ +--- Defines the configuration table for use throughout dorm. +--- @brief ]] + +-- TODO(vhyrro): Make `dorm_version` and `version` a `Version` class. + +--- @alias OperatingSystem +--- | "windows" +--- | "wsl" +--- | "wsl2" +--- | "mac" +--- | "linux" +--- | "bsd" + +--- @alias dorm.configuration.module { config?: table } + +--- @class (exact) dorm.configuration.user +--- @field hook? fun(manual: boolean, arguments?: string) A user-defined function that is invoked whenever dorm starts up. May be used to e.g. set custom keybindings. +--- @field lazy_loading? boolean Whether to defer loading the dorm base until after the user has entered a `.dorm` file. +--- @field load table A list of mod to load, alongside their configurations. +--- @field logger? dorm.log.configuration A configuration table for the logger. + +--- @class (exact) dorm.configuration +--- @field arguments table A list of arguments provided to the `:dormStart` function in the form of `key=value` pairs. Only applicable when `user_config.lazy_loading` is `true`. +--- @field manual boolean? Used if dorm was manually loaded via `:dormStart`. Only applicable when `user_config.lazy_loading` is `true`. +--- @field mod table Acts as a copy of the user's configuration that may be modified at runtime. +--- @field dorm_version string The version of the file format to be used throughout dorm. Used internally. +--- @field os_info OperatingSystem The operating system that dorm is currently running under. +--- @field pathsep "\\"|"/" The operating system that dorm is currently running under. +--- @field started boolean Set to `true` when dorm is fully initialized. +--- @field user_config dorm.configuration.user Stores the configuration provided by the user. +--- @field version string The version of dorm that is currently active. Automatically updated by CI on every release. + +--- Gets the current operating system. +--- @return OperatingSystem +local function get_os_info() + local os = vim.loop.os_uname().sysname:lower() + + if os:find("windows_nt") then + return "windows" + elseif os == "darwin" then + return "mac" + elseif os == "linux" then + local f = io.open("/proc/version", "r") + if f ~= nil then + local version = f:read("*all") + f:close() + if version:find("WSL2") then + return "wsl2" + elseif version:find("microsoft") then + return "wsl" + end + end + return "linux" + elseif os:find("bsd") then + return "bsd" + end + + error("[dorm]: Unable to determine the currently active operating system!") +end + +local os_info = get_os_info() + +--- Stores the configuration for the entirety of dorm. +--- This includes not only the user configuration (passed to `setup()`), but also internal +--- variables that describe something specific about the user's hardware. +--- @see dorm.setup +--- +--- @type dorm.configuration +local config = { + user_config = { + lazy_loading = false, + load = { + --[[ + ["name"] = { config = { ... } } + --]] + }, + }, + + mod = {}, + manual = nil, + arguments = {}, + + dorm_version = "1.1.1", + version = "9.1.1", + + os_info = os_info, + pathsep = os_info == "windows" and "\\" or "/", + + hook = nil, + started = false, +} + +return config diff --git a/lua/dorm/init.lua b/lua/dorm/init.lua new file mode 100644 index 0000000..bed90d3 --- /dev/null +++ b/lua/dorm/init.lua @@ -0,0 +1,149 @@ +--- @brief [[ +--- This file marks the beginning of the entire plugin. It's here that everything fires up and starts pumping. +--- @brief ]] + +local dorm = { + callbacks = require("dorm.util.callback"), + config = require("dorm.config"), + log = require("dorm.util.log"), + mod = require("dorm.mod"), + utils = require("dorm.util"), + lib = require("lua-utils"), +} + +local config, log, mod, utils = dorm.config, dorm.log, dorm.mod, dorm.utils + +--- @module "dorm.config" + +--- Initializes dorm. Parses the supplied user configuration, initializes all selected mod and adds filetype checking for `.dorm`. +--- @param cfg dorm.configuration.user? A table that reflects the structure of `config.user_config`. +--- @see config.user_config +--- @see dorm.configuration.user +function dorm.setup(cfg) + -- Ensure that we are running Neovim 0.10+ + assert(utils.is_minimum_version(0, 10, 0), "dorm requires at least Neovim version 0.10 to operate!") + + -- If the user supplied no configuration then generate a base one (assume the user wants the base) + cfg = cfg or { + load = { + base = {}, + }, + } + + -- If no `load` table was passed whatsoever then assume the user wants the base ones. + -- If the user explicitly sets `load = {}` in their configs then that means they do not want + -- any mod loaded. + -- + -- We check for nil specifically because some users might think `load = false` is a valid thing. + -- With the explicit check `load = false` will issue an error. + if cfg.load == nil then + cfg.load = { + ["base"] = {}, + } + end + + config.user_config = vim.tbl_deep_extend("force", config.user_config, cfg) + + -- Create a new global instance of the dorm logger. + log.new(config.user_config.logger or log.get_base_config(), true) + + -- If the file we have entered has a `.dorm` extension: + if vim.fn.expand("%:e") == "dorm" or not config.user_config.lazy_loading then + -- Then boot up the environment. + dorm.org_file_entered(false) + else + -- Else listen for a BufAdd event for `.dorm` files and fire up the dorm environment. + vim.api.nvim_create_user_command("dormStart", function() + vim.cmd.delcommand("dormStart") + dorm.org_file_entered(true) + end, {}) + + vim.api.nvim_create_autocmd("BufAdd", { + pattern = "dorm", + callback = function() + dorm.org_file_entered(false) + end, + }) + end +end + +--- This function gets called upon entering a .dorm file and loads all of the user-defined mod. +--- @param manual boolean If true then the environment was kickstarted manually by the user. +--- @param arguments string? A list of arguments in the format of "key=value other_key=other_value". +function dorm.org_file_entered(manual, arguments) + -- Extract the module list from the user config + local module_list = config.user_config and config.user_config.load or {} + + -- If we have already started dorm or if we haven't defined any mod to load then bail + if config.started or not module_list or vim.tbl_isempty(module_list) then + return + end + + -- If the user has defined a post-load hook then execute it + if config.user_config.hook then + config.user_config.hook(manual, arguments) + end + + -- If dorm was loaded manually (through `:dormStart`) then set this flag to true + config.manual = manual + + -- If the user has supplied any dorm environment variables + -- then parse those here + if arguments and arguments:len() > 0 then + for key, value in arguments:gmatch("([%w%W]+)=([%w%W]+)") do + config.arguments[key] = value + end + end + + -- Go through each defined module and grab its config + for name, module in pairs(module_list) do + -- Apply the config + config.mod[name] = vim.tbl_deep_extend("force", config.mod[name] or {}, module.config or {}) + end + + -- After all config are merged proceed to actually load the mod + local load_module = mod.load_module + for name, _ in pairs(module_list) do + -- If it could not be loaded then halt + if not load_module(name) then + log.warn("Recovering from error...") + mod.loaded_mod[name] = nil + end + end + + -- Goes through each loaded module and invokes dorm_post_load() + for _, module in pairs(mod.loaded_mod) do + module.dorm_post_load() + end + + -- Set this variable to prevent dorm from loading twice + config.started = true + + -- Lets the entire dorm environment know that dorm has started! + mod.broadcast_event({ + type = "started", + split_type = { "base", "started" }, + filename = "", + filehead = "", + cursor_position = { 0, 0 }, + referrer = "base", + line_content = "", + broadcast = true, + buffer = vim.api.nvim_get_current_buf(), + window = vim.api.nvim_get_current_win(), + mode = vim.fn.mode(), + }) + + -- Sometimes external plugins prefer hooking in to an autocommand + vim.api.nvim_exec_autocmds("User", { + pattern = "dormStarted", + }) +end + +--- Returns whether or not dorm is loaded +--- @return boolean +function dorm.is_loaded() + return config.started +end + +return dorm diff --git a/lua/dorm/mod/autocmd/module.lua b/lua/dorm/mod/autocmd/module.lua new file mode 100644 index 0000000..729b04b --- /dev/null +++ b/lua/dorm/mod/autocmd/module.lua @@ -0,0 +1,369 @@ +--[[ + file: autocmd + summary: Handles the creation and management of Neovim's autocmd. + description: Handles the creation and management of Neovim's autocmd. + internal: true + --- +This internal module exposes functionality for subscribing to autocmd and performing actions based on those autocmd. + +###### NOTE: This module will be soon deprecated, and it's favourable to use the `vim.api*` functions instead. + +In your `module.setup()`, make sure to require `base.autocmd` (`requires = { "autocmd" }`) +Afterwards in a function of your choice that gets called *after* base.autocommmands gets intialized (e.g. `load()`): + +```lua +module.load = function() + module.required["autocmd"].enable_autocommand("VimLeavePre") -- Substitute VimLeavePre for any valid neovim autocommand +end +``` + +Afterwards, be sure to subscribe to the event: + +```lua +module.events.subscribed = { + ["autocmd"] = { + vimleavepre = true + } +} +``` + +Upon receiving an event, it will come in this format: +```lua +{ + type = "autocmd.events.", + broadcast = true +} +``` +--]] + +local dorm = require("dorm") +local log, mod = dorm.log, dorm.mod + +local module = mod.create("autocmd") + +--- This function gets invoked whenever a base.autocmd enabled autocommand is triggered. Note that this function should be only used internally +---@param name string #The name of the autocommand that was just triggered +---@param triggered_from_dorm boolean #If true, that means we have received this event as part of a *.dorm autocommand +---@param ev? table the original event data +function _dorm_module_autocommand_triggered(name, triggered_from_dorm, ev) + local event = mod.create_event(module, name, { dorm = triggered_from_dorm }, ev) + assert(event) + mod.broadcast_event(event) +end + +-- A convenience wrapper around mod.define_event_event +module.private.autocmd_base = function(name) + return mod.define_event(module, name) +end + +---@class base.autocmd +module.public = { + + --- By base, all autocmd are disabled for performance reasons. To enable them, use this command. If an invalid autocmd is given nothing happens. + ---@param autocmd string #The relative name of the autocommand to enable + ---@param dont_isolate boolean #base to false. Specifies whether the autocommand should run globally (* instead of in dorm files (*.dorm) + enable_autocommand = function(autocmd, dont_isolate) + dont_isolate = dont_isolate or false + + autocmd = autocmd:lower() + local subscribed_autocommand = module.events.subscribed["autocmd"][autocmd] + + if subscribed_autocommand ~= nil then + vim.cmd("augroup dorm") + + if dont_isolate and vim.fn.exists("#dorm#" .. autocmd .. "#*") == 0 then + vim.api.nvim_create_autocmd(autocmd, { + callback = function(ev) + _dorm_module_autocommand_triggered("autocmd.events." .. autocmd, false, ev) + end, + }) + elseif vim.fn.exists("#dorm#" .. autocmd .. "#*.dorm") == 0 then + vim.api.nvim_create_autocmd(autocmd, { + pattern = "*.dorm", + callback = function(ev) + _dorm_module_autocommand_triggered("autocmd.events." .. autocmd, true, ev) + end, + }) + end + vim.cmd("augroup END") + module.events.subscribed["autocmd"][autocmd] = true + end + end, + + version = "0.0.8", +} + +-- All the subscribeable events for base.autocmd +module.events.subscribed = { + + ["autocmd"] = { + + bufadd = false, + bufdelete = false, + bufenter = false, + buffilepost = false, + buffilepre = false, + bufhidden = false, + bufleave = false, + bufmodifiedset = false, + bufnew = false, + bufnewfile = false, + bufreadpost = false, + bufreadcmd = false, + bufreadpre = false, + bufunload = false, + bufwinenter = false, + bufwinleave = false, + bufwipeout = false, + bufwrite = false, + bufwritecmd = false, + bufwritepost = false, + chaninfo = false, + chanopen = false, + cmdundefined = false, + cmdlinechanged = false, + cmdlineenter = false, + cmdlineleave = false, + cmdwinenter = false, + cmdwinleave = false, + colorscheme = false, + colorschemepre = false, + completechanged = false, + completedonepre = false, + completedone = false, + cursorhold = false, + cursorholdi = false, + cursormoved = false, + cursormovedi = false, + diffupdated = false, + dirchanged = false, + fileappendcmd = false, + fileappendpost = false, + fileappendpre = false, + filechangedro = false, + exitpre = false, + filechangedshell = false, + filechangedshellpost = false, + filereadcmd = false, + filereadpost = false, + filereadpre = false, + filetype = false, + filewritecmd = false, + filewritepost = false, + filewritepre = false, + filterreadpost = false, + filterreadpre = false, + filterwritepost = false, + filterwritepre = false, + focusgained = false, + focuslost = false, + funcundefined = false, + uienter = false, + uileave = false, + insertchange = false, + insertcharpre = false, + textyankpost = false, + insertenter = false, + insertleavepre = false, + insertleave = false, + menupopup = false, + optionset = false, + quickfixcmdpre = false, + quickfixcmdpost = false, + quitpre = false, + remotereply = false, + sessionloadpost = false, + shellcmdpost = false, + signal = false, + shellfilterpost = false, + sourcepre = false, + sourcepost = false, + sourcecmd = false, + spellfilemissing = false, + stdinreadpost = false, + stdinreadpre = false, + swapexists = false, + syntax = false, + tabenter = false, + tableave = false, + tabnew = false, + tabnewentered = false, + tabclosed = false, + termopen = false, + termenter = false, + termleave = false, + termclose = false, + termresponse = false, + textchanged = false, + textchangedi = false, + textchangedp = false, + user = false, + usergettingbored = false, + vimenter = false, + vimleave = false, + vimleavepre = false, + vimresized = false, + vimresume = false, + vimsuspend = false, + winclosed = false, + winenter = false, + winleave = false, + winnew = false, + winscrolled = false, + }, +} + +-- All the autocommand definitions +module.events.defined = { + + bufadd = module.private.autocmd_base("bufadd"), + bufdelete = module.private.autocmd_base("bufdelete"), + bufenter = module.private.autocmd_base("bufenter"), + buffilepost = module.private.autocmd_base("buffilepost"), + buffilepre = module.private.autocmd_base("buffilepre"), + bufhidden = module.private.autocmd_base("bufhidden"), + bufleave = module.private.autocmd_base("bufleave"), + bufmodifiedset = module.private.autocmd_base("bufmodifiedset"), + bufnew = module.private.autocmd_base("bufnew"), + bufnewfile = module.private.autocmd_base("bufnewfile"), + bufreadpost = module.private.autocmd_base("bufreadpost"), + bufreadcmd = module.private.autocmd_base("bufreadcmd"), + bufreadpre = module.private.autocmd_base("bufreadpre"), + bufunload = module.private.autocmd_base("bufunload"), + bufwinenter = module.private.autocmd_base("bufwinenter"), + bufwinleave = module.private.autocmd_base("bufwinleave"), + bufwipeout = module.private.autocmd_base("bufwipeout"), + bufwrite = module.private.autocmd_base("bufwrite"), + bufwritecmd = module.private.autocmd_base("bufwritecmd"), + bufwritepost = module.private.autocmd_base("bufwritepost"), + chaninfo = module.private.autocmd_base("chaninfo"), + chanopen = module.private.autocmd_base("chanopen"), + cmdundefined = module.private.autocmd_base("cmdundefined"), + cmdlinechanged = module.private.autocmd_base("cmdlinechanged"), + cmdlineenter = module.private.autocmd_base("cmdlineenter"), + cmdlineleave = module.private.autocmd_base("cmdlineleave"), + cmdwinenter = module.private.autocmd_base("cmdwinenter"), + cmdwinleave = module.private.autocmd_base("cmdwinleave"), + colorscheme = module.private.autocmd_base("colorscheme"), + colorschemepre = module.private.autocmd_base("colorschemepre"), + completechanged = module.private.autocmd_base("completechanged"), + completedonepre = module.private.autocmd_base("completedonepre"), + completedone = module.private.autocmd_base("completedone"), + cursorhold = module.private.autocmd_base("cursorhold"), + cursorholdi = module.private.autocmd_base("cursorholdi"), + cursormoved = module.private.autocmd_base("cursormoved"), + cursormovedi = module.private.autocmd_base("cursormovedi"), + diffupdated = module.private.autocmd_base("diffupdated"), + dirchanged = module.private.autocmd_base("dirchanged"), + fileappendcmd = module.private.autocmd_base("fileappendcmd"), + fileappendpost = module.private.autocmd_base("fileappendpost"), + fileappendpre = module.private.autocmd_base("fileappendpre"), + filechangedro = module.private.autocmd_base("filechangedro"), + exitpre = module.private.autocmd_base("exitpre"), + filechangedshell = module.private.autocmd_base("filechangedshell"), + filechangedshellpost = module.private.autocmd_base("filechangedshellpost"), + filereadcmd = module.private.autocmd_base("filereadcmd"), + filereadpost = module.private.autocmd_base("filereadpost"), + filereadpre = module.private.autocmd_base("filereadpre"), + filetype = module.private.autocmd_base("filetype"), + filewritecmd = module.private.autocmd_base("filewritecmd"), + filewritepost = module.private.autocmd_base("filewritepost"), + filewritepre = module.private.autocmd_base("filewritepre"), + filterreadpost = module.private.autocmd_base("filterreadpost"), + filterreadpre = module.private.autocmd_base("filterreadpre"), + filterwritepost = module.private.autocmd_base("filterwritepost"), + filterwritepre = module.private.autocmd_base("filterwritepre"), + focusgained = module.private.autocmd_base("focusgained"), + focuslost = module.private.autocmd_base("focuslost"), + funcundefined = module.private.autocmd_base("funcundefined"), + uienter = module.private.autocmd_base("uienter"), + uileave = module.private.autocmd_base("uileave"), + insertchange = module.private.autocmd_base("insertchange"), + insertcharpre = module.private.autocmd_base("insertcharpre"), + textyankpost = module.private.autocmd_base("textyankpost"), + insertenter = module.private.autocmd_base("insertenter"), + insertleavepre = module.private.autocmd_base("insertleavepre"), + insertleave = module.private.autocmd_base("insertleave"), + menupopup = module.private.autocmd_base("menupopup"), + optionset = module.private.autocmd_base("optionset"), + quickfixcmdpre = module.private.autocmd_base("quickfixcmdpre"), + quickfixcmdpost = module.private.autocmd_base("quickfixcmdpost"), + quitpre = module.private.autocmd_base("quitpre"), + remotereply = module.private.autocmd_base("remotereply"), + sessionloadpost = module.private.autocmd_base("sessionloadpost"), + shellcmdpost = module.private.autocmd_base("shellcmdpost"), + signal = module.private.autocmd_base("signal"), + shellfilterpost = module.private.autocmd_base("shellfilterpost"), + sourcepre = module.private.autocmd_base("sourcepre"), + sourcepost = module.private.autocmd_base("sourcepost"), + sourcecmd = module.private.autocmd_base("sourcecmd"), + spellfilemissing = module.private.autocmd_base("spellfilemissing"), + stdinreadpost = module.private.autocmd_base("stdinreadpost"), + stdinreadpre = module.private.autocmd_base("stdinreadpre"), + swapexists = module.private.autocmd_base("swapexists"), + syntax = module.private.autocmd_base("syntax"), + tabenter = module.private.autocmd_base("tabenter"), + tableave = module.private.autocmd_base("tableave"), + tabnew = module.private.autocmd_base("tabnew"), + tabnewentered = module.private.autocmd_base("tabnewentered"), + tabclosed = module.private.autocmd_base("tabclosed"), + termopen = module.private.autocmd_base("termopen"), + termenter = module.private.autocmd_base("termenter"), + termleave = module.private.autocmd_base("termleave"), + termclose = module.private.autocmd_base("termclose"), + termresponse = module.private.autocmd_base("termresponse"), + textchanged = module.private.autocmd_base("textchanged"), + textchangedi = module.private.autocmd_base("textchangedi"), + textchangedp = module.private.autocmd_base("textchangedp"), + user = module.private.autocmd_base("user"), + usergettingbored = module.private.autocmd_base("usergettingbored"), + vimenter = module.private.autocmd_base("vimenter"), + vimleave = module.private.autocmd_base("vimleave"), + vimleavepre = module.private.autocmd_base("vimleavepre"), + vimresized = module.private.autocmd_base("vimresized"), + vimresume = module.private.autocmd_base("vimresume"), + vimsuspend = module.private.autocmd_base("vimsuspend"), + winclosed = module.private.autocmd_base("winclosed"), + winenter = module.private.autocmd_base("winenter"), + winleave = module.private.autocmd_base("winleave"), + winnew = module.private.autocmd_base("winnew"), + winscrolled = module.private.autocmd_base("winscrolled"), +} + +module.examples = { + ["Binding to an Autocommand"] = function() + local mymodule = mod.create("my.module") + + mymodule.setup = function() + return { + success = true, + requires = { + "autocmd", -- Be sure to require the module! + }, + } + end + + mymodule.load = function() + -- Enable an autocommand (in this case InsertLeave) + module.required["autocmd"].enable_autocommand("InsertLeave") + end + + -- Listen for any incoming events + mymodule.on_event = function(event) + -- If it's the event we're looking for then do something! + if event.type == "autocmd.events.insertleave" then + log.warn("We left insert mode!") + end + end + + mymodule.events.subscribed = { + ["autocmd"] = { + insertleave = true, -- Be sure to listen in for this event! + }, + } + + return mymodule + end, +} + +return module diff --git a/lua/dorm/mod/base/module.lua b/lua/dorm/mod/base/module.lua new file mode 100644 index 0000000..325b3df --- /dev/null +++ b/lua/dorm/mod/base/module.lua @@ -0,0 +1,40 @@ +--[[ + file: base + summary: Metamodule for storing the most necessary mod. + internal: true + --- +This file contains all of the most important mod that any user would want +to have a "just works" experience. + +Individual entries can be disabled via the "disable" flag: +```lua +load = { + ["base"] = { + config = { + disable = { + -- module list goes here + "autocmd", + "itero", + }, + }, + }, +} +``` +--]] + +local dorm = require("dorm") +local mod = dorm.mod + +return mod.create_meta( +-- "treesitter", + "base", + "autocmd", + "notes", + "keys", + "cmd", + "store", + "code", + "todo", + "ui", + "calendar" +) diff --git a/lua/dorm/mod/calendar/module.lua b/lua/dorm/mod/calendar/module.lua new file mode 100644 index 0000000..6789561 --- /dev/null +++ b/lua/dorm/mod/calendar/module.lua @@ -0,0 +1,196 @@ +--[[ + file: Calendar + title: Frictionless Dates + description: The calendar module provides a range of functionality for different date-related tasks. + summary: Opens an interactive calendar for date-related tasks. +--- +The calendar is most often invoked with the intent of selecting a date, but may +also be launched in standalone mode, select date range mode and others. + +To view keys and help, press `?` in the calendar view. +--]] + +local dorm = require("dorm") +local mod = dorm.mod + +local module = mod.create("calendar") + +module.setup = function() + return { + requires = { + "ui", + "calendar.views.monthly", + }, + } +end + +module.private = { + + modes = {}, + views = {}, + + get_mode = function(name, callback) + if module.private.modes[name] ~= nil then + local cur_mode = module.private.modes[name](callback) + cur_mode.name = name + return cur_mode + end + + print("Error: mode not set or not available") + end, + + get_view = function(name) + if module.private.views[name] ~= nil then + return module.private.views[name] + end + + print("Error: view not set or not available") + end, + + extract_ui_info = function(buffer, window) + local width = vim.api.nvim_win_get_width(window) + local height = vim.api.nvim_win_get_height(window) + + local half_width = math.floor(width / 2) + local half_height = math.floor(height / 2) + + return { + window = window, + buffer = buffer, + width = width, + height = height, + half_width = half_width, + half_height = half_height, + } + end, + + open_window = function(options) + local MIN_HEIGHT = 14 + + local buffer, window = module.required["ui"].create_split( + "calendar-" .. tostring(os.clock()):gsub("%.", "-"), + {}, + options.height or MIN_HEIGHT + (options.padding or 0) + ) + + vim.api.nvim_create_autocmd({ "WinClosed", "BufDelete" }, { + buffer = buffer, + + callback = function() + pcall(vim.api.nvim_win_close, window, true) + pcall(vim.api.nvim_buf_delete, buffer, { force = true }) + end, + }) + + return buffer, window + end, +} + +---@class base.calendar +module.public = { + add_mode = function(name, factory) + module.private.modes[name] = factory + end, + + add_view = function(name, details) + module.private.views[name] = details + end, + + create_calendar = function(buffer, window, options) + local callback_and_close = function(result) + if options.callback ~= nil then + options.callback(result) + end + + pcall(vim.api.nvim_win_close, window, true) + pcall(vim.api.nvim_buf_delete, buffer, { force = true }) + end + + local mode = module.private.get_mode(options.mode, callback_and_close) + if mode == nil then + return + end + + local ui_info = module.private.extract_ui_info(buffer, window) + + local view = module.private.get_view(options.view or "monthly") + + view.setup(ui_info, mode, options.date or os.date("*t"), options) + end, + + open = function(options) + local buffer, window = module.private.open_window(options) + + options.mode = "standalone" + + return module.public.create_calendar(buffer, window, options) + end, + + select_date = function(options) + local buffer, window = module.private.open_window(options) + + options.mode = "select_date" + + return module.public.create_calendar(buffer, window, options) + end, + + select_date_range = function(options) + local buffer, window = module.private.open_window(options) + + options.mode = "select_range" + + return module.public.create_calendar(buffer, window, options) + end, +} + +module.load = function() + -- Add base calendar modes + module.public.add_mode("standalone", function(_) + return {} + end) + + module.public.add_mode("select_date", function(callback) + return { + on_select = function(_, date) + if callback then + callback(date) + end + return false + end, + } + end) + + module.public.add_mode("select_range", function(callback) + return { + range_start = nil, + range_end = nil, + + on_select = function(self, date) + if not self.range_start then + self.range_start = date + return true + else + if os.time(date) <= os.time(self.range_start) then + print("Error: you should choose a date that is after the starting day.") + return false + end + + self.range_end = date + callback({ self.range_start, self.range_end }) + return false + end + end, + + get_day_highlight = function(self, date, base_highlight) + if self.range_start ~= nil then + if os.time(date) < os.time(self.range_start) then + return "@comment" + end + end + return base_highlight + end, + } + end) +end + +return module diff --git a/lua/dorm/mod/calendar/views/monthly/module.lua b/lua/dorm/mod/calendar/views/monthly/module.lua new file mode 100644 index 0000000..7eaa2cf --- /dev/null +++ b/lua/dorm/mod/calendar/views/monthly/module.lua @@ -0,0 +1,1162 @@ +local dorm = require("dorm") +local lib, log, mod, utils = dorm.lib, dorm.log, dorm.mod, dorm.utils + +local module = mod.create("calendar.views.monthly") + +local function reformat_time(date) + return os.date("*t", os.time(date)) +end + +module.setup = function() + return { + requires = { + "calendar", + "time", + }, + } +end + +module.private = { + namespaces = { + logical = vim.api.nvim_create_namespace("dorm/calendar/logical"), + decorational = vim.api.nvim_create_namespace("dorm/calendar/decorational"), + }, + + set_extmark = function(ui_info, namespace, row, col, length, virt_text, alignment, extra) + if alignment then + local text_length = 0 + + for _, tuple in ipairs(virt_text) do + text_length = text_length + tuple[1]:len() + end + + if alignment == "center" then + col = col + (ui_info.half_width - math.floor(text_length / 2)) + elseif alignment == "right" then + col = col + (ui_info.width - text_length) + end + end + + local base_extra = { + virt_text = virt_text, + virt_text_pos = "overlay", + } + + if length then + base_extra.end_col = col + length + end + + return vim.api.nvim_buf_set_extmark( + ui_info.buffer, + namespace, + row, + col, + vim.tbl_deep_extend("force", base_extra, extra or {}) + ) + end, + + set_decorational_extmark = function(ui_info, row, col, length, virt_text, alignment, extra) + return module.private.set_extmark( + ui_info, + module.private.namespaces.decorational, + row, + col, + length, + virt_text, + alignment, + extra + ) + end, + + set_logical_extmark = function(ui_info, row, col, virt_text, alignment, extra) + return module.private.set_extmark( + ui_info, + module.private.namespaces.logical, + row, + col, + nil, + virt_text, + alignment, + extra + ) + end, + + new_view_instance = function() + return { + current_mode = {}, + + extmarks = { + decorational = { + calendar_text = nil, + help_and_custom_input = nil, + current_view = nil, + month_headings = {}, + weekday_displays = {}, + }, + logical = { + year = nil, + months = { + -- [3] = { [31] = } + }, + }, + }, + + -- TODO: implemant distance like in render_weekday_banner + render_month_banner = function(self, ui_info, date, weekday_banner_extmark_id) + local month_name = os.date( + "%B", + os.time({ + year = date.year, + month = date.month, + day = date.day, + }) + ) + ---@cast month_name string + local month_length = vim.api.nvim_strwidth(month_name) + + local weekday_banner_id = vim.api.nvim_buf_get_extmark_by_id( + ui_info.buffer, + module.private.namespaces.decorational, + weekday_banner_extmark_id, + { + details = true, + } + ) + + self.extmarks.decorational.month_headings[weekday_banner_extmark_id] = module.private + .set_decorational_extmark( + ui_info, + 4, + weekday_banner_id[2] + + math.ceil((weekday_banner_id[3].end_col - weekday_banner_id[2]) / 2) + - math.floor(month_length / 2), + month_length, + { { month_name, "@text.underline" } }, + nil, + { + id = self.extmarks.decorational.month_headings[weekday_banner_extmark_id], + } + ) + end, + + render_weekday_banner = function(self, ui_info, offset, distance) + offset = offset or 0 + distance = distance or 4 + + -- Render the days of the week + -- To effectively do this, we grab all the weekdays from a constant time. + -- This makes the weekdays retrieved locale dependent (which is what we want). + local weekdays = {} + local weekdays_string_length = 0 + for i = 1, 7 do + local weekday = os.date("%a", os.time({ year = 2000, month = 5, day = i })) + ---@cast weekday string + local truncated = utils.truncate_by_cell(weekday, 2) + local truncated_length = vim.api.nvim_strwidth(truncated) + weekdays[#weekdays + 1] = { truncated, "@text.title" } + weekdays[#weekdays + 1] = { (" "):rep(4 - truncated_length) } + weekdays_string_length = truncated_length -- remember last day's length + end + weekdays[#weekdays] = nil -- delete last padding + weekdays_string_length = weekdays_string_length + 4 * 6 + + -- This serves as the index of this week banner extmark inside the extmark table + local absolute_offset = offset + (offset < 0 and (-offset * 100) or 0) + + local extmark_position = 0 + + -- Calculate offset position only for the previous and following months + if offset ~= 0 then + extmark_position = (weekdays_string_length * math.abs(offset)) + (distance * math.abs(offset)) + end + + -- For previous months, revert the offset + if offset < 0 then + extmark_position = -extmark_position + end + + local weekday_banner_id = module.private.set_decorational_extmark( + ui_info, + 6, + extmark_position, + weekdays_string_length, + weekdays, + "center", + { + id = self.extmarks.decorational.weekday_displays[absolute_offset], + } + ) + + self.extmarks.decorational.weekday_displays[absolute_offset] = weekday_banner_id + + return weekday_banner_id + end, + + render_month = function(self, ui_info, target_date, weekday_banner_extmark_id) + --> Month rendering routine + -- We render the first month at the very center of the screen. Each + -- month takes up a static amount of characters. + + -- Render the top text of the month (June, August etc.) + -- Render the numbers for weekdays + local days_of_month = { + -- [day of month] = , + } + + local current_date = os.date("*t") + + local month, year = target_date.month, target_date.year + + local days_in_current_month = module.private.get_month_length(month, year) + + for i = 1, days_in_current_month do + days_of_month[i] = tonumber(os.date( + "%u", + os.time({ + year = year, + month = month, + day = i, + }) + )) + end + + local beginning_of_weekday_extmark = vim.api.nvim_buf_get_extmark_by_id( + ui_info.buffer, + module.private.namespaces.decorational, + weekday_banner_extmark_id, + {} + ) + + local render_column = days_of_month[1] - 1 + local render_row = 1 + + self.extmarks.logical.months[month] = self.extmarks.logical.months[month] or {} + + for day_of_month, day_of_week in ipairs(days_of_month) do + local is_current_day = current_date.year == year + and current_date.month == month + and day_of_month == current_date.day + + local start_row = beginning_of_weekday_extmark[1] + render_row + local start_col = beginning_of_weekday_extmark[2] + (4 * render_column) + + if is_current_day then + -- TODO: Make this configurable. The user might want the cursor to start + -- on a specific date in a specific month. + -- Just look up the extmark and place the cursor there. + vim.api.nvim_win_set_cursor(ui_info.window, { start_row + 1, start_col }) + end + + local day_highlight = is_current_day and "@text.todo" or nil + + if self.current_mode.get_day_highlight then + day_highlight = self.current_mode:get_day_highlight({ + year = year, + month = month, + day = day_of_month, + }, day_highlight) + end + + self.extmarks.logical.months[month][day_of_month] = vim.api.nvim_buf_set_extmark( + ui_info.buffer, + module.private.namespaces.logical, + start_row, + start_col, + { + virt_text = { + { + (day_of_month < 10 and "0" or "") .. tostring(day_of_month), + day_highlight, + }, + }, + virt_text_pos = "overlay", + id = self.extmarks.logical.months[month][day_of_month], + } + ) + + if day_of_week == 7 then + render_column = 0 + render_row = render_row + 1 + else + render_column = render_column + 1 + end + end + end, + + render_month_array = function(self, ui_info, date, options) + -- Render the first weekday banner in the middle + local weekday_banner = self:render_weekday_banner(ui_info, 0, options.distance) + self:render_month_banner(ui_info, date, weekday_banner) + self:render_month(ui_info, date, weekday_banner) + + local months_to_render = module.private.rendered_months_in_width(ui_info.width, options.distance) + months_to_render = math.floor(months_to_render / 2) + + for i = 1, months_to_render do + weekday_banner = self:render_weekday_banner(ui_info, i, options.distance) + + local positive_target_date = reformat_time({ + year = date.year, + month = date.month + i, + day = 1, + }) + + self:render_month_banner(ui_info, positive_target_date, weekday_banner) + self:render_month(ui_info, positive_target_date, weekday_banner) + + weekday_banner = self:render_weekday_banner(ui_info, i * -1, options.distance) + + local negative_target_date = reformat_time({ + year = date.year, + month = date.month - i, + day = 1, + }) + + self:render_month_banner(ui_info, negative_target_date, weekday_banner) + self:render_month(ui_info, negative_target_date, weekday_banner) + end + end, + + render_year_tag = function(self, ui_info, year) + -- Display the current year (i.e. `< 2022 >`) + local extra = nil + + if self.extmarks.logical.year ~= nil then + extra = { + id = self.extmarks.logical.year, + } + end + + local extmark = module.private.set_logical_extmark( + ui_info, + 2, + 0, + { { "< ", "Whitespace" }, { tostring(year), "@number" }, { " >", "Whitespace" } }, + "center", + extra + ) + + if self.extmarks.logical.year == nil then + self.extmarks.logical.year = extmark + end + end, + + render_decorative_text = function(self, ui_info, view) + --> Decorational section + -- CALENDAR text: + self.extmarks.decorational = vim.tbl_deep_extend("force", self.extmarks.decorational, { + calendar_text = module.private.set_decorational_extmark(ui_info, 0, 0, 0, { + { "CALENDAR", "@text.strong" }, + }, "center"), + + -- Help text at the bottom left of the screen + help_and_custom_input = module.private.set_decorational_extmark( + ui_info, + ui_info.height - 1, + 0, + 0, + { + { "?", "@character" }, + { " - " }, + { "help", "@text.strong" }, + { " " }, + { "i", "@character" }, + { " - " }, + { "custom input", "@text.strong" }, + } + ), + + -- The current view (bottom right of the screen) + current_view = module.private.set_decorational_extmark( + ui_info, + ui_info.height - 1, + 0, + 0, + { { "[", "Whitespace" }, { view, "@label" }, { "]", "Whitespace" } }, + "right" + ), + }) + end, + + select_current_day = function(self, ui_info, date) + local extmark_id = self.extmarks.logical.months[date.month][date.day] + + local position = vim.api.nvim_buf_get_extmark_by_id( + ui_info.buffer, + module.private.namespaces.logical, + extmark_id, + {} + ) + + vim.api.nvim_win_set_cursor(ui_info.window, { position[1] + 1, position[2] }) + end, + + render_view = function(self, ui_info, date, previous_date, options) + local is_first_render = (previous_date == nil) + + if is_first_render then + vim.api.nvim_buf_clear_namespace(ui_info.buffer, module.private.namespaces.decorational, 0, -1) + vim.api.nvim_buf_clear_namespace(ui_info.buffer, module.private.namespaces.logical, 0, -1) + + vim.api.nvim_buf_set_option(ui_info.buffer, "modifiable", true) + + module.private.fill_buffer(ui_info) + self:render_decorative_text(ui_info, module.public.view_name:upper()) + self:render_year_tag(ui_info, date.year) + self:render_month_array(ui_info, date, options) + self:select_current_day(ui_info, date) + + vim.api.nvim_buf_set_option(ui_info.buffer, "modifiable", false) + vim.api.nvim_set_option_value("winfixbuf", true, { win = ui_info.window }) + + return + end + + local year_changed = (date.year ~= previous_date.year) + local month_changed = (date.month ~= previous_date.month) + local day_changed = (date.day ~= previous_date.day) + + if year_changed then + self:render_year_tag(ui_info, date.year) + end + + if year_changed or month_changed then + self:render_month_array(ui_info, date, options) + self:clear_extmarks(ui_info, date, options) + end + + if year_changed or month_changed or day_changed then + self:select_current_day(ui_info, date) + end + end, + + clear_extmarks = function(self, ui_info, current_date, options) + local cur_month = current_date.month + + local rendered_months_offset = + math.floor(module.private.rendered_months_in_width(ui_info.width, options.distance) / 2) + + -- Mimics ternary operator to be concise + local month_min = cur_month - rendered_months_offset + month_min = month_min <= 0 and (12 + month_min) or month_min + + local month_max = cur_month + rendered_months_offset + month_max = month_max > 12 and (month_max - 12) or month_max + + local clear_extmarks_for_month = function(month) + for _, extmark_id in ipairs(self.extmarks.logical.months[month]) do + vim.api.nvim_buf_del_extmark(ui_info.buffer, module.private.namespaces.logical, extmark_id) + end + + self.extmarks.logical.months[month] = nil + end + + for month, _ in pairs(self.extmarks.logical.months) do + -- Check if the month is outside the current view range + -- considering the month wrapping after 12 + if month_min < month_max then + if month_min > month or month > month_max then + clear_extmarks_for_month(month) + end + elseif month_min > month_max then + if month_max < month and month < month_min then + clear_extmarks_for_month(month) + end + elseif month_min == month_max then + if month ~= cur_month then + clear_extmarks_for_month(month) + end + end + end + end, + } + end, + + fill_buffer = function(ui_info) + -- There are many steps to render a calendar. + -- The first step is to fill the entire buffer with spaces. This lets + -- us place extmarks at any position in the document. Won't be used for + -- the meaty stuff, but will come in handy for rendering decorational + -- elements. + local fill = {} + local filler = string.rep(" ", ui_info.width) + + for i = 1, ui_info.height do + fill[i] = filler + end + + vim.api.nvim_buf_set_lines(ui_info.buffer, 0, -1, true, fill) + end, + + --- get the number of days in the month, months are wrapped (ie, month 13 <==> month 1) + get_month_length = function(month, year) + return ({ + 31, + (module.private.is_leap_year(year)) and 29 or 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + })[lib.number_wrap(month, 1, 12)] + end, + + is_leap_year = function(year) + if year % 4 ~= 0 then + return false + end + + -- Years disible by 100 are leap years only if also divisible by 400 + if year % 100 == 0 and year % 400 ~= 0 then + return false + end + + return true + end, + + rendered_months_in_width = function(width, distance) + local rendered_month_width = 26 + local months = math.floor(width / (rendered_month_width + distance)) + + -- Do not show more than one year + if months > 12 then + months = 12 + end + + if months % 2 == 0 then + return months - 1 + end + return months + end, + + display_help = function(lines) + local width, height = 44, 32 + local buffer = vim.api.nvim_create_buf(false, true) + local window = vim.api.nvim_open_win(buffer, true, { + style = "minimal", + border = "rounded", + title = " Calendar ", + title_pos = "center", + row = (vim.o.lines / 2) - height / 2, + col = (vim.o.columns / 2) - width / 2, + width = width, + height = height, + relative = "editor", + noautocmd = true, + }) + vim.api.nvim_set_option_value("winfixbuf", true, { win = window }) + + local function quit() + vim.api.nvim_win_close(window, true) + pcall(vim.api.nvim_buf_delete, buffer, { force = true }) + end + + vim.keymap.set("n", "q", quit, { buffer = buffer }) + + vim.api.nvim_create_autocmd({ "BufLeave", "WinLeave" }, { + buffer = buffer, + callback = quit, + }) + + local namespace = vim.api.nvim_create_namespace("dorm/calendar-help") + vim.api.nvim_buf_set_option(buffer, "modifiable", false) + + vim.api.nvim_buf_set_extmark(buffer, namespace, 0, 0, { + virt_lines = lines, + }) + end, +} + +---@class base.calendar.views.monthly +module.public = { + + view_name = "monthly", + + setup = function(ui_info, mode, date, options) + options.distance = options.distance or 4 + + local view = module.private.new_view_instance() + + view.current_mode = mode + + view:render_view(ui_info, date, nil, options) + + do + vim.keymap.set("n", "q", function() + vim.api.nvim_buf_delete(ui_info.buffer, { force = true }) + end, { buffer = ui_info.buffer }) + + -- TODO: Make cursor wrapping behaviour configurable + vim.keymap.set("n", "l", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day + 1 * vim.v.count1, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "h", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day - 1 * vim.v.count1, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "j", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day + 7 * vim.v.count1, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "k", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day - 7 * vim.v.count1, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "", function() + local should_redraw = false + + if view.current_mode.on_select ~= nil then + should_redraw = view.current_mode:on_select(date) + end + + if should_redraw then + view:render_view(ui_info, date, nil, options) + end + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "L", function() + local new_date = reformat_time({ + year = date.year, + month = date.month + vim.v.count1, + day = date.day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "H", function() + local new_date = reformat_time({ + year = date.year, + month = date.month - vim.v.count1, + day = date.day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "m", function() + local new_date = reformat_time({ + year = date.year, + month = date.month + vim.v.count1, + day = 1, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "M", function() + if date.day > 1 then + date.month = date.month + 1 + end + local new_date = reformat_time({ + year = date.year, + month = date.month - vim.v.count1, + day = 1, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "y", function() + local new_date = reformat_time({ + year = date.year + vim.v.count1, + month = date.month, + day = date.day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "Y", function() + local new_date = reformat_time({ + year = date.year - vim.v.count1, + month = date.month, + day = date.day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "$", function() + local new_day = date.day - (lib.number_wrap(date.wday - 1, 1, 7) - 1) + 6 + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = new_day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + local start_of_week = function() + local new_day = date.day - (lib.number_wrap(date.wday - 1, 1, 7) - 1) + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = new_day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end + + vim.keymap.set("n", "0", start_of_week, { buffer = ui_info.buffer }) + vim.keymap.set("n", "_", start_of_week, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "t", function() + local new_date = os.date("*t") + + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "e", function() + local end_of_current_month = module.private.get_month_length(date.month, date.year) + if end_of_current_month > date.day then + date.month = date.month - 1 + end + local new_date = reformat_time({ + year = date.year, + month = date.month + vim.v.count1, + day = module.private.get_month_length(date.month + vim.v.count1, date.year), + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "E", function() + local new_date = reformat_time({ + year = date.year, + month = date.month - vim.v.count1, + day = module.private.get_month_length(date.month - vim.v.count1, date.year), + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "w", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day + 7 * vim.v.count1, + }) + new_date.day = new_date.day - (lib.number_wrap(new_date.wday - 1, 1, 7) - 1) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "W", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day - 7 * vim.v.count1, + }) + new_date.day = new_date.day - (lib.number_wrap(new_date.wday - 1, 1, 7) - 1) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + local months = {} + for i = 1, 12 do + table.insert( + months, + (os.date("%B", os.time({ year = 2000, month = i, day = 1 })) --[[@as string]]):lower() + ) + end + + -- store the last `;` repeatable search + local last_semi_jump = nil + -- flag to set when we're using `;` so it doesn't cycle + local skip_next = false + + vim.keymap.set("n", ";", function() + if last_semi_jump then + vim.api.nvim_feedkeys(last_semi_jump, "m", false) + end + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", ",", function() + if last_semi_jump then + local action = string.sub(last_semi_jump, 1, 1) + local subject = string.sub(last_semi_jump, 2) + local new_keys + if string.upper(action) == action then + new_keys = action:lower() .. subject + else + new_keys = action:upper() .. subject + end + vim.api.nvim_feedkeys(new_keys, "m", false) + + skip_next = true + end + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "f", function() + local char = vim.fn.getcharstr() + + for i = date.month + 1, date.month + 12 do + local m = lib.number_wrap(i, 1, 12) + if months[m]:match("^" .. char) then + if not skip_next then + last_semi_jump = "f" .. char + else + skip_next = false + end + + local new_date = reformat_time({ + year = date.year, + month = m, + day = date.day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + break + end + end + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "F", function() + local char = vim.fn.getcharstr() + + for i = date.month + 11, date.month, -1 do + local m = lib.number_wrap(i, 1, 12) + if months[m]:match("^" .. char) then + if not skip_next then + last_semi_jump = "F" .. char + else + skip_next = false + end + local new_date = reformat_time({ + year = date.year, + month = m, + day = date.day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + break + end + end + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "g", function() + local day = math.min(vim.v.count1, module.private.get_month_length(date.month, date.year)) + + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer, nowait = true }) + + vim.keymap.set("n", "G", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = module.private.get_month_length(date.month, date.year), + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "d", function() + local n = vim.v.count1 + local weekday = math.min(n, 7) + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day + (weekday - lib.number_wrap(date.wday - 1, 1, 7)), + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer, nowait = true }) + + vim.keymap.set( + "n", + "?", + lib.wrap(module.private.display_help, { + { + { "q", "@namespace" }, + { " - " }, + { "close this window", "@text.strong" }, + }, + {}, + { + { "", "@namespace" }, + { " - " }, + { "select date", "@text.strong" }, + }, + {}, + { + { "--- Basic Movement ---", "@text.title" }, + }, + {}, + { + { "l/h", "@namespace" }, + { " - " }, + { "next/previous day", "@text.strong" }, + }, + { + { "j/k", "@namespace" }, + { " - " }, + { "next/previous week", "@text.strong" }, + }, + { + { "w/W", "@namespace" }, + { " - " }, + { "start of next/this or previous week", "@text.strong" }, + }, + { + { "t", "@namespace" }, + { " - " }, + { "today", "@text.strong" }, + }, + { + { "d", "@namespace" }, + { "n" }, + { " - " }, + { "weekday ", "@text.strong" }, + { "n" }, + { " (1 = monday)", "@text.strong" }, + }, + {}, + { + { "--- Moving Between Months ---", "@text.title" }, + }, + {}, + { + { "L/H", "@namespace" }, + { " - " }, + { "next/previous month (same day)", "@text.strong" }, + }, + { + { "m/M", "@namespace" }, + { " - " }, + { "1st of next/this or previous month", "@text.strong" }, + }, + { + { "f", "@namespace" }, + { "x" }, + { "/F", "@namespace" }, + { "x" }, + { " - " }, + { "next/previous month starting with ", "@text.strong" }, + { "x" }, + }, + {}, + { + { "--- Moving Between Years ---", "@text.title" }, + }, + {}, + { + { "y/Y", "@namespace" }, + { " - " }, + { "next/previous year (same day)", "@text.strong" }, + }, + { + { "gy", "@namespace" }, + { " - " }, + { "start of the current year", "@text.strong" }, + }, + { + { "c/C", "@namespace" }, + { " - " }, + { "next/this or previous century", "@text.strong" }, + }, + { + { "g/G", "@namespace" }, + { " - " }, + { "start/end of month", "@text.strong" }, + }, + { + { " " }, + { "g takes you to day of the month", "@text.strong" }, + }, + {}, + { + { "--- Additional Info ---", "@text.title" }, + }, + {}, + { + { "All movements accept counts" }, + }, + { + { "f/F and g/G work with `;` and `,`" }, + }, + }), + { buffer = ui_info.buffer } + ) + + vim.keymap.set("n", "i", function() + local buffer = vim.api.nvim_create_buf(false, true) + vim.api.nvim_open_win(buffer, true, { + style = "minimal", + border = "single", + title = "Date (`?` for help)", + row = vim.api.nvim_win_get_height(0), + col = 0, + width = vim.o.columns, + height = 1, + relative = "win", + win = vim.api.nvim_get_current_win(), + noautocmd = true, + }) + + vim.cmd.startinsert() + + local function quit() + vim.cmd.stopinsert() + vim.api.nvim_buf_delete(buffer, { force = true }) + end + + vim.keymap.set("n", "", quit, { buffer = buffer }) + vim.keymap.set("i", "", quit, { buffer = buffer }) + vim.keymap.set( + "n", + "?", + lib.wrap(module.private.display_help, { + { + { "q", "@namespace" }, + { " - " }, + { "close this window", "@text.strong" }, + }, + { + { "", "@namespace" }, + { " - " }, + { "confirm date", "@text.strong" }, + }, + {}, + { + { "--- Quitting ---", "@text.title" }, + }, + {}, + { + { " (insert mode)", "@namespace" }, + { " - " }, + { "quit", "@text.strong" }, + }, + { + { "", "@namespace" }, + { " - " }, + { "quit", "@text.strong" }, + }, + {}, + { + { "--- Date Syntax ---", "@text.title" }, + }, + {}, + { + { "Order " }, + { "does not matter", "@text.strong" }, + { " with dates." }, + }, + {}, + { + { "Some things depend on locale." }, + }, + {}, + { + { "Months and weekdays may be written" }, + }, + { + { "with a shorthand." }, + }, + {}, + { + { "Years must contain 4 digits at" }, + }, + { + { "all times. Prefix with zeroes" }, + }, + { + { "where necessary." }, + }, + {}, + { + { "Hour syntax: `00:00.00` (hour, min, sec)" }, + }, + {}, + { + { "--- Examples ---", "@text.title" }, + }, + {}, + { + { "Tuesday May 5th 2023 19:00.23", "@dorm.markup.verbatim" }, + }, + { + { "10 Feb CEST 0600", "@dorm.markup.verbatim" }, + { " (", "@comment" }, + { "0600", "@text.emphasis" }, + { " is the year)", "@comment" }, + }, + { + { "9:00.4 2nd March Wed", "@dorm.markup.verbatim" }, + }, + }), + { buffer = buffer } + ) + + vim.keymap.set({ "n", "i" }, "", function() + local line = vim.api.nvim_buf_get_lines(buffer, 0, -1, true)[1] + + local parsed_date = module.required["time"].parse_date(line) + + if type(parsed_date) == "string" then + log.error("[ERROR]:", parsed_date) + return + end + + quit() + + local lua_date = module.required["time"].to_lua_date(parsed_date) + + local should_redraw = false + + if view.current_mode.on_select ~= nil then + should_redraw = view.current_mode:on_select(lua_date) + end + + if should_redraw then + view:render_view(ui_info, lua_date, nil, options) + end + end, { buffer = buffer }) + end, { buffer = ui_info.buffer }) + end + end, +} + +module.load = function() + module.required["calendar"].add_view(module.public.view_name, module.public) +end + +return module diff --git a/lua/dorm/mod/calendar/views/monthly/module.lua-E b/lua/dorm/mod/calendar/views/monthly/module.lua-E new file mode 100644 index 0000000..fb0a394 --- /dev/null +++ b/lua/dorm/mod/calendar/views/monthly/module.lua-E @@ -0,0 +1,1162 @@ +local dorm = require("dorm") +local lib, log, mod, utils = dorm.lib, dorm.log, dorm.mod, dorm.utils + +local module = mod.create("calendar.views.monthly") + +local function reformat_time(date) + return os.date("*t", os.time(date)) +end + +module.setup = function() + return { + requires = { + "calendar", + "time", + }, + } +end + +module.private = { + namespaces = { + logical = vim.api.nvim_create_namespace("dorm/calendar/logical"), + decorational = vim.api.nvim_create_namespace("dorm/calendar/decorational"), + }, + + set_extmark = function(ui_info, namespace, row, col, length, virt_text, alignment, extra) + if alignment then + local text_length = 0 + + for _, tuple in ipairs(virt_text) do + text_length = text_length + tuple[1]:len() + end + + if alignment == "center" then + col = col + (ui_info.half_width - math.floor(text_length / 2)) + elseif alignment == "right" then + col = col + (ui_info.width - text_length) + end + end + + local base_extra = { + virt_text = virt_text, + virt_text_pos = "overlay", + } + + if length then + base_extra.end_col = col + length + end + + return vim.api.nvim_buf_set_extmark( + ui_info.buffer, + namespace, + row, + col, + vim.tbl_deep_extend("force", base_extra, extra or {}) + ) + end, + + set_decorational_extmark = function(ui_info, row, col, length, virt_text, alignment, extra) + return module.private.set_extmark( + ui_info, + module.private.namespaces.decorational, + row, + col, + length, + virt_text, + alignment, + extra + ) + end, + + set_logical_extmark = function(ui_info, row, col, virt_text, alignment, extra) + return module.private.set_extmark( + ui_info, + module.private.namespaces.logical, + row, + col, + nil, + virt_text, + alignment, + extra + ) + end, + + new_view_instance = function() + return { + current_mode = {}, + + extmarks = { + decorational = { + calendar_text = nil, + help_and_custom_input = nil, + current_view = nil, + month_headings = {}, + weekday_displays = {}, + }, + logical = { + year = nil, + months = { + -- [3] = { [31] = } + }, + }, + }, + + -- TODO: implemant distance like in render_weekday_banner + render_month_banner = function(self, ui_info, date, weekday_banner_extmark_id) + local month_name = os.date( + "%B", + os.time({ + year = date.year, + month = date.month, + day = date.day, + }) + ) + ---@cast month_name string + local month_length = vim.api.nvim_strwidth(month_name) + + local weekday_banner_id = vim.api.nvim_buf_get_extmark_by_id( + ui_info.buffer, + module.private.namespaces.decorational, + weekday_banner_extmark_id, + { + details = true, + } + ) + + self.extmarks.decorational.month_headings[weekday_banner_extmark_id] = module.private + .set_decorational_extmark( + ui_info, + 4, + weekday_banner_id[2] + + math.ceil((weekday_banner_id[3].end_col - weekday_banner_id[2]) / 2) + - math.floor(month_length / 2), + month_length, + { { month_name, "@text.underline" } }, + nil, + { + id = self.extmarks.decorational.month_headings[weekday_banner_extmark_id], + } + ) + end, + + render_weekday_banner = function(self, ui_info, offset, distance) + offset = offset or 0 + distance = distance or 4 + + -- Render the days of the week + -- To effectively do this, we grab all the weekdays from a constant time. + -- This makes the weekdays retrieved locale dependent (which is what we want). + local weekdays = {} + local weekdays_string_length = 0 + for i = 1, 7 do + local weekday = os.date("%a", os.time({ year = 2000, month = 5, day = i })) + ---@cast weekday string + local truncated = utils.truncate_by_cell(weekday, 2) + local truncated_length = vim.api.nvim_strwidth(truncated) + weekdays[#weekdays + 1] = { truncated, "@text.title" } + weekdays[#weekdays + 1] = { (" "):rep(4 - truncated_length) } + weekdays_string_length = truncated_length -- remember last day's length + end + weekdays[#weekdays] = nil -- delete last padding + weekdays_string_length = weekdays_string_length + 4 * 6 + + -- This serves as the index of this week banner extmark inside the extmark table + local absolute_offset = offset + (offset < 0 and (-offset * 100) or 0) + + local extmark_position = 0 + + -- Calculate offset position only for the previous and following months + if offset ~= 0 then + extmark_position = (weekdays_string_length * math.abs(offset)) + (distance * math.abs(offset)) + end + + -- For previous months, revert the offset + if offset < 0 then + extmark_position = -extmark_position + end + + local weekday_banner_id = module.private.set_decorational_extmark( + ui_info, + 6, + extmark_position, + weekdays_string_length, + weekdays, + "center", + { + id = self.extmarks.decorational.weekday_displays[absolute_offset], + } + ) + + self.extmarks.decorational.weekday_displays[absolute_offset] = weekday_banner_id + + return weekday_banner_id + end, + + render_month = function(self, ui_info, target_date, weekday_banner_extmark_id) + --> Month rendering routine + -- We render the first month at the very center of the screen. Each + -- month takes up a static amount of characters. + + -- Render the top text of the month (June, August etc.) + -- Render the numbers for weekdays + local days_of_month = { + -- [day of month] = , + } + + local current_date = os.date("*t") + + local month, year = target_date.month, target_date.year + + local days_in_current_month = module.private.get_month_length(month, year) + + for i = 1, days_in_current_month do + days_of_month[i] = tonumber(os.date( + "%u", + os.time({ + year = year, + month = month, + day = i, + }) + )) + end + + local beginning_of_weekday_extmark = vim.api.nvim_buf_get_extmark_by_id( + ui_info.buffer, + module.private.namespaces.decorational, + weekday_banner_extmark_id, + {} + ) + + local render_column = days_of_month[1] - 1 + local render_row = 1 + + self.extmarks.logical.months[month] = self.extmarks.logical.months[month] or {} + + for day_of_month, day_of_week in ipairs(days_of_month) do + local is_current_day = current_date.year == year + and current_date.month == month + and day_of_month == current_date.day + + local start_row = beginning_of_weekday_extmark[1] + render_row + local start_col = beginning_of_weekday_extmark[2] + (4 * render_column) + + if is_current_day then + -- TODO: Make this configurable. The user might want the cursor to start + -- on a specific date in a specific month. + -- Just look up the extmark and place the cursor there. + vim.api.nvim_win_set_cursor(ui_info.window, { start_row + 1, start_col }) + end + + local day_highlight = is_current_day and "@text.todo" or nil + + if self.current_mode.get_day_highlight then + day_highlight = self.current_mode:get_day_highlight({ + year = year, + month = month, + day = day_of_month, + }, day_highlight) + end + + self.extmarks.logical.months[month][day_of_month] = vim.api.nvim_buf_set_extmark( + ui_info.buffer, + module.private.namespaces.logical, + start_row, + start_col, + { + virt_text = { + { + (day_of_month < 10 and "0" or "") .. tostring(day_of_month), + day_highlight, + }, + }, + virt_text_pos = "overlay", + id = self.extmarks.logical.months[month][day_of_month], + } + ) + + if day_of_week == 7 then + render_column = 0 + render_row = render_row + 1 + else + render_column = render_column + 1 + end + end + end, + + render_month_array = function(self, ui_info, date, options) + -- Render the first weekday banner in the middle + local weekday_banner = self:render_weekday_banner(ui_info, 0, options.distance) + self:render_month_banner(ui_info, date, weekday_banner) + self:render_month(ui_info, date, weekday_banner) + + local months_to_render = module.private.rendered_months_in_width(ui_info.width, options.distance) + months_to_render = math.floor(months_to_render / 2) + + for i = 1, months_to_render do + weekday_banner = self:render_weekday_banner(ui_info, i, options.distance) + + local positive_target_date = reformat_time({ + year = date.year, + month = date.month + i, + day = 1, + }) + + self:render_month_banner(ui_info, positive_target_date, weekday_banner) + self:render_month(ui_info, positive_target_date, weekday_banner) + + weekday_banner = self:render_weekday_banner(ui_info, i * -1, options.distance) + + local negative_target_date = reformat_time({ + year = date.year, + month = date.month - i, + day = 1, + }) + + self:render_month_banner(ui_info, negative_target_date, weekday_banner) + self:render_month(ui_info, negative_target_date, weekday_banner) + end + end, + + render_year_tag = function(self, ui_info, year) + -- Display the current year (i.e. `< 2022 >`) + local extra = nil + + if self.extmarks.logical.year ~= nil then + extra = { + id = self.extmarks.logical.year, + } + end + + local extmark = module.private.set_logical_extmark( + ui_info, + 2, + 0, + { { "< ", "Whitespace" }, { tostring(year), "@number" }, { " >", "Whitespace" } }, + "center", + extra + ) + + if self.extmarks.logical.year == nil then + self.extmarks.logical.year = extmark + end + end, + + render_decorative_text = function(self, ui_info, view) + --> Decorational section + -- CALENDAR text: + self.extmarks.decorational = vim.tbl_deep_extend("force", self.extmarks.decorational, { + calendar_text = module.private.set_decorational_extmark(ui_info, 0, 0, 0, { + { "CALENDAR", "@text.strong" }, + }, "center"), + + -- Help text at the bottom left of the screen + help_and_custom_input = module.private.set_decorational_extmark( + ui_info, + ui_info.height - 1, + 0, + 0, + { + { "?", "@character" }, + { " - " }, + { "help", "@text.strong" }, + { " " }, + { "i", "@character" }, + { " - " }, + { "custom input", "@text.strong" }, + } + ), + + -- The current view (bottom right of the screen) + current_view = module.private.set_decorational_extmark( + ui_info, + ui_info.height - 1, + 0, + 0, + { { "[", "Whitespace" }, { view, "@label" }, { "]", "Whitespace" } }, + "right" + ), + }) + end, + + select_current_day = function(self, ui_info, date) + local extmark_id = self.extmarks.logical.months[date.month][date.day] + + local position = vim.api.nvim_buf_get_extmark_by_id( + ui_info.buffer, + module.private.namespaces.logical, + extmark_id, + {} + ) + + vim.api.nvim_win_set_cursor(ui_info.window, { position[1] + 1, position[2] }) + end, + + render_view = function(self, ui_info, date, previous_date, options) + local is_first_render = (previous_date == nil) + + if is_first_render then + vim.api.nvim_buf_clear_namespace(ui_info.buffer, module.private.namespaces.decorational, 0, -1) + vim.api.nvim_buf_clear_namespace(ui_info.buffer, module.private.namespaces.logical, 0, -1) + + vim.api.nvim_buf_set_option(ui_info.buffer, "modifiable", true) + + module.private.fill_buffer(ui_info) + self:render_decorative_text(ui_info, module.public.view_name:upper()) + self:render_year_tag(ui_info, date.year) + self:render_month_array(ui_info, date, options) + self:select_current_day(ui_info, date) + + vim.api.nvim_buf_set_option(ui_info.buffer, "modifiable", false) + vim.api.nvim_set_option_value("winfixbuf", true, { win = ui_info.window }) + + return + end + + local year_changed = (date.year ~= previous_date.year) + local month_changed = (date.month ~= previous_date.month) + local day_changed = (date.day ~= previous_date.day) + + if year_changed then + self:render_year_tag(ui_info, date.year) + end + + if year_changed or month_changed then + self:render_month_array(ui_info, date, options) + self:clear_extmarks(ui_info, date, options) + end + + if year_changed or month_changed or day_changed then + self:select_current_day(ui_info, date) + end + end, + + clear_extmarks = function(self, ui_info, current_date, options) + local cur_month = current_date.month + + local rendered_months_offset = + math.floor(module.private.rendered_months_in_width(ui_info.width, options.distance) / 2) + + -- Mimics ternary operator to be concise + local month_min = cur_month - rendered_months_offset + month_min = month_min <= 0 and (12 + month_min) or month_min + + local month_max = cur_month + rendered_months_offset + month_max = month_max > 12 and (month_max - 12) or month_max + + local clear_extmarks_for_month = function(month) + for _, extmark_id in ipairs(self.extmarks.logical.months[month]) do + vim.api.nvim_buf_del_extmark(ui_info.buffer, module.private.namespaces.logical, extmark_id) + end + + self.extmarks.logical.months[month] = nil + end + + for month, _ in pairs(self.extmarks.logical.months) do + -- Check if the month is outside the current view range + -- considering the month wrapping after 12 + if month_min < month_max then + if month_min > month or month > month_max then + clear_extmarks_for_month(month) + end + elseif month_min > month_max then + if month_max < month and month < month_min then + clear_extmarks_for_month(month) + end + elseif month_min == month_max then + if month ~= cur_month then + clear_extmarks_for_month(month) + end + end + end + end, + } + end, + + fill_buffer = function(ui_info) + -- There are many steps to render a calendar. + -- The first step is to fill the entire buffer with spaces. This lets + -- us place extmarks at any position in the document. Won't be used for + -- the meaty stuff, but will come in handy for rendering decorational + -- elements. + local fill = {} + local filler = string.rep(" ", ui_info.width) + + for i = 1, ui_info.height do + fill[i] = filler + end + + vim.api.nvim_buf_set_lines(ui_info.buffer, 0, -1, true, fill) + end, + + --- get the number of days in the month, months are wrapped (ie, month 13 <==> month 1) + get_month_length = function(month, year) + return ({ + 31, + (module.private.is_leap_year(year)) and 29 or 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + })[lib.number_wrap(month, 1, 12)] + end, + + is_leap_year = function(year) + if year % 4 ~= 0 then + return false + end + + -- Years disible by 100 are leap years only if also divisible by 400 + if year % 100 == 0 and year % 400 ~= 0 then + return false + end + + return true + end, + + rendered_months_in_width = function(width, distance) + local rendered_month_width = 26 + local months = math.floor(width / (rendered_month_width + distance)) + + -- Do not show more than one year + if months > 12 then + months = 12 + end + + if months % 2 == 0 then + return months - 1 + end + return months + end, + + display_help = function(lines) + local width, height = 44, 32 + local buffer = vim.api.nvim_create_buf(false, true) + local window = vim.api.nvim_open_win(buffer, true, { + style = "minimal", + border = "rounded", + title = " Calendar ", + title_pos = "center", + row = (vim.o.lines / 2) - height / 2, + col = (vim.o.columns / 2) - width / 2, + width = width, + height = height, + relative = "editor", + noautocmd = true, + }) + vim.api.nvim_set_option_value("winfixbuf", true, { win = window }) + + local function quit() + vim.api.nvim_win_close(window, true) + pcall(vim.api.nvim_buf_delete, buffer, { force = true }) + end + + vim.keymap.set("n", "q", quit, { buffer = buffer }) + + vim.api.nvim_create_autocmd({ "BufLeave", "WinLeave" }, { + buffer = buffer, + callback = quit, + }) + + local namespace = vim.api.nvim_create_namespace("dorm/calendar-help") + vim.api.nvim_buf_set_option(buffer, "modifiable", false) + + vim.api.nvim_buf_set_extmark(buffer, namespace, 0, 0, { + virt_lines = lines, + }) + end, +} + +---@class base.calendar.views.monthly +module.public = { + + view_name = "monthly", + + setup = function(ui_info, mode, date, options) + options.distance = options.distance or 4 + + local view = module.private.new_view_instance() + + view.current_mode = mode + + view:render_view(ui_info, date, nil, options) + + do + vim.keymap.set("n", "q", function() + vim.api.nvim_buf_delete(ui_info.buffer, { force = true }) + end, { buffer = ui_info.buffer }) + + -- TODO: Make cursor wrapping behaviour configurable + vim.keymap.set("n", "l", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day + 1 * vim.v.count1, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "h", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day - 1 * vim.v.count1, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "j", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day + 7 * vim.v.count1, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "k", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day - 7 * vim.v.count1, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "", function() + local should_redraw = false + + if view.current_mode.on_select ~= nil then + should_redraw = view.current_mode:on_select(date) + end + + if should_redraw then + view:render_view(ui_info, date, nil, options) + end + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "L", function() + local new_date = reformat_time({ + year = date.year, + month = date.month + vim.v.count1, + day = date.day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "H", function() + local new_date = reformat_time({ + year = date.year, + month = date.month - vim.v.count1, + day = date.day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "m", function() + local new_date = reformat_time({ + year = date.year, + month = date.month + vim.v.count1, + day = 1, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "M", function() + if date.day > 1 then + date.month = date.month + 1 + end + local new_date = reformat_time({ + year = date.year, + month = date.month - vim.v.count1, + day = 1, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "y", function() + local new_date = reformat_time({ + year = date.year + vim.v.count1, + month = date.month, + day = date.day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "Y", function() + local new_date = reformat_time({ + year = date.year - vim.v.count1, + month = date.month, + day = date.day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "$", function() + local new_day = date.day - (lib.number_wrap(date.wday - 1, 1, 7) - 1) + 6 + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = new_day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + local start_of_week = function() + local new_day = date.day - (lib.number_wrap(date.wday - 1, 1, 7) - 1) + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = new_day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end + + vim.keymap.set("n", "0", start_of_week, { buffer = ui_info.buffer }) + vim.keymap.set("n", "_", start_of_week, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "t", function() + local new_date = os.date("*t") + + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "e", function() + local end_of_current_month = module.private.get_month_length(date.month, date.year) + if end_of_current_month > date.day then + date.month = date.month - 1 + end + local new_date = reformat_time({ + year = date.year, + month = date.month + vim.v.count1, + day = module.private.get_month_length(date.month + vim.v.count1, date.year), + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "E", function() + local new_date = reformat_time({ + year = date.year, + month = date.month - vim.v.count1, + day = module.private.get_month_length(date.month - vim.v.count1, date.year), + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "w", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day + 7 * vim.v.count1, + }) + new_date.day = new_date.day - (lib.number_wrap(new_date.wday - 1, 1, 7) - 1) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "W", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day - 7 * vim.v.count1, + }) + new_date.day = new_date.day - (lib.number_wrap(new_date.wday - 1, 1, 7) - 1) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + local months = {} + for i = 1, 12 do + table.insert( + months, + (os.date("%B", os.time({ year = 2000, month = i, day = 1 })) --[[@as string]]):lower() + ) + end + + -- store the last `;` repeatable search + local last_semi_jump = nil + -- flag to set when we're using `;` so it doesn't cycle + local skip_next = false + + vim.keymap.set("n", ";", function() + if last_semi_jump then + vim.api.nvim_feedkeys(last_semi_jump, "m", false) + end + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", ",", function() + if last_semi_jump then + local action = string.sub(last_semi_jump, 1, 1) + local subject = string.sub(last_semi_jump, 2) + local new_keys + if string.upper(action) == action then + new_keys = action:lower() .. subject + else + new_keys = action:upper() .. subject + end + vim.api.nvim_feedkeys(new_keys, "m", false) + + skip_next = true + end + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "f", function() + local char = vim.fn.getcharstr() + + for i = date.month + 1, date.month + 12 do + local m = lib.number_wrap(i, 1, 12) + if months[m]:match("^" .. char) then + if not skip_next then + last_semi_jump = "f" .. char + else + skip_next = false + end + + local new_date = reformat_time({ + year = date.year, + month = m, + day = date.day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + break + end + end + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "F", function() + local char = vim.fn.getcharstr() + + for i = date.month + 11, date.month, -1 do + local m = lib.number_wrap(i, 1, 12) + if months[m]:match("^" .. char) then + if not skip_next then + last_semi_jump = "F" .. char + else + skip_next = false + end + local new_date = reformat_time({ + year = date.year, + month = m, + day = date.day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + break + end + end + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "g", function() + local day = math.min(vim.v.count1, module.private.get_month_length(date.month, date.year)) + + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = day, + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer, nowait = true }) + + vim.keymap.set("n", "G", function() + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = module.private.get_month_length(date.month, date.year), + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer }) + + vim.keymap.set("n", "d", function() + local n = vim.v.count1 + local weekday = math.min(n, 7) + local new_date = reformat_time({ + year = date.year, + month = date.month, + day = date.day + (weekday - lib.number_wrap(date.wday - 1, 1, 7)), + }) + view:render_view(ui_info, new_date, date, options) + date = new_date + end, { buffer = ui_info.buffer, nowait = true }) + + vim.keymap.set( + "n", + "?", + lib.wrap(module.private.display_help, { + { + { "q", "@namespace" }, + { " - " }, + { "close this window", "@text.strong" }, + }, + {}, + { + { "", "@namespace" }, + { " - " }, + { "select date", "@text.strong" }, + }, + {}, + { + { "--- Basic Movement ---", "@text.title" }, + }, + {}, + { + { "l/h", "@namespace" }, + { " - " }, + { "next/previous day", "@text.strong" }, + }, + { + { "j/k", "@namespace" }, + { " - " }, + { "next/previous week", "@text.strong" }, + }, + { + { "w/W", "@namespace" }, + { " - " }, + { "start of next/this or previous week", "@text.strong" }, + }, + { + { "t", "@namespace" }, + { " - " }, + { "today", "@text.strong" }, + }, + { + { "d", "@namespace" }, + { "n" }, + { " - " }, + { "weekday ", "@text.strong" }, + { "n" }, + { " (1 = monday)", "@text.strong" }, + }, + {}, + { + { "--- Moving Between Months ---", "@text.title" }, + }, + {}, + { + { "L/H", "@namespace" }, + { " - " }, + { "next/previous month (same day)", "@text.strong" }, + }, + { + { "m/M", "@namespace" }, + { " - " }, + { "1st of next/this or previous month", "@text.strong" }, + }, + { + { "f", "@namespace" }, + { "x" }, + { "/F", "@namespace" }, + { "x" }, + { " - " }, + { "next/previous month starting with ", "@text.strong" }, + { "x" }, + }, + {}, + { + { "--- Moving Between Years ---", "@text.title" }, + }, + {}, + { + { "y/Y", "@namespace" }, + { " - " }, + { "next/previous year (same day)", "@text.strong" }, + }, + { + { "gy", "@namespace" }, + { " - " }, + { "start of the current year", "@text.strong" }, + }, + { + { "c/C", "@namespace" }, + { " - " }, + { "next/this or previous century", "@text.strong" }, + }, + { + { "g/G", "@namespace" }, + { " - " }, + { "start/end of month", "@text.strong" }, + }, + { + { " " }, + { "g takes you to day of the month", "@text.strong" }, + }, + {}, + { + { "--- Additional Info ---", "@text.title" }, + }, + {}, + { + { "All movements accept counts" }, + }, + { + { "f/F and g/G work with `;` and `,`" }, + }, + }), + { buffer = ui_info.buffer } + ) + + vim.keymap.set("n", "i", function() + local buffer = vim.api.nvim_create_buf(false, true) + vim.api.nvim_open_win(buffer, true, { + style = "minimal", + border = "single", + title = "Date (`?` for help)", + row = vim.api.nvim_win_get_height(0), + col = 0, + width = vim.o.columns, + height = 1, + relative = "win", + win = vim.api.nvim_get_current_win(), + noautocmd = true, + }) + + vim.cmd.startinsert() + + local function quit() + vim.cmd.stopinsert() + vim.api.nvim_buf_delete(buffer, { force = true }) + end + + vim.keymap.set("n", "", quit, { buffer = buffer }) + vim.keymap.set("i", "", quit, { buffer = buffer }) + vim.keymap.set( + "n", + "?", + lib.wrap(module.private.display_help, { + { + { "q", "@namespace" }, + { " - " }, + { "close this window", "@text.strong" }, + }, + { + { "", "@namespace" }, + { " - " }, + { "confirm date", "@text.strong" }, + }, + {}, + { + { "--- Quitting ---", "@text.title" }, + }, + {}, + { + { " (insert mode)", "@namespace" }, + { " - " }, + { "quit", "@text.strong" }, + }, + { + { "", "@namespace" }, + { " - " }, + { "quit", "@text.strong" }, + }, + {}, + { + { "--- Date Syntax ---", "@text.title" }, + }, + {}, + { + { "Order " }, + { "does not matter", "@text.strong" }, + { " with dates." }, + }, + {}, + { + { "Some things depend on locale." }, + }, + {}, + { + { "Months and weekdays may be written" }, + }, + { + { "with a shorthand." }, + }, + {}, + { + { "Years must contain 4 digits at" }, + }, + { + { "all times. Prefix with zeroes" }, + }, + { + { "where necessary." }, + }, + {}, + { + { "Hour syntax: `00:00.00` (hour, min, sec)" }, + }, + {}, + { + { "--- Examples ---", "@text.title" }, + }, + {}, + { + { "Tuesday May 5th 2023 19:00.23", "@dorm.markup.verbatim" }, + }, + { + { "10 Feb CEST 0600", "@dorm.markup.verbatim" }, + { " (", "@comment" }, + { "0600", "@text.emphasis" }, + { " is the year)", "@comment" }, + }, + { + { "9:00.4 2nd March Wed", "@dorm.markup.verbatim" }, + }, + }), + { buffer = buffer } + ) + + vim.keymap.set({ "n", "i" }, "", function() + local line = vim.api.nvim_buf_get_lines(buffer, 0, -1, true)[1] + + local parsed_date = module.required["time"].parse_date(line) + + if type(parsed_date) == "string" then + log.error("[ERROR]:", parsed_date) + return + end + + quit() + + local lua_date = module.required["tempus"].to_lua_date(parsed_date) + + local should_redraw = false + + if view.current_mode.on_select ~= nil then + should_redraw = view.current_mode:on_select(lua_date) + end + + if should_redraw then + view:render_view(ui_info, lua_date, nil, options) + end + end, { buffer = buffer }) + end, { buffer = ui_info.buffer }) + end + end, +} + +module.load = function() + module.required["calendar"].add_view(module.public.view_name, module.public) +end + +return module diff --git a/lua/dorm/mod/cmd/commands/return/module.lua b/lua/dorm/mod/cmd/commands/return/module.lua new file mode 100644 index 0000000..eee7e90 --- /dev/null +++ b/lua/dorm/mod/cmd/commands/return/module.lua @@ -0,0 +1,59 @@ +--[[ + file: cmd-return + title: Provides the `:dorm return` Command + summary: Return to last location before entering dorm. + internal: true + --- +When executed (`:dorm return`), all currently open `.dorm` files are deleted from +the buffer list, and the current workspace is set to "base". +--]] + +local dorm = require("dorm") +local mod = dorm.mod + +local module = mod.create("cmd.commands.return") + +module.setup = function() + return { success = true, requires = { "cmd" } } +end + +module.public = { + dorm_commands = { + ["return"] = { + args = 0, + name = "return", + }, + }, +} + +module.on_event = function(event) + if event.type == "cmd.events.return" then + -- Get all the buffers + local buffers = vim.api.nvim_list_bufs() + + local to_delete = {} + for buffer in vim.iter(buffers):rev() do + if vim.fn.buflisted(buffer) == 1 then + -- If the listed buffer we're working with has a .dorm extension then remove it (not forcibly) + if not vim.endswith(vim.api.nvim_buf_get_name(buffer), ".dorm") then + vim.api.nvim_win_set_buf(0, buffer) + break + else + table.insert(to_delete, buffer) + end + end + end + + for _, buffer in ipairs(to_delete) do + vim.api.nvim_buf_delete(buffer, {}) + end + end +end + +module.events.subscribed = { + ["cmd"] = { + ["return"] = true, + }, +} + +return module diff --git a/lua/dorm/mod/cmd/module.lua b/lua/dorm/mod/cmd/module.lua new file mode 100644 index 0000000..51aac2a --- /dev/null +++ b/lua/dorm/mod/cmd/module.lua @@ -0,0 +1,499 @@ +--[[ + file: cmd-Module + title: Does the Heavy Lifting for the `:dorm` Command + summary: This module deals with handling everything related to the `:dorm` command. + internal: true + --- +This internal module handles everything there is for the `:dorm` command to function. + +Different mod can define their own commands, completions and conditions on when they'd +like these commands to be avaiable. + +For a full example on how to create your own command, it is recommended to read the +`base.cmd`'s `module.lua` file. At the beginning of the file is an examples table +which walks you through the necessary steps. +--]] + +local dorm = require("dorm") +local log, mod = dorm.log, dorm.mod + +local module = mod.create("cmd") + +module.examples = { + ["Adding a dorm command"] = function() + -- In your module.setup(), make sure to require base.cmd (requires = { "cmd" }) + -- Afterwards in a function of your choice that gets called *after* base.cmd gets intialized e.g. load(): + + module.load = function() + module.required["cmd"].add_commands_from_table({ + -- The name of our command + my_command = { + min_args = 1, -- Tells cmd that we want at least one argument for this command + max_args = 1, -- Tells cmd we want no more than one argument + args = 1, -- Setting this variable instead would be the equivalent of min_args = 1 and max_args = 1 + -- This command is only avaiable within `.dorm` files. + -- This can also be a function(bufnr, is_in_an_dorm_file) + condition = "dorm", + + subcommands = { -- Defines subcommands + -- Repeat the definition cycle again + my_subcommand = { + args = 2, -- Force two arguments to be supplied + -- The identifying name of this command + -- Every "endpoint" must have a name associated with it + name = "my.command", + + -- If your command takes in arguments versus + -- subcommands you can make a table of tables with + -- completion for those arguments here. + -- This table is optional. + complete = { + { "first_completion1", "first_completion2" }, + { "second_completion1", "second_completion2" }, + }, + + -- We do not define a subcommands table here because we don't have any more subcommands + -- Creating an empty subcommands table will cause errors so don't bother + }, + }, + }, + }) + end + + -- Afterwards, you want to subscribe to the corresponding event: + + module.events.subscribed = { + ["cmd"] = { + ["my.command"] = true, -- Has the same name as our "name" variable had in the "data" table + }, + } + + -- There's also another way to define your own custom commands that's a lot more automated. Such automation can be achieved + -- by putting your code in a special directory. That directory is in base.cmd.commands. Creating your mod in this directory + -- will allow users to easily enable you as a "command module" without much hassle. + + -- To enable a command in the commands/ directory, do this: + + require("dorm").setup({ + load = { + ["cmd"] = { + config = { + load = { + "some.cmd", -- The name of a valid command + }, + }, + }, + }, + }) + + -- And that's it! You're good to go. + -- Want to find out more? Read the wiki entry! https://github.com/nvim-dorm/dorm/wiki/dorm-Command + end, +} + +module.load = function() + -- Define the :dorm command with autocompletion taking any number of arguments (-nargs=*) + -- If the user passes no arguments or too few, we'll query them for the remainder using select_next_cmd_arg. + vim.api.nvim_create_user_command("Dorm", module.private.command_callback, { + desc = "The dorm command", + nargs = "*", + complete = module.private.generate_completions, + }) + + -- Loop through all the command mod we want to load and load them + for _, command in ipairs(module.config.public.load) do + -- If one of the command mod is "base" then load all the base mod + if command == "base" then + for _, base_command in ipairs(module.config.public.base) do + module.public.add_commands_from_file(base_command) + end + end + end +end + +module.config.public = { + -- A list of cmd mod to load automatically. + -- This feature will soon be deprecated, so it is not recommended to touch it. + load = { + "base", + }, + + -- A list of base commands to load. + -- + -- This feature will soon be deprecated, so it is not recommended to touch it. + base = { + "return", + }, +} + +---@class base.cmd +module.public = { + -- The table containing all the functions. This can get a tad complex so I recommend you read the wiki entry + dorm_commands = { + module = { + subcommands = { + load = { + args = 1, + name = "module.load", + }, + + list = { + args = 0, + name = "module.list", + }, + }, + }, + }, + + --- Recursively merges the contents of the module's config.public.funtions table with base.cmd's module.config.public.dorm_commands table. + ---@param module_name string #An absolute path to a loaded module with a module.config.public.dorm_commands table following a valid structure + add_commands = function(module_name) + local module_config = mod.get_module(module_name) + + if not module_config or not module_config.dorm_commands then + return + end + + module.public.dorm_commands = + vim.tbl_extend("force", module.public.dorm_commands, module_config.dorm_commands) + end, + + --- Recursively merges the provided table with the module.config.public.dorm_commands table. + ---@param functions table #A table that follows the module.config.public.dorm_commands structure + add_commands_from_table = function(functions) + module.public.dorm_commands = vim.tbl_extend("force", module.public.dorm_commands, functions) + end, + + --- Takes a relative path (e.g "list.mod") and loads it from the commands/ directory + ---@param name string #The relative path of the module we want to load + add_commands_from_file = function(name) + -- Attempt to require the file + local err, ret = pcall(require, "dorm.mod.cmd.commands." .. name .. ".module") + + -- If we've failed bail out + if not err then + log.warn( + "Could not load command", + name, + "for module base.cmd - the corresponding module.lua file does not exist." + ) + return + end + + -- Load the module from table + mod.load_module_from_table(ret) + end, + + --- Rereads data from all mod and rebuild the list of available autocompletions and commands + sync = function() + -- Loop through every loaded module and set up all their commands + for _, mod in pairs(mod.loaded_mod) do + if mod.public.dorm_commands then + module.public.add_commands_from_table(mod.public.dorm_commands) + end + end + end, + + --- Defines a custom completion function to use for `base.cmd`. + ---@param callback function The same function format as you would receive by being called by `:command -completion=customlist,v:lua.callback dorm`. + set_completion_callback = function(callback) + module.private.generate_completions = callback + end, +} + +module.private = { + --- Handles the calling of the appropriate function based on the command the user entered + command_callback = function(data) + local args = data.fargs + + local current_buf = vim.api.nvim_get_current_buf() + local is_dorm = vim.bo[current_buf].filetype == "dorm" + + local function check_condition(condition) + if condition == nil then + return true + end + + if condition == "dorm" and not is_dorm then + return false + end + + if type(condition) == "function" then + return condition(current_buf, is_dorm) + end + + return condition + end + + local ref = { + subcommands = module.public.dorm_commands, + } + local argument_index = 0 + + for i, cmd in ipairs(args) do + if not ref.subcommands or vim.tbl_isempty(ref.subcommands) then + break + end + + ref = ref.subcommands[cmd] + + if not ref then + log.error( + ("Error when executing `:dorm %s` - such a command does not exist!"):format( + table.concat(vim.list_slice(args, 1, i), " ") + ) + ) + return + elseif not check_condition(ref.condition) then + log.error( + ("Error when executing `:dorm %s` - the command is currently disabled. Some commands will only become available under certain conditions, e.g. being within a `.dorm` file!") + :format( + table.concat(vim.list_slice(args, 1, i), " ") + ) + ) + return + end + + argument_index = i + end + + local argument_count = (#args - argument_index) + + if ref.args then + ref.min_args = ref.args + ref.max_args = ref.args + elseif ref.min_args and not ref.max_args then + ref.max_args = math.huge + else + ref.min_args = ref.min_args or 0 + ref.max_args = ref.max_args or 0 + end + + if #args == 0 or argument_count < ref.min_args then + local completions = module.private.generate_completions(_, table.concat({ "dorm ", data.args, " " })) + module.private.select_next_cmd_arg(data.args, completions) + return + elseif argument_count > ref.max_args then + log.error( + ("Error when executing `:dorm %s` - too many arguments supplied! The command expects %s argument%s.") + :format( + data.args, + ref.max_args == 0 and "no" or ref.max_args, + ref.max_args == 1 and "" or "s" + ) + ) + return + end + + if not ref.name then + log.error( + ("Error when executing `:dorm %s` - the ending command didn't have a `name` variable associated with it! This is an implementation error on the developer's side, so file a report to the author of the module.") + :format( + data.args + ) + ) + return + end + + if not module.events.defined[ref.name] then + module.events.defined[ref.name] = mod.define_event(module, ref.name) + end + + mod.broadcast_event( + assert( + mod.create_event( + module, + table.concat({ "cmd.events.", ref.name }), + vim.list_slice(args, argument_index + 1) + ) + ) + ) + end, + + --- This function returns all available commands to be used for the :dorm command + ---@param _ nil #Placeholder variable + ---@param command string #Supplied by nvim itself; the full typed out command + generate_completions = function(_, command) + local current_buf = vim.api.nvim_get_current_buf() + local is_dorm = vim.api.nvim_buf_get_option(current_buf, "filetype") == "dorm" + + local function check_condition(condition) + if condition == nil then + return true + end + + if condition == "dorm" and not is_dorm then + return false + end + + if type(condition) == "function" then + return condition(current_buf, is_dorm) + end + + return condition + end + + command = command:gsub("^%s*", "") + + local splitcmd = vim.list_slice( + vim.split(command, " ", { + plain = true, + trimempty = true, + }), + 2 + ) + + local ref = { + subcommands = module.public.dorm_commands, + } + local last_valid_ref = ref + local last_completion_level = 0 + + for _, cmd in ipairs(splitcmd) do + if not ref or not check_condition(ref.condition) then + break + end + + ref = ref.subcommands or {} + ref = ref[cmd] + + if ref then + last_valid_ref = ref + last_completion_level = last_completion_level + 1 + end + end + + if not last_valid_ref.subcommands and last_valid_ref.complete then + if type(last_valid_ref.complete) == "function" then + last_valid_ref.complete = last_valid_ref.complete(current_buf, is_dorm) + end + + if vim.endswith(command, " ") then + local completions = last_valid_ref.complete[#splitcmd - last_completion_level + 1] or {} + + if type(completions) == "function" then + completions = completions(current_buf, is_dorm) or {} + end + + return completions + else + local completions = last_valid_ref.complete[#splitcmd - last_completion_level] or {} + + if type(completions) == "function" then + completions = completions(current_buf, is_dorm) or {} + end + + return vim.tbl_filter(function(key) + return key:find(splitcmd[#splitcmd]) + end, completions) + end + end + + -- TODO: Fix `:dorm m ` giving invalid completions + local keys = ref and vim.tbl_keys(ref.subcommands or {}) + or ( + vim.tbl_filter(function(key) + return key:find(splitcmd[#splitcmd]) + end, vim.tbl_keys(last_valid_ref.subcommands or {})) + ) + table.sort(keys) + + do + local subcommands = (ref and ref.subcommands or last_valid_ref.subcommands) or {} + + return vim.tbl_filter(function(key) + return check_condition(subcommands[key].condition) + end, keys) + end + end, + + --- Queries the user to select next argument + ---@param qargs table #A string of arguments previously supplied to the dorm command + ---@param choices table #all possible choices for the next argument + select_next_cmd_arg = function(qargs, choices) + local current = table.concat({ "dorm ", qargs }) + + local query + + if vim.tbl_isempty(choices) then + query = function(...) + vim.ui.input(...) + end + else + query = function(...) + vim.ui.select(choices, ...) + end + end + + query({ + prompt = current, + }, function(choice) + if choice ~= nil then + vim.cmd(string.format("%s %s", current, choice)) + end + end) + end, +} + +module.dorm_post_load = module.public.sync + +module.on_event = function(event) + if event.type == "cmd.events.module.load" then + local ok = pcall(mod.load_module, event.content[1]) + + if not ok then + vim.notify(string.format("Module `%s` does not exist!", event.content[1]), vim.log.levels.ERROR, {}) + end + end + + if event.type == "cmd.events.module.list" then + local Popup = require("nui.popup") + + local module_list_popup = Popup({ + position = "50%", + size = { width = "50%", height = "80%" }, + enter = true, + buf_options = { + filetype = "dorm", + modifiable = true, + readonly = false, + }, + win_options = { + conceallevel = 3, + concealcursor = "nvi", + }, + }) + + module_list_popup:on("VimResized", function() + module_list_popup:update_layout() + end) + + local function close() + module_list_popup:unmount() + end + + module_list_popup:map("n", "", close, {}) + module_list_popup:map("n", "q", close, {}) + + local lines = {} + + for name, _ in pairs(dorm.mod.loaded_mod) do + table.insert(lines, "- `" .. name .. "`") + end + + vim.api.nvim_buf_set_lines(module_list_popup.bufnr, 0, -1, true, lines) + + vim.bo[module_list_popup.bufnr].modifiable = false + + module_list_popup:mount() + end +end + +module.events.subscribed = { + ["cmd"] = { + ["module.load"] = true, + ["module.list"] = true, + }, +} + +return module diff --git a/lua/dorm/mod/code/module.lua b/lua/dorm/mod/code/module.lua new file mode 100644 index 0000000..5343009 --- /dev/null +++ b/lua/dorm/mod/code/module.lua @@ -0,0 +1,526 @@ +--[[ + file: Tangling + title: From Code Blocks to Files + description: The `base.code` module exports code blocks within a `.dorm` file straight to a file of your choice. + summary: An Advanced Code Block Exporter. + --- +The goal of this module is to allow users to spit out the contents of code blocks into +many different files. This is the primary component required for a literate configuration in dorm, +where the configuration is annotated and described in a `.dorm` document, and the actual code itself +is thrown out into a file that can then be normally consumed by e.g. an application. + +The `code` module currently provides a single command: +- `:dorm code current-file` - performs all possible tangling operations on the current file + +### Usage Tutorial +By base, *zero* code blocks are coded. You must provide where you'd like to code each code +block manually (global configuration will be discussed later). To do so, add a `#code +` tag above the code block you'd wish to export, where is relative to the +current file. For example: + +```dorm +#code init.lua +@code lua +print("Hello World!") +@end +``` +The above snippet will *only* code that single code block to the desired output file: `init.lua`. + +#### Global Tangling for Single Files +Apart from tangling a single or a set of code blocks, you can declare a global output file in the document's metadata: +```dorm +@document.meta +code: ./init.lua +@end +``` + +This will code all `lua` code blocks to `init.lua`, *unless* the code block has an explicit `#code` tag associated with it, in which case +the `#code` tag takes precedence. + +#### Global Tangling for Multiple Files +Apart from a single filepath, you can provide many in an array: +```dorm +@document.meta +code: [ + ./init.lua + ./output.hs +] +@end +``` + +The above snippet tells the dorm tangling engine to code all `lua` code blocks to `./init.lua` and all `haskell` code blocks to `./output.hs`. +As always if any of the code blocks have a `#code` tag then that takes precedence. + +#### Ignoring Code Blocks +Sometimes when tangling you may want to omit some code blocks. For this you may use the `#code.none` tag: +```dorm +#code.none +@code lua +print("I won't be coded!") +@end +``` + +#### Global Tangling with Extra Options +But wait, it doesn't stop there! You can supply a string to `code`, an array to `code`, but also an object! +It looks like this: +```dorm +@document.meta +code: { + languages: { + lua: ./output.lua + haskell: my-haskell-file + } + delimiter: heading + scope: all +} +@end +``` + +The `language` option determines which filetype should go into which file. +It's a simple language-filepath mapping, but it's especially useful when the output file's language type cannot be inferred from the name or shebang. +It is also possible to use the name `_` as a catchall to direct output for all files not otherwise listed. + +The `delimiter` option determines how to delimit code blocks that export to the same file. +The following variations are allowed: + +* `heading` -- Try to determine the filetype of the code block and insert any headings from the original document as a comment in the coded output. + If filetype detection fails, `newline` will be used instead. +* `file-content` -- Try to determine the filetype of the codeblock and insert the dorm file content as a delimiter. + If filetype detection fails, `none` will be used instead. +* `newline` -- Use an extra newline between coded blocks. +* `none` -- Do not add any delimiter. This implies that the code blocks are inserted into the code target as-is. + +The `scope` option is discussed below. + +#### Tangling Scopes +What you've seen so far is the coder operating in `all` mode. This means it captures all code blocks of a certain type unless that code block is tagged +with `#code.none`. There are two other types: `tagged` and `main`. + +##### The `tagged` Scope +When in this mode, the coder will only code code blocks that have been `tagged` with a `#code` tag. +Note that you don't have to always provide a filetype, and that: +```dorm +#code +@code lua +@end +``` +Will use the global output file for that language as defined in the metadata. I.e., if I do: +```dorm +@document.meta +code: { + languages: { + lua: ./output.lua + } + scope: tagged +} +@end + +@code lua +print("Hello") +@end + +#code +@code lua +print("Sup") +@end + +#code other-file.lua +@code lua +print("Ayo") +@end +``` +The first code block will not be touched, the second code block will be coded to `./output.lua` and the third code block will be coded to `other-file.lua`. You +can probably see that this system can get expressive pretty quick. + +##### The `main` scope +This mode is the opposite of the `tagged` one in that it will only code code blocks to files that are defined in the document metadata. I.e. in this case: +```dorm +@document.meta +code: { + languages: { + lua: ./output.lua + } + scope: main +} +@end + +@code lua +print("Hello") +@end + +#code +@code lua +print("Sup") +@end + +#code other-file.lua +@code lua +print("Ayo") +@end +``` +The first code block will be coded to `./output.lua`, the second code block will also be coded to `./output.lua` and the third code block will be ignored. +--]] + +local dorm = require("dorm") +local lib, mod, utils, log = dorm.lib, dorm.mod, dorm.utils, dorm.log + +local module = mod.create("code") +local Path = require("pathlib") + +module.setup = function() + return { + requires = { + "treesitter", + "cmd", + }, + } +end + +module.load = function() + mod.await("cmd", function(cmd) + cmd.add_commands_from_table({ + code = { + args = 1, + condition = "dorm", + + subcommands = { + ["current-file"] = { + args = 0, + name = "code.current-file", + }, + -- directory = { + -- max_args = 1, + -- name = "code.directory", + -- } + }, + }, + }) + end) + + if module.config.public.code_on_write then + local augroup = vim.api.nvim_create_augroup("dorm_auto_code", { clear = true }) + vim.api.nvim_create_autocmd("BufWritePost", { + desc = "code the current file on write", + pattern = "*.dorm", + group = augroup, + command = "dorm code current-file", + }) + end +end + +local function get_comment_string(language) + local cur_buf = vim.api.nvim_get_current_buf() + local tmp_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_current_buf(tmp_buf) + vim.bo.filetype = language + local commentstring = vim.bo.commentstring + vim.api.nvim_set_current_buf(cur_buf) + vim.api.nvim_buf_delete(tmp_buf, { force = true }) + return commentstring +end + +---@class base.code +module.public = { + code = function(buffer) + ---@type base.treesitter + local treesitter = module.required["treesitter"] + local parsed_document_metadata = treesitter.get_document_metadata(buffer) or {} + local code_settings = parsed_document_metadata.code or {} + local options = { + languages = code_settings.languages or code_settings, + scope = code_settings.scope or "all", -- "all" | "tagged" | "main" + delimiter = code_settings.delimiter or "newline", -- "newline" | "heading" | "file-content" | "none" + } + + ---@diagnostic disable-next-line + if vim.tbl_islist(options.languages) then + options.filenames_only = options.languages + options.languages = {} + elseif type(options.languages) == "string" then + options.languages = { _ = options.languages } + end + + local document_root = treesitter.get_document_root(buffer) + local filename_to_languages = {} + local codes = { + -- filename = { block_content } + } + + local query_str = lib.match(options.scope)({ + _ = [[ + (ranged_verbatim_tag + name: (tag_name) @_name + (#eq? @_name "code") + (tag_parameters + . + (tag_param) @_language)) @tag + ]], + tagged = [[ + (ranged_verbatim_tag + [(strong_carryover_set + (strong_carryover + name: (tag_name) @_strong_carryover_tag_name + (#eq? @_strong_carryover_tag_name "code"))) + (weak_carryover_set + (weak_carryover + name: (tag_name) @_weak_carryover_tag_name + (#eq? @_weak_carryover_tag_name "code")))] + name: (tag_name) @_name + (#eq? @_name "code") + (tag_parameters + . + (tag_param) @_language)) @tag + ]], + }) + + local query = utils.ts_parse_query("dorm", query_str) + local previous_headings = {} + local commentstrings = {} + local file_content_line_start = {} + local buf_name = vim.api.nvim_buf_get_name(buffer) + + for id, node in query:iter_captures(document_root, buffer, 0, -1) do + local capture = query.captures[id] + + if capture == "tag" then + local ok, parsed_tag = pcall(treesitter.get_tag_info, node, true) + if not ok then + if module.config.public.indent_errors == "print" then + print(parsed_tag) + else + log.error(parsed_tag) + end + goto skip_tag + end + + if parsed_tag then + local declared_filetype = parsed_tag.parameters[1] + local block_content = parsed_tag.content + + if parsed_tag.parameters[1] == "dorm" then + for i, line in ipairs(block_content) do + -- remove escape char + local new_line, _ = line:gsub("\\(.?)", "%1") + block_content[i] = new_line or "" + end + end + + local file_to_code_to + for _, attribute in ipairs(parsed_tag.attributes) do + if attribute.name == "code.none" then + goto skip_tag + elseif attribute.name == "code" and attribute.parameters[1] then + if options.scope == "main" then + goto skip_tag + end + file_to_code_to = table.concat(attribute.parameters) + end + end + + -- determine code file target + if not file_to_code_to then + if declared_filetype and options.languages[declared_filetype] then + file_to_code_to = options.languages[declared_filetype] + else + if options.filenames_only then + for _, filename in ipairs(options.filenames_only) do + if + declared_filetype + == vim.filetype.match({ filename = filename, contents = block_content }) ---@diagnostic disable-line -- TODO: type error workaround + then + file_to_code_to = filename + break + end + end + end + if not file_to_code_to then + file_to_code_to = options.languages["_"] + end + if declared_filetype then + options.languages[declared_filetype] = file_to_code_to + end + end + end + if not file_to_code_to then + goto skip_tag + end + + local path_lib_path = Path.new(file_to_code_to) + if path_lib_path:is_relative() then + local buf_path = Path.new(buf_name) + file_to_code_to = tostring(buf_path:parent():child(file_to_code_to):resolve()) + end + + local delimiter_content + if options.delimiter == "heading" or options.delimiter == "file-content" then + local language + if filename_to_languages[file_to_code_to] then + language = filename_to_languages[file_to_code_to] + else + language = vim.filetype.match({ filename = file_to_code_to, contents = block_content }) ---@diagnostic disable-line -- TODO: type error workaround + if not language and declared_filetype then + language = vim.filetype.match({ + filename = "___." .. declared_filetype, + contents = block_content, + }) + end + filename_to_languages[file_to_code_to] = language + end + + -- Get commentstring from vim scratch buffer + if language and not commentstrings[language] then + commentstrings[language] = get_comment_string(language) + end + + -- TODO(vhyrro): Maybe issue warnings to the user when the target + -- commentstring is not found by Neovim? + -- if not language or commentstrings[language] == "" then + -- No action + -- end + if options.delimiter == "heading" then + -- get current heading + local heading_string + local heading = treesitter.find_parent(node, "heading%d+") + if heading and heading:named_child(1) then + local srow, scol, erow, ecol = heading:named_child(1):range() + heading_string = vim.api.nvim_buf_get_text(0, srow, scol, erow, ecol, {})[1] + end + + -- don't reuse the same header more than once + if heading_string and language and previous_headings[language] ~= heading then + previous_headings[language] = heading + if codes[file_to_code_to] then + delimiter_content = { "", commentstrings[language]:format(heading_string), "" } + else + delimiter_content = { commentstrings[language]:format(heading_string), "" } + end + elseif codes[file_to_code_to] then + delimiter_content = { "" } + end + elseif options.delimiter == "file-content" then + if not file_content_line_start[file_to_code_to] then + file_content_line_start[file_to_code_to] = 0 + end + local start = file_content_line_start[file_to_code_to] + local srow, _, erow, _ = node:range() + delimiter_content = vim.api.nvim_buf_get_lines(buffer, start, srow, true) + file_content_line_start[file_to_code_to] = erow + 1 + for idx, line in ipairs(delimiter_content) do + if line ~= "" then + delimiter_content[idx] = commentstrings[language]:format(line) + end + end + end + elseif options.delimiter == "newline" then + if codes[file_to_code_to] then + delimiter_content = { "" } + end + end + + if not codes[file_to_code_to] then + codes[file_to_code_to] = {} + end + + if delimiter_content then + vim.list_extend(codes[file_to_code_to], delimiter_content) + end + vim.list_extend(codes[file_to_code_to], block_content) + end + end + ::skip_tag:: + end + + if options.delimiter == "file-content" then + for filename, start in pairs(file_content_line_start) do + local language = filename_to_languages[filename] + local delimiter_content = vim.api.nvim_buf_get_lines(buffer, start, -1, true) + for idx, line in ipairs(delimiter_content) do + if line ~= "" then + delimiter_content[idx] = commentstrings[language]:format(line) + end + end + vim.list_extend(codes[filename], delimiter_content) + end + end + + return codes + end, +} + +module.config.public = { + -- Notify when there is nothing to code (INFO) or when the content is empty (WARN). + report_on_empty = true, + + -- code all code blocks in the current dorm file on file write. + code_on_write = false, + + -- When text in a code block is less indented than the block itself, dorm will not code that + -- block to a file. Instead it can either print or vim.notify error. By base, vim.notify is + -- loud and is more likely to create a press enter message. + -- - "notify" - Throw a normal looking error + -- - "print" - print the error + indent_errors = "notify", +} + +module.on_event = function(event) + if event.type == "cmd.events.base.code.current-file" then + local codes = module.public.code(event.buffer) + + if not codes or vim.tbl_isempty(codes) then + if module.config.public.report_on_empty then + utils.notify("Nothing to code!", vim.log.levels.INFO) + end + return + end + + local file_count = vim.tbl_count(codes) + local coded_count = 0 + + for file, content in pairs(codes) do + -- resolve upward relative path like `../../` + local relative_file, upward_count = string.gsub(file, "%.%.[\\/]", "") + if upward_count > 0 then + local base_dir = vim.fn.expand("%:p" .. string.rep(":h", upward_count + 1)) --[[@as string]] + file = vim.fs.joinpath(base_dir, relative_file) + end + + vim.loop.fs_open(vim.fn.expand(file) --[[@as string]], "w", 438, function(err, fd) + assert(not err and fd, lib.lazy_string_concat("Failed to open file '", file, "' for tangling: ", err)) + + local write_content = table.concat(content, "\n") + if module.config.public.report_on_empty and write_content:len() == 0 then + vim.schedule(function() + utils.notify(string.format("coded content for %s is empty.", file), vim.log.levels.WARN) + end) + end + + vim.loop.fs_write(fd, write_content, 0, function(werr) + assert(not werr, lib.lazy_string_concat("Failed to write to '", file, "' for tangling: ", werr)) + coded_count = coded_count + 1 + file_count = file_count - 1 + if file_count == 0 then + vim.schedule( + lib.wrap( + utils.notify, + string.format( + "Successfully coded %d file%s!", + coded_count, + coded_count == 1 and "" or "s" + ) + ) + ) + end + end) + end) + end + end +end + +module.events.subscribed = { + ["cmd"] = { + ["code.current-file"] = true, + ["code.directory"] = true, + }, +} + +return module diff --git a/lua/dorm/mod/conceal/base/module.lua b/lua/dorm/mod/conceal/base/module.lua new file mode 100644 index 0000000..c66ce7c --- /dev/null +++ b/lua/dorm/mod/conceal/base/module.lua @@ -0,0 +1,8 @@ +local dorm = require("dorm") +local mod = dorm.mod + +local module = mod.create("conceal.preset_basic") + +module.config.private.icon_preset_basic = {} + +return module diff --git a/lua/dorm/mod/conceal/diamond/module.lua b/lua/dorm/mod/conceal/diamond/module.lua new file mode 100644 index 0000000..703043d --- /dev/null +++ b/lua/dorm/mod/conceal/diamond/module.lua @@ -0,0 +1,24 @@ +local dorm = require("dorm") +local mod = dorm.mod + +local module = mod.create("conceal.preset_diamond") + +module.config.private.icon_preset_diamond = { + heading = { + icons = { "◈", "◇", "◆", "⋄", "❖", "⟡" }, + }, + + footnote = { + single = { + icon = "†", + }, + multi_prefix = { + icon = "‡ ", + }, + multi_suffix = { + icon = "‡ ", + }, + }, +} + +return module diff --git a/lua/dorm/mod/conceal/module.lua b/lua/dorm/mod/conceal/module.lua new file mode 100644 index 0000000..2d4c3be --- /dev/null +++ b/lua/dorm/mod/conceal/module.lua @@ -0,0 +1,1497 @@ +--[[ + file: conceal + title: Display Markup as Icons, not Text + description: The conceal module converts verbose markup elements into beautified icons for your viewing pleasure. + summary: Enhances the basic dorm experience by using icons instead of text. + embed: https://user-images.githubusercontent.com/76052559/216767027-726b451d-6da1-4d09-8fa4-d08ec4f93f54.png + --- +"Concealing" is the process of hiding away from plain sight. When writing raw dorm, long strings like +`***** Hello` or `$$ Definition` can be distracting and sometimes unpleasant when sifting through large notes. + +To reduce the amount of cognitive load required to "parse" dorm documents with your own eyes, this module +masks, or sometimes completely hides many categories of markup. + +The conceal depends on [Nerd Fonts >=v3.0.1](https://github.com/ryanoasis/nerd-fonts/releases/latest) to be +installed on your system. + +This module respects `:h conceallevel` and `:h concealcursor`. Setting the wrong values for these options can +make it look like this module isn't working. +--]] + +-- utils to be refactored + +local dorm = require("dorm") +local log, mod, utils = dorm.log, dorm.mod, dorm.utils + +local function in_range(k, l, r_ex) + return l <= k and k < r_ex +end + +local function is_concealing_on_row_range(mode, conceallevel, concealcursor, current_row_0b, row_start_0b, row_end_0bex) + if conceallevel < 1 then + return false + elseif not in_range(current_row_0b, row_start_0b, row_end_0bex) then + return true + else + return (concealcursor:find(mode) ~= nil) + end +end + +local function table_extend_in_place(tbl, tbl_ext) + for k, v in pairs(tbl_ext) do + tbl[k] = v + end +end + +local function get_node_position_and_text_length(bufid, node) + local row_start_0b, col_start_0b = node:range() + + -- FIXME parser: multi_definition_suffix, weak_paragraph_delimiter should not span across lines + -- assert(row_start_0b == row_end_0bin, row_start_0b .. "," .. row_end_0bin) + local text = vim.treesitter.get_node_text(node, bufid) + local past_end_offset_1b = text:find("%s") or text:len() + 1 + return row_start_0b, col_start_0b, (past_end_offset_1b - 1) +end + +local function get_header_prefix_node(header_node) + local first_child = header_node:child(0) + assert(first_child:type() == header_node:type() .. "_prefix") + return first_child +end + +local function get_line_length(bufid, row_0b) + return vim.api.nvim_strwidth(vim.api.nvim_buf_get_lines(bufid, row_0b, row_0b + 1, true)[1]) +end + +--- end utils + +local module = mod.create("conceal", { + "basic", "diamond" +}) + +module.setup = function() + return { + success = true, + requires = { + "autocmd", + "treesitter", + }, + } +end + +module.private = { + ns_icon = vim.api.nvim_create_namespace("dorm-conceals"), + ns_prettify_flag = vim.api.nvim_create_namespace("dorm-conceals.prettify-flag"), + rerendering_scheduled_bufids = {}, + enabled = true, + cursor_record = {}, +} + +local function set_mark(bufid, row_0b, col_0b, text, highlight, ext_opts) + local ns_icon = module.private.ns_icon + local opt = { + virt_text = { { text, highlight } }, + virt_text_pos = "overlay", + virt_text_win_col = nil, + hl_group = nil, + conceal = nil, + id = nil, + end_row = row_0b, + end_col = col_0b, + hl_eol = nil, + virt_text_hide = nil, + hl_mode = "combine", + virt_lines = nil, + virt_lines_above = nil, + virt_lines_leftcol = nil, + ephemeral = nil, + right_gravity = nil, + end_right_gravity = nil, + priority = nil, + strict = nil, -- base true + sign_text = nil, + sign_hl_group = nil, + number_hl_group = nil, + line_hl_group = nil, + cursorline_hl_group = nil, + spell = nil, + ui_watched = nil, + invalidate = true, + } + + if ext_opts then + table_extend_in_place(opt, ext_opts) + end + + vim.api.nvim_buf_set_extmark(bufid, ns_icon, row_0b, col_0b, opt) +end + +local function table_get_base_last(tbl, index) + return tbl[index] or tbl[#tbl] +end + +local function get_ordered_index(bufid, prefix_node) + -- TODO: calculate levels in one pass, since treesitter API implementation seems to have ridiculously high complexity + local _, _, level = get_node_position_and_text_length(bufid, prefix_node) + local header_node = prefix_node:parent() + -- TODO: fix parser: `(ERROR)` on standalone prefix not followed by text, like `- ` + -- assert(header_node:type() .. "_prefix" == prefix_node:type()) + local sibling = header_node:prev_named_sibling() + local count = 1 + + while sibling and (sibling:type() == header_node:type()) do + local _, _, sibling_level = get_node_position_and_text_length(bufid, get_header_prefix_node(sibling)) + if sibling_level < level then + break + elseif sibling_level == level then + count = count + 1 + end + sibling = sibling:prev_named_sibling() + end + + return count, (sibling or header_node:parent()) +end + +local function tbl_reverse(tbl) + local result = {} + for i = 1, #tbl do + result[i] = tbl[#tbl - i + 1] + end + return result +end + +local function tostring_lowercase(n) + local t = {} + while n > 0 do + t[#t + 1] = string.char(0x61 + (n - 1) % 26) + n = math.floor((n - 1) / 26) + end + return table.concat(t):reverse() +end + +local roman_numerals = { + { "i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix" }, + { "x", "xx", "xxx", "xl", "l", "lx", "lxx", "lxxx", "xc" }, + { "c", "cc", "ccc", "cd", "d", "dc", "dcc", "dccc", "cm" }, + { "m", "mm", "mmm" }, +} + +local function tostring_roman_lowercase(n) + if n >= 4000 then + -- too large to render + return + end + + local result = {} + local i = 1 + while n > 0 do + result[#result + 1] = roman_numerals[i][n % 10] + n = math.floor(n / 10) + i = i + 1 + end + return table.concat(tbl_reverse(result)) +end + +local ordered_icon_table = { + ["0"] = function(i) + return tostring(i - 1) + end, + ["1"] = function(i) + return tostring(i) + end, + ["a"] = function(i) + return tostring_lowercase(i) + end, + ["A"] = function(i) + return tostring_lowercase(i):upper() + end, + ["i"] = function(i) + return tostring_roman_lowercase(i) + end, + ["I"] = function(i) + return tostring_roman_lowercase(i):upper() + end, + ["Ⅰ"] = { + "Ⅰ", + "Ⅱ", + "Ⅲ", + "Ⅳ", + "Ⅴ", + "Ⅵ", + "Ⅶ", + "Ⅷ", + "Ⅸ", + "Ⅹ", + "Ⅺ", + "Ⅻ", + }, + ["ⅰ"] = { + "ⅰ", + "ⅱ", + "ⅲ", + "ⅳ", + "ⅴ", + "ⅵ", + "ⅶ", + "ⅷ", + "ⅸ", + "ⅹ", + "ⅺ", + "ⅻ", + }, + ["⒈"] = { + "⒈", + "⒉", + "⒊", + "⒋", + "⒌", + "⒍", + "⒎", + "⒏", + "⒐", + "⒑", + "⒒", + "⒓", + "⒔", + "⒕", + "⒖", + "⒗", + "⒘", + "⒙", + "⒚", + "⒛", + }, + ["⑴"] = { + "⑴", + "⑵", + "⑶", + "⑷", + "⑸", + "⑹", + "⑺", + "⑻", + "⑼", + "⑽", + "⑾", + "⑿", + "⒀", + "⒁", + "⒂", + "⒃", + "⒄", + "⒅", + "⒆", + "⒇", + }, + ["①"] = { + "①", + "②", + "③", + "④", + "⑤", + "⑥", + "⑦", + "⑧", + "⑨", + "⑩", + "⑪", + "⑫", + "⑬", + "⑭", + "⑮", + "⑯", + "⑰", + "⑱", + "⑲", + "⑳", + }, + ["⒜"] = { + "⒜", + "⒝", + "⒞", + "⒟", + "⒠", + "⒡", + "⒢", + "⒣", + "⒤", + "⒥", + "⒦", + "⒧", + "⒨", + "⒩", + "⒪", + "⒫", + "⒬", + "⒭", + "⒮", + "⒯", + "⒰", + "⒱", + "⒲", + "⒳", + "⒴", + "⒵", + }, + ["Ⓐ"] = { + "Ⓐ", + "Ⓑ", + "Ⓒ", + "Ⓓ", + "Ⓔ", + "Ⓕ", + "Ⓖ", + "Ⓗ", + "Ⓘ", + "Ⓙ", + "Ⓚ", + "Ⓛ", + "Ⓜ", + "Ⓝ", + "Ⓞ", + "Ⓟ", + "Ⓠ", + "Ⓡ", + "Ⓢ", + "Ⓣ", + "Ⓤ", + "Ⓥ", + "Ⓦ", + "Ⓧ", + "Ⓨ", + "Ⓩ", + }, + ["ⓐ"] = { + "ⓐ", + "ⓑ", + "ⓒ", + "ⓓ", + "ⓔ", + "ⓕ", + "ⓖ", + "ⓗ", + "ⓘ", + "ⓙ", + "ⓚ", + "ⓛ", + "ⓜ", + "ⓝ", + "ⓞ", + "ⓟ", + "ⓠ", + "ⓡ", + "ⓢ", + "ⓣ", + "ⓤ", + "ⓥ", + "ⓦ", + "ⓧ", + "ⓨ", + "ⓩ", + }, +} + +local memoized_ordered_icon_generator = {} + +local function format_ordered_icon(pattern, index) + if type(pattern) == "function" then + return pattern(index) + end + + local gen = memoized_ordered_icon_generator[pattern] + if gen then + return gen(index) + end + + for char_one, number_table in pairs(ordered_icon_table) do + local l, r = pattern:find(char_one:find("%w") and "%f[%w]" .. char_one .. "%f[%W]" or char_one) + if l then + gen = function(index_) + local icon = type(number_table) == "function" and number_table(index_) or number_table[index_] + return icon and pattern:sub(1, l - 1) .. icon .. pattern:sub(r + 1) + end + break + end + end + + gen = gen or function(_) end + + memoized_ordered_icon_generator[pattern] = gen + return gen(index) +end + +local superscript_digits = { + ["0"] = "⁰", + ["1"] = "¹", + ["2"] = "²", + ["3"] = "³", + ["4"] = "⁴", + ["5"] = "⁵", + ["6"] = "⁶", + ["7"] = "⁷", + ["8"] = "⁸", + ["9"] = "⁹", + ["-"] = "⁻", +} + +---@class base.conceal +module.public = { + icon_renderers = { + on_left = function(config, bufid, node) + if not config.icon then + return + end + local row_0b, col_0b, len = get_node_position_and_text_length(bufid, node) + local text = (" "):rep(len - 1) .. config.icon + set_mark(bufid, row_0b, col_0b, text, config.highlight) + end, + + multilevel_on_right = function(is_ordered) + return function(config, bufid, node) + if not config.icons then + return + end + + local row_0b, col_0b, len = get_node_position_and_text_length(bufid, node) + local icon_pattern = table_get_base_last(config.icons, len) + if not icon_pattern then + return + end + + local icon = not is_ordered and icon_pattern + or format_ordered_icon(icon_pattern, get_ordered_index(bufid, node)) + if not icon then + return + end + + local text = (" "):rep(len - 1) .. icon + + local _, first_unicode_end = text:find("[%z\1-\127\194-\244][\128-\191]*", len) + local highlight = config.hl and table_get_base_last(config.hl, len) + set_mark(bufid, row_0b, col_0b, text:sub(1, first_unicode_end), highlight) + if vim.fn.strcharlen(text) > len then + set_mark(bufid, row_0b, col_0b + len, text:sub(first_unicode_end + 1), highlight, { + virt_text_pos = "inline", + }) + end + end + end, + + footnote_concealed = function(config, bufid, node) + local link_title_node = node:next_named_sibling() + local link_title = vim.treesitter.get_node_text(link_title_node, bufid) + if config.numeric_superscript and link_title:match("^[-0-9]+$") then + local t = {} + for i = 1, #link_title do + local d = link_title:sub(i, i) + table.insert(t, superscript_digits[d]) + end + local superscripted_title = table.concat(t) + local row_start_0b, col_start_0b, _, _ = link_title_node:range() + local highlight = config.title_highlight + set_mark(bufid, row_start_0b, col_start_0b, superscripted_title, highlight) + end + end, + + ---@param node TSNode + quote_concealed = function(config, bufid, node) + if not config.icons then + return + end + + local prefix = node:named_child(0) + + local row_0b, col_0b, len = get_node_position_and_text_length(bufid, prefix) + + local last_icon, last_highlight + + for _, child in ipairs(node:field("content")) do + local row_last_0b, col_last_0b = child:end_() + + -- Sometimes the parser overshoots to the next newline, breaking + -- the range. + -- To counteract this we correct the overshoot. + if col_last_0b == 0 then + row_last_0b = row_last_0b - 1 + end + + for line = row_0b, row_last_0b do + if get_line_length(bufid, line) > len then + for col = 1, len do + if config.icons[col] ~= nil then + last_icon = config.icons[col] + end + if not last_icon then + goto continue + end + last_highlight = config.hl[col] or last_highlight + set_mark(bufid, line, col_0b + (col - 1), last_icon, last_highlight) + ::continue:: + end + end + end + end + end, + + fill_text = function(config, bufid, node) + if not config.icon then + return + end + local row_0b, col_0b, len = get_node_position_and_text_length(bufid, node) + local text = config.icon:rep(len) + set_mark(bufid, row_0b, col_0b, text, config.highlight) + end, + + fill_multiline_chop2 = function(config, bufid, node) + if not config.icon then + return + end + local row_start_0b, col_start_0b, row_end_0bin, col_end_0bex = node:range() + for i = row_start_0b, row_end_0bin do + local l = i == row_start_0b and col_start_0b + 1 or 0 + local r_ex = i == row_end_0bin and col_end_0bex - 1 or get_line_length(bufid, i) + set_mark(bufid, i, l, config.icon:rep(r_ex - l), config.highlight) + end + end, + + render_horizontal_line = function(config, bufid, node) + if not config.icon then + return + end + + local row_start_0b, col_start_0b, _, col_end_0bex = node:range() + local render_col_start_0b = config.left == "here" and col_start_0b or 0 + local opt_textwidth = vim.bo[bufid].textwidth + local render_col_end_0bex = config.right == "textwidth" and (opt_textwidth > 0 and opt_textwidth or 79) + or vim.api.nvim_win_get_width(assert(vim.fn.bufwinid(bufid))) + local len = math.max(col_end_0bex - col_start_0b, render_col_end_0bex - render_col_start_0b) + set_mark(bufid, row_start_0b, render_col_start_0b, config.icon:rep(len), config.highlight) + end, + + render_code_block = function(config, bufid, node) + local tag_name = vim.treesitter.get_node_text(node:named_child(0), bufid) + if not (tag_name == "code" or tag_name == "embed") then + return + end + + local row_start_0b, col_start_0b, row_end_0bin = node:range() + assert(row_start_0b < row_end_0bin) + local conceal_on = (vim.wo.conceallevel >= 2) and config.conceal + + if conceal_on then + for _, row_0b in ipairs({ row_start_0b, row_end_0bin }) do + vim.api.nvim_buf_set_extmark( + bufid, + module.private.ns_icon, + row_0b, + 0, + { end_col = get_line_length(bufid, row_0b), conceal = "" } + ) + end + end + + if conceal_on or config.content_only then + row_start_0b = row_start_0b + 1 + row_end_0bin = row_end_0bin - 1 + end + + local line_lengths = {} + local max_len = config.min_width or 0 + for row_0b = row_start_0b, row_end_0bin do + local len = get_line_length(bufid, row_0b) + if len > max_len then + max_len = len + end + table.insert(line_lengths, len) + end + + local to_eol = (config.width ~= "content") + + for row_0b = row_start_0b, row_end_0bin do + local len = line_lengths[row_0b - row_start_0b + 1] + local mark_col_start_0b = math.max(0, col_start_0b - config.padding.left) + local mark_col_end_0bex = max_len + config.padding.right + local priority = 101 + if len >= mark_col_start_0b then + vim.api.nvim_buf_set_extmark(bufid, module.private.ns_icon, row_0b, mark_col_start_0b, { + end_row = row_0b + 1, + hl_eol = to_eol, + hl_group = config.highlight, + hl_mode = "blend", + virt_text = not to_eol and { { (" "):rep(mark_col_end_0bex - len), config.highlight } } or nil, + virt_text_pos = "overlay", + virt_text_win_col = len, + spell = config.spell_check, + priority = priority, + }) + else + vim.api.nvim_buf_set_extmark(bufid, module.private.ns_icon, row_0b, len, { + end_row = row_0b + 1, + hl_eol = to_eol, + hl_group = config.highlight, + hl_mode = "blend", + virt_text = { + { (" "):rep(mark_col_start_0b - len) }, + { not to_eol and (" "):rep(mark_col_end_0bex - mark_col_start_0b) or "", config.highlight }, + }, + virt_text_pos = "overlay", + virt_text_win_col = len, + spell = config.spell_check, + priority = priority, + }) + end + end + end, + }, + + icon_removers = { + quote = function(_, bufid, node) + for _, content in ipairs(node:field("content")) do + local end_row, end_col = content:end_() + + -- This counteracts the issue where a quote can span onto the next + -- line, even though it shouldn't. + if end_col == 0 then + end_row = end_row - 1 + end + + vim.api.nvim_buf_clear_namespace(bufid, module.private.ns_icon, (content:start()), end_row + 1) + end + end, + }, +} + +module.config.public = { + -- Which icon preset to use. + -- + -- The currently available icon presets are: + -- - "basic" - use a mixture of icons (includes cute flower icons!) + -- - "diamond" - use diamond shapes for headings + -- - "varied" - use a mix of round and diamond shapes for headings; no cute flower icons though :( + icon_preset = "basic", + + -- If true, dorm will enable folding by base for `.dorm` documents. + -- You may use the inbuilt Neovim folding options like `foldnestmax`, + -- `foldlevelstart` and others to then tune the behaviour to your liking. + -- + -- Set to `false` if you do not want dorm setting anything. + folds = true, + + -- When set to `auto`, dorm will open all folds when opening new documents if `foldlevel` is 0. + -- When set to `always`, dorm will always open all folds when opening new documents. + -- When set to `never`, dorm will not do anything. + init_open_folds = "auto", + + -- Configuration for icons. + -- + -- This table contains the full configuration set for each icon, including + -- its query (where to be placed), render functions (how to be placed) and + -- characters to use. + -- + -- For most use cases, the only values that you should be changing is the `icon`/`icons` field. + -- `icon` is a string, while `icons` is a table of strings for multilevel elements like + -- headings, lists, and quotes. + -- + -- To disable part of the config, replace the table with `false`, or prepend `false and` to it. + -- For example: `done = false` or `done = false and { ... }`. + icons = { + todo = { + done = { + icon = "󰄬", + nodes = { "todo_item_done" }, + render = module.public.icon_renderers.on_left, + }, + pending = { + icon = "󰥔", + nodes = { "todo_item_pending" }, + render = module.public.icon_renderers.on_left, + }, + undone = { + icon = " ", + nodes = { "todo_item_undone" }, + render = module.public.icon_renderers.on_left, + }, + uncertain = { + icon = "", + nodes = { "todo_item_uncertain" }, + render = module.public.icon_renderers.on_left, + }, + on_hold = { + icon = "", + nodes = { "todo_item_on_hold" }, + render = module.public.icon_renderers.on_left, + }, + cancelled = { + icon = "", + nodes = { "todo_item_cancelled" }, + render = module.public.icon_renderers.on_left, + }, + recurring = { + icon = "↺", + nodes = { "todo_item_recurring" }, + render = module.public.icon_renderers.on_left, + }, + urgent = { + icon = "⚠", + nodes = { "todo_item_urgent" }, + render = module.public.icon_renderers.on_left, + }, + }, + + list = { + icons = { "•" }, + nodes = { + "unordered_list1_prefix", + "unordered_list2_prefix", + "unordered_list3_prefix", + "unordered_list4_prefix", + "unordered_list5_prefix", + "unordered_list6_prefix", + }, + render = module.public.icon_renderers.multilevel_on_right(false), + }, + ordered = { + icons = { "1.", "A.", "a.", "(1)", "I.", "i." }, + nodes = { + "ordered_list1_prefix", + "ordered_list2_prefix", + "ordered_list3_prefix", + "ordered_list4_prefix", + "ordered_list5_prefix", + "ordered_list6_prefix", + }, + render = module.public.icon_renderers.multilevel_on_right(true), + }, + quote = { + icons = { "│" }, + nodes = { + "quote1", + "quote2", + "quote3", + "quote4", + "quote5", + "quote6", + }, + hl = { + "@dorm.quotes.1.prefix", + "@dorm.quotes.2.prefix", + "@dorm.quotes.3.prefix", + "@dorm.quotes.4.prefix", + "@dorm.quotes.5.prefix", + "@dorm.quotes.6.prefix", + }, + render = module.public.icon_renderers.quote_concealed, + clear = module.public.icon_removers.quote, + }, + heading = { + icons = { "◉", "◎", "○", "✺", "▶", "⤷" }, + hl = { + "@dorm.headings.1.prefix", + "@dorm.headings.2.prefix", + "@dorm.headings.3.prefix", + "@dorm.headings.4.prefix", + "@dorm.headings.5.prefix", + "@dorm.headings.6.prefix", + }, + nodes = { + "heading1_prefix", + "heading2_prefix", + "heading3_prefix", + "heading4_prefix", + "heading5_prefix", + "heading6_prefix", + concealed = { + "link_target_heading1", + "link_target_heading2", + "link_target_heading3", + "link_target_heading4", + "link_target_heading5", + "link_target_heading6", + }, + }, + render = module.public.icon_renderers.multilevel_on_right(false), + }, + definition = { + single = { + icon = "≡", + nodes = { "single_definition_prefix", concealed = { "link_target_definition" } }, + render = module.public.icon_renderers.on_left, + }, + multi_prefix = { + icon = "⋙ ", + nodes = { "multi_definition_prefix" }, + render = module.public.icon_renderers.on_left, + }, + multi_suffix = { + icon = "⋘ ", + nodes = { "multi_definition_suffix" }, + render = module.public.icon_renderers.on_left, + }, + }, + + footnote = { + single = { + icon = "⁎", + -- When set to true, footnote link with numeric title will be + -- concealed to superscripts. + numeric_superscript = true, + title_highlight = "@dorm.footnotes.title", + nodes = { "single_footnote_prefix", concealed = { "link_target_footnote" } }, + render = module.public.icon_renderers.on_left, + render_concealed = module.public.icon_renderers.footnote_concealed, + }, + multi_prefix = { + icon = "⁑ ", + nodes = { "multi_footnote_prefix" }, + render = module.public.icon_renderers.on_left, + }, + multi_suffix = { + icon = "⁑ ", + nodes = { "multi_footnote_suffix" }, + render = module.public.icon_renderers.on_left, + }, + }, + + delimiter = { + weak = { + icon = "⟨", + highlight = "@dorm.delimiters.weak", + nodes = { "weak_paragraph_delimiter" }, + render = module.public.icon_renderers.fill_text, + }, + strong = { + icon = "⟪", + highlight = "@dorm.delimiters.strong", + nodes = { "strong_paragraph_delimiter" }, + render = module.public.icon_renderers.fill_text, + }, + horizontal_line = { + icon = "─", + highlight = "@dorm.delimiters.horizontal_line", + nodes = { "horizontal_line" }, + -- The starting position of horizontal lines: + -- - "window": the horizontal line starts from the first column, reaching the left of the window + -- - "here": the horizontal line starts from the node column + left = "here", + -- The ending position of horizontal lines: + -- - "window": the horizontal line ends at the last column, reaching the right of the window + -- - "textwidth": the horizontal line ends at column `textwidth` or 79 when it's set to zero + right = "window", + render = module.public.icon_renderers.render_horizontal_line, + }, + }, + + markup = { + spoiler = { + icon = "•", + highlight = "@dorm.markup.spoiler", + nodes = { "spoiler" }, + render = module.public.icon_renderers.fill_multiline_chop2, + }, + }, + + -- Options that control the behaviour of code block dimming + -- (placing a darker background behind `@code` tags). + code_block = { + -- If true will only dim the content of the code block (without the + -- `@code` and `@end` lines), not the entirety of the code block itself. + content_only = true, + + -- The width to use for code block backgrounds. + -- + -- When set to `fullwidth` (the base), will create a background + -- that spans the width of the buffer. + -- + -- When set to `content`, will only span as far as the longest line + -- within the code block. + width = "fullwidth", + + -- When set to a number, the code block background will be at least + -- this many chars wide. Useful in conjunction with `width = "content"` + min_width = nil, + + -- Additional padding to apply to either the left or the right. Making + -- these values negative is considered undefined behaviour (it is + -- likely to work, but it's not officially supported). + padding = { + left = 0, + right = 0, + }, + + -- If `true` will conceal (hide) the `@code` and `@end` portion of the code + -- block. + conceal = false, + + -- If `false` will disable spell check on code blocks when 'spell' option is switched on. + spell_check = true, + + nodes = { "ranged_verbatim_tag" }, + highlight = "@dorm.tags.ranged_verbatim.code_block", + render = module.public.icon_renderers.render_code_block, + insert_enabled = true, + }, + }, +} + +local function pos_eq(pos1, pos2) + return (pos1.x == pos2.x) and (pos1.y == pos2.y) +end + +local function pos_le(pos1, pos2) + return pos1.x < pos2.x or (pos1.x == pos2.x and pos1.y <= pos2.y) +end + +-- local function pos_lt(pos1, pos2) +-- return pos1.x < pos2.x or (pos1.x == pos2.x and pos1.y < pos2.y) +-- end + +local function remove_extmarks(bufid, pos_start_0b_0b, pos_end_0bin_0bex) + assert(pos_le(pos_start_0b_0b, pos_end_0bin_0bex)) + if pos_eq(pos_start_0b_0b, pos_end_0bin_0bex) then + return + end + + local ns_icon = module.private.ns_icon + for _, result in + ipairs( + vim.api.nvim_buf_get_extmarks( + bufid, + ns_icon, + { pos_start_0b_0b.x, pos_start_0b_0b.y }, + { pos_end_0bin_0bex.x - ((pos_end_0bin_0bex.y == 0) and 1 or 0), pos_end_0bin_0bex.y - 1 }, + {} + ) + ) + do + local extmark_id = result[1] + -- TODO: Optimize + -- local node_pos_0b_0b = { x = result[2], y = result[3] } + -- assert( + -- pos_le(pos_start_0b_0b, node_pos_0b_0b) and pos_le(node_pos_0b_0b, pos_end_0bin_0bex), + -- ("start=%s, end=%s, node=%s"):format( + -- vim.inspect(pos_start_0b_0b), + -- vim.inspect(pos_end_0bin_0bex), + -- vim.inspect(node_pos_0b_0b) + -- ) + -- ) + vim.api.nvim_buf_del_extmark(bufid, ns_icon, extmark_id) + end +end + +local function is_inside_example(_) + -- TODO: waiting for parser fix + return false +end + +local function should_skip_prettify(mode, current_row_0b, node, config, row_start_0b, row_end_0bex) + local result + if config.insert_enabled then + result = false + elseif (mode == "i") and in_range(current_row_0b, row_start_0b, row_end_0bex) then + result = true + elseif is_inside_example(node) then + result = true + else + result = false + end + return result +end + +local function query_get_nodes(query, document_root, bufid, row_start_0b, row_end_0bex) + local result = {} + local concealed_node_ids = {} + for id, node in query:iter_captures(document_root, bufid, row_start_0b, row_end_0bex) do + if node:missing() then + goto continue + end + if query.captures[id] == "icon-concealed" then + concealed_node_ids[node:id()] = true + end + table.insert(result, node) + ::continue:: + end + return result, concealed_node_ids +end + +local function check_min(xy, x_new, y_new) + if (x_new < xy.x) or (x_new == xy.x and y_new < xy.y) then + xy.x = x_new + xy.y = y_new + end +end + +local function check_max(xy, x_new, y_new) + if (x_new > xy.x) or (x_new == xy.x and y_new > xy.y) then + xy.x = x_new + xy.y = y_new + end +end + +local function add_prettify_flag_line(bufid, row) + local ns_prettify_flag = module.private.ns_prettify_flag + vim.api.nvim_buf_set_extmark(bufid, ns_prettify_flag, row, 0, {}) +end + +local function add_prettify_flag_range(bufid, row_start_0b, row_end_0bex) + for row = row_start_0b, row_end_0bex - 1 do + add_prettify_flag_line(bufid, row) + end +end + +local function remove_prettify_flag_on_line(bufid, row_0b) + -- TODO: optimize + local ns_prettify_flag = module.private.ns_prettify_flag + vim.api.nvim_buf_clear_namespace(bufid, ns_prettify_flag, row_0b, row_0b + 1) +end + +local function remove_prettify_flag_range(bufid, row_start_0b, row_end_0bex) + -- TODO: optimize + local ns_prettify_flag = module.private.ns_prettify_flag + vim.api.nvim_buf_clear_namespace(bufid, ns_prettify_flag, row_start_0b, row_end_0bex) +end + +local function remove_prettify_flag_all(bufid) + remove_prettify_flag_range(bufid, 0, -1) +end + +local function get_visible_line_range(winid) + local row_start_1b = vim.fn.line("w0", winid) + local row_end_1b = vim.fn.line("w$", winid) + return (row_start_1b - 1), row_end_1b +end + +local function get_parsed_query_lazy() + if module.private.prettify_query then + return module.private.prettify_query + end + + local keys = { "config", "icons" } + local function traverse_config(config, f) + if config == false then + return + end + if config.nodes then + f(config) + return + end + if type(config) ~= "table" then + log.warn(("unsupported icon config: %s = %s"):format(table.concat(keys, "."), config)) + return + end + local key_pos = #keys + 1 + for key, sub_config in pairs(config) do + keys[key_pos] = key + traverse_config(sub_config, f) + keys[key_pos] = nil + end + end + + local config_by_node_name = {} + local queries = { "[" } + + traverse_config(module.config.public.icons, function(config) + for _, node_type in ipairs(config.nodes) do + table.insert(queries, ("(%s)@icon"):format(node_type)) + config_by_node_name[node_type] = config + end + for _, node_type in ipairs(config.nodes.concealed or {}) do + table.insert(queries, ("(%s)@icon-concealed"):format(node_type)) + config_by_node_name[node_type] = config + end + end) + + table.insert(queries, "]") + local query_combined = table.concat(queries, " ") + module.private.prettify_query = utils.ts_parse_query("dorm", query_combined) + assert(module.private.prettify_query) + module.private.config_by_node_name = config_by_node_name + return module.private.prettify_query +end + +local function prettify_range(bufid, row_start_0b, row_end_0bex) + -- in case there's undo/removal garbage + -- TODO: optimize + row_end_0bex = math.min(row_end_0bex + 1, vim.api.nvim_buf_line_count(bufid)) + + local treesitter_module = module.required["treesitter"] + local document_root = treesitter_module.get_document_root(bufid) + assert(document_root) + + local nodes, concealed_node_ids = + query_get_nodes(get_parsed_query_lazy(), document_root, bufid, row_start_0b, row_end_0bex) + + local winid = vim.fn.bufwinid(bufid) + assert(winid > 0) + local current_row_0b = vim.api.nvim_win_get_cursor(winid)[1] - 1 + local current_mode = vim.api.nvim_get_mode().mode + local conceallevel = vim.wo[winid].conceallevel + local concealcursor = vim.wo[winid].concealcursor + + assert(document_root) + + for _, node in ipairs(nodes) do + local node_row_start_0b, node_col_start_0b, node_row_end_0bin, node_col_end_0bex = node:range() + local node_row_end_0bex = node_row_end_0bin + 1 + local config = module.private.config_by_node_name[node:type()] + + if config.clear then + config:clear(bufid, node) + else + local pos_start_0b_0b, pos_end_0bin_0bex = + { x = node_row_start_0b, y = node_col_start_0b }, { x = node_row_end_0bin, y = node_col_end_0bex } + + check_min(pos_start_0b_0b, node:start()) + check_max(pos_end_0bin_0bex, node:end_()) + + remove_extmarks(bufid, pos_start_0b_0b, pos_end_0bin_0bex) + end + + remove_prettify_flag_range(bufid, node_row_start_0b, node_row_end_0bex) + add_prettify_flag_range(bufid, node_row_start_0b, node_row_end_0bex) + + if should_skip_prettify(current_mode, current_row_0b, node, config, node_row_start_0b, node_row_end_0bex) then + goto continue + end + + local has_conceal = ( + concealed_node_ids[node:id()] + and (not config.check_conceal or config.check_conceal(node)) + and is_concealing_on_row_range( + current_mode, + conceallevel, + concealcursor, + current_row_0b, + node_row_start_0b, + node_row_end_0bex + ) + ) + + if has_conceal then + if config.render_concealed then + config:render_concealed(bufid, node) + end + else + config:render(bufid, node) + end + + ::continue:: + end +end + +local function render_window_buffer(bufid) + local ns_prettify_flag = module.private.ns_prettify_flag + local winid = vim.fn.bufwinid(bufid) + local row_start_0b, row_end_0bex = get_visible_line_range(winid) + local prettify_flags_0b = vim.api.nvim_buf_get_extmarks( + bufid, + ns_prettify_flag, + { row_start_0b, 0 }, + { row_end_0bex - 1, -1 }, + {} + ) + local row_nomark_start_0b, row_nomark_end_0bin + local i_flag = 1 + for i = row_start_0b, row_end_0bex - 1 do + while i_flag <= #prettify_flags_0b and i > prettify_flags_0b[i_flag][2] do + i_flag = i_flag + 1 + end + + if i_flag <= #prettify_flags_0b and i == prettify_flags_0b[i_flag][2] then + i_flag = i_flag + 1 + else + assert(i < (prettify_flags_0b[i_flag] and prettify_flags_0b[i_flag][2] or row_end_0bex)) + row_nomark_start_0b = row_nomark_start_0b or i + row_nomark_end_0bin = i + end + end + + assert((row_nomark_start_0b == nil) == (row_nomark_end_0bin == nil)) + if row_nomark_start_0b then + prettify_range(bufid, row_nomark_start_0b, row_nomark_end_0bin + 1) + end +end + +local function render_all_scheduled_and_done() + for bufid, _ in pairs(module.private.rerendering_scheduled_bufids) do + if vim.fn.bufwinid(bufid) >= 0 then + render_window_buffer(bufid) + end + end + module.private.rerendering_scheduled_bufids = {} +end + +local function schedule_rendering(bufid) + local not_scheduled = vim.tbl_isempty(module.private.rerendering_scheduled_bufids) + module.private.rerendering_scheduled_bufids[bufid] = true + if not_scheduled then + vim.schedule(render_all_scheduled_and_done) + end +end + +local function mark_line_changed(bufid, row_0b) + remove_prettify_flag_on_line(bufid, row_0b) + schedule_rendering(bufid) +end + +local function mark_line_range_changed(bufid, row_start_0b, row_end_0bex) + remove_prettify_flag_range(bufid, row_start_0b, row_end_0bex) + schedule_rendering(bufid) +end + +local function mark_all_lines_changed(bufid) + if not module.private.enabled then + return + end + + remove_prettify_flag_all(bufid) + schedule_rendering(bufid) +end + +local function clear_all_extmarks(bufid) + local ns_icon = module.private.ns_icon + local ns_prettify_flag = module.private.ns_prettify_flag + vim.api.nvim_buf_clear_namespace(bufid, ns_icon, 0, -1) + vim.api.nvim_buf_clear_namespace(bufid, ns_prettify_flag, 0, -1) +end + +local function get_table_base_empty(tbl, key) + if not tbl[key] then + tbl[key] = {} + end + return tbl[key] +end + +local function update_cursor(event) + local cursor_record = get_table_base_empty(module.private.cursor_record, event.buffer) + cursor_record.row_0b = event.cursor_position[1] - 1 + cursor_record.col_0b = event.cursor_position[2] + cursor_record.line_content = event.line_content +end + +local function handle_init_event(event) + assert(vim.api.nvim_win_is_valid(event.window)) + update_cursor(event) + + local function on_line_callback( + tag, + bufid, + _changedtick, ---@diagnostic disable-line -- TODO: type error workaround + row_start_0b, + _row_end_0bex, ---@diagnostic disable-line -- TODO: type error workaround + row_updated_0bex, + _n_byte_prev ---@diagnostic disable-line -- TODO: type error workaround + ) + assert(tag == "lines") + + if not module.private.enabled then + return + end + + mark_line_range_changed(bufid, row_start_0b, row_updated_0bex) + end + + local attach_succeeded = vim.api.nvim_buf_attach(event.buffer, true, { on_lines = on_line_callback }) + assert(attach_succeeded) + local language_tree = vim.treesitter.get_parser(event.buffer, "dorm") + + local bufid = event.buffer + -- used for detecting non-local (multiline) changes, like spoiler / code block + -- TODO: exemption in certain cases, for example when changing only heading followed by pure texts, + -- in which case all its descendents would be unnecessarily re-concealed. + local function on_changedtree_callback(ranges) + -- TODO: abandon if too large + for i = 1, #ranges do + local range = ranges[i] + local row_start_0b = range[1] + local row_end_0bex = range[3] + 1 + remove_prettify_flag_range(bufid, row_start_0b, row_end_0bex) + end + end + + language_tree:register_cbs({ on_changedtree = on_changedtree_callback }) + mark_all_lines_changed(event.buffer) + + if + module.config.public.folds + and vim.api.nvim_win_is_valid(event.window) + and vim.api.nvim_buf_is_valid(event.buffer) + then + vim.api.nvim_buf_call(event.buffer, function() + -- NOTE(vhyrro): `vim.wo` only supports `wo[winid][0]`, + -- hence the `buf_call` here. + local wo = vim.wo[event.window][0] + wo.foldmethod = "expr" + wo.foldexpr = vim.treesitter.foldexpr and "v:lua.vim.treesitter.foldexpr()" or "nvim_treesitter#foldexpr()" + wo.foldtext = "" + + local init_open_folds = module.config.public.init_open_folds + local function open_folds() + vim.cmd("normal! zR") + end + + if init_open_folds == "always" then + open_folds() + elseif init_open_folds == "never" then -- luacheck:ignore 542 + -- do nothing + else + if init_open_folds ~= "auto" then + log.warn('"init_open_folds" must be "auto", "always", or "never"') + end + + if wo.foldlevel == 0 then + open_folds() + end + end + end) + end +end + +local function handle_insert_toggle(event) + mark_line_changed(event.buffer, event.cursor_position[1] - 1) +end + +local function handle_insertenter(event) + handle_insert_toggle(event) +end + +local function handle_insertleave(event) + handle_insert_toggle(event) +end + +local function handle_toggle_prettifier(event) + -- FIXME: module.private.enabled should be a map from bufid to boolean + module.private.enabled = not module.private.enabled + if module.private.enabled then + mark_all_lines_changed(event.buffer) + else + module.private.rerendering_scheduled_bufids[event.buffer] = nil + clear_all_extmarks(event.buffer) + end +end + +local function is_same_line_movement(event) + -- some operations like dd / u cannot yet be listened reliably + -- below is our best approximation + local cursor_record = module.private.cursor_record + return ( + cursor_record + and cursor_record.row_0b == event.cursor_position[1] - 1 + and cursor_record.col_0b ~= event.cursor_position[2] + and cursor_record.line_content == event.line_content + ) +end + +local function handle_cursor_moved(event) + -- reveal/conceal when conceallevel>0 + -- also triggered when dd / u + if not is_same_line_movement(event) then + local cursor_record = module.private.cursor_record[event.buffer] + if cursor_record then + -- leaving previous line, conceal it if necessary + mark_line_changed(event.buffer, cursor_record.row_0b) + end + -- entering current line, conceal it if necessary + local current_row_0b = event.cursor_position[1] - 1 + mark_line_changed(event.buffer, current_row_0b) + end + update_cursor(event) +end + +local function handle_cursor_moved_i(event) + return handle_cursor_moved(event) +end + +local function handle_winscrolled(event) + schedule_rendering(event.buffer) +end + +local function handle_filetype(event) + handle_init_event(event) +end + +local event_handlers = { + ["cmd.events.base.conceal.toggle"] = handle_toggle_prettifier, + -- ["autocmd.events.bufnewfile"] = handle_init_event, + ["autocmd.events.filetype"] = handle_filetype, + ["autocmd.events.bufreadpost"] = handle_init_event, + ["autocmd.events.insertenter"] = handle_insertenter, + ["autocmd.events.insertleave"] = handle_insertleave, + ["autocmd.events.cursormoved"] = handle_cursor_moved, + ["autocmd.events.cursormovedi"] = handle_cursor_moved_i, + ["autocmd.events.winscrolled"] = handle_winscrolled, +} + +module.on_event = function(event) + if event.referrer == "autocmd" and vim.bo[event.buffer].ft ~= "dorm" then + return + end + + if (not module.private.enabled) and (event.type ~= "cmd.events.base.conceal.toggle") then + return + end + return event_handlers[event.type](event) +end + +module.load = function() + local icon_preset = + module.imported[module.name .. ".preset_" .. module.config.public.icon_preset].config.private + ["icon_preset_" .. module.config.public.icon_preset] + if not icon_preset then + log.error( + ("Unable to load icon preset '%s' - such a preset does not exist"):format(module.config.public.icon_preset) + ) + return + end + + module.config.public = + vim.tbl_deep_extend("force", module.config.public, { icons = icon_preset }, module.config.custom or {}) + + -- module.required["autocmd"].enable_autocommand("BufNewFile") + module.required["autocmd"].enable_autocommand("FileType", true) + module.required["autocmd"].enable_autocommand("BufReadPost") + module.required["autocmd"].enable_autocommand("InsertEnter") + module.required["autocmd"].enable_autocommand("InsertLeave") + module.required["autocmd"].enable_autocommand("CursorMoved") + module.required["autocmd"].enable_autocommand("CursorMovedI") + module.required["autocmd"].enable_autocommand("WinScrolled", true) + + mod.await("cmd", function(cmd) + cmd.add_commands_from_table({ + ["toggle-conceal"] = { + name = "conceal.toggle", + args = 0, + condition = "dorm", + }, + }) + end) + + vim.api.nvim_create_autocmd("OptionSet", { + pattern = "conceallevel", + callback = function() + local bufid = vim.api.nvim_get_current_buf() + if vim.bo[bufid].ft ~= "dorm" then + return + end + mark_all_lines_changed(bufid) + end, + }) +end + +module.events.subscribed = { + ["autocmd"] = { + -- bufnewfile = true, + filetype = true, + bufreadpost = true, + insertenter = true, + insertleave = true, + cursormoved = true, + cursormovedi = true, + winscrolled = true, + }, + + ["cmd"] = { + ["conceal.toggle"] = true, + }, +} + +return module diff --git a/lua/dorm/mod/hl/module.lua b/lua/dorm/mod/hl/module.lua new file mode 100644 index 0000000..e828472 --- /dev/null +++ b/lua/dorm/mod/hl/module.lua @@ -0,0 +1,634 @@ +--[[ + file: base-hl + title: No Colour Means no Productivity + summary: Manages your highlight groups with this module. + internal: true + --- +`base.hl` maps all possible highlight groups available throughout +dorm under a single tree of hl: `@dorm.*`. +--]] + +local dorm = require("dorm") +local lib, log, mod = dorm.lib, dorm.log, dorm.mod + +local module = mod.create("hl") + +--[[ +--]] +module.config.public = { + -- The TS hl for each dorm type. + -- + -- The `hl` table is a large collection of nested trees. At the leaves of each of these + -- trees is the final highlight to apply to that tree. For example: `"+@comment"` tells dorm to + -- link to an existing highlight group `@comment` (denoted by the `+` prefix). When no prefix is + -- found, the string is treated as arguments passed to `:highlight`, for example: `gui=bold + -- fg=#000000`. + -- + -- Nested trees concatenate, thus: + -- ```lua + -- tags = { + -- ranged_verbatim = { + -- begin = "+@comment", + -- }, + -- } + -- ``` + -- matches the highlight group: + -- ```lua + -- @dorm.tags.ranged_verbatim.begin + -- ``` + -- and converts into the following command: + -- ```vim + -- highlight! link @dorm.tags.ranged_verbatim.begin @comment + -- ``` + hl = { + -- hl displayed in dorm selection window popups. + selection_window = { + heading = "+@annotation", + arrow = "+@none", + key = "+@module", + keyname = "+@constant", + nestedkeyname = "+@string", + }, + + -- hl displayed in all sorts of tag types. + -- + -- These include: `@`, `.`, `|`, `#`, `+` and `=`. + tags = { + -- hl for the `@` verbatim tags. + ranged_verbatim = { + begin = "+@keyword", + + ["end"] = "+@keyword", + + name = { + [""] = "+@none", + delimiter = "+@none", + word = "+@keyword", + }, + + parameters = "+@type", + + document_meta = { + key = "+@variable.member", + value = "+@string", + number = "+@number", + trailing = "+@keyword.repeat", + title = "+@markup.heading", + description = "+@label", + authors = "+@annotation", + categories = "+@keyword", + created = "+@number.float", + updated = "+@number.float", + version = "+@number.float", + + object = { + bracket = "+@punctuation.bracket", + }, + + array = { + bracket = "+@punctuation.bracket", + value = "+@none", + }, + }, + }, + + -- hl for the carryover (`#`, `+`) tags. + carryover = { + begin = "+@label", + + name = { + [""] = "+@none", + word = "+@label", + delimiter = "+@none", + }, + + parameters = "+@string", + }, + + -- hl for the content of any tag named `comment`. + -- + -- Most prominent use case is for the `#comment` carryover tag. + comment = { + content = "+@comment", + }, + }, + + -- hl for each individual heading level. + headings = { + ["1"] = { + title = "+@attribute", + prefix = "+@attribute", + }, + ["2"] = { + title = "+@label", + prefix = "+@label", + }, + ["3"] = { + title = "+@constant", + prefix = "+@constant", + }, + ["4"] = { + title = "+@string", + prefix = "+@string", + }, + ["5"] = { + title = "+@label", + prefix = "+@label", + }, + ["6"] = { + title = "+@constructor", + prefix = "+@constructor", + }, + }, + + -- In case of errors in the syntax tree, use the following highlight. + error = "+Error", + + -- hl for definitions (`$ Definition`). + definitions = { + prefix = "+@punctuation.delimiter", + suffix = "+@punctuation.delimiter", + title = "+@markup.strong", + content = "+@markup.italic", + }, + + -- hl for footnotes (`^ My Footnote`). + footnotes = { + prefix = "+@punctuation.delimiter", + suffix = "+@punctuation.delimiter", + title = "+@markup.strong", + content = "+@markup.italic", + }, + + -- hl for TODO items. + -- + -- This strictly covers the `( )` component of any detached modifier. In other words, these + -- hl only bother with highlighting the brackets and the content within, but not the + -- object containing the TODO item itself. + todo_items = { + undone = "+@punctuation.delimiter", + pending = "+@module", + done = "+@string", + on_hold = "+@comment.note", + cancelled = "+NonText", + urgent = "+@comment.error", + uncertain = "+@boolean", + recurring = "+@keyword.repeat", + }, + + -- hl for all the possible levels of ordered and unordered lists. + lists = { + unordered = { prefix = "+@markup.list" }, + + ordered = { prefix = "+@keyword.repeat" }, + }, + + -- hl for all the possible levels of quotes. + quotes = { + ["1"] = { + prefix = "+@punctuation.delimiter", + content = "+@punctuation.delimiter", + }, + ["2"] = { + prefix = "+Blue", + content = "+Blue", + }, + ["3"] = { + prefix = "+Yellow", + content = "+Yellow", + }, + ["4"] = { + prefix = "+Red", + content = "+Red", + }, + ["5"] = { + prefix = "+Green", + content = "+Green", + }, + ["6"] = { + prefix = "+Brown", + content = "+Brown", + }, + }, + + -- hl for the anchor syntax: `[name]{location}`. + anchors = { + declaration = { + [""] = "+@markup.link.label", + delimiter = "+NonText", + }, + definition = { + delimiter = "+NonText", + }, + }, + + link = { + description = { + [""] = "+@markup.link.url", + delimiter = "+NonText", + }, + + file = { + [""] = "+@comment", + delimiter = "+NonText", + }, + + location = { + delimiter = "+NonText", + + url = "+@markup.link.url", + + generic = { + [""] = "+@type", + prefix = "+@type", + }, + + external_file = { + [""] = "+@label", + prefix = "+@label", + }, + + marker = { + [""] = "+@dorm.markers.title", + prefix = "+@dorm.markers.prefix", + }, + + definition = { + [""] = "+@dorm.definitions.title", + prefix = "+@dorm.definitions.prefix", + }, + + footnote = { + [""] = "+@dorm.footnotes.title", + prefix = "+@dorm.footnotes.prefix", + }, + + heading = { + ["1"] = { + [""] = "+@dorm.headings.1.title", + prefix = "+@dorm.headings.1.prefix", + }, + + ["2"] = { + [""] = "+@dorm.headings.2.title", + prefix = "+@dorm.headings.2.prefix", + }, + + ["3"] = { + [""] = "+@dorm.headings.3.title", + prefix = "+@dorm.headings.3.prefix", + }, + + ["4"] = { + [""] = "+@dorm.headings.4.title", + prefix = "+@dorm.headings.4.prefix", + }, + + ["5"] = { + [""] = "+@dorm.headings.5.title", + prefix = "+@dorm.headings.5.prefix", + }, + + ["6"] = { + [""] = "+@dorm.headings.6.title", + prefix = "+@dorm.headings.6.prefix", + }, + }, + }, + }, + + -- hl for inline markup. + -- + -- This is all the hl like `bold`, `italic` and so on. + markup = { + bold = { + [""] = "+@markup.strong", + delimiter = "+NonText", + }, + italic = { + [""] = "+@markup.italic", + delimiter = "+NonText", + }, + underline = { + [""] = "+@markup.underline", + delimiter = "+NonText", + }, + strikethrough = { + [""] = "+@markup.strikethrough", + delimiter = "+NonText", + }, + spoiler = { + [""] = "+@comment.error", + delimiter = "+NonText", + }, + subscript = { + [""] = "+@label", + delimiter = "+NonText", + }, + superscript = { + [""] = "+@number", + delimiter = "+NonText", + }, + variable = { + [""] = "+@function.macro", + delimiter = "+NonText", + }, + verbatim = { + delimiter = "+NonText", + }, + inline_comment = { + delimiter = "+NonText", + }, + inline_math = { + [""] = "+@markup.math", + delimiter = "+NonText", + }, + + free_form_delimiter = "+NonText", + }, + + -- hl for all the delimiter types. These include: + -- - `---` - the weak delimiter + -- - `===` - the strong delimiter + -- - `___` - the horizontal rule + delimiters = { + strong = "+@punctuation.delimiter", + weak = "+@punctuation.delimiter", + horizontal_line = "+@punctuation.delimiter", + }, + + -- Inline modifiers. + -- + -- This includes: + -- - `~` - the trailing modifier + -- - All link characters (`{`, `}`, `[`, `]`, `<`, `>`) + -- - The escape character (`\`) + modifiers = { + link = "+NonText", + escape = "+@type", + }, + + -- Rendered Latex, this will dictate the foreground color of latex images rendered via + -- base.latex.renderer + rendered = { + latex = "+Normal", + }, + }, + + -- Handles the dimming of certain highlight groups. + -- + -- It sometimes is favourable to use an existing highlight group, + -- but to dim or brighten it a little bit. + -- + -- To do so, you may use this table, which, similarly to the `hl` table, + -- will concatenate nested trees to form a highlight group name. + -- + -- The difference is, however, that the leaves of the tree are a table, not a single string. + -- This table has three possible fields: + -- - `reference` - which highlight to use as reference for the dimming. + -- - `percentage` - by how much to darken the reference highlight. This value may be between + -- `-100` and `100`, where negative percentages brighten the reference highlight, whereas + -- positive values dim the highlight by the given percentage. + dim = { + tags = { + ranged_verbatim = { + code_block = { + reference = "Normal", + percentage = 15, + affect = "background", + }, + }, + }, + + markup = { + verbatim = { + reference = "Normal", + percentage = 20, + }, + + inline_comment = { + reference = "Normal", + percentage = 40, + }, + }, + }, +} + +module.setup = function() + return { success = true, requires = { "autocmd" } } +end + +module.load = function() + module.required["autocmd"].enable_autocommand("BufEnter") + module.required["autocmd"].enable_autocommand("FileType") + module.required["autocmd"].enable_autocommand("ColorScheme", true) + + module.public.trigger_hl() + + vim.api.nvim_create_autocmd({ "FileType", "ColorScheme" }, { + callback = module.public.trigger_hl, + }) +end + +---@class base.hl +module.public = { + + --- Reads the hl configuration table and applies all defined hl + trigger_hl = function() + -- NOTE(vhyrro): This code was added here to work around oddities related to nvim-treesitter. + -- This code, with modern nvim-treesitter versions, will probably not break as harshly. + -- This code should be removed as soon as possible. + -- + -- do + -- local query = require("nvim-treesitter.query") + + -- if not query.has_hl("dorm") then + -- query.invalidate_query_cache() + + -- if not query.has_hl("dorm") then + -- log.error( + -- "nvim-treesitter has no available hl for dorm! Ensure treesitter is properly loaded in your config." + -- ) + -- end + -- end + + -- if vim.bo.filetype == "dorm" then + -- require("nvim-treesitter.highlight").attach(vim.api.nvim_get_current_buf(), "dorm") + -- end + -- end + + --- Recursively descends down the highlight configuration and applies every highlight accordingly + ---@param hl table #The table of hl to descend down + ---@param callback fun(hl_name: string, highlight: table, prefix: string): boolean? #A callback function to be invoked for every highlight. If it returns true then we should recurse down the table tree further ---@diagnostic disable-line -- TODO: type error workaround + ---@param prefix string #Should be only used by the function itself, acts as a "savestate" so the function can keep track of what path it has descended down + local function descend(hl, callback, prefix) + -- Loop through every highlight defined in the provided table + for hl_name, highlight in pairs(hl) do + -- If the callback returns true then descend further down the table tree + if callback(hl_name, highlight, prefix) then + descend(highlight, callback, prefix .. "." .. hl_name) + end + end + end + + -- Begin the descent down the public hl configuration table + descend(module.config.public.hl, function(hl_name, highlight, prefix) + -- If the type of highlight we have encountered is a table + -- then recursively descend down it as well + if type(highlight) == "table" then + return true + end + + -- Trim any potential leading and trailing whitespace + highlight = vim.trim(highlight) + + -- Check whether we are trying to link to an existing hl group + -- by checking for the existence of the + sign at the front + local is_link = highlight:sub(1, 1) == "+" + + local full_highlight_name = "@dorm" .. prefix .. (hl_name:len() > 0 and ("." .. hl_name) or "") + local does_hl_exist = lib.inline_pcall(vim.api.nvim_exec, "highlight " .. full_highlight_name, true) ---@diagnostic disable-line -- TODO: type error workaround + + -- If we are dealing with a link then link the hl together (excluding the + symbol) + if is_link then + -- If the highlight already exists then assume the user doesn't want it to be + -- overwritten + if does_hl_exist and does_hl_exist:len() > 0 and not does_hl_exist:match("xxx%s+cleared") then + return + end + + vim.api.nvim_set_hl(0, full_highlight_name, { + link = highlight:sub(2), + }) + else -- Otherwise simply apply the highlight options the user provided + -- If the highlight already exists then assume the user doesn't want it to be + -- overwritten + if does_hl_exist and does_hl_exist:len() > 0 then + return + end + + -- We have to use vim.cmd here + vim.cmd({ + cmd = "highlight", + args = { full_highlight_name, highlight }, + bang = true, + }) + end + end, "") + + -- Begin the descent down the dimming configuration table + descend(module.config.public.dim, function(hl_name, highlight, prefix) + -- If we don't have a percentage value then keep traversing down the table tree + if not highlight.percentage then + return true + end + + local full_highlight_name = "@dorm" .. prefix .. (hl_name:len() > 0 and ("." .. hl_name) or "") + local does_hl_exist = lib.inline_pcall(vim.api.nvim_exec, "highlight " .. full_highlight_name, true) ---@diagnostic disable-line -- TODO: type error workaround + + -- If the highlight already exists then assume the user doesn't want it to be + -- overwritten + if does_hl_exist and does_hl_exist:len() > 0 and not does_hl_exist:match("xxx%s+cleared") then + return + end + + -- Apply the dimmed highlight + vim.api.nvim_set_hl(0, full_highlight_name, { + [highlight.affect == "background" and "bg" or "fg"] = module.public.dim_color( + module.public.get_attribute( + highlight.reference or full_highlight_name, + highlight.affect or "foreground" + ), + highlight.percentage + ), + }) + end, "") + end, + + --- Takes in a table of hl and applies them to the current buffer + ---@param hl table #A table of hl + add_hl = function(hl) + module.config.public.hl = + vim.tbl_deep_extend("force", module.config.public.hl, hl or {}) + module.public.trigger_hl() + end, + + --- Takes in a table of items to dim and applies the dimming to them + ---@param dim table #A table of items to dim + add_dim = function(dim) + module.config.public.dim = vim.tbl_deep_extend("force", module.config.public.dim, dim or {}) + module.public.trigger_hl() + end, + + --- Assigns all dorm* hl to `clear` + clear_hl = function() + --- Recursively descends down the highlight configuration and clears every highlight accordingly + ---@param hl table #The table of hl to descend down + ---@param prefix string #Should be only used by the function itself, acts as a "savestate" so the function can keep track of what path it has descended down + local function descend(hl, prefix) + -- Loop through every defined highlight + for hl_name, highlight in pairs(hl) do + -- If it is a table then recursively traverse down it! + if type(highlight) == "table" then + descend(highlight, hl_name) + else -- Otherwise we're dealing with a string + -- Hence we should clear the highlight + vim.cmd("highlight! clear dorm" .. prefix .. hl_name) + end + end + end + + -- Begin the descent + descend(module.config.public.hl, "") + end, + + -- NOTE: Shamelessly taken and tweaked a little from akinsho's nvim-bufferline: + -- https://github.com/akinsho/nvim-bufferline.lua/blob/fec44821eededceadb9cc25bc610e5114510a364/lua/bufferline/colors.lua + -- <3 + get_attribute = function(name, attribute) + -- Attempt to get the highlight + local success, hl = pcall(vim.api.nvim_get_hl_by_name, name, true) ---@diagnostic disable-line -- TODO: type error workaround + + -- If we were successful and if the attribute exists then return it + if success and hl[attribute] then + return bit.tohex(hl[attribute], 6) + else -- Else log the message in a regular info() call, it's not an insanely important error + log.info("Unable to grab highlight for attribute", attribute, " - full error:", hl) + end + + return "NONE" + end, + + hex_to_rgb = function(hex_colour) + return tonumber(hex_colour:sub(1, 2), 16), tonumber(hex_colour:sub(3, 4), 16), tonumber(hex_colour:sub(5), 16) + end, + + dim_color = function(colour, percent) + if colour == "NONE" then + return colour + end + + local function alter(attr) + return math.floor(attr * (100 - percent) / 100) + end + + local r, g, b = module.public.hex_to_rgb(colour) + + if not r or not g or not b then + return "NONE" + end + + return string.format("#%02x%02x%02x", math.min(alter(r), 255), math.min(alter(g), 255), math.min(alter(b), 255)) + end, + + -- END of shamelessly ripped off akinsho code +} + +module.events.subscribed = { + ["autocmd"] = { + colorscheme = true, + bufenter = true, + }, +} + +return module diff --git a/lua/dorm/mod/init.lua b/lua/dorm/mod/init.lua new file mode 100644 index 0000000..4c612f9 --- /dev/null +++ b/lua/dorm/mod/init.lua @@ -0,0 +1,841 @@ +--- @brief [[ +--- Base file for mod. +--- This file contains the base implementation for "mod", building blocks of the dorm environment. +--- @brief ]] + +-- TODO: What goes below this line until the next notice used to belong to mod.base +-- We need to find a way to make these constructors easier to maintain and more efficient + +local callbacks = require("dorm.util.callback") +local config = require("dorm.config") +local log = require("dorm.util.log") +local utils = require("dorm.util") + +--- @alias dorm.module.public { version: string, [any]: any } + +--- @class (exact) dorm.module.resolver +--- @field ["autocmd"] autocmd +--- @field ["completion"] completion +--- @field ["conceal"] conceal +--- @field ["workspace"] workspace +--- @field ["hl"] hl +--- @field ["notes"] notes +--- @field ["keys"] keys +--- @field ["link"] link +--- @field ["cmd"] cmd +--- @field ["code"] code +--- @field ["todo"] todo +--- @field ["ui"] ui +--- @field ["calendar.views.monthly"] calendar.views.monthly +--- @field ["ui.selection_popup"] ui.selection_popup +--- @field ["ui.text_popup"] ui.text_popup + +--- Defines both a public and private configuration for a dorm module. +--- Public configurations may be tweaked by the user from the `dorm.setup()` function, +--- whereas private configurations are for internal use only. +--- @class (exact) dorm.module.configuration +--- @field custom? table Internal table that tracks the differences (changes) between the base `public` table and the new (altered) `public` table. It contains only the tables that the user has altered in their own configuration. +--- @field public private? table Internal configuration variables that may be tweaked by the developer. +--- @field public public? table Configuration variables that may be tweaked by the user. + +--- @class (exact) dorm.module.events +--- @field defined? { [string]: dorm.event } Lists all events defined by this module. +--- @field subscribed? { [string]: { [string]: boolean } } Lists the events that the module is subscribed to. + +--- @alias dorm.module.setup { success: boolean, requires?: string[], replaces?: string, replace_merge?: boolean, wants?: string[] } + +--- Defines a module. +--- A module is an object that contains a set of hooks which are invoked by dorm whenever something in the +--- environment occurs. This can be an event, a simple act of the module being loaded or anything else. +--- @class (exact) dorm.module +--- @field config? dorm.module.configuration The configuration for the module. +--- @field events? dorm.module.events Describes all information related to events for this module. +--- @field examples? table Contains examples of how to use the mod that users or developers may sift through. +--- @field imported? table Imported submod of the given module. Contrary to `required`, which only exposes the public API of a module, imported mod can be accessed in their entirety. +--- @field load? fun() Function that is invoked once the module is considered "stable", i.e. after all dependencies are loaded. Perform your main loading routine here. +--- @field name string The name of the module. +--- @field dorm_post_load? fun() Function that is invoked after all mod are loaded. Useful if you want the dorm environment to be fully set up before performing some task. +--- @field path string The full path to the module (a more verbose version of `name`). May be used in lua's `require()` statements. +--- @field public private? table A convenience table to place all of your private variables that you don't want to expose. +--- @field public public? dorm.module.public Every module can expose any set of information it sees fit through this field. All functions and variables declared in this table will be visiable to any other module loaded. +--- @field required? dorm.module.resolver Contains the public tables of all mod that were required via the `requires` array provided in the `setup()` function of this module. +--- @field setup? fun(): dorm.module.setup? Function that is invoked before any other loading occurs. Should perform preliminary startup tasks. +--- @field replaced? boolean If `true`, this means the module is a replacement for a base module. This flag is set automatically whenever `setup().replaces` is set to a value. +--- @field on_event fun(event: dorm.event) A callback that is invoked any time an event the module has subscribed to has fired. + +local mod = {} + +--- Returns a new dorm module, exposing all the necessary function and variables. +--- @param name string The name of the new module. Make sure this is unique. The recommended naming convention is `category.module_name` or `category.subcategory.module_name`. +--- @param imports? string[] A list of imports to attach to the module. Import data is requestable via `module.required`. Use paths relative to the current module. +--- @return dorm.module +function mod.create(name, imports) + ---@type dorm.module + local new_module = { + setup = function() + return { success = true, requires = {}, replaces = nil, replace_merge = false } + end, + + load = function() end, + + on_event = function() end, + + dorm_post_load = function() end, + + name = "base", + + path = "mod.base.module", + + private = {}, + + public = { + version = config.dorm_version, + }, + + config = { + private = { + --[[ + config_option = false, + + ["option_group"] = { + sub_option = true + } + --]] + }, + + public = { + --[[ + config_option = false, + + ["option_group"] = { + sub_option = true + } + --]] + }, + + custom = {}, + }, + + events = { + subscribed = { -- The events that the module is subscribed to + --[[ + ["test"] = { -- The name of the module that has events bound to it + ["test_event"] = true, -- Subscribes to event test.events.test_event + + ["other_event"] = true -- Subscribes to event test.events.other_event + } + --]] + }, + defined = { -- The events that the module itself has defined + --[[ + ["my_event"] = { event_data } -- Creates an event of type category.module.events.my_event + --]] + }, + }, + + required = { + --[[ + ["test"] = { + -- Their public API here... + }, + + ["some_other_plugin"] = { + -- Their public API here... + } + + --]] + }, + + examples = { + --[[ + a_cool_test = function() + print("Some code!") + end + --]] + }, + + imported = { + --[[ + ["my.module.submodule"] = { ... }, + --]] + }, + + tests = function() end, + } + + if imports then + for _, import in ipairs(imports) do + local fullpath = table.concat({ name, import }, ".") + + if not mod.load_module(fullpath) then + log.error("Unable to load import '" .. fullpath .. "'! An error occured (see traceback below):") + assert(false) -- Halt execution, no recovering from this error... + end + + new_module.imported[fullpath] = mod.loaded_mod[fullpath] + end + end + + if name then + new_module.name = name + new_module.path = "mod." .. name + end + + return new_module +end + +--- Constructs a metamodule from a list of submod. Metamod are mod that can autoload batches of mod at once. +--- @param name string The name of the new metamodule. Make sure this is unique. The recommended naming convention is `category.module_name` or `category.subcategory.module_name`. +--- @param ... string A list of module names to load. +--- @return dorm.module +function mod.create_meta(name, ...) + local module = mod.create(name) + + module.config.public.enable = { ... } + + module.setup = function() + return { success = true } + end + + module.load = function() + module.config.public.enable = (function() + -- If we haven't define any mod to disable then just return all enabled mod + if not module.config.public.disable then + return module.config.public.enable + end + + local ret = {} + + -- For every enabled module + for _, modname in ipairs(module.config.public.enable) do + -- If that module does not exist in the disable table (ie. it is enabled) then add it to the `ret` table + if not vim.tbl_contains(module.config.public.disable, modname) then + table.insert(ret, modname) + end + end + + -- Return the table containing all the mod we would like to enable + return ret + end)() + + -- Go through every module that we have defined in the metamodule and load it! + for _, modname in ipairs(module.config.public.enable) do + mod.load_module(modname) + end + end + + return module +end + +-- TODO: What goes below this line until the next notice used to belong to mod +-- We need to find a way to make these functions easier to maintain + +--- Tracks the amount of currently loaded mod. +mod.loaded_module_count = 0 + +--- The table of currently loaded mod +--- @type { [string]: dorm.module } +mod.loaded_mod = {} + +--- Loads and enables a module +--- Loads a specified module. If the module subscribes to any events then they will be activated too. +--- @param module dorm.module The actual module to load. +--- @return boolean # Whether the module successfully loaded. +function mod.load_module_from_table(module) + log.info("Loading module with name", module.name) + + -- If our module is already loaded don't try loading it again + if mod.loaded_mod[module.name] then + log.trace("Module", module.name, "already loaded. Omitting...") + return true + end + + -- Invoke the setup function. This function returns whether or not the loading of the module was successful and some metadata. + ---@type dorm.module.setup + local loaded_module = module.setup and module.setup() + or { + success = true, + replaces = {}, + replace_merge = false, + requires = {}, + wants = {}, + } + + -- We do not expect module.setup() to ever return nil, that's why this check is in place + if not loaded_module then + log.error( + "Module", + module.name, + "does not handle module loading correctly; module.setup() returned nil. Omitting..." + ) + return false + end + + -- A part of the table returned by module.setup() tells us whether or not the module initialization was successful + if loaded_module.success == false then + log.trace("Module", module.name, "did not load properly.") + return false + end + + --[[ + -- This small snippet of code creates a copy of an already loaded module with the same name. + -- If the module wants to replace an already loaded module then we need to create a deepcopy of that old module + -- in order to stop it from getting overwritten. + --]] + ---@type dorm.module + local module_to_replace + + -- If the return value of module.setup() tells us to hotswap with another module then cache the module we want to replace with + if loaded_module.replaces and loaded_module.replaces ~= "" then + module_to_replace = vim.deepcopy(mod.loaded_mod[loaded_module.replaces]) + end + + -- Add the module into the list of loaded mod + -- The reason we do this here is so other mod don't recursively require each other in the dependency loading loop below + mod.loaded_mod[module.name] = module + + -- If the module "wants" any other mod then verify they are loaded + if loaded_module.wants and not vim.tbl_isempty(loaded_module.wants) then + log.info("Module", module.name, "wants certain mod. Ensuring they are loaded...") + + -- Loop through each dependency and ensure it's loaded + for _, required_module in ipairs(loaded_module.wants) do + log.trace("Verifying", required_module) + + -- This would've always returned false had we not added the current module to the loaded module list earlier above + if not mod.is_module_loaded(required_module) then + if config.user_config.load[required_module] then + log.trace( + "Wanted module", + required_module, + "isn't loaded but can be as it's defined in the user's config. Loading..." + ) + + if not mod.load_module(required_module) then + log.error( + "Unable to load wanted module for", + module.name, + "- the module didn't load successfully" + ) + + -- Make sure to clean up after ourselves if the module failed to load + mod.loaded_mod[module.name] = nil + return false + end + else + log.error( + ("Unable to load module %s, wanted dependency %s was not satisfied. Be sure to load the module and its appropriate config too!") + :format( + module.name, + required_module + ) + ) + + -- Make sure to clean up after ourselves if the module failed to load + mod.loaded_mod[module.name] = nil + return false + end + end + + -- Create a reference to the dependency's public table + module.required[required_module] = mod.loaded_mod[required_module].public + end + end + + -- If any dependencies have been defined, handle them + if loaded_module.requires and vim.tbl_count(loaded_module.requires) > 0 then + log.info("Module", module.name, "has dependencies. Loading dependencies first...") + + -- Loop through each dependency and load it one by one + for _, required_module in pairs(loaded_module.requires) do + log.trace("Loading submodule", required_module) + + -- This would've always returned false had we not added the current module to the loaded module list earlier above + if not mod.is_module_loaded(required_module) then + if not mod.load_module(required_module) then + log.error( + ("Unable to load module %s, required dependency %s did not load successfully"):format( + module.name, + required_module + ) + ) + + -- Make sure to clean up after ourselves if the module failed to load + mod.loaded_mod[module.name] = nil + return false + end + else + log.trace("Module", required_module, "already loaded, skipping...") + end + + -- Create a reference to the dependency's public table + module.required[required_module] = mod.loaded_mod[required_module].public + end + end + + -- After loading all our dependencies, see if we need to hotswap another module with ourselves + if module_to_replace then + -- Make sure the names of both mod match + module.name = module_to_replace.name + + -- Whenever a module gets hotswapped, a special flag is set inside the module in order to signalize that it has been hotswapped before + -- If this flag has already been set before, then throw an error - there is no way for us to know which hotswapped module should take priority. + if module_to_replace.replaced then + log.error( + ("Unable to replace module %s - module replacement clashing detected. This error triggers when a module tries to be replaced more than two times - dorm doesn't know which replacement to prioritize.") + :format( + module_to_replace.name + ) + ) + + -- Make sure to clean up after ourselves if the module failed to load + mod.loaded_mod[module.name] = nil + + return false + end + + -- If the replace_merge flag is set to true in the setup() return value then recursively merge the data from the + -- previous module into our new one. This allows for practically seamless hotswapping, as it allows you to retain the data + -- of the previous module. + if loaded_module.replace_merge then + module = vim.tbl_deep_extend("force", module, { + private = module_to_replace.private, + config = module_to_replace.config, + public = module_to_replace.public, + events = module_to_replace.events, + }) + end + + -- Set the special module.replaced flag to let everyone know we've been hotswapped before + module.replaced = true + end + + log.info("Successfully loaded module", module.name) + + -- Keep track of the number of loaded mod + mod.loaded_module_count = mod.loaded_module_count + 1 + + -- NOTE(vhyrro): Left here for debugging. + -- Maybe make controllable with a switch in the future. + -- local start = vim.loop.hrtime() + + -- Call the load function + if module.load then + module.load() + end + + -- local msg = ("%fms"):format((vim.loop.hrtime() - start) / 1e6) + -- vim.notify(msg .. " " .. module.name) + + mod.broadcast_event({ + type = "module_loaded", + split_type = { "base", "module_loaded" }, + filename = "", + filehead = "", + cursor_position = { 0, 0 }, + referrer = "base", + line_content = "", + content = module, + broadcast = true, + buffer = vim.api.nvim_get_current_buf(), + window = vim.api.nvim_get_current_win(), + mode = vim.fn.mode(), + }) + + return true +end + +--- Unlike `load_module_from_table()`, which loads a module from memory, `load_module()` tries to find the corresponding module file on disk and loads it into memory. +--- If the module cannot not be found, attempt to load it off of github (unimplemented). This function also applies user-defined config and keys to the mod themselves. +--- This is the recommended way of loading mod - `load_module_from_table()` should only really be used by dorm itself. +--- @param module_name string A path to a module on disk. A path seperator in dorm is '.', not '/'. +--- @param cfg table? A config that reflects the structure of `dorm.config.user_config.load["module.name"].config`. +--- @return boolean # Whether the module was successfully loaded. +function mod.load_module(module_name, cfg) + -- Don't bother loading the module from disk if it's already loaded + if mod.is_module_loaded(module_name) then + return true + end + + -- Attempt to require the module, does not throw an error if the module doesn't exist + local module = require("dorm.mod." .. module_name .. ".module") + + -- If the module is nil for some reason return false + if not module then + log.error( + "Unable to load module", + module_name, + "- loaded file returned nil. Be sure to return the table created by mod.create() at the end of your module.lua file!" + ) + return false + end + + -- If the value of `module` is strictly true then it means the required file returned nothing + -- We obviously can't do anything meaningful with that! + if module == true then + log.error( + "An error has occurred when loading", + module_name, + "- loaded file didn't return anything meaningful. Be sure to return the table created by mod.create() at the end of your module.lua file!" + ) + return false + end + + -- Load the user-defined config + if cfg and not vim.tbl_isempty(cfg) then + module.config.custom = cfg + module.config.public = vim.tbl_deep_extend("force", module.config.public, cfg) + else + module.config.custom = config.mod[module_name] + module.config.public = vim.tbl_deep_extend("force", module.config.public, module.config.custom or {}) + end + + -- Pass execution onto load_module_from_table() and let it handle the rest + return mod.load_module_from_table(module) +end + +--- Has the same principle of operation as load_module_from_table(), except it then sets up the parent module's "required" table, allowing the parent to access the child as if it were a dependency. +--- @param module dorm.module A valid table as returned by mod.create() +--- @param parent_module string|dorm.module If a string, then the parent is searched for in the loaded mod. If a table, then the module is treated as a valid module as returned by mod.create() +function mod.load_module_as_dependency_from_table(module, parent_module) + if mod.load_module_from_table(module) then + if type(parent_module) == "string" then + mod.loaded_mod[parent_module].required[module.name] = module.public + elseif type(parent_module) == "table" then + parent_module.required[module.name] = module.public + end + end +end + +--- Normally loads a module, but then sets up the parent module's "required" table, allowing the parent module to access the child as if it were a dependency. +--- @param module_name string A path to a module on disk. A path seperator in dorm is '.', not '/' +--- @param parent_module string The name of the parent module. This is the module which the dependency will be attached to. +--- @param cfg? table A config that reflects the structure of dorm.config.user_config.load["module.name"].config +function mod.load_module_as_dependency(module_name, parent_module, cfg) + if mod.load_module(module_name, cfg) and mod.is_module_loaded(parent_module) then + mod.loaded_mod[parent_module].required[module_name] = mod.get_module_config(module_name) + end +end + +--- Retrieves the public API exposed by the module. +--- @generic T +--- @param module_name `T` The name of the module to retrieve. +--- @return T? +function mod.get_module(module_name) + if not mod.is_module_loaded(module_name) then + log.trace("Attempt to get module with name", module_name, "failed - module is not loaded.") + return + end + + return mod.loaded_mod[module_name].public +end + +--- Returns the module.config.public table if the module is loaded +--- @param module_name string The name of the module to retrieve (module must be loaded) +--- @return table? +function mod.get_module_config(module_name) + if not mod.is_module_loaded(module_name) then + log.trace("Attempt to get module config with name", module_name, "failed - module is not loaded.") + return + end + + return mod.loaded_mod[module_name].config.public +end + +--- Returns true if module with name module_name is loaded, false otherwise +--- @param module_name string The name of an arbitrary module +--- @return boolean +function mod.is_module_loaded(module_name) + return mod.loaded_mod[module_name] ~= nil +end + +--- Reads the module's public table and looks for a version variable, then converts it from a string into a table, like so: `{ major = , minor = , patch = }`. +--- @param module_name string The name of a valid, loaded module. +--- @return table? parsed_version +function mod.get_module_version(module_name) + -- If the module isn't loaded then don't bother retrieving its version + if not mod.is_module_loaded(module_name) then + log.trace("Attempt to get module version with name", module_name, "failed - module is not loaded.") + return + end + + -- Grab the version of the module + local version = mod.get_module(module_name).version + + -- If it can't be found then error out + if not version then + log.trace("Attempt to get module version with name", module_name, "failed - version variable not present.") + return + end + + return utils.parse_version_string(version) +end + +--- Executes `callback` once `module` is a valid and loaded module, else the callback gets instantly executed. +--- @param module_name string The name of the module to listen for. +--- @param callback fun(module_public_table: dorm.module.public) The callback to execute. +function mod.await(module_name, callback) + if mod.is_module_loaded(module_name) then + callback(assert(mod.get_module(module_name))) + return + end + + callbacks.on_event("module_loaded", function(_, module) + callback(module.public) + end, function(event) + return event.content.name == module_name + end) +end + +--- @alias Mode +--- | "n" +--- | "no" +--- | "nov" +--- | "noV" +--- | "noCTRL-V" +--- | "CTRL-V" +--- | "niI" +--- | "niR" +--- | "niV" +--- | "nt" +--- | "Terminal" +--- | "ntT" +--- | "v" +--- | "vs" +--- | "V" +--- | "Vs" +--- | "CTRL-V" +--- | "CTRL-Vs" +--- | "s" +--- | "S" +--- | "CTRL-S" +--- | "i" +--- | "ic" +--- | "ix" +--- | "R" +--- | "Rc" +--- | "Rx" +--- | "Rv" +--- | "Rvc" +--- | "Rvx" +--- | "c" +--- | "cr" +--- | "cv" +--- | "cvr" +--- | "r" +--- | "rm" +--- | "r?" +--- | "!" +--- | "t" + +--- @class (exact) dorm.event +--- @field type string The type of the event. Exists in the format of `category.name`. +--- @field split_type string[] The event type, just split on every `.` character, e.g. `{ "category", "name" }`. +--- @field content? table|any The content of the event. The data found here is specific to each individual event. Can be thought of as the payload. +--- @field referrer string The name of the module that triggered the event. +--- @field broadcast boolean Whether the event was broadcast to all mod. `true` is so, `false` if the event was specifically sent to a single recipient. +--- @field cursor_position { [1]: number, [2]: number } The position of the cursor at the moment of broadcasting the event. +--- @field filename string The name of the file that the user was in at the moment of broadcasting the event. +--- @field filehead string The directory the user was in at the moment of broadcasting the event. +--- @field line_content string The content of the line the user was editing at the moment of broadcasting the event. +--- @field buffer number The buffer ID of the buffer the user was in at the moment of broadcasting the event. +--- @field window number The window ID of the window the user was in at the moment of broadcasting the event. +--- @field mode Mode The mode Neovim was in at the moment of broadcasting the event. + +-- TODO: What goes below this line until the next notice used to belong to mod +-- We need to find a way to make these functions easier to maintain + +--[[ +-- dorm EVENT FILE +-- This file is responsible for dealing with event handling and broadcasting. +-- All mod that subscribe to an event will receive it once it is triggered. +--]] + +--- The working of this function is best illustrated with an example: +-- If type == 'some_plugin.events.my_event', this function will return { 'some_plugin', 'my_event' } +--- @param type string The full path of a module event +--- @return string[]? +function mod.split_event_type(type) + local start_str, end_str = type:find("%.events%.") + + local split_event_type = { type:sub(0, start_str - 1), type:sub(end_str + 1) } + + if #split_event_type ~= 2 then + log.warn("Invalid type name:", type) + return + end + + return split_event_type +end + +--- Returns an event template defined in `module.events.defined`. +--- @param module dorm.module A reference to the module invoking the function +--- @param type string A full path to a valid event type (e.g. `module.events.some_event`) +--- @return dorm.event? +function mod.get_event_template(module, type) + -- You can't get the event template of a type if the type isn't loaded + if not mod.is_module_loaded(module.name) then + log.info("Unable to get event of type", type, "with module", module.name) + return + end + + -- Split the event type into two + local split_type = mod.split_event_type(type) + + if not split_type then + log.warn("Unable to get event template for event", type, "and module", module.name) + return + end + + log.trace("Returning", split_type[2], "for module", split_type[1]) + + -- Return the defined event from the specific module + return mod.loaded_mod[module.name].events.defined[split_type[2]] +end + +--- Creates a deep copy of the `mod.base_event` event and returns it with a custom type and referrer. +--- @param module dorm.module A reference to the module invoking the function. +--- @param name string A relative path to a valid event template. +--- @return dorm.event +function mod.define_event(module, name) + -- Create a copy of the base event and override the values with ones specified by the user + + local new_event = { + type = "base_event", + split_type = {}, + content = nil, + referrer = nil, + broadcast = true, + + cursor_position = {}, + filename = "", + filehead = "", + line_content = "", + buffer = 0, + window = 0, + mode = "", + } + + if name then + new_event.type = module.name .. ".events." .. name + end + + new_event.referrer = module.name + + return new_event +end + +--- Returns a copy of the event template provided by a module. +--- @param module dorm.module A reference to the module invoking the function +--- @param type string A full path to a valid event type (e.g. `module.events.some_event`) +--- @param content table|any? The content of the event, can be anything from a string to a table to whatever you please. +--- @param ev? table The original event data. +--- @return dorm.event? # New event. +function mod.create_event(module, type, content, ev) + -- Get the module that contains the event + local module_name = mod.split_event_type(type)[1] + + -- Retrieve the template from module.events.defined + local event_template = mod.get_event_template(mod.loaded_mod[module_name] or { name = "" }, type) + + if not event_template then + log.warn("Unable to create event of type", type, ". Returning nil...") + return + end + + -- Make a deep copy here - we don't want to override the actual base table! + local new_event = vim.deepcopy(event_template) + + new_event.type = type + new_event.content = content + new_event.referrer = module.name + + -- Override all the important values + new_event.split_type = assert(mod.split_event_type(type)) + new_event.filename = vim.fn.expand("%:t") --[[@as string]] + new_event.filehead = vim.fn.expand("%:p:h") --[[@as string]] + + local bufid = ev and ev.buf or vim.api.nvim_get_current_buf() + local winid = assert(vim.fn.bufwinid(bufid)) + + if winid == -1 then + winid = vim.api.nvim_get_current_win() + end + + new_event.cursor_position = vim.api.nvim_win_get_cursor(winid) + + local row_1b = new_event.cursor_position[1] + new_event.line_content = vim.api.nvim_buf_get_lines(bufid, row_1b - 1, row_1b, true)[1] + new_event.referrer = module.name + new_event.broadcast = true + new_event.buffer = bufid + new_event.window = winid + new_event.mode = vim.api.nvim_get_mode().mode + + return new_event +end + +--- Sends an event to all subscribed mod. The event contains the filename, filehead, cursor position and line content as a bonus. +--- @param event dorm.event An event, usually created by `mod.create_event()`. +--- @param callback function? A callback to be invoked after all events have been asynchronously broadcast +function mod.broadcast_event(event, callback) + -- Broadcast the event to all mod + if not event.split_type then + log.error("Unable to broadcast event of type", event.type, "- invalid event name") + return + end + + -- Let the callback handler know of the event + callbacks.handle_callbacks(event) + + -- Loop through all the mod + for _, current_module in pairs(mod.loaded_mod) do + -- If the current module has any subscribed events and if it has a subscription bound to the event's module name then + if current_module.events.subscribed and current_module.events.subscribed[event.split_type[1]] then + -- Check whether we are subscribed to the event type + local evt = current_module.events.subscribed[event.split_type[1]][event.split_type[2]] + + if evt ~= nil and evt == true then + -- Run the on_event() for that module + current_module.on_event(event) + end + end + end + + -- Because the broadcasting of events is async we allow the event broadcaster to provide a callback + -- TODO: deprecate + if callback then + callback() + end +end + +--- Instead of broadcasting to all loaded mod, `send_event()` only sends to one module. +--- @param recipient string The name of a loaded module that will be the recipient of the event. +--- @param event dorm.event An event, usually created by `mod.create_event()`. +function mod.send_event(recipient, event) + -- If the recipient is not loaded then there's no reason to send an event to it + if not mod.is_module_loaded(recipient) then + log.warn("Unable to send event to module", recipient, "- the module is not loaded.") + return + end + + -- Set the broadcast variable to false since we're not invoking broadcast_event() + event.broadcast = false + + -- Let the callback handler know of the event + callbacks.handle_callbacks(event) + + -- Get the recipient module and check whether it's subscribed to our event + local mod = mod.loaded_mod[recipient] + + if mod.events.subscribed and mod.events.subscribed[event.split_type[1]] then + local evt = mod.events.subscribed[event.split_type[1]][event.split_type[2]] + + -- If it is then trigger the module's on_event() function + if evt ~= nil and evt == true then + mod.on_event(event) + end + end +end + +return mod diff --git a/lua/dorm/mod/keys/module.lua b/lua/dorm/mod/keys/module.lua new file mode 100644 index 0000000..fbca9bf --- /dev/null +++ b/lua/dorm/mod/keys/module.lua @@ -0,0 +1,388 @@ +--[[ + file: User-keys + title: The Language of dorm + description: `base.keys` manages mappings for operations on or in `.dorm` files. + summary: Module for managing keybindings with dorm mode support. + --- +The `base.keys` module configures an out-of-the-box Neovim experience by providing a base +set of keys. + +To disable base keys, see the next section. To remap the existing keys, see [here](https://github.com/nvim-dorm/dorm/wiki/User-keys#remapping-keys). + +To find common problems, consult the [FAQ](https://github.com/nvim-dorm/dorm/wiki/User-keys#faq). + +### Disabling base keys + +By base when you load the `base.keys` module all keys will be enabled. If you would like to change this, be sure to set `base_keys` to `false`: +```lua +["keys"] = { + config = { + base_keys = false, + }, +} +``` + +### Remapping Keys + +To understand how to effectively remap keys, one must understand how keys are set. +dorm binds actions to various `` mappings that look like so: `(dorm...`. + +To remap a key, simply map an action somewhere in your configuration: + +```lua +vim.keymap.set("n", "my-key-here", "(dorm.pivot.list.toggle)", {}) +``` + +dorm will recognize that the key has been bound by you and not bind its own key. + +#### Binding Keys for dorm Files Only + +This approach has a downside - all of dorm's keys are set on a per-buffer basis +so that keys don't "overflow" into buffers you don't want them active in. + +When you map a key using `vim.keymap.set`, you set a global key which is always active, even in non-dorm +files. There are two ways to combat this: +- Create a file under `/ftplugin/dorm.lua`: + ```lua + vim.keymap.set("n", "my-key-here", "(dorm.pivot.list.toggle)", { buffer = true }) + ``` +- Create an autocommand using `vim.api.nvim_create_autocmd`: + ```lua + vim.api.nvim_create_autocmd("Filetype", { + pattern = "dorm", + callback = function() + vim.keymap.set("n", "my-key-here", "(dorm.pivot.list.toggle)", { buffer = true }) + end, + }) + ``` + +Notice that in both situations a `{ buffer = true }` was supplied to the function. +This way, your remapped keys will never interfere with other files. + +### Discovering Keys + +A comprehensive list of all keys can be found on [this page!](https://github.com/nvim-dorm/dorm/wiki/base-keys) + +## FAQ + +### Some (or all) keys do not work + +dorm refuses to bind keys when it knows they'll interfere with your configuration. +Run `:checkhealth dorm` to see a full list of what keys dorm has considered "conflicted" +or "rebound". + +If you see that *all* of your keys are in conflict, you're likely using a plugin that is mapping to your +local leader key. This is a known issue with older versions of `which-key.nvim`. Since version `3.0` of which-key the issue has been fixed - we +recommend updating to the latest version to resolve the errors. + +--]] + +local dorm = require("dorm") +local mod = dorm.mod + +local module = mod.create("keys") + +local bound_keys = {} + +module.load = function() + if module.config.public.base_keys then + local preset = module.private.presets[module.config.public.preset] + assert(preset, string.format("keybind preset `%s` does not exist!", module.config.public.preset)) + + module.public.set_keys_for(false, preset.all) + + vim.api.nvim_create_autocmd("FileType", { + pattern = "dorm", + callback = function(ev) + module.public.set_keys_for(ev.buf, preset.dorm) + end, + }) + end +end + +module.config.public = { + -- Whether to enable the base keys. + base_keys = true, + + -- Which keybind preset to use. + -- Currently allows only a single value: `"dorm"`. + preset = "dorm", +} + +---@class base.keys +module.public = { + --- Adds a set of base keys for dorm to bind. + --- Should be used exclusively by external mod wanting to provide their own base keys. + ---@param name string The name of the preset to extend (allows for providing base keys for various presets) + ---@param preset dorm.keys.preset The preset data itself. + extend_preset = function(name, preset) + local original_preset = assert(module.private.presets[name], "provided preset doesn't exist!") + + local function extend(a, b) + for k, v in pairs(b) do + if type(v) == "table" then + if vim.islist(v) then + vim.list_extend(a[k], v) + else + extend(a[k], v) + end + end + + a[k] = v + end + end + + extend(original_preset, preset) + module.public.bind_dorm_keys(vim.api.nvim_get_current_buf()) + end, + + ---@param buffer number|boolean + ---@param preset_subdata table + set_keys_for = function(buffer, preset_subdata) + for mode, keys in pairs(preset_subdata) do + bound_keys[mode] = bound_keys[mode] or {} + + for _, keybind in ipairs(keys) do + if + vim.fn.hasmapto(keybind[2], mode, false) == 0 + and vim.fn.mapcheck(keybind[1], mode, false):len() == 0 + then + local opts = vim.tbl_deep_extend("force", { buffer = buffer }, keybind.opts or {}) + vim.keymap.set(mode, keybind[1], keybind[2], opts) + + bound_keys[mode][keybind[1]] = true + end + end + end + end, + + --- Checks the health of keys. Returns all remaps and all conflicts in a table. + ---@return { preset_exists: boolean, remaps: table, conflicts: table } + health = function() + local preset = module.private.presets[module.config.public.preset] + + if not preset then + return { + preset_exists = false, + } + end + + local remaps = {} + local conflicts = {} + + local function check_keys_for(data) + for mode, keys in pairs(data) do + for _, keybind in ipairs(keys) do + if not bound_keys[mode] or not bound_keys[mode][keybind[1]] then + if vim.fn.hasmapto(keybind[2], mode, false) ~= 0 then + remaps[keybind[1]] = keybind[2] + elseif vim.fn.mapcheck(keybind[1], mode, false):len() ~= 0 then + conflicts[keybind[1]] = keybind[2] + end + end + end + end + end + + check_keys_for(preset.all) + check_keys_for(preset.dorm) + + return { + preset_exists = true, + remaps = remaps, + conflicts = conflicts, + } + end, +} + +module.private = { + + -- TODO: Move these to the "vim" preset + -- { "gd", "(dorm.esupports.hop.hop-link)", opts = { desc = "[dorm] Jump to Link" } }, + -- { "gf", "(dorm.esupports.hop.hop-link)", opts = { desc = "[dorm] Jump to Link" } }, + -- { "gF", "(dorm.esupports.hop.hop-link)", opts = { desc = "[dorm] Jump to Link" } }, + presets = { + ---@class dorm.keys.preset + dorm = { + all = { + n = { + -- Create a new `.dorm` file to take notes in + -- ^New Note + { + "nn", + "(dorm.workspace.new-note)", + opts = { desc = "[dorm] Create New Note" }, + }, + }, + }, + dorm = { + n = { + -- Mark the task under the cursor as "undone" + -- ^mark Task as Undone + { + "tu", + "(dorm.qol.todo-items.todo.task-undone)", + opts = { desc = "[dorm] Mark as Undone" }, + }, + + -- Mark the task under the cursor as "pending" + -- ^mark Task as Pending + { + "tp", + "(dorm.qol.todo-items.todo.task-pending)", + opts = { desc = "[dorm] Mark as Pending" }, + }, + + -- Mark the task under the cursor as "done" + -- ^mark Task as Done + { + "td", + "(dorm.qol.todo-items.todo.task-done)", + opts = { desc = "[dorm] Mark as Done" }, + }, + + -- Mark the task under the cursor as "on-hold" + -- ^mark Task as on Hold + { + "th", + "(dorm.qol.todo-items.todo.task-on-hold)", + opts = { desc = "[dorm] Mark as On Hold" }, + }, + + -- Mark the task under the cursor as "cancelled" + -- ^mark Task as Cancelled + { + "tc", + "(dorm.qol.todo-items.todo.task-cancelled)", + opts = { desc = "[dorm] Mark as Cancelled" }, + }, + + -- Mark the task under the cursor as "recurring" + -- ^mark Task as Recurring + { + "tr", + "(dorm.qol.todo-items.todo.task-recurring)", + opts = { desc = "[dorm] Mark as Recurring" }, + }, + + -- Mark the task under the cursor as "important" + -- ^mark Task as Important + { + "ti", + "(dorm.qol.todo-items.todo.task-important)", + opts = { desc = "[dorm] Mark as Important" }, + }, + + -- Mark the task under the cursor as "ambiguous" + -- ^mark Task as Ambiguous + { + "ta", + "(dorm.qol.todo-items.todo.task-ambiguous)", + opts = { desc = "[dorm] Mark as Ambigous" }, + }, + + -- Switch the task under the cursor between a select few states + { + "", + "(dorm.qol.todo-items.todo.task-cycle)", + opts = { desc = "[dorm] Cycle Task" }, + }, + + -- Hop to the destination of the link under the cursor + { "", "(dorm.esupports.hop.hop-link)", opts = { desc = "[dorm] Jump to Link" } }, + + -- Same as ``, except open the destination in a vertical split + { + "", + "(dorm.esupports.hop.hop-link.vsplit)", + opts = { desc = "[dorm] Jump to Link (Vertical Split)" }, + }, + + -- Promote an object non-recursively + { + ">.", + "(dorm.promo.promote)", + opts = { desc = "[dorm] Promote Object (Non-Recursively)" }, + }, + -- Demote an object non-recursively + { "<,", "(dorm.promo.demote)", opts = { desc = "[dorm] Demote Object (Non-Recursively)" } }, + + -- Promote an object recursively + { + ">>", + "(dorm.promo.promote.nested)", + opts = { desc = "[dorm] Promote Object (Recursively)" }, + }, + -- Demote an object recursively + { + "<<", + "(dorm.promo.demote.nested)", + opts = { desc = "[dorm] Demote Object (Recursively)" }, + }, + + -- Toggle a list from ordered <-> unordered + -- ^List Toggle + { + "lt", + "(dorm.pivot.list.toggle)", + opts = { desc = "[dorm] Toggle (Un)ordered List" }, + }, + + -- Invert all items in a list + -- Unlike `lt`, inverting a list will respect mixed list + -- items, instead of snapping all list types to a single one. + -- ^List Invert + { + "li", + "(dorm.pivot.list.invert)", + opts = { desc = "[dorm] Invert (Un)ordered List" }, + }, + + -- Insert a link to a date at the given position + -- ^Insert Date + { "id", "(dorm.time.insert-date)", opts = { desc = "[dorm] Insert Date" } }, + + -- Magnifies a code block to a separate buffer. + -- ^Code Magnify + { + "cm", + "(dorm.looking-glass.magnify-code-block)", + opts = { desc = "[dorm] Magnify Code Block" }, + }, + }, + + i = { + -- Promote an object recursively + { + "", + "(dorm.promo.promote)", + opts = { desc = "[dorm] Promote Object (Recursively)" }, + }, + + -- Demote an object recursively + { "", "(dorm.promo.demote)", opts = { desc = "[dorm] Demote Object (Recursively)" } }, + + -- Create an iteration of e.g. a list item + { "", "(dorm.itero.next-iteration)", opts = { desc = "[dorm] Continue Object" } }, + + -- Insert a link to a date at the current cursor position + -- ^Date + { + "", + "(dorm.time.insert-date.insert-mode)", + opts = { desc = "[dorm] Insert Date" }, + }, + }, + + v = { + -- Promote objects in range + { ">", "(dorm.promo.promote.range)", opts = { desc = "[dorm] Promote Objects in Range" } }, + -- Demote objects in range + { "<", "(dorm.promo.demote.range)", opts = { desc = "[dorm] Demote Objects in Range" } }, + }, + }, + }, + }, +} + +return module diff --git a/lua/dorm/mod/keys/module.lua-E b/lua/dorm/mod/keys/module.lua-E new file mode 100644 index 0000000..f1f7278 --- /dev/null +++ b/lua/dorm/mod/keys/module.lua-E @@ -0,0 +1,388 @@ +--[[ + file: User-keys + title: The Language of dorm + description: `base.keys` manages mappings for operations on or in `.dorm` files. + summary: Module for managing keybindings with dorm mode support. + --- +The `base.keys` module configures an out-of-the-box Neovim experience by providing a base +set of keys. + +To disable base keys, see the next section. To remap the existing keys, see [here](https://github.com/nvim-dorm/dorm/wiki/User-keys#remapping-keys). + +To find common problems, consult the [FAQ](https://github.com/nvim-dorm/dorm/wiki/User-keys#faq). + +### Disabling base keys + +By base when you load the `base.keys` module all keys will be enabled. If you would like to change this, be sure to set `base_keys` to `false`: +```lua +["keys"] = { + config = { + base_keys = false, + }, +} +``` + +### Remapping Keys + +To understand how to effectively remap keys, one must understand how keys are set. +dorm binds actions to various `` mappings that look like so: `(dorm...`. + +To remap a key, simply map an action somewhere in your configuration: + +```lua +vim.keymap.set("n", "my-key-here", "(dorm.pivot.list.toggle)", {}) +``` + +dorm will recognize that the key has been bound by you and not bind its own key. + +#### Binding Keys for dorm Files Only + +This approach has a downside - all of dorm's keys are set on a per-buffer basis +so that keys don't "overflow" into buffers you don't want them active in. + +When you map a key using `vim.keymap.set`, you set a global key which is always active, even in non-dorm +files. There are two ways to combat this: +- Create a file under `/ftplugin/dorm.lua`: + ```lua + vim.keymap.set("n", "my-key-here", "(dorm.pivot.list.toggle)", { buffer = true }) + ``` +- Create an autocommand using `vim.api.nvim_create_autocmd`: + ```lua + vim.api.nvim_create_autocmd("Filetype", { + pattern = "dorm", + callback = function() + vim.keymap.set("n", "my-key-here", "(dorm.pivot.list.toggle)", { buffer = true }) + end, + }) + ``` + +Notice that in both situations a `{ buffer = true }` was supplied to the function. +This way, your remapped keys will never interfere with other files. + +### Discovering Keys + +A comprehensive list of all keys can be found on [this page!](https://github.com/nvim-dorm/dorm/wiki/base-keys) + +## FAQ + +### Some (or all) keys do not work + +dorm refuses to bind keys when it knows they'll interfere with your configuration. +Run `:checkhealth dorm` to see a full list of what keys dorm has considered "conflicted" +or "rebound". + +If you see that *all* of your keys are in conflict, you're likely using a plugin that is mapping to your +local leader key. This is a known issue with older versions of `which-key.nvim`. Since version `3.0` of which-key the issue has been fixed - we +recommend updating to the latest version to resolve the errors. + +--]] + +local dorm = require("dorm") +local mod = dorm.mod + +local module = mod.create("keys") + +local bound_keys = {} + +module.load = function() + if module.config.public.base_keys then + local preset = module.private.presets[module.config.public.preset] + assert(preset, string.format("keybind preset `%s` does not exist!", module.config.public.preset)) + + module.public.set_keys_for(false, preset.all) + + vim.api.nvim_create_autocmd("FileType", { + pattern = "dorm", + callback = function(ev) + module.public.set_keys_for(ev.buf, preset.dorm) + end, + }) + end +end + +module.config.public = { + -- Whether to enable the base keys. + base_keys = true, + + -- Which keybind preset to use. + -- Currently allows only a single value: `"dorm"`. + preset = "dorm", +} + +---@class base.keys +module.public = { + --- Adds a set of base keys for dorm to bind. + --- Should be used exclusively by external mod wanting to provide their own base keys. + ---@param name string The name of the preset to extend (allows for providing base keys for various presets) + ---@param preset dorm.keys.preset The preset data itself. + extend_preset = function(name, preset) + local original_preset = assert(module.private.presets[name], "provided preset doesn't exist!") + + local function extend(a, b) + for k, v in pairs(b) do + if type(v) == "table" then + if vim.islist(v) then + vim.list_extend(a[k], v) + else + extend(a[k], v) + end + end + + a[k] = v + end + end + + extend(original_preset, preset) + module.public.bind_dorm_keys(vim.api.nvim_get_current_buf()) + end, + + ---@param buffer number|boolean + ---@param preset_subdata table + set_keys_for = function(buffer, preset_subdata) + for mode, keys in pairs(preset_subdata) do + bound_keys[mode] = bound_keys[mode] or {} + + for _, keybind in ipairs(keys) do + if + vim.fn.hasmapto(keybind[2], mode, false) == 0 + and vim.fn.mapcheck(keybind[1], mode, false):len() == 0 + then + local opts = vim.tbl_deep_extend("force", { buffer = buffer }, keybind.opts or {}) + vim.keymap.set(mode, keybind[1], keybind[2], opts) + + bound_keys[mode][keybind[1]] = true + end + end + end + end, + + --- Checks the health of keys. Returns all remaps and all conflicts in a table. + ---@return { preset_exists: boolean, remaps: table, conflicts: table } + health = function() + local preset = module.private.presets[module.config.public.preset] + + if not preset then + return { + preset_exists = false, + } + end + + local remaps = {} + local conflicts = {} + + local function check_keys_for(data) + for mode, keys in pairs(data) do + for _, keybind in ipairs(keys) do + if not bound_keys[mode] or not bound_keys[mode][keybind[1]] then + if vim.fn.hasmapto(keybind[2], mode, false) ~= 0 then + remaps[keybind[1]] = keybind[2] + elseif vim.fn.mapcheck(keybind[1], mode, false):len() ~= 0 then + conflicts[keybind[1]] = keybind[2] + end + end + end + end + end + + check_keys_for(preset.all) + check_keys_for(preset.dorm) + + return { + preset_exists = true, + remaps = remaps, + conflicts = conflicts, + } + end, +} + +module.private = { + + -- TODO: Move these to the "vim" preset + -- { "gd", "(dorm.esupports.hop.hop-link)", opts = { desc = "[dorm] Jump to Link" } }, + -- { "gf", "(dorm.esupports.hop.hop-link)", opts = { desc = "[dorm] Jump to Link" } }, + -- { "gF", "(dorm.esupports.hop.hop-link)", opts = { desc = "[dorm] Jump to Link" } }, + presets = { + ---@class dorm.keys.preset + dorm = { + all = { + n = { + -- Create a new `.dorm` file to take notes in + -- ^New Note + { + "nn", + "(dorm.workspace.new-note)", + opts = { desc = "[dorm] Create New Note" }, + }, + }, + }, + dorm = { + n = { + -- Mark the task under the cursor as "undone" + -- ^mark Task as Undone + { + "tu", + "(dorm.qol.todo-items.todo.task-undone)", + opts = { desc = "[dorm] Mark as Undone" }, + }, + + -- Mark the task under the cursor as "pending" + -- ^mark Task as Pending + { + "tp", + "(dorm.qol.todo-items.todo.task-pending)", + opts = { desc = "[dorm] Mark as Pending" }, + }, + + -- Mark the task under the cursor as "done" + -- ^mark Task as Done + { + "td", + "(dorm.qol.todo-items.todo.task-done)", + opts = { desc = "[dorm] Mark as Done" }, + }, + + -- Mark the task under the cursor as "on-hold" + -- ^mark Task as on Hold + { + "th", + "(dorm.qol.todo-items.todo.task-on-hold)", + opts = { desc = "[dorm] Mark as On Hold" }, + }, + + -- Mark the task under the cursor as "cancelled" + -- ^mark Task as Cancelled + { + "tc", + "(dorm.qol.todo-items.todo.task-cancelled)", + opts = { desc = "[dorm] Mark as Cancelled" }, + }, + + -- Mark the task under the cursor as "recurring" + -- ^mark Task as Recurring + { + "tr", + "(dorm.qol.todo-items.todo.task-recurring)", + opts = { desc = "[dorm] Mark as Recurring" }, + }, + + -- Mark the task under the cursor as "important" + -- ^mark Task as Important + { + "ti", + "(dorm.qol.todo-items.todo.task-important)", + opts = { desc = "[dorm] Mark as Important" }, + }, + + -- Mark the task under the cursor as "ambiguous" + -- ^mark Task as Ambiguous + { + "ta", + "(dorm.qol.todo-items.todo.task-ambiguous)", + opts = { desc = "[dorm] Mark as Ambigous" }, + }, + + -- Switch the task under the cursor between a select few states + { + "", + "(dorm.qol.todo-items.todo.task-cycle)", + opts = { desc = "[dorm] Cycle Task" }, + }, + + -- Hop to the destination of the link under the cursor + { "", "(dorm.esupports.hop.hop-link)", opts = { desc = "[dorm] Jump to Link" } }, + + -- Same as ``, except open the destination in a vertical split + { + "", + "(dorm.esupports.hop.hop-link.vsplit)", + opts = { desc = "[dorm] Jump to Link (Vertical Split)" }, + }, + + -- Promote an object non-recursively + { + ">.", + "(dorm.promo.promote)", + opts = { desc = "[dorm] Promote Object (Non-Recursively)" }, + }, + -- Demote an object non-recursively + { "<,", "(dorm.promo.demote)", opts = { desc = "[dorm] Demote Object (Non-Recursively)" } }, + + -- Promote an object recursively + { + ">>", + "(dorm.promo.promote.nested)", + opts = { desc = "[dorm] Promote Object (Recursively)" }, + }, + -- Demote an object recursively + { + "<<", + "(dorm.promo.demote.nested)", + opts = { desc = "[dorm] Demote Object (Recursively)" }, + }, + + -- Toggle a list from ordered <-> unordered + -- ^List Toggle + { + "lt", + "(dorm.pivot.list.toggle)", + opts = { desc = "[dorm] Toggle (Un)ordered List" }, + }, + + -- Invert all items in a list + -- Unlike `lt`, inverting a list will respect mixed list + -- items, instead of snapping all list types to a single one. + -- ^List Invert + { + "li", + "(dorm.pivot.list.invert)", + opts = { desc = "[dorm] Invert (Un)ordered List" }, + }, + + -- Insert a link to a date at the given position + -- ^Insert Date + { "id", "(dorm.time.insert-date)", opts = { desc = "[dorm] Insert Date" } }, + + -- Magnifies a code block to a separate buffer. + -- ^Code Magnify + { + "cm", + "(dorm.looking-glass.magnify-code-block)", + opts = { desc = "[dorm] Magnify Code Block" }, + }, + }, + + i = { + -- Promote an object recursively + { + "", + "(dorm.promo.promote)", + opts = { desc = "[dorm] Promote Object (Recursively)" }, + }, + + -- Demote an object recursively + { "", "(dorm.promo.demote)", opts = { desc = "[dorm] Demote Object (Recursively)" } }, + + -- Create an iteration of e.g. a list item + { "", "(dorm.itero.next-iteration)", opts = { desc = "[dorm] Continue Object" } }, + + -- Insert a link to a date at the current cursor position + -- ^Date + { + "", + "(dorm.tempus.insert-date.insert-mode)", + opts = { desc = "[dorm] Insert Date" }, + }, + }, + + v = { + -- Promote objects in range + { ">", "(dorm.promo.promote.range)", opts = { desc = "[dorm] Promote Objects in Range" } }, + -- Demote objects in range + { "<", "(dorm.promo.demote.range)", opts = { desc = "[dorm] Demote Objects in Range" } }, + }, + }, + }, + }, +} + +return module diff --git a/lua/dorm/mod/link/module.lua b/lua/dorm/mod/link/module.lua new file mode 100644 index 0000000..df4a1da --- /dev/null +++ b/lua/dorm/mod/link/module.lua @@ -0,0 +1,95 @@ +--[[ + file: link + title: Find link/target in the buffer + description: Utility module to handle link/link targets in the buffer + internal: true + --- + +This module provides utility functions that are used to find link and their targets in the buffer. +--]] + +local dorm = require("dorm") +local lib, mod = dorm.lib, dorm.mod + +local module = mod.create("link") + +module.setup = function() + return { + success = true, + } +end + +---@class base.link +module.public = { + -- TS query strings for different link targets + ---@param link_type "generic" | "definition" | "footnote" | string + get_link_target_query_string = function(link_type) + return lib.match(link_type)({ + generic = [[ + [(_ + [(strong_carryover_set + (strong_carryover + name: (tag_name) @tag_name + (tag_parameters) @title + (#eq? @tag_name "name"))) + (weak_carryover_set + (weak_carryover + name: (tag_name) @tag_name + (tag_parameters) @title + (#eq? @tag_name "name")))]? + title: (paragraph_segment) @title) + (inline_link_target + (paragraph) @title)] + ]], + + [{ "definition", "footnote" }] = string.format( + [[ + (%s_list + (strong_carryover_set + (strong_carryover + name: (tag_name) @tag_name + (tag_parameters) @title + (#eq? @tag_name "name")))? + . + [(single_%s + (weak_carryover_set + (weak_carryover + name: (tag_name) @tag_name + (tag_parameters) @title + (#eq? @tag_name "name")))? + (single_%s_prefix) + title: (paragraph_segment) @title) + (multi_%s + (weak_carryover_set + (weak_carryover + name: (tag_name) @tag_name + (tag_parameters) @title + (#eq? @tag_name "name")))? + (multi_%s_prefix) + title: (paragraph_segment) @title)]) + ]], + lib.reparg(link_type, 5) + ), + _ = string.format( + [[ + (%s + [(strong_carryover_set + (strong_carryover + name: (tag_name) @tag_name + (tag_parameters) @title + (#eq? @tag_name "name"))) + (weak_carryover_set + (weak_carryover + name: (tag_name) @tag_name + (tag_parameters) @title + (#eq? @tag_name "name")))]? + (%s_prefix) + title: (paragraph_segment) @title) + ]], + lib.reparg(link_type, 2) + ), + }) + end, +} + +return module diff --git a/lua/dorm/mod/notes/module.lua b/lua/dorm/mod/notes/module.lua new file mode 100644 index 0000000..e8dbad8 --- /dev/null +++ b/lua/dorm/mod/notes/module.lua @@ -0,0 +1,506 @@ +--[[ + file: notes + title: Dear diary... + description: The notes module allows you to take personal notes with zero friction. + summary: Easily track a notes within dorm. + --- +The notes module exposes a total of six commands. +The first three, `:dorm notes today|yesterday|tomorrow`, allow you to access entries +for a given time relative to today. A file will be opened with the respective date as a `.dorm` file. + +The fourth command, `:dorm notes custom`, allows you to specify a custom date as an argument. +The date must be formatted according to the `YYYY-mm-dd` format, e.g. `2023-01-01`. + +The `:dorm notes template` command creates a template file which will be used as the base whenever +a new notes entry is created. + +Last but not least, the `:dorm notes toc open|update` commands open or create/update a Table of Contents +file found in the root of the notes. This file contains link to all other notes entries, alongside +their titles. +--]] + +local dorm = require("dorm") +local config, lib, log, mod = dorm.config, dorm.lib, dorm.log, dorm.mod + +local module = mod.create("notes") + +module.examples = { + ["Changing TOC format to divide year in quarters"] = function() + -- In your ["notes"] options, change toc_format to a function like this: + + require("dorm").setup({ + load = { + -- ... + ["notes"] = { + config = { + -- ... + toc_format = function(entries) + -- Convert the entries into a certain format + + local output = {} + local current_year + local current_quarter + local last_quarter + local current_month + for _, entry in ipairs(entries) do + -- Don't print the year if it hasn't changed + if not current_year or current_year < entry[1] then + current_year = entry[1] + current_month = nil + table.insert(output, "* " .. current_year) + end + + -- Check to which quarter the current month corresponds to + if entry[2] <= 3 then + current_quarter = 1 + elseif entry[2] <= 6 then + current_quarter = 2 + elseif entry[2] <= 9 then + current_quarter = 3 + else + current_quarter = 4 + end + + -- If the current month corresponds to another quarter, print it + if current_quarter ~= last_quarter then + table.insert(output, "** Quarter " .. current_quarter) + last_quarter = current_quarter + end + + -- Don't print the month if it hasn't changed + if not current_month or current_month < entry[2] then + current_month = entry[2] + table.insert(output, "*** Month " .. current_month) + end + + -- Prints the file link + table.insert(output, " " .. entry[4] .. string.format("[%s]", entry[5])) + end + + return output + end, + -- ... + }, + }, + }, + }) + end, +} + +module.setup = function() + return { + success = true, + requires = { + "workspace", + "treesitter", + }, + } +end + +module.private = { + --- Opens a diary entry at the given time + ---@param time? number #The time to open the notes entry at as returned by `os.time()` + ---@param custom_date? string #A YYYY-mm-dd string that specifies a date to open the diary at instead + open_diary = function(time, custom_date) + -- TODO(vhyrro): Change this to use dorm dates! + local workspace = module.config.public.workspace or module.required["workspace"].get_current_workspace()[1] + local folder_name = module.config.public.notes_folder + local template_name = module.config.public.template_name + + if custom_date then + local year, month, day = custom_date:match("^(%d%d%d%d)-(%d%d)-(%d%d)$") + + if not year or not month or not day then + log.error("Wrong date format: use YYYY-mm-dd") + return + end + + time = os.time({ + year = year, + month = month, + day = day, + }) + end + + local path = os.date( + type(module.config.public.strategy) == "function" and module.config.public.strategy(os.date("*t", time)) + or module.config.public.strategy, + time + ) + + local workspace_path = module.required["workspace"].get_workspace(workspace) + + local notes_file_exists = + module.required["workspace"].file_exists(workspace_path .. "/" .. folder_name .. config.pathsep .. path) + + module.required["workspace"].create_file(folder_name .. config.pathsep .. path, workspace) + + module.required["workspace"].create_file(folder_name .. config.pathsep .. path, workspace) + + if + not notes_file_exists + and module.config.public.use_template + and module.required["workspace"].file_exists(workspace_path .. "/" .. folder_name .. "/" .. template_name) + then + vim.cmd("$read " .. workspace_path .. "/" .. folder_name .. "/" .. template_name .. "| w") + end + end, + + --- Opens a diary entry for tomorrow's date + diary_tomorrow = function() + module.private.open_diary(os.time() + 24 * 60 * 60) + end, + + --- Opens a diary entry for yesterday's date + diary_yesterday = function() + module.private.open_diary(os.time() - 24 * 60 * 60) + end, + + --- Opens a diary entry for today's date + diary_today = function() + module.private.open_diary() + end, + + --- Creates a template file + create_template = function() + local workspace = module.config.public.workspace + local folder_name = module.config.public.notes_folder + local template_name = module.config.public.template_name + + module.required["workspace"].create_file( + folder_name .. config.pathsep .. template_name, + workspace or module.required["workspace"].get_current_workspace()[1] + ) + end, + + --- Opens the toc file + open_toc = function() + local workspace = module.config.public.workspace or module.required["workspace"].get_current_workspace()[1] + local index = mod.get_module_config("workspace").index + local folder_name = module.config.public.notes_folder + + -- If the toc exists, open it, if not, create it + if module.required["workspace"].file_exists(folder_name .. config.pathsep .. index) then + module.required["workspace"].open_file(workspace, folder_name .. config.pathsep .. index) + else + module.private.create_toc() + end + end, + + --- Creates or updates the toc file + create_toc = function() + local workspace = module.config.public.workspace or module.required["workspace"].get_current_workspace()[1] + local index = mod.get_module_config("workspace").index + local workspace_path = module.required["workspace"].get_workspace(workspace) + local workspace_name_for_link = module.config.public.workspace or "" + local folder_name = module.config.public.notes_folder + + -- Each entry is a table that contains tables like { yy, mm, dd, link, title } + local toc_entries = {} + + -- Get a filesystem handle for the files in the notes folder + -- path is for each subfolder + local get_fs_handle = function(path) + path = path or "" + local handle = + vim.loop.fs_scandir(workspace_path .. config.pathsep .. folder_name .. config.pathsep .. path) + + if type(handle) ~= "userdata" then + error(lib.lazy_string_concat("Failed to scan directory '", workspace, path, "': ", handle)) + end + + return handle + end + + -- Gets the title from the metadata of a file, must be called in a vim.schedule + local get_title = function(file) + local buffer = vim.fn.bufadd(workspace_path .. config.pathsep .. folder_name .. config.pathsep .. file) + local meta = module.required["treesitter"].get_document_metadata(buffer) + return meta.title + end + + vim.loop.fs_scandir(workspace_path .. config.pathsep .. folder_name .. config.pathsep, function(err, handle) + assert(not err, lib.lazy_string_concat("Unable to generate TOC for directory '", folder_name, "' - ", err)) + + while true do + -- Name corresponds to either a YYYY-mm-dd.dorm file, or just the year ("nested" strategy) + local name, type = vim.loop.fs_scandir_next(handle) ---@diagnostic disable-line -- TODO: type error workaround + + if not name then + break + end + + -- Handle nested entries + if type == "directory" then + local years_handle = get_fs_handle(name) + while true do + -- mname is the month + local mname, mtype = vim.loop.fs_scandir_next(years_handle) + + if not mname then + break + end + + if mtype == "directory" then + local months_handle = get_fs_handle(name .. config.pathsep .. mname) + while true do + -- dname is the day + local dname, dtype = vim.loop.fs_scandir_next(months_handle) + + if not dname then + break + end + + -- If it's a .dorm file, also ensure it is a day entry + if dtype == "file" and string.match(dname, "%d%d%.dorm") then + -- Split the file name + local file = vim.split(dname, ".", { plain = true }) + + vim.schedule(function() + -- Get the title from the metadata, else, it just base to the name of the file + local title = get_title( + name .. config.pathsep .. mname .. config.pathsep .. dname + ) or file[1] + + -- Insert a new entry + table.insert(toc_entries, { + tonumber(name), + tonumber(mname), + tonumber(file[1]), + "{:$" + .. workspace_name_for_link + .. config.pathsep + .. module.config.public.notes_folder + .. config.pathsep + .. name + .. config.pathsep + .. mname + .. config.pathsep + .. file[1] + .. ":}", + title, + }) + end) + end + end + end + end + end + + -- Handles flat entries + -- If it is a .dorm file, but it's not any user generated file. + -- The match is here to avoid handling files made by the user, like a template file, or + -- the toc file + if type == "file" and string.match(name, "%d+-%d+-%d+%.dorm") then + -- Split yyyy-mm-dd to a table + local file = vim.split(name, ".", { plain = true }) + local parts = vim.split(file[1], "-") + + -- Convert the parts into numbers + for k, v in pairs(parts) do + parts[k] = tonumber(v) ---@diagnostic disable-line -- TODO: type error workaround + end + + vim.schedule(function() + -- Get the title from the metadata, else, it just base to the name of the file + local title = get_title(name) or parts[3] + + -- And insert a new entry that corresponds to the file + table.insert(toc_entries, { + parts[1], + parts[2], + parts[3], + "{:$" + .. workspace_name_for_link + .. config.pathsep + .. module.config.public.notes_folder + .. config.pathsep + .. file[1] + .. ":}", + title, + }) + end) + end + end + + vim.schedule(function() + -- Gets a base format for the entries + local format = module.config.public.toc_format + or function(entries) + local months_text = { + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + } + -- Convert the entries into a certain format to be written + local output = {} + local current_year + local current_month + for _, entry in ipairs(entries) do + -- Don't print the year and month if they haven't changed + if not current_year or current_year < entry[1] then + current_year = entry[1] + current_month = nil + table.insert(output, "* " .. current_year) + end + if not current_month or current_month < entry[2] then + current_month = entry[2] + table.insert(output, "** " .. months_text[current_month]) + end + + -- Prints the file link + table.insert(output, " " .. entry[4] .. string.format("[%s]", entry[5])) + end + + return output + end + + module.required["workspace"].create_file( + folder_name .. config.pathsep .. index, + workspace or module.required["workspace"].get_current_workspace()[1] + ) + + -- The current buffer now must be the toc file, so we set our toc entries there + vim.api.nvim_buf_set_lines(0, 0, -1, false, format(toc_entries)) + vim.cmd("w") + end) + end) + end, +} + +module.config.public = { + -- Which workspace to use for the notes files, the base behaviour + -- is to use the current workspace. + -- + -- It is recommended to set this to a static workspace, but the most optimal + -- behaviour may vary from workflow to workflow. + workspace = nil, + + -- The name for the folder in which the notes files are put. + notes_folder = "notes", + + -- The strategy to use to create directories. + -- May be "flat" (`2022-03-02.dorm`), "nested" (`2022/03/02.dorm`), + -- a lua string with the format given to `os.date()` or a lua function + -- that returns a lua string with the same format. + strategy = "nested", + + -- The name of the template file to use when running `:dorm notes template`. + template_name = "template.dorm", + + -- Whether to apply the template file to new notes entries. + use_template = true, + + -- Formatter function used to generate the toc file. + -- Receives a table that contains tables like { yy, mm, dd, link, title }. + -- + -- The function must return a table of strings. + toc_format = nil, +} + +module.config.private = { + strategies = { + flat = "%Y-%m-%d.dorm", + nested = "%Y" .. config.pathsep .. "%m" .. config.pathsep .. "%d.dorm", + }, +} + +---@class base.notes +module.public = { + version = "0.0.9", +} + +module.load = function() + if module.config.private.strategies[module.config.public.strategy] then + module.config.public.strategy = module.config.private.strategies[module.config.public.strategy] + end + + mod.await("cmd", function(cmd) + cmd.add_commands_from_table({ + notes = { + min_args = 1, + max_args = 2, + subcommands = { + tomorrow = { args = 0, name = "notes.tomorrow" }, + yesterday = { args = 0, name = "notes.yesterday" }, + today = { args = 0, name = "notes.today" }, + custom = { max_args = 1, name = "notes.custom" }, -- format :yyyy-mm-dd + template = { args = 0, name = "notes.template" }, + toc = { + args = 1, + name = "notes.toc", + subcommands = { + open = { args = 0, name = "notes.toc.open" }, + update = { args = 0, name = "notes.toc.update" }, + }, + }, + }, + }, + }) + end) +end + +module.on_event = function(event) + if event.split_type[1] == "cmd" then + if event.split_type[2] == "notes.tomorrow" then + module.private.diary_tomorrow() + elseif event.split_type[2] == "notes.yesterday" then + module.private.diary_yesterday() + elseif event.split_type[2] == "notes.custom" then + if not event.content[1] then + local calendar = mod.get_module("calendar") + + if not calendar then + log.error("[ERROR]: `base.calendar` is not loaded! Said module is required for this operation.") + return + end + + calendar.select_date({ + callback = vim.schedule_wrap(function(osdate) + module.private.open_diary( + nil, + string.format("%04d", osdate.year) + .. "-" + .. string.format("%02d", osdate.month) + .. "-" + .. string.format("%02d", osdate.day) + ) + end), + }) + else + module.private.open_diary(nil, event.content[1]) + end + elseif event.split_type[2] == "notes.today" then + module.private.diary_today() + elseif event.split_type[2] == "notes.template" then + module.private.create_template() + elseif event.split_type[2] == "notes.toc.open" then + module.private.open_toc() + elseif event.split_type[2] == "notes.toc.update" then + module.private.create_toc() + end + end +end + +module.events.subscribed = { + ["cmd"] = { + ["notes.yesterday"] = true, + ["notes.tomorrow"] = true, + ["notes.today"] = true, + ["notes.custom"] = true, + ["notes.template"] = true, + ["notes.toc.update"] = true, + ["notes.toc.open"] = true, + }, +} + +return module diff --git a/lua/dorm/mod/store/module.lua b/lua/dorm/mod/store/module.lua new file mode 100644 index 0000000..c6ad688 --- /dev/null +++ b/lua/dorm/mod/store/module.lua @@ -0,0 +1,101 @@ +--[[ + File: store + Title: Store persistent data and query it easily with `base.store` + Summary: Deals with storing persistent data across dorm sessions. + Internal: true + --- +--]] + +local dorm = require("dorm") +local mod = dorm.mod + +local module = mod.create("store") + +module.setup = function() + return { + requires = { + "autocmd", + }, + } +end + +module.config.public = { + -- Full path to store data (saved in mpack data format) + path = vim.fn.stdpath("data") .. "/dorm.mpack", +} + +module.private = { + data = {}, +} + +---@class base.store +module.public = { + --- Grabs the data present on disk and overwrites it with the data present in memory + sync = function() + local file = io.open(module.config.public.path, "r") + + if not file then + return + end + + local content = file:read("*a") + + io.close(file) + + module.private.data = vim.mpack.decode and vim.mpack.decode(content) or vim.mpack.unpack(content) + end, + + --- Stores a key-value pair in the store + ---@param key string #The key to index in the store + ---@param data any #The data to store at the specific key + store = function(key, data) + module.private.data[key] = data + end, + + --- Removes a key from store + ---@param key string #The name of the key to remove + remove = function(key) + module.private.data[key] = nil + end, + + --- Retrieves a key from the store + ---@param key string #The name of the key to index + ---@return any|table #The data present at the key, or an empty table + retrieve = function(key) + return module.private.data[key] or {} + end, + + --- Flushes the contents in memory to the location specified in the `path` configuration option. + flush = function() + local file = io.open(module.config.public.path, "w") + + if not file then + return + end + + file:write(vim.mpack.encode and vim.mpack.encode(module.private.data) or vim.mpack.pack(module.private.data)) + + io.close(file) + end, +} + +module.on_event = function(event) + -- Synchronize the data in memory with the data on disk after we leave Neovim + if event.type == "autocmd.events.vimleavepre" then + module.public.flush() + end +end + +module.load = function() + module.required["autocmd"].enable_autocommand("VimLeavePre") + + module.public.sync() +end + +module.events.subscribed = { + ["autocmd"] = { + vimleavepre = true, + }, +} + +return module diff --git a/lua/dorm/mod/time/module.lua b/lua/dorm/mod/time/module.lua new file mode 100644 index 0000000..3ec16cd --- /dev/null +++ b/lua/dorm/mod/time/module.lua @@ -0,0 +1,476 @@ +--[[ + file: time + title: Hassle-Free Dates + summary: Parses and handles dates in dorm. + internal: true + --- +`core.time` is an internal module specifically designed +to handle complex dates. It exposes two functions: `parse_date(string) -> date|string` +and `to_lua_date(date) -> osdate`. + +## Keybinds + +This module exposes the following keybinds (see [`core.keybinds`](@core.keybinds) for instructions on +mapping them): + +- `dorm.time.insert-date` - Insert date at cursor position (called from normal mode) +- `dorm.time.insert-date.insert-mode` - Insert date at cursor position (called from insert mode) +--]] + +local d = require("dorm") +local lib, modules, utils = d.lib, d.mod, d.utils + +local module = modules.create("time") + +-- NOTE: Maybe encapsulate whole date parser in a single PEG grammar? +local _, time_regex = pcall(vim.re.compile, [[{%d%d?} ":" {%d%d} ("." {%d%d?})?]]) + +local timezone_list = { + "ACDT", + "ACST", + "ACT", + "ACWST", + "ADT", + "AEDT", + "AEST", + "AET", + "AFT", + "AKDT", + "AKST", + "ALMT", + "AMST", + "AMT", + "ANAT", + "AQTT", + "ART", + "AST", + "AWST", + "AZOST", + "AZOT", + "AZT", + "BNT", + "BIOT", + "BIT", + "BOT", + "BRST", + "BRT", + "BST", + "BTT", + "CAT", + "CCT", + "CDT", + "CEST", + "CET", + "CHADT", + "CHAST", + "CHOT", + "CHOST", + "CHST", + "CHUT", + "CIST", + "CKT", + "CLST", + "CLT", + "COST", + "COT", + "CST", + "CT", + "CVT", + "CWST", + "CXT", + "DAVT", + "DDUT", + "DFT", + "EASST", + "EAST", + "EAT", + "ECT", + "EDT", + "EEST", + "EET", + "EGST", + "EGT", + "EST", + "ET", + "FET", + "FJT", + "FKST", + "FKT", + "FNT", + "GALT", + "GAMT", + "GET", + "GFT", + "GILT", + "GIT", + "GMT", + "GST", + "GYT", + "HDT", + "HAEC", + "HST", + "HKT", + "HMT", + "HOVST", + "HOVT", + "ICT", + "IDLW", + "IDT", + "IOT", + "IRDT", + "IRKT", + "IRST", + "IST", + "JST", + "KALT", + "KGT", + "KOST", + "KRAT", + "KST", + "LHST", + "LINT", + "MAGT", + "MART", + "MAWT", + "MDT", + "MET", + "MEST", + "MHT", + "MIST", + "MIT", + "MMT", + "MSK", + "MST", + "MUT", + "MVT", + "MYT", + "NCT", + "NDT", + "NFT", + "NOVT", + "NPT", + "NST", + "NT", + "NUT", + "NZDT", + "NZST", + "OMST", + "ORAT", + "PDT", + "PET", + "PETT", + "PGT", + "PHOT", + "PHT", + "PHST", + "PKT", + "PMDT", + "PMST", + "PONT", + "PST", + "PWT", + "PYST", + "PYT", + "RET", + "ROTT", + "SAKT", + "SAMT", + "SAST", + "SBT", + "SCT", + "SDT", + "SGT", + "SLST", + "SRET", + "SRT", + "SST", + "SYOT", + "TAHT", + "THA", + "TFT", + "TJT", + "TKT", + "TLT", + "TMT", + "TRT", + "TOT", + "TVT", + "ULAST", + "ULAT", + "UTC", + "UYST", + "UYT", + "UZT", + "VET", + "VLAT", + "VOLT", + "VOST", + "VUT", + "WAKT", + "WAST", + "WAT", + "WEST", + "WET", + "WIB", + "WIT", + "WITA", + "WGST", + "WGT", + "WST", + "YAKT", + "YEKT", +} + +---@alias Date {weekday: {name: string, number: number}?, day: number?, month: {name: string, number: number}?, year: number?, timezone: string?, time: {hour: number, minute: number, second: number?}?} + +---@class time +module.public = { + --- Converts a parsed date with `parse_date` to a lua date. + ---@param parsed_date Date #The date to convert + ---@return osdate #A Lua date + to_lua_date = function(parsed_date) + local now = os.date("*t") --[[@as osdate]] + local parsed = os.time(vim.tbl_deep_extend("force", now, { + day = parsed_date.day, + month = parsed_date.month and parsed_date.month.number or nil, + year = parsed_date.year, + hour = parsed_date.time and parsed_date.time.hour, + min = parsed_date.time and parsed_date.time.minute, + sec = parsed_date.time and parsed_date.time.second, + }) --[[@as osdateparam]]) + return os.date("*t", parsed) --[[@as osdate]] + end, + + --- Converts a lua `osdate` to a dorm date. + ---@param osdate osdate #The date to convert + ---@param include_time boolean? #Whether to include the time (hh::mm.ss) in the output. + ---@return Date #The converted date + to_date = function(osdate, include_time) + -- TODO: Extract into a function to get weekdays (have to hot recalculate every time because the user may change locale + local weekdays = {} + for i = 1, 7 do + table.insert(weekdays, os.date("%A", os.time({ year = 2000, month = 5, day = i })):lower()) ---@diagnostic disable-line -- TODO: type error workaround + end + + local months = {} + for i = 1, 12 do + table.insert(months, os.date("%B", os.time({ year = 2000, month = i, day = 1 })):lower()) ---@diagnostic disable-line -- TODO: type error workaround + end + + -- os.date("*t") returns wday with Sunday as 1, needs to be + -- converted to Monday as 1 + local converted_weekday = lib.number_wrap(osdate.wday - 1, 1, 7) + + return module.private.tostringable_date({ + weekday = osdate.wday and { + number = converted_weekday, + name = lib.title(weekdays[converted_weekday]), + } or nil, + day = osdate.day, + month = osdate.month and { + number = osdate.month, + name = lib.title(months[osdate.month]), + } or nil, + year = osdate.year, + time = osdate.hour and setmetatable({ + hour = osdate.hour, + minute = osdate.min or 0, + second = osdate.sec or 0, + }, { + __tostring = function() + if not include_time then + return "" + end + + return tostring(osdate.hour) + .. ":" + .. tostring(string.format("%02d", osdate.min)) + .. (osdate.sec ~= 0 and ("." .. tostring(osdate.sec)) or "") + end, + }) or nil, + }) + end, + + --- Parses a date and returns a table representing the date + ---@param input string #The input which should follow the date specification found in the dorm spec. + ---@return Date|string #The data extracted from the input or an error message + parse_date = function(input) + local weekdays = {} + for i = 1, 7 do + table.insert(weekdays, os.date("%A", os.time({ year = 2000, month = 5, day = i })):lower()) ---@diagnostic disable-line -- TODO: type error workaround + end + + local months = {} + for i = 1, 12 do + table.insert(months, os.date("%B", os.time({ year = 2000, month = i, day = 1 })):lower()) ---@diagnostic disable-line -- TODO: type error workaround + end + + local output = {} + + for word in vim.gsplit(input, "%s+") do + if word:len() == 0 then + goto continue + end + + if word:match("^-?%d%d%d%d+$") then + output.year = tonumber(word) + elseif word:match("^%d+%w*$") then + output.day = tonumber(word:match("%d+")) + elseif vim.list_contains(timezone_list, word:upper()) then + output.timezone = word:upper() + else + do + local hour, minute, second = vim.re.match(word, time_regex) + + if hour and minute then + output.time = setmetatable({ + hour = tonumber(hour), + minute = tonumber(minute), + second = second and tonumber(second) or nil, + }, { + __tostring = function() + return word + end, + }) + + goto continue + end + end + + do + local valid_months = {} + + -- Check for month abbreviation + for i, month in ipairs(months) do + if vim.startswith(month, word:lower()) then + valid_months[month] = i + end + end + + local count = vim.tbl_count(valid_months) + if count > 1 then + return "Ambiguous month name! Possible interpretations: " + .. table.concat(vim.tbl_keys(valid_months), ",") + elseif count == 1 then + local valid_month_name, valid_month_number = next(valid_months) + + output.month = { + name = lib.title(valid_month_name), + number = valid_month_number, + } + + goto continue + end + end + + do + word = word:match("^([^,]+),?$") + + local valid_weekdays = {} + + -- Check for weekday abbreviation + for i, weekday in ipairs(weekdays) do + if vim.startswith(weekday, word:lower()) then + valid_weekdays[weekday] = i + end + end + + local count = vim.tbl_count(valid_weekdays) + if count > 1 then + return "Ambiguous weekday name! Possible interpretations: " + .. table.concat(vim.tbl_keys(valid_weekdays), ",") + elseif count == 1 then + local valid_weekday_name, valid_weekday_number = next(valid_weekdays) + + output.weekday = { + name = lib.title(valid_weekday_name), + number = valid_weekday_number, + } + + goto continue + end + end + + return "Unidentified string: `" + .. word + .. "` - make sure your locale and language are set correctly if you are using a language other than English!" + end + + ::continue:: + end + + return module.private.tostringable_date(output) + end, + + insert_date = function(insert_mode) + local function callback(input) + if input == "" or not input then + return + end + + local output + + if type(input) == "table" then + output = tostring(module.public.to_date(input)) + else + output = module.public.parse_date(input) + + if type(output) == "string" then + utils.notify(output, vim.log.levels.ERROR) + + vim.ui.input({ + prompt = "Date: ", + default = input, + }, callback) + + return + end + + output = tostring(output) + end + + vim.api.nvim_put({ "{@ " .. output .. "}" }, "c", false, true) + + if insert_mode then + vim.cmd.startinsert() + end + end + + if modules.is_module_loaded("calendar") then + vim.cmd.stopinsert() + modules.get_module("calendar").select_date({ callback = vim.schedule_wrap(callback) }) + else + vim.ui.input({ + prompt = "Date: ", + }, callback) + end + end, +} + +module.private = { + tostringable_date = function(date_table) + return setmetatable(date_table, { + __tostring = function() + local function d(str) + return str and (tostring(str) .. " ") or "" + end + + return vim.trim( + d(date_table.weekday and date_table.weekday.name) + .. d(date_table.day) + .. d(date_table.month and date_table.month.name) + .. d(date_table.year and string.format("%04d", date_table.year)) + .. d(date_table.time and tostring(date_table.time)) + .. d(date_table.timezone) + ) + end, + }) + end, +} + +module.load = function() + vim.keymap.set("", "(dorm.time.insert-date)", lib.wrap(module.public.insert_date, false)) + vim.keymap.set("i", "(dorm.time.insert-date.insert-mode)", lib.wrap(module.public.insert_date, true)) +end + +return module diff --git a/lua/dorm/mod/time/module.lua-E b/lua/dorm/mod/time/module.lua-E new file mode 100644 index 0000000..7ea383a --- /dev/null +++ b/lua/dorm/mod/time/module.lua-E @@ -0,0 +1,476 @@ +--[[ + file: time + title: Hassle-Free Dates + summary: Parses and handles dates in dorm. + internal: true + --- +`core.time` is an internal module specifically designed +to handle complex dates. It exposes two functions: `parse_date(string) -> date|string` +and `to_lua_date(date) -> osdate`. + +## Keybinds + +This module exposes the following keybinds (see [`core.keybinds`](@core.keybinds) for instructions on +mapping them): + +- `dorm.time.insert-date` - Insert date at cursor position (called from normal mode) +- `dorm.time.insert-date.insert-mode` - Insert date at cursor position (called from insert mode) +--]] + +local d = require("dorm") +local lib, modules, utils = d.lib, d.mod, d.utils + +local module = modules.create("time") + +-- NOTE: Maybe encapsulate whole date parser in a single PEG grammar? +local _, time_regex = pcall(vim.re.compile, [[{%d%d?} ":" {%d%d} ("." {%d%d?})?]]) + +local timezone_list = { + "ACDT", + "ACST", + "ACT", + "ACWST", + "ADT", + "AEDT", + "AEST", + "AET", + "AFT", + "AKDT", + "AKST", + "ALMT", + "AMST", + "AMT", + "ANAT", + "AQTT", + "ART", + "AST", + "AWST", + "AZOST", + "AZOT", + "AZT", + "BNT", + "BIOT", + "BIT", + "BOT", + "BRST", + "BRT", + "BST", + "BTT", + "CAT", + "CCT", + "CDT", + "CEST", + "CET", + "CHADT", + "CHAST", + "CHOT", + "CHOST", + "CHST", + "CHUT", + "CIST", + "CKT", + "CLST", + "CLT", + "COST", + "COT", + "CST", + "CT", + "CVT", + "CWST", + "CXT", + "DAVT", + "DDUT", + "DFT", + "EASST", + "EAST", + "EAT", + "ECT", + "EDT", + "EEST", + "EET", + "EGST", + "EGT", + "EST", + "ET", + "FET", + "FJT", + "FKST", + "FKT", + "FNT", + "GALT", + "GAMT", + "GET", + "GFT", + "GILT", + "GIT", + "GMT", + "GST", + "GYT", + "HDT", + "HAEC", + "HST", + "HKT", + "HMT", + "HOVST", + "HOVT", + "ICT", + "IDLW", + "IDT", + "IOT", + "IRDT", + "IRKT", + "IRST", + "IST", + "JST", + "KALT", + "KGT", + "KOST", + "KRAT", + "KST", + "LHST", + "LINT", + "MAGT", + "MART", + "MAWT", + "MDT", + "MET", + "MEST", + "MHT", + "MIST", + "MIT", + "MMT", + "MSK", + "MST", + "MUT", + "MVT", + "MYT", + "NCT", + "NDT", + "NFT", + "NOVT", + "NPT", + "NST", + "NT", + "NUT", + "NZDT", + "NZST", + "OMST", + "ORAT", + "PDT", + "PET", + "PETT", + "PGT", + "PHOT", + "PHT", + "PHST", + "PKT", + "PMDT", + "PMST", + "PONT", + "PST", + "PWT", + "PYST", + "PYT", + "RET", + "ROTT", + "SAKT", + "SAMT", + "SAST", + "SBT", + "SCT", + "SDT", + "SGT", + "SLST", + "SRET", + "SRT", + "SST", + "SYOT", + "TAHT", + "THA", + "TFT", + "TJT", + "TKT", + "TLT", + "TMT", + "TRT", + "TOT", + "TVT", + "ULAST", + "ULAT", + "UTC", + "UYST", + "UYT", + "UZT", + "VET", + "VLAT", + "VOLT", + "VOST", + "VUT", + "WAKT", + "WAST", + "WAT", + "WEST", + "WET", + "WIB", + "WIT", + "WITA", + "WGST", + "WGT", + "WST", + "YAKT", + "YEKT", +} + +---@alias Date {weekday: {name: string, number: number}?, day: number?, month: {name: string, number: number}?, year: number?, timezone: string?, time: {hour: number, minute: number, second: number?}?} + +---@class time +module.public = { + --- Converts a parsed date with `parse_date` to a lua date. + ---@param parsed_date Date #The date to convert + ---@return osdate #A Lua date + to_lua_date = function(parsed_date) + local now = os.date("*t") --[[@as osdate]] + local parsed = os.time(vim.tbl_deep_extend("force", now, { + day = parsed_date.day, + month = parsed_date.month and parsed_date.month.number or nil, + year = parsed_date.year, + hour = parsed_date.time and parsed_date.time.hour, + min = parsed_date.time and parsed_date.time.minute, + sec = parsed_date.time and parsed_date.time.second, + }) --[[@as osdateparam]]) + return os.date("*t", parsed) --[[@as osdate]] + end, + + --- Converts a lua `osdate` to a dorm date. + ---@param osdate osdate #The date to convert + ---@param include_time boolean? #Whether to include the time (hh::mm.ss) in the output. + ---@return Date #The converted date + to_date = function(osdate, include_time) + -- TODO: Extract into a function to get weekdays (have to hot recalculate every time because the user may change locale + local weekdays = {} + for i = 1, 7 do + table.insert(weekdays, os.date("%A", os.time({ year = 2000, month = 5, day = i })):lower()) ---@diagnostic disable-line -- TODO: type error workaround + end + + local months = {} + for i = 1, 12 do + table.insert(months, os.date("%B", os.time({ year = 2000, month = i, day = 1 })):lower()) ---@diagnostic disable-line -- TODO: type error workaround + end + + -- os.date("*t") returns wday with Sunday as 1, needs to be + -- converted to Monday as 1 + local converted_weekday = lib.number_wrap(osdate.wday - 1, 1, 7) + + return module.private.tostringable_date({ + weekday = osdate.wday and { + number = converted_weekday, + name = lib.title(weekdays[converted_weekday]), + } or nil, + day = osdate.day, + month = osdate.month and { + number = osdate.month, + name = lib.title(months[osdate.month]), + } or nil, + year = osdate.year, + time = osdate.hour and setmetatable({ + hour = osdate.hour, + minute = osdate.min or 0, + second = osdate.sec or 0, + }, { + __tostring = function() + if not include_time then + return "" + end + + return tostring(osdate.hour) + .. ":" + .. tostring(string.format("%02d", osdate.min)) + .. (osdate.sec ~= 0 and ("." .. tostring(osdate.sec)) or "") + end, + }) or nil, + }) + end, + + --- Parses a date and returns a table representing the date + ---@param input string #The input which should follow the date specification found in the dorm spec. + ---@return Date|string #The data extracted from the input or an error message + parse_date = function(input) + local weekdays = {} + for i = 1, 7 do + table.insert(weekdays, os.date("%A", os.time({ year = 2000, month = 5, day = i })):lower()) ---@diagnostic disable-line -- TODO: type error workaround + end + + local months = {} + for i = 1, 12 do + table.insert(months, os.date("%B", os.time({ year = 2000, month = i, day = 1 })):lower()) ---@diagnostic disable-line -- TODO: type error workaround + end + + local output = {} + + for word in vim.gsplit(input, "%s+") do + if word:len() == 0 then + goto continue + end + + if word:match("^-?%d%d%d%d+$") then + output.year = tonumber(word) + elseif word:match("^%d+%w*$") then + output.day = tonumber(word:match("%d+")) + elseif vim.list_contains(timezone_list, word:upper()) then + output.timezone = word:upper() + else + do + local hour, minute, second = vim.re.match(word, time_regex) + + if hour and minute then + output.time = setmetatable({ + hour = tonumber(hour), + minute = tonumber(minute), + second = second and tonumber(second) or nil, + }, { + __tostring = function() + return word + end, + }) + + goto continue + end + end + + do + local valid_months = {} + + -- Check for month abbreviation + for i, month in ipairs(months) do + if vim.startswith(month, word:lower()) then + valid_months[month] = i + end + end + + local count = vim.tbl_count(valid_months) + if count > 1 then + return "Ambiguous month name! Possible interpretations: " + .. table.concat(vim.tbl_keys(valid_months), ",") + elseif count == 1 then + local valid_month_name, valid_month_number = next(valid_months) + + output.month = { + name = lib.title(valid_month_name), + number = valid_month_number, + } + + goto continue + end + end + + do + word = word:match("^([^,]+),?$") + + local valid_weekdays = {} + + -- Check for weekday abbreviation + for i, weekday in ipairs(weekdays) do + if vim.startswith(weekday, word:lower()) then + valid_weekdays[weekday] = i + end + end + + local count = vim.tbl_count(valid_weekdays) + if count > 1 then + return "Ambiguous weekday name! Possible interpretations: " + .. table.concat(vim.tbl_keys(valid_weekdays), ",") + elseif count == 1 then + local valid_weekday_name, valid_weekday_number = next(valid_weekdays) + + output.weekday = { + name = lib.title(valid_weekday_name), + number = valid_weekday_number, + } + + goto continue + end + end + + return "Unidentified string: `" + .. word + .. "` - make sure your locale and language are set correctly if you are using a language other than English!" + end + + ::continue:: + end + + return module.private.tostringable_date(output) + end, + + insert_date = function(insert_mode) + local function callback(input) + if input == "" or not input then + return + end + + local output + + if type(input) == "table" then + output = tostring(module.public.to_date(input)) + else + output = module.public.parse_date(input) + + if type(output) == "string" then + utils.notify(output, vim.log.levels.ERROR) + + vim.ui.input({ + prompt = "Date: ", + default = input, + }, callback) + + return + end + + output = tostring(output) + end + + vim.api.nvim_put({ "{@ " .. output .. "}" }, "c", false, true) + + if insert_mode then + vim.cmd.startinsert() + end + end + + if modules.is_module_loaded("calendar") then + vim.cmd.stopinsert() + modules.get_module("calendar").select_date({ callback = vim.schedule_wrap(callback) }) + else + vim.ui.input({ + prompt = "Date: ", + }, callback) + end + end, +} + +module.private = { + tostringable_date = function(date_table) + return setmetatable(date_table, { + __tostring = function() + local function d(str) + return str and (tostring(str) .. " ") or "" + end + + return vim.trim( + d(date_table.weekday and date_table.weekday.name) + .. d(date_table.day) + .. d(date_table.month and date_table.month.name) + .. d(date_table.year and string.format("%04d", date_table.year)) + .. d(date_table.time and tostring(date_table.time)) + .. d(date_table.timezone) + ) + end, + }) + end, +} + +module.load = function() + vim.keymap.set("", "(dorm.time.insert-date)", lib.wrap(module.public.insert_date, false)) + vim.keymap.set("i", "(dorm.tempus.insert-date.insert-mode)", lib.wrap(module.public.insert_date, true)) +end + +return module diff --git a/lua/dorm/mod/todo/module.lua b/lua/dorm/mod/todo/module.lua new file mode 100644 index 0000000..07a9a4b --- /dev/null +++ b/lua/dorm/mod/todo/module.lua @@ -0,0 +1,230 @@ +--[[ + file: todo + title: See Your Progress at a Glance + description: The introspector module displays progress for nested tasks. + summary: Module for displaying progress of completed subtasks in the virtual line. + --- + +When an item with a TODO status has children with their own TODOs this module enables virtual text in the top level item and displays the +progress of the subtasks. By base it displays in the format of `[completed/total] (progress%)`. +--]] +local dorm = require("dorm") +local mod = dorm.mod + +local module = mod.create("todo") + +module.private = { + namespace = vim.api.nvim_create_namespace("dorm/todo"), + + --- List of active buffers + buffers = {}, +} + +---@class base.todo +module.config.public = { + + -- Highlight group to display introspector in. + -- + -- base to "Normal". + highlight_group = "Normal", + + -- Which status types to count towards the totol. + -- + -- base to the following: `done`, `pending`, `undone`, `urgent`. + counted_statuses = { + "done", + "pending", + "undone", + "urgent", + }, + + -- Which status should count towards the completed count (should be a subset of counted_statuses). + -- + -- base to the following: `done`. + completed_statuses = { + "done", + }, + + -- Callback to format introspector. Takes in two parameters: + -- * `completed`: number of completed tasks + -- * `total`: number of total counted tasks + -- + -- Should return a string with the format you want to display the introspector in. + -- + -- base to "[completed/total] (progress%)" + format = function(completed, total) + -- stylua: ignore start + return string.format( + "[%d/%d] (%d%%)", + completed, + total, + (total ~= 0 and math.floor((completed / total) * 100) or 0) + ) + -- stylua: ignore end + end, +} + +module.setup = function() + return { + requires = { "treesitter" }, + } +end + +module.load = function() + vim.api.nvim_create_autocmd("Filetype", { + pattern = "dorm", + desc = "Attaches the TODO introspector to any dorm buffer.", + callback = function(ev) + local buf = ev.buf + + if module.private.buffers[buf] then + return + end + + module.private.buffers[buf] = true + module.public.attach_introspector(buf) + end, + }) +end + +--- Attaches the introspector to a given dorm buffer. +--- Errors if the target buffer is not a dorm buffer. +---@param buffer number #The buffer ID to attach to. +function module.public.attach_introspector(buffer) + if not vim.api.nvim_buf_is_valid(buffer) or vim.bo[buffer].filetype ~= "dorm" then + error(string.format("Could not attach to buffer %d, buffer is not a dorm file!", buffer)) + end + + module.required["treesitter"].execute_query( + [[ + (_ + state: (detached_modifier_extension)) @item + ]], + function(query, id, node) + if query.captures[id] == "item" then + module.public.perform_introspection(buffer, node) + end + end, + buffer + ) + + vim.api.nvim_buf_attach(buffer, false, { + on_lines = vim.schedule_wrap(function(_, buf, _, first) + if not vim.api.nvim_buf_is_valid(buf) then + return + end + -- If we delete the last line of a file `first` will point to a nonexistent line + -- For this reason we fall back to the line count (accounting for 0-based indexing) + -- whenever a change to the document is made. + first = math.min(first, vim.api.nvim_buf_line_count(buf) - 1) + + ---@type TSNode? + local node = module.required["treesitter"].get_first_node_on_line(buf, first) + + if not node then + return + end + + vim.api.nvim_buf_clear_namespace(buffer, module.private.namespace, first + 1, first + 1) + + local function introspect(start_node) + local parent = start_node + + while parent do + local child = parent:named_child(1) + + if child and child:type() == "detached_modifier_extension" then + module.public.perform_introspection(buffer, parent) + -- NOTE: do not break here as we want the introspection to propagate all the way up the syntax tree + end + + parent = parent:parent() + end + end + + introspect(node) + + local node_above = module.required["treesitter"].get_first_node_on_line(buf, first - 1) + + do + local todo_status = node_above:named_child(1) + + if todo_status and todo_status:type() == "detached_modifier_extension" then + introspect(node_above) + end + end + end), + + on_detach = function() + vim.api.nvim_buf_clear_namespace(buffer, module.private.namespace, 0, -1) + module.private.buffers[buffer] = nil + end, + }) +end + +--- Aggregates TODO item counts from children. +---@param node TSNode +---@return number completed Total number of completed tasks +---@return number total Total number of counted tasks +function module.public.calculate_items(node) + local counts = {} + for _, status in ipairs(module.config.public.counted_statuses) do + counts[status] = 0 + end + + local total = 0 + + -- Go through all the children of the current todo item node and count the amount of "done" children + for child in node:iter_children() do + if child:named_child(1) and child:named_child(1):type() == "detached_modifier_extension" then + for status in child:named_child(1):iter_children() do + if status:type():match("^todo_item_") then + local type = status:type():match("^todo_item_(.+)$") + + if not counts[type] then + break + end + + counts[type] = counts[type] + 1 + total = total + 1 + end + end + end + end + + local completed = 0 + for _, status in ipairs(module.config.public.completed_statuses) do + if counts[status] then + completed = completed + counts[status] + end + end + + return completed, total +end + +--- Displays the amount of done items in the form of an extmark. +---@param buffer number +---@param node TSNode +function module.public.perform_introspection(buffer, node) + local completed, total = module.public.calculate_items(node) + + local line, col = node:start() + + vim.api.nvim_buf_clear_namespace(buffer, module.private.namespace, line, line + 1) + + if total == 0 then + return + end + + vim.api.nvim_buf_set_extmark(buffer, module.private.namespace, line, col, { + virt_text = { + { + module.config.public.format(completed, total), + module.config.public.highlight_group, + }, + }, + invalidate = true, + }) +end + +return module diff --git a/lua/dorm/mod/treesitter/module.lua b/lua/dorm/mod/treesitter/module.lua new file mode 100644 index 0000000..bf4093b --- /dev/null +++ b/lua/dorm/mod/treesitter/module.lua @@ -0,0 +1,991 @@ +--[[ + file: Treesitter-Integration + title: Snazzy Treesitter Integration + summary: A module designed to integrate Treesitter into dorm. + embed: https://user-images.githubusercontent.com/76052559/151668244-9805afc4-8c50-4925-85ec-1098aff5ede6.gif + internal: true + --- + +## Keybinds + +This module exposes the following keybinds (see [`core.keybinds`](@core.keybinds) for instructions on +mapping them): + +- `dorm.treesitter.next.heading` - jump to the next heading +- `dorm.treesitter.next.link` - jump to the next link +- `dorm.treesitter.previous.heading` - jump to the previous heading +- `dorm.treesitter.previous.link` - jump to the previous link +--]] + +local dorm = require("dorm") +local lib, log, modules, utils = dorm.lib, dorm.log, dorm.mod, dorm.utils + +local module = modules.create("treesitter") + +module.private = { + ts_utils = nil, + link_query = [[ + (link) @next-segment + (anchor_declaration) @next-segment + (anchor_definition) @next-segment + ]], + heading_query = [[ + [ + (heading1 + title: (paragraph_segment) @next-segment + ) + (heading2 + title: (paragraph_segment) @next-segment + ) + (heading3 + title: (paragraph_segment) @next-segment + ) + (heading4 + title: (paragraph_segment) @next-segment + ) + (heading5 + title: (paragraph_segment) @next-segment + ) + (heading6 + title: (paragraph_segment) @next-segment + ) + ] + ]], +} + +module.setup = function() + return { success = true, requires = { "hl" } } +end + +module.load = function() + local success, ts_utils = pcall(require, "nvim-treesitter.ts_utils") + + assert(success, "Unable to load nvim-treesitter.ts_utils :(") + + if module.config.public.configure_parsers then + -- luacheck: push ignore + + local parser_configs = require("nvim-treesitter.parsers").get_parser_configs() + + parser_configs.dorm = { + install_info = module.config.public.parser_configs.dorm, + } + + parser_configs.dorm_meta = { + install_info = module.config.public.parser_configs.dorm_meta, + } + + modules.await("cmd", function(dormcmd) + dormcmd.add_commands_from_table({ + ["sync-parsers"] = { + args = 0, + name = "sync-parsers", + }, + }) + end) + + -- luacheck: pop + + vim.api.nvim_create_autocmd("BufEnter", { + pattern = "*.dorm", + once = true, + callback = function() + module.public.parser_path = vim.api.nvim_get_runtime_file("parser/dorm.so", false)[1] + + if module.public.parser_path then + return + end + + if module.config.public.install_parsers then + require("nvim-treesitter.install").commands.TSInstallSync["run!"]("dorm", "dorm_meta") + module.public.parser_path = vim.api.nvim_get_runtime_file("parser/dorm.so", false)[1] + else + assert( + false, + "dorm's parser is not installed! Run `:dorm sync-parsers` to install it, then restart Neovim." + ) + end + end, + }) + end + + module.private.ts_utils = ts_utils + + vim.keymap.set( + "", + "(dorm.treesitter.next.heading)", + lib.wrap(module.public.goto_next_query_match, module.private.heading_query) + ) + vim.keymap.set( + "", + "(dorm.treesitter.next.link)", + lib.wrap(module.public.goto_next_query_match, module.private.link_query) + ) + vim.keymap.set( + "", + "(dorm.treesitter.previous.heading)", + lib.wrap(module.public.goto_previous_query_match, module.private.heading_query) + ) + vim.keymap.set( + "", + "(dorm.treesitter.previous.link)", + lib.wrap(module.public.goto_previous_query_match, module.private.link_query) + ) +end + +module.config.public = { + --- If true will auto-configure the parsers to use the recommended setup. + -- Set to false only if you know what you're doing, or if the setting messes + -- with your personal configuration. + configure_parsers = true, + --- If true will automatically install dorm parsers if they are not present. + install_parsers = true, + --- Configurations for each parser as required by `nvim-treesitter`. + -- If you would like to tweak your parser configs you may do so here. + parser_configs = { + -- Configuration for the mainline dorm parser. + dorm = { + url = "https://github.com/nvim-dorm/tree-sitter-dorm", + files = { "src/parser.c", "src/scanner.cc" }, + branch = "main", + revision = "6348056b999f06c2c7f43bb0a5aa7cfde5302712", + }, + -- Configuration for the metadata parser (used to parse the contents + -- of `@document.meta` blocks). + dorm_meta = { + url = "https://github.com/nvim-dorm/tree-sitter-dorm-meta", + files = { "src/parser.c" }, + branch = "main", + revision = "a479d1ca05848d0b51dd25bc9f71a17e0108b240", + }, + }, +} + +---@class treesitter +module.public = { + parser_path = nil, + --- Gives back an instance of `nvim-treesitter.ts_utils` + ---@return table #`nvim-treesitter.ts_utils` + get_ts_utils = function() + return module.private.ts_utils + end, + --- Jumps to the next match of a query in the current buffer + ---@param query_string string Query with `@next-segment` captures + goto_next_query_match = function(query_string) + local cursor = vim.api.nvim_win_get_cursor(0) + local line_number, col_number = cursor[1], cursor[2] + + local document_root = module.public.get_document_root(0) + + if not document_root then + return + end + local next_match_query = utils.ts_parse_query("dorm", query_string) + for id, node in next_match_query:iter_captures(document_root, 0, line_number - 1, -1) do + if next_match_query.captures[id] == "next-segment" then + local start_line, start_col = node:range() + -- start_line is 0-based; increment by one so we can compare it to the 1-based line_number + start_line = start_line + 1 + + -- Skip node if it's inside a closed fold + if not vim.tbl_contains({ -1, start_line }, vim.fn.foldclosed(start_line)) then + goto continue + end + + -- Find and go to the first matching node that starts after the current cursor position. + if (start_line == line_number and start_col > col_number) or start_line > line_number then + module.private.ts_utils.goto_node(node) ---@diagnostic disable-line -- TODO: type error workaround + return + end + end + + ::continue:: + end + end, + --- Jumps to the previous match of a query in the current buffer + ---@param query_string string Query with `@next-segment` captures + goto_previous_query_match = function(query_string) + local cursor = vim.api.nvim_win_get_cursor(0) + local line_number, col_number = cursor[1], cursor[2] + + local document_root = module.public.get_document_root(0) + + if not document_root then + return + end + local previous_match_query = utils.ts_parse_query("dorm", query_string) + local final_node = nil + + for id, node in previous_match_query:iter_captures(document_root, 0, 0, line_number) do + if previous_match_query.captures[id] == "next-segment" then + local start_line, _, _, end_col = node:range() + -- start_line is 0-based; increment by one so we can compare it to the 1-based line_number + start_line = start_line + 1 + + -- Skip node if it's inside a closed fold + if not vim.tbl_contains({ -1, start_line }, vim.fn.foldclosed(start_line)) then + goto continue + end + + -- Find the last matching node that ends before the current cursor position. + if start_line < line_number or (start_line == line_number and end_col < col_number) then + final_node = node + end + end + + ::continue:: + end + if final_node then + module.private.ts_utils.goto_node(final_node) ---@diagnostic disable-line -- TODO: type error workaround + end + end, + --- Gets all nodes of a given type from the AST + ---@param node_type string #The type of node to filter out + ---@param opts? table #A table of two options: `buf` and `ft`, for the buffer and format to use respectively. + get_all_nodes = function(node_type, opts) + local result = {} + opts = opts or {} + + if not opts.buf then + opts.buf = 0 + end + + if not opts.ft then + opts.ft = "dorm" + end + + -- Do we need to go through each tree? lol + vim.treesitter.get_parser(opts.buf, opts.ft):for_each_tree(function(tree) + table.insert(result, module.public.search_tree(tree, node_type)) + end) + + return vim.iter(result):flatten():totable() + end, + + ---Gets all nodes of a given type from the AST + ---@param node_type string #The type of node to filter out + ---@param path string path to the file to parse + ---@param filetype string? file type of the file or `dorm` if omitted + get_all_nodes_in_file = function(node_type, path, filetype) + path = vim.fs.normalize(path) + if not filetype then + filetype = "dorm" + end + + local contents = io.open(path, "r"):read("*a") + local tree = vim.treesitter.get_string_parser(contents, filetype):parse()[1] + if not (tree or tree.root) then + return {} + end + + return module.public.search_tree(tree, node_type) + end, + + search_tree = function(tree, node_type) + local result = {} + local root = tree:root() + + --- Recursively searches for a node of a given type + ---@param node TSNode #The starting point for the search + local function descend(node) + -- Iterate over all children of the node and try to match their type + for child, _ in node:iter_children() do + if child:type() == node_type then + table.insert(result, child) + else + -- If no match is found try descending further down the syntax tree + for _, child_node in ipairs(descend(child) or {}) do + table.insert(result, child_node) + end + end + end + end + + descend(root) + return result + end, + + --- Executes function callback on each child node of the root + ---@param callback function + ---@param ts_tree any #Optional syntax tree ---@diagnostic disable-line -- TODO: type error workaround + tree_map = function(callback, ts_tree) + local tree = ts_tree or vim.treesitter.get_parser(0, "dorm"):parse()[1] + + local root = tree:root() + + for child, _ in root:iter_children() do + callback(child) + end + end, + --- Executes callback on each child recursive + ---@param callback function Executes with each node as parameter, can return false to stop recursion + ---@param ts_tree any #Optional syntax tree ---@diagnostic disable-line -- TODO: type error workaround + tree_map_rec = function(callback, ts_tree) + local tree = ts_tree or vim.treesitter.get_parser(0, "dorm"):parse()[1] + + local root = tree:root() + + local function descend(start) + for child, _ in start:iter_children() do + local stop_descending = callback(child) + if not stop_descending then + descend(child) + end + end + end + + descend(root) + end, + get_node_text = function(node, source) + if not node then + return "" + end + + -- when source is the string contents of the file + if type(source) == "string" then + local _, _, start_bytes = node:start() + local _, _, end_bytes = node:end_() + return string.sub(source, start_bytes + 1, end_bytes) + end + + source = source or 0 + + local start_row, start_col = node:start() + local end_row, end_col = node:end_() + + local eof_row = vim.api.nvim_buf_line_count(source) + + if end_row >= eof_row then + end_row = eof_row - 1 + end_col = -1 + end + + if start_row >= eof_row then + return "" + end + + local lines = vim.api.nvim_buf_get_text(source, start_row, start_col, end_row, end_col, {}) + + return table.concat(lines, "\n") + end, + + --- Get the range of a TSNode as an LspRange + ---@param node TSNode + ---@return lsp.Range + node_to_lsp_range = function(node) + local start_line, start_col, end_line, end_col = node:range() + return { + start = { line = start_line, character = start_col }, + ["end"] = { line = end_line, character = end_col }, + } + end, + + --- Swap two nodes in the buffer. Ignores newlines at the end of the node + ---@param node1 TSNode + ---@param node2 TSNode + ---@param bufnr number + ---@param cursor_to_second boolean move the cursor to the start of the second node (default false) + swap_nodes = function(node1, node2, bufnr, cursor_to_second) + if not node1 or not node2 then + return + end + local range1 = module.public.node_to_lsp_range(node1) + local range2 = module.public.node_to_lsp_range(node2) + + local text1 = module.public.get_node_text(node1, bufnr) + local text2 = module.public.get_node_text(node2, bufnr) + + if not text1 or not text2 then + return + end + + text1 = vim.split(text1, "\n") + text2 = vim.split(text2, "\n") + + ---remove trailing blank lines from the text, and update the corresponding range appropriately + ---@param text string[] + ---@param range table + local function remove_trailing_blank_lines(text, range) + local end_line_offset = 0 + while text[#text] == "" do + text[#text] = nil + end_line_offset = end_line_offset + 1 + end + range["end"] = { + character = string.len(text[#text]), + line = range["end"].line - end_line_offset, + } + if #text == 1 then -- ie. start and end lines are equal + range["end"].character = range["end"].character + range.start.character + end + end + + remove_trailing_blank_lines(text1, range1) + remove_trailing_blank_lines(text2, range2) + + local edit1 = { range = range1, newText = table.concat(text2, "\n") } + local edit2 = { range = range2, newText = table.concat(text1, "\n") } + + vim.lsp.util.apply_text_edits({ edit1, edit2 }, bufnr, "utf-8") + + if cursor_to_second then + -- set jump location + vim.cmd("normal! m'") + + local char_delta = 0 + local line_delta = 0 + if + range1["end"].line < range2.start.line + or (range1["end"].line == range2.start.line and range1["end"].character <= range2.start.character) + then + line_delta = #text2 - #text1 + end + + if range1["end"].line == range2.start.line and range1["end"].character <= range2.start.character then + if line_delta ~= 0 then + --- why? + --correction_after_line_change = -range2.start.character + --text_now_before_range2 = #(text2[#text2]) + --space_between_ranges = range2.start.character - range1["end"].character + --char_delta = correction_after_line_change + text_now_before_range2 + space_between_ranges + --- Equivalent to: + char_delta = #text2[#text2] - range1["end"].character + + -- add range1.start.character if last line of range1 (now text2) does not start at 0 + if range1.start.line == range2.start.line + line_delta then + char_delta = char_delta + range1.start.character + end + else + char_delta = #text2[#text2] - #text1[#text1] + end + end + + vim.api.nvim_win_set_cursor( + vim.api.nvim_get_current_win(), + { range2.start.line + 1 + line_delta, range2.start.character + char_delta } + ) + end + end, + + --- Returns the first node of given type if present + ---@param type string #The type of node to search for + ---@param buf number #The buffer to search in + ---@param parent userdata #The node to start searching in + get_first_node = function(type, buf, parent) + if not buf then + buf = 0 + end + + local function iterate(parent_node) + for child, _ in parent_node:iter_children() do + if child:type() == type then + return child + end + end + end + + if parent then + return iterate(parent) + end + + vim.treesitter.get_parser(buf, "dorm"):for_each_tree(function(tree) + -- Iterate over all top-level children and attempt to find a match + return iterate(tree:root()) ---@diagnostic disable-line -- TODO: type error workaround + end) + end, + --- Recursively attempts to locate a node of a given type + ---@param type string #The type of node to look for + ---@param opts table #A table of two options: `buf` and `ft`, for the buffer and format respectively + ---@return any ---@diagnostic disable-line -- TODO: type error workaround + get_first_node_recursive = function(type, opts) + opts = opts or {} + local result + + if not opts.buf then + opts.buf = 0 + end + + if not opts.ft then + opts.ft = "dorm" + end + + -- Do we need to go through each tree? lol + vim.treesitter.get_parser(opts.buf, opts.ft):for_each_tree(function(tree) + -- Get the root for that tree + local root + if opts.parent then + root = opts.parent + else + root = tree:root() + end + + --- Recursively searches for a node of a given type + ---@param node TSNode #The starting point for the search + local function descend(node) + -- Iterate over all children of the node and try to match their type + for child, _ in node:iter_children() do ---@diagnostic disable-line -- TODO: type error workaround + if child:type() == type then + return child + else + -- If no match is found try descending further down the syntax tree + local descent = descend(child) + if descent then + return descent + end + end + end + + return nil + end + + result = result or descend(root) + end) + + return result + end, + --- Given a node this function will break down the AST elements and return the corresponding text for certain nodes + --- @param tag_node TSNode - a node of type tag/carryover_tag + --- @param throw boolean - when true, throw an error instead of logging and returning on failure + get_tag_info = function(tag_node, throw) + if + not tag_node + or not vim.tbl_contains( + { "ranged_tag", "ranged_verbatim_tag", "weak_carryover", "strong_carryover" }, + tag_node:type() + ) + then + return nil + end + + local start_row, start_column, end_row, end_column = tag_node:range() + + local attributes = {} + local resulting_name, params, content = {}, {}, {} + local content_start_column = 0 + + -- Iterate over all children of the tag node + for child, _ in tag_node:iter_children() do + -- If we are dealing with a weak/strong attribute set then parse that set + if vim.endswith(child:type(), "_carryover_set") then + for subchild in child:iter_children() do + if vim.endswith(subchild:type(), "_carryover") then + local meta = module.public.get_tag_info(subchild, throw) + + table.insert(attributes, meta) + end + end + elseif child:type() == "tag_name" then + -- If we're dealing with the tag name then append the text of the tag_name node to this table + table.insert(resulting_name, vim.split(module.public.get_node_text(child), "\n")[1]) ---@diagnostic disable-line -- TODO: type error workaround + elseif child:type() == "tag_parameters" then + table.insert(params, vim.split(module.public.get_node_text(child), "\n")[1]) ---@diagnostic disable-line -- TODO: type error workaround + elseif child:type() == "ranged_verbatim_tag_content" then + -- If we're dealing with tag content then retrieve that content + content = vim.split(module.public.get_node_text(child), "\n") ---@diagnostic disable-line -- TODO: type error workaround + _, content_start_column = child:range() + end + end + + for i, line in ipairs(content) do + if i == 1 then + if content_start_column < start_column then + local error_msg = string.format( + "Unable to query information about tag on line %d: content is indented less than tag start!", + start_row + 1 + ) + + if throw then + error(error_msg) + else + log.error(error_msg) + return nil + end + end + content[i] = string.rep(" ", content_start_column - start_column) .. line + else + content[i] = line:sub(1 + start_column) + end + end + + content[#content] = nil + + return { + name = table.concat(resulting_name, "."), + parameters = params, + content = content, + attributes = vim.fn.reverse(attributes), + start = { row = start_row, column = start_column }, + ["end"] = { row = end_row, column = end_column }, + } + end, + --- Gets the range of a given node + ---@param node userdata #The node to get the range of + ---@return { row_start: number, column_start: number, row_end: number, column_end: number } range + get_node_range = function(node) + if not node then + return { + row_start = 0, + column_start = 0, + row_end = 0, + column_end = 0, + } + end + + local rs, cs, re, ce = lib.when(type(node) == "table", function() + local brs, bcs, _, _ = node[1]:range() + local _, _, ere, ece = node[#node]:range() + return brs, bcs, ere, ece + end, function() + local a, b, c, d = node:range() ---@diagnostic disable-line -- TODO: type error workaround + return a, b, c, d + end) + + return { + row_start = rs, + column_start = cs, + row_end = re, + column_end = ce, + } + end, + --- Extracts the document root from the current document or from the string + ---@param src number|string The number of the buffer to extract or string with code (can be nil) + ---@param filetype string? #The filetype of the buffer or the string with code + ---@return TSNode? #The root node of the document + get_document_root = function(src, filetype) + filetype = filetype or "dorm" + + local parser + if type(src) == "string" then + parser = vim.treesitter.get_string_parser(src, filetype) + else + parser = vim.treesitter.get_parser(src or 0, filetype) + end + + local tree = parser:parse()[1] + + if not tree or not tree:root() then + return + end + + return tree:root() + end, + --- Attempts to find a parent of a node recursively + ---@param node userdata #The node to start at + ---@param types table|string #If `types` is a table, this function will attempt to match any of the types present in the table. + -- If the type is a string, the function will attempt to pattern match the `types` value with the node type. + find_parent = function(node, types) + local _node = node + + while _node do + if type(types) == "string" then + if _node:type():match(types) then ---@diagnostic disable-line -- TODO: type error workaround + return _node + end + elseif vim.tbl_contains(types, _node:type()) then ---@diagnostic disable-line -- TODO: type error workaround + return _node + end + + _node = _node:parent() ---@diagnostic disable-line -- TODO: type error workaround + end + end, + --- Retrieves the first node at a specific line + ---@param buf number #The buffer to search in (0 for current) + ---@param line number #The line number (0-indexed) to get the node from + -- the same line as `line`. + ---@param stop_type string|table? #Don't recurse to the provided type(s) + ---@return TSNode|nil #The first node on `line` + get_first_node_on_line = function(buf, line, stop_type) + if type(stop_type) == "string" then + stop_type = { stop_type } + end + + local document_root = module.public.get_document_root(buf) + + if not document_root then + return + end + + local first_char = (vim.api.nvim_buf_get_lines(buf, line, line + 1, true)[1] or ""):match("^(%s+)[^%s]") + first_char = first_char and first_char:len() or 0 + + local descendant = document_root:descendant_for_range(line, first_char, line, first_char + 1) ---@diagnostic disable-line -- TODO: type error workaround + + if not descendant then + return + end + + while + descendant:parent() + and (descendant:parent():start()) == line + and descendant:parent():symbol() ~= document_root:symbol() ---@diagnostic disable-line -- TODO: type error workaround + do + local parent = descendant:parent() + + if parent and stop_type and vim.tbl_contains(stop_type, parent:type()) then + break + end + + descendant = parent + end + + return descendant + end, + + ---get document's metadata + ---@param source number | string | PathlibPath + ---@param no_trim boolean? + ---@return table? + get_document_metadata = function(source, no_trim) + source = source or 0 + + local dorm_parser, iter_src = module.public.get_ts_parser(source) + if not dorm_parser then + return + end + + local dorm_tree = dorm_parser:parse()[1] + if not dorm_tree then + return + end + + local function trim(value) + return no_trim and value or vim.trim(value) + end + + local result = {} + local function parse_data(node, src) + return lib.match(node:type())({ + string = function() + return trim(module.public.get_node_text(node, src)) + end, + number = function() + return tonumber(module.public.get_node_text(node, src)) + end, + array = function() + local resulting_array = {} + + for child in node:iter_children() do + if child:named() then + local parsed_data = parse_data(child, src) + + if parsed_data then + table.insert(resulting_array, parsed_data) + end + end + end + + return resulting_array + end, + object = function() + local resulting_object = {} + + for child in node:iter_children() do + if not child:named() or child:type() ~= "pair" then + goto continue + end + + local key = child:named_child(0) + local value = child:named_child(1) + + if not key then + goto continue + end + + local key_content = trim(module.public.get_node_text(key, src)) + + resulting_object[key_content] = (value and parse_data(value, src) or vim.NIL) + + ::continue:: + end + + return resulting_object + end, + }) + end + + local dorm_query = utils.ts_parse_query( + "dorm", + [[ + (document + (ranged_verbatim_tag + (tag_name) @tag_name + (ranged_verbatim_tag_content) @tag_content + ) + ) + ]] + ) + + local meta_query = utils.ts_parse_query( + "dorm_meta", + [[ + (metadata + (pair + (key) @key + (value) @value + ) + ) + ]] + ) + + local meta_node + for id, node in dorm_query:iter_captures(dorm_tree:root(), iter_src) do + if dorm_query.captures[id] == "tag_name" then + local tag_name = trim(module.public.get_node_text(node, iter_src)) + if tag_name == "document.meta" then + meta_node = node:next_named_sibling() or vim.NIL + break + end + end + end + + if not meta_node then + return result + end + + local meta_source = module.public.get_node_text(meta_node, iter_src) + + local dorm_meta_parser = vim.treesitter.get_string_parser(meta_source, "dorm_meta") + + local dorm_meta_tree = dorm_meta_parser:parse()[1] + + if not dorm_meta_tree then + return + end + + for id, node in meta_query:iter_captures(dorm_meta_tree:root(), meta_source) do + if meta_query.captures[id] == "key" then + local key = trim(module.public.get_node_text(node, meta_source)) + + local val + if key == "title" then + -- force title's value as string type + val = trim(module.public.get_node_text(node:next_named_sibling(), meta_source)) + else + val = node:next_named_sibling() and parse_data(node:next_named_sibling(), meta_source) or vim.NIL + end + + result[key] = val + end + end + + return result + end, + --- Parses a query and automatically executes it for dorm + ---@param query_string string #The query string + ---@param callback function #The callback to execute with all values returned by + ---`Query:iter_captures()`. When callback returns true, this function returns early + ---@param source number | string | PathlibPath #buf number, or file path or 0 for current buffer + ---@param start number? #The start line for the query + ---@param finish number? #The end line for the query + execute_query = function(query_string, callback, source, start, finish) + local query = utils.ts_parse_query("dorm", query_string) + local dorm_parser, iter_src = module.public.get_ts_parser(source) + + if not dorm_parser then + return false + end + + local root = dorm_parser:parse()[1]:root() + for id, node, metadata in query:iter_captures(root, iter_src, start, finish) do + if callback(query, id, node, metadata) == true then + return true + end + end + + return true + end, + + ---Create a dorm TS parser from the given source + ---@param source string | number | PathlibPath file path or buf number or 0 for current buffer + ---@return vim.treesitter.LanguageTree? dorm_parser + ---@return string | number iter_src the corresponding source that you must pass to + ---`iter_query()`, either the full file text, or the buffer number + get_ts_parser = function(source) + local dorm_parser + local iter_src + if type(source) ~= "string" and type(source) ~= "number" then + source = tostring(source) + end + if type(source) == "string" then + -- check if the file is open; use the buffer contents if it is + if vim.fn.bufnr(source) ~= -1 then ---@diagnostic disable-line + source = vim.uri_to_bufnr(vim.uri_from_fname(source)) + else + iter_src = io.open(source, "r"):read("*a") + dorm_parser = vim.treesitter.get_string_parser(iter_src, "dorm") + end + end + if type(source) == "number" then + if source == 0 then + source = vim.api.nvim_get_current_buf() + end + dorm_parser = vim.treesitter.get_parser(source, "dorm") + iter_src = source + end + + return dorm_parser, iter_src + end, +} + +-- this fixes the problem of installing dorm ts parsers on macOS without resorting to using gcc +local function install_dorm_ts() + local install = require("nvim-treesitter.install") + + if vim.fn.has("macunix") == 1 then + -- https://github.com/nvim-dorm/tree-sitter-dorm/issues/7 + -- (we have to force clang to c++11 mode on macOS manually) + + local shell = require("nvim-treesitter.shell_command_selectors") + + -- save the original functions + local select_executable = shell.select_executable + local compilers = install.compilers + + -- temporarily patch treesitter install logic + local cc = "clang++ -std=c++11" + ---@diagnostic disable-next-line: duplicate-set-field + shell.select_executable = function(executables) + return vim.tbl_filter(function(c) ---@param c string + return c ~= vim.NIL and (vim.fn.executable(c) == 1 or c == cc) + end, executables)[1] + end + install.compilers = { cc } + + -- install dorm parsers + local ok, err = pcall(function() + install.commands.TSInstallSync["run!"]("dorm") + end) + + -- no matter what, restore the defaults back + shell.select_executable = select_executable + install.compilers = compilers + + -- if an error occurred during install, propagate it up + if not ok then + error(err) + end + else + install.commands.TSInstallSync["run!"]("dorm") + end +end + +module.on_event = function(event) + if event.split_type[2] == "sync-parsers" then + local ok, err = pcall(install_dorm_ts) + + if not ok then + utils.notify(string.format([[Unable to auto-install dorm parser: %s]], err), vim.log.levels.WARN) + end + + local install = require("nvim-treesitter.install") + install.commands.TSInstallSync["run!"]("dorm_meta") + end +end + +module.events.subscribed = { + ["cmd"] = { + ["sync-parsers"] = true, + }, +} + +return module diff --git a/lua/dorm/mod/ui/module.lua b/lua/dorm/mod/ui/module.lua new file mode 100644 index 0000000..a44ca32 --- /dev/null +++ b/lua/dorm/mod/ui/module.lua @@ -0,0 +1,428 @@ +--[[ + File: base-UI + Title: Module for managing and displaying UIs to the user. + Summary: A set of public functions to help developers create and manage UI (selection popups, prompts...) in their mod. + Internal: true + --- +--]] + +local dorm = require("dorm") +local log, mod = dorm.log, dorm.mod + +local module = mod.create("ui", { + "selection_popup", + "text_popup", +}) + +module.setup = function() + for _, imported in pairs(module.imported) do + module.public = vim.tbl_extend("force", module.public, imported.public) + end + + return {} +end + +module.private = { + namespace = vim.api.nvim_create_namespace("ui"), +} + +---@class base.ui +module.public = { + --- Returns a table in the form of { width, height } containing the width and height of the current window + ---@param half boolean #If true returns a position that could be considered the center of the window + get_window_size = function(half) + return half + and { + math.floor(vim.fn.winwidth(0) / 2), + math.floor(vim.fn.winheight(0) / 2), + } + or { vim.fn.winwidth(0), vim.fn.winheight(0) } + end, + + --- Returns a modified version of floating window options. + ---@param modifiers table #This option set has two values - center_x and center_y. + -- If they either of them is set to true then the window gets centered on that axis. + ---@param config table #A table containing regular Neovim options for a floating window + apply_custom_options = function(modifiers, config) + -- base modifier options + local user_options = { + center_x = false, + center_y = false, + } + + -- Override the base options with the user provided options + user_options = vim.tbl_extend("force", user_options, modifiers or {}) + + -- Assign some base values to certain config options in case they're not specified + config = vim.tbl_deep_extend("keep", config, { + relative = "win", + row = 0, + col = 0, + width = 100, + height = 100, + }) + + -- Get the current window's dimensions except halved + local halved_window_size = module.public.get_window_size(true) + + -- If we want to center along the x axis then return a configuration that does so + if user_options.center_x then + config.row = config.row + halved_window_size[2] - math.floor(config.height / 2) + end + + -- If we want to center along the y axis then return a configuration that does so + if user_options.center_y then + config.col = config.col + halved_window_size[1] - math.floor(config.width / 2) + end + + return config + end, + + --- Applies a set of options to a buffer + ---@param buf number the buffer number to apply the options to + ---@param option_list table a table of option = value pairs + apply_buffer_options = function(buf, option_list) + for option_name, value in pairs(option_list or {}) do + vim.api.nvim_set_option_value(option_name, value, { buf = buf }) + end + end, + + ---Creates a new horizontal split at the bottom of the screen + ---@param name string the name of the buffer contained within the split (will have dorm:// prepended to it) + ---@param config table? a table of