4362 lines
151 KiB
Python
4362 lines
151 KiB
Python
#!/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())
|