Skip to content

Commit 7fc2a9c

Browse files
committed
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 <jimisola@jimisola.com>
1 parent 5331f5d commit 7fc2a9c

17 files changed

Lines changed: 1294 additions & 5 deletions
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Copyright © LFV
2+
3+
from __future__ import annotations
4+
5+
import re
6+
7+
from lsprotocol import types
8+
9+
from reqstool.lsp.annotation_parser import annotation_at_position
10+
from reqstool.lsp.project_state import ProjectState
11+
12+
# Patterns matching diagnostic messages from diagnostics.py
13+
_UNKNOWN_REQ_RE = re.compile(r"Unknown requirement: (.+)")
14+
_UNKNOWN_SVC_RE = re.compile(r"Unknown SVC: (.+)")
15+
_LIFECYCLE_RE = re.compile(r"(Requirement|SVC) (.+) is (?:deprecated|obsolete)")
16+
17+
18+
def handle_code_actions(
19+
uri: str,
20+
range_: types.Range,
21+
context: types.CodeActionContext,
22+
text: str,
23+
language_id: str,
24+
project: ProjectState | None,
25+
) -> list[types.CodeAction]:
26+
only = set(context.only) if context.only else None
27+
actions = _actions_from_diagnostics(uri, context.diagnostics, only)
28+
actions += _source_action(uri, range_, text, language_id, project, only)
29+
return actions
30+
31+
32+
def _actions_from_diagnostics(
33+
uri: str,
34+
diagnostics: list,
35+
only: set | None,
36+
) -> list[types.CodeAction]:
37+
actions: list[types.CodeAction] = []
38+
if only is not None and types.CodeActionKind.QuickFix not in only:
39+
return actions
40+
for diag in diagnostics:
41+
if diag.source != "reqstool":
42+
continue
43+
action = _action_from_message(diag.message, uri)
44+
if action is not None:
45+
actions.append(action)
46+
return actions
47+
48+
49+
def _action_from_message(msg: str, uri: str) -> types.CodeAction | None:
50+
m = _UNKNOWN_REQ_RE.match(msg)
51+
if m:
52+
raw_id = m.group(1)
53+
return _make_action(f"Open Details for {raw_id}", raw_id, uri, "requirement", types.CodeActionKind.QuickFix)
54+
m = _UNKNOWN_SVC_RE.match(msg)
55+
if m:
56+
raw_id = m.group(1)
57+
return _make_action(f"Open Details for {raw_id}", raw_id, uri, "svc", types.CodeActionKind.QuickFix)
58+
m = _LIFECYCLE_RE.match(msg)
59+
if m:
60+
kind_label, raw_id = m.group(1), m.group(2)
61+
item_type = "requirement" if kind_label == "Requirement" else "svc"
62+
return _make_action(f"View details for {raw_id}", raw_id, uri, item_type, types.CodeActionKind.QuickFix)
63+
return None
64+
65+
66+
def _source_action(
67+
uri: str,
68+
range_: types.Range,
69+
text: str,
70+
language_id: str,
71+
project: ProjectState | None,
72+
only: set | None,
73+
) -> list[types.CodeAction]:
74+
if project is None or not project.ready:
75+
return []
76+
if only is not None and types.CodeActionKind.Source not in only:
77+
return []
78+
match = annotation_at_position(text, range_.start.line, range_.start.character, language_id)
79+
if match is None:
80+
return []
81+
item_type = "requirement" if match.kind == "Requirements" else "svc"
82+
known = project.get_requirement(match.raw_id) if match.kind == "Requirements" else project.get_svc(match.raw_id)
83+
if known is None:
84+
return []
85+
return [_make_action("Open Details", match.raw_id, uri, item_type, types.CodeActionKind.Source)]
86+
87+
88+
def _make_action(
89+
title: str,
90+
raw_id: str,
91+
uri: str,
92+
item_type: str,
93+
kind: types.CodeActionKind,
94+
) -> types.CodeAction:
95+
return types.CodeAction(
96+
title=title,
97+
kind=kind,
98+
command=types.Command(
99+
title=title,
100+
command="reqstool.openDetails",
101+
arguments=[{"id": raw_id, "uri": uri, "type": item_type}],
102+
),
103+
)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Copyright © LFV
2+
3+
from __future__ import annotations
4+
5+
from lsprotocol import types
6+
7+
from reqstool.lsp.annotation_parser import find_all_annotations
8+
from reqstool.lsp.project_state import ProjectState
9+
10+
11+
def handle_code_lens(
12+
uri: str,
13+
text: str,
14+
language_id: str,
15+
project: ProjectState | None,
16+
) -> list[types.CodeLens]:
17+
if project is None or not project.ready:
18+
return []
19+
20+
annotations = find_all_annotations(text, language_id)
21+
if not annotations:
22+
return []
23+
24+
# Group annotation matches by (line, kind)
25+
by_line: dict[tuple[int, str], list[str]] = {}
26+
for match in annotations:
27+
key = (match.line, match.kind)
28+
by_line.setdefault(key, []).append(match.raw_id)
29+
30+
lines = text.splitlines()
31+
result: list[types.CodeLens] = []
32+
33+
for (line_idx, kind), ids in by_line.items():
34+
line_len = len(lines[line_idx]) if line_idx < len(lines) else 0
35+
lens_range = types.Range(
36+
start=types.Position(line=line_idx, character=0),
37+
end=types.Position(line=line_idx, character=line_len),
38+
)
39+
40+
if kind == "Requirements":
41+
label = _req_label(ids, project)
42+
item_type = "requirement"
43+
else:
44+
label = _svc_label(ids, project)
45+
item_type = "svc"
46+
47+
result.append(
48+
types.CodeLens(
49+
range=lens_range,
50+
command=types.Command(
51+
title=label,
52+
command="reqstool.openDetails",
53+
arguments=[{"id": ids[0], "uri": uri, "type": item_type}],
54+
),
55+
)
56+
)
57+
58+
return result
59+
60+
61+
def _req_label(ids: list[str], project: ProjectState) -> str:
62+
all_svcs = []
63+
for raw_id in ids:
64+
all_svcs.extend(project.get_svcs_for_req(raw_id))
65+
66+
pass_count = 0
67+
fail_count = 0
68+
for svc in all_svcs:
69+
for mvr in project.get_mvrs_for_svc(svc.id.id):
70+
if mvr.passed:
71+
pass_count += 1
72+
else:
73+
fail_count += 1
74+
75+
id_str = ", ".join(ids)
76+
svc_count = len(all_svcs)
77+
78+
if pass_count == 0 and fail_count == 0:
79+
return f"{id_str}: {svc_count} SVCs"
80+
return f"{id_str}: {svc_count} SVCs · {pass_count}{fail_count}✗"
81+
82+
83+
def _svc_label(ids: list[str], project: ProjectState) -> str:
84+
id_str = ", ".join(ids)
85+
if len(ids) == 1:
86+
svc = project.get_svc(ids[0])
87+
if svc is not None:
88+
mvrs = project.get_mvrs_for_svc(ids[0])
89+
if mvrs:
90+
result = "pass" if all(m.passed for m in mvrs) else "fail"
91+
return f"{id_str}: {svc.verification.value} · {result}"
92+
return f"{id_str}: {svc.verification.value}"
93+
return id_str
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Copyright © LFV
2+
3+
from __future__ import annotations
4+
5+
from reqstool.lsp.project_state import ProjectState
6+
7+
8+
def get_requirement_details(raw_id: str, project: ProjectState) -> dict | None:
9+
req = project.get_requirement(raw_id)
10+
if req is None:
11+
return None
12+
svcs = project.get_svcs_for_req(raw_id)
13+
return {
14+
"type": "requirement",
15+
"id": req.id.id,
16+
"urn": str(req.id),
17+
"title": req.title,
18+
"significance": req.significance.value,
19+
"description": req.description,
20+
"rationale": req.rationale or "",
21+
"revision": str(req.revision),
22+
"lifecycle": {
23+
"state": req.lifecycle.state.value,
24+
"reason": req.lifecycle.reason or "",
25+
},
26+
"categories": [c.value for c in req.categories],
27+
"implementation": req.implementation.value,
28+
"svcs": [
29+
{
30+
"id": s.id.id,
31+
"urn": str(s.id),
32+
"title": s.title,
33+
"verification": s.verification.value,
34+
}
35+
for s in svcs
36+
],
37+
}
38+
39+
40+
def get_svc_details(raw_id: str, project: ProjectState) -> dict | None:
41+
svc = project.get_svc(raw_id)
42+
if svc is None:
43+
return None
44+
mvrs = project.get_mvrs_for_svc(raw_id)
45+
return {
46+
"type": "svc",
47+
"id": svc.id.id,
48+
"urn": str(svc.id),
49+
"title": svc.title,
50+
"description": svc.description or "",
51+
"verification": svc.verification.value,
52+
"instructions": svc.instructions or "",
53+
"revision": str(svc.revision),
54+
"lifecycle": {
55+
"state": svc.lifecycle.state.value,
56+
"reason": svc.lifecycle.reason or "",
57+
},
58+
"requirement_ids": [{"id": r.id, "urn": str(r)} for r in svc.requirement_ids],
59+
"mvrs": [
60+
{
61+
"id": m.id.id,
62+
"urn": str(m.id),
63+
"passed": m.passed,
64+
"comment": m.comment or "",
65+
}
66+
for m in mvrs
67+
],
68+
}
69+
70+
71+
def get_mvr_details(raw_id: str, project: ProjectState) -> dict | None:
72+
mvr = project.get_mvr(raw_id)
73+
if mvr is None:
74+
return None
75+
return {
76+
"type": "mvr",
77+
"id": mvr.id.id,
78+
"urn": str(mvr.id),
79+
"passed": mvr.passed,
80+
"comment": mvr.comment or "",
81+
"svc_ids": [{"id": s.id, "urn": str(s)} for s in mvr.svc_ids],
82+
}

