├── .github ├── dependabot.yml └── workflows │ ├── capabilities-nvim.yml │ ├── lsp-devtools-pr.yml │ ├── lsp-devtools-release.yml │ ├── pytest-lsp-pr.yml │ └── pytest-lsp-release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── .vscode ├── launch.json └── settings.json ├── README.md ├── docs ├── Makefile ├── _static │ └── custom.css ├── capabilities │ ├── general.rst │ ├── notebook-document.rst │ ├── text-document.rst │ ├── text-document │ │ ├── call-hierachy.rst │ │ ├── code-action.rst │ │ ├── code-lens.rst │ │ ├── completion.rst │ │ ├── declaration.rst │ │ ├── definition.rst │ │ ├── diagnostic.rst │ │ ├── document-color.rst │ │ ├── document-highlight.rst │ │ ├── document-link.rst │ │ ├── document-symbols.rst │ │ ├── folding-range.rst │ │ ├── formatting.rst │ │ ├── hover.rst │ │ ├── implementation.rst │ │ ├── inlay-hint.rst │ │ ├── inline-value.rst │ │ ├── linked-editing-range.rst │ │ ├── moniker.rst │ │ ├── on-type-formatting.rst │ │ ├── publish-diagnostics.rst │ │ ├── range-formatting.rst │ │ ├── references.rst │ │ ├── rename.rst │ │ ├── selection-range.rst │ │ ├── semantic-tokens.rst │ │ ├── signature-help.rst │ │ ├── synchronization.rst │ │ ├── type-definition.rst │ │ └── type-hierachy.rst │ ├── window.rst │ ├── window │ │ ├── progress.rst │ │ ├── show-document.rst │ │ └── show-message.rst │ ├── workspace.rst │ └── workspace │ │ ├── apply-edit.rst │ │ ├── code-lens.rst │ │ ├── configuration.rst │ │ ├── diagnostics.rst │ │ ├── did-change-configuration.rst │ │ ├── did-change-watched-files.rst │ │ ├── execute-command.rst │ │ ├── file-operations.rst │ │ ├── inlay-hint.rst │ │ ├── inline-value.rst │ │ ├── semantic-tokens.rst │ │ ├── symbol.rst │ │ ├── workspace-edit.rst │ │ └── workspace-folders.rst ├── conf.py ├── ext │ ├── capabilities.py │ └── supported_clients.py ├── images │ ├── lsp-devtools-architecture.svg │ ├── record-client-capabilities.svg │ ├── record-example.svg │ ├── record-log-messages.svg │ └── tui-screenshot.svg ├── index.rst ├── lsp-devtools │ ├── changelog.md │ ├── guide.rst │ └── guide │ │ ├── example-to-file-output.json │ │ ├── getting-started.rst │ │ ├── inspect-command.rst │ │ └── record-command.rst ├── make.bat ├── pytest-lsp │ ├── changelog.md │ ├── guide.rst │ ├── guide │ │ ├── client-capabilities-error.txt │ │ ├── client-capabilities-ignore.txt │ │ ├── client-capabilities-output.txt │ │ ├── client-capabilities.rst │ │ ├── fixtures.rst │ │ ├── getting-started-fail-output.txt │ │ ├── getting-started.rst │ │ ├── language-client.rst │ │ ├── troubleshooting.rst │ │ └── window-log-message-output.txt │ ├── howto.rst │ ├── howto │ │ ├── integrate-with-lsp-devtools.rst │ │ ├── migrate-to-v1.rst │ │ └── testing-json-rpc-servers.rst │ └── reference.rst └── requirements.txt ├── flake.lock ├── flake.nix ├── lib ├── lsp-devtools │ ├── .bumpversion.cfg │ ├── .gitignore │ ├── CHANGES.md │ ├── LICENSE │ ├── MANIFEST.in │ ├── README.md │ ├── changes │ │ └── github-template.html │ ├── flake.lock │ ├── flake.nix │ ├── hatch.toml │ ├── lsp_devtools │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── agent │ │ │ ├── __init__.py │ │ │ ├── agent.py │ │ │ ├── client.py │ │ │ ├── protocol.py │ │ │ └── server.py │ │ ├── cli.py │ │ ├── client │ │ │ ├── __init__.py │ │ │ ├── app.css │ │ │ ├── editor │ │ │ │ ├── __init__.py │ │ │ │ ├── completion.py │ │ │ │ └── text_editor.py │ │ │ └── lsp.py │ │ ├── database.py │ │ ├── handlers │ │ │ ├── __init__.py │ │ │ ├── dbinit.sql │ │ │ └── sql.py │ │ ├── inspector │ │ │ ├── __init__.py │ │ │ └── app.css │ │ ├── py.typed │ │ └── record │ │ │ ├── __init__.py │ │ │ ├── filters.py │ │ │ ├── formatters.py │ │ │ └── visualize.py │ ├── nix │ │ └── lsp-devtools-overlay.nix │ ├── pyproject.toml │ ├── ruff.toml │ ├── ruff_defaults.toml │ └── tests │ │ ├── record │ │ ├── test_filters.py │ │ ├── test_formatters.py │ │ └── test_record.py │ │ ├── servers │ │ └── simple.py │ │ └── test_agent.py └── pytest-lsp │ ├── .bumpversion.cfg │ ├── .gitignore │ ├── CHANGES.md │ ├── LICENSE │ ├── MANIFEST.in │ ├── Makefile │ ├── README.md │ ├── changes │ └── github-template.html │ ├── flake.lock │ ├── flake.nix │ ├── hatch.toml │ ├── nix │ └── pytest-lsp-overlay.nix │ ├── pyproject.toml │ ├── pytest_lsp │ ├── __init__.py │ ├── checks.py │ ├── client.py │ ├── clients │ │ ├── README.md │ │ ├── __init__.py │ │ ├── emacs_v29.1.json │ │ ├── neovim_v0.10.0.json │ │ ├── neovim_v0.6.1.json │ │ ├── neovim_v0.7.0.json │ │ ├── neovim_v0.8.0.json │ │ ├── neovim_v0.9.1.json │ │ └── visual_studio_code_v1.65.2.json │ ├── plugin.py │ ├── protocol.py │ └── py.typed │ ├── ruff.toml │ ├── ruff_defaults.toml │ └── tests │ ├── conftest.py │ ├── examples │ ├── client-capabilities │ │ ├── server.py │ │ └── t_server.py │ ├── diagnostics │ │ ├── server.py │ │ └── t_server.py │ ├── fixture-passthrough │ │ ├── server.py │ │ └── t_server.py │ ├── fixture-scope │ │ ├── server.py │ │ └── t_server.py │ ├── generic-rpc │ │ ├── server.py │ │ └── t_server.py │ ├── getting-started-fail │ │ ├── server.py │ │ └── t_server.py │ ├── getting-started │ │ ├── server.py │ │ └── t_server.py │ ├── parameterised-clients │ │ ├── server.py │ │ └── t_server.py │ ├── ruff.toml │ ├── server-stderr │ │ ├── server.py │ │ └── t_server.py │ ├── window-create-progress │ │ ├── server.py │ │ └── t_server.py │ ├── window-log-message-fail │ │ ├── server.py │ │ └── t_server.py │ ├── window-log-message │ │ ├── server.py │ │ └── t_server.py │ ├── window-show-document │ │ ├── server.py │ │ └── t_server.py │ ├── window-show-message │ │ ├── server.py │ │ └── t_server.py │ └── workspace-configuration │ │ ├── server.py │ │ └── t_server.py │ ├── servers │ ├── capabilities.py │ ├── completion_exit.py │ ├── crash.py │ ├── hello.py │ ├── invalid_json.py │ └── notify_exit.py │ ├── test_checks.py │ ├── test_client.py │ ├── test_examples.py │ └── test_plugin.py └── scripts ├── check_capabilities.py ├── make_release.py ├── nvim-capabilities.lua └── should-build.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | target-branch: "develop" 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.github/workflows/capabilities-nvim.yml: -------------------------------------------------------------------------------- 1 | name: Neovim Capabilities 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: 'Version to capture capabilities for' 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | capture: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.11" 20 | 21 | - run: | 22 | gh release download ${{ inputs.version }} -p '*.appimage' -R neovim/neovim 23 | ls -l 24 | 25 | chmod +x nvim-linux-x86_64.appimage 26 | ./nvim-linux-x86_64.appimage --appimage-extract 27 | ./squashfs-root/usr/bin/nvim -l scripts/nvim-capabilities.lua 28 | 29 | mv neovim_v*.json lib/pytest-lsp/pytest_lsp/clients/ 30 | ls -l 31 | 32 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 33 | git config user.name "github-actions[bot]" 34 | 35 | git checkout -b neovim-${{ inputs.version }}-capabilities 36 | git add lib/pytest-lsp/pytest_lsp/clients/ 37 | git commit -m "Add client capabilities for Neovim ${{ inputs.version }}" 38 | git push -u origin neovim-${{ inputs.version }}-capabilities 39 | 40 | gh pr create --base develop --fill 41 | name: Capture Neovim Capailities 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | -------------------------------------------------------------------------------- /.github/workflows/lsp-devtools-pr.yml: -------------------------------------------------------------------------------- 1 | name: 'PR: lsp-devtools' 2 | on: 3 | pull_request: 4 | branches: 5 | - develop 6 | - release 7 | paths: 8 | - 'lib/lsp-devtools/**' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.11" 20 | cache: pip 21 | cache-dependency-path: lib/lsp-devtools/pyproject.toml 22 | 23 | - run: | 24 | python --version 25 | python -m pip install --upgrade pip 26 | python -m pip install --upgrade hatch towncrier 27 | name: Setup Environment 28 | 29 | - run: | 30 | set -e 31 | ./scripts/make_release.py lsp-devtools 32 | name: Set Version 33 | 34 | - uses: hynek/build-and-inspect-python-package@v2 35 | with: 36 | path: lib/lsp-devtools 37 | 38 | test: 39 | name: "Python v${{ matrix.python-version }} -- ${{ matrix.os }}" 40 | runs-on: ${{ matrix.os }} 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | python-version: ["3.9", "3.10", "3.11", "3.12"] 45 | os: [ubuntu-latest] 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - name: Setup Python ${{ matrix.python-version }} 51 | uses: actions/setup-python@v5 52 | with: 53 | allow-prereleases: true 54 | python-version: ${{ matrix.python-version }} 55 | cache: pip 56 | cache-dependency-path: lib/lsp-devtools/pyproject.toml 57 | 58 | - run: | 59 | python --version 60 | python -m pip install --upgrade pip 61 | python -m pip install --upgrade hatch 62 | name: Setup Environment 63 | 64 | - run: | 65 | cd lib/lsp-devtools 66 | hatch test -i py=${{ matrix.python-version }} 67 | shell: bash 68 | name: Run Tests 69 | -------------------------------------------------------------------------------- /.github/workflows/lsp-devtools-release.yml: -------------------------------------------------------------------------------- 1 | name: 'Release: lsp-devtools' 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | paths: 8 | - 'lib/lsp-devtools/**' 9 | 10 | jobs: 11 | release: 12 | name: lsp-devtools release 13 | runs-on: ubuntu-latest 14 | environment: 15 | name: pypi 16 | url: https://pypi.org/p/lsp-devtools 17 | permissions: 18 | contents: write 19 | id-token: write 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - uses: actions/setup-python@v5 25 | with: 26 | python-version: "3.10" 27 | 28 | - run: | 29 | python --version 30 | python -m pip install --upgrade pip 31 | python -m pip install hatch towncrier docutils 32 | name: Install Build Tools 33 | 34 | - run: | 35 | set -e 36 | 37 | ./scripts/make_release.py lsp-devtools 38 | name: Set Version 39 | id: info 40 | 41 | - name: Package 42 | run: | 43 | cd lib/lsp-devtools 44 | hatch build 45 | 46 | - name: 'Upload Artifact' 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: 'dist' 50 | path: lib/lsp-devtools/dist 51 | 52 | - name: Publish 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | with: 55 | packages-dir: lib/lsp-devtools/dist/ 56 | 57 | - name: Create Release 58 | run: | 59 | gh release create "${RELEASE_TAG}" \ 60 | --title "lsp-devtools v${VERSION} - ${RELEASE_DATE}" \ 61 | -F lib/lsp-devtools/.changes.html \ 62 | ./lib/lsp-devtools/dist/* 63 | env: 64 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | -------------------------------------------------------------------------------- /.github/workflows/pytest-lsp-pr.yml: -------------------------------------------------------------------------------- 1 | name: 'PR: pytest-lsp' 2 | on: 3 | pull_request: 4 | branches: 5 | - develop 6 | - release 7 | paths: 8 | - 'lib/pytest-lsp/**' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.11" 20 | cache: pip 21 | cache-dependency-path: lib/pytest-lsp/pyproject.toml 22 | 23 | - run: | 24 | python --version 25 | python -m pip install --upgrade pip 26 | python -m pip install --upgrade hatch towncrier 27 | name: Setup Environment 28 | 29 | - run: | 30 | set -e 31 | ./scripts/make_release.py pytest-lsp 32 | name: Set Version 33 | 34 | - uses: hynek/build-and-inspect-python-package@v2 35 | with: 36 | path: lib/pytest-lsp 37 | 38 | test: 39 | name: "Python v${{ matrix.python-version }} -- ${{ matrix.os }}" 40 | runs-on: ${{ matrix.os }} 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 45 | os: [ubuntu-latest, windows-latest] 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - name: Setup Python ${{ matrix.python-version }} 51 | uses: actions/setup-python@v5 52 | with: 53 | allow-prereleases: true 54 | python-version: ${{ matrix.python-version }} 55 | cache: pip 56 | cache-dependency-path: lib/pytest-lsp/pyproject.toml 57 | 58 | - run: | 59 | python --version 60 | python -m pip install --upgrade pip 61 | python -m pip install --upgrade hatch 62 | name: Setup Environment 63 | 64 | - run: | 65 | cd lib/pytest-lsp 66 | hatch test -i py=${{ matrix.python-version }} 67 | shell: bash 68 | name: Run Tests 69 | -------------------------------------------------------------------------------- /.github/workflows/pytest-lsp-release.yml: -------------------------------------------------------------------------------- 1 | name: 'Release: pytest-lsp' 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | paths: 8 | - 'lib/pytest-lsp/**' 9 | 10 | jobs: 11 | release: 12 | name: pytest-lsp release 13 | runs-on: ubuntu-latest 14 | environment: 15 | name: pypi 16 | url: https://pypi.org/p/pytest-lsp 17 | permissions: 18 | contents: write 19 | id-token: write 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - uses: actions/setup-python@v5 25 | with: 26 | python-version: "3.10" 27 | 28 | - run: | 29 | python --version 30 | python -m pip install --upgrade pip 31 | python -m pip install tox hatch towncrier docutils 32 | name: Setup Environment 33 | 34 | - run: | 35 | set -e 36 | 37 | ./scripts/make_release.py pytest-lsp 38 | name: Set Version 39 | id: info 40 | 41 | - name: Package 42 | run: | 43 | cd lib/pytest-lsp 44 | hatch build 45 | 46 | - name: 'Upload Artifact' 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: 'dist' 50 | path: lib/pytest-lsp/dist 51 | 52 | - name: Publish 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | with: 55 | packages-dir: lib/pytest-lsp/dist/ 56 | 57 | - name: Create Release 58 | run: | 59 | gh release create "${RELEASE_TAG}" \ 60 | --title "pytest-lsp v${VERSION} - ${RELEASE_DATE}" \ 61 | -F lib/pytest-lsp/.changes.html \ 62 | ./lib/pytest-lsp/dist/* 63 | env: 64 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .env 3 | .tox 4 | *.pyc 5 | 6 | dist 7 | build 8 | node_modules 9 | .mypy_cache 10 | __pycache__ 11 | *.egg-info 12 | _build 13 | *.*~ 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: '.bumpversion.cfg$' 2 | repos: 3 | 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: check-yaml 8 | - id: end-of-file-fixer 9 | exclude: 'lib/pytest-lsp/pytest_lsp/clients/.*\.json' 10 | - id: trailing-whitespace 11 | 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: v0.11.9 14 | hooks: 15 | - id: ruff 16 | args: [--fix] 17 | files: 'lib/.*\.py' 18 | 19 | - id: ruff-format 20 | files: 'lib/.*\.py' 21 | 22 | - repo: https://github.com/pre-commit/mirrors-mypy 23 | rev: 'v1.15.0' 24 | hooks: 25 | - id: mypy 26 | name: mypy (pytest-lsp) 27 | args: [--explicit-package-bases,--check-untyped-defs] 28 | additional_dependencies: 29 | - importlib-resources 30 | - platformdirs 31 | - 'pygls>=2.0a2' 32 | - pytest 33 | - pytest-asyncio 34 | - websockets 35 | files: 'lib/pytest-lsp/pytest_lsp/.*\.py' 36 | 37 | - id: mypy 38 | name: mypy (lsp-devtools) 39 | args: [--explicit-package-bases,--check-untyped-defs] 40 | additional_dependencies: 41 | - aiosqlite 42 | - attrs 43 | - importlib-resources 44 | - platformdirs 45 | - 'pygls>=2.0a2' 46 | - stamina 47 | - textual 48 | - websockets 49 | files: 'lib/lsp-devtools/lsp_devtools/.*\.py' 50 | 51 | - repo: local 52 | hooks: 53 | - id: check-capabilities 54 | name: check-capabilities 55 | language: python 56 | additional_dependencies: 57 | - lsprotocol 58 | files: 'lib/pytest-lsp/pytest_lsp/clients/.*\.json' 59 | entry: python scripts/check_capabilities.py 60 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the OS, Python version and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the "docs/" directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # Optional but recommended, declare the Python requirements required 19 | # to build your documentation 20 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 21 | python: 22 | install: 23 | - requirements: docs/requirements.txt 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "LSP Inspector", 9 | "type": "python", 10 | "request": "launch", 11 | "module": "lsp_devtools", 12 | "args": [ 13 | "inspector" 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "ms-python.black-formatter", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.organizeImports": true 7 | } 8 | }, 9 | "isort.args": [ 10 | "--settings-file", 11 | "./lib/lsp-devtools/pyproject.toml" 12 | ], 13 | "files.exclude": { 14 | "**/.git": true, 15 | "**/.svn": true, 16 | "**/.hg": true, 17 | "**/CVS": true, 18 | "**/.DS_Store": true, 19 | "**/Thumbs.db": true, 20 | "**/__pycache__": true, 21 | "**/*.egg-info": true, 22 | "**/.pytest_cache": true, 23 | "**/.mypy_cache": true, 24 | }, 25 | "esbonio.sphinx.buildCommand": [ 26 | "sphinx-build", 27 | "-M", 28 | "html", 29 | "docs", 30 | "docs/_build" 31 | ], 32 | "python.testing.pytestArgs": [ 33 | "lib/pytest-lsp/tests" 34 | ], 35 | "python.testing.unittestEnabled": false, 36 | "python.testing.pytestEnabled": true, 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

LSP Devtools

