Skip to content

Commit 032c7f8

Browse files
committed
Add Element to abstract away parsing HTML strings.
1 parent ec02a72 commit 032c7f8

1 file changed

Lines changed: 113 additions & 74 deletions

File tree

src/dj_angles/replacers/attributes.py

Lines changed: 113 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,68 @@
1616

1717

1818
@dataclass
19-
class ConditionalElement:
20-
"""Represents an element with a dj-if/elif/else attribute."""
19+
class Element:
20+
"""Represents an HTML element with a dj-* attribute."""
21+
22+
tag_name: str
23+
"""Name of the HTML tag, e.g. ``div`` or ``img``."""
24+
25+
tag_start: int
26+
"""Position of the ``<`` that starts the opening tag in the original HTML."""
27+
28+
tag_end: int
29+
"""Position just after the ``>`` that ends the opening tag."""
30+
31+
full_end: int
32+
"""Position just after the element's closing tag, or ``tag_end`` for void/self-closing tags."""
33+
34+
original_tag: str
35+
"""The original opening tag string, including the dj-* attribute."""
36+
37+
original_full: str
38+
"""The full original element, from the opening tag through the closing tag."""
39+
40+
attr_match: re.Match
41+
"""Regex match for the dj-* attribute that triggered this element's discovery."""
42+
43+
type: str
44+
"""The dj-* attribute type, e.g. ``if``, ``elif``, ``else``, or ``value``."""
45+
46+
value: str = ""
47+
"""The value of the dj-* attribute, e.g. the condition for ``dj-if`` or the variable for ``dj-value``."""
2148

22-
type: str # 'if', 'elif', 'else'
23-
condition: str # The condition value (empty for else)
24-
start_pos: int # Position of '<' in original HTML
25-
end_pos: int # Position after '>' of opening tag
26-
full_end_pos: int # Position after closing tag (full element)
27-
original_tag: str # The original opening tag
28-
original_full: str # The full original element with content
29-
attr_match: re.Match # The regex match for the attribute
49+
@property
50+
def is_closing(self) -> bool:
51+
"""Whether the element is a closing tag."""
52+
return self.original_tag.startswith("</")
53+
54+
def remove_attribute(self) -> str:
55+
"""Remove the dj-* attribute from the opening tag."""
56+
return _remove_attribute(self.original_tag, self.attr_match, self.tag_start)
57+
58+
def contains(self, other: "Element") -> bool:
59+
"""Whether this element fully contains another element."""
60+
return self.tag_start <= other.tag_start and other.full_end <= self.full_end
61+
62+
def closing_tag(self) -> str:
63+
"""Return the existing closing tag or generate one."""
64+
matches = list(re.finditer(rf"</{self.tag_name}\s*>", self.original_full, re.IGNORECASE))
65+
return matches[-1].group(0) if matches else f"</{self.tag_name}>"
66+
67+
68+
@dataclass
69+
class ConditionalElement(Element):
70+
"""Represents an element with a dj-if/elif/else attribute."""
3071

3172
# Chain linking (assigned during processing)
3273
chain_id: int = -1
3374
next_in_chain: Optional["ConditionalElement"] = None
3475

76+
@property
77+
def condition(self) -> str:
78+
"""The condition value of the attribute (empty for else)."""
79+
return self.value
80+
3581

