├── .github ├── preview-glow.png ├── preview.png └── workflows │ └── ci.yaml ├── .luarc.json ├── .stylua.toml ├── LICENSE ├── README.md ├── lua └── nvim-devdocs │ ├── build.lua │ ├── completion.lua │ ├── config.lua │ ├── filetypes.lua │ ├── fs.lua │ ├── init.lua │ ├── keymaps.lua │ ├── list.lua │ ├── log.lua │ ├── operations.lua │ ├── pickers.lua │ ├── state.lua │ ├── transpiler.lua │ └── types.lua └── test ├── init.lua └── transpiler_spec.lua /.github/preview-glow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luckasRanarison/nvim-devdocs/1ab982d3e069d191d9157b897c8b70cf48b7f77a/.github/preview-glow.png -------------------------------------------------------------------------------- /.github/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luckasRanarison/nvim-devdocs/1ab982d3e069d191d9157b897c8b70cf48b7f77a/.github/preview.png -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | tests: 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest] 11 | runs-on: ${{ matrix.os }} 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Install Neovim 15 | shell: bash 16 | run: | 17 | mkdir -p /tmp/nvim 18 | wget -q https://github.com/neovim/neovim/releases/download/nightly/nvim.appimage -O /tmp/nvim/nvim.appimage 19 | cd /tmp/nvim 20 | chmod a+x ./nvim.appimage 21 | ./nvim.appimage --appimage-extract 22 | echo "/tmp/nvim/squashfs-root/usr/bin/" >> $GITHUB_PATH 23 | - name: Install parsers 24 | run: | 25 | nvim --headless -u test/init.lua -c "TSInstallSync html" -c "q" 26 | - name: Run Tests 27 | run: | 28 | nvim --version 29 | nvim --headless -u test/init.lua -c "PlenaryBustedDirectory test/ { init='test/init.lua' }" 30 | -------------------------------------------------------------------------------- /.luarc.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace.library": [ 3 | "/usr/local/share/nvim/runtime/lua", 4 | "~/.local/share/nvim/lazy/neodev.nvim/types/stable", 5 | "~/.local/share/nvim/lazy/plenary.nvim/", 6 | "~/.local/share/nvim/lazy/telescope.nvim/", 7 | "~/.local/share/nvim/lazy/nvim-treesitter/", 8 | "${3rd}/luv/library", 9 | "${3rd}/luassert/library" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 100 2 | line_endings = "Unix" 3 | indent_type = "Spaces" 4 | indent_width = 2 5 | quote_style = "AutoPreferDouble" 6 | call_parentheses = "Always" 7 | collapse_simple_statement = "Always" 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 LIOKA Ranarison Fiderana 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nvim-devdocs 2 | 3 | nvim-devdocs is a plugin which brings [DevDocs](https://devdocs.io) documentations into neovim. Install, search and preview documentations directly inside neovim in markdown format with telescope integration. You can also use custom commands like [glow](https://github.com/charmbracelet/glow) to render the markdown for a better experience. 4 | 5 | ## Preview 6 | 7 | ![nvim-devdocs search](./.github/preview.png) 8 | 9 | Using [glow](https://github.com/charmbracelet/glow) for rendering markdown: 10 | 11 | ![nvim-devdocs with glow](./.github/preview-glow.png) 12 | 13 | ## Features 14 | 15 | - Offline usage. 16 | 17 | - Search using Telescope. 18 | 19 | - Markdown rendering using custom commands. 20 | 21 | - Open in browser. 22 | 23 | - Internal search using the `K` key (see `:h K`). 24 | 25 | ## Installation 26 | 27 | Lazy: 28 | 29 | ```lua 30 | return { 31 | "luckasRanarison/nvim-devdocs", 32 | dependencies = { 33 | "nvim-lua/plenary.nvim", 34 | "nvim-telescope/telescope.nvim", 35 | "nvim-treesitter/nvim-treesitter", 36 | }, 37 | opts = {} 38 | } 39 | ``` 40 | 41 | Packer: 42 | 43 | ```lua 44 | use { 45 | "luckasRanarison/nvim-devdocs", 46 | requires = { 47 | "nvim-lua/plenary.nvim", 48 | "nvim-telescope/telescope.nvim", 49 | "nvim-treesitter/nvim-treesitter", 50 | }, 51 | config = function() 52 | require("nvim-devdocs").setup() 53 | end 54 | } 55 | ``` 56 | 57 | The plugin uses treesitter API for converting HTML to markdown so make sure you have treesitter `html` parser installed. 58 | 59 | Inside your treesitter configuration: 60 | 61 | ```lua 62 | { 63 | ensure_installed = { "html" }, 64 | } 65 | ``` 66 | 67 | ## Configuration 68 | 69 | Here is the default configuration: 70 | 71 | ```lua 72 | { 73 | dir_path = vim.fn.stdpath("data") .. "/devdocs", -- installation directory 74 | telescope = {}, -- passed to the telescope picker 75 | filetypes = { 76 | -- extends the filetype to docs mappings used by the `DevdocsOpenCurrent` command, the version doesn't have to be specified 77 | -- scss = "sass", 78 | -- javascript = { "node", "javascript" } 79 | }, 80 | float_win = { -- passed to nvim_open_win(), see :h api-floatwin 81 | relative = "editor", 82 | height = 25, 83 | width = 100, 84 | border = "rounded", 85 | }, 86 | wrap = false, -- text wrap, only applies to floating window 87 | previewer_cmd = nil, -- for example: "glow" 88 | cmd_args = {}, -- example using glow: { "-s", "dark", "-w", "80" } 89 | cmd_ignore = {}, -- ignore cmd rendering for the listed docs 90 | picker_cmd = false, -- use cmd previewer in picker preview 91 | picker_cmd_args = {}, -- example using glow: { "-s", "dark", "-w", "50" } 92 | mappings = { -- keymaps for the doc buffer 93 | open_in_browser = "" 94 | }, 95 | ensure_installed = {}, -- get automatically installed 96 | after_open = function(bufnr) end, -- callback that runs after the Devdocs window is opened. Devdocs buffer ID will be passed in 97 | } 98 | ``` 99 | 100 | ## Usage 101 | 102 | To use the documentations from nvim-devdocs, you need to install it by executing `:DevdocsInstall`. The documentation is indexed and built during the download. Since the building process is done synchronously and may block input, you may want to download larger documents (more than 10MB) in headless mode: `nvim --headless +"DevdocsInstall rust"`. 103 | 104 | ## Commands 105 | 106 | Available commands: 107 | 108 | - `DevdocsFetch`: Fetch DevDocs metadata. 109 | - `DevdocsInstall`: Install documentation, 0-n args. 110 | - `DevdocsUninstall`: Uninstall documentation, 0-n args. 111 | - `DevdocsOpen`: Open documentation in a normal buffer, 0 or 1 arg. 112 | - `DevdocsOpenFloat`: Open documentation in a floating window, 0 or 1 arg. 113 | - `DevdocsOpenCurrent`: Open documentation for the current filetype in a normal buffer. 114 | - `DevdocsOpenCurrentFloat`: Open documentation for the current filetype in a floating window. 115 | - `DevdocsToggle`: Toggle floating window. 116 | - `DevdocsUpdate`: Update documentation, 0-n args. 117 | - `DevdocsUpdateAll`: Update all documentations. 118 | 119 | Commands support completion, and the Telescope picker will be used when no argument is provided. 120 | 121 | ## Lifecycle Hook 122 | 123 | An `after_open` callback is supplied which accepts the buffer ID of the Devdocs window. It can be used for things like buffer-specific keymaps: 124 | 125 | ```lua 126 | require('nvim-devdocs').setup({ 127 | -- ... 128 | after_open = function(bufnr) 129 | vim.api.nvim_buf_set_keymap(bufnr, 'n', '', ':close', {}) 130 | end 131 | }) 132 | ``` 133 | 134 | ## TODO 135 | 136 | - More search options 137 | - External previewers. 138 | - More features. 139 | 140 | ## Contributing 141 | 142 | The HTML converter is still experimental, and not all documentation has been thoroughly tested yet. If you encounter rendering issues, feel free to submit an [issue](https://github.com/luckasRanarison/nvim-devdocs/issues). 143 | 144 | Pull requests and feature requests are welcome! 145 | 146 | ## Similar projects 147 | 148 | - [nvim-telescope-zeal-cli](https://gitlab.com/ivan-cukic/nvim-telescope-zeal-cli) Show Zeal documentation pages in Neovim Telescope. 149 | - [devdocs.vim](https://github.com/girishji/devdocs.vim) Offers similar features but uses vimscript and pandoc. 150 | 151 | ## Credits 152 | 153 | - [The DevDocs project](https://github.com/freeCodeCamp/devdocs) for the documentations. 154 | - [devdocs.el](https://github.com/astoff/devdocs.el) for inspiration. 155 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/build.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local fs = require("nvim-devdocs.fs") 4 | local log = require("nvim-devdocs.log") 5 | local transpiler = require("nvim-devdocs.transpiler") 6 | 7 | ---@param entry RegisteryEntry 8 | ---@param doc_index DocIndex 9 | ---@param docs table 10 | M.build_docs = function(entry, doc_index, docs) 11 | local alias = entry.slug:gsub("~", "-") 12 | local current_doc_dir = DOCS_DIR:joinpath(alias) 13 | 14 | log.info("Building " .. alias .. " documentation...") 15 | 16 | if not DOCS_DIR:exists() then DOCS_DIR:mkdir() end 17 | if not current_doc_dir:exists() then current_doc_dir:mkdir() end 18 | 19 | local index = fs.read_index() or {} 20 | local lockfile = fs.read_lockfile() or {} 21 | 22 | --- Used for extracting the markdown headers that will be used as breakpoints when spliting the docs 23 | local section_map = {} 24 | local path_map = {} 25 | 26 | for _, index_entry in pairs(doc_index.entries) do 27 | local splited = vim.split(index_entry.path, "#") 28 | local main = splited[1] 29 | local id = splited[2] 30 | 31 | if not section_map[main] then section_map[main] = {} end 32 | if id then table.insert(section_map[main], id) end 33 | end 34 | 35 | -- The entries need to be sorted in order to make spliting work 36 | local sort_lookup = {} 37 | local sort_lookup_last_index = 1 38 | local count = 1 39 | local total = vim.tbl_count(docs) 40 | 41 | for key, doc in pairs(docs) do 42 | log.debug(string.format("Converting %s (%s/%s)", key, count, total)) 43 | 44 | local sections = section_map[key] 45 | local file_path = current_doc_dir:joinpath(tostring(count) .. ".md") 46 | local success, result, md_sections = 47 | xpcall(transpiler.html_to_md, debug.traceback, doc, sections) 48 | 49 | if not success then 50 | local message = string.format( 51 | 'Failed to convert "%s", please report this issue\n\n%s\n\nOriginal html document:\n\n%s', 52 | key, 53 | result, 54 | doc 55 | ) 56 | log.error(message) 57 | return 58 | end 59 | 60 | for _, section in ipairs(md_sections) do 61 | path_map[key .. "#" .. section.id] = count .. "," .. section.md_path 62 | sort_lookup[key .. "#" .. section.id] = sort_lookup_last_index 63 | sort_lookup_last_index = sort_lookup_last_index + 1 64 | end 65 | 66 | -- Use number as filename instead of the entry name to avoid invalid filenames 67 | path_map[key] = tostring(count) 68 | file_path:write(result, "w") 69 | count = count + 1 70 | log.debug(file_path .. " has been writen") 71 | end 72 | 73 | log.debug("Sorting docs entries") 74 | table.sort(doc_index.entries, function(a, b) 75 | local index_a = sort_lookup[a.path] or -1 76 | local index_b = sort_lookup[b.path] or -1 77 | return index_a < index_b 78 | end) 79 | 80 | log.debug("Filling docs links and path") 81 | for i, index_entry in ipairs(doc_index.entries) do 82 | local main = vim.split(index_entry.path, "#")[1] 83 | doc_index.entries[i].link = doc_index.entries[i].path 84 | doc_index.entries[i].path = path_map[index_entry.path] or path_map[main] 85 | end 86 | 87 | index[alias] = doc_index 88 | lockfile[alias] = entry 89 | 90 | fs.write_index(index) 91 | fs.write_lockfile(lockfile) 92 | 93 | log.info("Build complete!") 94 | end 95 | 96 | return M 97 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/completion.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local list = require("nvim-devdocs.list") 4 | 5 | ---@param args string[] 6 | ---@param arg_lead string 7 | local function filter_args(args, arg_lead) 8 | local filtered = vim.tbl_filter(function(entry) 9 | return vim.startswith(entry, arg_lead) 10 | end, args) 11 | return filtered 12 | end 13 | 14 | M.get_installed = function(arg_lead) 15 | local installed = list.get_installed_alias() 16 | return filter_args(installed, arg_lead) 17 | end 18 | 19 | M.get_non_installed = function(arg_lead) 20 | local non_installed = list.get_non_installed_alias() 21 | return filter_args(non_installed, arg_lead) 22 | end 23 | 24 | M.get_updatable = function(arg_lead) 25 | local updatable = list.get_updatable() 26 | return filter_args(updatable, arg_lead) 27 | end 28 | 29 | return M 30 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/config.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local path = require("plenary.path") 4 | 5 | ---@class nvim_devdocs.Config 6 | local default = { 7 | dir_path = vim.fn.stdpath("data") .. "/devdocs", 8 | telescope = {}, 9 | filetypes = {}, 10 | float_win = { 11 | relative = "editor", 12 | height = 25, 13 | width = 100, 14 | border = "rounded", 15 | }, 16 | wrap = false, 17 | previewer_cmd = nil, 18 | cmd_args = {}, 19 | cmd_ignore = {}, 20 | picker_cmd = false, 21 | picker_cmd_args = {}, 22 | ensure_installed = {}, 23 | mappings = { 24 | open_in_browser = "", 25 | }, 26 | ---@diagnostic disable-next-line: unused-local 27 | after_open = function(bufnr) end, 28 | } 29 | 30 | ---@class nvim_devdocs.Config 31 | M.options = {} 32 | 33 | M.setup = function(new_config) 34 | M.options = vim.tbl_deep_extend("force", default, new_config or {}) 35 | 36 | DATA_DIR = path:new(default.dir_path) 37 | DOCS_DIR = DATA_DIR:joinpath("docs") 38 | INDEX_PATH = DATA_DIR:joinpath("index.json") 39 | LOCK_PATH = DATA_DIR:joinpath("docs-lock.json") 40 | REGISTERY_PATH = DATA_DIR:joinpath("registery.json") 41 | 42 | return default 43 | end 44 | 45 | M.get_float_options = function() 46 | local ui = vim.api.nvim_list_uis()[1] 47 | local row = (ui.height - M.options.float_win.height) * 0.5 48 | local col = (ui.width - M.options.float_win.width) * 0.5 49 | local float_opts = M.options.float_win 50 | 51 | float_opts.row = M.options.float_win.row or row 52 | float_opts.col = M.options.float_win.col or col 53 | float_opts.zindex = 10 54 | 55 | return float_opts 56 | end 57 | 58 | return M 59 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/filetypes.lua: -------------------------------------------------------------------------------- 1 | local M = { 2 | sh = "bash", 3 | scss = "sass", 4 | make = "gnu_make", 5 | dockerfile = "docker", 6 | javascript = { "node", "javascript" }, 7 | json = "jq", 8 | yaml = "ansible", 9 | javascriptreact = { "javascript", "react" }, 10 | typescriptreact = { "typescript", "react" }, 11 | } 12 | 13 | return M 14 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/fs.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | ---@param registery string 4 | M.write_registery = function(registery) REGISTERY_PATH:write(registery, "w") end 5 | 6 | ---@param index IndexTable 7 | M.write_index = function(index) 8 | local encoded = vim.fn.json_encode(index) 9 | INDEX_PATH:write(encoded, "w") 10 | end 11 | 12 | ---@param lockfile LockTable 13 | M.write_lockfile = function(lockfile) 14 | local encoded = vim.fn.json_encode(lockfile) 15 | LOCK_PATH:write(encoded, "w") 16 | end 17 | 18 | ---@return RegisteryEntry[]? 19 | M.read_registery = function() 20 | if not REGISTERY_PATH:exists() then return end 21 | local buf = REGISTERY_PATH:read() 22 | return vim.fn.json_decode(buf) 23 | end 24 | 25 | ---@return IndexTable? 26 | M.read_index = function() 27 | if not INDEX_PATH:exists() then return end 28 | local buf = INDEX_PATH:read() 29 | return vim.fn.json_decode(buf) 30 | end 31 | 32 | ---@return LockTable? 33 | M.read_lockfile = function() 34 | if not LOCK_PATH:exists() then return end 35 | local buf = LOCK_PATH:read() 36 | return vim.fn.json_decode(buf) 37 | end 38 | 39 | ---@param alias string 40 | M.remove_docs = function(alias) 41 | local doc_path = DOCS_DIR:joinpath(alias) 42 | doc_path:rm({ recursive = true }) 43 | end 44 | 45 | return M 46 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local log = require("nvim-devdocs.log") 4 | local list = require("nvim-devdocs.list") 5 | local state = require("nvim-devdocs.state") 6 | local pickers = require("nvim-devdocs.pickers") 7 | local operations = require("nvim-devdocs.operations") 8 | local config = require("nvim-devdocs.config") 9 | local completion = require("nvim-devdocs.completion") 10 | local filetypes = require("nvim-devdocs.filetypes") 11 | 12 | M.fetch_registery = operations.fetch 13 | 14 | M.install_doc = function(args) 15 | if vim.tbl_isempty(args.fargs) then 16 | pickers.installation_picker() 17 | else 18 | operations.install_args(args.fargs, true) 19 | end 20 | end 21 | 22 | M.uninstall_doc = function(args) 23 | if vim.tbl_isempty(args.fargs) then pickers.uninstallation_picker() end 24 | 25 | for _, arg in pairs(args.fargs) do 26 | operations.uninstall(arg) 27 | end 28 | end 29 | 30 | M.open_doc = function(args, float) 31 | if vim.tbl_isempty(args.fargs) then 32 | log.debug("Opening all installed entries") 33 | local installed = list.get_installed_alias() 34 | local entries = list.get_doc_entries(installed) 35 | pickers.open_picker(entries or {}, float) 36 | else 37 | local alias = args.fargs[1] 38 | log.debug("Opening " .. alias .. " entries") 39 | pickers.open_picker_alias(alias, float) 40 | end 41 | end 42 | 43 | M.open_doc_float = function(args) M.open_doc(args, true) end 44 | 45 | M.open_doc_current_file = function(float) 46 | local filetype = vim.bo.filetype 47 | local names = config.options.filetypes[filetype] or filetypes[filetype] or filetype 48 | 49 | if type(names) == "string" then names = { names } end 50 | 51 | local docs = 52 | vim.tbl_flatten(vim.tbl_map(function(name) return list.get_doc_variants(name) end, names)) 53 | local entries = list.get_doc_entries(docs) 54 | 55 | if entries and not vim.tbl_isempty(entries) then 56 | pickers.open_picker(entries, float) 57 | else 58 | log.error("No documentation found for the current filetype") 59 | end 60 | end 61 | 62 | M.update = function(args) 63 | if vim.tbl_isempty(args.fargs) then 64 | pickers.update_picker() 65 | else 66 | operations.install_args(args.fargs, true, true) 67 | end 68 | end 69 | 70 | M.update_all = function() 71 | local updatable = list.get_updatable() 72 | 73 | if vim.tbl_isempty(updatable) then 74 | log.info("All documentations are up to date") 75 | else 76 | operations.install_args(updatable, true, true) 77 | end 78 | end 79 | 80 | M.toggle = function() 81 | local buf = state.get("last_buf") 82 | local win = state.get("last_win") 83 | 84 | if not buf or not vim.api.nvim_buf_is_valid(buf) then return end 85 | 86 | if win and vim.api.nvim_win_is_valid(win) then 87 | vim.api.nvim_win_close(win, true) 88 | state.set("last_win", nil) 89 | else 90 | win = vim.api.nvim_open_win(buf, true, config.get_float_options()) 91 | state.set("last_win", win) 92 | end 93 | end 94 | 95 | M.keywordprg = function(args) 96 | local keyword = args.fargs[1] 97 | 98 | if keyword then 99 | operations.keywordprg(keyword) 100 | else 101 | log.error("No keyword provided") 102 | end 103 | end 104 | 105 | ---@param opts nvim_devdocs.Config 106 | M.setup = function(opts) 107 | config.setup(opts) 108 | 109 | vim.defer_fn(function() 110 | log.debug("Installing required docs") 111 | operations.install_args(config.options.ensure_installed) 112 | end, 3000) 113 | 114 | local cmd = vim.api.nvim_create_user_command 115 | 116 | cmd("DevdocsFetch", M.fetch_registery, {}) 117 | cmd("DevdocsInstall", M.install_doc, { nargs = "*", complete = completion.get_non_installed }) 118 | cmd("DevdocsUninstall", M.uninstall_doc, { nargs = "*", complete = completion.get_installed }) 119 | cmd("DevdocsOpen", M.open_doc, { nargs = "?", complete = completion.get_installed }) 120 | cmd("DevdocsOpenFloat", M.open_doc_float, { nargs = "?", complete = completion.get_installed }) 121 | cmd("DevdocsOpenCurrent", function() M.open_doc_current_file() end, {}) 122 | cmd("DevdocsOpenCurrentFloat", function() M.open_doc_current_file(true) end, {}) 123 | cmd("DevdocsKeywordprg", M.keywordprg, { nargs = "?" }) 124 | cmd("DevdocsUpdate", M.update, { nargs = "*", complete = completion.get_updatable }) 125 | cmd("DevdocsUpdateAll", M.update_all, {}) 126 | cmd("DevdocsToggle", M.toggle, {}) 127 | 128 | log.debug("Plugin initialized") 129 | end 130 | 131 | return M 132 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/keymaps.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local config = require("nvim-devdocs.config") 4 | 5 | local function set_buf_keymap(key, action, bufnr, description) 6 | vim.keymap.set("n", key, action, { buffer = bufnr, desc = description }) 7 | end 8 | 9 | local mappings = { 10 | open_in_browser = { 11 | desc = "Open in the browser", 12 | handler = function(entry) 13 | local slug = entry.alias:gsub("-", "~") 14 | vim.ui.open("https://devdocs.io/" .. slug .. "/" .. entry.link) 15 | end, 16 | }, 17 | } 18 | 19 | ---@param bufnr number 20 | ---@param entry DocEntry 21 | M.set_keymaps = function(bufnr, entry) 22 | for map, key in pairs(config.options.mappings) do 23 | if type(key) == "string" and key ~= "" then 24 | local value = mappings[map] 25 | if value then set_buf_keymap(key, function() value.handler(entry) end, bufnr, value.desc) end 26 | end 27 | end 28 | end 29 | 30 | return M 31 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/list.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local fs = require("nvim-devdocs.fs") 4 | local log = require("nvim-devdocs.log") 5 | 6 | ---@return string[] 7 | M.get_installed_alias = function() 8 | local lockfile = fs.read_lockfile() or {} 9 | local installed = vim.tbl_keys(lockfile) 10 | 11 | return installed 12 | end 13 | 14 | ---@return string[] 15 | M.get_non_installed_alias = function() 16 | local results = {} 17 | local registery = fs.read_registery() 18 | local installed = M.get_installed_alias() 19 | 20 | if not registery then return {} end 21 | 22 | for _, entry in pairs(registery) do 23 | local alias = entry.slug:gsub("~", "-") 24 | if not vim.tbl_contains(installed, alias) then table.insert(results, alias) end 25 | end 26 | 27 | return results 28 | end 29 | 30 | ---@param aliases string[] 31 | ---@return DocEntry[] | nil 32 | M.get_doc_entries = function(aliases) 33 | local entries = {} 34 | local index = fs.read_index() 35 | 36 | if not index then return end 37 | 38 | for _, alias in pairs(aliases) do 39 | if index[alias] then 40 | local current_entries = index[alias].entries 41 | 42 | for idx, doc_entry in ipairs(current_entries) do 43 | local next_path = nil 44 | local entries_count = #current_entries 45 | 46 | if idx < entries_count then next_path = current_entries[idx + 1].path end 47 | 48 | local entry = { 49 | name = doc_entry.name, 50 | path = doc_entry.path, 51 | link = doc_entry.link, 52 | alias = alias, 53 | next_path = next_path, 54 | } 55 | 56 | table.insert(entries, entry) 57 | end 58 | end 59 | end 60 | 61 | return entries 62 | end 63 | 64 | ---@param predicate function 65 | ---@return RegisteryEntry[]? 66 | local function get_registery_entry(predicate) 67 | local registery = fs.read_registery() 68 | 69 | if not registery then 70 | log.error("DevDocs registery not found, please run :DevdocsFetch") 71 | return 72 | end 73 | 74 | return vim.tbl_filter(predicate, registery) 75 | end 76 | 77 | M.get_installed_registery = function() 78 | local installed = M.get_installed_alias() 79 | local predicate = function(entry) 80 | local alias = entry.slug:gsub("~", "-") 81 | return vim.tbl_contains(installed, alias) 82 | end 83 | return get_registery_entry(predicate) 84 | end 85 | 86 | M.get_non_installed_registery = function() 87 | local installed = M.get_installed_alias() 88 | local predicate = function(entry) 89 | local alias = entry.slug:gsub("~", "-") 90 | return not vim.tbl_contains(installed, alias) 91 | end 92 | return get_registery_entry(predicate) 93 | end 94 | 95 | M.get_updatable_registery = function() 96 | local updatable = M.get_updatable() 97 | local predicate = function(entry) 98 | local alias = entry.slug:gsub("~", "-") 99 | return vim.tbl_contains(updatable, alias) 100 | end 101 | return get_registery_entry(predicate) 102 | end 103 | 104 | ---@return string[] 105 | M.get_updatable = function() 106 | local results = {} 107 | local registery = fs.read_registery() 108 | local lockfile = fs.read_lockfile() 109 | 110 | if not registery or not lockfile then return {} end 111 | 112 | for alias, value in pairs(lockfile) do 113 | for _, doc in pairs(registery) do 114 | if doc.slug == value.slug and doc.mtime > value.mtime then 115 | table.insert(results, alias) 116 | break 117 | end 118 | end 119 | end 120 | 121 | return results 122 | end 123 | 124 | ---@param name string 125 | ---@return string[] 126 | M.get_doc_variants = function(name) 127 | local variants = {} 128 | local entries = fs.read_registery() 129 | 130 | if not entries then return {} end 131 | 132 | for _, entry in pairs(entries) do 133 | if vim.startswith(entry.slug, name) then 134 | local alias = entry.slug:gsub("~", "-") 135 | table.insert(variants, alias) 136 | end 137 | end 138 | 139 | return variants 140 | end 141 | 142 | return M 143 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/log.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local log = require("plenary.log").new({ 4 | plugin = "nvim-devdocs", 5 | use_console = false, -- use vim.notify instead 6 | outfile = vim.fn.stdpath("data") .. "/devdocs/log.txt", 7 | fmt_msg = function(_, mode_name, src_path, src_line, message) 8 | local mode = mode_name:upper() 9 | local timestamp = os.date("%Y-%m-%d %H:%M:%S") 10 | local source = vim.fn.fnamemodify(src_path, ":t") .. ":" .. src_line 11 | 12 | return string.format("[%s][%s] %s: %s\n", mode, timestamp, source, message) 13 | end, 14 | }, false) 15 | 16 | local notify = vim.schedule_wrap( 17 | function(message, level) vim.notify("[nvim-devdocs] " .. message, level) end 18 | ) 19 | 20 | M.debug = function(message) 21 | notify(message, vim.log.levels.DEBUG) 22 | log.debug(message) 23 | end 24 | 25 | M.info = function(message) 26 | notify(message, vim.log.levels.INFO) 27 | log.info(message) 28 | end 29 | 30 | M.warn = function(message) 31 | notify(message, vim.log.levels.WARN) 32 | log.warn(message) 33 | end 34 | 35 | M.error = function(message) 36 | notify(message, vim.log.levels.ERROR) 37 | log.error(message) 38 | end 39 | 40 | return M 41 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/operations.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local job = require("plenary.job") 4 | local curl = require("plenary.curl") 5 | 6 | local fs = require("nvim-devdocs.fs") 7 | local log = require("nvim-devdocs.log") 8 | local list = require("nvim-devdocs.list") 9 | local state = require("nvim-devdocs.state") 10 | local build = require("nvim-devdocs.build") 11 | local config = require("nvim-devdocs.config") 12 | local keymaps = require("nvim-devdocs.keymaps") 13 | 14 | local devdocs_site_url = "https://devdocs.io" 15 | local devdocs_cdn_url = "https://documents.devdocs.io" 16 | 17 | M.fetch = function() 18 | log.info("Fetching DevDocs registery...") 19 | 20 | curl.get(devdocs_site_url .. "/docs.json", { 21 | headers = { 22 | ["User-agent"] = "chrome", -- fake user agent, see #25 23 | }, 24 | callback = function(response) 25 | if not DATA_DIR:exists() then 26 | log.debug("Docs directory not found, creating a new directory") 27 | DATA_DIR:mkdir() 28 | end 29 | fs.write_registery(response.body) 30 | log.info("DevDocs registery has been written to the disk") 31 | end, 32 | on_error = function(error) 33 | log.error("Error when fetching registery, exit code: " .. error.exit) 34 | end, 35 | }) 36 | end 37 | 38 | ---@param entry RegisteryEntry 39 | ---@param verbose? boolean 40 | ---@param is_update? boolean 41 | M.install = function(entry, verbose, is_update) 42 | if not REGISTERY_PATH:exists() then 43 | if verbose then log.error("DevDocs registery not found, please run :DevdocsFetch") end 44 | return 45 | end 46 | 47 | local alias = entry.slug:gsub("~", "-") 48 | local installed = list.get_installed_alias() 49 | local is_installed = vim.tbl_contains(installed, alias) 50 | 51 | if not is_update and is_installed then 52 | if verbose then log.warn("Documentation for " .. alias .. " is already installed") end 53 | else 54 | local ui = vim.api.nvim_list_uis() 55 | 56 | if ui[1] and entry.db_size > 10000000 then 57 | log.debug(string.format("%s docs is too large (%s)", alias, entry.db_size)) 58 | 59 | local input = vim.fn.input({ 60 | prompt = "Building large docs can freeze neovim, continue? y/n ", 61 | }) 62 | 63 | if input ~= "y" then return end 64 | end 65 | 66 | local callback = function(index) 67 | local doc_url = string.format("%s/%s/db.json?%s", devdocs_cdn_url, entry.slug, entry.mtime) 68 | 69 | log.info("Downloading " .. alias .. " documentation...") 70 | curl.get(doc_url, { 71 | callback = vim.schedule_wrap(function(response) 72 | local docs = vim.fn.json_decode(response.body) 73 | build.build_docs(entry, index, docs) 74 | end), 75 | on_error = function(error) 76 | log.error("(" .. alias .. ") Error during download, exit code: " .. error.exit) 77 | end, 78 | }) 79 | end 80 | 81 | local index_url = string.format("%s/%s/index.json?%s", devdocs_cdn_url, entry.slug, entry.mtime) 82 | 83 | log.info("Fetching " .. alias .. " documentation entries...") 84 | curl.get(index_url, { 85 | callback = vim.schedule_wrap(function(response) 86 | local index = vim.fn.json_decode(response.body) 87 | callback(index) 88 | end), 89 | on_error = function(error) 90 | log.error("(" .. alias .. ") Error during download, exit code: " .. error.exit) 91 | end, 92 | }) 93 | end 94 | end 95 | 96 | ---@param args string[] 97 | ---@param verbose? boolean 98 | ---@param is_update? boolean 99 | M.install_args = function(args, verbose, is_update) 100 | local updatable = list.get_updatable() 101 | local registery = fs.read_registery() 102 | 103 | if not registery then 104 | if verbose then log.error("DevDocs registery not found, please run :DevdocsFetch") end 105 | return 106 | end 107 | 108 | for _, arg in ipairs(args) do 109 | local slug = arg:gsub("-", "~") 110 | local data = {} 111 | 112 | for _, entry in ipairs(registery) do 113 | if entry.slug == slug then 114 | data = entry 115 | break 116 | end 117 | end 118 | 119 | if vim.tbl_isempty(data) then 120 | log.error("No documentation available for " .. arg) 121 | else 122 | if is_update and not vim.tbl_contains(updatable, arg) then 123 | log.info(arg .. " documentation is already up to date") 124 | else 125 | M.install(data, verbose, is_update) 126 | end 127 | end 128 | end 129 | end 130 | 131 | ---@param alias string 132 | M.uninstall = function(alias) 133 | local installed = list.get_installed_alias() 134 | 135 | if not vim.tbl_contains(installed, alias) then 136 | log.info(alias .. " documentation is already uninstalled") 137 | else 138 | local index = fs.read_index() 139 | local lockfile = fs.read_lockfile() 140 | 141 | if not index or not lockfile then return end 142 | 143 | index[alias] = nil 144 | lockfile[alias] = nil 145 | 146 | fs.write_index(index) 147 | fs.write_lockfile(lockfile) 148 | fs.remove_docs(alias) 149 | 150 | log.info(alias .. " documentation has been uninstalled") 151 | end 152 | end 153 | 154 | ---@param entry DocEntry 155 | ---@return string[] 156 | M.read_entry = function(entry) 157 | local splited_path = vim.split(entry.path, ",") 158 | local file = splited_path[1] 159 | local file_path = DOCS_DIR:joinpath(entry.alias, file .. ".md") 160 | local content = file_path:read() 161 | local pattern = splited_path[2] 162 | local next_pattern = nil 163 | 164 | if entry.next_path ~= nil then next_pattern = vim.split(entry.next_path, ",")[2] end 165 | 166 | local lines = vim.split(content, "\n") 167 | local filtered_lines = M.filter_doc(lines, pattern, next_pattern) 168 | 169 | return filtered_lines 170 | end 171 | 172 | ---@param entry DocEntry 173 | ---@param callback function 174 | M.read_entry_async = function(entry, callback) 175 | local splited_path = vim.split(entry.path, ",") 176 | local file = splited_path[1] 177 | local file_path = DOCS_DIR:joinpath(entry.alias, file .. ".md") 178 | 179 | file_path:_read_async(vim.schedule_wrap(function(content) 180 | local pattern = splited_path[2] 181 | local next_pattern = nil 182 | 183 | if entry.next_path ~= nil then next_pattern = vim.split(entry.next_path, ",")[2] end 184 | 185 | local lines = vim.split(content, "\n") 186 | local filtered_lines = M.filter_doc(lines, pattern, next_pattern) 187 | 188 | callback(filtered_lines) 189 | end)) 190 | end 191 | 192 | ---if we have a pattern to search for, only consider lines after the pattern 193 | ---@param lines string[] 194 | ---@param pattern? string 195 | ---@param next_pattern? string 196 | ---@return string[] 197 | M.filter_doc = function(lines, pattern, next_pattern) 198 | if not pattern then return lines end 199 | 200 | -- https://stackoverflow.com/a/34953646/516188 201 | local function create_pattern(text) return text:gsub("([^%w])", "%%%1") end 202 | 203 | local filtered_lines = {} 204 | local found = false 205 | local pattern_lines = vim.split(pattern, "\n") 206 | local search_pattern = create_pattern(pattern_lines[1]) -- only search the first line 207 | local next_search_pattern = nil 208 | 209 | if next_pattern then 210 | local next_pattern_lines = vim.split(next_pattern, "\n") 211 | next_search_pattern = create_pattern(next_pattern_lines[1]) -- only search the first line 212 | end 213 | 214 | for _, line in ipairs(lines) do 215 | if found and next_search_pattern then 216 | if line:match(next_search_pattern) then break end 217 | end 218 | if line:match(search_pattern) then found = true end 219 | if found then table.insert(filtered_lines, line) end 220 | end 221 | 222 | if not found then return lines end 223 | 224 | return filtered_lines 225 | end 226 | 227 | ---@param bufnr number 228 | ---@param is_picker? boolean 229 | M.render_cmd = function(bufnr, is_picker) 230 | vim.bo[bufnr].ft = config.options.previewer_cmd 231 | 232 | local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) 233 | local chan = vim.api.nvim_open_term(bufnr, {}) 234 | local args = is_picker and config.options.picker_cmd_args or config.options.cmd_args 235 | local previewer = job:new({ 236 | command = config.options.previewer_cmd, 237 | args = args, 238 | on_stdout = vim.schedule_wrap(function(_, data) 239 | if not data then return end 240 | local output_lines = vim.split(data, "\n", {}) 241 | for _, line in ipairs(output_lines) do 242 | pcall(function() vim.api.nvim_chan_send(chan, line .. "\r\n") end) 243 | end 244 | end), 245 | writer = lines, 246 | }) 247 | 248 | previewer:start() 249 | end 250 | 251 | ---@param entry DocEntry 252 | ---@param bufnr number 253 | ---@param float boolean 254 | M.open = function(entry, bufnr, float) 255 | vim.api.nvim_buf_set_option(bufnr, "modifiable", false) 256 | 257 | if not float then 258 | vim.api.nvim_set_current_buf(bufnr) 259 | else 260 | local win = nil 261 | local last_win = state.get("last_win") 262 | local float_opts = config.get_float_options() 263 | 264 | if last_win and vim.api.nvim_win_is_valid(last_win) then 265 | win = last_win 266 | vim.api.nvim_win_set_buf(win, bufnr) 267 | else 268 | win = vim.api.nvim_open_win(bufnr, true, float_opts) 269 | state.set("last_win", win) 270 | end 271 | 272 | vim.wo[win].wrap = config.options.wrap 273 | vim.wo[win].linebreak = config.options.wrap 274 | vim.wo[win].nu = false 275 | vim.wo[win].relativenumber = false 276 | vim.wo[win].conceallevel = 3 277 | end 278 | 279 | local ignore = vim.tbl_contains(config.options.cmd_ignore, entry.alias) 280 | 281 | if config.options.previewer_cmd and not ignore then 282 | M.render_cmd(bufnr) 283 | else 284 | vim.bo[bufnr].ft = "markdown" 285 | end 286 | 287 | vim.bo[bufnr].keywordprg = ":DevdocsKeywordprg" 288 | 289 | state.set("last_buf", bufnr) 290 | keymaps.set_keymaps(bufnr, entry) 291 | config.options.after_open(bufnr) 292 | end 293 | 294 | ---@param keyword string 295 | M.keywordprg = function(keyword) 296 | local alias = state.get("current_doc") 297 | local float = state.get("last_mode") == "float" 298 | local bufnr = vim.api.nvim_create_buf(false, false) 299 | local entries = list.get_doc_entries({ alias }) 300 | local entry 301 | 302 | local function callback(filtered_lines) 303 | vim.bo[bufnr].modifiable = true 304 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, filtered_lines) 305 | vim.bo[bufnr].modifiable = false 306 | 307 | M.open(entry, bufnr, float) 308 | end 309 | 310 | for _, value in pairs(entries or {}) do 311 | if value.name == keyword or value.link == keyword then 312 | entry = value 313 | M.read_entry_async(entry, callback) 314 | end 315 | end 316 | 317 | if not entry then log.error("No documentation found for " .. keyword) end 318 | end 319 | 320 | return M 321 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/pickers.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local finders = require("telescope.finders") 4 | local pickers = require("telescope.pickers") 5 | local previewers = require("telescope.previewers") 6 | local state = require("telescope.state") 7 | local actions = require("telescope.actions") 8 | local action_state = require("telescope.actions.state") 9 | local config = require("telescope.config").values 10 | local entry_display = require("telescope.pickers.entry_display") 11 | 12 | local log = require("nvim-devdocs.log") 13 | local list = require("nvim-devdocs.list") 14 | local operations = require("nvim-devdocs.operations") 15 | local transpiler = require("nvim-devdocs.transpiler") 16 | local plugin_state = require("nvim-devdocs.state") 17 | local plugin_config = require("nvim-devdocs.config") 18 | 19 | local metadata_previewer = previewers.new_buffer_previewer({ 20 | title = "Metadata", 21 | define_preview = function(self, entry) 22 | local bufnr = self.state.bufnr 23 | local transpiled = transpiler.to_yaml(entry.value) 24 | local lines = vim.split(transpiled, "\n") 25 | 26 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 27 | vim.bo[bufnr].ft = "yaml" 28 | end, 29 | }) 30 | 31 | ---@param prompt string 32 | ---@param entries RegisteryEntry[] 33 | ---@param on_attach function 34 | ---@return Picker 35 | local function new_registery_picker(prompt, entries, on_attach) 36 | return pickers.new(plugin_config.options.telescope, { 37 | prompt_title = prompt, 38 | finder = finders.new_table({ 39 | results = entries, 40 | entry_maker = function(entry) 41 | return { 42 | value = entry, 43 | display = entry.slug:gsub("~", "-"), 44 | ordinal = entry.slug:gsub("~", "-"), 45 | } 46 | end, 47 | }), 48 | sorter = config.generic_sorter(plugin_config.options.telescope), 49 | previewer = metadata_previewer, 50 | attach_mappings = on_attach, 51 | }) 52 | end 53 | 54 | local doc_previewer = previewers.new_buffer_previewer({ 55 | title = "Preview", 56 | keep_last_buf = true, 57 | define_preview = function(self, entry) 58 | local bufnr = self.state.bufnr 59 | 60 | operations.read_entry_async(entry.value, function(filtered_lines) 61 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, filtered_lines) 62 | 63 | if plugin_config.options.previewer_cmd and plugin_config.options.picker_cmd then 64 | plugin_state.set("preview_lines", filtered_lines) 65 | operations.render_cmd(bufnr, true) 66 | else 67 | vim.bo[bufnr].ft = "markdown" 68 | end 69 | end) 70 | end, 71 | }) 72 | 73 | local function open_doc(selection, float) 74 | local bufnr = state.get_global_key("last_preview_bufnr") 75 | local picker_cmd = plugin_config.options.picker_cmd 76 | 77 | if picker_cmd or not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then 78 | bufnr = vim.api.nvim_create_buf(false, true) 79 | local lines = plugin_state.get("preview_lines") or operations.read_entry(selection.value) 80 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 81 | end 82 | 83 | plugin_state.set("last_mode", float and "float" or "normal") 84 | operations.open(selection.value, bufnr, float) 85 | end 86 | 87 | M.installation_picker = function() 88 | local non_installed = list.get_non_installed_registery() 89 | 90 | if not non_installed then return end 91 | 92 | local picker = new_registery_picker("Install documentation", non_installed, function() 93 | actions.select_default:replace(function(prompt_bufnr) 94 | local selection = action_state.get_selected_entry() 95 | 96 | actions.close(prompt_bufnr) 97 | operations.install(selection.value) 98 | end) 99 | return true 100 | end) 101 | 102 | picker:find() 103 | end 104 | 105 | M.uninstallation_picker = function() 106 | local installed = list.get_installed_registery() 107 | 108 | if not installed then return end 109 | 110 | local picker = new_registery_picker("Uninstall documentation", installed, function() 111 | actions.select_default:replace(function(prompt_bufnr) 112 | local selection = action_state.get_selected_entry() 113 | local alias = selection.value.slug:gsub("~", "-") 114 | 115 | actions.close(prompt_bufnr) 116 | operations.uninstall(alias) 117 | end) 118 | return true 119 | end) 120 | 121 | picker:find() 122 | end 123 | 124 | M.update_picker = function() 125 | local updatable = list.get_updatable_registery() 126 | 127 | if not updatable then return end 128 | 129 | local picker = new_registery_picker("Update documentation", updatable, function() 130 | actions.select_default:replace(function(prompt_bufnr) 131 | local selection = action_state.get_selected_entry() 132 | local alias = selection.value.slug:gsub("~", "-") 133 | 134 | actions.close(prompt_bufnr) 135 | operations.install(alias, true, true) 136 | end) 137 | return true 138 | end) 139 | 140 | picker:find() 141 | end 142 | 143 | ---@param entries DocEntry[] 144 | ---@param float? boolean 145 | M.open_picker = function(entries, float) 146 | local displayer = entry_display.create({ 147 | separator = " ", 148 | items = { 149 | { remaining = true }, 150 | { remaining = true }, 151 | }, 152 | }) 153 | 154 | local picker = pickers.new(plugin_config.options.telescope, { 155 | prompt_title = "Select an entry", 156 | finder = finders.new_table({ 157 | results = entries, 158 | entry_maker = function(entry) 159 | return { 160 | value = entry, 161 | display = function() 162 | return displayer({ 163 | { string.format("[%s]", entry.alias), "markdownH1" }, 164 | { entry.name, "markdownH2" }, 165 | }) 166 | end, 167 | ordinal = string.format("[%s] %s", entry.alias, entry.name), 168 | } 169 | end, 170 | }), 171 | sorter = config.generic_sorter(plugin_config.options.telescope), 172 | previewer = doc_previewer, 173 | attach_mappings = function() 174 | actions.select_default:replace(function(prompt_bufnr) 175 | actions.close(prompt_bufnr) 176 | 177 | local selection = action_state.get_selected_entry() 178 | 179 | if selection then 180 | plugin_state.set("current_doc", selection.value.alias) 181 | open_doc(selection, float) 182 | end 183 | end) 184 | 185 | return true 186 | end, 187 | }) 188 | 189 | picker:find() 190 | end 191 | 192 | ---@param alias string 193 | ---@param float? boolean 194 | M.open_picker_alias = function(alias, float) 195 | local entries = list.get_doc_entries({ alias }) 196 | 197 | if not entries then return end 198 | 199 | if vim.tbl_isempty(entries) then 200 | log.error(alias .. " documentation is not installed") 201 | else 202 | plugin_state.set("current_doc", alias) 203 | M.open_picker(entries, float) 204 | end 205 | end 206 | 207 | return M 208 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/state.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | ---@alias nvim_devdocs.StateKey "current_doc" | "preview_lines" | "last_win" | "last_mode" | "last_buf" 4 | 5 | local state = { 6 | current_doc = nil, -- ex: "javascript", used for `keywordprg` 7 | preview_lines = nil, 8 | last_win = nil, 9 | last_mode = nil, -- "normal" | "float" 10 | last_buf = nil, 11 | } 12 | 13 | ---@param key nvim_devdocs.StateKey 14 | ---@return any 15 | M.get = function(key) return state[key] end 16 | 17 | ---@param key nvim_devdocs.StateKey 18 | M.set = function(key, value) state[key] = value end 19 | 20 | return M 21 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/transpiler.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: need-check-nil, param-type-mismatch 2 | local M = {} 3 | 4 | local normalize_html = function(str) 5 | local symbol_map = { 6 | ["<"] = "<", 7 | [">"] = ">", 8 | ["&"] = "&", 9 | ["""] = '"', 10 | ["'"] = "'", 11 | [" "] = " ", 12 | ["©"] = "©", 13 | ["–"] = "–", 14 | } 15 | 16 | for key, value in pairs(symbol_map) do 17 | str = str:gsub(key, value) 18 | end 19 | 20 | return str 21 | end 22 | 23 | local tag_mappings = { 24 | h1 = { left = "# ", right = "\n\n" }, 25 | h2 = { left = "## ", right = "\n\n" }, 26 | h3 = { left = "### ", right = "\n\n" }, 27 | h4 = { left = "#### ", right = "\n\n" }, 28 | h5 = { left = "##### ", right = "\n\n" }, 29 | h6 = { left = "###### ", right = "\n\n" }, 30 | span = {}, 31 | nav = {}, 32 | header = {}, 33 | div = { left = "\n", right = "\n" }, 34 | section = { right = "\n" }, 35 | p = { right = "\n\n" }, 36 | ul = { right = "\n" }, 37 | ol = { right = "\n" }, 38 | dl = { right = "\n" }, 39 | dt = { right = "\n" }, 40 | figure = { right = "\n" }, 41 | dd = { left = ": " }, 42 | pre = { left = "\n```\n", right = "\n```\n" }, 43 | code = { left = "`", right = "`" }, 44 | samp = { left = "`", right = "`" }, 45 | var = { left = "`", right = "`" }, 46 | kbd = { left = "`", right = "`" }, 47 | mark = { left = "`", right = "`" }, 48 | tt = { left = "`", right = "`" }, 49 | b = { left = "`", right = "`" }, 50 | strong = { left = "**", right = "**" }, 51 | i = { left = "_", right = "_" }, 52 | s = { left = "~~", right = "~~" }, 53 | em = { left = "_", right = "_" }, 54 | small = { left = "_", right = "_" }, 55 | sup = { left = "^", right = "^" }, 56 | blockquote = { left = "> " }, 57 | summary = { left = "<", right = ">" }, 58 | 59 | -- TODO: Handle these correctly 60 | math = { left = "```math\n", right = "\n```" }, 61 | annotation = { left = "[", right = "]" }, 62 | semantics = {}, 63 | mspace = { left = " " }, 64 | msup = { right = "^" }, 65 | mfrac = { right = "/" }, 66 | mrow = {}, 67 | mo = {}, 68 | mn = {}, 69 | mi = {}, 70 | 71 | br = { right = "\n" }, 72 | hr = { right = "---\n\n" }, 73 | } 74 | 75 | local inline_tags = { 76 | "span", 77 | "a", 78 | "strong", 79 | "em", 80 | "abbr", 81 | "code", 82 | "tt", 83 | "i", 84 | "s", 85 | "b", 86 | "sub", 87 | "sup", 88 | "mark", 89 | "small", 90 | "var", 91 | "kbd", 92 | } 93 | 94 | local skipable_tags = { 95 | "input", 96 | "use", 97 | "svg", 98 | "button", 99 | 100 | -- exceptions, (parent) table -> child 101 | "tr", 102 | "td", 103 | "th", 104 | "thead", 105 | "tbody", 106 | } 107 | 108 | local monospace_tags = { 109 | "code", 110 | "tt", 111 | "samp", 112 | "kbd", 113 | } 114 | 115 | local is_inline_tag = function(tag_name) return vim.tbl_contains(inline_tags, tag_name) end 116 | local is_skipable_tag = function(tag_name) return vim.tbl_contains(skipable_tags, tag_name) end 117 | local is_monospace_tag = function(tag_name) return vim.tbl_contains(monospace_tags, tag_name) end 118 | 119 | local transpiler = {} 120 | 121 | ---HTML to Markdown transpiler 122 | ---@param source string the string to convert 123 | ---@param section_map table? a map of the doc sections for indexing 124 | function transpiler:new(source, section_map) 125 | local new = { 126 | parser = vim.treesitter.get_string_parser(source, "html"), 127 | lines = vim.split(source, "\n"), 128 | result = "", 129 | section_map = section_map or {}, 130 | sections = {}, 131 | } 132 | new.parser:parse() 133 | self.__index = self 134 | 135 | return setmetatable(new, self) 136 | end 137 | 138 | ---Returns the text at a given range, zero based index 139 | function transpiler:get_text_range(row_start, col_start, row_end, col_end) 140 | local extracted_lines = {} 141 | 142 | for i = row_start, row_end do 143 | local line = self.lines[i + 1] 144 | 145 | if row_start == row_end then 146 | line = line:sub(col_start + 1, col_end) 147 | elseif i == row_start then 148 | line = line:sub(col_start + 1) 149 | elseif i == row_end then 150 | line = line:sub(1, col_end) 151 | end 152 | 153 | table.insert(extracted_lines, line) 154 | end 155 | 156 | return table.concat(extracted_lines, "\n") 157 | end 158 | 159 | ---@param node TSNode 160 | ---@return string 161 | function transpiler:get_node_text(node) 162 | if not node then return "" end 163 | 164 | local row_start, col_start = node:start() 165 | local row_end, col_end = node:end_() 166 | local text = self:get_text_range(row_start, col_start, row_end, col_end) 167 | 168 | return text 169 | end 170 | 171 | ---@param node TSNode 172 | function transpiler:get_node_tag_name(node) 173 | if not node then return "" end 174 | 175 | local tag_name = nil 176 | local child = node:named_child() 177 | 178 | if child then 179 | local tag_node = child:named_child() 180 | if tag_node then tag_name = self:get_node_text(tag_node) end 181 | end 182 | 183 | return tag_name 184 | end 185 | 186 | ---@param node TSNode 187 | ---@param tag_name boolean 188 | function transpiler:has_parent_tag(node, tag_name) 189 | local current = node:parent() 190 | 191 | while current do 192 | local parent_tag_name = self:get_node_tag_name(current) 193 | if parent_tag_name == tag_name then return true end 194 | current = current:parent() 195 | end 196 | 197 | return false 198 | end 199 | 200 | ---@param node TSNode 201 | ---@return table 202 | function transpiler:get_node_attributes(node) 203 | if not node then return {} end 204 | 205 | local attributes = {} 206 | local tag_node = node:named_child() 207 | 208 | if not tag_node then return {} end 209 | 210 | local tag_children = tag_node:named_children() 211 | 212 | for i = 2, #tag_children do 213 | local attribute_node = tag_children[i] 214 | local attribute_name_node = attribute_node:named_child() 215 | local attribute_name = self:get_node_text(attribute_name_node) 216 | local value = "" 217 | 218 | if attribute_name_node and attribute_name_node:next_named_sibling() then 219 | local quotetd_value_node = attribute_name_node:next_named_sibling() 220 | local value_node = quotetd_value_node:named_child() 221 | if value_node then value = self:get_node_text(value_node) end 222 | end 223 | 224 | attributes[attribute_name] = value 225 | end 226 | 227 | return attributes 228 | end 229 | 230 | ---Removes start and end tag 231 | ---@param node TSNode 232 | ---@return TSNode[] 233 | function transpiler:filter_tag_children(node) 234 | local children = node:named_children() 235 | local filtered = vim.tbl_filter(function(child) 236 | local type = child:type() 237 | return type ~= "start_tag" and type ~= "end_tag" 238 | end, children) 239 | 240 | return filtered 241 | end 242 | 243 | ---Converts HTML to Markdown 244 | ---@return string, table 245 | function transpiler:transpile() 246 | self.parser:for_each_tree(function(tree) 247 | local root = tree:root() 248 | if root then 249 | local children = root:named_children() 250 | for _, node in ipairs(children) do 251 | self.result = self.result .. self:eval(node) 252 | end 253 | end 254 | end) 255 | 256 | self.result = self.result:gsub("\n\n\n+", "\n\n") 257 | 258 | return self.result, self.sections 259 | end 260 | 261 | ---Returns the Markdown representation of a node 262 | ---@param node TSNode 263 | ---@return string 264 | function transpiler:eval(node) 265 | local result = "" 266 | local node_type = node:type() 267 | local node_text = self:get_node_text(node) 268 | local tag_name = self:get_node_tag_name(node) 269 | local attributes = self:get_node_attributes(node) 270 | 271 | if node_type == "text" or node_type == "entity" then 272 | result = result .. normalize_html(node_text) 273 | elseif node_type == "element" then 274 | local tag_node = node:named_child() 275 | local tag_type = tag_node:type() 276 | local parent_node = node:parent() 277 | local parent_tag_name = self:get_node_tag_name(parent_node) 278 | 279 | if tag_type == "start_tag" then 280 | local children = self:filter_tag_children(node) 281 | 282 | for _, child in ipairs(children) do 283 | result = result .. self:eval_child(child, node) 284 | end 285 | end 286 | 287 | if is_skipable_tag(tag_name) then return "" end 288 | if is_monospace_tag(tag_name) and self:has_parent_tag(node, "pre") then return result end -- avoid nested code blocks 289 | 290 | if tag_name == "a" then 291 | result = string.format("[%s](%s)", result, attributes.href) 292 | elseif tag_name == "img" and string.match(attributes.src or "", "^data:") then 293 | result = string.format("![%s](%s)\n", attributes.alt, "data:inline_image") 294 | elseif tag_name == "img" then 295 | result = string.format("![%s](%s)\n", attributes.alt, attributes.src) 296 | elseif tag_name == "pre" and attributes["data-language"] then 297 | result = "\n```" .. attributes["data-language"] .. "\n" .. result .. "\n```\n" 298 | elseif tag_name == "pre" and attributes["class"] then 299 | local language = attributes["class"]:match("language%-(.+)") 300 | result = "\n```" .. (language or "") .. "\n" .. result .. "\n```\n" 301 | elseif tag_name == "abbr" then 302 | result = string.format("%s(%s)", result, attributes.title) 303 | elseif tag_name == "iframe" then 304 | result = string.format("[%s](%s)\n", attributes.title, attributes.src) 305 | elseif tag_name == "details" then 306 | result = "..." 307 | elseif tag_name == "table" then 308 | result = self:eval_table(node) .. "\n" 309 | elseif tag_name == "li" then 310 | if parent_tag_name == "ul" then result = "- " .. result .. "\n" end 311 | if parent_tag_name == "ol" then 312 | local siblings = self:filter_tag_children(parent_node) 313 | for i, sibling in ipairs(siblings) do 314 | if node:equal(sibling) then result = i .. ". " .. result .. "\n" end 315 | end 316 | end 317 | else 318 | local map = tag_mappings[tag_name] 319 | if map then 320 | local left = map.left or "" 321 | local right = map.right or "" 322 | result = left .. result .. right 323 | else 324 | result = result .. node_text 325 | end 326 | end 327 | end 328 | 329 | local parent = node:parent() 330 | local sibling = node:next_named_sibling() 331 | 332 | -- checks if there should be additional spaces or linebreaks 333 | if parent and parent:type() == "fragment" and sibling then 334 | local start_row, start_col = node:end_() 335 | local target_row, target_col = sibling:start() 336 | local is_inline = is_inline_tag(tag_name) or not tag_name -- is text 337 | 338 | if is_inline and start_col ~= target_col then result = result .. " " end 339 | if is_inline then result = result .. string.rep("\n", target_row - start_row) end 340 | end 341 | 342 | -- use the Markdown text for indexing docs in the index.json file 343 | local id = attributes.id 344 | 345 | if id and self.section_map and vim.tbl_contains(self.section_map, id) then 346 | table.insert(self.sections, { id = id, md_path = vim.trim(result) }) 347 | end 348 | 349 | return result 350 | end 351 | 352 | ---@param node TSNode 353 | ---@param parent_node TSNode 354 | ---@return string 355 | function transpiler:eval_child(node, parent_node) 356 | local result = self:eval(node) 357 | local tag_name = self:get_node_tag_name(node) 358 | local sibling = node:next_named_sibling() 359 | local attributes = self:get_node_attributes(parent_node) 360 | 361 | -- checks if there should be additional spaces/characters between two elements 362 | if sibling then 363 | local start_row, start_col = node:end_() 364 | local target_row, target_col = sibling:start() 365 | 366 | -- The
 HTML element represents preformatted text
