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