diff --git a/README.md b/README.md index cec41be..bf5178c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A ChatGPT Vim plugin, an OpenAI Neovim plugin, and so much more! Neural integrat * Generate text easily `:Neural write a story` * Support for multiple machine learning models +* Focused on privacy and avoiding leaking data to third parties +* Easily ask AI to explain code or paragraphs `:NeuralExplain` * Compatible with Vim 8.0+ & Neovim 0.8+ * Supported on Linux, Mac OSX, and Windows * Only dependency is Python 3.7+ @@ -107,11 +109,25 @@ Try typing `:Neural say hello`, and if all goes well the machine learning tool will say "hello" to you in the current buffer. Type `:help neural` to see the full documentation. +## 🛠️ Commands + +### `:NeuralExplain` + +You can ask Neural to explain code or text by visually selecting it and running +the `:NeuralExplain` command. You may also create a custom keybind for +explaining a visual range with `(neural_explain)`. + +Neural will make basic attempts to redact lines that appear to contain passwords +or secrets. You may audit this code by reading +[`autoload/neural/redact.vim`](https://github.com/dense-analysis/neural/blob/main/autoload/neural/redact.vim) + +### `:NeuralStop` + You can stop Neural from working by with the `NeuralStop` command. Unless another keybind for `` (CTRL+C) is defined in normal mode, Neural will run the stop command by default when you enter that key combination. The default keybind can be disabled by setting `g:neural.set_default_keybinds` to any falsy -value. +value. You can set a keybind to stop Neural by mapping to `(neural_stop)`. ## 📜 Acknowledgements diff --git a/autoload/neural.vim b/autoload/neural.vim index 5a2a242..5daa41a 100644 --- a/autoload/neural.vim +++ b/autoload/neural.vim @@ -23,7 +23,9 @@ function! neural#GetScriptDir() abort return s:neural_script_dir endfunction -function! s:OutputErrorMessage(message) abort +" Output an error message. The message should be a string. +" The output error lines will be split in a platform-independent way. +function! neural#OutputErrorMessage(message) abort let l:lines = split(a:message, '\v\r\n|\n|\r') if len(l:lines) > 0 @@ -100,7 +102,7 @@ function! s:HandleOutputEnd(buffer, job_data, exit_code) abort " Output an error message from the program if something goes wrong. if a:exit_code != 0 - call s:OutputErrorMessage(join(a:job_data.error_lines, "\n")) + call neural#OutputErrorMessage(join(a:job_data.error_lines, "\n")) else " Signal Neural is done for plugin integration. silent doautocmd User NeuralWritePost @@ -110,6 +112,8 @@ function! s:HandleOutputEnd(buffer, job_data, exit_code) abort echomsg 'Neural is done!' endif endif + + let s:current_job = 0 endfunction " Get the path to the executable for a script language. @@ -151,8 +155,10 @@ function! neural#Escape(str) abort return shellescape (a:str) endfunction +" Complain about no prompt text being provided. +" This function is also called from Lua code. function! neural#ComplainNoPromptText() abort - call s:OutputErrorMessage('No prompt text!') + call neural#OutputErrorMessage('No prompt text!') endfunction function! neural#OpenPrompt() abort @@ -166,10 +172,6 @@ function! neural#OpenPrompt() abort endif endfunction -function! neural#ComplainNoPromptText() abort - call s:OutputErrorMessage('No prompt text!') -endfunction - function! s:InitiallyInformUser(job_id) abort if neural#job#IsRunning(a:job_id) " no-custom-checks @@ -185,8 +187,17 @@ function! s:InformUserIfStillBusy(job_id) abort endfunction function! neural#Cleanup() abort - " Stop any currently running jobs. - call neural#job#Stop(s:current_job) + " Stop :NeuralExplain if it might be running. + if exists('*neural#explain#Cleanup') + call neural#explain#Cleanup() + endif + + if s:current_job + " Stop any currently running jobs. + call neural#job#Stop(s:current_job) + let s:current_job = 0 + endif + " Stop timers for informing the user. call timer_stop(s:initial_timer) call timer_stop(s:busy_timer) @@ -229,7 +240,7 @@ function! s:LoadDataSource() abort try let l:source = function('neural#source#' . selected . '#Get')() catch /E117/ - call s:OutputErrorMessage('Invalid source: ' . l:selected) + call neural#OutputErrorMessage('Invalid source: ' . l:selected) return endtry @@ -253,6 +264,16 @@ function! s:GetSourceInput(buffer, source, prompt) abort return l:input endfunction +function! neural#GetCommand(buffer) abort + let l:source = s:LoadDataSource() + let l:script_exe = s:GetScriptExecutable(l:source) + let l:command = neural#Escape(l:script_exe) + \ . ' ' . neural#Escape(l:source.script) + let l:command = neural#job#PrepareCommand(a:buffer, l:command) + + return [l:source, l:command] +endfunction + function! neural#Prompt(prompt) abort " Reload the Neural config on a prompt request if needed. call neural#config#Load() @@ -262,7 +283,7 @@ function! neural#Prompt(prompt) abort if has('nvim') && g:neural.ui.prompt_enabled call neural#OpenPrompt() else - call s:OutputErrorMessage('No prompt text!') + call neural#ComplainNoPromptText() endif return @@ -276,18 +297,12 @@ function! neural#Prompt(prompt) abort let l:moving_line -= 1 endif - let l:source = s:LoadDataSource() - let l:script_exe = s:GetScriptExecutable(l:source) - let l:command = neural#Escape(l:script_exe) - \ . ' ' . neural#Escape(l:source.script) - let l:command = neural#job#PrepareCommand(l:buffer, l:command) + let [l:source, l:command] = neural#GetCommand(l:buffer) let l:job_data = { \ 'moving_line': l:moving_line, \ 'error_lines': [], \ 'content_started': 0, \} - let l:input = s:GetSourceInput(l:buffer, l:source, a:prompt) - let l:job_id = neural#job#Start(l:command, { \ 'mode': 'nl', \ 'out_cb': {job_id, line -> s:AddLineToBuffer(l:buffer, l:job_data, line)}, @@ -296,11 +311,10 @@ function! neural#Prompt(prompt) abort \}) if l:job_id > 0 - let l:stdin_data = json_encode(l:input) . "\n" - - call neural#job#SendRaw(l:job_id, l:stdin_data) + let l:input = s:GetSourceInput(l:buffer, l:source, a:prompt) + call neural#job#SendRaw(l:job_id, json_encode(l:input) . "\n") else - call s:OutputErrorMessage('Failed to run ' . l:source.name) + call neural#OutputErrorMessage('Failed to run ' . l:source.name) return endif diff --git a/autoload/neural/explain.vim b/autoload/neural/explain.vim new file mode 100644 index 0000000..75f0193 --- /dev/null +++ b/autoload/neural/explain.vim @@ -0,0 +1,90 @@ +" Author: w0rp +" Description: Explain a visual selection with Neural. + +let s:current_job = get(s:, 'current_explain_job', 0) + +function! s:AddOutputLine(buffer, job_data, line) abort + call add(a:job_data.output_lines, a:line) +endfunction + +function! s:AddErrorLine(buffer, job_data, line) abort + call add(a:job_data.error_lines, a:line) +endfunction + +function! s:HandleOutputEnd(buffer, job_data, exit_code) abort + " Output an error message from the program if something goes wrong. + if a:exit_code != 0 + " Complain when something goes wrong. + call neural#OutputErrorMessage(join(a:job_data.error_lines, "\n")) + else + let l:i = 0 + + while l:i < len(a:job_data.output_lines) + if !empty(a:job_data.output_lines[l:i]) + break + endif + + let l:i += 1 + endwhile + + call neural#preview#Show( + \ a:job_data.output_lines[l:i :], + \ {'stay_here': 1}, + \) + endif + + let s:current_job = 0 +endfunction + +function! neural#explain#Cleanup() abort + if s:current_job + call neural#job#Stop(s:current_job) + let s:current_job = 0 + endif +endfunction + +function! neural#explain#SelectedLines() abort + " Reload the Neural config if needed. + call neural#config#Load() + " Stop Neural doing anything else if explaining code. + call neural#Cleanup() + + let l:range = neural#visual#GetRange() + let l:buffer = bufnr('') + + let [l:source, l:command] = neural#GetCommand(l:buffer) + + let l:job_data = { + \ 'output_lines': [], + \ 'error_lines': [], + \} + let l:job_id = neural#job#Start(l:command, { + \ 'mode': 'nl', + \ 'out_cb': {job_id, line -> s:AddOutputLine(l:buffer, l:job_data, line)}, + \ 'err_cb': {job_id, line -> s:AddErrorLine(l:buffer, l:job_data, line)}, + \ 'exit_cb': {job_id, exit_code -> s:HandleOutputEnd(l:buffer, l:job_data, exit_code)}, + \}) + + if l:job_id > 0 + let l:lines = neural#redact#PasswordsAndSecrets(l:range.selection) + + let l:config = get(g:neural.source, l:source.name, {}) + + " If the config is not a Dictionary, throw it away. + if type(l:config) isnot v:t_dict + let l:config = {} + endif + + let l:input = { + \ 'config': l:config, + \ 'prompt': "Explain these lines:\n\n" . join(l:lines, "\n"), + \} + call neural#job#SendRaw(l:job_id, json_encode(l:input) . "\n") + else + call neural#OutputErrorMessage('Failed to run ' . l:source.name) + + return + endif + + let s:current_job = l:job_id +endfunction diff --git a/autoload/neural/preview.vim b/autoload/neural/preview.vim new file mode 100644 index 0000000..08f2ae4 --- /dev/null +++ b/autoload/neural/preview.vim @@ -0,0 +1,27 @@ +" Author: w0rp +" Description: Preview windows for showing whatever information in. + +" Open a preview window and show some lines in it. +" The second argument allows options to be passed in. +" +" filetype - The filetype to use, defaulting to 'neural-preview' +" stay_here - If 1, stay in the window you came from. +function! neural#preview#Show(lines, options) abort + silent pedit NeuralPreviewWindow + wincmd P + + setlocal modifiable + setlocal noreadonly + setlocal nobuflisted + setlocal buftype=nofile + setlocal bufhidden=wipe + :%d + call setline(1, a:lines) + setlocal nomodifiable + setlocal readonly + let &l:filetype = get(a:options, 'filetype', 'neural-preview') + + if get(a:options, 'stay_here') + wincmd p + endif +endfunction diff --git a/autoload/neural/redact.vim b/autoload/neural/redact.vim new file mode 100644 index 0000000..3b6271b --- /dev/null +++ b/autoload/neural/redact.vim @@ -0,0 +1,36 @@ +" Author: w0rp +" Description: Redact passwords and secrets from code. + +" This will never be a perfect implementation, but effort can be made. +" +" The regex to use for keys in objects. +let s:key_regex = 'password|secret[_ ]?key' +" This substitution will be applied for each of the s:key_value_groups. +let s:key_value_sub = '\1\3*\4' +" Regular expressions to replace with s:key_value_sub +let s:key_value_groups = map( +\ [ +\ '("(KEYS)")( *[=:]+ *r?")[^"]+(")', +\ '("(KEYS)")( *[=:]+ *r?'')[^'']+('')', +\ '(''(KEYS)'')( *[=:]+ *r?'')[^'']+('')', +\ '(''(KEYS)'')( *[=:]+ *r?")[^"]+(")', +\ '((KEYS))( *[=:]+ *r?")[^"]+(")', +\ '((KEYS))( *[=:]+ *r?'')[^'']+('')', +\ '((KEYS))( *[=:]+ *r?`)[^`]+(`)', +\ ], +\ {_, template -> '\v\c' . substitute(template, 'KEYS', s:key_regex, '')} +\) + +function! neural#redact#PasswordsAndSecrets(unredacted) abort + let l:line_list = [] + + for l:line in a:unredacted + for l:regex in s:key_value_groups + let l:line = substitute(l:line, l:regex, s:key_value_sub, 'g') + endfor + + call add(l:line_list, l:line) + endfor + + return l:line_list +endfunction diff --git a/autoload/neural/visual.vim b/autoload/neural/visual.vim new file mode 100644 index 0000000..f7c86c8 --- /dev/null +++ b/autoload/neural/visual.vim @@ -0,0 +1,30 @@ +" Author: w0rp +" Description: Neural functions for working with Vim's visual mode. + +" Get all information for a visual range command we might want. +" +" Credit to xolox for most of this. +function! neural#visual#GetRange() abort + let [l:lnum, l:col] = getpos("'<")[1:2] + let [l:end_lnum, l:end_col] = getpos("'>")[1:2] + let l:end_offset = &selection is# 'inclusive' ? 1 : 2 + + " Get the line range and slice the text we selected. + let l:selection = getline(l:lnum, l:end_lnum) + + if !empty(l:selection) + let l:selection[0] = l:selection[0][l:col - 1:] + let l:selection[-1] = l:selection[-1][: l:end_col - l:end_offset] + " Get the actual end column from the text length, as Vim can give us + " the maximum int for visual line mode. + let l:end_col = len(l:selection[-1]) + endif + + return { + \ 'lnum': l:lnum, + \ 'col': l:col, + \ 'end_lnum': l:end_lnum, + \ 'end_col': l:end_col, + \ 'selection': l:selection, + \} +endfunction diff --git a/doc/neural.txt b/doc/neural.txt index b600e83..f20e5b8 100644 --- a/doc/neural.txt +++ b/doc/neural.txt @@ -53,6 +53,18 @@ Neural *Neural* completes writing text to a buffer. +NeuralExplain *NeuralExplain* + + A |visual-mode| command for explaining the highlighted lines. The visual + selection will be sent to the currently selected source model and the + response will be displayed in a preview window. + + Neural will make basic attempts to redact lines that appear to contain + passwords or secrets. + + A plug mapping `(neural_explain)` is defined for this command. + + NeuralStop *NeuralStop* Stop any currently running Neural tasks, and immediately stop printing text diff --git a/lua/neural.lua b/lua/neural.lua index 2932e14..9ad7258 100644 --- a/lua/neural.lua +++ b/lua/neural.lua @@ -41,7 +41,7 @@ function Neural.prompt() end function Neural.start_animated_sign(line) - local sign_enabled = vim.g.neural.ui.animated_sign_enabled + local sign_enabled = vim.g.neural and vim.g.neural.ui.animated_sign_enabled if has_significant and (sign_enabled and sign_enabled ~= 0) and line > 0 then AnimatedSign.start_animated_sign(line, 'dots', 100) @@ -49,7 +49,7 @@ function Neural.start_animated_sign(line) end function Neural.stop_animated_sign(line) - local sign_enabled = vim.g.neural.ui.animated_sign_enabled + local sign_enabled = vim.g.neural and vim.g.neural.ui.animated_sign_enabled if has_significant and (sign_enabled and sign_enabled ~= 0) and line > 0 then AnimatedSign.stop_animated_sign(line, {unplace_sign=true}) diff --git a/neural_sources/chatgpt.py b/neural_sources/chatgpt.py index a8ee996..4704b7c 100644 --- a/neural_sources/chatgpt.py +++ b/neural_sources/chatgpt.py @@ -143,6 +143,30 @@ def load_config(raw_config: Dict[str, Any]) -> Config: ) +def get_error_message(error: urllib.error.HTTPError) -> str: + message = error.read().decode('utf-8', errors='ignore') + + try: + # JSON data might look like this: + # { + # "error": { + # "message": "...", + # "type": "...", + # "param": null, + # "code": null + # } + # } + message = json.loads(message)['error']['message'] + + if "This model's maximum context length is" in message: + message = 'Too much text for a request!' + except Exception: + # If we can't get a better message use the JSON payload at least. + pass + + return message + + def main() -> None: input_data = json.loads(sys.stdin.readline()) @@ -153,8 +177,11 @@ def main() -> None: try: get_chatgpt_completion(config, input_data["prompt"]) - except urllib.error.URLError as error: - if isinstance(error, urllib.error.HTTPError) and error.code == 429: + except urllib.error.HTTPError as error: + if error.code == 400: + message = get_error_message(error) + sys.exit('Neural error: OpenAI request failure: ' + message) + elif error.code == 429: sys.exit("Neural error: OpenAI request limit reached!") else: raise diff --git a/neural_sources/openai.py b/neural_sources/openai.py index c09069f..ba70a46 100644 --- a/neural_sources/openai.py +++ b/neural_sources/openai.py @@ -131,6 +131,30 @@ def load_config(raw_config: Dict[str, Any]) -> Config: ) +def get_error_message(error: urllib.error.HTTPError) -> str: + message = error.read().decode('utf-8', errors='ignore') + + try: + # JSON data might look like this: + # { + # "error": { + # "message": "...", + # "type": "...", + # "param": null, + # "code": null + # } + # } + message = json.loads(message)['error']['message'] + + if "This model's maximum context length is" in message: + message = 'Too much text for a request!' + except Exception: + # If we can't get a better message use the JSON payload at least. + pass + + return message + + def main() -> None: input_data = json.loads(sys.stdin.readline()) @@ -141,8 +165,11 @@ def main() -> None: try: get_openai_completion(config, input_data["prompt"]) - except urllib.error.URLError as error: - if isinstance(error, urllib.error.HTTPError) and error.code == 429: + except urllib.error.HTTPError as error: + if error.code == 400: + message = get_error_message(error) + sys.exit('Neural error: OpenAI request failure: ' + message) + elif error.code == 429: sys.exit("Neural error: OpenAI request limit reached!") else: raise diff --git a/plugin/neural.vim b/plugin/neural.vim index e2594a8..5087deb 100644 --- a/plugin/neural.vim +++ b/plugin/neural.vim @@ -29,12 +29,17 @@ if !s:has_features finish endif +" Have Neural write to the buffer given a prompt. command! -nargs=? Neural :call neural#Prompt() +" Stop Neural doing anything. command! -nargs=0 NeuralStop :call neural#Stop() +" Have Neural explain the visually selected lines. +command! -range NeuralExplain :call neural#explain#SelectedLines() " mappings for commands nnoremap (neural_prompt) :call neural#OpenPrompt() nnoremap (neural_stop) :call neural#Stop() +vnoremap (neural_explain) :NeuralExplain " Set default keybinds for Neural unless we're told not to. We should almost " never define keybinds by default in a plugin, but we can add only a few to diff --git a/test/python/test_chatgpt.py b/test/python/test_chatgpt.py index 315de98..fa561b2 100644 --- a/test/python/test_chatgpt.py +++ b/test/python/test_chatgpt.py @@ -3,7 +3,7 @@ import urllib.error import urllib.request from io import BytesIO -from typing import Any, Dict, cast +from typing import Any, Dict, Optional, cast from unittest import mock import pytest @@ -144,7 +144,44 @@ def test_main_function_bad_config(): assert str(exc.value) == 'expect this' -def test_main_function_rate_limit_error(): +@pytest.mark.parametrize( + 'code, error_text, expected_message', + ( + pytest.param( + 429, + None, + 'OpenAI request limit reached!', + id="request_limit", + ), + pytest.param( + 400, + '{]', + 'OpenAI request failure: {]', + id="error_with_mangled_json", + ), + pytest.param( + 400, + json.dumps({'error': {}}), + 'OpenAI request failure: {"error": {}}', + id="error_with_missing_message_key", + ), + pytest.param( + 400, + json.dumps({ + 'error': { + 'message': "This model's maximum context length is 123", + }, + }), + 'OpenAI request failure: Too much text for a request!', + id="too_much_text", + ), + ) +) +def test_api_error( + code: int, + error_text: Optional[str], + expected_message: str, +): with mock.patch.object(sys.stdin, 'readline') as readline_mock, \ mock.patch.object(chatgpt, 'get_chatgpt_completion') as compl_mock: @@ -152,9 +189,10 @@ def test_main_function_rate_limit_error(): url='', msg='', hdrs=mock.Mock(), - fp=None, - code=429, + fp=BytesIO(error_text.encode('utf-8')) if error_text else None, + code=code, ) + readline_mock.return_value = json.dumps({ "config": get_valid_config(), "prompt": "hello there", @@ -163,4 +201,4 @@ def test_main_function_rate_limit_error(): with pytest.raises(SystemExit) as exc: chatgpt.main() - assert str(exc.value) == 'Neural error: OpenAI request limit reached!' + assert str(exc.value) == f'Neural error: {expected_message}' diff --git a/test/python/test_openai.py b/test/python/test_openai.py index eef4e33..6b2035f 100644 --- a/test/python/test_openai.py +++ b/test/python/test_openai.py @@ -3,7 +3,7 @@ import urllib.error import urllib.request from io import BytesIO -from typing import Any, Dict, cast +from typing import Any, Dict, Optional, cast from unittest import mock import pytest @@ -137,17 +137,55 @@ def test_main_function_bad_config(): assert str(exc.value) == 'expect this' -def test_main_function_rate_limit_error(): +@pytest.mark.parametrize( + 'code, error_text, expected_message', + ( + pytest.param( + 429, + None, + 'OpenAI request limit reached!', + id="request_limit", + ), + pytest.param( + 400, + '{]', + 'OpenAI request failure: {]', + id="error_with_mangled_json", + ), + pytest.param( + 400, + json.dumps({'error': {}}), + 'OpenAI request failure: {"error": {}}', + id="error_with_missing_message_key", + ), + pytest.param( + 400, + json.dumps({ + 'error': { + 'message': "This model's maximum context length is 123", + }, + }), + 'OpenAI request failure: Too much text for a request!', + id="too_much_text", + ), + ) +) +def test_api_error( + code: int, + error_text: Optional[str], + expected_message: str, +): with mock.patch.object(sys.stdin, 'readline') as readline_mock, \ - mock.patch.object(openai, 'get_openai_completion') as completion_mock: + mock.patch.object(openai, 'get_openai_completion') as compl_mock: - completion_mock.side_effect = urllib.error.HTTPError( + compl_mock.side_effect = urllib.error.HTTPError( url='', msg='', hdrs=mock.Mock(), - fp=None, - code=429, + fp=BytesIO(error_text.encode('utf-8')) if error_text else None, + code=code, ) + readline_mock.return_value = json.dumps({ "config": get_valid_config(), "prompt": "hello there", @@ -156,4 +194,4 @@ def test_main_function_rate_limit_error(): with pytest.raises(SystemExit) as exc: openai.main() - assert str(exc.value) == 'Neural error: OpenAI request limit reached!' + assert str(exc.value) == f'Neural error: {expected_message}' diff --git a/test/vim/test_explain.vader b/test/vim/test_explain.vader new file mode 100644 index 0000000..dfd6e75 --- /dev/null +++ b/test/vim/test_explain.vader @@ -0,0 +1,113 @@ +Before: + Save g:neural + + " Load modules so we can mock the fuctions. + runtime autoload/neural/job.vim + runtime autoload/neural/visual.vim + runtime autoload/neural/preview.vim + + unlet! g:neural + let g:job_id = 0 + let g:calls = [] + let g:preview_calls = [] + let g:OutCb = {->0} + let g:ErrCb = {->0} + let g:ExitCb = {->0} + + function! neural#job#Start(command, options) abort + let g:job_id += 1 + call add(g:calls, ['neural#job#Start']) + + let g:OutCb = a:options.out_cb + let g:ErrCb = a:options.err_cb + let g:ExitCb = a:options.exit_cb + + return g:job_id + endfunction + + function! neural#job#IsRunning(job_id) abort + return g:job_id == a:job_id + endfunction + + function! neural#job#SendRaw(job_id, string) abort + call add(g:calls, ['neural#job#SendRaw', a:job_id, a:string]) + endfunction + + function! neural#job#Stop(job_id) abort + call add(g:calls, ['neural#job#Stop', a:job_id]) + let g:job_id = 0 + endfunction + + function! neural#visual#GetRange() abort + return g:get_range_result + endfunction + + function! neural#preview#Show(lines, options) abort + call add(g:preview_calls, [a:lines, a:options]) + endfunction + +After: + Restore + + unlet! g:job_id + unlet! g:calls + unlet! g:preview_calls + unlet! g:OutCb + unlet! g:ErrCb + unlet! g:ExitCb + unlet! g:get_range_result + + " Load actual code again. + runtime autoload/neural/job.vim + runtime autoload/neural/visual.vim + runtime autoload/neural/preview.vim + +Given text(Some lines): + One + Two + Three + Four + Five + Six +Execute(): + " Set up a mocked result for the range selection. + let g:get_range_result = { + \ 'lnum': 2, + \ 'col': 2, + \ 'end_lnum': 4, + \ 'end_col': 3, + \ 'selection': ['ne', 'Two', 'Three', 'Fou'], + \} + + " Run the function :NeuralExplain runs. + call neural#explain#SelectedLines() + call timer_stopall() + + " We should send the visually selected lines to the job. + AssertEqual + \ [ + \ ['neural#job#Start'], + \ [ + \ 'neural#job#SendRaw', + \ 1, + \ json_encode({ + \ 'config': g:neural.source[g:neural.selected], + \ 'prompt': "Explain these lines:\n\nne\nTwo\nThree\nFou", + \ }) . "\n", + \ ] + \ ], + \ g:calls + + call g:OutCb(g:job_id, 'first output line') + call g:OutCb(g:job_id, 'second output line') + call g:ExitCb(g:job_id, 0) + + " We should make a call to preview the lines. + AssertEqual + \ [ + \ [ + \ ['first output line', 'second output line'], + \ {'stay_here': 1} + \ ], + \ ], + \ g:preview_calls diff --git a/test/vim/test_neural.vader b/test/vim/test_neural.vader index dafc7d9..d607fdd 100644 --- a/test/vim/test_neural.vader +++ b/test/vim/test_neural.vader @@ -1,6 +1,7 @@ Before: Save g:neural + " Load modules so we can mock the fuctions. runtime autoload/neural/job.vim unlet! g:neural @@ -39,6 +40,8 @@ Before: call neural#ResetState() After: + Restore + redir END unlet! g:job_id @@ -46,15 +49,15 @@ After: unlet! g:OutCb unlet! g:ErrCb unlet! g:ExitCb + unlet! g:captured_output augroup WritePostGroup autocmd! augroup END + " Load actual code again. runtime autoload/neural/job.vim - Restore - Given text (A file with two lines and a blank line in the middle): First line @@ -71,8 +74,8 @@ Execute(Running Neural and collecting output should work): autocmd User NeuralWritePost :call add(g:calls, ['NeuralWritePost']) augroup END - call g:OutCb(g:job_id, "first output line") - call g:OutCb(g:job_id, "second output line") + call g:OutCb(g:job_id, 'first output line') + call g:OutCb(g:job_id, 'second output line') call g:ExitCb(g:job_id, 0) AssertEqual @@ -91,18 +94,16 @@ Execute(Running Neural and collecting output should work): AssertEqual \ [ - \ ['neural#job#Stop', 0], \ ['neural#job#Start'], \ [ \ 'neural#job#SendRaw', \ 1, \ json_encode({ - \ 'config': g:neural.source.openai, + \ 'config': g:neural.source[g:neural.selected], \ 'prompt': 'this is a test', \ }) . "\n", \ ], \ ['NeuralWritePost'], - \ ['neural#job#Stop', 1], \ ], \ g:calls @@ -170,7 +171,6 @@ Execute(It should be possible to stop Neural): AssertEqual \ [ - \ 'neural#job#Stop', \ 'neural#job#Start', \ 'neural#job#SendRaw', \ 'neural#job#Stop', diff --git a/test/vim/test_preview.vader b/test/vim/test_preview.vader new file mode 100644 index 0000000..bb0f01f --- /dev/null +++ b/test/vim/test_preview.vader @@ -0,0 +1,24 @@ +After: + unlet! g:preview_data + +Execute(Preview windows should be opened correctly): + call neural#preview#Show(['a', 'b'], {'stay_here': 1}) + wincmd p + let g:preview_data = { + \ 'lines': getline(1, '$'), + \ 'filetype': &filetype, + \ 'modifiable': &modifiable, + \ 'readonly': &readonly, + \} + :q + + " Assert values only after closing the preview window. + " Otherwise the window will remain open. + AssertEqual + \ { + \ 'lines': ['a', 'b'], + \ 'filetype': 'neural-preview', + \ 'modifiable': 0, + \ 'readonly': 1, + \ }, + \ g:preview_data diff --git a/test/vim/test_redactions.vader b/test/vim/test_redactions.vader new file mode 100644 index 0000000..9af2214 --- /dev/null +++ b/test/vim/test_redactions.vader @@ -0,0 +1,32 @@ +Execute(Object values for password and secret keys should be redacted): + AssertEqual + \ [ + \ '{', + \ ' "password": "*", "SecretKey": r"*",', + \ ' ''password'': ''*'', ''Secret_Key'': r''*'',', + \ ' "password": ''*'', "secret key": r''*'',', + \ ' ''password'': "*", ''secretkey'' : r"*",', + \ '}', + \ ], + \ neural#redact#PasswordsAndSecrets([ + \ '{', + \ ' "password": "hideme", "SecretKey": r"hideme",', + \ ' ''password'': ''hideme'', ''Secret_Key'': r''hideme'',', + \ ' "password": ''hideme'', "secret key": r''hideme'',', + \ ' ''password'': "hideme", ''secretkey'' : r"hideme",', + \ '}', + \ ]) + +Execute(Assignment expressions should be redacted): + AssertEqual + \ ['password = ''*'''], + \ neural#redact#PasswordsAndSecrets(['password = ''hideme''']) + AssertEqual + \ ['password = "*"'], + \ neural#redact#PasswordsAndSecrets(['password = "hideme"']) + AssertEqual + \ ['password = `*`'], + \ neural#redact#PasswordsAndSecrets(['password = `hideme`']) + AssertEqual + \ ['password := `*`'], + \ neural#redact#PasswordsAndSecrets(['password := `hideme`'])