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.
- opencrbasic/__init__.py +3 -0
- opencrbasic/__main__.py +5 -0
- opencrbasic/analysis.py +184 -0
- opencrbasic/bindings.py +1034 -0
- opencrbasic/catalog.py +173 -0
- opencrbasic/checkconfig.py +144 -0
- opencrbasic/checker/__init__.py +977 -0
- opencrbasic/checker/cr3xx.py +3118 -0
- opencrbasic/checker/ess.py +81 -0
- opencrbasic/checker/expressions.py +3581 -0
- opencrbasic/checker/instr_data.py +1802 -0
- opencrbasic/checker/instr_measure.py +1547 -0
- opencrbasic/checker/instr_rules.py +3540 -0
- opencrbasic/checker/instructions.py +17 -0
- opencrbasic/checker/intervals.py +678 -0
- opencrbasic/checker/longconv.py +133 -0
- opencrbasic/checker/scan_time.py +580 -0
- opencrbasic/checker/storage.py +456 -0
- opencrbasic/checker/structs.py +240 -0
- opencrbasic/checker/structure.py +6213 -0
- opencrbasic/checker/symbols.py +609 -0
- opencrbasic/checker/terminals.py +249 -0
- opencrbasic/checker/unused.py +186 -0
- opencrbasic/checker/var_types.py +564 -0
- opencrbasic/checker/warnings.py +661 -0
- opencrbasic/checks.py +491 -0
- opencrbasic/cli.py +391 -0
- opencrbasic/commands.py +128 -0
- opencrbasic/compiler.py +320 -0
- opencrbasic/consteval.py +327 -0
- opencrbasic/data/background_calibration.json +4 -0
- opencrbasic/data/before_beginprog.json +99 -0
- opencrbasic/data/cr1000x/background_calibration.json +4 -0
- opencrbasic/data/cr1000x/before_beginprog.json +138 -0
- opencrbasic/data/cr1000x/ess_variables.json +1087 -0
- opencrbasic/data/cr1000x/fnotch.json +29 -0
- opencrbasic/data/cr1000x/functions.json +1337 -0
- opencrbasic/data/cr1000x/instructions.json +4836 -0
- opencrbasic/data/cr1000x/measure.json +121 -0
- opencrbasic/data/cr1000x/mode.json +111 -0
- opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
- opencrbasic/data/cr1000x/settings_fields.json +321 -0
- opencrbasic/data/cr1000x/status_members.json +318 -0
- opencrbasic/data/cr1000x/symbols.json +872 -0
- opencrbasic/data/cr1000x/terminals.json +225 -0
- opencrbasic/data/cr300/before_beginprog.json +42 -0
- opencrbasic/data/cr300/ess_variables.json +1087 -0
- opencrbasic/data/cr300/fnotch.json +22 -0
- opencrbasic/data/cr300/functions.json +1261 -0
- opencrbasic/data/cr300/instructions.json +2837 -0
- opencrbasic/data/cr300/measure.json +33 -0
- opencrbasic/data/cr300/mode.json +121 -0
- opencrbasic/data/cr300/settings_fields.json +259 -0
- opencrbasic/data/cr300/status_members.json +303 -0
- opencrbasic/data/cr300/symbols.json +584 -0
- opencrbasic/data/cr300/terminals.json +193 -0
- opencrbasic/data/cr350/before_beginprog.json +44 -0
- opencrbasic/data/cr350/ess_variables.json +1087 -0
- opencrbasic/data/cr350/fnotch.json +22 -0
- opencrbasic/data/cr350/functions.json +1262 -0
- opencrbasic/data/cr350/instructions.json +2848 -0
- opencrbasic/data/cr350/measure.json +35 -0
- opencrbasic/data/cr350/mode.json +121 -0
- opencrbasic/data/cr350/settings_fields.json +292 -0
- opencrbasic/data/cr350/status_members.json +303 -0
- opencrbasic/data/cr350/symbols.json +572 -0
- opencrbasic/data/cr350/terminals.json +190 -0
- opencrbasic/data/ess_variables.json +1087 -0
- opencrbasic/data/fnotch.json +34 -0
- opencrbasic/data/functions.json +1337 -0
- opencrbasic/data/instructions.json +4965 -0
- opencrbasic/data/measure.json +123 -0
- opencrbasic/data/mode.json +121 -0
- opencrbasic/data/notices_cr6.json +890 -0
- opencrbasic/data/pipeline_tasks.json +84 -0
- opencrbasic/data/settings_fields.json +322 -0
- opencrbasic/data/status_members.json +319 -0
- opencrbasic/data/status_table.json +684 -0
- opencrbasic/data/symbols.json +902 -0
- opencrbasic/data/terminals.json +195 -0
- opencrbasic/data/type_codes.json +249 -0
- opencrbasic/diagnostics.py +196 -0
- opencrbasic/editing.py +827 -0
- opencrbasic/formatting.py +310 -0
- opencrbasic/helpdocs.py +677 -0
- opencrbasic/hover.py +667 -0
- opencrbasic/inlay.py +169 -0
- opencrbasic/lexer.py +183 -0
- opencrbasic/lsp/__init__.py +5 -0
- opencrbasic/lsp/__main__.py +7 -0
- opencrbasic/lsp/config.py +202 -0
- opencrbasic/lsp/features.py +602 -0
- opencrbasic/lsp/server.py +741 -0
- opencrbasic/lsp/session.py +1189 -0
- opencrbasic/lsp/worker.py +128 -0
- opencrbasic/mode.py +143 -0
- opencrbasic/navigation.py +401 -0
- opencrbasic/parser.py +3264 -0
- opencrbasic/positions.py +274 -0
- opencrbasic/preprocess.py +269 -0
- opencrbasic/profiles.py +266 -0
- opencrbasic/provenance.py +17 -0
- opencrbasic/quickfix.py +331 -0
- opencrbasic/rename.py +662 -0
- opencrbasic/signature.py +33 -0
- opencrbasic/source.py +600 -0
- opencrbasic/sources.py +125 -0
- opencrbasic/syntax.py +337 -0
- opencrbasic/tables.py +2173 -0
- opencrbasic/tables_hook.py +14 -0
- opencrbasic/tdf.py +142 -0
- opencrbasic/timing.py +565 -0
- opencrbasic-1.0.0.dist-info/METADATA +223 -0
- opencrbasic-1.0.0.dist-info/RECORD +117 -0
- opencrbasic-1.0.0.dist-info/WHEEL +4 -0
- opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
- 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)
|