From 94a8dcb0b31f4c2b7cf662660f7c331abdf4d5d7 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 15 Mar 2026 21:31:49 +0100 Subject: [PATCH 01/37] docs: add LSP server implementation plan (#314) --- docs/lsp-server-plan.md | 447 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 docs/lsp-server-plan.md diff --git a/docs/lsp-server-plan.md b/docs/lsp-server-plan.md new file mode 100644 index 00000000..9db207cd --- /dev/null +++ b/docs/lsp-server-plan.md @@ -0,0 +1,447 @@ +# Plan: #314 — LSP Server for Editor Integration + +## Context + +The VS Code extension (`reqstool/reqstool-vscode-extension`) currently shells out to `reqstool generate-json local -p .` to get requirement data, then implements hover/click-through/snippets in TypeScript. This approach is slow (full CLI invocation per workspace), editor-specific, and limits what features can be offered. + +**Goal**: Add a Python LSP server to reqstool-client using `pygls`, backed by the SQLite pipeline from #313. The server provides hover, diagnostics, completion, go-to-definition, and YAML schema assistance for reqstool files. Any LSP-capable editor (VS Code, Neovim, IntelliJ, etc.) can use it. + +**Branch**: `feat/314-lsp-server` (from `feat/313-sqlite-storage` / PR #321) + +--- + +## Design Decisions (from user questions) + +### Q1: Why does CombinedRawDatasetsGenerator still exist? + +`CombinedRawDatasetsGenerator` is the **parser** — it recursively resolves locations, parses YAML files (`requirements.yml`, `software_verification_cases.yml`, etc.), and feeds data into SQLite. It was not replaced by SQLite; it feeds SQLite. The old **indexing** layer (`CombinedIndexedDataset`, `StatisticsGenerator`, etc.) was replaced. The parser remains because it handles recursive location resolution, YAML parsing, and Pydantic validation — all still needed. + +### Q2/Q3: TypeScript/JavaScript tag support + +`reqstool-typescript-tags` uses JSDoc tags (`@Requirements`, `@SVCs`) parsed via the TypeScript compiler API. These are **comments**, not source-level annotations like Java/Python. The LSP must support both: +- **Java/Python**: `@Requirements("REQ_xxx")` — source-level decorators/annotations +- **TypeScript/JavaScript**: JSDoc `@Requirements REQ_xxx, REQ_yyy` — comment-based tags + +The annotation parser must handle both syntaxes. + +### Q4: Correct file names and static vs dynamic separation + +**Static files** (user-authored, LSP watches these): +- `requirements.yml` +- `software_verification_cases.yml` +- `manual_verification_results.yml` +- `reqstool_config.yml` + +**Dynamic files** (generated by build tools, LSP does NOT need to watch): +- `annotations.yml` — generated by `reqstool-python-decorators`, `reqstool-typescript-tags`, etc. +- `requirement_annotations.yml`, `svcs_annotations.yml` — generated by parsers +- `test_results/**/*.xml` — JUnit XML from test run + +The LSP already has access to annotations via source code analysis (it sees `@Requirements`/`@SVCs` directly). It does NOT need `annotations.yml`. However, `build_database()` reads `annotations.yml` and `test_results` as part of the full pipeline — the LSP gets those through the DB if they exist on disk. + +### Q5: Multi-project workspace support + +A workspace can contain multiple reqstool projects (e.g., a Gradle multi-module with system at root + microservice modules). The LSP must: + +1. **Discover all reqstool projects** in the workspace by finding all `requirements.yml` files +2. **Track per-project state** — each project gets its own SQLite DB + `RequirementsRepository` +3. **Know which project a source file belongs to** — match by file path proximity +4. **Watch static files only** — `requirements.yml`, `software_verification_cases.yml`, `manual_verification_results.yml`, `reqstool_config.yml` +5. **Only track local filesystem** — imports/implementations from maven/pypi/git are loaded during `build_database()` but not watched +6. **Manual refresh command** — for when remote dependencies change, user triggers `reqstool.refresh` to rebuild all DBs + +### Q6: Navigation (go-to-definition) + +Two directions: +- **Source → YAML**: From `@SVCs("SVC_xxx")` or `@Requirements("REQ_xxx")` in code, navigate to the YAML file where that ID is defined. If the ID comes from a local import, jump to the file. If from a remote source (maven/pypi/git), show a hover hint "Defined in remote repository: {urn}". +- **YAML → Source**: From a requirement ID in `requirements.yml`, navigate to the `@Requirements("REQ_xxx")` annotations in source code. Could be multiple locations. Uses `workspace/symbol` search or the DB's `annotations_impls` data. + +Implementation: `textDocument/definition` handler. Requires tracking which YAML file + line each ID was defined in (needs DB schema addition or file re-scan). + +### Q7: JSON Schema integration for YAML files + +The LSP can provide: +- **Diagnostics**: Validate YAML files against their JSON schemas (`requirements.schema.json`, `software_verification_cases.schema.json`, `manual_verification_results.schema.json`, `reqstool_config.schema.json`) +- **Completion**: For enum fields like `significance` (shall/should/may), `categories` (functional-suitability, etc.), `lifecycle.state` (draft/effective/deprecated/obsolete), `verification` (automated-test/manual-test/review/platform/other), `variant` (system/microservice/external), `language`, `build` +- **Hover**: Show schema descriptions for YAML fields + +The schemas are already bundled in `src/reqstool/resources/schemas/v1/`. + +--- + +## Architecture + +``` +Editor (VS Code / Neovim / etc.) + ↕ LSP protocol (stdio) +ReqstoolLanguageServer (pygls) + → WorkspaceManager (manages multiple projects) + → ProjectState[] (one per reqstool project found) + → build_database() pipeline (reused) + → RequirementsRepository (reused) + → Features: + → hover (source code + YAML schema descriptions) + → diagnostics (unknown/deprecated IDs + YAML schema validation) + → completion (IDs in annotations + enum values in YAML) + → go-to-definition (source ↔ YAML navigation) + → file watching (static files only + manual refresh for remote) +``` + +--- + +## New Files + +| File | Purpose | +|---|---| +| `src/reqstool/lsp/__init__.py` | Package init | +| `src/reqstool/lsp/server.py` | `ReqstoolLanguageServer` — pygls subclass, feature registration, `reqstool lsp` entry point | +| `src/reqstool/lsp/workspace_manager.py` | `WorkspaceManager` — discovers projects, manages `ProjectState` instances | +| `src/reqstool/lsp/project_state.py` | `ProjectState` — DB lifecycle, query helpers for one reqstool project | +| `src/reqstool/lsp/annotation_parser.py` | Regex-based detection of `@Requirements`/`@SVCs` (Java/Python + JSDoc TS/JS) | +| `src/reqstool/lsp/yaml_schema.py` | JSON Schema loading + validation + completion for YAML files | +| `src/reqstool/lsp/features/__init__.py` | Package init | +| `src/reqstool/lsp/features/hover.py` | `textDocument/hover` — requirement/SVC details + YAML field descriptions | +| `src/reqstool/lsp/features/diagnostics.py` | Diagnostics for source annotations + YAML schema validation | +| `src/reqstool/lsp/features/completion.py` | ID completion in annotations + enum completion in YAML | +| `src/reqstool/lsp/features/definition.py` | `textDocument/definition` — source ↔ YAML navigation | +| `tests/unit/reqstool/lsp/__init__.py` | Test package init | +| `tests/unit/reqstool/lsp/test_annotation_parser.py` | Annotation detection tests | +| `tests/unit/reqstool/lsp/test_project_state.py` | Project discovery and DB build tests | +| `tests/unit/reqstool/lsp/test_workspace_manager.py` | Multi-project discovery tests | +| `tests/unit/reqstool/lsp/test_yaml_schema.py` | Schema validation + completion tests | + +## Files to Modify + +| File | Change | +|---|---| +| `pyproject.toml` | Add `pygls` and `lsprotocol` to dependencies | +| `src/reqstool/command.py` | Add `lsp` subcommand (lazy import) | + +--- + +## Step 1: Dependencies and CLI Entry Point + +### `pyproject.toml` +Add to `dependencies`: +``` +"pygls>=2.0,<3.0", +"lsprotocol>=2024.0.0", +``` + +### `src/reqstool/command.py` +Add `lsp` subparser: +```python +# In get_arguments(): +subparsers.add_parser("lsp", help="Start the Language Server Protocol server") + +# In main(): +elif args.command == "lsp": + from reqstool.lsp.server import start_server + start_server() +``` + +Create `src/reqstool/lsp/__init__.py` and `src/reqstool/lsp/features/__init__.py`. + +--- + +## Step 2: Annotation Parser (`annotation_parser.py`) + +Detects annotations/tags in source code. Must handle: + +**Java/Python** (source-level): +```java +@Requirements("REQ_010") +@Requirements("REQ_010", "REQ_011") +@SVCs("SVC_010") +``` + +**TypeScript/JavaScript** (JSDoc comment-based): +```typescript +/** @Requirements REQ_010, REQ_011 */ +/** @SVCs SVC_010 */ +``` + +```python +@dataclass(frozen=True) +class AnnotationMatch: + kind: str # "REQ" or "SVC" + raw_id: str # e.g. "REQ_010" or "ms-001:REQ_010" + line: int # 0-based + start_col: int # column of the ID start (inside quotes for Java/Python, bare for JSDoc) + end_col: int # column of ID end (exclusive) + +# Java/Python pattern +SOURCE_ANNOTATION_RE = re.compile(r'@(Requirements|SVCs)\s*\(') +QUOTED_ID_RE = re.compile(r'"([^"]*)"') + +# JSDoc pattern (TS/JS) +JSDOC_TAG_RE = re.compile(r'@(Requirements|SVCs)\s+(.+)') +``` + +Functions: +- `find_all_annotations(text: str, language_id: str) -> list[AnnotationMatch]` +- `annotation_at_position(text: str, line: int, character: int, language_id: str) -> AnnotationMatch | None` +- `is_inside_annotation(line_text: str, character: int, language_id: str) -> str | None` — returns "Requirements" or "SVCs" for completion context + +--- + +## Step 3: Multi-Project Management + +### `project_state.py` — One reqstool project + +```python +class ProjectState: + def __init__(self, reqstool_path: str): + self._reqstool_path = reqstool_path # dir containing requirements.yml + self._db: RequirementsDatabase | None = None + self._repo: RequirementsRepository | None = None + self._ready: bool = False + + def build(self) -> None + def rebuild(self) -> None + def close(self) -> None + + # Query helpers (resolve raw_id using initial_urn via UrnId.assure_urn_id) + def get_requirement(self, raw_id: str) -> RequirementData | None + def get_svc(self, raw_id: str) -> SVCData | None + def get_svcs_for_req(self, raw_id: str) -> list[SVCData] + def get_mvrs_for_svc(self, raw_id: str) -> list[MVRData] + def get_all_requirement_ids(self) -> list[str] # bare IDs + def get_all_svc_ids(self) -> list[str] # bare IDs + def get_initial_urn(self) -> str +``` + +`build()` replicates the body of `build_database()` (`storage/pipeline.py:24-37`) without the context manager. Catches `SystemExit` (known `sys.exit()` bug in `CombinedRawDatasetsGenerator`). + +### `workspace_manager.py` — Multiple projects per workspace + +```python +class WorkspaceManager: + def __init__(self): + self._projects: dict[str, ProjectState] = {} # reqstool_path → ProjectState + + def discover_and_build(self, workspace_folders: list[str]) -> None + # Find all requirements.yml files (max 5 levels deep, skip .git/node_modules/etc.) + # Create ProjectState for each, call build() + + def rebuild_all(self) -> None + # Manual refresh — rebuild all projects + + def project_for_file(self, file_uri: str) -> ProjectState | None + # Find which project a source file belongs to (closest reqstool_path ancestor) + + def project_for_yaml(self, file_uri: str) -> ProjectState | None + # Find which project a YAML file belongs to + + def all_projects(self) -> list[ProjectState] + + def close_all(self) -> None +``` + +**Watched static files** (glob patterns for `workspace/didChangeWatchedFiles`): +- `**/requirements.yml` +- `**/software_verification_cases.yml` +- `**/manual_verification_results.yml` +- `**/reqstool_config.yml` + +When any of these change, rebuild the affected `ProjectState`. + +--- + +## Step 4: LSP Server (`server.py`) + +```python +class ReqstoolLanguageServer(LanguageServer): + def __init__(self): + super().__init__(name="reqstool", version="0.1.0") + self.workspace_manager = WorkspaceManager() +``` + +**Lifecycle handlers**: +- `initialized` — discover all reqstool projects, build DBs, register file watchers, publish initial diagnostics +- `textDocument/didOpen` — publish diagnostics +- `textDocument/didChange` — re-publish diagnostics +- `textDocument/didSave` — if static YAML file, rebuild affected project + re-publish all diagnostics +- `workspace/didChangeWatchedFiles` — rebuild affected project on static file changes +- `shutdown` — close all DBs + +**Commands**: +- `reqstool.refresh` — manual refresh, rebuilds all projects (for when remote deps change) + +**Feature handlers**: +- `textDocument/hover` → `features/hover.py` +- `textDocument/completion` (triggers: `"`, ` `) → `features/completion.py` +- `textDocument/definition` → `features/definition.py` + +Entry point: +```python +def start_server(): + server.start_io() +``` + +--- + +## Step 5: Hover Feature (`features/hover.py`) + +### Source code hover (Java/Python/TS/JS) + +When cursor is on a requirement/SVC ID inside an annotation: + +**Requirement hover**: +```markdown +### {title} +`{id}` `{significance}` `{revision}` +--- +{description} +--- +{rationale} +--- +**Categories**: {categories} +**Lifecycle**: {state} +**SVCs**: {linked SVC IDs} +``` + +**SVC hover**: +```markdown +### {title} +`{id}` `{verification}` `{revision}` +--- +{description} +--- +{instructions} +--- +**Lifecycle**: {state} +**Requirements**: {linked requirement IDs} +**MVRs**: {linked MVR IDs with pass/fail} +``` + +### YAML file hover + +When cursor is on a field name in a reqstool YAML file, show the JSON Schema description for that field (e.g., hovering on `significance` shows "Enum with level of significance. E.g. shall, should, may"). + +--- + +## Step 6: Diagnostics Feature (`features/diagnostics.py`) + +### Source code diagnostics + +| Condition | Severity | Message | +|---|---|---| +| ID not found in DB | Error | `Unknown requirement: REQ_xxx` / `Unknown SVC: SVC_xxx` | +| ID is deprecated | Warning | `Requirement REQ_xxx is deprecated: {reason}` | +| ID is obsolete | Warning | `Requirement REQ_xxx is obsolete: {reason}` | + +### YAML file diagnostics + +Validate YAML files against their JSON schemas using `jsonschema.validate()`: +- Match file by name: `requirements.yml` → `requirements.schema.json`, etc. +- Report schema validation errors with line/column positions +- Schemas at `src/reqstool/resources/schemas/v1/` + +Published on `didOpen`, `didChange`, and after DB rebuild. + +--- + +## Step 7: Completion Feature (`features/completion.py`) + +### Source code completion + +Inside `@Requirements("` → offer all requirement IDs (bare form, e.g. `REQ_010`). +Inside `@SVCs("` → offer all SVC IDs (bare form). + +Each `CompletionItem` includes `label` (ID), `detail` (title), `documentation` (description). + +### YAML file completion + +For enum fields, offer valid values: +- `significance`: shall, should, may +- `categories`: functional-suitability, performance-efficiency, compatibility, interaction-capability, reliability, security, maintainability, flexibility, safety +- `lifecycle.state`: draft, effective, deprecated, obsolete +- `verification`: automated-test, manual-test, review, platform, other +- `variant`: system, microservice, external +- `implementation`: in-code, N/A +- `language`: java, python, javascript, typescript +- `build`: gradle, hatch, maven, npm, poetry, yarn + +Derive these from the JSON schemas at `src/reqstool/resources/schemas/v1/`. + +--- + +## Step 8: Go-to-Definition (`features/definition.py`) + +### Source → YAML + +From `@Requirements("REQ_xxx")` in code → jump to the `- id: REQ_xxx` line in `requirements.yml`. +From `@SVCs("SVC_xxx")` in code → jump to the `- id: SVC_xxx` line in `software_verification_cases.yml`. + +**Implementation**: Scan the YAML files in the project's reqstool path for the ID string. The DB knows the URN but not the file line number, so we do a simple file search for `id: {raw_id}` in the appropriate YAML file. + +If the ID belongs to a **remote** import (URN doesn't match any local file), return no location but provide a hover hint: "Defined in remote source: {urn}". + +### YAML → Source + +From a requirement ID in `requirements.yml` → find `@Requirements("REQ_xxx")` annotations in workspace source files. + +**Implementation**: Use `workspace/symbol` or grep workspace files for the annotation pattern containing the ID. The DB's `annotations_impls` table has `fqn` (fully qualified name) but not file paths — we need to search source files. + +This direction is more expensive. For MVP, search open documents only. Future: index source files on workspace open. + +--- + +## Implementation Order + +1. **Step 1**: Dependencies + CLI entry point + package structure +2. **Step 2**: `annotation_parser.py` + tests (pure logic, no pipeline deps) +3. **Step 3**: `project_state.py` + `workspace_manager.py` + tests (uses pipeline, test with `tests/resources/test_data/data/local/test_standard/baseline/ms-001`) +4. **Step 4**: `server.py` — lifecycle, file watching, manual refresh command +5. **Step 5**: `features/hover.py` — source code + YAML hover +6. **Step 6**: `features/diagnostics.py` — source annotations + YAML schema validation +7. **Step 7**: `features/completion.py` — ID completion + YAML enum completion +8. **Step 8**: `features/definition.py` — source ↔ YAML navigation +9. **Step 9**: `yaml_schema.py` — shared JSON Schema loader for diagnostics/completion/hover + +Steps 5-8 can be committed incrementally. Step 9 is a shared utility used by steps 5-7. + +--- + +## Verification + +```bash +# Unit tests +hatch run dev:pytest tests/unit/reqstool/lsp/ -v + +# Full test suite +hatch run dev:pytest --cov=reqstool tests/unit + +# Lint +hatch run dev:black src tests && hatch run dev:flake8 + +# Manual: start LSP server +hatch run python src/reqstool/command.py lsp + +# Manual: test with VS Code using a generic LSP client extension +# or update reqstool-vscode-extension to use LSP client mode +``` + +--- + +## Key Reused Components + +| Component | File | Usage | +|---|---|---| +| `build_database()` body | `src/reqstool/storage/pipeline.py:24-37` | Replicated in `ProjectState.build()` | +| `RequirementsRepository` | `src/reqstool/storage/requirements_repository.py` | All data queries | +| `UrnId.assure_urn_id()` | `src/reqstool/common/models/urn_id.py:25` | ID resolution (bare vs URN-prefixed) | +| `LIFECYCLESTATE` | `src/reqstool/common/models/lifecycle.py` | Deprecated/obsolete checks | +| `LocalLocation` | `src/reqstool/locations/local_location.py` | Workspace path → location | +| JSON Schemas | `src/reqstool/resources/schemas/v1/*.schema.json` | YAML validation + enum completion | +| `CombinedRawDatasetsGenerator` | `src/reqstool/model_generators/combined_raw_datasets_generator.py` | Parse pipeline | +| `DatabaseFilterProcessor` | `src/reqstool/storage/database_filter_processor.py` | Apply filters | +| `LifecycleValidator` | `src/reqstool/common/validators/lifecycle_validator.py` | Post-build validation | + +## Known Risks + +1. **`sys.exit()` in parser**: `CombinedRawDatasetsGenerator` calls `sys.exit()` on missing files — must catch `SystemExit` in `ProjectState.build()`. +2. **pygls 2.x API**: Verify exact API against pygls 2.x docs (changed significantly from 1.x). +3. **YAML line number mapping**: Go-to-definition needs file+line for YAML IDs. Simple text search is sufficient for MVP; `ruamel.yaml` round-trip parsing could provide exact positions in future. +4. **YAML → Source navigation cost**: Searching workspace files for annotation patterns is O(n) in file count. For MVP, limit to open documents. Future: build an index on workspace open. From 4ce2501df2ab9165a4229f97c36dfed7cb648477 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 15 Mar 2026 22:15:40 +0100 Subject: [PATCH 02/37] docs: update LSP server plan with root discovery, document symbols, and multi-root workspace support (#314) Adds refined Q5 root project discovery algorithm, document symbols feature (Step 9), separate root_discovery.py module, workspace/didChangeWorkspaceFolders support, and YAML schema utility (Step 10). Signed-off-by: jimisola --- docs/lsp-server-plan.md | 185 ++++++++++++++++++++++++++++++++-------- 1 file changed, 149 insertions(+), 36 deletions(-) diff --git a/docs/lsp-server-plan.md b/docs/lsp-server-plan.md index 9db207cd..ef414a20 100644 --- a/docs/lsp-server-plan.md +++ b/docs/lsp-server-plan.md @@ -12,10 +12,6 @@ The VS Code extension (`reqstool/reqstool-vscode-extension`) currently shells ou ## Design Decisions (from user questions) -### Q1: Why does CombinedRawDatasetsGenerator still exist? - -`CombinedRawDatasetsGenerator` is the **parser** — it recursively resolves locations, parses YAML files (`requirements.yml`, `software_verification_cases.yml`, etc.), and feeds data into SQLite. It was not replaced by SQLite; it feeds SQLite. The old **indexing** layer (`CombinedIndexedDataset`, `StatisticsGenerator`, etc.) was replaced. The parser remains because it handles recursive location resolution, YAML parsing, and Pydantic validation — all still needed. - ### Q2/Q3: TypeScript/JavaScript tag support `reqstool-typescript-tags` uses JSDoc tags (`@Requirements`, `@SVCs`) parsed via the TypeScript compiler API. These are **comments**, not source-level annotations like Java/Python. The LSP must support both: @@ -33,22 +29,51 @@ The annotation parser must handle both syntaxes. - `reqstool_config.yml` **Dynamic files** (generated by build tools, LSP does NOT need to watch): -- `annotations.yml` — generated by `reqstool-python-decorators`, `reqstool-typescript-tags`, etc. -- `requirement_annotations.yml`, `svcs_annotations.yml` — generated by parsers -- `test_results/**/*.xml` — JUnit XML from test run +- `annotations.yml` — generated by build plugins (`reqstool-python-decorators`, `reqstool-typescript-tags`, etc.). Contains `requirement_annotations.implementations` and `requirement_annotations.tests` sections. +- `test_results/**/*.xml` — JUnit XML from test runs The LSP already has access to annotations via source code analysis (it sees `@Requirements`/`@SVCs` directly). It does NOT need `annotations.yml`. However, `build_database()` reads `annotations.yml` and `test_results` as part of the full pipeline — the LSP gets those through the DB if they exist on disk. -### Q5: Multi-project workspace support +### Q5: LSP instance model and root project discovery + +#### Single process, multi-root workspace support + +One `reqstool lsp` process handles **all workspace folders** (standard LSP pattern, same as pylsp, gopls, etc.). The VS Code extension creates a single `LanguageClient`. Internally, the server isolates state per workspace folder — each folder gets its own root discovery and `ProjectState` instances. No shared DB, no ID collisions, even if two folders contain the same repo on different branches. + +The server uses the LSP `workspace/workspaceFolders` capability and listens for `workspace/didChangeWorkspaceFolders` notifications to track folder additions/removals. + +#### Root project discovery within each workspace folder + +Within each workspace folder, the LSP does **NOT** create a database for every `requirements.yml` it finds. It auto-discovers **root projects** — the highest-level entry points that encompass their imports and implementations — then confirms with the user or allows override. + +**Example workspace folder layout:** +``` +workspace-folder/ +├── ext-001/ (external, imported by sys-001) +├── sys-001/ (system, imports ext-001, implementations: ms-001.1, ms-001.2) +├── ms-001.1/ (microservice, imports sys-001) +└── ms-001.2/ (microservice, imports sys-001) +``` + +**If sys-001 is present**: Only **one** DB is created, rooted at `sys-001`. It already pulls in ext-001 (via imports) and ms-001.1 + ms-001.2 (via implementations). The LSP covers the entire graph from that single entry point. -A workspace can contain multiple reqstool projects (e.g., a Gradle multi-module with system at root + microservice modules). The LSP must: +**If only ms-001.1 and ms-001.2 are present** (sys-001 is remote/absent): **Two** DBs are created — one per microservice. They are independent; neither imports/implements the other. -1. **Discover all reqstool projects** in the workspace by finding all `requirements.yml` files -2. **Track per-project state** — each project gets its own SQLite DB + `RequirementsRepository` -3. **Know which project a source file belongs to** — match by file path proximity -4. **Watch static files only** — `requirements.yml`, `software_verification_cases.yml`, `manual_verification_results.yml`, `reqstool_config.yml` -5. **Only track local filesystem** — imports/implementations from maven/pypi/git are loaded during `build_database()` but not watched -6. **Manual refresh command** — for when remote dependencies change, user triggers `reqstool.refresh` to rebuild all DBs +**Discovery algorithm:** +1. Find all `requirements.yml` files in workspace folder (max 5 levels deep, skip `.git`/`node_modules`/`build`/`target`/etc.) +2. Quick-parse each to extract `metadata.urn` and `metadata.variant` +3. Quick-parse `imports` and `implementations` sections to build a local reference graph +4. A project is a **root** if no other local project references it as an import or implementation +5. External-variant projects are never roots (they exist only as imports) +6. Present discovered root(s) to user for confirmation (via `window/showMessageRequest` or a setting) +7. Allow user to override via LSP config setting (e.g., `reqstool.rootPath`) +8. Create one `ProjectState` (DB) per confirmed root project + +**Rules:** +- **Watch static files only** — `requirements.yml`, `software_verification_cases.yml`, `manual_verification_results.yml`, `reqstool_config.yml` +- **Only track local filesystem** — imports/implementations from maven/pypi/git are loaded during `build_database()` but not watched +- **Manual refresh command** — for when remote dependencies change, user triggers `reqstool.refresh` to rebuild all DBs +- When a watched file changes, rebuild the root project that encompasses it ### Q6: Navigation (go-to-definition) @@ -73,17 +98,20 @@ The schemas are already bundled in `src/reqstool/resources/schemas/v1/`. ``` Editor (VS Code / Neovim / etc.) - ↕ LSP protocol (stdio) + ↕ LSP protocol (stdio) — single process ReqstoolLanguageServer (pygls) - → WorkspaceManager (manages multiple projects) - → ProjectState[] (one per reqstool project found) - → build_database() pipeline (reused) - → RequirementsRepository (reused) + → WorkspaceManager + → per workspace folder: + → RootDiscovery (finds root project(s)) + → ProjectState[] (one per root — usually just one) + → build_database() pipeline (reused) + → RequirementsRepository (reused) → Features: → hover (source code + YAML schema descriptions) → diagnostics (unknown/deprecated IDs + YAML schema validation) → completion (IDs in annotations + enum values in YAML) → go-to-definition (source ↔ YAML navigation) + → document symbols (outline view for YAML files) → file watching (static files only + manual refresh for remote) ``` @@ -95,7 +123,8 @@ ReqstoolLanguageServer (pygls) |---|---| | `src/reqstool/lsp/__init__.py` | Package init | | `src/reqstool/lsp/server.py` | `ReqstoolLanguageServer` — pygls subclass, feature registration, `reqstool lsp` entry point | -| `src/reqstool/lsp/workspace_manager.py` | `WorkspaceManager` — discovers projects, manages `ProjectState` instances | +| `src/reqstool/lsp/workspace_manager.py` | `WorkspaceManager` — manages per-folder isolation, root discovery, `ProjectState` lifecycle | +| `src/reqstool/lsp/root_discovery.py` | `discover_root_projects()` — finds root reqstool projects in a workspace folder | | `src/reqstool/lsp/project_state.py` | `ProjectState` — DB lifecycle, query helpers for one reqstool project | | `src/reqstool/lsp/annotation_parser.py` | Regex-based detection of `@Requirements`/`@SVCs` (Java/Python + JSDoc TS/JS) | | `src/reqstool/lsp/yaml_schema.py` | JSON Schema loading + validation + completion for YAML files | @@ -104,10 +133,11 @@ ReqstoolLanguageServer (pygls) | `src/reqstool/lsp/features/diagnostics.py` | Diagnostics for source annotations + YAML schema validation | | `src/reqstool/lsp/features/completion.py` | ID completion in annotations + enum completion in YAML | | `src/reqstool/lsp/features/definition.py` | `textDocument/definition` — source ↔ YAML navigation | +| `src/reqstool/lsp/features/document_symbols.py` | `textDocument/documentSymbol` — outline for YAML files | | `tests/unit/reqstool/lsp/__init__.py` | Test package init | | `tests/unit/reqstool/lsp/test_annotation_parser.py` | Annotation detection tests | | `tests/unit/reqstool/lsp/test_project_state.py` | Project discovery and DB build tests | -| `tests/unit/reqstool/lsp/test_workspace_manager.py` | Multi-project discovery tests | +| `tests/unit/reqstool/lsp/test_workspace_manager.py` | Workspace manager + root discovery tests | | `tests/unit/reqstool/lsp/test_yaml_schema.py` | Schema validation + completion tests | ## Files to Modify @@ -213,28 +243,46 @@ class ProjectState: `build()` replicates the body of `build_database()` (`storage/pipeline.py:24-37`) without the context manager. Catches `SystemExit` (known `sys.exit()` bug in `CombinedRawDatasetsGenerator`). -### `workspace_manager.py` — Multiple projects per workspace +### `root_discovery.py` — Find root projects in a workspace folder + +```python +@dataclass(frozen=True) +class DiscoveredProject: + path: str # directory containing requirements.yml + urn: str # metadata.urn + variant: VARIANTS # system/microservice/external + +def discover_root_projects(workspace_folder: str) -> list[DiscoveredProject]: + """Find root reqstool projects in a workspace folder. + + 1. Glob for **/requirements.yml (max 5 levels, skip .git/node_modules/build/target) + 2. Quick-parse each: extract metadata.urn, metadata.variant, imports, implementations + 3. Build local reference graph + 4. Return projects not referenced by any other local project (externals excluded) + """ +``` + +### `workspace_manager.py` — Per-folder isolation ```python class WorkspaceManager: def __init__(self): - self._projects: dict[str, ProjectState] = {} # reqstool_path → ProjectState + # workspace_folder_uri → list of ProjectState for that folder + self._folder_projects: dict[str, list[ProjectState]] = {} - def discover_and_build(self, workspace_folders: list[str]) -> None - # Find all requirements.yml files (max 5 levels deep, skip .git/node_modules/etc.) - # Create ProjectState for each, call build() + def add_folder(self, folder_uri: str) -> None + # Run root discovery for this folder, create ProjectState(s), build DB(s) + def remove_folder(self, folder_uri: str) -> None + # Close and remove all ProjectStates for this folder + + def rebuild_folder(self, folder_uri: str) -> None def rebuild_all(self) -> None - # Manual refresh — rebuild all projects def project_for_file(self, file_uri: str) -> ProjectState | None - # Find which project a source file belongs to (closest reqstool_path ancestor) - - def project_for_yaml(self, file_uri: str) -> ProjectState | None - # Find which project a YAML file belongs to + # Find which project across all folders encompasses this file def all_projects(self) -> list[ProjectState] - def close_all(self) -> None ``` @@ -263,6 +311,7 @@ class ReqstoolLanguageServer(LanguageServer): - `textDocument/didChange` — re-publish diagnostics - `textDocument/didSave` — if static YAML file, rebuild affected project + re-publish all diagnostics - `workspace/didChangeWatchedFiles` — rebuild affected project on static file changes +- `workspace/didChangeWorkspaceFolders` — add/remove folders from WorkspaceManager - `shutdown` — close all DBs **Commands**: @@ -272,6 +321,7 @@ class ReqstoolLanguageServer(LanguageServer): - `textDocument/hover` → `features/hover.py` - `textDocument/completion` (triggers: `"`, ` `) → `features/completion.py` - `textDocument/definition` → `features/definition.py` +- `textDocument/documentSymbol` → `features/document_symbols.py` Entry point: ```python @@ -388,19 +438,82 @@ This direction is more expensive. For MVP, search open documents only. Future: i --- +## Step 9: Document Symbols (`features/document_symbols.py`) + +`textDocument/documentSymbol` handler for reqstool YAML files. Provides the Outline view in VS Code (and breadcrumbs, Go to Symbol). + +**For `requirements.yml`** — each requirement shows linked SVCs as children: +``` +requirements.yml Outline: +├── REQ_001 — User authentication (shall) +│ ├── → SVC_001 — Login test (click navigates to svcs.yml) +│ └── → SVC_002 — Password validation (click navigates to svcs.yml) +├── REQ_002 — Password policy (shall) +│ └── → SVC_003 — Policy enforcement (click navigates to svcs.yml) +└── REQ_003 — Session timeout (should) +``` + +**For `software_verification_cases.yml`** — each SVC shows linked requirements and MVRs: +``` +software_verification_cases.yml Outline: +├── SVC_001 — Login test (automated-test) +│ ├── ← REQ_001 — User authentication (click navigates to requirements.yml) +│ └── → MVR: pass (click navigates to mvrs.yml) +└── SVC_002 — Password validation (manual-test) + └── ← REQ_001 — User authentication (click navigates to requirements.yml) +``` + +**For `manual_verification_results.yml`** — each MVR shows linked SVC: +``` +manual_verification_results.yml Outline: +├── SVC_002 — pass +│ └── ← SVC_002 — Password validation (click navigates to svcs.yml) +└── SVC_003 — fail + └── ← SVC_003 — Policy enforcement (click navigates to svcs.yml) +``` + +Each top-level item is a `DocumentSymbol` with: +- `name`: ID + title (e.g., `REQ_001 — User authentication`) +- `kind`: `SymbolKind.Key` +- `detail`: significance/verification/result +- `range`: the YAML block for that item +- `children`: linked items from other files as child symbols + +Cross-file navigation: child symbols use `DocumentLink` or are resolved via `textDocument/definition` when clicked. The outline provides the visual structure; clicking a cross-reference child navigates to the target file+line. + +Parse YAML with `ruamel.yaml` in round-trip mode to get line positions for each item. Cross-reference data comes from the DB (`get_svcs_for_req`, `get_mvrs_for_svc`, etc.). + +--- + +## Step 10: YAML Schema Utilities (`yaml_schema.py`) + +Shared JSON Schema loader used by hover (Step 5), diagnostics (Step 6), and completion (Step 7). + +```python +class YamlSchemaHelper: + # Load and cache JSON schemas from src/reqstool/resources/schemas/v1/ + # Map file names to schemas: requirements.yml → requirements.schema.json, etc. + # Extract enum values for completion + # Extract field descriptions for hover + # Validate YAML content against schema for diagnostics +``` + +--- + ## Implementation Order 1. **Step 1**: Dependencies + CLI entry point + package structure 2. **Step 2**: `annotation_parser.py` + tests (pure logic, no pipeline deps) -3. **Step 3**: `project_state.py` + `workspace_manager.py` + tests (uses pipeline, test with `tests/resources/test_data/data/local/test_standard/baseline/ms-001`) +3. **Step 3**: `project_state.py` + `root_discovery.py` + `workspace_manager.py` + tests (uses pipeline, test with `tests/resources/test_data/data/local/test_standard/baseline/ms-001`) 4. **Step 4**: `server.py` — lifecycle, file watching, manual refresh command 5. **Step 5**: `features/hover.py` — source code + YAML hover 6. **Step 6**: `features/diagnostics.py` — source annotations + YAML schema validation 7. **Step 7**: `features/completion.py` — ID completion + YAML enum completion 8. **Step 8**: `features/definition.py` — source ↔ YAML navigation -9. **Step 9**: `yaml_schema.py` — shared JSON Schema loader for diagnostics/completion/hover +9. **Step 9**: `features/document_symbols.py` — outline for YAML files +10. **Step 10**: `yaml_schema.py` — shared JSON Schema loader for diagnostics/completion/hover -Steps 5-8 can be committed incrementally. Step 9 is a shared utility used by steps 5-7. +Steps 5-9 can be committed incrementally. Step 10 is a shared utility used by steps 5-7. --- From 44ba724e71f8cf17d1cb19fe05e89c4fb1857457 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 15 Mar 2026 22:21:13 +0100 Subject: [PATCH 03/37] docs: use optional [lsp] extra for pygls dependencies (#314) Move pygls/lsprotocol to project.optional-dependencies instead of main dependencies. Users install with `pip install reqstool[lsp]` only when they need the LSP server; CI pipelines keep the lighter base install. Signed-off-by: jimisola --- docs/lsp-server-plan.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/lsp-server-plan.md b/docs/lsp-server-plan.md index ef414a20..0b2667cb 100644 --- a/docs/lsp-server-plan.md +++ b/docs/lsp-server-plan.md @@ -144,7 +144,7 @@ ReqstoolLanguageServer (pygls) | File | Change | |---|---| -| `pyproject.toml` | Add `pygls` and `lsprotocol` to dependencies | +| `pyproject.toml` | Add `pygls` and `lsprotocol` as optional `[lsp]` extra | | `src/reqstool/command.py` | Add `lsp` subcommand (lazy import) | --- @@ -152,21 +152,30 @@ ReqstoolLanguageServer (pygls) ## Step 1: Dependencies and CLI Entry Point ### `pyproject.toml` -Add to `dependencies`: -``` -"pygls>=2.0,<3.0", -"lsprotocol>=2024.0.0", +Add as an optional dependency extra (not in the main `dependencies` list): +```toml +[project.optional-dependencies] +lsp = [ + "pygls>=2.0,<3.0", + "lsprotocol>=2024.0.0", +] ``` +Users install with `pip install reqstool[lsp]` (or `hatch env create` with the extra). The base `pip install reqstool` remains lightweight for CI pipelines. + ### `src/reqstool/command.py` -Add `lsp` subparser: +Add `lsp` subparser with a runtime import guard: ```python # In get_arguments(): -subparsers.add_parser("lsp", help="Start the Language Server Protocol server") +subparsers.add_parser("lsp", help="Start the Language Server Protocol server (requires reqstool[lsp])") # In main(): elif args.command == "lsp": - from reqstool.lsp.server import start_server + try: + from reqstool.lsp.server import start_server + except ImportError: + print("LSP server requires extra dependencies: pip install reqstool[lsp]", file=sys.stderr) + sys.exit(1) start_server() ``` From 25b0cf9597e9ba732d32fcce0ace2122b92184c5 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 15 Mar 2026 23:28:00 +0100 Subject: [PATCH 04/37] =?UTF-8?q?feat:=20add=20LSP=20server=20foundation?= =?UTF-8?q?=20=E2=80=94=20dependencies,=20CLI=20entry=20point,=20and=20ann?= =?UTF-8?q?otation=20parser=20(#314)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pygls/lsprotocol as optional [lsp] extra in pyproject.toml - Add `reqstool lsp` subcommand with ImportError guard for missing extra - Create annotation_parser module detecting @Requirements/@SVCs in Java/Python (source decorators) and TypeScript/JavaScript (JSDoc tags) - Add 33 unit tests for annotation parser covering both syntaxes, position lookup, completion context, and multi-line annotations Signed-off-by: jimisola --- pyproject.toml | 7 + src/reqstool/command.py | 13 + src/reqstool/lsp/__init__.py | 1 + src/reqstool/lsp/annotation_parser.py | 225 ++++++++++++++++ src/reqstool/lsp/features/__init__.py | 1 + tests/unit/reqstool/lsp/__init__.py | 0 .../reqstool/lsp/test_annotation_parser.py | 248 ++++++++++++++++++ 7 files changed, 495 insertions(+) create mode 100644 src/reqstool/lsp/__init__.py create mode 100644 src/reqstool/lsp/annotation_parser.py create mode 100644 src/reqstool/lsp/features/__init__.py create mode 100644 tests/unit/reqstool/lsp/__init__.py create mode 100644 tests/unit/reqstool/lsp/test_annotation_parser.py diff --git a/pyproject.toml b/pyproject.toml index 230614de..a13b9a19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,12 @@ dependencies = [ "beautifulsoup4==4.14.3", ] +[project.optional-dependencies] +lsp = [ + "pygls>=2.0,<3.0", + "lsprotocol>=2024.0.0", +] + [project.urls] Homepage = "https://reqstool.github.io" Repository = "https://github.com/reqstool/reqstool-client" @@ -75,6 +81,7 @@ dataset_directory = "docs/reqstool" output_directory = "build/reqstool" [tool.hatch.envs.dev] +features = ["lsp"] dependencies = [ "pytest==8.3.5", "pytest-sugar==1.0.0", diff --git a/src/reqstool/command.py b/src/reqstool/command.py index 770d9bc8..3c0635f8 100755 --- a/src/reqstool/command.py +++ b/src/reqstool/command.py @@ -273,6 +273,9 @@ class ComboRawTextandArgsDefaultUltimateHelpFormatter( status_source_subparsers = status_parser.add_subparsers(dest="source", required=True) self._add_subparsers_source(status_source_subparsers) + # command: lsp + subparsers.add_parser("lsp", help="Start the Language Server Protocol server (requires reqstool[lsp])") + args = self.__parser.parse_args() return args @@ -400,6 +403,16 @@ def main(): command.command_generate_json(generate_json_args=args) elif args.command == "status": exit_code = command.command_status(status_args=args) + elif args.command == "lsp": + try: + from reqstool.lsp.server import start_server + except ImportError: + print( + "LSP server requires extra dependencies: pip install reqstool[lsp]", + file=sys.stderr, + ) + sys.exit(1) + start_server() else: command.print_help() except MissingRequirementsFileError as exc: diff --git a/src/reqstool/lsp/__init__.py b/src/reqstool/lsp/__init__.py new file mode 100644 index 00000000..051704bb --- /dev/null +++ b/src/reqstool/lsp/__init__.py @@ -0,0 +1 @@ +# Copyright © LFV diff --git a/src/reqstool/lsp/annotation_parser.py b/src/reqstool/lsp/annotation_parser.py new file mode 100644 index 00000000..12ea5314 --- /dev/null +++ b/src/reqstool/lsp/annotation_parser.py @@ -0,0 +1,225 @@ +# Copyright © LFV + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AnnotationMatch: + kind: str # "Requirements" or "SVCs" + raw_id: str # e.g. "REQ_010" or "ms-001:REQ_010" + line: int # 0-based line number + start_col: int # column of ID start + end_col: int # column of ID end (exclusive) + + +# Java/Python: @Requirements("REQ_010", "REQ_011") or @SVCs("SVC_010") +SOURCE_ANNOTATION_RE = re.compile(r"@(Requirements|SVCs)\s*\(") +QUOTED_ID_RE = re.compile(r'"([^"]*)"') + +# TypeScript/JavaScript JSDoc: /** @Requirements REQ_010, REQ_011 */ +JSDOC_TAG_RE = re.compile(r"@(Requirements|SVCs)\s+(.+)") +BARE_ID_RE = re.compile(r"[\w:./-]+") + +SOURCE_LANGUAGES = {"python", "java"} +JSDOC_LANGUAGES = {"javascript", "typescript", "javascriptreact", "typescriptreact"} + + +def find_all_annotations(text: str, language_id: str) -> list[AnnotationMatch]: + lines = text.splitlines() + if language_id in SOURCE_LANGUAGES: + return _find_source_annotations(lines) + elif language_id in JSDOC_LANGUAGES: + return _find_jsdoc_annotations(lines) + return [] + + +def annotation_at_position(text: str, line: int, character: int, language_id: str) -> AnnotationMatch | None: + for match in find_all_annotations(text, language_id): + if match.line == line and match.start_col <= character < match.end_col: + return match + return None + + +def is_inside_annotation(line_text: str, character: int, language_id: str) -> str | None: + if language_id in SOURCE_LANGUAGES: + return _is_inside_source_annotation(line_text, character) + elif language_id in JSDOC_LANGUAGES: + return _is_inside_jsdoc_annotation(line_text, character) + return None + + +def _find_source_annotations(lines: list[str]) -> list[AnnotationMatch]: + results: list[AnnotationMatch] = [] + i = 0 + while i < len(lines): + line = lines[i] + for m in SOURCE_ANNOTATION_RE.finditer(line): + kind = m.group(1) + # Collect the full argument text, handling multi-line parens + paren_start = m.end() - 1 # position of '(' + arg_text, arg_lines = _collect_paren_content(lines, i, paren_start) + # Find all quoted IDs within the argument text + offset_in_first_line = m.end() + _extract_quoted_ids(results, kind, arg_text, arg_lines, lines, i, offset_in_first_line) + i += 1 + return results + + +def _collect_paren_content(lines: list[str], start_line: int, paren_col: int) -> tuple[str, list[tuple[int, int]]]: + """Collect text between parens, possibly spanning multiple lines. + + Returns (full_text_between_parens, list_of_(line_idx, line_start_offset)). + """ + depth = 0 + parts: list[str] = [] + # Track which line and offset each character in the combined text came from + line_offsets: list[tuple[int, int]] = [] # (line_index, start_col_in_combined_text) + + combined_len = 0 + for line_idx in range(start_line, len(lines)): + line = lines[line_idx] + start_col = paren_col if line_idx == start_line else 0 + for col in range(start_col, len(line)): + ch = line[col] + if ch == "(": + depth += 1 + if depth == 1: + line_offsets.append((line_idx, combined_len)) + continue # skip the opening paren + elif ch == ")": + depth -= 1 + if depth == 0: + return "".join(parts), line_offsets + if depth >= 1: + if not parts or line_offsets[-1][0] != line_idx: + line_offsets.append((line_idx, combined_len)) + parts.append(ch) + combined_len += 1 + if depth >= 1: + parts.append("\n") + combined_len += 1 + return "".join(parts), line_offsets + + +def _extract_quoted_ids( + results: list[AnnotationMatch], + kind: str, + arg_text: str, + arg_lines: list[tuple[int, int]], + lines: list[str], + annotation_line: int, + offset_in_first_line: int, +) -> None: + for id_match in QUOTED_ID_RE.finditer(arg_text): + raw_id = id_match.group(1) + # Map position in arg_text back to source line/col + id_start_in_arg = id_match.start(1) + id_end_in_arg = id_match.end(1) + src_line, src_col_start = _map_offset_to_source(arg_lines, lines, annotation_line, id_start_in_arg) + _, src_col_end = _map_offset_to_source(arg_lines, lines, annotation_line, id_end_in_arg) + results.append( + AnnotationMatch( + kind=kind, + raw_id=raw_id, + line=src_line, + start_col=src_col_start, + end_col=src_col_end, + ) + ) + + +def _map_offset_to_source( + arg_lines: list[tuple[int, int]], + lines: list[str], + annotation_line: int, + offset: int, +) -> tuple[int, int]: + """Map an offset within the combined arg_text back to a (line, col) in the source.""" + # Find which line segment this offset falls into + target_line = annotation_line + target_start_offset = 0 + for line_idx, start_offset in arg_lines: + if start_offset <= offset: + target_line = line_idx + target_start_offset = start_offset + else: + break + + # Calculate column: find where in the actual source line this offset maps + chars_into_segment = offset - target_start_offset + line_text = lines[target_line] + + # Find the start of this segment in the actual line + if target_line == annotation_line: + # First line: content starts after @Kind( + segment_start_col = line_text.index("(", line_text.index("@")) + 1 + else: + segment_start_col = 0 + + # Walk through the line to find the actual column, accounting for the quote char + col = segment_start_col + counted = 0 + while col < len(line_text) and counted < chars_into_segment: + col += 1 + counted += 1 + + return target_line, col + + +def _find_jsdoc_annotations(lines: list[str]) -> list[AnnotationMatch]: + results: list[AnnotationMatch] = [] + for line_idx, line in enumerate(lines): + for m in JSDOC_TAG_RE.finditer(line): + kind = m.group(1) + ids_text = m.group(2) + ids_start = m.start(2) + # Strip trailing */ or whitespace + ids_text = re.sub(r"\s*\*/\s*$", "", ids_text) + for id_match in BARE_ID_RE.finditer(ids_text): + raw_id = id_match.group(0) + start_col = ids_start + id_match.start() + end_col = ids_start + id_match.end() + results.append( + AnnotationMatch( + kind=kind, + raw_id=raw_id, + line=line_idx, + start_col=start_col, + end_col=end_col, + ) + ) + return results + + +def _is_inside_source_annotation(line_text: str, character: int) -> str | None: + for m in SOURCE_ANNOTATION_RE.finditer(line_text): + kind = m.group(1) + paren_pos = m.end() - 1 + # Find closing paren on same line + depth = 0 + for col in range(paren_pos, len(line_text)): + if line_text[col] == "(": + depth += 1 + elif line_text[col] == ")": + depth -= 1 + if depth == 0: + if paren_pos < character <= col: + return kind + break + else: + # No closing paren found on this line — cursor might still be inside + if character > paren_pos: + return kind + return None + + +def _is_inside_jsdoc_annotation(line_text: str, character: int) -> str | None: + for m in JSDOC_TAG_RE.finditer(line_text): + kind = m.group(1) + line_end = len(line_text.rstrip()) + if m.start(2) <= character <= line_end: + return kind + return None diff --git a/src/reqstool/lsp/features/__init__.py b/src/reqstool/lsp/features/__init__.py new file mode 100644 index 00000000..051704bb --- /dev/null +++ b/src/reqstool/lsp/features/__init__.py @@ -0,0 +1 @@ +# Copyright © LFV diff --git a/tests/unit/reqstool/lsp/__init__.py b/tests/unit/reqstool/lsp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/reqstool/lsp/test_annotation_parser.py b/tests/unit/reqstool/lsp/test_annotation_parser.py new file mode 100644 index 00000000..5363619e --- /dev/null +++ b/tests/unit/reqstool/lsp/test_annotation_parser.py @@ -0,0 +1,248 @@ +# Copyright © LFV + +from reqstool.lsp.annotation_parser import annotation_at_position, find_all_annotations, is_inside_annotation + + +# -- find_all_annotations: Python/Java (source annotations) -- + + +def test_python_single_requirement(): + text = '@Requirements("REQ_010")\ndef foo(): pass' + result = find_all_annotations(text, "python") + assert len(result) == 1 + assert result[0].kind == "Requirements" + assert result[0].raw_id == "REQ_010" + assert result[0].line == 0 + + +def test_python_multiple_requirements(): + text = '@Requirements("REQ_010", "REQ_011")\ndef foo(): pass' + result = find_all_annotations(text, "python") + assert len(result) == 2 + assert result[0].raw_id == "REQ_010" + assert result[1].raw_id == "REQ_011" + + +def test_python_svcs(): + text = '@SVCs("SVC_010")\ndef test_foo(): pass' + result = find_all_annotations(text, "python") + assert len(result) == 1 + assert result[0].kind == "SVCs" + assert result[0].raw_id == "SVC_010" + + +def test_python_urn_prefixed_id(): + text = '@Requirements("ms-001:REQ_010")\ndef foo(): pass' + result = find_all_annotations(text, "python") + assert len(result) == 1 + assert result[0].raw_id == "ms-001:REQ_010" + + +def test_python_multiline_annotation(): + text = '@Requirements(\n "REQ_010",\n "REQ_011"\n)\ndef foo(): pass' + result = find_all_annotations(text, "python") + assert len(result) == 2 + assert result[0].raw_id == "REQ_010" + assert result[0].line == 1 + assert result[1].raw_id == "REQ_011" + assert result[1].line == 2 + + +def test_python_no_annotations(): + text = "def foo(): pass\nx = 42" + result = find_all_annotations(text, "python") + assert result == [] + + +def test_python_multiple_annotations_in_file(): + text = '@Requirements("REQ_010")\ndef foo(): pass\n\n@SVCs("SVC_010")\ndef test_foo(): pass' + result = find_all_annotations(text, "python") + assert len(result) == 2 + assert result[0].kind == "Requirements" + assert result[1].kind == "SVCs" + + +def test_python_column_positions(): + text = '@Requirements("REQ_010")' + result = find_all_annotations(text, "python") + assert len(result) == 1 + # @Requirements("REQ_010") — R is at col 15 (0-indexed) + assert result[0].start_col == 15 + assert result[0].end_col == 22 + + +def test_java_single_requirement(): + text = '@Requirements("REQ_010")\npublic void foo() {}' + result = find_all_annotations(text, "java") + assert len(result) == 1 + assert result[0].kind == "Requirements" + assert result[0].raw_id == "REQ_010" + + +def test_java_multiple_requirements(): + text = '@Requirements("REQ_010", "REQ_011")\npublic void foo() {}' + result = find_all_annotations(text, "java") + assert len(result) == 2 + + +# -- find_all_annotations: JSDoc (TypeScript/JavaScript) -- + + +def test_jsdoc_single_requirement(): + text = "/** @Requirements REQ_010 */\nfunction foo() {}" + result = find_all_annotations(text, "typescript") + assert len(result) == 1 + assert result[0].kind == "Requirements" + assert result[0].raw_id == "REQ_010" + assert result[0].line == 0 + + +def test_jsdoc_multiple_requirements(): + text = "/** @Requirements REQ_010, REQ_011 */\nfunction foo() {}" + result = find_all_annotations(text, "typescript") + assert len(result) == 2 + assert result[0].raw_id == "REQ_010" + assert result[1].raw_id == "REQ_011" + + +def test_jsdoc_svcs(): + text = '/** @SVCs SVC_010 */\ntest("foo", () => {});' + result = find_all_annotations(text, "javascript") + assert len(result) == 1 + assert result[0].kind == "SVCs" + assert result[0].raw_id == "SVC_010" + + +def test_jsdoc_urn_prefixed_id(): + text = "/** @Requirements ms-001:REQ_010 */" + result = find_all_annotations(text, "typescript") + assert len(result) == 1 + assert result[0].raw_id == "ms-001:REQ_010" + + +def test_jsdoc_no_annotations(): + text = "function foo() {}\nconst x = 42;" + result = find_all_annotations(text, "typescript") + assert result == [] + + +def test_jsdoc_column_positions(): + text = "/** @Requirements REQ_010 */" + result = find_all_annotations(text, "typescript") + assert len(result) == 1 + assert result[0].start_col == 18 + assert result[0].end_col == 25 + + +def test_jsdoc_javascriptreact(): + text = "/** @Requirements REQ_010 */" + result = find_all_annotations(text, "javascriptreact") + assert len(result) == 1 + + +def test_jsdoc_typescriptreact(): + text = "/** @SVCs SVC_001 */" + result = find_all_annotations(text, "typescriptreact") + assert len(result) == 1 + + +# -- annotation_at_position -- + + +def test_position_cursor_on_id(): + text = '@Requirements("REQ_010")' + match = annotation_at_position(text, 0, 17, "python") + assert match is not None + assert match.raw_id == "REQ_010" + + +def test_position_cursor_outside_id(): + text = '@Requirements("REQ_010")' + match = annotation_at_position(text, 0, 5, "python") + assert match is None + + +def test_position_cursor_on_second_id(): + text = '@Requirements("REQ_010", "REQ_011")' + match = annotation_at_position(text, 0, 27, "python") + assert match is not None + assert match.raw_id == "REQ_011" + + +def test_position_wrong_line(): + text = '@Requirements("REQ_010")\ndef foo(): pass' + match = annotation_at_position(text, 1, 5, "python") + assert match is None + + +def test_position_jsdoc_cursor_on_id(): + text = "/** @Requirements REQ_010 */" + match = annotation_at_position(text, 0, 20, "typescript") + assert match is not None + assert match.raw_id == "REQ_010" + + +# -- is_inside_annotation -- + + +def test_inside_requirements_quotes(): + line = '@Requirements("REQ_")' + result = is_inside_annotation(line, 17, "python") + assert result == "Requirements" + + +def test_inside_svcs_quotes(): + line = '@SVCs("SVC_")' + result = is_inside_annotation(line, 8, "python") + assert result == "SVCs" + + +def test_inside_outside_annotation(): + line = "def foo(): pass" + result = is_inside_annotation(line, 5, "python") + assert result is None + + +def test_inside_before_paren(): + line = '@Requirements("REQ_010")' + result = is_inside_annotation(line, 5, "python") + assert result is None + + +def test_inside_jsdoc(): + line = "/** @Requirements REQ_ */" + result = is_inside_annotation(line, 20, "typescript") + assert result == "Requirements" + + +def test_inside_jsdoc_outside(): + line = "const x = 42;" + result = is_inside_annotation(line, 5, "typescript") + assert result is None + + +def test_inside_open_paren_multiline(): + line = '@Requirements("REQ_010",' + result = is_inside_annotation(line, 20, "python") + assert result == "Requirements" + + +# -- Unsupported language -- + + +def test_unknown_language_find(): + text = '@Requirements("REQ_010")' + result = find_all_annotations(text, "rust") + assert result == [] + + +def test_unknown_language_position(): + text = '@Requirements("REQ_010")' + result = annotation_at_position(text, 0, 17, "rust") + assert result is None + + +def test_unknown_language_inside(): + line = '@Requirements("REQ_")' + result = is_inside_annotation(line, 17, "rust") + assert result is None From 8ab2ff3259de2412ce7decc3d6840bd7c297f445 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 15 Mar 2026 23:33:27 +0100 Subject: [PATCH 05/37] feat: add project state, root discovery, and workspace manager (#314) - ProjectState wraps build_database() pipeline for a single reqstool project with query helpers for requirements, SVCs, and MVRs - Root discovery finds root projects in workspace folders by parsing requirements.yml metadata and building a local reference graph - WorkspaceManager provides per-folder isolation with add/remove/rebuild operations and file-to-project resolution - Add 27 unit tests covering project lifecycle, root discovery algorithm, and workspace management Signed-off-by: jimisola --- src/reqstool/lsp/project_state.py | 132 +++++++++++++ src/reqstool/lsp/root_discovery.py | 175 +++++++++++++++++ src/reqstool/lsp/workspace_manager.py | 128 +++++++++++++ tests/unit/reqstool/lsp/test_project_state.py | 126 +++++++++++++ .../reqstool/lsp/test_workspace_manager.py | 178 ++++++++++++++++++ 5 files changed, 739 insertions(+) create mode 100644 src/reqstool/lsp/project_state.py create mode 100644 src/reqstool/lsp/root_discovery.py create mode 100644 src/reqstool/lsp/workspace_manager.py create mode 100644 tests/unit/reqstool/lsp/test_project_state.py create mode 100644 tests/unit/reqstool/lsp/test_workspace_manager.py diff --git a/src/reqstool/lsp/project_state.py b/src/reqstool/lsp/project_state.py new file mode 100644 index 00000000..8284f218 --- /dev/null +++ b/src/reqstool/lsp/project_state.py @@ -0,0 +1,132 @@ +# Copyright © LFV + +from __future__ import annotations + +import logging + +from reqstool.common.models.urn_id import UrnId +from reqstool.common.validators.lifecycle_validator import LifecycleValidator +from reqstool.common.validators.semantic_validator import SemanticValidator +from reqstool.common.validator_error_holder import ValidationErrorHolder +from reqstool.locations.local_location import LocalLocation +from reqstool.model_generators.combined_raw_datasets_generator import CombinedRawDatasetsGenerator +from reqstool.models.mvrs import MVRData +from reqstool.models.requirements import RequirementData +from reqstool.models.svcs import SVCData +from reqstool.storage.database import RequirementsDatabase +from reqstool.storage.database_filter_processor import DatabaseFilterProcessor +from reqstool.storage.requirements_repository import RequirementsRepository + +logger = logging.getLogger(__name__) + + +class ProjectState: + def __init__(self, reqstool_path: str): + self._reqstool_path = reqstool_path + self._db: RequirementsDatabase | None = None + self._repo: RequirementsRepository | None = None + self._ready: bool = False + self._error: str | None = None + + @property + def ready(self) -> bool: + return self._ready + + @property + def error(self) -> str | None: + return self._error + + @property + def reqstool_path(self) -> str: + return self._reqstool_path + + def build(self) -> None: + self.close() + self._error = None + db = RequirementsDatabase() + try: + location = LocalLocation(path=self._reqstool_path) + holder = ValidationErrorHolder() + semantic_validator = SemanticValidator(validation_error_holder=holder) + + crdg = CombinedRawDatasetsGenerator( + initial_location=location, + semantic_validator=semantic_validator, + database=db, + ) + crd = crdg.combined_raw_datasets + + DatabaseFilterProcessor(db, crd.raw_datasets).apply_filters() + LifecycleValidator(RequirementsRepository(db)) + + self._db = db + self._repo = RequirementsRepository(db) + self._ready = True + logger.info("Built project state for %s", self._reqstool_path) + except SystemExit as e: + logger.warning("build_database() called sys.exit(%s) for %s", e.code, self._reqstool_path) + self._error = f"Pipeline error (exit code {e.code})" + db.close() + except Exception as e: + logger.error("Failed to build project state for %s: %s", self._reqstool_path, e) + self._error = str(e) + db.close() + + def rebuild(self) -> None: + self.build() + + def close(self) -> None: + if self._db is not None: + self._db.close() + self._db = None + self._repo = None + self._ready = False + + def get_initial_urn(self) -> str | None: + if not self._ready or self._repo is None: + return None + return self._repo.get_initial_urn() + + def get_requirement(self, raw_id: str) -> RequirementData | None: + if not self._ready or self._repo is None: + return None + initial_urn = self._repo.get_initial_urn() + urn_id = UrnId.assure_urn_id(initial_urn, raw_id) + all_reqs = self._repo.get_all_requirements() + return all_reqs.get(urn_id) + + def get_svc(self, raw_id: str) -> SVCData | None: + if not self._ready or self._repo is None: + return None + initial_urn = self._repo.get_initial_urn() + urn_id = UrnId.assure_urn_id(initial_urn, raw_id) + all_svcs = self._repo.get_all_svcs() + return all_svcs.get(urn_id) + + def get_svcs_for_req(self, raw_id: str) -> list[SVCData]: + if not self._ready or self._repo is None: + return [] + initial_urn = self._repo.get_initial_urn() + req_urn_id = UrnId.assure_urn_id(initial_urn, raw_id) + svc_urn_ids = self._repo.get_svcs_for_req(req_urn_id) + all_svcs = self._repo.get_all_svcs() + return [all_svcs[uid] for uid in svc_urn_ids if uid in all_svcs] + + def get_mvrs_for_svc(self, raw_id: str) -> list[MVRData]: + if not self._ready or self._repo is None: + return [] + initial_urn = self._repo.get_initial_urn() + svc_urn_id = UrnId.assure_urn_id(initial_urn, raw_id) + mvr_urn_ids = self._repo.get_mvrs_for_svc(svc_urn_id) + all_mvrs = self._repo.get_all_mvrs() + return [all_mvrs[uid] for uid in mvr_urn_ids if uid in all_mvrs] + + def get_all_requirement_ids(self) -> list[str]: + if not self._ready or self._repo is None: + return [] + return [uid.id for uid in self._repo.get_all_requirements()] + + def get_all_svc_ids(self) -> list[str]: + if not self._ready or self._repo is None: + return [] + return [uid.id for uid in self._repo.get_all_svcs()] diff --git a/src/reqstool/lsp/root_discovery.py b/src/reqstool/lsp/root_discovery.py new file mode 100644 index 00000000..c30d2d83 --- /dev/null +++ b/src/reqstool/lsp/root_discovery.py @@ -0,0 +1,175 @@ +# Copyright © LFV + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass, field + +from ruamel.yaml import YAML + +from reqstool.models.requirements import VARIANTS + +logger = logging.getLogger(__name__) + +SKIP_DIRS = {".git", "node_modules", "build", "target", "__pycache__", ".hatch", ".tox", ".venv", "venv"} +MAX_DEPTH = 5 + + +@dataclass(frozen=True) +class DiscoveredProject: + path: str # directory containing requirements.yml + urn: str # metadata.urn + variant: VARIANTS # system/microservice/external + imported_urns: frozenset[str] = field(default_factory=frozenset) # URNs referenced in imports + implemented_urns: frozenset[str] = field(default_factory=frozenset) # URNs referenced in implementations + + +def discover_root_projects(workspace_folder: str) -> list[DiscoveredProject]: + """Find root reqstool projects in a workspace folder. + + 1. Glob for **/requirements.yml (max depth, skip build dirs) + 2. Quick-parse each: extract metadata.urn, metadata.variant, imports, implementations + 3. Build local reference graph + 4. Return projects not referenced by any other local project (externals excluded) + """ + req_files = _find_requirements_files(workspace_folder) + if not req_files: + return [] + + projects = [] + for req_file in req_files: + project = _quick_parse(req_file) + if project is not None: + projects.append(project) + + if not projects: + return [] + + return _find_roots(projects) + + +def _find_requirements_files(workspace_folder: str) -> list[str]: + results = [] + _walk_dir(workspace_folder, 0, results) + return results + + +def _walk_dir(dirpath: str, depth: int, results: list[str]) -> None: + if depth > MAX_DEPTH: + return + try: + entries = os.scandir(dirpath) + except PermissionError: + return + + subdirs = [] + for entry in entries: + if entry.is_file() and entry.name == "requirements.yml": + results.append(dirpath) + elif entry.is_dir() and not entry.name.startswith(".") and entry.name not in SKIP_DIRS: + subdirs.append(entry.path) + + for subdir in subdirs: + _walk_dir(subdir, depth + 1, results) + + +def _quick_parse(req_dir: str) -> DiscoveredProject | None: + req_file = os.path.join(req_dir, "requirements.yml") + yaml = YAML() + try: + with open(req_file) as f: + data = yaml.load(f) + except Exception as e: + logger.warning("Failed to parse %s: %s", req_file, e) + return None + + if not isinstance(data, dict): + return None + + metadata = data.get("metadata", {}) + urn = metadata.get("urn") + variant_str = metadata.get("variant") + if not urn or not variant_str: + return None + + try: + variant = VARIANTS(variant_str) + except ValueError: + logger.warning("Unknown variant %r in %s", variant_str, req_file) + return None + + imported_urns = _extract_import_urns(data) + implemented_urns = _extract_implementation_urns(data) + + return DiscoveredProject( + path=req_dir, + urn=urn, + variant=variant, + imported_urns=frozenset(imported_urns), + implemented_urns=frozenset(implemented_urns), + ) + + +def _extract_import_urns(data: dict) -> set[str]: + """Extract URNs referenced in the imports section. + + Imports can reference local paths — we resolve these to URNs by quick-parsing + the imported requirements.yml. For non-local imports (git, maven, pypi), + we skip them as they are remote. + """ + urns = set() + imports = data.get("imports", {}) + if not isinstance(imports, dict): + return urns + + local_imports = imports.get("local", []) + if isinstance(local_imports, list): + for item in local_imports: + if isinstance(item, dict) and "path" in item: + urns.add(item["path"]) + return urns + + +def _extract_implementation_urns(data: dict) -> set[str]: + """Extract URNs referenced in the implementations section.""" + urns = set() + implementations = data.get("implementations", {}) + if not isinstance(implementations, dict): + return urns + + local_impls = implementations.get("local", []) + if isinstance(local_impls, list): + for item in local_impls: + if isinstance(item, dict) and "path" in item: + urns.add(item["path"]) + return urns + + +def _find_roots(projects: list[DiscoveredProject]) -> list[DiscoveredProject]: + """A project is a root if no other local project references it. + + We match by resolving relative paths: if project A imports "../B", + we check if B's absolute path matches any other project's path. + External-variant projects are never roots. + """ + # Build a set of all project paths that are referenced by other projects + referenced_paths: set[str] = set() + for project in projects: + for rel_path in project.imported_urns | project.implemented_urns: + abs_path = os.path.normpath(os.path.join(project.path, rel_path)) + referenced_paths.add(abs_path) + + roots = [] + for project in projects: + if project.variant == VARIANTS.EXTERNAL: + continue + norm_path = os.path.normpath(project.path) + if norm_path not in referenced_paths: + roots.append(project) + + # If no roots found (e.g., circular references), fall back to all non-external projects + if not roots: + roots = [p for p in projects if p.variant != VARIANTS.EXTERNAL] + + return roots diff --git a/src/reqstool/lsp/workspace_manager.py b/src/reqstool/lsp/workspace_manager.py new file mode 100644 index 00000000..fcfd7ab3 --- /dev/null +++ b/src/reqstool/lsp/workspace_manager.py @@ -0,0 +1,128 @@ +# Copyright © LFV + +from __future__ import annotations + +import logging +import os +from urllib.parse import unquote, urlparse + +from reqstool.lsp.project_state import ProjectState +from reqstool.lsp.root_discovery import discover_root_projects + +logger = logging.getLogger(__name__) + +STATIC_YAML_FILES = { + "requirements.yml", + "software_verification_cases.yml", + "manual_verification_results.yml", + "reqstool_config.yml", +} + + +class WorkspaceManager: + def __init__(self): + self._folder_projects: dict[str, list[ProjectState]] = {} + + def add_folder(self, folder_uri: str) -> list[ProjectState]: + folder_path = uri_to_path(folder_uri) + roots = discover_root_projects(folder_path) + + projects = [] + for root in roots: + project = ProjectState(reqstool_path=root.path) + project.build() + projects.append(project) + logger.info( + "Discovered root project: urn=%s variant=%s path=%s ready=%s", + root.urn, + root.variant.value, + root.path, + project.ready, + ) + + self._folder_projects[folder_uri] = projects + return projects + + def remove_folder(self, folder_uri: str) -> None: + projects = self._folder_projects.pop(folder_uri, []) + for project in projects: + project.close() + + def rebuild_folder(self, folder_uri: str) -> None: + for project in self._folder_projects.get(folder_uri, []): + project.rebuild() + + def rebuild_all(self) -> None: + for folder_uri in self._folder_projects: + self.rebuild_folder(folder_uri) + + def rebuild_affected(self, file_uri: str) -> ProjectState | None: + """Rebuild the project affected by a changed file. Returns the project or None.""" + project = self.project_for_file(file_uri) + if project is not None: + project.rebuild() + return project + + def project_for_file(self, file_uri: str) -> ProjectState | None: + file_path = uri_to_path(file_uri) + best_match: ProjectState | None = None + best_depth = -1 + + for projects in self._folder_projects.values(): + for project in projects: + reqstool_path = os.path.normpath(project.reqstool_path) + norm_file = os.path.normpath(file_path) + # Check if the file is within the project's directory tree + if norm_file.startswith(reqstool_path + os.sep) or norm_file == reqstool_path: + depth = reqstool_path.count(os.sep) + if depth > best_depth: + best_match = project + best_depth = depth + + # If no direct match, find the closest project by walking up from the file + if best_match is None: + file_dir = os.path.dirname(file_path) if os.path.isfile(file_path) else file_path + best_match = self._find_closest_project(file_dir) + + return best_match + + def _find_closest_project(self, file_dir: str) -> ProjectState | None: + """Find the project whose reqstool_path is the closest ancestor of file_dir.""" + best_match: ProjectState | None = None + best_depth = -1 + + norm_dir = os.path.normpath(file_dir) + for projects in self._folder_projects.values(): + for project in projects: + reqstool_path = os.path.normpath(project.reqstool_path) + if norm_dir.startswith(reqstool_path + os.sep) or norm_dir == reqstool_path: + depth = reqstool_path.count(os.sep) + if depth > best_depth: + best_match = project + best_depth = depth + + return best_match + + def all_projects(self) -> list[ProjectState]: + result = [] + for projects in self._folder_projects.values(): + result.extend(projects) + return result + + def close_all(self) -> None: + for projects in self._folder_projects.values(): + for project in projects: + project.close() + self._folder_projects.clear() + + @staticmethod + def is_static_yaml(file_uri: str) -> bool: + file_path = uri_to_path(file_uri) + return os.path.basename(file_path) in STATIC_YAML_FILES + + +def uri_to_path(uri: str) -> str: + parsed = urlparse(uri) + if parsed.scheme == "file": + return unquote(parsed.path) + return uri diff --git a/tests/unit/reqstool/lsp/test_project_state.py b/tests/unit/reqstool/lsp/test_project_state.py new file mode 100644 index 00000000..01256ec5 --- /dev/null +++ b/tests/unit/reqstool/lsp/test_project_state.py @@ -0,0 +1,126 @@ +# Copyright © LFV + +from reqstool.lsp.project_state import ProjectState + + +def test_build_standard_ms001(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + assert state.ready + assert state.error is None + assert state.get_initial_urn() == "ms-001" + finally: + state.close() + + +def test_build_basic_ms101(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_basic/baseline/ms-101") + state = ProjectState(reqstool_path=path) + try: + state.build() + assert state.ready + assert state.error is None + finally: + state.close() + + +def test_get_all_requirement_ids(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + req_ids = state.get_all_requirement_ids() + assert len(req_ids) > 0 + assert "REQ_010" in req_ids + finally: + state.close() + + +def test_get_all_svc_ids(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + svc_ids = state.get_all_svc_ids() + assert len(svc_ids) > 0 + finally: + state.close() + + +def test_get_requirement(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + req = state.get_requirement("REQ_010") + assert req is not None + assert req.title == "Title REQ_010" + finally: + state.close() + + +def test_get_requirement_not_found(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + req = state.get_requirement("REQ_NONEXISTENT") + assert req is None + finally: + state.close() + + +def test_get_svc(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + svc_ids = state.get_all_svc_ids() + if svc_ids: + svc = state.get_svc(svc_ids[0]) + assert svc is not None + finally: + state.close() + + +def test_rebuild(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + assert state.ready + state.rebuild() + assert state.ready + assert state.get_initial_urn() == "ms-001" + finally: + state.close() + + +def test_close_idempotent(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + state.build() + state.close() + assert not state.ready + state.close() # should not raise + + +def test_queries_when_not_ready(): + state = ProjectState(reqstool_path="/nonexistent") + assert not state.ready + assert state.get_initial_urn() is None + assert state.get_requirement("REQ_010") is None + assert state.get_svc("SVC_010") is None + assert state.get_svcs_for_req("REQ_010") == [] + assert state.get_mvrs_for_svc("SVC_010") == [] + assert state.get_all_requirement_ids() == [] + assert state.get_all_svc_ids() == [] + + +def test_build_nonexistent_path(): + state = ProjectState(reqstool_path="/nonexistent/path") + state.build() + assert not state.ready + assert state.error is not None diff --git a/tests/unit/reqstool/lsp/test_workspace_manager.py b/tests/unit/reqstool/lsp/test_workspace_manager.py new file mode 100644 index 00000000..a32f2c75 --- /dev/null +++ b/tests/unit/reqstool/lsp/test_workspace_manager.py @@ -0,0 +1,178 @@ +# Copyright © LFV + +import os + +from reqstool.lsp.root_discovery import DiscoveredProject, discover_root_projects, _find_roots +from reqstool.lsp.workspace_manager import WorkspaceManager, uri_to_path +from reqstool.models.requirements import VARIANTS + + +# -- root_discovery tests -- + + +def test_discover_root_ms001(local_testdata_resources_rootdir_w_path): + workspace = local_testdata_resources_rootdir_w_path("test_standard/baseline") + roots = discover_root_projects(workspace) + # ms-001 imports sys-001, so sys-001 is referenced. But ms-001 also imports sys-001 + # meaning sys-001 is referenced by ms-001's imports. + # ms-001 is not referenced by anyone, so it should be a root. + urns = {r.urn for r in roots} + assert "ms-001" in urns + + +def test_discover_root_basic(local_testdata_resources_rootdir_w_path): + workspace = local_testdata_resources_rootdir_w_path("test_basic/baseline") + roots = discover_root_projects(workspace) + assert len(roots) >= 1 + urns = {r.urn for r in roots} + assert "ms-101" in urns + + +def test_discover_empty_folder(tmp_path): + roots = discover_root_projects(str(tmp_path)) + assert roots == [] + + +def test_discover_no_requirements_yml(tmp_path): + (tmp_path / "some_file.txt").write_text("hello") + roots = discover_root_projects(str(tmp_path)) + assert roots == [] + + +def test_discover_single_project(tmp_path): + req_dir = tmp_path / "my-project" + req_dir.mkdir() + (req_dir / "requirements.yml").write_text("metadata:\n urn: my-proj\n variant: microservice\n title: Test\n") + roots = discover_root_projects(str(tmp_path)) + assert len(roots) == 1 + assert roots[0].urn == "my-proj" + assert roots[0].variant == VARIANTS.MICROSERVICE + + +def test_discover_external_not_root(tmp_path): + ext_dir = tmp_path / "ext-001" + ext_dir.mkdir() + (ext_dir / "requirements.yml").write_text("metadata:\n urn: ext-001\n variant: external\n title: External\n") + roots = discover_root_projects(str(tmp_path)) + assert roots == [] + + +def test_find_roots_referenced_project_excluded(): + sys_project = DiscoveredProject( + path="/workspace/sys-001", + urn="sys-001", + variant=VARIANTS.SYSTEM, + imported_urns=frozenset(), + implemented_urns=frozenset({"../ms-001"}), + ) + ms_project = DiscoveredProject( + path="/workspace/ms-001", + urn="ms-001", + variant=VARIANTS.MICROSERVICE, + imported_urns=frozenset({"../sys-001"}), + implemented_urns=frozenset(), + ) + roots = _find_roots([sys_project, ms_project]) + # Both reference each other — neither is unreferenced. + # Fallback: all non-external projects are returned. + assert len(roots) == 2 + + +def test_find_roots_system_is_root(): + sys_project = DiscoveredProject( + path="/workspace/sys-001", + urn="sys-001", + variant=VARIANTS.SYSTEM, + imported_urns=frozenset(), + implemented_urns=frozenset({"../ms-001"}), + ) + ms_project = DiscoveredProject( + path="/workspace/ms-001", + urn="ms-001", + variant=VARIANTS.MICROSERVICE, + imported_urns=frozenset(), + implemented_urns=frozenset(), + ) + roots = _find_roots([sys_project, ms_project]) + # sys-001 references ms-001 via implementations, so ms-001 is referenced. + # sys-001 is not referenced by anyone → it's the root. + assert len(roots) == 1 + assert roots[0].urn == "sys-001" + + +# -- workspace_manager tests -- + + +def test_uri_to_path(): + assert uri_to_path("file:///home/user/project") == "/home/user/project" + assert uri_to_path("/home/user/project") == "/home/user/project" + + +def test_uri_to_path_encoded(): + assert uri_to_path("file:///home/user/my%20project") == "/home/user/my project" + + +def test_workspace_manager_add_folder(local_testdata_resources_rootdir_w_path): + workspace = local_testdata_resources_rootdir_w_path("test_standard/baseline") + folder_uri = "file://" + workspace + manager = WorkspaceManager() + try: + projects = manager.add_folder(folder_uri) + assert len(projects) >= 1 + assert any(p.ready for p in projects) + assert len(manager.all_projects()) >= 1 + finally: + manager.close_all() + + +def test_workspace_manager_remove_folder(local_testdata_resources_rootdir_w_path): + workspace = local_testdata_resources_rootdir_w_path("test_standard/baseline") + folder_uri = "file://" + workspace + manager = WorkspaceManager() + try: + manager.add_folder(folder_uri) + assert len(manager.all_projects()) >= 1 + manager.remove_folder(folder_uri) + assert len(manager.all_projects()) == 0 + finally: + manager.close_all() + + +def test_workspace_manager_project_for_file(local_testdata_resources_rootdir_w_path): + workspace = local_testdata_resources_rootdir_w_path("test_standard/baseline") + folder_uri = "file://" + workspace + manager = WorkspaceManager() + try: + manager.add_folder(folder_uri) + req_file_uri = "file://" + os.path.join(workspace, "ms-001", "requirements.yml") + project = manager.project_for_file(req_file_uri) + assert project is not None + assert project.ready + finally: + manager.close_all() + + +def test_workspace_manager_is_static_yaml(): + assert WorkspaceManager.is_static_yaml("file:///path/to/requirements.yml") + assert WorkspaceManager.is_static_yaml("file:///path/to/software_verification_cases.yml") + assert WorkspaceManager.is_static_yaml("file:///path/to/manual_verification_results.yml") + assert WorkspaceManager.is_static_yaml("file:///path/to/reqstool_config.yml") + assert not WorkspaceManager.is_static_yaml("file:///path/to/annotations.yml") + assert not WorkspaceManager.is_static_yaml("file:///path/to/some_file.py") + + +def test_workspace_manager_rebuild_all(local_testdata_resources_rootdir_w_path): + workspace = local_testdata_resources_rootdir_w_path("test_standard/baseline") + folder_uri = "file://" + workspace + manager = WorkspaceManager() + try: + manager.add_folder(folder_uri) + manager.rebuild_all() + assert any(p.ready for p in manager.all_projects()) + finally: + manager.close_all() + + +def test_workspace_manager_close_all_empty(): + manager = WorkspaceManager() + manager.close_all() # should not raise From 25dd212edf4d55ab57ac04201b29258d411b2029 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Mon, 16 Mar 2026 08:51:02 +0100 Subject: [PATCH 06/37] feat: add pygls LSP server with lifecycle, file watching, and refresh command (#314) - ReqstoolLanguageServer (pygls subclass) with workspace manager integration - Lifecycle handlers: initialized, shutdown, didOpen/Change/Save/Close - Workspace folder change tracking (add/remove folders dynamically) - File watcher for static YAML files triggers project rebuild - reqstool.refresh command for manual rebuild of all projects - Diagnostic publishing placeholders for Step 6 Signed-off-by: jimisola --- src/reqstool/lsp/server.py | 166 +++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/reqstool/lsp/server.py diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py new file mode 100644 index 00000000..d5b079f0 --- /dev/null +++ b/src/reqstool/lsp/server.py @@ -0,0 +1,166 @@ +# Copyright © LFV + +from __future__ import annotations + +import logging + +from lsprotocol import types +from pygls.lsp.server import LanguageServer + +from reqstool.lsp.workspace_manager import WorkspaceManager + +logger = logging.getLogger(__name__) + +SERVER_NAME = "reqstool" +SERVER_VERSION = "0.1.0" + + +class ReqstoolLanguageServer(LanguageServer): + def __init__(self): + super().__init__(name=SERVER_NAME, version=SERVER_VERSION) + self.workspace_manager = WorkspaceManager() + + +server = ReqstoolLanguageServer() + + +# -- Lifecycle handlers -- + + +@server.feature(types.INITIALIZED) +def on_initialized(ls: ReqstoolLanguageServer, params: types.InitializedParams) -> None: + logger.info("reqstool LSP server initialized") + _discover_and_build(ls) + + +@server.feature(types.SHUTDOWN) +def on_shutdown(ls: ReqstoolLanguageServer, params: None) -> None: + logger.info("reqstool LSP server shutting down") + ls.workspace_manager.close_all() + + +# -- Document lifecycle -- + + +@server.feature(types.TEXT_DOCUMENT_DID_OPEN) +def on_did_open(ls: ReqstoolLanguageServer, params: types.DidOpenTextDocumentParams) -> None: + _publish_diagnostics_for_document(ls, params.text_document.uri) + + +@server.feature(types.TEXT_DOCUMENT_DID_CHANGE) +def on_did_change(ls: ReqstoolLanguageServer, params: types.DidChangeTextDocumentParams) -> None: + _publish_diagnostics_for_document(ls, params.text_document.uri) + + +@server.feature(types.TEXT_DOCUMENT_DID_SAVE) +def on_did_save(ls: ReqstoolLanguageServer, params: types.DidSaveTextDocumentParams) -> None: + uri = params.text_document.uri + if WorkspaceManager.is_static_yaml(uri): + logger.info("Static YAML file saved, rebuilding affected project: %s", uri) + ls.workspace_manager.rebuild_affected(uri) + _publish_all_diagnostics(ls) + else: + _publish_diagnostics_for_document(ls, uri) + + +@server.feature(types.TEXT_DOCUMENT_DID_CLOSE) +def on_did_close(ls: ReqstoolLanguageServer, params: types.DidCloseTextDocumentParams) -> None: + # Clear diagnostics for closed document + ls.text_document_publish_diagnostics(types.PublishDiagnosticsParams(uri=params.text_document.uri, diagnostics=[])) + + +# -- Workspace folder changes -- + + +@server.feature(types.WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS) +def on_workspace_folders_changed(ls: ReqstoolLanguageServer, params: types.DidChangeWorkspaceFoldersParams) -> None: + for removed in params.event.removed: + logger.info("Workspace folder removed: %s", removed.uri) + ls.workspace_manager.remove_folder(removed.uri) + + for added in params.event.added: + logger.info("Workspace folder added: %s", added.uri) + ls.workspace_manager.add_folder(added.uri) + + _publish_all_diagnostics(ls) + + +# -- File watcher -- + + +@server.feature(types.WORKSPACE_DID_CHANGE_WATCHED_FILES) +def on_watched_files_changed(ls: ReqstoolLanguageServer, params: types.DidChangeWatchedFilesParams) -> None: + rebuild_needed = False + for change in params.changes: + if WorkspaceManager.is_static_yaml(change.uri): + logger.info("Watched file changed: %s (type=%s)", change.uri, change.type) + rebuild_needed = True + + if rebuild_needed: + ls.workspace_manager.rebuild_all() + _publish_all_diagnostics(ls) + + +# -- Commands -- + + +@server.command("reqstool.refresh") +def cmd_refresh(ls: ReqstoolLanguageServer, *args) -> None: + logger.info("Manual refresh requested") + ls.workspace_manager.rebuild_all() + _publish_all_diagnostics(ls) + ls.window_show_message(types.ShowMessageParams(type=types.MessageType.Info, message="reqstool: projects refreshed")) + + +# -- Internal helpers -- + + +def _discover_and_build(ls: ReqstoolLanguageServer) -> None: + """Discover reqstool projects in all workspace folders and build databases.""" + try: + folders = ls.workspace.folders + except RuntimeError: + logger.warning("Workspace not available during initialization") + return + + if not folders: + logger.info("No workspace folders found") + return + + for folder_uri, folder in folders.items(): + logger.info("Discovering reqstool projects in workspace folder: %s", folder.name) + projects = ls.workspace_manager.add_folder(folder_uri) + for project in projects: + if project.ready: + ls.window_show_message( + types.ShowMessageParams( + type=types.MessageType.Info, + message=f"reqstool: loaded project at {project.reqstool_path}", + ) + ) + elif project.error: + ls.window_show_message( + types.ShowMessageParams( + type=types.MessageType.Warning, + message=f"reqstool: failed to load {project.reqstool_path}: {project.error}", + ) + ) + + +def _publish_diagnostics_for_document(ls: ReqstoolLanguageServer, uri: str) -> None: + """Publish diagnostics for a single document. Placeholder for Step 6.""" + # Will be implemented in features/diagnostics.py + pass + + +def _publish_all_diagnostics(ls: ReqstoolLanguageServer) -> None: + """Re-publish diagnostics for all open documents. Placeholder for Step 6.""" + # Will be implemented in features/diagnostics.py + pass + + +def start_server() -> None: + """Entry point for `reqstool lsp` command.""" + logging.basicConfig(level=logging.INFO) + logger.info("Starting reqstool LSP server (stdio)") + server.start_io() From 4d25183e73e1a193133cf48f65150b90f9876838 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Mon, 16 Mar 2026 09:13:39 +0100 Subject: [PATCH 07/37] feat: add hover feature and YAML schema utilities for LSP server (#314) - Hover on @Requirements/@SVCs annotations shows requirement/SVC details - Hover on YAML fields shows JSON Schema descriptions - YAML schema utilities: load schemas, resolve $ref, get field descriptions/enum values - Wire hover handler into LSP server Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/hover.py | 211 ++++++++++++++++++++ src/reqstool/lsp/server.py | 17 ++ src/reqstool/lsp/yaml_schema.py | 118 +++++++++++ tests/unit/reqstool/lsp/test_hover.py | 144 +++++++++++++ tests/unit/reqstool/lsp/test_yaml_schema.py | 114 +++++++++++ 5 files changed, 604 insertions(+) create mode 100644 src/reqstool/lsp/features/hover.py create mode 100644 src/reqstool/lsp/yaml_schema.py create mode 100644 tests/unit/reqstool/lsp/test_hover.py create mode 100644 tests/unit/reqstool/lsp/test_yaml_schema.py diff --git a/src/reqstool/lsp/features/hover.py b/src/reqstool/lsp/features/hover.py new file mode 100644 index 00000000..709793d2 --- /dev/null +++ b/src/reqstool/lsp/features/hover.py @@ -0,0 +1,211 @@ +# Copyright © LFV + +from __future__ import annotations + +import os +import re + +from lsprotocol import types + +from reqstool.lsp.annotation_parser import annotation_at_position +from reqstool.lsp.project_state import ProjectState +from reqstool.lsp.yaml_schema import get_field_description, schema_for_yaml_file + +# YAML files that the LSP provides hover for +REQSTOOL_YAML_FILES = { + "requirements.yml", + "software_verification_cases.yml", + "manual_verification_results.yml", + "reqstool_config.yml", +} + + +def handle_hover( + uri: str, + position: types.Position, + text: str, + language_id: str, + project: ProjectState | None, +) -> types.Hover | None: + basename = os.path.basename(uri) + if basename in REQSTOOL_YAML_FILES: + return _hover_yaml(text, position, basename) + else: + return _hover_source(text, position, language_id, project) + + +def _hover_source( + text: str, + position: types.Position, + language_id: str, + project: ProjectState | None, +) -> types.Hover | None: + match = annotation_at_position(text, position.line, position.character, language_id) + if match is None: + return None + + if project is None or not project.ready: + return types.Hover( + contents=types.MarkupContent( + kind=types.MarkupKind.Markdown, + value=f"`{match.raw_id}` — *project not loaded*", + ), + range=types.Range( + start=types.Position(line=match.line, character=match.start_col), + end=types.Position(line=match.line, character=match.end_col), + ), + ) + + if match.kind == "Requirements": + return _hover_requirement(match.raw_id, match, project) + elif match.kind == "SVCs": + return _hover_svc(match.raw_id, match, project) + + return None + + +def _hover_requirement(raw_id: str, match, project: ProjectState) -> types.Hover | None: + req = project.get_requirement(raw_id) + if req is None: + md = f"**Unknown requirement**: `{raw_id}`" + else: + svcs = project.get_svcs_for_req(raw_id) + svc_ids = ", ".join(f"`{s.id.id}`" for s in svcs) if svcs else "—" + categories = ", ".join(c.value for c in req.categories) if req.categories else "—" + + parts = [ + f"### {req.title}", + f"`{req.id.id}` `{req.significance.value}` `{req.revision}`", + "---", + req.description, + ] + if req.rationale: + parts.extend(["---", req.rationale]) + parts.extend([ + "---", + f"**Categories**: {categories}", + f"**Lifecycle**: {req.lifecycle.state.value}", + f"**SVCs**: {svc_ids}", + ]) + md = "\n\n".join(parts) + + return types.Hover( + contents=types.MarkupContent(kind=types.MarkupKind.Markdown, value=md), + range=types.Range( + start=types.Position(line=match.line, character=match.start_col), + end=types.Position(line=match.line, character=match.end_col), + ), + ) + + +def _hover_svc(raw_id: str, match, project: ProjectState) -> types.Hover | None: + svc = project.get_svc(raw_id) + if svc is None: + md = f"**Unknown SVC**: `{raw_id}`" + else: + mvrs = project.get_mvrs_for_svc(raw_id) + req_ids = ", ".join(f"`{r.id}`" for r in svc.requirement_ids) if svc.requirement_ids else "—" + mvr_info = ", ".join(f"{'pass' if m.passed else 'fail'}" for m in mvrs) if mvrs else "—" + + parts = [ + f"### {svc.title}", + f"`{svc.id.id}` `{svc.verification.value}` `{svc.revision}`", + "---", + ] + if svc.description: + parts.append(svc.description) + parts.append("---") + if svc.instructions: + parts.append(svc.instructions) + parts.append("---") + parts.extend([ + f"**Lifecycle**: {svc.lifecycle.state.value}", + f"**Requirements**: {req_ids}", + f"**MVRs**: {mvr_info}", + ]) + md = "\n\n".join(parts) + + return types.Hover( + contents=types.MarkupContent(kind=types.MarkupKind.Markdown, value=md), + range=types.Range( + start=types.Position(line=match.line, character=match.start_col), + end=types.Position(line=match.line, character=match.end_col), + ), + ) + + +def _hover_yaml(text: str, position: types.Position, filename: str) -> types.Hover | None: + """Show JSON Schema description when hovering over a YAML field name.""" + schema = schema_for_yaml_file(filename) + if schema is None: + return None + + line = text.splitlines()[position.line] if position.line < len(text.splitlines()) else "" + field_path = _yaml_field_path_at_line(text, position.line) + if not field_path: + return None + + description = get_field_description(schema, field_path) + if not description: + return None + + # Find the field name on the current line for hover range + field_name = field_path[-1] + field_match = re.search(r"\b" + re.escape(field_name) + r"\s*:", line) + if field_match: + start_col = field_match.start() + end_col = field_match.start() + len(field_name) + else: + start_col = 0 + end_col = len(line.rstrip()) + + return types.Hover( + contents=types.MarkupContent( + kind=types.MarkupKind.Markdown, + value=f"**{field_name}**: {description}", + ), + range=types.Range( + start=types.Position(line=position.line, character=start_col), + end=types.Position(line=position.line, character=end_col), + ), + ) + + +def _yaml_field_path_at_line(text: str, target_line: int) -> list[str]: + """Determine the YAML field path at a given line by tracking indentation. + + Handles YAML array items: ` - id: REQ_001` and ` significance: shall` + both have parent `requirements:` (which is the array container). + """ + lines = text.splitlines() + if target_line >= len(lines): + return [] + + target = lines[target_line] + # Extract field name from " key: value" or " key:" or " - key: value" + m = re.match(r"^(\s*)(?:-\s+)?(\w[\w-]*)\s*:", target) + if not m: + return [] + + leading_spaces = len(m.group(1)) + field_name = m.group(2) + + path = [field_name] + + # Walk backwards to find parent fields with less indentation. + # Skip lines that are list item entries (have "- " prefix) — they are + # siblings within the same array, not structural parents. + current_indent = leading_spaces + for i in range(target_line - 1, -1, -1): + line = lines[i] + pm = re.match(r"^(\s*)(-\s+)?(\w[\w-]*)\s*:", line) + if pm: + indent = len(pm.group(1)) + has_dash = pm.group(2) is not None + if indent < current_indent and not has_dash: + path.insert(0, pm.group(3)) + current_indent = indent + if indent == 0: + break + + return path diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index d5b079f0..571876c6 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -7,6 +7,7 @@ from lsprotocol import types from pygls.lsp.server import LanguageServer +from reqstool.lsp.features.hover import handle_hover from reqstool.lsp.workspace_manager import WorkspaceManager logger = logging.getLogger(__name__) @@ -112,6 +113,22 @@ def cmd_refresh(ls: ReqstoolLanguageServer, *args) -> None: ls.window_show_message(types.ShowMessageParams(type=types.MessageType.Info, message="reqstool: projects refreshed")) +# -- Feature handlers -- + + +@server.feature(types.TEXT_DOCUMENT_HOVER) +def on_hover(ls: ReqstoolLanguageServer, params: types.HoverParams) -> types.Hover | None: + document = ls.workspace.get_text_document(params.text_document.uri) + project = ls.workspace_manager.project_for_file(params.text_document.uri) + return handle_hover( + uri=params.text_document.uri, + position=params.position, + text=document.source, + language_id=document.language_id or "", + project=project, + ) + + # -- Internal helpers -- diff --git a/src/reqstool/lsp/yaml_schema.py b/src/reqstool/lsp/yaml_schema.py new file mode 100644 index 00000000..565876fa --- /dev/null +++ b/src/reqstool/lsp/yaml_schema.py @@ -0,0 +1,118 @@ +# Copyright © LFV + +from __future__ import annotations + +import json +import logging +import os +from functools import lru_cache + +logger = logging.getLogger(__name__) + +SCHEMA_DIR = os.path.join(os.path.dirname(__file__), "..", "resources", "schemas", "v1") + +# Map YAML file names to their schema files +YAML_TO_SCHEMA: dict[str, str] = { + "requirements.yml": "requirements.schema.json", + "software_verification_cases.yml": "software_verification_cases.schema.json", + "manual_verification_results.yml": "manual_verification_results.schema.json", + "reqstool_config.yml": "reqstool_config.schema.json", +} + + +@lru_cache(maxsize=16) +def load_schema(schema_name: str) -> dict | None: + schema_path = os.path.join(SCHEMA_DIR, schema_name) + try: + with open(schema_path) as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as e: + logger.warning("Failed to load schema %s: %s", schema_name, e) + return None + + +def schema_for_yaml_file(filename: str) -> dict | None: + basename = os.path.basename(filename) + schema_name = YAML_TO_SCHEMA.get(basename) + if schema_name is None: + return None + return load_schema(schema_name) + + +def get_field_description(schema: dict, field_path: list[str]) -> str | None: + """Walk schema to find description for a nested field path. + + E.g., field_path=["metadata", "variant"] looks up: + schema -> properties.metadata -> $ref -> properties.variant -> description + """ + current = schema + for part in field_path: + current = _resolve_ref(schema, current) + props = current.get("properties", {}) + if part in props: + current = props[part] + else: + # Check items for array fields + items = current.get("items", {}) + if items: + items = _resolve_ref(schema, items) + props = items.get("properties", {}) + if part in props: + current = props[part] + else: + return None + else: + return None + + current = _resolve_ref(schema, current) + return current.get("description") + + +def get_enum_values(schema: dict, field_path: list[str]) -> list[str]: + """Walk schema to find enum values for a field.""" + current = schema + for part in field_path: + current = _resolve_ref(schema, current) + props = current.get("properties", {}) + if part in props: + current = props[part] + else: + items = current.get("items", {}) + if items: + items = _resolve_ref(schema, items) + props = items.get("properties", {}) + if part in props: + current = props[part] + else: + return [] + else: + return [] + + current = _resolve_ref(schema, current) + if "enum" in current: + return current["enum"] + # Check items for array-of-enum fields (e.g., categories) + items = current.get("items", {}) + if items: + items = _resolve_ref(schema, items) + if "enum" in items: + return items["enum"] + return [] + + +def _resolve_ref(root_schema: dict, node: dict) -> dict: + """Resolve a $ref within the same schema file.""" + ref = node.get("$ref") + if ref is None or not isinstance(ref, str): + return node + # Only handle local refs (starting with #/) + if not ref.startswith("#/"): + return node + parts = ref.lstrip("#/").split("/") + resolved = root_schema + for part in parts: + if isinstance(resolved, dict): + resolved = resolved.get(part, {}) + else: + return node + return resolved diff --git a/tests/unit/reqstool/lsp/test_hover.py b/tests/unit/reqstool/lsp/test_hover.py new file mode 100644 index 00000000..dfbc4d08 --- /dev/null +++ b/tests/unit/reqstool/lsp/test_hover.py @@ -0,0 +1,144 @@ +# Copyright © LFV + +from lsprotocol import types + +from reqstool.lsp.features.hover import handle_hover, _yaml_field_path_at_line + + +# -- Source code hover -- + + +def test_hover_python_requirement(local_testdata_resources_rootdir_w_path): + from reqstool.lsp.project_state import ProjectState + + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + text = '@Requirements("REQ_010")\ndef foo(): pass' + result = handle_hover( + uri="file:///test.py", + position=types.Position(line=0, character=17), + text=text, + language_id="python", + project=state, + ) + assert result is not None + assert "REQ_010" in result.contents.value + assert "Title REQ_010" in result.contents.value + finally: + state.close() + + +def test_hover_python_unknown_id(local_testdata_resources_rootdir_w_path): + from reqstool.lsp.project_state import ProjectState + + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + text = '@Requirements("REQ_NONEXISTENT")\ndef foo(): pass' + result = handle_hover( + uri="file:///test.py", + position=types.Position(line=0, character=17), + text=text, + language_id="python", + project=state, + ) + assert result is not None + assert "Unknown" in result.contents.value + finally: + state.close() + + +def test_hover_no_project(): + text = '@Requirements("REQ_010")\ndef foo(): pass' + result = handle_hover( + uri="file:///test.py", + position=types.Position(line=0, character=17), + text=text, + language_id="python", + project=None, + ) + assert result is not None + assert "not loaded" in result.contents.value + + +def test_hover_outside_annotation(): + text = "def foo(): pass" + result = handle_hover( + uri="file:///test.py", + position=types.Position(line=0, character=5), + text=text, + language_id="python", + project=None, + ) + assert result is None + + +# -- YAML hover -- + + +def test_hover_yaml_field(): + text = "metadata:\n urn: my-urn\n variant: system\n title: My Title\n" + result = handle_hover( + uri="file:///workspace/requirements.yml", + position=types.Position(line=2, character=3), + text=text, + language_id="yaml", + project=None, + ) + assert result is not None + assert "variant" in result.contents.value + + +def test_hover_yaml_significance(): + text = "requirements:\n - id: REQ_001\n significance: shall\n" + result = handle_hover( + uri="file:///workspace/requirements.yml", + position=types.Position(line=2, character=5), + text=text, + language_id="yaml", + project=None, + ) + assert result is not None + assert "significance" in result.contents.value + + +def test_hover_yaml_non_reqstool_file(): + text = "key: value\n" + result = handle_hover( + uri="file:///workspace/some_other.yml", + position=types.Position(line=0, character=1), + text=text, + language_id="yaml", + project=None, + ) + assert result is None + + +# -- YAML field path parsing -- + + +def test_yaml_field_path_simple(): + text = "metadata:\n urn: value" + path = _yaml_field_path_at_line(text, 1) + assert path == ["metadata", "urn"] + + +def test_yaml_field_path_top_level(): + text = "metadata:\n urn: value" + path = _yaml_field_path_at_line(text, 0) + assert path == ["metadata"] + + +def test_yaml_field_path_nested(): + text = "metadata:\n urn: value\n variant: system\nrequirements:\n - id: REQ_001\n significance: shall" + path = _yaml_field_path_at_line(text, 5) + assert path == ["requirements", "significance"] + + +def test_yaml_field_path_no_field(): + text = " - some list item" + path = _yaml_field_path_at_line(text, 0) + assert path == [] diff --git a/tests/unit/reqstool/lsp/test_yaml_schema.py b/tests/unit/reqstool/lsp/test_yaml_schema.py new file mode 100644 index 00000000..923a3a2d --- /dev/null +++ b/tests/unit/reqstool/lsp/test_yaml_schema.py @@ -0,0 +1,114 @@ +# Copyright © LFV + +from reqstool.lsp.yaml_schema import ( + get_enum_values, + get_field_description, + load_schema, + schema_for_yaml_file, +) + + +def test_load_requirements_schema(): + schema = load_schema("requirements.schema.json") + assert schema is not None + assert "$defs" in schema + + +def test_load_svcs_schema(): + schema = load_schema("software_verification_cases.schema.json") + assert schema is not None + + +def test_load_mvrs_schema(): + schema = load_schema("manual_verification_results.schema.json") + assert schema is not None + + +def test_load_nonexistent_schema(): + schema = load_schema("nonexistent.schema.json") + assert schema is None + + +def test_schema_for_requirements_yml(): + schema = schema_for_yaml_file("requirements.yml") + assert schema is not None + assert "$defs" in schema + + +def test_schema_for_svcs_yml(): + schema = schema_for_yaml_file("software_verification_cases.yml") + assert schema is not None + + +def test_schema_for_unknown_file(): + schema = schema_for_yaml_file("unknown.yml") + assert schema is None + + +def test_get_field_description_metadata_urn(): + schema = load_schema("requirements.schema.json") + desc = get_field_description(schema, ["metadata", "urn"]) + assert desc is not None + assert "resource name" in desc.lower() or "urn" in desc.lower() + + +def test_get_field_description_metadata_variant(): + schema = load_schema("requirements.schema.json") + desc = get_field_description(schema, ["metadata", "variant"]) + assert desc is not None + assert "system" in desc.lower() or "microservice" in desc.lower() + + +def test_get_field_description_requirements(): + schema = load_schema("requirements.schema.json") + desc = get_field_description(schema, ["requirements"]) + assert desc is not None + + +def test_get_field_description_significance(): + schema = load_schema("requirements.schema.json") + desc = get_field_description(schema, ["requirements", "significance"]) + assert desc is not None + assert "shall" in desc.lower() or "significance" in desc.lower() + + +def test_get_field_description_nonexistent(): + schema = load_schema("requirements.schema.json") + desc = get_field_description(schema, ["nonexistent"]) + assert desc is None + + +def test_get_enum_values_variant(): + schema = load_schema("requirements.schema.json") + values = get_enum_values(schema, ["metadata", "variant"]) + assert "microservice" in values + assert "system" in values + assert "external" in values + + +def test_get_enum_values_significance(): + schema = load_schema("requirements.schema.json") + values = get_enum_values(schema, ["requirements", "significance"]) + assert "shall" in values + assert "should" in values + assert "may" in values + + +def test_get_enum_values_categories(): + schema = load_schema("requirements.schema.json") + values = get_enum_values(schema, ["requirements", "categories"]) + assert "functional-suitability" in values + assert "security" in values + + +def test_get_enum_values_implementation(): + schema = load_schema("requirements.schema.json") + values = get_enum_values(schema, ["requirements", "implementation"]) + assert "in-code" in values + assert "N/A" in values + + +def test_get_enum_values_nonexistent(): + schema = load_schema("requirements.schema.json") + values = get_enum_values(schema, ["nonexistent"]) + assert values == [] From 8172da574b239c800ef20d04ac131122e4e87086 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Mon, 16 Mar 2026 09:15:55 +0100 Subject: [PATCH 08/37] feat: add diagnostics feature for LSP server (#314) - Source code diagnostics: unknown requirement/SVC IDs, deprecated/obsolete lifecycle warnings - YAML diagnostics: parse errors and JSON Schema validation against reqstool schemas - Wire diagnostics into server on didOpen, didChange, didSave, and rebuild events Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/diagnostics.py | 220 ++++++++++++++++++++ src/reqstool/lsp/server.py | 25 ++- tests/unit/reqstool/lsp/test_diagnostics.py | 200 ++++++++++++++++++ 3 files changed, 439 insertions(+), 6 deletions(-) create mode 100644 src/reqstool/lsp/features/diagnostics.py create mode 100644 tests/unit/reqstool/lsp/test_diagnostics.py diff --git a/src/reqstool/lsp/features/diagnostics.py b/src/reqstool/lsp/features/diagnostics.py new file mode 100644 index 00000000..8b8b065e --- /dev/null +++ b/src/reqstool/lsp/features/diagnostics.py @@ -0,0 +1,220 @@ +# Copyright © LFV + +from __future__ import annotations + +import logging +import os +import re + +import yaml +from jsonschema import Draft202012Validator +from lsprotocol import types + +from reqstool.common.models.lifecycle import LIFECYCLESTATE +from reqstool.lsp.annotation_parser import find_all_annotations +from reqstool.lsp.project_state import ProjectState +from reqstool.lsp.yaml_schema import schema_for_yaml_file + +logger = logging.getLogger(__name__) + +# YAML files that the LSP validates +REQSTOOL_YAML_FILES = { + "requirements.yml", + "software_verification_cases.yml", + "manual_verification_results.yml", + "reqstool_config.yml", +} + + +def compute_diagnostics( + uri: str, + text: str, + language_id: str, + project: ProjectState | None, +) -> list[types.Diagnostic]: + basename = os.path.basename(uri) + if basename in REQSTOOL_YAML_FILES: + return _yaml_diagnostics(text, basename) + else: + return _source_diagnostics(text, language_id, project) + + +def _source_diagnostics( + text: str, + language_id: str, + project: ProjectState | None, +) -> list[types.Diagnostic]: + if project is None or not project.ready: + return [] + + annotations = find_all_annotations(text, language_id) + diagnostics: list[types.Diagnostic] = [] + + for match in annotations: + if match.kind == "Requirements": + req = project.get_requirement(match.raw_id) + if req is None: + diagnostics.append( + types.Diagnostic( + range=types.Range( + start=types.Position(line=match.line, character=match.start_col), + end=types.Position(line=match.line, character=match.end_col), + ), + severity=types.DiagnosticSeverity.Error, + source="reqstool", + message=f"Unknown requirement: {match.raw_id}", + ) + ) + else: + _check_lifecycle(diagnostics, match, req.lifecycle.state, req.lifecycle.reason, "Requirement") + + elif match.kind == "SVCs": + svc = project.get_svc(match.raw_id) + if svc is None: + diagnostics.append( + types.Diagnostic( + range=types.Range( + start=types.Position(line=match.line, character=match.start_col), + end=types.Position(line=match.line, character=match.end_col), + ), + severity=types.DiagnosticSeverity.Error, + source="reqstool", + message=f"Unknown SVC: {match.raw_id}", + ) + ) + else: + _check_lifecycle(diagnostics, match, svc.lifecycle.state, svc.lifecycle.reason, "SVC") + + return diagnostics + + +def _check_lifecycle(diagnostics, match, state, reason, kind_label): + if state == LIFECYCLESTATE.DEPRECATED: + reason_text = f": {reason}" if reason else "" + diagnostics.append( + types.Diagnostic( + range=types.Range( + start=types.Position(line=match.line, character=match.start_col), + end=types.Position(line=match.line, character=match.end_col), + ), + severity=types.DiagnosticSeverity.Warning, + source="reqstool", + message=f"{kind_label} {match.raw_id} is deprecated{reason_text}", + ) + ) + elif state == LIFECYCLESTATE.OBSOLETE: + reason_text = f": {reason}" if reason else "" + diagnostics.append( + types.Diagnostic( + range=types.Range( + start=types.Position(line=match.line, character=match.start_col), + end=types.Position(line=match.line, character=match.end_col), + ), + severity=types.DiagnosticSeverity.Warning, + source="reqstool", + message=f"{kind_label} {match.raw_id} is obsolete{reason_text}", + ) + ) + + +def _yaml_diagnostics(text: str, filename: str) -> list[types.Diagnostic]: + """Validate YAML content against its JSON schema.""" + schema = schema_for_yaml_file(filename) + if schema is None: + return [] + + # Parse YAML first + try: + data = yaml.safe_load(text) + except yaml.YAMLError as e: + diag_range = types.Range( + start=types.Position(line=0, character=0), + end=types.Position(line=0, character=0), + ) + if hasattr(e, "problem_mark") and e.problem_mark is not None: + line = e.problem_mark.line + col = e.problem_mark.column + diag_range = types.Range( + start=types.Position(line=line, character=col), + end=types.Position(line=line, character=col), + ) + return [ + types.Diagnostic( + range=diag_range, + severity=types.DiagnosticSeverity.Error, + source="reqstool", + message=f"YAML parse error: {e}", + ) + ] + + if data is None: + return [] + + # Validate against JSON schema + validator = Draft202012Validator(schema) + diagnostics: list[types.Diagnostic] = [] + + for error in validator.iter_errors(data): + line, col = _find_error_position(text, error) + diagnostics.append( + types.Diagnostic( + range=types.Range( + start=types.Position(line=line, character=col), + end=types.Position(line=line, character=col), + ), + severity=types.DiagnosticSeverity.Error, + source="reqstool", + message=_format_schema_error(error), + ) + ) + + return diagnostics + + +def _find_error_position(text: str, error) -> tuple[int, int]: + """Try to find the line/column for a JSON Schema validation error. + + Uses the error's JSON path to locate the offending field in the YAML text. + Falls back to line 0, col 0 if the position can't be determined. + """ + if not error.absolute_path: + return 0, 0 + + # Build a search pattern from the path + # e.g., path ["requirements", 0, "significance"] → look for "significance:" in text + parts = list(error.absolute_path) + if parts: + last = parts[-1] + if isinstance(last, str): + # Search for the field name in the YAML text + pattern = re.compile(r"^\s*(?:-\s+)?" + re.escape(last) + r"\s*:", re.MULTILINE) + # If there are array indices in the path, try to narrow down + matches = list(pattern.finditer(text)) + if len(matches) == 1: + line = text[:matches[0].start()].count("\n") + col = matches[0].start() - text[:matches[0].start()].rfind("\n") - 1 + return line, col + elif len(matches) > 1: + # Use the array index to pick the right match + array_idx = None + for p in parts: + if isinstance(p, int): + array_idx = p + if array_idx is not None and array_idx < len(matches): + m = matches[array_idx] + line = text[:m.start()].count("\n") + col = m.start() - text[:m.start()].rfind("\n") - 1 + return line, col + # Fall back to first match + m = matches[0] + line = text[:m.start()].count("\n") + col = m.start() - text[:m.start()].rfind("\n") - 1 + return line, col + + return 0, 0 + + +def _format_schema_error(error) -> str: + """Format a jsonschema ValidationError into a user-friendly message.""" + path = ".".join(str(p) for p in error.absolute_path) if error.absolute_path else "root" + return f"Schema error at '{path}': {error.message}" diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index 571876c6..9ded143b 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -7,6 +7,7 @@ from lsprotocol import types from pygls.lsp.server import LanguageServer +from reqstool.lsp.features.diagnostics import compute_diagnostics from reqstool.lsp.features.hover import handle_hover from reqstool.lsp.workspace_manager import WorkspaceManager @@ -165,15 +166,27 @@ def _discover_and_build(ls: ReqstoolLanguageServer) -> None: def _publish_diagnostics_for_document(ls: ReqstoolLanguageServer, uri: str) -> None: - """Publish diagnostics for a single document. Placeholder for Step 6.""" - # Will be implemented in features/diagnostics.py - pass + """Publish diagnostics for a single document.""" + try: + document = ls.workspace.get_text_document(uri) + except Exception: + return + project = ls.workspace_manager.project_for_file(uri) + diagnostics = compute_diagnostics( + uri=uri, + text=document.source, + language_id=document.language_id or "", + project=project, + ) + ls.text_document_publish_diagnostics( + types.PublishDiagnosticsParams(uri=uri, diagnostics=diagnostics) + ) def _publish_all_diagnostics(ls: ReqstoolLanguageServer) -> None: - """Re-publish diagnostics for all open documents. Placeholder for Step 6.""" - # Will be implemented in features/diagnostics.py - pass + """Re-publish diagnostics for all open documents.""" + for uri in list(ls.workspace.text_documents.keys()): + _publish_diagnostics_for_document(ls, uri) def start_server() -> None: diff --git a/tests/unit/reqstool/lsp/test_diagnostics.py b/tests/unit/reqstool/lsp/test_diagnostics.py new file mode 100644 index 00000000..263f31ba --- /dev/null +++ b/tests/unit/reqstool/lsp/test_diagnostics.py @@ -0,0 +1,200 @@ +# Copyright © LFV + +from lsprotocol import types + +from reqstool.lsp.features.diagnostics import compute_diagnostics + + +# -- Source code diagnostics -- + + +def test_diagnostics_unknown_requirement(local_testdata_resources_rootdir_w_path): + from reqstool.lsp.project_state import ProjectState + + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + text = '@Requirements("REQ_NONEXISTENT")\ndef foo(): pass' + diags = compute_diagnostics( + uri="file:///test.py", + text=text, + language_id="python", + project=state, + ) + assert len(diags) == 1 + assert diags[0].severity == types.DiagnosticSeverity.Error + assert "Unknown requirement" in diags[0].message + assert "REQ_NONEXISTENT" in diags[0].message + assert diags[0].source == "reqstool" + finally: + state.close() + + +def test_diagnostics_valid_requirement(local_testdata_resources_rootdir_w_path): + from reqstool.lsp.project_state import ProjectState + + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + text = '@Requirements("REQ_010")\ndef foo(): pass' + diags = compute_diagnostics( + uri="file:///test.py", + text=text, + language_id="python", + project=state, + ) + # REQ_010 exists and is effective — no diagnostics + assert len(diags) == 0 + finally: + state.close() + + +def test_diagnostics_unknown_svc(local_testdata_resources_rootdir_w_path): + from reqstool.lsp.project_state import ProjectState + + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + text = '@SVCs("SVC_NONEXISTENT")\ndef foo(): pass' + diags = compute_diagnostics( + uri="file:///test.py", + text=text, + language_id="python", + project=state, + ) + assert len(diags) == 1 + assert diags[0].severity == types.DiagnosticSeverity.Error + assert "Unknown SVC" in diags[0].message + finally: + state.close() + + +def test_diagnostics_no_project(): + text = '@Requirements("REQ_010")\ndef foo(): pass' + diags = compute_diagnostics( + uri="file:///test.py", + text=text, + language_id="python", + project=None, + ) + assert len(diags) == 0 + + +def test_diagnostics_no_annotations(): + text = "def foo(): pass" + diags = compute_diagnostics( + uri="file:///test.py", + text=text, + language_id="python", + project=None, + ) + assert len(diags) == 0 + + +def test_diagnostics_multiple_ids_mixed(local_testdata_resources_rootdir_w_path): + from reqstool.lsp.project_state import ProjectState + + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + # One valid, one invalid + text = '@Requirements("REQ_010", "REQ_NONEXISTENT")\ndef foo(): pass' + diags = compute_diagnostics( + uri="file:///test.py", + text=text, + language_id="python", + project=state, + ) + # Only the unknown one should produce a diagnostic + assert len(diags) == 1 + assert "REQ_NONEXISTENT" in diags[0].message + finally: + state.close() + + +# -- YAML diagnostics -- + + +def test_diagnostics_yaml_valid(): + """Valid YAML with correct schema should produce no diagnostics.""" + text = ( + "metadata:\n" + " urn: test:urn\n" + " variant: microservice\n" + " title: Test\n" + " url: https://example.com\n" + "requirements:\n" + " - id: REQ_001\n" + " title: Test requirement\n" + " significance: shall\n" + " description: A test requirement\n" + " categories:\n" + " - functional-suitability\n" + " revision: '1.0.0'\n" + " implementation: in-code\n" + ) + diags = compute_diagnostics( + uri="file:///workspace/requirements.yml", + text=text, + language_id="yaml", + project=None, + ) + # May or may not have schema errors depending on exact schema requirements, + # but at minimum should not crash + assert isinstance(diags, list) + + +def test_diagnostics_yaml_parse_error(): + """Invalid YAML should produce a parse error diagnostic.""" + text = "metadata:\n urn: [\n" + diags = compute_diagnostics( + uri="file:///workspace/requirements.yml", + text=text, + language_id="yaml", + project=None, + ) + assert len(diags) >= 1 + assert diags[0].severity == types.DiagnosticSeverity.Error + assert "YAML parse error" in diags[0].message + + +def test_diagnostics_yaml_schema_error(): + """YAML with missing required fields should produce schema error diagnostics.""" + text = "metadata:\n urn: test\n" + diags = compute_diagnostics( + uri="file:///workspace/requirements.yml", + text=text, + language_id="yaml", + project=None, + ) + assert len(diags) >= 1 + assert any("Schema error" in d.message for d in diags) + + +def test_diagnostics_yaml_non_reqstool_file(): + """Non-reqstool YAML files should produce no diagnostics.""" + text = "key: value\n" + diags = compute_diagnostics( + uri="file:///workspace/other.yml", + text=text, + language_id="yaml", + project=None, + ) + assert len(diags) == 0 + + +def test_diagnostics_yaml_empty(): + """Empty YAML should produce no diagnostics (or schema errors for missing required).""" + text = "" + diags = compute_diagnostics( + uri="file:///workspace/requirements.yml", + text=text, + language_id="yaml", + project=None, + ) + # Empty YAML parses to None, which we skip + assert isinstance(diags, list) From fc7a87ddf4f29dfa5032ecfc0eca59daf38285b8 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Mon, 16 Mar 2026 09:17:38 +0100 Subject: [PATCH 09/37] feat: add completion feature for LSP server (#314) - Source completion: offer requirement/SVC IDs inside @Requirements/@SVCs annotations - YAML completion: offer enum values for fields like significance, variant, categories - Wire completion handler into server with trigger characters Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/completion.py | 155 +++++++++++++++++++ src/reqstool/lsp/server.py | 17 +++ tests/unit/reqstool/lsp/test_completion.py | 166 +++++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 src/reqstool/lsp/features/completion.py create mode 100644 tests/unit/reqstool/lsp/test_completion.py diff --git a/src/reqstool/lsp/features/completion.py b/src/reqstool/lsp/features/completion.py new file mode 100644 index 00000000..42ba617e --- /dev/null +++ b/src/reqstool/lsp/features/completion.py @@ -0,0 +1,155 @@ +# Copyright © LFV + +from __future__ import annotations + +import os +import re + +from lsprotocol import types + +from reqstool.lsp.annotation_parser import is_inside_annotation +from reqstool.lsp.project_state import ProjectState +from reqstool.lsp.yaml_schema import get_enum_values, schema_for_yaml_file + +# YAML files that the LSP provides completion for +REQSTOOL_YAML_FILES = { + "requirements.yml", + "software_verification_cases.yml", + "manual_verification_results.yml", + "reqstool_config.yml", +} + + +def handle_completion( + uri: str, + position: types.Position, + text: str, + language_id: str, + project: ProjectState | None, +) -> types.CompletionList | None: + basename = os.path.basename(uri) + if basename in REQSTOOL_YAML_FILES: + return _complete_yaml(text, position, basename) + else: + return _complete_source(text, position, language_id, project) + + +def _complete_source( + text: str, + position: types.Position, + language_id: str, + project: ProjectState | None, +) -> types.CompletionList | None: + if project is None or not project.ready: + return None + + lines = text.splitlines() + if position.line >= len(lines): + return None + line_text = lines[position.line] + + kind = is_inside_annotation(line_text, position.character, language_id) + if kind is None: + return None + + items: list[types.CompletionItem] = [] + + if kind == "Requirements": + for req_id in project.get_all_requirement_ids(): + req = project.get_requirement(req_id) + detail = req.title if req else "" + doc = req.description if req else "" + items.append( + types.CompletionItem( + label=req_id, + kind=types.CompletionItemKind.Reference, + detail=detail, + documentation=doc, + ) + ) + elif kind == "SVCs": + for svc_id in project.get_all_svc_ids(): + svc = project.get_svc(svc_id) + detail = svc.title if svc else "" + doc = svc.description if svc else "" + items.append( + types.CompletionItem( + label=svc_id, + kind=types.CompletionItemKind.Reference, + detail=detail, + documentation=doc if doc else None, + ) + ) + + if not items: + return None + + return types.CompletionList(is_incomplete=False, items=items) + + +def _complete_yaml( + text: str, + position: types.Position, + filename: str, +) -> types.CompletionList | None: + schema = schema_for_yaml_file(filename) + if schema is None: + return None + + lines = text.splitlines() + if position.line >= len(lines): + return None + + field_path = _yaml_value_context(text, position.line) + if not field_path: + return None + + values = get_enum_values(schema, field_path) + if not values: + return None + + items = [ + types.CompletionItem( + label=v, + kind=types.CompletionItemKind.EnumMember, + ) + for v in values + ] + + return types.CompletionList(is_incomplete=False, items=items) + + +def _yaml_value_context(text: str, target_line: int) -> list[str] | None: + """Determine the field path for the value being typed at target_line. + + Returns the field path if the cursor is in the value position of a YAML field, + or None if not on a field line. + """ + lines = text.splitlines() + if target_line >= len(lines): + return None + + target = lines[target_line] + m = re.match(r"^(\s*)(?:-\s+)?(\w[\w-]*)\s*:", target) + if not m: + return None + + leading_spaces = len(m.group(1)) + field_name = m.group(2) + path = [field_name] + + # Walk backwards for parent fields (same logic as hover's _yaml_field_path_at_line) + current_indent = leading_spaces + for i in range(target_line - 1, -1, -1): + line = lines[i] + pm = re.match(r"^(\s*)(-\s+)?(\w[\w-]*)\s*:", line) + if pm: + indent = len(pm.group(1)) + has_dash = pm.group(2) is not None + if indent < current_indent and not has_dash: + path.insert(0, pm.group(3)) + current_indent = indent + if indent == 0: + break + + return path diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index 9ded143b..5a7f7cb5 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -7,6 +7,7 @@ from lsprotocol import types from pygls.lsp.server import LanguageServer +from reqstool.lsp.features.completion import handle_completion from reqstool.lsp.features.diagnostics import compute_diagnostics from reqstool.lsp.features.hover import handle_hover from reqstool.lsp.workspace_manager import WorkspaceManager @@ -130,6 +131,22 @@ def on_hover(ls: ReqstoolLanguageServer, params: types.HoverParams) -> types.Hov ) +@server.feature( + types.TEXT_DOCUMENT_COMPLETION, + types.CompletionOptions(trigger_characters=['"', " ", ":"]), +) +def on_completion(ls: ReqstoolLanguageServer, params: types.CompletionParams) -> types.CompletionList | None: + document = ls.workspace.get_text_document(params.text_document.uri) + project = ls.workspace_manager.project_for_file(params.text_document.uri) + return handle_completion( + uri=params.text_document.uri, + position=params.position, + text=document.source, + language_id=document.language_id or "", + project=project, + ) + + # -- Internal helpers -- diff --git a/tests/unit/reqstool/lsp/test_completion.py b/tests/unit/reqstool/lsp/test_completion.py new file mode 100644 index 00000000..3e7a5a1d --- /dev/null +++ b/tests/unit/reqstool/lsp/test_completion.py @@ -0,0 +1,166 @@ +# Copyright © LFV + +from lsprotocol import types + +from reqstool.lsp.features.completion import handle_completion, _yaml_value_context + + +# -- Source code completion -- + + +def test_completion_requirements(local_testdata_resources_rootdir_w_path): + from reqstool.lsp.project_state import ProjectState + + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + text = '@Requirements("' + result = handle_completion( + uri="file:///test.py", + position=types.Position(line=0, character=16), + text=text, + language_id="python", + project=state, + ) + assert result is not None + assert len(result.items) > 0 + labels = [item.label for item in result.items] + assert "REQ_010" in labels + finally: + state.close() + + +def test_completion_svcs(local_testdata_resources_rootdir_w_path): + from reqstool.lsp.project_state import ProjectState + + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + text = '@SVCs("' + result = handle_completion( + uri="file:///test.py", + position=types.Position(line=0, character=7), + text=text, + language_id="python", + project=state, + ) + assert result is not None + assert len(result.items) > 0 + # All items should be SVC IDs + for item in result.items: + assert item.kind == types.CompletionItemKind.Reference + finally: + state.close() + + +def test_completion_no_project(): + text = '@Requirements("' + result = handle_completion( + uri="file:///test.py", + position=types.Position(line=0, character=16), + text=text, + language_id="python", + project=None, + ) + assert result is None + + +def test_completion_outside_annotation(): + text = "def foo(): pass" + result = handle_completion( + uri="file:///test.py", + position=types.Position(line=0, character=5), + text=text, + language_id="python", + project=None, + ) + assert result is None + + +# -- YAML completion -- + + +def test_completion_yaml_significance(): + text = "requirements:\n - id: REQ_001\n significance: " + result = handle_completion( + uri="file:///workspace/requirements.yml", + position=types.Position(line=2, character=20), + text=text, + language_id="yaml", + project=None, + ) + assert result is not None + labels = [item.label for item in result.items] + assert "shall" in labels + assert "should" in labels + assert "may" in labels + + +def test_completion_yaml_variant(): + text = "metadata:\n variant: " + result = handle_completion( + uri="file:///workspace/requirements.yml", + position=types.Position(line=1, character=12), + text=text, + language_id="yaml", + project=None, + ) + assert result is not None + labels = [item.label for item in result.items] + assert "microservice" in labels + assert "system" in labels + assert "external" in labels + + +def test_completion_yaml_non_enum_field(): + text = "metadata:\n urn: " + result = handle_completion( + uri="file:///workspace/requirements.yml", + position=types.Position(line=1, character=7), + text=text, + language_id="yaml", + project=None, + ) + # urn is not an enum field, no completion + assert result is None + + +def test_completion_yaml_non_reqstool_file(): + text = "key: value\n" + result = handle_completion( + uri="file:///workspace/other.yml", + position=types.Position(line=0, character=5), + text=text, + language_id="yaml", + project=None, + ) + assert result is None + + +# -- YAML value context -- + + +def test_yaml_value_context_simple(): + text = "metadata:\n variant: " + path = _yaml_value_context(text, 1) + assert path == ["metadata", "variant"] + + +def test_yaml_value_context_array_item(): + text = "requirements:\n - id: REQ_001\n significance: " + path = _yaml_value_context(text, 2) + assert path == ["requirements", "significance"] + + +def test_yaml_value_context_top_level(): + text = "metadata:\n urn: value" + path = _yaml_value_context(text, 0) + assert path == ["metadata"] + + +def test_yaml_value_context_no_field(): + text = " - some list item" + path = _yaml_value_context(text, 0) + assert path is None From a8e5d13032f54e346a831056d4492a09f0c542fa Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Mon, 16 Mar 2026 09:19:06 +0100 Subject: [PATCH 10/37] feat: add go-to-definition feature for LSP server (#314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Source → YAML: navigate from @Requirements/@SVCs annotations to YAML definitions - YAML → YAML: navigate from requirement IDs to SVC references and vice versa - Wire definition handler into LSP server Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/definition.py | 157 +++++++++++++++++++++ src/reqstool/lsp/server.py | 14 ++ tests/unit/reqstool/lsp/test_definition.py | 123 ++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 src/reqstool/lsp/features/definition.py create mode 100644 tests/unit/reqstool/lsp/test_definition.py diff --git a/src/reqstool/lsp/features/definition.py b/src/reqstool/lsp/features/definition.py new file mode 100644 index 00000000..fc46dd23 --- /dev/null +++ b/src/reqstool/lsp/features/definition.py @@ -0,0 +1,157 @@ +# Copyright © LFV + +from __future__ import annotations + +import logging +import os +import re + +from lsprotocol import types + +from reqstool.lsp.annotation_parser import annotation_at_position +from reqstool.lsp.project_state import ProjectState + +logger = logging.getLogger(__name__) + +# YAML files where IDs can be defined +YAML_ID_FILES = { + "requirements.yml": "requirements", + "software_verification_cases.yml": "svcs", + "manual_verification_results.yml": "mvrs", +} + + +def handle_definition( + uri: str, + position: types.Position, + text: str, + language_id: str, + project: ProjectState | None, +) -> list[types.Location]: + basename = os.path.basename(uri) + if basename in YAML_ID_FILES: + return _definition_from_yaml(text, position, basename, project) + else: + return _definition_from_source(text, position, language_id, project) + + +def _definition_from_source( + text: str, + position: types.Position, + language_id: str, + project: ProjectState | None, +) -> list[types.Location]: + """Go-to-definition from @Requirements/@SVCs annotation → YAML file.""" + if project is None or not project.ready: + return [] + + match = annotation_at_position(text, position.line, position.character, language_id) + if match is None: + return [] + + reqstool_path = project.reqstool_path + if not reqstool_path: + return [] + + if match.kind == "Requirements": + yaml_file = os.path.join(reqstool_path, "requirements.yml") + elif match.kind == "SVCs": + yaml_file = os.path.join(reqstool_path, "software_verification_cases.yml") + else: + return [] + + return _find_id_in_yaml(yaml_file, match.raw_id) + + +def _definition_from_yaml( + text: str, + position: types.Position, + filename: str, + project: ProjectState | None, +) -> list[types.Location]: + """Go-to-definition from YAML ID → source file annotations.""" + raw_id = _id_at_yaml_position(text, position) + if raw_id is None: + return [] + + if project is None or not project.ready: + return [] + + reqstool_path = project.reqstool_path + if not reqstool_path: + return [] + + # Determine what kind of ID this is based on the YAML file + file_kind = YAML_ID_FILES.get(filename) + + if file_kind == "requirements": + # From requirement ID → find annotations in source or SVC references + svc_file = os.path.join(reqstool_path, "software_verification_cases.yml") + return _find_id_in_yaml(svc_file, raw_id) + elif file_kind == "svcs": + # From SVC ID → find MVR references + mvr_file = os.path.join(reqstool_path, "manual_verification_results.yml") + return _find_id_in_yaml(mvr_file, raw_id) + + return [] + + +def _find_id_in_yaml(yaml_file: str, raw_id: str) -> list[types.Location]: + """Search a YAML file for a line containing `id: ` and return its location.""" + if not os.path.isfile(yaml_file): + return [] + + try: + with open(yaml_file) as f: + lines = f.readlines() + except OSError: + return [] + + pattern = re.compile(r"^\s*(?:-\s+)?id\s*:\s*" + re.escape(raw_id) + r"\s*$") + uri = _path_to_uri(yaml_file) + + locations: list[types.Location] = [] + for i, line in enumerate(lines): + if pattern.match(line): + locations.append( + types.Location( + uri=uri, + range=types.Range( + start=types.Position(line=i, character=0), + end=types.Position(line=i, character=len(line.rstrip())), + ), + ) + ) + + return locations + + +def _id_at_yaml_position(text: str, position: types.Position) -> str | None: + """Extract the requirement/SVC ID at the cursor position in a YAML file. + + Looks for patterns like `id: REQ_001` or `- id: REQ_001` on the current line, + and also for ID references in other fields (e.g. requirement_ids entries). + """ + lines = text.splitlines() + if position.line >= len(lines): + return None + + line = lines[position.line] + + # Match `id: VALUE` or `- id: VALUE` + m = re.match(r"^\s*(?:-\s+)?id\s*:\s*(\S+)", line) + if m: + return m.group(1) + + # Match bare ID in a list (e.g., ` - REQ_001`) + m = re.match(r"^\s*-\s+(\w[\w:-]*)\s*$", line) + if m: + return m.group(1) + + return None + + +def _path_to_uri(path: str) -> str: + """Convert a file path to a file URI.""" + abs_path = os.path.abspath(path) + return "file://" + abs_path diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index 5a7f7cb5..cd3720a4 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -8,6 +8,7 @@ from pygls.lsp.server import LanguageServer from reqstool.lsp.features.completion import handle_completion +from reqstool.lsp.features.definition import handle_definition from reqstool.lsp.features.diagnostics import compute_diagnostics from reqstool.lsp.features.hover import handle_hover from reqstool.lsp.workspace_manager import WorkspaceManager @@ -147,6 +148,19 @@ def on_completion(ls: ReqstoolLanguageServer, params: types.CompletionParams) -> ) +@server.feature(types.TEXT_DOCUMENT_DEFINITION) +def on_definition(ls: ReqstoolLanguageServer, params: types.DefinitionParams) -> list[types.Location]: + document = ls.workspace.get_text_document(params.text_document.uri) + project = ls.workspace_manager.project_for_file(params.text_document.uri) + return handle_definition( + uri=params.text_document.uri, + position=params.position, + text=document.source, + language_id=document.language_id or "", + project=project, + ) + + # -- Internal helpers -- diff --git a/tests/unit/reqstool/lsp/test_definition.py b/tests/unit/reqstool/lsp/test_definition.py new file mode 100644 index 00000000..ed3b2e28 --- /dev/null +++ b/tests/unit/reqstool/lsp/test_definition.py @@ -0,0 +1,123 @@ +# Copyright © LFV + +import os + +from lsprotocol import types + +from reqstool.lsp.features.definition import ( + handle_definition, + _find_id_in_yaml, + _id_at_yaml_position, + _path_to_uri, +) + + +# -- Source → YAML definition -- + + +def test_definition_from_source(local_testdata_resources_rootdir_w_path): + from reqstool.lsp.project_state import ProjectState + + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + text = '@Requirements("REQ_010")\ndef foo(): pass' + result = handle_definition( + uri="file:///test.py", + position=types.Position(line=0, character=17), + text=text, + language_id="python", + project=state, + ) + # Should find the ID in requirements.yml + assert isinstance(result, list) + if len(result) > 0: + assert "requirements.yml" in result[0].uri + finally: + state.close() + + +def test_definition_from_source_no_project(): + text = '@Requirements("REQ_010")\ndef foo(): pass' + result = handle_definition( + uri="file:///test.py", + position=types.Position(line=0, character=17), + text=text, + language_id="python", + project=None, + ) + assert result == [] + + +def test_definition_from_source_no_annotation(): + text = "def foo(): pass" + result = handle_definition( + uri="file:///test.py", + position=types.Position(line=0, character=5), + text=text, + language_id="python", + project=None, + ) + assert result == [] + + +# -- Find ID in YAML -- + + +def test_find_id_in_yaml(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + yaml_file = os.path.join(path, "requirements.yml") + if os.path.isfile(yaml_file): + result = _find_id_in_yaml(yaml_file, "REQ_010") + assert isinstance(result, list) + if len(result) > 0: + assert result[0].range.start.line >= 0 + + +def test_find_id_in_yaml_nonexistent_file(): + result = _find_id_in_yaml("/nonexistent/path/requirements.yml", "REQ_010") + assert result == [] + + +def test_find_id_in_yaml_nonexistent_id(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + yaml_file = os.path.join(path, "requirements.yml") + if os.path.isfile(yaml_file): + result = _find_id_in_yaml(yaml_file, "REQ_NONEXISTENT") + assert result == [] + + +# -- ID at YAML position -- + + +def test_id_at_yaml_position_id_field(): + text = "requirements:\n - id: REQ_001\n title: Test" + raw_id = _id_at_yaml_position(text, types.Position(line=1, character=10)) + assert raw_id == "REQ_001" + + +def test_id_at_yaml_position_bare_list_item(): + text = "requirement_ids:\n - REQ_001\n - REQ_002" + raw_id = _id_at_yaml_position(text, types.Position(line=1, character=5)) + assert raw_id == "REQ_001" + + +def test_id_at_yaml_position_no_id(): + text = "metadata:\n title: Test" + raw_id = _id_at_yaml_position(text, types.Position(line=1, character=5)) + assert raw_id is None + + +def test_id_at_yaml_position_out_of_range(): + text = "metadata:\n urn: test" + raw_id = _id_at_yaml_position(text, types.Position(line=5, character=0)) + assert raw_id is None + + +# -- Path to URI -- + + +def test_path_to_uri(): + uri = _path_to_uri("/home/user/project/requirements.yml") + assert uri == "file:///home/user/project/requirements.yml" From 64254af822e9f03903bf7254be43ed7de39fee50 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Mon, 16 Mar 2026 09:21:18 +0100 Subject: [PATCH 11/37] feat: add document symbols feature for LSP server (#314) - Outline view for requirements.yml, software_verification_cases.yml, manual_verification_results.yml - Shows requirement/SVC/MVR items with titles, significance, and cross-references as children - Wire document symbol handler into LSP server Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/document_symbols.py | 248 ++++++++++++++++++ src/reqstool/lsp/server.py | 12 + .../reqstool/lsp/test_document_symbols.py | 189 +++++++++++++ 3 files changed, 449 insertions(+) create mode 100644 src/reqstool/lsp/features/document_symbols.py create mode 100644 tests/unit/reqstool/lsp/test_document_symbols.py diff --git a/src/reqstool/lsp/features/document_symbols.py b/src/reqstool/lsp/features/document_symbols.py new file mode 100644 index 00000000..1118da1a --- /dev/null +++ b/src/reqstool/lsp/features/document_symbols.py @@ -0,0 +1,248 @@ +# Copyright © LFV + +from __future__ import annotations + +import os +import re + +from lsprotocol import types + +from reqstool.lsp.project_state import ProjectState + +# YAML files that the LSP provides document symbols for +REQSTOOL_YAML_FILES = { + "requirements.yml", + "software_verification_cases.yml", + "manual_verification_results.yml", +} + + +def handle_document_symbols( + uri: str, + text: str, + project: ProjectState | None, +) -> list[types.DocumentSymbol]: + basename = os.path.basename(uri) + if basename not in REQSTOOL_YAML_FILES: + return [] + + items = _parse_yaml_items(text) + if not items: + return [] + + if basename == "requirements.yml": + return _symbols_for_requirements(items, text, project) + elif basename == "software_verification_cases.yml": + return _symbols_for_svcs(items, text, project) + elif basename == "manual_verification_results.yml": + return _symbols_for_mvrs(items, text, project) + + return [] + + +def _symbols_for_requirements( + items: list[_YamlItem], + text: str, + project: ProjectState | None, +) -> list[types.DocumentSymbol]: + symbols: list[types.DocumentSymbol] = [] + for item in items: + req_id = item.fields.get("id", "") + title = item.fields.get("title", "") + significance = item.fields.get("significance", "") + + name = f"{req_id} — {title}" if title else req_id + detail = significance + + children: list[types.DocumentSymbol] = [] + if project is not None and project.ready and req_id: + svcs = project.get_svcs_for_req(req_id) + for svc in svcs: + children.append( + types.DocumentSymbol( + name=f"→ {svc.id.id} — {svc.title}", + kind=types.SymbolKind.Key, + range=item.range, + selection_range=item.range, + detail=svc.verification.value if hasattr(svc, "verification") else "", + ) + ) + + symbols.append( + types.DocumentSymbol( + name=name, + kind=types.SymbolKind.Key, + range=item.range, + selection_range=item.selection_range, + detail=detail, + children=children if children else None, + ) + ) + + return symbols + + +def _symbols_for_svcs( + items: list[_YamlItem], + text: str, + project: ProjectState | None, +) -> list[types.DocumentSymbol]: + symbols: list[types.DocumentSymbol] = [] + for item in items: + svc_id = item.fields.get("id", "") + title = item.fields.get("title", "") + verification = item.fields.get("verification", "") + + name = f"{svc_id} — {title}" if title else svc_id + detail = verification + + children: list[types.DocumentSymbol] = [] + if project is not None and project.ready and svc_id: + svc = project.get_svc(svc_id) + if svc and svc.requirement_ids: + for req_ref in svc.requirement_ids: + req_id_str = req_ref.id if hasattr(req_ref, "id") else str(req_ref) + children.append( + types.DocumentSymbol( + name=f"← {req_id_str}", + kind=types.SymbolKind.Key, + range=item.range, + selection_range=item.range, + ) + ) + + mvrs = project.get_mvrs_for_svc(svc_id) + for mvr in mvrs: + result = "pass" if mvr.passed else "fail" + children.append( + types.DocumentSymbol( + name=f"→ MVR: {result}", + kind=types.SymbolKind.Key, + range=item.range, + selection_range=item.range, + ) + ) + + symbols.append( + types.DocumentSymbol( + name=name, + kind=types.SymbolKind.Key, + range=item.range, + selection_range=item.selection_range, + detail=detail, + children=children if children else None, + ) + ) + + return symbols + + +def _symbols_for_mvrs( + items: list[_YamlItem], + text: str, + project: ProjectState | None, +) -> list[types.DocumentSymbol]: + symbols: list[types.DocumentSymbol] = [] + for item in items: + svc_id = item.fields.get("id", "") + passed = item.fields.get("passed", "") + + result = "pass" if passed.lower() == "true" else "fail" if passed else "" + name = f"{svc_id} — {result}" if result else svc_id + + symbols.append( + types.DocumentSymbol( + name=name, + kind=types.SymbolKind.Key, + range=item.range, + selection_range=item.selection_range, + detail="", + ) + ) + + return symbols + + +class _YamlItem: + """A parsed YAML list item with its fields and line range.""" + + __slots__ = ("fields", "start_line", "end_line", "id_line") + + def __init__(self, start_line: int): + self.fields: dict[str, str] = {} + self.start_line = start_line + self.end_line = start_line + self.id_line = start_line + + @property + def range(self) -> types.Range: + return types.Range( + start=types.Position(line=self.start_line, character=0), + end=types.Position(line=self.end_line, character=0), + ) + + @property + def selection_range(self) -> types.Range: + return types.Range( + start=types.Position(line=self.id_line, character=0), + end=types.Position(line=self.id_line, character=0), + ) + + +def _parse_yaml_items(text: str) -> list[_YamlItem]: + """Parse YAML text to extract list items under the main collection key. + + Looks for the first top-level array (e.g., requirements:, svcs:, results:) + and extracts each `- key: value` block. + """ + lines = text.splitlines() + items: list[_YamlItem] = [] + current_item: _YamlItem | None = None + list_indent = -1 + + for i, line in enumerate(lines): + stripped = line.rstrip() + if not stripped or stripped.startswith("#"): + continue + + current_item, list_indent = _process_yaml_line( + line, i, items, current_item, list_indent + ) + + if current_item is not None: + current_item.end_line = len(lines) - 1 + items.append(current_item) + + return items + + +def _process_yaml_line(line, i, items, current_item, list_indent): + """Process a single YAML line, returning updated (current_item, list_indent).""" + dash_match = re.match(r"^(\s*)-\s+(\w[\w-]*)\s*:\s*(.*)", line) + if dash_match: + indent = len(dash_match.group(1)) + if list_indent < 0: + list_indent = indent + if indent == list_indent: + if current_item is not None: + current_item.end_line = i - 1 + items.append(current_item) + current_item = _YamlItem(start_line=i) + current_item.fields[dash_match.group(2)] = dash_match.group(3).strip() + if dash_match.group(2) == "id": + current_item.id_line = i + return current_item, list_indent + + if current_item is not None and list_indent >= 0: + field_match = re.match(r"^(\s+)(\w[\w-]*)\s*:\s*(.*)", line) + if field_match and len(field_match.group(1)) > list_indent: + current_item.fields[field_match.group(2)] = field_match.group(3).strip() + if field_match.group(2) == "id": + current_item.id_line = i + current_item.end_line = i + elif field_match: + current_item.end_line = i - 1 + items.append(current_item) + return None, -1 + + return current_item, list_indent diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index cd3720a4..56118331 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -10,6 +10,7 @@ from reqstool.lsp.features.completion import handle_completion from reqstool.lsp.features.definition import handle_definition from reqstool.lsp.features.diagnostics import compute_diagnostics +from reqstool.lsp.features.document_symbols import handle_document_symbols from reqstool.lsp.features.hover import handle_hover from reqstool.lsp.workspace_manager import WorkspaceManager @@ -161,6 +162,17 @@ def on_definition(ls: ReqstoolLanguageServer, params: types.DefinitionParams) -> ) +@server.feature(types.TEXT_DOCUMENT_DOCUMENT_SYMBOL) +def on_document_symbol(ls: ReqstoolLanguageServer, params: types.DocumentSymbolParams) -> list[types.DocumentSymbol]: + document = ls.workspace.get_text_document(params.text_document.uri) + project = ls.workspace_manager.project_for_file(params.text_document.uri) + return handle_document_symbols( + uri=params.text_document.uri, + text=document.source, + project=project, + ) + + # -- Internal helpers -- diff --git a/tests/unit/reqstool/lsp/test_document_symbols.py b/tests/unit/reqstool/lsp/test_document_symbols.py new file mode 100644 index 00000000..2f1a19b3 --- /dev/null +++ b/tests/unit/reqstool/lsp/test_document_symbols.py @@ -0,0 +1,189 @@ +# Copyright © LFV + +from lsprotocol import types + +from reqstool.lsp.features.document_symbols import ( + handle_document_symbols, + _parse_yaml_items, +) + + +# -- YAML item parsing -- + + +def test_parse_yaml_items_requirements(): + text = ( + "requirements:\n" + " - id: REQ_001\n" + " title: First requirement\n" + " significance: shall\n" + " - id: REQ_002\n" + " title: Second requirement\n" + " significance: should\n" + ) + items = _parse_yaml_items(text) + assert len(items) == 2 + assert items[0].fields["id"] == "REQ_001" + assert items[0].fields["title"] == "First requirement" + assert items[0].fields["significance"] == "shall" + assert items[1].fields["id"] == "REQ_002" + + +def test_parse_yaml_items_svcs(): + text = ( + "svcs:\n" + " - id: SVC_001\n" + " title: Test case\n" + " verification: automated-test\n" + ) + items = _parse_yaml_items(text) + assert len(items) == 1 + assert items[0].fields["id"] == "SVC_001" + assert items[0].fields["verification"] == "automated-test" + + +def test_parse_yaml_items_empty(): + text = "metadata:\n urn: test\n" + items = _parse_yaml_items(text) + assert len(items) == 0 + + +def test_parse_yaml_items_with_metadata(): + text = ( + "metadata:\n" + " urn: test\n" + " variant: microservice\n" + "requirements:\n" + " - id: REQ_001\n" + " title: Test\n" + ) + items = _parse_yaml_items(text) + assert len(items) == 1 + assert items[0].fields["id"] == "REQ_001" + + +# -- Document symbols -- + + +def test_document_symbols_requirements(): + text = ( + "requirements:\n" + " - id: REQ_001\n" + " title: First requirement\n" + " significance: shall\n" + " - id: REQ_002\n" + " title: Second requirement\n" + " significance: should\n" + ) + symbols = handle_document_symbols( + uri="file:///workspace/requirements.yml", + text=text, + project=None, + ) + assert len(symbols) == 2 + assert "REQ_001" in symbols[0].name + assert "First requirement" in symbols[0].name + assert symbols[0].detail == "shall" + assert "REQ_002" in symbols[1].name + assert symbols[1].detail == "should" + + +def test_document_symbols_svcs(): + text = ( + "svcs:\n" + " - id: SVC_001\n" + " title: Login test\n" + " verification: automated-test\n" + ) + symbols = handle_document_symbols( + uri="file:///workspace/software_verification_cases.yml", + text=text, + project=None, + ) + assert len(symbols) == 1 + assert "SVC_001" in symbols[0].name + assert symbols[0].detail == "automated-test" + + +def test_document_symbols_mvrs(): + text = ( + "results:\n" + " - id: SVC_001\n" + " passed: true\n" + " - id: SVC_002\n" + " passed: false\n" + ) + symbols = handle_document_symbols( + uri="file:///workspace/manual_verification_results.yml", + text=text, + project=None, + ) + assert len(symbols) == 2 + assert "SVC_001" in symbols[0].name + assert "pass" in symbols[0].name + assert "SVC_002" in symbols[1].name + assert "fail" in symbols[1].name + + +def test_document_symbols_non_reqstool_file(): + text = "key: value\n" + symbols = handle_document_symbols( + uri="file:///workspace/other.yml", + text=text, + project=None, + ) + assert symbols == [] + + +def test_document_symbols_empty(): + text = "" + symbols = handle_document_symbols( + uri="file:///workspace/requirements.yml", + text=text, + project=None, + ) + assert symbols == [] + + +def test_document_symbols_with_project(local_testdata_resources_rootdir_w_path): + """Test that symbols include children when project is loaded.""" + import os + + from reqstool.lsp.project_state import ProjectState + + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + req_file = os.path.join(path, "requirements.yml") + if os.path.isfile(req_file): + with open(req_file) as f: + text = f.read() + symbols = handle_document_symbols( + uri="file://" + req_file, + text=text, + project=state, + ) + assert len(symbols) > 0 + # Symbols should be DocumentSymbol instances + for sym in symbols: + assert isinstance(sym, types.DocumentSymbol) + assert sym.kind == types.SymbolKind.Key + finally: + state.close() + + +def test_parse_yaml_items_line_ranges(): + text = ( + "requirements:\n" + " - id: REQ_001\n" + " title: Test\n" + " significance: shall\n" + " - id: REQ_002\n" + " title: Second\n" + ) + items = _parse_yaml_items(text) + assert len(items) == 2 + assert items[0].start_line == 1 + assert items[0].id_line == 1 + assert items[1].start_line == 4 From a576a973a9cfd656e9591509a79dd95b4a72ccea Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Mon, 16 Mar 2026 20:12:57 +0100 Subject: [PATCH 12/37] fix: use word-boundary search for YAML-to-YAML go-to-definition (#314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _find_id_in_yaml only matched `id: ` lines, so cross-reference navigation (REQ→SVC, SVC→MVR) returned empty results because IDs appear inside array fields like `requirement_ids: ["REQ_PASS"]`. Add _find_reference_in_yaml with a \b word-boundary pattern and strengthen the integration test to assert actual navigation results. Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/definition.py | 38 +- .../reqstool/lsp/test_lsp_integration.py | 362 ++++++++++++++++++ 2 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 tests/integration/reqstool/lsp/test_lsp_integration.py diff --git a/src/reqstool/lsp/features/definition.py b/src/reqstool/lsp/features/definition.py index fc46dd23..4823993c 100644 --- a/src/reqstool/lsp/features/definition.py +++ b/src/reqstool/lsp/features/definition.py @@ -85,13 +85,13 @@ def _definition_from_yaml( file_kind = YAML_ID_FILES.get(filename) if file_kind == "requirements": - # From requirement ID → find annotations in source or SVC references + # From requirement ID → find references in SVC file (e.g. requirement_ids: ["REQ_PASS"]) svc_file = os.path.join(reqstool_path, "software_verification_cases.yml") - return _find_id_in_yaml(svc_file, raw_id) + return _find_reference_in_yaml(svc_file, raw_id) elif file_kind == "svcs": - # From SVC ID → find MVR references + # From SVC ID → find references in MVR file (e.g. svc_ids: ["SVC_021"]) mvr_file = os.path.join(reqstool_path, "manual_verification_results.yml") - return _find_id_in_yaml(mvr_file, raw_id) + return _find_reference_in_yaml(mvr_file, raw_id) return [] @@ -126,6 +126,36 @@ def _find_id_in_yaml(yaml_file: str, raw_id: str) -> list[types.Location]: return locations +def _find_reference_in_yaml(yaml_file: str, raw_id: str) -> list[types.Location]: + """Search a YAML file for any line containing the given ID (word-boundary match).""" + if not os.path.isfile(yaml_file): + return [] + + try: + with open(yaml_file) as f: + lines = f.readlines() + except OSError: + return [] + + pattern = re.compile(r"\b" + re.escape(raw_id) + r"\b") + uri = _path_to_uri(yaml_file) + + locations: list[types.Location] = [] + for i, line in enumerate(lines): + if pattern.search(line): + locations.append( + types.Location( + uri=uri, + range=types.Range( + start=types.Position(line=i, character=0), + end=types.Position(line=i, character=len(line.rstrip())), + ), + ) + ) + + return locations + + def _id_at_yaml_position(text: str, position: types.Position) -> str | None: """Extract the requirement/SVC ID at the cursor position in a YAML file. diff --git a/tests/integration/reqstool/lsp/test_lsp_integration.py b/tests/integration/reqstool/lsp/test_lsp_integration.py new file mode 100644 index 00000000..c13234cf --- /dev/null +++ b/tests/integration/reqstool/lsp/test_lsp_integration.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from lsprotocol import types + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio(loop_scope="module")] + + +def _find_position_in_file(file_path: str, search_text: str) -> types.Position: + """Find the line and character position of search_text in a file.""" + with open(file_path) as f: + for line_no, line in enumerate(f): + col = line.find(search_text) + if col != -1: + return types.Position(line=line_no, character=col) + raise ValueError(f"{search_text!r} not found in {file_path}") + + +def _open_document(client, file_path: str, language_id: str) -> str: + """Send didOpen for a file and return its URI.""" + uri = Path(file_path).as_uri() + with open(file_path) as f: + text = f.read() + client.text_document_did_open( + types.DidOpenTextDocumentParams( + text_document=types.TextDocumentItem( + uri=uri, + language_id=language_id, + version=1, + text=text, + ) + ) + ) + return uri + + +# --------------------------------------------------------------------------- +# 1. Initialize capabilities +# --------------------------------------------------------------------------- + + +async def test_initialize_capabilities(lsp_client): + """Server responds with hover, completion, definition, documentSymbol providers.""" + client, result = lsp_client + caps = result.capabilities + + assert caps.hover_provider is not None + assert caps.completion_provider is not None + assert caps.definition_provider is not None + assert caps.document_symbol_provider is not None + + +# --------------------------------------------------------------------------- +# 2 & 3. Source diagnostics +# --------------------------------------------------------------------------- + + +async def test_source_diagnostics_valid_ids(lsp_client, fixture_dir): + """didOpen requirements_example.py -> no error diagnostics for known IDs.""" + client, _ = lsp_client + src_path = os.path.join(fixture_dir, "src", "requirements_example.py") + + client.clear_diagnostics() + uri = _open_document(client, src_path, "python") + diagnostics = await client.wait_for_diagnostics(uri) + + errors = [d for d in diagnostics if d.severity == types.DiagnosticSeverity.Error] + assert len(errors) == 0, f"Unexpected error diagnostics: {[e.message for e in errors]}" + + +async def test_source_diagnostics_deprecated(lsp_client, fixture_dir): + """didOpen requirements_example.py -> warnings for deprecated/obsolete IDs.""" + client, _ = lsp_client + src_path = os.path.join(fixture_dir, "src", "requirements_example.py") + + client.clear_diagnostics() + uri = _open_document(client, src_path, "python") + diagnostics = await client.wait_for_diagnostics(uri) + + warnings = [d for d in diagnostics if d.severity == types.DiagnosticSeverity.Warning] + warning_messages = [w.message for w in warnings] + + assert any( + "REQ_SKIPPED_TEST" in m and "deprecated" in m for m in warning_messages + ), f"Expected deprecation warning for REQ_SKIPPED_TEST, got: {warning_messages}" + assert any( + "REQ_OBSOLETE" in m and "obsolete" in m for m in warning_messages + ), f"Expected obsolete warning for REQ_OBSOLETE, got: {warning_messages}" + + +# --------------------------------------------------------------------------- +# 4 & 5. Hover +# --------------------------------------------------------------------------- + + +async def test_hover_requirement(lsp_client, fixture_dir): + """Hover at REQ_PASS -> markdown with 'Greeting message', 'shall'.""" + client, _ = lsp_client + src_path = os.path.join(fixture_dir, "src", "requirements_example.py") + uri = _open_document(client, src_path, "python") + + pos = _find_position_in_file(src_path, "REQ_PASS") + pos.character += 1 # inside the quoted ID string + + result = await client.text_document_hover_async( + types.HoverParams( + text_document=types.TextDocumentIdentifier(uri=uri), + position=pos, + ) + ) + + assert result is not None, "Expected hover result for REQ_PASS" + assert isinstance(result.contents, types.MarkupContent) + assert "Greeting message" in result.contents.value + assert "shall" in result.contents.value + + +async def test_hover_svc(lsp_client, fixture_dir): + """Hover at SVC_010 in test_svcs.py -> markdown with 'automated-test'.""" + client, _ = lsp_client + src_path = os.path.join(fixture_dir, "src", "test_svcs.py") + uri = _open_document(client, src_path, "python") + + pos = _find_position_in_file(src_path, "SVC_010") + pos.character += 1 + + result = await client.text_document_hover_async( + types.HoverParams( + text_document=types.TextDocumentIdentifier(uri=uri), + position=pos, + ) + ) + + assert result is not None, "Expected hover result for SVC_010" + assert isinstance(result.contents, types.MarkupContent) + assert "automated-test" in result.contents.value + + +# --------------------------------------------------------------------------- +# 6 & 7. Completion +# --------------------------------------------------------------------------- + + +async def test_completion_requirements(lsp_client, fixture_dir): + """Inside @Requirements(" -> items include all 7 REQ IDs.""" + client, _ = lsp_client + src_path = os.path.join(fixture_dir, "src", "requirements_example.py") + uri = _open_document(client, src_path, "python") + + pos = _find_position_in_file(src_path, "REQ_PASS") + pos.character += 1 + + result = await client.text_document_completion_async( + types.CompletionParams( + text_document=types.TextDocumentIdentifier(uri=uri), + position=pos, + ) + ) + + assert result is not None, "Expected completion result" + labels = {item.label for item in result.items} + expected_ids = { + "REQ_PASS", + "REQ_MANUAL_FAIL", + "REQ_NOT_IMPLEMENTED", + "REQ_FAILING_TEST", + "REQ_SKIPPED_TEST", + "REQ_MISSING_TEST", + "REQ_OBSOLETE", + } + assert expected_ids.issubset(labels), f"Missing REQ IDs in completion. Got: {labels}" + + +async def test_completion_svcs(lsp_client, fixture_dir): + """Inside @SVCs(" -> items include SVC_010 through SVC_070.""" + client, _ = lsp_client + src_path = os.path.join(fixture_dir, "src", "test_svcs.py") + uri = _open_document(client, src_path, "python") + + pos = _find_position_in_file(src_path, "SVC_010") + pos.character += 1 + + result = await client.text_document_completion_async( + types.CompletionParams( + text_document=types.TextDocumentIdentifier(uri=uri), + position=pos, + ) + ) + + assert result is not None, "Expected completion result" + labels = {item.label for item in result.items} + expected_ids = {"SVC_010", "SVC_020", "SVC_021", "SVC_022", "SVC_030", "SVC_040", "SVC_050", "SVC_060", "SVC_070"} + assert expected_ids.issubset(labels), f"Missing SVC IDs in completion. Got: {labels}" + + +# --------------------------------------------------------------------------- +# 8. Go-to-definition: source -> YAML +# --------------------------------------------------------------------------- + + +async def test_goto_definition_source_to_yaml(lsp_client, fixture_dir): + """Definition at REQ_PASS in .py -> location in requirements.yml.""" + client, _ = lsp_client + src_path = os.path.join(fixture_dir, "src", "requirements_example.py") + uri = _open_document(client, src_path, "python") + + pos = _find_position_in_file(src_path, "REQ_PASS") + pos.character += 1 + + result = await client.text_document_definition_async( + types.DefinitionParams( + text_document=types.TextDocumentIdentifier(uri=uri), + position=pos, + ) + ) + + assert result is not None and len(result) > 0, "Expected definition location" + req_yml_path = os.path.join(fixture_dir, "requirements.yml") + target_uris = [loc.uri for loc in result] + assert any(req_yml_path in u for u in target_uris), f"Expected definition in requirements.yml, got: {target_uris}" + + +# --------------------------------------------------------------------------- +# 9 & 10. Document symbols +# --------------------------------------------------------------------------- + + +async def test_document_symbols_requirements(lsp_client, fixture_dir): + """7 symbols for requirements.yml.""" + client, _ = lsp_client + req_path = os.path.join(fixture_dir, "requirements.yml") + uri = _open_document(client, req_path, "yaml") + + result = await client.text_document_document_symbol_async( + types.DocumentSymbolParams( + text_document=types.TextDocumentIdentifier(uri=uri), + ) + ) + + assert result is not None + assert len(result) == 7, f"Expected 7 requirement symbols, got {len(result)}: {[s.name for s in result]}" + + +async def test_document_symbols_svcs(lsp_client, fixture_dir): + """9 symbols for software_verification_cases.yml.""" + client, _ = lsp_client + svc_path = os.path.join(fixture_dir, "software_verification_cases.yml") + uri = _open_document(client, svc_path, "yaml") + + result = await client.text_document_document_symbol_async( + types.DocumentSymbolParams( + text_document=types.TextDocumentIdentifier(uri=uri), + ) + ) + + assert result is not None + assert len(result) == 9, f"Expected 9 SVC symbols, got {len(result)}: {[s.name for s in result]}" + + +# --------------------------------------------------------------------------- +# 11. YAML hover — schema description +# --------------------------------------------------------------------------- + + +async def test_yaml_hover_schema(lsp_client, fixture_dir): + """Hover on 'significance' key -> description from JSON schema.""" + client, _ = lsp_client + req_path = os.path.join(fixture_dir, "requirements.yml") + uri = _open_document(client, req_path, "yaml") + + pos = _find_position_in_file(req_path, "significance") + + result = await client.text_document_hover_async( + types.HoverParams( + text_document=types.TextDocumentIdentifier(uri=uri), + position=pos, + ) + ) + + assert result is not None, "Expected hover result for significance field" + assert isinstance(result.contents, types.MarkupContent) + assert "significance" in result.contents.value.lower() + + +# --------------------------------------------------------------------------- +# 12. YAML completion — enum values +# --------------------------------------------------------------------------- + + +async def test_yaml_completion_enum(lsp_client, fixture_dir): + """Completion at 'significance:' position -> 'shall', 'should', 'may'.""" + client, _ = lsp_client + req_path = os.path.join(fixture_dir, "requirements.yml") + uri = _open_document(client, req_path, "yaml") + + pos = _find_position_in_file(req_path, "significance: shall") + pos.character += len("significance: ") + + result = await client.text_document_completion_async( + types.CompletionParams( + text_document=types.TextDocumentIdentifier(uri=uri), + position=pos, + ) + ) + + assert result is not None, "Expected completion result for significance enum" + labels = {item.label for item in result.items} + assert {"shall", "should", "may"}.issubset(labels), f"Expected significance enum values, got: {labels}" + + +# --------------------------------------------------------------------------- +# 13. Go-to-definition: YAML -> YAML +# --------------------------------------------------------------------------- + + +async def test_goto_definition_yaml_to_yaml(lsp_client, fixture_dir): + """Definition at REQ_PASS in requirements.yml -> SVC file, + and SVC_021 in software_verification_cases.yml -> MVR file.""" + client, _ = lsp_client + + # --- REQ_PASS in requirements.yml → software_verification_cases.yml --- + req_path = os.path.join(fixture_dir, "requirements.yml") + req_uri = _open_document(client, req_path, "yaml") + + pos = _find_position_in_file(req_path, "id: REQ_PASS") + + result = await client.text_document_definition_async( + types.DefinitionParams( + text_document=types.TextDocumentIdentifier(uri=req_uri), + position=pos, + ) + ) + + assert result is not None and len(result) > 0, "Expected definition locations for REQ_PASS in SVC file" + svc_yml_path = os.path.join(fixture_dir, "software_verification_cases.yml") + target_uris = [loc.uri for loc in result] + assert any( + svc_yml_path in u for u in target_uris + ), f"Expected definition in software_verification_cases.yml, got: {target_uris}" + + # --- SVC_021 in software_verification_cases.yml → manual_verification_results.yml --- + svc_path = os.path.join(fixture_dir, "software_verification_cases.yml") + svc_uri = _open_document(client, svc_path, "yaml") + + pos = _find_position_in_file(svc_path, "id: SVC_021") + + result = await client.text_document_definition_async( + types.DefinitionParams( + text_document=types.TextDocumentIdentifier(uri=svc_uri), + position=pos, + ) + ) + + assert result is not None and len(result) > 0, "Expected definition locations for SVC_021 in MVR file" + mvr_yml_path = os.path.join(fixture_dir, "manual_verification_results.yml") + target_uris = [loc.uri for loc in result] + assert any( + mvr_yml_path in u for u in target_uris + ), f"Expected definition in manual_verification_results.yml, got: {target_uris}" From 80175665f5d096832cb8c806bc3b88736c2d29e4 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Mon, 16 Mar 2026 23:17:42 +0100 Subject: [PATCH 13/37] feat: track URN provenance and resolved source file paths (#314) Add location_type and location_uri columns to urn_metadata table to record where each URN's data originated (local, git, maven, pypi). Carry resolved file paths through CombinedRawDataset so the LSP go-to-definition handler uses actual paths from reqstool_config.yml instead of hard-coded filenames. Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/definition.py | 23 ++++--- src/reqstool/lsp/project_state.py | 10 +++ .../combined_raw_datasets_generator.py | 61 ++++++++++++++++++- src/reqstool/models/raw_datasets.py | 10 +++ src/reqstool/storage/database.py | 20 +++++- src/reqstool/storage/schema.py | 4 +- 6 files changed, 115 insertions(+), 13 deletions(-) diff --git a/src/reqstool/lsp/features/definition.py b/src/reqstool/lsp/features/definition.py index 4823993c..0f5894b0 100644 --- a/src/reqstool/lsp/features/definition.py +++ b/src/reqstool/lsp/features/definition.py @@ -49,17 +49,20 @@ def _definition_from_source( if match is None: return [] - reqstool_path = project.reqstool_path - if not reqstool_path: + initial_urn = project.get_initial_urn() + if not initial_urn: return [] if match.kind == "Requirements": - yaml_file = os.path.join(reqstool_path, "requirements.yml") + yaml_file = project.get_yaml_path(initial_urn, "requirements") elif match.kind == "SVCs": - yaml_file = os.path.join(reqstool_path, "software_verification_cases.yml") + yaml_file = project.get_yaml_path(initial_urn, "svcs") else: return [] + if yaml_file is None: + return [] + return _find_id_in_yaml(yaml_file, match.raw_id) @@ -77,8 +80,8 @@ def _definition_from_yaml( if project is None or not project.ready: return [] - reqstool_path = project.reqstool_path - if not reqstool_path: + initial_urn = project.get_initial_urn() + if not initial_urn: return [] # Determine what kind of ID this is based on the YAML file @@ -86,11 +89,15 @@ def _definition_from_yaml( if file_kind == "requirements": # From requirement ID → find references in SVC file (e.g. requirement_ids: ["REQ_PASS"]) - svc_file = os.path.join(reqstool_path, "software_verification_cases.yml") + svc_file = project.get_yaml_path(initial_urn, "svcs") + if svc_file is None: + return [] return _find_reference_in_yaml(svc_file, raw_id) elif file_kind == "svcs": # From SVC ID → find references in MVR file (e.g. svc_ids: ["SVC_021"]) - mvr_file = os.path.join(reqstool_path, "manual_verification_results.yml") + mvr_file = project.get_yaml_path(initial_urn, "mvrs") + if mvr_file is None: + return [] return _find_reference_in_yaml(mvr_file, raw_id) return [] diff --git a/src/reqstool/lsp/project_state.py b/src/reqstool/lsp/project_state.py index 8284f218..a1995f52 100644 --- a/src/reqstool/lsp/project_state.py +++ b/src/reqstool/lsp/project_state.py @@ -27,6 +27,7 @@ def __init__(self, reqstool_path: str): self._repo: RequirementsRepository | None = None self._ready: bool = False self._error: str | None = None + self._urn_source_paths: dict[str, dict[str, str]] = {} @property def ready(self) -> bool: @@ -61,6 +62,7 @@ def build(self) -> None: self._db = db self._repo = RequirementsRepository(db) + self._urn_source_paths = dict(crd.urn_source_paths) self._ready = True logger.info("Built project state for %s", self._reqstool_path) except SystemExit as e: @@ -80,6 +82,7 @@ def close(self) -> None: self._db.close() self._db = None self._repo = None + self._urn_source_paths = {} self._ready = False def get_initial_urn(self) -> str | None: @@ -130,3 +133,10 @@ def get_all_svc_ids(self) -> list[str]: if not self._ready or self._repo is None: return [] return [uid.id for uid in self._repo.get_all_svcs()] + + def get_yaml_path(self, urn: str, file_type: str) -> str | None: + """Return the resolved file path for a given URN and file type (requirements, svcs, mvrs, annotations).""" + urn_paths = self._urn_source_paths.get(urn) + if urn_paths is None: + return None + return urn_paths.get(file_type) diff --git a/src/reqstool/model_generators/combined_raw_datasets_generator.py b/src/reqstool/model_generators/combined_raw_datasets_generator.py index 4b9bfda8..3c5a151c 100644 --- a/src/reqstool/model_generators/combined_raw_datasets_generator.py +++ b/src/reqstool/model_generators/combined_raw_datasets_generator.py @@ -1,6 +1,7 @@ # Copyright © LFV import logging +import os from collections import defaultdict from typing import Dict, List, Optional @@ -10,6 +11,7 @@ from reqstool.common.utils import TempDirectoryUtil, Utils from reqstool.common.validators.semantic_validator import SemanticValidator from reqstool.location_resolver.location_resolver import LocationResolver +from reqstool.locations.local_location import LocalLocation from reqstool.locations.location import LocationInterface from reqstool.model_generators.annotations_model_generator import AnnotationsModelGenerator from reqstool.model_generators.mvrs_model_generator import MVRsModelGenerator @@ -64,11 +66,18 @@ def __generate(self) -> CombinedRawDataset: # handle imported sources self.__handle_initial_imports(raw_datasets=raw_datasets, rd=initial_imported_model.requirements_data) + # Aggregate resolved file paths from each RawDataset + urn_source_paths = {} + for urn, rd in raw_datasets.items(): + if rd.source_paths: + urn_source_paths[urn] = rd.source_paths + combined_raw_datasets = CombinedRawDataset( initial_model_urn=initial_urn, raw_datasets=raw_datasets, urn_parsing_order=self._parsing_order, parsing_graph=self._parsing_graph, + urn_source_paths=urn_source_paths, ) self.semantic_validator.validate_post_parsing(combined_raw_dataset=combined_raw_datasets) @@ -97,7 +106,11 @@ def _populate_database(self, crd: CombinedRawDataset) -> None: def __populate_requirements(self, crd: CombinedRawDataset) -> None: for urn in crd.urn_parsing_order: rd = crd.raw_datasets[urn] - self._database.insert_urn_metadata(rd.requirements_data.metadata) + self._database.insert_urn_metadata( + rd.requirements_data.metadata, + location_type=rd.location_type, + location_uri=rd.location_uri, + ) for req_data in rd.requirements_data.requirements.values(): self._database.insert_requirement(urn, req_data) @@ -255,16 +268,62 @@ def __parse_source(self, current_location_handler: LocationResolver) -> RawDatas actual_tmp_path, requirements_indata, rmg ) + # Capture location provenance + location_type, location_uri = self.__extract_location_provenance(current_location_handler.current) + + # Capture resolved file paths for LocalLocation only + source_paths = self.__extract_source_paths(current_location_handler.current, requirements_indata) + raw_dataset = RawDataset( requirements_data=rmg.requirements_data, annotations_data=annotations_data, svcs_data=svcs_data, mvrs_data=mvrs_data, automated_tests=automated_tests, + location_type=location_type, + location_uri=location_uri, + source_paths=source_paths, ) return raw_dataset + @staticmethod + def __extract_location_provenance(location: LocationInterface) -> tuple: + """Extract location_type and location_uri from a resolved location.""" + from reqstool.locations.git_location import GitLocation + from reqstool.locations.maven_location import MavenLocation + from reqstool.locations.pypi_location import PypiLocation + + if isinstance(location, LocalLocation): + return "local", f"file://{os.path.abspath(location.path)}" + elif isinstance(location, GitLocation): + return "git", location.url + elif isinstance(location, MavenLocation): + return "maven", f"{location.group_id}:{location.artifact_id}:{location.version}" + elif isinstance(location, PypiLocation): + return "pypi", f"{location.package}=={location.version}" + return None, None + + @staticmethod + def __extract_source_paths( + location: LocationInterface, requirements_indata: RequirementsIndata + ) -> Dict[str, str]: + """Extract resolved file paths for LocalLocation only.""" + if not isinstance(location, LocalLocation): + return {} + + source_paths: Dict[str, str] = {} + paths = requirements_indata.requirements_indata_paths + if paths.requirements_yml.exists: + source_paths["requirements"] = paths.requirements_yml.path + if paths.svcs_yml.exists: + source_paths["svcs"] = paths.svcs_yml.path + if paths.mvrs_yml.exists: + source_paths["mvrs"] = paths.mvrs_yml.path + if paths.annotations_yml.exists: + source_paths["annotations"] = paths.annotations_yml.path + return source_paths + @Requirements("REQ_009", "REQ_010", "REQ_013") def __parse_source_other( self, actual_tmp_path: str, requirements_indata: RequirementsIndata, rmg: RequirementsModelGenerator diff --git a/src/reqstool/models/raw_datasets.py b/src/reqstool/models/raw_datasets.py index 7d1b7117..5ad8b87e 100644 --- a/src/reqstool/models/raw_datasets.py +++ b/src/reqstool/models/raw_datasets.py @@ -24,6 +24,13 @@ class RawDataset(BaseModel): mvrs_data: Optional[MVRsData] = None + # URN provenance: location type and URI from the LocationResolver + location_type: Optional[str] = None + location_uri: Optional[str] = None + + # Resolved file paths (file_type → absolute path), only populated for LocalLocation + source_paths: Dict[str, str] = Field(default_factory=dict) + class CombinedRawDataset(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) @@ -32,3 +39,6 @@ class CombinedRawDataset(BaseModel): urn_parsing_order: List[str] = Field(default_factory=list) parsing_graph: Dict[str, List[str]] = Field(default_factory=dict) raw_datasets: Dict[str, RawDataset] = Field(default_factory=dict) + + # Aggregated resolved file paths: urn → file_type → absolute path (LSP only, LocalLocation only) + urn_source_paths: Dict[str, Dict[str, str]] = Field(default_factory=dict) diff --git a/src/reqstool/storage/database.py b/src/reqstool/storage/database.py index d46ee34c..5f910177 100644 --- a/src/reqstool/storage/database.py +++ b/src/reqstool/storage/database.py @@ -152,10 +152,24 @@ def insert_parsing_graph_edge(self, parent_urn: str, child_urn: str) -> None: (parent_urn, child_urn), ) - def insert_urn_metadata(self, metadata: MetaData) -> None: + def insert_urn_metadata( + self, + metadata: MetaData, + location_type: str | None = None, + location_uri: str | None = None, + ) -> None: self._conn.execute( - "INSERT INTO urn_metadata (urn, variant, title, url, parse_position) VALUES (?, ?, ?, ?, ?)", - (metadata.urn, metadata.variant.value, metadata.title, metadata.url, self._next_parse_position), + "INSERT INTO urn_metadata (urn, variant, title, url, parse_position, location_type, location_uri)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + metadata.urn, + metadata.variant.value, + metadata.title, + metadata.url, + self._next_parse_position, + location_type, + location_uri, + ), ) self._next_parse_position += 1 diff --git a/src/reqstool/storage/schema.py b/src/reqstool/storage/schema.py index add89de4..f0e6464f 100644 --- a/src/reqstool/storage/schema.py +++ b/src/reqstool/storage/schema.py @@ -122,7 +122,9 @@ variant TEXT NOT NULL CHECK (variant IN ('system', 'microservice', 'external')), title TEXT NOT NULL, url TEXT, - parse_position INTEGER NOT NULL UNIQUE + parse_position INTEGER NOT NULL UNIQUE, + location_type TEXT, + location_uri TEXT ); CREATE TABLE IF NOT EXISTS metadata ( From e07871a6be931740b17630f613c8fab7f9d9b678 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Tue, 17 Mar 2026 22:30:44 +0100 Subject: [PATCH 14/37] test: add LSP integration test fixtures and pytest-asyncio config (#314) Add regression-python fixture project with requirements, SVCs, MVRs, annotations, and test results for end-to-end LSP integration tests. Configure pytest-asyncio and add test client infrastructure. Signed-off-by: Jimisola Laursen --- pyproject.toml | 3 + .../reqstool-regression-python/README.md | 60 +++++++++++ .../annotations.yml | 40 +++++++ .../manual_verification_results.yml | 12 +++ .../reqstool_config.yml | 7 ++ .../requirements.yml | 73 +++++++++++++ .../software_verification_cases.yml | 73 +++++++++++++ .../src/requirements_example.py | 29 +++++ .../src/test_svcs.py | 35 ++++++ .../failsafe/TEST-py_demo.test_svcs_it.xml | 4 + .../surefire/TEST-py_demo.test_svcs.xml | 15 +++ tests/integration/reqstool/lsp/__init__.py | 0 tests/integration/reqstool/lsp/conftest.py | 100 ++++++++++++++++++ 13 files changed, 451 insertions(+) create mode 100644 tests/fixtures/reqstool-regression-python/README.md create mode 100644 tests/fixtures/reqstool-regression-python/annotations.yml create mode 100644 tests/fixtures/reqstool-regression-python/manual_verification_results.yml create mode 100644 tests/fixtures/reqstool-regression-python/reqstool_config.yml create mode 100644 tests/fixtures/reqstool-regression-python/requirements.yml create mode 100644 tests/fixtures/reqstool-regression-python/software_verification_cases.yml create mode 100644 tests/fixtures/reqstool-regression-python/src/requirements_example.py create mode 100644 tests/fixtures/reqstool-regression-python/src/test_svcs.py create mode 100644 tests/fixtures/reqstool-regression-python/test_results/failsafe/TEST-py_demo.test_svcs_it.xml create mode 100644 tests/fixtures/reqstool-regression-python/test_results/surefire/TEST-py_demo.test_svcs.xml create mode 100644 tests/integration/reqstool/lsp/__init__.py create mode 100644 tests/integration/reqstool/lsp/conftest.py diff --git a/pyproject.toml b/pyproject.toml index a13b9a19..b7367407 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ addopts = [ ] pythonpath = [".", "src", "tests"] testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "module" markers = [ "integration: tests that require external resources", "e2e: end-to-end tests that run the full pipeline locally", @@ -90,6 +92,7 @@ dependencies = [ "flake8==7.2.0", "flake8-pyproject==1.2.3", "datamodel-code-generator==0.54.1", + "pytest-asyncio>=0.25,<1.0", ] [tool.hatch.envs.dev.scripts] diff --git a/tests/fixtures/reqstool-regression-python/README.md b/tests/fixtures/reqstool-regression-python/README.md new file mode 100644 index 00000000..0dc35686 --- /dev/null +++ b/tests/fixtures/reqstool-regression-python/README.md @@ -0,0 +1,60 @@ +# reqstool-regression-python (fixture) + +Self-contained Python fake-project fixture for LSP integration testing. +When the real `reqstool-regression-python` repo is created, this directory becomes a git submodule. + +## Enum Coverage Matrix + +### Requirements + +| ID | Title | Significance | Lifecycle | Categories | Implementation | +|----|-------|-------------|-----------|------------|---------------| +| `REQ_PASS` | Greeting message | **shall** | effective *(default)* | functional-suitability | in-code | +| `REQ_MANUAL_FAIL` | Calculate total | **should** | effective | performance-efficiency, reliability | in-code | +| `REQ_NOT_IMPLEMENTED` | Export report | **may** | **draft** | compatibility, interaction-capability | **N/A** | +| `REQ_FAILING_TEST` | Email validation | shall | effective | security | in-code | +| `REQ_SKIPPED_TEST` | SMS notification | may | **deprecated** | maintainability | in-code | +| `REQ_MISSING_TEST` | Audit logging | shall | effective | safety, flexibility | in-code | +| `REQ_OBSOLETE` | Legacy greeting | should | **obsolete** | interaction-capability | in-code | + +**Coverage**: all 3 significance, all 4 lifecycle, all 9 categories, both implementation types. + +### SVCs + +| ID | Req IDs | Verification | Lifecycle | Test outcome | +|----|---------|-------------|-----------|-------------| +| `SVC_010` | REQ_PASS | **automated-test** | effective | PASS (unit + integration) | +| `SVC_020` | REQ_MANUAL_FAIL | automated-test | effective | PASS | +| `SVC_021` | REQ_PASS | **manual-test** | effective | MVR pass | +| `SVC_022` | REQ_MANUAL_FAIL | manual-test | effective | MVR fail | +| `SVC_030` | REQ_NOT_IMPLEMENTED | **review** | effective | *(N/A)* | +| `SVC_040` | REQ_FAILING_TEST | automated-test | effective | FAIL | +| `SVC_050` | REQ_SKIPPED_TEST | **platform** | **deprecated** | SKIPPED | +| `SVC_060` | REQ_MISSING_TEST | automated-test | effective | NO TEST | +| `SVC_070` | REQ_OBSOLETE | **other** | **obsolete** | *(N/A)* | + +**Coverage**: all 5 verification types, all test outcomes. + +### MVRs + +| ID | SVC IDs | Pass | Comment | +|----|---------|------|---------| +| `MVR_201` | SVC_021 | true | Greeting message correctly displayed | +| `MVR_202` | SVC_022 | false | Rounding error: 9.99 instead of 10.00 | + +## Structure + +``` +reqstool-regression-python/ + requirements.yml + software_verification_cases.yml + manual_verification_results.yml + annotations.yml + reqstool_config.yml + test_results/ + surefire/TEST-py_demo.test_svcs.xml + failsafe/TEST-py_demo.test_svcs_it.xml + src/ + requirements_example.py + test_svcs.py +``` diff --git a/tests/fixtures/reqstool-regression-python/annotations.yml b/tests/fixtures/reqstool-regression-python/annotations.yml new file mode 100644 index 00000000..a4b3eb9b --- /dev/null +++ b/tests/fixtures/reqstool-regression-python/annotations.yml @@ -0,0 +1,40 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/annotations.schema.json +--- +requirement_annotations: + implementations: + REQ_PASS: + - elementKind: "CLASS" + fullyQualifiedName: "requirements_example.RequirementsExample" + REQ_MANUAL_FAIL: + - elementKind: "METHOD" + fullyQualifiedName: "requirements_example.RequirementsExample.calculate_total" + REQ_FAILING_TEST: + - elementKind: "METHOD" + fullyQualifiedName: "requirements_example.RequirementsExample.validate_email" + REQ_SKIPPED_TEST: + - elementKind: "METHOD" + fullyQualifiedName: "requirements_example.RequirementsExample.send_sms" + REQ_MISSING_TEST: + - elementKind: "METHOD" + fullyQualifiedName: "requirements_example.RequirementsExample.log_action" + REQ_OBSOLETE: + - elementKind: "METHOD" + fullyQualifiedName: "requirements_example.RequirementsExample.legacy_greet" + tests: + SVC_010: + - elementKind: "METHOD" + fullyQualifiedName: "test_svcs.test_greeting_message" + - elementKind: "METHOD" + fullyQualifiedName: "test_svcs_it.test_greeting_integration" + SVC_020: + - elementKind: "METHOD" + fullyQualifiedName: "test_svcs.test_calculate_total" + SVC_030: + - elementKind: "METHOD" + fullyQualifiedName: "test_svcs.test_export_report_design" + SVC_040: + - elementKind: "METHOD" + fullyQualifiedName: "test_svcs.test_email_validation" + SVC_050: + - elementKind: "METHOD" + fullyQualifiedName: "test_svcs.test_sms_notification" diff --git a/tests/fixtures/reqstool-regression-python/manual_verification_results.yml b/tests/fixtures/reqstool-regression-python/manual_verification_results.yml new file mode 100644 index 00000000..5a47a605 --- /dev/null +++ b/tests/fixtures/reqstool-regression-python/manual_verification_results.yml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/manual_verification_results.schema.json + +results: + - id: MVR_201 + svc_ids: ["SVC_021"] + comment: "Greeting message correctly displayed" + pass: true + + - id: MVR_202 + svc_ids: ["SVC_022"] + comment: "Rounding error: 9.99 instead of 10.00" + pass: false diff --git a/tests/fixtures/reqstool-regression-python/reqstool_config.yml b/tests/fixtures/reqstool-regression-python/reqstool_config.yml new file mode 100644 index 00000000..183a0879 --- /dev/null +++ b/tests/fixtures/reqstool-regression-python/reqstool_config.yml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/reqstool_config.schema.json + +language: python +build: hatch +resources: + test_results: + - test_results/**/*.xml diff --git a/tests/fixtures/reqstool-regression-python/requirements.yml b/tests/fixtures/reqstool-regression-python/requirements.yml new file mode 100644 index 00000000..ab31f9b9 --- /dev/null +++ b/tests/fixtures/reqstool-regression-python/requirements.yml @@ -0,0 +1,73 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/requirements.schema.json + +metadata: + urn: regression-python + variant: microservice + title: Python Regression Test Requirements + url: https://github.com/reqstool/reqstool-client + +requirements: + - id: REQ_PASS + title: Greeting message + significance: shall + description: The system shall display a greeting message to the user + rationale: Users need visual confirmation that the system is running + categories: ["functional-suitability"] + revision: 1.0.0 + + - id: REQ_MANUAL_FAIL + title: Calculate total + significance: should + description: The system should calculate the total amount correctly + rationale: Financial accuracy is critical for user trust + categories: ["performance-efficiency", "reliability"] + revision: 1.0.0 + + - id: REQ_NOT_IMPLEMENTED + title: Export report + significance: may + description: The system may export reports in various formats + rationale: Reporting aids operational oversight + categories: ["compatibility", "interaction-capability"] + implementation: "N/A" + revision: 0.1.0 + lifecycle: + state: draft + + - id: REQ_FAILING_TEST + title: Email validation + significance: shall + description: The system shall validate email addresses before sending + rationale: Invalid emails cause delivery failures and waste resources + categories: ["security"] + revision: 1.0.0 + + - id: REQ_SKIPPED_TEST + title: SMS notification + significance: may + description: The system may send SMS notifications for critical alerts + rationale: SMS provides an alternative notification channel + categories: ["maintainability"] + revision: 1.0.0 + lifecycle: + state: deprecated + reason: "Replaced by push notifications" + + - id: REQ_MISSING_TEST + title: Audit logging + significance: shall + description: The system shall log all user actions for audit purposes + rationale: Audit trails are required for compliance + categories: ["safety", "flexibility"] + revision: 1.0.0 + + - id: REQ_OBSOLETE + title: Legacy greeting + significance: should + description: The system should display a legacy greeting format + rationale: Originally required for backward compatibility + categories: ["interaction-capability"] + revision: 0.1.0 + lifecycle: + state: obsolete + reason: "Superseded by REQ_PASS" diff --git a/tests/fixtures/reqstool-regression-python/software_verification_cases.yml b/tests/fixtures/reqstool-regression-python/software_verification_cases.yml new file mode 100644 index 00000000..1a480a2a --- /dev/null +++ b/tests/fixtures/reqstool-regression-python/software_verification_cases.yml @@ -0,0 +1,73 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/software_verification_cases.schema.json + +cases: + - id: SVC_010 + requirement_ids: ["REQ_PASS"] + title: "Verify greeting message displayed" + description: "Automated test verifying greeting output" + verification: automated-test + revision: "1.0.0" + + - id: SVC_020 + requirement_ids: ["REQ_MANUAL_FAIL"] + title: "Verify total calculation" + description: "Automated test verifying calculation accuracy" + verification: automated-test + revision: "1.0.0" + + - id: SVC_021 + requirement_ids: ["REQ_PASS"] + title: "Manual verify greeting display" + description: "Manual inspection of greeting message rendering" + verification: manual-test + instructions: "Open the application and verify the greeting message is displayed correctly" + revision: "1.0.0" + + - id: SVC_022 + requirement_ids: ["REQ_MANUAL_FAIL"] + title: "Manual verify total calculation" + description: "Manual inspection of calculation results" + verification: manual-test + instructions: "Calculate expected total and compare with displayed value" + revision: "1.0.0" + + - id: SVC_030 + requirement_ids: ["REQ_NOT_IMPLEMENTED"] + title: "Review export report design" + description: "Design review of export functionality" + verification: review + revision: "1.0.0" + + - id: SVC_040 + requirement_ids: ["REQ_FAILING_TEST"] + title: "Verify email validation" + description: "Automated test verifying email format checking" + verification: automated-test + revision: "1.0.0" + + - id: SVC_050 + requirement_ids: ["REQ_SKIPPED_TEST"] + title: "Verify SMS notification delivery" + description: "Platform-level verification of SMS gateway" + verification: platform + revision: "1.0.0" + lifecycle: + state: deprecated + reason: "SMS no longer supported" + + - id: SVC_060 + requirement_ids: ["REQ_MISSING_TEST"] + title: "Verify audit logging" + description: "Automated test verifying audit log entries" + verification: automated-test + revision: "1.0.0" + + - id: SVC_070 + requirement_ids: ["REQ_OBSOLETE"] + title: "Verify legacy greeting format" + description: "Other verification of legacy greeting" + verification: other + revision: "0.1.0" + lifecycle: + state: obsolete + reason: "Superseded by SVC_010" diff --git a/tests/fixtures/reqstool-regression-python/src/requirements_example.py b/tests/fixtures/reqstool-regression-python/src/requirements_example.py new file mode 100644 index 00000000..b0a51348 --- /dev/null +++ b/tests/fixtures/reqstool-regression-python/src/requirements_example.py @@ -0,0 +1,29 @@ +from reqstool_python_decorators.decorators.decorators import Requirements, SVCs + + +@Requirements("REQ_PASS") +class RequirementsExample: + """Example class implementing requirements.""" + + @Requirements("REQ_MANUAL_FAIL") + def calculate_total(self, items): + return sum(item["price"] for item in items) + + @Requirements("REQ_FAILING_TEST") + def validate_email(self, email): + return "@" in email and "." in email.split("@")[1] + + @Requirements("REQ_SKIPPED_TEST") + def send_sms(self, phone, message): + raise NotImplementedError("SMS gateway removed") + + @Requirements("REQ_MISSING_TEST") + def log_action(self, user, action): + print(f"AUDIT: {user} performed {action}") + + @Requirements("REQ_OBSOLETE") + def legacy_greet(self, name): + return f"Hello, {name}!" + + def greet(self, name): + return f"Welcome, {name}!" diff --git a/tests/fixtures/reqstool-regression-python/src/test_svcs.py b/tests/fixtures/reqstool-regression-python/src/test_svcs.py new file mode 100644 index 00000000..7cba082c --- /dev/null +++ b/tests/fixtures/reqstool-regression-python/src/test_svcs.py @@ -0,0 +1,35 @@ +from reqstool_python_decorators.decorators.decorators import SVCs + +from requirements_example import RequirementsExample + + +@SVCs("SVC_010") +def test_greeting_message(): + example = RequirementsExample() + result = example.greet("World") + assert result == "Welcome, World!" + + +@SVCs("SVC_020") +def test_calculate_total(): + example = RequirementsExample() + items = [{"price": 10.0}, {"price": 20.0}] + assert example.calculate_total(items) == 30.0 + + +@SVCs("SVC_030") +def test_export_report_design(): + pass + + +@SVCs("SVC_040") +def test_email_validation(): + example = RequirementsExample() + assert example.validate_email("user@example.com") + assert not example.validate_email("invalid") + + +@SVCs("SVC_050") +def test_sms_notification(): + example = RequirementsExample() + example.send_sms("+1234567890", "Test alert") diff --git a/tests/fixtures/reqstool-regression-python/test_results/failsafe/TEST-py_demo.test_svcs_it.xml b/tests/fixtures/reqstool-regression-python/test_results/failsafe/TEST-py_demo.test_svcs_it.xml new file mode 100644 index 00000000..6015e047 --- /dev/null +++ b/tests/fixtures/reqstool-regression-python/test_results/failsafe/TEST-py_demo.test_svcs_it.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/fixtures/reqstool-regression-python/test_results/surefire/TEST-py_demo.test_svcs.xml b/tests/fixtures/reqstool-regression-python/test_results/surefire/TEST-py_demo.test_svcs.xml new file mode 100644 index 00000000..cf92aa36 --- /dev/null +++ b/tests/fixtures/reqstool-regression-python/test_results/surefire/TEST-py_demo.test_svcs.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/tests/integration/reqstool/lsp/__init__.py b/tests/integration/reqstool/lsp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/reqstool/lsp/conftest.py b/tests/integration/reqstool/lsp/conftest.py new file mode 100644 index 00000000..3e6c448c --- /dev/null +++ b/tests/integration/reqstool/lsp/conftest.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +import pytest +import pytest_asyncio +from lsprotocol import types +from pygls.lsp.client import BaseLanguageClient + +FIXTURE_DIR = str(Path(__file__).resolve().parents[3] / "fixtures" / "reqstool-regression-python") + + +class ReqstoolTestClient(BaseLanguageClient): + """LSP client that collects publishDiagnostics notifications.""" + + def __init__(self): + super().__init__(name="reqstool-test-client", version="0.0.1") + self.diagnostics: dict[str, list[types.Diagnostic]] = {} + self._diagnostics_version: int = 0 + self._diagnostics_event = asyncio.Event() + + @self.feature(types.TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) + def on_publish_diagnostics(params: types.PublishDiagnosticsParams): + self.diagnostics[params.uri] = params.diagnostics + self._diagnostics_version += 1 + self._diagnostics_event.set() + + def clear_diagnostics(self): + """Clear cached diagnostics so the next wait_for_diagnostics blocks until fresh data arrives.""" + self.diagnostics.clear() + self._diagnostics_event.clear() + + async def wait_for_diagnostics(self, uri: str, timeout: float = 10.0) -> list[types.Diagnostic]: + """Wait until diagnostics arrive for the given URI.""" + deadline = asyncio.get_event_loop().time() + timeout + while True: + if uri in self.diagnostics: + return self.diagnostics[uri] + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + return self.diagnostics.get(uri, []) + self._diagnostics_event.clear() + try: + await asyncio.wait_for(self._diagnostics_event.wait(), timeout=remaining) + except asyncio.TimeoutError: + return self.diagnostics.get(uri, []) + + +@pytest.fixture(scope="session") +def fixture_dir(): + """Path to the regression-python fixture directory.""" + assert os.path.isdir(FIXTURE_DIR), f"Fixture directory not found: {FIXTURE_DIR}" + return FIXTURE_DIR + + +@pytest_asyncio.fixture(loop_scope="module", scope="module") +async def lsp_client(fixture_dir): + """Module-scoped async fixture: starts LSP server, initializes, yields client, shuts down.""" + client = ReqstoolTestClient() + + await client.start_io(sys.executable, "-m", "reqstool.command", "lsp") + + workspace_folder = types.WorkspaceFolder( + uri=Path(fixture_dir).as_uri(), + name="reqstool-regression-python", + ) + + result = await client.initialize_async( + types.InitializeParams( + capabilities=types.ClientCapabilities( + text_document=types.TextDocumentClientCapabilities( + hover=types.HoverClientCapabilities(), + completion=types.CompletionClientCapabilities(), + definition=types.DefinitionClientCapabilities(), + document_symbol=types.DocumentSymbolClientCapabilities(), + publish_diagnostics=types.PublishDiagnosticsClientCapabilities(), + ), + workspace=types.WorkspaceClientCapabilities( + workspace_folders=True, + ), + ), + root_uri=Path(fixture_dir).as_uri(), + workspace_folders=[workspace_folder], + ) + ) + + # Send initialized notification to trigger project discovery + client.initialized(types.InitializedParams()) + + # Give the server time to discover and build the project + await asyncio.sleep(2) + + yield client, result + + await client.shutdown_async(None) + client.exit(None) + await asyncio.sleep(0.5) From 7ae81a5fe687fa8d2883d275d85cf8ab22defd0e Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Tue, 17 Mar 2026 22:51:34 +0100 Subject: [PATCH 15/37] style: apply black formatting and fix flake8 unused import (#314) Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/diagnostics.py | 12 ++++----- src/reqstool/lsp/features/document_symbols.py | 4 +-- src/reqstool/lsp/features/hover.py | 26 +++++++++++-------- src/reqstool/lsp/server.py | 4 +-- .../combined_raw_datasets_generator.py | 4 +-- .../src/requirements_example.py | 2 +- .../reqstool/lsp/test_document_symbols.py | 22 +++------------- 7 files changed, 28 insertions(+), 46 deletions(-) diff --git a/src/reqstool/lsp/features/diagnostics.py b/src/reqstool/lsp/features/diagnostics.py index 8b8b065e..357d6130 100644 --- a/src/reqstool/lsp/features/diagnostics.py +++ b/src/reqstool/lsp/features/diagnostics.py @@ -191,8 +191,8 @@ def _find_error_position(text: str, error) -> tuple[int, int]: # If there are array indices in the path, try to narrow down matches = list(pattern.finditer(text)) if len(matches) == 1: - line = text[:matches[0].start()].count("\n") - col = matches[0].start() - text[:matches[0].start()].rfind("\n") - 1 + line = text[: matches[0].start()].count("\n") + col = matches[0].start() - text[: matches[0].start()].rfind("\n") - 1 return line, col elif len(matches) > 1: # Use the array index to pick the right match @@ -202,13 +202,13 @@ def _find_error_position(text: str, error) -> tuple[int, int]: array_idx = p if array_idx is not None and array_idx < len(matches): m = matches[array_idx] - line = text[:m.start()].count("\n") - col = m.start() - text[:m.start()].rfind("\n") - 1 + line = text[: m.start()].count("\n") + col = m.start() - text[: m.start()].rfind("\n") - 1 return line, col # Fall back to first match m = matches[0] - line = text[:m.start()].count("\n") - col = m.start() - text[:m.start()].rfind("\n") - 1 + line = text[: m.start()].count("\n") + col = m.start() - text[: m.start()].rfind("\n") - 1 return line, col return 0, 0 diff --git a/src/reqstool/lsp/features/document_symbols.py b/src/reqstool/lsp/features/document_symbols.py index 1118da1a..05806dc9 100644 --- a/src/reqstool/lsp/features/document_symbols.py +++ b/src/reqstool/lsp/features/document_symbols.py @@ -205,9 +205,7 @@ def _parse_yaml_items(text: str) -> list[_YamlItem]: if not stripped or stripped.startswith("#"): continue - current_item, list_indent = _process_yaml_line( - line, i, items, current_item, list_indent - ) + current_item, list_indent = _process_yaml_line(line, i, items, current_item, list_indent) if current_item is not None: current_item.end_line = len(lines) - 1 diff --git a/src/reqstool/lsp/features/hover.py b/src/reqstool/lsp/features/hover.py index 709793d2..6ee4d4e7 100644 --- a/src/reqstool/lsp/features/hover.py +++ b/src/reqstool/lsp/features/hover.py @@ -81,12 +81,14 @@ def _hover_requirement(raw_id: str, match, project: ProjectState) -> types.Hover ] if req.rationale: parts.extend(["---", req.rationale]) - parts.extend([ - "---", - f"**Categories**: {categories}", - f"**Lifecycle**: {req.lifecycle.state.value}", - f"**SVCs**: {svc_ids}", - ]) + parts.extend( + [ + "---", + f"**Categories**: {categories}", + f"**Lifecycle**: {req.lifecycle.state.value}", + f"**SVCs**: {svc_ids}", + ] + ) md = "\n\n".join(parts) return types.Hover( @@ -118,11 +120,13 @@ def _hover_svc(raw_id: str, match, project: ProjectState) -> types.Hover | None: if svc.instructions: parts.append(svc.instructions) parts.append("---") - parts.extend([ - f"**Lifecycle**: {svc.lifecycle.state.value}", - f"**Requirements**: {req_ids}", - f"**MVRs**: {mvr_info}", - ]) + parts.extend( + [ + f"**Lifecycle**: {svc.lifecycle.state.value}", + f"**Requirements**: {req_ids}", + f"**MVRs**: {mvr_info}", + ] + ) md = "\n\n".join(parts) return types.Hover( diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index 56118331..4f2eff9c 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -221,9 +221,7 @@ def _publish_diagnostics_for_document(ls: ReqstoolLanguageServer, uri: str) -> N language_id=document.language_id or "", project=project, ) - ls.text_document_publish_diagnostics( - types.PublishDiagnosticsParams(uri=uri, diagnostics=diagnostics) - ) + ls.text_document_publish_diagnostics(types.PublishDiagnosticsParams(uri=uri, diagnostics=diagnostics)) def _publish_all_diagnostics(ls: ReqstoolLanguageServer) -> None: diff --git a/src/reqstool/model_generators/combined_raw_datasets_generator.py b/src/reqstool/model_generators/combined_raw_datasets_generator.py index 3c5a151c..b4a929b7 100644 --- a/src/reqstool/model_generators/combined_raw_datasets_generator.py +++ b/src/reqstool/model_generators/combined_raw_datasets_generator.py @@ -305,9 +305,7 @@ def __extract_location_provenance(location: LocationInterface) -> tuple: return None, None @staticmethod - def __extract_source_paths( - location: LocationInterface, requirements_indata: RequirementsIndata - ) -> Dict[str, str]: + def __extract_source_paths(location: LocationInterface, requirements_indata: RequirementsIndata) -> Dict[str, str]: """Extract resolved file paths for LocalLocation only.""" if not isinstance(location, LocalLocation): return {} diff --git a/tests/fixtures/reqstool-regression-python/src/requirements_example.py b/tests/fixtures/reqstool-regression-python/src/requirements_example.py index b0a51348..d3eed6f9 100644 --- a/tests/fixtures/reqstool-regression-python/src/requirements_example.py +++ b/tests/fixtures/reqstool-regression-python/src/requirements_example.py @@ -1,4 +1,4 @@ -from reqstool_python_decorators.decorators.decorators import Requirements, SVCs +from reqstool_python_decorators.decorators.decorators import Requirements, SVCs # noqa: F401 @Requirements("REQ_PASS") diff --git a/tests/unit/reqstool/lsp/test_document_symbols.py b/tests/unit/reqstool/lsp/test_document_symbols.py index 2f1a19b3..744ec581 100644 --- a/tests/unit/reqstool/lsp/test_document_symbols.py +++ b/tests/unit/reqstool/lsp/test_document_symbols.py @@ -30,12 +30,7 @@ def test_parse_yaml_items_requirements(): def test_parse_yaml_items_svcs(): - text = ( - "svcs:\n" - " - id: SVC_001\n" - " title: Test case\n" - " verification: automated-test\n" - ) + text = "svcs:\n" " - id: SVC_001\n" " title: Test case\n" " verification: automated-test\n" items = _parse_yaml_items(text) assert len(items) == 1 assert items[0].fields["id"] == "SVC_001" @@ -89,12 +84,7 @@ def test_document_symbols_requirements(): def test_document_symbols_svcs(): - text = ( - "svcs:\n" - " - id: SVC_001\n" - " title: Login test\n" - " verification: automated-test\n" - ) + text = "svcs:\n" " - id: SVC_001\n" " title: Login test\n" " verification: automated-test\n" symbols = handle_document_symbols( uri="file:///workspace/software_verification_cases.yml", text=text, @@ -106,13 +96,7 @@ def test_document_symbols_svcs(): def test_document_symbols_mvrs(): - text = ( - "results:\n" - " - id: SVC_001\n" - " passed: true\n" - " - id: SVC_002\n" - " passed: false\n" - ) + text = "results:\n" " - id: SVC_001\n" " passed: true\n" " - id: SVC_002\n" " passed: false\n" symbols = handle_document_symbols( uri="file:///workspace/manual_verification_results.yml", text=text, From 8d3aa1dbcfe34a42ff3b6e0a74e0fc984e415ba1 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Wed, 18 Mar 2026 01:30:40 +0100 Subject: [PATCH 16/37] feat: add --stdio/--tcp transport args and improve LSP error handling (#314) - Accept --stdio flag (passed by VS Code LSP client) in lsp subcommand - Add --tcp, --host, --port args for TCP transport support - Wrap start_server() call with exception handler in command.py - Wrap server.start_io()/start_tcp() with exception logging in server.py --- src/reqstool/command.py | 33 +++++++++++++++++++++++++++++++-- src/reqstool/lsp/server.py | 14 +++++++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/reqstool/command.py b/src/reqstool/command.py index 3c0635f8..696e2563 100755 --- a/src/reqstool/command.py +++ b/src/reqstool/command.py @@ -274,7 +274,32 @@ class ComboRawTextandArgsDefaultUltimateHelpFormatter( self._add_subparsers_source(status_source_subparsers) # command: lsp - subparsers.add_parser("lsp", help="Start the Language Server Protocol server (requires reqstool[lsp])") + lsp_parser = subparsers.add_parser( + "lsp", help="Start the Language Server Protocol server (requires reqstool[lsp])" + ) + lsp_parser.add_argument( + "--stdio", + action="store_true", + default=True, + help="Use stdio transport (default)", + ) + lsp_parser.add_argument( + "--tcp", + action="store_true", + default=False, + help="Use TCP transport instead of stdio", + ) + lsp_parser.add_argument( + "--host", + default="127.0.0.1", + help="TCP host (default: %(default)s)", + ) + lsp_parser.add_argument( + "--port", + type=int, + default=2087, + help="TCP port (default: %(default)s)", + ) args = self.__parser.parse_args() @@ -412,7 +437,11 @@ def main(): file=sys.stderr, ) sys.exit(1) - start_server() + try: + start_server(tcp=args.tcp, host=args.host, port=args.port) + except Exception as exc: + logging.fatal("reqstool LSP server crashed: %s", exc) + sys.exit(1) else: command.print_help() except MissingRequirementsFileError as exc: diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index 4f2eff9c..72ebaea4 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -230,8 +230,16 @@ def _publish_all_diagnostics(ls: ReqstoolLanguageServer) -> None: _publish_diagnostics_for_document(ls, uri) -def start_server() -> None: +def start_server(tcp: bool = False, host: str = "127.0.0.1", port: int = 2087) -> None: """Entry point for `reqstool lsp` command.""" logging.basicConfig(level=logging.INFO) - logger.info("Starting reqstool LSP server (stdio)") - server.start_io() + try: + if tcp: + logger.info("Starting reqstool LSP server (TCP %s:%d)", host, port) + server.start_tcp(host, port) + else: + logger.info("Starting reqstool LSP server (stdio)") + server.start_io() + except Exception: + logger.exception("reqstool LSP server encountered a fatal error") + raise From a8b6f994b25814ebce9b9a8ebc744b43261f7230 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Wed, 18 Mar 2026 01:48:29 +0100 Subject: [PATCH 17/37] fix: replace PyYAML with ruamel.yaml in LSP diagnostics (#314) --- src/reqstool/lsp/features/diagnostics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reqstool/lsp/features/diagnostics.py b/src/reqstool/lsp/features/diagnostics.py index 357d6130..02b91945 100644 --- a/src/reqstool/lsp/features/diagnostics.py +++ b/src/reqstool/lsp/features/diagnostics.py @@ -6,7 +6,7 @@ import os import re -import yaml +from ruamel.yaml import YAML, YAMLError from jsonschema import Draft202012Validator from lsprotocol import types @@ -125,8 +125,8 @@ def _yaml_diagnostics(text: str, filename: str) -> list[types.Diagnostic]: # Parse YAML first try: - data = yaml.safe_load(text) - except yaml.YAMLError as e: + data = YAML(typ="safe").load(text) + except YAMLError as e: diag_range = types.Range( start=types.Position(line=0, character=0), end=types.Position(line=0, character=0), From 9b58fc532c0290a84b8f268b60710de7d39da92f Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Wed, 18 Mar 2026 01:57:06 +0100 Subject: [PATCH 18/37] feat: add --log-file option to lsp command for debugging (#314) --- src/reqstool/command.py | 8 +++++++- src/reqstool/lsp/server.py | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/reqstool/command.py b/src/reqstool/command.py index 696e2563..60033466 100755 --- a/src/reqstool/command.py +++ b/src/reqstool/command.py @@ -300,6 +300,12 @@ class ComboRawTextandArgsDefaultUltimateHelpFormatter( default=2087, help="TCP port (default: %(default)s)", ) + lsp_parser.add_argument( + "--log-file", + metavar="PATH", + default=None, + help="Write server logs to a file (in addition to stderr)", + ) args = self.__parser.parse_args() @@ -438,7 +444,7 @@ def main(): ) sys.exit(1) try: - start_server(tcp=args.tcp, host=args.host, port=args.port) + start_server(tcp=args.tcp, host=args.host, port=args.port, log_file=args.log_file) except Exception as exc: logging.fatal("reqstool LSP server crashed: %s", exc) sys.exit(1) diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index 72ebaea4..08850a29 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -230,9 +230,12 @@ def _publish_all_diagnostics(ls: ReqstoolLanguageServer) -> None: _publish_diagnostics_for_document(ls, uri) -def start_server(tcp: bool = False, host: str = "127.0.0.1", port: int = 2087) -> None: +def start_server(tcp: bool = False, host: str = "127.0.0.1", port: int = 2087, log_file: str | None = None) -> None: """Entry point for `reqstool lsp` command.""" - logging.basicConfig(level=logging.INFO) + handlers: list[logging.Handler] = [logging.StreamHandler()] + if log_file: + handlers.append(logging.FileHandler(log_file)) + logging.basicConfig(level=logging.INFO, handlers=handlers, force=True) try: if tcp: logger.info("Starting reqstool LSP server (TCP %s:%d)", host, port) From 5331f5d249a2ecbe110b69ead0c4451449a9ef6b Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Wed, 18 Mar 2026 02:17:29 +0100 Subject: [PATCH 19/37] fix: match source files to project via workspace folder, not reqstool_path (#314) --- src/reqstool/lsp/workspace_manager.py | 37 ++++++++++----------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/src/reqstool/lsp/workspace_manager.py b/src/reqstool/lsp/workspace_manager.py index fcfd7ab3..74ee3efc 100644 --- a/src/reqstool/lsp/workspace_manager.py +++ b/src/reqstool/lsp/workspace_manager.py @@ -65,43 +65,34 @@ def rebuild_affected(self, file_uri: str) -> ProjectState | None: def project_for_file(self, file_uri: str) -> ProjectState | None: file_path = uri_to_path(file_uri) + norm_file = os.path.normpath(file_path) best_match: ProjectState | None = None best_depth = -1 + # First: exact match — file is under the reqstool_path directory itself for projects in self._folder_projects.values(): for project in projects: reqstool_path = os.path.normpath(project.reqstool_path) - norm_file = os.path.normpath(file_path) - # Check if the file is within the project's directory tree if norm_file.startswith(reqstool_path + os.sep) or norm_file == reqstool_path: depth = reqstool_path.count(os.sep) if depth > best_depth: best_match = project best_depth = depth - # If no direct match, find the closest project by walking up from the file - if best_match is None: - file_dir = os.path.dirname(file_path) if os.path.isfile(file_path) else file_path - best_match = self._find_closest_project(file_dir) + if best_match is not None: + return best_match - return best_match + # Fallback: file is anywhere within the workspace folder that contains the project + # (e.g. a Java source file in src/ belonging to a project whose reqstool_path is docs/reqstool/) + for folder_uri, projects in self._folder_projects.items(): + if not projects: + continue + folder_path = uri_to_path(folder_uri) + norm_folder = os.path.normpath(folder_path) + if norm_file.startswith(norm_folder + os.sep) or norm_file == norm_folder: + return max(projects, key=lambda p: os.path.normpath(p.reqstool_path).count(os.sep)) - def _find_closest_project(self, file_dir: str) -> ProjectState | None: - """Find the project whose reqstool_path is the closest ancestor of file_dir.""" - best_match: ProjectState | None = None - best_depth = -1 - - norm_dir = os.path.normpath(file_dir) - for projects in self._folder_projects.values(): - for project in projects: - reqstool_path = os.path.normpath(project.reqstool_path) - if norm_dir.startswith(reqstool_path + os.sep) or norm_dir == reqstool_path: - depth = reqstool_path.count(os.sep) - if depth > best_depth: - best_match = project - best_depth = depth - - return best_match + return None def all_projects(self) -> list[ProjectState]: result = [] From 7fc2a9c7e82fb8fccaea4efada28b69cb9587884 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Wed, 18 Mar 2026 20:15:49 +0100 Subject: [PATCH 20/37] feat: add codeLens, inlayHint, references, workspaceSymbol, semanticTokens, codeAction LSP features (#314) - Add reqstool/details custom request for structured REQ/SVC/MVR data - Extend hover with "Open Details" command link - Add textDocument/codeLens (verification status above annotations) - Add textDocument/inlayHint (title inline after ID) - Add textDocument/references (find all usages across open docs + YAML) - Add workspace/symbol (quick-search REQ/SVC IDs) - Add textDocument/semanticTokens/full (color-code deprecated/obsolete) - Add textDocument/codeAction (quick fixes for unknown/deprecated IDs) - Add get_mvr() and get_yaml_paths() to ProjectState - Add _get() and _first_project() shared helpers to server.py Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/code_actions.py | 103 ++++++++++++ src/reqstool/lsp/features/codelens.py | 93 +++++++++++ src/reqstool/lsp/features/details.py | 82 ++++++++++ src/reqstool/lsp/features/hover.py | 22 ++- src/reqstool/lsp/features/inlay_hints.py | 47 ++++++ src/reqstool/lsp/features/references.py | 147 ++++++++++++++++++ src/reqstool/lsp/features/semantic_tokens.py | 61 ++++++++ .../lsp/features/workspace_symbols.py | 91 +++++++++++ src/reqstool/lsp/project_state.py | 11 ++ src/reqstool/lsp/server.py | 119 ++++++++++++++ tests/unit/reqstool/lsp/test_code_actions.py | 123 +++++++++++++++ tests/unit/reqstool/lsp/test_codelens.py | 67 ++++++++ tests/unit/reqstool/lsp/test_details.py | 66 ++++++++ tests/unit/reqstool/lsp/test_inlay_hints.py | 66 ++++++++ tests/unit/reqstool/lsp/test_references.py | 63 ++++++++ .../unit/reqstool/lsp/test_semantic_tokens.py | 75 +++++++++ .../reqstool/lsp/test_workspace_symbols.py | 63 ++++++++ 17 files changed, 1294 insertions(+), 5 deletions(-) create mode 100644 src/reqstool/lsp/features/code_actions.py create mode 100644 src/reqstool/lsp/features/codelens.py create mode 100644 src/reqstool/lsp/features/details.py create mode 100644 src/reqstool/lsp/features/inlay_hints.py create mode 100644 src/reqstool/lsp/features/references.py create mode 100644 src/reqstool/lsp/features/semantic_tokens.py create mode 100644 src/reqstool/lsp/features/workspace_symbols.py create mode 100644 tests/unit/reqstool/lsp/test_code_actions.py create mode 100644 tests/unit/reqstool/lsp/test_codelens.py create mode 100644 tests/unit/reqstool/lsp/test_details.py create mode 100644 tests/unit/reqstool/lsp/test_inlay_hints.py create mode 100644 tests/unit/reqstool/lsp/test_references.py create mode 100644 tests/unit/reqstool/lsp/test_semantic_tokens.py create mode 100644 tests/unit/reqstool/lsp/test_workspace_symbols.py diff --git a/src/reqstool/lsp/features/code_actions.py b/src/reqstool/lsp/features/code_actions.py new file mode 100644 index 00000000..619882f8 --- /dev/null +++ b/src/reqstool/lsp/features/code_actions.py @@ -0,0 +1,103 @@ +# Copyright © LFV + +from __future__ import annotations + +import re + +from lsprotocol import types + +from reqstool.lsp.annotation_parser import annotation_at_position +from reqstool.lsp.project_state import ProjectState + +# Patterns matching diagnostic messages from diagnostics.py +_UNKNOWN_REQ_RE = re.compile(r"Unknown requirement: (.+)") +_UNKNOWN_SVC_RE = re.compile(r"Unknown SVC: (.+)") +_LIFECYCLE_RE = re.compile(r"(Requirement|SVC) (.+) is (?:deprecated|obsolete)") + + +def handle_code_actions( + uri: str, + range_: types.Range, + context: types.CodeActionContext, + text: str, + language_id: str, + project: ProjectState | None, +) -> list[types.CodeAction]: + only = set(context.only) if context.only else None + actions = _actions_from_diagnostics(uri, context.diagnostics, only) + actions += _source_action(uri, range_, text, language_id, project, only) + return actions + + +def _actions_from_diagnostics( + uri: str, + diagnostics: list, + only: set | None, +) -> list[types.CodeAction]: + actions: list[types.CodeAction] = [] + if only is not None and types.CodeActionKind.QuickFix not in only: + return actions + for diag in diagnostics: + if diag.source != "reqstool": + continue + action = _action_from_message(diag.message, uri) + if action is not None: + actions.append(action) + return actions + + +def _action_from_message(msg: str, uri: str) -> types.CodeAction | None: + m = _UNKNOWN_REQ_RE.match(msg) + if m: + raw_id = m.group(1) + return _make_action(f"Open Details for {raw_id}", raw_id, uri, "requirement", types.CodeActionKind.QuickFix) + m = _UNKNOWN_SVC_RE.match(msg) + if m: + raw_id = m.group(1) + return _make_action(f"Open Details for {raw_id}", raw_id, uri, "svc", types.CodeActionKind.QuickFix) + m = _LIFECYCLE_RE.match(msg) + if m: + kind_label, raw_id = m.group(1), m.group(2) + item_type = "requirement" if kind_label == "Requirement" else "svc" + return _make_action(f"View details for {raw_id}", raw_id, uri, item_type, types.CodeActionKind.QuickFix) + return None + + +def _source_action( + uri: str, + range_: types.Range, + text: str, + language_id: str, + project: ProjectState | None, + only: set | None, +) -> list[types.CodeAction]: + if project is None or not project.ready: + return [] + if only is not None and types.CodeActionKind.Source not in only: + return [] + match = annotation_at_position(text, range_.start.line, range_.start.character, language_id) + if match is None: + return [] + item_type = "requirement" if match.kind == "Requirements" else "svc" + known = project.get_requirement(match.raw_id) if match.kind == "Requirements" else project.get_svc(match.raw_id) + if known is None: + return [] + return [_make_action("Open Details", match.raw_id, uri, item_type, types.CodeActionKind.Source)] + + +def _make_action( + title: str, + raw_id: str, + uri: str, + item_type: str, + kind: types.CodeActionKind, +) -> types.CodeAction: + return types.CodeAction( + title=title, + kind=kind, + command=types.Command( + title=title, + command="reqstool.openDetails", + arguments=[{"id": raw_id, "uri": uri, "type": item_type}], + ), + ) diff --git a/src/reqstool/lsp/features/codelens.py b/src/reqstool/lsp/features/codelens.py new file mode 100644 index 00000000..c7900e32 --- /dev/null +++ b/src/reqstool/lsp/features/codelens.py @@ -0,0 +1,93 @@ +# Copyright © LFV + +from __future__ import annotations + +from lsprotocol import types + +from reqstool.lsp.annotation_parser import find_all_annotations +from reqstool.lsp.project_state import ProjectState + + +def handle_code_lens( + uri: str, + text: str, + language_id: str, + project: ProjectState | None, +) -> list[types.CodeLens]: + if project is None or not project.ready: + return [] + + annotations = find_all_annotations(text, language_id) + if not annotations: + return [] + + # Group annotation matches by (line, kind) + by_line: dict[tuple[int, str], list[str]] = {} + for match in annotations: + key = (match.line, match.kind) + by_line.setdefault(key, []).append(match.raw_id) + + lines = text.splitlines() + result: list[types.CodeLens] = [] + + for (line_idx, kind), ids in by_line.items(): + line_len = len(lines[line_idx]) if line_idx < len(lines) else 0 + lens_range = types.Range( + start=types.Position(line=line_idx, character=0), + end=types.Position(line=line_idx, character=line_len), + ) + + if kind == "Requirements": + label = _req_label(ids, project) + item_type = "requirement" + else: + label = _svc_label(ids, project) + item_type = "svc" + + result.append( + types.CodeLens( + range=lens_range, + command=types.Command( + title=label, + command="reqstool.openDetails", + arguments=[{"id": ids[0], "uri": uri, "type": item_type}], + ), + ) + ) + + return result + + +def _req_label(ids: list[str], project: ProjectState) -> str: + all_svcs = [] + for raw_id in ids: + all_svcs.extend(project.get_svcs_for_req(raw_id)) + + pass_count = 0 + fail_count = 0 + for svc in all_svcs: + for mvr in project.get_mvrs_for_svc(svc.id.id): + if mvr.passed: + pass_count += 1 + else: + fail_count += 1 + + id_str = ", ".join(ids) + svc_count = len(all_svcs) + + if pass_count == 0 and fail_count == 0: + return f"{id_str}: {svc_count} SVCs" + return f"{id_str}: {svc_count} SVCs · {pass_count}✓ {fail_count}✗" + + +def _svc_label(ids: list[str], project: ProjectState) -> str: + id_str = ", ".join(ids) + if len(ids) == 1: + svc = project.get_svc(ids[0]) + if svc is not None: + mvrs = project.get_mvrs_for_svc(ids[0]) + if mvrs: + result = "pass" if all(m.passed for m in mvrs) else "fail" + return f"{id_str}: {svc.verification.value} · {result}" + return f"{id_str}: {svc.verification.value}" + return id_str diff --git a/src/reqstool/lsp/features/details.py b/src/reqstool/lsp/features/details.py new file mode 100644 index 00000000..7cd76850 --- /dev/null +++ b/src/reqstool/lsp/features/details.py @@ -0,0 +1,82 @@ +# Copyright © LFV + +from __future__ import annotations + +from reqstool.lsp.project_state import ProjectState + + +def get_requirement_details(raw_id: str, project: ProjectState) -> dict | None: + req = project.get_requirement(raw_id) + if req is None: + return None + svcs = project.get_svcs_for_req(raw_id) + return { + "type": "requirement", + "id": req.id.id, + "urn": str(req.id), + "title": req.title, + "significance": req.significance.value, + "description": req.description, + "rationale": req.rationale or "", + "revision": str(req.revision), + "lifecycle": { + "state": req.lifecycle.state.value, + "reason": req.lifecycle.reason or "", + }, + "categories": [c.value for c in req.categories], + "implementation": req.implementation.value, + "svcs": [ + { + "id": s.id.id, + "urn": str(s.id), + "title": s.title, + "verification": s.verification.value, + } + for s in svcs + ], + } + + +def get_svc_details(raw_id: str, project: ProjectState) -> dict | None: + svc = project.get_svc(raw_id) + if svc is None: + return None + mvrs = project.get_mvrs_for_svc(raw_id) + return { + "type": "svc", + "id": svc.id.id, + "urn": str(svc.id), + "title": svc.title, + "description": svc.description or "", + "verification": svc.verification.value, + "instructions": svc.instructions or "", + "revision": str(svc.revision), + "lifecycle": { + "state": svc.lifecycle.state.value, + "reason": svc.lifecycle.reason or "", + }, + "requirement_ids": [{"id": r.id, "urn": str(r)} for r in svc.requirement_ids], + "mvrs": [ + { + "id": m.id.id, + "urn": str(m.id), + "passed": m.passed, + "comment": m.comment or "", + } + for m in mvrs + ], + } + + +def get_mvr_details(raw_id: str, project: ProjectState) -> dict | None: + mvr = project.get_mvr(raw_id) + if mvr is None: + return None + return { + "type": "mvr", + "id": mvr.id.id, + "urn": str(mvr.id), + "passed": mvr.passed, + "comment": mvr.comment or "", + "svc_ids": [{"id": s.id, "urn": str(s)} for s in mvr.svc_ids], + } diff --git a/src/reqstool/lsp/features/hover.py b/src/reqstool/lsp/features/hover.py index 6ee4d4e7..8a234c3e 100644 --- a/src/reqstool/lsp/features/hover.py +++ b/src/reqstool/lsp/features/hover.py @@ -2,8 +2,10 @@ from __future__ import annotations +import json import os import re +import urllib.parse from lsprotocol import types @@ -31,10 +33,11 @@ def handle_hover( if basename in REQSTOOL_YAML_FILES: return _hover_yaml(text, position, basename) else: - return _hover_source(text, position, language_id, project) + return _hover_source(uri, text, position, language_id, project) def _hover_source( + uri: str, text: str, position: types.Position, language_id: str, @@ -57,14 +60,19 @@ def _hover_source( ) if match.kind == "Requirements": - return _hover_requirement(match.raw_id, match, project) + return _hover_requirement(match.raw_id, match, project, uri) elif match.kind == "SVCs": - return _hover_svc(match.raw_id, match, project) + return _hover_svc(match.raw_id, match, project, uri) return None -def _hover_requirement(raw_id: str, match, project: ProjectState) -> types.Hover | None: +def _open_details_link(raw_id: str, uri: str, kind: str) -> str: + args = urllib.parse.quote(json.dumps({"id": raw_id, "uri": uri, "type": kind})) + return f"[Open Details](command:reqstool.openDetails?{args})" + + +def _hover_requirement(raw_id: str, match, project: ProjectState, uri: str) -> types.Hover | None: req = project.get_requirement(raw_id) if req is None: md = f"**Unknown requirement**: `{raw_id}`" @@ -87,6 +95,8 @@ def _hover_requirement(raw_id: str, match, project: ProjectState) -> types.Hover f"**Categories**: {categories}", f"**Lifecycle**: {req.lifecycle.state.value}", f"**SVCs**: {svc_ids}", + "---", + _open_details_link(raw_id, uri, "requirement"), ] ) md = "\n\n".join(parts) @@ -100,7 +110,7 @@ def _hover_requirement(raw_id: str, match, project: ProjectState) -> types.Hover ) -def _hover_svc(raw_id: str, match, project: ProjectState) -> types.Hover | None: +def _hover_svc(raw_id: str, match, project: ProjectState, uri: str) -> types.Hover | None: svc = project.get_svc(raw_id) if svc is None: md = f"**Unknown SVC**: `{raw_id}`" @@ -125,6 +135,8 @@ def _hover_svc(raw_id: str, match, project: ProjectState) -> types.Hover | None: f"**Lifecycle**: {svc.lifecycle.state.value}", f"**Requirements**: {req_ids}", f"**MVRs**: {mvr_info}", + "---", + _open_details_link(raw_id, uri, "svc"), ] ) md = "\n\n".join(parts) diff --git a/src/reqstool/lsp/features/inlay_hints.py b/src/reqstool/lsp/features/inlay_hints.py new file mode 100644 index 00000000..50a04995 --- /dev/null +++ b/src/reqstool/lsp/features/inlay_hints.py @@ -0,0 +1,47 @@ +# Copyright © LFV + +from __future__ import annotations + +from lsprotocol import types + +from reqstool.lsp.annotation_parser import find_all_annotations +from reqstool.lsp.project_state import ProjectState + + +def handle_inlay_hints( + uri: str, + range_: types.Range, + text: str, + language_id: str, + project: ProjectState | None, +) -> list[types.InlayHint]: + if project is None or not project.ready: + return [] + + annotations = find_all_annotations(text, language_id) + result: list[types.InlayHint] = [] + + for match in annotations: + if match.line < range_.start.line or match.line > range_.end.line: + continue + + if match.kind == "Requirements": + item = project.get_requirement(match.raw_id) + title = item.title if item is not None else None + else: + item = project.get_svc(match.raw_id) + title = item.title if item is not None else None + + if title is None: + continue + + result.append( + types.InlayHint( + position=types.Position(line=match.line, character=match.end_col), + label=f" \u2190 {title}", + kind=types.InlayHintKind.Type, + padding_left=True, + ) + ) + + return result diff --git a/src/reqstool/lsp/features/references.py b/src/reqstool/lsp/features/references.py new file mode 100644 index 00000000..f157062e --- /dev/null +++ b/src/reqstool/lsp/features/references.py @@ -0,0 +1,147 @@ +# Copyright © LFV + +from __future__ import annotations + +import os +import re +from pathlib import Path + +from lsprotocol import types + +from reqstool.lsp.annotation_parser import annotation_at_position, find_all_annotations +from reqstool.lsp.project_state import ProjectState + +REQSTOOL_YAML_FILES = { + "requirements.yml", + "software_verification_cases.yml", + "manual_verification_results.yml", +} + +# Matches bare IDs like REQ_010 or SVC_010 as a whole word +_ID_RE_CACHE: dict[str, re.Pattern] = {} + + +def _id_pattern(raw_id: str) -> re.Pattern: + bare = raw_id.split(":")[-1] + if bare not in _ID_RE_CACHE: + _ID_RE_CACHE[bare] = re.compile(r"\b" + re.escape(bare) + r"\b") + return _ID_RE_CACHE[bare] + + +def handle_references( + uri: str, + position: types.Position, + text: str, + language_id: str, + project: ProjectState | None, + include_declaration: bool, + workspace_text_documents: dict, +) -> list[types.Location]: + if project is None or not project.ready: + return [] + + raw_id = _resolve_id_at_position(uri, position, text, language_id) + if not raw_id: + return [] + + pattern = _id_pattern(raw_id) + locations: list[types.Location] = [] + seen_uris: set[str] = set() + + _search_open_documents(workspace_text_documents, raw_id, pattern, include_declaration, locations, seen_uris) + _search_project_yaml_files(project, pattern, include_declaration, locations, seen_uris) + + return locations + + +def _search_open_documents( + workspace_text_documents: dict, + raw_id: str, + pattern: re.Pattern, + include_declaration: bool, + locations: list[types.Location], + seen_uris: set[str], +) -> None: + bare_search = raw_id.split(":")[-1] + for doc_uri, doc in workspace_text_documents.items(): + seen_uris.add(doc_uri) + basename = os.path.basename(doc_uri) + if basename in REQSTOOL_YAML_FILES: + _search_yaml_text(doc_uri, doc.source, pattern, include_declaration, locations) + else: + lang = getattr(doc, "language_id", None) or "" + for ann in find_all_annotations(doc.source, lang): + if ann.raw_id.split(":")[-1] == bare_search: + locations.append( + types.Location( + uri=doc_uri, + range=types.Range( + start=types.Position(line=ann.line, character=ann.start_col), + end=types.Position(line=ann.line, character=ann.end_col), + ), + ) + ) + + +def _search_project_yaml_files( + project: ProjectState, + pattern: re.Pattern, + include_declaration: bool, + locations: list[types.Location], + seen_uris: set[str], +) -> None: + for urn_paths in project.get_yaml_paths().values(): + for file_type, path in urn_paths.items(): + if file_type not in ("requirements", "svcs", "mvrs"): + continue + if not path or not os.path.isfile(path): + continue + file_uri = Path(path).as_uri() + if file_uri in seen_uris: + continue + seen_uris.add(file_uri) + try: + with open(path, encoding="utf-8") as f: + content = f.read() + _search_yaml_text(file_uri, content, pattern, include_declaration, locations) + except OSError: + pass + + +def _resolve_id_at_position(uri: str, position: types.Position, text: str, language_id: str) -> str | None: + basename = os.path.basename(uri) + if basename in REQSTOOL_YAML_FILES: + lines = text.splitlines() + if position.line < len(lines): + m = re.match(r"^\s*-?\s*id:\s*(\S+)", lines[position.line]) + if m: + return m.group(1) + return None + match = annotation_at_position(text, position.line, position.character, language_id) + return match.raw_id if match else None + + +def _search_yaml_text( + file_uri: str, + content: str, + pattern: re.Pattern, + include_declaration: bool, + locations: list[types.Location], +) -> None: + for line_idx, line in enumerate(content.splitlines()): + m = pattern.search(line) + if not m: + continue + is_decl = bool(re.match(r"^\s*-?\s*id:\s*", line)) + if is_decl and not include_declaration: + continue + col = m.start() + locations.append( + types.Location( + uri=file_uri, + range=types.Range( + start=types.Position(line=line_idx, character=col), + end=types.Position(line=line_idx, character=col + len(m.group(0))), + ), + ) + ) diff --git a/src/reqstool/lsp/features/semantic_tokens.py b/src/reqstool/lsp/features/semantic_tokens.py new file mode 100644 index 00000000..748366ff --- /dev/null +++ b/src/reqstool/lsp/features/semantic_tokens.py @@ -0,0 +1,61 @@ +# Copyright © LFV + +from __future__ import annotations + +from lsprotocol import types + +from reqstool.common.models.lifecycle import LIFECYCLESTATE +from reqstool.lsp.annotation_parser import find_all_annotations +from reqstool.lsp.project_state import ProjectState + +TOKEN_TYPES = ["reqstoolValid", "reqstoolDeprecated", "reqstoolObsolete"] +_STATE_TO_IDX = { + LIFECYCLESTATE.EFFECTIVE: 0, + LIFECYCLESTATE.DRAFT: 0, + LIFECYCLESTATE.DEPRECATED: 1, + LIFECYCLESTATE.OBSOLETE: 2, +} + +SEMANTIC_TOKENS_OPTIONS = types.SemanticTokensOptions( + legend=types.SemanticTokensLegend(token_types=TOKEN_TYPES, token_modifiers=[]), + full=True, +) + + +def _encode_tokens(tokens: list[tuple[int, int, int, int]]) -> list[int]: + """Encode (line, start_col, length, type_idx) tuples into LSP delta-compressed integers.""" + data: list[int] = [] + prev_line, prev_start = 0, 0 + for line, start, length, type_idx in sorted(tokens): + delta_line = line - prev_line + delta_start = start - prev_start if delta_line == 0 else start + data.extend([delta_line, delta_start, length, type_idx, 0]) + prev_line, prev_start = line, start + return data + + +def handle_semantic_tokens( + uri: str, + text: str, + language_id: str, + project: ProjectState | None, +) -> types.SemanticTokens: + if project is None or not project.ready: + return types.SemanticTokens(data=[]) + + annotations = find_all_annotations(text, language_id) + tokens: list[tuple[int, int, int, int]] = [] + + for match in annotations: + if match.kind == "Requirements": + item = project.get_requirement(match.raw_id) + state = item.lifecycle.state if item is not None else LIFECYCLESTATE.EFFECTIVE + else: + item = project.get_svc(match.raw_id) + state = item.lifecycle.state if item is not None else LIFECYCLESTATE.EFFECTIVE + + type_idx = _STATE_TO_IDX.get(state, 0) + length = match.end_col - match.start_col + tokens.append((match.line, match.start_col, length, type_idx)) + + return types.SemanticTokens(data=_encode_tokens(tokens)) diff --git a/src/reqstool/lsp/features/workspace_symbols.py b/src/reqstool/lsp/features/workspace_symbols.py new file mode 100644 index 00000000..7cb8c00a --- /dev/null +++ b/src/reqstool/lsp/features/workspace_symbols.py @@ -0,0 +1,91 @@ +# Copyright © LFV + +from __future__ import annotations + +import os +import re +from pathlib import Path + +from lsprotocol import types + + +def handle_workspace_symbols( + query: str, + workspace_manager, +) -> list[types.WorkspaceSymbol]: + results: list[types.WorkspaceSymbol] = [] + query_lower = query.lower() + + for project in workspace_manager.all_projects(): + if not project.ready: + continue + + initial_urn = project.get_initial_urn() or "" + + for req_id in project.get_all_requirement_ids(): + req = project.get_requirement(req_id) + if req is None: + continue + if query_lower and query_lower not in req_id.lower() and query_lower not in req.title.lower(): + continue + name = f"{req_id} \u2014 {req.title}" + yaml_path = project.get_yaml_path(req.id.urn or initial_urn, "requirements") + location = _make_location(yaml_path, req_id) + results.append( + types.WorkspaceSymbol( + name=name, + kind=types.SymbolKind.Key, + location=location, + ) + ) + + for svc_id in project.get_all_svc_ids(): + svc = project.get_svc(svc_id) + if svc is None: + continue + if query_lower and query_lower not in svc_id.lower() and query_lower not in svc.title.lower(): + continue + name = f"{svc_id} \u2014 {svc.title}" + yaml_path = project.get_yaml_path(svc.id.urn or initial_urn, "svcs") + location = _make_location(yaml_path, svc_id) + results.append( + types.WorkspaceSymbol( + name=name, + kind=types.SymbolKind.Key, + location=location, + ) + ) + + return results + + +def _make_location(yaml_path: str | None, bare_id: str) -> types.Location: + if yaml_path and os.path.isfile(yaml_path): + line = _find_id_line(yaml_path, bare_id) + uri = Path(yaml_path).as_uri() + return types.Location( + uri=uri, + range=types.Range( + start=types.Position(line=line, character=0), + end=types.Position(line=line, character=len(bare_id) + 4), + ), + ) + return types.Location( + uri="", + range=types.Range( + start=types.Position(line=0, character=0), + end=types.Position(line=0, character=0), + ), + ) + + +def _find_id_line(path: str, bare_id: str) -> int: + pattern = re.compile(r"^\s*-?\s*id:\s*" + re.escape(bare_id) + r"\s*$") + try: + with open(path, encoding="utf-8") as f: + for idx, line in enumerate(f): + if pattern.match(line): + return idx + except OSError: + pass + return 0 diff --git a/src/reqstool/lsp/project_state.py b/src/reqstool/lsp/project_state.py index a1995f52..aabe9eb6 100644 --- a/src/reqstool/lsp/project_state.py +++ b/src/reqstool/lsp/project_state.py @@ -129,11 +129,22 @@ def get_all_requirement_ids(self) -> list[str]: return [] return [uid.id for uid in self._repo.get_all_requirements()] + def get_mvr(self, raw_id: str) -> MVRData | None: + if not self._ready or self._repo is None: + return None + initial_urn = self._repo.get_initial_urn() + urn_id = UrnId.assure_urn_id(initial_urn, raw_id) + return self._repo.get_all_mvrs().get(urn_id) + def get_all_svc_ids(self) -> list[str]: if not self._ready or self._repo is None: return [] return [uid.id for uid in self._repo.get_all_svcs()] + def get_yaml_paths(self) -> dict[str, dict[str, str]]: + """Return all URN → file_type → path mappings.""" + return dict(self._urn_source_paths) + def get_yaml_path(self, urn: str, file_type: str) -> str | None: """Return the resolved file path for a given URN and file type (requirements, svcs, mvrs, annotations).""" urn_paths = self._urn_source_paths.get(urn) diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index 08850a29..430cf5f1 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -7,11 +7,18 @@ from lsprotocol import types from pygls.lsp.server import LanguageServer +from reqstool.lsp.features.code_actions import handle_code_actions +from reqstool.lsp.features.codelens import handle_code_lens from reqstool.lsp.features.completion import handle_completion from reqstool.lsp.features.definition import handle_definition +from reqstool.lsp.features.details import get_mvr_details, get_requirement_details, get_svc_details from reqstool.lsp.features.diagnostics import compute_diagnostics from reqstool.lsp.features.document_symbols import handle_document_symbols from reqstool.lsp.features.hover import handle_hover +from reqstool.lsp.features.inlay_hints import handle_inlay_hints +from reqstool.lsp.features.references import handle_references +from reqstool.lsp.features.semantic_tokens import SEMANTIC_TOKENS_OPTIONS, handle_semantic_tokens +from reqstool.lsp.features.workspace_symbols import handle_workspace_symbols from reqstool.lsp.workspace_manager import WorkspaceManager logger = logging.getLogger(__name__) @@ -173,6 +180,118 @@ def on_document_symbol(ls: ReqstoolLanguageServer, params: types.DocumentSymbolP ) +# -- Shared helpers -- + + +def _get(params, key: str, default=""): + """Extract a field from dict or object params uniformly.""" + return params.get(key, default) if isinstance(params, dict) else getattr(params, key, default) + + +def _first_project(ls: ReqstoolLanguageServer): + """Fallback: return first available ready project across all workspace folders.""" + projects = ls.workspace_manager.all_projects() + return projects[0] if projects else None + + +_DETAILS_DISPATCH = { + "requirement": get_requirement_details, + "svc": get_svc_details, + "mvr": get_mvr_details, +} + + +# -- New feature handlers -- + + +@server.feature("reqstool/details") +def on_details(ls: ReqstoolLanguageServer, params) -> dict | None: + uri = _get(params, "uri") + raw_id = _get(params, "id") + kind = _get(params, "type") + fn = _DETAILS_DISPATCH.get(kind) + if not fn: + return None + project = ls.workspace_manager.project_for_file(uri) or _first_project(ls) + if not project or not project.ready: + return None + return fn(raw_id, project) + + +@server.feature(types.TEXT_DOCUMENT_CODE_LENS, types.CodeLensOptions(resolve_provider=False)) +def on_code_lens(ls: ReqstoolLanguageServer, params: types.CodeLensParams) -> list[types.CodeLens]: + document = ls.workspace.get_text_document(params.text_document.uri) + project = ls.workspace_manager.project_for_file(params.text_document.uri) + return handle_code_lens( + uri=params.text_document.uri, + text=document.source, + language_id=document.language_id or "", + project=project, + ) + + +@server.feature(types.TEXT_DOCUMENT_INLAY_HINT, types.InlayHintOptions(resolve_provider=False)) +def on_inlay_hint(ls: ReqstoolLanguageServer, params: types.InlayHintParams) -> list[types.InlayHint]: + document = ls.workspace.get_text_document(params.text_document.uri) + project = ls.workspace_manager.project_for_file(params.text_document.uri) + return handle_inlay_hints( + uri=params.text_document.uri, + range_=params.range, + text=document.source, + language_id=document.language_id or "", + project=project, + ) + + +@server.feature(types.TEXT_DOCUMENT_REFERENCES) +def on_references(ls: ReqstoolLanguageServer, params: types.ReferenceParams) -> list[types.Location]: + document = ls.workspace.get_text_document(params.text_document.uri) + project = ls.workspace_manager.project_for_file(params.text_document.uri) + return handle_references( + uri=params.text_document.uri, + position=params.position, + text=document.source, + language_id=document.language_id or "", + project=project, + include_declaration=params.context.include_declaration, + workspace_text_documents=ls.workspace.text_documents, + ) + + +@server.feature(types.WORKSPACE_SYMBOL) +def on_workspace_symbol(ls: ReqstoolLanguageServer, params: types.WorkspaceSymbolParams) -> list[types.WorkspaceSymbol]: + return handle_workspace_symbols(params.query, ls.workspace_manager) + + +@server.feature(types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL, SEMANTIC_TOKENS_OPTIONS) +def on_semantic_tokens(ls: ReqstoolLanguageServer, params: types.SemanticTokensParams) -> types.SemanticTokens: + document = ls.workspace.get_text_document(params.text_document.uri) + project = ls.workspace_manager.project_for_file(params.text_document.uri) + return handle_semantic_tokens( + uri=params.text_document.uri, + text=document.source, + language_id=document.language_id or "", + project=project, + ) + + +@server.feature( + types.TEXT_DOCUMENT_CODE_ACTION, + types.CodeActionOptions(code_action_kinds=[types.CodeActionKind.QuickFix, types.CodeActionKind.Source]), +) +def on_code_action(ls: ReqstoolLanguageServer, params: types.CodeActionParams) -> list[types.CodeAction]: + document = ls.workspace.get_text_document(params.text_document.uri) + project = ls.workspace_manager.project_for_file(params.text_document.uri) + return handle_code_actions( + uri=params.text_document.uri, + range_=params.range, + context=params.context, + text=document.source, + language_id=document.language_id or "", + project=project, + ) + + # -- Internal helpers -- diff --git a/tests/unit/reqstool/lsp/test_code_actions.py b/tests/unit/reqstool/lsp/test_code_actions.py new file mode 100644 index 00000000..2045334f --- /dev/null +++ b/tests/unit/reqstool/lsp/test_code_actions.py @@ -0,0 +1,123 @@ +# Copyright © LFV + +import pytest +from lsprotocol import types + +from reqstool.lsp.features.code_actions import handle_code_actions +from reqstool.lsp.project_state import ProjectState + +URI = "file:///test.py" +EMPTY_RANGE = types.Range( + start=types.Position(line=0, character=0), + end=types.Position(line=0, character=0), +) + + +def _context(diagnostics=None, only=None): + return types.CodeActionContext( + diagnostics=diagnostics or [], + only=only, + ) + + +def test_code_actions_no_diagnostics_no_annotation(): + result = handle_code_actions(URI, EMPTY_RANGE, _context(), "def foo(): pass", "python", None) + assert result == [] + + +def test_code_actions_unknown_requirement_diagnostic(): + diag = types.Diagnostic( + range=EMPTY_RANGE, + severity=types.DiagnosticSeverity.Error, + source="reqstool", + message="Unknown requirement: REQ_UNKNOWN", + ) + result = handle_code_actions(URI, EMPTY_RANGE, _context([diag]), "", "python", None) + assert len(result) == 1 + assert result[0].kind == types.CodeActionKind.QuickFix + assert "REQ_UNKNOWN" in result[0].title + assert result[0].command.command == "reqstool.openDetails" + assert result[0].command.arguments[0]["type"] == "requirement" + + +def test_code_actions_unknown_svc_diagnostic(): + diag = types.Diagnostic( + range=EMPTY_RANGE, + severity=types.DiagnosticSeverity.Error, + source="reqstool", + message="Unknown SVC: SVC_UNKNOWN", + ) + result = handle_code_actions(URI, EMPTY_RANGE, _context([diag]), "", "python", None) + assert len(result) == 1 + assert result[0].command.arguments[0]["type"] == "svc" + + +def test_code_actions_deprecated_diagnostic(): + diag = types.Diagnostic( + range=EMPTY_RANGE, + severity=types.DiagnosticSeverity.Warning, + source="reqstool", + message="Requirement REQ_010 is deprecated: old", + ) + result = handle_code_actions(URI, EMPTY_RANGE, _context([diag]), "", "python", None) + assert len(result) == 1 + assert result[0].kind == types.CodeActionKind.QuickFix + assert "REQ_010" in result[0].title + + +def test_code_actions_ignores_non_reqstool_diagnostics(): + diag = types.Diagnostic( + range=EMPTY_RANGE, + severity=types.DiagnosticSeverity.Error, + source="pylint", + message="Unknown requirement: REQ_010", + ) + result = handle_code_actions(URI, EMPTY_RANGE, _context([diag]), "", "python", None) + assert result == [] + + +def test_code_actions_only_quickfix_filter(): + diag = types.Diagnostic( + range=EMPTY_RANGE, + severity=types.DiagnosticSeverity.Error, + source="reqstool", + message="Unknown requirement: REQ_UNKNOWN", + ) + result = handle_code_actions( + URI, EMPTY_RANGE, _context([diag], only=[types.CodeActionKind.QuickFix]), "", "python", None + ) + assert all(a.kind == types.CodeActionKind.QuickFix for a in result) + + +@pytest.fixture +def project(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + state.build() + yield state + state.close() + + +def test_code_actions_source_action_on_known_id(project): + text = '@Requirements("REQ_010")\ndef foo(): pass' + cursor_range = types.Range( + start=types.Position(line=0, character=17), + end=types.Position(line=0, character=24), + ) + result = handle_code_actions(URI, cursor_range, _context(), text, "python", project) + source_actions = [a for a in result if a.kind == types.CodeActionKind.Source] + assert source_actions + assert source_actions[0].command.arguments[0]["type"] == "requirement" + + +def test_code_actions_source_action_only_filter(project): + text = '@Requirements("REQ_010")\ndef foo(): pass' + cursor_range = types.Range( + start=types.Position(line=0, character=17), + end=types.Position(line=0, character=24), + ) + result = handle_code_actions( + URI, cursor_range, _context(only=[types.CodeActionKind.QuickFix]), text, "python", project + ) + # Source actions should be filtered out + assert all(a.kind != types.CodeActionKind.Source for a in result) diff --git a/tests/unit/reqstool/lsp/test_codelens.py b/tests/unit/reqstool/lsp/test_codelens.py new file mode 100644 index 00000000..8394c3dc --- /dev/null +++ b/tests/unit/reqstool/lsp/test_codelens.py @@ -0,0 +1,67 @@ +# Copyright © LFV + +import pytest + +from reqstool.lsp.features.codelens import handle_code_lens +from reqstool.lsp.project_state import ProjectState + +URI = "file:///test.py" + + +def test_codelens_no_project(): + text = '@Requirements("REQ_010")\ndef foo(): pass' + result = handle_code_lens(URI, text, "python", None) + assert result == [] + + +def test_codelens_project_not_ready(): + state = ProjectState(reqstool_path="/nonexistent") + result = handle_code_lens(URI, '@Requirements("REQ_010")', "python", state) + assert result == [] + + +def test_codelens_no_annotations(): + result = handle_code_lens(URI, "def foo(): pass", "python", None) + assert result == [] + + +@pytest.fixture +def project(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + state.build() + yield state + state.close() + + +def test_codelens_requirement_annotation(project): + text = '@Requirements("REQ_010")\ndef foo(): pass' + result = handle_code_lens(URI, text, "python", project) + assert len(result) == 1 + lens = result[0] + assert lens.command is not None + assert "REQ_010" in lens.command.title + assert lens.command.command == "reqstool.openDetails" + assert lens.command.arguments[0]["type"] == "requirement" + + +def test_codelens_svc_annotation(project): + svc_ids = project.get_all_svc_ids() + assert svc_ids + text = f'@SVCs("{svc_ids[0]}")\ndef test_foo(): pass' + result = handle_code_lens(URI, text, "python", project) + assert len(result) == 1 + lens = result[0] + assert svc_ids[0] in lens.command.title + assert lens.command.arguments[0]["type"] == "svc" + + +def test_codelens_multiple_ids_same_line(project): + req_ids = project.get_all_requirement_ids() + assert len(req_ids) >= 2 + text = f'@Requirements("{req_ids[0]}", "{req_ids[1]}")\ndef foo(): pass' + result = handle_code_lens(URI, text, "python", project) + # Both IDs on same line → one lens + assert len(result) == 1 + assert req_ids[0] in result[0].command.title + assert req_ids[1] in result[0].command.title diff --git a/tests/unit/reqstool/lsp/test_details.py b/tests/unit/reqstool/lsp/test_details.py new file mode 100644 index 00000000..095fe592 --- /dev/null +++ b/tests/unit/reqstool/lsp/test_details.py @@ -0,0 +1,66 @@ +# Copyright © LFV + +import pytest + +from reqstool.lsp.features.details import get_mvr_details, get_requirement_details, get_svc_details +from reqstool.lsp.project_state import ProjectState + + +@pytest.fixture +def project(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + state.build() + yield state + state.close() + + +def test_get_requirement_details_known(project): + result = get_requirement_details("REQ_010", project) + assert result is not None + assert result["type"] == "requirement" + assert result["id"] == "REQ_010" + assert "title" in result + assert "significance" in result + assert "description" in result + assert "lifecycle" in result + assert "svcs" in result + assert isinstance(result["svcs"], list) + + +def test_get_requirement_details_unknown(project): + result = get_requirement_details("REQ_NONEXISTENT", project) + assert result is None + + +def test_get_svc_details_known(project): + svc_ids = project.get_all_svc_ids() + assert svc_ids, "No SVCs in test fixture" + result = get_svc_details(svc_ids[0], project) + assert result is not None + assert result["type"] == "svc" + assert result["id"] == svc_ids[0] + assert "title" in result + assert "verification" in result + assert "lifecycle" in result + assert "requirement_ids" in result + assert "mvrs" in result + + +def test_get_svc_details_unknown(project): + result = get_svc_details("SVC_NONEXISTENT", project) + assert result is None + + +def test_get_mvr_details_unknown(project): + # No MVRs in the test_standard fixture; get_mvr should return None + result = get_mvr_details("MVR_NONEXISTENT", project) + assert result is None + + +def test_get_requirement_details_fields(project): + result = get_requirement_details("REQ_010", project) + assert result is not None + assert result["urn"].endswith(":REQ_010") + assert result["lifecycle"]["state"] in ("draft", "effective", "deprecated", "obsolete") + assert isinstance(result["categories"], list) diff --git a/tests/unit/reqstool/lsp/test_inlay_hints.py b/tests/unit/reqstool/lsp/test_inlay_hints.py new file mode 100644 index 00000000..ce6944f7 --- /dev/null +++ b/tests/unit/reqstool/lsp/test_inlay_hints.py @@ -0,0 +1,66 @@ +# Copyright © LFV + +import pytest +from lsprotocol import types + +from reqstool.lsp.features.inlay_hints import handle_inlay_hints +from reqstool.lsp.project_state import ProjectState + +URI = "file:///test.py" +FULL_RANGE = types.Range( + start=types.Position(line=0, character=0), + end=types.Position(line=999, character=0), +) + + +def test_inlay_hints_no_project(): + text = '@Requirements("REQ_010")\ndef foo(): pass' + result = handle_inlay_hints(URI, FULL_RANGE, text, "python", None) + assert result == [] + + +def test_inlay_hints_project_not_ready(): + state = ProjectState(reqstool_path="/nonexistent") + result = handle_inlay_hints(URI, FULL_RANGE, '@Requirements("REQ_010")', "python", state) + assert result == [] + + +def test_inlay_hints_no_annotations(): + result = handle_inlay_hints(URI, FULL_RANGE, "def foo(): pass", "python", None) + assert result == [] + + +@pytest.fixture +def project(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + state.build() + yield state + state.close() + + +def test_inlay_hints_known_id(project): + text = '@Requirements("REQ_010")\ndef foo(): pass' + result = handle_inlay_hints(URI, FULL_RANGE, text, "python", project) + assert len(result) == 1 + hint = result[0] + assert "\u2190" in hint.label + assert hint.kind == types.InlayHintKind.Type + + +def test_inlay_hints_unknown_id_skipped(project): + text = '@Requirements("REQ_NONEXISTENT")\ndef foo(): pass' + result = handle_inlay_hints(URI, FULL_RANGE, text, "python", project) + assert result == [] + + +def test_inlay_hints_range_filter(project): + text = '@Requirements("REQ_010")\ndef foo(): pass\n@Requirements("REQ_010")\ndef bar(): pass' + narrow_range = types.Range( + start=types.Position(line=2, character=0), + end=types.Position(line=3, character=0), + ) + result = handle_inlay_hints(URI, narrow_range, text, "python", project) + # Only the annotation on line 2 is within range + assert len(result) == 1 + assert result[0].position.line == 2 diff --git a/tests/unit/reqstool/lsp/test_references.py b/tests/unit/reqstool/lsp/test_references.py new file mode 100644 index 00000000..d949682a --- /dev/null +++ b/tests/unit/reqstool/lsp/test_references.py @@ -0,0 +1,63 @@ +# Copyright © LFV + +import pytest +from lsprotocol import types + +from reqstool.lsp.features.references import handle_references +from reqstool.lsp.project_state import ProjectState + +URI = "file:///test.py" + + +def _make_doc(source, language_id="python"): + class _Doc: + pass + + doc = _Doc() + doc.source = source + doc.language_id = language_id + return doc + + +def test_references_no_project(): + result = handle_references( + URI, types.Position(line=0, character=17), '@Requirements("REQ_010")', "python", None, True, {} + ) + assert result == [] + + +def test_references_no_id_at_cursor(): + result = handle_references(URI, types.Position(line=0, character=0), "def foo(): pass", "python", None, True, {}) + assert result == [] + + +@pytest.fixture +def project(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + state.build() + yield state + state.close() + + +def test_references_finds_open_document(project): + text = '@Requirements("REQ_010")\ndef foo(): pass' + open_docs = {URI: _make_doc(text)} + result = handle_references(URI, types.Position(line=0, character=17), text, "python", project, True, open_docs) + assert any(loc.uri == URI for loc in result) + + +def test_references_finds_yaml_files(project): + # Cursor on REQ_010 in source; YAML files for the project should also be searched + text = '@Requirements("REQ_010")\ndef foo(): pass' + result = handle_references(URI, types.Position(line=0, character=17), text, "python", project, True, {}) + yaml_locations = [loc for loc in result if loc.uri.endswith(".yml")] + assert yaml_locations, "Expected at least one YAML reference location" + + +def test_references_exclude_declaration(project): + text = '@Requirements("REQ_010")\ndef foo(): pass' + with_decl = handle_references(URI, types.Position(line=0, character=17), text, "python", project, True, {}) + without_decl = handle_references(URI, types.Position(line=0, character=17), text, "python", project, False, {}) + # Excluding declarations should produce fewer or equal results + assert len(without_decl) <= len(with_decl) diff --git a/tests/unit/reqstool/lsp/test_semantic_tokens.py b/tests/unit/reqstool/lsp/test_semantic_tokens.py new file mode 100644 index 00000000..01701c8e --- /dev/null +++ b/tests/unit/reqstool/lsp/test_semantic_tokens.py @@ -0,0 +1,75 @@ +# Copyright © LFV + +import pytest + +from reqstool.lsp.features.semantic_tokens import TOKEN_TYPES, _encode_tokens, handle_semantic_tokens +from reqstool.lsp.project_state import ProjectState + +URI = "file:///test.py" + + +def test_encode_tokens_empty(): + assert _encode_tokens([]) == [] + + +def test_encode_tokens_single(): + data = _encode_tokens([(3, 5, 6, 1)]) + assert data == [3, 5, 6, 1, 0] + + +def test_encode_tokens_same_line(): + # Two tokens on the same line: delta_start is relative to previous token start + data = _encode_tokens([(1, 2, 4, 0), (1, 10, 6, 1)]) + assert data == [1, 2, 4, 0, 0, 0, 8, 6, 1, 0] + + +def test_encode_tokens_different_lines(): + data = _encode_tokens([(0, 5, 3, 0), (2, 7, 4, 1)]) + assert data == [0, 5, 3, 0, 0, 2, 7, 4, 1, 0] + + +def test_encode_tokens_sorted(): + # Input out of order — must be sorted by line then col + data = _encode_tokens([(2, 0, 3, 0), (0, 0, 3, 1)]) + assert data[0] == 0 # first token is line 0 + assert data[5] == 2 # second token delta line is 2 + + +def test_token_types_count(): + assert len(TOKEN_TYPES) == 3 + + +def test_semantic_tokens_no_project(): + result = handle_semantic_tokens(URI, '@Requirements("REQ_010")', "python", None) + assert result.data == [] + + +def test_semantic_tokens_project_not_ready(): + state = ProjectState(reqstool_path="/nonexistent") + result = handle_semantic_tokens(URI, '@Requirements("REQ_010")', "python", state) + assert result.data == [] + + +@pytest.fixture +def project(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + state.build() + yield state + state.close() + + +def test_semantic_tokens_known_id(project): + text = '@Requirements("REQ_010")\ndef foo(): pass' + result = handle_semantic_tokens(URI, text, "python", project) + # Should produce 5 integers per token + assert len(result.data) % 5 == 0 + assert len(result.data) >= 5 + + +def test_semantic_tokens_unknown_id(project): + text = '@Requirements("REQ_NONEXISTENT")\ndef foo(): pass' + result = handle_semantic_tokens(URI, text, "python", project) + # Unknown IDs get type_idx 0 (effective fallback) + assert len(result.data) % 5 == 0 + assert len(result.data) >= 5 diff --git a/tests/unit/reqstool/lsp/test_workspace_symbols.py b/tests/unit/reqstool/lsp/test_workspace_symbols.py new file mode 100644 index 00000000..a62053be --- /dev/null +++ b/tests/unit/reqstool/lsp/test_workspace_symbols.py @@ -0,0 +1,63 @@ +# Copyright © LFV + +import pytest + +from reqstool.lsp.features.workspace_symbols import handle_workspace_symbols +from reqstool.lsp.project_state import ProjectState + + +class _MockWorkspaceManager: + def __init__(self, projects): + self._projects = projects + + def all_projects(self): + return self._projects + + +def test_workspace_symbols_empty_workspace(): + manager = _MockWorkspaceManager([]) + result = handle_workspace_symbols("", manager) + assert result == [] + + +def test_workspace_symbols_project_not_ready(): + state = ProjectState(reqstool_path="/nonexistent") + manager = _MockWorkspaceManager([state]) + result = handle_workspace_symbols("", manager) + assert result == [] + + +@pytest.fixture +def project(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + state.build() + yield state + state.close() + + +def test_workspace_symbols_empty_query_returns_all(project): + manager = _MockWorkspaceManager([project]) + result = handle_workspace_symbols("", manager) + ids = [s.name.split(" \u2014 ")[0] for s in result] + assert any(i.startswith("REQ_") for i in ids) + assert any(i.startswith("SVC_") for i in ids) + + +def test_workspace_symbols_query_filters(project): + manager = _MockWorkspaceManager([project]) + result = handle_workspace_symbols("REQ_010", manager) + assert all("REQ_010" in s.name for s in result) + + +def test_workspace_symbols_query_no_match(project): + manager = _MockWorkspaceManager([project]) + result = handle_workspace_symbols("ZZZNOMATCH", manager) + assert result == [] + + +def test_workspace_symbols_name_format(project): + manager = _MockWorkspaceManager([project]) + result = handle_workspace_symbols("REQ_010", manager) + assert result + assert " \u2014 " in result[0].name From bb564dd5f8fec7a31c73e1ccd020eff428719273 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Wed, 18 Mar 2026 23:27:41 +0100 Subject: [PATCH 21/37] feat: enrich details response with implementations, test_results, and references (#314) - get_requirement_details: adds `references` (cross-refs) and `implementations` (annotation impls) - get_svc_details: adds `test_annotations` and `test_results` (automated test status per annotation) - RequirementsRepository: adds get_test_results_for_svc for targeted per-SVC test result lookup - ProjectState: exposes get_impl_annotations_for_req, get_test_annotations_for_svc, get_test_results_for_svc Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/details.py | 8 +++++ src/reqstool/lsp/project_state.py | 23 +++++++++++++ .../storage/requirements_repository.py | 20 +++++++++++ tests/unit/reqstool/lsp/test_details.py | 33 +++++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/src/reqstool/lsp/features/details.py b/src/reqstool/lsp/features/details.py index 7cd76850..2e15c897 100644 --- a/src/reqstool/lsp/features/details.py +++ b/src/reqstool/lsp/features/details.py @@ -10,6 +10,8 @@ def get_requirement_details(raw_id: str, project: ProjectState) -> dict | None: if req is None: return None svcs = project.get_svcs_for_req(raw_id) + impls = project.get_impl_annotations_for_req(raw_id) + references = [str(ref_id) for rd in (req.references or []) for ref_id in rd.requirement_ids] return { "type": "requirement", "id": req.id.id, @@ -25,6 +27,8 @@ def get_requirement_details(raw_id: str, project: ProjectState) -> dict | None: }, "categories": [c.value for c in req.categories], "implementation": req.implementation.value, + "references": references, + "implementations": [{"element_kind": a.element_kind, "fqn": a.fully_qualified_name} for a in impls], "svcs": [ { "id": s.id.id, @@ -42,6 +46,8 @@ def get_svc_details(raw_id: str, project: ProjectState) -> dict | None: if svc is None: return None mvrs = project.get_mvrs_for_svc(raw_id) + test_annotations = project.get_test_annotations_for_svc(raw_id) + test_results = project.get_test_results_for_svc(raw_id) return { "type": "svc", "id": svc.id.id, @@ -56,6 +62,8 @@ def get_svc_details(raw_id: str, project: ProjectState) -> dict | None: "reason": svc.lifecycle.reason or "", }, "requirement_ids": [{"id": r.id, "urn": str(r)} for r in svc.requirement_ids], + "test_annotations": [{"element_kind": a.element_kind, "fqn": a.fully_qualified_name} for a in test_annotations], + "test_results": [{"fqn": t.fully_qualified_name, "status": t.status.value} for t in test_results], "mvrs": [ { "id": m.id.id, diff --git a/src/reqstool/lsp/project_state.py b/src/reqstool/lsp/project_state.py index aabe9eb6..03faa5b4 100644 --- a/src/reqstool/lsp/project_state.py +++ b/src/reqstool/lsp/project_state.py @@ -10,9 +10,11 @@ from reqstool.common.validator_error_holder import ValidationErrorHolder from reqstool.locations.local_location import LocalLocation from reqstool.model_generators.combined_raw_datasets_generator import CombinedRawDatasetsGenerator +from reqstool.models.annotations import AnnotationData from reqstool.models.mvrs import MVRData from reqstool.models.requirements import RequirementData from reqstool.models.svcs import SVCData +from reqstool.models.test_data import TestData from reqstool.storage.database import RequirementsDatabase from reqstool.storage.database_filter_processor import DatabaseFilterProcessor from reqstool.storage.requirements_repository import RequirementsRepository @@ -145,6 +147,27 @@ def get_yaml_paths(self) -> dict[str, dict[str, str]]: """Return all URN → file_type → path mappings.""" return dict(self._urn_source_paths) + def get_impl_annotations_for_req(self, raw_id: str) -> list[AnnotationData]: + if not self._ready or self._repo is None: + return [] + initial_urn = self._repo.get_initial_urn() + req_urn_id = UrnId.assure_urn_id(initial_urn, raw_id) + return self._repo.get_annotations_impls_for_req(req_urn_id) + + def get_test_annotations_for_svc(self, raw_id: str) -> list[AnnotationData]: + if not self._ready or self._repo is None: + return [] + initial_urn = self._repo.get_initial_urn() + svc_urn_id = UrnId.assure_urn_id(initial_urn, raw_id) + return self._repo.get_annotations_tests_for_svc(svc_urn_id) + + def get_test_results_for_svc(self, raw_id: str) -> list[TestData]: + if not self._ready or self._repo is None: + return [] + initial_urn = self._repo.get_initial_urn() + svc_urn_id = UrnId.assure_urn_id(initial_urn, raw_id) + return self._repo.get_test_results_for_svc(svc_urn_id) + def get_yaml_path(self, urn: str, file_type: str) -> str | None: """Return the resolved file path for a given URN and file type (requirements, svcs, mvrs, annotations).""" urn_paths = self._urn_source_paths.get(urn) diff --git a/src/reqstool/storage/requirements_repository.py b/src/reqstool/storage/requirements_repository.py index 7c62499b..83476cb6 100644 --- a/src/reqstool/storage/requirements_repository.py +++ b/src/reqstool/storage/requirements_repository.py @@ -124,6 +124,26 @@ def get_annotations_tests_for_svc(self, svc_urn_id: UrnId) -> list[AnnotationDat ).fetchall() return [AnnotationData(element_kind=row["element_kind"], fully_qualified_name=row["fqn"]) for row in rows] + def get_test_results_for_svc(self, svc_urn_id: UrnId) -> list[TestData]: + """Return test results for each annotation attached to the given SVC.""" + annotations = self.get_annotations_tests_for_svc(svc_urn_id) + results = [] + for ann in annotations: + if ann.element_kind == "CLASS": + results.append(self._process_class_annotated_test_results(svc_urn_id.urn, ann.fully_qualified_name)) + else: + row = self._db.connection.execute( + "SELECT fqn, status FROM test_results WHERE fqn = ?", + (ann.fully_qualified_name,), + ).fetchone() + if row is not None: + results.append(TestData(fully_qualified_name=row["fqn"], status=TEST_RUN_STATUS(row["status"]))) + else: + results.append( + TestData(fully_qualified_name=ann.fully_qualified_name, status=TEST_RUN_STATUS.MISSING) + ) + return results + # -- Test result resolution -- def get_automated_test_results(self) -> dict[UrnId, list[TestData]]: diff --git a/tests/unit/reqstool/lsp/test_details.py b/tests/unit/reqstool/lsp/test_details.py index 095fe592..9306b3ce 100644 --- a/tests/unit/reqstool/lsp/test_details.py +++ b/tests/unit/reqstool/lsp/test_details.py @@ -24,6 +24,10 @@ def test_get_requirement_details_known(project): assert "significance" in result assert "description" in result assert "lifecycle" in result + assert "references" in result + assert isinstance(result["references"], list) + assert "implementations" in result + assert isinstance(result["implementations"], list) assert "svcs" in result assert isinstance(result["svcs"], list) @@ -44,6 +48,10 @@ def test_get_svc_details_known(project): assert "verification" in result assert "lifecycle" in result assert "requirement_ids" in result + assert "test_annotations" in result + assert isinstance(result["test_annotations"], list) + assert "test_results" in result + assert isinstance(result["test_results"], list) assert "mvrs" in result @@ -64,3 +72,28 @@ def test_get_requirement_details_fields(project): assert result["urn"].endswith(":REQ_010") assert result["lifecycle"]["state"] in ("draft", "effective", "deprecated", "obsolete") assert isinstance(result["categories"], list) + + +def test_get_requirement_details_implementations(project): + # annotations.yml has implementations for REQ_010 + result = get_requirement_details("REQ_010", project) + assert result is not None + assert len(result["implementations"]) > 0 + impl = result["implementations"][0] + assert "element_kind" in impl + assert "fqn" in impl + assert impl["element_kind"] in ("CLASS", "METHOD", "FIELD", "ENUM", "INTERFACE", "RECORD") + + +def test_get_svc_details_test_results(project): + # Find a SVC that has test annotations (SVCs in the fixture are linked to test methods) + svc_ids = project.get_all_svc_ids() + # Look for an SVC that has test_annotations in the fixture + for svc_id in svc_ids: + result = get_svc_details(svc_id, project) + assert result is not None + if result["test_annotations"]: + assert all("element_kind" in a and "fqn" in a for a in result["test_annotations"]) + assert all("fqn" in t and "status" in t for t in result["test_results"]) + assert all(t["status"] in ("passed", "failed", "skipped", "missing") for t in result["test_results"]) + break From fea21b68e456479d524ddc2406baa96489b10707 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Wed, 18 Mar 2026 23:46:04 +0100 Subject: [PATCH 22/37] feat: improve hover, completion, codeLens, details, and semanticTokens LSP features (#314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - completion: filter DEPRECATED/OBSOLETE IDs from suggestions - codeLens: add ⚠/✕ lifecycle badge per ID; pass full ids list in command args - hover: show implementation count for requirements; tests passed/failed/missing and MVR counts for SVCs - details: add test_summary aggregate, enrich requirement_ids with title+lifecycle_state, add source_paths to all responses - semanticTokens: 4 distinct token types in lifecycle order — reqstoolDraft(0), reqstoolValid(1), reqstoolDeprecated(2), reqstoolObsolete(3) Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/codelens.py | 26 +++++++- src/reqstool/lsp/features/completion.py | 65 ++++++++++++------- src/reqstool/lsp/features/details.py | 19 +++++- src/reqstool/lsp/features/hover.py | 13 +++- src/reqstool/lsp/features/semantic_tokens.py | 8 +-- tests/unit/reqstool/lsp/test_codelens.py | 35 +++++++++- tests/unit/reqstool/lsp/test_completion.py | 29 +++++++++ tests/unit/reqstool/lsp/test_details.py | 20 ++++++ tests/unit/reqstool/lsp/test_hover.py | 26 ++++++++ .../unit/reqstool/lsp/test_semantic_tokens.py | 20 +++++- 10 files changed, 224 insertions(+), 37 deletions(-) diff --git a/src/reqstool/lsp/features/codelens.py b/src/reqstool/lsp/features/codelens.py index c7900e32..49911c62 100644 --- a/src/reqstool/lsp/features/codelens.py +++ b/src/reqstool/lsp/features/codelens.py @@ -4,6 +4,7 @@ from lsprotocol import types +from reqstool.common.models.lifecycle import LIFECYCLESTATE from reqstool.lsp.annotation_parser import find_all_annotations from reqstool.lsp.project_state import ProjectState @@ -50,7 +51,7 @@ def handle_code_lens( command=types.Command( title=label, command="reqstool.openDetails", - arguments=[{"id": ids[0], "uri": uri, "type": item_type}], + arguments=[{"ids": ids, "uri": uri, "type": item_type}], ), ) ) @@ -58,6 +59,14 @@ def handle_code_lens( return result +def _lifecycle_badge(state: LIFECYCLESTATE) -> str: + if state == LIFECYCLESTATE.DEPRECATED: + return "⚠ " + if state == LIFECYCLESTATE.OBSOLETE: + return "✕ " + return "" + + def _req_label(ids: list[str], project: ProjectState) -> str: all_svcs = [] for raw_id in ids: @@ -72,7 +81,12 @@ def _req_label(ids: list[str], project: ProjectState) -> str: else: fail_count += 1 - id_str = ", ".join(ids) + id_parts = [] + for raw_id in ids: + req = project.get_requirement(raw_id) + badge = _lifecycle_badge(req.lifecycle.state) if req else "" + id_parts.append(f"{badge}{raw_id}") + id_str = ", ".join(id_parts) svc_count = len(all_svcs) if pass_count == 0 and fail_count == 0: @@ -81,7 +95,13 @@ def _req_label(ids: list[str], project: ProjectState) -> str: def _svc_label(ids: list[str], project: ProjectState) -> str: - id_str = ", ".join(ids) + id_parts = [] + for raw_id in ids: + svc = project.get_svc(raw_id) + badge = _lifecycle_badge(svc.lifecycle.state) if svc else "" + id_parts.append(f"{badge}{raw_id}") + id_str = ", ".join(id_parts) + if len(ids) == 1: svc = project.get_svc(ids[0]) if svc is not None: diff --git a/src/reqstool/lsp/features/completion.py b/src/reqstool/lsp/features/completion.py index 42ba617e..f5c101d3 100644 --- a/src/reqstool/lsp/features/completion.py +++ b/src/reqstool/lsp/features/completion.py @@ -7,6 +7,7 @@ from lsprotocol import types +from reqstool.common.models.lifecycle import LIFECYCLESTATE from reqstool.lsp.annotation_parser import is_inside_annotation from reqstool.lsp.project_state import ProjectState from reqstool.lsp.yaml_schema import get_enum_values, schema_for_yaml_file @@ -55,31 +56,9 @@ def _complete_source( items: list[types.CompletionItem] = [] if kind == "Requirements": - for req_id in project.get_all_requirement_ids(): - req = project.get_requirement(req_id) - detail = req.title if req else "" - doc = req.description if req else "" - items.append( - types.CompletionItem( - label=req_id, - kind=types.CompletionItemKind.Reference, - detail=detail, - documentation=doc, - ) - ) + items = _req_completions(project) elif kind == "SVCs": - for svc_id in project.get_all_svc_ids(): - svc = project.get_svc(svc_id) - detail = svc.title if svc else "" - doc = svc.description if svc else "" - items.append( - types.CompletionItem( - label=svc_id, - kind=types.CompletionItemKind.Reference, - detail=detail, - documentation=doc if doc else None, - ) - ) + items = _svc_completions(project) if not items: return None @@ -87,6 +66,44 @@ def _complete_source( return types.CompletionList(is_incomplete=False, items=items) +_INACTIVE = (LIFECYCLESTATE.DEPRECATED, LIFECYCLESTATE.OBSOLETE) + + +def _req_completions(project: ProjectState) -> list[types.CompletionItem]: + items = [] + for req_id in project.get_all_requirement_ids(): + req = project.get_requirement(req_id) + if req is not None and req.lifecycle.state in _INACTIVE: + continue + items.append( + types.CompletionItem( + label=req_id, + kind=types.CompletionItemKind.Reference, + detail=req.title if req else "", + documentation=req.description if req else "", + ) + ) + return items + + +def _svc_completions(project: ProjectState) -> list[types.CompletionItem]: + items = [] + for svc_id in project.get_all_svc_ids(): + svc = project.get_svc(svc_id) + if svc is not None and svc.lifecycle.state in _INACTIVE: + continue + doc = svc.description if svc else "" + items.append( + types.CompletionItem( + label=svc_id, + kind=types.CompletionItemKind.Reference, + detail=svc.title if svc else "", + documentation=doc if doc else None, + ) + ) + return items + + def _complete_yaml( text: str, position: types.Position, diff --git a/src/reqstool/lsp/features/details.py b/src/reqstool/lsp/features/details.py index 2e15c897..8136eba9 100644 --- a/src/reqstool/lsp/features/details.py +++ b/src/reqstool/lsp/features/details.py @@ -38,6 +38,7 @@ def get_requirement_details(raw_id: str, project: ProjectState) -> dict | None: } for s in svcs ], + "source_paths": project.get_yaml_paths().get(req.id.urn, {}), } @@ -61,9 +62,23 @@ def get_svc_details(raw_id: str, project: ProjectState) -> dict | None: "state": svc.lifecycle.state.value, "reason": svc.lifecycle.reason or "", }, - "requirement_ids": [{"id": r.id, "urn": str(r)} for r in svc.requirement_ids], + "requirement_ids": [ + { + "id": r.id, + "urn": str(r), + "title": req.title if (req := project.get_requirement(r.id)) else "", + "lifecycle_state": req.lifecycle.state.value if req else "", + } + for r in svc.requirement_ids + ], "test_annotations": [{"element_kind": a.element_kind, "fqn": a.fully_qualified_name} for a in test_annotations], "test_results": [{"fqn": t.fully_qualified_name, "status": t.status.value} for t in test_results], + "test_summary": { + "passed": sum(1 for t in test_results if t.status.value == "passed"), + "failed": sum(1 for t in test_results if t.status.value == "failed"), + "skipped": sum(1 for t in test_results if t.status.value == "skipped"), + "missing": sum(1 for t in test_results if t.status.value == "missing"), + }, "mvrs": [ { "id": m.id.id, @@ -73,6 +88,7 @@ def get_svc_details(raw_id: str, project: ProjectState) -> dict | None: } for m in mvrs ], + "source_paths": project.get_yaml_paths().get(svc.id.urn, {}), } @@ -87,4 +103,5 @@ def get_mvr_details(raw_id: str, project: ProjectState) -> dict | None: "passed": mvr.passed, "comment": mvr.comment or "", "svc_ids": [{"id": s.id, "urn": str(s)} for s in mvr.svc_ids], + "source_paths": project.get_yaml_paths().get(mvr.id.urn, {}), } diff --git a/src/reqstool/lsp/features/hover.py b/src/reqstool/lsp/features/hover.py index 8a234c3e..fe9c2f71 100644 --- a/src/reqstool/lsp/features/hover.py +++ b/src/reqstool/lsp/features/hover.py @@ -80,6 +80,7 @@ def _hover_requirement(raw_id: str, match, project: ProjectState, uri: str) -> t svcs = project.get_svcs_for_req(raw_id) svc_ids = ", ".join(f"`{s.id.id}`" for s in svcs) if svcs else "—" categories = ", ".join(c.value for c in req.categories) if req.categories else "—" + impl_count = len(project.get_impl_annotations_for_req(raw_id)) parts = [ f"### {req.title}", @@ -95,6 +96,7 @@ def _hover_requirement(raw_id: str, match, project: ProjectState, uri: str) -> t f"**Categories**: {categories}", f"**Lifecycle**: {req.lifecycle.state.value}", f"**SVCs**: {svc_ids}", + f"**Implementations**: {impl_count}", "---", _open_details_link(raw_id, uri, "requirement"), ] @@ -116,8 +118,14 @@ def _hover_svc(raw_id: str, match, project: ProjectState, uri: str) -> types.Hov md = f"**Unknown SVC**: `{raw_id}`" else: mvrs = project.get_mvrs_for_svc(raw_id) + test_results = project.get_test_results_for_svc(raw_id) req_ids = ", ".join(f"`{r.id}`" for r in svc.requirement_ids) if svc.requirement_ids else "—" - mvr_info = ", ".join(f"{'pass' if m.passed else 'fail'}" for m in mvrs) if mvrs else "—" + + test_passed = sum(1 for t in test_results if t.status.value == "passed") + test_failed = sum(1 for t in test_results if t.status.value == "failed") + test_missing = sum(1 for t in test_results if t.status.value == "missing") + mvr_passed = sum(1 for m in mvrs if m.passed) + mvr_failed = sum(1 for m in mvrs if not m.passed) parts = [ f"### {svc.title}", @@ -134,7 +142,8 @@ def _hover_svc(raw_id: str, match, project: ProjectState, uri: str) -> types.Hov [ f"**Lifecycle**: {svc.lifecycle.state.value}", f"**Requirements**: {req_ids}", - f"**MVRs**: {mvr_info}", + f"**Tests**: {test_passed} passed · {test_failed} failed · {test_missing} missing", + f"**MVRs**: {mvr_passed} passed · {mvr_failed} failed", "---", _open_details_link(raw_id, uri, "svc"), ] diff --git a/src/reqstool/lsp/features/semantic_tokens.py b/src/reqstool/lsp/features/semantic_tokens.py index 748366ff..d57ca6fc 100644 --- a/src/reqstool/lsp/features/semantic_tokens.py +++ b/src/reqstool/lsp/features/semantic_tokens.py @@ -8,12 +8,12 @@ from reqstool.lsp.annotation_parser import find_all_annotations from reqstool.lsp.project_state import ProjectState -TOKEN_TYPES = ["reqstoolValid", "reqstoolDeprecated", "reqstoolObsolete"] +TOKEN_TYPES = ["reqstoolDraft", "reqstoolValid", "reqstoolDeprecated", "reqstoolObsolete"] _STATE_TO_IDX = { - LIFECYCLESTATE.EFFECTIVE: 0, LIFECYCLESTATE.DRAFT: 0, - LIFECYCLESTATE.DEPRECATED: 1, - LIFECYCLESTATE.OBSOLETE: 2, + LIFECYCLESTATE.EFFECTIVE: 1, + LIFECYCLESTATE.DEPRECATED: 2, + LIFECYCLESTATE.OBSOLETE: 3, } SEMANTIC_TOKENS_OPTIONS = types.SemanticTokensOptions( diff --git a/tests/unit/reqstool/lsp/test_codelens.py b/tests/unit/reqstool/lsp/test_codelens.py index 8394c3dc..2aafdec7 100644 --- a/tests/unit/reqstool/lsp/test_codelens.py +++ b/tests/unit/reqstool/lsp/test_codelens.py @@ -42,7 +42,10 @@ def test_codelens_requirement_annotation(project): assert lens.command is not None assert "REQ_010" in lens.command.title assert lens.command.command == "reqstool.openDetails" - assert lens.command.arguments[0]["type"] == "requirement" + args = lens.command.arguments[0] + assert args["type"] == "requirement" + assert "ids" in args + assert "REQ_010" in args["ids"] def test_codelens_svc_annotation(project): @@ -53,7 +56,35 @@ def test_codelens_svc_annotation(project): assert len(result) == 1 lens = result[0] assert svc_ids[0] in lens.command.title - assert lens.command.arguments[0]["type"] == "svc" + args = lens.command.arguments[0] + assert args["type"] == "svc" + assert "ids" in args + assert svc_ids[0] in args["ids"] + + +@pytest.fixture +def lifecycle_project(local_testdata_resources_rootdir_w_path): + path = local_testdata_resources_rootdir_w_path("test_basic/lifecycle/ms-101") + state = ProjectState(reqstool_path=path) + state.build() + yield state + state.close() + + +def test_codelens_deprecated_badge(lifecycle_project): + # REQ_101 is deprecated in the lifecycle fixture + text = '@Requirements("REQ_101")\ndef foo(): pass' + result = handle_code_lens(URI, text, "python", lifecycle_project) + assert len(result) == 1 + assert "⚠" in result[0].command.title + + +def test_codelens_obsolete_badge(lifecycle_project): + # REQ_102 is obsolete in the lifecycle fixture + text = '@Requirements("REQ_102")\ndef foo(): pass' + result = handle_code_lens(URI, text, "python", lifecycle_project) + assert len(result) == 1 + assert "✕" in result[0].command.title def test_codelens_multiple_ids_same_line(project): diff --git a/tests/unit/reqstool/lsp/test_completion.py b/tests/unit/reqstool/lsp/test_completion.py index 3e7a5a1d..04782b9a 100644 --- a/tests/unit/reqstool/lsp/test_completion.py +++ b/tests/unit/reqstool/lsp/test_completion.py @@ -55,6 +55,35 @@ def test_completion_svcs(local_testdata_resources_rootdir_w_path): state.close() +def test_completion_excludes_deprecated_and_obsolete(local_testdata_resources_rootdir_w_path): + from reqstool.lsp.project_state import ProjectState + + # test_basic/lifecycle/ms-101 has REQ_101 (deprecated) and REQ_102/REQ_202 (obsolete) + path = local_testdata_resources_rootdir_w_path("test_basic/lifecycle/ms-101") + state = ProjectState(reqstool_path=path) + try: + state.build() + text = '@Requirements("' + result = handle_completion( + uri="file:///test.py", + position=types.Position(line=0, character=16), + text=text, + language_id="python", + project=state, + ) + assert result is not None + labels = [item.label for item in result.items] + # No deprecated or obsolete IDs should appear + from reqstool.common.models.lifecycle import LIFECYCLESTATE + + for req_id in labels: + req = state.get_requirement(req_id) + if req is not None: + assert req.lifecycle.state not in (LIFECYCLESTATE.DEPRECATED, LIFECYCLESTATE.OBSOLETE) + finally: + state.close() + + def test_completion_no_project(): text = '@Requirements("' result = handle_completion( diff --git a/tests/unit/reqstool/lsp/test_details.py b/tests/unit/reqstool/lsp/test_details.py index 9306b3ce..4748a8dd 100644 --- a/tests/unit/reqstool/lsp/test_details.py +++ b/tests/unit/reqstool/lsp/test_details.py @@ -30,6 +30,8 @@ def test_get_requirement_details_known(project): assert isinstance(result["implementations"], list) assert "svcs" in result assert isinstance(result["svcs"], list) + assert "source_paths" in result + assert isinstance(result["source_paths"], dict) def test_get_requirement_details_unknown(project): @@ -52,7 +54,12 @@ def test_get_svc_details_known(project): assert isinstance(result["test_annotations"], list) assert "test_results" in result assert isinstance(result["test_results"], list) + assert "test_summary" in result + summary = result["test_summary"] + assert set(summary.keys()) == {"passed", "failed", "skipped", "missing"} assert "mvrs" in result + assert "source_paths" in result + assert isinstance(result["source_paths"], dict) def test_get_svc_details_unknown(project): @@ -85,6 +92,19 @@ def test_get_requirement_details_implementations(project): assert impl["element_kind"] in ("CLASS", "METHOD", "FIELD", "ENUM", "INTERFACE", "RECORD") +def test_get_svc_details_requirement_ids_enriched(project): + svc_ids = project.get_all_svc_ids() + for svc_id in svc_ids: + result = get_svc_details(svc_id, project) + assert result is not None + for req_entry in result["requirement_ids"]: + assert "id" in req_entry + assert "urn" in req_entry + assert "title" in req_entry + assert "lifecycle_state" in req_entry + break # one SVC is enough + + def test_get_svc_details_test_results(project): # Find a SVC that has test annotations (SVCs in the fixture are linked to test methods) svc_ids = project.get_all_svc_ids() diff --git a/tests/unit/reqstool/lsp/test_hover.py b/tests/unit/reqstool/lsp/test_hover.py index dfbc4d08..0761ebb4 100644 --- a/tests/unit/reqstool/lsp/test_hover.py +++ b/tests/unit/reqstool/lsp/test_hover.py @@ -26,6 +26,32 @@ def test_hover_python_requirement(local_testdata_resources_rootdir_w_path): assert result is not None assert "REQ_010" in result.contents.value assert "Title REQ_010" in result.contents.value + assert "Implementations" in result.contents.value + finally: + state.close() + + +def test_hover_python_svc_test_summary(local_testdata_resources_rootdir_w_path): + from reqstool.lsp.project_state import ProjectState + + path = local_testdata_resources_rootdir_w_path("test_standard/baseline/ms-001") + state = ProjectState(reqstool_path=path) + try: + state.build() + svc_ids = state.get_all_svc_ids() + assert svc_ids + text = f'@SVCs("{svc_ids[0]}")\ndef test_foo(): pass' + result = handle_hover( + uri="file:///test.py", + position=types.Position(line=0, character=8), + text=text, + language_id="python", + project=state, + ) + assert result is not None + assert "Tests" in result.contents.value + assert "MVRs" in result.contents.value + assert "passed" in result.contents.value finally: state.close() diff --git a/tests/unit/reqstool/lsp/test_semantic_tokens.py b/tests/unit/reqstool/lsp/test_semantic_tokens.py index 01701c8e..046f38a2 100644 --- a/tests/unit/reqstool/lsp/test_semantic_tokens.py +++ b/tests/unit/reqstool/lsp/test_semantic_tokens.py @@ -36,7 +36,25 @@ def test_encode_tokens_sorted(): def test_token_types_count(): - assert len(TOKEN_TYPES) == 3 + assert len(TOKEN_TYPES) == 4 + + +def test_token_types_order(): + assert TOKEN_TYPES[0] == "reqstoolDraft" + assert TOKEN_TYPES[1] == "reqstoolValid" + assert TOKEN_TYPES[2] == "reqstoolDeprecated" + assert TOKEN_TYPES[3] == "reqstoolObsolete" + + +def test_state_to_idx_all_distinct(): + from reqstool.lsp.features.semantic_tokens import _STATE_TO_IDX + from reqstool.common.models.lifecycle import LIFECYCLESTATE + + assert _STATE_TO_IDX[LIFECYCLESTATE.DRAFT] == 0 + assert _STATE_TO_IDX[LIFECYCLESTATE.EFFECTIVE] == 1 + assert _STATE_TO_IDX[LIFECYCLESTATE.DEPRECATED] == 2 + assert _STATE_TO_IDX[LIFECYCLESTATE.OBSOLETE] == 3 + assert len(set(_STATE_TO_IDX.values())) == 4 # all indices distinct def test_semantic_tokens_no_project(): From 1bff292dd3a336daaf318f5bf7dff397fcf82711 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Thu, 19 Mar 2026 01:28:49 +0100 Subject: [PATCH 23/37] docs: add LSP server documentation and OpenRPC protocol spec (#314) - Add static lsp.adoc covering all LSP capabilities, commands, and annotation languages - Add reqstool-lsp.openrpc.json (OpenRPC 1.2.6) as formal spec for the custom reqstool/details method - Fix details response: return id+urn separately (urn = project URN only, not composite UrnId) - Update nav.adoc to include LSP Server page - Update CLAUDE.md with LSP documentation guidance Signed-off-by: Jimisola Laursen --- CLAUDE.md | 7 + .../ROOT/lsp/reqstool-lsp.openrpc.json | 260 ++++++++++++++++++ docs/modules/ROOT/nav.adoc | 1 + docs/modules/ROOT/pages/lsp.adoc | 241 ++++++++++++++++ src/reqstool/lsp/features/codelens.py | 2 +- src/reqstool/lsp/features/details.py | 17 +- src/reqstool/lsp/features/hover.py | 16 +- src/reqstool/lsp/project_state.py | 5 + src/reqstool/lsp/server.py | 22 +- .../storage/requirements_repository.py | 9 + tests/unit/reqstool/lsp/test_details.py | 26 ++ .../unit/reqstool/lsp/test_server_details.py | 56 ++++ .../storage/test_requirements_repository.py | 32 +++ 13 files changed, 667 insertions(+), 27 deletions(-) create mode 100644 docs/modules/ROOT/lsp/reqstool-lsp.openrpc.json create mode 100644 docs/modules/ROOT/pages/lsp.adoc create mode 100644 tests/unit/reqstool/lsp/test_server_details.py diff --git a/CLAUDE.md b/CLAUDE.md index a5240eba..a5e17bd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,13 @@ diff /tmp/baseline-report-demo.txt /tmp/feature-report-demo.txt If a diff is expected (e.g. the PR intentionally changes output), note it in the PR description. +## LSP Documentation + +- `docs/modules/ROOT/pages/lsp.adoc` — hand-written human-readable doc page; edit directly. +- `docs/modules/ROOT/lsp/reqstool-lsp.openrpc.json` — formal protocol spec (OpenRPC 1.2.6) for the custom `reqstool/details` method only. Standard LSP methods are defined by the LSP specification. + +When adding or removing an LSP feature in `server.py`, update **both** files manually. + ## Key Conventions - **URN format**: `some:urn:string` — the separator is `:`. `UrnId` is the canonical composite key used throughout indexes. diff --git a/docs/modules/ROOT/lsp/reqstool-lsp.openrpc.json b/docs/modules/ROOT/lsp/reqstool-lsp.openrpc.json new file mode 100644 index 00000000..7e8a60aa --- /dev/null +++ b/docs/modules/ROOT/lsp/reqstool-lsp.openrpc.json @@ -0,0 +1,260 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "reqstool Custom LSP Protocol", + "version": "0.1.0", + "description": "Custom JSON-RPC methods that extend the Language Server Protocol for reqstool. These methods are understood only by reqstool-aware clients; generic LSP clients will ignore them. Standard LSP methods are documented in lsp.adoc and defined by the LSP specification." + }, + "methods": [ + { + "name": "reqstool/details", + "summary": "Fetch structured data for a requirement, SVC, or MVR", + "description": "Returns structured data for a single requirement, SVC, or MVR. Searches all ready projects. Clients can use the response to display detailed requirement information (e.g. in a Details panel).", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "type": "object", + "required": ["id", "type"], + "properties": { + "id": { "type": "string", "description": "Local identifier, e.g. \"REQ_010\"" }, + "type": { "type": "string", "enum": ["requirement", "svc", "mvr"], "description": "The kind of item to fetch" } + } + } + } + ], + "result": { + "name": "result", + "description": "One of RequirementDetails, SVCDetails, or MVRDetails depending on the requested type. null if the item is not found.", + "schema": { + "oneOf": [ + { "$ref": "#/components/schemas/RequirementDetails" }, + { "$ref": "#/components/schemas/SVCDetails" }, + { "$ref": "#/components/schemas/MVRDetails" } + ] + } + }, + "examples": [ + { + "name": "Fetch a requirement", + "params": [ + { "name": "params", "value": { "id": "REQ_010", "type": "requirement" } } + ], + "result": { + "name": "result", + "value": { + "type": "requirement", + "id": "REQ_010", + "urn": "ms-001", + "title": "Payment must succeed", + "significance": "shall", + "description": "The system shall process payments.", + "rationale": "", + "revision": "1", + "lifecycle": { "state": "effective", "reason": "" }, + "categories": ["functional-suitability"], + "implementation": "in_code", + "references": [], + "implementations": [{ "element_kind": "METHOD", "fqn": "com.example.PaymentService.pay" }], + "svcs": [{ "id": "SVC_010", "urn": "ms-001", "title": "Payment test", "verification": "automated_test" }], + "location": { "type": "local", "uri": "file:///home/user/project/docs/reqstool" }, + "source_paths": { + "requirements": "/home/user/project/docs/reqstool/requirements.yml", + "svcs": "/home/user/project/docs/reqstool/software_verification_cases.yml", + "mvrs": "/home/user/project/docs/reqstool/manual_verification_results.yml" + } + } + } + }, + { + "name": "Fetch an SVC", + "params": [ + { "name": "params", "value": { "id": "SVC_010", "type": "svc" } } + ], + "result": { + "name": "result", + "value": { + "type": "svc", + "id": "SVC_010", + "urn": "ms-001", + "title": "Payment test", + "description": "", + "verification": "automated_test", + "instructions": "", + "revision": "1", + "lifecycle": { "state": "effective", "reason": "" }, + "requirement_ids": [{ "id": "REQ_010", "urn": "ms-001", "title": "Payment must succeed", "lifecycle_state": "effective" }], + "test_annotations": [{ "element_kind": "METHOD", "fqn": "com.example.PaymentServiceTest.testPay" }], + "test_results": [{ "fqn": "com.example.PaymentServiceTest.testPay", "status": "passed" }], + "test_summary": { "passed": 1, "failed": 0, "skipped": 0, "missing": 0 }, + "mvrs": [], + "location": { "type": "local", "uri": "file:///home/user/project/docs/reqstool" }, + "source_paths": { + "requirements": "/home/user/project/docs/reqstool/requirements.yml", + "svcs": "/home/user/project/docs/reqstool/software_verification_cases.yml", + "mvrs": "/home/user/project/docs/reqstool/manual_verification_results.yml" + } + } + } + }, + { + "name": "Fetch an MVR", + "params": [ + { "name": "params", "value": { "id": "MVR_001", "type": "mvr" } } + ], + "result": { + "name": "result", + "value": { + "type": "mvr", + "id": "MVR_001", + "urn": "ms-001", + "passed": true, + "comment": "Verified manually on 2024-01-15", + "svc_ids": [{ "id": "SVC_010", "urn": "ms-001" }], + "location": { "type": "local", "uri": "file:///home/user/project/docs/reqstool" }, + "source_paths": { + "requirements": "/home/user/project/docs/reqstool/requirements.yml", + "svcs": "/home/user/project/docs/reqstool/software_verification_cases.yml", + "mvrs": "/home/user/project/docs/reqstool/manual_verification_results.yml" + } + } + } + } + ] + } + ], + "components": { + "schemas": { + "LifecycleInfo": { + "type": "object", + "properties": { + "state": { "type": "string", "description": "Lifecycle state: draft, effective, deprecated, or obsolete" }, + "reason": { "type": "string", "description": "Optional reason for the current lifecycle state" } + } + }, + "LocationInfo": { + "type": "object", + "properties": { + "type": { "type": "string", "description": "Location kind: local, git, maven, or pypi" }, + "uri": { "type": "string", "description": "URI of the reqstool project root" } + } + }, + "AnnotationRef": { + "type": "object", + "properties": { + "element_kind": { "type": "string", "description": "Code element kind, e.g. METHOD or CLASS" }, + "fqn": { "type": "string", "description": "Fully-qualified name of the annotated element" } + } + }, + "TestResult": { + "type": "object", + "properties": { + "fqn": { "type": "string", "description": "Fully-qualified name of the test" }, + "status": { "type": "string", "description": "Test status: passed, failed, skipped, or missing" } + } + }, + "TestSummary": { + "type": "object", + "properties": { + "passed": { "type": "integer" }, + "failed": { "type": "integer" }, + "skipped": { "type": "integer" }, + "missing": { "type": "integer" } + } + }, + "SVCRef": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Local SVC identifier" }, + "urn": { "type": "string", "description": "Project URN" }, + "title": { "type": "string" }, + "verification": { "type": "string", "description": "Verification method" } + } + }, + "RequirementRef": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Local requirement identifier" }, + "urn": { "type": "string", "description": "Project URN" }, + "title": { "type": "string" }, + "lifecycle_state": { "type": "string" } + } + }, + "MVRRef": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Local MVR identifier" }, + "urn": { "type": "string", "description": "Project URN" }, + "passed": { "type": "boolean" }, + "comment": { "type": "string" } + } + }, + "IDRef": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Local identifier" }, + "urn": { "type": "string", "description": "Project URN" } + } + }, + "RequirementDetails": { + "type": "object", + "description": "Returned when type is \"requirement\".", + "properties": { + "type": { "type": "string", "enum": ["requirement"] }, + "id": { "type": "string", "description": "Local requirement identifier, e.g. REQ_010" }, + "urn": { "type": "string", "description": "Project URN, e.g. ms-001" }, + "title": { "type": "string" }, + "significance": { "type": "string", "description": "shall, should, or may" }, + "description": { "type": "string" }, + "rationale": { "type": "string" }, + "revision": { "type": "string" }, + "lifecycle": { "$ref": "#/components/schemas/LifecycleInfo" }, + "categories": { "type": "array", "items": { "type": "string" } }, + "implementation": { "type": "string", "description": "in_code or none" }, + "references": { "type": "array", "items": { "type": "string" }, "description": "IDs of referenced requirements" }, + "implementations": { "type": "array", "items": { "$ref": "#/components/schemas/AnnotationRef" } }, + "svcs": { "type": "array", "items": { "$ref": "#/components/schemas/SVCRef" } }, + "location": { "$ref": "#/components/schemas/LocationInfo" }, + "source_paths": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Map of file type to absolute path, e.g. {\"requirements\": \"/path/to/requirements.yml\"}" } + } + }, + "SVCDetails": { + "type": "object", + "description": "Returned when type is \"svc\".", + "properties": { + "type": { "type": "string", "enum": ["svc"] }, + "id": { "type": "string", "description": "Local SVC identifier, e.g. SVC_010" }, + "urn": { "type": "string", "description": "Project URN, e.g. ms-001" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "verification": { "type": "string", "description": "automated_test or manual_inspection" }, + "instructions": { "type": "string" }, + "revision": { "type": "string" }, + "lifecycle": { "$ref": "#/components/schemas/LifecycleInfo" }, + "requirement_ids": { "type": "array", "items": { "$ref": "#/components/schemas/RequirementRef" } }, + "test_annotations": { "type": "array", "items": { "$ref": "#/components/schemas/AnnotationRef" } }, + "test_results": { "type": "array", "items": { "$ref": "#/components/schemas/TestResult" } }, + "test_summary": { "$ref": "#/components/schemas/TestSummary" }, + "mvrs": { "type": "array", "items": { "$ref": "#/components/schemas/MVRRef" } }, + "location": { "$ref": "#/components/schemas/LocationInfo" }, + "source_paths": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Map of file type to absolute path" } + } + }, + "MVRDetails": { + "type": "object", + "description": "Returned when type is \"mvr\".", + "properties": { + "type": { "type": "string", "enum": ["mvr"] }, + "id": { "type": "string", "description": "Local MVR identifier, e.g. MVR_001" }, + "urn": { "type": "string", "description": "Project URN, e.g. ms-001" }, + "passed": { "type": "boolean", "description": "Whether the manual verification passed" }, + "comment": { "type": "string" }, + "svc_ids": { "type": "array", "items": { "$ref": "#/components/schemas/IDRef" } }, + "location": { "$ref": "#/components/schemas/LocationInfo" }, + "source_paths": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Map of file type to absolute path" } + } + } + } + } +} diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 2d8d165c..5aac82ae 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -2,3 +2,4 @@ * xref:installation.adoc[Installation] * xref:usage.adoc[Usage] * xref:how_it_works.adoc[How it Works] +* xref:lsp.adoc[LSP Server] diff --git a/docs/modules/ROOT/pages/lsp.adoc b/docs/modules/ROOT/pages/lsp.adoc new file mode 100644 index 00000000..87ddbc32 --- /dev/null +++ b/docs/modules/ROOT/pages/lsp.adoc @@ -0,0 +1,241 @@ +// Copyright © LFV += LSP Server +:toc: left +:toclevels: 3 + +The reqstool LSP server implements the https://microsoft.github.io/language-server-protocol/[Language Server Protocol] for requirements, SVCs, and annotations +across Python, Java, TypeScript, and JavaScript projects. Any LSP-compatible client can use it — +IDEs, AI coding assistants (such as Claude Code or GitHub Copilot), and other tooling. + +== Starting the Server + +=== Installation + +The LSP server is included in the `reqstool` package: + +[source,bash] +---- +pip install reqstool +---- + +=== stdio mode (recommended) + +[source,bash] +---- +reqstool lsp +---- + +Most LSP clients launch the server automatically via stdio when the language-client configuration +is set up. + +=== TCP mode (for debugging) + +[source,bash] +---- +reqstool lsp --tcp --host 127.0.0.1 --port 2087 +---- + +=== Log file + +[source,bash] +---- +reqstool lsp --log-file /tmp/reqstool-lsp.log +---- + +== Capabilities + +=== Lifecycle + +==== Server Initialization + +_LSP method:_ `initialized` + +Discovers all reqstool projects under each workspace folder and builds in-memory SQLite databases for each on startup. + +==== Server Shutdown + +_LSP method:_ `shutdown` + +Closes all open project databases gracefully. + +=== Document Synchronisation + +==== Document Opened + +_LSP method:_ `textDocument/didOpen` + +Publishes diagnostics for the opened document immediately. + +==== Document Changed + +_LSP method:_ `textDocument/didChange` + +Re-runs diagnostics on every content change event. + +==== Document Saved + +_LSP method:_ `textDocument/didSave` + +On save of a reqstool YAML file (requirements.yml, svcs.yml, etc.), rebuilds the affected project +database and republishes all diagnostics. For source files, re-runs diagnostics for that file only. + +==== Document Closed + +_LSP method:_ `textDocument/didClose` + +Clears diagnostics for the closed document. + +=== Workspace + +==== Workspace Folders Changed + +_LSP method:_ `workspace/didChangeWorkspaceFolders` + +Adds or removes project databases when workspace folders are added or removed in the client. + +==== Watched Files Changed + +_LSP method:_ `workspace/didChangeWatchedFiles` + +Triggers a full project rebuild when any reqstool YAML file is created, modified, or deleted on +disk outside the client. + +=== Intelligence + +==== Hover + +_LSP method:_ `textDocument/hover` + +In source files: Markdown tooltip for @Requirements/@SVCs IDs — title, description, lifecycle, +SVCs, implementation count, and an "Open Details" link. +In reqstool YAML files: JSON Schema description for the hovered field. + +==== Completion + +_LSP method:_ `textDocument/completion` + +In source files: autocompletes requirement and SVC IDs inside @Requirements() and @SVCs() +annotations. Trigger characters: `"`, `space`, `:`. +In YAML files: completes enum values from the JSON Schema. + +==== Go to Definition + +_LSP method:_ `textDocument/definition` + +From a source annotation: jumps to the `id:` line in the YAML file. +From svcs.yml (on a requirement reference): jumps to the defining entry in requirements.yml. +From mvrs.yml (on an SVC reference): jumps to the defining entry in svcs.yml. + +==== Document Symbols + +_LSP method:_ `textDocument/documentSymbol` + +Outline view for reqstool YAML files. Requirements show linked SVCs as children; SVCs show +requirements and MVRs as children. + +==== Code Lens + +_LSP method:_ `textDocument/codeLens` + +Inline summary above @Requirements/@SVCs annotations: SVC count and pass/fail verification +results. Clicking opens the Details panel. + +==== Inlay Hints + +_LSP method:_ `textDocument/inlayHint` + +Appends the human-readable title of each requirement or SVC immediately after its ID in source +annotations. + +==== Find References + +_LSP method:_ `textDocument/references` + +Finds all usages of a requirement or SVC ID across open source files and all reqstool YAML files +in the project. + +==== Workspace Symbols + +_LSP method:_ `workspace/symbol` + +Global search across all loaded projects for requirements and SVCs matching the query string (by +ID or title). + +==== Semantic Tokens + +_LSP method:_ `textDocument/semanticTokens/full` + +Colorizes requirement and SVC IDs in source annotations by lifecycle state. Four custom token +types: `reqstoolDraft`, `reqstoolValid`, `reqstoolDeprecated`, `reqstoolObsolete`. + +==== Code Actions + +_LSP method:_ `textDocument/codeAction` + +QuickFix actions for unknown-ID and deprecated/obsolete diagnostics. Source action on any known +annotation to open the Details panel. + +Code action kinds: `quickfix`, `source`. + +=== Custom Protocol + +_LSP method:_ `reqstool/details` + +NOTE: `reqstool/details` is *not* part of the standard LSP specification. It is a reqstool +extension understood only by reqstool-aware clients. Generic LSP clients will ignore it. + +Custom LSP request. Fetches structured data for a single requirement, SVC, or MVR. Can drive +e.g. a Details panel in an IDE. Searches all ready projects. + +The full protocol spec (OpenRPC) is at `docs/modules/ROOT/lsp/reqstool-lsp.openrpc.json`. + +== Commands + +=== Refresh Projects + +_Command ID:_ `reqstool.refresh` + +Manually rebuilds all project databases and republishes diagnostics. Useful after external +changes to YAML files missed by the file watcher. + +Run `reqstool: Refresh projects` from the command palette. + +== Supported Annotation Languages + +The server recognises `@Requirements` and `@SVCs` annotations in the following languages: + +=== Python + +_Language IDs:_ `python` + +[source,python] +---- +@Requirements("REQ_010", "REQ_011") +@SVCs("SVC_010") +---- + +Uses the reqstool-python-decorators package. Multi-line annotations are supported. + +=== Java + +_Language IDs:_ `java` + +[source,java] +---- +@Requirements("REQ_010", "REQ_011") +@SVCs("SVC_010") +---- + +Same decorator syntax as Python. Multi-line annotations are supported. + +=== TypeScript / JavaScript + +_Language IDs:_ `typescript`, `javascript`, `typescriptreact`, `javascriptreact` + +[source,javascript] +---- +/** @Requirements REQ_010, REQ_011 */ +/** @SVCs SVC_010 */ +---- + +JSDoc-style tag inside a block comment. IDs are comma- or space-separated bare values (no quotes). diff --git a/src/reqstool/lsp/features/codelens.py b/src/reqstool/lsp/features/codelens.py index 49911c62..1585626c 100644 --- a/src/reqstool/lsp/features/codelens.py +++ b/src/reqstool/lsp/features/codelens.py @@ -51,7 +51,7 @@ def handle_code_lens( command=types.Command( title=label, command="reqstool.openDetails", - arguments=[{"ids": ids, "uri": uri, "type": item_type}], + arguments=[{"ids": ids, "type": item_type}], ), ) ) diff --git a/src/reqstool/lsp/features/details.py b/src/reqstool/lsp/features/details.py index 8136eba9..a8ddf454 100644 --- a/src/reqstool/lsp/features/details.py +++ b/src/reqstool/lsp/features/details.py @@ -15,7 +15,7 @@ def get_requirement_details(raw_id: str, project: ProjectState) -> dict | None: return { "type": "requirement", "id": req.id.id, - "urn": str(req.id), + "urn": req.id.urn, "title": req.title, "significance": req.significance.value, "description": req.description, @@ -32,12 +32,13 @@ def get_requirement_details(raw_id: str, project: ProjectState) -> dict | None: "svcs": [ { "id": s.id.id, - "urn": str(s.id), + "urn": s.id.urn, "title": s.title, "verification": s.verification.value, } for s in svcs ], + "location": project.get_urn_location(req.id.urn), "source_paths": project.get_yaml_paths().get(req.id.urn, {}), } @@ -52,7 +53,7 @@ def get_svc_details(raw_id: str, project: ProjectState) -> dict | None: return { "type": "svc", "id": svc.id.id, - "urn": str(svc.id), + "urn": svc.id.urn, "title": svc.title, "description": svc.description or "", "verification": svc.verification.value, @@ -65,7 +66,7 @@ def get_svc_details(raw_id: str, project: ProjectState) -> dict | None: "requirement_ids": [ { "id": r.id, - "urn": str(r), + "urn": r.urn, "title": req.title if (req := project.get_requirement(r.id)) else "", "lifecycle_state": req.lifecycle.state.value if req else "", } @@ -82,12 +83,13 @@ def get_svc_details(raw_id: str, project: ProjectState) -> dict | None: "mvrs": [ { "id": m.id.id, - "urn": str(m.id), + "urn": m.id.urn, "passed": m.passed, "comment": m.comment or "", } for m in mvrs ], + "location": project.get_urn_location(svc.id.urn), "source_paths": project.get_yaml_paths().get(svc.id.urn, {}), } @@ -99,9 +101,10 @@ def get_mvr_details(raw_id: str, project: ProjectState) -> dict | None: return { "type": "mvr", "id": mvr.id.id, - "urn": str(mvr.id), + "urn": mvr.id.urn, "passed": mvr.passed, "comment": mvr.comment or "", - "svc_ids": [{"id": s.id, "urn": str(s)} for s in mvr.svc_ids], + "svc_ids": [{"id": s.id, "urn": s.urn} for s in mvr.svc_ids], + "location": project.get_urn_location(mvr.id.urn), "source_paths": project.get_yaml_paths().get(mvr.id.urn, {}), } diff --git a/src/reqstool/lsp/features/hover.py b/src/reqstool/lsp/features/hover.py index fe9c2f71..9feccf89 100644 --- a/src/reqstool/lsp/features/hover.py +++ b/src/reqstool/lsp/features/hover.py @@ -60,19 +60,19 @@ def _hover_source( ) if match.kind == "Requirements": - return _hover_requirement(match.raw_id, match, project, uri) + return _hover_requirement(match.raw_id, match, project) elif match.kind == "SVCs": - return _hover_svc(match.raw_id, match, project, uri) + return _hover_svc(match.raw_id, match, project) return None -def _open_details_link(raw_id: str, uri: str, kind: str) -> str: - args = urllib.parse.quote(json.dumps({"id": raw_id, "uri": uri, "type": kind})) +def _open_details_link(raw_id: str, kind: str) -> str: + args = urllib.parse.quote(json.dumps({"id": raw_id, "type": kind})) return f"[Open Details](command:reqstool.openDetails?{args})" -def _hover_requirement(raw_id: str, match, project: ProjectState, uri: str) -> types.Hover | None: +def _hover_requirement(raw_id: str, match, project: ProjectState) -> types.Hover | None: req = project.get_requirement(raw_id) if req is None: md = f"**Unknown requirement**: `{raw_id}`" @@ -98,7 +98,7 @@ def _hover_requirement(raw_id: str, match, project: ProjectState, uri: str) -> t f"**SVCs**: {svc_ids}", f"**Implementations**: {impl_count}", "---", - _open_details_link(raw_id, uri, "requirement"), + _open_details_link(raw_id, "requirement"), ] ) md = "\n\n".join(parts) @@ -112,7 +112,7 @@ def _hover_requirement(raw_id: str, match, project: ProjectState, uri: str) -> t ) -def _hover_svc(raw_id: str, match, project: ProjectState, uri: str) -> types.Hover | None: +def _hover_svc(raw_id: str, match, project: ProjectState) -> types.Hover | None: svc = project.get_svc(raw_id) if svc is None: md = f"**Unknown SVC**: `{raw_id}`" @@ -145,7 +145,7 @@ def _hover_svc(raw_id: str, match, project: ProjectState, uri: str) -> types.Hov f"**Tests**: {test_passed} passed · {test_failed} failed · {test_missing} missing", f"**MVRs**: {mvr_passed} passed · {mvr_failed} failed", "---", - _open_details_link(raw_id, uri, "svc"), + _open_details_link(raw_id, "svc"), ] ) md = "\n\n".join(parts) diff --git a/src/reqstool/lsp/project_state.py b/src/reqstool/lsp/project_state.py index 03faa5b4..4ab81534 100644 --- a/src/reqstool/lsp/project_state.py +++ b/src/reqstool/lsp/project_state.py @@ -168,6 +168,11 @@ def get_test_results_for_svc(self, raw_id: str) -> list[TestData]: svc_urn_id = UrnId.assure_urn_id(initial_urn, raw_id) return self._repo.get_test_results_for_svc(svc_urn_id) + def get_urn_location(self, urn: str) -> dict | None: + if not self._ready or self._repo is None: + return None + return self._repo.get_urn_location(urn) + def get_yaml_path(self, urn: str, file_type: str) -> str | None: """Return the resolved file path for a given URN and file type (requirements, svcs, mvrs, annotations).""" urn_paths = self._urn_source_paths.get(urn) diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index 430cf5f1..66be078e 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -188,12 +188,6 @@ def _get(params, key: str, default=""): return params.get(key, default) if isinstance(params, dict) else getattr(params, key, default) -def _first_project(ls: ReqstoolLanguageServer): - """Fallback: return first available ready project across all workspace folders.""" - projects = ls.workspace_manager.all_projects() - return projects[0] if projects else None - - _DETAILS_DISPATCH = { "requirement": get_requirement_details, "svc": get_svc_details, @@ -201,21 +195,27 @@ def _first_project(ls: ReqstoolLanguageServer): } +def _find_details(raw_id: str, fn, ls: ReqstoolLanguageServer) -> dict | None: + """Search all ready projects for raw_id; return first non-None result.""" + for project in ls.workspace_manager.all_projects(): + if project.ready: + result = fn(raw_id, project) + if result is not None: + return result + return None + + # -- New feature handlers -- @server.feature("reqstool/details") def on_details(ls: ReqstoolLanguageServer, params) -> dict | None: - uri = _get(params, "uri") raw_id = _get(params, "id") kind = _get(params, "type") fn = _DETAILS_DISPATCH.get(kind) if not fn: return None - project = ls.workspace_manager.project_for_file(uri) or _first_project(ls) - if not project or not project.ready: - return None - return fn(raw_id, project) + return _find_details(raw_id, fn, ls) @server.feature(types.TEXT_DOCUMENT_CODE_LENS, types.CodeLensOptions(resolve_provider=False)) diff --git a/src/reqstool/storage/requirements_repository.py b/src/reqstool/storage/requirements_repository.py index 83476cb6..39251284 100644 --- a/src/reqstool/storage/requirements_repository.py +++ b/src/reqstool/storage/requirements_repository.py @@ -33,6 +33,15 @@ def get_urn_parsing_order(self) -> list[str]: rows = self._db.connection.execute("SELECT urn FROM urn_metadata ORDER BY parse_position").fetchall() return [row["urn"] for row in rows] + def get_urn_location(self, urn: str) -> dict | None: + row = self._db.connection.execute( + "SELECT location_type, location_uri FROM urn_metadata WHERE urn = ?", + (urn,), + ).fetchone() + if row is None: + return None + return {"type": row["location_type"], "uri": row["location_uri"]} + def get_import_graph(self) -> dict[str, list[str]]: graph: dict[str, list[str]] = {} all_urns = {row["urn"] for row in self._db.connection.execute("SELECT urn FROM urn_metadata").fetchall()} diff --git a/tests/unit/reqstool/lsp/test_details.py b/tests/unit/reqstool/lsp/test_details.py index 4748a8dd..2dd807c2 100644 --- a/tests/unit/reqstool/lsp/test_details.py +++ b/tests/unit/reqstool/lsp/test_details.py @@ -30,6 +30,7 @@ def test_get_requirement_details_known(project): assert isinstance(result["implementations"], list) assert "svcs" in result assert isinstance(result["svcs"], list) + assert "location" in result assert "source_paths" in result assert isinstance(result["source_paths"], dict) @@ -58,6 +59,7 @@ def test_get_svc_details_known(project): summary = result["test_summary"] assert set(summary.keys()) == {"passed", "failed", "skipped", "missing"} assert "mvrs" in result + assert "location" in result assert "source_paths" in result assert isinstance(result["source_paths"], dict) @@ -117,3 +119,27 @@ def test_get_svc_details_test_results(project): assert all("fqn" in t and "status" in t for t in result["test_results"]) assert all(t["status"] in ("passed", "failed", "skipped", "missing") for t in result["test_results"]) break + + +def test_get_requirement_details_location_keys(project): + result = get_requirement_details("REQ_010", project) + assert result is not None + loc = result["location"] + # local fixture populates location_type and location_uri + assert loc is None or isinstance(loc, dict) + if loc is not None: + assert "type" in loc + assert "uri" in loc + assert isinstance(loc["type"], str) or loc["type"] is None + assert isinstance(loc["uri"], str) or loc["uri"] is None + + +def test_get_svc_details_location_keys(project): + svc_ids = project.get_all_svc_ids() + result = get_svc_details(svc_ids[0], project) + assert result is not None + loc = result["location"] + assert loc is None or isinstance(loc, dict) + if loc is not None: + assert "type" in loc + assert "uri" in loc diff --git a/tests/unit/reqstool/lsp/test_server_details.py b/tests/unit/reqstool/lsp/test_server_details.py new file mode 100644 index 00000000..b2e1a5a6 --- /dev/null +++ b/tests/unit/reqstool/lsp/test_server_details.py @@ -0,0 +1,56 @@ +# Copyright © LFV + +from unittest.mock import MagicMock + +from reqstool.lsp.server import _find_details + + +def _make_ls(projects): + ls = MagicMock() + ls.workspace_manager.all_projects.return_value = projects + return ls + + +def test_find_details_returns_first_match(): + fn = MagicMock(side_effect=[None, {"type": "requirement", "id": "REQ_010"}]) + p1 = MagicMock() + p1.ready = True + p2 = MagicMock() + p2.ready = True + ls = _make_ls([p1, p2]) + + result = _find_details("REQ_010", fn, ls) + assert result == {"type": "requirement", "id": "REQ_010"} + assert fn.call_count == 2 + + +def test_find_details_skips_not_ready(): + fn = MagicMock(return_value={"type": "requirement", "id": "REQ_010"}) + p_not_ready = MagicMock() + p_not_ready.ready = False + p_ready = MagicMock() + p_ready.ready = True + ls = _make_ls([p_not_ready, p_ready]) + + result = _find_details("REQ_010", fn, ls) + assert result == {"type": "requirement", "id": "REQ_010"} + fn.assert_called_once_with("REQ_010", p_ready) + + +def test_find_details_unknown_id_returns_none(): + fn = MagicMock(return_value=None) + p = MagicMock() + p.ready = True + ls = _make_ls([p]) + + result = _find_details("REQ_NONEXISTENT", fn, ls) + assert result is None + + +def test_find_details_no_projects_returns_none(): + fn = MagicMock() + ls = _make_ls([]) + + result = _find_details("REQ_010", fn, ls) + assert result is None + fn.assert_not_called() diff --git a/tests/unit/reqstool/storage/test_requirements_repository.py b/tests/unit/reqstool/storage/test_requirements_repository.py index 57ae942c..ddd7d0ec 100644 --- a/tests/unit/reqstool/storage/test_requirements_repository.py +++ b/tests/unit/reqstool/storage/test_requirements_repository.py @@ -360,3 +360,35 @@ def test_get_automated_test_results_class_no_results(db): results = repo.get_automated_test_results() key = UrnId(urn=URN, id="com.example.FooTest") assert results[key][0].status == TEST_RUN_STATUS.MISSING + + +# -- URN location queries -- + + +def test_get_urn_location_with_values(db): + metadata = MetaData(urn="ms-001", variant=VARIANTS.MICROSERVICE, title="Test") + db.insert_urn_metadata(metadata, location_type="local", location_uri="file:///home/user/project/docs/reqstool") + db.commit() + + repo = RequirementsRepository(db) + loc = repo.get_urn_location("ms-001") + assert loc is not None + assert loc["type"] == "local" + assert loc["uri"] == "file:///home/user/project/docs/reqstool" + + +def test_get_urn_location_no_values(db): + metadata = MetaData(urn="ms-001", variant=VARIANTS.MICROSERVICE, title="Test") + db.insert_urn_metadata(metadata) + db.commit() + + repo = RequirementsRepository(db) + loc = repo.get_urn_location("ms-001") + assert loc is not None + assert loc["type"] is None + assert loc["uri"] is None + + +def test_get_urn_location_unknown_urn(db): + repo = RequirementsRepository(db) + assert repo.get_urn_location("nonexistent") is None From d08eb0b07bea35a5816d3166e85efef1e96b873e Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Thu, 19 Mar 2026 22:42:34 +0100 Subject: [PATCH 24/37] fix: reduce cyclomatic complexity of main() below flake8 C901 limit (#314) Extract LSP try/except handling into Command.command_lsp() to bring main() from complexity 13 down to the allowed maximum of 10. Signed-off-by: Jimisola Laursen --- src/reqstool/command.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/reqstool/command.py b/src/reqstool/command.py index 60033466..b071fee0 100755 --- a/src/reqstool/command.py +++ b/src/reqstool/command.py @@ -402,6 +402,21 @@ def command_status(self, status_args: argparse.Namespace) -> int: else 0 ) + def command_lsp(self, lsp_args: argparse.Namespace): + try: + from reqstool.lsp.server import start_server + except ImportError: + print( + "LSP server requires extra dependencies: pip install reqstool[lsp]", + file=sys.stderr, + ) + sys.exit(1) + try: + start_server(tcp=lsp_args.tcp, host=lsp_args.host, port=lsp_args.port, log_file=lsp_args.log_file) + except Exception as exc: + logging.fatal("reqstool LSP server crashed: %s", exc) + sys.exit(1) + def print_help(self): self.__parser.print_help(sys.stderr) @@ -435,19 +450,7 @@ def main(): elif args.command == "status": exit_code = command.command_status(status_args=args) elif args.command == "lsp": - try: - from reqstool.lsp.server import start_server - except ImportError: - print( - "LSP server requires extra dependencies: pip install reqstool[lsp]", - file=sys.stderr, - ) - sys.exit(1) - try: - start_server(tcp=args.tcp, host=args.host, port=args.port, log_file=args.log_file) - except Exception as exc: - logging.fatal("reqstool LSP server crashed: %s", exc) - sys.exit(1) + command.command_lsp(lsp_args=args) else: command.print_help() except MissingRequirementsFileError as exc: From db3e8b41dcda06a1d6377e8846fbf0a6500d0a0b Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Thu, 19 Mar 2026 22:50:07 +0100 Subject: [PATCH 25/37] fix: fix test_details urn assertion and exclude fixtures from pytest collection (#314) - Fix test_get_requirement_details_fields: result["urn"] is the URN portion only ("ms-001"), not the full urn:id; assert result["id"] instead - Add norecursedirs = tests/fixtures to prevent pytest collecting fixture source files that import project-specific modules Signed-off-by: Jimisola Laursen --- pyproject.toml | 1 + tests/unit/reqstool/lsp/test_details.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b7367407..196ede18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ addopts = [ ] pythonpath = [".", "src", "tests"] testpaths = ["tests"] +norecursedirs = ["tests/fixtures"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "module" markers = [ diff --git a/tests/unit/reqstool/lsp/test_details.py b/tests/unit/reqstool/lsp/test_details.py index 2dd807c2..4ed9ffd4 100644 --- a/tests/unit/reqstool/lsp/test_details.py +++ b/tests/unit/reqstool/lsp/test_details.py @@ -78,7 +78,7 @@ def test_get_mvr_details_unknown(project): def test_get_requirement_details_fields(project): result = get_requirement_details("REQ_010", project) assert result is not None - assert result["urn"].endswith(":REQ_010") + assert result["id"] == "REQ_010" assert result["lifecycle"]["state"] in ("draft", "effective", "deprecated", "obsolete") assert isinstance(result["categories"], list) From df016672b5a843132fa54ecdb42fab4345398b1d Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Thu, 19 Mar 2026 23:34:12 +0100 Subject: [PATCH 26/37] fix: pass SemanticTokensLegend to @feature decorator, fix completion test expectations (#314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pygls 2.x _with_semantic_tokens() uses the registered options object directly as the `legend` field when constructing ServerCapabilities. Passing SemanticTokensOptions caused a nested SemanticTokensOptions as the legend, breaking serialization at LSP initialize time. Fix: pass SemanticTokensLegend to @server.feature() directly so pygls can wrap it correctly into SemanticTokensOptions. Also fix integration test expectations: completion correctly excludes deprecated/obsolete REQs and SVCs (REQ_SKIPPED_TEST, REQ_OBSOLETE, SVC_050, SVC_070) — update tests to assert this behaviour. Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/semantic_tokens.py | 5 +---- src/reqstool/lsp/server.py | 4 ++-- tests/integration/reqstool/lsp/test_lsp_integration.py | 8 +++++--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/reqstool/lsp/features/semantic_tokens.py b/src/reqstool/lsp/features/semantic_tokens.py index d57ca6fc..3517d5f0 100644 --- a/src/reqstool/lsp/features/semantic_tokens.py +++ b/src/reqstool/lsp/features/semantic_tokens.py @@ -16,10 +16,7 @@ LIFECYCLESTATE.OBSOLETE: 3, } -SEMANTIC_TOKENS_OPTIONS = types.SemanticTokensOptions( - legend=types.SemanticTokensLegend(token_types=TOKEN_TYPES, token_modifiers=[]), - full=True, -) +SEMANTIC_TOKEN_LEGEND = types.SemanticTokensLegend(token_types=TOKEN_TYPES, token_modifiers=[]) def _encode_tokens(tokens: list[tuple[int, int, int, int]]) -> list[int]: diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index 66be078e..cc0f1fc5 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -17,7 +17,7 @@ from reqstool.lsp.features.hover import handle_hover from reqstool.lsp.features.inlay_hints import handle_inlay_hints from reqstool.lsp.features.references import handle_references -from reqstool.lsp.features.semantic_tokens import SEMANTIC_TOKENS_OPTIONS, handle_semantic_tokens +from reqstool.lsp.features.semantic_tokens import SEMANTIC_TOKEN_LEGEND, handle_semantic_tokens from reqstool.lsp.features.workspace_symbols import handle_workspace_symbols from reqstool.lsp.workspace_manager import WorkspaceManager @@ -263,7 +263,7 @@ def on_workspace_symbol(ls: ReqstoolLanguageServer, params: types.WorkspaceSymbo return handle_workspace_symbols(params.query, ls.workspace_manager) -@server.feature(types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL, SEMANTIC_TOKENS_OPTIONS) +@server.feature(types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL, SEMANTIC_TOKEN_LEGEND) def on_semantic_tokens(ls: ReqstoolLanguageServer, params: types.SemanticTokensParams) -> types.SemanticTokens: document = ls.workspace.get_text_document(params.text_document.uri) project = ls.workspace_manager.project_for_file(params.text_document.uri) diff --git a/tests/integration/reqstool/lsp/test_lsp_integration.py b/tests/integration/reqstool/lsp/test_lsp_integration.py index c13234cf..88a83035 100644 --- a/tests/integration/reqstool/lsp/test_lsp_integration.py +++ b/tests/integration/reqstool/lsp/test_lsp_integration.py @@ -167,11 +167,11 @@ async def test_completion_requirements(lsp_client, fixture_dir): "REQ_MANUAL_FAIL", "REQ_NOT_IMPLEMENTED", "REQ_FAILING_TEST", - "REQ_SKIPPED_TEST", "REQ_MISSING_TEST", - "REQ_OBSOLETE", } assert expected_ids.issubset(labels), f"Missing REQ IDs in completion. Got: {labels}" + assert "REQ_SKIPPED_TEST" not in labels, "Deprecated REQ should not appear in completion" + assert "REQ_OBSOLETE" not in labels, "Obsolete REQ should not appear in completion" async def test_completion_svcs(lsp_client, fixture_dir): @@ -192,8 +192,10 @@ async def test_completion_svcs(lsp_client, fixture_dir): assert result is not None, "Expected completion result" labels = {item.label for item in result.items} - expected_ids = {"SVC_010", "SVC_020", "SVC_021", "SVC_022", "SVC_030", "SVC_040", "SVC_050", "SVC_060", "SVC_070"} + expected_ids = {"SVC_010", "SVC_020", "SVC_021", "SVC_022", "SVC_030", "SVC_040", "SVC_060"} assert expected_ids.issubset(labels), f"Missing SVC IDs in completion. Got: {labels}" + assert "SVC_050" not in labels, "Deprecated SVC should not appear in completion" + assert "SVC_070" not in labels, "Obsolete SVC should not appear in completion" # --------------------------------------------------------------------------- From bac9657747a4019144a15bbd3325827f369bcf85 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 22 Mar 2026 18:57:58 +0100 Subject: [PATCH 27/37] fix: wrap openDetails command args in JSON array for VS Code command URI VS Code command: URI spec requires args to be a JSON array. The handler receives the first element as its argument. Wrapping in an array fixes the crash where args arrived as undefined in the extension handler. Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/hover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reqstool/lsp/features/hover.py b/src/reqstool/lsp/features/hover.py index 9feccf89..688de771 100644 --- a/src/reqstool/lsp/features/hover.py +++ b/src/reqstool/lsp/features/hover.py @@ -68,7 +68,7 @@ def _hover_source( def _open_details_link(raw_id: str, kind: str) -> str: - args = urllib.parse.quote(json.dumps({"id": raw_id, "type": kind})) + args = urllib.parse.quote(json.dumps([{"id": raw_id, "type": kind}])) return f"[Open Details](command:reqstool.openDetails?{args})" From 6abc1fd96ae210a29387ff6937e7c6aadb4a1a7c Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 22 Mar 2026 20:11:37 +0100 Subject: [PATCH 28/37] feat: add Go to Test (textDocument/implementation) and fix F12 on YAML refs Go to Test (Ctrl+F12): - From YAML requirements.yml id: line -> SVC id: lines in svcs.yml (uses ProjectState.get_svcs_for_req to resolve linked SVCs) - From YAML svcs.yml id: line -> source @SVCs test annotations in open documents - From source @Requirements annotation -> source @SVCs test annotations for all SVCs that verify this requirement (2-hop) F12 fix on YAML reference items: - Cursor on a bare list item like ` - REQ-001` inside requirement_ids or svc_ids now navigates to that ID's declaration (id: line) in the appropriate YAML file, resolved by ID prefix - id: declaration lines retain existing chain navigation behaviour Refactor: rename _find_id_in_yaml -> find_id_in_yaml (exported) so implementation.py can reuse it without duplication. Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/definition.py | 27 +++- src/reqstool/lsp/features/implementation.py | 133 ++++++++++++++++++++ src/reqstool/lsp/server.py | 15 +++ 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 src/reqstool/lsp/features/implementation.py diff --git a/src/reqstool/lsp/features/definition.py b/src/reqstool/lsp/features/definition.py index 0f5894b0..ba967d81 100644 --- a/src/reqstool/lsp/features/definition.py +++ b/src/reqstool/lsp/features/definition.py @@ -63,7 +63,7 @@ def _definition_from_source( if yaml_file is None: return [] - return _find_id_in_yaml(yaml_file, match.raw_id) + return find_id_in_yaml(yaml_file, match.raw_id) def _definition_from_yaml( @@ -84,7 +84,16 @@ def _definition_from_yaml( if not initial_urn: return [] - # Determine what kind of ID this is based on the YAML file + # If cursor is on a bare reference item (not an id: declaration), navigate to that ID's declaration. + lines = text.splitlines() + line = lines[position.line] if position.line < len(lines) else "" + if not re.match(r"^\s*(?:-\s+)?id\s*:", line): + target_path = _yaml_path_for_id(raw_id, initial_urn, project) + if target_path: + return find_id_in_yaml(target_path, raw_id) + return [] + + # id: declaration line: chain navigation to the next verification layer file_kind = YAML_ID_FILES.get(filename) if file_kind == "requirements": @@ -103,7 +112,19 @@ def _definition_from_yaml( return [] -def _find_id_in_yaml(yaml_file: str, raw_id: str) -> list[types.Location]: +def _yaml_path_for_id(raw_id: str, initial_urn: str, project: ProjectState) -> str | None: + """Resolve which YAML file owns this ID based on its prefix.""" + bare = raw_id.split(":")[-1].upper() + if bare.startswith("REQ"): + return project.get_yaml_path(initial_urn, "requirements") + if bare.startswith("SVC"): + return project.get_yaml_path(initial_urn, "svcs") + if bare.startswith("MVR"): + return project.get_yaml_path(initial_urn, "mvrs") + return None + + +def find_id_in_yaml(yaml_file: str, raw_id: str) -> list[types.Location]: """Search a YAML file for a line containing `id: ` and return its location.""" if not os.path.isfile(yaml_file): return [] diff --git a/src/reqstool/lsp/features/implementation.py b/src/reqstool/lsp/features/implementation.py new file mode 100644 index 00000000..800bf8c5 --- /dev/null +++ b/src/reqstool/lsp/features/implementation.py @@ -0,0 +1,133 @@ +# Copyright © LFV + +from __future__ import annotations + +import logging +import os +import re + +from lsprotocol import types + +from reqstool.lsp.annotation_parser import annotation_at_position, find_all_annotations +from reqstool.lsp.features.definition import find_id_in_yaml +from reqstool.lsp.project_state import ProjectState + +logger = logging.getLogger(__name__) + +REQSTOOL_YAML_FILES = { + "requirements.yml", + "software_verification_cases.yml", + "manual_verification_results.yml", +} + +_ID_LINE_RE = re.compile(r"^\s*(?:-\s+)?id\s*:\s*(\S+)") + + +def handle_implementation( + uri: str, + position: types.Position, + text: str, + language_id: str, + project: ProjectState | None, + workspace_text_documents: dict, +) -> list[types.Location]: + """Go to Test: navigate to the SVCs (in YAML) or @SVCs test annotations (in source) + that verify a given requirement or implement a given SVC.""" + if project is None or not project.ready: + return [] + + initial_urn = project.get_initial_urn() + if not initial_urn: + return [] + + basename = os.path.basename(uri) + + if basename == "requirements.yml": + return _from_yaml_req(text, position, initial_urn, project) + + if basename == "software_verification_cases.yml": + return _from_yaml_svc(text, position, workspace_text_documents) + + if basename not in REQSTOOL_YAML_FILES: + # Source file: @Requirements annotation → source @SVCs test annotations + match = annotation_at_position(text, position.line, position.character, language_id) + if match and match.kind == "Requirements": + return _svcs_in_source_for_req(match.raw_id, project, workspace_text_documents) + + return [] + + +def _from_yaml_req( + text: str, + position: types.Position, + initial_urn: str, + project: ProjectState, +) -> list[types.Location]: + """YAML requirements.yml id: REQ → SVC id: lines in svcs.yml.""" + lines = text.splitlines() + if position.line >= len(lines): + return [] + m = _ID_LINE_RE.match(lines[position.line]) + if not m: + return [] + raw_id = m.group(1) + + svcs_path = project.get_yaml_path(initial_urn, "svcs") + if not svcs_path: + return [] + + svcs = project.get_svcs_for_req(raw_id) + locations: list[types.Location] = [] + for svc in svcs: + locations.extend(find_id_in_yaml(svcs_path, svc.id.id)) + return locations + + +def _from_yaml_svc( + text: str, + position: types.Position, + workspace_text_documents: dict, +) -> list[types.Location]: + """YAML svcs.yml id: SVC → source @SVCs test annotations in open documents.""" + lines = text.splitlines() + if position.line >= len(lines): + return [] + m = _ID_LINE_RE.match(lines[position.line]) + if not m: + return [] + bare_id = m.group(1).split(":")[-1] + return _svc_annotations_in_source(bare_id, workspace_text_documents) + + +def _svcs_in_source_for_req( + raw_req_id: str, + project: ProjectState, + workspace_text_documents: dict, +) -> list[types.Location]: + """Source @Requirements(REQ) → source @SVCs for all SVCs that verify this requirement.""" + svcs = project.get_svcs_for_req(raw_req_id) + locations: list[types.Location] = [] + for svc in svcs: + locations.extend(_svc_annotations_in_source(svc.id.id, workspace_text_documents)) + return locations + + +def _svc_annotations_in_source(bare_svc_id: str, workspace_text_documents: dict) -> list[types.Location]: + """Find @SVCs("SVC-001") annotations in open source documents.""" + locations: list[types.Location] = [] + for doc_uri, doc in workspace_text_documents.items(): + if os.path.basename(doc_uri) in REQSTOOL_YAML_FILES: + continue + lang = getattr(doc, "language_id", None) or "" + for ann in find_all_annotations(doc.source, lang): + if ann.kind == "SVCs" and ann.raw_id.split(":")[-1] == bare_svc_id: + locations.append( + types.Location( + uri=doc_uri, + range=types.Range( + start=types.Position(line=ann.line, character=ann.start_col), + end=types.Position(line=ann.line, character=ann.end_col), + ), + ) + ) + return locations diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index cc0f1fc5..c20bdd1d 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -16,6 +16,7 @@ from reqstool.lsp.features.document_symbols import handle_document_symbols from reqstool.lsp.features.hover import handle_hover from reqstool.lsp.features.inlay_hints import handle_inlay_hints +from reqstool.lsp.features.implementation import handle_implementation from reqstool.lsp.features.references import handle_references from reqstool.lsp.features.semantic_tokens import SEMANTIC_TOKEN_LEGEND, handle_semantic_tokens from reqstool.lsp.features.workspace_symbols import handle_workspace_symbols @@ -243,6 +244,20 @@ def on_inlay_hint(ls: ReqstoolLanguageServer, params: types.InlayHintParams) -> ) +@server.feature(types.TEXT_DOCUMENT_IMPLEMENTATION) +def on_implementation(ls: ReqstoolLanguageServer, params: types.ImplementationParams) -> list[types.Location]: + document = ls.workspace.get_text_document(params.text_document.uri) + project = ls.workspace_manager.project_for_file(params.text_document.uri) + return handle_implementation( + uri=params.text_document.uri, + position=params.position, + text=document.source, + language_id=document.language_id or "", + project=project, + workspace_text_documents=ls.workspace.text_documents, + ) + + @server.feature(types.TEXT_DOCUMENT_REFERENCES) def on_references(ls: ReqstoolLanguageServer, params: types.ReferenceParams) -> list[types.Location]: document = ls.workspace.get_text_document(params.text_document.uri) From 0a86ec36661cce6f5beec2a08485168ebbfad5ff Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 22 Mar 2026 23:11:38 +0100 Subject: [PATCH 29/37] feat: navigation, hover, code lens and details view improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation (implementation.py): - YAML requirements.yml Ctrl+F12 now navigates to source @Requirements annotations instead of SVC id: lines, completing YAML→Java/Python navigation Hover (hover.py): - Move [Open Details] link to top of hover tooltip so it's always visible Code lens (codelens.py): - Compact label for multiple requirement IDs: show count summary "N requirements · M SVCs · X✓ Y✗" instead of listing all IDs Details (details.py): - Add lifecycle_state and test_summary to each SVC entry in requirement details response so the extension can show status per SVC Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/codelens.py | 15 ++++---- src/reqstool/lsp/features/details.py | 12 +++++++ src/reqstool/lsp/features/hover.py | 6 ++-- src/reqstool/lsp/features/implementation.py | 38 ++++++++++++--------- 4 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/reqstool/lsp/features/codelens.py b/src/reqstool/lsp/features/codelens.py index 1585626c..18c03b69 100644 --- a/src/reqstool/lsp/features/codelens.py +++ b/src/reqstool/lsp/features/codelens.py @@ -81,17 +81,14 @@ def _req_label(ids: list[str], project: ProjectState) -> str: else: fail_count += 1 - id_parts = [] - for raw_id in ids: - req = project.get_requirement(raw_id) - badge = _lifecycle_badge(req.lifecycle.state) if req else "" - id_parts.append(f"{badge}{raw_id}") - id_str = ", ".join(id_parts) svc_count = len(all_svcs) + counts = f"{svc_count} SVCs" if pass_count == 0 and fail_count == 0 else f"{svc_count} SVCs · {pass_count}✓ {fail_count}✗" - if pass_count == 0 and fail_count == 0: - return f"{id_str}: {svc_count} SVCs" - return f"{id_str}: {svc_count} SVCs · {pass_count}✓ {fail_count}✗" + if len(ids) == 1: + req = project.get_requirement(ids[0]) + badge = _lifecycle_badge(req.lifecycle.state) if req else "" + return f"{badge}{ids[0]}: {counts}" + return f"{len(ids)} requirements · {counts}" def _svc_label(ids: list[str], project: ProjectState) -> str: diff --git a/src/reqstool/lsp/features/details.py b/src/reqstool/lsp/features/details.py index a8ddf454..cb964d82 100644 --- a/src/reqstool/lsp/features/details.py +++ b/src/reqstool/lsp/features/details.py @@ -5,6 +5,16 @@ from reqstool.lsp.project_state import ProjectState +def _svc_test_summary(svc_id: str, project: ProjectState) -> dict: + test_results = project.get_test_results_for_svc(svc_id) + return { + "passed": sum(1 for t in test_results if t.status.value == "passed"), + "failed": sum(1 for t in test_results if t.status.value == "failed"), + "skipped": sum(1 for t in test_results if t.status.value == "skipped"), + "missing": sum(1 for t in test_results if t.status.value == "missing"), + } + + def get_requirement_details(raw_id: str, project: ProjectState) -> dict | None: req = project.get_requirement(raw_id) if req is None: @@ -35,6 +45,8 @@ def get_requirement_details(raw_id: str, project: ProjectState) -> dict | None: "urn": s.id.urn, "title": s.title, "verification": s.verification.value, + "lifecycle_state": s.lifecycle.state.value, + "test_summary": _svc_test_summary(s.id.id, project), } for s in svcs ], diff --git a/src/reqstool/lsp/features/hover.py b/src/reqstool/lsp/features/hover.py index 688de771..f2101307 100644 --- a/src/reqstool/lsp/features/hover.py +++ b/src/reqstool/lsp/features/hover.py @@ -83,6 +83,7 @@ def _hover_requirement(raw_id: str, match, project: ProjectState) -> types.Hover impl_count = len(project.get_impl_annotations_for_req(raw_id)) parts = [ + _open_details_link(raw_id, "requirement"), f"### {req.title}", f"`{req.id.id}` `{req.significance.value}` `{req.revision}`", "---", @@ -97,8 +98,6 @@ def _hover_requirement(raw_id: str, match, project: ProjectState) -> types.Hover f"**Lifecycle**: {req.lifecycle.state.value}", f"**SVCs**: {svc_ids}", f"**Implementations**: {impl_count}", - "---", - _open_details_link(raw_id, "requirement"), ] ) md = "\n\n".join(parts) @@ -128,6 +127,7 @@ def _hover_svc(raw_id: str, match, project: ProjectState) -> types.Hover | None: mvr_failed = sum(1 for m in mvrs if not m.passed) parts = [ + _open_details_link(raw_id, "svc"), f"### {svc.title}", f"`{svc.id.id}` `{svc.verification.value}` `{svc.revision}`", "---", @@ -144,8 +144,6 @@ def _hover_svc(raw_id: str, match, project: ProjectState) -> types.Hover | None: f"**Requirements**: {req_ids}", f"**Tests**: {test_passed} passed · {test_failed} failed · {test_missing} missing", f"**MVRs**: {mvr_passed} passed · {mvr_failed} failed", - "---", - _open_details_link(raw_id, "svc"), ] ) md = "\n\n".join(parts) diff --git a/src/reqstool/lsp/features/implementation.py b/src/reqstool/lsp/features/implementation.py index 800bf8c5..53b16153 100644 --- a/src/reqstool/lsp/features/implementation.py +++ b/src/reqstool/lsp/features/implementation.py @@ -9,7 +9,6 @@ from lsprotocol import types from reqstool.lsp.annotation_parser import annotation_at_position, find_all_annotations -from reqstool.lsp.features.definition import find_id_in_yaml from reqstool.lsp.project_state import ProjectState logger = logging.getLogger(__name__) @@ -36,14 +35,10 @@ def handle_implementation( if project is None or not project.ready: return [] - initial_urn = project.get_initial_urn() - if not initial_urn: - return [] - basename = os.path.basename(uri) if basename == "requirements.yml": - return _from_yaml_req(text, position, initial_urn, project) + return _from_yaml_req(text, position, workspace_text_documents) if basename == "software_verification_cases.yml": return _from_yaml_svc(text, position, workspace_text_documents) @@ -60,26 +55,37 @@ def handle_implementation( def _from_yaml_req( text: str, position: types.Position, - initial_urn: str, - project: ProjectState, + workspace_text_documents: dict, ) -> list[types.Location]: - """YAML requirements.yml id: REQ → SVC id: lines in svcs.yml.""" + """YAML requirements.yml id: REQ → source @Requirements annotations.""" lines = text.splitlines() if position.line >= len(lines): return [] m = _ID_LINE_RE.match(lines[position.line]) if not m: return [] - raw_id = m.group(1) + bare_id = m.group(1).split(":")[-1] + return _req_annotations_in_source(bare_id, workspace_text_documents) - svcs_path = project.get_yaml_path(initial_urn, "svcs") - if not svcs_path: - return [] - svcs = project.get_svcs_for_req(raw_id) +def _req_annotations_in_source(bare_req_id: str, workspace_text_documents: dict) -> list[types.Location]: + """Find @Requirements("REQ-001") annotations in open source documents.""" locations: list[types.Location] = [] - for svc in svcs: - locations.extend(find_id_in_yaml(svcs_path, svc.id.id)) + for doc_uri, doc in workspace_text_documents.items(): + if os.path.basename(doc_uri) in REQSTOOL_YAML_FILES: + continue + lang = getattr(doc, "language_id", None) or "" + for ann in find_all_annotations(doc.source, lang): + if ann.kind == "Requirements" and ann.raw_id.split(":")[-1] == bare_req_id: + locations.append( + types.Location( + uri=doc_uri, + range=types.Range( + start=types.Position(line=ann.line, character=ann.start_col), + end=types.Position(line=ann.line, character=ann.end_col), + ), + ) + ) return locations From 3e6b3190d4ad7a957ba7eeb8173e1030da8c8882 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Mon, 23 Mar 2026 00:57:20 +0100 Subject: [PATCH 30/37] feat: add reqstool/list endpoint for outline tree project scope Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/list.py | 38 +++++++++++++++++++++++++++++++ src/reqstool/lsp/server.py | 9 ++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/reqstool/lsp/features/list.py diff --git a/src/reqstool/lsp/features/list.py b/src/reqstool/lsp/features/list.py new file mode 100644 index 00000000..e70f18dd --- /dev/null +++ b/src/reqstool/lsp/features/list.py @@ -0,0 +1,38 @@ +# Copyright © LFV + +from __future__ import annotations + +from reqstool.lsp.project_state import ProjectState + + +def get_list(project: ProjectState) -> dict: + reqs = project._repo.get_all_requirements() if project._repo else {} + svcs = project._repo.get_all_svcs() if project._repo else {} + mvrs = project._repo.get_all_mvrs() if project._repo else {} + + return { + "requirements": [ + { + "id": r.id.id, + "title": r.title, + "lifecycle_state": r.lifecycle.state.value, + } + for r in reqs.values() + ], + "svcs": [ + { + "id": s.id.id, + "title": s.title, + "lifecycle_state": s.lifecycle.state.value, + "verification": s.verification.value, + } + for s in svcs.values() + ], + "mvrs": [ + { + "id": m.id.id, + "passed": m.passed, + } + for m in mvrs.values() + ], + } diff --git a/src/reqstool/lsp/server.py b/src/reqstool/lsp/server.py index c20bdd1d..297724fa 100644 --- a/src/reqstool/lsp/server.py +++ b/src/reqstool/lsp/server.py @@ -12,6 +12,7 @@ from reqstool.lsp.features.completion import handle_completion from reqstool.lsp.features.definition import handle_definition from reqstool.lsp.features.details import get_mvr_details, get_requirement_details, get_svc_details +from reqstool.lsp.features.list import get_list from reqstool.lsp.features.diagnostics import compute_diagnostics from reqstool.lsp.features.document_symbols import handle_document_symbols from reqstool.lsp.features.hover import handle_hover @@ -219,6 +220,14 @@ def on_details(ls: ReqstoolLanguageServer, params) -> dict | None: return _find_details(raw_id, fn, ls) +@server.feature("reqstool/list") +def on_list(ls: ReqstoolLanguageServer, params) -> dict | None: + for project in ls.workspace_manager.all_projects(): + if project.ready: + return get_list(project) + return None + + @server.feature(types.TEXT_DOCUMENT_CODE_LENS, types.CodeLensOptions(resolve_provider=False)) def on_code_lens(ls: ReqstoolLanguageServer, params: types.CodeLensParams) -> list[types.CodeLens]: document = ls.workspace.get_text_document(params.text_document.uri) From f2e829b9cd1b6b1c75f94c4cad3ab16a6b9a0235 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Tue, 24 Mar 2026 00:26:39 +0100 Subject: [PATCH 31/37] fix: black formatting --- src/reqstool/lsp/features/codelens.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/reqstool/lsp/features/codelens.py b/src/reqstool/lsp/features/codelens.py index 18c03b69..968dfe47 100644 --- a/src/reqstool/lsp/features/codelens.py +++ b/src/reqstool/lsp/features/codelens.py @@ -82,7 +82,11 @@ def _req_label(ids: list[str], project: ProjectState) -> str: fail_count += 1 svc_count = len(all_svcs) - counts = f"{svc_count} SVCs" if pass_count == 0 and fail_count == 0 else f"{svc_count} SVCs · {pass_count}✓ {fail_count}✗" + counts = ( + f"{svc_count} SVCs" + if pass_count == 0 and fail_count == 0 + else f"{svc_count} SVCs · {pass_count}✓ {fail_count}✗" + ) if len(ids) == 1: req = project.get_requirement(ids[0]) From cd172cd3b5ce1d63e5282eefffc9426ece54ec66 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Fri, 3 Apr 2026 09:57:50 +0200 Subject: [PATCH 32/37] fix: promote LSP deps to core and fix test import of find_id_in_yaml Move pygls and lsprotocol from optional [lsp] extra into core dependencies so no reqstool[lsp] install is needed. Fix test_definition.py import of renamed public function find_id_in_yaml (was _find_id_in_yaml). Signed-off-by: Jimisola Laursen --- pyproject.toml | 5 ----- tests/unit/reqstool/lsp/test_definition.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2df6ae00..781aca79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,10 +53,6 @@ dependencies = [ "packaging==26.0", "requests==2.32.5", "beautifulsoup4==4.14.3", -] - -[project.optional-dependencies] -lsp = [ "pygls>=2.0,<3.0", "lsprotocol>=2024.0.0", ] @@ -83,7 +79,6 @@ dataset_directory = "docs/reqstool" output_directory = "build/reqstool" [tool.hatch.envs.dev] -features = ["lsp"] dependencies = [ "pytest==8.3.5", "pytest-sugar==1.0.0", diff --git a/tests/unit/reqstool/lsp/test_definition.py b/tests/unit/reqstool/lsp/test_definition.py index ed3b2e28..2e0a0b29 100644 --- a/tests/unit/reqstool/lsp/test_definition.py +++ b/tests/unit/reqstool/lsp/test_definition.py @@ -6,7 +6,7 @@ from reqstool.lsp.features.definition import ( handle_definition, - _find_id_in_yaml, + find_id_in_yaml as _find_id_in_yaml, _id_at_yaml_position, _path_to_uri, ) From 233716cdbc6b205e9d9d33ecab2c414e2533d4bd Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Fri, 3 Apr 2026 11:35:26 +0200 Subject: [PATCH 33/37] fix: include requirement IDs in codelens title for multi-ID annotations Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/features/codelens.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/reqstool/lsp/features/codelens.py b/src/reqstool/lsp/features/codelens.py index 968dfe47..f924d11a 100644 --- a/src/reqstool/lsp/features/codelens.py +++ b/src/reqstool/lsp/features/codelens.py @@ -88,11 +88,13 @@ def _req_label(ids: list[str], project: ProjectState) -> str: else f"{svc_count} SVCs · {pass_count}✓ {fail_count}✗" ) - if len(ids) == 1: - req = project.get_requirement(ids[0]) + badges = [] + for raw_id in ids: + req = project.get_requirement(raw_id) badge = _lifecycle_badge(req.lifecycle.state) if req else "" - return f"{badge}{ids[0]}: {counts}" - return f"{len(ids)} requirements · {counts}" + badges.append(f"{badge}{raw_id}") + id_str = ", ".join(badges) + return f"{id_str}: {counts}" def _svc_label(ids: list[str], project: ProjectState) -> str: From f024e8d3c0ea1bb308905c78177f215835171bd8 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sat, 4 Apr 2026 13:41:45 +0200 Subject: [PATCH 34/37] feat: skip directories with .reqstoolignore during LSP root discovery Introduce a .reqstoolignore sentinel file convention for the LSP's workspace discovery. When _walk_dir encounters a directory containing .reqstoolignore it skips that directory entirely, preventing test fixture requirements.yml files from being loaded as root projects. Add .reqstoolignore to tests/resources/test_data/ so that the reqstool-client project's own test fixtures are not discovered when the project is opened in VS Code. Fixes: reqstool/reqstool-client#323 Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/root_discovery.py | 2 + tests/resources/test_data/.reqstoolignore | 0 .../unit/reqstool/lsp/test_root_discovery.py | 55 +++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 tests/resources/test_data/.reqstoolignore create mode 100644 tests/unit/reqstool/lsp/test_root_discovery.py diff --git a/src/reqstool/lsp/root_discovery.py b/src/reqstool/lsp/root_discovery.py index c30d2d83..2e5daa98 100644 --- a/src/reqstool/lsp/root_discovery.py +++ b/src/reqstool/lsp/root_discovery.py @@ -58,6 +58,8 @@ def _find_requirements_files(workspace_folder: str) -> list[str]: def _walk_dir(dirpath: str, depth: int, results: list[str]) -> None: if depth > MAX_DEPTH: return + if os.path.exists(os.path.join(dirpath, ".reqstoolignore")): + return try: entries = os.scandir(dirpath) except PermissionError: diff --git a/tests/resources/test_data/.reqstoolignore b/tests/resources/test_data/.reqstoolignore new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/reqstool/lsp/test_root_discovery.py b/tests/unit/reqstool/lsp/test_root_discovery.py new file mode 100644 index 00000000..9d5228a2 --- /dev/null +++ b/tests/unit/reqstool/lsp/test_root_discovery.py @@ -0,0 +1,55 @@ +# Copyright © LFV + +import os +import tempfile + +from reqstool.lsp.root_discovery import discover_root_projects + + +def _write_requirements_yml(directory: str, urn: str, variant: str = "microservice") -> None: + os.makedirs(directory, exist_ok=True) + path = os.path.join(directory, "requirements.yml") + with open(path, "w") as f: + f.write(f"metadata:\n urn: {urn}\n variant: {variant}\n") + + +def test_reqstoolignore_skips_directory(): + """A directory containing .reqstoolignore must not yield any projects.""" + with tempfile.TemporaryDirectory() as tmp: + # Real project + real = os.path.join(tmp, "docs", "reqstool") + _write_requirements_yml(real, "my-project") + + # Test fixture directory with .reqstoolignore + fixture_root = os.path.join(tmp, "tests", "resources") + os.makedirs(fixture_root, exist_ok=True) + open(os.path.join(fixture_root, ".reqstoolignore"), "w").close() + + fixture = os.path.join(fixture_root, "fixture-a") + _write_requirements_yml(fixture, "fixture-a") + + roots = discover_root_projects(tmp) + urns = {p.urn for p in roots} + assert "my-project" in urns + assert "fixture-a" not in urns + + +def test_reqstoolignore_at_workspace_root_skips_all(): + """A .reqstoolignore at the workspace root itself means nothing is discovered.""" + with tempfile.TemporaryDirectory() as tmp: + open(os.path.join(tmp, ".reqstoolignore"), "w").close() + _write_requirements_yml(os.path.join(tmp, "docs", "reqstool"), "my-project") + roots = discover_root_projects(tmp) + assert roots == [] + + +def test_no_reqstoolignore_discovers_all_roots(): + """Without .reqstoolignore, all non-referenced projects are roots.""" + with tempfile.TemporaryDirectory() as tmp: + _write_requirements_yml(os.path.join(tmp, "docs", "reqstool"), "project-a") + _write_requirements_yml(os.path.join(tmp, "services", "svc-b", "docs", "reqstool"), "project-b") + + roots = discover_root_projects(tmp) + urns = {p.urn for p in roots} + assert "project-a" in urns + assert "project-b" in urns From 2f85032ad691e47c290a652add0d9a805bcfa942 Mon Sep 17 00:00:00 2001 From: Jonas Werne Date: Sat, 4 Apr 2026 16:24:13 +0200 Subject: [PATCH 35/37] fix: add missing .reqstoolignore file --- tests/fixtures/reqstool-regression-python/.reqstoolignore | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/fixtures/reqstool-regression-python/.reqstoolignore diff --git a/tests/fixtures/reqstool-regression-python/.reqstoolignore b/tests/fixtures/reqstool-regression-python/.reqstoolignore new file mode 100644 index 00000000..e69de29b From bcd430d6f2e6ddb35533aef683c75ef8b54ff664 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sat, 4 Apr 2026 16:37:28 +0200 Subject: [PATCH 36/37] fix: move .reqstoolignore to tests/fixtures/ to avoid blocking integration tests Placing .reqstoolignore inside tests/fixtures/reqstool-regression-python/ caused the LSP integration tests to fail: _walk_dir finds the file at depth=0 (the workspace root) and skips the entire directory, so the regression fixture project is never discovered and all LSP features return 'project not loaded'. Move the file up one level to tests/fixtures/ so it only blocks discovery when the full reqstool-client project is opened as a workspace. When the integration test uses reqstool-regression-python as the workspace root directly, _walk_dir starts at that directory (depth=0, no .reqstoolignore present) and correctly discovers requirements.yml. Signed-off-by: Jimisola Laursen --- tests/fixtures/{reqstool-regression-python => }/.reqstoolignore | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/fixtures/{reqstool-regression-python => }/.reqstoolignore (100%) diff --git a/tests/fixtures/reqstool-regression-python/.reqstoolignore b/tests/fixtures/.reqstoolignore similarity index 100% rename from tests/fixtures/reqstool-regression-python/.reqstoolignore rename to tests/fixtures/.reqstoolignore From 553a98f823480e9fc67cb55cbf0fc760047942a0 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sat, 4 Apr 2026 18:11:30 +0200 Subject: [PATCH 37/37] feat: add glob pattern support to .reqstoolignore Patterns (one per line, # comments ignored) are matched with fnmatch against immediate child directory names. An empty or comments-only file has no effect; use ** to skip all children. Update tests/resources/test_data/.reqstoolignore and tests/fixtures/.reqstoolignore to use the explicit ** pattern with an explanatory comment instead of relying on an empty file. Signed-off-by: Jimisola Laursen --- src/reqstool/lsp/root_discovery.py | 28 ++++- tests/fixtures/.reqstoolignore | 3 + tests/resources/test_data/.reqstoolignore | 3 + .../unit/reqstool/lsp/test_root_discovery.py | 102 +++++++++++++++--- 4 files changed, 118 insertions(+), 18 deletions(-) diff --git a/src/reqstool/lsp/root_discovery.py b/src/reqstool/lsp/root_discovery.py index 2e5daa98..44494026 100644 --- a/src/reqstool/lsp/root_discovery.py +++ b/src/reqstool/lsp/root_discovery.py @@ -2,6 +2,7 @@ from __future__ import annotations +import fnmatch import logging import os from dataclasses import dataclass, field @@ -55,11 +56,31 @@ def _find_requirements_files(workspace_folder: str) -> list[str]: return results +def _read_ignore_patterns(dirpath: str) -> list[str] | None: + """Read .reqstoolignore from dirpath. + + Returns: + None — no .reqstoolignore file present + [] (empty) — file exists but is empty; caller should skip dirpath entirely + [pattern, ...] — glob patterns; caller should skip child dirs whose names match + """ + ignore_file = os.path.join(dirpath, ".reqstoolignore") + if not os.path.exists(ignore_file): + return None + try: + with open(ignore_file) as f: + patterns = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")] + except OSError: + return None + return patterns + + def _walk_dir(dirpath: str, depth: int, results: list[str]) -> None: if depth > MAX_DEPTH: return - if os.path.exists(os.path.join(dirpath, ".reqstoolignore")): - return + + patterns = _read_ignore_patterns(dirpath) # None → no file; [] → no effective patterns + try: entries = os.scandir(dirpath) except PermissionError: @@ -70,7 +91,8 @@ def _walk_dir(dirpath: str, depth: int, results: list[str]) -> None: if entry.is_file() and entry.name == "requirements.yml": results.append(dirpath) elif entry.is_dir() and not entry.name.startswith(".") and entry.name not in SKIP_DIRS: - subdirs.append(entry.path) + if not patterns or not any(fnmatch.fnmatch(entry.name, p) for p in patterns): + subdirs.append(entry.path) for subdir in subdirs: _walk_dir(subdir, depth + 1, results) diff --git a/tests/fixtures/.reqstoolignore b/tests/fixtures/.reqstoolignore index e69de29b..b0fc32b8 100644 --- a/tests/fixtures/.reqstoolignore +++ b/tests/fixtures/.reqstoolignore @@ -0,0 +1,3 @@ +# Skip all subdirectories — fixture projects must not be discovered by the +# LSP workspace scanner when the reqstool-client project itself is opened. +** diff --git a/tests/resources/test_data/.reqstoolignore b/tests/resources/test_data/.reqstoolignore index e69de29b..54e2ef33 100644 --- a/tests/resources/test_data/.reqstoolignore +++ b/tests/resources/test_data/.reqstoolignore @@ -0,0 +1,3 @@ +# Skip all subdirectories — test fixture requirements.yml files must not be +# discovered by the LSP workspace scanner. +** diff --git a/tests/unit/reqstool/lsp/test_root_discovery.py b/tests/unit/reqstool/lsp/test_root_discovery.py index 9d5228a2..4c754cc2 100644 --- a/tests/unit/reqstool/lsp/test_root_discovery.py +++ b/tests/unit/reqstool/lsp/test_root_discovery.py @@ -13,20 +13,25 @@ def _write_requirements_yml(directory: str, urn: str, variant: str = "microservi f.write(f"metadata:\n urn: {urn}\n variant: {variant}\n") -def test_reqstoolignore_skips_directory(): - """A directory containing .reqstoolignore must not yield any projects.""" +def _write_reqstoolignore(directory: str, content: str = "") -> None: + os.makedirs(directory, exist_ok=True) + with open(os.path.join(directory, ".reqstoolignore"), "w") as f: + f.write(content) + + +# --------------------------------------------------------------------------- +# ** pattern — skip all child directories +# --------------------------------------------------------------------------- + + +def test_reqstoolignore_wildcard_skips_all_children(): + """The ** pattern skips every child directory.""" with tempfile.TemporaryDirectory() as tmp: - # Real project - real = os.path.join(tmp, "docs", "reqstool") - _write_requirements_yml(real, "my-project") + _write_requirements_yml(os.path.join(tmp, "docs", "reqstool"), "my-project") - # Test fixture directory with .reqstoolignore fixture_root = os.path.join(tmp, "tests", "resources") - os.makedirs(fixture_root, exist_ok=True) - open(os.path.join(fixture_root, ".reqstoolignore"), "w").close() - - fixture = os.path.join(fixture_root, "fixture-a") - _write_requirements_yml(fixture, "fixture-a") + _write_reqstoolignore(fixture_root, "**\n") + _write_requirements_yml(os.path.join(fixture_root, "fixture-a"), "fixture-a") roots = discover_root_projects(tmp) urns = {p.urn for p in roots} @@ -34,13 +39,80 @@ def test_reqstoolignore_skips_directory(): assert "fixture-a" not in urns -def test_reqstoolignore_at_workspace_root_skips_all(): - """A .reqstoolignore at the workspace root itself means nothing is discovered.""" +def test_reqstoolignore_wildcard_at_workspace_root_skips_all(): + """** at the workspace root blocks discovery of all nested projects.""" + with tempfile.TemporaryDirectory() as tmp: + _write_reqstoolignore(tmp, "**\n") + _write_requirements_yml(os.path.join(tmp, "docs", "reqstool"), "my-project") + assert discover_root_projects(tmp) == [] + + +# --------------------------------------------------------------------------- +# Named patterns — skip only matching child directories +# --------------------------------------------------------------------------- + + +def test_reqstoolignore_named_patterns_skip_matching_children(): + """Named patterns skip only child directories whose names match.""" + with tempfile.TemporaryDirectory() as tmp: + _write_requirements_yml(os.path.join(tmp, "docs", "reqstool"), "my-project") + + tests_dir = os.path.join(tmp, "tests") + _write_reqstoolignore(tests_dir, "fixtures\nresources\n") + + # Skipped — names match patterns + _write_requirements_yml(os.path.join(tests_dir, "fixtures", "proj-a"), "proj-a") + _write_requirements_yml(os.path.join(tests_dir, "resources", "proj-b"), "proj-b") + + # Not skipped — name does not match + _write_requirements_yml(os.path.join(tests_dir, "integration", "docs", "reqstool"), "proj-c") + + roots = discover_root_projects(tmp) + urns = {p.urn for p in roots} + assert "my-project" in urns + assert "proj-a" not in urns + assert "proj-b" not in urns + assert "proj-c" in urns + + +# --------------------------------------------------------------------------- +# Empty file / comments only — no effect +# --------------------------------------------------------------------------- + + +def test_reqstoolignore_empty_has_no_effect(): + """An empty .reqstoolignore file does not restrict discovery.""" with tempfile.TemporaryDirectory() as tmp: - open(os.path.join(tmp, ".reqstoolignore"), "w").close() _write_requirements_yml(os.path.join(tmp, "docs", "reqstool"), "my-project") + + fixture_root = os.path.join(tmp, "tests", "resources") + _write_reqstoolignore(fixture_root) # empty + _write_requirements_yml(os.path.join(fixture_root, "fixture-a"), "fixture-a") + roots = discover_root_projects(tmp) - assert roots == [] + urns = {p.urn for p in roots} + assert "my-project" in urns + assert "fixture-a" in urns # discovered normally + + +def test_reqstoolignore_comments_only_has_no_effect(): + """A .reqstoolignore with only comments does not restrict discovery.""" + with tempfile.TemporaryDirectory() as tmp: + _write_requirements_yml(os.path.join(tmp, "docs", "reqstool"), "my-project") + + tests_dir = os.path.join(tmp, "tests") + _write_reqstoolignore(tests_dir, "# this is a comment\n") + _write_requirements_yml(os.path.join(tests_dir, "fixtures", "docs", "reqstool"), "fixture-proj") + + roots = discover_root_projects(tmp) + urns = {p.urn for p in roots} + assert "my-project" in urns + assert "fixture-proj" in urns + + +# --------------------------------------------------------------------------- +# No .reqstoolignore — normal discovery +# --------------------------------------------------------------------------- def test_no_reqstoolignore_discovers_all_roots():