opencrbasic 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. opencrbasic/__init__.py +3 -0
  2. opencrbasic/__main__.py +5 -0
  3. opencrbasic/analysis.py +184 -0
  4. opencrbasic/bindings.py +1034 -0
  5. opencrbasic/catalog.py +173 -0
  6. opencrbasic/checkconfig.py +144 -0
  7. opencrbasic/checker/__init__.py +977 -0
  8. opencrbasic/checker/cr3xx.py +3118 -0
  9. opencrbasic/checker/ess.py +81 -0
  10. opencrbasic/checker/expressions.py +3581 -0
  11. opencrbasic/checker/instr_data.py +1802 -0
  12. opencrbasic/checker/instr_measure.py +1547 -0
  13. opencrbasic/checker/instr_rules.py +3540 -0
  14. opencrbasic/checker/instructions.py +17 -0
  15. opencrbasic/checker/intervals.py +678 -0
  16. opencrbasic/checker/longconv.py +133 -0
  17. opencrbasic/checker/scan_time.py +580 -0
  18. opencrbasic/checker/storage.py +456 -0
  19. opencrbasic/checker/structs.py +240 -0
  20. opencrbasic/checker/structure.py +6213 -0
  21. opencrbasic/checker/symbols.py +609 -0
  22. opencrbasic/checker/terminals.py +249 -0
  23. opencrbasic/checker/unused.py +186 -0
  24. opencrbasic/checker/var_types.py +564 -0
  25. opencrbasic/checker/warnings.py +661 -0
  26. opencrbasic/checks.py +491 -0
  27. opencrbasic/cli.py +391 -0
  28. opencrbasic/commands.py +128 -0
  29. opencrbasic/compiler.py +320 -0
  30. opencrbasic/consteval.py +327 -0
  31. opencrbasic/data/background_calibration.json +4 -0
  32. opencrbasic/data/before_beginprog.json +99 -0
  33. opencrbasic/data/cr1000x/background_calibration.json +4 -0
  34. opencrbasic/data/cr1000x/before_beginprog.json +138 -0
  35. opencrbasic/data/cr1000x/ess_variables.json +1087 -0
  36. opencrbasic/data/cr1000x/fnotch.json +29 -0
  37. opencrbasic/data/cr1000x/functions.json +1337 -0
  38. opencrbasic/data/cr1000x/instructions.json +4836 -0
  39. opencrbasic/data/cr1000x/measure.json +121 -0
  40. opencrbasic/data/cr1000x/mode.json +111 -0
  41. opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
  42. opencrbasic/data/cr1000x/settings_fields.json +321 -0
  43. opencrbasic/data/cr1000x/status_members.json +318 -0
  44. opencrbasic/data/cr1000x/symbols.json +872 -0
  45. opencrbasic/data/cr1000x/terminals.json +225 -0
  46. opencrbasic/data/cr300/before_beginprog.json +42 -0
  47. opencrbasic/data/cr300/ess_variables.json +1087 -0
  48. opencrbasic/data/cr300/fnotch.json +22 -0
  49. opencrbasic/data/cr300/functions.json +1261 -0
  50. opencrbasic/data/cr300/instructions.json +2837 -0
  51. opencrbasic/data/cr300/measure.json +33 -0
  52. opencrbasic/data/cr300/mode.json +121 -0
  53. opencrbasic/data/cr300/settings_fields.json +259 -0
  54. opencrbasic/data/cr300/status_members.json +303 -0
  55. opencrbasic/data/cr300/symbols.json +584 -0
  56. opencrbasic/data/cr300/terminals.json +193 -0
  57. opencrbasic/data/cr350/before_beginprog.json +44 -0
  58. opencrbasic/data/cr350/ess_variables.json +1087 -0
  59. opencrbasic/data/cr350/fnotch.json +22 -0
  60. opencrbasic/data/cr350/functions.json +1262 -0
  61. opencrbasic/data/cr350/instructions.json +2848 -0
  62. opencrbasic/data/cr350/measure.json +35 -0
  63. opencrbasic/data/cr350/mode.json +121 -0
  64. opencrbasic/data/cr350/settings_fields.json +292 -0
  65. opencrbasic/data/cr350/status_members.json +303 -0
  66. opencrbasic/data/cr350/symbols.json +572 -0
  67. opencrbasic/data/cr350/terminals.json +190 -0
  68. opencrbasic/data/ess_variables.json +1087 -0
  69. opencrbasic/data/fnotch.json +34 -0
  70. opencrbasic/data/functions.json +1337 -0
  71. opencrbasic/data/instructions.json +4965 -0
  72. opencrbasic/data/measure.json +123 -0
  73. opencrbasic/data/mode.json +121 -0
  74. opencrbasic/data/notices_cr6.json +890 -0
  75. opencrbasic/data/pipeline_tasks.json +84 -0
  76. opencrbasic/data/settings_fields.json +322 -0
  77. opencrbasic/data/status_members.json +319 -0
  78. opencrbasic/data/status_table.json +684 -0
  79. opencrbasic/data/symbols.json +902 -0
  80. opencrbasic/data/terminals.json +195 -0
  81. opencrbasic/data/type_codes.json +249 -0
  82. opencrbasic/diagnostics.py +196 -0
  83. opencrbasic/editing.py +827 -0
  84. opencrbasic/formatting.py +310 -0
  85. opencrbasic/helpdocs.py +677 -0
  86. opencrbasic/hover.py +667 -0
  87. opencrbasic/inlay.py +169 -0
  88. opencrbasic/lexer.py +183 -0
  89. opencrbasic/lsp/__init__.py +5 -0
  90. opencrbasic/lsp/__main__.py +7 -0
  91. opencrbasic/lsp/config.py +202 -0
  92. opencrbasic/lsp/features.py +602 -0
  93. opencrbasic/lsp/server.py +741 -0
  94. opencrbasic/lsp/session.py +1189 -0
  95. opencrbasic/lsp/worker.py +128 -0
  96. opencrbasic/mode.py +143 -0
  97. opencrbasic/navigation.py +401 -0
  98. opencrbasic/parser.py +3264 -0
  99. opencrbasic/positions.py +274 -0
  100. opencrbasic/preprocess.py +269 -0
  101. opencrbasic/profiles.py +266 -0
  102. opencrbasic/provenance.py +17 -0
  103. opencrbasic/quickfix.py +331 -0
  104. opencrbasic/rename.py +662 -0
  105. opencrbasic/signature.py +33 -0
  106. opencrbasic/source.py +600 -0
  107. opencrbasic/sources.py +125 -0
  108. opencrbasic/syntax.py +337 -0
  109. opencrbasic/tables.py +2173 -0
  110. opencrbasic/tables_hook.py +14 -0
  111. opencrbasic/tdf.py +142 -0
  112. opencrbasic/timing.py +565 -0
  113. opencrbasic-1.0.0.dist-info/METADATA +223 -0
  114. opencrbasic-1.0.0.dist-info/RECORD +117 -0
  115. opencrbasic-1.0.0.dist-info/WHEEL +4 -0
  116. opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
  117. opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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`