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/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,7 @@
1
+ """`python -m opencrbasic.lsp`: stdio language server. Imports stay inside the guard so spawned
2
+ analysis workers (which re-import the main module) do not load pygls."""
3
+
4
+ if __name__ == "__main__":
5
+ from .server import main
6
+
7
+ main()
@@ -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) + "}"