Fortran XML for poor people
FoXy is a pure Fortran library to read, query, edit, and write XML, with no C underneath: use foxy and your code talks XML (hopefully). It started as a small parser for another poor people project (VTKFortran); the last four releases (0.5 to 0.8) rebuilt it (with the help of an AI assistant) into what scientific code needs from XML (at least in my mind, even though I prefer other kinds of I/O formats generally): case files read as numbers, results metadata written as you compute, and files of any size, including over 2 GiB.
Five types, one for each way of working with a document:
| Type | For |
|---|---|
xml_file |
a whole document in memory (a DOM): parse, query, edit, save |
xml_index |
a file too large to hold: tags and attributes, contents read on demand |
xml_handler |
a document of any size read once: your callbacks, nothing stored |
xml_writer |
a document of any size, written element by element to a file, a unit or memory |
xml_tag |
a single element: build it, parse it, write it |
The common case: a solver reads its settings from XML. No string juggling, no read(..., *):
use foxy
type(xml_file) :: doc
real(R8P) :: dt
integer(I4P) :: steps
call doc%parse(filename='case.xml')
call doc%get_content(id=doc%find(path='case/time/dt'), value=dt)
call doc%get_content(id=doc%find(path='case/time/steps'), value=steps)
values= into an allocatable array), with iostat/ iomsg. A value that is not a number gives XML_BAD_VALUE and leaves your variable unchanged; a missing tag or attribute gives XML_NOT_FOUND. 1e3 is not accepted as an integer.parse returns XML_MALFORMED with the first problem and its line: tag structure, names, duplicate attributes, a second root, text outside the root, malformed references, ill-formed UTF-8.
ids = doc%select('case/boundary[@type="wall"]') ! attribute value
ids = doc%select('//*[@unit]') ! any name, at any depth
ids = doc%select('/case/boundary[2]') ! the second boundary child
ids = doc%select('//boundary[@side~="^(east|west)$"]/velocity') ! regular expression
/, //, *, [@a], [@a="v"], [@a~="re"], [n] among siblings. A malformed expression returns no IDs and the column of the problem in matching(re, ...), using the StringiFor engine (Python re syntax, linear time, no backtracking).find/ find_all: by prefix, by {uri}local, or with your own prefixes mapped to URIs.
type(xml_writer) :: w
call w%open()
call w%start('cell')
call w%attribute('dt', 0.1_R8P) ! the shortest text that reads back: 0.1
call w%attribute('extent', [0_I4P, 63_I4P, 0_I4P, 31_I4P])
call w%start('volume')
call w%text(1._R8P/3._R8P)
call w%end('volume')
call w%start('center')
call w%text([0.5_R8P, 0.25_R8P, 0._R8P], fmt='(F5.3)')
call w%end('center')
call w%close()
<cell dt="0.1" extent="0 63 0 31">
<volume>0.3333333333333333</volume>
<center>0.500 0.250 0.000</center>
</cell>
0.1, not 1.000000000000000E-001), or your own fmt=. A value too wide for the field is an error, not a row of asterisks.& < > " ', checks names, duplicate attributes, one root element, and PI targets. Errors are sticky and reported once, by close or set_content/ set_attribute with value=/ add_tag(parent=|before=|after=), set_name, delete_tag, then save.
type, extends(xml_handler) :: pressure_sum
...
contains
procedure :: start_element => on_start
procedure :: characters => on_text
procedure :: end_element => on_end
endtype pressure_sum
call summer%parse(filename='results.xml', iostat=iostat)
Extend xml_handler, override the callbacks you need (start and end tags, text, CDATA, comments, PIs, DOCTYPE, problems with their line), and the file is read in chunks with nothing stored; stop_parsing ends it early. Your callbacks are plain type-bound procedures: I/O and side effects are allowed.
For random access to files too large for a DOM, there is xml_index: it scans the tags and attributes only, and reads content from the file when you ask for it (stop_at= stops the scan early).
UTF-8, UTF-16, UTF-32 (byte order mark or not), ISO-8859-1, Windows-1252, and US-ASCII sources are detected (XML 1.0 Appendix F, then the declaration) and converted to UTF-8 piece by piece. save writes UTF-8 and declares it. An encoding FoXy does not read is reported, not guessed.
What I use it for, and what it is designed for:
xml_writer as the run goes, numbers formatted for you. VTKFortran writes its XML formats through FoXy.xml_index or xml_handler on files of hundreds of MB or over 2 GiB, without them.
Performance: parsing is linear in the document size. A generated QuakeML-like catalogue of 100,000 tags (3 MB) is parsed into the DOM in about 0.11 s and written back by stringify in 0.03 s (gfortran 16, -O2, a desktop CPU; fobis build --mode benchmark-gnu reproduces it).
Known limits: entities declared in a DTD are not expanded, and there is no validation against a DTD or schema.
43 test programs, run with gfortran, nvfortran and ifx (debug builds with runtime checks included). Chunked parsing is checked against whole-string parsing down to 1-character chunks, the streaming events against the DOM on every test file, and the index against the DOM tag by tag. Every sample in the documentation is a program compiled and run to produce the output shown, and CI fails if the output changes.
The documentation has a tutorial that builds the input and output of a small solver in nine chapters, a cookbook, and an Upgrading page listing the behaviour changes release by release.
FoXy builds with FoBiS, fpm, or CMake, is tested with GNU gfortran, NVIDIA nvfortran, and Intel ifx, and is multi-licensed (GPL v3, BSD 2-Clause, BSD 3-Clause, MIT, select the best fit for you).
Feedback, issues, and contributions are welcome.
Stefano