367 |     -- which is to be presented exactly as written in the HTML file
368 |     -- FIXME: this implementation is still not completetely corect
369 |     if self:has_parent_tag(node, "pre") or attributes.class == "_rfc-pre" then
370 |       local child = sibling:named_child()
371 | 
372 |       -- skip all the tags to get the actual text offset, see #56
373 |       while child do
374 |         local child_sibling = child:next_named_sibling()
375 | 
376 |         if child:type() == "start_tag" and child_sibling then
377 |           local c_row, c_col = child:end_()
378 |           local s_row, s_col = child_sibling:start()
379 | 
380 |           if c_row == s_row and c_col == s_col then
381 |             child = child_sibling:named_child()
382 |           else
383 |             start_row, start_col = c_row, c_col
384 |             target_row, target_col = s_row, s_col
385 |             break
386 |           end
387 |         else
388 |           break
389 |         end
390 |       end
391 | 
392 |       local row, col = start_row, start_col
393 | 
394 |       while row ~= target_row or col ~= target_col do
395 |         local char = self:get_text_range(row, col, row, col + 1)
396 | 
397 |         if char ~= "" then
398 |           result = result .. char
399 |           col = col + 1
400 |         else
401 |           result = result .. "\n"
402 |           row, col = row + 1, 0
403 |         end
404 |       end
405 |     else
406 |       local is_inline = is_inline_tag(tag_name) or not tag_name -- is text
407 | 
408 |       if is_inline and start_col ~= target_col then result = result .. " " end
409 |       if is_inline then result = result .. string.rep("\n", target_row - start_row) end
410 |     end
411 |   end
412 | 
413 |   return result
414 | end
415 | 
416 | ---Converts  tag, table nodes are evaluated from parent to child
417 | ---@param node TSNode
418 | ---@return string
419 | function transpiler:eval_table(node)
420 |   local result = ""
421 |   local children = self:filter_tag_children(node)
422 |   ---@type TSNode[]
423 |   local tr_nodes = {}
424 |   local first_child_tag = self:get_node_tag_name(children[1])
425 | 
426 |   if first_child_tag == "tr" then
427 |     vim.list_extend(tr_nodes, children)
428 |   else
429 |     -- extracts tr from thead, tbody
430 |     for _, child in ipairs(children) do
431 |       vim.list_extend(tr_nodes, self:filter_tag_children(child))
432 |     end
433 |   end
434 | 
435 |   local max_col_len_map = {}
436 |   local result_map = {} -- the converted text of each col
437 |   local colspan_map = {} -- colspan attribute
438 | 
439 |   for i, tr in ipairs(tr_nodes) do
440 |     local tr_children = self:filter_tag_children(tr)
441 | 
442 |     result_map[i] = {}
443 |     colspan_map[i] = {}
444 | 
445 |     for j, tcol_node in ipairs(tr_children) do
446 |       local inner_result = ""
447 |       local tcol_children = self:filter_tag_children(tcol_node)
448 |       local attributes = self:get_node_attributes(tcol_node)
449 | 
450 |       for _, tcol_child in ipairs(tcol_children) do
451 |         inner_result = inner_result .. self:eval(tcol_child)
452 |       end
453 | 
454 |       inner_result = inner_result:gsub("\n", "")
455 |       result_map[i][j] = inner_result
456 |       colspan_map[i][j] = attributes.colspan or 1
457 | 
458 |       if not max_col_len_map[j] then max_col_len_map[j] = 1 end
459 |       if max_col_len_map[j] < #inner_result then max_col_len_map[j] = #inner_result end
460 |     end
461 |   end
462 | 
463 |   -- draws columns evenly
464 |   for i = 1, #tr_nodes do
465 |     local current_col = 1
466 |     for j, value in ipairs(result_map[i]) do
467 |       local colspan = tonumber(colspan_map[i][j])
468 |       local col_len = max_col_len_map[current_col]
469 | 
470 |       if not col_len then break end
471 | 
472 |       result = result .. "| " .. value .. string.rep(" ", col_len - #value + 1)
473 |       current_col = current_col + 1
474 | 
475 |       if colspan > 1 then
476 |         local len = current_col + colspan - 1
477 |         while current_col < len do
478 |           local spacing = max_col_len_map[current_col]
479 |           if spacing then result = result .. string.rep(" ", spacing + 3) end
480 |           current_col = current_col + 1
481 |         end
482 |       end
483 |     end
484 | 
485 |     result = result .. "|\n"
486 | 
487 |     -- generates row separator
488 |     if i == 1 then
489 |       current_col = 1
490 |       for j = 1, #result_map[i] do
491 |         local colspan = tonumber(colspan_map[i][j])
492 |         local col_len = max_col_len_map[current_col]
493 | 
494 |         if not col_len then break end
495 | 
496 |         local line = string.rep("-", col_len)
497 |         current_col = current_col + 1
498 | 
499 |         if colspan > 1 then
500 |           local len = current_col + colspan - 1
501 |           while current_col < len do
502 |             local spacing = max_col_len_map[current_col]
503 |             if spacing then line = line .. string.rep("-", spacing + 3) end
504 |             current_col = current_col + 1
505 |           end
506 |         end
507 |         result = result .. "| " .. string.gsub(line, "\n", "") .. " "
508 |       end
509 | 
510 |       result = result .. "|\n"
511 |     end
512 |   end
513 | 
514 |   return result
515 | end
516 | 
517 | ---Converts a `RegisteryEntry` to yaml
518 | ---@param entry RegisteryEntry
519 | ---@return string
520 | M.to_yaml = function(entry)
521 |   local lines = {}
522 | 
523 |   for key, value in pairs(entry) do
524 |     if key == "attribution" then
525 |       value = normalize_html(value)
526 |       value = value:gsub("(.*)", "%1")
527 |       value = value:gsub("
", "") 528 | value = value:gsub("\n *", " ") 529 | end 530 | if key == "links" then value = vim.fn.json_encode(value) end 531 | table.insert(lines, key .. ": " .. value) 532 | end 533 | 534 | return table.concat(lines, "\n") 535 | end 536 | 537 | ---Converts HTML to markdown 538 | ---@param html string 539 | ---@param section_map table? 540 | ---@return string, table 541 | M.html_to_md = function(html, section_map) 542 | local t = transpiler:new(html, section_map) 543 | return t:transpile() 544 | end 545 | 546 | return M 547 | -------------------------------------------------------------------------------- /lua/nvim-devdocs/types.lua: -------------------------------------------------------------------------------- 1 | ---Represents an entry in the Devdocs registery 2 | ---@see https://devdocs.io/docs.json 3 | ---@class RegisteryEntry 4 | ---@field name string 5 | ---@field slug string 6 | ---@field type string 7 | ---@field version number 8 | ---@field release string 9 | ---@field mtime number 10 | ---@field db_size number 11 | ---@field links? table 12 | ---@field attribution string 13 | 14 | ---Represents an entry in the index.json file 15 | ---NOTE: alias and next_path are filled at runtime 16 | ---@see nvim_devdocs_path/index.json 17 | ---@class DocEntry 18 | ---@field name string 19 | ---@field path string 20 | ---@field link string 21 | ---@field alias? string 22 | ---@field next_path? string 23 | 24 | ---Represents a type in the index.json file 25 | ---@class DocType 26 | ---@field slug string 27 | ---@field name string 28 | ---@field count number 29 | 30 | ---Represents a doc in the index.json file 31 | ---@class DocIndex 32 | ---@field types DocType[] 33 | ---@field entries DocEntry[] 34 | 35 | ---Represents the index.json file 36 | ---@alias IndexTable table 37 | 38 | ---Represents the docs-lock.json file 39 | ---@alias LockTable table 40 | -------------------------------------------------------------------------------- /test/init.lua: -------------------------------------------------------------------------------- 1 | -- minimal init.lua 2 | local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" 3 | 4 | if not vim.loop.fs_stat(lazypath) then 5 | vim.fn.system({ 6 | "git", 7 | "clone", 8 | "--filter=blob:none", 9 | "https://github.com/folke/lazy.nvim.git", 10 | "--branch=stable", 11 | lazypath, 12 | }) 13 | end 14 | 15 | vim.opt.rtp:prepend(lazypath) 16 | 17 | local plugins = { 18 | "nvim-treesitter/nvim-treesitter", 19 | "nvim-lua/plenary.nvim", 20 | { dir = "../" }, 21 | } 22 | 23 | require("lazy").setup(plugins) 24 | -------------------------------------------------------------------------------- /test/transpiler_spec.lua: -------------------------------------------------------------------------------- 1 | local assert = require("luassert") 2 | local html_to_md = require("nvim-devdocs.transpiler").html_to_md 3 | 4 | describe("Transpiler", function() 5 | local test_cases = { 6 | { 7 | desc = "
", 8 | input = "

Hello World

", 9 | expected = "# Hello World\n\n", 10 | }, 11 | { 12 | desc = "", 13 | input = "alt", 14 | expected = "![alt](link)\n", 15 | }, 16 | { 17 | desc = "
    ", 18 | input = [[ 19 |
      20 |
    • Item 1
    • 21 |
    • Item 2
    • 22 |
    23 | ]], 24 | expected = "- Item 1\n- Item 2\n\n", 25 | }, 26 | { 27 | desc = "
      ", 28 | input = [[ 29 |
        30 |
      1. Item 1
      2. 31 |
      3. Item 2
      4. 32 |
      33 | ]], 34 | expected = "1. Item 1\n2. Item 2\n\n", 35 | }, 36 | { 37 | desc = "
      ",
       38 |       input = [[
       39 |         
      console.log("Hello World")
      40 | ]], 41 | expected = [[ 42 | 43 | ```javascript 44 | console.log("Hello World") 45 | ``` 46 | ]], 47 | }, 48 | { 49 | desc = "
       multiline",
       50 |       input = [[
       51 | 
       52 | * {
       53 |   margin: 0;
       54 |   padding: 0;
       55 | }
      56 | ]], 57 | expected = [[ 58 | 59 | ```css 60 | * { 61 | margin: 0; 62 | padding: 0; 63 | } 64 | ``` 65 | ]], 66 | }, 67 | { 68 | desc = "
       with tag children",
       69 |       input =
       70 |       [[
      <script>
       71 |   import { getAllContexts } from 'svelte';
       72 | 
       73 |   const contexts = getAllContexts();
       74 | </script>
      ]], 75 | expected = [[ 76 | 77 | ```svelte 78 | 83 | ``` 84 | ]], 85 | }, 86 | { 87 | desc = "
", 88 | input = [[ 89 |
90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 |
Header 1Header 2
Row 1, Cell 1Row 1, Cell 2
Row 2, Cell 1Row 2, Cell 2
103 | ]], 104 | expected = [[| Header 1 | Header 2 | 105 | | ------------- | ------------- | 106 | | Row 1, Cell 1 | Row 1, Cell 2 | 107 | | Row 2, Cell 1 | Row 2, Cell 2 | 108 | 109 | ]], 110 | }, 111 | } 112 | 113 | for _, case in ipairs(test_cases) do 114 | it("converts " .. case.desc, function() assert.same(case.expected, html_to_md(case.input)) end) 115 | end 116 | end) 117 | --------------------------------------------------------------------------------