Skip to content

Commit b2b7fe3

Browse files
committed
Finish migration of diagnostics to usage of API server
1 parent 0a34fa8 commit b2b7fe3

20 files changed

Lines changed: 640 additions & 75 deletions

File tree

docs/api-protocol.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ Execute an action with streaming partial results. The server sends
333333
"params": {"file_paths": ["/path/to/file.py"]},
334334
"partial_result_token": "diag_1",
335335
"options": {
336+
"result_formats": ["json", "string"],
336337
"trigger": "system",
337338
"dev_env": "ide"
338339
}
@@ -341,10 +342,20 @@ Execute an action with streaming partial results. The server sends
341342

342343
Required: `action`, `project`, `partial_result_token`.
343344

345+
Supported `result_formats`: `"json"`, `"string"`, etc. (same as `actions/run`).
346+
344347
**Result:** Same as `actions/run` (the final aggregated result).
345348

346349
During execution, the server sends `actions/partialResult` notifications (see below).
347350

351+
> **Guarantee:** The API server always delivers results via `actions/partialResult`
352+
> notifications, even when an extension runner does not stream incrementally (i.e.
353+
> it collects all results internally and returns them as a single final response).
354+
> In that case the server emits the final result as a partial result notification
355+
> before returning the aggregated response. Clients can therefore rely solely on
356+
> `actions/partialResult` notifications to receive results and safely ignore the
357+
> response body of this request.
358+
348359
---
349360

350361
#### `actions/reload`
@@ -513,11 +524,23 @@ Sent during `actions/runWithPartialResults` execution as results stream in.
513524
**Params:**
514525

515526
```json
516-
{"token": "diag_1", "value": {"messages": {"file.py": [...]}}}
527+
{
528+
"token": "diag_1",
529+
"value": {
530+
"result_by_format": {
531+
"json": {"messages": {"file.py": [...]}},
532+
"string": "3 issues found in file.py"
533+
}
534+
}
535+
}
517536
```
518537

519538
`token` matches the `partial_result_token` from the originating request.
520539

540+
`result_by_format` contains results in all formats requested in the originating
541+
`actions/runWithPartialResults` params (same structure as `actions/run` response,
542+
but without `return_code`).
543+
521544
> **Note:** Notifications are delivered only to the client connection that
522545
> initiated the corresponding `actions/runWithPartialResults` request. The
523546
> API server does **not** broadcast these messages to every connected client.

