Coverage for tdom/parser_utils.py: 100%
65 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-19 21:37 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-19 21:37 +0000
1import typing as t
2from bisect import bisect_left
3from dataclasses import dataclass
4from string.templatelib import Template
6from .placeholders import PlaceholderConfig
7from .source import LinePosition
8from .template_utils import PartPosition, TemplateSpan
10type HTMLAttribute = tuple[str, str | None]
11type AbsolutePosition = int
12"""Absolute position in the placeholder-expanded template source, starting at 0."""
15def make_parser_pos_translator(
16 template: Template, config: PlaceholderConfig
17) -> ParserPositionTranslator:
18 """
19 Configure and return a `ParserPositionTranslator`.
21 Precompute line and string positions to make translation efficient.
22 """
24 line_start_positions: list[AbsolutePosition] = [0]
25 string_start_positions: list[AbsolutePosition] = []
26 string_end_positions: list[AbsolutePosition] = []
27 source_pos: AbsolutePosition = 0
29 def line_starts(string: str) -> t.Iterator[AbsolutePosition]:
30 return (
31 source_pos + offset + 1
32 for offset, char in enumerate(string)
33 if char == "\n"
34 )
36 for s_index, string in enumerate(template.strings):
37 string_start_positions.append(source_pos)
38 line_start_positions.extend(line_starts(string))
39 source_pos += len(string)
40 string_end_positions.append(source_pos)
42 if s_index < len(template.interpolations):
43 placeholder = config.make_placeholder(s_index)
44 line_start_positions.extend(line_starts(placeholder))
45 source_pos += len(placeholder)
47 return ParserPositionTranslator(
48 line_start_positions=tuple(line_start_positions),
49 string_start_positions=tuple(string_start_positions),
50 string_end_positions=tuple(string_end_positions),
51 )
54@dataclass(frozen=True, slots=True)
55class ParserPositionTranslator:
56 line_start_positions: tuple[AbsolutePosition, ...]
57 """Absolute positions where lines in the parser input start."""
59 string_start_positions: tuple[AbsolutePosition, ...]
60 """Absolute positions where static strings start in the parser input."""
62 string_end_positions: tuple[AbsolutePosition, ...]
63 """Absolute positions where static strings end in the parser input."""
65 def line_pos_to_abs_pos(
66 self,
67 line_pos: LinePosition,
68 ) -> AbsolutePosition:
69 """
70 Validate and normalize a parser line position to an absolute position.
72 An offset equal to a non-final line's length points at its newline. An
73 offset equal to the final line's length points at EOF.
74 """
75 line = line_pos.line
76 offset = line_pos.offset
77 line_count = len(self.line_start_positions)
78 if line > line_count:
79 raise ValueError("Line does not exist in source.")
80 elif line <= 0:
81 raise ValueError("Unreachable line number, must be > 0.")
82 if offset < 0:
83 raise ValueError("Unreachable offset, must be >= 0.")
85 line_start = self.line_start_positions[line - 1]
86 line_end = (
87 self.line_start_positions[line] - 1
88 if line < line_count
89 else self.string_end_positions[-1]
90 )
91 line_length = line_end - line_start
92 if offset > line_length:
93 raise ValueError(
94 f"Offset exceeds reachable characters of line: {line}: {offset} > {line_length}"
95 )
96 return line_start + offset
98 def abs_pos_to_part_pos(self, abs_pos: AbsolutePosition) -> PartPosition:
99 """
100 Translate an absolute position into a template part position.
102 Positions at a placeholder's start and end are represented by the end of
103 its preceding string and the start of its following string, respectively.
104 Positions inside placeholders cannot be translated because interpolations
105 are atomic.
106 """
107 source_length = self.string_end_positions[-1]
108 if not 0 <= abs_pos <= source_length:
109 raise ValueError(
110 f"Absolute position falls outside the input: {abs_pos} not in [0, {source_length}]"
111 )
113 s_index = bisect_left(self.string_end_positions, abs_pos)
114 string_start = self.string_start_positions[s_index]
115 if abs_pos < string_start:
116 raise ValueError(
117 "Positions inside interpolation placeholders are undefined."
118 )
119 return PartPosition(s_index, abs_pos - string_start)
121 def translate(self, parser_pos: LinePosition) -> PartPosition:
122 """
123 Translate a parser line position to a template part position.
125 parser_pos:
126 A line position in a coordinate system that consists of the entire
127 template merged into a continuous string with placeholder strings
128 injected for `Interpolation`s.
130 return:
131 A position relative to one of the `Template`'s static strings.
132 """
133 abs_pos = self.line_pos_to_abs_pos(parser_pos)
134 return self.abs_pos_to_part_pos(abs_pos)
136 def translate_span(
137 self,
138 parser_start: LinePosition,
139 parser_length: int,
140 ) -> TemplateSpan:
141 """
142 Translate a half-open span from parser input to template part coordinates.
144 `parser_length` is measured in the placeholder-expanded parser input.
145 """
146 if parser_length < 0:
147 raise ValueError("Parser span length must be positive or zero.")
149 absolute_start = self.line_pos_to_abs_pos(parser_start)
150 return TemplateSpan(
151 start=self.abs_pos_to_part_pos(absolute_start),
152 stop=self.abs_pos_to_part_pos(absolute_start + parser_length),
153 )