--!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([[ -- hello -- ]])) -- 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 ", 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("", 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 { 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("foo")` -- -- @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("foo"))` -- -- @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