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.
- st_formatter/__init__.py +1 -0
- st_formatter/__main__.py +6 -0
- st_formatter/align.py +115 -0
- st_formatter/blocks.py +135 -0
- st_formatter/cli.py +181 -0
- st_formatter/config.py +108 -0
- st_formatter/formatter.py +40 -0
- st_formatter/indent.py +89 -0
- st_formatter/regions.py +101 -0
- st_formatter/tokenizer.py +210 -0
- st_formatter/validator.py +93 -0
- structured_text_formatter-0.1.0.dist-info/METADATA +175 -0
- structured_text_formatter-0.1.0.dist-info/RECORD +16 -0
- structured_text_formatter-0.1.0.dist-info/WHEEL +4 -0
- structured_text_formatter-0.1.0.dist-info/entry_points.txt +2 -0
- structured_text_formatter-0.1.0.dist-info/licenses/LICENSE +674 -0
st_formatter/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
st_formatter/__main__.py
ADDED
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)
|