opencrbasic 1.0.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.
Files changed (117) hide show
  1. opencrbasic/__init__.py +3 -0
  2. opencrbasic/__main__.py +5 -0
  3. opencrbasic/analysis.py +184 -0
  4. opencrbasic/bindings.py +1034 -0
  5. opencrbasic/catalog.py +173 -0
  6. opencrbasic/checkconfig.py +144 -0
  7. opencrbasic/checker/__init__.py +977 -0
  8. opencrbasic/checker/cr3xx.py +3118 -0
  9. opencrbasic/checker/ess.py +81 -0
  10. opencrbasic/checker/expressions.py +3581 -0
  11. opencrbasic/checker/instr_data.py +1802 -0
  12. opencrbasic/checker/instr_measure.py +1547 -0
  13. opencrbasic/checker/instr_rules.py +3540 -0
  14. opencrbasic/checker/instructions.py +17 -0
  15. opencrbasic/checker/intervals.py +678 -0
  16. opencrbasic/checker/longconv.py +133 -0
  17. opencrbasic/checker/scan_time.py +580 -0
  18. opencrbasic/checker/storage.py +456 -0
  19. opencrbasic/checker/structs.py +240 -0
  20. opencrbasic/checker/structure.py +6213 -0
  21. opencrbasic/checker/symbols.py +609 -0
  22. opencrbasic/checker/terminals.py +249 -0
  23. opencrbasic/checker/unused.py +186 -0
  24. opencrbasic/checker/var_types.py +564 -0
  25. opencrbasic/checker/warnings.py +661 -0
  26. opencrbasic/checks.py +491 -0
  27. opencrbasic/cli.py +391 -0
  28. opencrbasic/commands.py +128 -0
  29. opencrbasic/compiler.py +320 -0
  30. opencrbasic/consteval.py +327 -0
  31. opencrbasic/data/background_calibration.json +4 -0
  32. opencrbasic/data/before_beginprog.json +99 -0
  33. opencrbasic/data/cr1000x/background_calibration.json +4 -0
  34. opencrbasic/data/cr1000x/before_beginprog.json +138 -0
  35. opencrbasic/data/cr1000x/ess_variables.json +1087 -0
  36. opencrbasic/data/cr1000x/fnotch.json +29 -0
  37. opencrbasic/data/cr1000x/functions.json +1337 -0
  38. opencrbasic/data/cr1000x/instructions.json +4836 -0
  39. opencrbasic/data/cr1000x/measure.json +121 -0
  40. opencrbasic/data/cr1000x/mode.json +111 -0
  41. opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
  42. opencrbasic/data/cr1000x/settings_fields.json +321 -0
  43. opencrbasic/data/cr1000x/status_members.json +318 -0
  44. opencrbasic/data/cr1000x/symbols.json +872 -0
  45. opencrbasic/data/cr1000x/terminals.json +225 -0
  46. opencrbasic/data/cr300/before_beginprog.json +42 -0
  47. opencrbasic/data/cr300/ess_variables.json +1087 -0
  48. opencrbasic/data/cr300/fnotch.json +22 -0
  49. opencrbasic/data/cr300/functions.json +1261 -0
  50. opencrbasic/data/cr300/instructions.json +2837 -0
  51. opencrbasic/data/cr300/measure.json +33 -0
  52. opencrbasic/data/cr300/mode.json +121 -0
  53. opencrbasic/data/cr300/settings_fields.json +259 -0
  54. opencrbasic/data/cr300/status_members.json +303 -0
  55. opencrbasic/data/cr300/symbols.json +584 -0
  56. opencrbasic/data/cr300/terminals.json +193 -0
  57. opencrbasic/data/cr350/before_beginprog.json +44 -0
  58. opencrbasic/data/cr350/ess_variables.json +1087 -0
  59. opencrbasic/data/cr350/fnotch.json +22 -0
  60. opencrbasic/data/cr350/functions.json +1262 -0
  61. opencrbasic/data/cr350/instructions.json +2848 -0
  62. opencrbasic/data/cr350/measure.json +35 -0
  63. opencrbasic/data/cr350/mode.json +121 -0
  64. opencrbasic/data/cr350/settings_fields.json +292 -0
  65. opencrbasic/data/cr350/status_members.json +303 -0
  66. opencrbasic/data/cr350/symbols.json +572 -0
  67. opencrbasic/data/cr350/terminals.json +190 -0
  68. opencrbasic/data/ess_variables.json +1087 -0
  69. opencrbasic/data/fnotch.json +34 -0
  70. opencrbasic/data/functions.json +1337 -0
  71. opencrbasic/data/instructions.json +4965 -0
  72. opencrbasic/data/measure.json +123 -0
  73. opencrbasic/data/mode.json +121 -0
  74. opencrbasic/data/notices_cr6.json +890 -0
  75. opencrbasic/data/pipeline_tasks.json +84 -0
  76. opencrbasic/data/settings_fields.json +322 -0
  77. opencrbasic/data/status_members.json +319 -0
  78. opencrbasic/data/status_table.json +684 -0
  79. opencrbasic/data/symbols.json +902 -0
  80. opencrbasic/data/terminals.json +195 -0
  81. opencrbasic/data/type_codes.json +249 -0
  82. opencrbasic/diagnostics.py +196 -0
  83. opencrbasic/editing.py +827 -0
  84. opencrbasic/formatting.py +310 -0
  85. opencrbasic/helpdocs.py +677 -0
  86. opencrbasic/hover.py +667 -0
  87. opencrbasic/inlay.py +169 -0
  88. opencrbasic/lexer.py +183 -0
  89. opencrbasic/lsp/__init__.py +5 -0
  90. opencrbasic/lsp/__main__.py +7 -0
  91. opencrbasic/lsp/config.py +202 -0
  92. opencrbasic/lsp/features.py +602 -0
  93. opencrbasic/lsp/server.py +741 -0
  94. opencrbasic/lsp/session.py +1189 -0
  95. opencrbasic/lsp/worker.py +128 -0
  96. opencrbasic/mode.py +143 -0
  97. opencrbasic/navigation.py +401 -0
  98. opencrbasic/parser.py +3264 -0
  99. opencrbasic/positions.py +274 -0
  100. opencrbasic/preprocess.py +269 -0
  101. opencrbasic/profiles.py +266 -0
  102. opencrbasic/provenance.py +17 -0
  103. opencrbasic/quickfix.py +331 -0
  104. opencrbasic/rename.py +662 -0
  105. opencrbasic/signature.py +33 -0
  106. opencrbasic/source.py +600 -0
  107. opencrbasic/sources.py +125 -0
  108. opencrbasic/syntax.py +337 -0
  109. opencrbasic/tables.py +2173 -0
  110. opencrbasic/tables_hook.py +14 -0
  111. opencrbasic/tdf.py +142 -0
  112. opencrbasic/timing.py +565 -0
  113. opencrbasic-1.0.0.dist-info/METADATA +223 -0
  114. opencrbasic-1.0.0.dist-info/RECORD +117 -0
  115. opencrbasic-1.0.0.dist-info/WHEEL +4 -0
  116. opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
  117. opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1189 @@
