Repository: AlphaMycelium/pathfinder.vim Branch: master Commit: 5b6343de1b46 Files: 49 Total size: 73.8 KB Directory structure: gitextract_r8990n86/ ├── .github/ │ ├── CODEOWNERS │ └── workflows/ │ ├── pr.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── pathfinder/ │ ├── __init__.py │ ├── client/ │ │ ├── __init__.py │ │ ├── autorun.py │ │ ├── client.py │ │ ├── explore_lines.py │ │ ├── output.py │ │ ├── plugin.py │ │ ├── popup.py │ │ └── state_tracker.py │ ├── debytes.py │ ├── server/ │ │ ├── __init__.py │ │ ├── dijkstra.py │ │ ├── motions/ │ │ │ ├── __init__.py │ │ │ ├── find.py │ │ │ ├── search.py │ │ │ └── simple.py │ │ ├── node.py │ │ └── server.py │ └── window.py ├── plugin/ │ ├── defaults.vim │ ├── dimensions.vim │ └── main.vim ├── serverrc.vim ├── setup.cfg ├── setup.py ├── test_requirements.txt └── tests/ ├── __init__.py ├── conftest.py ├── test_autorun.py ├── test_debytes.py ├── test_explore_lines.py ├── test_motions_find.py ├── test_motions_search.py ├── test_motions_simple.py ├── test_node.py ├── test_output.py ├── test_popup.py ├── test_state_tracker.py └── test_window.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/CODEOWNERS ================================================ LICENSE @AlphaMycelium .github/ @AlphaMycelium setup.cfg @AlphaMycelium ================================================ FILE: .github/workflows/pr.yml ================================================ name: Checks on: [pull_request] jobs: test: name: Test on Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: matrix: python-version: [3.6, 3.7, 3.8, 3.9] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} - name: Checkout repository uses: actions/checkout@v2 - name: Cache dependencies uses: actions/cache@v1 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('test_requirements.txt') }} restore-keys: | ${{ runner.os }}-pip- - name: Install dependencies run: python -m pip install -r test_requirements.txt - name: Install pytest-github-actions-annotate-failures run: python -m pip install pytest-github-actions-annotate-failures - name: Run pytest run: pytest -v ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: branches: - master jobs: test: name: Test on Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: matrix: python-version: [3.6, 3.7, 3.8, 3.9] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} - name: Checkout repository uses: actions/checkout@v2 - name: Cache dependencies uses: actions/cache@v1 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('test_requirements.txt') }} restore-keys: | ${{ runner.os }}-pip- - name: Install dependencies run: python -m pip install -r test_requirements.txt - name: Install pytest-github-actions-annotate-failures run: python -m pip install pytest-github-actions-annotate-failures - name: Run pytest run: pytest -v release: name: Semantic Release runs-on: ubuntu-latest needs: [test] if: github.repository_owner == 'danth' steps: - name: Checkout repository uses: actions/checkout@v2 with: fetch-depth: 0 ref: ${{ needs.beautify.outputs.new_sha }} - name: Fetch master run: git fetch --prune origin +refs/heads/master:refs/remotes/origin/master - name: Semantic Release uses: relekang/python-semantic-release@v7.15.5 with: github_token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ ================================================ FILE: .gitmodules ================================================ [submodule "heapdict"] path = heapdict url = https://github.com/DanielStutzbach/heapdict.git ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## v3.1.3 (2022-08-24) | Type | Change | | --- | --- | | Fix | Don't error when `g:python3_host_prog` is unset ([`4c01c96`](https://github.com/danth/pathfinder.vim/commit/4c01c9635a54e5669e05c26b9c443c3f86de84d2)) | | Documentation | Update contributing guidelines ([`c83f926`](https://github.com/danth/pathfinder.vim/commit/c83f9264f859367f2f7998e10907e57a99d414cb)) | ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Pathfinder.vim ## Commit messages **Please follow the [Angular commit style](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits) in your commit messages so that the automatic version numbering can work.** ## Development environment The plugin files will be cloned somewhere under `~/.vim` by your plugin manager. For example Plug places them in `~/.vim/plugged/pathfinder.vim/`. Some plugin managers also allow you to clone the repository into a directory of your choice and use the path to that directory to install the plugin. ## Pathfinding The pathfinding algorithm used is [Dijkstra's algorithm][dijkstra], which is just a greedy algorithm. Vertices are generated on-the-fly rather than building the entire graph beforehand. Each vertex in the graph is a tuple of `(view, most recent motion)`. `view` is the result of `winsaveview()` in Vim: this includes both the cursor and scroll positions. We need to store the most recent motion because it is used to calculate certain weights - `2fk` should be cheaper than `fkfy`. [dijkstra]: https://en.wikipedia.org/wiki/Dijkstra%27s_algorithm ## Python to Vim interface Vim (when compiled with `+python3`) comes with an embedded Python module called `vim`, which is used to send commands and output from Python. In Vimscript, the following syntax is used to call Python code: ```vim python3 ``` ```vim python3 << endpython endpython ``` ## Client and Server The way we discover connections between nodes (characters) in the pathfinding graph is: 1. Move the cursor to the node position 2. Run the motion with `silent! normal! ` 3. Check where the cursor moved to and record it as a connection This causes the cursor to move around while things are being tested. We want to allow users to continue working while paths are found, hence we start a "server" which is basically another Vim process using [this barebones vimrc](serverrc.vim). The user's Vim (the client) sends the contents of the current buffer, the start and target positions, the window dimensions and some other information to the server when it wants to request a path. This communication happens through `multiprocessing.connection` with a temporary file. The server will then do pathfinding in the background and send the result back when it is done, which the client receives and displays. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Daniel Thwaites Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: MANIFEST.in ================================================ include LICENSE README.md include plugin/* colors/* ftdetect/* ftplugin/* indent/* compiler/* after/* autoload/* doc/* include heapdict/LICENSE heapdict/heapdict.py ================================================ FILE: README.md ================================================ # pathfinder.vim A Vim plugin to give suggestions to improve your movements. It's a bit like [Clippy][office-assistant]. [![Demo](https://asciinema.org/a/CYX4I94GGBsHZqMVc9N8MerFD.svg)](https://asciinema.org/a/CYX4I94GGBsHZqMVc9N8MerFD) [office-assistant]: https://en.wikipedia.org/wiki/Office_Assistant ## Features - Find the shortest motion sequence or search query to move the cursor - Help summaries to explain a suggestion - Asynchronous - pathfinding runs in a separate process ## Installation Use your favorite plugin manager. I recommend [vim-plug](https://github.com/junegunn/vim-plug). ```vim if has('python3') && has('timers') Plug 'danth/pathfinder.vim' else echoerr 'pathfinder.vim is not supported on this Vim installation' endif ``` You may also need to run `git submodule update --init` from inside the plugin directory. Most popular plugin managers will do that automatically. ## Usage 1. Move the cursor in normal, visual or visual-line mode. 2. That's it. Suggestions pop up above the cursor if you have: - Vim 8.2 or above, with `+popupwin` - Neovim 0.4 or above Otherwise, they will appear as a plain `echo` at the bottom of the screen. ### Explanations If you don't understand how a suggestion works, you should use the `:PathfinderExplain` command, which will show a short description of each motion used. If you find yourself using this a lot, make a mapping for it! ```vim noremap pe :PathfinderExplain ``` ### Manual Commands If you set [`g:pf_autorun_delay`](#gpf_autorun_delay) to a negative value, you get two commands instead: - `:PathfinderBegin`: Set the start position. This still happens automatically when switching windows/tabs, or loading a new file. - `:PathfinderRun`: Set the target position and get a suggestion. ## Related Plugins - [vim-be-good](https://github.com/ThePrimeagen/vim-be-good) - Various training games to practice certain actions - [vim-hardtime](https://github.com/takac/vim-hardtime) - Prevent yourself from repeating keys like `h`,`j`,`k`,`l` ## Configuration *pathfinder.vim works out-of-the box with the default configuration. You don't need to read this section if you don't want to.* ### `highlight PathfinderPopup` Change the appearance of suggestion popups. *Default: same as cursor* ### `g:pf_popup_time` Milliseconds to display the popup for. *Default: 3000* ### `g:pf_autorun_delay` When this number of seconds have elapsed with no motions being made, the pathfinder will run. It also runs for other events such as changing modes. A negative value will disable automatic suggestions. *Default: 2* ### `g:pf_explore_scale` Multiplier which determines the range of lines to be explored around the start and target positions. This is calculated as (lines between start and target × multiplier) and added to both sides. *Default: 0.5* This limitation improves performance by disallowing movements outside the area of interest. It also prevents suggestions which rely on knowing about the exact text hundreds of lines away. Settings below 1 cause movements within a line to only use motions inside that line. If you have a powerful computer, you can increase this option to a high value to allow exploring more of the file. You can also disable it completely by setting a negative value. ### `g:pf_max_explore` Cap the number of surrounding lines explored (see above) to a maximum value. As usual, this can be disabled by making it negative. *Default: 10* ### `g:pf_descriptions` Dictionary of descriptions, used for `:PathfinderExplain`. ```vim let g:pf_descriptions['k'] = 'Up {count} lines' let g:pf_descriptions['f'] = 'To occurence {count} of "{argument}", to the right' ``` Ensure the plugin is loaded before trying to override keys. Otherwise, the default dictionary will not exist and you'll get an error. Re-defining the entire dictionary is not recommended since it could cause problems if support for a new motion is added and you don't have a description for it. ================================================ FILE: pathfinder/__init__.py ================================================ ================================================ FILE: pathfinder/client/__init__.py ================================================ ================================================ FILE: pathfinder/client/autorun.py ================================================ import time import vim def choose_action(start_state, current_state, update_time): """ Select an action to take automatically. This is intended for use with StateTracker.choose_action_using(). Returns one of: "reset" - Set start and target to the current state "set_target" - Set target to the current state "pathfind" - Start pathfinding, using the target from last time it was set None - Do nothing """ if ( start_state.window != current_state.window or start_state.buffer != current_state.buffer ): # Reset to ensure the start or target view isn't set to a location # which is now impossible to access return "reset" delay = vim.vars["pf_autorun_delay"] if delay > 0: # If delay <= 0, then the user disabled autorun if start_state.mode not in {"n", "v", "V", ""}: # Motions are not used in this mode, so pathfinding is useless return "reset" if ( time.time() >= update_time + delay or start_state.mode != current_state.mode or start_state.lines != current_state.lines ): return "pathfind" else: return "set_target" ================================================ FILE: pathfinder/client/client.py ================================================ import os import subprocess import tempfile from multiprocessing import connection import vim from pathfinder.client.explore_lines import get_line_limits class Client: """ Starts and connects to a separate Vim instance used for testing motions. This is used to test motions in the exact same environment as the user (loaded via a temporary session file), without moving the user's cursor in the process. This allows pathfinding to happen in the background while the user may continue working. A custom vimrc is used to only load this plugin, disabling other plugins and user settings. """ def __init__(self): self.open() def open(self): """Launch and connect to the server Vim.""" # Create a file used to communicate with the server self.file_path = os.path.join( tempfile.gettempdir(), "pathfinder_vim_" + vim.eval("getpid()") ) self.server_process = subprocess.Popen( self._build_server_cmd(), stdout=subprocess.PIPE, stderr=subprocess.PIPE ) self.server_connection = None self.to_send = None def _build_server_cmd(self): """Build the command used to launch the server Vim.""" progpath = vim.eval("v:progpath") options = [ progpath, "--clean", "--cmd", f"let g:pf_server_communication_file='{self.file_path}'", "-u", os.path.normpath( # serverrc.vim in the root of this repository, instead of the user's # regular .vimrc or init.vim os.path.join(os.path.dirname(__file__), "..", "..", "serverrc.vim") ), ] if progpath.endswith("nvim"): options += ["--headless"] try: python3_host_prog = vim.eval("g:python3_host_prog") options += ["--cmd", f"let g:python3_host_prog='{python3_host_prog}'"] except: pass else: options += ["-v", "--not-a-term"] return options def close(self): """Shut down the server Vim.""" if self.server_connection is not None: # Server will shut down Vim gracefully when we disconnect self.server_connection.close() elif self.server_process is not None: # Not connected yet, terminate the process instead self.server_process.terminate() def poll_responses(self): if not self.connect(): return # Check if a request is waiting to be sent if self.to_send is not None: self.server_connection.send(self.to_send) self.to_send = None # Check if any data is available to be read elif self.server_connection.poll(): # Get response (sent in a tuple of type, data) response_type, data = self.server_connection.recv() self.handle_response(response_type, data) def connect(self): """ Attempt to connect to the server. :returns: whether a connection is ready. """ if self.server_connection is not None: return True if self.server_process is None: # Server process has exited but we already raised an exception return False return_code = self.server_process.poll() if return_code is not None: # Server process has exited stdout, stderr = self.server_process.communicate() self.server_process = None raise Exception( f"Pathfinding server process exited with return code {return_code}:\n" + stderr.decode() ) try: # Attempt to connect self.server_connection = connection.Client(self.file_path) return True except FileNotFoundError: return False def handle_response(self, response_type, data): """ Process a response recieved from the server. This will be one of: - ``RESULT`` - A pathfinding result. Call the first queued callback. - ``ERROR`` - An unexpected exception was caught and the server has exited. Relay the traceback to the user for debugging. """ if response_type == "RESULT": # Get the first callback function and pass the result to it self.callback(data) del self.callback elif response_type == "ERROR": raise Exception("Pathfinding server encountered an exception:\n" + data) else: raise Exception("Received an unexpected response " + response_type) def pathfind(self, buffer_contents, start_view, target_view, callback): """ Request a pathfinding result from the server. :param buffer_contents: List of lines we are pathfinding inside. :param start_view: The start position, in the current window. :param target_view: The target position, in the current window. :param callback: Function to be called once a path is found. Recieves a list of motions as a parameter. """ self.callback = callback min_line, max_line = get_line_limits(start_view, target_view) self.to_send = { "start": start_view, "target": target_view, "min_line": min_line, "max_line": max_line, "size": ( # WindowTextWidth() - see plugin/dimensions.vim vim.eval("WindowTextWidth()"), vim.eval("winheight(0)"), ), "buffer": buffer_contents, "wrap": vim.current.window.options["wrap"], "scrolloff": vim.options["scrolloff"], "sidescrolloff": vim.options["sidescrolloff"], } ================================================ FILE: pathfinder/client/explore_lines.py ================================================ import vim def get_explore_lines(search_area_lines): """ :param search_area_lines: Number of lines between, and including, the start and target positions. :returns: Number of lines to explore either side of the search area. """ # Get setting values from Vim variables explore_scale = float(vim.vars["pf_explore_scale"]) max_explore = int(vim.vars["pf_max_explore"]) if explore_scale < 0: # This filtering is disabled, explore the entire buffer return len(vim.current.buffer) # Number of lines to explore above and below the search area is scaled based # on the length of the area. This setting defaults to 0.5, if the search area # was e.g. 6 lines then 3 more lines would be explored on either side. explore_lines = search_area_lines * explore_scale if max_explore >= 0: # Limit to no more than max_explore lines return min(max_explore, explore_lines) else: # Do not limit return explore_lines def get_line_limits(start_view, target_view): """ Return the minimum and maximum line numbers to explore. :param start_view: The start position. :param target_view: The target position. :returns: Tuple of (min line, max line) """ min_line = min(int(start_view.lnum), int(target_view.lnum)) max_line = max(int(start_view.lnum), int(target_view.lnum)) explore_lines = get_explore_lines(max_line - min_line) return ( max(1, min_line - explore_lines), min(len(vim.current.buffer), max_line + explore_lines), ) ================================================ FILE: pathfinder/client/output.py ================================================ import itertools import vim from pathfinder.debytes import debytes last_output = None def strtrans(string): """Convert special characters like '' to '^D'.""" escaped_string = string.replace("'", "\\'").replace("\\", "\\\\") return vim.eval(f"strtrans('{escaped_string}')") def get_count(motion, count): """Build a string like 'k', 'hh', '15w'""" motion_str = strtrans(motion.motion + (motion.argument or "")) if count == 1: return motion_str elif count == 2 and len(motion_str) == 1: # It's easier to press a single-character motion twice # than to type a 2 before it return (motion_str) * 2 return str(count) + motion_str def compact_motions(motions): """ Return the given motion sequence in single-line form. e.g. 2* 5j $ """ return " ".join( [ get_count(motion, len(list(group))) for motion, group in itertools.groupby(motions) ] ) def get_description(motion, repetitions): description = debytes(vim.vars["pf_descriptions"][motion.motion]) description = description.replace("{count}", str(repetitions)) if motion.argument is not None: description = description.replace("{argument}", motion.argument) return description def explained_motions(motions): """ Yield each motion in the form "motion help" e.g. ['5j Down 5 lines', '$ To the end of the line'] """ for motion, group in itertools.groupby(motions): repetitions = len(list(group)) yield ( get_count(motion, repetitions) + " " + get_description(motion, repetitions) ) ================================================ FILE: pathfinder/client/plugin.py ================================================ import time import vim import pathfinder.client.output as output from pathfinder.client.autorun import choose_action from pathfinder.client.client import Client from pathfinder.client.popup import open_popup from pathfinder.client.state_tracker import StateTracker from pathfinder.window import cursor_in_same_position class Plugin: def __init__(self): self.client = Client() self.state_tracker = StateTracker() self.last_output = None def _run(self): """Start calculating a path in the background.""" self.client.pathfind( self.state_tracker.start_state.lines, self.state_tracker.start_state.view, self.state_tracker.target_state.view, self.popup, ) def popup(self, motions): self.last_output = motions open_popup(output.compact_motions(motions)) def autorun(self): """Called on a timer several times per second.""" if self.state_tracker.choose_action_using(choose_action) == "pathfind": if not cursor_in_same_position( self.state_tracker.start_state.view, self.state_tracker.target_state.view, ): self._run() self.state_tracker.reset() def command_begin(self): """Called for the :PathfinderBegin command.""" self.state_tracker.reset() def command_run(self): """Called for the :PathfinderRun command.""" self.state_tracker.set_target() if cursor_in_same_position( self.state_tracker.start_state.view, self.state_tracker.target_state.view, ): print("You must move the cursor to a new location first!") else: self._run() def command_explain(self): """Called for the :PathfinderExplain command.""" if self.last_output is None: print("No suggestion to explain.") else: # explained_motions yields each line # sep tells print to put \n between them rather than space print(*output.explained_motions(self.last_output), sep="\n") def stop(self): """Called when Vim is about to shut down.""" self.client.close() ================================================ FILE: pathfinder/client/popup.py ================================================ import vim from pathfinder.debytes import debytes def _neovim_popup(text, line_offset): """Create a popup using Neovim 0.4+ floating windows.""" # Insert text into a scratch buffer buffer = vim.api.create_buf(False, True) vim.api.buf_set_lines(buffer, 0, -1, True, [f" {text} "]) # Create a window containing the buffer window = vim.api.open_win( buffer, 0, { "relative": "cursor", "row": int(line_offset), "col": 0, "style": "minimal", "focusable": 0, "height": 1, "width": len(text) + 2, }, ) # Set the highlight of the window to match the cursor vim.api.win_set_option(window, "winhl", "Normal:PathfinderPopup") # Create a timer to close the window popup_time = int(vim.vars["pf_popup_time"]) vim.eval(f"timer_start({popup_time}, {{-> nvim_win_close({window.handle}, 1)}})") def _vim_popup(text, line_offset): """Create a popup using Vim +popupwin.""" vim.Function("popup_create")( text, { "line": f"cursor{line_offset}", "col": "cursor", "wrap": False, "padding": (0, 1, 0, 1), "highlight": "PathfinderPopup", "time": int(vim.vars["pf_popup_time"]), "zindex": 1000, }, ) def open_popup(text): line_offset = "+1" if vim.eval("line('.')") == "1" else "-1" if vim.eval("has('nvim-0.4')") == "1": _neovim_popup(text, line_offset) elif vim.eval("has('popupwin')") == "1": _vim_popup(text, line_offset) else: # Not able to create a popup print(text) ================================================ FILE: pathfinder/client/state_tracker.py ================================================ import time from collections import namedtuple import vim from pathfinder.window import winsaveview State = namedtuple("State", "view mode buffer window lines") class StateTracker: def __init__(self): self.reset() def _set_update_time(self): self.update_time = time.time() def _record_state(self): return State( winsaveview(), vim.eval("mode()"), vim.current.buffer.number, vim.current.window.number, vim.current.buffer[:], ) def reset(self): current_state = self._record_state() self._reset(current_state) def _reset(self, new_state): self.start_state = new_state self.target_state = new_state self._set_update_time() def set_target(self): current_state = self._record_state() self._set_target(current_state) def _set_target(self, new_state): if new_state != self.target_state: self.target_state = new_state self._set_update_time() def choose_action_using(self, function): """ Choose an action to take using the given function. Function will be called with the arguments (start state, current state, time of most recent update). It may return "reset" or "set_target" to call the corresponding method, or any other value to do nothing. The function's return value is passed through this method, so can be used to take further actions elsewhere. """ current_state = self._record_state() result = function(self.start_state, current_state, self.update_time) if result == "reset": self._reset(current_state) elif result == "set_target": self._set_target(current_state) return result ================================================ FILE: pathfinder/debytes.py ================================================ def debytes(string): """ Decode string if it is a bytes object. This is necessary since Neovim, correctly, gives strings as a str, but regular Vim leaves them encoded as bytes. """ try: return string.decode() except AttributeError: return string ================================================ FILE: pathfinder/server/__init__.py ================================================ ================================================ FILE: pathfinder/server/dijkstra.py ================================================ from heapdict import heapdict from pathfinder.server.motions.find import FindMotionGenerator from pathfinder.server.motions.search import SearchMotionGenerator from pathfinder.server.motions.simple import SimpleMotionGenerator from pathfinder.server.node import Node class Dijkstra: """ A path between a start and end point in the same window. :param from_view: View of the start point :param target_view: View of the target point :param min_line: Do not explore nodes above this line :param max_line: Do not explore nodes below this line """ def __init__(self, from_view, target_view, min_line, max_line): self.from_view = from_view self.target_view = target_view self.min_line = min_line self.max_line = max_line self.motion_generators = { SimpleMotionGenerator(self), FindMotionGenerator(self), SearchMotionGenerator(self), } self._open_queue = heapdict() # Min-priority queue: Key -> Distance self._open_nodes = dict() # Key -> Node self._closed_nodes = set() # Key start_node = Node(self, self.from_view, None) self._open_queue[start_node.key] = 0 self._open_nodes[start_node.key] = start_node def find_path(self, client_connection): """ Use Dijkstra's algorithm to find the optimal sequence of motions. :param client_connection: If another pathfinding request is waiting on this connection, exit (returning None) as soon as possible. This cancels the pathfinding, moving on to the new request immediately. """ while len(self._open_queue) > 0 and not client_connection.poll(): current_node_key, current_distance = self._open_queue.popitem() current_node = self._open_nodes.pop(current_node_key) self._closed_nodes.add(current_node_key) if current_node.is_target(): return current_node.reconstruct_path() for node in current_node.get_neighbours(): if node.key in self._closed_nodes: continue new_distance = current_distance + current_node.motion_weight( node.came_by_motion ) if ( node.key not in self._open_nodes or new_distance < self._open_queue[node.key] ): node.set_came_from(current_node) self._open_nodes[node.key] = node self._open_queue[node.key] = new_distance ================================================ FILE: pathfinder/server/motions/__init__.py ================================================ import abc from collections import namedtuple from pathfinder.server.node import Node # motion - The motion such as h,j,k,f,T,gM # argument - Used for the additional argument to f,t,/ etc Motion = namedtuple("Motion", "motion argument") class MotionGenerator(abc.ABC): def __init__(self, dijkstra): self.dijkstra = dijkstra @abc.abstractmethod def generate(self, view): """Yield all neighbouring nodes found from the given view.""" pass def _create_node(self, *args, **kwargs): return Node(self.dijkstra, *args, **kwargs) ================================================ FILE: pathfinder/server/motions/find.py ================================================ import vim from pathfinder.server.motions import Motion, MotionGenerator class FindMotionGenerator(MotionGenerator): MOTIONS = {"f", "t", "F", "T"} def generate(self, view): for motion in self.MOTIONS: yield from self._find(view, motion) def _find(self, view, motion): line_text = vim.current.buffer[view.lnum - 1] seen_characters = set() # characters = string of characters which may be accessible using this motion # column = lambda function which converts index in `characters` to a column number if motion == "f" and view.col < len(line_text): column = lambda i: view.col + i + 1 characters = line_text[view.col + 1 :] elif motion == "t" and view.col < len(line_text) - 1: column = lambda i: view.col + i + 1 characters = line_text[view.col + 2 :] seen_characters.add(line_text[view.col + 1]) elif motion == "F" and view.col > 0 and len(line_text) > view.col: column = lambda i: view.col - i - 1 # Characters are reversed because we are looking backwards characters = line_text[: view.col][::-1] elif motion == "T" and view.col > 1 and len(line_text) > view.col: column = lambda i: view.col - i - 1 characters = line_text[: view.col - 1][::-1] seen_characters.add(line_text[view.col - 1]) else: return for i, character in enumerate(characters): # Only use each unique character once if character in seen_characters: continue seen_characters.add(character) new_col = column(i) new_view = view._replace(col=new_col, curswant=new_col) yield self._create_node(new_view, Motion(motion, character)) ================================================ FILE: pathfinder/server/motions/search.py ================================================ import re import vim from pathfinder.server.motions import Motion, MotionGenerator class SearchMotionGenerator(MotionGenerator): def generate(self, view): # Only return results from the starting node if view != self.dijkstra.from_view: return motion = self._search_lines( vim.current.buffer[:], view.lnum - 1, view.col, self.dijkstra.target_view.lnum - 1, self.dijkstra.target_view.col, ) if motion: yield self._create_node(self.dijkstra.target_view, motion) def _create_motion(self, search_query, motion="/"): return Motion(motion, self._escape_magic(search_query)) def _escape_magic(self, search_query): """Add backslash escapes to any "magic" characters in a query.""" for char in r"\^$.*[~/": search_query = search_query.replace(char, "\\" + char) return search_query def _search(self, text, start, target): """ Return the simplest possible searching motion to reach the given target. :param text: Contents of the file. :param start: Index in ``text`` to start the search from. :param target: Index of the target position in ``text``. """ search_text = text[target:] # ("a", "ab", "abc", "abcd"...) until we reach # the end of search_text or find a working query for query_length in range(1, len(search_text) + 1): query = search_text[:query_length] # Get a list of all match positions for this search query # query="x" text="x___x_xx" == [0, 4, 6, 7] pattern = re.escape(query) matches = [m.start() for m in re.finditer(pattern, text)] if matches: # Sort the list so it begins with matches after `start`, rather # than matches at the beginning of the file # sorted([True, False]) == [False, True] matches.sort(key=lambda position: position <= start) if matches[0] == target: return self._create_motion(query) if matches[-1] == target: return self._create_motion(query, "?") def _search_lines(self, lines, start_line, start_col, target_line, target_col): """ Wrapper around _search which handles 2d coordinates and a list of lines. :param lines: List of lines. :param start_line: Starting line, indexed from 0. :param start_col: Starting column. :param target_line: Target line, indexed from 0. :param target_col: Target column. """ text = "\n".join(lines) start = sum(len(line) + 1 for line in lines[:start_line]) + start_col target = sum(len(line) + 1 for line in lines[:target_line]) + target_col return self._search(text, start, target) ================================================ FILE: pathfinder/server/motions/simple.py ================================================ import vim from pathfinder.server.motions import Motion, MotionGenerator from pathfinder.window import winrestview, winsaveview class SimpleMotionGenerator(MotionGenerator): MOTIONS = { "h", "j", "k", "l", "gj", "gk", "gg", "G", "H", "M", "L", "", "", "", "", "", "", "zt", "z\ ", "z.", "zb", "z-", "0", "^", "g^", "$", "g$", "g_", "gm", "gM", "W", "E", "B", "gE", "w", "e", "b", "ge", "(", ")", "{", "}", "]]", "][", "[[", "[]", "]m", "[m", "]M", "[M", "*", "#", "g*", "g#", "%", } def generate(self, view): for motion in self.MOTIONS: result_view = self._try_motion(view, motion) if result_view is not None and result_view != view: yield self._create_node(result_view, Motion(motion, None)) def _try_motion(self, view, motion): """ Use a motion inside Vim, starting from the given view. If the motion causes an error, return None. """ winrestview(view) try: vim.command(f"silent! normal! {motion}") except: return None return winsaveview() ================================================ FILE: pathfinder/server/node.py ================================================ from pathfinder.window import cursor_in_same_position class Node: """Graph node linked to a view (cursor+scroll location) within the document.""" def __init__(self, dijkstra, view, came_by_motion): self.dijkstra = dijkstra self.view = view self.came_by_motion = came_by_motion self.key = (view, came_by_motion) self.came_from = None self.came_by_motion_repetitions = 1 def get_neighbours(self): """Yield all neighbours of this node.""" for motion_generator in self.dijkstra.motion_generators: for node in motion_generator.generate(self.view): if ( node.view.lnum >= self.dijkstra.min_line and node.view.lnum <= self.dijkstra.max_line ): yield node def motion_weight(self, motion): """Return the weight of using a motion from this node.""" if motion != self.came_by_motion: # First repetition, return number of characters in the motion return len(motion.motion) + ( 0 if motion.argument is None else len(motion.argument) ) elif self.came_by_motion_repetitions == 1: # Second repetition, adding a "2" is 1 extra character return 1 else: # Difference in length of current and future count # 2j -> 3j = 0 # 9j -> 10j = 1 return len(str(self.came_by_motion_repetitions + 1)) - len( str(self.came_by_motion_repetitions) ) def set_came_from(self, node): """Set the node this node was reached from.""" self.came_from = node if node.came_by_motion == self.came_by_motion: self.came_by_motion_repetitions = node.came_by_motion_repetitions + 1 else: self.came_by_motion_repetitions = 1 def is_target(self): return cursor_in_same_position(self.view, self.dijkstra.target_view) def reconstruct_path(self): """Return the sequence of motions used to reach this node.""" motions = list() node = self while node.came_from is not None: motions.insert(0, node.came_by_motion) node = node.came_from return motions ================================================ FILE: pathfinder/server/server.py ================================================ import traceback from multiprocessing import connection import vim from pathfinder.debytes import debytes from pathfinder.server.dijkstra import Dijkstra class Server: """ Local server which runs inside an instance of Vim in a separate process. This is used to test motions in the same environment as the user, but without blocking their Vim or moving their cursor. This allows pathfinding to happen in the background while the user may continue working. The server's Vim is quitted as soon as the client (the user's Vim) disconnects, which happens when it is closed. It will also exit if an exception is caught, relaying the traceback message to the client beforehand so that it can be displayed for debugging. """ def __init__(self, file_path): self.listener = connection.Listener(debytes(file_path)) def run(self): try: # Wait for the user's Vim to connect self.client_connection = self.listener.accept() # Continuously process jobs until EOFError is raised # (when the client disconnects) self.message_loop() except EOFError: pass finally: self.listener.close() # Exit the background Vim since it is no longer needed vim.command("qa!") def message_loop(self): """ Continuously wait for and handle instructions from the client. This waiting blocks Vim, but that does not matter since nobody is looking at it. Blocking also prevents CPU resources from being wasted on redrawing. :raises EOFError: when the connection is closed. """ while True: try: data = self.client_connection.recv() # If there is still data waiting, then multiple requests were sent, # so we skip pathfinding and move on to the next one if not self.client_connection.poll(): self.do_action(data) except: # Send any unexpected exceptions back to the client # to be displayed for debugging purposes self.client_connection.send(("ERROR", traceback.format_exc())) def do_action(self, data): """Process an instruction from the client.""" self.start_view = data["start"] self.target_view = data["target"] self.min_line = data["min_line"] self.max_line = data["max_line"] vim.current.buffer[:] = data["buffer"] vim.current.window.options["wrap"] = data["wrap"] vim.options["scrolloff"] = data["scrolloff"] vim.options["sidescrolloff"] = data["sidescrolloff"] # Set size of the entire Vim display to match the size of the # corresponding window in the client vim.options["columns"] = int(data["size"][0]) vim.options["lines"] = vim.options["cmdheight"] + int(data["size"][1]) self.pathfind() def pathfind(self): """Run the pathfinder, then send the result back to the client.""" dijkstra = Dijkstra( self.start_view, self.target_view, self.min_line, self.max_line ) motions = dijkstra.find_path(self.client_connection) # If motions is None, that means we cancelled pathfinding because a new # request was received. We also check for another request now in case one was # sent during the last iteration of the pathfinding loop. if not (motions is None or self.client_connection.poll()): self.client_connection.send(("RESULT", motions)) server = Server(vim.vars["pf_server_communication_file"]) server.run() ================================================ FILE: pathfinder/window.py ================================================ from collections import namedtuple import vim View = namedtuple("View", "lnum col curswant leftcol topline") def winsaveview(): view_dict = vim.eval("winsaveview()") # Any dictionary elements not in View._fields will be discarded return View._make(int(view_dict[field]) for field in View._fields) def winrestview(view): view_dict = dict(view._asdict()) vim.eval(f"winrestview({view_dict})") def cursor_in_same_position(a, b): """ Check if the given views have the cursor on the same position. The scroll position and other properties may differ. """ return a.lnum == b.lnum and a.col == b.col ================================================ FILE: plugin/defaults.vim ================================================ highlight default link PathfinderPopup Cursor if !exists('g:pf_autorun_delay') let g:pf_autorun_delay = 2 endif if !exists('g:pf_popup_time') let g:pf_popup_time = 3000 endif if !exists('g:pf_explore_scale') let g:pf_explore_scale = 0.5 endif if !exists('g:pf_max_explore') let g:pf_max_explore = 10 endif if !exists('g:pf_descriptions') let g:pf_descriptions = { \ 'h': 'Left {count} columns', \ 'l': 'Right {count} columns', \ 'j': 'Down {count} lines', \ 'k': 'Up {count} lines', \ 'gj': 'Down {count} display lines', \ 'gk': 'Up {count} display lines', \ 'gg': 'To the start of the buffer', \ 'G': 'To the end of the buffer', \ 'H': 'To line {count} from the top of the window', \ 'M': 'To the middle of the window', \ 'L': 'To line {count} from the bottom of the window', \ '': 'Scroll downward {count} lines', \ '': 'Scroll upward {count} lines', \ '': 'Scroll forward {count} pages', \ '': 'Scroll backward {count} pages', \ '': 'Scroll downward {count} times', \ '': 'Scroll upward {count} times', \ 'zt': 'Current line to the top of the window', \ 'z\ ': 'Current line to the top of the window and move to the first non-blank character', \ 'zz': 'Current line to the centre of the window', \ 'z.': 'Current line to the centre of the window and move to the first non-blank character', \ 'zb': 'Current line to the bottom of the window', \ 'z-': 'Current line to the bottom of the window and move to the first non-blank character', \ '0': 'To the start of the line', \ '^': 'To the first non-blank character on the line', \ 'g^': 'To the first non-blank character on the display line', \ '$': 'To the end of the line', \ 'g$': 'To the end of the display line', \ 'g_': 'To the last non-blank character on the line', \ 'gm': 'To the centre of the screen', \ 'gM': 'To the centre of the line', \ 'W': '{count} WORDs forward (inclusive)', \ 'E': 'Forward to the end of WORD {count} (exclusive)', \ 'B': '{count} WORDs backward (inclusive)', \ 'gE': 'Backward to the end of WORD {count} (exclusive)', \ 'w': '{count} words forward (inclusive)', \ 'e': 'Forward to the end of word {count} (exclusive)', \ 'b': '{count} words backward (inclusive)', \ 'ge': 'Backward to the end of word {count} (exclusive)', \ '(': '{count} sentences backward', \ ')': '{count} sentences forward', \ '{': '{count} paragraphs backward', \ '}': '{count} paragraphs forward', \ ']]': '{count} sections forward or to the next { in the first column', \ '][': '{count} sections forward or to the next } in the first column', \ '[[': '{count} sections backward or to the previous { in the first column', \ '[]': '{count} sections backward or to the previous } in the first column', \ ']m': '{count} next start of a method (Java or similar)', \ '[m': '{count} previous start of a method (Java or similar)', \ ']M': '{count} next end of a method (Java or similar)', \ '[M': '{count} previous end of a method (Java or similar)', \ '*': 'Search forward for occurrence {count} of the word nearest to the cursor', \ '#': 'Search backward for occurrence {count} of the word nearest to the cursor', \ 'g*': 'Search forward for occurrence {count} of the word nearest to the cursor, allowing matches which are not a whole word', \ 'g#': 'Search backward for occurrence {count} of the word nearest to the cursor, allowing matches which are not a whole word', \ '%': 'Go to the matching bracket', \ 'f': 'To occurrence {count} of "{argument}", to the right', \ 't': 'Till before occurrence {count} of "{argument}", to the right', \ 'F': 'To occurrence {count} of "{argument}", to the left', \ 'T': 'Till after occurrence {count} of "{argument}", to the left', \ '/': 'Search forwards for occurence {count} of "{argument}"', \ '?': 'Search backwards for occurence {count} of "{argument}"', \ } endif ================================================ FILE: plugin/dimensions.vim ================================================ " Get the width of the current window, excluding sign and number columns function! WindowTextWidth() let l:decorationColumns = &number ? &numberwidth : 0 if has('folding') let l:decorationColumns += &foldcolumn endif if has('signs') if &signcolumn ==? 'yes' || &signcolumn ==? 'number' " Sign column is always enabled let l:decorationColumns += 2 elseif &signcolumn ==? 'auto' redir => l:signs silent execute 'sign place buffer=' . bufnr('') redir END " The output of the command above contains two header lines " Any more and that means signs are displayed if len(split(l:signs, "\n")) > 2 let l:decorationColumns += 2 endif endif endif return winwidth(0) - l:decorationColumns endfunction ================================================ FILE: plugin/main.vim ================================================ if exists('g:pf_loaded') | finish | endif if !has('python3') echom 'The +python3 feature is required to run pathfinder.vim' finish endif if !has('timers') echom 'The +timers feature is required to run pathfinder.vim' finish endif python3 << endpython import vim import sys from os.path import normpath, join plugin_root = vim.eval("fnamemodify(resolve(expand(':p')), ':h')") sys.path.insert(0, normpath(join(plugin_root, '..'))) sys.path.insert(0, normpath(join(plugin_root, '..', 'heapdict'))) endpython if exists('g:pf_server_communication_file') " Importing this will run the server and connect back to the client python3 import pathfinder.server.server else python3 from pathfinder.client.plugin import Plugin; plugin = Plugin() command! PathfinderBegin python3 plugin.command_begin() command! PathfinderRun python3 plugin.command_run() command! PathfinderExplain python3 plugin.command_explain() function! PathfinderLoop(timer) " Check for responses from the server python3 plugin.client.poll_responses() " Check if we should take any actions automatically python3 plugin.autorun() endfunction if !exists("s:timer") let s:timer = timer_start(100, 'PathfinderLoop', {'repeat': -1}) endif augroup PathfinderStopOnLeave autocmd! autocmd VimLeave * call timer_stop(s:timer) autocmd VimLeave * python3 plugin.stop() augroup END endif let g:pf_loaded = 1 ================================================ FILE: serverrc.vim ================================================ set nocompatible " Disable unnecessary features set noruler set nonumber set noshowcmd set signcolumn=no filetype off syntax off " Add pathfinder.vim to runtimepath let &runtimepath .= ',' . escape(expand(':p:h'), '\,') ================================================ FILE: setup.cfg ================================================ [semantic_release] version_variable=setup.py:__version__ changelog_components=semantic_release.changelog.changelog_table commit_subject=release: v{version} upload_to_pypi=false build_command=python setup.py sdist --formats=gztar,zip [isort] default_section=THIRDPARTY known_third_party=vim known_first_party=pathfinder order_by_type=True combine_star=True reverse_relative=True multi_line_output=3 include_trailing_comma=True force_grid_wrap=0 use_parentheses=True line_length=88 ================================================ FILE: setup.py ================================================ from setuptools import find_packages, setup __version__ = "3.1.3" setup( name="pathfinder.vim", version=__version__, author="Daniel Thwaites", author_email="danthwaites30@btinternet.com", url="https://github.com/danth/pathfinder.vim", packages=find_packages(exclude=("tests",)), ) ================================================ FILE: test_requirements.txt ================================================ pytest >=5,<6 pytest-stub >=1,<2 ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/conftest.py ================================================ from pytest_stub.toolbox import stub_global stub_global( { "vim": "[mock_persist]", } ) ================================================ FILE: tests/test_autorun.py ================================================ import time from unittest import mock import pytest from pathfinder.client.autorun import choose_action from pathfinder.client.state_tracker import State from pathfinder.window import View @pytest.fixture def default_state(): return State(View(0, 0, 0, 0, 0), "n", 1, 1, ["hello world"]) def test_choose_action_new_window(default_state): assert ( choose_action( default_state._replace(window=1), default_state._replace(window=2), time.time(), ) == "reset" ) @mock.patch("pathfinder.client.autorun.vim.vars", {"pf_autorun_delay": 1}) def test_choose_action_non_motion_mode(default_state): assert ( choose_action( default_state._replace(mode="i"), default_state._replace(mode="i"), time.time(), ) == "reset" ) @mock.patch("pathfinder.client.autorun.vim.vars", {"pf_autorun_delay": 1}) def test_choose_action_changed_mode(default_state): assert ( choose_action( default_state._replace(mode="n"), default_state._replace(mode="i"), time.time(), ) == "pathfind" ) @mock.patch("pathfinder.client.autorun.vim.vars", {"pf_autorun_delay": 1}) def test_choose_action_changed_lines(default_state): assert ( choose_action( default_state._replace(lines=["foo"]), default_state._replace(lines=["bar"]), time.time(), ) == "pathfind" ) @mock.patch("pathfinder.client.autorun.vim.vars", {"pf_autorun_delay": 1}) def test_choose_action_cursor_idle(default_state): assert ( choose_action( default_state, default_state, time.time() - 2, ) == "pathfind" ) @mock.patch("pathfinder.client.autorun.vim.vars", {"pf_autorun_delay": 1}) def test_choose_action_set_target(default_state): assert ( choose_action( default_state, default_state, time.time(), ) == "set_target" ) @mock.patch("pathfinder.client.autorun.vim.vars", {"pf_autorun_delay": 0}) def test_choose_action_do_nothing(default_state): assert ( choose_action( default_state, default_state, time.time(), ) is None ) ================================================ FILE: tests/test_debytes.py ================================================ from pathfinder.debytes import debytes def test_debytes_bytes(): assert debytes(b"hello world") == "hello world" def test_debytes_str(): assert debytes("hello world") == "hello world" ================================================ FILE: tests/test_explore_lines.py ================================================ from collections import namedtuple from unittest import mock from pathfinder.client.explore_lines import get_explore_lines, get_line_limits def test_get_explore_lines(): with mock.patch( "pathfinder.client.explore_lines.vim.vars", {"pf_explore_scale": 0.5, "pf_max_explore": 10}, ): assert get_explore_lines(10) == 5 def test_get_explore_lines_max_0(): with mock.patch( "pathfinder.client.explore_lines.vim.vars", {"pf_explore_scale": 0.5, "pf_max_explore": 0}, ): assert get_explore_lines(10) == 0 def test_get_explore_lines_scale_0(): with mock.patch( "pathfinder.client.explore_lines.vim.vars", {"pf_explore_scale": 0, "pf_max_explore": 10}, ): assert get_explore_lines(10) == 0 @mock.patch("pathfinder.client.explore_lines.get_explore_lines", return_value=0) def test_get_line_limits(mock_get_explore_lines): View = namedtuple("View", "lnum") with mock.patch( "pathfinder.client.explore_lines.vim.current.buffer", ["line"] * 10 ): assert get_line_limits(View(2), View(8)) == (2, 8) assert mock_get_explore_lines.called_once_with(6) @mock.patch("pathfinder.client.explore_lines.get_explore_lines", return_value=2) def test_get_line_limits_with_explore(mock_get_explore_lines): View = namedtuple("View", "lnum") with mock.patch( "pathfinder.client.explore_lines.vim.current.buffer", ["line"] * 10 ): assert get_line_limits(View(4), View(6)) == (2, 8) assert mock_get_explore_lines.called_once_with(4) ================================================ FILE: tests/test_motions_find.py ================================================ from collections import namedtuple from unittest import mock from pathfinder.server.motions import Motion from pathfinder.server.motions.find import FindMotionGenerator View = namedtuple("View", "lnum col curswant") @mock.patch("pathfinder.server.motions.find.vim.current.buffer", ["abcdde"]) @mock.patch.object(FindMotionGenerator, "_create_node", new=lambda self, v, m: (v, m)) def test_find_f(): generator = FindMotionGenerator(None) output = list(generator._find(View(1, 0, 0), "f")) assert output == [ (View(1, 1, 1), Motion("f", "b")), (View(1, 2, 2), Motion("f", "c")), (View(1, 3, 3), Motion("f", "d")), (View(1, 5, 5), Motion("f", "e")), ] @mock.patch("pathfinder.server.motions.find.vim.current.buffer", ["abcdde"]) def test_find_f_final_column(): generator = FindMotionGenerator(None) output = list(generator._find(View(1, 5, 5), "f")) assert len(output) == 0 @mock.patch("pathfinder.server.motions.find.vim.current.buffer", ["abcdde"]) @mock.patch.object(FindMotionGenerator, "_create_node", new=lambda self, v, m: (v, m)) def test_find_t(): generator = FindMotionGenerator(None) output = list(generator._find(View(1, 0, 0), "t")) assert output == [ (View(1, 1, 1), Motion("t", "c")), (View(1, 2, 2), Motion("t", "d")), (View(1, 4, 4), Motion("t", "e")), ] @mock.patch("pathfinder.server.motions.find.vim.current.buffer", ["abcdde"]) def test_find_t_penultimate_column(): generator = FindMotionGenerator(None) output = list(generator._find(View(1, 4, 4), "t")) assert len(output) == 0 @mock.patch("pathfinder.server.motions.find.vim.current.buffer", ["abcdde"]) @mock.patch.object(FindMotionGenerator, "_create_node", new=lambda self, v, m: (v, m)) def test_find_F(): generator = FindMotionGenerator(None) output = list(generator._find(View(1, 5, 5), "F")) assert output == [ (View(1, 4, 4), Motion("F", "d")), (View(1, 2, 2), Motion("F", "c")), (View(1, 1, 1), Motion("F", "b")), (View(1, 0, 0), Motion("F", "a")), ] @mock.patch("pathfinder.server.motions.find.vim.current.buffer", ["abcdde"]) def test_find_F_first_column(): generator = FindMotionGenerator(None) output = list(generator._find(View(1, 0, 0), "F")) assert len(output) == 0 @mock.patch("pathfinder.server.motions.find.vim.current.buffer", ["abcdde"]) @mock.patch.object(FindMotionGenerator, "_create_node", new=lambda self, v, m: (v, m)) def test_find_T(): generator = FindMotionGenerator(None) output = list(generator._find(View(1, 5, 5), "T")) assert output == [ (View(1, 3, 3), Motion("T", "c")), (View(1, 2, 2), Motion("T", "b")), (View(1, 1, 1), Motion("T", "a")), ] @mock.patch("pathfinder.server.motions.find.vim.current.buffer", ["abcdde"]) def test_find_T_second_column(): generator = FindMotionGenerator(None) output = list(generator._find(View(1, 1, 1), "T")) assert len(output) == 0 ================================================ FILE: tests/test_motions_search.py ================================================ from unittest import mock from pathfinder.server.motions import Motion from pathfinder.server.motions.search import SearchMotionGenerator def test_escape_magic(): assert ( SearchMotionGenerator(None)._escape_magic(r"x\x^x$x.x*x[x~x/x") == r"x\\x\^x\$x\.x\*x\[x\~x\/x" ) def test_search_finds_shortest_possible_query(): generator = SearchMotionGenerator(None) assert generator._search("abcde", 0, 2) == Motion("/", "c") assert generator._search("abcbcdebbc", 0, 3) == Motion("/", "bcd") def test_search_when_target_is_before_start(): generator = SearchMotionGenerator(None) assert generator._search("abcde", 2, 0) == Motion("/", "a") assert generator._search("bcdabc", 3, 0) == Motion("?", "b") @mock.patch.object(SearchMotionGenerator, "_search") def test_search_lines_calls_search_correctly(mock_search): SearchMotionGenerator(None)._search_lines(["a", "bc", "d"], 0, 0, 1, 1) mock_search.assert_called_once_with("a\nbc\nd", 0, 3) ================================================ FILE: tests/test_motions_simple.py ================================================ from unittest import mock from pathfinder.server.motions import Motion from pathfinder.server.motions.simple import SimpleMotionGenerator INPUT_VIEW = mock.sentinel.input_view OUTPUT_VIEW = mock.sentinel.output_view @mock.patch("pathfinder.server.motions.simple.vim.command") @mock.patch("pathfinder.server.motions.simple.winsaveview", return_value=OUTPUT_VIEW) @mock.patch("pathfinder.server.motions.simple.winrestview") def test_try_motion(winrestview, winsaveview, command): generator = SimpleMotionGenerator(None) assert generator._try_motion(INPUT_VIEW, "j") == OUTPUT_VIEW winrestview.assert_called_once_with(INPUT_VIEW) command.assert_called_once_with("silent! normal! j") winsaveview.assert_called_once() @mock.patch("pathfinder.server.motions.simple.vim.command", side_effect=Exception) @mock.patch("pathfinder.server.motions.simple.winsaveview", return_value=OUTPUT_VIEW) @mock.patch("pathfinder.server.motions.simple.winrestview") def test_try_motion_when_motion_raises_exception(winrestview, winsaveview, command): generator = SimpleMotionGenerator(None) assert generator._try_motion(INPUT_VIEW, "j") is None winrestview.assert_called_once_with(INPUT_VIEW) command.assert_called_once_with("silent! normal! j") winsaveview.assert_not_called() @mock.patch.object(SimpleMotionGenerator, "_create_node") @mock.patch.object(SimpleMotionGenerator, "_try_motion", return_value=OUTPUT_VIEW) def test_generate(try_motion, create_node): generator = SimpleMotionGenerator(None) output = list(generator.generate(INPUT_VIEW)) assert len(output) == len(SimpleMotionGenerator.MOTIONS) for motion in SimpleMotionGenerator.MOTIONS: try_motion.assert_any_call(INPUT_VIEW, motion) create_node.assert_any_call(OUTPUT_VIEW, Motion(motion, None)) @mock.patch.object(SimpleMotionGenerator, "_create_node") @mock.patch.object(SimpleMotionGenerator, "_try_motion", return_value=None) def test_generate_when_try_motion_returns_none(try_motion, create_node): generator = SimpleMotionGenerator(None) output = list(generator.generate(INPUT_VIEW)) assert len(output) == 0 create_node.assert_not_called() for motion in SimpleMotionGenerator.MOTIONS: try_motion.assert_any_call(INPUT_VIEW, motion) @mock.patch.object(SimpleMotionGenerator, "_create_node") @mock.patch.object(SimpleMotionGenerator, "_try_motion", return_value=INPUT_VIEW) def test_generate_when_try_motion_returns_same_as_input(try_motion, create_node): generator = SimpleMotionGenerator(None) output = list(generator.generate(INPUT_VIEW)) assert len(output) == 0 create_node.assert_not_called() for motion in SimpleMotionGenerator.MOTIONS: try_motion.assert_any_call(INPUT_VIEW, motion) ================================================ FILE: tests/test_node.py ================================================ from unittest import mock import pytest from pathfinder.server.motions import Motion from pathfinder.server.node import Node def test_reconstruct_path(): node1 = Node(None, None, None) node2 = Node(None, None, mock.sentinel.motion_1) node2.set_came_from(node1) node3 = Node(None, None, mock.sentinel.motion_2) node3.set_came_from(node2) node4 = Node(None, None, mock.sentinel.motion_3) node4.set_came_from(node3) output = node4.reconstruct_path() assert output == [ mock.sentinel.motion_1, mock.sentinel.motion_2, mock.sentinel.motion_3, ] def test_set_came_from_same_motion(): node1 = Node(None, None, mock.sentinel.motion_1) node2 = Node(None, None, mock.sentinel.motion_1) node2.set_came_from(node1) assert node2.came_from == node1 assert node2.came_by_motion_repetitions == 2 def test_set_came_from_different_motion(): node1 = Node(None, None, mock.sentinel.motion_1) node2 = Node(None, None, mock.sentinel.motion_2) node2.set_came_from(node1) assert node2.came_from == node1 assert node2.came_by_motion_repetitions == 1 @pytest.mark.parametrize( "repetitions,expected", [(1, 1), (2, 0), (9, 1), (10, 0), (99, 1)], ids=[1, 2, 9, 10, 99], ) def test_motion_weight_same_motion(repetitions, expected): node = Node(None, None, mock.sentinel.motion) node.came_by_motion_repetitions = repetitions assert node.motion_weight(mock.sentinel.motion) == expected @pytest.mark.parametrize( "motion,expected", [(Motion("b", None), 1), (Motion("cd", None), 2), (Motion("e", "fg"), 3)], ids=["b", "cd", "e/fg"], ) def test_motion_weight_different_motion(motion, expected): node = Node(None, None, Motion("a", None)) assert node.motion_weight(motion) == expected ================================================ FILE: tests/test_output.py ================================================ from unittest import mock import pytest from pathfinder.client.output import compact_motions, explained_motions, get_count from pathfinder.server.motions import Motion COUNTS = [ ("j", 1, "j"), ("j", 2, "jj"), ("long", 2, "2long"), ("j", 3, "3j"), ("j", 10, "10j"), ] @mock.patch("pathfinder.client.output.strtrans", lambda x: x) @pytest.mark.parametrize("motion,count,expected", COUNTS, ids=[x[2] for x in COUNTS]) def test_get_count(motion, count, expected): motion = Motion(motion, None) assert get_count(motion, count) == expected @mock.patch("pathfinder.client.output.strtrans", lambda x: x) def test_compact_motions(): motion1 = Motion("j", None) motion2 = Motion("k", None) assert compact_motions([motion1, motion2, motion2, motion2]) == "j 3k" @mock.patch("pathfinder.client.output.strtrans", lambda x: x) def test_explained_motions(): motion1 = Motion("j", None) motion2 = Motion("f", "g") with mock.patch( "pathfinder.client.output.vim.vars", { "pf_descriptions": { "j": "Down {count} lines", "f": "To occurence {count} of {argument}", } }, ): assert list(explained_motions([motion1, motion2, motion2])) == [ "j Down 1 lines", "2fg To occurence 2 of g", ] ================================================ FILE: tests/test_popup.py ================================================ from unittest import mock from pathfinder.client.popup import _neovim_popup, _vim_popup, open_popup @mock.patch("pathfinder.client.popup._vim_popup") @mock.patch("pathfinder.client.popup._neovim_popup") @mock.patch( "pathfinder.client.popup.vim.eval", side_effect=lambda x: { "line('.')": "1", "has('nvim-0.4')": "1", "has('popupwin')": "0", }[x], ) def test_open_popup_neovim(vim_eval, neovim_popup, vim_popup): open_popup(mock.sentinel.text) vim_eval.assert_any_call("has('nvim-0.4')") neovim_popup.assert_called_once_with(mock.sentinel.text, "+1") vim_popup.assert_not_called() @mock.patch("pathfinder.client.popup._vim_popup") @mock.patch("pathfinder.client.popup._neovim_popup") @mock.patch( "pathfinder.client.popup.vim.eval", side_effect=lambda x: { "line('.')": "2", "has('nvim-0.4')": "0", "has('popupwin')": "1", }[x], ) def test_open_popup_vim(vim_eval, neovim_popup, vim_popup): open_popup(mock.sentinel.text) vim_eval.assert_any_call("has('popupwin')") neovim_popup.assert_not_called() vim_popup.assert_called_once_with(mock.sentinel.text, "-1") @mock.patch("pathfinder.client.popup._vim_popup") @mock.patch("pathfinder.client.popup._neovim_popup") @mock.patch( "pathfinder.client.popup.vim.eval", side_effect=lambda x: { "line('.')": "3", "has('nvim-0.4')": "0", "has('popupwin')": "0", }[x], ) def test_open_popup_echo(vim_eval, neovim_popup, vim_popup): open_popup(mock.sentinel.text) neovim_popup.assert_not_called() vim_popup.assert_not_called() @mock.patch("pathfinder.client.popup.vim.vars", {"pf_popup_time": b"2000"}) def test_vim_popup(): popup_create = mock.MagicMock(name='vim.Function("popup_create")') with mock.patch("pathfinder.client.popup.vim.Function", return_value=popup_create): _vim_popup("hello world", "+1") popup_create.assert_called_once_with( "hello world", { "line": "cursor+1", "col": "cursor", "wrap": False, "padding": (0, 1, 0, 1), "highlight": "PathfinderPopup", "time": 2000, # Mocked in decorator "zindex": 1000, }, ) @mock.patch("pathfinder.client.popup.vim.vars", {"pf_popup_time": b"2000"}) @mock.patch("pathfinder.client.popup.vim.eval") @mock.patch("pathfinder.client.popup.vim.api.win_set_option") @mock.patch( "pathfinder.client.popup.vim.api.open_win", return_value=mock.sentinel.window ) @mock.patch("pathfinder.client.popup.vim.api.buf_set_lines") @mock.patch( "pathfinder.client.popup.vim.api.create_buf", return_value=mock.sentinel.buffer ) def test_neovim_popup(create_buf, buf_set_lines, open_win, win_set_option, vim_eval): type(open_win.return_value).handle = mock.PropertyMock(return_value="window handle") _neovim_popup("hello world", "-1") create_buf.assert_called_once_with(False, True) buf_set_lines.assert_called_once_with( mock.sentinel.buffer, 0, -1, True, [" hello world "] ) open_win.assert_called_once_with( mock.sentinel.buffer, 0, { "relative": "cursor", "row": -1, "col": 0, "style": "minimal", "focusable": 0, "height": 1, "width": 13, }, ) win_set_option.assert_called_once_with( mock.sentinel.window, "winhl", "Normal:PathfinderPopup" ) vim_eval.assert_called_once_with( "timer_start(2000, {-> nvim_win_close(window handle, 1)})" ) ================================================ FILE: tests/test_state_tracker.py ================================================ from unittest import mock import pytest from pathfinder.client.state_tracker import StateTracker @pytest.fixture def tracker(): with mock.patch.object( StateTracker, "_record_state", return_value=mock.sentinel.initial_state, ): return StateTracker() def test_reset(tracker): initial_update_time = tracker.update_time with mock.patch.object( tracker, "_record_state", return_value=mock.sentinel.new_state, ): tracker.reset() assert tracker.start_state == mock.sentinel.new_state assert tracker.target_state == mock.sentinel.new_state assert tracker.update_time > initial_update_time def test_set_new_target(tracker): initial_update_time = tracker.update_time with mock.patch.object( tracker, "_record_state", return_value=mock.sentinel.new_state, ): tracker.set_target() assert tracker.start_state == mock.sentinel.initial_state assert tracker.target_state == mock.sentinel.new_state assert tracker.update_time > initial_update_time def test_set_same_target(tracker): initial_update_time = tracker.update_time with mock.patch.object( tracker, "_record_state", return_value=mock.sentinel.initial_state, ): tracker.set_target() assert tracker.start_state == mock.sentinel.initial_state assert tracker.target_state == mock.sentinel.initial_state assert tracker.update_time == initial_update_time def test_choose_action_reset(tracker): update_time = tracker.update_time with mock.patch.object(tracker, "_reset") as reset: with mock.patch.object(tracker, "_set_target") as set_target: with mock.patch.object( tracker, "_record_state", return_value=mock.sentinel.new_state, ): chooser = mock.MagicMock(name="chooser", return_value="reset") assert tracker.choose_action_using(chooser) == "reset" chooser.assert_called_once_with( mock.sentinel.initial_state, mock.sentinel.new_state, update_time ) reset.assert_called_once() set_target.assert_not_called() def test_choose_action_set_target(tracker): update_time = tracker.update_time with mock.patch.object(tracker, "_reset") as reset: with mock.patch.object(tracker, "_set_target") as set_target: with mock.patch.object( tracker, "_record_state", return_value=mock.sentinel.new_state, ): chooser = mock.MagicMock(name="chooser", return_value="set_target") assert tracker.choose_action_using(chooser) == "set_target" chooser.assert_called_once_with( mock.sentinel.initial_state, mock.sentinel.new_state, update_time ) set_target.assert_called_once() reset.assert_not_called() def test_choose_action_other(tracker): update_time = tracker.update_time with mock.patch.object(tracker, "_reset") as reset: with mock.patch.object(tracker, "_set_target") as set_target: with mock.patch.object( tracker, "_record_state", return_value=mock.sentinel.new_state, ): chooser = mock.MagicMock(name="chooser", return_value="other") assert tracker.choose_action_using(chooser) == "other" chooser.assert_called_once_with( mock.sentinel.initial_state, mock.sentinel.new_state, update_time ) reset.assert_not_called() set_target.assert_not_called() ================================================ FILE: tests/test_window.py ================================================ from unittest import mock import pytest from pathfinder.window import View, cursor_in_same_position, winrestview, winsaveview TEST_DICT = {"lnum": 0, "col": 10, "curswant": 10, "leftcol": 5, "topline": 0} TEST_DICT_SOME_STRINGS = { "lnum": 0, "col": "10", "curswant": b"10", "leftcol": 5, "topline": "0", } TEST_VIEW = View(0, 10, 10, 5, 0) @mock.patch( "pathfinder.window.vim.eval", return_value={**TEST_DICT_SOME_STRINGS, "extra values should be ignored": 200}, ) def test_winsaveview(vim_eval): assert winsaveview() == TEST_VIEW vim_eval.assert_called_once_with("winsaveview()") @mock.patch("pathfinder.window.vim.eval") def test_winrestview(vim_eval): winrestview(TEST_VIEW) vim_eval.assert_called_once_with(f"winrestview({TEST_DICT})") @pytest.mark.parametrize( "view_a,view_b,expected", [ (View(0, 0, None, None, None), View(0, 0, None, None, None), True), (View(0, 0, None, None, None), View(1, 0, None, None, None), False), (View(0, 0, None, None, None), View(0, 1, None, None, None), False), (View(0, 0, None, None, None), View(1, 1, None, None, None), False), ], ids=["same position", "lnum differs", "col differs", "both differ"], ) def test_cursor_in_same_position(view_a, view_b, expected): assert cursor_in_same_position(view_a, view_b) is expected