From 217d1c9cde5f12cc35a5a802c7fdcc6a2a69dc09 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Wed, 29 Jul 2026 15:59:35 -0700 Subject: [PATCH 01/10] Add parser exceptions. --- tdom/exc.py | 2 + tdom/parser.py | 289 ++++++++++++++++++++++++++++++++++++++------ tdom/parser_test.py | 168 +++++++++++++++++++++---- tdom/source.py | 10 ++ 4 files changed, 409 insertions(+), 60 deletions(-) create mode 100644 tdom/exc.py diff --git a/tdom/exc.py b/tdom/exc.py new file mode 100644 index 00000000..67fc85e9 --- /dev/null +++ b/tdom/exc.py @@ -0,0 +1,2 @@ +class TemplatingError(Exception): + pass diff --git a/tdom/parser.py b/tdom/parser.py index 40277b86..ff81b492 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -3,6 +3,7 @@ from html.parser import HTMLParser from string.templatelib import Template +from .exc import TemplatingError from .htmlspec import VOID_ELEMENTS from .parser_utils import ( HTMLAttribute, @@ -36,6 +37,18 @@ ) +class ParsingError(TemplatingError): + pass + + +class ParsingAssertionError(ParsingError): + pass + + +class AttributeParsingError(ParsingError): + pass + + @dataclass(frozen=True, slots=True) class OpenTagSourceInfo: """ @@ -208,6 +221,9 @@ class TemplateParser(HTMLParser): stack: list[OpenTag] source: SourceTracker | None + tcomponent_children: dict[TComponent, list[TNode]] + "List of children for each finished tcomponent, stored at closing. " + sinfo_table: dict[PartPosition, TagSourceInfo] """Tags with more source info than just a position are tracked in this mapping.""" @@ -269,11 +285,11 @@ def make_tattr(self, attr: HTMLAttribute) -> TAttribute: else: return TTemplatedAttribute(name=name, value_ref=value_ref) if value_ref is not None: - raise ValueError( + raise AttributeParsingError( "Attribute names cannot contain interpolations if the value is also interpolated." ) if not name_ref.is_singleton: - raise ValueError( + raise AttributeParsingError( "Spread attributes must have exactly one interpolation in the name." ) return TSpreadAttribute(i_index=name_ref.i_start) @@ -310,7 +326,7 @@ def make_open_tag( ) if not tag_ref.is_singleton: - raise ValueError( + raise ParsingError( "Component element tags must have exactly one interpolation." ) @@ -368,6 +384,7 @@ def finalize_tag( attrs=attrs, source_pos=source_pos, sinfo=sinfo, + children=children, ): children_span = ( TemplateSpan(start=children_start, stop=endtag_pos) @@ -375,59 +392,127 @@ def finalize_tag( else None ) self.sinfo_table[source_pos] = sinfo.close(endtag_pos=endtag_pos) - return TComponent( + tnode = TComponent( start_i_index=start_i_index, end_i_index=endtag_i_index, children_span=children_span, attrs=attrs, source_pos=source_pos, ) + # Save children for error handling in the parser. + self.tcomponent_children[tnode] = children + return tnode + + def make_mismatch_error( + self, + starttag_sinfo: OpenTagSourceInfo, + starttag_attrs: tuple[TAttribute, ...], + endtag_ref: TemplateRef, + endtag_pos: PartPosition, + ) -> ParsingError: + reader = self.get_source().get_reader() + starttag_repr = reader.span_to_repr(starttag_sinfo.starttag_span) + starttag_pos_msg = reader.make_template_pos_msg(starttag_sinfo.starttag_pos) + endtag_repr = reader.ref_to_repr(endtag_ref) + endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) + e = ParsingError( + f"Mismatched closing tag at {endtag_pos_msg} for {starttag_repr} at {starttag_pos_msg}." + ) + if self.has_ambiguous_forward_slash(starttag_sinfo, starttag_attrs): + e.add_note( + f'Did you mean to quote the last attribute or put a space before "/>" for "{starttag_repr}" at {starttag_pos_msg}?' + ) + return e + + def make_invalid_endtag_error( + self, endtag_ref: TemplateRef, endtag_pos: PartPosition + ) -> ParsingError: + reader = self.get_source().get_reader() + endtag_repr = reader.ref_to_repr(endtag_ref) + endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) + raise ParsingError( + f"Component end tags must have exactly one interpolation, {endtag_repr} at {endtag_pos_msg}." + ) def validate_end_tag(self, tag: str, open_tag: OpenTag) -> int | None: """Validate that closing tag matches open tag. Return component end index if applicable.""" source = self.get_source() - tag_ref = source.remove_placeholders(tag) + tag_ref = source.placeholders.remove_placeholders(tag) match open_tag: case OpenTElement(): - if not tag_ref.is_literal: - raise ValueError( - f"Component closing tag found for element <{open_tag.tag}>." - ) - if tag != open_tag.tag: - raise ValueError( - f"Mismatched closing tag for element <{open_tag.tag}>." + if tag_ref.is_singleton or (tag_ref.is_literal and tag != open_tag.tag): + raise self.make_mismatch_error( + open_tag.sinfo, open_tag.attrs, tag_ref, self.get_source_pos() ) + elif not tag_ref.is_singleton and not tag_ref.is_literal: + raise self.make_invalid_endtag_error(tag_ref, self.get_source_pos()) return None - case OpenTFragment(): - raise NotImplementedError("We do not support anonymous fragments.") - - case OpenTComponent(start_i_index=start_i_index): + raise ParsingAssertionError("We do not support anonymous fragments.") + case OpenTComponent(): if tag_ref.is_literal: - raise ValueError( - f"Mismatched closing tag for component starting at {source.format_starttag(start_i_index)}." + raise self.make_mismatch_error( + open_tag.sinfo, open_tag.attrs, tag_ref, self.get_source_pos() ) if not tag_ref.is_singleton: - raise ValueError( - "Component end tags must have exactly one interpolation." - ) - # HERE BE DRAGONS: the interpolation at end_i_index shuld be a - # component callable that matches the start tag. We do not check - # any of this in the parser, instead relying on higher layers. + raise self.make_invalid_endtag_error(tag_ref, self.get_source_pos()) return tag_ref.i_start def get_starttag_span(self) -> TemplateSpan: """Return the source span occupied by the current start tag.""" starttag_text = self.get_starttag_text() - assert starttag_text is not None, ( - "Expected the parser to have starttag_text set." - ) + if starttag_text is None: + raise ParsingAssertionError( + "Expected the parser to have starttag_text set." + ) source = self.get_source() line_pos = self.get_parser_pos() return source.translate_parser_span(line_pos, len(starttag_text)) + def has_ambiguous_forward_slash( + self, + sinfo: OpenTagSourceInfo | TagSourceInfo | None, + attrs: tuple[TAttribute, ...], + ) -> bool: + """ + Detect when an unquoted attribute value consumes a trailing "/" that + *might* have been meant to attempt to self-close a tag, ie. "/>". + + This can come up with literal values or values with interpolations. + + Such as "
" or "<{Component} title=test/>". + + Or more often "<{Component} title={title}/>" which should be corrected + with "<{Component} title={title} />". + """ + source = self.get_source() + reader = source.get_reader() + return ( + # has source info + sinfo is not None + # has attributes + and len(attrs) > 0 + # last attribute ends with "/" + # @NOTE: spread and interpolated attrs never do + and ( + ( + isinstance(attrs[-1], TLiteralAttribute) + and attrs[-1].value is not None + and attrs[-1].value.endswith("/") + ) + or ( + isinstance(attrs[-1], TTemplatedAttribute) + and attrs[-1].value_ref.strings[-1].endswith("/") + ) + ) + # original starttag ends with "/>", + and reader.span_to_template(sinfo.starttag_span).strings[-1].endswith("/>") + # if parsed AS startend already then its not ambiguous + and not sinfo.startend + ) + # ------------------------------------------ # HTMLParser tag callbacks # ------------------------------------------ @@ -449,15 +534,58 @@ def handle_startendtag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> None: def handle_endtag(self, tag: str) -> None: endtag_pos = self.get_source_pos() if not self.stack: - raise ValueError(f"Unexpected closing tag with no open tag.") - + source = self.get_source() + reader = source.get_reader() + # @TODO: Not sure if we want the pre-parsed content here but + # I'm not sure if we'd be able to easily find the "end" of it + # without parsing ourself. So for now we settle with the post-parsed + # content and just resolve any interpolation expressions. + endtag_ref = source.find_placeholders(tag) + endtag_repr = reader.ref_to_repr(endtag_ref) + endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) + if endtag_ref.is_literal or endtag_ref.is_singleton: + raise ParsingError( + f"Unexpected closing tag with no open tag, {endtag_pos_msg}." + ) + else: + raise self.make_invalid_endtag_error(endtag_ref, endtag_pos) open_tag = self.stack.pop() endtag_i_index = self.validate_end_tag(tag, open_tag) final_tag = self.finalize_tag( - open_tag, endtag_i_index=endtag_i_index, endtag_pos=endtag_pos + open_tag, + endtag_i_index=endtag_i_index, + endtag_pos=endtag_pos, ) self.append_child(final_tag) + def get_closed_tcomps( + self, root: OpenTag | None, recurse_component_children: bool = False + ) -> list[TComponent]: + """ + Get TComponents that were closed during parsing starting from `root`. + + If `root` is None then use the parser's default `root`. + + TComponents should be returned in the order they were closed in: + from first closed to last closed. + + @NOTE: That the root is an `OpenTag` but its `children` are actually `TNode`s. + """ + if root is None: + root = self.root + tcomps = [] + nodes = list(root.children) + while nodes: + node = nodes.pop() + if isinstance(node, TComponent): + tcomps.append(node) + if recurse_component_children: + children = self.tcomponent_children.get(node, []) + nodes.extend(children) + elif isinstance(node, (TElement, TFragment)): + nodes.extend(node.children) + return tcomps + # ------------------------------------------ # HTMLParser other callbacks # ------------------------------------------ @@ -486,13 +614,13 @@ def handle_decl(self, decl: str) -> None: source = self.get_source() ref = source.remove_placeholders(decl) if not ref.is_literal: - raise ValueError("Interpolations are not allowed in declarations.") + raise ParsingError("Interpolations are not allowed in declarations.") elif decl.upper().startswith("DOCTYPE "): doctype_content = decl[7:].strip() doctype = TDocumentType(doctype_content, source_pos=self.get_source_pos()) self.append_child(doctype) else: - raise NotImplementedError( + raise ParsingError( "Only well formed DOCTYPE declarations are currently supported." ) @@ -502,22 +630,107 @@ def reset(self): self.stack = [] self.source = None self.sinfo_table = {} + self.tcomponent_children = {} + + def run_unclosed_ambiguous_slash_checks( + self, parent: OpenTag, e: ParsingError + ) -> None: + """ + Check for cases where ambiguous slash might create a confusing error. + + @NOTE: This add exception notes to the exception but does not throw it. + """ + source = self.get_source() + reader = source.get_reader() + if isinstance( + parent, (OpenTElement, OpenTComponent) + ) and self.has_ambiguous_forward_slash(parent.sinfo, parent.attrs): + # CASE: "<{C1} attr={value}/>" -- maybe user meant to self-close? + # CASE: "
" -- mayber user meant to self-close? + starttag_span = parent.sinfo.starttag_span + starttag_repr = reader.span_to_repr(starttag_span) + pos_msg = reader.make_template_pos_msg(parent.source_pos) + e.add_note( + f'Did you mean to quote the last attribute or put a space before "/>" for "{starttag_repr}" at {pos_msg}?' + ) + elif isinstance(parent, OpenTElement): + # ie. t"
", looks + # like we missed a closing
but really we meant to + # self-close the middle div. + children = parent.children[:] + while children: + child = children.pop(0) + if isinstance(child, TElement) and child.tag == parent.tag: + sinfo = ( + self.sinfo_table.get(child.source_pos) + if child.source_pos is not None + else None + ) + if sinfo and self.has_ambiguous_forward_slash(sinfo, child.attrs): + full_starttag_repr = reader.span_to_repr(sinfo.starttag_span) + e.add_note( + f'Did you mean to quote the last attribute or put a space before "/>" for "{full_starttag_repr}"?' + ) + children.extend(child.children) + elif isinstance(parent, OpenTComponent): + # This is a special case where a component accidentally closes + # another component but we don't check the actual values in + # the parser so we can't tell until we are generating an error + # (when we can check the values). + # + # CASE: t"<{C2}><{C1} attr=/>" + # Maybe user meant to self-close <{C1} ...>, but closed by leaving <{C2}...> open? + # CASE: t"<{C3}><{C2}><{C1} attr=/>" + for comp in reversed( + self.get_closed_tcomps(parent, recurse_component_children=True) + ): + if ( + comp.end_i_index is not None + and comp.start_i_index != comp.end_i_index + and not reader.values_match(comp.start_i_index, comp.end_i_index) + ): + starttag_repr = reader.make_interpolation_repr(comp.start_i_index) + endtag_repr = reader.make_interpolation_repr(comp.end_i_index) + e.add_note( + f"Component start tag, <{starttag_repr} ...>, and end tag, , have values that do not match." + ) + sinfo = ( + self.sinfo_table.get(comp.source_pos) + if comp.source_pos is not None + else None + ) + if sinfo and self.has_ambiguous_forward_slash(sinfo, comp.attrs): + full_starttag_repr = reader.span_to_repr(sinfo.starttag_span) + e.add_note( + f'Did you mean to quote the last attribute or put a space before "/>" for "{full_starttag_repr}"?' + ) def close(self) -> None: + source = self.get_source() if self.waiting_for_data(): # We apply heuristics here to try to guess why the parser didn't finish. if self.rawdata.count('"') % 2 == 1 or self.rawdata.count("'") % 2 == 1: - raise ValueError( + raise ParsingError( "Parser expects more data, maybe you left an attribute quote unclosed?" ) else: - raise ValueError( + raise ParsingError( "Parser expects more data, is the template valid html?" ) if self.stack: - raise ValueError("Invalid HTML structure: unclosed tags remain.") + parent = self.stack[-1] + if isinstance(parent, (OpenTElement, OpenTComponent)): + reader = source.get_reader() + starttag_repr = reader.span_to_repr(parent.sinfo.starttag_span) + pos_msg = reader.make_template_pos_msg(parent.source_pos) + unclosed_msg = f"unclosed tag {starttag_repr} at {pos_msg}" + else: + unclosed_msg = "unclosed tags remain" + e = ParsingError(f"Invalid HTML structure: {unclosed_msg}.") + self.run_unclosed_ambiguous_slash_checks(parent, e) + raise e if self.source and self.source.has_placeholders(): - raise ValueError("Some placeholders were never resolved.") + raise ParsingError("Some placeholders were never resolved.") super().close() def waiting_for_data(self): @@ -556,12 +769,12 @@ def get_ttree(self) -> TTree: def get_source(self) -> SourceTracker: if self.source is None: - raise AssertionError("Source has not been initialized.") + raise ParsingAssertionError("Source has not been initialized.") return self.source def track_source(self, template: Template) -> SourceTracker: if self.source: - raise AssertionError("Did you forget to call reset?") + raise ParsingAssertionError("Did you forget to call reset?") source = self.source = configure_source_tracker(template) return source diff --git a/tdom/parser_test.py b/tdom/parser_test.py index 8d327853..547b1acc 100644 --- a/tdom/parser_test.py +++ b/tdom/parser_test.py @@ -2,7 +2,12 @@ import pytest -from .parser import TemplateParser, configure_source_tracker +from .parser import ( + AttributeParsingError, + ParsingError, + TemplateParser, + configure_source_tracker, +) from .placeholders import make_placeholder_config from .template_utils import PartPosition, TemplateRef, TemplateSpan from .tnodes import ( @@ -223,17 +228,17 @@ def test_parse_title_unusual(): def test_parse_mismatched_tags(): - with pytest.raises(ValueError): + with pytest.raises(ParsingError, match="Mismatch"): _ = parse_root(t"
Mismatched
") -def test_parse_unclosed_tag(): - with pytest.raises(ValueError): +def test_parse_unclosed_element(): + with pytest.raises(ParsingError, match="unclosed tag
"): _ = parse_root(t"
Unclosed") def test_parse_unexpected_closing_tag(): - with pytest.raises(ValueError): + with pytest.raises(ParsingError, match="Unexpected closing tag"): _ = parse_root(t"Unopened
") @@ -257,12 +262,12 @@ def test_nested_self_closing_tags(): def test_self_closing_tags_unexpected_closing_tag(): - with pytest.raises(ValueError): + with pytest.raises(ParsingError, match="Unexpected closing tag"): _ = parse_root(t"
") def test_self_closing_void_tags_unexpected_closing_tag(): - with pytest.raises(ValueError): + with pytest.raises(ParsingError, match="Unexpected closing tag"): _ = parse_root(t"") @@ -351,20 +356,28 @@ def test_spread_attr(): def test_templated_attribute_name_error(): - with pytest.raises(ValueError): + with pytest.raises( + AttributeParsingError, + match="cannot contain interpolations if the value is also interpolated", + ): attr_name = "some-attr" _ = parse_root(t'
') def test_templated_attribute_name_and_value_error(): - with pytest.raises(ValueError): + with pytest.raises( + AttributeParsingError, + match="cannot contain interpolations if the value is also interpolated", + ): attr_name = "some-attr" value = "value" _ = parse_root(t'
') def test_adjacent_spread_attrs_error(): - with pytest.raises(ValueError): + with pytest.raises( + AttributeParsingError, match="must have exactly one interpolation in the name" + ): attrs1 = {} attrs2 = {} _ = parse_root(t"
") @@ -394,14 +407,16 @@ def test_parse_doctype(): def test_parse_doctype_interpolation_error(): extra = "SYSTEM" - with pytest.raises(ValueError): + with pytest.raises( + ParsingError, match="Interpolations are not allowed in declarations" + ): _ = parse_root(t"") def test_unsupported_decl_error(): - with pytest.raises(NotImplementedError): + with pytest.raises(ParsingError, match="Only well formed DOCTYPE declarations"): _ = parse_root(t"") # Unknown declaration - with pytest.raises(NotImplementedError): + with pytest.raises(ParsingError, match="Only well formed DOCTYPE declarations"): _ = parse_root(t"") # missing DTD @@ -460,7 +475,7 @@ def test_component_element_invalid_closing_tag(): def Component(): pass - with pytest.raises(ValueError): + with pytest.raises(ParsingError, match="Mismatched closing tag
"): _ = parse_root(t"<{Component}>
") @@ -468,7 +483,8 @@ def test_component_element_invalid_opening_tag(): def Component(): pass - with pytest.raises(ValueError): + # @NOTE: intentional expression + with pytest.raises(ParsingError, match="Mismatched closing tag "): _ = parse_root(t"
") @@ -476,7 +492,7 @@ def test_adjacent_start_component_tag_error(): def Component(): pass - with pytest.raises(ValueError): + with pytest.raises(ParsingError, match="must have exactly one interpolation"): _ = parse_root(t"<{Component}{Component}>") @@ -484,10 +500,26 @@ def test_adjacent_end_component_tag_error(): def Component(): pass - with pytest.raises(ValueError): + with pytest.raises(ParsingError, match="must have exactly one interpolation"): _ = parse_root(t"<{Component}>") +def test_unmatched_end_component_tag_error(): + def Component(): + pass + + with pytest.raises(ParsingError, match="Unexpected closing tag "): + _ = TemplateParser.parse(t"") + + +def test_unclosed_component_tag_error(): + def Component(): + pass + + with pytest.raises(ParsingError, match="unclosed tag <{Component}>"): + _ = TemplateParser.parse(t"<{Component}>") + + def test_placeholder_collision_avoidance(): config = make_placeholder_config() # This test is to ensure that our placeholder detection avoids collisions @@ -513,7 +545,7 @@ def test_unresolved_placeholder(): # This would be a bug in the parser so we have to fabricate # this error manually. tp.get_source().placeholders.add_placeholder(3) - with pytest.raises(ValueError, match="Some placeholders were never resolved"): + with pytest.raises(ParsingError, match="Some placeholders were never resolved"): tp.close() @@ -558,17 +590,17 @@ def test_iter(self): class TestIncompleteParsing: def test_dangling_quotes(self): - with pytest.raises(ValueError, match="Parser expects more data"): + with pytest.raises(ParsingError, match="Parser expects more data"): _ = parse_root(t"
") + + def test_nested_unclosed_error(self): + with pytest.raises( + ParsingError, + match="Did you mean to quote the last attribute.*attr[=]nested/", + ): + _ = TemplateParser.parse(t"
") + + def test_double_nested_unclosed_error(self): + with pytest.raises( + ParsingError, + match="Did you mean to quote the last attribute.*attr[=]nested/", + ): + _ = TemplateParser.parse(t"
") + + def test_mismatch_with_element_error(self): + with pytest.raises( + ParsingError, + match="Did you mean to quote the last attribute.*attr[=]mismatch/", + ): + _ = TemplateParser.parse(t"
") + + def test_mismatch_with_component_error(self): + def Comp(children: Template) -> Template: + return t"" + + with pytest.raises( + ParsingError, + match="Did you mean to quote the last attribute.*attr[=]mismatch/", + ): + _ = TemplateParser.parse(t"<{Comp}>
") + + +class TestComponentWithAmbiguousSlash: + @pytest.fixture + def Comp1(self): + def _Comp1(children: Template, title: str) -> Template: + return children + + return _Comp1 + + @pytest.fixture + def Comp2(self): + def _Comp2(children: Template, title: str) -> Template: + return children + + return _Comp2 + + @pytest.fixture + def Comp3(self): + def _Comp3(children: Template, title: str) -> Template: + return children + + return _Comp3 + + def test_mismatch_with_element_error(self, Comp1): + with pytest.raises( + ParsingError, + match="Did you mean to quote the last attribute.*title[=]today/", + ): + _ = TemplateParser.parse(t"
<{Comp1} title=today/>
") + + def test_root_unclosed_error(self, Comp1): + with pytest.raises( + ParsingError, + match="Did you mean to quote the last attribute.*title[=]today/", + ): + _ = TemplateParser.parse(t"<{Comp1} title=today/>") + + def test_single_nested_unclosed_error(self, Comp1, Comp2): + with pytest.raises( + ParsingError, + match="Did you mean to quote the last attribute.*title[=]today/", + ): + _ = TemplateParser.parse(t"<{Comp2}><{Comp1} title=today/>") + + def test_double_nested_unclosed_error(self, Comp1, Comp2, Comp3): + with pytest.raises( + ParsingError, + match="Did you mean to quote the last attribute.*title[=]today/", + ): + _ = TemplateParser.parse( + t"<{Comp2}><{Comp1}><{Comp3} title=today/>" + ) + + class TestSourcePos: """ Test that common nodes have a source position translated and set during parsing. diff --git a/tdom/source.py b/tdom/source.py index 9a8ca045..706d42df 100644 --- a/tdom/source.py +++ b/tdom/source.py @@ -73,6 +73,16 @@ def ref_to_repr(self, ref: TemplateRef, limit: int | None = None) -> str: filled_template = ref.bind(self.template.interpolations) return template_repr(filled_template)[:limit] + def span_to_repr(self, span: TemplateSpan, limit: int | None = None) -> str: + """ + Extract template span and convert to string representation. + """ + filled_template = span.extract(self.template) + return template_repr(filled_template)[:limit] + + def span_to_template(self, span: TemplateSpan) -> Template: + return span.extract(self.template) + def make_template_pos_msg(self, source_pos: PartPosition) -> str: """ Make a message to display the line number and offset number. From c7a555f2a1e95c4b3e3a1def81b673951adcbab5 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 24 Aug 2026 21:45:40 -0700 Subject: [PATCH 02/10] Cleanup comments a bit. --- tdom/parser.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tdom/parser.py b/tdom/parser.py index ff81b492..db059e21 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -399,7 +399,9 @@ def finalize_tag( attrs=attrs, source_pos=source_pos, ) - # Save children for error handling in the parser. + # Save children for introspection after some parsing errors otherwise + # they are discarded since we extract the children_span in the processor + # for the components. self.tcomponent_children[tnode] = children return tnode @@ -536,10 +538,6 @@ def handle_endtag(self, tag: str) -> None: if not self.stack: source = self.get_source() reader = source.get_reader() - # @TODO: Not sure if we want the pre-parsed content here but - # I'm not sure if we'd be able to easily find the "end" of it - # without parsing ourself. So for now we settle with the post-parsed - # content and just resolve any interpolation expressions. endtag_ref = source.find_placeholders(tag) endtag_repr = reader.ref_to_repr(endtag_ref) endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) @@ -638,7 +636,7 @@ def run_unclosed_ambiguous_slash_checks( """ Check for cases where ambiguous slash might create a confusing error. - @NOTE: This add exception notes to the exception but does not throw it. + @NOTE: This adds exception notes to the exception but does not throw it. """ source = self.get_source() reader = source.get_reader() From a82184d7259af7ab6ff5a8adf1b281ed97634758 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Tue, 25 Aug 2026 22:20:53 -0700 Subject: [PATCH 03/10] Experiment with moving complicated error generation into helper class. --- tdom/parser.py | 428 ++++++++++++++++++++++++++----------------------- 1 file changed, 230 insertions(+), 198 deletions(-) diff --git a/tdom/parser.py b/tdom/parser.py index db059e21..bf1d5437 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -159,9 +159,6 @@ 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. @@ -216,6 +213,218 @@ def format_starttag(self, i_index: int) -> str: return self.get_expression(i_index, fallback_prefix="component-starttag") +@dataclass(frozen=True) +class ParsingErrorHelper: + """ + Helps the parser include extra info in parsing errors. + + This helper *should not* decide what error to raise. + + This helper *should* help to: + - include possible reasons (did you forget to close a quote?) + - include original template source (the start tag, etc.) + - include position info (line #, etc.) + """ + + parser: TemplateParser + """The parser, used for introspection.""" + + def get_source_reader(self): + return SourceReader(self.parser.get_source().template) + + def run_unclosed_ambiguous_slash_checks( + self, parent: OpenTag, e: ParsingError + ) -> None: + """ + Check for cases where ambiguous slash might create a confusing error. + + @NOTE: This adds exception notes to the exception but does not throw it. + """ + reader = self.get_source_reader() + if isinstance( + parent, (OpenTElement, OpenTComponent) + ) and self.has_ambiguous_forward_slash(parent.sinfo, parent.attrs): + # CASE: t"<{C1} attr={value}/>" -- maybe user meant to self-close? + # CASE: t"
" -- maybe user meant to self-close? + starttag_span = parent.sinfo.starttag_span + starttag_repr = reader.span_to_repr(starttag_span) + pos_msg = reader.make_template_pos_msg(parent.source_pos) + e.add_note( + f'Did you mean to quote the last attribute or put a space before "/>" for "{starttag_repr}" at {pos_msg}?' + ) + elif isinstance(parent, OpenTElement): + # CASE: t"
" -- maybe user meant to self-close? + # looks like user missed a closing
but they really meant to + # self-close the middle div. + children = parent.children[:] + while children: + child = children.pop(0) + if isinstance(child, TElement) and child.tag == parent.tag: + sinfo = ( + self.parser.sinfo_table.get(child.source_pos) + if child.source_pos is not None + else None + ) + if sinfo and self.has_ambiguous_forward_slash(sinfo, child.attrs): + full_starttag_repr = reader.span_to_repr(sinfo.starttag_span) + e.add_note( + f'Did you mean to quote the last attribute or put a space before "/>" for "{full_starttag_repr}"?' + ) + children.extend(child.children) + elif isinstance(parent, OpenTComponent): + # This is a special case where a component accidentally closes + # another component but we don't check the actual values in + # the parser so we can't tell until we are generating an error + # (when we can check the values). + # + # CASE: t"<{C2}><{C1} attr=/>" + # Maybe user meant to self-close <{C1} ...>, but closed by leaving <{C2}...> open? + # CASE: t"<{C3}><{C2}><{C1} attr=/>" + for comp in reversed( + self.get_closed_tcomps(parent, recurse_component_children=True) + ): + if ( + comp.end_i_index is not None + and comp.start_i_index != comp.end_i_index + and not reader.values_match(comp.start_i_index, comp.end_i_index) + ): + starttag_repr = reader.make_interpolation_repr(comp.start_i_index) + endtag_repr = reader.make_interpolation_repr(comp.end_i_index) + e.add_note( + f"Component start tag, <{starttag_repr} ...>, and end tag, , have values that do not match." + ) + sinfo = ( + self.parser.sinfo_table.get(comp.source_pos) + if comp.source_pos is not None + else None + ) + if sinfo and self.has_ambiguous_forward_slash(sinfo, comp.attrs): + full_starttag_repr = reader.span_to_repr(sinfo.starttag_span) + e.add_note( + f'Did you mean to quote the last attribute or put a space before "/>" for "{full_starttag_repr}"?' + ) + + def make_mismatch_error( + self, + starttag_sinfo: OpenTagSourceInfo, + starttag_attrs: tuple[TAttribute, ...], + endtag_ref: TemplateRef, + endtag_pos: PartPosition, + ) -> ParsingError: + reader = self.get_source_reader() + starttag_repr = reader.span_to_repr(starttag_sinfo.starttag_span) + starttag_pos_msg = reader.make_template_pos_msg(starttag_sinfo.starttag_pos) + endtag_repr = reader.ref_to_repr(endtag_ref) + endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) + e = ParsingError( + f"Mismatched closing tag at {endtag_pos_msg} for {starttag_repr} at {starttag_pos_msg}." + ) + if self.has_ambiguous_forward_slash(starttag_sinfo, starttag_attrs): + e.add_note( + f'Did you mean to quote the last attribute or put a space before "/>" for "{starttag_repr}" at {starttag_pos_msg}?' + ) + return e + + def make_malformed_endtag_error( + self, endtag_ref: TemplateRef, endtag_pos: PartPosition + ) -> ParsingError: + reader = self.get_source_reader() + endtag_repr = reader.ref_to_repr(endtag_ref) + endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) + return ParsingError( + f"Component end tags must have exactly one interpolation, {endtag_repr} at {endtag_pos_msg}." + ) + + def make_unexpected_endtag_error( + self, endtag_ref: TemplateRef, endtag_pos: PartPosition + ) -> ParsingError: + reader = self.get_source_reader() + endtag_repr = reader.ref_to_repr(endtag_ref) + endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) + return ParsingError( + f"Unexpected closing tag with no open tag, {endtag_pos_msg}." + ) + + def get_closed_tcomps( + self, root: OpenTag | None, recurse_component_children: bool = False + ) -> list[TComponent]: + """ + Get TComponents that were closed during parsing starting from `root`. + + If `root` is None then use the parser's default `root`. + + TComponents should be returned in the order they were closed in: + from first closed to last closed. + + @NOTE: That the root is an `OpenTag` but its `children` are actually `TNode`s. + """ + if root is None: + root = self.parser.root + tcomps = [] + nodes = list(root.children) + while nodes: + node = nodes.pop() + if isinstance(node, TComponent): + tcomps.append(node) + if recurse_component_children: + children = self.parser.tcomponent_children.get(node, []) + nodes.extend(children) + elif isinstance(node, (TElement, TFragment)): + nodes.extend(node.children) + return tcomps + + def has_ambiguous_forward_slash( + self, + sinfo: OpenTagSourceInfo | TagSourceInfo | None, + attrs: tuple[TAttribute, ...], + ) -> bool: + """ + Detect when an unquoted attribute value consumes a trailing "/" that + *might* have been meant to attempt to self-close a tag, ie. "/>". + + This can come up with literal values or values with interpolations. + + Such as "
" or "<{Component} title=test/>". + + Or more often "<{Component} title={title}/>" which should be corrected + with "<{Component} title={title} />". + """ + reader = self.get_source_reader() + return ( + # has source info + sinfo is not None + # has attributes + and len(attrs) > 0 + # last attribute ends with "/" + # @NOTE: spread and interpolated attrs never do + and ( + ( + isinstance(attrs[-1], TLiteralAttribute) + and attrs[-1].value is not None + and attrs[-1].value.endswith("/") + ) + or ( + isinstance(attrs[-1], TTemplatedAttribute) + and attrs[-1].value_ref.strings[-1].endswith("/") + ) + ) + # original starttag ends with "/>", + and reader.span_to_template(sinfo.starttag_span).strings[-1].endswith("/>") + # if parsed AS startend already then its not ambiguous + and not sinfo.startend + ) + + def make_unclosed_starttag_error(self, parent: OpenTElement | OpenTComponent): + reader = self.get_source_reader() + starttag_repr = reader.span_to_repr(parent.sinfo.starttag_span) + pos_msg = reader.make_template_pos_msg(parent.source_pos) + e = ParsingError( + f"Invalid HTML structure: unclosed tag {starttag_repr} at {pos_msg}." + ) + self.run_unclosed_ambiguous_slash_checks(parent, e) + return e + + class TemplateParser(HTMLParser): root: OpenTFragment stack: list[OpenTag] @@ -405,37 +614,6 @@ def finalize_tag( self.tcomponent_children[tnode] = children return tnode - def make_mismatch_error( - self, - starttag_sinfo: OpenTagSourceInfo, - starttag_attrs: tuple[TAttribute, ...], - endtag_ref: TemplateRef, - endtag_pos: PartPosition, - ) -> ParsingError: - reader = self.get_source().get_reader() - starttag_repr = reader.span_to_repr(starttag_sinfo.starttag_span) - starttag_pos_msg = reader.make_template_pos_msg(starttag_sinfo.starttag_pos) - endtag_repr = reader.ref_to_repr(endtag_ref) - endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) - e = ParsingError( - f"Mismatched closing tag at {endtag_pos_msg} for {starttag_repr} at {starttag_pos_msg}." - ) - if self.has_ambiguous_forward_slash(starttag_sinfo, starttag_attrs): - e.add_note( - f'Did you mean to quote the last attribute or put a space before "/>" for "{starttag_repr}" at {starttag_pos_msg}?' - ) - return e - - def make_invalid_endtag_error( - self, endtag_ref: TemplateRef, endtag_pos: PartPosition - ) -> ParsingError: - reader = self.get_source().get_reader() - endtag_repr = reader.ref_to_repr(endtag_ref) - endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) - raise ParsingError( - f"Component end tags must have exactly one interpolation, {endtag_repr} at {endtag_pos_msg}." - ) - def validate_end_tag(self, tag: str, open_tag: OpenTag) -> int | None: """Validate that closing tag matches open tag. Return component end index if applicable.""" source = self.get_source() @@ -444,21 +622,25 @@ def validate_end_tag(self, tag: str, open_tag: OpenTag) -> int | None: match open_tag: case OpenTElement(): if tag_ref.is_singleton or (tag_ref.is_literal and tag != open_tag.tag): - raise self.make_mismatch_error( + raise ParsingErrorHelper(self).make_mismatch_error( open_tag.sinfo, open_tag.attrs, tag_ref, self.get_source_pos() ) elif not tag_ref.is_singleton and not tag_ref.is_literal: - raise self.make_invalid_endtag_error(tag_ref, self.get_source_pos()) + raise ParsingErrorHelper(self).make_malformed_endtag_error( + tag_ref, self.get_source_pos() + ) return None case OpenTFragment(): raise ParsingAssertionError("We do not support anonymous fragments.") case OpenTComponent(): if tag_ref.is_literal: - raise self.make_mismatch_error( + raise ParsingErrorHelper(self).make_mismatch_error( open_tag.sinfo, open_tag.attrs, tag_ref, self.get_source_pos() ) if not tag_ref.is_singleton: - raise self.make_invalid_endtag_error(tag_ref, self.get_source_pos()) + raise ParsingErrorHelper(self).make_malformed_endtag_error( + tag_ref, self.get_source_pos() + ) return tag_ref.i_start def get_starttag_span(self) -> TemplateSpan: @@ -473,48 +655,6 @@ def get_starttag_span(self) -> TemplateSpan: line_pos = self.get_parser_pos() return source.translate_parser_span(line_pos, len(starttag_text)) - def has_ambiguous_forward_slash( - self, - sinfo: OpenTagSourceInfo | TagSourceInfo | None, - attrs: tuple[TAttribute, ...], - ) -> bool: - """ - Detect when an unquoted attribute value consumes a trailing "/" that - *might* have been meant to attempt to self-close a tag, ie. "/>". - - This can come up with literal values or values with interpolations. - - Such as "
" or "<{Component} title=test/>". - - Or more often "<{Component} title={title}/>" which should be corrected - with "<{Component} title={title} />". - """ - source = self.get_source() - reader = source.get_reader() - return ( - # has source info - sinfo is not None - # has attributes - and len(attrs) > 0 - # last attribute ends with "/" - # @NOTE: spread and interpolated attrs never do - and ( - ( - isinstance(attrs[-1], TLiteralAttribute) - and attrs[-1].value is not None - and attrs[-1].value.endswith("/") - ) - or ( - isinstance(attrs[-1], TTemplatedAttribute) - and attrs[-1].value_ref.strings[-1].endswith("/") - ) - ) - # original starttag ends with "/>", - and reader.span_to_template(sinfo.starttag_span).strings[-1].endswith("/>") - # if parsed AS startend already then its not ambiguous - and not sinfo.startend - ) - # ------------------------------------------ # HTMLParser tag callbacks # ------------------------------------------ @@ -537,16 +677,15 @@ def handle_endtag(self, tag: str) -> None: endtag_pos = self.get_source_pos() if not self.stack: source = self.get_source() - reader = source.get_reader() endtag_ref = source.find_placeholders(tag) - endtag_repr = reader.ref_to_repr(endtag_ref) - endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) if endtag_ref.is_literal or endtag_ref.is_singleton: - raise ParsingError( - f"Unexpected closing tag with no open tag, {endtag_pos_msg}." + raise ParsingErrorHelper(self).make_unexpected_endtag_error( + endtag_ref, endtag_pos ) else: - raise self.make_invalid_endtag_error(endtag_ref, endtag_pos) + raise ParsingErrorHelper(self).make_malformed_endtag_error( + endtag_ref, endtag_pos + ) open_tag = self.stack.pop() endtag_i_index = self.validate_end_tag(tag, open_tag) final_tag = self.finalize_tag( @@ -556,34 +695,6 @@ def handle_endtag(self, tag: str) -> None: ) self.append_child(final_tag) - def get_closed_tcomps( - self, root: OpenTag | None, recurse_component_children: bool = False - ) -> list[TComponent]: - """ - Get TComponents that were closed during parsing starting from `root`. - - If `root` is None then use the parser's default `root`. - - TComponents should be returned in the order they were closed in: - from first closed to last closed. - - @NOTE: That the root is an `OpenTag` but its `children` are actually `TNode`s. - """ - if root is None: - root = self.root - tcomps = [] - nodes = list(root.children) - while nodes: - node = nodes.pop() - if isinstance(node, TComponent): - tcomps.append(node) - if recurse_component_children: - children = self.tcomponent_children.get(node, []) - nodes.extend(children) - elif isinstance(node, (TElement, TFragment)): - nodes.extend(node.children) - return tcomps - # ------------------------------------------ # HTMLParser other callbacks # ------------------------------------------ @@ -630,81 +741,7 @@ def reset(self): self.sinfo_table = {} self.tcomponent_children = {} - def run_unclosed_ambiguous_slash_checks( - self, parent: OpenTag, e: ParsingError - ) -> None: - """ - Check for cases where ambiguous slash might create a confusing error. - - @NOTE: This adds exception notes to the exception but does not throw it. - """ - source = self.get_source() - reader = source.get_reader() - if isinstance( - parent, (OpenTElement, OpenTComponent) - ) and self.has_ambiguous_forward_slash(parent.sinfo, parent.attrs): - # CASE: "<{C1} attr={value}/>" -- maybe user meant to self-close? - # CASE: "
" -- mayber user meant to self-close? - starttag_span = parent.sinfo.starttag_span - starttag_repr = reader.span_to_repr(starttag_span) - pos_msg = reader.make_template_pos_msg(parent.source_pos) - e.add_note( - f'Did you mean to quote the last attribute or put a space before "/>" for "{starttag_repr}" at {pos_msg}?' - ) - elif isinstance(parent, OpenTElement): - # ie. t"
", looks - # like we missed a closing
but really we meant to - # self-close the middle div. - children = parent.children[:] - while children: - child = children.pop(0) - if isinstance(child, TElement) and child.tag == parent.tag: - sinfo = ( - self.sinfo_table.get(child.source_pos) - if child.source_pos is not None - else None - ) - if sinfo and self.has_ambiguous_forward_slash(sinfo, child.attrs): - full_starttag_repr = reader.span_to_repr(sinfo.starttag_span) - e.add_note( - f'Did you mean to quote the last attribute or put a space before "/>" for "{full_starttag_repr}"?' - ) - children.extend(child.children) - elif isinstance(parent, OpenTComponent): - # This is a special case where a component accidentally closes - # another component but we don't check the actual values in - # the parser so we can't tell until we are generating an error - # (when we can check the values). - # - # CASE: t"<{C2}><{C1} attr=/>" - # Maybe user meant to self-close <{C1} ...>, but closed by leaving <{C2}...> open? - # CASE: t"<{C3}><{C2}><{C1} attr=/>" - for comp in reversed( - self.get_closed_tcomps(parent, recurse_component_children=True) - ): - if ( - comp.end_i_index is not None - and comp.start_i_index != comp.end_i_index - and not reader.values_match(comp.start_i_index, comp.end_i_index) - ): - starttag_repr = reader.make_interpolation_repr(comp.start_i_index) - endtag_repr = reader.make_interpolation_repr(comp.end_i_index) - e.add_note( - f"Component start tag, <{starttag_repr} ...>, and end tag, , have values that do not match." - ) - sinfo = ( - self.sinfo_table.get(comp.source_pos) - if comp.source_pos is not None - else None - ) - if sinfo and self.has_ambiguous_forward_slash(sinfo, comp.attrs): - full_starttag_repr = reader.span_to_repr(sinfo.starttag_span) - e.add_note( - f'Did you mean to quote the last attribute or put a space before "/>" for "{full_starttag_repr}"?' - ) - def close(self) -> None: - source = self.get_source() if self.waiting_for_data(): # We apply heuristics here to try to guess why the parser didn't finish. if self.rawdata.count('"') % 2 == 1 or self.rawdata.count("'") % 2 == 1: @@ -717,16 +754,11 @@ def close(self) -> None: ) if self.stack: parent = self.stack[-1] - if isinstance(parent, (OpenTElement, OpenTComponent)): - reader = source.get_reader() - starttag_repr = reader.span_to_repr(parent.sinfo.starttag_span) - pos_msg = reader.make_template_pos_msg(parent.source_pos) - unclosed_msg = f"unclosed tag {starttag_repr} at {pos_msg}" - else: - unclosed_msg = "unclosed tags remain" - e = ParsingError(f"Invalid HTML structure: {unclosed_msg}.") - self.run_unclosed_ambiguous_slash_checks(parent, e) - raise e + if not isinstance(parent, (OpenTElement, OpenTComponent)): + raise ParsingAssertionError( + "OpenTFragment or unrecognized OpenTag should not be on the stack." + ) + raise ParsingErrorHelper(self).make_unclosed_starttag_error(parent) if self.source and self.source.has_placeholders(): raise ParsingError("Some placeholders were never resolved.") super().close() From 1b621388156290efc351be782eaaae99604b1c78 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Tue, 25 Aug 2026 23:35:18 -0700 Subject: [PATCH 04/10] Copy over parser attributes so we don't have a complete dependency on the entire parser. --- tdom/parser.py | 243 ++++++++++++++++++++++++++----------------------- 1 file changed, 131 insertions(+), 112 deletions(-) diff --git a/tdom/parser.py b/tdom/parser.py index bf1d5437..047939d2 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -213,7 +213,21 @@ def format_starttag(self, i_index: int) -> str: return self.get_expression(i_index, fallback_prefix="component-starttag") -@dataclass(frozen=True) +def make_error_helper(parser: TemplateParser) -> ParsingErrorHelper: + """ + Factory that creates an error helper for the parser. + + @NOTE: This should only be used when an exception is already being + generated. + """ + return ParsingErrorHelper( + reader=SourceReader(template=parser.get_source().template), + tcomponent_children=parser.tcomponent_children.copy(), + sinfo_table=parser.sinfo_table.copy(), + ) + + +@dataclass() class ParsingErrorHelper: """ Helps the parser include extra info in parsing errors. @@ -226,83 +240,14 @@ class ParsingErrorHelper: - include position info (line #, etc.) """ - parser: TemplateParser - """The parser, used for introspection.""" - - def get_source_reader(self): - return SourceReader(self.parser.get_source().template) + reader: SourceReader + """ Used to read original template fragments and/or interpolation values. """ - def run_unclosed_ambiguous_slash_checks( - self, parent: OpenTag, e: ParsingError - ) -> None: - """ - Check for cases where ambiguous slash might create a confusing error. + tcomponent_children: dict[TComponent, list[TNode]] + """ Preserved tcomponent children, copied from parser. """ - @NOTE: This adds exception notes to the exception but does not throw it. - """ - reader = self.get_source_reader() - if isinstance( - parent, (OpenTElement, OpenTComponent) - ) and self.has_ambiguous_forward_slash(parent.sinfo, parent.attrs): - # CASE: t"<{C1} attr={value}/>" -- maybe user meant to self-close? - # CASE: t"
" -- maybe user meant to self-close? - starttag_span = parent.sinfo.starttag_span - starttag_repr = reader.span_to_repr(starttag_span) - pos_msg = reader.make_template_pos_msg(parent.source_pos) - e.add_note( - f'Did you mean to quote the last attribute or put a space before "/>" for "{starttag_repr}" at {pos_msg}?' - ) - elif isinstance(parent, OpenTElement): - # CASE: t"
" -- maybe user meant to self-close? - # looks like user missed a closing
but they really meant to - # self-close the middle div. - children = parent.children[:] - while children: - child = children.pop(0) - if isinstance(child, TElement) and child.tag == parent.tag: - sinfo = ( - self.parser.sinfo_table.get(child.source_pos) - if child.source_pos is not None - else None - ) - if sinfo and self.has_ambiguous_forward_slash(sinfo, child.attrs): - full_starttag_repr = reader.span_to_repr(sinfo.starttag_span) - e.add_note( - f'Did you mean to quote the last attribute or put a space before "/>" for "{full_starttag_repr}"?' - ) - children.extend(child.children) - elif isinstance(parent, OpenTComponent): - # This is a special case where a component accidentally closes - # another component but we don't check the actual values in - # the parser so we can't tell until we are generating an error - # (when we can check the values). - # - # CASE: t"<{C2}><{C1} attr=/>" - # Maybe user meant to self-close <{C1} ...>, but closed by leaving <{C2}...> open? - # CASE: t"<{C3}><{C2}><{C1} attr=/>" - for comp in reversed( - self.get_closed_tcomps(parent, recurse_component_children=True) - ): - if ( - comp.end_i_index is not None - and comp.start_i_index != comp.end_i_index - and not reader.values_match(comp.start_i_index, comp.end_i_index) - ): - starttag_repr = reader.make_interpolation_repr(comp.start_i_index) - endtag_repr = reader.make_interpolation_repr(comp.end_i_index) - e.add_note( - f"Component start tag, <{starttag_repr} ...>, and end tag, , have values that do not match." - ) - sinfo = ( - self.parser.sinfo_table.get(comp.source_pos) - if comp.source_pos is not None - else None - ) - if sinfo and self.has_ambiguous_forward_slash(sinfo, comp.attrs): - full_starttag_repr = reader.span_to_repr(sinfo.starttag_span) - e.add_note( - f'Did you mean to quote the last attribute or put a space before "/>" for "{full_starttag_repr}"?' - ) + sinfo_table: dict[PartPosition, TagSourceInfo] + """ Source info mapping, copied from parser. """ def make_mismatch_error( self, @@ -311,11 +256,12 @@ def make_mismatch_error( endtag_ref: TemplateRef, endtag_pos: PartPosition, ) -> ParsingError: - reader = self.get_source_reader() - starttag_repr = reader.span_to_repr(starttag_sinfo.starttag_span) - starttag_pos_msg = reader.make_template_pos_msg(starttag_sinfo.starttag_pos) - endtag_repr = reader.ref_to_repr(endtag_ref) - endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) + starttag_repr = self.reader.span_to_repr(starttag_sinfo.starttag_span) + starttag_pos_msg = self.reader.make_template_pos_msg( + starttag_sinfo.starttag_pos + ) + endtag_repr = self.reader.ref_to_repr(endtag_ref) + endtag_pos_msg = self.reader.make_template_pos_msg(endtag_pos) e = ParsingError( f"Mismatched closing tag at {endtag_pos_msg} for {starttag_repr} at {starttag_pos_msg}." ) @@ -328,9 +274,8 @@ def make_mismatch_error( def make_malformed_endtag_error( self, endtag_ref: TemplateRef, endtag_pos: PartPosition ) -> ParsingError: - reader = self.get_source_reader() - endtag_repr = reader.ref_to_repr(endtag_ref) - endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) + endtag_repr = self.reader.ref_to_repr(endtag_ref) + endtag_pos_msg = self.reader.make_template_pos_msg(endtag_pos) return ParsingError( f"Component end tags must have exactly one interpolation, {endtag_repr} at {endtag_pos_msg}." ) @@ -338,28 +283,32 @@ def make_malformed_endtag_error( def make_unexpected_endtag_error( self, endtag_ref: TemplateRef, endtag_pos: PartPosition ) -> ParsingError: - reader = self.get_source_reader() - endtag_repr = reader.ref_to_repr(endtag_ref) - endtag_pos_msg = reader.make_template_pos_msg(endtag_pos) + endtag_repr = self.reader.ref_to_repr(endtag_ref) + endtag_pos_msg = self.reader.make_template_pos_msg(endtag_pos) return ParsingError( f"Unexpected closing tag with no open tag, {endtag_pos_msg}." ) + def make_unclosed_starttag_error(self, parent: OpenTElement | OpenTComponent): + starttag_repr = self.reader.span_to_repr(parent.sinfo.starttag_span) + pos_msg = self.reader.make_template_pos_msg(parent.source_pos) + e = ParsingError( + f"Invalid HTML structure: unclosed tag {starttag_repr} at {pos_msg}." + ) + self.run_unclosed_ambiguous_slash_checks(parent, e) + return e + def get_closed_tcomps( - self, root: OpenTag | None, recurse_component_children: bool = False + self, root: OpenTComponent, recurse_component_children: bool = False ) -> list[TComponent]: """ Get TComponents that were closed during parsing starting from `root`. - If `root` is None then use the parser's default `root`. - TComponents should be returned in the order they were closed in: from first closed to last closed. - @NOTE: That the root is an `OpenTag` but its `children` are actually `TNode`s. + @NOTE: That the root is an `OpenTComponent` but its `children` are actually `TNode`s. """ - if root is None: - root = self.parser.root tcomps = [] nodes = list(root.children) while nodes: @@ -367,7 +316,7 @@ def get_closed_tcomps( if isinstance(node, TComponent): tcomps.append(node) if recurse_component_children: - children = self.parser.tcomponent_children.get(node, []) + children = self.tcomponent_children.get(node, []) nodes.extend(children) elif isinstance(node, (TElement, TFragment)): nodes.extend(node.children) @@ -389,7 +338,6 @@ def has_ambiguous_forward_slash( Or more often "<{Component} title={title}/>" which should be corrected with "<{Component} title={title} />". """ - reader = self.get_source_reader() return ( # has source info sinfo is not None @@ -409,20 +357,91 @@ def has_ambiguous_forward_slash( ) ) # original starttag ends with "/>", - and reader.span_to_template(sinfo.starttag_span).strings[-1].endswith("/>") + and self.reader.span_to_template(sinfo.starttag_span) + .strings[-1] + .endswith("/>") # if parsed AS startend already then its not ambiguous and not sinfo.startend ) - def make_unclosed_starttag_error(self, parent: OpenTElement | OpenTComponent): - reader = self.get_source_reader() - starttag_repr = reader.span_to_repr(parent.sinfo.starttag_span) - pos_msg = reader.make_template_pos_msg(parent.source_pos) - e = ParsingError( - f"Invalid HTML structure: unclosed tag {starttag_repr} at {pos_msg}." - ) - self.run_unclosed_ambiguous_slash_checks(parent, e) - return e + def run_unclosed_ambiguous_slash_checks( + self, parent: OpenTag, e: ParsingError + ) -> None: + """ + Check for cases where ambiguous slash might create a confusing error. + + @NOTE: This adds exception notes to the exception but does not throw it. + """ + if isinstance( + parent, (OpenTElement, OpenTComponent) + ) and self.has_ambiguous_forward_slash(parent.sinfo, parent.attrs): + # CASE: t"<{C1} attr={value}/>" -- maybe user meant to self-close? + # CASE: t"
" -- maybe user meant to self-close? + starttag_span = parent.sinfo.starttag_span + starttag_repr = self.reader.span_to_repr(starttag_span) + pos_msg = self.reader.make_template_pos_msg(parent.source_pos) + e.add_note( + f'Did you mean to quote the last attribute or put a space before "/>" for "{starttag_repr}" at {pos_msg}?' + ) + elif isinstance(parent, OpenTElement): + # CASE: t"
" -- maybe user meant to self-close? + # looks like user missed a closing
but they really meant to + # self-close the middle div. + children = parent.children[:] + while children: + child = children.pop(0) + if isinstance(child, TElement) and child.tag == parent.tag: + sinfo = ( + self.sinfo_table.get(child.source_pos) + if child.source_pos is not None + else None + ) + if sinfo and self.has_ambiguous_forward_slash(sinfo, child.attrs): + full_starttag_repr = self.reader.span_to_repr( + sinfo.starttag_span + ) + e.add_note( + f'Did you mean to quote the last attribute or put a space before "/>" for "{full_starttag_repr}"?' + ) + children.extend(child.children) + elif isinstance(parent, OpenTComponent): + # This is a special case where a component accidentally closes + # another component but we don't check the actual values in + # the parser so we can't tell until we are generating an error + # (when we can check the values). + # + # CASE: t"<{C2}><{C1} attr=/>" + # Maybe user meant to self-close <{C1} ...>, but closed by leaving <{C2}...> open? + # CASE: t"<{C3}><{C2}><{C1} attr=/>" + for comp in reversed( + self.get_closed_tcomps(parent, recurse_component_children=True) + ): + if ( + comp.end_i_index is not None + and comp.start_i_index != comp.end_i_index + and not self.reader.values_match( + comp.start_i_index, comp.end_i_index + ) + ): + starttag_repr = self.reader.make_interpolation_repr( + comp.start_i_index + ) + endtag_repr = self.reader.make_interpolation_repr(comp.end_i_index) + e.add_note( + f"Component start tag, <{starttag_repr} ...>, and end tag, , have values that do not match." + ) + sinfo = ( + self.sinfo_table.get(comp.source_pos) + if comp.source_pos is not None + else None + ) + if sinfo and self.has_ambiguous_forward_slash(sinfo, comp.attrs): + full_starttag_repr = self.reader.span_to_repr( + sinfo.starttag_span + ) + e.add_note( + f'Did you mean to quote the last attribute or put a space before "/>" for "{full_starttag_repr}"?' + ) class TemplateParser(HTMLParser): @@ -622,11 +641,11 @@ def validate_end_tag(self, tag: str, open_tag: OpenTag) -> int | None: match open_tag: case OpenTElement(): if tag_ref.is_singleton or (tag_ref.is_literal and tag != open_tag.tag): - raise ParsingErrorHelper(self).make_mismatch_error( + raise make_error_helper(self).make_mismatch_error( open_tag.sinfo, open_tag.attrs, tag_ref, self.get_source_pos() ) elif not tag_ref.is_singleton and not tag_ref.is_literal: - raise ParsingErrorHelper(self).make_malformed_endtag_error( + raise make_error_helper(self).make_malformed_endtag_error( tag_ref, self.get_source_pos() ) return None @@ -634,11 +653,11 @@ def validate_end_tag(self, tag: str, open_tag: OpenTag) -> int | None: raise ParsingAssertionError("We do not support anonymous fragments.") case OpenTComponent(): if tag_ref.is_literal: - raise ParsingErrorHelper(self).make_mismatch_error( + raise make_error_helper(self).make_mismatch_error( open_tag.sinfo, open_tag.attrs, tag_ref, self.get_source_pos() ) if not tag_ref.is_singleton: - raise ParsingErrorHelper(self).make_malformed_endtag_error( + raise make_error_helper(self).make_malformed_endtag_error( tag_ref, self.get_source_pos() ) return tag_ref.i_start @@ -679,11 +698,11 @@ def handle_endtag(self, tag: str) -> None: source = self.get_source() endtag_ref = source.find_placeholders(tag) if endtag_ref.is_literal or endtag_ref.is_singleton: - raise ParsingErrorHelper(self).make_unexpected_endtag_error( + raise make_error_helper(self).make_unexpected_endtag_error( endtag_ref, endtag_pos ) else: - raise ParsingErrorHelper(self).make_malformed_endtag_error( + raise make_error_helper(self).make_malformed_endtag_error( endtag_ref, endtag_pos ) open_tag = self.stack.pop() @@ -758,7 +777,7 @@ def close(self) -> None: raise ParsingAssertionError( "OpenTFragment or unrecognized OpenTag should not be on the stack." ) - raise ParsingErrorHelper(self).make_unclosed_starttag_error(parent) + raise make_error_helper(self).make_unclosed_starttag_error(parent) if self.source and self.source.has_placeholders(): raise ParsingError("Some placeholders were never resolved.") super().close() From 6e1d4a6fd41db6ef8e269df1355aad573152b237 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 6 Sep 2026 18:41:43 -0700 Subject: [PATCH 05/10] Simplify has_ambiguous_forward_slash. --- tdom/parser.py | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/tdom/parser.py b/tdom/parser.py index 15f62da4..c2e82d43 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -265,7 +265,7 @@ def make_mismatch_error( e = ParsingError( f"Mismatched closing tag at {endtag_pos_msg} for {starttag_repr} at {starttag_pos_msg}." ) - if self.has_ambiguous_forward_slash(starttag_sinfo, starttag_attrs): + if self.has_ambiguous_forward_slash(starttag_sinfo): e.add_note( f'Did you mean to quote the last attribute or put a space before "/>" for "{starttag_repr}" at {starttag_pos_msg}?' ) @@ -325,7 +325,6 @@ def get_closed_tcomps( def has_ambiguous_forward_slash( self, sinfo: OpenTagSourceInfo | TagSourceInfo | None, - attrs: tuple[TAttribute, ...], ) -> bool: """ Detect when an unquoted attribute value consumes a trailing "/" that @@ -339,29 +338,11 @@ def has_ambiguous_forward_slash( with "<{Component} title={title} />". """ return ( - # has source info sinfo is not None - # has attributes - and len(attrs) > 0 - # last attribute ends with "/" - # @NOTE: spread and interpolated attrs never do - and ( - ( - isinstance(attrs[-1], TLiteralAttribute) - and attrs[-1].value is not None - and attrs[-1].value.endswith("/") - ) - or ( - isinstance(attrs[-1], TTemplatedAttribute) - and attrs[-1].value_ref.strings[-1].endswith("/") - ) - ) - # original starttag ends with "/>", and self.reader.span_to_template(sinfo.starttag_span) .strings[-1] - .endswith("/>") - # if parsed AS startend already then its not ambiguous - and not sinfo.startend + .endswith("/>") # ends with trailing slash + and not sinfo.startend # but was not parsed as startend ) def run_unclosed_ambiguous_slash_checks( @@ -374,7 +355,7 @@ def run_unclosed_ambiguous_slash_checks( """ if isinstance( parent, (OpenTElement, OpenTComponent) - ) and self.has_ambiguous_forward_slash(parent.sinfo, parent.attrs): + ) and self.has_ambiguous_forward_slash(parent.sinfo): # CASE: t"<{C1} attr={value}/>" -- maybe user meant to self-close? # CASE: t"
" -- maybe user meant to self-close? starttag_span = parent.sinfo.starttag_span @@ -396,7 +377,7 @@ def run_unclosed_ambiguous_slash_checks( if child.source_pos is not None else None ) - if sinfo and self.has_ambiguous_forward_slash(sinfo, child.attrs): + if sinfo and self.has_ambiguous_forward_slash(sinfo): full_starttag_repr = self.reader.span_to_repr( sinfo.starttag_span ) @@ -435,7 +416,7 @@ def run_unclosed_ambiguous_slash_checks( if comp.source_pos is not None else None ) - if sinfo and self.has_ambiguous_forward_slash(sinfo, comp.attrs): + if sinfo and self.has_ambiguous_forward_slash(sinfo): full_starttag_repr = self.reader.span_to_repr( sinfo.starttag_span ) From 47ff96384764bffac21d9b862cf9af6248534b4b Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 6 Sep 2026 19:40:16 -0700 Subject: [PATCH 06/10] Finally remove nested fragments from parser. --- tdom/parser.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/tdom/parser.py b/tdom/parser.py index c2e82d43..a5ca907f 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -106,7 +106,7 @@ class OpenTComponent: children: list[TNode] = field(default_factory=list) -type OpenTag = OpenTElement | OpenTFragment | OpenTComponent +type OpenTag = OpenTElement | OpenTComponent def configure_source_tracker( @@ -318,7 +318,7 @@ def get_closed_tcomps( if recurse_component_children: children = self.tcomponent_children.get(node, []) nodes.extend(children) - elif isinstance(node, (TElement, TFragment)): + elif isinstance(node, TElement): nodes.extend(node.children) return tcomps @@ -444,7 +444,7 @@ def __init__(self, *, convert_charrefs: bool = True): # Parse state helpers # ------------------------------------------ - def get_parent(self) -> OpenTag: + def get_parent(self) -> OpenTag | OpenTFragment: """Return the current parent node to which new children should be added.""" return self.stack[-1] if self.stack else self.root @@ -585,8 +585,6 @@ def finalize_tag( children=tuple(children), source_pos=source_pos, ) - case OpenTFragment(children=children, source_pos=source_pos): - return TFragment(children=tuple(children), source_pos=source_pos) case OpenTComponent( start_i_index=start_i_index, children_start=children_start, @@ -630,8 +628,6 @@ def validate_end_tag(self, tag: str, open_tag: OpenTag) -> int | None: tag_ref, self.get_source_pos() ) return None - case OpenTFragment(): - raise ParsingAssertionError("We do not support anonymous fragments.") case OpenTComponent(): if tag_ref.is_literal: raise make_error_helper(self).make_mismatch_error( @@ -744,12 +740,7 @@ def reset(self): def close(self) -> None: super().close() if self.stack: - parent = self.stack[-1] - if not isinstance(parent, (OpenTElement, OpenTComponent)): - raise ParsingAssertionError( - "OpenTFragment or unrecognized OpenTag should not be on the stack." - ) - raise make_error_helper(self).make_unclosed_starttag_error(parent) + raise make_error_helper(self).make_unclosed_starttag_error(self.stack[-1]) if self.source and self.source.has_placeholders(): raise ParsingError("Some placeholders were never resolved.") @@ -759,11 +750,10 @@ def close(self) -> None: def get_tnode(self) -> TNode: """Get the Node tree parsed from the input HTML.""" - # TODO: consider always returning a TTag? if len(self.root.children) > 1: # The parse structure results in multiple root elements, so we # return a Fragment to hold them all. - return self.finalize_tag(self.root) + return TFragment(children=tuple(self.root.children)) elif len(self.root.children) == 1: # The parse structure results in a single root element, so we # return that element directly. This will be a non-Fragment Node. @@ -772,7 +762,7 @@ def get_tnode(self) -> TNode: # Special case: the parse structure is empty; we treat # this as an empty document fragment. # CONSIDER: or as an empty text node? - return self.finalize_tag(self.root) + return TFragment(children=()) def get_ttree(self) -> TTree: return TTree( From 589e3409e86cafc83d95750d5ee56b6ff4bf6639 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 6 Sep 2026 19:41:22 -0700 Subject: [PATCH 07/10] Fragment cannot have source position because it conflicts in lookup table and could be inferred. --- tdom/parser.py | 1 - tdom/tnodes.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/tdom/parser.py b/tdom/parser.py index a5ca907f..ae94c64a 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -87,7 +87,6 @@ class OpenTElement: @dataclass class OpenTFragment: - source_pos: PartPosition | None = None children: list[TNode] = field(default_factory=list) diff --git a/tdom/tnodes.py b/tdom/tnodes.py index 2887e662..0db92830 100644 --- a/tdom/tnodes.py +++ b/tdom/tnodes.py @@ -78,8 +78,6 @@ class TDocumentType(TNode): class TFragment(TNode): children: tuple[TNode, ...] = field(default_factory=tuple) - source_pos: PartPosition | None = field(default=None, compare=False) - @dataclass(slots=True, frozen=True) class TElement(TNode): From baf3c74f2fe1e602d87ba84f6e758cb4d33b3ad2 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 6 Sep 2026 19:42:18 -0700 Subject: [PATCH 08/10] Remove old unused alias we long ago considered for parser return value. --- tdom/tnodes.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tdom/tnodes.py b/tdom/tnodes.py index 0db92830..0b14ade4 100644 --- a/tdom/tnodes.py +++ b/tdom/tnodes.py @@ -134,6 +134,3 @@ class TTree: def unpack_sinfo_table(self) -> dict[PartPosition, TagSourceInfo]: return {sinfo.starttag_pos: sinfo for sinfo in self.sinfos} - - -type TTag = TElement | TComponent | TFragment From 756456394867e64311f40e9c0ed5fb0e7134b8d9 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 6 Sep 2026 21:37:40 -0700 Subject: [PATCH 09/10] Simplify because we always recurse. --- tdom/parser.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tdom/parser.py b/tdom/parser.py index ae94c64a..402e99ed 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -297,9 +297,7 @@ def make_unclosed_starttag_error(self, parent: OpenTElement | OpenTComponent): self.run_unclosed_ambiguous_slash_checks(parent, e) return e - def get_closed_tcomps( - self, root: OpenTComponent, recurse_component_children: bool = False - ) -> list[TComponent]: + def get_closed_tcomps(self, root: OpenTComponent) -> list[TComponent]: """ Get TComponents that were closed during parsing starting from `root`. @@ -314,9 +312,8 @@ def get_closed_tcomps( node = nodes.pop() if isinstance(node, TComponent): tcomps.append(node) - if recurse_component_children: - children = self.tcomponent_children.get(node, []) - nodes.extend(children) + children = self.tcomponent_children.get(node, []) + nodes.extend(children) elif isinstance(node, TElement): nodes.extend(node.children) return tcomps @@ -393,9 +390,7 @@ def run_unclosed_ambiguous_slash_checks( # CASE: t"<{C2}><{C1} attr=/>" # Maybe user meant to self-close <{C1} ...>, but closed by leaving <{C2}...> open? # CASE: t"<{C3}><{C2}><{C1} attr=/>" - for comp in reversed( - self.get_closed_tcomps(parent, recurse_component_children=True) - ): + for comp in reversed(self.get_closed_tcomps(parent)): if ( comp.end_i_index is not None and comp.start_i_index != comp.end_i_index From 4a3b246944043175aaea238be68adde5425a3271 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 6 Sep 2026 22:02:25 -0700 Subject: [PATCH 10/10] We can remove this as long as we guard before using an sinfo for a TNode. --- tdom/parser.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tdom/parser.py b/tdom/parser.py index 402e99ed..f8417ca2 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -319,8 +319,7 @@ def get_closed_tcomps(self, root: OpenTComponent) -> list[TComponent]: return tcomps def has_ambiguous_forward_slash( - self, - sinfo: OpenTagSourceInfo | TagSourceInfo | None, + self, sinfo: OpenTagSourceInfo | TagSourceInfo ) -> bool: """ Detect when an unquoted attribute value consumes a trailing "/" that @@ -334,8 +333,7 @@ def has_ambiguous_forward_slash( with "<{Component} title={title} />". """ return ( - sinfo is not None - and self.reader.span_to_template(sinfo.starttag_span) + self.reader.span_to_template(sinfo.starttag_span) .strings[-1] .endswith("/>") # ends with trailing slash and not sinfo.startend # but was not parsed as startend