From 94a8dcb0b31f4c2b7cf662660f7c331abdf4d5d7 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 15 Mar 2026 21:31:49 +0100 Subject: [PATCH 01/26] 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/26] 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/26] 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/26] =?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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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" # ---------------------------------------------------------------------------