Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions tdom/parser_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ def literal_template_span(template: Template, text: str) -> TemplateSpan:
assert len(matches) == 1
string_index, offset = matches[0]
return TemplateSpan(
start=PartPosition(2 * string_index, offset),
stop=PartPosition(2 * string_index, offset + len(text)),
start=PartPosition(string_index, offset),
stop=PartPosition(string_index, offset + len(text)),
)


Expand Down Expand Up @@ -437,7 +437,7 @@ def Component():
assert node == TComponent(
start_i_index=0,
end_i_index=1,
children_span=TemplateSpan(PartPosition(2, 1), PartPosition(2, 1)),
children_span=TemplateSpan(PartPosition(1, 1), PartPosition(1, 1)),
)


Expand All @@ -452,7 +452,7 @@ def Component2():
assert node == TComponent(
start_i_index=0,
end_i_index=1,
children_span=TemplateSpan(PartPosition(2, 1), PartPosition(2, 1)),
children_span=TemplateSpan(PartPosition(1, 1), PartPosition(1, 1)),
)


Expand Down Expand Up @@ -585,7 +585,7 @@ def test_extract_no_content(self, Component):
assert node == TComponent(
start_i_index=0,
end_i_index=1,
children_span=TemplateSpan(PartPosition(2, 1), PartPosition(2, 1)),
children_span=TemplateSpan(PartPosition(1, 1), PartPosition(1, 1)),
)

def test_extract_startend(self, Component):
Expand Down Expand Up @@ -685,7 +685,7 @@ class TestSourcePos:
("t", "part_pos"),
(
(t"ABC<div></div>", PartPosition(0, offset=len("ABC"))),
(t"{' '}<div></div>", PartPosition(2, offset=0)),
(t"{' '}<div></div>", PartPosition(1, offset=0)),
),
)
def test_el(self, t: Template, part_pos: PartPosition):
Expand All @@ -698,7 +698,7 @@ def test_el(self, t: Template, part_pos: PartPosition):
("t", "part_pos"),
(
(t"<div></div>ABC", PartPosition(0, offset=len("<div></div>"))),
(t"<div>{' '}</div>ABC", PartPosition(2, offset=len("</div>"))),
(t"<div>{' '}</div>ABC", PartPosition(1, offset=len("</div>"))),
),
)
def test_text(self, t: Template, part_pos: PartPosition):
Expand All @@ -711,7 +711,7 @@ def test_text(self, t: Template, part_pos: PartPosition):
("t", "part_pos"),
(
(t" <!doctype html>", PartPosition(0, offset=2)),
(t"{' '}<!doctype html>", PartPosition(2, 0)),
(t"{' '}<!doctype html>", PartPosition(1, 0)),
),
)
def test_doctype(self, t: Template, part_pos: PartPosition):
Expand All @@ -724,7 +724,7 @@ def test_doctype(self, t: Template, part_pos: PartPosition):
("t", "part_pos"),
(
(t" <!--comment-->", PartPosition(0, offset=2)),
(t"<div>{'ABC'}</div><!--comment-->", PartPosition(2, len("</div>"))),
(t"<div>{'ABC'}</div><!--comment-->", PartPosition(1, len("</div>"))),
),
)
def test_comment(self, t: Template, part_pos: PartPosition):
Expand All @@ -739,7 +739,7 @@ def Comp() -> Template:

for t, part_pos in (
(t" <{Comp} />", PartPosition(0, offset=len(" "))),
(t" {'ABC'}DEF<{Comp} />", PartPosition(2, offset=len("DEF"))),
(t" {'ABC'}DEF<{Comp} />", PartPosition(1, offset=len("DEF"))),
):
root = parse_root(t)
assert isinstance(root, TFragment)
Expand Down Expand Up @@ -805,9 +805,9 @@ def Comp() -> Template:
assert sinfo.startend == False
assert sinfo.starttag_pos == PartPosition(
0, 0
) and sinfo.endtag_pos == PartPosition(2, len(">"))
) and sinfo.endtag_pos == PartPosition(1, len(">"))
assert sinfo.starttag_span == TemplateSpan(
PartPosition(0, 0), PartPosition(2, len(">"))
PartPosition(0, 0), PartPosition(1, len(">"))
)

def test_component_self_closed(self):
Expand All @@ -827,7 +827,7 @@ def Comp() -> Template:
assert sinfo.startend == True
assert sinfo.starttag_pos == PartPosition(0, 0) and sinfo.endtag_pos is None
assert sinfo.starttag_span == TemplateSpan(
PartPosition(0, 0), PartPosition(2, len(" />"))
PartPosition(0, 0), PartPosition(1, len(" />"))
)

def test_multiline_component_spans(self):
Expand Down
87 changes: 45 additions & 42 deletions tdom/parser_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import typing as t
from bisect import bisect_left
from dataclasses import dataclass
from itertools import accumulate
from string.templatelib import Template

