-- lx-bundle:true

--- Small, safe TeX-math parser for Markdown previews
-- @module plugins.mdpreview.math.parser

local parser = {}

local ACCENT_DEFINITIONS = require("plugins.mdpreview.math.accents")
local ITALIC_GREEK = require("plugins.mdpreview.math.italic_greek")
local CLASSES = require("plugins.mdpreview.math.classes")
local OPERATORS = require("plugins.mdpreview.math.operators")
local SYMBOLS = require("plugins.mdpreview.math.symbols")

local ACCENTS = ACCENT_DEFINITIONS.accents
local ACCENT_LINES = ACCENT_DEFINITIONS.lines
local CANNOT_PRECEDE_BINARY = {
	binary = true,
	relation = true,
	open = true,
	punctuation = true,
	operator = true,
}
local CANNOT_FOLLOW_BINARY = {
	relation = true,
	close = true,
	punctuation = true,
}
-- These internal controls change TeX parsing but draw nothing by themselves
local INVISIBLE_CONTROL_COMMANDS = {
	par = true,
	long = true,
	mathchoice = true,
	relax = true,
	expandafter = true,
	noexpand = true,
	makeatletter = true,
	TextOrMath = true,
}
-- The compact reference lists these internal helpers without their arguments
local INVISIBLE_AT_COMMANDS = {
	ifstar = true,
	ifnextchar = true,
	firstoftwo = true,
	secondoftwo = true,
}
local LARGE_OPERATOR_COMMANDS = {
	sum = true,
	prod = true,
	coprod = true,
	bigcap = true,
	bigcup = true,
	bigwedge = true,
	bigvee = true,
	biguplus = true,
	bigsqcup = true,
	bigodot = true,
	bigoplus = true,
	bigotimes = true,
	int = true,
	intop = true,
	smallint = true,
	iint = true,
	iiint = true,
	iiiint = true,
	oint = true,
	oiint = true,
	oiiint = true,
	intclockwise = true,
	varointclockwise = true,
	ointctrclockwise = true,
	fint = true,
}
-- These aliases are relations even when their visible text is also punctuation
local RELATION_COMMANDS = {
	vcentcolon = true,
	ratio = true,
	trianglelefteq = true,
	trianglerighteq = true,
	vartriangleleft = true,
	vartriangleright = true,
}
-- Extensible aliases share one labelled-arrow layout and differ only in shape
local EXTENSIBLE_ARROW_GLYPHS = {
	xleftarrow = "⟵",
	xrightarrow = "⟶",
	xLeftarrow = "⟸",
	xRightarrow = "⟹",
	xleftrightarrow = "⟷",
	xLeftrightarrow = "⟺",
	xhookleftarrow = "↩",
	xhookrightarrow = "↪",
	xtwoheadleftarrow = "↞",
	xtwoheadrightarrow = "↠",
	xleftharpoonup = "↼",
	xrightharpoonup = "⇀",
	xleftharpoondown = "↽",
	xrightharpoondown = "⇁",
	xleftrightharpoons = "⇋",
	xrightleftharpoons = "⇌",
	xtofrom = "⇄",
	xmapsto = "⟼",
	xlongequal = "=",
}
local DISPLAY_LIMIT_COMMANDS = {
	sum = true,
	prod = true,
	coprod = true,
	bigcap = true,
	bigcup = true,
	bigwedge = true,
	bigvee = true,
	biguplus = true,
	bigsqcup = true,
	bigodot = true,
	bigoplus = true,
	bigotimes = true,
}
local FIXED_DELIMITER_SCALES = {
	big = 1.5,
	Big = 1.8,
	bigg = 2.4,
	Bigg = 3,
}
local FIXED_DELIMITER_CLASSES = {
	l = "open",
	r = "close",
	m = "relation",
}
local MATH_STYLE_COMMANDS = {
	displaystyle = "display",
	textstyle = "text",
	scriptstyle = "script",
	scriptscriptstyle = "scriptscript",
}
local FONT_STYLE_DECLARATIONS = {
	bf = "mathbf",
	sf = "mathsf",
	rm = "mathrm",
	it = "mathit",
	tt = "mathtt",
	cal = "mathcal",
}
local FONT_STYLE_COMMANDS = {
	mathrm = "mathrm", mathbf = "mathbf", boldsymbol = "boldsymbol",
	mathit = "mathit", mathsf = "mathsf", mathtt = "mathtt",
	mathbb = "mathbb", mathcal = "mathcal", mathfrak = "mathfrak",
	mathnormal = "mathit", bold = "mathbf", bm = "boldsymbol",
	mathsfit = "mathsf", Bbb = "mathbb", frak = "mathfrak",
	mathscr = "mathcal", pmb = "boldsymbol",
}
-- Text font commands change one part of the current text face and therefore stack
local TEXT_FONT_COMMANDS = {
	textbf = { weight = "bold" },
	textsf = { family = "sans" },
	textrm = { family = "roman" },
	textnormal = { family = "roman", weight = "medium", shape = "upright" },
	textup = { shape = "upright" },
	textmd = { weight = "medium" },
	textit = { shape = "italic" },
	texttt = { family = "mono" },
	emph = { shape = "italic" },
}
-- Text-size declarations scale the remainder of their current group
local FONT_SIZE_DECLARATIONS = {
	tiny = 0.5,
	scriptsize = 0.7,
	footnotesize = 0.8,
	small = 0.9,
	normalsize = 1,
	large = 1.2,
	Large = 1.44,
	LARGE = 1.728,
	huge = 2.074,
	Huge = 2.488,
}
-- Rule dimensions accept the complete TeX unit set shown by the reference
local RULE_LENGTH_UNITS = {
	em = true, ex = true, mu = true,
	pt = true, pc = true, bp = true, dd = true, cc = true,
	nd = true, nc = true, sp = true,
	["in"] = true, cm = true, mm = true,
}
local MATH_CLASS_COMMANDS = {
	mathbin = "binary",
	mathclose = "close",
	mathinner = "inner",
	mathop = "operator",
	mathopen = "open",
	mathord = "ordinary",
	mathpunct = "punctuation",
	mathrel = "relation",
}
-- Named spaces reuse the same em fractions as their control-symbol forms
local SPACE_SCALES = {
	[","] = 3 / 18,
	[":"] = 4 / 18,
	[";"] = 5 / 18,
	["!"] = -3 / 18,
	[">"] = 4 / 18,
	[" "] = 1 / 3,
	thinspace = 3 / 18,
	medspace = 4 / 18,
	thickspace = 5 / 18,
	enspace = 0.5,
	quad = 1,
	qquad = 2,
	space = 1 / 3,
	nobreakspace = 1 / 3,
	negthinspace = -3 / 18,
	negmedspace = -4 / 18,
	negthickspace = -5 / 18,
}
local TEXT_ACCENT_COMMANDS = {
	["'"] = true,
	["`"] = true,
	['"'] = true,
	["^"] = true,
	["~"] = true,
	["="] = true,
	["."] = true,
	H = true,
	r = true,
	u = true,
	v = true,
	underbar = true,
}
local ROW_ENVIRONMENTS = {
	matrix = "matrix",
	["matrix*"] = "matrix",
	pmatrix = "matrix",
	["pmatrix*"] = "matrix",
	bmatrix = "matrix",
	["bmatrix*"] = "matrix",
	Bmatrix = "matrix",
	["Bmatrix*"] = "matrix",
	vmatrix = "matrix",
	["vmatrix*"] = "matrix",
	Vmatrix = "matrix",
	["Vmatrix*"] = "matrix",
	smallmatrix = "matrix",
	subarray = "array",
	cases = "array",
	rcases = "array",
	array = "array",
	aligned = "aligned",
	alignedat = "aligned",
	alignat = "aligned",
	split = "aligned",
	align = "aligned",
	["align*"] = "aligned",
	["alignat*"] = "aligned",
	CD = "cd",
	gathered = "centered",
	gather = "centered",
	["gather*"] = "centered",
	multline = "multline",
	equation = "centered",
	["equation*"] = "centered",
}
local MATRIX_DELIMITERS = {
	pmatrix = { "(", ")" },
	["pmatrix*"] = { "(", ")" },
	bmatrix = { "[", "]" },
	["bmatrix*"] = { "[", "]" },
	Bmatrix = { "{", "}" },
	["Bmatrix*"] = { "{", "}" },
	vmatrix = { "|", "|" },
	["vmatrix*"] = { "|", "|" },
	Vmatrix = { SYMBOLS.Vert, SYMBOLS.Vert },
	["Vmatrix*"] = { SYMBOLS.Vert, SYMBOLS.Vert },
	cases = { "{", "" },
	rcases = { "", "}" },
}
local ARRAY_COLUMN_ALIGNMENTS = {
	l = "left",
	c = "center",
	r = "right",
}
local NUMBERED_ENVIRONMENTS = {
	align = true,
	alignat = true,
	gather = true,
	multline = true,
	equation = true,
}

