The Java library
dev.stxt:stxt-core is the
STXT parser for Java, published on Maven Central. It implements the five
specifications —syntax, canonical tree, schemas, templates and resolution— and
reports the same error codes as the TypeScript library, the
Python one and the command line. It is a library with
no CLI; the command of the ecosystem is stxt.
The guide has the same structure as the TypeScript library one; where the Java API differs, it is noted.
Installation
Requires Java 17 or later. It has no runtime dependencies and under JPMS it
is an automatic module named dev.stxt.
<dependency>
<groupId>dev.stxt</groupId>
<artifactId>stxt-core</artifactId>
<version>1.0.0</version>
</dependency>
Or with Gradle:
implementation 'dev.stxt:stxt-core:1.0.0'
The javadoc of every version is on
javadoc.io, and the artifact's
README is its front page on Central: its examples are compiled and run against
the real parser.
Parsing
Parser has two entry points. parseResult(text) collects every error and
also returns the nodes it managed to build; parse(text) throws a
ParseException on the first one and returns List<Node> when there is none.
Both have a file-based counterpart: parseResultFile(File) and parseFile(File).
The STXT facade gives ready-made parsers: STXT.rawParser() is a parser with
no validation (syntax only) and STXT.parser(loader) one with schema and
template validation registered, as shown below.
import java.util.List;
import dev.stxt.Node;
import dev.stxt.ParseResult;
import dev.stxt.Parser;
import dev.stxt.exceptions.ParseException;
import dev.stxt.runtime.STXT;
Parser parser = STXT.rawParser();
ParseResult result = parser.parseResult(text);
if (result.hasErrors()) {
for (ParseException error : result.getErrors()) {
System.err.printf("line %d [%s]: %s%n", error.getLine(), error.getCode(), error.getMessage());
}
}
List<Node> roots = result.getNodes(); // the root nodes, in order; there may be several
// The "throw on first error" form
try {
List<Node> nodes = parser.parse(text);
} catch (ParseException e) {
System.err.println(e.getLine() + " " + e.getCode() + " " + e.getMessage());
}
Every error is a ParseException with getLine() (the line of the document,
starting at 1), getCode() (stable, upper-case: INVALID_LINE,
INDENTATION_MIXED, INDENTATION_LEVEL_NOT_VALID…) and getMessage().
Grammar errors are ValidationException, a subclass with the same fields, so a
single loop walks both and instanceof tells the severity apart. Every exception
is unchecked and extends STXTException (see Errors). A document with a syntax
error may still return nodes: the parser recovers and carries on, so that an
editor can underline everything that is wrong at once.
The tree
Node is a sealed class with exactly two forms, and each one exposes only what
is its own:
| Class | Syntax | What it owns |
|---|---|---|
InlineNode |
Name: value |
getValue()/setValue(), getChildren(), getChild(name), getChildren(name), addChild(), removeChild(), addInlineNode(), addTextNode() |
TextNode |
Name >> |
getTextLines(), setText(), setTextLines(), addTextLine(), clearText() |
What they share lives in Node: getName() and getCanonicalName() (the canonical
name of STXT-SPEC: lower-case, NFC, unified separators), getDeclaredNamespace()
(what the node writes between parentheses, or "") and getNamespace() (the
effective one, inherited through the chain of parents), getLine(),
getLevel() (derived from the depth), getParent() (always an InlineNode, or
null at the root), detach() and getText() —the value of an inline node or the
joined lines of a block—. Walking a tree means asking for the form with
instanceof, using Java 17 pattern matching, the same way the canonical tree of
STXT-TREE-SPEC has children only for inline nodes and lines only for blocks.
Child lookups go by canonical name (getChild("Título") and
getChild("titulo") find the same node), getChild returns null when there is
none and throws AMBIGUOUS_CHILD when there is more than one —repeated children
are what getChildren(name) is for—; both accept a second argument with the
namespace.
With the document of the tutorial, a book record:
Book (com.acme.book):
Title: Arquitectura de software moderna
Authors:
Author: María Pérez
Author: Juan García
ISBN: 978-84-123456-7-8
Published: 2025-10-01
Chapter: Introducción
Content >>
Conceptos básicos y objetivos del libro.import dev.stxt.InlineNode;
import dev.stxt.Node;
import dev.stxt.TextNode;
import dev.stxt.runtime.STXT;
Node book = STXT.rawParser().parseResult(text).getNodes().get(0);
book.getName(); // "Book"
book.getCanonicalName(); // "book"
book.getNamespace(); // "com.acme.book"
book.getLine(); // 1
if (book instanceof InlineNode inline) {
inline.getChild("Title").getText(); // "Arquitectura de software moderna"
inline.getChild("title").getName(); // "Title": lookups go by canonical name
inline.getChild("Publisher"); // null: not there
InlineNode authors = (InlineNode) inline.getChild("Authors");
authors.getChildren("Author").stream().map(Node::getText).toList(); // [María Pérez, Juan García]
authors.getDeclaredNamespace(); // "": it declares none…
authors.getNamespace(); // "com.acme.book": …it inherits it
InlineNode chapter = (InlineNode) inline.getChild("Chapter");
if (chapter.getChild("Content") instanceof TextNode content) {
content.getTextLines(); // [Conceptos básicos y objetivos del libro.]
content.getLevel(); // 2
content.getParent() == chapter; // true
}
}
// A generic walk
static void walk(Node node, int depth) {
System.out.println(" ".repeat(depth) + node.getName());
if (node instanceof InlineNode inline)
for (Node child : inline.getChildren()) walk(child, depth + 1);
}
Building and editing
Trees are mutable and keep their own integrity: every node knows its parent,
addChild links both ends and refuses a node that already has one
(NODE_ALREADY_ATTACHED) or that is an ancestor (NODE_CYCLE), and setValue and
addTextLine refuse a line break (LINE_BREAK_NOT_ALLOWED); removeChild and
detach() undo it. Levels are derived from the chain of parents, and the line is
only set by the parser. In the factories with two Strings, the second one is
always the content (value or text); the namespace only appears in the
three-argument form. To insert at a position, addChild(index, node).
import dev.stxt.InlineNode;
import dev.stxt.TextNode;
InlineNode email = new InlineNode("Email", "com.example.mail", "Weekly report");
email.addInlineNode("From", "Ana García <[email protected]>");
InlineNode to = email.addInlineNode("To");
to.addInlineNode("Address", "[email protected]");
TextNode body = email.addTextNode("Body", "Hi Bob,\n\nSee attached.");
body.getParent() == email; // true
body.getLevel(); // 1
to.getNamespace(); // "com.example.mail", inherited
// Reorder: "To" first
to.detach();
email.addChild(0, to);
// Edit
email.setNamespace("com.example.docs"); // the whole inheriting subtree follows
body.setText("Hi Bob,\n\nSee the new attachment.");
Validating against a schema or a template
Grammars are STXT documents in the reserved namespaces @stxt.schema and
@stxt.template; the template is the short authoring form and compiles to a
schema when loaded. In Java, a ResourcesLoader determines where they come from:
ResourcesLoaderDirectory looks them up on disk with the layout
<dir>/@stxt.schema/<namespace>.stxt and <dir>/@stxt.template/<namespace>.stxt
—the same one stxt install leaves, so a project's .stxt/ can be used
directly—, and
STXT.parser(loader) returns a parser that resolves both kinds, validates each
grammar against its meta-schema when loading it, caches it and validates every
node with a namespace as it is closed: underneath it registers a
SchemaValidator, which does not validate nodes without a namespace because that
is the rule of the language (STXT-SCHEMA-SPEC §5) —a document without a namespace
is not invalid, but it cannot be validated—.
Template (@stxt.template): com.acme.book
Structure >>
Book:
Title: (1)
Authors: (1)
Author: (+)
ISBN: (1)
Publisher: (?)
Published: (?) DATE
Summary: (?) TEXT
Chapter: (+)
Content: (?) TEXT
Description >>
Book: Template for publisher book recordsimport java.io.File;
import dev.stxt.ParseResult;
import dev.stxt.Parser;
import dev.stxt.exceptions.ParseException;
import dev.stxt.exceptions.ValidationException;
import dev.stxt.resources.ResourcesLoader;
import dev.stxt.resources.ResourcesLoaderDirectory;
import dev.stxt.runtime.STXT;
ResourcesLoader loader = new ResourcesLoaderDirectory(new File("/home/ana/libros/.stxt"));
Parser parser = STXT.parser(loader);
ParseResult result = parser.parseResult(documentText);
for (ParseException error : result.getErrors()) {
String kind = (error instanceof ValidationException) ? "schema" : "syntax";
System.out.printf("%s line %d [%s]: %s%n", kind, error.getLine(), error.getCode(), error.getMessage());
}
With a book that lacks its ISBN and whose date is not YYYY-MM-DD, the loop
prints the same codes as stxt validate:
schema line 5 [INVALID_VALUE]: Published: Invalid date (1 de octubre de 2025)
schema line 1 [TOO_FEW_CHILDREN]: 0 nodes of 'com.acme.book:isbn' and min is 1
ResourcesLoader is a single-method interface, retrieve(namespace, resource)
—where namespace is @stxt.schema or @stxt.template and resource the
namespace being looked up—, so a grammar in memory, or taken from the
classpath, a database or a URL, is a lambda that returns its text, or throws
ResourceNotFoundException when it does not have it:
import dev.stxt.exceptions.ResourceNotFoundException;
ResourcesLoader loader = (namespace, resource) -> {
if (namespace.equals("@stxt.template") && resource.equals("com.acme.book")) return templateText;
throw new ResourceNotFoundException(namespace, resource);
};
Parser parser = STXT.parser(loader);
Behaviour rules:
- A cardinality error is reported on the line of the parent, which is the one
with zero
ISBN. - If the document uses a namespace the loader does not know, each node produces a
SCHEMA_NOT_FOUND; the provider never throws for a missing namespace (getSchema()returnsnull). - A grammar that does not validate against its meta-schema is reported as a
finding of the document (for instance
TYPE_NOT_VALID), not as an exception. - Nodes without a namespace are not validated (a rule of
SchemaValidatoritself); a document that is itself a definition is always validated against its meta-schema. Registering aSchemaValidatorexplicitly gives the same behaviour as the facade, the CLI and the extension.
import dev.stxt.schema.SchemaValidator;
Parser parser = new Parser();
parser.registerValidator(new SchemaValidator(STXT.schemaProvider(loader)));
- The available value types are those of STXT-SCHEMA-SPEC:
INLINE,BLOCK,TEXT,MARKDOWN,BOOLEAN,INTEGER,NATURAL,NUMBER,DATE,TIME,TIMESTAMP,UUID,EMAIL,URL,HEXADECIMAL,BINARY,BASE64,GROUPandENUM. To add a custom type, implementdev.stxt.schema.Typeand register it inTypeRegistry.
The compiled schema can be inspected: STXT.schemaProvider(loader) is the
SchemaProvider the facade uses, and getSchema(ns) returns a Schema with
getNamespace(), getNodes() and getNodeDefinition(name); each
NodeDefinition has getType(), getChildren() (a map of ChildDefinition by
qualified name namespace:name, with getMin() / getMax()), getValues() for
an ENUM and getDescription().
Resolution: which grammar applies to a document
ResourcesLoaderDirectory looks in a single directory. Resolution answers the
full question: given a document, which definitions apply to it.
DiscoveryResolver implements STXT-DISCOVERY-SPEC —the
.stxt/ directories of the document and of all its ancestors, then ~/.stxt and
/etc/stxt, per-namespace precedence, STXT_PATH replacing the chain—, just like
the CLI and the extension, so all three agree by construction.
Unlike the TypeScript port, which has to run on several hosts, here disk access is
direct through java.nio.file; the only injectable piece is the
DiscoveryEnvironment (STXT_PATH, user and system directories), with
SystemDiscoveryEnvironment as the real implementation —the one the no-argument
constructor uses— and the option of injecting a custom one in a test. The chain is per
document: pass the directory it lives in (null for standard input or an unsaved
buffer, which starts the chain at the user level). Since DiscoveryResult
implements SchemaProvider, resolving and validating are two steps:
import java.nio.file.Path;
import dev.stxt.Parser;
import dev.stxt.discovery.DiscoveryDefinition;
import dev.stxt.discovery.DiscoveryError;
import dev.stxt.discovery.DiscoveryResolver;
import dev.stxt.discovery.DiscoveryResult;
import dev.stxt.schema.SchemaValidator;
DiscoveryResolver resolver = new DiscoveryResolver();
DiscoveryResult discovery = resolver.resolve(Path.of("/home/ana/libros/docs"));
discovery.getChain(); // [/home/ana/libros/.stxt] (every ancestor, nearest first)
// Resolution errors are collected, never thrown: report them and carry on
for (DiscoveryError error : discovery.getErrors()) {
System.err.printf("[%s] %s: %s%n", error.getCode(), error.getFile(), error.getMessage());
}
Parser parser = new Parser();
parser.registerValidator(new SchemaValidator(discovery));
ParseResult result = parser.parseResult(documentText);
DiscoveryResult also records where each grammar came from, which is what an
editor needs for "go to definition" or a diagnostic that names the origin of the
applied definition:
DiscoveryDefinition definition = discovery.getDefinition("com.acme.book");
definition.getFile(); // /home/ana/libros/.stxt/@stxt.template/com.acme.book.stxt
definition.getLevelDir(); // /home/ana/libros/.stxt (the level that won)
definition.getSchema(); // the compiled Schema
discovery.getActiveDefinitions(); // one per namespace, precedence applied
discovery.getAllSchemas(); // just the schemas of the above
Levels are cached by directory: resolving many documents that share ancestors reads
each .stxt/ once. Call resolver.clearCache() when the definition files may have
changed. The resolution error codes are DISCOVERY_DUPLICATE_NAMESPACE,
DISCOVERY_NOT_A_DEFINITION, DISCOVERY_NOT_PARSEABLE and
DISCOVERY_INVALID_DEFINITION; DiscoveryError is a data class (getCode(),
getFile(), getMessage(), getNamespace()), not an exception, because the spec
wants a bad definition reported without stopping the load of the rest.
The canonical tree as JSON
TreeJson.toCanonicalJson(nodes) —or toCanonicalJson(node) for a single root—
writes the JSON of STXT-TREE-SPEC, the same one stxt describe
emits, with two-space indentation. It is an explicit function that emits only the
normative fields —children for inline nodes and lines for blocks, with no
positions or comments— and depends on no JSON library: it returns the text,
which can then be passed to Jackson, Gson or any other library.
import dev.stxt.runtime.TreeJson;
String json = TreeJson.toCanonicalJson(result.getNodes());
System.out.print(json);
Writing STXT
NodeWriter performs the inverse operation: it serialises a node, or a list of documents, to
STXT text in the canonical form of STXT-TREE-SPEC §11, with
IndentStyle.TABS (default) or IndentStyle.SPACES_4. It writes the namespace only
where it changes from the parent's, wherever the source declared it. Writing a tree out
and parsing it back yields the same tree, in both styles. Since it starts from the
logical tree, a text that goes through parse and NodeWriter comes out without
comments or blank lines outside blocks —that is what stxt format --clean does—.
import dev.stxt.runtime.NodeWriter;
import dev.stxt.runtime.NodeWriter.IndentStyle;
String one = NodeWriter.toSTXT(email); // one node, with tabs
String all = NodeWriter.toSTXT(result.getNodes(), IndentStyle.SPACES_4); // a whole document
The reformatting that keeps comments and blank lines is Formatter
(STXT-TREE-SPEC §12): it rewrites the original text line by line
—the lines that open a node in canonical form, those of a block at the level of the
block, the rest as they are, with their indentation units converted to the requested
style— and returns, along with the text, the syntax errors it met, so that the caller
decides what to do with a document that does not parse. It is the same formatter, with
the same rules, as the one behind stxt format, the extension and the playground.
import dev.stxt.runtime.Formatter;
import dev.stxt.runtime.FormatResult;
FormatResult formatted = Formatter.format(source, IndentStyle.TABS);
String text = formatted.text();
List<ParseException> errors = formatted.errors();
The email built above, written with toSTXT:
Email (com.example.mail): Weekly report
To:
Address: [email protected]
From: Ana García <[email protected]>
Body >>
Hi Bob,
See attached.Observing and validating while streaming
The parser does not depend on schemas: validation is a decoupled layer connected
through two extension points. Observer receives streaming callbacks while the
document is parsed —onCreate(node) when a node is opened, already with its parent,
effective namespace and level, and onFinish(node) when it is closed—, and
Validator runs on each node as it is closed and returns a list of
ValidationException (it does not throw), so a document is validated while it is
read, without waiting for the end. SchemaValidator is a built-in Validator;
Validator is a functional interface, so it accepts a lambda.
import java.util.List;
import dev.stxt.Node;
import dev.stxt.Parser;
import dev.stxt.exceptions.ValidationException;
import dev.stxt.processors.Observer;
Parser parser = new Parser();
parser.registerObserver(new Observer() {
@Override public void onCreate(Node node) { System.out.println("open " + node.getQualifiedName()); }
@Override public void onFinish(Node node) { System.out.println("close " + node.getQualifiedName()); }
});
parser.registerValidator(node -> List.<ValidationException>of()); // a validator that never complains
parser.parseResult(text);
Errors
Every exception is unchecked and extends dev.stxt.exceptions.STXTException, with
an upper-case code in getCode():
| Exception | When |
|---|---|
ParseException |
The syntax is wrong; adds getLine() |
ValidationException |
The document breaks its grammar (type, cardinality, undeclared child); a subclass of ParseException |
SchemaException |
The schema or template itself is malformed |
ResourceNotFoundException |
A ResourcesLoader has no such resource (providers turn it into a SCHEMA_NOT_FOUND finding) |
STXTIOException |
Reading a file failed |
STXTException (base) |
Tree integrity (NODE_ALREADY_ATTACHED, NODE_CYCLE, LINE_BREAK_NOT_ALLOWED), an ambiguous lookup (AMBIGUOUS_CHILD) and other runtime failures |
The ones coming out of parseResult are not thrown: they are collected in
getErrors() as ParseException (syntax) or ValidationException (grammar).
Tree-integrity ones are thrown, because they are program errors, not document
errors.
The API surface
| Package | What is there |
|---|---|
dev.stxt |
Parser, ParseResult, Node, InlineNode, TextNode, Constants |
dev.stxt.exceptions |
STXTException, ParseException, ValidationException, SchemaException, ResourceNotFoundException, STXTIOException |
dev.stxt.processors |
Observer, Validator |
dev.stxt.schema |
Schema, NodeDefinition, ChildDefinition, SchemaProvider, SchemaValidator, SchemaProviderResources, SchemaProviderCache, SchemaProviderMeta, SchemaParser, Type, TypeRegistry, and the types in dev.stxt.schema.type |
dev.stxt.template |
TemplateParser, TemplateSchemaProvider, MetaTemplateSchemaProvider |
dev.stxt.resources |
ResourcesLoader, ResourcesLoaderDirectory |
dev.stxt.runtime |
STXT (the facade), NodeWriter and IndentStyle, Formatter and FormatResult, TreeJson |
dev.stxt.discovery |
DiscoveryResolver, DiscoveryResult, DiscoveryDefinition, DiscoveryLevel, DiscoveryError, DiscoveryEnvironment, SystemDiscoveryEnvironment |
From 1.0 on this API is frozen within the package's 1.x line, and the language
and the canonical tree are frozen for good: STXT-SPEC and STXT-TREE-SPEC are in
Zenith status, and a document that parses today parses the same tomorrow. What
exactly is frozen, what is not, and why the package version is not the specification
date, in Stability and versions. The release notes of
each version are in the CHANGELOG.md of the
repository, which is also where its
errors are reported.