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 ADDED
@@ -0,0 +1,3 @@
1
+ """xbsl-lint – линтер исходников 1С:Элемент (пары .yaml/.xbsl)."""
2
+
3
+ __version__ = "0.2.0"
xbsllint/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from xbsllint.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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"))
@@ -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