diff --git a/tdom/parser.py b/tdom/parser.py index f0f71ff..40277b8 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -16,7 +16,7 @@ from .placeholders import ( make_placeholder_config as default_make_placeholder_config, ) -from .source import LinePosition +from .source import LinePosition, SourceReader from .template_utils import PartPosition, TemplateRef, TemplateSpan from .tnodes import ( TagSourceInfo, @@ -146,6 +146,9 @@ def __next__(self): else: raise StopIteration + def get_reader(self) -> SourceReader: + return SourceReader(template=self.template) + def remove_placeholders(self, text: str) -> TemplateRef: """ Find tracked placeholders in text and mark them as found. diff --git a/tdom/source.py b/tdom/source.py index e9b4fae..9a8ca04 100644 --- a/tdom/source.py +++ b/tdom/source.py @@ -1,4 +1,8 @@ +import typing as t from dataclasses import dataclass +from string.templatelib import Interpolation, Template + +from .template_utils import PartPosition, TemplateRef, TemplateSpan @dataclass(slots=True, frozen=True) @@ -9,3 +13,82 @@ class LinePosition: """Line of code, starts at 1.""" offset: int = 0 """Offset from the start of the line, starts at 0.""" + + +def template_repr_iter(template: Template) -> t.Generator[str]: + """ + Yield a string representation of each part of a given template. + + @NOTE: This will not yield empty strings because it uses the underlying + template iterator which does not. + """ + for part in template: + if isinstance(part, str): + yield part + else: + yield interpolation_repr(part) + + +def template_repr(template: Template) -> str: + """ + Create a string representation of the given template. + """ + return "".join(template_repr_iter(template)) + + +def interpolation_repr(ip: Interpolation) -> str: + """ + Create a string representation of the given interpolation. + """ + expr_str = ip.expression + conversion_str = f"!{ip.conversion}" if ip.conversion is not None else "" + format_spec_str = f":{ip.format_spec}" if ip.format_spec else "" + return f"{{{expr_str}{conversion_str}{format_spec_str}}}" + + +@dataclass +class SourceReader: + """Format report-like strings from template source for error reporting.""" + + template: Template + + def values_match(self, i_index1: int, i_index2: int) -> bool: + """Check if the two interpolation values match. + + @NOTE: This is meant to be used for reporting *better* error messages + after an error has already occurred. + + @TODO: Consider pulling this into another helper class with other + "inspection" type methods. + """ + return ( + self.template.interpolations[i_index1].value + == self.template.interpolations[i_index2].value + ) + + def ref_to_repr(self, ref: TemplateRef, limit: int | None = None) -> str: + """ + Convert tref to string representation of the underlying template. + """ + filled_template = ref.bind(self.template.interpolations) + return template_repr(filled_template)[:limit] + + def make_template_pos_msg(self, source_pos: PartPosition) -> str: + """ + Make a message to display the line number and offset number. + """ + template_pos = self.to_template_pos(source_pos) + return f"line {template_pos.line} offset {template_pos.offset}" + + def make_interpolation_repr(self, i_index: int) -> str: + return interpolation_repr(self.template.interpolations[i_index]) + + def to_template_pos(self, source_pos: PartPosition) -> LinePosition: + """ + Convert a (template) part position into a line position based on the + string representation of the template. + """ + span_up_to_pos = TemplateSpan(start=PartPosition(0, 0), stop=source_pos) + repr_up_to_pos = template_repr(span_up_to_pos.extract(self.template)) + lines_up_to_pos = repr_up_to_pos.split("\n") + return LinePosition(line=len(lines_up_to_pos), offset=len(lines_up_to_pos[-1])) diff --git a/tdom/source_test.py b/tdom/source_test.py new file mode 100644 index 0000000..d839ae7 --- /dev/null +++ b/tdom/source_test.py @@ -0,0 +1,118 @@ +from string.templatelib import Template + +import pytest + +from .source import LinePosition, SourceReader, template_repr +from .template_utils import PartPosition, TemplateRef + + +class TestSourceReader: + """ + Top-level tests for SourceReader class. + + More in depth tests are handled in more specialized test. + """ + + def test_values_match(self): + def comp() -> Template: + return t"" + + reader = SourceReader(template=t"<{comp}>{'content'}") + assert reader.values_match(0, 1) + assert not reader.values_match(0, 2) + + def test_ref_to_repr(self): + reader = SourceReader(template=t"a{'b'!s}c") + assert ( + reader.ref_to_repr(TemplateRef(strings=("A", ""), i_start=0)) == "A{'b'!s}" + ) + + def test_make_template_pos_msg(self): + reader = SourceReader(template=t"
{'content'}
") + msg = reader.make_template_pos_msg(source_pos=PartPosition(s_index=0, offset=1)) + assert msg == "line 1 offset 1" + + def test_make_interpolation_repr(self): + reader = SourceReader(template=t"
{'content'}
") + assert reader.make_interpolation_repr(0) == "{'content'}" + + def test_to_template_pos(self): + reader = SourceReader(template=t"
{'content'}
") + assert reader.to_template_pos( + PartPosition(s_index=0, offset=len("
")) + ) == LinePosition(line=1, offset=len("
")) + + +class TestTemplateRepresentation: + # whitespace is part of test + # fmt: off + @pytest.mark.parametrize( + ("t", "result"), + ( + (t"
{15!s:formatspec}
", "
{15!s:formatspec}
"), + (t"
{15:formatspec}
", "
{15:formatspec}
"), + (t"
{15!s}
", "
{15!s}
"), + (t"
{15}
", "
{15}
"), + (t"{15}", "{15}"), + (t"", ""), + (t"A{0}B{1}{2}C", "A{0}B{1}{2}C"), + (t"ABC", "ABC"), + (t"""
+
""", """
\n
"""), + (t"""{''' +'''}""", """{'''\n'''}"""), + ) + ) + def test_repr(self, t: Template, result: str): + assert template_repr(t) == result + # fmt: on + + +class TestToTemplatePosition: + def test_origin(self): + t = t"
{'content'}
" + reader = SourceReader(template=t) + source_pos = PartPosition(s_index=0, offset=0) + assert reader.to_template_pos(source_pos) == LinePosition(line=1, offset=0) + + def test_offset_no_lines(self): + t = t"
{'content'}
" + reader = SourceReader(template=t) + source_pos = PartPosition(s_index=0, offset=len(t.strings[0])) + assert reader.to_template_pos(source_pos) == LinePosition( + line=1, offset=len(t.strings[0]) + ) + + def test_offset_full_interpolation(self): + t = t"
{''!s:lower}
" # conversion and formatspec + reader = SourceReader(template=t) + source_pos = PartPosition(s_index=1, offset=0) + assert reader.to_template_pos(source_pos) == LinePosition( + line=1, offset=len('
{""!s:lower}') + ) + + def test_line(self): + # whitespace is part of test + # fmt: off + t = t"""
+{"content"}
""" + # fmt: on + reader = SourceReader(template=t) + source_pos = PartPosition(s_index=1, offset=0) + assert reader.to_template_pos(source_pos) == LinePosition( + line=2, offset=len('{"content"}') + ) + + def test_line_in_interpolation(self): + # whitespace is part of test + # fmt: off + t = t"""
+{''' +content +'''}
""" + # fmt: on + reader = SourceReader(template=t) + source_pos = PartPosition(s_index=1, offset=0) + assert reader.to_template_pos(source_pos) == LinePosition( + line=4, offset=len("'''}") + )