commit 8a232ba64047f76d21d01eafa423676a66332167
parent c4d049f3a49c55ef53fc2fa3b14673ef6a254693
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Thu, 23 Jul 2026 21:31:22 -0300
parse: build dod style html document
Diffstat:
| M | parse.odin | | | 483 | ++++++++++++++++++++++++++++++++++++++++++++++++------------------------------- |
| A | parse_test.odin | | | 370 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
2 files changed, 667 insertions(+), 186 deletions(-)
diff --git a/parse.odin b/parse.odin
@@ -1,37 +1,139 @@
-#+feature dynamic-literals
-
package html
/*
- This parsing logic DOES NOT attempt to validate the DOM object model;
- <head> and <body> tags are not generated, nor are tables validated.
+ The parser builds an immutable, DOD-stored document tree from a token
+ stream produced by the lexer.
+
+ Nodes are appended to a #soa[dynamic]Node arena and attributes to a flat
+ [dynamic]Attribute array. On return the arenas are frozen to slices.
- Instead this logic gives a tree that literally describes the tags provided,
- with the exception that the root tag is always <html>.
+ The algorithm is recursive descent. It is forgiving: malformed input
+ produces a best-effort tree rather than an error.
- The parsing algorithm is a simple recursive descent.
+ Spec-awareness is limited to what improves real-world results:
+ - void elements (br, img, ...) are treated as self-closing
+ - raw-text elements (script, style, textarea, title) scan to their
+ matching close tag without tokenizing the body
+ - comments become Comment nodes
*/
import "core:log"
import "core:strings"
-import "core:testing"
-parse :: proc(text: string) -> Document {
+// parse tokenizes and parses `text` into an immutable Document.
+parse :: proc(text: string, allocator := context.allocator) -> Document {
+ b: Builder
+ b.nodes = make(#soa[dynamic]Node, 0, 64, allocator)
+ b.attrs = make([dynamic]Attribute, 0, 16, allocator)
+ b.source = strings.clone(text, allocator) or_else ""
+
l: Lexer
- l.source = text
- return _parse_document(&l)
+ l.source = b.source
+
+ parse_document(&b, &l)
+
+ doc: Document
+ doc.source = b.source
+ doc.nodes = b.nodes[:]
+ doc.attrs = b.attrs[:]
+ doc.preamble = b.preamble
+ return doc
}
+// Void elements never have children even without a trailing slash.
@(private = "file")
-_parse_document :: proc(l: ^Lexer) -> (doc: Document) {
- doc.preamble = _parse_preamble(l)
- doc.root.name = "html"
- doc.root.children = _parse_children(l)
- return
+is_void_element :: proc(name: string) -> bool {
+ switch name {
+ case "area",
+ "base",
+ "br",
+ "col",
+ "embed",
+ "hr",
+ "img",
+ "input",
+ "link",
+ "meta",
+ "param",
+ "source",
+ "track",
+ "wbr":
+ return true
+ }
+ return false
+}
+
+// Raw-text elements: their content is scanned verbatim to the matching
+// close tag, not tokenized as HTML.
+@(private = "file")
+is_raw_text_element :: proc(name: string) -> bool {
+ switch name {
+ case "script", "style", "textarea", "title":
+ return true
+ }
+ return false
+}
+
+
+@(private = "file")
+Builder :: struct {
+ nodes: #soa[dynamic]Node,
+ attrs: [dynamic]Attribute,
+ source: string,
+ preamble: string,
+}
+
+// alloc_node appends a node and returns its index, then wires parent links.
+@(private = "file")
+alloc_node :: proc(b: ^Builder, n: Node, parent: Node_Ref) -> Node_Ref {
+ node := n
+ node.parent = parent
+ node.first_child = -1
+ node.last_child = -1
+ node.next_sibling = -1
+ node.prev_sibling = -1
+ node.attrs_offset = -1
+ append(&b.nodes, node)
+ idx := len(b.nodes) - 1
+
+ // Wire sibling / child links on the parent.
+ if parent >= 0 {
+ if b.nodes.first_child[parent] == -1 {
+ b.nodes.first_child[parent] = idx
+ } else {
+ prev := b.nodes.last_child[parent]
+ b.nodes.next_sibling[prev] = idx
+ b.nodes.prev_sibling[idx] = prev
+ }
+ b.nodes.last_child[parent] = idx
+ b.nodes.child_count[parent] += 1
+ }
+ return idx
+}
+
+// parse_document creates the root html node and parses its children.
+@(private = "file")
+parse_document :: proc(b: ^Builder, l: ^Lexer) {
+ b.preamble = parse_preamble(l)
+
+ // Root is always <html>.
+ root_idx := alloc_node(b, Node{kind = .Element, name = "html"}, -1)
+
+ // If the first token is an explicit <html>, consume its open tag so we
+ // don't nest it under the synthetic root.
+ if t, ok := lexer_peek_token(l); ok && token_kind(t) == .Open_Tag_Start {
+ if token_lookup(t, l.source) == "html" {
+ lexer_next(l) // consume Open_Tag_Start
+ parse_attributes(b, l, root_idx)
+ }
+ }
+
+ parse_children(b, l, root_idx)
}
+// parse_preamble consumes any leading text/doctype before the first tag.
@(private = "file")
-_parse_preamble :: proc(l: ^Lexer) -> (s: string) {
+parse_preamble :: proc(l: ^Lexer) -> (s: string) {
for {
t, ok := lexer_peek_token(l)
if !ok {
@@ -46,234 +148,243 @@ _parse_preamble :: proc(l: ^Lexer) -> (s: string) {
return
}
+// parse_attributes parses attributes into the node at `parent_idx` until Tag_End
+// or Self_Closing_Tag_End. Returns true if the tag was self-closing.
@(private = "file")
-_parse_attributes :: proc(l: ^Lexer) -> (attrs: [dynamic]Attribute) {
+parse_attributes :: proc(b: ^Builder, l: ^Lexer, parent_idx: Node_Ref) -> (self_closing: bool) {
+ attrs_start := len(b.attrs)
+
for token in lexer_next(l) {
#partial switch token_kind(token) {
case .Tag_End:
- return
+ set_attrs(b, parent_idx, attrs_start)
+ return false
+ case .Self_Closing_Tag_End:
+ set_attrs(b, parent_idx, attrs_start)
+ return true
case .Raw_Text:
- // Raw_Text might contain leading whitespace, and might contain several
- // space-delimited boolean attributes.
source := token_lookup(token, l.source)
for field in strings.fields_iterator(&source) {
- append(&attrs, Attribute{name = field})
+ append(&b.attrs, Attribute{name = field})
}
case .Assign:
- token, ok := lexer_next(l)
+ t, ok := lexer_next(l)
if !ok {
- return
+ set_attrs(b, parent_idx, attrs_start)
+ return false
}
- #partial switch token_kind(token) {
+ #partial switch token_kind(t) {
case .Double_Quote_String_Literal, .Single_Quote_String_Literal:
- if len(attrs) == 0 {
+ if len(b.attrs) == 0 || len(b.attrs) <= attrs_start {
log.errorf(
"skipping unexpected string literal (missing applicable attribute): %q",
- token_lookup(token, l.source),
+ token_lookup(t, l.source),
)
continue
}
- attrs[len(attrs) - 1].value = token_lookup(token, l.source)
+ // Strip the surrounding quotes.
+ val := token_lookup(t, l.source)
+ if len(val) >= 2 {
+ val = val[1:len(val) - 1]
+ }
+ b.attrs[len(b.attrs) - 1].value = val
case:
log.errorf("expecting string literal after assign")
continue
}
}
}
- return
+
+ count := len(b.attrs) - attrs_start
+ if count > 0 {
+ b.nodes.attrs_offset[parent_idx] = attrs_start
+ b.nodes.attrs_count[parent_idx] = u16(count)
+ }
+ return false
+}
+
+// set_attrs records the attribute offset/count on the node.
+@(private = "file")
+set_attrs :: proc(b: ^Builder, parent_idx: Node_Ref, attrs_start: Attribute_Ref) {
+ count := len(b.attrs) - attrs_start
+ if count > 0 {
+ b.nodes.attrs_offset[parent_idx] = attrs_start
+ b.nodes.attrs_count[parent_idx] = u16(count)
+ }
}
+// parse_children parses nodes into `parent_idx` until EOF or Close_Tag_Start.
@(private = "file")
-_parse_children :: proc(l: ^Lexer) -> (ch: [dynamic]Node) {
+parse_children :: proc(b: ^Builder, l: ^Lexer, parent_idx: Node_Ref) {
for token in lexer_next(l) {
#partial switch token_kind(token) {
case .Raw_Text:
- append(&ch, Node_Text{text = token_lookup(token, l.source)})
+ text := token_lookup(token, l.source)
+ alloc_node(b, Node{kind = .Text, text = text}, parent_idx)
+
+ case .Comment_Start:
+ // Consume comment body until Comment_End.
+ body := parse_comment_body(b, l)
+ alloc_node(b, Node{kind = .Comment, text = body}, parent_idx)
+
+ case .Doctype:
+ // Doctype is recorded as a child node.
+ alloc_node(b, Node{kind = .Doctype, text = token_lookup(token, l.source)}, parent_idx)
+
case .Open_Tag_Start:
- node: Node_Tag
- node.name = token_lookup(token, l.source)
- node.attributes = _parse_attributes(l)
- if node.name == "html" {
+ name := token_lookup(token, l.source)
+
+ // Explicit </html> or close of parent: the close tag will be handled below.
+ // Create the element node.
+ elem_idx := alloc_node(b, Node{kind = .Element, name = name}, parent_idx)
+
+ // Parse attributes (consumes through Tag_End or Self_Closing_Tag_End).
+ self_closing := parse_attributes(b, l, elem_idx)
+
+ // Void elements and self-closing tags have no children.
+ if is_void_element(name) || self_closing {
continue
}
- node.children = _parse_children(l)
- append(&ch, node)
+
+ // Raw-text elements: scan verbatim to matching close tag.
+ if is_raw_text_element(name) {
+ parse_raw_text_child(b, l, elem_idx, name)
+ continue
+ }
+
+ // Recurse into children.
+ parse_children(b, l, elem_idx)
+
case .Close_Tag_Start:
if _, ok := lexer_expect(l, .Tag_End); !ok {
- log.errorf("expected tag end after tag identifier")
- return
+ log.errorf("expected tag end after close tag identifier")
}
return
+
+ case .Tag_End, .Self_Closing_Tag_End:
+ // Stray tag-end tokens outside attributes; ignore.
case:
- log.errorf("unexpected token while parsing text and nodes: %v", token)
+ log.errorf("unexpected token while parsing children: %v", token)
}
}
- return
-}
-
-@(test)
-test_parse_doctype :: proc(t: ^testing.T) {
- text := "<!DOCTYPE html>"
-
- want := []Node{Node_Tag{name = "html"}}
-
- _test_parse(t, text, want)
}
-@(test)
-test_parse_doctype_2 :: proc(t: ^testing.T) {
- text := "<!DOCTYPE html><html><body><div>foo</div></body></html>"
+// parse_comment_body consumes tokens until Comment_End, returning the body text.
+@(private = "file")
+parse_comment_body :: proc(b: ^Builder, l: ^Lexer) -> string {
+ parts: [dynamic]string
+ defer delete(parts)
- want := []Node {
- Node_Tag{name = "html"},
- Node_Tag{name = "body"},
- Node_Tag{name = "div"},
- Node_Text{text = "foo"},
+ for token in lexer_next(l) {
+ #partial switch token_kind(token) {
+ case .Comment_End:
+ if len(parts) == 0 {
+ return ""
+ }
+ if len(parts) == 1 {
+ return parts[0]
+ }
+ return strings.join(parts[:], "")
+ case .Raw_Text:
+ append(&parts, token_lookup(token, l.source))
+ }
}
- _test_parse(t, text, want)
-}
-
-@(test)
-test_parse_single_tag :: proc(t: ^testing.T) {
- text := "<button />"
-
- want := []Node{Node_Tag{name = "html"}, Node_Tag{name = "button"}}
-
- _test_parse(t, text, want)
-}
-
-@(test)
-test_parse_single_tag_with_attribute :: proc(t: ^testing.T) {
- text := `<button class=".style"/>`
-
- want := []Node {
- Node_Tag{name = "html"},
- Node_Tag {
- name = "button",
- attributes = [dynamic]Attribute{{name = "class", value = ".style"}},
- },
+ // EOF without Comment_End; return whatever we collected.
+ if len(parts) == 0 {
+ return ""
}
-
- _test_parse(t, text, want)
-}
-
-@(test)
-test_parse_single_tag_with_attributes :: proc(t: ^testing.T) {
- text := `<button class=".style" attribute foo="bar" data-form/>`
-
- want := []Node {
- Node_Tag{name = "html"},
- Node_Tag {
- name = "button",
- attributes = [dynamic]Attribute {
- {name = "class", value = ".style"},
- {name = "attribute", value = ""},
- {name = "foo", value = "bar"},
- {name = "data-form", value = ""},
- },
- },
+ if len(parts) == 1 {
+ return parts[0]
}
-
- _test_parse(t, text, want)
+ return strings.join(parts[:], "")
}
-@(test)
-test_parse_nested_tags :: proc(t: ^testing.T) {
- text := "<ul><li>one</li><li>two</li><li>three</li></ul>"
-
- want := []Node {
- Node_Tag{name = "html"},
- Node_Tag{name = "ul"},
- Node_Tag{name = "li"},
- Node_Text{text = "one"},
- Node_Tag{name = "li"},
- Node_Text{text = "two"},
- Node_Tag{name = "li"},
- Node_Text{text = "three"},
+// parse_raw_text_child scans the source from the current cursor for the
+// matching close tag </name>, creating a single Text child.
+@(private = "file")
+parse_raw_text_child :: proc(b: ^Builder, l: ^Lexer, parent_idx: Node_Ref, name: string) {
+ // The lexer cursor is positioned right after the open tag's Tag_End.
+ // We scan the raw source for </name>.
+ src := l.source
+ start := l.cursor
+
+ // Search case-insensitively for </name.
+ idx := find_close_tag(src, int(start), name)
+ if idx < 0 {
+ // No close tag found; consume rest as text.
+ text := src[start:]
+ if len(text) > 0 {
+ alloc_node(b, Node{kind = .Text, text = text}, parent_idx)
+ }
+ // Advance lexer to EOF.
+ l.cursor = u32(len(src))
+ return
}
- _test_parse(t, text, want)
-}
-
-@(test)
-test_parse_raw_text :: proc(t: ^testing.T) {
- text := "<ul><li> one < two < three </li></ul>"
-
- want := []Node {
- Node_Tag{name = "html"},
- Node_Tag{name = "ul"},
- Node_Tag{name = "li"},
- Node_Text{text = " one < two < three "},
+ text := src[start:idx]
+ if len(text) > 0 {
+ alloc_node(b, Node{kind = .Text, text = text}, parent_idx)
}
- _test_parse(t, text, want)
-}
-
-@(test)
-test_parse_invalid_tag :: proc(t: ^testing.T) {
- text := "<ul><li> one <two three </li></ul>"
-
- want := []Node {
- Node_Tag{name = "html"},
- Node_Tag{name = "ul"},
- Node_Tag{name = "li"},
- Node_Text{text = " one "},
- Node_Tag{name = "two"},
- Node_Text{text = " three "},
+ // Advance the lexer cursor past </name> and the closing >.
+ // idx points at '<' of </name>.
+ close_end := idx + 2 + len(name)
+ // Skip optional whitespace.
+ for close_end < len(src) &&
+ (src[close_end] == ' ' ||
+ src[close_end] == '\t' ||
+ src[close_end] == '\n' ||
+ src[close_end] == '\r') {
+ close_end += 1
}
-
- _test_parse(t, text, want)
-}
-
-@(test)
-test_parse_explicit_html_tag :: proc(t: ^testing.T) {
- text := "<html><ul><li>one</li><li>two</li><li>three</li></ul></html>"
-
- want := []Node {
- Node_Tag{name = "html"},
- Node_Tag{name = "ul"},
- Node_Tag{name = "li"},
- Node_Text{text = "one"},
- Node_Tag{name = "li"},
- Node_Text{text = "two"},
- Node_Tag{name = "li"},
- Node_Text{text = "three"},
+ if close_end < len(src) && src[close_end] == '>' {
+ close_end += 1
}
-
- _test_parse(t, text, want)
+ l.cursor = u32(close_end)
}
-_test_parse :: proc(t: ^testing.T, text: string, want: []Node) {
- defer free_all()
-
- doc := parse(text)
-
- it := node_iterator_from_document(doc)
-
- ii: int
- for node in node_iterator_depth_first(&it) {
- defer ii += 1
- if ii >= len(want) {
- continue
- }
- want := want[ii]
- switch node in node {
- case Node_Tag:
- #partial switch want in want {
- case Node_Tag:
- testing.expect_value(t, node.name, want.name)
- case:
- log.errorf("got %q, want %q", node_to_string(node), node_to_string(want))
+// find_close_tag searches src from `from` for a case-insensitive </name>
+// followed by optional whitespace and '>'. Returns the index of '<' or -1.
+@(private = "file")
+find_close_tag :: proc(src: string, from: int, name: string) -> int {
+ close := "</"
+ n := len(src)
+ name_len := len(name)
+ i := from
+ for i + 2 + name_len <= n {
+ if src[i] == '<' && src[i + 1] == '/' {
+ // Check name (case-insensitive).
+ match := true
+ for k in 0 ..< name_len {
+ if to_lower(src[i + 2 + k]) != to_lower(name[k]) {
+ match = false
+ break
+ }
}
- case Node_Text:
- #partial switch want in want {
- case Node_Text:
- testing.expect_value(t, node.text, want.text)
- case:
- log.errorf("got %q, want %q", node_to_string(node), node_to_string(want))
+ if match {
+ // Must be followed by whitespace or '>'.
+ after := i + 2 + name_len
+ if after < n &&
+ (src[after] == '>' ||
+ src[after] == ' ' ||
+ src[after] == '\t' ||
+ src[after] == '\n' ||
+ src[after] == '\r') {
+ return i
+ }
}
}
+ i += 1
+ }
+ return -1
+}
+
+@(private = "file")
+to_lower :: proc(b: byte) -> byte {
+ if b >= 'A' && b <= 'Z' {
+ return b + 32
}
+ return b
}
diff --git a/parse_test.odin b/parse_test.odin
@@ -0,0 +1,370 @@
+package html
+
+import "core:testing"
+
+// Expected describes a node in the tree for test assertions.
+Expected :: struct {
+ kind: Node_Kind,
+ name: string, // for Element
+ text: string, // for Text/Comment/Doctype
+ attrs: []Attr_Expect, // optional
+}
+
+Attr_Expect :: struct {
+ name: string,
+ value: string,
+}
+
+// collect_dfs collects node indices in depth-first pre-order starting at `root`.
+collect_dfs :: proc(doc: ^Document, root: Node_Ref) -> []Node_Ref {
+ out: [dynamic]Node_Ref
+ defer delete(out)
+ visit_dfs(doc, root, &out)
+ return out[:]
+}
+
+visit_dfs :: proc(doc: ^Document, i: Node_Ref, out: ^[dynamic]Node_Ref) {
+ append(out, i)
+ child := doc.nodes.first_child[i]
+ for child != -1 {
+ visit_dfs(doc, child, out)
+ child = doc.nodes.next_sibling[child]
+ }
+}
+
+// assert_dfs walks the document depth-first from root (index 0) and asserts
+// each node matches the `want` slice in order.
+assert_dfs :: proc(t: ^testing.T, doc: ^Document, want: []Expected) {
+ order := collect_dfs(doc, 0)
+
+ for i, node_idx in order {
+ if i >= len(want) {
+ testing.expectf(t, false, "got %d nodes, expected %d", len(order), len(want))
+ return
+ }
+ w := want[i]
+ k := doc.nodes.kind[node_idx]
+
+ testing.expect_value(t, k, w.kind)
+ if k == .Element {
+ testing.expect_value(t, doc.nodes.name[node_idx], w.name)
+ } else {
+ testing.expect_value(t, doc.nodes.text[node_idx], w.text)
+ }
+
+ for attr in w.attrs {
+ val, ok := node_attr(doc, node_idx, attr.name)
+ testing.expect(t, ok)
+ testing.expect_value(t, val, attr.value)
+ }
+ }
+
+ if len(order) != len(want) {
+ testing.expectf(t, false, "got %d nodes, expected %d", len(order), len(want))
+ }
+}
+
+// Port of the original parser tests, adapted to the DOD arena model.
+// Each test parses input and asserts the depth-first node order.
+
+@(test)
+test_parse_doctype :: proc(t: ^testing.T) {
+ text := "<!DOCTYPE html>"
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+}
+
+@(test)
+test_parse_doctype_2 :: proc(t: ^testing.T) {
+ text := "<!DOCTYPE html><html><body><div>foo</div></body></html>"
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ {kind = .Element, name = "body"},
+ {kind = .Element, name = "div"},
+ {kind = .Text, text = "foo"},
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+}
+
+@(test)
+test_parse_single_tag :: proc(t: ^testing.T) {
+ text := "<button />"
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ {kind = .Element, name = "button"},
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+}
+
+@(test)
+test_parse_single_tag_with_attribute :: proc(t: ^testing.T) {
+ text := `<button class=".style"/>`
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ {
+ kind = .Element, name = "button",
+ attrs = {
+ {name = "class", value = ".style"},
+ },
+ },
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+}
+
+@(test)
+test_parse_single_tag_with_attributes :: proc(t: ^testing.T) {
+ text := `<button class=".style" attribute foo="bar" data-form/>`
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ {
+ kind = .Element, name = "button",
+ attrs = {
+ {name = "class", value = ".style"},
+ {name = "attribute", value = ""},
+ {name = "foo", value = "bar"},
+ {name = "data-form", value = ""},
+ },
+ },
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+}
+
+@(test)
+test_parse_nested_tags :: proc(t: ^testing.T) {
+ text := "<ul><li>one</li><li>two</li><li>three</li></ul>"
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ {kind = .Element, name = "ul"},
+ {kind = .Element, name = "li"},
+ {kind = .Text, text = "one"},
+ {kind = .Element, name = "li"},
+ {kind = .Text, text = "two"},
+ {kind = .Element, name = "li"},
+ {kind = .Text, text = "three"},
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+}
+
+@(test)
+test_parse_raw_text :: proc(t: ^testing.T) {
+ text := "<ul><li> one < two < three </li></ul>"
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ {kind = .Element, name = "ul"},
+ {kind = .Element, name = "li"},
+ {kind = .Text, text = " one < two < three "},
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+}
+
+@(test)
+test_parse_invalid_tag :: proc(t: ^testing.T) {
+ text := "<ul><li> one <two three </li></ul>"
+ // <two three> is malformed: "three" becomes a boolean attribute on <two>.
+ // </li> is consumed by the attributes loop (best-effort recovery).
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ {kind = .Element, name = "ul"},
+ {kind = .Element, name = "li"},
+ {kind = .Text, text = " one "},
+ {kind = .Element, name = "two"},
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+}
+
+@(test)
+test_parse_explicit_html_tag :: proc(t: ^testing.T) {
+ text := "<html><ul><li>one</li><li>two</li><li>three</li></ul></html>"
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ {kind = .Element, name = "ul"},
+ {kind = .Element, name = "li"},
+ {kind = .Text, text = "one"},
+ {kind = .Element, name = "li"},
+ {kind = .Text, text = "two"},
+ {kind = .Element, name = "li"},
+ {kind = .Text, text = "three"},
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+}
+
+// --- new tests for DOD features ----------------------------------------
+
+@(test)
+test_parse_void_element :: proc(t: ^testing.T) {
+ text := "<div><br><p>hi</p></div>"
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ {kind = .Element, name = "div"},
+ {kind = .Element, name = "br"},
+ {kind = .Element, name = "p"},
+ {kind = .Text, text = "hi"},
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+
+ // br should have no children.
+ br_idx := doc.nodes.first_child[doc.nodes.first_child[0]]
+ testing.expect_value(t, doc.nodes.child_count[br_idx], 0)
+ testing.expect_value(t, doc.nodes.first_child[br_idx], -1)
+}
+
+@(test)
+test_parse_self_closing :: proc(t: ^testing.T) {
+ text := "<div><img src=\"x.png\"/></div>"
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ {kind = .Element, name = "div"},
+ {kind = .Element, name = "img"},
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+
+ // img should have no children and the src attribute.
+ div_idx := doc.nodes.first_child[0]
+ img_idx := doc.nodes.first_child[div_idx]
+ testing.expect_value(t, doc.nodes.child_count[img_idx], 0)
+
+ val, ok := node_attr(&doc, img_idx, "src")
+ testing.expect(t, ok)
+ testing.expect_value(t, val, "x.png")
+}
+
+@(test)
+test_parse_raw_text_element :: proc(t: ^testing.T) {
+ text := "<script>if (a < b) { c = \"<div>\"; }</script>"
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ {kind = .Element, name = "script"},
+ {kind = .Text, text = "if (a < b) { c = \"<div>\"; }"},
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+}
+
+@(test)
+test_parse_comment :: proc(t: ^testing.T) {
+ text := "<div><!-- hello --></div>"
+ want := []Expected{
+ {kind = .Element, name = "html"},
+ {kind = .Element, name = "div"},
+ {kind = .Comment, text = " hello "},
+ }
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+ assert_dfs(t, &doc, want)
+}
+
+@(test)
+test_parse_sibling_links :: proc(t: ^testing.T) {
+ text := "<ul><li>a</li><li>b</li><li>c</li></ul>"
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+
+ // root (0) -> ul (1) -> li(2), li(4), li(6)
+ ul_idx := doc.nodes.first_child[0]
+
+ first := doc.nodes.first_child[ul_idx]
+ testing.expect_value(t, doc.nodes.name[first], "li")
+
+ second := doc.nodes.next_sibling[first]
+ testing.expect_value(t, doc.nodes.name[second], "li")
+ testing.expect_value(t, doc.nodes.prev_sibling[second], first)
+
+ third := doc.nodes.next_sibling[second]
+ testing.expect_value(t, doc.nodes.name[third], "li")
+ testing.expect_value(t, doc.nodes.prev_sibling[third], second)
+ testing.expect_value(t, doc.nodes.next_sibling[third], -1)
+ testing.expect_value(t, doc.nodes.last_child[ul_idx], third)
+ testing.expect_value(t, doc.nodes.child_count[ul_idx], 3)
+}
+
+@(test)
+test_parse_parent_links :: proc(t: ^testing.T) {
+ text := "<div><p>hi</p></div>"
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+
+ // root(0) -> div(1) -> p(2) -> text(3)
+ div_idx := doc.nodes.first_child[0]
+ testing.expect_value(t, doc.nodes.parent[div_idx], 0)
+
+ p_idx := doc.nodes.first_child[div_idx]
+ testing.expect_value(t, doc.nodes.parent[p_idx], div_idx)
+
+ text_idx := doc.nodes.first_child[p_idx]
+ testing.expect_value(t, doc.nodes.parent[text_idx], p_idx)
+ testing.expect_value(t, doc.nodes.kind[text_idx], Node_Kind.Text)
+}
+
+@(test)
+test_parse_node_children :: proc(t: ^testing.T) {
+ text := "<ul><li>a</li><li>b</li></ul>"
+ defer free_all()
+
+ doc := parse(text)
+ defer document_delete(&doc)
+
+ ul_ref := doc.nodes.first_child[0]
+
+ it := node_children(&doc, ul_ref)
+ count := 0
+ for child in next(&it) {
+ count += 1
+ testing.expect_value(t, doc.nodes.name[child], "li")
+ }
+ testing.expect_value(t, count, 2)
+}