[
  {
    "path": "README.md",
    "content": "# backseat.nvim\nA neovim plugin that uses GPT to highlight and explain code readability issues. Get unsolicited advice of dubious quality in never-before-seen quantities!\n<br><br>\n![image](https://user-images.githubusercontent.com/32351696/229314187-f229664f-f396-4840-9765-8118810b3dae.png)\n\n# Commands\n| User Command | Purpose |\n| -- | -- |\n| `:Backseat`  | Sends the current buffer to OpenAI to highlight readability feedback |\n| `:BackseatAsk <question>` | Ask a question about the code in the current buffer (i.e What does the function on line 20 do?, Summarize this code)\n| `:BackseatClear` | Clear all Backseat highlighting from the current buffer\n| `:BackseatClearLine` | Clear the current line of Backseat highlighting\n\nIf a buffer contains more than 100 lines, it will be split into multiple <= 100 line requests.\n## Requirements\n * curl\n * OpenAI API key - You can get yours with a free account from [their website](https://platform.openai.com/account/api-keys). If you don't have any more free credits, usage is very cheap at ~$0.004 per 100 lines submitted.\n# Install\n### Lazy plugin manager\n```lua\n{\n    \"james1236/backseat.nvim\",\n    config = function()\n        require(\"backseat\").setup({\n            -- Alternatively, set the env var $OPENAI_API_KEY by putting \"export OPENAI_API_KEY=sk-xxxxx\" in your ~/.bashrc\n            openai_api_key = 'sk-xxxxxxxxxxxxxx', -- Get yours from platform.openai.com/account/api-keys\n            openai_model_id = 'gpt-3.5-turbo', --gpt-4 (If you do not have access to a model, it says \"The model does not exist\")\n            -- language = 'english', -- Such as 'japanese', 'french', 'pirate', 'LOLCAT'\n            -- split_threshold = 100,\n            -- additional_instruction = \"Respond snarkily\", -- (GPT-3 will probably deny this request, but GPT-4 complies)\n            -- highlight = {\n            --     icon = '', -- ''\n            --     group = 'Comment',\n            -- }\n        })\n    end\n},\n```\n### The result of using `additional_instruction = \"Respond snarkily\"`\n![image](https://user-images.githubusercontent.com/32351696/229297495-6d145848-10bf-43eb-8c2a-ab4264f514b1.png)\n\n# Config \n| Setup Table Name | Default | Purpose |\n| --- | --- | -- |\n| `openai_api_key` | `nil` | Your OpenAI API key, needed to use their language models\n| `openai_model_id` | `'gpt-3.5-turbo'` | The model's identifier, such as gpt-3.5-turbo and gpt-4\n| `language` | `'english'` | The language of suggestions, such as 'japanese', 'french' or made up ones like 'pirate', 'LOLCAT'\n| `split_threshold` | `100` | The max number of lines of code sent per request (lower uses more tokens but increases number of suggestions)\n| `additional_instruction`, | `nil` | An additional instruction to give the AI, like \"Make your responses more brief\"\n| `highlight.icon` | `''` | The sign column icon to display for each line containing suggestions\n| `highlight.group` | `'String'` | The `:hi` highlight color group for the icon and the suggestion text \n\n# More Examples\n![image](https://user-images.githubusercontent.com/32351696/229299250-1fcb4135-2a6a-4663-9637-13af7c0ee7cd.png)\n"
  },
  {
    "path": "lua/backseat/fewshot.lua",
    "content": "local fewshot = {\n    messages = {\n        {\n            role = \"system\",\n            content = [[\nYou must identify any readability issues in the code snippet.\nSome readability issues to consider:\n- Unclear naming\n- Unclear purpose\n- Redundant or obvious comments\n- Lack of comments\n- Long or complex one liners\n- Too much nesting\n- Long variable names\n- Inconsistent naming and code style.\n- Code repetition\nYou may identify additional problems. The user submits a small section of code from a larger file.\nOnly list lines with readability issues, in the format line=<num>: <issue and proposed solution>\nYour commentary must fit on a single line]]\n        },\n        {\n            role = \"user\",\n            content = [[\n04 public class Logic {\n05     public static void main(String[] args) {\n06         Scanner sc = new Scanner(System.in);\n07         int n = sc.nextInt();\n08         int[] arr = new int[n];\n09         for (int i = 0; i < n; i++) {\n10             arr[i] = sc.nextInt();\n11         }\n12         int[] dp = new int[n];\n13         dp[0] = arr[0];\n14         dp[1] = Math.max(arr[0], arr[1]);\n15         for (int i = 2; i < n; i++) {\n16             dp[i] = Math.max(dp[i - 1], dp[i - 2] + arr[i]);\n17         }\n18         System.out.println(dp[n - 1]);\n19     }\n20 }]]\n        },\n        {\n            role = \"assistant\",\n            content = [[\nline=4: The class name 'Logic' is too generic. A more meaningful name could be 'DynamicProgramming'\nline=6: The variable name 'sc' is unclear. A more meaningful name could be 'scanner'.\nline=7: The variable name 'n' is unclear. A more meaningful name could be 'arraySize' or 'numElements'.\nline=8: The variable name 'arr' unclear. A more descriptive name could be 'inputArray' or 'elementValues'.\nline=12: The variable name 'dp' is unclear. A more informative name could be 'maxSum' or 'optimalSolution'.\nline=13: There are no comments explaining the meaning of the 'dp' array values and how they relate to the problem statement.\nline=15: There are no comments explaining the logic and purpose of the for loop]]\n        },\n        {\n            role = \"user\",\n            content = [[\n673    for (let i: number = 0; i < l; i++) {\n674       let notAddr: boolean = false;\n675       // non standard input\n676       if (items[i].scriptSig && !items[i].addr) {\n677         items[i].addr = 'Unparsed address [' + u++ + ']';\n678         items[i].notAddr = true;\n679         notAddr = true;\n680       }\n681\n682       // non standard output\n683       if (items[i].scriptPubKey && !items[i].scriptPubKey.addresses) {\n684         items[i].scriptPubKey.addresses = ['Unparsed address [' + u++ + ']'];]]\n        },\n        {\n            role = \"assistant\",\n            content = [[\nline=673: The variable name 'i' and 'l' are unclear and easily confused with other characters like '1'. More meaningful names could be 'index' and 'length' respectively.\nline=674: The variable name 'notAddr' is unclear and a double negative. An alternative could be 'hasUnparsedAddress'.\nline=676: The comment \"non standard input\" is not very informative. It could be more descriptive, e.g., \"Check for non standard input address\"\nline=682: The comment \"non standard output\" is not very informative. It could be more descriptive, e.g., \"Check for non standard output address\"\nline=683: The variable name 'items' might be more informative if changed to 'transactions' or 'txItems'.\nline=684: The array element 'Unparsed address [' + u++ + ']' could use a more descriptive comment, e.g., \"Assign a unique identifier to non standard output addresses\"\nline=684: The variable name 'u' is unclear. A more meaningful name could be 'unparsedAddressCount' or 'unparsedAddressId'.]]\n        }\n    }\n}\n\nreturn fewshot\n"
  },
  {
    "path": "lua/backseat/init.lua",
    "content": "local M = {}\n\nlocal default_opts = {\n    openai_api_key = nil,\n    openai_model_id = 'gpt-3.5-turbo',\n    language = 'english',\n    additional_instruction = nil,\n    split_threshold = 100,\n    highlight = {\n        icon = '',\n        group = 'String',\n    }\n}\n\nfunction M.setup(opts)\n    -- Merge default_opts with opts\n    opts = vim.tbl_deep_extend('force', default_opts, opts or {})\n\n    -- Set the module's options\n    -- if vim.g.backseat_openai_api_key == nil then\n    vim.g.backseat_openai_api_key = opts.openai_api_key\n    -- end\n\n    -- if vim.g.backseat_openai_model_id == nil then\n    vim.g.backseat_openai_model_id = opts.openai_model_id\n    -- end\n\n    -- if vim.g.backseat_language == nil then\n    vim.g.backseat_language = opts.language\n    -- end\n\n    -- if vim.g.backseat_additional_instruction == nil then\n    vim.g.backseat_additional_instruction = opts.additional_instruction\n    -- end\n\n    -- if vim.g.backseat_split_threshold == nil then\n    vim.g.backseat_split_threshold = opts.split_threshold\n    -- end\n\n    -- if vim.g.backseat_highlight_icon == nil then\n    vim.g.backseat_highlight_icon = opts.highlight.icon\n    -- end\n\n    -- if vim.g.backseat_highlight_group == nil then\n    vim.g.backseat_highlight_group = opts.highlight.group\n    -- end\nend\n\nreturn M\n"
  },
  {
    "path": "plugin/backseat.lua",
    "content": "-- Automatically executed on startup\nif vim.g.loaded_backseat then\n    return\nend\nvim.g.loaded_backseat = true\n\nrequire(\"backseat\").setup()\nlocal fewshot = require(\"backseat.fewshot\") -- The training messages\n\n-- Create namespace for backseat suggestions\nlocal backseatNamespace = vim.api.nvim_create_namespace(\"backseat\")\n\nlocal function print(msg)\n    _G.print(\"Backseat > \" .. msg)\nend\n\nlocal function get_api_key()\n    -- Priority: 1. g:backseat_openai_api_key 2. $OPENAI_API_KEY 3. Prompt user\n    local api_key = vim.g.backseat_openai_api_key\n    if api_key == nil then\n        local key = os.getenv(\"OPENAI_API_KEY\")\n        if key ~= nil then\n            return key\n        end\n        local message =\n        \"No API key found. Please set openai_api_key in the setup table or set the $OPENAI_API_KEY environment variable.\"\n        vim.fn.confirm(message, \"&OK\", 1, \"Warning\")\n        return nil\n    end\n    return api_key\nend\n\nlocal function get_model_id()\n    local model = vim.g.backseat_openai_model_id\n    if model == nil then\n        if vim.g.backseat_model_id_complained == nil then\n            local message =\n            \"No model id specified. Please set openai_model_id in the setup table. Defaulting to gpt-3.5-turbo for now\" -- \"gpt-4\"\n            vim.fn.confirm(message, \"&OK\", 1, \"Warning\")\n            vim.g.backseat_model_id_complained = 1\n        end\n        return \"gpt-3.5-turbo\"\n    end\n    return model\nend\n\nlocal function get_language()\n    return vim.g.backseat_language\nend\n\nlocal function get_additional_instruction()\n    return vim.g.backseat_additional_instruction or \"\"\nend\n\nlocal function get_split_threshold()\n    return vim.g.backseat_split_threshold\nend\n\nlocal function get_highlight_icon()\n    return vim.g.backseat_highlight_icon\nend\n\nlocal function get_highlight_group()\n    return vim.g.backseat_highlight_group\nend\n\nlocal function split_long_text(text)\n    local lines = vim.split(text, \"\\n\")\n    -- Get the width of the screen\n    local screenWidth = vim.api.nvim_win_get_width(0) - 20\n    -- Split any suggestionLines that are too long\n    local newLines = {}\n    for _, line in ipairs(lines) do\n        if string.len(line) >= screenWidth then\n            local splitLines = vim.split(line, \" \")\n            local currentLine = \"\"\n            for _, word in ipairs(splitLines) do\n                if string.len(currentLine) + string.len(word) > screenWidth then\n                    table.insert(newLines, currentLine)\n                    currentLine = word\n                else\n                    currentLine = currentLine .. \" \" .. word\n                end\n            end\n            table.insert(newLines, currentLine)\n        else\n            table.insert(newLines, line)\n        end\n    end\n    return newLines\nend\n\nlocal function gpt_request(dataJSON, callback, callbackTable)\n    local api_key = get_api_key()\n    if api_key == nil then\n        return nil\n    end\n\n    -- Check if curl is installed\n    if vim.fn.executable(\"curl\") == 0 then\n        vim.fn.confirm(\"curl installation not found. Please install curl to use Backseat\", \"&OK\", 1, \"Warning\")\n        return nil\n    end\n\n    local curlRequest\n\n    -- Create temp file\n    local tempFilePath = vim.fn.tempname()\n    local tempFile = io.open(tempFilePath, \"w\")\n    if tempFile == nil then\n        print(\"Error creating temp file\")\n        return nil\n    end\n    -- Write dataJSON to temp file\n    tempFile:write(dataJSON)\n    tempFile:close()\n\n    -- Escape the name of the temp file for command line\n    local tempFilePathEscaped = vim.fn.fnameescape(tempFilePath)\n\n    -- Check if the user is on windows\n    local isWindows = vim.fn.has(\"win32\") == 1 or vim.fn.has(\"win64\") == 1\n\n    if isWindows ~= true then\n        -- Linux\n        curlRequest = string.format(\n            \"curl -s https://api.openai.com/v1/chat/completions -H \\\"Content-Type: application/json\\\" -H \\\"Authorization: Bearer \" ..\n            api_key ..\n            \"\\\" --data-binary \\\"@\" .. tempFilePathEscaped .. \"\\\"; rm \" .. tempFilePathEscaped .. \" > /dev/null 2>&1\"\n        )\n    else\n        -- Windows\n        curlRequest = string.format(\n            \"curl -s https://api.openai.com/v1/chat/completions -H \\\"Content-Type: application/json\\\" -H \\\"Authorization: Bearer \" ..\n            api_key ..\n            \"\\\" --data-binary \\\"@\" .. tempFilePathEscaped .. \"\\\" & del \" .. tempFilePathEscaped .. \" > nul 2>&1\"\n        )\n    end\n\n    -- vim.fn.confirm(curlRequest, \"&OK\", 1, \"Warning\")\n\n    vim.fn.jobstart(curlRequest, {\n        stdout_buffered = true,\n        on_stdout = function(_, data, _)\n            local response = table.concat(data, \"\\n\")\n            local success, responseTable = pcall(vim.json.decode, response)\n\n            if success == false or responseTable == nil then\n                if response == nil then\n                    response = \"nil\"\n                end\n                print(\"Bad or no response: \" .. response)\n                return nil\n            end\n\n            if responseTable.error ~= nil then\n                print(\"OpenAI Error: \" .. responseTable.error.message)\n                return nil\n            end\n\n            -- print(response)\n            callback(responseTable, callbackTable)\n            -- return response\n        end,\n        on_stderr = function(_, data, _)\n            return data\n        end,\n        on_exit = function(_, data, _)\n            return data\n        end,\n    })\n\n    -- vim.cmd(\"sleep 10000m\") -- Sleep to give time to read the error messages\nend\n\nlocal function parse_response(response, partNumberString, bufnr)\n    -- split response.choices[1].message.content into lines\n    local lines = vim.split(response.choices[1].message.content, \"\\n\")\n    --Suggestions may span multiple lines, so we need to change the list of lines into a list of suggestions\n    local suggestions = {}\n\n    -- Add each line to the suggestions table if it starts with line= or lines=\n    for _, line in ipairs(lines) do\n        if (string.sub(line, 1, 5) == \"line=\") or string.sub(line, 1, 6) == \"lines=\" then\n            -- Add this line to the suggestions table\n            table.insert(suggestions, line)\n        elseif #suggestions > 0 then\n            -- Append lines that don't start with line= or lines= to the previous suggestion\n            suggestions[#suggestions] = suggestions[#suggestions] .. \"\\n\" .. line\n        end\n    end\n\n    if #suggestions == 0 then\n        print(\"AI Says: \" ..\n        response.choices[1].message.content ..\n        \" - Used \" .. response.usage.total_tokens .. \" tokens from model \" .. get_model_id() .. partNumberString)\n    else\n        print(\"AI made \" ..\n        #suggestions ..\n        \" suggestion(s) using \" ..\n        response.usage.total_tokens .. \" tokens from model \" .. get_model_id() .. partNumberString)\n    end\n\n    -- Act on each suggestion\n    for _, suggestion in ipairs(suggestions) do\n        -- Get the line number\n        local lineString = string.sub(suggestion, 6, string.find(suggestion, \":\") - 1)\n        -- The string may be in the format \"line=1-3\", so we can extract the first number\n        if string.find(lineString, \"-\") ~= nil then\n            lineString = string.sub(lineString, 1, string.find(lineString, \"-\") - 1)\n        end\n        local lineNum = tonumber(lineString)\n\n        if lineNum == nil then\n            -- print(\"Bad line number: \" .. line)\n            -- If the line number is bad, just add the suggestion to the first line\n            lineNum = 1\n            -- goto continue\n        end\n        -- Get the message\n        local message = string.sub(suggestion, string.find(suggestion, \":\") + 1, string.len(suggestion))\n        -- If the first character is a space, remove it\n        if string.sub(message, 1, 1) == \" \" then\n            message = string.sub(message, 2, string.len(message))\n        end\n        -- print(\"Line \" .. lineNum .. \": \" .. message)\n\n        -- Split suggestion into line, highlight group pairs\n        local newLines = split_long_text(message)\n\n        local pairs = {}\n        for i, line in ipairs(newLines) do\n            local pair = {}\n            pair[1] = line\n            pair[2] = get_highlight_group()\n            pairs[i] = { pair }\n        end\n\n        -- Add suggestion virtual text and a lightbulb icon to the sign column\n        vim.api.nvim_buf_set_extmark(bufnr, backseatNamespace, lineNum - 1, 0, {\n            virt_text_pos = \"overlay\",\n            virt_lines = pairs,\n            hl_mode = \"combine\",\n            sign_text = get_highlight_icon(),\n            sign_hl_group = get_highlight_group()\n        })\n        -- ::continue::\n    end\nend\n\nlocal function prepare_code_snippet(bufnr, startingLineNumber, endingLineNumber)\n    -- print(\"Preparing code snippet from lines \" .. startingLineNumber .. \" to \" .. endingLineNumber)\n    local lines = vim.api.nvim_buf_get_lines(bufnr, startingLineNumber - 1, endingLineNumber, false)\n\n    -- Get the max number of digits needed to display a line number\n    local maxDigits = string.len(tostring(#lines + startingLineNumber))\n    -- Prepend each line with its line number zero padded to numDigits\n    for i, line in ipairs(lines) do\n        lines[i] = string.format(\"%0\" .. maxDigits .. \"d\", i - 1 + startingLineNumber) .. \" \" .. line\n    end\n\n    local text = table.concat(lines, \"\\n\")\n    return text\nend\n\nlocal backseat_callback\nlocal function backseat_send_from_request_queue(callbackTable)\n    -- Stop if there are no more requests in the queue\n    if (#callbackTable.requests == 0) then\n        return nil\n    end\n\n    -- Get bufname without the path\n    local bufname = vim.fn.fnamemodify(vim.fn.bufname(callbackTable.bufnr), \":t\")\n\n    if callbackTable.requestIndex == 0 then\n        if callbackTable.startingRequestCount == 1 then\n            print(\"Sending \" .. bufname .. \" (\" .. callbackTable.lineCount .. \" lines) and waiting for response...\")\n        else\n            print(\"Sending \" ..\n            bufname .. \" (split into \" .. callbackTable.startingRequestCount .. \" requests) and waiting for response...\")\n        end\n    end\n\n    -- Get the first request from the queue\n    local requestJSON = table.remove(callbackTable.requests, 1)\n    callbackTable.requestIndex = callbackTable.requestIndex + 1\n\n    gpt_request(requestJSON, backseat_callback, callbackTable)\nend\n\n-- Callback for a backseat request\nfunction backseat_callback(responseTable, callbackTable)\n    if responseTable ~= nil then\n        if callbackTable.startingRequestCount == 1 then\n            parse_response(responseTable, \"\", callbackTable.bufnr)\n        else\n            parse_response(responseTable,\n                \" (request \" .. callbackTable.requestIndex .. \" of \" .. callbackTable.startingRequestCount .. \")\",\n                callbackTable.bufnr)\n        end\n    end\n\n    if callbackTable.requestIndex < callbackTable.startingRequestCount + 1 then\n        backseat_send_from_request_queue(callbackTable)\n    end\nend\n\n-- Send the current buffer to the AI for readability feedback\nvim.api.nvim_create_user_command(\"Backseat\", function()\n    -- Split the current buffer into groups of lines of size splitThreshold\n    local splitThreshold = get_split_threshold()\n    local bufnr = vim.api.nvim_get_current_buf()\n    local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)\n    local numRequests = math.ceil(#lines / splitThreshold)\n    local model = get_model_id()\n\n    local requestTable = {\n        model = model,\n        messages = fewshot.messages\n    }\n\n    local requests = {}\n    for i = 1, numRequests do\n        local startingLineNumber = (i - 1) * splitThreshold + 1\n        local text = prepare_code_snippet(bufnr, startingLineNumber, startingLineNumber + splitThreshold - 1)\n        -- print(text)\n\n        if get_additional_instruction() ~= \"\" then\n            text = text .. \"\\n\" .. get_additional_instruction()\n        end\n\n        if get_language() ~= \"\" and get_language() ~= \"english\" then\n            text = text .. \"\\nRespond only in \" .. get_language() .. \", but keep the 'line=<num>:' part in english\"\n        end\n\n        -- Make a copy of requestTable (value not reference)\n        local tempRequestTable = vim.deepcopy(requestTable)\n\n        -- Add the code snippet to the request\n        table.insert(tempRequestTable.messages, {\n            role = \"user\",\n            content = text\n        })\n\n        local requestJSON = vim.json.encode(tempRequestTable)\n        requests[i] = requestJSON\n        -- print(requestJSON)\n    end\n\n    backseat_send_from_request_queue({\n        requests = requests,\n        startingRequestCount = numRequests,\n        requestIndex = 0,\n        bufnr = bufnr,\n        lineCount = #lines,\n    })\n    -- require(\"backseat.main\"):run()\nend, {})\n\n-- Use the underlying chat API to ask a question about the current buffer's code\nlocal function backseat_ask_callback(responseTable)\n    if responseTable == nil then\n        return nil\n    end\n    local message = \"AI Says: \" .. responseTable.choices[1].message.content\n\n    -- Split long messages into multiple lines\n    message = table.concat(split_long_text(message), \"\\r\\n\")\n\n    vim.fn.confirm(message, \"&OK\", 1, \"Generic\")\nend\n\nvim.api.nvim_create_user_command(\"BackseatAsk\", function(opts)\n    local bufnr = vim.api.nvim_get_current_buf()\n    local text = prepare_code_snippet(bufnr, 1, -1)\n\n    if get_additional_instruction() ~= \"\" then\n        text = text .. \"\\n\" .. get_additional_instruction()\n    end\n\n    if get_language() ~= \"\" and get_language() ~= \"english\" then\n        text = text .. \"\\nRespond only in \" .. get_language()\n    end\n\n    local bufname = vim.fn.fnamemodify(vim.fn.bufname(bufnr), \":t\")\n\n    print(\"Asking AI '\" .. opts.args .. \"' (in \" .. bufname .. \")...\")\n\n    gpt_request(vim.json.encode(\n        {\n            model = get_model_id(),\n            messages = {\n                {\n                    role = \"system\",\n                    content = \"You are a helpful assistant who can respond to questions about the following code. You can also act as a regular assistant\"\n                },\n                {\n                    role = \"user\",\n                    content = text\n                },\n                {\n                    role = \"user\",\n                    content = opts.args\n                }\n            },\n        }\n    ), backseat_ask_callback)\nend, { nargs = \"+\" })\n\n-- Clear all backseat virtual text and signs\nvim.api.nvim_create_user_command(\"BackseatClear\", function()\n    local bufnr = vim.api.nvim_get_current_buf()\n    vim.api.nvim_buf_clear_namespace(bufnr, backseatNamespace, 0, -1)\nend, {})\n\n-- Clear backseat virtual text and signs for that line\nvim.api.nvim_create_user_command(\"BackseatClearLine\", function()\n    local bufnr = vim.api.nvim_get_current_buf()\n    local lineNum = vim.api.nvim_win_get_cursor(0)[1]\n    vim.api.nvim_buf_clear_namespace(bufnr, backseatNamespace, lineNum - 1, lineNum)\nend, {})\n"
  }
]