structured-text-formatter 0.1.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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
st_formatter/align.py ADDED
@@ -0,0 +1,115 @@
1
+ """Column-alignment passes: consecutive `:=`, consecutive `=>`, and
2
+ consecutive trailing `(* ... *)` comments.
3
+
4
+ Runs after indent.py, so leading indentation is already final. Each pass
5
+ re-tokenizes the current text (cheap at this file scale) and works purely
6
+ by splicing whitespace between two token boundaries on a line -- it never
7
+ touches the operator token, the comment token, or anything else.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from .regions import Regions, detect
12
+ from .tokenizer import Token, TokenType, tokenize
13
+
14
+ _NON_SIG = (TokenType.WHITESPACE, TokenType.NEWLINE, TokenType.EOF)
15
+
16
+ LineInfo = tuple[int, list[Token], int, bool] # (line_no, sig_tokens, leading_width, protected)
17
+
18
+
19
+ def _group_lines(tokens: list[Token]) -> dict[int, list[Token]]:
20
+ by_line: dict[int, list[Token]] = {}
21
+ for t in tokens:
22
+ by_line.setdefault(t.line, []).append(t)
23
+ return by_line
24
+
25
+
26
+ def _build_lines_info(text: str) -> tuple[list[str], list[LineInfo], Regions]:
27
+ regions = detect(text)
28
+ tokens = tokenize(text)
29
+ by_line = _group_lines(tokens)
30
+ lines = text.splitlines(keepends=True)
31
+
32
+ info: list[LineInfo] = []
33
+ for line_no in range(1, len(lines) + 1):
34
+ line_toks = by_line.get(line_no, [])
35
+ sig = [t for t in line_toks if t.type not in _NON_SIG]
36
+ width = len(line_toks[0].text) if line_toks and line_toks[0].type == TokenType.WHITESPACE else 0
37
+ protected = regions.is_protected(line_no - 1)
38
+ info.append((line_no, sig, width, protected))
39
+ return lines, info, regions
40
+
41
+
42
+ def _get_assign_op(sig: list[Token]) -> Token | None:
43
+ if not sig or (len(sig) == 1 and sig[0].type == TokenType.COMMENT):
44
+ return None
45
+ for t in sig:
46
+ if t.type in (TokenType.ASSIGN, TokenType.ARROW):
47
+ return t if t.type == TokenType.ASSIGN else None
48
+ return None
49
+
50
+
51
+ def _get_arrow_op(sig: list[Token]) -> Token | None:
52
+ if not sig or (len(sig) == 1 and sig[0].type == TokenType.COMMENT):
53
+ return None
54
+ for t in sig:
55
+ if t.type in (TokenType.ASSIGN, TokenType.ARROW):
56
+ return t if t.type == TokenType.ARROW else None
57
+ return None
58
+
59
+
60
+ def _get_trailing_comment_op(sig: list[Token]) -> Token | None:
61
+ if len(sig) < 2 or sig[-1].type != TokenType.COMMENT:
62
+ return None
63
+ return sig[-1]
64
+
65
+
66
+ def _run_align(lines: list[str], lines_info: list[LineInfo], get_op) -> None:
67
+ i = 0
68
+ n = len(lines_info)
69
+ while i < n:
70
+ line_no, sig, width, protected = lines_info[i]
71
+ op = None if protected else get_op(sig)
72
+ if op is None:
73
+ i += 1
74
+ continue
75
+
76
+ run = [i]
77
+ j = i + 1
78
+ while j < n:
79
+ _, sig2, width2, prot2 = lines_info[j]
80
+ if prot2 or width2 != width or get_op(sig2) is None:
81
+ break
82
+ run.append(j)
83
+ j += 1
84
+
85
+ target = 0
86
+ for k in run:
87
+ sig_k = lines_info[k][1]
88
+ op_k = get_op(sig_k)
89
+ idx = sig_k.index(op_k)
90
+ before_end = (sig_k[idx - 1].col + len(sig_k[idx - 1].text)) if idx > 0 else 0
91
+ target = max(target, before_end + 1)
92
+
93
+ for k in run:
94
+ line_no_k, sig_k, _, _ = lines_info[k]
95
+ op_k = get_op(sig_k)
96
+ idx = sig_k.index(op_k)
97
+ before_end = (sig_k[idx - 1].col + len(sig_k[idx - 1].text)) if idx > 0 else 0
98
+ pad = max(1, target - before_end)
99
+ raw = lines[line_no_k - 1]
100
+ lines[line_no_k - 1] = raw[:before_end] + (" " * pad) + raw[op_k.col:]
101
+
102
+ i = j if j > i else i + 1
103
+
104
+
105
+ def _run_pass(text: str, get_op) -> str:
106
+ lines, info, _ = _build_lines_info(text)
107
+ _run_align(lines, info, get_op)
108
+ return "".join(lines)
109
+
110
+
111
+ def apply(text: str) -> str:
112
+ text = _run_pass(text, _get_assign_op)
113
+ text = _run_pass(text, _get_arrow_op)
114
+ text = _run_pass(text, _get_trailing_comment_op)
115
+ return text
st_formatter/blocks.py ADDED
@@ -0,0 +1,135 @@
1
+ """Block-nesting analysis, shared by indent.py (reindentation) and
2
+ validator.py (Check B: nesting-tree equality).
3
+
4
+ Confirmed against real usage in the repo: PROGRAM/ACTION/TYPE/FUNCTION
5
+ bodies sit flush with their header (they are POU-level wrappers spanning
6
+ almost the whole file), while VAR*/STRUCT/IF/CASE/FOR/WHILE/REPEAT bodies
7
+ are genuinely indented one level relative to their header.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from .tokenizer import Token, TokenType
14
+
15
+ # Opener keyword -> its closer keyword. VAR_INPUT/VAR_OUTPUT/VAR_IN_OUT/
16
+ # VAR_GLOBAL all close on END_VAR, matching real usage.
17
+ OPENER_TO_CLOSER = {
18
+ "PROGRAM": "END_PROGRAM",
19
+ "FUNCTION": "END_FUNCTION",
20
+ "FUNCTION_BLOCK": "END_FUNCTION_BLOCK",
21
+ "ACTION": "END_ACTION",
22
+ "TYPE": "END_TYPE",
23
+ "STRUCT": "END_STRUCT",
24
+ "VAR": "END_VAR",
25
+ "VAR_INPUT": "END_VAR",
26
+ "VAR_OUTPUT": "END_VAR",
27
+ "VAR_IN_OUT": "END_VAR",
28
+ "VAR_GLOBAL": "END_VAR",
29
+ "IF": "END_IF",
30
+ "CASE": "END_CASE",
31
+ "FOR": "END_FOR",
32
+ "WHILE": "END_WHILE",
33
+ "REPEAT": "END_REPEAT",
34
+ }
35
+ OPENER_KEYWORDS = set(OPENER_TO_CLOSER)
36
+ CLOSER_KEYWORDS = set(OPENER_TO_CLOSER.values())
37
+
38
+ # Openers whose own body is NOT indented relative to their header line.
39
+ FLAT_OPENERS = {"PROGRAM", "FUNCTION", "FUNCTION_BLOCK", "ACTION", "TYPE"}
40
+
41
+ MID_KEYWORDS = {"THEN", "ELSE", "ELSIF", "UNTIL"}
42
+
43
+ _NON_SIG = (TokenType.WHITESPACE, TokenType.NEWLINE, TokenType.EOF)
44
+
45
+
46
+ @dataclass
47
+ class Frame:
48
+ kind: str
49
+ closer: str
50
+ header_level: int
51
+ body_level: int
52
+ content_level: int
53
+
54
+
55
+ def significant(tokens: list[Token]) -> list[Token]:
56
+ return [t for t in tokens if t.type not in _NON_SIG]
57
+
58
+
59
+ def is_case_label_line(sig: list[Token], in_case: bool) -> bool:
60
+ """A CASE label line: a comma/`..`-separated list of idents/numbers
61
+ followed by a bare `:` (not `:=`), as the first thing on the line."""
62
+ if not in_case or not sig:
63
+ return False
64
+ saw_value = False
65
+ for t in sig:
66
+ if t.type in (TokenType.IDENT, TokenType.NUMBER):
67
+ saw_value = True
68
+ elif t.type in (TokenType.DOTDOT, TokenType.COMMA):
69
+ continue
70
+ elif t.type == TokenType.COLON:
71
+ return saw_value
72
+ else:
73
+ return False
74
+ return False
75
+
76
+
77
+ class NestingWalker:
78
+ """Walks a file's content lines in order, maintaining the block-frame
79
+ stack, and returns the indentation level for each line visited."""
80
+
81
+ def __init__(self) -> None:
82
+ self.stack: list[Frame] = []
83
+
84
+ def current_level(self) -> int:
85
+ return self.stack[-1].content_level if self.stack else 0
86
+
87
+ def visit_line(self, sig: list[Token]) -> int:
88
+ if not sig:
89
+ return self.current_level()
90
+
91
+ tok0 = sig[0]
92
+ kw0 = tok0.text.upper() if tok0.type == TokenType.KEYWORD else None
93
+
94
+ if kw0 in OPENER_KEYWORDS:
95
+ level = self.current_level()
96
+ body_level = level if kw0 in FLAT_OPENERS else level + 1
97
+ self.stack.append(Frame(
98
+ kind=kw0, closer=OPENER_TO_CLOSER[kw0],
99
+ header_level=level, body_level=body_level,
100
+ content_level=body_level,
101
+ ))
102
+ return level
103
+
104
+ if kw0 in CLOSER_KEYWORDS:
105
+ if self.stack and self.stack[-1].closer == kw0:
106
+ return self.stack.pop().header_level
107
+ # Malformed/mismatched nesting: best-effort, never crash.
108
+ return self.current_level()
109
+
110
+ in_case = bool(self.stack) and self.stack[-1].kind == "CASE"
111
+ if kw0 in MID_KEYWORDS or is_case_label_line(sig, in_case):
112
+ if self.stack:
113
+ level = self.stack[-1].body_level
114
+ self.stack[-1].content_level = self.stack[-1].body_level + 1
115
+ else:
116
+ level = 0
117
+ return level
118
+
119
+ return self.current_level()
120
+
121
+
122
+ def nesting_events(tokens: list[Token]) -> list[str]:
123
+ """Flat OPEN/CLOSE event sequence from a token stream's KEYWORD
124
+ tokens alone -- independent of line/indentation, used by the
125
+ validator to assert nesting is unchanged by formatting."""
126
+ events: list[str] = []
127
+ for t in tokens:
128
+ if t.type != TokenType.KEYWORD:
129
+ continue
130
+ kw = t.text.upper()
131
+ if kw in OPENER_KEYWORDS:
132
+ events.append(f"OPEN:{kw}")
133
+ elif kw in CLOSER_KEYWORDS:
134
+ events.append(f"CLOSE:{kw}")
135
+ return events
st_formatter/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ """CLI entry point for the ST formatter.
2
+
3
+ st-formatter <paths...> [options]
4
+ python -m st_formatter <paths...> [options]
5
+
6
+ There is no implicit default path: callers must name the directory/files
7
+ to format explicitly (e.g. `code`), so the tool stays safe to point at a
8
+ small test-fixtures directory too. The tool never invokes git itself.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import difflib
14
+ import fnmatch
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from . import __version__
19
+ from .config import resolve_config
20
+ from .formatter import format_text
21
+
22
+ EXIT_CLEAN = 0
23
+ EXIT_WOULD_CHANGE = 1
24
+ EXIT_VALIDATION_FAILED = 2
25
+ EXIT_FATAL = 3
26
+
27
+
28
+ def _is_excluded(path: Path, exclude_patterns: tuple) -> bool:
29
+ posix = path.as_posix()
30
+ return any(fnmatch.fnmatch(posix, pat) for pat in exclude_patterns)
31
+
32
+
33
+ def _iter_source_files(paths: list, extensions: tuple, exclude_patterns: tuple, verbose: bool = False) -> list:
34
+ files = []
35
+ for raw in paths:
36
+ p = Path(raw)
37
+ if p.is_dir():
38
+ for f in sorted(p.rglob("*")):
39
+ if not f.is_file() or f.suffix.lower() not in extensions:
40
+ continue
41
+ if _is_excluded(f, exclude_patterns):
42
+ continue
43
+ files.append(f)
44
+ elif p.is_file():
45
+ if verbose and p.suffix.lower() not in extensions:
46
+ print(f"note: {p} has an unrecognized extension, formatting it anyway (explicit file argument)")
47
+ files.append(p)
48
+ else:
49
+ raise FileNotFoundError(f"no such file or directory: {raw}")
50
+ return files
51
+
52
+
53
+ def _read(path: Path) -> str:
54
+ with path.open("r", encoding="latin-1", newline="") as f:
55
+ return f.read()
56
+
57
+
58
+ def _write(path: Path, text: str) -> None:
59
+ with path.open("w", encoding="latin-1", newline="") as f:
60
+ f.write(text)
61
+
62
+
63
+ def main(argv: list = None) -> int:
64
+ parser = argparse.ArgumentParser(
65
+ description="clang-format-style formatter for IEC 61131-3 Structured "
66
+ "Text (.st) and Bodas/CoDeSys 2.3 .EXP exports."
67
+ )
68
+ parser.add_argument("paths", nargs="+", help="Files and/or directories to format (.st and .EXP by default)")
69
+ parser.add_argument("--write", action="store_true", help="Apply changes in place (default: dry run)")
70
+ parser.add_argument("--check", action="store_true", help="Explicit dry-run alias (default behavior)")
71
+ parser.add_argument("--diff", action="store_true", help="Print a unified diff for each file that would change")
72
+ parser.add_argument("--config", metavar="PATH", help="Use this config file instead of auto-discovery")
73
+ parser.add_argument("--extensions", metavar="EXT,...", help="Comma-separated extensions to treat as ST source, "
74
+ "e.g. .st,.exp (default: .st,.exp)")
75
+ parser.add_argument("--indent-size", type=int, default=None)
76
+ parser.add_argument("--tab-width", type=int, default=None)
77
+ parser.add_argument("--no-align", action="store_true", default=None,
78
+ help="Skip the := / => / comment alignment pass")
79
+ parser.add_argument("--no-indent", action="store_true", default=None, help="Skip the reindentation pass")
80
+ parser.add_argument("--report", metavar="PATH", help="Write the full summary/diff/failure log to this file")
81
+ parser.add_argument("-v", "--verbose", action="store_true")
82
+ parser.add_argument("--version", action="version", version=f"st-formatter {__version__}")
83
+ args = parser.parse_args(argv)
84
+
85
+ try:
86
+ config = resolve_config(
87
+ paths=args.paths,
88
+ explicit_config=args.config,
89
+ cli_overrides={
90
+ "indent_size": args.indent_size,
91
+ "tab_width": args.tab_width,
92
+ "do_indent": None if args.no_indent is None else not args.no_indent,
93
+ "do_align": None if args.no_align is None else not args.no_align,
94
+ "extensions": tuple(e.strip() for e in args.extensions.split(",")) if args.extensions else None,
95
+ },
96
+ )
97
+ except (FileNotFoundError, OSError, ValueError) as exc:
98
+ print(f"error: invalid config: {exc}", file=sys.stderr)
99
+ return EXIT_FATAL
100
+
101
+ try:
102
+ files = _iter_source_files(args.paths, config.extensions, config.exclude, verbose=args.verbose)
103
+ except FileNotFoundError as exc:
104
+ print(f"error: {exc}", file=sys.stderr)
105
+ return EXIT_FATAL
106
+
107
+ unchanged = reformatted = failed = 0
108
+ report_lines: list = []
109
+
110
+ for path in files:
111
+ try:
112
+ original = _read(path)
113
+ except OSError as exc:
114
+ print(f"error: cannot read {path}: {exc}", file=sys.stderr)
115
+ failed += 1
116
+ continue
117
+
118
+ result = format_text(
119
+ original,
120
+ indent_size=config.indent_size,
121
+ tab_width=config.tab_width,
122
+ do_indent=config.do_indent,
123
+ do_align=config.do_align,
124
+ )
125
+
126
+ if not result.ok:
127
+ failed += 1
128
+ msg = f"FAILED VALIDATION: {path}"
129
+ print(msg, file=sys.stderr)
130
+ report_lines.append(msg)
131
+ for f in result.failures:
132
+ print(f" {f}", file=sys.stderr)
133
+ report_lines.append(f" {f}")
134
+ continue
135
+
136
+ if not result.changed:
137
+ unchanged += 1
138
+ if args.verbose:
139
+ print(f"unchanged: {path}")
140
+ continue
141
+
142
+ reformatted += 1
143
+ if args.verbose:
144
+ print(f"reformatted: {path}" if args.write else f"would reformat: {path}")
145
+
146
+ if args.diff:
147
+ diff = "".join(difflib.unified_diff(
148
+ original.splitlines(keepends=True),
149
+ result.formatted_text.splitlines(keepends=True),
150
+ fromfile=str(path), tofile=str(path),
151
+ ))
152
+ print(diff)
153
+ report_lines.append(diff)
154
+
155
+ if args.write:
156
+ try:
157
+ _write(path, result.formatted_text)
158
+ except OSError as exc:
159
+ print(f"error: cannot write {path}: {exc}", file=sys.stderr)
160
+ failed += 1
161
+
162
+ verb = "reformatted" if args.write else "would be reformatted"
163
+ summary = (
164
+ f"{len(files)} files: {unchanged} unchanged, {reformatted} {verb}, "
165
+ f"{failed} FAILED VALIDATION (skipped, left untouched)"
166
+ )
167
+ print(summary)
168
+ report_lines.append(summary)
169
+
170
+ if args.report:
171
+ Path(args.report).write_text("\n".join(report_lines) + "\n", encoding="utf-8")
172
+
173
+ if failed:
174
+ return EXIT_VALIDATION_FAILED
175
+ if reformatted and not args.write:
176
+ return EXIT_WOULD_CHANGE
177
+ return EXIT_CLEAN
178
+
179
+
180
+ if __name__ == "__main__":
181
+ sys.exit(main())
st_formatter/config.py ADDED
@@ -0,0 +1,108 @@
1
+ """Project configuration: discovery, precedence, and merging.
2
+
3
+ Mirrors the black/ruff convention: look for a dedicated `.stformat.toml`
4
+ first, then a `[tool.st_formatter]` table in `pyproject.toml`, walking
5
+ upward from the target path(s). CLI flags always win over a config file,
6
+ and a config file always wins over the built-in defaults.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ import tomllib
13
+ from dataclasses import dataclass, field, fields
14
+ from pathlib import Path
15
+
16
+ _DEDICATED_FILENAME = ".stformat.toml"
17
+ _PYPROJECT_FILENAME = "pyproject.toml"
18
+ _KNOWN_KEYS = {"indent_size", "tab_width", "indent", "align", "extensions", "exclude"}
19
+
20
+
21
+ @dataclass
22
+ class Config:
23
+ indent_size: int = 2
24
+ tab_width: int = 4
25
+ do_indent: bool = True
26
+ do_align: bool = True
27
+ extensions: tuple = (".st", ".exp")
28
+ exclude: tuple = field(default_factory=tuple)
29
+
30
+
31
+ def _normalize_extensions(values) -> tuple:
32
+ return tuple(v if v.startswith(".") else f".{v}" for v in (e.lower() for e in values))
33
+
34
+
35
+ def _table_to_overrides(table: dict) -> dict:
36
+ unknown = set(table) - _KNOWN_KEYS
37
+ if unknown:
38
+ print(f"warning: ignoring unknown st_formatter config key(s): {', '.join(sorted(unknown))}", file=sys.stderr)
39
+
40
+ overrides = {}
41
+ if "indent_size" in table:
42
+ overrides["indent_size"] = int(table["indent_size"])
43
+ if "tab_width" in table:
44
+ overrides["tab_width"] = int(table["tab_width"])
45
+ if "indent" in table:
46
+ overrides["do_indent"] = bool(table["indent"])
47
+ if "align" in table:
48
+ overrides["do_align"] = bool(table["align"])
49
+ if "extensions" in table:
50
+ overrides["extensions"] = _normalize_extensions(table["extensions"])
51
+ if "exclude" in table:
52
+ overrides["exclude"] = tuple(table["exclude"])
53
+ return overrides
54
+
55
+
56
+ def _load_toml_table(path: Path) -> dict:
57
+ with path.open("rb") as f:
58
+ data = tomllib.load(f)
59
+ if path.name == _PYPROJECT_FILENAME:
60
+ return data.get("tool", {}).get("st_formatter", {})
61
+ return data
62
+
63
+
64
+ def _find_config_file(start_dir: Path) -> Path | None:
65
+ current = start_dir.resolve()
66
+ while True:
67
+ dedicated = current / _DEDICATED_FILENAME
68
+ if dedicated.is_file():
69
+ return dedicated
70
+ pyproject = current / _PYPROJECT_FILENAME
71
+ if pyproject.is_file():
72
+ try:
73
+ if _load_toml_table(pyproject):
74
+ return pyproject
75
+ except tomllib.TOMLDecodeError:
76
+ raise
77
+ if current.parent == current:
78
+ return None
79
+ current = current.parent
80
+
81
+
82
+ def _common_start_dir(paths: list) -> Path:
83
+ resolved = [Path(p).resolve() for p in paths]
84
+ dirs = [p if p.is_dir() else p.parent for p in resolved]
85
+ return Path(os.path.commonpath(dirs))
86
+
87
+
88
+ def resolve_config(paths: list, explicit_config: str = None, cli_overrides: dict = None) -> Config:
89
+ overrides = {}
90
+
91
+ if explicit_config:
92
+ config_path = Path(explicit_config)
93
+ if not config_path.is_file():
94
+ raise FileNotFoundError(f"no such config file: {explicit_config}")
95
+ overrides.update(_table_to_overrides(_load_toml_table(config_path)))
96
+ elif paths:
97
+ found = _find_config_file(_common_start_dir(paths))
98
+ if found is not None:
99
+ overrides.update(_table_to_overrides(_load_toml_table(found)))
100
+
101
+ if cli_overrides:
102
+ for key, value in cli_overrides.items():
103
+ if value is not None:
104
+ overrides[key] = value
105
+
106
+ known_fields = {f.name for f in fields(Config)}
107
+ overrides = {k: v for k, v in overrides.items() if k in known_fields}
108
+ return Config(**overrides)
@@ -0,0 +1,40 @@
1
+ """Orchestrates formatting one file's text: classify -> indent -> align ->
2
+ validate. Never touches disk itself -- callers (cli.py) decide that."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from . import align, indent, validator
8
+ from .regions import FileClass, detect
9
+
10
+
11
+ @dataclass
12
+ class FormatResult:
13
+ changed: bool
14
+ ok: bool
15
+ formatted_text: str
16
+ failures: list = field(default_factory=list)
17
+
18
+
19
+ def format_text(
20
+ original_text: str,
21
+ indent_size: int = 2,
22
+ tab_width: int = 4,
23
+ do_indent: bool = True,
24
+ do_align: bool = True,
25
+ ) -> FormatResult:
26
+ regions = detect(original_text)
27
+ if regions.file_class == FileClass.LIBRARY_MANIFEST:
28
+ return FormatResult(changed=False, ok=True, formatted_text=original_text)
29
+
30
+ text = original_text
31
+ if do_indent:
32
+ text = indent.apply(text, regions, indent_size=indent_size, tab_width=tab_width)
33
+ if do_align:
34
+ text = align.apply(text)
35
+
36
+ result = validator.check(original_text, text)
37
+ if not result.ok:
38
+ return FormatResult(changed=False, ok=False, formatted_text=original_text, failures=result.failures)
39
+
40
+ return FormatResult(changed=(text != original_text), ok=True, formatted_text=text)
st_formatter/indent.py ADDED
@@ -0,0 +1,89 @@
1
+ """Reindentation pass: recomputes each content line's leading indentation
2
+ from block-keyword nesting (blocks.py).
3
+
4
+ This is a line-based, non-reflowing transform: it never merges/splits
5
+ lines or reorders tokens. It only ever rewrites WHITESPACE token text (or
6
+ inserts a new leading-whitespace token where a line previously had none) --
7
+ every other token (keyword/identifier/number/string/comment/operator/
8
+ newline) is always re-emitted verbatim. That invariant is what makes the
9
+ validator's Check A (structural token-stream equality) hold by
10
+ construction.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from .blocks import NestingWalker, significant
15
+ from .regions import Regions
16
+ from .tokenizer import Token, TokenType, tokenize
17
+
18
+ _NON_SIG = (TokenType.WHITESPACE, TokenType.NEWLINE, TokenType.EOF)
19
+
20
+
21
+ def _group_lines(tokens: list[Token]) -> dict[int, list[Token]]:
22
+ by_line: dict[int, list[Token]] = {}
23
+ for t in tokens:
24
+ by_line.setdefault(t.line, []).append(t)
25
+ return by_line
26
+
27
+
28
+ def _paren_depth_at_line_start(tokens: list[Token]) -> dict[int, int]:
29
+ depth_at_start: dict[int, int] = {}
30
+ depth = 0
31
+ seen_line = 0
32
+ for t in tokens:
33
+ if t.line != seen_line:
34
+ depth_at_start[t.line] = depth
35
+ seen_line = t.line
36
+ if t.type == TokenType.LPAREN:
37
+ depth += 1
38
+ elif t.type == TokenType.RPAREN:
39
+ depth = max(0, depth - 1)
40
+ return depth_at_start
41
+
42
+
43
+ def _reindent_tokens(tokens: list[Token], regions: Regions, indent_size: int, tab_width: int) -> list[Token]:
44
+ by_line = _group_lines(tokens)
45
+ if not by_line:
46
+ return tokens
47
+ depth_at_start = _paren_depth_at_line_start(tokens)
48
+ walker = NestingWalker()
49
+
50
+ out: list[Token] = []
51
+ for line_no in range(1, max(by_line) + 1):
52
+ line_toks = by_line.get(line_no)
53
+ if not line_toks:
54
+ continue
55
+
56
+ sig = significant(line_toks)
57
+ protected = regions.is_protected(line_no - 1)
58
+ continuation = depth_at_start.get(line_no, 0) > 0
59
+
60
+ if not sig or protected:
61
+ out.extend(line_toks)
62
+ continue
63
+
64
+ level = None if continuation else walker.visit_line(sig)
65
+
66
+ has_leading_ws = line_toks[0].type == TokenType.WHITESPACE
67
+ if has_leading_ws:
68
+ lead = line_toks[0]
69
+ new_text = (" " * (level * indent_size)) if level is not None else lead.text.expandtabs(tab_width)
70
+ out.append(Token(TokenType.WHITESPACE, new_text, lead.line, 0, lead.line))
71
+ rest = line_toks[1:]
72
+ else:
73
+ if level is not None and level > 0:
74
+ out.append(Token(TokenType.WHITESPACE, " " * (level * indent_size), line_no, 0, line_no))
75
+ rest = line_toks
76
+
77
+ for t in rest:
78
+ if t.type == TokenType.WHITESPACE and "\t" in t.text:
79
+ out.append(Token(TokenType.WHITESPACE, t.text.replace("\t", " "), t.line, t.col, t.end_line))
80
+ else:
81
+ out.append(t)
82
+
83
+ return out
84
+
85
+
86
+ def apply(text: str, regions: Regions, indent_size: int = 2, tab_width: int = 4) -> str:
87
+ tokens = tokenize(text)
88
+ new_tokens = _reindent_tokens(tokens, regions, indent_size, tab_width)
89
+ return "".join(t.text for t in new_tokens)