├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── workflow.yaml ├── .gitignore ├── .releaserc.json ├── LICENSE ├── README.md ├── addon-info.json ├── autoload ├── ultest.vim └── ultest │ ├── adapter.vim │ ├── handler.vim │ ├── output.vim │ ├── positions.vim │ ├── process.vim │ ├── signs.vim │ ├── statusline.vim │ ├── summary.vim │ └── util.vim ├── doc └── ultest.txt ├── lua └── ultest.lua ├── plugin └── ultest.vim ├── pyproject.toml ├── requirements.txt ├── rplugin └── python3 │ └── ultest │ ├── __init__.py │ ├── handler │ ├── __init__.py │ ├── parsers │ │ ├── __init__.py │ │ ├── file.py │ │ └── output.py │ ├── runner │ │ ├── __init__.py │ │ ├── attach.py │ │ ├── handle.py │ │ └── processes.py │ └── tracker.py │ ├── logging.py │ ├── models │ ├── __init__.py │ ├── base.py │ ├── file.py │ ├── namespace.py │ ├── result.py │ ├── test.py │ ├── tree.py │ └── types.py │ └── vim_client │ ├── __init__.py │ └── jobs │ ├── __init__.py │ └── watcher.py ├── scripts ├── check-commits ├── style └── test ├── setup.cfg └── tests ├── __init__.py ├── mocks ├── __init__.py ├── test_files │ ├── java │ ├── jest │ └── python └── test_outputs │ ├── exunit │ ├── gotest │ ├── jest │ ├── pytest │ ├── pyunit │ └── richgo ├── test_init.py └── unit ├── __init__.py ├── handler ├── __init__.py ├── parsers │ ├── __init__.py │ ├── test_file.py │ └── test_output.py └── runner │ ├── __init__.py │ ├── test_handle.py │ └── test_processes.py └── models ├── __init__.py └── test_tree.py /.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 | Please include language and test runner being used. 13 | 14 | Logs should be created using `ULTEST_LOG_LEVEL=DEBUG ULTEST_LOG_FILE=vim-ultest.log nvim ` and added here as well 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behavior: 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 | **Additional context** 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yaml: -------------------------------------------------------------------------------- 1 | name: Ultest Workflow 2 | on: [push] 3 | jobs: 4 | tests: 5 | name: tests 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | python-version: [3.7, 3.8, 3.9] 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Python ${{ matrix.python-version }} 13 | uses: actions/setup-python@v2 14 | with: 15 | python-version: ${{ matrix.python-version }} 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install -r requirements.txt 20 | - name: Styling 21 | run: ./scripts/style 22 | - name: Testing 23 | run: ./scripts/test 24 | 25 | release: 26 | name: release 27 | runs-on: ubuntu-18.04 28 | needs: tests 29 | if: ${{ github.ref == 'refs/heads/master' }} 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v2 33 | with: 34 | fetch-depth: 0 35 | - name: Setup Node.js 36 | uses: actions/setup-node@v1 37 | with: 38 | node-version: 16 39 | - name: Release 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | run: npx semantic-release 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache/ 2 | __pycache__/ 3 | .vimrc 4 | .vim/ 5 | tags 6 | *coverage 7 | Pipfile* 8 | .hypothesis 9 | Session.vim 10 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | [ 6 | "@semantic-release/github", 7 | { 8 | "successComment": false 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Rónán Carrigan 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 | # vim-ultest 2 | 3 | ## Deprecation Warning 4 | 5 | This repo is no longer being actively worked on. I've moved my efforts into 6 | [Neotest](https://github.com/nvim-neotest/neotest). If you use NeoVim then I'd suggest switching to that as it will 7 | cover all features of vim-ultest. If you're a Vim user, this repo will still work and I will accept PRs but will no 8 | longer be adding features. 9 | 10 | 1. [Introduction](#introduction) 11 | 2. [Features](#features) 12 | 3. [Installation](#installation) 13 | 4. [Usage](#usage) 14 | 1. [Configuration](#configuration) 15 | 2. [Commands](#commands) 16 | 3. [Plug mappings](#plug-mappings) 17 | 5. [Debugging](#debugging) 18 | 6. [Feedback](#feedback) 19 | 20 | ## Introduction 21 | 22 | _The ultimate testing plugin for NeoVim_ 23 | 24 | Running tests should be as quick and painless as possible. 25 | [vim-test](https://github.com/vim-test/vim-test) is a very powerful and extensive testing plugin, but it can be cumbersome to configure and lacks some features to make it feel like an integrated piece of your editor. 26 | 27 | Rather than replacing vim-test altogether, vim-ultest (in name and practice) builds upon vim-test to make it even better while maintaining the ability to use your existing configuration. 28 | If you're already using vim-test then switching to vim-ultest is as easy as installing and... well, that's pretty much it. 29 | 30 | The goal behind vim-ultest is to make running tests as seamless as possible. 31 | 32 | ## Features 33 | 34 | - Run tests and view results individually 35 | - Test result markers using signs or virtual text 36 | - Failure outputs in a floating window 37 | - Key mappings to jump between failed tests 38 | - Stop long running tests 39 | 40 | ![Running Example](https://user-images.githubusercontent.com/24252670/107279654-39d2a980-6a4f-11eb-95f5-074f69b856e6.gif) 41 | 42 | - Attach to running processes to debug 43 | - Currently experimental so please report issues! 44 | - Uses python's readline library to pass input 45 | 46 | ![debugging](https://user-images.githubusercontent.com/24252670/107827860-8552c380-6d7f-11eb-8f69-04f95e048cfb.gif) 47 | 48 | - Summary window 49 | - Highlight tests based on current status (running, succeeded, failed) 50 | - Show test output 51 | - View all tests currently found in all test files 52 | - Run tests with key binding 53 | 54 | ![summary](https://user-images.githubusercontent.com/24252670/118359583-105c7900-b57c-11eb-848d-9fc0cdf7ca0c.gif) 55 | 56 | - Multithreaded (not just asynchronous) to prevent blocking 57 | 58 | - Use existing vim-test configuration 59 | 60 | - Customisable 61 | 62 | More features are being worked on. 63 | If you have any ideas, feel free to open an issue! 64 | 65 | ## Installation 66 | 67 | **Requirements**: 68 | 69 | All users: 70 | 71 | - Python >= 3.7 72 | - [Pynvim library](https://pynvim.readthedocs.io/en/latest/installation.html) 73 | - [vim-test](https://github.com/vim-test/vim-test) 74 | 75 | Vim only: 76 | 77 | - [nvim-yarp](https://github.com/roxma/nvim-yarp) 78 | - [vim-hug-neovim-rpc](https://github.com/roxma/vim-hug-neovim-rpc) 79 | 80 | **Note:** Vim support is maintained with a best effort. 81 | Due to the differences between Vim and NeoVim and their RPC libraries, it is inevitable that bugs will occur in one and not the other. 82 | I primarily use NeoVim so I will catch issues in it myself. 83 | Please file bug reports for Vim if you find them! 84 | 85 | NeoVim >= 0.5 is currently supported. 86 | 87 | vim-ultest can be installed as usual with your favourite plugin manager. 88 | **Note:** NeoVim users must run `:UpdateRemotePlugins` after install if they don't use a plugin manager that already does. 89 | 90 | [**dein**](https://github.com/Shougo/dein.vim): 91 | 92 | ```vim 93 | " Vim Only 94 | call dein#add("roxma/nvim-yarp") 95 | call dein#add("roxma/vim-hug-neovim-rpc") 96 | 97 | call dein#add("vim-test/vim-test") 98 | call dein#add("rcarriga/vim-ultest") 99 | ``` 100 | 101 | [**vim-plug**](https://github.com/junegunn/vim-plug) 102 | 103 | ```vim 104 | " Vim Only 105 | Plug 'roxma/nvim-yarp' 106 | Plug 'roxma/vim-hug-neovim-rpc' 107 | 108 | Plug 'vim-test/vim-test' 109 | Plug 'rcarriga/vim-ultest', { 'do': ':UpdateRemotePlugins' } 110 | ``` 111 | 112 | [packer.nvim](https://github.com/wbthomason/packer.nvim) 113 | 114 | ```lua 115 | use { "rcarriga/vim-ultest", requires = {"vim-test/vim-test"}, run = ":UpdateRemotePlugins" } 116 | ``` 117 | 118 | ## Usage 119 | 120 | ### Configuration 121 | 122 | `:help ultest-config` 123 | 124 | Any vim-test configuration should carry over to vim-ultest. 125 | See the vim-test documentation on further details for changing test runner and options. 126 | If you have compatibility problems please raise an issue. 127 | 128 | One change you will notice is that test output is not coloured. 129 | This is due to the way the command is run. 130 | To work around this you can simply tell your test runner to always output with colour. 131 | For example 132 | 133 | ```vim 134 | let test#python#pytest#options = "--color=yes" 135 | 136 | let test#javascript#jest#options = "--color=always" 137 | ``` 138 | 139 | Alternatively if you are using a neovim >= 0.5 you can enable PTY 140 | usage which makes the process think it is in an interactive session 141 | ```vim 142 | let g:ultest_use_pty = 1 143 | ``` 144 | 145 | Because Ultest runs processes in an interactive way, test runners may wait for 146 | input or run in "watch" mode. To avoid this you have to pass a flag to your 147 | runner to disable this. 148 | 149 | For example with react-scripts 150 | ```vim 151 | let test#javascript#reactscripts#options = "--watchAll=false" 152 | ``` 153 | 154 | **Note**: The window to show results relies on the 'updatetime' setting which by default is 4 seconds. 155 | A longer 'updatetime' will mean the window takes longer to show automatically but a shorter time means (Neo)Vim will write to disk much more often which can degrade SSDs over time and cause slowdowns on HDDs. 156 | 157 | ### Commands 158 | 159 | `:help ultest-commands` 160 | 161 | For example to run the nearest test every time a file is written: 162 | 163 | ```vim 164 | augroup UltestRunner 165 | au! 166 | au BufWritePost * UltestNearest 167 | augroup END 168 | ``` 169 | 170 | **Need user contributions** 171 | 172 | The `Ultest` command runs all tests in a file. For some test runners the plugin 173 | can parse the output of the runner to get results so that they can all be run 174 | as a single process. For other runners the tests all have to be run as 175 | inidividual processes, which can have a significant performance impact. Please 176 | check the wiki to see if your runner is supported. If it is not please open an 177 | issue with example output and I can add support for it! 178 | 179 | ### Plug mappings 180 | 181 | `:help ultest-mappings` 182 | 183 | For example to be able to jump between failures in a test file: 184 | 185 | ```vim 186 | nmap ]t (ultest-next-fail) 187 | nmap [t (ultest-prev-fail) 188 | ``` 189 | 190 | For configuration options and more documentation see `:h ultest` 191 | 192 | ## Debugging 193 | 194 | `:help ultest-debugging` 195 | 196 | Debugging with nvim-dap is supported but some user configuration is required. 197 | See the [debugging recipes](https://github.com/rcarriga/vim-ultest/wiki/Debugging-Recipes) for some working configurations. 198 | If you do not see one for your runner/language, please submit a change to the wiki so others can use it too! 199 | 200 | ## Feedback 201 | 202 | Feel free to open an issue for bug reports, feature requests or suggestions. 203 | I will try address them as soon as I can! 204 | -------------------------------------------------------------------------------- /addon-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ultest" 3 | } 4 | -------------------------------------------------------------------------------- /autoload/ultest.vim: -------------------------------------------------------------------------------- 1 | "" 2 | " @public 3 | " @usage [file] 4 | " Get the status of a file. If the [file] argument is not given, the current 5 | " buffer will be used. Full paths are expected for [file]. 6 | " 7 | " The return value is a dict with the following keys: 8 | " 9 | " 'tests': Number of tests found 10 | " 11 | " 'passed': Number of tests passed 12 | " 13 | " 'failed': Number of tests failed 14 | " 15 | " 'running': Number of tests running 16 | function! ultest#status(...) abort 17 | try 18 | let file = a:0 == 1 ? a:1 : expand("%:.") 19 | let ids = getbufvar(file, "ultest_sorted_tests", []) 20 | let tests = getbufvar(file, "ultest_tests", {}) 21 | let results = getbufvar(file, "ultest_results", {}) 22 | let status = {"tests": len(ids), "passed": 0, "failed": 0, "running": 0} 23 | for test_id in ids 24 | if get(get(tests, test_id, {}), "type", "") != "test" | continue | endif 25 | let result = get(results, test_id, {}) 26 | if result != {} 27 | let key = result.code ? "failed" : "passed" 28 | let status[key] += 1 29 | elseif get(get(tests, test_id, {}), "running") 30 | let status.running += 1 31 | endif 32 | endfor 33 | return status 34 | catch /.*/ 35 | return {"tests": 0, "passed": 0, "failed": 0, "running": 0} 36 | endtry 37 | 38 | endfunction 39 | 40 | "" 41 | " @public 42 | " @usage [file] 43 | " Check if a file has tests detected within it. 44 | " N.B. This can return false if a file has not been processed yet. 45 | " You can use the 'User UltestPositionsUpdate' autocommand to detect when a 46 | " file has been processed. 47 | " 48 | "If the [file] argument is not given, the current 49 | " buffer will be used. Full paths are expected for [file]. 50 | function! ultest#is_test_file(...) abort 51 | let file = a:0 == 1 ? a:1 : expand("%:.") 52 | return !empty(getbufvar(file, "ultest_tests", {})) 53 | endfunction 54 | 55 | function! ultest#run_file(...) abort 56 | let args = a:0 == 1 ? a:1 : {} 57 | let file = get(args, "file", expand("%:.")) 58 | let pre_run = get(args, "pre_run") 59 | if type(pre_run) != v:t_number 60 | call call(pre_run, [file]) 61 | endif 62 | let runner = get(args, "runner", "ultest") 63 | if runner == "ultest" 64 | call ultest#handler#run_nearest(0, file) 65 | elseif runner == "nvim-dap" 66 | lua require("ultest").dap_run_nearest({file = file, line = 0}) 67 | endif 68 | endfunction 69 | 70 | function! ultest#run_nearest(...) abort 71 | let args = a:0 == 1 ? a:1 : {} 72 | let file = get(args, "file", expand("%:.")) 73 | let line = getbufinfo(file)[0]["lnum"] 74 | let pre_run = get(args, "pre_run") 75 | if type(pre_run) != v:t_number 76 | call call(pre_run, [file]) 77 | endif 78 | let runner = get(args, "runner", "ultest") 79 | if runner == "ultest" 80 | call ultest#handler#run_nearest(line, file) 81 | elseif runner == "nvim-dap" 82 | lua require("ultest").dap_run_nearest({file = file, line = line}) 83 | endif 84 | endfunction 85 | 86 | function! ultest#stop_file(...) abort 87 | let file = a:0 == 1 ? a:1 : expand("%:.") 88 | let ids = getbufvar(file, "ultest_sorted_tests", []) 89 | let tests = getbufvar(file, "ultest_tests", {}) 90 | for test_id in ids 91 | let test = get(tests, test_id) 92 | call ultest#handler#stop_test(test) 93 | endfor 94 | call ultest#handler#update_positions(file) 95 | endfunction 96 | 97 | function! ultest#stop_nearest(...) abort 98 | let file = a:0 == 1 ? a:1 : expand("%:.") 99 | let test = ultest#handler#get_nearest_test(line("."), file, v:false) 100 | call ultest#handler#stop_test(test) 101 | call ultest#handler#update_positions(file) 102 | endfunction 103 | -------------------------------------------------------------------------------- /autoload/ultest/adapter.vim: -------------------------------------------------------------------------------- 1 | 2 | function! ultest#adapter#get_runner(file) 3 | if exists('g:test#project_root') 4 | let cwd = getcwd() 5 | execute 'cd' g:test#project_root 6 | endif 7 | let runner = test#determine_runner(a:file) 8 | if exists('g:test#project_root') 9 | exec 'cd'.cwd 10 | endif 11 | return runner 12 | endfunction 13 | 14 | function! ultest#adapter#build_cmd(test, scope) abort 15 | if exists('g:test#project_root') 16 | let cwd = getcwd() 17 | execute 'cd' g:test#project_root 18 | endif 19 | let a:test.file = fnamemodify(a:test.file, get(g:, "test#filename_modifier", ":.")) 20 | call ultest#process#pre(a:test) 21 | let runner = test#determine_runner(a:test.file) 22 | let executable = test#base#executable(runner) 23 | 24 | let base_args = test#base#build_position(runner, a:scope, a:test) 25 | let args = test#base#options(runner, base_args) 26 | let args = test#base#build_args(runner, args, "ultest") 27 | 28 | let cmd = split(executable) + args 29 | 30 | call filter(cmd, '!empty(v:val)') 31 | if has_key(g:, 'test#transformation') 32 | let cmd = g:test#custom_transformations[g:test#transformation](cmd) 33 | endif 34 | for index in range(len(cmd)) 35 | if cmd[index] == a:test.file 36 | let cmd[index] = shellescape(a:test.file) 37 | end 38 | endfor 39 | let cmd = ultest#handler#safe_split(cmd) 40 | if exists('g:test#project_root') 41 | exec 'cd'.cwd 42 | endif 43 | return cmd 44 | endfunction 45 | 46 | function ultest#adapter#run_test(test) abort 47 | let cmd = ultest#adapter#build_cmd(a:test) 48 | call ultest#handler#strategy(cmd, a:test) 49 | endfunction 50 | 51 | function ultest#adapter#get_patterns(file_name) abort 52 | let runner = test#determine_runner(a:file_name) 53 | if type(runner) == v:t_number | return {} | endif 54 | let file_type = split(runner, "#")[0] 55 | let ultest_pattern = get(g:ultest_patterns, runner, get(g:ultest_patterns, file_type)) 56 | if type(ultest_pattern) == v:t_dict 57 | return ultest_pattern 58 | endif 59 | try 60 | try 61 | return eval("g:test#".runner."#patterns") 62 | catch /.*/ 63 | return eval("g:test#".file_type."#patterns") 64 | endtry 65 | catch /.*/ 66 | endtry 67 | endfunction 68 | -------------------------------------------------------------------------------- /autoload/ultest/handler.vim: -------------------------------------------------------------------------------- 1 | 2 | if has("nvim") 3 | let s:is_nvim = v:true 4 | let s:update_warn_sent = 0 5 | else 6 | let s:is_nvim = v:false 7 | let s:yarp = yarp#py3('ultest') 8 | endif 9 | 10 | function! s:Call(func, args) abort 11 | if s:is_nvim 12 | try 13 | return call(a:func, a:args) 14 | catch /.*E117.*/ 15 | " Send twice because first one isn't shown if triggered during startup 16 | if s:update_warn_sent < 2 17 | echom "Error: vim-ultest remote function not detected, try running :UpdateRemotePlugins on install/update" 18 | let s:update_warn_sent += 1 19 | endif 20 | catch 21 | " Send twice because first one isn't shown if triggered during startup 22 | if s:update_warn_sent < 2 23 | echom "Error: vim-ultest encountered an unknown error on startup ".v:exception 24 | let s:update_warn_sent += 1 25 | endif 26 | endtry 27 | else 28 | let args = copy(a:args) 29 | call insert(args, a:func) 30 | return call(s:yarp.call, args, s:yarp) 31 | endif 32 | endfunction 33 | 34 | function! ultest#handler#run_all(...) abort 35 | call s:Call('_ultest_run_all', a:000) 36 | endfunction 37 | 38 | function! ultest#handler#run_nearest(...) abort 39 | call s:Call('_ultest_run_nearest', a:000) 40 | endfunction 41 | 42 | function! ultest#handler#run_single(...) abort 43 | call s:Call('_ultest_run_single', a:000) 44 | endfunction 45 | 46 | function! ultest#handler#run_last(...) abort 47 | call s:Call('_ultest_run_last', a:000) 48 | endfunction 49 | 50 | function! ultest#handler#update_positions(...) abort 51 | call s:Call('_ultest_update_positions', a:000) 52 | endfunction 53 | 54 | function! ultest#handler#get_sorted_ids(...) abort 55 | return s:Call('_ultest_get_sorted_test_ids', a:000) 56 | endfunction 57 | 58 | function! ultest#handler#get_nearest_test(...) abort 59 | return s:Call('_ultest_get_nearest_test', a:000) 60 | endfunction 61 | 62 | function! ultest#handler#get_attach_script(...) abort 63 | return s:Call('_ultest_get_attach_script', a:000) 64 | endfunction 65 | 66 | function! ultest#handler#stop_test(...) abort 67 | return s:Call('_ultest_stop_test', a:000) 68 | endfunction 69 | 70 | function! ultest#handler#external_start(...) abort 71 | return s:Call('_ultest_external_start', a:000) 72 | endfunction 73 | 74 | function! ultest#handler#external_result(...) abort 75 | return s:Call('_ultest_external_result', a:000) 76 | endfunction 77 | 78 | function! ultest#handler#safe_split(...) abort 79 | return s:Call('_ultest_safe_split', a:000) 80 | endfunction 81 | 82 | function! ultest#handler#clear_results(...) abort 83 | return s:Call('_ultest_clear_results', a:000) 84 | endfunction 85 | -------------------------------------------------------------------------------- /autoload/ultest/output.vim: -------------------------------------------------------------------------------- 1 | augroup UltestOutputClose 2 | autocmd! 3 | autocmd User UltestOutputOpen call ultest#output#close(v:false) 4 | augroup END 5 | 6 | augroup UltestOutputMappings 7 | autocmd! 8 | autocmd FileType UltestOutput tnoremap q 9 | autocmd FileType UltestOutput nnoremap q 10 | augroup END 11 | 12 | function! ultest#output#open(test) abort 13 | if type(a:test) != v:t_dict || empty(a:test) | return | endif 14 | doautocmd User UltestOutputOpen 15 | let result = get(getbufvar(a:test.file, "ultest_results", {}), a:test.id, {}) 16 | let output = get(result, "output", "") 17 | if output == "" | return | endif 18 | let [width, height] = s:CalculateBounds(output) 19 | if has("nvim") 20 | let cmd = output 21 | call s:NvimOpenFloat(cmd, width, height, "UltestOutput") 22 | autocmd InsertEnter,CursorMoved * ++once call ultest#output#close(v:false) 23 | else 24 | let cmd = ['less', "-R", "-Ps", output] 25 | call s:VimOpenFloat(cmd, width, height) 26 | endif 27 | endfunction 28 | 29 | function! ultest#output#attach(test) abort 30 | if type(a:test) != v:t_dict || empty(a:test) | return | endif 31 | let process_ids = [a:test.id, a:test.file] + a:test.namespaces 32 | for process_id in process_ids 33 | let attach_res = ultest#handler#get_attach_script(process_id) 34 | if type(attach_res) == v:t_list 35 | break 36 | endif 37 | endfor 38 | if type(attach_res) != v:t_list | return | endif 39 | let [stdout_path, py_script] = attach_res 40 | doautocmd User UltestOutputOpen 41 | let cmd = ['python3', py_script] 42 | let [_width, height] = s:CalculateBounds(stdout_path) 43 | let width = g:ultest_attach_width 44 | let width = width ? width : _width 45 | if has("nvim") 46 | call s:NvimOpenFloat(cmd, width, height, "UltestAttach") 47 | call nvim_set_current_win(g:ultest#output_windows[0]) 48 | au TermClose * ++once call ultest#output#close(v:true) 49 | au InsertEnter,CursorMoved * ++once call ultest#output#close(v:false) 50 | else 51 | call s:VimOpenFloat(cmd, width, height) 52 | endif 53 | endfunction 54 | 55 | function! ultest#output#close(force) abort 56 | if !s:OutputIsOpen() || !has("nvim") 57 | return 58 | endif 59 | if !a:force && nvim_get_current_win() == g:ultest#output_windows[0] 60 | autocmd InsertEnter,CursorMoved * ++once call ultest#output#close(v:false) 61 | return 62 | endif 63 | for window in g:ultest#output_windows 64 | try 65 | exec "bd! ".nvim_win_get_buf(window) 66 | catch /.*/ 67 | endtry 68 | endfor 69 | let g:ultest#output_windows = [] 70 | endfunction 71 | 72 | function! s:OutputIsOpen() 73 | return !empty(get(g:, "ultest#output_windows", [])) 74 | endfunction 75 | 76 | function ultest#output#jumpto() abort 77 | if !s:OutputIsOpen() 78 | call ultest#output#open(ultest#handler#get_nearest_test(line("."), expand("%:."), v:false)) 79 | if !s:OutputIsOpen() 80 | return 81 | endif 82 | endif 83 | if has("nvim") 84 | call nvim_set_current_win(g:ultest#output_windows[0]) 85 | endif 86 | endfunction 87 | 88 | function! s:CalculateBounds(path) abort 89 | let width = str2nr(split(system("sed 's/\x1b\[[0-9;]*m//g' ".shellescape(a:path)." | wc -L"))[0]) 90 | let height = str2nr(split(system("wc -l ".shellescape(a:path)))[0]) 91 | 92 | let height = min([max([height, g:ultest_output_min_height]), g:ultest_output_max_height ? g:ultest_output_max_height : &lines]) 93 | let width = min([max([width, g:ultest_output_min_width]), g:ultest_output_max_width ? g:ultest_output_max_width : &columns]) 94 | return [width, height] 95 | endfunction 96 | 97 | function! s:VimOpenFloat(cmd, width, height) abort 98 | " TODO: Background shows as solid when highlight has bg=NONE 99 | " See: https://github.com/vim/vim/issues/2361 100 | let popup_options = { 101 | \ "highlight": "Normal", 102 | \ "border": [1,1,1,1], 103 | \ "maxheight": a:height, 104 | \ "maxwidth": a:width, 105 | \ "minheight": a:height, 106 | \ "minwidth": a:width, 107 | \ "borderhighlight": ["UltestBorder"], 108 | \ "borderchars": ['─', '│', '─', '│', '╭', '╮', '╯', '╰'], 109 | \ "mapping": 1 110 | \} 111 | let buf = term_start(a:cmd, {"hidden": 1, "term_kill": "term", "term_finish": 'close', "term_highlight": "Normal"}) 112 | let g:ultest#output_windows = [popup_atcursor(buf, popup_options)] 113 | endfunction 114 | 115 | function! s:NvimOpenFloat(cmd, width, height, filetype) abort 116 | 117 | let lineNo = screenrow() 118 | let colNo = screencol() 119 | let vert_anchor = "N" 120 | let hor_anchor = "W" 121 | 122 | let row = min([1, &lines - (lineNo + a:height)]) 123 | let col = min([1, &columns - (colNo + a:width)]) 124 | 125 | let content_opts = { 126 | \ 'relative': 'cursor', 127 | \ 'row': row, 128 | \ 'col': col, 129 | \ 'anchor': vert_anchor.hor_anchor, 130 | \ 'width': a:width, 131 | \ 'height': a:height, 132 | \ 'style': 'minimal', 133 | \ 'border': 'rounded' 134 | \ } 135 | 136 | let out_buffer = nvim_create_buf(v:false, v:true) 137 | call nvim_buf_set_option(out_buffer, "filetype", a:filetype) 138 | let user_window = nvim_get_current_win() 139 | let output_window = nvim_open_win(out_buffer, v:true, content_opts) 140 | if type(a:cmd) == v:t_list 141 | call termopen(join(a:cmd, " ")) 142 | else 143 | exec 'lua vim.api.nvim_chan_send(vim.api.nvim_open_term(0, {}),(io.open("'.a:cmd.'", "r"):read("*a"):gsub("\n", "\r\n")))' 144 | endif 145 | call nvim_set_current_win(user_window) 146 | call nvim_win_set_option(output_window, "winhl", "Normal:Normal,FloatBorder:UltestBorder") 147 | 148 | let g:ultest#output_windows = [output_window] 149 | endfunction 150 | -------------------------------------------------------------------------------- /autoload/ultest/positions.vim: -------------------------------------------------------------------------------- 1 | function! ultest#positions#next() abort 2 | if b:ultest_sorted_tests == [] | return | endif 3 | let current = ultest#handler#get_nearest_test(line("."), expand("%:."), v:false) 4 | let start = type(current) == v:t_dict ? index(b:ultest_sorted_tests, current.id) + 1 : 0 5 | for ind in range(start, len(b:ultest_sorted_tests) - 1) 6 | let test_id = b:ultest_sorted_tests[ind] 7 | if has_key(b:ultest_results, test_id) && b:ultest_results[test_id].code 8 | return s:GoToTest(b:ultest_tests[test_id]) 9 | endif 10 | endfor 11 | endfunction 12 | 13 | function! ultest#positions#prev() abort 14 | if b:ultest_sorted_tests == [] | return | endif 15 | let current = ultest#handler#get_nearest_test(line("."), expand("%:."), v:false) 16 | if type(current) != v:t_dict | return | endif 17 | let reversed = reverse(copy(b:ultest_sorted_tests)) 18 | let start = index(reversed, current.id) 19 | if current.line == line(".") 20 | let start += 1 21 | endif 22 | for ind in range(start, len(b:ultest_sorted_tests) - 1) 23 | let test_id = reversed[ind] 24 | if has_key(b:ultest_results, test_id) && b:ultest_results[test_id].code 25 | return s:GoToTest(b:ultest_tests[test_id]) 26 | endif 27 | endfor 28 | endfunction 29 | 30 | function! s:GoToTest(test) abort 31 | if a:test.type != "file" 32 | exec "normal! ".string(a:test.line)."G" 33 | endif 34 | endfunction 35 | -------------------------------------------------------------------------------- /autoload/ultest/process.vim: -------------------------------------------------------------------------------- 1 | let g:ultest#active_processors = [] 2 | let g:ultest_buffers = [] 3 | 4 | for processor in g:ultest#processors 5 | if get(processor, "condition", 1) 6 | call insert(g:ultest#active_processors, processor) 7 | endif 8 | endfor 9 | 10 | augroup UltestBufferTracking 11 | au! 12 | au BufDelete * call ClearBuffer(expand("")) 13 | augroup END 14 | 15 | function s:ClearBuffer(buf_name) abort 16 | let path = fnamemodify(a:buf_name, ":p") 17 | let buf_index = index(g:ultest_buffers, path) 18 | if buf_index != -1 19 | call remove(g:ultest_buffers, buf_index) 20 | endif 21 | call ultest#summary#render() 22 | endfunction 23 | 24 | function ultest#process#new(test) abort 25 | call ultest#process#pre(a:test) 26 | if index(g:ultest_buffers, a:test.file) == -1 27 | let g:ultest_buffers = add(g:ultest_buffers, a:test.file) 28 | endif 29 | let tests = getbufvar(a:test.file, "ultest_tests", {}) 30 | let tests[a:test.id] = a:test 31 | for processor in g:ultest#active_processors 32 | let new = get(processor, "new", "") 33 | if new != "" 34 | call function(new)(a:test) 35 | endif 36 | endfor 37 | endfunction 38 | 39 | function ultest#process#start(test) abort 40 | call ultest#process#pre(a:test) 41 | let tests = getbufvar(a:test.file, "ultest_tests", {}) 42 | let tests[a:test.id] = a:test 43 | let results = getbufvar(a:test.file, "ultest_results") 44 | if has_key(results, a:test.id) 45 | call remove(results, a:test.id) 46 | endif 47 | for processor in g:ultest#active_processors 48 | let start = get(processor, "start", "") 49 | if start != "" 50 | call function(start)(a:test) 51 | endif 52 | endfor 53 | endfunction 54 | 55 | function ultest#process#move(test) abort 56 | call ultest#process#pre(a:test) 57 | let tests = getbufvar(a:test.file, "ultest_tests") 58 | let tests[a:test.id] = a:test 59 | for processor in g:ultest#active_processors 60 | let start = get(processor, "move", "") 61 | if start != "" 62 | call function(start)(a:test) 63 | endif 64 | endfor 65 | endfunction 66 | 67 | function ultest#process#replace(test, result) abort 68 | call ultest#process#pre(a:test) 69 | let tests = getbufvar(a:test.file, "ultest_tests") 70 | let tests[a:test.id] = a:test 71 | let results = getbufvar(a:result.file, "ultest_results") 72 | let results[a:result.id] = a:result 73 | for processor in g:ultest#active_processors 74 | let exit = get(processor, "replace", "") 75 | if exit != "" 76 | call function(exit)(a:result) 77 | endif 78 | endfor 79 | endfunction 80 | 81 | function ultest#process#clear(test) abort 82 | call ultest#process#pre(a:test) 83 | let tests = getbufvar(a:test.file, "ultest_tests") 84 | if has_key(tests, a:test.id) 85 | call remove(tests, a:test.id) 86 | endif 87 | let results = getbufvar(a:test.file, "ultest_results") 88 | if has_key(results, a:test.id) 89 | call remove(results, a:test.id) 90 | endif 91 | for processor in g:ultest#active_processors 92 | let clear = get(processor, "clear", "") 93 | if clear != "" 94 | call function(clear)(a:test) 95 | endif 96 | endfor 97 | endfunction 98 | 99 | function ultest#process#exit(test, result) abort 100 | call ultest#process#pre(a:test) 101 | if !has_key(getbufvar(a:result.file, "ultest_tests", {}), a:result.id) 102 | return 103 | endif 104 | let tests = getbufvar(a:test.file, "ultest_tests", {}) 105 | let tests[a:test.id] = a:test 106 | let results = getbufvar(a:result.file, "ultest_results") 107 | let results[a:result.id] = a:result 108 | for processor in g:ultest#active_processors 109 | let exit = get(processor, "exit", "") 110 | if exit != "" 111 | call function(exit)(a:result) 112 | endif 113 | endfor 114 | endfunction 115 | 116 | function ultest#process#pre(test) abort 117 | if type(a:test.name) == v:t_list 118 | if exists("*list2str") 119 | let newName = list2str(a:test.name) 120 | else 121 | let newName = join(map(a:test.name, {nr, val -> nr2char(val)}), '') 122 | endif 123 | let a:test.name = newName 124 | endif 125 | endfunction 126 | -------------------------------------------------------------------------------- /autoload/ultest/signs.vim: -------------------------------------------------------------------------------- 1 | function! ultest#signs#move(test) abort 2 | if (a:test.type != "test") | return | endif 3 | let result = get(getbufvar(a:test.file, "ultest_results"), a:test.id, {}) 4 | if result != {} 5 | call ultest#signs#process(result) 6 | else 7 | call ultest#signs#start(a:test) 8 | endif 9 | endfunction 10 | 11 | function! ultest#signs#start(test) abort 12 | if (a:test.type != "test") | return | endif 13 | call ultest#signs#unplace(a:test) 14 | if !a:test.running | return | endif 15 | if s:UseVirtual() 16 | call s:PlaceVirtualText(a:test, g:ultest_running_text, "UltestRunning") 17 | else 18 | call s:PlaceSign(a:test, "test_running") 19 | endif 20 | endfunction 21 | 22 | function! ultest#signs#process(result) abort 23 | let test = getbufvar(a:result.file, "ultest_tests")[a:result.id] 24 | if (test.type != "test") | return | endif 25 | call ultest#signs#unplace(test) 26 | if s:UseVirtual() 27 | let text_highlight = a:result.code ? "UltestFail" : "UltestPass" 28 | let text = a:result.code ? g:ultest_fail_text : g:ultest_pass_text 29 | call s:PlaceVirtualText(test, text, text_highlight) 30 | else 31 | let test_icon = a:result.code ? "test_fail" : "test_pass" 32 | call s:PlaceSign(test, test_icon) 33 | endif 34 | endfunction 35 | 36 | function! s:UseVirtual() abort 37 | return get(g:, "ultest_virtual_text", 1) && exists("*nvim_buf_set_virtual_text") 38 | endfunction 39 | 40 | function! s:PlaceSign(test, test_icon) abort 41 | call sign_place(0, a:test.id, a:test_icon, a:test.file, {"lnum": a:test.line, "priority": 1000}) 42 | redraw 43 | endfunction 44 | 45 | function! s:PlaceVirtualText(test, text, highlight) abort 46 | let namespace = s:GetNamespace(a:test) 47 | let buffer = nvim_win_get_buf(win_getid(bufwinnr(a:test.file))) 48 | call nvim_buf_set_virtual_text(buffer, namespace, str2nr(a:test.line) - 1, [[a:text, a:highlight]], {}) 49 | endfunction 50 | 51 | function! ultest#signs#unplace(test) 52 | if (a:test.type != "test") | return | endif 53 | if s:UseVirtual() 54 | let namespace = s:GetNamespace(a:test) 55 | call nvim_buf_clear_namespace(0, namespace, 0, -1) 56 | else 57 | call sign_unplace(a:test.id, {"buffer": a:test.file}) 58 | redraw 59 | endif 60 | endfunction 61 | 62 | function! s:GetNamespace(test) 63 | let virtual_namespace = "ultest".substitute(a:test.id, " ", "_", "g") 64 | return nvim_create_namespace(virtual_namespace) 65 | endfunction 66 | -------------------------------------------------------------------------------- /autoload/ultest/statusline.vim: -------------------------------------------------------------------------------- 1 | function! ultest#statusline#process(test) abort 2 | call setbufvar(a:test["file"], "ultest_total", get(b:, "ultest_total", 0) + 1) 3 | if a:test["code"] 4 | call setbufvar(a:test["file"], "ultest_failed", get(b:, "ultest_failed", 0) + 1) 5 | else 6 | call setbufvar(a:test["file"], "ultest_passed", get(b:, "ultest_passed", 0) + 1) 7 | endif 8 | endfunction 9 | 10 | function! ultest#statusline#remove(test) abort 11 | call setbufvar(a:test["file"], "ultest_total", get(b:, "ultest_total", 1) - 1) 12 | if a:test["code"] 13 | call setbufvar(a:test["file"], "ultest_failed", get(b:, "ultest_failed", 1) - 1) 14 | else 15 | call setbufvar(a:test["file"], "ultest_passed", get(b:, "ultest_passed", 1) - 1) 16 | endif 17 | endfunction 18 | -------------------------------------------------------------------------------- /autoload/ultest/summary.vim: -------------------------------------------------------------------------------- 1 | let s:buffer_name = "Ultest Summary" 2 | let s:test_line_map = {} 3 | let s:mappings = { 4 | \ "run": "r", 5 | \ "jumpto": "", 6 | \ "output": "o", 7 | \ "attach": "a", 8 | \ "stop": "s", 9 | \ "next_fail": "", 10 | \ "prev_fail": "", 11 | \ } 12 | 13 | call extend(s:mappings, g:ultest_summary_mappings) 14 | 15 | augroup UltestSummaryAutocmds 16 | au! 17 | autocmd FileType UltestSummary call CreateMappings() 18 | autocmd DirChanged * call ultest#summary#render() 19 | augroup END 20 | 21 | 22 | function! s:CreateMappings() 23 | exec "nnoremap ".s:mappings["run"]." :call RunCurrent()" 24 | exec "nnoremap ".s:mappings["output"]." :call OpenCurrentOutput()" 25 | exec "nnoremap ".s:mappings["jumpto"]." :call JumpToCurrent()" 26 | exec "nnoremap ".s:mappings["attach"]." :call AttachToCurrent()" 27 | exec "nnoremap ".s:mappings["stop"]." :call StopCurrent()" 28 | exec "nnoremap ".s:mappings["next_fail"]." :call JumpToFail(1)" 29 | exec "nnoremap ".s:mappings["prev_fail"]." :call JumpToFail(-1)" 30 | endfunction 31 | 32 | function! s:IsOpen() abort 33 | return bufexists(s:buffer_name) && bufwinnr(s:buffer_name) != -1 34 | endfunction 35 | 36 | function! ultest#summary#jumpto() abort 37 | call ultest#summary#open() 38 | call ultest#util#goToBuffer(s:buffer_name) 39 | endfunction 40 | 41 | function! ultest#summary#open(jump = v:false) abort 42 | if !s:IsOpen() 43 | call s:OpenNewWindow() 44 | if a:jump 45 | call ultest#summary#jumpto() 46 | end 47 | endif 48 | endfunction 49 | 50 | function! ultest#summary#close() abort 51 | if s:IsOpen() 52 | exec bufwinnr(s:buffer_name)."close" 53 | endif 54 | endfunction 55 | 56 | function! ultest#summary#toggle(jump) abort 57 | if s:IsOpen() 58 | call ultest#summary#close() 59 | else 60 | if a:jump 61 | call ultest#summary#jumpto() 62 | else 63 | call ultest#summary#open() 64 | end 65 | endif 66 | endfunction 67 | 68 | function! ultest#summary#render(...) abort 69 | if s:IsOpen() 70 | call s:RenderSummary() 71 | endif 72 | endfunction 73 | 74 | function! s:OpenNewWindow() abort 75 | exec g:ultest_summary_open 76 | let buf = bufnr(s:buffer_name) 77 | if buf != -1 78 | exec buf."bwipeout!" 79 | endif 80 | let buf = bufnr(s:buffer_name, 1) 81 | exec "edit #".buf 82 | let buf_settings = { 83 | \ "buftype": "nofile", 84 | \ "bufhidden": "hide", 85 | \ "buflisted": 0, 86 | \ "swapfile": 0, 87 | \ "modifiable": 0, 88 | \ "relativenumber": 0, 89 | \ "number": 0, 90 | \ "filetype": "UltestSummary", 91 | \ "foldmethod": "expr", 92 | \ "foldlevel": 99, 93 | \ } 94 | for [key, val] in items(buf_settings) 95 | call setbufvar(s:buffer_name, "&".key, val) 96 | endfor 97 | let win_settings = { 98 | \ "foldtext": 'substitute(getline(v:foldstart),"\s*{{{[0-9]\s*$","","")." ▶"', 99 | \ "foldexpr": "GetFoldLevel()", 100 | \ "winfixwidth": 1 101 | \ } 102 | let win = bufwinnr(s:buffer_name) 103 | for [key, val] in items(win_settings) 104 | call setwinvar(win, "&".key, val) 105 | endfor 106 | augroup UltestSummary 107 | au! 108 | " au CursorMoved norm! 0 109 | augroup END 110 | call s:RenderSummary() 111 | exec "norm! \p" 112 | endfunction 113 | 114 | function! Render() 115 | call s:RenderSummary() 116 | endfunction 117 | 118 | function! s:RenderSummary() abort 119 | call setbufvar(s:buffer_name, "&modifiable", 1) 120 | call s:Clear() 121 | let lines = [] 122 | let matches = [] 123 | let win = bufwinnr(s:buffer_name) 124 | let s:test_line_map = {} 125 | for test_file in g:ultest_buffers 126 | let structure = getbufvar(test_file, "ultest_file_structure") 127 | let tests = getbufvar(test_file, "ultest_tests", {}) 128 | let results = getbufvar(test_file, "ultest_results", {}) 129 | let state = {"lines": lines, "matches": matches, "tests": tests, "results": results } 130 | call s:RenderGroup("", structure, 0, state) 131 | if test_file != g:ultest_buffers[-1] 132 | call add(lines, "") 133 | endif 134 | endfor 135 | if has("nvim") 136 | call nvim_buf_set_lines(bufnr(s:buffer_name), 0, len(lines), v:false, lines) 137 | else 138 | call setbufline(s:buffer_name, 1, lines) 139 | endif 140 | for mch in matches 141 | call matchaddpos(mch[0], [mch[1]], 10, -1, {"window": win}) 142 | endfor 143 | silent call deletebufline(s:buffer_name, len(lines)+1, "$") 144 | call setbufvar(s:buffer_name, "&modifiable", 0) 145 | endfunction 146 | 147 | function! s:RenderGroup(root_prefix, group, indent, group_state) abort 148 | let state = a:group_state 149 | let root = a:group[0] 150 | call s:RenderGroupMember(a:root_prefix, root, state) 151 | if len(a:group) < 2 152 | " Empty file 153 | return 154 | endif 155 | for index in range(1, len(a:group) - 2) 156 | let member = a:group[index] 157 | if type(member) == v:t_dict 158 | call s:RenderGroupMember(repeat(" ", a:indent).." ", member, state) 159 | else 160 | call s:RenderGroup(repeat(" ", a:indent).." ", member, a:indent+2, state) 161 | endif 162 | endfor 163 | let member = a:group[-1] 164 | if type(member) == v:t_dict 165 | call s:RenderGroupMember(repeat(" ", a:indent).." ", member, state) 166 | else 167 | call s:RenderGroup(repeat(" ", a:indent).." ", member, a:indent+2, state) 168 | endif 169 | endfunction 170 | 171 | function! s:RenderGroupMember(prefix, member, group_state) abort 172 | let state = a:group_state 173 | let test = get(state.tests, a:member.id, {}) 174 | if test != {} 175 | let result = get(state.results, a:member.id, {}) 176 | call s:RenderPosition(a:prefix, test, result, state) 177 | endif 178 | endfunction 179 | 180 | function! s:RenderPosition(prefix, test, result, group_state) abort 181 | if has_key(a:result, "code") 182 | let highlight = a:result.code ? "UltestFail" : "UltestPass" 183 | let icon = a:result.code ? g:ultest_fail_sign : g:ultest_pass_sign 184 | else 185 | let icon = a:test.running ? g:ultest_running_sign : g:ultest_not_run_sign 186 | let highlight = a:test.running ? "UltestRunning" : "UltestDefault" 187 | endif 188 | let line = a:prefix..icon.." "..(a:test.type == "file" ? fnamemodify(a:test.name, ":.") : a:test.name) 189 | call add(a:group_state.lines, line) 190 | call add(a:group_state.matches, [highlight, [len(a:group_state.lines), len(a:prefix) + 1, 1]]) 191 | if a:test.type == "file" 192 | call add(a:group_state.matches, ["UltestSummaryFile", [len(a:group_state.lines), len(line) - len(a:test.name) + 1, len(a:test.name)]]) 193 | elseif a:test.type == "namespace" 194 | call add(a:group_state.matches, ["UltestSummaryNamespace", [len(a:group_state.lines), len(line) - len(a:test.name) + 1, len(a:test.name)]]) 195 | endif 196 | let s:test_line_map[len(a:group_state.lines)] = [a:test.file, a:test.id] 197 | endfunction 198 | 199 | function! s:Clear() abort 200 | if bufexists(s:buffer_name) 201 | if has("nvim-0.5.0") || has("patch-8.1.1084") 202 | call clearmatches(bufwinnr(s:buffer_name)) 203 | else 204 | call clearmatches() 205 | endif 206 | endif 207 | endfunction 208 | 209 | function! s:GetFoldLevel() abort 210 | let [cur_file, cur_test] = s:GetAtLine(v:lnum) 211 | if cur_file == "" 212 | return 0 213 | elseif cur_test == "" 214 | return 1 215 | endif 216 | let position = get(getbufvar(cur_file, "ultest_tests", {}), cur_test) 217 | if position.type == "test" | return len(position.namespaces) + 1 | endif 218 | return ">"..string(len(position.namespaces) + 2) 219 | endfunction 220 | 221 | function! s:RunCurrent() abort 222 | let [cur_file, cur_test] = s:GetAtLine(s:GetCurrentLine()) 223 | if cur_file == "" 224 | return 225 | elseif cur_test == "" 226 | call ultest#handler#run_single(cur_file, cur_file) 227 | else 228 | call ultest#handler#run_single(cur_test, cur_file) 229 | endif 230 | endfunction 231 | 232 | function! s:JumpToCurrent() abort 233 | let [cur_file, cur_test] = s:GetAtLine(s:GetCurrentLine()) 234 | if cur_file == "" 235 | return 236 | endif 237 | let win = bufwinnr(cur_file) 238 | if win == -1 239 | echom "Window not open for ".cur_file 240 | return 241 | else 242 | exec win."wincmd w" 243 | endif 244 | if cur_test != "" 245 | let tests = getbufvar(cur_file, "ultest_tests", {}) 246 | let test = get(tests, cur_test, {}) 247 | if test != {} 248 | exec "norm! ".test["line"]."G" 249 | endif 250 | endif 251 | endfunction 252 | 253 | function! s:AttachToCurrent() abort 254 | let [cur_file, cur_test] = s:GetAtLine(s:GetCurrentLine()) 255 | if cur_file == "" 256 | return 257 | elseif cur_test == "" 258 | let test = {"id": cur_file, "file": cur_file, "namespaces": []} 259 | else 260 | let test = get(getbufvar(cur_file, "ultest_tests", {}), cur_test) 261 | endif 262 | call ultest#output#attach(test) 263 | endfunction 264 | 265 | function! s:StopCurrent() abort 266 | let [cur_file, cur_test] = s:GetAtLine(s:GetCurrentLine()) 267 | if cur_file == "" || cur_test == "" 268 | return 269 | else 270 | let test = get(getbufvar(cur_file, "ultest_tests", {}), cur_test) 271 | call ultest#handler#stop_test(test) 272 | endif 273 | endfunction 274 | 275 | function! s:OpenCurrentOutput() abort 276 | let [cur_file, cur_test] = s:GetAtLine(s:GetCurrentLine()) 277 | if cur_file == "" || cur_test == "" 278 | return 279 | endif 280 | let test = get(getbufvar(cur_file, "ultest_tests", {}), cur_test) 281 | call ultest#output#open(test) 282 | call ultest#output#jumpto() 283 | endfunction 284 | 285 | function! s:JumpToFail(direction) abort 286 | let index = s:GetCurrentLine() + a:direction 287 | let fail = {} 288 | 289 | if has("nvim") 290 | let line_count = nvim_buf_line_count(0) 291 | else 292 | let line_count = line("$") 293 | endif 294 | 295 | while index > 0 && index <= line_count 296 | let [cur_file, cur_test] = s:GetAtLine(index) 297 | if cur_test != "" 298 | let result = get(getbufvar(cur_file, "ultest_results", {}), cur_test, {}) 299 | if get(result, "code") > 0 300 | call setpos(".", [0, index, 1, 0]) 301 | return 302 | end 303 | end 304 | let index += a:direction 305 | endwhile 306 | endfunction 307 | 308 | function! s:GetCurrentLine() abort 309 | if !s:IsOpen() | return 0 | endif 310 | return getbufinfo(s:buffer_name)[0]["lnum"] 311 | endfunction 312 | 313 | function! s:GetAtLine(line) abort 314 | return get(s:test_line_map, a:line, ["", ""]) 315 | endfunction 316 | -------------------------------------------------------------------------------- /autoload/ultest/util.vim: -------------------------------------------------------------------------------- 1 | function ultest#util#goToBuffer(expr) abort 2 | let window = bufwinnr(bufnr(a:expr)) 3 | if window == -1 | return 0 | endif 4 | 5 | if window != winnr() 6 | exe window . "wincmd w" 7 | endif 8 | 9 | return 1 10 | endfunction 11 | -------------------------------------------------------------------------------- /doc/ultest.txt: -------------------------------------------------------------------------------- 1 | *ultest.txt* 2 | *vim-ultest* *ultest* 3 | 4 | ============================================================================== 5 | CONTENTS *ultest-contents* 6 | 1. Introduction........................................|ultest-introduction| 7 | 2. Configuration.............................................|ultest-config| 8 | 3. Commands................................................|ultest-commands| 9 | 4. Functions..............................................|ultest-functions| 10 | 5. Highlights............................................|ultest-highlights| 11 | 6. Mappings................................................|ultest-mappings| 12 | 7. Debugging..............................................|ultest-debugging| 13 | 14 | ============================================================================== 15 | INTRODUCTION *ultest-introduction* 16 | 17 | 18 | The ultimate testing plugin for Vim/NeoVim 19 | 20 | Running tests should be as quick and painless as possible. 21 | [vim-test](https://github.com/janko/vim-test) is a very powerful and extensive 22 | testing plugin, but it can be cumbersome to configure and lacks some features 23 | to make it feel like an integrated piece of your editor. Rather than replacing 24 | vim-test altogether, vim-ultest makes it even better while maintaining the 25 | ability to use your existing configuration. If you're already using vim-test 26 | then switching to vim-ultest is as easy as installing and... well, that's 27 | pretty much it. 28 | 29 | The goal behind vim-ultest is to make running tests as seamless as possible. 30 | 31 | * Tests are displayed individually so that any errors can be addressed 32 | individually. 33 | * Tests are run in seperate threads (not just asynchronously on the same 34 | thread) so your Vim session will never be blocked. 35 | * When tests are complete, results can be viewed immediately or on command. 36 | * Utilise the existing power of vim-test by extending upon it. 37 | 38 | ============================================================================== 39 | CONFIGURATION *ultest-config* 40 | 41 | *g:ultest_max_threads* 42 | Number of workers that are used for running tests. (default: 2) 43 | 44 | *g:ultest_use_pty* 45 | Connect jobs to a pty. This will trick the process into thinking it is running 46 | an interactive session which generally enables colour escape codes. Currently 47 | experimental! 48 | 49 | This should only be enabled if you are using a nightly version of neovim with 50 | the `nvim_open_term` function because it is needed to parse terminal escape 51 | codes properly in order to show output. (default: 0) 52 | 53 | *g:ultest_disable_grouping* 54 | Ultest allows running a group of tests (files/namespaces) as a single process. 55 | This allows for better performance as there are fewer processes spawned. Only 56 | for supported runners, check the repo wiki for details. If for some reason 57 | you wish to disable grouped running for a runner, add the runner to this list. 58 | For example for jest: 59 | > 60 | let g:ultest_disable_grouping = ["javascript#jest"] 61 | < 62 | (default: []) 63 | 64 | *g:ultest_env* 65 | 66 | Custom environment variables for test processes in a dictionary. (default: 67 | v:null) 68 | 69 | *g:ultest_output_on_run* 70 | Show failed outputs when completed run. (default: 1) 71 | 72 | *g:ultest_output_on_line* 73 | Show failed outputs when cursor is on first line of test/namespace. 74 | 75 | This relies on the 'updatetime' setting which by default is 4 seconds. A 76 | longer 'updatetime' will mean the window takes longer to show automatically 77 | but a shorter time means (Neo)Vim will write to disk much more often which can 78 | degrade SSDs over time and cause slowdowns on HDDs. 79 | 80 | Due to how Vim handles terminal popups, this is disabled by default as it can 81 | be annoying. (default: has("nvim")) 82 | 83 | *g:ultest_icons* 84 | Use unicode icons (default: 1) 85 | 86 | *g:ultest_output_rows* 87 | Number of rows for terminal size where tests are run (default: 0) Set to zero 88 | to not instruct runner on size of terminal. Note: It is up to the test runner 89 | to respect these bounds 90 | 91 | *g:ultest_output_cols* 92 | Number of columns for terminal size where tests are run (default: 0) Set to 93 | zero to not instruct runner on size of terminal. Note: It is up to the test 94 | runner to respect these bounds 95 | 96 | *g:ultest_output_max_width* 97 | Max width of the output window (default: 0) 98 | 99 | *g:ultest_output_max_height* 100 | Max height of the output window (default: 0) 101 | 102 | *g:ultest_output_min_width* 103 | Min width of the output window (default: 0) 104 | 105 | *g:ultest_output_min_height* 106 | Min height of the output window (default: 0) 107 | 108 | *g:ultest_show_in_file* 109 | Enable sign/virtual text processor for tests. (default: 1) 110 | 111 | *g:ultest_virtual_text* 112 | Use virtual text (if available) instead of signs to show test results in file. 113 | (default: 0) 114 | 115 | *g:ultest_pass_sign* 116 | Sign for passing tests. (default: g:ultest_icons ? "✔" : "O") 117 | 118 | *g:ultest_fail_sign* 119 | Sign for failing tests. (default: g:ultest_icons ? "✖" : "X") 120 | 121 | *g:ultest_running_sign* 122 | Sign for running tests (string) (default: g:ultest_icons ? "🗘" : ">") 123 | 124 | *g:ultest_not_run_sign* 125 | Sign for tests not yet run (string) (default: g:ultest_icons ? "?" : "~") 126 | 127 | *g:ultest_pass_text* 128 | Virtual text for passing tests (string) (default: g:ultest_icons? 129 | "●":"Passing") 130 | 131 | *g:ultest_fail_text* 132 | Virtual text for failing tests (string) (default: g:ultest_icons? 133 | "●":"Failing") 134 | 135 | *g:ultest_running_text* 136 | Virtual text for passing tests (string) (default: g:ultest_icons? 137 | "●":"Running") 138 | 139 | *g:ultest_summary_width* 140 | Width of the summary window (default: 50) 141 | 142 | *g:ultest_summary_open* 143 | Command to open the summary window. 144 | 145 | Opening in different positions: 146 | 147 | Right: "botright vsplit | vertical resize " . g:ultest_summary_width 148 | 149 | Left: "topleft vsplit | vertical resize " . g:ultest_summary_width 150 | 151 | Top: "topleft split | resize " . g:ultest_summary_width 152 | 153 | Bottom: "botright split | resize " . g:ultest_summary_width 154 | 155 | (default: "botright vsplit | vertical resize ".g:ultest_summary_width) 156 | 157 | *g:ultest_pre_run* 158 | A function name to call before running any tests in a file. Receives the 159 | relative file path as an argument. 160 | 161 | *g:ultest_attach_width* 162 | Width of the attach window Some test runners don't output anyting until 163 | finished (e.g. Jest) so the attach window can't figure out a good width. Use 164 | this to hardcode a size. (default: 0) 165 | 166 | *g:ultest_custom_processors* 167 | Custom list of receivers for position events. This is experimental and could 168 | change! Receivers are dictionaries with any of the following keys: 169 | 170 | 'new': A function which takes a new position which has been discovered. 171 | 172 | 'move': A function which takes a position which has been moved. 173 | 174 | 'replace': A function which takes a position which has previously been cleared 175 | but has been replaced. 176 | 177 | 'start': A function which takes a position which has been run. 178 | 179 | 'exit': A function which takes a position result once it has completed. 180 | 181 | 'clear': A function which takes a position which has been removed for some 182 | reason. 183 | 184 | Positions can be either a file, namespace or test, distinguished with a 'type' 185 | key. 186 | 187 | 188 | *g:ultest_custom_patterns* 189 | Custom patterns for identifying positions. This dictionary should use the keys 190 | in the form '' or '#'. The values should be a 191 | dictionary with the following keys: 192 | 193 | 'test': A list a python-style regex patterns that can each indentify tests in 194 | a line of code 195 | 196 | 'namepsace': A list of python-style regex patterns that can idenitfy test 197 | namespaces (e.g. Classes). 198 | 199 | If you find a missing language that requires you to set this value, 200 | considering onpening an issue/PR to make it available to others. 201 | 202 | *g:ultest_summary_mappings* 203 | Key mappings for the summary window (dict) Possible values: 204 | 205 | 'run': (default "r") Runs the test currently selected or whole file if file 206 | name is selected. 207 | 208 | 'jumpto': (default "") Jump to currently selected test in its file. 209 | 210 | 'output': (default "o") Open the output to the current test if failed. 211 | 212 | 'attach': (default "a") Attach to the running process of the current test. 213 | 214 | 'stop': (default "s") Stop the running process of the current test. 215 | 216 | 'next_fail': (default "") Jump down to the next fail. 217 | 218 | 'prev_fail': (default "") Jump up to the next fail. 219 | 220 | The summary window also defines folds for each files and namespaces so they 221 | can be hidden as desired using the regular fold mappings. 222 | 223 | ============================================================================== 224 | COMMANDS *ultest-commands* 225 | 226 | :Ultest *:Ultest* 227 | Run all tests in the current file Some runners can be run as a single 228 | command if the output can be parsed to gain results. Otherwise, each test is 229 | run as a single command. 230 | 231 | Running as a single command can significanly improve performance, so if your 232 | runner is not yet supported please open an issue with sample output! Check 233 | the repo wiki for an updated list of runners. 234 | 235 | :UltestNearest *:UltestNearest* 236 | Run nearest position in the current file If no position is found it will 237 | attempt to run the entire file 238 | 239 | :UltestLast *:UltestLast* 240 | Run test(s) that were last ran 241 | 242 | :UltestDebug *:UltestDebug* 243 | Debug the current file with nvim-dap 244 | 245 | :UltestDebugNearest *:UltestDebugNearest* 246 | Debug the nearest position with nvim-dap 247 | 248 | :UltestOutput *:UltestOutput* 249 | Show the output of the nearest position in the current file 250 | 251 | :UltestAttach *:UltestAttach* 252 | Attach to the running process of a position to be able to send input and 253 | read output as it runs. This is useful for debugging 254 | 255 | :UltestStop *:UltestStop* 256 | Stop all running jobs for the current file 257 | 258 | :UltestStopNearest *:UltestStopNearest* 259 | Stop any running jobs and results for the nearest position 260 | 261 | :UltestSummary[!] *:UltestSummary* 262 | Toggle the summary window between open and closed. If [!] is given, jump to 263 | the window if opened 264 | 265 | :UltestSummaryOpen[!] *:UltestSummaryOpen* 266 | Open the summary window. If [!] is given, jump to the window 267 | 268 | :UltestSummaryClose *:UltestSummaryClose* 269 | Close the summary window 270 | 271 | :UltestClear *:UltestClear* 272 | Clear results from the current file 273 | 274 | ============================================================================== 275 | FUNCTIONS *ultest-functions* 276 | 277 | ultest#status([file]) *ultest#status()* 278 | Get the status of a file. If the [file] argument is not given, the current 279 | buffer will be used. Full paths are expected for [file]. 280 | 281 | The return value is a dict with the following keys: 282 | 283 | 'tests': Number of tests found 284 | 285 | 'passed': Number of tests passed 286 | 287 | 'failed': Number of tests failed 288 | 289 | 'running': Number of tests running 290 | 291 | ultest#is_test_file([file]) *ultest#is_test_file()* 292 | Check if a file has tests detected within it. N.B. This can return false if 293 | a file has not been processed yet. You can use the 'User 294 | UltestPositionsUpdate' autocommand to detect when a file has been processed. 295 | 296 | If the [file] argument is not given, the current buffer will be used. Full 297 | paths are expected for [file]. 298 | 299 | ============================================================================== 300 | HIGHLIGHTS *ultest-highlights* 301 | 302 | 303 | Define the following highlight groups to override their values by copying 304 | these commands and changing their colours/attributes. 305 | 306 | hi UltestPass ctermfg=Green guifg=#96F291 307 | 308 | hi UltestFail ctermfg=Red guifg=#F70067 309 | 310 | hi UltestRunning ctermfg=Yellow guifg=#FFEC63 311 | 312 | hi UltestBorder ctermfg=Red guifg=#F70067 313 | 314 | hi UltestSummaryInfo ctermfg=cyan guifg=#00F1F5 gui=bold cterm=bold 315 | 316 | hi link UltestSummaryFile UltestSummaryInfo 317 | 318 | hi link UltestSummaryNamespace UltestSummaryInfo 319 | 320 | ============================================================================== 321 | MAPPINGS *ultest-mappings* 322 | 323 | 324 | (ultest-next-fail) Jump to next failed test. 325 | 326 | (ultest-prev-fail) Jump to previous failed test. 327 | 328 | (ultest-run-file) Run all tests in a file. 329 | 330 | (ultest-run-nearest) Run test closest to the cursor. 331 | 332 | (ultest-run-last) Run test(s) that were last run. 333 | 334 | (ultest-summary-toggle) Toggle the summary window between open and 335 | closed 336 | 337 | (ultest-summary-jump) Jump to the summary window (opening if it 338 | isn't already) 339 | 340 | (ultest-output-show) Show error output of the nearest test. (Will 341 | jump to popup window in Vim) 342 | 343 | (ultest-output-jump) Show error output of the nearest test. (Same 344 | behabviour as (ultest-output-show) in Vim) 345 | 346 | (ultest-attach) Attach to the nearest test's running process. 347 | 348 | (ultest-stop-file) Stop all running jobs for current file 349 | 350 | (ultest-stop-nearest) Stop any running jobs for nearest test 351 | 352 | (ultest-debug) Debug the current file with nvim-dap 353 | 354 | (ultest-debug-nearest) Debug the nearest test with nvim-dap 355 | 356 | ============================================================================== 357 | DEBUGGING *ultest-debugging* 358 | 359 | 360 | vim-ultest supports debugging through nvim-dap. Due to how debugging 361 | configurations can vary greatly between users and projects, some configuration 362 | is required for test debugging to work. 363 | 364 | You must provide a way to build a suitable nvim-dap confiuration to run a 365 | test, given the original command for the test. The command is given in the 366 | form of a list of strings. The returned table should contain a 'dap' entry 367 | which will be provided to nvim-dap's 'run' function. 368 | 369 | For example with a python test using the adapter defined in nvim-dap-python: 370 | > 371 | function(cmd) 372 | -- The command can start with python command directly or an env manager 373 | local non_modules = {'python', 'pipenv', 'poetry'} 374 | -- Index of the python module to run the test. 375 | local module 376 | if vim.tbl_contains(non_modules, cmd[1]) then 377 | module = cmd[3] 378 | else 379 | module = cmd[1] 380 | end 381 | -- Remaining elements are arguments to the module 382 | local args = vim.list_slice(cmd, module_index + 1) 383 | return { 384 | dap = { 385 | type = 'python', 386 | request = 'launch', 387 | module = module, 388 | args = args 389 | } 390 | } 391 | end, 392 | < 393 | This will separate out the module name and arguments to provide to the 394 | nvim-dap-python adapter. 395 | 396 | To provide this to vim-ultest, call the setup function, providing a table with 397 | a 'builders' entry, with your language mapped to the builder. If you require 398 | multiple runners, the key can be in the form of '#' 399 | (Example: 'python#pytest') 400 | > 401 | require("ultest").setup({ 402 | builders = { 403 | ['python#pytest'] = function (cmd) 404 | ... 405 | end 406 | } 407 | }) 408 | < 409 | 410 | Some adapters don't provide an exit code when running tests, such as 411 | vscode-go. This causes vim-ultest to never receive a result. To work around 412 | this, you can provide a 'parse_result' entry in the returned table of your 413 | config builder function which will receive the output of the program as a list 414 | of lines. You can then return an exit code for the test. For example with 415 | vscode-go and gotest, the final line will state 'FAIL' on a failed test. 416 | > 417 | ["go#gotest"] = function(cmd) 418 | local args = {} 419 | for i = 3, #cmd - 1, 1 do 420 | local arg = cmd[i] 421 | if vim.startswith(arg, "-") then 422 | -- Delve requires test flags be prefix with 'test.' 423 | arg = "-test." .. string.sub(arg, 2) 424 | end 425 | args[#args + 1] = arg 426 | end 427 | return { 428 | dap = { 429 | type = "go", 430 | request = "launch", 431 | mode = "test", 432 | program = "${workspaceFolder}", 433 | dlvToolPath = vim.fn.exepath("dlv"), 434 | args = args 435 | }, 436 | parse_result = function(lines) 437 | return lines[#lines] == "FAIL" and 1 or 0 438 | end 439 | } 440 | end 441 | < 442 | 443 | 444 | vim:tw=78:ts=8:ft=help:norl: 445 | -------------------------------------------------------------------------------- /lua/ultest.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local builders = {} 4 | 5 | local function dap_run_test(test, build_config) 6 | local dap = require("dap") 7 | local cmd = vim.fn["ultest#adapter#build_cmd"](test, "nearest") 8 | 9 | local output_name = vim.fn["tempname"]() 10 | 11 | local handler_id = "ultest_" .. test.id 12 | 13 | local user_config = build_config(cmd) 14 | 15 | local exit_handler = function(_, info) 16 | io.close() 17 | vim.fn["ultest#handler#external_result"](test.id, test.file, info.exitCode) 18 | end 19 | local terminated_handler = function() 20 | if user_config.parse_result then 21 | local lines = {} 22 | for line in io.lines(output_name) do 23 | lines[#lines + 1] = line 24 | end 25 | local exit_code = user_config.parse_result(lines) 26 | vim.fn["ultest#handler#external_result"](test.id, test.file, exit_code) 27 | end 28 | end 29 | 30 | local output_handler = function(_, body) 31 | if vim.tbl_contains({"stdout", "stderr"}, body.category) then 32 | io.write(body.output) 33 | io.flush() 34 | end 35 | end 36 | 37 | require("dap").run( 38 | user_config.dap, 39 | { 40 | before = function(config) 41 | local output_file = io.open(output_name, "w") 42 | io.output(output_file) 43 | vim.fn["ultest#handler#external_start"](test.id, test.file, output_name) 44 | dap.listeners.after.event_output[handler_id] = output_handler 45 | dap.listeners.before.event_terminated[handler_id] = terminated_handler 46 | dap.listeners.after.event_exited[handler_id] = exit_handler 47 | return config 48 | end, 49 | after = function() 50 | dap.listeners.after.event_exited[handler_id] = nil 51 | dap.listeners.before.event_terminated[handler_id] = nil 52 | dap.listeners.after.event_output[handler_id] = nil 53 | end 54 | } 55 | ) 56 | end 57 | 58 | local function get_builder(test, config) 59 | local builder = 60 | config.build_config or builders[vim.fn["ultest#adapter#get_runner"](test.file)] or 61 | builders[vim.fn["getbufvar"](test.file, "&filetype")] 62 | 63 | if builder == nil then 64 | print("Unsupported runner, need to provide a customer nvim-dap config builder") 65 | return nil 66 | end 67 | 68 | if config.override_config ~= nil then 69 | builder = function(cmd) 70 | return config.override_config(builder(cmd)) 71 | end 72 | end 73 | 74 | return builder 75 | end 76 | 77 | -- Run the nearest test to a position using nvim-dap 78 | -- 79 | -- @param config {table | nil} 80 | -- Optional keys: 81 | -- 'file': File to search in, defaults to current file. 82 | -- 'line': Line to search from, defaults to current line. 83 | -- 'build_config': Function to receive the test command in a list of 84 | -- strings, to return a nvim-dap configuration. Required 85 | -- if there is no default builder for the test runner 86 | function M.dap_run_nearest(config) 87 | config = config or {} 88 | local file = config.file 89 | local line = config.line 90 | if file == nil then 91 | file = vim.fn["expand"]("%:.") 92 | end 93 | 94 | if line == nil then 95 | line = vim.fn["getbufinfo"](file)[1]["lnum"] 96 | end 97 | 98 | local test = vim.fn["ultest#handler#get_nearest_test"](line, file, false) 99 | if test == vim.NIL then 100 | return 101 | end 102 | local builder = get_builder(test, config) 103 | if builder == nil then 104 | return 105 | end 106 | 107 | dap_run_test(test, builder) 108 | end 109 | 110 | function M.setup(config) 111 | builders = config.builders 112 | end 113 | 114 | return M 115 | -------------------------------------------------------------------------------- /plugin/ultest.vim: -------------------------------------------------------------------------------- 1 | if get(g:, "ultest_loaded") 2 | finish 3 | endif 4 | let g:ultest_loaded = 1 5 | 6 | let s:strategy = "ultest" 7 | let g:test#custom_strategies = get(g:, "test#custom_strategies", {}) 8 | let g:test#custom_strategies[s:strategy] = function('ultest#handler#strategy') 9 | let g:ultest_buffers = [] 10 | 11 | "" 12 | " @section Introduction 13 | " @order introduction config commands functions highlights mappings debugging 14 | " @stylized vim-ultest 15 | " 16 | " The ultimate testing plugin for Vim/NeoVim 17 | " 18 | " Running tests should be as quick and painless as possible. 19 | " [vim-test](https://github.com/janko/vim-test) is a very powerful and extensive testing plugin, but it can be cumbersome to configure and lacks some features to make it feel like an integrated piece of your editor. 20 | " Rather than replacing vim-test altogether, vim-ultest makes it even better while maintaining the ability to use your existing configuration. 21 | " If you're already using vim-test then switching to vim-ultest is as easy as installing and... well, that's pretty much it. 22 | " 23 | " The goal behind vim-ultest is to make running tests as seamless as possible. 24 | " 25 | " * Tests are displayed individually so that any errors can be addressed individually. 26 | " * Tests are run in seperate threads (not just asynchronously on the same thread) so your Vim session will never be blocked. 27 | " * When tests are complete, results can be viewed immediately or on command. 28 | " * Utilise the existing power of vim-test by extending upon it. 29 | 30 | " 31 | 32 | "" 33 | " @section Highlights 34 | " 35 | " Define the following highlight groups to override their values by copying 36 | " these commands and changing their colours/attributes. 37 | " 38 | " hi UltestPass ctermfg=Green guifg=#96F291 39 | " 40 | " hi UltestFail ctermfg=Red guifg=#F70067 41 | " 42 | " hi UltestRunning ctermfg=Yellow guifg=#FFEC63 43 | " 44 | " hi UltestBorder ctermfg=Red guifg=#F70067 45 | " 46 | " hi UltestSummaryInfo ctermfg=cyan guifg=#00F1F5 gui=bold cterm=bold 47 | " 48 | " hi link UltestSummaryFile UltestSummaryInfo 49 | " 50 | " hi link UltestSummaryNamespace UltestSummaryInfo 51 | 52 | hi default UltestPass ctermfg=Green guifg=#96F291 53 | hi default UltestFail ctermfg=Red guifg=#F70067 54 | hi default UltestRunning ctermfg=Yellow guifg=#FFEC63 55 | hi default UltestDefault ctermfg=Grey guifg=#8B8B8B 56 | hi default UltestBorder ctermfg=Red guifg=#F70067 57 | hi default UltestSummaryInfo ctermfg=cyan guifg=#00F1F5 58 | hi default link UltestSummaryFile UltestSummaryInfo 59 | hi default link UltestSummaryNamespace UltestSummaryInfo 60 | 61 | "" 62 | " Number of workers that are used for running tests. 63 | " (default: 2) 64 | let g:ultest_max_threads = get(g:, "ultest_max_threads", 2) 65 | 66 | "" 67 | " Connect jobs to a pty. This will trick the process into thinking it is 68 | " running an interactive session which generally enables colour escape codes. 69 | " Currently experimental! 70 | " 71 | " This should only be enabled if you are using a nightly version of neovim 72 | " with the `nvim_open_term` function because it is needed to parse terminal 73 | " escape codes properly in order to show output. 74 | " (default: 0) 75 | let g:ultest_use_pty = get(g:, "ultest_use_pty", 0) 76 | 77 | 78 | "" 79 | " Ultest allows running a group of tests (files/namespaces) as a single 80 | " process. This allows for better performance as there are fewer processes 81 | " spawned. Only for supported runners, check the repo wiki for details. If 82 | " for some reason you wish to disable grouped running for a runner, add the 83 | " runner to this list. For example for jest: 84 | " > 85 | " let g:ultest_disable_grouping = ["javascript#jest"] 86 | " < 87 | " (default: []) 88 | let g:ultest_disable_grouping = get(g:, "ultest_disable_grouping", []) 89 | 90 | "" 91 | " 92 | " Custom environment variables for test processes in a dictionary. 93 | " (default: v:null) 94 | let g:ultest_env = get(g:, "ultest_env", v:null) 95 | 96 | "" 97 | " Show failed outputs when completed run. 98 | " (default: 1) 99 | let g:ultest_output_on_run = get(g:, "ultest_output_on_run", 1) 100 | 101 | "" 102 | " Show failed outputs when cursor is on first line of test/namespace. 103 | " 104 | " This relies on the 'updatetime' setting which by default is 4 seconds. 105 | " A longer 'updatetime' will mean the window takes longer to show 106 | " automatically but a shorter time means (Neo)Vim will write to disk 107 | " much more often which can degrade SSDs over time and cause slowdowns on HDDs. 108 | " 109 | " Due to how Vim handles terminal popups, this is disabled by default as it 110 | " can be annoying. 111 | " (default: has("nvim")) 112 | let g:ultest_output_on_line = get(g:, "ultest_output_on_line", has("nvim")) 113 | 114 | "" 115 | " Use unicode icons 116 | " (default: 1) 117 | let g:ultest_icons = get(g:, "ultest_icons", 1) 118 | 119 | "" 120 | " Number of rows for terminal size where tests are run (default: 0) 121 | " Set to zero to not instruct runner on size of terminal. 122 | " Note: It is up to the test runner to respect these bounds 123 | let g:ultest_output_rows = get(g:, "ultest_output_rows", 0) 124 | 125 | "" 126 | " Number of columns for terminal size where tests are run (default: 0) 127 | " Set to zero to not instruct runner on size of terminal. 128 | " Note: It is up to the test runner to respect these bounds 129 | let g:ultest_output_cols = get(g:, "ultest_output_cols", 0) 130 | 131 | 132 | "" 133 | " Max width of the output window 134 | " (default: 0) 135 | let g:ultest_output_max_width = get(g:, "ultest_output_max_width", 0) 136 | 137 | "" 138 | " Max height of the output window 139 | " (default: 0) 140 | let g:ultest_output_max_height = get(g:, "ultest_output_max_height", 0) 141 | 142 | "" 143 | " Min width of the output window 144 | " (default: 0) 145 | let g:ultest_output_min_width = get(g:, "ultest_output_min_width", 80) 146 | 147 | "" 148 | " Min height of the output window 149 | " (default: 0) 150 | let g:ultest_output_min_height = get(g:, "ultest_output_min_height", 20) 151 | 152 | "" Enable sign/virtual text processor for tests. 153 | " (default: 1) 154 | let g:ultest_show_in_file = get(g:, "ultest_show_in_file", 1) 155 | 156 | "" 157 | " Use virtual text (if available) instead of signs to show test results in file. 158 | " (default: 0) 159 | let g:ultest_virtual_text = get(g:, "ultest_virtual_text", 0) 160 | 161 | "" 162 | " Sign for passing tests. 163 | " (default: g:ultest_icons ? "✔" : "O") 164 | let g:ultest_pass_sign = get(g:, "ultest_pass_sign", g:ultest_icons ? "✔" : "O") 165 | "" 166 | " Sign for failing tests. 167 | " (default: g:ultest_icons ? "✖" : "X") 168 | let g:ultest_fail_sign = get(g:, "ultest_fail_sign", g:ultest_icons ? "✖" : "X") 169 | 170 | "" 171 | " Sign for running tests (string) 172 | " (default: g:ultest_icons ? "🗘" : ">") 173 | let g:ultest_running_sign = get(g:, "ultest_running_sign", g:ultest_icons ? "🗘" : ">") 174 | 175 | "" 176 | " Sign for tests not yet run (string) 177 | " (default: g:ultest_icons ? "?" : "~") 178 | let g:ultest_not_run_sign = get(g:, "ultest_not_run_sign", g:ultest_icons ? "?" : "~") 179 | 180 | "" 181 | " Virtual text for passing tests (string) 182 | " (default: g:ultest_icons? "●":"Passing") 183 | let g:ultest_pass_text = get(g:, "ultest_pass_text", g:ultest_icons? "●":"Passing") 184 | "" 185 | " Virtual text for failing tests (string) 186 | " (default: g:ultest_icons? "●":"Failing") 187 | let g:ultest_fail_text = get(g:, "ultest_fail_text", g:ultest_icons? "●":"Failing") 188 | "" 189 | " Virtual text for passing tests (string) 190 | " (default: g:ultest_icons? "●":"Running") 191 | let g:ultest_running_text = get(g:, "ultest_running_text", g:ultest_icons? "●":"Running") 192 | 193 | "" 194 | " Width of the summary window 195 | " (default: 50) 196 | let g:ultest_summary_width = get(g:, "ultest_summary_width", 50) 197 | 198 | let g:ultest_deprecation_notice = get(g:, "ultest_deprecation_notice", 1) 199 | 200 | if (g:ultest_deprecation_notice && has("nvim")) 201 | lua << EOF 202 | vim.notify([[vim-ultest is no longer maintained. 203 | You can switch to using neotest (https://github.com/nvim-neotest/neotest) instead. 204 | 205 | To disable this message: 206 | ```vim 207 | let g:ultest_deprecation_notice = 0 208 | ```]], vim.log.levels.WARN, { title = "vim-ultest", on_open = function(win) 209 | vim.api.nvim_buf_set_option(vim.api.nvim_win_get_buf(win), "filetype", "markdown") 210 | end}) 211 | EOF 212 | endif 213 | 214 | "" 215 | " Command to open the summary window. 216 | " 217 | " Opening in different positions: 218 | " 219 | " Right: "botright vsplit | vertical resize " . g:ultest_summary_width 220 | " 221 | " Left: "topleft vsplit | vertical resize " . g:ultest_summary_width 222 | " 223 | " Top: "topleft split | resize " . g:ultest_summary_width 224 | " 225 | " Bottom: "botright split | resize " . g:ultest_summary_width 226 | " 227 | " (default: "botright vsplit | vertical resize ".g:ultest_summary_width) 228 | let g:ultest_summary_open = get(g:, "ultest_summary_open", "botright vsplit | vertical resize ".g:ultest_summary_width) 229 | 230 | "" 231 | " A function name to call before running any tests in a file. 232 | " Receives the relative file path as an argument. 233 | let g:ultest_pre_run = get(g:, "ultest_pre_run") 234 | 235 | "" 236 | " Width of the attach window 237 | " Some test runners don't output anyting until finished (e.g. Jest) so the 238 | " attach window can't figure out a good width. Use this to hardcode a size. 239 | " (default: 0) 240 | let g:ultest_attach_width = get(g:, "ultest_attach_width", 0) 241 | 242 | "" 243 | " Custom list of receivers for position events. 244 | " This is experimental and could change! 245 | " Receivers are dictionaries with any of the following keys: 246 | " 247 | " 'new': A function which takes a new position which has been discovered. 248 | " 249 | " 'move': A function which takes a position which has been moved. 250 | " 251 | " 'replace': A function which takes a position which has previously been cleared but has been replaced. 252 | " 253 | " 'start': A function which takes a position which has been run. 254 | " 255 | " 'exit': A function which takes a position result once it has completed. 256 | " 257 | " 'clear': A function which takes a position which has been removed for some 258 | " reason. 259 | " 260 | " Positions can be either a file, namespace or test, distinguished with a 261 | " 'type' key. 262 | " 263 | let g:ultest_custom_processors = get(g:, "ultest_custom_processors", []) 264 | let g:ultest#processors = [ 265 | \ { 266 | \ "condition": g:ultest_show_in_file, 267 | \ "start": "ultest#signs#start", 268 | \ "clear": "ultest#signs#unplace", 269 | \ "exit": "ultest#signs#process", 270 | \ "move": "ultest#signs#move", 271 | \ "replace": "ultest#signs#process" 272 | \ }, 273 | \ { 274 | \ "new": "ultest#summary#render", 275 | \ "start": "ultest#summary#render", 276 | \ "clear": "ultest#summary#render", 277 | \ "exit": "ultest#summary#render", 278 | \ "move": "ultest#summary#render", 279 | \ "replace": "ultest#summary#render" 280 | \ }, 281 | \] + get(g:, "ultest_custom_processors", []) 282 | 283 | "" 284 | " Custom patterns for identifying positions. This dictionary should use the keys 285 | " in the form '' or '#'. The values should be a 286 | " dictionary with the following keys: 287 | " 288 | " 'test': A list a python-style regex patterns that can each indentify tests 289 | " in a line of code 290 | " 291 | " 'namepsace': A list of python-style regex patterns that can idenitfy test 292 | " namespaces (e.g. Classes). 293 | " 294 | " If you find a missing language that requires you to set this value, 295 | " considering onpening an issue/PR to make it available to others. 296 | let g:ultest_custom_patterns = get(g:, "ultest_custom_patterns", {}) 297 | 298 | let g:ultest_patterns = extend({ 299 | \ "elixir#exunit": { 300 | \ 'test': ["^\\s*test\\s+['\"](.+)['\"](,\\s+%{.+})*\\s+do", "^\\s*feature\\s+['\"](.+)['\"](,\\s+%{.+})*\\s+do",], 301 | \} 302 | \ }, g:ultest_custom_patterns) 303 | 304 | "" 305 | " Key mappings for the summary window (dict) 306 | " Possible values: 307 | " 308 | " 'run': (default "r") Runs the test currently selected or whole file if file name is selected. 309 | " 310 | " 'jumpto': (default "") Jump to currently selected test in its file. 311 | " 312 | " 'output': (default "o") Open the output to the current test if failed. 313 | " 314 | " 'attach': (default "a") Attach to the running process of the current test. 315 | " 316 | " 'stop': (default "s") Stop the running process of the current test. 317 | " 318 | " 'next_fail': (default "") Jump down to the next fail. 319 | " 320 | " 'prev_fail': (default "") Jump up to the next fail. 321 | " 322 | " The summary window also defines folds for each files and namespaces so they 323 | " can be hidden as desired using the regular fold mappings. 324 | let g:ultest_summary_mappings = get(g:, "ultest_summary_mappings", { 325 | \ "run": "r", 326 | \ "jumpto": "", 327 | \ "output": "o", 328 | \ "attach": "a", 329 | \ "stop": "s", 330 | \ "next_fail": "", 331 | \ "prev_fail": "" 332 | \ }) 333 | 334 | call sign_define("test_pass", {"text":g:ultest_pass_sign, "texthl": "UltestPass"}) 335 | call sign_define("test_fail", {"text":g:ultest_fail_sign, "texthl": "UltestFail"}) 336 | call sign_define("test_running", {"text":g:ultest_running_sign, "texthl": "UltestRunning"}) 337 | 338 | "" 339 | " Run all tests in the current file 340 | " Some runners can be run as a single command if the output can be parsed to 341 | " gain results. Otherwise, each test is run as a single command. 342 | " 343 | " Running as a single command can significanly improve performance, so if your 344 | " runner is not yet supported please open an issue with sample output! Check 345 | " the repo wiki for an updated list of runners. 346 | command! Ultest call ultest#run_file({"pre_run": g:ultest_pre_run}) 347 | 348 | "" 349 | " Run nearest position in the current file 350 | " If no position is found it will attempt to run the entire file 351 | command! UltestNearest call ultest#run_nearest({"pre_run": g:ultest_pre_run}) 352 | 353 | "" 354 | " Run test(s) that were last ran 355 | command! UltestLast call ultest#handler#run_last() 356 | 357 | "" 358 | " Debug the current file with nvim-dap 359 | command! UltestDebug call ultest#run_file({"pre_run": g:ultest_pre_run, "runner": "nvim-dap"}) 360 | 361 | "" 362 | " Debug the nearest position with nvim-dap 363 | command! UltestDebugNearest call ultest#run_nearest({"pre_run": g:ultest_pre_run, "runner": "nvim-dap"}) 364 | 365 | "" 366 | " Show the output of the nearest position in the current file 367 | command! UltestOutput call ultest#output#open(ultest#handler#get_nearest_test(line("."), expand("%:."), v:false)) 368 | 369 | "" 370 | " Attach to the running process of a position to be able to send input and read 371 | " output as it runs. This is useful for debugging 372 | command! UltestAttach call ultest#output#attach(ultest#handler#get_nearest_test(line("."), expand("%:."), v:false)) 373 | 374 | "" 375 | " Stop all running jobs for the current file 376 | command! UltestStop call ultest#stop_file() 377 | 378 | "" 379 | " Stop any running jobs and results for the nearest position 380 | command! UltestStopNearest call ultest#stop_nearest() 381 | 382 | "" 383 | " Toggle the summary window between open and closed. 384 | " If [!] is given, jump to the window if opened 385 | command! -bang UltestSummary call ultest#summary#toggle(expand("") == "!") 386 | 387 | "" 388 | " Open the summary window. 389 | " If [!] is given, jump to the window 390 | command! -bang UltestSummaryOpen call ultest#summary#open(expand("") == "!") 391 | 392 | "" 393 | " Close the summary window 394 | command! UltestSummaryClose call ultest#summary#close() 395 | 396 | "" 397 | " Clear results from the current file 398 | command! UltestClear call ultest#handler#clear_results(expand("%:p")) 399 | 400 | "" 401 | " @section Mappings 402 | " 403 | " (ultest-next-fail) Jump to next failed test. 404 | " 405 | " (ultest-prev-fail) Jump to previous failed test. 406 | " 407 | " (ultest-run-file) Run all tests in a file. 408 | " 409 | " (ultest-run-nearest) Run test closest to the cursor. 410 | " 411 | " (ultest-run-last) Run test(s) that were last run. 412 | " 413 | " (ultest-summary-toggle) Toggle the summary window between open and closed 414 | " 415 | " (ultest-summary-jump) Jump to the summary window (opening if it isn't already) 416 | " 417 | " (ultest-output-show) Show error output of the nearest test. (Will jump to popup window in Vim) 418 | " 419 | " (ultest-output-jump) Show error output of the nearest test. (Same behabviour as (ultest-output-show) in Vim) 420 | " 421 | " (ultest-attach) Attach to the nearest test's running process. 422 | " 423 | " (ultest-stop-file) Stop all running jobs for current file 424 | " 425 | " (ultest-stop-nearest) Stop any running jobs for nearest test 426 | " 427 | " (ultest-debug) Debug the current file with nvim-dap 428 | " 429 | " (ultest-debug-nearest) Debug the nearest test with nvim-dap 430 | 431 | nnoremap (ultest-next-fail) :call ultest#positions#next() 432 | nnoremap (ultest-prev-fail) :call ultest#positions#prev() 433 | nnoremap (ultest-run-file) :Ultest 434 | nnoremap (ultest-run-nearest) :UltestNearest 435 | nnoremap (ultest-run-last) :UltestLast 436 | nnoremap (ultest-summary-toggle) :UltestSummary 437 | nnoremap (ultest-summary-jump) :call ultest#summary#jumpto() 438 | nnoremap (ultest-output-show) :UltestOutput 439 | nnoremap (ultest-output-jump) :call ultest#output#jumpto() 440 | nnoremap (ultest-attach) :UltestAttach 441 | nnoremap (ultest-stop-file) :UltestStop 442 | nnoremap (ultest-stop-nearest) :UltestStop 443 | nnoremap (ultest-debug) :UltestDebug 444 | nnoremap (ultest-debug-nearest) :UltestDebugNearest 445 | 446 | let s:monitored = {} 447 | 448 | function! s:MonitorFile(file) abort 449 | if has_key(s:monitored, a:file) 450 | return 451 | end 452 | try 453 | if !test#test_file(a:file) 454 | let s:monitored[a:file] = v:false 455 | return 456 | endif 457 | catch /.*/ 458 | " vim-test throws error on new test files that don't exist in fs. See #30 459 | endtry 460 | let buffer = bufnr(a:file) 461 | call ultest#handler#update_positions(a:file) 462 | exec 'au BufWrite call ultest#handler#update_positions("'.a:file.'")' 463 | exec 'au BufUnload au! * ' 464 | if g:ultest_output_on_line 465 | exec 'au CursorHold call ultest#output#open(ultest#handler#get_nearest_test(line("."), expand("%:."), v:true))' 466 | endif 467 | let s:monitored[a:file] = v:true 468 | endfunction 469 | 470 | augroup UltestPositionUpdater 471 | au! 472 | au BufEnter * call MonitorFile(expand(":.")) 473 | if !has("nvim") 474 | au VimEnter * call MonitorFile(expand(":.")) 475 | endif 476 | augroup END 477 | 478 | if !has("nvim") 479 | augroup UltestDummyCommand 480 | au! 481 | au User UltestPositionsUpdate let s:dummy = 1 482 | au User UltestOutputOpen let s:dummy = 1 483 | augroup END 484 | endif 485 | 486 | if !has("vim_starting") 487 | " Avoids race condition https://github.com/neovim/pynvim/issues/341 488 | call ultest#handler#get_attach_script("") 489 | for open_file in split(execute("buffers"), "\n") 490 | let file_name = matchstr(open_file, '".\+"') 491 | if file_name != "" 492 | call s:MonitorFile(fnamemodify(file_name[1:-2], ".")) 493 | endif 494 | endfor 495 | end 496 | 497 | 498 | "" 499 | " @section Debugging 500 | " 501 | " vim-ultest supports debugging through nvim-dap. Due to how debugging 502 | " configurations can vary greatly between users and projects, some 503 | " configuration is required for test debugging to work. 504 | " 505 | " You must provide a way to build a suitable nvim-dap confiuration to run a 506 | " test, given the original command for the test. The command is given in the 507 | " form of a list of strings. The returned table should contain a 'dap' entry 508 | " which will be provided to nvim-dap's 'run' function. 509 | " 510 | " For example with a python test using the adapter defined in nvim-dap-python: 511 | " > 512 | " function(cmd) 513 | " -- The command can start with python command directly or an env manager 514 | " local non_modules = {'python', 'pipenv', 'poetry'} 515 | " -- Index of the python module to run the test. 516 | " local module 517 | " if vim.tbl_contains(non_modules, cmd[1]) then 518 | " module = cmd[3] 519 | " else 520 | " module = cmd[1] 521 | " end 522 | " -- Remaining elements are arguments to the module 523 | " local args = vim.list_slice(cmd, module_index + 1) 524 | " return { 525 | " dap = { 526 | " type = 'python', 527 | " request = 'launch', 528 | " module = module, 529 | " args = args 530 | " } 531 | " } 532 | " end, 533 | " < 534 | " This will separate out the module name and arguments to provide to the 535 | " nvim-dap-python adapter. 536 | " 537 | " To provide this to vim-ultest, call the setup function, providing a table 538 | " with a 'builders' entry, with your language mapped to the builder. If you require 539 | " multiple runners, the key can be in the form of '#' 540 | " (Example: 'python#pytest') 541 | " > 542 | " require("ultest").setup({ 543 | " builders = { 544 | " ['python#pytest'] = function (cmd) 545 | " ... 546 | " end 547 | " } 548 | " }) 549 | " < 550 | " 551 | " Some adapters don't provide an exit code when running tests, such as 552 | " vscode-go. This causes vim-ultest to never receive a result. To work around 553 | " this, you can provide a 'parse_result' entry in the returned table of your 554 | " config builder function which will receive the output of the program as a 555 | " list of lines. You can then return an exit code for the test. 556 | " For example with vscode-go and gotest, the final line will state 'FAIL' on a 557 | " failed test. 558 | " > 559 | " ["go#gotest"] = function(cmd) 560 | " local args = {} 561 | " for i = 3, #cmd - 1, 1 do 562 | " local arg = cmd[i] 563 | " if vim.startswith(arg, "-") then 564 | " -- Delve requires test flags be prefix with 'test.' 565 | " arg = "-test." .. string.sub(arg, 2) 566 | " end 567 | " args[#args + 1] = arg 568 | " end 569 | " return { 570 | " dap = { 571 | " type = "go", 572 | " request = "launch", 573 | " mode = "test", 574 | " program = "${workspaceFolder}", 575 | " dlvToolPath = vim.fn.exepath("dlv"), 576 | " args = args 577 | " }, 578 | " parse_result = function(lines) 579 | " return lines[#lines] == "FAIL" and 1 or 0 580 | " end 581 | " } 582 | " end 583 | " < 584 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | 2 | [tools.black] 3 | line-length=120 4 | 5 | [tool.isort] 6 | profile = "black" 7 | multi_line_output = 3 8 | 9 | [tool.pytest.ini_options] 10 | filterwarnings = [ 11 | "error", 12 | "ignore::pytest.PytestCollectionWarning", 13 | "ignore:::pynvim[.*]" 14 | ] 15 | asyncio_mode = "auto" 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.4 2 | attrs==21.4.0 3 | autoflake==1.4 4 | black==20.8b0 5 | click==8.0.3 6 | coverage==6.2 7 | greenlet==1.1.2 8 | hypothesis==6.36.0 9 | iniconfig==1.1.1 10 | isort==5.10.1 11 | msgpack==1.0.3 12 | mypy-extensions==0.4.3 13 | packaging==21.3 14 | pathspec==0.9.0 15 | pip==21.3.1 16 | pluggy==1.0.0 17 | py==1.11.0 18 | pyflakes==2.4.0 19 | pynvim==0.4.3 20 | pyparsing==3.0.7 21 | pytest-asyncio==0.17.2 22 | pytest-cov==3.0.0 23 | pytest==6.2.5 24 | regex==2022.1.18 25 | setuptools==59.3.0 26 | sortedcontainers==2.4.0 27 | toml==0.10.2 28 | tomli==2.0.0 29 | typed-ast==1.5.1 30 | typing_extensions==4.0.1 31 | wheel==0.37.1 32 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | 4 | if os.getenv("ULTEST_DEBUG") or os.getenv("ULTEST_DEBUG_PORT"): 5 | import debugpy 6 | 7 | debugpy.listen(int(os.getenv("ULTEST_DEBUG_PORT") or 5678)) 8 | debugpy.wait_for_client() 9 | 10 | 11 | try: 12 | import vim # type: ignore 13 | 14 | HANDLER = None 15 | 16 | def _check_started(): 17 | global HANDLER # pylint: disable=W0603 18 | if not HANDLER: 19 | from .handler import HandlerFactory 20 | 21 | HANDLER = HandlerFactory.create(vim) 22 | 23 | def _ultest_run_nearest(*args): 24 | _check_started() 25 | HANDLER.run_nearest(*args) 26 | 27 | def _ultest_run_single(*args): 28 | _check_started() 29 | HANDLER.run_single(*args) 30 | 31 | def _ultest_run_last(*args): 32 | _check_started() 33 | HANDLER.run_last(*args) 34 | 35 | def _ultest_update_positions(*args): 36 | _check_started() 37 | HANDLER.update_positions(*args) 38 | 39 | def _ultest_get_nearest_test(*args): 40 | _check_started() 41 | return HANDLER.get_nearest_test_dict(*args) 42 | 43 | def _ultest_get_attach_script(*args): 44 | _check_started() 45 | return HANDLER.get_attach_script(*args) 46 | 47 | def _ultest_stop_test(*args): 48 | _check_started() 49 | return HANDLER.stop_test(*args) 50 | 51 | def _ultest_external_start(*args): 52 | _check_started() 53 | return HANDLER.external_start(*args) 54 | 55 | def _ultest_external_result(*args): 56 | _check_started() 57 | return HANDLER.external_result(*args) 58 | 59 | def _ultest_safe_split(*args): 60 | _check_started() 61 | return HANDLER.safe_split(*args) 62 | 63 | def _ultest_clear_results(*args): 64 | _check_started() 65 | return HANDLER.clear_results(*args) 66 | 67 | 68 | except ImportError: 69 | from pynvim import Nvim, function, plugin 70 | 71 | @plugin 72 | class Ultest: 73 | def __init__(self, nvim: Nvim): 74 | self._vim = nvim 75 | self._handler = None 76 | 77 | @property 78 | def handler(self): 79 | if not self._handler: 80 | from .handler import HandlerFactory 81 | 82 | self._handler = HandlerFactory.create(self._vim) 83 | return self._handler 84 | 85 | @function("_ultest_run_nearest", sync=True) 86 | def _run_nearest(self, args): 87 | self.handler.run_nearest(*args) 88 | 89 | @function("_ultest_run_single", sync=True) 90 | def _run_single(self, args): 91 | self.handler.run_single(*args) 92 | 93 | @function("_ultest_run_last", allow_nested=True) 94 | def _run_last(self, args): 95 | self.handler.run_last(*args) 96 | 97 | @function("_ultest_update_positions", allow_nested=True) 98 | def _update_positions(self, args): 99 | self.handler.update_positions(*args) 100 | 101 | @function("_ultest_get_nearest_test", sync=True) 102 | def _get_nearest_test(self, args): 103 | return self.handler.get_nearest_test_dict(*args) 104 | 105 | @function("_ultest_get_attach_script", sync=True) 106 | def _get_attach_script(self, args): 107 | return self.handler.get_attach_script(*args) 108 | 109 | @function("_ultest_stop_test", allow_nested=True) 110 | def _stop_test(self, args): 111 | return self.handler.stop_test(*args) 112 | 113 | @function("_ultest_external_start", allow_nested=True) 114 | def _external_start(self, args): 115 | return self.handler.external_start(*args) 116 | 117 | @function("_ultest_external_result", allow_nested=True) 118 | def _external_result(self, args): 119 | return self.handler.external_result(*args) 120 | 121 | @function("_ultest_safe_split", sync=True) 122 | def _safe_split(self, args): 123 | return self.handler.safe_split(*args) 124 | 125 | @function("_ultest_clear_results", sync=True) 126 | def _clear_results(self, args): 127 | return self.handler.clear_results(*args) 128 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/handler/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from shlex import split 3 | from typing import Callable, Dict, List, Optional, Tuple, Union 4 | 5 | from pynvim import Nvim 6 | 7 | from ..logging import get_logger 8 | from ..models import File, Namespace, Position, Result, Test, Tree 9 | from ..vim_client import VimClient 10 | from .parsers import FileParser, OutputParser, Position 11 | from .runner import PositionRunner, ProcessManager 12 | from .tracker import PositionTracker 13 | 14 | logger = get_logger() 15 | 16 | 17 | class HandlerFactory: 18 | @staticmethod 19 | def create(vim: Nvim) -> "Handler": 20 | client = VimClient(vim) 21 | file_parser = FileParser(client) 22 | process_manager = ProcessManager(client) 23 | output_parser = OutputParser(client.sync_eval("g:ultest_disable_grouping")) 24 | runner = PositionRunner( 25 | vim=client, process_manager=process_manager, output_parser=output_parser 26 | ) 27 | tracker = PositionTracker(file_parser=file_parser, runner=runner, vim=client) 28 | return Handler(client, tracker=tracker, runner=runner) 29 | 30 | 31 | class Handler: 32 | def __init__( 33 | self, 34 | nvim: VimClient, 35 | tracker: PositionTracker, 36 | runner: PositionRunner, 37 | ): 38 | self._vim = nvim 39 | self._runner = runner 40 | self._tracker = tracker 41 | self._prepare_env() 42 | self._show_on_run = self._vim.sync_eval("get(g:, 'ultest_output_on_run', 1)") 43 | self._last_run = None 44 | logger.debug("Handler created") 45 | 46 | def _prepare_env(self): 47 | rows = self._vim.sync_eval("g:ultest_output_rows") 48 | if rows: 49 | logger.debug(f"Setting ROWS to {rows}") 50 | os.environ["ROWS"] = str(rows) 51 | elif "ROWS" in os.environ: 52 | logger.debug("Clearing ROWS value") 53 | os.environ.pop("ROWS") 54 | cols = self._vim.sync_eval("g:ultest_output_cols") 55 | if cols: 56 | logger.debug(f"Setting COLUMNS to {cols}") 57 | os.environ["COLUMNS"] = str(cols) 58 | elif "COLUMNS" in os.environ: 59 | logger.debug("Clearing COLUMNS value") 60 | os.environ.pop("COLUMNS") 61 | 62 | @property 63 | def _user_env(self): 64 | return self._vim.sync_call("get", "g:", "ultest_env") or None 65 | 66 | def safe_split(self, cmd: Union[str, List[str]]) -> List[str]: 67 | # Some runner position builders in vim-test don't split args properly (e.g. go test) 68 | return split(cmd if isinstance(cmd, str) else " ".join(cmd)) 69 | 70 | def external_start(self, pos_id: str, file_name: str, stdout: str): 71 | tree = self._tracker.file_positions(file_name) 72 | if not tree: 73 | logger.error( 74 | "Attempted to register started test for unknown file {file_name}" 75 | ) 76 | raise ValueError(f"Unknown file {file_name}") 77 | 78 | position = tree.search(pos_id, lambda pos: pos.id) 79 | if not position: 80 | logger.error(f"Attempted to register unknown test as started {pos_id}") 81 | return 82 | 83 | self._runner.register_external_start( 84 | position, tree, stdout, self._on_test_start 85 | ) 86 | 87 | def external_result(self, pos_id: str, file_name: str, exit_code: int): 88 | tree = self._tracker.file_positions(file_name) 89 | if not tree: 90 | logger.error( 91 | "Attempted to register test result for unknown file {file_name}" 92 | ) 93 | raise ValueError(f"Unknown file {file_name}") 94 | position = tree.search(pos_id, lambda pos: pos.id) 95 | if not position: 96 | logger.error(f"Attempted to register unknown test result {pos_id}") 97 | return 98 | self._runner.register_external_result( 99 | position, tree, exit_code, self._on_test_finish 100 | ) 101 | 102 | def _on_test_start(self, position: Position): 103 | self._vim.call("ultest#process#start", position) 104 | 105 | def _on_test_finish(self, position: Position, result: Result): 106 | self._vim.call("ultest#process#exit", position, result) 107 | if self._show_on_run and result.code and result.output: 108 | self._vim.schedule(self._present_output, result) 109 | 110 | def _present_output(self, result): 111 | if result.code and self._vim.sync_call("expand", "%") == result.file: 112 | logger.fdebug("Showing {result.id} output") 113 | line = self._vim.sync_call("getbufinfo", result.file)[0].get("lnum") 114 | nearest = self.get_nearest_position(line, result.file, strict=False) 115 | if nearest and nearest.data.id == result.id: 116 | self._vim.sync_call("ultest#output#open", result.dict()) 117 | 118 | def run_nearest(self, line: int, file_name: str, update_empty: bool = True): 119 | """ 120 | Run nearest test to cursor in file. 121 | 122 | If the line is 0 it will run the entire file. 123 | 124 | :param line: Line to run test nearest to. 125 | :param file_name: File to run in. 126 | """ 127 | 128 | logger.finfo("Running nearest test in {file_name} at line {line}") 129 | positions = self._tracker.file_positions(file_name) 130 | 131 | if not positions and update_empty: 132 | logger.finfo( 133 | "No tests found for {file_name}, rerunning after processing positions" 134 | ) 135 | 136 | def run_after_update(): 137 | self._vim.schedule( 138 | self.run_nearest, line, file_name, update_empty=False 139 | ) 140 | 141 | return self.update_positions(file_name, callback=run_after_update) 142 | 143 | if not positions: 144 | return 145 | 146 | position = self.get_nearest_position(line, file_name, strict=False) 147 | 148 | if not position: 149 | return 150 | 151 | self._last_run = position.data 152 | self._runner.run( 153 | position, 154 | positions, 155 | file_name, 156 | on_start=self._on_test_start, 157 | on_finish=self._on_test_finish, 158 | env=self._user_env, 159 | ) 160 | 161 | def run_single(self, test_id: str, file_name: str): 162 | """ 163 | Run a test with the given ID 164 | 165 | :param test_id: Test to run 166 | :param file_name: File to run in. 167 | """ 168 | logger.finfo("Running test {test_id} in {file_name}") 169 | positions = self._tracker.file_positions(file_name) 170 | if not positions: 171 | return 172 | match = None 173 | for position in positions.nodes(): 174 | if test_id == position.data.id: 175 | match = position 176 | 177 | if not match: 178 | return 179 | 180 | self._last_run = match.data 181 | self._runner.run( 182 | match, 183 | positions, 184 | file_name, 185 | on_start=self._on_test_start, 186 | on_finish=self._on_test_finish, 187 | env=self._user_env, 188 | ) 189 | 190 | def run_last(self): 191 | if not self._last_run: 192 | logger.info("No tests run yet") 193 | return 194 | 195 | self.run_single(self._last_run.id, self._last_run.file) 196 | 197 | def update_positions(self, file_name: str, callback: Optional[Callable] = None): 198 | try: 199 | self._vim.sync_call( 200 | "setbufvar", file_name, "_ultest_is_unique_file", "is_unique_file" 201 | ) 202 | except Exception: 203 | logger.warn( 204 | f"Multiple buffers matched file name {file_name}, can't check for tests" 205 | ) 206 | return 207 | self._tracker.update(file_name, callback) 208 | 209 | def get_nearest_position( 210 | self, line: int, file_name: str, strict: bool 211 | ) -> Optional[Tree[Position]]: 212 | positions = self._tracker.file_positions(file_name) 213 | if not positions: 214 | return None 215 | key = lambda pos: pos.line 216 | return positions.sorted_search(line, key=key, strict=strict) 217 | 218 | def get_nearest_test_dict( 219 | self, line: int, file_name: str, strict: bool 220 | ) -> Optional[Dict]: 221 | test = self.get_nearest_position(line, file_name, strict) 222 | if not test: 223 | return None 224 | return test.data.dict() 225 | 226 | def get_attach_script(self, process_id: str) -> Optional[Tuple[str, str]]: 227 | logger.finfo("Creating script to attach to process {process_id}") 228 | return self._runner.get_attach_script(process_id) 229 | 230 | def stop_test(self, pos_dict: Optional[Dict]): 231 | if not pos_dict: 232 | logger.fdebug("No process to cancel") 233 | return 234 | 235 | pos = self._parse_position(pos_dict) 236 | if not pos: 237 | logger.error(f"Invalid dict passed for position {pos_dict}") 238 | return 239 | 240 | tree = self._tracker.file_positions(pos.file) 241 | if not tree: 242 | logger.error(f"Positions not found for file {pos.file}") 243 | return 244 | 245 | self._runner.stop(pos, tree) 246 | 247 | def clear_results(self, file_name: str): 248 | logger.fdebug("Clearing results for file {file_name}") 249 | cleared = set(self._runner.clear_results(file_name)) 250 | if not cleared: 251 | return 252 | 253 | positions = self._tracker.file_positions(file_name) 254 | if not positions: 255 | logger.error("Successfully cleared results for unknown file") 256 | 257 | for position in positions: 258 | if position.id in cleared: 259 | position.running = 0 260 | self._vim.sync_call("ultest#process#clear", position) 261 | self._vim.sync_call("ultest#process#new", position) 262 | 263 | def _parse_position(self, pos_dict: Dict) -> Optional[Position]: 264 | pos_type = pos_dict.get("type") 265 | if pos_type == "test": 266 | return Test(**pos_dict) 267 | if pos_type == "namespace": 268 | return Namespace(**pos_dict) 269 | if pos_type == "file": 270 | return File(**pos_dict) 271 | return None 272 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/handler/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | from .file import FileParser, Position 2 | from .output import OutputParser, OutputPatterns, ParseResult 3 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/handler/parsers/file.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Dict, List, Optional, Pattern, Tuple, Union 3 | 4 | from ...logging import get_logger 5 | from ...models import File, Namespace, Test, Tree 6 | from ...vim_client import VimClient 7 | 8 | REGEX_CONVERSIONS = {r"\\v": "", r"%\((.*?)\)": r"(?:\1)", r"\\zs": "", r"\\ze": ""} 9 | INDENT_PATTERN = re.compile(r"(^\s*)\S") 10 | 11 | Position = Union[File, Test, Namespace] 12 | PosList = Union[Position, List["PosList"]] 13 | 14 | logger = get_logger() 15 | 16 | 17 | class FileParser: 18 | def __init__(self, vim: VimClient): 19 | self._vim = vim 20 | 21 | async def parse_file_structure( 22 | self, file_name: str, vim_patterns: Dict 23 | ) -> Tree[Position]: 24 | patterns = self._convert_patterns(vim_patterns) 25 | logger.fdebug("Converted pattern {vim_patterns} to {patterns}") 26 | with open(file_name, "r") as test_file: 27 | lines = test_file.readlines() 28 | res, _ = self._parse_position_tree( 29 | file_name, patterns["test"], patterns["namespace"], lines 30 | ) 31 | x = Tree[Position].from_list( 32 | [File(id=file_name, name=file_name, file=file_name, running=0), *res] 33 | ) 34 | return x 35 | 36 | def _convert_patterns( 37 | self, vim_patterns: Dict[str, List[str]] 38 | ) -> Dict[str, List[Pattern]]: 39 | tests = [ 40 | self._convert_regex(pattern) for pattern in vim_patterns.get("test", "") 41 | ] 42 | namespaces = [ 43 | self._convert_regex(pattern) 44 | for pattern in vim_patterns.get("namespace", "") 45 | ] 46 | return {"test": tests, "namespace": namespaces} 47 | 48 | def _convert_regex(self, vim_regex: str) -> Pattern: 49 | regex = vim_regex 50 | for pattern, repl in REGEX_CONVERSIONS.items(): 51 | regex = re.sub(pattern, repl, regex) 52 | return re.compile(regex) 53 | 54 | def _parse_position_tree( 55 | self, 56 | file_name: str, 57 | test_patterns: List[Pattern], 58 | namespace_patterns: List[Pattern], 59 | lines: List[str], 60 | init_line: int = 1, 61 | init_indent: int = -1, 62 | current_namespaces: Optional[List[str]] = None, 63 | last_test_indent=-1, 64 | ) -> Tuple[List[PosList], int]: 65 | """ 66 | This function tries to emulate how vim-test will parse files based off 67 | of indents. This means that if a namespace is on the same indent as a 68 | test within it, the test will not detected correctly. Since we fall 69 | back to vim-test for running there's no solution we can add here to 70 | avoid this without vim-test working around it too. 71 | """ 72 | positions = [] 73 | current_namespaces = current_namespaces or [] 74 | line_no = init_line 75 | while line_no - init_line < len(lines): 76 | line = lines[line_no - init_line] 77 | test_name = self._find_match(line, test_patterns) 78 | namespace_name = self._find_match(line, namespace_patterns) 79 | 80 | if test_name: 81 | cls = Test 82 | name = test_name 83 | children = None 84 | elif namespace_name: 85 | cls = Namespace 86 | name = namespace_name 87 | else: 88 | line_no += 1 89 | continue 90 | 91 | current_indent = INDENT_PATTERN.match(line) 92 | if current_indent and len(current_indent[1]) <= init_indent: 93 | consumed = max(line_no - 1 - init_line, 1) 94 | return positions, consumed 95 | 96 | if cls is Test: 97 | last_test_indent = len(current_indent[1]) 98 | 99 | id_suffix = hash((file_name, " ".join(current_namespaces))) 100 | position = cls( 101 | id=self._clean_id(name + str(id_suffix)), 102 | file=file_name, 103 | line=line_no, 104 | col=1, 105 | name=name, 106 | running=0, 107 | namespaces=current_namespaces, 108 | ) 109 | 110 | if cls is Namespace: 111 | children, lines_consumed = self._parse_position_tree( 112 | file_name, 113 | test_patterns, 114 | namespace_patterns, 115 | lines[line_no - init_line + 1 :], 116 | init_line=line_no + 1, 117 | init_indent=len(current_indent[1]), 118 | current_namespaces=[*current_namespaces, position.id], 119 | last_test_indent=last_test_indent, 120 | ) 121 | lines_consumed += 1 122 | if children and ( 123 | last_test_indent == -1 or last_test_indent >= len(current_indent[1]) 124 | ): 125 | positions.append([position, *children]) 126 | else: 127 | lines_consumed = 1 128 | positions.append(position) 129 | 130 | line_no += lines_consumed 131 | return positions, line_no 132 | 133 | def _clean_id(self, id: str) -> str: 134 | return re.subn(r"[.'\" \\/]", "_", id)[0] 135 | 136 | def _find_match(self, line: str, patterns: List[Pattern]) -> Optional[str]: 137 | for pattern in patterns: 138 | matched = pattern.match(line) 139 | if matched: 140 | return matched[1] 141 | return None 142 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/handler/parsers/output.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dataclasses import dataclass 3 | from typing import Iterator, List, Optional 4 | 5 | from ...logging import get_logger 6 | 7 | 8 | @dataclass(frozen=True) 9 | class ParseResult: 10 | name: str 11 | namespaces: List[str] 12 | 13 | 14 | @dataclass 15 | class OutputPatterns: 16 | failed_test: str 17 | namespace_separator: Optional[str] = None 18 | ansi: bool = False 19 | failed_name_prefix: Optional[str] = None 20 | 21 | 22 | _BASE_PATTERNS = { 23 | "python#pytest": OutputPatterns( 24 | failed_test=r"^(FAILED|ERROR) .+?::(?P.+::)?(?P[^[\s]*)(.+])?( |$)", 25 | namespace_separator="::", 26 | ), 27 | "python#pyunit": OutputPatterns( 28 | failed_test=r"^FAIL: (?P.*) \(.*?(?P\..+)\)", 29 | namespace_separator=r"\.", 30 | ), 31 | "go#gotest": OutputPatterns(failed_test=r"^.*--- FAIL: (?P.+?) "), 32 | "go#richgo": OutputPatterns( 33 | failed_test=r"^FAIL\s\|\s(?P.+?) \(.*\)", 34 | ansi=True, 35 | failed_name_prefix="Test", 36 | ), 37 | "javascript#jest": OutputPatterns( 38 | failed_test=r"^\s*● (?P.* › )?(?P.*)$", 39 | ansi=True, 40 | namespace_separator=" › ", 41 | ), 42 | "elixir#exunit": OutputPatterns(failed_test=r"\s*\d\) test (?P.*) \(.*\)$"), 43 | } 44 | 45 | # https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python 46 | _ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") 47 | 48 | logger = get_logger() 49 | 50 | 51 | class OutputParser: 52 | def __init__(self, disable_patterns: List[str]) -> None: 53 | self._patterns = { 54 | runner: patterns 55 | for runner, patterns in _BASE_PATTERNS.items() 56 | if runner not in disable_patterns 57 | } 58 | 59 | def can_parse(self, runner: str) -> bool: 60 | return runner in self._patterns 61 | 62 | def parse_failed(self, runner: str, output: List[str]) -> Iterator[ParseResult]: 63 | pattern = self._patterns[runner] 64 | fail_pattern = re.compile(pattern.failed_test) 65 | for line in output: 66 | match = fail_pattern.match( 67 | _ANSI_ESCAPE.sub("", line) if pattern.ansi else line 68 | ) 69 | if match: 70 | logger.finfo( 71 | "Found failed test in output {match['name']} in namespaces {match['namespaces']} of runner {runner}" 72 | ) 73 | namespaces = ( 74 | [ 75 | namespace 76 | for namespace in re.split( 77 | pattern.namespace_separator, match["namespaces"] 78 | ) 79 | if namespace 80 | ] 81 | if pattern.namespace_separator and match["namespaces"] 82 | else [] 83 | ) 84 | name = ( 85 | f"{pattern.failed_name_prefix}{match['name']}" 86 | if pattern.failed_name_prefix 87 | else match["name"] 88 | ) 89 | yield ParseResult(name=name, namespaces=namespaces) 90 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/handler/runner/__init__.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from functools import partial 3 | from typing import Callable, Dict, Iterable, Iterator, List, Optional, Set, Tuple 4 | 5 | from ...logging import get_logger 6 | from ...models import File, Namespace, Position, Result, Test, Tree 7 | from ...vim_client import VimClient 8 | from ..parsers import OutputParser, ParseResult, Position 9 | from .processes import ProcessManager 10 | 11 | logger = get_logger() 12 | 13 | 14 | class PositionRunner: 15 | """ 16 | Handle running of tests and gathering results objects 17 | """ 18 | 19 | def __init__( 20 | self, 21 | vim: VimClient, 22 | process_manager: ProcessManager, 23 | output_parser: OutputParser, 24 | ): 25 | self._vim = vim 26 | self._results = defaultdict(dict) 27 | self._processes = process_manager 28 | self._output_parser = output_parser 29 | self._running: Set[str] = set() 30 | self._external_outputs = {} 31 | 32 | def run( 33 | self, 34 | tree: Tree[Position], 35 | file_tree: Tree[Position], 36 | file_name: str, 37 | on_start: Callable[[Position], None], 38 | on_finish: Callable[[Position, Result], None], 39 | env: Optional[Dict] = None, 40 | ): 41 | 42 | runner = self._vim.sync_call("ultest#adapter#get_runner", file_name) 43 | if not self._output_parser.can_parse(runner) or len(tree) == 1: 44 | self._run_separately(tree, on_start, on_finish, env) 45 | return 46 | self._run_group(tree, file_tree, file_name, on_start, on_finish, env) 47 | 48 | def stop(self, pos: Position, tree: Tree[Position]): 49 | root = None 50 | if self._vim.stop(pos.id): 51 | root = tree.search(pos.id, lambda data: data.id) 52 | else: 53 | for namespace in [*pos.namespaces, pos.file]: 54 | if self._vim.stop(namespace): 55 | root = tree.search(namespace, lambda data: data.id) 56 | break 57 | if not root: 58 | logger.warn(f"No matching job found for position {pos}") 59 | return 60 | 61 | for node in root: 62 | node.running = 0 63 | self._vim.call("ultest#process#move", node) 64 | 65 | def clear_results(self, file_name: str) -> Iterable[str]: 66 | return self._results.pop(file_name, {}).keys() 67 | 68 | def register_external_start( 69 | self, 70 | tree: Tree[Position], 71 | file_tree: Tree[Position], 72 | output_path: str, 73 | on_start: Callable[[Position], None], 74 | ): 75 | logger.finfo( 76 | "Saving external stdout path '{output_path}' for test {tree.data.id}" 77 | ) 78 | self._external_outputs[tree.data.id] = output_path 79 | for pos in tree: 80 | self._register_started(pos, on_start) 81 | 82 | def register_external_result( 83 | self, 84 | tree: Tree[Position], 85 | file_tree: Tree[Position], 86 | code: int, 87 | on_finish: Callable[[Position, Result], None], 88 | ): 89 | file_name = tree.data.file 90 | runner = self._vim.sync_call("ultest#adapter#get_runner", file_name) 91 | path = self._external_outputs.pop(tree.data.id) 92 | logger.finfo( 93 | "Saving external result for process '{tree.data.id}' with exit code {code}" 94 | ) 95 | if not path: 96 | logger.error(f"No output path registered for position {tree.data.id}") 97 | return 98 | if not self._output_parser.can_parse(runner): 99 | for pos in tree: 100 | self._register_result( 101 | pos, 102 | result=Result(id=pos.id, file=pos.file, code=code, output=path), 103 | on_finish=on_finish, 104 | ) 105 | return 106 | self._process_results( 107 | tree=tree, 108 | file_tree=file_tree, 109 | code=code, 110 | output_path=path, 111 | runner=runner, 112 | on_finish=on_finish, 113 | ) 114 | 115 | def is_running(self, position_id: str) -> int: 116 | return int(position_id in self._running) 117 | 118 | def get_result(self, pos_id: str, file_name: str) -> Optional[Result]: 119 | return self._results[file_name].get(pos_id) 120 | 121 | def get_attach_script(self, process_id: str): 122 | return self._processes.create_attach_script(process_id) 123 | 124 | def _get_cwd(self) -> Optional[str]: 125 | return ( 126 | self._vim.sync_call("get", "g:", "test#project_root") 127 | or self._vim.sync_call("getcwd") 128 | or None 129 | ) 130 | 131 | def _run_separately( 132 | self, 133 | tree: Tree[Position], 134 | on_start: Callable[[Position], None], 135 | on_finish: Callable[[Position, Result], None], 136 | env: Optional[Dict] = None, 137 | ): 138 | """ 139 | Run a collection of tests. Each will be done in 140 | a separate thread. 141 | """ 142 | root = self._get_cwd() 143 | tests = [] 144 | for pos in tree: 145 | if isinstance(pos, Test): 146 | tests.append(pos) 147 | 148 | for test in tests: 149 | self._register_started(test, on_start) 150 | cmd = self._vim.sync_call("ultest#adapter#build_cmd", test, "nearest") 151 | 152 | async def run(cmd=cmd, test=test): 153 | (code, output_path) = await self._processes.run( 154 | cmd, test.file, test.id, cwd=root, env=env 155 | ) 156 | self._register_result( 157 | test, 158 | Result(id=test.id, file=test.file, code=code, output=output_path), 159 | on_finish, 160 | ) 161 | 162 | self._vim.launch(run(), test.id) 163 | 164 | def _run_group( 165 | self, 166 | tree: Tree[Position], 167 | file_tree: Tree[Position], 168 | file_name: str, 169 | on_start: Callable[[Position], None], 170 | on_finish: Callable[[Position, Result], None], 171 | env: Optional[Dict] = None, 172 | ): 173 | runner = self._vim.sync_call("ultest#adapter#get_runner", file_name) 174 | scope = "file" if isinstance(tree.data, File) else "nearest" 175 | cmd = self._vim.sync_call("ultest#adapter#build_cmd", tree[0], scope) 176 | root = self._get_cwd() 177 | 178 | for pos in tree: 179 | self._register_started(pos, on_start) 180 | 181 | async def run(cmd=cmd): 182 | (code, output_path) = await self._processes.run( 183 | cmd, tree.data.file, tree.data.id, cwd=root, env=env 184 | ) 185 | self._process_results(tree, file_tree, code, output_path, runner, on_finish) 186 | 187 | self._vim.launch(run(), tree.data.id) 188 | 189 | def _process_results( 190 | self, 191 | tree: Tree[Position], 192 | file_tree: Tree[Position], 193 | code: int, 194 | output_path: str, 195 | runner: str, 196 | on_finish: Callable[[Position, Result], None], 197 | ): 198 | 199 | namespaces = { 200 | position.id: position 201 | for position in file_tree 202 | if isinstance(position, Namespace) 203 | } 204 | output = [] 205 | if code: 206 | with open(output_path, "r") as cmd_out: 207 | output = cmd_out.readlines() 208 | 209 | parsed_failures = self._output_parser.parse_failed(runner, output) 210 | failed = self._get_failed_set(parsed_failures, tree) 211 | 212 | get_code = partial(self._get_exit_code, tree.data, code, failed, namespaces) 213 | 214 | for pos in tree: 215 | self._register_result( 216 | pos, 217 | Result( 218 | id=pos.id, 219 | file=pos.file, 220 | code=get_code(pos) if code else 0, 221 | output=output_path, 222 | ), 223 | on_finish, 224 | ) 225 | 226 | def _get_exit_code( 227 | self, 228 | root: Position, 229 | group_code: int, 230 | failed: Set[Tuple[str, ...]], 231 | namespaces: Dict[str, Namespace], 232 | pos: Position, 233 | ): 234 | # If none were parsed but the process failed then something else went wrong, 235 | # and we treat it as all failed 236 | if not failed: 237 | return group_code 238 | if isinstance(root, Test): 239 | return group_code 240 | if isinstance(pos, File): 241 | return group_code 242 | if isinstance(pos, Namespace): 243 | namespace_names = tuple( 244 | namespaces[namespace_id].name 245 | for namespace_id in [*pos.namespaces, pos.id] 246 | ) 247 | for failed_names in failed: 248 | if namespace_names == failed_names[1 : len(namespace_names) + 1]: 249 | return group_code 250 | return 0 251 | 252 | if ( 253 | pos.name, 254 | *[namespaces[namespace_id].name for namespace_id in pos.namespaces], 255 | ) in failed: 256 | return group_code 257 | 258 | return 0 259 | 260 | def _get_failed_set( 261 | self, parsed_failures: Iterator[ParseResult], tree: Tree[Position] 262 | ) -> Set[Tuple[str, ...]]: 263 | def from_root(namespaces: List[str]): 264 | for index, namespace in enumerate(namespaces): 265 | if namespace == tree.data.name: 266 | return namespaces[index:] 267 | 268 | logger.warn( 269 | f"No namespaces found from root {tree.data.name} in parsed result {namespaces}" 270 | ) 271 | return [] 272 | 273 | return { 274 | ( 275 | failed.name, 276 | *( 277 | from_root(failed.namespaces) 278 | if not isinstance(tree.data, File) 279 | else failed.namespaces 280 | ), 281 | ) 282 | for failed in parsed_failures 283 | } 284 | 285 | def _register_started( 286 | self, position: Position, on_start: Callable[[Position], None] 287 | ): 288 | logger.fdebug("Registering {position.id} as started") 289 | position.running = 1 290 | self._running.add(position.id) 291 | on_start(position) 292 | 293 | def _register_result( 294 | self, 295 | position: Position, 296 | result: Result, 297 | on_finish: Callable[[Position, Result], None], 298 | ): 299 | logger.fdebug("Registering {position.id} as exited with result {result}") 300 | self._results[position.file][position.id] = result 301 | self._running.remove(position.id) 302 | on_finish(position, result) 303 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/handler/runner/attach.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import os 3 | import readline # type: ignore 4 | import select 5 | import sys 6 | import time 7 | from threading import Thread 8 | 9 | IN_FILE = "{IN_FILE}" 10 | OUT_FILE = "{OUT_FILE}" 11 | 12 | 13 | def forward_fd(from_fd: int, to_fd: int) -> Thread: 14 | def forward(): 15 | try: 16 | while True: 17 | ready, _, _ = select.select([from_fd], [], []) 18 | for fd in ready: 19 | try: 20 | data = os.read(fd, 512) 21 | if not data: # EOF 22 | time.sleep(0.1) 23 | break 24 | os.write(to_fd, data) 25 | except OSError as e: 26 | if e.errno != errno.EIO: 27 | raise 28 | # EIO means EOF on some systems 29 | break 30 | except: 31 | ... 32 | 33 | thread = Thread(target=forward, daemon=True) 34 | thread.start() 35 | return thread 36 | 37 | 38 | def run(): 39 | output_thread = forward_fd(os.open(OUT_FILE, os.O_RDONLY), sys.stdout.fileno()) 40 | 41 | # Use a non visible prompt to prevent readline overwriting text from stdout 42 | # on the last line, where stdin is also written. This is still not perfect, 43 | # for example vi bindings still allow deleting prompt text. 44 | try: 45 | if IN_FILE: 46 | PROMPT = "\010" 47 | 48 | to_input = os.open(IN_FILE, os.O_WRONLY) 49 | try: 50 | while True: 51 | in_ = input(PROMPT) + "\n" 52 | os.write(to_input, in_.encode()) 53 | except BaseException: 54 | pass 55 | 56 | else: 57 | output_thread.join() 58 | except: 59 | ... 60 | 61 | 62 | if __name__ == "__main__": 63 | run() 64 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/handler/runner/handle.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import os 3 | import pty 4 | import select 5 | from contextlib import contextmanager 6 | from io import BufferedReader, BufferedWriter 7 | from threading import Event, Thread 8 | from typing import Iterator, Tuple, Union 9 | 10 | from ...logging import get_logger 11 | 12 | 13 | class ProcessIOHandle: 14 | """ 15 | IO handle for a process which opens a named pipe for input to emulate an interactive session. 16 | """ 17 | 18 | def __init__(self, in_path: str, out_path: str): 19 | self.in_path = in_path 20 | self.out_path = out_path 21 | self._close_event = Event() 22 | self._logger = get_logger() 23 | 24 | @contextmanager 25 | def open( 26 | self, use_pty: bool 27 | ) -> Iterator[Tuple[BufferedReader, Union[int, BufferedWriter]]]: 28 | """ 29 | Open a reader, writer pair for processes to use for stdin & stdout/stderr 30 | 31 | To allow for other processes to send input without closing the pipe, a 32 | thread is spawned which holds it open until this function exits. 33 | """ 34 | if use_pty: 35 | in_file = self._open_stdin() 36 | master_out_fd, slave_out_fd = pty.openpty() 37 | out_file = self._open_stdout() 38 | self._forward_output(master_out_fd, out_file) 39 | else: 40 | in_file = self._open_stdin() 41 | out_file = self._open_stdout() 42 | try: 43 | yield (in_file, slave_out_fd if use_pty else out_file) 44 | finally: 45 | out_file.close() 46 | 47 | self._close_event.set() 48 | in_file.close() 49 | if use_pty: 50 | os.close(master_out_fd) 51 | os.remove(self.in_path) 52 | 53 | def _open_stdin(self) -> BufferedReader: 54 | if os.path.exists(self.in_path): 55 | os.remove(self.in_path) 56 | os.mkfifo(self.in_path, 0o777) 57 | if self._close_event.is_set(): 58 | raise IOError(f"Handle is already open for {self.in_path}") 59 | self._keep_stdin_open() 60 | return open(self.in_path, "rb") 61 | 62 | def _open_stdout(self) -> BufferedWriter: 63 | if os.path.exists(self.out_path): 64 | os.remove(self.out_path) 65 | return open(self.out_path, "wb") 66 | 67 | def _keep_stdin_open(self): 68 | def keep_open(): 69 | with open(self.in_path, "wb"): 70 | self._close_event.wait() 71 | self._close_event.clear() 72 | 73 | Thread(target=keep_open).start() 74 | 75 | def _forward_output(self, out_fd: int, out_file: BufferedWriter): 76 | # Inspired by https://stackoverflow.com/questions/52954248/capture-output-as-a-tty-in-python 77 | def forward(): 78 | while True: 79 | ready, _, _ = select.select([out_fd], [], [], 0.04) 80 | for fd in ready: 81 | try: 82 | data = os.read(fd, 512) 83 | if not data: # EOF 84 | break 85 | self._logger.debug(f"Writing data to output file") 86 | out_file.write(data) 87 | except OSError as e: 88 | if e.errno != errno.EIO: 89 | raise 90 | # EIO means EOF on some systems 91 | break 92 | out_file.flush() 93 | 94 | Thread(target=forward).start() 95 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/handler/runner/processes.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | import re 4 | import tempfile 5 | from asyncio import CancelledError, subprocess 6 | from os import path 7 | from typing import Dict, List, Optional, Tuple 8 | 9 | from ...logging import get_logger 10 | from ...vim_client import VimClient 11 | from .handle import ProcessIOHandle 12 | 13 | logger = get_logger() 14 | 15 | 16 | class ProcessManager: 17 | """ 18 | Handle scheduling and running tests. 19 | """ 20 | 21 | def __init__(self, vim: VimClient): 22 | self._vim = vim 23 | self._dir = tempfile.TemporaryDirectory(prefix="ultest") 24 | self._processes: Dict[str, Optional[ProcessIOHandle]] = {} 25 | self._external_stdout: Dict[str, str] = {} 26 | self._use_pty = self._vim.sync_call("get", "g:", "ultest_use_pty") 27 | 28 | async def run( 29 | self, 30 | cmd: List[str], 31 | group_id: str, 32 | process_id: str, 33 | cwd: Optional[str] = None, 34 | env: Optional[Dict] = None, 35 | ) -> Tuple[int, str]: 36 | """ 37 | Run a test with the given command. 38 | 39 | Constucts a result from the given test. 40 | 41 | :param cmd: Command arguments to run 42 | :return: Exit code and path to file containing stdout/stderr 43 | """ 44 | 45 | parent_dir = self._create_group_dir(group_id) 46 | stdin_path = path.join(parent_dir, f"{self._safe_file_name(process_id)}_in") 47 | stdout_path = path.join(parent_dir, f"{self._safe_file_name(process_id)}_out") 48 | io_handle = ProcessIOHandle(in_path=stdin_path, out_path=stdout_path) 49 | self._processes[process_id] = io_handle 50 | logger.fdebug( 51 | "Starting test process {process_id} with command {cmd}, cwd = {cwd}, env = {env}" 52 | ) 53 | try: 54 | async with self._vim.semaphore: 55 | with io_handle.open(use_pty=self._use_pty) as (in_, out_): 56 | try: 57 | process = await subprocess.create_subprocess_exec( 58 | *cmd, 59 | stdin=in_, 60 | stdout=out_, 61 | stderr=out_, 62 | cwd=cwd, 63 | env=env and {**os.environ, **env}, 64 | close_fds=True, 65 | ) 66 | except CancelledError: 67 | raise 68 | except Exception: 69 | logger.warn( 70 | f"An exception was thrown when starting process {process_id} with command: {cmd}", 71 | exc_info=True, 72 | ) 73 | code = 1 74 | else: 75 | code = await process.wait() 76 | logger.fdebug( 77 | "Process {process_id} complete with exit code: {code}" 78 | ) 79 | return (code, stdout_path) 80 | finally: 81 | del self._processes[process_id] 82 | 83 | def _safe_file_name(self, name: str) -> str: 84 | return re.subn(r"[.'\" \\/]", "_", name.replace(os.sep, "__"))[0] 85 | 86 | def _group_dir(self, file: str) -> str: 87 | return os.path.join(self._dir.name, self._safe_file_name(file)) 88 | 89 | def _create_group_dir(self, file: str): 90 | path = self._group_dir(file) 91 | if not os.path.isdir(path): 92 | os.mkdir(path) 93 | return path 94 | 95 | def create_attach_script(self, process_id: str) -> Optional[Tuple[str, str]]: 96 | """ 97 | Create a python script to attach to a running tests process. 98 | 99 | This is a pretty simple hack where we create a script that will show 100 | the output of a test by tailing the test process's stdout which is 101 | written to a temp file, and sending all input to the process's stdin 102 | which is a FIFO/named pipe. 103 | """ 104 | test_process = self._processes.get(process_id) 105 | if test_process: 106 | OUT_FILE = test_process.out_path 107 | IN_FILE = test_process.in_path 108 | else: 109 | OUT_FILE = self._external_stdout.get(process_id) 110 | IN_FILE = None 111 | 112 | if not OUT_FILE: 113 | return None 114 | 115 | from . import attach 116 | 117 | source = inspect.getsource(attach).format(IN_FILE=IN_FILE, OUT_FILE=OUT_FILE) 118 | script_path = os.path.join(self._dir.name, "attach.py") 119 | with open(script_path, "w") as script_file: 120 | script_file.write(source) 121 | return (OUT_FILE, script_path) 122 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/handler/tracker.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Callable, Dict, Optional 3 | 4 | from ..logging import get_logger 5 | from ..models import Tree 6 | from ..vim_client import VimClient 7 | from .parsers import FileParser, Position 8 | from .runner import PositionRunner 9 | 10 | logger = get_logger() 11 | 12 | 13 | class PositionTracker: 14 | def __init__( 15 | self, vim: VimClient, file_parser: FileParser, runner: PositionRunner 16 | ) -> None: 17 | self._vim = vim 18 | self._file_parser = file_parser 19 | self._stored_positions: Dict[str, Tree[Position]] = {} 20 | self._runner = runner 21 | 22 | def update(self, file_name: str, callback: Optional[Callable] = None): 23 | """ 24 | Check for new, moved and removed tests and send appropriate events. 25 | 26 | :param file_name: Name of file to clear results from. 27 | """ 28 | 29 | file_name = self._vim.sync_call("fnamemodify", file_name, ":p") 30 | 31 | if not os.path.isfile(file_name): 32 | return 33 | 34 | vim_patterns = self._get_file_patterns(file_name) 35 | if not vim_patterns: 36 | logger.fdebug("No patterns found for {file_name}") 37 | return 38 | 39 | recorded_tests: Dict[str, Position] = { 40 | test.id: test for test in self._stored_positions.get(file_name, []) 41 | } 42 | if not recorded_tests: 43 | self._init_test_file(file_name) 44 | 45 | self._vim.launch( 46 | self._async_update(file_name, vim_patterns, recorded_tests, callback), 47 | "update_positions", 48 | ) 49 | 50 | async def _async_update( 51 | self, 52 | file_name: str, 53 | vim_patterns: Dict, 54 | recorded_tests: Dict[str, Position], 55 | callback: Optional[Callable], 56 | ): 57 | logger.finfo("Updating positions in {file_name}") 58 | 59 | positions = await self._parse_positions(file_name, vim_patterns) 60 | tests = list(positions) 61 | self._vim.call( 62 | "setbufvar", 63 | file_name, 64 | "ultest_sorted_tests", 65 | [test.id for test in tests], 66 | ) 67 | for test in tests: 68 | if test.id in recorded_tests: 69 | recorded = recorded_tests.pop(test.id) 70 | if recorded.line != test.line: 71 | test.running = self._runner.is_running(test.id) 72 | logger.fdebug( 73 | "Moving test {test.id} from {recorded.line} to {test.line} in {file_name}" 74 | ) 75 | self._vim.call("ultest#process#move", test) 76 | else: 77 | existing_result = self._runner.get_result(test.id, test.file) 78 | if existing_result: 79 | logger.fdebug( 80 | "Replacing test {test.id} to {test.line} in {file_name}" 81 | ) 82 | self._vim.call("ultest#process#replace", test, existing_result) 83 | else: 84 | logger.fdebug("New test {test.id} found in {file_name}") 85 | self._vim.call("ultest#process#new", test) 86 | 87 | self._remove_old_positions(recorded_tests) 88 | self._vim.command("doau User UltestPositionsUpdate") 89 | if callback: 90 | callback() 91 | 92 | def file_positions(self, file: str) -> Optional[Tree[Position]]: 93 | absolute_path = self._vim.sync_call("fnamemodify", file, ":p") 94 | return self._stored_positions.get(absolute_path) 95 | 96 | def _init_test_file(self, file: str): 97 | logger.info(f"Initialising test file {file}") 98 | self._vim.call("setbufvar", file, "ultest_results", {}) 99 | self._vim.call("setbufvar", file, "ultest_tests", {}) 100 | self._vim.call("setbufvar", file, "ultest_sorted_tests", []) 101 | self._vim.call("setbufvar", file, "ultest_file_structure", []) 102 | 103 | def _get_file_patterns(self, file: str) -> Dict: 104 | try: 105 | return self._vim.sync_call("ultest#adapter#get_patterns", file) 106 | except Exception: 107 | logger.exception(f"Error while evaluating patterns for file {file}") 108 | return {} 109 | 110 | async def _parse_positions(self, file: str, vim_patterns: Dict) -> Tree[Position]: 111 | positions = await self._file_parser.parse_file_structure(file, vim_patterns) 112 | self._stored_positions[file] = positions 113 | self._vim.call( 114 | "setbufvar", 115 | file, 116 | "ultest_file_structure", 117 | positions.map(lambda pos: {"type": pos.type, "id": pos.id}).to_list(), 118 | ) 119 | return positions 120 | 121 | def _remove_old_positions(self, positions: Dict[str, Position]): 122 | if positions: 123 | logger.fdebug( 124 | "Removing tests {[recorded for recorded in recorded_tests]} from {file_name}" 125 | ) 126 | for removed in positions.values(): 127 | self._vim.call("ultest#process#clear", removed) 128 | else: 129 | logger.fdebug("No tests removed") 130 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import tempfile 4 | from logging import handlers 5 | 6 | 7 | class UltestLogger(logging.Logger): 8 | def fdebug(self, fstr, *args): 9 | """ 10 | Deferred f-string debug logger 11 | 12 | :param fstr: A string to be evaluated as an f-string 13 | """ 14 | self.__deferred_flog(fstr, logging.DEBUG, *args) 15 | 16 | def finfo(self, fstr, *args): 17 | """ 18 | Deferred f-string info logger 19 | 20 | :param fstr: A string to be evaluated as an f-string 21 | """ 22 | self.__deferred_flog(fstr, logging.INFO, *args) 23 | 24 | def makeRecord( 25 | self, 26 | name, 27 | level, 28 | fn, 29 | lno, 30 | msg, 31 | args, 32 | exc_info, 33 | func=None, 34 | extra=None, 35 | sinfo=None, 36 | ): 37 | rv = logging.getLogRecordFactory()( 38 | name, level, fn, lno, msg, args, exc_info, func, sinfo 39 | ) 40 | if extra is not None: 41 | for key in extra: 42 | rv.__dict__[key] = extra[key] 43 | return rv 44 | 45 | def __deferred_flog(self, fstr, level, *args): 46 | if self.isEnabledFor(level): 47 | try: 48 | import inspect 49 | 50 | frame = inspect.currentframe().f_back.f_back 51 | code = frame.f_code 52 | extra = { 53 | "filename": os.path.split(code.co_filename)[-1], 54 | "funcName": code.co_name, 55 | "lineno": frame.f_lineno, 56 | } 57 | fstr = 'f"' + fstr + '"' 58 | self.log( 59 | level, eval(fstr, frame.f_globals, frame.f_locals), extra=extra 60 | ) 61 | except Exception as e: 62 | self.error(f"Error {e} converting args to str {fstr}") 63 | 64 | 65 | def create_logger() -> UltestLogger: 66 | logfile = os.environ.get( 67 | "ULTEST_LOG_FILE", os.path.join(tempfile.gettempdir(), "vim-ultest.log") 68 | ) 69 | logger = UltestLogger(name="ultest") 70 | if logfile: 71 | env_level = os.environ.get("ULTEST_LOG_LEVEL", "INFO") 72 | level = getattr(logging, env_level.strip(), None) 73 | format = [ 74 | "%(asctime)s", 75 | "%(levelname)s", 76 | "%(threadName)s", 77 | "%(filename)s:%(funcName)s:%(lineno)s", 78 | "%(message)s", 79 | ] 80 | if not isinstance(level, int): 81 | logger.warning("Invalid NVIM_PYTHON_LOG_LEVEL: %r, using INFO.", env_level) 82 | level = logging.INFO 83 | if level >= logging.INFO: 84 | logging.logThreads = 0 85 | logging.logProcesses = 0 86 | logging._srcfile = None 87 | format.pop(-3) 88 | format.pop(-2) 89 | try: 90 | handler = handlers.RotatingFileHandler( 91 | logfile, maxBytes=20 * 1024, backupCount=1 92 | ) 93 | handler.formatter = logging.Formatter( 94 | " | ".join(format), 95 | datefmt="%H:%M:%S", 96 | ) 97 | logger.addHandler(handler) 98 | except PermissionError: 99 | ... 100 | logger.setLevel(level) 101 | logger.info("Logger created") 102 | return logger 103 | 104 | 105 | _logger = None 106 | 107 | 108 | def get_logger() -> UltestLogger: 109 | global _logger 110 | if not _logger: 111 | _logger = create_logger() 112 | return _logger 113 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/models/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from .file import File 4 | from .namespace import Namespace 5 | from .result import Result 6 | from .test import Test 7 | from .tree import Tree 8 | 9 | Position = Union[Test, File, Namespace] 10 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/models/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import asdict, dataclass 3 | from typing import List 4 | 5 | 6 | @dataclass 7 | class BasePosition: 8 | id: str 9 | name: str 10 | file: str 11 | line: int 12 | col: int 13 | running: int 14 | namespaces: List[str] 15 | type: str 16 | 17 | def __str__(self): 18 | props = self.dict() 19 | props["name"] = [ord(char) for char in self.name] 20 | return json.dumps(props) 21 | 22 | def dict(self): 23 | return asdict(self) 24 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/models/file.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List 3 | 4 | from .base import BasePosition 5 | from .types import Literal 6 | 7 | 8 | @dataclass 9 | class File(BasePosition): 10 | running: int = 0 11 | line: Literal[0] = 0 12 | col: Literal[0] = 0 13 | namespaces: List[str] = field(default_factory=list) 14 | type: Literal["file"] = "file" 15 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/models/namespace.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from .base import BasePosition 4 | from .types import Literal 5 | 6 | 7 | @dataclass 8 | class Namespace(BasePosition): 9 | type: Literal["namespace"] = "namespace" 10 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/models/result.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import asdict, dataclass 3 | 4 | 5 | @dataclass 6 | class Result: 7 | 8 | id: str 9 | file: str 10 | code: int 11 | output: str 12 | 13 | def __str__(self): 14 | props = self.dict() 15 | return json.dumps(props) 16 | 17 | def dict(self): 18 | return asdict(self) 19 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/models/test.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from .base import BasePosition 4 | from .types import Literal 5 | 6 | 7 | @dataclass 8 | class Test(BasePosition): 9 | type: Literal["test"] = "test" 10 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/models/tree.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Generic, Iterator, List, Optional, TypeVar 2 | 3 | from .types import Protocol 4 | 5 | TreeData = TypeVar("TreeData") 6 | 7 | 8 | C = TypeVar("C") 9 | 10 | 11 | class Comparabale(Protocol): 12 | def __gt__(self: C, x: C) -> bool: 13 | ... 14 | 15 | def __lt__(self: C, x: C) -> bool: 16 | ... 17 | 18 | def __eq__(self, x) -> bool: 19 | ... 20 | 21 | 22 | SearchKey = TypeVar("SearchKey", bound=Comparabale) 23 | 24 | 25 | class Tree(Generic[TreeData]): 26 | def __init__(self, data: TreeData, children: List["Tree[TreeData]"]) -> None: 27 | self._children: List[Tree[TreeData]] = children 28 | self._data = data 29 | self._length = 1 + sum(len(child) for child in self._children) 30 | 31 | def __repr__(self) -> str: 32 | return f"Tree(data={self._data}, children={self._children})" 33 | 34 | @classmethod 35 | def from_list(cls, data) -> "Tree[TreeData]": 36 | """ 37 | Parses a tree in the shape of nested lists. 38 | 39 | The head of the list is the root of the tree, and all following elements are its children. 40 | """ 41 | if isinstance(data, List): 42 | node_data = data[0] 43 | children = [cls.from_list(child_data) for child_data in data[1:]] 44 | 45 | return Tree(data=node_data, children=children) 46 | else: 47 | return Tree(data=data, children=[]) 48 | 49 | def __len__(self) -> int: 50 | return self._length 51 | 52 | def __getitem__(self, index: int) -> TreeData: 53 | orig = index 54 | if index > len(self): 55 | raise IndexError(f"No node found with index {orig}") 56 | 57 | if index == 0: 58 | return self._data 59 | 60 | checked = 1 61 | for child in self._children: 62 | if len(child) > index - checked: 63 | return child[index - checked] 64 | checked += len(child) 65 | 66 | raise Exception # Shouldn't happen 67 | 68 | def to_list(self): 69 | if not self._children: 70 | return [self._data] 71 | return self._to_list() 72 | 73 | def _to_list(self): 74 | if not self._children: 75 | return self._data 76 | return [self._data, *[child._to_list() for child in self._children]] 77 | 78 | @property 79 | def data(self) -> TreeData: 80 | return self._data 81 | 82 | @property 83 | def children(self) -> List["Tree"]: 84 | return self._children 85 | 86 | def __iter__(self) -> Iterator[TreeData]: 87 | yield self._data 88 | for child in self._children: 89 | for data in child: 90 | yield data 91 | 92 | def nodes(self) -> Iterator["Tree[TreeData]"]: 93 | yield self 94 | for child in self._children: 95 | for data in child.nodes(): 96 | yield data 97 | 98 | def node(self, index: int) -> "Tree[TreeData]": 99 | orig = index 100 | if index > len(self): 101 | raise IndexError(f"No node found with index {orig}") 102 | 103 | if index == 0: 104 | return self 105 | 106 | checked = 1 107 | for child in self._children: 108 | if len(child) > index - checked: 109 | return child.node(index - checked) 110 | checked += len(child) 111 | 112 | raise Exception # Shouldn't happen 113 | 114 | X = TypeVar("X") 115 | 116 | def map(self, f: Callable[[TreeData], X]) -> "Tree[X]": 117 | try: 118 | return Tree( 119 | data=f(self._data), children=[child.map(f) for child in self._children] 120 | ) 121 | except Exception: 122 | breakpoint() 123 | raise 124 | 125 | def sorted_search( 126 | self, 127 | target: SearchKey, 128 | key: Callable[[TreeData], SearchKey], 129 | strict: bool = False, 130 | ) -> Optional["Tree[TreeData]"]: 131 | """ 132 | Search through the tree using binary search to search through children 133 | 134 | :param target: The target value to find 135 | :param key: Function to return a value to sort nodes with 136 | :param strict: The search will only return an exact match, defaults to False 137 | :return: The matching node, or nearest one if not strict 138 | """ 139 | l = 0 140 | r = len(self) - 1 141 | while l <= r: 142 | m = int((l + r) / 2) 143 | mid = self.node(m) 144 | if key(mid.data) < target: 145 | l = m + 1 146 | elif key(mid.data) > target: 147 | r = m - 1 148 | else: 149 | return mid 150 | 151 | if r < 0: 152 | return None 153 | 154 | return self.node(r) if not strict and key(self[r]) < target else None 155 | 156 | def search( 157 | self, 158 | target: SearchKey, 159 | key: Callable[[TreeData], SearchKey], 160 | ) -> Optional["Tree[TreeData]"]: 161 | """ 162 | Search through the tree using depth first search 163 | 164 | :param target: The target value to find 165 | :param key: Function to return a value to sort nodes with 166 | :return: The matching node 167 | """ 168 | if target == key(self.data): 169 | return self 170 | if not self.children: 171 | return None 172 | for child in self.children: 173 | result = child.search(target, key) 174 | if result: 175 | return result 176 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/models/types.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Any 3 | 4 | if sys.version_info >= (3, 8): 5 | from typing import Literal, Protocol 6 | else: 7 | 8 | class _Literal: 9 | def __getitem__(self, a): 10 | return Any 11 | 12 | Literal = _Literal() 13 | 14 | class Protocol: 15 | ... 16 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/vim_client/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Coroutine, List 2 | 3 | from pynvim import Nvim 4 | 5 | from .jobs import JobManager 6 | 7 | 8 | class VimClient: 9 | def __init__(self, vim_: Nvim): 10 | self._vim = vim_ 11 | num_threads = int(self.sync_eval("g:ultest_max_threads")) # type: ignore 12 | self._job_manager = JobManager(num_threads) 13 | 14 | @property 15 | def semaphore(self): 16 | return self._job_manager.semaphore 17 | 18 | def message(self, message, sync=False): 19 | if not isinstance(message, str) or not message.endswith("\n"): 20 | message = str(message) + "\n" 21 | if sync: 22 | self._vim.out_write(message) 23 | else: 24 | self.schedule(self._vim.out_write, message) 25 | 26 | def schedule(self, func: Callable, *args, **kwargs) -> None: 27 | """ 28 | Schedule a function to be called on Vim thread. 29 | 30 | :param func: Function to run. 31 | :param *args: Positional args for function. 32 | :param **kwargs: Keywords args for function. 33 | """ 34 | self._vim.async_call(func, *args, **kwargs) 35 | 36 | def launch( 37 | self, 38 | func: Coroutine, 39 | job_group: str, 40 | ) -> None: 41 | """ 42 | Launch a function to be run on a separate thread. 43 | 44 | :param func: Function to run. 45 | :param *args: Positional args for function. 46 | :param **kwargs: kwargs for function. 47 | """ 48 | self._job_manager.run(func, job_group=job_group) 49 | 50 | def stop(self, job_group: str) -> bool: 51 | """ 52 | Stop all jobs associated with the given job group 53 | 54 | :param job_group: group for a group of jobs 55 | """ 56 | return self._job_manager.stop_jobs(job_group) 57 | 58 | def command( 59 | self, 60 | command: str, 61 | *args, 62 | **kwargs, 63 | ): 64 | """ 65 | Call a Vim command asynchronously. This can be called from a thread to be 66 | scheduled on the main Vim thread. 67 | Args are supplied first. Kwargs are supplied after in the format "name=value" 68 | 69 | :param command: Command to run 70 | :param callback: Function to supply resulting output to. 71 | """ 72 | 73 | def runner(): 74 | expr = self.construct_command(command, *args, **kwargs) 75 | self._vim.command(expr, async_=True) 76 | 77 | self.schedule(runner) 78 | 79 | def sync_command(self, command: str, *args, **kwargs) -> List[str]: 80 | """ 81 | Call a Vim command. 82 | Args are supplied first. Kwargs are supplied after in the format "name=value" 83 | 84 | :param command: Command to run 85 | """ 86 | expr = self.construct_command(command, *args, **kwargs) 87 | output = self._vim.command_output(expr) 88 | return output.splitlines() if output else [] # type: ignore 89 | 90 | def construct_command(self, command, *args, **kwargs): 91 | args_str = " ".join(f"{arg}" for arg in args) 92 | kwargs_str = " ".join(f" {name}={val}" for name, val in kwargs.items()) 93 | return f"{command} {args_str} {kwargs_str}" 94 | 95 | def call(self, func: str, *args) -> None: 96 | """ 97 | Call a vimscript function asynchronously. This can be called 98 | from a different thread to main Vim thread. 99 | 100 | :param func: Name of function to call. 101 | :param args: Arguments for the function. 102 | :rtype: None 103 | """ 104 | expr = self.construct_function(func, *args) 105 | 106 | def runner(): 107 | self._eval(expr, sync=False) 108 | 109 | self.schedule(runner) 110 | 111 | def sync_call(self, func: str, *args) -> Any: 112 | """ 113 | Call a vimscript function from the main Vim thread. 114 | 115 | :param func: Name of function to call. 116 | :param args: Arguments for the function. 117 | :return: Result of function call. 118 | :rtype: Any 119 | """ 120 | expr = self.construct_function(func, *args) 121 | return self._eval(expr, sync=True) 122 | 123 | def eval(self, expr: str) -> Any: 124 | return self._eval(expr, sync=False) 125 | 126 | def sync_eval(self, expr: str) -> Any: 127 | return self._eval(expr, sync=True) 128 | 129 | def construct_function(self, func: str, *args): 130 | func_args = ", ".join(self._convert_arg(arg) for arg in args) 131 | return f"{func}({func_args})" 132 | 133 | def _eval(self, expr: str, sync: bool): 134 | return self._vim.eval(expr, async_=not sync) 135 | 136 | def _convert_arg(self, arg): 137 | if isinstance(arg, str) and self._needs_quotes(arg): 138 | return f"'{arg}'" 139 | if isinstance(arg, bool): 140 | arg = 1 if arg else 0 141 | return str(arg) 142 | 143 | def _needs_quotes(self, arg: str) -> bool: 144 | if not any(char in arg for char in "\"'("): 145 | return not (len(arg) == 2 and arg[1] == ":") 146 | return "://" in arg 147 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/vim_client/jobs/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | import traceback 4 | from asyncio import CancelledError, Event, Semaphore 5 | from collections import defaultdict 6 | from typing import Coroutine, Dict 7 | from uuid import uuid4 8 | 9 | from ...logging import get_logger 10 | 11 | logger = get_logger() 12 | 13 | 14 | class JobManager: 15 | def __init__(self, num_threads: int = 2): 16 | self._jobs: defaultdict[str, Dict[str, Event]] = defaultdict(dict) 17 | self._loop = asyncio.get_event_loop() 18 | self._sem = Semaphore(num_threads) 19 | if sys.version_info < (3, 8): 20 | # Use the new default watcher from >= 3.8, implemented locally 21 | # https://bugs.python.org/issue35621 22 | from .watcher import ThreadedChildWatcher 23 | 24 | logger.info("Using local threaded child watcher") 25 | asyncio.set_child_watcher(ThreadedChildWatcher()) 26 | self._sem = Semaphore(num_threads, loop=self._loop) 27 | else: 28 | self._sem = Semaphore(num_threads) 29 | 30 | @property 31 | def semaphore(self) -> Semaphore: 32 | return self._sem 33 | 34 | def run(self, cor: Coroutine, job_group: str): 35 | job_id = str(uuid4()) 36 | # loop parameter has been deprecated since version 3.8 37 | # and will be removed in version 3.10 but loop parameters 38 | # still required for python <3.10 39 | cancel_event = Event() 40 | wrapped_cor = self._handle_coroutine( 41 | cor, job_group=job_group, job_id=job_id, cancel_event=cancel_event 42 | ) 43 | asyncio.run_coroutine_threadsafe(wrapped_cor, loop=self._loop) 44 | self._jobs[job_group][job_id] = cancel_event 45 | 46 | def stop_jobs(self, group: str) -> bool: 47 | logger.finfo("Stopping jobs in group {group}") 48 | cancel_events = self._jobs[group] 49 | if not cancel_events: 50 | logger.finfo("No jobs found for group {group}") 51 | return False 52 | for cancel_event in cancel_events.values(): 53 | self._loop.call_soon_threadsafe(cancel_event.set) 54 | return True 55 | 56 | async def _handle_coroutine( 57 | self, cor: Coroutine, job_group: str, job_id: str, cancel_event: Event 58 | ): 59 | try: 60 | logger.fdebug("Starting job with group {job_group}") 61 | run_task = asyncio.create_task(cor) 62 | cancel_task = asyncio.create_task(cancel_event.wait()) 63 | try: 64 | done, _ = await asyncio.wait( 65 | [run_task, cancel_task], 66 | return_when=asyncio.FIRST_COMPLETED, 67 | ) 68 | except CancelledError: 69 | logger.exception(f"Task was cancelled prematurely {run_task}") 70 | else: 71 | if run_task in done: 72 | e = run_task.exception() 73 | if e: 74 | logger.warn(f"Exception throw in job: {e}") 75 | logger.warn( 76 | "\n".join( 77 | traceback.format_exception(type(e), e, e.__traceback__) 78 | ) 79 | ) 80 | logger.fdebug("Finished job with group {job_group}") 81 | else: 82 | run_task.cancel() 83 | logger.fdebug("Cancelled running job with group {job_group}") 84 | except CancelledError: 85 | logger.exception("Job runner cancelled") 86 | raise 87 | except Exception: 88 | logger.exception("Error running job") 89 | finally: 90 | self._jobs[job_group].pop(job_id) 91 | -------------------------------------------------------------------------------- /rplugin/python3/ultest/vim_client/jobs/watcher.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import os 3 | import threading 4 | import warnings 5 | from asyncio import events 6 | from asyncio.log import logger 7 | from asyncio.unix_events import AbstractChildWatcher 8 | 9 | 10 | def _compute_returncode(status): 11 | if os.WIFSIGNALED(status): 12 | # The child process died because of a signal. 13 | return -os.WTERMSIG(status) 14 | elif os.WIFEXITED(status): 15 | # The child process exited (e.g sys.exit()). 16 | return os.WEXITSTATUS(status) 17 | else: 18 | # The child exited, but we don't understand its status. 19 | # This shouldn't happen, but if it does, let's just 20 | # return that status; perhaps that helps debug it. 21 | return status 22 | 23 | 24 | # Ripped directly from https://github.com/python/cpython/blob/4649202ea75d48e1496e99911709824ca2d3170e/Lib/asyncio/unix_events.py#L1326 25 | 26 | 27 | class ThreadedChildWatcher(AbstractChildWatcher): 28 | """Threaded child watcher implementation. 29 | The watcher uses a thread per process 30 | for waiting for the process finish. 31 | It doesn't require subscription on POSIX signal 32 | but a thread creation is not free. 33 | The watcher has O(1) complexity, its performance doesn't depend 34 | on amount of spawn processes. 35 | """ 36 | 37 | def __init__(self): 38 | self._pid_counter = itertools.count(0) 39 | self._threads = {} 40 | 41 | def is_active(self): 42 | return True 43 | 44 | def close(self): 45 | self._join_threads() 46 | 47 | def _join_threads(self): 48 | """Internal: Join all non-daemon threads""" 49 | threads = [ 50 | thread 51 | for thread in list(self._threads.values()) 52 | if thread.is_alive() and not thread.daemon 53 | ] 54 | for thread in threads: 55 | thread.join() 56 | 57 | def __enter__(self): 58 | return self 59 | 60 | def __exit__(self, exc_type, exc_val, exc_tb): 61 | pass 62 | 63 | def __del__(self, _warn=warnings.warn): 64 | threads = [ 65 | thread for thread in list(self._threads.values()) if thread.is_alive() 66 | ] 67 | if threads: 68 | _warn( 69 | f"{self.__class__} has registered but not finished child processes", 70 | ResourceWarning, 71 | source=self, 72 | ) 73 | 74 | def add_child_handler(self, pid, callback, *args): 75 | loop = events.get_running_loop() 76 | thread = threading.Thread( 77 | target=self._do_waitpid, 78 | name=f"waitpid-{next(self._pid_counter)}", 79 | args=(loop, pid, callback, args), 80 | daemon=True, 81 | ) 82 | self._threads[pid] = thread 83 | thread.start() 84 | 85 | def remove_child_handler(self, pid): 86 | # asyncio never calls remove_child_handler() !!! 87 | # The method is no-op but is implemented because 88 | # abstract base class requires it 89 | return True 90 | 91 | def attach_loop(self, loop): 92 | pass 93 | 94 | def _do_waitpid(self, loop, expected_pid, callback, args): 95 | assert expected_pid > 0 96 | 97 | try: 98 | pid, status = os.waitpid(expected_pid, 0) 99 | except ChildProcessError: 100 | # The child process is already reaped 101 | # (may happen if waitpid() is called elsewhere). 102 | pid = expected_pid 103 | returncode = 255 104 | logger.warning( 105 | "Unknown child process pid %d, will report returncode 255", pid 106 | ) 107 | else: 108 | returncode = _compute_returncode(status) 109 | if loop.get_debug(): 110 | logger.debug( 111 | "process %s exited with returncode %s", expected_pid, returncode 112 | ) 113 | 114 | if loop.is_closed(): 115 | logger.warning("Loop %r that handles pid %r is closed", loop, pid) 116 | else: 117 | loop.call_soon_threadsafe(callback, pid, returncode, *args) 118 | 119 | self._threads.pop(expected_pid) 120 | -------------------------------------------------------------------------------- /scripts/check-commits: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cz check --rev-range master..HEAD 4 | -------------------------------------------------------------------------------- /scripts/style: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | PYTHON_DIRS=("rplugin/python3/ultest" "tests") 6 | 7 | if [[ $1 == "-w" ]]; then 8 | black "${PYTHON_DIRS[@]}" 9 | isort "${PYTHON_DIRS[@]}" 10 | autoflake --remove-unused-variables --remove-all-unused-imports --ignore-init-module-imports --remove-duplicate-keys --recursive -i "${PYTHON_DIRS[@]}" 11 | find -name \*.lua -print0 | xargs -0 luafmt -w replace -i 2 12 | else 13 | black --check "${PYTHON_DIRS[@]}" 14 | isort --check "${PYTHON_DIRS[@]}" 15 | autoflake --remove-unused-variables --remove-all-unused-imports --ignore-init-module-imports --remove-duplicate-keys --recursive "${PYTHON_DIRS[@]}" 16 | fi 17 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | PYTHON_DIR="rplugin/python3/ultest" 4 | 5 | pytest \ 6 | --cov-branch \ 7 | --cov=${PYTHON_DIR} \ 8 | --cov-report xml:coverage/coverage.xml \ 9 | --cov-report term \ 10 | --cov-report html:coverage 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [mypy] 2 | warn_unused_ignores=True 3 | warn_redundant_casts=True 4 | ignore_missing_imports=True 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | sys.path 4 | sys.path.append("rplugin/python3/") 5 | 6 | import ultest # type: ignore 7 | 8 | sys.modules["rplugin.python3.ultest"] = ultest 9 | 10 | import hypothesis 11 | from hypothesis import settings 12 | 13 | settings.register_profile( 14 | "default", max_examples=1, suppress_health_check=[hypothesis.HealthCheck.too_slow] 15 | ) 16 | 17 | settings.load_profile("default") 18 | -------------------------------------------------------------------------------- /tests/mocks/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List 3 | 4 | 5 | def get_output(runner: str) -> List[str]: 6 | dirname = os.path.dirname(__file__) 7 | filename = os.path.join(dirname, "test_outputs", runner) 8 | with open(filename) as output: 9 | return output.readlines() 10 | 11 | 12 | def get_test_file(name: str) -> str: 13 | dirname = os.path.dirname(__file__) 14 | return os.path.join(dirname, "test_files", name) 15 | -------------------------------------------------------------------------------- /tests/mocks/test_files/java: -------------------------------------------------------------------------------- 1 | import org.junit.Test; 2 | import org.junit.Ignore; 3 | import static org.junit.Assert.assertEquals; 4 | 5 | public class TestJunit1 { 6 | 7 | String message = "Robert"; 8 | MessageUtil messageUtil = new MessageUtil(message); 9 | 10 | @Test 11 | public void testPrintMessage() { 12 | System.out.println("Inside testPrintMessage()"); 13 | assertEquals(message, messageUtil.printMessage()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/mocks/test_files/jest: -------------------------------------------------------------------------------- 1 | describe("First namespace", () => { 2 | describe("Second namespace", () => { 3 | test("it shouldn't pass", () => { 4 | expect(true).toBeFalsy() 5 | }); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /tests/mocks/test_files/python: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | 4 | def test_a(): 5 | 6 | x = 3 7 | 8 | 9 | class TestAgain(TestCase): 10 | def test_d(self): # type: ignore 11 | class MyClass: 12 | ... 13 | 14 | assert 3 == 3 15 | 16 | def test_a(self): # type: ignore 17 | class MyClass: 18 | def test_a(self): # type: ignore 19 | class MyClass: 20 | ... 21 | 22 | assert 33 == 3 23 | 24 | 25 | class TestMyClass(TestCase): 26 | def test_d(self): 27 | assert 33 == 3 28 | 29 | def test_a(self): 30 | assert 33 == 3 31 | 32 | 33 | def test_b(self): 34 | assert 33 == 3 35 | 36 | -------------------------------------------------------------------------------- /tests/mocks/test_outputs/exunit: -------------------------------------------------------------------------------- 1 | . 2 | 3 | 1) test the world (BarTest) 4 | test/foo_test.exs:16 5 | Assertion with == failed 6 | code: assert Foo.hello() == :world 7 | left: :worlds 8 | right: :world 9 | stacktrace: 10 | test/foo_test.exs:17: (test) 11 | 12 | . 13 | 14 | 2) test greets the world (FooTest) 15 | test/foo_test.exs:4 16 | Assertion with == failed 17 | code: assert Foo.hello() == :world 18 | left: :worlds 19 | right: :world 20 | stacktrace: 21 | test/foo_test.exs:5: (test) 22 | 23 | 24 | 25 | Finished in 0.03 seconds 26 | 4 tests, 2 failures 27 | 28 | Randomized with seed 515654 29 | -------------------------------------------------------------------------------- /tests/mocks/test_outputs/gotest: -------------------------------------------------------------------------------- 1 | user_output--- FAIL: TestA (0.00s) 2 | main_test.go:10: 2 != 2 3 | --- FAIL: TestB (0.00s) 4 | main_test.go:19: 2 != 2 5 | FAIL 6 | FAIL command-line-arguments 0.002s 7 | FAIL 8 | -------------------------------------------------------------------------------- /tests/mocks/test_outputs/jest: -------------------------------------------------------------------------------- 1 |  FAIL  spec/ultest a.test.ts 2 | ✕ it shouldn't pass again 3 | ✓ it shouldn pass (1 ms) 4 | First namespace 5 | Another namespace 6 | ✕ it shouldn't pass (3 ms) 7 | 8 |  ● First namespace › Another namespace › it shouldn't pass 9 | 10 | expect(received).toBeFalsy() 11 | 12 | Received: true 13 |  14 |    2 |  describe("Another namespace", () => { 15 |    3 |  test("it shouldn't pass", () => { 16 |  > 4 |  expect(true).toBeFalsy() 17 |    |  ^ 18 |    5 |  }); 19 |    6 |  }); 20 |    7 | }); 21 |  22 |  at Object. (spec/ultest a.test.ts:4:20) 23 |  at processTicksAndRejections (node:internal/process/task_queues:94:5) 24 | 25 |  ● it shouldn't pass again 26 | 27 | expect(received).toBeFalsy() 28 | 29 | Received: true 30 |  31 |    7 | }); 32 |    8 | test("it shouldn't pass again", () => { 33 |  > 9 |  expect(true).toBeFalsy() 34 |    |  ^ 35 |    10 | }); 36 |    11 | test("it shouldn pass", () => { 37 |    12 |  expect(false).toBeFalsy() 38 |  39 |  at Object. (spec/ultest a.test.ts:9:16) 40 |  at processTicksAndRejections (node:internal/process/task_queues:94:5) 41 | 42 | Test Suites: 1 failed, 1 total 43 | Tests: 2 failed, 1 passed, 3 total 44 | Snapshots: 0 total 45 | Time: 1.658 s 46 | Ran all test suites matching /spec\/ultest a.test.ts/i. 47 | 48 | -------------------------------------------------------------------------------- /tests/mocks/test_outputs/pytest: -------------------------------------------------------------------------------- 1 | ============================= test session starts ============================== 2 | platform linux -- Python 3.9.2, pytest-6.2.3, py-1.10.0, pluggy-0.13.1 3 | rootdir: /home/ronan/tests 4 | plugins: cov-2.11.1 5 | collected 3 items 6 | 7 | test_a.py F.E [100%] 8 | 9 | ==================================== ERRORS ==================================== 10 | ___________________________ ERROR at setup of test_a ___________________________ 11 | file /home/ronan/tests/test_a.py, line 17 12 | def test_a(self): 13 | E fixture 'self' not found 14 | > available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, cov, doctest_namespace, monkeypatch, no_cover, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory 15 | > use 'pytest --fixtures [testpath]' for help on them. 16 | 17 | /home/ronan/tests/test_a.py:17 18 | =================================== FAILURES =================================== 19 | ______________________________ TestMyClass.test_d ______________________________ 20 | 21 | self = 22 | 23 | def test_d(self): # type: ignore 24 | class MyClass: 25 | ... 26 | > assert 33 == 3 27 | E AssertionError: assert 33 == 3 28 | 29 | test_a.py:7: AssertionError 30 | =========================== short test summary info ============================ 31 | FAILED test_a.py::TestMyClass::test_d - AssertionError: assert 33 == 3 32 | FAILED test_a.py::test_parametrize[5] - assert 5 == 3 33 | ERROR test_a.py::test_a 34 | ===================== 1 failed, 1 passed, 1 error in 0.07s ===================== 35 | -------------------------------------------------------------------------------- /tests/mocks/test_outputs/pyunit: -------------------------------------------------------------------------------- 1 | F. 2 | ====================================================================== 3 | FAIL: test_d (test_a.TestMyClass) 4 | ---------------------------------------------------------------------- 5 | Traceback (most recent call last): 6 | File "/home/ronan/tests/test_a.py", line 9, in test_d 7 | assert 33 == 3 8 | AssertionError 9 | 10 | ---------------------------------------------------------------------- 11 | Ran 2 tests in 0.001s 12 | 13 | FAILED (failures=1) 14 | -------------------------------------------------------------------------------- /tests/mocks/test_outputs/richgo: -------------------------------------------------------------------------------- 1 | FAIL | A (0.00s) 2 |  | main_test.go:10: 2 != 2 3 | FAIL | AAAB (0.00s) 4 |  | main_test.go:19: 2 != 2 5 | FAIL 6 |  | exit status 1 7 | FAIL | test 0.001s 8 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | from hypothesis import settings 2 | 3 | settings.register_profile("ci", max_examples=10) 4 | settings.load_profile("ci") 5 | 6 | 7 | def test_init(): 8 | assert True == True 9 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcarriga/vim-ultest/b06bc8715cbcb4aa0444abfd85fb705b659ba055/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/handler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcarriga/vim-ultest/b06bc8715cbcb4aa0444abfd85fb705b659ba055/tests/unit/handler/__init__.py -------------------------------------------------------------------------------- /tests/unit/handler/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcarriga/vim-ultest/b06bc8715cbcb4aa0444abfd85fb705b659ba055/tests/unit/handler/parsers/__init__.py -------------------------------------------------------------------------------- /tests/unit/handler/parsers/test_file.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | 5 | from rplugin.python3.ultest.handler.parsers import FileParser 6 | from rplugin.python3.ultest.models import Namespace, Test 7 | from rplugin.python3.ultest.models.file import File 8 | from rplugin.python3.ultest.models.namespace import Namespace 9 | from tests.mocks import get_test_file 10 | 11 | vim = Mock() 12 | vim.launch = lambda f, _: f() 13 | file_parser = FileParser(vim) 14 | 15 | 16 | @patch("os.path.isfile", lambda _: True) 17 | @pytest.mark.asyncio 18 | async def test_parse_python_tests(): 19 | patterns = { 20 | "test": [r"\v^\s*%(async )?def (test_\w+)"], 21 | "namespace": [r"\v^\s*class (\w+)"], 22 | } 23 | 24 | file_name = get_test_file("python") 25 | tests = list(await file_parser.parse_file_structure(file_name, patterns)) 26 | 27 | expected = [ 28 | File( 29 | id=file_name, 30 | name=file_name, 31 | file=file_name, 32 | line=0, 33 | col=0, 34 | namespaces=[], 35 | type="file", 36 | ), 37 | Test( 38 | id=tests[1].id, 39 | name="test_a", 40 | file=file_name, 41 | line=4, 42 | col=1, 43 | running=0, 44 | namespaces=[], 45 | type="test", 46 | ), 47 | Namespace( 48 | id=tests[2].id, 49 | name="TestAgain", 50 | file=file_name, 51 | line=9, 52 | col=1, 53 | running=0, 54 | namespaces=[], 55 | type="namespace", 56 | ), 57 | Test( 58 | id=tests[3].id, 59 | name="test_d", 60 | file=file_name, 61 | line=10, 62 | col=1, 63 | running=0, 64 | namespaces=[tests[2].id], 65 | type="test", 66 | ), 67 | Test( 68 | id=tests[4].id, 69 | name="test_a", 70 | file=file_name, 71 | line=16, 72 | col=1, 73 | running=0, 74 | namespaces=[tests[2].id], 75 | type="test", 76 | ), 77 | Namespace( 78 | id=tests[5].id, 79 | name="TestMyClass", 80 | file=file_name, 81 | line=25, 82 | col=1, 83 | running=0, 84 | namespaces=[], 85 | type="namespace", 86 | ), 87 | Test( 88 | id=tests[6].id, 89 | name="test_d", 90 | file=file_name, 91 | line=26, 92 | col=1, 93 | running=0, 94 | namespaces=[tests[5].id], 95 | type="test", 96 | ), 97 | Test( 98 | id=tests[7].id, 99 | name="test_a", 100 | file=file_name, 101 | line=29, 102 | col=1, 103 | running=0, 104 | namespaces=[tests[5].id], 105 | type="test", 106 | ), 107 | Test( 108 | id=tests[8].id, 109 | name="test_b", 110 | file=file_name, 111 | line=33, 112 | col=1, 113 | running=0, 114 | namespaces=[], 115 | type="test", 116 | ), 117 | ] 118 | 119 | assert tests == expected 120 | 121 | 122 | @patch("os.path.isfile", lambda _: True) 123 | @pytest.mark.asyncio 124 | async def test_parse_java_tests(): 125 | patterns = { 126 | "test": [r"\v^\s*%(\zs\@Test\s+\ze)?%(\zspublic\s+\ze)?void\s+(\w+)"], 127 | "namespace": [r"\v^\s*%(\zspublic\s+\ze)?class\s+(\w+)"], 128 | } 129 | 130 | file_name = get_test_file("java") 131 | tests = list(await file_parser.parse_file_structure(file_name, patterns)) 132 | 133 | expected = [ 134 | File( 135 | id=file_name, 136 | name=file_name, 137 | file=file_name, 138 | line=0, 139 | col=0, 140 | running=0, 141 | namespaces=[], 142 | type="file", 143 | ), 144 | Namespace( 145 | id=tests[1].id, 146 | name="TestJunit1", 147 | file=file_name, 148 | line=5, 149 | col=1, 150 | running=0, 151 | namespaces=[], 152 | type="namespace", 153 | ), 154 | Test( 155 | id=tests[2].id, 156 | name="testPrintMessage", 157 | file=file_name, 158 | line=11, 159 | col=1, 160 | running=0, 161 | namespaces=[tests[1].id], 162 | type="test", 163 | ), 164 | ] 165 | 166 | assert tests == expected 167 | 168 | 169 | @patch("os.path.isfile", lambda _: True) 170 | @pytest.mark.asyncio 171 | async def test_parse_jest_tests(): 172 | patterns = { 173 | "test": [r'\v^\s*%(it|test)\s*[( ]\s*%("|' '|`)(.*)%("|' "|`)"], 174 | "namespace": [ 175 | r'\v^\s*%(describe|suite|context)\s*[( ]\s*%("|' '|`)(.*)%("|' "|`)" 176 | ], 177 | } 178 | 179 | file_name = get_test_file("jest") 180 | tests = list(await file_parser.parse_file_structure(file_name, patterns)) 181 | 182 | expected = [ 183 | File( 184 | id=file_name, 185 | name=file_name, 186 | file=file_name, 187 | line=0, 188 | col=0, 189 | running=0, 190 | namespaces=[], 191 | type="file", 192 | ), 193 | Namespace( 194 | id=tests[1].id, 195 | name='First namespace", () => {', 196 | file=file_name, 197 | line=1, 198 | col=1, 199 | running=0, 200 | namespaces=[], 201 | type="namespace", 202 | ), 203 | Namespace( 204 | id=tests[2].id, 205 | name='Second namespace", () => {', 206 | file=file_name, 207 | line=2, 208 | col=1, 209 | running=0, 210 | namespaces=[tests[1].id], 211 | type="namespace", 212 | ), 213 | Test( 214 | id=tests[3].id, 215 | name="it shouldn't pass\", () => {", 216 | file=file_name, 217 | line=3, 218 | col=1, 219 | running=0, 220 | namespaces=[ 221 | tests[1].id, 222 | tests[2].id, 223 | ], 224 | type="test", 225 | ), 226 | ] 227 | 228 | assert tests == expected 229 | 230 | 231 | @patch("os.path.isfile", lambda _: True) 232 | @pytest.mark.asyncio 233 | async def test_parse_namespace_structure(): 234 | patterns = { 235 | "test": [r"\v^\s*%(async )?def (test_\w+)"], 236 | "namespace": [r"\v^\s*class (\w+)"], 237 | } 238 | 239 | file_name = get_test_file("python") 240 | tests = await file_parser.parse_file_structure(file_name, patterns) 241 | 242 | expected = [ 243 | File( 244 | id=file_name, 245 | name=file_name, 246 | file=file_name, 247 | line=0, 248 | col=0, 249 | namespaces=[], 250 | type="file", 251 | ), 252 | Test( 253 | id=tests[1].id, 254 | name="test_a", 255 | file=file_name, 256 | line=4, 257 | col=1, 258 | running=0, 259 | namespaces=[], 260 | type="test", 261 | ), 262 | [ 263 | Namespace( 264 | id=tests[2].id, 265 | name="TestAgain", 266 | file=file_name, 267 | line=9, 268 | col=1, 269 | running=0, 270 | namespaces=[], 271 | type="namespace", 272 | ), 273 | Test( 274 | id=tests[3].id, 275 | name="test_d", 276 | file=file_name, 277 | line=10, 278 | col=1, 279 | running=0, 280 | namespaces=[tests[2].id], 281 | type="test", 282 | ), 283 | Test( 284 | id=tests[4].id, 285 | name="test_a", 286 | file=file_name, 287 | line=16, 288 | col=1, 289 | running=0, 290 | namespaces=[tests[2].id], 291 | type="test", 292 | ), 293 | ], 294 | [ 295 | Namespace( 296 | id=tests[5].id, 297 | name="TestMyClass", 298 | file=file_name, 299 | line=25, 300 | col=1, 301 | running=0, 302 | namespaces=[], 303 | type="namespace", 304 | ), 305 | Test( 306 | id=tests[6].id, 307 | name="test_d", 308 | file=file_name, 309 | line=26, 310 | col=1, 311 | running=0, 312 | namespaces=[tests[5].id], 313 | type="test", 314 | ), 315 | Test( 316 | id=tests[7].id, 317 | name="test_a", 318 | file=file_name, 319 | line=29, 320 | col=1, 321 | running=0, 322 | namespaces=[tests[5].id], 323 | type="test", 324 | ), 325 | ], 326 | Test( 327 | id=tests[8].id, 328 | name="test_b", 329 | file=file_name, 330 | line=33, 331 | col=1, 332 | running=0, 333 | namespaces=[], 334 | type="test", 335 | ), 336 | ] 337 | 338 | assert tests.to_list() == expected 339 | -------------------------------------------------------------------------------- /tests/unit/handler/parsers/test_output.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from rplugin.python3.ultest.handler.parsers import OutputParser, ParseResult 4 | from tests.mocks import get_output 5 | 6 | 7 | class TestOutputParser(TestCase): 8 | def setUp(self) -> None: 9 | self.parser = OutputParser([]) 10 | 11 | def test_parse_pytest(self): 12 | output = get_output("pytest") 13 | failed = list(self.parser.parse_failed("python#pytest", output)) 14 | self.assertEqual( 15 | failed, 16 | [ 17 | ParseResult(name="test_d", namespaces=["TestMyClass"]), 18 | ParseResult(name="test_parametrize", namespaces=[]), 19 | ParseResult(name="test_a", namespaces=[]), 20 | ], 21 | ) 22 | 23 | def test_parse_pyunit(self): 24 | output = get_output("pyunit") 25 | failed = list(self.parser.parse_failed("python#pyunit", output)) 26 | self.assertEqual( 27 | failed, [ParseResult(name="test_d", namespaces=["TestMyClass"])] 28 | ) 29 | 30 | def test_parse_gotest(self): 31 | output = get_output("gotest") 32 | failed = list(self.parser.parse_failed("go#gotest", output)) 33 | self.assertEqual( 34 | failed, 35 | [ 36 | ParseResult(name="TestA", namespaces=[]), 37 | ParseResult(name="TestB", namespaces=[]), 38 | ], 39 | ) 40 | 41 | def test_parse_jest(self): 42 | output = get_output("jest") 43 | failed = list(self.parser.parse_failed("javascript#jest", output)) 44 | self.assertEqual( 45 | failed, 46 | [ 47 | ParseResult( 48 | name="it shouldn't pass", 49 | namespaces=["First namespace", "Another namespace"], 50 | ), 51 | ParseResult(name="it shouldn't pass again", namespaces=[]), 52 | ], 53 | ) 54 | 55 | def test_parse_exunit(self): 56 | output = get_output("exunit") 57 | failed = list(self.parser.parse_failed("elixir#exunit", output)) 58 | self.assertEqual( 59 | failed, 60 | [ 61 | ParseResult(name="the world", namespaces=[]), 62 | ParseResult(name="greets the world", namespaces=[]), 63 | ], 64 | ) 65 | 66 | def test_parse_richgo(self): 67 | output = get_output("richgo") 68 | failed = list(self.parser.parse_failed("go#richgo", output)) 69 | self.assertEqual( 70 | failed, 71 | [ 72 | ParseResult(name="TestA", namespaces=[]), 73 | ParseResult(name="TestAAAB", namespaces=[]), 74 | ], 75 | ) 76 | -------------------------------------------------------------------------------- /tests/unit/handler/runner/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcarriga/vim-ultest/b06bc8715cbcb4aa0444abfd85fb705b659ba055/tests/unit/handler/runner/__init__.py -------------------------------------------------------------------------------- /tests/unit/handler/runner/test_handle.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import Mock, call, mock_open, patch 3 | 4 | from rplugin.python3.ultest.handler.runner.handle import ProcessIOHandle 5 | 6 | 7 | @patch("ultest.handler.runner.handle.os") 8 | class TestUltestProcess(TestCase): 9 | def test_open_keeps_input_open(self, _): 10 | handle = ProcessIOHandle(in_path="in", out_path="out") 11 | m_open = mock_open() 12 | with patch("ultest.handler.runner.handle.open", m_open): 13 | with handle.open(use_pty=False) as (in_, out_): 14 | self.assertIn(call("in", "wb"), m_open.mock_calls) 15 | in_.close.assert_not_called() 16 | in_.close.assert_called() 17 | 18 | def test_open_cleans_up_input(self, mock_os: Mock): 19 | handle = ProcessIOHandle(in_path="in", out_path="out") 20 | m_open = mock_open() 21 | mock_os.path.exists.return_value = False 22 | with patch("ultest.handler.runner.handle.open", m_open): 23 | with handle.open(use_pty=False) as (in_, out_): 24 | mock_os.remove.assert_not_called() 25 | mock_os.remove.assert_called_with("in") 26 | 27 | def test_open_preserves_output(self, mock_os: Mock): 28 | handle = ProcessIOHandle(in_path="in", out_path="out") 29 | m_open = mock_open() 30 | mock_os.path.exists.return_value = False 31 | with patch("ultest.handler.runner.handle.open", m_open): 32 | with handle.open(use_pty=False) as (in_, out_): 33 | ... 34 | self.assertNotIn(call("out"), mock_os.remove.mock_calls) 35 | -------------------------------------------------------------------------------- /tests/unit/handler/runner/test_processes.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | 4 | class TestProcessManager(TestCase): 5 | ... 6 | -------------------------------------------------------------------------------- /tests/unit/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcarriga/vim-ultest/b06bc8715cbcb4aa0444abfd85fb705b659ba055/tests/unit/models/__init__.py -------------------------------------------------------------------------------- /tests/unit/models/test_tree.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import List, Union 3 | 4 | from hypothesis import given 5 | from hypothesis.strategies import builds, integers, lists 6 | 7 | from rplugin.python3.ultest.handler.parsers import Position 8 | from rplugin.python3.ultest.models import File, Namespace, Test, Tree 9 | from rplugin.python3.ultest.models.namespace import Namespace 10 | 11 | 12 | def sorted_tests( 13 | min_line: int = 1, max_line: int = 1000, min_length: int = 10, max_length: int = 12 14 | ): 15 | return lists( 16 | builds( 17 | Test, 18 | line=integers(min_value=min_line, max_value=max_line).map( 19 | lambda line: line * 2 20 | ), 21 | ), 22 | min_size=min_length, 23 | max_size=max_length, 24 | unique_by=lambda test: test.line, # type: ignore 25 | ).map(lambda tests: sorted(tests, key=lambda test: test.line)) 26 | 27 | 28 | @given(sorted_tests()) 29 | def test_get_nearest_from_strict_match(tests: List[Union[Test, Namespace]]): 30 | test_i = int(random.random() * len(tests)) 31 | expected = tests[test_i] 32 | tree = Tree[Position].from_list([File(file="", name="", id=""), *tests]) 33 | result = tree.sorted_search(expected.line, lambda test: test.line, strict=True) 34 | assert expected == result.data 35 | 36 | 37 | @given(sorted_tests()) 38 | def test_get_nearest_from_strict_no_match(tests: List[Union[Test, Namespace]]): 39 | test_i = int(random.random() * len(tests)) 40 | tree = Tree[Position].from_list([File(file="", name="", id=""), *tests]) 41 | result = tree.sorted_search( 42 | tests[test_i].line + 1, lambda pos: pos.line, strict=True 43 | ) 44 | assert result is None 45 | 46 | 47 | @given(sorted_tests()) 48 | def test_get_nearest_from_non_strict_match(tests: List[Union[Test, Namespace]]): 49 | test_i = int(random.random() * len(tests)) 50 | expected = tests[test_i] 51 | tree = Tree[Position].from_list([File(file="", name="", id=""), *tests]) 52 | result = tree.sorted_search(expected.line + 1, lambda pos: pos.line, strict=False) 53 | assert expected == result.data 54 | 55 | 56 | @given(sorted_tests(min_line=20)) 57 | def test_get_nearest_from_non_strict_no_match(tests: List[Union[Test, Namespace]]): 58 | line = -1 59 | tree = Tree[Position].from_list([File(file="", name="", id=""), *tests]) 60 | result = tree.sorted_search(line, lambda pos: pos.line, strict=False) 61 | assert result is None 62 | --------------------------------------------------------------------------------