├── .devcontainer ├── Dockerfile ├── config │ ├── init.lua │ └── lua │ │ └── plugins.lua ├── devcontainer.json └── setup_nvim_config.sh ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── general.md └── workflows │ └── ci.yml ├── .ignore ├── .luacheckrc ├── .styluaignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── docs ├── backends.md ├── config.md ├── gifs │ ├── completion.gif │ ├── docstring.gif │ └── suggest.gif └── openai.md ├── lua ├── nvim-magic-openai │ ├── _cache.lua │ ├── _completion.lua │ ├── _curl.lua │ ├── _http.lua │ ├── _log.lua │ ├── _random.lua │ ├── backend.lua │ └── init.lua └── nvim-magic │ ├── _buffer.lua │ ├── _fs.lua │ ├── _keymaps.lua │ ├── _log.lua │ ├── _templates.lua │ ├── _ui.lua │ ├── flows.lua │ ├── init.lua │ └── vendor │ └── lustache │ ├── LICENSE │ ├── notes.md │ ├── origin.txt │ └── src │ ├── lustache.lua │ └── lustache │ ├── context.lua │ ├── renderer.lua │ └── scanner.lua ├── prompts ├── alter │ ├── meta.json │ └── template.mustache └── docstring │ ├── meta.json │ └── template.mustache └── stylua.toml /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:jammy-20221130 2 | RUN apt-get update && apt-get install -y software-properties-common git ninja-build gettext libtool libtool-bin autoconf automake cmake g++ pkg-config unzip curl doxygen 3 | RUN git clone https://github.com/neovim/neovim 4 | RUN cd neovim && git checkout stable && make CMAKE_BUILD_TYPE=RelWithDebInfo 5 | RUN cd neovim && make install 6 | 7 | # UID 1000 conveniently corresponds to default user on Ubuntu desktop installation 8 | RUN groupadd -g 1000 ubuntu && \ 9 | useradd -r -m -u 1000 -g ubuntu ubuntu 10 | USER ubuntu -------------------------------------------------------------------------------- /.devcontainer/config/init.lua: -------------------------------------------------------------------------------- 1 | require('plugins') 2 | 3 | -- highlight anything which is yanked 4 | vim.api.nvim_exec( 5 | [[ 6 | augroup YankHighlight 7 | autocmd! 8 | autocmd TextYankPost * silent! lua vim.highlight.on_yank() 9 | augroup end 10 | ]] , 11 | false 12 | ) 13 | 14 | -- whitespace 15 | vim.cmd([[ 16 | set list lcs=trail:~,tab:»»,extends:>,precedes:<,nbsp:· 17 | ]]) 18 | vim.o.tabstop = 4 19 | vim.o.shiftwidth = vim.o.tabstop 20 | vim.o.breakindent = true 21 | vim.o.viewoptions = 'folds,cursor' 22 | 23 | -- search 24 | vim.o.ignorecase = true 25 | vim.o.smartcase = true 26 | vim.o.hlsearch = false 27 | 28 | -- other 29 | vim.cmd([[filetype plugin on]]) 30 | 31 | vim.cmd([[set clipboard+=unnamedplus]]) 32 | 33 | vim.o.completeopt = 'menu,menuone,noselect' 34 | vim.o.mouse = 'a' 35 | 36 | vim.o.number = true 37 | vim.o.termguicolors = true 38 | -------------------------------------------------------------------------------- /.devcontainer/config/lua/plugins.lua: -------------------------------------------------------------------------------- 1 | -- packer bootstrapping code from 2 | -- 3 | local ensure_packer = function() 4 | local fn = vim.fn 5 | local install_path = fn.stdpath('data') .. '/site/pack/packer/start/packer.nvim' 6 | if fn.empty(fn.glob(install_path)) > 0 then 7 | fn.system({ 'git', 'clone', '--depth', '1', 'https://github.com/wbthomason/packer.nvim', install_path }) 8 | vim.cmd [[packadd packer.nvim]] 9 | return true 10 | end 11 | return false 12 | end 13 | 14 | local packer_bootstrap = ensure_packer() 15 | 16 | return require('packer').startup(function(use) 17 | use 'wbthomason/packer.nvim' 18 | 19 | use({ 20 | '/workspaces/nvim-magic', 21 | config = function() 22 | require('nvim-magic').setup() 23 | end, 24 | requires = { 25 | 'nvim-lua/plenary.nvim', 26 | 'MunifTanjim/nui.nvim' 27 | } 28 | }) 29 | 30 | -- Automatically set up your configuration after cloning packer.nvim 31 | -- Put this at the end after all plugins 32 | if packer_bootstrap then 33 | require('packer').sync() 34 | end 35 | end) 36 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nvim-magic", 3 | "dockerFile": "Dockerfile", 4 | "customizations": { 5 | "vscode": { 6 | "settings": { 7 | "terminal.integrated.defaultProfile.linux": "bash" 8 | }, 9 | "extensions": [ 10 | "sumneko.lua" 11 | ] 12 | } 13 | }, 14 | "postCreateCommand": ".devcontainer/setup_nvim_config.sh" 15 | } -------------------------------------------------------------------------------- /.devcontainer/setup_nvim_config.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | mkdir -p ~/.config 3 | cp -R .devcontainer/config/ ~/.config/nvim -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | e.g. 16 | 1. Select some text 17 | 2. Press `mcs` 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Details (please complete the following information):** 26 | - OS: [e.g. Windows 10, Ubuntu 21.04] 27 | - Neovim version: [e.g. 0.5] 28 | - nvim-magic version: [e.g. v0.2.2, commit 4bd2777e2b31b545272e3b57155651b421f74f57] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: General 3 | about: Questions, feature ideas, anything else 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | branches: 5 | - 'master' 6 | pull_request: 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | container: 12 | image: registry.gitlab.com/jameshiew/toolbox/images/luaci:421900a1cf6f2f3e577cf84ec1f72a74d30eee35e4088c28ccadcdba5918b7a3 13 | steps: 14 | - uses: actions/checkout@v2.3.4 15 | - run: | 16 | luacheck . 17 | - run: | 18 | stylua --check . 19 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | exclude_files = { '**/vendor/**/*.lua' } 2 | globals = { 'vim' } 3 | -------------------------------------------------------------------------------- /.styluaignore: -------------------------------------------------------------------------------- 1 | **/vendor/**/*.lua 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.3.1] - 2021-09-29 8 | 9 | ### Changed 10 | * More logging for `curl` calls when debug logging is enabled 11 | 12 | ### Fixed 13 | * Selections made in visual lines mode ("V") should now work (thanks @naefl for debugging) 14 | * No longer necessary to select past the end of the final line in visual mode ("v") 15 | 16 | ## [0.3.0] - 2021-09-20 17 | 18 | ### Added 19 | * Added functions to get the current version e.g. `require('nvim-magic').version()` 20 | 21 | ### Changed 22 | * Prefixed Lua modules that are meant to be private with `_` 23 | * Gave `nvim-magic-openai` its own logger 24 | 25 | ### Fixed 26 | * Running stylua in CI (thanks @abatilo) 27 | 28 | ## [0.2.3] - 2021-09-18 29 | 30 | ### Added 31 | * Predefined `` commands to make mapping custom key sequences to flows easier 32 | 33 | ## [0.2.2] - 2021-09-16 34 | 35 | ### Changed 36 | * Vim notifications display buffer filename if possible 37 | * Use [lustache](https://github.com/Olivine-Labs/lustache) for filling out prompt templates 38 | 39 | ## [0.2.1] - 2021-09-14 40 | 41 | ### Changed 42 | * Disable requesting compressed HTTP responses on Windows as possibly it is less likely to be supported by Windows' `curl`s 43 | 44 | ### Fixed 45 | * Send an appropriate Vim notification in more cases when `curl` terminates with nonzero exit code, rather than raising a plenary.path error 46 | * Prevent logspam when OpenAI returns a non-200 HTTP status that was caused by a timer not being closed properly 47 | 48 | ## [0.2.0] - 2021-09-14 49 | 50 | ### Changed 51 | * Use Vim notifications for info messages instead of echoing them 52 | * Vendor forked curl code from [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) so that upstream plenary.nvim can be required as a dependency without issue 53 | * No longer freeze up Neovim while completions are being fetched ([#1](https://github.com/jameshiew/nvim-magic/issues/1)) 54 | 55 | ### Fixed 56 | * Using a default keymap in a buffer should work first time rather than just deselecting the current visual selection ([#2](https://github.com/jameshiew/nvim-magic/issues/2)) 57 | * OpenAI API key should no longer leak in an error message when `curl` times out, rather a generic request timed out error message is shown 58 | 59 | ## [0.1.0] - 2021-09-13 60 | 61 | [0.3.1]: https://github.com/jameshiew/nvim-magic/compare/0.3.0...0.3.1 62 | [0.3.0]: https://github.com/jameshiew/nvim-magic/compare/0.2.3...0.3.0 63 | [0.2.3]: https://github.com/jameshiew/nvim-magic/compare/v0.2.2...0.2.3 64 | [0.2.2]: https://github.com/jameshiew/nvim-magic/compare/v0.2.1...v0.2.2 65 | [0.2.1]: https://github.com/jameshiew/nvim-magic/compare/v0.2.0...v0.2.1 66 | [0.2.0]: https://github.com/jameshiew/nvim-magic/compare/v0.1.0...v0.2.0 67 | [0.1.0]: https://github.com/jameshiew/nvim-magic/releases/tag/v0.1.0 68 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions appreciated, please open an issue for questions, bug reports, feature requests, etc. 4 | 5 | ## Development 6 | 7 | PRs welcome! 8 | 9 | ### Conventions 10 | 11 | Trying to follow these conventions as best as possible: 12 | 13 | - [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 14 | - [Semantic Versioning](https://semver.org/) 15 | - [keep a changelog](https://keepachangelog.com/en/1.0.0/) 16 | 17 | Using [StyLua](https://github.com/JohnnyMorganz/StyLua) for formatting, formatting conventions in [`stylua.toml`](stylua.toml). -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021- 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 | > :warning: This repository is no longer maintained. The plugin was last tested to work with Neovim v0.8.3 and OpenAI's API as it was on 14 February, 2023. 2 | 3 | > 🍴 There is a fork with ChatGPT support at 4 | 5 | # nvim-magic 6 | 7 | ![ci](https://github.com/jameshiew/nvim-magic/actions/workflows/ci.yml/badge.svg) 8 | 9 | A pluggable framework for integrating AI code assistance into Neovim. The goals are to make using AI code assistance unobtrusive, and to make it easy to create and share new flows that use AI code assistance. Go to [quickstart](#quickstart) for how to install. It currently works with [OpenAI Codex](https://openai.com/blog/openai-codex/). 10 | 11 | ## Features 12 | 13 | ### Completion (`mcs`) 14 | 15 | Example of Python script being generated from a docstring 19 | 20 | ### Generating a docstring (`mds`) 21 | 22 | Example of Python function having a docstring generated 26 | 27 | ### Asking for an alteration (`mss`) 28 | 29 | Example of Python function being altered 33 | 34 | ## Quickstart 35 | 36 | ### Prerequisites 37 | 38 | - latest stable version of Neovim (nightly may work as well) 39 | - `curl` 40 | - OpenAI API key 41 | 42 | ### Installation 43 | 44 | ```lua 45 | -- using packer.nvim 46 | use({ 47 | 'jameshiew/nvim-magic', 48 | config = function() 49 | require('nvim-magic').setup() 50 | end, 51 | requires = { 52 | 'nvim-lua/plenary.nvim', 53 | 'MunifTanjim/nui.nvim' 54 | } 55 | }) 56 | ``` 57 | 58 | See [docs/config.md](docs/config.md) if you want to override the default configuration e.g. to turn off the default keymaps, or use a different OpenAI engine than the default one (`davinci-codex`). Your OpenAI account might not have access to `davinci-codex` if it is not in the OpenAI Codex private beta (as of 2022-02-02). 59 | 60 | Your API key should be made available to your Neovim session in an environment variable `OPENAI_API_KEY`. See [docs/openai.md](docs/openai.md) for more details. Note that API calls may be charged for by OpenAI depending on the engine used. 61 | 62 | ```shell 63 | export OPENAI_API_KEY='your-api-key-here' 64 | ``` 65 | 66 | ### Keymaps 67 | 68 | These flows have keymaps set by default for visual mode selections (though you can disable this by passing `use_default_keymap = false` in the setup config). 69 | 70 | You can map your own key sequences to the predefined ``s if you don't want to use the default keymaps. 71 | 72 | | `` | default keymap | mode | action | 73 | | ------------------------------------- | -------------- | ------ | ------------------------------------------ | 74 | | `nvim-magic-append-completion` | `mcs` | visual | Fetch and append completion | 75 | | `nvim-magic-suggest-alteration` | `mss` | visual | Ask for an alteration to the selected text | 76 | | `nvim-magic-suggest-docstring` | `mds` | visual | Generate a docstring | 77 | 78 | ## Development 79 | 80 | There is a [development container](https://containers.dev/) specified under the [`.devcontainer`](.devcontainer/) directory, that builds and installs the latest stable version of Neovim, and sets it up to use the local `nvim-magic` repo as a plugin. 81 | -------------------------------------------------------------------------------- /docs/backends.md: -------------------------------------------------------------------------------- 1 | # Backends 2 | 3 | A backend is a Lua table which implements the following `complete` method: 4 | 5 | ```lua 6 | function BackendImpl:complete(prompt_lines, max_tokens, stop, success, fail) 7 | -- [[ 8 | -- @param prompt_lines[type=table] a list of (full and partial) lines of text making up the prompt 9 | -- @param max_tokens[type=number] positive integer indicating the maximum number of tokens that should be returned 10 | -- @param stops[type=table] list of strings that will be recognized as stop codes when generating a completion - may be an empty table 11 | -- @param success[type=function] callback that should be called with the resulting completion string e.g. success(completion_text) 12 | -- @param fail[type=function] callback that should be called in case of an error with an appropriate error message e.g. fail(errmsg) 13 | -- ]] 14 | end 15 | ``` 16 | 17 | The `complete` method must call exactly one of the passed `success` or `fail` callbacks. 18 | 19 | A custom backend can be passed in the `backends` hashmap of a config passed to this plugin's `setup()` function e.g. 20 | 21 | ```lua 22 | require('nvim-magic').setup({backends = { custom = require('some-custom-backend').new() }}) 23 | ``` 24 | 25 | The custom backend can then be used with flows as normal, an example keymapping might look like 26 | 27 | ```lua 28 | vim.api.nvim_set_keymap( -- uses backends.custom instead of backends.default 29 | 'v', 30 | 'mccs', 31 | "lua require('nvim-magic.flows').append_completion(require('nvim-magic').backends.custom)", 32 | {} 33 | ) 34 | ``` 35 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | ## Options 4 | | key | default value | description | 5 | | ------------------ | ------------- | -------------------------------------------------------- | 6 | | use_default_keymap | true | enables the default keybind for injecting generated code | 7 | | backends | { default = require('nvim-magic-openai').new() } | used in code assistance flows, at least a `default` backend should be specified | 8 | 9 | For enabling logging, there is also the `NVIM_MAGIC_LOGLEVEL` environment variable which can be set to a loglevel. 10 | 11 | ```shell 12 | export NVIM_MAGIC_LOGLEVEL='debug' 13 | ``` 14 | 15 | ## Default 16 | The default config is as follows: 17 | 18 | ```lua 19 | { 20 | backends = { 21 | default = require('nvim-magic-openai').new(), 22 | }, 23 | use_default_keymap = true, 24 | } 25 | ``` 26 | 27 | ## Passing extra configuration 28 | Extra config should be passed as a hashtable to the initial `require('nvim-magic').setup()` call. It will be merged with the default config and override any default values. 29 | 30 | e.g. to override the default backend to use the OpenAI `cushman-codex` engine, and not set the default keymaps. 31 | 32 | ```lua 33 | require('nvim-magic').setup({ 34 | backends = { 35 | default = require('nvim-magic-openai').new({ 36 | api_endpoint = 'https://api.openai.com/v1/engines/cushman-codex/completions', 37 | }), 38 | }, 39 | use_default_keymap = false 40 | }) 41 | ``` 42 | -------------------------------------------------------------------------------- /docs/gifs/completion.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jameshiew/nvim-magic/9b13803df9ff4ca24418d6e0191ceed24ccf160e/docs/gifs/completion.gif -------------------------------------------------------------------------------- /docs/gifs/docstring.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jameshiew/nvim-magic/9b13803df9ff4ca24418d6e0191ceed24ccf160e/docs/gifs/docstring.gif -------------------------------------------------------------------------------- /docs/gifs/suggest.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jameshiew/nvim-magic/9b13803df9ff4ca24418d6e0191ceed24ccf160e/docs/gifs/suggest.gif -------------------------------------------------------------------------------- /docs/openai.md: -------------------------------------------------------------------------------- 1 | # OpenAI 2 | 3 | The OpenAI backend is provided and uses the [`davinci-codex`](https://beta.openai.com/docs/engines/codex-series-private-beta) engine by default as that is the most useful for code. 4 | 5 | ## Configuration 6 | 7 | ### Options 8 | 9 | | key | default value | description | 10 | | ------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | 11 | | api_endpoint | https://api.openai.com/v1/engines/davinci-codex/completions | v1 create completion HTTP POST endpoint, replace `davinci-codex` with another engine name to use a different engine | 12 | | cache | `{ dir_name = 'http' }` | requests/responses will be saved under to `stdpath('cache') .. '/nvim-magic-openai/' .. cache.dir_name`, set to `nil` to disable this functionality | 13 | 14 | ### API Key 15 | 16 | The API key must be provided via an `OPENAI_API_KEY` environment variable. 17 | 18 | ## Security 19 | 20 | Be aware of the following: 21 | 22 | Since the API key is passed as an environment variable to Neovim, it could be exposed to other processes. It will be visible if the environment of the Neovim process is inspected using something like `htop`, for example. 23 | 24 | -------------------------------------------------------------------------------- /lua/nvim-magic-openai/_cache.lua: -------------------------------------------------------------------------------- 1 | local cache = {} 2 | 3 | local log = require('nvim-magic-openai._log') 4 | local pathlib = require('plenary.path') 5 | 6 | local DIR = pathlib.new(vim.fn.stdpath('cache')):joinpath('nvim-magic-openai') 7 | DIR:mkdir({ parents = true, exist_ok = true }) 8 | 9 | local CacheMethods = {} 10 | 11 | function CacheMethods:save(filename, contents) 12 | local path = tostring(self.directory:joinpath(filename)) 13 | local fh, errmsg = io.open(path, 'w') 14 | assert(errmsg == nil, errmsg) 15 | fh:write(contents) 16 | fh:close() 17 | log.fmt_debug('Saved to cache path=%s', path) 18 | end 19 | 20 | local CacheMt = { __index = CacheMethods } 21 | 22 | function cache.new(directory) 23 | assert(type(directory) == 'string' and directory ~= '') 24 | local directory_path = DIR:joinpath(directory) 25 | directory_path:mkdir({ parents = true, exist_ok = true }) 26 | 27 | return setmetatable({ 28 | directory = directory_path, 29 | }, CacheMt) 30 | end 31 | 32 | -- for use when caching isn't wanted 33 | local DummyCacheMethods = {} 34 | 35 | function DummyCacheMethods:save(filename, _) -- luacheck: ignore 36 | log.fmt_debug('Dummy cache received request to save filename=%s', filename) 37 | end 38 | 39 | local DummyCacheMt = { __index = DummyCacheMethods } 40 | 41 | function cache.new_dummy() 42 | return setmetatable({}, DummyCacheMt) 43 | end 44 | 45 | return cache 46 | -------------------------------------------------------------------------------- /lua/nvim-magic-openai/_completion.lua: -------------------------------------------------------------------------------- 1 | local completion = {} 2 | 3 | function completion.new_request(prompt, max_tokens, stops) 4 | assert(type(prompt) == 'string', 'prompt must be a string') 5 | assert(type(max_tokens) == 'number', 'max tokens must be a number') 6 | if stops then 7 | assert(type(stops) == 'table', 'stops must be an array of strings') 8 | end 9 | 10 | return { 11 | prompt = prompt, 12 | temperature = 0, 13 | max_tokens = max_tokens, 14 | n = 1, 15 | top_p = 1, 16 | stop = stops, 17 | frequency_penalty = 0, 18 | presence_penalty = 0, 19 | } 20 | end 21 | 22 | function completion.extract_from(res_body) 23 | local ok, decoded = pcall(vim.fn.json_decode, res_body) 24 | if not ok then 25 | local errmsg = decoded 26 | error(string.format("couldn't decode response body errmsg=%s body=%s", errmsg, res_body)) 27 | end 28 | assert(decoded.choices ~= nil, 'no choices returned') 29 | return decoded.choices[1].text 30 | end 31 | 32 | return completion 33 | -------------------------------------------------------------------------------- /lua/nvim-magic-openai/_curl.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | *** 3 | This file adapted from https://github.com/nvim-lua/plenary.nvim 4 | 5 | MIT License 6 | 7 | Copyright (c) 2020 TJ DeVries 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | **** 27 | ]] 28 | --[[ 29 | Curl Wrapper 30 | 31 | all curl methods accepts 32 | 33 | url = "The url to make the request to.", (string) 34 | query = "url query, append after the url", (table) 35 | body = "The request body" (string/filepath/table) 36 | auth = "Basic request auth, 'user:pass', or {"user", "pass"}" (string/array) 37 | form = "request form" (table) 38 | raw = "any additonal curl args, it must be an array/list." (array) 39 | dry_run = "whether to return the args to be ran through curl." (boolean) 40 | output = "where to download something." (filepath) 41 | timeout = "timeout in milliseconds for synchronous requests" (number) 42 | return_job = "whether to return the Job and a function which can be called to check the response" (boolean) 43 | 44 | and returns table: 45 | 46 | exit = "The shell process exit code." (number) 47 | status = "The https response status." (number) 48 | headers = "The https response headers." (array) 49 | body = "The http response body." (string) 50 | 51 | see test/plenary/curl_spec.lua for examples. 52 | 53 | author = github.com/tami5 54 | ]] 55 | -- 56 | 57 | local util, parse = {}, {} 58 | 59 | -- Helpers -------------------------------------------------- 60 | ------------------------------------------------------------- 61 | local F = require('plenary.functional') 62 | local J = require('plenary.job') 63 | local P = require('plenary.path') 64 | 65 | local log = require('nvim-magic-openai._log') 66 | 67 | local DEFAULT_TIMEOUT = 10000 -- milliseconds 68 | 69 | local function is_windows() 70 | return P.path.sep == '\\' 71 | end 72 | 73 | local function default_compressed() 74 | return not is_windows() -- it may be more common on Windows for curl to be installed without compression support 75 | end 76 | 77 | -- Utils ---------------------------------------------------- 78 | ------------------------------------------------------------- 79 | 80 | util.url_encode = function(str) 81 | if type(str) ~= 'number' then 82 | str = str:gsub('\r?\n', '\r\n') 83 | str = str:gsub('([^%w%-%.%_%~ ])', function(c) 84 | return string.format('%%%02X', c:byte()) 85 | end) 86 | str = str:gsub(' ', '+') 87 | return str 88 | else 89 | return str 90 | end 91 | end 92 | 93 | util.kv_to_list = function(kv, prefix, sep) 94 | return vim.tbl_flatten(F.kv_map(function(kvp) 95 | return { prefix, kvp[1] .. sep .. kvp[2] } 96 | end, kv)) 97 | end 98 | 99 | util.kv_to_str = function(kv, sep, kvsep) 100 | return F.join( 101 | F.kv_map(function(kvp) 102 | return kvp[1] .. kvsep .. util.url_encode(kvp[2]) 103 | end, kv), 104 | sep 105 | ) 106 | end 107 | 108 | util.gen_dump_path = function() 109 | local path 110 | local id = string.gsub('xxxx4xxx', '[xy]', function(l) 111 | local v = (l == 'x') and math.random(0, 0xf) or math.random(0, 0xb) 112 | return string.format('%x', v) 113 | end) 114 | if is_windows() then 115 | path = string.format('%s\\AppData\\Local\\Temp\\plenary_curl_%s.headers', os.getenv('USERPROFILE'), id) 116 | else 117 | path = '/tmp/plenary_curl_' .. id .. '.headers' 118 | end 119 | return { '-D', path } 120 | end 121 | 122 | -- Parsers ---------------------------------------------------- 123 | --------------------------------------------------------------- 124 | 125 | parse.headers = function(t) 126 | if not t then 127 | return 128 | end 129 | local upper = function(str) 130 | return string.gsub(' ' .. str, '%W%l', string.upper):sub(2) 131 | end 132 | return util.kv_to_list( 133 | (function() 134 | local normilzed = {} 135 | for k, v in pairs(t) do 136 | normilzed[upper(k:gsub('_', '%-'))] = v 137 | end 138 | return normilzed 139 | end)(), 140 | '-H', 141 | ': ' 142 | ) 143 | end 144 | 145 | parse.data_body = function(t) 146 | if not t then 147 | return 148 | end 149 | return util.kv_to_list(t, '-d', '=') 150 | end 151 | 152 | parse.raw_body = function(xs) 153 | if not xs then 154 | return 155 | end 156 | if type(xs) == 'table' then 157 | return parse.data_body(xs) 158 | else 159 | return { '--data-raw', xs } 160 | end 161 | end 162 | 163 | parse.form = function(t) 164 | if not t then 165 | return 166 | end 167 | return util.kv_to_list(t, '-F', '=') 168 | end 169 | 170 | parse.curl_query = function(t) 171 | if not t then 172 | return 173 | end 174 | return util.kv_to_str(t, '&', '=') 175 | end 176 | 177 | parse.method = function(s) 178 | if not s then 179 | return 180 | end 181 | if s ~= 'head' then 182 | return { '-X', string.upper(s) } 183 | else 184 | return { '-I' } 185 | end 186 | end 187 | 188 | parse.file = function(p) 189 | if not p then 190 | return 191 | end 192 | return { '-d', '@' .. P.expand(P.new(p)) } 193 | end 194 | 195 | parse.auth = function(xs) 196 | if not xs then 197 | return 198 | end 199 | return { '-u', type(xs) == 'table' and util.kv_to_str(xs, nil, ':') or xs } 200 | end 201 | 202 | parse.url = function(xs, q) 203 | if not xs then 204 | return 205 | end 206 | q = parse.curl_query(q) 207 | if type(xs) == 'string' then 208 | return q and xs .. '?' .. q or xs 209 | elseif type(xs) == 'table' then 210 | error('Low level URL definition is not supported.') 211 | end 212 | end 213 | 214 | parse.accept_header = function(s) 215 | if not s then 216 | return 217 | end 218 | return { '-H', 'Accept: ' .. s } 219 | end 220 | 221 | -- Parse Request ------------------------------------------- 222 | ------------------------------------------------------------ 223 | parse.request = function(opts) 224 | if opts.body then 225 | local b = opts.body 226 | opts.body = nil 227 | if type(b) == 'table' then 228 | opts.data = b 229 | elseif P.is_file(P.new(b)) then 230 | opts.in_file = b 231 | elseif type(b) == 'string' then 232 | opts.raw_body = b 233 | end 234 | end 235 | 236 | local preargs = vim.tbl_flatten({ 237 | '-sSL', 238 | opts.dump, 239 | opts.compressed and '--compressed' or nil, 240 | parse.method(opts.method), 241 | parse.headers(opts.headers), 242 | parse.accept_header(opts.accept), 243 | parse.raw_body(opts.raw_body), 244 | parse.data_body(opts.data), 245 | parse.form(opts.form), 246 | parse.file(opts.in_file), 247 | }) 248 | local postargs = vim.tbl_flatten({ 249 | opts.raw, 250 | opts.output and { '-o', opts.output } or nil, 251 | parse.url(opts.url, opts.query), 252 | }) 253 | local nonauth_args = vim.deepcopy(preargs) 254 | vim.list_extend(nonauth_args, postargs) 255 | log.fmt_debug('Parsed request options nonauth_curl_args=%s', nonauth_args) 256 | 257 | local args = preargs 258 | vim.list_extend( 259 | args, 260 | vim.tbl_flatten({ 261 | parse.auth(opts.auth), 262 | }) 263 | ) 264 | vim.list_extend(args, postargs) 265 | return preargs, opts 266 | end 267 | 268 | -- Parse response ------------------------------------------ 269 | ------------------------------------------------------------ 270 | parse.response = function(lines, dump_path, code) 271 | local headers = P.readlines(dump_path) 272 | local status = tonumber(string.match(headers[1], '([%w+]%d+)')) 273 | local body = F.join(lines, '\n') 274 | 275 | vim.loop.fs_unlink(dump_path) 276 | table.remove(headers, 1) 277 | 278 | return { 279 | status = status, 280 | headers = headers, 281 | body = body, 282 | exit = code, 283 | } 284 | end 285 | 286 | local curl_job = function(args, dump_path, callback) 287 | return J:new({ 288 | command = 'curl', 289 | args = args, 290 | on_exit = function(j, code) 291 | if code ~= 0 then 292 | callback( 293 | nil, 294 | 'curl exited with code=' .. tostring(code) .. ' stderr=' .. vim.inspect(j:stderr_result()) 295 | ) 296 | return 297 | end 298 | local output = parse.response(j:result(), dump_path, code) 299 | callback(output) 300 | end, 301 | }) 302 | end 303 | 304 | local request = function(specs) 305 | local args, opts = parse.request(vim.tbl_extend('force', { 306 | compressed = default_compressed(), 307 | dry_run = false, 308 | dump = util.gen_dump_path(), 309 | }, specs)) 310 | 311 | if opts.dry_run then 312 | return args 313 | end 314 | 315 | local response 316 | local cb 317 | local errmsg 318 | if opts.callback then 319 | cb = opts.callback 320 | else 321 | cb = function(output, error_msg) 322 | if error_msg then 323 | errmsg = error_msg 324 | end 325 | response = output 326 | end 327 | end 328 | 329 | local job = curl_job(args, opts.dump[2], cb) 330 | 331 | if opts.return_job then 332 | return job, function() 333 | return response, errmsg 334 | end 335 | end 336 | 337 | if opts.callback then 338 | return job:start() 339 | else 340 | local timeout 341 | if opts.timeout then 342 | timeout = opts.timeout 343 | else 344 | timeout = DEFAULT_TIMEOUT 345 | end 346 | job:sync(timeout) 347 | return response 348 | end 349 | end 350 | 351 | -- Main ---------------------------------------------------- 352 | ------------------------------------------------------------ 353 | return (function() 354 | local spec = {} 355 | local partial = function(method) 356 | return function(url, opts) 357 | opts = opts or {} 358 | if type(url) == 'table' then 359 | opts = url 360 | spec.method = method 361 | else 362 | spec.url = url 363 | spec.method = method 364 | end 365 | opts = method == 'request' and opts or (vim.tbl_extend('keep', opts, spec)) 366 | return request(opts) 367 | end 368 | end 369 | return { 370 | get = partial('get'), 371 | post = partial('post'), 372 | put = partial('put'), 373 | head = partial('head'), 374 | patch = partial('patch'), 375 | delete = partial('delete'), 376 | request = partial('request'), 377 | } 378 | end)() 379 | -------------------------------------------------------------------------------- /lua/nvim-magic-openai/_http.lua: -------------------------------------------------------------------------------- 1 | local http = {} 2 | 3 | local curl = require('nvim-magic-openai._curl') 4 | local random = require('nvim-magic-openai._random') 5 | 6 | local DEFAULT_TIMEOUT_MILLI = 30000 7 | 8 | local ClientMethods = {} 9 | 10 | function ClientMethods:post(api_endpoint, json_body, api_key, success, fail) 11 | assert(type(api_key) == 'string', 'API key must be a string') 12 | assert(api_key ~= nil and api_key ~= '', 'empty API key') 13 | 14 | local req = { 15 | accept = 'application/json', 16 | raw_body = json_body, 17 | headers = { 18 | content_type = 'application/json', 19 | }, 20 | return_job = true, 21 | } 22 | 23 | local id = random.generate_timestamped_string() 24 | 25 | local req_filename = id .. '-request.json' 26 | local req_json = vim.fn.json_encode(req) 27 | assert(req.auth == nil, 'auth details should be added only after caching!') 28 | self.cache:save(req_filename, req_json) 29 | 30 | -- using basic auth instead of bearer auth because plenary.curl doesn't support bearer auth rn 31 | req.auth = ':' .. api_key 32 | 33 | local job, res_fn = curl.post(api_endpoint, req) 34 | job:start() 35 | 36 | local timer = vim.loop.new_timer() 37 | local interval_ms = 100 38 | local elapsed_ms = 0 39 | timer:start( 40 | 0, 41 | interval_ms, 42 | vim.schedule_wrap(function() 43 | local res, errmsg = res_fn() 44 | if errmsg then 45 | timer:close() 46 | fail('error: ' .. errmsg) 47 | return 48 | end 49 | if res then 50 | local res_filename = id .. '-response.json' 51 | local res_json = vim.fn.json_encode(res) 52 | self.cache:save(res_filename, res_json) 53 | 54 | assert(type(res.exit) == 'number') 55 | if res.exit ~= 0 then 56 | timer:close() 57 | fail('curl call failed exit_code=' .. tostring(res.exit)) 58 | return 59 | end 60 | 61 | if res.status ~= 200 then 62 | timer:close() 63 | fail('non-200 HTTP status response=' .. res_json) 64 | return 65 | end 66 | 67 | timer:close() 68 | success(res.body) 69 | return 70 | end 71 | 72 | elapsed_ms = elapsed_ms + interval_ms 73 | if elapsed_ms > DEFAULT_TIMEOUT_MILLI then 74 | timer:close() 75 | fail('timed out after milliseconds=' .. tostring(DEFAULT_TIMEOUT_MILLI)) 76 | return 77 | end 78 | end) 79 | ) 80 | end 81 | 82 | local ClientMt = { __index = ClientMethods } 83 | 84 | function http.new(cache) 85 | assert(type(cache) == 'table', 'cache must be a table with a method save(filename, contents)') 86 | return setmetatable({ 87 | cache = cache, 88 | }, ClientMt) 89 | end 90 | 91 | return http 92 | -------------------------------------------------------------------------------- /lua/nvim-magic-openai/_log.lua: -------------------------------------------------------------------------------- 1 | local plenary_log = require('plenary.log') 2 | 3 | local LOGLEVEL_ENVVAR = 'NVIM_MAGIC_LOGLEVEL' 4 | 5 | local function get_loglevel() 6 | local level = vim.fn.getenv(LOGLEVEL_ENVVAR) 7 | if level == vim.NIL then 8 | return 'info' 9 | end 10 | return level 11 | end 12 | 13 | local log = plenary_log.new({ 14 | plugin = 'nvim-magic-openai', 15 | level = get_loglevel(), 16 | }) 17 | log.fmt_debug('Logger initialized level=%s', log.level) 18 | 19 | return log 20 | -------------------------------------------------------------------------------- /lua/nvim-magic-openai/_random.lua: -------------------------------------------------------------------------------- 1 | local random = {} 2 | 3 | math.randomseed(os.time()) 4 | 5 | local A_ORD = 97 6 | local Z_ORD = 122 7 | 8 | function random.generate_timestamped_string() 9 | local s = '' 10 | for _ = 1, 8 do 11 | s = s .. string.char(math.random(A_ORD, Z_ORD)) 12 | end 13 | return os.date('%Y-%m-%d-%H-%M-%S-') .. s 14 | end 15 | 16 | return random 17 | -------------------------------------------------------------------------------- /lua/nvim-magic-openai/backend.lua: -------------------------------------------------------------------------------- 1 | local backend = {} 2 | 3 | local completion = require('nvim-magic-openai._completion') 4 | local log = require('nvim-magic-openai._log') 5 | 6 | local BackendMethods = {} 7 | 8 | function BackendMethods:complete(lines, max_tokens, stops, success, fail) 9 | if type(stops) == 'table' and #stops == 0 then 10 | stops = nil -- OpenAI API does not accept empty array for stops 11 | end 12 | local prompt = table.concat(lines, '\n') 13 | log.fmt_debug( 14 | 'Fetching async completion prompt_length=%s max_tokens=%s stops=%s', 15 | #prompt, 16 | max_tokens, 17 | tostring(stops) 18 | ) 19 | 20 | local req_body = completion.new_request(prompt, max_tokens, stops) 21 | local req_body_json = vim.fn.json_encode(req_body) 22 | 23 | self.http:post(self.api_endpoint, req_body_json, self.get_api_key(), function(body) 24 | local compl = completion.extract_from(body) 25 | success(compl) 26 | end, fail) 27 | end 28 | 29 | local BackendMt = { __index = BackendMethods } 30 | 31 | function backend.new(api_endpoint, http, api_key_fn) 32 | return setmetatable({ 33 | api_endpoint = api_endpoint, 34 | get_api_key = api_key_fn, 35 | http = http, 36 | }, BackendMt) 37 | end 38 | 39 | return backend 40 | -------------------------------------------------------------------------------- /lua/nvim-magic-openai/init.lua: -------------------------------------------------------------------------------- 1 | local openai = {} 2 | 3 | local backend = require('nvim-magic-openai.backend') 4 | local cache = require('nvim-magic-openai._cache') 5 | local log = require('nvim-magic-openai._log') 6 | local http = require('nvim-magic-openai._http') 7 | 8 | local DEFAULT_API_ENDPOINT = 'https://api.openai.com/v1/engines/davinci-codex/completions' 9 | local API_KEY_ENVVAR = 'OPENAI_API_KEY' 10 | 11 | local function env_get_api_key() 12 | local api_key = vim.env[API_KEY_ENVVAR] 13 | assert(api_key ~= nil and api_key ~= '', API_KEY_ENVVAR .. ' must be set in your environment') 14 | return api_key 15 | end 16 | 17 | local function default_config() 18 | return { 19 | api_endpoint = DEFAULT_API_ENDPOINT, 20 | cache = { 21 | dir_name = 'http', 22 | }, 23 | } 24 | end 25 | 26 | function openai.version() 27 | return '0.3.2-dev' 28 | end 29 | 30 | function openai.new(override) 31 | local config = default_config() 32 | 33 | if override then 34 | assert(type(override) == 'table', 'config must be a table') 35 | 36 | if override.api_endpoint then 37 | assert( 38 | type(override.api_endpoint) == 'string' and 1 <= #override.api_endpoint, 39 | 'api_endpoint must be a non-empty string' 40 | ) 41 | config.api_endpoint = override.api_endpoint 42 | end 43 | 44 | if not override.cache then 45 | config.cache = nil 46 | else 47 | assert(type(override.cache) == 'table', 'cache must be a table or nil') 48 | assert( 49 | type(override.cache.dir_name) == 'string' and 1 <= #override.cache.dir_name, 50 | 'cache.dir_name must be a non-empty string' 51 | ) 52 | config.cache = override.cache 53 | end 54 | end 55 | 56 | log.fmt_debug('Got config=%s', config) 57 | 58 | local http_cache 59 | if config.cache then 60 | http_cache = cache.new(config.cache.dir_name) 61 | else 62 | log.fmt_debug('Using dummy cache') 63 | http_cache = cache.new_dummy() 64 | end 65 | 66 | return backend.new(config.api_endpoint, http.new(http_cache), env_get_api_key) 67 | end 68 | 69 | return openai 70 | -------------------------------------------------------------------------------- /lua/nvim-magic/_buffer.lua: -------------------------------------------------------------------------------- 1 | local buffer = {} 2 | 3 | local log = require('nvim-magic._log') 4 | 5 | local ESC_FEEDKEY = vim.api.nvim_replace_termcodes('', true, false, true) 6 | 7 | local MAX_COL = 2147483647 8 | 9 | function buffer.get_handles() 10 | local winnr = vim.api.nvim_get_current_win() 11 | local bufnr = vim.api.nvim_win_get_buf(winnr) 12 | return bufnr, winnr 13 | end 14 | 15 | function buffer.get_filename() 16 | return vim.fn.expand('%:t') 17 | end 18 | 19 | function buffer.get_filetype(bufnr) 20 | if not bufnr then 21 | bufnr = 0 22 | end 23 | return vim.api.nvim_buf_get_option(bufnr, 'filetype') 24 | end 25 | 26 | function buffer.append(bufnr, row, lines) 27 | local col = buffer.get_end_col(bufnr, row) 28 | vim.api.nvim_buf_set_text(bufnr, row - 1, col, row - 1, col, lines) 29 | log.fmt_debug('Appended lines count=%s row=%s col=%s)', #lines, row, col) 30 | end 31 | 32 | function buffer.paste_over(bufnr, start_row, start_col, end_row, lines) 33 | local end_col = buffer.get_end_col(bufnr, end_row) 34 | vim.api.nvim_buf_set_text(bufnr, start_row - 1, start_col - 1, end_row - 1, end_col, lines) 35 | end 36 | 37 | function buffer.get_visual_lines(bufnr) 38 | if not bufnr then 39 | bufnr = 0 40 | end 41 | 42 | local start_row, start_col, end_row, end_col = buffer.get_visual_start_end() 43 | if start_row == end_row and start_col == end_col then 44 | return nil 45 | end 46 | 47 | local visual_lines = buffer.get_lines(bufnr, start_row, start_col, end_row, end_col) 48 | 49 | return visual_lines, start_row, start_col, end_row, end_col 50 | end 51 | 52 | -- should be called while in visual mode only 53 | function buffer.get_visual_start_end() 54 | -- NB: switches out of visual mode then back again to ensure marks are current 55 | vim.api.nvim_feedkeys(ESC_FEEDKEY, 'n', true) 56 | vim.api.nvim_feedkeys('gv', 'x', false) 57 | vim.api.nvim_feedkeys(ESC_FEEDKEY, 'n', true) 58 | 59 | local _, start_row, start_col, _ = unpack(vim.fn.getpos("'<")) 60 | local _, end_row, end_col, _ = unpack(vim.fn.getpos("'>")) 61 | 62 | log.fmt_debug( 63 | 'Visual bounds start_row=%s start_col=%s end_row=%s end_col=%s', 64 | start_row, 65 | start_col, 66 | end_row, 67 | end_col 68 | ) 69 | 70 | -- handle selections made in visual line mode (see :help getpos) 71 | if end_col == MAX_COL then 72 | end_col = buffer.get_end_col(0, end_row) 73 | log.fmt_debug( 74 | 'Normalized visual bounds to start_row=%s start_col=%s end_row=%s end_col=%s', 75 | start_row, 76 | start_col, 77 | end_row, 78 | end_col 79 | ) 80 | end 81 | 82 | return start_row, start_col, end_row, end_col 83 | end 84 | 85 | function buffer.get_end_col(bufnr, row) 86 | local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, true)[1] 87 | return #line 88 | end 89 | 90 | -- gets full and partial lines between start and end 91 | function buffer.get_lines(bufnr, start_row, start_col, end_row, end_col) 92 | if start_row == end_row and start_col == end_col then 93 | return {} 94 | end 95 | 96 | local lines = vim.api.nvim_buf_get_lines(bufnr, start_row - 1, end_row, false) 97 | lines[1] = lines[1]:sub(start_col, -1) 98 | if #lines == 1 then -- visual selection all in the same line 99 | lines[1] = lines[1]:sub(1, end_col - start_col + 1) 100 | else 101 | lines[#lines] = lines[#lines]:sub(1, end_col) 102 | end 103 | return lines 104 | end 105 | 106 | return buffer 107 | -------------------------------------------------------------------------------- /lua/nvim-magic/_fs.lua: -------------------------------------------------------------------------------- 1 | local fs = {} 2 | 3 | function fs.read(path) 4 | -- @returns [string] 5 | assert(path ~= nil, 'path cannot be nil') 6 | assert(type(path) == 'string', 'path must be a string') 7 | local fh, errmsg = io.open(path, 'r') 8 | assert(errmsg == nil, errmsg) 9 | local contents = fh:read('*all') 10 | fh:close() 11 | return contents 12 | end 13 | 14 | return fs 15 | -------------------------------------------------------------------------------- /lua/nvim-magic/_keymaps.lua: -------------------------------------------------------------------------------- 1 | local keymaps = {} 2 | 3 | local function luacmd(lua) 4 | return 'lua ' .. lua .. '' 5 | end 6 | 7 | keymaps.plugs = { 8 | ['nvim-magic-append-completion'] = { 9 | default_keymap = 'mcs', 10 | lua = "require('nvim-magic.flows').append_completion(require('nvim-magic').backends.default)", 11 | }, 12 | ['nvim-magic-suggest-alteration'] = { 13 | default_keymap = 'mss', 14 | lua = "require('nvim-magic.flows').suggest_alteration(require('nvim-magic').backends.default)", 15 | }, 16 | ['nvim-magic-suggest-docstring'] = { 17 | default_keymap = 'mds', 18 | lua = "require('nvim-magic.flows').suggest_docstring(require('nvim-magic').backends.default)", 19 | }, 20 | } 21 | 22 | for plug, v in pairs(keymaps.plugs) do 23 | vim.api.nvim_set_keymap('v', plug, luacmd(v.lua), {}) 24 | end 25 | 26 | function keymaps.set_default() 27 | for plug, v in pairs(keymaps.plugs) do 28 | vim.api.nvim_set_keymap('v', v.default_keymap, plug, {}) 29 | end 30 | end 31 | 32 | function keymaps.get_quick_quit() 33 | return { 34 | { 35 | 'n', 36 | 'q', 37 | function(_) 38 | vim.api.nvim_win_close(0, true) 39 | end, 40 | { noremap = true }, 41 | }, 42 | { 43 | 'n', 44 | '', 45 | function(_) 46 | vim.api.nvim_win_close(0, true) 47 | end, 48 | { noremap = true }, 49 | }, 50 | } 51 | end 52 | 53 | return keymaps 54 | -------------------------------------------------------------------------------- /lua/nvim-magic/_log.lua: -------------------------------------------------------------------------------- 1 | local plenary_log = require('plenary.log') 2 | 3 | local LOGLEVEL_ENVVAR = 'NVIM_MAGIC_LOGLEVEL' 4 | 5 | local function get_loglevel() 6 | local level = vim.fn.getenv(LOGLEVEL_ENVVAR) 7 | if level == vim.NIL then 8 | return 'info' 9 | end 10 | return level 11 | end 12 | 13 | local log = plenary_log.new({ 14 | plugin = 'nvim-magic', 15 | level = get_loglevel(), 16 | }) 17 | log.fmt_debug('Logger initialized level=%s', log.level) 18 | 19 | return log 20 | -------------------------------------------------------------------------------- /lua/nvim-magic/_templates.lua: -------------------------------------------------------------------------------- 1 | local templates = {} 2 | 3 | local fs = require('nvim-magic._fs') 4 | local log = require('nvim-magic._log') 5 | 6 | local lustache = require('nvim-magic.vendor.lustache.src.lustache') 7 | 8 | local TemplateMethods = {} 9 | 10 | function TemplateMethods:fill(values) 11 | return lustache:render(self.template, values) 12 | end 13 | 14 | local TemplateMt = { __index = TemplateMethods } 15 | 16 | function templates.new(tmpl, stop_code) 17 | local template = { 18 | template = tmpl, 19 | -- TODO: parse tags as well 20 | stop_code = stop_code, 21 | } 22 | return setmetatable(template, TemplateMt) 23 | end 24 | 25 | local function load(name) 26 | local prompt_dir = 'prompts/' .. name 27 | 28 | local tmpl = fs.read(vim.api.nvim_get_runtime_file(prompt_dir .. '/template.mustache', false)[1]) 29 | local meta_raw = fs.read(vim.api.nvim_get_runtime_file(prompt_dir .. '/meta.json', false)[1]) 30 | local meta = vim.fn.json_decode(meta_raw) 31 | 32 | return templates.new(tmpl, meta.stop_code) 33 | end 34 | 35 | templates.loaded = {} 36 | for _, name in pairs({ 'alter', 'docstring' }) do 37 | templates.loaded[name] = load(name) 38 | log.fmt_debug('Loaded template=%s', name) 39 | end 40 | 41 | return templates 42 | -------------------------------------------------------------------------------- /lua/nvim-magic/_ui.lua: -------------------------------------------------------------------------------- 1 | -- interacting with the user 2 | local ui = {} 3 | 4 | local Input = require('nui.input') 5 | local Popup = require('nui.popup') 6 | local event = require('nui.utils.autocmd').event 7 | 8 | function ui.notify(msg, log_level, opts) 9 | vim.notify('nvim-magic: ' .. msg, log_level, opts) 10 | end 11 | 12 | function ui.pop_up(lines, filetype, border_text, keymaps) 13 | local popup = Popup({ 14 | enter = true, 15 | focusable = true, 16 | border = { 17 | style = 'rounded', 18 | highlight = 'Bold', 19 | text = border_text, 20 | }, 21 | position = '50%', 22 | size = { 23 | width = '80%', 24 | height = '60%', 25 | }, 26 | buf_options = { 27 | modifiable = true, 28 | readonly = false, 29 | filetype = filetype, 30 | buftype = 'nofile', 31 | }, 32 | win_options = { 33 | number = true, 34 | }, 35 | }) 36 | popup:mount() 37 | popup:on(event.BufLeave, function() 38 | popup:unmount() 39 | end) 40 | 41 | for _, v in ipairs(keymaps) do 42 | popup:map(unpack(v)) 43 | end 44 | 45 | vim.api.nvim_buf_set_lines(popup.bufnr, 0, 1, false, lines) 46 | end 47 | 48 | function ui.prompt_input(title, keymaps, on_submit) 49 | local input = Input({ 50 | position = '20%', 51 | size = { 52 | width = '60%', 53 | height = '20%', 54 | }, 55 | relative = 'editor', 56 | border = { 57 | highlight = 'MyHighlightGroup', 58 | style = 'single', 59 | text = { 60 | top = title, 61 | top_align = 'center', 62 | }, 63 | }, 64 | win_options = { 65 | winblend = 10, 66 | winhighlight = 'Normal:Normal', 67 | }, 68 | }, { 69 | prompt = '> ', 70 | default_value = '', 71 | on_close = function() end, 72 | on_submit = on_submit, 73 | }) 74 | input:mount() 75 | input:on(event.BufLeave, function() 76 | input:unmount() 77 | end) 78 | 79 | for _, v in ipairs(keymaps) do 80 | input:map(unpack(v)) 81 | end 82 | end 83 | 84 | return ui 85 | -------------------------------------------------------------------------------- /lua/nvim-magic/flows.lua: -------------------------------------------------------------------------------- 1 | -- helpful flows that can be mapped to key bindings 2 | -- they can assume sensible defaults and/or interact with the user 3 | local flows = {} 4 | 5 | local buffer = require('nvim-magic._buffer') 6 | local keymaps = require('nvim-magic._keymaps') 7 | local log = require('nvim-magic._log') 8 | local templates = require('nvim-magic._templates') 9 | local ui = require('nvim-magic._ui') 10 | 11 | local function notify_prefix(filename) 12 | local prefix 13 | if 1 <= #filename then 14 | prefix = string.format('%s - ', filename) 15 | else 16 | prefix = '(buffer) -' 17 | end 18 | return prefix 19 | end 20 | 21 | function flows.append_completion(backend, max_tokens, stops) 22 | assert(backend ~= nil, 'backend must be provided') 23 | if max_tokens then 24 | assert(type(max_tokens) == 'number', 'max tokens must be a number') 25 | assert(1 <= max_tokens, 'max tokens must be at least 1') 26 | else 27 | max_tokens = 300 28 | end 29 | if stops then 30 | assert(type(stops) == 'table', 'stop must be an array of strings') 31 | assert(type(stops[1]) == 'string', 'stop must be an array of strings') 32 | end 33 | 34 | local orig_bufnr, orig_winnr = buffer.get_handles() 35 | local filename = buffer.get_filename() 36 | local nprefix = notify_prefix(filename) 37 | 38 | local visual_lines, _, _, end_row, end_col = buffer.get_visual_lines() 39 | if not visual_lines then 40 | ui.notify(nprefix .. 'nothing selected') 41 | return 42 | end 43 | 44 | log.fmt_debug('Fetching completion max_tokens=%s stops=%s', max_tokens, vim.inspect(stops)) 45 | ui.notify(nprefix .. 'fetching completion...') 46 | backend:complete(visual_lines, max_tokens, stops, function(completion) 47 | local compl_lines = vim.split(completion, '\n', true) 48 | 49 | buffer.append(orig_bufnr, end_row, compl_lines) 50 | vim.api.nvim_set_current_win(orig_winnr) 51 | vim.api.nvim_set_current_buf(orig_bufnr) 52 | vim.api.nvim_win_set_cursor(0, { end_row, end_col }) -- TODO: use specific window 53 | 54 | ui.notify(nprefix .. 'fetched completion (' .. tostring(#completion) .. ' characters)', 'info') 55 | end, function(errmsg) 56 | ui.notify(nprefix .. errmsg) 57 | end) 58 | end 59 | 60 | function flows.suggest_alteration(backend, language) 61 | assert(backend ~= nil, 'backend must be provided') 62 | if not language then 63 | language = buffer.get_filetype() 64 | else 65 | assert(type(language) == 'string', 'language must be a string') 66 | end 67 | 68 | local orig_bufnr, orig_winnr = buffer.get_handles() 69 | local filename = buffer.get_filename() 70 | local nprefix = notify_prefix(filename) 71 | 72 | local visual_lines, start_row, start_col, end_row, _ = buffer.get_visual_lines() 73 | if not visual_lines then 74 | ui.notify(nprefix .. 'nothing selected') 75 | return 76 | end 77 | 78 | ui.prompt_input('This code should be altered to...', keymaps.get_quick_quit(), function(task) 79 | local visual = table.concat(visual_lines, '\n') 80 | local tmpl = templates.loaded.alter 81 | local prompt = tmpl:fill({ 82 | language = language, 83 | task = task, 84 | snippet = visual, 85 | }) 86 | local prompt_lines = vim.fn.split(prompt, '\n', false) 87 | -- we default max tokens to a "large" value in case the prompt is large, this isn't robust 88 | -- ideally we would estimate the number of tokens in the prompt and then set a max tokens 89 | -- value proportional to that (e.g. 2x) and taking into account the max token limit as well 90 | local max_tokens = 1000 91 | local stops = { tmpl.stop_code } 92 | 93 | log.fmt_debug('Fetching alteration max_tokens=%s stops=%s', max_tokens, vim.inspect(stops)) 94 | ui.notify(nprefix .. string.format('fetching suggested alteration (task=%s)', task)) 95 | backend:complete(prompt_lines, max_tokens, stops, function(completion) 96 | ui.notify(nprefix .. 'fetched suggested alteration (' .. tostring(#completion) .. ' characters)', 'info') 97 | local compl_lines = vim.split(completion, '\n', true) 98 | vim.api.nvim_set_current_win(orig_winnr) 99 | vim.api.nvim_set_current_buf(orig_bufnr) 100 | 101 | ui.pop_up( 102 | compl_lines, 103 | language, 104 | { 105 | top = 'Suggested alteration', 106 | top_align = 'center', 107 | bottom = '[a] - append | [p] paste over', 108 | bottom_align = 'left', 109 | }, 110 | vim.list_extend(keymaps.get_quick_quit(), { 111 | { 112 | 'n', 113 | 'a', -- append to original buffer 114 | function(_) 115 | buffer.append(orig_bufnr, end_row, compl_lines) 116 | vim.api.nvim_win_close(0, true) 117 | end, 118 | { noremap = true }, 119 | }, 120 | { 121 | 'n', 122 | 'p', -- replace in original buffer 123 | function(_) 124 | buffer.paste_over(orig_bufnr, start_row, start_col, end_row, compl_lines) 125 | vim.api.nvim_win_close(0, true) 126 | end, 127 | { noremap = true }, 128 | }, 129 | }) 130 | ) 131 | end, function(errmsg) 132 | ui.notify(nprefix .. errmsg) 133 | end) 134 | end) 135 | end 136 | 137 | function flows.suggest_docstring(backend, language) 138 | assert(backend ~= nil, 'backend must be provided') 139 | if not language then 140 | language = buffer.get_filetype() 141 | else 142 | assert(type(language) == 'string', 'language must be a string') 143 | end 144 | 145 | local orig_bufnr, orig_winnr = buffer.get_handles() 146 | local filename = buffer.get_filename() 147 | local nprefix = notify_prefix(filename) 148 | 149 | local vis_lines, start_row, start_col, end_row, _ = buffer.get_visual_lines() 150 | if not vis_lines then 151 | ui.notify(nprefix .. 'nothing selected') 152 | return 153 | end 154 | 155 | local visual = table.concat(vis_lines, '\n') 156 | local tmpl = templates.loaded.docstring 157 | local prompt = tmpl:fill({ 158 | language = language, 159 | snippet = visual, 160 | }) 161 | local prompt_lines = vim.fn.split(prompt, '\n', false) 162 | -- we default max tokens to a "large" value in case the prompt is large, this isn't robust 163 | -- ideally we would estimate the number of tokens in the prompt and then set a max tokens 164 | -- value proportional to that (e.g. 2x) and taking into account the max token limit as well 165 | local max_tokens = 1000 166 | local stops = { tmpl.stop_code } 167 | 168 | log.fmt_debug('Fetching docstring max_tokens=%s stops=%s', max_tokens, tostring(stops)) 169 | ui.notify(nprefix .. 'fetching suggested docstring...') 170 | backend:complete(prompt_lines, max_tokens, stops, function(completion) 171 | ui.notify(nprefix .. 'fetched suggested docstring (' .. tostring(#completion) .. ' characters)', 'info') 172 | local compl_lines = vim.split(completion, '\n', true) 173 | vim.api.nvim_set_current_win(orig_winnr) 174 | vim.api.nvim_set_current_buf(orig_bufnr) 175 | 176 | ui.pop_up( 177 | compl_lines, 178 | language, 179 | { 180 | top = 'Suggested alteration', 181 | top_align = 'center', 182 | bottom = '[a] - append | [p] paste over', 183 | bottom_align = 'left', 184 | }, 185 | vim.list_extend(keymaps.get_quick_quit(), { 186 | { 187 | 'n', 188 | 'a', -- append to original buffer 189 | function(_) 190 | buffer.append(orig_bufnr, end_row, compl_lines) 191 | vim.api.nvim_win_close(0, true) 192 | end, 193 | { noremap = true }, 194 | }, 195 | { 196 | 'n', 197 | 'p', -- replace in original buffer 198 | function(_) 199 | buffer.paste_over(orig_bufnr, start_row, start_col, end_row, compl_lines) 200 | vim.api.nvim_win_close(0, true) 201 | end, 202 | { noremap = true }, 203 | }, 204 | }) 205 | ) 206 | end, function(errmsg) 207 | ui.notify(nprefix .. errmsg) 208 | end) 209 | end 210 | 211 | return flows 212 | -------------------------------------------------------------------------------- /lua/nvim-magic/init.lua: -------------------------------------------------------------------------------- 1 | local magic = {} 2 | 3 | local log = require('nvim-magic._log') 4 | 5 | magic.backends = {} -- should be set during setup() 6 | 7 | local function default_config() 8 | return { 9 | backends = { 10 | default = require('nvim-magic-openai').new(), 11 | }, 12 | use_default_keymap = true, 13 | } 14 | end 15 | 16 | function magic.version() 17 | return '0.3.2-dev' 18 | end 19 | 20 | function magic.setup(override) 21 | local config = default_config() 22 | 23 | if override then 24 | if override.backends then 25 | assert(type(override.backends) == 'table', 'backends must be a map of backends') 26 | assert(type(override.backends.default) == 'table', 'backends must be a map of backends') 27 | for name, backend in pairs(override.backends) do 28 | assert(type(backend.complete) == 'function', 'backend ' .. name .. ' needs a complete function') 29 | end 30 | config.backends = override.backends 31 | end 32 | if override.use_default_keymap then 33 | assert(type(override.use_default_keymap == 'boolean'), 'use_default_keymap must be a boolean') 34 | config.use_default_keymap = override.use_default_keymap 35 | end 36 | end 37 | 38 | log.fmt_debug('Got config=%s ', config) 39 | 40 | magic.backends = config.backends 41 | 42 | if config.use_default_keymap then 43 | require('nvim-magic._keymaps').set_default() 44 | 45 | log.debug('Set default keymaps') 46 | end 47 | end 48 | 49 | return magic 50 | -------------------------------------------------------------------------------- /lua/nvim-magic/vendor/lustache/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2012 Olivine Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lua/nvim-magic/vendor/lustache/notes.md: -------------------------------------------------------------------------------- 1 | # Notes 2 | 3 | ## Differences with upstream 4 | * Unneeded files removed 5 | * Prefixes were added to Lua `require()` calls to account for being used within Neovim 6 | -------------------------------------------------------------------------------- /lua/nvim-magic/vendor/lustache/origin.txt: -------------------------------------------------------------------------------- 1 | https://github.com/Olivine-Labs/lustache.git@bbe1915d7354d55db4ed4abd3419d8709b8acf77 2 | -------------------------------------------------------------------------------- /lua/nvim-magic/vendor/lustache/src/lustache.lua: -------------------------------------------------------------------------------- 1 | -- lustache: Lua mustache template parsing. 2 | -- Copyright 2013 Olivine Labs, LLC 3 | -- MIT Licensed. 4 | 5 | local REQUIRE_PREFIX = 'nvim-magic.vendor.lustache.src.' 6 | 7 | local string_gmatch = string.gmatch 8 | 9 | function string.split(str, sep) 10 | local out = {} 11 | for m in string_gmatch(str, "[^"..sep.."]+") do out[#out+1] = m end 12 | return out 13 | end 14 | 15 | local lustache = { 16 | name = "lustache", 17 | version = "1.3.1-0", 18 | renderer = require(REQUIRE_PREFIX .. "lustache.renderer"):new(), 19 | } 20 | 21 | return setmetatable(lustache, { 22 | __index = function(self, idx) 23 | if self.renderer[idx] then return self.renderer[idx] end 24 | end, 25 | __newindex = function(self, idx, val) 26 | if idx == "partials" then self.renderer.partials = val end 27 | if idx == "tags" then self.renderer.tags = val end 28 | end 29 | }) 30 | -------------------------------------------------------------------------------- /lua/nvim-magic/vendor/lustache/src/lustache/context.lua: -------------------------------------------------------------------------------- 1 | local string_find, string_split, tostring, type = 2 | string.find, string.split, tostring, type 3 | 4 | local context = {} 5 | context.__index = context 6 | 7 | function context:clear_cache() 8 | self.cache = {} 9 | end 10 | 11 | function context:push(view) 12 | return self:new(view, self) 13 | end 14 | 15 | function context:lookup(name) 16 | local value = self.cache[name] 17 | 18 | if not value then 19 | if name == "." then 20 | value = self.view 21 | else 22 | local context = self 23 | 24 | while context do 25 | if string_find(name, ".") > 0 then 26 | local names = string_split(name, ".") 27 | local i = 0 28 | 29 | value = context.view 30 | 31 | if(type(value)) == "number" then 32 | value = tostring(value) 33 | end 34 | 35 | while value and i < #names do 36 | i = i + 1 37 | value = value[names[i]] 38 | end 39 | else 40 | value = context.view[name] 41 | end 42 | 43 | if value then 44 | break 45 | end 46 | 47 | context = context.parent 48 | end 49 | end 50 | 51 | self.cache[name] = value 52 | end 53 | 54 | return value 55 | end 56 | 57 | function context:new(view, parent) 58 | local out = { 59 | view = view, 60 | parent = parent, 61 | cache = {}, 62 | } 63 | return setmetatable(out, context) 64 | end 65 | 66 | return context 67 | -------------------------------------------------------------------------------- /lua/nvim-magic/vendor/lustache/src/lustache/renderer.lua: -------------------------------------------------------------------------------- 1 | local REQUIRE_PREFIX = 'nvim-magic.vendor.lustache.src.' 2 | 3 | local Scanner = require(REQUIRE_PREFIX .. "lustache.scanner") 4 | local Context = require(REQUIRE_PREFIX .. "lustache.context") 5 | 6 | local error, ipairs, pairs, setmetatable, tostring, type = 7 | error, ipairs, pairs, setmetatable, tostring, type 8 | local math_floor, math_max, string_find, string_gsub, string_split, string_sub, table_concat, table_insert, table_remove = 9 | math.floor, math.max, string.find, string.gsub, string.split, string.sub, table.concat, table.insert, table.remove 10 | 11 | local patterns = { 12 | white = "%s*", 13 | space = "%s+", 14 | nonSpace = "%S", 15 | eq = "%s*=", 16 | curly = "%s*}", 17 | tag = "[#\\^/>{&=!?]" 18 | } 19 | 20 | local html_escape_characters = { 21 | ["&"] = "&", 22 | ["<"] = "<", 23 | [">"] = ">", 24 | ['"'] = """, 25 | ["'"] = "'", 26 | ["/"] = "/" 27 | } 28 | 29 | local block_tags = { 30 | ["#"] = true, 31 | ["^"] = true, 32 | ["?"] = true, 33 | } 34 | 35 | local function is_array(array) 36 | if type(array) ~= "table" then return false end 37 | local max, n = 0, 0 38 | for k, _ in pairs(array) do 39 | if not (type(k) == "number" and k > 0 and math_floor(k) == k) then 40 | return false 41 | end 42 | max = math_max(max, k) 43 | n = n + 1 44 | end 45 | return n == max 46 | end 47 | 48 | -- Low-level function that compiles the given `tokens` into a 49 | -- function that accepts two arguments: a Context and a 50 | -- Renderer. 51 | 52 | local function compile_tokens(tokens, originalTemplate) 53 | local subs = {} 54 | 55 | local function subrender(i, tokens) 56 | if not subs[i] then 57 | local fn = compile_tokens(tokens, originalTemplate) 58 | subs[i] = function(ctx, rnd) return fn(ctx, rnd) end 59 | end 60 | return subs[i] 61 | end 62 | 63 | local function render(ctx, rnd) 64 | local buf = {} 65 | local token, section 66 | for i, token in ipairs(tokens) do 67 | local t = token.type 68 | buf[#buf+1] = 69 | t == "?" and rnd:_conditional( 70 | token, ctx, subrender(i, token.tokens) 71 | ) or 72 | t == "#" and rnd:_section( 73 | token, ctx, subrender(i, token.tokens), originalTemplate 74 | ) or 75 | t == "^" and rnd:_inverted( 76 | token.value, ctx, subrender(i, token.tokens) 77 | ) or 78 | t == ">" and rnd:_partial(token.value, ctx, originalTemplate) or 79 | (t == "{" or t == "&") and rnd:_name(token.value, ctx, false) or 80 | t == "name" and rnd:_name(token.value, ctx, true) or 81 | t == "text" and token.value or "" 82 | end 83 | return table_concat(buf) 84 | end 85 | return render 86 | end 87 | 88 | local function escape_tags(tags) 89 | 90 | return { 91 | string_gsub(tags[1], "%%", "%%%%").."%s*", 92 | "%s*"..string_gsub(tags[2], "%%", "%%%%"), 93 | } 94 | end 95 | 96 | local function nest_tokens(tokens) 97 | local tree = {} 98 | local collector = tree 99 | local sections = {} 100 | local token, section 101 | 102 | for i,token in ipairs(tokens) do 103 | if block_tags[token.type] then 104 | token.tokens = {} 105 | sections[#sections+1] = token 106 | collector[#collector+1] = token 107 | collector = token.tokens 108 | elseif token.type == "/" then 109 | if #sections == 0 then 110 | error("Unopened section: "..token.value) 111 | end 112 | 113 | -- Make sure there are no open sections when we're done 114 | section = table_remove(sections, #sections) 115 | 116 | if not section.value == token.value then 117 | error("Unclosed section: "..section.value) 118 | end 119 | 120 | section.closingTagIndex = token.startIndex 121 | 122 | if #sections > 0 then 123 | collector = sections[#sections].tokens 124 | else 125 | collector = tree 126 | end 127 | else 128 | collector[#collector+1] = token 129 | end 130 | end 131 | 132 | section = table_remove(sections, #sections) 133 | 134 | if section then 135 | error("Unclosed section: "..section.value) 136 | end 137 | 138 | return tree 139 | end 140 | 141 | -- Combines the values of consecutive text tokens in the given `tokens` array 142 | -- to a single token. 143 | local function squash_tokens(tokens) 144 | local out, txt = {}, {} 145 | local txtStartIndex, txtEndIndex 146 | for _, v in ipairs(tokens) do 147 | if v.type == "text" then 148 | if #txt == 0 then 149 | txtStartIndex = v.startIndex 150 | end 151 | txt[#txt+1] = v.value 152 | txtEndIndex = v.endIndex 153 | else 154 | if #txt > 0 then 155 | out[#out+1] = { type = "text", value = table_concat(txt), startIndex = txtStartIndex, endIndex = txtEndIndex } 156 | txt = {} 157 | end 158 | out[#out+1] = v 159 | end 160 | end 161 | if #txt > 0 then 162 | out[#out+1] = { type = "text", value = table_concat(txt), startIndex = txtStartIndex, endIndex = txtEndIndex } 163 | end 164 | return out 165 | end 166 | 167 | local function make_context(view) 168 | if not view then return view end 169 | return getmetatable(view) == Context and view or Context:new(view) 170 | end 171 | 172 | local renderer = { } 173 | 174 | function renderer:clear_cache() 175 | self.cache = {} 176 | self.partial_cache = {} 177 | end 178 | 179 | function renderer:compile(tokens, tags, originalTemplate) 180 | tags = tags or self.tags 181 | if type(tokens) == "string" then 182 | tokens = self:parse(tokens, tags) 183 | end 184 | 185 | local fn = compile_tokens(tokens, originalTemplate) 186 | 187 | return function(view) 188 | return fn(make_context(view), self) 189 | end 190 | end 191 | 192 | function renderer:render(template, view, partials) 193 | if type(self) == "string" then 194 | error("Call mustache:render, not mustache.render!") 195 | end 196 | 197 | if partials then 198 | -- remember partial table 199 | -- used for runtime lookup & compile later on 200 | self.partials = partials 201 | end 202 | 203 | if not template then 204 | return "" 205 | end 206 | 207 | local fn = self.cache[template] 208 | 209 | if not fn then 210 | fn = self:compile(template, self.tags, template) 211 | self.cache[template] = fn 212 | end 213 | 214 | return fn(view) 215 | end 216 | 217 | function renderer:_conditional(token, context, callback) 218 | local value = context:lookup(token.value) 219 | 220 | if value then 221 | return callback(context, self) 222 | end 223 | 224 | return "" 225 | end 226 | 227 | function renderer:_section(token, context, callback, originalTemplate) 228 | local value = context:lookup(token.value) 229 | 230 | if type(value) == "table" then 231 | if is_array(value) then 232 | local buffer = "" 233 | 234 | for i,v in ipairs(value) do 235 | buffer = buffer .. callback(context:push(v), self) 236 | end 237 | 238 | return buffer 239 | end 240 | 241 | return callback(context:push(value), self) 242 | elseif type(value) == "function" then 243 | local section_text = string_sub(originalTemplate, token.endIndex+1, token.closingTagIndex - 1) 244 | 245 | local scoped_render = function(template) 246 | return self:render(template, context) 247 | end 248 | 249 | return value(section_text, scoped_render) or "" 250 | else 251 | if value then 252 | return callback(context, self) 253 | end 254 | end 255 | 256 | return "" 257 | end 258 | 259 | function renderer:_inverted(name, context, callback) 260 | local value = context:lookup(name) 261 | 262 | -- From the spec: inverted sections may render text once based on the 263 | -- inverse value of the key. That is, they will be rendered if the key 264 | -- doesn't exist, is false, or is an empty list. 265 | 266 | if value == nil or value == false or (type(value) == "table" and is_array(value) and #value == 0) then 267 | return callback(context, self) 268 | end 269 | 270 | return "" 271 | end 272 | 273 | function renderer:_partial(name, context, originalTemplate) 274 | local fn = self.partial_cache[name] 275 | 276 | -- check if partial cache exists 277 | if (not fn and self.partials) then 278 | 279 | local partial = self.partials[name] 280 | if (not partial) then 281 | return "" 282 | end 283 | 284 | -- compile partial and store result in cache 285 | fn = self:compile(partial, nil, partial) 286 | self.partial_cache[name] = fn 287 | end 288 | return fn and fn(context, self) or "" 289 | end 290 | 291 | function renderer:_name(name, context, escape) 292 | local value = context:lookup(name) 293 | 294 | if type(value) == "function" then 295 | value = value(context.view) 296 | end 297 | 298 | local str = value == nil and "" or value 299 | str = tostring(str) 300 | 301 | if escape then 302 | return string_gsub(str, '[&<>"\'/]', function(s) return html_escape_characters[s] end) 303 | end 304 | 305 | return str 306 | end 307 | 308 | -- Breaks up the given `template` string into a tree of token objects. If 309 | -- `tags` is given here it must be an array with two string values: the 310 | -- opening and closing tags used in the template (e.g. ["<%", "%>"]). Of 311 | -- course, the default is to use mustaches (i.e. Mustache.tags). 312 | function renderer:parse(template, tags) 313 | tags = tags or self.tags 314 | local tag_patterns = escape_tags(tags) 315 | local scanner = Scanner:new(template) 316 | local tokens = {} -- token buffer 317 | local spaces = {} -- indices of whitespace tokens on the current line 318 | local has_tag = false -- is there a {{tag} on the current line? 319 | local non_space = false -- is there a non-space char on the current line? 320 | 321 | -- Strips all whitespace tokens array for the current line if there was 322 | -- a {{#tag}} on it and otherwise only space 323 | local function strip_space() 324 | if has_tag and not non_space then 325 | while #spaces > 0 do 326 | table_remove(tokens, table_remove(spaces)) 327 | end 328 | else 329 | spaces = {} 330 | end 331 | has_tag = false 332 | non_space = false 333 | end 334 | 335 | local type, value, chr 336 | 337 | while not scanner:eos() do 338 | local start = scanner.pos 339 | 340 | value = scanner:scan_until(tag_patterns[1]) 341 | 342 | if value then 343 | for i = 1, #value do 344 | chr = string_sub(value,i,i) 345 | 346 | if string_find(chr, "%s+") then 347 | spaces[#spaces+1] = #tokens + 1 348 | else 349 | non_space = true 350 | end 351 | 352 | tokens[#tokens+1] = { type = "text", value = chr, startIndex = start, endIndex = start } 353 | start = start + 1 354 | if chr == "\n" then 355 | strip_space() 356 | end 357 | end 358 | end 359 | 360 | if not scanner:scan(tag_patterns[1]) then 361 | break 362 | end 363 | 364 | has_tag = true 365 | type = scanner:scan(patterns.tag) or "name" 366 | 367 | scanner:scan(patterns.white) 368 | 369 | if type == "=" then 370 | value = scanner:scan_until(patterns.eq) 371 | scanner:scan(patterns.eq) 372 | scanner:scan_until(tag_patterns[2]) 373 | elseif type == "{" then 374 | local close_pattern = "%s*}"..tags[2] 375 | value = scanner:scan_until(close_pattern) 376 | scanner:scan(patterns.curly) 377 | scanner:scan_until(tag_patterns[2]) 378 | else 379 | value = scanner:scan_until(tag_patterns[2]) 380 | end 381 | 382 | if not scanner:scan(tag_patterns[2]) then 383 | error("Unclosed tag " .. value .. " of type " .. type .. " at position " .. scanner.pos) 384 | end 385 | 386 | tokens[#tokens+1] = { type = type, value = value, startIndex = start, endIndex = scanner.pos - 1 } 387 | if type == "name" or type == "{" or type == "&" then 388 | non_space = true --> what does this do? 389 | end 390 | 391 | if type == "=" then 392 | tags = string_split(value, patterns.space) 393 | tag_patterns = escape_tags(tags) 394 | end 395 | end 396 | 397 | return nest_tokens(squash_tokens(tokens)) 398 | end 399 | 400 | function renderer:new() 401 | local out = { 402 | cache = {}, 403 | partial_cache = {}, 404 | tags = {"{{", "}}"} 405 | } 406 | return setmetatable(out, { __index = self }) 407 | end 408 | 409 | return renderer 410 | -------------------------------------------------------------------------------- /lua/nvim-magic/vendor/lustache/src/lustache/scanner.lua: -------------------------------------------------------------------------------- 1 | local string_find, string_match, string_sub = 2 | string.find, string.match, string.sub 3 | 4 | local scanner = {} 5 | 6 | -- Returns `true` if the tail is empty (end of string). 7 | function scanner:eos() 8 | return self.tail == "" 9 | end 10 | 11 | -- Tries to match the given regular expression at the current position. 12 | -- Returns the matched text if it can match, `null` otherwise. 13 | function scanner:scan(pattern) 14 | local match = string_match(self.tail, pattern) 15 | 16 | if match and string_find(self.tail, pattern) == 1 then 17 | self.tail = string_sub(self.tail, #match + 1) 18 | self.pos = self.pos + #match 19 | 20 | return match 21 | end 22 | 23 | end 24 | 25 | -- Skips all text until the given regular expression can be matched. Returns 26 | -- the skipped string, which is the entire tail of this scanner if no match 27 | -- can be made. 28 | function scanner:scan_until(pattern) 29 | 30 | local match 31 | local pos = string_find(self.tail, pattern) 32 | 33 | if pos == nil then 34 | match = self.tail 35 | self.pos = self.pos + #self.tail 36 | self.tail = "" 37 | elseif pos == 1 then 38 | match = nil 39 | else 40 | match = string_sub(self.tail, 1, pos - 1) 41 | self.tail = string_sub(self.tail, pos) 42 | self.pos = self.pos + #match 43 | end 44 | 45 | return match 46 | end 47 | 48 | function scanner:new(str) 49 | local out = { 50 | str = str, 51 | tail = str, 52 | pos = 1 53 | } 54 | return setmetatable(out, { __index = self } ) 55 | end 56 | 57 | return scanner 58 | -------------------------------------------------------------------------------- /prompts/alter/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Make minor alteration", 3 | "description": "Make a minor alteration to some code", 4 | "tags": { 5 | "language": "The language of the code snippet being provided", 6 | "snippet": "The code snippet itself, ideally relatively short", 7 | "task": "Succinct description of what alteration should be made" 8 | }, 9 | "stop_code": "\n```" 10 | } 11 | -------------------------------------------------------------------------------- /prompts/alter/template.mustache: -------------------------------------------------------------------------------- 1 | # Making minimal alterations to code 2 | 3 | This is an example of minimally altering some given code to achieve a specific task. 4 | 5 | I received the following code: 6 | 7 | ```{{{language}}} 8 | {{{snippet}}} 9 | ``` 10 | 11 | My task was to make minimal alterations to this code to: "{{{task}}}". 12 | 13 | The altered code is below. 14 | 15 | ```{{{language}}} 16 | 17 | -------------------------------------------------------------------------------- /prompts/docstring/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Generate a docstring", 3 | "description": "Generates a docstring for some given code (e.g. a function or class)", 4 | "tags": { 5 | "language": "The language of the code snippet being provided", 6 | "snippet": "The code snippet itself" 7 | }, 8 | "stop_code": "\n```" 9 | } 10 | -------------------------------------------------------------------------------- /prompts/docstring/template.mustache: -------------------------------------------------------------------------------- 1 | # Writing a good docstring 2 | 3 | This is an example of writing a really good docstring that follows a best practice for the given language. Attention is paid to detailing things like 4 | * parameter and return types (if applicable) 5 | * any errors that might be raised or returned, depending on the language 6 | 7 | I received the following code: 8 | 9 | ```{{{language}}} 10 | {{{snippet}}} 11 | ``` 12 | 13 | The code with a really good docstring added is below: 14 | 15 | ```{{{language}}} 16 | 17 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | quote_style = 'AutoPreferSingle' 2 | --------------------------------------------------------------------------------