#!/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}(?:" elif text.startswith("" elif text.startswith("" elif ( text.startswith("" 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"" 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_case_insensitive = True elif html_content.startswith("" elif re.match(r"" elif html_content.startswith("" 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("" 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())