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/editing.py
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
"""Editing-feature logic for editor tooling (outline, folding, include links, completion), kept
|
|
2
|
+
free of any protocol library. The language server (LSP-03b-wire) only converts what these functions
|
|
3
|
+
return. See `docs/lsp-api.md` "Editing features (LSP-03b-core)".
|
|
4
|
+
|
|
5
|
+
Positions. Every range here is *before LSP position-encoding conversion*: a `Span` is 0-based
|
|
6
|
+
physical lines and 0-based decoded (Latin-1) columns, i.e. byte offsets into the physical line,
|
|
7
|
+
the unit `source.Piece` and `positions.PhysicalRange` use. `end_col=None` means "the end of
|
|
8
|
+
`end_line`" (whole-line spans: the AST carries lines only, so symbol ranges are whole lines). The
|
|
9
|
+
server turns a `Span` into an LSP range with `positions.lsp_position(data, line + 1, col, ...)`
|
|
10
|
+
(that function takes a 1-based line).
|
|
11
|
+
|
|
12
|
+
Language knowledge is not duplicated: block words come from the instruction catalog (`catalog.py`)
|
|
13
|
+
and are matched the way the parser and the structure checker match them; `Include` statements are
|
|
14
|
+
found by the loader itself; completion candidates are the catalog's entries plus names read from
|
|
15
|
+
the parsed program and the table definitions `tables.build_tables` computes.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import functools
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import NamedTuple
|
|
25
|
+
|
|
26
|
+
from . import lexer, positions, profiles, source
|
|
27
|
+
from . import syntax as S
|
|
28
|
+
from .catalog import InstructionCatalog
|
|
29
|
+
from .parser import _DIRECTIVE_RE, _split_text_segments
|
|
30
|
+
from .sources import DiskSource, OverlaySource
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Span",
|
|
34
|
+
"Symbol",
|
|
35
|
+
"FoldRange",
|
|
36
|
+
"IncludeLink",
|
|
37
|
+
"DeclaredName",
|
|
38
|
+
"CompletionItem",
|
|
39
|
+
"Completion",
|
|
40
|
+
"block_pairs",
|
|
41
|
+
"document_symbols",
|
|
42
|
+
"folding_ranges",
|
|
43
|
+
"include_links",
|
|
44
|
+
"declared_names",
|
|
45
|
+
"completion_candidates",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Span:
|
|
51
|
+
"""0-based physical lines and decoded columns, before LSP position-encoding conversion.
|
|
52
|
+
`end_col=None`: through the end of `end_line` (whole-line spans have `start_col == 0`)."""
|
|
53
|
+
|
|
54
|
+
start_line: int
|
|
55
|
+
start_col: int
|
|
56
|
+
end_line: int
|
|
57
|
+
end_col: int | None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _lines(first: int, last: int) -> Span:
|
|
61
|
+
"""Whole-line `Span` for 1-based physical lines `first..last`."""
|
|
62
|
+
return Span(first - 1, 0, last - 1, None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------ block words
|
|
66
|
+
|
|
67
|
+
# Block pairs whose closer is not `End` + opener. Every word is a row of the instruction table
|
|
68
|
+
# (checked against the catalog for each profile in `block_pairs`, and against the parser in
|
|
69
|
+
# `tests/lsp/test_editing.py`). All other pairs are derived from the catalog: an instruction row
|
|
70
|
+
# `X` for which the row `EndX` also exists (If/EndIf, Select/EndSelect, Sub/EndSub, ...).
|
|
71
|
+
_IRREGULAR_PAIRS = {
|
|
72
|
+
"FOR": "NEXT",
|
|
73
|
+
"DO": "LOOP",
|
|
74
|
+
"WHILE": "WEND",
|
|
75
|
+
"DATATABLE": "ENDTABLE",
|
|
76
|
+
"BEGINPROG": "ENDPROG",
|
|
77
|
+
"SCAN": "NEXTSCAN",
|
|
78
|
+
"SUBSCAN": "NEXTSUBSCAN",
|
|
79
|
+
"SLOWSEQUENCE": "ENDSEQUENCE",
|
|
80
|
+
"DISPLAYMENU": "ENDMENU",
|
|
81
|
+
"WEBPAGEBEGIN": "WEBPAGEEND",
|
|
82
|
+
"SHUTDOWNBEGIN": "SHUTDOWNEND",
|
|
83
|
+
"BEGINBURSTTRIGGER": "ENDBURSTTRIGGER",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@functools.cache
|
|
88
|
+
def block_pairs(profile: str = "cr6") -> dict[str, str]:
|
|
89
|
+
"""Upper-case opener word -> closer word, for the blocks of `profile`'s instruction table."""
|
|
90
|
+
cat = InstructionCatalog.for_profile(profile)
|
|
91
|
+
pairs = {o: c for o, c in _IRREGULAR_PAIRS.items() if cat.get(o, "instruction") and cat.get(c, "instruction")}
|
|
92
|
+
for entry in cat:
|
|
93
|
+
if entry.kind == "instruction":
|
|
94
|
+
opener = entry.name.upper()
|
|
95
|
+
if cat.get("END" + opener, "instruction"):
|
|
96
|
+
pairs.setdefault(opener, "END" + opener)
|
|
97
|
+
return pairs
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Blocks that the programs the compiler accepts (this project's `Main.CR6`, the CHM samples) end
|
|
101
|
+
# without writing the closer: a `SlowSequence` runs to the next `SlowSequence` or to `EndProg`. The
|
|
102
|
+
# compiler's own rule was not observed, so where such a block ends is an editor heuristic.
|
|
103
|
+
_IMPLICIT_END = frozenset({"SLOWSEQUENCE"})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _Roles(NamedTuple):
|
|
107
|
+
openers: dict[str, str] # opener -> closer
|
|
108
|
+
closers: dict[str, str] # closer -> opener (the family name)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@functools.cache
|
|
112
|
+
def _roles(profile: str) -> _Roles:
|
|
113
|
+
pairs = block_pairs(profile)
|
|
114
|
+
return _Roles(pairs, {c: o for o, c in pairs.items()})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _classify(word: str, second: str | None, roles: _Roles) -> tuple[str, str] | None:
|
|
118
|
+
"""`("open" | "close", family)` for a statement starting with `word` (`second`: the identifier
|
|
119
|
+
after `End` / `Next`, for the two-word closers `End If`, `Next Scan`, `End Sub`, ...)."""
|
|
120
|
+
w = word.upper()
|
|
121
|
+
if w in ("END", "NEXT") and second and (w + second.upper()) in roles.closers:
|
|
122
|
+
return "close", roles.closers[w + second.upper()]
|
|
123
|
+
if w in roles.closers:
|
|
124
|
+
return "close", roles.closers[w]
|
|
125
|
+
if w in roles.openers:
|
|
126
|
+
return "open", w
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _ast_event(stmt: S.Stmt, roles: _Roles) -> tuple[str, str] | None:
|
|
131
|
+
"""The block role of one parsed statement (the parser's statement classes, see `syntax.py`)."""
|
|
132
|
+
if isinstance(stmt, S.If):
|
|
133
|
+
return None if stmt.inline is not None else _classify("IF", None, roles)
|
|
134
|
+
if isinstance(stmt, S.For):
|
|
135
|
+
return _classify("FOR", None, roles)
|
|
136
|
+
if isinstance(stmt, (S.DoLoop, S.SubDef)):
|
|
137
|
+
return _classify(stmt.keyword, None, roles)
|
|
138
|
+
if isinstance(stmt, S.Instruction):
|
|
139
|
+
second = None
|
|
140
|
+
if stmt.keyword in ("END", "NEXT") and stmt.args and isinstance(stmt.args[0], S.Name):
|
|
141
|
+
second = stmt.args[0].name
|
|
142
|
+
return _classify(stmt.keyword, second, roles)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------ loading helpers
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _tolerant_tokens(text: str) -> list[lexer.Token]:
|
|
150
|
+
"""`lexer.tokenize`, cutting the text at an unlexable character instead of raising."""
|
|
151
|
+
while True:
|
|
152
|
+
try:
|
|
153
|
+
return lexer.tokenize(text)
|
|
154
|
+
except lexer.LexError as e:
|
|
155
|
+
text = text[: e.col]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _logical_lines(text: str) -> list[tuple[source.LogicalLine, int, int]]:
|
|
159
|
+
"""The loader's logical lines of `text` (includes unresolved), with 0-based absolute physical
|
|
160
|
+
first/last lines. A line the loader calls an overflow (over 510 characters) ends its pass, so
|
|
161
|
+
loading restarts after it: editing a very long line does not hide the rest of the file."""
|
|
162
|
+
physical = source._split_physical_lines(text)
|
|
163
|
+
out: list[tuple[source.LogicalLine, int, int]] = []
|
|
164
|
+
base = 0
|
|
165
|
+
while base < len(physical):
|
|
166
|
+
lines, _ = source.load_text("\n".join(physical[base:]), "t", directory=None)
|
|
167
|
+
restart = None
|
|
168
|
+
for ll in lines:
|
|
169
|
+
if ll.phys is None:
|
|
170
|
+
continue
|
|
171
|
+
if ll.kind == "overflow":
|
|
172
|
+
restart = base + ll.phys[1]
|
|
173
|
+
break
|
|
174
|
+
out.append((ll, base + ll.phys[0] - 1, base + ll.phys[1] - 1))
|
|
175
|
+
if restart is None or restart <= base:
|
|
176
|
+
break
|
|
177
|
+
base = restart
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------ folding
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class FoldRange:
|
|
186
|
+
"""Fold physical lines `start_line..end_line` (0-based, inclusive). A block's range ends on the
|
|
187
|
+
line before its closer, so the closer stays visible. `kind` is an LSP folding kind: "region"
|
|
188
|
+
for blocks and `#If`, "comment" for runs of comment lines."""
|
|
189
|
+
|
|
190
|
+
start_line: int
|
|
191
|
+
end_line: int
|
|
192
|
+
kind: str = "region"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class _Block(NamedTuple):
|
|
196
|
+
family: str # opener word (`IF`, `SUB`, ...) or `#IF`
|
|
197
|
+
name: str | None # the routine name for Sub / Function
|
|
198
|
+
start: int # 0-based first physical line of the opener
|
|
199
|
+
header_end: int # 0-based last physical line of the opener statement
|
|
200
|
+
end: int | None # 0-based first physical line of the closer; None: never closed
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _block_events(text: str, profile: str):
|
|
204
|
+
"""Yield `(role, family, name, first, last)` for every block word and `#If` directive of
|
|
205
|
+
`text`, in order. Words inside strings and comments never appear (the loader strips comments,
|
|
206
|
+
the lexer reads strings)."""
|
|
207
|
+
roles = _roles(profile)
|
|
208
|
+
colon_if = profile in ("cr6", "cr1000x") # `If c Then : stmt` is a one-line If (parser.py)
|
|
209
|
+
for ll, first, last in _logical_lines(text):
|
|
210
|
+
if ll.kind != "code":
|
|
211
|
+
continue
|
|
212
|
+
d = _DIRECTIVE_RE.match(ll.text)
|
|
213
|
+
if d:
|
|
214
|
+
kind = d.group(1).upper()
|
|
215
|
+
if kind in ("IF", "IF_NO_REMOVE"):
|
|
216
|
+
yield "open", "#IF", None, first, last
|
|
217
|
+
elif kind == "ENDIF":
|
|
218
|
+
yield "close", "#IF", None, first, last
|
|
219
|
+
continue
|
|
220
|
+
segments = _split_text_segments(ll.text)
|
|
221
|
+
for idx, seg in enumerate(segments):
|
|
222
|
+
tokens = [t for t in _tolerant_tokens(seg) if t.kind != "EOF"]
|
|
223
|
+
if not tokens or tokens[0].kind != "IDENT":
|
|
224
|
+
continue
|
|
225
|
+
word = tokens[0].upper
|
|
226
|
+
second = tokens[1].upper if len(tokens) > 1 and tokens[1].kind == "IDENT" else None
|
|
227
|
+
event = _classify(word, second, roles)
|
|
228
|
+
if event is None:
|
|
229
|
+
continue
|
|
230
|
+
if event == ("open", "IF"):
|
|
231
|
+
depth, then = 0, None
|
|
232
|
+
for k, t in enumerate(tokens):
|
|
233
|
+
if t.kind == "OP" and t.text in "({":
|
|
234
|
+
depth += 1
|
|
235
|
+
elif t.kind == "OP" and t.text in ")}":
|
|
236
|
+
depth -= 1
|
|
237
|
+
elif depth == 0 and t.kind == "IDENT" and t.upper == "THEN":
|
|
238
|
+
then = k
|
|
239
|
+
break
|
|
240
|
+
if then is not None and (then < len(tokens) - 1 or (colon_if and idx < len(segments) - 1)):
|
|
241
|
+
break # a one-line If: the rest of the line belongs to it
|
|
242
|
+
name = None
|
|
243
|
+
if event[0] == "open" and event[1] in ("SUB", "FUNCTION") and second is not None:
|
|
244
|
+
name = tokens[1].text
|
|
245
|
+
yield event[0], event[1], name, first, last
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Results of `_scan` kept per (text, profile). The key is the text itself, so a hit is exact (no hash
|
|
249
|
+
# collisions); a completion or folding request on an unchanged buffer reuses the scan. Eight texts
|
|
250
|
+
# bound the memory (a few hundred KB for a large program).
|
|
251
|
+
_SCAN_CACHE_SIZE = 8
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@functools.lru_cache(maxsize=_SCAN_CACHE_SIZE)
|
|
255
|
+
def _scan(text: str, profile: str) -> tuple[_Block, ...]:
|
|
256
|
+
"""Every block of `text` with its closer (or None), as a tuple; cached, see
|
|
257
|
+
`_scan.__wrapped__` for the uncached function (the tests compare the two)."""
|
|
258
|
+
return tuple(_scan_uncached(text, profile))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _scan_uncached(text: str, profile: str) -> list[_Block]:
|
|
262
|
+
"""Every block of `text` with its closer (or None), by nearest-matching-opener recovery: a
|
|
263
|
+
closer closes the innermost open block of its family and drops the blocks opened inside it
|
|
264
|
+
that were never closed, a closer with no opener is ignored. `SlowSequence` also ends, without
|
|
265
|
+
`EndSequence`, where the next `SlowSequence` or the enclosing `EndProg` begins (`_IMPLICIT`).
|
|
266
|
+
Works on any text."""
|
|
267
|
+
stack: list[_Block] = []
|
|
268
|
+
done: list[_Block] = []
|
|
269
|
+
|
|
270
|
+
def close(k: int, first: int) -> None:
|
|
271
|
+
for lost in stack[k + 1 :]:
|
|
272
|
+
if lost.family in _IMPLICIT_END:
|
|
273
|
+
done.append(lost._replace(end=first))
|
|
274
|
+
else:
|
|
275
|
+
done.append(lost)
|
|
276
|
+
done.append(stack[k]._replace(end=first))
|
|
277
|
+
del stack[k:]
|
|
278
|
+
|
|
279
|
+
for role, family, name, first, last in _block_events(text, profile):
|
|
280
|
+
if role == "open":
|
|
281
|
+
if family in _IMPLICIT_END:
|
|
282
|
+
for k in range(len(stack) - 1, -1, -1):
|
|
283
|
+
if stack[k].family == family:
|
|
284
|
+
done.extend(stack[k + 1 :])
|
|
285
|
+
done.append(stack[k]._replace(end=first))
|
|
286
|
+
del stack[k:]
|
|
287
|
+
break
|
|
288
|
+
stack.append(_Block(family, name, first, last, None))
|
|
289
|
+
continue
|
|
290
|
+
for k in range(len(stack) - 1, -1, -1):
|
|
291
|
+
if stack[k].family == family:
|
|
292
|
+
close(k, first)
|
|
293
|
+
break
|
|
294
|
+
done.extend(stack)
|
|
295
|
+
return done
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def folding_ranges(text: str, profile: str = "cr6") -> list[FoldRange]:
|
|
299
|
+
"""Fold ranges for the buffer `text` (decoded as the compiler decodes: Latin-1): every block
|
|
300
|
+
that has an opener and a closer and at least one line between them, `#If`..`#EndIf` (inactive
|
|
301
|
+
branches included: conditions are not evaluated), and runs of two or more comment-only lines.
|
|
302
|
+
Unclosed blocks give no range. Sorted by start line, outer before inner."""
|
|
303
|
+
out = [
|
|
304
|
+
FoldRange(b.start, b.end - 1, "region")
|
|
305
|
+
for b in _scan(text, profile)
|
|
306
|
+
if b.end is not None and b.end - 1 > b.header_end
|
|
307
|
+
]
|
|
308
|
+
run = None
|
|
309
|
+
lines = source._split_physical_lines(text)
|
|
310
|
+
for n, line in enumerate(lines + [""]):
|
|
311
|
+
if n < len(lines) and line.lstrip(" \t").startswith("'"):
|
|
312
|
+
run = n if run is None else run
|
|
313
|
+
else:
|
|
314
|
+
if run is not None and n - 1 > run:
|
|
315
|
+
out.append(FoldRange(run, n - 1, "comment"))
|
|
316
|
+
run = None
|
|
317
|
+
out.sort(key=lambda f: (f.start_line, -f.end_line))
|
|
318
|
+
return out
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ------------------------------------------------------------------ include links
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass(frozen=True)
|
|
325
|
+
class IncludeLink:
|
|
326
|
+
"""One `Include` / `IncludeSection` statement. `range` is the file name inside the quotes;
|
|
327
|
+
`target` is the file the compiler resolved (`Program.include_paths`), None when it did not."""
|
|
328
|
+
|
|
329
|
+
file: Path
|
|
330
|
+
range: Span
|
|
331
|
+
spec: str
|
|
332
|
+
target: Path | None
|
|
333
|
+
keyword: str
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _same_path(a: Path, b: Path) -> bool:
|
|
337
|
+
na, nb = os.path.normpath(a), os.path.normpath(b)
|
|
338
|
+
return na == nb or na.lower() == nb.lower()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _program_files(result) -> list[Path]:
|
|
342
|
+
seen: list[Path] = []
|
|
343
|
+
program = result.program
|
|
344
|
+
paths = [o.path for o in getattr(program, "occurrences", None) or () if o.path is not None]
|
|
345
|
+
for p in [*paths] or [result.request.entry]:
|
|
346
|
+
if not any(_same_path(p, q) for q in seen):
|
|
347
|
+
seen.append(p)
|
|
348
|
+
return seen
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def include_links(result) -> list[IncludeLink]:
|
|
352
|
+
"""The `Include` / `IncludeSection` statements of every file of the analysed program, in file
|
|
353
|
+
then line order. The statements are the loader's (`source.py`, so continuation lines and
|
|
354
|
+
case follow the compiler); the file text is read through the request's overlays. A file
|
|
355
|
+
included twice is listed once. With no parsed program only the entry file is scanned and every
|
|
356
|
+
target is None."""
|
|
357
|
+
includes = getattr(result.program, "include_paths", None) or {}
|
|
358
|
+
reader = OverlaySource(DiskSource(), result.request.overlays)
|
|
359
|
+
out: list[IncludeLink] = []
|
|
360
|
+
for path in _program_files(result):
|
|
361
|
+
data = reader.read(Path(path).absolute())
|
|
362
|
+
if data is None:
|
|
363
|
+
continue
|
|
364
|
+
text = data.decode("latin-1")
|
|
365
|
+
physical = source._split_physical_lines(text)
|
|
366
|
+
for ll, first, last in _logical_lines(text):
|
|
367
|
+
if ll.kind not in ("include_warning", "include_error"):
|
|
368
|
+
continue
|
|
369
|
+
strings = []
|
|
370
|
+
keyword = None
|
|
371
|
+
for n in range(first, last + 1):
|
|
372
|
+
toks = _tolerant_tokens(source._strip_comment(physical[n]))
|
|
373
|
+
if keyword is None and toks and toks[0].kind == "IDENT":
|
|
374
|
+
keyword = toks[0].text
|
|
375
|
+
strings.extend((n, t) for t in toks if t.kind == "STRING")
|
|
376
|
+
index = 1 if keyword and keyword.upper() == "INCLUDESECTION" else 0
|
|
377
|
+
if keyword is None or index >= len(strings):
|
|
378
|
+
continue
|
|
379
|
+
n, tok = strings[index]
|
|
380
|
+
spec = str(tok.value)
|
|
381
|
+
out.append(
|
|
382
|
+
IncludeLink(
|
|
383
|
+
Path(path),
|
|
384
|
+
Span(n, tok.col + 1, n, tok.col + len(tok.text) - 1),
|
|
385
|
+
spec,
|
|
386
|
+
includes.get(spec.strip()),
|
|
387
|
+
keyword,
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
return out
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------------------------ outline
|
|
394
|
+
|
|
395
|
+
_SCOPE_FAMILIES = {
|
|
396
|
+
"SUB",
|
|
397
|
+
"FUNCTION",
|
|
398
|
+
"DATATABLE",
|
|
399
|
+
"CONSTTABLE",
|
|
400
|
+
"STRUCTURETYPE",
|
|
401
|
+
"BEGINPROG",
|
|
402
|
+
"SCAN",
|
|
403
|
+
"SUBSCAN",
|
|
404
|
+
"SLOWSEQUENCE",
|
|
405
|
+
}
|
|
406
|
+
_ROUTINES = {"SUB", "FUNCTION"}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@dataclass(frozen=True)
|
|
410
|
+
class Symbol:
|
|
411
|
+
"""One outline entry. `range` runs from the header through the closer (the header only when
|
|
412
|
+
the block is never closed); `selection` is the header. Both are whole-line spans. `occurrence`
|
|
413
|
+
is the `source.Occurrence.id` of the load the symbol came from: a file included twice has one
|
|
414
|
+
entry per occurrence, at the same physical lines."""
|
|
415
|
+
|
|
416
|
+
name: str
|
|
417
|
+
kind: str
|
|
418
|
+
detail: str
|
|
419
|
+
range: Span
|
|
420
|
+
selection: Span
|
|
421
|
+
occurrence: int
|
|
422
|
+
children: tuple[Symbol, ...] = ()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class _Frame:
|
|
426
|
+
def __init__(self, family: str, symbol: dict, children: list):
|
|
427
|
+
self.family, self.symbol, self.children = family, symbol, children
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _param_text(p: S.Param) -> str:
|
|
431
|
+
text = p.name + ("()" if p.dims else "")
|
|
432
|
+
return text + (f" As {p.type.title() if p.type else ''}" if p.type else "")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _sub_detail(stmt: S.SubDef) -> str:
|
|
436
|
+
detail = "(" + ", ".join(_param_text(p) for p in stmt.params) + ")"
|
|
437
|
+
return detail + (f" As {stmt.return_type.title()}" if stmt.return_type else "")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _locations(program, path: Path):
|
|
441
|
+
"""`[(stmt, first, last, occurrence)]` for the statements of `program` that come from the file
|
|
442
|
+
at `path`, in program order, with 1-based physical lines. The AST gives each statement a
|
|
443
|
+
reported line; `Program.line_map` turns it into physical lines. A file loaded twice has its
|
|
444
|
+
statements attributed in load order: the reported line going back means the next load."""
|
|
445
|
+
occs = [o for o in program.occurrences if o.path is not None and _same_path(o.path, path)]
|
|
446
|
+
ids: dict[str, list[int]] = {}
|
|
447
|
+
for o in occs:
|
|
448
|
+
ids.setdefault(o.file.lower(), []).append(o.id)
|
|
449
|
+
state: dict[str, list[int]] = {}
|
|
450
|
+
out = []
|
|
451
|
+
for stmt in program.statements:
|
|
452
|
+
key = stmt.file.lower()
|
|
453
|
+
if key not in ids:
|
|
454
|
+
continue
|
|
455
|
+
known = ids[key]
|
|
456
|
+
spans = [s for s in positions.reported_spans(program.line_map, stmt.file, stmt.line) if s.occurrence in known]
|
|
457
|
+
if not spans:
|
|
458
|
+
continue
|
|
459
|
+
st = state.setdefault(key, [0, 0]) # [index of the load, last reported line]
|
|
460
|
+
if stmt.line < st[1]:
|
|
461
|
+
st[0] = min(st[0] + 1, len(known) - 1)
|
|
462
|
+
st[1] = stmt.line
|
|
463
|
+
span = next((s for s in spans if s.occurrence == known[st[0]]), spans[0])
|
|
464
|
+
out.append((stmt, span.first, span.last, span.occurrence))
|
|
465
|
+
return out
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _is_keyword(cat: InstructionCatalog, word: str) -> bool:
|
|
469
|
+
return cat.get(word, "keyword") is not None
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _outline(located, roles: _Roles, cat: InstructionCatalog, occ: int) -> list[Symbol]:
|
|
473
|
+
root: list[Symbol] = []
|
|
474
|
+
frames: list[_Frame] = []
|
|
475
|
+
prev_last = 0 # last physical line of the previous statement (implicit block ends)
|
|
476
|
+
|
|
477
|
+
def leaf(name, kind, detail, first, last) -> Symbol:
|
|
478
|
+
r = _lines(first, last)
|
|
479
|
+
return Symbol(name, kind, detail, r, r, occ)
|
|
480
|
+
|
|
481
|
+
def pop(k: int, end: int | None) -> None:
|
|
482
|
+
"""Close `frames[k]` through physical line `end` (None: header only); the frames opened
|
|
483
|
+
inside it and never closed keep their header only."""
|
|
484
|
+
inner = frames[k + 1 :]
|
|
485
|
+
frame = frames[k]
|
|
486
|
+
del frames[k:]
|
|
487
|
+
for fr in inner:
|
|
488
|
+
frame.children.append(_symbol(fr, None))
|
|
489
|
+
(frames[-1].children if frames else root).append(_symbol(frame, end))
|
|
490
|
+
|
|
491
|
+
def _symbol(fr: _Frame, end: int | None) -> Symbol:
|
|
492
|
+
sym = fr.symbol
|
|
493
|
+
r = sym["range"] if end is None else _lines(sym["first"], end)
|
|
494
|
+
return Symbol(sym["name"], sym["kind"], sym["detail"], r, sym["selection"], occ, tuple(fr.children))
|
|
495
|
+
|
|
496
|
+
for stmt, first, last, _ in located:
|
|
497
|
+
event = _ast_event(stmt, roles)
|
|
498
|
+
if event and event[0] == "close":
|
|
499
|
+
for k in range(len(frames) - 1, -1, -1):
|
|
500
|
+
if frames[k].family == event[1]:
|
|
501
|
+
for j in range(len(frames) - 1, k, -1): # unclosed SlowSequence inside
|
|
502
|
+
if frames[j].family in _IMPLICIT_END:
|
|
503
|
+
pop(j, prev_last)
|
|
504
|
+
pop(k, last)
|
|
505
|
+
break
|
|
506
|
+
prev_last = last
|
|
507
|
+
continue
|
|
508
|
+
if event and event[0] == "open" and event[1] in _SCOPE_FAMILIES:
|
|
509
|
+
family = event[1]
|
|
510
|
+
if family in _IMPLICIT_END:
|
|
511
|
+
for k in range(len(frames) - 1, -1, -1):
|
|
512
|
+
if frames[k].family == family:
|
|
513
|
+
pop(k, prev_last)
|
|
514
|
+
break
|
|
515
|
+
hdr = _lines(first, last)
|
|
516
|
+
name, kind, detail = _header(stmt, family, cat)
|
|
517
|
+
children = (
|
|
518
|
+
[Symbol(p.name, "parameter", p.type.title() if p.type else "", hdr, hdr, occ) for p in stmt.params]
|
|
519
|
+
if isinstance(stmt, S.SubDef)
|
|
520
|
+
else []
|
|
521
|
+
)
|
|
522
|
+
frames.append(
|
|
523
|
+
_Frame(
|
|
524
|
+
family, dict(name=name, kind=kind, detail=detail, range=hdr, selection=hdr, first=first), children
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
prev_last = last
|
|
528
|
+
continue
|
|
529
|
+
prev_last = last
|
|
530
|
+
scope = frames[-1].family if frames else None
|
|
531
|
+
container = frames[-1].children if frames else root
|
|
532
|
+
if scope in _ROUTINES:
|
|
533
|
+
continue
|
|
534
|
+
if scope == "DATATABLE":
|
|
535
|
+
if isinstance(stmt, S.Instruction) and not _is_keyword(cat, stmt.keyword):
|
|
536
|
+
spelled = cat.get(stmt.keyword, "instruction")
|
|
537
|
+
container.append(
|
|
538
|
+
leaf(
|
|
539
|
+
spelled.name if spelled else stmt.keyword,
|
|
540
|
+
"output",
|
|
541
|
+
" ".join(stmt.text.split())[:80],
|
|
542
|
+
first,
|
|
543
|
+
last,
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
continue
|
|
547
|
+
if scope == "STRUCTURETYPE":
|
|
548
|
+
if isinstance(stmt, S.Declaration):
|
|
549
|
+
container.extend(
|
|
550
|
+
leaf(v.name, "member", v.type.title() if v.type else "", first, last) for v in stmt.vars
|
|
551
|
+
)
|
|
552
|
+
continue
|
|
553
|
+
if isinstance(stmt, S.Declaration) and stmt.kind in ("PUBLIC", "DIM", "STATIC"):
|
|
554
|
+
container.extend(
|
|
555
|
+
leaf(
|
|
556
|
+
v.name,
|
|
557
|
+
stmt.kind.lower(),
|
|
558
|
+
(v.type.title() if v.type else "") + (" array" if v.dims else ""),
|
|
559
|
+
first,
|
|
560
|
+
last,
|
|
561
|
+
)
|
|
562
|
+
for v in stmt.vars
|
|
563
|
+
)
|
|
564
|
+
elif isinstance(stmt, S.ConstDecl):
|
|
565
|
+
container.append(leaf(stmt.name, "const", stmt.type.title() if stmt.type else "", first, last))
|
|
566
|
+
elif isinstance(stmt, S.Instruction) and stmt.keyword == "ALIAS":
|
|
567
|
+
container.extend(leaf(a.name, "alias", "", first, last) for a in stmt.args[1:] if isinstance(a, S.Name))
|
|
568
|
+
while frames: # never closed: header only
|
|
569
|
+
pop(len(frames) - 1, None)
|
|
570
|
+
return root
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _declared_name_arg(first, family: str) -> str | None:
|
|
574
|
+
"""The name a DataTable / ConstTable / StructureType statement declares, from its first
|
|
575
|
+
argument: the bare name, or for DataTable and ConstTable the quoted name of
|
|
576
|
+
`DataTable("Hist", ...)` (declared the same way by `bindings.py`)."""
|
|
577
|
+
if isinstance(first, S.Name):
|
|
578
|
+
return first.name
|
|
579
|
+
if isinstance(first, S.String) and first.value and family in ("DATATABLE", "CONSTTABLE"):
|
|
580
|
+
return first.value
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _header(stmt: S.Stmt, family: str, cat: InstructionCatalog) -> tuple[str, str, str]:
|
|
585
|
+
"""`(name, kind, detail)` of a block-opening statement."""
|
|
586
|
+
if isinstance(stmt, S.SubDef):
|
|
587
|
+
return stmt.name, family.lower(), _sub_detail(stmt)
|
|
588
|
+
if family in ("DATATABLE", "CONSTTABLE", "STRUCTURETYPE"):
|
|
589
|
+
first = stmt.args[0] if getattr(stmt, "args", None) else None
|
|
590
|
+
name = _declared_name_arg(first, family)
|
|
591
|
+
if name is not None:
|
|
592
|
+
return name, family.lower(), ""
|
|
593
|
+
m = re.match(r"\s*(\w+)", stmt.text)
|
|
594
|
+
return (m.group(1) if m else family), family.lower(), " ".join(stmt.text.split())[:80]
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def document_symbols(result, path: Path, profile: str | None = None) -> list[Symbol]:
|
|
598
|
+
"""The outline of the file at `path` from the analysed program `result` (an
|
|
599
|
+
`analysis.AnalysisResult`): Sub / Function (parameters as children), DataTable (its
|
|
600
|
+
instructions), ConstTable (its constants), StructureType (its members), BeginProg / Scan /
|
|
601
|
+
SubScan / SlowSequence blocks (declarations inside them as children), and the Const / Public /
|
|
602
|
+
Dim / Alias declarations. Declarations inside a Sub or Function are not listed. Only code the
|
|
603
|
+
compiler reads is there: inactive `#If` branches and text after `EndProg` have no symbols. A
|
|
604
|
+
file included twice has one entry per occurrence. An empty list when nothing was parsed."""
|
|
605
|
+
program = result.program
|
|
606
|
+
if program is None or not hasattr(program, "line_map") or not hasattr(program, "occurrences"):
|
|
607
|
+
return []
|
|
608
|
+
profile = profile or result.request.logger
|
|
609
|
+
roles, cat = _roles(profile), InstructionCatalog.for_profile(profile)
|
|
610
|
+
located = _locations(program, Path(path))
|
|
611
|
+
out: list[Symbol] = []
|
|
612
|
+
for occ in dict.fromkeys(o for *_, o in located):
|
|
613
|
+
out.extend(_outline([x for x in located if x[3] == occ], roles, cat, occ))
|
|
614
|
+
return out
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# ------------------------------------------------------------------ declared names
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
class DeclaredName(NamedTuple):
|
|
621
|
+
"""A name the program declares. `scope` is None for a global, else the Sub / Function it is
|
|
622
|
+
local to (parameters and routine-level declarations)."""
|
|
623
|
+
|
|
624
|
+
name: str
|
|
625
|
+
kind: str # variable | constant | alias | sub | user_function | table | type | parameter
|
|
626
|
+
scope: str | None
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def declared_names(program, profile: str = "cr6") -> list[DeclaredName]:
|
|
630
|
+
"""Names declared by the parsed `program` (an AST walk; LSP-05a's binding index will replace
|
|
631
|
+
it). Text the compiler does not read has none."""
|
|
632
|
+
if program is None:
|
|
633
|
+
return []
|
|
634
|
+
roles = _roles(profile)
|
|
635
|
+
out: list[DeclaredName] = []
|
|
636
|
+
routine: str | None = None
|
|
637
|
+
for stmt in getattr(program, "statements", ()):
|
|
638
|
+
event = _ast_event(stmt, roles)
|
|
639
|
+
if isinstance(stmt, S.SubDef):
|
|
640
|
+
out.append(DeclaredName(stmt.name, "sub" if stmt.keyword == "SUB" else "user_function", None))
|
|
641
|
+
out.extend(DeclaredName(p.name, "parameter", stmt.name) for p in stmt.params)
|
|
642
|
+
routine = stmt.name
|
|
643
|
+
elif event and event[0] == "close" and event[1] in _ROUTINES:
|
|
644
|
+
routine = None
|
|
645
|
+
elif isinstance(stmt, S.Declaration) and stmt.kind in ("PUBLIC", "DIM", "STATIC"):
|
|
646
|
+
out.extend(DeclaredName(v.name, "variable", routine) for v in stmt.vars)
|
|
647
|
+
elif isinstance(stmt, S.ConstDecl):
|
|
648
|
+
out.append(DeclaredName(stmt.name, "constant", routine))
|
|
649
|
+
elif isinstance(stmt, S.Instruction):
|
|
650
|
+
first = stmt.args[0] if stmt.args else None
|
|
651
|
+
if stmt.keyword == "ALIAS":
|
|
652
|
+
out.extend(DeclaredName(a.name, "alias", routine) for a in stmt.args[1:] if isinstance(a, S.Name))
|
|
653
|
+
elif stmt.keyword in ("DATATABLE", "CONSTTABLE"):
|
|
654
|
+
table = _declared_name_arg(first, stmt.keyword)
|
|
655
|
+
if table is not None:
|
|
656
|
+
out.append(DeclaredName(table, "table", None))
|
|
657
|
+
elif stmt.keyword == "STRUCTURETYPE" and isinstance(first, S.Name):
|
|
658
|
+
out.append(DeclaredName(first.name, "type", None))
|
|
659
|
+
return out
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
# ------------------------------------------------------------------ completion
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@dataclass(frozen=True)
|
|
666
|
+
class CompletionItem:
|
|
667
|
+
label: str
|
|
668
|
+
kind: str # instruction | function | symbol | keyword (catalog); variable |
|
|
669
|
+
# constant | alias | sub | user_function | table | type | parameter (program); field (member)
|
|
670
|
+
source: str # "catalog" | "program" | "member"
|
|
671
|
+
detail: str = ""
|
|
672
|
+
param_count: int | None = None # catalog instructions: number of table parameters; catalog
|
|
673
|
+
# functions: `nargs` / number of `argtypes` when the table has them; otherwise None
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@dataclass(frozen=True)
|
|
677
|
+
class Completion:
|
|
678
|
+
items: tuple[CompletionItem, ...]
|
|
679
|
+
replace: Span # the identifier prefix before the cursor (empty at a word boundary)
|
|
680
|
+
prefix: str
|
|
681
|
+
member_of: str | None = None # the name before the `.` when completing a member
|
|
682
|
+
is_incomplete: bool = False # True when more matches exist than `items` holds (COMPLETION_LIMIT)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# At most this many items per completion result, declared program names first. An empty prefix
|
|
686
|
+
# matches about 1000 catalog names; the client re-asks as the prefix grows, and `is_incomplete`
|
|
687
|
+
# tells it to (LSP `CompletionList.isIncomplete`).
|
|
688
|
+
COMPLETION_LIMIT = 200
|
|
689
|
+
|
|
690
|
+
_IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\$?$")
|
|
691
|
+
_PREFIX_RE = re.compile(r"(?<![A-Za-z0-9_$])[A-Za-z_][A-Za-z0-9_]*\$?$")
|
|
692
|
+
_BASE_RE = re.compile(r"(?<![A-Za-z0-9_$.])([A-Za-z_][A-Za-z0-9_]*\$?)\s*\.\s*$")
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@functools.cache
|
|
696
|
+
def _catalog_items(profile: str) -> tuple[CompletionItem, ...]:
|
|
697
|
+
"""The catalog's completion items of a profile, in completion order (label, case-insensitive
|
|
698
|
+
then exact). Built once per profile: the items are immutable and the catalog is cached."""
|
|
699
|
+
best: dict[str, CompletionItem] = {}
|
|
700
|
+
for entry in InstructionCatalog.for_profile(profile):
|
|
701
|
+
if not _IDENT_RE.match(entry.name):
|
|
702
|
+
continue # `<<` and `>>` are catalog "functions", not names
|
|
703
|
+
if entry.kind == "instruction":
|
|
704
|
+
count = len(entry.params)
|
|
705
|
+
elif entry.kind == "function":
|
|
706
|
+
count = (
|
|
707
|
+
entry.nargs
|
|
708
|
+
if entry.nargs is not None
|
|
709
|
+
else (len(entry.argtypes) if entry.argtypes is not None else None)
|
|
710
|
+
)
|
|
711
|
+
else:
|
|
712
|
+
count = None
|
|
713
|
+
item = CompletionItem(entry.name, entry.kind, "catalog", "", count)
|
|
714
|
+
key = entry.name.upper()
|
|
715
|
+
# the language words win over their instruction row (`If`, `Scan`, ...), then the
|
|
716
|
+
# catalog's usual priority
|
|
717
|
+
order = ("keyword", "instruction", "function", "symbol")
|
|
718
|
+
if key not in best or order.index(entry.kind) < order.index(best[key].kind):
|
|
719
|
+
best[key] = item
|
|
720
|
+
return tuple(sorted(best.values(), key=lambda i: (i.label.lower(), i.label)))
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _limited(items: list[CompletionItem]) -> tuple[tuple[CompletionItem, ...], bool]:
|
|
724
|
+
return tuple(items[:COMPLETION_LIMIT]), len(items) > COMPLETION_LIMIT
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _member_items(result, name: str, profile: str) -> list[CompletionItem]:
|
|
728
|
+
program = getattr(result, "program", None)
|
|
729
|
+
if program is None or not hasattr(program, "statements"):
|
|
730
|
+
return []
|
|
731
|
+
from . import profiles, tables
|
|
732
|
+
|
|
733
|
+
try:
|
|
734
|
+
built = tables.build_tables(program, profiles.get(profile), getattr(program, "consts", {}))
|
|
735
|
+
except Exception: # a half-edited program the table builder rejects: no members
|
|
736
|
+
return []
|
|
737
|
+
items = []
|
|
738
|
+
for table in built[0]:
|
|
739
|
+
if table.name.lower() == name.lower():
|
|
740
|
+
for f in table.fields:
|
|
741
|
+
for label in (f.name, *f.aliases):
|
|
742
|
+
items.append(CompletionItem(label, "field", "member", table.name))
|
|
743
|
+
return items
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _in_string_or_comment(lines: list[str], line: int, before: str) -> bool:
|
|
747
|
+
"""True when the end of `before` (the text of `lines[line]` before the cursor) lies inside a
|
|
748
|
+
string or a `'` comment. A string still open at the end of a `_`-continued line goes on in the
|
|
749
|
+
next one, as the loader joins them."""
|
|
750
|
+
first = line
|
|
751
|
+
while 0 < first <= len(lines) and source._is_continuation(source._strip_comment(lines[first - 1]))[0]:
|
|
752
|
+
first -= 1
|
|
753
|
+
in_string = False
|
|
754
|
+
for text in [*lines[first:line], before]:
|
|
755
|
+
in_string, in_comment = _scan_quotes(text, in_string)
|
|
756
|
+
return in_string or in_comment
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _scan_quotes(text: str, in_string: bool) -> tuple[bool, bool]:
|
|
760
|
+
"""`(in a string, in a comment)` at the end of `text`, which starts inside a string or not."""
|
|
761
|
+
i, n = 0, len(text)
|
|
762
|
+
while i < n:
|
|
763
|
+
ch = text[i]
|
|
764
|
+
if ch == '"':
|
|
765
|
+
if in_string and text[i + 1 : i + 2] == '"':
|
|
766
|
+
i += 2
|
|
767
|
+
continue
|
|
768
|
+
in_string = not in_string
|
|
769
|
+
elif ch == "'" and not in_string:
|
|
770
|
+
return False, True
|
|
771
|
+
i += 1
|
|
772
|
+
return in_string, False
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def completion_candidates(
|
|
776
|
+
result, text: str, line: int, column: int, profile: str = "cr6", declared: Sequence[tuple] | None = None
|
|
777
|
+
) -> Completion:
|
|
778
|
+
"""Completion at 0-based physical `line` and decoded `column` of the buffer `text` (Latin-1
|
|
779
|
+
decoded, like the compiler). `result` is the last `analysis.AnalysisResult` or None; it may be
|
|
780
|
+
stale, it only supplies names and table fields. `declared` replaces the names read from the
|
|
781
|
+
program with `(name, kind, scope)` tuples (`declared_names` is the default).
|
|
782
|
+
|
|
783
|
+
Inside a string or after a `'` comment start there are no candidates. After `name.` the
|
|
784
|
+
candidates are the fields of the table called `name` (Status, Public, a DataTable, a
|
|
785
|
+
StructureType variable), none when the table is unknown. Otherwise: the program's names (the
|
|
786
|
+
locals and parameters only inside their own Sub / Function, found from `text`) and the
|
|
787
|
+
catalog's instructions, functions, symbols and keywords in the profile's spelling, matched
|
|
788
|
+
case-insensitively on the prefix. Program names come first, then alphabetically. At most
|
|
789
|
+
`COMPLETION_LIMIT` items are returned (declared names first); `is_incomplete` is then True."""
|
|
790
|
+
lines = source._split_physical_lines(text)
|
|
791
|
+
row = lines[line] if 0 <= line < len(lines) else ""
|
|
792
|
+
column = max(0, min(column, len(row)))
|
|
793
|
+
before = row[:column]
|
|
794
|
+
here = Span(line, column, line, column)
|
|
795
|
+
nothing = Completion((), here, "")
|
|
796
|
+
if _in_string_or_comment(lines, line, before):
|
|
797
|
+
return nothing
|
|
798
|
+
m = _PREFIX_RE.search(before)
|
|
799
|
+
prefix = m.group(0) if m else ""
|
|
800
|
+
start = column - len(prefix)
|
|
801
|
+
replace = Span(line, start, line, column)
|
|
802
|
+
base = _BASE_RE.search(before[:start])
|
|
803
|
+
if base is not None:
|
|
804
|
+
items = [i for i in _member_items(result, base.group(1), profile) if i.label.lower().startswith(prefix.lower())]
|
|
805
|
+
items.sort(key=lambda i: i.label.lower())
|
|
806
|
+
shown, incomplete = _limited(items)
|
|
807
|
+
return Completion(shown, replace, prefix, base.group(1), incomplete)
|
|
808
|
+
if before[:start].rstrip().endswith("."):
|
|
809
|
+
return Completion((), replace, prefix)
|
|
810
|
+
if declared is None:
|
|
811
|
+
declared = declared_names(getattr(result, "program", None), profile)
|
|
812
|
+
routine = None
|
|
813
|
+
for b in _scan(text, profile):
|
|
814
|
+
if b.family in _ROUTINES and b.name and b.start <= line and (b.end is None or line <= b.end):
|
|
815
|
+
routine = b.name.lower()
|
|
816
|
+
visible: dict[str, tuple[str, str, bool]] = {} # upper-case label -> routine-scoped wins
|
|
817
|
+
for n, k, s in declared:
|
|
818
|
+
if s is None or (routine is not None and s.lower() == routine):
|
|
819
|
+
if n.upper() not in visible or (s is not None and not visible[n.upper()][2]):
|
|
820
|
+
visible[n.upper()] = (n, k, s is not None)
|
|
821
|
+
program_items = [CompletionItem(n, k, "program") for n, k, _ in visible.values()]
|
|
822
|
+
program_items.sort(key=lambda i: (i.label.lower(), i.label))
|
|
823
|
+
lower = prefix.lower()
|
|
824
|
+
items = [i for i in program_items if i.label.lower().startswith(lower)]
|
|
825
|
+
items += [i for i in _catalog_items(profiles.get(profile).name) if i.label.lower().startswith(lower)]
|
|
826
|
+
shown, incomplete = _limited(items)
|
|
827
|
+
return Completion(shown, replace, prefix, None, incomplete)
|