--- Return one UTF-8 character and its following byte position
-- @param source string complete expression
-- @param position number current byte position
-- @return string character
-- @return number next_position
local function read_character(source, position)
	local next_position = utf8.offset(source, 2, position) or (#source + 1)

	return source:sub(position, next_position - 1), next_position
end

--- Read one TeX control word or escaped control symbol
-- @param state table parser state
-- @return string name command name or escaped character
-- @return boolean word true for a letter command
local function read_command(state)
	local start = state.position + 1
	local word = state.source:match("^([A-Za-z]+)", start)

	if word then
		state.position = start + #word

		return word, true
	end

	local character, next_position = read_character(state.source, start)
	state.position = next_position

	return character, false
end

--- Read one hexadecimal character code after '\char"'
-- @param state table parser state after the char command
-- @return string|nil text decoded Unicode character
local function read_character_code(state)
	if state.source:sub(state.position, state.position) ~= '"' then
		return nil
	end

	state.position = state.position + 1
	-- Pattern breakdown: hexadecimal digits followed by their first unused byte
	local digits, finish = state.source:match("^([0-9A-Fa-f]+)()", state.position)
	local codepoint = digits and tonumber(digits, 16)

	if not codepoint or codepoint > 0x10FFFF
		or codepoint >= 0xD800 and codepoint <= 0xDFFF
	then
		return nil
	end

	state.position = finish

	return utf8.char(codepoint)
end

local parse_sequence
local parse_nested_source
local parse_atom
local parse_delimited
local parse_environment
local split_environment

--- Read one delimiter following an automatic size command
-- A period represents TeX's invisible delimiter and therefore becomes an empty string
-- @param state table parser state
-- @return string|nil text delimiter glyph or nil when missing
local function read_delimiter(state)
	while state.source:sub(state.position, state.position):match("%s") do
		state.position = state.position + 1
	end

	local character = state.source:sub(state.position, state.position)
	local text

	if character == "" then
		return nil
	elseif character == "\\" then
		local command, is_word = read_command(state)

		if not is_word then
			text = command == "|" and SYMBOLS.Vert or command
		else
			text = SYMBOLS[command]
		end
	else
		text, state.position = read_character(state.source, state.position)
	end

	if text == "." then
		return ""
	end

	return text
end

--- Peek at a command without consuming it
-- @param state table parser state
-- @return string|nil command command at the current position
local function peek_command(state)
	if state.source:sub(state.position, state.position) ~= "\\" then
		return nil
	end

	local position = state.position
	local command = read_command(state)
	state.position = position

	return command
end

--- Read one required argument, accepting either a group or a single atom
-- @param state table parser state
-- @return table node parsed node
local function parse_argument(state)
	while state.source:sub(state.position, state.position):match("%s") do
		state.position = state.position + 1
	end

	if state.source:sub(state.position, state.position) == "{" then
		state.position = state.position + 1
		local node = parse_sequence(state, "}")

		if state.source:sub(state.position, state.position) == "}" then
			state.position = state.position + 1
		end

		return node
	end

	-- A bare script argument owns one atom; a following script still belongs to the outer base
	return parse_atom(state)
end

--- Read one optional square-bracket argument
-- @param state table parser state
-- @return table|nil node parsed optional node
local function parse_optional_argument(state)
	while state.source:sub(state.position, state.position):match("%s") do
		state.position = state.position + 1
	end

	if state.source:sub(state.position, state.position) ~= "[" then
		return nil
	end

	state.position = state.position + 1
	local node = parse_sequence(state, "]")

	if state.source:sub(state.position, state.position) == "]" then
		state.position = state.position + 1
	end

	return node
end

--- Check whether a command has a following body in the current group
-- @param state table parser state
-- @return boolean present true when an argument can be read
local function has_argument(state)
	local position = state.position

	while state.source:sub(position, position):match("%s") do
		position = position + 1
	end

	local character = state.source:sub(position, position)

	return character ~= "" and character ~= "}"
end

-- Text mode keeps a small set of TeX glyph names that have direct Unicode forms
local TEXT_SYMBOLS = {
	OE = "Œ",
	oe = "œ",
	o = "ø",
	O = "Ø",
	ss = "ß",
	aa = "å",
	i = "ı",
	AA = "Å",
	j = "ȷ",
	ae = "æ",
	AE = "Æ",
	textbar = "|",
	textbardbl = "‖",
	textbraceleft = "{",
	textbraceright = "}",
	textbackslash = "\\",
	textdegree = "°",
	-- Text punctuation commands keep their literal upright forms
	textunderscore = "_",
	textellipsis = "…",
	textendash = "–",
	textemdash = "—",
	textdagger = "†",
	textdaggerdbl = "‡",
	textasciitilde = "~",
	textasciicircum = "^",
	textquoteleft = "‘",
	textquoteright = "’",
	textquotedblleft = "“",
	textquotedblright = "”",
	-- Text-only currency, section, and comparison names stay upright
	textdollar = "$",
	textless = "<",
	textgreater = ">",
	textsterling = "£",
	P = "¶",
	S = "§",
	sect = "§",
	textregistered = "®",
}
local TEXT_ACCENTS = {
	H = "̋",
	r = "̊",
}

--- Return one Unicode circled letter or digit
-- @param character string ASCII letter or digit
-- @return string circled visible character
local function circled_character(character)
	local byte = character:byte()

	if character == "0" then
		return utf8.char(0x24EA)
	elseif byte >= string.byte("1") and byte <= string.byte("9") then
		return utf8.char(0x2460 + byte - string.byte("1"))
	elseif byte >= string.byte("A") and byte <= string.byte("Z") then
		return utf8.char(0x24B6 + byte - string.byte("A"))
	elseif byte >= string.byte("a") and byte <= string.byte("z") then
		return utf8.char(0x24D0 + byte - string.byte("a"))
	end

	return character
end

--- Read a text-like command argument while preserving its spaces
-- Nested braces remain visible text because full TeX text mode is intentionally excluded
-- @param state table parser state
-- @return string text visible argument
local function parse_text_argument(state)
	while state.source:sub(state.position, state.position):match("%s") do
		state.position = state.position + 1
	end

	if state.source:sub(state.position, state.position) ~= "{" then
		local node = parse_argument(state)

		-- A missing argument is empty text, not a parser failure
		return node and node.text or ""
	end

	local start = state.position + 1
	local depth = 1
	local position = start

	while position <= #state.source and depth > 0 do
		local character = state.source:sub(position, position)

		if character == "{" then
			depth = depth + 1
		elseif character == "}" then
			depth = depth - 1
		end
		position = position + 1
	end
	if depth ~= 0 then
		state.position = #state.source + 1

		return nil
	end

	local finish = position - 2
	state.position = position
	local text = state.source:sub(start, finish)

	-- Pattern breakdown: match 'textcircled', optional braces, and one ASCII character
	text = text:gsub("\\textcircled%s*{?([%w])}?", circled_character)

	-- Pattern breakdown: one supported text accent command, then its braced text
	text = text:gsub("\\([Hr])%{([^{}]+)%}", function(command, body)
		return body .. TEXT_ACCENTS[command]
	end)

	-- Pattern breakdown: '\\([%a]+)' captures one alphabetic TeX command after a backslash
	text = text:gsub("\\([%a]+)", function(command)
		return TEXT_SYMBOLS[command] or "\\" .. command
	end)

	-- Pattern breakdown: '\%%' matches a backslash followed by a literal percent sign
	text = text:gsub("\\%%", "%%")
	-- TeX text mode turns three hyphens into an em dash before handling two
	text = text:gsub("%-%-%-", "—")

	return (text:gsub("%-%-", "–"))
end

--- Parse text that may briefly return to math between dollar signs
-- @param state table parser state
-- @return table node plain text and embedded math segments
local function parse_mixed_text_argument(state)
	local text = parse_text_argument(state)
	-- Pattern breakdown: match one complete '\sout{...}' wrapper and capture its plain text
	local struck_text = text:match("^\\sout%{(.*)%}$")

	if struck_text then
		return {
			kind = "strike",
			body = { kind = "plain_text", text = struck_text },
		}
	end

	local children = {}
	local position = 1

	while position <= #text do
		local math_start = text:find("$", position, true)

		if not math_start then
			children[#children + 1] = {
				kind = "plain_text",
				text = text:sub(position),
			}
			break
		end
		if math_start > position then
			children[#children + 1] = {
				kind = "plain_text",
				text = text:sub(position, math_start - 1),
			}
		end

		local math_finish = text:find("$", math_start + 1, true)

		if not math_finish then
			children[#children + 1] = {
				kind = "plain_text",
				text = text:sub(math_start),
			}
			break
		end

		children[#children + 1] = parse_nested_source(
			state,
			text:sub(math_start + 1, math_finish - 1)
		)
		position = math_finish + 1
	end

	if #children == 0 then
		return { kind = "plain_text", text = "" }
	elseif #children == 1 then
		return children[1]
	end

	return { kind = "sequence", children = children }
end

--- Skip whitespace before one macro token or argument
-- @param state table parser state
local function skip_whitespace(state)
	while state.source:sub(state.position, state.position):match("%s") do
		state.position = state.position + 1
	end
end

--- Read balanced raw source without parsing its contents
-- @param state table parser state
-- @param opening string required opening character
-- @param closing string matching closing character
-- @return string|nil source text inside the pair
local function read_raw_group(state, opening, closing)
	skip_whitespace(state)

	if state.source:sub(state.position, state.position) ~= opening then
		return nil
	end

	local start = state.position + 1
	local depth = 1
	local position = start

	while position <= #state.source and depth > 0 do
		local character = state.source:sub(position, position)

		if character == opening then
			depth = depth + 1
		elseif character == closing then
			depth = depth - 1
		end
		position = position + 1
	end

	local finish = depth == 0 and position - 2 or #state.source
	state.position = depth == 0 and position or (#state.source + 1)

	return state.source:sub(start, finish)
end

--- Parse the small MathJax bbox option subset used by Markdown examples
-- @param source string comma-separated bbox options
-- @return table options frame fields
local function parse_bbox_options(source)
	local options = { draw_frame = false }

	for raw_item in (source or ""):gmatch("[^,]+") do
		local item = raw_item:match("^%s*(.-)%s*$")
		local border_width, border_color = item:match(
			"^border:%s*(%d*%.?%d+)px%s+solid%s+(.+)$"
		)
		local padding = item:match("^(%d*%.?%d+)px$")

		if border_width then
			options.draw_frame = true
			options.rule_width = tonumber(border_width)
			options.frame_color = border_color:match("^%s*(.-)%s*$")
		elseif padding then
			options.padding = tonumber(padding)
		elseif item ~= "" then
			options.background = item
		end
	end

	return options
end

--- Read one unparsed required macro argument
-- @param state table parser state
-- @return string source one group, command, or character
local function read_raw_argument(state)
	skip_whitespace(state)
	if state.position > #state.source then
		return ""
	end

	if state.source:sub(state.position, state.position) == "{" then
		return read_raw_group(state, "{", "}") or ""
	end

	local start = state.position

	if state.source:sub(state.position, state.position) == "\\" then
		read_command(state)
	else
		local _, next_position = read_character(state.source, state.position)
		state.position = next_position
	end

	return state.source:sub(start, state.position - 1)
end

--- Read one command name used by a macro definition
-- @param state table parser state
-- @return string|nil name command name without its slash
local function read_macro_name(state)
	skip_whitespace(state)
	local braced = state.source:sub(state.position, state.position) == "{"

	if braced then
		state.position = state.position + 1
		skip_whitespace(state)
	end
	if state.source:sub(state.position, state.position) ~= "\\" then
		return nil
	end

	local name = read_command(state)

	if braced then
		skip_whitespace(state)

		if state.source:sub(state.position, state.position) ~= "}" then
			return nil
		end
		state.position = state.position + 1
	end

	return name
end

--- Expand one stored macro through the ordinary parser
-- @param state table parser state after the macro command
-- @param command string invoked macro name
-- @param macro table stored macro definition
-- @return table node parsed replacement
local function expand_macro(state, command, macro)
	if (state.expansion_depth or 0) >= 32 then
		return { kind = "unsupported", text = "\\" .. command }
	end
	if macro.operator then
		return {
			kind = "operator",
			text = macro.operator,
			math_class = "operator",
			display_limits = macro.display_limits,
		}
	end

	local arguments = {}
	local first_required = 1

	if macro.default ~= nil then
		arguments[1] = read_raw_group(state, "[", "]") or macro.default
		first_required = 2
	end
	for argument_index = first_required, macro.arguments do
		arguments[argument_index] = read_raw_argument(state)
	end

	-- Pattern breakdown:
	-- '#' marks one replacement parameter
	-- '([1-9])' captures its one-based argument number
	local replacement = macro.replacement:gsub("#([1-9])", function(index)
		-- Grouping preserves the token boundary after a control word such as '\lVert#1'
		return "{" .. (arguments[tonumber(index)] or "") .. "}"
	end)

	return parse_sequence({
		source = replacement,
		position = 1,
		references = state.references,
		equation_tags = state.equation_tags,
		-- Eager definitions use the macro meanings captured when they were declared
		macros = macro.expansion_macros or state.macros,
		environments = state.environments,
		expansion_depth = (state.expansion_depth or 0) + 1,
	})
end

--- Copy the current macro names without sharing later replacements
-- @param macros table current formula-local macro table
-- @return table copy captured macro meanings
local function copy_macros(macros)
	local copy = {}

	for name, macro in pairs(macros) do
		copy[name] = macro
	end

	return copy
end

--- Store one plain TeX def declaration
-- @param state table parser state after the def command
-- @param eager boolean capture current macro meanings for edef and xdef
-- @return boolean defined true when a complete declaration was stored
local function define_plain_macro(state, eager)
	local name = read_macro_name(state)
	local argument_count = 0

	if not name then
		return false
	end

	skip_whitespace(state)
	while state.source:sub(state.position, state.position) == "#" do
		local argument = tonumber(state.source:sub(state.position + 1, state.position + 1))

		if not argument then
			break
		end
		argument_count = math.max(argument_count, argument)
		state.position = state.position + 2
		skip_whitespace(state)
	end

	local replacement = read_raw_group(state, "{", "}")

	if replacement then
		state.macros[name] = {
			arguments = argument_count,
			replacement = replacement,
			-- A shallow copy is enough because later declarations replace whole entries
			expansion_macros = eager and copy_macros(state.macros) or nil,
		}

		return true
	end

	return false
end

--- Store one LaTeX command declaration
-- @param state table parser state after the declaration command
-- @param mode string new, renew, or provide behavior
-- @return boolean defined true when a complete declaration was stored
local function define_latex_macro(state, mode)
	local name = read_macro_name(state)

	if not name then
		return false
	end

	local count_source = read_raw_group(state, "[", "]")
	local parsed_count = tonumber(count_source)
	local argument_count = parsed_count or 0

	if count_source and (not parsed_count
		or argument_count < 0 or argument_count > 9 or argument_count % 1 ~= 0)
	then
		return false
	end

	local default = argument_count > 0 and read_raw_group(state, "[", "]") or nil
	local replacement = read_raw_group(state, "{", "}")

	if replacement then
		local exists = state.macros[name] ~= nil

		if mode == "new" and exists or mode == "renew" and not exists then
			return false
		end
		if mode == "provide" and exists then
			return true
		end

		state.macros[name] = {
			arguments = argument_count,
			default = default,
			replacement = replacement,
		}

		return true
	end

	return false
end

--- Hide one declaration and discard unfinished placeholder text
-- @param state table parser state after the declaration
-- @param defined boolean true when a complete declaration was stored
-- @return table node invisible declaration node
local function finish_declaration(state, defined)
	if not defined then
		-- An unfinished declaration owns the remaining source instead of showing it as math
		state.position = #state.source + 1
	end

	return { kind = "empty" }
end

--- Store one DeclareMathOperator definition
-- @param state table parser state after the declaration command
-- @return boolean defined true when a complete operator was stored
local function define_math_operator(state)
	local display_limits = state.source:sub(state.position, state.position) == "*"

	if display_limits then
		state.position = state.position + 1
	end

	local name = read_macro_name(state)
	local text = read_raw_group(state, "{", "}")

	if not name or text == nil then
		return false
	end

	state.macros[name] = {
		arguments = 0,
		operator = text,
		display_limits = display_limits,
	}

	return true
end

--- Store one no-argument newenvironment declaration
-- @param state table parser state after the declaration command
-- @return boolean defined true when a complete environment was stored
local function define_environment(state)
	local name = read_raw_group(state, "{", "}")
	local before = read_raw_group(state, "{", "}")
	local after = read_raw_group(state, "{", "}")

	if not name or name == "" or before == nil or after == nil then
		return false
	end

	state.environments[name] = {
		before = before,
		after = after,
	}

	return true
end

--- Find the closing marker paired with one possibly nested environment
-- @param source string complete expression
-- @param position number first byte inside the environment
-- @param name string environment name
-- @return number|nil finish opening byte of the paired end marker
local function find_environment_end(source, position, name)
	local begin_marker = "\\begin{" .. name .. "}"
	local end_marker = "\\end{" .. name .. "}"
	local depth = 1

	while depth > 0 do
		local nested_begin = source:find(begin_marker, position, true)
		local nested_end = source:find(end_marker, position, true)

		if not nested_end then
			return nil
		end
		if nested_begin and nested_begin < nested_end then
			depth = depth + 1
			position = nested_begin + #begin_marker
		else
			depth = depth - 1
			if depth == 0 then
				return nested_end
			end
			position = nested_end + #end_marker
		end
	end
end

--- Expand one stored environment through its opening and closing source
-- @param state table parser state after the environment name
-- @param name string invoked environment name
-- @param environment table stored opening and closing source
-- @return table node parsed expanded environment
local function expand_environment(state, name, environment)
	if (state.expansion_depth or 0) >= 32 then
		return { kind = "unsupported", text = "\\begin{" .. name .. "}" }
	end

	local end_marker = "\\end{" .. name .. "}"
	local finish = find_environment_end(state.source, state.position, name)

	if not finish then
		return { kind = "unsupported", text = "\\begin{" .. name .. "}" }
	end

	local body = state.source:sub(state.position, finish - 1)
	state.position = finish + #end_marker

	return parse_sequence({
		source = environment.before .. body .. environment.after,
		position = 1,
		references = state.references,
		equation_tags = state.equation_tags,
		macros = state.macros,
		environments = state.environments,
		expansion_depth = (state.expansion_depth or 0) + 1,
	})
end

--- Copy one command meaning into a new let name
-- @param state table parser state after the let command
-- @return boolean defined true when a source command was copied
local function define_let_macro(state)
	local name = read_macro_name(state)

	if not name then
		return false
	end

	skip_whitespace(state)
	if state.source:sub(state.position, state.position) == "=" then
		state.position = state.position + 1
	end
	skip_whitespace(state)

	local source_start = state.position
	local source_name = read_macro_name(state)

	if source_name and state.macros[source_name] then
		local source = state.macros[source_name]
		state.macros[name] = {
			arguments = source.arguments,
			default = source.default,
			replacement = source.replacement,
			operator = source.operator,
			display_limits = source.display_limits,
			expansion_macros = source.expansion_macros,
		}
	elseif source_name then
		state.macros[name] = {
			arguments = 0,
			replacement = state.source:sub(source_start, state.position - 1),
		}
	else
		return false
	end

	return true
end

--- Read one supported TeX length with optional surrounding braces
-- @param state table parser state
-- @param braced boolean require braces around the length
-- @param allowed_units table accepted unit names
-- @return number|nil amount numeric length
-- @return string|nil unit accepted unit name
local function read_length(state, braced, allowed_units)
	local start = state.position

	while state.source:sub(state.position, state.position):match("%s") do
		state.position = state.position + 1
	end
	if braced and state.source:sub(state.position, state.position) ~= "{" then
		state.position = start

		return nil
	end
	if braced then
		state.position = state.position + 1
	end

	-- Pattern breakdown:
	-- '[+-]?' accepts an optional sign
	-- '%d*%.?%d+' accepts integers, decimals, and leading decimals such as '.5'
	-- '[A-Za-z]+' captures the required unit name
	local amount, unit, finish = state.source:match(
		"^([+-]?%d*%.?%d+)%s*([A-Za-z]+)()",
		state.position
	)

	if not amount or not allowed_units[unit] then
		state.position = start

		return nil
	end
	state.position = finish

	while braced and state.source:sub(state.position, state.position):match("%s") do
		state.position = state.position + 1
	end
	if braced and state.source:sub(state.position, state.position) ~= "}" then
		state.position = start

		return nil
	end
	if braced then
		state.position = state.position + 1
	end

	return tonumber(amount), unit
end

--- Parse one named or control-symbol accent through the shared accent node
-- @param state table parser state
-- @param command string TeX command name or escaped symbol
-- @param accent table accent drawing data
-- @return table node parsed accent
local function parse_accent(state, command, accent)
	local body = parse_argument(state)

	if TEXT_ACCENT_COMMANDS[command] then
		-- TeX text accents keep upright letters even when called from math
		body = { kind = "styled", style = "mathrm", body = body }
	end

	return {
		kind = "accent",
		command = command,
		mark = accent.glyph,
		dots = accent.dots,
		bar = accent.bar == true,
		under = accent.position == "under",
		rule = ACCENT_LINES[command] ~= nil or accent.rule == true,
		bracket = accent.bracket == true,
		segment = accent.segment == true,
		stretchy = accent.stretchy == true,
		attach_scripts = accent.attach_scripts == true,
		body = body,
	}
end

--- Parse one atom before scripts are attached
-- @param state table parser state
-- @return table|nil node parsed atom
parse_atom = function(state)
	local character = state.source:sub(state.position, state.position)

	if character == "" then
		return nil
	end

	if character == "{" then
		state.position = state.position + 1
		local group = parse_sequence(state, "}")

		if state.source:sub(state.position, state.position) == "}" then
			state.position = state.position + 1
		end

		return group
	end

	if character == "\\" then
		local command, is_word = read_command(state)

		if not is_word then
			if command == "@" then
				local internal = state.source:match("^([A-Za-z]+)", state.position)

				if internal and INVISIBLE_AT_COMMANDS[internal] then
					state.position = state.position + #internal

					return { kind = "empty" }
				end
			end

			local accent = ACCENTS[command]

			if accent then
				return parse_accent(state, command, accent)
			end

			if SPACE_SCALES[command] then
				-- TeX spaces use fixed fractions of one em
				return { kind = "space", scale = SPACE_SCALES[command] }
			end

			return { kind = "text", text = command }
		end

		-- Formula previews accept global syntax without leaking state into other formulas
		if command == "global" then
			local declaration_start = state.position

			skip_whitespace(state)
			if state.source:sub(state.position, state.position) == "\\" then
				local nested_command, nested_is_word = read_command(state)

				if nested_is_word and (nested_command == "def" or nested_command == "gdef"
					or nested_command == "edef" or nested_command == "xdef"
					or nested_command == "let" or nested_command == "futurelet")
				then
					command = nested_command
				else
					state.position = declaration_start
				end
			end
		end

		if command == "newenvironment" then
			local defined = define_environment(state)

			return defined
				and { kind = "empty" }
				or { kind = "unsupported", text = "\\newenvironment" }
		elseif command == "DeclareMathOperator" then
			local defined = define_math_operator(state)

			return defined
				and { kind = "empty" }
				or { kind = "unsupported", text = "\\DeclareMathOperator" }
		elseif command == "def" or command == "gdef"
			or command == "edef" or command == "xdef"
		then
			local defined = define_plain_macro(
				state,
				command == "edef" or command == "xdef"
			)

			return finish_declaration(state, defined)
		elseif command == "newcommand" or command == "renewcommand"
			or command == "providecommand"
		then
			local mode = command == "newcommand" and "new"
				or command == "renewcommand" and "renew"
				or "provide"
			local defined = define_latex_macro(state, mode)

			return finish_declaration(state, defined)
		elseif command == "let" then
			local defined = define_let_macro(state)

			return finish_declaration(state, defined)
		elseif command == "futurelet" then
			-- Future look-ahead definitions are documentation-only and have no visible result
			state.position = #state.source + 1

			return { kind = "empty" }
		elseif state.macros[command] then
			return expand_macro(state, command, state.macros[command])
		end

		if INVISIBLE_CONTROL_COMMANDS[command] then
			return { kind = "empty" }
		end

		if command == "frac" or command == "dfrac" or command == "tfrac"
			or command == "cfrac"
		then
			local fraction = {
				kind = "fraction",
				numerator = parse_argument(state),
				denominator = parse_argument(state),
			}

			-- Display and text variants reuse the normal fraction with an explicit math size
			if command ~= "frac" then
				return {
					kind = "math_style",
					style = command == "tfrac" and "text" or "display",
					body = fraction,
				}
			end

			return fraction
		elseif command == "genfrac" then
			local left = parse_text_argument(state)
			local right = parse_text_argument(state)
			local rule = parse_text_argument(state)
			-- A general fraction accepts either an empty rule or one em/pt thickness
			local rule_width, rule_unit = rule:match("^([%d.]+)%s*(%a+)$")
			rule_unit = (rule_unit == "em" or rule_unit == "pt") and rule_unit or nil
			rule_width = tonumber(rule_width)

			if not rule_unit then
				rule_width = nil
			end

			local style = ({
				["0"] = "display",
				["1"] = "text",
				["2"] = "script",
				["3"] = "scriptscript",
			})[parse_text_argument(state)]
			local fraction = {
				kind = "fraction",
				numerator = parse_argument(state),
				denominator = parse_argument(state),
				line_hidden = rule_width == 0,
				rule_width = rule_width,
				rule_unit = rule_unit,
			}

			-- Empty delimiter arguments mean no wrapper
			if left ~= "" or right ~= "" then
				fraction = {
					kind = "delimited",
					left = left,
					right = right,
					body = fraction,
				}
			end
			if style then
				fraction = { kind = "math_style", style = style, body = fraction }
			end

			return fraction
		elseif command == "binom" or command == "dbinom" or command == "tbinom" then
			local binomial = {
				kind = "delimited",
				left = "(",
				right = ")",
				body = {
					kind = "fraction",
					numerator = parse_argument(state),
					denominator = parse_argument(state),
					line_hidden = true,
				},
			}

			-- Size-specific binomials differ only in their surrounding math style
			if command ~= "binom" then
				return {
					kind = "math_style",
					style = command == "tbinom" and "text" or "display",
					body = binomial,
				}
			end

			return binomial
		elseif command == "sqrt" then
			return {
				kind = "root",
				index = parse_optional_argument(state),
				body = parse_argument(state),
			}
		elseif command == "mathstrut" then
			-- A math strut contributes height and depth without drawing text
			return {
				kind = "phantom",
				mode = "vphantom",
				body = { kind = "symbol", text = "(" },
			}
		elseif command == "verb" then
			local delimiter = state.source:sub(state.position, state.position)
			local finish = delimiter ~= "" and not delimiter:match("%s")
				and state.source:find(delimiter, state.position + 1, true)

			-- An incomplete verb command stays visible so the source can be corrected
			if not finish then
				return { kind = "unsupported", text = "\\verb" }
			end

			local text = state.source:sub(state.position + 1, finish - 1)
			state.position = finish + 1

			return {
				kind = "text_styled",
				family = "mono",
				body = { kind = "plain_text", text = text },
			}
		elseif command == "text" then
			return parse_mixed_text_argument(state)
		elseif command == "vcenter" then
			-- The wrapper moves a tall box's center onto the surrounding math axis
			return { kind = "vcenter", body = parse_argument(state) }
		elseif command == "hbox" then
			return parse_mixed_text_argument(state)
		elseif command == "mbox" then
			return { kind = "plain_text", text = parse_text_argument(state) }
		elseif command == "operatorname" or command == "operatornamewithlimits" then
			local starred = command == "operatorname"
				and state.source:sub(state.position, state.position) == "*"

			if starred then
				state.position = state.position + 1
			end

			return {
				kind = "operator",
				text = parse_text_argument(state),
				math_class = "operator",
				display_limits = starred or command == "operatornamewithlimits",
			}
		elseif command == "href" then
			-- Math links keep the same target used by ordinary Markdown links
			return {
				kind = "link",
				target = parse_text_argument(state),
				body = parse_argument(state),
			}
		elseif command == "url" then
			local target = parse_text_argument(state) or ""

			-- A URL uses its address as both the visible label and click target
			return {
				kind = "link",
				target = target,
				body = { kind = "plain_text", text = target },
			}
		elseif command == "KaTeX" or command == "LaTeX" or command == "TeX" then
			-- Brand names use ordinary text without special logo typography
			return { kind = "plain_text", text = command }
		elseif command == "allowbreak" or command == "newline" or command == "nobreak" then
			-- These commands affect wrapping but draw no visible math atom
			return { kind = "empty" }
		elseif command == "require" then
			parse_text_argument(state)

			return { kind = "empty" }
		elseif command == "tag" then
			local starred = state.source:sub(state.position, state.position) == "*"

			-- A starred tag omits the ordinary surrounding parentheses
			if starred then
				state.position = state.position + 1
			end

			return {
				kind = "equation_tag",
				text = parse_text_argument(state),
				starred = starred,
			}
		elseif command == "nonumber" or command == "notag" then
			-- Number suppression is collected by the surrounding row environment
			return { kind = "equation_no_number" }
		elseif command == "label" then
			return { kind = "equation_label", text = parse_text_argument(state) }
		elseif command == "eqref" then
			local name = parse_text_argument(state)
			local value = state.references and state.references[name] or "??"

			return { kind = "plain_text", text = "(" .. value .. ")" }
		elseif command == "begin" then
			local name = parse_text_argument(state)
			local alignment = ROW_ENVIRONMENTS[name]

			if state.environments[name] then
				return expand_environment(state, name, state.environments[name])
			elseif alignment then
				return parse_environment(state, name, alignment)
			end

			return { kind = "unsupported", text = "\\begin{" .. name .. "}" }
		elseif TEXT_FONT_COMMANDS[command] then
			local text_style = TEXT_FONT_COMMANDS[command]

			-- Text family, weight, and shape changes remain separate for nesting
			return {
				kind = "text_styled",
				family = text_style.family,
				weight = text_style.weight,
				shape = text_style.shape,
				body = parse_argument(state),
			}
		elseif FONT_STYLE_COMMANDS[command] then
			return {
				kind = "styled",
				style = FONT_STYLE_COMMANDS[command],
				body = parse_argument(state),
			}
		elseif MATH_CLASS_COMMANDS[command] then
			-- A class signature without a body is invisible in the support table
			return {
				kind = "class",
				math_class = MATH_CLASS_COMMANDS[command],
				body = has_argument(state) and parse_argument(state)
					or { kind = "empty" },
			}
		elseif command == "bra" or command == "Bra" then
			return {
				kind = "delimited",
				left = "⟨",
				right = "|",
				body = parse_argument(state),
			}
		elseif command == "ket" or command == "Ket" then
			return {
				kind = "delimited",
				left = "|",
				right = "⟩",
				body = parse_argument(state),
			}
		elseif command == "braket" or command == "Braket" then
			return {
				kind = "delimited",
				left = "⟨",
				right = "⟩",
				body = parse_argument(state),
			}
		elseif command == "limits" then
			-- Limits modifies a preceding operator and draws nothing by itself
			return { kind = "empty" }
		elseif command == "Set" or command == "set" then
			local body = parse_argument(state)

			-- Set builders add their braces and the small inner air shown by KaTeX
			return {
				kind = "delimited",
				left = "{",
				right = "}",
				body = {
					kind = "sequence",
					children = {
						{ kind = "space", scale = 1 / 6 },
						body,
						{ kind = "space", scale = 1 / 6 },
					},
				},
			}
		elseif command == "pmod" or command == "pod" then
			local body = parse_argument(state)
			local children = {
				{ kind = "space", scale = 4 / 9 },
				{ kind = "text", text = "(", math_class = "open" },
			}

			-- Parenthesized mod includes its name while pod includes only the value
			if command == "pmod" then
				children[#children + 1] = {
					kind = "plain_text",
					text = "mod",
					math_class = "ordinary",
				}
				children[#children + 1] = { kind = "space", scale = 1 / 6 }
			end
			children[#children + 1] = body
			children[#children + 1] = {
				kind = "text",
				text = ")",
				math_class = "close",
			}

			return { kind = "sequence", children = children }
		elseif command == "llap" or command == "rlap" or command == "clap"
			or command == "mathllap" or command == "mathrlap" or command == "mathclap"
		then
			-- Text and math spellings use the same zero-width placement
			return {
				kind = "overlap",
				alignment = command:find("llap", 1, true) and "left"
					or command:find("clap", 1, true) and "center"
					or "right",
				body = parse_argument(state),
			}
		elseif FONT_SIZE_DECLARATIONS[command] then
			-- A size command parsed as one atom applies to its following argument
			return {
				kind = "scaled",
				scale = FONT_SIZE_DECLARATIONS[command],
				body = parse_argument(state),
			}
		elseif EXTENSIBLE_ARROW_GLYPHS[command] then
			local below = parse_optional_argument(state)

			return {
				kind = "extensible_arrow",
				glyph = EXTENSIBLE_ARROW_GLYPHS[command],
				above = parse_argument(state),
				below = below,
				math_class = "relation",
			}
		elseif command == "phase" then
			return { kind = "phase", body = parse_argument(state) }
		elseif ACCENTS[command] or ACCENT_LINES[command] then
			local accent = ACCENTS[command] or ACCENT_LINES[command]

			return parse_accent(state, command, accent)
		elseif command == "overset" or command == "underset" or command == "stackrel" then
			local annotation = parse_argument(state)
			local base = parse_argument(state)
			local single_base = base.children and #base.children == 1
				and base.children[1]

			return {
				kind = "scripts",
				base = base,
				superscript = command ~= "underset" and annotation or nil,
				subscript = command == "underset" and annotation or nil,
				stacked_scripts = true,
				math_class = base.math_class or single_base and single_base.math_class,
			}
		elseif command == "substack" then
			-- Each top-level row break becomes one compact centered row
			local source = read_raw_group(state, "{", "}") or ""
			local raw_rows = split_environment(source)
			local rows = {}

			for row_index = 1, #raw_rows do
				rows[row_index] = {
					parse_nested_source(state, table.concat(raw_rows[row_index], "&")),
				}
			end

			return {
				kind = "rows",
				rows = rows,
				alignment = "centered",
				environment = "substack",
				compact_rows = true,
			}
		elseif command == "textcolor" then
			return {
				kind = "color",
				name = parse_text_argument(state),
				body = parse_argument(state),
			}
		elseif command == "not" then
			-- TeX ignores ordinary space before the relation being negated
			skip_whitespace(state)
			local body = parse_atom(state)

			if body then
				return {
					kind = "negated",
					body = body,
					math_class = body.math_class,
				}
			end
		elseif FIXED_DELIMITER_SCALES[command:gsub("[lrm]$", "")] then
			local size_command = command:gsub("[lrm]$", "")
			local suffix = command:match("([lrm])$")
			local delimiter = parse_atom(state)

			if delimiter then
				return {
					kind = "fixed_delimiter",
					body = delimiter,
					scale = FIXED_DELIMITER_SCALES[size_command],
					math_class = FIXED_DELIMITER_CLASSES[suffix] or delimiter.math_class,
				}
			end
		elseif command == "left" then
			return parse_delimited(state)
		elseif command == "right" or command == "middle" then
			-- Unmatched structural commands stay visible for a controlled fallback
			return { kind = "unsupported", text = "\\" .. command }
		elseif command == "kern" or command == "mkern" or command == "mskip"
			or command == "hskip" or command == "hspace"
		then
			local argument_start = state.position
			local starred = command == "hspace"
				and state.source:sub(state.position, state.position) == "*"

			-- The star changes page breaking in TeX but not preview spacing
			if starred then
				state.position = state.position + 1
			end

			local math_space = command == "mkern" or command == "mskip"
			local units = math_space and { mu = true } or { em = true }
			local amount, unit = read_length(state, false, units)

			if not amount then
				amount, unit = read_length(state, true, units)
			end

			if amount then
				return {
					kind = "space",
					scale = unit == "mu" and amount / 18 or amount,
				}
			end
			state.position = argument_start
		elseif command == "rule" then
			local argument_start = state.position
			local width, width_unit = read_length(state, true, RULE_LENGTH_UNITS)
			local height, height_unit = read_length(state, true, RULE_LENGTH_UNITS)

			if width and height then
				-- Keep the original units so layout can distinguish relative and fixed sizes
				return {
					kind = "rule",
					width = width,
					width_unit = width_unit,
					height = height,
					height_unit = height_unit,
				}
			end
			state.position = argument_start
		elseif command == "phantom" or command == "hphantom" or command == "vphantom" then
			return {
				kind = "phantom",
				mode = command,
				body = parse_argument(state),
			}
		elseif command == "smash" then
			local mode = read_raw_group(state, "[", "]")

			-- Optional t or b selects which vertical side loses its layout size
			return {
				kind = "smash",
				mode = mode,
				body = parse_argument(state),
			}
		elseif command == "cancel" or command == "bcancel" or command == "xcancel" then
			return {
				kind = "cancel",
				mode = command,
				body = parse_argument(state),
			}
		elseif command == "sout" then
			return { kind = "strike", body = parse_argument(state) }
		elseif command == "raisebox" then
			local amount, unit = read_length(
				state,
				true,
				{ em = true, pt = true }
			)

			if amount then
				return {
					kind = "raise",
					amount = amount,
					unit = unit,
					body = parse_mixed_text_argument(state),
				}
			end
		elseif command == "char" then
			-- Invalid or incomplete codes remain visible for correction
			local text = read_character_code(state)

			return text and { kind = "text", text = text }
				or { kind = "unsupported", text = "\\char" }
		elseif command == "boxed" or command == "fbox" then
			local body = command == "boxed"
				and parse_argument(state)
				or { kind = "plain_text", text = parse_text_argument(state) }

			return { kind = "frame", body = body }
		elseif command == "colorbox" then
			if not has_argument(state) then
				return { kind = "empty" }
			end

			return {
				kind = "frame",
				background = parse_text_argument(state),
				-- Color-box contents use text mode but may enter math mode with dollar signs
				body = parse_mixed_text_argument(state),
				draw_frame = false,
			}
		elseif command == "fcolorbox" then
			if not has_argument(state) then
				return { kind = "empty" }
			end

			return {
				kind = "frame",
				frame_color = parse_text_argument(state),
				background = parse_text_argument(state),
				-- Framed color-box contents follow the same text and embedded-math rule
				body = parse_mixed_text_argument(state),
			}
		elseif command == "bbox" then
			local options = parse_bbox_options(read_raw_group(state, "[", "]"))
			options.kind = "frame"
			options.body = parse_argument(state)

			return options
		elseif command == "enclose" then
			return {
				kind = "enclose",
				mode = parse_text_argument(state),
				body = parse_argument(state),
			}
		elseif command == "angl" or command == "angln" then
			-- Actuarial notation draws a top and right rule around its argument
			return {
				kind = "enclose",
				mode = "actuarial",
				body = command == "angln"
					and { kind = "text", text = "n" }
					or parse_argument(state),
			}
		elseif command == "multicolumn" then
			-- The row layout expands this body across the requested columns
			return {
				kind = "multicolumn",
				columns = tonumber(parse_text_argument(state)) or 1,
				alignment = parse_text_argument(state),
				body = parse_argument(state),
			}
		elseif SPACE_SCALES[command] then
			return { kind = "space", scale = SPACE_SCALES[command] }
		elseif command == "smallsetminus" then
			-- The small set-minus needs a shallow stroke that font glyphs cannot provide
			return { kind = "small_setminus", math_class = "binary" }
		elseif SYMBOLS[command] then
			local text = SYMBOLS[command]
			local large_operator = LARGE_OPERATOR_COMMANDS[command] == true
			local math_class = large_operator and "operator"
				or RELATION_COMMANDS[command] and "relation"
				or CLASSES[text]
			local symbol = {
				kind = "symbol",
				text = text,
				command = command,
				base_codepoint = utf8.codepoint(text),
				styled_codepoint = ITALIC_GREEK[command],
				math_class = math_class,
				large_operator = large_operator,
				display_limits = DISPLAY_LIMIT_COMMANDS[command] == true,
			}

			-- The font's circle glyph is too small to surround an overlaid letter
			if command == "bigcirc" then
				return {
					kind = "scaled",
					scale = 1.45,
					body = symbol,
					math_class = math_class,
				}
			end

			return symbol
		elseif OPERATORS[command] then
			local operator = OPERATORS[command]

			return {
				kind = "operator",
				text = operator.text or command,
				math_class = operator.form == "binary" and "binary" or "operator",
				display_limits = operator.limits == true,
			}
		end

		-- Unsupported commands stay visible instead of silently corrupting a formula
		return { kind = "unsupported", text = "\\" .. command }
	end

	if character == "%" then
		local line_end = state.source:find("\n", state.position, true)
		state.position = line_end and line_end + 1 or #state.source + 1

		return nil
	end
	if character == "~" then
		state.position = state.position + 1

		-- A bare tilde is TeX's visible-width non-breaking space
		return { kind = "space", scale = 1 / 3 }
	end

	local text, next_position = read_character(state.source, state.position)
	state.position = next_position
	-- TeX draws a full arithmetic minus while its source uses the ASCII hyphen
	local visible_text = text == "-" and "−" or text

	return { kind = "text", text = visible_text, math_class = CLASSES[text] }
end

--- Apply TeX's contextual rule for unary and trailing binary symbols
-- @param children table parsed atoms in one sequence
local function normalize_binary_classes(children)
	for child_index = 1, #children do
		local child = children[child_index]

		if child.math_class == "binary" and child.kind ~= "class" then
			local previous = children[child_index - 1]
			local following = children[child_index + 1]
			local previous_class = previous and (previous.math_class or "ordinary")
			local following_class = following and (following.math_class or "ordinary")

			if not previous
				or CANNOT_PRECEDE_BINARY[previous_class]
				or not following
				or CANNOT_FOLLOW_BINARY[following_class]
			then
				child.math_class = "ordinary"
			end
		end
	end
end

--- Parse a sequence up to one delimiter or one atom
-- @param state table parser state
-- @param stop_character string|nil closing character
-- @param one_atom boolean|nil stop after the first atom
-- @param stop_commands table|nil commands left for the owning structure
-- @return table sequence parsed expression
parse_sequence = function(state, stop_character, one_atom, stop_commands)
	local children = {}

	while state.position <= #state.source do
		local character = state.source:sub(state.position, state.position)

		if stop_character and character == stop_character then
			break
		end
		if stop_commands and stop_commands[peek_command(state)] then
			break
		end

		if character:match("%s") then
			state.position = state.position + 1
		else
			local math_style = MATH_STYLE_COMMANDS[peek_command(state)]

			if math_style and not one_atom then
				read_command(state)
				normalize_binary_classes(children)

				-- A style declaration owns the remainder of its current group
				children[#children + 1] = {
					kind = "math_style",
					style = math_style,
					body = parse_sequence(
						state,
						stop_character,
						nil,
						stop_commands
					),
				}

				return { kind = "sequence", children = children }
			end

			local font_style = FONT_STYLE_DECLARATIONS[peek_command(state)]

			if font_style and not one_atom then
				read_command(state)
				normalize_binary_classes(children)

				-- A font declaration owns the remainder of its current group
				children[#children + 1] = {
					kind = "styled",
					style = font_style,
					body = parse_sequence(state, stop_character, nil, stop_commands),
				}

				return { kind = "sequence", children = children }
			end

			local font_scale = FONT_SIZE_DECLARATIONS[peek_command(state)]

			if font_scale and not one_atom then
				read_command(state)
				normalize_binary_classes(children)

				-- A size declaration owns the remainder of its current group
				children[#children + 1] = {
					kind = "scaled",
					scale = font_scale,
					body = parse_sequence(state, stop_character, nil, stop_commands),
				}

				return { kind = "sequence", children = children }
			end

			if peek_command(state) == "color" and not one_atom then
				read_command(state)
				normalize_binary_classes(children)

				-- A color declaration owns the remainder of its current group
				children[#children + 1] = {
					kind = "color",
					name = parse_text_argument(state),
					body = parse_sequence(
						state,
						stop_character,
						nil,
						stop_commands
					),
				}

				return { kind = "sequence", children = children }
			end

			local infix_command = not one_atom and peek_command(state)

			if infix_command == "over" or infix_command == "choose"
				or infix_command == "atop" or infix_command == "above"
				or infix_command == "brace" or infix_command == "brack"
			then
				read_command(state)
				normalize_binary_classes(children)
				local rule_width, rule_unit

				if infix_command == "above" then
					rule_width, rule_unit = read_length(
						state,
						true,
						{ em = true, pt = true }
					)
				end

				local fraction = {
					kind = "fraction",
					numerator = { kind = "sequence", children = children },
					denominator = parse_sequence(
						state,
						stop_character,
						nil,
						stop_commands
					),
					line_hidden = infix_command ~= "over" and infix_command ~= "above",
					rule_width = rule_width,
					rule_unit = rule_unit,
				}

				-- Plain TeX binomial forms are ruleless fractions inside matching delimiters
				local delimiters = {
					choose = { "(", ")" },
					brace = { "{", "}" },
					brack = { "[", "]" },
				}
				local pair = delimiters[infix_command]

				if pair then
					fraction = {
						kind = "delimited",
						left = pair[1],
						right = pair[2],
						body = fraction,
					}
				end

				return { kind = "sequence", children = { fraction } }
			end

			-- A leading script attaches to an invisible zero-width base
			local leading_script = character == "^" or character == "_"
			local atom = leading_script and { kind = "empty" } or parse_atom(state)

			if atom and (atom.kind ~= "empty" or leading_script) then
				local display_limits = atom.display_limits

				-- Limits modifiers override only the following script placement
				if atom.large_operator or atom.kind == "operator" then
					local modifier_position = state.position

					if state.source:sub(state.position, state.position) == "\\" then
						local modifier = read_command(state)

						if modifier == "limits" then
							display_limits = true
						elseif modifier == "nolimits" then
							display_limits = false
						else
							state.position = modifier_position
						end
					end
				end

				local next_character = state.source:sub(state.position, state.position)
				local superscript
				local subscript

				while next_character == "^" or next_character == "_" do
					state.position = state.position + 1

					if next_character == "^" then
						superscript = parse_argument(state)
					else
						subscript = parse_argument(state)
					end
					next_character = state.source:sub(state.position, state.position)
				end

				if superscript or subscript then
					atom = {
						kind = "scripts",
						base = atom,
						superscript = superscript,
						subscript = subscript,
						math_class = atom.math_class,
						display_limits = display_limits,
						stacked_scripts = atom.attach_scripts,
					}
				end

				children[#children + 1] = atom

				if one_atom then
					break
				end
			end
		end
	end

	normalize_binary_classes(children)

	return { kind = "sequence", children = children }
end

--- Parse one paired automatic delimiter group with optional middle delimiters
-- @param state table parser state positioned after the left command
-- @return table node delimited group
parse_delimited = function(state)
	local delimiter_start = state.position
	local left = read_delimiter(state)
	local segments = {}
	local middles = {}
	local stop_commands = { middle = true, right = true }
	local right = ""
	local closed = false

	if left == nil then
		return { kind = "unsupported", text = "\\left" }
	end

	while state.position <= #state.source do
		segments[#segments + 1] = parse_sequence(state, nil, nil, stop_commands)

		local command = peek_command(state)

		if command ~= "middle" and command ~= "right" then
			break
		end
		read_command(state)

		if command == "right" then
			local delimiter = read_delimiter(state)

			if delimiter == nil then
				break
			end
			right = delimiter
			closed = true
			break
		end
		middles[#middles + 1] = read_delimiter(state) or ""
	end
	if not closed then
		-- Let the normal parser keep every character of an incomplete pair visible
		state.position = delimiter_start

		return { kind = "unsupported", text = "\\left" }
	end

	return {
		kind = "delimited",
		left = left,
		right = right,
		segments = segments,
		middles = middles,
	}
end

--- Split environment source into top-level rows, cells, and rules
-- @param source string environment body
-- @return table rows raw cell strings
-- @return table row_gaps extra spacing after each row in em
-- @return table row_rules horizontal rule after each row
split_environment = function(source)
	local rows = { { "" } }
	local row_gaps = {}
	local row_rules = {}
	local depth = 0
	local environment_depth = 0
	local position = 1

	while position <= #source do
		local character = source:sub(position, position)
		local following = source:sub(position + 1, position + 1)
		local row = rows[#rows]
		local environment_finish

		if source:sub(position, position + 6) == "\\begin{" then
			environment_finish = source:find("}", position + 7, true)
			if environment_finish then
				environment_depth = environment_depth + 1
			end
		elseif source:sub(position, position + 4) == "\\end{" then
			environment_finish = source:find("}", position + 5, true)
			if environment_finish then
				environment_depth = math.max(0, environment_depth - 1)
			end
		end

		if environment_finish then
			row[#row] = row[#row] .. source:sub(position, environment_finish)
			position = environment_finish + 1
		elseif character == "{" then
			depth = depth + 1
			row[#row] = row[#row] .. character
			position = position + 1
		elseif character == "}" then
			depth = math.max(0, depth - 1)
			row[#row] = row[#row] .. character
			position = position + 1
		elseif depth == 0 and environment_depth == 0
			and source:sub(position, position + 9) == "\\hdashline"
		then
			row_rules[math.max(0, #rows - 1)] = "dashed"
			position = position + 10
		elseif depth == 0 and environment_depth == 0
			and source:sub(position, position + 5) == "\\hline"
		then
			row_rules[math.max(0, #rows - 1)] = "solid"
			position = position + 6
		elseif depth == 0 and environment_depth == 0
			and (character == "\\" and following == "\\"
				or source:sub(position, position + 2) == "\\cr")
		then
			local previous_row = #rows
			local is_cr = source:sub(position, position + 2) == "\\cr"
			rows[#rows + 1] = { "" }
			position = position + (is_cr and 3 or 2)

			if not is_cr and source:sub(position, position) == "[" then
				local spacing_finish = source:find("]", position + 1, true)
				local spacing = spacing_finish
					and source:sub(position + 1, spacing_finish - 1)
				local amount, unit

				if spacing then
					-- Pattern breakdown: signed decimal amount followed by one alphabetic unit
					amount, unit = spacing:match("^([%+%-]?%d*%.?%d+)(%a+)$")
				end
				local unit_scale = unit == "em" and 1
					or unit == "ex" and 0.5
					or unit == "pt" and 0.1
				if amount and unit_scale then
					row_gaps[previous_row] = tonumber(amount) * unit_scale
					position = spacing_finish + 1
				end
			end
		elseif depth == 0 and environment_depth == 0
			and source:sub(position, position + 6) == "\\cline{"
		then
			local range_finish = source:find("}", position + 7, true)
			local first_column
			local last_column

			-- Match one numeric start and end column separated by a hyphen
			if range_finish then
				first_column, last_column = source:sub(
					position + 7,
					range_finish - 1
				):match("^(%d+)%-(%d+)$")
			end

			if first_column and last_column then
				local boundary = math.max(0, #rows - 1)
				row_rules[boundary] = "solid"
				row_rules.ranges = row_rules.ranges or {}
				row_rules.ranges[boundary] = {
					first = tonumber(first_column),
					last = tonumber(last_column),
				}
				position = range_finish + 1
			else
				row[#row] = row[#row] .. character
				position = position + 1
			end
		elseif character == "\\" and following == "&" then
			row[#row] = row[#row] .. "\\&"
			position = position + 2
		elseif character == "&" and depth == 0 and environment_depth == 0 then
			row[#row + 1] = ""
			position = position + 1
		else
			row[#row] = row[#row] .. character
			position = position + 1
		end
	end

	return rows, row_gaps, row_rules
end

--- Parse one source fragment with the current formula-local definitions
-- @param state table parent parser state
-- @param source string nested source
-- @return table node parsed fragment
parse_nested_source = function(state, source)
	return parse_sequence({
		source = source,
		position = 1,
		references = state.references,
		equation_tags = state.equation_tags,
		macros = state.macros,
		environments = state.environments,
		expansion_depth = state.expansion_depth,
	})
end

--- Create one labelled CD arrow
-- @param state table parent parser state
-- @param direction string left, right, up, or down
-- @param above string|nil source above the arrow
-- @param below string|nil source below the arrow
-- @return table node arrow node
local function cd_arrow(state, direction, above, below)
	if direction == "up" or direction == "down" then
		local command = direction == "up" and "uparrow" or "downarrow"

		return {
			kind = "cd_vertical_arrow",
			body = {
				kind = "symbol",
				text = SYMBOLS[command],
				command = command,
			},
			left = above and above ~= "" and parse_nested_source(state, above) or nil,
			right = below and below ~= "" and parse_nested_source(state, below) or nil,
		}
	end

	return {
		kind = "extensible_arrow",
		-- CD arrows need the package's fixed three-em connector
		cd = true,
		direction = direction,
		above = above and above ~= "" and parse_nested_source(state, above) or nil,
		below = below and below ~= "" and parse_nested_source(state, below) or nil,
		math_class = "relation",
	}
end

--- Parse one horizontal AMS CD row into alternating object and arrow cells
-- @param state table parent parser state
-- @param source string raw CD row
-- @return table cells parsed cells
local function parse_cd_horizontal_row(state, source)
	local cells = {}
	local position = 1

	while position <= #source do
		local marker = source:find("@", position, true)
		local object = source:sub(position, marker and marker - 1 or #source)
		object = object:match("^%s*(.-)%s*$")

		if object ~= "" then
			cells[#cells + 1] = parse_nested_source(state, object)
		end
		if not marker then
			break
		end

		if source:sub(marker, marker + 3) == "@>>>" then
			cells[#cells + 1] = cd_arrow(state, "right")
			position = marker + 4
		elseif source:sub(marker, marker + 3) == "@<<<" then
			cells[#cells + 1] = cd_arrow(state, "left")
			position = marker + 4
		elseif source:sub(marker, marker + 1) == "@=" then
			-- '@=' uses the same long connector width as CD arrows
			cells[#cells + 1] = { kind = "cd_equal" }
			position = marker + 2
		elseif source:sub(marker, marker + 2) == "@>>" then
			local finish = source:find(">", marker + 3, true)
			local label = finish and source:sub(marker + 3, finish - 1) or ""
			cells[#cells + 1] = cd_arrow(state, "right", nil, label)
			position = finish and finish + 1 or #source + 1
		elseif source:sub(marker, marker + 1) == "@>" then
			local finish = source:find(">>", marker + 2, true)
			local label = finish and source:sub(marker + 2, finish - 1) or ""
			cells[#cells + 1] = cd_arrow(state, "right", label)
			position = finish and finish + 2 or #source + 1
		else
			position = marker + 1
		end
	end

	return cells
end

--- Parse one vertical AMS CD row and align it with object columns
-- @param state table parent parser state
-- @param source string raw CD row
-- @param column_count number shared diagram column count
-- @return table cells parsed cells
local function parse_cd_vertical_row(state, source, column_count)
	local items = {}

	for raw_chunk in source:gmatch("@([^@]*)") do
		local chunk = raw_chunk:match("^%s*(.-)%s*$")
		local arrow

		-- Each chunk describes the connector in the next object column
		if chunk == "." then
			arrow = { kind = "empty" }
		elseif chunk == "|" then
			arrow = {
				kind = "cd_vertical_arrow",
				body = { kind = "symbol", text = "‖", command = "Vert" },
			}
		elseif chunk:sub(1, 1) == "A" or chunk:sub(1, 1) == "V" then
			local marker = chunk:sub(1, 1)
			local direction = marker == "A" and "up" or "down"
			local left
			local right

			if chunk ~= marker:rep(3) then
				-- Pattern breakdown: opening marker, label, then matching closing markers
				-- One leading marker places the label left; two place it right
				left = chunk:match("^" .. marker .. "(.-)" .. marker:rep(2) .. "$")
				if left == nil then
					right = chunk:match("^" .. marker:rep(2) .. "(.-)" .. marker .. "$")
				end
			end
			arrow = cd_arrow(state, direction, left, right)
		end

		items[#items + 1] = arrow or { kind = "empty" }
	end

	local cells = {}

	-- Vertical arrows occupy the object columns and leave arrow columns empty
	for item_index = 1, math.ceil(column_count / 2) do
		cells[#cells + 1] = items[item_index] or { kind = "empty" }
		if #cells < column_count then
			cells[#cells + 1] = { kind = "empty" }
		end
	end

	return cells
end

--- Parse one AMS CD environment through the shared row layout
-- @param state table parent parser state
-- @param source string environment body
-- @return table node row node
local function parse_cd_environment(state, source)
	local raw_rows, row_gaps = split_environment(source)
	local rows = {}
	local vertical = {}
	local column_count = 0

	for row_index = 1, #raw_rows do
		local row_source = table.concat(raw_rows[row_index], "&")
		local trimmed = row_source:match("^%s*(.-)%s*$")
		vertical[row_index] = trimmed:sub(1, 1) == "@"

		if not vertical[row_index] then
			rows[row_index] = parse_cd_horizontal_row(state, trimmed)
			column_count = math.max(column_count, #rows[row_index])
		end
	end

	for row_index = 1, #raw_rows do
		if vertical[row_index] then
			rows[row_index] = parse_cd_vertical_row(
				state,
				table.concat(raw_rows[row_index], "&"),
				column_count
			)
		end
	end

	return {
		kind = "rows",
		rows = rows,
		row_gaps = row_gaps,
		alignment = "matrix",
		environment = "CD",
	}
end

--- Parse one supported row environment and consume its matching end marker
-- @param state table parser state positioned after the environment name
-- @param name string environment name
-- @param alignment string aligned, centered, or multline
-- @return table node parsed row environment
parse_environment = function(state, name, alignment)
	if name == "alignedat" or name == "alignat" or name == "alignat*" then
		parse_text_argument(state) -- The shared column layout does not need the pair count
	end

	local column_alignments
	local column_rules
	local column_rule_styles
	local default_column_alignment
	if name:sub(-1) == "*" then
		local requested_alignment = read_raw_group(state, "[", "]")
		default_column_alignment = ARRAY_COLUMN_ALIGNMENTS[requested_alignment or ""]
	elseif name == "array" or name == "subarray" then
		local specification_position = state.position
		local specification = read_raw_group(state, "{", "}")
		column_alignments = {}
		column_rules = {}
		column_rule_styles = {}

		for column in (specification or ""):gmatch(".") do
			if column == "|" then
				local boundary = #column_alignments
				column_rules[boundary] = (column_rules[boundary] or 0) + 1
			elseif column == ":" then
				local boundary = #column_alignments
				column_rule_styles[boundary] = "dashed"
			else
				local column_alignment = ARRAY_COLUMN_ALIGNMENTS[column]

				if not column_alignment then
					state.position = specification_position

					return { kind = "unsupported", text = "\\begin{" .. name .. "}" }
				end
				column_alignments[#column_alignments + 1] = column_alignment
			end
		end
		if #column_alignments == 0 then
			state.position = specification_position

			return { kind = "unsupported", text = "\\begin{" .. name .. "}" }
		end
	elseif name == "cases" or name == "rcases" then
		column_alignments = { "left", "left" }
	end

	local end_marker = "\\end{" .. name .. "}"
	local finish = find_environment_end(state.source, state.position, name)

	if not finish then
		-- KaTeX documents the starred matrix prefix on its own
		if name:sub(-1) == "*" then
			return { kind = "empty" }
		end

		return { kind = "unsupported", text = "\\begin{" .. name .. "}" }
	end

	local source = state.source:sub(state.position, finish - 1)

	if name == "CD" then
		state.position = finish + #end_marker

		return parse_cd_environment(state, source)
	end

	local raw_rows, row_gaps, row_rules = split_environment(source)
	local rows = {}
	local labels = {}
	local explicit_tags = {}
	local explicit_tag_stars = {}
	local suppressed_tags = {}
	state.position = finish + #end_marker

	for row_index = 1, #raw_rows do
		local raw_row = raw_rows[row_index]
		local row = {}

		for cell_index = 1, #raw_row do
			row[cell_index] = parse_nested_source(state, raw_row[cell_index])

			for child_index = #row[cell_index].children, 1, -1 do
				local child = row[cell_index].children[child_index]

				if child.kind == "equation_tag" then
					explicit_tags[row_index] = child.text
					explicit_tag_stars[row_index] = child.starred
					table.remove(row[cell_index].children, child_index)
				elseif child.kind == "equation_label" then
					labels[row_index] = labels[row_index] or {}
					table.insert(labels[row_index], 1, child.text)
					table.remove(row[cell_index].children, child_index)
				elseif child.kind == "equation_no_number" then
					suppressed_tags[row_index] = true
					table.remove(row[cell_index].children, child_index)
				end
			end
		end
		rows[row_index] = row
	end

	local visible_tags = NUMBERED_ENVIRONMENTS[name] and state.equation_tags or nil

	if name == "multline" and visible_tags and #rows > 1 then
		visible_tags = { [#rows] = visible_tags[1] }
	end
	local tags = visible_tags or {}

	for row_index, tag in pairs(explicit_tags) do
		tags[row_index] = tag
	end
	if next(tags) == nil then
		tags = nil
	end

	local node = {
		kind = "rows",
		rows = rows,
		row_gaps = row_gaps,
		alignment = alignment,
		column_alignments = column_alignments,
		column_rules = column_rules,
		column_rule_styles = column_rule_styles,
		default_column_alignment = default_column_alignment,
		row_rules = row_rules,
		environment = name,
		compact_rows = name == "subarray",
		tags = tags,
		tag_stars = explicit_tag_stars,
		labels = labels,
		suppressed_tags = suppressed_tags,
	}
	local array_stretch = state.macros.arraystretch
	local stretch = array_stretch and array_stretch.arguments == 0
		and tonumber(array_stretch.replacement)

	if stretch and stretch > 0 then
		node.row_stretch = stretch
	end

	if name == "smallmatrix" then
		-- Compact rows sit on the surrounding math axis instead of below it
		node = {
			kind = "raise",
			amount = 0.12,
			unit = "em",
			body = { kind = "scaled", scale = 0.7, body = node },
		}
	end

	local delimiters = MATRIX_DELIMITERS[name]
	if delimiters then
		node = {
			kind = "delimited",
			left = delimiters[1],
			right = delimiters[2],
			body = node,
			matrix_environment = true, -- Preserve space outside the delimiter pair
		}
	end

	return node
end

--- Parse one safe TeX-like math expression
-- @param source string expression source without Markdown delimiters
-- @param references table|nil equation label lookup
-- @param equation_tags table|nil visible tags for one numbered environment
-- @return table expression parsed tree
function parser.parse(source, references, equation_tags)
	local expression = parse_sequence({
		source = tostring(source or ""),
		position = 1,
		references = references,
		equation_tags = equation_tags,
		macros = {},
		environments = {},
		expansion_depth = 0,
	})
	local numbered_rows
	local child_index = 1

	-- Tags and labels may follow the closing environment on the same source line
	while child_index <= #expression.children do
		local child = expression.children[child_index]

		if child.kind == "rows" and NUMBERED_ENVIRONMENTS[child.environment] then
			numbered_rows = child
			child_index = child_index + 1
		elseif numbered_rows and child.kind == "equation_tag" then
			local row_index = #numbered_rows.rows
			numbered_rows.tags = numbered_rows.tags or {}
			numbered_rows.tags[row_index] = child.text
			table.remove(expression.children, child_index)
		elseif numbered_rows and child.kind == "equation_label" then
			local row_index = #numbered_rows.rows
			numbered_rows.labels[row_index] = numbered_rows.labels[row_index] or {}
			table.insert(numbered_rows.labels[row_index], child.text)
			table.remove(expression.children, child_index)
		else
			numbered_rows = nil
			child_index = child_index + 1
		end
	end

	local standalone_tag

	for index = #expression.children, 1, -1 do
		if expression.children[index].kind == "equation_tag" then
			standalone_tag = expression.children[index]
			table.remove(expression.children, index)
		end
	end
	if standalone_tag then
		-- A display tag belongs at the panel edge rather than inside its formula
		expression = {
			kind = "sequence",
			children = {
				{
					kind = "rows",
					rows = { { expression } },
					alignment = "centered",
					environment = "equation*",
					tag = standalone_tag.text,
					tag_star = standalone_tag.starred,
				},
			},
		}
	end

	return expression
end

return parser
