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
opencrbasic/hover.py ADDED
@@ -0,0 +1,667 @@
1
+ """Hover and signature-help logic, independent of any language-server protocol.
2
+
3
+ Positions in and out are physical: 0-based line, decoded-character column (the convention of
4
+ `docs/lsp-api.md` "Source positions"); the server converts encodings. Help text is display
5
+ information only: it never changes a checker rule. See `docs/lsp-api.md` "Hover and signature
6
+ help (LSP-04b-core)".
7
+ """
8
+
9
+ import dataclasses
10
+ import json
11
+ import re
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from functools import lru_cache
15
+ from pathlib import Path
16
+
17
+ from . import positions
18
+ from . import syntax as S
19
+ from .bindings import closes_routine
20
+ from .catalog import CatalogEntry, InstructionCatalog
21
+ from .checker.expressions import _ARITY_ALLOWED, _ARITY_MAX_SAFE, _ARITY_MIN_SAFE, _ARITY_RANGE
22
+ from .editing import _declared_name_arg
23
+ from .helpdocs import HelpLookup, InstructionHelp, _md, pair_signature
24
+ from .source import _is_continuation, _strip_comment
25
+
26
+ __all__ = [
27
+ "CallContext",
28
+ "call_context",
29
+ "call_stack",
30
+ "ParameterInfo",
31
+ "SignatureInfo",
32
+ "signature_help",
33
+ "Declaration",
34
+ "declarations",
35
+ "Hover",
36
+ "hover",
37
+ ]
38
+
39
+
40
+ def _esc(text: str) -> str:
41
+ """Markdown-escaped text on one line (help descriptions keep their source line breaks)."""
42
+ return _md(" ".join(text.split()))
43
+
44
+
45
+ _TYPE_CODES_PATH = Path(__file__).resolve().parent / "data" / "type_codes.json"
46
+
47
+ # Words that may be followed by `(` without being a call: operators, the words the parser
48
+ # dispatches on (`If (a) Then`, `While (a)`, ...). Grammar words, not a catalog subset.
49
+ _NOT_CALLS = frozenset(
50
+ {
51
+ "AND",
52
+ "OR",
53
+ "NOT",
54
+ "XOR",
55
+ "MOD",
56
+ "EQV",
57
+ "IMP",
58
+ "TO",
59
+ "IS",
60
+ "THEN",
61
+ "STEP",
62
+ "AS",
63
+ "IF",
64
+ "ELSEIF",
65
+ "WHILE",
66
+ "UNTIL",
67
+ "SELECT",
68
+ "CASE",
69
+ "RETURN",
70
+ }
71
+ )
72
+
73
+ _CALL_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\$?(?:\.[A-Za-z0-9_]+\$?)*\s*$")
74
+ _WORD_RE = re.compile(r"(?<![A-Za-z0-9_$.])[A-Za-z_][A-Za-z0-9_]*\$?")
75
+ _RADIX_RE = re.compile(r"&[HhBb][0-9A-Fa-f]+")
76
+
77
+
78
+ # ---------------------------------------------------------------------------- text scanning
79
+
80
+
81
+ def _lines(text: str) -> list[str]:
82
+ return text.replace("\r\n", "\n").split("\n")
83
+
84
+
85
+ class _Frame:
86
+ __slots__ = ("name", "line", "col", "commas")
87
+
88
+ def __init__(self, name: str | None, line: int, col: int):
89
+ self.name = name # None: grouping parenthesis or `{`
90
+ self.line = line
91
+ self.col = col
92
+ self.commas = 0
93
+
94
+
95
+ class _Scan:
96
+ """Character scanner over physical lines: strings (`""` escape), `'` comments, and a stack of
97
+ open `(`/`{` with the top-level comma count of each."""
98
+
99
+ def __init__(self) -> None:
100
+ self.stack: list[_Frame] = []
101
+ self.state = "code" # state at the end of the last fed text: code | string | comment
102
+
103
+ def feed(self, lineno: int, text: str, end: int) -> None:
104
+ """Scan `text[:end]`; a string still open at the end of the previous fed line stays open
105
+ (the loader continues a string across `_`), a comment does not."""
106
+ if self.state == "comment":
107
+ self.state = "code"
108
+ i, n = 0, min(end, len(text))
109
+ while i < n:
110
+ ch = text[i]
111
+ if self.state == "string":
112
+ if ch == '"':
113
+ if text[i + 1 : i + 2] == '"':
114
+ i += 2
115
+ continue
116
+ self.state = "code"
117
+ i += 1
118
+ continue
119
+ if ch == '"':
120
+ self.state = "string"
121
+ elif ch == "'":
122
+ self.state = "comment"
123
+ return
124
+ elif ch == "(":
125
+ m = _CALL_NAME_RE.search(text, 0, i)
126
+ name = m.group(0).rstrip() if m else None
127
+ if name is not None and name.upper() in _NOT_CALLS:
128
+ name = None
129
+ self.stack.append(_Frame(name, lineno, m.start() if m and name else i))
130
+ elif ch == "{":
131
+ self.stack.append(_Frame(None, lineno, i))
132
+ elif ch in ")}":
133
+ if self.stack:
134
+ self.stack.pop()
135
+ elif ch == "," and self.stack:
136
+ self.stack[-1].commas += 1
137
+ i += 1
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class CallContext:
142
+ name: str # as written; may be dotted (`Table.Field`)
143
+ active: int # 0-based argument index
144
+ line: int # physical line / column where the name starts
145
+ column: int
146
+
147
+
148
+ def _statement_scan(lines: list[str], line: int, column: int) -> _Scan | None:
149
+ """The scanner after feeding the statement (the physical line joined with its `_`
150
+ continuations) up to `column` of `line`; None when the position lies in a comment."""
151
+ first = line
152
+ while first > 0 and _is_continuation(_strip_comment(lines[first - 1]))[0]:
153
+ first -= 1
154
+ scan = _Scan()
155
+ for k in range(first, line + 1):
156
+ scan.feed(k, lines[k], len(lines[k]) if k < line else column)
157
+ if scan.state == "comment" and k == line:
158
+ return None
159
+ return scan
160
+
161
+
162
+ def call_stack(text: str, line: int, column: int) -> list[CallContext]:
163
+ """Every named call enclosing the position, innermost first, each with the argument the
164
+ position is in (grouping parentheses and `{}` are skipped). Same scanning rules as
165
+ `call_context`; an array index or user Sub call nested in an instruction call is a frame of
166
+ its own, so the caller can fall through to the enclosing instruction."""
167
+ lines = _lines(text)
168
+ if not 0 <= line < len(lines) or column < 0:
169
+ return []
170
+ scan = _statement_scan(lines, line, column)
171
+ if scan is None:
172
+ return []
173
+ return [CallContext(f.name, f.commas, f.line, f.col) for f in reversed(scan.stack) if f.name]
174
+
175
+
176
+ def call_context(text: str, line: int, column: int) -> CallContext | None:
177
+ """The innermost call enclosing the position and the argument the position is in.
178
+
179
+ The statement is the physical line joined with its `_` continuations; strings (also across a
180
+ `_`), `'` comments, nested parentheses and `{}` are respected, and an incomplete call (no `)`,
181
+ trailing comma, unterminated string) still answers. None outside any call, in a comment, or
182
+ for a position outside the text. `call_stack` gives the enclosing calls as well."""
183
+ stack = call_stack(text, line, column)
184
+ return stack[0] if stack else None
185
+
186
+
187
+ def _word_at(lines: list[str], line: int, column: int) -> tuple[str, int, int] | None:
188
+ """The identifier at the position (or ending at it) when it lies in code."""
189
+ if not 0 <= line < len(lines) or column < 0:
190
+ return None
191
+ text = lines[line]
192
+ radix = [m.span() for m in _RADIX_RE.finditer(text)]
193
+ found = None
194
+ for m in _WORD_RE.finditer(text):
195
+ s, e = m.span()
196
+ if any(a <= s < b for a, b in radix):
197
+ continue
198
+ if s <= column < e or (e == column and found is None):
199
+ found = (m.group(0), s, e)
200
+ if s <= column < e:
201
+ break
202
+ if found is None:
203
+ return None
204
+ scan = _statement_scan(lines, line, found[1])
205
+ if scan is None or scan.state != "code":
206
+ return None
207
+ return found
208
+
209
+
210
+ # ---------------------------------------------------------------------------- type-code kinds
211
+
212
+
213
+ @lru_cache(maxsize=1)
214
+ def _type_code_kinds() -> dict[int, str]:
215
+ data = json.loads(_TYPE_CODES_PATH.read_text())
216
+ return {int(code): entry["kind"] for code, entry in data.items()}
217
+
218
+
219
+ def _kind_of(code: int) -> tuple[str | None, bool]:
220
+ """(`type_codes.json` kind, optional) of a catalog parameter code; codes >= 100 are the
221
+ optional form of code-100 (docs/type-codes.md)."""
222
+ base = code - 100 if code >= 100 else code
223
+ return _type_code_kinds().get(base), code >= 100
224
+
225
+
226
+ # ---------------------------------------------------------------------------- signature help
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class ParameterInfo:
231
+ label: str # help name when paired, else `argN`
232
+ label_span: tuple[int, int] # offsets of the parameter in `SignatureInfo.label`
233
+ documentation: str # Markdown; "" when none
234
+ type_code: int | None # catalog code (instructions); None for functions
235
+ kind: str | None # `type_codes.json` kind of the code; None for functions
236
+ optional: bool # catalog code >= 100, or the help syntax says optional
237
+
238
+
239
+ @dataclass(frozen=True)
240
+ class SignatureInfo:
241
+ name: str # the profile's spelling
242
+ kind: str # "instruction" | "function"
243
+ label: str # `Name(p1, p2)`
244
+ documentation: str # Markdown: summary, help syntax, mismatch note, provenance
245
+ parameters: tuple[ParameterInfo, ...]
246
+ active_parameter: int | None # clamped; None when there are no parameters
247
+ paired: bool # parameter names come from help
248
+ catalog_count: int | None
249
+ help_count: int | None # None: no help
250
+ note: str # why names are not paired ("" when paired)
251
+ help_source: Path | None
252
+
253
+
254
+ def _catalog(profile) -> InstructionCatalog:
255
+ return InstructionCatalog.for_profile(profile if isinstance(profile, str) else profile.name)
256
+
257
+
258
+ def _provenance(help: InstructionHelp) -> str:
259
+ p = help.source_path
260
+ return f"Source: help page `{p.parent.name}/{p.name}` (local Campbell Scientific documentation)."
261
+
262
+
263
+ def _callable_entry(cat: InstructionCatalog, name: str) -> CatalogEntry | None:
264
+ return cat.get(name, "instruction") or cat.get(name, "function")
265
+
266
+
267
+ def _accepted_counts(name: str, nargs: int) -> tuple[int, ...] | None:
268
+ """Argument counts the checker accepts for a builtin function whose table has `nargs`, from its
269
+ own tables (`checker/expressions.py`, `_call_arity_problem`); None when those tables do not bound
270
+ the count both ways. `nargs` is not a universal bound (`_ARITY_ALLOWED`, `_ARITY_RANGE`)."""
271
+ upper = name.upper()
272
+ if allowed := _ARITY_ALLOWED.get(upper, {}).get(nargs):
273
+ return tuple(sorted(allowed))
274
+ if bounds := _ARITY_RANGE.get(upper, {}).get(nargs):
275
+ return tuple(range(bounds[0], bounds[1] + 1))
276
+ if _ARITY_MIN_SAFE.get(upper) == nargs and _ARITY_MAX_SAFE.get(upper) == nargs:
277
+ return (nargs,)
278
+ return None
279
+
280
+
281
+ def _count_text(counts: tuple[int, ...]) -> str:
282
+ """`6, 10 or 11` for (6, 10, 11)."""
283
+ words = [str(c) for c in counts]
284
+ return words[0] if len(words) == 1 else f"{', '.join(words[:-1])} or {words[-1]}"
285
+
286
+
287
+ # Explicit placeholders when no help text is available (human instruction, LSP-04e). Never inferred.
288
+ _HELP_TODO = "Help: still to do (No help page for this name)."
289
+ _HELP_OFF = "Help: not configured (No help directory configured)."
290
+
291
+
292
+ def _no_help_line(help_configured: bool) -> str:
293
+ return _HELP_TODO if help_configured else _HELP_OFF
294
+
295
+
296
+ def _accepted_note(accepted: tuple[int, ...]) -> str:
297
+ return (
298
+ f"Accepted argument counts (checker): {_count_text(accepted)}. "
299
+ f"Parameters after the first {accepted[0]} are optional."
300
+ )
301
+
302
+
303
+ def _build_signature(
304
+ entry: CatalogEntry, help: InstructionHelp | None, active: int, help_configured: bool = True
305
+ ) -> SignatureInfo | None:
306
+ accepted: tuple[int, ...] | None = None
307
+ if entry.kind == "instruction":
308
+ codes = tuple(entry.params)
309
+ kinds = [_kind_of(c) for c in codes]
310
+ paired = pair_signature(entry.name, codes, help)
311
+ count = len(codes)
312
+ type_codes: list[int | None] = list(codes)
313
+ else:
314
+ # Help names apply when the largest count the checker accepts is the help parameter count
315
+ # (FTPClient: 6, 10 or 11 against 11 help names). Parameters past the smallest accepted count
316
+ # are optional; the accepted counts are stated in the documentation.
317
+ if entry.nargs is not None:
318
+ accepted = _accepted_counts(entry.name, entry.nargs)
319
+ if help is not None and accepted and len(help.params) == accepted[-1]:
320
+ count = accepted[-1]
321
+ else:
322
+ accepted = None
323
+ count = entry.nargs if entry.nargs is not None else (len(help.params) if help and help.params else None)
324
+ if count is None:
325
+ return None
326
+ kinds = [(None, False)] * count
327
+ paired = pair_signature(entry.name, (0,) * count, help)
328
+ if accepted:
329
+ paired = dataclasses.replace(
330
+ paired,
331
+ params=tuple(
332
+ dataclasses.replace(p, optional=True) if i >= accepted[0] else p
333
+ for i, p in enumerate(paired.params)
334
+ ),
335
+ )
336
+ type_codes = [None] * count
337
+ labels, spans, pos = [], [], len(entry.name) + 1
338
+ params = []
339
+ for i, p in enumerate(paired.params):
340
+ kind, cat_optional = kinds[i] if i < len(kinds) else (None, False)
341
+ type_code = type_codes[i] if i < len(type_codes) else None
342
+ piece = p.label + (" (optional)" if p.optional or cat_optional else "")
343
+ spans.append((pos, pos + len(piece)))
344
+ pos += len(piece) + 2
345
+ labels.append(piece)
346
+ params.append(
347
+ ParameterInfo(
348
+ p.label,
349
+ spans[-1],
350
+ _esc(p.description) if p.from_help else "",
351
+ type_code,
352
+ kind,
353
+ p.optional or cat_optional,
354
+ )
355
+ )
356
+ label = f"{entry.name}({', '.join(labels)})"
357
+ doc: list[str] = []
358
+ if help is not None:
359
+ if help.summary:
360
+ doc.append(_esc(help.summary))
361
+ if not paired.matched:
362
+ doc.append(_mismatch(help, paired.note))
363
+ doc.append(_provenance(help))
364
+ else:
365
+ doc.append(_no_help_line(help_configured))
366
+ if accepted:
367
+ doc.append(_accepted_note(accepted))
368
+ elif entry.kind == "function" and entry.nargs is not None:
369
+ doc.append("Argument count from the function table; a trailing argument may be optional.")
370
+ clamped = None if count == 0 else max(0, min(active, count - 1))
371
+ return SignatureInfo(
372
+ entry.name,
373
+ entry.kind,
374
+ label,
375
+ "\n\n".join(doc),
376
+ tuple(params),
377
+ clamped,
378
+ paired.matched,
379
+ count,
380
+ paired.help_count,
381
+ paired.note,
382
+ help.source_path if help else None,
383
+ )
384
+
385
+
386
+ def _mismatch(help: InstructionHelp, note: str) -> str:
387
+ syntax = f"Help syntax: `{help.syntax}`. " if help.syntax else ""
388
+ return f"{syntax}{note[:1].upper()}{note[1:]}; help parameter names are not applied to the catalog positions."
389
+
390
+
391
+ def signature_help(name: str, active: int, profile, help_provider: HelpLookup | None = None) -> SignatureInfo | None:
392
+ """Signature of an instruction or builtin function for the profile (name or `Profile`), with
393
+ parameter names and text from help when its parameter count equals the catalog's, otherwise
394
+ `argN` labels from the catalog count. None for a name that is neither (including dotted
395
+ names), or a function whose argument count is unknown."""
396
+ entry = _callable_entry(_catalog(profile), name)
397
+ if entry is None:
398
+ return None
399
+ help = help_provider.lookup(entry.name) if help_provider is not None else None
400
+ return _build_signature(entry, help, active, help_configured=help_provider is not None)
401
+
402
+
403
+ # ---------------------------------------------------------------------------- declarations
404
+
405
+
406
+ @dataclass(frozen=True)
407
+ class Declaration:
408
+ name: str
409
+ kind: str # Public | Dim | Static | Const | Sub | Function | DataTable |
410
+ # ConstTable | Alias
411
+ type: str | None = None # as written (upper case); None: no `As` written
412
+ dims: tuple[str, ...] = ()
413
+ string_size: str | None = None
414
+ units: str | None = None
415
+ value: str | None = None # Const: resolved value when known, else the written expression
416
+ detail: str = "" # Sub/Function: parameter list; Alias: the aliased name
417
+ scope: str | None = None # enclosing Sub/Function name, None: top level
418
+ file: str = "" # compiler display name
419
+ line: int = 0 # reported line
420
+ physical_line: int | None = None # first physical line of the statement, when known
421
+
422
+
423
+ def _expr_text(e, depth: int = 0) -> str:
424
+ if e is None:
425
+ return ""
426
+ if depth > 6:
427
+ return "..."
428
+ if isinstance(e, S.Number):
429
+ return e.text or str(e.value)
430
+ if isinstance(e, S.String):
431
+ return '"' + e.value.replace('"', '""') + '"'
432
+ if isinstance(e, S.Name):
433
+ return e.name
434
+ if isinstance(e, S.Index):
435
+ return f"{e.name}({', '.join(_expr_text(a, depth + 1) for a in e.args)})"
436
+ if isinstance(e, S.Member):
437
+ base = _expr_text(e.base, depth + 1)
438
+ args = "" if e.args is None else f"({', '.join(_expr_text(a, depth + 1) for a in e.args)})"
439
+ return f"{base}.{e.member}{args}"
440
+ if isinstance(e, S.Unary):
441
+ return (
442
+ f"{e.op} {_expr_text(e.operand, depth + 1)}"
443
+ if e.op.isalpha()
444
+ else f"{e.op}{_expr_text(e.operand, depth + 1)}"
445
+ )
446
+ if isinstance(e, S.Binary):
447
+ return f"{_expr_text(e.left, depth + 1)} {e.op} {_expr_text(e.right, depth + 1)}"
448
+ if isinstance(e, S.ArrayLiteral):
449
+ return "{" + ", ".join(_expr_text(a, depth + 1) for a in e.items) + "}"
450
+ return "..."
451
+
452
+
453
+ def _target_name(e) -> str | None:
454
+ if isinstance(e, S.Name):
455
+ return e.name
456
+ if isinstance(e, S.Index):
457
+ return e.name
458
+ return None
459
+
460
+
461
+ def declarations(program) -> dict[str, list[Declaration]]:
462
+ """Declared names of a parsed program (`AnalysisResult.program`; None gives `{}`), keyed by
463
+ upper-case name, in source order. Covers `Public`/`Dim`/`Static`, `Const`, `Sub`/`Function`,
464
+ `DataTable`/`ConstTable` names and `Alias` names, with `Units` labels attached. Scope is the
465
+ enclosing Sub/Function name only; the same name can be declared in several scopes."""
466
+ out: dict[str, list[Declaration]] = {}
467
+ statements = getattr(program, "statements", None)
468
+ if not statements:
469
+ return out
470
+ line_map = getattr(program, "line_map", None)
471
+ consts = getattr(program, "consts", {}) or {}
472
+ units: dict[str, str] = {}
473
+ scope: str | None = None
474
+
475
+ def where(st) -> tuple[str, int, int | None]:
476
+ file = st.file or getattr(program, "name", "")
477
+ span = positions.line_span(line_map, file, st.line) if line_map else None
478
+ return file, st.line, span[0] if span else None
479
+
480
+ def add(name: str, st, kind: str, **kw) -> None:
481
+ file, line, phys = where(st)
482
+ out.setdefault(name.upper(), []).append(
483
+ Declaration(name, kind, scope=kw.pop("scope", scope), file=file, line=line, physical_line=phys, **kw)
484
+ )
485
+
486
+ for st in statements:
487
+ if isinstance(st, S.Declaration):
488
+ for v in st.vars:
489
+ add(
490
+ v.name,
491
+ st,
492
+ st.kind.capitalize(),
493
+ type=v.type,
494
+ dims=tuple(_expr_text(d) for d in v.dims),
495
+ string_size=_expr_text(v.string_size) if v.string_size is not None else None,
496
+ units=v.units,
497
+ )
498
+ elif isinstance(st, S.ConstDecl):
499
+ known = consts.get(st.name.upper())
500
+ add(st.name, st, "Const", type=st.type, value=str(known) if known is not None else _expr_text(st.value))
501
+ elif isinstance(st, S.SubDef):
502
+ detail = ", ".join(
503
+ ("Optional " if p.default is not None else "")
504
+ + p.name
505
+ + ("()" if p.dims else "")
506
+ + (f" As {p.type}" if p.type else "")
507
+ for p in st.params
508
+ )
509
+ add(st.name, st, st.keyword.capitalize(), type=st.return_type, detail=detail, scope=None)
510
+ scope = st.name
511
+ elif isinstance(st, S.Instruction):
512
+ kw = st.keyword
513
+ if closes_routine(st):
514
+ scope = None
515
+ elif kw in ("DATATABLE", "CONSTTABLE") and st.args and (table := _declared_name_arg(st.args[0], kw)):
516
+ add(table, st, "DataTable" if kw == "DATATABLE" else "ConstTable")
517
+ elif kw == "UNITS" and len(st.args) == 2 and isinstance(st.args[1], S.String):
518
+ target = _target_name(st.args[0])
519
+ if target:
520
+ units.setdefault(target.upper(), st.args[1].value)
521
+ elif kw == "ALIAS" and st.args:
522
+ source = _expr_text(st.args[0])
523
+ for a in st.args[1:]:
524
+ alias = _target_name(a)
525
+ if alias:
526
+ add(alias, st, "Alias", detail=source)
527
+ for entries in out.values():
528
+ for k, d in enumerate(entries):
529
+ u = units.get(d.name.upper())
530
+ if u is not None and d.units is None and d.kind in ("Public", "Dim", "Static", "Alias"):
531
+ entries[k] = dataclasses.replace(d, units=u)
532
+ return out
533
+
534
+
535
+ def _declaration_markdown(decls: list[Declaration]) -> str:
536
+ parts = []
537
+ for d in decls[:5]:
538
+ dims = f"({', '.join(d.dims)})" if d.dims else ""
539
+ if d.kind in ("Sub", "Function"):
540
+ tail = f" As {d.type}" if d.type else ""
541
+ code = f"{d.kind} {d.name}({d.detail}){tail}"
542
+ lines = []
543
+ elif d.kind == "Const":
544
+ code = f"Const {d.name} = {d.value}"
545
+ lines = [f"Type: {d.type}"] if d.type else []
546
+ elif d.kind in ("DataTable", "ConstTable"):
547
+ code = f"{d.kind}({d.name}, ...)"
548
+ lines = []
549
+ elif d.kind == "Alias":
550
+ code = f"Alias {d.detail} = {d.name}"
551
+ lines = []
552
+ else:
553
+ size = f" * {d.string_size}" if d.string_size else ""
554
+ code = f"{d.kind} {d.name}{dims}" + (f" As {d.type}{size}" if d.type else "")
555
+ lines = [] if d.type else ["Type: none written (Float)"]
556
+ if d.kind in ("Public", "Dim", "Static") and d.dims:
557
+ lines.append(f"Dimensions: {', '.join(d.dims)}")
558
+ if d.units:
559
+ lines.append(f"Units: {_esc(d.units)}")
560
+ where = f"Declared in `{d.file}`, line {d.physical_line or d.line}"
561
+ if d.scope:
562
+ where += f", local to `{d.scope}`"
563
+ lines.append(where + ".")
564
+ parts.append("\n".join([f"**{_esc(d.name)}** ({d.kind})", "", "```crbasic", code, "```", "", *lines]))
565
+ if len(decls) > 5:
566
+ parts.append(f"{len(decls) - 5} more declaration(s) of this name.")
567
+ if len(decls) > 1:
568
+ parts.insert(0, "Declared more than once; scope is not resolved at hover.")
569
+ return "\n\n".join(parts)
570
+
571
+
572
+ # ---------------------------------------------------------------------------- hover
573
+
574
+
575
+ @dataclass(frozen=True)
576
+ class Hover:
577
+ markdown: str
578
+ line: int # physical line and decoded columns of the word
579
+ start_column: int
580
+ end_column: int
581
+
582
+
583
+ def _callable_markdown(
584
+ entry: CatalogEntry, help: InstructionHelp | None, help_configured: bool, others: tuple[CatalogEntry, ...]
585
+ ) -> str:
586
+ sig = _build_signature(entry, help, 0, help_configured)
587
+ label = entry.kind
588
+ if any(e.kind == "keyword" for e in others):
589
+ label = "statement keyword" if entry.kind == "instruction" else label
590
+ head = f"**{_esc(help.title)}**" if help is not None else f"**{_esc(entry.name)}** ({label})"
591
+ out = [head]
592
+ if sig is None:
593
+ out += [
594
+ "",
595
+ f"Function table row: code {entry.code}, kind {entry.func_kind}, argspec {entry.argspec}; "
596
+ "no argument count in the table.",
597
+ ]
598
+ else:
599
+ out += ["", "```crbasic", sig.label, "```"]
600
+ if help is not None and help.summary:
601
+ out += ["", _esc(help.summary)]
602
+ if help is not None and not sig.paired:
603
+ out += ["", _mismatch(help, sig.note)]
604
+ rows = []
605
+ for p in sig.parameters:
606
+ info = ", ".join(x for x in (p.kind, "optional" if p.optional else None) if x)
607
+ text = f"- `{p.label}`" + (f" ({info})" if info else "")
608
+ rows.append(text + (f": {p.documentation}" if p.documentation else ""))
609
+ if rows:
610
+ out += [""] + rows
611
+ accepted = (
612
+ _accepted_counts(entry.name, entry.nargs) if entry.kind == "function" and entry.nargs is not None else None
613
+ )
614
+ if accepted and sig.paired:
615
+ out += ["", _accepted_note(accepted)]
616
+ elif entry.kind == "function" and entry.nargs is not None:
617
+ out += ["", "Argument count from the function table; a trailing argument may be optional."]
618
+ if help is not None:
619
+ out += ["", _provenance(help)]
620
+ else:
621
+ out += ["", _no_help_line(help_configured)]
622
+ return "\n".join(out)
623
+
624
+
625
+ def _simple_markdown(entry: CatalogEntry) -> str:
626
+ if entry.kind == "symbol":
627
+ return f"**{_esc(entry.name)}** (predefined constant)\n\n```crbasic\n{entry.name} = {entry.value}\n```"
628
+ return f"**{_esc(entry.name)}** (CRBasic keyword)"
629
+
630
+
631
+ def hover(
632
+ result,
633
+ text: str,
634
+ line: int,
635
+ column: int,
636
+ profile,
637
+ help_provider: HelpLookup | None = None,
638
+ declared: Mapping[str, list[Declaration]] | None = None,
639
+ ) -> Hover | None:
640
+ """Markdown for the word at the position, or None.
641
+
642
+ `result` is an `AnalysisResult` (or None): its AST gives declared names unless `declared`
643
+ (upper-case name -> declarations, e.g. from LSP-05a bindings) is passed. Words in comments
644
+ and strings, numbers, and member names after `.` give None. Declared names win over catalog
645
+ names; catalog names resolve instruction, function, predefined constant, keyword."""
646
+ word = _word_at(_lines(text), line, column)
647
+ if word is None:
648
+ return None
649
+ name, start, end = word
650
+ decls = declared if declared is not None else declarations(getattr(result, "program", None))
651
+ found = decls.get(name.upper())
652
+ if found:
653
+ return Hover(_declaration_markdown(list(found)), line, start, end)
654
+ cat = _catalog(profile)
655
+ entry = cat.get(name)
656
+ if entry is None:
657
+ return None
658
+ callable_entry = _callable_entry(cat, name)
659
+ if callable_entry is not None:
660
+ help = help_provider.lookup(callable_entry.name) if help_provider is not None else None
661
+ md = _callable_markdown(callable_entry, help, help_provider is not None, cat.entries(name))
662
+ sym = cat.get(name, "symbol")
663
+ if sym is not None:
664
+ md += f"\n\nAlso a predefined constant: {sym.name} = {sym.value}."
665
+ else:
666
+ md = _simple_markdown(entry)
667
+ return Hover(md, line, start, end)