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,741 @@
|
|
|
1
|
+
"""pygls adapter: lifecycle, document sync, configuration, diagnostics and language features.
|
|
2
|
+
|
|
3
|
+
No language rules: everything about CRBasic comes from `opencrbasic.analysis` through `Session`
|
|
4
|
+
and from the core feature modules through `features`. Capabilities advertised: full text document
|
|
5
|
+
sync (open/close/change/save), published diagnostics, completion (trigger `.`), hover, signature
|
|
6
|
+
help (triggers `(` `,`), document symbols, folding ranges, document links, definition,
|
|
7
|
+
references, document highlights, full semantic tokens, prepareRename/rename, document and range
|
|
8
|
+
formatting, quick-fix code actions, parameter-name inlay hints and the commands
|
|
9
|
+
`opencrbasic.showCompilerOutput` and `opencrbasic.showTableFields`. Each has a stdio transcript test.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import traceback
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, BinaryIO
|
|
23
|
+
|
|
24
|
+
EXPECTED_API_VERSION = 2 # the `analysis.API_VERSION` this server was written for
|
|
25
|
+
SECTION = "opencrbasic"
|
|
26
|
+
WATCH_ID = "opencrbasic-watch"
|
|
27
|
+
DELETE_PATTERN = "**/*" # VS Code reports a deleted folder only as its own URI
|
|
28
|
+
CHECKS_PATTERN = "**/opencrbasic.toml" # the check configuration (`checkconfig.FILE_NAME`)
|
|
29
|
+
CONFIG_PULL_TIMEOUT = 10.0
|
|
30
|
+
COMMAND_COMPILER_OUTPUT = "opencrbasic.showCompilerOutput"
|
|
31
|
+
COMMAND_TABLE_FIELDS = "opencrbasic.showTableFields"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _git_revision(src_dir: Path) -> str | None:
|
|
35
|
+
"""HEAD of the checkout `src_dir` (the imported package directory) belongs to, or None when
|
|
36
|
+
`src_dir` is not that checkout's `src/opencrbasic` or `opencrbasic/src/opencrbasic` (a wheel
|
|
37
|
+
installed inside some other repository)."""
|
|
38
|
+
try:
|
|
39
|
+
out = subprocess.run(
|
|
40
|
+
["git", "-C", str(src_dir), "rev-parse", "--show-toplevel", "--short", "HEAD"],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
timeout=5,
|
|
44
|
+
)
|
|
45
|
+
except OSError, subprocess.SubprocessError:
|
|
46
|
+
return None
|
|
47
|
+
lines = out.stdout.split()
|
|
48
|
+
if out.returncode != 0 or len(lines) != 2:
|
|
49
|
+
return None
|
|
50
|
+
top, rev = Path(lines[0]), lines[1]
|
|
51
|
+
try:
|
|
52
|
+
here = src_dir.resolve()
|
|
53
|
+
ours = any((top / sub).resolve() == here for sub in ("src/opencrbasic", "opencrbasic/src/opencrbasic"))
|
|
54
|
+
except OSError:
|
|
55
|
+
return None
|
|
56
|
+
return rev if ours else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def startup_line() -> str:
|
|
60
|
+
import opencrbasic
|
|
61
|
+
|
|
62
|
+
from .. import analysis
|
|
63
|
+
|
|
64
|
+
where = Path(opencrbasic.__file__).resolve()
|
|
65
|
+
rev = _git_revision(where.parent)
|
|
66
|
+
return f"opencrbasic-lsp: opencrbasic={where} api_version={analysis.API_VERSION} git={rev or 'unknown'}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_api_version() -> str | None:
|
|
70
|
+
"""None when the facade matches; else the refusal message."""
|
|
71
|
+
from .. import analysis
|
|
72
|
+
|
|
73
|
+
if analysis.API_VERSION != EXPECTED_API_VERSION:
|
|
74
|
+
return (
|
|
75
|
+
f"opencrbasic-lsp: refusing to start: opencrbasic.analysis.API_VERSION is "
|
|
76
|
+
f"{analysis.API_VERSION}, this server was written for {EXPECTED_API_VERSION}"
|
|
77
|
+
)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_server():
|
|
82
|
+
from lsprotocol import types
|
|
83
|
+
from pygls.exceptions import JsonRpcException
|
|
84
|
+
from pygls.lsp.server import LanguageServer
|
|
85
|
+
from pygls.uris import to_fs_path
|
|
86
|
+
|
|
87
|
+
from .. import __version__, commands, formatting
|
|
88
|
+
from ..sources import DiskSource, OverlaySource
|
|
89
|
+
from . import features
|
|
90
|
+
from .config import Config, parse_config, watch_pattern
|
|
91
|
+
from .session import CHANGED, CREATED, DELETED, Diag, ServerError, Session
|
|
92
|
+
|
|
93
|
+
server = LanguageServer(SECTION, __version__, text_document_sync_kind=types.TextDocumentSyncKind.Full)
|
|
94
|
+
severities = {
|
|
95
|
+
"error": types.DiagnosticSeverity.Error,
|
|
96
|
+
"warning": types.DiagnosticSeverity.Warning,
|
|
97
|
+
"information": types.DiagnosticSeverity.Information,
|
|
98
|
+
"hint": types.DiagnosticSeverity.Hint,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def publish(uri: str, version: int | None, diags: list[Diag]) -> None:
|
|
102
|
+
server.text_document_publish_diagnostics(
|
|
103
|
+
types.PublishDiagnosticsParams(
|
|
104
|
+
uri=uri,
|
|
105
|
+
version=version,
|
|
106
|
+
diagnostics=[
|
|
107
|
+
types.Diagnostic(
|
|
108
|
+
range=types.Range(types.Position(d.line, 0), types.Position(d.end_line, d.end)),
|
|
109
|
+
message=d.message,
|
|
110
|
+
severity=severities[d.severity],
|
|
111
|
+
source=d.source,
|
|
112
|
+
code=d.code,
|
|
113
|
+
code_description=types.CodeDescription(d.link) if d.link else None,
|
|
114
|
+
data=dict(d.data) if d.data else None,
|
|
115
|
+
)
|
|
116
|
+
for d in diags
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
session = Session(publish)
|
|
122
|
+
state: dict[str, Any] = {"init_options": None}
|
|
123
|
+
background: set[asyncio.Task] = set()
|
|
124
|
+
|
|
125
|
+
def send_message(message: str) -> None:
|
|
126
|
+
if not state.get("shutdown"):
|
|
127
|
+
server.window_log_message(
|
|
128
|
+
types.LogMessageParams(type=types.MessageType.Warning, message=f"opencrbasic-lsp: {message}")
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
session.on_message = send_message
|
|
132
|
+
|
|
133
|
+
def fs_path(uri: str) -> Path:
|
|
134
|
+
fs = to_fs_path(uri)
|
|
135
|
+
if fs is None: # not a `file:` URI (was a TypeError from Path(None))
|
|
136
|
+
raise ValueError(f"not a file URI: {uri!r}")
|
|
137
|
+
return Path(fs).absolute()
|
|
138
|
+
|
|
139
|
+
def folders_of(params: types.InitializeParams) -> list[Path]:
|
|
140
|
+
if params.workspace_folders:
|
|
141
|
+
session.folder_uris.update({fs_path(f.uri): f.uri for f in params.workspace_folders})
|
|
142
|
+
return [fs_path(f.uri) for f in params.workspace_folders]
|
|
143
|
+
if params.root_uri:
|
|
144
|
+
session.folder_uris[fs_path(params.root_uri)] = params.root_uri
|
|
145
|
+
return [fs_path(params.root_uri)]
|
|
146
|
+
if params.root_path:
|
|
147
|
+
return [Path(params.root_path).absolute()]
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
def supports_configuration() -> bool:
|
|
151
|
+
workspace = server.client_capabilities.workspace
|
|
152
|
+
return bool(workspace and workspace.configuration)
|
|
153
|
+
|
|
154
|
+
async def pull_settings() -> Any:
|
|
155
|
+
try:
|
|
156
|
+
result = await asyncio.wait_for(
|
|
157
|
+
server.workspace_configuration_async(
|
|
158
|
+
types.ConfigurationParams(items=[types.ConfigurationItem(section=SECTION)])
|
|
159
|
+
),
|
|
160
|
+
CONFIG_PULL_TIMEOUT,
|
|
161
|
+
)
|
|
162
|
+
return result[0] if result else None
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
session.log(f"workspace/configuration failed: {type(exc).__name__}: {exc}")
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
async def reload(settings: Any = None, pull: bool = False, first: bool = False) -> None:
|
|
168
|
+
base: Config = Config() if first else session.config
|
|
169
|
+
if pull and supports_configuration():
|
|
170
|
+
settings = await pull_settings()
|
|
171
|
+
if first or settings is not None:
|
|
172
|
+
# Every reload rebuilds from the defaults, so a key removed from the settings returns
|
|
173
|
+
# to its default; no settings at all (a failed pull) keeps the current configuration.
|
|
174
|
+
cfg, warnings = parse_config(state["init_options"], Config())
|
|
175
|
+
cfg, more = parse_config(settings, cfg)
|
|
176
|
+
else:
|
|
177
|
+
cfg, warnings, more = base, [], []
|
|
178
|
+
for warning in warnings + more:
|
|
179
|
+
session.log(warning)
|
|
180
|
+
before = (base.inlay_parameter_names, base.help_directory)
|
|
181
|
+
await session.configure(cfg, state.get("folders") if first else None)
|
|
182
|
+
if not first and before != (cfg.inlay_parameter_names, cfg.help_directory):
|
|
183
|
+
refresh_inlay_hints()
|
|
184
|
+
|
|
185
|
+
@server.feature(types.INITIALIZE)
|
|
186
|
+
def on_initialize(params: types.InitializeParams) -> None:
|
|
187
|
+
state["init_options"] = params.initialization_options
|
|
188
|
+
session.position_encoding = position_encoding()
|
|
189
|
+
state["folders"] = folders_of(params)
|
|
190
|
+
|
|
191
|
+
@server.feature(types.INITIALIZED)
|
|
192
|
+
async def on_initialized(params: types.InitializedParams) -> None:
|
|
193
|
+
# Also an Info log message: clients show stderr at error level, which would mark a good start as an error.
|
|
194
|
+
server.window_log_message(types.LogMessageParams(type=types.MessageType.Info, message=startup_line()))
|
|
195
|
+
await reload(pull=True, first=True)
|
|
196
|
+
task = asyncio.get_running_loop().create_task(register_watchers())
|
|
197
|
+
background.add(task)
|
|
198
|
+
task.add_done_callback(background.discard)
|
|
199
|
+
|
|
200
|
+
async def register_watchers() -> None:
|
|
201
|
+
"""Ask the client to report changes to closed program and library files (dynamic
|
|
202
|
+
registration, only when the client supports it). A failure is logged, never fatal: the
|
|
203
|
+
server still handles open documents and `didChangeConfiguration`."""
|
|
204
|
+
workspace = server.client_capabilities.workspace
|
|
205
|
+
support = workspace.did_change_watched_files if workspace else None
|
|
206
|
+
if not (support and support.dynamic_registration):
|
|
207
|
+
session.log(
|
|
208
|
+
"client does not support watched-file registration; changes to closed "
|
|
209
|
+
"files are noticed when a document is saved, opened or closed"
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
try:
|
|
213
|
+
await asyncio.wait_for(
|
|
214
|
+
server.client_register_capability_async(
|
|
215
|
+
types.RegistrationParams(
|
|
216
|
+
registrations=[
|
|
217
|
+
types.Registration(
|
|
218
|
+
id=WATCH_ID,
|
|
219
|
+
method=types.WORKSPACE_DID_CHANGE_WATCHED_FILES,
|
|
220
|
+
register_options=types.DidChangeWatchedFilesRegistrationOptions(
|
|
221
|
+
watchers=[
|
|
222
|
+
types.FileSystemWatcher(glob_pattern=watch_pattern()),
|
|
223
|
+
types.FileSystemWatcher(glob_pattern=CHECKS_PATTERN),
|
|
224
|
+
types.FileSystemWatcher(
|
|
225
|
+
glob_pattern=DELETE_PATTERN, kind=types.WatchKind.Delete
|
|
226
|
+
),
|
|
227
|
+
]
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
]
|
|
231
|
+
)
|
|
232
|
+
),
|
|
233
|
+
CONFIG_PULL_TIMEOUT,
|
|
234
|
+
)
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
session.log(f"watched-file registration failed: {type(exc).__name__}: {exc}")
|
|
237
|
+
|
|
238
|
+
kinds = {
|
|
239
|
+
types.FileChangeType.Created: CREATED,
|
|
240
|
+
types.FileChangeType.Changed: CHANGED,
|
|
241
|
+
types.FileChangeType.Deleted: DELETED,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@server.feature(types.WORKSPACE_DID_CHANGE_WATCHED_FILES)
|
|
245
|
+
async def on_watched_files(params: types.DidChangeWatchedFilesParams) -> None:
|
|
246
|
+
await session.files_changed([(fs_path(e.uri), kinds[e.type]) for e in params.changes if e.type in kinds])
|
|
247
|
+
|
|
248
|
+
@server.feature(types.WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS)
|
|
249
|
+
async def on_folders(params: types.DidChangeWorkspaceFoldersParams) -> None:
|
|
250
|
+
for folder in params.event.removed or []:
|
|
251
|
+
session.folder_uris.pop(fs_path(folder.uri), None)
|
|
252
|
+
session.folder_uris.update({fs_path(f.uri): f.uri for f in params.event.added or []})
|
|
253
|
+
await session.set_folders(
|
|
254
|
+
[fs_path(f.uri) for f in params.event.added or []], [fs_path(f.uri) for f in params.event.removed or []]
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
@server.feature(types.WORKSPACE_DID_CHANGE_CONFIGURATION)
|
|
258
|
+
async def on_config(params: types.DidChangeConfigurationParams) -> None:
|
|
259
|
+
settings = params.settings
|
|
260
|
+
if isinstance(settings, dict) and isinstance(settings.get(SECTION), dict):
|
|
261
|
+
settings = settings[SECTION]
|
|
262
|
+
await reload(settings, pull=True)
|
|
263
|
+
|
|
264
|
+
@server.feature(types.TEXT_DOCUMENT_DID_OPEN)
|
|
265
|
+
def on_open(params: types.DidOpenTextDocumentParams) -> None:
|
|
266
|
+
doc = params.text_document
|
|
267
|
+
session.did_open(fs_path(doc.uri), doc.uri, doc.version, doc.text)
|
|
268
|
+
|
|
269
|
+
@server.feature(types.TEXT_DOCUMENT_DID_CHANGE)
|
|
270
|
+
def on_change(params: types.DidChangeTextDocumentParams) -> None:
|
|
271
|
+
changes = params.content_changes
|
|
272
|
+
if not changes or hasattr(changes[-1], "range"):
|
|
273
|
+
session.log("ignoring a non-full document change (full sync is advertised)")
|
|
274
|
+
return
|
|
275
|
+
session.did_change(fs_path(params.text_document.uri), params.text_document.version, changes[-1].text)
|
|
276
|
+
|
|
277
|
+
@server.feature(types.TEXT_DOCUMENT_DID_SAVE)
|
|
278
|
+
def on_save(params: types.DidSaveTextDocumentParams) -> None:
|
|
279
|
+
session.did_save(fs_path(params.text_document.uri))
|
|
280
|
+
|
|
281
|
+
@server.feature(types.TEXT_DOCUMENT_DID_CLOSE)
|
|
282
|
+
def on_close(params: types.DidCloseTextDocumentParams) -> None:
|
|
283
|
+
session.did_close(fs_path(params.text_document.uri))
|
|
284
|
+
|
|
285
|
+
@server.feature(types.SHUTDOWN)
|
|
286
|
+
def on_shutdown(*_: Any) -> None:
|
|
287
|
+
state["shutdown"] = True
|
|
288
|
+
session.close()
|
|
289
|
+
|
|
290
|
+
# ---- language features -------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def position_encoding() -> str:
|
|
293
|
+
value = getattr(server.workspace, "position_encoding", None)
|
|
294
|
+
return str(getattr(value, "value", value) or "utf-16")
|
|
295
|
+
|
|
296
|
+
async def make_ctx() -> features.Ctx:
|
|
297
|
+
await session.configured()
|
|
298
|
+
return features.Ctx(
|
|
299
|
+
session.config.logger,
|
|
300
|
+
position_encoding(),
|
|
301
|
+
session.config.encoding,
|
|
302
|
+
session.blobs(),
|
|
303
|
+
{p: d.uri for p, d in session.docs.items()},
|
|
304
|
+
session.help_provider(),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
async def prepared(uri: str, wait: bool, first: bool = False):
|
|
308
|
+
"""(path, Analysed or None, AnalysisResult or None) for a request about `uri`."""
|
|
309
|
+
await session.configured()
|
|
310
|
+
path = fs_path(uri)
|
|
311
|
+
ana = await session.analysed_for(path, wait, first)
|
|
312
|
+
return path, ana, await session.load(ana)
|
|
313
|
+
|
|
314
|
+
def guarded(name: str, empty: Any):
|
|
315
|
+
"""A request never fails: an internal error is logged and answered with `empty`."""
|
|
316
|
+
|
|
317
|
+
def wrap(handler):
|
|
318
|
+
async def run(params):
|
|
319
|
+
try:
|
|
320
|
+
return await handler(params)
|
|
321
|
+
except asyncio.CancelledError, JsonRpcException:
|
|
322
|
+
raise
|
|
323
|
+
except Exception:
|
|
324
|
+
session.log(f"{name} failed:\n{traceback.format_exc()}")
|
|
325
|
+
return empty() if callable(empty) else empty
|
|
326
|
+
|
|
327
|
+
run.__name__ = handler.__name__
|
|
328
|
+
return run
|
|
329
|
+
|
|
330
|
+
return wrap
|
|
331
|
+
|
|
332
|
+
@server.feature(types.TEXT_DOCUMENT_COMPLETION, types.CompletionOptions(trigger_characters=["."]))
|
|
333
|
+
@guarded("completion", lambda: types.CompletionList(False, []))
|
|
334
|
+
async def on_completion(params: types.CompletionParams):
|
|
335
|
+
path, ana, result = await prepared(params.text_document.uri, wait=False, first=True)
|
|
336
|
+
return await asyncio.to_thread(features.completion, await make_ctx(), ana, result, path, params.position)
|
|
337
|
+
|
|
338
|
+
@server.feature(types.TEXT_DOCUMENT_HOVER)
|
|
339
|
+
@guarded("hover", None)
|
|
340
|
+
async def on_hover(params: types.HoverParams):
|
|
341
|
+
path, ana, result = await prepared(params.text_document.uri, wait=False)
|
|
342
|
+
return await asyncio.to_thread(features.hover, await make_ctx(), ana, result, path, params.position)
|
|
343
|
+
|
|
344
|
+
@server.feature(types.TEXT_DOCUMENT_SIGNATURE_HELP, types.SignatureHelpOptions(trigger_characters=["(", ","]))
|
|
345
|
+
@guarded("signatureHelp", None)
|
|
346
|
+
async def on_signature_help(params: types.SignatureHelpParams):
|
|
347
|
+
return await asyncio.to_thread(
|
|
348
|
+
features.signature_help, await make_ctx(), fs_path(params.text_document.uri), params.position
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
@server.feature(types.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
|
|
352
|
+
@guarded("documentSymbol", list)
|
|
353
|
+
async def on_document_symbol(params: types.DocumentSymbolParams):
|
|
354
|
+
path, ana, result = await prepared(params.text_document.uri, wait=True)
|
|
355
|
+
return await asyncio.to_thread(features.document_symbols, await make_ctx(), ana, result, path)
|
|
356
|
+
|
|
357
|
+
@server.feature(types.TEXT_DOCUMENT_FOLDING_RANGE)
|
|
358
|
+
@guarded("foldingRange", list)
|
|
359
|
+
async def on_folding_range(params: types.FoldingRangeParams):
|
|
360
|
+
return await asyncio.to_thread(features.folding_ranges, await make_ctx(), fs_path(params.text_document.uri))
|
|
361
|
+
|
|
362
|
+
@server.feature(types.TEXT_DOCUMENT_DOCUMENT_LINK)
|
|
363
|
+
@guarded("documentLink", list)
|
|
364
|
+
async def on_document_link(params: types.DocumentLinkParams):
|
|
365
|
+
path, ana, result = await prepared(params.text_document.uri, wait=True)
|
|
366
|
+
return await asyncio.to_thread(features.document_links, await make_ctx(), ana, result, path)
|
|
367
|
+
|
|
368
|
+
@server.feature(types.TEXT_DOCUMENT_DEFINITION)
|
|
369
|
+
@guarded("definition", list)
|
|
370
|
+
async def on_definition(params: types.DefinitionParams):
|
|
371
|
+
path, ana, result = await prepared(params.text_document.uri, wait=True)
|
|
372
|
+
return await asyncio.to_thread(features.definition, await make_ctx(), ana, result, path, params.position)
|
|
373
|
+
|
|
374
|
+
@server.feature(types.TEXT_DOCUMENT_REFERENCES)
|
|
375
|
+
@guarded("references", list)
|
|
376
|
+
async def on_references(params: types.ReferenceParams):
|
|
377
|
+
"""The request file's program (as for every read-only feature, possibly stale) plus every
|
|
378
|
+
other entry with a current program that reads the request file or a declaration's file
|
|
379
|
+
(LSP-R4a); entries without a current program are skipped."""
|
|
380
|
+
path, ana, result = await prepared(params.text_document.uri, wait=True)
|
|
381
|
+
if result is None:
|
|
382
|
+
return []
|
|
383
|
+
ctx = await make_ctx()
|
|
384
|
+
home = next((p for p, e in session.entries.items() if e.analysed is ana), Path())
|
|
385
|
+
analyses: dict[int, tuple] = {id(ana): (home, ana, result)}
|
|
386
|
+
|
|
387
|
+
async def add(entries) -> list:
|
|
388
|
+
for e in entries:
|
|
389
|
+
if e.analysed is not None and id(e.analysed) not in analyses:
|
|
390
|
+
loaded = await session.load(e.analysed)
|
|
391
|
+
if loaded is not None:
|
|
392
|
+
analyses[id(e.analysed)] = (e.path, e.analysed, loaded)
|
|
393
|
+
return [(a, r) for _, a, r in sorted(analyses.values(), key=lambda t: str(t[0]))]
|
|
394
|
+
|
|
395
|
+
pairs = await add(session.current_readers(path))
|
|
396
|
+
files = await asyncio.to_thread(features.declaration_files, ctx, pairs, path, params.position)
|
|
397
|
+
for f in sorted(files):
|
|
398
|
+
pairs = await add(session.current_readers(f))
|
|
399
|
+
return await asyncio.to_thread(
|
|
400
|
+
features.references_across, ctx, pairs, path, params.position, params.context.include_declaration
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
@server.feature(types.TEXT_DOCUMENT_DOCUMENT_HIGHLIGHT)
|
|
404
|
+
@guarded("documentHighlight", list)
|
|
405
|
+
async def on_document_highlight(params: types.DocumentHighlightParams):
|
|
406
|
+
path, ana, result = await prepared(params.text_document.uri, wait=True)
|
|
407
|
+
return await asyncio.to_thread(
|
|
408
|
+
features.document_highlights, await make_ctx(), ana, result, path, params.position
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
@server.feature(types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL, features.LEGEND)
|
|
412
|
+
@guarded("semanticTokens", lambda: types.SemanticTokens(data=[]))
|
|
413
|
+
async def on_semantic_tokens(params: types.SemanticTokensParams):
|
|
414
|
+
path, ana, result = await prepared(params.text_document.uri, wait=True)
|
|
415
|
+
return await asyncio.to_thread(features.semantic_tokens, await make_ctx(), ana, result, path)
|
|
416
|
+
|
|
417
|
+
# ---- rename, formatting, quick fixes (LSP-07-wire) ---------------------------------
|
|
418
|
+
|
|
419
|
+
class Refused(JsonRpcException):
|
|
420
|
+
"""A refusal of an edit request: an LSP error carrying the reason (`data.reason`)."""
|
|
421
|
+
|
|
422
|
+
CODE = int(types.LSPErrorCodes.RequestFailed)
|
|
423
|
+
|
|
424
|
+
class QuietRefusals(logging.Filter):
|
|
425
|
+
"""pygls logs every exception a handler raises with its traceback; a refusal is an
|
|
426
|
+
expected answer, not a server error."""
|
|
427
|
+
|
|
428
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
429
|
+
return not (record.exc_info and isinstance(record.exc_info[1], Refused))
|
|
430
|
+
|
|
431
|
+
logging.getLogger("pygls.protocol.json_rpc").addFilter(QuietRefusals())
|
|
432
|
+
report_default = server.report_server_error
|
|
433
|
+
|
|
434
|
+
def report_server_error(error: Exception, source: Any) -> None:
|
|
435
|
+
if not isinstance(error, Refused):
|
|
436
|
+
report_default(error, source)
|
|
437
|
+
|
|
438
|
+
server.report_server_error = report_server_error # type: ignore[method-assign]
|
|
439
|
+
|
|
440
|
+
def refuse(what: str, reason: str, message: str) -> Refused:
|
|
441
|
+
return Refused(f"{what}: {message}", data={"reason": reason})
|
|
442
|
+
|
|
443
|
+
def context_now() -> features.Ctx:
|
|
444
|
+
"""The request context from the documents as they are now (no await in between)."""
|
|
445
|
+
return features.Ctx(
|
|
446
|
+
session.config.logger,
|
|
447
|
+
position_encoding(),
|
|
448
|
+
session.config.encoding,
|
|
449
|
+
session.blobs(),
|
|
450
|
+
{p: d.uri for p, d in session.docs.items()},
|
|
451
|
+
session.help_provider(),
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def locked(entries, function, *args):
|
|
455
|
+
"""Runs in a thread: `function` with the core-call guard of every entry's program held
|
|
456
|
+
(always taken in entry-path order)."""
|
|
457
|
+
with contextlib.ExitStack() as stack:
|
|
458
|
+
for entry in entries:
|
|
459
|
+
stack.enter_context(entry.analysed.guard)
|
|
460
|
+
return function(*args)
|
|
461
|
+
|
|
462
|
+
async def current_entries(path: Path, what: str):
|
|
463
|
+
"""(entries reading `path`, their `Analysed`) once none is pending; a refusal when no
|
|
464
|
+
entry reads the file or one of them has no program that matches the open documents."""
|
|
465
|
+
await session.configured()
|
|
466
|
+
entries = await session.entries_reading(path)
|
|
467
|
+
if not entries:
|
|
468
|
+
raise refuse(what, "no_entry", "no configured entry program includes this file")
|
|
469
|
+
for entry in entries:
|
|
470
|
+
if not session.current(entry):
|
|
471
|
+
raise refuse(
|
|
472
|
+
what,
|
|
473
|
+
"pending",
|
|
474
|
+
f"the analysis of {entry.path.name} is not current "
|
|
475
|
+
"(pending, failed or timed out); try again when it has finished",
|
|
476
|
+
)
|
|
477
|
+
return entries
|
|
478
|
+
|
|
479
|
+
async def prepare_or_rename(uri: str, position, new_name: str | None, what: str):
|
|
480
|
+
"""Starts with the entries reading the request file; while the declaration or an edit is
|
|
481
|
+
in a file some entry outside the set reads, adds the entries reading it and recomputes,
|
|
482
|
+
so every entry the rename changes is checked (LSP-R3a)."""
|
|
483
|
+
path = fs_path(uri)
|
|
484
|
+
files = {path}
|
|
485
|
+
while True:
|
|
486
|
+
entries = {}
|
|
487
|
+
for f in sorted(files):
|
|
488
|
+
for e in await current_entries(f, what):
|
|
489
|
+
entries[e.path] = e
|
|
490
|
+
entries = [entries[k] for k in sorted(entries)]
|
|
491
|
+
found, hashes, versions, touched = await rename_with(entries, path, position, new_name, what)
|
|
492
|
+
if touched <= files:
|
|
493
|
+
break
|
|
494
|
+
files |= touched
|
|
495
|
+
for p, digest in hashes.items(): # an open document must still be what was renamed
|
|
496
|
+
doc = session.docs.get(p)
|
|
497
|
+
if doc is not None and (doc.version != versions.get(p) or hashlib.sha256(doc.data).hexdigest() != digest):
|
|
498
|
+
raise refuse(what, "stale", f"{p.name} changed while the rename was computed")
|
|
499
|
+
return found
|
|
500
|
+
|
|
501
|
+
async def rename_with(entries, path: Path, position, new_name: str | None, what: str):
|
|
502
|
+
"""One core call over `entries`: (result, hashes, document versions, touched files)."""
|
|
503
|
+
unknown = session.unknown_entries()
|
|
504
|
+
if unknown:
|
|
505
|
+
raise refuse(
|
|
506
|
+
what,
|
|
507
|
+
"unanalysed",
|
|
508
|
+
f"the analysis of {unknown[0].path.name} failed or "
|
|
509
|
+
"has not finished, so it is unknown whether it depends on this file",
|
|
510
|
+
)
|
|
511
|
+
results = [await session.load(e.analysed) for e in entries]
|
|
512
|
+
if any(r is None for r in results):
|
|
513
|
+
raise refuse(what, "no_program", "a parsed program is not available")
|
|
514
|
+
# no await from the currency check to the snapshot: the edit fits these documents
|
|
515
|
+
if not all(session.current(e) for e in entries):
|
|
516
|
+
raise refuse(what, "pending", "a document changed while the request was prepared")
|
|
517
|
+
ctx = context_now()
|
|
518
|
+
versions = {p: d.version for p, d in session.docs.items()}
|
|
519
|
+
source = OverlaySource(DiskSource(), session.blobs()) # buffers as the analyses read them
|
|
520
|
+
if new_name is None:
|
|
521
|
+
found, refusal, touched = await asyncio.to_thread(
|
|
522
|
+
locked, entries, features.prepare_rename, ctx, results, path, position, source
|
|
523
|
+
)
|
|
524
|
+
hashes = {}
|
|
525
|
+
else:
|
|
526
|
+
found, refusal, hashes = await asyncio.to_thread(
|
|
527
|
+
locked, entries, features.rename_edit, ctx, results, path, position, new_name, versions, source
|
|
528
|
+
)
|
|
529
|
+
touched = set(hashes)
|
|
530
|
+
if refusal is not None:
|
|
531
|
+
raise refuse(what, refusal.code, refusal.message)
|
|
532
|
+
return found, hashes, versions, {Path(p) for p in touched}
|
|
533
|
+
|
|
534
|
+
@server.feature(types.TEXT_DOCUMENT_PREPARE_RENAME)
|
|
535
|
+
@guarded("prepareRename", None)
|
|
536
|
+
async def on_prepare_rename(params: types.PrepareRenameParams):
|
|
537
|
+
return await prepare_or_rename(params.text_document.uri, params.position, None, "Cannot rename")
|
|
538
|
+
|
|
539
|
+
@server.feature(types.TEXT_DOCUMENT_RENAME, types.RenameOptions(prepare_provider=True))
|
|
540
|
+
@guarded("rename", None)
|
|
541
|
+
async def on_rename(params: types.RenameParams):
|
|
542
|
+
return await prepare_or_rename(params.text_document.uri, params.position, params.new_name, "Cannot rename")
|
|
543
|
+
|
|
544
|
+
def show_info(message: str) -> None:
|
|
545
|
+
server.window_show_message(
|
|
546
|
+
types.ShowMessageParams(type=types.MessageType.Info, message=f"opencrbasic: {message}")
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
async def format_document(params, where):
|
|
550
|
+
path = fs_path(params.text_document.uri)
|
|
551
|
+
try:
|
|
552
|
+
entries = await current_entries(path, "Cannot format")
|
|
553
|
+
for entry in entries:
|
|
554
|
+
why = formatting.format_refusal(await session.load(entry.analysed))
|
|
555
|
+
if why:
|
|
556
|
+
raise refuse("Cannot format", "errors", f"{entry.path.name}: {why}")
|
|
557
|
+
except Refused as exc:
|
|
558
|
+
show_info(exc.message)
|
|
559
|
+
return []
|
|
560
|
+
if not all(session.current(e) for e in entries):
|
|
561
|
+
show_info("Cannot format: a document changed while the request was prepared")
|
|
562
|
+
return []
|
|
563
|
+
ctx = context_now()
|
|
564
|
+
return await asyncio.to_thread(features.formatting_edits, ctx, path, params.options, where)
|
|
565
|
+
|
|
566
|
+
@server.feature(types.TEXT_DOCUMENT_FORMATTING)
|
|
567
|
+
@guarded("formatting", list)
|
|
568
|
+
async def on_formatting(params: types.DocumentFormattingParams):
|
|
569
|
+
return await format_document(params, None)
|
|
570
|
+
|
|
571
|
+
@server.feature(types.TEXT_DOCUMENT_RANGE_FORMATTING)
|
|
572
|
+
@guarded("rangeFormatting", list)
|
|
573
|
+
async def on_range_formatting(params: types.DocumentRangeFormattingParams):
|
|
574
|
+
return await format_document(params, params.range)
|
|
575
|
+
|
|
576
|
+
@server.feature(
|
|
577
|
+
types.TEXT_DOCUMENT_CODE_ACTION, types.CodeActionOptions(code_action_kinds=[types.CodeActionKind.QuickFix])
|
|
578
|
+
)
|
|
579
|
+
@guarded("codeAction", list)
|
|
580
|
+
async def on_code_action(params: types.CodeActionParams):
|
|
581
|
+
only = params.context.only
|
|
582
|
+
if only and types.CodeActionKind.QuickFix not in only:
|
|
583
|
+
return []
|
|
584
|
+
await session.configured()
|
|
585
|
+
path = fs_path(params.text_document.uri)
|
|
586
|
+
entries = session.fresh_contributors(path)
|
|
587
|
+
sources = []
|
|
588
|
+
for entry in entries:
|
|
589
|
+
analysed = entry.analysed
|
|
590
|
+
result = await session.load(analysed)
|
|
591
|
+
if result is not None and analysed is not None:
|
|
592
|
+
sources.append((entry.path, analysed.token, result))
|
|
593
|
+
if not sources or [id(e) for e in session.fresh_contributors(path)] != [id(e) for e in entries]:
|
|
594
|
+
return []
|
|
595
|
+
doc = session.docs.get(path)
|
|
596
|
+
ctx = context_now()
|
|
597
|
+
return await asyncio.to_thread(
|
|
598
|
+
locked,
|
|
599
|
+
entries,
|
|
600
|
+
features.code_actions,
|
|
601
|
+
ctx,
|
|
602
|
+
sources,
|
|
603
|
+
path,
|
|
604
|
+
list(params.context.diagnostics),
|
|
605
|
+
doc.version if doc else None,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
def refresh(supported, send) -> None:
|
|
609
|
+
if supported and not state.get("shutdown"):
|
|
610
|
+
pending = send(None)
|
|
611
|
+
pending.add_done_callback(lambda f: f.cancelled() or f.exception()) # answer unused
|
|
612
|
+
|
|
613
|
+
def refresh_semantic_tokens() -> None:
|
|
614
|
+
"""New programs change the tokens of unchanged documents: ask the client to re-request."""
|
|
615
|
+
workspace = server.client_capabilities.workspace
|
|
616
|
+
support = workspace.semantic_tokens if workspace else None
|
|
617
|
+
refresh(support and support.refresh_support, server.workspace_semantic_tokens_refresh)
|
|
618
|
+
|
|
619
|
+
def refresh_inlay_hints() -> None:
|
|
620
|
+
workspace = server.client_capabilities.workspace
|
|
621
|
+
support = workspace.inlay_hint if workspace else None
|
|
622
|
+
refresh(support and support.refresh_support, server.workspace_inlay_hint_refresh)
|
|
623
|
+
|
|
624
|
+
def on_new_program() -> None:
|
|
625
|
+
refresh_semantic_tokens()
|
|
626
|
+
refresh_inlay_hints()
|
|
627
|
+
|
|
628
|
+
session.on_analysed = on_new_program
|
|
629
|
+
|
|
630
|
+
# ---- inlay hints and commands (LSP-09c) ----------------------------------------------
|
|
631
|
+
|
|
632
|
+
@server.feature(types.TEXT_DOCUMENT_INLAY_HINT)
|
|
633
|
+
@guarded("inlayHint", list)
|
|
634
|
+
async def on_inlay_hint(params: types.InlayHintParams):
|
|
635
|
+
await session.configured()
|
|
636
|
+
if not session.config.inlay_parameter_names:
|
|
637
|
+
return []
|
|
638
|
+
path, ana, result = await prepared(params.text_document.uri, wait=True)
|
|
639
|
+
if result is None or not session.fresh(path, ana): # a stale program has stale lines
|
|
640
|
+
return []
|
|
641
|
+
ctx = await make_ctx()
|
|
642
|
+
first, last = ctx.decoded(path, params.range.start), ctx.decoded(path, params.range.end)
|
|
643
|
+
if first is None or last is None:
|
|
644
|
+
return []
|
|
645
|
+
end = last[0] - 1 if params.range.end.character == 0 and last[0] > first[0] else last[0]
|
|
646
|
+
return await asyncio.to_thread(features.inlay_hints, ctx, ana, result, path, first[0], end)
|
|
647
|
+
|
|
648
|
+
async def command_entry(args: tuple, what: str):
|
|
649
|
+
"""(document path, entry) for a command argument: the document URI. A `.crb` takes the
|
|
650
|
+
first sorted entry that reads it, as the other features do. pygls does not cancel a
|
|
651
|
+
command's task on `$/cancelRequest` (it drops the answer), so the task's refusal is marked
|
|
652
|
+
retrieved here instead of being logged as never retrieved."""
|
|
653
|
+
task = asyncio.current_task()
|
|
654
|
+
if task is not None:
|
|
655
|
+
task.add_done_callback(lambda t: t.cancelled() or t.exception())
|
|
656
|
+
if len(args) != 1 or not isinstance(args[0], str):
|
|
657
|
+
raise refuse(what, "bad_arguments", "expected one argument: the document URI")
|
|
658
|
+
try:
|
|
659
|
+
path = fs_path(args[0])
|
|
660
|
+
except ValueError as exc:
|
|
661
|
+
raise refuse(what, "bad_arguments", str(exc)) from exc
|
|
662
|
+
await session.configured()
|
|
663
|
+
reading = await session.entries_reading(path)
|
|
664
|
+
if path in session.entries:
|
|
665
|
+
return path, session.entries[path]
|
|
666
|
+
if reading:
|
|
667
|
+
return path, reading[0]
|
|
668
|
+
raise refuse(what, "no_entry", "no configured entry program includes this file")
|
|
669
|
+
|
|
670
|
+
@server.command(COMMAND_COMPILER_OUTPUT)
|
|
671
|
+
async def on_compiler_output(*args):
|
|
672
|
+
what = "Cannot show compiler output"
|
|
673
|
+
_, entry = await command_entry(args, what)
|
|
674
|
+
out = await session.compiler_output(entry) # worker pool, analysis bounds
|
|
675
|
+
if isinstance(out, ServerError):
|
|
676
|
+
raise refuse(what, out.reason, str(out))
|
|
677
|
+
return session.display(out.text)
|
|
678
|
+
|
|
679
|
+
@server.command(COMMAND_TABLE_FIELDS)
|
|
680
|
+
async def on_table_fields(*args):
|
|
681
|
+
what = "Cannot show table fields"
|
|
682
|
+
_, entry = await command_entry(args, what)
|
|
683
|
+
if not session.current(entry):
|
|
684
|
+
raise refuse(
|
|
685
|
+
what,
|
|
686
|
+
"pending",
|
|
687
|
+
f"the analysis of {entry.path.name} is not current "
|
|
688
|
+
"(pending, failed or timed out); try again when it has finished",
|
|
689
|
+
)
|
|
690
|
+
result = await session.load(entry.analysed)
|
|
691
|
+
if result is None:
|
|
692
|
+
raise refuse(what, "no_program", "a parsed program is not available")
|
|
693
|
+
tables = await asyncio.to_thread(locked, [entry], commands.table_fields, result)
|
|
694
|
+
return session.display(commands.render_table_fields(tables))
|
|
695
|
+
|
|
696
|
+
server.session = session # type: ignore[attr-defined]
|
|
697
|
+
server.state = state # type: ignore[attr-defined]
|
|
698
|
+
return server
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@contextlib.contextmanager
|
|
702
|
+
def protocol_streams() -> Iterator[tuple[BinaryIO | None, BinaryIO | None]]:
|
|
703
|
+
if sys.platform == "win32":
|
|
704
|
+
import ctypes
|
|
705
|
+
import msvcrt
|
|
706
|
+
from ctypes import wintypes
|
|
707
|
+
|
|
708
|
+
# H-11: keep protocol pipes private before pygls starts reading or the pool spawns.
|
|
709
|
+
# Hypothesis, awaiting Windows validation: a child's stdio setup blocks behind the
|
|
710
|
+
# reader's pending ReadFile on an inherited pipe. Children should see NUL instead.
|
|
711
|
+
with contextlib.ExitStack() as stack:
|
|
712
|
+
stdin = stack.enter_context(os.fdopen(os.dup(sys.stdin.fileno()), "rb"))
|
|
713
|
+
stdout = stack.enter_context(os.fdopen(os.dup(sys.stdout.fileno()), "wb"))
|
|
714
|
+
nul = stack.enter_context(open(os.devnull, "r+b"))
|
|
715
|
+
sys.stdout.flush()
|
|
716
|
+
set_std_handle = ctypes.WinDLL("kernel32", use_last_error=True).SetStdHandle
|
|
717
|
+
set_std_handle.argtypes = [wintypes.DWORD, wintypes.HANDLE]
|
|
718
|
+
set_std_handle.restype = wintypes.BOOL
|
|
719
|
+
for fd, handle in ((0, -10), (1, -11)):
|
|
720
|
+
os.dup2(nul.fileno(), fd)
|
|
721
|
+
if not set_std_handle(handle, msvcrt.get_osfhandle(fd)):
|
|
722
|
+
raise ctypes.WinError()
|
|
723
|
+
yield stdin, stdout
|
|
724
|
+
else:
|
|
725
|
+
yield None, None
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def main() -> None:
|
|
729
|
+
print(startup_line(), file=sys.stderr, flush=True)
|
|
730
|
+
refusal = check_api_version()
|
|
731
|
+
if refusal:
|
|
732
|
+
print(refusal, file=sys.stderr, flush=True)
|
|
733
|
+
sys.exit(2)
|
|
734
|
+
with protocol_streams() as (stdin, stdout):
|
|
735
|
+
server = build_server()
|
|
736
|
+
try:
|
|
737
|
+
server.start_io(stdin, stdout)
|
|
738
|
+
finally:
|
|
739
|
+
server.session.close() # type: ignore[attr-defined]
|
|
740
|
+
if not server.state.get("shutdown"): # type: ignore[attr-defined]
|
|
741
|
+
sys.exit(1) # LSP: `exit` without a prior `shutdown`
|