Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6e295f9
Add simple copy method to allow simulating removing placeholders.
ianjosephwilson Jun 11, 2026
4077f3e
Typeguard against bogus empty starttag_text.
ianjosephwilson Jun 11, 2026
b95eaa8
Add debugging/introspection info to open tags.
ianjosephwilson Jun 11, 2026
9084150
Improve unclosed tags message for ambiguous slash case.
ianjosephwilson Jun 11, 2026
8b226fc
Fix method defn order.
ianjosephwilson Jun 12, 2026
f1b5739
Always fallback to tag str for error, fixes typecheck.
ianjosephwilson Jun 12, 2026
922c2bf
Refine error messages for other cases with trailing slash is consumed…
ianjosephwilson Jun 12, 2026
2b03b4e
Use getter directly but still guard against None.
ianjosephwilson Jun 12, 2026
a10f209
Restrict self-close suggestion to components.
ianjosephwilson Jun 12, 2026
e21b9bd
Restore component start/tag mismatch test.
ianjosephwilson Jun 15, 2026
75df60e
Add tmap and format_endtag for error reporting.
ianjosephwilson Jun 15, 2026
60095db
Be more surgical about catching nested component error.
ianjosephwilson Jun 15, 2026
323cafc
Clump parse info into class.
ianjosephwilson Jun 15, 2026
d8f98f8
Cut this out for now.
ianjosephwilson Jun 15, 2026
e3b71a7
Fold parse info tracking into source tracker.
ianjosephwilson Jun 16, 2026
4e3def3
Backport more consistent parser position and source info tracking.
ianjosephwilson Jun 24, 2026
e9a58d9
Formatting.
ianjosephwilson Jun 24, 2026
28e34e0
Actually store positions on other elements.
ianjosephwilson Jun 24, 2026
5dcc189
Test that the parser position is actually being set on the nodes.
ianjosephwilson Jun 24, 2026
0e0a842
Fold loop into test as tempfix for type issues around parser_pos.
ianjosephwilson Jun 24, 2026
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
140 changes: 125 additions & 15 deletions tdom/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@

@dataclass
class OpenTElement:
starttag_text: str
Comment thread
ianjosephwilson marked this conversation as resolved.
" Entire starttag as parsed, includes placeholders, used for debugging. "
raw_attrs: Sequence[HTMLAttribute]
" Attrs as parsed, includes placeholders, used for debugging. "
startend: bool
" Was parsed as startend tag, ie. <tag />, used for debugging. "
tag: str
attrs: tuple[TAttribute, ...]
children: list[TNode] = field(default_factory=list)
Expand All @@ -39,6 +45,12 @@ class OpenTFragment:

@dataclass
class OpenTComponent:
starttag_text: str
" Entire starttag as parsed, includes placeholders, used for debugging. "
raw_attrs: Sequence[HTMLAttribute]
" Attrs as parsed, includes placeholders, used for debugging. "
startend: bool
" Was parsed as startend tag, ie. <tag />, used for debugging. "
start_i_index: int
children_start_s_index: int
"""The strings index where the component's children template starts."""
Expand Down Expand Up @@ -72,6 +84,26 @@ class SourceTracker:
def interpolations(self) -> tuple[Interpolation, ...]:
return self.template.interpolations

def _check_indices(self, index1: int, index2: int):
last_index = len(self.interpolations) - 1
if max(index1, index2) > last_index or min(index1, index2) < 0:
raise ValueError(
f"Interpolation indices exceed bounds: {index1} {index2}: [0...{last_index}]"
)

def expressions_match(self, i_index1: int, i_index2: int) -> bool:
self._check_indices(i_index1, i_index2)
return (
self.interpolations[i_index1].expression
== self.interpolations[i_index2].expression
)

def values_match(self, i_index1: int, i_index2: int) -> bool:
self._check_indices(i_index1, i_index2)
return (
self.interpolations[i_index1].value == self.interpolations[i_index2].value
)

def advance_interpolation(self) -> int:
"""Call before processing an interpolation to move to the next one."""
self.i_index += 1
Expand Down Expand Up @@ -159,12 +191,20 @@ def make_tattrs(self, attrs: Sequence[HTMLAttribute]) -> tuple[TAttribute, ...]:
# Tag Helpers
# ------------------------------------------

def make_open_tag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> OpenTag:
def make_open_tag(
self, tag: str, attrs: Sequence[HTMLAttribute], startend: bool = False
) -> OpenTag:
"""Build an OpenTag from a raw tag and attribute tuples."""
tag_ref = self.placeholders.remove_placeholders(tag)

if tag_ref.is_literal:
return OpenTElement(tag=tag, attrs=self.make_tattrs(attrs))
return OpenTElement(
starttag_text=self.get_starttag_text(),
raw_attrs=attrs,
startend=startend,
tag=tag,
attrs=self.make_tattrs(attrs),
)

