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 +6 -0
- bslfmt/__main__.py +295 -0
- bslfmt/formatter.py +1194 -0
- bslfmt/lexer.py +345 -0
- bslfmt-0.20.1.dist-info/METADATA +180 -0
- bslfmt-0.20.1.dist-info/RECORD +11 -0
- bslfmt-0.20.1.dist-info/WHEEL +5 -0
- bslfmt-0.20.1.dist-info/entry_points.txt +2 -0
- bslfmt-0.20.1.dist-info/licenses/LICENSE +202 -0
- bslfmt-0.20.1.dist-info/licenses/NOTICE +40 -0
- bslfmt-0.20.1.dist-info/top_level.txt +1 -0
bslfmt/__init__.py
ADDED
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())
|