Module:Flowchart

From WikiMSK

local p = {}

--[======[ Fonctions utilitaires ]======]--

local getArgs = require("Module:Arguments").getArgs

--- Récupère la liste des clés d’un tableau associatif
local function get_table_keys(t)
	local keys = {}
	
	for key, _ in pairs(t) do
		table.insert(keys, '"' .. key .. '"')
	end
	
	return table.concat(keys, ", ")
end

--- Convertit un identifiant d’objet en identifiant utilisé en syntaxe Mermaid
local function generate_id(id)
	-- On utilise une version hachée de l’identifiant de chaque objet (avec un
	-- algorithme de hachage faible, acceptable ici car nous n’en faisons pas
	-- une utilisation cryptographique) afin de permettre l’utilisation de
	-- caractères arbitraires dans le Wikicode sans poser de problèmes au
	-- parseur Mermaid
	return mw.hash.hashValue("md5", id)
end

--- Échappe les guillemets droits utilisés dans une étiquette
local function escape_quotes(label)
	return string.gsub(label, '"', "#quot;")
end

--- Découpe une chaîne autour de la première occurrence d’un délimiteur
--- et ignore les caractères blancs autour des deux segments résultant

-- Liste des caractères Unicode blancs, tirée de
-- <https://en.wikipedia.org/wiki/Unicode_character_property#Whitespace>
local whitespace = mw.ustring.char(
	0x9, 0xA, 0xB, 0xC, 0xD, 0x20, 0x85, 0xA0, 0x1680,
	0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006,
	0x2007, 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x202F,
	0x205F, 0x3000
)

local function split_delimiter(s, delim)
	local d_start, d_end = string.find(s, delim, 1, true)
	
	if d_start and d_end then
		local left = mw.text.trim(string.sub(s, 1, d_start - 1), whitespace)
		local right = mw.text.trim(string.sub(s, d_end + 1), whitespace)
		return left, right
	else
		return s
	end
end

--- Affecte une valeur dans une clé d’une table, en utilisant un chemin en
--- notation pointée
local function set_key_path(t, key, default_key, value)
	local left, right = split_delimiter(key, ".")
	
	if not right and default_key then
		right = default_key
	end
	
	if not right then
		t[left] = value
	else
		if not t[left] then
			t[left] = {}
		end
		
		set_key_path(t[left], right, nil, value)
	end
end

--[======[ Représentation intermédiaire du flowchart ]======]--

local link_separator = "->"
local group_prefix = "group "