2 | 3 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/swyddfa/lsp-devtools/develop.svg)](https://results.pre-commit.ci/latest/github/swyddfa/lsp-devtools/develop) 4 | 5 | This repo is an attempt at building the developer tooling I wished existed when I first started working on [Esbonio](https://github.com/swyddfa/esbonio/). 6 | 7 | This is a monorepo containing a number of sub-projects. 8 | 9 | ## `lib/lsp-devtools` - A grab bag of development utilities 10 | 11 | [![PyPI](https://img.shields.io/pypi/v/lsp-devtools?style=flat-square)](https://pypi.org/project/lsp-devtools)[![PyPI - Downloads](https://img.shields.io/pypi/dm/lsp-devtools?style=flat-square)](https://pypistats.org/packages/lsp-devtools)[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://github.com/swyddfa/lsp-devtools/blob/develop/lib/lsp-devtools/LICENSE) 12 | 13 | ![TUI Screenshot](https://user-images.githubusercontent.com/2675694/212438877-d332dd84-14b4-4568-b36f-4c3e04d4f95f.png) 14 | 15 | A collection of cli utilities aimed at aiding the development of language servers and/or clients. 16 | 17 | - `agent`: Used to wrap an lsp server allowing messages sent between it and the client to be intercepted and inspected by other tools. 18 | - `record`: Connects to an agent and record traffic to file, sqlite db or console. Supports filtering and formatting the output 19 | - `inspect`: A browser devtools inspired TUI to visualise and inspecting LSP traffic. Powered by [textual](https://textual.textualize.io/) 20 | - `client`: **Experimental** A TUI language client with built in `inspect` panel. Powered by [textual](https://textual.textualize.io/) 21 | 22 | ## `lib/pytest-lsp` - End-to-end testing of language servers with pytest 23 | 24 | [![PyPI](https://img.shields.io/pypi/v/pytest-lsp?style=flat-square)](https://pypi.org/project/pytest-lsp)[![PyPI - Downloads](https://img.shields.io/pypi/dm/pytest-lsp?style=flat-square)](https://pypistats.org/packages/pytest-lsp)[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://github.com/swyddfa/lsp-devtools/blob/develop/lib/pytest-lsp/LICENSE) 25 | 26 | `pytest-lsp` is a pytest plugin for writing end-to-end tests for language servers. 27 | 28 | It works by running the language server in a subprocess and communicating with it over stdio, just like a real language client. 29 | This also means `pytest-lsp` can be used to test language servers written in any language - not just Python. 30 | 31 | `pytest-lsp` relies on the [`pygls`](https://github.com/openlawlibrary/pygls) library for its language server protocol implementation. 32 | 33 | ```python 34 | import sys 35 | 36 | import pytest 37 | import pytest_lsp 38 | from lsprotocol import types 39 | from pytest_lsp import ( 40 | ClientServerConfig, 41 | LanguageClient, 42 | client_capabilities, 43 | ) 44 | 45 | 46 | @pytest_lsp.fixture( 47 | scope="module", 48 | config=ClientServerConfig( 49 | server_command=[sys.executable, "-m", "esbonio"], 50 | ), 51 | ) 52 | async def client(lsp_client: LanguageClient): 53 | # Setup 54 | response = await lsp_client.initialize_session( 55 | types.InitializeParams( 56 | capabilities=client_capabilities("visual-studio-code"), 57 | workspace_folders=[ 58 | types.WorkspaceFolder( 59 | uri="file:///path/to/test/project/root/", name="project" 60 | ), 61 | ], 62 | ) 63 | ) 64 | 65 | yield 66 | 67 | # Teardown 68 | await lsp_client.shutdown_session() 69 | 70 | 71 | @pytest.mark.asyncio(loop_scope="module") 72 | async def test_completion(client: LanguageClient): 73 | result = await client.text_document_completion_async( 74 | params=types.CompletionParams( 75 | position=types.Position(line=5, character=23), 76 | text_document=types.TextDocumentIdentifier( 77 | uri="file:///path/to/test/project/root/test_file.rst" 78 | ), 79 | ) 80 | ) 81 | 82 | assert len(result.items) > 0 83 | ``` 84 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | ifeq ($(CI),true) 2 | BUILD=html-build 3 | else 4 | BUILD=html-local 5 | endif 6 | 7 | 8 | ifeq ($(GITHUB_REF),refs/heads/release) 9 | BUILDDIR=stable 10 | else 11 | BUILDDIR=latest 12 | endif 13 | 14 | 15 | html-build: 16 | BUILDDIR=$(BUILDDIR) sphinx-build -b html . _build/$(BUILDDIR)/en/ 17 | echo "version=$(BUILDDIR)" >> $(GITHUB_OUTPUT) 18 | 19 | 20 | html-local: 21 | sphinx-build -M html . _build $(SPHINXOPTS) 22 | 23 | html: $(BUILD) 24 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | .scrollable-svg { 2 | max-height: 450px; 3 | overflow: auto; 4 | } 5 | -------------------------------------------------------------------------------- /docs/capabilities/general.rst: -------------------------------------------------------------------------------- 1 | ``general`` 2 | =========== 3 | 4 | .. default-domain:: capabilities 5 | 6 | .. values-table:: general.position_encodings 7 | :value-set: PositionEncodingKind 8 | 9 | ``staleRequestSupport`` 10 | ----------------------- 11 | 12 | .. bool-table:: general.stale_request_support.cancel 13 | 14 | .. values-table:: general.stale_request_support.retry_on_content_modified 15 | 16 | ``regularExpressions`` 17 | ---------------------- 18 | 19 | .. values-table:: general.regular_expressions.engine 20 | 21 | .. values-table:: general.regular_expressions.version 22 | 23 | ``markdown`` 24 | ------------ 25 | 26 | .. values-table:: general.markdown.parser 27 | 28 | .. values-table:: general.markdown.version 29 | 30 | .. values-table:: general.markdown.allowed_tags 31 | -------------------------------------------------------------------------------- /docs/capabilities/notebook-document.rst: -------------------------------------------------------------------------------- 1 | ``notebook`` 2 | ============ 3 | 4 | .. default-domain:: capabilities 5 | 6 | ``synchronization`` 7 | ------------------- 8 | 9 | .. bool-table:: notebook_document.synchronization.dynamic_registration 10 | 11 | .. bool-table:: notebook_document.synchronization.execution_summary_support 12 | -------------------------------------------------------------------------------- /docs/capabilities/text-document.rst: -------------------------------------------------------------------------------- 1 | ``textDocument`` 2 | ================ 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | :glob: 7 | 8 | text-document/* 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/call-hierachy.rst: -------------------------------------------------------------------------------- 1 | ``callHierarchy`` 2 | ================= 3 | 4 | Capabilities relating to the :lsp:`textDocument/prepareCallHierarchy` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.call_hierarchy.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/code-action.rst: -------------------------------------------------------------------------------- 1 | ``codeAction`` 2 | ============== 3 | 4 | Capabilities relating to the :lsp:`textDocument/codeAction` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.code_action.dynamic_registration 9 | 10 | .. values-table:: text_document.code_action.code_action_literal_support.code_action_kind.value_set 11 | :value-set: CodeActionKind 12 | 13 | .. bool-table:: text_document.code_action.is_preferred_support 14 | 15 | .. bool-table:: text_document.code_action.disabled_support 16 | 17 | .. bool-table:: text_document.code_action.data_support 18 | 19 | .. values-table:: text_document.code_action.resolve_support.properties 20 | 21 | .. bool-table:: text_document.code_action.honors_change_annotations 22 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/code-lens.rst: -------------------------------------------------------------------------------- 1 | ``codeLens`` 2 | ============ 3 | 4 | Capabilities relating to the :lsp:`textDocument/codeLens` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.code_lens.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/completion.rst: -------------------------------------------------------------------------------- 1 | ``completion`` 2 | ============== 3 | 4 | Capabilities relating to the :lsp:`textDocument/completion` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.completion.dynamic_registration 9 | 10 | .. bool-table:: text_document.completion.context_support 11 | 12 | .. values-table:: text_document.completion.insert_text_mode 13 | 14 | ``completionItem`` 15 | ------------------ 16 | 17 | .. bool-table:: text_document.completion.completion_item.snippet_support 18 | 19 | .. bool-table:: text_document.completion.completion_item.commit_characters_support 20 | 21 | .. values-table:: text_document.completion.completion_item.documentation_format 22 | :value-set: MarkupKind 23 | 24 | .. bool-table:: text_document.completion.completion_item.deprecated_support 25 | 26 | .. bool-table:: text_document.completion.completion_item.preselect_support 27 | 28 | .. bool-table:: text_document.completion.completion_item.insert_replace_support 29 | 30 | .. bool-table:: text_document.completion.completion_item.label_details_support 31 | 32 | ``insertTextModeSupport`` 33 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 34 | 35 | .. values-table:: text_document.completion.completion_item.insert_text_mode_support.value_set 36 | :value-set: InsertTextMode 37 | 38 | ``resolveSupport`` 39 | ^^^^^^^^^^^^^^^^^^ 40 | 41 | .. values-table:: text_document.completion.completion_item.resolve_support.properties 42 | 43 | 44 | ``tagSupport`` 45 | ^^^^^^^^^^^^^^ 46 | 47 | .. values-table:: text_document.completion.completion_item.tag_support.value_set 48 | :value-set: CompletionItemTag 49 | 50 | 51 | ``completionItemKind`` 52 | ---------------------- 53 | 54 | .. values-table:: text_document.completion.completion_item_kind.value_set 55 | :value-set: CompletionItemKind 56 | 57 | ``completionList`` 58 | ------------------ 59 | 60 | .. values-table:: text_document.completion.completion_list.item_defaults 61 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/declaration.rst: -------------------------------------------------------------------------------- 1 | ``declaration`` 2 | =============== 3 | 4 | Capabilities relating to the :lsp:`textDocument/declaration` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.declaration.dynamic_registration 9 | 10 | .. bool-table:: text_document.declaration.link_support 11 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/definition.rst: -------------------------------------------------------------------------------- 1 | ``definition`` 2 | ============== 3 | 4 | Capabilities relating to the :lsp:`textDocument/definition` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.definition.dynamic_registration 9 | 10 | .. bool-table:: text_document.definition.link_support 11 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/diagnostic.rst: -------------------------------------------------------------------------------- 1 | ``diagnostic`` 2 | ============== 3 | 4 | Capabilities relating to the :lsp:`textDocument/diagnostic` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.diagnostic.dynamic_registration 9 | 10 | .. bool-table:: text_document.diagnostic.related_document_support 11 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/document-color.rst: -------------------------------------------------------------------------------- 1 | ``documentColor`` 2 | ================= 3 | 4 | Capabilities relating to the :lsp:`textDocument/documentColor` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.color_provider.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/document-highlight.rst: -------------------------------------------------------------------------------- 1 | ``documentHighlight`` 2 | ===================== 3 | 4 | Capabilities relating to the :lsp:`textDocument/documentHighlight` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.document_highlight.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/document-link.rst: -------------------------------------------------------------------------------- 1 | ``documentLink`` 2 | ================ 3 | 4 | Capabilities relating to the :lsp:`textDocument/documentLink` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.document_link.dynamic_registration 9 | 10 | .. bool-table:: text_document.document_link.tooltip_support 11 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/document-symbols.rst: -------------------------------------------------------------------------------- 1 | ``documentSymbols`` 2 | =================== 3 | 4 | Capabilities relating to the :lsp:`textDocument/documentSymbols` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.document_symbol.dynamic_registration 9 | 10 | .. bool-table:: text_document.document_symbol.hierarchical_document_symbol_support 11 | 12 | .. bool-table:: text_document.document_symbol.label_support 13 | 14 | 15 | ``symbolKind`` 16 | -------------- 17 | 18 | .. values-table:: text_document.document_symbol.symbol_kind.value_set 19 | :value-set: SymbolKind 20 | 21 | ``tagSupport`` 22 | -------------- 23 | 24 | .. values-table:: text_document.document_symbol.tag_support.value_set 25 | :value-set: SymbolTag 26 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/folding-range.rst: -------------------------------------------------------------------------------- 1 | ``foldingRange`` 2 | ================ 3 | 4 | Capabilities relating to the :lsp:`textDocument/foldingRange` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.folding_range.dynamic_registration 9 | 10 | .. values-table:: text_document.folding_range.range_limit 11 | 12 | .. bool-table:: text_document.folding_range.line_folding_only 13 | 14 | ``foldingRange`` 15 | ---------------- 16 | 17 | .. bool-table:: text_document.folding_range.folding_range.collapsed_text 18 | 19 | 20 | ``foldingRangeKind`` 21 | -------------------- 22 | 23 | .. values-table:: text_document.folding_range.folding_range_kind.value_set 24 | :value-set: FoldingRangeKind 25 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/formatting.rst: -------------------------------------------------------------------------------- 1 | ``formatting`` 2 | ============== 3 | 4 | Capabilities relating to the :lsp:`textDocument/formatting` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.formatting.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/hover.rst: -------------------------------------------------------------------------------- 1 | ``hover`` 2 | ========= 3 | 4 | Capabilities relating to the :lsp:`textDocument/hover` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.hover.dynamic_registration 9 | 10 | .. values-table:: text_document.hover.content_format 11 | :value-set: MarkupKind 12 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/implementation.rst: -------------------------------------------------------------------------------- 1 | ``implementation`` 2 | ================== 3 | 4 | Capabilities relating to the :lsp:`textDocument/implementation` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.implementation.dynamic_registration 9 | 10 | .. bool-table:: text_document.implementation.link_support 11 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/inlay-hint.rst: -------------------------------------------------------------------------------- 1 | ``inlayHint`` 2 | ============= 3 | 4 | Capabilities relating to the :lsp:`textDocument/inlayHint` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.inlay_hint.dynamic_registration 9 | 10 | ``resolveSupport`` 11 | ------------------ 12 | 13 | .. values-table:: text_document.inlay_hint.resolve_support.properties 14 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/inline-value.rst: -------------------------------------------------------------------------------- 1 | ``inlineValue`` 2 | =============== 3 | 4 | Capabilities relating to the :lsp:`textDocument/inlineValue` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.inline_value.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/linked-editing-range.rst: -------------------------------------------------------------------------------- 1 | ``linkedEditingRange`` 2 | ====================== 3 | 4 | Capabilities relating to the :lsp:`textDocument/linkedEditingRange` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.linked_editing_range.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/moniker.rst: -------------------------------------------------------------------------------- 1 | ``moniker`` 2 | =========== 3 | 4 | Capabilities relating to the :lsp:`textDocument/moniker` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.moniker.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/on-type-formatting.rst: -------------------------------------------------------------------------------- 1 | ``onTypeFormatting`` 2 | ==================== 3 | 4 | Capabilities relating to the :lsp:`textDocument/onTypeFormatting` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.on_type_formatting.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/publish-diagnostics.rst: -------------------------------------------------------------------------------- 1 | ``publishDiagnostics`` 2 | ====================== 3 | 4 | Capabilities relating to the :lsp:`textDocument/publishDiagnostics` notification 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.publish_diagnostics.related_information 9 | 10 | .. bool-table:: text_document.publish_diagnostics.version_support 11 | 12 | .. bool-table:: text_document.publish_diagnostics.code_description_support 13 | 14 | .. bool-table:: text_document.publish_diagnostics.data_support 15 | 16 | ``tagSupport`` 17 | -------------- 18 | 19 | .. values-table:: text_document.publish_diagnostics.tag_support.value_set 20 | :value-set: DiagnosticTag 21 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/range-formatting.rst: -------------------------------------------------------------------------------- 1 | ``rangeFormatting`` 2 | =================== 3 | 4 | Capabilities relating to the :lsp:`textDocument/rangeFormatting` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.range_formatting.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/references.rst: -------------------------------------------------------------------------------- 1 | ``references`` 2 | ============== 3 | 4 | Capabilities relating to the :lsp:`textDocument/references` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.references.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/rename.rst: -------------------------------------------------------------------------------- 1 | ``rename`` 2 | ========== 3 | 4 | Capabilities relating to the :lsp:`textDocument/rename` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.rename.dynamic_registration 9 | 10 | .. bool-table:: text_document.rename.prepare_support 11 | 12 | .. bool-table:: text_document.rename.honors_change_annotations 13 | 14 | 15 | ``prepareSupportDefaultBehavior`` 16 | --------------------------------- 17 | 18 | .. values-table:: text_document.rename.prepare_support_default_behavior 19 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/selection-range.rst: -------------------------------------------------------------------------------- 1 | ``selectionRange`` 2 | ================== 3 | 4 | Capabilities relating to the :lsp:`textDocument/selectionRange` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.selection_range.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/semantic-tokens.rst: -------------------------------------------------------------------------------- 1 | ``semanticTokens`` 2 | ================== 3 | 4 | Capabilities relating to :lsp:`textDocument/semanticTokens`. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.semantic_tokens.dynamic_registration 9 | 10 | .. bool-table:: text_document.semantic_tokens.requests.range 11 | 12 | .. bool-table:: text_document.semantic_tokens.requests.full 13 | 14 | .. bool-table:: text_document.semantic_tokens.requests.full.delta 15 | 16 | .. values-table:: text_document.semantic_tokens.token_types 17 | :value-set: SemanticTokenTypes 18 | 19 | .. values-table:: text_document.semantic_tokens.token_modifiers 20 | :value-set: SemanticTokenModifiers 21 | 22 | .. bool-table:: text_document.semantic_tokens.overlapping_token_support 23 | 24 | .. bool-table:: text_document.semantic_tokens.multiline_token_support 25 | 26 | .. bool-table:: text_document.semantic_tokens.server_cancel_support 27 | 28 | .. bool-table:: text_document.semantic_tokens.augments_syntax_tokens 29 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/signature-help.rst: -------------------------------------------------------------------------------- 1 | ``signatureHelp`` 2 | ================= 3 | 4 | Capabilities relating to the :lsp:`textDocument/signatureHelp` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.signature_help.dynamic_registration 9 | 10 | .. bool-table:: text_document.signature_help.context_support 11 | 12 | ``signatureInformation`` 13 | ------------------------ 14 | 15 | .. bool-table:: text_document.signature_help.signature_information.active_parameter_support 16 | 17 | 18 | ``parameterInformation`` 19 | ^^^^^^^^^^^^^^^^^^^^^^^^ 20 | 21 | .. bool-table:: text_document.signature_help.signature_information.parameter_information.label_offset_support 22 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/synchronization.rst: -------------------------------------------------------------------------------- 1 | ``synchronization`` 2 | =================== 3 | 4 | .. default-domain:: capabilities 5 | 6 | .. bool-table:: text_document.synchronization.dynamic_registration 7 | 8 | .. bool-table:: text_document.synchronization.did_save 9 | 10 | .. bool-table:: text_document.synchronization.will_save 11 | 12 | .. bool-table:: text_document.synchronization.will_save_wait_until 13 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/type-definition.rst: -------------------------------------------------------------------------------- 1 | ``typeDefinition`` 2 | ================== 3 | 4 | Capabilities relating to the :lsp:`textDocument/typeDefinition` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.type_definition.dynamic_registration 9 | 10 | .. bool-table:: text_document.type_definition.link_support 11 | -------------------------------------------------------------------------------- /docs/capabilities/text-document/type-hierachy.rst: -------------------------------------------------------------------------------- 1 | ``typeHierarchy`` 2 | ================= 3 | 4 | Capabilities relating to the :lsp:`textDocument/prepareTypeHierarchy` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: text_document.type_hierarchy.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/window.rst: -------------------------------------------------------------------------------- 1 | ``window`` 2 | ========== 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | :glob: 7 | 8 | window/* 9 | -------------------------------------------------------------------------------- /docs/capabilities/window/progress.rst: -------------------------------------------------------------------------------- 1 | ``workDoneProgress`` 2 | ==================== 3 | 4 | .. capabilities:bool-table:: window.work_done_progress 5 | -------------------------------------------------------------------------------- /docs/capabilities/window/show-document.rst: -------------------------------------------------------------------------------- 1 | ``showDocument`` 2 | ================ 3 | 4 | Capabilities relating to the :lsp:`window/showDocument` request. 5 | 6 | .. capabilities:bool-table:: window.show_document.support 7 | -------------------------------------------------------------------------------- /docs/capabilities/window/show-message.rst: -------------------------------------------------------------------------------- 1 | ``showMessage`` 2 | =============== 3 | 4 | Capabilities relating to the :lsp:`window/showMessageRequest` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | ``messageActionItem`` 9 | --------------------- 10 | 11 | .. bool-table:: window.show_message.message_action_item.additional_properties_support 12 | -------------------------------------------------------------------------------- /docs/capabilities/workspace.rst: -------------------------------------------------------------------------------- 1 | ``workspace`` 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | :glob: 7 | 8 | workspace/* 9 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/apply-edit.rst: -------------------------------------------------------------------------------- 1 | ``applyEdit`` 2 | ============= 3 | 4 | Capabilities relating to the :lsp:`workspace/applyEdit` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: workspace.apply_edit 9 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/code-lens.rst: -------------------------------------------------------------------------------- 1 | ``codeLens`` 2 | ============ 3 | 4 | Capabilities relating to the :lsp:`workspace/codeLens/refresh` request. 5 | 6 | .. capabilities:bool-table:: workspace.code_lens.refresh_support 7 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/configuration.rst: -------------------------------------------------------------------------------- 1 | ``configuration`` 2 | ================= 3 | 4 | Capabilities relating to the :lsp:`workspace/configuration` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: workspace.configuration 9 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/diagnostics.rst: -------------------------------------------------------------------------------- 1 | ``diagnostics`` 2 | =============== 3 | 4 | Capabilities relating to the :lsp:`workspace/diagnostic/refresh` request. 5 | 6 | .. capabilities:bool-table:: workspace.diagnostics.refresh_support 7 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/did-change-configuration.rst: -------------------------------------------------------------------------------- 1 | ``didChangeConfiguration`` 2 | ========================== 3 | 4 | Capabilities relating to the :lsp:`workspace/didChangeConfiguration` notification. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: workspace.did_change_configuration.dynamic_registration 9 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/did-change-watched-files.rst: -------------------------------------------------------------------------------- 1 | ``didChangeWatchedFiles`` 2 | ========================= 3 | 4 | .. default-domain:: capabilities 5 | 6 | .. bool-table:: workspace.did_change_watched_files.dynamic_registration 7 | 8 | .. bool-table:: workspace.did_change_watched_files.relative_pattern_support 9 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/execute-command.rst: -------------------------------------------------------------------------------- 1 | ``executeCommand`` 2 | ================== 3 | 4 | Capabilities relating to the :lsp:`workspace/executeCommand` request. 5 | 6 | .. capabilities:bool-table:: workspace.execute_command.dynamic_registration 7 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/file-operations.rst: -------------------------------------------------------------------------------- 1 | ``fileOperations`` 2 | ================== 3 | 4 | .. default-domain:: capabilities 5 | 6 | .. bool-table:: workspace.file_operations.dynamic_registration 7 | 8 | .. bool-table:: workspace.file_operations.will_create 9 | 10 | .. bool-table:: workspace.file_operations.did_create 11 | 12 | .. bool-table:: workspace.file_operations.will_rename 13 | 14 | .. bool-table:: workspace.file_operations.did_rename 15 | 16 | .. bool-table:: workspace.file_operations.will_delete 17 | 18 | .. bool-table:: workspace.file_operations.did_delete 19 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/inlay-hint.rst: -------------------------------------------------------------------------------- 1 | ``inlayHint`` 2 | ============= 3 | 4 | Capabilities relating to the :lsp:`workspace/inlayHint/refresh` request. 5 | 6 | .. capabilities:bool-table:: workspace.inlay_hint.refresh_support 7 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/inline-value.rst: -------------------------------------------------------------------------------- 1 | ``inlineValue`` 2 | =============== 3 | 4 | Capabilities relating to the :lsp:`workspace/inlineValue/refresh` request. 5 | 6 | .. capabilities:bool-table:: workspace.inline_value.refresh_support 7 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/semantic-tokens.rst: -------------------------------------------------------------------------------- 1 | ``semanticTokens`` 2 | ================== 3 | 4 | Capabilities relating to the :lsp:`workspace/semanticTokens/refresh` request. 5 | 6 | .. capabilities:bool-table:: workspace.semantic_tokens.refresh_support 7 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/symbol.rst: -------------------------------------------------------------------------------- 1 | ``symbol`` 2 | ========== 3 | 4 | Capabilities relating to the :lsp:`workspace/symbol` request. 5 | 6 | .. default-domain:: capabilities 7 | 8 | .. bool-table:: workspace.symbol.dynamic_registration 9 | 10 | ``symbolKind`` 11 | -------------- 12 | 13 | .. values-table:: workspace.symbol.symbol_kind.value_set 14 | :value-set: SymbolKind 15 | 16 | ``tagSupport`` 17 | -------------- 18 | 19 | .. values-table:: workspace.symbol.tag_support.value_set 20 | :value-set: SymbolTag 21 | 22 | ``resolveSupport`` 23 | ------------------ 24 | 25 | .. values-table:: workspace.symbol.resolve_support.properties 26 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/workspace-edit.rst: -------------------------------------------------------------------------------- 1 | ``workspaceEdit`` 2 | ================= 3 | 4 | .. default-domain:: capabilities 5 | 6 | .. bool-table:: workspace.workspace_edit.document_changes 7 | 8 | .. values-table:: workspace.workspace_edit.resource_operations 9 | :value-set: ResourceOperationKind 10 | 11 | .. values-table:: workspace.workspace_edit.failure_handling 12 | 13 | .. bool-table:: workspace.workspace_edit.normalizes_line_endings 14 | 15 | ``changeAnnotationSupport`` 16 | --------------------------- 17 | 18 | .. bool-table:: workspace.workspace_edit.change_annotation_support.groups_on_label 19 | -------------------------------------------------------------------------------- /docs/capabilities/workspace/workspace-folders.rst: -------------------------------------------------------------------------------- 1 | ``workspaceFolders`` 2 | ==================== 3 | 4 | Capabilities relating to the :lsp:`workspace/workspaceFolders` request. 5 | 6 | .. capabilities:bool-table:: workspace.workspace_folders 7 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | import os 9 | import sys 10 | 11 | sys.path.insert(0, os.path.abspath("./ext")) 12 | 13 | from docutils import nodes # noqa: E402 14 | from sphinx.application import Sphinx # noqa: E402 15 | 16 | DEV_BUILD = os.getenv("BUILDDIR", None) == "latest" 17 | BRANCH = "develop" if DEV_BUILD else "release" 18 | 19 | project = "LSP Devtools" 20 | copyright = "2023, Alex Carney" 21 | author = "Alex Carney" 22 | 23 | # -- General configuration --------------------------------------------------- 24 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 25 | 26 | extensions = [ 27 | # built-in extensions 28 | "sphinx.ext.autodoc", 29 | "sphinx.ext.napoleon", 30 | "sphinx.ext.intersphinx", 31 | # 3rd party extensions 32 | "myst_parser", 33 | "sphinx_copybutton", 34 | "sphinx_design", 35 | # local extensions 36 | "capabilities", 37 | "supported_clients", 38 | ] 39 | 40 | autoclass_content = "both" 41 | autodoc_member_order = "groupwise" 42 | autodoc_typehints = "description" 43 | 44 | intersphinx_mapping = { 45 | "pygls": ("https://pygls.readthedocs.io/en/latest/", None), 46 | "python": ("https://docs.python.org/3/", None), 47 | "pytest": ("https://docs.pytest.org/en/stable/", None), 48 | } 49 | 50 | templates_path = ["_templates"] 51 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 52 | 53 | 54 | # -- Options for HTML output ------------------------------------------------- 55 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 56 | 57 | html_theme = "furo" 58 | html_title = "LSP Devtools" 59 | html_static_path = ["_static"] 60 | html_theme_options = { 61 | "source_repository": "https://github.com/swyddfa/lsp-devtools/", 62 | "source_branch": BRANCH, 63 | "source_directory": "docs/", 64 | } 65 | 66 | if DEV_BUILD: 67 | html_theme_options["announcement"] = ( 68 | "This is the unstable version of the documentation, features may change or " 69 | "be removed without warning. " 70 | 'Click here ' 71 | "to view the released version" 72 | ) 73 | 74 | 75 | def lsp_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 76 | """Link to sections within the lsp specification.""" 77 | 78 | anchor = text.replace("/", "_") 79 | ref = f"https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#{anchor}" # noqa: E501 80 | 81 | node = nodes.reference(rawtext, text, refuri=ref, **options) 82 | return [node], [] 83 | 84 | 85 | def setup(app: Sphinx): 86 | app.add_css_file("custom.css") 87 | app.add_role("lsp", lsp_role) 88 | -------------------------------------------------------------------------------- /docs/ext/supported_clients.py: -------------------------------------------------------------------------------- 1 | import importlib.resources as resources 2 | 3 | from docutils import nodes 4 | from docutils.parsers.rst import Directive 5 | from packaging.version import parse as parse_version 6 | from sphinx.application import Sphinx 7 | 8 | 9 | class SupportedClients(Directive): 10 | def load_clients(self): 11 | clients = {} 12 | 13 | for resource in resources.files("pytest_lsp.clients").iterdir(): 14 | # Skip the README or any other files that we don't care about. 15 | if not resource.name.endswith(".json"): 16 | continue 17 | 18 | client, version = resource.name.replace(".json", "").split("_v") 19 | client = " ".join([c.capitalize() for c in client.split("_")]) 20 | 21 | clients.setdefault(client, []).append(version) 22 | 23 | return clients 24 | 25 | def run(self): 26 | rows = [] 27 | clients = self.load_clients() 28 | 29 | for client, versions in clients.items(): 30 | version_string = ", ".join(sorted(versions, key=parse_version)) 31 | rows.append( 32 | nodes.row( 33 | "", 34 | nodes.entry( 35 | "", 36 | nodes.paragraph("", client), 37 | ), 38 | nodes.entry( 39 | "", 40 | nodes.paragraph("", version_string), 41 | ), 42 | ), 43 | ) 44 | 45 | header = nodes.row( 46 | "", 47 | nodes.entry( 48 | "", 49 | nodes.paragraph("", "Client"), 50 | ), 51 | nodes.entry( 52 | "", 53 | nodes.paragraph("", "Versions"), 54 | ), 55 | ) 56 | 57 | table = nodes.table( 58 | "", 59 | nodes.tgroup( 60 | "", 61 | nodes.colspec("", colwidth="8"), 62 | nodes.colspec("", colwidth="4"), 63 | nodes.thead("", header), 64 | nodes.tbody("", *rows), 65 | cols=2, 66 | ), 67 | ) 68 | 69 | return [table] 70 | 71 | 72 | def setup(app: Sphinx): 73 | app.add_directive("supported-clients", SupportedClients) 74 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | LSP Devtools 2 | ============ 3 | 4 | The LSP Devtools project provides a number of tools for making the the 5 | process of developing language servers easier. 6 | 7 | Client Capability Index 8 | ----------------------- 9 | 10 | .. important:: 11 | 12 | This accuracy of this section entirely depends on the captured capabilities data that is `bundled `__ with pytest-lsp. 13 | 14 | Pull requests for corrections and new data welcome! 15 | 16 | .. toctree:: 17 | :glob: 18 | :hidden: 19 | :caption: Client Capabilities 20 | 21 | capabilities/* 22 | 23 | Inspired by `caniuse.com `__ this provides information on which clients support which features of the `LSP Specification `__. 24 | 25 | .. grid:: 2 26 | :gutter: 2 27 | 28 | .. grid-item-card:: General 29 | :columns: 12 30 | :link: /capabilities/general 31 | :link-type: doc 32 | :text-align: center 33 | 34 | General client capabilities. 35 | 36 | .. grid-item-card:: NotebookDocument 37 | :link: /capabilities/notebook-document 38 | :link-type: doc 39 | :text-align: center 40 | 41 | Capabilities for NotebookDocuments. 42 | 43 | .. grid-item-card:: TextDocument 44 | :link: /capabilities/text-document 45 | :link-type: doc 46 | :text-align: center 47 | 48 | Capabilities for text document methods like completion, code actions and more. 49 | 50 | .. grid-item-card:: Window 51 | :link: /capabilities/window 52 | :link-type: doc 53 | :text-align: center 54 | 55 | Work done progress, show document and message requests 56 | 57 | .. grid-item-card:: Workspace 58 | :link: /capabilities/workspace 59 | :link-type: doc 60 | :text-align: center 61 | 62 | File operations, workspace folders and configuration 63 | 64 | lsp-devtools 65 | ------------ 66 | 67 | .. toctree:: 68 | :hidden: 69 | :caption: lsp-devtools 70 | 71 | lsp-devtools/guide 72 | lsp-devtools/changelog 73 | 74 | 75 | .. figure:: https://user-images.githubusercontent.com/2675694/273293510-e43fdc92-03dd-40c9-aaca-ddb5e526031a.png 76 | 77 | The `lsp-devtools `_ package provides a collection of CLI utilities that help inspect and visualise the interactions between a language client and a server. 78 | 79 | See the :doc:`lsp-devtools/guide/getting-started` guide for details. 80 | 81 | pytest-lsp 82 | ---------- 83 | 84 | .. toctree:: 85 | :hidden: 86 | :caption: pytest-lsp 87 | 88 | pytest-lsp/guide 89 | pytest-lsp/howto 90 | pytest-lsp/reference 91 | pytest-lsp/changelog 92 | 93 | `pytest-lsp `_ is a pytest plugin for writing end-to-end tests for language servers. 94 | 95 | .. literalinclude:: ./pytest-lsp/guide/window-log-message-output.txt 96 | :language: none 97 | 98 | It works by running the language server in a subprocess and communicating with it over stdio, just like a real language client. 99 | This also means ``pytest-lsp`` can be used to test language servers written in any language - not just Python. 100 | 101 | ``pytest-lsp`` relies on `pygls `__ for its language server protocol implementation. 102 | 103 | See the :doc:`pytest-lsp/guide/getting-started` guide for details on how to write your first test case. 104 | -------------------------------------------------------------------------------- /docs/lsp-devtools/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ```{include} ../../lib/lsp-devtools/CHANGES.md 4 | :relative-images: 5 | ``` 6 | -------------------------------------------------------------------------------- /docs/lsp-devtools/guide.rst: -------------------------------------------------------------------------------- 1 | User Guide 2 | ---------- 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | guide/getting-started 8 | guide/record-command 9 | guide/inspect-command 10 | -------------------------------------------------------------------------------- /docs/lsp-devtools/guide/getting-started.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | .. highlight:: none 5 | 6 | This guide will introduce you to the tools available in the ``lsp-devtools`` package. 7 | If you have not done so already, you can install it using ``pipx`` :: 8 | 9 | pipx install lsp-devtools 10 | 11 | .. admonition:: Did you say pipx? 12 | 13 | `pipx `_ is a tool that automates the process of installing Python packages into their own isolated Python environments - useful for standalone applications like ``lsp-devtools`` 14 | 15 | The LSP Agent 16 | ------------- 17 | 18 | In order to use most of the tools in ``lsp-devtools`` you need to wrap your language server with the LSP Agent. 19 | The agent is a simple program that sits inbetween a language client and the server as shown in the diagram below. 20 | 21 | .. figure:: /images/lsp-devtools-architecture.svg 22 | 23 | ``lsp-devtools`` architecture 24 | 25 | The agent acts as a messenger, forwarding messages from the client to the server and vice versa. 26 | However, it sends an additional copy of each message over a local TCP connection to some "Server" application - typically another ``lsp-devtools`` command like ``record`` or ``tui``. 27 | 28 | In general, using ``lsp-devtools`` can be broken down into a 3 step process. 29 | 30 | #. Configure your language client to launch your language server via the agent, rather than launching it directly. 31 | 32 | #. Start the server application e.g. ``lsp-devtools record`` or ``lsp-devtools tui`` 33 | 34 | #. Start your language client. 35 | 36 | .. _lsp-devtools-configure-client: 37 | 38 | Configuring your client 39 | ^^^^^^^^^^^^^^^^^^^^^^^ 40 | 41 | In order to wrap your language server with the LSP Agent, you need to be able to modify the command your language client uses to start your language server to the following:: 42 | 43 | lsp-devtools agent -- 44 | 45 | The ``agent`` command will interpret anything given after the double dashes (``--``) to be the command used to invoke your language server. 46 | By default, the agent will attempt to connect to a server application on ``localhost:8765`` but this can be changed using the ``--host `` and ``--port `` arguments:: 47 | 48 | lsp-devtools agent --host 127.0.0.1 --port 1234 -- 49 | 50 | .. tip:: 51 | 52 | Since the agent only requires your server's start command, you can use ``lsp-devtools`` with a server written in any language. 53 | 54 | 55 | As an example, let's configure Neovim to launch the ``esbonio`` language server via the agent. 56 | Using `nvim-lspconfig `_ a standard configuration might look something like the following 57 | 58 | .. code-block:: lua 59 | 60 | lspconfig.esbonio.setup{ 61 | capabilities = capabilities, 62 | cmd = { "esbonio" }, 63 | filetypes = {"rst"}, 64 | init_options = { 65 | server = { 66 | logLevel = "debug" 67 | }, 68 | sphinx = { 69 | buildDir = "${confDir}/_build" 70 | } 71 | }, 72 | on_attach = on_attach, 73 | } 74 | 75 | To update this to launch the server via the agent, we need only modify the ``cmd`` field (or add one if it does not exist) to include ``lsp-devtools agent --`` 76 | 77 | .. code-block:: diff 78 | 79 | lspconfig.esbonio.setup{ 80 | capabilities = capabilities, 81 | - cmd = { "esbonio" }, 82 | + cmd = { "lsp-devtools", "agent", "--", "esbonio" }, 83 | ... 84 | } 85 | 86 | Server Applications 87 | ------------------- 88 | 89 | Once you have your client configured, you need to start the application the agent is going to try to connect to. 90 | Currently ``lsp-devtools`` provides the following applications 91 | 92 | ``lsp-devtools record`` 93 | As the name suggests, this command supports recording all (or a subset of) messages in a LSP session to a text file or SQLite database. 94 | However, it can also print these messages direct to the console with support for filtering and custom formatting of message contents. 95 | 96 | .. figure:: /images/record-example.svg 97 | 98 | See :doc:`/lsp-devtools/guide/record-command` for details 99 | 100 | ``lsp-devtools inspect`` 101 | An interactive terminal application, powered by `textual `_. 102 | 103 | .. figure:: /images/tui-screenshot.svg 104 | 105 | See :doc:`/lsp-devtools/guide/inspect-command` for details 106 | -------------------------------------------------------------------------------- /docs/lsp-devtools/guide/inspect-command.rst: -------------------------------------------------------------------------------- 1 | LSP Inspector 2 | ============= 3 | 4 | .. figure:: /images/tui-screenshot.svg 5 | :align: center 6 | 7 | The ``lsp-devtools inspect`` command 8 | 9 | 10 | The ``lsp-devtools inspect`` command opens an application that allows you to browse all the messages sent between a client and server during an LSP session. 11 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/pytest-lsp/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ```{include} ../../lib/pytest-lsp/CHANGES.md 4 | :relative-images: 5 | ``` 6 | -------------------------------------------------------------------------------- /docs/pytest-lsp/guide.rst: -------------------------------------------------------------------------------- 1 | User Guide 2 | ========== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | guide/getting-started 8 | guide/language-client 9 | guide/client-capabilities 10 | guide/fixtures 11 | guide/troubleshooting 12 | -------------------------------------------------------------------------------- /docs/pytest-lsp/guide/client-capabilities-error.txt: -------------------------------------------------------------------------------- 1 | $ pytest -W error::pytest_lsp.LspSpecificationWarning 2 | ======================================== test session starts ======================================== 3 | platform linux -- Python 3.11.3, pytest-7.2.0, pluggy-1.0.0 4 | rootdir: test_client_capabilities_error0, configfile: tox.ini 5 | plugins: typeguard-2.13.3, asyncio-0.20.2, lsp-0.2.1 6 | asyncio: mode=Mode.AUTO 7 | collected 1 item 8 | 9 | test_server.py F [100%] 10 | 11 | ============================================= FAILURES ============================================== 12 | _________________________________________ test_completions __________________________________________ 13 | 14 | ... 15 | try: 16 | result_checker(capabilities, result) 17 | except AssertionError as e: 18 | > warnings.warn(str(e), LspSpecificationWarning, stacklevel=4) 19 | E pytest_lsp.checks.LspSpecificationWarning: Client does not support snippets. 20 | E assert False 21 | 22 | /.../site-packages/pytest_lsp/checks.py:73: LspSpecificationWarning 23 | ====================================== short test summary info ====================================== 24 | FAILED test_server.py::test_completions - pytest_lsp.checks.LspSpecificationWarning: Client does n... 25 | ========================================= 1 failed in 1.16s ========================================= 26 | -------------------------------------------------------------------------------- /docs/pytest-lsp/guide/client-capabilities-ignore.txt: -------------------------------------------------------------------------------- 1 | $ pytest -W ignore::pytest_lsp.LspSpecificationWarning 2 | ======================================== test session starts ======================================== 3 | platform linux -- Python 3.11.3, pytest-7.2.0, pluggy-1.0.0 4 | rootdir: test_client_capabilities_ignore0, configfile: tox.ini 5 | plugins: typeguard-2.13.3, asyncio-0.20.2, lsp-0.2.1 6 | asyncio: mode=Mode.AUTO 7 | collected 1 item 8 | 9 | test_server.py . [100%] 10 | 11 | ========================================= 1 passed in 1.02s ========================================= 12 | -------------------------------------------------------------------------------- /docs/pytest-lsp/guide/client-capabilities-output.txt: -------------------------------------------------------------------------------- 1 | $ pytest 2 | ======================================== test session starts ======================================== 3 | platform linux -- Python 3.11.3, pytest-7.2.0, pluggy-1.0.0 4 | rootdir: test_client_capabilities0, configfile: tox.ini 5 | plugins: typeguard-2.13.3, asyncio-0.20.2, lsp-0.2.1 6 | asyncio: mode=Mode.AUTO 7 | collected 1 item 8 | 9 | test_server.py . [100%] 10 | 11 | ========================================= warnings summary ========================================== 12 | test_server.py::test_completions 13 | test_client_capabilities0/test_server.py:35: LspSpecificationWarning: Client does not support snippets. 14 | assert False 15 | results = await client.text_document_completion_async( 16 | 17 | -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html 18 | =================================== 1 passed, 1 warning in 1.02s ==================================== 19 | -------------------------------------------------------------------------------- /docs/pytest-lsp/guide/client-capabilities.rst: -------------------------------------------------------------------------------- 1 | Client Capabilities 2 | =================== 3 | 4 | The 5 | `initialize `_ 6 | request at the start of an LSP session allows the client and server to exchange information about each other. 7 | Of particular interest is the 8 | `ClientCapabilities `_ 9 | field which is used to inform the server which parts of the specification the client supports. 10 | 11 | Setting this field to the right value ``pytest-lsp`` can pretend to be a particular editor at a particular version and check to see if the server adapts accordingly. 12 | 13 | .. _pytest-lsp-supported-clients: 14 | 15 | Supported Clients 16 | ----------------- 17 | 18 | ``pytest-lsp`` currently supports the following clients and versions. 19 | 20 | .. supported-clients:: 21 | 22 | The :func:`~pytest_lsp.client_capabilities` function can be used to load the capabilities corresponding to a given client name 23 | 24 | .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/client-capabilities/t_server.py 25 | :language: python 26 | :start-at: @pytest_lsp.fixture 27 | :end-at: await lsp_client.shutdown_session() 28 | 29 | .. _pytest-lsp-spec-checks: 30 | 31 | Specification Compliance Checks 32 | ------------------------------- 33 | 34 | By setting the client's capabilities to anything other than ``ClientCapabilities()``, ``pytest-lsp`` will automatically enable checks to ensure that the server respects the capabilities published by the client. 35 | If any issues are found, ``pytest-lsp`` will emit an :class:`~pytest_lsp.checks.LspSpecificationWarning`. 36 | 37 | .. tip:: 38 | 39 | For full details on the checks that have been implemented see the :mod:`pytest_lsp.checks` module. 40 | 41 | 42 | As an example, let's write a test for the following language server. 43 | 44 | .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/client-capabilities/server.py 45 | :language: python 46 | :start-at: @server.feature 47 | :end-at: ] 48 | 49 | When it receives a completion request it returns a single item called ``greet`` which, when selected, expands into a snippet making it easier to type the sequence ``"Hello, world!"``. 50 | Let's write a test to confirm it works as expected. 51 | 52 | .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/client-capabilities/t_server.py 53 | :language: python 54 | :start-at: async def test_completions 55 | 56 | Running this test while pretending to be ``neovim`` we should see that while it passes, ``pytest-lsp`` will emit a warning saying that neovim does not support snippets. 57 | 58 | .. note:: 59 | 60 | *Vanilla* Neovim v0.6.1 does not support snippets, though there are many plugins that can be installed to enable support for them. 61 | 62 | .. literalinclude:: ./client-capabilities-output.txt 63 | :language: none 64 | 65 | Strict Checks 66 | ^^^^^^^^^^^^^ 67 | 68 | You can upgrade these warnings to be errors if you wish by passing ``-W error::pytest_lsp.LspSpecificationWarning`` to pytest. 69 | 70 | .. literalinclude:: ./client-capabilities-error.txt 71 | :language: none 72 | 73 | Disabling Checks 74 | ^^^^^^^^^^^^^^^^ 75 | 76 | Alternatively, you can ignore these warnings by passing ``-W ignore::pytest_lsp.LspSpecificationWarning`` to pytest. 77 | 78 | .. literalinclude:: ./client-capabilities-ignore.txt 79 | :language: none 80 | 81 | 82 | .. seealso:: 83 | 84 | :ref:`pytest:controlling-warnings` 85 | Pytest's documentation on configuring how warnings should be handled 86 | 87 | :ref:`python:warning-filter` 88 | Python's built in warning filter syntax 89 | -------------------------------------------------------------------------------- /docs/pytest-lsp/guide/fixtures.rst: -------------------------------------------------------------------------------- 1 | Fixtures 2 | ======== 3 | 4 | .. highlight:: none 5 | 6 | Parameterised Fixtures 7 | ---------------------- 8 | 9 | Like regular pytest fixtures, :func:`pytest_lsp.fixture` supports `parameterisation `__. 10 | This can be used to run the same set of tests while pretending to be a different client each time. 11 | 12 | .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/parameterised-clients/t_server.py 13 | :language: python 14 | :start-at: @pytest_lsp.fixture 15 | :end-at: await lsp_client.shutdown_session() 16 | 17 | 18 | Requesting Other Fixtures 19 | ------------------------- 20 | 21 | As you would expect, it's possible to request other fixtures to help set up your client. 22 | 23 | .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/fixture-passthrough/t_server.py 24 | :language: python 25 | :start-at: @pytest.fixture 26 | :end-at: await lsp_client.shutdown_session() 27 | -------------------------------------------------------------------------------- /docs/pytest-lsp/guide/getting-started-fail-output.txt: -------------------------------------------------------------------------------- 1 | $ pytest 2 | ============================= test session starts ============================== 3 | platform linux -- Python 3.11.9, pytest-7.4.4, pluggy-1.5.0 4 | rootdir: /tmp/pytest-of-alex/pytest-12/test_getting_started_fail0 5 | configfile: tox.ini 6 | plugins: lsp-0.4.2, asyncio-0.23.8 7 | asyncio: mode=Mode.AUTO 8 | collected 1 item 9 | 10 | test_server.py E [100%] 11 | 12 | ==================================== ERRORS ==================================== 13 | ______________________ ERROR at setup of test_completions ______________________ 14 | 15 | lsp_client = 16 | 17 | @pytest_lsp.fixture( 18 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 19 | ) 20 | async def client(lsp_client: LanguageClient): 21 | # Setup 22 | params = InitializeParams(capabilities=ClientCapabilities()) 23 | > await lsp_client.initialize_session(params) 24 | 25 | test_server.py:21: 26 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 27 | /var/home/alex/Projects/swyddfa/lsp-devtools/release/lib/pytest-lsp/pytest_lsp/client.py:245: in initialize_session 28 | response = await self.initialize_async(params) 29 | /var/home/alex/.local/share/hatch/env/virtual/pytest-lsp/oa_H1-lS/hatch-test.py3.11-7/lib/python3.11/site-packages/pygls/lsp/client.py:244: in initialize_async 30 | return await self.protocol.send_request_async("initialize", params) 31 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 32 | 33 | self = 34 | method = 'initialize' 35 | params = InitializeParams(capabilities=ClientCapabilities(workspace=None, text_document=None, notebook_document=None, window=No..., root_path=None, root_uri=None, initialization_options=None, trace=None, work_done_token=None, workspace_folders=None) 36 | 37 | async def send_request_async(self, method, params=None): 38 | """Wrap pygls' ``send_request_async`` implementation. This will 39 | 40 | - Check the params to see if they're compatible with the client's stated 41 | capabilities 42 | - Check the result to see if it's compatible with the client's stated 43 | capabilities 44 | 45 | Parameters 46 | ---------- 47 | method 48 | The method name of the request to send 49 | 50 | params 51 | The associated parameters to go with the request 52 | 53 | Returns 54 | ------- 55 | Any 56 | The result 57 | """ 58 | check_params_against_client_capabilities( 59 | self._server.capabilities, method, params 60 | ) 61 | > result = await super().send_request_async(method, params) 62 | E RuntimeError: Server process 42326 exited with code: 0 63 | 64 | /var/home/alex/Projects/swyddfa/lsp-devtools/release/lib/pytest-lsp/pytest_lsp/protocol.py:81: RuntimeError 65 | =========================== short test summary info ============================ 66 | ERROR test_server.py::test_completions - RuntimeError: Server process 42326 e... 67 | =============================== 1 error in 1.12s =============================== 68 | -------------------------------------------------------------------------------- /docs/pytest-lsp/guide/getting-started.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | .. highlight:: none 5 | 6 | This guide will walk you through the process of writing your first test case using ``pytest-lsp``. 7 | 8 | If you have not done so already, you can install the ``pytest-lsp`` package using pip:: 9 | 10 | pip install pytest-lsp 11 | 12 | A Simple Language Server 13 | ------------------------ 14 | 15 | Before we can write any tests, we need a language server to test! 16 | For the purposes of this example we'll write a simple language server in Python using the `pygls`_ library but note that ``pytest-lsp`` should work with language servers written in any language or framework. 17 | 18 | The following server implements the ``textDocument/completion`` method 19 | 20 | .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/getting-started/server.py 21 | :language: python 22 | :end-at: ] 23 | 24 | Copy and paste the above code into a file named ``server.py``. 25 | 26 | A Simple Test Case 27 | ------------------ 28 | 29 | Now we can go ahead and test it. 30 | Copy the following code and save it into a file named ``test_server.py``, in the same directory as the ``server.py`` file you created in the previous step. 31 | 32 | .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/getting-started/t_server.py 33 | :language: python 34 | :end-at: await lsp_client.shutdown_session() 35 | 36 | This creates a `pytest fixture`_ named ``client``, it uses the given ``server_command`` to automatically launch the server in a background process and connect it to a :class:`~pytest_lsp.LanguageClient` instance. 37 | 38 | The setup code (everything before the ``yield``) statement is executed before any tests run, calling :meth:`~pytest_lsp.LanguageClient.initialize_session` on the client to open the LSP session. 39 | 40 | Once all test cases have been called, the code after the ``yield`` statement will be called to shutdown the server and close the session 41 | 42 | With the framework in place, we can go ahead and define our first test case 43 | 44 | .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/getting-started/t_server.py 45 | :language: python 46 | :start-at: @pytest.mark.asyncio 47 | 48 | All that's left is to run the test suite! 49 | 50 | .. literalinclude:: ./getting-started-fail-output.txt 51 | 52 | We forgot to start the server! 53 | Add the following to the bottom of ``server.py``. 54 | 55 | .. code-block:: python 56 | 57 | if __name__ == "__main__": 58 | server.start_io() 59 | 60 | Let's try again 61 | 62 | 63 | .. code-block:: none 64 | 65 | $ pytest 66 | ================================================ test session starts ================================================= 67 | platform linux -- Python 3.11.2, pytest-7.2.0, pluggy-1.0.0 68 | rootdir: /var/home/user/Projects/lsp-devtools/lib/pytest-lsp, configfile: pyproject.toml 69 | plugins: typeguard-2.13.3, asyncio-0.20.2, lsp-0.2.1 70 | asyncio: mode=Mode.AUTO 71 | collected 1 item 72 | 73 | test_server.py . [100%] 74 | 75 | ================================================= 1 passed in 0.96s ================================================== 76 | 77 | Much better! 78 | 79 | 80 | .. _pygls: https://github.com/openlawlibrary/pygls 81 | .. _pytest fixture: https://docs.pytest.org/en/7.1.x/how-to/fixtures.html 82 | -------------------------------------------------------------------------------- /docs/pytest-lsp/guide/window-log-message-output.txt: -------------------------------------------------------------------------------- 1 | ================================== test session starts ==================================== 2 | platform linux -- Python 3.11.2, pytest-7.2.0, pluggy-1.0.0 3 | rootdir: /..., configfile: tox.ini 4 | plugins: typeguard-2.13.3, asyncio-0.20.2, lsp-0.2.1 5 | asyncio: mode=Mode.AUTO 6 | collected 1 item 7 | 8 | test_server.py F [100%] 9 | 10 | ======================================== FAILURES ========================================= 11 | ____________________________________ test_completions _____________________________________ 12 | 13 | client = 14 | ... 15 | E assert False 16 | 17 | test_server.py:35: AssertionError 18 | ---------------------------- Captured window/logMessages call ----------------------------- 19 | LOG: Suggesting item 0 20 | LOG: Suggesting item 1 21 | LOG: Suggesting item 2 22 | LOG: Suggesting item 3 23 | LOG: Suggesting item 4 24 | LOG: Suggesting item 5 25 | LOG: Suggesting item 6 26 | LOG: Suggesting item 7 27 | LOG: Suggesting item 8 28 | LOG: Suggesting item 9 29 | ================================ short test summary info ================================== 30 | FAILED test_server.py::test_completions - assert False 31 | =================================== 1 failed in 1.02s ===================================== 32 | -------------------------------------------------------------------------------- /docs/pytest-lsp/howto.rst: -------------------------------------------------------------------------------- 1 | How To 2 | ====== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | Integrate with lsp-devtools 8 | Migrate to v1 9 | Test Generic JSON-RPC Servers 10 | -------------------------------------------------------------------------------- /docs/pytest-lsp/howto/integrate-with-lsp-devtools.rst: -------------------------------------------------------------------------------- 1 | How To Integrate ``pytest-lsp`` with ``lsp-devtools`` 2 | ===================================================== 3 | 4 | ``pytest-lsp`` is able to forward LSP traffic to utilities like :doc:`lsp-devtools record ` and :doc:`lsp-devtools inspect ` 5 | 6 | .. important:: 7 | 8 | ``pytest-lsp`` does not depend on ``lsp-devtools`` directly and instead assumes that the ``lsp-devtools`` command is available on your ``$PATH``. 9 | It's recommended to install ``lsp-devtools`` via `pipx `__:: 10 | 11 | $ pipx install lsp-devtools 12 | 13 | To enable the integration pass the ``--lsp-devtools`` flag to ``pytest``:: 14 | 15 | $ pytest --lsp-devtools 16 | 17 | This will make ``pytest-lsp`` send the captured traffic to an ``lsp-devtools`` command listening on ``localhost:8765`` by default. 18 | 19 | To change the default host and/or port number you can pass it to the ``--lsp-devtools`` cli option:: 20 | 21 | $ pytest --lsp-devtools 1234 # change port number 22 | $ pytest --lsp-devtools 127.0.01:1234 # change host and port 23 | -------------------------------------------------------------------------------- /docs/pytest-lsp/howto/migrate-to-v1.rst: -------------------------------------------------------------------------------- 1 | How To Migrate to v1 2 | ==================== 3 | 4 | The ``v1`` release of ``pytest-lsp`` contains some breaking changes, mostly as a result of changes in the wider ecosystem. 5 | This guide summarises the changes and provides references on where to get more details. 6 | 7 | Python Support 8 | -------------- 9 | 10 | This release removes support for Python 3.8 and adds support for Python 3.13. 11 | 12 | ``pytest`` 13 | ---------- 14 | 15 | This release removes support for pytest ``v7``, if you have not done so already please update to pytest ``v8``. 16 | 17 | 18 | ``pytest-asyncio`` 19 | ------------------ 20 | 21 | The minimum required version for ``pytest-asyncio`` is now ``0.24``, see `this guide `__ for details on upgrading 22 | 23 | ``pygls`` 24 | --------- 25 | 26 | ``pygls``, the underlying language server protocol implementation used by ``pytest-lsp`` has been upgraded to ``v2``. 27 | See `this guide `__ for details on the breaking changes this brings. 28 | -------------------------------------------------------------------------------- /docs/pytest-lsp/howto/testing-json-rpc-servers.rst: -------------------------------------------------------------------------------- 1 | How To Test Generic JSON-RPC Servers 2 | ==================================== 3 | 4 | While ``pytest-lsp`` is primarily focused on writing tests for LSP servers it is possible to reuse some of the machinery to test other JSON-RPC servers. 5 | 6 | A Simple JSON-RPC Server 7 | ------------------------ 8 | 9 | As an example we'll reuse some of the `pygls`_ internals to write a simple JSON-RPC server that implements the following protocol. 10 | 11 | - client to server request ``math/add``, returns the sum of two numbers ``a`` and ``b`` 12 | - client to server request ``math/sub``, returns the difference of two numbers ``a`` and ``b`` 13 | - client to server notification ``server/exit`` that instructs the server to exit 14 | - server to client notification ``log/message``, allows the server to send debug messages to the client. 15 | 16 | .. note:: 17 | 18 | The details of the implementation below don't really matter as we just need *something* to help us illustrate how to use ``pytest-lsp`` in this way. 19 | 20 | Remember you can write your servers in whatever language/framework you prefer! 21 | 22 | .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/server.py 23 | :language: python 24 | 25 | Constructing a Client 26 | --------------------- 27 | 28 | While ``pytest-lsp`` can manage the connection between client and server, it needs to be given a client that understands the protocol that the server implements. 29 | This is done with a factory function 30 | 31 | .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/t_server.py 32 | :language: python 33 | :start-at: def client_factory(): 34 | :end-at: return client 35 | 36 | The Client Fixture 37 | ------------------ 38 | 39 | Once you have your factory function defined you can pass it to the :class:`~pytest_lsp.ClientServerConfig` when defining your client fixture 40 | 41 | .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/t_server.py 42 | :language: python 43 | :start-at: @pytest_lsp.fixture( 44 | :end-at: rpc_client.protocol.notify 45 | 46 | Writing Test Cases 47 | ------------------ 48 | 49 | With the client fixuture defined, test cases are written almost identically as they would be for your LSP servers. 50 | The only difference is that the generic :meth:`~pygls:pygls.protocol.JsonRPCProtocol.send_request_async` and :meth:`~pygls:pygls.protocol.JsonRPCProtocol.notify` methods are used to communicate with the server. 51 | 52 | .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/t_server.py 53 | :language: python 54 | :start-at: @pytest.mark.asyncio 55 | 56 | However, it is also possible to extend the base :class:`~pygls:pygls.client.JsonRPCClient` to provide a higher level interface to your server. 57 | See the `SubprocessSphinxClient`_ from the `esbonio`_ project for such an example. 58 | 59 | .. _esbonio: https://github.com/swyddfa/esbonio 60 | .. _pygls: https://github.com/openlawlibrary/pygls 61 | .. _SubprocessSphinxClient: https://github.com/swyddfa/esbonio/blob/develop/lib/esbonio/esbonio/server/features/sphinx_manager/client_subprocess.py 62 | -------------------------------------------------------------------------------- /docs/pytest-lsp/reference.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. currentmodule:: pytest_lsp 5 | 6 | LanguageClient 7 | -------------- 8 | 9 | .. autoclass:: LanguageClient 10 | :members: 11 | :show-inheritance: 12 | 13 | 14 | Test Setup 15 | ---------- 16 | 17 | .. autofunction:: fixture 18 | 19 | .. autofunction:: client_capabilities 20 | 21 | .. autoclass:: ClientServerConfig 22 | :members: 23 | 24 | .. autofunction:: make_test_lsp_client 25 | 26 | 27 | Checks 28 | ------ 29 | 30 | .. automodule:: pytest_lsp.checks 31 | :members: 32 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # This assumes you are running the pip install command from the root of the repo e.g. 2 | # $ pip install -r docs/requirements.txt 3 | sphinx 4 | sphinx-copybutton 5 | sphinx-design 6 | furo 7 | myst-parser 8 | pygls>=1.1.0 9 | -e lib/pytest-lsp 10 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1688585123, 6 | "narHash": "sha256-+xFOB4WaRUHuZI7H1tWHTrwY4BnbPmh8M1n/XhPRH0w=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "23de9f3b56e72632c628d92b71c47032e14a3d4d", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixpkgs-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs", 22 | "utils": "utils" 23 | } 24 | }, 25 | "systems": { 26 | "locked": { 27 | "lastModified": 1681028828, 28 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 29 | "owner": "nix-systems", 30 | "repo": "default", 31 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 32 | "type": "github" 33 | }, 34 | "original": { 35 | "owner": "nix-systems", 36 | "repo": "default", 37 | "type": "github" 38 | } 39 | }, 40 | "utils": { 41 | "inputs": { 42 | "systems": "systems" 43 | }, 44 | "locked": { 45 | "lastModified": 1687709756, 46 | "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=", 47 | "owner": "numtide", 48 | "repo": "flake-utils", 49 | "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "numtide", 54 | "repo": "flake-utils", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Developer tooling for language servers"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, utils }: 10 | { 11 | overlays.default = self: super: 12 | nixpkgs.lib.composeManyExtensions [ 13 | (import ./lib/pytest-lsp/nix/pytest-lsp-overlay.nix) 14 | (import ./lib/lsp-devtools/nix/lsp-devtools-overlay.nix) 15 | ] self super; 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /lib/lsp-devtools/.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.2.0 3 | commit = False 4 | tag = False 5 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(.dev(?P\d+))? 6 | serialize = 7 | {major}.{minor}.{patch}.dev{dev} 8 | {major}.{minor}.{patch} 9 | 10 | [bumpversion:file:lsp_devtools/__init__.py] 11 | 12 | [bumpversion:file:pyproject.toml] 13 | -------------------------------------------------------------------------------- /lib/lsp-devtools/.gitignore: -------------------------------------------------------------------------------- 1 | result 2 | -------------------------------------------------------------------------------- /lib/lsp-devtools/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alex Carney 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 | -------------------------------------------------------------------------------- /lib/lsp-devtools/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include lsp_devtools/handlers/dbinit.sql 2 | include lsp_devtools/py.typed 3 | include lsp_devtools/tui/app.css 4 | -------------------------------------------------------------------------------- /lib/lsp-devtools/README.md: -------------------------------------------------------------------------------- 1 | # lsp-devtools: Developer tooling for language servers 2 | 3 | This package provides a collection of cli utilities to support the development of language servers. 4 | While this is a Python package, it can be used with language servers written in any langauge. 5 | 6 | ![lsp-devtools client](https://user-images.githubusercontent.com/2675694/273293510-e43fdc92-03dd-40c9-aaca-ddb5e526031a.png) 7 | 8 | Available commands: 9 | 10 | - `agent`: Wrap a language server allowing other commands to access the messages sent to and from the client 11 | - `client`: **Experimental** a language client with an embedded inspector. Powered by [textual](https://textual.textualize.io/) 12 | - `record`: Connects to an agent and record traffic to file, sqlite db or console. Supports filtering and formatting the output 13 | - `inspect`: A text user interface to visualise and inspect LSP traffic. Powered by [textual](https://textual.textualize.io/) 14 | 15 | See the [documentation](https://lsp-devtools.readthedocs.io/en/latest/) for more information 16 | 17 | ## Installation 18 | 19 | Install using [pipx](https://pypa.github.io/pipx/) 20 | 21 | ``` 22 | pipx install lsp-devtools 23 | ``` 24 | -------------------------------------------------------------------------------- /lib/lsp-devtools/changes/github-template.html: -------------------------------------------------------------------------------- 1 | %(body_pre_docinfo)s 2 | %(docinfo)s 3 | %(body)s 4 | -------------------------------------------------------------------------------- /lib/lsp-devtools/flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1704161960, 6 | "narHash": "sha256-QGua89Pmq+FBAro8NriTuoO/wNaUtugt29/qqA8zeeM=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "63143ac2c9186be6d9da6035fa22620018c85932", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixpkgs-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs", 22 | "utils": "utils" 23 | } 24 | }, 25 | "systems": { 26 | "locked": { 27 | "lastModified": 1681028828, 28 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 29 | "owner": "nix-systems", 30 | "repo": "default", 31 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 32 | "type": "github" 33 | }, 34 | "original": { 35 | "owner": "nix-systems", 36 | "repo": "default", 37 | "type": "github" 38 | } 39 | }, 40 | "utils": { 41 | "inputs": { 42 | "systems": "systems" 43 | }, 44 | "locked": { 45 | "lastModified": 1701680307, 46 | "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", 47 | "owner": "numtide", 48 | "repo": "flake-utils", 49 | "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "numtide", 54 | "repo": "flake-utils", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /lib/lsp-devtools/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "lsp-devtools: Developer tooling for language servers"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, utils }: 10 | 11 | let 12 | eachPythonVersion = versions: f: 13 | builtins.listToAttrs (builtins.map (version: {name = "py${version}"; value = f version; }) versions); in { 14 | 15 | overlays.default = import ./nix/lsp-devtools-overlay.nix; 16 | 17 | packages = utils.lib.eachDefaultSystemMap (system: 18 | let 19 | pkgs = import nixpkgs { inherit system; overlays = [ self.overlays.default ]; }; 20 | in 21 | eachPythonVersion [ "38" "39" "310" "311"] (pyVersion: 22 | pkgs."python${pyVersion}Packages".lsp-devtools 23 | ) 24 | ); 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /lib/lsp-devtools/hatch.toml: -------------------------------------------------------------------------------- 1 | [version] 2 | path = "lsp_devtools/__init__.py" 3 | validate-bump = false 4 | 5 | [build.targets.sdist] 6 | include = ["lsp_devtools", "tests", "CHANGES.md"] 7 | 8 | [build.targets.wheel] 9 | packages = ["lsp_devtools"] 10 | 11 | [envs.hatch-test] 12 | extra-dependencies = ["pytest-asyncio"] 13 | 14 | [envs.hatch-test.env-vars] 15 | UV_PRERELEASE="allow" 16 | 17 | [envs.hatch-static-analysis] 18 | config-path = "ruff_defaults.toml" 19 | dependencies = ["ruff==0.8.0"] 20 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.4" 2 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from lsp_devtools.cli import main 4 | 5 | if __name__ == "__main__": 6 | sys.exit(main()) 7 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/agent/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import asyncio 5 | import subprocess 6 | import sys 7 | 8 | from .agent import Agent 9 | from .agent import RPCMessage 10 | from .agent import logger 11 | from .agent import parse_rpc_message 12 | from .client import AgentClient 13 | from .server import AgentServer 14 | 15 | __all__ = [ 16 | "Agent", 17 | "AgentClient", 18 | "AgentServer", 19 | "RPCMessage", 20 | "logger", 21 | "parse_rpc_message", 22 | ] 23 | 24 | 25 | async def forward_stderr(server: asyncio.subprocess.Process): 26 | """Forward the server's stderr to the agent's stderr.""" 27 | if server.stderr is None: 28 | return 29 | 30 | # EOF is signalled with an empty bytestring 31 | while (line := await server.stderr.readline()) != b"": 32 | sys.stderr.buffer.write(line) 33 | 34 | 35 | async def main(args, extra: list[str]): 36 | if extra is None: 37 | print("Missing server start command", file=sys.stderr) 38 | return 1 39 | 40 | command, *arguments = extra 41 | server = await asyncio.create_subprocess_exec( 42 | command, 43 | *arguments, 44 | stdin=subprocess.PIPE, 45 | stdout=subprocess.PIPE, 46 | stderr=subprocess.PIPE, 47 | ) 48 | client = AgentClient() 49 | agent = Agent(server, sys.stdin.buffer, sys.stdout.buffer, client.forward_message) 50 | 51 | await asyncio.gather( 52 | client.start_tcp(args.host, args.port), 53 | agent.start(), 54 | forward_stderr(server), 55 | ) 56 | 57 | 58 | def run_agent(args, extra: list[str]): 59 | try: 60 | asyncio.run(main(args, extra)) 61 | except asyncio.CancelledError: 62 | pass 63 | 64 | 65 | def cli(commands: argparse._SubParsersAction): 66 | cmd: argparse.ArgumentParser = commands.add_parser( 67 | "agent", 68 | help="instrument an LSP session", 69 | formatter_class=argparse.RawDescriptionHelpFormatter, 70 | description="""\ 71 | This command runs the given JSON-RPC server as a subprocess, wrapping it in a 72 | an "AgentClient" which will capture all messages sent to/from the wrapped 73 | server, forwarding them onto an "AgentServer" to be processed. 74 | 75 | To wrap a server, supply its start command after all other agent options and 76 | preceeded by a `--`, for example: 77 | 78 | lsp-devtools agent -p 1234 -- python -m esbonio 79 | 80 | Wrapping a JSON-RPC server with this command is required to enable the 81 | majority of the lsp-devtools suite of tools. 82 | 83 | ┌─ RPC Client ─┐ ┌──── Agent Client ────┐ ┌─ RPC Server ─┐ 84 | │ │ │ ┌──────────────┐ │ │ │ 85 | │ stdout│─────│───│ │───│────│stdin │ 86 | │ │ │ │ Agent │ │ │ │ 87 | │ stdin│─────│───│ │───│────│stdout │ 88 | │ │ │ └──────────────┘ │ │ │ 89 | │ │ │ │ │ │ 90 | └──────────────┘ └──────────────────────┘ └──────────────┘ 91 | │ 92 | │ tcp/websocket 93 | │ 94 | ┌──────────────┐ 95 | │ │ 96 | │ Agent Server │ 97 | │ │ 98 | └──────────────┘ 99 | 100 | """, 101 | ) 102 | 103 | cmd.add_argument( 104 | "--host", 105 | help="the host to connect to.", 106 | default="localhost", 107 | ) 108 | cmd.add_argument( 109 | "-p", 110 | "--port", 111 | help="the port to connect to", 112 | default=8765, 113 | ) 114 | 115 | cmd.set_defaults(run=run_agent) 116 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/agent/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import inspect 5 | import typing 6 | 7 | import stamina 8 | from pygls.client import JsonRPCClient 9 | from pygls.protocol import default_converter 10 | 11 | from lsp_devtools.agent.protocol import AgentProtocol 12 | 13 | if typing.TYPE_CHECKING: 14 | from typing import Any 15 | 16 | 17 | class AgentClient(JsonRPCClient): 18 | """Client for connecting to an AgentServer instance.""" 19 | 20 | protocol: AgentProtocol 21 | 22 | def __init__(self): 23 | super().__init__( 24 | protocol_cls=AgentProtocol, converter_factory=default_converter 25 | ) 26 | self.connected = False 27 | self._buffer: list[bytes] = [] 28 | self._tasks: set[asyncio.Task[Any]] = set() 29 | 30 | def _report_server_error(self, error, source): 31 | # Bail on error 32 | # TODO: Report the actual error somehow 33 | self._stop_event.set() 34 | 35 | def feature(self, feature_name: str, options: Any | None = None): 36 | return self.protocol.fm.feature(feature_name, options) 37 | 38 | async def start_tcp(self, host: str, port: int): 39 | # The user might not have started the server app immediately and since the 40 | # agent will live as long as the wrapper language server we may as well 41 | # try indefinitely. 42 | retries = stamina.retry_context( 43 | on=OSError, 44 | attempts=None, 45 | timeout=None, 46 | wait_initial=1, 47 | wait_max=60, 48 | ) 49 | async for attempt in retries: 50 | with attempt: 51 | await super().start_tcp(host, port) 52 | self.connected = True 53 | 54 | def forward_message(self, message: bytes): 55 | """Forward the given message to the server instance.""" 56 | 57 | if not self.connected or self.protocol.writer is None: 58 | self._buffer.append(message) 59 | return 60 | 61 | # Send any buffered messages 62 | while len(self._buffer) > 0: 63 | res = self.protocol.writer.write(self._buffer.pop(0)) 64 | if inspect.isawaitable(res): 65 | task = asyncio.ensure_future(res) 66 | task.add_done_callback(self._tasks.discard) 67 | self._tasks.add(task) 68 | 69 | res = self.protocol.writer.write(message) 70 | if inspect.isawaitable(res): 71 | task = asyncio.ensure_future(res) 72 | task.add_done_callback(self._tasks.discard) 73 | self._tasks.add(task) 74 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/agent/protocol.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pygls.protocol import JsonRPCProtocol 4 | 5 | 6 | class AgentProtocol(JsonRPCProtocol): 7 | """The RPC protocol exposed by the agent.""" 8 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/agent/server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import json 5 | import logging 6 | import traceback 7 | import typing 8 | 9 | from pygls.protocol import default_converter 10 | from pygls.server import JsonRPCServer 11 | 12 | from lsp_devtools.agent.agent import aio_readline 13 | from lsp_devtools.agent.protocol import AgentProtocol 14 | from lsp_devtools.database import Database 15 | 16 | if typing.TYPE_CHECKING: 17 | from typing import Any 18 | 19 | from lsp_devtools.agent.agent import MessageHandler 20 | 21 | 22 | class AgentServer(JsonRPCServer): 23 | """A pygls server that accepts connections from agents allowing them to send their 24 | collected messages.""" 25 | 26 | lsp: AgentProtocol 27 | 28 | def __init__( 29 | self, 30 | *args, 31 | logger: logging.Logger | None = None, 32 | handler: MessageHandler | None = None, 33 | **kwargs, 34 | ): 35 | if "protocol_cls" not in kwargs: 36 | kwargs["protocol_cls"] = AgentProtocol 37 | 38 | if "converter_factory" not in kwargs: 39 | kwargs["converter_factory"] = default_converter 40 | 41 | super().__init__(*args, **kwargs) 42 | 43 | self.logger = logger or logging.getLogger(__name__) 44 | self.handler = handler or self._default_handler 45 | self.db: Database | None = None 46 | 47 | self._client_buffer: list[str] = [] 48 | self._server_buffer: list[str] = [] 49 | self._tcp_server: asyncio.Task | None = None 50 | 51 | def _default_handler(self, data: bytes): 52 | message = self.protocol.structure_message(json.loads(data)) 53 | self.protocol.handle_message(message) 54 | 55 | def _report_server_error(self, error: Exception, source): 56 | """Report internal server errors.""" 57 | tb = "".join( 58 | traceback.format_exception(type(error), error, error.__traceback__) 59 | ) 60 | self.logger.error("%s: %s", type(error).__name__, error) 61 | self.logger.debug("%s", tb) 62 | 63 | def feature(self, feature_name: str, options: Any | None = None): 64 | return self.lsp.fm.feature(feature_name, options) 65 | 66 | async def start_tcp(self, host: str, port: int) -> None: # type: ignore[override] 67 | async def handle_client( 68 | reader: asyncio.StreamReader, writer: asyncio.StreamWriter 69 | ): 70 | self.protocol.set_writer(writer) 71 | 72 | try: 73 | await aio_readline(reader, self.handler) 74 | except asyncio.CancelledError: 75 | pass 76 | finally: 77 | writer.close() 78 | await writer.wait_closed() 79 | 80 | # Uncomment if we ever need to introduce a mode where the server stops 81 | # automatically once a session ends. 82 | # 83 | # self.stop() 84 | 85 | server = await asyncio.start_server(handle_client, host, port) 86 | async with server: 87 | self._tcp_server = asyncio.create_task(server.serve_forever()) 88 | await self._tcp_server 89 | 90 | def stop(self): 91 | if self._tcp_server is not None: 92 | self._tcp_server.cancel() 93 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import importlib 3 | import logging 4 | import sys 5 | import traceback 6 | 7 | from lsp_devtools import __version__ 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | BUILTIN_COMMANDS = [ 13 | "lsp_devtools.agent", 14 | "lsp_devtools.client", 15 | "lsp_devtools.inspector", 16 | "lsp_devtools.record", 17 | ] 18 | 19 | 20 | def load_command(commands: argparse._SubParsersAction, name: str): 21 | try: 22 | mod = importlib.import_module(name) 23 | except Exception: 24 | logger.warning("Unable to load command '%s'\n%s", name, traceback.format_exc()) 25 | return 26 | 27 | if not hasattr(mod, "cli"): 28 | logger.warning("Unable to load command '%s': missing 'cli' definition", name) 29 | return 30 | 31 | try: 32 | mod.cli(commands) 33 | except Exception: 34 | logger.warning("Unable to load command '%s'\n%s", name, traceback.format_exc()) 35 | return 36 | 37 | 38 | def main(): 39 | cli = argparse.ArgumentParser( 40 | prog="lsp-devtools", description="Developer tooling for language servers" 41 | ) 42 | cli.add_argument("--version", action="version", version=f"%(prog)s v{__version__}") 43 | commands = cli.add_subparsers(title="commands") 44 | 45 | for mod in BUILTIN_COMMANDS: 46 | load_command(commands, mod) 47 | 48 | try: 49 | idx = sys.argv.index("--") 50 | args, extra = sys.argv[1:idx], sys.argv[idx + 1 :] 51 | except ValueError: 52 | args, extra = sys.argv[1:], None 53 | 54 | parsed_args = cli.parse_args(args) 55 | 56 | if hasattr(parsed_args, "run"): 57 | return parsed_args.run(parsed_args, extra) 58 | 59 | cli.print_help() 60 | return 0 61 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/client/app.css: -------------------------------------------------------------------------------- 1 | Screen { 2 | layers: base overlay; 3 | } 4 | 5 | Explorer { 6 | width: 30; 7 | dock: left; 8 | transition: offset 300ms out_cubic; 9 | } 10 | Explorer.-hidden { 11 | display: none; 12 | } 13 | 14 | Header { 15 | dock: top; 16 | } 17 | 18 | Devtools { 19 | width: 40%; 20 | dock: right; 21 | transition: offset 300ms out_cubic; 22 | } 23 | Devtools.-hidden { 24 | display: none; 25 | } 26 | 27 | MessagesTable { 28 | height: 100%; 29 | } 30 | 31 | CompletionList { 32 | height: 10; 33 | width: 30; 34 | layer: overlay; 35 | } 36 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/client/editor/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | import typing 5 | 6 | from lsprotocol import types 7 | from textual import on 8 | from textual.app import ComposeResult 9 | from textual.containers import Vertical 10 | from textual.widgets import OptionList 11 | 12 | from .completion import CompletionList 13 | from .text_editor import TextEditor 14 | 15 | if typing.TYPE_CHECKING: 16 | from lsp_devtools.client.lsp import LanguageClient 17 | 18 | 19 | class EditorView(Vertical): 20 | """A container to manage all the widgets that make up a single text editor.""" 21 | 22 | def __init__(self, lsp_client: LanguageClient, *args, **kwargs): 23 | super().__init__(*args, **kwargs) 24 | self.lsp_client = lsp_client 25 | 26 | def compose(self) -> ComposeResult: 27 | yield TextEditor(self.lsp_client) 28 | 29 | def open_file(self, path: pathlib.Path): 30 | editor = self.query_one(TextEditor) 31 | editor.open_file(path) 32 | editor.focus() 33 | 34 | def on_text_editor_completion(self, completion: TextEditor.Completion): 35 | """Render textDocument/completion results.""" 36 | candidates = CompletionList.fromresult(completion.result) 37 | if candidates is None: 38 | return 39 | 40 | editor = self.query_one(TextEditor) 41 | row, col = editor.cursor_location 42 | 43 | gutter_width = 2 # TODO: How to get actual gutter width? 44 | first_line = 0 # TODO: How to get the first visible line number? 45 | candidates.offset = (col + gutter_width, row - first_line + 1) 46 | 47 | self.mount(candidates) 48 | self.app.set_focus(candidates) 49 | 50 | @on(OptionList.OptionSelected) 51 | def insert_selected_completion(self, event: CompletionList.OptionSelected): 52 | """Insert the completion item selected by the user into the editor.""" 53 | selected: types.CompletionItem = event.option.prompt # type: ignore 54 | event.option_list.action_dismiss() # type: ignore 55 | 56 | editor = self.query_one(TextEditor) 57 | if (edit := selected.text_edit) is not None: 58 | # TODO: Support InsertReplaceEdit 59 | if isinstance(edit, types.InsertReplaceEdit): 60 | return 61 | 62 | # TextEdit support. 63 | start = edit.range.start.line, edit.range.start.character 64 | end = edit.range.end.line, edit.range.end.character 65 | 66 | with editor.set_state(suppress_completion=True): 67 | editor.replace( 68 | edit.new_text, start, end, maintain_selection_offset=False 69 | ) 70 | 71 | # TODO: Support insert_text 72 | # TODO: Fallback to label 73 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/client/editor/completion.py: -------------------------------------------------------------------------------- 1 | from lsprotocol import types 2 | from textual import events 3 | from textual.binding import Binding 4 | from textual.widgets import OptionList 5 | 6 | 7 | class CompletionList(OptionList): 8 | BINDINGS = [ 9 | Binding("escape", "dismiss", "Dismiss", show=False), 10 | ] 11 | 12 | @classmethod 13 | def fromresult(cls, result): 14 | """Build a list of completion candidates based on a response from the 15 | language server.""" 16 | 17 | if result is None: 18 | return None 19 | 20 | if isinstance(result, types.CompletionList): 21 | items = result.items 22 | else: 23 | items = result 24 | 25 | if len(items) == 0: 26 | return None 27 | 28 | candidates = cls() 29 | candidates.add_options( 30 | sorted( 31 | [CompletionItem(i) for i in items], 32 | key=lambda i: i.item.label, # type: ignore 33 | ) 34 | ) 35 | return candidates 36 | 37 | def on_blur(self, event: events.Blur): 38 | self.action_dismiss() 39 | 40 | def action_dismiss(self): 41 | self.remove() 42 | if self.parent: 43 | self.app.set_focus(self.parent) # type: ignore 44 | 45 | 46 | class CompletionItem: 47 | """Renders a completion item for display in a completion list.""" 48 | 49 | def __init__(self, item: types.CompletionItem): 50 | self.item = item 51 | 52 | def __rich__(self): 53 | # TODO: Make pretty 54 | return self.item.label 55 | 56 | def __getattr__(self, key): 57 | return getattr(self.item, key) 58 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/client/editor/text_editor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import pathlib 5 | import typing 6 | from typing import Union 7 | 8 | from lsprotocol import types 9 | from pygls import uris as uri 10 | from pygls.capabilities import get_capability 11 | from textual.message import Message 12 | from textual.widgets import TextArea 13 | 14 | if typing.TYPE_CHECKING: 15 | from lsp_devtools.client.lsp import LanguageClient 16 | 17 | CompletionResult = Union[list[types.CompletionItem], types.CompletionList, None] 18 | 19 | 20 | # TODO: Refactor to 21 | # - emit relevent events. 22 | # - split handlers out into multiple features that can listen and respond 23 | # to these events.. 24 | class TextEditor(TextArea): 25 | """A wrapper around textual's ``TextArea`` widget.""" 26 | 27 | class Completion(Message): 28 | """Emitted when completion results are received.""" 29 | 30 | def __init__(self, result: CompletionResult): 31 | self.result = result 32 | super().__init__() 33 | 34 | def __init__(self, lsp_client: LanguageClient, *args, **kwargs): 35 | super().__init__(*args, **kwargs) 36 | self.uri = None 37 | self.version = 0 38 | 39 | self.lsp_client = lsp_client 40 | self.suppress_completion = False 41 | 42 | @contextlib.contextmanager 43 | def set_state(self, **kwargs): 44 | """Temporarily override a value on the editor.""" 45 | old_values = {} 46 | for key, value in kwargs.items(): 47 | old_values[key] = getattr(self, key) 48 | setattr(self, key, value) 49 | 50 | yield 51 | 52 | for key, value in old_values.items(): 53 | setattr(self, key, value) 54 | 55 | @property 56 | def completion_triggers(self): 57 | """Return the completion trigger characters registered by the server.""" 58 | return get_capability( 59 | self.lsp_client.server_capabilities, # type: ignore 60 | "completion_provider.trigger_characters", 61 | set(), 62 | ) 63 | 64 | def open_file(self, path: pathlib.Path): 65 | self.uri = uri.from_fs_path(str(path.resolve())) 66 | if self.uri is None: 67 | return 68 | 69 | content = path.read_text() 70 | self.version = 0 71 | self.load_text(content) 72 | 73 | self.lsp_client.text_document_did_open( 74 | types.DidOpenTextDocumentParams( 75 | text_document=types.TextDocumentItem( 76 | uri=self.uri, 77 | language_id="restructuredtext", 78 | version=self.version, 79 | text=content, 80 | ) 81 | ) 82 | ) 83 | 84 | def edit(self, edit): 85 | """Extend the base ``edit()`` method to. 86 | 87 | - Ensure that any edits that are made to the document are syncronised with the 88 | server. 89 | - Completions are triggered if necessary. 90 | """ 91 | super().edit(edit) 92 | 93 | if self.uri is None: 94 | return 95 | 96 | self.version += 1 97 | start_line, start_col = edit.from_location 98 | end_line, end_col = edit.to_location 99 | 100 | self.lsp_client.text_document_did_change( 101 | types.DidChangeTextDocumentParams( 102 | text_document=types.VersionedTextDocumentIdentifier( 103 | version=self.version, uri=self.uri 104 | ), 105 | content_changes=[ 106 | types.TextDocumentContentChangePartial( 107 | text=edit.text, 108 | range=types.Range( 109 | start=types.Position(line=start_line, character=start_col), 110 | end=types.Position(line=end_line, character=end_col), 111 | ), 112 | ) 113 | ], 114 | ) 115 | ) 116 | 117 | if len(edit.text) == 0: 118 | return 119 | 120 | char = edit.text[-1] 121 | if not self.suppress_completion and char in self.completion_triggers: 122 | # TODO: How to send $/cancelRequest if a worker is cancelled? 123 | self.run_worker( 124 | self.trigger_completion(end_line, end_col), 125 | group="lsp-completion", 126 | exclusive=True, 127 | ) 128 | 129 | async def trigger_completion(self, line: int, character: int): 130 | """Trigger completion at the given location.""" 131 | 132 | if self.uri is None: 133 | return 134 | 135 | result = await self.lsp_client.text_document_completion_async( 136 | types.CompletionParams( 137 | text_document=types.TextDocumentIdentifier(uri=self.uri), 138 | position=types.Position(line=line, character=character), 139 | ) 140 | ) 141 | 142 | self.post_message(self.Completion(result)) 143 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/client/lsp.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | import json 3 | from datetime import datetime 4 | from datetime import timezone 5 | from typing import Optional 6 | from uuid import uuid4 7 | 8 | from lsprotocol import types 9 | from pygls.lsp.client import BaseLanguageClient 10 | from pygls.protocol import LanguageServerProtocol 11 | 12 | from lsp_devtools.agent import logger 13 | 14 | UTC = timezone.utc 15 | VERSION = importlib.metadata.version("lsp-devtools") 16 | 17 | 18 | class RecordingLSProtocol(LanguageServerProtocol): 19 | """A version of the LanguageServerProtocol that also records all the traffic.""" 20 | 21 | def __init__(self, server, converter): 22 | super().__init__(server, converter) 23 | self.session_id = "" 24 | 25 | def _procedure_handler(self, message): 26 | logger.info( 27 | "%s", 28 | json.dumps(message, default=self._serialize_message), 29 | extra={ 30 | "Message-Source": "server", 31 | "Message-Session": self.session_id, 32 | "Message-Timestamp": datetime.now(tz=UTC).isoformat(), 33 | }, 34 | ) 35 | return super()._procedure_handler(message) 36 | 37 | def _send_data(self, data): 38 | logger.info( 39 | "%s", 40 | json.dumps(data, default=self._serialize_message), 41 | extra={ 42 | "Message-Source": "client", 43 | "Message-Session": self.session_id, 44 | "Message-Timestamp": datetime.now(tz=UTC).isoformat(), 45 | }, 46 | ) 47 | return super()._send_data(data) 48 | 49 | 50 | class LanguageClient(BaseLanguageClient): 51 | """A language client for integrating with a textual text edit.""" 52 | 53 | def __init__(self): 54 | super().__init__("lsp-devtools", VERSION, protocol_cls=RecordingLSProtocol) 55 | 56 | self.session_id = str(uuid4()) 57 | self.protocol.session_id = self.session_id # type: ignore[attr-defined] 58 | self._server_capabilities: Optional[types.ServerCapabilities] = None 59 | 60 | @property 61 | def server_capabilities(self) -> types.ServerCapabilities: 62 | if self._server_capabilities is None: 63 | raise RuntimeError( 64 | "sever_capabilities is None - has the server been initialized?" 65 | ) 66 | 67 | return self._server_capabilities 68 | 69 | async def initialize_async( 70 | self, params: types.InitializeParams 71 | ) -> types.InitializeResult: 72 | result = await super().initialize_async(params) 73 | self._server_capabilities = result.capabilities 74 | 75 | return result 76 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import logging 5 | import typing 6 | from datetime import datetime 7 | 8 | import attrs 9 | 10 | if typing.TYPE_CHECKING: 11 | from collections.abc import Mapping 12 | from typing import Any 13 | from typing import Literal 14 | 15 | MessageSource = Literal["client", "server"] 16 | 17 | 18 | def maybe_json(value): 19 | try: 20 | return json.loads(value) 21 | except Exception: 22 | return value 23 | 24 | 25 | @attrs.define 26 | class LspMessage: 27 | """A container that holds a message from the LSP protocol, with some additional 28 | metadata.""" 29 | 30 | session: str 31 | """An id representing the session the message is a part of.""" 32 | 33 | timestamp: datetime 34 | """When the message was sent.""" 35 | 36 | source: MessageSource 37 | """Indicates if the message was sent by the client or the server.""" 38 | 39 | id: str | None 40 | """The ``id`` field, if it exists.""" 41 | 42 | method: str | None 43 | """The ``method`` field, if it exists.""" 44 | 45 | params: Any | None = attrs.field(converter=maybe_json) 46 | """The ``params`` field, if it exists.""" 47 | 48 | result: Any | None = attrs.field(converter=maybe_json) 49 | """The ``result`` field, if it exists.""" 50 | 51 | error: Any | None = attrs.field(converter=maybe_json) 52 | """The ``error`` field, if it exists.""" 53 | 54 | @classmethod 55 | def from_rpc( 56 | cls, session: str, timestamp: str, source: str, message: Mapping[str, Any] 57 | ): 58 | """Create an instance from a JSON-RPC message.""" 59 | return cls( 60 | session=session, 61 | timestamp=datetime.fromisoformat(timestamp), 62 | source=source, # type: ignore 63 | id=message.get("id", None), 64 | method=message.get("method", None), 65 | params=message.get("params", None), 66 | result=message.get("result", None), 67 | error=message.get("error", None), 68 | ) 69 | 70 | @property 71 | def is_request(self) -> bool: 72 | return self.id is not None and self.params is not None 73 | 74 | @property 75 | def is_response(self) -> bool: 76 | result_or_error = self.result is not None or self.error is not None 77 | return self.id is not None and result_or_error 78 | 79 | @property 80 | def is_notification(self) -> bool: 81 | return self.id is None and self.params is not None 82 | 83 | 84 | class LspHandler(logging.Handler): 85 | """Base class for lsp log handlers.""" 86 | 87 | def __init__(self, *args, **kwargs): 88 | super().__init__(*args, **kwargs) 89 | 90 | def handle_message(self, message: LspMessage): 91 | """Called each time a message is processed.""" 92 | 93 | def emit(self, record: logging.LogRecord): 94 | if not isinstance(record.args, dict): 95 | return 96 | 97 | message = record.args 98 | source = record.__dict__["Message-Source"] 99 | 100 | self.handle_message( 101 | LspMessage.from_rpc( 102 | session=record.__dict__["Message-Session"], 103 | timestamp=record.__dict__["Message-Timestamp"], 104 | source=source, 105 | message=message, 106 | ) 107 | ) 108 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql: -------------------------------------------------------------------------------- 1 | -- Tables 2 | 3 | -- We use a single table 'protocol' to store all messages sent between client and server. 4 | -- Data within the table is then exposed through a number of SQL views, that parse out the 5 | -- details relevant to that view. 6 | CREATE TABLE IF NOT EXISTS protocol ( 7 | session TEXT, 8 | timestamp REAL, 9 | source TEXT, 10 | 11 | id TEXT NULL, 12 | method TEXT NULL, 13 | params TEXT NULL, 14 | result TEXT NULL, 15 | error TEXT NULL 16 | ); 17 | 18 | -- Views 19 | 20 | -- Requests 21 | CREATE VIEW IF NOT EXISTS requests AS 22 | SELECT 23 | client.session, 24 | client.timestamp, 25 | (server.timestamp - client.timestamp) * 1000 as duration, 26 | client.id, 27 | client.method, 28 | client.params, 29 | server.result, 30 | server.error 31 | FROM protocol as client 32 | INNER JOIN protocol as server ON 33 | client.session = server.session AND 34 | client.id = server.id AND 35 | client.params IS NOT NULL AND 36 | ( 37 | server.result IS NOT NULL OR 38 | server.error IS NOT NULL 39 | ); 40 | 41 | -- Notifications 42 | CREATE VIEW IF NOT EXISTS notifications AS 43 | SELECT 44 | rowid, 45 | session, 46 | timestamp, 47 | source, 48 | method, 49 | params 50 | FROM protocol 51 | WHERE id is NULL; 52 | 53 | -- Sessions 54 | CREATE VIEW IF NOT EXISTS sessions AS 55 | SELECT 56 | session, 57 | timestamp, 58 | json_extract(params, "$.clientInfo.name") as client_name, 59 | json_extract(params, "$.clientInfo.version") as client_version, 60 | json_extract(params, "$.rootUri") as root_uri, 61 | json_extract(params, "$.workspaceFolders") as workspace_folders, 62 | params, 63 | result 64 | FROM requests WHERE method = 'initialize'; 65 | 66 | -- Log Messages 67 | CREATE VIEW IF NOT EXISTS logMessages AS 68 | SELECT 69 | rowid, 70 | session, 71 | timestamp, 72 | json_extract(params, "$.type") as type, 73 | json_extract(params, "$.message") as message 74 | FROM protocol 75 | WHERE method = 'window/logMessage'; 76 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/handlers/sql.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pathlib 3 | import sqlite3 4 | from contextlib import closing 5 | from importlib import resources 6 | 7 | from lsp_devtools.handlers import LspHandler 8 | from lsp_devtools.handlers import LspMessage 9 | 10 | 11 | class SqlHandler(LspHandler): 12 | """A logging handler that sends log records to a SQL database""" 13 | 14 | def __init__(self, dbpath: pathlib.Path, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | 17 | self.dbpath = dbpath 18 | 19 | resource = resources.files("lsp_devtools.handlers").joinpath("dbinit.sql") 20 | sql_script = resource.read_text(encoding="utf8") 21 | 22 | with closing(sqlite3.connect(self.dbpath)) as conn: 23 | conn.executescript(sql_script) 24 | 25 | def handle_message(self, message: LspMessage): 26 | with closing(sqlite3.connect(self.dbpath)) as conn: 27 | cursor = conn.cursor() 28 | cursor.execute( 29 | "INSERT INTO protocol VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 30 | ( 31 | message.session, 32 | message.timestamp, 33 | message.source, 34 | message.id, 35 | message.method, 36 | json.dumps(message.params) if message.params else None, 37 | json.dumps(message.result) if message.result else None, 38 | json.dumps(message.error) if message.error else None, 39 | ), 40 | ) 41 | 42 | conn.commit() 43 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/inspector/app.css: -------------------------------------------------------------------------------- 1 | Sidebar { 2 | width: 60; 3 | dock: right; 4 | background: $panel; 5 | transition: offset 300ms out_cubic; 6 | } 7 | Sidebar.-hidden { 8 | offset-x: 100%; 9 | } 10 | 11 | 12 | DataTable { 13 | height: 100%; 14 | } 15 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file for PEP 561. 2 | -------------------------------------------------------------------------------- /lib/lsp-devtools/lsp_devtools/record/filters.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import typing 5 | 6 | import attrs 7 | 8 | from .formatters import FormatString 9 | 10 | if typing.TYPE_CHECKING: 11 | from typing import Literal 12 | 13 | MessageSource = Literal["client", "server", "both"] 14 | MessageType = Literal["request", "response", "result", "error", "notification"] 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | @attrs.define 20 | class LSPFilter(logging.Filter): 21 | """Logging filter for LSP messages.""" 22 | 23 | message_source: MessageSource = attrs.field(default="both") 24 | """Only include messages from the given source.""" 25 | 26 | include_message_types: set[MessageType] = attrs.field(factory=set, converter=set) 27 | """Only include the given message types.""" 28 | 29 | exclude_message_types: set[MessageType] = attrs.field(factory=set, converter=set) 30 | """Exclude the given message types.""" 31 | 32 | include_methods: set[str] = attrs.field(factory=set, converter=set) 33 | """Only include messages associated with the given method.""" 34 | 35 | exclude_methods: set[str] = attrs.field(factory=set, converter=set) 36 | """Exclude messages associated with the given method.""" 37 | 38 | formatter: FormatString = attrs.field( 39 | default="", 40 | converter=FormatString, 41 | ) # type: ignore 42 | """Format messages according to the given string""" 43 | 44 | _response_method_map: dict[int | str, str] = attrs.field(factory=dict) 45 | """Used to determine the method for response messages""" 46 | 47 | def filter(self, record: logging.LogRecord) -> bool: 48 | message = record.args 49 | if not isinstance(message, dict): 50 | return False 51 | 52 | source = record.__dict__["Message-Source"] 53 | message_type = get_message_type(message) 54 | message_method = self._get_message_method(message_type, message) 55 | 56 | if self.message_source not in {"both", source}: 57 | return False 58 | 59 | if self.include_message_types and not message_matches_type( 60 | message_type, self.include_message_types 61 | ): 62 | return False 63 | 64 | if self.exclude_message_types and message_matches_type( 65 | message_type, self.exclude_message_types 66 | ): 67 | return False 68 | 69 | if self.include_methods and message_method not in self.include_methods: 70 | return False 71 | 72 | if self.exclude_methods and message_method in self.exclude_methods: 73 | return False 74 | 75 | if self.formatter.pattern: 76 | try: 77 | record.msg = self.formatter.format(message) 78 | record.args = None 79 | except Exception: 80 | logger.debug( 81 | "Skipping message that failed to format: %s", message, exc_info=True 82 | ) 83 | return False 84 | 85 | return True 86 | 87 | def _get_message_method(self, message_type: str, message: dict) -> str: 88 | if message_type == "request": 89 | method = message["method"] 90 | self._response_method_map[message["id"]] = method 91 | 92 | return method 93 | 94 | if message_type == "notification": 95 | return message["method"] 96 | 97 | return self._response_method_map[message["id"]] 98 | 99 | 100 | def message_matches_type(message_type: str, types: set[MessageType]) -> bool: 101 | """Determine if the type of message is included in the given set of types""" 102 | 103 | if message_type == "result": 104 | return len({"result", "response"} & types) > 0 105 | 106 | if message_type == "error": 107 | return len({"error", "response"} & types) > 0 108 | 109 | return message_type in types 110 | 111 | 112 | def get_message_type(message: dict) -> str: 113 | if "id" in message: 114 | if "error" in message: 115 | return "error" 116 | elif "method" in message: 117 | return "request" 118 | else: 119 | return "result" 120 | else: 121 | return "notification" 122 | -------------------------------------------------------------------------------- /lib/lsp-devtools/nix/lsp-devtools-overlay.nix: -------------------------------------------------------------------------------- 1 | final: prev: 2 | 3 | let 4 | # Read the package's version from file 5 | lines = prev.lib.splitString "\n" (builtins.readFile ../lsp_devtools/__init__.py); 6 | matches = builtins.map (builtins.match ''__version__ = "(.+)"'') lines; 7 | versionStr = prev.lib.concatStrings (prev.lib.flatten (builtins.filter builtins.isList matches)); 8 | in { 9 | pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [( 10 | python-final: python-prev: { 11 | 12 | stamina = python-prev.buildPythonPackage rec { 13 | pname = "stamina"; 14 | version = "24.1.0"; 15 | format = "pyproject"; 16 | 17 | src = prev.fetchFromGitHub { 18 | owner = "hynek"; 19 | repo = "stamina"; 20 | rev = "refs/tags/${version}"; 21 | hash = "sha256-bIVzE9/QsdGw/UE83q3Q/XG3jFnPy65pkDdMpYkIrrs="; 22 | }; 23 | 24 | SETUPTOOLS_SCM_PRETEND_VERSION = version; 25 | nativeBuildInputs = with python-final; [ 26 | hatchling 27 | hatch-vcs 28 | hatch-fancy-pypi-readme 29 | ]; 30 | 31 | propagatedBuildInputs = with python-final; [ 32 | tenacity 33 | ] ++ prev.lib.optional (pythonOlder "3.10") typing-extensions; 34 | 35 | doCheck = true; 36 | pythonImportsCheck = [ "stamina" ]; 37 | nativeCheckInputs = with python-prev; [ 38 | anyio 39 | pytestCheckHook 40 | ]; 41 | 42 | }; 43 | 44 | lsp-devtools = python-prev.buildPythonPackage { 45 | pname = "lsp-devtools"; 46 | version = versionStr; 47 | format = "pyproject"; 48 | 49 | src = ./..; 50 | 51 | nativeBuildInputs = with python-final; [ 52 | hatchling 53 | ]; 54 | 55 | propagatedBuildInputs = with python-final; [ 56 | aiosqlite 57 | platformdirs 58 | pygls 59 | stamina 60 | textual 61 | ] ++ prev.lib.optional (pythonOlder "3.9") importlib-resources 62 | ++ prev.lib.optional (pythonOlder "3.8") typing-extensions; 63 | 64 | doCheck = true; 65 | pythonImportsCheck = [ "lsp_devtools" ]; 66 | nativeCheckInputs = with python-prev; [ 67 | pytestCheckHook 68 | ]; 69 | 70 | }; 71 | } 72 | )]; 73 | } 74 | -------------------------------------------------------------------------------- /lib/lsp-devtools/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.17.1"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "lsp-devtools" 7 | dynamic = ["version"] 8 | description = "Developer tooling for language servers" 9 | readme = "README.md" 10 | requires-python = ">=3.9" 11 | license = { text = "MIT" } 12 | authors = [{ name = "Alex Carney", email = "alcarneyme@gmail.com" }] 13 | classifiers = [ 14 | "Development Status :: 3 - Alpha", 15 | "License :: OSI Approved :: MIT License", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3 :: Only", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | ] 24 | dependencies = [ 25 | "aiosqlite", 26 | "platformdirs", 27 | "pygls>=2.0a2", 28 | "stamina", 29 | "textual>=0.41.0", 30 | ] 31 | 32 | [project.urls] 33 | "Bug Tracker" = "https://github.com/swyddfa/lsp-devtools/issues" 34 | "Documentation" = "https://lsp-devtools.readthedocs.io/en/latest/" 35 | "Source Code" = "https://github.com/swyddfa/lsp-devtools" 36 | 37 | [project.scripts] 38 | lsp-devtools = "lsp_devtools.cli:main" 39 | 40 | [tool.coverage.run] 41 | source_pkgs = ["lsp_devtools"] 42 | 43 | [tool.coverage.report] 44 | show_missing = true 45 | skip_covered = true 46 | sort = "Cover" 47 | 48 | [tool.pyright] 49 | include = ["lsp_devtools"] 50 | pythonVersion = "3.9" 51 | 52 | [tool.towncrier] 53 | filename = "CHANGES.md" 54 | directory = "changes/" 55 | title_format = "## v{version} - {project_date}" 56 | issue_format = "[#{issue}](https://github.com/swyddfa/lsp-devtools/issues/{issue})" 57 | underlines = ["", "", ""] 58 | 59 | type = [ 60 | { name = "Features", directory = "feature", showcontent = true }, 61 | { name = "Enhancements", directory = "enhancement", showcontent = true }, 62 | { name = "Fixes", directory = "fix", showcontent = true }, 63 | { name = "Docs", directory = "doc", showcontent = true }, 64 | { name = "Breaking Changes", directory = "breaking", showcontent = true }, 65 | { name = "Deprecated", directory = "deprecated", showcontent = true }, 66 | { name = "Misc", directory = "misc", showcontent = true }, 67 | ] 68 | -------------------------------------------------------------------------------- /lib/lsp-devtools/ruff.toml: -------------------------------------------------------------------------------- 1 | extend = "ruff_defaults.toml" 2 | line-length = 88 3 | indent-width = 4 4 | 5 | [format] 6 | # Be like black where possible 7 | quote-style = "double" 8 | indent-style = "space" 9 | line-ending = "auto" 10 | skip-magic-trailing-comma = false 11 | 12 | [lint] 13 | ignore = [ 14 | "BLE001", # catch Exception: 15 | "INP001", # Complains about namespace packages 16 | "PT018", # Assertion should be broken down into multiple parts 17 | "T201", # print found 18 | "TRY003", # Exception message defined outside of class 19 | 20 | # The following were added when migrating to ruff, we might want to consider 21 | # enabling some of these again at some point. 22 | "A002", # argument shadowing 23 | "ARG001", # unused function argument 24 | "ARG002", # unused method argument 25 | "C405", # rewrite as set literal 26 | "C408", # dict(x=y) 27 | "C416", # Unecessary dict comprehension 28 | "C419", # Unecessary list comprehension 29 | "E402", # module import not at top of file 30 | "EM101", # raise ValueError("Literal string, not variable") 31 | "EM102", # raise ValueError(f"-string, not variable") 32 | "FBT001", # boolean arguments 33 | "FBT002", # boolean arguments 34 | "FLY002", # f-string alternative available 35 | "G003", # logging statement uses f-string 36 | "G004", # logging statement uses + 37 | "G201", # logging.error(.., exc_info=True) 38 | "N801", # naming conventions 39 | "N802", # naming conventions 40 | "N806", # naming conventions 41 | "PERF401", # use list comprehension 42 | "PERF402", # use list or list.copy 43 | "PLR2004", # magic values 44 | "PLW2901", # overwriting for-loop variable 45 | "PT006", # Complains about how `pytest.mark.parametrize` parameters are passed 46 | "PT011", # pytest.raises(ValueError) 47 | "RET503", # Missing return 48 | "RET504", # Unecessary assignment before return 49 | "RET505", # Unecessary elif after return 50 | "RUF001", # ambiguous characters 51 | "RUF012", # Mutable ClassVar annotation... 52 | "RUF015", # Prefer next(iter(...)) 53 | "SIM102", # Use single if 54 | "SIM105", # Use contextlib.suppress(...) 55 | "SIM108", # Use ternary operator 56 | "SIM115", # Use key in dict 57 | "SIM118", # Use key in dict 58 | "SLF001", # private member access 59 | "TC001", # move import to type checking block 60 | "TC002", # move import to type checking block 61 | "TC003", # move import to type checking block 62 | "TID252", # Absolute vs relative imports 63 | "TRY300", # Move statement to else block 64 | ] 65 | 66 | [lint.per-file-ignores] 67 | "**/tests/**/*" = [ 68 | "S", 69 | "SLF001", # private member accessed 70 | ] 71 | 72 | [lint.isort] 73 | force-single-line = true 74 | 75 | [lint.pyupgrade] 76 | # At least for now... 77 | keep-runtime-typing = true 78 | -------------------------------------------------------------------------------- /lib/lsp-devtools/tests/record/test_record.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import logging 5 | import typing 6 | 7 | import pytest 8 | 9 | from lsp_devtools.record import cli 10 | from lsp_devtools.record import setup_file_output 11 | 12 | if typing.TYPE_CHECKING: 13 | import pathlib 14 | from typing import Any 15 | 16 | 17 | @pytest.fixture(scope="module") 18 | def record(): 19 | """Return a cli parser for the record command.""" 20 | parser = argparse.ArgumentParser(description="for testing purposes") 21 | commands = parser.add_subparsers() 22 | cli(commands) 23 | 24 | return parser 25 | 26 | 27 | @pytest.fixture 28 | def logger(): 29 | """Return the logger instance to use.""" 30 | 31 | log = logging.getLogger(__name__) 32 | log.setLevel(logging.INFO) 33 | 34 | for handler in log.handlers: 35 | log.removeHandler(handler) 36 | 37 | return log 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "args, messages, expected", 42 | [ 43 | ( 44 | [], 45 | [dict(jsonrpc="2.0", id=1, method="initialize", params=dict())], 46 | '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}\n', 47 | ), 48 | ( 49 | ["-f", "{.|json-compact}"], 50 | [dict(jsonrpc="2.0", id=1, method="initialize", params=dict())], 51 | '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}\n', 52 | ), 53 | ( 54 | ["-f", "{.|json}"], 55 | [dict(jsonrpc="2.0", id=1, method="initialize", params=dict())], 56 | "\n".join( 57 | [ 58 | "{", 59 | ' "jsonrpc": "2.0",', 60 | ' "id": 1,', 61 | ' "method": "initialize",', 62 | ' "params": {}', 63 | "}", 64 | "", 65 | ] 66 | ), 67 | ), 68 | ( 69 | ["-f", "{.method|json}"], 70 | [ 71 | dict(jsonrpc="2.0", id=1, method="initialize", params=dict()), 72 | dict(jsonrpc="2.0", id=1, result=dict()), 73 | ], 74 | "initialize\n", 75 | ), 76 | ( 77 | ["-f", "{.id}"], 78 | [ 79 | dict(jsonrpc="2.0", id=1, method="initialize", params=dict()), 80 | dict(jsonrpc="2.0", id=1, result=dict()), 81 | ], 82 | "1\n1\n", 83 | ), 84 | ], 85 | ) 86 | def test_file_output( 87 | tmp_path: pathlib.Path, 88 | record: argparse.ArgumentParser, 89 | logger: logging.Logger, 90 | args: list[str], 91 | messages: list[dict[str, Any]], 92 | expected: str, 93 | ): 94 | """Ensure that we can log to files correctly. 95 | 96 | Parameters 97 | ---------- 98 | tmp_path 99 | pytest's ``tmp_path`` fixture 100 | 101 | record 102 | The record command's cli parser 103 | 104 | logger 105 | The logging instance to use 106 | 107 | messages 108 | The messages to record 109 | 110 | expected 111 | The expected file output. 112 | """ 113 | log = tmp_path / "log.json" 114 | parsed_args = record.parse_args(["record", "--to-file", str(log), *args]) 115 | 116 | setup_file_output(parsed_args, logger) 117 | 118 | for message in messages: 119 | logger.info("%s", message, extra={"Message-Source": "client"}) 120 | 121 | assert log.read_text() == expected 122 | -------------------------------------------------------------------------------- /lib/lsp-devtools/tests/servers/simple.py: -------------------------------------------------------------------------------- 1 | """A very simple language server.""" 2 | 3 | from lsprotocol import types 4 | from pygls.lsp.server import LanguageServer 5 | 6 | server = LanguageServer("simple-server", "v1") 7 | 8 | 9 | @server.feature(types.INITIALIZED) 10 | def _(ls: LanguageServer, params: types.InitializedParams): 11 | ls.window_show_message( 12 | types.ShowMessageParams( 13 | message="Hello, world!", 14 | type=types.MessageType.Log, 15 | ) 16 | ) 17 | 18 | 19 | if __name__ == "__main__": 20 | server.start_io() 21 | -------------------------------------------------------------------------------- /lib/lsp-devtools/tests/test_agent.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import os 4 | import pathlib 5 | import subprocess 6 | import sys 7 | 8 | import pytest 9 | 10 | from lsp_devtools.agent import Agent 11 | 12 | SERVER_DIR = pathlib.Path(__file__).parent / "servers" 13 | 14 | 15 | def format_message(obj): 16 | content = json.dumps(obj) 17 | message = "".join( 18 | [ 19 | f"Content-Length: {len(content)}\r\n", 20 | "\r\n", 21 | f"{content}", 22 | ] 23 | ) 24 | return message.encode() 25 | 26 | 27 | def echo_handler(d: bytes): 28 | sys.stdout.buffer.write(d) 29 | sys.stdout.flush() 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_agent_exits(): 34 | """Ensure that when the client closes down the lsp session and the server process 35 | exits, the agent does also.""" 36 | 37 | (stdin_read, stdin_write) = os.pipe() 38 | (stdout_read, stdout_write) = os.pipe() 39 | 40 | server = await asyncio.create_subprocess_exec( 41 | sys.executable, 42 | str(SERVER_DIR / "simple.py"), 43 | stdin=subprocess.PIPE, 44 | stdout=subprocess.PIPE, 45 | stderr=subprocess.PIPE, 46 | ) 47 | 48 | agent = Agent( 49 | server, 50 | os.fdopen(stdin_read, mode="rb"), 51 | os.fdopen(stdout_write, mode="wb"), 52 | echo_handler, 53 | ) 54 | 55 | os.write( 56 | stdin_write, 57 | format_message( 58 | dict(jsonrpc="2.0", id=1, method="initialize", params=dict(capabilities={})) 59 | ), 60 | ) 61 | 62 | os.write( 63 | stdin_write, 64 | format_message(dict(jsonrpc="2.0", id=2, method="shutdown", params=None)), 65 | ) 66 | 67 | os.write( 68 | stdin_write, 69 | format_message(dict(jsonrpc="2.0", method="exit", params=None)), 70 | ) 71 | 72 | try: 73 | await asyncio.wait_for( 74 | # asyncio.gather(server.wait(), agent.start()), 75 | agent.start(), 76 | timeout=10, # s 77 | ) 78 | except asyncio.CancelledError: 79 | pass # The agent's tasks should be cancelled 80 | 81 | except TimeoutError as exc: 82 | # Make sure this timed out for the right reason. 83 | if server.returncode is None: 84 | raise RuntimeError("Server process did not exit") from exc 85 | 86 | exc.add_note("lsp-devtools agent did not stop") 87 | -------------------------------------------------------------------------------- /lib/pytest-lsp/.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.3.1 3 | commit = False 4 | tag = False 5 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(.dev(?P\d+))? 6 | serialize = 7 | {major}.{minor}.{patch}.dev{dev} 8 | {major}.{minor}.{patch} 9 | 10 | [bumpversion:file:pytest_lsp/client.py] 11 | 12 | [bumpversion:file:pyproject.toml] 13 | -------------------------------------------------------------------------------- /lib/pytest-lsp/.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | result 3 | -------------------------------------------------------------------------------- /lib/pytest-lsp/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alex Carney 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 | -------------------------------------------------------------------------------- /lib/pytest-lsp/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include pytest_lsp/clients/*.json 2 | include pytest_lsp/py.typed 3 | -------------------------------------------------------------------------------- /lib/pytest-lsp/Makefile: -------------------------------------------------------------------------------- 1 | PY ?= 310 2 | COMMAND ?= 3 | 4 | ifdef COMMAND 5 | CMD = --command $(COMMAND) 6 | endif 7 | 8 | .PHONY: develop test repl 9 | 10 | 11 | repl: CMD = --command python 12 | repl: develop 13 | 14 | 15 | test: CMD = --command pytest $(ARGS) 16 | test: develop 17 | 18 | 19 | develop: 20 | nix develop .#py$(PY) $(CMD) 21 | -------------------------------------------------------------------------------- /lib/pytest-lsp/README.md: -------------------------------------------------------------------------------- 1 | # pytest-lsp: End-to-end testing of language servers with pytest 2 | 3 | `pytest-lsp` is a pytest plugin for writing end-to-end tests for language servers. 4 | 5 | It works by running the language server in a subprocess and communicating with it over stdio, just like a real language client. 6 | This also means `pytest-lsp` can be used to test language servers written in any language - not just Python. 7 | 8 | `pytest-lsp` relies on the [`pygls`](https://github.com/openlawlibrary/pygls) library for its language server protocol implementation. 9 | 10 | See the [documentation](https://lsp-devtools.readthedocs.io/en/latest/) for details on getting started. 11 | 12 | ```python 13 | import sys 14 | 15 | import pytest 16 | import pytest_lsp 17 | from lsprotocol import types 18 | from pytest_lsp import ( 19 | ClientServerConfig, 20 | LanguageClient, 21 | client_capabilities, 22 | ) 23 | 24 | 25 | @pytest_lsp.fixture( 26 | scope="module", 27 | config=ClientServerConfig( 28 | server_command=[sys.executable, "-m", "esbonio"], 29 | ), 30 | ) 31 | async def client(lsp_client: LanguageClient): 32 | # Setup 33 | response = await lsp_client.initialize_session( 34 | types.InitializeParams( 35 | capabilities=client_capabilities("visual-studio-code"), 36 | workspace_folders=[ 37 | types.WorkspaceFolder( 38 | uri="file:///path/to/test/project/root/", name="project" 39 | ), 40 | ], 41 | ) 42 | ) 43 | 44 | yield 45 | 46 | # Teardown 47 | await lsp_client.shutdown_session() 48 | 49 | 50 | @pytest.mark.asyncio(loop_scope="module") 51 | async def test_completion(client: LanguageClient): 52 | result = await client.text_document_completion_async( 53 | params=types.CompletionParams( 54 | position=types.Position(line=5, character=23), 55 | text_document=types.TextDocumentIdentifier( 56 | uri="file:///path/to/test/project/root/test_file.rst" 57 | ), 58 | ) 59 | ) 60 | 61 | assert len(result.items) > 0 62 | ``` 63 | -------------------------------------------------------------------------------- /lib/pytest-lsp/changes/github-template.html: -------------------------------------------------------------------------------- 1 | %(body_pre_docinfo)s 2 | %(docinfo)s 3 | %(body)s 4 | -------------------------------------------------------------------------------- /lib/pytest-lsp/flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1704161960, 6 | "narHash": "sha256-QGua89Pmq+FBAro8NriTuoO/wNaUtugt29/qqA8zeeM=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "63143ac2c9186be6d9da6035fa22620018c85932", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixpkgs-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs", 22 | "utils": "utils" 23 | } 24 | }, 25 | "systems": { 26 | "locked": { 27 | "lastModified": 1681028828, 28 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 29 | "owner": "nix-systems", 30 | "repo": "default", 31 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 32 | "type": "github" 33 | }, 34 | "original": { 35 | "owner": "nix-systems", 36 | "repo": "default", 37 | "type": "github" 38 | } 39 | }, 40 | "utils": { 41 | "inputs": { 42 | "systems": "systems" 43 | }, 44 | "locked": { 45 | "lastModified": 1701680307, 46 | "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", 47 | "owner": "numtide", 48 | "repo": "flake-utils", 49 | "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "numtide", 54 | "repo": "flake-utils", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /lib/pytest-lsp/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "pytest-lsp: End to end testing of language servers with pytest"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, utils }: 10 | 11 | let 12 | eachPythonVersion = versions: f: 13 | builtins.listToAttrs (builtins.map (version: {name = "py${version}"; value = f version; }) versions); 14 | pytest-lsp-overlay = import ./nix/pytest-lsp-overlay.nix; 15 | in { 16 | 17 | overlays.default = pytest-lsp-overlay; 18 | 19 | packages = utils.lib.eachDefaultSystemMap (system: 20 | let 21 | pkgs = import nixpkgs { inherit system; overlays = [ pytest-lsp-overlay ]; }; 22 | in 23 | eachPythonVersion [ "38" "39" "310" "311"] (pyVersion: 24 | pkgs."python${pyVersion}Packages".pytest-lsp 25 | ) 26 | ); 27 | 28 | devShells = utils.lib.eachDefaultSystemMap (system: 29 | let 30 | pkgs = import nixpkgs { inherit system; overlays = [ pytest-lsp-overlay ]; }; 31 | in 32 | eachPythonVersion [ "38" "39" "310" "311" ] (pyVersion: 33 | with pkgs; mkShell { 34 | name = "py${pyVersion}"; 35 | 36 | shellHook = '' 37 | export PYTHONPATH="./:$PYTHONPATH" 38 | ''; 39 | 40 | packages = with pkgs."python${pyVersion}Packages"; [ 41 | pygls 42 | pytest 43 | pytest-asyncio 44 | ]; 45 | } 46 | ) 47 | ); 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /lib/pytest-lsp/hatch.toml: -------------------------------------------------------------------------------- 1 | [version] 2 | path = "pytest_lsp/client.py" 3 | validate-bump = false 4 | 5 | [build.targets.sdist] 6 | include = ["pytest_lsp", "tests", "CHANGES.md"] 7 | 8 | [build.targets.wheel] 9 | packages = ["pytest_lsp"] 10 | 11 | [envs.hatch-test] 12 | dependencies = ["pytest-asyncio"] 13 | 14 | [envs.hatch-test.env-vars] 15 | UV_PRERELEASE="allow" 16 | 17 | [[envs.hatch-test.matrix]] 18 | python = ["3.9", "3.10", "3.11", "3.12", "3.13"] 19 | pytest = ["8"] 20 | 21 | [envs.hatch-test.overrides] 22 | matrix.pytest.dependencies = [ 23 | { value = "pytest>=8,<9", if = ["8"] }, 24 | ] 25 | 26 | [envs.hatch-static-analysis] 27 | config-path = "ruff_defaults.toml" 28 | dependencies = ["ruff==0.8.0"] 29 | -------------------------------------------------------------------------------- /lib/pytest-lsp/nix/pytest-lsp-overlay.nix: -------------------------------------------------------------------------------- 1 | final: prev: 2 | 3 | let 4 | # Read the package's version from file 5 | lines = prev.lib.splitString "\n" (builtins.readFile ../pytest_lsp/client.py); 6 | matches = builtins.map (builtins.match ''__version__ = "(.+)"'') lines; 7 | versionStr = prev.lib.concatStrings (prev.lib.flatten (builtins.filter builtins.isList matches)); 8 | in { 9 | pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [( 10 | python-final: python-prev: { 11 | 12 | pytest-lsp = python-prev.buildPythonPackage { 13 | pname = "pytest-lsp"; 14 | version = versionStr; 15 | format = "pyproject"; 16 | 17 | src = ./..; 18 | 19 | nativeBuildInputs = with python-final; [ 20 | hatchling 21 | ]; 22 | 23 | propagatedBuildInputs = with python-final; [ 24 | pygls 25 | pytest 26 | pytest-asyncio 27 | ]; 28 | 29 | doCheck = true; 30 | pythonImportsCheck = [ "pytest_lsp" ]; 31 | nativeCheckInputs = with python-prev; [ 32 | pytestCheckHook 33 | ]; 34 | 35 | }; 36 | } 37 | )]; 38 | } 39 | -------------------------------------------------------------------------------- /lib/pytest-lsp/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.17.1"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "pytest-lsp" 7 | dynamic = ["version"] 8 | description = "A pytest plugin for end-to-end testing of language servers" 9 | readme = "README.md" 10 | requires-python = ">=3.9" 11 | license = { text = "MIT" } 12 | authors = [{ name = "Alex Carney", email = "alcarneyme@gmail.com" }] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "License :: OSI Approved :: MIT License", 16 | "Framework :: Pytest", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3 :: Only", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | ] 26 | dependencies = [ 27 | "packaging", 28 | "pygls>=2.0.0a3", 29 | "pytest", 30 | "pytest-asyncio>=0.24", 31 | ] 32 | 33 | [project.urls] 34 | "Bug Tracker" = "https://github.com/swyddfa/lsp-devtools/issues" 35 | "Documentation" = "https://lsp-devtools.readthedocs.io/en/latest/" 36 | "Source Code" = "https://github.com/swyddfa/lsp-devtools" 37 | 38 | [project.entry-points.pytest11] 39 | pytest-lsp = "pytest_lsp" 40 | 41 | [tool.coverage.run] 42 | source_pkgs = ["pytest_lsp"] 43 | 44 | [tool.coverage.report] 45 | show_missing = true 46 | skip_covered = true 47 | sort = "Cover" 48 | 49 | [tool.pytest.ini_options] 50 | asyncio_mode = "auto" 51 | asyncio_default_fixture_loop_scope = "function" 52 | 53 | [tool.towncrier] 54 | filename = "CHANGES.md" 55 | directory = "changes/" 56 | title_format = "## v{version} - {project_date}" 57 | issue_format = "[#{issue}](https://github.com/swyddfa/lsp-devtools/issues/{issue})" 58 | underlines = ["", "", ""] 59 | 60 | type = [ 61 | { name = "Features", directory = "feature", showcontent = true }, 62 | { name = "Enhancements", directory = "enhancement", showcontent = true }, 63 | { name = "Client Capabilities", directory = "capability", showcontent = true }, 64 | { name = "Fixes", directory = "fix", showcontent = true }, 65 | { name = "Docs", directory = "doc", showcontent = true }, 66 | { name = "Breaking Changes", directory = "breaking", showcontent = true }, 67 | { name = "Deprecated", directory = "deprecated", showcontent = true }, 68 | { name = "Misc", directory = "misc", showcontent = true }, 69 | { name = "Removed", directory = "removed", showcontent = true }, 70 | ] 71 | -------------------------------------------------------------------------------- /lib/pytest-lsp/pytest_lsp/__init__.py: -------------------------------------------------------------------------------- 1 | from .checks import LspSpecificationWarning 2 | from .client import LanguageClient 3 | from .client import __version__ 4 | from .client import client_capabilities 5 | from .client import make_test_lsp_client 6 | from .plugin import ClientServerConfig 7 | from .plugin import fixture 8 | from .plugin import pytest_addoption 9 | from .plugin import pytest_runtest_makereport 10 | from .protocol import LanguageClientProtocol 11 | 12 | __all__ = [ 13 | "__version__", 14 | "ClientServerConfig", 15 | "LanguageClient", 16 | "LanguageClientProtocol", 17 | "LspSpecificationWarning", 18 | "client_capabilities", 19 | "fixture", 20 | "make_test_lsp_client", 21 | "pytest_addoption", 22 | "pytest_runtest_makereport", 23 | ] 24 | -------------------------------------------------------------------------------- /lib/pytest-lsp/pytest_lsp/clients/README.md: -------------------------------------------------------------------------------- 1 | # Clients 2 | 3 | This folder contains captured [`ClientCapabilities`](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#clientCapabilities) responses from various language clients at various versions. 4 | 5 | These snapshots are how `pytest-lsp` plugin is able to impersonate different language clients when testing a server. 6 | They also power the [Client Capability Index](https://lsp-devtools.readthedocs.io/en/latest/#client-capability-index) section of the documentation. 7 | 8 | Each filename follows the `_v.json` naming convention and contain the following fields of an [`InitializeParams`](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initializeParams) object. 9 | 10 | ```json 11 | { 12 | "clientInfo": { 13 | "name": "Client Name", 14 | "version": "1.2.3" 15 | }, 16 | "capabilities": { ... } 17 | } 18 | ``` 19 | 20 | ## Adding new clients and versions 21 | 22 | ### Neovim 23 | 24 | Adding new neovim versions has been semi-automated through a [Github Actions workflow](https://github.com/swyddfa/lsp-devtools/blob/develop/.github/workflows/capabilities-nvim.yml) 25 | 26 | ### Other Clients 27 | 28 | This can be done with `lsp-devtools` command. 29 | 30 | 1. Configure the language client to invoke a language server [wrapped with the lsp-devtools agent](https://lsp-devtools.readthedocs.io/en/latest/lsp-devtools/guide/getting-started.html#configuring-your-client). 31 | 32 | 1. In a separate terminal, use the following `lsp-devtools` command to record the necessary information to a JSON file 33 | ``` 34 | lsp-devtools record -f '{{"clientInfo": {.params.clientInfo}, "capabilities": {.params.capabilities}}}' --to-file _v.json 35 | ``` 36 | 37 | 1. Run the language server via the client to generate the necessary data, once the server is ready to use you should be able to close the client. 38 | 39 | 1. The `lsp-devtools record` command should have exited with a file called `_v.json` saved to your working directory. 40 | 41 | 1. Open a pull request adding the file to this folder! 42 | -------------------------------------------------------------------------------- /lib/pytest-lsp/pytest_lsp/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swyddfa/lsp-devtools/bc9c2973dbc648f6026122172b016917827fa97a/lib/pytest-lsp/pytest_lsp/clients/__init__.py -------------------------------------------------------------------------------- /lib/pytest-lsp/pytest_lsp/clients/emacs_v29.1.json: -------------------------------------------------------------------------------- 1 | { 2 | "clientInfo": { 3 | "name": "Emacs (eglot)", 4 | "version": "29.1" 5 | }, 6 | "capabilities": { 7 | "workspace": { 8 | "applyEdit": true, 9 | "executeCommand": { 10 | "dynamicRegistration": false 11 | }, 12 | "workspaceEdit": { 13 | "documentChanges": true 14 | }, 15 | "didChangeWatchedFiles": { 16 | "dynamicRegistration": true 17 | }, 18 | "symbol": { 19 | "dynamicRegistration": false 20 | }, 21 | "configuration": true, 22 | "workspaceFolders": true 23 | }, 24 | "textDocument": { 25 | "synchronization": { 26 | "dynamicRegistration": false, 27 | "willSave": true, 28 | "willSaveWaitUntil": true, 29 | "didSave": true 30 | }, 31 | "completion": { 32 | "dynamicRegistration": false, 33 | "completionItem": { 34 | "snippetSupport": false, 35 | "deprecatedSupport": true, 36 | "resolveSupport": { 37 | "properties": [ 38 | "documentation", 39 | "details", 40 | "additionalTextEdits" 41 | ] 42 | }, 43 | "tagSupport": { 44 | "valueSet": [ 45 | 1 46 | ] 47 | } 48 | }, 49 | "contextSupport": true 50 | }, 51 | "hover": { 52 | "dynamicRegistration": false, 53 | "contentFormat": [ 54 | "markdown", 55 | "plaintext" 56 | ] 57 | }, 58 | "signatureHelp": { 59 | "dynamicRegistration": false, 60 | "signatureInformation": { 61 | "parameterInformation": { 62 | "labelOffsetSupport": true 63 | }, 64 | "activeParameterSupport": true 65 | } 66 | }, 67 | "references": { 68 | "dynamicRegistration": false 69 | }, 70 | "definition": { 71 | "dynamicRegistration": false, 72 | "linkSupport": true 73 | }, 74 | "declaration": { 75 | "dynamicRegistration": false, 76 | "linkSupport": true 77 | }, 78 | "implementation": { 79 | "dynamicRegistration": false, 80 | "linkSupport": true 81 | }, 82 | "typeDefinition": { 83 | "dynamicRegistration": false, 84 | "linkSupport": true 85 | }, 86 | "documentSymbol": { 87 | "dynamicRegistration": false, 88 | "hierarchicalDocumentSymbolSupport": true, 89 | "symbolKind": { 90 | "valueSet": [ 91 | 1, 92 | 2, 93 | 3, 94 | 4, 95 | 5, 96 | 6, 97 | 7, 98 | 8, 99 | 9, 100 | 10, 101 | 11, 102 | 12, 103 | 13, 104 | 14, 105 | 15, 106 | 16, 107 | 17, 108 | 18, 109 | 19, 110 | 20, 111 | 21, 112 | 22, 113 | 23, 114 | 24, 115 | 25, 116 | 26 117 | ] 118 | } 119 | }, 120 | "documentHighlight": { 121 | "dynamicRegistration": false 122 | }, 123 | "codeAction": { 124 | "dynamicRegistration": false, 125 | "codeActionLiteralSupport": { 126 | "codeActionKind": { 127 | "valueSet": [ 128 | "quickfix", 129 | "refactor", 130 | "refactor.extract", 131 | "refactor.inline", 132 | "refactor.rewrite", 133 | "source", 134 | "source.organizeImports" 135 | ] 136 | } 137 | }, 138 | "isPreferredSupport": true 139 | }, 140 | "formatting": { 141 | "dynamicRegistration": false 142 | }, 143 | "rangeFormatting": { 144 | "dynamicRegistration": false 145 | }, 146 | "rename": { 147 | "dynamicRegistration": false 148 | }, 149 | "inlayHint": { 150 | "dynamicRegistration": false 151 | }, 152 | "publishDiagnostics": { 153 | "relatedInformation": false, 154 | "codeDescriptionSupport": false, 155 | "tagSupport": { 156 | "valueSet": [ 157 | 1, 158 | 2 159 | ] 160 | } 161 | } 162 | }, 163 | "window": { 164 | "workDoneProgress": true 165 | }, 166 | "general": { 167 | "positionEncodings": [ 168 | "utf-32", 169 | "utf-8", 170 | "utf-16" 171 | ] 172 | }, 173 | "experimental": {} 174 | } 175 | } -------------------------------------------------------------------------------- /lib/pytest-lsp/pytest_lsp/protocol.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | import typing 6 | from concurrent.futures import Future 7 | 8 | from pygls.protocol import LanguageServerProtocol 9 | 10 | from .checks import check_params_against_client_capabilities 11 | from .checks import check_result_against_client_capabilities 12 | 13 | if typing.TYPE_CHECKING: 14 | from .client import LanguageClient 15 | 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class LanguageClientProtocol(LanguageServerProtocol): 21 | """An extended protocol class adding functionality useful for testing.""" 22 | 23 | _server: LanguageClient # type: ignore[assignment] 24 | 25 | def __init__(self, *args, **kwargs): 26 | super().__init__(*args, **kwargs) 27 | 28 | self._notification_futures = {} 29 | 30 | def _handle_request(self, msg_id, method_name, params): 31 | """Wrap pygls' handle_request implementation. This will 32 | 33 | - Check if the request from the server is compatible with the client's stated 34 | capabilities. 35 | 36 | """ 37 | check_params_against_client_capabilities( 38 | self._server.capabilities, method_name, params 39 | ) 40 | return super()._handle_request(msg_id, method_name, params) 41 | 42 | def _handle_notification(self, method_name, params): 43 | """Wrap pygls' handle_notification implementation. This will 44 | 45 | - Notify a future waiting on the notification, if applicable. 46 | 47 | - Check the params to see if they are compatible with the client's stated 48 | capabilities. 49 | 50 | """ 51 | future = self._notification_futures.pop(method_name, None) 52 | if future: 53 | future.set_result(params) 54 | 55 | super()._handle_notification(method_name, params) 56 | 57 | async def send_request_async(self, method, params=None, msg_id=None): 58 | """Wrap pygls' ``send_request_async`` implementation. This will 59 | 60 | - Check the params to see if they're compatible with the client's stated 61 | capabilities 62 | - Check the result to see if it's compatible with the client's stated 63 | capabilities 64 | 65 | Parameters 66 | ---------- 67 | method 68 | The method name of the request to send 69 | 70 | params 71 | The associated parameters to go with the request 72 | 73 | Returns 74 | ------- 75 | Any 76 | The result 77 | """ 78 | check_params_against_client_capabilities( 79 | self._server.capabilities, method, params 80 | ) 81 | result = await super().send_request_async(method, params, msg_id) 82 | check_result_against_client_capabilities( 83 | self._server.capabilities, 84 | method, 85 | result, # type: ignore 86 | ) 87 | 88 | return result 89 | 90 | def wait_for_notification(self, method: str, callback=None) -> Future: 91 | """Wait for a notification message with the given ``method``. 92 | 93 | Parameters 94 | ---------- 95 | method 96 | The method name to wait for 97 | 98 | callback 99 | If given, ``callback`` will be called with the notification message's 100 | ``params`` when recevied 101 | 102 | Returns 103 | ------- 104 | Future 105 | A future that will resolve when the requested notification message is 106 | recevied. 107 | """ 108 | future: Future = Future() 109 | if callback: 110 | 111 | def wrapper(future: Future): 112 | result = future.result() 113 | callback(result) 114 | 115 | future.add_done_callback(wrapper) 116 | 117 | self._notification_futures[method] = future 118 | return future 119 | 120 | def wait_for_notification_async(self, method: str): 121 | """Wait for a notification message with the given ``method``. 122 | 123 | Parameters 124 | ---------- 125 | method 126 | The method name to wait for 127 | 128 | Returns 129 | ------- 130 | Any 131 | The notification message's ``params`` 132 | """ 133 | future = self.wait_for_notification(method) 134 | return asyncio.wrap_future(future) 135 | -------------------------------------------------------------------------------- /lib/pytest-lsp/pytest_lsp/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swyddfa/lsp-devtools/bc9c2973dbc648f6026122172b016917827fa97a/lib/pytest-lsp/pytest_lsp/py.typed -------------------------------------------------------------------------------- /lib/pytest-lsp/ruff.toml: -------------------------------------------------------------------------------- 1 | extend = "ruff_defaults.toml" 2 | line-length = 88 3 | indent-width = 4 4 | 5 | [format] 6 | # Be like black where possible 7 | quote-style = "double" 8 | indent-style = "space" 9 | line-ending = "auto" 10 | skip-magic-trailing-comma = false 11 | 12 | [lint] 13 | ignore = [ 14 | "BLE001", # catch Exception: 15 | "INP001", # Complains about namespace packages 16 | "PT018", # Assertion should be broken down into multiple parts 17 | "T201", # print found 18 | "TRY003", # Exception message defined outside of class 19 | 20 | # The following were added when migrating to ruff, we might want to consider 21 | # enabling some of these again at some point. 22 | "A002", # argument shadowing 23 | "ARG001", # unused function argument 24 | "ARG002", # unused method argument 25 | "C405", # rewrite as set literal 26 | "C408", # dict(x=y) 27 | "C416", # Unecessary dict comprehension 28 | "C419", # Unecessary list comprehension 29 | "E402", # module import not at top of file 30 | "EM101", # raise ValueError("Literal string, not variable") 31 | "EM102", # raise ValueError(f"-string, not variable") 32 | "FBT001", # boolean arguments 33 | "FBT002", # boolean arguments 34 | "FLY002", # f-string alternative available 35 | "G003", # logging statement uses f-string 36 | "G004", # logging statement uses + 37 | "G201", # logging.error(.., exc_info=True) 38 | "N801", # naming conventions 39 | "N802", # naming conventions 40 | "N806", # naming conventions 41 | "PERF401", # use list comprehension 42 | "PERF402", # use list or list.copy 43 | "PLR2004", # magic values 44 | "PLW2901", # overwriting for-loop variable 45 | "PT006", # Complains about how `pytest.mark.parametrize` parameters are passed 46 | "PT011", # pytest.raises(ValueError) 47 | "RET503", # Missing return 48 | "RET504", # Unecessary assignment before return 49 | "RET505", # Unecessary elif after return 50 | "RUF001", # ambiguous characters 51 | "RUF012", # Mutable ClassVar annotation... 52 | "RUF015", # Prefer next(iter(...)) 53 | "SIM102", # Use single if 54 | "SIM105", # Use contextlib.suppress(...) 55 | "SIM108", # Use ternary operator 56 | "SIM115", # Use key in dict 57 | "SIM118", # Use key in dict 58 | "SLF001", # private member access 59 | "TC001", # move import to type checking block 60 | "TC002", # move import to type checking block 61 | "TC003", # move import to type checking block 62 | "TID252", # Absolute vs relative imports 63 | "TRY300", # Move statement to else block 64 | ] 65 | 66 | [lint.per-file-ignores] 67 | "**/tests/**/*" = [ 68 | "S", 69 | "SLF001", # private member accessed 70 | ] 71 | 72 | [lint.isort] 73 | force-single-line = true 74 | 75 | [lint.pyupgrade] 76 | # At least for now... 77 | keep-runtime-typing = true 78 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = ["pytester"] 2 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/client-capabilities/server.py: -------------------------------------------------------------------------------- 1 | from lsprotocol.types import ( 2 | TEXT_DOCUMENT_COMPLETION, 3 | CompletionItem, 4 | CompletionParams, 5 | InsertTextFormat, 6 | ) 7 | from pygls.lsp.server import LanguageServer 8 | 9 | server = LanguageServer("hello-world", "v1") 10 | 11 | 12 | @server.feature(TEXT_DOCUMENT_COMPLETION) 13 | def completion(ls: LanguageServer, params: CompletionParams): 14 | return [ 15 | CompletionItem( 16 | label="greet", 17 | insert_text='"Hello, ${1:name}!"$0', 18 | insert_text_format=InsertTextFormat.Snippet, 19 | ), 20 | ] 21 | 22 | 23 | if __name__ == "__main__": 24 | server.start_io() 25 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/client-capabilities/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest_lsp 4 | from lsprotocol.types import ( 5 | CompletionList, 6 | CompletionParams, 7 | InitializeParams, 8 | Position, 9 | TextDocumentIdentifier, 10 | ) 11 | from pytest_lsp import ClientServerConfig, LanguageClient, client_capabilities 12 | 13 | 14 | @pytest_lsp.fixture( 15 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 16 | ) 17 | async def client(lsp_client: LanguageClient): 18 | # Setup 19 | await lsp_client.initialize_session( 20 | InitializeParams( 21 | capabilities=client_capabilities("neovim"), 22 | ), 23 | ) 24 | 25 | yield 26 | 27 | # Teardown 28 | await lsp_client.shutdown_session() 29 | 30 | 31 | async def test_completions(client: LanguageClient): 32 | """Ensure that the server implements completions correctly.""" 33 | 34 | results = await client.text_document_completion_async( 35 | params=CompletionParams( 36 | position=Position(line=1, character=0), 37 | text_document=TextDocumentIdentifier(uri="file:///path/to/file.txt"), 38 | ) 39 | ) 40 | assert results is not None 41 | 42 | if isinstance(results, CompletionList): 43 | items = results.items 44 | else: 45 | items = results 46 | 47 | labels = [item.label for item in items] 48 | assert labels == ["greet"] 49 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/diagnostics/server.py: -------------------------------------------------------------------------------- 1 | from lsprotocol import types 2 | from pygls.lsp.server import LanguageServer 3 | 4 | server = LanguageServer("diagnostic-server", "v1") 5 | 6 | 7 | @server.feature(types.TEXT_DOCUMENT_DID_OPEN) 8 | def did_open(ls: LanguageServer, params: types.DidOpenTextDocumentParams): 9 | ls.text_document_publish_diagnostics( 10 | types.PublishDiagnosticsParams( 11 | uri=params.text_document.uri, 12 | diagnostics=[ 13 | types.Diagnostic( 14 | message="There is an error here.", 15 | range=types.Range( 16 | start=types.Position(line=1, character=1), 17 | end=types.Position(line=1, character=10), 18 | ), 19 | ) 20 | ], 21 | ) 22 | ) 23 | 24 | 25 | if __name__ == "__main__": 26 | server.start_io() 27 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/diagnostics/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | import pytest_lsp 5 | from lsprotocol.types import ( 6 | TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS, 7 | ClientCapabilities, 8 | DidOpenTextDocumentParams, 9 | InitializeParams, 10 | TextDocumentItem, 11 | ) 12 | from pytest_lsp import ClientServerConfig, LanguageClient 13 | 14 | 15 | @pytest_lsp.fixture( 16 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 17 | ) 18 | async def client(lsp_client: LanguageClient): 19 | # Setup 20 | params = InitializeParams(capabilities=ClientCapabilities()) 21 | await lsp_client.initialize_session(params) 22 | 23 | yield 24 | 25 | # Teardown 26 | await lsp_client.shutdown_session() 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_diagnostics(client: LanguageClient): 31 | """Ensure that the server implements diagnostics correctly.""" 32 | 33 | test_uri = "file:///path/to/file.txt" 34 | client.text_document_did_open( 35 | DidOpenTextDocumentParams( 36 | text_document=TextDocumentItem( 37 | uri=test_uri, 38 | language_id="plaintext", 39 | version=1, 40 | text="The file's contents", 41 | ) 42 | ) 43 | ) 44 | 45 | # Wait for the server to publish its diagnostics 46 | await client.wait_for_notification(TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) 47 | 48 | assert test_uri in client.diagnostics 49 | assert client.diagnostics[test_uri][0].message == "There is an error here." 50 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/fixture-passthrough/server.py: -------------------------------------------------------------------------------- 1 | from lsprotocol.types import TEXT_DOCUMENT_COMPLETION, CompletionItem, CompletionParams 2 | from pygls.lsp.server import LanguageServer 3 | 4 | server = LanguageServer("hello-world", "v1") 5 | 6 | 7 | @server.feature(TEXT_DOCUMENT_COMPLETION) 8 | def completion(ls: LanguageServer, params: CompletionParams): 9 | return [ 10 | CompletionItem(label="hello"), 11 | CompletionItem(label="world"), 12 | ] 13 | 14 | 15 | if __name__ == "__main__": 16 | server.start_io() 17 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/fixture-passthrough/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | import pytest_lsp 5 | from lsprotocol.types import ( 6 | CompletionList, 7 | CompletionParams, 8 | InitializeParams, 9 | Position, 10 | TextDocumentIdentifier, 11 | ) 12 | from pytest_lsp import ClientServerConfig, LanguageClient, client_capabilities 13 | 14 | 15 | @pytest.fixture(scope="module") 16 | def client_name(): 17 | return "neovim" 18 | 19 | 20 | @pytest_lsp.fixture( 21 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 22 | ) 23 | async def client(client_name: str, lsp_client: LanguageClient): 24 | # Setup 25 | params = InitializeParams(capabilities=client_capabilities(client_name)) 26 | await lsp_client.initialize_session(params) 27 | 28 | yield 29 | 30 | # Teardown 31 | await lsp_client.shutdown_session() 32 | 33 | 34 | async def test_completions(client: LanguageClient): 35 | """Ensure that the server implements completions correctly.""" 36 | 37 | results = await client.text_document_completion_async( 38 | params=CompletionParams( 39 | position=Position(line=1, character=0), 40 | text_document=TextDocumentIdentifier(uri="file:///path/to/file.txt"), 41 | ) 42 | ) 43 | assert results is not None 44 | 45 | if isinstance(results, CompletionList): 46 | items = results.items 47 | else: 48 | items = results 49 | 50 | labels = [item.label for item in items] 51 | assert labels == ["hello", "world"] 52 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/fixture-scope/server.py: -------------------------------------------------------------------------------- 1 | from lsprotocol.types import TEXT_DOCUMENT_COMPLETION, CompletionItem, CompletionParams 2 | from pygls.lsp.server import LanguageServer 3 | 4 | server = LanguageServer("hello-world", "v1") 5 | 6 | 7 | @server.feature(TEXT_DOCUMENT_COMPLETION) 8 | def completion(ls: LanguageServer, params: CompletionParams): 9 | return [ 10 | CompletionItem(label="hello"), 11 | CompletionItem(label="world"), 12 | ] 13 | 14 | 15 | if __name__ == "__main__": 16 | server.start_io() 17 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/fixture-scope/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | import pytest_lsp 5 | from lsprotocol import types 6 | from pytest_lsp import ClientServerConfig, LanguageClient 7 | 8 | 9 | @pytest_lsp.fixture( 10 | scope="module", 11 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 12 | ) 13 | async def client(lsp_client: LanguageClient): 14 | # Setup 15 | params = types.InitializeParams(capabilities=types.ClientCapabilities()) 16 | await lsp_client.initialize_session(params) 17 | 18 | yield 19 | 20 | # Teardown 21 | await lsp_client.shutdown_session() 22 | 23 | 24 | @pytest.mark.asyncio(loop_scope="module") 25 | async def test_completion_hello(client: LanguageClient): 26 | """Ensure that the server implements completions correctly.""" 27 | 28 | results = await client.text_document_completion_async( 29 | params=types.CompletionParams( 30 | position=types.Position(line=1, character=0), 31 | text_document=types.TextDocumentIdentifier(uri="file:///path/to/file.txt"), 32 | ) 33 | ) 34 | assert results is not None 35 | 36 | if isinstance(results, types.CompletionList): 37 | items = results.items 38 | else: 39 | items = results 40 | 41 | labels = {item.label for item in items} 42 | assert "hello" in labels 43 | 44 | 45 | @pytest.mark.asyncio(loop_scope="module") 46 | async def test_completion_world(client: LanguageClient): 47 | """Ensure that the server implements completions correctly.""" 48 | 49 | results = await client.text_document_completion_async( 50 | params=types.CompletionParams( 51 | position=types.Position(line=1, character=0), 52 | text_document=types.TextDocumentIdentifier(uri="file:///path/to/file.txt"), 53 | ) 54 | ) 55 | assert results is not None 56 | 57 | if isinstance(results, types.CompletionList): 58 | items = results.items 59 | else: 60 | items = results 61 | 62 | labels = {item.label for item in items} 63 | assert "world" in labels 64 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/generic-rpc/server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from pygls.protocol import JsonRPCProtocol, default_converter 4 | from pygls.server import JsonRPCServer 5 | 6 | server = JsonRPCServer( 7 | protocol_cls=JsonRPCProtocol, converter_factory=default_converter 8 | ) 9 | 10 | 11 | @server.feature("math/add") 12 | def addition(ls: JsonRPCServer, params): 13 | a = params.a 14 | b = params.b 15 | 16 | ls.protocol.notify("log/message", dict(message=f"{a=}")) 17 | ls.protocol.notify("log/message", dict(message=f"{b=}")) 18 | 19 | return dict(total=a + b) 20 | 21 | 22 | @server.feature("math/sub") 23 | def subtraction(ls: JsonRPCServer, params): 24 | a = params.a 25 | b = params.b 26 | 27 | ls.protocol.notify("log/message", dict(message=f"{a=}")) 28 | ls.protocol.notify("log/message", dict(message=f"{b=}")) 29 | 30 | return dict(total=b - a) 31 | 32 | 33 | @server.feature("server/exit") 34 | def server_exit(ls: JsonRPCServer, params): 35 | sys.exit(0) 36 | 37 | 38 | if __name__ == "__main__": 39 | server.start_io() 40 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/generic-rpc/t_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import pytest 5 | import pytest_lsp 6 | from pygls.client import JsonRPCClient 7 | from pytest_lsp import ClientServerConfig 8 | 9 | 10 | def client_factory(): 11 | client = JsonRPCClient() 12 | 13 | @client.feature("log/message") 14 | def _on_message(params): 15 | logging.info("LOG: %s", params.message) 16 | 17 | return client 18 | 19 | 20 | @pytest_lsp.fixture( 21 | config=ClientServerConfig( 22 | client_factory=client_factory, server_command=[sys.executable, "server.py"] 23 | ), 24 | ) 25 | async def client(rpc_client: JsonRPCClient): 26 | # Setup code here (if any) 27 | 28 | yield 29 | 30 | # Teardown code here 31 | rpc_client.protocol.notify("server/exit", {}) 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_add(client: JsonRPCClient): 36 | """Ensure that the server implements addition correctly.""" 37 | 38 | result = await client.protocol.send_request_async( 39 | "math/add", params={"a": 1, "b": 2} 40 | ) 41 | assert result.total == 3 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_sub(client: JsonRPCClient): 46 | """Ensure that the server implements addition correctly.""" 47 | 48 | result = await client.protocol.send_request_async( 49 | "math/sub", params={"a": 1, "b": 2} 50 | ) 51 | assert result.total == -1 52 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/getting-started-fail/server.py: -------------------------------------------------------------------------------- 1 | from lsprotocol.types import TEXT_DOCUMENT_COMPLETION, CompletionItem, CompletionParams 2 | from pygls.lsp.server import LanguageServer 3 | 4 | server = LanguageServer("hello-world", "v1") 5 | 6 | 7 | @server.feature(TEXT_DOCUMENT_COMPLETION) 8 | def completion(ls: LanguageServer, params: CompletionParams): 9 | return [ 10 | CompletionItem(label="hello"), 11 | CompletionItem(label="world"), 12 | ] 13 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/getting-started-fail/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest_lsp 4 | from lsprotocol.types import ( 5 | ClientCapabilities, 6 | CompletionList, 7 | CompletionParams, 8 | InitializeParams, 9 | Position, 10 | TextDocumentIdentifier, 11 | ) 12 | from pytest_lsp import ClientServerConfig, LanguageClient 13 | 14 | 15 | @pytest_lsp.fixture( 16 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 17 | ) 18 | async def client(lsp_client: LanguageClient): 19 | # Setup 20 | params = InitializeParams(capabilities=ClientCapabilities()) 21 | await lsp_client.initialize_session(params) 22 | 23 | yield 24 | 25 | # Teardown 26 | await lsp_client.shutdown_session() 27 | 28 | 29 | async def test_completions(client: LanguageClient): 30 | """Ensure that the server implements completions correctly.""" 31 | 32 | results = await client.text_document_completion_async( 33 | params=CompletionParams( 34 | position=Position(line=1, character=0), 35 | text_document=TextDocumentIdentifier(uri="file:///path/to/file.txt"), 36 | ) 37 | ) 38 | 39 | assert results is not None 40 | 41 | if isinstance(results, CompletionList): 42 | items = results.items 43 | else: 44 | items = results 45 | 46 | labels = [item.label for item in items] 47 | assert labels == ["hello", "world"] 48 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/getting-started/server.py: -------------------------------------------------------------------------------- 1 | from lsprotocol.types import TEXT_DOCUMENT_COMPLETION, CompletionItem, CompletionParams 2 | from pygls.lsp.server import LanguageServer 3 | 4 | server = LanguageServer("hello-world", "v1") 5 | 6 | 7 | @server.feature(TEXT_DOCUMENT_COMPLETION) 8 | def completion(ls: LanguageServer, params: CompletionParams): 9 | return [ 10 | CompletionItem(label="hello"), 11 | CompletionItem(label="world"), 12 | ] 13 | 14 | 15 | if __name__ == "__main__": 16 | server.start_io() 17 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/getting-started/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | import pytest_lsp 5 | from lsprotocol.types import ( 6 | ClientCapabilities, 7 | CompletionList, 8 | CompletionParams, 9 | InitializeParams, 10 | Position, 11 | TextDocumentIdentifier, 12 | ) 13 | from pytest_lsp import ClientServerConfig, LanguageClient 14 | 15 | 16 | @pytest_lsp.fixture( 17 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 18 | ) 19 | async def client(lsp_client: LanguageClient): 20 | # Setup 21 | params = InitializeParams(capabilities=ClientCapabilities()) 22 | await lsp_client.initialize_session(params) 23 | 24 | yield 25 | 26 | # Teardown 27 | await lsp_client.shutdown_session() 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_completions(client: LanguageClient): 32 | """Ensure that the server implements completions correctly.""" 33 | 34 | results = await client.text_document_completion_async( 35 | params=CompletionParams( 36 | position=Position(line=1, character=0), 37 | text_document=TextDocumentIdentifier(uri="file:///path/to/file.txt"), 38 | ) 39 | ) 40 | assert results is not None 41 | 42 | if isinstance(results, CompletionList): 43 | items = results.items 44 | else: 45 | items = results 46 | 47 | labels = [item.label for item in items] 48 | assert labels == ["hello", "world"] 49 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/parameterised-clients/server.py: -------------------------------------------------------------------------------- 1 | from lsprotocol.types import TEXT_DOCUMENT_COMPLETION, CompletionItem, CompletionParams 2 | from pygls.lsp.server import LanguageServer 3 | 4 | server = LanguageServer("hello-world", "v1") 5 | 6 | 7 | @server.feature(TEXT_DOCUMENT_COMPLETION) 8 | def completion(ls: LanguageServer, params: CompletionParams): 9 | return [ 10 | CompletionItem(label="hello"), 11 | CompletionItem(label="world"), 12 | ] 13 | 14 | 15 | if __name__ == "__main__": 16 | server.start_io() 17 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/parameterised-clients/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest_lsp 4 | from lsprotocol.types import ( 5 | CompletionList, 6 | CompletionParams, 7 | InitializeParams, 8 | Position, 9 | TextDocumentIdentifier, 10 | ) 11 | from pytest_lsp import ClientServerConfig, LanguageClient, client_capabilities 12 | 13 | 14 | @pytest_lsp.fixture( 15 | params=["neovim", "visual_studio_code"], 16 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 17 | ) 18 | async def client(request, lsp_client: LanguageClient): 19 | # Setup 20 | params = InitializeParams(capabilities=client_capabilities(request.param)) 21 | await lsp_client.initialize_session(params) 22 | 23 | yield 24 | 25 | # Teardown 26 | await lsp_client.shutdown_session() 27 | 28 | 29 | async def test_completions(client: LanguageClient): 30 | """Ensure that the server implements completions correctly.""" 31 | 32 | results = await client.text_document_completion_async( 33 | params=CompletionParams( 34 | position=Position(line=1, character=0), 35 | text_document=TextDocumentIdentifier(uri="file:///path/to/file.txt"), 36 | ) 37 | ) 38 | assert results is not None 39 | 40 | if isinstance(results, CompletionList): 41 | items = results.items 42 | else: 43 | items = results 44 | 45 | labels = [item.label for item in items] 46 | assert labels == ["hello", "world"] 47 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/ruff.toml: -------------------------------------------------------------------------------- 1 | extend = "../../ruff.toml" 2 | src = ["."] 3 | 4 | [lint.isort] 5 | force-single-line = false 6 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/server-stderr/server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from lsprotocol import types 4 | from pygls.lsp.server import LanguageServer 5 | 6 | server = LanguageServer("server-stderr", "v1") 7 | 8 | 9 | @server.feature(types.TEXT_DOCUMENT_COMPLETION) 10 | def completion(params: types.CompletionParams): 11 | items = [] 12 | 13 | for i in range(10): 14 | print(f"Suggesting item {i}", file=sys.stderr, flush=True) 15 | items.append(types.CompletionItem(label=f"item-{i}")) 16 | 17 | return items 18 | 19 | 20 | if __name__ == "__main__": 21 | server.start_io() 22 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/server-stderr/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | import pytest_lsp 5 | from lsprotocol.types import ( 6 | ClientCapabilities, 7 | CompletionList, 8 | CompletionParams, 9 | InitializeParams, 10 | Position, 11 | TextDocumentIdentifier, 12 | ) 13 | from pytest_lsp import ClientServerConfig, LanguageClient 14 | 15 | 16 | @pytest_lsp.fixture( 17 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 18 | ) 19 | async def client(lsp_client: LanguageClient): 20 | # Setup 21 | params = InitializeParams(capabilities=ClientCapabilities()) 22 | await lsp_client.initialize_session(params) 23 | 24 | yield 25 | 26 | # Teardown 27 | await lsp_client.shutdown_session() 28 | 29 | 30 | async def test_completions(client: LanguageClient): 31 | results = await client.text_document_completion_async( 32 | params=CompletionParams( 33 | position=Position(line=1, character=0), 34 | text_document=TextDocumentIdentifier(uri="file:///path/to/file.txt"), 35 | ) 36 | ) 37 | 38 | assert results is not None 39 | 40 | if isinstance(results, CompletionList): 41 | items = results.items 42 | else: 43 | items = results 44 | 45 | labels = [item.label for item in items] 46 | assert labels == [f"item-{i}" for i in range(10)] 47 | pytest.fail("Failing test!") # Force the test case to fail. 48 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/window-create-progress/server.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from lsprotocol import types 4 | from pygls.lsp.server import LanguageServer 5 | 6 | server = LanguageServer("window-create-progress", "v1") 7 | 8 | 9 | @server.command("do.progress") 10 | async def do_progress(ls: LanguageServer, *args): 11 | token = "a-token" 12 | 13 | await ls.work_done_progress.create_async(token) 14 | 15 | # Begin 16 | ls.work_done_progress.begin( 17 | token, 18 | types.WorkDoneProgressBegin(title="Indexing", percentage=0), 19 | ) 20 | # Report 21 | for i in range(1, 4): 22 | ls.work_done_progress.report( 23 | token, 24 | types.WorkDoneProgressReport(message=f"{i * 25}%", percentage=i * 25), 25 | ) 26 | # End 27 | ls.work_done_progress.end(token, types.WorkDoneProgressEnd(message="Finished")) 28 | 29 | return "a result" 30 | 31 | 32 | @server.command("duplicate.progress") 33 | async def duplicate_progress(ls: LanguageServer, *args): 34 | token = "duplicate-token" 35 | 36 | # Need to stop pygls preventing us from using the progress API wrong. 37 | ls.work_done_progress._check_token_registered = Mock() 38 | await ls.work_done_progress.create_async(token) 39 | 40 | # pytest-lsp should return an error here. 41 | await ls.work_done_progress.create_async(token) 42 | 43 | 44 | @server.command("no.progress") 45 | async def no_progress(ls: LanguageServer, *args): 46 | token = "undefined-token" 47 | 48 | # Begin 49 | ls.work_done_progress.begin( 50 | token, 51 | types.WorkDoneProgressBegin(title="Indexing", percentage=0, cancellable=False), 52 | ) 53 | # Report 54 | for i in range(1, 4): 55 | ls.work_done_progress.report( 56 | token, 57 | types.WorkDoneProgressReport(message=f"{i * 25}%", percentage=i * 25), 58 | ) 59 | # End 60 | ls.work_done_progress.end(token, types.WorkDoneProgressEnd(message="Finished")) 61 | 62 | 63 | if __name__ == "__main__": 64 | server.start_io() 65 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/window-create-progress/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | import pytest_lsp 5 | from lsprotocol import types 6 | from pytest_lsp import ClientServerConfig, LanguageClient, LspSpecificationWarning 7 | 8 | 9 | @pytest_lsp.fixture( 10 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 11 | ) 12 | async def client(lsp_client: LanguageClient): 13 | # Setup 14 | params = types.InitializeParams(capabilities=types.ClientCapabilities()) 15 | await lsp_client.initialize_session(params) 16 | 17 | yield 18 | 19 | # Teardown 20 | await lsp_client.shutdown_session() 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_progress(client: LanguageClient): 25 | result = await client.workspace_execute_command_async( 26 | params=types.ExecuteCommandParams(command="do.progress") 27 | ) 28 | 29 | assert result == "a result" 30 | 31 | progress = client.progress_reports["a-token"] 32 | assert progress == [ 33 | types.WorkDoneProgressBegin(title="Indexing", percentage=0), 34 | types.WorkDoneProgressReport(message="25%", percentage=25), 35 | types.WorkDoneProgressReport(message="50%", percentage=50), 36 | types.WorkDoneProgressReport(message="75%", percentage=75), 37 | types.WorkDoneProgressEnd(message="Finished"), 38 | ] 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_duplicate_progress(client: LanguageClient): 43 | with pytest.warns( 44 | LspSpecificationWarning, match="Duplicate progress token: 'duplicate-token'" 45 | ): 46 | await client.workspace_execute_command_async( 47 | params=types.ExecuteCommandParams(command="duplicate.progress") 48 | ) 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_unknown_progress(client: LanguageClient): 53 | with pytest.warns( 54 | LspSpecificationWarning, match="Unknown progress token: 'undefined-token'" 55 | ): 56 | await client.workspace_execute_command_async( 57 | params=types.ExecuteCommandParams(command="no.progress") 58 | ) 59 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/window-log-message-fail/server.py: -------------------------------------------------------------------------------- 1 | from lsprotocol import types 2 | from pygls.lsp.server import LanguageServer 3 | 4 | server = LanguageServer("window-log-message", "v1") 5 | 6 | 7 | @server.feature(types.TEXT_DOCUMENT_COMPLETION) 8 | def completion(ls: LanguageServer, params: types.CompletionParams): 9 | items = [] 10 | 11 | for i in range(10): 12 | ls.window_log_message( 13 | types.LogMessageParams( 14 | message=f"Suggesting item {i}", 15 | type=types.MessageType.Log, 16 | ) 17 | ) 18 | items.append(types.CompletionItem(label=f"item-{i}")) 19 | 20 | return items 21 | 22 | 23 | if __name__ == "__main__": 24 | server.start_io() 25 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/window-log-message-fail/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | import pytest_lsp 5 | from lsprotocol.types import ( 6 | ClientCapabilities, 7 | CompletionList, 8 | CompletionParams, 9 | InitializeParams, 10 | Position, 11 | TextDocumentIdentifier, 12 | ) 13 | from pytest_lsp import ClientServerConfig, LanguageClient 14 | 15 | 16 | @pytest_lsp.fixture( 17 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 18 | ) 19 | async def client(lsp_client: LanguageClient): 20 | # Setup 21 | params = InitializeParams(capabilities=ClientCapabilities()) 22 | await lsp_client.initialize_session(params) 23 | 24 | yield 25 | 26 | # Teardown 27 | await lsp_client.shutdown_session() 28 | 29 | 30 | async def test_completions(client: LanguageClient): 31 | results = await client.text_document_completion_async( 32 | params=CompletionParams( 33 | position=Position(line=1, character=0), 34 | text_document=TextDocumentIdentifier(uri="file:///path/to/file.txt"), 35 | ) 36 | ) 37 | 38 | assert results is not None 39 | 40 | if isinstance(results, CompletionList): 41 | items = results.items 42 | else: 43 | items = results 44 | 45 | labels = [item.label for item in items] 46 | assert labels == [f"item-{i}" for i in range(10)] 47 | 48 | for idx, log_message in enumerate(client.log_messages): 49 | assert log_message.message == f"Suggesting item {idx}" 50 | 51 | pytest.fail("Failing test!") # Force the test case to fail. 52 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/window-log-message/server.py: -------------------------------------------------------------------------------- 1 | from lsprotocol import types 2 | from pygls.lsp.server import LanguageServer 3 | 4 | server = LanguageServer("window-log-message", "v1") 5 | 6 | 7 | @server.feature(types.TEXT_DOCUMENT_COMPLETION) 8 | def completion(ls: LanguageServer, params: types.CompletionParams): 9 | items = [] 10 | 11 | for i in range(10): 12 | ls.window_log_message( 13 | types.LogMessageParams( 14 | message=f"Suggesting item {i}", type=types.MessageType.Log 15 | ) 16 | ) 17 | items.append(types.CompletionItem(label=f"item-{i}")) 18 | 19 | return items 20 | 21 | 22 | if __name__ == "__main__": 23 | server.start_io() 24 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/window-log-message/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | import pytest_lsp 5 | from lsprotocol.types import ( 6 | ClientCapabilities, 7 | CompletionList, 8 | CompletionParams, 9 | InitializeParams, 10 | Position, 11 | TextDocumentIdentifier, 12 | ) 13 | from pytest_lsp import ClientServerConfig, LanguageClient 14 | 15 | 16 | @pytest_lsp.fixture( 17 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 18 | ) 19 | async def client(lsp_client: LanguageClient): 20 | # Setup 21 | params = InitializeParams(capabilities=ClientCapabilities()) 22 | await lsp_client.initialize_session(params) 23 | 24 | yield 25 | 26 | # Teardown 27 | await lsp_client.shutdown_session() 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_completions(client: LanguageClient): 32 | results = await client.text_document_completion_async( 33 | params=CompletionParams( 34 | position=Position(line=1, character=0), 35 | text_document=TextDocumentIdentifier(uri="file:///path/to/file.txt"), 36 | ) 37 | ) 38 | 39 | assert results is not None 40 | 41 | if isinstance(results, CompletionList): 42 | items = results.items 43 | else: 44 | items = results 45 | 46 | labels = [item.label for item in items] 47 | assert labels == [f"item-{i}" for i in range(10)] 48 | 49 | for idx, log_message in enumerate(client.log_messages): 50 | assert log_message.message == f"Suggesting item {idx}" 51 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/window-show-document/server.py: -------------------------------------------------------------------------------- 1 | from lsprotocol import types 2 | from pygls.lsp.server import LanguageServer 3 | 4 | server = LanguageServer("window-show-document", "v1") 5 | 6 | 7 | @server.feature(types.TEXT_DOCUMENT_COMPLETION) 8 | async def completion(ls: LanguageServer, params: types.CompletionParams): 9 | items = [] 10 | await ls.window_show_document_async( 11 | types.ShowDocumentParams(uri=params.text_document.uri) 12 | ) 13 | 14 | for i in range(10): 15 | items.append(types.CompletionItem(label=f"item-{i}")) 16 | 17 | return items 18 | 19 | 20 | if __name__ == "__main__": 21 | server.start_io() 22 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/window-show-document/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | import pytest_lsp 5 | from lsprotocol.types import ( 6 | ClientCapabilities, 7 | CompletionList, 8 | CompletionParams, 9 | InitializeParams, 10 | Position, 11 | TextDocumentIdentifier, 12 | ) 13 | from pytest_lsp import ClientServerConfig, LanguageClient 14 | 15 | 16 | @pytest_lsp.fixture( 17 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 18 | ) 19 | async def client(lsp_client: LanguageClient): 20 | # Setup 21 | params = InitializeParams(capabilities=ClientCapabilities()) 22 | await lsp_client.initialize_session(params) 23 | 24 | yield 25 | 26 | # Teardown 27 | await lsp_client.shutdown_session() 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_completions(client: LanguageClient): 32 | test_uri = "file:///path/to/file.txt" 33 | results = await client.text_document_completion_async( 34 | params=CompletionParams( 35 | position=Position(line=1, character=0), 36 | text_document=TextDocumentIdentifier(uri=test_uri), 37 | ) 38 | ) 39 | 40 | assert results is not None 41 | 42 | if isinstance(results, CompletionList): 43 | items = results.items 44 | else: 45 | items = results 46 | 47 | labels = [item.label for item in items] 48 | assert labels == [f"item-{i}" for i in range(10)] 49 | 50 | assert client.shown_documents[0].uri == test_uri 51 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/window-show-message/server.py: -------------------------------------------------------------------------------- 1 | from lsprotocol import types 2 | from pygls.lsp.server import LanguageServer 3 | 4 | server = LanguageServer("window-show-message", "v1") 5 | 6 | 7 | @server.feature(types.TEXT_DOCUMENT_COMPLETION) 8 | def completion(ls: LanguageServer, params: types.CompletionParams): 9 | items = [] 10 | 11 | for i in range(10): 12 | ls.window_show_message( 13 | types.ShowMessageParams( 14 | message=f"Suggesting item {i}", 15 | type=types.MessageType.Log, 16 | ) 17 | ) 18 | items.append(types.CompletionItem(label=f"item-{i}")) 19 | 20 | return items 21 | 22 | 23 | if __name__ == "__main__": 24 | server.start_io() 25 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/window-show-message/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | import pytest_lsp 5 | from lsprotocol.types import ( 6 | ClientCapabilities, 7 | CompletionList, 8 | CompletionParams, 9 | InitializeParams, 10 | Position, 11 | TextDocumentIdentifier, 12 | ) 13 | from pytest_lsp import ClientServerConfig, LanguageClient 14 | 15 | 16 | @pytest_lsp.fixture( 17 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 18 | ) 19 | async def client(lsp_client: LanguageClient): 20 | # Setup 21 | params = InitializeParams(capabilities=ClientCapabilities()) 22 | await lsp_client.initialize_session(params) 23 | 24 | yield 25 | 26 | # Teardown 27 | await lsp_client.shutdown_session() 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_completions(client: LanguageClient): 32 | results = await client.text_document_completion_async( 33 | params=CompletionParams( 34 | position=Position(line=1, character=0), 35 | text_document=TextDocumentIdentifier(uri="file:///path/to/file.txt"), 36 | ) 37 | ) 38 | 39 | assert results is not None 40 | 41 | if isinstance(results, CompletionList): 42 | items = results.items 43 | else: 44 | items = results 45 | 46 | labels = [item.label for item in items] 47 | assert labels == [f"item-{i}" for i in range(10)] 48 | 49 | for idx, shown_message in enumerate(client.messages): 50 | assert shown_message.message == f"Suggesting item {idx}" 51 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/workspace-configuration/server.py: -------------------------------------------------------------------------------- 1 | from lsprotocol import types 2 | from pygls.lsp.server import LanguageServer 3 | 4 | server = LanguageServer("workspace-configuration", "v1") 5 | 6 | 7 | @server.command("server.configuration") 8 | async def configuration(ls: LanguageServer, *args): 9 | results = await ls.workspace_configuration_async( 10 | types.ConfigurationParams( 11 | items=[ 12 | types.ConfigurationItem(scope_uri="file://workspace/file.txt"), 13 | types.ConfigurationItem(section="not.found"), 14 | types.ConfigurationItem(section="values.c"), 15 | ] 16 | ) 17 | ) 18 | 19 | a = results[0]["values"]["a"] 20 | assert results[1] is None 21 | c = results[2] 22 | 23 | return a + c 24 | 25 | 26 | if __name__ == "__main__": 27 | server.start_io() 28 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/examples/workspace-configuration/t_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | import pytest_lsp 5 | from lsprotocol import types 6 | from pytest_lsp import ClientServerConfig, LanguageClient 7 | 8 | 9 | @pytest_lsp.fixture( 10 | config=ClientServerConfig(server_command=[sys.executable, "server.py"]), 11 | ) 12 | async def client(lsp_client: LanguageClient): 13 | # Setup 14 | params = types.InitializeParams( 15 | capabilities=types.ClientCapabilities( 16 | workspace=types.WorkspaceClientCapabilities(configuration=False) 17 | ) 18 | ) 19 | await lsp_client.initialize_session(params) 20 | 21 | yield 22 | 23 | # Teardown 24 | await lsp_client.shutdown_session() 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_configuration(client: LanguageClient): 29 | global_config = {"values": {"a": 42, "c": 4}} 30 | 31 | workspace_uri = "file://workspace/file.txt" 32 | workspace_config = {"a": 1, "c": 1} 33 | 34 | client.set_configuration(global_config) 35 | client.set_configuration( 36 | workspace_config, section="values", scope_uri=workspace_uri 37 | ) 38 | 39 | result = await client.workspace_execute_command_async( 40 | params=types.ExecuteCommandParams(command="server.configuration") 41 | ) 42 | assert result == 5 43 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/servers/capabilities.py: -------------------------------------------------------------------------------- 1 | from lsprotocol.converters import get_converter 2 | from pygls.lsp.server import LanguageServer 3 | 4 | converter = get_converter() 5 | server = LanguageServer(name="capabilities-server", version="v1.0") 6 | 7 | 8 | @server.command("return.client.capabilities") 9 | def on_initialize(ls: LanguageServer, *args): 10 | return ls.client_capabilities 11 | 12 | 13 | if __name__ == "__main__": 14 | server.start_io() 15 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/servers/completion_exit.py: -------------------------------------------------------------------------------- 1 | # A server that exits mid request. 2 | import sys 3 | 4 | from lsprotocol import types 5 | from pygls.lsp.server import LanguageServer 6 | 7 | 8 | class CountingLanguageServer(LanguageServer): 9 | count: int = 0 10 | 11 | 12 | server = CountingLanguageServer(name="completion-exit-server", version="v1.0") 13 | 14 | 15 | @server.feature(types.TEXT_DOCUMENT_COMPLETION) 16 | def on_complete(server: CountingLanguageServer, params: types.CompletionParams): 17 | server.count += 1 18 | if server.count == 5: 19 | sys.exit(0) 20 | 21 | return [types.CompletionItem(label=f"{server.count}")] 22 | 23 | 24 | if __name__ == "__main__": 25 | server.start_io() 26 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/servers/crash.py: -------------------------------------------------------------------------------- 1 | # Not a server, used to simulate the case where the server crashes before 2 | # it can boot. 3 | 4 | 5 | def f(x): 6 | return x / 0 7 | 8 | 9 | def g(x): 10 | return f(x) * f(x) 11 | 12 | 13 | def h(x): 14 | return g(x) - f(x) 15 | 16 | 17 | h(2) 18 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/servers/hello.py: -------------------------------------------------------------------------------- 1 | # Not actually a server, but a script that prints hello world. 2 | print("Hello, world!") 3 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/servers/invalid_json.py: -------------------------------------------------------------------------------- 1 | # A server that returns a message that cannot be parsed as JSON. 2 | import json 3 | import sys 4 | 5 | from lsprotocol import types 6 | from pygls.io_ import StdoutWriter 7 | from pygls.lsp.server import LanguageServer 8 | 9 | server = LanguageServer(name="completion-exit-server", version="v1.0") 10 | 11 | 12 | def bad_send_data(data): 13 | """Sends data to the client in a way that cannot be parsed.""" 14 | if not data: 15 | return 16 | 17 | self = server.protocol 18 | body = json.dumps(data, default=self._serialize_message) 19 | body = body.replace('"', "'").encode(self.CHARSET) 20 | header = ( 21 | f"Content-Length: {len(body)}\r\n" 22 | f"Content-Type: {self.CONTENT_TYPE}; charset={self.CHARSET}\r\n\r\n" 23 | ).encode(self.CHARSET) 24 | 25 | self.writer.write(header + body) 26 | 27 | 28 | @server.feature(types.TEXT_DOCUMENT_COMPLETION) 29 | def on_complete(server: LanguageServer, params: types.CompletionParams): 30 | server.protocol._send_data = bad_send_data 31 | server.protocol.set_writer(StdoutWriter(sys.stdout.buffer)) 32 | 33 | return [types.CompletionItem(label="item-one")] 34 | 35 | 36 | if __name__ == "__main__": 37 | server.start_io() 38 | -------------------------------------------------------------------------------- /lib/pytest-lsp/tests/servers/notify_exit.py: -------------------------------------------------------------------------------- 1 | # A server that exits mid request. 2 | import sys 3 | 4 | from lsprotocol import types 5 | from pygls.lsp.server import LanguageServer 6 | 7 | 8 | class CountingLanguageServer(LanguageServer): 9 | count: int = 0 10 | 11 | 12 | server = CountingLanguageServer(name="completion-exit-server", version="v1.0") 13 | 14 | 15 | @server.feature("server/exit") 16 | def server_exit(*args): 17 | sys.exit(0) 18 | 19 | 20 | @server.feature(types.TEXT_DOCUMENT_COMPLETION) 21 | def on_complete(server: CountingLanguageServer, params: types.CompletionParams): 22 | server.count += 1 23 | if server.count == 5: 24 | sys.exit(0) 25 | 26 | return [types.CompletionItem(label=f"{server.count}")] 27 | 28 | 29 | if __name__ == "__main__": 30 | server.start_io() 31 | -------------------------------------------------------------------------------- /scripts/check_capabilities.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pathlib 3 | import sys 4 | 5 | from lsprotocol import types 6 | from lsprotocol.converters import get_converter 7 | 8 | 9 | def check(filepath: pathlib.Path, converter): 10 | obj = json.loads(filepath.read_text()) 11 | 12 | try: 13 | converter.structure(obj, types.InitializeParams) 14 | except Exception as e: 15 | print(f"{filepath.name}: {e}") 16 | return 1 17 | 18 | filepath.write_text(json.dumps(obj, indent=2)) 19 | return 0 20 | 21 | 22 | def main(files: list[str]): 23 | converter = get_converter() 24 | 25 | total = 0 26 | for filename in files: 27 | total += check(pathlib.Path(filename), converter) 28 | 29 | return total 30 | 31 | 32 | if __name__ == "__main__": 33 | sys.exit(main(sys.argv[1:])) 34 | -------------------------------------------------------------------------------- /scripts/nvim-capabilities.lua: -------------------------------------------------------------------------------- 1 | -- Dump the client capabilities for this version of neovim. 2 | local version_info = vim.version() 3 | local version = string.format('%d.%d.%d', version_info.major, version_info.minor, version_info.patch) 4 | 5 | local params = { 6 | clientInfo = { 7 | name = 'Neovim', 8 | version = version, 9 | }, 10 | capabilities = vim.lsp.protocol.make_client_capabilities(), 11 | } 12 | 13 | local json = vim.json.encode(params) 14 | local bufnr = vim.api.nvim_create_buf(true, false) 15 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { json }) 16 | 17 | vim.api.nvim_buf_call(bufnr, function() 18 | vim.cmd(string.format(':w neovim_v%s.json', version)) 19 | vim.cmd('.!python -m json.tool %') 20 | vim.cmd(':w') 21 | end) 22 | -------------------------------------------------------------------------------- /scripts/should-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script to check if we should build a given component or not. 3 | 4 | # File patterns to check for each component, if there's a match a build will be 5 | # triggered 6 | LSP_DEVTOOLS="^lib/lsp-devtools/" 7 | PYTEST_LSP="^lib/pytest-lsp/" 8 | 9 | # Determine which files have changed 10 | git diff --name-only ${BASE}..HEAD -- >> changes 11 | echo -e "Files Changed:\n" 12 | cat changes 13 | 14 | case $1 in 15 | lsp-devtools) 16 | PATTERN=${LSP_DEVTOOLS} 17 | ;; 18 | pytest-lsp) 19 | PATTERN=${PYTEST_LSP} 20 | ;; 21 | *) 22 | echo "Unknown component ${1}" 23 | exit 1 24 | ;; 25 | esac 26 | 27 | changes=$(grep -E "${PATTERN}" changes) 28 | echo 29 | 30 | rm changes 31 | 32 | if [ -z "$changes" ]; then 33 | echo "There is nothing to do." 34 | else 35 | echo "Changes detected, doing build!" 36 | echo "build::true" >> $GITHUB_OUTPUT 37 | fi 38 | --------------------------------------------------------------------------------