sattline-parser 2026.8__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.
- sattline_parser/__init__.py +116 -0
- sattline_parser/__version__.py +3 -0
- sattline_parser/api.py +305 -0
- sattline_parser/decode_fuzzer.py +17 -0
- sattline_parser/errors.py +101 -0
- sattline_parser/formatting/__init__.py +12 -0
- sattline_parser/formatting/formatter.py +391 -0
- sattline_parser/fuzz_harness.py +276 -0
- sattline_parser/grammar/__init__.py +5 -0
- sattline_parser/grammar/constants.py +234 -0
- sattline_parser/grammar/sattline.lark +447 -0
- sattline_parser/grammar/sattline_lexer.py +106 -0
- sattline_parser/models/__init__.py +1 -0
- sattline_parser/models/_ast_model_support.py +188 -0
- sattline_parser/models/ast_model.py +582 -0
- sattline_parser/models/expressions.py +195 -0
- sattline_parser/parser_fuzzer.py +21 -0
- sattline_parser/preprocessing/__init__.py +17 -0
- sattline_parser/preprocessing/compressed.py +276 -0
- sattline_parser/transformer/__init__.py +5 -0
- sattline_parser/transformer/_comments_mixin.py +77 -0
- sattline_parser/transformer/_expressions_mixin.py +247 -0
- sattline_parser/transformer/_graphics_interact_mixin.py +433 -0
- sattline_parser/transformer/_module_assembly_mixin.py +587 -0
- sattline_parser/transformer/_module_header_mixin.py +188 -0
- sattline_parser/transformer/_module_layout_mixin.py +196 -0
- sattline_parser/transformer/_module_shared.py +106 -0
- sattline_parser/transformer/_modules_mixin.py +27 -0
- sattline_parser/transformer/_sfc_mixin.py +315 -0
- sattline_parser/transformer/_tokens_mixin.py +149 -0
- sattline_parser/transformer/sl_transformer.py +240 -0
- sattline_parser-2026.8.dist-info/METADATA +175 -0
- sattline_parser-2026.8.dist-info/RECORD +36 -0
- sattline_parser-2026.8.dist-info/WHEEL +5 -0
- sattline_parser-2026.8.dist-info/licenses/LICENSE +21 -0
- sattline_parser-2026.8.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Reusable SattLine parser-core package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from sattline_parser.models.ast_model import BasePicture, SourceSpan
|
|
10
|
+
from sattline_parser.models.expressions import (
|
|
11
|
+
Assignment,
|
|
12
|
+
BinOp,
|
|
13
|
+
BoolOp,
|
|
14
|
+
Compare,
|
|
15
|
+
FuncCall,
|
|
16
|
+
FuncCallStmt,
|
|
17
|
+
IfStmt,
|
|
18
|
+
NotOp,
|
|
19
|
+
SLExpression,
|
|
20
|
+
SLStmt,
|
|
21
|
+
TernaryOp,
|
|
22
|
+
UnaryOp,
|
|
23
|
+
VarRef,
|
|
24
|
+
)
|
|
25
|
+
from sattline_parser.preprocessing import is_compressed, preprocess_sl_text
|
|
26
|
+
from sattline_parser.transformer.sl_transformer import SLTransformer
|
|
27
|
+
|
|
28
|
+
from .__version__ import __version__
|
|
29
|
+
from .api import create_parser, create_sl_parser, describe_parse_error, parse_source_file, parse_source_text
|
|
30
|
+
from .grammar import constants
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from . import fuzz_harness as fuzz_harness
|
|
34
|
+
from .fuzz_harness import (
|
|
35
|
+
FuzzResult,
|
|
36
|
+
assert_no_crashes,
|
|
37
|
+
assert_no_timeouts,
|
|
38
|
+
collect_corpus_inputs,
|
|
39
|
+
fuzz_parse_text,
|
|
40
|
+
generate_random_text,
|
|
41
|
+
run_corpus_regression,
|
|
42
|
+
run_random_fuzz,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_FUZZ_EXPORTS = (
|
|
46
|
+
"FuzzResult",
|
|
47
|
+
"assert_no_crashes",
|
|
48
|
+
"assert_no_timeouts",
|
|
49
|
+
"collect_corpus_inputs",
|
|
50
|
+
"fuzz_harness",
|
|
51
|
+
"fuzz_parse_text",
|
|
52
|
+
"generate_random_text",
|
|
53
|
+
"run_corpus_regression",
|
|
54
|
+
"run_random_fuzz",
|
|
55
|
+
)
|
|
56
|
+
_FUZZ_EXPORT_SET = frozenset(_FUZZ_EXPORTS)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_fuzz_harness() -> ModuleType:
|
|
60
|
+
module = import_module(".fuzz_harness", __name__)
|
|
61
|
+
globals()["fuzz_harness"] = module
|
|
62
|
+
for export_name in _FUZZ_EXPORTS:
|
|
63
|
+
if export_name == "fuzz_harness":
|
|
64
|
+
continue
|
|
65
|
+
globals()[export_name] = getattr(module, export_name)
|
|
66
|
+
return module
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def __getattr__(name: str) -> Any:
|
|
70
|
+
if name not in _FUZZ_EXPORT_SET:
|
|
71
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
72
|
+
module = _load_fuzz_harness()
|
|
73
|
+
if name == "fuzz_harness":
|
|
74
|
+
return module
|
|
75
|
+
return getattr(module, name)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def __dir__() -> list[str]:
|
|
79
|
+
return sorted(set(globals()) | _FUZZ_EXPORT_SET)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"Assignment",
|
|
84
|
+
"BasePicture",
|
|
85
|
+
"BinOp",
|
|
86
|
+
"BoolOp",
|
|
87
|
+
"Compare",
|
|
88
|
+
"FuncCall",
|
|
89
|
+
"FuncCallStmt",
|
|
90
|
+
"FuzzResult",
|
|
91
|
+
"IfStmt",
|
|
92
|
+
"NotOp",
|
|
93
|
+
"SLExpression",
|
|
94
|
+
"SLStmt",
|
|
95
|
+
"SLTransformer",
|
|
96
|
+
"SourceSpan",
|
|
97
|
+
"TernaryOp",
|
|
98
|
+
"UnaryOp",
|
|
99
|
+
"VarRef",
|
|
100
|
+
"__version__",
|
|
101
|
+
"assert_no_crashes",
|
|
102
|
+
"assert_no_timeouts",
|
|
103
|
+
"collect_corpus_inputs",
|
|
104
|
+
"constants",
|
|
105
|
+
"create_parser",
|
|
106
|
+
"create_sl_parser",
|
|
107
|
+
"describe_parse_error",
|
|
108
|
+
"fuzz_parse_text",
|
|
109
|
+
"generate_random_text",
|
|
110
|
+
"is_compressed",
|
|
111
|
+
"parse_source_file",
|
|
112
|
+
"parse_source_text",
|
|
113
|
+
"preprocess_sl_text",
|
|
114
|
+
"run_corpus_regression",
|
|
115
|
+
"run_random_fuzz",
|
|
116
|
+
]
|
sattline_parser/api.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Public parser-core entry points and orchestration layer.
|
|
2
|
+
|
|
3
|
+
This module owns the public API, the Lark parser cache, and the parse
|
|
4
|
+
pipeline. Lower-level error formatting lives in :mod:`sattline_parser.errors`
|
|
5
|
+
and source pre-processing lives in :mod:`sattline_parser.preprocessing`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from hashlib import sha256
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from tempfile import gettempdir
|
|
15
|
+
from typing import Protocol, cast
|
|
16
|
+
|
|
17
|
+
from lark import Lark, Token, Tree
|
|
18
|
+
from lark import __version__ as lark_version
|
|
19
|
+
from lark.exceptions import UnexpectedInput
|
|
20
|
+
from lark.lexer import ContextualLexer
|
|
21
|
+
|
|
22
|
+
from sattline_parser.models.ast_model import BasePicture
|
|
23
|
+
from sattline_parser.transformer.sl_transformer import SLTransformer
|
|
24
|
+
|
|
25
|
+
from .errors import (
|
|
26
|
+
ParseErrorDetails,
|
|
27
|
+
_log_parser_failure, # pyright: ignore[reportPrivateUsage] # imported for internal use
|
|
28
|
+
describe_parse_error,
|
|
29
|
+
)
|
|
30
|
+
from .errors import (
|
|
31
|
+
_failure_details as _failure_details, # pyright: ignore[reportPrivateUsage] # re-exported as a test seam
|
|
32
|
+
)
|
|
33
|
+
from .errors import (
|
|
34
|
+
_unexpected_input_summary as _unexpected_input_summary, # pyright: ignore[reportPrivateUsage] # re-exported as a test seam
|
|
35
|
+
)
|
|
36
|
+
from .grammar import constants as const
|
|
37
|
+
from .grammar.sattline_lexer import SattLineLexer
|
|
38
|
+
from .preprocessing import is_compressed, preprocess_sl_text
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"ParseErrorDetails",
|
|
42
|
+
"build_lark_parser",
|
|
43
|
+
"create_parser",
|
|
44
|
+
"create_sl_parser",
|
|
45
|
+
"describe_parse_error",
|
|
46
|
+
"load_source_text",
|
|
47
|
+
"parse_source_file",
|
|
48
|
+
"parse_source_text",
|
|
49
|
+
"read_text_with_fallback",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
GRAMMAR_PATH = Path(__file__).resolve().parent / "grammar" / "sattline.lark"
|
|
53
|
+
_PARSER_CACHE_DIR = Path(gettempdir()) / "sattline-parser" / "lark-cache"
|
|
54
|
+
|
|
55
|
+
if not GRAMMAR_PATH.exists():
|
|
56
|
+
raise RuntimeError(f"Grammar file missing: {GRAMMAR_PATH}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _ParserProtocol(Protocol):
|
|
60
|
+
def parse(
|
|
61
|
+
self,
|
|
62
|
+
text: str,
|
|
63
|
+
start: str | None = None,
|
|
64
|
+
_on_error: Callable[[UnexpectedInput], bool] | None = None,
|
|
65
|
+
) -> Tree[Token]: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@lru_cache(maxsize=1)
|
|
69
|
+
def _formatted_grammar() -> str:
|
|
70
|
+
grammar_text = GRAMMAR_PATH.read_text(encoding="utf-8")
|
|
71
|
+
grammar_substitutions = {
|
|
72
|
+
name: getattr(const, name)
|
|
73
|
+
for name in dir(const)
|
|
74
|
+
if name.startswith("GRAMMAR_VALUE_") or name.startswith("GRAMMAR_REGEX_")
|
|
75
|
+
}
|
|
76
|
+
return grammar_text.format(**grammar_substitutions)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
#: Comments are structurally exposed in the default grammar, which makes the
|
|
80
|
+
#: LALR table carry inherent Shift/Reduce ambiguities at construct boundaries
|
|
81
|
+
#: (a comment may trail the inner construct or start the enclosing repetition).
|
|
82
|
+
#: Strict mode therefore validates the core, comment-free grammar instead.
|
|
83
|
+
_COMMENT_RULE_PREFIXES = (
|
|
84
|
+
"comment:",
|
|
85
|
+
"?comment_content:",
|
|
86
|
+
"comments:",
|
|
87
|
+
"code_comment:",
|
|
88
|
+
"comment_stmt:",
|
|
89
|
+
"change_description:",
|
|
90
|
+
"module_description_comment:",
|
|
91
|
+
"module_typedescription:",
|
|
92
|
+
"module_end_comment:",
|
|
93
|
+
)
|
|
94
|
+
_COMMENT_TERMINAL_PREFIXES = ("COMMENT_START:", "COMMENT_END:", "COMMENT_TEXT:")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@lru_cache(maxsize=1)
|
|
98
|
+
def _core_grammar() -> str:
|
|
99
|
+
"""The comment-free grammar used by strict-mode builds."""
|
|
100
|
+
lines = _formatted_grammar().splitlines()
|
|
101
|
+
kept: list[str] = []
|
|
102
|
+
for line in lines:
|
|
103
|
+
stripped = line.strip()
|
|
104
|
+
if any(stripped.startswith(prefix) for prefix in _COMMENT_RULE_PREFIXES):
|
|
105
|
+
continue
|
|
106
|
+
if any(stripped.startswith(prefix) for prefix in _COMMENT_TERMINAL_PREFIXES):
|
|
107
|
+
continue
|
|
108
|
+
kept.append(line)
|
|
109
|
+
text = "\n".join(kept)
|
|
110
|
+
# Longest role-tagged rules first to avoid partial-match corruption.
|
|
111
|
+
return (
|
|
112
|
+
text.replace("code_comment | ", "")
|
|
113
|
+
.replace("comments | ", "")
|
|
114
|
+
.replace("comments? ", "")
|
|
115
|
+
.replace("comments? ,", "")
|
|
116
|
+
.replace("comments?", "")
|
|
117
|
+
.replace("change_description? ", "")
|
|
118
|
+
.replace("change_description?", "")
|
|
119
|
+
.replace("module_description_comment? ", "")
|
|
120
|
+
.replace("module_description_comment?", "")
|
|
121
|
+
.replace("module_typedescription? ", "")
|
|
122
|
+
.replace("module_typedescription?", "")
|
|
123
|
+
.replace(" module_end_comment? ", " ")
|
|
124
|
+
.replace(" module_end_comment?", "")
|
|
125
|
+
.replace(" module_end_comment ", " ")
|
|
126
|
+
.replace("| comment_stmt", "")
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parser_cache_path(
|
|
131
|
+
*,
|
|
132
|
+
start: str,
|
|
133
|
+
propagate_positions: bool,
|
|
134
|
+
strict: bool,
|
|
135
|
+
) -> str:
|
|
136
|
+
cache_key = sha256()
|
|
137
|
+
grammar_text = _core_grammar() if strict else _formatted_grammar()
|
|
138
|
+
cache_key.update(grammar_text.encode("utf-8"))
|
|
139
|
+
cache_key.update(start.encode("utf-8"))
|
|
140
|
+
cache_key.update(str(propagate_positions).encode("ascii"))
|
|
141
|
+
cache_key.update(str(strict).encode("ascii"))
|
|
142
|
+
cache_key.update(lark_version.encode("utf-8"))
|
|
143
|
+
_PARSER_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
return str(_PARSER_CACHE_DIR / f"{cache_key.hexdigest()}.lark")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_lark_parser(
|
|
148
|
+
*,
|
|
149
|
+
start: str = "start",
|
|
150
|
+
propagate_positions: bool = True,
|
|
151
|
+
strict: bool = False,
|
|
152
|
+
) -> Lark:
|
|
153
|
+
plugins: dict[str, type[ContextualLexer]] = {}
|
|
154
|
+
if not strict:
|
|
155
|
+
plugins["ContextualLexer"] = SattLineLexer
|
|
156
|
+
return Lark(
|
|
157
|
+
_core_grammar() if strict else _formatted_grammar(),
|
|
158
|
+
start=start,
|
|
159
|
+
parser="lalr",
|
|
160
|
+
lexer="contextual",
|
|
161
|
+
propagate_positions=propagate_positions,
|
|
162
|
+
strict=strict,
|
|
163
|
+
regex=True,
|
|
164
|
+
cache=_parser_cache_path(
|
|
165
|
+
start=start,
|
|
166
|
+
propagate_positions=propagate_positions,
|
|
167
|
+
strict=strict,
|
|
168
|
+
),
|
|
169
|
+
cache_grammar=True,
|
|
170
|
+
_plugins=plugins,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def create_parser(*, strict: bool = False) -> Lark:
|
|
175
|
+
"""Load and compile the SattLine grammar."""
|
|
176
|
+
return build_lark_parser(strict=strict)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def create_sl_parser(*, strict: bool = False) -> Lark:
|
|
180
|
+
"""Compatibility alias for create_parser."""
|
|
181
|
+
return create_parser(strict=strict)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@lru_cache(maxsize=1)
|
|
185
|
+
def _default_parser() -> Lark:
|
|
186
|
+
return create_parser()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _decode_compressed_source(
|
|
190
|
+
src: str,
|
|
191
|
+
*,
|
|
192
|
+
debug: Callable[[str], None] | None = None,
|
|
193
|
+
source_path: Path | None = None,
|
|
194
|
+
log_failures: bool = True,
|
|
195
|
+
) -> str:
|
|
196
|
+
if not is_compressed(src):
|
|
197
|
+
return src
|
|
198
|
+
if debug is not None:
|
|
199
|
+
debug("Compressed format detected; decoding before parsing")
|
|
200
|
+
try:
|
|
201
|
+
src, _ = preprocess_sl_text(src)
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
if log_failures:
|
|
204
|
+
_log_parser_failure(stage="decode", exc=exc, source_text=src, source_path=source_path)
|
|
205
|
+
raise
|
|
206
|
+
return src
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def read_text_with_fallback(path: Path) -> str:
|
|
210
|
+
"""Read a text file trying utf-8, then cp1252, then latin-1."""
|
|
211
|
+
for encoding in ("utf-8", "cp1252", "latin-1"):
|
|
212
|
+
try:
|
|
213
|
+
return path.read_text(encoding=encoding)
|
|
214
|
+
except UnicodeDecodeError:
|
|
215
|
+
continue
|
|
216
|
+
except OSError as exc:
|
|
217
|
+
_log_parser_failure(stage="read", exc=exc, source_path=path)
|
|
218
|
+
raise
|
|
219
|
+
return path.read_text(encoding="latin-1")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# Internal alias kept for callers that import the private name.
|
|
223
|
+
_read_text_simple = read_text_with_fallback
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def load_source_text(
|
|
227
|
+
code_path: Path,
|
|
228
|
+
*,
|
|
229
|
+
debug: Callable[[str], None] | None = None,
|
|
230
|
+
) -> str:
|
|
231
|
+
source_path = Path(code_path)
|
|
232
|
+
if debug is not None:
|
|
233
|
+
debug(f"Parsing file: {source_path}")
|
|
234
|
+
|
|
235
|
+
src = _read_text_simple(source_path)
|
|
236
|
+
return _decode_compressed_source(src, debug=debug, source_path=source_path)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def parse_source_text(
|
|
240
|
+
src: str,
|
|
241
|
+
*,
|
|
242
|
+
parser: Lark | None = None,
|
|
243
|
+
transformer: SLTransformer | None = None,
|
|
244
|
+
debug: Callable[[str], None] | None = None,
|
|
245
|
+
source_path: Path | None = None,
|
|
246
|
+
log_failures: bool = True,
|
|
247
|
+
) -> BasePicture:
|
|
248
|
+
decoded = _decode_compressed_source(
|
|
249
|
+
src,
|
|
250
|
+
debug=debug,
|
|
251
|
+
source_path=source_path,
|
|
252
|
+
log_failures=log_failures,
|
|
253
|
+
)
|
|
254
|
+
active_parser = parser if parser is not None else _default_parser()
|
|
255
|
+
active_transformer = transformer if transformer is not None else SLTransformer()
|
|
256
|
+
parser_runner = cast(_ParserProtocol, active_parser)
|
|
257
|
+
try:
|
|
258
|
+
tree = parser_runner.parse(decoded)
|
|
259
|
+
except Exception as exc:
|
|
260
|
+
if log_failures:
|
|
261
|
+
_log_parser_failure(stage="parse", exc=exc, source_text=src, source_path=source_path)
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
if debug is not None:
|
|
265
|
+
debug("Parse OK, transforming with SLTransformer")
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
transformed = active_transformer.transform(tree)
|
|
269
|
+
if not isinstance(transformed, BasePicture):
|
|
270
|
+
raise RuntimeError("Transform result is not BasePicture; check transformer.start()")
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
if log_failures:
|
|
273
|
+
_log_parser_failure(stage="transform", exc=exc, source_text=src, source_path=source_path)
|
|
274
|
+
raise
|
|
275
|
+
|
|
276
|
+
basepic = transformed
|
|
277
|
+
try:
|
|
278
|
+
basepic.parse_tree = tree
|
|
279
|
+
except AttributeError:
|
|
280
|
+
if debug is not None:
|
|
281
|
+
debug("BasePicture does not allow dynamic attributes; parse tree not attached")
|
|
282
|
+
|
|
283
|
+
if debug is not None:
|
|
284
|
+
debug(f"Transform result type: {type(basepic).__name__}")
|
|
285
|
+
|
|
286
|
+
return basepic
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def parse_source_file(
|
|
290
|
+
code_path: Path,
|
|
291
|
+
*,
|
|
292
|
+
parser: Lark | None = None,
|
|
293
|
+
transformer: SLTransformer | None = None,
|
|
294
|
+
debug: Callable[[str], None] | None = None,
|
|
295
|
+
log_failures: bool = True,
|
|
296
|
+
) -> BasePicture:
|
|
297
|
+
src = load_source_text(code_path, debug=debug)
|
|
298
|
+
return parse_source_text(
|
|
299
|
+
src,
|
|
300
|
+
parser=parser,
|
|
301
|
+
transformer=transformer,
|
|
302
|
+
debug=debug,
|
|
303
|
+
source_path=code_path,
|
|
304
|
+
log_failures=log_failures,
|
|
305
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Atheris-based fuzz harness for the compressed text decoder."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import atheris # type: ignore[import-untyped]
|
|
6
|
+
|
|
7
|
+
from sattline_parser.preprocessing.compressed import preprocess_sl_text
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_one_input(data: bytes) -> None:
|
|
11
|
+
source = data.decode("utf-8", errors="replace")
|
|
12
|
+
preprocess_sl_text(source)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
atheris.Setup(sys.argv, test_one_input)
|
|
17
|
+
atheris.Fuzz()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Parse-error representation, description, and logging for parser-core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from lark.exceptions import UnexpectedCharacters, UnexpectedEOF, UnexpectedInput, UnexpectedToken
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ParseErrorDetails",
|
|
13
|
+
"_log_parser_failure",
|
|
14
|
+
"describe_parse_error",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("sattline_parser")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ParseErrorDetails:
|
|
22
|
+
message: str
|
|
23
|
+
line: int | None = None
|
|
24
|
+
column: int | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unexpected_input_summary(exc: UnexpectedInput) -> str:
|
|
28
|
+
summary = str(exc).splitlines()[0].strip()
|
|
29
|
+
expected = getattr(exc, "expected", None)
|
|
30
|
+
if expected:
|
|
31
|
+
expected_text = ", ".join(sorted(expected)[:12])
|
|
32
|
+
if expected_text and expected_text not in summary:
|
|
33
|
+
summary = f"{summary}. Expected one of: {expected_text}"
|
|
34
|
+
elif isinstance(exc, UnexpectedEOF):
|
|
35
|
+
expected = sorted(getattr(exc, "expected", ()) or ())
|
|
36
|
+
if expected:
|
|
37
|
+
summary = f"Unexpected end of input. Expected one of: {', '.join(expected[:12])}"
|
|
38
|
+
elif isinstance(exc, UnexpectedToken):
|
|
39
|
+
token = getattr(exc, "token", None)
|
|
40
|
+
if token is not None:
|
|
41
|
+
summary = f"Unexpected token {token!r}"
|
|
42
|
+
expected = sorted(getattr(exc, "expected", ()) or ())
|
|
43
|
+
if expected:
|
|
44
|
+
summary = f"{summary}. Expected one of: {', '.join(expected[:12])}"
|
|
45
|
+
elif isinstance(exc, UnexpectedCharacters):
|
|
46
|
+
summary = summary.rstrip(".")
|
|
47
|
+
return summary
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def describe_parse_error(exc: Exception, source_text: str) -> ParseErrorDetails:
|
|
51
|
+
line = getattr(exc, "line", None)
|
|
52
|
+
column = getattr(exc, "column", None)
|
|
53
|
+
if isinstance(exc, UnexpectedInput):
|
|
54
|
+
message = _unexpected_input_summary(exc)
|
|
55
|
+
context = exc.get_context(source_text, span=40).rstrip()
|
|
56
|
+
if context:
|
|
57
|
+
message = f"{message}\n{context}"
|
|
58
|
+
return ParseErrorDetails(message=message, line=line, column=column)
|
|
59
|
+
return ParseErrorDetails(message=str(exc), line=line, column=column)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _failure_details(exc: Exception, source_text: str | None = None) -> ParseErrorDetails:
|
|
63
|
+
if source_text is not None:
|
|
64
|
+
return describe_parse_error(exc, source_text)
|
|
65
|
+
return ParseErrorDetails(
|
|
66
|
+
message=str(exc),
|
|
67
|
+
line=getattr(exc, "line", None),
|
|
68
|
+
column=getattr(exc, "column", None),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _log_parser_failure(
|
|
73
|
+
*,
|
|
74
|
+
stage: str,
|
|
75
|
+
exc: Exception,
|
|
76
|
+
source_text: str | None = None,
|
|
77
|
+
source_path: Path | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
details = _failure_details(exc, source_text)
|
|
80
|
+
path_text = str(source_path) if source_path is not None else None
|
|
81
|
+
location_text = ""
|
|
82
|
+
if details.line is not None and details.column is not None:
|
|
83
|
+
location_text = f" (line {details.line}, column {details.column})"
|
|
84
|
+
elif details.line is not None:
|
|
85
|
+
location_text = f" (line {details.line})"
|
|
86
|
+
path_suffix = f" for {path_text}" if path_text is not None else ""
|
|
87
|
+
log.error(
|
|
88
|
+
"Parser %s failure%s%s: %s",
|
|
89
|
+
stage,
|
|
90
|
+
path_suffix,
|
|
91
|
+
location_text,
|
|
92
|
+
details.message,
|
|
93
|
+
extra={
|
|
94
|
+
"parser_stage": stage,
|
|
95
|
+
"parser_path": path_text,
|
|
96
|
+
"parser_line": details.line,
|
|
97
|
+
"parser_column": details.column,
|
|
98
|
+
"parser_context": details.message,
|
|
99
|
+
},
|
|
100
|
+
exc_info=(type(exc), exc, exc.__traceback__),
|
|
101
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""AST formatting helpers for parser-core.
|
|
2
|
+
|
|
3
|
+
Renders nested expression trees and SFC node lists into a readable,
|
|
4
|
+
SattLine-like notation. This package is a leaf: it only depends on the
|
|
5
|
+
grammar constants and the AST models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .formatter import format_expr, format_list, format_optional, format_seq_nodes
|
|
11
|
+
|
|
12
|
+
__all__ = ["format_expr", "format_list", "format_optional", "format_seq_nodes"]
|