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/rename.py ADDED
@@ -0,0 +1,662 @@
1
+ """Rename logic for editor tooling: `prepare_rename` and `rename` over the `AnalysisResult` of every
2
+ configured entry that depends on the file. Protocol independent (no pygls): positions in and out
3
+ are physical, a 0-based line and a decoded-character column (a byte offset into the line, as in
4
+ `positions.py`). See `docs/lsp-api.md` "Rename (LSP-07a)".
5
+
6
+ The edits come only from the binding index (`bindings`) and the ranges of `navigation`: the
7
+ declaration's range plus the range of every reference that resolves to it, in every entry. No
8
+ source text is searched to find occurrences. Anything that cannot be proven a single, fully
9
+ ranged binding is refused with a reason (`Refusal`), never guessed. A few conservative refusal
10
+ checks read identifiers of lines the parser did not turn into statements (inactive `#If`
11
+ branches, directives, text after `EndProg`) only to refuse; they never produce an edit.
12
+ """
13
+
14
+ import hashlib
15
+ import re
16
+ from collections import OrderedDict
17
+ from collections.abc import Sequence
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import NamedTuple
21
+
22
+ from . import bindings, lexer, navigation
23
+ from .catalog import InstructionCatalog
24
+ from .source import _strip_comment
25
+ from .sources import DiskSource, OverlaySource
26
+
27
+ __all__ = ["TextEdit", "Refusal", "PrepareResult", "RenameResult", "prepare_rename", "rename", "REASONS"]
28
+
29
+ REASONS = (
30
+ "no_results",
31
+ "no_ast",
32
+ "stale",
33
+ "index_failure",
34
+ "not_a_name",
35
+ "unresolved",
36
+ "ambiguous",
37
+ "unmodelled",
38
+ "external",
39
+ "quoted",
40
+ "kind",
41
+ "no_range",
42
+ "text_mismatch",
43
+ "occurrences_differ",
44
+ "entries_disagree",
45
+ "unparsed",
46
+ "unindexed_code",
47
+ "string_mention",
48
+ "table_field",
49
+ "unchanged",
50
+ "invalid_name",
51
+ "reserved_name",
52
+ "collision",
53
+ "overlap",
54
+ )
55
+
56
+ # Declaration kinds whose every use the index records. Labels are declared but their uses (`GoTo`)
57
+ # are not references, so they are not renamable.
58
+ _RENAMABLE = {
59
+ "public",
60
+ "dim",
61
+ "static",
62
+ "const",
63
+ "sub",
64
+ "function",
65
+ "param",
66
+ "alias",
67
+ "datatable",
68
+ "consttable",
69
+ "structuretype",
70
+ "objecttype",
71
+ "structure_member",
72
+ }
73
+ _VARIABLE = {"public", "dim", "static", "alias"}
74
+ # kinds whose names can become data table field names (`Sample(1, P, Pt)` names fields after members)
75
+ _FIELD_SOURCES = _VARIABLE | {"structure_member"}
76
+ _WORD = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\$?")
77
+ _STRING = re.compile(r'"(?:[^"]|"")*"?')
78
+
79
+
80
+ class TextEdit(NamedTuple):
81
+ line: int # physical, 0-based
82
+ start: int # decoded column, inclusive
83
+ end: int # decoded column, exclusive
84
+ new_text: str
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Refusal:
89
+ code: str # one of REASONS
90
+ message: str
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class PrepareResult:
95
+ ok: bool
96
+ refusal: Refusal | None = None
97
+ path: Path | None = None
98
+ line: int = 0
99
+ start: int = 0
100
+ end: int = 0
101
+ placeholder: str = "" # the name as spelled at the position
102
+ files: frozenset = frozenset() # every file with the declaration or a reference to it
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class RenameResult:
107
+ ok: bool
108
+ refusal: Refusal | None = None
109
+ edits: dict = field(default_factory=dict) # Path -> [TextEdit], sorted, no overlap
110
+ versions: dict = field(default_factory=dict) # Path -> sha256 hex of the bytes the edits fit
111
+
112
+
113
+ class _Refuse(Exception):
114
+ def __init__(self, code: str, message: str):
115
+ super().__init__(message)
116
+ self.refusal = Refusal(code, message)
117
+
118
+
119
+ # ------------------------------------------------------------------- per-result cache
120
+
121
+
122
+ class _State:
123
+ """Navigation and file facts of one `AnalysisResult`, computed once."""
124
+
125
+ def __init__(self, result):
126
+ self.result = result
127
+ self.nav = navigation.Navigation(result)
128
+ self._unindexed: dict | None = None
129
+ self._strings: dict[str, tuple[str, int]] | None = None
130
+ self._parsed: set[tuple[str, int]] | None = None
131
+ self._text: dict[Path, bytes] = {}
132
+
133
+ def data(self, path: Path) -> bytes:
134
+ got = self._text.get(path)
135
+ if got is None:
136
+ overlay = self.result.request.overlays.get(path)
137
+ got = overlay if overlay is not None else path.read_bytes()
138
+ self._text[path] = got
139
+ return got
140
+
141
+ def parses(self, path: Path, line: int) -> bool:
142
+ """Whether physical `line` (0-based) of `path` is in a statement of this program (not in an
143
+ `IncludeSection` that is not read, an inactive `#If` branch, a directive or text after
144
+ `EndProg`)."""
145
+ if self._parsed is None:
146
+ parsed = set()
147
+ program = self.result.program
148
+ paths = self.nav.occurrence_paths()
149
+ line_map = getattr(program, "line_map", None) or {}
150
+ for st in program.statements:
151
+ for sp in line_map.get(st.file, {}).get(st.line, ()):
152
+ p = paths.get(sp.occurrence)
153
+ if p is not None:
154
+ parsed.update((k, n - 1) for k in navigation.path_keys(p) for n in range(sp.first, sp.last + 1))
155
+ self._parsed = parsed
156
+ return any((k, line) in self._parsed for k in navigation.path_keys(path))
157
+
158
+ def unindexed(self) -> dict[str, list[tuple[Path, int, int, int]]]:
159
+ """Words of the physical lines that are in no statement (inactive `#If`, directives, text
160
+ after `EndProg`, includes): upper-case word -> [(path, 0-based line, start, end)]. Strings
161
+ and comments are blanked first."""
162
+ if self._unindexed is not None:
163
+ return self._unindexed
164
+ out: dict[str, list] = {}
165
+ done = set()
166
+ for path, n in self._unparsed_lines():
167
+ lines = self.data(path).decode("latin-1").replace("\r\n", "\n").split("\n")
168
+ if (path, n) in done or not 0 <= n < len(lines):
169
+ continue
170
+ done.add((path, n))
171
+ text = _STRING.sub(lambda m: " " * len(m.group()), _strip_comment(lines[n]))
172
+ for m in _WORD.finditer(text):
173
+ out.setdefault(m.group().upper(), []).append((path, n, m.start(), m.end()))
174
+ self._unindexed = out
175
+ return out
176
+
177
+ def string_words(self) -> dict[str, tuple[str, int]]:
178
+ """Identifier-shaped words inside string literals, upper-case -> (file, line) of one
179
+ occurrence: statements via the lexer, other code lines by their quotes. A string such as
180
+ `SetSetting("Table.Const", ...)` names a declaration; the index does not model that."""
181
+ if self._strings is not None:
182
+ return self._strings
183
+ out: dict[str, tuple[str, int]] = {}
184
+ for st in self.result.program.statements:
185
+ toks = bindings.classified_tokens(st)
186
+ for t, kind in toks or ():
187
+ if kind == "string":
188
+ for w in _WORD.findall(str(t.value)):
189
+ out.setdefault(w.upper(), (st.file, st.line))
190
+ for path, n in set(self._unparsed_lines()):
191
+ lines = self.data(path).decode("latin-1").replace("\r\n", "\n").split("\n")
192
+ if 0 <= n < len(lines):
193
+ for m in _STRING.finditer(_strip_comment(lines[n])):
194
+ for w in _WORD.findall(m.group()):
195
+ out.setdefault(w.upper(), (path.name, n + 1))
196
+ self._strings = out
197
+ return out
198
+
199
+ def _unparsed_lines(self):
200
+ program = self.result.program
201
+ covered = {(st.file, st.line) for st in program.statements}
202
+ paths = self.nav.occurrence_paths()
203
+ for file, by_line in (getattr(program, "line_map", None) or {}).items():
204
+ for line, spans in by_line.items():
205
+ if (file, line) in covered:
206
+ continue
207
+ for span in spans:
208
+ path = paths.get(span.occurrence)
209
+ if path is not None:
210
+ for n in range(span.first, span.last + 1):
211
+ yield path, n - 1
212
+
213
+
214
+ _states: OrderedDict[int, _State] = OrderedDict()
215
+
216
+
217
+ def _state(result) -> _State:
218
+ key = id(result)
219
+ st = _states.get(key)
220
+ if st is not None and st.result is result:
221
+ _states.move_to_end(key)
222
+ return st
223
+ st = _State(result)
224
+ _states[key] = st
225
+ while len(_states) > 64:
226
+ _states.popitem(last=False)
227
+ return st
228
+
229
+
230
+ # ------------------------------------------------------------------- one entry
231
+
232
+
233
+ class _Entry(NamedTuple):
234
+ state: _State
235
+ name: str # upper-case current name
236
+ targets: tuple # bindings.Declaration
237
+ ranges: frozenset # {(path, line, start, end)} to edit
238
+ refs: frozenset # id() of the references to edit
239
+ key: frozenset # the declaration set, comparable across entries
240
+
241
+
242
+ def _site_key(site) -> tuple:
243
+ return (site.path, site.line, site.start, site.end)
244
+
245
+
246
+ def _entry(state: _State, path: Path, line: int, col: int) -> tuple[_Entry, str, tuple]:
247
+ result, nav = state.result, state.nav
248
+ if getattr(result, "program", None) is None or nav.index is None:
249
+ raise _Refuse("no_ast", "the program could not be parsed")
250
+ ix = nav.index
251
+ if ix.failures:
252
+ raise _Refuse("index_failure", f"the binding index failed for a statement: {ix.failures[0].message}")
253
+ sites = nav.sites_at(path, line, col)
254
+ if not sites:
255
+ raise _Refuse("not_a_name", "the position is not on a name the program declares or uses")
256
+ targets: list[bindings.Declaration] = []
257
+ clicked = sites[0]
258
+ for s in sites:
259
+ rec = s.rec
260
+ if isinstance(rec, bindings.Declaration):
261
+ cands: Sequence = (rec,)
262
+ else:
263
+ status = rec.status
264
+ if status == "unmodelled":
265
+ raise _Refuse("unmodelled", f"{rec.name!r} is a table field name; field names are not modelled")
266
+ if status == "unresolved":
267
+ raise _Refuse("unresolved", f"{rec.name!r} does not resolve to a declaration")
268
+ if status == "ambiguous":
269
+ raise _Refuse(
270
+ "ambiguous", f"{rec.name!r} is ambiguous: {rec.ambiguity or 'several candidate declarations'}"
271
+ )
272
+ cands = rec.resolved
273
+ for d in cands:
274
+ if all(d is not o for o in targets):
275
+ targets.append(d)
276
+ if not targets:
277
+ raise _Refuse("not_a_name", "the position is not on a name the program declares or uses")
278
+ for d in targets:
279
+ if d.kind == "field": # `Tab.Field`, `Status.X`: field names are not modelled (LSP-05c)
280
+ raise _Refuse("unmodelled", f"{d.name!r} is a table field name; field names are not modelled")
281
+ if d.kind in ("external", "system_table") or d.scope == "predefined" or d.external is not None:
282
+ raise _Refuse("external", f"{d.name!r} is predefined (catalog or system table)")
283
+ if d.quoted:
284
+ raise _Refuse("quoted", f"table {d.name!r} is named by a string; string names are never rewritten")
285
+ if d.kind not in _RENAMABLE:
286
+ raise _Refuse("kind", f"a {d.kind} is not renamable")
287
+ if d.occurrence is None:
288
+ raise _Refuse("no_range", f"{d.name!r} has no token position")
289
+ names = {d.name.upper() for d in targets}
290
+ if len(names) != 1:
291
+ raise _Refuse("ambiguous", "the position resolves to differently named declarations")
292
+ name = names.pop()
293
+ placeholder = clicked.rec.name
294
+ if clicked.end - clicked.start != len(placeholder) or placeholder.upper() != name:
295
+ raise _Refuse("no_range", f"the range at the position is not the name {name!r}")
296
+ sets = []
297
+ ref_ids: set[int] = set()
298
+ for d in targets:
299
+ dsite = nav.site_of(d)
300
+ if dsite is None or dsite.path is None:
301
+ raise _Refuse("no_range", f"the declaration of {d.name!r} has no exact physical range")
302
+ rs = {_site_key(dsite)}
303
+ for r in ix.references_to(d):
304
+ if r.status != "resolved":
305
+ raise _Refuse(
306
+ "ambiguous",
307
+ f"a reference to {d.name!r} at {r.file}:{r.line} is "
308
+ f"{r.status}: {r.ambiguity or 'not a single proven target'}",
309
+ )
310
+ rsite = nav.site_of(r)
311
+ if rsite is None or rsite.path is None:
312
+ raise _Refuse(
313
+ "no_range", f"the reference to {d.name!r} at {r.file}:{r.line} has no exact physical range"
314
+ )
315
+ rs.add(_site_key(rsite))
316
+ ref_ids.add(id(r))
317
+ sets.append(frozenset(rs))
318
+ if any(s != sets[0] for s in sets):
319
+ raise _Refuse(
320
+ "occurrences_differ", f"{name!r} is declared by a file loaded more than once and the loads differ"
321
+ )
322
+ _check_ordinals(ix, targets)
323
+ for u in ix.unparsed:
324
+ if name in {t.upper for t in lexer_tokens(u)}:
325
+ raise _Refuse(
326
+ "unparsed", f"a statement the parser could not take apart ({u.file}:{u.line}) mentions {name!r}"
327
+ )
328
+ if targets[0].kind in _FIELD_SOURCES:
329
+ _check_field_names(ix, name)
330
+ key = frozenset((rk[0], rk[1], rk[2], rk[3], d.kind, name) for d in targets for rk in [_site_key(nav.site_of(d))])
331
+ return (
332
+ _Entry(state, name, tuple(targets), sets[0], frozenset(ref_ids), key),
333
+ placeholder,
334
+ (clicked.path, clicked.line, clicked.start, clicked.end),
335
+ )
336
+
337
+
338
+ def _check_ordinals(ix, targets) -> None:
339
+ """`token_of` picks a record's token by its ordinal among the same-name tokens of its unit, so a
340
+ token of that name the index has no record for (a name the parser skipped, such as the ignored
341
+ words of `Alias A = X junk, Y`) shifts every later ordinal onto the wrong token. In every unit an
342
+ edit falls in, the tokens of each edited name must not outnumber the records of it."""
343
+ seen: set[tuple] = set()
344
+ for d in targets:
345
+ for rec in (d, *ix.references_to(d)):
346
+ unit, key = rec.unit, rec.tok_key
347
+ if unit is None or not key or (id(unit), key) in seen:
348
+ continue
349
+ seen.add((id(unit), key))
350
+ toks = bindings.classified_tokens(unit) or ()
351
+ n_tok = sum(1 for t, k in toks if k == key[1] and bindings._tok_name(t, k) == key[0])
352
+ n_rec = sum(1 for o in ix.records_in(unit) if o.tok_key == key)
353
+ n_rec += sum(1 for k in bindings.skipped_keys(unit) if k == key)
354
+ if n_tok > n_rec:
355
+ raise _Refuse(
356
+ "no_range",
357
+ f"{unit.file}:{unit.line}: the statement spells "
358
+ f"{rec.name!r} {n_tok} times but the index records {n_rec}; the "
359
+ "token of each occurrence is not proven",
360
+ )
361
+
362
+
363
+ def lexer_tokens(stmt):
364
+ toks = bindings.classified_tokens(stmt)
365
+ if toks is not None:
366
+ return [t for t, _ in toks]
367
+ out = []
368
+ for w in _WORD.findall(_STRING.sub(" ", getattr(stmt, "text", "") or "")):
369
+ out.append(lexer.Token("IDENT", w, w.upper(), 0))
370
+ return out
371
+
372
+
373
+ def _check_field_names(ix, name: str) -> None:
374
+ """A variable's name can be a data table field name (`Sample`, `Average` -> `name_Avg`), so a
375
+ `Table.Field` use derived from the name would not follow a rename. Field names are not renamed
376
+ (`unmodelled`), so every table member use is checked, whether it is unmodelled or resolves to a
377
+ field declaration (LSP-05c)."""
378
+ for r in ix.references:
379
+ if r.role == bindings.MEMBER and any(c.kind == "datatable" for c in r.container):
380
+ up = r.name.upper()
381
+ if up == name or up.startswith(name + "_"):
382
+ raise _Refuse(
383
+ "table_field",
384
+ f"the table field use {r.file}:{r.line} "
385
+ f"({r.name!r}) may derive from {name!r}; field names are not modelled",
386
+ )
387
+
388
+
389
+ # ------------------------------------------------------------------- new name
390
+
391
+ _SYSTEM_NAMES = ("STATUS", "PUBLIC", "SETTINGS", "DATATABLEINFO")
392
+
393
+
394
+ def _check_new_name(entry: _Entry, new_name: str) -> None:
395
+ state = entry.state
396
+ ix = state.nav.index
397
+ assert ix is not None # an entry exists only for a program with an index
398
+ up = new_name.upper()
399
+ cat = InstructionCatalog.for_profile(state.result.request.logger)
400
+ if cat.get(new_name) is not None or up in _SYSTEM_NAMES or up in bindings._EXTRA_PREDEFINED:
401
+ raise _Refuse(
402
+ "reserved_name", f"{new_name!r} is a keyword, instruction, function or predefined symbol of {cat.profile}"
403
+ )
404
+ if up == entry.name:
405
+ return # a case-only change binds nothing new; the old name was checked in `_entry`
406
+ for u in ix.unparsed:
407
+ if up in {t.upper for t in lexer_tokens(u)}:
408
+ raise _Refuse(
409
+ "unparsed", f"a statement the parser could not take apart ({u.file}:{u.line}) mentions {new_name!r}"
410
+ )
411
+ ours = {id(d) for d in entry.targets}
412
+ for t in entry.targets:
413
+ for d in ix.declarations_named(up):
414
+ if id(d) in ours:
415
+ continue
416
+ if t.kind == "structure_member":
417
+ clash = d.kind == "structure_member" and d.container is t.container
418
+ elif d.kind == "structure_member":
419
+ clash = False
420
+ elif t.scope == "routine":
421
+ clash = d.scope == "global" or d.unverified_scope or (d.scope == "routine" and d.routine is t.routine)
422
+ else:
423
+ clash = True # a global is visible in every routine
424
+ if clash:
425
+ raise _Refuse("collision", f"{new_name!r} is already declared ({d.kind} at {d.file}:{d.line})")
426
+ for r in ix.references:
427
+ if r.name.upper() != up or id(r) in entry.refs:
428
+ continue
429
+ if r.role == bindings.MEMBER:
430
+ # a member use binds the new name when its container is the namespace of the target:
431
+ # the owning type, or the Public table for a Public variable or alias
432
+ clash = (t.container is not None and any(c is t.container for c in r.container)) or (
433
+ t.kind in ("public", "alias")
434
+ and any(c.kind == "system_table" and c.name.upper() == "PUBLIC" for c in r.container)
435
+ )
436
+ else:
437
+ clash = t.kind != "structure_member"
438
+ if clash:
439
+ raise _Refuse("collision", f"{new_name!r} is already used at {r.file}:{r.line} ({r.status})")
440
+ if entry.targets[0].kind in _FIELD_SOURCES:
441
+ _check_field_names(ix, up)
442
+ hit = state.string_words().get(up)
443
+ if hit is not None:
444
+ raise _Refuse("string_mention", f"{new_name!r} occurs inside a string literal ({hit[0]}:{hit[1]})")
445
+ for path, ln, _, _ in state.unindexed().get(up, ()):
446
+ raise _Refuse("unindexed_code", f"{new_name!r} occurs in code the index does not cover ({path.name}:{ln + 1})")
447
+
448
+
449
+ def _check_unchanged(entries: list[_Entry], new_name: str) -> None:
450
+ """`unchanged` only when every range to edit is already spelled `new_name` exactly; a case-only
451
+ change of any occurrence (`TOTAL` -> `total` for `Public total`) is a rename."""
452
+ if new_name.upper() != entries[0].name:
453
+ return
454
+ for e in entries:
455
+ for p, ln, a, b in e.ranges:
456
+ text = e.state.data(p).decode("latin-1").replace("\r\n", "\n").split("\n")
457
+ if not 0 <= ln < len(text) or text[ln][a:b] != new_name:
458
+ return
459
+ raise _Refuse("unchanged", "the new name equals the current name")
460
+
461
+
462
+ def _valid_identifier(new_name: str) -> bool:
463
+ m = _WORD.fullmatch(new_name)
464
+ return m is not None and not new_name.endswith("$")
465
+
466
+
467
+ # ------------------------------------------------------------------- API
468
+
469
+
470
+ class _Current:
471
+ """The bytes the files have now, read at the operation boundary (never the `_State` cache, which
472
+ serves only checks derived from the analysis). `source` is the caller's provider, else each
473
+ result's own overlays on the disk."""
474
+
475
+ def __init__(self, source):
476
+ self.source = source
477
+ self._own: dict[int, OverlaySource] = {}
478
+ self._bytes: dict[tuple, bytes | None] = {}
479
+
480
+ def _provider(self, result):
481
+ if self.source is not None:
482
+ return self.source
483
+ got = self._own.get(id(result))
484
+ if got is None:
485
+ got = self._own[id(result)] = OverlaySource(DiskSource(), result.request.overlays)
486
+ return got
487
+
488
+ def read(self, result, path) -> bytes | None:
489
+ key = (id(result) if self.source is None else 0, Path(path))
490
+ if key not in self._bytes:
491
+ self._bytes[key] = self._provider(result).read(Path(path))
492
+ return self._bytes[key]
493
+
494
+ def verify(self, result) -> None:
495
+ """Every file the analysis read, edited or not, must have the analysed bytes now (and a
496
+ missing one must still be missing): bindings and collisions depend on all of them."""
497
+ for q, analysed in result.dependencies.reads.items():
498
+ data = self.read(result, q)
499
+ now = None if data is None else hashlib.sha256(data).hexdigest()
500
+ if now != analysed:
501
+ raise _Refuse("stale", f"{Path(q).name} differs from the bytes the analysis read")
502
+
503
+
504
+ def _reads(result, path: Path) -> bool:
505
+ return any(Path(p) == path or _same(p, path) for p in result.dependencies.reads)
506
+
507
+
508
+ def _collect(results, path, line, col, current):
509
+ """The entries of `results` that the rename concerns: those that read `path` and parse `line`
510
+ (the clicked binding), then, until nothing changes, every other result that reads the
511
+ declaration's file or an edited file. Such a result is identified with the same declaration
512
+ by the declaration token's file and position; one that parses an edited line but not the
513
+ declaration is refused (`entries_disagree`), one that parses none of them is skipped."""
514
+ path = Path(path).absolute()
515
+ if not results:
516
+ raise _Refuse("no_results", "no analysis result was given")
517
+ entries: list[_Entry] = []
518
+ used: set[int] = set()
519
+ first = None
520
+ readers = 0
521
+ for r in results:
522
+ st = _state(r)
523
+ if not _reads(r, path):
524
+ continue # this entry does not depend on the file
525
+ readers += 1
526
+ if getattr(r, "program", None) is not None and not st.parses(path, line):
527
+ continue # the declaration is not in this entry's program
528
+ current.verify(r)
529
+ e, placeholder, rng = _entry(st, path, line, col)
530
+ if first is None:
531
+ first = (placeholder, rng)
532
+ entries.append(e)
533
+ used.add(id(r))
534
+ if not entries and readers:
535
+ raise _Refuse(
536
+ "not_a_name",
537
+ "the position is not in code any entry parses (a comment "
538
+ "line, an inactive #If branch, a directive or text after EndProg)",
539
+ )
540
+ if not entries:
541
+ raise _Refuse("no_results", "no given analysis depends on the file")
542
+ assert first is not None # set with the first entry
543
+ decl = min(entries[0].key, key=lambda k: (str(k[0]), k[1], k[2]))[:4]
544
+ grown = True
545
+ while grown: # entries reached through the declaration or an edit
546
+ grown = False
547
+ union = set().union(*(e.ranges for e in entries))
548
+ for r in results:
549
+ if id(r) in used:
550
+ continue
551
+ touched = [k for k in union | {decl} if _reads(r, k[0])]
552
+ if not touched:
553
+ continue
554
+ st = _state(r)
555
+ if getattr(r, "program", None) is not None:
556
+ if not _reads(r, decl[0]) or not st.parses(decl[0], decl[1]):
557
+ if any(st.parses(k[0], k[1]) for k in touched):
558
+ raise _Refuse(
559
+ "entries_disagree", "an entry uses an edited line but does not declare the target"
560
+ )
561
+ continue
562
+ current.verify(r)
563
+ e, _, _ = _entry(st, decl[0], decl[1], decl[2])
564
+ entries.append(e)
565
+ used.add(id(r))
566
+ grown = True
567
+ if any(e.key != entries[0].key or e.name != entries[0].name for e in entries):
568
+ raise _Refuse("entries_disagree", "the entries disagree on the declaration set")
569
+ for e in entries:
570
+ hit = e.state.string_words().get(e.name)
571
+ if hit is not None:
572
+ raise _Refuse(
573
+ "string_mention",
574
+ f"{e.name!r} occurs inside a string literal ({hit[0]}:{hit[1]}); names in strings are never rewritten",
575
+ )
576
+ union = set().union(*(e.ranges for e in entries))
577
+ for e in entries: # the old name in code no entry indexes cannot be renamed
578
+ for p, ln, a, b in e.state.unindexed().get(e.name, ()):
579
+ if (p, ln, a, b) not in union:
580
+ raise _Refuse(
581
+ "unindexed_code",
582
+ f"{e.name!r} occurs in code the index does not "
583
+ f"cover ({p.name}:{ln + 1}: inactive #If branch, directive or text "
584
+ "after EndProg)",
585
+ )
586
+ return entries, first
587
+
588
+
589
+ def _same(a, b) -> bool:
590
+ try:
591
+ return Path(a).resolve() == Path(b).resolve()
592
+ except OSError:
593
+ return False
594
+
595
+
596
+ def prepare_rename(results, path, line: int, column: int, source=None) -> PrepareResult:
597
+ """The range and current name at (`line`, `column`) when a rename of it can be offered, else
598
+ the reason it cannot. `results`: the `AnalysisResult` of every configured entry that depends
599
+ on `path`. `source`: optional `SourceProvider` giving the current bytes of every file (default:
600
+ each result's overlays on the disk)."""
601
+ try:
602
+ current = _Current(source)
603
+ entries, (placeholder, rng) = _collect(results, path, line, column, current)
604
+ _versions(entries, {rng[0]}, current)
605
+ except _Refuse as e:
606
+ return PrepareResult(False, e.refusal)
607
+ p, ln, a, b = rng
608
+ files = frozenset(k[0] for e in entries for k in e.ranges)
609
+ return PrepareResult(True, None, p, ln, a, b, placeholder, files)
610
+
611
+
612
+ def rename(results, path, line: int, column: int, new_name: str, source=None) -> RenameResult:
613
+ """Edits renaming the declaration at (`line`, `column`) and every reference to it, across
614
+ every entry in `results`, or a `Refusal`. Edits are deduplicated; `versions` maps each edited
615
+ path to the sha256 of the bytes the edits were computed against (equal in every entry). Every
616
+ file any entry read must still have the analysed bytes now, read through `source` (optional
617
+ `SourceProvider`; default each result's overlays on the disk), else `stale`."""
618
+ try:
619
+ current = _Current(source)
620
+ entries, (placeholder, rng) = _collect(results, path, line, column, current)
621
+ if not _valid_identifier(new_name):
622
+ raise _Refuse("invalid_name", f"{new_name!r} is not a valid identifier")
623
+ _check_unchanged(entries, new_name)
624
+ for e in entries:
625
+ _check_new_name(e, new_name)
626
+ ranges = sorted(set().union(*(e.ranges for e in entries)), key=lambda k: (str(k[0]), k[1], k[2]))
627
+ versions = _versions(entries, {k[0] for k in ranges}, current)
628
+ edits: dict[Path, list[TextEdit]] = {}
629
+ for p, ln, a, b in ranges:
630
+ data = current.read(entries[0].state.result, p)
631
+ if data is None:
632
+ raise _Refuse("stale", f"{p.name} can no longer be read")
633
+ text = data.decode("latin-1").replace("\r\n", "\n").split("\n")[ln]
634
+ if text[a:b].upper() != entries[0].name:
635
+ raise _Refuse(
636
+ "text_mismatch", f"{p.name}:{ln + 1}: the text at the range is {text[a:b]!r}, not the name"
637
+ )
638
+ lst = edits.setdefault(p, [])
639
+ if lst and lst[-1].line == ln and lst[-1].end > a:
640
+ raise _Refuse("overlap", f"{p.name}:{ln + 1}: edits overlap")
641
+ lst.append(TextEdit(ln, a, b, new_name))
642
+ return RenameResult(True, None, edits, versions)
643
+ except _Refuse as e:
644
+ return RenameResult(False, e.refusal)
645
+
646
+
647
+ def _versions(entries: list[_Entry], paths, current: _Current) -> dict:
648
+ """sha256 of the current bytes of each edited path; `_Current.verify` has already shown that
649
+ they are what every entry that read the path analysed."""
650
+ out = {}
651
+ for p in sorted(paths, key=str):
652
+ for e in entries:
653
+ r = e.state.result
654
+ if not any(Path(q) == p or _same(q, p) for q in r.dependencies.reads):
655
+ continue # this entry did not read the file
656
+ data = current.read(r, p)
657
+ digest = None if data is None else hashlib.sha256(data).hexdigest()
658
+ if digest is None or out.setdefault(p, digest) != digest:
659
+ raise _Refuse("stale", f"{p.name} differs between entries")
660
+ if p not in out:
661
+ raise _Refuse("stale", f"{p.name} was not read by any analysis")
662
+ return out