Files
Alexander Medvednikov 11c71fbd59 encoding.xml: fix valueless attributes, text order around comments and CDATA, and empty error messages (fix #30021) (#30064)
The DOM parser had six defects, all in parser.v:

- parse_attributes took everything up to a "=" as the key, so a name without
  a value was merged into the key of the next attribute (`<a disabled k="v">`
  gave `{'disabled k': 'v'}`), or dropped after the last value. It is an error
  now, as XML requires `name="value"`. A value also ended at the first quote
  of any kind; only the quote that opened it closes it now, so that the rest
  of `k="it's"` is not taken for a name without a value.
- parse_children added the pending text only before a child node and at the
  end of the node, so text in front of a comment or a CDATA section came
  after it, merged with the text behind it. The text is added at every "<".
- parse_prolog returned as soon as the document did not start with "<?", so
  a comment or a DOCTYPE in front of the root was read as the root tag. They
  are parsed the same way with and without an XML declaration.
- An input that ended early returned an io.Eof, which has no message. The
  error says what was not closed: the tag, node, comment, CDATA or DOCTYPE.
- "<?" in content or before the root was read as a node. It is reported as
  an unsupported processing instruction: the tree has no node type for it.
- `<>` was a node named `><`, `</>` a node without a name, and `< >` a
  panic. A tag without a name is an error.
2026-10-13 04:14:18 +03:00

810 lines
21 KiB
V

module xml
import io
import os
import strings
const default_prolog_attributes = {
'version': '1.0'
'encoding': 'UTF-8'
}
const default_string_builder_cap = 32
const element_len = '<!ELEMENT'.len
const entity_len = '<!ENTITY'.len
const doctype_chars = 'OCTYPE'.bytes()
const double_dash = '--'.bytes()
const c_tag = '[C'.bytes()
const data_chars = 'DATA'.bytes()
const byte_order_marking_first = u8(0xEF)
const byte_order_marking_bytes = [u8(0xBB), 0xBF]
// Helper types to assist in parsing
struct TextSpan {
mut:
start int
end int
}
enum AttributeParserState {
key
eq
value
}
fn parse_attributes(attribute_contents string) !map[string]string {
return parse_attributes_with_entities(attribute_contents, default_entities)
}
fn parse_attributes_with_entities(attribute_contents string, entities map[string]string) !map[string]string {
if attribute_contents.contains_u8(`<`) {
return error('Malformed XML. Found "<" in attribute string: "${attribute_contents}"')
}
mut attributes := map[string]string{}
mut state := AttributeParserState.key
mut key_span, mut value_span := TextSpan{}, TextSpan{}
mut key := ''
mut quote := u8(0)
for index, ch in attribute_contents {
match state {
.key {
match ch {
`=` {
key = attribute_contents[key_span.start..key_span.end].trim_space()
if key.contains_any(' \t\r\n') {
return valueless_attribute_error(key, attribute_contents)
}
if key == '' {
return error('XML attribute is missing name.')
}
state = AttributeParserState.eq
}
else {
key_span.end++
}
}
}
.eq {
match ch {
`=` {
return error('Duplicate "=" in attribute string: "${attribute_contents}"')
}
`'`, `"` {
state = AttributeParserState.value
value_span.start = index + 1
quote = ch
}
else {
return error('Invalid character in attribute string: "${attribute_contents}"')
}
}
}
.value {
match ch {
quote {
state = AttributeParserState.key
value_span.end = index
if key in attributes {
return error('Duplicate XML attribute: ${key}')
}
attributes[key] = unescape_text(attribute_contents[value_span.start..value_span.end],
entities: entities
)!
key_span.start = index + 1
key_span.end = index + 1
}
else {
state = AttributeParserState.value
value_span.end++
}
}
}
}
}
if state == .eq {
return valueless_attribute_error(key, attribute_contents)
}
if state == .value {
return error('Unterminated value of XML attribute "${key}".')
}
if key_span.end > key_span.start {
key = attribute_contents[key_span.start..key_span.end].trim_space()
if key != '' {
return valueless_attribute_error(key, attribute_contents)
}
}
return attributes
}
// valueless_attribute_error reports the first name with no attribute value.
fn valueless_attribute_error(names string, contents string) IError {
name := names.split_any(' \t\r\n')[0]
return error('Attribute "${name}" has no value in attribute string: "${contents}"')
}
fn parse_comment(mut reader io.Reader) !XMLComment {
mut comment_buffer := strings.new_builder(default_string_builder_cap)
mut local_buf := [u8(0)]
for {
ch := next_char(mut reader, mut local_buf)!
match ch {
`-` {
after_ch := next_char(mut reader, mut local_buf)!
if after_ch == `-` {
if next_char(mut reader, mut local_buf)! == `>` {
break
}
return error('XML Comment not closed. Expected ">".')
} else {
comment_buffer.write_u8(ch)
comment_buffer.write_u8(after_ch)
}
}
else {
comment_buffer.write_u8(ch)
}
}
}
comment_contents := comment_buffer.str()
return XMLComment{comment_contents}
}
enum CDATAParserState {
normal
single
double
}
fn parse_cdata(mut reader io.Reader) !XMLCData {
mut contents_buf := strings.new_builder(default_string_builder_cap)
mut state := CDATAParserState.normal
mut local_buf := [u8(0)]
for {
ch := next_char(mut reader, mut local_buf)!
contents_buf.write_u8(ch)
match ch {
`]` {
match state {
.double {
// Another ] after the ]] for some reason. Keep the state
}
.single {
state = .double
}
.normal {
state = .single
}
}
}
`>` {
match state {
.double {
break
}
else {
state = .normal
}
}
}
else {
state = .normal
}
}
}
contents := contents_buf.str().trim_space()
if !contents.ends_with(']]>') {
return error('CDATA section not closed.')
}
return XMLCData{contents[1..contents.len - 3]}
}
fn parse_entity(contents string) !(DTDEntity, string) {
// We find the nearest '>' to the start of the ENTITY
entity_end := contents.index('>') or { return error('Entity declaration not closed.') }
entity_contents := contents[entity_len..entity_end]
name := entity_contents.trim_left(' \t\n').all_before(' ')
if name == '' {
return error('Entity is missing name.')
}
value := entity_contents.all_after_first(name).trim_space().trim('"\'')
if value.len == 0 {
return error('Entity is missing value.')
}
// TODO: Add support for SYSTEM and PUBLIC entities
return DTDEntity{name, value}, contents[entity_end + 1..]
}
fn parse_element(contents string) !(DTDElement, string) {
// We find the nearest '>' to the start of the ELEMENT
element_end := contents.index('>') or { return error('Element declaration not closed.') }
element_contents := contents[element_len..element_end].trim_left(' \t\r\n')
mut name_span := TextSpan{}
for ch in element_contents {
match ch {
` `, `\t`, `\n` {
break
}
// Valid characters in an entity name are:
// 1. Lowercase alphabet - a-z
// 2. Uppercase alphabet - A-Z
// 3. Numbers - 0-9
// 4. Underscore - _
// 5. Colon - :
// 6. Period - .
`a`...`z`, `A`...`Z`, `0`...`9`, `_`, `:`, `.` {
name_span.end++
}
else {
return error('Invalid character in element name: "${ch}"')
}
}
}
name := element_contents[name_span.start..name_span.end].trim_left(' \t\n')
if name == '' {
return error('Element is missing name.')
}
definition_string := element_contents.all_after_first(name).trim_space().trim('"\'')
definition := if definition_string.starts_with('(') {
// We have a list of possible children
// Ensure that both ( and ) are present
if !definition_string.ends_with(')') {
return error('Element declaration not closed.')
}
definition_string.trim('()').split(',')
} else {
// Invalid definition
return error('Invalid element definition: ${definition_string}')
}
// TODO: Add support for SYSTEM and PUBLIC entities
return DTDElement{name, definition}, contents[element_end + 1..]
}
fn parse_doctype_literal(contents string) !(string, string) {
text := contents.trim_left(' \t\r\n')
if text.len == 0 || text[0] !in [`'`, `"`] {
return error('Invalid DOCTYPE: expected a quoted external identifier.')
}
end := text[1..].index_u8(text[0])
if end < 0 {
return error('Invalid DOCTYPE: external identifier not closed.')
}
return text[..end + 2], text[end + 2..]
}
fn parse_doctype_external_id(contents string) !string {
text := contents.trim_space()
if text == '' {
return ''
}
kind := if text.starts_with('SYSTEM') {
'SYSTEM'
} else if text.starts_with('PUBLIC') {
'PUBLIC'
} else {
return error('Invalid DOCTYPE: expected SYSTEM or PUBLIC.')
}
if text.len <= kind.len || text[kind.len] !in [` `, `\t`, `\r`, `\n`] {
return error('Invalid DOCTYPE: missing external identifier.')
}
_, remaining := parse_doctype_literal(text[kind.len..])!
mut trailing := remaining
if kind == 'PUBLIC' {
if remaining.len == 0 || remaining[0] !in [` `, `\t`, `\r`, `\n`] {
return error('Invalid DOCTYPE: PUBLIC requires a system identifier.')
}
_, rest := parse_doctype_literal(remaining)!
trailing = rest
}
if trailing.trim_space() != '' {
return error('Invalid DOCTYPE: unexpected text after external identifier.')
}
return text
}
fn parse_doctype(mut reader io.Reader) !DocumentType {
mut doctype_buffer := strings.new_builder(default_string_builder_cap)
mut local_buf := [u8(0)]
mut quote := u8(0)
mut subset_depth := 0
mut subset_start := -1
mut subset_end := -1
for {
ch := next_char(mut reader, mut local_buf) or {
if err is io.Eof || err is os.Eof || err.msg() == 'Unexpected End Of File.' {
return error('Invalid DOCTYPE: declaration not closed.')
}
return err
}
if quote != 0 {
if ch == quote {
quote = 0
}
} else {
match ch {
`'`, `"` { quote = ch }
`[` {
if subset_depth == 0 {
subset_start = doctype_buffer.len
}
subset_depth++
}
`]` {
subset_depth--
if subset_depth < 0 {
return error('Invalid DOCTYPE: unexpected closing bracket.')
}
if subset_depth == 0 {
subset_end = doctype_buffer.len
}
}
`>` {
if subset_depth == 0 {
break
}
}
else {}
}
}
doctype_buffer.write_u8(ch)
}
contents := doctype_buffer.str()
header := if subset_start >= 0 { contents[..subset_start] } else { contents }
text := header.trim_space()
mut name_end := 0
for ch in text {
if ch in [` `, `\t`, `\r`, `\n`] {
break
}
name_end++
}
if name_end == 0 {
return error('Invalid DOCTYPE: missing root name.')
}
name := text[..name_end]
external_id := parse_doctype_external_id(text[name_end..])!
mut list_contents := ''
if subset_start >= 0 {
if subset_end <= subset_start || contents[subset_end + 1..].trim_space() != '' {
return error('Invalid DOCTYPE: malformed internal subset.')
}
list_contents = contents[subset_start + 1..subset_end].trim_space()
}
mut items := []DTDListItem{}
for list_contents.len > 0 {
if list_contents.starts_with('<!ENTITY') {
entity, remaining := parse_entity(list_contents)!
items << entity
list_contents = remaining.trim_space()
} else if list_contents.starts_with('<!ELEMENT') {
element, remaining := parse_element(list_contents)!
items << element
list_contents = remaining.trim_space()
} else {
return error('Unknown DOCTYPE list item: ${list_contents}')
}
}
return DocumentType{
name: name
external_id: external_id
dtd: DocumentTypeDefinition{ list: items }
}
}
fn parse_document_start(first_char u8, mut reader io.Reader) !(DocumentType, []XMLComment, u8) {
mut ch := first_char
mut local_buf := [u8(0)]
mut comments := []XMLComment{}
mut doctype := DocumentType{ name: '', dtd: '' }
mut found_doctype := false
for ch == `!` {
match next_char(mut reader, mut local_buf)! {
`-` {
if next_char(mut reader, mut local_buf)! != `-` {
return error('Invalid comment.')
}
comments << parse_comment(mut reader) or {
return eof_error(err, 'XML Comment not closed.')
}
}
`D` {
if found_doctype {
return error('Duplicate DOCTYPE declaration.')
}
mut doc_buf := []u8{len: 6}
if reader.read(mut doc_buf)! != 6 || doc_buf != doctype_chars {
return error('Invalid DOCTYPE.')
}
doctype = parse_doctype(mut reader) or {
return eof_error(err, 'DOCTYPE declaration not closed.')
}
found_doctype = true
}
else {
return error('Unsupported control sequence found in prolog.')
}
}
ch = next_char(mut reader, mut local_buf)!
for (ch in [` `, `\t`, `\r`, `\n`]) {
ch = next_char(mut reader, mut local_buf)!
}
if ch != `<` {
return error('Expected root element after XML declaration or DOCTYPE.')
}
ch = next_char(mut reader, mut local_buf)!
}
return doctype, comments, ch
}
fn parse_prolog(mut reader io.Reader) !(Prolog, u8) {
// Skip trailing whitespace and invalid characters
mut local_buf := [u8(0)]
mut ch := next_char(mut reader, mut local_buf)!
for {
match ch {
` `, `\t`, `\r`, `\n` {
ch = next_char(mut reader, mut local_buf)!
continue
}
`<` {
break
}
byte_order_marking_first {
// UTF-8 BOM
mut bom_buf := [u8(0), 0]
if reader.read(mut bom_buf)! != 2 {
return error('Invalid UTF-8 BOM.')
}
if bom_buf != byte_order_marking_bytes {
return error('Invalid UTF-8 BOM.')
}
ch = next_char(mut reader, mut local_buf)!
continue
}
else {
return error('Expecting a prolog or root node starting with "<".')
}
}
}
ch = next_char(mut reader, mut local_buf)!
if ch != `?` {
doctype, comments, root_char := parse_document_start(ch, mut reader)!
return Prolog{ doctype: doctype, comments: comments }, root_char
}
ch = next_char(mut reader, mut local_buf)!
if ch != `x` {
return error('Expecting a prolog starting with "<?x".')
}
ch = next_char(mut reader, mut local_buf)!
if ch != `m` {
return error('Expecting a prolog starting with "<?xm".')
}
ch = next_char(mut reader, mut local_buf)!
if ch != `l` {
return error('Expecting a prolog starting with "<?xml".')
}
mut prolog_buffer := strings.new_builder(default_string_builder_cap)
// Keep reading character by character until we find the end of the prolog
mut found_question_mark := false
for {
ch = next_char(mut reader, mut local_buf)!
match ch {
`?` {
if found_question_mark {
return error('Invalid prolog: Two question marks found in a row.')
}
found_question_mark = true
}
`>` {
if found_question_mark {
break
}
return error('Invalid prolog: Found ">" before "?".')
}
else {
if found_question_mark {
found_question_mark = false
prolog_buffer.write_u8(`?`)
}
prolog_buffer.write_u8(ch)
}
}
}
prolog_attributes := prolog_buffer.str().trim_space()
attributes := if prolog_attributes.len == 0 {
default_prolog_attributes
} else {
parse_attributes(prolog_attributes)!
}
version := attributes['version'] or { return error('XML declaration missing version.') }
encoding := attributes['encoding'] or { 'UTF-8' }
ch = next_char(mut reader, mut local_buf)!
for (ch in [` `, `\t`, `\r`, `\n`]) {
ch = next_char(mut reader, mut local_buf)!
}
if ch != `<` {
return error('Expected root element after XML declaration.')
}
ch = next_char(mut reader, mut local_buf)!
doctype, comments, root_char := parse_document_start(ch, mut reader)!
return Prolog{
version: version
encoding: encoding
doctype: doctype
comments: comments
}, root_char
}
fn append_text_run(mut children []XMLNodeContents, mut pending []XMLNodeContents, mut contents strings.Builder, entities map[string]string) ! {
text := contents.str()
if text.len > 0 {
pending << text
}
// Comments split text fragments, but only element boundaries trim the whole text run.
for i in 0 .. pending.len {
item := pending[i]
match item {
string {
trimmed := item.trim_space_left()
pending[i] = trimmed
if trimmed.len > 0 {
break
}
}
XMLCData {
if item.text.len > 0 {
break
}
}
else {}
}
}
for i := pending.len - 1; i >= 0; i-- {
item := pending[i]
match item {
string {
trimmed := item.trim_space_right()
pending[i] = trimmed
if trimmed.len > 0 {
break
}
}
XMLCData {
if item.text.len > 0 {
break
}
}
else {}
}
}
for item in pending {
if item is string {
if item.len > 0 {
children << unescape_text(item.replace('\r\n', '\n'), entities: entities)!
}
} else {
children << item
}
}
pending.clear()
}
fn parse_children(name string, attributes map[string]string, entities map[string]string, mut reader io.Reader) !XMLNode {
mut inner_contents := strings.new_builder(default_string_builder_cap)
mut children := []XMLNodeContents{}
mut pending := []XMLNodeContents{}
mut local_buf := [u8(0)]
for {
ch := next_char(mut reader, mut local_buf)!
match ch {
`<` {
second_char := next_char(mut reader, mut local_buf)!
match second_char {
`!` {
text := inner_contents.str()
if text.len > 0 {
pending << text
}
// Comment, CDATA
mut next_two := [u8(0), 0]
if reader.read(mut next_two)! != 2 {
return error('Invalid XML. Incomplete comment or CDATA declaration.')
}
if next_two == double_dash {
// Comment
comment := parse_comment(mut reader) or {
return eof_error(err, 'XML Comment not closed.')
}
pending << comment
} else if next_two == c_tag {
// <![CDATA -> DATA
mut cdata_buf := []u8{len: 4}
if reader.read(mut cdata_buf)! != 4 {
return error('Invalid XML. Incomplete CDATA declaration.')
}
if cdata_buf != data_chars {
return error('Invalid XML. Expected "CDATA" after "<![C".')
}
cdata := parse_cdata(mut reader) or {
return eof_error(err, 'CDATA section not closed.')
}
pending << cdata
} else {
return error('Invalid XML. Unknown control sequence: ${next_two.bytestr()}')
}
}
`/` {
// End of node
mut node_end_buffer := []u8{len: name.len + 1}
if reader.read(mut node_end_buffer)! != name.len + 1 {
return error('Invalid XML. Incomplete node end.')
}
mut ending_chars := name.bytes()
ending_chars << `>`
if node_end_buffer != ending_chars {
return error('XML node <${name}> not closed.')
}
append_text_run(mut children, mut pending, mut inner_contents, entities)!
return XMLNode{
name: name
attributes: attributes
children: children
}
}
else {
// Start of child node
child := parse_node_with_entities(second_char, entities, mut reader) or {
if err.msg() == 'XML node cannot start with "</".' {
return error('XML node <${name}> not closed.')
} else {
return err
}
}
append_text_run(mut children, mut pending, mut inner_contents, entities)!
children << child
}
}
}
else {
inner_contents.write_u8(ch)
}
}
}
return error('XML node <${name}> not closed.')
}
// parse_single_node parses a single XML node from the reader. The first character of the tag is passed
// in as the first_char parameter. Predefined entities and character references are decoded.
// This function is meant to assist in parsing nested nodes one at a time. Using this function as
// opposed to the recommended static functions makes it easier to parse smaller nodes in extremely large
// XML documents without running out of memory.
pub fn parse_single_node(first_char u8, mut reader io.Reader) !XMLNode {
return parse_node_with_entities(first_char, default_entities, mut reader)
}
fn parse_node_with_entities(first_char u8, entities map[string]string, mut reader io.Reader) !XMLNode {
if first_char == `?` {
return error('XML processing instructions are not supported.')
}
mut contents := strings.new_builder(default_string_builder_cap)
mut local_buf := [u8(0)]
mut ch := first_char
for ch != `>` {
contents.write_u8(ch)
ch = next_char(mut reader, mut local_buf) or {
return eof_error(err, 'XML tag not closed. Expected ">".')
}
}
tag_contents := contents.str().trim_space()
parts := tag_contents.split_any(' \t\r\n')
name := if parts.len > 0 { parts[0].trim_right('/') } else { '' }
if name == '' {
return error('XML node is missing name.')
}
// Check if it is a self-closing tag
if tag_contents.ends_with('/') {
// We're not looking for children and inner text
return XMLNode{
name: name
attributes: parse_attributes_with_entities(tag_contents[name.len..tag_contents.len - 1].trim_space(), entities)!
}
}
attribute_string := tag_contents[name.len..].trim_space()
attributes := parse_attributes_with_entities(attribute_string, entities)!
return parse_children(name, attributes, entities, mut reader) or {
return eof_error(err, 'XML node <${name}> not closed.')
}
}
// XMLDocument.from_string parses an XML document and decodes entities in text and attributes.
pub fn XMLDocument.from_string(raw_contents string) !XMLDocument {
mut reader := FullBufferReader{
contents: raw_contents.bytes()
}
return XMLDocument.from_reader(mut reader)!
}
// XMLDocument.from_file parses an XML document from a file. Note that the file is read in its entirety
// and then parsed. If the file is too large, try using the XMLDocument.from_reader function instead.
pub fn XMLDocument.from_file(path string) !XMLDocument {
mut reader := FullBufferReader{
contents: os.read_bytes(path)!
}
return XMLDocument.from_reader(mut reader)!
}
// XMLDocument.from_reader parses an XML document and decodes entities in text and attributes. This is the most generic way to parse
// an XML document from any arbitrary source that implements that io.Reader interface.
pub fn XMLDocument.from_reader(mut reader io.Reader) !XMLDocument {
prolog, first_char := parse_prolog(mut reader) or {
if err is os.Eof || err is io.Eof || err.msg() == 'Unexpected End Of File.' {
return error('XML document is empty.')
} else {
return err
}
}
mut entities := default_entities.clone()
if prolog.doctype.dtd is DocumentTypeDefinition {
for item in prolog.doctype.dtd.list {
if item is DTDEntity {
entities[item.name] = item.value
}
}
}
root := parse_node_with_entities(first_char, entities, mut reader)!
return XMLDocument{
version: prolog.version
encoding: prolog.encoding
comments: prolog.comments
doctype: prolog.doctype
root: root
}
}