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

1import typing as t 

2from bisect import bisect_left 

3from dataclasses import dataclass 

4from string.templatelib import Template 

5 

6from .placeholders import PlaceholderConfig 

7from .source import LinePosition 

8from .template_utils import PartPosition, TemplateSpan 

9 

10type HTMLAttribute = tuple[str, str | None] 

11type AbsolutePosition = int 

12"""Absolute position in the placeholder-expanded template source, starting at 0.""" 

13 

14 

15def make_parser_pos_translator( 

16 template: Template, config: PlaceholderConfig 

17) -> ParserPositionTranslator: 

18 """ 

19 Configure and return a `ParserPositionTranslator`. 

20 

21 Precompute line and string positions to make translation efficient. 

22 """ 

23 

24 line_start_positions: list[AbsolutePosition] = [0] 

25 string_start_positions: list[AbsolutePosition] = [] 

26 string_end_positions: list[AbsolutePosition] = [] 

27 source_pos: AbsolutePosition = 0 

28 

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 ) 

35 

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) 

41 

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) 

46 

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 ) 

52 

53 

54@dataclass(frozen=True, slots=True) 

55class ParserPositionTranslator: 

56 line_start_positions: tuple[AbsolutePosition, ...] 

57 """Absolute positions where lines in the parser input start.""" 

58 

59 string_start_positions: tuple[AbsolutePosition, ...] 

60 """Absolute positions where static strings start in the parser input.""" 

61 

62 string_end_positions: tuple[AbsolutePosition, ...] 

63 """Absolute positions where static strings end in the parser input.""" 

64 

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. 

71 

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.") 

84 

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 

97 

98 def abs_pos_to_part_pos(self, abs_pos: AbsolutePosition) -> PartPosition: 

99 """ 

100 Translate an absolute position into a template part position. 

101 

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 ) 

112 

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) 

120 

121 def translate(self, parser_pos: LinePosition) -> PartPosition: 

122 """ 

123 Translate a parser line position to a template part position. 

124 

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. 

129 

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) 

135 

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. 

143 

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.") 

148 

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 )