From 29fbfdef99861931d81578eac3434f452efddb8e Mon Sep 17 00:00:00 2001 From: ruki Date: Sat, 15 Nov 2025 00:48:55 +0800 Subject: add xml module --- tests/modules/xml/test.lua | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/modules/xml/test.lua (limited to 'tests/modules/xml/test.lua') diff --git a/tests/modules/xml/test.lua b/tests/modules/xml/test.lua new file mode 100644 index 000000000..eb01aad86 --- /dev/null +++ b/tests/modules/xml/test.lua @@ -0,0 +1,29 @@ +import("core.base.xml") + +function test_parse_basic(t) + local doc = xml.decode([[foo]]) + 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[2].attrs.id, "2") +end + +function test_encode(t) + local doc = xml.new("root", {id = "1"}, { + xml.new("item", {}, {xml.text("foo")}), + xml.new("item", {id = "2"}) + }) + local compact = xml.encode(doc) + t:are_equal(compact, 'foo') + local pretty = xml.encode(doc, {pretty = true, indent = 2}) + local expected = table.concat({ + '', + ' foo', + ' ', + '' + }, "\n") + t:are_equal(pretty, expected) +end + -- cgit v1.3.1 From d6b3bc59cad34b6279f3df8a2066505ca82a2733 Mon Sep 17 00:00:00 2001 From: ruki Date: Sat, 15 Nov 2025 00:54:20 +0800 Subject: improve xml module --- tests/modules/xml/test.lua | 30 ++++++- xmake/core/base/xml.lua | 91 +++++++++++++++++++--- .../core/sandbox/modules/import/core/base/xml.lua | 4 + 3 files changed, 110 insertions(+), 15 deletions(-) (limited to 'tests/modules/xml/test.lua') diff --git a/tests/modules/xml/test.lua b/tests/modules/xml/test.lua index eb01aad86..80ce486f6 100644 --- a/tests/modules/xml/test.lua +++ b/tests/modules/xml/test.lua @@ -7,13 +7,18 @@ function test_parse_basic(t) 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_encode(t) - local doc = xml.new("root", {id = "1"}, { - xml.new("item", {}, {xml.text("foo")}), - xml.new("item", {id = "2"}) + 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, 'foo') @@ -27,3 +32,22 @@ function test_encode(t) t:are_equal(pretty, expected) end +function test_special_nodes(t) + t:are_equal(xml.encode(xml.comment("note")), "") + t:are_equal(xml.encode(xml.cdata("a < b")), "") + t:are_equal(xml.encode(xml.doctype("note SYSTEM \"note.dtd\"")), "") + t:are_equal(xml.encode(xml.empty("br")), "
") +end + +function test_parse_special_nodes(t) + local doc = xml.decode([[]]) + t:are_equal(doc.children[1].name, "__comment__") + t:are_equal(doc.children[1].children[1], "note") + t:are_equal(doc.children[2].name, "__cdata__") + t:are_equal(doc.children[2].children[1], "a < b") + t:are_equal(doc.children[3].name, "child") + local nodes = xml.decode([[]]) + t:are_equal(nodes[1].name, "__doctype__") + t:are_equal(nodes[2].name, "root") +end + diff --git a/xmake/core/base/xml.lua b/xmake/core/base/xml.lua index 7ac12f133..a3f2c96f0 100644 --- a/xmake/core/base/xml.lua +++ b/xmake/core/base/xml.lua @@ -45,25 +45,55 @@ function xml._encode_attr(str) end function xml._parse_attrs(attrstr) - local attrs = {} + local attrs attrstr:gsub("([%w_:%-%.]+)%s*=%s*([\"'])(.-)%2", function(key, quote, value) + attrs = attrs or {} attrs[key] = xml._decode_entities(value) end) return attrs end -- create an xml element node -function xml.new(name, attrs, children) +-- e.g. `local node = xml.new({name = "item", attrs = {id = "1"}, children = {xml.text("value")}})` +function xml.new(opt) + opt = opt or {} return { - name = name, - attrs = attrs or {}, - children = children or {} + name = opt.name, + attrs = opt.attrs, + kind = opt.kind or "element", + text = opt.text, + children = opt.children or {} } end -- create a text node +-- e.g. `local textnode = xml.text("hello")` function xml.text(value) - return {type = "text", text = value or ""} + return xml.new({kind = "text", text = value or ""}) +end + +-- create an empty element node +-- e.g. `local br = xml.empty("br", {class = "line"})` +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")` +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) { ... }")` +function xml.cdata(value) + return xml.new({kind = "cdata", text = value or ""}) +end + +-- create a doctype node +-- e.g. `local doc = xml.doctype('html')` +function xml.doctype(value) + return xml.new({kind = "doctype", text = value or ""}) end function xml._append_text(stack, text, opt) @@ -74,7 +104,7 @@ function xml._append_text(stack, text, opt) if text ~= "" then local top = stack[#stack] top.children = top.children or {} - table.insert(top.children, {type = "text", text = xml._decode_entities(text)}) + table.insert(top.children, xml.text(xml._decode_entities(text))) end end @@ -88,6 +118,7 @@ function xml._handle_closing(stack, tagname) end -- decode xml string to tree node(s) +-- e.g. `local doc, err = xml.decode("foo")` function xml.decode(data, opt) opt = opt or {} local root = {name = "__root__", attrs = {}, children = {}} @@ -110,7 +141,31 @@ function xml.decode(data, opt) if not close then return nil, "unterminated xml comment" end + local value = data:sub(lt + 4, close - 1) + local top = stack[#stack] + top.children = top.children or {} + table.insert(top.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] + top.children = top.children or {} + table.insert(top.children, xml.cdata(value)) i = close + 3 + elseif data:sub(lt + 1, lt + 9):upper() == "!DOCTYPE" then + local close = data:find(">", lt + 9) + if not close then + return nil, "unterminated doctype declaration" + end + local value = data:sub(lt + 10, close - 1) + local top = stack[#stack] + top.children = top.children or {} + table.insert(top.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 @@ -146,7 +201,7 @@ function xml.decode(data, opt) end local tagname, attrstr = inside:match("^%s*([^%s>]+)%s*(.-)%s*$") local attrs = xml._parse_attrs(attrstr or "") - local node = {name = tagname, attrs = attrs, children = {}} + local node = xml.empty(tagname, attrs) local top = stack[#stack] top.children = top.children or {} table.insert(top.children, node) @@ -180,13 +235,19 @@ end function xml._encode_node(node, opt, level) opt = opt or {} level = level or 0 - if node.type == "text" then + if 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("", tostring(node.text or "")) + elseif node.kind == "cdata" then + return xml._indent(opt, level) .. string.format("", tostring(node.text or "")) + elseif node.kind == "doctype" then + return xml._indent(opt, level) .. string.format("", tostring(node.text or "")) end local attrs = {} for k, v in pairs(node.attrs or {}) do @@ -201,7 +262,7 @@ function xml._encode_node(node, opt, level) return xml._indent(opt, level) .. open .. "/>" end local newline = opt.pretty and "\n" or "" - if #node.children == 1 and node.children[1].type == "text" then + 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", xml._indent(opt, level), open, text, node.name) end @@ -215,12 +276,14 @@ function xml._encode_node(node, opt, level) end -- encode xml node to string +-- e.g. `local xmlstr = xml.encode(node, {pretty = true, indent = 2})` function xml.encode(node, opt) opt = opt or {} return xml._encode_node(node, opt, 0) end -- load xml file +-- e.g. `local doc, err = xml.load("foo.xml")` function xml.load(filepath, opt) local data, err = io.readfile(filepath, opt) if not data then @@ -230,6 +293,7 @@ function xml.load(filepath, opt) end -- save xml node to file +-- e.g. `assert(xml.save("foo.xml", node, {pretty = true}))` function xml.save(filepath, node, opt) local data = xml.encode(node, opt) if not data then @@ -239,6 +303,8 @@ function xml.save(filepath, node, opt) end -- find the first child node with the given name +-- e.g. `local doc = xml.decode("")` +-- `local item = xml.find(doc, "item")` function xml.find(node, name) if not node or not node.children then return nil @@ -251,14 +317,15 @@ function xml.find(node, name) end -- get concatenated text from child nodes +-- e.g. `local text = xml.text_of(xml.decode("foo"))` 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.type == "text" then - table.insert(buffer, child.text) + if child.kind == "text" then + table.insert(buffer, child.text or "") end end return table.concat(buffer, "") diff --git a/xmake/core/sandbox/modules/import/core/base/xml.lua b/xmake/core/sandbox/modules/import/core/base/xml.lua index 7867c3295..6af763e47 100644 --- a/xmake/core/sandbox/modules/import/core/base/xml.lua +++ b/xmake/core/sandbox/modules/import/core/base/xml.lua @@ -31,6 +31,10 @@ 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) -- cgit v1.3.1 From 3d6be9e48846d619bf389ee9a16157281bafa87b Mon Sep 17 00:00:00 2001 From: ruki Date: Sat, 15 Nov 2025 00:14:29 +0800 Subject: fix test --- tests/modules/xml/test.lua | 41 ++++++++++++++------- xmake/core/base/xml.lua | 88 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 97 insertions(+), 32 deletions(-) (limited to 'tests/modules/xml/test.lua') diff --git a/tests/modules/xml/test.lua b/tests/modules/xml/test.lua index 80ce486f6..271865338 100644 --- a/tests/modules/xml/test.lua +++ b/tests/modules/xml/test.lua @@ -1,7 +1,8 @@ import("core.base.xml") -function test_parse_basic(t) +function test_decode_basic(t) local doc = xml.decode([[foo]]) + 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) @@ -11,7 +12,7 @@ function test_parse_basic(t) t:are_equal(doc.children[2].attrs.id, "2") end -function test_encode(t) +function test_encode_basic(t) local doc = xml.new({ name = "root", attrs = {id = "1"}, @@ -32,22 +33,38 @@ function test_encode(t) t:are_equal(pretty, expected) end -function test_special_nodes(t) +function test_encode_special_nodes(t) t:are_equal(xml.encode(xml.comment("note")), "") t:are_equal(xml.encode(xml.cdata("a < b")), "") t:are_equal(xml.encode(xml.doctype("note SYSTEM \"note.dtd\"")), "") t:are_equal(xml.encode(xml.empty("br")), "
") end -function test_parse_special_nodes(t) - local doc = xml.decode([[]]) - t:are_equal(doc.children[1].name, "__comment__") - t:are_equal(doc.children[1].children[1], "note") - t:are_equal(doc.children[2].name, "__cdata__") - t:are_equal(doc.children[2].children[1], "a < b") +function test_decode_special_nodes(t) + local doc = xml.decode([=[]=]) + 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([[]]) - t:are_equal(nodes[1].name, "__doctype__") - t:are_equal(nodes[2].name, "root") + local nodes = xml.decode([=[]=]) + 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.save(filepath, doc, {pretty = true})) + local reloaded = xml.load(filepath) + t:are_equal(reloaded.name, "root") + t:are_equal(xml.text_of(reloaded), "hello") + os.tryrm(filepath) end diff --git a/xmake/core/base/xml.lua b/xmake/core/base/xml.lua index 1e644777f..2aa4036b8 100644 --- a/xmake/core/base/xml.lua +++ b/xmake/core/base/xml.lua @@ -29,10 +29,11 @@ local table = require("base/table") -- XML node structure: -- { -- name = "element-name" | nil (for non-element nodes) --- kind = "element" | "text" | "comment" | "cdata" | "doctype" +-- 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 (only for elements with children) +-- children = { child1, child2, ... } or nil (for elements or document nodes) +-- prolog = { comment/doctypes before root } or nil (only on root element) -- } -- -- Example: @@ -41,6 +42,7 @@ local table = require("base/table") -- doc.attrs.id == "1" -- xml.find(doc, "root/item").kind == "element" -- xml.text_of(doc) == "" -- since root has no direct text nodes +-- doc.prolog[1].kind == "doctype" -- e.g. when document had -- doc.children[2].kind == "comment" and doc.children[2].text == "note" -- @@ -145,8 +147,19 @@ end -- e.g. `local doc, err = xml.decode("foo")` function xml.decode(data, opt) opt = opt or {} - local root = {name = "__root__", attrs = {}, children = {}} - local stack = {root} + 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 @@ -167,8 +180,8 @@ function xml.decode(data, opt) end local value = data:sub(lt + 4, close - 1) local top = stack[#stack] - top.children = top.children or {} - table.insert(top.children, xml.comment(value)) + 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) @@ -177,18 +190,18 @@ function xml.decode(data, opt) end local value = data:sub(lt + 9, close - 1) local top = stack[#stack] - top.children = top.children or {} - table.insert(top.children, xml.cdata(value)) + local children = ensure_children(top) + table.insert(children, xml.cdata(value)) i = close + 3 - elseif data:sub(lt + 1, lt + 9):upper() == "!DOCTYPE" then - local close = data:find(">", lt + 9) + elseif data:sub(lt + 1, lt + 8):upper() == "!DOCTYPE" then + local close = data:find(">", lt + 8) if not close then return nil, "unterminated doctype declaration" end - local value = data:sub(lt + 10, close - 1) + local value = data:sub(lt + 9, close - 1) local top = stack[#stack] - top.children = top.children or {} - table.insert(top.children, xml.doctype(value)) + 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) @@ -227,8 +240,8 @@ function xml.decode(data, opt) local attrs = xml._parse_attrs(attrstr or "") local node = xml.empty(tagname, attrs) local top = stack[#stack] - top.children = top.children or {} - table.insert(top.children, node) + local children = ensure_children(top) + table.insert(children, node) if not selfclose then table.insert(stack, node) end @@ -238,10 +251,26 @@ function xml.decode(data, opt) if #stack ~= 1 then return nil, "malformed xml: unclosed tags" end - if #root.children == 1 then - return root.children[1] + 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 + table.insert(prolog, child) + end + end + if #prolog > 0 then + rootnode.prolog = prolog + end + return rootnode end - return root.children + return root_children end -- compute indent string for pretty output @@ -260,7 +289,15 @@ end function xml._encode_node(node, opt, level) opt = opt or {} level = level or 0 - if node.kind == "text" then + 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 @@ -306,7 +343,18 @@ end -- e.g. `local xmlstr = xml.encode(node, {pretty = true, indent = 2})` function xml.encode(node, opt) opt = opt or {} - return xml._encode_node(node, opt, 0) + 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 -- cgit v1.3.1 From e8aa6bd86dcfc67fb2fd5d9bb4319a956097f9c1 Mon Sep 17 00:00:00 2001 From: ruki Date: Sat, 15 Nov 2025 00:19:32 +0800 Subject: add more tests --- tests/modules/xml/test.lua | 33 +++++++++++++++++++++++++++++++++ xmake/core/base/xml.lua | 12 +++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) (limited to 'tests/modules/xml/test.lua') diff --git a/tests/modules/xml/test.lua b/tests/modules/xml/test.lua index 271865338..841a1c797 100644 --- a/tests/modules/xml/test.lua +++ b/tests/modules/xml/test.lua @@ -68,3 +68,36 @@ function test_load_save(t) os.tryrm(filepath) end +function test_plist_sample(t) + local plist = [[ + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + NSHumanReadableCopyright + Copyright © 2020 tboox. All rights reserved. + NSSupportsAutomaticTermination + + +]] + 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 + diff --git a/xmake/core/base/xml.lua b/xmake/core/base/xml.lua index 2aa4036b8..84da8e16b 100644 --- a/xmake/core/base/xml.lua +++ b/xmake/core/base/xml.lua @@ -385,9 +385,12 @@ function xml.find(node, path) end local segments = path:split("/", {strict = true}) local current = {node} - for _, segment in ipairs(segments) do + for idx, segment in ipairs(segments) do local next_level = {} for _, parent in ipairs(current) do + if parent.name == segment then + table.insert(next_level, parent) + end if parent.children then for _, child in ipairs(parent.children) do if child.name == segment then @@ -395,6 +398,13 @@ function xml.find(node, path) end end end + if parent.prolog then + for _, child in ipairs(parent.prolog) do + if child.name == segment then + table.insert(next_level, child) + end + end + end end if #next_level == 0 then return nil -- cgit v1.3.1 From dc6a23d0752243d950b81628124fcaa9b907dabe Mon Sep 17 00:00:00 2001 From: ruki Date: Sat, 15 Nov 2025 00:40:08 +0800 Subject: add new apis --- tests/modules/xml/test.lua | 23 ++ xmake/core/base/xml.lua | 311 +++++++++++++++++---- .../core/sandbox/modules/import/core/base/xml.lua | 9 + 3 files changed, 281 insertions(+), 62 deletions(-) (limited to 'tests/modules/xml/test.lua') diff --git a/tests/modules/xml/test.lua b/tests/modules/xml/test.lua index 841a1c797..60d50d62a 100644 --- a/tests/modules/xml/test.lua +++ b/tests/modules/xml/test.lua @@ -101,3 +101,26 @@ function test_plist_sample(t) t:are_equal(last_flag.name, "true") end +function test_scan_stop(t) + local plist = [[ + + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + NSPrincipalClass + NSApplication + +]] + 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 + diff --git a/xmake/core/base/xml.lua b/xmake/core/base/xml.lua index 84da8e16b..07260959c 100644 --- a/xmake/core/base/xml.lua +++ b/xmake/core/base/xml.lua @@ -97,11 +97,81 @@ function xml._handle_closing(stack, tagname) return nil, string.format("malformed xml: unexpected closing ", tagname) end table.remove(stack) - return true + 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("", tostring(node.text or "")) + elseif node.kind == "cdata" then + return xml._indent(opt, level) .. string.format("", tostring(node.text or "")) + elseif node.kind == "doctype" then + return xml._indent(opt, level) .. string.format("", 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", 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) .. "") + 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 { @@ -115,36 +185,62 @@ 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 +-- 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("foo")` +-- +-- @param data xml string +-- @param opt options (trim_text, etc.) +-- @return root node or list on success, nil + error on failure +-- function xml.decode(data, opt) opt = opt or {} local root_children = {} @@ -221,8 +317,8 @@ function xml.decode(data, opt) return nil, "unterminated closing tag" end local tagname = data:sub(lt + 2, close - 1):match("^%s*([^%s>]+)") - local ok, err = xml._handle_closing(stack, tagname) - if not ok then + local node, err = xml._handle_closing(stack, tagname) + if not node then return nil, err end i = close + 1 @@ -273,74 +369,146 @@ function xml.decode(data, opt) return root_children 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) +-- stream parse xml data +-- +-- @param data xml string +-- @param callback function(node) -> true|false (return false to stop scanning) +-- @param opt options (trim_text, etc.) +-- @return true on success or nil, error on failure +-- +function xml.scan(data, callback, opt) 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)) + 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 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("", tostring(node.text or "")) - elseif node.kind == "cdata" then - return xml._indent(opt, level) .. string.format("", tostring(node.text or "")) - elseif node.kind == "doctype" then - return xml._indent(opt, level) .. string.format("", 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 .. "/>" + return node.children 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", xml._indent(opt, level), open, text, node.name) + local function emit(node) + if callback and node.kind ~= "document" then + if callback(node) == false then + stop = true + end + end 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)) + 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) + 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) + 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) + 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) + 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 - table.insert(result, xml._indent(opt, level) .. "") - return table.concat(result, newline ~= "" and newline or "") + 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 = {} @@ -359,6 +527,11 @@ end -- load xml file -- e.g. `local doc, err = xml.load("foo.xml")` +-- +-- @param filepath file path +-- @param opt read/decode options +-- @return node or nil + error +-- function xml.load(filepath, opt) local data, err = io.readfile(filepath, opt) if not data then @@ -369,6 +542,12 @@ end -- save xml node to file -- e.g. `assert(xml.save("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.save(filepath, node, opt) local data = xml.encode(node, opt) if not data then @@ -378,7 +557,11 @@ function xml.save(filepath, node, opt) end -- find the first matching node by name or path (e.g. "root/item/subitem") --- returns nil if not found +-- +-- @param node root node +-- @param path slash separated string +-- @return first matched node or nil +-- function xml.find(node, path) if not node or not path or path == "" then return nil @@ -416,6 +599,10 @@ end -- get concatenated text from child nodes -- e.g. `local text = xml.text_of(xml.decode("foo"))` +-- +-- @param node xml node +-- @return concatenated string +-- function xml.text_of(node) if not node or not node.children then return "" diff --git a/xmake/core/sandbox/modules/import/core/base/xml.lua b/xmake/core/sandbox/modules/import/core/base/xml.lua index 6af763e47..1aed8b159 100644 --- a/xmake/core/sandbox/modules/import/core/base/xml.lua +++ b/xmake/core/sandbox/modules/import/core/base/xml.lua @@ -45,6 +45,15 @@ function sandbox_core_base_xml.decode(data, opt) 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.load(filepath, opt) local node, errors = xml.load(filepath, opt) -- cgit v1.3.1 From cda90e424674c41c3447d4ebc3709dd290ccf512 Mon Sep 17 00:00:00 2001 From: ruki Date: Sat, 15 Nov 2025 19:38:12 +0800 Subject: add xpath support --- tests/modules/xml/test.lua | 19 +++ xmake/core/base/xml.lua | 280 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 278 insertions(+), 21 deletions(-) (limited to 'tests/modules/xml/test.lua') diff --git a/tests/modules/xml/test.lua b/tests/modules/xml/test.lua index 60d50d62a..21a4e7745 100644 --- a/tests/modules/xml/test.lua +++ b/tests/modules/xml/test.lua @@ -124,3 +124,22 @@ function test_scan_stop(t) t:are_equal(xml.text_of(found), "NSPrincipalClass") end +function test_find_xpath(t) + local doc = xml.decode([[ + + + foo + bar + + + + +]]) + 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 + diff --git a/xmake/core/base/xml.lua b/xmake/core/base/xml.lua index 9bcbe420c..8bbdbb201 100644 --- a/xmake/core/base/xml.lua +++ b/xmake/core/base/xml.lua @@ -77,6 +77,220 @@ function xml._parse_attrs(attrstr) return attrs end +-- trim helper +function xml._trim(str) + return (str:gsub("^%s+", ""):gsub("%s+$", "")) +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 = xml._trim(path:sub(start, i - 1)) + 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 = xml._trim(name) + 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 = xml._trim(predicate:sub(2, -2)) + 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 {} @@ -556,43 +770,67 @@ function xml.save(filepath, node, opt) return io.writefile(filepath, data, opt) end --- find the first matching node by name or path (e.g. "root/item/subitem") +-- 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 slash separated string +-- @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 segments = path:split("/", {strict = true}) - local current = {node} - for idx, segment in ipairs(segments) do - local next_level = {} - for _, parent in ipairs(current) do - if parent.name == segment then - table.insert(next_level, parent) - end - if parent.children then - for _, child in ipairs(parent.children) do - if child.name == segment then - table.insert(next_level, child) - 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 - if parent.prolog then - for _, child in ipairs(parent.prolog) do - if child.name == segment then - table.insert(next_level, child) + 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 - if #next_level == 0 then + matches = xml._apply_xpath_indexes(matches, step.indexes) + if #matches == 0 then return nil end - current = next_level + current = matches end return current[1] end -- cgit v1.3.1 From 0a21a3d88e6126b5bb691d393bfc7499810e3d4a Mon Sep 17 00:00:00 2001 From: ruki Date: Sat, 15 Nov 2025 19:42:39 +0800 Subject: update tests --- tests/modules/xml/test.lua | 12 ++++++++++++ xmake/core/base/xml.lua | 13 ++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) (limited to 'tests/modules/xml/test.lua') diff --git a/tests/modules/xml/test.lua b/tests/modules/xml/test.lua index 21a4e7745..e7266c919 100644 --- a/tests/modules/xml/test.lua +++ b/tests/modules/xml/test.lua @@ -143,3 +143,15 @@ function test_find_xpath(t) t:are_equal(xml.text_of(value), "bar") end +function test_find_update(t) + local doc = xml.decode("foo") + 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, 'barbaz') +end + diff --git a/xmake/core/base/xml.lua b/xmake/core/base/xml.lua index 8bbdbb201..ab04c9696 100644 --- a/xmake/core/base/xml.lua +++ b/xmake/core/base/xml.lua @@ -26,7 +26,7 @@ local io = require("base/io") local os = require("base/os") local table = require("base/table") --- XML node structure: +-- XML node structure (mutable DOM-style tables): -- { -- name = "element-name" | nil (for non-element nodes) -- kind = "element" | "text" | "comment" | "cdata" | "doctype" | "document" @@ -38,12 +38,11 @@ local table = require("base/table") -- -- Example: -- local doc = xml.decode("foo") --- doc.kind == "element" --- doc.attrs.id == "1" --- xml.find(doc, "root/item").kind == "element" --- xml.text_of(doc) == "" -- since root has no direct text nodes --- doc.prolog[1].kind == "doctype" -- e.g. when document had --- doc.children[2].kind == "comment" and doc.children[2].text == "note" +-- local node = xml.find(doc, "root/item") +-- node.attrs = {lang = "en"} +-- node.children = {xml.text("bar")} +-- xml.encode(doc) == 'bar' +-- -- nodes are regular Lua tables, so modifying them in-place updates the DOM -- -- decode entities -- cgit v1.3.1 From 9c8f2febdddbe6e1ca4f6e04d4ba0d6f4f0f364f Mon Sep 17 00:00:00 2001 From: ruki Date: Sat, 15 Nov 2025 19:54:35 +0800 Subject: use string apis --- tests/modules/xml/test.lua | 13 +++++++++++++ xmake/core/base/xml.lua | 42 ++++++++++++++++++++++-------------------- 2 files changed, 35 insertions(+), 20 deletions(-) (limited to 'tests/modules/xml/test.lua') diff --git a/tests/modules/xml/test.lua b/tests/modules/xml/test.lua index e7266c919..985600a9d 100644 --- a/tests/modules/xml/test.lua +++ b/tests/modules/xml/test.lua @@ -155,3 +155,16 @@ function test_find_update(t) t:are_equal(encoded, 'barbaz') end +function test_decode_trim_text(t) + local doc = xml.decode(" foo ") + t:are_equal(xml.text_of(doc), " foo ") + local trimmed = xml.decode(" foo ", {trim_text = true}) + t:are_equal(xml.text_of(trimmed), "foo") + local formatted = "\n \n" + 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 index 25c43423d..beb3a2ed4 100644 --- a/xmake/core/base/xml.lua +++ b/xmake/core/base/xml.lua @@ -22,9 +22,10 @@ local xml = xml or {} -- load modules -local io = require("base/io") -local os = require("base/os") -local table = require("base/table") +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): -- { @@ -90,11 +91,6 @@ function xml._parse_attrs(attrstr) return attrs end --- trim helper -function xml._trim(str) - return (str:gsub("^%s+", ""):gsub("%s+$", "")) -end - -- iterate element children and optional prolog nodes function xml._each_child(node, callback) if not node or not callback then @@ -168,7 +164,7 @@ function xml._parse_xpath(path) end i = i + 1 end - local segment = xml._trim(path:sub(start, i - 1)) + local segment = path:sub(start, i - 1):trim() if segment ~= "" then local step = xml._parse_xpath_segment(segment, axis) table.insert(steps, step) @@ -182,7 +178,7 @@ end function xml._parse_xpath_segment(segment, axis) local step = {axis = axis or "child", predicates = {}} local name = segment:gsub("%b[]", "") - name = xml._trim(name) + name = name:trim() if name == "" or name == "*" then step.node_test = "any" elseif name == "." then @@ -200,7 +196,7 @@ function xml._parse_xpath_segment(segment, axis) step.name = name end for predicate in segment:gmatch("%b[]") do - local expr = xml._trim(predicate:sub(2, -2)) + local expr = predicate:sub(2, -2):trim() if expr ~= "" then local number_index = tonumber(expr) if number_index then @@ -307,14 +303,18 @@ end -- append normalized text node to top element on stack function xml._append_text(stack, text, opt) opt = opt or {} - if opt.trim_text ~= false then - text = text:gsub("^%s+", ""):gsub("%s+$", "") + if opt.trim_text then + text = string.trim(text) end - if text ~= "" then - local top = stack[#stack] - top.children = top.children or {} - table.insert(top.children, xml.text(xml._decode_entities(text))) + 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 @@ -465,7 +465,7 @@ end -- e.g. `local doc, err = xml.decode("foo")` -- -- @param data xml string --- @param opt options (trim_text, etc.) +-- @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) @@ -585,7 +585,9 @@ function xml.decode(data, opt) local prolog = {} for _, child in ipairs(root_children) do if child ~= rootnode then - table.insert(prolog, child) + if not (child.kind == "text" and (child.text or ""):match("^%s*$")) then + table.insert(prolog, child) + end end end if #prolog > 0 then @@ -600,7 +602,7 @@ end -- -- @param data xml string -- @param callback function(node) -> true|false (return false to stop scanning) --- @param opt options (trim_text, etc.) +-- @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) -- cgit v1.3.1 From 88b0fb7c39cd6b867cdde29fd195f5f2c49b62d7 Mon Sep 17 00:00:00 2001 From: ruki Date: Sat, 15 Nov 2025 20:02:25 +0800 Subject: improve to parse attrs --- tests/modules/xml/test.lua | 19 +++++++++++++++++++ xmake/core/base/xml.lua | 30 ++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) (limited to 'tests/modules/xml/test.lua') diff --git a/tests/modules/xml/test.lua b/tests/modules/xml/test.lua index 985600a9d..41c56c867 100644 --- a/tests/modules/xml/test.lua +++ b/tests/modules/xml/test.lua @@ -12,6 +12,25 @@ function test_decode_basic(t) t:are_equal(doc.children[2].attrs.id, "2") end +function test_decode_unquoted_attrs(t) + local doc = xml.decode("") + 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 = [[]] + 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", diff --git a/xmake/core/base/xml.lua b/xmake/core/base/xml.lua index beb3a2ed4..7dc0025b8 100644 --- a/xmake/core/base/xml.lua +++ b/xmake/core/base/xml.lua @@ -84,10 +84,36 @@ end -- parse attribute string to table (or nil if empty) function xml._parse_attrs(attrstr) local attrs - attrstr:gsub("([%w_:%-%.]+)%s*=%s*([\"'])(.-)%2", function(key, quote, value) + 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) + end return attrs end -- cgit v1.3.1 From b250c84aee0072ff92b73fe41403d364f95a03d5 Mon Sep 17 00:00:00 2001 From: ruki Date: Sat, 15 Nov 2025 21:46:29 +0800 Subject: update xml api --- tests/modules/xml/test.lua | 4 ++-- xmake/core/base/xml.lua | 8 ++++---- xmake/core/sandbox/modules/import/core/base/xml.lua | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) (limited to 'tests/modules/xml/test.lua') diff --git a/tests/modules/xml/test.lua b/tests/modules/xml/test.lua index 41c56c867..4358762fd 100644 --- a/tests/modules/xml/test.lua +++ b/tests/modules/xml/test.lua @@ -80,8 +80,8 @@ function test_load_save(t) attrs = {id = "1"}, children = {xml.text("hello")} }) - assert(xml.save(filepath, doc, {pretty = true})) - local reloaded = xml.load(filepath) + 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) diff --git a/xmake/core/base/xml.lua b/xmake/core/base/xml.lua index 7dc0025b8..5e7282e97 100644 --- a/xmake/core/base/xml.lua +++ b/xmake/core/base/xml.lua @@ -781,13 +781,13 @@ function xml.encode(node, opt) end -- load xml file --- e.g. `local doc, err = xml.load("foo.xml")` +-- 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.load(filepath, opt) +function xml.loadfile(filepath, opt) local data, err = io.readfile(filepath, opt) if not data then return nil, err @@ -796,14 +796,14 @@ function xml.load(filepath, opt) end -- save xml node to file --- e.g. `assert(xml.save("foo.xml", node, {pretty = true}))` +-- 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.save(filepath, node, opt) +function xml.savefile(filepath, node, opt) local data = xml.encode(node, opt) if not data then return nil, "failed to encode xml" diff --git a/xmake/core/sandbox/modules/import/core/base/xml.lua b/xmake/core/sandbox/modules/import/core/base/xml.lua index 1aed8b159..fdfe8318a 100644 --- a/xmake/core/sandbox/modules/import/core/base/xml.lua +++ b/xmake/core/sandbox/modules/import/core/base/xml.lua @@ -55,8 +55,8 @@ function sandbox_core_base_xml.scan(data, callback, opt) end -- load xml file to the lua table -function sandbox_core_base_xml.load(filepath, opt) - local node, errors = xml.load(filepath, opt) +function sandbox_core_base_xml.loadfile(filepath, opt) + local node, errors = xml.loadfile(filepath, opt) if not node then raise(errors) end @@ -64,8 +64,8 @@ function sandbox_core_base_xml.load(filepath, opt) end -- save xml node to the file -function sandbox_core_base_xml.save(filepath, node, opt) - local ok, errors = xml.save(filepath, node, opt) +function sandbox_core_base_xml.savefile(filepath, node, opt) + local ok, errors = xml.savefile(filepath, node, opt) if not ok then raise(errors) end -- cgit v1.3.1