whykit 0.3.1__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.
- whykit/__init__.py +21 -0
- whykit/adopt.py +776 -0
- whykit/backlinks.py +106 -0
- whykit/check.py +320 -0
- whykit/ci_formats.py +283 -0
- whykit/cli.py +1719 -0
- whykit/completion.py +193 -0
- whykit/config.py +233 -0
- whykit/console.py +217 -0
- whykit/context.py +204 -0
- whykit/contract.py +210 -0
- whykit/contract_schemas/backlinks-report.schema.json +61 -0
- whykit/contract_schemas/context-bundle.schema.json +27 -0
- whykit/contract_schemas/context-pack.schema.json +29 -0
- whykit/contract_schemas/impact-report.schema.json +122 -0
- whykit/contract_schemas/query-result.schema.json +105 -0
- whykit/contract_schemas/status-report.schema.json +176 -0
- whykit/contract_schemas/trace-report.schema.json +211 -0
- whykit/diff.py +823 -0
- whykit/evidence.py +178 -0
- whykit/explorer_index.py +554 -0
- whykit/graph.py +318 -0
- whykit/immutability.py +624 -0
- whykit/impact.py +272 -0
- whykit/init_layout.py +205 -0
- whykit/io.py +563 -0
- whykit/lint.py +2190 -0
- whykit/lsp.py +1241 -0
- whykit/markdown_code.py +107 -0
- whykit/mcp_server.py +2289 -0
- whykit/messages.py +46 -0
- whykit/pack.py +294 -0
- whykit/parse_cache.py +752 -0
- whykit/placeholders.py +84 -0
- whykit/promote.py +651 -0
- whykit/py.typed +0 -0
- whykit/query.py +174 -0
- whykit/review.py +527 -0
- whykit/rule_policy.py +1063 -0
- whykit/rules.py +274 -0
- whykit/scaffold.py +784 -0
- whykit/sensitivity.py +95 -0
- whykit/snapshot.py +319 -0
- whykit/status.py +167 -0
- whykit/tables.py +120 -0
- whykit/template/00-context/README.md +17 -0
- whykit/template/00-context/company.md +52 -0
- whykit/template/00-context/evidence-register.md +34 -0
- whykit/template/00-context/goals.md +38 -0
- whykit/template/00-context/review-log.md +22 -0
- whykit/template/00-context/terminology.md +34 -0
- whykit/template/01-strategy/README.md +7 -0
- whykit/template/02-discoverability/README.md +10 -0
- whykit/template/03-website/README.md +7 -0
- whykit/template/04-automation/README.md +10 -0
- whykit/template/05-operations/README.md +19 -0
- whykit/template/06-decisions/README.md +11 -0
- whykit/template/06-decisions/decision-log.md +38 -0
- whykit/template/07-research/README.md +10 -0
- whykit/template/AGENTS.md +163 -0
- whykit/template/Home.md +57 -0
- whykit/template/INTEROP.md +117 -0
- whykit/template/OBSIDIAN.md +92 -0
- whykit/template/README.md +51 -0
- whykit/template/assets/README.md +6 -0
- whykit/template/reports/README.md +7 -0
- whykit/template/templates/README.md +7 -0
- whykit/template/templates/automation-spec-template.md +70 -0
- whykit/template/templates/decision-record-template.md +65 -0
- whykit/template/templates/document-template.md +55 -0
- whykit/template/templates/experiment-template.md +62 -0
- whykit/template/templates/meeting-note-template.md +47 -0
- whykit/template/templates/page-brief-template.md +63 -0
- whykit/template/templates/research-note-template.md +53 -0
- whykit/template/templates/source-ingestion-template.md +43 -0
- whykit/template/whykit.toml +39 -0
- whykit/trace.py +272 -0
- whykit/vault_index.py +190 -0
- whykit-0.3.1.dist-info/METADATA +162 -0
- whykit-0.3.1.dist-info/RECORD +84 -0
- whykit-0.3.1.dist-info/WHEEL +4 -0
- whykit-0.3.1.dist-info/entry_points.txt +3 -0
- whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
- whykit-0.3.1.dist-info/licenses/NOTICE +13 -0
whykit/lsp.py
ADDED
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
"""A read-only Language Server for WhyKit vaults (``whykit lsp``).
|
|
2
|
+
|
|
3
|
+
Speaks JSON-RPC 2.0 over stdio with the subset of LSP 3.17 an editor needs to
|
|
4
|
+
show lint findings and navigate a vault:
|
|
5
|
+
|
|
6
|
+
* ``textDocument/publishDiagnostics`` from the same linter as ``whykit lint``,
|
|
7
|
+
run on open, change, save and watched-file events, debounced per vault;
|
|
8
|
+
* completion for ``[[wikilinks]]`` (paths, aliases, decision IDs) and for
|
|
9
|
+
``E-NNN`` / ``D-NNN`` identifiers;
|
|
10
|
+
* hover and go-to-definition for wikilinks and identifiers;
|
|
11
|
+
* document links for wikilinks and local Markdown links.
|
|
12
|
+
|
|
13
|
+
The server never writes a file. Open documents are linted from the editor's
|
|
14
|
+
buffer, so unsaved edits are checked as typed; everything else is read from
|
|
15
|
+
disk. The evidence register and ``whykit.toml`` are always read from disk by
|
|
16
|
+
the linter, so their findings follow the saved file.
|
|
17
|
+
|
|
18
|
+
Standard library only, like the rest of the core package.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import contextlib
|
|
24
|
+
import datetime as dt
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import traceback
|
|
31
|
+
import unicodedata
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from dataclasses import replace
|
|
34
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
35
|
+
from typing import IO, Any
|
|
36
|
+
from urllib.parse import quote, quote_from_bytes, unquote, unquote_to_bytes, urlsplit
|
|
37
|
+
|
|
38
|
+
from . import __version__
|
|
39
|
+
from .lint import (
|
|
40
|
+
CONTENT_SKIP_DIRS,
|
|
41
|
+
DECISION_FILE_RE,
|
|
42
|
+
MARKDOWN_LINK_RE,
|
|
43
|
+
URL_SCHEME_RE,
|
|
44
|
+
WIKILINK_RE,
|
|
45
|
+
Finding,
|
|
46
|
+
Note,
|
|
47
|
+
_build_index,
|
|
48
|
+
_decision_own_id,
|
|
49
|
+
_mask_code,
|
|
50
|
+
_parse_evidence_register_text,
|
|
51
|
+
_real,
|
|
52
|
+
_within,
|
|
53
|
+
collect_markdown,
|
|
54
|
+
evidence_register,
|
|
55
|
+
is_vault_root,
|
|
56
|
+
find_vault_root,
|
|
57
|
+
lint,
|
|
58
|
+
load_note,
|
|
59
|
+
path_cache,
|
|
60
|
+
)
|
|
61
|
+
from .vault_index import NoteCache, VaultIndex
|
|
62
|
+
|
|
63
|
+
JsonObject = dict[str, Any]
|
|
64
|
+
Send = Callable[[JsonObject], None]
|
|
65
|
+
|
|
66
|
+
# JSON-RPC and LSP error codes.
|
|
67
|
+
PARSE_ERROR = -32700
|
|
68
|
+
INVALID_REQUEST = -32600
|
|
69
|
+
METHOD_NOT_FOUND = -32601
|
|
70
|
+
INVALID_PARAMS = -32602
|
|
71
|
+
INTERNAL_ERROR = -32603
|
|
72
|
+
SERVER_NOT_INITIALIZED = -32002
|
|
73
|
+
|
|
74
|
+
MAX_MESSAGE_BYTES = 64 * 1024 * 1024
|
|
75
|
+
COMPLETION_LIMIT = 200
|
|
76
|
+
ENCODINGS = ("utf-16", "utf-8", "utf-32")
|
|
77
|
+
REGISTER = PurePosixPath("00-context/evidence-register.md")
|
|
78
|
+
|
|
79
|
+
ID_RE = re.compile(r"\b[ED]-[0-9]{3,}\b")
|
|
80
|
+
ID_PREFIX_RE = re.compile(r"(?<![\w-])([ED]-[0-9]*)$")
|
|
81
|
+
HEADING_RE = re.compile(r"^#{1,6}\s+(.*?)\s*#*\s*$")
|
|
82
|
+
MESSAGE_LINK_RE = re.compile(r"\[\[(.+?)\]\]")
|
|
83
|
+
MESSAGE_PAREN_RE = re.compile(r"\(([^()]+)\)$")
|
|
84
|
+
|
|
85
|
+
# Findings that depend on files other than the one they are reported on. A
|
|
86
|
+
# lint of only the edited files cannot produce them, so while typing they are
|
|
87
|
+
# carried over from the last whole-vault lint. Keep in step with the checks
|
|
88
|
+
# `lint()` runs only without `paths` (and with decision.duplicate, which needs
|
|
89
|
+
# the other holder of the ID); tests/test_lsp.py holds the two together.
|
|
90
|
+
CROSS_FILE_CODES = frozenset({
|
|
91
|
+
"agents.absent", "agents.unconfigured", "config.invalid", "decision.duplicate", "hub.unlinked_workstream",
|
|
92
|
+
"note.orphan", "path.case_collision", "review_log.date", "review_log.next_review",
|
|
93
|
+
"review_log.outcome", "review_log.reviewer", "review_log.row", "review_log.table",
|
|
94
|
+
"review_log.target", "secret.detected", "secret.scan_skipped_large_file", "secret.scan_unreadable",
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
# LSP enums.
|
|
98
|
+
SEVERITY = {"error": 1, "warning": 2, "info": 3, "information": 3, "hint": 4}
|
|
99
|
+
KIND_FILE, KIND_REFERENCE, KIND_CONSTANT = 17, 18, 21
|
|
100
|
+
TAG_DEPRECATED = 1
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ProtocolError(ValueError):
|
|
104
|
+
"""The byte stream is not a valid base-protocol frame."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _RequestError(Exception):
|
|
108
|
+
def __init__(self, code: int, message: str) -> None:
|
|
109
|
+
super().__init__(message)
|
|
110
|
+
self.code = code
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------
|
|
114
|
+
# Base protocol: Content-Length framed JSON over a byte stream.
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_message(stream: IO[bytes]) -> JsonObject | None:
|
|
118
|
+
"""Read one framed message; ``None`` at end of input.
|
|
119
|
+
|
|
120
|
+
Raises :class:`ProtocolError` for a header block without a usable
|
|
121
|
+
``Content-Length`` and :class:`json.JSONDecodeError` for a body that is not
|
|
122
|
+
JSON (the frame has been consumed, so the caller can answer and go on).
|
|
123
|
+
"""
|
|
124
|
+
length: int | None = None
|
|
125
|
+
while True:
|
|
126
|
+
line = stream.readline()
|
|
127
|
+
if not line:
|
|
128
|
+
return None
|
|
129
|
+
if line in (b"\r\n", b"\n"):
|
|
130
|
+
if length is None:
|
|
131
|
+
raise ProtocolError("message header has no Content-Length")
|
|
132
|
+
break
|
|
133
|
+
name, _, value = line.decode("ascii", "replace").partition(":")
|
|
134
|
+
if name.strip().lower() == "content-length":
|
|
135
|
+
try:
|
|
136
|
+
length = int(value.strip())
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
raise ProtocolError(f"bad Content-Length: {value.strip()!r}") from exc
|
|
139
|
+
if length < 0:
|
|
140
|
+
raise ProtocolError("negative Content-Length")
|
|
141
|
+
assert length is not None
|
|
142
|
+
if length > MAX_MESSAGE_BYTES:
|
|
143
|
+
remaining = length
|
|
144
|
+
while remaining > 0:
|
|
145
|
+
chunk = stream.read(min(remaining, 1 << 20))
|
|
146
|
+
if not chunk:
|
|
147
|
+
return None
|
|
148
|
+
remaining -= len(chunk)
|
|
149
|
+
raise ProtocolError(f"message of {length} bytes is over the {MAX_MESSAGE_BYTES}-byte limit")
|
|
150
|
+
body = stream.read(length)
|
|
151
|
+
if len(body) < length:
|
|
152
|
+
return None
|
|
153
|
+
message = json.loads(body.decode("utf-8"))
|
|
154
|
+
if not isinstance(message, dict):
|
|
155
|
+
raise json.JSONDecodeError("a message must be a JSON object", body.decode("utf-8", "replace"), 0)
|
|
156
|
+
return message
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def write_message(stream: IO[bytes], message: JsonObject) -> None:
|
|
160
|
+
body = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
161
|
+
stream.write(b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n\r\n" + body)
|
|
162
|
+
stream.flush()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --------------------------------------------------------------------------
|
|
166
|
+
# URIs and positions.
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def uri_to_path(uri: str, *, windows: bool | None = None) -> str:
|
|
170
|
+
"""The filesystem path of a ``file:`` URI.
|
|
171
|
+
|
|
172
|
+
Accepts both drive spellings editors send (``file:///c%3A/...`` from VS
|
|
173
|
+
Code, ``file:///C:/...`` elsewhere) and UNC shares (``file://host/share``).
|
|
174
|
+
"""
|
|
175
|
+
parts = urlsplit(uri)
|
|
176
|
+
if parts.scheme.lower() != "file":
|
|
177
|
+
raise ValueError(f"not a file URI: {uri}")
|
|
178
|
+
windows = os.name == "nt" if windows is None else windows
|
|
179
|
+
if windows:
|
|
180
|
+
path = unquote(parts.path)
|
|
181
|
+
if parts.netloc and parts.netloc.lower() != "localhost":
|
|
182
|
+
return str(PureWindowsPath(f"//{parts.netloc}{path}"))
|
|
183
|
+
if re.match(r"^/[A-Za-z]:", path):
|
|
184
|
+
path = path[1:]
|
|
185
|
+
return str(PureWindowsPath(path))
|
|
186
|
+
return os.fsdecode(unquote_to_bytes(parts.path))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def path_to_uri(path: str | os.PathLike[str], *, windows: bool | None = None) -> str:
|
|
190
|
+
"""A ``file:`` URI for an absolute path, percent-encoding everything but ``/``."""
|
|
191
|
+
windows = os.name == "nt" if windows is None else windows
|
|
192
|
+
if windows:
|
|
193
|
+
pure = PureWindowsPath(path)
|
|
194
|
+
drive = pure.drive
|
|
195
|
+
rest = pure.as_posix()[len(drive):]
|
|
196
|
+
if drive.startswith(("\\\\", "//")):
|
|
197
|
+
host, _, share = drive.replace("\\", "/").lstrip("/").partition("/")
|
|
198
|
+
return f"file://{host}/{quote(share)}{quote(rest)}"
|
|
199
|
+
return f"file:///{drive}{quote(rest)}"
|
|
200
|
+
text = os.fspath(PurePosixPath(os.fspath(path)))
|
|
201
|
+
return "file://" + quote_from_bytes(os.fsencode(text), safe="/")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _units(char: str, encoding: str) -> int:
|
|
205
|
+
if encoding == "utf-16":
|
|
206
|
+
return 2 if ord(char) > 0xFFFF else 1
|
|
207
|
+
if encoding == "utf-8":
|
|
208
|
+
return len(char.encode("utf-8", "surrogatepass"))
|
|
209
|
+
return 1
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def to_character(line: str, index: int, encoding: str) -> int:
|
|
213
|
+
"""LSP ``character`` of string index *index* in *line*."""
|
|
214
|
+
index = max(0, min(index, len(line)))
|
|
215
|
+
if encoding == "utf-32":
|
|
216
|
+
return index
|
|
217
|
+
return sum(_units(char, encoding) for char in line[:index])
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def from_character(line: str, character: int, encoding: str) -> int:
|
|
221
|
+
"""String index of LSP ``character`` in *line*, clamped to the line."""
|
|
222
|
+
if encoding == "utf-32":
|
|
223
|
+
return max(0, min(character, len(line)))
|
|
224
|
+
units = 0
|
|
225
|
+
for index, char in enumerate(line):
|
|
226
|
+
if units >= character:
|
|
227
|
+
return index
|
|
228
|
+
units += _units(char, encoding)
|
|
229
|
+
return len(line)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _split_lines(text: str) -> list[str]:
|
|
233
|
+
return [line[:-1] if line.endswith("\r") else line for line in text.split("\n")]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _offset_at(text: str, position: JsonObject, encoding: str) -> int:
|
|
237
|
+
line_no = int(position.get("line", 0))
|
|
238
|
+
start = 0
|
|
239
|
+
for _ in range(max(0, line_no)):
|
|
240
|
+
found = text.find("\n", start)
|
|
241
|
+
if found < 0:
|
|
242
|
+
return len(text)
|
|
243
|
+
start = found + 1
|
|
244
|
+
end = text.find("\n", start)
|
|
245
|
+
end = len(text) if end < 0 else end
|
|
246
|
+
line = text[start:end].removesuffix("\r")
|
|
247
|
+
return start + from_character(line, int(position.get("character", 0)), encoding)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def apply_change(text: str, change: JsonObject, encoding: str) -> str:
|
|
251
|
+
"""Apply one ``contentChanges`` entry (full or ranged) to *text*."""
|
|
252
|
+
new_text = str(change.get("text", ""))
|
|
253
|
+
span = change.get("range")
|
|
254
|
+
if not isinstance(span, dict):
|
|
255
|
+
return new_text
|
|
256
|
+
start = _offset_at(text, span.get("start", {}), encoding)
|
|
257
|
+
end = _offset_at(text, span.get("end", {}), encoding)
|
|
258
|
+
if end < start:
|
|
259
|
+
start, end = end, start
|
|
260
|
+
return text[:start] + new_text + text[end:]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _key(path: Path) -> str:
|
|
264
|
+
"""Identity of a file across spellings: real path, case-folded where the OS is, NFC."""
|
|
265
|
+
return unicodedata.normalize("NFC", os.path.normcase(os.fspath(_real(path))))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# --------------------------------------------------------------------------
|
|
269
|
+
# Server state.
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class _Document:
|
|
273
|
+
__slots__ = ("uri", "path", "text", "version", "vault", "_masked")
|
|
274
|
+
|
|
275
|
+
def __init__(self, uri: str, path: Path | None, text: str, version: int | None, vault: _Vault | None) -> None:
|
|
276
|
+
self.uri = uri
|
|
277
|
+
self.path = path
|
|
278
|
+
self.text = text
|
|
279
|
+
self.version = version
|
|
280
|
+
self.vault = vault
|
|
281
|
+
self._masked: tuple[str, list[str], list[str]] | None = None
|
|
282
|
+
|
|
283
|
+
def lines(self) -> tuple[list[str], list[str]]:
|
|
284
|
+
"""Raw and code-masked lines of the current text."""
|
|
285
|
+
if self._masked is None or self._masked[0] is not self.text:
|
|
286
|
+
normalized = self.text.replace("\r\n", "\n")
|
|
287
|
+
self._masked = (self.text, _split_lines(normalized), _split_lines(_mask_code(normalized)))
|
|
288
|
+
return self._masked[1], self._masked[2]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class _Vault:
|
|
292
|
+
def __init__(self, root: Path) -> None:
|
|
293
|
+
self.root = root
|
|
294
|
+
self.notes = NoteCache()
|
|
295
|
+
# The last scan of the disk: parsed notes, their real paths and keys.
|
|
296
|
+
self.disk: list[Note] | None = None
|
|
297
|
+
self.disk_by_path: dict[Path, Note] = {}
|
|
298
|
+
self.disk_keys: dict[str, int] = {}
|
|
299
|
+
# Disk plus open buffers, as of the last lint; navigation reads it.
|
|
300
|
+
self.index: VaultIndex | None = None
|
|
301
|
+
self.decisions: dict[str, Note] | None = None
|
|
302
|
+
# Findings of the last whole-vault lint, by file key.
|
|
303
|
+
self.full_findings: dict[str, list[Finding]] | None = None
|
|
304
|
+
self.published: set[str] = set()
|
|
305
|
+
self.full_pending = False
|
|
306
|
+
self.changed: set[str] = set()
|
|
307
|
+
self.timer: threading.Timer | None = None
|
|
308
|
+
self.lint_lock = threading.Lock()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class LanguageServer:
|
|
312
|
+
"""Protocol handler. Feed it decoded messages; it calls *send* with replies.
|
|
313
|
+
|
|
314
|
+
*debounce* is in seconds. With ``0`` the lint runs inline, before
|
|
315
|
+
:meth:`handle` returns, which keeps tests deterministic.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
def __init__(
|
|
319
|
+
self,
|
|
320
|
+
send: Send,
|
|
321
|
+
*,
|
|
322
|
+
root: Path | None = None,
|
|
323
|
+
debounce: float = 0.3,
|
|
324
|
+
today: dt.date | None = None,
|
|
325
|
+
) -> None:
|
|
326
|
+
self._send = send
|
|
327
|
+
self.root = Path(root).resolve() if root is not None else None
|
|
328
|
+
self.debounce = max(0.0, debounce)
|
|
329
|
+
self.today = today
|
|
330
|
+
self.encoding = "utf-16"
|
|
331
|
+
self.running = True
|
|
332
|
+
self.exit_code: int | None = None
|
|
333
|
+
self.lint_runs = 0
|
|
334
|
+
self._initialized = False
|
|
335
|
+
self._shutdown = False
|
|
336
|
+
self._documents: dict[str, _Document] = {}
|
|
337
|
+
self._vaults: dict[Path, _Vault] = {}
|
|
338
|
+
self._vault_dirs: dict[str, _Vault | None] = {}
|
|
339
|
+
self._workspace: list[_Vault] = []
|
|
340
|
+
self._lock = threading.RLock()
|
|
341
|
+
self._watch_registration = False
|
|
342
|
+
|
|
343
|
+
# ---------------------------------------------------------------- plumbing
|
|
344
|
+
|
|
345
|
+
def send(self, message: JsonObject) -> None:
|
|
346
|
+
self._send({"jsonrpc": "2.0", **message})
|
|
347
|
+
|
|
348
|
+
def notify(self, method: str, params: JsonObject) -> None:
|
|
349
|
+
self.send({"method": method, "params": params})
|
|
350
|
+
|
|
351
|
+
def log(self, text: str, level: int = 3) -> None:
|
|
352
|
+
self.notify("window/logMessage", {"type": level, "message": text})
|
|
353
|
+
|
|
354
|
+
def handle(self, message: JsonObject) -> None:
|
|
355
|
+
method = message.get("method")
|
|
356
|
+
ident = message.get("id")
|
|
357
|
+
is_request = "id" in message
|
|
358
|
+
if not isinstance(method, str):
|
|
359
|
+
if is_request and ("result" in message or "error" in message):
|
|
360
|
+
return # a reply to one of our requests (capability registration)
|
|
361
|
+
self.send({"id": ident if is_request else None, "error": {"code": INVALID_REQUEST, "message": "missing method"}})
|
|
362
|
+
return
|
|
363
|
+
params = message.get("params", {})
|
|
364
|
+
try:
|
|
365
|
+
result = self._dispatch(method, params if params is not None else {}, is_request)
|
|
366
|
+
except _RequestError as exc:
|
|
367
|
+
if is_request:
|
|
368
|
+
self.send({"id": ident, "error": {"code": exc.code, "message": str(exc)}})
|
|
369
|
+
return
|
|
370
|
+
except Exception as exc: # noqa: BLE001 - one bad message must not end the session
|
|
371
|
+
if os.environ.get("WHYKIT_DEBUG"):
|
|
372
|
+
traceback.print_exc(file=sys.stderr)
|
|
373
|
+
text = f"whykit lsp: {method} failed: {type(exc).__name__}: {exc}"
|
|
374
|
+
if is_request:
|
|
375
|
+
self.send({"id": ident, "error": {"code": INTERNAL_ERROR, "message": text}})
|
|
376
|
+
else:
|
|
377
|
+
self.log(text, 1)
|
|
378
|
+
return
|
|
379
|
+
if is_request:
|
|
380
|
+
self.send({"id": ident, "result": result})
|
|
381
|
+
|
|
382
|
+
def _dispatch(self, method: str, params: Any, is_request: bool) -> Any:
|
|
383
|
+
if method == "exit":
|
|
384
|
+
self._stop()
|
|
385
|
+
return None
|
|
386
|
+
if method == "initialize":
|
|
387
|
+
return self._initialize(params)
|
|
388
|
+
if not self._initialized:
|
|
389
|
+
if is_request:
|
|
390
|
+
raise _RequestError(SERVER_NOT_INITIALIZED, "server not initialized")
|
|
391
|
+
return None
|
|
392
|
+
if self._shutdown and is_request:
|
|
393
|
+
raise _RequestError(INVALID_REQUEST, "server is shutting down")
|
|
394
|
+
handler = _HANDLERS.get(method)
|
|
395
|
+
if handler is None:
|
|
396
|
+
if is_request and not method.startswith("$/"):
|
|
397
|
+
raise _RequestError(METHOD_NOT_FOUND, f"method not supported: {method}")
|
|
398
|
+
return None
|
|
399
|
+
if not isinstance(params, dict):
|
|
400
|
+
raise _RequestError(INVALID_PARAMS, "params must be an object")
|
|
401
|
+
if not is_request:
|
|
402
|
+
return handler(self, params)
|
|
403
|
+
with path_cache(fresh=True):
|
|
404
|
+
return handler(self, params)
|
|
405
|
+
|
|
406
|
+
def _stop(self) -> None:
|
|
407
|
+
with self._lock:
|
|
408
|
+
for vault in self._vaults.values():
|
|
409
|
+
if vault.timer is not None:
|
|
410
|
+
vault.timer.cancel()
|
|
411
|
+
self.running = False
|
|
412
|
+
self.exit_code = 0 if self._shutdown else 1
|
|
413
|
+
|
|
414
|
+
# --------------------------------------------------------------- lifecycle
|
|
415
|
+
|
|
416
|
+
def _initialize(self, params: Any) -> JsonObject:
|
|
417
|
+
if not isinstance(params, dict):
|
|
418
|
+
raise _RequestError(INVALID_PARAMS, "params must be an object")
|
|
419
|
+
capabilities = params.get("capabilities") or {}
|
|
420
|
+
offered = ((capabilities.get("general") or {}).get("positionEncodings")) or []
|
|
421
|
+
self.encoding = next((enc for enc in offered if enc in ENCODINGS), "utf-16")
|
|
422
|
+
watched = ((capabilities.get("workspace") or {}).get("didChangeWatchedFiles")) or {}
|
|
423
|
+
self._watch_registration = bool(watched.get("dynamicRegistration"))
|
|
424
|
+
folders: list[str] = []
|
|
425
|
+
for folder in params.get("workspaceFolders") or []:
|
|
426
|
+
if isinstance(folder, dict) and isinstance(folder.get("uri"), str):
|
|
427
|
+
folders.append(folder["uri"])
|
|
428
|
+
if isinstance(params.get("rootUri"), str):
|
|
429
|
+
folders.append(params["rootUri"])
|
|
430
|
+
elif isinstance(params.get("rootPath"), str):
|
|
431
|
+
folders.append(path_to_uri(params["rootPath"]))
|
|
432
|
+
with self._lock:
|
|
433
|
+
if self.root is not None:
|
|
434
|
+
self._workspace = [self._vault_at(self.root)]
|
|
435
|
+
else:
|
|
436
|
+
for uri in folders:
|
|
437
|
+
path = self._path(uri)
|
|
438
|
+
vault = self._vault_containing(path) if path is not None else None
|
|
439
|
+
if vault is not None and vault not in self._workspace:
|
|
440
|
+
self._workspace.append(vault)
|
|
441
|
+
self._initialized = True
|
|
442
|
+
return {
|
|
443
|
+
"capabilities": {
|
|
444
|
+
"positionEncoding": self.encoding,
|
|
445
|
+
"textDocumentSync": {"openClose": True, "change": 2, "save": {"includeText": False}},
|
|
446
|
+
"completionProvider": {"triggerCharacters": ["[", "-"], "resolveProvider": False},
|
|
447
|
+
"hoverProvider": True,
|
|
448
|
+
"definitionProvider": True,
|
|
449
|
+
"documentLinkProvider": {"resolveProvider": False},
|
|
450
|
+
},
|
|
451
|
+
"serverInfo": {"name": "whykit", "version": __version__},
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
def _initialized_notification(self, params: JsonObject) -> None:
|
|
455
|
+
if self._watch_registration:
|
|
456
|
+
self.send({"id": "whykit-watch", "method": "client/registerCapability", "params": {"registrations": [{
|
|
457
|
+
"id": "whykit-watch",
|
|
458
|
+
"method": "workspace/didChangeWatchedFiles",
|
|
459
|
+
"registerOptions": {"watchers": [{"globPattern": "**/*.md"}, {"globPattern": "**/whykit.toml"}]},
|
|
460
|
+
}]}})
|
|
461
|
+
for vault in list(self._workspace):
|
|
462
|
+
self._schedule(vault, full=True)
|
|
463
|
+
|
|
464
|
+
def _shutdown_request(self, params: JsonObject) -> None:
|
|
465
|
+
self._shutdown = True
|
|
466
|
+
with self._lock:
|
|
467
|
+
for vault in self._vaults.values():
|
|
468
|
+
if vault.timer is not None:
|
|
469
|
+
vault.timer.cancel()
|
|
470
|
+
|
|
471
|
+
# ------------------------------------------------------------------ vaults
|
|
472
|
+
|
|
473
|
+
def _path(self, uri: str) -> Path | None:
|
|
474
|
+
try:
|
|
475
|
+
return Path(uri_to_path(uri))
|
|
476
|
+
except ValueError:
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
def _vault_at(self, root: Path) -> _Vault:
|
|
480
|
+
root = root.resolve()
|
|
481
|
+
vault = self._vaults.get(root)
|
|
482
|
+
if vault is None:
|
|
483
|
+
vault = self._vaults[root] = _Vault(root)
|
|
484
|
+
return vault
|
|
485
|
+
|
|
486
|
+
def _vault_containing(self, path: Path) -> _Vault | None:
|
|
487
|
+
if self.root is not None:
|
|
488
|
+
vault = self._vault_at(self.root)
|
|
489
|
+
return vault if _within(vault.root, path) else None
|
|
490
|
+
start = path if path.is_dir() else path.parent
|
|
491
|
+
key = os.fspath(start)
|
|
492
|
+
if key not in self._vault_dirs:
|
|
493
|
+
found = find_vault_root(start) if start.exists() else None
|
|
494
|
+
self._vault_dirs[key] = self._vault_at(found) if found is not None else None
|
|
495
|
+
discovered = self._vault_dirs[key]
|
|
496
|
+
return discovered if discovered is not None and _within(discovered.root, path) else None
|
|
497
|
+
|
|
498
|
+
def _document(self, params: JsonObject) -> _Document | None:
|
|
499
|
+
"""The open document named in *params*, or a read-only view of the file on disk."""
|
|
500
|
+
ident = params.get("textDocument")
|
|
501
|
+
if not isinstance(ident, dict) or not isinstance(ident.get("uri"), str):
|
|
502
|
+
raise _RequestError(INVALID_PARAMS, "textDocument.uri is required")
|
|
503
|
+
uri = ident["uri"]
|
|
504
|
+
with self._lock:
|
|
505
|
+
doc = self._documents.get(uri)
|
|
506
|
+
if doc is not None:
|
|
507
|
+
return None if doc.vault is not None and doc.path is not None and not _within(doc.vault.root, doc.path) else doc
|
|
508
|
+
path = self._path(uri)
|
|
509
|
+
if path is None or not path.is_file():
|
|
510
|
+
return None
|
|
511
|
+
vault = self._vault_containing(path)
|
|
512
|
+
if vault is None:
|
|
513
|
+
return None
|
|
514
|
+
try:
|
|
515
|
+
text = path.read_text(encoding="utf-8")
|
|
516
|
+
except (OSError, UnicodeDecodeError):
|
|
517
|
+
return None
|
|
518
|
+
return _Document(uri, path, text, None, vault)
|
|
519
|
+
|
|
520
|
+
def _position(self, params: JsonObject) -> tuple[int, int]:
|
|
521
|
+
position = params.get("position")
|
|
522
|
+
if not isinstance(position, dict):
|
|
523
|
+
raise _RequestError(INVALID_PARAMS, "position is required")
|
|
524
|
+
line, character = position.get("line"), position.get("character")
|
|
525
|
+
if not isinstance(line, int) or not isinstance(character, int) or line < 0 or character < 0:
|
|
526
|
+
raise _RequestError(INVALID_PARAMS, "position.line and position.character must be non-negative integers")
|
|
527
|
+
return line, character
|
|
528
|
+
|
|
529
|
+
# ------------------------------------------------------- text sync events
|
|
530
|
+
|
|
531
|
+
def _did_open(self, params: JsonObject) -> None:
|
|
532
|
+
item = params.get("textDocument")
|
|
533
|
+
if not isinstance(item, dict) or not isinstance(item.get("uri"), str):
|
|
534
|
+
return
|
|
535
|
+
uri = item["uri"]
|
|
536
|
+
path = self._path(uri)
|
|
537
|
+
with self._lock:
|
|
538
|
+
vault = self._vault_containing(path) if path is not None else None
|
|
539
|
+
self._documents[uri] = _Document(uri, path, str(item.get("text", "")), item.get("version"), vault)
|
|
540
|
+
if vault is not None:
|
|
541
|
+
self._schedule(vault, changed=uri)
|
|
542
|
+
|
|
543
|
+
def _did_change(self, params: JsonObject) -> None:
|
|
544
|
+
ident = params.get("textDocument") or {}
|
|
545
|
+
with self._lock:
|
|
546
|
+
doc = self._documents.get(str(ident.get("uri"))) if isinstance(ident, dict) else None
|
|
547
|
+
if doc is None:
|
|
548
|
+
return
|
|
549
|
+
text = doc.text
|
|
550
|
+
for change in params.get("contentChanges") or []:
|
|
551
|
+
if isinstance(change, dict):
|
|
552
|
+
text = apply_change(text, change, self.encoding)
|
|
553
|
+
doc.text = text
|
|
554
|
+
doc.version = ident.get("version", doc.version)
|
|
555
|
+
vault = doc.vault
|
|
556
|
+
if vault is not None:
|
|
557
|
+
self._schedule(vault, changed=doc.uri)
|
|
558
|
+
|
|
559
|
+
def _did_save(self, params: JsonObject) -> None:
|
|
560
|
+
ident = params.get("textDocument") or {}
|
|
561
|
+
with self._lock:
|
|
562
|
+
doc = self._documents.get(str(ident.get("uri"))) if isinstance(ident, dict) else None
|
|
563
|
+
vault = doc.vault if doc is not None else None
|
|
564
|
+
if vault is not None:
|
|
565
|
+
self._schedule(vault, full=True)
|
|
566
|
+
|
|
567
|
+
def _did_close(self, params: JsonObject) -> None:
|
|
568
|
+
ident = params.get("textDocument") or {}
|
|
569
|
+
with self._lock:
|
|
570
|
+
doc = self._documents.pop(str(ident.get("uri")), None) if isinstance(ident, dict) else None
|
|
571
|
+
if doc is None or doc.vault is None or doc.path is None:
|
|
572
|
+
return
|
|
573
|
+
try:
|
|
574
|
+
unchanged = doc.path.read_text(encoding="utf-8").replace("\r\n", "\n") == doc.text.replace("\r\n", "\n")
|
|
575
|
+
except (OSError, UnicodeDecodeError):
|
|
576
|
+
unchanged = False
|
|
577
|
+
# A closed buffer that matched the file changes nothing; one that did
|
|
578
|
+
# not was being linted in place of the file, so recheck the vault.
|
|
579
|
+
if not unchanged:
|
|
580
|
+
self._schedule(doc.vault, full=True)
|
|
581
|
+
|
|
582
|
+
def _did_change_watched(self, params: JsonObject) -> None:
|
|
583
|
+
touched: list[_Vault] = []
|
|
584
|
+
with self._lock:
|
|
585
|
+
for change in params.get("changes") or []:
|
|
586
|
+
if not isinstance(change, dict) or not isinstance(change.get("uri"), str):
|
|
587
|
+
continue
|
|
588
|
+
path = self._path(change["uri"])
|
|
589
|
+
if path is None:
|
|
590
|
+
continue
|
|
591
|
+
for vault in self._vaults.values():
|
|
592
|
+
if _within(vault.root, path) and vault not in touched:
|
|
593
|
+
touched.append(vault)
|
|
594
|
+
for vault in touched:
|
|
595
|
+
self._schedule(vault, full=True)
|
|
596
|
+
|
|
597
|
+
# ---------------------------------------------------------------- linting
|
|
598
|
+
|
|
599
|
+
def _schedule(self, vault: _Vault, *, full: bool = False, changed: str | None = None) -> None:
|
|
600
|
+
"""Queue a lint of *vault*: the whole vault, or only the edited buffers.
|
|
601
|
+
|
|
602
|
+
Typing re-lints only the edited files, which takes a fraction of a
|
|
603
|
+
second at any vault size; opening the workspace, saving, and files
|
|
604
|
+
changing on disk re-lint the whole vault.
|
|
605
|
+
"""
|
|
606
|
+
if self._shutdown:
|
|
607
|
+
return
|
|
608
|
+
with self._lock:
|
|
609
|
+
if full or vault.full_findings is None:
|
|
610
|
+
vault.full_pending = True
|
|
611
|
+
if changed is not None:
|
|
612
|
+
vault.changed.add(changed)
|
|
613
|
+
if self.debounce > 0:
|
|
614
|
+
if vault.timer is not None:
|
|
615
|
+
vault.timer.cancel()
|
|
616
|
+
timer = threading.Timer(self.debounce, self._lint_quietly, (vault,))
|
|
617
|
+
timer.daemon = True
|
|
618
|
+
vault.timer = timer
|
|
619
|
+
timer.start()
|
|
620
|
+
return
|
|
621
|
+
self._lint(vault)
|
|
622
|
+
|
|
623
|
+
def _lint_quietly(self, vault: _Vault) -> None:
|
|
624
|
+
try:
|
|
625
|
+
self._lint(vault)
|
|
626
|
+
except Exception as exc: # noqa: BLE001 - a background failure is reported, not raised
|
|
627
|
+
if os.environ.get("WHYKIT_DEBUG"):
|
|
628
|
+
traceback.print_exc(file=sys.stderr)
|
|
629
|
+
self.log(f"whykit lsp: lint failed: {type(exc).__name__}: {exc}", 1)
|
|
630
|
+
|
|
631
|
+
def _overlays(self, vault: _Vault) -> list[tuple[Path, str]]:
|
|
632
|
+
return [(path, text) for path, text, _, _ in self._snapshot(vault)]
|
|
633
|
+
|
|
634
|
+
def _snapshot(self, vault: _Vault) -> list[tuple[Path, str, str, Any]]:
|
|
635
|
+
"""Path, text, URI and version of every open Markdown buffer in *vault*, taken atomically."""
|
|
636
|
+
with self._lock:
|
|
637
|
+
return [
|
|
638
|
+
(doc.path, doc.text, doc.uri, doc.version) for doc in self._documents.values()
|
|
639
|
+
if doc.vault is vault and doc.path is not None and doc.path.suffix.lower() == ".md"
|
|
640
|
+
]
|
|
641
|
+
|
|
642
|
+
def _build_index(self, vault: _Vault, overlays: list[tuple[Path, str]], *, rescan: bool) -> VaultIndex:
|
|
643
|
+
"""Disk notes (reusing unchanged parses) with open buffers laid over them.
|
|
644
|
+
|
|
645
|
+
Call inside ``path_cache()``. Without *rescan* the last disk scan is
|
|
646
|
+
reused, so a keystroke does not stat every file of a large vault.
|
|
647
|
+
"""
|
|
648
|
+
root = vault.root
|
|
649
|
+
if rescan or vault.disk is None:
|
|
650
|
+
disk = vault.notes.load_all(collect_markdown(root, []))
|
|
651
|
+
vault.disk = disk
|
|
652
|
+
vault.disk_by_path = {_real(note.path): note for note in disk}
|
|
653
|
+
vault.disk_keys = {_key(note.path): number for number, note in enumerate(disk)}
|
|
654
|
+
notes = list(vault.disk)
|
|
655
|
+
by_path = dict(vault.disk_by_path)
|
|
656
|
+
depth = len(root.parts)
|
|
657
|
+
for path, text in overlays:
|
|
658
|
+
number = vault.disk_keys.get(_key(path))
|
|
659
|
+
if number is not None:
|
|
660
|
+
note = load_note(notes[number].path, text=text)
|
|
661
|
+
notes[number] = note
|
|
662
|
+
else:
|
|
663
|
+
real = _real(path)
|
|
664
|
+
if not _within(root, real) or any(part in CONTENT_SKIP_DIRS for part in real.parts[depth:]):
|
|
665
|
+
continue
|
|
666
|
+
note = load_note(real, text=text)
|
|
667
|
+
notes.append(note)
|
|
668
|
+
by_path[_real(note.path)] = note
|
|
669
|
+
return VaultIndex(root=root, notes=notes, by_path=by_path, link_index=_build_index(notes))
|
|
670
|
+
|
|
671
|
+
def _lint(self, vault: _Vault) -> None:
|
|
672
|
+
with vault.lint_lock:
|
|
673
|
+
with self._lock:
|
|
674
|
+
full = vault.full_pending or vault.full_findings is None
|
|
675
|
+
changed = [self._documents[uri] for uri in sorted(vault.changed) if uri in self._documents]
|
|
676
|
+
vault.full_pending = False
|
|
677
|
+
vault.changed = set()
|
|
678
|
+
snapshot = self._snapshot(vault)
|
|
679
|
+
overlays = [(path, text) for path, text, _, _ in snapshot]
|
|
680
|
+
versions = {uri: version for _, _, uri, version in snapshot}
|
|
681
|
+
with path_cache(fresh=True):
|
|
682
|
+
index = self._build_index(vault, overlays, rescan=full)
|
|
683
|
+
paths: list[str] = []
|
|
684
|
+
if not full:
|
|
685
|
+
for doc in changed:
|
|
686
|
+
if doc.path is None or vault.disk_keys.get(_key(doc.path)) is None:
|
|
687
|
+
full = True # a new, unsaved file: only a whole-vault lint sees it
|
|
688
|
+
break
|
|
689
|
+
paths.append(index.relative(doc.path))
|
|
690
|
+
if not full and not paths:
|
|
691
|
+
return
|
|
692
|
+
_, findings = lint(vault.root, paths or None, vault=index, today=self.today)
|
|
693
|
+
with self._lock:
|
|
694
|
+
vault.index = index
|
|
695
|
+
vault.decisions = None
|
|
696
|
+
self.lint_runs += 1
|
|
697
|
+
if full:
|
|
698
|
+
self._publish_full(vault, index, findings, versions)
|
|
699
|
+
else:
|
|
700
|
+
self._publish_scoped(vault, index, changed, findings, versions)
|
|
701
|
+
|
|
702
|
+
def _by_file(self, vault: _Vault, findings: list[Finding]) -> dict[str, tuple[Path, list[Finding]]]:
|
|
703
|
+
grouped: dict[str, tuple[Path, list[Finding]]] = {}
|
|
704
|
+
for finding in findings:
|
|
705
|
+
path = Path(finding.path)
|
|
706
|
+
if not path.is_absolute():
|
|
707
|
+
path = vault.root / PurePosixPath(finding.path)
|
|
708
|
+
grouped.setdefault(_key(path), (path, []))[1].append(finding)
|
|
709
|
+
return grouped
|
|
710
|
+
|
|
711
|
+
def _send_diagnostics(self, uri: str, diagnostics: list[JsonObject], versions: dict[str, Any]) -> None:
|
|
712
|
+
params: JsonObject = {"uri": uri, "diagnostics": diagnostics}
|
|
713
|
+
if isinstance(versions.get(uri), int):
|
|
714
|
+
params["version"] = versions[uri]
|
|
715
|
+
self.notify("textDocument/publishDiagnostics", params)
|
|
716
|
+
|
|
717
|
+
def _publish_full(self, vault: _Vault, index: VaultIndex, findings: list[Finding], versions: dict[str, Any]) -> None:
|
|
718
|
+
with self._lock:
|
|
719
|
+
open_docs = {_key(doc.path): doc for doc in self._documents.values()
|
|
720
|
+
if doc.vault is vault and doc.path is not None}
|
|
721
|
+
grouped = self._by_file(vault, findings)
|
|
722
|
+
vault.full_findings = {key: items for key, (_, items) in grouped.items()}
|
|
723
|
+
outgoing: dict[str, list[JsonObject]] = {}
|
|
724
|
+
for key, (path, items) in grouped.items():
|
|
725
|
+
doc = open_docs.get(key)
|
|
726
|
+
uri = doc.uri if doc is not None else path_to_uri(path)
|
|
727
|
+
# The text that was linted: an open buffer as it was, or the file.
|
|
728
|
+
lines = self._disk_lines(index, path)
|
|
729
|
+
outgoing[uri] = [self._diagnostic(item, lines) for item in items]
|
|
730
|
+
for doc in open_docs.values():
|
|
731
|
+
outgoing.setdefault(doc.uri, [])
|
|
732
|
+
with self._lock:
|
|
733
|
+
stale = vault.published - set(outgoing)
|
|
734
|
+
vault.published = {uri for uri, diagnostics in outgoing.items() if diagnostics}
|
|
735
|
+
for uri in sorted(stale):
|
|
736
|
+
self._send_diagnostics(uri, [], versions)
|
|
737
|
+
for uri, diagnostics in outgoing.items():
|
|
738
|
+
self._send_diagnostics(uri, diagnostics, versions)
|
|
739
|
+
|
|
740
|
+
def _publish_scoped(self, vault: _Vault, index: VaultIndex, docs: list[_Document], findings: list[Finding],
|
|
741
|
+
versions: dict[str, Any]) -> None:
|
|
742
|
+
"""Diagnostics for edited buffers: their own findings, fresh, plus the
|
|
743
|
+
cross-file findings of the last whole-vault lint (refreshed on save)."""
|
|
744
|
+
grouped = self._by_file(vault, findings)
|
|
745
|
+
previous = vault.full_findings or {}
|
|
746
|
+
for doc in docs:
|
|
747
|
+
assert doc.path is not None
|
|
748
|
+
key = _key(doc.path)
|
|
749
|
+
items = list(grouped.get(key, (doc.path, []))[1])
|
|
750
|
+
seen = {(item.line, item.code, item.message) for item in items}
|
|
751
|
+
for item in previous.get(key, []):
|
|
752
|
+
if item.code in CROSS_FILE_CODES and (item.line, item.code, item.message) not in seen:
|
|
753
|
+
items.append(item)
|
|
754
|
+
lines = self._disk_lines(index, doc.path)
|
|
755
|
+
diagnostics = [self._diagnostic(item, lines) for item in items]
|
|
756
|
+
with self._lock:
|
|
757
|
+
(vault.published.add if diagnostics else vault.published.discard)(doc.uri)
|
|
758
|
+
self._send_diagnostics(doc.uri, diagnostics, versions)
|
|
759
|
+
|
|
760
|
+
def _disk_lines(self, index: VaultIndex, path: Path) -> list[str]:
|
|
761
|
+
note = index.note_for(path)
|
|
762
|
+
if note is not None:
|
|
763
|
+
return _split_lines(note.text)
|
|
764
|
+
try:
|
|
765
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
766
|
+
return _split_lines(handle.read(4 * 1024 * 1024))
|
|
767
|
+
except OSError:
|
|
768
|
+
return []
|
|
769
|
+
|
|
770
|
+
def _diagnostic(self, finding: Finding, lines: list[str]) -> JsonObject:
|
|
771
|
+
line_no = max(0, (finding.line or 1) - 1)
|
|
772
|
+
text = lines[line_no] if line_no < len(lines) else ""
|
|
773
|
+
start, end = _finding_span(text, finding)
|
|
774
|
+
return {
|
|
775
|
+
"range": self._range(line_no, text, start, end),
|
|
776
|
+
"severity": SEVERITY.get(finding.level, 3),
|
|
777
|
+
"code": finding.code,
|
|
778
|
+
"source": "whykit",
|
|
779
|
+
"message": finding.message,
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
def _range(self, line_no: int, text: str, start: int, end: int) -> JsonObject:
|
|
783
|
+
return {
|
|
784
|
+
"start": {"line": line_no, "character": to_character(text, start, self.encoding)},
|
|
785
|
+
"end": {"line": line_no, "character": to_character(text, end, self.encoding)},
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
# ------------------------------------------------------------- navigation
|
|
789
|
+
|
|
790
|
+
def _index(self, vault: _Vault) -> VaultIndex:
|
|
791
|
+
"""The index of the last lint, or a fresh one when there was none yet."""
|
|
792
|
+
with self._lock:
|
|
793
|
+
index = vault.index
|
|
794
|
+
if index is None:
|
|
795
|
+
with path_cache():
|
|
796
|
+
index = self._build_index(vault, self._overlays(vault), rescan=False)
|
|
797
|
+
with self._lock:
|
|
798
|
+
vault.index = index
|
|
799
|
+
vault.decisions = None
|
|
800
|
+
return replace(index, _resolved={}, _relative={}, derived={})
|
|
801
|
+
|
|
802
|
+
def _decisions(self, vault: _Vault, index: VaultIndex) -> dict[str, Note]:
|
|
803
|
+
with self._lock:
|
|
804
|
+
if vault.decisions is not None and vault.index is index:
|
|
805
|
+
return vault.decisions
|
|
806
|
+
found: dict[str, Note] = {}
|
|
807
|
+
for note in index.notes:
|
|
808
|
+
if not _within(vault.root, note.path):
|
|
809
|
+
continue
|
|
810
|
+
if str(note.front.get("status", "")).strip() == "template":
|
|
811
|
+
continue
|
|
812
|
+
explicit = str(note.front.get("decision_id") or "").strip()
|
|
813
|
+
did = explicit if re.fullmatch(r"D-[0-9]{3,}", explicit) else None
|
|
814
|
+
if did is None and "06-decisions" in note.path.parts and DECISION_FILE_RE.match(note.path.name):
|
|
815
|
+
did = _decision_own_id(note)
|
|
816
|
+
if did is not None:
|
|
817
|
+
found.setdefault(did, note)
|
|
818
|
+
with self._lock:
|
|
819
|
+
if vault.index is index:
|
|
820
|
+
vault.decisions = found
|
|
821
|
+
return found
|
|
822
|
+
|
|
823
|
+
def _evidence(self, vault: _Vault) -> tuple[dict[str, dict[str, str]], dict[str, dict[str, str]]]:
|
|
824
|
+
register = vault.root / REGISTER
|
|
825
|
+
if not _within(vault.root, register):
|
|
826
|
+
return {}, {}
|
|
827
|
+
with self._lock:
|
|
828
|
+
open_text = next((doc.text for doc in self._documents.values()
|
|
829
|
+
if doc.path is not None and doc.vault is vault and _key(doc.path) == _key(register)), None)
|
|
830
|
+
if open_text is not None:
|
|
831
|
+
active, retired, _ = _parse_evidence_register_text(open_text.replace("\r\n", "\n"))
|
|
832
|
+
return active, retired
|
|
833
|
+
active, retired, _ = evidence_register(vault.root)
|
|
834
|
+
return active, retired
|
|
835
|
+
|
|
836
|
+
def _uri_for(self, path: Path) -> str:
|
|
837
|
+
key = _key(path)
|
|
838
|
+
with self._lock:
|
|
839
|
+
for doc in self._documents.values():
|
|
840
|
+
if doc.path is not None and _key(doc.path) == key:
|
|
841
|
+
return doc.uri
|
|
842
|
+
return path_to_uri(path)
|
|
843
|
+
|
|
844
|
+
def _location(self, path: Path, line: int = 0) -> JsonObject:
|
|
845
|
+
position = {"line": line, "character": 0}
|
|
846
|
+
return {"uri": self._uri_for(path), "range": {"start": position, "end": position}}
|
|
847
|
+
|
|
848
|
+
def _at(self, params: JsonObject) -> tuple[_Document, VaultIndex, int, str, str, int] | None:
|
|
849
|
+
"""Document, index, line number, raw and masked line, string index under the cursor."""
|
|
850
|
+
doc = self._document(params)
|
|
851
|
+
line_no, character = self._position(params)
|
|
852
|
+
if doc is None or doc.vault is None:
|
|
853
|
+
return None
|
|
854
|
+
raw, masked = doc.lines()
|
|
855
|
+
if line_no >= len(raw):
|
|
856
|
+
return None
|
|
857
|
+
index = self._index(doc.vault)
|
|
858
|
+
return doc, index, line_no, raw[line_no], masked[line_no], from_character(raw[line_no], character, self.encoding)
|
|
859
|
+
|
|
860
|
+
def _token(self, masked: str, at: int) -> tuple[str, re.Match[str]] | None:
|
|
861
|
+
for match in WIKILINK_RE.finditer(masked):
|
|
862
|
+
if match.start() <= at <= match.end():
|
|
863
|
+
return "link", match
|
|
864
|
+
for match in ID_RE.finditer(masked):
|
|
865
|
+
if match.start() <= at <= match.end():
|
|
866
|
+
return "id", match
|
|
867
|
+
return None
|
|
868
|
+
|
|
869
|
+
def _resolve_link(self, index: VaultIndex, target: str) -> Path | None:
|
|
870
|
+
path, ambiguous = index.resolve_link(target.strip())
|
|
871
|
+
return None if ambiguous or path is not None and not _within(index.root, path) else path
|
|
872
|
+
|
|
873
|
+
def _hover(self, params: JsonObject) -> JsonObject | None:
|
|
874
|
+
found = self._at(params)
|
|
875
|
+
if found is None:
|
|
876
|
+
return None
|
|
877
|
+
doc, index, line_no, raw, masked, at = found
|
|
878
|
+
assert doc.vault is not None
|
|
879
|
+
token = self._token(masked, at)
|
|
880
|
+
if token is None:
|
|
881
|
+
return None
|
|
882
|
+
kind, match = token
|
|
883
|
+
if kind == "link":
|
|
884
|
+
path = self._resolve_link(index, match.group(1))
|
|
885
|
+
note = index.note_for(path) if path is not None else None
|
|
886
|
+
if note is None:
|
|
887
|
+
value = f"`[[{match.group(1).strip()}]]` does not resolve to a note in this vault"
|
|
888
|
+
else:
|
|
889
|
+
value = _note_card(index, note)
|
|
890
|
+
else:
|
|
891
|
+
value = self._id_card(doc.vault, index, match.group(0))
|
|
892
|
+
return {"contents": {"kind": "markdown", "value": value},
|
|
893
|
+
"range": self._range(line_no, raw, match.start(), match.end())}
|
|
894
|
+
|
|
895
|
+
def _id_card(self, vault: _Vault, index: VaultIndex, ident: str) -> str:
|
|
896
|
+
if ident.startswith("E-"):
|
|
897
|
+
active, retired = self._evidence(vault)
|
|
898
|
+
if ident in active:
|
|
899
|
+
row = active[ident]
|
|
900
|
+
lines = [f"**{ident}** · active evidence", "", f"**Source:** {row['source']}"]
|
|
901
|
+
for label, key in (("Type", "type"), ("Date", "date"), ("Accessed", "accessed"),
|
|
902
|
+
("Location", "location"), ("Supports", "claims")):
|
|
903
|
+
if row.get(key):
|
|
904
|
+
lines.append(f"**{label}:** {row[key]}")
|
|
905
|
+
return " \n".join(lines)
|
|
906
|
+
if ident in retired:
|
|
907
|
+
row = retired[ident]
|
|
908
|
+
lines = [f"**{ident}** · retired evidence", "", f"**Source:** {row['source']}"]
|
|
909
|
+
for label, key in (("Retired", "retired_on"), ("Why", "why"), ("Replaced by", "replaced_by")):
|
|
910
|
+
if row.get(key):
|
|
911
|
+
lines.append(f"**{label}:** {row[key]}")
|
|
912
|
+
return " \n".join(lines)
|
|
913
|
+
return f"**{ident}** is not in the evidence register"
|
|
914
|
+
note = self._decisions(vault, index).get(ident)
|
|
915
|
+
if note is None:
|
|
916
|
+
return f"No decision record has the ID **{ident}**"
|
|
917
|
+
return _note_card(index, note)
|
|
918
|
+
|
|
919
|
+
def _definition(self, params: JsonObject) -> JsonObject | None:
|
|
920
|
+
found = self._at(params)
|
|
921
|
+
if found is None:
|
|
922
|
+
return None
|
|
923
|
+
doc, index, _, raw, masked, at = found
|
|
924
|
+
assert doc.vault is not None
|
|
925
|
+
token = self._token(masked, at)
|
|
926
|
+
if token is None:
|
|
927
|
+
return None
|
|
928
|
+
kind, match = token
|
|
929
|
+
if kind == "link":
|
|
930
|
+
target = match.group(1)
|
|
931
|
+
path = self._resolve_link(index, target)
|
|
932
|
+
if path is None:
|
|
933
|
+
attachment = self._attachment(doc, target)
|
|
934
|
+
return self._location(attachment) if attachment is not None else None
|
|
935
|
+
note = index.note_for(path)
|
|
936
|
+
heading = _link_heading(raw[match.start():match.end()])
|
|
937
|
+
line = _heading_line(note.text, heading) if note is not None and heading else 0
|
|
938
|
+
return self._location(path, line)
|
|
939
|
+
ident = match.group(0)
|
|
940
|
+
if ident.startswith("E-"):
|
|
941
|
+
active, retired = self._evidence(doc.vault)
|
|
942
|
+
row = active.get(ident) or retired.get(ident)
|
|
943
|
+
if row is None:
|
|
944
|
+
return None
|
|
945
|
+
return self._location(doc.vault.root / REGISTER, max(0, int(row.get("line", "1")) - 1))
|
|
946
|
+
note = self._decisions(doc.vault, index).get(ident)
|
|
947
|
+
return self._location(note.path) if note is not None else None
|
|
948
|
+
|
|
949
|
+
def _attachment(self, doc: _Document, target: str) -> Path | None:
|
|
950
|
+
assert doc.vault is not None
|
|
951
|
+
normalized = target.strip().replace("\\", "/")
|
|
952
|
+
if not normalized or normalized.lower().endswith(".md"):
|
|
953
|
+
return None
|
|
954
|
+
candidates = [doc.vault.root / normalized.lstrip("/")]
|
|
955
|
+
if doc.path is not None:
|
|
956
|
+
candidates.append(doc.path.parent / normalized)
|
|
957
|
+
for candidate in candidates:
|
|
958
|
+
if candidate.is_file() and _within(doc.vault.root, candidate):
|
|
959
|
+
return candidate
|
|
960
|
+
return None
|
|
961
|
+
|
|
962
|
+
def _document_links(self, params: JsonObject) -> list[JsonObject]:
|
|
963
|
+
doc = self._document(params)
|
|
964
|
+
if doc is None or doc.vault is None:
|
|
965
|
+
return []
|
|
966
|
+
index = self._index(doc.vault)
|
|
967
|
+
raw, masked = doc.lines()
|
|
968
|
+
links: list[JsonObject] = []
|
|
969
|
+
for line_no, (line, hidden) in enumerate(zip(raw, masked, strict=True)):
|
|
970
|
+
if "[" not in hidden:
|
|
971
|
+
continue
|
|
972
|
+
for match in WIKILINK_RE.finditer(hidden):
|
|
973
|
+
target = match.group(1)
|
|
974
|
+
if not target.strip() or URL_SCHEME_RE.match(target.strip()):
|
|
975
|
+
continue
|
|
976
|
+
path = self._resolve_link(index, target)
|
|
977
|
+
if path is None:
|
|
978
|
+
path = self._attachment(doc, target)
|
|
979
|
+
if path is not None:
|
|
980
|
+
links.append({"range": self._range(line_no, line, match.start(1), match.end(1)),
|
|
981
|
+
"target": self._uri_for(path)})
|
|
982
|
+
for match in MARKDOWN_LINK_RE.finditer(hidden):
|
|
983
|
+
target = match.group(2).strip()
|
|
984
|
+
if target.startswith("<") and ">" in target:
|
|
985
|
+
target = target[1:target.index(">")]
|
|
986
|
+
else:
|
|
987
|
+
target = target.split(None, 1)[0] if target else ""
|
|
988
|
+
if not target or target.startswith("#") or URL_SCHEME_RE.match(target):
|
|
989
|
+
continue
|
|
990
|
+
local = unquote(target.split("#", 1)[0].split("?", 1)[0])
|
|
991
|
+
if not local:
|
|
992
|
+
continue
|
|
993
|
+
base = doc.vault.root if local.startswith("/") else (doc.path.parent if doc.path else doc.vault.root)
|
|
994
|
+
candidate = base / local.lstrip("/")
|
|
995
|
+
if candidate.exists() and _within(doc.vault.root, candidate):
|
|
996
|
+
start = match.start(2) + match.group(2).index(target) if target in match.group(2) else match.start(2)
|
|
997
|
+
links.append({"range": self._range(line_no, line, start, start + len(target)),
|
|
998
|
+
"target": self._uri_for(candidate)})
|
|
999
|
+
return links
|
|
1000
|
+
|
|
1001
|
+
def _completion(self, params: JsonObject) -> JsonObject:
|
|
1002
|
+
empty = {"isIncomplete": False, "items": []}
|
|
1003
|
+
found = self._at(params)
|
|
1004
|
+
if found is None:
|
|
1005
|
+
return empty
|
|
1006
|
+
doc, index, line_no, raw, _, at = found
|
|
1007
|
+
assert doc.vault is not None
|
|
1008
|
+
before = raw[:at]
|
|
1009
|
+
opening = before.rfind("[[")
|
|
1010
|
+
if opening >= 0 and "]]" not in before[opening:]:
|
|
1011
|
+
typed = before[opening + 2:]
|
|
1012
|
+
if "|" in typed or "#" in typed:
|
|
1013
|
+
return empty
|
|
1014
|
+
return self._link_items(doc.vault, index, line_no, raw, opening + 2, at, typed)
|
|
1015
|
+
match = ID_PREFIX_RE.search(before)
|
|
1016
|
+
if match is None:
|
|
1017
|
+
return empty
|
|
1018
|
+
return self._id_items(doc.vault, index, line_no, raw, match.start(1), at, match.group(1))
|
|
1019
|
+
|
|
1020
|
+
def _link_items(self, vault: _Vault, index: VaultIndex, line_no: int, raw: str,
|
|
1021
|
+
start: int, end: int, typed: str) -> JsonObject:
|
|
1022
|
+
needle = typed.strip().casefold()
|
|
1023
|
+
closing = "" if raw[end:].startswith(("]]", "|", "#")) else "]]"
|
|
1024
|
+
edit_range = self._range(line_no, raw, start, end)
|
|
1025
|
+
candidates: list[JsonObject] = []
|
|
1026
|
+
|
|
1027
|
+
def unique(name: str, note: Note) -> bool:
|
|
1028
|
+
hits = index.link_index.get(unicodedata.normalize("NFC", name).casefold(), set())
|
|
1029
|
+
return hits == {note.path}
|
|
1030
|
+
|
|
1031
|
+
def shortest(note: Note) -> str:
|
|
1032
|
+
relative = index.relative(note.path).removesuffix(".md")
|
|
1033
|
+
return note.path.stem if unique(note.path.stem, note) else relative
|
|
1034
|
+
|
|
1035
|
+
def add(label: str, insert: str, kind: int, detail: str, filter_text: str, sort: str) -> None:
|
|
1036
|
+
if needle and needle not in filter_text.casefold():
|
|
1037
|
+
return
|
|
1038
|
+
candidates.append({
|
|
1039
|
+
"label": label, "kind": kind, "detail": detail, "filterText": filter_text,
|
|
1040
|
+
"sortText": sort, "textEdit": {"range": edit_range, "newText": insert + closing},
|
|
1041
|
+
})
|
|
1042
|
+
|
|
1043
|
+
decisions = self._decisions(vault, index)
|
|
1044
|
+
for did, note in sorted(decisions.items()):
|
|
1045
|
+
title = str(note.front.get("title") or note.path.stem)
|
|
1046
|
+
insert = did if unique(did, note) else shortest(note)
|
|
1047
|
+
add(did, insert, KIND_REFERENCE, f"{title} · {note.front.get('status') or 'no status'}", f"{did} {title}", f"0{did}")
|
|
1048
|
+
for note in index.notes:
|
|
1049
|
+
if not _within(vault.root, note.path):
|
|
1050
|
+
continue
|
|
1051
|
+
if str(note.front.get("status", "")).strip() == "template":
|
|
1052
|
+
continue
|
|
1053
|
+
relative = index.relative(note.path).removesuffix(".md")
|
|
1054
|
+
title = str(note.front.get("title") or note.path.stem)
|
|
1055
|
+
add(relative, shortest(note), KIND_FILE, title, relative, f"1{relative}")
|
|
1056
|
+
aliases = note.front.get("aliases")
|
|
1057
|
+
for alias in aliases if isinstance(aliases, list) else []:
|
|
1058
|
+
alias = str(alias).strip()
|
|
1059
|
+
if alias and alias not in decisions and unique(alias, note):
|
|
1060
|
+
add(alias, alias, KIND_REFERENCE, f"alias of {relative}", alias, f"2{alias}")
|
|
1061
|
+
candidates.sort(key=lambda item: item["sortText"])
|
|
1062
|
+
return {"isIncomplete": len(candidates) > COMPLETION_LIMIT, "items": candidates[:COMPLETION_LIMIT]}
|
|
1063
|
+
|
|
1064
|
+
def _id_items(self, vault: _Vault, index: VaultIndex, line_no: int, raw: str,
|
|
1065
|
+
start: int, end: int, typed: str) -> JsonObject:
|
|
1066
|
+
edit_range = self._range(line_no, raw, start, end)
|
|
1067
|
+
items: list[JsonObject] = []
|
|
1068
|
+
if typed.startswith("E"):
|
|
1069
|
+
active, retired = self._evidence(vault)
|
|
1070
|
+
for ident, row in sorted({**retired, **active}.items()):
|
|
1071
|
+
if not ident.startswith(typed):
|
|
1072
|
+
continue
|
|
1073
|
+
item: JsonObject = {
|
|
1074
|
+
"label": ident, "kind": KIND_CONSTANT, "detail": row.get("source", ""),
|
|
1075
|
+
"documentation": row.get("claims") or row.get("why") or "",
|
|
1076
|
+
"textEdit": {"range": edit_range, "newText": ident},
|
|
1077
|
+
}
|
|
1078
|
+
if ident not in active:
|
|
1079
|
+
item["tags"] = [TAG_DEPRECATED]
|
|
1080
|
+
item["detail"] = f"{row.get('source', '')} (retired)"
|
|
1081
|
+
items.append(item)
|
|
1082
|
+
else:
|
|
1083
|
+
for ident, note in sorted(self._decisions(vault, index).items()):
|
|
1084
|
+
if ident.startswith(typed):
|
|
1085
|
+
title = str(note.front.get("title") or note.path.stem)
|
|
1086
|
+
items.append({
|
|
1087
|
+
"label": ident, "kind": KIND_REFERENCE,
|
|
1088
|
+
"detail": f"{title} · {note.front.get('status') or 'no status'}",
|
|
1089
|
+
"textEdit": {"range": edit_range, "newText": ident},
|
|
1090
|
+
})
|
|
1091
|
+
return {"isIncomplete": len(items) > COMPLETION_LIMIT, "items": items[:COMPLETION_LIMIT]}
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
_HANDLERS: dict[str, Callable[[LanguageServer, JsonObject], Any]] = {
|
|
1095
|
+
"initialized": LanguageServer._initialized_notification,
|
|
1096
|
+
"shutdown": LanguageServer._shutdown_request,
|
|
1097
|
+
"textDocument/didOpen": LanguageServer._did_open,
|
|
1098
|
+
"textDocument/didChange": LanguageServer._did_change,
|
|
1099
|
+
"textDocument/didSave": LanguageServer._did_save,
|
|
1100
|
+
"textDocument/didClose": LanguageServer._did_close,
|
|
1101
|
+
"workspace/didChangeWatchedFiles": LanguageServer._did_change_watched,
|
|
1102
|
+
"textDocument/hover": LanguageServer._hover,
|
|
1103
|
+
"textDocument/definition": LanguageServer._definition,
|
|
1104
|
+
"textDocument/completion": LanguageServer._completion,
|
|
1105
|
+
"textDocument/documentLink": LanguageServer._document_links,
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
# --------------------------------------------------------------------------
|
|
1110
|
+
# Helpers.
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _finding_span(text: str, finding: Finding) -> tuple[int, int]:
|
|
1114
|
+
"""Columns of the part of *text* a finding is about; the whole line otherwise."""
|
|
1115
|
+
link = MESSAGE_LINK_RE.search(finding.message)
|
|
1116
|
+
if link is not None:
|
|
1117
|
+
at = text.find("[[" + link.group(1))
|
|
1118
|
+
if at >= 0:
|
|
1119
|
+
close = text.find("]]", at)
|
|
1120
|
+
return at, (close + 2 if close >= 0 else at + len(link.group(1)) + 2)
|
|
1121
|
+
paren = MESSAGE_PAREN_RE.search(finding.message)
|
|
1122
|
+
if paren is not None and finding.code.startswith("markdown_link"):
|
|
1123
|
+
at = text.find(paren.group(1))
|
|
1124
|
+
if at >= 0:
|
|
1125
|
+
return at, at + len(paren.group(1))
|
|
1126
|
+
for ident in ID_RE.findall(finding.message):
|
|
1127
|
+
match = re.search(rf"\b{re.escape(ident)}\b", text)
|
|
1128
|
+
if match is not None:
|
|
1129
|
+
return match.start(), match.end()
|
|
1130
|
+
stripped = text.rstrip()
|
|
1131
|
+
start = len(stripped) - len(stripped.lstrip())
|
|
1132
|
+
return start, len(stripped)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _note_card(index: VaultIndex, note: Note) -> str:
|
|
1136
|
+
front = note.front
|
|
1137
|
+
title = str(front.get("title") or note.path.stem)
|
|
1138
|
+
lines = [f"**{title}**", "", f"`{index.relative(note.path)}`", ""]
|
|
1139
|
+
for label, key in (("Type", "type"), ("Status", "status"), ("Owner", "owner"),
|
|
1140
|
+
("Review by", "review_by"), ("Decision", "decision_id"),
|
|
1141
|
+
("Supersedes", "supersedes"), ("Superseded by", "superseded_by")):
|
|
1142
|
+
value = front.get(key)
|
|
1143
|
+
if value not in (None, "", []):
|
|
1144
|
+
lines.append(f"**{label}:** {', '.join(map(str, value)) if isinstance(value, list) else value}")
|
|
1145
|
+
sources = front.get("source_ids")
|
|
1146
|
+
if isinstance(sources, list) and sources:
|
|
1147
|
+
lines.append(f"**Evidence:** {', '.join(map(str, sources))}")
|
|
1148
|
+
return " \n".join(lines)
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _link_heading(link_text: str) -> str | None:
|
|
1152
|
+
inner = link_text.strip("![]")
|
|
1153
|
+
inner = inner.split("|", 1)[0]
|
|
1154
|
+
if "#" not in inner:
|
|
1155
|
+
return None
|
|
1156
|
+
heading = inner.split("#", 1)[1].strip()
|
|
1157
|
+
return heading or None
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _heading_line(text: str, heading: str) -> int:
|
|
1161
|
+
wanted = heading.casefold()
|
|
1162
|
+
for number, line in enumerate(text.split("\n")):
|
|
1163
|
+
match = HEADING_RE.match(line)
|
|
1164
|
+
if match is not None and match.group(1).casefold() == wanted:
|
|
1165
|
+
return number
|
|
1166
|
+
return 0
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
# --------------------------------------------------------------------------
|
|
1170
|
+
# Entry points.
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def serve(
|
|
1174
|
+
stdin: IO[bytes],
|
|
1175
|
+
stdout: IO[bytes],
|
|
1176
|
+
*,
|
|
1177
|
+
root: Path | None = None,
|
|
1178
|
+
debounce: float = 0.3,
|
|
1179
|
+
today: dt.date | None = None,
|
|
1180
|
+
) -> int:
|
|
1181
|
+
"""Run the server until ``exit`` or end of input; return the exit code."""
|
|
1182
|
+
write_lock = threading.Lock()
|
|
1183
|
+
|
|
1184
|
+
def send(message: JsonObject) -> None:
|
|
1185
|
+
with write_lock:
|
|
1186
|
+
write_message(stdout, message)
|
|
1187
|
+
|
|
1188
|
+
server = LanguageServer(send, root=root, debounce=debounce, today=today)
|
|
1189
|
+
while server.running:
|
|
1190
|
+
try:
|
|
1191
|
+
message = read_message(stdin)
|
|
1192
|
+
except (ProtocolError, ValueError) as exc:
|
|
1193
|
+
# json.JSONDecodeError and UnicodeDecodeError are ValueErrors too.
|
|
1194
|
+
send({"jsonrpc": "2.0", "id": None, "error": {"code": PARSE_ERROR, "message": str(exc)}})
|
|
1195
|
+
continue
|
|
1196
|
+
if message is None:
|
|
1197
|
+
server._stop()
|
|
1198
|
+
return 0 if server.exit_code == 0 else 1
|
|
1199
|
+
server.handle(message)
|
|
1200
|
+
return server.exit_code if server.exit_code is not None else 1
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1204
|
+
parser = argparse.ArgumentParser(
|
|
1205
|
+
prog="whykit lsp",
|
|
1206
|
+
description="Run the read-only WhyKit language server over stdio.",
|
|
1207
|
+
)
|
|
1208
|
+
parser.add_argument("--root", help="serve only this vault (default: the vault around each open file)")
|
|
1209
|
+
parser.add_argument("--debounce", type=int, default=300, help="milliseconds to wait after an edit before linting (default: %(default)s)")
|
|
1210
|
+
parser.add_argument("--today", help="evaluate review dates as of this YYYY-MM-DD date instead of today")
|
|
1211
|
+
parser.add_argument("--stdio", action="store_true", help="accepted for editor clients that pass it; stdio is the only transport")
|
|
1212
|
+
return parser
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1216
|
+
from .contract import emit_error, vault_not_found
|
|
1217
|
+
|
|
1218
|
+
args = build_parser().parse_args(argv)
|
|
1219
|
+
today = None
|
|
1220
|
+
if args.today:
|
|
1221
|
+
try:
|
|
1222
|
+
today = dt.date.fromisoformat(args.today)
|
|
1223
|
+
except ValueError:
|
|
1224
|
+
return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=False)
|
|
1225
|
+
if args.debounce < 0:
|
|
1226
|
+
return emit_error("invalid_argument", "--debounce must be >= 0", json_mode=False)
|
|
1227
|
+
root = None
|
|
1228
|
+
if args.root:
|
|
1229
|
+
root = Path(args.root).expanduser().resolve()
|
|
1230
|
+
if not is_vault_root(root):
|
|
1231
|
+
return vault_not_found(args.root, json_mode=False)
|
|
1232
|
+
stdin, stdout = sys.stdin.buffer, sys.stdout.buffer
|
|
1233
|
+
# The protocol owns stdout: anything else printed there would corrupt a frame.
|
|
1234
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
1235
|
+
return serve(stdin, stdout, root=root, debounce=args.debounce / 1000, today=today)
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
__all__ = [
|
|
1239
|
+
"LanguageServer", "ProtocolError", "apply_change", "from_character", "main", "path_to_uri",
|
|
1240
|
+
"read_message", "serve", "to_character", "uri_to_path", "write_message",
|
|
1241
|
+
]
|