3682
def replace_attributes(html: str) -> str:
3783
"""Convert dj-if/elif/else attributes to Django template tags.
@@ -91,30 +137,24 @@ def _find_conditional_elements(html: str, prefix: str) -> list[ConditionalElemen
91137
# Value is in named groups v1 (double), v2 (single), or v3 (unquoted)
92138
condition = match.group("v1") or match.group("v2") or match.group("v3") or ""
93139

94-
# Find the start of the containing tag
95-
tag_start = html.rfind("<", 0, match.start())
96-
97-
# Find the end of the opening tag
98-
tag_end = html.find(">", match.end()) + 1
99-
100-
# Find the element's full extent (including closing tag)
101-
full_end = _find_element_end(html, tag_start, tag_end)
140+
element = _find_element(html, match, attr_type)
102141

103142
elements.append(
104143
ConditionalElement(
105-
type=attr_type,
106-
condition=condition,
107-
start_pos=tag_start,
108-
end_pos=tag_end,
109-
full_end_pos=full_end,
110-
original_tag=html[tag_start:tag_end],
111-
original_full=html[tag_start:full_end],
144+
tag_name=element.tag_name,
145+
tag_start=element.tag_start,
146+
tag_end=element.tag_end,
147+
full_end=element.full_end,
148+
original_tag=element.original_tag,
149+
original_full=element.original_full,
112150
attr_match=match,
151+
type=attr_type,
152+
value=condition,
113153
)
114154
)
115155

116156
# Sort by position
117-
elements.sort(key=lambda e: e.start_pos)
157+
elements.sort(key=lambda e: e.tag_start)
118158

119159
return elements
120160

@@ -159,6 +199,36 @@ def _find_element_end(html: str, tag_start: int, tag_end: int) -> int:
159199
return pos
160200

161201

202+
def _find_element(html: str, match: re.Match, attr_type: str) -> Element:
203+
"""Find the HTML element containing a dj-* attribute match."""
204+
205+
# Find the start of the containing tag
206+
tag_start = html.rfind("<", 0, match.start())
207+
208+
# Find the end of the opening tag
209+
tag_end = html.find(">", match.end()) + 1
210+
211+
# Find the element's full extent (including closing tag)
212+
full_end = _find_element_end(html, tag_start, tag_end)
213+
214+
original_tag = html[tag_start:tag_end]
215+
original_full = html[tag_start:full_end]
216+
217+
tag_match = re.match(r"</?(\w+)", original_tag)
218+
tag_name = tag_match.group(1) if tag_match else ""
219+
220+
return Element(
221+
tag_name=tag_name,
222+
tag_start=tag_start,
223+
tag_end=tag_end,
224+
full_end=full_end,
225+
original_tag=original_tag,
226+
original_full=original_full,
227+
attr_match=match,
228+
type=attr_type,
229+
)
230+
231+
162232
def _link_chains(elements: list[ConditionalElement]) -> None:
163233
"""Link if-elif-else elements into chains based on sibling relationships.
164234
@@ -202,7 +272,7 @@ def _find_preceding_sibling(
202272

203273
# Key check: candidate's full element must END before this starts
204274
# This means they're siblings, not parent-child
205-
if candidate.full_end_pos <= elem.start_pos:
275+
if candidate.full_end <= elem.tag_start:
206276
# Additional check: candidate must not be inside another element
207277
# that our else is OUTSIDE of (meaning different hierarchy levels)
208278
is_nested_inside_other = False
@@ -212,7 +282,7 @@ def _find_preceding_sibling(
212282
continue
213283

214284
# If candidate is inside 'other', and elem is outside 'other'
215-
if other.start_pos < candidate.start_pos < other.full_end_pos and elem.start_pos >= other.full_end_pos:
285+
if other.tag_start < candidate.tag_start < other.full_end and elem.tag_start >= other.full_end:
216286
is_nested_inside_other = True
217287

218288
break
@@ -251,18 +321,18 @@ def _apply_atomic_edits(elements: list[ConditionalElement], html: str) -> str:
251321
continue
252322

253323
if start_tag:
254-
edits.append(AtomicEdit(position=elem.start_pos, content=start_tag))
324+
edits.append(AtomicEdit(position=elem.tag_start, content=start_tag))
255325

256326
# 2. Remove the attribute (replace with cleaned tag)
257-
new_tag = _remove_attribute(elem.original_tag, elem.attr_match, elem.start_pos)
258-
edits.append(AtomicEdit(position=elem.start_pos, content=new_tag, is_insert=False, end_position=elem.end_pos))
327+
new_tag = _remove_attribute(elem.original_tag, elem.attr_match, elem.tag_start)
328+
edits.append(AtomicEdit(position=elem.tag_start, content=new_tag, is_insert=False, end_position=elem.tag_end))
259329

260330
# 3. Insert endif if needed
261331
# Logic: If no next element in chain, AND it's a structural tag (if/elif/else), add endif.
262332
should_add_endif = elem.next_in_chain is None and elem.type in ("if", "elif", "else")
263333

264334
if should_add_endif:
265-
edits.append(AtomicEdit(position=elem.full_end_pos, content="{% endif %}"))
335+
edits.append(AtomicEdit(position=elem.full_end, content="{% endif %}"))
266336

267337
return apply_edits(html, edits)
268338

@@ -301,7 +371,7 @@ def replace_values(html: str) -> str:
301371

302372
attr_pattern = rf"\s({prefix}value)(?:=(?:\"(?P<v1>[^\"]*)\"|" + r"'(?P<v2>[^']*)'" + r"|(?P<v3>[^\s>]+)))?"
303373

304-
elements = []
374+
elements: list[Element] = []
305375

306376
for match in re.finditer(attr_pattern, html):
307377
condition = match.group("v1") or match.group("v2") or match.group("v3") or ""
@@ -310,71 +380,40 @@ def replace_values(html: str) -> str:
310380
attr_name = match.group(1)
311381
raise AssertionError(f"{attr_name} attribute must have a value")
312382

313-
# Find the start of the containing tag
314-
tag_start = html.rfind("<", 0, match.start())
315-
316-
# Find the end of the opening tag
317-
tag_end = html.find(">", match.end()) + 1
318-
319-
# Find the element's full extent (including closing tag)
320-
full_end = _find_element_end(html, tag_start, tag_end)
321-
322-
original_tag = html[tag_start:tag_end]
323-
original_full = html[tag_start:full_end]
383+
element = _find_element(html, match, "value")
324384

325385
# dj-value is only valid on opening tags
326-
if original_tag.startswith("</"):
386+
if element.is_closing:
327387
attr_name = match.group(1)
328388
raise AssertionError(f"Invalid use of {attr_name} attribute on closing tag")
329389

330-
elements.append(
331-
{
332-
"match": match,
333-
"tag_start": tag_start,
334-
"tag_end": tag_end,
335-
"full_end": full_end,
336-
"original_tag": original_tag,
337-
"original_full": original_full,
338-
"condition": condition,
339-
}
340-
)
390+
element.value = condition
391+
elements.append(element)
341392

342393
# Sort by position so outermost elements are processed first
343-
elements.sort(key=lambda e: e["tag_start"])
394+
elements.sort(key=lambda e: e.tag_start)
344395

345396
edits: list[AtomicEdit] = []
346397

347398
for elem in elements:
348399
# Skip elements that are nested inside another dj-value element
349-
if any(
350-
other["tag_start"] <= elem["tag_start"] and other["full_end"] >= elem["full_end"]
351-
for other in elements
352-
if other is not elem
353-
):
400+
if any(other.contains(elem) for other in elements if other is not elem):
354401
continue
355402

356403
# Remove the dj-value attribute from the opening tag
357-
cleaned_tag = _remove_attribute(elem["original_tag"], elem["match"], elem["tag_start"])
404+
cleaned_tag = elem.remove_attribute()
358405

359406
# Turn self-closing tags into regular tags so they can have inner content
360407
cleaned_tag = re.sub(r"\s*/>$", ">", cleaned_tag)
361408

362-
# Get the tag name
363-
tag_match = re.match(r"<(\w+)", cleaned_tag)
364-
tag_name = tag_match.group(1) if tag_match else ""
365-
366-
# Find the existing closing tag, or generate one
367-
closing_tag_matches = list(re.finditer(rf"</{tag_name}\s*>", elem["original_full"], re.IGNORECASE))
368-
closing_tag = closing_tag_matches[-1].group(0) if closing_tag_matches else f"</{tag_name}>"
369-
370-
replacement = f"{cleaned_tag}{{{{ {elem['condition']} }}}}{closing_tag}"
409+
replacement = f"{cleaned_tag}{{{{ {elem.value} }}}}{elem.closing_tag()}"
371410

372411
edits.append(
373412
AtomicEdit(
374-
position=elem["tag_start"],
413+
position=elem.tag_start,
375414
content=replacement,
376415
is_insert=False,
377-
end_position=elem["full_end"],
416+
end_position=elem.full_end,
378417
)
379418
)
380419

0 commit comments

Comments
 (0)