--- Lit la définition d’un flowchart
function p.read_flowchart(args)
	local nodes = {}
	local groups = {}
	local links = {}
	
	for key, value in pairs(args) do
		if type(key) == "number" then
			key = value
			value = nil
		end
		
		local is_group = string.find(key, group_prefix, 1, true) == 1
		local is_link = string.find(key, link_separator, 1, true)
		
		if is_group then
			-- Concerne un groupe de nœuds
			local group_key = string.sub(key, #group_prefix + 1)
			
			set_key_path(groups, group_key, "name", value)
		elseif is_link then
			-- Concerne un lien
			local base, rest = split_delimiter(key, ".")
			local from, to = split_delimiter(base, link_separator)
			local link_key = nil
			
			if not rest then
				link_key = string.format("%s->%s", from, to)
			else
				link_key = string.format("%s->%s.%s", from, to, rest)
			end
			
			if not nodes[from] then
				nodes[from] = {}
			end
			
			if not nodes[to] then
				nodes[to] = {}
			end
			
			set_key_path(links, link_key, "name", value)
		else
			-- Concerne un nœud
			set_key_path(nodes, key, "name", value)
		end
	end
	
	return nodes, groups, links
end

--[======[ Génération de code Mermaid ]======]--

local orientations = {
	["to bottom"] = "TB",
	["to top"] = "BT",
	["to right"] = "LR",
	["to left"] = "RL",
}

local nodes_shapes = {
	rectangle = '%s["%s"]',
	rounded = '%s("%s")',
	circle = '%s(("%s"))',
	flag = '%s>"%s"]',
	diamond = '%s{"%s"}',
}

local endings = {
	plain = "---",
	arrow = "-->",
}

local links_shapes = {
	curve = "basis",
	linear = "linear",
	["step before"] = "stepBefore",
	["step after"] = "stepAfter",
	step = "step",
}

--- Génère le code Mermaid pour un flowchart
function p.flowchart_to_mermaid(frame, params, nodes, groups, links)
	local lines = {}
	local orientation = params.orientation or "to bottom"
	
	if not orientations[orientation] then
		error(string.format(
			'Orientation "%s" inconnue pour le flowchart. Les orientations possibles sont %s.',
			orientation, get_table_keys(orientations)
		), 0)
	end
	
	table.insert(lines, string.format("graph %s", orientations[orientation]))
	
	-- Liste des nœuds membres de chaque groupe, construite en même temps
	-- qu’on itère sur les nœuds pour les insérer dans la sortie
	local groups_members = {}
	
	for key, _ in pairs(groups) do
		groups_members[key] = {}
	end
	
	-- Génère les nœuds
	for key, properties in pairs(nodes) do
		local id = generate_id(key)
		local name = properties.name or key
		local shape = properties.shape or "rounded"
		
		if properties.group then
			table.insert(groups_members[properties.group], id)
		end
		
		if not nodes_shapes[shape] then
			error(string.format(
				'Forme "%s" inconnue pour le nœud "%s". Les formes possibles sont %s.',
				shape, name, get_table_keys(nodes_shapes)
			), 0)
		end
		
		-- Forme et étiquette du nœud
		table.insert(lines, string.format(nodes_shapes[shape], id, escape_quotes(name)))
		
		-- Style
		if properties.style then
			local rules = {}
			
			for property, value in pairs(properties.style) do
				table.insert(rules, string.format("%s:%s", property, value))
			end
			
			table.insert(lines, string.format("style %s %s", id, table.concat(rules, ",")))
		end
	end
	
	-- Génère les groupes
	for key, properties in pairs(groups) do
		local name = properties.name or key
		
		table.insert(lines, string.format('subgraph %s', name))
		
		for _, node in pairs(groups_members[key]) do
			table.insert(lines, node)
		end
		
		table.insert(lines, "end")
	end
	
	-- Génère les liens
	local link_number = 0
	
	-- Classe CSS pour les noeuds invisibles
	table.insert(lines, "classDef SkipLevel width:0px;")
	
	for key, properties in pairs(links) do
		local from, to = split_delimiter(key, link_separator)
		local from_id = generate_id(from)
		local to_id = generate_id(to)
		
		local ending = properties.ending or "arrow"
		
		if not endings[ending] then
			error(string.format(
				'Terminaison "%s" inconnue pour le lien "%s -> %s". Les terminaisons possibles sont %s.',
				shape, from, to, get_table_keys(endings)
			), 0)
		end
		
		-- Définit les noeuds permettant de sauter de niveau.
		-- Voir https://github.com/mermaid-js/mermaid/issues/637 pour la technique
		-- qui consiste en rajouter des noeuds invisibles.
		local level = nodes[to].level
		
		if level ~= '-' and level ~= nil then
			level = tonumber(level)
			
			if level == nil or level < 1 then
				error(string.format('Le niveau du noeud %s doit être un nombre entier > 0.', to), 0)
			end
			
			local prev_id = from_id
			local class = "class " .. from_id .. '-0'
			
			for i = 1, level - 1 do
				-- Ajoute un noeud invisible
				local next_id = from_id .. '-' .. i
				class = class .. ',' .. next_id
				table.insert(lines, next_id .. '( )')
				
				-- Ajoute une connexion entre les noeuds invisibles
				table.insert(lines, prev_id .. " --- " .. next_id)
				prev_id = next_id
			end
			
			table.insert(lines, class .. " SkipLevel")
			
			-- La dernière connexion sera créée normalement
			from_id = prev_id
		end
		
		if properties.name then
			table.insert(lines, string.format('%s -- "%s" %s %s', from_id, escape_quotes(properties.name), endings[ending], to_id))
		else
			table.insert(lines, string.format("%s %s %s", from_id, endings[ending], to_id))
		end
		
		local shape = properties.shape or "curve"
		
		if not links_shapes[shape] then
			error(string.format(
				'Forme "%s" inconnue pour le lien "%s -> %s". Les formes possibles sont %s.',
				shape, from, to, get_table_keys(links_shapes)
			), 0)
		end
		
		if shape ~= "linear" then
			table.insert(lines, string.format("linkStyle %d interpolate %s", link_number, links_shapes[shape]))
		end
		
		link_number = link_number + 1
	end
	
	return table.concat(lines, "\n")
end

local themes = {
	default = true,
	neutral = true,
	forest = true,
	dark = true,
}

--- Appelle l’extension Mermaid
function p.render_mermaid(frame, params, nodes, groups, links)
	local mermaid = p.flowchart_to_mermaid(frame, params, nodes, groups, links)
	local theme = params.theme or "neutral"
	
	if not themes[theme] then
		error(string.format(
			'Thème "%s" inconnu pour le flowchart. Les thèmes possibles sont %s.',
			theme, get_table_keys(themes)
		), 0)
	end
	
	local rendered_mermaid = frame:callParserFunction(
		"#mermaid", mermaid,
		"config.theme = " .. theme
	)
	
	if params.debug then
		dump_params = mw.text.nowiki(mw.dumpObject(params))
		dump_nodes = mw.text.nowiki(mw.dumpObject(nodes))
		dump_groups = mw.text.nowiki(mw.dumpObject(groups))
		dump_links = mw.text.nowiki(mw.dumpObject(links))
		dump_mermaid = mw.text.nowiki(mermaid)
		
		return string.format([[
			<h4>Représentation interne</h4>
			
			<table>
				<tr>
					<th>params</th>
					<th>nodes</th>
					<th>groups</th>
					<th>links</th>
				</tr>
				<tr>
					<td><pre>%s</pre></td>
					<td><pre>%s</pre></td>
					<td><pre>%s</pre></td>
					<td><pre>%s</pre></td>
				</tr>
			</table>
			
			<h4>Code Mermaid</h4>
			
			<pre>%s</pre>
			
			<h4>Résultat</h4>
			
			%s
		]], dump_params, dump_nodes, dump_groups, dump_links, dump_mermaid, rendered_mermaid)
	else
		return rendered_mermaid
	end
end

--[======[ Interface ]======]--

function p.render(frame)
	-- Normalise les arguments
	local args = getArgs(frame, {
		wrappers = 'Module:Flowchart'
	})

	-- Lit et consomme les paramètres généraux du flowchart préfixés par "$"
	local params = {}
	
	for args_key, value in pairs(args) do
		local key = args_key
		
		if type(args_key) == "number" then
			key = value
			value = "true"
		end
		
		if string.sub(key, 1, 1) == "$" then
			params[string.sub(key, 2)] = value
			args[args_key] = nil
		end
	end
	
	local nodes, groups, links = p.read_flowchart(args)
	return p.render_mermaid(frame, params, nodes, groups, links)
end

return p