bslfmt 0.20.1__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.
bslfmt/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Детерминированный форматтер структурных отступов BSL."""
2
+
3
+ from .lexer import LexerError, Token, lex, restore
4
+ from .formatter import FormatError, format_code
5
+
6
+ __all__ = ["FormatError", "LexerError", "Token", "format_code", "lex", "restore"]
bslfmt/__main__.py ADDED
@@ -0,0 +1,295 @@
1
+ """CLI форматтера BSL: вывод на экран, проверка и правка файлов на месте."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ import difflib
8
+ import os
9
+ import shutil
10
+ import stat
11
+ import sys
12
+ import tempfile
13
+ from importlib import metadata
14
+ from pathlib import Path
15
+
16
+ from .formatter import FormatError, format_code
17
+ from .lexer import LexerError, _split_lines
18
+
19
+ HELP = """\
20
+ Использование: bslfmt [режим] ФАЙЛ [ФАЙЛ ...]
21
+
22
+ Форматирует отступы и пробелы в модулях 1С (BSL). Меняет только пробелы,
23
+ табуляцию и переносы строк: код, строки и комментарии не трогает.
24
+ Файлы читаются и пишутся в UTF-8; BOM и переводы строк (CRLF/LF) сохраняются.
25
+
26
+ ФАЙЛ путь к .bsl-файлу; «-» — читать из стандартного ввода
27
+ (единственным файлом, не с -i)
28
+ -- дальше только файлы (для имён, начинающихся с «-»)
29
+
30
+ Режимы (без режима — отформатированный текст выводится на экран):
31
+ -i, --in-place переписать сами файлы. Файл без изменений не трогается,
32
+ при отказе остаётся как был; файл только для чтения
33
+ не переписывается (ошибка, код 2).
34
+ Пример: bslfmt -i МодульОбъекта.bsl Форма.bsl
35
+ --check только проверить, ничего не записывая: какие файлы
36
+ нужно отформатировать.
37
+ Пример: bslfmt --check МодульОбъекта.bsl
38
+ --diff показать различия «было / станет»; вместе с --check —
39
+ проверка с показом изменений.
40
+ Пример: bslfmt --check --diff МодульОбъекта.bsl
41
+ --output ФАЙЛ записать результат в новый файл (один входной файл;
42
+ существующий файл не перезаписывается).
43
+ Пример: bslfmt Модуль.bsl --output Модуль.новый.bsl
44
+ --version показать версию.
45
+
46
+ Дополнительно (с любым режимом):
47
+ -sbc, --strip-body-comments
48
+ удалить строки-комментарии внутри процедур и функций
49
+ (включая закомментированный код и маркеры доработок).
50
+ Комментарии в конце строки кода, над методами, внутри
51
+ строк и областей #Вставка/#Удаление остаются.
52
+ Пример: bslfmt -i -sbc Модуль.bsl
53
+ -h, --help показать эту справку.
54
+
55
+ Несколько файлов — только с -i, --check или --diff; каждый обрабатывается
56
+ отдельно, отказ на одном не останавливает остальные.
57
+
58
+ Коды выхода:
59
+ 0 успех (при --check — всё уже отформатировано)
60
+ 1 --check: есть файлы, которые нужно отформатировать
61
+ 2 ошибка аргументов, чтения/записи, кодировки или отказ форматирования
62
+ 3 внутренняя ошибка форматтера
63
+
64
+ Примеры:
65
+ bslfmt Модуль.bsl показать результат, файл не меняется
66
+ bslfmt -i Модуль.bsl отформатировать файл на месте
67
+ bslfmt --check *.bsl проверить несколько файлов (для CI)
68
+ bslfmt - < Модуль.bsl форматировать стандартный ввод
69
+ """
70
+
71
+ OK, NEEDS_FORMAT, FAILED, INTERNAL = 0, 1, 2, 3
72
+
73
+ # Сообщения argparse приходят на английском; основные — по-русски.
74
+ _ARGPARSE_MESSAGES = (
75
+ ("unrecognized arguments", "неизвестные аргументы"),
76
+ ("expected one argument", "ожидается одно значение"),
77
+ ("ignored explicit argument", "лишнее значение у флага"),
78
+ )
79
+
80
+
81
+ class _UsageError(Exception):
82
+ """Неверные аргументы командной строки."""
83
+
84
+
85
+ class _Parser(argparse.ArgumentParser):
86
+ def error(self, message: str) -> None: # type: ignore[override]
87
+ for english, russian in _ARGPARSE_MESSAGES:
88
+ message = message.replace(english, russian)
89
+ raise _UsageError(message)
90
+
91
+
92
+ def _write(stream, text: str) -> None:
93
+ # Байты UTF-8 без перевода \n в \r\n: так вывод читают агенты и хуки, а
94
+ # консоль Windows принимает UTF-8 через buffer (PEP 528).
95
+ if stream is None:
96
+ raise OSError("стандартный вывод недоступен")
97
+ buffer = getattr(stream, "buffer", None)
98
+ if buffer is None:
99
+ stream.write(text)
100
+ return
101
+ stream.flush()
102
+ buffer.write(text.encode("utf-8"))
103
+ buffer.flush()
104
+
105
+
106
+ def _report_error(text: str) -> None:
107
+ try:
108
+ _write(sys.stderr, f"bslfmt: {text}\n")
109
+ except OSError:
110
+ pass
111
+
112
+
113
+ def _read_stdin() -> str:
114
+ # Байты UTF-8, а не текстовый поток: иначе на Windows действуют кодировка
115
+ # локали и замена CRLF на LF.
116
+ if sys.stdin is None:
117
+ raise OSError("стандартный ввод недоступен")
118
+ buffer = getattr(sys.stdin, "buffer", None)
119
+ if buffer is None:
120
+ return sys.stdin.read()
121
+ return buffer.read().decode("utf-8")
122
+
123
+
124
+ def _read(name: str) -> str:
125
+ if name == "-":
126
+ return _read_stdin()
127
+ with Path(name).open("r", encoding="utf-8", newline="") as stream:
128
+ return stream.read()
129
+
130
+
131
+ def _display_name(name: str) -> str:
132
+ return "стандартный ввод" if name == "-" else name
133
+
134
+
135
+ def _version() -> str:
136
+ try:
137
+ return metadata.version("bslfmt")
138
+ except metadata.PackageNotFoundError:
139
+ return "(версия неизвестна: пакет не установлен)"
140
+
141
+
142
+ def _changed_lines(source: str, formatted: str) -> int:
143
+ before, after = _split_lines(source), _split_lines(formatted)
144
+ if len(before) == len(after):
145
+ return sum(old != new for old, new in zip(before, after))
146
+ matcher = difflib.SequenceMatcher(None, before, after)
147
+ return sum(
148
+ max(i2 - i1, j2 - j1)
149
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes()
150
+ if tag != "equal"
151
+ )
152
+
153
+
154
+ def _diff(name: str, source: str, formatted: str) -> str:
155
+ return "".join(difflib.unified_diff(
156
+ _split_lines(source),
157
+ _split_lines(formatted),
158
+ fromfile=name,
159
+ tofile=f"{name} (formatted)",
160
+ ))
161
+
162
+
163
+ def _replace_file(path: Path, text: str) -> None:
164
+ """Заменить содержимое файла атомарно: временный файл рядом и os.replace.
165
+
166
+ При сбое исходный файл остаётся прежним, временный удаляется. Права
167
+ файла сохраняются; у символической ссылки меняется цель. Файл только для
168
+ чтения не переписывается ни на одной ОС: на Windows замена всё равно
169
+ упала бы, а на POSIX молча обошла бы запрет.
170
+ """
171
+ target = path.resolve()
172
+ if not os.access(target, os.W_OK):
173
+ raise OSError("файл только для чтения")
174
+ descriptor, temp_name = tempfile.mkstemp(
175
+ dir=target.parent, prefix=f".{target.name}.", suffix=".tmp"
176
+ )
177
+ temp = Path(temp_name)
178
+ try:
179
+ with os.fdopen(descriptor, "wb") as stream:
180
+ stream.write(text.encode("utf-8"))
181
+ shutil.copymode(target, temp)
182
+ os.replace(temp, target)
183
+ except BaseException:
184
+ # Уборка не должна подменять исходную ошибку: на Windows временный
185
+ # файл с перенесённым атрибутом «только чтение» не удаляется без chmod.
186
+ with contextlib.suppress(OSError):
187
+ os.chmod(temp, stat.S_IREAD | stat.S_IWRITE)
188
+ with contextlib.suppress(OSError):
189
+ temp.unlink(missing_ok=True)
190
+ raise
191
+
192
+
193
+ def _parse(argv: list[str] | None) -> argparse.Namespace:
194
+ parser = _Parser(prog="bslfmt", add_help=False)
195
+ parser.add_argument("files", nargs="*")
196
+ parser.add_argument("-h", "--help", action="store_true")
197
+ parser.add_argument("--version", action="store_true")
198
+ parser.add_argument("-i", "--in-place", action="store_true")
199
+ parser.add_argument("--check", action="store_true")
200
+ parser.add_argument("--diff", action="store_true")
201
+ parser.add_argument("--output", type=Path)
202
+ parser.add_argument("-sbc", "--strip-body-comments", action="store_true")
203
+ # «--» завершает флаги: хвост — только файлы. Разбираем сами:
204
+ # parse_intermixed_args до Python 3.13 не понимает «--».
205
+ arguments = list(sys.argv[1:] if argv is None else argv)
206
+ files_after_dash: list[str] = []
207
+ if "--" in arguments:
208
+ split = arguments.index("--")
209
+ arguments, files_after_dash = arguments[:split], arguments[split + 1:]
210
+ # Файлы и флаги в любом порядке: «bslfmt а.bsl -i б.bsl».
211
+ args = parser.parse_intermixed_args(arguments)
212
+ args.files += files_after_dash
213
+ if args.help or args.version:
214
+ return args
215
+ if not args.files:
216
+ raise _UsageError("не указан файл")
217
+ if args.in_place and (args.check or args.diff or args.output):
218
+ raise _UsageError("-i нельзя вместе с --check, --diff и --output")
219
+ if args.output and (args.check or args.diff):
220
+ raise _UsageError("--output нельзя вместе с --check и --diff")
221
+ if len(args.files) > 1 and not (args.in_place or args.check or args.diff):
222
+ raise _UsageError("несколько файлов — только с -i, --check или --diff")
223
+ if "-" in args.files and (len(args.files) > 1 or args.in_place):
224
+ raise _UsageError("стандартный ввод («-») — только единственным файлом и не с -i")
225
+ return args
226
+
227
+
228
+ def _process(name: str, args: argparse.Namespace) -> int:
229
+ source = _read(name)
230
+ formatted = format_code(source, strip_body_comments=args.strip_body_comments)
231
+ if args.in_place:
232
+ if formatted == source:
233
+ _write(sys.stdout, f"{name}: без изменений\n")
234
+ else:
235
+ _replace_file(Path(name), formatted)
236
+ _write(sys.stdout, f"{name}: изменён (строк: {_changed_lines(source, formatted)})\n")
237
+ return OK
238
+ if args.check:
239
+ if formatted == source:
240
+ return OK
241
+ _write(sys.stdout, f"{_display_name(name)}: нужно отформатировать\n")
242
+ if args.diff:
243
+ _write(sys.stdout, _diff(name, source, formatted))
244
+ return NEEDS_FORMAT
245
+ if args.diff:
246
+ _write(sys.stdout, _diff(name, source, formatted))
247
+ return OK
248
+ if args.output:
249
+ if name != "-" and args.output.resolve() == Path(name).resolve():
250
+ raise FormatError("выходной файл должен отличаться от исходного")
251
+ # Режим "x" создаёт только новый файл, поэтому при сбое записи
252
+ # удаляем именно свой неполный файл: он не блокирует повторный запуск.
253
+ stream = args.output.open("x", encoding="utf-8", newline="")
254
+ try:
255
+ with stream:
256
+ stream.write(formatted)
257
+ except BaseException:
258
+ args.output.unlink(missing_ok=True)
259
+ raise
260
+ return OK
261
+ _write(sys.stdout, formatted)
262
+ return OK
263
+
264
+
265
+ def main(argv: list[str] | None = None) -> int:
266
+ """Запустить CLI; коды выхода — в HELP."""
267
+ try:
268
+ args = _parse(argv)
269
+ except _UsageError as error:
270
+ _report_error(f"ошибка: {error}\nСправка: bslfmt --help")
271
+ return FAILED
272
+ if args.help:
273
+ _write(sys.stdout, HELP)
274
+ return OK
275
+ if args.version:
276
+ _write(sys.stdout, f"bslfmt {_version()}\n")
277
+ return OK
278
+ code = OK
279
+ for name in args.files:
280
+ try:
281
+ result = _process(name, args)
282
+ except (OSError, UnicodeError, LexerError, FormatError) as error:
283
+ _report_error(f"{_display_name(name)}: {error}")
284
+ result = FAILED
285
+ except Exception as error:
286
+ # Ошибка в самом форматтере: короткое сообщение без traceback.
287
+ _report_error(f"{_display_name(name)}: внутренняя ошибка форматтера "
288
+ f"({type(error).__name__})")
289
+ result = INTERNAL
290
+ code = max(code, result)
291
+ return code
292
+
293
+
294
+ if __name__ == "__main__":
295
+ raise SystemExit(main())