summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorruki <[email protected]>2025-11-16 00:16:23 +0800
committerGitHub <[email protected]>2025-11-16 00:16:23 +0800
commitd5e9f0ee721f63fc664855ea33c73fc62093fcb6 (patch)
treef461030671509bb245e2a3676f6f81e6a8b47208
parent6869f1541f155dcc240f0a0a2de9e076f377856a (diff)
parentb250c84aee0072ff92b73fe41403d364f95a03d5 (diff)
Merge pull request #7025 from xmake-io/xml
Add xml module
-rw-r--r--tests/modules/xml/test.lua189
-rw-r--r--xmake/core/base/xml.lua899
-rw-r--r--xmake/core/sandbox/modules/import/core/base/xml.lua77
3 files changed, 1165 insertions, 0 deletions
diff --git a/tests/modules/xml/test.lua b/tests/modules/xml/test.lua
new file mode 100644
index 000000000..4358762fd
--- /dev/null
+++ b/tests/modules/xml/test.lua
@@ -0,0 +1,189 @@
+import("core.base.xml")
+
+function test_decode_basic(t)
+ local doc = xml.decode([[<?xml version="1.0"?><root id="1"><item>foo</item><item id="2"/></root>]])
+ t:are_equal(doc.kind, "element")
+ t:are_equal(doc.name, "root")
+ t:are_equal(doc.attrs.id, "1")
+ t:are_equal(#doc.children, 2)
+ t:are_equal(doc.children[1].name, "item")
+ t:are_equal(xml.text_of(doc.children[1]), "foo")
+ t:are_equal(doc.children[1].attrs, nil)
+ t:are_equal(doc.children[2].attrs.id, "2")
+end
+
+function test_decode_unquoted_attrs(t)
+ local doc = xml.decode("<root flag=true count=42 path=/tmp/file><child data=abc/></root>")
+ t:are_equal(doc.attrs.flag, "true")
+ t:are_equal(doc.attrs.count, "42")
+ t:are_equal(doc.attrs.path, "/tmp/file")
+ t:are_equal(doc.children[1].attrs.data, "abc")
+end
+
+function test_decode_mixed_attrs(t)
+ local xmltext = [[<node a="1 2" b='foo &amp; bar' c=bare data-id=abc123 ns:flag=true dashed-name="hello-world"/>]]
+ local doc = xml.decode(xmltext)
+ t:are_equal(doc.attrs.a, "1 2")
+ t:are_equal(doc.attrs.b, "foo & bar")
+ t:are_equal(doc.attrs.c, "bare")
+ t:are_equal(doc.attrs["data-id"], "abc123")
+ t:are_equal(doc.attrs["ns:flag"], "true")
+ t:are_equal(doc.attrs["dashed-name"], "hello-world")
+end
+
+function test_encode_basic(t)
+ local doc = xml.new({
+ name = "root",
+ attrs = {id = "1"},
+ children = {
+ xml.new({name = "item", children = {xml.text("foo")}}),
+ xml.new({name = "item", attrs = {id = "2"}})
+ }
+ })
+ local compact = xml.encode(doc)
+ t:are_equal(compact, '<root id="1"><item>foo</item><item id="2"/></root>')
+ local pretty = xml.encode(doc, {pretty = true, indent = 2})
+ local expected = table.concat({
+ '<root id="1">',
+ ' <item>foo</item>',
+ ' <item id="2"/>',
+ '</root>'
+ }, "\n")
+ t:are_equal(pretty, expected)
+end
+
+function test_encode_special_nodes(t)
+ t:are_equal(xml.encode(xml.comment("note")), "<!--note-->")
+ t:are_equal(xml.encode(xml.cdata("a < b")), "<![CDATA[a < b]]>")
+ t:are_equal(xml.encode(xml.doctype("note SYSTEM \"note.dtd\"")), "<!DOCTYPE note SYSTEM \"note.dtd\">")
+ t:are_equal(xml.encode(xml.empty("br")), "<br/>")
+end
+
+function test_decode_special_nodes(t)
+ local doc = xml.decode([=[<root><!--note--><![CDATA[a < b]]><child/></root>]=])
+ t:are_equal(doc.children[1].kind, "comment")
+ t:are_equal(doc.children[1].text, "note")
+ t:are_equal(doc.children[2].kind, "cdata")
+ t:are_equal(doc.children[2].text, "a < b")
+ t:are_equal(doc.children[3].name, "child")
+ local nodes = xml.decode([=[<!DOCTYPE note SYSTEM "note.dtd"><root/>]=])
+ t:are_equal(nodes.kind, "element")
+ t:are_equal(nodes.name, "root")
+ t:are_equal(nodes.prolog[1].kind, "doctype")
+end
+
+function test_load_save(t)
+ local tmpdir = os.tmpdir()
+ local filepath = path.join(tmpdir, "xml_test.xml")
+ local doc = xml.new({
+ name = "root",
+ attrs = {id = "1"},
+ children = {xml.text("hello")}
+ })
+ assert(xml.savefile(filepath, doc, {pretty = true}))
+ local reloaded = xml.loadfile(filepath)
+ t:are_equal(reloaded.name, "root")
+ t:are_equal(xml.text_of(reloaded), "hello")
+ os.tryrm(filepath)
+end
+
+function test_plist_sample(t)
+ local plist = [[<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>NSHumanReadableCopyright</key>
+ <string>Copyright © 2020 tboox. All rights reserved.</string>
+ <key>NSSupportsAutomaticTermination</key>
+ <true/>
+</dict>
+</plist>]]
+ local doc = xml.decode(plist)
+ t:are_equal(doc.name, "plist")
+ t:are_equal(doc.attrs.version, "1.0")
+ t:are_equal(doc.prolog[1].kind, "doctype")
+ local dict = xml.find(doc, "plist/dict")
+ t:are_equal(dict.kind, "element")
+ local first_key = dict.children[1]
+ t:are_equal(first_key.name, "key")
+ t:are_equal(xml.text_of(first_key), "CFBundleDevelopmentRegion")
+ local first_value = dict.children[2]
+ t:are_equal(first_value.name, "string")
+ t:are_equal(xml.text_of(first_value), "$(DEVELOPMENT_LANGUAGE)")
+ local last_flag = dict.children[#dict.children]
+ t:are_equal(last_flag.name, "true")
+end
+
+function test_scan_stop(t)
+ local plist = [[
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+ <dict>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>NSPrincipalClass</key>
+ <string>NSApplication</string>
+ </dict>
+</plist>]]
+ local found
+ xml.scan(plist, function(node)
+ if node.name == "key" and xml.text_of(node) == "NSPrincipalClass" then
+ found = node
+ return false
+ end
+ end)
+ t:are_equal(found ~= nil, true)
+ t:are_equal(xml.text_of(found), "NSPrincipalClass")
+end
+
+function test_find_xpath(t)
+ local doc = xml.decode([[
+<root>
+ <items>
+ <item id="a"><value>foo</value></item>
+ <item id="b"><value>bar</value></item>
+ </items>
+ <extras>
+ <item id="c"/>
+ </extras>
+</root>]])
+ local second = xml.find(doc, "root/items/item[2]")
+ t:are_equal(second.attrs.id, "b")
+ local descendant = xml.find(doc, "//item[@id='c']")
+ t:are_equal(descendant.attrs.id, "c")
+ local value = xml.find(doc, "//value[text()='bar']")
+ t:are_equal(xml.text_of(value), "bar")
+end
+
+function test_find_update(t)
+ local doc = xml.decode("<root><item id='a'>foo</item><item id='b'/></root>")
+ local target = xml.find(doc, "//item[@id='a']")
+ t:are_not_equal(target, nil)
+ target.attrs.lang = "en"
+ target.children = {xml.text("bar")}
+ local new_item = xml.new({name = "item", attrs = {id = "c"}, children = {xml.text("baz")}})
+ table.insert(doc.children, new_item)
+ local encoded = xml.encode(doc)
+ t:are_equal(encoded, '<root><item id="a" lang="en">bar</item><item id="b"/><item id="c">baz</item></root>')
+end
+
+function test_decode_trim_text(t)
+ local doc = xml.decode("<root> foo </root>")
+ t:are_equal(xml.text_of(doc), " foo ")
+ local trimmed = xml.decode("<root> foo </root>", {trim_text = true})
+ t:are_equal(xml.text_of(trimmed), "foo")
+ local formatted = "<root>\n <item/>\n</root>"
+ local default = xml.decode(formatted)
+ t:are_equal(#default.children, 1)
+ local keep_ws = xml.decode(formatted, {keep_whitespace_nodes = true})
+ t:are_equal(#keep_ws.children, 3)
+ t:are_equal(keep_ws.children[1].kind, "text")
+end
+
diff --git a/xmake/core/base/xml.lua b/xmake/core/base/xml.lua
new file mode 100644
index 000000000..5e7282e97
--- /dev/null
+++ b/xmake/core/base/xml.lua
@@ -0,0 +1,899 @@
+--!A cross-platform build utility based on Lua
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+-- http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+--
+-- Copyright (C) 2015-present, Xmake Open Source Community.
+--
+-- @author ruki
+-- @file xml.lua
+--
+
+-- define module: xml
+local xml = xml or {}
+
+-- load modules
+local io = require("base/io")
+local os = require("base/os")
+local table = require("base/table")
+local string = require("base/string")
+
+-- XML node structure (mutable DOM-style tables):
+-- {
+-- name = "element-name" | nil (for non-element nodes)
+-- kind = "element" | "text" | "comment" | "cdata" | "doctype" | "document"
+-- attrs = { key = value, ... } or nil (only for elements)
+-- text = "raw text" (for text/comment/cdata/doctype nodes)
+-- children = { child1, child2, ... } or nil (for elements or document nodes)
+-- prolog = { comment/doctypes before root } or nil (only on root element)
+-- }
+--
+-- Quick start:
+-- local doc = assert(xml.decode([[
+-- <?xml version="1.0"?><root id="1"><item id="foo">hello</item></root>
+-- ]]))
+-- local item = assert(xml.find(doc, "//item[@id='foo']"))
+-- item.attrs.lang = "en" -- mutate attributes directly
+-- item.children = {xml.text("world")} -- replace children with new nodes
+-- table.insert(doc.children, xml.comment("generated by xmake"))
+-- table.insert(doc.children, xml.new({name = "extra", children = {xml.text("42")}}))
+-- local pretty = assert(xml.encode(doc, {pretty = true}))
+-- -- save or reuse `pretty`
+--
+-- Streaming example (no full DOM in memory):
+-- xml.scan(xml_text, function(node)
+-- if node.name == "item" then
+-- print("item payload:", xml.text_of(node))
+-- if xml.text_of(node) == "stop" then
+-- return false -- early terminate
+-- end
+-- end
+-- end)
+--
+
+-- decode entities
+function xml._decode_entities(str)
+ return (str:gsub("&lt;", "<")
+ :gsub("&gt;", ">")
+ :gsub("&apos;", "'")
+ :gsub("&quot;", "\"")
+ :gsub("&amp;", "&"))
+end
+
+-- encode raw text for xml element content
+function xml._encode_text(str)
+ return (str:gsub("&", "&amp;")
+ :gsub("<", "&lt;")
+ :gsub(">", "&gt;"))
+end
+
+-- encode attribute value for xml output
+function xml._encode_attr(str)
+ return xml._encode_text(str):gsub("\"", "&quot;")
+end
+
+-- parse attribute string to table (or nil if empty)
+function xml._parse_attrs(attrstr)
+ local attrs
+ local i, len = 1, #attrstr
+ while i <= len do
+ local key, value_start = attrstr:match("^%s*([%w_:%-%.]+)%s*=%s*()", i)
+ if not key then
+ break
+ end
+ local value
+ local first_char = attrstr:sub(value_start, value_start)
+ if first_char == "\"" or first_char == "'" then
+ local closing = attrstr:find(first_char, value_start + 1, true)
+ if not closing then
+ break
+ end
+ value = attrstr:sub(value_start + 1, closing - 1)
+ i = closing + 1
+ else
+ local j = value_start
+ while j <= len do
+ local ch = attrstr:sub(j, j)
+ if ch:match("%s") or ch == ">" or (ch == "/" and attrstr:sub(j + 1, j + 1) == ">") then
+ break
+ end
+ j = j + 1
+ end
+ value = attrstr:sub(value_start, j - 1)
+ i = j
+ end
+ attrs = attrs or {}
+ attrs[key] = xml._decode_entities(value)
+ end
+ return attrs
+end
+
+-- iterate element children and optional prolog nodes
+function xml._each_child(node, callback)
+ if not node or not callback then
+ return
+ end
+ if node.children then
+ for _, child in ipairs(node.children) do
+ callback(child)
+ end
+ end
+ if node.prolog then
+ for _, child in ipairs(node.prolog) do
+ callback(child)
+ end
+ end
+end
+
+-- collect descendants that match predicate
+function xml._collect_descendants(node, matcher, results)
+ results = results or {}
+ xml._each_child(node, function(child)
+ if matcher(child) then
+ table.insert(results, child)
+ end
+ xml._collect_descendants(child, matcher, results)
+ end)
+ return results
+end
+
+-- parse xpath expression into steps
+function xml._parse_xpath(path)
+ local steps = {}
+ local len = #path
+ local i = 1
+ local first = true
+ while i <= len do
+ local axis
+ if path:sub(i, i + 1) == "//" then
+ axis = "descendant"
+ i = i + 2
+ elseif path:sub(i, i) == "/" then
+ axis = "child"
+ i = i + 1
+ elseif first then
+ axis = "self"
+ else
+ axis = "child"
+ end
+ while path:sub(i, i) == "/" do
+ if path:sub(i, i + 1) == "//" then
+ axis = "descendant"
+ i = i + 2
+ else
+ axis = "child"
+ i = i + 1
+ end
+ end
+ if i > len then
+ break
+ end
+ local start = i
+ local depth = 0
+ while i <= len do
+ local ch = path:sub(i, i)
+ if ch == "[" then
+ depth = depth + 1
+ elseif ch == "]" then
+ depth = depth - 1
+ elseif ch == "/" and depth == 0 then
+ break
+ end
+ i = i + 1
+ end
+ local segment = path:sub(start, i - 1):trim()
+ if segment ~= "" then
+ local step = xml._parse_xpath_segment(segment, axis)
+ table.insert(steps, step)
+ end
+ first = false
+ end
+ return steps
+end
+
+-- parse a single xpath step
+function xml._parse_xpath_segment(segment, axis)
+ local step = {axis = axis or "child", predicates = {}}
+ local name = segment:gsub("%b[]", "")
+ name = name:trim()
+ if name == "" or name == "*" then
+ step.node_test = "any"
+ elseif name == "." then
+ step.node_test = "self"
+ elseif name == "text()" then
+ step.node_test = "text"
+ elseif name == "comment()" then
+ step.node_test = "comment"
+ elseif name == "cdata()" then
+ step.node_test = "cdata"
+ elseif name == "doctype()" then
+ step.node_test = "doctype"
+ else
+ step.node_test = "name"
+ step.name = name
+ end
+ for predicate in segment:gmatch("%b[]") do
+ local expr = predicate:sub(2, -2):trim()
+ if expr ~= "" then
+ local number_index = tonumber(expr)
+ if number_index then
+ step.indexes = step.indexes or {}
+ table.insert(step.indexes, number_index)
+ else
+ local attr_key, quote, attr_value = expr:match("^@([%w_:%-%.]+)%s*=%s*(['\"])(.-)%2$")
+ if attr_key then
+ table.insert(step.predicates, {type = "attr", key = attr_key, value = attr_value})
+ else
+ local attr_exists = expr:match("^@([%w_:%-%.]+)%s*$")
+ if attr_exists then
+ table.insert(step.predicates, {type = "attr_exists", key = attr_exists})
+ else
+ local text_value = expr:match("^text%(%s*%)%s*=%s*\"(.-)\"$")
+ if not text_value then
+ text_value = expr:match("^text%(%s*%)%s*=%s*'(.-)'$")
+ end
+ if text_value then
+ table.insert(step.predicates, {type = "text", value = text_value})
+ else
+ step.unsupported = true
+ end
+ end
+ end
+ end
+ end
+ end
+ return step
+end
+
+-- check whether node matches xpath step
+function xml._match_xpath_node(node, step)
+ if step.unsupported then
+ return false
+ end
+ local nodetype = step.node_test or "name"
+ if nodetype == "self" then
+ -- always match, predicates will refine
+ elseif nodetype == "any" then
+ -- match every node
+ elseif nodetype == "node" then
+ -- unused for now
+ elseif nodetype == "text" then
+ if node.kind ~= "text" then
+ return false
+ end
+ elseif nodetype == "comment" then
+ if node.kind ~= "comment" then
+ return false
+ end
+ elseif nodetype == "cdata" then
+ if node.kind ~= "cdata" then
+ return false
+ end
+ elseif nodetype == "doctype" then
+ if node.kind ~= "doctype" then
+ return false
+ end
+ else
+ if node.kind ~= "element" then
+ return false
+ end
+ if step.name and step.name ~= "*" and node.name ~= step.name then
+ return false
+ end
+ end
+ if step.predicates then
+ for _, predicate in ipairs(step.predicates) do
+ if predicate.type == "attr" then
+ if not node.attrs or node.attrs[predicate.key] ~= predicate.value then
+ return false
+ end
+ elseif predicate.type == "attr_exists" then
+ if not node.attrs or node.attrs[predicate.key] == nil then
+ return false
+ end
+ elseif predicate.type == "text" then
+ if xml.text_of(node) ~= predicate.value then
+ return false
+ end
+ end
+ end
+ end
+ return true
+end
+
+-- apply positional predicates
+function xml._apply_xpath_indexes(nodes, indexes)
+ if not indexes or #indexes == 0 then
+ return nodes
+ end
+ local current = nodes
+ for _, index in ipairs(indexes) do
+ local selected = current[index]
+ if not selected then
+ return {}
+ end
+ current = {selected}
+ end
+ return current
+end
+
+-- append normalized text node to top element on stack
+function xml._append_text(stack, text, opt)
+ opt = opt or {}
+ if opt.trim_text then
+ text = string.trim(text)
+ end
+ if text == "" then
+ return
+ end
+ if not opt.keep_whitespace_nodes and text:match("^%s*$") then
+ return
+ end
+ local top = stack[#stack]
+ top.children = top.children or {}
+ table.insert(top.children, xml.text(xml._decode_entities(text)))
+end
+
+-- ensure closing tag matches stack and pop it
+function xml._handle_closing(stack, tagname)
+ local top = stack[#stack]
+ if not top or top.name ~= tagname then
+ return nil, string.format("malformed xml: unexpected closing </%s>", tagname)
+ end
+ table.remove(stack)
+ return top
+end
+
+-- compute indent string for pretty output
+function xml._indent(opt, level)
+ if not opt.pretty then
+ return ""
+ end
+ local indent = opt.indent or 4
+ local indentchar = opt.indentchar or " "
+ if type(indent) == "number" then
+ return string.rep(indentchar, indent * level)
+ end
+ return indent:rep(level)
+end
+
+function xml._encode_node(node, opt, level)
+ opt = opt or {}
+ level = level or 0
+ if node.kind == "document" then
+ local parts = {}
+ if node.children then
+ for _, child in ipairs(node.children) do
+ table.insert(parts, xml._encode_node(child, opt, level))
+ end
+ end
+ return table.concat(parts, opt.pretty and "\n" or "")
+ elseif node.kind == "text" then
+ local indent = xml._indent(opt, level)
+ local text = xml._encode_text(tostring(node.text or ""))
+ if opt.pretty then
+ return indent .. text
+ end
+ return text
+ elseif node.kind == "comment" then
+ return xml._indent(opt, level) .. string.format("<!--%s-->", tostring(node.text or ""))
+ elseif node.kind == "cdata" then
+ return xml._indent(opt, level) .. string.format("<![CDATA[%s]]>", tostring(node.text or ""))
+ elseif node.kind == "doctype" then
+ return xml._indent(opt, level) .. string.format("<!DOCTYPE %s>", tostring(node.text or ""))
+ end
+ local attrs = {}
+ for k, v in pairs(node.attrs or {}) do
+ table.insert(attrs, string.format('%s="%s"', k, xml._encode_attr(tostring(v))))
+ end
+ table.sort(attrs)
+ local open = "<" .. node.name
+ if #attrs > 0 then
+ open = open .. " " .. table.concat(attrs, " ")
+ end
+ if not node.children or #node.children == 0 then
+ return xml._indent(opt, level) .. open .. "/>"
+ end
+ local newline = opt.pretty and "\n" or ""
+ if #node.children == 1 and node.children[1].kind == "text" then
+ local text = xml._encode_text(tostring(node.children[1].text or ""))
+ return string.format("%s%s>%s</%s>", xml._indent(opt, level), open, text, node.name)
+ end
+ local result = {}
+ table.insert(result, xml._indent(opt, level) .. open .. ">")
+ if node.children then
+ for _, child in ipairs(node.children) do
+ table.insert(result, xml._encode_node(child, opt, level + 1))
+ end
+ end
+ table.insert(result, xml._indent(opt, level) .. "</" .. node.name .. ">")
+ return table.concat(result, newline ~= "" and newline or "")
+end
+
+-- create an xml element node
+-- e.g. `local node = xml.new({name = "item", attrs = {id = "1"}, children = {xml.text("value")}})`
+--
+-- @param opt table with name/attrs/children/kind/text fields
+-- @return node table
+--
+function xml.new(opt)
+ opt = opt or {}
+ return {
+ name = opt.name,
+ attrs = opt.attrs,
+ kind = opt.kind or "element",
+ text = opt.text,
+ children = opt.children
+ }
+end
+
+-- create a text node
+-- e.g. `local textnode = xml.text("hello")`
+--
+-- @param value string content
+-- @return text node
+--
+function xml.text(value)
+ return xml.new({kind = "text", text = value or ""})
+end
+
+-- create an empty element node
+-- e.g. `local br = xml.empty("br", {class = "line"})`
+--
+-- @param name element name
+-- @param attrs attribute table
+-- @return element node
+--
+function xml.empty(name, attrs)
+ return xml.new({name = name, attrs = attrs})
+end
+
+-- create a comment node
+-- e.g. `local comment = xml.comment("generated by xmake")`
+--
+-- @param value comment text
+-- @return comment node
+--
+function xml.comment(value)
+ return xml.new({kind = "comment", text = value or ""})
+end
+
+-- create a cdata node
+-- e.g. `local cdata = xml.cdata("if (a < b) { ... }")`
+--
+-- @param value cdata text
+-- @return cdata node
+--
+function xml.cdata(value)
+ return xml.new({kind = "cdata", text = value or ""})
+end
+
+-- create a doctype node
+-- e.g. `local doc = xml.doctype('html')`
+--
+-- @param value doctype payload
+-- @return doctype node
+--
+function xml.doctype(value)
+ return xml.new({kind = "doctype", text = value or ""})
+end
+
+-- decode xml string to tree node(s)
+-- e.g. `local doc, err = xml.decode("<root><item>foo</item></root>")`
+--
+-- @param data xml string
+-- @param opt options (e.g. {trim_text = true} to strip leading/trailing text, keep_whitespace_nodes = true)
+-- @return root node or list on success, nil + error on failure
+--
+function xml.decode(data, opt)
+ opt = opt or {}
+ local root_children = {}
+ local doc_node = {kind = "document", children = root_children}
+ local stack = {doc_node}
+ local function ensure_children(node)
+ if not node.children then
+ if node == doc_node then
+ node.children = root_children
+ else
+ node.children = {}
+ end
+ end
+ return node.children
+ end
+ local i = 1
+ local len = #data
+ while i <= len do
+ local lt = data:find("<", i, true)
+ if not lt then
+ local text = data:sub(i)
+ xml._append_text(stack, text, opt)
+ break
+ end
+ if lt > i then
+ local text = data:sub(i, lt - 1)
+ xml._append_text(stack, text, opt)
+ end
+ if data:sub(lt + 1, lt + 3) == "!--" then
+ local close = data:find("-->", lt + 4, true)
+ if not close then
+ return nil, "unterminated xml comment"
+ end
+ local value = data:sub(lt + 4, close - 1)
+ local top = stack[#stack]
+ local children = ensure_children(top)
+ table.insert(children, xml.comment(value))
+ i = close + 3
+ elseif data:sub(lt + 1, lt + 8) == "![CDATA[" then
+ local close = data:find("]]>", lt + 9, true)
+ if not close then
+ return nil, "unterminated cdata section"
+ end
+ local value = data:sub(lt + 9, close - 1)
+ local top = stack[#stack]
+ local children = ensure_children(top)
+ table.insert(children, xml.cdata(value))
+ i = close + 3
+ elseif data:sub(lt + 1, lt + 8):upper() == "!DOCTYPE" then
+ local close = data:find(">", lt + 8, true)
+ if not close then
+ return nil, "unterminated doctype declaration"
+ end
+ local value = data:sub(lt + 9, close - 1)
+ local top = stack[#stack]
+ local children = ensure_children(top)
+ table.insert(children, xml.doctype(value))
+ i = close + 1
+ elseif data:sub(lt + 1, lt + 1) == "?" then
+ local close = data:find("?>", lt + 2, true)
+ if not close then
+ return nil, "unterminated xml declaration"
+ end
+ i = close + 2
+ elseif data:sub(lt + 1, lt + 1) == "!" then
+ local close = data:find(">", lt + 2, true)
+ if not close then
+ return nil, "unterminated xml declaration"
+ end
+ i = close + 1
+ elseif data:sub(lt + 1, lt + 1) == "/" then
+ local close = data:find(">", lt + 1, true)
+ if not close then
+ return nil, "unterminated closing tag"
+ end
+ local tagname = data:sub(lt + 2, close - 1):match("^%s*([^%s>]+)")
+ local node, err = xml._handle_closing(stack, tagname)
+ if not node then
+ return nil, err
+ end
+ i = close + 1
+ else
+ local close = data:find(">", lt + 1, true)
+ if not close then
+ return nil, "unterminated opening tag"
+ end
+ local inside = data:sub(lt + 1, close - 1)
+ local selfclose = inside:find("/%s*$")
+ if selfclose then
+ inside = inside:gsub("/%s*$", "")
+ end
+ local tagname, attrstr = inside:match("^%s*([^%s>]+)%s*(.-)%s*$")
+ local attrs = xml._parse_attrs(attrstr or "")
+ local node = xml.empty(tagname, attrs)
+ local top = stack[#stack]
+ local children = ensure_children(top)
+ table.insert(children, node)
+ if not selfclose then
+ table.insert(stack, node)
+ end
+ i = close + 1
+ end
+ end
+ if #stack ~= 1 then
+ return nil, "malformed xml: unclosed tags"
+ end
+ local element_nodes = {}
+ for _, child in ipairs(root_children) do
+ if child.kind == "element" then
+ table.insert(element_nodes, child)
+ end
+ end
+ if #element_nodes == 1 then
+ local rootnode = element_nodes[1]
+ local prolog = {}
+ for _, child in ipairs(root_children) do
+ if child ~= rootnode then
+ if not (child.kind == "text" and (child.text or ""):match("^%s*$")) then
+ table.insert(prolog, child)
+ end
+ end
+ end
+ if #prolog > 0 then
+ rootnode.prolog = prolog
+ end
+ return rootnode
+ end
+ return root_children
+end
+
+-- stream parse xml data
+--
+-- @param data xml string
+-- @param callback function(node) -> true|false (return false to stop scanning)
+-- @param opt options (e.g. {trim_text = true}, {keep_whitespace_nodes = true})
+-- @return true on success or nil, error on failure
+--
+function xml.scan(data, callback, opt)
+ opt = opt or {}
+ local root_children = {}
+ local doc_node = {kind = "document", children = root_children}
+ local stack = {doc_node}
+ local stop = false
+ local function ensure_children(node)
+ if not node.children then
+ if node == doc_node then
+ node.children = root_children
+ else
+ node.children = {}
+ end
+ end
+ return node.children
+ end
+ local function emit(node)
+ if callback and node.kind ~= "document" then
+ if callback(node) == false then
+ stop = true
+ end
+ end
+ end
+ local i = 1
+ local len = #data
+ while i <= len do
+ if stop then
+ break
+ end
+ local lt = data:find("<", i, true)
+ if not lt then
+ local text = data:sub(i)
+ xml._append_text(stack, text, opt)
+ break
+ end
+ if lt > i then
+ local text = data:sub(i, lt - 1)
+ xml._append_text(stack, text, opt)
+ end
+ if data:sub(lt + 1, lt + 3) == "!--" then
+ local close = data:find("-->", lt + 4, true)
+ if not close then
+ return nil, "unterminated xml comment"
+ end
+ local value = data:sub(lt + 4, close - 1)
+ local top = stack[#stack]
+ local children = ensure_children(top)
+ local node = xml.comment(value)
+ table.insert(children, node)
+ emit(node)
+ i = close + 3
+ elseif data:sub(lt + 1, lt + 8) == "![CDATA[" then
+ local close = data:find("]]>", lt + 9, true)
+ if not close then
+ return nil, "unterminated cdata section"
+ end
+ local value = data:sub(lt + 9, close - 1)
+ local top = stack[#stack]
+ local children = ensure_children(top)
+ local node = xml.cdata(value)
+ table.insert(children, node)
+ emit(node)
+ i = close + 3
+ elseif data:sub(lt + 1, lt + 8):upper() == "!DOCTYPE" then
+ local close = data:find(">", lt + 8, true)
+ if not close then
+ return nil, "unterminated doctype declaration"
+ end
+ local value = data:sub(lt + 9, close - 1)
+ local top = stack[#stack]
+ local children = ensure_children(top)
+ local node = xml.doctype(value)
+ table.insert(children, node)
+ emit(node)
+ i = close + 1
+ elseif data:sub(lt + 1, lt + 1) == "?" then
+ local close = data:find("?>", lt + 2, true)
+ if not close then
+ return nil, "unterminated xml declaration"
+ end
+ i = close + 2
+ elseif data:sub(lt + 1, lt + 1) == "!" then
+ local close = data:find(">", lt + 2, true)
+ if not close then
+ return nil, "unterminated xml declaration"
+ end
+ i = close + 1
+ elseif data:sub(lt + 1, lt + 1) == "/" then
+ local close = data:find(">", lt + 1, true)
+ if not close then
+ return nil, "unterminated closing tag"
+ end
+ local tagname = data:sub(lt + 2, close - 1):match("^%s*([^%s>]+)")
+ local node, err = xml._handle_closing(stack, tagname)
+ if not node then
+ return nil, err
+ end
+ emit(node)
+ i = close + 1
+ else
+ local close = data:find(">", lt + 1, true)
+ if not close then
+ return nil, "unterminated opening tag"
+ end
+ local inside = data:sub(lt + 1, close - 1)
+ local selfclose = inside:find("/%s*$")
+ if selfclose then
+ inside = inside:gsub("/%s*$", "")
+ end
+ local tagname, attrstr = inside:match("^%s*([^%s>]+)%s*(.-)%s*$")
+ local attrs = xml._parse_attrs(attrstr or "")
+ local node = xml.empty(tagname, attrs)
+ local top = stack[#stack]
+ local children = ensure_children(top)
+ table.insert(children, node)
+ if not selfclose then
+ table.insert(stack, node)
+ else
+ emit(node)
+ end
+ i = close + 1
+ end
+ end
+ return true
+end
+
+-- encode xml node to string
+-- e.g. `local xmlstr = xml.encode(node, {pretty = true, indent = 2})`
+--
+-- @param node xml node
+-- @param opt options (pretty, indent, etc.)
+-- @return xml string or nil, err
+--
+function xml.encode(node, opt)
+ opt = opt or {}
+ local fragments = {}
+ local prolog = node.prolog
+ if prolog then
+ for _, child in ipairs(prolog) do
+ table.insert(fragments, xml._encode_node(child, opt, 0))
+ if opt.pretty then
+ table.insert(fragments, "\n")
+ end
+ end
+ end
+ table.insert(fragments, xml._encode_node(node, opt, 0))
+ return table.concat(fragments)
+end
+
+-- load xml file
+-- e.g. `local doc, err = xml.loadfile("foo.xml")`
+--
+-- @param filepath file path
+-- @param opt read/decode options
+-- @return node or nil + error
+--
+function xml.loadfile(filepath, opt)
+ local data, err = io.readfile(filepath, opt)
+ if not data then
+ return nil, err
+ end
+ return xml.decode(data, opt)
+end
+
+-- save xml node to file
+-- e.g. `assert(xml.savefile("foo.xml", node, {pretty = true}))`
+--
+-- @param filepath destination file
+-- @param node xml node
+-- @param opt encode/write options
+-- @return true on success or nil + error message
+--
+function xml.savefile(filepath, node, opt)
+ local data = xml.encode(node, opt)
+ if not data then
+ return nil, "failed to encode xml"
+ end
+ return io.writefile(filepath, data, opt)
+end
+
+-- find the first matching node with an XPath-like expression
+-- e.g. `xml.find(doc, "//dict/key[@name='CFBundleName']")`
+--
+-- Supported syntax:
+-- - `/` child axis, `//` descendant-or-self axis
+-- - `*`, `text()`, `comment()`, `cdata()`, `doctype()`, `.`
+-- - Attribute predicates: `[@id='foo']`, `[@enabled]`
+-- - Text predicate: `[text()='value']`
+-- - Positional predicate: `[2]`
+--
+-- @param node root node
+-- @param path xpath-like string
+-- @return first matched node or nil
+--
+function xml.find(node, path)
+ if not node or not path or path == "" then
+ return nil
+ end
+ local steps = xml._parse_xpath(path)
+ if #steps == 0 then
+ return nil
+ end
+ local current
+ if path:sub(1, 1) == "/" then
+ current = {{kind = "document", children = {node}}}
+ else
+ current = {node}
+ end
+ for _, step in ipairs(steps) do
+ local matches = {}
+ if step.axis == "self" then
+ for _, candidate in ipairs(current) do
+ if xml._match_xpath_node(candidate, step) then
+ table.insert(matches, candidate)
+ end
+ end
+ elseif step.axis == "child" then
+ for _, parent in ipairs(current) do
+ xml._each_child(parent, function(child)
+ if xml._match_xpath_node(child, step) then
+ table.insert(matches, child)
+ end
+ end)
+ end
+ elseif step.axis == "descendant" then
+ for _, parent in ipairs(current) do
+ if xml._match_xpath_node(parent, step) then
+ table.insert(matches, parent)
+ end
+ xml._collect_descendants(parent, function(desc)
+ return xml._match_xpath_node(desc, step)
+ end, matches)
+ end
+ else
+ return nil
+ end
+ matches = xml._apply_xpath_indexes(matches, step.indexes)
+ if #matches == 0 then
+ return nil
+ end
+ current = matches
+ end
+ return current[1]
+end
+
+-- get concatenated text from child nodes
+-- e.g. `local text = xml.text_of(xml.decode("<item>foo</item>"))`
+--
+-- @param node xml node
+-- @return concatenated string
+--
+function xml.text_of(node)
+ if not node or not node.children then
+ return ""
+ end
+ local buffer = {}
+ for _, child in ipairs(node.children) do
+ if child.kind == "text" then
+ table.insert(buffer, child.text or "")
+ end
+ end
+ return table.concat(buffer, "")
+end
+
+return xml
+
diff --git a/xmake/core/sandbox/modules/import/core/base/xml.lua b/xmake/core/sandbox/modules/import/core/base/xml.lua
new file mode 100644
index 000000000..fdfe8318a
--- /dev/null
+++ b/xmake/core/sandbox/modules/import/core/base/xml.lua
@@ -0,0 +1,77 @@
+--!A cross-platform build utility based on Lua
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+-- http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+--
+-- Copyright (C) 2015-present, Xmake Open Source Community.
+--
+-- @author ruki
+-- @file xml.lua
+--
+
+-- define module
+local sandbox_core_base_xml = sandbox_core_base_xml or {}
+
+-- load modules
+local xml = require("base/xml")
+local raise = require("sandbox/modules/raise")
+
+-- inherit some builtin interfaces
+sandbox_core_base_xml.encode = xml.encode
+sandbox_core_base_xml.find = xml.find
+sandbox_core_base_xml.text_of = xml.text_of
+sandbox_core_base_xml.text = xml.text
+sandbox_core_base_xml.new = xml.new
+sandbox_core_base_xml.empty = xml.empty
+sandbox_core_base_xml.comment = xml.comment
+sandbox_core_base_xml.cdata = xml.cdata
+sandbox_core_base_xml.doctype = xml.doctype
+
+-- decode xml data
+function sandbox_core_base_xml.decode(data, opt)
+ local node, errors = xml.decode(data, opt)
+ if not node then
+ raise(errors)
+ end
+ return node
+end
+
+-- stream parse xml data
+function sandbox_core_base_xml.scan(data, callback, opt)
+ local ok, errors = xml.scan(data, callback, opt)
+ if ok == nil then
+ raise(errors)
+ end
+ return ok
+end
+
+-- load xml file to the lua table
+function sandbox_core_base_xml.loadfile(filepath, opt)
+ local node, errors = xml.loadfile(filepath, opt)
+ if not node then
+ raise(errors)
+ end
+ return node
+end
+
+-- save xml node to the file
+function sandbox_core_base_xml.savefile(filepath, node, opt)
+ local ok, errors = xml.savefile(filepath, node, opt)
+ if not ok then
+ raise(errors)
+ end
+ return ok
+end
+
+-- return module
+return sandbox_core_base_xml
+