From 069ac7e04883b9bad34d75374005a62d08b6683e Mon Sep 17 00:00:00 2001
From: "AD\\aabdoun"
Date: Tue, 3 Nov 2026 10:39:24 +0100
Subject: [PATCH 1/6] feat(io): replace ANTLR Turtle/TriG parsers with
streaming FSM scanners and add unit tests
---
.../io/parser/DefaultRDFParserFactory.java | 12 +-
.../io/parser/support/TurtleTriGDocument.java | 837 ++++++++++++++++++
.../data/impl/io/parser/trig/TriGScanner.java | 300 +++++++
.../impl/io/parser/turtle/TurtleScanner.java | 72 ++
.../parser/DefaultRDFParserFactoryTest.java | 10 +-
.../impl/io/parser/trig/TriGScannerTest.java | 421 +++++++++
.../io/parser/turtle/TurtleScannerTest.java | 411 +++++++++
7 files changed, 2052 insertions(+), 11 deletions(-)
create mode 100644 src/main/java/fr/inria/corese/core/next/data/impl/io/parser/support/TurtleTriGDocument.java
create mode 100644 src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGScanner.java
create mode 100644 src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleScanner.java
create mode 100644 src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGScannerTest.java
create mode 100644 src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleScannerTest.java
diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/DefaultRDFParserFactory.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/DefaultRDFParserFactory.java
index 34354bdca..fdf53c777 100644
--- a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/DefaultRDFParserFactory.java
+++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/DefaultRDFParserFactory.java
@@ -17,10 +17,10 @@
import fr.inria.corese.core.next.data.impl.io.parser.rdfxml.RDFXMLParser;
import fr.inria.corese.core.next.data.impl.io.parser.rdfxml.RDFXMLParserOptions;
import fr.inria.corese.core.next.data.api.exception.ParsingException;
-import fr.inria.corese.core.next.data.impl.io.parser.turtle.TurtleParser;
import fr.inria.corese.core.next.data.impl.io.parser.turtle.TurtleParserOptions;
-import fr.inria.corese.core.next.data.impl.io.parser.trig.TriGParser;
+import fr.inria.corese.core.next.data.impl.io.parser.turtle.TurtleScanner;
import fr.inria.corese.core.next.data.impl.io.parser.trig.TriGParserOptions;
+import fr.inria.corese.core.next.data.impl.io.parser.trig.TriGScanner;
import fr.inria.corese.core.next.data.api.io.JSONLDOptions;
import java.util.Objects;
@@ -61,7 +61,7 @@ public RDFParser createRDFParser(
if (RDFFormat.JSONLD.equals(format)) {
return new JSONLDParser(model, factory, config);
} else if (RDFFormat.TURTLE.equals(format)) {
- return new TurtleParser(model, factory, config);
+ return new TurtleScanner(model, factory, config);
} else if (RDFFormat.NTRIPLES.equals(format)) {
return new NTriplesParser(model, factory, config);
} else if (RDFFormat.NQUADS.equals(format) || RDFFormat.RDFC_1_0.equals(format)) {
@@ -69,7 +69,7 @@ public RDFParser createRDFParser(
} else if (RDFFormat.RDFXML.equals(format)) {
return new RDFXMLParser(model, factory, config);
} else if (RDFFormat.TRIG.equals(format)) {
- return new TriGParser(model, factory, config);
+ return new TriGScanner(model, factory, config);
} else if (RDFFormat.RDFA.equals(format)) {
return new RDFaParser(model, factory, config);
}
@@ -91,7 +91,7 @@ public RDFParser createRDFParser(RDFFormat format, Model model, ValueFactory fac
if (RDFFormat.JSONLD.equals(format)) {
return new JSONLDParser(model, factory);
} else if (RDFFormat.TURTLE.equals(format)) {
- return new TurtleParser(model, factory);
+ return new TurtleScanner(model, factory);
} else if (RDFFormat.NTRIPLES.equals(format)) {
return new NTriplesParser(model, factory);
} else if (RDFFormat.NQUADS.equals(format) || RDFFormat.RDFC_1_0.equals(format)) {
@@ -99,7 +99,7 @@ public RDFParser createRDFParser(RDFFormat format, Model model, ValueFactory fac
} else if (RDFFormat.RDFXML.equals(format)) {
return new RDFXMLParser(model, factory);
} else if (RDFFormat.TRIG.equals(format)) {
- return new TriGParser(model, factory);
+ return new TriGScanner(model, factory);
} else if (RDFFormat.RDFA.equals(format)) {
return new RDFaParser(model, factory);
}
diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/support/TurtleTriGDocument.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/support/TurtleTriGDocument.java
new file mode 100644
index 000000000..ae175fb87
--- /dev/null
+++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/support/TurtleTriGDocument.java
@@ -0,0 +1,837 @@
+package fr.inria.corese.core.next.data.impl.io.parser.support;
+
+import fr.inria.corese.core.next.data.api.exception.IncorrectFormatException;
+import fr.inria.corese.core.next.data.api.exception.ParsingException;
+import fr.inria.corese.core.next.data.api.factory.ValueFactory;
+import fr.inria.corese.core.next.data.api.model.Model;
+import fr.inria.corese.core.next.data.api.term.BNode;
+import fr.inria.corese.core.next.data.api.term.IRI;
+import fr.inria.corese.core.next.data.api.term.Literal;
+import fr.inria.corese.core.next.data.api.term.Resource;
+import fr.inria.corese.core.next.data.api.term.Value;
+import fr.inria.corese.core.next.data.spi.term.IRIUtils;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Character-level streaming FSM for Turtle and TriG documents.
+ *
+ *
Implements an event-driven byte-streaming scanner that handles Turtle/TriG
+ * directives ({@code @prefix}, {@code PREFIX}, {@code @base}, {@code BASE}) and
+ * triples/quads without allocating intermediate AST nodes. Override
+ * {@link #emitTriple} to route statements to named graphs (TriG) or override
+ * {@link #parseDocument} to intercept graph blocks.
+ *
+ *
Memory is O(longest term + distinct blank node labels + IRI cache size).
+ * Diagnostics use one-based lines and UTF-16 columns; CRLF counts as one line
+ * ending. Input is borrowed and never closed.
+ */
+public class TurtleTriGDocument {
+
+ // ── Constants ──────────────────────────────────────────────────────────
+ static final int BUFFER_SIZE = 64 * 1024;
+ static final int IRI_CACHE_LIMIT = 4096;
+ private static final int NO_PUSHBACK = Integer.MIN_VALUE;
+
+ // ── I/O state ──────────────────────────────────────────────────────────
+ private final Reader reader;
+ private final char[] buffer = new char[BUFFER_SIZE];
+ private int bufOffset, bufLimit;
+ private boolean eof;
+ /** One-character pushback slot used for dot-in-local-name disambiguation. */
+ private int pushback = NO_PUSHBACK;
+
+ // ── Position tracking ──────────────────────────────────────────────────
+ protected long line = 1;
+ protected long column = 1;
+ private boolean afterCr;
+
+ // ── RDF infrastructure ─────────────────────────────────────────────────
+ protected final Model model;
+ protected final ValueFactory factory;
+
+ // ── Shared term builder – reset before every leaf token ────────────────
+ private final StringBuilder term = new StringBuilder(256);
+
+ // ── Term caches ────────────────────────────────────────────────────────
+ private final Map iriCache = new HashMap<>();
+ private final Map blankNodes = new HashMap<>();
+
+ // ── Prefix / base state ────────────────────────────────────────────────
+ protected final Map prefixes = new HashMap<>();
+ protected String baseURI;
+
+ // ── Pre-cached RDF / XSD constants ────────────────────────────────────
+ protected final IRI RDF_TYPE;
+ protected final IRI RDF_FIRST;
+ protected final IRI RDF_REST;
+ protected final IRI RDF_NIL;
+ protected final IRI XSD_INTEGER;
+ protected final IRI XSD_DECIMAL;
+ protected final IRI XSD_DOUBLE;
+ protected final IRI XSD_BOOLEAN;
+
+
+ public TurtleTriGDocument(Reader reader, Model model, ValueFactory factory, String baseURI) {
+ this.reader = reader;
+ this.model = model;
+ this.factory = factory;
+ this.baseURI = baseURI != null ? baseURI : "";
+ RDF_TYPE = intern("http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
+ RDF_FIRST = intern("http://www.w3.org/1999/02/22-rdf-syntax-ns#first");
+ RDF_REST = intern("http://www.w3.org/1999/02/22-rdf-syntax-ns#rest");
+ RDF_NIL = intern("http://www.w3.org/1999/02/22-rdf-syntax-ns#nil");
+ XSD_INTEGER = intern("http://www.w3.org/2001/XMLSchema#integer");
+ XSD_DECIMAL = intern("http://www.w3.org/2001/XMLSchema#decimal");
+ XSD_DOUBLE = intern("http://www.w3.org/2001/XMLSchema#double");
+ XSD_BOOLEAN = intern("http://www.w3.org/2001/XMLSchema#boolean");
+ }
+
+
+ public final void parse() {
+ try {
+ parseDocument();
+ } catch (IOException e) {
+ throw new ParsingException(location("Failed to read Turtle/TriG input"), e);
+ } catch (IllegalArgumentException | IncorrectFormatException e) {
+ throw new ParsingException(location("Value factory rejected RDF term: " + e.getMessage()), e);
+ }
+ }
+
+
+ protected void parseDocument() throws IOException {
+ while (true) {
+ ws();
+ if (peek() == -1) break;
+ checkInterrupted();
+ topLevel();
+ }
+ }
+
+ /**
+ * Dispatches one top-level item: {@code @prefix}, {@code @base},
+ * SPARQL {@code PREFIX}/{@code BASE}, or a triple statement.
+ */
+ protected void topLevel() throws IOException {
+ int c = peek();
+
+ // @prefix / @base
+ if (c == '@') { atDirective(); return; }
+
+ // Blank node property list as subject: [ ... ] predicateObjectList? .
+ if (c == '[') {
+ Resource subj = blankNodePropertyList();
+ ws();
+ if (peek() != '.') { predicateObjectList(subj); ws(); }
+ expect('.');
+ return;
+ }
+
+ // Collection as subject: ( items ) predicateObjectList .
+ if (c == '(') {
+ Resource subj = collection();
+ ws();
+ predicateObjectList(subj);
+ ws();
+ expect('.');
+ return;
+ }
+
+ // Angle-bracket IRI as subject
+ if (c == '<') {
+ Resource subj = intern(iriRefContent());
+ ws();
+ predicateObjectList(subj);
+ ws();
+ expect('.');
+ return;
+ }
+
+ // Blank node _:label as subject
+ if (c == '_') {
+ Resource subj = blankNodeLabel();
+ ws();
+ predicateObjectList(subj);
+ ws();
+ expect('.');
+ return;
+ }
+
+ // Default-prefix name :local
+ if (c == ':') {
+ take();
+ Resource subj = intern(lookupPrefix("") + readLocalPart());
+ ws();
+ predicateObjectList(subj);
+ ws();
+ expect('.');
+ return;
+ }
+
+ // Name: SPARQL keyword (PREFIX / BASE) or prefixed-name subject
+ if (isAsciiAlpha(c) || unicodeNameStart(c)) {
+ String name = readName();
+ if (peek() == ':') {
+ take();
+ Resource subj = intern(lookupPrefix(name) + readLocalPart());
+ ws();
+ predicateObjectList(subj);
+ ws();
+ expect('.');
+ } else if (name.equalsIgnoreCase("PREFIX")) {
+ ws();
+ sparqlPrefixDirective();
+ } else if (name.equalsIgnoreCase("BASE")) {
+ ws();
+ sparqlBaseDirective();
+ } else {
+ throw error("Expected ':' after '" + name + "' or a valid directive keyword");
+ }
+ return;
+ }
+
+ throw error("Unexpected character at statement start: '" + (char) c + "'");
+ }
+
+
+ protected void atDirective() throws IOException {
+ expect('@');
+ String kw = readName();
+ ws();
+ if (kw.equals("prefix")) turtlePrefixBody(false);
+ else if (kw.equals("base")) turtleBaseBody(false);
+ else throw error("Unknown directive: @" + kw);
+ }
+
+ protected void sparqlPrefixDirective() throws IOException { turtlePrefixBody(true); }
+ protected void sparqlBaseDirective() throws IOException { turtleBaseBody(true); }
+
+ protected void turtlePrefixBody(boolean sparql) throws IOException {
+ term.setLength(0);
+ while (peek() != ':' && peek() != -1 && !isWhitespace(peek())) {
+ term.appendCodePoint(scalar());
+ }
+ String prefix = term.toString();
+ expect(':');
+ ws();
+ String ns = iriRefContent();
+ registerPrefix(prefix, ns);
+ if (!sparql) { ws(); expect('.'); }
+ }
+
+ protected void turtleBaseBody(boolean sparql) throws IOException {
+ baseURI = iriRefContent();
+ if (!sparql) { ws(); expect('.'); }
+ }
+
+ protected void registerPrefix(String prefix, String ns) {
+ prefixes.put(prefix, ns);
+ model.setNamespace(prefix, ns);
+ }
+
+
+ protected void predicateObjectList(Resource subj) throws IOException {
+ IRI pred = verb();
+ ws();
+ objectList(subj, pred);
+ ws();
+ while (peek() == ';') {
+ take();
+ ws();
+ int c = peek();
+ if (c == '.' || c == '}' || c == -1) break; // trailing semicolon is legal
+ if (c == ';') continue; // skip consecutive semicolons (;; is valid)
+ pred = verb();
+ ws();
+ objectList(subj, pred);
+ ws();
+ }
+ }
+
+ private void objectList(Resource subj, IRI pred) throws IOException {
+ Value obj = object();
+ emitTriple(subj, pred, obj);
+ ws();
+ while (peek() == ',') {
+ take();
+ ws();
+ obj = object();
+ emitTriple(subj, pred, obj);
+ ws();
+ }
+ }
+
+ protected IRI verb() throws IOException {
+ int c = peek();
+ if (c == '<') return intern(iriRefContent());
+ if (c == '_') throw error("Blank node cannot be used as predicate");
+ if (c == ':') { take(); return intern(lookupPrefix("") + readLocalPart()); }
+ String name = readName();
+ if (peek() == ':') { take(); return intern(lookupPrefix(name) + readLocalPart()); }
+ if (name.equals("a")) return RDF_TYPE;
+ throw error("Expected predicate IRI or 'a', got: '" + name + "'");
+ }
+
+
+ protected Resource subject() throws IOException {
+ int c = peek();
+ if (c == '<') return intern(iriRefContent());
+ if (c == '_') return blankNodeLabel();
+ if (c == '[') return blankNodePropertyList();
+ if (c == '(') return collection();
+ if (c == ':') { take(); return intern(lookupPrefix("") + readLocalPart()); }
+ if (isAsciiAlpha(c) || unicodeNameStart(c)) {
+ String name = readName();
+ if (peek() == ':') { take(); return intern(lookupPrefix(name) + readLocalPart()); }
+ throw error("Expected ':' after prefix '" + name + "'");
+ }
+ throw error("Expected subject (IRI, blank node, or collection), got: '" + (char) c + "'");
+ }
+
+
+ protected Value object() throws IOException {
+ int c = peek();
+ if (c == '<') return intern(iriRefContent());
+ if (c == '_') return blankNodeLabel();
+ if (c == '[') return blankNodePropertyList();
+ if (c == '(') return collection();
+ if (c == '"' || c == '\'') return literal();
+ if (c == '+' || c == '-' || digit(c)) return numericLiteral();
+ if (c == '.') return numericLeadingDot();
+ if (c == ':') { take(); return intern(lookupPrefix("") + readLocalPart()); }
+ if (isAsciiAlpha(c) || unicodeNameStart(c)) {
+ String name = readName();
+ if (peek() == ':') { take(); return intern(lookupPrefix(name) + readLocalPart()); }
+ if (name.equals("true")) return factory.createLiteral("true", XSD_BOOLEAN);
+ if (name.equals("false")) return factory.createLiteral("false", XSD_BOOLEAN);
+ throw error("Unexpected token in object position: '" + name + "'");
+ }
+ throw error("Expected object (IRI, literal, blank node, or collection), got: '" + (char) c + "'");
+ }
+
+
+ /**
+ * Reads {@code }, resolves against {@link #baseURI}, and returns
+ * the absolute IRI string. Does not create an IRI object; caller should use
+ * {@link #intern(String)}.
+ */
+ protected String iriRefContent() throws IOException {
+ expect('<');
+ term.setLength(0);
+ while (peek() != '>') {
+ int c = peek();
+ if (c == -1 || isLineEnd(c)) throw error("Unterminated IRI reference");
+ int scalar = (c == '\\') ? escape(false) : scalar();
+ if (forbiddenIriChar(scalar)) throw error("Forbidden character U+" + Integer.toHexString(scalar) + " in IRI");
+ term.appendCodePoint(scalar);
+ }
+ expect('>');
+ return resolveIRI(term.toString());
+ }
+
+ protected String resolveIRI(String raw) {
+ if (raw.isEmpty()) return baseURI.isEmpty() ? "" : baseURI;
+ if (isAbsoluteIri(raw)) return raw;
+ if (baseURI.isEmpty()) return raw;
+ return IRIUtils.resolveIRIAgainstBase(baseURI, raw);
+ }
+
+ protected String lookupPrefix(String prefix) {
+ String ns = prefixes.get(prefix);
+ if (ns == null) {
+ throw error(prefix.isEmpty()
+ ? "Undeclared default prefix ':'; declare with '@prefix : .'"
+ : "Undeclared prefix: '" + prefix + "'");
+ }
+ return ns;
+ }
+
+
+ protected BNode blankNodeLabel() throws IOException {
+ expect('_');
+ expect(':');
+ term.setLength(0);
+ int first = scalar();
+ if (!nameStart(first) && !digit(first)) throw error("Invalid blank node label start character");
+ term.appendCodePoint(first);
+ int trailingDots = blankNodeTail();
+ if (trailingDots > 1) throw error("Blank node label cannot end with multiple dots");
+ if (trailingDots == 1) {
+ term.setLength(term.length() - 1);
+ pushBack();
+ }
+ return blankNodes.computeIfAbsent(term.toString(), k -> factory.createBNode());
+ }
+
+ private int blankNodeTail() throws IOException {
+ int dots = 0;
+ while (true) {
+ int c = peek();
+ if (c == '.') {
+ take(); term.append('.'); dots++;
+ } else if (nameCharacter(c) || Character.isHighSurrogate((char) c)) {
+ int cp = scalar();
+ if (!nameCharacter(cp)) throw error("Invalid character in blank node label");
+ term.appendCodePoint(cp); dots = 0;
+ } else {
+ return dots;
+ }
+ }
+ }
+
+
+ protected Resource blankNodePropertyList() throws IOException {
+ expect('[');
+ ws();
+ BNode bnode = factory.createBNode();
+ if (peek() != ']') {
+ predicateObjectList(bnode);
+ ws();
+ }
+ expect(']');
+ return bnode;
+ }
+
+
+ protected Resource collection() throws IOException {
+ expect('(');
+ ws();
+ if (peek() == ')') { take(); return RDF_NIL; }
+ BNode head = factory.createBNode();
+ BNode current = head;
+ while (true) {
+ Value item = object();
+ ws();
+ emitTriple(current, RDF_FIRST, item);
+ if (peek() == ')') {
+ emitTriple(current, RDF_REST, RDF_NIL);
+ take();
+ break;
+ }
+ BNode next = factory.createBNode();
+ emitTriple(current, RDF_REST, next);
+ current = next;
+ }
+ return head;
+ }
+
+
+ private Literal literal() throws IOException {
+ int q = peek();
+ take(); // opening quote
+ String label;
+ if (peek() == q) { // potential triple quote or empty string
+ take();
+ if (peek() == q) { // confirmed triple quote
+ take();
+ label = longStringContent((char) q);
+ } else {
+ label = ""; // empty short string ""
+ }
+ } else {
+ label = shortStringContent((char) q);
+ }
+ // Language tag
+ if (peek() == '@') {
+ take();
+ return factory.createLiteral(label, language());
+ }
+ // Datatype IRI ^^ or ^^prefix:local
+ if (peek() == '^') {
+ expect('^'); expect('^');
+ String dtIri;
+ if (peek() == '<') {
+ dtIri = iriRefContent();
+ } else {
+ String pfx = peek() == ':' ? "" : readName();
+ expect(':');
+ dtIri = lookupPrefix(pfx) + readLocalPart();
+ }
+ return factory.createLiteral(label, intern(dtIri));
+ }
+ return factory.createLiteral(label);
+ }
+
+ private String shortStringContent(char q) throws IOException {
+ term.setLength(0);
+ while (true) {
+ int c = peek();
+ if ((char) c == q) { take(); return term.toString(); }
+ if (c == -1 || isLineEnd(c)) throw error("Unterminated string literal");
+ term.appendCodePoint(c == '\\' ? escape(true) : scalar());
+ }
+ }
+
+ private String longStringContent(char q) throws IOException {
+ term.setLength(0);
+ while (true) {
+ int c = peek();
+ if (c == -1) throw error("Unterminated long string literal");
+ if ((char) c == q) {
+ take();
+ if (peek() == (int) q) {
+ take();
+ if (peek() == (int) q) { take(); return term.toString(); } // closing """
+ term.append(q); // two quotes, not closing
+ }
+ term.append(q); // one quote, not closing
+ } else {
+ term.appendCodePoint(c == '\\' ? escape(true) : scalar());
+ }
+ }
+ }
+
+ private String language() throws IOException {
+ term.setLength(0);
+ if (!letter(peek())) throw error("Language tag must start with an ASCII letter");
+ while (letter(peek())) term.append((char) take());
+ while (peek() == '-') {
+ term.append((char) take());
+ if (!letter(peek()) && !digit(peek())) throw error("Expected alphanumeric language subtag after '-'");
+ while (letter(peek()) || digit(peek())) term.append((char) take());
+ }
+ return term.toString();
+ }
+
+
+ private Literal numericLiteral() throws IOException {
+ term.setLength(0);
+ int c = peek();
+ if (c == '+' || c == '-') term.append((char) take()); // sign
+ if (!digit(peek()) && peek() != '.') throw error("Expected digit or '.' in numeric literal");
+ return numericBody();
+ }
+
+ /** Called when leading '.' is seen in object position (potential {@code .5}). */
+ private Value numericLeadingDot() throws IOException {
+ // We haven't consumed the '.' yet. Check next char for digit.
+ // Peek at buffer position +1 if available; otherwise take+pushBack.
+ boolean followedByDigit;
+ if (pushback == NO_PUSHBACK && bufOffset + 1 < bufLimit) {
+ followedByDigit = digit(buffer[bufOffset + 1]);
+ } else {
+ take(); // consume '.'
+ followedByDigit = digit(peek());
+ pushBack();
+ }
+ if (!followedByDigit) throw error("Unexpected '.' in object position");
+ return numericLiteral();
+ }
+
+ private Literal numericBody() throws IOException {
+ boolean hasDot = false, hasExp = false;
+ // Integer part
+ while (digit(peek())) term.append((char) take());
+ // Decimal dot (only if followed by digit or exponent — e.g. "1.e5" is valid)
+ if (peek() == '.') {
+ take(); // tentatively consume
+ if (digit(peek())) {
+ term.append('.');
+ hasDot = true;
+ while (digit(peek())) term.append((char) take());
+ } else if (peek() == 'e' || peek() == 'E') {
+ // e.g. "1.e5" — empty fractional part is allowed by the grammar
+ term.append('.');
+ hasDot = true;
+ } else {
+ pushBack(); // not a decimal dot
+ }
+ }
+ // Exponent
+ int e = peek();
+ if (e == 'e' || e == 'E') {
+ take(); term.append((char) e); hasExp = true;
+ int sign = peek();
+ if (sign == '+' || sign == '-') { take(); term.append((char) sign); }
+ if (!digit(peek())) throw error("Expected digits after exponent marker");
+ while (digit(peek())) term.append((char) take());
+ }
+ String text = term.toString();
+ if (text.isEmpty() || text.equals("+") || text.equals("-"))
+ throw error("Invalid numeric literal: '" + text + "'");
+ if (hasExp) return factory.createLiteral(text, XSD_DOUBLE);
+ if (hasDot) return factory.createLiteral(text, XSD_DECIMAL);
+ return factory.createLiteral(text, XSD_INTEGER);
+ }
+
+ /**
+ * Reads the local-name part of a prefixed name (after the {@code :}).
+ * Handles {@code %XX} percent-encoded sequences and {@code \X} backslash escapes.
+ * Internal dots are included only when followed by another local-name character.
+ */
+ protected String readLocalPart() throws IOException {
+ term.setLength(0);
+ // '-' is a PN_CHARS character but NOT a valid PN_LOCAL start character
+ if (peek() == '-') throw error("Local name cannot begin with '-'");
+ while (true) {
+ int c = peek();
+ if (c == '%') {
+ take(); term.append('%');
+ int h1 = take(), h2 = take();
+ if (hexValue(h1) < 0 || hexValue(h2) < 0) throw error("Invalid %XX encoding in local name");
+ term.append((char) h1); term.append((char) h2);
+ } else if (c == '\\') {
+ take();
+ int esc = take();
+ if (!isLocalNameEscapeChar(esc)) throw error("Invalid '\\' escape in local name: '\\" + (char) esc + "'");
+ term.append((char) esc);
+ } else if (c == '.') {
+ // Include '.' only when followed by another local-name char
+ take();
+ int next = peek();
+ if (localNameChar(next) || next == '.' || next == '%' || next == '\\'
+ || Character.isHighSurrogate((char) next)) {
+ term.append('.');
+ } else {
+ pushBack();
+ break;
+ }
+ } else if (localNameChar(c) || Character.isHighSurrogate((char) c)) {
+ // Read full Unicode code point (handles surrogate pairs for chars > U+FFFF)
+ int cp = scalar();
+ if (!localNameChar(cp)) break;
+ term.appendCodePoint(cp);
+ } else {
+ break;
+ }
+ }
+ return term.toString();
+ }
+
+
+ /**
+ * Reads a name token (prefix label, keyword, or {@code a}).
+ * Stops at {@code :}, whitespace, or non-name characters.
+ * Internal dots are included only when followed by another name character.
+ */
+ protected String readName() throws IOException {
+ term.setLength(0);
+ int first = scalar();
+ if (!nameStart(first) && !digit(first))
+ throw error("Expected name start character, got: '" + (char) first + "'");
+ term.appendCodePoint(first);
+ while (true) {
+ int c = peek();
+ if (nameCharacter(c) || Character.isHighSurrogate((char) c)) {
+ term.appendCodePoint(scalar());
+ } else if (c == '.') {
+ // Include '.' only if next char is also a name char (e.g. "ex.org:foo")
+ if (pushback == NO_PUSHBACK && bufOffset + 1 < bufLimit
+ && nameCharacter(buffer[bufOffset + 1])) {
+ take(); term.append('.');
+ } else {
+ break;
+ }
+ } else {
+ break;
+ }
+ }
+ return term.toString();
+ }
+
+
+ /**
+ * Emits a parsed triple. Override in TriG subclasses to include named graph context.
+ */
+ protected void emitTriple(Resource s, IRI p, Value o) {
+ model.add(s, p, o);
+ }
+
+
+ protected IRI intern(String text) {
+ IRI cached = iriCache.get(text);
+ if (cached != null) return cached;
+ IRI result = factory.createIRI(text);
+ if (iriCache.size() == IRI_CACHE_LIMIT) iriCache.clear();
+ iriCache.put(text, result);
+ return result;
+ }
+
+ private int escape(boolean inLiteral) throws IOException {
+ expect('\\');
+ int type = take();
+ if (type == 'u' || type == 'U') return unicodeEscape(type == 'u' ? 4 : 8);
+ if (!inLiteral) throw error("Only Unicode escapes (\\uXXXX / \\UXXXXXXXX) are allowed in IRIs");
+ return switch (type) {
+ case 't' -> '\t';
+ case 'b' -> '\b';
+ case 'n' -> '\n';
+ case 'r' -> '\r';
+ case 'f' -> '\f';
+ case '"' -> '"';
+ case '\'' -> '\'';
+ case '\\' -> '\\';
+ default -> throw error("Invalid literal escape sequence: '\\" + (char) type + "'");
+ };
+ }
+
+ private int unicodeEscape(int digits) throws IOException {
+ long result = 0;
+ for (int i = 0; i < digits; i++) {
+ int hex = hexValue(peek());
+ if (hex < 0) throw error("Expected hexadecimal digit in Unicode escape");
+ take();
+ result = (result << 4) | hex;
+ }
+ if (result > Character.MAX_CODE_POINT || (result >= 0xD800 && result <= 0xDFFF)) {
+ throw error("Unicode escape 0x" + Long.toHexString(result) + " is not a valid scalar value");
+ }
+ return (int) result;
+ }
+
+
+ /** Skips whitespace (space, tab, CR, LF) and line comments ({@code # ...}). */
+ protected void ws() throws IOException {
+ while (true) {
+ int c = peek();
+ if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
+ take();
+ } else if (c == '#') {
+ while (peek() != -1 && !isLineEnd(peek())) take();
+ } else {
+ break;
+ }
+ }
+ }
+
+ private int scalar() throws IOException {
+ int first = peek();
+ if (first == -1) throw error("Unexpected end of input");
+ if (Character.isLowSurrogate((char) first)) throw error("Unpaired low surrogate");
+ take();
+ if (!Character.isHighSurrogate((char) first)) return first;
+ int second = peek();
+ if (second == -1 || !Character.isLowSurrogate((char) second)) throw error("Unpaired high surrogate");
+ take();
+ return Character.toCodePoint((char) first, (char) second);
+ }
+
+ protected int peek() throws IOException {
+ if (pushback != NO_PUSHBACK) return pushback;
+ if (bufOffset == bufLimit && !eof) {
+ checkInterrupted();
+ int count = reader.read(buffer, 0, buffer.length);
+ if (count == 0) throw new IOException("Reader returned zero characters for a nonempty buffer");
+ eof = count < 0;
+ bufOffset = 0;
+ bufLimit = Math.max(count, 0);
+ }
+ return bufOffset == bufLimit ? -1 : buffer[bufOffset];
+ }
+
+ protected int take() throws IOException {
+ int c;
+ if (pushback != NO_PUSHBACK) {
+ c = pushback;
+ pushback = NO_PUSHBACK;
+ } else {
+ c = peek();
+ if (c == -1) return -1;
+ bufOffset++;
+ }
+ updatePosition(c);
+ return c;
+ }
+
+ /**
+ * Pushes back a {@code '.'} character. Used exclusively for dot-in-local-name
+ * and decimal-dot disambiguation. Only one character can be pushed back at a time.
+ */
+ protected void pushBack() {
+ pushback = '.';
+ if (column > 1) column--;
+ }
+
+ protected void expect(int expected) throws IOException {
+ int actual = peek();
+ if (actual != expected) {
+ throw error("Expected '" + (char) expected + "' but got "
+ + (actual == -1 ? "end of input" : "'" + (char) actual + "'"));
+ }
+ take();
+ }
+
+ private void updatePosition(int c) {
+ if (c == '\r') {
+ line++; column = 1;
+ } else if (c == '\n') {
+ if (!afterCr) { line++; column = 1; }
+ } else {
+ column++;
+ }
+ afterCr = (c == '\r');
+ }
+
+ protected void checkInterrupted() {
+ if (Thread.currentThread().isInterrupted()) throw error("Parsing interrupted");
+ }
+
+ private static boolean forbiddenIriChar(int c) {
+ return c <= 0x20 || "<>\"{}|^`\\".indexOf(c) >= 0;
+ }
+
+ private static boolean isAbsoluteIri(String text) {
+ if (text == null || text.isEmpty() || !isAsciiAlpha(text.charAt(0))) return false;
+ int i = 1, n = text.length();
+ while (i < n && isSchemeChar(text.charAt(i))) i++;
+ return i < n && text.charAt(i) == ':';
+ }
+
+ protected static boolean isAsciiAlpha(int c) {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+ }
+
+ private static boolean isSchemeChar(char c) {
+ return isAsciiAlpha(c) || (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.';
+ }
+
+ protected static boolean letter(int c) { return isAsciiAlpha(c); }
+ protected static boolean digit(int c) { return c >= '0' && c <= '9'; }
+
+ private static boolean isLineEnd(int c) { return c == '\r' || c == '\n'; }
+ private static boolean isWhitespace(int c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; }
+
+ protected static boolean nameStart(int c) { return c == '_' || letter(c) || unicodeNameStart(c); }
+
+ protected static boolean unicodeNameStart(int c) {
+ return between(c, 0xC0, 0xD6) || between(c, 0xD8, 0xF6)
+ || between(c, 0xF8, 0x2FF) || between(c, 0x370, 0x37D)
+ || between(c, 0x37F, 0x1FFF) || between(c, 0x200C, 0x200D)
+ || between(c, 0x2070, 0x218F) || between(c, 0x2C00, 0x2FEF)
+ || between(c, 0x3001, 0xD7FF) || between(c, 0xF900, 0xFDCF)
+ || between(c, 0xFDF0, 0xFFFD) || between(c, 0x10000, 0xEFFFF);
+ }
+
+ protected static boolean nameCharacter(int c) {
+ return nameStart(c) || digit(c) || c == '-' || c == 0xB7
+ || between(c, 0x300, 0x36F) || between(c, 0x203F, 0x2040);
+ }
+
+ private static boolean localNameChar(int c) {
+ return nameCharacter(c) || c == ':';
+ }
+
+ private static boolean isLocalNameEscapeChar(int c) {
+ return "_~.-!$&'()*+,;=/?#@%".indexOf(c) >= 0;
+ }
+
+ private static int hexValue(int c) {
+ if (digit(c)) return c - '0';
+ if (c >= 'a' && c <= 'f') return c - 'a' + 10;
+ return (c >= 'A' && c <= 'F') ? c - 'A' + 10 : -1;
+ }
+
+ private static boolean between(int c, int lo, int hi) { return c >= lo && c <= hi; }
+
+
+ protected ParsingException error(String message) {
+ return new ParsingException(location(message));
+ }
+
+ protected String location(String message) {
+ return message + " at line " + line + ", column " + column;
+ }
+}
diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGScanner.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGScanner.java
new file mode 100644
index 000000000..477330b28
--- /dev/null
+++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGScanner.java
@@ -0,0 +1,300 @@
+package fr.inria.corese.core.next.data.impl.io.parser.trig;
+
+import fr.inria.corese.core.next.data.api.exception.ParsingException;
+import fr.inria.corese.core.next.data.api.factory.ValueFactory;
+import fr.inria.corese.core.next.data.api.io.format.RDFFormat;
+import fr.inria.corese.core.next.data.api.io.option.BaseIRIOptions;
+import fr.inria.corese.core.next.data.api.io.option.RDFParsingOptions;
+import fr.inria.corese.core.next.data.api.model.Model;
+import fr.inria.corese.core.next.data.api.term.BNode;
+import fr.inria.corese.core.next.data.api.term.IRI;
+import fr.inria.corese.core.next.data.api.term.Resource;
+import fr.inria.corese.core.next.data.api.term.Value;
+import fr.inria.corese.core.next.data.impl.io.parser.support.TurtleTriGDocument;
+import fr.inria.corese.core.next.data.spi.io.parser.AbstractRDFParser;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+
+/**
+ * Streaming RDF 1.1 TriG parser without tokens, parse trees, or ANTLR.
+ *
+ *
Extends the Turtle FSM with named-graph block support:
+ * {@code GRAPH
+ *
+ *
All Turtle features (prefixes, base, blank-node property lists, collections,
+ * long literals, numeric and boolean literals) are available both at the top level
+ * and inside graph blocks.
+ */
+public final class TriGScanner extends AbstractRDFParser {
+
+ public TriGScanner(Model model, ValueFactory factory) {
+ this(model, factory, new TriGParserOptions.Builder().build());
+ }
+
+ public TriGScanner(Model model, ValueFactory factory, RDFParsingOptions config) {
+ super(model, factory, config);
+ }
+
+ @Override
+ public RDFFormat getRDFFormat() {
+ return RDFFormat.TRIG;
+ }
+
+ @Override
+ public void parse(InputStream input) throws ParsingException {
+ parse(input, null);
+ }
+
+ @Override
+ public void parse(InputStream input, String baseURI) throws ParsingException {
+ Objects.requireNonNull(input, "input");
+ var decoder = StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT);
+ parse(new InputStreamReader(input, decoder), baseURI);
+ }
+
+ @Override
+ public void parse(Reader reader, String baseURI) throws ParsingException {
+ String base = baseURI;
+ if (base == null && getConfig() instanceof BaseIRIOptions o) {
+ base = o.getBaseIRI();
+ }
+ new TriGDocument(
+ Objects.requireNonNull(reader, "reader"),
+ getModel(), getValueFactory(), base
+ ).parse();
+ }
+
+
+ private static final class TriGDocument extends TurtleTriGDocument {
+
+ /** Active named graph; {@code null} means the default graph. */
+ private Resource currentGraph;
+
+ TriGDocument(Reader reader, Model model, ValueFactory factory, String baseURI) {
+ super(reader, model, factory, baseURI);
+ }
+
+ // ── Route statements to the current named graph ────────────────────
+
+ @Override
+ protected void emitTriple(Resource s, IRI p, Value o) {
+ model.add(s, p, o, currentGraph);
+ }
+
+ // ── TriG document loop ─────────────────────────────────────────────
+
+ @Override
+ protected void parseDocument() throws IOException {
+ while (true) {
+ ws();
+ if (peek() == -1) break;
+ checkInterrupted();
+ trigTopLevel();
+ }
+ }
+
+ /**
+ * Dispatches one top-level TriG item: directive, anonymous graph block,
+ * {@code GRAPH} block, or triple/graph-label statement.
+ */
+ private void trigTopLevel() throws IOException {
+ int c = peek();
+
+ // @prefix / @base
+ if (c == '@') { atDirective(); return; }
+
+ // Anonymous default-graph block: { triples }
+ if (c == '{') { wrappedGraph(null); return; }
+
+ // Blank node: either anonymous '[]' (valid graph label) or property list '[ ... ]' (subject only)
+ if (c == '[') {
+ expect('[');
+ ws();
+ BNode bnode = factory.createBNode();
+ boolean isEmpty = (peek() == ']');
+ if (!isEmpty) {
+ // Non-empty property list: parse and emit its internal triples
+ predicateObjectList(bnode);
+ ws();
+ }
+ expect(']');
+ ws();
+ if (isEmpty && peek() == '{') {
+ wrappedGraph(bnode); // [] { ... } — ANON as graph label
+ } else {
+ if (!isEmpty && peek() == '{') {
+ throw error("A blank node property list cannot be used as a named graph label");
+ }
+ if (peek() != '.' && peek() != '}' && peek() != -1) { predicateObjectList(bnode); ws(); }
+ if (peek() == '.') take();
+ }
+ return;
+ }
+
+ // Collection as subject
+ if (c == '(') {
+ Resource subj = collection();
+ ws();
+ predicateObjectList(subj);
+ ws();
+ if (peek() == '.') take();
+ return;
+ }
+
+ // Angle-bracket IRI: could be graph label or triple subject
+ if (c == '<') {
+ Resource res = intern(iriRefContent());
+ ws();
+ labelOrTriples(res);
+ return;
+ }
+
+ // Blank node label
+ if (c == '_') {
+ Resource res = blankNodeLabel();
+ ws();
+ labelOrTriples(res);
+ return;
+ }
+
+ // Default-prefix name
+ if (c == ':') {
+ take();
+ Resource res = intern(lookupPrefix("") + readLocalPart());
+ ws();
+ labelOrTriples(res);
+ return;
+ }
+
+ // Named token: SPARQL keyword, GRAPH keyword, or prefixed name
+ if (isAsciiAlpha(c) || unicodeNameStart(c)) {
+ String name = readName();
+ if (peek() == ':') {
+ take();
+ Resource res = intern(lookupPrefix(name) + readLocalPart());
+ ws();
+ labelOrTriples(res);
+ } else if (name.equalsIgnoreCase("GRAPH")) {
+ ws();
+ trigGraphBlock();
+ } else if (name.equalsIgnoreCase("PREFIX")) {
+ ws();
+ sparqlPrefixDirective();
+ } else if (name.equalsIgnoreCase("BASE")) {
+ ws();
+ sparqlBaseDirective();
+ } else {
+ throw error("Unexpected keyword at TriG top level: '" + name + "'");
+ }
+ return;
+ }
+
+ throw error("Unexpected character in TriG document: '" + (char) c + "'");
+ }
+
+ /**
+ * After reading a resource at top level, determines whether it is a
+ * named-graph label (followed by {@code {}) or a triple subject.
+ */
+ private void labelOrTriples(Resource res) throws IOException {
+ if (peek() == '{') {
+ wrappedGraph(res);
+ } else {
+ predicateObjectList(res);
+ ws();
+ if (peek() == '.') take();
+ }
+ }
+
+ /** Handles {@code GRAPH \{ ... \}}. */
+ private void trigGraphBlock() throws IOException {
+ int c = peek();
+ Resource graphLabel;
+ if (c == '<') {
+ graphLabel = intern(iriRefContent());
+ } else if (c == '_') {
+ graphLabel = blankNodeLabel();
+ } else if (c == '[') {
+ // GRAPH [] { ... } — anonymous blank node as graph label
+ expect('[');
+ ws();
+ expect(']');
+ graphLabel = factory.createBNode();
+ } else if (c == ':') {
+ take();
+ graphLabel = intern(lookupPrefix("") + readLocalPart());
+ } else if (isAsciiAlpha(c) || unicodeNameStart(c)) {
+ String pfx = readName();
+ expect(':');
+ graphLabel = intern(lookupPrefix(pfx) + readLocalPart());
+ } else {
+ throw error("Expected graph label IRI or blank node after GRAPH keyword");
+ }
+ ws();
+ wrappedGraph(graphLabel);
+ }
+
+ /**
+ * Parses {@code \{ items* \}} and routes all contained statements to
+ * {@code graphLabel} (or the default graph if {@code null}).
+ */
+ private void wrappedGraph(Resource graphLabel) throws IOException {
+ expect('{');
+ Resource saved = currentGraph;
+ currentGraph = graphLabel;
+ try {
+ ws();
+ while (peek() != '}' && peek() != -1) {
+ checkInterrupted();
+ graphBlockItem();
+ ws();
+ }
+ } finally {
+ currentGraph = saved;
+ }
+ expect('}');
+ }
+
+ /** Parses one item inside a TriG graph block. */
+ private void graphBlockItem() throws IOException {
+ int c = peek();
+
+ // Blank node property list as subject
+ if (c == '[') {
+ Resource subj = blankNodePropertyList();
+ ws();
+ if (peek() != '.' && peek() != '}') { predicateObjectList(subj); ws(); }
+ if (peek() == '.') take();
+ return;
+ }
+
+ // Collection as subject
+ if (c == '(') {
+ Resource subj = collection();
+ ws();
+ predicateObjectList(subj);
+ ws();
+ if (peek() == '.') take();
+ return;
+ }
+
+ // Regular subject
+ Resource subj = subject();
+ ws();
+ predicateObjectList(subj);
+ ws();
+ if (peek() == '.') take();
+ }
+
+ }
+}
diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleScanner.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleScanner.java
new file mode 100644
index 000000000..0b7c4c46e
--- /dev/null
+++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleScanner.java
@@ -0,0 +1,72 @@
+package fr.inria.corese.core.next.data.impl.io.parser.turtle;
+
+import fr.inria.corese.core.next.data.api.exception.ParsingException;
+import fr.inria.corese.core.next.data.api.factory.ValueFactory;
+import fr.inria.corese.core.next.data.api.io.format.RDFFormat;
+import fr.inria.corese.core.next.data.api.io.option.BaseIRIOptions;
+import fr.inria.corese.core.next.data.api.io.option.RDFParsingOptions;
+import fr.inria.corese.core.next.data.api.model.Model;
+import fr.inria.corese.core.next.data.impl.io.parser.support.TurtleTriGDocument;
+import fr.inria.corese.core.next.data.spi.io.parser.AbstractRDFParser;
+
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+
+/**
+ * Streaming RDF 1.1 Turtle parser without tokens, parse trees, or ANTLR.
+ *
+ *
Replaces the ANTLR-based {@link TurtleParser} with a character-level FSM that
+ * streams resolved terms directly to the model with minimal allocations.
+ * IRIs are cached with a bounded cache; blank nodes are scoped to one document.
+ * Memory is O(longest term + distinct blank node labels + IRI cache size).
+ *
+ *
Supports the full Turtle 1.1 grammar: {@code @prefix}/{@code PREFIX},
+ * {@code @base}/{@code BASE}, prefixed names, relative IRIs, blank node
+ * property lists, RDF collections, long string literals, and all numeric and
+ * boolean literal forms.
Using this method instead of {@code factory.createBNode(label)} ensures that blank-node
- * labels are scoped to this parse session: the same label always maps to the same blank node
- * within one document, but maps to a different blank node in every other document.
- *
- * @param label the raw blank-node label (without the {@code _:} prefix)
- * @return the document-scoped blank node for {@code label}
- */
- public BNode scopedBlankNode(String label) {
- return blankNodeScope.computeIfAbsent(label, k -> factory.createBNode());
- }
-
- /**
- * Extracts IRI from angle brackets and processes Unicode escape sequences.
- *
- * @param text raw IRI text including angle brackets
- * @return unescaped IRI string
- * @throws ParsingException if the IRI contains invalid characters after escape processing
- */
- public String extractAndUnescapeIRI(String text) {
- String iri = text.substring(1, text.length() - 1);
- return unescapeIRI(iri);
- }
-
- /**
- * Updates the base URI and registers it in prefix mappings.
- *
- * @param newBase new base URI to set
- */
- public void updateBaseURI(String newBase) {
- this.baseURI = resolveIRIAgainstBase(newBase);
- validateIRI(this.baseURI);
- }
-
- /**
- * Registers a namespace prefix with its corresponding IRI.
- *
- * @param prefix namespace prefix
- * @param iri namespace IRI
- */
- public void registerPrefix(String prefix, String iri) {
- String resolvedIRI = resolveIRIAgainstBase(iri);
- validateIRI(resolvedIRI);
- prefixHandler.setPrefix(prefix, resolvedIRI);
- model.setNamespace(prefix, resolvedIRI);
- }
-
- /**
- * Resolves an IRI reference to absolute form.
- * Handles prefixed names (QNames), relative IRIs, and absolute IRIs.
- *
- * @param raw raw IRI string
- * @return resolved absolute IRI
- * @throws ParsingException if the IRI cannot be resolved
- */
- @SuppressWarnings("java:S3776")
- public String resolveIRI(String raw) {
- try {
-
- raw = raw.trim();
-
- if (raw.equals(ParserConstants.RDF_TYPE_SHORTCUT)) {
- return RDF.type.getIRI().stringValue();
- }
-
- if (raw.equals(ParserConstants.COLON)) {
- String ns = prefixHandler.getNamespace(ParserConstants.EMPTY_STRING);
- if (ns == null) {
- throw new ParsingException(
- "Undeclared prefix: '' (empty prefix). " +
- "Use '@prefix : .' to declare it.");
- }
- return ns;
- }
-
- if (raw.startsWith(ParserConstants.IRI_START) && raw.endsWith(ParserConstants.IRI_END)) {
- String iri = raw.substring(1, raw.length() - 1);
- iri = unescapeIRI(iri);
- validateIRI(iri);
- return iri.isEmpty() ? getEffectiveBaseURI() : resolveIRIAgainstBase(iri);
- }
-
- if (raw.contains(ParserConstants.COLON)) {
- String[] parts = raw.split(ParserConstants.COLON, 2);
- String prefix = parts[0];
- String localName = parts[1];
-
- if (prefixHandler.hasPrefix(prefix)) {
- localName = unescapeIRI(localName);
- String ns = prefixHandler.getNamespace(prefix);
- if (ns != null) {
- String result = ns + localName;
- validateIRI(result);
- return result;
- }
- } else if (isAbsoluteIRI(raw)) {
- return raw;
- } else {
- if (prefix.isEmpty()) {
- throw new ParsingException(
- "Undeclared prefix: '' (empty prefix). " +
- "Use '@prefix : .' to declare it.");
- }
- throw new ParsingException("Undeclared prefix: " + prefix);
- }
- }
-
- return resolveIRIAgainstBase(raw);
-
- } catch (ParsingException e) {
- throw new ParsingException(e.getMessage(), e);
- }
- }
-
- /**
- * Resolves a relative IRI reference against the base URI using RFC 3986 algorithm.
- *
- * @param iri IRI reference to resolve
- * @return resolved absolute IRI
- */
- public String resolveIRIAgainstBase(String iri) {
- String effectiveBase = getEffectiveBaseURI();
-
- if (isAbsoluteIRI(iri)) {
- return iri;
- }
- return IRIUtils.resolveIRIAgainstBase(effectiveBase, iri);
- }
-
- /**
- * Returns the effective base URI, using a default if none is set.
- *
- * @return effective base URI
- */
- public String getEffectiveBaseURI() {
- String effective = (baseURI != null && !baseURI.isEmpty()) ? baseURI : ParserConstants.getDefaultBaseURI();
- return normalizeURI(effective);
- }
-
- /**
- * Processes Unicode escape sequences in IRIs.
- *
- * @param rawIri IRI string potentially containing escape sequences
- * @return unescaped IRI string
- * @throws IllegalArgumentException if escape sequences are malformed or contain surrogates
- */
- @SuppressWarnings({"java:S3776", "java:S127"})
- public String unescapeIRI(String rawIri) {
- StringBuilder sb = new StringBuilder();
- for (int i = 0; i < rawIri.length(); i++) {
- char c = rawIri.charAt(i);
- if (c == '\\' && i + 1 < rawIri.length()) {
- char next = rawIri.charAt(i + 1);
- if (next == 'u' || next == 'U') {
- int len = (next == 'u') ? 4 : 8;
- if (i + len + 1 <= rawIri.length()) {
- String hex = rawIri.substring(i + 2, i + 2 + len);
- int codePoint = Integer.parseInt(hex, 16);
- if (codePoint >= 0xD800 && codePoint <= 0xDFFF) {
- throw new IllegalArgumentException("Surrogates not allowed: \\u" + hex);
- }
- sb.appendCodePoint(codePoint);
- i += len + 1;
- } else {
- throw new IllegalArgumentException("Incomplete Unicode escape");
- }
- } else {
- sb.append(next);
- i++;
- }
- } else {
- sb.append(c);
- }
- }
- return sb.toString();
- }
-
- /**
- * Processes escape sequences in RDF string literals.
- * Handles standard escapes (\t, \n, \r, etc.) and Unicode escapes.
- *
- * @param text literal string including delimiters (quotes or triple-quotes)
- * @return unescaped string content
- * @throws IllegalArgumentException if escape sequences are malformed
- */
- @SuppressWarnings({"java:S3776", "java:S127"})
- public String unescapeString(String text) {
- if (text == null || text.length() < 2) {
- return text;
- }
-
- boolean isMultiline = text.startsWith(ParserConstants.TRIPLE_QUOTE) || text.startsWith(ParserConstants.TRIPLE_APOSTROPHE);
- String content = isMultiline ? text.substring(3, text.length() - 3) : text.substring(1, text.length() - 1);
-
- StringBuilder sb = new StringBuilder();
- for (int i = 0; i < content.length(); i++) {
- char c = content.charAt(i);
- if (c == '\\' && i + 1 < content.length()) {
- char next = content.charAt(i + 1);
- switch (next) {
- case 't':
- sb.append('\t');
- i++;
- break;
- case 'n':
- sb.append('\n');
- i++;
- break;
- case 'r':
- sb.append('\r');
- i++;
- break;
- case 'b':
- sb.append('\b');
- i++;
- break;
- case 'f':
- sb.append('\f');
- i++;
- break;
- case '"':
- sb.append('"');
- i++;
- break;
- case '\'':
- sb.append('\'');
- i++;
- break;
- case '\\':
- sb.append('\\');
- i++;
- break;
- case 'u', 'U':
- int len = (next == 'u') ? 4 : 8;
- if (i + len + 1 <= content.length()) {
- String hex = content.substring(i + 2, i + 2 + len);
- int codePoint = Integer.parseInt(hex, 16);
- if (codePoint >= 0xD800 && codePoint <= 0xDFFF) {
- throw new IllegalArgumentException("Invalid Unicode escape: Surrogate code points not allowed");
- }
- sb.appendCodePoint(codePoint);
- i += len + 1;
- } else {
- throw new IllegalArgumentException("Incomplete Unicode escape sequence");
- }
- break;
- default:
- sb.append(c).append(next);
- i++;
- break;
- }
- } else {
- sb.append(c);
- }
- }
- return sb.toString();
- }
-
- /**
- * Adds an RDF statement to the model with exception handling.
- * Subclasses may override to support named graphs or other extensions.
- *
- * @param subject statement subject
- * @param predicate statement predicate
- * @param object statement object
- * @throws ParsingException if the statement cannot be added
- */
- public void safeAddStatement(Resource subject, IRI predicate, Value object) {
- try {
- model.add(subject, predicate, object);
- } catch (Exception e) {
- throw new ParsingException("Failed to add statement: " + e.getMessage(), e);
- }
- }
-
- /**
- * Creates an RDF literal with optional language tag or datatype.
- *
- * @param label literal lexical value
- * @param langTag language tag (may be null)
- * @param datatypeIRI datatype IRI (may be null)
- * @return RDF literal
- */
- public Literal createLiteral(String label, String langTag, String datatypeIRI) {
- if (langTag != null) {
- return factory.createLiteral(label, langTag);
- }
- if (datatypeIRI != null) {
- return factory.createLiteral(label, factory.createIRI(datatypeIRI));
- }
- return factory.createLiteral(label);
- }
-
- /**
- * Creates a boolean literal.
- *
- * @param text boolean value as string ("true" or "false")
- * @return boolean literal with xsd:boolean datatype
- */
- public Literal createBooleanLiteral(String text) {
- return factory.createLiteral(text, XSDDatatype.BOOLEAN.getIRI());
- }
-
- /**
- * Creates a numeric literal with appropriate XSD datatype.
- *
- * @param text numeric value as string
- * @param type numeric type (INTEGER, DECIMAL, or DOUBLE)
- * @return numeric literal with corresponding XSD datatype
- */
- public Literal createNumericLiteral(String text, NumericType type) {
- return switch (type) {
- case DOUBLE -> factory.createLiteral(text, XSDDatatype.DOUBLE.getIRI());
- case DECIMAL -> factory.createLiteral(text, XSDDatatype.DECIMAL.getIRI());
- default -> factory.createLiteral(text, XSDDatatype.INTEGER.getIRI());
- };
- }
-
- /**
- * Validates that an IRI contains only valid characters after escape sequence processing.
- *
- * @param iri the IRI string to validate (after escape sequences have been processed)
- * @throws ParsingException if the IRI contains forbidden characters
- */
- private void validateIRI(String iri) throws ParsingException {
- if (iri == null || iri.isEmpty()) {
- return;
- }
-
- // Check each character in the IRI
- for (int i = 0; i < iri.length(); i++) {
- char c = iri.charAt(i);
-
- // Check for forbidden characters
- if (IRIUtils.isInvalidIRICharacter(c)) {
- String codePoint = String.format("U+%04X", (int) c);
- String charDesc = RdfText.describeCharacter(c);
- String displayIRI = RdfText.escapeForDisplay(iri);
-
- throw new ParsingException(
- "Invalid character in IRI: " + codePoint + " (" + charDesc + ") " +
- "at position " + i + ". " +
- "IRI after escape processing: " + displayIRI + ". " +
- "IRIs cannot contain space, control characters, or reserved characters."
- );
- }
- }
- }
-
- /**
- * Enumeration of numeric literal types corresponding to XSD datatypes.
- */
- public enum NumericType {
- /**
- * XSD integer type
- */
- INTEGER,
- /**
- * XSD decimal type
- */
- DECIMAL,
- /**
- * XSD double type
- */
- DOUBLE
- }
-}
diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGListener.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGListener.java
deleted file mode 100644
index a33fd9ea2..000000000
--- a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGListener.java
+++ /dev/null
@@ -1,568 +0,0 @@
-package fr.inria.corese.core.next.data.impl.io.parser.trig;
-
-import fr.inria.corese.core.next.data.api.term.*;
-import fr.inria.corese.core.next.data.api.model.*;
-import fr.inria.corese.core.next.data.api.factory.ValueFactory;
-import fr.inria.corese.core.next.data.api.io.option.IOOptions;
-import fr.inria.corese.core.next.data.api.io.option.BaseIRIOptions;
-import fr.inria.corese.core.next.data.api.vocabulary.RDF;
-import fr.inria.corese.core.next.data.api.exception.ParsingException;
-import fr.inria.corese.core.next.data.impl.io.parser.support.AbstractTurtleTriGListener;
-import fr.inria.corese.core.next.data.impl.io.parser.support.ParserConstants;
-import fr.inria.corese.core.next.generated.antlr.TriGBaseListener;
-import fr.inria.corese.core.next.generated.antlr.TriGParser;
-
-import java.util.List;
-
-/**
- * ANTLR listener for parsing TriG documents into RDF datasets.
- * and adds support for named graphs as specified in the TriG format.
- *
-
- */
-public class TriGListener extends TriGBaseListener {
-
- private final TriGListenerDelegate delegate;
- private boolean insideGraphBlock = false;
-
- /**
- * Constructs a TriG listener with explicit base URI.
- *
- * @param model RDF model to populate with parsed quads
- * @param factory factory for creating RDF terms
- * @param options I/O options (unused in this constructor)
- * @param baseURI base URI for resolving relative references
- */
- public TriGListener(Model model, ValueFactory factory, IOOptions options, String baseURI) {
- this.delegate = new TriGListenerDelegate(model, factory, baseURI);
- }
-
- /**
- * Constructs a TriG listener extracting base URI from options.
- *
- * @param model RDF model to populate with parsed quads
- * @param factory factory for creating RDF terms
- * @param options I/O options potentially containing base URI
- */
- public TriGListener(Model model, ValueFactory factory, IOOptions options) {
- String baseURI;
- if (options instanceof BaseIRIOptions baseIRIOptions) {
- baseURI = baseIRIOptions.getBaseIRI() != null ? baseIRIOptions.getBaseIRI() : ParserConstants.EMPTY_STRING;
- } else {
- baseURI = ParserConstants.EMPTY_STRING;
- }
- this.delegate = new TriGListenerDelegate(model, factory, baseURI);
- }
-
- /**
- * Handles directive by updating the base URI for relative IRI resolution.
- *
- * @param ctx base directive context
- */
- @Override
- public void exitBase(TriGParser.BaseContext ctx) {
- String newBase = delegate.extractAndUnescapeIRI(ctx.IRIREF().getText());
- delegate.updateBaseURI(newBase);
- }
-
- /**
- * Handles {@code @prefix} directive by registering a namespace prefix.
- *
- * @param ctx prefix directive context
- */
- @Override
- public void exitPrefixID(TriGParser.PrefixIDContext ctx) {
- String prefix = ctx.PNAME_NS().getText();
- prefix = prefix.substring(0, prefix.length() - 1);
- String iri = delegate.extractAndUnescapeIRI(ctx.IRIREF().getText());
- delegate.registerPrefix(prefix, iri);
- }
-
- /**
- * Handles SPARQL-style {@code BASE} directive by updating the base URI.
- *
- * @param ctx SPARQL base directive context
- */
- @Override
- public void exitSparqlBase(TriGParser.SparqlBaseContext ctx) {
- String newBase = delegate.extractAndUnescapeIRI(ctx.IRIREF().getText());
- delegate.updateBaseURI(newBase);
- }
-
- /**
- * Handles SPARQL-style {@code PREFIX} directive by registering a namespace prefix.
- *
- * @param ctx SPARQL prefix directive context
- */
- @Override
- public void exitSparqlPrefix(TriGParser.SparqlPrefixContext ctx) {
- String prefix = ctx.PNAME_NS().getText();
- prefix = prefix.substring(0, prefix.length() - 1);
- String iri = delegate.extractAndUnescapeIRI(ctx.IRIREF().getText());
- delegate.registerPrefix(prefix, iri);
- }
-
- /**
- * Handles entry into a graph block, setting the current graph context.
- * If a graph label is present, it becomes the target graph for subsequent statements.
- *
- * @param ctx block context
- */
- @Override
- public void enterBlock(TriGParser.BlockContext ctx) {
- if (ctx.Graph_w() != null && ctx.labelOrSubject() != null) {
- delegate.setCurrentGraph(extractLabelOrSubject(ctx.labelOrSubject()));
- } else {
- delegate.setCurrentGraph(null);
- }
- }
-
- /**
- * Handles exit from a graph block, clearing the current graph context.
- *
- * @param ctx block context
- */
- @Override
- public void exitBlock(TriGParser.BlockContext ctx) {
- delegate.setCurrentGraph(null);
- }
-
- /**
- * Marks entry into a wrapped graph block
- * Used for validating standalone collection syntax.
- *
- * @param ctx wrapped graph context
- */
- @Override
- public void enterWrappedGraph(TriGParser.WrappedGraphContext ctx) {
- insideGraphBlock = true;
- }
-
- /**
- * Marks exit from a wrapped graph block.
- *
- * @param ctx wrapped graph context
- */
- @Override
- public void exitWrappedGraph(TriGParser.WrappedGraphContext ctx) {
- insideGraphBlock = false;
- }
-
- /**
- * Handles triples or graph declarations (disambiguated by following syntax).
- * Either processes triples with an explicit subject or sets up a named graph.
- *
- * @param ctx triples or graph context
- */
- @Override
- public void enterTriplesOrGraph(TriGParser.TriplesOrGraphContext ctx) {
- if (ctx.labelOrSubject() != null) {
- Resource resource = extractLabelOrSubject(ctx.labelOrSubject());
- if (ctx.predicateObjectList() != null) {
- delegate.setCurrentSubject(resource);
- processPredicateObjectList(ctx.predicateObjectList());
- } else if (ctx.wrappedGraph() != null) {
- delegate.setCurrentGraph(resource);
- }
- }
- }
-
- /**
- * Handles standard triple declarations starting with a subject or blank node property list.
- * Subject context is saved and restored to handle nested structures.
- *
- * @param ctx triples context
- */
- @Override
- public void enterTriples(TriGParser.TriplesContext ctx) {
- Resource savedSubject = delegate.getCurrentSubject();
- try {
- if (ctx.subject() != null) {
- delegate.setCurrentSubject(extractSubject(ctx.subject()));
- } else if (ctx.blankNodePropertyList() != null) {
- delegate.setCurrentSubject(processBlankNodePropertyList(ctx.blankNodePropertyList()));
- }
-
- if (ctx.predicateObjectList() != null) {
- processPredicateObjectList(ctx.predicateObjectList());
- }
- } finally {
- delegate.setCurrentSubject(savedSubject);
- }
- }
-
- /**
- * Handles alternative triple forms starting with blank node property lists or collections.
- * Validates that standalone collections only appear within graph blocks.
- * Subject context is saved and restored to handle nested structures.
- *
- * @param ctx triples2 context
- */
- @Override
- public void enterTriples2(TriGParser.Triples2Context ctx) {
- Resource savedSubject = delegate.getCurrentSubject();
-
- if (ctx.collection() != null && ctx.predicateObjectList() == null) {
- validateStandaloneCollection(ctx);
- }
-
- try {
- if (ctx.blankNodePropertyList() != null) {
- delegate.setCurrentSubject(processBlankNodePropertyList(ctx.blankNodePropertyList()));
- } else if (ctx.collection() != null) {
- delegate.setCurrentSubject(processCollection(ctx.collection()));
- }
-
- if (ctx.predicateObjectList() != null) {
- processPredicateObjectList(ctx.predicateObjectList());
- }
- } finally {
- delegate.setCurrentSubject(savedSubject);
- }
- }
-
- /**
- * Validates that standalone collections only appear within graph blocks.
- * TriG syntax requires collections without predicates to be inside braces.
- *
- * @param ctx triples2 context containing a potential standalone collection
- * @throws ParsingException if a standalone collection appears outside graph blocks
- */
- private void validateStandaloneCollection(TriGParser.Triples2Context ctx) {
- if (!insideGraphBlock) {
- List objects = ctx.collection().object();
- if (objects.isEmpty()) {
- throw new ParsingException("Free-standing list of zero-elements outside {} : bad syntax");
- } else {
- throw new ParsingException("Free-standing list outside {} : bad syntax");
- }
- }
- }
-
- /**
- * Processes a blank node property list
- * Creates a fresh blank node and parses its property list.
- *
- * @param ctx blank node property list context
- * @return created blank node resource
- */
- private Resource processBlankNodePropertyList(TriGParser.BlankNodePropertyListContext ctx) {
- Resource bnode = delegate.createBNode();
- Resource savedSubject = delegate.getCurrentSubject();
- IRI savedPredicate = delegate.getCurrentPredicate();
-
- try {
- delegate.setCurrentSubject(bnode);
- if (ctx.predicateObjectList() != null) {
- processPredicateObjectList(ctx.predicateObjectList());
- }
- } finally {
- delegate.setCurrentSubject(savedSubject);
- delegate.setCurrentPredicate(savedPredicate);
- }
-
- return bnode;
- }
-
- /**
- * Processes an RDF collection
- * Constructs the linked list structure
- *
- * @param ctx collection context
- * @return head of the list (blank node) or {@code rdf:nil} for empty lists
- */
- private Resource processCollection(TriGParser.CollectionContext ctx) {
- List objects = ctx.object();
-
- if (objects.isEmpty()) {
- return delegate.createIRI(RDF.nil.getIRI().stringValue());
- }
-
- Resource head = delegate.createBNode();
- Resource current = head;
- Resource savedSubject = delegate.getCurrentSubject();
- IRI savedPredicate = delegate.getCurrentPredicate();
-
- try {
- IRI firstPredicate = delegate.createIRI(RDF.first.getIRI().stringValue());
- IRI restPredicate = delegate.createIRI(RDF.rest.getIRI().stringValue());
- Value nilValue = delegate.createIRI(RDF.nil.getIRI().stringValue());
-
- for (int i = 0; i < objects.size(); i++) {
- Value object = extractObject(objects.get(i));
- delegate.addStatement(current, firstPredicate, object);
-
- if (i == objects.size() - 1) {
- delegate.addStatement(current, restPredicate, nilValue);
- } else {
- Resource next = delegate.createBNode();
- delegate.addStatement(current, restPredicate, next);
- current = next;
- }
- }
- } finally {
- delegate.setCurrentSubject(savedSubject);
- delegate.setCurrentPredicate(savedPredicate);
- }
-
- return head;
- }
-
- /**
- * Processes a predicate-object list, generating statements for each predicate-object pair.
- *
- * @param ctx predicate-object list context
- */
- private void processPredicateObjectList(TriGParser.PredicateObjectListContext ctx) {
- for (int i = 0; i < ctx.verb().size(); i++) {
- TriGParser.VerbContext verb = ctx.verb(i);
- TriGParser.ObjectListContext objectList = ctx.objectList(i);
-
- IRI savedPredicate = delegate.getCurrentPredicate();
- try {
- delegate.setCurrentPredicate(extractVerb(verb));
-
- if (objectList != null) {
- for (TriGParser.ObjectContext objectCtx : objectList.object()) {
- Value object = extractObject(objectCtx);
- delegate.addStatement(object);
- }
- }
- } finally {
- delegate.setCurrentPredicate(savedPredicate);
- }
- }
- }
-
- /**
- * Extracts an RDF value from an object context.
- *
- * @param ctx object context
- * @return extracted RDF value (IRI, blank node, or literal)
- * @throws ParsingException if the object type is unsupported
- */
- private Value extractObject(TriGParser.ObjectContext ctx) {
- if (ctx.iri() != null) {
- return delegate.createIRI(delegate.resolveIRI(ctx.iri().getText()));
- }
- if (ctx.blank() != null) {
- return extractBlank(ctx.blank());
- }
- if (ctx.literal() != null) {
- return extractLiteral(ctx.literal());
- }
- if (ctx.blankNodePropertyList() != null) {
- return processBlankNodePropertyList(ctx.blankNodePropertyList());
- }
- throw new ParsingException("Unsupported object: " + ctx.getText());
- }
-
- /**
- * Extracts a blank node resource from a blank node context.
- *
- * @param ctx blank node context
- * @return blank node resource or collection head
- * @throws ParsingException if the blank node format is unsupported
- */
- private Resource extractBlank(TriGParser.BlankContext ctx) {
- TriGParser.BlankNodeContext node = ctx.blankNode();
- if (node != null) {
- if (node.BLANK_NODE_LABEL() != null) {
- return delegate.createBNode(node.BLANK_NODE_LABEL().getText().substring(2));
- }
- if (node.ANON() != null) {
- return delegate.createBNode();
- }
- }
-
- TriGParser.CollectionContext collection = ctx.collection();
- if (collection != null) {
- return processCollection(collection);
- }
-
- throw new ParsingException("Unsupported blank node: " + ctx.getText());
- }
-
- /**
- * Extracts a subject resource from a subject context.
- *
- * @param ctx subject context
- * @return subject resource (IRI, blank node, or collection head)
- * @throws ParsingException if the subject type is unsupported
- */
- private Resource extractSubject(TriGParser.SubjectContext ctx) {
- if (ctx.iri() != null) {
- return delegate.createIRI(delegate.resolveIRI(ctx.iri().getText()));
- }
- if (ctx.blank() != null) {
- if (ctx.blank().blankNode() != null) {
- TriGParser.BlankNodeContext node = ctx.blank().blankNode();
- if (node.BLANK_NODE_LABEL() != null) {
- return delegate.createBNode(node.BLANK_NODE_LABEL().getText().substring(2));
- }
- if (node.ANON() != null) {
- return delegate.createBNode();
- }
- } else if (ctx.blank().collection() != null) {
- return processCollection(ctx.blank().collection());
- }
- }
- throw new ParsingException("Unsupported subject: " + ctx.getText());
- }
-
- /**
- * Extracts a graph label or subject resource.
- *
- * @param ctx label or subject context
- * @return resource serving as graph name or statement subject
- * @throws ParsingException if IRI resolution fails or type is unsupported
- */
- private Resource extractLabelOrSubject(TriGParser.LabelOrSubjectContext ctx) {
- if (ctx.iri() != null) {
- String iriText = ctx.iri().getText();
- try {
- return delegate.createIRI(delegate.resolveIRI(iriText));
- } catch (Exception e) {
- throw new ParsingException("Failed to resolve IRI: " + iriText, e);
- }
- }
- if (ctx.blankNode() != null) {
- if (ctx.blankNode().BLANK_NODE_LABEL() != null) {
- return delegate.createBNode(ctx.blankNode().BLANK_NODE_LABEL().getText().substring(2));
- }
- if (ctx.blankNode().ANON() != null) {
- return delegate.createBNode();
- }
- }
- throw new ParsingException("Unsupported label or subject: " + ctx.getText());
- }
-
- /**
- * Extracts a predicate IRI from a verb context.
- * Handles the special case of {@code a} as shorthand for {@code rdf:type}.
- *
- * @param ctx verb context
- * @return predicate IRI
- */
- private IRI extractVerb(TriGParser.VerbContext ctx) {
- String verbText = ctx.getText();
- if (verbText.equals(ParserConstants.RDF_TYPE_SHORTCUT)) {
- return delegate.createIRI(RDF.type.getIRI().stringValue());
- }
- return delegate.createIRI(delegate.resolveIRI(verbText));
- }
-
- /**
- * Extracts an RDF literal from a literal context.
- * Handles plain, language-tagged, typed, boolean, and numeric literals.
- *
- * @param ctx literal context
- * @return RDF literal value
- * @throws ParsingException if the literal type is unsupported
- */
- private Literal extractLiteral(TriGParser.LiteralContext ctx) {
- if (ctx.rDFLiteral() != null) {
- String label = delegate.unescapeString(ctx.rDFLiteral().string().getText());
- if (ctx.rDFLiteral().LANGTAG() != null) {
- return delegate.createLiteral(label, ctx.rDFLiteral().LANGTAG().getText().substring(1), null);
- }
- if (ctx.rDFLiteral().iri() != null) {
- return delegate.createLiteral(label, null, delegate.resolveIRI(ctx.rDFLiteral().iri().getText()));
- }
- return delegate.createLiteral(label, null, null);
- }
- if (ctx.BooleanLiteral() != null) {
- return delegate.createBooleanLiteral(ctx.BooleanLiteral().getText());
- }
- if (ctx.numericLiteral() != null) {
- String numericText = ctx.numericLiteral().getText();
-
- if (ctx.numericLiteral().DOUBLE() != null) {
- return delegate.createNumericLiteral(numericText, AbstractTurtleTriGListener.NumericType.DOUBLE);
- } else if (ctx.numericLiteral().DECIMAL() != null) {
- return delegate.createNumericLiteral(numericText, AbstractTurtleTriGListener.NumericType.DECIMAL);
- } else {
- return delegate.createNumericLiteral(numericText, AbstractTurtleTriGListener.NumericType.INTEGER);
- }
- }
- throw new ParsingException("Unsupported literal: " + ctx.getText());
- }
-
- /**
- * Delegate extending {@link AbstractTurtleTriGListener} with TriG-specific named graph support.
- * Overrides statement addition to handle quads (subject, predicate, object, graph).
- */
- private static class TriGListenerDelegate extends AbstractTurtleTriGListener {
- private Resource currentGraph;
-
- public TriGListenerDelegate(Model model, ValueFactory factory, String baseURI) {
- super(model, factory, baseURI);
- }
-
- public Resource getCurrentSubject() {
- return currentSubject;
- }
-
- public void setCurrentSubject(Resource subject) {
- this.currentSubject = subject;
- }
-
- public IRI getCurrentPredicate() {
- return currentPredicate;
- }
-
- public void setCurrentPredicate(IRI predicate) {
- this.currentPredicate = predicate;
- }
-
-
- public void setCurrentGraph(Resource graph) {
- this.currentGraph = graph;
- }
-
- public void addStatement(Value object) {
- safeAddStatement(currentSubject, currentPredicate, object);
- }
-
- public void addStatement(Resource subject, IRI predicate, Value object) {
- safeAddStatement(subject, predicate, object);
- }
-
- public Resource createBNode() {
- return factory.createBNode();
- }
-
- public Resource createBNode(String id) {
- return scopedBlankNode(id);
- }
-
- public IRI createIRI(String iri) {
- return factory.createIRI(iri);
- }
-
- /**
- * Adds a quad to the model with fallback to default graph on failure.
- *
- * @param subject statement subject
- * @param predicate statement predicate
- * @param object statement object
- * @throws ParsingException if statement cannot be added to any graph
- */
- @Override
- public void safeAddStatement(Resource subject, IRI predicate, Value object) {
- try {
- model.add(subject, predicate, object, currentGraph);
- } catch (Exception e) {
- if (currentGraph != null) {
- try {
- model.add(subject, predicate, object, (Resource) null);
- } catch (Exception e2) {
- throw new ParsingException("Failed to add statement: " + e.getMessage(), e);
- }
- } else {
- throw new ParsingException("Failed to add statement: " + e.getMessage(), e);
- }
- }
- }
- }
-}
diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGParser.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGParser.java
deleted file mode 100644
index 9eb62e915..000000000
--- a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGParser.java
+++ /dev/null
@@ -1,161 +0,0 @@
-package fr.inria.corese.core.next.data.impl.io.parser.trig;
-
-import fr.inria.corese.core.next.data.api.model.Model;
-import fr.inria.corese.core.next.data.api.factory.ValueFactory;
-import fr.inria.corese.core.next.data.api.io.format.RDFFormat;
-import fr.inria.corese.core.next.data.spi.io.parser.AbstractRDFParser;
-import fr.inria.corese.core.next.data.api.io.option.RDFParsingOptions;
-import fr.inria.corese.core.next.data.api.exception.ParsingException;
-import fr.inria.corese.core.next.generated.antlr.TriGLexer;
-import org.antlr.v4.runtime.BaseErrorListener;
-import org.antlr.v4.runtime.CharStream;
-import org.antlr.v4.runtime.CharStreams;
-import org.antlr.v4.runtime.CommonTokenStream;
-import org.antlr.v4.runtime.RecognitionException;
-import org.antlr.v4.runtime.Recognizer;
-import org.antlr.v4.runtime.Token;
-import org.antlr.v4.runtime.tree.ParseTree;
-import org.antlr.v4.runtime.tree.ParseTreeWalker;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * A parser for the TriG format based on ANTLR4.
- * It uses an ANTLR grammar to tokenize and parse TriG documents,
- * then a listener to build the RDF model.
- */
-public class TriGParser extends AbstractRDFParser {
-
- /**
- * Constructs a TriGParser with default options.
- *
- * @param model The RDF model to be populated.
- * @param factory The value factory for creating RDF resources.
- */
- public TriGParser(Model model, ValueFactory factory) {
- this(model, factory, new TriGParserOptions.Builder().build());
- }
-
- /**
- * Constructs a TriGParser with configuration options.
- *
- * @param model The RDF model to be populated.
- * @param factory The value factory for creating RDF resources.
- * @param config Configuration options for parsing.
- */
- public TriGParser(Model model, ValueFactory factory, RDFParsingOptions config) {
- super(model, factory, config);
- }
-
- @Override
- public RDFFormat getRDFFormat() {
- return RDFFormat.TRIG;
- }
-
- @Override
- public void parse(InputStream in, String baseURI) throws ParsingException {
- parse(new InputStreamReader(in, StandardCharsets.UTF_8), baseURI);
- }
-
- /**
- * Parses TriG data from a {@link Reader} using ANTLR4.
- *
- * @param reader The {@link Reader} to read the RDF data.
- * @param baseURI The base URI.
- * @throws ParsingException if a parsing or I/O error occurs.
- */
- @Override
- public void parse(Reader reader, String baseURI) throws ParsingException {
- try {
- CharStream charStream = CharStreams.fromReader(reader);
- TriGLexer triGLexer = new TriGLexer(charStream);
-
- TrigErrorListener trigErrorListener = new TrigErrorListener();
- triGLexer.removeErrorListeners();
- triGLexer.addErrorListener(trigErrorListener);
-
- CommonTokenStream tokens = new CommonTokenStream(triGLexer);
- fr.inria.corese.core.next.generated.antlr.TriGParser triGParser = new fr.inria.corese.core.next.generated.antlr.TriGParser(tokens);
-
-
- triGParser.removeErrorListeners();
- triGParser.addErrorListener(trigErrorListener);
-
- ParseTreeWalker walker = new ParseTreeWalker();
- ParseTree tree = triGParser.trigDoc();
-
- if (trigErrorListener.hasErrors()) {
- throw new ParsingException("Syntax error in TriG document: " + trigErrorListener.getErrorMessage());
- }
- RDFParsingOptions optionsWithBaseURI = new TriGParserOptions.Builder()
- .baseIRI(baseURI)
- .build();
- TriGListener listener = new TriGListener(getModel(), getValueFactory(), optionsWithBaseURI);
- walker.walk(listener, tree);
-
- } catch (ParsingException e) {
- throw e;
- } catch (IOException e) {
- throw new ParsingException("Failed to parse TriG RDF: " + e.getMessage(), e);
- } catch (Exception e) {
- throw new ParsingException("Unexpected error during TriG parsing: " + e.getMessage(), e);
- }
- }
-
-
- /**
- * A custom error listener to collect errors from the lexer and parser.
- */
- private static class TrigErrorListener extends BaseErrorListener {
- private final List errors = new ArrayList<>();
-
- /**
- * Records syntax errors generated by ANTLR.
- *
- * @param recognizer The recognizer that detected the error.
- * @param offendingSymbol The symbol that caused the error.
- * @param line The line number where the error occurred.
- * @param charPositionInLine The character position on the line.
- * @param msg The error message.
- * @param e The recognition exception.
- */
- @Override
- public void syntaxError(Recognizer, ?> recognizer, Object offendingSymbol,
- int line, int charPositionInLine, String msg, RecognitionException e) {
- if (msg != null && (msg.contains("token recognition error") || msg.contains("mismatched input"))
- && offendingSymbol instanceof Token token) {
- String tokenText = token.getText();
- if (msg.contains("token recognition error") && tokenText != null && tokenText.contains("\"")) {
- msg = "Invalid string literal - possibly unterminated or contains invalid escape sequence: " + msg;
- }
- }
-
- String error = "line " + line + ":" + charPositionInLine + " " + msg;
- errors.add(error);
- }
-
- /**
- * Checks if parsing errors have been found.
- *
- * @return `true` if the error list is not empty, otherwise `false`.
- */
- public boolean hasErrors() {
- return !errors.isEmpty();
- }
-
- /**
- * Returns a formatted error message containing all found errors.
- *
- * @return A {@link String} containing the error messages.
- */
- public String getErrorMessage() {
- return String.join("; ", errors);
- }
- }
-}
diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleListener.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleListener.java
deleted file mode 100644
index db2d63418..000000000
--- a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleListener.java
+++ /dev/null
@@ -1,347 +0,0 @@
-package fr.inria.corese.core.next.data.impl.io.parser.turtle;
-
-import fr.inria.corese.core.next.data.api.term.*;
-import fr.inria.corese.core.next.data.api.model.*;
-import fr.inria.corese.core.next.data.api.factory.ValueFactory;
-import fr.inria.corese.core.next.data.api.io.option.IOOptions;
-import fr.inria.corese.core.next.data.api.io.option.BaseIRIOptions;
-import fr.inria.corese.core.next.data.api.vocabulary.RDF;
-import fr.inria.corese.core.next.data.api.exception.ParsingException;
-import fr.inria.corese.core.next.data.impl.io.parser.support.AbstractTurtleTriGListener;
-import fr.inria.corese.core.next.data.impl.io.parser.support.ParserConstants;
-import fr.inria.corese.core.next.generated.antlr.TurtleBaseListener;
-import fr.inria.corese.core.next.generated.antlr.TurtleParser;
-
-import java.util.List;
-
-/**
- * ANTLR listener for parsing Turtle documents into RDF graphs.
- * Extends {@link AbstractTurtleTriGListener} for common RDF parsing functionality.
- *
- */
-public class TurtleListener extends TurtleBaseListener {
-
- private final AbstractTurtleTriGListener delegate;
-
- /**
- * Constructs a Turtle listener extracting base URI from options.
- *
- * @param model RDF model to populate with parsed triples
- * @param factory factory for creating RDF terms
- * @param options I/O options potentially containing base URI
- */
- public TurtleListener(Model model, ValueFactory factory, IOOptions options) {
- String baseURI = null;
-
- if (options instanceof BaseIRIOptions baseIRIOptions) {
- baseURI = baseIRIOptions.getBaseIRI();
- }
-
- if (baseURI == null || baseURI.isEmpty()) {
- baseURI = ParserConstants.EMPTY_STRING;
- }
-
- this.delegate = new TurtleListenerDelegate(model, factory, baseURI);
- }
-
- /**
- * Handles {@code @prefix} directive by registering a namespace prefix.
- *
- * @param ctx prefix directive context
- */
- @Override
- public void exitPrefixID(TurtleParser.PrefixIDContext ctx) {
- String prefix = ctx.PNAME_NS().getText();
- prefix = prefix.substring(0, prefix.length() - 1);
- String iri = delegate.extractAndUnescapeIRI(ctx.IRIREF().getText());
- delegate.registerPrefix(prefix, iri);
- }
-
- /**
- * Handles directive by updating the base URI for relative IRI resolution.
- *
- * @param ctx base directive context
- */
- @Override
- public void exitBase(TurtleParser.BaseContext ctx) {
- if (ctx.IRIREF() != null) {
- String newBase = delegate.extractAndUnescapeIRI(ctx.IRIREF().getText());
- delegate.updateBaseURI(newBase);
- }
- }
-
- /**
- * Handles SPARQL-style {@code BASE} directive by updating the base URI.
- *
- * @param ctx SPARQL base directive context
- */
- @Override
- public void exitSparqlBase(TurtleParser.SparqlBaseContext ctx) {
- String newBase = delegate.extractAndUnescapeIRI(ctx.IRIREF().getText());
- delegate.updateBaseURI(newBase);
- }
-
- /**
- * Handles SPARQL-style {@code PREFIX} directive by registering a namespace prefix.
- *
- * @param ctx SPARQL prefix directive context
- */
- @Override
- public void exitSparqlPrefix(TurtleParser.SparqlPrefixContext ctx) {
- String prefix = ctx.PNAME_NS().getText();
- prefix = prefix.substring(0, prefix.length() - 1);
- String iri = delegate.extractAndUnescapeIRI(ctx.IRIREF().getText());
- delegate.registerPrefix(prefix, iri);
- }
-
- /**
- * Handles triple declarations starting with a subject or blank node property list.
- * Processes the subject and its associated predicate-object list.
- *
- * @param ctx triples context
- * @throws ParsingException if the subject is missing or processing fails
- */
- @Override
- public void enterTriples(TurtleParser.TriplesContext ctx) {
- try {
- if (ctx.subject() != null) {
- delegate.currentSubject = extractSubject(ctx.subject());
- if (ctx.predicateObjectList() != null) {
- processPredicateObjectList(ctx.predicateObjectList());
- }
- } else if (ctx.blankNodePropertyList() != null) {
- delegate.currentSubject = processBlankNodePropertyList(ctx.blankNodePropertyList());
- if (ctx.predicateObjectList() != null) {
- processPredicateObjectList(ctx.predicateObjectList());
- }
- } else {
- throw new ParsingException("Missing subject in triple.");
- }
- } catch (ParsingException e) {
- throw e;
- } catch (Exception e) {
- throw new ParsingException("Error processing triples: " + e.getMessage(), e);
- }
- }
-
- /**
- * Processes a predicate-object list, generating triples for each predicate-object pair.
- *
- * @param ctx predicate-object list context
- */
- private void processPredicateObjectList(TurtleParser.PredicateObjectListContext ctx) {
- for (int i = 0; i < ctx.verb().size(); i++) {
- TurtleParser.VerbContext verb = ctx.verb(i);
- TurtleParser.ObjectListContext objectList = ctx.objectList(i);
-
- delegate.currentPredicate = extractVerb(verb);
-
- if (objectList != null) {
- for (TurtleParser.Object_Context objectCtx : objectList.object_()) {
- Value object = extractObject(objectCtx);
- delegate.safeAddStatement(delegate.currentSubject, delegate.currentPredicate, object);
- }
- }
- }
- }
-
- /**
- * Extracts an RDF value from an object context.
- *
- * @param ctx object context
- * @return extracted RDF value (IRI, blank node, or literal)
- * @throws ParsingException if the object type is unsupported or IRI cannot be resolved
- */
- private Value extractObject(TurtleParser.Object_Context ctx) {
- if (ctx.iri() != null) {
- String resolvedIRI = delegate.resolveIRI(ctx.iri().getText());
- if (resolvedIRI.isEmpty()) {
- throw new ParsingException("Cannot resolve object IRI: " + ctx.iri().getText());
- }
- return delegate.factory.createIRI(resolvedIRI);
- }
-
- if (ctx.BlankNode() != null) {
- String blankNodeText = ctx.BlankNode().getText();
- if (blankNodeText.startsWith(ParserConstants.BLANK_NODE_PREFIX)) {
- return delegate.scopedBlankNode(blankNodeText.substring(2));
- } else if (blankNodeText.equals(ParserConstants.EMPTY_SQUARE_BRACKET)) {
- return delegate.factory.createBNode();
- } else {
- throw new ParsingException("Unsupported blank node format: " + blankNodeText);
- }
- }
-
- if (ctx.literal() != null) {
- return extractLiteral(ctx.literal());
- }
-
- if (ctx.blankNodePropertyList() != null) {
- return processBlankNodePropertyList(ctx.blankNodePropertyList());
- }
-
- if (ctx.collection() != null) {
- return processCollection(ctx.collection());
- }
-
- throw new ParsingException("Unsupported object type: " + (ctx.getText() != null ? ctx.getText() : "null"));
- }
-
- /**
- * Extracts a subject resource from a subject context.
- *
- * @param ctx subject context
- * @return subject resource (IRI, blank node, or collection head)
- * @throws ParsingException if the subject type is unsupported
- */
- private Resource extractSubject(TurtleParser.SubjectContext ctx) {
- if (ctx.iri() != null) {
- return delegate.factory.createIRI(delegate.resolveIRI(ctx.iri().getText()));
- }
- if (ctx.BlankNode() != null) {
- String blankNodeText = ctx.BlankNode().getText();
- if (blankNodeText.startsWith(ParserConstants.BLANK_NODE_PREFIX)) {
- return delegate.scopedBlankNode(blankNodeText.substring(2));
- } else if (blankNodeText.equals(ParserConstants.EMPTY_SQUARE_BRACKET)) {
- return delegate.factory.createBNode();
- } else {
- throw new ParsingException("Unsupported blank node format: " + blankNodeText);
- }
- }
- if (ctx.collection() != null) {
- return processCollection(ctx.collection());
- }
- throw new ParsingException("Unsupported subject type: " + ctx.getText());
- }
-
- /**
- * Processes a blank node property list
- * Creates a fresh blank node and parses its property list.
- * Subject and predicate contexts are saved and restored to handle nested structures.
- *
- * @param ctx blank node property list context
- * @return created blank node resource
- */
- private Resource processBlankNodePropertyList(TurtleParser.BlankNodePropertyListContext ctx) {
- Resource bnode = delegate.factory.createBNode();
- Resource savedSubject = delegate.currentSubject;
- IRI savedPredicate = delegate.currentPredicate;
-
- try {
- delegate.currentSubject = bnode;
- if (ctx.predicateObjectList() != null) {
- processPredicateObjectList(ctx.predicateObjectList());
- }
- } finally {
- delegate.currentSubject = savedSubject;
- delegate.currentPredicate = savedPredicate;
- }
-
- return bnode;
- }
-
- /**
- * Processes an RDF collection
- * Constructs the linked list structure using
- *
- * @param ctx collection context
- * @return head of the list (blank node) or {@code rdf:nil} for empty lists
- */
- private Resource processCollection(TurtleParser.CollectionContext ctx) {
- List objects = ctx.object_();
-
- if (objects.isEmpty()) {
- return delegate.factory.createIRI(RDF.nil.getIRI().stringValue());
- }
-
- Resource head = delegate.factory.createBNode();
- Resource current = head;
- IRI firstPredicate = delegate.factory.createIRI(RDF.first.getIRI().stringValue());
- IRI restPredicate = delegate.factory.createIRI(RDF.rest.getIRI().stringValue());
- Value nilValue = delegate.factory.createIRI(RDF.nil.getIRI().stringValue());
-
- for (int i = 0; i < objects.size(); i++) {
- Value object = extractObject(objects.get(i));
- delegate.safeAddStatement(current, firstPredicate, object);
-
- if (i == objects.size() - 1) {
- delegate.safeAddStatement(current, restPredicate, nilValue);
- } else {
- Resource next = delegate.factory.createBNode();
- delegate.safeAddStatement(current, restPredicate, next);
- current = next;
- }
- }
-
- return head;
- }
-
- /**
- * Extracts an RDF literal from a literal context.
- * Handles plain, language-tagged, typed, boolean, and numeric literals.
- *
- * @param ctx literal context
- * @return RDF literal value
- * @throws ParsingException if the literal type is unsupported
- */
- private Literal extractLiteral(TurtleParser.LiteralContext ctx) {
- if (ctx.rdfLiteral() != null) {
- String label = delegate.unescapeString(ctx.rdfLiteral().string().getText());
- if (ctx.rdfLiteral().LANGTAG() != null) {
- return delegate.createLiteral(label, ctx.rdfLiteral().LANGTAG().getText().substring(1), null);
- }
- if (ctx.rdfLiteral().iri() != null) {
- return delegate.createLiteral(label, null, delegate.resolveIRI(ctx.rdfLiteral().iri().getText()));
- }
- return delegate.createLiteral(label, null, null);
- }
-
- if (ctx.BooleanLiteral() != null) {
- return delegate.createBooleanLiteral(ctx.BooleanLiteral().getText());
- }
-
- if (ctx.numericLiteral() != null) {
- String numericText = ctx.numericLiteral().getText();
- AbstractTurtleTriGListener.NumericType type;
-
- if (ctx.numericLiteral().DOUBLE() != null) {
- type = AbstractTurtleTriGListener.NumericType.DOUBLE;
- } else if (ctx.numericLiteral().DECIMAL() != null) {
- type = AbstractTurtleTriGListener.NumericType.DECIMAL;
- } else {
- type = AbstractTurtleTriGListener.NumericType.INTEGER;
- }
-
- return delegate.createNumericLiteral(numericText, type);
- }
-
- throw new ParsingException("Unsupported literal: " + ctx.getText());
- }
-
- /**
- * Extracts a predicate IRI from a verb context.
- * Handles the special case of {@code a} as shorthand for {@code rdf:type}.
- *
- * @param ctx verb context
- * @return predicate IRI
- * @throws ParsingException if the IRI cannot be resolved
- */
- private IRI extractVerb(TurtleParser.VerbContext ctx) {
- String verbText = ctx.getText();
- String resolvedIRI = delegate.resolveIRI(verbText);
- if (resolvedIRI.isEmpty()) {
- throw new ParsingException("Cannot resolve verb to a valid IRI: " + verbText);
- }
- return delegate.factory.createIRI(resolvedIRI);
- }
-
- /**
- * Delegate extending for Turtle parsing.
- * Provides access to common RDF parsing functionality without additional extensions.
- */
- private static class TurtleListenerDelegate extends AbstractTurtleTriGListener {
- public TurtleListenerDelegate(Model model, ValueFactory factory, String baseURI) {
- super(model, factory, baseURI);
- }
- }
-}
diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleParser.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleParser.java
deleted file mode 100644
index 41f54edb2..000000000
--- a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleParser.java
+++ /dev/null
@@ -1,181 +0,0 @@
-package fr.inria.corese.core.next.data.impl.io.parser.turtle;
-
-import fr.inria.corese.core.next.data.api.model.Model;
-import fr.inria.corese.core.next.data.api.factory.ValueFactory;
-import fr.inria.corese.core.next.data.api.io.format.RDFFormat;
-import fr.inria.corese.core.next.data.spi.io.parser.AbstractRDFParser;
-import fr.inria.corese.core.next.data.api.io.option.RDFParsingOptions;
-import fr.inria.corese.core.next.data.api.exception.ParsingException;
-import fr.inria.corese.core.next.generated.antlr.TurtleLexer;
-import org.antlr.v4.runtime.BaseErrorListener;
-import org.antlr.v4.runtime.CharStream;
-import org.antlr.v4.runtime.CharStreams;
-import org.antlr.v4.runtime.CommonTokenStream;
-import org.antlr.v4.runtime.RecognitionException;
-import org.antlr.v4.runtime.Recognizer;
-import org.antlr.v4.runtime.Token;
-import org.antlr.v4.runtime.tree.ParseTree;
-import org.antlr.v4.runtime.tree.ParseTreeWalker;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Parser for Turtle RDF files.
- *
- */
-public class TurtleParser extends AbstractRDFParser {
-
- /**
- * Constructor for TurtleParser that initializes the model and value
- * factory.
- *
- * @param model the model to be populated by the parser
- * @param factory the value factory used to create RDF values
- */
- public TurtleParser(Model model, ValueFactory factory) {
- this(model, factory, new TurtleParserOptions.Builder().build());
- }
-
- /**
- * Constructor for TurtleParser that initializes the model, value factory,
- * and configuration options.
- *
- * @param model the model to be populated by the parser
- * @param factory the value factory used to create RDF values
- * @param config configuration object for the parser
- */
- public TurtleParser(Model model, ValueFactory factory, RDFParsingOptions config) {
- super(model, factory, config);
- }
-
- @Override
- public RDFFormat getRDFFormat() {
- return RDFFormat.TURTLE;
- }
-
- @Override
- public void parse(InputStream in, String baseURI) throws ParsingException {
- parse(new InputStreamReader(in, StandardCharsets.UTF_8), baseURI);
- }
-
- /**
- * Parses Turtle data from a {@link Reader} using ANTLR4.
- *
- * @param reader The {@link Reader} to read the RDF data.
- * @param baseURI The base URI.
- * @throws ParsingException if a parsing or I/O error occurs.
- */
- @Override
- public void parse(Reader reader, String baseURI) throws ParsingException {
- try {
- CharStream charStream = CharStreams.fromReader(reader);
- TurtleLexer turtleLexer = new TurtleLexer(charStream);
-
- TurtleErrorListener turtleErrorListener = new TurtleErrorListener();
- turtleLexer.removeErrorListeners();
- turtleLexer.addErrorListener(turtleErrorListener);
-
- CommonTokenStream tokens = new CommonTokenStream(turtleLexer);
- fr.inria.corese.core.next.generated.antlr.TurtleParser turtleParser = new fr.inria.corese.core.next.generated.antlr.TurtleParser(tokens);
-
- turtleParser.removeErrorListeners();
- turtleParser.addErrorListener(turtleErrorListener);
-
- ParseTreeWalker walker = new ParseTreeWalker();
- ParseTree tree = parseTree(turtleParser, turtleErrorListener);
-
- RDFParsingOptions optionsWithBaseURI = new TurtleParserOptions.Builder()
- .baseIRI(baseURI)
- .build();
- TurtleListener listener = new TurtleListener(getModel(), getValueFactory(), optionsWithBaseURI);
- walker.walk(listener, tree);
-
- } catch (ParsingException e) {
- throw e;
- } catch (IOException e) {
- throw new ParsingException("Failed to parse Turtle RDF: " + e.getMessage(), e);
- } catch (Exception e) {
- throw new ParsingException("Unexpected error during Turtle parsing: " + e.getMessage(), e);
- }
- }
-
- private ParseTree parseTree(fr.inria.corese.core.next.generated.antlr.TurtleParser turtleParser, TurtleErrorListener turtleErrorListener) {
- try {
- ParseTree tree = turtleParser.turtleDoc();
-
- if (turtleErrorListener.hasErrors()) {
- String errorMsg = turtleErrorListener.getErrorMessage();
- if (errorMsg == null || errorMsg.trim().isEmpty()) {
- errorMsg = "Unknown syntax error detected";
- }
- throw new ParsingException("Syntax error in Turtle document: " + errorMsg);
- }
- return tree;
- } catch (RecognitionException e) {
- throw new ParsingException("Recognition error in Turtle document: " + e.getMessage());
- }
- }
-
- /**
- * A custom error listener to collect errors from the lexer and parser.
- */
- private static class TurtleErrorListener extends BaseErrorListener {
- private final List errors = new ArrayList<>();
-
- /**
- * Records syntax errors generated by ANTLR.
- *
- * @param recognizer The recognizer that detected the error.
- * @param offendingSymbol The symbol that caused the error.
- * @param line The line number where the error occurred.
- * @param charPositionInLine The character position on the line.
- * @param msg The error message.
- * @param e The recognition exception.
- */
- @Override
- public void syntaxError(Recognizer, ?> recognizer, Object offendingSymbol,
- int line, int charPositionInLine, String msg, RecognitionException e) {
- if (msg == null || msg.trim().isEmpty()) {
- msg = "Unknown syntax error";
- }
-
- if ((msg.contains("token recognition error") || msg.contains("mismatched input"))
- && offendingSymbol instanceof Token token) {
- String tokenText = token.getText();
- if (msg.contains("token recognition error") && tokenText != null && tokenText.contains("\"")) {
- msg = "Invalid string literal - possibly unterminated or contains invalid escape sequence: " + msg;
- }
- }
-
- String error = "line " + line + ":" + charPositionInLine + " " + msg;
- errors.add(error);
- }
-
- /**
- * Checks if parsing errors have been found.
- *
- * @return `true` if the error list is not empty, otherwise `false`.
- */
- public boolean hasErrors() {
- return !errors.isEmpty();
- }
-
- /**
- * Returns a formatted error message containing all found errors.
- *
- * @return A {@link String} containing the error messages.
- */
- public String getErrorMessage() {
- if (errors.isEmpty()) {
- return "Unknown parsing error";
- }
- return String.join("; ", errors);
- }
- }
-}
diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleScanner.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleScanner.java
index 0b7c4c46e..a259f0dbe 100644
--- a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleScanner.java
+++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleScanner.java
@@ -19,7 +19,7 @@
/**
* Streaming RDF 1.1 Turtle parser without tokens, parse trees, or ANTLR.
*
- *
Replaces the ANTLR-based {@link TurtleParser} with a character-level FSM that
+ *
Uses a character-level scanner that
* streams resolved terms directly to the model with minimal allocations.
* IRIs are cached with a bounded cache; blank nodes are scoped to one document.
* Memory is O(longest term + distinct blank node labels + IRI cache size).
The method ensures that blank-node labels are scoped to a single parse session:
- * the same label maps to the same {@link BNode} within one document but to a different
- * {@link BNode} in every other document.
- */
-@DisplayName("AbstractTurtleTriGListener - blank-node document scoping")
-class ScopedBlankNodeTest {
-
- private static final ValueFactory FACTORY = Values.factory();
-
- /** Creates a fresh listener backed by an in-memory model. */
- private static AbstractTurtleTriGListener listener() {
- return new AbstractTurtleTriGListener(
- StorageModels.create(MemoryStorageManager.builder().build()),
- FACTORY,
- "") {};
- }
-
- @Test
- @DisplayName("same label returns the same BNode instance within one document")
- void sameLabelReturnsSameBNodeWithinDocument() {
- AbstractTurtleTriGListener l = listener();
- BNode first = l.scopedBlankNode("x");
- BNode second = l.scopedBlankNode("x");
- assertSame(first, second,
- "repeated calls with the same label must return the same BNode");
- }
-
- @Test
- @DisplayName("different labels return distinct BNodes within one document")
- void differentLabelsReturnDifferentBNodes() {
- AbstractTurtleTriGListener l = listener();
- BNode b1 = l.scopedBlankNode("a");
- BNode b2 = l.scopedBlankNode("b");
- assertNotEquals(b1, b2,
- "different labels must map to distinct BNode instances");
- }
-
- @Test
- @DisplayName("separate listener instances produce distinct BNodes for the same label")
- void separateListenersProduceDistinctBNodesForSameLabel() {
- BNode fromFirst = listener().scopedBlankNode("x");
- BNode fromSecond = listener().scopedBlankNode("x");
- assertNotEquals(fromFirst, fromSecond,
- "blank nodes from separate parse sessions must be distinct even when labels match");
- }
-}
diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGListenerTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGListenerTest.java
deleted file mode 100644
index c174b4841..000000000
--- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGListenerTest.java
+++ /dev/null
@@ -1,113 +0,0 @@
-package fr.inria.corese.core.next.data.impl.io.parser.trig;
-
-import fr.inria.corese.core.next.data.api.term.IRI;
-import fr.inria.corese.core.next.data.api.model.Model;
-import fr.inria.corese.core.next.data.api.term.Resource;
-import fr.inria.corese.core.next.data.impl.io.parser.support.ParserTestBase;
-import fr.inria.corese.core.next.generated.antlr.TriGLexer;
-import fr.inria.corese.core.next.generated.antlr.TriGParser;
-import org.antlr.v4.runtime.CharStream;
-import org.antlr.v4.runtime.CharStreams;
-import org.antlr.v4.runtime.CommonTokenStream;
-import org.antlr.v4.runtime.tree.ParseTree;
-import org.antlr.v4.runtime.tree.ParseTreeWalker;
-import org.junit.jupiter.api.Test;
-
-import java.io.StringReader;
-import java.util.Set;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-/**
- * Unit tests for the TriGListenerImpl class.
- * These tests verify that the listener correctly processes ANTLR parse tree contexts
- * to extract and unescape RDF terms (IRIs, Blank Nodes, Literals) and add them to the model.
- */
-class TriGListenerTest extends ParserTestBase {
-
- private Model parseTrig(String trigData) throws Exception {
- CharStream input = CharStreams.fromReader(new StringReader(trigData));
- TriGLexer lexer = new TriGLexer(input);
- CommonTokenStream tokens = new CommonTokenStream(lexer);
- TriGParser parser = new TriGParser(tokens);
- ParseTree tree = parser.trigDoc();
-
- Model coreseModel = createTestModel();
-
- TriGListener listener = new TriGListener(coreseModel, valueFactory, null);
- ParseTreeWalker.DEFAULT.walk(listener, tree);
-
- return coreseModel;
- }
-
- @Test
- void testSimpleNamedGraph() throws Exception {
- String trig = """
- @prefix ex: .
-
- GRAPH ex:graph {
- ex:subject ex:predicate "Hello" .
- }
- """;
-
- Model model = parseTrig(trig);
- assertEquals(1, model.size());
- assertEquals(1, model.contexts().size());
- }
-
- @Test
- void testBlankNodeWithProperties() throws Exception {
- String trig = """
- @prefix ex: .
- GRAPH ex:graph {
- ex:Bob ex:knows [ ex:name "Charlie" ] .
- }
- """;
-
- Model model = parseTrig(trig);
- assertEquals(2, model.size());
- }
-
- @Test
- void testMultipleGraphsAndBase() throws Exception {
- String trig = """
- @base .
- @prefix dc: .
- @prefix ex: .
-
- dc:creator "Bob" .
-
- GRAPH ex:other {
- dc:creator "Alice" .
- }
- """;
-
- Model model = parseTrig(trig);
-
- // Total statements: 1 in default graph + 1 in named graph
- assertEquals(2, model.size(), "Should have 2 statements total");
-
- Set contexts = model.contexts();
- assertEquals(2, contexts.size(),
- "Should have 2 contexts (1 named graph + default graph)");
-
- IRI otherGraph = valueFactory.createIRI("http://example.org/other");
- assertTrue(contexts.contains(otherGraph),
- "Should contain ex:other as a named graph");
- }
-
- @Test
- void testTypedLiteralsAndLang() throws Exception {
- String trig = """
- @prefix ex: .
- @prefix xsd: .
-
- ex:subject ex:age "30"^^xsd:integer ;
- ex:name "Jean"@fr .
- """;
-
- Model model = parseTrig(trig);
- assertEquals(2, model.size());
- }
-}
diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGParserTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGParserTest.java
index 6c44c904c..2a8658f8b 100644
--- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGParserTest.java
+++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGParserTest.java
@@ -14,7 +14,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
- * Unit tests for the ANTLRTrigParser class.
+ * Unit tests for the TriGScanner class.
* These tests verify the parser's ability to correctly parse Trig
* and interact with the Model and ValueFactory, including error handling
* and unescaping of IRIs and literals, and named graphs.
@@ -33,7 +33,7 @@ class TriGParserTest extends ParserTestBase {
private Model parseFromString(String trigData, String baseURI) {
Model model = createTestModel();
- RDFParser parser = new TriGParser(model, valueFactory);
+ RDFParser parser = new TriGScanner(model, valueFactory);
parser.parse(new StringReader(trigData), baseURI);
return model;
}
diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleListenerTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleListenerTest.java
deleted file mode 100644
index a34f0ba17..000000000
--- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleListenerTest.java
+++ /dev/null
@@ -1,132 +0,0 @@
-package fr.inria.corese.core.next.data.impl.io.parser.turtle;
-
-import fr.inria.corese.core.next.data.api.model.Model;
-import fr.inria.corese.core.next.data.impl.io.parser.support.ParserTestBase;
-import fr.inria.corese.core.next.generated.antlr.TurtleLexer;
-import fr.inria.corese.core.next.generated.antlr.TurtleParser;
-import org.antlr.v4.runtime.CharStream;
-import org.antlr.v4.runtime.CharStreams;
-import org.antlr.v4.runtime.CommonTokenStream;
-import org.antlr.v4.runtime.tree.ParseTree;
-import org.antlr.v4.runtime.tree.ParseTreeWalker;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.Arguments;
-import org.junit.jupiter.params.provider.MethodSource;
-import java.util.stream.Stream;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.StringReader;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-/**
- * Unit tests for the TurtleListenerImpl parser.
- */
-class TurtleListenerTest extends ParserTestBase {
-
- private static final Logger logger = LoggerFactory.getLogger(TurtleListenerTest.class);
-
- /**
- * Parses a Turtle string and returns the RDF model.
- *
- * @param turtleData Turtle syntax input as a string
- * @return parsed RDF model
- * @throws Exception if parsing fails
- */
- private Model parseAndPrintModel(String turtleData) throws Exception {
- CharStream input = CharStreams.fromReader(new StringReader(turtleData));
- TurtleLexer lexer = new TurtleLexer(input);
- CommonTokenStream tokens = new CommonTokenStream(lexer);
- TurtleParser parser = new TurtleParser(tokens);
- ParseTree tree = parser.turtleDoc();
-
- Model model = createTestModel();
-
- TurtleListener listener = new TurtleListener(model, valueFactory, null);
- ParseTreeWalker walker = new ParseTreeWalker();
- walker.walk(listener, tree);
-
- return model;
- }
-
- @ParameterizedTest
- @MethodSource("provideTurtleTestCases")
- void testTurtleParsing(String turtleData, int expectedTriples, int expectedNamespaces) throws Exception {
- Model model = parseAndPrintModel(turtleData);
- assertEquals(expectedTriples, model.size(), "Triple count mismatch");
- assertEquals(expectedNamespaces, model.getNamespaces().size(), "Namespace count mismatch");
- }
-
- private static Stream provideTurtleTestCases() {
- return Stream.of(
- Arguments.of(
- "@prefix ex: .\nex:subject ex:predicate 1 .",
- 1, 1
- ),
- Arguments.of(
- """
- @prefix ex: .
- @prefix xsd: .
- ex:subject ex:age "27"^^xsd:integer .
- """,
- 1, 2
- ),
- Arguments.of(
- """
- @prefix ex: .
- ex:subject ex:knows ex:Alice , ex:Bob ;
- ex:likes ex:Pizza .
- """,
- 3, 1
- ),
- Arguments.of(
- """
- @prefix ex: .
- ex:Alice a ex:Person .
- ex:subject ex:knows ex:Alice , ex:Bob ;
- ex:likes ex:Pizza .
- """,
- 4, 1
- )
- );
- }
-
- @Test
- void testBaseIRI() throws Exception {
- String turtleData = """
- @base .
- @prefix : .
- @prefix rdf: .
-
- rdf:type rdf:Property .
- :phone rdf:type rdf:Property .
- """;
-
- Model model = parseAndPrintModel(turtleData);
- assertEquals(2, model.size(), "Triple count mismatch");
- assertEquals(2, model.getNamespaces().size(), "Namespace count mismatch");
- }
-
- @Test
- void testTypedIntegerLiteral() throws Exception {
- String turtleData = """
- @prefix : .
- @prefix xsd: .
- :John :age "42"^^xsd:integer .
- """;
-
- Model model = parseAndPrintModel(turtleData);
- model.objects().forEach(obj -> {
- assertTrue(obj.isLiteral(), "Expected object to be a literal");
- assertDoesNotThrow(() -> {
- int value = Integer.parseInt(obj.stringValue());
- logger.info("Parsed integer: {}", value);
- }, "Literal should be a valid integer: " + obj.stringValue());
- });
-
- assertEquals(1, model.size(), "Triple count mismatch");
- assertEquals(2, model.getNamespaces().size(), "Namespace count mismatch");
- }
-}
\ No newline at end of file
diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleParserTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleParserTest.java
index a97fbd3a6..a4d1798c1 100644
--- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleParserTest.java
+++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleParserTest.java
@@ -19,10 +19,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
- * Unit tests for the ANTLRTurtle class.
- * These tests verify the parser's ability to correctly parse Turtle
- * and interact with the Model and ValueFactory, including error handling
- * and unescaping of IRIs and literals, and named graphs.
+ * Integration tests for the streaming TurtleScanner class.
*/
class TurtleParserTest extends ParserTestBase {
@@ -35,17 +32,17 @@ void testParseWithPrefixAndTriple() {
ex:Alice ex:knows ex:Bob .""";
Model model = createTestModel();
- RDFParser parser = new TurtleParser(model, factory, new TurtleParserOptions.Builder().baseIRI("http://inria.fr/").build());
+ RDFParser parser = new TurtleScanner(model, factory, new TurtleParserOptions.Builder().baseIRI("http://inria.fr/").build());
parser.parse(new StringReader(turtle));
assertEquals(1, model.size());
- assertEquals(1, model.getNamespaces().size()); // Should contains ex: and the relative base uri :
+ assertEquals(1, model.getNamespaces().size());
}
@ParameterizedTest
@MethodSource("provideTripleQuoteTestCases")
void testTripleQuoteLiteralWithDoubleQuotes(String turtleInput, String expectedLiteralValue) {
Model model = createTestModel();
- RDFParser parser = new TurtleParser(model, factory, new TurtleParserOptions.Builder().baseIRI("http://inria.fr/").build());
+ RDFParser parser = new TurtleScanner(model, factory, new TurtleParserOptions.Builder().baseIRI("http://inria.fr/").build());
parser.parse(new StringReader(turtleInput));
assertEquals(1, model.size());
assertTrue(model.contains(factory.createIRI("http://inria.fr/#0214"), RDFS.comment.getIRI(), factory.createLiteral(expectedLiteralValue)));
@@ -57,14 +54,14 @@ private static Stream provideTripleQuoteTestCases() {
"""
@prefix rdfs: .
<#0214>\s
- rdfs:comment \"""blabla"abc\\\"\""" .""",
+ rdfs:comment \"""blabla"abc\\"\""" .""",
"blabla\"abc\""
),
Arguments.of(
"""
@prefix rdfs: .
<#0214>\s
- rdfs:comment ""\""abc"blabla\""" .""",
+ rdfs:comment "\"""abc"blabla\""" .""",
"\"abc\"blabla"
),
Arguments.of(
@@ -78,7 +75,7 @@ private static Stream provideTripleQuoteTestCases() {
"""
@prefix rdfs: .
<#0214>\s
- rdfs:comment ""\""abc\\\"\""" .""",
+ rdfs:comment "\"""abc\\"\""" .""",
"\"abc\""
)
);
@@ -97,7 +94,7 @@ void testRdfaManifest() {
\s""";
Model model = createTestModel();
- RDFParser parser = new TurtleParser(model, factory, new TurtleParserOptions.Builder().baseIRI("http://inria.fr/").build());
+ RDFParser parser = new TurtleScanner(model, factory, new TurtleParserOptions.Builder().baseIRI("http://inria.fr/").build());
parser.parse(new StringReader(turtle));
Model refModel = createTestModel();
@@ -118,13 +115,13 @@ void testRdfaManifest() {
void testUndeclaredEmptyPrefixThrowsParsingException() {
String turtle = ":subject :predicate :object .";
Model model = createTestModel();
- RDFParser parser = new TurtleParser(model, factory);
+ RDFParser parser = new TurtleScanner(model, factory);
fr.inria.corese.core.next.data.api.exception.ParsingException ex = org.junit.jupiter.api.Assertions.assertThrows(
fr.inria.corese.core.next.data.api.exception.ParsingException.class,
() -> parser.parse(new StringReader(turtle))
);
- assertTrue(ex.getMessage().contains("Undeclared prefix: '' (empty prefix)"));
+ assertTrue(ex.getMessage().contains("Undeclared default prefix ':'"));
}
}
From 32dca47acc8352d521cd701a7e3006b64f2edfd2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9mi=20C=C3=A9r=C3=A8s?=
Date: Fri, 6 Nov 2026 18:34:03 +0100
Subject: [PATCH 5/6] fix(rdfxml): sanitize namespace prefixes and handle
collisions
Filter invalid and reserved XML prefixes from model and configuration mappings, generate safe alternatives, and resolve fallback-prefix collisions using the prefix-to-namespace lookup. Verify generated XML with a namespace-aware parser and simplify literal writing.
---
.../serializer/rdfxml/RDFXMLSerializer.java | 88 ++++++++-------
.../rdfxml/RDFXMLSerializerTest.java | 103 ++++++++++++++++++
2 files changed, 152 insertions(+), 39 deletions(-)
diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializer.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializer.java
index caabc0f37..0fc660fc1 100644
--- a/src/main/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializer.java
+++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializer.java
@@ -6,6 +6,7 @@
import fr.inria.corese.core.next.data.api.io.option.IOOptions;
import fr.inria.corese.core.next.data.api.io.serializer.*;
import fr.inria.corese.core.next.data.api.io.serializer.option.*;
+import fr.inria.corese.core.next.data.impl.io.parser.rdfxml.RDFXMLUtils;
import fr.inria.corese.core.next.data.impl.namespace.PrefixHandler;
import fr.inria.corese.core.next.data.spi.term.IRIUtils;
import fr.inria.corese.core.next.data.api.vocabulary.RDF;
@@ -70,11 +71,18 @@ public RDFXMLSerializer(Model model, IOOptions config) {
this.config = Objects.requireNonNull(config, "Configuration cannot be null");
this.prefixHandler = new PrefixHandler(false);
- // Seed with namespaces declared on the model (e.g. parsed @prefix directives).
+ // Seed with namespaces declared on the model (e.g. parsed @prefix directives),
+ // filtering out empty, reserved, or invalid XML prefixes.
// Config-level prefixes and standard RDF/XML namespaces are applied afterwards and take precedence on conflict.
- model.getNamespaces().forEach(this.prefixHandler::setNamespace);
+ model.getNamespaces().stream()
+ .filter(ns -> isAllowedXmlPrefix(ns.getPrefix()))
+ .forEach(this.prefixHandler::setNamespace);
if (config instanceof UsesPrefixOptions usesPrefixOptions && usesPrefixOptions.usePrefixes()) {
- this.prefixHandler.copyFrom(usesPrefixOptions.getPrefixMapping());
+ usesPrefixOptions.getPrefixMapping().getPrefixMap().forEach((prefix, namespace) -> {
+ if (isAllowedXmlPrefix(prefix)) {
+ this.prefixHandler.setPrefix(prefix, namespace);
+ }
+ });
} else {
// These namespaces are part of the RDF/XML standard
this.prefixHandler.setPrefix(RDF.getVocabularyPreferredPrefix(), RDF.getVocabularyNamespace());
@@ -83,6 +91,13 @@ public RDFXMLSerializer(Model model, IOOptions config) {
this.blankNodeIds = new HashMap<>();
}
+ private static boolean isAllowedXmlPrefix(String prefix) {
+ return prefix != null && !prefix.isEmpty()
+ && !"xml".equalsIgnoreCase(prefix)
+ && !"xmlns".equalsIgnoreCase(prefix)
+ && !RDFXMLUtils.isInvalidXMLName(prefix, false);
+ }
+
/**
* Writes the model to the given writer in RDF/XML format.
*
@@ -204,7 +219,7 @@ private void writeNamespaceAttributes(Writer writer, Set actuallyUsedNam
for(String namespace : namespacelist) {
String prefix = this.prefixHandler.getPrefix(namespace);
- writer.write(String.format(" %s%s=\"%s\"", SerializationConstants.XMLNS_PREFIX, prefix, escapeXmlAttribute(namespace)));
+ writer.write(" " + SerializationConstants.XMLNS_PREFIX + prefix + "=\"" + escapeXmlAttribute(namespace) + "\"");
}
}
@@ -288,9 +303,9 @@ private void writeDescriptionElement(Writer writer, Resource subject, List", SerializationConstants.RDF_DESCRIPTION_START, SerializationConstants.RDF_ABOUT_ATTRIBUTE, escapeXmlAttribute(subject.stringValue())));
+ writer.write(SerializationConstants.RDF_DESCRIPTION_START + " " + SerializationConstants.RDF_ABOUT_ATTRIBUTE + "=\"" + escapeXmlAttribute(subject.stringValue()) + "\">");
} else if (subject.isBNode()) {
- writer.write(String.format("%s %s=\"%s\">", SerializationConstants.RDF_DESCRIPTION_START, SerializationConstants.RDF_NODEID_ATTRIBUTE, getBlankNodeId(subject)));
+ writer.write(SerializationConstants.RDF_DESCRIPTION_START + " " + SerializationConstants.RDF_NODEID_ATTRIBUTE + "=\"" + getBlankNodeId(subject) + "\">");
}
if(this.config instanceof LineEndingOptions lineEndingOptions) {
writer.write(lineEndingOptions.getLineEnding());
@@ -326,7 +341,6 @@ private void writeDescriptionElement(Writer writer, Resource subject, List", SerializationConstants.RDF_RESOURCE_ATTRIBUTE, escapeXmlAttribute(object.stringValue())));
- if(this.config instanceof LineEndingOptions lineEndingOptions) {
- writer.write(lineEndingOptions.getLineEnding());
- }
+ writer.write(" " + SerializationConstants.RDF_RESOURCE_ATTRIBUTE + "=\"" + escapeXmlAttribute(object.stringValue()) + "\"/>");
} else if (object.isBNode()) {
- writer.write(String.format(" %s=\"%s\"/>", SerializationConstants.RDF_NODEID_ATTRIBUTE, getBlankNodeId((Resource) object)));
- if(this.config instanceof LineEndingOptions lineEndingOptions) {
- writer.write(lineEndingOptions.getLineEnding());
- }
+ writer.write(" " + SerializationConstants.RDF_NODEID_ATTRIBUTE + "=\"" + getBlankNodeId((Resource) object) + "\"/>");
} else if (object.isLiteral()) {
- Literal literal = (Literal) object;
-
- literal.getLanguage().ifPresent(lang -> {
- try {
- writer.write(String.format(" %s=\"%s\">", SerializationConstants.XML_LANG_ATTRIBUTE, escapeXmlAttribute(lang)));
- } catch (IOException e) {
- throw new UncheckedIOException("Failed to write xml:lang attribute", e);
- }
- });
-
- if (!literal.getLanguage().isPresent() && shouldWriteDatatype(literal)) {
- String datatypeUri = literal.getDatatype().stringValue();
- String prefixedDatatype = getPrefixedNameInternal(datatypeUri);
- writer.write(String.format(" %s=\"%s\">", SerializationConstants.RDF_DATATYPE_ATTRIBUTE, escapeXmlAttribute(prefixedDatatype != null ? prefixedDatatype : datatypeUri)));
- } else if (!literal.getLanguage().isPresent()) {
- writer.write(">");
- }
+ writeLiteralElement(writer, (Literal) object, elementName);
+ } else {
+ throw new IllegalArgumentException("Unsupported value type for RDF/XML serialization: " + object.getClass().getName());
+ }
+ if (this.config instanceof LineEndingOptions lineEndingOptions) {
+ writer.write(lineEndingOptions.getLineEnding());
+ }
+ }
- writer.write(escapeXmlContent(literal.stringValue()));
+ private void writeLiteralElement(Writer writer, Literal literal, String elementName) throws IOException {
+ writeLiteralAttributes(writer, literal);
+ writer.write(escapeXmlContent(literal.stringValue()));
+ writer.write("" + elementName + ">");
+ }
- writer.write("" + elementName + ">");
- if(this.config instanceof LineEndingOptions lineEndingOptions) {
- writer.write(lineEndingOptions.getLineEnding());
+ private void writeLiteralAttributes(Writer writer, Literal literal) throws IOException {
+ if (literal.getLanguage().isPresent()) {
+ try {
+ writer.write(" " + SerializationConstants.XML_LANG_ATTRIBUTE + "=\"" + escapeXmlAttribute(literal.getLanguage().orElseThrow()) + "\">");
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to write xml:lang attribute", e);
}
+ } else if (shouldWriteDatatype(literal)) {
+ String datatypeUri = literal.getDatatype().stringValue();
+ String prefixedDatatype = getPrefixedNameInternal(datatypeUri);
+ writer.write(" " + SerializationConstants.RDF_DATATYPE_ATTRIBUTE + "=\"" + escapeXmlAttribute(prefixedDatatype != null ? prefixedDatatype : datatypeUri) + "\">");
} else {
- throw new IllegalArgumentException("Unsupported value type for RDF/XML serialization: " + object.getClass().getName());
+ writer.write(">");
}
}
@@ -453,11 +463,11 @@ private String getSuggestedPrefix(String namespace) {
}
base = base.replaceAll("[^a-zA-Z0-9]", SerializationConstants.EMPTY_STRING).toLowerCase();
- if (base.isEmpty()) base = "p";
+ if (!isAllowedXmlPrefix(base)) base = "p";
String candidate = base;
int i = 0;
- while (this.prefixHandler.hasPrefix(candidate) && !this.prefixHandler.getPrefix(candidate).equals(namespace)) {
+ while (this.prefixHandler.hasPrefix(candidate) && !namespace.equals(this.prefixHandler.getNamespace(candidate))) {
candidate = base + (++i);
}
return candidate;
diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializerTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializerTest.java
index d27fe48d9..c1aa85a2f 100644
--- a/src/test/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializerTest.java
+++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializerTest.java
@@ -2,6 +2,7 @@
import fr.inria.corese.core.next.data.api.model.Model;
import fr.inria.corese.core.next.data.api.model.Statement;
+import fr.inria.corese.core.next.data.api.namespace.SimpleNamespace;
import fr.inria.corese.core.next.data.api.io.option.IOOptions;
import fr.inria.corese.core.next.data.api.vocabulary.XSD;
import fr.inria.corese.core.next.data.api.exception.SerializationException;
@@ -526,4 +527,106 @@ void shouldAcceptAnyOptionFile() {
new RDFXMLSerializer(mockModel, option);
});
}
+
+ @Test
+ @DisplayName("Should ignore empty default prefix and invalid prefixes when seeding from model")
+ void shouldIgnoreEmptyOrInvalidPrefixesFromModel() throws SerializationException {
+ when(mockModel.getNamespaces()).thenReturn(java.util.Set.of(
+ new SimpleNamespace("", "http://example.org/default#"),
+ new SimpleNamespace("123bad", "http://example.org/bad#"),
+ new SimpleNamespace("ex", "http://example.org/ns#")
+ ));
+ Statement stmt = factory.createStatement(
+ factory.createIRI("http://example.org/ns#subject"),
+ factory.createIRI("http://example.org/ns#predicate"),
+ factory.createIRI("http://example.org/default#object")
+ );
+ when(mockModel.stream()).thenReturn(Stream.of(stmt));
+
+ RDFXMLSerializerOptions testConfig = new RDFXMLSerializerOptions.Builder()
+ .usePrefixes(true)
+ .autoDeclarePrefixes(true)
+ .prefixOrdering(PrefixOrdering.ALPHABETICAL)
+ .build();
+
+ RDFXMLSerializer serializer = new RDFXMLSerializer(mockModel, testConfig);
+ serializer.write(writer);
+
+ String result = writer.toString();
+ assertFalse(result.contains("xmlns:="), "Should never produce xmlns:=");
+ assertFalse(result.contains("xmlns:123bad="), "Should never produce invalid XML prefix");
+ assertTrue(result.contains("xmlns:ex=\"http://example.org/ns#\""), "Should include valid prefix");
+ }
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.ValueSource(strings = {"", "xml", "XML", "xMl", "xmlns", "XMLNS", "XmlNs", "123bad"})
+ void shouldIgnoreUnsafePrefixesFromModelAndConfiguration(String prefix) throws Exception {
+ for (boolean fromModel : new boolean[] {true, false}) {
+ String namespace = "http://example.org/ns#";
+ when(mockModel.getNamespaces()).thenReturn(fromModel
+ ? java.util.Set.of(new SimpleNamespace(prefix, namespace))
+ : java.util.Set.of());
+ Statement statement = factory.createStatement(
+ factory.createIRI(namespace + "subject"),
+ factory.createIRI(namespace + "predicate"),
+ factory.createIRI(namespace + "object"));
+ when(mockModel.stream()).thenReturn(Stream.of(statement));
+ var builder = new RDFXMLSerializerOptions.Builder().autoDeclarePrefixes(true);
+ if (!fromModel) {
+ builder.addPrefix(prefix, namespace);
+ }
+ StringWriter output = new StringWriter();
+ new RDFXMLSerializer(mockModel, builder.build()).write(output);
+ String xml = output.toString();
+ assertFalse(xml.contains("xmlns:" + prefix + "="), xml);
+ var documentBuilderFactory = javax.xml.parsers.DocumentBuilderFactory.newInstance();
+ documentBuilderFactory.setNamespaceAware(true);
+ var document = documentBuilderFactory.newDocumentBuilder().parse(
+ new org.xml.sax.InputSource(new java.io.StringReader(xml)));
+ assertEquals(1, document.getElementsByTagNameNS(namespace, "predicate").getLength());
+ }
+ }
+
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.ValueSource(strings = {"xml", "xmlns"})
+ void shouldNotGenerateReservedPrefixes(String segment) throws Exception {
+ String namespace = "http://example.org/" + segment + "/";
+ Statement statement = factory.createStatement(
+ factory.createIRI(namespace + "subject"),
+ factory.createIRI(namespace + "predicate"),
+ factory.createIRI(namespace + "object"));
+ when(mockModel.stream()).thenReturn(Stream.of(statement));
+ new RDFXMLSerializer(mockModel, mockConfig).write(writer);
+ assertFalse(writer.toString().contains("xmlns:" + segment + "="));
+ var xmlFactory = javax.xml.parsers.DocumentBuilderFactory.newInstance();
+ xmlFactory.setNamespaceAware(true);
+ var document = xmlFactory.newDocumentBuilder().parse(
+ new org.xml.sax.InputSource(new java.io.StringReader(writer.toString())));
+ assertEquals(1, document.getElementsByTagNameNS(namespace, "predicate").getLength());
+ }
+
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.CsvSource({"xml, xmlns", "2020, 2021"})
+ void shouldGenerateDistinctPrefixesWhenSuggestionsCollide(String first, String second) throws Exception {
+ String firstNamespace = "http://example.org/" + first + "/";
+ String secondNamespace = "http://example.org/" + second + "/";
+ Statement firstStatement = factory.createStatement(
+ factory.createIRI("http://example.org/subject"),
+ factory.createIRI(firstNamespace + "predicate"),
+ factory.createIRI("http://example.org/object"));
+ Statement secondStatement = factory.createStatement(
+ firstStatement.getSubject(),
+ factory.createIRI(secondNamespace + "predicate"),
+ firstStatement.getObject());
+ when(mockModel.stream()).thenReturn(Stream.of(firstStatement, secondStatement));
+
+ new RDFXMLSerializer(mockModel, mockConfig).write(writer);
+
+ var xmlFactory = javax.xml.parsers.DocumentBuilderFactory.newInstance();
+ xmlFactory.setNamespaceAware(true);
+ var document = xmlFactory.newDocumentBuilder().parse(
+ new org.xml.sax.InputSource(new java.io.StringReader(writer.toString())));
+ assertEquals(1, document.getElementsByTagNameNS(firstNamespace, "predicate").getLength());
+ assertEquals(1, document.getElementsByTagNameNS(secondNamespace, "predicate").getLength());
+ }
+
}
From 32942bedb1010399ea910bc3e811f3330cbad436 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9mi=20C=C3=A9r=C3=A8s?=
Date: Fri, 6 Nov 2026 19:06:10 +0100
Subject: [PATCH 6/6] feat(rdfxml): enhance RDF/XML serialization with
namespace handling and improved prefix options
---
.../serializer/rdfxml/RDFXMLSerializer.java | 317 ++++++++++--------
.../rdfxml/RDFXMLSerializerOptions.java | 19 +-
.../io/parser/rdfxml/RDFXMLCircularTest.java | 4 -
.../rdfxml/RDFXMLNamespaceTest.java | 187 +++++++++++
.../rdfxml/RDFXMLSerializerTest.java | 10 +-
5 files changed, 388 insertions(+), 149 deletions(-)
create mode 100644 src/test/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLNamespaceTest.java
diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializer.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializer.java
index 0fc660fc1..33303d1c4 100644
--- a/src/main/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializer.java
+++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializer.java
@@ -1,12 +1,19 @@
package fr.inria.corese.core.next.data.impl.io.serializer.rdfxml;
-import fr.inria.corese.core.next.data.api.term.*;
-import fr.inria.corese.core.next.data.api.model.*;
+import fr.inria.corese.core.next.data.api.term.IRI;
+import fr.inria.corese.core.next.data.api.term.Literal;
+import fr.inria.corese.core.next.data.api.term.Resource;
+import fr.inria.corese.core.next.data.api.term.Value;
+import fr.inria.corese.core.next.data.api.model.Model;
+import fr.inria.corese.core.next.data.api.model.Statement;
import fr.inria.corese.core.next.data.api.io.format.RDFFormat;
import fr.inria.corese.core.next.data.api.io.option.IOOptions;
-import fr.inria.corese.core.next.data.api.io.serializer.*;
-import fr.inria.corese.core.next.data.api.io.serializer.option.*;
-import fr.inria.corese.core.next.data.impl.io.parser.rdfxml.RDFXMLUtils;
+import fr.inria.corese.core.next.data.api.io.serializer.RDFSerializer;
+import fr.inria.corese.core.next.data.api.io.serializer.option.BlankNodeIdGenerationOptions;
+import fr.inria.corese.core.next.data.api.io.serializer.option.DatatypePolicyOptions;
+import fr.inria.corese.core.next.data.api.io.serializer.option.LineEndingOptions;
+import fr.inria.corese.core.next.data.api.io.serializer.option.PrettyPrintOptions;
+import fr.inria.corese.core.next.data.api.io.serializer.option.UsesPrefixOptions;
import fr.inria.corese.core.next.data.impl.namespace.PrefixHandler;
import fr.inria.corese.core.next.data.spi.term.IRIUtils;
import fr.inria.corese.core.next.data.api.vocabulary.RDF;
@@ -18,10 +25,25 @@
import java.io.BufferedWriter;
import java.io.IOException;
-import java.io.UncheckedIOException;
import java.io.Writer;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
import java.util.stream.Collectors;
+import javax.xml.XMLConstants;
+import javax.xml.namespace.QName;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import org.w3c.dom.DOMException;
+import org.w3c.dom.Document;
/**
* Serializes a {@link Model} to RDF/XML format.
@@ -33,7 +55,7 @@
*
Declaration and usage of XML namespaces for IRIs.
*
Basic pretty-printing (indentation).
*
Serialization of triples as rdf:Description elements with properties.
- *
Serialization of blank nodes using rdf:nodeID or nested elements.
+ *
Serialization of blank nodes using rdf:nodeID identifiers.
*
Serialization of literals with language tags or datatypes.
*
*
Advanced features such as handling XML schemata, specific RDF/XML graph structures (e.g., rdf:Bag, rdf:Seq, rdf:Alt),
@@ -41,10 +63,17 @@
*/
public class RDFXMLSerializer implements RDFSerializer {
+ private static final Set RESERVED_RDF_PROPERTIES = Set.of(
+ "RDF", "Description", "ID", "about", "parseType", "resource", "nodeID", "datatype",
+ "aboutEach", "aboutEachPrefix", "bagID", "li");
+
+ private final Map propertyNames = new HashMap<>();
+ private final Document nameValidator = newNameValidator();
private final Model model;
private final IOOptions config;
private final PrefixHandler prefixHandler;
private final Map blankNodeIds;
+ private final Set usedBlankNodeIds = new HashSet<>();
private int blankNodeCounter = 0;
private List cachedStatements;
@@ -71,31 +100,48 @@ public RDFXMLSerializer(Model model, IOOptions config) {
this.config = Objects.requireNonNull(config, "Configuration cannot be null");
this.prefixHandler = new PrefixHandler(false);
- // Seed with namespaces declared on the model (e.g. parsed @prefix directives),
- // filtering out empty, reserved, or invalid XML prefixes.
- // Config-level prefixes and standard RDF/XML namespaces are applied afterwards and take precedence on conflict.
- model.getNamespaces().stream()
- .filter(ns -> isAllowedXmlPrefix(ns.getPrefix()))
- .forEach(this.prefixHandler::setNamespace);
- if (config instanceof UsesPrefixOptions usesPrefixOptions && usesPrefixOptions.usePrefixes()) {
- usesPrefixOptions.getPrefixMapping().getPrefixMap().forEach((prefix, namespace) -> {
- if (isAllowedXmlPrefix(prefix)) {
- this.prefixHandler.setPrefix(prefix, namespace);
- }
- });
- } else {
- // These namespaces are part of the RDF/XML standard
- this.prefixHandler.setPrefix(RDF.getVocabularyPreferredPrefix(), RDF.getVocabularyNamespace());
- this.prefixHandler.setPrefix(XSD.getVocabularyPreferredPrefix(), XSD.getVocabularyNamespace());
+ if (config instanceof UsesPrefixOptions options && options.usePrefixes()) {
+ model.getNamespaces().forEach(ns -> registerPrefix(ns.getPrefix(), ns.getNamespace()));
+ options.getPrefixMapping().getPrefixMap().forEach(this::registerPrefix);
}
+ // RDF/XML syntax and XML namespace bindings cannot be overridden by data/configuration.
+ this.prefixHandler.setPrefix(RDF.getVocabularyPreferredPrefix(), RDF.getVocabularyNamespace());
+ this.prefixHandler.setPrefix(XMLConstants.XML_NS_PREFIX, XMLConstants.XML_NS_URI);
this.blankNodeIds = new HashMap<>();
}
- private static boolean isAllowedXmlPrefix(String prefix) {
+ private boolean isAllowedXmlPrefix(String prefix) {
return prefix != null && !prefix.isEmpty()
&& !"xml".equalsIgnoreCase(prefix)
&& !"xmlns".equalsIgnoreCase(prefix)
- && !RDFXMLUtils.isInvalidXMLName(prefix, false);
+ && isXmlName(prefix);
+ }
+
+ private static Document newNameValidator() {
+ try {
+ return DocumentBuilderFactory.newDefaultInstance().newDocumentBuilder().newDocument();
+ } catch (ParserConfigurationException e) {
+ throw new IllegalStateException("Cannot initialize XML name validation", e);
+ }
+ }
+
+ private boolean isXmlName(String name) {
+ if (name == null || name.isEmpty()) return false;
+ try {
+ // Stay compatible with runtimes implementing an older edition of XML 1.0.
+ nameValidator.createElementNS("urn:corese:xml-name", "p:" + name);
+ return true;
+ } catch (DOMException e) {
+ return false;
+ }
+ }
+
+ private void registerPrefix(String prefix, String namespace) {
+ if (isAllowedXmlPrefix(prefix) && !namespace.isEmpty()
+ && !XMLConstants.XML_NS_URI.equals(namespace)
+ && !XMLConstants.XMLNS_ATTRIBUTE_NS_URI.equals(namespace)) {
+ prefixHandler.setPrefix(prefix, namespace);
+ }
}
/**
@@ -110,6 +156,9 @@ public void write(Writer writer) throws SerializationException {
Writer bufferedWriter = new BufferedWriter(writer);
try {
this.cachedStatements = model.stream().toList();
+ propertyNames.clear();
+ cachedStatements.stream().map(statement -> statement.getPredicate().stringValue())
+ .distinct().sorted().forEach(iri -> propertyNames.put(iri, resolvePropertyName(iri)));
writeXmlDeclaration(bufferedWriter);
writeRdfRootElement(bufferedWriter);
@@ -149,6 +198,8 @@ private void writeXmlDeclaration(Writer writer) throws IOException {
private void writeRdfRootElement(Writer writer) throws IOException {
Set actuallyUsedNamespaces = new HashSet<>();
actuallyUsedNamespaces.add(RDF.getVocabularyNamespace());
+ propertyNames.values().stream().filter(name -> !name.getPrefix().isEmpty())
+ .map(QName::getNamespaceURI).forEach(actuallyUsedNamespaces::add);
if (this.config instanceof UsesPrefixOptions usesPrefixOptions
&& usesPrefixOptions.usePrefixes()
&& usesPrefixOptions.autoDeclarePrefixes()) {
@@ -194,12 +245,13 @@ private void writeRdfRootElement(Writer writer) throws IOException {
* @throws IOException if an I/O error occurs.
*/
private void writeNamespaceAttributes(Writer writer, Set actuallyUsedNamespaces) throws IOException {
- ArrayList namespacelist = new ArrayList<>(actuallyUsedNamespaces);
+ ArrayList namespaces = new ArrayList<>(actuallyUsedNamespaces);
+ namespaces.sort(Comparator.naturalOrder());
if(this.config instanceof UsesPrefixOptions usesPrefixOptions
&& usesPrefixOptions.autoDeclarePrefixes()) {
- namespacelist.forEach(namespace -> {
+ namespaces.forEach(namespace -> {
if (! this.prefixHandler.hasNamespace(namespace)) {
String prefix = getSuggestedPrefix(namespace);
if (prefix != null) {
@@ -211,21 +263,20 @@ private void writeNamespaceAttributes(Writer writer, Set actuallyUsedNam
if (this.config instanceof PrettyPrintOptions prettyPrintOptions
&& prettyPrintOptions.getPrefixOrdering() == PrefixOrdering.ALPHABETICAL) {
- namespacelist.sort(
+ namespaces.sort(
(ns1, ns2) ->
prefixHandler.getPrefix(ns1).compareTo(prefixHandler.getPrefix(ns2))
);
}
- for(String namespace : namespacelist) {
+ for(String namespace : namespaces) {
String prefix = this.prefixHandler.getPrefix(namespace);
writer.write(" " + SerializationConstants.XMLNS_PREFIX + prefix + "=\"" + escapeXmlAttribute(namespace) + "\"");
}
}
/**
- * Collects all namespaces used in the model (subjects, predicates, objects, contexts)
- * and attempts to assign prefixes if auto-declaration is enabled and they are not already mapped.
+ * Collects namespaces from model IRIs when automatic prefix discovery is enabled.
*/
private Set collectUsedNamespaces() {
// Collecting namespaces of all IRIs in the data
@@ -248,41 +299,59 @@ private Set collectUsedNamespaces() {
.filter(Objects::nonNull)
.filter(Value::isIRI)
.map(v -> IRIUtils.guessNamespace(v.stringValue()))
+ .filter(namespace -> !namespace.isEmpty() && !XMLConstants.XMLNS_ATTRIBUTE_NS_URI.equals(namespace))
.collect(Collectors.toSet());
}
- /**
- * Retrieves the prefixed name for a given IRI string.
- * This method now prioritizes the longest matching namespace to ensure correct prefix application.
- *
- * @param iriString The full IRI.
- * @return The prefixed name (e.g., "foaf:name") or null if no suitable prefix is found.
- */
- private String getPrefixedNameInternal(String iriString) {
- String longestMatchingNamespace = null;
- String correspondingPrefix = null;
- int longestMatchLength = -1;
-
- for (String namespace : this.prefixHandler.getNamespaces()) {
- if (iriString.startsWith(namespace)) {
- String prefix = this.prefixHandler.getPrefix(namespace);
- if (namespace.length() > longestMatchLength) {
- longestMatchLength = namespace.length();
- longestMatchingNamespace = namespace;
- correspondingPrefix = prefix;
- }
+ /** Resolves a predicate to an XML name without changing its RDF IRI. */
+ private QName resolvePropertyName(String iri) {
+ if (iri.startsWith(RDF.getVocabularyNamespace())
+ && RESERVED_RDF_PROPERTIES.contains(iri.substring(RDF.getVocabularyNamespace().length()))) {
+ throw new IllegalArgumentException("Reserved RDF/XML syntax cannot be used as a property: " + iri);
+ }
+ if (config instanceof UsesPrefixOptions options && options.usePrefixes()) {
+ String namespace = prefixHandler.getNamespaces().stream()
+ .filter(iri::startsWith)
+ .filter(ns -> isXmlName(iri.substring(ns.length())))
+ .max(Comparator.comparingInt(String::length)).orElse(null);
+ if (namespace != null) {
+ return new QName(namespace, iri.substring(namespace.length()), prefixHandler.getPrefix(namespace));
}
}
+ String namespace = IRIUtils.guessNamespace(iri);
+ String localName = iri.substring(namespace.length());
+ if (!isXmlName(localName) || XMLConstants.XMLNS_ATTRIBUTE_NS_URI.equals(namespace)) {
+ int split = propertyNameSplit(iri);
+ namespace = iri.substring(0, split);
+ localName = iri.substring(split);
+ }
+ if (namespace.isEmpty() || XMLConstants.XMLNS_ATTRIBUTE_NS_URI.equals(namespace)) {
+ throw new IllegalArgumentException("Invalid namespace for RDF/XML property: " + iri);
+ }
+ String prefix = "";
+ if (XMLConstants.XML_NS_URI.equals(namespace)) {
+ prefix = XMLConstants.XML_NS_PREFIX;
+ } else if (config instanceof UsesPrefixOptions options
+ && options.usePrefixes() && options.autoDeclarePrefixes()) {
+ prefix = getSuggestedPrefix(namespace);
+ prefixHandler.setPrefix(prefix, namespace);
+ }
+ return new QName(namespace, localName, prefix);
+ }
- if (longestMatchingNamespace != null) {
- String localName = iriString.substring(longestMatchingNamespace.length());
- if (localName.isEmpty()) {
- return correspondingPrefix + SerializationConstants.COLON;
- }
- return correspondingPrefix + SerializationConstants.COLON + localName;
+ /** Finds an XML-compatible NCName suffix while keeping a nonempty namespace. */
+ private int propertyNameSplit(String iri) {
+ int split = Math.max(iri.lastIndexOf(':') + 1,
+ Math.max(iri.lastIndexOf('/') + 1, iri.lastIndexOf('#') + 1));
+ while (split < iri.length() && (!isXmlName(iri.substring(split))
+ || XMLConstants.XMLNS_ATTRIBUTE_NS_URI.equals(iri.substring(0, split)))) {
+ split += Character.charCount(iri.codePointAt(split));
+ }
+ if (split == 0 || split == iri.length()) {
+ throw new IllegalArgumentException("Predicate IRI cannot be represented as an RDF/XML property name: " + iri);
}
- return null;
+ return split;
}
/**
@@ -342,18 +411,15 @@ private void writeDescriptionElement(Writer writer, Resource subject, List");
@@ -377,15 +443,11 @@ private void writeLiteralElement(Writer writer, Literal literal, String elementN
private void writeLiteralAttributes(Writer writer, Literal literal) throws IOException {
if (literal.getLanguage().isPresent()) {
- try {
- writer.write(" " + SerializationConstants.XML_LANG_ATTRIBUTE + "=\"" + escapeXmlAttribute(literal.getLanguage().orElseThrow()) + "\">");
- } catch (IOException e) {
- throw new UncheckedIOException("Failed to write xml:lang attribute", e);
- }
+ writer.write(" " + SerializationConstants.XML_LANG_ATTRIBUTE + "=\""
+ + escapeXmlAttribute(literal.getLanguage().orElseThrow()) + "\">");
} else if (shouldWriteDatatype(literal)) {
String datatypeUri = literal.getDatatype().stringValue();
- String prefixedDatatype = getPrefixedNameInternal(datatypeUri);
- writer.write(" " + SerializationConstants.RDF_DATATYPE_ATTRIBUTE + "=\"" + escapeXmlAttribute(prefixedDatatype != null ? prefixedDatatype : datatypeUri) + "\">");
+ writer.write(" " + SerializationConstants.RDF_DATATYPE_ATTRIBUTE + "=\"" + escapeXmlAttribute(datatypeUri) + "\">");
} else {
writer.write(">");
}
@@ -395,16 +457,25 @@ private void writeLiteralAttributes(Writer writer, Literal literal) throws IOExc
* Retrieves or generates a stable blank node ID.
*
* @param bNode the blank node.
- * @return a stable ID for the blank node.
+ * @return a unique XML-compatible ID for the blank node.
*/
private String getBlankNodeId(Resource bNode) {
- return blankNodeIds.computeIfAbsent(bNode, k -> {
- if (this.config instanceof BlankNodeIdGenerationOptions bnGenOptions && bnGenOptions.stableBlankNodeIds()) {
- return "b" + (blankNodeCounter++);
- } else {
- return bNode.stringValue().substring(2);
+ return blankNodeIds.computeIfAbsent(bNode, this::assignBlankNodeId);
+ }
+
+ private String assignBlankNodeId(Resource node) {
+ if (!(config instanceof BlankNodeIdGenerationOptions options && options.stableBlankNodeIds())) {
+ String label = node.stringValue();
+ String candidate = label.startsWith("_:") ? label.substring(2) : label;
+ if (isXmlName(candidate) && usedBlankNodeIds.add(candidate)) {
+ return candidate;
}
- });
+ }
+ String candidate;
+ do {
+ candidate = "b" + blankNodeCounter++;
+ } while (!usedBlankNodeIds.add(candidate));
+ return candidate;
}
/**
@@ -462,7 +533,7 @@ private String getSuggestedPrefix(String namespace) {
}
}
- base = base.replaceAll("[^a-zA-Z0-9]", SerializationConstants.EMPTY_STRING).toLowerCase();
+ base = base.replaceAll("[^a-zA-Z0-9]", SerializationConstants.EMPTY_STRING).toLowerCase(Locale.ROOT);
if (!isAllowedXmlPrefix(base)) base = "p";
String candidate = base;
@@ -481,59 +552,41 @@ private String getSuggestedPrefix(String namespace) {
* @return The escaped string.
*/
private String escapeXmlAttribute(String value) {
- StringBuilder sb = new StringBuilder();
- for (int i = 0; i < value.length(); i++) {
- char c = value.charAt(i);
- switch (c) {
- case '&':
- sb.append(SerializationConstants.AMP_ENTITY);
- break;
- case '<':
- sb.append(SerializationConstants.LT_ENTITY);
- break;
- case '>':
- sb.append(SerializationConstants.GT_ENTITY);
- break;
- case '"':
- sb.append(SerializationConstants.QUOT_ENTITY);
- break;
- case '\'':
- sb.append(SerializationConstants.APOS_ENTITY);
- break;
- default:
- sb.append(c);
- }
- }
- return sb.toString();
+ return escapeXml(value, true);
}
- /**
- * Escapes a string for use as XML element content.
- * Replaces characters like '&', '<', '>' with their XML entity equivalents.
- *
- * @param value The string to escape.
- * @return The escaped string.
- */
private String escapeXmlContent(String value) {
- StringBuilder sb = new StringBuilder();
- for (int i = 0; i < value.length(); i++) {
- char c = value.charAt(i);
- switch (c) {
- case '&':
- sb.append(SerializationConstants.AMP_ENTITY);
- break;
- case '<':
- sb.append(SerializationConstants.LT_ENTITY);
- break;
- case '>':
- sb.append(SerializationConstants.GT_ENTITY);
- break;
- default:
- sb.append(c);
- }
- }
- return sb.toString();
+ return escapeXml(value, false);
}
+ /** Escapes XML markup and preserves whitespace that XML parsers would normalize. */
+ private String escapeXml(String value, boolean attribute) {
+ StringBuilder result = new StringBuilder(value.length());
+ value.codePoints().forEach(cp -> appendXmlCharacter(result, cp, attribute));
+ return result.toString();
+ }
+
+ private static void appendXmlCharacter(StringBuilder result, int cp, boolean attribute) {
+ if (!isXmlCharacter(cp)) {
+ throw new IllegalArgumentException("Character U+" + Integer.toHexString(cp) + " cannot be represented in XML 1.0");
+ }
+ switch (cp) {
+ case '&' -> result.append(SerializationConstants.AMP_ENTITY);
+ case '<' -> result.append(SerializationConstants.LT_ENTITY);
+ case '>' -> result.append(SerializationConstants.GT_ENTITY);
+ case '"' -> result.append(attribute ? SerializationConstants.QUOT_ENTITY : "\"");
+ case '\'' -> result.append(attribute ? SerializationConstants.APOS_ENTITY : "'");
+ case '\r' -> result.append("
");
+ case '\n' -> result.append(attribute ? "
" : "\n");
+ case '\t' -> result.append(attribute ? " " : "\t");
+ default -> result.appendCodePoint(cp);
+ }
+ }
+ private static boolean isXmlCharacter(int cp) {
+ return cp == '\t' || cp == '\n' || cp == '\r'
+ || (cp >= 0x20 && cp <= 0xD7FF)
+ || (cp >= 0xE000 && cp <= 0xFFFD)
+ || (cp >= 0x10000 && cp <= Character.MAX_CODE_POINT);
+ }
}
diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializerOptions.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializerOptions.java
index a0e6f2369..381d04638 100644
--- a/src/main/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializerOptions.java
+++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializerOptions.java
@@ -23,13 +23,15 @@
public class RDFXMLSerializerOptions extends AbstractSerializerOptions implements PrettyPrintOptions, UsesPrefixOptions {
/**
- * Whether prefix declarations (e.g., `xmlns:prefix="uri"`) should be used for compact IRIs.
- * This is crucial for human-readable formats like RDF/XML.
+ * Whether property element names use namespace prefixes. When disabled,
+ * properties use local names with an inline default namespace declaration.
+ * RDF/XML syntax retains its required {@code rdf} binding.
*/
protected final boolean usePrefixes;
/**
- * Whether the serializer should automatically discover and declare prefixes used in the graph.
- * This avoids manual prefix configuration but can lead to more prefixes than strictly needed.
+ * Whether the serializer generates prefixes for namespaces discovered in the graph.
+ * Used model/configuration prefixes are always declared. If discovery is disabled,
+ * unmapped properties use an inline default namespace to keep the XML valid.
*/
protected final boolean autoDeclarePrefixes;
/**
@@ -96,7 +98,7 @@ protected RDFXMLSerializerOptions(Builder builder) {
}
/**
- * Checks if prefix declarations should be used for compact IRIs.
+ * Checks whether property names should use namespace prefixes.
*
* @return {@code true} if prefixes are used, {@code false} otherwise.
*/
@@ -106,7 +108,7 @@ public boolean usePrefixes() {
}
/**
- * Checks if the serializer should automatically discover and declare prefixes.
+ * Checks whether the serializer should generate prefixes for discovered namespaces.
*
* @return {@code true} if auto-declaration is enabled, {@code false} otherwise.
*/
@@ -248,7 +250,7 @@ public Builder(IOOptions otherOptions) {
}
/**
- * Sets whether prefix declarations should be used for compact IRIs.
+ * Sets whether property names use prefixes or inline default namespaces.
*
* @param usePrefixes {@code true} to use prefixes, {@code false} otherwise.
* @return The builder instance for fluent chaining.
@@ -259,7 +261,8 @@ public Builder usePrefixes(boolean usePrefixes) {
}
/**
- * Sets whether the serializer should automatically discover and declare prefixes.
+ * Sets whether prefixes are generated for discovered namespaces.
+ * Used explicit bindings are declared even when this option is disabled.
*
* @param autoDeclarePrefixes {@code true} to enable auto-declaration, {@code false} otherwise.
* @return The builder instance for fluent chaining.
diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/rdfxml/RDFXMLCircularTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/rdfxml/RDFXMLCircularTest.java
index 29ec24289..af87c84c5 100644
--- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/rdfxml/RDFXMLCircularTest.java
+++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/rdfxml/RDFXMLCircularTest.java
@@ -29,10 +29,6 @@
* are compatible and preserve data integrity across format transformations.
* RDF/XML supports namespaces, so additional tests are included for prefix
* handling.
- * NOTE: These tests are currently disabled because they cannot work yet.
- * We need to wait for the RDF/XML parser implementation from PR #176:
- * Once the parser is implemented, these tests can be enabled to verify
- * the round-trip functionality between the parser and serializer.
*/
@DisplayName("RDF/XML Circular Integration Tests")
class RDFXMLCircularTest extends ParserTestBase {
diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLNamespaceTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLNamespaceTest.java
new file mode 100644
index 000000000..b5d5346ed
--- /dev/null
+++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLNamespaceTest.java
@@ -0,0 +1,187 @@
+package fr.inria.corese.core.next.data.impl.io.serializer.rdfxml;
+
+import fr.inria.corese.core.next.data.api.exception.SerializationException;
+import fr.inria.corese.core.next.data.api.model.Model;
+import fr.inria.corese.core.next.data.api.vocabulary.RDF;
+import fr.inria.corese.core.next.data.api.vocabulary.XSD;
+import fr.inria.corese.core.next.data.impl.io.parser.rdfxml.RDFXMLParser;
+import fr.inria.corese.core.next.data.impl.io.parser.support.ParserTestBase;
+import fr.inria.corese.core.next.data.impl.namespace.PrefixHandler;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import java.io.IOException;
+import org.xml.sax.SAXException;
+import java.io.StringReader;
+import java.io.StringWriter;
+import org.xml.sax.InputSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/** Ensures namespace options preserve both XML validity and RDF meaning. */
+class RDFXMLNamespaceTest extends ParserTestBase {
+
+ @ParameterizedTest
+ @CsvSource({"true, true", "true, false", "false, true", "false, false"})
+ void namespaceOptionsPreserveGraph(boolean usePrefixes, boolean autoDeclare) throws ParserConfigurationException, SAXException, IOException {
+ Model model = createTestModel();
+ model.setNamespace("ex", "http://example.org/");
+ var subject = valueFactory.createIRI("http://example.org/subject");
+ var node = valueFactory.createBNode();
+ model.add(subject, valueFactory.createIRI("http://example.org/name"), valueFactory.createLiteral("Alice", "fr"));
+ model.add(subject, valueFactory.createIRI("http://unknown.org/count"), valueFactory.createLiteral("42", XSD.xsdInteger.getIRI()));
+ model.add(subject, valueFactory.createIRI("http://unknown.org/link"), node);
+ model.add(node, RDF.type.getIRI(), valueFactory.createIRI("http://example.org/Person"));
+ var options = new RDFXMLSerializerOptions.Builder()
+ .prefixHandler(new PrefixHandler(false))
+ .usePrefixes(usePrefixes).autoDeclarePrefixes(autoDeclare).build();
+
+ assertRoundTrip(model, options);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"", "http://wrong.example/rdf#"})
+ void rdfBindingIsPresentAndCannotBeOverridden(String replacement) throws ParserConfigurationException, SAXException, IOException {
+ Model model = createTestModel();
+ var predicate = valueFactory.createIRI("http://example.org/property");
+ model.add(valueFactory.createIRI("http://example.org/subject"), predicate, valueFactory.createLiteral("value"));
+ model.setNamespace("rdf", "http://model.example/incorrect#");
+ var mapping = new PrefixHandler(false);
+ if (!replacement.isEmpty()) {
+ mapping.setPrefix("rdf", replacement);
+ }
+ var options = new RDFXMLSerializerOptions.Builder().prefixHandler(mapping).build();
+
+ String xml = assertRoundTrip(model, options);
+ assertFalse(xml.contains("xmlns:null="));
+ assertEquals(RDF.getVocabularyNamespace(), parseXml(xml).getDocumentElement().lookupNamespaceURI("rdf"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "http://example.org/123name", "http://example.org/has%20name", "urn:example:property",
+ "http://example.org/cafe\u0301", "http://example.org/\u200Cname", "http://example.org/\uD800\uDC00name",
+ "http://www.w3.org/2000/xmlns/property"
+ })
+ void predicateNamesPreserveTheirExactIri(String predicate) throws ParserConfigurationException, SAXException, IOException {
+ Model model = createTestModel();
+ model.add(valueFactory.createIRI("http://example.org/subject"),
+ valueFactory.createIRI(predicate), valueFactory.createLiteral("value"));
+
+ assertRoundTrip(model, RDFXMLSerializerOptions.defaultConfig());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "http://example.org/", "http://example.org/123",
+ "http://www.w3.org/1999/02/22-rdf-syntax-ns#li",
+ "http://www.w3.org/1999/02/22-rdf-syntax-ns#Description",
+ "http://www.w3.org/2000/xmlns/p"
+ })
+ void unrepresentablePredicatesFailBeforeWriting(String predicate) {
+ Model model = createTestModel();
+ model.add(valueFactory.createIRI("http://example.org/subject"),
+ valueFactory.createIRI(predicate), valueFactory.createLiteral("value"));
+ var serializer = new RDFXMLSerializer(model);
+ var output = new StringWriter();
+
+ assertThrows(SerializationException.class, () -> serializer.write(output));
+ assertEquals("", output.toString());
+ }
+
+ @Test
+ void datatypeAttributesContainFullIris() throws ParserConfigurationException, SAXException, IOException {
+ Model model = createTestModel();
+ var predicate = valueFactory.createIRI("http://example.org/count");
+ model.add(valueFactory.createIRI("http://example.org/subject"), predicate,
+ valueFactory.createLiteral("42", XSD.xsdInteger.getIRI()));
+
+ String xml = assertRoundTrip(model, RDFXMLSerializerOptions.defaultConfig());
+ var element = parseXml(xml).getElementsByTagNameNS("http://example.org/", "count").item(0);
+ assertEquals(XSD.xsdInteger.getIRI().stringValue(),
+ element.getAttributes().getNamedItemNS(RDF.getVocabularyNamespace(), "datatype").getNodeValue());
+ }
+
+ @Test
+ void reservedNamespaceIrisInAttributesDoNotCreateNamespaceDeclarations() throws ParserConfigurationException, SAXException, IOException {
+ Model model = createTestModel();
+ String namespace = XMLConstants.XMLNS_ATTRIBUTE_NS_URI;
+ var subject = valueFactory.createIRI(namespace + "subject");
+ model.add(subject, valueFactory.createIRI("http://example.org/link"), valueFactory.createIRI(namespace + "object"));
+ model.add(subject, valueFactory.createIRI("http://example.org/value"),
+ valueFactory.createLiteral("text", valueFactory.createIRI(namespace + "datatype")));
+
+ assertRoundTrip(model, RDFXMLSerializerOptions.defaultConfig());
+ }
+
+ @Test
+ void invalidBlankNodeLabelsAndGeneratedIdsDoNotCollide() throws ParserConfigurationException, SAXException, IOException {
+ Model model = createTestModel();
+ var first = valueFactory.createBNode("123bad");
+ var second = valueFactory.createBNode("b0");
+ var predicate = valueFactory.createIRI("http://example.org/link");
+ model.add(first, predicate, second);
+ model.add(second, predicate, first);
+
+ assertRoundTrip(model, new RDFXMLSerializerOptions.Builder().stableBlankNodeIds(false).build());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"a\rb", "a\r\nb", "a\nb", "a\tb", "text \uD83D\uDE00"})
+ void literalWhitespaceAndUnicodeSurviveRoundTrip(String value) throws ParserConfigurationException, SAXException, IOException {
+ Model model = createTestModel();
+ model.add(valueFactory.createIRI("http://example.org/subject"),
+ valueFactory.createIRI("http://example.org/value"), valueFactory.createLiteral(value));
+
+ assertRoundTrip(model, RDFXMLSerializerOptions.defaultConfig());
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, 11, 0xD800, 0xFFFF})
+ void charactersNotRepresentableInXmlFailExplicitly(int codePoint) {
+ Model model = createTestModel();
+ model.add(valueFactory.createIRI("http://example.org/subject"),
+ valueFactory.createIRI("http://example.org/value"),
+ valueFactory.createLiteral("invalid" + Character.toString(codePoint)));
+ var serializer = new RDFXMLSerializer(model);
+ var output = new StringWriter();
+
+ assertThrows(SerializationException.class, () -> serializer.write(output));
+ }
+
+ private String assertRoundTrip(Model original, RDFXMLSerializerOptions options) throws ParserConfigurationException, SAXException, IOException {
+ var output = new StringWriter();
+ new RDFXMLSerializer(original, options).write(output);
+ String xml = output.toString();
+ parseXml(xml);
+ Model parsed = createTestModel();
+ new RDFXMLParser(parsed, valueFactory).parse(new StringReader(xml));
+ assertModelsIsomorphic(original, parsed);
+ return xml;
+ }
+
+ private static org.w3c.dom.Document parseXml(String xml) throws ParserConfigurationException, SAXException, IOException {
+ var factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+ try {
+ factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
+ factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
+ } catch (IllegalArgumentException e) {
+ // Ignore if parser implementation does not recognize JAXP 1.5 properties
+ }
+ factory.setXIncludeAware(false);
+ factory.setExpandEntityReferences(false);
+ factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
+ return factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml)));
+ }
+}
diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializerTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializerTest.java
index c1aa85a2f..d27c5e0d0 100644
--- a/src/test/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializerTest.java
+++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/serializer/rdfxml/RDFXMLSerializerTest.java
@@ -204,7 +204,7 @@ void shouldSerializeLiteralWithCustomDatatypeMinimalPolicy() throws Serializatio
- 123
+ 123
""";
@@ -411,7 +411,7 @@ void shouldNotAutoDeclarePrefixesIfDisabled() throws SerializationException {
String expected = """
-
+
@@ -445,7 +445,7 @@ void shouldNotUsePrefixesIfDisabled() throws SerializationException {
-
+
""";
@@ -484,11 +484,11 @@ void shouldNotGenerateStableBlankNodeIds() throws SerializationException {
String expected = """
-
+
-
+
""";