Serialize XML in a consistent order by default

Thu, 23 Mar 2023 15:12:30 +0000

author
Matthew Wild <mwild1@gmail.com>
date
Thu, 23 Mar 2023 15:12:30 +0000
changeset 174
662bd8c5ae28
parent 173
14ed4cb241f4
child 175
e48074386468

Serialize XML in a consistent order by default

This overrides all XML serialization to emit attributes in an ordered form, so
the XML will match across multiple runs. This can be useful for comparing
different runs, or even two stanzas printed in the same run (e.g. if there is
a mismatch).

main.lua file | annotate | diff | comparison | revisions
scansion/console.lua file | annotate | diff | comparison | revisions
scansion/ordered_serializer.lua file | annotate | diff | comparison | revisions
scansion/pretty.lua file | annotate | diff | comparison | revisions
squishy file | annotate | diff | comparison | revisions
--- a/main.lua	Thu Mar 23 15:09:10 2023 +0000
+++ b/main.lua	Thu Mar 23 15:12:30 2023 +0000
@@ -15,6 +15,7 @@
 local action_timeout = 10;
 local verse_log_levels = { "warn", "error" };
 local quiet = false;
+local ordered = true;
 local force_summary = false;
 local serve_mode = false;
 local serve_origin = nil;
@@ -120,6 +121,8 @@
 		elseif opt == "--serve-port" then
 			serve_mode = assert(tonumber(get_value()), "expected port number");
 			serve_origin = assert(get_value(), "origin expected for '--serve-port'");
+		elseif opt == "--unordered" then
+			ordered = false;
 		else
 			error("Unhandled command-line option: "..opt);
 		end
@@ -280,10 +283,15 @@
 local files = process_options();
 
 local console_handlers = require "scansion.console".new({
+	ordered = ordered;
 	summary = not(quiet) or force_summary;
 	quiet = quiet;
 });
 
+if ordered then
+	require "scansion.ordered_serializer".enable();
+end
+
 local function console_logger(event, data)
 	local h = console_handlers[event];
 	if h then
--- a/scansion/console.lua	Thu Mar 23 15:09:10 2023 +0000
+++ b/scansion/console.lua	Thu Mar 23 15:12:30 2023 +0000
@@ -1,86 +1,90 @@
-local pretty = require "scansion.pretty".new({});
 
 local function lines(l, indent)
 	return table.concat(l, "\n"..string.rep(" ", indent or 0));
 end
 
-local handlers = {
-	["script"] = function (data)
-		return "TEST: "..(data.title or data.filename or "untitled");
-	end;
-	["test-passed"] = function ()
-		return "PASS";
-	end;
-	["test-failed"] = function (data)
-		local error_text;
-		if data.reason and data.reason.type == "unexpected-stanza" then
-			error_text = "Received unexpected stanza:\n\n"..pretty(data.reason.data.stanza, 4);
-			if data.reason.data.expected then
-				error_text = error_text.."\n\nExpected:\n\n"..pretty(data.reason.data.expected, 4);
-			end
-		else
-			error_text = tostring(data.reason);
-		end
-		return lines({
-			"FAILED: "..data.name;
-			"";
-			(error_text:gsub("\n", "\n    "));
-			"";
-		}, 4);
-	end;
-	["test-error"] = function (data)
-		return lines({
-			"ERROR: "..data.name;
-			"";
-			(tostring(data.reason):gsub("\n", "\n    "));
-			"";
-		}, 4);
-	end;
-
-	["action"] = function (data)
-		local action = data.action;
-		local obj_type = data.object_type;
-		local l = {};
-		if data.annotation then
-			table.insert(l, action.annotation);
-		end
-		table.insert(l, data.object.." "..action);
-		if data.extra and obj_type == "client" and (action == "sends" or action == "receives") then
-			table.insert(l, "\n"..pretty(lines(data.extra), 4).."\n");
-		end
-		return lines(l);
-	end;
-
-	["end"] = function (data)
-		local r = {};
-
-		local all_results = {};
-		for k, v in pairs(data.summary.all) do
-			table.insert(all_results, v);
-		end
-		table.sort(all_results, function (a, b) return a.name < b.name end);
-
-		print("");
-		print("Summary");
-		print("-------");
-
-		for _, test_result in ipairs(all_results) do
-			print("", test_result.status, test_result.name);
-		end
-
-		print("");
-
-		for _, test_result in ipairs{ "ok", "fail", "error", "skipped", "total" } do
-			local count = data.summary[test_result] and #data.summary[test_result] or 0;
-			table.insert(r, tostring(count).." "..test_result);
-		end
-		return table.concat(r, " / ");
-	end;
-};
-
 local quiet_handlers = { "test-failed", "test-error" };
 
 local function new(config)
