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
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)
|