diff options
| author | ruki <[email protected]> | 2025-11-16 00:16:23 +0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-11-16 00:16:23 +0800 |
| commit | d5e9f0ee721f63fc664855ea33c73fc62093fcb6 (patch) | |
| tree | f461030671509bb245e2a3676f6f81e6a8b47208 | |
| parent | 6869f1541f155dcc240f0a0a2de9e076f377856a (diff) | |
| parent | b250c84aee0072ff92b73fe41403d364f95a03d5 (diff) | |
Merge pull request #7025 from xmake-io/xml
Add xml module
| -rw-r--r-- | tests/modules/xml/test.lua | 189 | ||||
| -rw-r--r-- | xmake/core/base/xml.lua | 899 | ||||
| -rw-r--r-- | xmake/core/sandbox/modules/import/core/base/xml.lua | 77 |
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 & 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("<", "<") + :gsub(">", ">") + :gsub("'", "'") + :gsub(""", "\"") + :gsub("&", "&")) +end + +-- encode raw text for xml element content +function xml._encode_text(str) + return (str:gsub("&", "&") + :gsub("<", "<") + :gsub(">", ">")) +end + +-- encode attribute value for xml output +function xml._encode_attr(str) + return xml._encode_text(str):gsub("\"", """) +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 + |
