diff --git a/tdom/parser.py b/tdom/parser.py index 2eec36d8..57475b36 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -1,12 +1,24 @@ from collections.abc import Sequence from dataclasses import dataclass, field from html.parser import HTMLParser -from string.templatelib import Interpolation, Template +from string.templatelib import Template from .htmlspec import VOID_ELEMENTS -from .placeholders import PlaceholderConfig, PlaceholderState -from .template_utils import TemplateRef, combine_template_refs +from .parser_utils import ( + HTMLAttribute, + ParserPositionTranslator, + make_parser_pos_translator, +) +from .placeholders import ( + PlaceholderState, +) +from .source import ( + LinePosition, + SourceReader, +) +from .template_utils import PartPosition, TemplateRef, combine_template_refs from .tnodes import ( + TagSourceInfo, TAttribute, TComment, TComponent, @@ -19,21 +31,59 @@ TSpreadAttribute, TTemplatedAttribute, TText, + TTree, ) -type HTMLAttribute = tuple[str, str | None] -type HTMLAttributesDict = dict[str, str | None] + +class ParsingError(Exception): + pass + + +class ParsingAssertionError(ParsingError): + pass + + +class AttributeParsingError(ParsingError): + pass + + +@dataclass(frozen=True, slots=True) +class OpenTagSourceInfo: + """ + Retained tag information from the parsed source meant for error reporting. + + @NOTE: This is an temporary structure that will be finalized when the + tag is closed. + """ + + starttag_ref: TemplateRef + " Entire starttag as parsed except placeholders are replaced by references. " + startend: bool + " Was parsed as startend tag, ie. . " + starttag_pos: PartPosition + " Template part position of the starttag. " + + def close(self, endtag_pos: PartPosition | None = None) -> TagSourceInfo: + return TagSourceInfo( + starttag_ref=self.starttag_ref, + startend=self.startend, + starttag_pos=self.starttag_pos, + endtag_pos=endtag_pos, + ) @dataclass class OpenTElement: tag: str attrs: tuple[TAttribute, ...] + source_pos: PartPosition + sinfo: OpenTagSourceInfo children: list[TNode] = field(default_factory=list) @dataclass class OpenTFragment: + source_pos: PartPosition | None = None children: list[TNode] = field(default_factory=list) @@ -45,10 +95,12 @@ class OpenTComponent: offset_into_children_start_s: int """The offset INTO the starting string where the component's children template starts.""" attrs: tuple[TAttribute, ...] + source_pos: PartPosition + sinfo: OpenTagSourceInfo # @NOTE: The `children` are discarded after parsing and are just used to - # track template consistency. If the component is processed and - # returns its children template then that template will be - # re-parsed (or pulled from the cache). + # track template consistency or assist with error reporting. If the + # component is processed and returns its children template then that + # template will be re-parsed (or pulled from the cache). children: list[TNode] = field(default_factory=list) @@ -63,44 +115,67 @@ class SourceTracker: # template itself in context and the relevant line/column underlined/etc. template: Template - # if i_index >= s_index, feeding an interpolation; - # otherwise, when i_index < s_index, feeding a string. - i_index: int = -1 # The current interpolation index. - s_index: int = -1 # The current string index. - - @property - def interpolations(self) -> tuple[Interpolation, ...]: - return self.template.interpolations - - def advance_interpolation(self) -> int: - """Call before processing an interpolation to move to the next one.""" - self.i_index += 1 - return self.i_index - - def advance_string(self) -> int: - self.s_index += 1 - return self.s_index - - def get_expression( - self, i_index: int, fallback_prefix: str = "interpolation" - ) -> str: + + placeholders: PlaceholderState = field(default_factory=lambda: PlaceholderState()) + + index: int = -1 + + def __iter__(self): + # + # @NOTE: This iterator is only meant to be used once since we track + # placeholders both by adding them and letting the user remove them + # with calls to `remove_placeholders()`. + return self + + def __next__(self): + if self.index < 2 * len(self.template.strings) - 2: + self.index += 1 + if self.index % 2 == 0: + return self.template.strings[self.index // 2] + else: + return self.placeholders.add_placeholder((self.index - 1) // 2) + else: + raise StopIteration + + def get_reader(self) -> SourceReader: + return SourceReader(template=self.template) + + def remove_placeholders(self, text: str) -> TemplateRef: """ - Resolve an interpolation index to its original expression for error messages. - Falls back to a synthetic expression if the original is empty. + Find tracked placeholders in text and mark them as found. + + @NOTE: Raises if any untracked placeholders are found. + + If you want to make a TemplateRef without changing state use + `self.find_placeholders()`. """ - ip = self.interpolations[i_index] - return ip.expression if ip.expression else f"{{{fallback_prefix}-{i_index}}}" + return self.placeholders.remove_placeholders(text) - def format_starttag(self, i_index: int) -> str: - """Format a component start tag for error messages.""" - return self.get_expression(i_index, fallback_prefix="component-starttag") + def find_placeholders(self, text: str) -> TemplateRef: + """ + Find all placeholders without affecting tracking. + """ + return self.placeholders.config.find_placeholders(text) class TemplateParser(HTMLParser): root: OpenTFragment + "Fallback container of parsed nodes if no other topmost container is found." + stack: list[OpenTag] - placeholders: PlaceholderState + "Stack of tags left open during parsing." + source: SourceTracker | None + "Source iterator of template parts, injecting placeholders as needed." + + parser_pos_translator: ParserPositionTranslator | None + "Translator from parser position to template part position. " + + 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. " def __init__(self, *, convert_charrefs: bool = True): # This calls HTMLParser.reset() which we override to set up our state. @@ -118,6 +193,24 @@ def append_child(self, child: TNode) -> None: parent = self.get_parent() parent.children.append(child) + def get_parser_pos(self) -> LinePosition: + """ + Get the current position of the parser. + + @NOTE: This position is relative to text embedded with placeholders but + can be translated back to the position within the original template. + Since it *IS* relative to placeholders, ie. "SLOTS", this position is + unique across a "family" of templates with the same structure. + """ + line, offset = self.getpos() + return LinePosition(line=line, offset=offset) + + def get_source_pos(self, parser_pos: LinePosition | None = None) -> PartPosition: + "Translate the parser position into a part position in the source template." + return self.get_parser_pos_translator().translate( + self.get_parser_pos() if parser_pos is None else parser_pos + ) + # ------------------------------------------ # Attribute Helpers # ------------------------------------------ @@ -126,10 +219,12 @@ def make_tattr(self, attr: HTMLAttribute) -> TAttribute: """Build a TAttribute from a raw attribute tuple.""" name, value = attr - - name_ref = self.placeholders.remove_placeholders(name) + source = self.get_source() + name_ref = source.placeholders.remove_placeholders(name) value_ref = ( - self.placeholders.remove_placeholders(value) if value is not None else None + source.placeholders.remove_placeholders(value) + if value is not None + else None ) if name_ref.is_literal: @@ -142,11 +237,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_indexes[0]) @@ -159,15 +254,28 @@ 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) - + source = self.get_source() + tag_ref = source.placeholders.remove_placeholders(tag) if tag_ref.is_literal: - return OpenTElement(tag=tag, attrs=self.make_tattrs(attrs)) + source_pos = self.get_source_pos() + open_tag = OpenTElement( + tag=tag, + attrs=self.make_tattrs(attrs), + sinfo=OpenTagSourceInfo( + starttag_ref=self.get_starttag_ref(), + startend=startend, + starttag_pos=source_pos, + ), + source_pos=source_pos, + ) + return open_tag if not tag_ref.is_singleton: - raise ValueError( + raise ParsingError( "Component element tags must have exactly one interpolation." ) @@ -176,115 +284,95 @@ def make_open_tag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> OpenTag: # relying on higher layers to validate types and render correctly. i_index = tag_ref.i_indexes[0] - # @NOTE: This must be stored when the tag is handled since it is - # set based on when the template parts are fed in and otherwise - # might be out of sync. + # @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_ref = self.get_starttag_ref() # The starting s_index of the component's children template. Note that # this string either contains ">" or " />". It might not be # i_index + 1 because attributes WITHIN the component's tag might # contain interpolations causing the i_index (and s_index) to advance # arbitrarily. - children_start_s_index = self.get_source().s_index - - # @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}." - ) - - tattrs = self.make_tattrs(attrs) - - offset_into_children_start_s = self.compute_offset_into_children_start_s( - start_i_index=i_index, - tattrs=tattrs, - config=self.placeholders.config, - starttag_text=starttag_text, + children_start_s_index = ( + i_index # i_index of comp callable, from start of the WHOLE template + + len(starttag_ref.strings) # then count up to the end + - 1 # remove 1 since we want an index instead of a limit ) + # @NOTE: The last string should terminate the starttag and end with ">" + # So this length is the offset from the last interpolation to the start + # of the children's leading string. + offset_into_children_start_s = len(starttag_ref.strings[-1]) + + source_pos = self.get_source_pos() - return OpenTComponent( + open_tag = OpenTComponent( start_i_index=i_index, children_start_s_index=children_start_s_index, offset_into_children_start_s=offset_into_children_start_s, - attrs=tattrs, + attrs=self.make_tattrs(attrs), + source_pos=source_pos, + sinfo=OpenTagSourceInfo( + starttag_ref=starttag_ref, + startend=startend, + starttag_pos=source_pos, + ), ) - - def compute_offset_into_children_start_s( - self, - start_i_index: int, - tattrs: tuple[TAttribute, ...], - config: PlaceholderConfig, - starttag_text: str, - ) -> int: - """ - Compute offset into "string" containing the start of children template. - - @NOTE: This is to actually OFFLOAD work to the parser itself. If we try - to "rebuild" the tag from the parse result we are bound to fail in some - way(s). We essentially re-run the placeholder process but with content - we KNOWN ends at the end of the starttag, ie. ">", because the parser - told us that is where it ends (rather than trying to scan for ">" - because ">" might be in literal tags). - - Examples: - - <{Comp}> -- len(">") - <{Comp}>children -- len(">") - <{Comp} title="1>0">children -- len(' title="1>0">') - <{Comp} title="{'1>0'}">children -- len('">') - """ - # Rebuild known interpolations in the starttag. - known: set[int] = {start_i_index} # The component callable itself. - for attr in tattrs: - if isinstance(attr, TInterpolatedAttribute): - known.add(attr.value_i_index) - elif isinstance(attr, TSpreadAttribute): - known.add(attr.i_index) - elif isinstance(attr, TTemplatedAttribute): - known.update(attr.value_ref.i_indexes) - # Now re-remove those placeholders using the same config we used to - # make them. - temp_placeholders = PlaceholderState(known=known, config=config) - tag_ref = temp_placeholders.remove_placeholders(starttag_text) - if not temp_placeholders.is_empty: - raise AssertionError( - "There are extra placeholders still in the starttag_text." - ) - # Now the last string should terminate the starttag and end with ">" - # So this length is the offset from the last interpolation to the start - # of the children's leading string. - return len(tag_ref.strings[-1]) + return open_tag def finalize_tag( - self, open_tag: OpenTag, endtag_i_index: int | None = None + self, + open_tag: OpenTag, + endtag_i_index: int | None = None, + endtag_pos: PartPosition | None = None, ) -> TNode: """Finalize an OpenTag into a TNode.""" + source = self.get_source() match open_tag: - case OpenTElement(tag=tag, attrs=attrs, children=children): - return TElement(tag=tag, attrs=attrs, children=tuple(children)) - case OpenTFragment(children=children): - return TFragment(children=tuple(children)) + case OpenTElement( + tag=tag, + attrs=attrs, + children=children, + source_pos=source_pos, + sinfo=sinfo, + ): + tnode = TElement( + tag=tag, + attrs=attrs, + children=tuple(children), + source_pos=source_pos, + ) + source_pos = ( + open_tag.source_pos + ) # Re-assignment for ty regression in 0.0.59 + self.sinfo_table[source_pos] = sinfo.close(endtag_pos=endtag_pos) + case OpenTFragment(children=children, source_pos=source_pos): + tnode = TFragment(children=tuple(children), source_pos=source_pos) case OpenTComponent( start_i_index=start_i_index, children_start_s_index=children_start_s_index, offset_into_children_start_s=offset_into_children_start_s, attrs=attrs, + source_pos=source_pos, + sinfo=sinfo, + children=children, ): children_ref = self.extract_component_children_ref( start_i_index=start_i_index, endtag_i_index=endtag_i_index, children_start_s_index=children_start_s_index, offset_into_children_start_s=offset_into_children_start_s, - template=self.get_source().template, + template=source.template, ) - return TComponent( + tnode = TComponent( start_i_index=start_i_index, end_i_index=endtag_i_index, children_ref=children_ref, attrs=attrs, + source_pos=source_pos, ) + self.sinfo_table[source_pos] = sinfo.close(endtag_pos=endtag_pos) + self.tcomponent_children[tnode] = children + return tnode def extract_component_children_ref( self, @@ -337,40 +425,116 @@ def extract_component_children_ref( children_ref = TemplateRef(strings=("",), i_indexes=()) return children_ref + 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.ref_to_repr(starttag_sinfo.starttag_ref) + 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.""" - assert self.source, "Parser source tracker not initialized." - tag_ref = self.placeholders.remove_placeholders(tag) + source = self.get_source() + 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 {self.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. + elif not tag_ref.is_singleton: + raise self.make_invalid_endtag_error(tag_ref, self.get_source_pos()) return tag_ref.i_indexes[0] + def get_starttag_ref(self) -> TemplateRef: + """ + Wrap get_starttag_text and just raise if None is returned. + + Do this so we don't guard for `None` everywhere. + """ + starttag_text = self.get_starttag_text() + if starttag_text is None: + raise ParsingAssertionError( + "Expected the parser to have starttag_text set." + ) + # @NOTE: We assume the source tracker already manages the placeholders. + return self.get_source().find_placeholders(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} />". + """ + 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("/") + ) + ) + # parsed starttag ends with "/>", + and sinfo.starttag_ref.strings[-1].endswith("/>") + # if parsed AS startend already then its not ambiguous + and not sinfo.startend + ) + # ------------------------------------------ # HTMLParser tag callbacks # ------------------------------------------ @@ -385,48 +549,96 @@ 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, `` 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) 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() + 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) + final_tag = self.finalize_tag( + 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 # ------------------------------------------ def handle_data(self, data: str) -> None: - ref = self.placeholders.remove_placeholders(data) + source = self.get_source() + ref = source.remove_placeholders(data) parent = self.get_parent() if parent.children and isinstance(parent.children[-1], TText): + prior_text = parent.children[-1] parent.children[-1] = TText( - ref=combine_template_refs(parent.children[-1].ref, ref) + ref=combine_template_refs(prior_text.ref, ref), + # Keep starting position of the prior text + source_pos=prior_text.source_pos, ) else: - self.append_child(TText(ref=ref)) + self.append_child(TText(ref=ref, source_pos=self.get_source_pos())) def handle_comment(self, data: str) -> None: - ref = self.placeholders.remove_placeholders(data) - comment = TComment(ref) + source = self.get_source() + ref = source.remove_placeholders(data) + comment = TComment(ref, source_pos=self.get_source_pos()) self.append_child(comment) def handle_decl(self, decl: str) -> None: - ref = self.placeholders.remove_placeholders(decl) + 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) + 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." ) @@ -434,24 +646,110 @@ def reset(self): super().reset() self.root = OpenTFragment() self.stack = [] - self.placeholders = PlaceholderState() self.source = None + self.parser_pos_translator = 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_ref = parent.sinfo.starttag_ref + starttag_repr = reader.ref_to_repr(starttag_ref) + 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.ref_to_repr(sinfo.starttag_ref) + 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.ref_to_repr(sinfo.starttag_ref) + 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.") - if not self.placeholders.is_empty: - raise ValueError("Some placeholders were never resolved.") + parent = self.stack[-1] + if isinstance(parent, (OpenTElement, OpenTComponent)): + reader = source.get_reader() + starttag_repr = reader.ref_to_repr(parent.sinfo.starttag_ref) + 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 source.placeholders.is_empty: + raise ParsingError("Some placeholders were never resolved.") super().close() def waiting_for_data(self): @@ -478,43 +776,51 @@ def get_tnode(self) -> TNode: # CONSIDER: or as an empty text node? return self.finalize_tag(self.root) + def get_ttree(self) -> TTree: + return TTree( + self.get_tnode(), + sinfos=tuple(self.sinfo_table.values()), + ) + # ------------------------------------------ # Feeding and parsing # ------------------------------------------ def get_source(self) -> SourceTracker: if self.source is None: + # This would be a bug. raise AssertionError("Source has not been initialized.") return self.source - def feed_str(self, s: str) -> None: - """Feed a string part of a Template to the parser.""" - self.feed(s) - - def feed_interpolation(self, index: int) -> None: - placeholder = self.placeholders.add_placeholder(index) - self.feed(placeholder) + def get_parser_pos_translator(self) -> ParserPositionTranslator: + if self.parser_pos_translator is None: + raise AssertionError("Parser position translator has not been initialized.") + return self.parser_pos_translator def feed_template(self, template: Template) -> None: """Feed a Template's content to the parser.""" assert self.source is None, "Did you forget to call reset?" self.source = SourceTracker(template) - for i_index in range(len(template.interpolations)): - self.source.advance_string() - self.feed_str(template.strings[i_index]) - self.source.advance_interpolation() - self.feed_interpolation(i_index) - self.source.advance_string() - self.feed_str(template.strings[-1]) + self.parser_pos_translator = make_parser_pos_translator( + template, self.source.placeholders.config + ) + for content in self.source: + self.feed(content) @staticmethod def parse(t: Template) -> TNode: """ Parse a Template containing valid HTML and substitutions and return - a TNode tree representing its structure. This cachable structure can later - be resolved against actual interpolation values to produce a Node tree. + a cacheable TNode tree representing its structure. + + A placeholder config must be passed to keep parser positions consistent + between calls. """ + return TemplateParser.parse_to_ttree(t).root + + @staticmethod + def parse_to_ttree(t: Template) -> TTree: parser = TemplateParser() parser.feed_template(t) parser.close() - return parser.get_tnode() + return parser.get_ttree() diff --git a/tdom/parser_test.py b/tdom/parser_test.py index d1650ae0..46fc5ff0 100644 --- a/tdom/parser_test.py +++ b/tdom/parser_test.py @@ -2,9 +2,13 @@ import pytest -from .parser import TemplateParser +from .parser import ( + AttributeParsingError, + ParsingError, + TemplateParser, +) from .placeholders import make_placeholder_config -from .template_utils import TemplateRef +from .template_utils import PartPosition, TemplateRef from .tnodes import ( TComment, TComponent, @@ -208,17 +212,17 @@ def test_parse_title_unusual(): def test_parse_mismatched_tags(): - with pytest.raises(ValueError): + with pytest.raises(ParsingError, match="Mismatch"): _ = TemplateParser.parse(t"
Mismatched
") -def test_parse_unclosed_tag(): - with pytest.raises(ValueError): +def test_parse_unclosed_element(): + with pytest.raises(ParsingError, match="unclosed tag
"): _ = TemplateParser.parse(t"
Unclosed") def test_parse_unexpected_closing_tag(): - with pytest.raises(ValueError): + with pytest.raises(ParsingError, match="Unexpected closing tag"): _ = TemplateParser.parse(t"Unopened
") @@ -242,12 +246,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"): _ = TemplateParser.parse(t"
") def test_self_closing_void_tags_unexpected_closing_tag(): - with pytest.raises(ValueError): + with pytest.raises(ParsingError, match="Unexpected closing tag"): _ = TemplateParser.parse(t"") @@ -338,20 +342,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" _ = TemplateParser.parse(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" _ = TemplateParser.parse(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 = {} _ = TemplateParser.parse(t"
") @@ -383,14 +395,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" + ): _ = TemplateParser.parse(t"") def test_unsupported_decl_error(): - with pytest.raises(NotImplementedError): + with pytest.raises(ParsingError, match="Only well formed DOCTYPE declarations"): _ = TemplateParser.parse(t"") # Unknown declaration - with pytest.raises(NotImplementedError): + with pytest.raises(ParsingError, match="Only well formed DOCTYPE declarations"): _ = TemplateParser.parse(t"") # missing DTD @@ -440,7 +454,7 @@ def test_component_element_invalid_closing_tag(): def Component(): pass - with pytest.raises(ValueError): + with pytest.raises(ParsingError, match="Mismatched closing tag
"): _ = TemplateParser.parse(t"<{Component}>
") @@ -448,7 +462,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 "): _ = TemplateParser.parse(t"
") @@ -456,7 +471,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"): _ = TemplateParser.parse(t"<{Component}{Component}>") @@ -464,10 +479,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"): _ = TemplateParser.parse(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 @@ -487,17 +518,17 @@ def test_placeholder_collision_avoidance(): 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"): _ = TemplateParser.parse(t"
Hello, World!
",), i_indexes=() ), ) + + +class TestElementWithAmbiguousSlash: + def test_root_unclosed_error(self): + with pytest.raises( + ParsingError, match="Did you mean to quote the last attribute.*attr[=]root/" + ): + _ = TemplateParser.parse(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 TestSourcePosition: + def test_tnode_source_position(self): + "Check that non-fragments are assigned a source position." + + def PositionComp() -> Template: + return t"" + + for tnode_type, fragment in ( + (TElement, t""), + (TComment, t""), + (TDocumentType, t""), + (TComponent, t"<{PositionComp}>"), + (TText, t"Just a simple text."), + ): + tnode = TemplateParser.parse(t"
" + fragment + t"
") + assert ( + isinstance(tnode, TElement) + and tnode.tag == "div" + and len(tnode.children) == 1 + ) + el = tnode.children[0] + assert isinstance(el, tnode_type) + assert el.source_pos == PartPosition(index=0, offset=len("
")) + + def test_fragment_source_position(self): + "Fragments do not have a position right now." + root = TemplateParser.parse(t"
") + assert isinstance(root, TFragment) + assert not root.source_pos diff --git a/tdom/parser_utils.py b/tdom/parser_utils.py new file mode 100644 index 00000000..4e769828 --- /dev/null +++ b/tdom/parser_utils.py @@ -0,0 +1,193 @@ +from dataclasses import dataclass +from string.templatelib import Template + +from .placeholders import PlaceholderConfig +from .source import LinePosition, MutableLinePosition +from .template_utils import PartPosition, validate_part_position + +type HTMLAttribute = tuple[str, str | None] + + +@dataclass(frozen=True) +class ParserPosition: + """ + A parser position returned by the template parser. + + In certain cases the offset points at "nothing" but has extra meaning + handling by flags. These can be used when converting this position + to a PartPosition. + """ + + line: int = 1 + " Line number, starts counting at 1. " + + offset: int = 0 + " Offset into the line, starts counting at 0. " + + eol: bool = False + " Offset to the NL at the end of line. " + + eof: bool = False + " Offset to the end of the input, there is no line terminator." + + +def precompute_line_to_part_pos( + source_text_parts: tuple[str, ...], +) -> dict[int, PartPosition]: + line_to_part_pos = {1: PartPosition(0, 0)} + line = 1 + for index, part_text in enumerate(source_text_parts): + start = 0 + while 1: + nl_index = part_text.find("\n", start) + if nl_index != -1: + line += 1 + start = nl_index + 1 + line_to_part_pos[line] = PartPosition(index, start) + else: + break + return line_to_part_pos + + +def make_parser_pos_translator( + template: Template, config: PlaceholderConfig +) -> ParserPositionTranslator: + + source_text_parts = tuple( + template.strings[index // 2] + if index % 2 == 0 + else config.make_placeholder((index - 1) // 2) + for index in range(2 * len(template.strings) - 1) + ) + source_text_lines = tuple("".join(source_text_parts).split("\n")) + + line_to_part_pos = precompute_line_to_part_pos(source_text_parts) + + return ParserPositionTranslator( + source_text_parts, source_text_lines, line_to_part_pos + ) + + +@dataclass +class ParserPositionTranslator: + source_text_parts: tuple[str, ...] + " The source text of each template part, with placeholders. " + + source_text_lines: tuple[str, ...] + " The source text of the entire template, with placeholders. " + + line_to_part_pos: dict[int, PartPosition] + " Precomputed mapping from line number to part position. " + + def validate_raw_parser_pos( + self, + raw_parser_pos: LinePosition, + coerce_eol: bool = True, + coerce_eof: bool = True, + ) -> ParserPosition: + """ + Check parser position targets existing line and offset in template. + + This attempts to reduce the complexity of the translating by letting us + assume the translation is possible. + """ + line = raw_parser_pos.line + offset = raw_parser_pos.offset + if line > len(self.source_text_lines): + raise ValueError("Line does not exist in source.") + elif line <= 0: + raise ValueError("Unreachable line number, must be > 0.") + # either eol or eof + end_index = len(self.source_text_lines[line - 1]) + last_line = len(self.source_text_lines) # 1-based + eof = False + eol = False + if offset < 0: + raise ValueError("Unreachable offset, must be >= 0.") + elif offset == end_index and line == last_line: + if coerce_eof: + eof = True + else: + raise ValueError( + f"Offset exceeds reachable characters of last line and coerce EOF is off: {line}: {offset} == {end_index}" + ) + elif offset == end_index and line != last_line: + if coerce_eol: + eol = True + else: + raise ValueError( + f"Offset exceeds reachable characters of line terminated with newline and coerce EOL is off: {line}: {offset} == {end_index}" + ) + elif offset >= end_index: + raise ValueError( + f"Offset exceeds reachable characters of line: {line}: {offset} >= {end_index}" + ) + return ParserPosition(line=line, offset=offset, eol=eol, eof=eof) + + def translate(self, pos: LinePosition) -> PartPosition: + parser_pos = self.validate_raw_parser_pos(pos) + part_pos = parser_pos_to_part_pos( + self.source_text_parts, parser_pos, self.line_to_part_pos + ) + validate_part_position(part_pos) + return part_pos + + +def parser_pos_to_part_pos( + parts: tuple[str, ...], + parser_pos: ParserPosition, + line_to_part_pos: dict[int, PartPosition], +) -> PartPosition: + """ + Translate the given parser position into a template part position. + + - Jump to the precomputed part position for the given line. + - Iterate over the subsequent template parts. + - Track the offset while advancing into each part. + - When we reach the parser position then return the current part + and the current offset from the start of that part. + + """ + pos = MutableLinePosition(line=parser_pos.line, offset=0) + part_pos = line_to_part_pos[parser_pos.line] + start_text = parts[part_pos.index][part_pos.offset :] + last_index = len(parts) - 1 + for index, part_text in enumerate( + (start_text, *parts[part_pos.index + 1 :]), start=part_pos.index + ): + # got enough lines, we just need more offset + first_nl_index = part_text.find("\n") + offset_found = ( + len(part_text[:first_nl_index]) if first_nl_index != -1 else len(part_text) + ) + offset_need = parser_pos.offset - pos.offset + part_offset = 0 if index != part_pos.index else part_pos.offset + if offset_found > offset_need: + pos.offset += offset_need + part_offset += offset_need + return PartPosition(index, part_offset) + elif offset_found == offset_need: + part_offset += offset_need + if first_nl_index == -1: + if index != last_index: + return PartPosition(index + 1, 0) + elif parser_pos.eof: # index is last_index + return PartPosition(index, part_offset) + else: + # This is the last index, a string, + # and the parser position is pointing off the end. + raise ValueError( + "Configured parser position lands at EOF but eof is False." + ) + else: + if parser_pos.eol: + return PartPosition(index, part_offset) + else: + raise ValueError( + "Configured parser position lands at EOL but eol is False." + ) + else: + pos.offset += offset_found + raise AssertionError( + f"Unexpected position {pos}, did not reach required position {parser_pos}" + ) diff --git a/tdom/parser_utils_test.py b/tdom/parser_utils_test.py new file mode 100644 index 00000000..a54b7501 --- /dev/null +++ b/tdom/parser_utils_test.py @@ -0,0 +1,165 @@ +from string.templatelib import Template + +import pytest + +from .parser_utils import ParserPositionTranslator, make_parser_pos_translator +from .placeholders import PlaceholderConfig, make_placeholder_config +from .source import LinePosition +from .template_utils import PartPosition + + +@pytest.fixture(scope="module") +def ph_config(): + return make_placeholder_config() + + +def make_ppt(template: Template, config: PlaceholderConfig) -> ParserPositionTranslator: + "Just a shorthand function." + return make_parser_pos_translator(template=template, config=config) + + +class TestParserPositionTranslator: + def test_case_nontailing_string_ends_with_newline(self, ph_config): + ppt = make_ppt(t"a\n{0}b", ph_config) + assert ppt.translate(LinePosition(line=2, offset=0)) == PartPosition( + index=1, offset=0 + ), """This could also be considered PartPosition(index=1, offset=0) + but either way should work. """ + assert ppt.translate( + LinePosition(line=2, offset=len(ph_config.make_placeholder(0))) + ) == PartPosition( + index=2, offset=0 + ), """This must be the start of the following string because we can't + know the offset of the actual interpolation content. Ie. It cannot be + index=1 with "some" offset.""" + + def test_case_tailing_string_starts_with_newline(self, ph_config): + ppt = make_ppt(t"a{0}\nb", ph_config) + assert ppt.translate(LinePosition(line=2, offset=0)) == PartPosition( + index=2, offset=1 + ), "line 2 should be inside the tailing string" + assert ppt.translate( + LinePosition(line=1, offset=1 + len(ph_config.make_placeholder(0))) + ) == PartPosition(index=2, offset=0), ( + "end of line 1 should be inside the tailing string?" + ) + + def test_case_interpolation_without_lines(self, ph_config): + ppt = make_ppt(t"a{0}b", ph_config) + assert ppt.translate(LinePosition(line=1, offset=1)) == PartPosition( + index=1, offset=0 + ), "end of the head string is the start of the interpolation" + assert ppt.translate( + LinePosition(line=1, offset=1 + len(ph_config.make_placeholder(0))) + ) == PartPosition(index=2, offset=0), ( + "the end of the interpolation is the start of the tailing string" + ) + assert ppt.translate( + LinePosition(line=1, offset=1 + len(ph_config.make_placeholder(0)) + 1) + ) == PartPosition(index=2, offset=1), ( + "the end of the tailing string remains the end." + ) + + def test_offset_without_line(self, ph_config): + ppt = make_ppt(t"a*", ph_config) + assert ppt.translate(LinePosition(line=1, offset=1)) == PartPosition( + index=0, offset=1 + ), "the offset matches up without lines" + assert ppt.translate(LinePosition(line=1, offset=2)) == PartPosition( + index=0, offset=2 + ), "end of line is end of string" + with pytest.raises(ValueError, match="Offset exceeds reachable"): + # only 0, 1 and 2 are valid offsets for line 1 + _ = ppt.translate(LinePosition(line=1, offset=3)) == PartPosition( + index=0, offset=3 + ) + + def test_offset_with_line(self, ph_config): + ppt = make_ppt(t"ab\n*", ph_config) + assert ppt.translate(LinePosition(line=2, offset=0)) == PartPosition( + index=0, offset=3 + ), "2nd line starts at offset in the head string" + assert ppt.translate(LinePosition(line=1, offset=2)) == PartPosition( + index=0, offset=2 + ), "end of 1st line is offset to NL" + with pytest.raises(ValueError, match="Offset exceeds reachable"): + # only 0, 1 and 2 are valid offsets for line 1 + _ = ppt.translate(LinePosition(line=1, offset=3)) + + def test_offset_with_line_in_middle_part(self, ph_config): + ppt = make_ppt(t"a\nb{0}cd\ne{1}\nfe", ph_config) + assert ppt.translate( + LinePosition(line=2, offset=1 + len(ph_config.make_placeholder(0)) + 2) + ) == PartPosition(index=2, offset=2) + + def test_empty_strings(self, ph_config): + ppt = make_ppt(t"{0}", ph_config) + assert ppt.translate(LinePosition(line=1, offset=0)) == PartPosition( + index=1, offset=0 + ), "start of head string is start of interpolation" + assert ppt.translate( + LinePosition(line=1, offset=len(ph_config.make_placeholder(0))) + ) == PartPosition(index=2, offset=0), ( + "end of interpolation is start of tail string" + ) + with pytest.raises(ValueError, match="Offset exceeds reachable"): + # Cannot go past end of the template. + _ = ppt.translate( + LinePosition(line=1, offset=len(ph_config.make_placeholder(0)) + 1) + ) + + def test_empty_string(self, ph_config): + ppt = make_ppt(t"", ph_config) + assert ppt.translate(LinePosition(line=1, offset=0)) == PartPosition( + index=0, offset=0 + ) + with pytest.raises(ValueError, match="Offset exceeds reachable"): + # Cannot go past end of the template. + _ = ppt.translate(LinePosition(line=1, offset=1)) + + def test_empty_line(self, ph_config): + ppt = make_ppt(t"\n", ph_config) + assert ppt.translate(LinePosition(line=1, offset=0)) == PartPosition( + index=0, offset=0 + ) + with pytest.raises(ValueError, match="Offset exceeds reachable"): + # line 1 is empty, cannot offset anything + _ = ppt.translate(LinePosition(line=1, offset=1)) + assert ppt.translate(LinePosition(line=2, offset=0)) == PartPosition( + index=0, offset=1 + ), "To skip over empty line just skip over newline" + with pytest.raises(ValueError, match="Offset exceeds reachable"): + # line 2 is empty, cannot offset anything + _ = ppt.translate(LinePosition(line=2, offset=1)) + + def test_bad_parser_pos_check_bounds(self, ph_config): + ppt = make_ppt(t"abc\ndef", ph_config) + + with pytest.raises(ValueError, match="Line does not exist"): + _ = ppt.translate(LinePosition(line=3, offset=0)) + with pytest.raises(ValueError, match="Unreachable line number"): + _ = ppt.translate(LinePosition(line=0, offset=0)) + with pytest.raises(ValueError, match="Unreachable offset"): + _ = ppt.translate(LinePosition(line=1, offset=-1)) + with pytest.raises(ValueError, match="Unreachable offset"): + _ = ppt.translate(LinePosition(line=2, offset=-1)) + with pytest.raises(ValueError, match="Offset exceeds reachable"): + _ = ppt.translate(LinePosition(line=1, offset=100)) + with pytest.raises(ValueError, match="Offset exceeds reachable"): + _ = ppt.translate(LinePosition(line=2, offset=100)) + + def test_bad_parser_pos_cannot_offset_interpolation(self, ph_config): + ppt = make_ppt(t"abc\n{0}def", ph_config) + + with pytest.raises( + ValueError, + match="Invalid part position, interpolations are not divisible, offset must be 0.", + ): + _ = ppt.translate(LinePosition(line=2, offset=1)) + with pytest.raises( + ValueError, + match="Invalid part position, interpolations are not divisible, offset must be 0.", + ): + _ = ppt.translate( + LinePosition(line=2, offset=len(ph_config.make_placeholder(0)) - 1) + ) diff --git a/tdom/placeholders.py b/tdom/placeholders.py index 1cf47128..eabe4583 100644 --- a/tdom/placeholders.py +++ b/tdom/placeholders.py @@ -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): + return PlaceholderState(known=self.known.copy(), config=self.config) + @property def is_empty(self) -> bool: return len(self.known) == 0 diff --git a/tdom/processor.py b/tdom/processor.py index 318cf671..55d93658 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -29,14 +29,18 @@ SVG_TAG_FIX, VOID_ELEMENTS, ) -from .parser import ( - HTMLAttribute, +from .parser import ParsingError, TemplateParser +from .parser_utils import HTMLAttribute +from .protocols import HasHTMLDunder +from .scope import ScopedTemplate +from .source import SourceReader +from .template_utils import TemplateRef +from .tnodes import ( TAttribute, TComment, TComponent, TDocumentType, TElement, - TemplateParser, TFragment, TInterpolatedAttribute, TLiteralAttribute, @@ -44,10 +48,8 @@ TSpreadAttribute, TTemplatedAttribute, TText, + TTree, ) -from .protocols import HasHTMLDunder -from .scope import ScopedTemplate -from .template_utils import TemplateRef from .utils import CachableTemplate, LastUpdatedOrderedDict type Attribute = tuple[str, object] @@ -59,6 +61,50 @@ # -------------------------------------------------------------------------- +@dataclass(frozen=True) +class TemplateErrorState: + template: Template + ttree: TTree | None = None + tnode: TNode | None = None + values_index: int | None = None + iter_index: int | None = None + + +class ProcessingError(Exception): + """General error when processing a template.""" + + last_tnode: TNode | None + " Nearest tnode from error if applicable. " + + template_e_states: list[TemplateErrorState] + " Stack of processor template error states if applicable. " + + values_index: int | None + " Index of the last failed interpolation. " + + iter_index: int | None + " Iteration of the last failed iterable value. " + + def __init__(self, msg: str = "") -> None: + super().__init__(msg) + self.template_e_states = [] + self.last_tnode = None + self.values_index = None + self.iter_index = None + + +class AttributeProcessingError(ProcessingError): + """Error while processing an element or component attribute.""" + + +class TextProcessingError(ProcessingError): + """Error while processing an element or component attribute.""" + + +class ComponentInvocationError(ProcessingError): + """Error while processing an element or component attribute.""" + + def _format_safe(value: object, format_spec: str) -> str: """Use Markup() to mark a value as safe HTML.""" assert format_spec == "safe" @@ -111,7 +157,7 @@ def _expand_aria_attr(value: object) -> Iterable[HTMLAttribute]: else: yield f"aria-{sub_k}", str(sub_v) else: - raise TypeError( + raise AttributeProcessingError( f"Cannot use {type(value).__name__} as value for aria attribute" ) @@ -127,7 +173,7 @@ def _expand_data_attr(value: object) -> Iterable[Attribute]: else: yield f"data-{sub_k}", str(sub_v) else: - raise TypeError( + raise AttributeProcessingError( f"Cannot use {type(value).__name__} as value for data attribute" ) @@ -145,7 +191,7 @@ def _substitute_spread_attrs(value: object) -> Iterable[Attribute]: elif isinstance(value, Mapping): yield from value.items() else: - raise TypeError( + raise AttributeProcessingError( f"Cannot use {type(value).__name__} as value for spread attributes" ) @@ -166,7 +212,7 @@ def parse_style_attribute_value(style_str: str) -> list[tuple[str, str | None]]: if prop: prop_parts = [p.strip() for p in prop.split(":") if p.strip()] if len(prop_parts) != 2: - raise ValueError( + raise AttributeProcessingError( f"Invalid number of parts for style property {prop} in {style_str}" ) styles.append((prop_parts[0], prop_parts[1])) @@ -185,7 +231,7 @@ def make_style_accumulator(old_value: object) -> StyleAccumulator: case True: # A bare attribute will just default to {}. styles = {} case _: - raise TypeError(f"Unexpected value: {old_value}") + raise AttributeProcessingError(f"Unexpected style value: {old_value}") return StyleAccumulator(styles=styles) @@ -212,7 +258,7 @@ def merge_value(self, value: object) -> None: case None: pass case _: - raise TypeError( + raise AttributeProcessingError( f"Unknown interpolated style value {value}, use '' to omit." ) @@ -238,7 +284,7 @@ def make_class_accumulator(old_value: object) -> ClassAccumulator: case True: toggled_classes = {} case _: - raise ValueError(f"Unexpected value {old_value}") + raise AttributeProcessingError(f"Unexpected class value {old_value}") return ClassAccumulator(toggled_classes=toggled_classes) @@ -267,11 +313,11 @@ def merge_value(self, value: object) -> None: pass case _: if item == value: - raise TypeError( + raise AttributeProcessingError( f"Unknown interpolated class value: {value}" ) else: - raise TypeError( + raise AttributeProcessingError( f"Unknown interpolated class item in {value}: {item}" ) @@ -345,7 +391,9 @@ def _resolve_t_attrs( ) new_attrs[name] = attr_accs[name].merge_value(attr_value) elif expander := ATTR_EXPANDERS.get(name): - raise TypeError(f"{name} attributes cannot be templated") + raise AttributeProcessingError( + f"{name} attributes cannot be templated" + ) else: new_attrs[name] = attr_value case TSpreadAttribute(i_index=i_index): @@ -364,7 +412,9 @@ def _resolve_t_attrs( else: new_attrs[sub_k] = sub_v case _: - raise ValueError(f"Unknown TAttribute type: {type(attr).__name__}") + raise AttributeProcessingError( + f"Unknown TAttribute type: {type(attr).__name__}" + ) for acc_name, acc in attr_accs.items(): # Skip "touching" the key here so that the order remains intact. super(type(new_attrs), new_attrs).__setitem__(acc_name, acc.to_value()) @@ -421,7 +471,7 @@ def _prep_component_kwargs( # We can't know what kwarg to put here... if raise_on_requires_positional and callable_info.requires_positional: - raise TypeError( + raise ComponentInvocationError( "Component callables cannot have required positional arguments." ) @@ -433,10 +483,12 @@ def _prep_component_kwargs( if snake_name in callable_info.named_params or callable_info.kwargs: kwargs[snake_name] = attr_value else: - raise ValueError(f"Unexpected attribute {snake_name}.") + raise ComponentInvocationError(f"Unexpected attribute {snake_name}.") if "children" in kwargs: - raise ValueError("The children attribute is reserved for component children.") + raise ComponentInvocationError( + "The children attribute is reserved for component children." + ) if "children" in callable_info.named_params: kwargs["children"] = children @@ -450,7 +502,7 @@ def _prep_component_kwargs( if raise_on_missing: missing = callable_info.required_named_params - kwargs.keys() if missing: - raise TypeError( + raise ComponentInvocationError( f"Missing required parameters for component: {', '.join(missing)}" ) @@ -523,23 +575,34 @@ def copy( class ITemplateParserProxy(t.Protocol): def to_tnode(self, template: Template) -> TNode: ... + def to_ttree(self, template: Template) -> TTree: ... @dataclass(frozen=True) class TemplateParserProxy(ITemplateParserProxy): - def to_tnode(self, template: Template) -> TNode: + def to_tnode(self, template: Template) -> TNode: # BWC return TemplateParser.parse(template) + def to_ttree(self, template: Template) -> TTree: + return TemplateParser.parse_to_ttree(template) + @dataclass(frozen=True) class CachedTemplateParserProxy(TemplateParserProxy): @lru_cache(512) # noqa: B019 - def _to_tnode(self, ct: CachableTemplate) -> TNode: + def _to_tnode(self, ct: CachableTemplate) -> TNode: # BWC return super().to_tnode(ct.template) - def to_tnode(self, template: Template) -> TNode: + def to_tnode(self, template: Template) -> TNode: # BWC return self._to_tnode(CachableTemplate(template)) + @lru_cache(512) # noqa: B019 + def _to_ttree(self, ct: CachableTemplate) -> TTree: + return super().to_ttree(ct.template) + + def to_ttree(self, template: Template) -> TTree: + return self._to_ttree(CachableTemplate(template)) + class IComponentProcessor(t.Protocol): """Isolate component processing to allow for replacement.""" @@ -604,30 +667,51 @@ def process( won't construct one directly. """ if not callable(component_callable): - raise TypeError( + raise ComponentInvocationError( f"Component callable must be callable: {type(component_callable)}" ) + try: + tattrs = _resolve_t_attrs(attrs, template.interpolations) + except ProcessingError: # @TODO: Is there a native way to guard this? + raise + except Exception as e: + # Causes: + # - Could be a failed "callback" formatter + # + raise AttributeProcessingError( + "Error occurred processing component attributes" + ) from e kwargs = _prep_component_kwargs( get_callable_info(component_callable), - _resolve_t_attrs(attrs, template.interpolations), + tattrs, children=component_template, provided_attrs=provided_attrs, raise_on_requires_positional=True, raise_on_missing=True, ) - res1 = component_callable(**kwargs) # ty: ignore[call-top-callable] + try: + res1 = component_callable(**kwargs) # ty: ignore[call-top-callable] + except Exception as e: + raise ComponentInvocationError( + "Failed when invoking component callable." + ) from e if isinstance(res1, (Template, ScopedTemplate)): return res1 elif callable(res1): - res2 = res1() # ty: ignore[call-top-callable] + try: + res2 = res1() # ty: ignore[call-top-callable] + except Exception as e: + raise ComponentInvocationError( + "Failed when invoking component callable the second time." + ) from e if isinstance(res2, (Template, ScopedTemplate)): return res2 else: - raise TypeError( + raise ComponentInvocationError( f"Component object must return Template when called: {type(res2)}" ) else: - raise TypeError( + raise ComponentInvocationError( f"Component callable must return Template or Callable: {type(res1)}" ) @@ -656,6 +740,75 @@ class TemplateProcessor(ITemplateProcessor): uppercase_doctype: bool = False # DOCTYPE vs doctype + def _add_process_error_notes( + self, + e: ProcessingError, + ) -> None: + for e_state in reversed(e.template_e_states): + if not e_state.ttree: + # Just skip this special case where processing could not + # even get started because the template wouldn't parse. + continue + elif not (e_state.tnode and e_state.template): + raise AssertionError( + "This should not happen if we have properly contained the error." + ) + else: + self._add_tnode_error_note( + e, e_state.ttree, e_state.tnode, e_state.template + ) + + def _add_tnode_error_note( + self, + e: ProcessingError, + ttree: TTree, # The root metadata for the "current" template + tnode: TNode, # The leafmost tnode where the error was caught for the "current" template + template: Template, # The "current" template that was being processed + ) -> None: + reader = SourceReader(template) + source_pos = ( + tnode.source_pos + if isinstance( + tnode, (TElement, TComponent, TFragment, TComment, TDocumentType, TText) + ) + else None + ) + + if isinstance(tnode, (TElement, TComponent)): + sinfo_table = ttree.unpack_sinfo_table() + sinfo = sinfo_table.get(source_pos, None) if source_pos else None + if sinfo: + starttag_repr = reader.ref_to_repr(sinfo.starttag_ref) + starttag_pos_msg = reader.make_template_pos_msg(sinfo.starttag_pos) + else: + if isinstance(tnode, TComponent): + starttag_repr = reader.ref_to_repr( + TemplateRef( + strings=("<", "...>"), i_indexes=(tnode.start_i_index,) + ) + ) + elif isinstance(tnode, TElement): + starttag_repr = f"<{tnode.tag} ...>" + else: + starttag_repr = "unknown source" # This would likely be a bug. + else: + if isinstance(tnode, TText): + starttag_repr = reader.ref_to_repr(tnode.ref) + elif isinstance(tnode, TComment): + starttag_repr = f"" + elif isinstance(tnode, TDocumentType): + starttag_repr = f"" + else: + # @TODO: TFragment/TNode/? + starttag_repr = tnode.__class__.__name__.upper() + + if source_pos: + starttag_pos_msg = reader.make_template_pos_msg(source_pos) + else: + starttag_pos_msg = "unknown location" # source_pos is optional right now + + e.add_note(f"Error occurred at {starttag_repr} at {starttag_pos_msg}.") + def process( self, root_template: Template, @@ -664,11 +817,39 @@ def process( """ Process a TDOM compatible template into a string. """ - return self._process_template(root_template, assume_ctx) + try: + return self._process_template(root_template, assume_ctx) + except ProcessingError as e: + self._add_process_error_notes(e) + raise def _process_template(self, template: Template, last_ctx: ProcessContext) -> str: - root = self.parser_api.to_tnode(template) - return self._process_tnode(template, last_ctx, root) + try: + ttree = self.parser_api.to_ttree(template) + except ParsingError as parsing_e: + # Chain the parsing error into a processing error. + e = ProcessingError("Failed to parse template.") + e.template_e_states.append( + TemplateErrorState(template) + ) # Special case where nothing is set yet. + raise e from parsing_e + try: + return self._process_tnode(template, last_ctx, ttree.root) + except ProcessingError as e: + e.template_e_states.append( + TemplateErrorState( + template=template, + ttree=ttree, + tnode=e.last_tnode, + values_index=e.values_index, + iter_index=e.iter_index, + ) + ) + # Reset everything. + e.last_tnode = None + e.values_index = None + e.iter_index = None + raise def _process_tnode( self, template: Template, last_ctx: ProcessContext, tnode: TNode @@ -676,28 +857,35 @@ def _process_tnode( """ Process a tnode from a template's "t-tree" into a string. """ - match tnode: - case TDocumentType(text): - return self._process_document_type(last_ctx, text) - case TComment(ref): - return self._process_comment(template, last_ctx, ref) - case TFragment(children): - return self._process_fragment(template, last_ctx, children) - case TComponent(start_i_index, end_i_index, children_ref, attrs): - return self._process_component( - template, - last_ctx, - attrs, - start_i_index, - end_i_index, - children_ref, - ) - case TElement(tag, attrs, children): - return self._process_element(template, last_ctx, tag, attrs, children) - case TText(ref): - return self._process_texts(template, last_ctx, ref) - case _: - raise ValueError(f"Unrecognized tnode: {tnode}") + try: + match tnode: + case TDocumentType(text): + return self._process_document_type(last_ctx, text) + case TComment(ref): + return self._process_comment(template, last_ctx, ref) + case TFragment(children): + return self._process_fragment(template, last_ctx, children) + case TComponent(start_i_index, end_i_index, children_ref, attrs): + return self._process_component( + template, + last_ctx, + attrs, + start_i_index, + end_i_index, + children_ref, + ) + case TElement(tag, attrs, children): + return self._process_element( + template, last_ctx, tag, attrs, children + ) + case TText(ref): + return self._process_texts(template, last_ctx, ref) + case _: + raise ValueError(f"Unrecognized tnode: {tnode}") + except ProcessingError as e: + if e.last_tnode is None: + e.last_tnode = tnode + raise def _process_document_type( self, @@ -706,7 +894,7 @@ def _process_document_type( ) -> str: if last_ctx.ns != "html": # Nit - raise ValueError( + raise ProcessingError( "Cannot process document type in subtree of a foreign element." ) if self.uppercase_doctype: @@ -801,7 +989,14 @@ def _process_attrs( """ Process an element's attributes into a string. """ - resolved_attrs = _resolve_t_attrs(attrs, template.interpolations) + try: + resolved_attrs = _resolve_t_attrs(attrs, template.interpolations) + except ProcessingError: # @TODO: Is there a native way to guard this? + raise + except Exception as e: + raise AttributeProcessingError( + "Unexpected error occurred while processing element attrs." + ) from e if last_ctx.ns == "svg": attrs_str = serialize_html_attrs( _fix_svg_attrs(_resolve_html_attrs(resolved_attrs)) @@ -831,7 +1026,7 @@ def _process_component( and template.interpolations[start_i_index].value != template.interpolations[end_i_index].value ): - raise TypeError( + raise ComponentInvocationError( "Component callable in start tag must match component callable in end tag." ) component_callable = template.interpolations[start_i_index].value @@ -867,7 +1062,7 @@ def _process_raw_texts( allow_markup=True, ) else: - raise NotImplementedError( + raise TextProcessingError( f"Parent tag {last_ctx.parent_tag} is not supported." ) @@ -914,13 +1109,17 @@ def _process_normal_text( """ value = format_interpolation(template.interpolations[values_index]) value = t.cast(NormalTextInterpolationValue, value) # ty: ignore[redundant-cast] - return self._process_normal_text_from_value(template, last_ctx, value) + return self._process_normal_text_from_value( + template, last_ctx, value, values_index=values_index + ) def _process_normal_text_from_value( self, template: Template, last_ctx: ProcessContext, value: NormalTextInterpolationValue, + values_index: int | None = None, + iter_index: int | None = None, ) -> str: """ Process a single value into a string as "normal text". @@ -935,18 +1134,36 @@ def _process_normal_text_from_value( # implementing HasHTMLDunder. return self.escape_html_text(value) elif isinstance(value, Template): - return self._process_template(value, last_ctx) + try: + return self._process_template(value, last_ctx) + except ProcessingError as e: + assert e.values_index is None and e.iter_index is None + e.values_index = values_index + e.iter_index = iter_index + raise elif isinstance(value, Iterable): return "".join( - self._process_normal_text_from_value(template, last_ctx, v) - for v in value + self._process_normal_text_from_value( + template, + last_ctx, + v, + iter_index=iter_index, + values_index=values_index, + ) + for iter_index, v in enumerate(value) ) elif isinstance(value, HasHTMLDunder): # @NOTE: markupsafe's escape does this for us but we put this in # here for completeness. # @NOTE: An actual Markup() would actually pass as a str() but a # custom object with __html__ might not. - return Markup(value.__html__()) + try: + return Markup(value.__html__()) + except Exception as e: + pe = TextProcessingError("Error occurred when processing text.") + pe.values_index = values_index + pe.iter_index = iter_index + raise pe from e else: # @DESIGN: Everything that isn't an object we recognize is # coerced to a str() and emitted. @@ -976,7 +1193,7 @@ def resolve_text_without_recursion( # the interpolation in this special case. return Markup(value.__html__()) elif isinstance(value, (Template, Iterable)): - raise ValueError( + raise TextProcessingError( f"Recursive includes are not supported within {parent_tag}" ) else: @@ -998,11 +1215,11 @@ def resolve_text_without_recursion( if value: text.append(value) elif not isinstance(value, str) and isinstance(value, (Template, Iterable)): - raise ValueError( + raise TextProcessingError( f"Recursive includes are not supported within {parent_tag}" ) elif isinstance(value, HasHTMLDunder): - raise ValueError( + raise TextProcessingError( f"Non-exact trusted interpolations are not supported within {parent_tag}" ) else: diff --git a/tdom/processor_test.py b/tdom/processor_test.py index babd6a20..91985997 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -12,17 +12,23 @@ from .callables import get_callable_info from .escaping import escape_html_text +from .parser import ParsingError from .processor import ( + AttributeProcessingError, CachedTemplateParserProxy, + ComponentInvocationError, ProcessContext, + ProcessingError, TemplateParserProxy, TemplateProcessor, + TextProcessingError, _make_default_template_processor, ) from .processor import ( _prep_component_kwargs as prep_component_kwargs, ) from .protocols import HasHTMLDunder +from .tnodes import TElement, TText processor_api = _make_default_template_processor( parser_api=TemplateParserProxy(), # do not use cache @@ -183,11 +189,11 @@ def test_templated_bool(self, bool_value): def test_templated_has_html_dunder_error(self, html_dunder_cls): """Objects with __html__ are not processed with literal text or other interpolations.""" text = html_dunder_cls("in a comment") - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") def test_templated_multiple_interpolations(self): @@ -207,12 +213,12 @@ def test_templated_escaping(self): def test_not_supported__recursive_template_error(self): text_t = t"comment" - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") def test_not_supported_recursive_iterable_error(self): texts = ["This", "is", "a", "comment"] - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") @@ -410,12 +416,12 @@ def test_style_with_content_escaped_in_normal_text(self): def test_not_supported_recursive_template_error(self): text_t = t"comment" - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") def test_not_supported_recursive_iterable_error(self): texts = ["This", "is", "a", "comment"] - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") @@ -520,7 +526,7 @@ def test_templated_object(self): ) def test_templated_has_html_dunder(self, html_dunder_cls): content = html_dunder_cls("anything") - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") def test_templated_escaping(self): @@ -538,12 +544,12 @@ def test_templated_multiple_interpolations(self): def test_not_supported_recursive_template_error(self): text_t = t"script" - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") def test_not_supported_recursive_iterable_error(self): texts = ["This", "is", "a", "script"] - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") @@ -625,7 +631,7 @@ def test_templated_object(self): ) def test_templated_has_html_dunder(self, html_dunder_cls): content = html_dunder_cls("anything") - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") def test_templated_escaping(self): @@ -647,22 +653,22 @@ def test_templated_multiple_interpolations(self): def test_exact_not_supported_recursive_template_error(self): text_t = t"style" - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") def test_inexact_not_supported_recursive_template_error(self): text_t = t"style" - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") def test_exact_not_supported_recursive_iterable_error(self): texts = ["This", "is", "a", "style"] - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") def test_inexact_not_supported_recursive_iterable_error(self): texts = ["This", "is", "a", "style"] - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") @@ -736,7 +742,7 @@ def test_templated_object(self): ) def test_templated_has_html_dunder(self, html_dunder_cls): content = html_dunder_cls("No") - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"Literal html?: {content}") def test_templated_escaping(self): @@ -754,22 +760,22 @@ def test_templated_multiple_interpolations(self): def test_exact_not_supported_recursive_template_error(self): text_t = t"title" - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"{text_t}") def test_exact_not_supported_recursive_iterable_error(self): texts = ["This", "is", "a", "title"] - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"{texts}") def test_inexact_not_supported_recursive_template_error(self): text_t = t"title" - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"{text_t} and more") def test_inexact_not_supported_recursive_iterable_error(self): texts = ["This", "is", "a", "title"] - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"{texts} and more") @@ -850,7 +856,7 @@ def test_templated_object(self): ) def test_templated_has_html_dunder(self, html_dunder_cls): content = html_dunder_cls("No") - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") def test_templated_multiple_interpolations(self): @@ -868,12 +874,12 @@ def test_templated_escaping(self): def test_not_supported_recursive_template_error(self): text_t = t"textarea" - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") def test_not_supported_recursive_iterable_error(self): texts = ["This", "is", "a", "textarea"] - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(TextProcessingError, match="not supported"): _ = html(t"") @@ -1001,13 +1007,17 @@ def get_value(): == f"<{tag}>The value is dynamic." ) + @pytest.mark.skip def test_callback_nonzero_callable_error(self): def add(a, b): return a + b assert add(1, 2) == 3, "Make sure fixture could work..." - with pytest.raises(TypeError): + with pytest.raises( + ProcessingError, + match="Should we wrap every call to format_interpolation and chain the exception?", + ): for tag in ("p", "script", "style"): _ = html( Template(f"<{tag}>") @@ -1015,6 +1025,19 @@ def add(a, b): + Template(f"") ) + def test_callback_internal_error(self): + def raise_value_error(): + raise ValueError("Failed to compute count.") + + with pytest.raises( + AttributeProcessingError, + match="Unexpected error occurred while processing element attrs", + ) as exc_info: + _ = html(t"
") + assert isinstance(exc_info.value.__cause__, ValueError), ( + "Original error should be chained." + ) + # -------------------------------------------------------------------------- # Conditional rendering and control flow @@ -1117,7 +1140,7 @@ def test_spread_attr_none(self): def test_spread_attr_type_errors(self): for attrs in (0, [], (), False, True): - with pytest.raises(TypeError): + with pytest.raises(AttributeProcessingError): _ = html(t"") @@ -1228,7 +1251,7 @@ def test_data_attr_unrelated_unaffected(self): def test_data_attr_templated_error(self): data1 = {"user-id": "user-123"} data2 = {"role": "admin"} - with pytest.raises(TypeError): + with pytest.raises(AttributeProcessingError): _ = html(t'
') def test_data_attr_none(self): @@ -1238,7 +1261,7 @@ def test_data_attr_none(self): def test_data_attr_errors(self): for v in [False, [], (), 0, "data?"]: - with pytest.raises(TypeError): + with pytest.raises(AttributeProcessingError): _ = html(t"") def test_data_literal_attr_bypass(self): @@ -1255,7 +1278,7 @@ class TestSpecialAriaAttribute: def test_aria_templated_attr_error(self): aria1 = {"label": "close"} aria2 = {"hidden": "true"} - with pytest.raises(TypeError): + with pytest.raises(AttributeProcessingError): _ = html(t'
') def test_interpolated_mapping(self): @@ -1277,7 +1300,7 @@ def test_aria_interpolate_attr_none(self): def test_aria_attr_errors(self): for v in [False, [], (), 0, "aria?"]: - with pytest.raises(TypeError): + with pytest.raises(AttributeProcessingError): _ = html(t"") def test_aria_literal_attr_bypass(self): @@ -1359,9 +1382,9 @@ def test_class_none_ignored(self): def test_class_type_errors(self): for class_item in (False, True, 0): - with pytest.raises(TypeError): + with pytest.raises(AttributeProcessingError): _ = html(t"

") - with pytest.raises(TypeError): + with pytest.raises(AttributeProcessingError): _ = html(t"

") def test_class_merge_literals(self): @@ -1432,7 +1455,7 @@ def test_interpolated_style_attribute_multiple_placeholders(self): # CONSIDER: Is this what we want? Currently, when we have multiple # placeholders in a single attribute, we treat it as a string attribute # which produces an invalid style attribute. - with pytest.raises(ValueError): + with pytest.raises(AttributeProcessingError): _ = html(t"

Warning!

") def test_interpolated_style_attribute_merged(self): @@ -1453,7 +1476,7 @@ def test_style_attribute_str(self): assert res == '

Warning!

' def test_style_attribute_non_str_non_dict(self): - with pytest.raises(TypeError): + with pytest.raises(AttributeProcessingError): styles = [1, 2] _ = html(t"

Warning!

") @@ -1511,7 +1534,7 @@ def InputElement(size=10, type="text"): pass callable_info = get_callable_info(InputElement) - with pytest.raises(ValueError): + with pytest.raises(ComponentInvocationError): assert ( prep_component_kwargs(callable_info, {"type2": 15}, children=t"") == {} ) @@ -1555,7 +1578,9 @@ def Comp(children: Template) -> Template: return t"
{children}
" callable_info = get_callable_info(Comp) - with pytest.raises(ValueError, match="The children attribute is reserved"): + with pytest.raises( + ComponentInvocationError, match="The children attribute is reserved" + ): _ = prep_component_kwargs( callable_info, {"children": t""}, children=t"" ) @@ -1597,7 +1622,7 @@ def test_with_no_children(self): ) def test_missing_props_error(self): - with pytest.raises(TypeError): + with pytest.raises(ComponentInvocationError): _ = html( t"<{self.FunctionComponent}>Missing props" ) @@ -1823,14 +1848,38 @@ def AttributeTypeComponent( class TestComponentErrors: def test_component_non_callable_fails(self): - with pytest.raises(TypeError): + with pytest.raises(ComponentInvocationError, match="must be callable"): _ = html(t"<{'not a function'} />") + def test_catchall_for_attr_prep_callback_error(self): + def prep_attr(): + return 1 / 0 + + def Repeat(count: int = 0, children: Template = t"") -> Template: + return sum([children] * count, t"") + + with pytest.raises( + AttributeProcessingError, + match="Error occurred processing component attributes", + ): + _ = html(t"<{Repeat} count={prep_attr:callback}>OK") + + def test_normal_attr_error(self): + def Comp(children: Template, **kwargs) -> Template: + return t"
{children}
" + + with pytest.raises( + AttributeProcessingError, match="Cannot use int as value for aria attribute" + ): + _ = html(t"<{Comp} aria={0}>OK") + def test_component_requiring_positional_arg_fails(self): def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover return t"

Positional arg: {whoops}

" - with pytest.raises(TypeError): + with pytest.raises( + ComponentInvocationError, match="cannot have required positional arguments" + ): _ = html(t"<{RequiresPositional} />") def test_mismatched_component_closing_tag_fails(self): @@ -1840,9 +1889,39 @@ def OpenTag(children: Template) -> Template: def CloseTag(children: Template) -> Template: return t"
close
" - with pytest.raises(TypeError): + with pytest.raises( + ComponentInvocationError, match="must match component callable" + ): _ = html(t"<{OpenTag}>Hello") + def test_func_comp_error(self): + def RaisesValueError(children: Template) -> Template: + raise ValueError("Failed to build template.") + + with pytest.raises( + ComponentInvocationError, match="Failed when invoking component callable[.]" + ) as exc_info: + _ = html(t"<{RaisesValueError}>Hello") + assert isinstance(exc_info.value.__cause__, ValueError), ( + "Original error should be chained." + ) + + def test_factory_comp_error(self): + def RaisesValueError(children: Template) -> Callable[[], Template]: + def _RaisesValueError() -> Template: + raise ValueError("Failed to build template.") + + return _RaisesValueError + + with pytest.raises( + ComponentInvocationError, + match="Failed when invoking component callable the second time.", + ) as exc_info: + _ = html(t"<{RaisesValueError}>Hello") + assert isinstance(exc_info.value.__cause__, ValueError), ( + "Original error should be chained." + ) + @pytest.mark.parametrize( "bad_value", ("", "text", None, 1, ("tuple", "of", "strs")) ) @@ -1851,7 +1930,8 @@ def BadFunctionComp(children: Template): return bad_value with pytest.raises( - TypeError, match="Component callable must return Template or Callable:" + ComponentInvocationError, + match="Component callable must return Template or Callable:", ): _ = html(t"<{BadFunctionComp}>Hello") @@ -1866,7 +1946,8 @@ def component_object(): return component_object with pytest.raises( - TypeError, match="Component object must return Template when called:" + ComponentInvocationError, + match="Component object must return Template when called:", ): _ = html(t"<{BadFactoryComp}>Hello") @@ -2099,7 +2180,8 @@ def test_dynamic_raw_text(self): content = '' content_t = t"{content}" with pytest.raises( - ValueError, match="Recursive includes are not supported within script" + TextProcessingError, + match="Recursive includes are not supported within script", ): content_t = t'' _ = html(t"") @@ -2109,7 +2191,8 @@ def test_dynamic_escapable_raw_text(self): content = '' content_t = t"{content}" with pytest.raises( - ValueError, match="Recursive includes are not supported within textarea" + TextProcessingError, + match="Recursive includes are not supported within textarea", ): _ = html(t"") @@ -2208,3 +2291,64 @@ def test_mathml(): is not a decimal number.

""" ) + + +@pytest.fixture +def bad_html_dunder(): + return _BadHTMLDunder() + + +class _BadHTMLDunder: + def __html__(self): + raise ValueError("bad value") + + +class TestProcessingException: + def test_attr_error_has_matching_tnode(self): + "AttriubteProcessingError should point to tnode where error first occurred." + invalid_t = t"
" # 0 is invalid aria value + with pytest.raises(AttributeProcessingError) as exc_info: + _ = html(invalid_t) + assert len(exc_info.value.template_e_states) == 1 + tnode = exc_info.value.template_e_states[0].tnode + assert tnode and isinstance(tnode, TElement) and tnode.tag == "div" + + def test_text_error_has_matching_tnode(self, bad_html_dunder): + "TextProcessingError should point to tnode where error first occurred." + invalid_t = t"
{bad_html_dunder}
" + with pytest.raises(TextProcessingError) as exc_info: + _ = html(invalid_t) + assert len(exc_info.value.template_e_states) == 1 + tnode = exc_info.value.template_e_states[0].tnode + assert tnode and isinstance(tnode, TText) + + def test_processing_error_multiple_templates(self): + "*ProcessingError should stack error state for each template/tnode as stack unwinds." + inner_t = t"
" # 0 is invalid aria value + wrapper_t = t"
{inner_t}
" + with pytest.raises(AttributeProcessingError) as exc_info: + _ = html(wrapper_t) + assert len(exc_info.value.template_e_states) == 2 + inner_tnode = exc_info.value.template_e_states[0].tnode + assert ( + inner_tnode + and isinstance(inner_tnode, TElement) + and inner_tnode.tag == "div" + ) + wrapper_tnode = exc_info.value.template_e_states[1].tnode + assert wrapper_tnode and isinstance(wrapper_tnode, TText) + + def test_parsing_error_while_processing(self): + inner_t = t"
" + wrapper_t = t"
{inner_t}
" + with pytest.raises(ProcessingError) as exc_info: + _ = html(wrapper_t) + assert len(exc_info.value.template_e_states) == 2 + assert not exc_info.value.template_e_states[0].ttree, ( + "This can't be set for a parsing error." + ) + wrapper_tnode = exc_info.value.template_e_states[1].tnode + assert wrapper_tnode and isinstance(wrapper_tnode, TText) + assert isinstance(exc_info.value.__cause__, ParsingError), ( + "ProcessingError should be chained to parsing error." + ) diff --git a/tdom/source.py b/tdom/source.py new file mode 100644 index 00000000..2a1d7a32 --- /dev/null +++ b/tdom/source.py @@ -0,0 +1,114 @@ +import typing as t +from dataclasses import dataclass +from string.templatelib import Interpolation, Template + +from .template_utils import PartPosition, TemplateRef, slice_to_tref + + +@dataclass(slots=True, frozen=True) +class LinePosition: + "A immutable position in a block of source code." + + line: int = 1 + " Line of code, starts at 1. " + offset: int = 0 + " Offset from the start of the line, starts at 0. " + + +@dataclass(slots=True) +class MutableLinePosition: + "A mutable position in a block of source code." + + line: int = 1 + " Line of code, starts at 1. " + offset: int = 0 + " Offset from the start of the line, starts at 0. " + + def freeze(self) -> LinePosition: + "Freeze ourself into an immutable object with the same values." + return LinePosition(line=self.line, offset=self.offset) + + +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. + """ + 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.resolve(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. + """ + pos = MutableLinePosition() + for part in slice_to_tref(self.template, start=None, stop=source_pos): + if isinstance(part, str): + text = part + else: + text = interpolation_repr(self.template.interpolations[part]) + nls = text.count("\n") + if nls: + pos.offset = len(text) - (text.rfind("\n") + 1) + pos.line += nls + else: + pos.offset += len(text) + return pos.freeze() diff --git a/tdom/source_test.py b/tdom/source_test.py new file mode 100644 index 00000000..2832787c --- /dev/null +++ b/tdom/source_test.py @@ -0,0 +1,52 @@ +from .source import LinePosition, SourceReader +from .template_utils import PartPosition + + +class TestToTemplatePosition: + def test_origin(self): + t = t"
{'content'}
" + reader = SourceReader(template=t) + source_pos = PartPosition(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(index=1, offset=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(index=2, 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(index=2, 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(index=2, offset=0) + assert reader.to_template_pos(source_pos) == LinePosition( + line=4, offset=len("'''}") + ) diff --git a/tdom/template_utils.py b/tdom/template_utils.py index f5e3f060..ac2e176a 100644 --- a/tdom/template_utils.py +++ b/tdom/template_utils.py @@ -1,6 +1,7 @@ import typing as t from collections.abc import Sequence from dataclasses import dataclass +from itertools import chain from string.templatelib import Interpolation, Template @@ -16,9 +17,16 @@ def template_from_parts( def combine_template_refs(*template_refs: TemplateRef) -> TemplateRef: - return TemplateRef.from_naive_template( - sum((tr.to_naive_template() for tr in template_refs), t"") + """Concatenate multiple template refs together into a single ref.""" + combined_strings = [""] + combined_i_indexes = tuple( + chain.from_iterable(tref.i_indexes for tref in template_refs) ) + for tref in template_refs: + # Join last tref tail to this tref head + combined_strings[-1] = combined_strings[-1] + tref.strings[0] + combined_strings.extend(tref.strings[1:]) + return TemplateRef(strings=tuple(combined_strings), i_indexes=combined_i_indexes) @dataclass(slots=True, frozen=True) @@ -76,18 +84,149 @@ def __post_init__(self): "TemplateRef must have one more string than interpolation indexes." ) + def parts_iter(self): + """ + Similar to __iter__ but returns empty strings. + """ + size = len(self.strings) * 2 - 1 + for index in range(size): + if index % 2 == 0: + yield self.strings[index // 2] + else: + yield self.i_indexes[(index - 1) // 2] + def __iter__(self): - index = 0 - last_s_index = len(self.strings) - 1 - while index <= last_s_index: - s = self.strings[index] - if s: - yield s - if index < last_s_index: - yield self.i_indexes[index] - index += 1 + """ + Yield parts like `string.templatelib.Template`: `str, [int, str], ...`. + + Empty strings are omitted which parallels the behavior + of `Template.__iter__`. Use `parts_iter` to include empty strings. + """ + size = len(self.strings) * 2 - 1 + for index in range(size): + if index % 2 != 0: + yield self.i_indexes[(index - 1) // 2] + elif self.strings[index // 2]: + yield self.strings[index // 2] def resolve(self, interpolations: tuple[Interpolation, ...]) -> Template: """Use the given interpolations to resolve this reference template into a Template.""" resolved = [interpolations[i_index] for i_index in self.i_indexes] return template_from_parts(self.strings, resolved) + + def slice( + self, + start: PartPosition | None = None, + stop: PartPosition | None = None, + ) -> TemplateRef: + """ + Slice template ref based on the given start and stop. + """ + # @NOTE: A start interpolation must always be defined since start == None + # will be the first "part" which is a string (index=0). + if start and start.index % 2 != 0: + assert start.offset == 0, ( + "Interpolation part positions must always have offset 0." + ) + # @NOTE: A stop interpolation must always be defined since stop == None + # will be the last "part" which is a string (index=size - 1). + if stop and stop.index % 2 != 0: + assert stop.offset == 0, ( + "Interpolation part positions must always have offset 0." + ) + size = 2 * len(self.strings) - 1 + first = start.index if start and start.index is not None else 0 + assert 0 <= first < size + offset = start.offset if start else None + last = stop.index if stop and stop.index is not None else size - 1 + assert 0 <= last < size + limit = stop.offset if stop else None + + strings = [] + i_indexes = [] + if first == last: + if first % 2 == 0: + strings.append(self.strings[first // 2][offset:limit]) + else: + # offset == 0, so this is the equivalent of an empty interval + # therefore we should exclude this interpolation but + # template-ify with empty string. + strings.append("") + return TemplateRef(strings=tuple(strings), i_indexes=tuple(i_indexes)) + else: + if first % 2 == 0: + strings.append(self.strings[first // 2][offset:]) + else: + # offset == 0, so template-ify with empty string but start by + # including this interpolation. + strings.append("") + i_indexes.append((first - 1) // 2) + + for index in range(first + 1, last + 1): + if index % 2 == 0: + if index == last: + strings.append(self.strings[index // 2][:limit]) + else: + strings.append(self.strings[index // 2]) + else: + if index == last: + break # offset == 0, so exclude this interpolation. + else: + i_indexes.append((index - 1) // 2) + return TemplateRef(strings=tuple(strings), i_indexes=tuple(i_indexes)) + + +def slice_to_tref( + template: Template, + start: PartPosition | None = None, + stop: PartPosition | None = None, +) -> TemplateRef: + """ + Slice a template ref from a template based on the given start and stop. + """ + tref = TemplateRef( + strings=template.strings, i_indexes=tuple(range(len(template.strings) - 1)) + ) + return tref.slice(start=start, stop=stop) + + +@dataclass(slots=True, frozen=True) +class PartPosition: + """ + A unified template part position. + + Translate indexes into strings by multiplying by 2. + ie. 0->0, 1->2, 2->4, etc. + Reverse by dividing by 2. + + Translate indexes into interpolations by multiplying by 2 and then adding 1. + ie. 0->1, 1->3, 2->5, etc. + Reverse by subtracting 1 and dividing by 2. + + Using unified indexes allows for simpler iteration as well as starting + or stopping at either type of part more seamlessly. + """ + + index: int + " Index of the template parts, translate for strings/interpolations. " + + offset: int = 0 + " Offset from the start of the template part. " + + +def validate_part_position(part_pos: PartPosition) -> None: + """ + Basic part position validation for parts that are converted to template + source `LinePosition`. + + @TODO: This might move into the constructor eventually depending on usage. + """ + if part_pos.index % 2 != 0 and part_pos.offset != 0: + # You can only land on the start of an interpolation + raise ValueError( + "Invalid part position, interpolations are not divisible, offset must be 0." + ) + if not (part_pos.offset >= 0): + raise ValueError("Offset must always be positive or zero.") + if not (part_pos.index >= 0): + raise ValueError("Index must always be positive or zero.") diff --git a/tdom/template_utils_test.py b/tdom/template_utils_test.py index afce3615..07816135 100644 --- a/tdom/template_utils_test.py +++ b/tdom/template_utils_test.py @@ -2,7 +2,13 @@ import pytest -from .template_utils import TemplateRef, combine_template_refs, template_from_parts +from .template_utils import ( + PartPosition, + TemplateRef, + combine_template_refs, + slice_to_tref, + template_from_parts, +) def test_template_from_parts() -> None: @@ -42,55 +48,123 @@ def test_template_ref_post_init_validation() -> None: _ = TemplateRef(("Hello",), (0, 1)) -def test_combine_template_refs(): - template_refs = map( - TemplateRef.from_naive_template, - [ - t"ab", - t"c{0}d", - t"ef{1}", - t"{2}ghi", - ], - ) - assert combine_template_refs(*template_refs) == TemplateRef.from_naive_template( - t"abc{0}def{1}{2}ghi" - ) - - -def test_template_ref_iter_singleton(): - assert list(TemplateRef.from_naive_template(t"{1}")) == [1] - - -def test_template_ref_iter_empty(): - assert list(TemplateRef.from_naive_template(t"")) == [] - - -def test_template_ref_iter_empty_prefix(): - assert list(TemplateRef.from_naive_template(t"{1}def")) == [1, "def"] - - -def test_template_ref_iter_empty_suffix(): - assert list(TemplateRef.from_naive_template(t"abc{1}")) == ["abc", 1] - - -def test_template_ref_iter_literal(): - assert list(TemplateRef.from_naive_template(t"abc")) == ["abc"] - - -def test_template_ref_iter_only_interpolations(): - assert list(TemplateRef.from_naive_template(t"{1}{3}{5}")) == [1, 3, 5] - - -def test_template_ref_iter_complete(): - assert list(TemplateRef.from_naive_template(t"abc{1}def{3}ghi{5}jkl")) == [ - "abc", - 1, - "def", - 3, - "ghi", - 5, - "jkl", - ] +class TestCombineTemplateRefs: + def test_general_case(self): + template_refs = map( + TemplateRef.from_naive_template, + [ + t"ab", + t"c{100}d{0}e", + t"f{200}", + t"{300}ghi", + ], + ) + tref = combine_template_refs(*template_refs) + assert tref.strings == ("abc", "d", "ef", "", "ghi") and tref.i_indexes == ( + 100, + 0, + 200, + 300, + ) + + def test_strings(self): + trefs = [ + TemplateRef(strings=(s,), i_indexes=()) for s in ["ab", "", "cdef", "g", ""] + ] + tref = combine_template_refs(*trefs) + assert tref.strings == ("abcdefg",) and tref.i_indexes == () + + def test_indexes(self): + trefs = [TemplateRef(strings=("", ""), i_indexes=(i,)) for i in (100, 200, 300)] + tref = combine_template_refs(*trefs) + assert tref.strings == ("", "", "", "") and tref.i_indexes == (100, 200, 300) + + def test_null(self): + assert combine_template_refs() == TemplateRef.empty() + + +class TestTRefIter: + "Tests for TemplateRef.__iter__." + + def test_template_ref_iter_singleton(self): + assert list(TemplateRef.from_naive_template(t"{1}")) == [1] + + def test_template_ref_iter_empty(self): + assert list(TemplateRef.from_naive_template(t"")) == [] + + def test_template_ref_iter_empty_prefix(self): + assert list(TemplateRef.from_naive_template(t"{1}def")) == [1, "def"] + + def test_template_ref_iter_empty_suffix(self): + assert list(TemplateRef.from_naive_template(t"abc{1}")) == ["abc", 1] + + def test_template_ref_iter_literal(self): + assert list(TemplateRef.from_naive_template(t"abc")) == ["abc"] + + def test_template_ref_iter_only_interpolations(self): + assert list(TemplateRef.from_naive_template(t"{1}{3}{5}")) == [1, 3, 5] + + def test_template_ref_iter_complete(self): + assert list(TemplateRef.from_naive_template(t"abc{1}def{3}ghi{5}jkl")) == [ + "abc", + 1, + "def", + 3, + "ghi", + 5, + "jkl", + ] + + +class TestTRefPartsIter: + "Tests for TemplateRef.parts_iter." + + def test_singleton(self): + assert list(TemplateRef.from_naive_template(t"{1}").parts_iter()) == ["", 1, ""] + + def test_empty(self): + assert list(TemplateRef.from_naive_template(t"").parts_iter()) == [""] + + def test_empty_prefix(self): + assert list(TemplateRef.from_naive_template(t"{1}def").parts_iter()) == [ + "", + 1, + "def", + ] + + def test_empty_suffix(self): + assert list(TemplateRef.from_naive_template(t"abc{1}").parts_iter()) == [ + "abc", + 1, + "", + ] + + def test_literal(self): + assert list(TemplateRef.from_naive_template(t"abc").parts_iter()) == ["abc"] + + def test_only_interpolations(self): + assert list(TemplateRef.from_naive_template(t"{1}{3}{5}").parts_iter()) == [ + "", + 1, + "", + 3, + "", + 5, + "", + ] + + def test_complete(self): + assert list( + TemplateRef.from_naive_template(t"abc{1}def{3}ghi{5}jkl").parts_iter() + ) == [ + "abc", + 1, + "def", + 3, + "ghi", + 5, + "jkl", + ] def test_template_ref_resolve(): @@ -101,3 +175,115 @@ def test_template_ref_resolve(): resolved_t = src_ref.resolve(src_t.interpolations) assert resolved_t.values == ("a", "c", "e") assert resolved_t.strings == ("", "b", "d", "f") + + +class TestSliceToTRef: + def test_string_only_stop(self): + parts = list( + slice_to_tref( + t"
", start=None, stop=PartPosition(index=0, offset=5) + ) + ) + assert parts == ["
"] + + def test_string_only_start(self): + parts = list( + slice_to_tref(t"
", start=PartPosition(index=0, offset=5)) + ) + assert parts == ["
"] + + def test_string_only_start_stop(self): + parts = list( + slice_to_tref( + t"
", + start=PartPosition(index=0, offset=4), + stop=PartPosition(index=0, offset=6), + ) + ) + assert parts == ["><"] + + def test_single_interpolation_stop(self): + parts = slice_to_tref( + t"
{0}
", start=None, stop=PartPosition(index=1, offset=0) + ) + assert list(parts) == ["
"] + + def test_single_interpolation_start(self): + parts = slice_to_tref(t"
{0}
", start=PartPosition(index=1, offset=0)) + assert list(parts) == [0, "
"] + + def test_end_after_interpolation(self): + parts = list( + slice_to_tref( + t"
{0}
", start=None, stop=PartPosition(index=2, offset=0) + ) + ) + assert parts == ["
", 0] + + def test_newlines(self): + parts = list( + slice_to_tref( + t"
\n{0}
", start=None, stop=PartPosition(index=0, offset=5) + ) + ) + assert parts == ["
"] + parts = list( + slice_to_tref( + t"
\n{0}
", start=None, stop=PartPosition(index=0, offset=6) + ) + ) + assert parts == ["
\n"] + parts = list( + slice_to_tref( + t"
\n{0}
", start=None, stop=PartPosition(index=0, offset=7) + ) + ) + assert parts == ["
\n"] + parts = list( + slice_to_tref(t"
\n{0}
", start=PartPosition(index=0, offset=7)) + ) + assert parts == [0, "
"] + + def test_start_stop_same_string_is_empty(self): + assert slice_to_tref( + t"
{0}={1}
", + start=PartPosition(index=2, offset=0), + stop=PartPosition(index=2, offset=0), + ).is_empty + + def test_start_stop_same_interpolation_is_empty(self): + assert slice_to_tref( + t"
{0}={1}
", + start=PartPosition(index=3, offset=0), + stop=PartPosition(index=3, offset=0), + ).is_empty + + def test_start_stop_same_substring(self): + parts = list( + slice_to_tref( + t"
{0}={1}
", + start=PartPosition(index=0, offset=1), + stop=PartPosition(index=0, offset=4), + ) + ) + assert parts == ["div"] + + def test_start_stop_just_interpolation(self): + parts = list( + slice_to_tref( + t"
{0}={1}
", + start=PartPosition(index=1, offset=0), + stop=PartPosition(index=2, offset=0), + ) + ) + assert parts == [0] + + def test_start_stop_just_string(self): + parts = list( + slice_to_tref( + t"
{0}={1}
", + start=PartPosition(index=2, offset=0), + stop=PartPosition(index=3, offset=0), + ) + ) + assert parts == ["="] diff --git a/tdom/tnodes.py b/tdom/tnodes.py index 3afb1063..3e8392c8 100644 --- a/tdom/tnodes.py +++ b/tdom/tnodes.py @@ -1,7 +1,7 @@ import typing as t from dataclasses import dataclass, field -from .template_utils import TemplateRef +from .template_utils import PartPosition, TemplateRef @dataclass(slots=True, frozen=True) @@ -45,6 +45,8 @@ def __str__(self) -> str: class TText(TNode): ref: TemplateRef + source_pos: PartPosition | None = field(default=None, compare=False) + @classmethod def empty(cls) -> t.Self: return cls(TemplateRef.empty()) @@ -58,6 +60,8 @@ def literal(cls, text: str) -> t.Self: class TComment(TNode): ref: TemplateRef + source_pos: PartPosition | None = field(default=None, compare=False) + @classmethod def literal(cls, text: str) -> t.Self: return cls(TemplateRef.literal(text)) @@ -67,11 +71,15 @@ def literal(cls, text: str) -> t.Self: class TDocumentType(TNode): text: str + source_pos: PartPosition | None = field(default=None, compare=False) + @dataclass(slots=True, frozen=True) 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): @@ -79,6 +87,8 @@ class TElement(TNode): attrs: tuple[TAttribute, ...] = field(default_factory=tuple) children: tuple[TNode, ...] = field(default_factory=tuple) + source_pos: PartPosition | None = field(default=None, compare=False) + @dataclass(slots=True, frozen=True) class TComponent(TNode): @@ -95,5 +105,36 @@ class TComponent(TNode): attrs: tuple[TAttribute, ...] = field(default_factory=tuple) + source_pos: PartPosition | None = field(default=None, compare=False) + + +@dataclass(frozen=True, slots=True) +class TagSourceInfo: + """ + Retained tag information from the parsed source meant for error reporting. + + @NOTE: This must be cacheable so it should not directly reference a + template instance. + """ + + starttag_ref: TemplateRef + " Entire starttag as parsed except placeholders are replaced by references. " + startend: bool + " Was parsed as startend tag, ie. . " + starttag_pos: PartPosition + " Template part position of the starttag, ie. or . " + endtag_pos: PartPosition | None = None + " Template part position of the endtag, ie. . " + + +@dataclass +class TTree: + root: TNode + + sinfos: tuple[TagSourceInfo, ...] = () + + def unpack_sinfo_table(self) -> dict[PartPosition, TagSourceInfo]: + return {sinfo.starttag_pos: sinfo for sinfo in self.sinfos} + type TTag = TElement | TComponent | TFragment