from .placeholders import PlaceholderConfig
Expand All @@ -12,36 +12,42 @@
"""Absolute position in the placeholder-expanded template source, starting at 0."""


def precompute_line_start_positions(source_text: str) -> tuple[AbsolutePosition, ...]:
"""
Return the absolute positions where each line in the parser input starts.

The first line always starts at zero. A trailing newline therefore produces
one final line start whose absolute position is also the length of the input.
"""
return (0, *(index + 1 for index, char in enumerate(source_text) if char == "\n"))


def make_parser_pos_translator(
template: Template, config: PlaceholderConfig
) -> ParserPositionTranslator:
"""
Configure and return a `ParserPositionTranslator`.

We precompute a few things to make the translator's job easier.
Precompute line and string positions to make translation efficient.
"""

source_text_parts = tuple(
template.strings[index // 2]
if index % 2 == 0
else config.make_placeholder((index - 1) // 2)
for index in range(2 * len(template.strings) - 1)
)
source_text = "".join(source_text_parts)
line_start_positions: list[AbsolutePosition] = [0]
string_start_positions: list[AbsolutePosition] = []
string_end_positions: list[AbsolutePosition] = []
source_pos: AbsolutePosition = 0

def line_starts(string: str) -> t.Iterator[AbsolutePosition]:
return (
source_pos + offset + 1
for offset, char in enumerate(string)
if char == "\n"
)

for s_index, string in enumerate(template.strings):
string_start_positions.append(source_pos)
line_start_positions.extend(line_starts(string))
source_pos += len(string)
string_end_positions.append(source_pos)

if s_index < len(template.interpolations):
placeholder = config.make_placeholder(s_index)
line_start_positions.extend(line_starts(placeholder))
source_pos += len(placeholder)

return ParserPositionTranslator(
line_start_positions=precompute_line_start_positions(source_text),
part_end_positions=tuple(accumulate(map(len, source_text_parts))),
line_start_positions=tuple(line_start_positions),
string_start_positions=tuple(string_start_positions),
string_end_positions=tuple(string_end_positions),
)


Expand All @@ -50,8 +56,11 @@ class ParserPositionTranslator:
line_start_positions: tuple[AbsolutePosition, ...]
"""Absolute positions where lines in the parser input start."""

part_end_positions: tuple[AbsolutePosition, ...]
"""Absolute positions where placeholder-expanded template parts end."""
string_start_positions: tuple[AbsolutePosition, ...]
"""Absolute positions where static strings start in the parser input."""

string_end_positions: tuple[AbsolutePosition, ...]
"""Absolute positions where static strings end in the parser input."""

def line_pos_to_abs_pos(
self,
Expand All @@ -77,7 +86,7 @@ def line_pos_to_abs_pos(
line_end = (
self.line_start_positions[line] - 1
if line < line_count
else self.part_end_positions[-1]
else self.string_end_positions[-1]
)
line_length = line_end - line_start
if offset > line_length:
Expand All @@ -90,28 +99,24 @@ def abs_pos_to_part_pos(self, abs_pos: AbsolutePosition) -> PartPosition:
"""
Translate an absolute position into a template part position.

A position exactly between parts belongs to the following part. EOF is the
exception: a template always ends with a string part, and EOF belongs to the
end of that final string.
Positions at a placeholder's start and end are represented by the end of
its preceding string and the start of its following string, respectively.
Positions inside placeholders cannot be translated because interpolations
are atomic.
"""
source_length = self.part_end_positions[-1]
source_length = self.string_end_positions[-1]
if not 0 <= abs_pos <= source_length:
raise ValueError(
f"Absolute position falls outside the input: {abs_pos} not in [0, {source_length}]"
)

last_index = len(self.part_end_positions) - 1
if abs_pos == source_length:
final_part_start = (
self.part_end_positions[last_index - 1] if last_index else 0
s_index = bisect_left(self.string_end_positions, abs_pos)
string_start = self.string_start_positions[s_index]
if abs_pos < string_start:
raise ValueError(
"Positions inside interpolation placeholders are undefined."
)
return PartPosition(last_index, source_length - final_part_start)

index = bisect_left(self.part_end_positions, abs_pos)
part_start = self.part_end_positions[index - 1] if index else 0
if abs_pos == self.part_end_positions[index]:
return PartPosition(index + 1, 0)
return PartPosition(index, abs_pos - part_start)
return PartPosition(s_index, abs_pos - string_start)

def translate(self, parser_pos: LinePosition) -> PartPosition:
"""
Expand All @@ -123,9 +128,7 @@ def translate(self, parser_pos: LinePosition) -> PartPosition:
injected for `Interpolation`s.

return:
A position in a coordinate system that uses a unified index into
the parts of the `Template`. For interpolations the offset must be
`0` but the offset can be a non-zero number for string parts.
A position relative to one of the `Template`'s static strings.
"""
abs_pos = self.line_pos_to_abs_pos(parser_pos)
return self.abs_pos_to_part_pos(abs_pos)
Expand Down
Loading