odin-html

HTML Parsing library in Odin.
Log | Files | Refs | README | LICENSE

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:
Mparse.odin | 483++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Aparse_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) +}