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.
Files changed (36) hide show
  1. sattline_parser/__init__.py +116 -0
  2. sattline_parser/__version__.py +3 -0
  3. sattline_parser/api.py +305 -0
  4. sattline_parser/decode_fuzzer.py +17 -0
  5. sattline_parser/errors.py +101 -0
  6. sattline_parser/formatting/__init__.py +12 -0
  7. sattline_parser/formatting/formatter.py +391 -0
  8. sattline_parser/fuzz_harness.py +276 -0
  9. sattline_parser/grammar/__init__.py +5 -0
  10. sattline_parser/grammar/constants.py +234 -0
  11. sattline_parser/grammar/sattline.lark +447 -0
  12. sattline_parser/grammar/sattline_lexer.py +106 -0
  13. sattline_parser/models/__init__.py +1 -0
  14. sattline_parser/models/_ast_model_support.py +188 -0
  15. sattline_parser/models/ast_model.py +582 -0
  16. sattline_parser/models/expressions.py +195 -0
  17. sattline_parser/parser_fuzzer.py +21 -0
  18. sattline_parser/preprocessing/__init__.py +17 -0
  19. sattline_parser/preprocessing/compressed.py +276 -0
  20. sattline_parser/transformer/__init__.py +5 -0
  21. sattline_parser/transformer/_comments_mixin.py +77 -0
  22. sattline_parser/transformer/_expressions_mixin.py +247 -0
  23. sattline_parser/transformer/_graphics_interact_mixin.py +433 -0
  24. sattline_parser/transformer/_module_assembly_mixin.py +587 -0
  25. sattline_parser/transformer/_module_header_mixin.py +188 -0
  26. sattline_parser/transformer/_module_layout_mixin.py +196 -0
  27. sattline_parser/transformer/_module_shared.py +106 -0
  28. sattline_parser/transformer/_modules_mixin.py +27 -0
  29. sattline_parser/transformer/_sfc_mixin.py +315 -0
  30. sattline_parser/transformer/_tokens_mixin.py +149 -0
  31. sattline_parser/transformer/sl_transformer.py +240 -0
  32. sattline_parser-2026.8.dist-info/METADATA +175 -0
  33. sattline_parser-2026.8.dist-info/RECORD +36 -0
  34. sattline_parser-2026.8.dist-info/WHEEL +5 -0
  35. sattline_parser-2026.8.dist-info/licenses/LICENSE +21 -0
  36. 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
+ ]
@@ -0,0 +1,3 @@
1
+ """Package version metadata."""
2
+
3
+ __version__ = "2026.8"
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"]