if not tag_ref.is_singleton:
raise ValueError(
Expand All @@ -189,11 +229,9 @@ def make_open_tag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> OpenTag:
# @NOTE: This must be called when the tag is handled since it is
# populated based on the most recently finished start tag. Otherwise
# the value will be out of sync.
starttag_text = self.get_starttag_text()
if starttag_text is None:
raise AssertionError(
f"Expected startag_text to be set when parsing component at {i_index}."
)
starttag_text = self.get_starttag_text(
f"Expected startag_text to be set when parsing component at {i_index}."
)

tattrs = self.make_tattrs(attrs)

Expand All @@ -205,6 +243,9 @@ def make_open_tag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> OpenTag:
)

return OpenTComponent(
starttag_text=starttag_text,
raw_attrs=attrs,
startend=startend,
start_i_index=i_index,
children_start_s_index=children_start_s_index,
offset_into_children_start_s=offset_into_children_start_s,
Expand Down Expand Up @@ -339,7 +380,7 @@ def extract_component_children_ref(

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."""
assert self.source, "Parser source tracker not initialized."
source = self.get_source()
tag_ref = self.placeholders.remove_placeholders(tag)

match open_tag:
Expand All @@ -359,18 +400,73 @@ def validate_end_tag(self, tag: str, open_tag: OpenTag) -> int | None:

case OpenTComponent(start_i_index=start_i_index):
if tag_ref.is_literal:
raise ValueError(
f"Mismatched closing tag </{tag}> for component starting at {self.source.format_starttag(start_i_index)}."
starttag = source.format_starttag(start_i_index)
e = ValueError(
f"Mismatched closing tag </{tag}> for component with tag {{{starttag}}}."
)
if self.has_ambiguous_forward_slash(open_tag):
e.add_note(
f'Did you mean to quote the last attribute or put a space before "/>" for "<{{{starttag}}} .../>"?'
)
raise e
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.
if not source.expressions_match(

@davepeck davepeck Jun 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...but there's a catch, which is that "cache only on .strings" is problematic now that we're looking at interpolation values in the parser.

def C1():
    pass

def C2():
    pass

proxy = CachedTemplateParserProxy()
proxy.to_tnode(t"<{C1}></{C1}>")  # works as expected
proxy.to_tnode(t"<{C1}></{C2}>")  # doesn't raise because we hit the cache

I'm not sure exactly how to think about this. I know some check is necessary to handle your t"<{C2}><{C1} attr=value/></{C2}></{C2}>" case. But I'm reluctant to change our caching story.

I wonder if we could compute has_ambiguous_slash in the parser and stash it on the TComponent, leaving it up to the processor to surface a good error message.

@davepeck davepeck Jun 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Random aside: is the expression check before the value check a perf thing? In the common case, it seems good. But I suppose we can craft identical expressions that may not be equivalent, like t"<{(lambda: 0)}></{(lambda: 0)}>")

@ianjosephwilson ianjosephwilson Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess, in this case, I am ok NOT involving this in the caching story. I think it is okay to say that the open tag value and the closing tag value for component tags need to have the same value if you want it parsed correctly ( or always?). After the "first" time whenever that is as there might be a startup stampede then we'll just assume you passed in the right values otherwise the behavior is "unpredictable". The only part I don't like is digging in the interpolation values "in general". I think if for example you wanted to pre-parse the templates then you could pass dummy values that follow that rule and it should still work. Otherwise we end up with essentially this situation t'<{C1}><{C2}></*><{C3}></*></*>'. I feel like confusing error messages would be worse but this also seems like something we could potentially remove later if it was a problem or even make configurable?

An alternative which might be possible is that when the inevitable failure occurs then we backtrack and try to rerun through the tags and decipher which component was incorrectly closed with a different component for whatever reason, the common case probably being this: t'<{C1}><{C2} title=/></{C1}>', where </{C1}> closes <{C2} title=/> because the / was consumed as part of title. Then the interpolation introspection only occurs during errors. We could try to make that work?

open_tag.start_i_index, tag_ref.i_indexes[0]
) and not source.values_match(
open_tag.start_i_index, tag_ref.i_indexes[0]
):
e = TypeError(
"Component start and end tags must contain the same callable."
)
if self.has_ambiguous_forward_slash(open_tag):
starttag = source.format_starttag(start_i_index)
e.add_note(
f'Did you mean to quote the last attribute or put a space before "/>" for "<{{{starttag}}} .../>"?'
)
raise e
return tag_ref.i_indexes[0]

def get_starttag_text(self, msg: str = "Expecting starttag text to be set.") -> str:
"""
Wrap get_starttag_text and just raise if None is returned.

Do this so we don't guard for `None` everywhere.
"""
starttag_text = super().get_starttag_text()
if starttag_text is None:
raise AssertionError(msg)
return starttag_text

def has_ambiguous_forward_slash(self, open_tag: OpenTag) -> 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 "<div title=test/>" or "<{Component} title=test/>".

Or more often "<{Component} title={title}/>" which should be corrected
with "<{Component} title={title} />".
"""
if isinstance(open_tag, (OpenTElement, OpenTComponent)):
return (
# has attributes
len(open_tag.raw_attrs) > 0
# last attr not bare attribute
and open_tag.raw_attrs[-1][1] is not None
# last char of last attr is "/"
and open_tag.raw_attrs[-1][1][-1] == "/"
# parsed starttag ends with "/>"
and open_tag.starttag_text.endswith("/>")
# if parsed as startend then its not ambiguous
and not open_tag.startend
)
return False

# ------------------------------------------
# HTMLParser tag callbacks
# ------------------------------------------
Expand All @@ -385,7 +481,7 @@ def handle_starttag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> None:

def handle_startendtag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> None:
"""Dispatch a self-closing tag, `<tag />` to specialized handlers."""
open_tag = self.make_open_tag(tag, attrs)
open_tag = self.make_open_tag(tag, attrs, startend=True)
final_tag = self.finalize_tag(open_tag)
self.append_child(final_tag)

Expand Down Expand Up @@ -449,7 +545,21 @@ def close(self) -> None:
"Parser expects more data, is the template valid html?"
)
if self.stack:
raise ValueError("Invalid HTML structure: unclosed tags remain.")
e = ValueError("Invalid HTML structure: unclosed tags remain.")
# Check for tags that might have meant to self-close but whose
# unquoted last attribute value consumed a "/", ie. <div id=app/>.
parent = self.stack[-1]
# @TODO: We need to determine which tags this might apply to, this only applies to components.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this PR sequences before the work that would answer this. But it does feel like where we land is: anything self closable (components here; in a future PR, void tags; even further, foreign tags once the parser becomes smart about such things) gets one message; everyone else gets the other.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think actually void tags wouldn't get this message because they don't go on the stack but yes we'd probably add foreign tags.

Just to clarify: This error is happening regardless of the slash but we try to make "Did you mean ..." help message for a potentially hard to debug case but the "unclosed tags remain" is always happening. They could have meant to use the slash as an attribute value but ... just forgot to close the component (or foreign tag). Ie. t'<{Comp1} path=/about/>' instead of t'<{Comp1} path="/about/" />'

if isinstance(parent, OpenTComponent) and self.has_ambiguous_forward_slash(
parent
):
starttag = (
f"{{{self.get_source().format_starttag(parent.start_i_index)}}}"
)
e.add_note(
f'Did you mean to quote the last attribute or put a space before "/>" for "<{starttag} .../>"?'
)
raise e

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davepeck This block now performs the component start/end values check but where an exception is already going to happen (we closed the parser but tags are still open on the stack). So the parsing would have already failed. I think that should still keep our cache solely dependent on the shape because the values check does not affect what succeeds it is only used to provided better error reporting. Does that make sense?

if not self.placeholders.is_empty:
raise ValueError("Some placeholders were never resolved.")
super().close()
Expand Down
40 changes: 37 additions & 3 deletions tdom/parser_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,15 +425,17 @@ def Component():
assert node == TComponent(start_i_index=0, end_i_index=1)


def test_component_element_special_case_mismatched_closing_tag_still_parses():
def test_component_element_special_case_mismatched_closing_tag_error():
def Component1():
pass

def Component2():
pass

node = TemplateParser.parse(t"<{Component1}></{Component2}>")
assert node == TComponent(start_i_index=0, end_i_index=1)
with pytest.raises(
TypeError, match="Component start and end tags must contain the same callable."
):
_ = TemplateParser.parse(t"<{Component1}></{Component2}>")

@davepeck davepeck Jun 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love that we're revisiting this...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha yeah, clearly it is one of the project's hard to scratch itches. Anytime a large involved change comes along ... so does it.



def test_component_element_invalid_closing_tag():
Expand Down Expand Up @@ -602,3 +604,35 @@ def test_extract_with_templated_attr_gt_char(self, Component):
strings=("<div>Hello, World!</div>",), i_indexes=()
),
)