src/reqstool/lsp/features/hover.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from __future__ import annotations
44

5+
import json
56
import os
67
import re
8+
import urllib.parse
79

810
from lsprotocol import types
911

@@ -31,10 +33,11 @@ def handle_hover(
3133
if basename in REQSTOOL_YAML_FILES:
3234
return _hover_yaml(text, position, basename)
3335
else:
34-
return _hover_source(text, position, language_id, project)
36+
return _hover_source(uri, text, position, language_id, project)
3537

3638

3739
def _hover_source(
40+
uri: str,
3841
text: str,
3942
position: types.Position,
4043
language_id: str,
@@ -57,14 +60,19 @@ def _hover_source(
5760
)
5861

5962
if match.kind == "Requirements":
60-
return _hover_requirement(match.raw_id, match, project)
63+
return _hover_requirement(match.raw_id, match, project, uri)
6164
elif match.kind == "SVCs":
62-
return _hover_svc(match.raw_id, match, project)
65+
return _hover_svc(match.raw_id, match, project, uri)
6366

6467
return None
6568

6669

67-
def _hover_requirement(raw_id: str, match, project: ProjectState) -> types.Hover | None:
70+
def _open_details_link(raw_id: str, uri: str, kind: str) -> str:
71+
args = urllib.parse.quote(json.dumps({"id": raw_id, "uri": uri, "type": kind}))
72+
return f"[Open Details](command:reqstool.openDetails?{args})"
73+
74+
75+
def _hover_requirement(raw_id: str, match, project: ProjectState, uri: str) -> types.Hover | None:
6876
req = project.get_requirement(raw_id)
6977
if req is None:
7078
md = f"**Unknown requirement**: `{raw_id}`"
@@ -87,6 +95,8 @@ def _hover_requirement(raw_id: str, match, project: ProjectState) -> types.Hover
8795
f"**Categories**: {categories}",
8896
f"**Lifecycle**: {req.lifecycle.state.value}",
8997
f"**SVCs**: {svc_ids}",
98+
"---",
99+
_open_details_link(raw_id, uri, "requirement"),
90100
]
91101
)
92102
md = "\n\n".join(parts)
@@ -100,7 +110,7 @@ def _hover_requirement(raw_id: str, match, project: ProjectState) -> types.Hover
100110
)
101111

102112

103-
def _hover_svc(raw_id: str, match, project: ProjectState) -> types.Hover | None:
113+
def _hover_svc(raw_id: str, match, project: ProjectState, uri: str) -> types.Hover | None:
104114
svc = project.get_svc(raw_id)
105115
if svc is None:
106116
md = f"**Unknown SVC**: `{raw_id}`"
@@ -125,6 +135,8 @@ def _hover_svc(raw_id: str, match, project: ProjectState) -> types.Hover | None:
125135
f"**Lifecycle**: {svc.lifecycle.state.value}",
126136
f"**Requirements**: {req_ids}",
127137
f"**MVRs**: {mvr_info}",
138+
"---",
139+
_open_details_link(raw_id, uri, "svc"),
128140
]
129141
)
130142
md = "\n\n".join(parts)

0 commit comments

Comments
 (0)