diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..df536b1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,54 @@ +name: Bug Report +description: File a bug/issue +title: "bug: " +labels: [bug] +body: + - type: markdown + attributes: + value: | + **Before** reporting an issue, make sure to read the documentation and search existing issues. Usage questions such as ***"How do I...?"*** belong in Discussions and will be closed. + - type: checkboxes + attributes: + label: Did you check docs and existing issues? + description: Make sure you checked all of the below before submitting an issue + options: + - label: I have read all the plugin docs + required: true + - label: I have searched the existing issues + required: true + - label: I have searched the existing issues of plugins related to this issue + required: true + - type: input + attributes: + label: "Neovim version (nvim -v)" + placeholder: "0.8.0 commit db1b0ee3b30f" + validations: + required: true + - type: input + attributes: + label: "Operating system/version" + placeholder: "MacOS 11.5" + validations: + required: true + - type: textarea + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. Please include any related errors you see in Neovim. + validations: + required: true + - type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. + 2. + 3. + validations: + required: true + - type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..f8ce0f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,36 @@ +name: Feature Request +description: Suggest a new feature +title: "feature: " +labels: [enhancement] +body: + - type: checkboxes + attributes: + label: Did you check the docs? + description: Make sure you read all the docs before submitting a feature request + options: + - label: I have read all the docs + required: true + - type: textarea + validations: + required: true + attributes: + label: Is your feature request related to a problem? Please describe. + description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + - type: textarea + validations: + required: true + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + - type: textarea + validations: + required: true + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + - type: textarea + validations: + required: false + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..0b8d187 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,25 @@ +on: + push: + branches: + - main +name: docs + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: panvimdoc + uses: kdheepak/panvimdoc@main + with: + vimdoc: my-template-docs + version: "Neovim >= 0.8.0" + demojify: true + treesitter: true + - name: Push changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: "auto-generate vimdoc" + commit_user_name: "github-actions[bot]" + commit_user_email: "github-actions[bot]@users.noreply.github.com" + commit_author: "github-actions[bot] " diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml new file mode 100644 index 0000000..19a56eb --- /dev/null +++ b/.github/workflows/lint-test.yml @@ -0,0 +1,33 @@ +--- +on: [push, pull_request] +name: lint-test + +jobs: + stylua: + name: stylua + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: JohnnyMorganz/stylua-action@v3 + with: + version: latest + token: ${{ secrets.GITHUB_TOKEN }} + args: --color always --check lua + + test: + runs-on: ubuntu-latest + strategy: + matrix: + nvim-versions: ['stable', 'nightly'] + name: test + steps: + - name: checkout + uses: actions/checkout@v3 + + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: ${{ matrix.nvim-versions }} + + - name: run tests + run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..05db001 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,14 @@ +name: "release" +on: + push: + tags: + - 'v*' +jobs: + luarocks-upload: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - name: LuaRocks Upload + uses: nvim-neorocks/luarocks-tag-release@v4 + env: + LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..238d11c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +vendor/plenary.nvim diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..0fd4cb5 --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,6 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +no_call_parentheses = false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..83dc276 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Ellison + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..54bc132 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +TESTS_INIT=tests/minimal_init.lua +TESTS_DIR=tests/ + +.PHONY: test + +test: + @nvim \ + --headless \ + --noplugin \ + -u ${TESTS_INIT} \ + -c "PlenaryBustedDirectory ${TESTS_DIR} { minimal_init = '${TESTS_INIT}' }" diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e3a28c --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# A Neovim Plugin Template + +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/ellisonleao/nvim-plugin-template/lint-test.yml?branch=main&style=for-the-badge) +![Lua](https://img.shields.io/badge/Made%20with%20Lua-blueviolet.svg?style=for-the-badge&logo=lua) + +A template repository for Neovim plugins. + +## Using it + +Via `gh`: + +``` +$ gh repo create my-plugin -p ellisonleao/nvim-plugin-template +``` + +Via github web page: + +Click on `Use this template` + +![](https://docs.github.com/assets/cb-36544/images/help/repository/use-this-template-button.png) + +## Features and structure + +- 100% Lua +- Github actions for: + - running tests using [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) and [busted](https://olivinelabs.com/busted/) + - check for formatting errors (Stylua) + - vimdocs autogeneration from README.md file + - luarocks release (LUAROCKS_API_KEY secret configuration required) + +### Plugin structure + +``` +. +├── lua +│   ├── plugin_name +│   │   └── module.lua +│   └── plugin_name.lua +├── Makefile +├── plugin +│   └── plugin_name.lua +├── README.md +├── tests +│   ├── minimal_init.lua +│   └── plugin_name +│   └── plugin_name_spec.lua +``` diff --git a/doc/my-template-docs.txt b/doc/my-template-docs.txt new file mode 100644 index 0000000..5f5501c --- /dev/null +++ b/doc/my-template-docs.txt @@ -0,0 +1,70 @@ +*my-template-docs.txt* For Neovim >= 0.8.0 Last change: 2024 January 18 + +============================================================================== +Table of Contents *my-template-docs-table-of-contents* + +1. A Neovim Plugin Template |my-template-docs-a-neovim-plugin-template| + - Using it |my-template-docs-a-neovim-plugin-template-using-it| + - Features and structure|my-template-docs-a-neovim-plugin-template-features-and-structure| + +============================================================================== +1. A Neovim Plugin Template *my-template-docs-a-neovim-plugin-template* + + + +A template repository for Neovim plugins. + + +USING IT *my-template-docs-a-neovim-plugin-template-using-it* + +Via `gh` + +> + $ gh repo create my-plugin -p ellisonleao/nvim-plugin-template +< + +Viagithub web page: + +Click on `Use this template` + + + + +FEATURES AND STRUCTURE*my-template-docs-a-neovim-plugin-template-features-and-structure* + +- 100% Lua +- Github actions for: + - running tests using plenary.nvim and busted + - check for formatting errors (Stylua) + - vimdocs autogeneration from README.md file + - luarocks release (LUAROCKS_API_KEY secret configuration required) + + +PLUGIN STRUCTURE ~ + +> + . + ├── lua + │   ├── plugin_name + │   │   └── module.lua + │   └── plugin_name.lua + ├── Makefile + ├── plugin + │   └── plugin_name.lua + ├── README.md + ├── tests + │   ├── minimal_init.lua + │   └── plugin_name + │   └── plugin_name_spec.lua +< + +============================================================================== +2. Links *my-template-docs-links* + +1. *GitHub Workflow Status*: https://img.shields.io/github/actions/workflow/status/ellisonleao/nvim-plugin-template/lint-test.yml?branch=main&style=for-the-badge +2. *Lua*: https://img.shields.io/badge/Made%20with%20Lua-blueviolet.svg?style=for-the-badge&logo=lua +3. **: https://docs.github.com/assets/cb-36544/images/help/repository/use-this-template-button.png + +Generated by panvimdoc + +vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/lua/neolog.lua b/lua/neolog.lua new file mode 100644 index 0000000..f4c08ef --- /dev/null +++ b/lua/neolog.lua @@ -0,0 +1,25 @@ +local actions = require("neolog.actions") + +---@alias NeologLogTemplates { [string]: string } + +---@class Config +---@field log_templates NeologLogTemplates +local default_config = { + log_templates = { + typescript = [[console.log("%label", %identifier)]], + tsx = [[console.log("%label", %identifier)]], + }, +} + +---@class MyModule +---@field config Config +local M = {} + +---@param config Config? +M.setup = function(config) + M.config = vim.tbl_deep_extend("force", default_config, config or {}) + + actions.setup(M.config.log_templates) +end + +return M diff --git a/lua/neolog/actions.lua b/lua/neolog/actions.lua new file mode 100644 index 0000000..d96024d --- /dev/null +++ b/lua/neolog/actions.lua @@ -0,0 +1,215 @@ +---@class NeologActions +--- @field log_templates NeologLogTemplates +local M = {} + +local utils = require("neolog.utils") +local ts_utils = require("nvim-treesitter.ts_utils") + +--- Build the log label from template. Support special placeholers: +--- %identifier: the identifier text +--- %fn_name: the enclosing function name. If there's none, replaces with empty string +--- %line_number: the line_number number +---@param label_template string +---@param log_target_node TSNode +---@return string +local function build_log_label(label_template, log_target_node) + local label = label_template + + if string.find(label, "%%identifier") then + local bufnr = vim.api.nvim_get_current_buf() + local identifier_text = vim.treesitter.get_node_text(log_target_node, bufnr) + label = string.gsub(label, "%%identifier", identifier_text) + end + + if string.find(label, "%%line_number") then + local start_row = log_target_node:start() + label = string.gsub(label, "%%line_number", start_row + 1) + end + + return label +end + +---@param label_text string +---@param identifier_text string +---@return string +local function build_log_statement(label_text, identifier_text) + local lang = vim.treesitter.language.get_lang(vim.bo.filetype) + local template = M.log_templates[lang] + + template = string.gsub(template, "%%label", label_text) + template = string.gsub(template, "%%identifier", identifier_text) + + return template +end + +---@param line_number number +local function indent_line_number(line_number) + local current_pos = vim.api.nvim_win_get_cursor(0) + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) + vim.cmd("normal! ==") + vim.api.nvim_win_set_cursor(0, current_pos) +end + +---@alias log_placement "inner" | "outer" +---@param label_template string +---@param log_target_node TSNode +---@param container_node TSNode +---@param position "above" | "below" +---@param log_placement log_placement +local function insert_log_statement(label_template, log_target_node, container_node, position, log_placement) + local bufnr = vim.api.nvim_get_current_buf() + local identifier_text = vim.treesitter.get_node_text(log_target_node, bufnr) + + local insert_line + if log_placement == "inner" then + insert_line = container_node:start() + 1 + else + insert_line = position == "above" and container_node:start() or container_node:end_() + 1 + end + + local log_label = build_log_label(label_template, log_target_node) + + vim.api.nvim_buf_set_lines( + bufnr, + insert_line, + insert_line, + false, + { build_log_statement(log_label, identifier_text) } + ) + + indent_line_number(insert_line + 1) +end + +---@param query vim.treesitter.Query +---@param target_log_node TSNode +---@param match TSNode +---@return TSNode? +local function get_container_node(query, target_log_node, match, metadata) + local container_capture = match[utils.get_key_by_value(query.captures, "container")] + + if container_capture then + return container_capture + else + -- comma separated list of container types + local container_types = vim.split(metadata.container_type, ",") + + -- Traverse up the tree to find the container node + ---@type TSNode? + local current = target_log_node + repeat + current = current and current:parent() + until current == nil or utils.array_includes(container_types, current:type()) + + return current + end +end + +local LANGUAGE_SPEC = { + typescript = { + identifier = "identifier", + container = { "lexical_declaration", "return_statement", "expression_statement" }, + }, + tsx = { + identifier = "identifier", + container = { "lexical_declaration", "return_statement", "expression_statement" }, + }, +} + +--- Traverse up the tree from the current node to find the container node +---@return boolean, {[1]: TSNode, [2]: TSNode, [3]: log_placement}? +function M.get_container_node_fallback() + local lang = vim.treesitter.language.get_lang(vim.bo.filetype) + local spec = LANGUAGE_SPEC[lang] + if not spec then + return false + end + + local current_node = ts_utils.get_node_at_cursor() + + if current_node:type() ~= spec.identifier then + return false + end + + local log_target_node = current_node + + -- Traverse up the tree to find the container node + ---@type TSNode? + local log_container_node = current_node + repeat + log_container_node = log_container_node and log_container_node:parent() + until log_container_node == nil or require("custom.utils").array_includes(spec.container, log_container_node:type()) + + return true, { log_target_node, log_container_node, "outer" } +end + +--- Add log statement for the current identifier at the cursor +--- @param label_template string +--- @param position "above" | "below" +function M.add_log(label_template, position) + local lang = vim.treesitter.language.get_lang(vim.bo.filetype) + if not lang then + vim.notify("Cannot determine language for current buffer", vim.log.levels.ERROR) + return + end + + local query = vim.treesitter.query.get(lang, "neolog") + if not query then + vim.notify(string.format("logging_framework doesn't support %s language", lang), vim.log.levels.ERROR) + return + end + + local template = M.log_templates[lang] + if not template then + vim.notify(string.format("Log template for %s language is not found", lang), vim.log.levels.ERROR) + return + end + + local bufnr = vim.api.nvim_get_current_buf() + local parser = vim.treesitter.get_parser(bufnr, lang) + local tree = parser:parse()[1] + local root = tree:root() + + local cursor_pos = vim.api.nvim_win_get_cursor(0) + + local log_target_node + local log_container_node + local log_placement + + for _, match, metadata in query:iter_matches(root, bufnr, 0, -1) do + local log_target_capture = match[utils.get_key_by_value(query.captures, "log_target")] + + if log_target_capture then + local cursor_range = { cursor_pos[1] - 1, cursor_pos[2], cursor_pos[1] - 1, cursor_pos[2] } + + if vim.treesitter.node_contains(log_target_capture, cursor_range) then + log_container_node = get_container_node(query, log_target_capture, match, metadata) + log_placement = metadata.log_placement or "outer" + log_target_node = log_target_capture + + break + end + end + end + + if not log_container_node then + local success, result = M.get_container_node_fallback() + if success and result then + log_target_node = result[1] + log_container_node = result[2] + log_placement = result[3] + end + end + + if log_container_node and log_placement then + insert_log_statement(label_template, log_target_node, log_container_node, position, log_placement) + else + vim.notify("Cursor is not inside a valid log target", vim.log.levels.INFO) + end +end + +---@param templates NeologLogTemplates +function M.setup(templates) + M.log_templates = templates +end + +return M diff --git a/lua/neolog/utils.lua b/lua/neolog/utils.lua new file mode 100644 index 0000000..58331f2 --- /dev/null +++ b/lua/neolog/utils.lua @@ -0,0 +1,23 @@ +local M = {} + +function M.array_includes(array, value) + for _, v in ipairs(array) do + if v == value then + return true + end + end + + return false +end + +function M.get_key_by_value(t, value) + for k, v in pairs(t) do + if v == value then + return k + end + end + + return nil +end + +return M diff --git a/queries/tsx/neolog.scm b/queries/tsx/neolog.scm new file mode 100644 index 0000000..1b61e36 --- /dev/null +++ b/queries/tsx/neolog.scm @@ -0,0 +1 @@ +; inherits: typescript diff --git a/queries/typescript/neolog.scm b/queries/typescript/neolog.scm new file mode 100644 index 0000000..59bd27f --- /dev/null +++ b/queries/typescript/neolog.scm @@ -0,0 +1,65 @@ +; identifier is the log target + +; const idenifier = foo +(lexical_declaration + (variable_declarator + name: (identifier) @log_target + ) +) @container + +; const { identifier: foo } = bar +(lexical_declaration + (variable_declarator + name: (object_pattern + (pair_pattern + value: (identifier) @log_target + ) + ) + ) +) @container + +; const { identifier } = foo +(lexical_declaration + (variable_declarator + name: (object_pattern + (shorthand_property_identifier_pattern) @log_target + ) + ) +) @container + +; const [identifier] = foo +(lexical_declaration + (variable_declarator + name: (array_pattern + (identifier) @log_target + ) + ) +) @container + +; function foo(identifier: T) +(formal_parameters + ([ + (required_parameter + pattern: (identifier) @log_target) + (optional_parameter + pattern: (identifier) @log_target) + ]) +) @container + +; function foo(identifier: T) +(formal_parameters + (required_parameter + pattern: (object_pattern + (shorthand_property_identifier_pattern) @log_target + ) + ) +) @container + +; catch(error) {} +( + (catch_clause + parameter: (identifier) @log_target + body: (statement_block) @container + ) + (#set! log_placement "inner") +) diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..f7313c3 --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,23 @@ +local plenary_dir = os.getenv("PLENARY_DIR") or "/tmp/plenary.nvim" + +if vim.fn.isdirectory(plenary_dir) == 0 then + vim.fn.system({ "git", "clone", "https://github.com/nvim-lua/plenary.nvim", plenary_dir }) +end + +local nvim_treesitter_dir = "/tmp/nvim-treesitter" +if vim.fn.isdirectory(nvim_treesitter_dir) == 0 then + vim.fn.system({ "git", "clone", "https://github.com/nvim-treesitter/nvim-treesitter.git", nvim_treesitter_dir }) +end + +vim.opt.rtp:append(".") +vim.opt.rtp:append(plenary_dir) +vim.opt.rtp:append(nvim_treesitter_dir) + +vim.cmd("runtime plugin/plenary.vim") +require("plenary.busted") + +require("nvim-treesitter.configs").setup({ + ensure_installed = { "typescript", "tsx" }, + sync_install = true, + auto_install = false, +}) diff --git a/tests/neolog/neolog_spec.lua b/tests/neolog/neolog_spec.lua new file mode 100644 index 0000000..a52afce --- /dev/null +++ b/tests/neolog/neolog_spec.lua @@ -0,0 +1,105 @@ +local assert = require("luassert") +local neolog = require("neolog") + +local function setup_buffer(input, filetype) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_option_value("filetype", filetype, { buf = buf }) + vim.api.nvim_command("buffer " .. buf) + vim.api.nvim_buf_set_lines(0, 0, -1, true, vim.split(input, "\n")) +end + +local assert_buf_output = function(expected) + local output = vim.api.nvim_buf_get_lines(0, 0, -1, false) + assert.are.same(vim.split(expected, "\n"), output) +end + +describe("neolog", function() + it("provides default config", function() + neolog.setup() + local actions = require("neolog.actions") + + local input = [[ +const foo = "bar" +]] + + local expected = [[ +const foo = "bar" +console.log("foo", foo) +]] + + setup_buffer(input, "typescript") + + vim.api.nvim_win_set_cursor(0, { 1, 7 }) + actions.add_log("%identifier", "below") + + assert_buf_output(expected) + end) + + it("allows customize log templates", function() + neolog.setup({ + log_templates = { typescript = [[console.log("%label bar 123", %identifier)]] }, + }) + local actions = require("neolog.actions") + + local input = [[ +const foo = "bar" +]] + + local expected = [[ +const foo = "bar" +console.log("foo bar 123", foo) +]] + + setup_buffer(input, "typescript") + + vim.api.nvim_win_set_cursor(0, { 1, 7 }) + actions.add_log("%identifier", "below") + + assert_buf_output(expected) + end) +end) + +describe("neolog.actions", function() + describe("label template", function() + it("supports %line_number", function() + neolog.setup() + local actions = require("neolog.actions") + + local input = [[ +// Comment +const foo = "bar" +]] + + local expected = [[ +// Comment +const foo = "bar" +console.log("2 foo", foo) +]] + + setup_buffer(input, "typescript") + vim.api.nvim_win_set_cursor(0, { 2, 7 }) + actions.add_log("%line_number %identifier", "below") + assert_buf_output(expected) + end) + end) + + describe("typescript", function() + it("logs the identifier under the cursor", function() + local actions = require("neolog.actions") + + local input = [[ +const foo = "bar" +]] + + local expected = [[ +const foo = "bar" +console.log("foo", foo) +]] + + setup_buffer(input, "typescript") + vim.api.nvim_win_set_cursor(0, { 1, 7 }) + actions.add_log("%identifier", "below") + assert_buf_output(expected) + end) + end) +end)