diff --git a/.github/workflows/black.yaml b/.github/workflows/black.yaml deleted file mode 100644 index 1f56b89..0000000 --- a/.github/workflows/black.yaml +++ /dev/null @@ -1,55 +0,0 @@ ---- - name: Run black - - defaults: - run: - # To load bashrc - shell: bash -ieo pipefail {0} - - on: - pull_request: - branches: [main, dev] - paths: - - "**/*.py" - schedule: - # run CI every day even if no PRs/merges occur - - cron: '0 12 * * *' - - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - jobs: - build: - name: Black - runs-on: ubuntu-latest - - steps: - - name: Checkout Code - uses: actions/checkout@v4 - with: - # Full git history is needed to get a proper list of changed files within `super-linter` - fetch-depth: 0 - - - name: Set up Python 3.10 - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install dependencies - run: | - mkdir -p .github/linters - cp pyproject.toml .github/linters - - - name: Black - uses: super-linter/super-linter/slim@v4.9.2 - if: always() - env: - # run linter on everything to catch preexisting problems - VALIDATE_ALL_CODEBASE: true - DEFAULT_BRANCH: master - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Run only black - VALIDATE_PYTHON_BLACK: true - PYTHON_BLACK_CONFIG_FILE: pyproject.toml - FILTER_REGEX_EXCLUDE: .*tests/.*.(json|zip|sol) \ No newline at end of file diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..139caaa --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,36 @@ +name: Lint + +on: + pull_request: + branches: [main, dev] + paths: ["**/*.py"] + push: + branches: [main, dev] + paths: ["**/*.py"] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca # v6.0.1 + with: + enable-cache: true + + - run: uv sync --group lint + + - run: uv run ruff format --check . + + - run: uv run ruff check --output-format=github . + + - run: uv run ty check slither_lsp/ diff --git a/.github/workflows/matchers/pylint.json b/.github/workflows/matchers/pylint.json deleted file mode 100644 index 4d9e13f..0000000 --- a/.github/workflows/matchers/pylint.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "problemMatcher": [ - { - "owner": "pylint-error", - "severity": "error", - "pattern": [ - { - "regexp": "^(.+):(\\d+):(\\d+):\\s(([EF]\\d{4}):\\s.+)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4, - "code": 5 - } - ] - }, - { - "owner": "pylint-warning", - "severity": "warning", - "pattern": [ - { - "regexp": "^(.+):(\\d+):(\\d+):\\s(([CRW]\\d{4}):\\s.+)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4, - "code": 5 - } - ] - } - ] -} \ No newline at end of file diff --git a/.github/workflows/pip-audit.yaml b/.github/workflows/pip-audit.yaml index d3b74ae..9eedf70 100644 --- a/.github/workflows/pip-audit.yaml +++ b/.github/workflows/pip-audit.yaml @@ -1,39 +1,28 @@ ---- - name: pip-audit - - on: - push: - branches: [ dev, main ] - pull_request: - branches: [ dev, main ] - schedule: [ cron: "0 7 * * 2" ] - - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - jobs: - audit: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Install Slither - run: | - python -m venv /tmp/pip-audit-env - source /tmp/pip-audit-env/bin/activate - - python -m pip install --upgrade pip setuptools wheel - python -m pip install . - - - name: Run pip-audit - uses: pypa/gh-action-pip-audit@v1.0.8 - with: - virtual-environment: /tmp/pip-audit-env \ No newline at end of file +name: pip-audit + +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca # v6.0.1 + + - run: uv sync --group audit + + - run: uv run pip-audit diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index bc82add..319119b 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -4,26 +4,23 @@ on: release: types: [published] +permissions: {} + jobs: build-release: - + permissions: + contents: read runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - python-version: '3.x' - - - name: Build distributions - run: | - python -m pip install --upgrade pip - python -m pip install build - python -m build - - name: Upload distributions - uses: actions/upload-artifact@v4 + persist-credentials: false + + - uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca # v6.0.1 + + - run: uv build + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: slither-lsp-dists path: dist/ @@ -32,22 +29,19 @@ jobs: runs-on: ubuntu-latest environment: release permissions: - id-token: write # For trusted publishing + codesigning. - contents: write # For attaching signing artifacts to the release. + id-token: write + contents: write needs: - build-release steps: - - name: fetch dists - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: slither-lsp-dists path: dist/ - - name: publish - uses: pypa/gh-action-pypi-publish@v1.8.14 + - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 - - name: sign - uses: sigstore/gh-action-sigstore-python@v2.1.1 + - uses: sigstore/gh-action-sigstore-python@f514d46b907ebcd5bedc05145c03b69c1edd8b46 # v3.0.0 with: inputs: ./dist/*.tar.gz ./dist/*.whl - release-signing-artifacts: true \ No newline at end of file + release-signing-artifacts: true diff --git a/.github/workflows/pylint.yaml b/.github/workflows/pylint.yaml deleted file mode 100644 index f040b81..0000000 --- a/.github/workflows/pylint.yaml +++ /dev/null @@ -1,60 +0,0 @@ ---- - name: Run pylint - - defaults: - run: - # To load bashrc - shell: bash -ieo pipefail {0} - - on: - pull_request: - branches: [main, dev] - paths: - - "**/*.py" - - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - jobs: - build: - name: Lint Code Base - runs-on: ubuntu-latest - - steps: - - name: Checkout Code - uses: actions/checkout@v4 - with: - # Full git history is needed to get a proper list of changed files within `super-linter` - fetch-depth: 0 - - - name: Set up Python 3.10 - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install dependencies - run: | - mkdir -p .github/linters - cp pyproject.toml .github/linters - pip install . - - - name: Register pylint problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/pylint.json" - - - name: Pylint - uses: super-linter/super-linter/slim@v6.1.1 - if: always() - env: - # Run linters only on new files for pylint to speed up the CI - VALIDATE_ALL_CODEBASE: false - # Compare against the base branch - # This is only accessible on PR - DEFAULT_BRANCH: ${{ github.base_ref }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Run only pylint - VALIDATE_PYTHON: true - VALIDATE_PYTHON_PYLINT: true - PYTHON_PYLINT_CONFIG_FILE: pyproject.toml - FILTER_REGEX_EXCLUDE: .*tests/.*.(json|zip|sol) \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..2adbfd5 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,25 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + + - name: Install dependencies + run: uv sync --group dev + + - name: Run tests + run: uv run pytest -q diff --git a/.gitignore b/.gitignore index 096634e..b75d56b 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,12 @@ ENV/ # mypy .mypy_cache +# ruff +.ruff_cache + +# uv (lockfile not committed for library packages) +uv.lock + *.sw* # PyCharm project dir diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8fdd60c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.14 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f60ac77 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: dev lint format check audit test test-cov + +dev: + uv sync --group dev + +lint: + uv run ruff check . + uv run ty check slither_lsp/ + +format: + uv run ruff format . + uv run ruff check --fix . + +check: + uv run ruff format --check . + uv run ruff check . + +audit: + uv run pip-audit + +test: + uv run pytest -q + +test-cov: + uv run pytest --cov=slither_lsp --cov-report=term-missing diff --git a/pyproject.toml b/pyproject.toml index 3af5b2c..151c7c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" +requires = ["uv_build>=0.9,<1"] +build-backend = "uv_build" [project] name = "slither-lsp" @@ -10,7 +10,7 @@ readme = "README.md" dependencies = [ "slither-analyzer>=0.10.2", "semantic-version>=2.10.0", - "pygls>=1.3.0" + "pygls>=1.3.0", ] classifiers = [ "License :: OSI Approved :: GNU Affero General Public License v3", @@ -18,7 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only", "Topic :: Security", ] -requires-python = ">=3.10" +requires-python = ">=3.11" [[project.authors]] name = "Trail of Bits" @@ -34,28 +34,82 @@ Issues = "https://github.com/crytic/slither-lsp/issues" [project.scripts] slither-lsp = "slither_lsp.__main__:main" -# Pylint settings - -[tool.pylint.messages_control] -max-line-length = 120 - -disable = """ -missing-module-docstring, -missing-class-docstring, -missing-function-docstring, -unnecessary-lambda, -cyclic-import, -line-too-long, -invalid-name, -fixme, -too-many-return-statements, -too-many-ancestors, -logging-fstring-interpolation, -logging-not-lazy, -duplicate-code, -import-error, -unsubscriptable-object, -unnecessary-lambda-assignment, -too-few-public-methods, -too-many-instance-attributes -""" \ No newline at end of file +[dependency-groups] +test = ["pytest>=8", "pytest-asyncio>=0.24"] +lint = ["ruff", "ty"] +audit = ["pip-audit"] +dev = [{ include-group = "lint" }, { include-group = "audit" }, { include-group = "test" }] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "D", # pydocstyle (no docstrings currently) + "ANN", # type annotations (gradual adoption) + "COM812", # trailing comma (formatter conflict) + "ISC001", # string concat (formatter conflict) + "T20", # print statements (used for logging) + # Rules disabled to match original pylint config + "TD", # TODO format requirements + "FIX002", # TODO comment resolution + "PLR0913",# too many arguments + "PLR0912",# too many branches + "PLR0911",# too many return statements + "C901", # complex function + "BLE001", # blind except + "S110", # try-except-pass + "S108", # hardcoded temp file + # Stylistic preferences to keep existing code patterns + "TC", # type checking block imports + "PTH", # pathlib suggestions (keep os.path for now) + "FBT", # boolean positional arguments + "SIM102", # combine if statements + "EM", # exception message format + "TRY003", # long exception messages + "SIM108", # ternary instead of if-else + "B019", # lru_cache on methods (intentional pattern in pygls) + "RUF012", # ClassVar annotations for mutable class attrs + "B026", # star-arg unpacking after keyword + "ARG001", # unused function arguments (common in LSP handlers) + "F401", # unused imports (used for re-exports in __init__.py) + "F403", # star imports (used in __init__.py) + "A001", # variable shadowing builtin (range in list comprehension) + "SLF001", # private member access (intentional argparse inspection) + "PERF401",# list comprehension suggestion (keep for readability) + "S112", # try-except-continue (intentional error handling) +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "S101", # assert statements are fine in tests + "PLR2004", # magic values are fine in tests +] + +[tool.ruff.lint.isort] +known-first-party = ["slither_lsp"] + +[tool.ruff.format] +quote-style = "double" +docstring-code-format = true + +[tool.uv.build-backend] +module-root = "" + +[tool.ty.environment] +python-version = "3.11" + +[tool.ty.rules] +# Disable rules for gradual adoption - codebase wasn't type-checked before +possibly-missing-attribute = "ignore" +unresolved-import = "ignore" +invalid-argument-type = "ignore" +not-subscriptable = "ignore" +unresolved-attribute = "ignore" + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/slither_lsp/app/feature_analyses/__init__.py b/slither_lsp/app/feature_analyses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/slither_lsp/app/feature_analyses/slither_diagnostics.py b/slither_lsp/app/feature_analyses/slither_diagnostics.py index 303cb42..7bd40a4 100644 --- a/slither_lsp/app/feature_analyses/slither_diagnostics.py +++ b/slither_lsp/app/feature_analyses/slither_diagnostics.py @@ -1,8 +1,7 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Dict, List, Set +from typing import TYPE_CHECKING import lsprotocol.types as lsp + from slither_lsp.app.types.analysis_structures import ( AnalysisResult, SlitherDetectorSettings, @@ -18,33 +17,36 @@ class SlitherDiagnostics: Tracks and reports diagnostics that were derived from AnalysisResults """ - def __init__(self, context: SlitherServer): + def __init__(self, context: "SlitherServer"): # Set basic parameters self.context = context - # Define a lookup of file uri -> diagnostics. This is necessary so we can track non-existent diagnostics. - self.diagnostics: Dict[str, lsp.PublishDiagnosticsParams] = {} + # Define a lookup of file uri -> diagnostics. + # This is necessary so we can track non-existent diagnostics. + self.diagnostics: dict[str, lsp.PublishDiagnosticsParams] = {} # TODO: Detector filters def update( self, - analysis_results: List[AnalysisResult], + analysis_results: list[AnalysisResult], detector_settings: SlitherDetectorSettings, ) -> None: """ - Generates and tracks the diagnostics for provided analysis results and detector settings. - :param analysis_results: Analysis results containing detector results which diagnostics will be generated from. + Generates and tracks diagnostics for provided analysis results and settings. + + :param analysis_results: Analysis results containing detector results which + diagnostics will be generated from. :param detector_settings: User-provided settings for slither detector results. :return: None """ # Create a new diagnostics array which our current array will be swapped to later. - new_diagnostics: Dict[str, lsp.PublishDiagnosticsParams] = {} + new_diagnostics: dict[str, lsp.PublishDiagnosticsParams] = {} # Convert our hidden checks to a set hidden_checks = set(detector_settings.hidden_checks) - # Compile our list of diagnostics for all analyses and all their underlying detector finders. + # Compile diagnostics for all analyses and their underlying detector finders. if detector_settings.enabled: for analysis_result in analysis_results: # Skip any analyses without detector results @@ -63,14 +65,15 @@ def update( if detector_result.check in hidden_checks: continue - # Obtain the target filename for this finding (the first element is the most significant) + # Obtain the target filename for this finding + # (the first element is the most significant) target_result_element = detector_result.elements[0] target_uri = fs_path_to_uri( target_result_element.source_mapping.filename_absolute ) # Obtain our params for this file uri, or create them if they haven't been yet. - params = new_diagnostics.get(target_uri, None) + params = new_diagnostics.get(target_uri) if params is None: params = lsp.PublishDiagnosticsParams( uri=target_uri, version=None, diagnostics=[] @@ -82,19 +85,19 @@ def update( lsp.Diagnostic( lsp.Range( start=lsp.Position( - line=target_result_element.source_mapping.lines[0] - - 1, + line=target_result_element.source_mapping.lines[0] - 1, character=target_result_element.source_mapping.starting_column - 1, ), end=lsp.Position( - line=target_result_element.source_mapping.lines[-1] - - 1, + line=target_result_element.source_mapping.lines[-1] - 1, character=target_result_element.source_mapping.ending_column - 1, ), ), - message=f"[{detector_result.impact.upper()}] {detector_result.description}", + message=( + f"[{detector_result.impact.upper()}] {detector_result.description}" + ), severity=lsp.DiagnosticSeverity.Information, code=detector_result.check, source="slither", @@ -102,7 +105,7 @@ def update( ) # Clear any diagnostics for files that no longer have any. - files_to_clear: Set[str] = set(self.diagnostics) - set(new_diagnostics) + files_to_clear: set[str] = set(self.diagnostics) - set(new_diagnostics) for file_to_clear in files_to_clear: self._clear_single(file_to_clear, False) @@ -117,10 +120,12 @@ def update( def _clear_single(self, file_uri: str, clear_from_lookup: bool = False) -> None: """ - Clears a single file's diagnostics, and optionally removes it from the lookup maintained by this object. + Clears a single file's diagnostics. + + Optionally removes it from the lookup maintained by this object. + :param file_uri: The uri of the file to clear diagnostics for. - :param clear_from_lookup: Indicates whether the lookup tracking file diagnostics should be purged of this - diagnostic. + :param clear_from_lookup: Whether to purge this from the diagnostics lookup. :return: None """ # Send empty diagnostics for this file to the client. diff --git a/slither_lsp/app/logging/lsp_handler.py b/slither_lsp/app/logging/lsp_handler.py index b1b8441..8bc7cb1 100644 --- a/slither_lsp/app/logging/lsp_handler.py +++ b/slither_lsp/app/logging/lsp_handler.py @@ -1,6 +1,7 @@ -from logging import Handler, LogRecord, FATAL, ERROR, WARNING, INFO, DEBUG, NOTSET -from pygls.server import LanguageServer +from logging import DEBUG, ERROR, FATAL, INFO, NOTSET, WARNING, Handler, LogRecord + from lsprotocol.types import MessageType +from pygls.server import LanguageServer _level_to_type = { FATAL: MessageType.Error, diff --git a/slither_lsp/app/request_handlers/__init__.py b/slither_lsp/app/request_handlers/__init__.py index c0300f0..e88f41d 100644 --- a/slither_lsp/app/request_handlers/__init__.py +++ b/slither_lsp/app/request_handlers/__init__.py @@ -4,17 +4,17 @@ register_on_get_outgoing_calls, register_on_prepare_call_hierarchy, ) +from .code_lens import register_code_lens_handlers from .compilation import * from .goto_def_impl_refs import ( register_on_find_references, register_on_goto_definition, register_on_goto_implementation, ) +from .inlay_hints import register_inlay_hints_handlers +from .symbols import register_symbols_handlers from .type_hierarchy import ( register_on_get_subtypes, register_on_get_supertypes, register_on_prepare_type_hierarchy, ) -from .inlay_hints import register_inlay_hints_handlers -from .symbols import register_symbols_handlers -from .code_lens import register_code_lens_handlers diff --git a/slither_lsp/app/request_handlers/analysis/get_detector_list.py b/slither_lsp/app/request_handlers/analysis/get_detector_list.py index 02ee451..f0d9fa8 100644 --- a/slither_lsp/app/request_handlers/analysis/get_detector_list.py +++ b/slither_lsp/app/request_handlers/analysis/get_detector_list.py @@ -1,17 +1,16 @@ -# pylint: disable=unused-argument - from pygls.server import LanguageServer from slither.__main__ import get_detectors_and_printers, output_detectors_json def get_detector_list(ls: LanguageServer, params): """ - Handler which invokes slither to obtain a list of all detectors and some properties that describe them. + Handler which invokes slither to obtain a list of all detectors. + + Returns properties that describe each detector. """ # Obtain a list of detectors detectors, _ = get_detectors_and_printers() # Obtain the relevant object to be output as JSON. - detector_types_json = output_detectors_json(detectors) - return detector_types_json + return output_detectors_json(detectors) diff --git a/slither_lsp/app/request_handlers/analysis/get_version.py b/slither_lsp/app/request_handlers/analysis/get_version.py index 0766e96..f7b6987 100644 --- a/slither_lsp/app/request_handlers/analysis/get_version.py +++ b/slither_lsp/app/request_handlers/analysis/get_version.py @@ -1,5 +1,3 @@ -# pylint: disable=unused-argument - from importlib.metadata import version as pkg_version from pygls.server import LanguageServer diff --git a/slither_lsp/app/request_handlers/call_hierarchy.py b/slither_lsp/app/request_handlers/call_hierarchy.py index e7fe283..936d3f2 100644 --- a/slither_lsp/app/request_handlers/call_hierarchy.py +++ b/slither_lsp/app/request_handlers/call_hierarchy.py @@ -1,6 +1,6 @@ from collections import defaultdict from dataclasses import dataclass -from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple +from typing import TYPE_CHECKING import lsprotocol.types as lsp from slither.core.declarations import Function @@ -32,7 +32,7 @@ def register_on_prepare_call_hierarchy(ls: "SlitherServer"): @ls.feature(lsp.TEXT_DOCUMENT_PREPARE_CALL_HIERARCHY) def on_prepare_call_hierarchy( ls: "SlitherServer", params: lsp.CallHierarchyPrepareParams - ) -> Optional[List[lsp.CallHierarchyItem]]: + ) -> list[lsp.CallHierarchyItem] | None: """ `textDocument/prepareCallHierarchy` doesn't actually produce the call hierarchy in this case, it only detects what objects @@ -40,7 +40,7 @@ def on_prepare_call_hierarchy( The data returned from this method will be sent by the client back to the "get incoming/outgoing calls" later. """ - res: Dict[Tuple[str, int], lsp.CallHierarchyItem] = {} + res: dict[tuple[str, int], lsp.CallHierarchyItem] = {} # Obtain our filename for this file target_filename_str: str = uri_to_fs_path(params.text_document.uri) @@ -78,8 +78,8 @@ def register_on_get_incoming_calls(ls: "SlitherServer"): @ls.feature(lsp.CALL_HIERARCHY_INCOMING_CALLS) def on_get_incoming_calls( ls: "SlitherServer", params: lsp.CallHierarchyIncomingCallsParams - ) -> Optional[List[lsp.CallHierarchyIncomingCall]]: - res: Dict[CallItem, Set[Range]] = defaultdict(set) + ) -> list[lsp.CallHierarchyIncomingCall] | None: + res: dict[CallItem, set[Range]] = defaultdict(set) # Obtain our filename for this file # These will have been populated either by @@ -102,8 +102,7 @@ def on_get_incoming_calls( for comp_unit in analysis_result.analysis.compilation_units for f in comp_unit.functions for op in f.all_slithir_operations() - if isinstance(op, (InternalCall, HighLevelCall)) - and isinstance(op.function, Function) + if isinstance(op, (InternalCall, HighLevelCall)) and isinstance(op.function, Function) ] for func in referenced_functions: @@ -143,8 +142,8 @@ def register_on_get_outgoing_calls(ls: "SlitherServer"): @ls.feature(lsp.CALL_HIERARCHY_OUTGOING_CALLS) def on_get_outgoing_calls( ls: "SlitherServer", params: lsp.CallHierarchyOutgoingCallsParams - ) -> Optional[List[lsp.CallHierarchyOutgoingCall]]: - res: Dict[CallItem, Set[Range]] = defaultdict(set) + ) -> list[lsp.CallHierarchyOutgoingCall] | None: + res: dict[CallItem, set[Range]] = defaultdict(set) # Obtain our filename for this file target_filename_str = params.item.data["filename"] diff --git a/slither_lsp/app/request_handlers/code_lens.py b/slither_lsp/app/request_handlers/code_lens.py index b403b35..7b459da 100644 --- a/slither_lsp/app/request_handlers/code_lens.py +++ b/slither_lsp/app/request_handlers/code_lens.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING import lsprotocol.types as lsp @@ -12,18 +12,15 @@ def register_code_lens_handlers(ls: "SlitherServer"): @ls.thread() @ls.feature(lsp.TEXT_DOCUMENT_CODE_LENS) - def code_lens( - ls: "SlitherServer", params: lsp.CodeLensParams - ) -> Optional[List[lsp.CodeLens]]: + def code_lens(ls: "SlitherServer", params: lsp.CodeLensParams) -> list[lsp.CodeLens] | None: target_filename_str: str = uri_to_fs_path(params.text_document.uri) - res: List[lsp.CodeLens] = [] + res: list[lsp.CodeLens] = [] for analysis, comp in ls.get_analyses_containing(target_filename_str): filename = comp.filename_lookup(target_filename_str) functions = [ func for contract in analysis.contracts - if contract.source_mapping - and contract.source_mapping.filename == filename + if contract.source_mapping and contract.source_mapping.filename == filename for func in contract.functions_and_modifiers_declared ] for func in functions: diff --git a/slither_lsp/app/request_handlers/compilation/get_command_line_args.py b/slither_lsp/app/request_handlers/compilation/get_command_line_args.py index 981d15a..5e12e26 100644 --- a/slither_lsp/app/request_handlers/compilation/get_command_line_args.py +++ b/slither_lsp/app/request_handlers/compilation/get_command_line_args.py @@ -1,5 +1,3 @@ -# pylint: disable=protected-access, unused-argument - from argparse import ArgumentParser from crytic_compile.cryticparser.cryticparser import init as crytic_parser_init @@ -16,7 +14,7 @@ def get_command_line_args(ls: LanguageServer, params): crytic_parser_init(parser) # Loop through all option groups and underlying options and populate our result. - # (this is a bit hacky as it accesses a private variable, but we don't have that much of an option). + # (this is a bit hacky as it accesses a private variable, but we have no option). results = [] for arg_group in parser._action_groups: # Compile a list of args, skipping the help command. diff --git a/slither_lsp/app/request_handlers/goto_def_impl_refs.py b/slither_lsp/app/request_handlers/goto_def_impl_refs.py index 1a024f4..dca1367 100644 --- a/slither_lsp/app/request_handlers/goto_def_impl_refs.py +++ b/slither_lsp/app/request_handlers/goto_def_impl_refs.py @@ -1,6 +1,5 @@ -# pylint: disable=broad-exception-caught - -from typing import TYPE_CHECKING, Callable, List, Optional, Set +from collections.abc import Callable +from typing import TYPE_CHECKING import lsprotocol.types as lsp from slither import Slither @@ -18,16 +17,16 @@ def _inspect_analyses( target_filename_str: str, line: int, col: int, - func: Callable[[Slither, int], Set[Source]], -) -> List[lsp.Location]: + func: Callable[[Slither, int], set[Source]], +) -> list[lsp.Location]: # Compile a list of definitions results = [] # Loop through all compilations for analysis_result in ls.analyses: if analysis_result.analysis is not None: - # TODO: Remove this temporary try/catch once we refactor crytic-compile to now throw errors in - # these functions. + # TODO: Remove this temporary try/catch once we refactor + # crytic-compile to now throw errors in these functions. try: # Obtain the offset for this line + character position target_offset = analysis_result.compilation.get_global_offset_from_line( @@ -40,7 +39,7 @@ def _inspect_analyses( else: # Add all definitions from this source. for source in sources: - source_location: Optional[lsp.Location] = source_to_location(source) + source_location: lsp.Location | None = source_to_location(source) if source_location is not None: results.append(source_location) @@ -50,9 +49,7 @@ def _inspect_analyses( def register_on_goto_definition(ls: "SlitherServer"): @ls.thread() @ls.feature(lsp.TEXT_DOCUMENT_DEFINITION) - def on_goto_definition( - ls: "SlitherServer", params: lsp.DefinitionParams - ) -> List[lsp.Location]: + def on_goto_definition(ls: "SlitherServer", params: lsp.DefinitionParams) -> list[lsp.Location]: # Obtain our filename for this file target_filename_str: str = uri_to_fs_path(params.text_document.uri) @@ -61,9 +58,7 @@ def on_goto_definition( target_filename_str, params.position.line + 1, params.position.character, - lambda analysis, offset: analysis.offset_to_definitions( - target_filename_str, offset - ), + lambda analysis, offset: analysis.offset_to_definitions(target_filename_str, offset), ) @@ -72,7 +67,7 @@ def register_on_goto_implementation(ls: "SlitherServer"): @ls.feature(lsp.TEXT_DOCUMENT_IMPLEMENTATION) def on_goto_implementation( ls: "SlitherServer", params: lsp.ImplementationParams - ) -> List[lsp.Location]: + ) -> list[lsp.Location]: # Obtain our filename for this file target_filename_str: str = uri_to_fs_path(params.text_document.uri) @@ -92,7 +87,7 @@ def register_on_find_references(ls: "SlitherServer"): @ls.feature(lsp.TEXT_DOCUMENT_REFERENCES) def on_find_references( ls: "SlitherServer", params: lsp.ImplementationParams - ) -> Optional[List[lsp.Location]]: + ) -> list[lsp.Location] | None: # Obtain our filename for this file target_filename_str: str = uri_to_fs_path(params.text_document.uri) @@ -101,7 +96,5 @@ def on_find_references( target_filename_str, params.position.line + 1, params.position.character, - lambda analysis, offset: analysis.offset_to_references( - target_filename_str, offset - ), + lambda analysis, offset: analysis.offset_to_references(target_filename_str, offset), ) diff --git a/slither_lsp/app/request_handlers/inlay_hints.py b/slither_lsp/app/request_handlers/inlay_hints.py index 73da426..3d2bbeb 100644 --- a/slither_lsp/app/request_handlers/inlay_hints.py +++ b/slither_lsp/app/request_handlers/inlay_hints.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING import lsprotocol.types as lsp from slither.utils.function import get_function_id @@ -13,23 +13,20 @@ def register_inlay_hints_handlers(ls: "SlitherServer"): @ls.thread() @ls.feature(lsp.TEXT_DOCUMENT_INLAY_HINT) - def inlay_hints( - ls: "SlitherServer", params: lsp.InlayHintParams - ) -> Optional[List[lsp.InlayHint]]: + def inlay_hints(ls: "SlitherServer", params: lsp.InlayHintParams) -> list[lsp.InlayHint] | None: """ Shows the ID of a function next to its definition """ # Obtain our filename for this file target_filename_str: str = uri_to_fs_path(params.text_document.uri) - res: List[lsp.InlayHint] = [] + res: list[lsp.InlayHint] = [] for analysis, comp in ls.get_analyses_containing(target_filename_str): filename = comp.filename_lookup(target_filename_str) functions = [ func for contract in analysis.contracts - if contract.source_mapping - and contract.source_mapping.filename == filename + if contract.source_mapping and contract.source_mapping.filename == filename for func in contract.functions_and_modifiers_declared if func.visibility in {"public", "external"} ] @@ -39,9 +36,7 @@ def inlay_hints( name_range = get_object_name_range(func, comp) res.append( lsp.InlayHint( - position=lsp.Position( - name_range.end.line, name_range.end.character - ), + position=lsp.Position(name_range.end.line, name_range.end.character), label=f": {function_id:#0{10}x}", ) ) diff --git a/slither_lsp/app/request_handlers/symbols.py b/slither_lsp/app/request_handlers/symbols.py index 0a9bd2d..3d29943 100644 --- a/slither_lsp/app/request_handlers/symbols.py +++ b/slither_lsp/app/request_handlers/symbols.py @@ -1,6 +1,4 @@ -# pylint: disable=too-many-branches - -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING import lsprotocol.types as lsp @@ -16,13 +14,13 @@ def register_symbols_handlers(ls: "SlitherServer"): @ls.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL) def document_symbol( ls: "SlitherServer", params: lsp.DocumentSymbolParams - ) -> Optional[List[lsp.DocumentSymbol]]: + ) -> list[lsp.DocumentSymbol] | None: """ Allows navigation using VSCode "breadcrumbs" """ # Obtain our filename for this file target_filename_str: str = uri_to_fs_path(params.text_document.uri) - res: List[lsp.DocumentSymbol] = [] + res: list[lsp.DocumentSymbol] = [] def add_child(children, obj, kind): children.append( @@ -38,16 +36,13 @@ def add_child(children, obj, kind): filename = comp.filename_lookup(target_filename_str) for contract in analysis.contracts: - if ( - not contract.source_mapping - or contract.source_mapping.filename != filename - ): + if not contract.source_mapping or contract.source_mapping.filename != filename: continue if contract.is_interface: kind = lsp.SymbolKind.Interface else: kind = lsp.SymbolKind.Class - children: List[lsp.DocumentSymbol] = [] + children: list[lsp.DocumentSymbol] = [] for struct in contract.structures_declared: if struct.source_mapping is None: diff --git a/slither_lsp/app/request_handlers/type_hierarchy.py b/slither_lsp/app/request_handlers/type_hierarchy.py index 22b140d..bcade44 100644 --- a/slither_lsp/app/request_handlers/type_hierarchy.py +++ b/slither_lsp/app/request_handlers/type_hierarchy.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import TYPE_CHECKING, List, Optional, Set +from typing import TYPE_CHECKING import lsprotocol.types as lsp from slither.core.declarations import Contract @@ -28,8 +28,8 @@ def register_on_prepare_type_hierarchy(ls: "SlitherServer"): @ls.feature(lsp.TEXT_DOCUMENT_PREPARE_TYPE_HIERARCHY) def on_prepare_type_hierarchy( ls: "SlitherServer", params: lsp.TypeHierarchyPrepareParams - ) -> Optional[List[lsp.TypeHierarchyItem]]: - res: Set[TypeItem] = set() + ) -> list[lsp.TypeHierarchyItem] | None: + res: set[TypeItem] = set() # Obtain our filename for this file target_filename_str: str = uri_to_fs_path(params.text_document.uri) @@ -83,8 +83,8 @@ def register_on_get_subtypes(ls: "SlitherServer"): @ls.feature(lsp.TYPE_HIERARCHY_SUBTYPES) def on_get_subtypes( ls: "SlitherServer", params: lsp.TypeHierarchySubtypesParams - ) -> Optional[List[lsp.TypeHierarchyItem]]: - res: Set[TypeItem] = set() + ) -> list[lsp.TypeHierarchyItem] | None: + res: set[TypeItem] = set() # Obtain our filename for this file # These will have been populated either by @@ -96,9 +96,7 @@ def on_get_subtypes( referenced_contracts = [ contract for analysis, _ in ls.get_analyses_containing(target_filename_str) - for contract in analysis.offset_to_objects( - target_filename_str, target_offset - ) + for contract in analysis.offset_to_objects(target_filename_str, target_offset) if isinstance(contract, Contract) ] @@ -148,8 +146,8 @@ def register_on_get_supertypes(ls: "SlitherServer"): @ls.feature(lsp.TYPE_HIERARCHY_SUPERTYPES) def on_get_supertypes( ls: "SlitherServer", params: lsp.TypeHierarchySupertypesParams - ) -> Optional[List[lsp.TypeHierarchyItem]]: - res: Set[TypeItem] = set() + ) -> list[lsp.TypeHierarchyItem] | None: + res: set[TypeItem] = set() # Obtain our filename for this file # These will have been populated either by @@ -161,9 +159,7 @@ def on_get_supertypes( supertypes = [ (supertype, comp) for analysis, comp in ls.get_analyses_containing(target_filename_str) - for contract in analysis.offset_to_objects( - target_filename_str, target_offset - ) + for contract in analysis.offset_to_objects(target_filename_str, target_offset) if isinstance(contract, Contract) for supertype in contract.immediate_inheritance ] diff --git a/slither_lsp/app/request_handlers/types.py b/slither_lsp/app/request_handlers/types.py index 1eb281a..3708fec 100644 --- a/slither_lsp/app/request_handlers/types.py +++ b/slither_lsp/app/request_handlers/types.py @@ -1,9 +1,10 @@ -from typing import TypeAlias, Tuple +from typing import TypeAlias + import lsprotocol.types as lsp # Type definitions for call hierarchy -Pos: TypeAlias = Tuple[int, int] -Range: TypeAlias = Tuple[Pos, Pos] +Pos: TypeAlias = tuple[int, int] +Range: TypeAlias = tuple[Pos, Pos] def to_lsp_pos(pos: Pos) -> lsp.Position: diff --git a/slither_lsp/app/slither_server.py b/slither_lsp/app/slither_server.py index 708a7eb..1fc32d4 100644 --- a/slither_lsp/app/slither_server.py +++ b/slither_lsp/app/slither_server.py @@ -1,12 +1,9 @@ -# pylint: disable=broad-exception-caught, protected-access, unused-argument - import logging from collections import defaultdict from concurrent.futures import ThreadPoolExecutor from functools import lru_cache -from threading import Lock -from typing import Dict, List, Optional, Tuple, Type from os.path import split +from threading import Lock import lsprotocol.types as lsp from crytic_compile.crytic_compile import CryticCompile @@ -24,6 +21,8 @@ from slither_lsp.app.feature_analyses.slither_diagnostics import SlitherDiagnostics from slither_lsp.app.logging import LSPHandler from slither_lsp.app.request_handlers import ( + register_code_lens_handlers, + register_inlay_hints_handlers, register_on_find_references, register_on_get_incoming_calls, register_on_get_outgoing_calls, @@ -33,9 +32,7 @@ register_on_goto_implementation, register_on_prepare_call_hierarchy, register_on_prepare_type_hierarchy, - register_inlay_hints_handlers, register_symbols_handlers, - register_code_lens_handlers, ) from slither_lsp.app.types.analysis_structures import ( AnalysisResult, @@ -44,45 +41,41 @@ ) from slither_lsp.app.types.params import ( METHOD_TO_TYPES, - SLITHER_SET_DETECTOR_SETTINGS, SLITHER_ANALYZE, + SLITHER_SET_DETECTOR_SETTINGS, AnalysisRequestParams, ) from slither_lsp.app.utils.file_paths import normalize_uri, uri_to_fs_path # TODO(frabert): Maybe this should be upstreamed? https://github.com/openlawlibrary/pygls/discussions/338 -METHOD_TO_OPTIONS[ - lsp.WORKSPACE_DID_CHANGE_WATCHED_FILES -] = lsp.DidChangeWatchedFilesRegistrationOptions +METHOD_TO_OPTIONS[lsp.WORKSPACE_DID_CHANGE_WATCHED_FILES] = ( + lsp.DidChangeWatchedFilesRegistrationOptions +) class SlitherProtocol(LanguageServerProtocol): # See https://github.com/openlawlibrary/pygls/discussions/441 @lru_cache - def get_message_type(self, method: str) -> Optional[Type]: - return METHOD_TO_TYPES.get(method, (None,))[0] or super().get_message_type( - method - ) + def get_message_type(self, method: str) -> type | None: + return METHOD_TO_TYPES.get(method, (None,))[0] or super().get_message_type(method) @lru_cache - def get_result_type(self, method: str) -> Optional[Type]: - return METHOD_TO_TYPES.get(method, (None, None))[1] or super().get_result_type( - method - ) + def get_result_type(self, method: str) -> type | None: + return METHOD_TO_TYPES.get(method, (None, None))[1] or super().get_result_type(method) class SlitherServer(LanguageServer): _logger: logging.Logger - _init_params: Optional[lsp.InitializeParams] = None + _init_params: lsp.InitializeParams | None = None # Define our workspace parameters. - workspaces: Dict[str, AnalysisResult] = {} - # `workspace_in_progress[uri]` is locked if there's a compilation in progress for the workspace `uri` - workspace_in_progress: Dict[str, Lock] = defaultdict(Lock) + workspaces: dict[str, AnalysisResult] = {} + # `workspace_in_progress[uri]` is locked if compilation is in progress + workspace_in_progress: dict[str, Lock] = defaultdict(Lock) @property - def analyses(self) -> List[AnalysisResult]: + def analyses(self) -> list[AnalysisResult]: return list(self.workspaces.values()) # Define our slither diagnostics provider @@ -195,9 +188,7 @@ def do_compile(): analysis, detector_classes, [] ) # Parse detector results - if detector_results is not None and isinstance( - detector_results, list - ): + if detector_results is not None and isinstance(detector_results, list): detector_results = [ SlitherDetectorResult.from_dict(detector_result) for detector_result in detector_results @@ -221,9 +212,7 @@ def do_compile(): f"Compilation for {workspace_name} has failed. See log for details.", lsp.MessageType.Info, ) - self._logger.log( - logging.ERROR, "Compiling %s has failed: %s", path, err - ) + self._logger.log(logging.ERROR, "Compiling %s has failed: %s", path, err) self.workspaces[uri] = AnalysisResult( succeeded=analyzed_successfully, @@ -236,9 +225,7 @@ def do_compile(): self.analysis_pool.submit(do_compile) - def _on_did_change_workspace_folders( - self, params: lsp.DidChangeWorkspaceFoldersParams - ) -> None: + def _on_did_change_workspace_folders(self, params: lsp.DidChangeWorkspaceFoldersParams) -> None: """ Applies client-reported changes to the workspace folders. :param params: The client's workspace change message parameters. @@ -255,11 +242,12 @@ def _on_did_change_workspace_folders( def _on_set_detector_settings(self, params: SlitherDetectorSettings) -> None: """ - Sets the detector settings for the workspace, indicating how detector output should be presented. + Sets detector settings, indicating how detector output should be presented. + :param params: The parameters provided for the set detector settings request. :return: None """ - # If our detector settings are not different than existing ones, we do not need to trigger any on-change events. + # If settings unchanged, we do not need to trigger any on-change events. if params == self.detector_settings: return @@ -277,9 +265,7 @@ def _refresh_detector_output(self): # Update our diagnostics with new detector output. self.slither_diagnostics.update(self.analyses, self.detector_settings) - def get_analyses_containing( - self, filename: str - ) -> List[Tuple[Slither, CryticCompile]]: + def get_analyses_containing(self, filename: str) -> list[tuple[Slither, CryticCompile]]: def lookup(comp: CryticCompile): try: return comp.filename_lookup(filename) diff --git a/slither_lsp/app/types/__init__.py b/slither_lsp/app/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/slither_lsp/app/types/analysis_structures.py b/slither_lsp/app/types/analysis_structures.py index b39dcc3..625b792 100644 --- a/slither_lsp/app/types/analysis_structures.py +++ b/slither_lsp/app/types/analysis_structures.py @@ -1,5 +1,3 @@ -from typing import List, Optional - import attrs from crytic_compile import CryticCompile from slither import Slither @@ -14,8 +12,8 @@ class SlitherDetectorSettings: enabled: bool = attrs.field(default=True) """ Defines whether detector output should be enabled at all """ - hidden_checks: List[str] = attrs.field(default=[]) - """ Defines a list of detector check identifiers which represent detector output we wish to suppress. """ + hidden_checks: list[str] = attrs.field(default=[]) + """ List of detector check IDs whose output should be suppressed. """ @attrs.define @@ -35,7 +33,7 @@ class SlitherDetectorResultElementSourceMapping: filename_short: str = attrs.field() """ A short filepath used for display purposes. """ - lines: List[int] = attrs.field() + lines: list[int] = attrs.field() """ A list of line numbers associated with the finding. """ starting_column: int = attrs.field() @@ -52,7 +50,7 @@ class SlitherDetectorResultElement: name: str = attrs.field() """ The name of the source mapped item associated with a slither detector result """ - source_mapping: Optional[SlitherDetectorResultElementSourceMapping] = attrs.field() + source_mapping: SlitherDetectorResultElementSourceMapping | None = attrs.field() """ The source mapping associated with the element associated with the detector result. """ type: str = attrs.field() @@ -89,7 +87,7 @@ class SlitherDetectorResult: description: str = attrs.field() """ A description of a detector result. """ - elements: List[SlitherDetectorResultElement] = attrs.field() + elements: list[SlitherDetectorResultElement] = attrs.field() """ Source mapped elements that are relevant to this detector result. """ @staticmethod @@ -99,10 +97,7 @@ def from_dict(dict_): confidence=dict_["confidence"], impact=dict_["impact"], description=dict_["description"], - elements=[ - SlitherDetectorResultElement.from_dict(elem) - for elem in dict_["elements"] - ], + elements=[SlitherDetectorResultElement.from_dict(elem) for elem in dict_["elements"]], ) @@ -115,14 +110,14 @@ class AnalysisResult: succeeded: bool = attrs.field() """ Defines if our analysis succeeded """ - compilation: Optional[CryticCompile] = attrs.field() + compilation: CryticCompile | None = attrs.field() """ Our compilation result """ - analysis: Optional[Slither] = attrs.field() + analysis: Slither | None = attrs.field() """ Our analysis result """ - error: Optional[Exception] = attrs.field() + error: Exception | None = attrs.field() """ An exception if the analysis did not succeed """ - detector_results: Optional[List[SlitherDetectorResult]] = attrs.field(default=None) + detector_results: list[SlitherDetectorResult] | None = attrs.field(default=None) """ Detector output """ diff --git a/slither_lsp/app/types/params.py b/slither_lsp/app/types/params.py index b7814fc..696e599 100644 --- a/slither_lsp/app/types/params.py +++ b/slither_lsp/app/types/params.py @@ -1,12 +1,11 @@ -from typing import List, Optional, Union - import attrs + from slither_lsp.app.types.analysis_structures import SlitherDetectorSettings @attrs.define class AnalysisRequestParams: - uris: Optional[List[str]] = attrs.field() + uris: list[str] | None = attrs.field() SLITHER_ANALYZE = "$/slither/analyze" @@ -18,7 +17,7 @@ class AnalysisRequestParams: @attrs.define class SetDetectorSettingsRequest: - id: Union[int, str] = attrs.field() + id: int | str = attrs.field() params: SlitherDetectorSettings = attrs.field() method: str = SLITHER_SET_DETECTOR_SETTINGS jsonrpc: str = attrs.field(default="2.0") diff --git a/slither_lsp/app/utils/__init__.py b/slither_lsp/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/slither_lsp/app/utils/file_paths.py b/slither_lsp/app/utils/file_paths.py index 0fa94b4..8d88fb3 100644 --- a/slither_lsp/app/utils/file_paths.py +++ b/slither_lsp/app/utils/file_paths.py @@ -1,7 +1,7 @@ import os -from typing import Iterable, Set -from urllib.parse import unquote_plus, urlparse, urljoin -from urllib.request import url2pathname, pathname2url +from collections.abc import Iterable +from urllib.parse import unquote_plus, urljoin, urlparse +from urllib.request import pathname2url, url2pathname def is_solidity_file(path: str) -> bool: @@ -10,8 +10,7 @@ def is_solidity_file(path: str) -> bool: def uri_to_fs_path(uri: str) -> str: - path = url2pathname(unquote_plus(urlparse(uri).path)) - return path + return url2pathname(unquote_plus(urlparse(uri).path)) def normalize_uri(uri: str) -> str: @@ -19,11 +18,10 @@ def normalize_uri(uri: str) -> str: def fs_path_to_uri(path: str) -> str: - uri = urljoin("file:", pathname2url(path)) - return uri + return urljoin("file:", pathname2url(path)) -def get_solidity_files(folders: Iterable[str], recursive=True) -> Set[str]: +def get_solidity_files(folders: Iterable[str], recursive=True) -> set[str]: """ Loops through all provided folders and obtains a list of all solidity files existing in them. This skips 'node_module' folders created by npm/yarn. diff --git a/slither_lsp/app/utils/ranges.py b/slither_lsp/app/utils/ranges.py index c28dac8..7a179e4 100644 --- a/slither_lsp/app/utils/ranges.py +++ b/slither_lsp/app/utils/ranges.py @@ -1,5 +1,3 @@ -from typing import Union - import lsprotocol.types as lsp from crytic_compile.crytic_compile import CryticCompile from slither.core.declarations import ( @@ -11,6 +9,7 @@ ) from slither.core.source_mapping.source_mapping import Source from slither.utils.source_mapping import get_definition + from slither_lsp.app.utils.file_paths import fs_path_to_uri @@ -49,7 +48,7 @@ def source_to_location(source: Source) -> lsp.Location: def get_object_name_range( - obj: Union[Function, Contract, Enum, Event, Structure], comp: CryticCompile + obj: Function | Contract | Enum | Event | Structure, comp: CryticCompile ) -> lsp.Range: name_pos = get_definition(obj, comp) return lsp.Range( diff --git a/slither_lsp/py.typed b/slither_lsp/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cbfac95 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +"""Shared test fixtures for slither-lsp tests.""" + +from pathlib import Path + +import pytest + + +@pytest.fixture +def fixtures_path() -> Path: + """Return the path to the test fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +@pytest.fixture +def simple_sol_path(fixtures_path: Path) -> Path: + """Return the path to the simple.sol fixture.""" + return fixtures_path / "simple.sol" + + +@pytest.fixture +def detector_output_path(fixtures_path: Path) -> Path: + """Return the path to the detector output JSON fixture.""" + return fixtures_path / "detector_output.json" diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/test_lsp_server.py b/tests/e2e/test_lsp_server.py new file mode 100644 index 0000000..0562774 --- /dev/null +++ b/tests/e2e/test_lsp_server.py @@ -0,0 +1,241 @@ +"""End-to-end tests for the LSP server.""" + +from pathlib import Path + +import lsprotocol.types as lsp + + +class TestServerInitialization: + """Tests for server initialization and capabilities.""" + + def test_server_capabilities_structure(self): + """Test that server capabilities are properly structured.""" + # Test basic capability structures + text_doc_sync = lsp.TextDocumentSyncOptions( + open_close=True, + change=lsp.TextDocumentSyncKind.Incremental, + save=lsp.SaveOptions(include_text=True), + ) + assert text_doc_sync.open_close is True + assert text_doc_sync.change == lsp.TextDocumentSyncKind.Incremental + + def test_completion_options(self): + """Test completion options structure.""" + completion = lsp.CompletionOptions( + trigger_characters=[".", ":"], + resolve_provider=True, + ) + assert "." in completion.trigger_characters + assert completion.resolve_provider is True + + def test_hover_options(self): + """Test hover provider options.""" + hover = lsp.HoverOptions(work_done_progress=True) + assert hover.work_done_progress is True + + +class TestDocumentSynchronization: + """Tests for document synchronization events.""" + + def test_text_document_item(self): + """Test TextDocumentItem creation for didOpen.""" + doc = lsp.TextDocumentItem( + uri="file:///tmp/test.sol", + language_id="solidity", + version=1, + text="// SPDX-License-Identifier: MIT\npragma solidity ^0.8.0;\n", + ) + assert doc.uri == "file:///tmp/test.sol" + assert doc.language_id == "solidity" + assert doc.version == 1 + + def test_text_document_content_change(self): + """Test TextDocumentContentChangeEvent structure.""" + # Full document change + change_full = lsp.TextDocumentContentChangeWholeDocument( + text="new content", + ) + assert change_full.text == "new content" + + # Incremental change + change_incremental = lsp.TextDocumentContentChangePartial( + range=lsp.Range( + start=lsp.Position(line=5, character=0), + end=lsp.Position(line=5, character=10), + ), + text="new text", + ) + assert change_incremental.text == "new text" + assert change_incremental.range.start.line == 5 + + def test_versioned_text_document_identifier(self): + """Test VersionedTextDocumentIdentifier for didChange.""" + versioned_doc = lsp.VersionedTextDocumentIdentifier( + uri="file:///tmp/test.sol", + version=2, + ) + assert versioned_doc.version == 2 + + +class TestDiagnostics: + """Tests for diagnostic publishing.""" + + def test_diagnostic_creation(self): + """Test Diagnostic structure.""" + diagnostic = lsp.Diagnostic( + range=lsp.Range( + start=lsp.Position(line=10, character=0), + end=lsp.Position(line=10, character=50), + ), + message="Reentrancy vulnerability detected", + severity=lsp.DiagnosticSeverity.Warning, + code="reentrancy-eth", + source="slither", + ) + assert diagnostic.message == "Reentrancy vulnerability detected" + assert diagnostic.severity == lsp.DiagnosticSeverity.Warning + assert diagnostic.code == "reentrancy-eth" + assert diagnostic.source == "slither" + + def test_publish_diagnostics_params(self): + """Test PublishDiagnosticsParams structure.""" + params = lsp.PublishDiagnosticsParams( + uri="file:///tmp/test.sol", + version=1, + diagnostics=[ + lsp.Diagnostic( + range=lsp.Range( + start=lsp.Position(line=5, character=0), + end=lsp.Position(line=5, character=20), + ), + message="Test diagnostic", + severity=lsp.DiagnosticSeverity.Information, + ) + ], + ) + assert params.uri == "file:///tmp/test.sol" + assert len(params.diagnostics) == 1 + + +class TestCustomCommands: + """Tests for custom Slither commands.""" + + def test_execute_command_params(self): + """Test ExecuteCommandParams for custom commands.""" + params = lsp.ExecuteCommandParams( + command="slither.analyze", + arguments=["file:///tmp/test.sol", {"detectors": ["reentrancy-eth"]}], + ) + assert params.command == "slither.analyze" + assert len(params.arguments) == 2 + + def test_command_registration(self): + """Test CommandRegistrationOptions for command registration.""" + commands = ["slither.analyze", "slither.getVersion", "slither.getDetectorList"] + registration = lsp.ExecuteCommandOptions(commands=commands) + assert "slither.analyze" in registration.commands + assert len(registration.commands) == 3 + + +class TestWorkspaceFolders: + """Tests for workspace folder support.""" + + def test_workspace_folder(self): + """Test WorkspaceFolder structure.""" + folder = lsp.WorkspaceFolder( + uri="file:///home/user/project", + name="my-project", + ) + assert folder.uri == "file:///home/user/project" + assert folder.name == "my-project" + + def test_workspace_folders_change_event(self): + """Test WorkspaceFoldersChangeEvent structure.""" + added = [ + lsp.WorkspaceFolder(uri="file:///new/project", name="new-project"), + ] + removed = [ + lsp.WorkspaceFolder(uri="file:///old/project", name="old-project"), + ] + event = lsp.WorkspaceFoldersChangeEvent(added=added, removed=removed) + assert len(event.added) == 1 + assert len(event.removed) == 1 + + +class TestServerMessages: + """Tests for server-to-client messages.""" + + def test_show_message_params(self): + """Test ShowMessageParams for notifications.""" + params = lsp.ShowMessageParams( + type=lsp.MessageType.Info, + message="Slither analysis complete", + ) + assert params.type == lsp.MessageType.Info + assert params.message == "Slither analysis complete" + + def test_log_message_params(self): + """Test LogMessageParams for logging.""" + params = lsp.LogMessageParams( + type=lsp.MessageType.Log, + message="Starting analysis of test.sol", + ) + assert params.type == lsp.MessageType.Log + + def test_message_action_item(self): + """Test MessageActionItem for show message request.""" + action = lsp.MessageActionItem(title="Show Details") + assert action.title == "Show Details" + + +class TestProgressReporting: + """Tests for progress reporting.""" + + def test_work_done_progress_begin(self): + """Test WorkDoneProgressBegin structure.""" + begin = lsp.WorkDoneProgressBegin( + kind="begin", + title="Running Slither", + message="Analyzing contracts...", + cancellable=True, + percentage=0, + ) + assert begin.title == "Running Slither" + assert begin.cancellable is True + assert begin.percentage == 0 + + def test_work_done_progress_report(self): + """Test WorkDoneProgressReport structure.""" + report = lsp.WorkDoneProgressReport( + kind="report", + message="Analyzing Token.sol", + percentage=50, + ) + assert report.percentage == 50 + + def test_work_done_progress_end(self): + """Test WorkDoneProgressEnd structure.""" + end = lsp.WorkDoneProgressEnd( + kind="end", + message="Analysis complete", + ) + assert end.message == "Analysis complete" + + +class TestFixtureContracts: + """Tests using fixture contracts.""" + + def test_simple_sol_exists(self, simple_sol_path: Path): + """Test that simple.sol fixture exists and is valid.""" + assert simple_sol_path.exists() + content = simple_sol_path.read_text() + assert "contract SimpleStorage" in content + assert "pragma solidity" in content + + def test_inheritance_sol_exists(self, fixtures_path: Path): + """Test that inheritance.sol fixture exists.""" + inheritance_path = fixtures_path / "inheritance.sol" + assert inheritance_path.exists() + content = inheritance_path.read_text() + assert "interface ICounter" in content + assert "contract Counter is BaseCounter" in content diff --git a/tests/fixtures/detector_output.json b/tests/fixtures/detector_output.json new file mode 100644 index 0000000..1cecef6 --- /dev/null +++ b/tests/fixtures/detector_output.json @@ -0,0 +1,76 @@ +[ + { + "check": "reentrancy-eth", + "confidence": "Medium", + "impact": "High", + "description": "Reentrancy in Test.withdraw() (tests/fixtures/vulnerable.sol#10-15)", + "elements": [ + { + "name": "withdraw", + "source_mapping": { + "start": 200, + "length": 150, + "filename_absolute": "/tmp/test/vulnerable.sol", + "filename_relative": "vulnerable.sol", + "filename_short": "vulnerable.sol", + "lines": [10, 11, 12, 13, 14, 15], + "starting_column": 5, + "ending_column": 6, + "is_dependency": false + }, + "type": "function" + }, + { + "name": "balances", + "source_mapping": { + "start": 50, + "length": 30, + "filename_absolute": "/tmp/test/vulnerable.sol", + "filename_relative": "vulnerable.sol", + "filename_short": "vulnerable.sol", + "lines": [3], + "starting_column": 5, + "ending_column": 35, + "is_dependency": false + }, + "type": "variable" + } + ] + }, + { + "check": "unchecked-lowlevel", + "confidence": "High", + "impact": "Medium", + "description": "Low-level call return value not checked in Test.withdraw() (tests/fixtures/vulnerable.sol#12)", + "elements": [ + { + "name": "withdraw", + "source_mapping": { + "start": 250, + "length": 50, + "filename_absolute": "/tmp/test/vulnerable.sol", + "filename_relative": "vulnerable.sol", + "filename_short": "vulnerable.sol", + "lines": [12], + "starting_column": 9, + "ending_column": 45, + "is_dependency": false + }, + "type": "function" + } + ] + }, + { + "check": "naming-convention", + "confidence": "High", + "impact": "Informational", + "description": "Parameter Test.setValue(uint256)._value does not follow naming convention", + "elements": [ + { + "name": "_value", + "source_mapping": null, + "type": "parameter" + } + ] + } +] diff --git a/tests/fixtures/inheritance.sol b/tests/fixtures/inheritance.sol new file mode 100644 index 0000000..72f34f7 --- /dev/null +++ b/tests/fixtures/inheritance.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface ICounter { + function increment() external; + function getCount() external view returns (uint256); +} + +abstract contract BaseCounter { + uint256 internal count; + + function _increment() internal { + count += 1; + } +} + +contract Counter is BaseCounter, ICounter { + function increment() external override { + _increment(); + } + + function getCount() external view override returns (uint256) { + return count; + } +} + +contract ExtendedCounter is Counter { + function decrement() external { + require(count > 0, "Counter cannot go below zero"); + count -= 1; + } + + function reset() external { + count = 0; + } +} diff --git a/tests/fixtures/simple.sol b/tests/fixtures/simple.sol new file mode 100644 index 0000000..e1455c7 --- /dev/null +++ b/tests/fixtures/simple.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract SimpleStorage { + uint256 private storedValue; + + event ValueChanged(uint256 newValue); + + function setValue(uint256 newValue) public { + storedValue = newValue; + emit ValueChanged(newValue); + } + + function getValue() public view returns (uint256) { + return storedValue; + } +} diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_request_handlers.py b/tests/integration/test_request_handlers.py new file mode 100644 index 0000000..99b83ee --- /dev/null +++ b/tests/integration/test_request_handlers.py @@ -0,0 +1,191 @@ +"""Integration tests for request handlers.""" + +import lsprotocol.types as lsp + + +class TestGotoDefinition: + """Tests for goto definition functionality patterns.""" + + def test_location_creation(self): + """Test that LSP locations are created correctly.""" + location = lsp.Location( + uri="file:///tmp/test.sol", + range=lsp.Range( + start=lsp.Position(line=10, character=5), + end=lsp.Position(line=10, character=20), + ), + ) + assert location.uri == "file:///tmp/test.sol" + assert location.range.start.line == 10 + assert location.range.start.character == 5 + + +class TestDocumentSymbols: + """Tests for document symbol functionality patterns.""" + + def test_symbol_information_creation(self): + """Test that symbol information is created correctly.""" + symbol = lsp.SymbolInformation( + name="MyContract", + kind=lsp.SymbolKind.Class, + location=lsp.Location( + uri="file:///tmp/test.sol", + range=lsp.Range( + start=lsp.Position(line=0, character=0), + end=lsp.Position(line=50, character=1), + ), + ), + ) + assert symbol.name == "MyContract" + assert symbol.kind == lsp.SymbolKind.Class + + def test_document_symbol_creation(self): + """Test DocumentSymbol creation for hierarchical symbols.""" + child = lsp.DocumentSymbol( + name="setValue", + kind=lsp.SymbolKind.Method, + range=lsp.Range( + start=lsp.Position(line=5, character=4), + end=lsp.Position(line=10, character=5), + ), + selection_range=lsp.Range( + start=lsp.Position(line=5, character=13), + end=lsp.Position(line=5, character=21), + ), + ) + parent = lsp.DocumentSymbol( + name="MyContract", + kind=lsp.SymbolKind.Class, + range=lsp.Range( + start=lsp.Position(line=0, character=0), + end=lsp.Position(line=50, character=1), + ), + selection_range=lsp.Range( + start=lsp.Position(line=0, character=9), + end=lsp.Position(line=0, character=19), + ), + children=[child], + ) + assert parent.name == "MyContract" + assert len(parent.children) == 1 + assert parent.children[0].name == "setValue" + + +class TestCallHierarchy: + """Tests for call hierarchy functionality patterns.""" + + def test_call_hierarchy_item_creation(self): + """Test CallHierarchyItem creation.""" + item = lsp.CallHierarchyItem( + name="withdraw", + kind=lsp.SymbolKind.Function, + uri="file:///tmp/test.sol", + range=lsp.Range( + start=lsp.Position(line=10, character=4), + end=lsp.Position(line=20, character=5), + ), + selection_range=lsp.Range( + start=lsp.Position(line=10, character=13), + end=lsp.Position(line=10, character=21), + ), + ) + assert item.name == "withdraw" + assert item.kind == lsp.SymbolKind.Function + + def test_call_hierarchy_incoming_call(self): + """Test CallHierarchyIncomingCall creation.""" + caller = lsp.CallHierarchyItem( + name="main", + kind=lsp.SymbolKind.Function, + uri="file:///tmp/test.sol", + range=lsp.Range( + start=lsp.Position(line=0, character=0), + end=lsp.Position(line=5, character=1), + ), + selection_range=lsp.Range( + start=lsp.Position(line=0, character=9), + end=lsp.Position(line=0, character=13), + ), + ) + incoming = lsp.CallHierarchyIncomingCall( + from_=caller, + from_ranges=[ + lsp.Range( + start=lsp.Position(line=3, character=8), + end=lsp.Position(line=3, character=16), + ) + ], + ) + assert incoming.from_.name == "main" + assert len(incoming.from_ranges) == 1 + + +class TestTypeHierarchy: + """Tests for type hierarchy functionality patterns.""" + + def test_type_hierarchy_item_creation(self): + """Test TypeHierarchyItem creation.""" + item = lsp.TypeHierarchyItem( + name="ERC20", + kind=lsp.SymbolKind.Class, + uri="file:///tmp/token.sol", + range=lsp.Range( + start=lsp.Position(line=0, character=0), + end=lsp.Position(line=100, character=1), + ), + selection_range=lsp.Range( + start=lsp.Position(line=0, character=9), + end=lsp.Position(line=0, character=14), + ), + ) + assert item.name == "ERC20" + assert item.kind == lsp.SymbolKind.Class + + +class TestInlayHints: + """Tests for inlay hint functionality patterns.""" + + def test_inlay_hint_creation(self): + """Test InlayHint creation.""" + hint = lsp.InlayHint( + position=lsp.Position(line=10, character=20), + label="uint256", + kind=lsp.InlayHintKind.Type, + ) + assert hint.label == "uint256" + assert hint.kind == lsp.InlayHintKind.Type + assert hint.position.line == 10 + + def test_inlay_hint_with_parts(self): + """Test InlayHint with label parts.""" + parts = [ + lsp.InlayHintLabelPart(value="address"), + lsp.InlayHintLabelPart(value=" "), + lsp.InlayHintLabelPart(value="owner"), + ] + hint = lsp.InlayHint( + position=lsp.Position(line=5, character=10), + label=parts, + kind=lsp.InlayHintKind.Parameter, + ) + assert len(hint.label) == 3 + + +class TestCodeLens: + """Tests for code lens functionality patterns.""" + + def test_code_lens_creation(self): + """Test CodeLens creation.""" + lens = lsp.CodeLens( + range=lsp.Range( + start=lsp.Position(line=0, character=0), + end=lsp.Position(line=0, character=20), + ), + command=lsp.Command( + title="Run Slither", + command="slither.analyze", + arguments=["file:///tmp/test.sol"], + ), + ) + assert lens.command.title == "Run Slither" + assert lens.command.command == "slither.analyze" diff --git a/tests/integration/test_slither_diagnostics.py b/tests/integration/test_slither_diagnostics.py new file mode 100644 index 0000000..8806b01 --- /dev/null +++ b/tests/integration/test_slither_diagnostics.py @@ -0,0 +1,286 @@ +"""Integration tests for slither_lsp.app.feature_analyses.slither_diagnostics module.""" + +from unittest.mock import MagicMock + +import lsprotocol.types as lsp + +from slither_lsp.app.feature_analyses.slither_diagnostics import SlitherDiagnostics +from slither_lsp.app.types.analysis_structures import ( + AnalysisResult, + SlitherDetectorResult, + SlitherDetectorResultElement, + SlitherDetectorResultElementSourceMapping, + SlitherDetectorSettings, +) + + +def make_mock_server(): + """Create a mock SlitherServer for testing.""" + server = MagicMock() + server.publish_diagnostics = MagicMock() + return server + + +def make_detector_result( + check: str, + impact: str, + description: str, + filename: str, + lines: list[int], + starting_column: int = 1, + ending_column: int = 10, +) -> SlitherDetectorResult: + """Create a detector result for testing.""" + return SlitherDetectorResult( + check=check, + confidence="High", + impact=impact, + description=description, + elements=[ + SlitherDetectorResultElement( + name="test_element", + type="function", + source_mapping=SlitherDetectorResultElementSourceMapping( + start=0, + length=100, + filename_absolute=filename, + filename_relative=filename.split("/")[-1], + filename_short=filename.split("/")[-1], + lines=lines, + starting_column=starting_column, + ending_column=ending_column, + is_dependency=False, + ), + ) + ], + ) + + +def make_analysis_result(detector_results: list[SlitherDetectorResult]) -> AnalysisResult: + """Create an analysis result for testing.""" + return AnalysisResult( + succeeded=True, + compilation=None, + analysis=None, + error=None, + detector_results=detector_results, + ) + + +class TestSlitherDiagnosticsUpdate: + def test_publishes_diagnostics_for_detector_results(self): + server = make_mock_server() + diagnostics = SlitherDiagnostics(server) + settings = SlitherDetectorSettings(enabled=True, hidden_checks=[]) + + result = make_detector_result( + check="reentrancy-eth", + impact="High", + description="Reentrancy vulnerability found", + filename="/tmp/test.sol", + lines=[10, 11, 12], + ) + analysis = make_analysis_result([result]) + + diagnostics.update([analysis], settings) + + # Should publish diagnostics + server.publish_diagnostics.assert_called() + call_args = server.publish_diagnostics.call_args + assert "test.sol" in call_args[0][0] # URI contains filename + assert len(call_args[1]["diagnostics"]) == 1 + + def test_filters_hidden_checks(self): + server = make_mock_server() + diagnostics = SlitherDiagnostics(server) + settings = SlitherDetectorSettings( + enabled=True, + hidden_checks=["reentrancy-eth"], # Hide this check + ) + + result = make_detector_result( + check="reentrancy-eth", + impact="High", + description="Reentrancy vulnerability found", + filename="/tmp/test.sol", + lines=[10], + ) + analysis = make_analysis_result([result]) + + diagnostics.update([analysis], settings) + + # Should not publish any diagnostics (check is hidden) + # publish_diagnostics is called but with empty list + if server.publish_diagnostics.called: + call_args = server.publish_diagnostics.call_args + assert len(call_args[1]["diagnostics"]) == 0 + + def test_disabled_detectors_produces_no_diagnostics(self): + server = make_mock_server() + diagnostics = SlitherDiagnostics(server) + settings = SlitherDetectorSettings(enabled=False, hidden_checks=[]) + + result = make_detector_result( + check="reentrancy-eth", + impact="High", + description="Reentrancy vulnerability found", + filename="/tmp/test.sol", + lines=[10], + ) + analysis = make_analysis_result([result]) + + diagnostics.update([analysis], settings) + + # Should not produce any diagnostics when disabled + if server.publish_diagnostics.called: + call_args = server.publish_diagnostics.call_args + # Either not called or called with empty diagnostics + assert ( + not call_args + or "diagnostics" not in call_args[1] + or len(call_args[1].get("diagnostics", [])) == 0 + ) + + def test_skips_results_without_source_mapping(self): + server = make_mock_server() + diagnostics = SlitherDiagnostics(server) + settings = SlitherDetectorSettings(enabled=True, hidden_checks=[]) + + # Result with no source mapping + result = SlitherDetectorResult( + check="naming-convention", + confidence="High", + impact="Informational", + description="Naming issue", + elements=[ + SlitherDetectorResultElement( + name="_value", + type="parameter", + source_mapping=None, + ) + ], + ) + analysis = make_analysis_result([result]) + + diagnostics.update([analysis], settings) + + # Should handle gracefully without error + # No diagnostics should be published for this result + + def test_skips_results_with_empty_elements(self): + server = make_mock_server() + diagnostics = SlitherDiagnostics(server) + settings = SlitherDetectorSettings(enabled=True, hidden_checks=[]) + + result = SlitherDetectorResult( + check="some-check", + confidence="High", + impact="Medium", + description="Some issue", + elements=[], # Empty elements + ) + analysis = make_analysis_result([result]) + + diagnostics.update([analysis], settings) + + # Should handle gracefully without error + + def test_diagnostic_message_format(self): + server = make_mock_server() + diagnostics = SlitherDiagnostics(server) + settings = SlitherDetectorSettings(enabled=True, hidden_checks=[]) + + result = make_detector_result( + check="unchecked-lowlevel", + impact="Medium", + description="Low-level call return value not checked", + filename="/tmp/test.sol", + lines=[15], + ) + analysis = make_analysis_result([result]) + + diagnostics.update([analysis], settings) + + call_args = server.publish_diagnostics.call_args + diag = call_args[1]["diagnostics"][0] + assert "[MEDIUM]" in diag.message + assert "Low-level call" in diag.message + assert diag.code == "unchecked-lowlevel" + assert diag.source == "slither" + + def test_diagnostic_range_calculation(self): + server = make_mock_server() + diagnostics = SlitherDiagnostics(server) + settings = SlitherDetectorSettings(enabled=True, hidden_checks=[]) + + result = make_detector_result( + check="test-check", + impact="High", + description="Test issue", + filename="/tmp/test.sol", + lines=[10, 11, 12], # Lines 10-12 (1-indexed) + starting_column=5, + ending_column=20, + ) + analysis = make_analysis_result([result]) + + diagnostics.update([analysis], settings) + + call_args = server.publish_diagnostics.call_args + diag = call_args[1]["diagnostics"][0] + + # Range should be 0-indexed + assert diag.range.start.line == 9 # Line 10 -> 9 + assert diag.range.start.character == 4 # Column 5 -> 4 + assert diag.range.end.line == 11 # Line 12 -> 11 + assert diag.range.end.character == 19 # Column 20 -> 19 + + def test_multiple_files_get_separate_diagnostics(self): + server = make_mock_server() + diagnostics = SlitherDiagnostics(server) + settings = SlitherDetectorSettings(enabled=True, hidden_checks=[]) + + result1 = make_detector_result( + check="check1", + impact="High", + description="Issue in file1", + filename="/tmp/file1.sol", + lines=[10], + ) + result2 = make_detector_result( + check="check2", + impact="Medium", + description="Issue in file2", + filename="/tmp/file2.sol", + lines=[20], + ) + analysis = make_analysis_result([result1, result2]) + + diagnostics.update([analysis], settings) + + # Should be called twice (once per file) + assert server.publish_diagnostics.call_count == 2 + + def test_clears_old_diagnostics_on_update(self): + server = make_mock_server() + diagnostics_obj = SlitherDiagnostics(server) + settings = SlitherDetectorSettings(enabled=True, hidden_checks=[]) + + # First update with a result + result = make_detector_result( + check="check1", + impact="High", + description="Issue", + filename="/tmp/test.sol", + lines=[10], + ) + diagnostics_obj.update([make_analysis_result([result])], settings) + + # Reset mock + server.publish_diagnostics.reset_mock() + + # Second update with no results + diagnostics_obj.update([make_analysis_result([])], settings) + + # Should clear diagnostics for the file that no longer has issues + server.publish_diagnostics.assert_called() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_analysis_structures.py b/tests/unit/test_analysis_structures.py new file mode 100644 index 0000000..20b36f6 --- /dev/null +++ b/tests/unit/test_analysis_structures.py @@ -0,0 +1,199 @@ +"""Unit tests for slither_lsp.app.types.analysis_structures module.""" + +import json +from pathlib import Path + +import pytest + +from slither_lsp.app.types.analysis_structures import ( + AnalysisResult, + SlitherDetectorResult, + SlitherDetectorResultElement, + SlitherDetectorResultElementSourceMapping, + SlitherDetectorSettings, +) + + +class TestSlitherDetectorSettings: + def test_default_values(self): + settings = SlitherDetectorSettings() + assert settings.enabled is True + assert settings.hidden_checks == [] + + def test_custom_values(self): + settings = SlitherDetectorSettings( + enabled=False, + hidden_checks=["reentrancy-eth", "naming-convention"], + ) + assert settings.enabled is False + assert len(settings.hidden_checks) == 2 + assert "reentrancy-eth" in settings.hidden_checks + + +class TestSlitherDetectorResultElementSourceMapping: + def test_all_fields(self): + mapping = SlitherDetectorResultElementSourceMapping( + start=100, + length=50, + filename_absolute="/tmp/test.sol", + filename_relative="test.sol", + filename_short="test.sol", + lines=[5, 6, 7], + starting_column=10, + ending_column=20, + is_dependency=False, + ) + assert mapping.start == 100 + assert mapping.length == 50 + assert mapping.filename_absolute == "/tmp/test.sol" + assert mapping.lines == [5, 6, 7] + assert mapping.is_dependency is False + + +class TestSlitherDetectorResultElement: + def test_from_dict_with_source_mapping(self): + data = { + "name": "withdraw", + "type": "function", + "source_mapping": { + "start": 200, + "length": 150, + "filename_absolute": "/tmp/test.sol", + "filename_relative": "test.sol", + "filename_short": "test.sol", + "lines": [10, 11, 12], + "starting_column": 5, + "ending_column": 6, + "is_dependency": False, + }, + } + element = SlitherDetectorResultElement.from_dict(data) + + assert element.name == "withdraw" + assert element.type == "function" + assert element.source_mapping is not None + assert element.source_mapping.start == 200 + assert element.source_mapping.lines == [10, 11, 12] + + def test_from_dict_without_source_mapping(self): + data = { + "name": "_value", + "type": "parameter", + "source_mapping": None, + } + element = SlitherDetectorResultElement.from_dict(data) + + assert element.name == "_value" + assert element.type == "parameter" + assert element.source_mapping is None + + +class TestSlitherDetectorResult: + def test_from_dict_single_element(self): + data = { + "check": "reentrancy-eth", + "confidence": "Medium", + "impact": "High", + "description": "Reentrancy in Test.withdraw()", + "elements": [ + { + "name": "withdraw", + "type": "function", + "source_mapping": { + "start": 200, + "length": 150, + "filename_absolute": "/tmp/test.sol", + "filename_relative": "test.sol", + "filename_short": "test.sol", + "lines": [10], + "starting_column": 5, + "ending_column": 50, + "is_dependency": False, + }, + } + ], + } + result = SlitherDetectorResult.from_dict(data) + + assert result.check == "reentrancy-eth" + assert result.confidence == "Medium" + assert result.impact == "High" + assert "Reentrancy" in result.description + assert len(result.elements) == 1 + assert result.elements[0].name == "withdraw" + + def test_from_dict_multiple_elements(self): + data = { + "check": "unused-state", + "confidence": "High", + "impact": "Informational", + "description": "Unused state variable", + "elements": [ + { + "name": "var1", + "type": "variable", + "source_mapping": None, + }, + { + "name": "var2", + "type": "variable", + "source_mapping": None, + }, + ], + } + result = SlitherDetectorResult.from_dict(data) + + assert len(result.elements) == 2 + assert result.elements[0].name == "var1" + assert result.elements[1].name == "var2" + + +class TestSlitherDetectorResultFromFixture: + def test_parse_fixture_file(self, detector_output_path: Path): + with open(detector_output_path) as f: + detector_data = json.load(f) + + results = [SlitherDetectorResult.from_dict(d) for d in detector_data] + + assert len(results) == 3 + + # First result: reentrancy-eth + assert results[0].check == "reentrancy-eth" + assert results[0].impact == "High" + assert len(results[0].elements) == 2 + assert results[0].elements[0].source_mapping is not None + assert results[0].elements[0].source_mapping.lines == [10, 11, 12, 13, 14, 15] + + # Second result: unchecked-lowlevel + assert results[1].check == "unchecked-lowlevel" + assert results[1].impact == "Medium" + + # Third result: naming-convention (no source mapping) + assert results[2].check == "naming-convention" + assert results[2].elements[0].source_mapping is None + + +class TestAnalysisResult: + def test_successful_result(self): + result = AnalysisResult( + succeeded=True, + compilation=None, # Would be CryticCompile in real usage + analysis=None, # Would be Slither in real usage + error=None, + detector_results=[], + ) + assert result.succeeded is True + assert result.error is None + + def test_failed_result(self): + error = RuntimeError("Compilation failed") + result = AnalysisResult( + succeeded=False, + compilation=None, + analysis=None, + error=error, + detector_results=None, + ) + assert result.succeeded is False + assert result.error is error + assert result.detector_results is None diff --git a/tests/unit/test_file_paths.py b/tests/unit/test_file_paths.py new file mode 100644 index 0000000..6b72bd7 --- /dev/null +++ b/tests/unit/test_file_paths.py @@ -0,0 +1,144 @@ +"""Unit tests for slither_lsp.app.utils.file_paths module.""" + +from pathlib import Path + +from slither_lsp.app.utils.file_paths import ( + fs_path_to_uri, + get_solidity_files, + is_solidity_file, + normalize_uri, + uri_to_fs_path, +) + + +class TestIsSolidityFile: + def test_sol_extension(self): + assert is_solidity_file("contract.sol") is True + + def test_sol_uppercase_extension(self): + assert is_solidity_file("Contract.SOL") is True + + def test_sol_mixed_case_extension(self): + assert is_solidity_file("contract.Sol") is True + + def test_js_extension(self): + assert is_solidity_file("script.js") is False + + def test_no_extension(self): + assert is_solidity_file("contract") is False + + def test_sol_in_path(self): + assert is_solidity_file("/path/to/sol/file.txt") is False + + def test_full_path_with_sol(self): + assert is_solidity_file("/home/user/contracts/Token.sol") is True + + +class TestUriToFsPath: + def test_simple_path(self): + uri = "file:///home/user/contract.sol" + result = uri_to_fs_path(uri) + assert result == "/home/user/contract.sol" + + def test_path_with_spaces(self): + uri = "file:///home/user/my%20contracts/Token.sol" + result = uri_to_fs_path(uri) + assert result == "/home/user/my contracts/Token.sol" + + def test_path_with_encoded_chars(self): + uri = "file:///home/user/contracts%2Ftest.sol" + result = uri_to_fs_path(uri) + assert result == "/home/user/contracts/test.sol" + + +class TestFsPathToUri: + def test_simple_path(self): + path = "/home/user/contract.sol" + result = fs_path_to_uri(path) + assert result == "file:///home/user/contract.sol" + + def test_path_with_spaces(self): + path = "/home/user/my contracts/Token.sol" + result = fs_path_to_uri(path) + # URL encoding for space + assert "my%20contracts" in result or "my contracts" in result.replace("%20", " ") + + +class TestNormalizeUri: + def test_roundtrip(self): + original_uri = "file:///home/user/contract.sol" + result = normalize_uri(original_uri) + assert result == original_uri + + def test_normalize_encoded_uri(self): + uri_with_encoding = "file:///home/user/my%20contracts/Token.sol" + normalized = normalize_uri(uri_with_encoding) + # Should preserve the path after normalization + assert "user" in normalized + assert "Token.sol" in normalized + + +class TestGetSolidityFiles: + def test_finds_sol_files(self, tmp_path: Path): + # Create test files + (tmp_path / "contract1.sol").write_text("// Contract 1") + (tmp_path / "contract2.sol").write_text("// Contract 2") + (tmp_path / "readme.md").write_text("# Readme") + + result = get_solidity_files([str(tmp_path)], recursive=False) + + assert len(result) == 2 + assert any("contract1.sol" in f for f in result) + assert any("contract2.sol" in f for f in result) + + def test_recursive_search(self, tmp_path: Path): + # Create nested directory structure + subdir = tmp_path / "subdir" + subdir.mkdir() + (tmp_path / "main.sol").write_text("// Main") + (subdir / "helper.sol").write_text("// Helper") + + result = get_solidity_files([str(tmp_path)], recursive=True) + + assert len(result) == 2 + assert any("main.sol" in f for f in result) + assert any("helper.sol" in f for f in result) + + def test_non_recursive_ignores_subdirs(self, tmp_path: Path): + subdir = tmp_path / "subdir" + subdir.mkdir() + (tmp_path / "main.sol").write_text("// Main") + (subdir / "helper.sol").write_text("// Helper") + + result = get_solidity_files([str(tmp_path)], recursive=False) + + assert len(result) == 1 + assert any("main.sol" in f for f in result) + + def test_skips_node_modules(self, tmp_path: Path): + node_modules = tmp_path / "node_modules" + node_modules.mkdir() + (tmp_path / "main.sol").write_text("// Main") + (node_modules / "dependency.sol").write_text("// Dependency") + + result = get_solidity_files([str(tmp_path)], recursive=True) + + assert len(result) == 1 + assert any("main.sol" in f for f in result) + assert not any("dependency.sol" in f for f in result) + + def test_empty_directory(self, tmp_path: Path): + result = get_solidity_files([str(tmp_path)], recursive=True) + assert len(result) == 0 + + def test_multiple_folders(self, tmp_path: Path): + dir1 = tmp_path / "dir1" + dir2 = tmp_path / "dir2" + dir1.mkdir() + dir2.mkdir() + (dir1 / "contract1.sol").write_text("// Contract 1") + (dir2 / "contract2.sol").write_text("// Contract 2") + + result = get_solidity_files([str(dir1), str(dir2)], recursive=False) + + assert len(result) == 2 diff --git a/tests/unit/test_ranges.py b/tests/unit/test_ranges.py new file mode 100644 index 0000000..56bbd58 --- /dev/null +++ b/tests/unit/test_ranges.py @@ -0,0 +1,88 @@ +"""Unit tests for slither_lsp.app.utils.ranges module.""" + +from unittest.mock import MagicMock + +import lsprotocol.types as lsp + +from slither_lsp.app.utils.ranges import source_to_location, source_to_range + + +def make_mock_source( + lines: list[int], + starting_column: int, + ending_column: int, + filename_absolute: str = "/tmp/test.sol", +): + """Create a mock Source object with the given attributes.""" + source = MagicMock() + source.lines = lines + source.starting_column = starting_column + source.ending_column = ending_column + source.filename = MagicMock() + source.filename.absolute = filename_absolute + return source + + +class TestSourceToRange: + def test_single_line(self): + source = make_mock_source(lines=[5], starting_column=10, ending_column=20) + result = source_to_range(source) + + assert isinstance(result, lsp.Range) + assert result.start.line == 4 # 0-indexed + assert result.start.character == 9 # 0-indexed + assert result.end.line == 4 + assert result.end.character == 19 + + def test_multi_line(self): + source = make_mock_source(lines=[10, 11, 12], starting_column=5, ending_column=15) + result = source_to_range(source) + + assert result.start.line == 9 # First line, 0-indexed + assert result.start.character == 4 + assert result.end.line == 11 # Last line, 0-indexed + assert result.end.character == 14 + + def test_column_at_beginning(self): + source = make_mock_source(lines=[1], starting_column=1, ending_column=10) + result = source_to_range(source) + + assert result.start.character == 0 + assert result.end.character == 9 + + def test_zero_column_clamped(self): + # Edge case where column might be 0 (invalid but should be handled) + source = make_mock_source(lines=[1], starting_column=0, ending_column=0) + result = source_to_range(source) + + assert result.start.character == 0 # max(0, -1) = 0 + assert result.end.character == 0 + + +class TestSourceToLocation: + def test_returns_location_with_uri(self): + source = make_mock_source( + lines=[5], + starting_column=10, + ending_column=20, + filename_absolute="/home/user/contracts/Token.sol", + ) + result = source_to_location(source) + + assert isinstance(result, lsp.Location) + assert "Token.sol" in result.uri + assert result.uri.startswith("file://") + + def test_location_contains_correct_range(self): + source = make_mock_source( + lines=[10, 11], + starting_column=5, + ending_column=30, + filename_absolute="/tmp/test.sol", + ) + result = source_to_location(source) + + assert result.range.start.line == 9 + assert result.range.start.character == 4 + assert result.range.end.line == 10 + assert result.range.end.character == 29