diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 6dca44f..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Documentation -on: - pull_request: - branches: - - release - - develop - paths: - - 'docs/**' - - 'lib/pytest-lsp/**' - push: - branches: - - release - - develop - paths: - - 'docs/**' - - 'lib/pytest-lsp/**' - -jobs: - docs: - name: Documentation - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - - run: | - set -e - - python --version - python -m pip install --upgrade pip - python -m pip install -r docs/requirements.txt - - name: Setup Environment - - - id: build - run: | - set -e - - cd docs - make html - name: Build Docs - - - name: 'Upload Aritfact' - uses: actions/upload-artifact@v3 - with: - name: 'docs' - path: 'docs/_build/${{ steps.build.outputs.version }}' - - - name: 'Publish Docs' - uses: JamesIves/github-pages-deploy-action@v4 - with: - branch: gh-pages - folder: docs/_build/${{ steps.build.outputs.version }} - target-folder: docs/${{ steps.build.outputs.version }} - clean: true - if: success() && ( startsWith(github.ref, 'refs/heads/release') || startsWith(github.ref, 'refs/heads/develop') ) diff --git a/.github/workflows/lsp-devtools-pr.yml b/.github/workflows/lsp-devtools-pr.yml index 44d1d9e..70ee63f 100644 --- a/.github/workflows/lsp-devtools-pr.yml +++ b/.github/workflows/lsp-devtools-pr.yml @@ -13,7 +13,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest] steps: diff --git a/.github/workflows/lsp-devtools-release.yml b/.github/workflows/lsp-devtools-release.yml new file mode 100644 index 0000000..05d20b2 --- /dev/null +++ b/.github/workflows/lsp-devtools-release.yml @@ -0,0 +1,66 @@ +name: 'Release: lsp-devtools' + +on: + push: + branches: + - release + paths: + - 'lib/lsp-devtools/**' + +jobs: + release: + name: lsp-devtools release + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/lsp-devtools + permissions: + id-token: write + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - run: | + sudo apt update + sudo apt install pandoc + + python --version + python -m pip install --upgrade pip + python -m pip install build bump2version towncrier docutils + name: Install Build Tools + + - run: | + set -e + + ./scripts/make-release.sh lsp-devtools + name: Set Version + id: info + + - name: Package + run: | + cd lib/lsp-devtools + python -m build + + - name: 'Upload Artifact' + uses: actions/upload-artifact@v3 + with: + name: 'dist' + path: lib/lsp-devtools/dist + + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: lib/lsp-devtools/dist/ + + - name: Create Release + run: | + gh release create "${RELEASE_TAG}" \ + --title "lsp-devtools v${VERSION} - ${RELEASE_DATE}" \ + -F lib/lsp-devtools/.changes.html \ + ./lib/lsp-devtools/dist/* + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pytest-lsp-pr.yml b/.github/workflows/pytest-lsp-pr.yml index 116aa12..cad5b0c 100644 --- a/.github/workflows/pytest-lsp-pr.yml +++ b/.github/workflows/pytest-lsp-pr.yml @@ -13,8 +13,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] - os: [ubuntu-latest] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v3 @@ -23,6 +23,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - run: | python --version @@ -37,24 +38,25 @@ jobs: # dev version number e.g. v1.2.3-dev4 ./scripts/make-release.sh pytest-lsp name: Set Version - if: matrix.python-version == '3.10' + if: matrix.python-version == '3.10' && matrix.os == 'ubuntu-latest' - run: | cd lib/pytest-lsp version=$(echo ${{ matrix.python-version }} | tr -d .) - python -m tox -e `tox -l | grep $version | tr '\n' ','` + python -m tox run -f "py${version}" + shell: bash name: Test - name: Package run: | cd lib/pytest-lsp python -m build - if: always() && matrix.python-version == '3.10' + if: always() && matrix.python-version == '3.10' && matrix.os == 'ubuntu-latest' - name: 'Upload Artifact' uses: actions/upload-artifact@v3 with: name: 'dist' path: lib/pytest-lsp/dist - if: always() && matrix.python-version == '3.10' + if: always() && matrix.python-version == '3.10' && matrix.os == 'ubuntu-latest' diff --git a/.github/workflows/pytest-lsp-release.yml b/.github/workflows/pytest-lsp-release.yml new file mode 100644 index 0000000..4903dd5 --- /dev/null +++ b/.github/workflows/pytest-lsp-release.yml @@ -0,0 +1,67 @@ +name: 'Release: pytest-lsp' + +on: + push: + branches: + - release + paths: + - 'lib/pytest-lsp/**' + +jobs: + release: + name: pytest-lsp release + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pytest-lsp + permissions: + id-token: write + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - run: | + sudo apt update + sudo apt install pandoc + + python --version + python -m pip install --upgrade pip + python -m pip install build bump2version towncrier docutils + + name: Install Build Tools + + - run: | + set -e + + ./scripts/make-release.sh pytest-lsp + name: Set Version + id: info + + - name: Package + run: | + cd lib/pytest-lsp + python -m build + + - name: 'Upload Artifact' + uses: actions/upload-artifact@v3 + with: + name: 'dist' + path: lib/pytest-lsp/dist + + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: lib/pytest-lsp/dist/ + + - name: Create Release + run: | + gh release create "${RELEASE_TAG}" \ + --title "pytest-lsp v${VERSION} - ${RELEASE_DATE}" \ + -F lib/pytest-lsp/.changes.html \ + ./lib/pytest-lsp/dist/* + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index d3a8814..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,157 +0,0 @@ -name: Release - -on: - push: - branches: - - release - -jobs: - # Simple job the checks to see which parts we actually have to build. - trigger: - name: Trigger - runs-on: ubuntu-latest - outputs: - lsp-devtools: ${{steps.check-lsp-devtools.outputs.build}} - pytest-lsp: ${{steps.check-pytest-lsp.outputs.build}} - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - run: | - if [ -z "${BASE_REF}" ]; then - echo "BASE=HEAD^" >> $GITHUB_ENV - else - echo "BASE=origin/${BASE_REF}" >> $GITHUB_ENV - fi - name: Determine base - env: - BASE_REF: ${{ github.base_ref }} - - - id: check-lsp-devtools - run: | - set -e - echo ${BASE} - - ./scripts/should-build.sh lsp-devtools - name: "Build lsp-devtools?" - - - id: check-pytest-lsp - run: | - set -e - echo ${BASE} - - ./scripts/should-build.sh pytest-lsp - name: "Build pytest-lsp?" - - lsp-devtools: - name: lsp-devtools - needs: trigger - if: always() && needs.trigger.outputs.lsp-devtools - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - - run: | - sudo apt update - sudo apt install pandoc - - python --version - python -m pip install --upgrade pip - python -m pip install build bump2version towncrier docutils - name: Install Build Tools - - - run: | - set -e - - ./scripts/make-release.sh lsp-devtools - name: Set Version - id: info - - - name: Package - run: | - cd lib/lsp-devtools - python -m build - - - name: 'Upload Artifact' - uses: actions/upload-artifact@v3 - with: - name: 'dist' - path: lib/lsp-devtools/dist - - - name: Publish - id: assets - run: | - cd lib/lsp-devtools - python -m pip install twine - python -m twine upload dist/* -u alcarney -p ${{ secrets.PYPI_PASS }} - - - name: Create Release - run: | - gh release create "${RELEASE_TAG}" \ - --title "lsp-devtools v${VERSION} - ${RELEASE_DATE}" \ - -F lib/lsp-devtools/.changes.html \ - ./lib/lsp-devtools/dist/* - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - pytest-lsp: - name: pytest-lsp - needs: trigger - if: always() && needs.trigger.outputs.pytest-lsp - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - - run: | - sudo apt update - sudo apt install pandoc - - python --version - python -m pip install --upgrade pip - python -m pip install build bump2version towncrier docutils - - name: Install Build Tools - - - run: | - set -e - - ./scripts/make-release.sh pytest-lsp - name: Set Version - id: info - - - name: Package - run: | - cd lib/pytest-lsp - python -m build - - - name: 'Upload Artifact' - uses: actions/upload-artifact@v3 - with: - name: 'dist' - path: lib/pytest-lsp/dist - - - name: Publish - run: | - cd lib/pytest-lsp - python -m pip install twine - python -m twine upload dist/* -u alcarney -p ${{ secrets.PYPI_PASS }} - - - name: Create Release - run: | - gh release create "${RELEASE_TAG}" \ - --title "pytest-lsp v${VERSION} - ${RELEASE_DATE}" \ - -F lib/pytest-lsp/.changes.html \ - ./lib/pytest-lsp/dist/* - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index f117763..dfd8b34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.coverage .env .tox *.pyc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6bc33d3..c5c5f50 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,17 +4,18 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: + - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.9.1 hooks: - id: black exclude: 'lib/pytest-lsp/pytest_lsp/gen.py' - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 args: [--config=lib/lsp-devtools/setup.cfg] @@ -34,17 +35,18 @@ repos: exclude: 'lib/pytest-lsp/pytest_lsp/gen.py' - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.3.0' + rev: 'v1.5.1' hooks: - id: mypy name: mypy (pytest-lsp) args: [--explicit-package-bases,--check-untyped-defs] additional_dependencies: - importlib-resources + - platformdirs - pygls - pytest - pytest-asyncio - - types-appdirs + - websockets files: 'lib/pytest-lsp/pytest_lsp/.*\.py' - id: mypy @@ -54,8 +56,9 @@ repos: - aiosqlite - attrs - importlib-resources + - platformdirs - pygls + - stamina - textual - - types-appdirs - websockets files: 'lib/lsp-devtools/lsp_devtools/.*\.py' diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..411b29d --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,23 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Optional but recommended, declare the Python requirements required +# to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: docs/requirements.txt diff --git a/docs/Makefile b/docs/Makefile index 4ae965d..b16343c 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -14,7 +14,7 @@ endif html-build: BUILDDIR=$(BUILDDIR) sphinx-build -b html . _build/$(BUILDDIR)/en/ - echo "version=$(BUILDDIR)" >> $GITHUB_OUTPUT + echo "version=$(BUILDDIR)" >> $(GITHUB_OUTPUT) html-local: diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..2f2a077 --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,4 @@ +.scrollable-svg { + max-height: 450px; + overflow: auto; +} diff --git a/docs/conf.py b/docs/conf.py index eaec57c..538e610 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,6 +13,9 @@ from docutils import nodes # noqa: E402 from sphinx.application import Sphinx # noqa: E402 +DEV_BUILD = os.getenv("BUILDDIR", None) == "latest" +BRANCH = "develop" if DEV_BUILD else "release" + project = "LSP Devtools" copyright = "2023, Alex Carney" author = "Alex Carney" @@ -24,6 +27,7 @@ "sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx.ext.intersphinx", + "sphinx_copybutton", "sphinx_design", "supported_clients", ] @@ -33,6 +37,7 @@ autodoc_typehints = "description" intersphinx_mapping = { + "pygls": ("https://pygls.readthedocs.io/en/latest/", None), "python": ("https://docs.python.org/3/", None), "pytest": ("https://docs.pytest.org/en/stable/", None), } @@ -46,7 +51,20 @@ html_theme = "furo" html_title = "LSP Devtools" -# html_static_path = ["_static"] +html_static_path = ["_static"] +html_theme_options = { + "source_repository": "https://github.com/swyddfa/lsp-devtools/", + "source_branch": BRANCH, + "source_directory": "docs/", +} + +if DEV_BUILD: + html_theme_options["announcement"] = ( + "This is the unstable version of the documentation, features may change or " + "be removed without warning. " + 'Click here ' + "to view the released version" + ) def lsp_role(name, rawtext, text, lineno, inliner, options={}, content=[]): @@ -60,4 +78,5 @@ def lsp_role(name, rawtext, text, lineno, inliner, options={}, content=[]): def setup(app: Sphinx): + app.add_css_file("custom.css") app.add_role("lsp", lsp_role) diff --git a/docs/images/lsp-devtools-architecture.svg b/docs/images/lsp-devtools-architecture.svg new file mode 100644 index 0000000..58d2267 --- /dev/null +++ b/docs/images/lsp-devtools-architecture.svg @@ -0,0 +1,17 @@ + + + + + + + + Language ClientLanguage ServerAgentAgent Serverstdinstdinstdoutstdouttcp diff --git a/docs/images/record-client-capabilities.svg b/docs/images/record-client-capabilities.svg new file mode 100644 index 0000000..9108529 --- /dev/null +++ b/docs/images/record-client-capabilities.svg @@ -0,0 +1,1311 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 22:59:30CLIENTNeovim v0.9.1 +{ +"window"{ +"showDocument"{ +"support": true                                            +},                                                           +"workDoneProgress": true,                                    +"showMessage"{ +"messageActionItem"{ +"additionalPropertiesSupport": false                     +} +} +},                                                             +"textDocument"{ +"references"{ +"dynamicRegistration": false                               +},                                                           +"semanticTokens"{ +"overlappingTokenSupport": true,                           +"dynamicRegistration": false,                              +"serverCancelSupport": false,                              +"augmentsSyntaxTokens": true,                              +"tokenModifiers"[ +"declaration",                                           +"definition",                                            +"readonly",                                              +"static",                                                +"deprecated",                                            +"abstract",                                              +"async",                                                 +"modification",                                          +"documentation",                                         +"defaultLibrary" +],                                                         +"tokenTypes"[ +"namespace",                                             +"type",                                                  +"class",                                                 +"enum",                                                  +"interface",                                             +"struct",                                                +"typeParameter",                                         +"parameter",                                             +"variable",                                              +"property",                                              +"enumMember",                                            +"event",                                                 +"function",                                              +"method",                                                +"macro",                                                 +"keyword",                                               +"modifier",                                              +"comment",                                               +"string",                                                +"number",                                                +"regexp",                                                +"operator",                                              +"decorator" +],                                                         +"multilineTokenSupport": false,                            +"requests"{ +"range": false,                                          +"full"{ +"delta": true                                          +} +},                                                         +"formats"[ +"relative" +] +},                                                           +"documentSymbol"{ +"hierarchicalDocumentSymbolSupport": true,                 +"dynamicRegistration": false,                              +"symbolKind"{ +"valueSet"[ +1,                                                     +2,                                                     +3,                                                     +4,                                                     +5,                                                     +6,                                                     +7,                                                     +8,                                                     +9,                                                     +10,                                                    +11,                                                    +12,                                                    +13,                                                    +14,                                                    +15,                                                    +16,                                                    +17,                                                    +18,                                                    +19,                                                    +20,                                                    +21,                                                    +22,                                                    +23,                                                    +24,                                                    +25,                                                    +26 +] +} +},                                                           +"signatureHelp"{ +"dynamicRegistration": false,                              +"signatureInformation"{ +"documentationFormat"[ +"markdown",                                            +"plaintext" +],                                                       +"parameterInformation"{ +"labelOffsetSupport": true                             +},                                                       +"activeParameterSupport": true                           +} +},                                                           +"documentHighlight"{ +"dynamicRegistration": false                               +},                                                           +"synchronization"{ +"didSave": true,                                           +"willSaveWaitUntil": true,                                 +"dynamicRegistration": false,                              +"willSave": true                                           +},                                                           +"declaration"{ +"linkSupport": true                                        +},                                                           +"codeAction"{ +"codeActionLiteralSupport"{ +"codeActionKind"{ +"valueSet"[ +"",                                                  +"quickfix",                                          +"refactor",                                          +"refactor.extract",                                  +"refactor.inline",                                   +"refactor.rewrite",                                  +"source",                                            +"source.organizeImports" +] +} +},                                                         +"dynamicRegistration": false,                              +"isPreferredSupport": true,                                +"dataSupport": true,                                       +"resolveSupport"{ +"properties"[ +"edit" +] +} +},                                                           +"definition"{ +"linkSupport": true                                        +},                                                           +"callHierarchy"{ +"dynamicRegistration": false                               +},                                                           +"implementation"{ +"linkSupport": true                                        +},                                                           +"completion"{ +"completionItem"{ +"insertTextModeSupport"{ +"valueSet"[ +1,                                                   +2 +] +},                                                       +"snippetSupport": true,                                  +"commitCharactersSupport": true,                         +"preselectSupport": true,                                +"deprecatedSupport": true,                               +"documentationFormat"[ +"markdown",                                            +"plaintext" +],                                                       +"tagSupport"{ +"valueSet"[ +1 +] +},                                                       +"labelDetailsSupport": true,                             +"resolveSupport"{ +"properties"[ +"documentation",                                     +"detail",                                            +"additionalTextEdits" +] +},                                                       +"insertReplaceSupport": true                             +},                                                         +"dynamicRegistration": false,                              +"completionList"{ +"itemDefaults"[ +"commitCharacters",                                    +"editRange",                                           +"insertTextFormat",                                    +"insertTextMode",                                      +"data" +] +},                                                         +"insertTextMode"1,                                       +"completionItemKind"{ +"valueSet"[ +1,                                                     +2,                                                     +3,                                                     +4,                                                     +5,                                                     +6,                                                     +7,                                                     +8,                                                     +9,                                                     +10,                                                    +11,                                                    +12,                                                    +13,                                                    +14,                                                    +15,                                                    +16,                                                    +17,                                                    +18,                                                    +19,                                                    +20,                                                    +21,                                                    +22,                                                    +23,                                                    +24,                                                    +25 +] +},                                                         +"contextSupport": true                                     +},                                                           +"hover"{ +"dynamicRegistration": false,                              +"contentFormat"[ +"markdown",                                              +"plaintext" +] +},                                                           +"rename"{ +"dynamicRegistration": false,                              +"prepareSupport": true                                     +},                                                           +"typeDefinition"{ +"linkSupport": true                                        +},                                                           +"publishDiagnostics"{ +"tagSupport"{ +"valueSet"[ +1,                                                     +2 +] +},                                                         +"relatedInformation": true                                 +} +},                                                             +"workspace"{ +"semanticTokens"{ +"refreshSupport": true                                     +},                                                           +"symbol"{ +"hierarchicalWorkspaceSymbolSupport": true,                +"dynamicRegistration": false,                              +"symbolKind"{ +"valueSet"[ +1,                                                     +2,                                                     +3,                                                     +4,                                                     +5,                                                     +6,                                                     +7,                                                     +8,                                                     +9,                                                     +10,                                                    +11,                                                    +12,                                                    +13,                                                    +14,                                                    +15,                                                    +16,                                                    +17,                                                    +18,                                                    +19,                                                    +20,                                                    +21,                                                    +22,                                                    +23,                                                    +24,                                                    +25,                                                    +26 +] +} +},                                                           +"applyEdit": true,                                           +"configuration": true,                                       +"workspaceFolders": true,                                    +"workspaceEdit"{ +"resourceOperations"[ +"rename",                                                +"create",                                                +"delete" +] +},                                                           +"didChangeWatchedFiles"{ +"dynamicRegistration": false,                              +"relativePatternSupport": true                             +} +} +} + + + + diff --git a/docs/images/record-example.svg b/docs/images/record-example.svg new file mode 100644 index 0000000..564b19a --- /dev/null +++ b/docs/images/record-example.svg @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 16:39:50CLIENT{ +"params"{ +"position"{ +"character"26,                                                                                                                 +"line"34 +},                                                                                                                                 +"textDocument"{ +"uri""file:///var/home/alex/Projects/lsp-devtools/docs/index.rst" +} +},                                                                                                                                   +"method""textDocument/definition",                                                                                                 +"id"2,                                                                                                                             +"jsonrpc""2.0" +} +16:39:50SERVER{ +"id"2,                                                                                                                             +"jsonrpc""2.0",                                                                                                                    +"result"[ +{ +"uri""file:///var/home/alex/Projects/lsp-devtools/docs/pytest-lsp/guide/window-log-message-output.txt",                        +"range"{ +"start"{ +"line"0,                                                                                                                   +"character"0 +},                                                                                                                             +"end"{ +"line"1,                                                                                                                   +"character"0 +} +} +} +] +} + + + + diff --git a/docs/images/record-log-messages.svg b/docs/images/record-log-messages.svg new file mode 100644 index 0000000..55a89d2 --- /dev/null +++ b/docs/images/record-log-messages.svg @@ -0,0 +1,357 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.directives' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.roles' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.rst.directives' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.rst.roles' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.autodoc' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.codeblocks' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.domains' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.directives' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.images' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.includes' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.roles' +23:00:12SERVERLog: [esbonio.lsp] User Config { +"buildDir""${confDir}/_build" +} +23:00:12SERVERLog: [esbonio.lsp] Workspace Folder: 'file:///var/home/alex/Projects/lsp-devtools' +23:00:13SERVERLog: [esbonio.lsp] Sphinx Args { +"buildername""html",                                                             +"confdir""/var/home/alex/Projects/lsp-devtools/docs",                            +"confoverrides"{},                                                               +"doctreedir""/var/home/alex/Projects/lsp-devtools/docs/_build/doctrees",         +"freshenv": false,                                                                 +"keep_going": false,                                                               +"outdir""/var/home/alex/Projects/lsp-devtools/docs/_build/html",                 +"parallel"1,                                                                     +"srcdir""/var/home/alex/Projects/lsp-devtools/docs",                             +"status": null,                                                                    +"tags"[],                                                                        +"verbosity"0,                                                                    +"warning": null,                                                                   +"warningiserror": false                                                            +} +23:00:13SERVERLog: Running Sphinx v6.2.1 +23:00:13SERVERLog: [esbonio.lsp] Traceback (most recent call last):                                +  File                                                                               +"/var/home/alex/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/registry +.py", line 442, in load_extension                                                    +    mod = import_module(extname) +          ^^^^^^^^^^^^^^^^^^^^^^                                                     +  File "/usr/lib64/python3.11/importlib/__init__.py", line 126, in import_module     +    return _bootstrap._gcd_import(name[level:], package, level) +           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                      +  File "<frozen importlib._bootstrap>", line 1204, in _gcd_import +  File "<frozen importlib._bootstrap>", line 1176, in _find_and_load +  File "<frozen importlib._bootstrap>", line 1140, in _find_and_load_unlocked        +ModuleNotFoundError: No module named 'sphinx_copybutton' + +The above exception was the direct cause of the following exception:                 + +Traceback (most recent call last):                                                   +  File "/var/home/alex/Projects/esbonio/lib/esbonio/esbonio/lsp/sphinx/__init__.py", +line 149, in _initialize_sphinx                                                      +    return self.create_sphinx_app(self.user_config)  # type: ignore                  +           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                  +  File "/var/home/alex/Projects/esbonio/lib/esbonio/esbonio/lsp/sphinx/__init__.py", +line 343, in create_sphinx_app                                                       +    app = Sphinx(**self.sphinx_args) +          ^^^^^^^^^^^^^^^^^^^^^^^^^^                                                 +  File                                                                               +"/var/home/alex/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/applicat +ion.py", line 229, in __init__                                                       +self.setup_extension(extension) +  File                                                                               +"/var/home/alex/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/applicat +ion.py", line 404, in setup_extension                                                +self.registry.load_extension(self, extname) +  File                                                                               +"/var/home/alex/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/registry +.py", line 445, in load_extension                                                    +    raise ExtensionError(__('Could not import extension %s') % extname,              +sphinx.errors.ExtensionError: Could not import extension sphinx_copybutton           +(exception: No module named 'sphinx_copybutton') +23:00:13SERVERLog: [esbonio.lsp] Publishing 1 diagnostics for:                                     +file:///var/home/alex/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/re +gistry.py + + + + diff --git a/docs/images/tui-screenshot.svg b/docs/images/tui-screenshot.svg new file mode 100644 index 0000000..690b7e4 --- /dev/null +++ b/docs/images/tui-screenshot.svg @@ -0,0 +1,294 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LSPInspector + + + + + + + + + + LSPInspector + Time          Source  ID  Method                          ▼ params + 1   16:46:08.867  client  1   initialize                      ├── uri = 'file:///var/home/alex/Projects/esbonio/.env/lib64 + 2   16:46:10.253  server  window/logMessage               └── ▼ diagnostics + 3   16:46:10.253  server  window/logMessage               └── ▼ 0 + 4   16:46:10.254  server  window/logMessage               ├── ▼ range + 5   16:46:10.254  server  window/logMessage               │   ├── ▼ start + 6   16:46:10.254  server  window/logMessage               │   │   ├── line = 444 + 7   16:46:10.254  server  window/logMessage               │   │   └── character = 0 + 8   16:46:10.255  server  window/logMessage               │   └── ▼ end + 9   16:46:10.255  server  window/logMessage               │   ├── line = 445 + 10  16:46:10.255  server  window/logMessage               │   └── character = 0 + 11  16:46:10.255  server  window/logMessage               ├── message = 'Could not import extension sphinx_cop + 12  16:46:10.256  server  window/logMessage               ├── severity = 1 + 13  16:46:10.270  server  1  └── source = 'conf.py' + 14  16:46:10.271  client  initialized                      + 15  16:46:10.271  client  textDocument/didOpen             + 16  16:46:10.277  server  window/logMessage                + 17  16:46:10.277  server  window/logMessage                + 18  16:46:10.466  server  window/logMessage                + 19  16:46:10.467  server  window/logMessage                + 20  16:46:10.782  server  window/logMessage                + 21  16:46:10.783  server  window/logMessage                + 22  16:46:10.786  server  textDocument/publishDiagnostics  + 23  16:46:10.795  server  esbonio/buildComplete            + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CTRL+C  Quit  CTRL+B  Sidebar  CTRL+S  Take Screenshot  + + + diff --git a/docs/index.rst b/docs/index.rst index 55a6b42..b274137 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,13 +4,25 @@ LSP Devtools The LSP Devtools project provides a number of tools that aim to make the process of developing language servers and clients easier. +lsp-devtools +------------ + +.. toctree:: + :hidden: + :caption: lsp-devtools + + lsp-devtools/guide + lsp-devtools/changelog + + +The `lsp-devtools `_ package provides a collection of CLI utilities that help inspect and visualise the interactions between a language client and a server. + +See the :doc:`lsp-devtools/guide/getting-started` guide for details. pytest-lsp ---------- .. toctree:: - :maxdepth: 1 - :glob: :hidden: :caption: pytest-lsp @@ -18,9 +30,10 @@ pytest-lsp pytest-lsp/reference pytest-lsp/changelog +`pytest-lsp `_ is a pytest plugin for writing end-to-end tests for language servers. - -``pytest-lsp`` is a pytest plugin for writing end-to-end tests for language servers. +.. literalinclude:: ./pytest-lsp/guide/window-log-message-output.txt + :language: none It works by running the language server in a subprocess and communicating with it over stdio, just like a real language client. This also means ``pytest-lsp`` can be used to test language servers written in any language - not just Python. diff --git a/docs/lsp-devtools/changelog.rst b/docs/lsp-devtools/changelog.rst new file mode 100644 index 0000000..9327b09 --- /dev/null +++ b/docs/lsp-devtools/changelog.rst @@ -0,0 +1,4 @@ +Changelog +========= + +.. include:: ../../lib/lsp-devtools/CHANGES.rst diff --git a/docs/lsp-devtools/guide.rst b/docs/lsp-devtools/guide.rst new file mode 100644 index 0000000..01be0ae --- /dev/null +++ b/docs/lsp-devtools/guide.rst @@ -0,0 +1,9 @@ +User Guide +---------- + +.. toctree:: + :maxdepth: 2 + + guide/getting-started + guide/record-command + guide/tui-command diff --git a/docs/lsp-devtools/guide/example-to-file-output.json b/docs/lsp-devtools/guide/example-to-file-output.json new file mode 100644 index 0000000..87a4408 --- /dev/null +++ b/docs/lsp-devtools/guide/example-to-file-output.json @@ -0,0 +1,27 @@ +{'jsonrpc': '2.0', 'id': 1, 'params': {'rootUri': 'file:///var/home/username/Projects/lsp-devtools', 'workspaceFolders': [{'uri': 'file:///var/home/username/Projects/lsp-devtools', 'name': '/var/home/username/Projects/lsp-devtools'}], 'capabilities': {'window': {'workDoneProgress': True, 'showMessage': {'messageActionItem': {'additionalPropertiesSupport': False}}, 'showDocument': {'support': True}}, 'workspace': {'semanticTokens': {'refreshSupport': True}, 'workspaceFolders': True, 'applyEdit': True, 'configuration': True, 'symbol': {'dynamicRegistration': False, 'symbolKind': {'valueSet': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]}, 'hierarchicalWorkspaceSymbolSupport': True}, 'didChangeWatchedFiles': {'relativePatternSupport': True, 'dynamicRegistration': False}, 'workspaceEdit': {'resourceOperations': ['rename', 'create', 'delete']}}, 'textDocument': {'typeDefinition': {'linkSupport': True}, 'definition': {'linkSupport': True}, 'signatureHelp': {'signatureInformation': {'activeParameterSupport': True, 'parameterInformation': {'labelOffsetSupport': True}, 'documentationFormat': ['markdown', 'plaintext']}, 'dynamicRegistration': False}, 'callHierarchy': {'dynamicRegistration': False}, 'declaration': {'linkSupport': True}, 'synchronization': {'didSave': True, 'willSave': True, 'dynamicRegistration': False, 'willSaveWaitUntil': True}, 'semanticTokens': {'requests': {'range': False, 'full': {'delta': True}}, 'formats': ['relative'], 'overlappingTokenSupport': True, 'dynamicRegistration': False, 'tokenTypes': ['namespace', 'type', 'class', 'enum', 'interface', 'struct', 'typeParameter', 'parameter', 'variable', 'property', 'enumMember', 'event', 'function', 'method', 'macro', 'keyword', 'modifier', 'comment', 'string', 'number', 'regexp', 'operator', 'decorator'], 'augmentsSyntaxTokens': True, 'tokenModifiers': ['declaration', 'definition', 'readonly', 'static', 'deprecated', 'abstract', 'async', 'modification', 'documentation', 'defaultLibrary'], 'multilineTokenSupport': False, 'serverCancelSupport': False}, 'references': {'dynamicRegistration': False}, 'documentHighlight': {'dynamicRegistration': False}, 'documentSymbol': {'dynamicRegistration': False, 'symbolKind': {'valueSet': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]}, 'hierarchicalDocumentSymbolSupport': True}, 'implementation': {'linkSupport': True}, 'completion': {'dynamicRegistration': False, 'insertTextMode': 1, 'completionList': {'itemDefaults': ['commitCharacters', 'editRange', 'insertTextFormat', 'insertTextMode', 'data']}, 'completionItemKind': {'valueSet': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]}, 'contextSupport': True, 'completionItem': {'resolveSupport': {'properties': ['documentation', 'detail', 'additionalTextEdits']}, 'insertReplaceSupport': True, 'snippetSupport': True, 'commitCharactersSupport': True, 'preselectSupport': True, 'deprecatedSupport': True, 'documentationFormat': ['markdown', 'plaintext'], 'insertTextModeSupport': {'valueSet': [1, 2]}, 'labelDetailsSupport': True, 'tagSupport': {'valueSet': [1]}}}, 'hover': {'contentFormat': ['markdown', 'plaintext'], 'dynamicRegistration': False}, 'publishDiagnostics': {'relatedInformation': True, 'tagSupport': {'valueSet': [1, 2]}}, 'codeAction': {'dynamicRegistration': False, 'isPreferredSupport': True, 'dataSupport': True, 'resolveSupport': {'properties': ['edit']}, 'codeActionLiteralSupport': {'codeActionKind': {'valueSet': ['', 'quickfix', 'refactor', 'refactor.extract', 'refactor.inline', 'refactor.rewrite', 'source', 'source.organizeImports']}}}, 'rename': {'prepareSupport': True, 'dynamicRegistration': False}}}, 'rootPath': '/var/home/username/Projects/lsp-devtools', 'processId': 24997, 'clientInfo': {'version': '0.9.1', 'name': 'Neovim'}, 'initializationOptions': {'server': {'logLevel': 'debug'}, 'sphinx': {'buildDir': '${confDir}/_build'}}, 'trace': 'off'}, 'method': 'initialize'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.directives'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.roles'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.rst.directives'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.rst.roles'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.autodoc'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.codeblocks'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.domains'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.directives'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.images'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.includes'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.roles'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'id': 1, 'jsonrpc': '2.0', 'result': {'capabilities': {'textDocumentSync': {'openClose': True, 'change': 2, 'willSave': False, 'willSaveWaitUntil': False, 'save': True}, 'completionProvider': {'triggerCharacters': ['>', '.', ':', '`', '<', '/'], 'resolveProvider': True}, 'hoverProvider': True, 'definitionProvider': True, 'implementationProvider': {}, 'documentSymbolProvider': True, 'codeActionProvider': True, 'documentLinkProvider': {}, 'executeCommandProvider': {'commands': ['esbonio.server.build', 'esbonio.server.configuration', 'esbonio.server.preview']}, 'workspace': {'workspaceFolders': {'supported': True, 'changeNotifications': True}, 'fileOperations': {}}}, 'serverInfo': {'name': 'esbonio', 'version': '0.16.1'}}} +{'jsonrpc': '2.0', 'method': 'initialized', 'params': {}} +{'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': 'file:///var/home/username/Projects/lsp-devtools/docs/index.rst', 'text': 'LSP Devtools\n============\n\nThe LSP Devtools project provides a number of tools that aim to make the\nprocess of developing language servers and clients easier.\n\nlsp-devtools\n------------\n\n.. toctree::\n :hidden:\n :caption: lsp-devtools\n\n lsp-devtools/guide\n lsp-devtools/changelog\n\n\nThe `lsp-devtools `_ package provides a collection of CLI utilities that help inspect and visualise the interactions between a language client and a server.\n\nSee the :doc:`lsp-devtools/guide/getting-started` guide for details.\n\npytest-lsp\n----------\n\n.. toctree::\n :hidden:\n :caption: pytest-lsp\n\n pytest-lsp/guide\n pytest-lsp/reference\n pytest-lsp/changelog\n\n`pytest-lsp `_ is a pytest plugin for writing end-to-end tests for language servers.\n\n.. literalinclude:: ./pytest-lsp/guide/window-log-message-output.txt\n :language: none\n\nIt works by running the language server in a subprocess and communicating with it over stdio, just like a real language client.\nThis also means ``pytest-lsp`` can be used to test language servers written in any language - not just Python.\n\n``pytest-lsp`` relies on `pygls `__ for its language server protocol implementation.\n\nSee the :doc:`pytest-lsp/guide/getting-started` guide for details on how to write your first test case.\n', 'languageId': 'rst', 'version': 0}}} +{'params': {'type': 4, 'message': '[esbonio.lsp] User Config {\n "buildDir": "${confDir}/_build"\n}'}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Workspace Folder: 'file:///var/home/username/Projects/lsp-devtools'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': '[esbonio.lsp] Sphinx Args {\n "buildername": "html",\n "confdir": "/var/home/username/Projects/lsp-devtools/docs",\n "confoverrides": {},\n "doctreedir": "/var/home/username/Projects/lsp-devtools/docs/_build/doctrees",\n "freshenv": false,\n "keep_going": false,\n "outdir": "/var/home/username/Projects/lsp-devtools/docs/_build/html",\n "parallel": 1,\n "srcdir": "/var/home/username/Projects/lsp-devtools/docs",\n "status": null,\n "tags": [],\n "verbosity": 0,\n "warning": null,\n "warningiserror": false\n}'}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': 'Running Sphinx v6.2.1'}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': '[esbonio.lsp] Traceback (most recent call last):\n File "/var/home/username/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/registry.py", line 442, in load_extension\n mod = import_module(extname)\n ^^^^^^^^^^^^^^^^^^^^^^\n File "/usr/lib64/python3.11/importlib/__init__.py", line 126, in import_module\n return _bootstrap._gcd_import(name[level:], package, level)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File "", line 1204, in _gcd_import\n File "", line 1176, in _find_and_load\n File "", line 1140, in _find_and_load_unlocked\nModuleNotFoundError: No module named \'sphinx_copybutton\'\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n File "/var/home/username/Projects/esbonio/lib/esbonio/esbonio/lsp/sphinx/__init__.py", line 149, in _initialize_sphinx\n return self.create_sphinx_app(self.user_config) # type: ignore\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File "/var/home/username/Projects/esbonio/lib/esbonio/esbonio/lsp/sphinx/__init__.py", line 343, in create_sphinx_app\n app = Sphinx(**self.sphinx_args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^\n File "/var/home/username/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/application.py", line 229, in __init__\n self.setup_extension(extension)\n File "/var/home/username/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/application.py", line 404, in setup_extension\n self.registry.load_extension(self, extname)\n File "/var/home/username/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/registry.py", line 445, in load_extension\n raise ExtensionError(__(\'Could not import extension %s\') % extname,\nsphinx.errors.ExtensionError: Could not import extension sphinx_copybutton (exception: No module named \'sphinx_copybutton\')'}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': '[esbonio.lsp] Publishing 1 diagnostics for: file:///var/home/username/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/registry.py'}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'uri': 'file:///var/home/username/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/registry.py', 'diagnostics': [{'range': {'start': {'line': 444, 'character': 0}, 'end': {'line': 445, 'character': 0}}, 'message': 'Could not import extension sphinx_copybutton', 'severity': 1, 'source': 'conf.py'}]}, 'method': 'textDocument/publishDiagnostics', 'jsonrpc': '2.0'} +{'method': 'esbonio/buildComplete', 'jsonrpc': '2.0', 'params': {'config': {'sphinx': {'buildDir': '/var/home/username/Projects/lsp-devtools/docs/_build/html', 'confDir': '/var/home/username/Projects/lsp-devtools/docs', 'doctreeDir': '/var/home/username/Projects/lsp-devtools/docs/_build/doctrees', 'srcDir': '/var/home/username/Projects/lsp-devtools/docs', 'command': ['sphinx-build', '-M', 'html', '/var/home/username/Projects/lsp-devtools/docs', '/var/home/username/Projects/lsp-devtools/docs/_build/html', '-d', '/var/home/username/Projects/lsp-devtools/docs/_build/doctrees'], 'version': '6.2.1'}, 'server': {'logLevel': 'debug'}}, 'error': True, 'warnings': 0}} +{'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'contentChanges': [{'text': '\n\n', 'range': {'start': {'character': 58, 'line': 4}, 'end': {'character': 0, 'line': 5}}, 'rangeLength': 1}], 'textDocument': {'uri': 'file:///var/home/username/Projects/lsp-devtools/docs/index.rst', 'version': 4}}} +{'jsonrpc': '2.0', 'id': 2, 'method': 'shutdown'} +{'id': 2, 'jsonrpc': '2.0', 'result': None} +{'jsonrpc': '2.0', 'method': 'exit'} diff --git a/docs/lsp-devtools/guide/getting-started.rst b/docs/lsp-devtools/guide/getting-started.rst new file mode 100644 index 0000000..6e8aace --- /dev/null +++ b/docs/lsp-devtools/guide/getting-started.rst @@ -0,0 +1,105 @@ +Getting Started +=============== + +.. highlight:: none + +This guide will introduce you to the tools available in the ``lsp-devtools`` package. +If you have not done so already, you can install it using ``pipx`` :: + + pipx install lsp-devtools + +.. admonition:: Did you say pipx? + + `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`` + +The LSP Agent +------------- + +In order to use most of the tools in ``lsp-devtools`` you need to wrap your language server with the LSP Agent. +The agent is a simple program that sits inbetween a language client and the server as shown in the diagram below. + +.. figure:: /images/lsp-devtools-architecture.svg + + ``lsp-devtools`` architecture + +The agent acts as a messenger, forwarding messages from the client to the server and vice versa. +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``. + +In general, using ``lsp-devtools`` can be broken down into a 3 step process. + +#. Configure your language client to launch your language server via the agent, rather than launching it directly. + +#. Start the server application e.g. ``lsp-devtools record`` or ``lsp-devtools tui`` + +#. Start your language client. + +.. _lsp-devtools-configure-client: + +Configuring your client +^^^^^^^^^^^^^^^^^^^^^^^ + +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:: + + lsp-devtools agent -- + +The ``agent`` command will interpret anything given after the double dashes (``--``) to be the command used to invoke your language server. +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:: + + lsp-devtools agent --host 127.0.0.1 --port 1234 -- + +.. tip:: + + Since the agent only requires your server's start command, you can use ``lsp-devtools`` with a server written in any language. + + +As an example, let's configure Neovim to launch the ``esbonio`` language server via the agent. +Using `nvim-lspconfig `_ a standard configuration might look something like the following + +.. code-block:: lua + + lspconfig.esbonio.setup{ + capabilities = capabilities, + cmd = { "esbonio" }, + filetypes = {"rst"}, + init_options = { + server = { + logLevel = "debug" + }, + sphinx = { + buildDir = "${confDir}/_build" + } + }, + on_attach = on_attach, + } + +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 --`` + +.. code-block:: diff + + lspconfig.esbonio.setup{ + capabilities = capabilities, + - cmd = { "esbonio" }, + + cmd = { "lsp-devtools", "agent", "--", "esbonio" }, + ... + } + +Server Applications +------------------- + +Once you have your client configured, you need to start the application the agent is going to try to connect to. +Currently ``lsp-devtools`` provides the following applications + +``lsp-devtools record`` + 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. + However, it can also print these messages direct to the console with support for filtering and custom formatting of message contents. + + .. figure:: /images/record-example.svg + + See :doc:`/lsp-devtools/guide/record-command` for details + +``lsp-devtools tui`` + An interactive terminal application, powered by `textual `_. + + .. figure:: /images/tui-screenshot.svg + + See :doc:`/lsp-devtools/guide/tui-command` for details diff --git a/docs/lsp-devtools/guide/record-command.rst b/docs/lsp-devtools/guide/record-command.rst new file mode 100644 index 0000000..b195ced --- /dev/null +++ b/docs/lsp-devtools/guide/record-command.rst @@ -0,0 +1,315 @@ +Recording Sessions +================== + +.. important:: + + This guide assumes that you have already :ref:`configured your client ` to wrap your language server with the LSP Agent. + +.. highlight:: none + +.. program:: lsp-devtools record + +The ``lsp-devtools record`` command can be used to either record an LSP session to a file, SQLite database or print the received messages direct to the console. +Running the ``lsp-devtools record`` command you should see a message like the following:: + + $ lsp-devtools record + Waiting for connection on localhost:8765... + +once the agent connects, the record command will by default, start printing all LSP messages to the console, with the JSON contents pretty printed. + +.. figure:: /images/record-example.svg + + +Example Commands +---------------- + +Here are some example usages of the ``record`` command that you may find useful. + +**Capture the client's capabilities** + +The following command will only capture and show the ``ClientCapabilities`` sent during the ``initialize`` request - useful for :ref:`adding clients to pytest-lsp `! 😉 + +:: + + lsp-devtools record -f "{.params.clientInfo.name} v{.params.clientInfo.version}\\n{.params.capabilities}" + +.. figure:: /images/record-client-capabilities.svg + :figclass: scrollable-svg + + +**Format and show any window/logMessages** + +This can be used to replicate the ``Output`` log panel in VSCode in editors that do not provide a similar facility. + +:: + + lsp-devtools record -f "{.params.type|MessageType}: {.params.message}" + +.. figure:: /images/record-log-messages.svg + :figclass: scrollable-svg + +Read on for a comprehensive overview of all the available command line options. + +Connection Options +------------------ + +By default, the LSP agent and other commands will attempt to connect to each other on ``localhost:8765``. +The following options can be used to change this behavior + +.. option:: --host + + The host to bind to. + +.. option:: -p , --port + + The port number to open the connection on. + + +Alternate Destinations +---------------------- + +As well as printing to console, the record command supports a number of other output destinations. + +.. option:: --to-file + + Saves all collected messages to a plain text file with each line representing a complete JSON-RPC message:: + + lsp-devtools record --to-file example.json + + See :download:`here <./example-to-file-output.json>` for example of the output produced by this command. + +.. option:: --to-sqlite + + Save messages to a SQLite database:: + + lsp-devtools record --to-sqlite example.db + + This database can then be opened in other tools like `datasette `_, `SQLite Browser `_ or even ``lsp-devtools`` own :doc:`/lsp-devtools/guide/tui-command`. + + .. dropdown:: DB Schema + + Here is the schema currently used by ``lsp-devtools``. + **Note:** Except perhaps the base ``protocol`` table, this schema is not stable and may change between ``lsp-devtools`` releases. + + .. literalinclude:: ../../../lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql + :language: sql + +.. option:: --save-output + + Print to console as normal but additionally, the ouput will be saved into a text file using the + `export `__ + feature of rich's ``Console`` object:: + + lsp-devtools record --save-output filename.{html,svg,txt} + + Depending on the file extension used, this will save the output as plain text or rendered as an SVG image or HTML webpage - useful for generating screenshots for your documentation! + +Filtering Messages +------------------ + +Once it gets going, the LSP protocol can generate *a lot* of messages! +To help you focus on the messages you are interested in the ``record`` command provides the following options for selecting a subset of messages to show. + +.. option:: --message-source + + The following values are accepted + + ``client`` + Only show messages sent from the client + + ``server`` + Only show messages sent from the server + + ``both`` (the default) + Show message sent from both client and server + +.. option:: --include-message-type + + Only show messages of the given type. + This option can be used more than once to select multiple message types. + The following values are accepted + + ``request`` + Show only JSON-RPC request messages + + ``response`` + Show only JSON-RPC response messages, matches responses containing either successful results or error codes. + + ``result`` + Show only JSON-RPC response messages containing successful results + + ``error`` + Show only JSON-RPC response messages that contain errors. + + ``notification`` + Show only JSON-RPC notification messages + +.. option:: --include-method + + Only show messages with the given method name. + This option can be used more than once to select multiple methods. + +.. option:: --exclude-message-type + + Like :option:`--include-message-type`, but omit matches rather than showing them + +.. option:: --exclude-method + + Like :option:`--include-method`, but omit matches rather than showing them + +If multiple options from this list are used, they will be ANDed together, for example:: + + lsp-devtools record --message-source client \ + --include-message-type request \ + --include-message-type notification + +will only show requests or notifications that have been sent by the client. + +Formatting messages +------------------- + +.. note:: + + These options do not apply when using the :option:`--to-sqlite` option. + + +.. option:: -f , --format-message + + Set the format string to use when formatting messages. + By default, the ``record`` command will simply print the JSON contents of a message however, you can supply a custom format string to use instead. + + .. tip:: + + Format strings are also a powerful filtering mechanism! - any messages that do not fit with the supplied format will not be shown + + Format strings use the following syntax + + .. admonition:: Feedback Wanted! + + We're looking for feedback on this syntax, especially when it comes to formatting lists of items. + Let us know by `opening an issue `_ if you have any thoughts or suggested improvements + + + Similar to Python's :ref:`python:formatstrings` a pair of braces (``{}``) denote a placeholder where a value can be inserted. + Inside the braces you can then select and the message field you want to be inserted using a dot-separated syntax that should feel familiar if you've ever used `jq `_:: + + Message: + { + "method": "textDocument/completion", + "params": { + "position": {"line": 1, "character": 2}, + "textDocument": {"uri": "file:///path/to/file.txt"}, + } + } + + Format String: + "{.params.position.line}:{.params.position.character}" + + Result: + 1:2 + + The pipe symbol (``|``) can be used to pass the selected field to a formatter e.g. ``Position``:: + + Message: + { + "method": "textDocument/completion", + "params": { + "position": {"line": 1, "character": 2}, + "textDocument": {"uri": "file:///path/to/file.txt"}, + } + } + + Format String: + "{.params.position|Position}" + + Result: + 1:2 + + See :ref:`lsp-devtools-record-formatters` for details on all available formatters. + Fields that contain an array of items can be accessed with square brackets (``[]``), by default items in an array will be separated by newlines when formatted:: + + Message: + { + "result": { + "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] + } + } + + Format String: + "{.result.items[].label}" + + Result: + one + two + three + + However, you can specify a custom separator inside the brackets:: + + Message: + { + "result": { + "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] + } + } + + Format String: + "{.result.items[\n- ].label}" + + Result: + - one + - two + - three + + The brackets also support Python's standard list indexing rules:: + + Message: + { + "result": { + "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] + } + } + + Format String: Result: + "{.result.items[0].label}" one + "{.result.items[-1].label}" three + "{.result.items[0:2].label}" "one\ntwo" + + Finally, if you want to supply an index *and* adjust the separator you can separate them with the ``#`` symbol:: + + Message: + { + "result": { + "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] + } + } + + Format String: + "{.result.items[0:2#\n- ].label}" + + Result: + - one + - two + +.. _lsp-devtools-record-formatters: + +Formatters +^^^^^^^^^^ + +``lsp-devtools`` knows how to format the following LSP Types + +``Position`` + ``{"line": 1, "character": 2}`` will be rendered as ``1:2`` + +``Range`` + ``{"start": {"line": 1, "character": 2}, "end": {"line": 3, "character": 4}}`` will be rendered as ``1:2-3:4`` + +Additionally, any enum type can be used as a formatter in which case a number will be replaced with the corresponding name, for example:: + + Format String: + "{.type|MessageType}" + + Value: Result: + 1 Error + 2 Warning + 3 Info + 4 Log diff --git a/docs/lsp-devtools/guide/tui-command.rst b/docs/lsp-devtools/guide/tui-command.rst new file mode 100644 index 0000000..b482117 --- /dev/null +++ b/docs/lsp-devtools/guide/tui-command.rst @@ -0,0 +1,2 @@ +TUI Application +=============== diff --git a/docs/pytest-lsp/guide.rst b/docs/pytest-lsp/guide.rst index bf421e3..b8b4f4d 100644 --- a/docs/pytest-lsp/guide.rst +++ b/docs/pytest-lsp/guide.rst @@ -8,3 +8,5 @@ User Guide guide/language-client guide/client-capabilities guide/fixtures + guide/troubleshooting + guide/testing-json-rpc-servers diff --git a/docs/pytest-lsp/guide/client-capabilities.rst b/docs/pytest-lsp/guide/client-capabilities.rst index b11dd9f..301144a 100644 --- a/docs/pytest-lsp/guide/client-capabilities.rst +++ b/docs/pytest-lsp/guide/client-capabilities.rst @@ -10,6 +10,8 @@ field which is used to inform the server which parts of the specification the cl 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. +.. _pytest-lsp-supported-clients: + Supported Clients ----------------- diff --git a/docs/pytest-lsp/guide/fixtures.rst b/docs/pytest-lsp/guide/fixtures.rst index 07b80f5..3e5cfd0 100644 --- a/docs/pytest-lsp/guide/fixtures.rst +++ b/docs/pytest-lsp/guide/fixtures.rst @@ -3,36 +3,6 @@ Fixtures .. highlight:: none -Fixture Scope -------------- - -Setting your client `fixture's scope `__ to something like ``session`` will allow you to reuse the same client-server connection across multiple test cases. -However, you're likely to encounter an error like the following:: - - __________________________ ERROR at setup of test_capabilities _________________________ - ScopeMismatch: You tried to access the function scoped fixture event_loop with a session - scoped request object, involved factories: - /.../site-packages/pytest_lsp/plugin.py:201: def the_fixture(request) - - -This is due to the default `event_loop `__ fixture provided by `pytest-asyncio`_ not living long enough to support your client. -To fix this you can override the ``event_loop`` fixture, setting its scope to match that of your client. - -.. code-block:: python - - @pytest.fixture(scope="session") - def event_loop(): - """Redefine `pytest-asyncio's default event_loop fixture to match the scope - of our client fixture.""" - policy = asyncio.get_event_loop_policy() - loop = policy.new_event_loop() - yield loop - loop.close() - - -.. _pytest-asyncio: https://github.com/pytest-dev/pytest-asyncio - - Parameterised Fixtures ---------------------- @@ -43,3 +13,14 @@ This can be used to run the same set of tests while pretending to be a different :language: python :start-at: @pytest_lsp.fixture :end-at: await lsp_client.shutdown_session() + + +Requesting Other Fixtures +------------------------- + +As you would expect, it's possible to request other fixtures to help set up your client. + +.. literalinclude:: ../../../lib/pytest-lsp/tests/examples/fixture-passthrough/t_server.py + :language: python + :start-at: @pytest.fixture + :end-at: await lsp_client.shutdown_session() diff --git a/docs/pytest-lsp/guide/getting-started-fail-output.txt b/docs/pytest-lsp/guide/getting-started-fail-output.txt index cd42432..3cfde65 100644 --- a/docs/pytest-lsp/guide/getting-started-fail-output.txt +++ b/docs/pytest-lsp/guide/getting-started-fail-output.txt @@ -1,17 +1,17 @@ $ pytest -================================================= test session starts ================================================== +================================================ test session starts ================================================ platform linux -- Python 3.11.3, pytest-7.2.0, pluggy-1.0.0 -rootdir: /tmp/pytest-of-alex/pytest-2/test_getting_started_fail0, configfile: tox.ini -plugins: typeguard-2.13.3, asyncio-0.20.2, lsp-0.2.1 +rootdir: /tmp/pytest-of-alex/pytest-38/test_getting_started_fail0, configfile: tox.ini +plugins: asyncio-0.21.0, typeguard-3.0.2, lsp-0.3.0 asyncio: mode=Mode.AUTO collected 1 item -test_server.py E [100%] +test_server.py E [100%] -======================================================== ERRORS ======================================================== -__________________________________________ ERROR at setup of test_completions __________________________________________ +====================================================== ERRORS ======================================================= +________________________________________ ERROR at setup of test_completions _________________________________________ -lsp_client = +lsp_client = @pytest_lsp.fixture( config=ClientServerConfig(server_command=[sys.executable, "server.py"]), @@ -22,33 +22,24 @@ lsp_client = > await lsp_client.initialize_session(params) test_server.py:21: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pytest_lsp/client.py:171: in initialize_session +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pytest_lsp/client.py:137: in initialize_sess +ion response = await self.initialize_async(params) -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -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) - - async def initialize_async( - self, - params: InitializeParams, - ) -> InitializeResult: - """Make a ``initialize`` request. - - The initialize request is sent from the client to the server. - - It is sent once as the request after starting up the server. The - requests parameter is of type {@link InitializeParams} the response - if of type {@link InitializeResult} of a Thenable that resolves to - such. - """ -> return await self.lsp.send_request_async("initialize", params) -E asyncio.exceptions.CancelledError: RuntimeError: Server exited with return code: 0 -E -E NoneType: None - -/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pytest_lsp/gen.py:307: CancelledError -=============================================== short test summary info ================================================ -ERROR test_server.py::test_completions - asyncio.exceptions.CancelledError: RuntimeError: Server exited with return c... -=================================================== 1 error in 1.11s =================================================== +/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pygls/lsp/client.py:349: in initialize_async + return await self.protocol.send_request_async("initialize", params) +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = , method = 'initialize' +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, workspac +e_folders=None) + + async def send_request_async(self, method, params=None): +> result = await super().send_request_async(method, params) +E asyncio.exceptions.CancelledError: Server process exited with return code: 0 + +/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pytest_lsp/protocol.py:42: CancelledError +============================================== short test summary info ============================================== +ERROR test_server.py::test_completions - asyncio.exceptions.CancelledError: Server process exited with return code: 0 +================================================= 1 error in 1.15s ================================================== diff --git a/docs/pytest-lsp/guide/getting-started.rst b/docs/pytest-lsp/guide/getting-started.rst index 02fcf1b..d8a92c4 100644 --- a/docs/pytest-lsp/guide/getting-started.rst +++ b/docs/pytest-lsp/guide/getting-started.rst @@ -1,8 +1,14 @@ Getting Started =============== +.. highlight:: none + This guide will walk you through the process of writing your first test case using ``pytest-lsp``. +If you have not done so already, you can install the ``pytest-lsp`` package using pip:: + + pip install pytest-lsp + A Simple Language Server ------------------------ @@ -37,12 +43,11 @@ With the framework in place, we can go ahead and define our first test case .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/getting-started/t_server.py :language: python - :start-at: async def test_ + :start-at: @pytest.mark.asyncio All that's left is to run the test suite! .. literalinclude:: ./getting-started-fail-output.txt - :language: none We forgot to start the server! Add the following to the bottom of ``server.py``. diff --git a/docs/pytest-lsp/guide/language-client.rst b/docs/pytest-lsp/guide/language-client.rst index 510b127..1c35ee7 100644 --- a/docs/pytest-lsp/guide/language-client.rst +++ b/docs/pytest-lsp/guide/language-client.rst @@ -14,7 +14,7 @@ The client maintains a record of any :attr:`~pytest_lsp.LanguageClient.diagnosti .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/diagnostics/t_server.py :language: python - :start-at: async def test_ + :start-at: @pytest.mark.asyncio .. note:: @@ -40,7 +40,7 @@ Any :lsp:`window/logMessage` notifications sent from the server will be accessib .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/window-log-message/t_server.py :language: python - :start-at: async def test_ + :start-at: @pytest.mark.asyncio .. card:: server.py @@ -49,39 +49,9 @@ Any :lsp:`window/logMessage` notifications sent from the server will be accessib :start-at: @server.feature :end-at: return items -If a test case fails ``pytest-lsp`` will also include any captured log messages in the error report:: - - ================================== test session starts ==================================== - platform linux -- Python 3.11.2, pytest-7.2.0, pluggy-1.0.0 - rootdir: /..., configfile: tox.ini - plugins: typeguard-2.13.3, asyncio-0.20.2, lsp-0.2.1 - asyncio: mode=Mode.AUTO - collected 1 item - - test_server.py F [100%] - - ======================================== FAILURES ========================================= - ____________________________________ test_completions _____________________________________ - - client = - ... - E assert False - - test_server.py:35: AssertionError - ---------------------------- Captured window/logMessages call ----------------------------- - LOG: Suggesting item 0 - LOG: Suggesting item 1 - LOG: Suggesting item 2 - LOG: Suggesting item 3 - LOG: Suggesting item 4 - LOG: Suggesting item 5 - LOG: Suggesting item 6 - LOG: Suggesting item 7 - LOG: Suggesting item 8 - LOG: Suggesting item 9 - ================================ short test summary info ================================== - FAILED test_server.py::test_completions - assert False - =================================== 1 failed in 1.02s ===================================== +If a test case fails ``pytest-lsp`` will also include any captured log messages in the error report + +.. literalinclude:: ./window-log-message-output.txt ``window/showDocument`` ----------------------- @@ -92,7 +62,7 @@ Similar to ``window/logMessage`` above, the client records any :lsp:`window/show .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/window-show-document/t_server.py :language: python - :start-at: async def test_ + :start-at: @pytest.mark.asyncio .. card:: server.py @@ -111,7 +81,7 @@ Similar to ``window/logMessage`` above, the client records any :lsp:`window/show .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/window-show-message/t_server.py :language: python - :start-at: async def test_ + :start-at: @pytest.mark.asyncio .. card:: server.py diff --git a/docs/pytest-lsp/guide/testing-json-rpc-servers.rst b/docs/pytest-lsp/guide/testing-json-rpc-servers.rst new file mode 100644 index 0000000..1005c59 --- /dev/null +++ b/docs/pytest-lsp/guide/testing-json-rpc-servers.rst @@ -0,0 +1,60 @@ +Testing JSON-RPC Servers +======================== + +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. + +A Simple JSON-RPC Server +------------------------ + +As an example we'll reuse some of the `pygls`_ internals to write a simple JSON-RPC server that implements the following protocol. + +- client to server request ``math/add``, returns the sum of two numbers ``a`` and ``b`` +- client to server request ``math/sub``, returns the difference of two numbers ``a`` and ``b`` +- server to client notification ``log/message``, allows the server to send debug messages to the client. + +.. note:: + + 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. + + Remember you can write your servers in whatever language/framework you prefer! + +.. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/server.py + :language: python + +Constructing a Client +--------------------- + +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. +This is done with a factory function + +.. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/t_server.py + :language: python + :start-at: def client_factory(): + :end-at: return client + +The Client Fixture +------------------ + +Once you have your factory function defined you can pass it to the :class:`~pytest_lsp.ClientServerConfig` when defining your client fixture + +.. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/t_server.py + :language: python + :start-at: @pytest_lsp.fixture( + :end-at: # Teardown code + +Writing Test Cases +------------------ + +With the client fixuture defined, test cases are written almost identically as they would be for your LSP servers. +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. + +.. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/t_server.py + :language: python + :start-at: @pytest.mark.asyncio + +However, it is also possible to extend the base :class:`~pygls:pygls.client.JsonRPCClient` to provide a higher level interface to your server. +See the `SubprocessSphinxClient`_ from the `esbonio`_ project for such an example. + +.. _esbonio: https://github.com/swyddfa/esbonio +.. _pygls: https://github.com/openlawlibrary/pygls +.. _SubprocessSphinxClient: https://github.com/swyddfa/esbonio/blob/develop/lib/esbonio/esbonio/server/features/sphinx_manager/client_subprocess.py diff --git a/docs/pytest-lsp/guide/troubleshooting.rst b/docs/pytest-lsp/guide/troubleshooting.rst new file mode 100644 index 0000000..bd93473 --- /dev/null +++ b/docs/pytest-lsp/guide/troubleshooting.rst @@ -0,0 +1,118 @@ +Troubleshooting +=============== + +My tests won't run! +------------------- + +You may encounter an issue where some of your test cases that use ``pytest-lsp`` are unexpectedly skipped. + +.. code-block:: none + + ================================ test session starts ================================= + platform linux -- Python 3.10.6, pytest-7.3.2, pluggy-1.1.0 + rootdir: /home/username/projects/lsp/pytest-lsp + plugins: lsp-0.3.0, typeguard-3.0.2, asyncio-0.21.0 + asyncio: mode=strict + collected 1 item + + test_server.py s [100%] + + ================================== warnings summary ================================== + test_server.py::test_completions + /home/username/projects/lsp/pytest-lsp/venv/lib/python3.10/site-packages/_pytest/python.py:183: PytestUnhandledCoroutineWarning: async def functions are not natively supported and have been skipped. + You need to install a suitable plugin for your async framework, for example: + - anyio + - pytest-asyncio + - pytest-tornasync + - pytest-trio + - pytest-twisted + warnings.warn(PytestUnhandledCoroutineWarning(msg.format(nodeid))) + + =========================== 1 skipped, 1 warning in 0.64s ============================ + +It's likely that you forgot to add a ``@pytest.mark.asyncio`` marker to your test function(s) + +.. code-block:: python + + import pytest + + @pytest.mark.asyncio + async def test_server(client: LanguageClient): + ... + +Alternatively, if you prefer, you can set the following configuration option in your project's ``pyproject.toml`` + +.. code-block:: toml + + [tool.pytest.ini_options] + asyncio_mode = "auto" + +In which case `pytest-asyncio`_ will automatically collect and run any ``async`` test function in your test suite. + +``ScopeMismatch`` Error +----------------------- + +Setting your client `fixture's scope `__ to something like ``session`` will allow you to reuse the same client-server connection across multiple test cases. +However, you're likely to encounter an error like the following:: + + __________________________ ERROR at setup of test_capabilities _________________________ + ScopeMismatch: You tried to access the function scoped fixture event_loop with a session + scoped request object, involved factories: + /.../site-packages/pytest_lsp/plugin.py:201: def the_fixture(request) + + +This is due to the default `event_loop `__ fixture provided by `pytest-asyncio`_ not living long enough to support your client. +To fix this you can override the ``event_loop`` fixture, setting its scope to match that of your client. + +.. code-block:: python + + @pytest.fixture(scope="session") + def event_loop(): + """Redefine `pytest-asyncio's default event_loop fixture to match the scope + of our client fixture.""" + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + yield loop + loop.close() + + +.. _pytest-asyncio: https://github.com/pytest-dev/pytest-asyncio + +``DeprecationWarning``: Unclosed event loop +------------------------------------------- + +Depending on the version of ``pygls`` (the LSP implementation used by ``pytest-lsp``) you have installed, you may encounter a ``DeprecationWarning`` abount an unclosed event loop. + +.. code-block:: none + + ================================ test session starts ================================= + platform linux -- Python 3.10.6, pytest-7.3.2, pluggy-1.1.0 + rootdir: /home/username/projects/lsp/pytest-lsp + plugins: lsp-0.3.0, typeguard-3.0.2, asyncio-0.21.0 + asyncio: mode=strict + collected 1 item + + test_server.py . [100%] + + ================================== warnings summary ================================== + test_server.py::test_completions + /home/username/projects/lsp/pytest-lsp/venv/lib/python3.10/site-packages/pytest_asyncio/plugin.py:444: DeprecationWarning: pytest-asyncio detected an unclosed event loop when tearing down the event_loop + fixture: <_UnixSelectorEventLoop running=False closed=False debug=False> + pytest-asyncio will close the event loop for you, but future versions of the + library will no longer do so. In order to ensure compatibility with future + versions, please make sure that: + 1. Any custom "event_loop" fixture properly closes the loop after yielding it + 5. Your code does not modify the event loop in async fixtures or tests + + warnings.warn( + + -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html + =========================== 1 passed, 1 warning in 0.64s ============================= + +This is a known issue in ``pygls v1.0.2`` and older, upgrading your ``pygls`` version to ``1.1.0`` or newer should resolve the issue. + +.. note:: + + While this issue has been `fixed `_ upstream, it is not yet generally available. + However, the warning itself is fairly mild - ``pytest-lsp``/``pygls`` are not cleaning the event loop up correctly but are otherwise working as expected. + It should be safe to ignore this while waiting for the fix to become available. diff --git a/docs/pytest-lsp/guide/window-log-message-output.txt b/docs/pytest-lsp/guide/window-log-message-output.txt new file mode 100644 index 0000000..bd7a0d4 --- /dev/null +++ b/docs/pytest-lsp/guide/window-log-message-output.txt @@ -0,0 +1,31 @@ +================================== test session starts ==================================== +platform linux -- Python 3.11.2, pytest-7.2.0, pluggy-1.0.0 +rootdir: /..., configfile: tox.ini +plugins: typeguard-2.13.3, asyncio-0.20.2, lsp-0.2.1 +asyncio: mode=Mode.AUTO +collected 1 item + +test_server.py F [100%] + +======================================== FAILURES ========================================= +____________________________________ test_completions _____________________________________ + +client = + ... +E assert False + +test_server.py:35: AssertionError +---------------------------- Captured window/logMessages call ----------------------------- + LOG: Suggesting item 0 + LOG: Suggesting item 1 + LOG: Suggesting item 2 + LOG: Suggesting item 3 + LOG: Suggesting item 4 + LOG: Suggesting item 5 + LOG: Suggesting item 6 + LOG: Suggesting item 7 + LOG: Suggesting item 8 + LOG: Suggesting item 9 +================================ short test summary info ================================== +FAILED test_server.py::test_completions - assert False +=================================== 1 failed in 1.02s ===================================== diff --git a/docs/pytest-lsp/reference.rst b/docs/pytest-lsp/reference.rst index 8768a30..d6daa55 100644 --- a/docs/pytest-lsp/reference.rst +++ b/docs/pytest-lsp/reference.rst @@ -8,7 +8,7 @@ LanguageClient .. autoclass:: LanguageClient :members: - :inherited-members: + :show-inheritance: Test Setup @@ -21,12 +21,8 @@ Test Setup .. autoclass:: ClientServerConfig :members: -.. autofunction:: make_client_server +.. autofunction:: make_test_lsp_client -.. autofunction:: make_test_client - -.. autoclass:: ClientServer - :members: Checks ------ diff --git a/docs/requirements.txt b/docs/requirements.txt index 92e6e63..8476ae3 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,8 @@ # This assumes you are running the pip install command from the root of the repo e.g. # $ pip install -r docs/requirements.txt sphinx +sphinx-copybutton sphinx-design furo +pygls>=1.1.0 -e lib/pytest-lsp diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..8d0a4ab --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1688585123, + "narHash": "sha256-+xFOB4WaRUHuZI7H1tWHTrwY4BnbPmh8M1n/XhPRH0w=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "23de9f3b56e72632c628d92b71c47032e14a3d4d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1687709756, + "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1c335ce --- /dev/null +++ b/flake.nix @@ -0,0 +1,13 @@ +{ + description = "Developer tooling for language servers"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, utils }: + { + overlays.default = import ./lib/pytest-lsp/nix/pytest-lsp-overlay.nix; + }; +} diff --git a/lib/lsp-devtools/changes/17.fix.rst b/lib/lsp-devtools/changes/17.fix.rst new file mode 100644 index 0000000..af9cc8e --- /dev/null +++ b/lib/lsp-devtools/changes/17.fix.rst @@ -0,0 +1 @@ +The ``lsp-devtools agent`` command no longer fails to exit once an LSP session closes. diff --git a/lib/lsp-devtools/changes/28.fix.rst b/lib/lsp-devtools/changes/28.fix.rst new file mode 100644 index 0000000..d4884d8 --- /dev/null +++ b/lib/lsp-devtools/changes/28.fix.rst @@ -0,0 +1 @@ +``lsp-devtools record`` no longer emits a ``ResourceWarning`` diff --git a/lib/lsp-devtools/changes/29.fix.rst b/lib/lsp-devtools/changes/29.fix.rst new file mode 100644 index 0000000..bef6b1e --- /dev/null +++ b/lib/lsp-devtools/changes/29.fix.rst @@ -0,0 +1 @@ +As a consequence of the new architecture, commands like ``lsp-devtools record`` no longer miss the start of an LSP session diff --git a/lib/lsp-devtools/changes/37.misc.rst b/lib/lsp-devtools/changes/37.misc.rst new file mode 100644 index 0000000..a42dd18 --- /dev/null +++ b/lib/lsp-devtools/changes/37.misc.rst @@ -0,0 +1 @@ +The ``lsp-devtools agent`` now uses a TCP connection, which should make distribution easier diff --git a/lib/lsp-devtools/changes/38.fix.rst b/lib/lsp-devtools/changes/38.fix.rst new file mode 100644 index 0000000..eb9bd1f --- /dev/null +++ b/lib/lsp-devtools/changes/38.fix.rst @@ -0,0 +1 @@ +``lsp-devtools agent`` no longer emits ``Unable to send data, no available transport!`` messages diff --git a/lib/lsp-devtools/changes/77.enhancement.rst b/lib/lsp-devtools/changes/77.enhancement.rst new file mode 100644 index 0000000..3439059 --- /dev/null +++ b/lib/lsp-devtools/changes/77.enhancement.rst @@ -0,0 +1 @@ +If the agent is unable to connect to a server app immediately, it will now retry indefinitely until it succeeds or the language server exits diff --git a/lib/lsp-devtools/changes/77.misc.rst b/lib/lsp-devtools/changes/77.misc.rst new file mode 100644 index 0000000..aa98fdb --- /dev/null +++ b/lib/lsp-devtools/changes/77.misc.rst @@ -0,0 +1 @@ +Drop Python 3.7 support diff --git a/lib/lsp-devtools/changes/83.feature.rst b/lib/lsp-devtools/changes/83.feature.rst new file mode 100644 index 0000000..a755fc1 --- /dev/null +++ b/lib/lsp-devtools/changes/83.feature.rst @@ -0,0 +1 @@ +**Experimental** Add proof of concept ``lsp-devtools client`` command that builds on textual's ``TextArea`` widget to offer an interactive language server client. diff --git a/lib/lsp-devtools/changes/83.misc.rst b/lib/lsp-devtools/changes/83.misc.rst new file mode 100644 index 0000000..864e34e --- /dev/null +++ b/lib/lsp-devtools/changes/83.misc.rst @@ -0,0 +1,3 @@ +The ``lsp-devtools capabilities`` command has been removed in favour of ``lsp-devtools record`` + +The ``lsp-devtools tui`` command has been renamed to ``lsp-devtools inspect`` diff --git a/lib/lsp-devtools/lsp_devtools/agent/__init__.py b/lib/lsp-devtools/lsp_devtools/agent/__init__.py index c1d4165..a6bccaa 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/agent/__init__.py @@ -3,16 +3,16 @@ import logging import subprocess import sys -import threading from typing import List +from uuid import uuid4 from .agent import Agent from .agent import logger from .client import AgentClient -from .client import parse_rpc_message from .protocol import MESSAGE_TEXT_NOTIFICATION from .protocol import MessageText from .server import AgentServer +from .server import parse_rpc_message __all__ = [ "Agent", @@ -25,47 +25,66 @@ ] -class WSHandler(logging.Handler): - """Logging handler that forwards captured LSP messages through to the web socket - client.""" +class MessageHandler(logging.Handler): + """Logging handler that forwards captured JSON-RPC messages through to the + ``AgentServer`` instance.""" - def __init__(self, server: AgentServer, *args, **kwargs): + def __init__(self, client: AgentClient, *args, **kwargs): super().__init__(*args, **kwargs) - self.server = server + self.client = client + self.session = str(uuid4()) + self._buffer: List[MessageText] = [] def emit(self, record: logging.LogRecord): message = MessageText( text=record.args[0], # type: ignore + session=self.session, + timestamp=record.created, source=record.__dict__["source"], ) - self.server.lsp.message_text_notification(message) + if not self.client.connected: + self._buffer.append(message) + return -def run_agent(args, extra: List[str]): + # Send any buffered messages + while len(self._buffer) > 0: + self.client.protocol.message_text_notification(self._buffer.pop(0)) + + self.client.protocol.message_text_notification(message) + + +async def main(args, extra: List[str]): if extra is None: print("Missing server start command", file=sys.stderr) return 1 - process = subprocess.Popen(extra, stdin=subprocess.PIPE, stdout=subprocess.PIPE) - agent = Agent(process, sys.stdin.buffer, sys.stdout.buffer) + command, *arguments = extra - server = AgentServer() - handler = WSHandler(server) + server = await asyncio.create_subprocess_exec( + command, + *arguments, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + agent = Agent(server, sys.stdin.buffer, sys.stdout.buffer) + + client = AgentClient() + handler = MessageHandler(client) handler.setLevel(logging.INFO) logger.setLevel(logging.INFO) logger.addHandler(handler) - agent_thread = threading.Thread( - target=asyncio.run, - args=(agent.start(),), + await asyncio.gather( + client.start_tcp(args.host, args.port), + agent.start(), ) - agent_thread.start() - try: - server.start_ws(args.host, args.port) - finally: - agent.stop() + +def run_agent(args, extra: List[str]): + asyncio.run(main(args, extra)) def cli(commands: argparse._SubParsersAction): @@ -74,31 +93,32 @@ def cli(commands: argparse._SubParsersAction): help="instrument an LSP session", formatter_class=argparse.RawDescriptionHelpFormatter, description="""\ -This command runs the given language server as a subprocess, wrapping it in a -websocket server allowing all traffic to be inspected by some client. +This command runs the given JSON-RPC server as a subprocess, wrapping it in a +an "AgentClient" which will capture all messages sent to/from the wrapped +server, forwarding them onto an "AgentServer" to be processed. To wrap a server, supply its start command after all other agent options and preceeded by a `--`, for example: lsp-devtools agent -p 1234 -- python -m esbonio -Wrapping a language server with this command is required to enable the +Wrapping a JSON-RPC server with this command is required to enable the majority of the lsp-devtools suite of tools. - ┌─ LSP Client ─┐ ┌─────── Agent ────────┐ ┌─ LSP Server ─┐ + ┌─ RPC Client ─┐ ┌──── Agent Client ────┐ ┌─ RPC Server ─┐ │ │ │ ┌──────────────┐ │ │ │ │ stdout│─────│───│ │───│────│stdin │ - │ │ │ │ Agent Server │ │ │ │ + │ │ │ │ Agent │ │ │ │ │ stdin│─────│───│ │───│────│stdout │ │ │ │ └──────────────┘ │ │ │ - │ │ │ │ │ │ │ - └──────────────┘ └──────────┴───────────┘ └──────────────┘ + │ │ │ │ │ │ + └──────────────┘ └──────────────────────┘ └──────────────┘ │ - │ web socket + │ tcp/websocket │ ┌──────────────┐ │ │ - │ Agent Client │ + │ Agent Server │ │ │ └──────────────┘ @@ -107,13 +127,13 @@ def cli(commands: argparse._SubParsersAction): cmd.add_argument( "--host", - help="the host to run the websocket server on.", + help="the host to connect to.", default="localhost", ) cmd.add_argument( "-p", "--port", - help="the port to run the websocket server on", + help="the port to connect to", default=8765, ) diff --git a/lib/lsp-devtools/lsp_devtools/agent/agent.py b/lib/lsp-devtools/lsp_devtools/agent/agent.py index 982f4b8..8cb1f90 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/agent.py +++ b/lib/lsp-devtools/lsp_devtools/agent/agent.py @@ -1,20 +1,18 @@ import asyncio +import inspect import logging -import subprocess +import re import threading -from concurrent.futures import ThreadPoolExecutor from functools import partial from typing import BinaryIO -from pygls.server import aio_readline - logger = logging.getLogger("lsp_devtools.agent") -def forward_message(source: str, dest: BinaryIO, message: bytes): +async def forward_message(source: str, dest: asyncio.StreamWriter, message: bytes): """Forward the given message to the destination channel""" dest.write(message) - dest.flush() + await dest.drain() # Log the full message logger.info( @@ -24,81 +22,103 @@ def forward_message(source: str, dest: BinaryIO, message: bytes): ) -async def check_server_process( - server_process: subprocess.Popen, stop_event: threading.Event -): - """Ensure that the server process is still alive.""" - - while not stop_event.is_set(): - retcode = server_process.poll() - print(".") - if retcode is not None: - # Cancel any pending tasks. - for task in asyncio.all_tasks(): - task.cancel(f"Server process exited with code: {retcode}") +# TODO: Upstream this? +async def aio_readline(stop_event, reader, message_handler): + CONTENT_LENGTH_PATTERN = re.compile(rb"^Content-Length: (\d+)\r\n$") - # Signal everything to stop. - stop_event.set() + # Initialize message buffer + message = [] + content_length = 0 - await asyncio.sleep(0.1) + while not stop_event.is_set(): + # Read a header line + header = await reader.readline() + if not header: + break + message.append(header) + + # Extract content length if possible + if not content_length: + match = CONTENT_LENGTH_PATTERN.fullmatch(header) + if match: + content_length = int(match.group(1)) + logger.debug("Content length: %s", content_length) + + # Check if all headers have been read (as indicated by an empty line \r\n) + if content_length and not header.strip(): + # Read body + body = await reader.readexactly(content_length) + if not body: + break + message.append(body) + + # Pass message to protocol, optionally async + result = message_handler(b"".join(message)) + if inspect.isawaitable(result): + await result + + # Reset the buffer + message = [] + content_length = 0 + + +async def get_streams(stdin, stdout): + """Convert blocking stdin/stdout streams into async streams.""" + loop = asyncio.get_running_loop() + + reader = asyncio.StreamReader() + read_protocol = asyncio.StreamReaderProtocol(reader) + await loop.connect_read_pipe(lambda: read_protocol, stdin) + + write_transport, write_protocol = await loop.connect_write_pipe( + asyncio.streams.FlowControlMixin, stdout + ) + writer = asyncio.StreamWriter(write_transport, write_protocol, reader, loop) + return reader, writer class Agent: """The Agent sits between a language server and its client, listening to messages enabling them to be recorded.""" - def __init__(self, server: subprocess.Popen, stdin: BinaryIO, stdout: BinaryIO): + def __init__( + self, server: asyncio.subprocess.Process, stdin: BinaryIO, stdout: BinaryIO + ): self.stdin = stdin self.stdout = stdout - self.server_process = server + self.server = server self.stop_event = threading.Event() - self.thread_pool_executor = ThreadPoolExecutor( - max_workers=4, - thread_name_prefix="LSP Traffic Worker ", - ) async def start(self): - event_loop = asyncio.get_running_loop() + # Get async versions of stdin/stdout + reader, writer = await get_streams(self.stdin, self.stdout) # Connect stdin to the subprocess' stdin client_to_server = aio_readline( - loop=event_loop, - executor=self.thread_pool_executor, - stop_event=self.stop_event, - rfile=self.stdin, - proxy=partial(forward_message, "client", self.server_process.stdin), + self.stop_event, + reader, + partial(forward_message, "client", self.server.stdin), ) # Connect the subprocess' stdout to stdout server_to_client = aio_readline( - loop=event_loop, - executor=self.thread_pool_executor, - stop_event=self.stop_event, - rfile=self.server_process.stdout, - proxy=partial(forward_message, "server", self.stdout), + self.stop_event, + self.server.stdout, + partial(forward_message, "server", writer), ) # Run both connections concurrently. return await asyncio.gather( client_to_server, server_to_client, - check_server_process(self.server_process, self.stop_event), ) - def stop(self): + async def stop(self): self.stop_event.set() - self.thread_pool_executor.shutdown(wait=False, cancel_futures=True) try: - self.server_process.terminate() - ret = self.server_process.wait(timeout=1) + self.server.terminate() + ret = await self.server.wait() print(f"Server process exited with code: {ret}") except TimeoutError: - self.server_process.kill() - - # Need to close these to prevent open file warnings - if self.server_process.stdin is not None: - self.server_process.stdin.close() - - if self.server_process.stdout is not None: - self.server_process.stdout.close() + self.server.kill() diff --git a/lib/lsp-devtools/lsp_devtools/agent/client.py b/lib/lsp-devtools/lsp_devtools/agent/client.py index 01816dc..03f5662 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/client.py +++ b/lib/lsp-devtools/lsp_devtools/agent/client.py @@ -1,94 +1,44 @@ import asyncio -import json -import re -from json.decoder import JSONDecodeError -from threading import Event from typing import Any -from typing import Callable from typing import Optional -import websockets +import stamina +from pygls.client import JsonRPCClient +from pygls.client import aio_readline from pygls.protocol import default_converter -from pygls.server import Server -from websockets.client import WebSocketClientProtocol from lsp_devtools.agent.protocol import AgentProtocol -from lsp_devtools.agent.protocol import MessageText +# from websockets.client import WebSocketClientProtocol -class WebSocketClientTransportAdapter: - """Protocol adapter for the WebSocket client interface.""" - def __init__(self, ws: WebSocketClientProtocol, loop: asyncio.AbstractEventLoop): - self._ws = ws - self._loop = loop +# class WebSocketClientTransportAdapter: +# """Protocol adapter for the WebSocket client interface.""" - def close(self) -> None: - """Stop the WebSocket server.""" - print("-- CLOSING --") - self._loop.create_task(self._ws.close()) +# def __init__(self, ws: WebSocketClientProtocol, loop: asyncio.AbstractEventLoop): +# self._ws = ws +# self._loop = loop - def write(self, data: Any) -> None: - """Create a task to write specified data into a WebSocket.""" - asyncio.ensure_future(self._ws.send(data)) +# def close(self) -> None: +# """Stop the WebSocket server.""" +# print("-- CLOSING --") +# self._loop.create_task(self._ws.close()) +# def write(self, data: Any) -> None: +# """Create a task to write specified data into a WebSocket.""" +# asyncio.ensure_future(self._ws.send(data)) -MESSAGE_PATTERN = re.compile( - r"^(?:[^\r\n]+\r\n)*" - + r"Content-Length: (?P\d+)\r\n" - + r"(?:[^\r\n]+\r\n)*\r\n" - + r"(?P{.*)", - re.DOTALL, -) +class AgentClient(JsonRPCClient): + """Client for connecting to an AgentServer instance.""" -def parse_rpc_message( - ls: "AgentClient", message: MessageText, callback: Callable[[dict], None] -): - """Parse json-rpc messages coming from the agent. - - Originally adatped from the ``data_received`` method on pygls' ``JsonRPCProtocol`` - class. - """ - data = message.text - message_buf = ls._client_buf if message.source == "client" else ls._server_buf - - while len(data): - # Append the incoming chunk to the message buffer - message_buf.append(data) - - # Look for the body of the message - msg = "".join(message_buf) - found = MESSAGE_PATTERN.fullmatch(msg) - - body = found.group("body") if found else "" - length = int(found.group("length")) if found else 1 - - if len(body) < length: - # Message is incomplete; bail until more data arrives - return - - # Message is complete; - # extract the body and any remaining data, - # and reset the buffer for the next message - body, data = body[:length], body[length:] - message_buf.clear() - - callback(json.loads(body)) - - -class AgentClient(Server): - """Client for connecting to an LSPAgent instance.""" - - lsp: AgentProtocol + protocol: AgentProtocol def __init__(self): super().__init__( protocol_cls=AgentProtocol, converter_factory=default_converter ) - self._client_buf = [] - self._server_buf = [] - self._stop_event: Event = Event() + self.connected = False def _report_server_error(self, error, source): # Bail on error @@ -96,50 +46,71 @@ def _report_server_error(self, error, source): self._stop_event.set() def feature(self, feature_name: str, options: Optional[Any] = None): - return self.lsp.fm.feature(feature_name, options) - - def start_ws_client(self, host: str, port: int): - """Similar to ``start_ws``, but where we create a client connection rather than - host a server.""" - - self.lsp._send_only_body = True # Don't send headers within the payload - - async def client_connection(host: str, port: int): - """Create and run a client connection.""" - - self._client = await websockets.connect( # type: ignore - f"ws://{host}:{port}" - ) - self.lsp.transport = WebSocketClientTransportAdapter( - self._client, self.loop - ) - message = None - - try: - while not self._stop_event.is_set(): - try: - message = await asyncio.wait_for( - self._client.recv(), timeout=0.5 - ) - self.lsp._procedure_handler( - json.loads( - message, object_hook=self.lsp._deserialize_message - ) - ) - except JSONDecodeError: - print(message or "-- message not found --") - raise - except TimeoutError: - pass - except Exception: - raise - - finally: - await self._client.close() - - try: - asyncio.run(client_connection(host, port)) - except KeyboardInterrupt: - pass - finally: - self.shutdown() + return self.protocol.fm.feature(feature_name, options) + + # TODO: Upstream this... or at least something equivalent. + async def start_tcp(self, host: str, port: int): + # The user might not have started the server app immediately and since the + # agent will live as long as the wrapper language server we may as well + # try indefinitely. + retries = stamina.retry_context( + on=OSError, + attempts=None, + timeout=None, + wait_initial=1, + wait_max=60, + ) + async for attempt in retries: + with attempt: + reader, writer = await asyncio.open_connection(host, port) + + self.protocol.connection_made(writer) # type: ignore[arg-type] + connection = asyncio.create_task( + aio_readline(self._stop_event, reader, self.protocol.data_received) + ) + self.connected = True + self._async_tasks.append(connection) + + # TODO: Upstream this... or at least something equivalent. + # def start_ws(self, host: str, port: int): + # self.protocol._send_only_body = True # Don't send headers within the payload + + # async def client_connection(host: str, port: int): + # """Create and run a client connection.""" + + # self._client = await websockets.connect( # type: ignore + # f"ws://{host}:{port}" + # ) + # loop = asyncio.get_running_loop() + # self.protocol.transport = WebSocketClientTransportAdapter( + # self._client, loop + # ) + # message = None + + # try: + # while not self._stop_event.is_set(): + # try: + # message = await asyncio.wait_for( + # self._client.recv(), timeout=0.5 + # ) + # self.protocol._procedure_handler( + # json.loads( + # message, + # object_hook=self.protocol._deserialize_message + # ) + # ) + # except JSONDecodeError: + # print(message or "-- message not found --") + # raise + # except TimeoutError: + # pass + # except Exception: + # raise + + # finally: + # await self._client.close() + + # try: + # asyncio.run(client_connection(host, port)) + # except KeyboardInterrupt: + # pass diff --git a/lib/lsp-devtools/lsp_devtools/agent/protocol.py b/lib/lsp-devtools/lsp_devtools/agent/protocol.py index 742c734..c871746 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/protocol.py +++ b/lib/lsp-devtools/lsp_devtools/agent/protocol.py @@ -14,6 +14,12 @@ class MessageText: text: str """The captured text.""" + timestamp: float + """The timestamp of when the message was recorded.""" + + session: str + """The session id.""" + source: str """The source the text was captured from e.g. client.""" diff --git a/lib/lsp-devtools/lsp_devtools/agent/server.py b/lib/lsp-devtools/lsp_devtools/agent/server.py index b0456a5..f9633a2 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/server.py +++ b/lib/lsp-devtools/lsp_devtools/agent/server.py @@ -1,12 +1,22 @@ +import asyncio +import json +import re +import threading +from typing import Any +from typing import Optional + +from pygls.client import aio_readline from pygls.protocol import default_converter from pygls.server import Server from lsp_devtools.agent.protocol import AgentProtocol +from lsp_devtools.agent.protocol import MessageText +from lsp_devtools.database import Database class AgentServer(Server): - """A pygls server that wraps an agent allowing other processes to interact with it - via websockets.""" + """A pygls server that accepts connections from agents allowing them to send their + collected messages.""" lsp: AgentProtocol @@ -18,3 +28,75 @@ def __init__(self, *args, **kwargs): kwargs["converter_factory"] = default_converter super().__init__(*args, **kwargs) + + self.db: Optional[Database] = None + + self._client_buffer = [] + self._server_buffer = [] + self._stop_event = threading.Event() + self._tcp_server = None + + def feature(self, feature_name: str, options: Optional[Any] = None): + return self.lsp.fm.feature(feature_name, options) + + async def start_tcp(self, host: str, port: int) -> None: # type: ignore[override] + async def handle_client(reader, writer): + self.lsp.connection_made(writer) + await aio_readline(self._stop_event, reader, self.lsp.data_received) + + writer.close() + await writer.wait_closed() + + if self._tcp_server is not None: + self._tcp_server.cancel() + + server = await asyncio.start_server(handle_client, host, port) + async with server: + self._tcp_server = asyncio.create_task(server.serve_forever()) + await self._tcp_server + + async def stop(self): + if self._tcp_server is not None: + self._tcp_server.cancel() + + +MESSAGE_PATTERN = re.compile( + r"^(?:[^\r\n]+\r\n)*" + + r"Content-Length: (?P\d+)\r\n" + + r"(?:[^\r\n]+\r\n)*\r\n" + + r"(?P{.*)", + re.DOTALL, +) + + +def parse_rpc_message(ls: AgentServer, message: MessageText, callback): + """Parse json-rpc messages coming from the agent. + + Originally adatped from the ``data_received`` method on pygls' ``JsonRPCProtocol`` + class. + """ + data = message.text + message_buf = ls._client_buffer if message.source == "client" else ls._server_buffer + + while len(data): + # Append the incoming chunk to the message buffer + message_buf.append(data) + + # Look for the body of the message + msg = "".join(message_buf) + found = MESSAGE_PATTERN.fullmatch(msg) + + body = found.group("body") if found else "" + length = int(found.group("length")) if found else 1 + + if len(body) < length: + # Message is incomplete; bail until more data arrives + return + + # Message is complete; + # extract the body and any remaining data, + # and reset the buffer for the next message + body, data = body[:length], body[length:] + message_buf.clear() + + callback(json.loads(body)) diff --git a/lib/lsp-devtools/lsp_devtools/cli.py b/lib/lsp-devtools/lsp_devtools/cli.py index 24cc1d0..3ec1ff6 100644 --- a/lib/lsp-devtools/lsp_devtools/cli.py +++ b/lib/lsp-devtools/lsp_devtools/cli.py @@ -11,9 +11,9 @@ BUILTIN_COMMANDS = [ "lsp_devtools.agent", - "lsp_devtools.cmds.capabilities", # TODO: Remove in favour of record + cli args + "lsp_devtools.client", + "lsp_devtools.inspector", "lsp_devtools.record", - "lsp_devtools.tui", ] @@ -37,7 +37,7 @@ def load_command(commands: argparse._SubParsersAction, name: str): def main(): cli = argparse.ArgumentParser( - prog="lsp-devtools", description="Development tooling for language servers" + prog="lsp-devtools", description="Developer tooling for language servers" ) cli.add_argument("--version", action="version", version=f"%(prog)s v{__version__}") commands = cli.add_subparsers(title="commands") diff --git a/lib/lsp-devtools/lsp_devtools/client/__init__.py b/lib/lsp-devtools/lsp_devtools/client/__init__.py new file mode 100644 index 0000000..23f6024 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/__init__.py @@ -0,0 +1,175 @@ +import argparse +import asyncio +import logging +import os +import pathlib +from typing import List +from uuid import uuid4 + +import platformdirs +from lsprotocol import types +from pygls import uris as uri +from textual import events +from textual import on +from textual.app import App +from textual.app import ComposeResult +from textual.containers import ScrollableContainer +from textual.containers import Vertical +from textual.widgets import DirectoryTree +from textual.widgets import Footer +from textual.widgets import Header + +from lsp_devtools.agent import logger +from lsp_devtools.database import Database +from lsp_devtools.database import DatabaseLogHandler +from lsp_devtools.inspector import MessagesTable +from lsp_devtools.inspector import MessageViewer + +from .editor import TextEditor +from .lsp import LanguageClient + + +class Explorer(DirectoryTree): + @on(DirectoryTree.FileSelected) + def open_file(self, event: DirectoryTree.FileSelected): + if not self.parent: + return + + editor = self.parent.query_one(TextEditor) + editor.open_file(event.path) + editor.focus() + + +class Devtools(Vertical): + pass + + +class LSPClient(App): + """A simple LSP client for use with language servers.""" + + CSS_PATH = pathlib.Path(__file__).parent / "app.css" + BINDINGS = [ + ("f2", "toggle_explorer", "Explorer"), + ("f12", "toggle_devtools", "Devtools"), + # ("ctrl+g", "refresh_table", "Refresh table"), + ] + + def __init__( + self, db: Database, server_command: List[str], session: str, *args, **kwargs + ): + super().__init__(*args, **kwargs) + + self.db = db + db.app = self + + self.session = session + self.server_command = server_command + self.lsp_client = LanguageClient() + + self._async_tasks: List[asyncio.Task] = [] + + def compose(self) -> ComposeResult: + message_viewer = MessageViewer("") + messages_table = MessagesTable(self.db, message_viewer, session=self.session) + + yield Header() + yield Explorer(".") + yield TextEditor(self.lsp_client) + devtools = Devtools(ScrollableContainer(messages_table), message_viewer) + devtools.add_class("-hidden") + yield devtools + yield Footer() + + def action_toggle_devtools(self) -> None: + devtools = self.query_one(Devtools) + is_visible = not devtools.has_class("-hidden") + + if is_visible: + self.screen.focus_next() + devtools.add_class("-hidden") + + else: + devtools.remove_class("-hidden") + self.screen.set_focus(devtools) + + def action_toggle_explorer(self) -> None: + explorer = self.query_one(Explorer) + is_visible = not explorer.has_class("-hidden") + + if is_visible and explorer.has_focus: + self.screen.focus_next() + explorer.add_class("-hidden") + + else: + explorer.remove_class("-hidden") + self.screen.set_focus(explorer) + + async def on_ready(self, event: events.Ready): + editor = self.query_one(TextEditor) + + # Start the lsp server. + await self.lsp_client.start_io(self.server_command[0], *self.server_command[1:]) + result = await self.lsp_client.initialize_async( + types.InitializeParams( + capabilities=types.ClientCapabilities(), + process_id=os.getpid(), + root_uri=uri.from_fs_path(os.getcwd()), + ) + ) + + editor.capabilities = result.capabilities + self.lsp_client.initialized(types.InitializedParams()) + + @on(Database.Update) + async def update_table(self, event: Database.Update): + table = self.query_one(MessagesTable) + await table.update() + + async def action_quit(self): + await self.lsp_client.shutdown_async(None) + self.lsp_client.exit(None) + await self.lsp_client.stop() + await super().action_quit() + + +def client(args, extra: List[str]): + if len(extra) == 0: + raise ValueError("Missing server command.") + + db = Database(args.dbpath) + + session = str(uuid4()) + dbhandler = DatabaseLogHandler(db, session=session) + dbhandler.setLevel(logging.INFO) + + logger.setLevel(logging.INFO) + logger.addHandler(dbhandler) + + app = LSPClient(db, session=session, server_command=extra) + app.run() + + asyncio.run(db.close()) + + +def cli(commands: argparse._SubParsersAction): + cmd: argparse.ArgumentParser = commands.add_parser( + "client", + help="launch an LSP client with built in inspector", + description="""\ +Open a simple text editor to drive a given language server. +""", + ) + + default_db = pathlib.Path( + platformdirs.user_cache_dir(appname="lsp-devtools", appauthor="swyddfa"), + "sessions.db", + ) + cmd.add_argument( + "--dbpath", + type=pathlib.Path, + metavar="DB", + default=default_db, + help="the database path to use", + ) + + cmd.set_defaults(run=client) diff --git a/lib/lsp-devtools/lsp_devtools/client/app.css b/lib/lsp-devtools/lsp_devtools/client/app.css new file mode 100644 index 0000000..df160f1 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/app.css @@ -0,0 +1,35 @@ +Screen { + layers: base overlay; +} + +Explorer { + width: 30; + dock: left; + transition: offset 300ms out_cubic; +} +Explorer.-hidden { + display: none; +} + +Header { + dock: top; +} + +Devtools { + width: 40%; + dock: right; + transition: offset 300ms out_cubic; +} +Devtools.-hidden { + display: none; +} + +MessagesTable { + height: 100%; +} + +CompletionList { + height: 10; + width: 30; + layer: overlay; +} diff --git a/lib/lsp-devtools/lsp_devtools/client/editor.py b/lib/lsp-devtools/lsp_devtools/client/editor.py new file mode 100644 index 0000000..fff312d --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/editor.py @@ -0,0 +1,161 @@ +import asyncio +import pathlib +from typing import Optional +from typing import Set + +from lsprotocol import types +from pygls import uris as uri +from pygls.capabilities import get_capability +from textual import events +from textual import log +from textual import on +from textual.binding import Binding +from textual.widgets import OptionList +from textual.widgets import TextArea + +from .lsp import LanguageClient + + +class CompletionList(OptionList): + BINDINGS = [ + Binding("escape", "dismiss", "Dismiss", show=False), + Binding("ctrl+j", "dismiss", "Dismiss", show=False), + ] + + @classmethod + def fromresult(cls, result): + """Build a list of completion candidates based on a response from the + language server.""" + candidates = cls() + + if result is None: + return candidates + + if isinstance(result, types.CompletionList): + items = result.items + else: + items = result + + if len(items) == 0: + return candidates + + candidates.add_options(sorted([i.label for i in items])) + return candidates + + def on_blur(self, event: events.Blur): + self.action_dismiss() + + def action_dismiss(self): + self.remove() + if self.parent: + self.app.set_focus(self.parent) # type: ignore + + +class TextEditor(TextArea): + def __init__(self, lsp_client: LanguageClient, *args, **kwargs): + super().__init__(*args, **kwargs) + self.uri = None + self.version = 0 + + self.lsp_client = lsp_client + self.capabilities: Optional[types.ServerCapabilities] = None + + self._tasks: Set[asyncio.Task] = set() + + @property + def completion_triggers(self): + return get_capability( + self.capabilities, # type: ignore + "completion_provider.trigger_characters", + set(), + ) + + def open_file(self, path: pathlib.Path): + self.uri = uri.from_fs_path(str(path.resolve())) + if self.uri is None: + return + + content = path.read_text() + self.version = 0 + self.load_text(content) + + self.lsp_client.text_document_did_open( + types.DidOpenTextDocumentParams( + text_document=types.TextDocumentItem( + uri=self.uri, + language_id="restructuredtext", + version=self.version, + text=content, + ) + ) + ) + + def edit(self, edit): + super().edit(edit) + + if self.uri is None: + return + + self.version += 1 + start_line, start_col = edit.from_location + end_line, end_col = edit.to_location + + self.lsp_client.text_document_did_change( + types.DidChangeTextDocumentParams( + text_document=types.VersionedTextDocumentIdentifier( + version=self.version, uri=self.uri + ), + content_changes=[ + types.TextDocumentContentChangeEvent_Type1( + text=edit.text, + range=types.Range( + start=types.Position(line=start_line, character=start_col), + end=types.Position(line=end_line, character=end_col), + ), + ) + ], + ) + ) + + if len(edit.text) == 0: + return + + char = edit.text[-1] + if char in self.completion_triggers: + self.trigger_completion(end_line, end_col) + + def trigger_completion(self, line: int, character: int): + """Trigger completion at the given location.""" + + if self.uri is None: + return + + task = asyncio.create_task( + self.lsp_client.text_document_completion_async( + types.CompletionParams( + text_document=types.TextDocumentIdentifier(uri=self.uri), + position=types.Position(line=line, character=character), + ) + ) + ) + + self._tasks.add(task) + task.add_done_callback(self.show_completions) + + def show_completions(self, task: asyncio.Task): + self._tasks.discard(task) + + candidates = CompletionList.fromresult(task.result()) + if candidates.option_count == 0: + return + + row, col = self.cursor_location + candidates.offset = (col + 2, row + 1) + + self.mount(candidates) + self.app.set_focus(candidates) + + @on(OptionList.OptionSelected) + def completion_selected(self, event: OptionList.OptionSelected): + log(f"{event.option} was selected!") + event.option_list.action_dismiss() # type: ignore diff --git a/lib/lsp-devtools/lsp_devtools/client/lsp.py b/lib/lsp-devtools/lsp_devtools/client/lsp.py new file mode 100644 index 0000000..897505f --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/lsp.py @@ -0,0 +1,39 @@ +import importlib.metadata +import json + +from pygls.lsp.client import BaseLanguageClient +from pygls.protocol import LanguageServerProtocol + +from lsp_devtools.agent import logger + +VERSION = importlib.metadata.version("lsp-devtools") + + +class RecordingLSProtocol(LanguageServerProtocol): + """A version of the LanguageServerProtocol that also records all the traffic.""" + + def __init__(self, server, converter): + super().__init__(server, converter) + + def _procedure_handler(self, message): + logger.info( + "%s", + json.dumps(message, default=self._serialize_message), + extra={"source": "server"}, + ) + return super()._procedure_handler(message) + + def _send_data(self, data): + logger.info( + "%s", + json.dumps(data, default=self._serialize_message), + extra={"source": "client"}, + ) + return super()._send_data(data) + + +class LanguageClient(BaseLanguageClient): + """A language client for integrating with a textual text edit.""" + + def __init__(self): + super().__init__("lsp-devtools", VERSION, protocol_cls=RecordingLSProtocol) diff --git a/lib/lsp-devtools/lsp_devtools/cmds/__init__.py b/lib/lsp-devtools/lsp_devtools/cmds/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/lib/lsp-devtools/lsp_devtools/cmds/capabilities.py b/lib/lsp-devtools/lsp_devtools/cmds/capabilities.py deleted file mode 100644 index d520ed3..0000000 --- a/lib/lsp-devtools/lsp_devtools/cmds/capabilities.py +++ /dev/null @@ -1,35 +0,0 @@ -import argparse -import json - -from lsprotocol.types import INITIALIZE -from lsprotocol.types import InitializeParams -from pygls.server import LanguageServer - - -def capabilities(args, extra): - server = LanguageServer(name="capabilities-dumper", version="v1.0") - - @server.feature(INITIALIZE) - def on_initialize(ls: LanguageServer, params: InitializeParams): - client_info = params.client_info - if client_info: - client_name = client_info.name.lower().replace(" ", "_") - client_version = client_info.version or "unknown" - else: - client_name = "unknown" - client_version = "unknown" - - filename = f"{client_name}_v{client_version}.json" - with open(filename, "w") as f: - obj = params.capabilities - json.dump(ls.lsp._converter.unstructure(obj), f, indent=2) - - server.start_io() - - -def cli(commands: argparse._SubParsersAction): - cmd = commands.add_parser( - "capabilities", - help="dummy lsp server for recording a client's capabilities.", - ) - cmd.set_defaults(run=capabilities) diff --git a/lib/lsp-devtools/lsp_devtools/database.py b/lib/lsp-devtools/lsp_devtools/database.py new file mode 100644 index 0000000..f3eff5d --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/database.py @@ -0,0 +1,167 @@ +import asyncio +import json +import logging +import pathlib +import sys +from contextlib import asynccontextmanager +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Set +from uuid import uuid4 + +import aiosqlite +from textual.app import App +from textual.message import Message + +from lsp_devtools.handlers import LspMessage + +if sys.version_info.minor < 9: + import importlib_resources as resources +else: + import importlib.resources as resources # type: ignore[no-redef] + + +class Database: + """Controls access to the backing sqlite database.""" + + class Update(Message): + """Sent when there are updates to the database""" + + def __init__(self, dbpath: Optional[pathlib.Path] = None): + self.dbpath = dbpath or ":memory:" + self.db: Optional[aiosqlite.Connection] = None + self.app: Optional[App] = None + self._handlers: Dict[str, set] = {} + + async def close(self): + if self.db: + await self.db.close() + + @asynccontextmanager + async def cursor(self): + """Get a connection to the database.""" + + if self.db is None: + if ( + isinstance(self.dbpath, pathlib.Path) + and not self.dbpath.parent.exists() + ): + self.dbpath.parent.mkdir(parents=True) + + resource = resources.files("lsp_devtools.handlers").joinpath("dbinit.sql") + schema = resource.read_text(encoding="utf8") + + self.db = await aiosqlite.connect(self.dbpath) + await self.db.executescript(schema) + await self.db.commit() + + cursor = await self.db.cursor() + yield cursor + + await self.db.commit() + + async def add_message(self, session: str, timestamp: float, source: str, rpc: dict): + """Add a new rpc message to the database.""" + + msg_id = rpc.get("id", None) + method = rpc.get("method", None) + params = rpc.get("params", None) + result = rpc.get("result", None) + error = rpc.get("error", None) + + async with self.cursor() as cursor: + await cursor.execute( + "INSERT INTO protocol VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + session, + timestamp, + source, + msg_id, + method, + json.dumps(params) if params else None, + json.dumps(result) if result else None, + json.dumps(error) if error else None, + ), + ) + + if self.app is not None: + self.app.post_message(Database.Update()) + + async def get_messages( + self, + *, + session: str = "", + max_row: Optional[int] = None, + ): + """Get messages from the database + + Parameters + ---------- + session + If set, only return messages with the given session id + + max_row + If set, only return messages with a row id greater than ``max_row`` + """ + + base_query = "SELECT rowid, * FROM protocol" + where: List[str] = [] + parameters: List[Any] = [] + + if session: + where.append("session = ?") + parameters.append(session) + + if max_row: + where.append("rowid > ?") + parameters.append(max_row) + + if where: + conditions = " AND ".join(where) + query = " ".join([base_query, "WHERE", conditions]) + else: + query = base_query + + async with self.cursor() as cursor: + await cursor.execute(query, tuple(parameters)) + + rows = await cursor.fetchall() + results = [] + for row in rows: + message = LspMessage( + session=row[1], + timestamp=row[2], + source=row[3], + id=row[4], + method=row[5], + params=row[6], + result=row[7], + error=row[8], + ) + + results.append((row[0], message)) + + return results + + +class DatabaseLogHandler(logging.Handler): + """A logging handler that records messages in the given database.""" + + def __init__(self, db: Database, *args, session=None, **kwargs): + super().__init__(*args, **kwargs) + self.db = db + self.session = session or str(uuid4()) + self._tasks: Set[asyncio.Task] = set() + + def emit(self, record: logging.LogRecord): + body = json.loads(record.args[0]) # type: ignore + task = asyncio.create_task( + self.db.add_message( + self.session, record.created, record.__dict__["source"], body + ) + ) + + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) diff --git a/lib/lsp-devtools/lsp_devtools/handlers/__init__.py b/lib/lsp-devtools/lsp_devtools/handlers/__init__.py index 4b8219a..33f6686 100644 --- a/lib/lsp-devtools/lsp_devtools/handlers/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/handlers/__init__.py @@ -1,18 +1,21 @@ +import json import logging from typing import Any +from typing import Literal from typing import Mapping from typing import Optional from uuid import uuid4 import attrs -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal # type: ignore[assignment] +MessageSource = Literal["client", "server"] -MessageSource = Literal["client", "server"] +def maybe_json(value): + try: + return json.loads(value) + except Exception: + return value @attrs.define @@ -37,13 +40,13 @@ class LspMessage: method: Optional[str] """The ``method`` field, if it exists.""" - params: Optional[Any] + params: Optional[Any] = attrs.field(converter=maybe_json) """The ``params`` field, if it exists.""" - result: Optional[Any] + result: Optional[Any] = attrs.field(converter=maybe_json) """The ``result`` field, if it exists.""" - error: Optional[Any] + error: Optional[Any] = attrs.field(converter=maybe_json) """The ``error`` field, if it exists.""" @classmethod @@ -81,7 +84,7 @@ class LspHandler(logging.Handler): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.session_id = "" + self.session_id = str(uuid4()) def handle_message(self, message: LspMessage): """Called each time a message is processed.""" @@ -94,9 +97,6 @@ def emit(self, record: logging.LogRecord): timestamp = record.created source = record.__dict__["source"] - if message.get("method", None) == "initialize": - self.session_id = str(uuid4()) - self.handle_message( LspMessage.from_rpc( session=self.session_id, diff --git a/lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql b/lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql index 18604d1..fcadb11 100644 --- a/lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql +++ b/lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql @@ -58,6 +58,7 @@ SELECT json_extract(params, "$.clientInfo.name") as client_name, json_extract(params, "$.clientInfo.version") as client_version, json_extract(params, "$.rootUri") as root_uri, + json_extract(params, "$.workspaceFolders") as workspace_folders, params, result FROM requests WHERE method = 'initialize'; diff --git a/lib/lsp-devtools/lsp_devtools/inspector/__init__.py b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py new file mode 100644 index 0000000..a531c00 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py @@ -0,0 +1,301 @@ +import argparse +import asyncio +import json +import logging +import pathlib +import re +from datetime import datetime +from typing import Any +from typing import Dict +from typing import List +from typing import Optional + +import platformdirs +from rich.highlighter import ReprHighlighter +from rich.text import Text +from textual import on +from textual.app import App +from textual.app import ComposeResult +from textual.containers import Container +from textual.containers import ScrollableContainer +from textual.events import Ready +from textual.widgets import DataTable +from textual.widgets import Footer +from textual.widgets import Header +from textual.widgets import Tree +from textual.widgets.tree import TreeNode + +from lsp_devtools.agent import MESSAGE_TEXT_NOTIFICATION +from lsp_devtools.agent import AgentServer +from lsp_devtools.agent import MessageText +from lsp_devtools.database import Database +from lsp_devtools.handlers import LspMessage + +logger = logging.getLogger(__name__) + + +class MessageViewer(Tree): + """Used to inspect the fields of an object.""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.highlighter = ReprHighlighter() + + def set_object(self, name: str, obj: Any): + self.clear() + self.root.set_label(name) + self.walk_object(name, self.root, obj) + + def walk_object(self, label: str, node: TreeNode, obj: Any): + if isinstance(obj, dict): + node.expand() + for field, value in obj.items(): + child = node.add(field) + self.walk_object(field, child, value) + + elif isinstance(obj, list): + node.expand() + for idx, value in enumerate(obj): + child_label = self.highlighter(str(idx)) + child = node.add(child_label) + self.walk_object(str(idx), child, value) + + else: + node.allow_expand = False + node.set_label(Text.assemble(label, " = ", self.highlighter(repr(obj)))) + + +class MessagesTable(DataTable): + """Datatable used to display all messages between client and server""" + + def __init__(self, db: Database, viewer: MessageViewer, session=None): + super().__init__() + + self.db = db + + self.rpcdata: Dict[int, LspMessage] = {} + self.max_row = 0 + self.session: Optional[str] = session + + self.viewer = viewer + + self.cursor_type = "row" + + self.add_column("") + self.add_column("Time") + self.add_column("Source") + self.add_column("ID") + self.add_column("Method") + + @on(DataTable.RowHighlighted) + def show_object(self, event: DataTable.RowHighlighted): + """Show the message object on the currently highlighted row.""" + if event.cursor_row < 0: + return + + rowid = int(self.get_row_at(event.cursor_row)[0]) + if (message := self.rpcdata.get(rowid, None)) is None: + return + + name = "" + obj = {} + + if message.params: + name = "params" + obj = message.params + + elif message.result: + name = "result" + obj = message.result + + elif message.error: + name = "error" + obj = message.error + + self.viewer.set_object(name, obj) + + def _get_query_params(self): + """Return the set of query parameters to use when populating the table.""" + query: Dict[str, Any] = dict(max_row=self.max_row) + + if self.session is not None: + query["session"] = self.session + + return query + + async def update(self): + """Trigger a re-run of the query to pull in new data.""" + + query_params = self._get_query_params() + messages = await self.db.get_messages(**query_params) + for idx, message in messages: + self.max_row = idx + self.rpcdata[idx] = message + + # Surely there's a more direct way to do this? + dt = datetime.fromtimestamp(message.timestamp) + time = dt.isoformat(timespec="milliseconds") + time = time[time.find("T") + 1 :] + + self.add_row(str(idx), time, message.source, message.id, message.method) + + self.move_cursor(row=self.row_count, animate=True) + + +class Sidebar(Container): + pass + + +class LSPInspector(App): + CSS_PATH = pathlib.Path(__file__).parent / "app.css" + BINDINGS = [ + ("ctrl+b", "toggle_sidebar", "Sidebar"), + ("ctrl+c", "quit", "Quit"), + ("ctrl+s", "screenshot", "Take Screenshot"), + ] + + def __init__(self, db: Database, server: AgentServer, *args, **kwargs): + super().__init__(*args, **kwargs) + + db.app = self + self.db = db + """Where the data for the app is being held""" + + self.server = server + """Server used to manage connections to lsp servers.""" + + self._async_tasks: List[asyncio.Task] = [] + + def compose(self) -> ComposeResult: + yield Header() + + viewer = MessageViewer("") + messages = MessagesTable(self.db, viewer) + yield Container(ScrollableContainer(messages), Sidebar(viewer)) + yield Footer() + + def action_screenshot(self): + self.bell() + self.save_screenshot(None, "./") + + def action_toggle_sidebar(self) -> None: + sidebar = self.query_one(Sidebar) + self.set_focus(None) + + if sidebar.has_class("-hidden"): + sidebar.remove_class("-hidden") + else: + if sidebar.query("*:focus"): + self.screen.set_focus(None) + sidebar.add_class("-hidden") + + async def on_ready(self, event: Ready): + self._async_tasks.append( + asyncio.create_task(self.server.start_tcp("localhost", 8765)) + ) + await self.update_table() + + async def update_table(self): + table = self.query_one(MessagesTable) + await table.update() + + async def action_quit(self): + await self.server.stop() + await self.db.close() + await super().action_quit() + + +MESSAGE_PATTERN = re.compile( + r"^(?:[^\r\n]+\r\n)*" + + r"Content-Length: (?P\d+)\r\n" + + r"(?:[^\r\n]+\r\n)*\r\n" + + r"(?P{.*)", + re.DOTALL, +) + + +async def handle_message(ls: AgentServer, message: MessageText): + """Handle messages received from the connected lsp server.""" + + data = message.text + message_buf = ls._client_buffer if message.source == "client" else ls._server_buffer + + while len(data): + # Append the incoming chunk to the message buffer + message_buf.append(data) + + # Look for the body of the message + msg = "".join(message_buf) + found = MESSAGE_PATTERN.fullmatch(msg) + + body = found.group("body") if found else "" + length = int(found.group("length")) if found else 1 + + if len(body) < length: + # Message is incomplete; bail until more data arrives + return + + # Message is complete; + # extract the body and any remaining data, + # and reset the buffer for the next message + body, data = body[:length], body[length:] + message_buf.clear() + + rpc = json.loads(body) + if ls.db is not None: + await ls.db.add_message( + message.session, message.timestamp, message.source, rpc + ) + + +def setup_server(db: Database): + server = AgentServer() + server.db = db + server.feature(MESSAGE_TEXT_NOTIFICATION)(handle_message) + return server + + +def tui(args, extra: List[str]): + db = Database(args.dbpath) + server = setup_server(db) + + app = LSPInspector(db, server) + app.run() + + +def cli(commands: argparse._SubParsersAction): + cmd: argparse.ArgumentParser = commands.add_parser( + "inspect", + help="launch an interactive LSP session inspector", + description="""\ +This command opens a text user interface that can be used to inspect and +manipulate an LSP session interactively. +""", + ) + + default_db = pathlib.Path( + platformdirs.user_cache_dir(appname="lsp-devtools", appauthor="swyddfa"), + "sessions.db", + ) + cmd.add_argument( + "--dbpath", + type=pathlib.Path, + metavar="DB", + default=default_db, + help="the database path to use", + ) + + connect = cmd.add_argument_group( + title="connection options", + description="options that control the connection to the LSP Agent.", + ) + connect.add_argument( + "--host", + type=str, + default="localhost", + help="the host that is hosting the agent.", + ) + connect.add_argument( + "-p", "--port", type=int, default=8765, help="the port to connect to." + ) + cmd.set_defaults(run=tui) diff --git a/lib/lsp-devtools/lsp_devtools/tui/app.css b/lib/lsp-devtools/lsp_devtools/inspector/app.css similarity index 81% rename from lib/lsp-devtools/lsp_devtools/tui/app.css rename to lib/lsp-devtools/lsp_devtools/inspector/app.css index c2c97d4..77ba509 100644 --- a/lib/lsp-devtools/lsp_devtools/tui/app.css +++ b/lib/lsp-devtools/lsp_devtools/inspector/app.css @@ -7,3 +7,8 @@ Sidebar { Sidebar.-hidden { offset-x: 100%; } + + +DataTable { + height: 100%; +} diff --git a/lib/lsp-devtools/lsp_devtools/record/__init__.py b/lib/lsp-devtools/lsp_devtools/record/__init__.py index 24cce15..2f66f32 100644 --- a/lib/lsp-devtools/lsp_devtools/record/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/record/__init__.py @@ -1,22 +1,31 @@ import argparse +import asyncio +import json import logging import pathlib +from functools import partial +from logging import LogRecord from typing import List from typing import Optional -from pygls.protocol import partial +from rich.console import Console from rich.console import ConsoleRenderable from rich.logging import RichHandler from rich.traceback import Traceback from lsp_devtools.agent import MESSAGE_TEXT_NOTIFICATION -from lsp_devtools.agent import AgentClient +from lsp_devtools.agent import AgentServer from lsp_devtools.agent import MessageText from lsp_devtools.agent import parse_rpc_message from lsp_devtools.handlers.sql import SqlHandler from .filters import LSPFilter +EXPORTERS = { + ".html": ("save_html", {}), + ".svg": ("save_svg", {"title": ""}), + ".txt": ("save_text", {}), +} logger = logging.getLogger(__name__) @@ -50,22 +59,30 @@ def render( return res + def format(self, record: LogRecord) -> str: + # Pretty print json messages + if isinstance(record.args, dict): + record.args = (json.dumps(record.args, indent=2),) + return super().format(record) -def log_raw_message(ls: AgentClient, message: MessageText): + +def log_raw_message(ls: AgentServer, message: MessageText): """Push raw messages through the logging system.""" logger.info(message.text, extra={"source": message.source}) -def log_rpc_message(ls: AgentClient, message: MessageText): +def log_rpc_message(ls: AgentServer, message: MessageText): """Push parsed json-rpc messages through the logging system""" logfn = partial(logger.info, "%s", extra={"source": message.source}) parse_rpc_message(ls, message, logfn) -def setup_stdout_output(args): +def setup_stdout_output(args) -> Console: """Log to stdout.""" - handler = RichLSPHandler(level=logging.INFO) + + console = Console(record=args.save_output is not None) + handler = RichLSPHandler(level=logging.INFO, console=console) handler.addFilter( LSPFilter( message_source=args.message_source, @@ -78,6 +95,7 @@ def setup_stdout_output(args): ) logger.addHandler(handler) + return console def setup_file_output(args): @@ -114,10 +132,14 @@ def setup_sqlite_output(args): def start_recording(args, extra: List[str]): - client = AgentClient() + server = AgentServer() log_func = log_raw_message if args.capture_raw_output else log_rpc_message logger.setLevel(logging.INFO) - client.feature(MESSAGE_TEXT_NOTIFICATION)(log_func) + server.feature(MESSAGE_TEXT_NOTIFICATION)(log_func) + + console: Optional[Console] = None + host = args.host + port = args.port if args.to_file: setup_file_output(args) @@ -126,13 +148,25 @@ def start_recording(args, extra: List[str]): setup_sqlite_output(args) else: - setup_stdout_output(args) + console = setup_stdout_output(args) try: - client.start_ws_client(args.host, args.port) - except Exception: - # TODO: Error handling - raise + print(f"Waiting for connection on {host}:{port}...", end="\r", flush=True) + asyncio.run(server.start_tcp(host, port)) + except asyncio.CancelledError: + pass + except KeyboardInterrupt: + pass + + if console is not None and args.save_output is not None: + destination = args.save_output + exporter_name, kwargs = EXPORTERS.get(destination.suffix, (None, None)) + if exporter_name is None: + console.print(f"Unable to save output to '{destination.suffix}' files") + return + + exporter = getattr(console, exporter_name) + exporter(str(destination), **kwargs) def setup_filter_args(cmd: argparse.ArgumentParser): @@ -188,10 +222,10 @@ def setup_filter_args(cmd: argparse.ArgumentParser): def cli(commands: argparse._SubParsersAction): cmd: argparse.ArgumentParser = commands.add_parser( "record", - help="record an LSP session, requires the server be wrapped by an agent.", + help="record a JSON-RPC session.", description="""\ -This command connects to an LSP agent allowing for messages sent -between client and server to be logged. +This command starts a JSON-RPC server allowing for a client to connect (over TCP by +default) and push messages to it and have them be recorded. """, ) @@ -258,6 +292,18 @@ def cli(commands: argparse._SubParsersAction): type=pathlib.Path, help="save messages to a SQLite DB", ) + output.add_argument( + "--save-output", + default=None, + metavar="DEST", + type=pathlib.Path, + help=( + "only applies when printing messages to the console. " + "This makes use of the rich.Console's export feature to save its output in " + "HTML, SVG or plain text format. The format used will be picked " + "automatically based on the desintation's file extension." + ), + ) cmd.set_defaults(run=start_recording) diff --git a/lib/lsp-devtools/lsp_devtools/record/filters.py b/lib/lsp-devtools/lsp_devtools/record/filters.py index 5e534e9..1429fb7 100644 --- a/lib/lsp-devtools/lsp_devtools/record/filters.py +++ b/lib/lsp-devtools/lsp_devtools/record/filters.py @@ -1,5 +1,6 @@ import logging from typing import Dict +from typing import Literal from typing import Set from typing import Union @@ -7,12 +8,6 @@ from .formatters import FormatString -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal # type: ignore[assignment] - - logger = logging.getLogger(__name__) MessageSource = Literal["client", "server", "both"] @@ -78,6 +73,7 @@ def filter(self, record: logging.LogRecord) -> bool: if self.formatter.pattern: try: record.msg = self.formatter.format(message) + record.args = None except Exception: logger.debug( "Skipping message that failed to format: %s", message, exc_info=True diff --git a/lib/lsp-devtools/lsp_devtools/record/formatters.py b/lib/lsp-devtools/lsp_devtools/record/formatters.py index 8def8fe..d8cb5cb 100644 --- a/lib/lsp-devtools/lsp_devtools/record/formatters.py +++ b/lib/lsp-devtools/lsp_devtools/record/formatters.py @@ -1,3 +1,4 @@ +import json import re from typing import Any from typing import Callable @@ -16,6 +17,13 @@ cache = lru_cache(None) +def format_json(obj: dict) -> str: + if isinstance(obj, str): + return obj + + return json.dumps(obj, indent=2) + + def format_position(position: dict) -> str: return f"{position['line']}:{position['character']}" @@ -147,7 +155,7 @@ class FormatString: VARIABLE = re.compile(r"{\.([^}]+)}") def __init__(self, pattern: str): - self.pattern = pattern + self.pattern = pattern # .replace("\\n", "\n").replace("\\t", "\t") self._parse() def _parse(self): @@ -164,7 +172,7 @@ def _parse(self): formatter = get_formatter(fmt) else: accessor = variable - formatter = str + formatter = format_json parts.append(Value(accessor=accessor, formatter=formatter)) idx = end diff --git a/lib/lsp-devtools/lsp_devtools/tui/__init__.py b/lib/lsp-devtools/lsp_devtools/tui/__init__.py deleted file mode 100644 index 6d8c6a8..0000000 --- a/lib/lsp-devtools/lsp_devtools/tui/__init__.py +++ /dev/null @@ -1,264 +0,0 @@ -import argparse -import asyncio -import json -import pathlib -import threading -from datetime import datetime -from typing import Any -from typing import Dict -from typing import List -from typing import Optional -from typing import Tuple - -import aiosqlite -import appdirs -from rich.highlighter import ReprHighlighter -from rich.text import Text -from textual import events -from textual.app import App -from textual.app import ComposeResult -from textual.containers import Container -from textual.events import Ready -from textual.widgets import DataTable -from textual.widgets import Footer -from textual.widgets import Header -from textual.widgets import Tree -from textual.widgets.tree import TreeNode - -from lsp_devtools.record import setup_filter_args - -from .client import Ping -from .client import TUIAgentClient -from .client import connect_to_agent - - -class ObjectViewer(Tree): - """Used to inspect the fields of an object.""" - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.highlighter = ReprHighlighter() - - def set_object(self, name: str, obj: Any): - self.clear() - self.root.set_label(name) - self.walk_object(name, self.root, obj) - - def walk_object(self, label: str, node: TreeNode, obj: Any): - if isinstance(obj, dict): - node.expand() - for field, value in obj.items(): - child = node.add(field) - self.walk_object(field, child, value) - - elif isinstance(obj, list): - node.expand() - for idx, value in enumerate(obj): - child_label = self.highlighter(str(idx)) - child = node.add(child_label) - self.walk_object(str(idx), child, value) - - else: - node.allow_expand = False - node.set_label(Text.assemble(label, " = ", self.highlighter(repr(obj)))) - - -RPCData = Dict[int, Tuple[Optional[str], Optional[str], Optional[str]]] - - -class MessagesTable(DataTable): - """Datatable used to display all messages between client and server""" - - def __init__(self, dbpath: pathlib.Path, viewer: ObjectViewer): - super().__init__() - - self.dbpath = dbpath - self.dbquery = "SELECT rowid, * FROM protocol WHERE rowid > ?" - self.rpcdata: RPCData = {} - self.max_row = -1 - - self.viewer = viewer - - self.cursor_type = "row" - - self.add_column("") - self.add_column("Time") - self.add_column("Source") - self.add_column("ID") - self.add_column("Method") - - def on_key(self, event: events.Key): - if event.key != "enter": - return - - rowid = int(self.get_row_at(self.cursor_row)[0]) - params, result, error = self.rpcdata[rowid] - - if params: - name = "params" - message = json.loads(params) - - elif result: - name = "result" - message = json.loads(result) - - elif error: - name = "error" - message = json.loads(error) - - else: - name = "data" - message = {} - - self.viewer.set_object(name, message) - - async def update(self): - """Trigger a re-run of the query to pull in new data.""" - - async with aiosqlite.connect(self.dbpath) as conn: - async with conn.execute(self.dbquery, (self.max_row,)) as cursor: - async for row in cursor: - rowid = row[0] - timestamp = row[2] - source = row[3] - id_ = row[4] - method = row[5] - params = row[6] - result = row[7] - error = row[8] - - self.rpcdata[rowid] = (params, result, error) - - # Surely there's a more direct way to do this? - dt = datetime.fromtimestamp(timestamp) - time = dt.isoformat(timespec="milliseconds") - time = time[time.find("T") + 1 :] - - self.add_row(str(rowid), time, source, id_, method) - self.max_row = rowid - - -class Sidebar(Container): - pass - - -class LSPInspector(App): - CSS_PATH = pathlib.Path(__file__).parent / "app.css" - BINDINGS = [("ctrl+b", "toggle_sidebar", "Sidebar"), ("q", "quit", "Quit")] - - def __init__(self, dbpath: pathlib.Path, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.dbpath = dbpath - """Where the data for the app is being held""" - - self.client: Optional[TUIAgentClient] = None - """Client used to interact with the LSPAgent hosting the server we're - inspecting.""" - - self.loop: Optional[asyncio.AbstractEventLoop] = None - """Accessed by the AgentClient to push messages into the UI""" - - def compose(self) -> ComposeResult: - yield Header() - - viewer = ObjectViewer("") - messages = MessagesTable(self.dbpath, viewer) - - yield Container(messages, Sidebar(viewer)) - yield Footer() - - def action_toggle_sidebar(self) -> None: - sidebar = self.query_one(Sidebar) - self.set_focus(None) - - if sidebar.has_class("-hidden"): - sidebar.remove_class("-hidden") - else: - if sidebar.query("*:focus"): - self.screen.set_focus(None) - sidebar.add_class("-hidden") - - async def on_ready(self, event: Ready): - self.loop = asyncio.get_running_loop() - await self.update_table() - - async def on_ping(self, message: Ping): - """Fired when the agent client receives new messages""" - await self.update_table() - - async def update_table(self): - table = self.query_one(MessagesTable) - await table.update() - - async def action_quit(self): - if self.client: - self.client._stop_event.set() - await super().action_quit() - - -def start_client(client, host, port): - try: - client.start_ws_client(host, port) - except Exception: - # TODO: Surface the error somehow - pass - - -def tui(args, extra: List[str]): - dbpath = args.to_sqlite - if not dbpath.parent.exists(): - dbpath.parent.mkdir(parents=True) - - app = LSPInspector(dbpath) - client = connect_to_agent(args, app) - app.client = client - - agent_thread = threading.Thread( - name="AgentClient", target=start_client, args=(client, args.host, args.port) - ) - agent_thread.start() - - app.run() - agent_thread.join() - - -def cli(commands: argparse._SubParsersAction): - cmd: argparse.ArgumentParser = commands.add_parser( - "tui", - help="launch TUI", - description="""\ -This command opens a text user interface that can be used to inspect and -manipulate an LSP session interactively. -""", - ) - - default_db = pathlib.Path( - appdirs.user_cache_dir(appname="lsp-devtools", appauthor="swyddfa"), - "sessions.db", - ) - cmd.add_argument( - "--dbpath", - type=pathlib.Path, - metavar="DB", - default=default_db, - dest="to_sqlite", # to be compatible with code borrowed from record command. - help="the database path to use", - ) - - connect = cmd.add_argument_group( - title="connection options", - description="options that control the connection to the LSP Agent.", - ) - connect.add_argument( - "--host", - type=str, - default="localhost", - help="the host that is hosting the agent.", - ) - connect.add_argument( - "-p", "--port", type=int, default=8765, help="the port to connect to." - ) - - setup_filter_args(cmd) - cmd.set_defaults(run=tui) diff --git a/lib/lsp-devtools/lsp_devtools/tui/client.py b/lib/lsp-devtools/lsp_devtools/tui/client.py deleted file mode 100644 index 87c576f..0000000 --- a/lib/lsp-devtools/lsp_devtools/tui/client.py +++ /dev/null @@ -1,54 +0,0 @@ -import asyncio -import logging -import typing -from functools import partial - -from textual.message import Message - -from lsp_devtools.agent import MESSAGE_TEXT_NOTIFICATION -from lsp_devtools.agent import AgentClient -from lsp_devtools.agent import MessageText -from lsp_devtools.agent import parse_rpc_message -from lsp_devtools.record import logger -from lsp_devtools.record import setup_sqlite_output - -if typing.TYPE_CHECKING: - from . import LSPInspector - - -class Ping(Message): - """Sent when the UI needs a refresh.""" - - -class TUIAgentClient(AgentClient): - def __init__(self, app: "LSPInspector"): - self.app = app - super().__init__() - - -def log_message(ls: TUIAgentClient, source: str, message: dict): - logger.info("%s", message, extra={"source": source}) - app = ls.app - - # The event loop only becomes available once the on_ready event has fired - # and the UI has bootstrapped itself. So it's possible we recevie - # messages before the app is ready to accept them. - if app.loop is None: - return - - asyncio.run_coroutine_threadsafe(app.post_message(Ping(app)), app.loop) - - -def recv_message(ls: TUIAgentClient, message: MessageText): - logfn = partial(log_message, ls, message.source) - parse_rpc_message(ls, message, logfn) - - -def connect_to_agent(args, app: "LSPInspector"): - client = TUIAgentClient(app) - client.feature(MESSAGE_TEXT_NOTIFICATION)(recv_message) - - logger.setLevel(logging.INFO) - setup_sqlite_output(args) - - return client diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index 19c7100..1d37dfc 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -7,7 +7,7 @@ name = "lsp-devtools" version = "0.1.1" description = "Developer tooling for language servers" readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" license = { text = "MIT" } authors = [{ name = "Alex Carney", email = "alcarneyme@gmail.com" }] classifiers = [ @@ -16,18 +16,19 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dependencies = [ - "appdirs", "aiosqlite", "importlib-resources; python_version<\"3.9\"", - "pygls[ws]", - "textual>=0.14.0", + "platformdirs", + "pygls>=1.1.0", + "stamina", + "textual>=0.38.0", "typing-extensions; python_version<\"3.8\"", ] @@ -43,14 +44,9 @@ dev = [ "pre-commit", "tox", ] -test=[ - "pytest-cov", - "pytest-timeout", -] typecheck=[ "mypy", "importlib_resources", - "types-appdirs", "types-setuptools", ] prometheus = ["prometheus_client"] @@ -61,6 +57,14 @@ lsp-devtools = "lsp_devtools.cli:main" [tool.setuptools.packages.find] include = ["lsp_devtools*"] +[tool.coverage.run] +source_pkgs = ["lsp_devtools"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +sort = "Cover" + [tool.isort] force_single_line = true profile = "black" @@ -72,45 +76,11 @@ title_format = "v{version} - {project_date}" issue_format = "`#{issue} `_" underlines = ["-", "^", "\""] -[[tool.towncrier.type]] -directory = "feature" -name = "Features" -showcontent = true - -[[tool.towncrier.type]] -directory = "fix" -name = "Fixes" -showcontent = true - -[[tool.towncrier.type]] -directory = "doc" -name = "Docs" -showcontent = true - -[[tool.towncrier.type]] -directory = "breaking" -name = "Breaking Changes" -showcontent = true - -[[tool.towncrier.type]] -directory = "deprecated" -name = "Deprecated" -showcontent = true - -[[tool.towncrier.type]] -directory = "misc" -name = "Misc" -showcontent = true - -[tool.tox] -legacy_tox_ini = """ -[tox] -isolated_build = True -skip_missing_interpreters = true -envlist = py{37,38,39,310,311} - -[testenv] -extras=test -commands = - pytest {posargs} -""" +type = [ + { name = "Features", directory = "feature", showcontent = true }, + { name = "Fixes", directory = "fix", showcontent = true }, + { name = "Docs", directory = "doc", showcontent = true }, + { name = "Breaking Changes", directory = "breaking", showcontent = true }, + { name = "Deprecated", directory = "deprecated", showcontent = true }, + { name = "Misc", directory = "misc", showcontent = true }, +] diff --git a/lib/lsp-devtools/pyrightconfig.json b/lib/lsp-devtools/pyrightconfig.json new file mode 100644 index 0000000..0622ac8 --- /dev/null +++ b/lib/lsp-devtools/pyrightconfig.json @@ -0,0 +1,3 @@ +{ + "venv": ".env" +} diff --git a/lib/lsp-devtools/tests/record/test_filters.py b/lib/lsp-devtools/tests/record/test_filters.py index d75deac..6c01f0f 100644 --- a/lib/lsp-devtools/tests/record/test_filters.py +++ b/lib/lsp-devtools/tests/record/test_filters.py @@ -384,7 +384,5 @@ def test_filter_format_message(): record = logging.LogRecord("example", logging.INFO, "", 0, "%s", request, None) record.__dict__["source"] = "client" - lsp.filter(record) assert lsp.filter(record) is True - assert record.msg == "file:///path/to/file.txt" diff --git a/lib/lsp-devtools/tests/record/test_formatters.py b/lib/lsp-devtools/tests/record/test_formatters.py index d6e6ca7..c6f7066 100644 --- a/lib/lsp-devtools/tests/record/test_formatters.py +++ b/lib/lsp-devtools/tests/record/test_formatters.py @@ -27,7 +27,7 @@ "textDocument": {"uri": "file:///path/to/file.txt"}, }, }, - "textDocument/completion file:///path/to/file.txt:{'line': 1, 'character': 2}", # noqa: E501 + 'textDocument/completion file:///path/to/file.txt:{\n "line": 1,\n "character": 2\n}', # noqa: E501 ), ( "{.method} {.params.textDocument.uri}:{.params.position|Position}", @@ -61,7 +61,7 @@ "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] } }, - "{'label': 'one'}\n{'label': 'two'}\n{'label': 'three'}", + '{\n "label": "one"\n}\n{\n "label": "two"\n}\n{\n "label": "three"\n}', ), ( "{.result.items[].label}", @@ -88,7 +88,7 @@ "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] } }, - "{'label': 'one'}", + '{\n "label": "one"\n}', ), ( "{.result.items[-1]}", @@ -97,7 +97,7 @@ "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] } }, - "{'label': 'three'}", + '{\n "label": "three"\n}', ), ( "- {.result.items[0].label}", diff --git a/lib/lsp-devtools/tox.ini b/lib/lsp-devtools/tox.ini new file mode 100644 index 0000000..223b1c1 --- /dev/null +++ b/lib/lsp-devtools/tox.ini @@ -0,0 +1,21 @@ +[tox] +isolated_build = True +skip_missing_interpreters = true +min_version = 4.0 +envlist = py{38,39,310,311} + +[testenv] +description = "Run lsp-devtools' test suite" +package = wheel +wheel_build_env = .pkg +deps = + coverage[toml] + pytest + + git+https://github.com/openlawlibrary/pygls +commands_pre = + coverage erase +commands = + coverage run -m pytest {posargs} +commands_post = + coverage report diff --git a/lib/pytest-lsp/.gitignore b/lib/pytest-lsp/.gitignore new file mode 100644 index 0000000..6350e98 --- /dev/null +++ b/lib/pytest-lsp/.gitignore @@ -0,0 +1 @@ +.coverage diff --git a/lib/pytest-lsp/changes/61.enhancement.rst b/lib/pytest-lsp/changes/61.enhancement.rst new file mode 100644 index 0000000..b3deb12 --- /dev/null +++ b/lib/pytest-lsp/changes/61.enhancement.rst @@ -0,0 +1,2 @@ +pytest-lsp's ``LanguageClient`` is now based on the one provided by ``pygls``. +The main benefit is that the server connection is now based on an ``asyncio.subprocess.Process`` removing the need for pytest-lsp to constantly check to see if the server is still running. diff --git a/lib/pytest-lsp/changes/71.enhancement.rst b/lib/pytest-lsp/changes/71.enhancement.rst new file mode 100644 index 0000000..d1d97a9 --- /dev/null +++ b/lib/pytest-lsp/changes/71.enhancement.rst @@ -0,0 +1 @@ +Fixtures created with the `@pytest_lsp.fixture` decorator can now request additional pytest fixtures diff --git a/lib/pytest-lsp/changes/72.enhancement.rst b/lib/pytest-lsp/changes/72.enhancement.rst new file mode 100644 index 0000000..5afc834 --- /dev/null +++ b/lib/pytest-lsp/changes/72.enhancement.rst @@ -0,0 +1 @@ +It is now possible to set the environment variables that the server under test is launched with. diff --git a/lib/pytest-lsp/changes/73.enhancement.rst b/lib/pytest-lsp/changes/73.enhancement.rst new file mode 100644 index 0000000..dbe479e --- /dev/null +++ b/lib/pytest-lsp/changes/73.enhancement.rst @@ -0,0 +1,2 @@ +It is now possible to test any JSON-RPC based server with ``pytest-lsp``. +Note however, this support will only ever extend to managing the client-server connection. diff --git a/lib/pytest-lsp/changes/73.misc.rst b/lib/pytest-lsp/changes/73.misc.rst new file mode 100644 index 0000000..e4049c7 --- /dev/null +++ b/lib/pytest-lsp/changes/73.misc.rst @@ -0,0 +1 @@ +``make_test_client`` has been renamed to ``make_test_lsp_client`` diff --git a/lib/pytest-lsp/changes/75.misc.rst b/lib/pytest-lsp/changes/75.misc.rst new file mode 100644 index 0000000..99f8272 --- /dev/null +++ b/lib/pytest-lsp/changes/75.misc.rst @@ -0,0 +1 @@ +Drop support for Python 3.7, add support for Python 3.12 diff --git a/lib/pytest-lsp/flake.lock b/lib/pytest-lsp/flake.lock index f14cad0..5b38e8d 100644 --- a/lib/pytest-lsp/flake.lock +++ b/lib/pytest-lsp/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1680273054, - "narHash": "sha256-Bs6/5LpvYp379qVqGt9mXxxx9GSE789k3oFc+OAL07M=", + "lastModified": 1688556768, + "narHash": "sha256-mhd6g0iJGjEfOr3+6mZZOclUveeNr64OwxdbNtLc8mY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3364b5b117f65fe1ce65a3cdd5612a078a3b31e3", + "rev": "27bd67e55fe09f9d68c77ff151c3e44c4f81f7de", "type": "github" }, "original": { @@ -22,13 +22,31 @@ "utils": "utils" } }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, "utils": { + "inputs": { + "systems": "systems" + }, "locked": { - "lastModified": 1678901627, - "narHash": "sha256-U02riOqrKKzwjsxc/400XnElV+UtPUQWpANPlyazjH0=", + "lastModified": 1687709756, + "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=", "owner": "numtide", "repo": "flake-utils", - "rev": "93a2b84fc4b70d9e089d029deacc3583435c2ed6", + "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7", "type": "github" }, "original": { diff --git a/lib/pytest-lsp/flake.nix b/lib/pytest-lsp/flake.nix index d847fa3..0812115 100644 --- a/lib/pytest-lsp/flake.nix +++ b/lib/pytest-lsp/flake.nix @@ -20,18 +20,18 @@ let pkgs = import nixpkgs { inherit system; overlays = [ pytest-lsp-overlay ]; }; in - eachPythonVersion [ "37" "38" "39" "310" "311" ] (pyVersion: - - - let - pytest-lsp = pkgs."python${pyVersion}Packages".pytest-lsp.overridePythonAttrs (_: { doCheck = false; }); - in - + eachPythonVersion [ "38" "39" "310" "311" ] (pyVersion: with pkgs; mkShell { name = "py${pyVersion}"; + shellHook = '' + export PYTHONPATH="./:$PYTHONPATH" + ''; + packages = with pkgs."python${pyVersion}Packages"; [ - pytest-lsp + pygls + pytest + pytest-asyncio ]; } ) diff --git a/lib/pytest-lsp/nix/pytest-lsp-overlay.nix b/lib/pytest-lsp/nix/pytest-lsp-overlay.nix index 31242ad..b971989 100644 --- a/lib/pytest-lsp/nix/pytest-lsp-overlay.nix +++ b/lib/pytest-lsp/nix/pytest-lsp-overlay.nix @@ -1,13 +1,58 @@ final: prev: { pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [( python-final: python-prev: { + + # TODO: Remove once https://github.com/NixOS/nixpkgs/pull/233870 is merged + typeguard = python-prev.typeguard.overridePythonAttrs (oldAttrs: rec { + version = "3.0.2"; + format = "pyproject"; + + src = prev.fetchPypi { + inherit version; + pname = oldAttrs.pname; + sha256 = "sha256-/uUpf9so+Onvy4FCte4hngI3VQnNd+qdJwta+CY1jVo="; + }; + + propagatedBuildInputs = with python-prev; [ + importlib-metadata + typing-extensions + ]; + + }); + + lsprotocol = python-prev.lsprotocol.overridePythonAttrs(oldAttrs: rec { + version = "2023.0.0a3"; + + src = prev.fetchFromGitHub { + rev = version; + owner = "microsoft"; + repo = oldAttrs.pname; + sha256 = "sha256-Q4jvUIMMaDX8mvdmRtYKHB2XbMEchygO2NMmMQdNkTc="; + }; + }); + + pygls = python-prev.pygls.overridePythonAttrs (_: { + format = "pyproject"; + + src = prev.fetchFromGitHub { + owner = "openlawlibrary"; + repo = "pygls"; + rev = "main"; + hash = "sha256-JpopfqeLNi23TuZ5mkPEShUPScd1fB0IDXSVGvDYFXE="; + }; + + nativeBuildInputs = with python-prev; [ + poetry-core + ]; + }); + pytest-lsp = python-prev.buildPythonPackage { pname = "pytest-lsp"; - version = "0.2.1"; + version = "0.3.0"; src = ./..; - propagatedBuildInputs = with python-prev; [ + propagatedBuildInputs = with python-final; [ pygls pytest pytest-asyncio diff --git a/lib/pytest-lsp/pyproject.toml b/lib/pytest-lsp/pyproject.toml index 29fec5a..a797d67 100644 --- a/lib/pytest-lsp/pyproject.toml +++ b/lib/pytest-lsp/pyproject.toml @@ -7,7 +7,7 @@ name = "pytest-lsp" version = "0.3.0" description = "pytest plugin for end-to-end testing of language servers" readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" license = { text = "MIT" } authors = [{ name = "Alex Carney", email = "alcarneyme@gmail.com" }] classifiers = [ @@ -17,15 +17,15 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dependencies = [ "importlib-resources; python_version<\"3.9\"", - "pygls>=1.0.0", + "pygls>=1.1.0", "pytest", "pytest-asyncio", ] @@ -58,6 +58,14 @@ typecheck = [ [tool.setuptools.packages.find] include = ["pytest_lsp*"] +[tool.coverage.run] +source_pkgs = ["pytest_lsp"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +sort = "Cover" + [tool.isort] force_single_line = true profile = "black" @@ -72,56 +80,13 @@ title_format = "v{version} - {project_date}" issue_format = "`#{issue} `_" underlines = ["-", "^", "\""] -[[tool.towncrier.type]] -directory = "feature" -name = "Features" -showcontent = true - -[[tool.towncrier.type]] -directory = "enhancement" -name = "Enhancements" -showcontent = true - -[[tool.towncrier.type]] -directory = "fix" -name = "Fixes" -showcontent = true - -[[tool.towncrier.type]] -directory = "doc" -name = "Docs" -showcontent = true - -[[tool.towncrier.type]] -directory = "breaking" -name = "Breaking Changes" -showcontent = true - -[[tool.towncrier.type]] -directory = "deprecated" -name = "Deprecated" -showcontent = true - -[[tool.towncrier.type]] -directory = "misc" -name = "Misc" -showcontent = true - -[[tool.towncrier.type]] -directory = "removed" -name = "Removed" -showcontent = true - - -[tool.tox] -legacy_tox_ini = """ -[tox] -isolated_build = True -skip_missing_interpreters = true -envlist = py{37,38,39,310,311} - -[testenv] -extras= dev -commands = - pytest {posargs} -""" +type = [ + { name = "Features", directory = "feature", showcontent = true }, + { name = "Enhancements", directory = "enhancement", showcontent = true }, + { name = "Fixes", directory = "fix", showcontent = true }, + { name = "Docs", directory = "doc", showcontent = true }, + { name = "Breaking Changes", directory = "breaking", showcontent = true }, + { name = "Deprecated", directory = "deprecated", showcontent = true }, + { name = "Misc", directory = "misc", showcontent = true }, + { name = "Removed", directory = "removed", showcontent = true }, +] diff --git a/lib/pytest-lsp/pyrightconfig.json b/lib/pytest-lsp/pyrightconfig.json new file mode 100644 index 0000000..0622ac8 --- /dev/null +++ b/lib/pytest-lsp/pyrightconfig.json @@ -0,0 +1,3 @@ +{ + "venv": ".env" +} diff --git a/lib/pytest-lsp/pytest_lsp/__init__.py b/lib/pytest-lsp/pytest_lsp/__init__.py index 86fef4a..c205546 100644 --- a/lib/pytest-lsp/pytest_lsp/__init__.py +++ b/lib/pytest-lsp/pytest_lsp/__init__.py @@ -2,26 +2,20 @@ from .client import LanguageClient from .client import __version__ from .client import client_capabilities -from .client import make_test_client -from .plugin import ClientServer +from .client import make_test_lsp_client from .plugin import ClientServerConfig from .plugin import fixture -from .plugin import make_client_server from .plugin import pytest_runtest_makereport -from .plugin import pytest_runtest_setup from .protocol import LanguageClientProtocol __all__ = [ "__version__", - "ClientServer", "ClientServerConfig", "LanguageClient", "LanguageClientProtocol", "LspSpecificationWarning", "client_capabilities", "fixture", - "make_client_server", - "make_test_client", + "make_test_lsp_client", "pytest_runtest_makereport", - "pytest_runtest_setup", ] diff --git a/lib/pytest-lsp/pytest_lsp/client.py b/lib/pytest-lsp/pytest_lsp/client.py index a9e9601..7a60d18 100644 --- a/lib/pytest-lsp/pytest_lsp/client.py +++ b/lib/pytest-lsp/pytest_lsp/client.py @@ -4,30 +4,18 @@ import os import sys import traceback -from typing import Any from typing import Dict from typing import List from typing import Optional -from typing import Type +from typing import Union +from lsprotocol import types from lsprotocol.converters import get_converter -from lsprotocol.types import TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS -from lsprotocol.types import WINDOW_LOG_MESSAGE -from lsprotocol.types import WINDOW_SHOW_DOCUMENT -from lsprotocol.types import WINDOW_SHOW_MESSAGE -from lsprotocol.types import ClientCapabilities -from lsprotocol.types import Diagnostic -from lsprotocol.types import InitializedParams -from lsprotocol.types import InitializeParams -from lsprotocol.types import InitializeResult -from lsprotocol.types import LogMessageParams -from lsprotocol.types import PublishDiagnosticsParams -from lsprotocol.types import ShowDocumentParams -from lsprotocol.types import ShowDocumentResult -from lsprotocol.types import ShowMessageParams +from pygls.exceptions import JsonRpcException +from pygls.exceptions import PyglsError +from pygls.lsp.client import BaseLanguageClient from pygls.protocol import default_converter -from .gen import Client from .protocol import LanguageClientProtocol if sys.version_info.minor < 9: @@ -40,26 +28,31 @@ logger = logging.getLogger(__name__) -class LanguageClient(Client): +class LanguageClient(BaseLanguageClient): """Used to drive language servers under test.""" + protocol: LanguageClientProtocol + def __init__(self, *args, **kwargs): + if "protocol_cls" not in kwargs: + kwargs["protocol_cls"] = LanguageClientProtocol + super().__init__("pytest-lsp-client", __version__, *args, **kwargs) - self.capabilities: Optional[ClientCapabilities] = None + self.capabilities: Optional[types.ClientCapabilities] = None """The client's capabilities.""" - self.shown_documents: List[ShowDocumentParams] = [] + self.shown_documents: List[types.ShowDocumentParams] = [] """Used to keep track of the documents requested to be shown via a ``window/showDocument`` request.""" - self.messages: List[ShowMessageParams] = [] + self.messages: List[types.ShowMessageParams] = [] """Holds any received ``window/showMessage`` requests.""" - self.log_messages: List[LogMessageParams] = [] + self.log_messages: List[types.LogMessageParams] = [] """Holds any received ``window/logMessage`` requests.""" - self.diagnostics: Dict[str, List[Diagnostic]] = {} + self.diagnostics: Dict[str, List[types.Diagnostic]] = {} """Used to hold any recieved diagnostics.""" self.error: Optional[Exception] = None @@ -71,24 +64,33 @@ def __init__(self, *args, **kwargs): self._last_log_index = 0 """Used to keep track of which log messages correspond with which test case.""" - def feature( - self, - feature_name: str, - options: Optional[Any] = None, - ): - return self.lsp.fm.feature(feature_name, options) + async def server_exit(self, server: asyncio.subprocess.Process): + """Called when the server process exits.""" + logger.debug("Server process exited with code: %s", server.returncode) - def _report_server_error(self, error: Exception, source: Type[Exception]): - # This may wind up being a mistake, but let's ignore broken pipe errors... - # If the server process has exited, the watchdog task will give us a better - # error message. - if isinstance(error, BrokenPipeError): + if self._stop_event.is_set(): return + stderr = "" + if server.stderr is not None: + stderr_bytes = await server.stderr.read() + stderr = stderr_bytes.decode("utf8") + + loop = asyncio.get_running_loop() + loop.call_soon( + cancel_all_tasks, + f"Server process exited with return code: {server.returncode}\n{stderr}", + ) + + def report_server_error( + self, error: Exception, source: Union[PyglsError, JsonRpcException] + ): + """Called when the server does something unexpected, e.g. sending malformed + JSON.""" self.error = error tb = "".join(traceback.format_exc()) - message = f"{source.__name__}: {error}\n{tb}" + message = f"{source.__name__}: {error}\n{tb}" # type: ignore loop = asyncio.get_running_loop() loop.call_soon(cancel_all_tasks, message) @@ -96,7 +98,9 @@ def _report_server_error(self, error: Exception, source: Type[Exception]): if self._stop_event: self._stop_event.set() - async def initialize_session(self, params: InitializeParams) -> InitializeResult: + async def initialize_session( + self, params: types.InitializeParams + ) -> types.InitializeResult: """Make an ``initialize`` request to a lanaguage server. It will also automatically send an ``initialized`` notification once @@ -122,7 +126,7 @@ async def initialize_session(self, params: InitializeParams) -> InitializeResult params.process_id = os.getpid() response = await self.initialize_async(params) - self.initialized(InitializedParams()) + self.initialized(types.InitializedParams()) return response @@ -141,7 +145,7 @@ async def shutdown_session(self) -> None: if self.error is not None or self.capabilities is None: return - await self.shutdown_request_async(None) + await self.shutdown_async(None) self.exit(None) async def wait_for_notification(self, method: str): @@ -152,7 +156,7 @@ async def wait_for_notification(self, method: str): method The notification method to wait for, e.g. ``textDocument/publishDiagnostics`` """ - return await self.lsp.wait_for_notification_async(method) + return await self.protocol.wait_for_notification_async(method) def cancel_all_tasks(message: str): @@ -165,42 +169,42 @@ def cancel_all_tasks(message: str): task.cancel(message) -def make_test_client() -> LanguageClient: +def make_test_lsp_client() -> LanguageClient: """Construct a new test client instance with the handlers needed to capture additional responses from the server.""" client = LanguageClient( - protocol_cls=LanguageClientProtocol, converter_factory=default_converter, - loop=asyncio.get_running_loop(), ) - @client.feature(TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) - def publish_diagnostics(client: LanguageClient, params: PublishDiagnosticsParams): + @client.feature(types.TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) + def publish_diagnostics( + client: LanguageClient, params: types.PublishDiagnosticsParams + ): client.diagnostics[params.uri] = params.diagnostics - @client.feature(WINDOW_LOG_MESSAGE) - def log_message(client: LanguageClient, params: LogMessageParams): + @client.feature(types.WINDOW_LOG_MESSAGE) + def log_message(client: LanguageClient, params: types.LogMessageParams): client.log_messages.append(params) levels = [logger.error, logger.warning, logger.info, logger.debug] levels[params.type.value - 1](params.message) - @client.feature(WINDOW_SHOW_MESSAGE) + @client.feature(types.WINDOW_SHOW_MESSAGE) def show_message(client: LanguageClient, params): client.messages.append(params) - @client.feature(WINDOW_SHOW_DOCUMENT) + @client.feature(types.WINDOW_SHOW_DOCUMENT) def show_document( - client: LanguageClient, params: ShowDocumentParams - ) -> ShowDocumentResult: + client: LanguageClient, params: types.ShowDocumentParams + ) -> types.ShowDocumentResult: client.shown_documents.append(params) - return ShowDocumentResult(success=True) + return types.ShowDocumentResult(success=True) return client -def client_capabilities(client_spec: str) -> ClientCapabilities: +def client_capabilities(client_spec: str) -> types.ClientCapabilities: """Find the capabilities that correspond to the given client spec. Parameters @@ -230,4 +234,4 @@ def client_capabilities(client_spec: str) -> ClientCapabilities: converter = get_converter() capabilities = json.loads(filename.read_text()) - return converter.structure(capabilities, ClientCapabilities) + return converter.structure(capabilities, types.ClientCapabilities) diff --git a/lib/pytest-lsp/pytest_lsp/gen.py b/lib/pytest-lsp/pytest_lsp/gen.py deleted file mode 100644 index 9d155dc..0000000 --- a/lib/pytest-lsp/pytest_lsp/gen.py +++ /dev/null @@ -1,1702 +0,0 @@ -# GENERATED FROM scripts/gen-client.py -- DO NOT EDIT -# Last Modified: 2023-04-06 19:06:16.290214 -# flake8: noqa -from concurrent.futures import Future -from lsprotocol.types import CallHierarchyIncomingCall -from lsprotocol.types import CallHierarchyIncomingCallsParams -from lsprotocol.types import CallHierarchyItem -from lsprotocol.types import CallHierarchyOutgoingCall -from lsprotocol.types import CallHierarchyOutgoingCallsParams -from lsprotocol.types import CallHierarchyPrepareParams -from lsprotocol.types import CancelParams -from lsprotocol.types import CodeAction -from lsprotocol.types import CodeActionParams -from lsprotocol.types import CodeLens -from lsprotocol.types import CodeLensParams -from lsprotocol.types import ColorInformation -from lsprotocol.types import ColorPresentation -from lsprotocol.types import ColorPresentationParams -from lsprotocol.types import Command -from lsprotocol.types import CompletionItem -from lsprotocol.types import CompletionList -from lsprotocol.types import CompletionParams -from lsprotocol.types import CreateFilesParams -from lsprotocol.types import DeclarationParams -from lsprotocol.types import DefinitionParams -from lsprotocol.types import DeleteFilesParams -from lsprotocol.types import DidChangeConfigurationParams -from lsprotocol.types import DidChangeNotebookDocumentParams -from lsprotocol.types import DidChangeTextDocumentParams -from lsprotocol.types import DidChangeWatchedFilesParams -from lsprotocol.types import DidChangeWorkspaceFoldersParams -from lsprotocol.types import DidCloseNotebookDocumentParams -from lsprotocol.types import DidCloseTextDocumentParams -from lsprotocol.types import DidOpenNotebookDocumentParams -from lsprotocol.types import DidOpenTextDocumentParams -from lsprotocol.types import DidSaveNotebookDocumentParams -from lsprotocol.types import DidSaveTextDocumentParams -from lsprotocol.types import DocumentColorParams -from lsprotocol.types import DocumentDiagnosticParams -from lsprotocol.types import DocumentFormattingParams -from lsprotocol.types import DocumentHighlight -from lsprotocol.types import DocumentHighlightParams -from lsprotocol.types import DocumentLink -from lsprotocol.types import DocumentLinkParams -from lsprotocol.types import DocumentOnTypeFormattingParams -from lsprotocol.types import DocumentRangeFormattingParams -from lsprotocol.types import DocumentSymbol -from lsprotocol.types import DocumentSymbolParams -from lsprotocol.types import ExecuteCommandParams -from lsprotocol.types import FoldingRange -from lsprotocol.types import FoldingRangeParams -from lsprotocol.types import Hover -from lsprotocol.types import HoverParams -from lsprotocol.types import ImplementationParams -from lsprotocol.types import InitializeParams -from lsprotocol.types import InitializeResult -from lsprotocol.types import InitializedParams -from lsprotocol.types import InlayHint -from lsprotocol.types import InlayHintParams -from lsprotocol.types import InlineValueEvaluatableExpression -from lsprotocol.types import InlineValueParams -from lsprotocol.types import InlineValueText -from lsprotocol.types import InlineValueVariableLookup -from lsprotocol.types import LinkedEditingRangeParams -from lsprotocol.types import LinkedEditingRanges -from lsprotocol.types import Location -from lsprotocol.types import LocationLink -from lsprotocol.types import Moniker -from lsprotocol.types import MonikerParams -from lsprotocol.types import PrepareRenameParams -from lsprotocol.types import PrepareRenameResult_Type1 -from lsprotocol.types import PrepareRenameResult_Type2 -from lsprotocol.types import ProgressParams -from lsprotocol.types import Range -from lsprotocol.types import ReferenceParams -from lsprotocol.types import RelatedFullDocumentDiagnosticReport -from lsprotocol.types import RelatedUnchangedDocumentDiagnosticReport -from lsprotocol.types import RenameFilesParams -from lsprotocol.types import RenameParams -from lsprotocol.types import SelectionRange -from lsprotocol.types import SelectionRangeParams -from lsprotocol.types import SemanticTokens -from lsprotocol.types import SemanticTokensDelta -from lsprotocol.types import SemanticTokensDeltaParams -from lsprotocol.types import SemanticTokensParams -from lsprotocol.types import SemanticTokensRangeParams -from lsprotocol.types import SetTraceParams -from lsprotocol.types import SignatureHelp -from lsprotocol.types import SignatureHelpParams -from lsprotocol.types import SymbolInformation -from lsprotocol.types import TextEdit -from lsprotocol.types import TypeDefinitionParams -from lsprotocol.types import TypeHierarchyItem -from lsprotocol.types import TypeHierarchyPrepareParams -from lsprotocol.types import TypeHierarchySubtypesParams -from lsprotocol.types import TypeHierarchySupertypesParams -from lsprotocol.types import WillSaveTextDocumentParams -from lsprotocol.types import WorkDoneProgressCancelParams -from lsprotocol.types import WorkspaceDiagnosticParams -from lsprotocol.types import WorkspaceDiagnosticReport -from lsprotocol.types import WorkspaceEdit -from lsprotocol.types import WorkspaceSymbol -from lsprotocol.types import WorkspaceSymbolParams -from pygls.protocol import LanguageServerProtocol -from pygls.protocol import default_converter -from pygls.server import Server -from typing import Any -from typing import Callable -from typing import List -from typing import Optional -from typing import Union - - -class Client(Server): - - def __init__( - self, - name: str, - version: str, - protocol_cls=LanguageServerProtocol, - converter_factory=default_converter, - **kwargs, - ): - self.name = name - self.version = version - super().__init__(protocol_cls, converter_factory, **kwargs) - - def call_hierarchy_incoming_calls( - self, - params: CallHierarchyIncomingCallsParams, - callback: Optional[Callable[[Optional[List[CallHierarchyIncomingCall]]], None]] = None, - ) -> Future: - """Make a ``callHierarchy/incomingCalls`` request. - - A request to resolve the incoming calls for a given `CallHierarchyItem`. - - @since 3.16.0 - """ - return self.lsp.send_request("callHierarchy/incomingCalls", params, callback) - - async def call_hierarchy_incoming_calls_async( - self, - params: CallHierarchyIncomingCallsParams, - ) -> Optional[List[CallHierarchyIncomingCall]]: - """Make a ``callHierarchy/incomingCalls`` request. - - A request to resolve the incoming calls for a given `CallHierarchyItem`. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("callHierarchy/incomingCalls", params) - - def call_hierarchy_outgoing_calls( - self, - params: CallHierarchyOutgoingCallsParams, - callback: Optional[Callable[[Optional[List[CallHierarchyOutgoingCall]]], None]] = None, - ) -> Future: - """Make a ``callHierarchy/outgoingCalls`` request. - - A request to resolve the outgoing calls for a given `CallHierarchyItem`. - - @since 3.16.0 - """ - return self.lsp.send_request("callHierarchy/outgoingCalls", params, callback) - - async def call_hierarchy_outgoing_calls_async( - self, - params: CallHierarchyOutgoingCallsParams, - ) -> Optional[List[CallHierarchyOutgoingCall]]: - """Make a ``callHierarchy/outgoingCalls`` request. - - A request to resolve the outgoing calls for a given `CallHierarchyItem`. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("callHierarchy/outgoingCalls", params) - - def code_action_resolve( - self, - params: CodeAction, - callback: Optional[Callable[[CodeAction], None]] = None, - ) -> Future: - """Make a ``codeAction/resolve`` request. - - Request to resolve additional information for a given code action.The - request's parameter is of type {@link CodeAction} the response is of type. - - {@link CodeAction} or a Thenable that resolves to such. - """ - return self.lsp.send_request("codeAction/resolve", params, callback) - - async def code_action_resolve_async( - self, - params: CodeAction, - ) -> CodeAction: - """Make a ``codeAction/resolve`` request. - - Request to resolve additional information for a given code action.The - request's parameter is of type {@link CodeAction} the response is of type. - - {@link CodeAction} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("codeAction/resolve", params) - - def code_lens_resolve( - self, - params: CodeLens, - callback: Optional[Callable[[CodeLens], None]] = None, - ) -> Future: - """Make a ``codeLens/resolve`` request. - - A request to resolve a command for a given code lens. - """ - return self.lsp.send_request("codeLens/resolve", params, callback) - - async def code_lens_resolve_async( - self, - params: CodeLens, - ) -> CodeLens: - """Make a ``codeLens/resolve`` request. - - A request to resolve a command for a given code lens. - """ - return await self.lsp.send_request_async("codeLens/resolve", params) - - def completion_item_resolve( - self, - params: CompletionItem, - callback: Optional[Callable[[CompletionItem], None]] = None, - ) -> Future: - """Make a ``completionItem/resolve`` request. - - Request to resolve additional information for a given completion - item.The request's parameter is of type {@link CompletionItem} the response - is of type {@link CompletionItem} or a Thenable that resolves to such. - """ - return self.lsp.send_request("completionItem/resolve", params, callback) - - async def completion_item_resolve_async( - self, - params: CompletionItem, - ) -> CompletionItem: - """Make a ``completionItem/resolve`` request. - - Request to resolve additional information for a given completion - item.The request's parameter is of type {@link CompletionItem} the response - is of type {@link CompletionItem} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("completionItem/resolve", params) - - def document_link_resolve( - self, - params: DocumentLink, - callback: Optional[Callable[[DocumentLink], None]] = None, - ) -> Future: - """Make a ``documentLink/resolve`` request. - - Request to resolve additional information for a given document link. - - The request's parameter is of type {@link DocumentLink} the response - is of type {@link DocumentLink} or a Thenable that resolves to such. - """ - return self.lsp.send_request("documentLink/resolve", params, callback) - - async def document_link_resolve_async( - self, - params: DocumentLink, - ) -> DocumentLink: - """Make a ``documentLink/resolve`` request. - - Request to resolve additional information for a given document link. - - The request's parameter is of type {@link DocumentLink} the response - is of type {@link DocumentLink} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("documentLink/resolve", params) - - def initialize( - self, - params: InitializeParams, - callback: Optional[Callable[[InitializeResult], None]] = None, - ) -> Future: - """Make a ``initialize`` request. - - The initialize request is sent from the client to the server. - - It is sent once as the request after starting up the server. The - requests parameter is of type {@link InitializeParams} the response - if of type {@link InitializeResult} of a Thenable that resolves to - such. - """ - return self.lsp.send_request("initialize", params, callback) - - async def initialize_async( - self, - params: InitializeParams, - ) -> InitializeResult: - """Make a ``initialize`` request. - - The initialize request is sent from the client to the server. - - It is sent once as the request after starting up the server. The - requests parameter is of type {@link InitializeParams} the response - if of type {@link InitializeResult} of a Thenable that resolves to - such. - """ - return await self.lsp.send_request_async("initialize", params) - - def inlay_hint_resolve( - self, - params: InlayHint, - callback: Optional[Callable[[InlayHint], None]] = None, - ) -> Future: - """Make a ``inlayHint/resolve`` request. - - A request to resolve additional properties for an inlay hint. The - request's parameter is of type {@link InlayHint}, the response is of type. - - {@link InlayHint} or a Thenable that resolves to such. - - @since 3.17.0 - """ - return self.lsp.send_request("inlayHint/resolve", params, callback) - - async def inlay_hint_resolve_async( - self, - params: InlayHint, - ) -> InlayHint: - """Make a ``inlayHint/resolve`` request. - - A request to resolve additional properties for an inlay hint. The - request's parameter is of type {@link InlayHint}, the response is of type. - - {@link InlayHint} or a Thenable that resolves to such. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("inlayHint/resolve", params) - - def shutdown_request( - self, - params: None, - callback: Optional[Callable[[None], None]] = None, - ) -> Future: - """Make a ``shutdown`` request. - - A shutdown request is sent from the client to the server. - - It is sent once when the client decides to shutdown the server. The - only notification that is sent after a shutdown request is the exit - event. - """ - return self.lsp.send_request("shutdown", params, callback) - - async def shutdown_request_async( - self, - params: None, - ) -> None: - """Make a ``shutdown`` request. - - A shutdown request is sent from the client to the server. - - It is sent once when the client decides to shutdown the server. The - only notification that is sent after a shutdown request is the exit - event. - """ - return await self.lsp.send_request_async("shutdown", params) - - def text_document_code_action( - self, - params: CodeActionParams, - callback: Optional[Callable[[Optional[List[Union[Command, CodeAction]]]], None]] = None, - ) -> Future: - """Make a ``textDocument/codeAction`` request. - - A request to provide commands for the given text document and range. - """ - return self.lsp.send_request("textDocument/codeAction", params, callback) - - async def text_document_code_action_async( - self, - params: CodeActionParams, - ) -> Optional[List[Union[Command, CodeAction]]]: - """Make a ``textDocument/codeAction`` request. - - A request to provide commands for the given text document and range. - """ - return await self.lsp.send_request_async("textDocument/codeAction", params) - - def text_document_code_lens( - self, - params: CodeLensParams, - callback: Optional[Callable[[Optional[List[CodeLens]]], None]] = None, - ) -> Future: - """Make a ``textDocument/codeLens`` request. - - A request to provide code lens for the given text document. - """ - return self.lsp.send_request("textDocument/codeLens", params, callback) - - async def text_document_code_lens_async( - self, - params: CodeLensParams, - ) -> Optional[List[CodeLens]]: - """Make a ``textDocument/codeLens`` request. - - A request to provide code lens for the given text document. - """ - return await self.lsp.send_request_async("textDocument/codeLens", params) - - def text_document_color_presentation( - self, - params: ColorPresentationParams, - callback: Optional[Callable[[List[ColorPresentation]], None]] = None, - ) -> Future: - """Make a ``textDocument/colorPresentation`` request. - - A request to list all presentation for a color. - - The request's parameter is of type {@link ColorPresentationParams} - the response is of type {@link ColorInformation ColorInformation[]} - or a Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/colorPresentation", params, callback) - - async def text_document_color_presentation_async( - self, - params: ColorPresentationParams, - ) -> List[ColorPresentation]: - """Make a ``textDocument/colorPresentation`` request. - - A request to list all presentation for a color. - - The request's parameter is of type {@link ColorPresentationParams} - the response is of type {@link ColorInformation ColorInformation[]} - or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/colorPresentation", params) - - def text_document_completion( - self, - params: CompletionParams, - callback: Optional[Callable[[Union[List[CompletionItem], CompletionList, None]], None]] = None, - ) -> Future: - """Make a ``textDocument/completion`` request. - - Request to request completion at a given text document position. The - request's parameter is of type {@link TextDocumentPosition} the response is - of type {@link CompletionItem CompletionItem[]} or {@link CompletionList} - or a Thenable that resolves to such. - - The request can delay the computation of the {@link - CompletionItem.detail `detail`} and {@link - CompletionItem.documentation `documentation`} properties to the - `completionItem/resolve` request. However, properties that are - needed for the initial sorting and filtering, like `sortText`, - `filterText`, `insertText`, and `textEdit`, must not be changed - during resolve. - """ - return self.lsp.send_request("textDocument/completion", params, callback) - - async def text_document_completion_async( - self, - params: CompletionParams, - ) -> Union[List[CompletionItem], CompletionList, None]: - """Make a ``textDocument/completion`` request. - - Request to request completion at a given text document position. The - request's parameter is of type {@link TextDocumentPosition} the response is - of type {@link CompletionItem CompletionItem[]} or {@link CompletionList} - or a Thenable that resolves to such. - - The request can delay the computation of the {@link - CompletionItem.detail `detail`} and {@link - CompletionItem.documentation `documentation`} properties to the - `completionItem/resolve` request. However, properties that are - needed for the initial sorting and filtering, like `sortText`, - `filterText`, `insertText`, and `textEdit`, must not be changed - during resolve. - """ - return await self.lsp.send_request_async("textDocument/completion", params) - - def text_document_declaration( - self, - params: DeclarationParams, - callback: Optional[Callable[[Union[Location, List[Location], List[LocationLink], None]], None]] = None, - ) -> Future: - """Make a ``textDocument/declaration`` request. - - A request to resolve the type definition locations of a symbol at a - given text document position. - - The request's parameter is of type [TextDocumentPositionParams] - (#TextDocumentPositionParams) the response is of type {@link - Declaration} or a typed array of {@link DeclarationLink} or a - Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/declaration", params, callback) - - async def text_document_declaration_async( - self, - params: DeclarationParams, - ) -> Union[Location, List[Location], List[LocationLink], None]: - """Make a ``textDocument/declaration`` request. - - A request to resolve the type definition locations of a symbol at a - given text document position. - - The request's parameter is of type [TextDocumentPositionParams] - (#TextDocumentPositionParams) the response is of type {@link - Declaration} or a typed array of {@link DeclarationLink} or a - Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/declaration", params) - - def text_document_definition( - self, - params: DefinitionParams, - callback: Optional[Callable[[Union[Location, List[Location], List[LocationLink], None]], None]] = None, - ) -> Future: - """Make a ``textDocument/definition`` request. - - A request to resolve the definition location of a symbol at a given text - document position. - - The request's parameter is of type [TextDocumentPosition] - (#TextDocumentPosition) the response is of either type {@link - Definition} or a typed array of {@link DefinitionLink} or a Thenable - that resolves to such. - """ - return self.lsp.send_request("textDocument/definition", params, callback) - - async def text_document_definition_async( - self, - params: DefinitionParams, - ) -> Union[Location, List[Location], List[LocationLink], None]: - """Make a ``textDocument/definition`` request. - - A request to resolve the definition location of a symbol at a given text - document position. - - The request's parameter is of type [TextDocumentPosition] - (#TextDocumentPosition) the response is of either type {@link - Definition} or a typed array of {@link DefinitionLink} or a Thenable - that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/definition", params) - - def text_document_diagnostic( - self, - params: DocumentDiagnosticParams, - callback: Optional[Callable[[Union[RelatedFullDocumentDiagnosticReport, RelatedUnchangedDocumentDiagnosticReport]], None]] = None, - ) -> Future: - """Make a ``textDocument/diagnostic`` request. - - The document diagnostic request definition. - - @since 3.17.0 - """ - return self.lsp.send_request("textDocument/diagnostic", params, callback) - - async def text_document_diagnostic_async( - self, - params: DocumentDiagnosticParams, - ) -> Union[RelatedFullDocumentDiagnosticReport, RelatedUnchangedDocumentDiagnosticReport]: - """Make a ``textDocument/diagnostic`` request. - - The document diagnostic request definition. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("textDocument/diagnostic", params) - - def text_document_document_color( - self, - params: DocumentColorParams, - callback: Optional[Callable[[List[ColorInformation]], None]] = None, - ) -> Future: - """Make a ``textDocument/documentColor`` request. - - A request to list all color symbols found in a given text document. - - The request's parameter is of type {@link DocumentColorParams} the - response is of type {@link ColorInformation ColorInformation[]} or a - Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/documentColor", params, callback) - - async def text_document_document_color_async( - self, - params: DocumentColorParams, - ) -> List[ColorInformation]: - """Make a ``textDocument/documentColor`` request. - - A request to list all color symbols found in a given text document. - - The request's parameter is of type {@link DocumentColorParams} the - response is of type {@link ColorInformation ColorInformation[]} or a - Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/documentColor", params) - - def text_document_document_highlight( - self, - params: DocumentHighlightParams, - callback: Optional[Callable[[Optional[List[DocumentHighlight]]], None]] = None, - ) -> Future: - """Make a ``textDocument/documentHighlight`` request. - - Request to resolve a {@link DocumentHighlight} for a given text document - position. - - The request's parameter is of type [TextDocumentPosition] - (#TextDocumentPosition) the request response is of type - [DocumentHighlight[]] (#DocumentHighlight) or a Thenable that - resolves to such. - """ - return self.lsp.send_request("textDocument/documentHighlight", params, callback) - - async def text_document_document_highlight_async( - self, - params: DocumentHighlightParams, - ) -> Optional[List[DocumentHighlight]]: - """Make a ``textDocument/documentHighlight`` request. - - Request to resolve a {@link DocumentHighlight} for a given text document - position. - - The request's parameter is of type [TextDocumentPosition] - (#TextDocumentPosition) the request response is of type - [DocumentHighlight[]] (#DocumentHighlight) or a Thenable that - resolves to such. - """ - return await self.lsp.send_request_async("textDocument/documentHighlight", params) - - def text_document_document_link( - self, - params: DocumentLinkParams, - callback: Optional[Callable[[Optional[List[DocumentLink]]], None]] = None, - ) -> Future: - """Make a ``textDocument/documentLink`` request. - - A request to provide document links. - """ - return self.lsp.send_request("textDocument/documentLink", params, callback) - - async def text_document_document_link_async( - self, - params: DocumentLinkParams, - ) -> Optional[List[DocumentLink]]: - """Make a ``textDocument/documentLink`` request. - - A request to provide document links. - """ - return await self.lsp.send_request_async("textDocument/documentLink", params) - - def text_document_document_symbol( - self, - params: DocumentSymbolParams, - callback: Optional[Callable[[Union[List[SymbolInformation], List[DocumentSymbol], None]], None]] = None, - ) -> Future: - """Make a ``textDocument/documentSymbol`` request. - - A request to list all symbols found in a given text document. - - The request's parameter is of type {@link TextDocumentIdentifier} - the response is of type {@link SymbolInformation - SymbolInformation[]} or a Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/documentSymbol", params, callback) - - async def text_document_document_symbol_async( - self, - params: DocumentSymbolParams, - ) -> Union[List[SymbolInformation], List[DocumentSymbol], None]: - """Make a ``textDocument/documentSymbol`` request. - - A request to list all symbols found in a given text document. - - The request's parameter is of type {@link TextDocumentIdentifier} - the response is of type {@link SymbolInformation - SymbolInformation[]} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/documentSymbol", params) - - def text_document_folding_range( - self, - params: FoldingRangeParams, - callback: Optional[Callable[[Optional[List[FoldingRange]]], None]] = None, - ) -> Future: - """Make a ``textDocument/foldingRange`` request. - - A request to provide folding ranges in a document. - - The request's parameter is of type {@link FoldingRangeParams}, the - response is of type {@link FoldingRangeList} or a Thenable that - resolves to such. - """ - return self.lsp.send_request("textDocument/foldingRange", params, callback) - - async def text_document_folding_range_async( - self, - params: FoldingRangeParams, - ) -> Optional[List[FoldingRange]]: - """Make a ``textDocument/foldingRange`` request. - - A request to provide folding ranges in a document. - - The request's parameter is of type {@link FoldingRangeParams}, the - response is of type {@link FoldingRangeList} or a Thenable that - resolves to such. - """ - return await self.lsp.send_request_async("textDocument/foldingRange", params) - - def text_document_formatting( - self, - params: DocumentFormattingParams, - callback: Optional[Callable[[Optional[List[TextEdit]]], None]] = None, - ) -> Future: - """Make a ``textDocument/formatting`` request. - - A request to to format a whole document. - """ - return self.lsp.send_request("textDocument/formatting", params, callback) - - async def text_document_formatting_async( - self, - params: DocumentFormattingParams, - ) -> Optional[List[TextEdit]]: - """Make a ``textDocument/formatting`` request. - - A request to to format a whole document. - """ - return await self.lsp.send_request_async("textDocument/formatting", params) - - def text_document_hover( - self, - params: HoverParams, - callback: Optional[Callable[[Optional[Hover]], None]] = None, - ) -> Future: - """Make a ``textDocument/hover`` request. - - Request to request hover information at a given text document position. - - The request's parameter is of type {@link TextDocumentPosition} the - response is of type {@link Hover} or a Thenable that resolves to - such. - """ - return self.lsp.send_request("textDocument/hover", params, callback) - - async def text_document_hover_async( - self, - params: HoverParams, - ) -> Optional[Hover]: - """Make a ``textDocument/hover`` request. - - Request to request hover information at a given text document position. - - The request's parameter is of type {@link TextDocumentPosition} the - response is of type {@link Hover} or a Thenable that resolves to - such. - """ - return await self.lsp.send_request_async("textDocument/hover", params) - - def text_document_implementation( - self, - params: ImplementationParams, - callback: Optional[Callable[[Union[Location, List[Location], List[LocationLink], None]], None]] = None, - ) -> Future: - """Make a ``textDocument/implementation`` request. - - A request to resolve the implementation locations of a symbol at a given - text document position. - - The request's parameter is of type [TextDocumentPositionParams] - (#TextDocumentPositionParams) the response is of type {@link - Definition} or a Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/implementation", params, callback) - - async def text_document_implementation_async( - self, - params: ImplementationParams, - ) -> Union[Location, List[Location], List[LocationLink], None]: - """Make a ``textDocument/implementation`` request. - - A request to resolve the implementation locations of a symbol at a given - text document position. - - The request's parameter is of type [TextDocumentPositionParams] - (#TextDocumentPositionParams) the response is of type {@link - Definition} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/implementation", params) - - def text_document_inlay_hint( - self, - params: InlayHintParams, - callback: Optional[Callable[[Optional[List[InlayHint]]], None]] = None, - ) -> Future: - """Make a ``textDocument/inlayHint`` request. - - A request to provide inlay hints in a document. The request's parameter - is of type {@link InlayHintsParams}, the response is of type. - - {@link InlayHint InlayHint[]} or a Thenable that resolves to such. - - @since 3.17.0 - """ - return self.lsp.send_request("textDocument/inlayHint", params, callback) - - async def text_document_inlay_hint_async( - self, - params: InlayHintParams, - ) -> Optional[List[InlayHint]]: - """Make a ``textDocument/inlayHint`` request. - - A request to provide inlay hints in a document. The request's parameter - is of type {@link InlayHintsParams}, the response is of type. - - {@link InlayHint InlayHint[]} or a Thenable that resolves to such. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("textDocument/inlayHint", params) - - def text_document_inline_value( - self, - params: InlineValueParams, - callback: Optional[Callable[[Optional[List[Union[InlineValueText, InlineValueVariableLookup, InlineValueEvaluatableExpression]]]], None]] = None, - ) -> Future: - """Make a ``textDocument/inlineValue`` request. - - A request to provide inline values in a document. The request's - parameter is of type {@link InlineValueParams}, the response is of type. - - {@link InlineValue InlineValue[]} or a Thenable that resolves to such. - - @since 3.17.0 - """ - return self.lsp.send_request("textDocument/inlineValue", params, callback) - - async def text_document_inline_value_async( - self, - params: InlineValueParams, - ) -> Optional[List[Union[InlineValueText, InlineValueVariableLookup, InlineValueEvaluatableExpression]]]: - """Make a ``textDocument/inlineValue`` request. - - A request to provide inline values in a document. The request's - parameter is of type {@link InlineValueParams}, the response is of type. - - {@link InlineValue InlineValue[]} or a Thenable that resolves to such. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("textDocument/inlineValue", params) - - def text_document_linked_editing_range( - self, - params: LinkedEditingRangeParams, - callback: Optional[Callable[[Optional[LinkedEditingRanges]], None]] = None, - ) -> Future: - """Make a ``textDocument/linkedEditingRange`` request. - - A request to provide ranges that can be edited together. - - @since 3.16.0 - """ - return self.lsp.send_request("textDocument/linkedEditingRange", params, callback) - - async def text_document_linked_editing_range_async( - self, - params: LinkedEditingRangeParams, - ) -> Optional[LinkedEditingRanges]: - """Make a ``textDocument/linkedEditingRange`` request. - - A request to provide ranges that can be edited together. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("textDocument/linkedEditingRange", params) - - def text_document_moniker( - self, - params: MonikerParams, - callback: Optional[Callable[[Optional[List[Moniker]]], None]] = None, - ) -> Future: - """Make a ``textDocument/moniker`` request. - - A request to get the moniker of a symbol at a given text document - position. - - The request parameter is of type {@link TextDocumentPositionParams}. - The response is of type {@link Moniker Moniker[]} or `null`. - """ - return self.lsp.send_request("textDocument/moniker", params, callback) - - async def text_document_moniker_async( - self, - params: MonikerParams, - ) -> Optional[List[Moniker]]: - """Make a ``textDocument/moniker`` request. - - A request to get the moniker of a symbol at a given text document - position. - - The request parameter is of type {@link TextDocumentPositionParams}. - The response is of type {@link Moniker Moniker[]} or `null`. - """ - return await self.lsp.send_request_async("textDocument/moniker", params) - - def text_document_on_type_formatting( - self, - params: DocumentOnTypeFormattingParams, - callback: Optional[Callable[[Optional[List[TextEdit]]], None]] = None, - ) -> Future: - """Make a ``textDocument/onTypeFormatting`` request. - - A request to format a document on type. - """ - return self.lsp.send_request("textDocument/onTypeFormatting", params, callback) - - async def text_document_on_type_formatting_async( - self, - params: DocumentOnTypeFormattingParams, - ) -> Optional[List[TextEdit]]: - """Make a ``textDocument/onTypeFormatting`` request. - - A request to format a document on type. - """ - return await self.lsp.send_request_async("textDocument/onTypeFormatting", params) - - def text_document_prepare_call_hierarchy( - self, - params: CallHierarchyPrepareParams, - callback: Optional[Callable[[Optional[List[CallHierarchyItem]]], None]] = None, - ) -> Future: - """Make a ``textDocument/prepareCallHierarchy`` request. - - A request to result a `CallHierarchyItem` in a document at a given - position. Can be used as an input to an incoming or outgoing call - hierarchy. - - @since 3.16.0 - """ - return self.lsp.send_request("textDocument/prepareCallHierarchy", params, callback) - - async def text_document_prepare_call_hierarchy_async( - self, - params: CallHierarchyPrepareParams, - ) -> Optional[List[CallHierarchyItem]]: - """Make a ``textDocument/prepareCallHierarchy`` request. - - A request to result a `CallHierarchyItem` in a document at a given - position. Can be used as an input to an incoming or outgoing call - hierarchy. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("textDocument/prepareCallHierarchy", params) - - def text_document_prepare_rename( - self, - params: PrepareRenameParams, - callback: Optional[Callable[[Union[Range, PrepareRenameResult_Type1, PrepareRenameResult_Type2, None]], None]] = None, - ) -> Future: - """Make a ``textDocument/prepareRename`` request. - - A request to test and perform the setup necessary for a rename. - - @since 3.16 - support for default behavior - """ - return self.lsp.send_request("textDocument/prepareRename", params, callback) - - async def text_document_prepare_rename_async( - self, - params: PrepareRenameParams, - ) -> Union[Range, PrepareRenameResult_Type1, PrepareRenameResult_Type2, None]: - """Make a ``textDocument/prepareRename`` request. - - A request to test and perform the setup necessary for a rename. - - @since 3.16 - support for default behavior - """ - return await self.lsp.send_request_async("textDocument/prepareRename", params) - - def text_document_prepare_type_hierarchy( - self, - params: TypeHierarchyPrepareParams, - callback: Optional[Callable[[Optional[List[TypeHierarchyItem]]], None]] = None, - ) -> Future: - """Make a ``textDocument/prepareTypeHierarchy`` request. - - A request to result a `TypeHierarchyItem` in a document at a given - position. Can be used as an input to a subtypes or supertypes type - hierarchy. - - @since 3.17.0 - """ - return self.lsp.send_request("textDocument/prepareTypeHierarchy", params, callback) - - async def text_document_prepare_type_hierarchy_async( - self, - params: TypeHierarchyPrepareParams, - ) -> Optional[List[TypeHierarchyItem]]: - """Make a ``textDocument/prepareTypeHierarchy`` request. - - A request to result a `TypeHierarchyItem` in a document at a given - position. Can be used as an input to a subtypes or supertypes type - hierarchy. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("textDocument/prepareTypeHierarchy", params) - - def text_document_range_formatting( - self, - params: DocumentRangeFormattingParams, - callback: Optional[Callable[[Optional[List[TextEdit]]], None]] = None, - ) -> Future: - """Make a ``textDocument/rangeFormatting`` request. - - A request to to format a range in a document. - """ - return self.lsp.send_request("textDocument/rangeFormatting", params, callback) - - async def text_document_range_formatting_async( - self, - params: DocumentRangeFormattingParams, - ) -> Optional[List[TextEdit]]: - """Make a ``textDocument/rangeFormatting`` request. - - A request to to format a range in a document. - """ - return await self.lsp.send_request_async("textDocument/rangeFormatting", params) - - def text_document_references( - self, - params: ReferenceParams, - callback: Optional[Callable[[Optional[List[Location]]], None]] = None, - ) -> Future: - """Make a ``textDocument/references`` request. - - A request to resolve project-wide references for the symbol denoted by - the given text document position. The request's parameter is of type {@link - ReferenceParams} the response is of type. - - {@link Location Location[]} or a Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/references", params, callback) - - async def text_document_references_async( - self, - params: ReferenceParams, - ) -> Optional[List[Location]]: - """Make a ``textDocument/references`` request. - - A request to resolve project-wide references for the symbol denoted by - the given text document position. The request's parameter is of type {@link - ReferenceParams} the response is of type. - - {@link Location Location[]} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/references", params) - - def text_document_rename( - self, - params: RenameParams, - callback: Optional[Callable[[Optional[WorkspaceEdit]], None]] = None, - ) -> Future: - """Make a ``textDocument/rename`` request. - - A request to rename a symbol. - """ - return self.lsp.send_request("textDocument/rename", params, callback) - - async def text_document_rename_async( - self, - params: RenameParams, - ) -> Optional[WorkspaceEdit]: - """Make a ``textDocument/rename`` request. - - A request to rename a symbol. - """ - return await self.lsp.send_request_async("textDocument/rename", params) - - def text_document_selection_range( - self, - params: SelectionRangeParams, - callback: Optional[Callable[[Optional[List[SelectionRange]]], None]] = None, - ) -> Future: - """Make a ``textDocument/selectionRange`` request. - - A request to provide selection ranges in a document. - - The request's parameter is of type {@link SelectionRangeParams}, the - response is of type {@link SelectionRange SelectionRange[]} or a - Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/selectionRange", params, callback) - - async def text_document_selection_range_async( - self, - params: SelectionRangeParams, - ) -> Optional[List[SelectionRange]]: - """Make a ``textDocument/selectionRange`` request. - - A request to provide selection ranges in a document. - - The request's parameter is of type {@link SelectionRangeParams}, the - response is of type {@link SelectionRange SelectionRange[]} or a - Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/selectionRange", params) - - def text_document_semantic_tokens_full( - self, - params: SemanticTokensParams, - callback: Optional[Callable[[Optional[SemanticTokens]], None]] = None, - ) -> Future: - """Make a ``textDocument/semanticTokens/full`` request. - - @since 3.16.0 - """ - return self.lsp.send_request("textDocument/semanticTokens/full", params, callback) - - async def text_document_semantic_tokens_full_async( - self, - params: SemanticTokensParams, - ) -> Optional[SemanticTokens]: - """Make a ``textDocument/semanticTokens/full`` request. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("textDocument/semanticTokens/full", params) - - def text_document_semantic_tokens_full_delta( - self, - params: SemanticTokensDeltaParams, - callback: Optional[Callable[[Union[SemanticTokens, SemanticTokensDelta, None]], None]] = None, - ) -> Future: - """Make a ``textDocument/semanticTokens/full/delta`` request. - - @since 3.16.0 - """ - return self.lsp.send_request("textDocument/semanticTokens/full/delta", params, callback) - - async def text_document_semantic_tokens_full_delta_async( - self, - params: SemanticTokensDeltaParams, - ) -> Union[SemanticTokens, SemanticTokensDelta, None]: - """Make a ``textDocument/semanticTokens/full/delta`` request. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("textDocument/semanticTokens/full/delta", params) - - def text_document_semantic_tokens_range( - self, - params: SemanticTokensRangeParams, - callback: Optional[Callable[[Optional[SemanticTokens]], None]] = None, - ) -> Future: - """Make a ``textDocument/semanticTokens/range`` request. - - @since 3.16.0 - """ - return self.lsp.send_request("textDocument/semanticTokens/range", params, callback) - - async def text_document_semantic_tokens_range_async( - self, - params: SemanticTokensRangeParams, - ) -> Optional[SemanticTokens]: - """Make a ``textDocument/semanticTokens/range`` request. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("textDocument/semanticTokens/range", params) - - def text_document_signature_help( - self, - params: SignatureHelpParams, - callback: Optional[Callable[[Optional[SignatureHelp]], None]] = None, - ) -> Future: - """Make a ``textDocument/signatureHelp`` request. - - - """ - return self.lsp.send_request("textDocument/signatureHelp", params, callback) - - async def text_document_signature_help_async( - self, - params: SignatureHelpParams, - ) -> Optional[SignatureHelp]: - """Make a ``textDocument/signatureHelp`` request. - - - """ - return await self.lsp.send_request_async("textDocument/signatureHelp", params) - - def text_document_type_definition( - self, - params: TypeDefinitionParams, - callback: Optional[Callable[[Union[Location, List[Location], List[LocationLink], None]], None]] = None, - ) -> Future: - """Make a ``textDocument/typeDefinition`` request. - - A request to resolve the type definition locations of a symbol at a - given text document position. - - The request's parameter is of type [TextDocumentPositionParams] - (#TextDocumentPositionParams) the response is of type {@link - Definition} or a Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/typeDefinition", params, callback) - - async def text_document_type_definition_async( - self, - params: TypeDefinitionParams, - ) -> Union[Location, List[Location], List[LocationLink], None]: - """Make a ``textDocument/typeDefinition`` request. - - A request to resolve the type definition locations of a symbol at a - given text document position. - - The request's parameter is of type [TextDocumentPositionParams] - (#TextDocumentPositionParams) the response is of type {@link - Definition} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/typeDefinition", params) - - def text_document_will_save_wait_until( - self, - params: WillSaveTextDocumentParams, - callback: Optional[Callable[[Optional[List[TextEdit]]], None]] = None, - ) -> Future: - """Make a ``textDocument/willSaveWaitUntil`` request. - - A document will save request is sent from the client to the server - before the document is actually saved. - - The request can return an array of TextEdits which will be applied - to the text document before it is saved. Please note that clients - might drop results if computing the text edits took too long or if a - server constantly fails on this request. This is done to keep the - save fast and reliable. - """ - return self.lsp.send_request("textDocument/willSaveWaitUntil", params, callback) - - async def text_document_will_save_wait_until_async( - self, - params: WillSaveTextDocumentParams, - ) -> Optional[List[TextEdit]]: - """Make a ``textDocument/willSaveWaitUntil`` request. - - A document will save request is sent from the client to the server - before the document is actually saved. - - The request can return an array of TextEdits which will be applied - to the text document before it is saved. Please note that clients - might drop results if computing the text edits took too long or if a - server constantly fails on this request. This is done to keep the - save fast and reliable. - """ - return await self.lsp.send_request_async("textDocument/willSaveWaitUntil", params) - - def type_hierarchy_subtypes( - self, - params: TypeHierarchySubtypesParams, - callback: Optional[Callable[[Optional[List[TypeHierarchyItem]]], None]] = None, - ) -> Future: - """Make a ``typeHierarchy/subtypes`` request. - - A request to resolve the subtypes for a given `TypeHierarchyItem`. - - @since 3.17.0 - """ - return self.lsp.send_request("typeHierarchy/subtypes", params, callback) - - async def type_hierarchy_subtypes_async( - self, - params: TypeHierarchySubtypesParams, - ) -> Optional[List[TypeHierarchyItem]]: - """Make a ``typeHierarchy/subtypes`` request. - - A request to resolve the subtypes for a given `TypeHierarchyItem`. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("typeHierarchy/subtypes", params) - - def type_hierarchy_supertypes( - self, - params: TypeHierarchySupertypesParams, - callback: Optional[Callable[[Optional[List[TypeHierarchyItem]]], None]] = None, - ) -> Future: - """Make a ``typeHierarchy/supertypes`` request. - - A request to resolve the supertypes for a given `TypeHierarchyItem`. - - @since 3.17.0 - """ - return self.lsp.send_request("typeHierarchy/supertypes", params, callback) - - async def type_hierarchy_supertypes_async( - self, - params: TypeHierarchySupertypesParams, - ) -> Optional[List[TypeHierarchyItem]]: - """Make a ``typeHierarchy/supertypes`` request. - - A request to resolve the supertypes for a given `TypeHierarchyItem`. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("typeHierarchy/supertypes", params) - - def workspace_diagnostic( - self, - params: WorkspaceDiagnosticParams, - callback: Optional[Callable[[WorkspaceDiagnosticReport], None]] = None, - ) -> Future: - """Make a ``workspace/diagnostic`` request. - - The workspace diagnostic request definition. - - @since 3.17.0 - """ - return self.lsp.send_request("workspace/diagnostic", params, callback) - - async def workspace_diagnostic_async( - self, - params: WorkspaceDiagnosticParams, - ) -> WorkspaceDiagnosticReport: - """Make a ``workspace/diagnostic`` request. - - The workspace diagnostic request definition. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("workspace/diagnostic", params) - - def workspace_execute_command( - self, - params: ExecuteCommandParams, - callback: Optional[Callable[[Optional[Any]], None]] = None, - ) -> Future: - """Make a ``workspace/executeCommand`` request. - - A request send from the client to the server to execute a command. - - The request might return a workspace edit which the client will - apply to the workspace. - """ - return self.lsp.send_request("workspace/executeCommand", params, callback) - - async def workspace_execute_command_async( - self, - params: ExecuteCommandParams, - ) -> Optional[Any]: - """Make a ``workspace/executeCommand`` request. - - A request send from the client to the server to execute a command. - - The request might return a workspace edit which the client will - apply to the workspace. - """ - return await self.lsp.send_request_async("workspace/executeCommand", params) - - def workspace_symbol( - self, - params: WorkspaceSymbolParams, - callback: Optional[Callable[[Union[List[SymbolInformation], List[WorkspaceSymbol], None]], None]] = None, - ) -> Future: - """Make a ``workspace/symbol`` request. - - A request to list project-wide symbols matching the query string given - by the {@link WorkspaceSymbolParams}. The response is of type {@link - SymbolInformation SymbolInformation[]} or a Thenable that resolves to such. - - @since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients - need to advertise support for WorkspaceSymbols via the client capability - `workspace.symbol.resolveSupport`. - """ - return self.lsp.send_request("workspace/symbol", params, callback) - - async def workspace_symbol_async( - self, - params: WorkspaceSymbolParams, - ) -> Union[List[SymbolInformation], List[WorkspaceSymbol], None]: - """Make a ``workspace/symbol`` request. - - A request to list project-wide symbols matching the query string given - by the {@link WorkspaceSymbolParams}. The response is of type {@link - SymbolInformation SymbolInformation[]} or a Thenable that resolves to such. - - @since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients - need to advertise support for WorkspaceSymbols via the client capability - `workspace.symbol.resolveSupport`. - """ - return await self.lsp.send_request_async("workspace/symbol", params) - - def workspace_symbol_resolve( - self, - params: WorkspaceSymbol, - callback: Optional[Callable[[WorkspaceSymbol], None]] = None, - ) -> Future: - """Make a ``workspaceSymbol/resolve`` request. - - A request to resolve the range inside the workspace symbol's location. - - @since 3.17.0 - """ - return self.lsp.send_request("workspaceSymbol/resolve", params, callback) - - async def workspace_symbol_resolve_async( - self, - params: WorkspaceSymbol, - ) -> WorkspaceSymbol: - """Make a ``workspaceSymbol/resolve`` request. - - A request to resolve the range inside the workspace symbol's location. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("workspaceSymbol/resolve", params) - - def workspace_will_create_files( - self, - params: CreateFilesParams, - callback: Optional[Callable[[Optional[WorkspaceEdit]], None]] = None, - ) -> Future: - """Make a ``workspace/willCreateFiles`` request. - - The will create files request is sent from the client to the server - before files are actually created as long as the creation is triggered from - within the client. - - @since 3.16.0 - """ - return self.lsp.send_request("workspace/willCreateFiles", params, callback) - - async def workspace_will_create_files_async( - self, - params: CreateFilesParams, - ) -> Optional[WorkspaceEdit]: - """Make a ``workspace/willCreateFiles`` request. - - The will create files request is sent from the client to the server - before files are actually created as long as the creation is triggered from - within the client. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("workspace/willCreateFiles", params) - - def workspace_will_delete_files( - self, - params: DeleteFilesParams, - callback: Optional[Callable[[Optional[WorkspaceEdit]], None]] = None, - ) -> Future: - """Make a ``workspace/willDeleteFiles`` request. - - The did delete files notification is sent from the client to the server - when files were deleted from within the client. - - @since 3.16.0 - """ - return self.lsp.send_request("workspace/willDeleteFiles", params, callback) - - async def workspace_will_delete_files_async( - self, - params: DeleteFilesParams, - ) -> Optional[WorkspaceEdit]: - """Make a ``workspace/willDeleteFiles`` request. - - The did delete files notification is sent from the client to the server - when files were deleted from within the client. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("workspace/willDeleteFiles", params) - - def workspace_will_rename_files( - self, - params: RenameFilesParams, - callback: Optional[Callable[[Optional[WorkspaceEdit]], None]] = None, - ) -> Future: - """Make a ``workspace/willRenameFiles`` request. - - The will rename files request is sent from the client to the server - before files are actually renamed as long as the rename is triggered from - within the client. - - @since 3.16.0 - """ - return self.lsp.send_request("workspace/willRenameFiles", params, callback) - - async def workspace_will_rename_files_async( - self, - params: RenameFilesParams, - ) -> Optional[WorkspaceEdit]: - """Make a ``workspace/willRenameFiles`` request. - - The will rename files request is sent from the client to the server - before files are actually renamed as long as the rename is triggered from - within the client. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("workspace/willRenameFiles", params) - - def cancel_request(self, params: CancelParams) -> None: - """Send a ``$/cancelRequest`` notification. - - - """ - self.lsp.notify("$/cancelRequest", params) - - def exit(self, params: None) -> None: - """Send a ``exit`` notification. - - The exit event is sent from the client to the server to ask the server - to exit its process. - """ - self.lsp.notify("exit", params) - - def initialized(self, params: InitializedParams) -> None: - """Send a ``initialized`` notification. - - The initialized notification is sent from the client to the server after - the client is fully initialized and the server is allowed to send requests - from the server to the client. - """ - self.lsp.notify("initialized", params) - - def notebook_document_did_change(self, params: DidChangeNotebookDocumentParams) -> None: - """Send a ``notebookDocument/didChange`` notification. - - - """ - self.lsp.notify("notebookDocument/didChange", params) - - def notebook_document_did_close(self, params: DidCloseNotebookDocumentParams) -> None: - """Send a ``notebookDocument/didClose`` notification. - - A notification sent when a notebook closes. - - @since 3.17.0 - """ - self.lsp.notify("notebookDocument/didClose", params) - - def notebook_document_did_open(self, params: DidOpenNotebookDocumentParams) -> None: - """Send a ``notebookDocument/didOpen`` notification. - - A notification sent when a notebook opens. - - @since 3.17.0 - """ - self.lsp.notify("notebookDocument/didOpen", params) - - def notebook_document_did_save(self, params: DidSaveNotebookDocumentParams) -> None: - """Send a ``notebookDocument/didSave`` notification. - - A notification sent when a notebook document is saved. - - @since 3.17.0 - """ - self.lsp.notify("notebookDocument/didSave", params) - - def progress(self, params: ProgressParams) -> None: - """Send a ``$/progress`` notification. - - - """ - self.lsp.notify("$/progress", params) - - def set_trace(self, params: SetTraceParams) -> None: - """Send a ``$/setTrace`` notification. - - - """ - self.lsp.notify("$/setTrace", params) - - def text_document_did_change(self, params: DidChangeTextDocumentParams) -> None: - """Send a ``textDocument/didChange`` notification. - - The document change notification is sent from the client to the server - to signal changes to a text document. - """ - self.lsp.notify("textDocument/didChange", params) - - def text_document_did_close(self, params: DidCloseTextDocumentParams) -> None: - """Send a ``textDocument/didClose`` notification. - - The document close notification is sent from the client to the server - when the document got closed in the client. - - The document's truth now exists where the document's uri points to - (e.g. if the document's uri is a file uri the truth now exists on - disk). As with the open notification the close notification is about - managing the document's content. Receiving a close notification - doesn't mean that the document was open in an editor before. A close - notification requires a previous open notification to be sent. - """ - self.lsp.notify("textDocument/didClose", params) - - def text_document_did_open(self, params: DidOpenTextDocumentParams) -> None: - """Send a ``textDocument/didOpen`` notification. - - The document open notification is sent from the client to the server to - signal newly opened text documents. - - The document's truth is now managed by the client and the server - must not try to read the document's truth using the document's uri. - Open in this sense means it is managed by the client. It doesn't - necessarily mean that its content is presented in an editor. An open - notification must not be sent more than once without a corresponding - close notification send before. This means open and close - notification must be balanced and the max open count is one. - """ - self.lsp.notify("textDocument/didOpen", params) - - def text_document_did_save(self, params: DidSaveTextDocumentParams) -> None: - """Send a ``textDocument/didSave`` notification. - - The document save notification is sent from the client to the server - when the document got saved in the client. - """ - self.lsp.notify("textDocument/didSave", params) - - def text_document_will_save(self, params: WillSaveTextDocumentParams) -> None: - """Send a ``textDocument/willSave`` notification. - - A document will save notification is sent from the client to the server - before the document is actually saved. - """ - self.lsp.notify("textDocument/willSave", params) - - def window_work_done_progress_cancel(self, params: WorkDoneProgressCancelParams) -> None: - """Send a ``window/workDoneProgress/cancel`` notification. - - The `window/workDoneProgress/cancel` notification is sent from the - client to the server to cancel a progress initiated on the server side. - """ - self.lsp.notify("window/workDoneProgress/cancel", params) - - def workspace_did_change_configuration(self, params: DidChangeConfigurationParams) -> None: - """Send a ``workspace/didChangeConfiguration`` notification. - - The configuration change notification is sent from the client to the - server when the client's configuration has changed. - - The notification contains the changed configuration as defined by - the language client. - """ - self.lsp.notify("workspace/didChangeConfiguration", params) - - def workspace_did_change_watched_files(self, params: DidChangeWatchedFilesParams) -> None: - """Send a ``workspace/didChangeWatchedFiles`` notification. - - The watched files notification is sent from the client to the server - when the client detects changes to file watched by the language client. - """ - self.lsp.notify("workspace/didChangeWatchedFiles", params) - - def workspace_did_change_workspace_folders(self, params: DidChangeWorkspaceFoldersParams) -> None: - """Send a ``workspace/didChangeWorkspaceFolders`` notification. - - The `workspace/didChangeWorkspaceFolders` notification is sent from the - client to the server when the workspace folder configuration changes. - """ - self.lsp.notify("workspace/didChangeWorkspaceFolders", params) - - def workspace_did_create_files(self, params: CreateFilesParams) -> None: - """Send a ``workspace/didCreateFiles`` notification. - - The did create files notification is sent from the client to the server - when files were created from within the client. - - @since 3.16.0 - """ - self.lsp.notify("workspace/didCreateFiles", params) - - def workspace_did_delete_files(self, params: DeleteFilesParams) -> None: - """Send a ``workspace/didDeleteFiles`` notification. - - The will delete files request is sent from the client to the server - before files are actually deleted as long as the deletion is triggered from - within the client. - - @since 3.16.0 - """ - self.lsp.notify("workspace/didDeleteFiles", params) - - def workspace_did_rename_files(self, params: RenameFilesParams) -> None: - """Send a ``workspace/didRenameFiles`` notification. - - The did rename files notification is sent from the client to the server - when files were renamed from within the client. - - @since 3.16.0 - """ - self.lsp.notify("workspace/didRenameFiles", params) diff --git a/lib/pytest-lsp/pytest_lsp/plugin.py b/lib/pytest-lsp/pytest_lsp/plugin.py index 807d292..af7e94c 100644 --- a/lib/pytest-lsp/pytest_lsp/plugin.py +++ b/lib/pytest-lsp/pytest_lsp/plugin.py @@ -1,155 +1,46 @@ -import asyncio import inspect import logging -import subprocess import sys import textwrap -import threading import typing -from concurrent.futures import ThreadPoolExecutor +from typing import Any from typing import Callable +from typing import Dict from typing import List from typing import Optional +import attrs import pytest import pytest_asyncio -from pygls.server import StdOutTransportAdapter -from pygls.server import aio_readline +from pygls.client import JsonRPCClient from pytest_lsp.client import LanguageClient -from pytest_lsp.client import make_test_client +from pytest_lsp.client import make_test_lsp_client logger = logging.getLogger("client") -async def check_server_process( - server: subprocess.Popen, stop: threading.Event, client: LanguageClient -): - """Continously poll server process to see if it is still running.""" - while not stop.is_set(): - retcode = server.poll() - if retcode is not None: - stderr = "" - if server.stderr is not None: - stderr = server.stderr.read().decode("utf8") - - message = f"Server exited with return code: {retcode}\n{stderr}" - client._report_server_error(RuntimeError(message), RuntimeError) - - else: - await asyncio.sleep(0.1) - - -class ClientServer: - """A client server pair used to drive test cases.""" - - def __init__(self, *, client: LanguageClient, server: subprocess.Popen): - self.server = server - """The process object running the server.""" - - self.client = client - """The client used to drive the test.""" - - self._thread_pool_executor = ThreadPoolExecutor(max_workers=2) - self._stop_event = threading.Event() - - def start(self): - loop = asyncio.get_running_loop() - - self.client._stop_event = self._stop_event - transport = StdOutTransportAdapter(self.server.stdout, self.server.stdin) - self.client.lsp.connection_made(transport) - - # TODO: Remove once Python 3.7 is no longer supported - conn_name = {} - watch_name = {} - - if sys.version_info.minor > 7: - conn_name["name"] = "Client-Server Connection" - watch_name["name"] = "Server Watchdog" - - # Have the client listen to and respond to requests from the server. - self.conn = loop.create_task( - aio_readline( - loop, - self._thread_pool_executor, - self.client._stop_event, - self.server.stdout, - self.client.lsp.data_received, - ), - **conn_name, # type: ignore[arg-type] - ) - - # Watch the server process to see if it exits prematurely. - self.watch = loop.create_task( - check_server_process(self.server, self._stop_event, self.client), - **watch_name, # type: ignore[arg-type] - ) - - async def stop(self): - self.server.terminate() - - if self.client._stop_event: - self.client._stop_event.set() - - # Wait for background tasks to finish. - await asyncio.gather(self.conn, self.watch) - - +@attrs.define class ClientServerConfig: - """Configuration for a LSP Client-Server pair.""" - - def __init__( - self, - server_command: List[str], - *, - client_factory: Callable[[], LanguageClient] = make_test_client, - ) -> None: - """ - Parameters - ---------- - server_command - The command to use to start the language server. - - client_factory - Factory function to use when constructing the language client instance. - Defaults to :func:`pytest_lsp.make_test_client` - """ - - self.server_command = server_command - self.client_factory = client_factory - - -def make_client_server(config: ClientServerConfig) -> ClientServer: - """Construct a new ``ClientServer`` instance.""" - - server = subprocess.Popen( - config.server_command, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) + """Configuration for a Client-Server connection.""" - return ClientServer( - server=server, - client=config.client_factory(), - ) + server_command: List[str] + """The command to use to start the language server.""" + client_factory: Callable[[], JsonRPCClient] = attrs.field( + default=make_test_lsp_client, + ) + """Factory function to use when constructing the test client instance.""" -@pytest.hookimpl(trylast=True) -def pytest_runtest_setup(item: pytest.Item): - """Ensure that that client has not errored before running a test.""" - - client: Optional[LanguageClient] = None - for arg in item.funcargs.values(): # type: ignore[attr-defined] - if isinstance(arg, LanguageClient): - client = arg - break + server_env: Optional[Dict[str, str]] = attrs.field(default=None) + """Environment variables to set when starting the server.""" - if not client or client.error is None: - return + async def start(self) -> JsonRPCClient: + """Return the client instance to use for the test.""" + client = self.client_factory() - raise client.error + await client.start_io(*self.server_command, env=self.server_env) + return client def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo): @@ -191,17 +82,46 @@ async def anext(it): return await it.__anext__() -def get_fixture_arguments(fn: Callable, client: LanguageClient, request) -> dict: - """Return the arguments to pass to the user's fixture function""" - kwargs = {} +def get_fixture_arguments( + fn: Callable, + client: JsonRPCClient, + request: pytest.FixtureRequest, +) -> dict: + """Return the arguments to pass to the user's fixture function. + + Parameters + ---------- + fn + The user's fixture function + + client + The test client instance to inject - parameters = inspect.signature(fn).parameters - if "request" in parameters: + request + pytest's request fixture + + Returns + ------- + dict + The set of arguments to pass to the user's fixture function + """ + kwargs: Dict[str, Any] = {} + required_parameters = set(inspect.signature(fn).parameters.keys()) + + # Inject the 'request' fixture if requested + if "request" in required_parameters: kwargs["request"] = request + required_parameters.remove("request") + # Inject the language client for name, cls in typing.get_type_hints(fn).items(): - if issubclass(cls, LanguageClient): + if issubclass(cls, JsonRPCClient): kwargs[name] = client + required_parameters.remove(name) + + # Assume all remaining parameters are pytest fixtures + for name in required_parameters: + kwargs[name] = request.getfixturevalue(name) return kwargs @@ -224,10 +144,9 @@ def fixture( def wrapper(fn): @pytest_asyncio.fixture(**kwargs) async def the_fixture(request): - client_server = make_client_server(config) - client_server.start() + client = await config.start() - kwargs = get_fixture_arguments(fn, client_server.client, request) + kwargs = get_fixture_arguments(fn, client, request) result = fn(**kwargs) if inspect.isasyncgen(result): try: @@ -235,7 +154,7 @@ async def the_fixture(request): except StopAsyncIteration: pass - yield client_server.client + yield client if inspect.isasyncgen(result): try: @@ -243,7 +162,7 @@ async def the_fixture(request): except StopAsyncIteration: pass - await client_server.stop() + await client.stop() return the_fixture diff --git a/lib/pytest-lsp/pytest_lsp/protocol.py b/lib/pytest-lsp/pytest_lsp/protocol.py index a521ec1..24adfb2 100644 --- a/lib/pytest-lsp/pytest_lsp/protocol.py +++ b/lib/pytest-lsp/pytest_lsp/protocol.py @@ -41,7 +41,7 @@ def _handle_notification(self, method_name, params): async def send_request_async(self, method, params=None): result = await super().send_request_async(method, params) check_result_against_client_capabilities( - self._server.capabilities, method, result + self._server.capabilities, method, result # type: ignore ) return result diff --git a/lib/pytest-lsp/tests/examples/diagnostics/t_server.py b/lib/pytest-lsp/tests/examples/diagnostics/t_server.py index 9d42709..3331be4 100644 --- a/lib/pytest-lsp/tests/examples/diagnostics/t_server.py +++ b/lib/pytest-lsp/tests/examples/diagnostics/t_server.py @@ -6,6 +6,7 @@ from lsprotocol.types import InitializeParams from lsprotocol.types import TextDocumentItem +import pytest import pytest_lsp from pytest_lsp import ClientServerConfig from pytest_lsp import LanguageClient @@ -25,6 +26,7 @@ async def client(lsp_client: LanguageClient): await lsp_client.shutdown_session() +@pytest.mark.asyncio async def test_diagnostics(client: LanguageClient): """Ensure that the server implements diagnostics correctly.""" diff --git a/lib/pytest-lsp/tests/examples/fixture-passthrough/server.py b/lib/pytest-lsp/tests/examples/fixture-passthrough/server.py new file mode 100644 index 0000000..2a54853 --- /dev/null +++ b/lib/pytest-lsp/tests/examples/fixture-passthrough/server.py @@ -0,0 +1,18 @@ +from lsprotocol.types import TEXT_DOCUMENT_COMPLETION +from lsprotocol.types import CompletionItem +from lsprotocol.types import CompletionParams +from pygls.server import LanguageServer + +server = LanguageServer("hello-world", "v1") + + +@server.feature(TEXT_DOCUMENT_COMPLETION) +def completion(ls: LanguageServer, params: CompletionParams): + return [ + CompletionItem(label="hello"), + CompletionItem(label="world"), + ] + + +if __name__ == "__main__": + server.start_io() diff --git a/lib/pytest-lsp/tests/examples/fixture-passthrough/t_server.py b/lib/pytest-lsp/tests/examples/fixture-passthrough/t_server.py new file mode 100644 index 0000000..890454c --- /dev/null +++ b/lib/pytest-lsp/tests/examples/fixture-passthrough/t_server.py @@ -0,0 +1,52 @@ +import sys + +import pytest +from lsprotocol.types import CompletionList +from lsprotocol.types import CompletionParams +from lsprotocol.types import InitializeParams +from lsprotocol.types import Position +from lsprotocol.types import TextDocumentIdentifier + +import pytest_lsp +from pytest_lsp import ClientServerConfig +from pytest_lsp import LanguageClient +from pytest_lsp import client_capabilities + + +@pytest.fixture(scope="module") +def client_name(): + return "neovim" + + +@pytest_lsp.fixture( + config=ClientServerConfig(server_command=[sys.executable, "server.py"]), +) +async def client(client_name: str, lsp_client: LanguageClient): + # Setup + params = InitializeParams(capabilities=client_capabilities(client_name)) + await lsp_client.initialize_session(params) + + yield + + # Teardown + await lsp_client.shutdown_session() + + +async def test_completions(client: LanguageClient): + """Ensure that the server implements completions correctly.""" + + results = await client.text_document_completion_async( + params=CompletionParams( + position=Position(line=1, character=0), + text_document=TextDocumentIdentifier(uri="file:///path/to/file.txt"), + ) + ) + assert results is not None + + if isinstance(results, CompletionList): + items = results.items + else: + items = results + + labels = [item.label for item in items] + assert labels == ["hello", "world"] diff --git a/lib/pytest-lsp/tests/examples/generic-rpc/server.py b/lib/pytest-lsp/tests/examples/generic-rpc/server.py new file mode 100644 index 0000000..3381860 --- /dev/null +++ b/lib/pytest-lsp/tests/examples/generic-rpc/server.py @@ -0,0 +1,30 @@ +from pygls.protocol import JsonRPCProtocol, default_converter +from pygls.server import Server + +server = Server(protocol_cls=JsonRPCProtocol, converter_factory=default_converter) + + +@server.lsp.fm.feature("math/add") +def addition(ls: Server, params): + a = params.a + b = params.b + + ls.lsp.notify("log/message", dict(message=f"{a=}")) + ls.lsp.notify("log/message", dict(message=f"{b=}")) + + return dict(total=a + b) + + +@server.lsp.fm.feature("math/sub") +def subtraction(ls: Server, params): + a = params.a + b = params.b + + ls.lsp.notify("log/message", dict(message=f"{a=}")) + ls.lsp.notify("log/message", dict(message=f"{b=}")) + + return dict(total=b - a) + + +if __name__ == "__main__": + server.start_io() diff --git a/lib/pytest-lsp/tests/examples/generic-rpc/t_server.py b/lib/pytest-lsp/tests/examples/generic-rpc/t_server.py new file mode 100644 index 0000000..38b6e3c --- /dev/null +++ b/lib/pytest-lsp/tests/examples/generic-rpc/t_server.py @@ -0,0 +1,50 @@ +import logging +import sys + +import pytest +import pytest_lsp +from pygls.client import JsonRPCClient +from pytest_lsp import ClientServerConfig + + +def client_factory(): + client = JsonRPCClient() + + @client.feature("log/message") + def _on_message(params): + logging.info("LOG: %s", params.message) + + return client + + +@pytest_lsp.fixture( + config=ClientServerConfig( + client_factory=client_factory, server_command=[sys.executable, "server.py"] + ), +) +async def client(rpc_client: JsonRPCClient): + # Setup code here (if any) + + yield + + # Teardown code here (if any) + + +@pytest.mark.asyncio +async def test_add(client: JsonRPCClient): + """Ensure that the server implements addition correctly.""" + + result = await client.protocol.send_request_async( + "math/add", params={"a": 1, "b": 2} + ) + assert result.total == 3 + + +@pytest.mark.asyncio +async def test_sub(client: JsonRPCClient): + """Ensure that the server implements addition correctly.""" + + result = await client.protocol.send_request_async( + "math/sub", params={"a": 1, "b": 2} + ) + assert result.total == -1 diff --git a/lib/pytest-lsp/tests/examples/getting-started/t_server.py b/lib/pytest-lsp/tests/examples/getting-started/t_server.py index 7b5f8fd..0abbade 100644 --- a/lib/pytest-lsp/tests/examples/getting-started/t_server.py +++ b/lib/pytest-lsp/tests/examples/getting-started/t_server.py @@ -7,6 +7,7 @@ from lsprotocol.types import Position from lsprotocol.types import TextDocumentIdentifier +import pytest import pytest_lsp from pytest_lsp import ClientServerConfig from pytest_lsp import LanguageClient @@ -26,6 +27,7 @@ async def client(lsp_client: LanguageClient): await lsp_client.shutdown_session() +@pytest.mark.asyncio async def test_completions(client: LanguageClient): """Ensure that the server implements completions correctly.""" diff --git a/lib/pytest-lsp/tests/examples/window-log-message/t_server.py b/lib/pytest-lsp/tests/examples/window-log-message/t_server.py index 0a215c8..bd68f44 100644 --- a/lib/pytest-lsp/tests/examples/window-log-message/t_server.py +++ b/lib/pytest-lsp/tests/examples/window-log-message/t_server.py @@ -7,6 +7,7 @@ from lsprotocol.types import Position from lsprotocol.types import TextDocumentIdentifier +import pytest import pytest_lsp from pytest_lsp import ClientServerConfig from pytest_lsp import LanguageClient @@ -26,6 +27,7 @@ async def client(lsp_client: LanguageClient): await lsp_client.shutdown_session() +@pytest.mark.asyncio async def test_completions(client: LanguageClient): results = await client.text_document_completion_async( params=CompletionParams( diff --git a/lib/pytest-lsp/tests/examples/window-show-document/t_server.py b/lib/pytest-lsp/tests/examples/window-show-document/t_server.py index b6ec862..bdad0a9 100644 --- a/lib/pytest-lsp/tests/examples/window-show-document/t_server.py +++ b/lib/pytest-lsp/tests/examples/window-show-document/t_server.py @@ -7,6 +7,7 @@ from lsprotocol.types import Position from lsprotocol.types import TextDocumentIdentifier +import pytest import pytest_lsp from pytest_lsp import ClientServerConfig from pytest_lsp import LanguageClient @@ -26,6 +27,7 @@ async def client(lsp_client: LanguageClient): await lsp_client.shutdown_session() +@pytest.mark.asyncio async def test_completions(client: LanguageClient): test_uri = "file:///path/to/file.txt" results = await client.text_document_completion_async( diff --git a/lib/pytest-lsp/tests/examples/window-show-message/t_server.py b/lib/pytest-lsp/tests/examples/window-show-message/t_server.py index d7de616..12dab5f 100644 --- a/lib/pytest-lsp/tests/examples/window-show-message/t_server.py +++ b/lib/pytest-lsp/tests/examples/window-show-message/t_server.py @@ -7,6 +7,7 @@ from lsprotocol.types import Position from lsprotocol.types import TextDocumentIdentifier +import pytest import pytest_lsp from pytest_lsp import ClientServerConfig from pytest_lsp import LanguageClient @@ -26,6 +27,7 @@ async def client(lsp_client: LanguageClient): await lsp_client.shutdown_session() +@pytest.mark.asyncio async def test_completions(client: LanguageClient): results = await client.text_document_completion_async( params=CompletionParams( diff --git a/lib/pytest-lsp/tests/test_client.py b/lib/pytest-lsp/tests/test_client.py index 1bfc74e..102bc7f 100644 --- a/lib/pytest-lsp/tests/test_client.py +++ b/lib/pytest-lsp/tests/test_client.py @@ -52,14 +52,14 @@ def test_client_capabilities( @pytest_lsp.fixture( config=ClientServerConfig( - server_command=["{python}", "{server}"], + server_command=[r"{python}", r"{server}"], ) ) async def client(lsp_client: LanguageClient): await lsp_client.initialize_session( InitializeParams( capabilities=client_capabilities("{client_spec}"), - root_uri="{root_uri}" + root_uri=r"{root_uri}" ) ) yield diff --git a/lib/pytest-lsp/tests/test_examples.py b/lib/pytest-lsp/tests/test_examples.py index bd5389e..d493e3d 100644 --- a/lib/pytest-lsp/tests/test_examples.py +++ b/lib/pytest-lsp/tests/test_examples.py @@ -27,6 +27,7 @@ def setup_test(pytester: pytest.Pytester, example_name: str): [ ("diagnostics", dict(passed=1)), ("getting-started", dict(passed=1)), + ("fixture-passthrough", dict(passed=1)), ("parameterised-clients", dict(passed=2)), ("window-log-message", dict(passed=1)), ("window-show-document", dict(passed=1)), @@ -95,11 +96,23 @@ def test_getting_started_fail(pytester: pytest.Pytester): if sys.version_info.minor < 9: message = "E*CancelledError" else: - message = "E*asyncio.exceptions.CancelledError: RuntimeError: Server exited with return code: 0" # noqa: E501 + message = "E*asyncio.exceptions.CancelledError: Server process exited with return code: 0" # noqa: E501 results.stdout.fnmatch_lines(message) +def test_generic_rpc(pytester: pytest.Pytester): + """Ensure that the generic rpc example works as expected""" + + setup_test(pytester, "generic-rpc") + + results = pytester.runpytest("--log-cli-level", "info") + results.assert_outcomes(passed=1, failed=1) + + results.stdout.fnmatch_lines(" *LOG: a=1") + results.stdout.fnmatch_lines(" *LOG: b=2") + + def test_window_log_message_fail(pytester: pytest.Pytester): """Ensure that the initial getting started example fails as expected.""" diff --git a/lib/pytest-lsp/tests/test_plugin.py b/lib/pytest-lsp/tests/test_plugin.py index 2755edf..15f5b13 100644 --- a/lib/pytest-lsp/tests/test_plugin.py +++ b/lib/pytest-lsp/tests/test_plugin.py @@ -33,14 +33,14 @@ def setup_test(pytester: pytest.Pytester, server_name: str, test_code: str): @pytest_lsp.fixture( config=ClientServerConfig( - server_command=["{python}", "{server}"], + server_command=[r"{python}", r"{server}"], ) ) async def client(lsp_client: LanguageClient): await lsp_client.initialize_session( InitializeParams( capabilities=client_capabilities("visual-studio-code"), - root_uri="{root_uri}" + root_uri=r"{root_uri}" ) ) yield @@ -71,7 +71,7 @@ async def test_capabilities(client): if sys.version_info.minor < 9: message = "E*CancelledError" else: - message = "E*asyncio.exceptions.CancelledError: RuntimeError: Server exited with return code: 0" # noqa: E501 + message = "E*asyncio.exceptions.CancelledError: Server process exited with return code: 0" # noqa: E501 results.stdout.fnmatch_lines(message) @@ -102,14 +102,15 @@ async def test_capabilities(client): setup_test(pytester, "completion_exit.py", test_code) results = pytester.runpytest("-vv") - results.assert_outcomes(failed=1) + results.assert_outcomes(failed=1, errors=1) if sys.version_info.minor < 9: message = "E*CancelledError" else: - message = "E*asyncio.exceptions.CancelledError: RuntimeError: Server exited with return code: 0" # noqa: E501 + message = "E*asyncio.exceptions.CancelledError: Server process exited with return code: 0" # noqa: E501 results.stdout.fnmatch_lines(message) + results.stdout.fnmatch_lines("E*RuntimeError: Client has been stopped.") def test_detect_server_crash(pytester: pytest.Pytester): @@ -132,7 +133,7 @@ async def test_capabilities(client): message = "E*CancelledError" else: message = [ - "E*asyncio.exceptions.CancelledError: RuntimeError: Server exited with return code: 1", # noqa: E501 + "E*asyncio.exceptions.CancelledError: Server process exited with return code: 1", # noqa: E501 "E*ZeroDivisionError: division by zero", ] diff --git a/lib/pytest-lsp/tox.ini b/lib/pytest-lsp/tox.ini new file mode 100644 index 0000000..7bf9df9 --- /dev/null +++ b/lib/pytest-lsp/tox.ini @@ -0,0 +1,19 @@ +[tox] +isolated_build = true +skip_missing_interpreters = true +min_version = 4.0 +envlist = py{38,39,310,311,312} + +[testenv] +description = "Run pytest-lsp's test suite" +package = wheel +wheel_build_env = .pkg +deps = + coverage[toml] + git+https://github.com/openlawlibrary/pygls +commands_pre = + coverage erase +commands = + coverage run -m pytest {posargs} +commands_post = + coverage report diff --git a/scripts/gen_client.py b/scripts/gen_client.py deleted file mode 100644 index 60fb0b8..0000000 --- a/scripts/gen_client.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Script to automatically generate a lanaguge client from `lsprotocol` type definitons -""" -import argparse -import inspect -import pathlib -import re -import sys -import textwrap -from datetime import datetime -from typing import Optional -from typing import Set -from typing import Tuple -from typing import Type - -from lsprotocol._hooks import _resolve_forward_references -from lsprotocol.types import METHOD_TO_TYPES -from lsprotocol.types import message_direction - -cli = argparse.ArgumentParser( - description="generate language client from lsprotocol types." -) -cli.add_argument("-o", "--output", default=None) - - -def write_imports(imports: Set[Tuple[str, str]]) -> str: - lines = [] - - for import_ in sorted(list(imports), key=lambda i: (i[0], i[1])): - if isinstance(import_, tuple): - mod, name = import_ - lines.append(f"from {mod} import {name}") - continue - - lines.append(f"import {import_}") - - return "\n".join(lines) - - -def to_snake_case(string: str) -> str: - return "".join(f"_{c.lower()}" if c.isupper() else c for c in string) - - -def write_notification( - method: str, - request: Type, - params: Optional[Type], - imports: Set[Tuple[str, str]], -) -> str: - python_name = to_snake_case(method).replace("/", "_").replace("$_", "") - - if params is None: - param_name = "None" - else: - param_mod, param_name = params.__module__, params.__name__ - imports.add((param_mod, param_name)) - - return "\n".join( - [ - f"def {python_name}(self, params: {param_name}) -> None:", - f' """Send a ``{method}`` notification.', - "", - textwrap.indent(inspect.getdoc(request) or "", " "), - ' """', - f' self.lsp.notify("{method}", params)', - "", - ] - ) - - -def get_response_type(response: Type, imports: Set[Tuple[str, str]]) -> str: - # Find the response type. - result_field = [f for f in response.__attrs_attrs__ if f.name == "result"][0] - result = re.sub(r"", r"\1", str(result_field.type)) - result = re.sub(r"ForwardRef\('([\w.]+)'\)", r"lsprotocol.types.\1", result) - result = result.replace("NoneType", "None") - - # Replace any lsprotocol types with their short name. - for match in re.finditer(r"lsprotocol.types.([\w]+)", result): - imports.add(("lsprotocol.types", match.group(1))) - - # Replace any typing imports with their short name. - for match in re.finditer(r"typing.([\w]+)", result): - imports.add(("typing", match.group(1))) - - result = result.replace("lsprotocol.types.", "") - result = result.replace("typing.", "") - - return result - - -def write_method( - method: str, - request: Type, - params: Optional[Type], - response: Type, - imports: Set[Tuple[str, str]], -) -> str: - python_name = to_snake_case(method).replace("/", "_").replace("$_", "") - if python_name == "shutdown": - python_name = "shutdown_request" - - if params is None: - param_name = "None" - else: - param_mod, param_name = params.__module__, params.__name__ - imports.add((param_mod, param_name)) - - result_type = get_response_type(response, imports) - - return "\n".join( - [ - f"def {python_name}(", - " self,", - f" params: {param_name},", - f" callback: Optional[Callable[[{result_type}], None]] = None,", - ") -> Future:", - f' """Make a ``{method}`` request.', - "", - textwrap.indent(inspect.getdoc(request) or "", " "), - ' """', - f' return self.lsp.send_request("{method}", params, callback)', - "", - f"async def {python_name}_async(", - " self,", - f" params: {param_name},", - f") -> {result_type}:", - f' """Make a ``{method}`` request.', - "", - textwrap.indent(inspect.getdoc(request) or "", " "), - ' """', - f' return await self.lsp.send_request_async("{method}", params)', - "", - ] - ) - - -def generate_client() -> str: - methods = [] - imports = { - ("concurrent.futures", "Future"), - ("pygls.protocol", "LanguageServerProtocol"), - ("pygls.protocol", "default_converter"), - ("pygls.server", "Server"), - ("typing", "Callable"), - ("typing", "Optional"), - } - - for method_name, types in METHOD_TO_TYPES.items(): - # Skip any requests that come from the server. - if message_direction(method_name) == "serverToClient": - continue - - request, response, params, _ = types - - if response is None: - method = write_notification(method_name, request, params, imports) - else: - method = write_method(method_name, request, params, response, imports) - - methods.append(textwrap.indent(method, " ")) - - code = [ - "# GENERATED FROM scripts/gen-client.py -- DO NOT EDIT", - f"# Last Modified: {datetime.now()}", - "# flake8: noqa", - write_imports(imports), - "", - "", - "class Client(Server):", - "", - " def __init__(", - " self,", - " name: str,", - " version: str,", - " protocol_cls=LanguageServerProtocol,", - " converter_factory=default_converter,", - " **kwargs,", - " ):", - " self.name = name", - " self.version = version", - " super().__init__(protocol_cls, converter_factory, **kwargs)", - "", - *methods, - ] - return "\n".join(code) - - -def main(): - args = cli.parse_args() - - # Make sure all the type annotations in lsprotocol are resolved correctly. - _resolve_forward_references() - client = generate_client() - - if args.output is None: - sys.stdout.write(client) - else: - output = pathlib.Path(args.output) - output.write_text(client) - - -if __name__ == "__main__": - main() diff --git a/scripts/should-build.sh b/scripts/should-build.sh index e42dad5..99f4e9a 100755 --- a/scripts/should-build.sh +++ b/scripts/should-build.sh @@ -33,5 +33,5 @@ if [ -z "$changes" ]; then echo "There is nothing to do." else echo "Changes detected, doing build!" - echo "::set-output name=build::true" + echo "build::true" >> $GITHUB_OUTPUT fi