extensions/fine_python_flake8/fine_python_flake8/action.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ def map_flake8_check_result_to_lint_message(result: tuple) -> lint_files_action.
2525
error_code, line_number, column, text, physical_line = result
2626
return lint_files_action.LintMessage(
2727
range=lint_files_action.Range(
28-
start=lint_files_action.Position(line=line_number, character=column),
28+
start=lint_files_action.Position(line=line_number - 1, character=column),
2929
end=lint_files_action.Position(
30-
line=line_number,
30+
line=line_number - 1,
3131
character=len(physical_line) if physical_line is not None else column,
3232
),
3333
),

extensions/fine_python_mypy/fine_python_mypy/output_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
ERROR_CODE_BASE_URL = "https://mypy.readthedocs.io/en/latest/_refs.html#code-"
1919
SEE_HREF_PREFIX = "See https://mypy.readthedocs.io"
2020
SEE_PREFIX_LEN = len("See ")
21-
LINE_OFFSET = 0
21+
LINE_OFFSET = 1
2222
CHAR_OFFSET = 1
2323
NOTE_CODE = "note"
2424

extensions/fine_python_pyrefly/fine_python_pyrefly/lint_files_handler.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,8 @@ def map_pyrefly_error_to_lint_message(error: dict) -> lint_files_action.LintMess
217217

218218
return lint_files_action.LintMessage(
219219
range=lint_files_action.Range(
220-
start=lint_files_action.Position(line=start_line, character=start_column),
221-
end=lint_files_action.Position(line=end_line, character=end_column),
220+
start=lint_files_action.Position(line=start_line - 1, character=start_column),
221+
end=lint_files_action.Position(line=end_line - 1, character=end_column),
222222
),
223223
message=error.get("description", ""),
224224
code=error_code,

extensions/fine_python_ruff/fine_python_ruff/lint_files_handler.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,8 @@ def map_ruff_violation_to_lint_message(
201201

202202
return lint_files_action.LintMessage(
203203
range=lint_files_action.Range(
204-
start=lint_files_action.Position(line=start_line, character=start_column),
205-
end=lint_files_action.Position(line=end_line, character=end_column),
204+
start=lint_files_action.Position(line=start_line - 1, character=start_column),
205+
end=lint_files_action.Position(line=end_line - 1, character=end_column),
206206
),
207207
message=violation.get("message", ""),
208208
code=code,

finecode_extension_api/src/finecode_extension_api/actions/lint_files.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@
88

99
@dataclasses.dataclass
1010
class Position:
11+
"""A position in a text document.
12+
13+
Both ``line`` and ``character`` are **0-based**, matching the LSP specification:
14+
- ``line``: 0-based line index (line 0 = first line of the file).
15+
- ``character``: 0-based UTF-16 code unit offset within the line.
16+
17+
Extension authors note: most CLI linters (ruff, mypy, flake8) report 1-based line
18+
numbers in their output. You must subtract 1 when building a ``Position`` from such
19+
output::
20+
21+
# ruff JSON: location["row"] is 1-based
22+
Position(line=location["row"] - 1, character=location["column"])
23+
24+
Extensions that receive diagnostics from an embedded LSP server (via
25+
``map_diagnostics_to_lint_messages``) get 0-based values directly from the LSP
26+
protocol — do NOT subtract 1 in that case.
27+
"""
28+
1129
line: int
1230
character: int
1331

@@ -91,8 +109,8 @@ def to_text(self) -> str | textstyler.StyledText:
91109
if message.source is not None:
92110
source_str = f" ({message.source})"
93111
text.append_styled(file_path_str, bold=True)
94-
text.append(f":{message.range.start.line}")
95-
text.append(f":{message.range.start.character}: ")
112+
text.append(f":{message.range.start.line + 1}")
113+
text.append(f":{message.range.start.character + 1}: ")
96114
if message.code is not None:
97115
text.append_styled(
98116
message.code, foreground=textstyler.Color.RED
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import dataclasses
2+
3+
from loguru import logger
4+
from pydantic.dataclasses import dataclass as pydantic_dataclass
5+
6+
from finecode_extension_api import code_action
7+
from finecode_extension_runner import global_state, run_utils
8+
9+
10+
async def merge_results(action_name: str, results: list[dict]) -> dict:
11+
"""Merge multiple serialized action results into one using the action's result type.
12+
13+
Each entry in ``results`` must be a dict produced by ``dataclasses.asdict()``
14+
of the action's ``RESULT_TYPE``. Merging is delegated to
15+
``RunActionResult.update()``, the same mechanism the runner uses when
16+
combining results from multiple handlers within a single run.
17+
"""
18+
if global_state.runner_context is None:
19+
raise ValueError("Extension runner is not initialized yet")
20+
21+
# Prefer cached result_type to avoid re-importing the action module.
22+
action_cache = global_state.runner_context.action_cache_by_name.get(action_name)
23+
if action_cache is not None and action_cache.exec_info is not None:
24+
result_type = action_cache.exec_info.result_type
25+
else:
26+
# Cold cache: action hasn't been run yet in this runner; import the type.
27+
try:
28+
action = global_state.runner_context.project.actions[action_name]
29+
except KeyError:
30+
raise ValueError(f"Action '{action_name}' not found")
31+
action_type = run_utils.import_module_member_by_source_str(action.source)
32+
result_type = action_type.RESULT_TYPE
33+
34+
non_empty = [r for r in results if r]
35+
if result_type is None or not non_empty:
36+
return {}
37+
38+
result_type_pydantic = pydantic_dataclass(result_type)
39+
40+
merged: code_action.RunActionResult | None = None
41+
for result_dict in non_empty:
42+
typed = result_type_pydantic(**result_dict)
43+
if merged is None:
44+
merged = typed
45+
else:
46+
merged.update(typed)
47+
48+
if merged is None:
49+
return {}
50+
51+
logger.trace(f"merge_results: merged {len(non_empty)} results for action '{action_name}'")
52+
return dataclasses.asdict(merged)

finecode_extension_runner/src/finecode_extension_runner/_services/run_action.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ async def run_action(
7676
last_run_id += 1
7777

7878
logger.trace(
79-
f"Run action '{action_def.name}', run id: {run_id}, partial result token: {partial_result_token}"
79+
f"run_action: action='{action_def.name}', run_id={run_id}, partial_result_token={partial_result_token}"
8080
)
8181

8282
# TODO: check whether config is set: this will be solved by passing initial
@@ -144,6 +144,7 @@ async def run_action(
144144

145145
try:
146146
send_partial_results = partial_result_token is not None
147+
logger.trace(f"R{run_id} | send_partial_results={send_partial_results}, partial_result_token={partial_result_token}, payload_type={type(payload).__name__}, is_iterable={isinstance(payload, collections.abc.AsyncIterable)}")
147148
with action_exec_info.process_executor.activate():
148149
# action payload can be iterable or not
149150
if isinstance(payload, collections.abc.AsyncIterable):
@@ -417,11 +418,12 @@ def create_action_exec_info(action: domain.ActionDeclaration) -> domain.ActionEx
417418

418419
payload_type = action_type_def.PAYLOAD_TYPE
419420
run_context_type = action_type_def.RUN_CONTEXT_TYPE
421+
result_type = action_type_def.RESULT_TYPE
420422

421423
# TODO: validate that classes and correct subclasses?
422424

423425
action_exec_info = domain.ActionExecInfo(
424-
payload_type=payload_type, run_context_type=run_context_type
426+
payload_type=payload_type, run_context_type=run_context_type, result_type=result_type
425427
)
426428
return action_exec_info
427429

finecode_extension_runner/src/finecode_extension_runner/domain.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,13 @@ def __init__(
5454
self,
5555
payload_type: type[code_action.RunActionPayload] | None,
5656
run_context_type: type[code_action.RunActionContext] | None,
57+
result_type: type[code_action.RunActionResult] | None = None,
5758
) -> None:
5859
self.payload_type: type[code_action.RunActionPayload] | None = payload_type
5960
self.run_context_type: type[code_action.RunActionContext] | None = (
6061
run_context_type
6162
)
63+
self.result_type: type[code_action.RunActionResult] | None = result_type
6264
# instantiation of process executor impl is cheap. To avoid analyzing all
6365
# action handlers and checking whether they need process executor, just
6466
# instantiate here. It will be started only if handlers need it.

finecode_extension_runner/src/finecode_extension_runner/lsp_server.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
from finecode_extension_runner import schemas, services
2727
from finecode_extension_runner._services import run_action as run_action_service
28+
from finecode_extension_runner._services import merge_results as merge_results_service
2829
from finecode_extension_runner.di import resolver
2930

3031
import sys
@@ -258,6 +259,9 @@ def create_lsp_server() -> lsp_server.LanguageServer:
258259
register_resolve_package_path_cmd = server.command("packages/resolvePath")
259260
register_resolve_package_path_cmd(resolve_package_path)
260261

262+
register_merge_results_cmd = server.command("actions/mergeResults")
263+
register_merge_results_cmd(merge_results_cmd)
264+
261265
def on_process_exit():
262266
logger.info("Exit extension runner")
263267
services.shutdown_all_action_handlers()
@@ -270,8 +274,8 @@ def send_partial_result(
270274
) -> None:
271275
partial_result_dict = dataclasses.asdict(partial_result)
272276
partial_result_json = json.dumps(partial_result_dict)
273-
logger.debug(
274-
f"Send partial result for {token}, length {len(partial_result_json)}"
277+
logger.trace(
278+
f"send_partial_result: token={token}, length={len(partial_result_json)}, preview={partial_result_json[:200]}"
275279
)
276280
server.progress(types.ProgressParams(token=token, value=partial_result_json))
277281

@@ -526,3 +530,13 @@ async def resolve_package_path(ls: lsp_server.LanguageServer, package_name: str)
526530
result = services.resolve_package_path(package_name)
527531
logger.trace(f"Resolved {package_name} to {result}")
528532
return {"packagePath": result}
533+
534+
535+
async def merge_results_cmd(ls: lsp_server.LanguageServer, action_name: str, results: list):
536+
logger.trace(f"Merge results: action={action_name}, count={len(results)}")
537+
try:
538+
merged = await merge_results_service.merge_results(action_name=action_name, results=results)
539+
return {"merged": merged}
540+
except Exception as exception:
541+
logger.exception(f"Merge results error: {exception}")
542+
return {"error": str(exception)}

0 commit comments

Comments
 (0)