xbsllint 0.2.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.
- xbsllint/__init__.py +3 -0
- xbsllint/__main__.py +4 -0
- xbsllint/cli.py +126 -0
- xbsllint/dataset.py +70 -0
- xbsllint/diagnostics.py +35 -0
- xbsllint/engine.py +180 -0
- xbsllint/lexer.py +222 -0
- xbsllint/mcp_server.py +104 -0
- xbsllint/rules/__init__.py +27 -0
- xbsllint/rules/_syntax.py +385 -0
- xbsllint/rules/code_structure.py +114 -0
- xbsllint/rules/handlers.py +73 -0
- xbsllint/rules/locals_usage.py +152 -0
- xbsllint/rules/semantics.py +258 -0
- xbsllint/rules/structure.py +35 -0
- xbsllint/rules/style_conditions.py +117 -0
- xbsllint/rules/style_layout.py +135 -0
- xbsllint/rules/style_naming.py +198 -0
- xbsllint/rules/style_strings.py +152 -0
- xbsllint/rules/style_types.py +191 -0
- xbsllint/rules/typography.py +90 -0
- xbsllint/rules/whitespace.py +41 -0
- xbsllint/rules/yaml_properties.py +87 -0
- xbsllint/rules/yaml_schema.py +143 -0
- xbsllint/web.py +383 -0
- xbsllint-0.2.0.dist-info/METADATA +155 -0
- xbsllint-0.2.0.dist-info/RECORD +32 -0
- xbsllint-0.2.0.dist-info/WHEEL +5 -0
- xbsllint-0.2.0.dist-info/entry_points.txt +4 -0
- xbsllint-0.2.0.dist-info/licenses/LICENSE +21 -0
- xbsllint-0.2.0.dist-info/licenses/NOTICE +36 -0
- xbsllint-0.2.0.dist-info/top_level.txt +1 -0
xbsllint/__init__.py
ADDED
xbsllint/__main__.py
ADDED
xbsllint/cli.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Точка входа командной строки: xbsllint / python -m xbsllint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from xbsllint import __version__, dataset
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def discover(paths: list[str]) -> list[Path]:
|
|
13
|
+
"""Собрать файлы исходников (.xbsl и .yaml) по указанным путям."""
|
|
14
|
+
out: list[Path] = []
|
|
15
|
+
for raw in paths:
|
|
16
|
+
p = Path(raw)
|
|
17
|
+
if p.is_file():
|
|
18
|
+
if p.suffix in (".xbsl", ".yaml"):
|
|
19
|
+
out.append(p)
|
|
20
|
+
elif p.is_dir():
|
|
21
|
+
out.extend(sorted(p.rglob("*.xbsl")))
|
|
22
|
+
out.extend(sorted(p.rglob("*.yaml")))
|
|
23
|
+
# Уникализируем, сохраняя порядок
|
|
24
|
+
seen: set[Path] = set()
|
|
25
|
+
uniq: list[Path] = []
|
|
26
|
+
for f in out:
|
|
27
|
+
rp = f.resolve()
|
|
28
|
+
if rp not in seen:
|
|
29
|
+
seen.add(rp)
|
|
30
|
+
uniq.append(f)
|
|
31
|
+
return uniq
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog="xbsllint",
|
|
37
|
+
description="Линтер исходников 1С:Элемент (пары .yaml/.xbsl).",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("paths", nargs="*", default=["."], help="файлы или каталоги для проверки")
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--select",
|
|
42
|
+
metavar="ID/ГРУППА/ТИР",
|
|
43
|
+
help="проверять только эти правила (через запятую: id, группа – часть id до '/' "
|
|
44
|
+
"(напр. style) – или буква тира A/B/C/D)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--ignore",
|
|
48
|
+
metavar="ID/ГРУППА/ТИР",
|
|
49
|
+
help="исключить эти правила (через запятую: id, группа или буква тира)",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--list-rules", action="store_true", help="вывести список правил и выйти"
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--element-version",
|
|
56
|
+
metavar="ВЕРСИЯ",
|
|
57
|
+
help="версия данных Элемента (по умолчанию – последняя из бандла)",
|
|
58
|
+
)
|
|
59
|
+
data_note = ""
|
|
60
|
+
try:
|
|
61
|
+
data_note = (
|
|
62
|
+
f" (данные Элемента: {dataset.default_version()}; "
|
|
63
|
+
f"доступно: {', '.join(dataset.available_versions())})"
|
|
64
|
+
)
|
|
65
|
+
except dataset.DatasetError:
|
|
66
|
+
pass
|
|
67
|
+
parser.add_argument("--version", action="version", version=f"xbsllint {__version__}{data_note}")
|
|
68
|
+
return parser
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_set(value: str | None) -> set[str] | None:
|
|
72
|
+
if not value:
|
|
73
|
+
return None
|
|
74
|
+
return {part.strip() for part in value.split(",") if part.strip()}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv: list[str] | None = None) -> int:
|
|
78
|
+
# Вывод линтера всегда в UTF-8, независимо от кодировки консоли (важно для кириллицы
|
|
79
|
+
# и для перенаправления в файл/редактор).
|
|
80
|
+
for stream in (sys.stdout, sys.stderr):
|
|
81
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
82
|
+
if reconfigure is not None:
|
|
83
|
+
try:
|
|
84
|
+
reconfigure(encoding="utf-8")
|
|
85
|
+
except (ValueError, OSError):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
parser = build_parser()
|
|
89
|
+
args = parser.parse_args(argv)
|
|
90
|
+
|
|
91
|
+
if args.element_version:
|
|
92
|
+
dataset.set_version(args.element_version)
|
|
93
|
+
try:
|
|
94
|
+
dataset.resolve_version() # проверить доступность выбранной версии данных
|
|
95
|
+
except dataset.DatasetError as exc:
|
|
96
|
+
print(f"Ошибка данных Элемента: {exc}", file=sys.stderr)
|
|
97
|
+
return 2
|
|
98
|
+
|
|
99
|
+
from xbsllint.engine import RULES, run
|
|
100
|
+
|
|
101
|
+
if args.list_rules:
|
|
102
|
+
for r in sorted(RULES, key=lambda x: (x.tier, x.id)):
|
|
103
|
+
mark = " " if r.enabled_by_default else "off"
|
|
104
|
+
print(f"{r.tier} {mark} {r.id:30} {r.severity.value:7} {r.title}")
|
|
105
|
+
if not RULES:
|
|
106
|
+
print("(правила ещё не зарегистрированы)")
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
files = discover(args.paths or ["."])
|
|
110
|
+
diagnostics = run(files, select=_parse_set(args.select), ignore=_parse_set(args.ignore))
|
|
111
|
+
for d in sorted(diagnostics, key=lambda x: x.sort_key()):
|
|
112
|
+
print(d.format())
|
|
113
|
+
|
|
114
|
+
n_xbsl = sum(1 for f in files if f.suffix == ".xbsl")
|
|
115
|
+
n_yaml = sum(1 for f in files if f.suffix == ".yaml")
|
|
116
|
+
n_err = sum(1 for d in diagnostics if d.severity.value == "error")
|
|
117
|
+
print(
|
|
118
|
+
f"\nПроверено файлов: {len(files)} ({n_xbsl} .xbsl, {n_yaml} .yaml); "
|
|
119
|
+
f"замечаний: {len(diagnostics)} (ошибок: {n_err})",
|
|
120
|
+
file=sys.stderr,
|
|
121
|
+
)
|
|
122
|
+
return 1 if n_err else 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
raise SystemExit(main())
|
xbsllint/dataset.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Версионированный доступ к данным о языке и типах (самодостаточно, без дистрибутива).
|
|
2
|
+
|
|
3
|
+
Данные лежат в xbsllint/data/element/<версия>/{language.json, stdlib.json}, а
|
|
4
|
+
xbsllint/data/element/index.json хранит список доступных версий и версию по умолчанию.
|
|
5
|
+
Версия выбирается так: явный аргумент/set_version > env XBSLLINT_ELEMENT_VERSION > default из индекса.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
DATA_ROOT = Path(__file__).parent / "data" / "element"
|
|
16
|
+
_ENV_VERSION = "XBSLLINT_ELEMENT_VERSION"
|
|
17
|
+
_selected: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DatasetError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_index() -> dict:
|
|
25
|
+
idx = DATA_ROOT / "index.json"
|
|
26
|
+
if not idx.exists():
|
|
27
|
+
raise DatasetError(
|
|
28
|
+
f"Нет индекса версий данных: {idx}. Сгенерируйте данные через tools/extract_*.py."
|
|
29
|
+
)
|
|
30
|
+
return json.loads(idx.read_text(encoding="utf-8"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def available_versions() -> list[str]:
|
|
34
|
+
try:
|
|
35
|
+
return list(_read_index().get("available", []))
|
|
36
|
+
except DatasetError:
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def default_version() -> str:
|
|
41
|
+
version = _read_index().get("default")
|
|
42
|
+
if not version:
|
|
43
|
+
raise DatasetError("В индексе версий не задан default")
|
|
44
|
+
return version
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def set_version(version: str | None) -> None:
|
|
48
|
+
"""Зафиксировать версию данных для процесса (CLI --element-version). Сбрасывает кэш."""
|
|
49
|
+
global _selected
|
|
50
|
+
_selected = version
|
|
51
|
+
load_json.cache_clear()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_version(override: str | None = None) -> str:
|
|
55
|
+
version = override or _selected or os.environ.get(_ENV_VERSION) or default_version()
|
|
56
|
+
avail = available_versions()
|
|
57
|
+
if version not in avail:
|
|
58
|
+
raise DatasetError(
|
|
59
|
+
f"Версия данных '{version}' недоступна. Доступны: {', '.join(avail) or '—'}"
|
|
60
|
+
)
|
|
61
|
+
return version
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@lru_cache(maxsize=None)
|
|
65
|
+
def load_json(name: str, version: str | None = None) -> dict:
|
|
66
|
+
ver = resolve_version(version)
|
|
67
|
+
path = DATA_ROOT / ver / name
|
|
68
|
+
if not path.exists():
|
|
69
|
+
raise DatasetError(f"Нет файла данных '{name}' для версии {ver}: {path}")
|
|
70
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
xbsllint/diagnostics.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Модель диагностик линтера."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Severity(enum.Enum):
|
|
10
|
+
ERROR = "error"
|
|
11
|
+
WARNING = "warning"
|
|
12
|
+
INFO = "info"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Diagnostic:
|
|
17
|
+
"""Одно замечание линтера, привязанное к позиции в файле.
|
|
18
|
+
|
|
19
|
+
Строка и колонка – 1-индексированные (как в редакторах и в выводе компиляторов).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
path: str
|
|
23
|
+
line: int
|
|
24
|
+
col: int
|
|
25
|
+
rule_id: str
|
|
26
|
+
severity: Severity
|
|
27
|
+
message: str
|
|
28
|
+
|
|
29
|
+
def format(self) -> str:
|
|
30
|
+
# Формат, дружелюбный к переходу по клику: path:line:col
|
|
31
|
+
return f"{self.path}:{self.line}:{self.col}: {self.severity.value}: [{self.rule_id}] {self.message}"
|
|
32
|
+
|
|
33
|
+
# Сортировка замечаний по месту возникновения
|
|
34
|
+
def sort_key(self) -> tuple:
|
|
35
|
+
return (self.path, self.line, self.col, self.rule_id)
|
xbsllint/engine.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Ядро линтера: загрузка исходников, реестр правил и их запуск.
|
|
2
|
+
|
|
3
|
+
Правила регистрируются декоратором @rule(...) с метаданными (id, тир, severity, scope).
|
|
4
|
+
Область (scope):
|
|
5
|
+
- 'file' – пофайловое правило: (SourceFile) -> Iterable[Diagnostic];
|
|
6
|
+
- 'project' – кросс-файловое (напр. уникальность Ид): (list[SourceFile]) -> Iterable[Diagnostic].
|
|
7
|
+
|
|
8
|
+
Тиры: 'A' структура/YAML, 'B' текст/конвенции, 'C' парсер/структура кода, 'D' семантика.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Iterable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from xbsllint.diagnostics import Diagnostic, Severity
|
|
18
|
+
|
|
19
|
+
UTF8_BOM = b"\xef\xbb\xbf"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SourceFile:
|
|
24
|
+
path: Path
|
|
25
|
+
kind: str # 'xbsl' | 'yaml'
|
|
26
|
+
data: bytes
|
|
27
|
+
text: str
|
|
28
|
+
had_bom: bool
|
|
29
|
+
newline: str # '\n', '\r\n', '\r', 'mixed' или '' если переводов строк нет
|
|
30
|
+
decode_error: str | None = None
|
|
31
|
+
# Кэш тяжёлых представлений (токены, AST, YAML) – заполняется по требованию
|
|
32
|
+
cache: dict = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def rel(self) -> str:
|
|
36
|
+
return str(self.path)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _detect_newline(data: bytes) -> str:
|
|
40
|
+
crlf = data.count(b"\r\n")
|
|
41
|
+
cr = data.count(b"\r") - crlf
|
|
42
|
+
lf = data.count(b"\n") - crlf
|
|
43
|
+
kinds = [k for k, n in (("\r\n", crlf), ("\r", cr), ("\n", lf)) if n]
|
|
44
|
+
if not kinds:
|
|
45
|
+
return ""
|
|
46
|
+
if len(kinds) > 1:
|
|
47
|
+
return "mixed"
|
|
48
|
+
return kinds[0]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def make_source(path: Path, data: bytes) -> SourceFile:
|
|
52
|
+
"""Собрать SourceFile из пути и байтов (общий код для диска и памяти)."""
|
|
53
|
+
kind = "xbsl" if path.suffix == ".xbsl" else "yaml"
|
|
54
|
+
had_bom = data.startswith(UTF8_BOM)
|
|
55
|
+
decode_error: str | None = None
|
|
56
|
+
try:
|
|
57
|
+
text = data.decode("utf-8-sig")
|
|
58
|
+
except UnicodeDecodeError as exc:
|
|
59
|
+
decode_error = str(exc)
|
|
60
|
+
text = data.decode("utf-8", errors="replace")
|
|
61
|
+
return SourceFile(
|
|
62
|
+
path=path,
|
|
63
|
+
kind=kind,
|
|
64
|
+
data=data,
|
|
65
|
+
text=text,
|
|
66
|
+
had_bom=had_bom,
|
|
67
|
+
newline=_detect_newline(data),
|
|
68
|
+
decode_error=decode_error,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load(path: Path) -> SourceFile:
|
|
73
|
+
return make_source(path, path.read_bytes())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_text(name: str, content: str) -> SourceFile:
|
|
77
|
+
"""Собрать SourceFile из содержимого в памяти (для MCP lint_source)."""
|
|
78
|
+
return make_source(Path(name), content.encode("utf-8"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --- Реестр правил -------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
FileRuleFn = Callable[[SourceFile], Iterable[Diagnostic]]
|
|
84
|
+
ProjectRuleFn = Callable[[list[SourceFile]], Iterable[Diagnostic]]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class RuleInfo:
|
|
89
|
+
id: str
|
|
90
|
+
title: str
|
|
91
|
+
tier: str # 'A' | 'B' | 'C' | 'D'
|
|
92
|
+
scope: str # 'file' | 'project'
|
|
93
|
+
severity: Severity
|
|
94
|
+
func: Callable
|
|
95
|
+
enabled_by_default: bool = True
|
|
96
|
+
|
|
97
|
+
def as_dict(self) -> dict:
|
|
98
|
+
return {
|
|
99
|
+
"id": self.id,
|
|
100
|
+
"title": self.title,
|
|
101
|
+
"tier": self.tier,
|
|
102
|
+
"scope": self.scope,
|
|
103
|
+
"severity": self.severity.value,
|
|
104
|
+
"enabled_by_default": self.enabled_by_default,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
RULES: list[RuleInfo] = []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def rule(
|
|
112
|
+
rule_id: str,
|
|
113
|
+
title: str,
|
|
114
|
+
tier: str,
|
|
115
|
+
*,
|
|
116
|
+
scope: str = "file",
|
|
117
|
+
severity: Severity = Severity.WARNING,
|
|
118
|
+
enabled_by_default: bool = True,
|
|
119
|
+
) -> Callable[[Callable], Callable]:
|
|
120
|
+
"""Декоратор регистрации правила с метаданными."""
|
|
121
|
+
|
|
122
|
+
def deco(fn: Callable) -> Callable:
|
|
123
|
+
RULES.append(RuleInfo(rule_id, title, tier, scope, severity, fn, enabled_by_default))
|
|
124
|
+
return fn
|
|
125
|
+
|
|
126
|
+
return deco
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _is_selected(info: RuleInfo, select: set[str] | None, ignore: set[str] | None) -> bool:
|
|
130
|
+
# select/ignore сопоставляются по id правила, по группе (часть id до '/')
|
|
131
|
+
# или по букве тира ('A'..'D')
|
|
132
|
+
group = info.id.split("/", 1)[0]
|
|
133
|
+
|
|
134
|
+
def matches(keys: set[str]) -> bool:
|
|
135
|
+
return info.id in keys or group in keys or info.tier in keys
|
|
136
|
+
|
|
137
|
+
if ignore and matches(ignore):
|
|
138
|
+
return False
|
|
139
|
+
if select:
|
|
140
|
+
# Явный выбор включает правило, даже если оно выключено по умолчанию
|
|
141
|
+
return matches(select)
|
|
142
|
+
return info.enabled_by_default
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def active_rules(select: set[str] | None = None, ignore: set[str] | None = None) -> list[RuleInfo]:
|
|
146
|
+
return [r for r in RULES if _is_selected(r, select, ignore)]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def run_sources(
|
|
150
|
+
sources: list[SourceFile],
|
|
151
|
+
*,
|
|
152
|
+
select: set[str] | None = None,
|
|
153
|
+
ignore: set[str] | None = None,
|
|
154
|
+
scopes: tuple[str, ...] = ("file", "project"),
|
|
155
|
+
) -> list[Diagnostic]:
|
|
156
|
+
diags: list[Diagnostic] = []
|
|
157
|
+
active = active_rules(select, ignore)
|
|
158
|
+
if "file" in scopes:
|
|
159
|
+
file_rules = [r for r in active if r.scope == "file"]
|
|
160
|
+
for src in sources:
|
|
161
|
+
for r in file_rules:
|
|
162
|
+
diags.extend(r.func(src))
|
|
163
|
+
if "project" in scopes:
|
|
164
|
+
for r in (r for r in active if r.scope == "project"):
|
|
165
|
+
diags.extend(r.func(sources))
|
|
166
|
+
return diags
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run(
|
|
170
|
+
paths: list[Path],
|
|
171
|
+
*,
|
|
172
|
+
select: set[str] | None = None,
|
|
173
|
+
ignore: set[str] | None = None,
|
|
174
|
+
) -> list[Diagnostic]:
|
|
175
|
+
sources = [load(p) for p in paths]
|
|
176
|
+
return run_sources(sources, select=select, ignore=ignore)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# Импорт пакета правил регистрирует их (декораторы выполняются при импорте модулей).
|
|
180
|
+
from xbsllint import rules as _rules # noqa: E402,F401
|
xbsllint/lexer.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Лексер XBSL.
|
|
2
|
+
|
|
3
|
+
Токенизация верна грамматике платформы (InternalBsl.g): двуязычные ключевые слова,
|
|
4
|
+
идентификаторы (Latin/Cyrillic/CJK), числа (в т.ч. форма с буквенными частями),
|
|
5
|
+
многострочные строки с экранированием и интерполяцией, комментарии // /* */ /** */.
|
|
6
|
+
|
|
7
|
+
Языковые таблицы берутся из xbsllint/data/language.json (генерит tools/extract_grammar.py).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import bisect
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
|
|
17
|
+
from xbsllint import dataset
|
|
18
|
+
|
|
19
|
+
# Класс «буквы» из fragment RULE_LETTER грамматики (Latin + Cyrillic в Ā.. + CJK).
|
|
20
|
+
_LETTER = (
|
|
21
|
+
"A-Za-z"
|
|
22
|
+
"À-ÖØ-öø-ÿ"
|
|
23
|
+
"Ā--㌀-㍿㐀-㴭一-鿿豈-"
|
|
24
|
+
)
|
|
25
|
+
_IDENT_RE = re.compile(rf"[_{_LETTER}][_{_LETTER}0-9]*")
|
|
26
|
+
# RULE_LITERAL_WITH_NUMBER_PARTS: сначала форма с буквенными частями (макс. откус), затем число.
|
|
27
|
+
_NUMBER_RE = re.compile(rf"(?:[0-9]+[{_LETTER}]+)+[0-9]*|[0-9]+(?:\.[0-9]+)?")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@lru_cache(maxsize=1)
|
|
31
|
+
def _language() -> dict:
|
|
32
|
+
return dataset.load_json("language.json")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@lru_cache(maxsize=1)
|
|
36
|
+
def _keyword_forms() -> dict[str, str]:
|
|
37
|
+
forms: dict[str, str] = {}
|
|
38
|
+
for canon, entry in _language()["keywords"].items():
|
|
39
|
+
for f in entry["forms"]:
|
|
40
|
+
forms[f] = canon
|
|
41
|
+
return forms
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@lru_cache(maxsize=1)
|
|
45
|
+
def _operators() -> list[str]:
|
|
46
|
+
# По убыванию длины – для максимального откуса (?? и ?. раньше ?, :: раньше : и т.д.)
|
|
47
|
+
return sorted(_language()["operators"], key=lambda s: (-len(s), s))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Token:
|
|
52
|
+
kind: str # KEYWORD | IDENT | NUMBER | STRING | COMMENT | OP | BOM | UNKNOWN | EOF
|
|
53
|
+
value: str
|
|
54
|
+
start: int # смещение начала (вкл.)
|
|
55
|
+
end: int # смещение конца (искл.)
|
|
56
|
+
line: int # 1-индекс
|
|
57
|
+
col: int # 1-индекс
|
|
58
|
+
end_line: int
|
|
59
|
+
end_col: int
|
|
60
|
+
canonical: str | None = None # для KEYWORD – канон (IF/METHOD/...)
|
|
61
|
+
subkind: str | None = None # для COMMENT – line|block|doc
|
|
62
|
+
flags: dict = field(default_factory=dict) # напр. {'unterminated': True}
|
|
63
|
+
|
|
64
|
+
def __repr__(self) -> str: # компактно для отладки
|
|
65
|
+
v = self.value if len(self.value) <= 20 else self.value[:17] + "..."
|
|
66
|
+
extra = f" {self.canonical}" if self.canonical else (f" {self.subkind}" if self.subkind else "")
|
|
67
|
+
return f"<{self.kind}{extra} {v!r} @{self.line}:{self.col}>"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _line_starts(text: str) -> list[int]:
|
|
71
|
+
starts = [0]
|
|
72
|
+
i, n = 0, len(text)
|
|
73
|
+
while i < n:
|
|
74
|
+
c = text[i]
|
|
75
|
+
if c == "\r":
|
|
76
|
+
i += 2 if (i + 1 < n and text[i + 1] == "\n") else 1
|
|
77
|
+
starts.append(i)
|
|
78
|
+
elif c == "\n":
|
|
79
|
+
i += 1
|
|
80
|
+
starts.append(i)
|
|
81
|
+
else:
|
|
82
|
+
i += 1
|
|
83
|
+
return starts
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _LineMap:
|
|
87
|
+
def __init__(self, text: str) -> None:
|
|
88
|
+
self._starts = _line_starts(text)
|
|
89
|
+
|
|
90
|
+
def linecol(self, offset: int) -> tuple[int, int]:
|
|
91
|
+
line = bisect.bisect_right(self._starts, offset)
|
|
92
|
+
return line, offset - self._starts[line - 1] + 1
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def tokenize(text: str) -> list[Token]:
|
|
96
|
+
"""Разобрать исходный текст в список токенов (без пробелов и переводов строк)."""
|
|
97
|
+
lm = _LineMap(text)
|
|
98
|
+
ops = _operators()
|
|
99
|
+
kwforms = _keyword_forms()
|
|
100
|
+
tokens: list[Token] = []
|
|
101
|
+
i, n = 0, len(text)
|
|
102
|
+
|
|
103
|
+
def emit(kind: str, start: int, end: int, **kw) -> None:
|
|
104
|
+
sl, sc = lm.linecol(start)
|
|
105
|
+
el, ec = lm.linecol(end)
|
|
106
|
+
tokens.append(Token(kind, text[start:end], start, end, sl, sc, el, ec, **kw))
|
|
107
|
+
|
|
108
|
+
while i < n:
|
|
109
|
+
c = text[i]
|
|
110
|
+
|
|
111
|
+
# BOM (обычно снимается при декодировании utf-8-sig, но на всякий случай)
|
|
112
|
+
if c == "":
|
|
113
|
+
emit("BOM", i, i + 1)
|
|
114
|
+
i += 1
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# Пробелы и переводы строк – тривия, пропускаем
|
|
118
|
+
if c in " \t":
|
|
119
|
+
i += 1
|
|
120
|
+
continue
|
|
121
|
+
if c in "\r\n":
|
|
122
|
+
i += 2 if (c == "\r" and i + 1 < n and text[i + 1] == "\n") else 1
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# Комментарии (раньше оператора '/')
|
|
126
|
+
if c == "/" and i + 1 < n:
|
|
127
|
+
nxt = text[i + 1]
|
|
128
|
+
if nxt == "/":
|
|
129
|
+
j = i + 2
|
|
130
|
+
while j < n and text[j] not in "\r\n":
|
|
131
|
+
j += 1
|
|
132
|
+
emit("COMMENT", i, j, subkind="line")
|
|
133
|
+
i = j
|
|
134
|
+
continue
|
|
135
|
+
if nxt == "*":
|
|
136
|
+
# doc-комментарий /** ... */ (но не /**/), иначе блочный /* ... */
|
|
137
|
+
is_doc = text.startswith("/**", i) and not text.startswith("/**/", i)
|
|
138
|
+
close = text.find("*/", i + 2)
|
|
139
|
+
if close == -1:
|
|
140
|
+
emit("COMMENT", i, n, subkind="doc" if is_doc else "block", flags={"unterminated": True})
|
|
141
|
+
i = n
|
|
142
|
+
continue
|
|
143
|
+
end = close + 2
|
|
144
|
+
emit("COMMENT", i, end, subkind="doc" if is_doc else "block")
|
|
145
|
+
i = end
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
# Строки (многострочные, с экранированием \)
|
|
149
|
+
if c == '"':
|
|
150
|
+
j = i + 1
|
|
151
|
+
unterminated = True
|
|
152
|
+
while j < n:
|
|
153
|
+
cj = text[j]
|
|
154
|
+
if cj == "\\":
|
|
155
|
+
j += 2
|
|
156
|
+
continue
|
|
157
|
+
if cj == '"':
|
|
158
|
+
j += 1
|
|
159
|
+
unterminated = False
|
|
160
|
+
break
|
|
161
|
+
j += 1
|
|
162
|
+
flags = {"unterminated": True} if unterminated else {}
|
|
163
|
+
emit("STRING", i, min(j, n), flags=flags)
|
|
164
|
+
i = min(j, n)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Числа (лидирующая цифра ⇒ число)
|
|
168
|
+
if c.isdigit():
|
|
169
|
+
m = _NUMBER_RE.match(text, i)
|
|
170
|
+
end = m.end() if m else i + 1
|
|
171
|
+
emit("NUMBER", i, end)
|
|
172
|
+
i = end
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
# Идентификаторы и ключевые слова (максимальный откус, затем проверка на ключевое слово)
|
|
176
|
+
if c == "_" or _IDENT_RE.match(text, i):
|
|
177
|
+
m = _IDENT_RE.match(text, i)
|
|
178
|
+
word = m.group(0)
|
|
179
|
+
canon = kwforms.get(word)
|
|
180
|
+
if canon is not None:
|
|
181
|
+
emit("KEYWORD", i, m.end(), canonical=canon)
|
|
182
|
+
else:
|
|
183
|
+
emit("IDENT", i, m.end())
|
|
184
|
+
i = m.end()
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
# Операторы и пунктуация (максимальный откус по длине)
|
|
188
|
+
matched = None
|
|
189
|
+
for op in ops:
|
|
190
|
+
if text.startswith(op, i):
|
|
191
|
+
matched = op
|
|
192
|
+
break
|
|
193
|
+
if matched is not None:
|
|
194
|
+
emit("OP", i, i + len(matched))
|
|
195
|
+
i += len(matched)
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
# Неизвестный символ – одиночный токен (пусть правило его пометит)
|
|
199
|
+
emit("UNKNOWN", i, i + 1)
|
|
200
|
+
i += 1
|
|
201
|
+
|
|
202
|
+
sl, sc = lm.linecol(n)
|
|
203
|
+
tokens.append(Token("EOF", "", n, n, sl, sc, sl, sc))
|
|
204
|
+
return tokens
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def tokens(source) -> list[Token]:
|
|
208
|
+
"""Токены исходного файла с кэшированием в source.cache."""
|
|
209
|
+
cached = source.cache.get("tokens")
|
|
210
|
+
if cached is None:
|
|
211
|
+
cached = tokenize(source.text)
|
|
212
|
+
source.cache["tokens"] = cached
|
|
213
|
+
return cached
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def linemap(source) -> _LineMap:
|
|
217
|
+
"""Карта смещение -> (строка, колонка) для файла, с кэшированием."""
|
|
218
|
+
lm = source.cache.get("linemap")
|
|
219
|
+
if lm is None:
|
|
220
|
+
lm = _LineMap(source.text)
|
|
221
|
+
source.cache["linemap"] = lm
|
|
222
|
+
return lm
|