oct
Loading...
Searching...
No Matches
parsing.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/core/parsing.py
4
5"""
6Purpose
7-------
8Provide shared Python-source parsing helpers:
9
10- :func:`safe_parse` parses source text into an AST while capturing any
11 ``SyntaxWarning`` diagnostics (so callers can surface them without
12 letting them leak to stderr).
13- :func:`header_boundary` (OI-509 / FS-505) returns the line index where
14 file content begins, scanning at most the first 5 lines in the strict
15 order shebang → encoding → file-identity comment → blank line. This
16 replaces two drift-prone inline copies that previously existed in the
17 linter and the formatter.
18
19Responsibilities
20----------------
21- Parse Python source text into an AST tree.
22- Capture ``SyntaxWarning`` (e.g. invalid escape sequences) as structured data.
23- Locate the header/content boundary of an Option-C-formatted Python file
24 using only the file's basename and (optionally) its expected identity
25 comment, so both the linter (with full ``LinterContext``) and the
26 formatter (with a lighter ``FormatterContext``) can share one code path.
27
28Diagnostics
29-----------
30Domain: OCT-CORE
31Levels:
32 L2 - lifecycle
33 L3 - semantic details
34 L4 - deep tracing
35
36Contracts
37---------
38- :func:`safe_parse` must not emit warnings to stderr; all warnings are
39 returned as strings.
40- :func:`safe_parse` must return ``None`` for the tree on ``SyntaxError``.
41- :func:`header_boundary` must never raise; it returns an integer in
42 ``[0, min(len(lines), 5)]``.
43- :func:`header_boundary` must consume the trailing blank only if at
44 least one header element was matched first.
45
46Dependencies
47------------
48- oct.core.diagnostics (_dbg structured logger)
49"""
50
51from __future__ import annotations
52
53import ast
54import re
55import warnings
56from pathlib import Path
57from oct.core.diagnostics import _dbg
58
59
60_ENCODING_RE = re.compile(r"#.*coding[:=]")
61
62
63def safe_parse(text: str) -> tuple[ast.Module | None, list[str]]:
64 """Parse Python source, capturing SyntaxWarnings instead of emitting to stderr.
65
66 Returns (tree, warnings_list). tree is None on SyntaxError.
67 """
68 func = "safe_parse"
69 _dbg("OCT-CORE", func, f"parsing {len(text)} chars", 4)
70 with warnings.catch_warnings(record=True) as w:
71 warnings.simplefilter("always", SyntaxWarning)
72 try:
73 tree = ast.parse(text)
74 except SyntaxError:
75 return None, []
76
77 caught = [str(warning.message) for warning in w
78 if issubclass(warning.category, SyntaxWarning)]
79 return tree, caught
80
81
83 lines: list[str],
84 path: Path,
85 expected_identity: str | None = None,
86) -> int:
87 """Return the line index where file content begins.
88
89 Scans at most the first 5 lines of *lines*, in strict order:
90 shebang → encoding → file-identity comment → blank line.
91
92 Parameters
93 ----------
94 lines : list[str]
95 Source file split by newlines (as returned by ``str.splitlines()``).
96 path : Path
97 The file's path; its basename is used as the fallback identity
98 match when *expected_identity* is not provided.
99 expected_identity : str | None, optional
100 The exact expected file-identity comment (e.g.
101 ``"# oct/core/parsing.py"``). When supplied the match is strict
102 against this string; otherwise any ``"# ...<path.name>"`` line
103 qualifies.
104 """
105 func = "header_boundary"
106 _dbg("OCT-CORE", func, f"locating header boundary in {path}", 4)
107 limit = min(len(lines), 5)
108 i = 0
109
110 # 1. Shebang (any flavour: python, python3, etc.)
111 if i < limit and lines[i].strip().startswith("#!"):
112 i += 1
113
114 # 2. Encoding declaration (PEP 263)
115 if i < limit and _ENCODING_RE.match(lines[i].strip()):
116 i += 1
117
118 # 3. File-identity comment - only if it references THIS file's name
119 if i < limit:
120 stripped = lines[i].strip()
121 if expected_identity is not None:
122 is_identity = (
123 stripped == expected_identity
124 or (stripped.startswith("# ") and stripped.endswith(path.name))
125 )
126 else:
127 is_identity = (
128 stripped.startswith("# ") and stripped.endswith(path.name)
129 )
130 if is_identity:
131 i += 1
132
133 # 4. Trailing blank line (only if at least one header element was found)
134 if i > 0 and i < limit and lines[i].strip() == "":
135 i += 1
136
137 return i
int header_boundary(list[str] lines, Path path, str|None expected_identity=None)
Definition parsing.py:86
tuple[ast.Module|None, list[str]] safe_parse(str text)
Definition parsing.py:63