///|
pub(all) enum SexpNode {
Text(String)
Comment(String)
RawHtml(String)
Element(String, HtmlAttrs, Array[SexpNode])
}
///|
type NodeWithTrimBeforeAttr = (SexpNode, Bool)
///|
priv enum ParsedListForm {
Attr(String, String?)
Nodes(Array[NodeWithTrimBeforeAttr])
}
///|
priv enum ParsedHead {
CommentHead
RawTextHead
SpaceHead
SpacedElementHead(String)
AttributeHead(String)
ElementHead(String)
}
///|
/// Parses a full S-expression HTML fragment into normalized AST nodes.
pub fn parse_sexp_html(source : String) -> Result[Array[SexpNode], String] {
parse_document(Cursor::new(source))
}
///|
/// Parses top-level nodes, where attributes are not valid and whitespace only
/// separates neighboring terms.
fn parse_document(start : Cursor) -> Result[Array[SexpNode], String] {
let nodes : Array[SexpNode] = []
let mut cursor = start.skip_whitespace()
while !cursor.is_end() {
if cursor.peek().map_or(false, is_sexp_close_paren) {
return Err(cursor.error("unexpected ')'"))
}
match parse_node(cursor) {
Ok((parsed_nodes, next)) => {
append_node_values(nodes, parsed_nodes)
cursor = next.skip_whitespace()
}
Err(error) => return Err(error)
}
}
Ok(nodes)
}
///|
/// Parses one root/child item. Text nodes carry a trim flag so later attribute
/// forms can remove separator whitespace that belonged to syntax, not content.
fn parse_node(
cursor : Cursor,
) -> Result[(Array[(SexpNode, Bool)], Cursor), String] {
match cursor.peek() {
None => Err(cursor.error("unexpected end of input"))
Some(ch) if is_sexp_open_paren(ch) =>
parse_list_form(cursor).bind(item => {
match item {
(Nodes(nodes), next) => Ok((nodes, next))
(Attr(_, _), _) =>
Err(cursor.error("attribute cannot appear at the document root"))
}
})
Some(ch) if is_sexp_close_paren(ch) => Err(cursor.error("unexpected ')'"))
Some(_) => {
let (text, next) = parse_text(cursor)
Ok(([(Text(text), true)], next))
}
}
}
///|
/// Reads the list head and dispatches the rest of the list to the matching
/// payload parser.
fn parse_list_form(open : Cursor) -> Result[(ParsedListForm, Cursor), String] {
parse_list_form_head(open).bind(ParsedHead::parse_payload_pair)
}
///|
fn parse_list_form_head(open : Cursor) -> Result[(ParsedHead, Cursor), String] {
let head_start = open.advance_code_units(1).skip_whitespace()
match head_start.peek() {
None => return Err(open.error("unterminated list"))
Some(ch) if is_sexp_close_paren(ch) =>
return Err(open.error("empty list is invalid"))
_ => ()
}
let (head, after_head) = parse_head(head_start)
if head.is_empty() {
return Err(head_start.error("list head cannot be empty"))
}
ParsedHead::parse(head, after_head.index).map(parsed_head => {
(parsed_head, after_head)
})
}
///|
/// Classifies a head token into syntax forms: `%`, `#`, `~`, `~tag`, `:attr`, or element.
fn ParsedHead::parse(
head : String,
after_head : Int,
) -> Result[ParsedHead, String] {
match head {
"%" => Ok(CommentHead)
"#" => Ok(RawTextHead)
"~" => Ok(SpaceHead)
_ if head.has_prefix("~") =>
ParsedHead::parse_spaced_element(head, after_head)
_ if head.has_prefix(":") => ParsedHead::parse_attribute(head, after_head)
_ => Ok(ElementHead(head))
}
}
///|
fn ParsedHead::parse_spaced_element(
head : String,
after_head : Int,
) -> Result[ParsedHead, String] {
let tag = head.sub(start=1).to_owned()
if tag.is_empty() {
return Err(error_at(after_head, "empty ~ tag must use the exact form (~)"))
}
Ok(SpacedElementHead(tag))
}
///|
fn ParsedHead::parse_attribute(
head : String,
after_head : Int,
) -> Result[ParsedHead, String] {
let name = head.sub(start=1).to_owned()
if name.is_empty() {
return Err(error_at(after_head, "attribute name cannot be empty"))
}
Ok(AttributeHead(name))
}
///|
fn ParsedHead::parse_payload_pair(
data : (ParsedHead, Cursor),
) -> Result[(ParsedListForm, Cursor), String] {
data.0.parse_payload(data.1)
}
///|
fn ParsedHead::parse_payload(
head : ParsedHead,
after_head : Cursor,
) -> Result[(ParsedListForm, Cursor), String] {
match head {
CommentHead => parse_comment(after_head)
RawTextHead => parse_raw_text(after_head)
SpaceHead => parse_space(after_head)
SpacedElementHead(tag) => parse_element_with_spacing(after_head, tag, true)
AttributeHead(name) => parse_attribute(after_head, name)
ElementHead(tag) => parse_element(after_head, tag)
}
}
///|
/// Parses `(% payload)`. Separator whitespace after `%` is not comment content;
/// the payload parser then keeps balanced parentheses until the closing list paren.
fn parse_comment(
after_head : Cursor,
) -> Result[(ParsedListForm, Cursor), String] {
let start = after_head.skip_whitespace()
parse_comment_payload(start).map(result => {
let (content, next) = result
(Nodes([(Comment(content), false)]), next)
})
}
///|
/// Parses `(# payload)`. At most one whitespace char after `#` is syntax
/// separator; any further whitespace is literal raw text.
fn parse_raw_text(
after_head : Cursor,
) -> Result[(ParsedListForm, Cursor), String] {
let start = if after_head.peek().map_or(false, ch => ch.is_whitespace()) {
after_head.advance_code_units(1)
} else {
after_head
}
parse_raw_text_payload(start).map(result => {
let (content, next) = result
(Nodes([(Text(content), false)]), next)
})
}
///|
/// Parses `(~)`, the explicit single-space node. Other payload is rejected so
/// `~tag` remains the only spaced element form.
fn parse_space(after_head : Cursor) -> Result[(ParsedListForm, Cursor), String] {
let cursor = after_head.skip_whitespace()
match cursor.peek() {
None => Err(after_head.error("unterminated (~) form"))
Some(ch) if is_sexp_close_paren(ch) =>
Ok((Nodes([(Text(" "), false)]), cursor.advance_code_units(1)))
_ => Err(cursor.error("(~) cannot contain attributes or children"))
}
}
///|
/// Parses `(:name)` and `(:name value)`. Attribute values are plain text only;
/// nested list structure is intentionally rejected by the payload scanner.
fn parse_attribute(
after_head : Cursor,
name : String,
) -> Result[(ParsedListForm, Cursor), String] {
let cursor = after_head.skip_whitespace()
match cursor.peek() {
None => Err(after_head.error("unterminated attribute"))
Some(ch) if is_sexp_close_paren(ch) =>
Ok((Attr(name, None), cursor.advance_code_units(1)))
_ =>
parse_attribute_value_payload(cursor).map(result => {
let (value, next) = result
(Attr(name, Some(value)), next)
})
}
}
///|
fn parse_element(
after_head : Cursor,
tag : String,
) -> Result[(ParsedListForm, Cursor), String] {
parse_element_with_spacing(after_head, tag, false)
}
///|
/// Parses an element body, collecting attributes and children in source order.
/// Attribute forms may appear after children; the final AST stores attributes on
/// the element and trims separator text that preceded those late attributes.
fn parse_element_with_spacing(
after_head : Cursor,
tag : String,
spaced : Bool,
) -> Result[(ParsedListForm, Cursor), String] {
let attrs : HtmlAttrs = HtmlAttrs({})
let children : Array[(SexpNode, Bool)] = []
let mut cursor = after_head.skip_whitespace()
while !cursor.is_end() {
match cursor.peek() {
Some(ch) if is_sexp_close_paren(ch) =>
return build_element_result(tag, attrs, children, spaced, cursor)
Some(ch) if is_sexp_open_paren(ch) =>
match parse_element_list_item(cursor, attrs, children) {
Ok(next) => cursor = next.skip_whitespace()
Err(error) => return Err(error)
}
_ => cursor = parse_element_text(cursor, children).skip_whitespace()
}
}
Err(after_head.error("unterminated element <" + tag + ">"))
}
///|
/// Builds the element result after the closing paren, applying void-element and
/// `~tag` spacing rules at the boundary where all children are known.
fn build_element_result(
tag : String,
attrs : HtmlAttrs,
children : Array[(SexpNode, Bool)],
spaced : Bool,
close : Cursor,
) -> Result[(ParsedListForm, Cursor), String] {
if is_void_element_name(tag) && !children.is_empty() {
return Err(
close.error("void element <" + tag + "> cannot have child nodes"),
)
}
let element = Element(tag, attrs, strip_parse_flags(children))
guard spaced else {
Ok((Nodes([(element, false)]), close.advance_code_units(1)))
}
Ok(
(
Nodes([(Text(" "), false), (element, false), (Text(" "), false)]),
close.advance_code_units(1),
),
)
}
///|
/// Handles one parenthesized item inside an element, either merging an attribute
/// into the element attrs or appending child nodes.
fn parse_element_list_item(
cursor : Cursor,
attrs : HtmlAttrs,
children : Array[(SexpNode, Bool)],
) -> Result[Cursor, String] {
parse_list_form(cursor).bind(item => {
match item {
(Attr(name, value), next) => {
trim_trailing_text_before_attribute(children)
HtmlAttrs::upsert(attrs, name, value)
Ok(next)
}
(Nodes(nodes), next) => {
append_parsed_child_nodes(children, nodes)
Ok(next)
}
}
})
}
///|
fn parse_element_text(
cursor : Cursor,
children : Array[(SexpNode, Bool)],
) -> Cursor {
let (text, next) = parse_text(cursor)
if !text.is_empty() {
children.push((Text(text), true))
}
next
}
///|
/// Reads an unparenthesized head token up to whitespace or a list boundary.
fn parse_head(start : Cursor) -> (String, Cursor) {
let mut index = start.index
while index < start.code_unit_length() {
let ch = start.unsafe_char_at(index)
if is_sexp_paren(ch) || ch.is_whitespace() {
break
}
index = start.next_char_index(index)
}
(start.slice_until(index), start.with_index(index))
}
///|
/// Payload scanning modes share escaping but differ in how unescaped parens end
/// or invalidate the payload.
priv enum PayloadScanMode {
CommentPayload
RawTextPayload
AttributeValuePayload
}
///|
/// Parses ordinary text until an unescaped list boundary. Only `\(` and `\)`
/// are unescaped; other backslashes remain literal text.
fn parse_text(start : Cursor) -> (String, Cursor) {
let buffer : Array[Char] = []
let mut index = start.index
while index < start.code_unit_length() {
let ch = start.unsafe_char_at(index)
if ch == '\\' {
match consume_escaped_paren_into(start, index, buffer) {
Some(next) => {
index = next
continue
}
None => ()
}
} else if is_sexp_paren(ch) {
break
}
buffer.push(ch)
index = start.next_char_index(index)
}
(text_from_buffer(buffer), start.with_index(index))
}
///|
/// Parses comment payload text, allowing balanced unescaped parentheses inside
/// the comment until the list's closing paren is reached.
fn parse_comment_payload(start : Cursor) -> Result[(String, Cursor), String] {
scan_payload(start, CommentPayload)
}
///|
/// Parses raw text payload up to the first unescaped closing paren.
fn parse_raw_text_payload(start : Cursor) -> Result[(String, Cursor), String] {
scan_payload(start, RawTextPayload)
}
///|
/// Parses an attribute value payload; values may contain escaped parens but not
/// unescaped nested list structure.
fn parse_attribute_value_payload(
start : Cursor,
) -> Result[(String, Cursor), String] {
scan_payload(start, AttributeValuePayload)
}
///|
/// Shared payload scanner for delimited text forms. It centralizes `\(`/`\)`
/// handling while keeping each syntax form's paren rules explicit in `mode`.
fn scan_payload(
start : Cursor,
mode : PayloadScanMode,
) -> Result[(String, Cursor), String] {
let buffer : Array[Char] = []
let mut index = start.index
let mut comment_depth = 0
while index < start.code_unit_length() {
let ch = start.unsafe_char_at(index)
match ch {
'\\' =>
if consume_escaped_paren_into(start, index, buffer) is Some(next) {
index = next
continue
}
ch if is_sexp_open_paren(ch) =>
match mode {
CommentPayload => comment_depth = comment_depth + 1
AttributeValuePayload =>
return Err(
start
.with_index(index)
.error("attribute values must be plain text"),
)
RawTextPayload => ()
}
ch if is_sexp_close_paren(ch) =>
match mode {
CommentPayload => {
guard comment_depth == 0 else { comment_depth = comment_depth - 1 }
return Ok((text_from_buffer(buffer), start.with_index(index + 1)))
}
RawTextPayload | AttributeValuePayload =>
return Ok((text_from_buffer(buffer), start.with_index(index + 1)))
}
_ => ()
}
buffer.push(ch)
index = start.next_char_index(index)
}
Err(start.error(mode.unterminated_message()))
}
///|
/// Returns the EOF error text for each delimited payload form.
fn PayloadScanMode::unterminated_message(self : PayloadScanMode) -> String {
match self {
CommentPayload => "unterminated comment"
RawTextPayload => "unterminated (# ...) text node"
AttributeValuePayload => "unterminated attribute"
}
}
///|
fn text_from_buffer(buffer : Array[Char]) -> String {
String::from_array(buffer.view())
}
///|
/// Consumes `\(` or `\)` as a literal paren and returns the next code-unit
/// index. Other backslashes are left for the caller to copy unchanged.
fn consume_escaped_paren_into(
source : Cursor,
index : Int,
buffer : Array[Char],
) -> Int? {
guard index + 1 < source.code_unit_length() else { return None }
let escaped = source.unsafe_char_at(index + 1)
guard is_sexp_text_escape_target(escaped) else { return None }
buffer.push(escaped)
Some(index + 2)
}
///|
fn append_node_values(
target : Array[SexpNode],
nodes : Array[(SexpNode, Bool)],
) -> Unit {
target.append(strip_parse_flags(nodes))
}
///|
fn append_parsed_child_nodes(
target : Array[(SexpNode, Bool)],
nodes : Array[(SexpNode, Bool)],
) -> Unit {
target.append(nodes)
}
///|
/// Removes parser-only trim flags once the caller no longer needs to distinguish
/// syntactic separator text from literal text.
fn strip_parse_flags(nodes : Array[NodeWithTrimBeforeAttr]) -> Array[SexpNode] {
nodes.map(pair => pair.0)
}
///|
/// If an attribute appears after text, trim or remove the immediately preceding
/// text node when it only carried separator whitespace before that attribute.
fn trim_trailing_text_before_attribute(
children : Array[(SexpNode, Bool)],
) -> Unit {
guard !children.is_empty() else { return }
let last_index = children.length() - 1
guard children[last_index] is (Text(text), true) else { }
let trimmed = text.trim_end().to_owned()
guard trimmed.is_empty() else { children[last_index] = (Text(trimmed), true) }
children.truncate(last_index)
}