From cbd95a8deccc460616735635d4feee88f06b91e1 Mon Sep 17 00:00:00 2001 From: Oli Morris Date: Tue, 16 Jan 2024 21:56:33 +0000 Subject: [PATCH 1/3] chore: log any client errors --- lua/codecompanion/client.lua | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lua/codecompanion/client.lua b/lua/codecompanion/client.lua index fdf7ec0e..cbe0b20f 100644 --- a/lua/codecompanion/client.lua +++ b/lua/codecompanion/client.lua @@ -27,15 +27,21 @@ end ---@return nil|any local function parse_response(code, stdout) if code ~= 0 then + log:error("Error: %s", stdout) return string.format("Error: %s", stdout) end + local ok, data = pcall(vim.json.decode, stdout, { luanil = { object = true } }) if not ok then + log:error("Error malformed json: %s", data) return string.format("Error malformed json: %s", data) end + if data.error then + log:error("API Error: %s", data.error.message) return string.format("API Error: %s", data.error.message) end + return nil, data end @@ -174,6 +180,7 @@ function Client:stream_call(url, payload, bufnr, cb) local ok, data = pcall(vim.json.decode, chunk, { luanil = { object = true } }) if not ok then done = true + log:error("Error malformed json: %s", data) return cb(string.format("Error malformed json: %s", data)) end @@ -202,6 +209,7 @@ function Client:stream_call(url, payload, bufnr, cb) end end, }) + if jid == 0 then cb("Passed invalid arguments to curl") elseif jid == -1 then @@ -213,6 +221,7 @@ function Client:stream_call(url, payload, bufnr, cb) strategy = "chat", } end + return jid end From fd8b83053fc638e8d19d939bbad6a039fcff9ccd Mon Sep 17 00:00:00 2001 From: Oli Morris Date: Tue, 16 Jan 2024 21:57:35 +0000 Subject: [PATCH 2/3] chore: add base64 utils --- lua/codecompanion/utils/base64.lua | 273 +++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 lua/codecompanion/utils/base64.lua diff --git a/lua/codecompanion/utils/base64.lua b/lua/codecompanion/utils/base64.lua new file mode 100644 index 00000000..2dc0c8fb --- /dev/null +++ b/lua/codecompanion/utils/base64.lua @@ -0,0 +1,273 @@ +--[[ + + base64 -- v1.5.3 public domain Lua base64 encoder/decoder + no warranty implied; use at your own risk + + Needs bit32.extract function. If not present it's implemented using BitOp + or Lua 5.3 native bit operators. For Lua 5.1 fallbacks to pure Lua + implementation inspired by Rici Lake's post: + http://ricilake.blogspot.co.uk/2007/10/iterating-bits-in-lua.html + + author: Ilya Kolbin (iskolbin@gmail.com) + url: github.com/iskolbin/lbase64 + + COMPATIBILITY + + Lua 5.1+, LuaJIT + + LICENSE + + See end of file for license information. + +--]] + +local base64 = {} + +local extract = _G.bit32 and _G.bit32.extract -- Lua 5.2/Lua 5.3 in compatibility mode +if not extract then + if _G.bit then -- LuaJIT + local shl, shr, band = _G.bit.lshift, _G.bit.rshift, _G.bit.band + extract = function(v, from, width) + return band(shr(v, from), shl(1, width) - 1) + end + elseif _G._VERSION == "Lua 5.1" then + extract = function(v, from, width) + local w = 0 + local flag = 2 ^ from + for i = 0, width - 1 do + local flag2 = flag + flag + if v % flag2 >= flag then + w = w + 2 ^ i + end + flag = flag2 + end + return w + end + else -- Lua 5.3+ + extract = load([[return function( v, from, width ) + return ( v >> from ) & ((1 << width) - 1) + end]])() + end +end + +function base64.makeencoder(s62, s63, spad) + local encoder = {} + for b64code, char in pairs({ + [0] = "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + s62 or "+", + s63 or "/", + spad or "=", + }) do + encoder[b64code] = char:byte() + end + return encoder +end + +function base64.makedecoder(s62, s63, spad) + local decoder = {} + for b64code, charcode in pairs(base64.makeencoder(s62, s63, spad)) do + decoder[charcode] = b64code + end + return decoder +end + +local DEFAULT_ENCODER = base64.makeencoder() +local DEFAULT_DECODER = base64.makedecoder() + +local char, concat = string.char, table.concat + +function base64.encode(str, encoder, usecaching) + encoder = encoder or DEFAULT_ENCODER + local t, k, n = {}, 1, #str + local lastn = n % 3 + local cache = {} + for i = 1, n - lastn, 3 do + local a, b, c = str:byte(i, i + 2) + local v = a * 0x10000 + b * 0x100 + c + local s + if usecaching then + s = cache[v] + if not s then + s = char( + encoder[extract(v, 18, 6)], + encoder[extract(v, 12, 6)], + encoder[extract(v, 6, 6)], + encoder[extract(v, 0, 6)] + ) + cache[v] = s + end + else + s = char( + encoder[extract(v, 18, 6)], + encoder[extract(v, 12, 6)], + encoder[extract(v, 6, 6)], + encoder[extract(v, 0, 6)] + ) + end + t[k] = s + k = k + 1 + end + if lastn == 2 then + local a, b = str:byte(n - 1, n) + local v = a * 0x10000 + b * 0x100 + t[k] = char(encoder[extract(v, 18, 6)], encoder[extract(v, 12, 6)], encoder[extract(v, 6, 6)], encoder[64]) + elseif lastn == 1 then + local v = str:byte(n) * 0x10000 + t[k] = char(encoder[extract(v, 18, 6)], encoder[extract(v, 12, 6)], encoder[64], encoder[64]) + end + return concat(t) +end + +function base64.decode(b64, decoder, usecaching) + decoder = decoder or DEFAULT_DECODER + local pattern = "[^%w%+%/%=]" + if decoder then + local s62, s63 + for charcode, b64code in pairs(decoder) do + if b64code == 62 then + s62 = charcode + elseif b64code == 63 then + s63 = charcode + end + end + pattern = ("[^%%w%%%s%%%s%%=]"):format(char(s62), char(s63)) + end + b64 = b64:gsub(pattern, "") + local cache = usecaching and {} + local t, k = {}, 1 + local n = #b64 + local padding = b64:sub(-2) == "==" and 2 or b64:sub(-1) == "=" and 1 or 0 + for i = 1, padding > 0 and n - 4 or n, 4 do + local a, b, c, d = b64:byte(i, i + 3) + local s + if usecaching then + local v0 = a * 0x1000000 + b * 0x10000 + c * 0x100 + d + s = cache[v0] + if not s then + local v = decoder[a] * 0x40000 + decoder[b] * 0x1000 + decoder[c] * 0x40 + decoder[d] + s = char(extract(v, 16, 8), extract(v, 8, 8), extract(v, 0, 8)) + cache[v0] = s + end + else + local v = decoder[a] * 0x40000 + decoder[b] * 0x1000 + decoder[c] * 0x40 + decoder[d] + s = char(extract(v, 16, 8), extract(v, 8, 8), extract(v, 0, 8)) + end + t[k] = s + k = k + 1 + end + if padding == 1 then + local a, b, c = b64:byte(n - 3, n - 1) + local v = decoder[a] * 0x40000 + decoder[b] * 0x1000 + decoder[c] * 0x40 + t[k] = char(extract(v, 16, 8), extract(v, 8, 8)) + elseif padding == 2 then + local a, b = b64:byte(n - 3, n - 2) + local v = decoder[a] * 0x40000 + decoder[b] * 0x1000 + t[k] = char(extract(v, 16, 8)) + end + return concat(t) +end + +return base64 + +--[[ +------------------------------------------------------------------------------ +This software is available under 2 licenses -- choose whichever you prefer. +------------------------------------------------------------------------------ +ALTERNATIVE A - MIT License +Copyright (c) 2018 Ilya Kolbin +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +------------------------------------------------------------------------------ +ALTERNATIVE B - Public Domain (www.unlicense.org) +This is free and unencumbered software released into the public domain. +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +software, either in source code form or as a compiled binary, for any purpose, +commercial or non-commercial, and by any means. +In jurisdictions that recognize copyright laws, the author or authors of this +software dedicate any and all copyright interest in the software to the public +domain. We make this dedication for the benefit of the public at large and to +the detriment of our heirs and successors. We intend this dedication to be an +overt act of relinquishment in perpetuity of all present and future rights to +this software under copyright law. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------------ +--]] From d20e23d089490d093013bc20569b8761dd4918fe Mon Sep 17 00:00:00 2001 From: Oli Morris Date: Tue, 16 Jan 2024 22:13:49 +0000 Subject: [PATCH 3/3] test: add tests and ci --- .github/workflows/ci.yml | 49 ++++++++++++++++++++++++ Makefile | 46 ++++++++++++++++++++++ lua/spec/codecompanion/client_spec.lua | 53 ++++++++++++++++++++++++++ scripts/minimal.vim | 23 +++++++++++ 4 files changed, 171 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 Makefile create mode 100644 lua/spec/codecompanion/client_spec.lua create mode 100644 scripts/minimal.vim diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..285924ed --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: Continuous Integration + +on: + push: + branches: + - main + pull_request: ~ + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} + cancel-in-progress: true + +jobs: + tests: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-20.04 + url: https://github.com/neovim/neovim/releases/download/nightly/nvim-linux64.tar.gz + - os: ubuntu-20.04 + url: https://github.com/neovim/neovim/releases/download/v0.9.2/nvim-linux64.tar.gz + + steps: + - uses: actions/checkout@v2 + - run: date +%F > todays-date + - name: Restore from todays cache + uses: actions/cache@v2 + with: + path: _neovim + key: ${{ runner.os }}-${{ matrix.url }}-${{ hashFiles('todays-date') }} + + - name: Prepare + run: | + test -d _neovim || { + mkdir -p _neovim + curl -sL ${{ matrix.url }} | tar xzf - --strip-components=1 -C "${PWD}/_neovim" + } + mkdir -p ~/.local/share/nvim/site/pack/vendor/start + git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/site/pack/vendor/start/plenary.nvim + ln -s $(pwd) ~/.local/share/nvim/site/pack/vendor/start + + - name: Run tests + run: | + export PATH="${PWD}/_neovim/bin:${PATH}" + export VIM="${PWD}/_neovim/share/nvim/runtime" + nvim --version + make test diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..29a1d876 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +PANVIMDOC_DIR = misc/panvimdoc +PANVIMDOC_URL = https://github.com/kdheepak/panvimdoc +PLENARY_DIR = misc/plenary +PLENARY_URL = https://github.com/nvim-lua/plenary.nvim +TREESITTER_DIR = misc/treesitter +TREESITTER_URL = https://github.com/nvim-treesitter/nvim-treesitter + +all: format test docs + +docs: $(PANVIMDOC_DIR) + @echo "===> Docs:" && \ + cd $(PANVIMDOC_DIR) && \ + pandoc \ + --metadata="project:codecompanion" \ + --metadata="description:Use the OpenAI APIs directly in Neovim" \ + --metadata="toc:true" \ + --metadata="incrementheadinglevelby:0" \ + --metadata="treesitter:true" \ + --lua-filter scripts/skip-blocks.lua \ + --lua-filter scripts/include-files.lua \ + --lua-filter scripts/remove-emojis.lua \ + -t scripts/panvimdoc.lua \ + ../../README.md \ + -o ../../doc/codecompanion.txt + +$(PANVIMDOC_DIR): + git clone --depth=1 --no-single-branch $(PANVIMDOC_URL) $(PANVIMDOC_DIR) + @rm -rf doc/panvimdoc/.git + +format: + @echo "===> Formatting:" + @stylua lua/ -f ./stylua.toml + +test: $(PLENARY_DIR) $(TREESITTER_DIR) + @echo "===> Testing:" + nvim --headless --clean \ + -u scripts/minimal.vim \ + -c "PlenaryBustedDirectory lua/spec/codecompanion { minimal_init = 'scripts/minimal.vim' }" + +$(PLENARY_DIR): + git clone --depth=1 --branch v0.1.4 $(PLENARY_URL) $(PLENARY_DIR) + @rm -rf $(PLENARY_DIR)/.git + +$(TREESITTER_DIR): + git clone --depth=1 --branch v0.9.1 $(TREESITTER_URL) $(TREESITTER_DIR) + @rm -rf $(TREESITTER_DIR)/.git diff --git a/lua/spec/codecompanion/client_spec.lua b/lua/spec/codecompanion/client_spec.lua new file mode 100644 index 00000000..053423d9 --- /dev/null +++ b/lua/spec/codecompanion/client_spec.lua @@ -0,0 +1,53 @@ +local mock = require("luassert.mock") +local stub = require("luassert.stub") +local match = require("luassert.match") +local spy = require("luassert.spy") + +local Client = require("codecompanion.client") + +local function setup(opts) + require("codecompanion").setup(opts) +end + +describe("Client", function() + it("should call API correctly when chat is invoked", function() + local fn_mock = mock(vim.fn, true) + local log_mock = mock(require("codecompanion.utils.log"), true) + local autocmds_spy = spy.on(vim.api, "nvim_exec_autocmds") + + local jobstart_stub = stub(fn_mock, "jobstart", function(_, opts) + local stdout_response = { vim.json.encode("SOME JSON RESPONSE") } + + if opts.on_stdout then + opts.on_stdout(nil, stdout_response) + end + + local exit_code = 0 + if opts.on_exit then + opts.on_exit(nil, exit_code) + end + + return 1 + end) + + setup({ + base_url = "https://api.example.com", + }) + + local client = Client.new({ secret_key = "TEST_SECRET_KEY" }) + local cb_stub = stub.new() + + client:chat({ messages = { { role = "user", content = "hello" } } }, cb_stub) + + assert.stub(jobstart_stub).was_called(1) + assert.stub(jobstart_stub).was_called_with(match.is_table(), match.is_table()) + + -- It's only called once as the jobstart_stub is stubbed to not fire an event + assert.spy(autocmds_spy).was_called(1) + + autocmds_spy:revert() + jobstart_stub:revert() + mock.revert(fn_mock) + mock.revert(log_mock) + end) +end) diff --git a/scripts/minimal.vim b/scripts/minimal.vim new file mode 100644 index 00000000..f69cf12a --- /dev/null +++ b/scripts/minimal.vim @@ -0,0 +1,23 @@ +set rtp+=. +set rtp+=./misc/plenary +set rtp+=./misc/treesitter + +set noswapfile + +runtime! plugin/plenary.vim +runtime! plugin/nvim-treesitter.lua + +lua < 0 then + -- fixes 'pos_delta >= 0' error - https://github.com/nvim-lua/plenary.nvim/issues/52 + vim.cmd('set display=lastline') + -- make "TSInstall*" available + vim.cmd 'runtime! plugin/nvim-treesitter.vim' + vim.cmd('TSInstallSync ' .. table.concat(to_install, ' ')) +end +EOF