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/inlay.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Parameter-name inlay hints, independent of any language-server protocol.
|
|
2
|
+
|
|
3
|
+
A hint is drawn before each positional argument of an instruction or builtin-function call, using
|
|
4
|
+
the parameter names `hover.signature_help` shows. Positions are physical: 0-based line and
|
|
5
|
+
decoded-character column (`docs/lsp-api.md` "Source positions"); the server converts encodings.
|
|
6
|
+
See `docs/lsp-api.md` "Inlay hints (LSP-09a)".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import lexer, positions
|
|
14
|
+
from . import syntax as S
|
|
15
|
+
from .hover import declarations, signature_help
|
|
16
|
+
from .navigation import path_keys
|
|
17
|
+
|
|
18
|
+
_UNITS_STATEMENT = re.compile(r"\s*Units\s+\w", re.I)
|
|
19
|
+
|
|
20
|
+
__all__ = ["InlayHint", "parameter_hints"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class InlayHint:
|
|
25
|
+
line: int # 0-based physical line
|
|
26
|
+
column: int # decoded-character column of the argument's first character
|
|
27
|
+
label: str # `Name:`
|
|
28
|
+
kind: str = "parameter"
|
|
29
|
+
padding_right: bool = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Frame:
|
|
33
|
+
__slots__ = ("sig", "index", "first", "last")
|
|
34
|
+
|
|
35
|
+
def __init__(self, sig):
|
|
36
|
+
self.sig = sig # SignatureInfo of a call frame; None for grouping `(` and `{`
|
|
37
|
+
self.index = 0
|
|
38
|
+
self.first = None # first and last token of the current argument
|
|
39
|
+
self.last = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _occurrence_paths(program, result) -> dict[int, Path | None]:
|
|
43
|
+
paths = {o.id: o.path for o in getattr(program, "occurrences", None) or []}
|
|
44
|
+
if paths.get(0) is None:
|
|
45
|
+
paths[0] = Path(result.request.entry).absolute()
|
|
46
|
+
return paths
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _shift(unit, ll, seg: int) -> int | None:
|
|
50
|
+
"""Offset of `unit.text` within `ll.text[seg:]` (a `Units` text is stripped), or None."""
|
|
51
|
+
tail = ll.text[seg:]
|
|
52
|
+
for cand in (0, len(tail) - len(tail.lstrip())):
|
|
53
|
+
if ll.text.startswith(unit.text, seg + cand):
|
|
54
|
+
return cand
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parameter_hints(result, path, start_line: int, end_line: int, profile, help_provider=None) -> list[InlayHint]:
|
|
59
|
+
"""Hints for the calls of `path` (a file of the analysis) on physical lines
|
|
60
|
+
`start_line..end_line`, both 0-based and inclusive, sorted by position.
|
|
61
|
+
|
|
62
|
+
A hint needs the call's help names to pair with the catalog positions (`SignatureInfo.paired`),
|
|
63
|
+
so none appear without a help provider. No hint for an empty argument, for an argument whose
|
|
64
|
+
text already equals the parameter name (case-insensitive), past the parameter count, or when the
|
|
65
|
+
argument's first character has no exact single-piece physical position. Never raises."""
|
|
66
|
+
try:
|
|
67
|
+
return _hints(result, path, start_line, end_line, profile, help_provider)
|
|
68
|
+
except Exception:
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _hints(result, path, start_line, end_line, profile, help_provider) -> list[InlayHint]:
|
|
73
|
+
program = getattr(result, "program", None)
|
|
74
|
+
statements = getattr(program, "statements", None)
|
|
75
|
+
if not statements or help_provider is None:
|
|
76
|
+
return []
|
|
77
|
+
keys = path_keys(path)
|
|
78
|
+
paths = _occurrence_paths(program, result)
|
|
79
|
+
declared = declarations(program)
|
|
80
|
+
sigs: dict[tuple[str, bool], object] = {}
|
|
81
|
+
found: dict[tuple[int, int], InlayHint] = {}
|
|
82
|
+
|
|
83
|
+
def signature(name: str, instruction: bool):
|
|
84
|
+
key = (name.upper(), instruction)
|
|
85
|
+
if key not in sigs:
|
|
86
|
+
sig = None if name.upper() in declared else signature_help(name, 0, profile, help_provider)
|
|
87
|
+
if sig is not None and (sig.kind == "instruction") != instruction:
|
|
88
|
+
sig = None
|
|
89
|
+
sigs[key] = sig if sig is not None and sig.paired and sig.parameters else None
|
|
90
|
+
return sigs[key]
|
|
91
|
+
|
|
92
|
+
for stmt in statements:
|
|
93
|
+
text = getattr(stmt, "text", "")
|
|
94
|
+
origin = getattr(stmt, "origin", None)
|
|
95
|
+
if origin is None or "(" not in text or _UNITS_STATEMENT.match(text):
|
|
96
|
+
continue
|
|
97
|
+
ll, seg = origin
|
|
98
|
+
shift = _shift(stmt, ll, seg)
|
|
99
|
+
if shift is None:
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
toks = lexer.tokenize(text, stmt.line)
|
|
103
|
+
except lexer.LexError:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
def emit(first, last, frame) -> None:
|
|
107
|
+
if first is None or frame.index >= len(frame.sig.parameters):
|
|
108
|
+
return
|
|
109
|
+
label = frame.sig.parameters[frame.index].label
|
|
110
|
+
written = text[first.col : last.col + len(last.text)]
|
|
111
|
+
if written.strip().lower() == label.lower():
|
|
112
|
+
return
|
|
113
|
+
start = seg + shift + first.col
|
|
114
|
+
r = positions.exact_range(ll, start, start + len(first.text))
|
|
115
|
+
if r is None or r.end_col <= r.start_col or not start_line <= r.line - 1 <= end_line:
|
|
116
|
+
return
|
|
117
|
+
p = paths.get(r.occurrence)
|
|
118
|
+
if p is None or not (path_keys(p) & keys):
|
|
119
|
+
return
|
|
120
|
+
found.setdefault((r.line - 1, r.start_col), InlayHint(r.line - 1, r.start_col, label + ":"))
|
|
121
|
+
|
|
122
|
+
stack: list[_Frame] = []
|
|
123
|
+
for i, t in enumerate(toks):
|
|
124
|
+
if t.kind == "EOF":
|
|
125
|
+
break
|
|
126
|
+
top = stack[-1] if stack else None
|
|
127
|
+
closing = t.kind == "OP" and t.text in (")", "}")
|
|
128
|
+
comma = t.kind == "OP" and t.text == ","
|
|
129
|
+
if top is not None and top.sig is not None:
|
|
130
|
+
if comma:
|
|
131
|
+
emit(top.first, top.last, top)
|
|
132
|
+
top.index += 1
|
|
133
|
+
top.first = top.last = None
|
|
134
|
+
continue
|
|
135
|
+
if closing:
|
|
136
|
+
emit(top.first, top.last, top)
|
|
137
|
+
stack.pop()
|
|
138
|
+
# the closing token belongs to the enclosing argument
|
|
139
|
+
if stack and stack[-1].sig is not None:
|
|
140
|
+
_extend(stack[-1], t)
|
|
141
|
+
continue
|
|
142
|
+
_extend(top, t)
|
|
143
|
+
elif closing and top is not None:
|
|
144
|
+
stack.pop()
|
|
145
|
+
if stack and stack[-1].sig is not None:
|
|
146
|
+
_extend(stack[-1], t)
|
|
147
|
+
continue
|
|
148
|
+
if t.kind == "OP" and t.text in ("(", "{"):
|
|
149
|
+
sig = None
|
|
150
|
+
if (
|
|
151
|
+
t.text == "("
|
|
152
|
+
and i
|
|
153
|
+
and toks[i - 1].kind == "IDENT"
|
|
154
|
+
and not (i > 1 and toks[i - 2].kind == "OP" and toks[i - 2].text == ".")
|
|
155
|
+
):
|
|
156
|
+
name = toks[i - 1]
|
|
157
|
+
first_in_stmt = i == 1 and isinstance(stmt, S.Instruction) and stmt.keyword == name.upper
|
|
158
|
+
sig = signature(name.text, first_in_stmt)
|
|
159
|
+
stack.append(_Frame(sig))
|
|
160
|
+
for frame in stack: # an unclosed call ends at the end of the line
|
|
161
|
+
if frame.sig is not None:
|
|
162
|
+
emit(frame.first, frame.last, frame)
|
|
163
|
+
return sorted(found.values(), key=lambda h: (h.line, h.column))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _extend(frame: _Frame, tok) -> None:
|
|
167
|
+
if frame.first is None:
|
|
168
|
+
frame.first = tok
|
|
169
|
+
frame.last = tok
|
opencrbasic/lexer.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Tokenizer for one CRBasic logical line.
|
|
2
|
+
|
|
3
|
+
`source.py` hands this module already-joined, comment-stripped logical lines (continuation
|
|
4
|
+
lines merged, in-string content untouched). This module never sees raw physical files.
|
|
5
|
+
|
|
6
|
+
Token kinds: ``IDENT`` (identifier or case-insensitive keyword), ``NUMBER``, ``STRING``,
|
|
7
|
+
``OP`` (operator/punctuation, including compound assignments and ``#``), ``EOF``.
|
|
8
|
+
|
|
9
|
+
Numbers: decimal integers and floats (with optional exponent), and ``&H..``/``&B..`` integer
|
|
10
|
+
literals. Identifiers: letters, digits, ``_`` (leading ``_`` allowed, e.g. ``_60Hz``), with an
|
|
11
|
+
optional single trailing ``$`` (e.g. ``CHR$``). Strings: ``"..."`` with ``""`` as an escaped
|
|
12
|
+
quote. Every token keeps its original spelling (`text`), an upper-cased form for
|
|
13
|
+
case-insensitive matching (`upper`), and its `col` (0-based offset into the logical line's
|
|
14
|
+
joined text) and `line` (the physical source line supplied by the caller -- see docs/frontend.md
|
|
15
|
+
for why CR6Comp reports the *last* physical line of a continued statement, which is what
|
|
16
|
+
`source.py` assigns as the logical line's `line`).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
# Longest operators first so the scanner prefers the multi-character spelling.
|
|
23
|
+
_OPERATORS = [
|
|
24
|
+
"<>",
|
|
25
|
+
"<=",
|
|
26
|
+
">=",
|
|
27
|
+
"<<",
|
|
28
|
+
">>",
|
|
29
|
+
"+=",
|
|
30
|
+
"-=",
|
|
31
|
+
"*=",
|
|
32
|
+
"/=",
|
|
33
|
+
"\\=",
|
|
34
|
+
"^=",
|
|
35
|
+
"&=",
|
|
36
|
+
"+",
|
|
37
|
+
"-",
|
|
38
|
+
"*",
|
|
39
|
+
"/",
|
|
40
|
+
"\\",
|
|
41
|
+
"^",
|
|
42
|
+
"&",
|
|
43
|
+
"=",
|
|
44
|
+
"<",
|
|
45
|
+
">",
|
|
46
|
+
"(",
|
|
47
|
+
")",
|
|
48
|
+
",",
|
|
49
|
+
".",
|
|
50
|
+
":",
|
|
51
|
+
";",
|
|
52
|
+
"{",
|
|
53
|
+
"}",
|
|
54
|
+
"#",
|
|
55
|
+
"!",
|
|
56
|
+
"@", # pointer variables: `Type!` declarations, `!x` dereference, `@x` address-of
|
|
57
|
+
]
|
|
58
|
+
# Sort by length desc so the regex alternation tries multi-char spellings first.
|
|
59
|
+
_OPERATORS.sort(key=len, reverse=True)
|
|
60
|
+
_OP_RE = re.compile("|".join(re.escape(op) for op in _OPERATORS))
|
|
61
|
+
|
|
62
|
+
_IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\$?")
|
|
63
|
+
_HEX_RE = re.compile(r"&[Hh][0-9A-Fa-f]+")
|
|
64
|
+
_BIN_RE = re.compile(r"&[Bb][01]+")
|
|
65
|
+
# Exponent is optional; the decimal point may lead with no integer part (`.111111`, confirmed
|
|
66
|
+
# valid CRBasic by corpus testing -- addprecise_1.CR6 -- see docs/frontend.md) or have no
|
|
67
|
+
# fractional part at all.
|
|
68
|
+
_NUMBER_RE = re.compile(r"(\d+\.\d*|\.\d+|\d+)([Ee][+-]?\d+)?")
|
|
69
|
+
|
|
70
|
+
KEYWORD_KINDS = {"IDENT"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Token:
|
|
75
|
+
kind: str # "IDENT" | "NUMBER" | "STRING" | "OP" | "EOF"
|
|
76
|
+
text: str # original spelling
|
|
77
|
+
upper: str # case-folded form (identifiers/keywords only; same as text otherwise)
|
|
78
|
+
col: int # 0-based offset into the logical line's joined text
|
|
79
|
+
line: int = 0 # physical source line (assigned by the caller)
|
|
80
|
+
value: object = None # NUMBER: int|float; STRING: decoded contents (quotes/escapes resolved)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LexError(ValueError):
|
|
84
|
+
def __init__(self, message: str, col: int):
|
|
85
|
+
super().__init__(message)
|
|
86
|
+
self.message = message
|
|
87
|
+
self.col = col
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _number_value(text: str) -> int | float:
|
|
91
|
+
if text[0] == "&":
|
|
92
|
+
base = 16 if text[1] in "Hh" else 2
|
|
93
|
+
return int(text[2:], base)
|
|
94
|
+
if "." in text or "e" in text or "E" in text:
|
|
95
|
+
return float(text)
|
|
96
|
+
return int(text)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def tokenize(text: str, line: int = 0) -> list[Token]:
|
|
100
|
+
"""Tokenize one already-joined logical line. Raises `LexError` on an unrecognised character."""
|
|
101
|
+
tokens: list[Token] = []
|
|
102
|
+
i = 0
|
|
103
|
+
n = len(text)
|
|
104
|
+
while i < n:
|
|
105
|
+
ch = text[i]
|
|
106
|
+
if ch in " \t":
|
|
107
|
+
i += 1
|
|
108
|
+
continue
|
|
109
|
+
if ch == '"':
|
|
110
|
+
start = i
|
|
111
|
+
i += 1
|
|
112
|
+
out = []
|
|
113
|
+
while True:
|
|
114
|
+
if i >= n:
|
|
115
|
+
raise LexError("unterminated string", start)
|
|
116
|
+
if text[i] == '"':
|
|
117
|
+
if i + 1 < n and text[i + 1] == '"':
|
|
118
|
+
out.append('"')
|
|
119
|
+
i += 2
|
|
120
|
+
continue
|
|
121
|
+
i += 1
|
|
122
|
+
break
|
|
123
|
+
out.append(text[i])
|
|
124
|
+
i += 1
|
|
125
|
+
raw = text[start:i]
|
|
126
|
+
tokens.append(Token("STRING", raw, raw, start, line, "".join(out)))
|
|
127
|
+
continue
|
|
128
|
+
m = _HEX_RE.match(text, i) or _BIN_RE.match(text, i)
|
|
129
|
+
if m:
|
|
130
|
+
raw = m.group(0)
|
|
131
|
+
tokens.append(Token("NUMBER", raw, raw.upper(), i, line, _number_value(raw)))
|
|
132
|
+
i = m.end()
|
|
133
|
+
continue
|
|
134
|
+
if (
|
|
135
|
+
ch == "."
|
|
136
|
+
and i + 1 < n
|
|
137
|
+
and text[i + 1].isdigit()
|
|
138
|
+
and tokens
|
|
139
|
+
and tokens[-1].kind == "IDENT"
|
|
140
|
+
and text[i - 1].isalnum()
|
|
141
|
+
or (
|
|
142
|
+
ch == "."
|
|
143
|
+
and i + 1 < n
|
|
144
|
+
and text[i + 1].isdigit()
|
|
145
|
+
and tokens
|
|
146
|
+
and tokens[-1].kind == "IDENT"
|
|
147
|
+
and text[i - 1] == "_"
|
|
148
|
+
)
|
|
149
|
+
):
|
|
150
|
+
tokens.append(Token("OP", ".", ".", i, line)) # `Tmp.001`: a member name, not a number
|
|
151
|
+
m = re.compile(r"\w+").match(text, i + 1)
|
|
152
|
+
assert m is not None # (a digit follows the `.`)
|
|
153
|
+
tokens.append(Token("IDENT", m.group(0), m.group(0).upper(), i + 1, line))
|
|
154
|
+
i = m.end()
|
|
155
|
+
continue
|
|
156
|
+
if ch.isdigit() or (ch == "." and i + 1 < n and text[i + 1].isdigit()):
|
|
157
|
+
m = _NUMBER_RE.match(text, i)
|
|
158
|
+
assert m is not None # (a digit, or `.` and a digit)
|
|
159
|
+
raw = m.group(0)
|
|
160
|
+
value = _number_value(raw)
|
|
161
|
+
end = m.end()
|
|
162
|
+
if text[end : end + 1] in ("R", "r") and not (
|
|
163
|
+
text[end + 1 : end + 2].isalnum() or text[end + 1 : end + 2] == "_"
|
|
164
|
+
):
|
|
165
|
+
end += 1 # `2.15R`: a double-precision literal (oracle, CR6/CR1000X, 2026-10-10)
|
|
166
|
+
tokens.append(Token("NUMBER", text[i:end], text[i:end], i, line, value))
|
|
167
|
+
i = end
|
|
168
|
+
continue
|
|
169
|
+
m = _IDENT_RE.match(text, i)
|
|
170
|
+
if m:
|
|
171
|
+
raw = m.group(0)
|
|
172
|
+
tokens.append(Token("IDENT", raw, raw.upper(), i, line))
|
|
173
|
+
i = m.end()
|
|
174
|
+
continue
|
|
175
|
+
m = _OP_RE.match(text, i)
|
|
176
|
+
if m:
|
|
177
|
+
raw = m.group(0)
|
|
178
|
+
tokens.append(Token("OP", raw, raw, i, line))
|
|
179
|
+
i = m.end()
|
|
180
|
+
continue
|
|
181
|
+
raise LexError(f"unrecognised character {ch!r}", i)
|
|
182
|
+
tokens.append(Token("EOF", "", "", n, line))
|
|
183
|
+
return tokens
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""Language server for CRBasic (LSP-01b). Protocol adapter only: no language rules live here.
|
|
2
|
+
|
|
3
|
+
This package's `__init__` must stay free of third-party imports: analysis worker processes import
|
|
4
|
+
`opencrbasic.lsp.worker` and must not load pygls. See `docs/lsp-api.md` "Language server".
|
|
5
|
+
"""
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Server configuration (`opencrbasic` section / initializationOptions) and entry discovery."""
|
|
2
|
+
|
|
3
|
+
import codecs
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .. import profiles
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Config:
|
|
16
|
+
entries: tuple[str, ...] = ("**/*.CR6",)
|
|
17
|
+
exclude: tuple[str, ...] = ()
|
|
18
|
+
logger: str = "cr6"
|
|
19
|
+
encoding: str = "utf-8"
|
|
20
|
+
debounce_ms: int = 300
|
|
21
|
+
analysis_timeout_ms: int = 10000
|
|
22
|
+
help_directory: str = "" # "" : no help text
|
|
23
|
+
feature_wait_ms: int = 1500 # bounded wait for a fresh analysis (docs/lsp-api.md)
|
|
24
|
+
max_file_bytes: int = 2 * 1024 * 1024 # larger files are not analysed (docs/lsp-api.md "Bounds")
|
|
25
|
+
max_queued_jobs: int = 64 # entries with an analysis task at once; the rest wait
|
|
26
|
+
max_cache_mb: int = 256 # parsed programs kept for the language features
|
|
27
|
+
inlay_parameter_names: bool = True # `inlayHints.parameterNames`
|
|
28
|
+
checks: tuple[tuple[str, str], ...] = () # `checks`: severity per check code (LSP layer)
|
|
29
|
+
coverage_notices: bool = False # `coverageNotices`: publish coverage notices as hints
|
|
30
|
+
|
|
31
|
+
def analysis_key(self) -> tuple[str, str, int, tuple[tuple[str, str], ...], bool]:
|
|
32
|
+
"""What changes every entry's analysis input or outcome."""
|
|
33
|
+
return (self.logger, self.encoding, self.max_file_bytes, self.checks, self.coverage_notices)
|
|
34
|
+
|
|
35
|
+
def entry_suffixes(self) -> frozenset[str]:
|
|
36
|
+
"""Extensions (lower case) that make an opened file an entry program."""
|
|
37
|
+
return frozenset({".cr6"} | {ext for ext, name in profiles.EXTENSIONS.items() if name == self.logger})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _str_list(value: Any) -> tuple[str, ...] | None:
|
|
41
|
+
if isinstance(value, (list, tuple)) and all(isinstance(v, str) for v in value):
|
|
42
|
+
return tuple(value)
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _int(value: Any, minimum: int) -> int | None:
|
|
47
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
48
|
+
return None
|
|
49
|
+
value = int(value)
|
|
50
|
+
return value if value >= minimum else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_config(options: Any, base: Config | None = None) -> tuple[Config, list[str]]:
|
|
54
|
+
"""`base` overlaid with the recognised, valid keys of `options`. Returns the config and one
|
|
55
|
+
warning per rejected value (the base value is kept)."""
|
|
56
|
+
cfg = base or Config()
|
|
57
|
+
warnings: list[str] = []
|
|
58
|
+
if not isinstance(options, Mapping):
|
|
59
|
+
return cfg, warnings
|
|
60
|
+
changes: dict[str, Any] = {}
|
|
61
|
+
for key, attr in (("entries", "entries"), ("exclude", "exclude")):
|
|
62
|
+
if key in options:
|
|
63
|
+
value = _str_list(options[key])
|
|
64
|
+
if value is None:
|
|
65
|
+
warnings.append(f"ignoring {key!r}: expected a list of strings")
|
|
66
|
+
else:
|
|
67
|
+
changes[attr] = value
|
|
68
|
+
if "logger" in options:
|
|
69
|
+
value = options["logger"]
|
|
70
|
+
if isinstance(value, str) and value in profiles.PROFILES:
|
|
71
|
+
changes["logger"] = value
|
|
72
|
+
else:
|
|
73
|
+
warnings.append(f"ignoring 'logger': expected one of {', '.join(profiles.PROFILES)}")
|
|
74
|
+
if "encoding" in options:
|
|
75
|
+
value = options["encoding"]
|
|
76
|
+
try:
|
|
77
|
+
if not isinstance(value, str):
|
|
78
|
+
raise LookupError(value)
|
|
79
|
+
codecs.lookup(value)
|
|
80
|
+
changes["encoding"] = value
|
|
81
|
+
except LookupError:
|
|
82
|
+
warnings.append(f"ignoring 'encoding': unknown encoding {value!r}")
|
|
83
|
+
for key, attr, minimum in (
|
|
84
|
+
("debounceMs", "debounce_ms", 0),
|
|
85
|
+
("analysisTimeoutMs", "analysis_timeout_ms", 1),
|
|
86
|
+
("featureWaitMs", "feature_wait_ms", 0),
|
|
87
|
+
("maxFileBytes", "max_file_bytes", 1),
|
|
88
|
+
("maxQueuedJobs", "max_queued_jobs", 1),
|
|
89
|
+
("maxCacheMb", "max_cache_mb", 1),
|
|
90
|
+
):
|
|
91
|
+
if key in options:
|
|
92
|
+
value = _int(options[key], minimum)
|
|
93
|
+
if value is None:
|
|
94
|
+
warnings.append(f"ignoring {key!r}: expected an integer >= {minimum}")
|
|
95
|
+
else:
|
|
96
|
+
changes[attr] = value
|
|
97
|
+
nested = options.get("inlayHints")
|
|
98
|
+
flat = "inlayHints.parameterNames"
|
|
99
|
+
if isinstance(nested, Mapping) and "parameterNames" in nested:
|
|
100
|
+
present, value = True, nested["parameterNames"]
|
|
101
|
+
else:
|
|
102
|
+
present, value = flat in options, options.get(flat)
|
|
103
|
+
if present:
|
|
104
|
+
if isinstance(value, bool):
|
|
105
|
+
changes["inlay_parameter_names"] = value
|
|
106
|
+
else:
|
|
107
|
+
warnings.append("ignoring 'inlayHints.parameterNames': expected a boolean")
|
|
108
|
+
if "checks" in options:
|
|
109
|
+
from .. import checkconfig, checks
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
changes["checks"] = tuple(
|
|
113
|
+
sorted(checkconfig._severities(options["checks"], checks.DEFAULT_SEVERITY, "'checks'").items())
|
|
114
|
+
)
|
|
115
|
+
except checkconfig.ConfigError as e:
|
|
116
|
+
warnings.append(f"ignoring {e}")
|
|
117
|
+
if "coverageNotices" in options:
|
|
118
|
+
if isinstance(options["coverageNotices"], bool):
|
|
119
|
+
changes["coverage_notices"] = options["coverageNotices"]
|
|
120
|
+
else:
|
|
121
|
+
warnings.append("ignoring 'coverageNotices': expected a boolean")
|
|
122
|
+
if "helpDirectory" in options:
|
|
123
|
+
value = options["helpDirectory"]
|
|
124
|
+
if value is None or isinstance(value, str):
|
|
125
|
+
changes["help_directory"] = (value or "").strip()
|
|
126
|
+
else:
|
|
127
|
+
warnings.append("ignoring 'helpDirectory': expected a string")
|
|
128
|
+
return (Config(**{**cfg.__dict__, **changes}) if changes else cfg), warnings
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def glob_regex(pattern: str) -> re.Pattern[str]:
|
|
132
|
+
"""Case-insensitive regex for a workspace-relative glob: `*`, `?`, `**`, `**/`, `{a,b}`."""
|
|
133
|
+
pat = pattern.replace("\\", "/")
|
|
134
|
+
while pat.startswith("./"):
|
|
135
|
+
pat = pat[2:]
|
|
136
|
+
out: list[str] = []
|
|
137
|
+
i = 0
|
|
138
|
+
depth = 0
|
|
139
|
+
while i < len(pat):
|
|
140
|
+
c = pat[i]
|
|
141
|
+
if pat.startswith("**/", i):
|
|
142
|
+
out.append("(?:.*/)?")
|
|
143
|
+
i += 3
|
|
144
|
+
continue
|
|
145
|
+
if pat.startswith("**", i):
|
|
146
|
+
out.append(".*")
|
|
147
|
+
i += 2
|
|
148
|
+
continue
|
|
149
|
+
if c == "*":
|
|
150
|
+
out.append("[^/]*")
|
|
151
|
+
elif c == "?":
|
|
152
|
+
out.append("[^/]")
|
|
153
|
+
elif c == "{":
|
|
154
|
+
out.append("(?:")
|
|
155
|
+
depth += 1
|
|
156
|
+
elif c == "}" and depth:
|
|
157
|
+
out.append(")")
|
|
158
|
+
depth -= 1
|
|
159
|
+
elif c == "," and depth:
|
|
160
|
+
out.append("|")
|
|
161
|
+
else:
|
|
162
|
+
out.append(re.escape(c))
|
|
163
|
+
i += 1
|
|
164
|
+
out.append(")" * depth)
|
|
165
|
+
return re.compile("".join(out), re.IGNORECASE | re.DOTALL)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_SKIP_DIRS = {"node_modules", "__pycache__"}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def discover(folders: list[Path], entries: tuple[str, ...], exclude: tuple[str, ...]) -> list[Path]:
|
|
172
|
+
"""Files under `folders` matching an `entries` glob and no `exclude` glob (workspace-relative,
|
|
173
|
+
case-insensitive). Directories starting with `.` and `node_modules` are not walked."""
|
|
174
|
+
include = [glob_regex(p) for p in entries]
|
|
175
|
+
skip = [glob_regex(p) for p in exclude]
|
|
176
|
+
found: dict[Path, None] = {}
|
|
177
|
+
for folder in folders:
|
|
178
|
+
folder = Path(folder).absolute()
|
|
179
|
+
for root, dirs, files in os.walk(folder):
|
|
180
|
+
rel_root = Path(root).relative_to(folder).as_posix()
|
|
181
|
+
prefix = "" if rel_root == "." else rel_root + "/"
|
|
182
|
+
dirs[:] = sorted(
|
|
183
|
+
d
|
|
184
|
+
for d in dirs
|
|
185
|
+
if not d.startswith(".")
|
|
186
|
+
and d not in _SKIP_DIRS
|
|
187
|
+
and not any(rx.fullmatch(f"{prefix}{d}/_") for rx in skip)
|
|
188
|
+
)
|
|
189
|
+
for name in sorted(files):
|
|
190
|
+
rel = prefix + name
|
|
191
|
+
if any(rx.fullmatch(rel) for rx in include) and not any(rx.fullmatch(rel) for rx in skip):
|
|
192
|
+
found[Path(root, name).absolute()] = None
|
|
193
|
+
return sorted(found)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def watch_pattern() -> str:
|
|
197
|
+
"""The `workspace/didChangeWatchedFiles` glob: library files and the program extensions of
|
|
198
|
+
every logger profile, each in lower and upper case (LSP globs are case-sensitive; the server
|
|
199
|
+
filters by the configured logger). A mixed-case extension such as `.Cr6` is not watched."""
|
|
200
|
+
suffixes = {".crb", ".cr6", *profiles.EXTENSIONS}
|
|
201
|
+
names = sorted({v for s in suffixes for v in (s.lower()[1:], s.upper()[1:])})
|
|
202
|
+
return "**/*.{" + ",".join(names) + "}"
|