fcp-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fcp_python/__init__.py +1 -0
- fcp_python/bridge.py +195 -0
- fcp_python/domain/__init__.py +0 -0
- fcp_python/domain/format.py +221 -0
- fcp_python/domain/model.py +42 -0
- fcp_python/domain/mutation.py +393 -0
- fcp_python/domain/query.py +627 -0
- fcp_python/domain/verbs.py +37 -0
- fcp_python/lsp/__init__.py +1 -0
- fcp_python/lsp/client.py +196 -0
- fcp_python/lsp/lifecycle.py +89 -0
- fcp_python/lsp/transport.py +105 -0
- fcp_python/lsp/types.py +510 -0
- fcp_python/lsp/workspace_edit.py +115 -0
- fcp_python/main.py +288 -0
- fcp_python/resolver/__init__.py +25 -0
- fcp_python/resolver/index.py +55 -0
- fcp_python/resolver/pipeline.py +105 -0
- fcp_python/resolver/selectors.py +161 -0
- fcp_python-0.1.0.dist-info/METADATA +8 -0
- fcp_python-0.1.0.dist-info/RECORD +23 -0
- fcp_python-0.1.0.dist-info/WHEEL +4 -0
- fcp_python-0.1.0.dist-info/entry_points.txt +2 -0
fcp_python/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""fcp-python — Python Code Intelligence FCP server."""
|
fcp_python/bridge.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Slipstream bridge — connects FCP server to daemon via Unix socket.
|
|
2
|
+
|
|
3
|
+
Runs in a daemon thread so it never blocks the main MCP server.
|
|
4
|
+
Silently returns on any connection failure (bridge is invisible
|
|
5
|
+
when Slipstream isn't running).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Any, Awaitable, Callable
|
|
15
|
+
|
|
16
|
+
_AGENT_HELP = """\
|
|
17
|
+
### python — LSP-powered Python navigation and refactoring
|
|
18
|
+
|
|
19
|
+
#### Session
|
|
20
|
+
```
|
|
21
|
+
slipstream fcp python "open /path/to/project"
|
|
22
|
+
slipstream fcp python "status"
|
|
23
|
+
slipstream fcp python "close"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
#### Navigation
|
|
27
|
+
```
|
|
28
|
+
slipstream fcp python_query "find MyClass"
|
|
29
|
+
slipstream fcp python_query "find process kind:function"
|
|
30
|
+
slipstream fcp python_query "def main @file:src/app.py"
|
|
31
|
+
slipstream fcp python_query "refs Config @file:models.py"
|
|
32
|
+
slipstream fcp python_query "symbols src/utils.py"
|
|
33
|
+
slipstream fcp python_query "impl BaseHandler"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
#### Inspection
|
|
37
|
+
```
|
|
38
|
+
slipstream fcp python_query "inspect MyClass"
|
|
39
|
+
slipstream fcp python_query "callers process_data"
|
|
40
|
+
slipstream fcp python_query "callees handle_request"
|
|
41
|
+
slipstream fcp python_query "diagnose"
|
|
42
|
+
slipstream fcp python_query "diagnose src/main.py"
|
|
43
|
+
slipstream fcp python_query "map"
|
|
44
|
+
slipstream fcp python_query "unused @file:src/utils.py"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
#### Refactoring
|
|
48
|
+
```
|
|
49
|
+
slipstream fcp python "rename Config Settings"
|
|
50
|
+
slipstream fcp python "extract validate @file:server.py @lines:15-30"
|
|
51
|
+
slipstream fcp python "import os @file:main.py @line:5"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
#### Selectors
|
|
55
|
+
- `@file:PATH` — filter by file path
|
|
56
|
+
- `@class:NAME` — filter by containing class
|
|
57
|
+
- `@module:NAME` — filter by module
|
|
58
|
+
- `@kind:KIND` — function, class, method, variable, constant, module, property
|
|
59
|
+
- `@line:N` — filter by line number
|
|
60
|
+
- `@lines:N-M` — line range (for extract)
|
|
61
|
+
- `@decorator:NAME` — filter by decorator (e.g. `@decorator:staticmethod`)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def start_bridge(
|
|
66
|
+
handle_session: Callable[[str], Awaitable[str]],
|
|
67
|
+
handle_query: Callable[[str], Awaitable[str]],
|
|
68
|
+
handle_mutation: Callable[[list[str]], Awaitable[str]],
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Connect to Slipstream daemon if available.
|
|
71
|
+
|
|
72
|
+
Spawns a daemon thread with its own event loop. Silently returns
|
|
73
|
+
if the socket is not found or any connection error occurs.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
path = _discover_socket()
|
|
77
|
+
if path is None:
|
|
78
|
+
return
|
|
79
|
+
t = threading.Thread(
|
|
80
|
+
target=_bridge_thread,
|
|
81
|
+
args=(path, handle_session, handle_query, handle_mutation),
|
|
82
|
+
daemon=True,
|
|
83
|
+
)
|
|
84
|
+
t.start()
|
|
85
|
+
except Exception: # noqa: BLE001
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _bridge_thread(
|
|
90
|
+
path: str,
|
|
91
|
+
handle_session: Callable[[str], Awaitable[str]],
|
|
92
|
+
handle_query: Callable[[str], Awaitable[str]],
|
|
93
|
+
handle_mutation: Callable[[list[str]], Awaitable[str]],
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Entry point for the daemon thread."""
|
|
96
|
+
try:
|
|
97
|
+
asyncio.run(_run_bridge_at(path, handle_session, handle_query, handle_mutation))
|
|
98
|
+
except Exception: # noqa: BLE001
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _discover_socket() -> str | None:
|
|
103
|
+
"""Find daemon socket path."""
|
|
104
|
+
# 1. SLIPSTREAM_SOCKET env var
|
|
105
|
+
path = os.environ.get("SLIPSTREAM_SOCKET")
|
|
106
|
+
if path and os.path.exists(path):
|
|
107
|
+
return path
|
|
108
|
+
|
|
109
|
+
# 2. XDG_RUNTIME_DIR/slipstream/daemon.sock
|
|
110
|
+
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
|
111
|
+
if xdg:
|
|
112
|
+
path = os.path.join(xdg, "slipstream", "daemon.sock")
|
|
113
|
+
if os.path.exists(path):
|
|
114
|
+
return path
|
|
115
|
+
|
|
116
|
+
# 3. /tmp/slipstream-{uid}/daemon.sock
|
|
117
|
+
uid = os.getuid()
|
|
118
|
+
path = f"/tmp/slipstream-{uid}/daemon.sock"
|
|
119
|
+
if os.path.exists(path):
|
|
120
|
+
return path
|
|
121
|
+
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _run_bridge_at(
|
|
126
|
+
path: str,
|
|
127
|
+
handle_session: Callable[[str], Awaitable[str]],
|
|
128
|
+
handle_query: Callable[[str], Awaitable[str]],
|
|
129
|
+
handle_mutation: Callable[[list[str]], Awaitable[str]],
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Async loop: connect, register, then handle NDJSON requests."""
|
|
132
|
+
reader, writer = await asyncio.open_unix_connection(path)
|
|
133
|
+
|
|
134
|
+
# Send registration
|
|
135
|
+
register = {
|
|
136
|
+
"jsonrpc": "2.0",
|
|
137
|
+
"method": "fcp.register",
|
|
138
|
+
"params": {
|
|
139
|
+
"handler_name": "fcp-py",
|
|
140
|
+
"extensions": ["py"],
|
|
141
|
+
"capabilities": ["ops", "query", "session"],
|
|
142
|
+
"agent_help": _AGENT_HELP,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
writer.write((json.dumps(register) + "\n").encode())
|
|
146
|
+
await writer.drain()
|
|
147
|
+
|
|
148
|
+
# Request loop (newline-delimited JSON)
|
|
149
|
+
while True:
|
|
150
|
+
line = await reader.readline()
|
|
151
|
+
if not line:
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
req = json.loads(line)
|
|
156
|
+
except json.JSONDecodeError:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
req_id = req.get("id")
|
|
160
|
+
method = req.get("method", "")
|
|
161
|
+
params = req.get("params") or {}
|
|
162
|
+
|
|
163
|
+
text = await _handle_request(
|
|
164
|
+
method, params, handle_session, handle_query, handle_mutation
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
response = {
|
|
168
|
+
"jsonrpc": "2.0",
|
|
169
|
+
"id": req_id,
|
|
170
|
+
"result": {"text": text},
|
|
171
|
+
}
|
|
172
|
+
writer.write((json.dumps(response) + "\n").encode())
|
|
173
|
+
await writer.drain()
|
|
174
|
+
|
|
175
|
+
writer.close()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _handle_request(
|
|
179
|
+
method: str,
|
|
180
|
+
params: dict[str, Any],
|
|
181
|
+
handle_session: Callable[[str], Awaitable[str]],
|
|
182
|
+
handle_query: Callable[[str], Awaitable[str]],
|
|
183
|
+
handle_mutation: Callable[[list[str]], Awaitable[str]],
|
|
184
|
+
) -> str:
|
|
185
|
+
if method == "fcp.session":
|
|
186
|
+
action = params.get("action", "")
|
|
187
|
+
return await handle_session(action)
|
|
188
|
+
elif method == "fcp.ops":
|
|
189
|
+
ops = params.get("ops", [])
|
|
190
|
+
return await handle_mutation(ops)
|
|
191
|
+
elif method == "fcp.query":
|
|
192
|
+
q = params.get("q", "")
|
|
193
|
+
return await handle_query(q)
|
|
194
|
+
else:
|
|
195
|
+
return f"unknown method: {method}"
|
|
File without changes
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Output formatting for query results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fcp_python.lsp.types import (
|
|
6
|
+
CallHierarchyIncomingCall,
|
|
7
|
+
CallHierarchyOutgoingCall,
|
|
8
|
+
CodeAction,
|
|
9
|
+
Diagnostic,
|
|
10
|
+
DiagnosticSeverity,
|
|
11
|
+
DocumentSymbol,
|
|
12
|
+
Location,
|
|
13
|
+
Range,
|
|
14
|
+
)
|
|
15
|
+
from fcp_python.lsp.workspace_edit import ApplyResult
|
|
16
|
+
from fcp_python.resolver.index import SymbolEntry
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_navigation_result(locations: list[Location], description: str) -> str:
|
|
20
|
+
if not locations:
|
|
21
|
+
return f"No {description} found."
|
|
22
|
+
lines = [f"{description} ({len(locations)}):"]
|
|
23
|
+
for loc in locations:
|
|
24
|
+
lines.append(
|
|
25
|
+
f" {short_uri(loc.uri)} L{loc.range.start.line + 1}:{loc.range.start.character + 1}"
|
|
26
|
+
)
|
|
27
|
+
return "\n".join(lines)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_definition(uri: str, range_: Range, source_snippet: str | None = None) -> str:
|
|
31
|
+
result = f"Definition: {short_uri(uri)} L{range_.start.line + 1}:{range_.start.character + 1}"
|
|
32
|
+
if source_snippet:
|
|
33
|
+
result += f"\n\n{source_snippet}"
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_symbol_outline(file: str, symbols: list[DocumentSymbol], indent: int) -> str:
|
|
38
|
+
lines: list[str] = []
|
|
39
|
+
if indent == 0:
|
|
40
|
+
lines.append(f"Symbols in {short_uri(file)}:")
|
|
41
|
+
prefix = " " * (indent + 1)
|
|
42
|
+
for sym in symbols:
|
|
43
|
+
kind_str = sym.kind.display_name()
|
|
44
|
+
lines.append(f"{prefix}{sym.name} ({kind_str}) L{sym.range.start.line + 1}")
|
|
45
|
+
if sym.children:
|
|
46
|
+
lines.append(format_symbol_outline(file, sym.children, indent + 1))
|
|
47
|
+
return "\n".join(lines)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_diagnostics(uri: str, diagnostics: list[Diagnostic]) -> str:
|
|
51
|
+
if not diagnostics:
|
|
52
|
+
return f"{short_uri(uri)}: clean"
|
|
53
|
+
lines = [f"{short_uri(uri)} ({len(diagnostics)} issues):"]
|
|
54
|
+
for d in diagnostics:
|
|
55
|
+
severity = {
|
|
56
|
+
DiagnosticSeverity.Error: "ERROR",
|
|
57
|
+
DiagnosticSeverity.Warning: "WARN",
|
|
58
|
+
DiagnosticSeverity.Information: "INFO",
|
|
59
|
+
DiagnosticSeverity.Hint: "HINT",
|
|
60
|
+
}.get(d.severity, "???") # type: ignore[arg-type]
|
|
61
|
+
lines.append(
|
|
62
|
+
f" L{d.range.start.line + 1}: [{severity}] {summarize_diagnostic_message(d.message)}"
|
|
63
|
+
)
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_disambiguation(name: str, entries: list[SymbolEntry]) -> str:
|
|
68
|
+
lines = [f"? Multiple matches for '{name}'. Narrow with a selector:"]
|
|
69
|
+
for i, entry in enumerate(entries):
|
|
70
|
+
container = f" in {entry.container_name}" if entry.container_name else ""
|
|
71
|
+
lines.append(
|
|
72
|
+
f" {i + 1}. {entry.name} ({entry.kind!r}){container} — {short_uri(entry.uri)}"
|
|
73
|
+
)
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def format_hover(
|
|
78
|
+
name: str, kind: str, uri: str, range_: Range, contents: str
|
|
79
|
+
) -> str:
|
|
80
|
+
lines = [f"{name} ({kind}) — {short_uri(uri)} L{range_.start.line + 1}"]
|
|
81
|
+
if contents:
|
|
82
|
+
lines.append("")
|
|
83
|
+
lines.append(contents)
|
|
84
|
+
return "\n".join(lines)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_callers(name: str, calls: list[CallHierarchyIncomingCall]) -> str:
|
|
88
|
+
if not calls:
|
|
89
|
+
return f"No callers of '{name}'."
|
|
90
|
+
lines = [f"Callers of '{name}' ({len(calls)}):"]
|
|
91
|
+
for call in calls:
|
|
92
|
+
lines.append(
|
|
93
|
+
f" {call.from_item.name} ({call.from_item.kind!r}) — "
|
|
94
|
+
f"{short_uri(call.from_item.uri)} L{call.from_item.range.start.line + 1}"
|
|
95
|
+
)
|
|
96
|
+
return "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def format_callees(name: str, calls: list[CallHierarchyOutgoingCall]) -> str:
|
|
100
|
+
if not calls:
|
|
101
|
+
return f"No callees of '{name}'."
|
|
102
|
+
lines = [f"Callees of '{name}' ({len(calls)}):"]
|
|
103
|
+
for call in calls:
|
|
104
|
+
lines.append(
|
|
105
|
+
f" {call.to.name} ({call.to.kind!r}) — "
|
|
106
|
+
f"{short_uri(call.to.uri)} L{call.to.range.start.line + 1}"
|
|
107
|
+
)
|
|
108
|
+
return "\n".join(lines)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def format_implementations(name: str, locations: list[Location]) -> str:
|
|
112
|
+
if not locations:
|
|
113
|
+
return f"No implementations of '{name}'."
|
|
114
|
+
lines = [f"Implementations of '{name}' ({len(locations)}):"]
|
|
115
|
+
for loc in locations:
|
|
116
|
+
lines.append(
|
|
117
|
+
f" {short_uri(loc.uri)} L{loc.range.start.line + 1}:{loc.range.start.character + 1}"
|
|
118
|
+
)
|
|
119
|
+
return "\n".join(lines)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def format_workspace_map(
|
|
123
|
+
root_uri: str,
|
|
124
|
+
file_count: int,
|
|
125
|
+
symbol_count: int,
|
|
126
|
+
errors: int,
|
|
127
|
+
warnings: int,
|
|
128
|
+
) -> str:
|
|
129
|
+
lines = [
|
|
130
|
+
f"Workspace: {short_uri(root_uri)}",
|
|
131
|
+
f" Files: {file_count}",
|
|
132
|
+
f" Symbols: {symbol_count}",
|
|
133
|
+
]
|
|
134
|
+
if errors > 0 or warnings > 0:
|
|
135
|
+
lines.append(f" Diagnostics: {errors} errors, {warnings} warnings")
|
|
136
|
+
else:
|
|
137
|
+
lines.append(" Diagnostics: clean")
|
|
138
|
+
return "\n".join(lines)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def format_unused(items: list[tuple[str, Diagnostic]]) -> str:
|
|
142
|
+
if not items:
|
|
143
|
+
return "No unused symbols found."
|
|
144
|
+
lines = [f"Unused symbols ({len(items)}):"]
|
|
145
|
+
for uri, diag in items:
|
|
146
|
+
classification = _classify_unused(diag.message)
|
|
147
|
+
lines.append(
|
|
148
|
+
f" {short_uri(uri)} L{diag.range.start.line + 1}: "
|
|
149
|
+
f"[{classification}] {summarize_diagnostic_message(diag.message)}"
|
|
150
|
+
)
|
|
151
|
+
return "\n".join(lines)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def format_mutation_result(
|
|
155
|
+
verb: str, description: str, result: ApplyResult, root_uri: str
|
|
156
|
+
) -> str:
|
|
157
|
+
total = result.total_edits()
|
|
158
|
+
file_count = len(result.files_changed)
|
|
159
|
+
lines = [
|
|
160
|
+
f"{verb}: {description} ({file_count} {'file' if file_count == 1 else 'files'}, "
|
|
161
|
+
f"{total} {'edit' if total == 1 else 'edits'})"
|
|
162
|
+
]
|
|
163
|
+
for uri, count in result.files_changed:
|
|
164
|
+
lines.append(
|
|
165
|
+
f" {relative_path(uri, root_uri)}: {count} {'edit' if count == 1 else 'edits'}"
|
|
166
|
+
)
|
|
167
|
+
for uri in result.files_created:
|
|
168
|
+
lines.append(f" {relative_path(uri, root_uri)} (created)")
|
|
169
|
+
for old, new in result.files_renamed:
|
|
170
|
+
lines.append(
|
|
171
|
+
f" {relative_path(old, root_uri)} → {relative_path(new, root_uri)} (renamed)"
|
|
172
|
+
)
|
|
173
|
+
return "\n".join(lines)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def format_code_action_choices(actions: list[CodeAction]) -> str:
|
|
177
|
+
lines = [f"? Multiple code actions available ({len(actions)}):"]
|
|
178
|
+
for i, action in enumerate(actions):
|
|
179
|
+
kind = action.kind or "unknown"
|
|
180
|
+
preferred = " (preferred)" if action.is_preferred else ""
|
|
181
|
+
lines.append(f" {i + 1}. [{kind}] {action.title}{preferred}")
|
|
182
|
+
return "\n".join(lines)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _classify_unused(message: str) -> str:
|
|
186
|
+
lower = message.lower()
|
|
187
|
+
if "dead_code" in lower or "never constructed" in lower:
|
|
188
|
+
return "dead_code"
|
|
189
|
+
if "never read" in lower:
|
|
190
|
+
return "never_read"
|
|
191
|
+
return "unused"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def format_error(message: str, suggestion: str | None = None) -> str:
|
|
195
|
+
if suggestion:
|
|
196
|
+
return f"! {message} Did you mean '{suggestion}'?"
|
|
197
|
+
return f"! {message}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def summarize_diagnostic_message(raw: str) -> str:
|
|
201
|
+
# Strip Python error code prefixes like "E0308: "
|
|
202
|
+
if raw.startswith("E") and len(raw) > 5:
|
|
203
|
+
code = raw[1:5]
|
|
204
|
+
if code.isdigit():
|
|
205
|
+
after = raw[5:]
|
|
206
|
+
if after.startswith(": "):
|
|
207
|
+
return after[2:]
|
|
208
|
+
return raw
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def short_uri(uri: str) -> str:
|
|
212
|
+
return uri.removeprefix("file://")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def relative_path(uri: str, root_uri: str) -> str:
|
|
216
|
+
path = short_uri(uri)
|
|
217
|
+
root = short_uri(root_uri).rstrip("/")
|
|
218
|
+
if path.startswith(root):
|
|
219
|
+
rel = path[len(root):]
|
|
220
|
+
return rel.lstrip("/")
|
|
221
|
+
return path
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Domain model for Python workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from fcp_python.lsp.client import LspClient
|
|
8
|
+
from fcp_python.lsp.lifecycle import ServerStatus
|
|
9
|
+
from fcp_python.lsp.types import Diagnostic, DiagnosticSeverity
|
|
10
|
+
from fcp_python.resolver.index import SymbolIndex
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PythonModel:
|
|
14
|
+
def __init__(self, root_uri: str) -> None:
|
|
15
|
+
self.root_uri = root_uri
|
|
16
|
+
self.lsp_client: LspClient | None = None
|
|
17
|
+
self.symbol_index = SymbolIndex()
|
|
18
|
+
self.diagnostics: dict[str, list[Diagnostic]] = {}
|
|
19
|
+
self.open_documents: dict[str, int] = {} # uri -> version
|
|
20
|
+
self.server_status = ServerStatus.NotStarted
|
|
21
|
+
self.py_file_count = 0
|
|
22
|
+
self.last_reload: float | None = None
|
|
23
|
+
|
|
24
|
+
def update_diagnostics(self, uri: str, diagnostics: list[Diagnostic]) -> None:
|
|
25
|
+
if not diagnostics:
|
|
26
|
+
self.diagnostics.pop(uri, None)
|
|
27
|
+
else:
|
|
28
|
+
self.diagnostics[uri] = diagnostics
|
|
29
|
+
|
|
30
|
+
def total_diagnostics(self) -> tuple[int, int]:
|
|
31
|
+
errors = 0
|
|
32
|
+
warnings = 0
|
|
33
|
+
for diags in self.diagnostics.values():
|
|
34
|
+
for d in diags:
|
|
35
|
+
if d.severity == DiagnosticSeverity.Error:
|
|
36
|
+
errors += 1
|
|
37
|
+
elif d.severity == DiagnosticSeverity.Warning:
|
|
38
|
+
warnings += 1
|
|
39
|
+
return (errors, warnings)
|
|
40
|
+
|
|
41
|
+
def diagnostic_count(self) -> int:
|
|
42
|
+
return sum(len(v) for v in self.diagnostics.values())
|