1
+ """Server state and scheduling, independent of the protocol library.
2
+
3
+ Owns the open documents (overlays), the configured entries, per-entry debounced analysis in a
4
+ spawn-context process pool, and the merge of per-entry results into per-file diagnostics. The
5
+ protocol adapter (`server.py`) feeds it events and supplies a `publish` callback. Analysis runs
6
+ in worker processes and is awaited, so the event loop never blocks on it.
7
+ """
8
+
9
+ import asyncio
10
+ import hashlib
11
+ import itertools
12
+ import multiprocessing
13
+ import os
14
+ import pickle
15
+ import re
16
+ import sys
17
+ import threading
18
+ import traceback
19
+ from collections.abc import Callable
20
+ from concurrent.futures import ProcessPoolExecutor
21
+ from concurrent.futures.process import BrokenProcessPool
22
+ from dataclasses import dataclass, field, replace
23
+ from pathlib import Path
24
+ from urllib.parse import quote
25
+
26
+ from .. import checkconfig, positions
27
+ from .config import Config, discover
28
+ from .worker import JobResult, OutputResult, run_analysis, run_compiler_output
29
+
30
+ SRC_COMPILE = "opencrbasic"
31
+ SRC_INTERNAL = "opencrbasic (internal)"
32
+ SRC_SERVER = "opencrbasic (server)"
33
+ SRC_CHECK = "opencrbasic (check)" # opencrbasic checks (`OCB...`): the reference accepts these
34
+ SRC_COVERAGE = "opencrbasic (coverage)" # coverage notices: reference rules opencrbasic does not model
35
+ _CHANNELS = {"check": SRC_CHECK, "notice": SRC_COVERAGE}
36
+ _SEVERITIES = {"info": "information"} # check severity -> LSP severity name, where they differ
37
+ MARKERS = ("Internal error: unable to compile program.", "Internal error: unable to parse program.")
38
+ INFO_UNINCLUDED = (
39
+ "No configured entry program includes this file; diagnostics come from the entry programs that include it."
40
+ )
41
+ INFO_NOT_ENTRY = (
42
+ "This file is not an entry program for the configured logger and entries, and no entry program includes it."
43
+ )
44
+ _LINE_END = re.compile(r"\r\n|\r|\n")
45
+ MIB = 1024 * 1024
46
+ CREATED, CHANGED, DELETED = "created", "changed", "deleted"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Diag:
51
+ line: int # 0-based LSP line where the range starts; the range starts at character 0
52
+ end_line: int # 0-based LSP line where the range ends (== line for one physical line)
53
+ end: int # end character (negotiated position-encoding units) of end_line
54
+ severity: str # "error" | "warning" | "information" | "hint"
55
+ message: str
56
+ source: str
57
+ code: str | None = None
58
+ link: str | None = None # `codeDescription.href`
59
+ data: tuple[tuple[str, object], ...] = () # `data`: the entry and the diagnostic's index (quick fixes)
60
+
61
+
62
+ # publish(uri, version or None, diagnostics)
63
+ Publish = Callable[[str, "int | None", "list[Diag]"], None]
64
+
65
+
66
+ @dataclass
67
+ class Doc:
68
+ uri: str
69
+ version: int
70
+ text: str
71
+ data: bytes = b""
72
+ replaced: bool = False
73
+
74
+
75
+ @dataclass
76
+ class Raw:
77
+ severity: str
78
+ message: str
79
+ line: int # compiler line (0: none)
80
+ internal: bool = False
81
+ span: tuple[int, int] | None = None # physical (first, last) line in the target file
82
+ source: str = SRC_COMPILE
83
+ code: str | None = None
84
+ link: str | None = None
85
+ index: int | None = None # position in the job's diagnostics (quick fixes)
86
+
87
+
88
+ class Analysed:
89
+ """The parsed program of one entry for the language features. Built from the pickled program
90
+ a worker returned; the unpickling happens lazily in a thread (`Session.load`). The caches hold
91
+ objects the core feature modules derive from the program (declared names, binding index...)."""
92
+
93
+ def __init__(
94
+ self,
95
+ entry: Path,
96
+ logger: str,
97
+ overlays: tuple[tuple[str, bytes], ...],
98
+ versions: dict[Path, int],
99
+ blob: bytes,
100
+ job: JobResult | None = None,
101
+ ):
102
+ self.entry = entry
103
+ self.token = 0 # set by `Session._apply`: which applied result this is
104
+ self.ok = True if job is None else job.ok
105
+ self.failures = () if job is None else job.failures
106
+ self.reads = {} if job is None else {Path(p): h for p, h in job.reads}
107
+ self.raw_diagnostics = () if job is None else job.diagnostics
108
+ self.logger = logger
109
+ self.overlays = overlays
110
+ self.versions = dict(versions) # open documents (path -> version) the analysis read
111
+ self.blob: bytes | None = blob
112
+ self.size = len(blob) # pickled bytes, for the cache budget
113
+ self.result = None # analysis.AnalysisResult with the program only
114
+ self.failed = False
115
+ self.lock: asyncio.Lock | None = None
116
+ self._cache: dict[str, object] = {}
117
+ self._cache_lock = threading.Lock()
118
+ self.guard = threading.RLock() # serialises core feature calls on this program
119
+
120
+ def build(self):
121
+ """Runs in a thread. The result has the program and the request; its diagnostics are
122
+ empty (diagnostics are published from the job result, not from here)."""
123
+ from .. import analysis
124
+
125
+ try:
126
+ program = pickle.loads(self.blob or b"")
127
+ except Exception:
128
+ self.failed = True
129
+ return None
130
+ self.blob = None
131
+ request = analysis.AnalysisRequest(self.entry, self.logger, {Path(p): data for p, data in self.overlays})
132
+ diagnostics = tuple(
133
+ analysis.AnalysisDiagnostic(sev, msg, line, file, None if path is None else Path(path), "", span)
134
+ for sev, msg, line, path, file, span in self.raw_diagnostics
135
+ )
136
+ return analysis.AnalysisResult(
137
+ analysis.API_VERSION,
138
+ request,
139
+ self.ok,
140
+ None,
141
+ diagnostics,
142
+ (),
143
+ tuple(self.failures),
144
+ analysis.Dependencies(dict(self.reads), {}),
145
+ program,
146
+ )
147
+
148
+ def cached(self, key: str, make: Callable[[], object]) -> object:
149
+ with self._cache_lock:
150
+ if key not in self._cache:
151
+ self._cache[key] = make()
152
+ return self._cache[key]
153
+
154
+
155
+ class _LockedHelp:
156
+ """A `helpdocs.HelpProvider` shared by the request threads (its lazy index is not locked)."""
157
+
158
+ def __init__(self, directory: Path):
159
+ from ..helpdocs import HelpProvider
160
+
161
+ self.directory = directory
162
+ self._provider = HelpProvider(directory)
163
+ self._lock = threading.Lock()
164
+ self.warming = False
165
+
166
+ def lookup(self, name: str):
167
+ with self._lock:
168
+ return self._provider.lookup(name)
169
+
170
+ def warm(self) -> None:
171
+ with self._lock:
172
+ self._provider.names()
173
+
174
+
175
+ @dataclass
176
+ class Entry:
177
+ path: Path
178
+ requested: int = 0
179
+ done: int = 0
180
+ not_before: float = 0.0
181
+ task: asyncio.Task | None = None
182
+ removed: bool = False
183
+ settled: bool = False # a result or an error has been applied
184
+ reads: dict[str, str | None] = field(default_factory=dict) # lower-cased path -> sha256
185
+ dirs: frozenset[str] = frozenset() # lower-cased listed directories
186
+ contrib: dict[Path, list[Raw]] = field(default_factory=dict)
187
+ versions: dict[Path, int] = field(default_factory=dict) # open docs at capture
188
+ error: str | None = None
189
+ error_limit: bool = False # `error` is a server limit, not a failure
190
+ triggers: set[Path] | None = field(default_factory=set) # paths re-requesting it while a job
191
+ # runs (None: a request not tied to a path)
192
+ used: int = 0 # last feature use (cache eviction order)
193
+ evicted: bool = False # its program was dropped by the cache budget
194
+ analysed: Analysed | None = None # newest program that was parsed, for features
195
+ analysed_current: bool = False # the newest applied analysis produced it
196
+ root_used: tuple[str | None] | None = None # (config root,) its last job searched; None: no job yet
197
+
198
+
199
+ @dataclass
200
+ class Snapshot:
201
+ overlays: tuple[tuple[str, bytes], ...]
202
+ versions: dict[Path, int]
203
+ logger: str
204
+ timeout: float
205
+ max_file_bytes: int | None = None
206
+ kind: str = "analysis" # the worker job: "analysis" or "compiler_output"
207
+ checks: tuple[tuple[str, str], ...] = () # LSP check severities (analysis only)
208
+ notices: bool = False # coverage notices (analysis only)
209
+
210
+
211
+ class ServerError(Exception):
212
+ def __init__(self, message: str, limit: bool = False, result: JobResult | None = None, reason: str = "failed"):
213
+ super().__init__(message)
214
+ self.limit = limit # a configured bound was hit: reported as a server warning
215
+ self.result = result # the analysis that was discarded (its reads still count)
216
+ self.reason = reason # "failed", "timeout" or "too_large" (command refusals)
217
+
218
+
219
+ class _Broken:
220
+ """Marks a worker pool break, internal to `_run_job`."""
221
+
222
+
223
+ _BROKEN = _Broken()
224
+
225
+
226
+ def _oversize(path: Path, overlays: dict[Path, int], limit: int) -> tuple[Path, int] | None:
227
+ """`path` and its size (open text, else the disk file) when larger than `limit` bytes."""
228
+ try:
229
+ size = overlays[path] if path in overlays else path.stat().st_size
230
+ except OSError:
231
+ return None
232
+ return (path, size) if size > limit else None
233
+
234
+
235
+ def _key(path: Path) -> str:
236
+ return str(path).lower()
237
+
238
+
239
+ def _digest(path: Path) -> str | None:
240
+ try:
241
+ return hashlib.sha256(path.read_bytes()).hexdigest()
242
+ except OSError:
243
+ return None
244
+
245
+
246
+ def display_text(message: str, encoding: str) -> str:
247
+ """Compiler message text for display. The compiler decodes source bytes as Latin-1, so a
248
+ name written in the editor's encoding shows as mojibake; encode back to the original bytes and
249
+ decode with the configured editor encoding. Display only (LSP-01f, host finding H-04).
250
+ Text with a character above U+00FF cannot be compiler text and is left unchanged."""
251
+ try:
252
+ return message.encode("latin-1").decode(encoding, "replace")
253
+ except UnicodeEncodeError, LookupError:
254
+ return message
255
+
256
+
257
+ def _kill(pool: ProcessPoolExecutor) -> None:
258
+ for proc in list((getattr(pool, "_processes", None) or {}).values()):
259
+ try:
260
+ proc.kill()
261
+ except Exception:
262
+ pass
263
+ pool.shutdown(wait=False, cancel_futures=True)
264
+
265
+
266
+ class Session:
267
+ def __init__(self, publish: Publish, config: Config | None = None, workers: int = 2):
268
+ self.publish = publish
269
+ self.config = config or Config()
270
+ self.workers = workers
271
+ self.position_encoding = "utf-16" # negotiated by the server adapter
272
+ self.folders: list[Path] = []
273
+ self.docs: dict[Path, Doc] = {}
274
+ self.entries: dict[Path, Entry] = {}
275
+ self.discovered: set[Path] = set()
276
+ self._last: dict[str, tuple] = {}
277
+ self._last_uris: dict[Path, str] = {}
278
+ self._uris: dict[Path, str] = {} # the client's URI of every path it opened
279
+ self.folder_uris: dict[Path, str] = {} # the client's URI of each workspace folder
280
+ self._pool: ProcessPoolExecutor | None = None
281
+ self._pool_gen = 0
282
+ self._sem: asyncio.Semaphore | None = None
283
+ self._solo: asyncio.Lock | None = None
284
+ self._closed = False
285
+ self._ready = False # first configure() has finished
286
+ self._configured = asyncio.Event()
287
+ self.on_analysed: Callable[[], None] | None = None # a new program is available
288
+ self._event: asyncio.Event | None = None
289
+ self._help: dict[str, _LockedHelp] = {}
290
+ self._background: set[asyncio.Task] = set()
291
+ self._running = 0 # entry tasks alive (<= config.max_queued_jobs)
292
+ self._deferred: dict[Path, Entry] = {} # entries waiting for a task slot
293
+ self._discover_lock: asyncio.Lock | None = None
294
+ self._use = itertools.count(1)
295
+ self._tokens = itertools.count(1)
296
+ self._noticed: set[str] = set()
297
+ self.on_message: Callable[[str], None] | None = None # server message (limits)
298
+
299
+ # ---- logging -------------------------------------------------------------------------
300
+
301
+ @staticmethod
302
+ def log(message: str) -> None:
303
+ print(f"opencrbasic-lsp: {message}", file=sys.stderr, flush=True)
304
+
305
+ def notice(self, key: str, message: str) -> None:
306
+ """A bound was hit: logged and sent to the client once per `key` until `reset_notice`."""
307
+ if key in self._noticed:
308
+ return
309
+ self._noticed.add(key)
310
+ self.log(message)
311
+ if self.on_message is not None:
312
+ self.on_message(message)
313
+
314
+ # ---- configuration and entries -------------------------------------------------------
315
+
316
+ def _encode(self, text: str) -> tuple[positions.EditorBytes, bool]:
317
+ """The compiler bytes of `text`, which keep `text` for the position mapping."""
318
+ enc = self.config.encoding
319
+ try:
320
+ return positions.EditorBytes(text.encode(enc), text), False
321
+ except UnicodeEncodeError:
322
+ return positions.EditorBytes(text.encode(enc, errors="replace"), text), True
323
+
324
+ async def configure(self, config: Config, folders: list[Path] | None = None) -> None:
325
+ old = self.config
326
+ self.config = config
327
+ if folders is not None:
328
+ self.folders = folders
329
+ touched: set[Path] = set()
330
+ if old.encoding != config.encoding:
331
+ for path, doc in self.docs.items():
332
+ doc.data, doc.replaced = self._encode(doc.text)
333
+ touched.add(path)
334
+ await self._rediscover()
335
+ self._ready = True
336
+ self._configured.set()
337
+ self._sync_entries(force=old.analysis_key() != config.analysis_key())
338
+ if touched:
339
+ self._publish(touched)
340
+ self._warm_help()
341
+
342
+ async def _rediscover(self) -> None:
343
+ if self._discover_lock is None:
344
+ self._discover_lock = asyncio.Lock()
345
+ async with self._discover_lock: # the newest folders/config win, in order
346
+ self.discovered = set(
347
+ await asyncio.to_thread(discover, list(self.folders), self.config.entries, self.config.exclude)
348
+ )
349
+
350
+ async def set_folders(self, added: list[Path], removed: list[Path]) -> None:
351
+ """`workspace/didChangeWorkspaceFolders`: discovery runs again over the new folder set;
352
+ entries of a removed folder drop out (their diagnostics are cleared) unless open."""
353
+ gone = {p.absolute() for p in removed}
354
+ folders = [f for f in self.folders if f not in gone]
355
+ folders += [a.absolute() for a in added if a.absolute() not in folders]
356
+ if folders == self.folders:
357
+ return
358
+ self.folders = folders
359
+ await self._rediscover()
360
+ self._sync_entries()
361
+
362
+ async def files_changed(self, events: list[tuple[Path, str]]) -> None:
363
+ """`workspace/didChangeWatchedFiles` for files that are not open (an open document's text
364
+ wins over the disk, so its disk changes are ignored until it is closed). A created or
365
+ deleted file may be an entry, so discovery runs again; every entry whose last analysis
366
+ read the file, or listed its directory, is re-analysed (that is how a created include
367
+ resolves a missing one, and a deleted one is reported missing). An event for a path that
368
+ is not a program or library file is a directory: everything read below it is affected."""
369
+ if self._closed:
370
+ return
371
+ known = self.config.entry_suffixes() | {".crb"}
372
+ rediscover = False
373
+ files: list[Path] = []
374
+ trees: list[Path] = []
375
+ for path, kind in events:
376
+ path = path.absolute()
377
+ if path.name.lower() == checkconfig.FILE_NAME: # the entries that probed it are re-analysed
378
+ if path not in self.docs:
379
+ files.append(path)
380
+ elif path.suffix.lower() in known:
381
+ if kind != CHANGED:
382
+ rediscover = rediscover or path.suffix.lower() != ".crb"
383
+ if path not in self.docs:
384
+ files.append(path)
385
+ elif kind == CREATED or (kind == DELETED and self._known_below(path)):
386
+ rediscover = True
387
+ trees.append(path)
388
+ if rediscover:
389
+ await self._rediscover()
390
+ self._sync_entries()
391
+ delay = self.config.debounce_ms / 1000
392
+ for path in dict.fromkeys(files):
393
+ shortcut = await asyncio.to_thread(_digest, path)
394
+ self._changed(path, delay, shortcut=shortcut)
395
+ for path in dict.fromkeys(trees):
396
+ self._changed_tree(path, delay)
397
+
398
+ def _known_below(self, path: Path) -> bool:
399
+ """Whether `path` is, or contains, something the session knows: a discovered or open
400
+ file, or a file or directory an entry read or listed. (A deleted file of another type, or a
401
+ folder with no program in it, changes nothing.)"""
402
+ key = _key(path)
403
+ below = key.rstrip(os.sep) + os.sep
404
+ names = [_key(p) for p in (*self.discovered, *self.docs)]
405
+ for entry in self.entries.values():
406
+ names += [*entry.reads, *entry.dirs, _key(entry.path)]
407
+ return any(n == key or n.startswith(below) for n in names)
408
+
409
+ def _config_root(self, path: Path) -> str | None:
410
+ """The workspace folder holding `path`: the check configuration is searched no higher,
411
+ which is the part the `**/opencrbasic.toml` watcher covers."""
412
+ return next((str(f) for f in self.folders if f == path or f in path.parents), None)
413
+
414
+ def _wanted_entries(self) -> set[Path]:
415
+ suffixes = self.config.entry_suffixes()
416
+ return set(self.discovered) | {p for p in self.docs if p.suffix.lower() in suffixes}
417
+
418
+ def _sync_entries(self, force: bool = False) -> None:
419
+ wanted = self._wanted_entries()
420
+ touched: set[Path] = set()
421
+ for path in [p for p in self.entries if p not in wanted]:
422
+ entry = self.entries.pop(path)
423
+ entry.removed = True
424
+ self._deferred.pop(path, None)
425
+ if entry.task:
426
+ entry.task.cancel()
427
+ touched |= set(entry.contrib) | {path}
428
+ self._notify()
429
+ for path in sorted(wanted):
430
+ entry = self.entries.get(path)
431
+ if entry is None:
432
+ entry = self.entries[path] = Entry(path)
433
+ self._request(entry, 0.0)
434
+ elif force or (entry.root_used is not None and entry.root_used != (self._config_root(path),)):
435
+ self._request(entry, 0.0) # (folders arrived or changed: another config root)
436
+ self._publish(touched | self._open_non_entry_paths())
437
+
438
+ # ---- document events -----------------------------------------------------------------
439
+
440
+ def did_open(self, path: Path, uri: str, version: int, text: str) -> None:
441
+ doc = Doc(uri, version, text)
442
+ doc.data, doc.replaced = self._encode(text)
443
+ self.docs[path] = doc
444
+ self._uris[path] = uri
445
+ self._sync_entries()
446
+ self._changed(path, 0.0, shortcut=hashlib.sha256(doc.data).hexdigest())
447
+ self._publish({path})
448
+
449
+ def did_change(self, path: Path, version: int, text: str) -> None:
450
+ doc = self.docs.get(path)
451
+ if doc is None:
452
+ return
453
+ replaced_before = doc.replaced
454
+ doc.version, doc.text = version, text
455
+ doc.data, doc.replaced = self._encode(text)
456
+ self._changed(path, self.config.debounce_ms / 1000)
457
+ if doc.replaced != replaced_before:
458
+ self._publish({path})
459
+
460
+ def did_save(self, path: Path) -> None:
461
+ if path in self.docs:
462
+ self._changed(path, 0.0)
463
+
464
+ def did_close(self, path: Path) -> None:
465
+ if self.docs.pop(path, None) is None:
466
+ return
467
+ try:
468
+ data = path.read_bytes()
469
+ shortcut = hashlib.sha256(data).hexdigest()
470
+ except OSError:
471
+ shortcut = None
472
+ self._sync_entries()
473
+ self._changed(path, 0.0, shortcut=shortcut)
474
+ self._publish({path})
475
+
476
+ def _affected(self, path: Path) -> list[Entry]:
477
+ key, parent = _key(path), _key(path.parent)
478
+ return [
479
+ e for e in self.entries.values() if not e.settled or e.path == path or key in e.reads or parent in e.dirs
480
+ ]
481
+
482
+ def _changed(self, path: Path, delay: float, shortcut: str | None = None) -> None:
483
+ """Re-analyse the entries `path` affects. With `shortcut` (the sha256 of the text the
484
+ entries should now read), an entry whose last analysis read exactly that is not re-run;
485
+ only its captured overlay version is updated."""
486
+ doc = self.docs.get(path)
487
+ for entry in self._affected(path):
488
+ if entry.settled and shortcut is not None and not entry.error and entry.reads.get(_key(path)) == shortcut:
489
+ for versions in (
490
+ entry.versions,
491
+ entry.analysed.versions if entry.analysed is not None and entry.analysed_current else {},
492
+ ):
493
+ if doc is None:
494
+ versions.pop(path, None)
495
+ else:
496
+ versions[path] = doc.version
497
+ self._notify()
498
+ continue
499
+ self._request(entry, delay, path)
500
+
501
+ def _changed_tree(self, path: Path, delay: float) -> None:
502
+ """A directory appeared or disappeared: re-analyse the entries that read or listed it or
503
+ anything below it, and the entries that have not completed a first analysis."""
504
+ key = _key(path)
505
+ below = key.rstrip(os.sep) + os.sep
506
+ for entry in list(self.entries.values()):
507
+ names = [*entry.reads, *entry.dirs, _key(entry.path)]
508
+ if not entry.settled or any(n == key or n.startswith(below) for n in names):
509
+ self._request(entry, delay)
510
+
511
+ # ---- scheduling ----------------------------------------------------------------------
512
+
513
+ def _request(self, entry: Entry, delay: float, path: Path | None = None) -> None:
514
+ """Re-analyse `entry`. `path`: the file whose change asks for it (see `_unaffected`)."""
515
+ loop = asyncio.get_running_loop()
516
+ entry.requested += 1
517
+ if path is None:
518
+ entry.triggers = None
519
+ elif entry.triggers is not None:
520
+ entry.triggers.add(path)
521
+ entry.not_before = loop.time() + delay
522
+ if entry.task is None or entry.task.done():
523
+ self._start(entry)
524
+
525
+ def _start(self, entry: Entry) -> None:
526
+ """Give `entry` a task, or hold it back while `maxQueuedJobs` entry tasks are alive."""
527
+ if self._running >= self.config.max_queued_jobs:
528
+ self._deferred[entry.path] = entry
529
+ if len(self._deferred) == 1:
530
+ self.notice(
531
+ "queue",
532
+ f"{self._running} analyses are queued (maxQueuedJobs="
533
+ f"{self.config.max_queued_jobs}); further entry programs wait for a "
534
+ "free slot and are analysed in turn.",
535
+ )
536
+ return
537
+ self._running += 1
538
+ entry.task = asyncio.get_running_loop().create_task(self._entry_loop(entry))
539
+ entry.task.add_done_callback(self._task_done)
540
+
541
+ def _task_done(self, _task: asyncio.Task) -> None:
542
+ self._running -= 1
543
+ while self._deferred and self._running < self.config.max_queued_jobs and not self._closed:
544
+ first = next((p for p in self._deferred if p in self.docs), next(iter(self._deferred)))
545
+ entry = self._deferred.pop(first)
546
+ if not entry.removed and entry.done != entry.requested:
547
+ self._start(entry)
548
+ if not self._deferred:
549
+ self._noticed.discard("queue")
550
+
551
+ def _snapshot(self) -> Snapshot:
552
+ return Snapshot(
553
+ tuple((str(p), d.data) for p, d in self.docs.items()),
554
+ {p: d.version for p, d in self.docs.items()},
555
+ self.config.logger,
556
+ self.config.analysis_timeout_ms / 1000,
557
+ self.config.max_file_bytes,
558
+ checks=self.config.checks,
559
+ notices=self.config.coverage_notices,
560
+ )
561
+
562
+ async def _entry_loop(self, entry: Entry) -> None:
563
+ loop = asyncio.get_running_loop()
564
+ try:
565
+ while not entry.removed and entry.done != entry.requested:
566
+ while (wait := entry.not_before - loop.time()) > 0:
567
+ await asyncio.sleep(wait)
568
+ if entry.removed:
569
+ break
570
+ target = entry.requested
571
+ entry.triggers = set()
572
+ snap = self._snapshot()
573
+ outcome = await self._guarded_job(entry, snap)
574
+ if not entry.removed and entry.requested != target and self._unaffected(entry, outcome):
575
+ target = entry.requested # the newer requests cannot change it
576
+ entry.done = target
577
+ if entry.removed or entry.requested != target:
578
+ continue # superseded: discard
579
+ self._apply(entry, snap, outcome)
580
+ except asyncio.CancelledError:
581
+ raise
582
+ except Exception:
583
+ self.log("internal error in the scheduler:\n" + traceback.format_exc())
584
+ entry.done = entry.requested
585
+ self._apply(
586
+ entry,
587
+ self._snapshot(),
588
+ ServerError("Internal error in the language server scheduler; see the server log."),
589
+ )
590
+
591
+ @staticmethod
592
+ def _unaffected(entry: Entry, outcome: JobResult | ServerError) -> bool:
593
+ """Whether the requests made while `outcome` was computed are all for files it neither
594
+ read (or missed) nor could find through a directory listing, the entry itself excluded:
595
+ the result then still stands, and `_apply` checks the open versions it read."""
596
+ if entry.triggers is None or not isinstance(outcome, JobResult):
597
+ return False
598
+ reads = {_key(Path(p)) for p, _ in outcome.reads}
599
+ dirs = {_key(Path(d)) for d in outcome.listings}
600
+ return all(p != entry.path and _key(p) not in reads and _key(p.parent) not in dirs for p in entry.triggers)
601
+
602
+ async def _guarded_job(self, entry: Entry, snap: Snapshot) -> JobResult | ServerError:
603
+ """`_run_job` with the `maxFileBytes` bound: the entry file is checked before the job, and
604
+ the worker refuses (without reading) every file of this analysis that is over the limit, so
605
+ the bound applies to what the current text includes, not to what an earlier one did."""
606
+ limit = self.config.max_file_bytes
607
+ sizes = {Path(p): len(data) for p, data in snap.overlays}
608
+ over = await asyncio.to_thread(_oversize, entry.path, sizes, limit)
609
+ result = None
610
+ if over is None:
611
+ outcome = await self._run_job(entry, snap)
612
+ assert not isinstance(outcome, OutputResult)
613
+ if isinstance(outcome, ServerError) or not outcome.oversized:
614
+ return outcome
615
+ over, result = (Path(outcome.oversized[0][0]), outcome.oversized[0][1]), outcome
616
+ path, size = over
617
+ self.notice(
618
+ f"size:{path}",
619
+ f"{path.name} is {size} bytes, above the maxFileBytes limit "
620
+ f"of {limit}; the entry programs that read it are not analysed.",
621
+ )
622
+ return ServerError(
623
+ f"Analysis of {entry.path.name} skipped: {path.name} is {size} bytes, "
624
+ f"above the maxFileBytes limit of {limit}.",
625
+ limit=True,
626
+ result=result,
627
+ reason="too_large",
628
+ )
629
+
630
+ async def compiler_output(self, entry: Entry) -> OutputResult | ServerError:
631
+ """The `crbc` text for `entry` over the open documents, computed in the worker pool with
632
+ the analysis bounds: the `maxFileBytes` limit applies to the entry (checked first) and to
633
+ every file this compilation reads, and `analysisTimeoutMs` kills the worker as for an
634
+ analysis (docs/lsp-api.md "Inlay hints and commands (LSP-09c)")."""
635
+ snap = replace(self._snapshot(), kind="compiler_output")
636
+ limit = self.config.max_file_bytes
637
+ sizes = {Path(p): len(data) for p, data in snap.overlays}
638
+ over = await asyncio.to_thread(_oversize, entry.path, sizes, limit)
639
+ if over is None:
640
+ outcome = await self._run_job(entry, snap)
641
+ assert not isinstance(outcome, JobResult)
642
+ if isinstance(outcome, ServerError) or not outcome.oversized:
643
+ return outcome
644
+ over = Path(outcome.oversized[0][0]), outcome.oversized[0][1]
645
+ path, size = over
646
+ return ServerError(
647
+ f"{path.name} is {size} bytes, above the maxFileBytes limit of {limit}.", limit=True, reason="too_large"
648
+ )
649
+
650
+ def _semaphore(self) -> asyncio.Semaphore:
651
+ if self._sem is None:
652
+ self._sem = asyncio.Semaphore(self.workers)
653
+ return self._sem
654
+
655
+ def _get_pool(self) -> tuple[ProcessPoolExecutor, int]:
656
+ if self._pool is None:
657
+ self._pool = ProcessPoolExecutor(max_workers=self.workers, mp_context=multiprocessing.get_context("spawn"))
658
+ return self._pool, self._pool_gen
659
+
660
+ def _reset_pool(self, gen: int) -> None:
661
+ if self._pool is not None and self._pool_gen == gen:
662
+ pool, self._pool = self._pool, None
663
+ self._pool_gen += 1
664
+ _kill(pool)
665
+
666
+ async def _run_job(self, entry: Entry, snap: Snapshot) -> JobResult | OutputResult | ServerError:
667
+ """One worker job (`snap.kind`) in the pool. When the pool breaks (a crash, or a kill after
668
+ another job's timeout) the pool is recreated and the job is retried once with the pool to
669
+ itself, so a crash is only ever blamed on the job that was running alone."""
670
+ async with self._semaphore():
671
+ outcome = await self._attempt(entry, snap)
672
+ if not isinstance(outcome, _Broken):
673
+ return outcome
674
+ async with self._solo_lock():
675
+ slots = self._semaphore()
676
+ held = 0
677
+ try:
678
+ while held < self.workers: # every permit taken is released, even on cancel
679
+ await slots.acquire()
680
+ held += 1
681
+ outcome = await self._attempt(entry, snap)
682
+ finally:
683
+ for _ in range(held):
684
+ slots.release()
685
+ if isinstance(outcome, _Broken):
686
+ doing = "analysing" if snap.kind == "analysis" else "compiling"
687
+ return ServerError(
688
+ f"The analysis worker process terminated unexpectedly while "
689
+ f"{doing} {entry.path.name}; the worker pool was restarted."
690
+ )
691
+ return outcome
692
+
693
+ def _solo_lock(self) -> asyncio.Lock:
694
+ if self._solo is None:
695
+ self._solo = asyncio.Lock()
696
+ return self._solo
697
+
698
+ async def _attempt(self, entry: Entry, snap: Snapshot) -> JobResult | OutputResult | ServerError | _Broken:
699
+ if self._closed:
700
+ return ServerError("server is shutting down")
701
+ loop = asyncio.get_running_loop()
702
+ label = "Analysis" if snap.kind == "analysis" else "Compilation"
703
+ pool, gen = self._get_pool()
704
+ try:
705
+ if snap.kind == "analysis":
706
+ root = self._config_root(entry.path)
707
+ entry.root_used = (root,)
708
+ future = loop.run_in_executor(
709
+ pool,
710
+ run_analysis,
711
+ str(entry.path),
712
+ snap.logger,
713
+ snap.overlays,
714
+ snap.max_file_bytes,
715
+ snap.checks,
716
+ snap.notices,
717
+ root,
718
+ )
719
+ else:
720
+ future = loop.run_in_executor(
721
+ pool, run_compiler_output, str(entry.path), snap.logger, snap.overlays, snap.max_file_bytes
722
+ )
723
+ return await asyncio.wait_for(future, snap.timeout)
724
+ except TimeoutError:
725
+ self._reset_pool(gen)
726
+ return ServerError(
727
+ f"{label} of {entry.path.name} timed out after "
728
+ f"{round(snap.timeout * 1000)} ms; the analysis worker was "
729
+ "restarted.",
730
+ reason="timeout",
731
+ )
732
+ except BrokenProcessPool:
733
+ self._reset_pool(gen)
734
+ return _BROKEN
735
+ except asyncio.CancelledError:
736
+ raise
737
+ except Exception as exc:
738
+ return ServerError(f"{label} of {entry.path.name} failed in the worker: {type(exc).__name__}: {exc}")
739
+
740
+ # ---- applying results ----------------------------------------------------------------
741
+
742
+ def _apply(self, entry: Entry, snap: Snapshot, outcome: JobResult | ServerError) -> None:
743
+ old_paths = set(entry.contrib)
744
+ installed = False
745
+ if isinstance(outcome, ServerError):
746
+ entry.error = str(outcome)
747
+ entry.error_limit = outcome.limit
748
+ entry.contrib = {}
749
+ entry.analysed_current = False
750
+ if outcome.result is not None: # keep watching what it read
751
+ self._record_reads(entry, outcome.result)
752
+ else:
753
+ reads = {_key(Path(p)): h for p, h in outcome.reads}
754
+ stale = any(
755
+ (self.docs[p].version if p in self.docs else None) != snap.versions.get(p)
756
+ for p in set(self.docs) | set(snap.versions)
757
+ if _key(p) in reads or p == entry.path
758
+ )
759
+ if stale:
760
+ self._request(entry, 0.0)
761
+ return
762
+ entry.error = None
763
+ entry.error_limit = False
764
+ self._record_reads(entry, outcome)
765
+ entry.contrib = self._contributions(entry, outcome)
766
+ program = outcome.program
767
+ entry.analysed_current = program is not None
768
+ if program is not None:
769
+ entry.analysed = Analysed(entry.path, snap.logger, snap.overlays, snap.versions, program, outcome)
770
+ entry.analysed.token = next(self._tokens) # names this result in quick-fix `data`
771
+ entry.used = next(self._use)
772
+ entry.evicted = False
773
+ installed = True
774
+ self._enforce_cache(entry)
775
+ entry.versions = dict(snap.versions)
776
+ entry.settled = True
777
+ self._publish(old_paths | set(entry.contrib) | {entry.path} | self._open_non_entry_paths())
778
+ self._notify()
779
+ if installed and self.on_analysed is not None:
780
+ self.on_analysed()
781
+
782
+ @staticmethod
783
+ def _record_reads(entry: Entry, result: JobResult) -> None:
784
+ entry.reads = {_key(Path(p)): h for p, h in result.reads}
785
+ entry.dirs = frozenset(_key(Path(d)) for d in result.listings)
786
+
787
+ def _enforce_cache(self, keep: Entry) -> None:
788
+ """Programs kept for the features stay within `maxCacheMb`: the least recently used
789
+ program of another entry is dropped (its diagnostics stay) and rebuilt by a new analysis
790
+ when a feature request needs it."""
791
+ budget = self.config.max_cache_mb * MIB
792
+ held = [e for e in self.entries.values() if e.analysed is not None]
793
+ sizes = {id(e): e.analysed.size for e in held if e.analysed is not None}
794
+ total = sum(sizes.values())
795
+ for victim in sorted((e for e in held if e is not keep), key=lambda e: e.used):
796
+ if total <= budget:
797
+ break
798
+ total -= sizes[id(victim)]
799
+ victim.analysed, victim.analysed_current, victim.evicted = None, False, True
800
+ self.notice(
801
+ "cache",
802
+ f"the parsed programs exceed maxCacheMb={self.config.max_cache_mb}"
803
+ "; the least recently used ones are dropped and rebuilt when a language "
804
+ "feature needs them.",
805
+ )
806
+
807
+ @staticmethod
808
+ def _contributions(entry: Entry, result: JobResult) -> dict[Path, list[Raw]]:
809
+ out: dict[Path, list[Raw]] = {}
810
+ failures = "\n".join(result.failures)
811
+ attached = False
812
+ for index, (severity, message, line, path, file, span) in enumerate(result.diagnostics):
813
+ target = Path(path) if path else entry.path
814
+ if not path:
815
+ message = f"{file}:{line}: {message}" if file else message
816
+ line, span = 0, None
817
+ internal = message in MARKERS
818
+ if internal and failures and not attached:
819
+ message, attached = f"{message}\n{failures}", True
820
+ out.setdefault(target, []).append(Raw(severity, message, line, internal, span, index=index))
821
+ for channel, code, severity, message, line, path, file, span, link in result.findings:
822
+ target = Path(path) if path else entry.path
823
+ if not path:
824
+ message = f"{file}:{line}: {message}" if file else message
825
+ line, span = 0, None
826
+ out.setdefault(target, []).append(
827
+ Raw(
828
+ _SEVERITIES.get(severity, severity),
829
+ message,
830
+ line,
831
+ span=span,
832
+ source=_CHANNELS[channel],
833
+ code=code,
834
+ link=link,
835
+ )
836
+ )
837
+ if result.config_error:
838
+ out.setdefault(entry.path, []).append(
839
+ Raw(
840
+ "warning",
841
+ f"Check configuration ignored, no checks ran: {result.config_error}",
842
+ 0,
843
+ source=SRC_SERVER,
844
+ )
845
+ )
846
+ if failures and not attached:
847
+ out.setdefault(entry.path, []).append(Raw("error", f"Internal error in the compiler:\n{failures}", 0, True))
848
+ return out
849
+
850
+ # ---- publishing ----------------------------------------------------------------------
851
+
852
+ def _open_non_entry_paths(self) -> set[Path]:
853
+ return {p for p in self.docs if p not in self.entries}
854
+
855
+ def _text_of(self, path: Path) -> str:
856
+ doc = self.docs.get(path)
857
+ if doc is not None:
858
+ return doc.text
859
+ try:
860
+ return path.read_bytes().decode(self.config.encoding, errors="replace")
861
+ except OSError, LookupError:
862
+ return ""
863
+
864
+ def display(self, message: str) -> str:
865
+ return display_text(message, self.config.encoding)
866
+
867
+ def _compose(self, path: Path) -> list[Diag]:
868
+ text = self._text_of(path)
869
+ lines = _LINE_END.split(text)
870
+ doc = self.docs.get(path)
871
+ current = doc.version if doc else None
872
+
873
+ last_idx = len(lines) - 1
874
+
875
+ def make(
876
+ line: int,
877
+ severity: str,
878
+ message: str,
879
+ source: str,
880
+ span: tuple[int, int] | None = None,
881
+ raw: Raw | None = None,
882
+ entry: Path | None = None,
883
+ token: int | None = None,
884
+ ) -> Diag:
885
+ if span is not None:
886
+ first = min(max(span[0] - 1, 0), last_idx)
887
+ last = min(max(span[1] - 1, first), last_idx)
888
+ else:
889
+ first = last = min(max(line - 1, 0), last_idx) if line > 0 else 0
890
+ data = (
891
+ ()
892
+ if raw is None or raw.index is None or token is None
893
+ else (("entry", str(entry)), ("result", token), ("index", raw.index))
894
+ )
895
+ return Diag(
896
+ first,
897
+ last,
898
+ positions.units(lines[last], self.position_encoding),
899
+ severity,
900
+ message,
901
+ source,
902
+ raw.code if raw else None,
903
+ raw.link if raw else None,
904
+ data,
905
+ )
906
+
907
+ contributors = [e for e in self.entries.values() if e.contrib.get(path) and e.versions.get(path) == current]
908
+ prefix = len(contributors) > 1
909
+ out: list[Diag] = []
910
+ for e in contributors:
911
+ for raw in e.contrib[path]:
912
+ text = raw.message if raw.internal else self.display(raw.message)
913
+ message = f"[{e.path.name}] {text}" if prefix else text
914
+ token = e.analysed.token if e.analysed is not None and e.analysed_current else None
915
+ out.append(
916
+ make(
917
+ raw.line,
918
+ raw.severity,
919
+ message,
920
+ SRC_INTERNAL if raw.internal else raw.source,
921
+ raw.span,
922
+ raw,
923
+ e.path,
924
+ token,
925
+ )
926
+ )
927
+ for e in self.entries.values():
928
+ if e.error and e.path == path:
929
+ out.append(
930
+ make(0, "warning", e.error, SRC_SERVER)
931
+ if e.error_limit
932
+ else make(0, "error", e.error, SRC_INTERNAL)
933
+ )
934
+ if doc is not None and doc.replaced:
935
+ out.append(
936
+ make(
937
+ 0,
938
+ "warning",
939
+ f"Some characters in this file are not representable in the "
940
+ f"configured encoding {self.config.encoding!r} and were replaced "
941
+ "in the text sent to the compiler.",
942
+ SRC_SERVER,
943
+ )
944
+ )
945
+ if (
946
+ doc is not None
947
+ and path not in self.entries
948
+ and self._ready
949
+ and path.name.lower() != checkconfig.FILE_NAME
950
+ and all(e.settled for e in self.entries.values())
951
+ and not any(_key(path) in e.reads for e in self.entries.values())
952
+ ):
953
+ out.append(
954
+ make(
955
+ 0, "information", INFO_UNINCLUDED if path.suffix.lower() == ".crb" else INFO_NOT_ENTRY, SRC_COMPILE
956
+ )
957
+ )
958
+ return out
959
+
960
+ def uri(self, path: Path) -> str:
961
+ if path in self._uris:
962
+ return self._uris[path]
963
+ for parent in path.parents:
964
+ if parent in self.folder_uris:
965
+ relative = quote(path.relative_to(parent).as_posix(), safe="/")
966
+ return self.folder_uris[parent].rstrip("/") + "/" + relative
967
+ return path.as_uri()
968
+
969
+ def _publish(self, paths: set[Path]) -> None:
970
+ if self._closed:
971
+ return
972
+ for path in sorted(paths):
973
+ diags = self._compose(path)
974
+ doc = self.docs.get(path)
975
+ uri = doc.uri if doc else self.uri(path)
976
+ old_uri = self._last_uris.get(path)
977
+ if old_uri != uri and old_uri in self._last:
978
+ self.publish(old_uri, None, [])
979
+ self._last.pop(old_uri, None)
980
+ self._last_uris[path] = uri
981
+ version = doc.version if doc else None
982
+ key = (version, tuple(diags))
983
+ last = self._last.get(uri)
984
+ if key == last or (last is None and not diags and doc is None):
985
+ continue
986
+ if diags or doc is not None:
987
+ self._last[uri] = key
988
+ else:
989
+ self._last.pop(uri, None)
990
+ self.publish(uri, version, diags)
991
+
992
+ # ---- language features ---------------------------------------------------------------
993
+
994
+ def _notify(self) -> None:
995
+ event, self._event = self._event, None
996
+ if event is not None:
997
+ event.set()
998
+
999
+ async def configured(self, timeout: float = 15.0) -> None:
1000
+ """Returns once the first configuration is applied (bounded: a client that never answers
1001
+ the configuration pull must not hang requests)."""
1002
+ try:
1003
+ await asyncio.wait_for(self._configured.wait(), timeout)
1004
+ except TimeoutError:
1005
+ pass
1006
+
1007
+ def blobs(self) -> dict[Path, bytes]:
1008
+ """Bytes of every open document (what the compiler would read), by path."""
1009
+ return {p: d.data for p, d in self.docs.items()}
1010
+
1011
+ def _pick(self, path: Path) -> Analysed | None:
1012
+ """The program features use for `path`: its own entry's, else the first entry (sorted by
1013
+ path) whose last analysis read the file."""
1014
+ if path in self.entries:
1015
+ candidates = [self.entries[path]]
1016
+ else:
1017
+ key = _key(path)
1018
+ candidates = [e for _, e in sorted(self.entries.items()) if e.reads.get(key)]
1019
+ entry = next((e for e in candidates if e.analysed is not None), None)
1020
+ if entry is None:
1021
+ for e in candidates: # dropped by the cache budget: rebuild
1022
+ if e.evicted:
1023
+ e.evicted = False
1024
+ self._request(e, 0.0)
1025
+ return None
1026
+ entry.used = next(self._use)
1027
+ return entry.analysed
1028
+
1029
+ def _fresh(self, path: Path, ana: Analysed | None) -> bool:
1030
+ doc = self.docs.get(path)
1031
+ return ana is not None and ana.versions.get(path) == (doc.version if doc else None)
1032
+
1033
+ def fresh(self, path: Path, ana: Analysed | None) -> bool:
1034
+ """`ana` reflects the newest version of the open document `path` (a closed file: always)."""
1035
+ return self._fresh(path, ana)
1036
+
1037
+ def _pending(self) -> bool:
1038
+ return any(
1039
+ not e.settled or e.done != e.requested or (e.task is not None and not e.task.done())
1040
+ for e in self.entries.values()
1041
+ )
1042
+
1043
+ async def analysed_for(self, path: Path, wait: bool = True, first: bool = False) -> Analysed | None:
1044
+ """The program to answer a request about `path`. With `wait`, a program that does not
1045
+ reflect the document's newest version is waited for while an analysis is pending: up to
1046
+ `featureWaitMs` when an older program exists, up to the analysis timeout when none does.
1047
+ With `first` instead (completion), only a missing program is waited for, up to
1048
+ `featureWaitMs`; any program, stale or not, is returned at once. After that the older
1049
+ program (possibly stale) or None is returned; never an error."""
1050
+ loop = asyncio.get_running_loop()
1051
+ start = loop.time()
1052
+ while True:
1053
+ ana = self._pick(path)
1054
+ if (
1055
+ (not wait and not (first and ana is None))
1056
+ or self._fresh(path, ana)
1057
+ or not self._pending()
1058
+ or self._closed
1059
+ ):
1060
+ return ana
1061
+ limit = (
1062
+ self.config.feature_wait_ms / 1000
1063
+ if first
1064
+ else self.config.analysis_timeout_ms / 1000 + 2
1065
+ if ana is None
1066
+ else self.config.feature_wait_ms / 1000
1067
+ )
1068
+ left = start + limit - loop.time()
1069
+ if left <= 0:
1070
+ return ana
1071
+ if self._event is None:
1072
+ self._event = asyncio.Event()
1073
+ try:
1074
+ await asyncio.wait_for(self._event.wait(), left)
1075
+ except TimeoutError:
1076
+ return self._pick(path)
1077
+
1078
+ async def load(self, ana: Analysed | None):
1079
+ """The `AnalysisResult` (program only) of `ana`, unpickled once in a thread."""
1080
+ if ana is None:
1081
+ return None
1082
+ if ana.result is None and not ana.failed:
1083
+ if ana.lock is None:
1084
+ ana.lock = asyncio.Lock()
1085
+ async with ana.lock:
1086
+ if ana.result is None and not ana.failed:
1087
+ ana.result = await asyncio.to_thread(ana.build)
1088
+ return ana.result
1089
+
1090
+ # ---- edit features (rename, formatting, quick fixes) --------------------------------
1091
+
1092
+ @staticmethod
1093
+ def _entry_pending(entry: Entry) -> bool:
1094
+ return not entry.settled or entry.done != entry.requested or (entry.task is not None and not entry.task.done())
1095
+
1096
+ async def entries_reading(self, path: Path) -> list[Entry]:
1097
+ """The entries whose last analysis read `path`, sorted by path, once no entry that `path`
1098
+ can affect has an analysis pending (bounded by the analysis timeout plus 2 s; after that
1099
+ the entries as they are). An entry whose program the cache budget dropped is re-analysed."""
1100
+ loop = asyncio.get_running_loop()
1101
+ key = _key(path)
1102
+ deadline = loop.time() + self.config.analysis_timeout_ms / 1000 + 2
1103
+ while True:
1104
+ affected = self._affected(path)
1105
+ for entry in affected:
1106
+ if entry.analysed is None and entry.evicted and entry.reads.get(key):
1107
+ entry.evicted = False
1108
+ self._request(entry, 0.0)
1109
+ if self._closed or not any(self._entry_pending(e) for e in affected):
1110
+ break
1111
+ left = deadline - loop.time()
1112
+ if left <= 0:
1113
+ break
1114
+ if self._event is None:
1115
+ self._event = asyncio.Event()
1116
+ try:
1117
+ await asyncio.wait_for(self._event.wait(), min(left, 0.25))
1118
+ except TimeoutError:
1119
+ pass
1120
+ return [e for _, e in sorted(self.entries.items()) if e.reads.get(key)]
1121
+
1122
+ def unknown_entries(self) -> list[Entry]:
1123
+ """Entries that never completed an analysis (failed, timed out, still waiting): what they
1124
+ read is unknown, so an edit that must consider every dependent entry cannot be proven."""
1125
+ return [e for _, e in sorted(self.entries.items()) if not e.reads and (e.error or not e.settled)]
1126
+
1127
+ def current(self, entry: Entry) -> bool:
1128
+ """The entry's parsed program is the newest analysis and reflects the version of every
1129
+ open document the entry read."""
1130
+ ana = entry.analysed
1131
+ if ana is None or not entry.analysed_current or entry.error:
1132
+ return False
1133
+ return all(ana.versions.get(p) == d.version for p, d in self.docs.items() if _key(p) in entry.reads)
1134
+
1135
+ def current_readers(self, path: Path) -> list[Entry]:
1136
+ """Entries whose last analysis read `path` and whose program is current (`current`),
1137
+ sorted by path; no waiting (read-only features skip the others, LSP-R4a)."""
1138
+ key = _key(path)
1139
+ return [e for _, e in sorted(self.entries.items()) if e.reads.get(key) and self.current(e)]
1140
+
1141
+ def fresh_contributors(self, path: Path) -> list[Entry]:
1142
+ """Entries whose diagnostics for `path` are published now (same rule as `_compose`) and
1143
+ whose parsed program is the one that produced them."""
1144
+ doc = self.docs.get(path)
1145
+ current = doc.version if doc else None
1146
+ return [
1147
+ e
1148
+ for _, e in sorted(self.entries.items())
1149
+ if e.contrib.get(path)
1150
+ and e.versions.get(path) == current
1151
+ and e.analysed is not None
1152
+ and e.analysed_current
1153
+ and e.analysed.versions.get(path) == current
1154
+ ]
1155
+
1156
+ def help_provider(self) -> _LockedHelp | None:
1157
+ directory = self.config.help_directory
1158
+ if not directory:
1159
+ return None
1160
+ path = Path(directory).expanduser()
1161
+ if not path.is_absolute():
1162
+ path = (self.folders[0] if self.folders else Path.cwd()) / path
1163
+ key = str(path)
1164
+ if key not in self._help:
1165
+ self._help[key] = _LockedHelp(path)
1166
+ return self._help[key]
1167
+
1168
+ def _warm_help(self) -> None:
1169
+ provider = self.help_provider()
1170
+ if provider is None or provider.warming:
1171
+ return
1172
+ provider.warming = True
1173
+ task = asyncio.get_running_loop().create_task(asyncio.to_thread(provider.warm))
1174
+ self._background.add(task)
1175
+ task.add_done_callback(self._background.discard)
1176
+
1177
+ # ---- shutdown ------------------------------------------------------------------------
1178
+
1179
+ def close(self) -> None:
1180
+ self._closed = True
1181
+ self._deferred.clear()
1182
+ self._notify()
1183
+ for entry in self.entries.values():
1184
+ entry.removed = True
1185
+ if entry.task:
1186
+ entry.task.cancel()
1187
+ if self._pool is not None:
1188
+ pool, self._pool = self._pool, None
1189
+ _kill(pool)