Real Tasks
This chapter solves six everyday problems with a grammar each: a log file, semicolon-separated values, dates written in several ways, a small programming language, Markdown-like text, and the grammar inside a specification. Every solution has two parts, a grammar that turns the text into XML and a query that uses the XML.
Log files
log.ixml:
log: line++-#a, -#a?.
line: date, -" ", time, -" ", level, -" ", message.
@date: digit, digit, digit, digit, "-", digit, digit, "-", digit, digit.
@time: digit, digit, ":", digit, digit, ":", digit, digit.
@level: "INFO"; "WARN"; "ERROR".
message: ~[#a]*.
-digit: ["0"-"9"].
A log is lines separated by line feeds, with an optional line feed at
the end (-#a?). The date, time and level become attributes of their
line, and message takes everything up to the end of the line,
~[#a]*. The grammar describes this log:
app.log:
2026-10-20 09:15:02 INFO server started
2026-10-20 09:17:45 WARN disk 91% full
2026-10-20 09:21:13 ERROR disk full
2026-10-20 09:21:14 INFO cleanup started
2026-10-20 09:24:50 INFO cleanup done
2026-10-20 10:02:31 ERROR connection refused
Once the log is a document, grouping and filtering are ordinary XQuery:
levels.xq:
(: Counts the lines of each level in app.log and lists the errors. :)
let $parse := invisible-xml(unparsed-text("log.ixml"))
let $log := $parse(unparsed-text("app.log"))
return string-join((
for $line in $log//line
group by $level := $line/@level
order by count($line) descending, $level
return `{$level}: {count($line)}`,
$log//line[@level = "ERROR"] ! `{@time} {message}`
), " ")
$ sxml xquery --method=text levels.xq
INFO: 3
ERROR: 2
WARN: 1
09:21:13 disk full
10:02:31 connection refused
Real logs are rarely this tidy. Two habits keep the grammar useful: make the part you care about precise (the date, the level) and let the rest be "anything up to the end of the line", and test the grammar on the oddest lines first.
Semicolon-separated values
For plain CSV, parse-csv (JSON, CSV, HTML and Invisible
XML) is quicker. A grammar earns its place when the
format is a variant that a CSV parser does not know. This one separates
fields with semicolons and lets a quoted field hold semicolons and
doubled quotes:
prices.ixml:
{Semicolon-separated values: a header line, then rows. A field in
double quotes may hold semicolons; "" in it is one quote.}
table: header, -#a, (row, -#a)+.
header: field++-";".
row: field++-";".
field: quoted; plain.
-plain: ~[";"; '"'; #a]*.
-quoted: -'"', (~['"']; -'"', '"')*, -'"'.
prices.txt:
item;price;note
Coffee;3.50;"hot; large"
Tea;2.10;
"Juice ""fresh""";4.20;cold
$ sxml xquery --param=grammar=prices.ixml --param=input=prices.txt ixml.xq
<table>
<header>
<field>item</field>
<field>price</field>
<field>note</field>
</header>
<row>
<field>Coffee</field>
<field>3.50</field>
<field>hot; large</field>
</row>
<row>
<field>Tea</field>
<field>2.10</field>
<field/>
</row>
<row>
<field>Juice "fresh"</field>
<field>4.20</field>
<field>cold</field>
</row>
</table>
Note the first rule: every row ends with a line feed, (row, -#a)+. A
first try, row++-#a, -#a?, reads the last line feed in two ways, as the
end of the file or as a separator before a row with one empty field, and
the result is ambiguous. Saying exactly where each line feed belongs
removes the doubt.
From the table to JSON is one query, with the header naming the fields:
prices-json.xq:
(: Turns prices.txt into JSON: one object per row, keyed by the header. :)
declare option output:method "json";
declare option output:indent "yes";
let $table := invisible-xml(unparsed-text("prices.ixml"))(unparsed-text("prices.txt"))
let $names := $table//header/field ! string()
return array {
for $row in $table//row
return map:merge(
for $field at $position in $row/field
return map:entry($names[$position],
if ($field castable as xs:decimal) then xs:decimal($field) else string($field)))
}
$ sxml xquery prices-json.xq
[
{
"item": "Coffee",
"price": 3.5,
"note": "hot; large"
},
{
"item": "Tea",
"price": 2.1,
"note": ""
},
{
"item": "Juice \"fresh\"",
"price": 4.2,
"note": "cold"
}
]
Dates
People write dates in many ways. One grammar can read several forms and give them all the same XML, so the query after it sees one form only:
dates.ixml:
ixml version "1.1".
{Dates in three forms, one per line, all made into year, month and day.}
dates: (date, -#a)+.
date: iso; written; american.
-iso: year, -"-", month, -"-", day.
-written: day, -" ", month-name, -" ", year.
-american: month, -"/", day, -"/", year.
year: digit, digit, digit, digit.
month: digit, digit; +"0", digit.
day: digit, digit; +"0", digit.
month-name>month:
-"January", +"01"; -"February", +"02"; -"March", +"03";
-"April", +"04"; -"May", +"05"; -"June", +"06";
-"July", +"07"; -"August", +"08"; -"September", +"09";
-"October", +"10"; -"November", +"11"; -"December", +"12".
-digit: ["0"-"9"].
dates.txt:
2026-10-20
20 October 2026
7/4/1976
1 May 1886
Three features do the work. The three forms are hidden alternatives of
date, so each gives year, month and day directly. The month names
are hidden strings with an insertion of the month's number, and >month
renames month-name to month, so a written month and a numbered one
are the same element. And +"0", digit pads a one-digit day or month.
The result is in the first chapter;
here a query sorts the dates:
sort-dates.xq:
(: Reads dates.txt as xs:date values and lists them in order. :)
let $dates := invisible-xml(unparsed-text("dates.ixml"))(unparsed-text("dates.txt"))
for $date in $dates//date
let $value := xs:date(`{$date/year}-{$date/month}-{$date/day}`)
order by $value
return format-date($value, "[D] [MNn] [Y], a [FNn]")
$ sxml xquery --method=adaptive sort-dates.xq
"1 May 1886, a Saturday"
"4 July 1976, a Sunday"
"20 October 2026, a Tuesday"
"20 October 2026, a Tuesday"
The first and second lines of dates.txt were the same day in two forms.
A small programming language
A grammar can describe a language with statements, names and
expressions. In this one, let names a value, print prints one, and
* and / bind tighter than + and -:
calc.ixml:
ixml version "1.1".
{A small language: statements end with ";", "let" names a value,
"print" prints one. Products bind tighter than sums.}
program: s, (statement, s, -";", s)*.
-statement: let; print.
let: -"let", S, @name, s, -"=", s, expression.
print: -"print", S, expression.
-expression: sum; -term.
sum: expression, s, @add>op, s, term.
-term: product; -factor.
product: term, s, @multiply>op, s, factor.
-factor: number; variable; -"(", s, expression, s, -")".
number: ["0"-"9"]+.
variable: @name.
@name: ["a"-"z"]+.
add: ["+-"].
multiply: ["*/"].
-s: -[" "; #a]*.
-S: -[" "; #a]+.
Precedence comes from the shape of the rules: a sum adds terms, a
term is a product or a factor, and a product multiplies
factors, so 1 + 2 * 3 can only be read with the product inside the
sum. Because sum begins with expression and product with term,
both are left-recursive, and 8 - 2 - 1 groups as (8 - 2) - 1.
@add>op and @multiply>op make both kinds of operator an attribute
op. s is optional space and S required space.
area.calc:
let width = 6;
let height = 7;
print width * height;
print (width + height) * 2;
$ sxml xquery --param=grammar=calc.ixml --param=input=area.calc ixml.xq
<program>
<let name="width">
<number>6</number>
</let>
<let name="height">
<number>7</number>
</let>
<print>
<product op="*">
<variable name="width"/>
<variable name="height"/>
</product>
</print>
<print>
<product op="*">
<sum op="+">
<variable name="width"/>
<variable name="height"/>
</sum>
<number>2</number>
</product>
</print>
</program>
The XML is the program's syntax tree, and an interpreter is a recursive function over it:
run.xq:
(: Runs a program of the small language of calc.ixml, in the file $file:
each print statement gives one integer. :)
declare variable $file external;
declare function local:value($node as element(), $names as map(*)) as xs:integer {
typeswitch ($node)
case element(number) return xs:integer($node)
case element(variable) return $names(string($node/@name))
default return
let $left := local:value($node/*[1], $names)
let $right := local:value($node/*[2], $names)
return switch (string($node/@op))
case "+" return $left + $right
case "-" return $left - $right
case "*" return $left * $right
default return $left idiv $right
};
declare function local:run($statements as element()*, $names as map(*)) as xs:integer* {
if (empty($statements)) then () else
let $statement := head($statements)
return
if ($statement instance of element(let)) then
local:run(tail($statements),
map:put($names, string($statement/@name), local:value($statement/*, $names)))
else (
local:value($statement/*, $names),
local:run(tail($statements), $names)
)
};
let $program := invisible-xml(unparsed-text("calc.ixml"))(unparsed-text($file))
return local:run($program/program/*, {})
$ sxml xquery --method=adaptive --param=file=area.calc run.xq
42
26
The same tree could be checked for names used before they are set, pretty-printed, or translated into another language with XSLT.
Markdown-like text
The last grammar reads a little of Markdown: headings, lists and paragraphs separated by blank lines, with emphasis and code inside. Its names are HTML's, so the XML it makes is HTML:
markdown.ixml:
ixml version "1.1".
{A little Markdown: headings, lists and paragraphs, separated by blank
lines, with *emphasis* and `code` inside. The names are HTML's.}
document>body: block++(-#a, -#a+), -#a*.
-block: h1; h2; ul; p.
h1: -"# ", inline.
h2: -"## ", inline.
ul: li++-#a.
li: -"- ", inline.
p: line++(-#a, +" ").
-line: ~["#-*`"; #a], inline?.
-inline: (char; em; code)+.
-char: ~["*`"; #a].
em: -"*", ~["*"; #a]+, -"*".
code: -"`", ~["`"; #a]+, -"`".
notes.md:
# Release notes
Version 2 reads *Invisible XML* grammars
and parses `CSV` files.
## Changes
- faster parsing
- better *error* messages
$ sxml xquery --param=grammar=markdown.ixml --param=input=notes.md ixml.xq
<body>
<h1>Release notes</h1>
<p>Version 2 reads <em>Invisible XML</em> grammars and parses <code>CSV</code> files.</p>
<h2>Changes</h2>
<ul>
<li>faster parsing</li>
<li>better <em>error</em> messages</li>
</ul>
</body>
Two details make this grammar unambiguous. The characters of a line are
single hidden characters, -char, so a run of them is one text node and
there is only one way to split it. And a paragraph line may not start
with # or -, so a heading or a list item can never also be read as a
paragraph. The line feed inside a paragraph becomes a space with
-#a, +" ".
The grammar in a specification
Specifications define their syntax in ABNF (RFC 5234), and the grammar
sits in the document as text. The Vane draft
(Transforming with XSLT and the chapters before it use it) has
the syntax of its messages in a sourcecode element; vane.xml here is
a link to the draft:
$ sxml xpath --context-item=vane.xml --method=text '//sourcecode[@type = "abnf"]'
message = rollout-update / kill-update / status-request
rollout-update = "ROLLOUT" SP flag-key SP percentage CRLF
kill-update = "KILL" SP flag-key CRLF
status-request = "STATUS" SP flag-key CRLF
flag-key = 1*32(ALPHA / DIGIT / "-")
percentage = 1*3DIGIT
To a program, those lines are one string. A small grammar for ABNF
rules turns them into XML: rule names, alternatives separated by /,
sequences of elements, repetitions such as 1*32 and 1*3, quoted
strings and groups in parentheses:
abnf.ixml:
ixml version "1.1".
{The rules of ABNF (RFC 5234): one rule on each line, a name, "=" and
alternatives separated by "/", each a sequence of elements, each
element with an optional repetition.}
rules: -#a*, rule++(-#a+), -#a*.
rule: @name, s, -"=", s, alternatives.
-alternatives: alternative++(s, -"/", s).
alternative: element++S.
-element: repeat; -item.
repeat: (@min, -"*", @max?; -"*", @max; @min), -item.
-item: ref; string; group.
ref: @name.
string: -'"', ~['"']*, -'"'.
group: -"(", s, alternatives, s, -")".
@name: ["A"-"Z"; "a"-"z"], ["A"-"Z"; "a"-"z"; "0"-"9"; "-"]*.
@min: ["0"-"9"]+.
@max: ["0"-"9"]+.
-s: -" "*.
-S: -" "+.
rule++(-#a+) is one or more rules separated by line feeds, and
element++S one or more elements separated by spaces. A repetition
has a minimum, a maximum or both around the *, or just a number,
which ABNF reads as an exact count. XPath extracts the text, and the
grammar parses it:
$ sxml xquery --context-item=vane.xml --expression='declare option output:indent "yes"; invisible-xml(unparsed-text("abnf.ixml"))(string(//sourcecode[@type = "abnf"]))/rules/rule[@name = ("flag-key", "percentage")]'
<rule name="flag-key">
<alternative>
<repeat min="1" max="32">
<group>
<alternative>
<ref name="ALPHA"/>
</alternative>
<alternative>
<ref name="DIGIT"/>
</alternative>
<alternative>
<string>-</string>
</alternative>
</group>
</repeat>
</alternative>
</rule>
<rule name="percentage">
<alternative>
<repeat min="1" max="3">
<ref name="DIGIT"/>
</repeat>
</alternative>
</rule>
Once the grammar is XML, questions about it are paths. Which rules does each rule use, and which names does the draft use without defining them?
$ sxml xquery --context-item=vane.xml --method=adaptive --expression='
let $rules := invisible-xml(unparsed-text("abnf.ixml"))(string(//sourcecode[@type = "abnf"]))//rule
return (
for $rule in $rules
return `{$rule/@name} uses {string-join(distinct-values($rule//ref/@name), ", ")}`,
`undefined: {string-join(distinct-values($rules//ref/@name[not(. = $rules/@name)]), ", ")}`
)'
"message uses rollout-update, kill-update, status-request"
"rollout-update uses SP, flag-key, percentage, CRLF"
"kill-update uses SP, flag-key, CRLF"
"status-request uses SP, flag-key, CRLF"
"flag-key uses ALPHA, DIGIT"
"percentage uses DIGIT"
"undefined: SP, CRLF, ALPHA, DIGIT"
The four undefined names are the core rules of RFC 5234 (Appendix B.1),
which every ABNF grammar may use, so the draft is complete. A misspelled
rule name, flag_key for flag-key, would show up in the same list, and
a broken rule fails to parse with the line and column of the mistake:
$ sxml xpath 'invisible-xml(unparsed-text("abnf.ixml"))("limit = percentage / ")'
<failed xmlns:ixml="http://invisiblexml.org/NS" ixml:state="failed"><line>1</line><column>22</column><position>22</position><end-of-input/><expected>"("</expected><expected>"*"</expected><expected>#20</expected><expected>'"'</expected><expected>["0"-"9"]</expected><expected>["A"-"Z"; "a"-"z"]</expected></failed>