Numbered-indent markup
A small numbered-indent markup language and an Odin parser for it.
Trees without significant whitespace: depth is a number at the start of the line. Requires Odin.
0 person
1 name: Ada
1 address
2 city: London
2 note: see section 3: extras
Format
See examples/ok.bnml for a valid file and examples/bad.bnml for an indent jump that fails validation.
- After
strip, every remaining line starts with a digit prefix (0,1,2, …). That number is the depth. A line with no leading digits isMalformed_Line. - After the number:
keyorkey: value. - Depth may stay the same, go down by any amount, or go up by exactly 1. A jump like
0then2isInvalid_Indent_Sequence. The first remaining line must be depth0; repeating0is allowed, so a file can have multiple roots. An empty document (or comments only) is valid. stripdrops blank lines and lines whose first non-space character is;. Inline;is not a comment.- The first unescaped
:splits key from value. Both sides are trimmed. In the key, a backslash escapes the next character (\:is a colon,\\is a backslash,\nisn). A trailing unmatched\is dropped. The value is not unescaped —:,\, and everything else can be written as-is.
examples/ok.bnml:
; A valid BNML tree.
; Blank lines and comments are ignored.
0 person
1 name: Ada
1 address
2 city: London
2 note: see section 3: extras
examples/bad.bnml:
; Invalid: depth jumps from 0 to 2.
0 person
2 city: London
Build
The package is bnml at the repository root.
odin build cmd/bnml -out:bnml
C bindings. Build on the machine you will run on (Odin does not cross-link Windows or macOS from Linux). From the repository root:
sh c/build.sh # Linux, macOS, Git Bash
c\build.bat # Windows cmd
That produces the shared lib, a static lib, bnml_c / bnml_c.exe, and the C tests (bnml_c_test; Unix also builds bnml_c_test_static). The scripts run the tests. Run them from the repository root so examples/ok.bnml resolves.
Manual commands:
# Linux
odin build c -build-mode:shared -no-entry-point -out:libbnml.so \
-extra-linker-flags:"-Wl,--version-script=c/libbnml.version,-soname,libbnml.so"
cc -I c examples/c/main.c ./libbnml.so -Wl,-rpath,'$ORIGIN' -o bnml_c
odin build c -build-mode:static -no-entry-point -out:libbnml.a
cc -I c examples/c/main.c libbnml.a -lpthread -ldl -lm -o bnml_c
# macOS (lld: Apple ld mishandles Odin's quoted -init)
odin build c -build-mode:shared -no-entry-point -linker:lld -out:libbnml.dylib \
-extra-linker-flags:"-Wl,-exported_symbols_list,c/libbnml.exports,-install_name,@rpath/libbnml.dylib"
cc -I c examples/c/main.c ./libbnml.dylib -Wl,-rpath,@loader_path -o bnml_c
odin build c -build-mode:static -no-entry-point -out:libbnml.a
cc -I c examples/c/main.c libbnml.a -o bnml_c
# Windows (native). Define BNML_DLL when using the DLL.
odin build c -build-mode:shared -out:bnml.dll
cl /I c /DBNML_DLL examples\c\main.c /Fe:bnml_c.exe bnml.lib
# or: gcc -I c -DBNML_DLL examples/c/main.c bnml.dll -o bnml_c.exe
Prefer the shared library on Windows. A static .lib/.a still needs the Odin runtime’s system libraries (kernel32, ws2_32, winmm, …); the set depends on the toolchain, so linking a Windows static lib is not covered here.
Shared builds hide non-API symbols: GNU ld uses c/libbnml.version, Darwin uses c/libbnml.exports — keep those two in sync. -no-entry-point is for static libs and Unix shared objects so they do not define main. Windows DLLs keep an empty main because DllMain calls it after starting the Odin runtime.
macOS shared builds use -linker:lld. Odin 2026-09 passes -Wl,-init,'__odin_entry_point'; Apple’s ld treats those quotes as part of the symbol name. lld ignores -init, which is fine — the C API starts the Odin runtime on the first call. Expect ld64.lld: warning: Option '-init' is not yet implemented.
Tests
From the repository root:
odin test tests
C tests run as part of c/build.sh / c/build.bat. Run those from the repository root so examples/ok.bnml resolves.
CLI
Three commands. Each takes a file path.
bnml validate <path>
bnml parse <path>
bnml find <path> <key>
./bnml validate examples/ok.bnml
./bnml parse examples/ok.bnml
./bnml find examples/ok.bnml name
./bnml validate examples/bad.bnml
validate
Exit 0 if the file is valid (prints a success line).
parse
Print the tree.
find
Print each matching node and its descendants (exact key). Exit 1 if none.
Library
Node has key, value, and children. Errors are File_Read_Failed, Invalid_Indent_Sequence, Malformed_Line, and Out_Of_Memory.
read_lines and strip are separate from parse. Without strip, blank and comment lines are Malformed_Line.
package main
import bnml "path/to/bnml"
import "core:fmt"
main :: proc() {
lines, data, err := bnml.read_lines("file.bnml")
if err != .None {
fmt.println(err)
return
}
defer delete(data)
defer delete(lines)
bnml.strip(&lines)
roots, parse_err := bnml.parse(lines[:])
if parse_err != .None {
fmt.println(parse_err)
return
}
defer bnml.destroy_tree(&roots)
bnml.print_tree(roots[:])
hits, find_err := bnml.find_nodes(roots[:], "name")
if find_err != .None {
fmt.println(find_err)
return
}
defer delete(hits)
}
read_lines
Load a file into lines.
strip
Trim lines; drop blanks and whole-line ; comments.
validate
Check prefixes / indent sequence.
parse
Validate + build the tree.
parse_unchecked
Build the tree without validate. Lines with no digit prefix are treated as depth 0; indent jumps still fail.
find_nodes
Collect every node with that exact key.
print_tree / print_node
Dump a node and its descendants to stdout.
destroy_node
Free one node and its children.
destroy_tree
Free the tree.
C API
c/bnml.h is the public header. BnmlTree and BnmlNode are opaque. BnmlError is int32_t. The C API is not thread-safe.
Parse requires *out_tree == NULL; a live pointer returns BNML_OUTPUT_NOT_NULL and is left unchanged. Trees from parse own their nodes; free them with bnml_tree_destroy (NULL is a no-op). Accessor strings and find pointers are valid until that destroy.
For a non-NULL node, key and value are never NULL; a missing value is "". They are C strings, so an interior NUL in the document is not representable. bnml_find returns 0 if the tree or key is NULL.
A CLI-shaped example is examples/c/main.c.
#include "bnml.h"
#include <stdio.h>
int main(void) {
BnmlTree *tree = NULL;
BnmlError err = bnml_parse_file("file.bnml", &tree);
if (err != BNML_OK) {
fprintf(stderr, "%s\n", bnml_error_string(err));
return 1;
}
bnml_print_tree(tree);
BnmlNode *hits[8];
size_t n = bnml_find(tree, "name", hits, 8);
for (size_t i = 0; i < n && i < 8; i++) {
printf("%s: %s\n", bnml_node_key(hits[i]), bnml_node_value(hits[i]));
}
bnml_tree_destroy(tree);
return 0;
}
Parse and validate always strip blank lines and whole-line ; comments, same as the CLI.
bnml_parse_file / bnml_parse_string / bnml_parse_buffer
Strip + parse into a tree.
bnml_validate_file / bnml_validate_string / bnml_validate_buffer
Strip, then check prefixes / indent sequence.
bnml_tree_root_count / bnml_tree_root
Access root nodes.
bnml_node_key / bnml_node_value / bnml_node_child_count / bnml_node_child
Access a node.
bnml_find
Count matches (may exceed cap); write up to cap pointers into the tree. out == NULL returns only the count.
bnml_print_tree / bnml_print_node
Dump a node and its descendants to stdout.
bnml_tree_destroy
Free the tree (NULL is a no-op).
bnml_error_string
Message for an error code.
bnml_parse_buffer / bnml_validate_buffer take a pointer and length; they do not need a terminating NUL. text may be NULL only when len is 0. If len does not fit in a signed pointer-sized int, the result is BNML_ARGUMENT_TOO_LARGE.
Errors: BNML_FILE_READ_FAILED, BNML_INVALID_INDENT_SEQUENCE, BNML_MALFORMED_LINE, BNML_OUT_OF_MEMORY, BNML_NULL_ARGUMENT, BNML_OUTPUT_NOT_NULL, BNML_ARGUMENT_TOO_LARGE.
Status
v0 — the format may change.