Files
dzentra_bot/scripts/check_documentation_integrity.py

4362 lines
151 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Локальная проверка обязательных документов и Markdown-ссылок."""
from __future__ import annotations
import argparse
import heapq
import html
import os
import re
import sys
import unicodedata
from bisect import bisect_left, bisect_right
from dataclasses import dataclass, replace
from html.entities import html5 as HTML5_ENTITIES
from pathlib import Path
from urllib.parse import unquote, urlsplit
REQUIRED_DOCUMENTS: tuple[str, ...] = (
"README.md",
"app/README.md",
"app/src/storage/README.md",
"docs/architecture/overview.md",
"docs/architecture/project_structure.md",
"docs/architecture/dzentra_target_architecture.md",
"docs/architecture/trades_feed.md",
"docs/operations/trades_feed_runtime.md",
"docs/roadmap/master-roadmap.md",
"docs/migrations/build_060_30_architecture.md",
"docs/migrations/build_060_20.md",
"docs/migrations/build_060_20_architecture.md",
"docs/migrations/build_060_20_1.md",
"docs/migrations/build_060_20_1_architecture.md",
"docs/migrations/build_060_21.md",
"docs/migrations/build_060_21_architecture.md",
"docs/migrations/build_060_22.md",
"docs/migrations/build_060_22_architecture.md",
"docs/migrations/build_060_23.md",
"docs/migrations/build_060_23_architecture.md",
"docs/migrations/build_060_24.md",
"docs/migrations/build_060_24_architecture.md",
"docs/migrations/build_060_25.md",
"docs/migrations/build_060_25_architecture.md",
"docs/migrations/build_060_26.md",
"docs/migrations/build_060_26_architecture.md",
"docs/migrations/build_060_27.md",
"docs/migrations/build_060_27_architecture.md",
"docs/migrations/build_060_28.md",
"docs/migrations/build_060_28_architecture.md",
"docs/migrations/build_060_29.md",
"docs/migrations/build_060_29_architecture.md",
)
DEFAULT_MARKDOWN_SOURCES: tuple[Path, ...] = (
Path("."),
Path("app"),
Path("app/src"),
Path("app/tools"),
Path("docs"),
)
_DEFAULT_SHALLOW_SOURCES = frozenset({Path("."), Path("app")})
_ALLOWED_EXTERNAL_SCHEMES = frozenset({"http", "https", "mailto", "ws", "wss"})
_FENCE_START = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})(.*)$")
_REFERENCE_DEFINITION = re.compile(
r"^[ \t]{0,3}\[((?:\\[^\r\n]|[^\]\\\r\n])+)\]:[ \t]*(.*)$"
)
_ATX_HEADING = re.compile(r"^[ ]{0,3}#{1,6}(?:[ \t]+|$)")
_SETEXT_HEADING = re.compile(r"^[ ]{0,3}(?:=+|-+)[ \t]*$")
_THEMATIC_BREAK = re.compile(
r"^[ ]{0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$"
)
_LIST_OR_QUOTE = re.compile(
r"^[ ]{0,3}(?:>|[-+*][ \t]+|\d{1,9}[.)][ \t]+)"
)
_HTML_BLOCK_INTERRUPT = re.compile(
r"^[ ]{0,3}(?:<!--|<\?|(?-i:<![A-Z]|<!\[CDATA\[)|</?(?:"
r"address|article|aside|base|basefont|blockquote|body|caption|center|"
r"col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|"
r"figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|"
r"legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|"
r"param|pre|script|search|section|style|summary|table|tbody|td|tfoot|"
r"th|thead|title|tr|track|ul"
r")(?:[ \t\r\n>]|/>))",
re.IGNORECASE,
)
_LIST_CONTAINER_PREFIX = re.compile(
r"^[ ]{0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+"
)
_GFM_DELIMITER_CELL = re.compile(r"^:?-{3,}:?$")
_RAW_HTML_TAG_START = re.compile(
r"<(/?)([A-Za-z][A-Za-z0-9-]*)(?=[ \t\r\n>]|/>)",
)
_RAW_HTML_BLOCK_TAG_START = re.compile(
r"<(script|pre|style|textarea)(?=[ \t\r\n>])",
re.IGNORECASE,
)
_EXTERNAL_AUTOLINK = re.compile(
r"<([A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\s]+)>",
re.IGNORECASE,
)
_WINDOWS_DRIVE = re.compile(r"^[A-Za-z]:")
_INVALID_PERCENT_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})")
_MARKDOWN_ESCAPABLE = frozenset("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~")
_COMMONMARK_ENTITY = re.compile(
r"&(?:#[0-9]{1,7}|#[xX][0-9A-Fa-f]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});"
)
@dataclass(frozen=True, slots=True)
class DocumentationIssue:
"""Одна воспроизводимая ошибка документации."""
code: str
source: str
line: int | None
target: str | None
message: str
@dataclass(frozen=True, slots=True)
class DocumentationIntegrityResult:
"""Полный результат одной проверки репозитория."""
scanned_documents: int
checked_links: int
issues: tuple[DocumentationIssue, ...]
@property
def is_clean(self) -> bool:
"""Показывает, что проверка не нашла ошибок."""
return not self.issues
@dataclass(frozen=True, slots=True)
class _MarkdownLink:
target: str
line: int
is_image: bool
@dataclass(frozen=True, slots=True)
class _PathInspection:
path: Path | None
issue_code: str | None = None
message: str | None = None
@dataclass(frozen=True, slots=True)
class _DirectoryIndex:
"""O(1)-индекс точных и регистронезависимых имён каталога."""
exact_names: dict[str, Path]
casefold_names: dict[str, tuple[Path, ...]]
@dataclass(frozen=True, slots=True)
class _ContainerFrame:
"""Один упорядоченный уровень пути Markdown-контейнера."""
kind: str
indent: int = 0
@dataclass(frozen=True, slots=True)
class _BlockContainer:
"""Упорядоченный путь цитат и списков блочного элемента."""
path: tuple[_ContainerFrame, ...] = ()
@property
def quote_depth(self) -> int:
"""Возвращает число уровней цитирования."""
return sum(frame.kind == "quote" for frame in self.path)
@property
def list_indent(self) -> int | None:
"""Возвращает отступ самого глубокого уровня списка."""
for frame in reversed(self.path):
if frame.kind == "list":
return frame.indent
return None
@dataclass(frozen=True, slots=True)
class _PhysicalLine:
"""Физическая строка с абсолютными смещениями в Markdown."""
start: int
content_end: int
end: int
number: int
content: str
@dataclass(frozen=True, slots=True)
class _LineContext:
"""Семантическое содержимое строки и идентификатор контейнера."""
line: _PhysicalLine
container: _BlockContainer
container_id: tuple[int, ...]
semantic_offset: int
semantic_content: str
explicit_list_item: bool
@dataclass(frozen=True, slots=True)
class _TableRow:
"""Разобранная строка-кандидат GFM-таблицы."""
container_id: tuple[int, ...]
cells: tuple[str, ...]
pipe_positions: tuple[int, ...]
@dataclass(frozen=True, slots=True)
class _RawHtmlTag:
"""Один синтаксически корректный необработанный HTML-тег."""
start: int
end: int
name: str
is_closing: bool
attribute_names: frozenset[str]
@dataclass(slots=True)
class _ParenthesisFrame:
"""Состояние одной открытой скобки во внутристрочном проходе."""
start: int
is_link_argument: bool
title_separator_seen: bool = False
quote: str | None = None
title_closed: bool = False
leading_content: bool = True
angle_destination: bool = False
invalid_argument: bool = False
@dataclass(frozen=True, slots=True)
class _InlineLinkCandidate:
"""Одна внутристрочная ссылка или картинка и разбор её аргумента."""
opening_bracket: int
closing_bracket: int
opening_parenthesis: int
closing_parenthesis: int | None
is_image: bool
target: str | None
error: str | None
shadowed_by_link: bool = False
inside_link_argument: bool = False
@dataclass(frozen=True, slots=True)
class _ReferenceLinkCandidate:
"""Одна полная, сокращённая или свёрнутая ссылочная конструкция."""
opening_bracket: int
closing_bracket: int
reference_label: str
is_image: bool
suffix_end: int | None
@dataclass(frozen=True, slots=True)
class _ReferenceDefinition:
"""Одно блочное определение reference-ссылки до inline-разбора."""
context_index: int
end_context_index: int
label: str
target: str | None
error: str | None
span_start: int
span_end: int
@property
def is_valid(self) -> bool:
"""Показывает синтаксическую корректность определения."""
return self.error is None and self.target is not None
@dataclass(frozen=True, slots=True)
class _InlineCharacterIndex:
"""Позиции символов для быстрого разбора перекрывающихся ссылок."""
whitespace: tuple[int, ...]
non_whitespace: tuple[int, ...]
line_endings: tuple[int, ...]
angle_openings: tuple[int, ...]
angle_closings: tuple[int, ...]
parentheses: tuple[int, ...]
double_quotes: tuple[int, ...]
single_quotes: tuple[int, ...]
def _issue_sort_key(issue: DocumentationIssue) -> tuple[str, int, str, str]:
return (
issue.source,
issue.line or 0,
issue.code,
issue.target or "",
)
def _relative_source(repository_root: Path, path: Path) -> str:
try:
return path.relative_to(repository_root).as_posix()
except ValueError:
return path.as_posix()
def _path_kind(path: Path) -> tuple[bool, bool, OSError | None]:
try:
return path.is_file(), path.is_dir(), None
except OSError as error:
return False, False, error
def _contains_control_character(value: str) -> bool:
return any(unicodedata.category(character) == "Cc" for character in value)
def _normalise_reference_label(label: str) -> str:
return re.sub(r"[ \t\r\n]+", " ", label).strip(" \t\r\n").casefold()
def _valid_reference_label(label: str) -> bool:
return (
len(label) <= 999
and bool(_normalise_reference_label(label))
and all(
character not in {"[", "]"} or _is_escaped(label, index)
for index, character in enumerate(label)
)
)
def _line_start_offsets(text: str) -> tuple[int, ...]:
"""Индексирует только окончания строк CommonMark: LF, CR и CRLF."""
starts = [0]
index = 0
while index < len(text):
character = text[index]
if character == "\r":
index += 2 if text[index + 1 : index + 2] == "\n" else 1
starts.append(index)
continue
if character == "\n":
index += 1
starts.append(index)
continue
index += 1
return tuple(starts)
def _line_number(line_starts: tuple[int, ...], offset: int) -> int:
"""Возвращает номер строки с единицы за O(log N)."""
return bisect_right(line_starts, offset)
def _is_escaped(text: str | list[str], offset: int) -> bool:
backslashes = 0
index = offset - 1
while index >= 0 and text[index] == "\\":
backslashes += 1
index -= 1
return backslashes % 2 == 1
def _mask_range(buffer: list[str], start: int, end: int) -> None:
for index in range(start, end):
if buffer[index] not in {"\n", "\r"}:
buffer[index] = " "
def _physical_lines(text: str) -> tuple[_PhysicalLine, ...]:
"""Строит строки, не теряя абсолютные позиции и последнюю строку."""
lines: list[_PhysicalLine] = []
start = 0
number = 1
while start < len(text):
cursor = start
while cursor < len(text) and text[cursor] not in {"\r", "\n"}:
cursor += 1
content_end = cursor
if cursor < len(text):
if text[cursor] == "\r" and text[cursor + 1 : cursor + 2] == "\n":
cursor += 2
else:
cursor += 1
lines.append(
_PhysicalLine(
start=start,
content_end=content_end,
end=cursor,
number=number,
content=text[start:content_end],
)
)
start = cursor
number += 1
return tuple(lines)
def _strip_quote_prefixes(
content: str,
*,
limit: int | None = None,
) -> tuple[int, int]:
"""Снимает до ``limit`` маркеров цитаты и возвращает их глубину."""
index = 0
depth = 0
while limit is None or depth < limit:
marker = index
spaces = 0
while marker < len(content) and content[marker] == " " and spaces < 3:
marker += 1
spaces += 1
if marker >= len(content) or content[marker] != ">":
break
index = marker + 1
if index < len(content) and content[index] in {" ", "\t"}:
index += 1
depth += 1
return depth, index
def _quote_prefix_end(content: str, offset: int) -> int | None:
"""Снимает один маркер цитаты без создания подстроки."""
marker = offset
spaces = 0
while marker < len(content) and content[marker] == " " and spaces < 3:
marker += 1
spaces += 1
if marker >= len(content) or content[marker] != ">":
return None
marker += 1
if marker < len(content) and content[marker] in {" ", "\t"}:
marker += 1
return marker
def _indent_columns(content: str) -> tuple[int, int]:
"""Возвращает число колонок отступа и позицию первого не-пробела."""
columns = 0
index = 0
while index < len(content) and content[index] in {" ", "\t"}:
if content[index] == "\t":
columns += 4 - columns % 4
else:
columns += 1
index += 1
return columns, index
def _consume_indent(content: str, required_columns: int) -> int | None:
"""Снимает требуемый отступ и возвращает позицию остатка строки."""
columns = 0
index = 0
while index < len(content) and content[index] in {" ", "\t"}:
if content[index] == "\t":
columns += 4 - columns % 4
else:
columns += 1
index += 1
if columns >= required_columns:
return index
return None
def _consume_indent_at(
content: str,
offset: int,
required_columns: int,
) -> int | None:
"""Снимает отступ от абсолютной позиции без создания подстроки."""
columns = 0
index = offset
while index < len(content) and content[index] in {" ", "\t"}:
if content[index] == "\t":
columns += 4 - columns % 4
else:
columns += 1
index += 1
if columns >= required_columns:
return index
return None
def _list_prefix_end(content: str, offset: int) -> int | None:
"""Находит один маркер списка от абсолютной позиции."""
marker = offset
spaces = 0
while marker < len(content) and content[marker] == " " and spaces < 3:
marker += 1
spaces += 1
if marker >= len(content):
return None
if content[marker] in {"-", "+", "*"}:
marker_end = marker + 1
else:
digit_end = marker
while (
digit_end < len(content)
and digit_end - marker < 9
and content[digit_end].isdigit()
):
digit_end += 1
if (
digit_end == marker
or digit_end >= len(content)
or content[digit_end] not in {".", ")"}
):
return None
marker_end = digit_end + 1
if marker_end >= len(content) or content[marker_end] not in {" ", "\t"}:
return None
while marker_end < len(content) and content[marker_end] in {" ", "\t"}:
marker_end += 1
return marker_end
def _opening_block_content(
content: str,
) -> tuple[_BlockContainer, int, str]:
"""Возвращает контейнер и содержимое возможного начала блока."""
frames: list[_ContainerFrame] = []
content_offset = 0
while content_offset < len(content):
quote_end = _quote_prefix_end(content, content_offset)
if quote_end is not None:
frames.append(_ContainerFrame(kind="quote"))
content_offset = quote_end
continue
list_end = _list_prefix_end(content, content_offset)
if list_end is None:
break
list_indent = len(
content[content_offset:list_end].expandtabs(4)
)
frames.append(_ContainerFrame(kind="list", indent=list_indent))
content_offset = list_end
return (
_BlockContainer(path=tuple(frames)),
content_offset,
content[content_offset:],
)
def _consume_container_path(
content: str,
path: tuple[_ContainerFrame, ...],
) -> int | None:
"""Снимает упорядоченный путь контейнера и возвращает позицию текста."""
content_offset = 0
content_end = len(content.rstrip(" \t"))
for frame_index, frame in enumerate(path):
if frame.kind == "quote":
quote_end = _quote_prefix_end(content, content_offset)
if quote_end is None:
return None
content_offset = quote_end
continue
if content_offset >= content_end:
if any(
nested_frame.kind == "quote"
for nested_frame in path[frame_index + 1 :]
):
return None
return len(content)
list_offset = _consume_indent_at(
content,
content_offset,
frame.indent,
)
if list_offset is None:
return None
content_offset = list_offset
return content_offset
def _longest_container_prefix(
content: str,
path: tuple[_ContainerFrame, ...],
) -> tuple[int, int]:
"""За один проход возвращает длину совпавшего пути и смещение."""
content_offset = 0
content_end = len(content.rstrip(" \t"))
frame_index = 0
while frame_index < len(path):
frame = path[frame_index]
if frame.kind == "quote":
quote_end = _quote_prefix_end(content, content_offset)
if quote_end is None:
break
content_offset = quote_end
frame_index += 1
continue
if content_offset >= content_end:
while (
frame_index < len(path)
and path[frame_index].kind == "list"
):
frame_index += 1
return frame_index, len(content)
list_end = _consume_indent_at(
content,
content_offset,
frame.indent,
)
if list_end is None:
break
content_offset = list_end
frame_index += 1
return frame_index, content_offset
def _allows_lazy_continuation(content: str) -> bool:
"""Проверяет возможность продолжить абзац контейнера без маркера."""
if not content.strip():
return False
_, _, semantic_content = _opening_block_content(content)
return not (
_ATX_HEADING.match(semantic_content) is not None
or _THEMATIC_BREAK.match(semantic_content) is not None
or _HTML_BLOCK_INTERRUPT.match(semantic_content) is not None
or _FENCE_START.match(semantic_content) is not None
or _is_gfm_table_delimiter_line(semantic_content)
)
def _opens_paragraph(
context: _LineContext,
*,
paragraph_was_open: bool = False,
) -> bool:
"""Показывает, оставляет ли смысловая строка абзац открытым."""
content = context.semantic_content
if not content.strip():
return False
interrupted = (
_ATX_HEADING.match(content) is not None
or _SETEXT_HEADING.match(content) is not None
or _THEMATIC_BREAK.match(content) is not None
or _HTML_BLOCK_INTERRUPT.match(content) is not None
or _FENCE_START.match(content) is not None
)
if paragraph_was_open:
return not interrupted
return not (
interrupted
or _SETEXT_HEADING.match(content) is not None
or _REFERENCE_DEFINITION.match(content) is not None
or _is_complete_type_7_html_block_start(content)
)
def _build_line_contexts(
text: str,
*,
list_continuations: dict[int, _BlockContainer] | None = None,
) -> tuple[_LineContext, ...]:
"""Единообразно восстанавливает цитаты, списки и их идентификаторы."""
continuation_events = list_continuations or {}
contexts: list[_LineContext] = []
previous_path: tuple[_ContainerFrame, ...] = ()
previous_ids: tuple[int, ...] = ()
paragraph_open = False
paragraph_allows_lazy = False
injected_container_pending = False
next_container_id = 1
for line_index, line in enumerate(_physical_lines(text)):
prior_ids = previous_ids
prior_paragraph_open = paragraph_open
continuation = continuation_events.get(line_index)
base_path = previous_path
base_ids = previous_ids
if continuation is not None:
injected_container_pending = True
base_path = continuation.path
generated_ids = range(
next_container_id,
next_container_id + len(base_path),
)
base_ids = tuple(generated_ids)
next_container_id += len(base_path)
kept_length, prefix_offset = _longest_container_prefix(
line.content,
base_path,
)
explicit_container, explicit_offset, semantic_content = (
_opening_block_content(line.content[prefix_offset:])
)
explicit_path = explicit_container.path
explicit_ids = tuple(
range(
next_container_id,
next_container_id + len(explicit_path),
)
)
next_container_id += len(explicit_path)
combined_path = base_path[:kept_length] + explicit_path
combined_ids = base_ids[:kept_length] + explicit_ids
semantic_offset = prefix_offset + explicit_offset
semantic_content = line.content[semantic_offset:]
if (
kept_length < len(base_path)
and not explicit_path
and paragraph_open
and paragraph_allows_lazy
and _allows_lazy_continuation(line.content[prefix_offset:])
):
combined_path = base_path
combined_ids = base_ids
semantic_offset = prefix_offset
semantic_content = line.content[semantic_offset:]
context = _LineContext(
line=line,
container=_BlockContainer(path=combined_path),
container_id=combined_ids,
semantic_offset=semantic_offset,
semantic_content=semantic_content,
explicit_list_item=any(
frame.kind == "list" for frame in explicit_path
),
)
contexts.append(context)
previous_path = combined_path
previous_ids = combined_ids
paragraph_open = _opens_paragraph(
context,
paragraph_was_open=(
prior_paragraph_open and prior_ids == combined_ids
),
)
if paragraph_open:
if not prior_paragraph_open or prior_ids != combined_ids:
paragraph_allows_lazy = not injected_container_pending
if context.semantic_content.strip():
injected_container_pending = False
else:
paragraph_allows_lazy = False
if context.semantic_content.strip():
injected_container_pending = False
return tuple(contexts)
def _content_in_container(
content: str,
container: _BlockContainer,
) -> str | None:
"""Снимает только путь контейнера, в котором был открыт блок."""
content_offset = _consume_container_path(content, container.path)
if content_offset is None:
return None
return content[content_offset:]
def _is_fence_closer(
content: str,
*,
marker_character: str,
marker_length: int,
) -> bool:
"""Проверяет закрывающий маркер блока в подтверждённом контейнере."""
stripped = content.lstrip(" ")
spaces = len(content) - len(stripped)
return (
spaces <= 3
and stripped.startswith(marker_character * marker_length)
and not stripped.lstrip(marker_character).strip()
)
def _unescaped_pipe_positions(content: str) -> tuple[int, ...]:
"""Находит GFM-разделители с учётом чётности обратных слешей."""
return tuple(
position
for position, character in enumerate(content)
if character == "|" and not _is_escaped(content, position)
)
def _is_gfm_table_delimiter_line(content: str) -> bool:
"""Распознаёт строку-разделитель, не являющуюся продолжением абзаца."""
indent, content_offset = _indent_columns(content)
if indent > 3:
return False
semantic_content = content[content_offset:]
pipe_positions = _unescaped_pipe_positions(semantic_content)
if not pipe_positions:
return False
cells: list[str] = []
cell_start = 0
for position in pipe_positions:
cells.append(semantic_content[cell_start:position].strip())
cell_start = position + 1
cells.append(semantic_content[cell_start:].strip())
if pipe_positions[0] == 0:
cells.pop(0)
if cells and pipe_positions[-1] == len(semantic_content.rstrip()) - 1:
cells.pop()
return bool(cells) and all(
_GFM_DELIMITER_CELL.fullmatch(cell) is not None for cell in cells
)
def _mask_raw_html_tags(
text: str,
*,
source: str,
protected_ranges: tuple[tuple[int, int], ...] = (),
) -> tuple[str, tuple[DocumentationIssue, ...]]:
"""Маскирует HTML-теги и отклоняет необработанные ``a/img`` href/src."""
buffer = list(text)
issues: list[DocumentationIssue] = []
line_starts = _line_start_offsets(text)
for tag in _scan_raw_html_tags(
text,
protected_ranges=protected_ranges,
):
if (
not tag.is_closing
and tag.name in {"a", "img"}
and not {"href", "src"}.isdisjoint(tag.attribute_names)
):
issues.append(
DocumentationIssue(
code="UNSUPPORTED_HTML_LINK",
source=source,
line=_line_number(line_starts, tag.start),
target=None,
message=(
"Используйте Markdown-ссылку вместо raw HTML "
"href/src."
),
)
)
_mask_range(buffer, tag.start, tag.end)
return "".join(buffer), tuple(issues)
def _scan_raw_html_tags(
text: str,
*,
protected_ranges: tuple[tuple[int, int], ...] = (),
) -> tuple[_RawHtmlTag, ...]:
"""Одним проходом находит строгие HTML-теги вне защищённых участков."""
tags: list[_RawHtmlTag] = []
protected_starts = tuple(start for start, _ in protected_ranges)
def protected_range_end(offset: int) -> int | None:
range_index = bisect_right(protected_starts, offset) - 1
if range_index < 0:
return None
_, end = protected_ranges[range_index]
return end if offset < end else None
search_offset = 0
while match := _RAW_HTML_TAG_START.search(text, search_offset):
protected_end = protected_range_end(match.start())
if protected_end is not None:
search_offset = protected_end
continue
if _is_escaped(text, match.start()):
search_offset = match.end()
continue
cursor = match.end()
quote: str | None = None
malformed_restart: int | None = None
quoted_nested_restart: int | None = None
tag_end: int | None = None
while cursor < len(text):
character = text[cursor]
if quote is not None:
if character == quote:
quote = None
elif (
character == "<"
and quoted_nested_restart is None
and _RAW_HTML_TAG_START.match(text, cursor) is not None
and protected_range_end(cursor) is None
):
quoted_nested_restart = cursor
cursor += 1
continue
if character in {'"', "'"}:
quote = character
cursor += 1
continue
if character == "<":
malformed_restart = cursor
break
if character == ">":
tag_end = cursor + 1
break
cursor += 1
attribute_names = (
_raw_html_attribute_names(text, match=match, tag_end=tag_end)
if tag_end is not None
else None
)
if tag_end is not None and attribute_names is not None:
tags.append(
_RawHtmlTag(
start=match.start(),
end=tag_end,
name=match.group(2).casefold(),
is_closing=bool(match.group(1)),
attribute_names=attribute_names,
)
)
search_offset = tag_end
continue
if malformed_restart is not None:
search_offset = malformed_restart
elif quoted_nested_restart is not None:
search_offset = quoted_nested_restart
else:
search_offset = match.end()
return tuple(tags)
def _is_complete_type_7_html_block_start(content: str) -> bool:
"""Распознаёт отдельный HTML-тег, способный открыть блок типа 7."""
stripped = content.lstrip(" ")
if len(content) - len(stripped) > 3:
return False
tags = _scan_raw_html_tags(stripped)
if not tags or tags[0].start != 0:
return False
return not stripped[tags[0].end :].strip(" \t")
def _html_block_start_indices(
contexts: tuple[_LineContext, ...],
) -> frozenset[int]:
"""Находит начала HTML-блоков с учётом уже открытого абзаца."""
starts: set[int] = set()
paragraph_open = False
previous_container_id: tuple[int, ...] | None = None
for context_index, context in enumerate(contexts):
content = context.semantic_content
if not content.strip():
paragraph_open = False
previous_container_id = None
continue
if (
previous_container_id is not None
and previous_container_id != context.container_id
):
paragraph_open = False
if (
_HTML_BLOCK_INTERRUPT.match(content) is not None
or (
not paragraph_open
and _is_complete_type_7_html_block_start(content)
)
):
starts.add(context_index)
paragraph_open = False
else:
paragraph_open = _opens_paragraph(
context,
paragraph_was_open=paragraph_open,
)
previous_container_id = context.container_id
return frozenset(starts)
def _raw_html_policy_excluded_ranges(
text: str,
*,
include_raw_text: bool = True,
require_terminator: bool = False,
) -> tuple[tuple[int, int], ...]:
"""Исключает служебные и текстовые HTML-области из проверки ссылок."""
ranges: list[tuple[int, int]] = []
folded = text.casefold()
missing_terminators: set[tuple[str, bool]] = set()
search_offset = 0
while True:
start = text.find("<", search_offset)
if start < 0:
break
if _is_escaped(text, start):
search_offset = start + 1
continue
terminator: str | None = None
case_insensitive = False
if text.startswith("<!--", start):
terminator = "-->"
elif text.startswith("<?", start):
terminator = "?>"
elif text.startswith("<![CDATA[", start):
terminator = "]]>"
elif (
text.startswith("<!", start)
and start + 2 < len(text)
and "A" <= text[start + 2] <= "Z"
):
terminator = ">"
else:
raw_tag = (
_RAW_HTML_BLOCK_TAG_START.match(text, start)
if include_raw_text
else None
)
if raw_tag is not None and raw_tag.group(1).casefold() != "pre":
terminator = f"</{raw_tag.group(1)}>"
case_insensitive = True
if terminator is None:
search_offset = start + 1
continue
haystack = folded if case_insensitive else text
needle = terminator.casefold() if case_insensitive else terminator
terminator_key = (needle, case_insensitive)
closing_start = (
-1
if require_terminator and terminator_key in missing_terminators
else haystack.find(needle, start + 1)
)
if closing_start < 0 and require_terminator:
missing_terminators.add(terminator_key)
search_offset = start + 1
continue
end = len(text) if closing_start < 0 else closing_start + len(needle)
ranges.append((start, end))
search_offset = end
return tuple(ranges)
def _raw_html_attribute_names(
text: str,
*,
match: re.Match[str],
tag_end: int | None,
) -> frozenset[str] | None:
"""Проверяет грамматику HTML-тега CommonMark и возвращает атрибуты."""
if tag_end is None:
return None
content_end = tag_end - 1
cursor = match.end()
if match.group(1):
spacing_end = _consume_raw_html_spacing(text, cursor, content_end)
return frozenset() if spacing_end == content_end else None
attributes: set[str] = set()
while True:
separator_start = cursor
spacing_end = _consume_raw_html_spacing(text, cursor, content_end)
if spacing_end is None:
return None
cursor = spacing_end
if cursor == content_end:
return frozenset(attributes)
if text.startswith("/>", cursor) and cursor + 1 == content_end:
return frozenset(attributes)
if cursor == separator_start:
return None
name_start = cursor
if not (
"A" <= text[cursor] <= "Z"
or "a" <= text[cursor] <= "z"
or text[cursor] in {"_", ":"}
):
return None
cursor += 1
while cursor < content_end and (
"A" <= text[cursor] <= "Z"
or "a" <= text[cursor] <= "z"
or "0" <= text[cursor] <= "9"
or text[cursor] in {"_", ".", ":", "-"}
):
cursor += 1
attributes.add(text[name_start:cursor].casefold())
after_name = cursor
spacing_end = _consume_raw_html_spacing(text, cursor, content_end)
if spacing_end is None:
return None
cursor = spacing_end
if cursor >= content_end or text[cursor] != "=":
cursor = after_name
continue
cursor += 1
spacing_end = _consume_raw_html_spacing(text, cursor, content_end)
if spacing_end is None:
return None
cursor = spacing_end
if cursor == content_end:
return None
if text[cursor] in {'"', "'"}:
quote = text[cursor]
cursor += 1
while cursor < content_end and text[cursor] != quote:
cursor += 1
if cursor == content_end:
return None
cursor += 1
continue
value_start = cursor
while (
cursor < content_end
and not text[cursor].isspace()
and text[cursor] not in {'"', "'", "=", "<", ">", "`"}
):
cursor += 1
if cursor == value_start:
return None
def _consume_raw_html_spacing(
text: str,
start: int,
end: int,
) -> int | None:
"""Снимает пробелы, табуляции и не более одного окончания строки."""
cursor = start
line_ending_seen = False
while cursor < end:
character = text[cursor]
if character in {" ", "\t"}:
cursor += 1
continue
if character not in {"\r", "\n"}:
break
if line_ending_seen:
return None
line_ending_seen = True
if character == "\r" and cursor + 1 < end and text[cursor + 1] == "\n":
cursor += 2
else:
cursor += 1
return cursor
def _table_row(
context: _LineContext,
) -> _TableRow | None:
"""Разбирает строку-кандидат, но ещё не подтверждает таблицу."""
semantic_content = context.semantic_content
pipe_positions = _unescaped_pipe_positions(semantic_content)
if not pipe_positions:
return None
cells: list[str] = []
cell_start = 0
for position in pipe_positions:
cells.append(semantic_content[cell_start:position].strip())
cell_start = position + 1
cells.append(semantic_content[cell_start:].strip())
first_non_space = len(semantic_content) - len(semantic_content.lstrip(" "))
last_non_space = len(semantic_content.rstrip(" ")) - 1
if pipe_positions[0] == first_non_space:
cells.pop(0)
if cells and pipe_positions[-1] == last_non_space:
cells.pop()
if not cells:
return None
return _TableRow(
container_id=context.container_id,
cells=tuple(cells),
pipe_positions=tuple(
context.semantic_offset + position for position in pipe_positions
),
)
def _is_table_block_interrupt(context: _LineContext) -> bool:
"""Показывает, что строка начинает новый блок вместо строки таблицы."""
content = context.semantic_content
return (
_ATX_HEADING.match(content) is not None
or _THEMATIC_BREAK.match(content) is not None
or _REFERENCE_DEFINITION.match(content) is not None
or _HTML_BLOCK_INTERRUPT.match(content) is not None
or _is_complete_type_7_html_block_start(content)
)
def _confirmed_table_rows(
contexts: tuple[_LineContext, ...],
) -> frozenset[int]:
"""Подтверждает GFM-таблицы только через строку-разделитель."""
candidates = tuple(_table_row(context) for context in contexts)
confirmed: set[int] = set()
index = 0
while index + 1 < len(contexts):
header = candidates[index]
delimiter = candidates[index + 1]
delimiter_is_valid = (
header is not None
and delimiter is not None
and header.container_id == delimiter.container_id
and not _is_table_block_interrupt(contexts[index])
and len(header.cells) == len(delimiter.cells)
and all(
_GFM_DELIMITER_CELL.fullmatch(cell) is not None
for cell in delimiter.cells
)
)
if not delimiter_is_valid:
index += 1
continue
assert header is not None
confirmed.update({index, index + 1})
body_index = index + 2
while body_index < len(contexts):
body = candidates[body_index]
if (
body is None
or body.container_id != header.container_id
or _is_table_block_interrupt(contexts[body_index])
):
break
confirmed.add(body_index)
body_index += 1
index = body_index
return frozenset(confirmed)
def _mask_indented_code(
text: str,
*,
buffer: list[str],
block_ranges: list[tuple[int, int]],
list_continuations: dict[int, _BlockContainer],
protected_line_indices: frozenset[int] = frozenset(),
) -> None:
"""Маскирует код с отступом относительно контейнера цитат и списков."""
contexts = _build_line_contexts(
text,
list_continuations=list_continuations,
)
table_rows = _confirmed_table_rows(contexts)
paragraph_open = False
previous_container_key: tuple[int, ...] | None = None
line_index = 0
while line_index < len(contexts):
context = contexts[line_index]
if line_index in protected_line_indices:
paragraph_open = False
previous_container_key = context.container_id
line_index += 1
continue
if not context.semantic_content.strip():
paragraph_open = False
previous_container_key = None
line_index += 1
continue
if (
previous_container_key is not None
and previous_container_key != context.container_id
):
paragraph_open = False
semantic_indent, _ = _indent_columns(context.semantic_content)
if (
not context.explicit_list_item
and semantic_indent >= 4
and not paragraph_open
):
block_end = context.line.end
search_index = line_index + 1
while search_index < len(contexts):
search_context = contexts[search_index]
if search_context.container_id != context.container_id:
break
if not search_context.semantic_content.strip():
block_end = search_context.line.end
search_index += 1
continue
search_indent, _ = _indent_columns(
search_context.semantic_content
)
if search_indent < 4:
break
block_end = search_context.line.end
search_index += 1
_mask_range(buffer, context.line.start, block_end)
block_ranges.append((context.line.start, block_end))
paragraph_open = False
previous_container_key = context.container_id
line_index = search_index
continue
paragraph_open = _opens_paragraph(
context,
paragraph_was_open=paragraph_open,
)
if line_index in table_rows:
paragraph_open = False
previous_container_key = context.container_id
line_index += 1
def _mask_code(
text: str,
*,
source: str,
) -> tuple[
str,
tuple[DocumentationIssue, ...],
tuple[_LineContext, ...],
tuple[tuple[int, int], ...],
]:
"""Сначала разбирает блочные, затем внутристрочные конструкции."""
buffer = list(text)
issues: list[DocumentationIssue] = []
block_ranges: list[tuple[int, int]] = []
list_continuations: dict[int, _BlockContainer] = {}
initial_contexts = _build_line_contexts(text)
initial_reference_definitions = tuple(
definition
for definition in _reference_definitions(initial_contexts)
if definition.is_valid
)
reference_definition_line_indices = frozenset(
line_index
for definition in initial_reference_definitions
for line_index in range(
definition.context_index,
definition.end_context_index + 1,
)
)
html_block_starts = _html_block_start_indices(initial_contexts)
raw_html_tags = _scan_raw_html_tags(text)
raw_html_tag_starts = tuple(tag.start for tag in raw_html_tags)
raw_html_policy_excluded = _raw_html_policy_excluded_ranges(text)
raw_html_policy_excluded_starts = tuple(
start for start, _ in raw_html_policy_excluded
)
line_starts = _line_start_offsets(text)
def record_raw_html_policy_issues(start: int, end: int) -> None:
tag_index = bisect_left(raw_html_tag_starts, start)
while tag_index < len(raw_html_tags) and raw_html_tags[tag_index].start < end:
tag = raw_html_tags[tag_index]
excluded_index = (
bisect_right(raw_html_policy_excluded_starts, tag.start) - 1
)
policy_excluded = (
excluded_index >= 0
and tag.start < raw_html_policy_excluded[excluded_index][1]
)
if (
not policy_excluded
and not tag.is_closing
and tag.name in {"a", "img"}
and not {"href", "src"}.isdisjoint(tag.attribute_names)
):
issues.append(
DocumentationIssue(
code="UNSUPPORTED_HTML_LINK",
source=source,
line=_line_number(line_starts, tag.start),
target=None,
message=(
"Используйте Markdown-ссылку вместо "
"необработанного HTML href/src."
),
)
)
tag_index += 1
line_index = 0
while line_index < len(initial_contexts):
context = initial_contexts[line_index]
line = context.line
content = line.content
if not content.strip():
line_index += 1
continue
if line_index in reference_definition_line_indices:
line_index += 1
continue
container = context.container
block_content = context.semantic_content
html_content = block_content.lstrip(" ")
html_indent = len(block_content) - len(html_content)
html_terminator: str | None = None
html_case_insensitive = False
html_tag: re.Match[str] | None = None
if html_indent <= 3:
html_tag = _RAW_HTML_BLOCK_TAG_START.match(html_content)
if html_tag is not None:
html_terminator = f"</{html_tag.group(1)}>"
html_case_insensitive = True
elif html_content.startswith("<?"):
html_terminator = "?>"
elif re.match(r"<![A-Z]", html_content) is not None:
html_terminator = ">"
elif html_content.startswith("<![CDATA["):
html_terminator = "]]>"
if html_terminator is not None:
block_end = len(text)
next_line_index = len(initial_contexts)
contextual = bool(container.path)
search_index = line_index
while search_index < len(initial_contexts):
search_line = initial_contexts[search_index].line
search_content = (
block_content
if search_index == line_index
else _content_in_container(search_line.content, container)
)
if search_content is None and contextual:
block_end = search_line.start
next_line_index = search_index
break
comparable = search_content or ""
terminator = html_terminator
if html_case_insensitive:
comparable = comparable.casefold()
terminator = terminator.casefold()
if terminator in comparable:
block_end = search_line.end
next_line_index = search_index + 1
break
search_index += 1
if html_tag is not None and html_tag.group(1).casefold() == "pre":
record_raw_html_policy_issues(line.start, block_end)
_mask_range(buffer, line.start, block_end)
block_ranges.append((line.start, block_end))
if container.list_indent is not None:
list_continuations[next_line_index] = container
line_index = next_line_index
continue
comment_content = block_content.lstrip(" ")
comment_indent = len(block_content) - len(comment_content)
if comment_indent <= 3 and comment_content.startswith("<!--"):
block_end = len(text)
next_line_index = len(initial_contexts)
contextual = bool(container.path)
search_index = line_index
while search_index < len(initial_contexts):
search_line = initial_contexts[search_index].line
search_content = (
block_content
if search_index == line_index
else _content_in_container(search_line.content, container)
)
if search_content is None and contextual:
block_end = search_line.start
next_line_index = search_index
break
if search_content is not None and "-->" in search_content:
block_end = search_line.end
next_line_index = search_index + 1
break
search_index += 1
_mask_range(buffer, line.start, block_end)
block_ranges.append((line.start, block_end))
if container.list_indent is not None:
list_continuations[next_line_index] = container
line_index = next_line_index
continue
if line_index in html_block_starts:
block_end = len(text)
next_line_index = len(initial_contexts)
contextual = bool(container.path)
search_index = line_index + 1
while search_index < len(initial_contexts):
search_line = initial_contexts[search_index].line
search_content = _content_in_container(
search_line.content,
container,
)
if search_content is None and contextual:
block_end = search_line.start
next_line_index = search_index
break
if search_content is not None and not search_content.strip():
block_end = search_line.start
next_line_index = search_index
break
search_index += 1
record_raw_html_policy_issues(line.start, block_end)
_mask_range(buffer, line.start, block_end)
block_ranges.append((line.start, block_end))
if container.list_indent is not None:
list_continuations[next_line_index] = container
line_index = next_line_index
continue
fence_match = _FENCE_START.match(block_content)
if fence_match is not None:
marker = fence_match.group(1)
marker_character = marker[0]
marker_length = len(marker)
if marker_character != "`" or "`" not in fence_match.group(2):
block_end = len(text)
next_line_index = len(initial_contexts)
found_closer = False
contextual = bool(container.path)
search_index = line_index + 1
while search_index < len(initial_contexts):
search_line = initial_contexts[search_index].line
closing_content = _content_in_container(
search_line.content,
container,
)
if closing_content is None and contextual:
block_end = search_line.start
next_line_index = search_index
break
if closing_content is not None and _is_fence_closer(
closing_content,
marker_character=marker_character,
marker_length=marker_length,
):
block_end = search_line.end
next_line_index = search_index + 1
found_closer = True
break
search_index += 1
if not found_closer:
issues.append(
DocumentationIssue(
code="UNCLOSED_CODE_FENCE",
source=source,
line=line.number,
target=None,
message=(
"Блок кода не закрыт в исходном контейнере."
),
)
)
_mask_range(buffer, line.start, block_end)
block_ranges.append((line.start, block_end))
if container.list_indent is not None:
list_continuations[next_line_index] = container
line_index = next_line_index
continue
line_index += 1
block_masked = "".join(buffer)
_mask_indented_code(
block_masked,
buffer=buffer,
block_ranges=block_ranges,
list_continuations=list_continuations,
protected_line_indices=reference_definition_line_indices,
)
block_masked = "".join(buffer)
barriers = list(block_ranges)
masked_contexts = _build_line_contexts(
block_masked,
list_continuations=list_continuations,
)
table_rows = _confirmed_table_rows(masked_contexts)
allowed_reference_indices = _allowed_reference_definition_indices(
masked_contexts
)
remaining_html_block_starts = _html_block_start_indices(masked_contexts)
previous_container_key: tuple[int, ...] | None = None
paragraph_open = False
for masked_index, context in enumerate(masked_contexts):
line = context.line
semantic_content = context.semantic_content
if not semantic_content.strip():
barriers.append((line.start, line.end))
previous_container_key = None
paragraph_open = False
else:
if (
previous_container_key is not None
and previous_container_key != context.container_id
and line.start > 0
):
barriers.append((line.start - 1, line.start))
paragraph_open = False
previous_container_key = context.container_id
is_table_row = masked_index in table_rows
starts_html_block = masked_index in remaining_html_block_starts
standalone_block = (
_ATX_HEADING.match(semantic_content) is not None
or _THEMATIC_BREAK.match(semantic_content) is not None
or masked_index in allowed_reference_indices
or starts_html_block
or is_table_row
)
setext_boundary = (
_SETEXT_HEADING.match(semantic_content) is not None
)
if standalone_block and line.start > 0:
barriers.append((line.start - 1, line.start))
if (standalone_block or setext_boundary) and line.end > line.content_end:
barriers.append((line.content_end, line.end))
if is_table_row:
table_row = _table_row(context)
assert table_row is not None
barriers.extend(
(
line.start + position,
line.start + position + 1,
)
for position in table_row.pipe_positions
)
paragraph_open = (
False
if standalone_block or setext_boundary
else _opens_paragraph(
context,
paragraph_was_open=paragraph_open,
)
)
merged_barriers: list[tuple[int, int]] = []
for start, end in sorted(barriers):
if start == end:
continue
if merged_barriers and start <= merged_barriers[-1][1]:
previous_start, previous_end = merged_barriers[-1]
merged_barriers[-1] = (previous_start, max(previous_end, end))
else:
merged_barriers.append((start, end))
run_lengths_by_start: dict[int, int] = {}
run_positions_by_length: dict[int, list[int]] = {}
index = 0
while index < len(block_masked):
if block_masked[index] != "`":
index += 1
continue
run_end = index + 1
while run_end < len(block_masked) and block_masked[run_end] == "`":
run_end += 1
run_length = run_end - index
run_lengths_by_start[index] = run_length
run_positions_by_length.setdefault(run_length, []).append(index)
index = run_end
inline_special_ranges = dict(
_raw_html_policy_excluded_ranges(
block_masked,
include_raw_text=False,
require_terminator=True,
)
)
inline_token_ranges = {
**{
tag.start: tag.end
for tag in _scan_raw_html_tags(block_masked)
},
**dict(_external_autolink_ranges(block_masked)),
}
definition_ranges: dict[int, int] = {}
for definition in _reference_definitions(masked_contexts):
if definition.is_valid:
definition_ranges[definition.span_start] = definition.span_end
def mask_inline_tokens(protected_ranges: dict[int, int]) -> str:
output = list(block_masked)
non_code_ranges = {**inline_token_ranges, **protected_ranges}
def mask_inline_segment(start: int, end: int) -> None:
index = start
while index < end:
token_end = non_code_ranges.get(index)
if token_end is not None:
index = min(token_end, end)
continue
special_end = inline_special_ranges.get(index)
if special_end is not None:
closing_end = min(special_end, end)
_mask_range(output, index, closing_end)
index = closing_end
continue
run_length = run_lengths_by_start.get(index)
if run_length is not None:
if _is_escaped(block_masked, index):
index += run_length
continue
positions = run_positions_by_length[run_length]
next_position = bisect_right(positions, index)
if (
next_position < len(positions)
and positions[next_position] < end
):
closing_end = positions[next_position] + run_length
_mask_range(output, index, closing_end)
index = closing_end
continue
index += run_length
continue
index += 1
segment_start = 0
for barrier_start, barrier_end in merged_barriers:
if segment_start < barrier_start:
mask_inline_segment(segment_start, barrier_start)
segment_start = max(segment_start, barrier_end)
if segment_start < len(block_masked):
mask_inline_segment(segment_start, len(block_masked))
return "".join(output)
declared_reference_labels = _declared_reference_labels(
masked_contexts,
allowed_indices=allowed_reference_indices,
)
pre_inline_candidates = _analyse_inline_links(
block_masked,
reference_labels=declared_reference_labels,
reference_label_source=block_masked,
barriers=tuple(merged_barriers),
)
argument_ranges = {
candidate.opening_parenthesis: candidate.closing_parenthesis + 1
for candidate in pre_inline_candidates
if not candidate.shadowed_by_link
and not candidate.inside_link_argument
and candidate.closing_parenthesis is not None
and candidate.error is None
and candidate.target is not None
}
base_protected_ranges = {**definition_ranges, **argument_ranges}
preliminary_masked = mask_inline_tokens(base_protected_ranges)
preliminary_bracket_closers = _matching_square_brackets(
preliminary_masked,
barriers=tuple(merged_barriers),
)
preliminary_references, _ = _reference_link_candidates(
preliminary_masked,
bracket_closers=preliminary_bracket_closers,
reference_labels=declared_reference_labels,
label_source=block_masked,
)
preliminary_references, _ = _apply_reference_overlap_precedence(
preliminary_references,
reference_labels=declared_reference_labels,
label_source=block_masked,
successful_inline_openings=frozenset(
candidate.opening_bracket
for candidate in pre_inline_candidates
if not candidate.shadowed_by_link
and not candidate.is_image
and not candidate.inside_link_argument
and candidate.closing_parenthesis is not None
and candidate.error is None
and candidate.target is not None
),
)
suffix_ranges = {
candidate.closing_bracket + 1: candidate.suffix_end + 1
for candidate in preliminary_references
if candidate.suffix_end is not None
and _normalise_reference_label(candidate.reference_label)
in declared_reference_labels
}
final_masked = mask_inline_tokens(
{**base_protected_ranges, **suffix_ranges}
)
return (
final_masked,
tuple(issues),
_build_line_contexts(
final_masked,
list_continuations=list_continuations,
),
tuple(merged_barriers),
)
def _inline_token_ranges(text: str) -> tuple[tuple[int, int], ...]:
"""Возвращает участки HTML и автоссылок с приоритетом над скобками."""
raw_html_ranges = tuple(
(tag.start, tag.end) for tag in _scan_raw_html_tags(text)
)
ranges = [
*raw_html_ranges,
*_raw_html_policy_excluded_ranges(
text,
include_raw_text=False,
require_terminator=True,
),
*_external_autolink_ranges(text, raw_html_ranges=raw_html_ranges),
]
merged: list[tuple[int, int]] = []
for start, end in sorted(ranges):
if merged and start <= merged[-1][1]:
previous_start, previous_end = merged[-1]
merged[-1] = (previous_start, max(previous_end, end))
else:
merged.append((start, end))
return tuple(merged)
def _external_autolink_ranges(
text: str,
*,
raw_html_ranges: tuple[tuple[int, int], ...] | None = None,
) -> tuple[tuple[int, int], ...]:
"""Находит URI-автоссылки вне HTML и экранированной разметки."""
html_ranges = (
tuple((tag.start, tag.end) for tag in _scan_raw_html_tags(text))
if raw_html_ranges is None
else raw_html_ranges
)
html_starts = tuple(start for start, _ in html_ranges)
def inside_raw_html(offset: int) -> bool:
range_index = bisect_right(html_starts, offset) - 1
return (
range_index >= 0
and offset < html_ranges[range_index][1]
)
return tuple(
(match.start(), match.end())
for match in _EXTERNAL_AUTOLINK.finditer(text)
if not _is_escaped(text, match.start())
and not inside_raw_html(match.start())
)
def _matching_delimiters(
text: str,
*,
opening: str,
closing: str,
barriers: tuple[tuple[int, int], ...] = (),
) -> dict[int, int]:
"""Строит пары разделителей одним проходом с учётом токенов."""
ignored_ranges = _inline_token_ranges(text)
range_index = 0
barrier_index = 0
stack: list[int] = []
closers: dict[int, int] = {}
index = 0
while index < len(text):
while (
barrier_index < len(barriers)
and barriers[barrier_index][1] <= index
):
barrier_index += 1
if (
barrier_index < len(barriers)
and barriers[barrier_index][0] <= index
):
stack.clear()
index = barriers[barrier_index][1]
continue
while (
range_index < len(ignored_ranges)
and ignored_ranges[range_index][1] <= index
):
range_index += 1
if (
range_index < len(ignored_ranges)
and ignored_ranges[range_index][0] <= index
):
index = ignored_ranges[range_index][1]
continue
character = text[index]
if character in {opening, closing} and not _is_escaped(text, index):
if character == opening:
stack.append(index)
elif stack:
closers[stack.pop()] = index
index += 1
return closers
def _matching_square_brackets(
text: str,
*,
barriers: tuple[tuple[int, int], ...] = (),
) -> dict[int, int]:
"""Строит пары ``[]`` одним проходом с учётом токенов."""
return _matching_delimiters(
text,
opening="[",
closing="]",
barriers=barriers,
)
def _matching_link_parentheses(
text: str,
*,
link_openings: frozenset[int],
barriers: tuple[tuple[int, int], ...] = (),
) -> tuple[dict[int, int], frozenset[int]]:
"""Индексирует ``()`` ссылок с учётом угловых адресов и заголовков."""
ignored_ranges = _inline_token_ranges(text)
range_index = 0
barrier_index = 0
stack: list[_ParenthesisFrame] = []
closers: dict[int, int] = {}
invalid_openings: set[int] = set()
index = 0
while index < len(text):
while (
barrier_index < len(barriers)
and barriers[barrier_index][1] <= index
):
barrier_index += 1
if (
barrier_index < len(barriers)
and barriers[barrier_index][0] <= index
):
stack.clear()
index = barriers[barrier_index][1]
continue
while (
range_index < len(ignored_ranges)
and ignored_ranges[range_index][1] <= index
):
range_index += 1
if (
range_index < len(ignored_ranges)
and ignored_ranges[range_index][0] <= index
):
if stack and stack[-1].is_link_argument:
frame = stack[-1]
if frame.quote is None and not frame.angle_destination:
frame.leading_content = False
frame.title_separator_seen = False
index = ignored_ranges[range_index][1]
continue
character = text[index]
frame = stack[-1] if stack else None
if character == "\\":
if frame is not None and frame.is_link_argument:
frame.leading_content = False
if frame.quote is None and not frame.angle_destination:
frame.title_separator_seen = False
index += 2
continue
can_open_nested_candidate = (
frame is None
or not frame.is_link_argument
or frame.invalid_argument
or (frame.quote is None and not frame.angle_destination)
)
if (
character == "("
and index in link_openings
and can_open_nested_candidate
):
if frame is not None and frame.is_link_argument:
if frame.title_closed:
frame.invalid_argument = True
frame.leading_content = False
if frame.quote is None and not frame.angle_destination:
frame.title_separator_seen = False
stack.append(
_ParenthesisFrame(
start=index,
is_link_argument=True,
)
)
index += 1
continue
if frame is not None and frame.is_link_argument:
if frame.quote is not None:
if character == frame.quote:
frame.quote = None
frame.title_closed = True
index += 1
continue
if frame.angle_destination:
if character == ">":
frame.angle_destination = False
elif character in {"\r", "\n", "<"}:
frame.angle_destination = False
frame.invalid_argument = True
index += 1
continue
if frame.leading_content:
if character in {" ", "\t", "\r", "\n"}:
frame.title_separator_seen = True
index += 1
continue
frame.leading_content = False
if frame.title_separator_seen and character in {'"', "'", "("}:
frame.quote = ")" if character == "(" else character
frame.title_separator_seen = False
index += 1
continue
if character == "<":
frame.angle_destination = True
frame.title_separator_seen = False
index += 1
continue
frame.title_separator_seen = False
if frame.title_closed and character not in {" ", "\t", "\r", "\n", ")"}:
frame.invalid_argument = True
if frame.title_separator_seen and character in {'"', "'", "("}:
frame.quote = ")" if character == "(" else character
frame.title_separator_seen = False
index += 1
continue
if character == "(":
if frame is not None and frame.is_link_argument:
frame.leading_content = False
stack.append(
_ParenthesisFrame(
start=index,
is_link_argument=index in link_openings,
)
)
elif character == ")" and stack:
closed = stack.pop()
if closed.is_link_argument:
closers[closed.start] = index
if closed.invalid_argument:
invalid_openings.add(closed.start)
elif frame is not None and frame.is_link_argument:
frame.title_separator_seen = character in {" ", "\t", "\r", "\n"}
index += 1
return closers, frozenset(invalid_openings)
def _inline_character_index(text: str) -> _InlineCharacterIndex:
whitespace: list[int] = []
non_whitespace: list[int] = []
line_endings: list[int] = []
angle_openings: list[int] = []
angle_closings: list[int] = []
parentheses: list[int] = []
double_quotes: list[int] = []
single_quotes: list[int] = []
backslash_run = 0
for index, character in enumerate(text):
escaped = backslash_run % 2 == 1
if character in {" ", "\t", "\r", "\n"}:
whitespace.append(index)
else:
non_whitespace.append(index)
if character in {"\r", "\n"}:
line_endings.append(index)
if not escaped:
if character == "<":
angle_openings.append(index)
elif character == ">":
angle_closings.append(index)
elif character == "(":
parentheses.append(index)
elif character == '"':
double_quotes.append(index)
elif character == "'":
single_quotes.append(index)
backslash_run = backslash_run + 1 if character == "\\" else 0
return _InlineCharacterIndex(
whitespace=tuple(whitespace),
non_whitespace=tuple(non_whitespace),
line_endings=tuple(line_endings),
angle_openings=tuple(angle_openings),
angle_closings=tuple(angle_closings),
parentheses=tuple(parentheses),
double_quotes=tuple(double_quotes),
single_quotes=tuple(single_quotes),
)
def _next_indexed_position(
positions: tuple[int, ...],
start: int,
end: int,
) -> int | None:
position_index = bisect_left(positions, start)
if position_index >= len(positions):
return None
position = positions[position_index]
return position if position < end else None
def _obvious_link_argument_error(
text: str,
*,
start: int,
end: int,
character_index: _InlineCharacterIndex,
parenthesis_closers: dict[int, int],
) -> str | None:
"""Без повторного сканирования отклоняет явно сломанный аргумент."""
content_start = _next_indexed_position(
character_index.non_whitespace,
start,
end,
)
if content_start is None:
return None
if text[content_start] == "<":
destination_end = _next_indexed_position(
character_index.angle_closings,
content_start + 1,
end,
)
nested_angle = _next_indexed_position(
character_index.angle_openings,
content_start + 1,
destination_end or end,
)
embedded_line_ending = _next_indexed_position(
character_index.line_endings,
content_start + 1,
destination_end or end,
)
if destination_end is None:
return "У углового адреса отсутствует закрывающий символ '>'."
if nested_angle is not None or embedded_line_ending is not None:
return "Угловой адрес содержит недопустимый символ."
destination_end += 1
if destination_end < end and text[destination_end] not in {
" ",
"\t",
"\r",
"\n",
}:
return "После углового адреса отсутствует разделитель."
else:
separator = _next_indexed_position(
character_index.whitespace,
content_start,
end,
)
if separator is None:
return None
destination_end = separator
title_start = _next_indexed_position(
character_index.non_whitespace,
destination_end,
end,
)
if title_start is None:
return None
delimiter = text[title_start]
if delimiter in {'"', "'"}:
quote_positions = (
character_index.double_quotes
if delimiter == '"'
else character_index.single_quotes
)
title_end = _next_indexed_position(
quote_positions,
title_start + 1,
end,
)
elif delimiter == "(":
title_end = parenthesis_closers.get(title_start)
if title_end is not None and title_end >= end:
title_end = None
if title_end is not None and _next_indexed_position(
character_index.parentheses,
title_start + 1,
title_end,
) is not None:
return "Название ссылки содержит незакрытую круглую скобку."
else:
return "После адреса указано некорректное название ссылки."
if title_end is None:
return "Название ссылки не закрыто."
trailing_content = _next_indexed_position(
character_index.non_whitespace,
title_end + 1,
end,
)
if trailing_content is not None:
return "После названия ссылки присутствует лишний текст."
return None
def _has_blank_line(value: str) -> bool:
"""Ищет два окончания строки CommonMark между пробелами и табуляциями."""
index = 0
while index < len(value):
if value[index] not in {"\r", "\n"}:
index += 1
continue
index += (
2
if value[index] == "\r" and value[index + 1 : index + 2] == "\n"
else 1
)
while index < len(value) and value[index] in {" ", "\t"}:
index += 1
if index < len(value) and value[index] in {"\r", "\n"}:
return True
return False
def _parse_destination_and_title(
content: str,
) -> tuple[str | None, bool, str | None]:
"""Строго разбирает адрес и заголовок с учётом пустых строк."""
if _has_blank_line(content):
return None, False, "В Markdown-ссылке запрещена пустая строка."
structural_whitespace = " \t\r\n"
leading_length = len(content) - len(content.lstrip(structural_whitespace))
stripped = content.strip(structural_whitespace)
if not stripped:
return "", False, None
if leading_length and _valid_link_title(stripped):
return "", True, None
if stripped.startswith("<"):
cursor = 1
closing: int | None = None
while cursor < len(stripped):
character = stripped[cursor]
if (
character == "\\"
and cursor + 1 < len(stripped)
and stripped[cursor + 1] in _MARKDOWN_ESCAPABLE
):
cursor += 2
continue
if character in {"\r", "\n"}:
return None, False, "Angle-target не может содержать line ending."
if character == "<":
return None, False, "Angle-target содержит незакрытый символ '<'."
if character == ">":
closing = cursor
break
cursor += 1
if closing is None:
return None, False, "У angle-target отсутствует закрывающий символ '>'."
destination = stripped[1:closing]
raw_remainder = stripped[closing + 1 :]
if raw_remainder and raw_remainder[0] not in structural_whitespace:
return None, False, "После angle-target отсутствует пробел перед title."
remainder = raw_remainder.strip(structural_whitespace)
else:
index = 0
depth = 0
while index < len(stripped):
character = stripped[index]
if (
character == "\\"
and index + 1 < len(stripped)
and stripped[index + 1] in _MARKDOWN_ESCAPABLE
):
index += 2
continue
if character == "(":
depth += 1
if depth > 32:
return (
None,
False,
"В target допускается не более 32 уровней скобок.",
)
elif character == ")":
if not depth:
return (
None,
False,
"В target не сбалансированы круглые скобки.",
)
depth -= 1
elif character in structural_whitespace:
break
index += 1
if depth:
return None, False, "В target не сбалансированы круглые скобки."
destination = stripped[:index]
remainder = stripped[index:].strip(structural_whitespace)
if not remainder:
return destination, False, None
if not _valid_link_title(remainder):
return None, False, "После target указано некорректное название ссылки."
return destination, True, None
def _unclosed_link_title_closer(content: str) -> str | None:
"""Возвращает ожидаемый delimiter незакрытого title без повторного разбора."""
structural_whitespace = " \t\r\n"
stripped = content.strip(structural_whitespace)
if not stripped:
return None
if stripped.startswith("<"):
cursor = 1
while cursor < len(stripped):
if (
stripped[cursor] == "\\"
and cursor + 1 < len(stripped)
and stripped[cursor + 1] in _MARKDOWN_ESCAPABLE
):
cursor += 2
continue
if stripped[cursor] == ">":
cursor += 1
break
cursor += 1
remainder = stripped[cursor:].lstrip(structural_whitespace)
else:
cursor = 0
depth = 0
while cursor < len(stripped):
character = stripped[cursor]
if (
character == "\\"
and cursor + 1 < len(stripped)
and stripped[cursor + 1] in _MARKDOWN_ESCAPABLE
):
cursor += 2
continue
if character == "(":
depth += 1
elif character == ")" and depth:
depth -= 1
elif character in structural_whitespace and depth == 0:
break
cursor += 1
remainder = stripped[cursor:].lstrip(structural_whitespace)
if not remainder or remainder[0] not in {'"', "'", "("}:
return None
return ")" if remainder[0] == "(" else remainder[0]
def _split_destination_and_title(content: str) -> tuple[str | None, str | None]:
target, _, error = _parse_destination_and_title(content)
return target, error
def _valid_link_title(value: str) -> bool:
"""Проверяет разделители, экранирование и отсутствие пустой строки."""
if len(value) < 2:
return False
if _has_blank_line(value):
return False
if any(
_contains_control_character(character)
and character not in {"\t", "\r", "\n"}
for character in value
):
return False
opening = value[0]
closing = opening if opening in {'"', "'"} else ")"
if (opening not in {'"', "'", "("}) or value[-1] != closing:
return False
backslash_run = 0
for index, character in enumerate(value[1:], start=1):
if character == "\\":
backslash_run += 1
continue
escaped = backslash_run % 2 == 1
backslash_run = 0
if index == len(value) - 1:
return character == closing and not escaped
if not escaped and (
character == closing or (opening == "(" and character == "(")
):
return False
return False
def _has_inline_link_title(content: str) -> bool:
"""Показывает, что адрес уже содержит заголовок на той же строке."""
_, has_title, error = _parse_destination_and_title(content)
return error is None and has_title
def _reference_definition_candidate(
line_contexts: tuple[_LineContext, ...],
context_index: int,
) -> _ReferenceDefinition | None:
"""Разбирает одно, в том числе многострочное, определение ссылки."""
context = line_contexts[context_index]
prefix = re.match(r"^[ \t]{0,3}\[", context.semantic_content)
if prefix is None:
return None
label_parts: list[str] = []
label_context_index = context_index
cursor = prefix.end()
definition_content: str | None = None
while label_context_index < len(line_contexts):
label_context = line_contexts[label_context_index]
if label_context.container_id != context.container_id:
return None
content = label_context.semantic_content
while cursor < len(content):
character = content[cursor]
if character == "[" and not _is_escaped(content, cursor):
return None
if character == "]" and not _is_escaped(content, cursor):
if content[cursor + 1 : cursor + 2] != ":":
return None
definition_content = content[cursor + 2 :]
break
label_parts.append(character)
cursor += 1
if definition_content is not None:
break
next_index = label_context_index + 1
if next_index >= len(line_contexts):
return None
next_context = line_contexts[next_index]
if (
next_context.container_id != context.container_id
or not next_context.semantic_content.strip()
):
return None
label_parts.append("\n")
label_context_index = next_index
cursor = 0
assert definition_content is not None
label = "".join(label_parts)
end_context_index = label_context_index
structural_whitespace = " \t\r\n"
if not definition_content.strip(" \t"):
destination_index = end_context_index + 1
if destination_index < len(line_contexts):
destination_context = line_contexts[destination_index]
if (
destination_context.container_id == context.container_id
and destination_context.semantic_content.strip()
):
definition_content += (
"\n" + destination_context.semantic_content
)
end_context_index = destination_index
target, has_title, error = _parse_destination_and_title(
definition_content
)
multiline_title_index = end_context_index + 1
title_closer = (
_unclosed_link_title_closer(definition_content)
if error is not None
else None
)
title_parts = [definition_content]
while (
error is not None
and title_closer is not None
and multiline_title_index < len(line_contexts)
):
multiline_title_context = line_contexts[multiline_title_index]
if (
multiline_title_context.container_id != context.container_id
or not multiline_title_context.semantic_content.strip()
):
break
continuation = multiline_title_context.semantic_content
title_parts.append(continuation)
closing_present = any(
character == title_closer
and not _is_escaped(continuation, position)
for position, character in enumerate(continuation)
)
if closing_present:
extended_content = "\n".join(title_parts)
extended_target, extended_has_title, extended_error = (
_parse_destination_and_title(extended_content)
)
if extended_error is None and extended_target is not None:
definition_content = extended_content
target = extended_target
has_title = extended_has_title
error = None
end_context_index = multiline_title_index
break
multiline_title_index += 1
if (
error is None
and target == ""
and not definition_content.strip(structural_whitespace).startswith("<>")
):
error = "Определение reference-ссылки не содержит target."
title_index = end_context_index + 1
if (
error is None
and target is not None
and not has_title
and title_index < len(line_contexts)
):
title_context = line_contexts[title_index]
if (
title_context.container_id == context.container_id
and _standalone_link_title(title_context.semantic_content)
):
extended_content = (
definition_content + "\n" + title_context.semantic_content
)
extended_target, _, extended_error = _parse_destination_and_title(
extended_content
)
if extended_error is None and extended_target is not None:
target = extended_target
end_context_index = title_index
if not _valid_reference_label(label):
error = (
"Метка определения ссылки должна содержать от 1 до 999 "
"значимых символов и неэкранированные квадратные скобки запрещены."
)
return _ReferenceDefinition(
context_index=context_index,
end_context_index=end_context_index,
label=label,
target=target,
error=error,
span_start=context.line.start,
span_end=line_contexts[end_context_index].line.content_end,
)
def _reference_definitions(
line_contexts: tuple[_LineContext, ...],
) -> tuple[_ReferenceDefinition, ...]:
"""Собирает определения, учитывая абзацы и пути контейнеров."""
definitions: list[_ReferenceDefinition] = []
consumed_until = -1
paragraph_open = False
previous_container_id: tuple[int, ...] | None = None
for context_index, context in enumerate(line_contexts):
if context_index <= consumed_until:
paragraph_open = False
previous_container_id = context.container_id
continue
content = context.semantic_content
if not content.strip():
paragraph_open = False
previous_container_id = None
continue
if (
previous_container_id is not None
and previous_container_id != context.container_id
):
paragraph_open = False
definition = (
None
if paragraph_open
else _reference_definition_candidate(line_contexts, context_index)
)
if definition is not None:
definitions.append(definition)
if definition.is_valid:
consumed_until = definition.end_context_index
paragraph_open = False
else:
paragraph_open = True
else:
paragraph_open = _opens_paragraph(
context,
paragraph_was_open=paragraph_open,
)
previous_container_id = context.container_id
return tuple(definitions)
def _declared_reference_labels(
line_contexts: tuple[_LineContext, ...],
*,
allowed_indices: frozenset[int],
) -> frozenset[str]:
"""Возвращает только синтаксически корректные метки определений."""
return frozenset(
_normalise_reference_label(definition.label)
for definition in _reference_definitions(line_contexts)
if definition.context_index in allowed_indices and definition.is_valid
)
def _reference_definition_indices(
line_contexts: tuple[_LineContext, ...],
) -> tuple[frozenset[int], frozenset[int]]:
"""Разделяет корректные и ошибочные определения вне абзацев."""
definitions = _reference_definitions(line_contexts)
return (
frozenset(
definition.context_index
for definition in definitions
if definition.is_valid
),
frozenset(
definition.context_index
for definition in definitions
if not definition.is_valid
),
)
def _allowed_reference_definition_indices(
line_contexts: tuple[_LineContext, ...],
) -> frozenset[int]:
"""Возвращает корректные определения ссылок вне открытых абзацев."""
return _reference_definition_indices(line_contexts)[0]
def _reference_link_candidates(
text: str,
*,
bracket_closers: dict[int, int],
reference_labels: frozenset[str],
label_source: str | None = None,
) -> tuple[
tuple[_ReferenceLinkCandidate, ...],
frozenset[int],
]:
"""Разбирает полные и сокращённые ссылки и их скобочные суффиксы."""
labels = text if label_source is None else label_source
candidates: list[_ReferenceLinkCandidate] = []
suffix_openings: set[int] = set()
for opening_bracket, closing_bracket in sorted(bracket_closers.items()):
cursor = closing_bracket + 1
if cursor < len(text) and text[cursor] == "(":
continue
reference_label: str | None = None
suffix_end: int | None = None
if (
cursor < len(text)
and text[cursor] == "["
and not _is_escaped(text, cursor)
):
literal_suffix = _literal_reference_suffix(labels, cursor)
if literal_suffix is None:
continue
suffix_label, reference_end = literal_suffix
suffix_openings.add(cursor)
reference_label = suffix_label
if not reference_label:
reference_label = labels[
opening_bracket + 1 : closing_bracket
]
if not _valid_reference_label(reference_label):
continue
suffix_end = reference_end
elif reference_labels:
if closing_bracket - opening_bracket - 1 > 999:
continue
label_text = labels[opening_bracket + 1 : closing_bracket]
if not _valid_reference_label(label_text):
continue
if _normalise_reference_label(label_text) in reference_labels:
reference_label = label_text
if reference_label is None:
continue
candidates.append(
_ReferenceLinkCandidate(
opening_bracket=opening_bracket,
closing_bracket=closing_bracket,
reference_label=reference_label,
is_image=(
opening_bracket > 0
and text[opening_bracket - 1] == "!"
and not _is_escaped(text, opening_bracket - 1)
),
suffix_end=suffix_end,
)
)
return tuple(candidates), frozenset(suffix_openings)
def _literal_reference_suffix(
text: str,
opening_bracket: int,
) -> tuple[str, int] | None:
"""Разбирает суффикс reference как буквальный, нерендеримый label."""
cursor = opening_bracket + 1
while cursor < len(text) and cursor - opening_bracket - 1 <= 999:
character = text[cursor]
if character == "[" and not _is_escaped(text, cursor):
return None
if character == "]" and not _is_escaped(text, cursor):
label = text[opening_bracket + 1 : cursor]
return (
(label, cursor)
if not label or _valid_reference_label(label)
else None
)
cursor += 1
return None
def _apply_reference_overlap_precedence(
candidates: tuple[_ReferenceLinkCandidate, ...],
*,
reference_labels: frozenset[str],
label_source: str,
successful_inline_openings: frozenset[int] = frozenset(),
) -> tuple[
tuple[_ReferenceLinkCandidate, ...],
frozenset[int],
]:
"""Оставляет ссылки, реально создаваемые при перекрывающихся suffix."""
successful_candidates = tuple(
candidate
for candidate in candidates
if _normalise_reference_label(candidate.reference_label)
in reference_labels
)
chosen_successful: list[_ReferenceLinkCandidate] = []
consumed_suffix_openings: set[int] = set()
max_non_image_opening = -1
sorted_inline_openings = tuple(sorted(successful_inline_openings))
for candidate in sorted(
successful_candidates,
key=lambda item: (item.closing_bracket, item.opening_bracket),
):
if candidate.opening_bracket in consumed_suffix_openings:
continue
nested_reference_link = max_non_image_opening > candidate.opening_bracket
inline_index = bisect_right(
sorted_inline_openings,
candidate.opening_bracket,
)
nested_inline_link = (
inline_index < len(sorted_inline_openings)
and sorted_inline_openings[inline_index] < candidate.closing_bracket
)
if (
not candidate.is_image
and (nested_reference_link or nested_inline_link)
):
continue
chosen_successful.append(candidate)
if not candidate.is_image:
max_non_image_opening = max(
max_non_image_opening,
candidate.opening_bracket,
)
if candidate.suffix_end is not None:
consumed_suffix_openings.add(candidate.closing_bracket + 1)
chosen_successful.sort(key=lambda item: item.opening_bracket)
chosen_openings = frozenset(
candidate.opening_bracket for candidate in chosen_successful
)
effective: list[_ReferenceLinkCandidate] = []
for candidate in candidates:
normalised = _normalise_reference_label(candidate.reference_label)
if normalised in reference_labels:
if candidate.opening_bracket in chosen_openings:
effective.append(candidate)
continue
if candidate.suffix_end is None:
effective.append(candidate)
continue
suffix_opening = candidate.closing_bracket + 1
if (
suffix_opening in chosen_openings
or suffix_opening in successful_inline_openings
):
continue
effective.append(candidate)
suffix_openings = frozenset(
candidate.closing_bracket + 1
for candidate in effective
if candidate.suffix_end is not None
and _normalise_reference_label(candidate.reference_label)
in reference_labels
)
return tuple(effective), suffix_openings
def _analyse_inline_links(
text: str,
*,
reference_labels: frozenset[str] = frozenset(),
reference_label_source: str | None = None,
barriers: tuple[tuple[int, int], ...] = (),
) -> tuple[_InlineLinkCandidate, ...]:
"""Собирает внутристрочные ссылки с учётом вложенного приоритета."""
bracket_closers = _matching_square_brackets(text, barriers=barriers)
link_openings = frozenset(
closing_bracket + 1
for closing_bracket in bracket_closers.values()
if closing_bracket + 1 < len(text)
and text[closing_bracket + 1] == "("
and not _is_escaped(text, closing_bracket + 1)
)
parenthesis_closers, invalid_parenthesis_openings = (
_matching_link_parentheses(
text,
link_openings=link_openings,
barriers=barriers,
)
)
fallback_parenthesis_closers = _matching_delimiters(
text,
opening="(",
closing=")",
barriers=barriers,
)
parenthesis_closers = {
opening: fallback_parenthesis_closers[opening]
for opening in link_openings
if opening in fallback_parenthesis_closers
} | parenthesis_closers
character_index = _inline_character_index(text)
candidates: list[_InlineLinkCandidate] = []
pending_argument_intervals: list[tuple[int, int]] = []
furthest_argument_end = -1
for opening_bracket, closing_bracket in sorted(bracket_closers.items()):
opening_parenthesis = closing_bracket + 1
if opening_parenthesis not in link_openings:
continue
while (
pending_argument_intervals
and pending_argument_intervals[0][0] < opening_bracket
):
_, interval_end = heapq.heappop(pending_argument_intervals)
furthest_argument_end = max(furthest_argument_end, interval_end)
inside_link_argument = opening_bracket < furthest_argument_end
closing_parenthesis = parenthesis_closers.get(opening_parenthesis)
if inside_link_argument:
target = None
error = None
elif closing_parenthesis is None:
target = None
error = "У Markdown-ссылки отсутствует закрывающая скобка."
elif obvious_error := _obvious_link_argument_error(
text,
start=opening_parenthesis + 1,
end=closing_parenthesis,
character_index=character_index,
parenthesis_closers=fallback_parenthesis_closers,
):
target = None
error = obvious_error
elif opening_parenthesis in invalid_parenthesis_openings:
target = None
error = (
"Аргумент Markdown-ссылки содержит текст после "
"закрытого заголовка или некорректный угловой адрес."
)
else:
target, error = _split_destination_and_title(
text[opening_parenthesis + 1 : closing_parenthesis]
)
if error is None and target is not None:
heapq.heappush(
pending_argument_intervals,
(opening_parenthesis, closing_parenthesis + 1),
)
candidates.append(
_InlineLinkCandidate(
opening_bracket=opening_bracket,
closing_bracket=closing_bracket,
opening_parenthesis=opening_parenthesis,
closing_parenthesis=closing_parenthesis,
is_image=(
opening_bracket > 0
and text[opening_bracket - 1] == "!"
and not _is_escaped(text, opening_bracket - 1)
),
target=target,
error=error,
inside_link_argument=inside_link_argument,
)
)
argument_intervals = tuple(
sorted(
(
candidate.opening_parenthesis,
candidate.closing_parenthesis + 1,
)
for candidate in candidates
if not candidate.inside_link_argument
and candidate.closing_parenthesis is not None
and candidate.error is None
and candidate.target is not None
)
)
argument_starts = tuple(start for start, _ in argument_intervals)
def inside_argument(offset: int) -> bool:
interval_index = bisect_right(argument_starts, offset) - 1
return (
interval_index >= 0
and offset < argument_intervals[interval_index][1]
)
successful_link_starts = tuple(
candidate.opening_bracket
for candidate in candidates
if not candidate.is_image
and not candidate.inside_link_argument
and candidate.closing_parenthesis is not None
and candidate.error is None
and candidate.target is not None
)
reference_candidates, _ = _reference_link_candidates(
text,
bracket_closers=bracket_closers,
reference_labels=reference_labels,
label_source=reference_label_source,
)
successful_reference_starts = tuple(
candidate.opening_bracket
for candidate in reference_candidates
if not candidate.is_image
and not inside_argument(candidate.opening_bracket)
and _normalise_reference_label(candidate.reference_label)
in reference_labels
)
successful_nested_starts = tuple(
sorted((*successful_link_starts, *successful_reference_starts))
)
autolink_starts = tuple(
start
for start, _ in _external_autolink_ranges(text)
if not inside_argument(start)
)
with_precedence: list[_InlineLinkCandidate] = []
for candidate in candidates:
shadowed = False
if not candidate.is_image:
nested_index = bisect_right(
successful_nested_starts,
candidate.opening_bracket,
)
shadowed = (
nested_index < len(successful_nested_starts)
and successful_nested_starts[nested_index]
< candidate.closing_bracket
)
autolink_index = bisect_right(
autolink_starts,
candidate.opening_bracket,
)
shadowed = shadowed or (
autolink_index < len(autolink_starts)
and autolink_starts[autolink_index]
< candidate.closing_bracket
)
with_precedence.append(
replace(candidate, shadowed_by_link=shadowed)
)
return tuple(with_precedence)
def _standalone_link_title(content: str) -> bool:
"""Проверяет заголовок определения ссылки на следующей строке."""
indent, _ = _indent_columns(content)
if indent > 3:
return False
stripped = content.strip(" \t")
return _valid_link_title(stripped)
def _reference_continuation_end(
contexts: tuple[_LineContext, ...],
context_index: int,
definition_content: str,
) -> int | None:
"""Возвращает конец перенесённого заголовка в том же контейнере."""
if (
_has_inline_link_title(definition_content)
or context_index + 1 >= len(contexts)
):
return None
context = contexts[context_index]
continuation = contexts[context_index + 1]
if continuation.container_id != context.container_id:
return None
if not _standalone_link_title(continuation.semantic_content):
return None
return continuation.line.content_end
def _markdown_link_argument_ranges(
text: str,
*,
line_contexts: tuple[_LineContext, ...],
inline_candidates: tuple[_InlineLinkCandidate, ...],
allowed_reference_indices: frozenset[int],
reference_suffix_ranges: tuple[tuple[int, int], ...] = (),
) -> tuple[tuple[int, int], ...]:
"""Защищает нерендеримые части ссылок от разбора как HTML."""
ranges: list[tuple[int, int]] = [
(definition.span_start, definition.span_end)
for definition in _reference_definitions(line_contexts)
if definition.is_valid
and definition.context_index in allowed_reference_indices
]
ranges.extend(reference_suffix_ranges)
ranges.extend(
(
candidate.opening_parenthesis,
candidate.closing_parenthesis + 1,
)
for candidate in inline_candidates
if not candidate.shadowed_by_link
and not candidate.inside_link_argument
and candidate.closing_parenthesis is not None
and candidate.error is None
and candidate.target is not None
)
merged: list[tuple[int, int]] = []
for start, end in sorted(ranges):
if merged and start <= merged[-1][1]:
previous_start, previous_end = merged[-1]
merged[-1] = (previous_start, max(previous_end, end))
else:
merged.append((start, end))
return tuple(merged)
def _parse_markdown_links(
text: str,
*,
source: str,
) -> tuple[tuple[_MarkdownLink, ...], tuple[DocumentationIssue, ...]]:
code_masked, code_issues, line_contexts, barriers = _mask_code(
text,
source=source,
)
(
allowed_reference_indices,
_,
) = _reference_definition_indices(
line_contexts
)
declared_reference_labels = _declared_reference_labels(
line_contexts,
allowed_indices=allowed_reference_indices,
)
inline_candidates = _analyse_inline_links(
code_masked,
reference_labels=declared_reference_labels,
reference_label_source=text,
barriers=barriers,
)
pre_html_bracket_closers = _matching_square_brackets(
code_masked,
barriers=barriers,
)
pre_html_reference_candidates, _ = _reference_link_candidates(
code_masked,
bracket_closers=pre_html_bracket_closers,
reference_labels=declared_reference_labels,
label_source=text,
)
pre_html_reference_candidates, _ = _apply_reference_overlap_precedence(
pre_html_reference_candidates,
reference_labels=declared_reference_labels,
label_source=text,
successful_inline_openings=frozenset(
candidate.opening_bracket
for candidate in inline_candidates
if not candidate.shadowed_by_link
and not candidate.is_image
and not candidate.inside_link_argument
and candidate.closing_parenthesis is not None
and candidate.error is None
and candidate.target is not None
),
)
reference_suffix_ranges = tuple(
(candidate.closing_bracket + 1, candidate.suffix_end + 1)
for candidate in pre_html_reference_candidates
if candidate.suffix_end is not None
and _normalise_reference_label(candidate.reference_label)
in declared_reference_labels
)
protected_ranges = _markdown_link_argument_ranges(
code_masked,
line_contexts=line_contexts,
inline_candidates=inline_candidates,
allowed_reference_indices=allowed_reference_indices,
reference_suffix_ranges=reference_suffix_ranges,
)
masked, raw_html_issues = _mask_raw_html_tags(
code_masked,
source=source,
protected_ranges=protected_ranges,
)
issues = [*code_issues, *raw_html_issues]
links: list[_MarkdownLink] = []
references: dict[str, tuple[str, int]] = {}
definition_ranges: list[tuple[int, int]] = []
for definition in _reference_definitions(line_contexts):
line_number = line_contexts[definition.context_index].line.number
label = _normalise_reference_label(definition.label)
if not definition.is_valid:
issues.append(
DocumentationIssue(
code="MALFORMED_REFERENCE",
source=source,
line=line_number,
target=None,
message=(
definition.error or "Некорректное определение ссылки."
),
)
)
else:
target = definition.target
assert target is not None
definition_ranges.append(
(definition.span_start, definition.span_end)
)
if label in references:
issues.append(
DocumentationIssue(
code="DUPLICATE_REFERENCE",
source=source,
line=line_number,
target=target,
message=(
"Идентификатор reference-ссылки "
"объявлен повторно."
),
)
)
else:
references[label] = (target, line_number)
links.append(
_MarkdownLink(
target=target,
line=line_number,
is_image=False,
)
)
merged_definition_ranges: list[tuple[int, int]] = []
for start, end in sorted(definition_ranges):
if (
merged_definition_ranges
and start <= merged_definition_ranges[-1][1]
):
previous_start, previous_end = merged_definition_ranges[-1]
merged_definition_ranges[-1] = (
previous_start,
max(previous_end, end),
)
else:
merged_definition_ranges.append((start, end))
definition_starts = tuple(
start for start, _ in merged_definition_ranges
)
def inside_definition(offset: int) -> bool:
range_index = bisect_right(definition_starts, offset) - 1
return (
range_index >= 0
and offset < merged_definition_ranges[range_index][1]
)
protected_starts = tuple(start for start, _ in protected_ranges)
def inside_protected_range(offset: int) -> bool:
range_index = bisect_right(protected_starts, offset) - 1
return (
range_index >= 0
and offset < protected_ranges[range_index][1]
)
bracket_closers = _matching_square_brackets(masked, barriers=barriers)
inline_by_bracket = {
candidate.opening_bracket: candidate
for candidate in inline_candidates
}
raw_reference_candidates, _ = (
_reference_link_candidates(
masked,
bracket_closers=bracket_closers,
reference_labels=frozenset(references),
label_source=text,
)
)
reference_candidates, reference_suffix_openings = (
_apply_reference_overlap_precedence(
raw_reference_candidates,
reference_labels=frozenset(references),
label_source=text,
successful_inline_openings=frozenset(
candidate.opening_bracket
for candidate in inline_candidates
if not candidate.shadowed_by_link
and not candidate.is_image
and not candidate.inside_link_argument
and candidate.closing_parenthesis is not None
and candidate.error is None
and candidate.target is not None
),
)
)
reference_by_bracket = {
candidate.opening_bracket: candidate
for candidate in reference_candidates
}
autolink_ranges = tuple(
(start, end)
for start, end in _external_autolink_ranges(masked)
if not inside_protected_range(start)
)
successful_nested_starts = tuple(
sorted(
(
*(
candidate.opening_bracket
for candidate in inline_candidates
if not candidate.is_image
and not candidate.shadowed_by_link
and not candidate.inside_link_argument
and candidate.closing_parenthesis is not None
and candidate.error is None
and candidate.target is not None
),
*(
candidate.opening_bracket
for candidate in reference_candidates
if not candidate.is_image
and not inside_protected_range(candidate.opening_bracket)
and _normalise_reference_label(candidate.reference_label)
in references
),
*(start for start, _ in autolink_ranges),
)
)
)
line_starts = _line_start_offsets(masked)
index = 0
while index < len(masked):
image = (
masked.startswith("![", index)
and not _is_escaped(masked, index)
and not _is_escaped(masked, index + 1)
)
bracket = index + 1 if image else index
if masked[bracket : bracket + 1] != "[":
index += 1
continue
if _is_escaped(masked, bracket):
index += 1
continue
if (
bracket > 0
and masked[bracket - 1] == "!"
and not image
and not _is_escaped(masked, bracket - 1)
):
index += 1
continue
if inside_definition(bracket):
index += 1
continue
if inside_protected_range(bracket):
index += 1
continue
if bracket in reference_suffix_openings:
index += 1
continue
closing_bracket = bracket_closers.get(bracket)
if closing_bracket is None:
index += 1
continue
cursor = closing_bracket + 1
line_number = _line_number(line_starts, bracket)
candidate = inline_by_bracket.get(bracket)
if candidate is not None:
if candidate.shadowed_by_link or candidate.inside_link_argument:
index += 1
continue
if (
candidate.closing_parenthesis is None
or candidate.error is not None
or candidate.target is None
):
issues.append(
DocumentationIssue(
code="MALFORMED_LINK",
source=source,
line=line_number,
target=None,
message=(
candidate.error
or "Некорректная Markdown-ссылка."
),
)
)
else:
links.append(
_MarkdownLink(
target=candidate.target,
line=line_number,
is_image=candidate.is_image,
)
)
index += 1
continue
reference_candidate = reference_by_bracket.get(bracket)
if reference_candidate is not None:
nested_index = bisect_right(successful_nested_starts, bracket)
shadowed = (
not reference_candidate.is_image
and nested_index < len(successful_nested_starts)
and successful_nested_starts[nested_index] < closing_bracket
)
if shadowed:
index += 1
continue
normalised = _normalise_reference_label(
reference_candidate.reference_label
)
reference = references.get(normalised)
if reference is None:
issues.append(
DocumentationIssue(
code="UNDEFINED_REFERENCE",
source=source,
line=line_number,
target=reference_candidate.reference_label,
message="Reference-ссылка не имеет определения.",
)
)
else:
links.append(
_MarkdownLink(
target=reference[0],
line=line_number,
is_image=reference_candidate.is_image,
)
)
index += 1
continue
if (
cursor < len(masked)
and masked[cursor] == "["
and bracket_closers.get(cursor) is None
):
issues.append(
DocumentationIssue(
code="MALFORMED_REFERENCE",
source=source,
line=line_number,
target=None,
message="Reference-ссылка не закрыта.",
)
)
index += 1
for start, end in autolink_ranges:
match = _EXTERNAL_AUTOLINK.fullmatch(masked[start:end])
assert match is not None
links.append(
_MarkdownLink(
target=match.group(1),
line=_line_number(line_starts, start),
is_image=False,
)
)
return tuple(links), tuple(issues)
def _path_cache_key(path: os.PathLike[str]) -> str:
"""Сохраняет точное написание и регистр компонентов path."""
return os.fspath(path)
def _inspect_exact_path(
repository_root: Path,
candidate: Path,
*,
directory_cache: dict[Path, _DirectoryIndex] | None = None,
inspection_cache: dict[str, _PathInspection] | None = None,
) -> _PathInspection:
cache_key = _path_cache_key(candidate)
if inspection_cache is not None:
cached = inspection_cache.get(cache_key)
if cached is not None:
return cached
def finish(inspection: _PathInspection) -> _PathInspection:
if inspection_cache is not None:
inspection_cache[cache_key] = inspection
return inspection
try:
relative_candidate = candidate.relative_to(repository_root)
except ValueError:
return finish(
_PathInspection(
path=None,
issue_code="OUTSIDE_REPOSITORY",
message="Ссылка выходит за пределы repository root.",
)
)
current = repository_root
for component in relative_candidate.parts:
try:
current_is_directory = current.is_dir()
except OSError as error:
return finish(
_PathInspection(
path=None,
issue_code="PATH_IO_ERROR",
message=f"Не удалось проверить компонент пути: {error}.",
)
)
if not current_is_directory:
return finish(
_PathInspection(
path=None,
issue_code="FILE_AS_DIRECTORY",
message="Компонент URI path не является каталогом.",
)
)
if component in {"", "."}:
continue
if component == "..":
if current == repository_root:
return finish(
_PathInspection(
path=None,
issue_code="OUTSIDE_REPOSITORY",
message="Ссылка выходит за пределы repository root.",
)
)
current = current.parent
continue
try:
directory_index = (
directory_cache.get(current)
if directory_cache is not None
else None
)
if directory_index is None:
children = tuple(
sorted(
current.iterdir(),
key=lambda child: (child.name.casefold(), child.name),
)
)
casefold_names: dict[str, list[Path]] = {}
for child in children:
casefold_names.setdefault(child.name.casefold(), []).append(child)
directory_index = _DirectoryIndex(
exact_names={child.name: child for child in children},
casefold_names={
name: tuple(paths)
for name, paths in casefold_names.items()
},
)
if directory_cache is not None:
directory_cache[current] = directory_index
except OSError as error:
return finish(
_PathInspection(
path=None,
issue_code=(
"FILE_AS_DIRECTORY"
if isinstance(error, NotADirectoryError)
else "PATH_IO_ERROR"
),
message=(
"Компонент URI path не является каталогом."
if isinstance(error, NotADirectoryError)
else f"Не удалось прочитать компонент пути: {error}."
),
)
)
exact = directory_index.exact_names.get(component)
if exact is None:
case_variants = directory_index.casefold_names.get(
component.casefold(),
(),
)
case_variant = case_variants[0] if case_variants else None
if case_variant is not None:
return finish(
_PathInspection(
path=None,
issue_code="PATH_CASE_MISMATCH",
message=(
"Регистр компонента пути не совпадает: "
f"ожидалось '{case_variant.name}'."
),
)
)
return finish(
_PathInspection(
path=None,
issue_code="MISSING_TARGET",
message="Целевой файл или каталог не существует.",
)
)
try:
is_indirection = exact.is_symlink() or exact.is_junction()
except OSError as error:
return finish(
_PathInspection(
path=None,
issue_code="PATH_IO_ERROR",
message=f"Не удалось проверить компонент пути: {error}.",
)
)
if is_indirection:
return finish(
_PathInspection(
path=None,
issue_code="SYMLINK_TARGET",
message="Ссылки через symlink запрещены.",
)
)
current = exact
return finish(_PathInspection(path=current))
def _decode_commonmark_destination(value: str) -> str:
"""Одним проходом применяет escapes и завершённые entity CommonMark."""
decoded: list[str] = []
cursor = 0
while cursor < len(value):
character = value[cursor]
if (
character == "\\"
and cursor + 1 < len(value)
and value[cursor + 1] in _MARKDOWN_ESCAPABLE
):
decoded.append(value[cursor + 1])
cursor += 2
continue
if character == "&":
entity_match = _COMMONMARK_ENTITY.match(value, cursor)
if entity_match is not None:
entity = entity_match.group(0)
decoded.append(
html.unescape(entity)
if entity.startswith("&#")
else HTML5_ENTITIES.get(entity[1:], entity)
)
cursor = entity_match.end()
continue
decoded.append(character)
cursor += 1
return "".join(decoded)
def _decode_local_path(
raw_path: str,
*,
reject_encoded_separators: bool = True,
) -> tuple[str | None, str | None, str | None]:
value = raw_path
if not value:
return None, "EMPTY_TARGET", "Markdown-ссылка не содержит target."
if _contains_control_character(value):
return None, "CONTROL_CHARACTER", "Target содержит управляющий символ."
if _INVALID_PERCENT_ESCAPE.search(value):
return None, "INVALID_PERCENT_ESCAPE", "Target содержит неверное %-кодирование."
encoded_segments = tuple(value.split("/"))
try:
decoded_segments = tuple(
unquote(segment, encoding="utf-8", errors="strict")
for segment in encoded_segments
)
except UnicodeDecodeError:
return None, "INVALID_PERCENT_ESCAPE", "Target не декодируется как UTF-8."
if reject_encoded_separators and any(
"/" in decoded_segment
or decoded_segment.count("\\") > encoded_segment.count("\\")
for encoded_segment, decoded_segment in zip(
encoded_segments,
decoded_segments,
strict=True,
)
):
return (
None,
"ENCODED_PATH_SEPARATOR",
"Разделитель path не должен быть закодирован внутри сегмента.",
)
decoded = "/".join(decoded_segments)
if _contains_control_character(decoded):
return None, "CONTROL_CHARACTER", "Target содержит управляющий символ."
return decoded, None, None
def _validate_link(
*,
repository_root: Path,
source_path: Path,
source: str,
link: _MarkdownLink,
directory_cache: dict[Path, _DirectoryIndex] | None = None,
inspection_cache: dict[str, _PathInspection] | None = None,
) -> DocumentationIssue | None:
raw_target = _decode_commonmark_destination(link.target)
if not raw_target:
return DocumentationIssue(
code="EMPTY_TARGET",
source=source,
line=link.line,
target=link.target,
message="Markdown-ссылка не содержит target.",
)
if _contains_control_character(raw_target):
return DocumentationIssue(
code="CONTROL_CHARACTER",
source=source,
line=link.line,
target=link.target,
message="Target содержит управляющий символ.",
)
if _INVALID_PERCENT_ESCAPE.search(raw_target):
return DocumentationIssue(
code="INVALID_PERCENT_ESCAPE",
source=source,
line=link.line,
target=link.target,
message="Target содержит неверное %-кодирование.",
)
if link.target.startswith("\\\\") or raw_target.startswith("\\\\"):
return DocumentationIssue(
code="ABSOLUTE_TARGET",
source=source,
line=link.line,
target=link.target,
message="UNC paths запрещены.",
)
if raw_target.startswith("//"):
return DocumentationIssue(
code="ABSOLUTE_TARGET",
source=source,
line=link.line,
target=link.target,
message="Protocol-relative и UNC paths запрещены.",
)
if _WINDOWS_DRIVE.match(raw_target) or raw_target.startswith("/"):
return DocumentationIssue(
code="ABSOLUTE_TARGET",
source=source,
line=link.line,
target=link.target,
message="Абсолютные локальные paths запрещены.",
)
leading_spaces = len(raw_target) - len(raw_target.lstrip(" "))
split_target = "%20" * leading_spaces + raw_target[leading_spaces:]
try:
split = urlsplit(split_target)
except ValueError:
return DocumentationIssue(
code="MALFORMED_TARGET",
source=source,
line=link.line,
target=link.target,
message="Target содержит синтаксически некорректный URL.",
)
scheme = split.scheme.casefold()
if scheme:
if scheme in _ALLOWED_EXTERNAL_SCHEMES:
return None
return DocumentationIssue(
code="FORBIDDEN_SCHEME",
source=source,
line=link.line,
target=link.target,
message=f"Схема '{scheme}' не разрешена documentation gate.",
)
if split.netloc:
return DocumentationIssue(
code="ABSOLUTE_TARGET",
source=source,
line=link.line,
target=link.target,
message="Сетевой target без разрешённой схемы запрещён.",
)
if "?" in raw_target.split("#", 1)[0]:
return DocumentationIssue(
code="LOCAL_QUERY",
source=source,
line=link.line,
target=link.target,
message="Query string у локальной ссылки не поддерживается.",
)
if "#" in raw_target and not split.fragment:
return DocumentationIssue(
code="EMPTY_FRAGMENT",
source=source,
line=link.line,
target=link.target,
message="Fragment после '#' не может быть пустым.",
)
decoded_path: str | None = ""
if split.path:
decoded_path, error_code, error_message = _decode_local_path(split.path)
if decoded_path is None:
return DocumentationIssue(
code=error_code or "INVALID_TARGET",
source=source,
line=link.line,
target=link.target,
message=error_message or "Некорректный path.",
)
if split.fragment:
decoded_fragment, error_code, error_message = _decode_local_path(
split.fragment,
reject_encoded_separators=False,
)
if decoded_fragment is None:
return DocumentationIssue(
code=error_code or "INVALID_TARGET",
source=source,
line=link.line,
target=link.target,
message=error_message or "Некорректный fragment.",
)
assert decoded_path is not None
if decoded_path.startswith("//") or decoded_path.startswith("\\\\"):
return DocumentationIssue(
code="ABSOLUTE_TARGET",
source=source,
line=link.line,
target=link.target,
message="Protocol-relative и UNC paths запрещены.",
)
if _WINDOWS_DRIVE.match(decoded_path) or decoded_path.startswith("/"):
return DocumentationIssue(
code="ABSOLUTE_TARGET",
source=source,
line=link.line,
target=link.target,
message="Абсолютные локальные paths запрещены.",
)
if "\\" in decoded_path:
return DocumentationIssue(
code="BACKSLASH_TARGET",
source=source,
line=link.line,
target=link.target,
message="В локальной Markdown-ссылке используйте '/'.",
)
if not decoded_path and split.fragment:
if link.is_image:
return DocumentationIssue(
code="IMAGE_NOT_FILE",
source=source,
line=link.line,
target=link.target,
message="Изображение должно ссылаться на файл.",
)
return None
path_text = decoded_path
if not path_text:
return DocumentationIssue(
code="EMPTY_TARGET",
source=source,
line=link.line,
target=link.target,
message="Markdown-ссылка не содержит локальный path.",
)
inspection = _inspect_exact_path(
repository_root,
source_path.parent / Path(path_text),
directory_cache=directory_cache,
inspection_cache=inspection_cache,
)
if inspection.path is None:
return DocumentationIssue(
code=inspection.issue_code or "INVALID_TARGET",
source=source,
line=link.line,
target=link.target,
message=inspection.message or "Некорректный target.",
)
target_path = inspection.path
try:
target_is_file = target_path.is_file()
target_is_directory = target_path.is_dir()
except OSError as error:
return DocumentationIssue(
code="PATH_IO_ERROR",
source=source,
line=link.line,
target=link.target,
message=f"Не удалось определить тип target: {error}.",
)
final_path_segment = decoded_path.rsplit("/", 1)[-1]
if target_is_file and (
decoded_path.endswith("/") or final_path_segment in {".", ".."}
):
return DocumentationIssue(
code="FILE_AS_DIRECTORY",
source=source,
line=link.line,
target=link.target,
message="Файл указан как каталог через URI path.",
)
if link.is_image and not target_is_file:
return DocumentationIssue(
code="IMAGE_NOT_FILE",
source=source,
line=link.line,
target=link.target,
message="Изображение должно ссылаться на обычный файл.",
)
if split.fragment:
if target_is_directory:
return DocumentationIssue(
code="DIRECTORY_FRAGMENT",
source=source,
line=link.line,
target=link.target,
message="Fragment у ссылки на каталог запрещён.",
)
if target_path.suffix.casefold() != ".md":
return DocumentationIssue(
code="NON_MARKDOWN_FRAGMENT",
source=source,
line=link.line,
target=link.target,
message="Fragment разрешён только у Markdown-файла.",
)
if not target_is_file and not target_is_directory:
return DocumentationIssue(
code="UNSUPPORTED_TARGET_TYPE",
source=source,
line=link.line,
target=link.target,
message="Target не является обычным файлом или каталогом.",
)
return None
def _validate_relative_configuration_path(value: Path, *, field_name: str) -> None:
depth = 0
escapes_root = False
for component in value.parts:
if component in {"", "."}:
continue
if component == "..":
depth -= 1
if depth < 0:
escapes_root = True
break
else:
depth += 1
if value.is_absolute() or escapes_root:
raise ValueError(f"{field_name} должен быть repository-relative: {value}")
if "\\" in os.fspath(value):
raise ValueError(f"{field_name} должен использовать '/': {value}")
def _discover_markdown_files(
repository_root: Path,
sources: tuple[Path, ...],
*,
shallow_sources: frozenset[Path] = frozenset(),
directory_cache: dict[Path, _DirectoryIndex] | None = None,
inspection_cache: dict[str, _PathInspection] | None = None,
) -> tuple[tuple[Path, ...], tuple[DocumentationIssue, ...]]:
files: set[Path] = set()
issues: list[DocumentationIssue] = []
for source in sources:
_validate_relative_configuration_path(source, field_name="markdown_sources")
inspection = _inspect_exact_path(
repository_root,
repository_root / source,
directory_cache=directory_cache,
inspection_cache=inspection_cache,
)
if inspection.path is None:
issues.append(
DocumentationIssue(
code=inspection.issue_code or "SOURCE_MISSING",
source=source.as_posix(),
line=None,
target=None,
message=inspection.message or "Markdown source не существует.",
)
)
continue
resolved_source = inspection.path
source_is_file, source_is_directory, source_kind_error = _path_kind(
resolved_source
)
if source_kind_error is not None:
issues.append(
DocumentationIssue(
code="SOURCE_IO_ERROR",
source=source.as_posix(),
line=None,
target=None,
message=(
"Не удалось определить тип Markdown source: "
f"{source_kind_error}."
),
)
)
continue
if source_is_file:
if resolved_source.suffix == ".md":
files.add(resolved_source)
continue
if not source_is_directory:
issues.append(
DocumentationIssue(
code="INVALID_SOURCE_TYPE",
source=source.as_posix(),
line=None,
target=None,
message="Markdown source не является файлом или каталогом.",
)
)
continue
def record_source_io_error(
error: OSError,
error_path: Path | None = None,
) -> None:
error_path = (
Path(error.filename)
if error.filename
else error_path or resolved_source
)
try:
error_source = error_path.relative_to(repository_root).as_posix()
except ValueError:
error_source = source.as_posix()
issues.append(
DocumentationIssue(
code="SOURCE_IO_ERROR",
source=error_source,
line=None,
target=None,
message=f"Не удалось прочитать Markdown scope: {error}.",
)
)
candidates: list[Path] = []
if source in shallow_sources:
try:
candidates.extend(tuple(resolved_source.glob("*.md")))
except OSError as error:
record_source_io_error(error)
else:
for current_directory, directory_names, file_names in os.walk(
resolved_source,
topdown=True,
followlinks=False,
onerror=record_source_io_error,
):
current_path = Path(current_directory)
retained_directories: list[str] = []
for directory_name in sorted(
directory_names,
key=lambda name: (name.casefold(), name),
):
directory = current_path / directory_name
try:
directory_is_indirection = (
directory.is_symlink() or directory.is_junction()
)
except OSError as error:
record_source_io_error(error, directory)
continue
if directory_is_indirection:
issues.append(
DocumentationIssue(
code="SYMLINK_SOURCE",
source=directory.relative_to(repository_root).as_posix(),
line=None,
target=None,
message="Symlink-каталог внутри Markdown scope запрещён.",
)
)
else:
retained_directories.append(directory_name)
directory_names[:] = retained_directories
candidates.extend(
current_path / file_name
for file_name in sorted(
file_names,
key=lambda name: (name.casefold(), name),
)
if Path(file_name).suffix == ".md"
)
for candidate in candidates:
relative_candidate = candidate.relative_to(repository_root)
candidate_inspection = _inspect_exact_path(
repository_root,
candidate,
directory_cache=directory_cache,
inspection_cache=inspection_cache,
)
if candidate_inspection.path is None:
issues.append(
DocumentationIssue(
code=candidate_inspection.issue_code or "INVALID_SOURCE",
source=relative_candidate.as_posix(),
line=None,
target=None,
message=(
candidate_inspection.message
or "Markdown source невозможно проверить."
),
)
)
else:
candidate_is_file, _, candidate_kind_error = _path_kind(
candidate_inspection.path
)
if candidate_kind_error is not None:
record_source_io_error(
candidate_kind_error,
candidate_inspection.path,
)
elif candidate_is_file:
files.add(candidate_inspection.path)
return tuple(sorted(files)), tuple(issues)
def _validate_required_documents(
repository_root: Path,
required_documents: tuple[str, ...],
*,
directory_cache: dict[Path, _DirectoryIndex] | None = None,
inspection_cache: dict[str, _PathInspection] | None = None,
) -> tuple[DocumentationIssue, ...]:
issues: list[DocumentationIssue] = []
for document in required_documents:
path = Path(document)
try:
_validate_relative_configuration_path(path, field_name="required_documents")
except ValueError as error:
issues.append(
DocumentationIssue(
code="INVALID_MANIFEST_PATH",
source=document,
line=None,
target=document,
message=str(error),
)
)
continue
inspection = _inspect_exact_path(
repository_root,
repository_root / path,
directory_cache=directory_cache,
inspection_cache=inspection_cache,
)
if inspection.path is None:
issues.append(
DocumentationIssue(
code=(
"MANIFEST_MISSING"
if inspection.issue_code == "MISSING_TARGET"
else inspection.issue_code or "INVALID_MANIFEST_PATH"
),
source=document,
line=None,
target=document,
message=inspection.message or "Обязательный документ отсутствует.",
)
)
else:
manifest_is_file, _, manifest_kind_error = _path_kind(
inspection.path
)
if manifest_kind_error is not None:
issues.append(
DocumentationIssue(
code="MANIFEST_IO_ERROR",
source=document,
line=None,
target=document,
message=(
"Не удалось определить тип обязательного "
f"документа: {manifest_kind_error}."
),
)
)
elif not manifest_is_file:
issues.append(
DocumentationIssue(
code="MANIFEST_NOT_FILE",
source=document,
line=None,
target=document,
message=(
"Обязательный документ должен быть обычным файлом."
),
)
)
return tuple(issues)
def check_documentation_integrity(
repository_root: Path,
*,
required_documents: tuple[str, ...] | None = None,
markdown_sources: tuple[Path, ...] | None = None,
) -> DocumentationIntegrityResult:
"""Проверяет манифест и ссылки без сети, Git и записи на диск."""
try:
root = repository_root.expanduser().resolve(strict=True)
except (OSError, RuntimeError) as error:
raise ValueError(f"Repository root не существует: {repository_root}") from error
_, root_is_directory, root_kind_error = _path_kind(root)
if root_kind_error is not None:
raise ValueError(
f"Не удалось определить тип repository root: {root_kind_error}"
) from root_kind_error
if not root_is_directory:
raise ValueError(f"Repository root не является каталогом: {root}")
manifest = REQUIRED_DOCUMENTS if required_documents is None else required_documents
sources = DEFAULT_MARKDOWN_SOURCES if markdown_sources is None else markdown_sources
directory_cache: dict[Path, _DirectoryIndex] = {}
inspection_cache: dict[str, _PathInspection] = {}
issues = list(
_validate_required_documents(
root,
manifest,
directory_cache=directory_cache,
inspection_cache=inspection_cache,
)
)
documents, discovery_issues = _discover_markdown_files(
root,
sources,
shallow_sources=(
_DEFAULT_SHALLOW_SOURCES
if markdown_sources is None
else frozenset()
),
directory_cache=directory_cache,
inspection_cache=inspection_cache,
)
issues.extend(discovery_issues)
checked_links = 0
for document in documents:
source = _relative_source(root, document)
try:
text = document.read_text(encoding="utf-8")
except UnicodeDecodeError:
issues.append(
DocumentationIssue(
code="INVALID_UTF8",
source=source,
line=None,
target=None,
message="Markdown-файл не декодируется как UTF-8.",
)
)
continue
except OSError as error:
issues.append(
DocumentationIssue(
code="SOURCE_IO_ERROR",
source=source,
line=None,
target=None,
message=f"Markdown-файл не удалось прочитать: {error}.",
)
)
continue
links, parse_issues = _parse_markdown_links(text, source=source)
issues.extend(parse_issues)
checked_links += len(links)
for link in links:
issue = _validate_link(
repository_root=root,
source_path=document,
source=source,
link=link,
directory_cache=directory_cache,
inspection_cache=inspection_cache,
)
if issue is not None:
issues.append(issue)
return DocumentationIntegrityResult(
scanned_documents=len(documents),
checked_links=checked_links,
issues=tuple(sorted(issues, key=_issue_sort_key)),
)
def _build_argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Проверяет обязательные документы и локальные Markdown-ссылки.",
)
parser.add_argument(
"--repository-root",
type=Path,
default=Path(__file__).resolve().parents[1],
help="Корень репозитория; по умолчанию определяется по пути скрипта.",
)
return parser
def main(argv: list[str] | None = None) -> int:
"""Запускает CLI и возвращает стабильный код завершения процесса."""
arguments = _build_argument_parser().parse_args(argv)
try:
result = check_documentation_integrity(arguments.repository_root)
except ValueError as error:
print(f"documentation-integrity: {error}", file=sys.stderr)
return 2
for issue in result.issues:
location = issue.source
if issue.line is not None:
location = f"{location}:{issue.line}"
target = f" target={issue.target!r}" if issue.target is not None else ""
print(
f"{location}: {issue.code}:{target} {issue.message}",
file=sys.stderr,
)
summary = (
"documentation-integrity: "
f"documents={result.scanned_documents} "
f"links={result.checked_links} issues={len(result.issues)}"
)
print(summary, file=sys.stdout if result.is_clean else sys.stderr)
return 0 if result.is_clean else 1
if __name__ == "__main__":
raise SystemExit(main())