diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..3e4c9291 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +--- +on: [push, pull_request] +name: test + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + nvim-versions: ['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/Makefile b/Makefile index 12393b0a..08a23875 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,17 @@ +TESTS_INIT=tests/minimal_init.lua +TESTS_DIR=tests/ + +.PHONY: test + documentation: nvim --headless --noplugin -u ./scripts/minimal_init.vim -c "lua MiniDoc.generate()" -c "qa!" tag: ./scripts/generate_tag.sh + +test: + @nvim \ + --headless \ + --noplugin \ + -u ${TESTS_INIT} \ + -c "PlenaryBustedDirectory ${TESTS_DIR} { minimal_init = '${TESTS_INIT}' }" diff --git a/doc/neogen.txt b/doc/neogen.txt index cd135a77..76aa897d 100644 --- a/doc/neogen.txt +++ b/doc/neogen.txt @@ -183,12 +183,15 @@ Feel free to submit a PR, I will be happy to help you ! We use semantic versioning ! (https://semver.org) Here is the current Neogen version: > - neogen.version = "2.17.1" + neogen.version = "2.18.0" < # Changelog~ Note: We will only document `major` and `minor` versions, not `patch` ones. (only X and Y in X.Y.z) +## 2.18.0~ + - Added plenary unittests to the repository + - Added Python + Google-style docstrings ## 2.17.1~ - Python raises now supports `raise foo.Bar()` syntax ## 2.17.0~ diff --git a/lua/neogen/configurations/python.lua b/lua/neogen/configurations/python.lua index e10fe2ef..1410fe98 100644 --- a/lua/neogen/configurations/python.lua +++ b/lua/neogen/configurations/python.lua @@ -247,8 +247,8 @@ return { end if nodes[i.Return] then - validate_bare_returns(nodes) validate_direct_returns(nodes, node) + validate_bare_returns(nodes) end validate_yield_nodes(nodes) diff --git a/lua/neogen/init.lua b/lua/neogen/init.lua index b3febecb..f404156d 100644 --- a/lua/neogen/init.lua +++ b/lua/neogen/init.lua @@ -305,7 +305,7 @@ end --- with multiple annotation conventions. ---@tag neogen-changelog ---@toc_entry Changes in neogen plugin -neogen.version = "2.17.1" +neogen.version = "2.18.0" --minidoc_afterlines_end return neogen diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 00000000..0fd86c65 --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,27 @@ +local plugins = { + ["https://github.com/nvim-treesitter/nvim-treesitter"] = os.getenv("NVIM_TREESITTER_DIRECTORY") + or "/tmp/nvim_treesitter", + ["https://github.com/nvim-lua/plenary.nvim"] = os.getenv("PLENARY_DIRECTORY") or "/tmp/plenary.nvim", +} + +local tests_directory = vim.fn.fnamemodify(vim.fn.expand(""), ":h") +local plugin_root = vim.fn.fnamemodify(tests_directory, ":h") +vim.opt.runtimepath:append(plugin_root) + +for url, directory in pairs(plugins) do + if vim.fn.isdirectory(directory) == 0 then + vim.fn.system({ "git", "clone", url, directory }) + end + + vim.opt.runtimepath:append(directory) +end + +vim.cmd("runtime plugin/plenary.vim") +require("plenary.busted") + +vim.cmd("runtime plugin/nvim-treesitter.lua") + +-- Some tests require the Python parser +vim.cmd([[TSInstallSync! python]]) + +require("neogen").setup({ snippet_engine = "nvim" }) diff --git a/tests/neogen/python_google_spec.lua b/tests/neogen/python_google_spec.lua new file mode 100644 index 00000000..5af4668a --- /dev/null +++ b/tests/neogen/python_google_spec.lua @@ -0,0 +1,849 @@ +--- Make sure Python docstrings generate as expected. +--- +--- @module 'tests.neogen.python_spec' + +local textmate = require("tests.textmate") +local neogen = require("neogen") + +--- Make a Python docstring and return the `neogen` result. +---@param source string Pseudo-Python source-code to call. It contains `"|cursor|"` which is the expected user position. +---@return string? # The same source code, after calling neogen. +local function _make_python_docstring(source) + local result = textmate.extract_cursors(source) + + if not result then + vim.notify( + string.format("Source\n\n%s\n\nwas not parsable. Does it have a |cursor| defined?", source), + vim.log.levels.ERROR + ) + + return nil + end + + local cursors, code = unpack(result) + + local buffer = vim.api.nvim_create_buf(true, true) + vim.bo[buffer].filetype = "python" + vim.cmd.buffer(buffer) + local window = vim.fn.win_getid() -- We just created the buffer so the current window works + + local strict_indexing = false + vim.api.nvim_buf_set_lines(buffer, 0, -1, strict_indexing, vim.fn.split(code, "\n")) + + -- IMPORTANT: Because we are adding docstrings in the buffer, we must start + -- from the bottom docstring up. Otherwise the row/column positions of the + -- next `cursor` in `cursors` will be out of date. + for index = #cursors, 1, -1 do + local cursor = cursors[index] + vim.api.nvim_win_set_cursor(window, cursor) + + neogen.generate({ snippet_engine = "nvim" }) + end + + return table.concat(vim.api.nvim_buf_get_lines(buffer, 0, -1, strict_indexing), "\n") +end + +describe("python function docstrings", function() + it("works with an empty function", function() + local source = [[ + def foo():|cursor| + pass + ]] + + local expected = [[ + def foo(): + """[TODO:description]""" + pass + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works with typed arguments", function() + local source = [[ + def foo(bar: list[str], fizz: int, buzz: dict[str, int]):|cursor| + pass + ]] + + local expected = [[ + def foo(bar: list[str], fizz: int, buzz: dict[str, int]): + """[TODO:description] + + Args: + bar: [TODO:description] + fizz: [TODO:description] + buzz: [TODO:description] + """ + pass + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) +end) + +describe("python function docstrings - arguments", function() + it("works with class methods", function() + local source = [[ + class Foo: + @classmethod + def no_arguments(cls):|cursor| + return 7 + + @classmethod + def one_argument(cls, items):|cursor| + return 8 + + @classmethod + def two_arguments(cls, items, another):|cursor| + return 9 + ]] + + local expected = [[ + class Foo: + @classmethod + def no_arguments(cls): + """[TODO:description] + + Returns: + [TODO:return] + """ + return 7 + + @classmethod + def one_argument(cls, items): + """[TODO:description] + + Args: + items ([TODO:parameter]): [TODO:description] + + Returns: + [TODO:return] + """ + return 8 + + @classmethod + def two_arguments(cls, items, another): + """[TODO:description] + + Args: + items ([TODO:parameter]): [TODO:description] + another ([TODO:parameter]): [TODO:description] + + Returns: + [TODO:return] + """ + return 9 + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works with methods + nested functions", function() + local source = [[ + # Reference: https://github.com/danymat/neogen/pull/151 + class Thing(object): + def foo(self, bar, fizz, buzz):|cursor| + def another(inner, function):|cursor| + def inner_most(more, stuff):|cursor| + pass + ]] + + local expected = [[ + # Reference: https://github.com/danymat/neogen/pull/151 + class Thing(object): + def foo(self, bar, fizz, buzz): + """[TODO:description] + + Args: + bar ([TODO:parameter]): [TODO:description] + fizz ([TODO:parameter]): [TODO:description] + buzz ([TODO:parameter]): [TODO:description] + """ + def another(inner, function): + """[TODO:description] + + Args: + inner ([TODO:parameter]): [TODO:description] + function ([TODO:parameter]): [TODO:description] + """ + def inner_most(more, stuff): + """[TODO:description] + + Args: + more ([TODO:parameter]): [TODO:description] + stuff ([TODO:parameter]): [TODO:description] + """ + pass + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works with static methods", function() + local source = [[ + class Foo: + @staticmethod + def no_arguments():|cursor| + return 7 + + @staticmethod + def one_argument(items):|cursor| + return 8 + + @staticmethod + def two_arguments(items, another):|cursor| + return 9 + ]] + + local expected = [[ + class Foo: + @staticmethod + def no_arguments(): + """[TODO:description] + + Returns: + [TODO:return] + """ + return 7 + + @staticmethod + def one_argument(items): + """[TODO:description] + + Args: + items ([TODO:parameter]): [TODO:description] + + Returns: + [TODO:return] + """ + return 8 + + @staticmethod + def two_arguments(items, another): + """[TODO:description] + + Args: + items ([TODO:parameter]): [TODO:description] + another ([TODO:parameter]): [TODO:description] + + Returns: + [TODO:return] + """ + return 9 + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) +end) + +describe("python function docstrings - arguments permutations", function() + it("works with named typed arguments", function() + local source = [[ + def foo(fizz: str=None, buzz: list[str]=None):|cursor| + pass + ]] + + local expected = [[ + def foo(fizz: str=None, buzz: list[str]=None): + """[TODO:description] + + Args: + fizz: [TODO:description] + buzz: [TODO:description] + """ + pass + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works with named untyped arguments", function() + local source = [[ + def foo(fizz=None, buzz=8):|cursor| + pass + ]] + + local expected = [[ + def foo(fizz=None, buzz=8): + """[TODO:description] + + Args: + fizz ([TODO:parameter]): [TODO:description] + buzz ([TODO:parameter]): [TODO:description] + """ + pass + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works with positional typed arguments", function() + local source = [[ + def foo(fizz: str, buzz: list[str]):|cursor| + pass + ]] + + local expected = [[ + def foo(fizz: str, buzz: list[str]): + """[TODO:description] + + Args: + fizz: [TODO:description] + buzz: [TODO:description] + """ + pass + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works with positional untyped arguments", function() + local source = [[ + def foo(fizz, buzz):|cursor| + pass + ]] + + local expected = [[ + def foo(fizz, buzz): + """[TODO:description] + + Args: + fizz ([TODO:parameter]): [TODO:description] + buzz ([TODO:parameter]): [TODO:description] + """ + pass + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works with required named typed arguments", function() + local source = [[ + def foo(fizz, *, buzz: int):|cursor| + pass + ]] + + local expected = [[ + def foo(fizz, *, buzz: int): + """[TODO:description] + + Args: + fizz ([TODO:parameter]): [TODO:description] + buzz: [TODO:description] + """ + pass + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works with required named untyped arguments", function() + local source = [[ + def foo(fizz, *, buzz):|cursor| + pass + ]] + + local expected = [[ + def foo(fizz, *, buzz): + """[TODO:description] + + Args: + fizz ([TODO:parameter]): [TODO:description] + buzz ([TODO:parameter]): [TODO:description] + """ + pass + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + -- TODO: These tests currently fail but should pass. Fix the bugs! + -- it("works with *args typed arguments", function() + -- local source = [[ + -- def foo(*args: list[str]):|cursor| + -- pass + -- ]] + -- + -- local expected = [[ + -- def foo(*args: list[str]): + -- """[TODO:description] + -- + -- Args: + -- *args: [TODO:description] + -- """ + -- pass + -- ]] + -- + -- local result = _make_python_docstring(source) + -- + -- assert.equal(expected, result) + -- end) + -- + -- it("works with *args untyped arguments", function() + -- local source = [[ + -- def foo(*args):|cursor| + -- pass + -- ]] + -- + -- local expected = [[ + -- def foo(*args): + -- """[TODO:description] + -- + -- Args: + -- *args: [TODO:description] + -- """ + -- pass + -- ]] + -- + -- local result = _make_python_docstring(source) + -- + -- assert.equal(expected, result) + -- end) + + -- TODO: These tests currently fail but should pass. Fix the bugs! + -- it("works with *kwargs typed arguments", function() + -- local source = [[ + -- def foo(**kwargs: dict[str, str]):|cursor| + -- pass + -- ]] + -- + -- local expected = [[ + -- def foo(**kwargs: dict[str, str]): + -- """[TODO:description] + -- + -- Args: + -- **kwargs: [TODO:description] + -- """ + -- pass + -- ]] + -- + -- local result = _make_python_docstring(source) + -- + -- assert.equal(expected, result) + -- end) + -- + -- it("works with *args untyped arguments", function() + -- local source = [[ + -- def foo(**kwargs):|cursor| + -- pass + -- ]] + -- + -- local expected = [[ + -- def foo(**kwargs): + -- """[TODO:description] + -- + -- Args: + -- **kwargs: [TODO:description] + -- """ + -- pass + -- ]] + -- + -- local result = _make_python_docstring(source) + -- + -- assert.equal(expected, result) + -- end) +end) + +describe("python function docstrings - raises", function() + it("does not show when implicitly re-raising an exception", function() + local source = [[ + def foo():|cursor| + try: + blah() + except: + print("Oh no!") + + raise + ]] + + local expected = [[ + def foo(): + """[TODO:description]""" + try: + blah() + except: + print("Oh no!") + + raise + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + -- TODO: This is broken. Fix it! + -- This used to work but broke later on, it seems - https://github.com/danymat/neogen/pull/142 + -- it("lists only one entry per-raised type", function() + -- local source = [[ + -- def foo(bar):|cursor| + -- if bar: + -- raise TypeError("THING") + -- + -- if GLOBAL: + -- raise ValueError("asdffsd") + -- + -- raise TypeError("BLAH") + -- ]] + -- + -- local expected = [[ + -- def foo(bar): + -- """[TODO:description] + -- + -- Args: + -- bar ([TODO:parameter]): [TODO:description] + -- + -- Raises: + -- TypeError: [TODO:throw] + -- ValueError: [TODO:throw] + -- """ + -- if bar: + -- raise TypeError("THING") + -- + -- raise ValueError("asdffsd") + -- + -- raise TypeError("BLAH") + -- ]] + -- + -- local result = _make_python_docstring(source) + -- + -- assert.equal(expected, result) + -- end) + + it("works with modules, even if they are nested", function() + local source = [[ + def foo():|cursor| + raise some_package.submodule.BlahError("asdffsd") + ]] + + local expected = [[ + def foo(): + """[TODO:description] + + Raises: + some_package.submodule.BlahError: [TODO:throw] + """ + raise some_package.submodule.BlahError("asdffsd") + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works with 1 raise", function() + local source = [[ + def foo():|cursor| + raise ValueError("asdffsd") + ]] + + local expected = [[ + def foo(): + """[TODO:description] + + Raises: + ValueError: [TODO:throw] + """ + raise ValueError("asdffsd") + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + -- TODO: This is broken. Fix it! + -- This used to work but broke later on, it seems - https://github.com/danymat/neogen/pull/142 + -- it("works with 2+ raises", function() + -- local source = [[ + -- def foo(bar):|cursor| + -- if bar: + -- raise TypeError("THING") + -- + -- if GLOBAL: + -- raise TypeError("asdffsd") + -- + -- raise TypeError("BLAH") + -- ]] + -- + -- local expected = [[ + -- def foo(bar): + -- """[TODO:description] + -- + -- Args: + -- bar ([TODO:parameter]): [TODO:description] + -- + -- Raises: + -- TypeError: [TODO:throw] + -- """ + -- if bar: + -- raise TypeError("THING") + -- + -- if GLOBAL: + -- raise TypeError("asdffsd") + -- + -- raise TypeError("BLAH") + -- ]] + -- + -- local result = _make_python_docstring(source) + -- + -- assert.equal(expected, result) + -- end) +end) + +describe("python function docstrings - returns", function() + it("does not show if there are only implicit returns", function() + local source = [[ + def foo(bar):|cursor| + if bar: + return + + return # Unneeded but good for the unittest + ]] + + local expected = [[ + def foo(bar): + """[TODO:description] + + Args: + bar ([TODO:parameter]): [TODO:description] + """ + if bar: + return + + return # Unneeded but good for the unittest + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + -- TODO: This test is broken. Needs fixing + -- it("works with an inline comment", function() + -- local source = [[ + -- def flags(self, index):|cursor| # pylint: disable=unused-argument + -- return ( + -- QtCore.Qt.ItemIsEnabled + -- | QtCore.Qt.ItemIsSelectable + -- | QtCore.Qt.ItemIsUserCheckable + -- ) + -- ]] + -- + -- local expected = [[ + -- def flags(self, index): # pylint: disable=unused-argument + -- """[TODO:description] + -- + -- Args: + -- self ([TODO:parameter]): [TODO:description] + -- index ([TODO:parameter]): [TODO:description] + -- + -- Returns: + -- [TODO:return] + -- """ + -- return ( + -- QtCore.Qt.ItemIsEnabled + -- | QtCore.Qt.ItemIsSelectable + -- | QtCore.Qt.ItemIsUserCheckable + -- ) + -- ]] + -- + -- local result = _make_python_docstring(source) + -- + -- assert.equal(expected, result) + -- end) + + it("works with no arguments", function() + local source = [[ + def foo():|cursor| + return 10 + ]] + + local expected = [[ + def foo(): + """[TODO:description] + + Returns: + [TODO:return] + """ + return 10 + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works with no return", function() + local source = [[ + def foo():|cursor| + pass + ]] + + local expected = [[ + def foo(): + """[TODO:description]""" + pass + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works with various returns in one function", function() + local source = [[ + def foo(items):|cursor| + for item in items: + if item == "blah": + return "asdf" + + return "something else" + ]] + + local expected = [[ + def foo(items): + """[TODO:description] + + Args: + items ([TODO:parameter]): [TODO:description] + + Returns: + [TODO:return] + """ + for item in items: + if item == "blah": + return "asdf" + + return "something else" + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) +end) + +describe("python function docstrings - yields", function() + it("works even with no explicit yield value", function() + local source = [[ + @contextlib.contextmanager + def foo(items):|cursor| + try: + yield + except: + print("bad thing happened") + ]] + + local expected = [[ + @contextlib.contextmanager + def foo(items): + """[TODO:description] + + Args: + items ([TODO:parameter]): [TODO:description] + + Yields: + [TODO:description] + """ + try: + yield + except: + print("bad thing happened") + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works when doing yield + return at once", function() + local source = [[ + def items(value):|cursor| + if value: + return + yield + ]] + + local expected = [[ + def items(value): + """[TODO:description] + + Args: + value ([TODO:parameter]): [TODO:description] + + Yields: + [TODO:description] + """ + if value: + return + yield + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) + + it("works with 2+ yields in one function", function() + local source = [[ + def foo(thing):|cursor| + if thing: + yield 10 + yield 20 + yield 30 + else: + yield 0 + + for _ in range(10): + yield + ]] + + local expected = [[ + def foo(thing): + """[TODO:description] + + Args: + thing ([TODO:parameter]): [TODO:description] + + Yields: + [TODO:description] + """ + if thing: + yield 10 + yield 20 + yield 30 + else: + yield 0 + + for _ in range(10): + yield + ]] + + local result = _make_python_docstring(source) + + assert.equal(expected, result) + end) +end) diff --git a/tests/textmate.lua b/tests/textmate.lua new file mode 100644 index 00000000..a8b7c883 --- /dev/null +++ b/tests/textmate.lua @@ -0,0 +1,65 @@ +--- A file to make dealing with text in Lua a little easier. +--- +--- @module 'tests.textmate' + +local _CURSOR_MARKER = "|cursor|" + +local M = {} + +---@class Cursor +--- A fake position in-space where a user's cursor is meant to be. +---@field [1] number +--- A 1-or-more value indicating the buffer line value. +---@field [2] number +--- A 1-or-more value indicating the buffer column value. + +--- Find all lines marked with `"|cursor|"` and return their row / column positions. +--- +---@param source string Pseudo-Python source-code to call. It contains `"|cursor|"` which is the expected user position. +---@return Cursor[] # The row and column cursor position in `source`. +---@return string # The same source code but with `"|cursor|"` stripped out. +function M.extract_cursors(source) + local index = 1 + local count = #source + local cursors = {} + local cursor_marker_offset = #_CURSOR_MARKER - 1 + local code = "" + + local current_row = 1 + local current_column = 1 + + while index <= count do + local character = source:sub(index, index) + + if character == "\n" then + current_row = current_row + 1 + current_column = 1 + else + current_column = current_column + 1 + end + + if character == "|" then + if source:sub(index, index + cursor_marker_offset) == _CURSOR_MARKER then + index = index + cursor_marker_offset + table.insert(cursors, { current_row, current_column }) + else + code = code .. character + end + else + code = code .. character + end + + index = index + 1 + end + + if index <= count then + -- If this happens, is because the while loop called `break` early + -- This is very likely so we add the last character(s) to the output + local remainder = source:sub(index, #source) + code = code .. remainder + end + + return { cursors, code } +end + +return M