class TestComponentUnquotedAttrValue:
@pytest.fixture
def Comp(self):
def _Comp(children: Template, title: str) -> Template:
return children

return _Comp

@pytest.fixture
def Comp2(self):
def _Comp2(children: Template, title: str) -> Template:
return children

return _Comp2

def test_comp_unquoted_attr_value_error_root(self, Comp):
with pytest.raises(
ValueError, match="Did you mean to quote the last attribute"
):
_ = TemplateParser.parse(t"<{Comp} title=today/>")

def test_comp_unquoted_attr_value_error_nested_in_el(self, Comp):
with pytest.raises(
ValueError, match="Did you mean to quote the last attribute"
):
_ = TemplateParser.parse(t"<div><{Comp} title=today/></div>")

def test_comp_unquoted_attr_value_error_nested_in_comp(self, Comp, Comp2):
with pytest.raises(TypeError, match="Did you mean to quote the last attribute"):
_ = TemplateParser.parse(t"<{Comp2}><{Comp} title=today/></{Comp2}>")
3 changes: 3 additions & 0 deletions tdom/placeholders.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ class PlaceholderState:
config: PlaceholderConfig = field(default_factory=make_placeholder_config)
"""Collection of currently 'known and active' placeholder indexes."""

def copy(self):
Comment thread
ianjosephwilson marked this conversation as resolved.
return PlaceholderState(known=self.known.copy(), config=self.config)

@property
def is_empty(self) -> bool:
return len(self.known) == 0
Expand Down
Loading