+	local pretty = require "scansion.pretty".new({
+		sorted = config.ordered;
+	});
+
+	local handlers = {
+		["script"] = function (data)
+			return "TEST: "..(data.title or data.filename or "untitled");
+		end;
+		["test-passed"] = function ()
+			return "PASS";
+		end;
+		["test-failed"] = function (data)
+			local error_text;
+			if data.reason and data.reason.type == "unexpected-stanza" then
+				error_text = "Received unexpected stanza:\n\n"..pretty(data.reason.data.stanza, 4);
+				if data.reason.data.expected then
+					error_text = error_text.."\n\nExpected:\n\n"..pretty(data.reason.data.expected, 4);
+				end
+			else
+				error_text = tostring(data.reason);
+			end
+			return lines({
+				"FAILED: "..data.name;
+				"";
+				(error_text:gsub("\n", "\n    "));
+				"";
+			}, 4);
+		end;
+		["test-error"] = function (data)
+			return lines({
+				"ERROR: "..data.name;
+				"";
+				(tostring(data.reason):gsub("\n", "\n    "));
+				"";
+			}, 4);
+		end;
+
+		["action"] = function (data)
+			local action = data.action;
+			local obj_type = data.object_type;
+			local l = {};
+			if data.annotation then
+				table.insert(l, action.annotation);
+			end
+			table.insert(l, data.object.." "..action);
+			if data.extra and obj_type == "client" and (action == "sends" or action == "receives") then
+				table.insert(l, "\n"..pretty(lines(data.extra), 4).."\n");
+			end
+			return lines(l);
+		end;
+
+		["end"] = function (data)
+			local r = {};
+
+			local all_results = {};
+			for _, v in pairs(data.summary.all) do
+				table.insert(all_results, v);
+			end
+			table.sort(all_results, function (a, b) return a.name < b.name end);
+
+			print("");
+			print("Summary");
+			print("-------");
+
+			for _, test_result in ipairs(all_results) do
+				print("", test_result.status, test_result.name);
+			end
+
+			print("");
+
+			for _, test_result in ipairs{ "ok", "fail", "error", "skipped", "total" } do
+				local count = data.summary[test_result] and #data.summary[test_result] or 0;
+				table.insert(r, tostring(count).." "..test_result);
+			end
+			return table.concat(r, " / ");
+		end;
+	};
+
+
 	local h = {};
 	if config.quiet then
 		for _, handler_name in ipairs(quiet_handlers) do
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scansion/ordered_serializer.lua	Thu Mar 23 15:12:30 2023 +0000
@@ -0,0 +1,56 @@
+local s_find, s_gsub, s_match = string.find, string.gsub, string.match;
+local t_concat, t_insert = table.concat, table.insert;
+
+local pairs = require "util.iterators".sorted_pairs;
+
+local escape_table = { ["'"] = "&apos;", ["\""] = "&quot;", ["<"] = "&lt;", [">"] = "&gt;", ["&"] = "&amp;" };
+local function xml_escape(str) return (s_gsub(str, "['&<>\"]", escape_table)); end
+
+local function _dostring(t, buf, self, _xml_escape, parentns)
+	local nsid = 0;
+	local name = t.name
+	t_insert(buf, "<"..name);
+	for k, v in pairs(t.attr) do
+		if s_find(k, "\1", 1, true) then
+			local ns, attrk = s_match(k, "^([^\1]*)\1?(.*)$");
+			nsid = nsid + 1;
+			t_insert(buf, " xmlns:ns"..nsid.."='".._xml_escape(ns).."' ".."ns"..nsid..":"..attrk.."='".._xml_escape(v).."'");
+		elseif not(k == "xmlns" and v == parentns) then
+			t_insert(buf, " "..k.."='".._xml_escape(v).."'");
+		end
+	end
+	local len = #t;
+	if len == 0 then
+		t_insert(buf, "/>");
+	else
+		t_insert(buf, ">");
+		for n=1,len do
+			local child = t[n];
+			if child.name then
+				self(child, buf, self, _xml_escape, t.attr.xmlns);
+			else
+				t_insert(buf, _xml_escape(child));
+			end
+		end
+		t_insert(buf, "</"..name..">");
+	end
+end
+
+return {
+	enable = function ()
+		local stanza_mt = require "util.stanza".stanza_mt;
+		stanza_mt._unsorted_tostring = stanza_mt.__tostring;
+		function stanza_mt.__tostring(t)
+			local buf = {};
+			_dostring(t, buf, _dostring, xml_escape, nil);
+			return t_concat(buf);
+		end;
+	end;
+	disable = function ()
+		local stanza_mt = require "util.stanza".stanza_mt;
+		if stanza_mt._unsorted_tostring then
+			stanza_mt.__tostring = stanza_mt._unsorted_tostring;
+		end
+	end;
+};
+
--- a/scansion/pretty.lua	Thu Mar 23 15:09:10 2023 +0000
+++ b/scansion/pretty.lua	Thu Mar 23 15:12:30 2023 +0000
@@ -2,6 +2,9 @@
 local xml = require "scansion.xml";
 local s_format, s_gsub = string.format, string.gsub;
 
+local unsorted_pairs = pairs;
+local sorted_pairs = require "util.iterators".sorted_pairs;
+
 local escape_table = { ["'"] = "&apos;", ["\""] = "&quot;", ["<"] = "&lt;", [">"] = "&gt;", ["&"] = "&amp;" };
 local function xml_escape(str) return (s_gsub(str, "['&<>\"]", escape_table)); end
 
@@ -18,6 +21,7 @@
 local default_config = {
 	indent = 2;
 	preserve_whitespace = false;
+	sorted = true;
 };
 
 local function new(user_config)
@@ -27,6 +31,8 @@
 	local style_tagname = getstyle("red");
 	local style_punc = getstyle("magenta");
 
+	local pairs = user_config.sorted and sorted_pairs or unsorted_pairs;
+
 	local attr_format = " "..getstring(style_attrk, "%s")..getstring(style_punc, "=")..getstring(style_attrv, "'%s'");
 	local open_tag_format = getstring(style_punc, "<")..getstring(style_tagname, "%s").."%s"..getstring(style_punc, ">");
 	local close_tag_format = getstring(style_punc, "</")..getstring(style_tagname, "%s")..getstring(style_punc, ">");
--- a/squishy	Thu Mar 23 15:09:10 2023 +0000
+++ b/squishy	Thu Mar 23 15:12:30 2023 +0000
@@ -14,6 +14,7 @@
 Module "scansion.xml"
 Module "scansion.console"
 Module "scansion.pretty"
+Module "scansion.ordered_serializer"
 
 if GetOption "with-verse" then
 	Module "verse"

mercurial