From b188c73d71db90477a37f9a07763adda8b1877e9 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:12:29 +0100 Subject: [PATCH 1/2] fix: scope blank node labels to formulas only in N3 mode Entering a list or blank node property list no longer rescopes labels, so `_:a` inside a top-level list co-references `_:a` outside it instead of producing an invalid `_:.a` label. Co-Authored-By: Claude Fable 5 --- src/N3Parser.js | 13 ++++++++----- test/N3Parser-test.js | 35 +++++++++++++++++++++++++++++++++++ test/N3Writer-test.js | 11 +++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/N3Parser.js b/src/N3Parser.js index 878dad2c..e80d7fce 100644 --- a/src/N3Parser.js +++ b/src/N3Parser.js @@ -87,11 +87,14 @@ export default class N3Parser { if (n3Mode) { // Every new scope resets the predicate direction this._inversePredicate = false; - // In N3, blank nodes are scoped to a formula - // (using a dot as separator, as a blank node label cannot start with it) - this._prefixes._ = (this._graph ? `${this._graph.value}.` : '.'); - // Quantifiers are scoped to a formula - this._quantified = Object.create(this._quantified); + // In N3, blank nodes and quantifiers are scoped to a formula; + // lists and blank node property lists share the enclosing scope + if (type === 'formula') { + // Label the scope with the formula's blank node + // (using a dot as separator, as a blank node label cannot start with it) + this._prefixes._ = `${this._graph.value}.`; + this._quantified = Object.create(this._quantified); + } } } diff --git a/test/N3Parser-test.js b/test/N3Parser-test.js index 963e9ee4..1a4708db 100644 --- a/test/N3Parser-test.js +++ b/test/N3Parser-test.js @@ -2609,6 +2609,41 @@ describe('Parser', () => { ['_:b3.a', '_:b3.b', '_:b3.c', '_:b3']), ); + it( + 'should parse a blank node in a list', + shouldParse(parser, '

(_:a).', + ['s', 'p', '_:b0'], + ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first', '_:b0_a'], + ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil']), + ); + + it( + 'should reuse identifiers of blank nodes within and outside of lists', + shouldParse(parser, '

(_:a). _:a .', + ['s', 'p', '_:b0'], + ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first', '_:b0_a'], + ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil'], + ['_:b0_a', 'b', 'c']), + ); + + it( + 'should reuse identifiers of blank nodes within and outside of blank node property lists', + shouldParse(parser, '_:a

[ _:a ].', + ['_:b0_a', 'p', '_:b0'], + ['_:b0', 'q', '_:b0_a']), + ); + + it( + 'should scope blank nodes in a list to the enclosing formula', + shouldParse(parser, '_:a

. { (_:a). _:a . } .', + ['_:b0_a', 'p', 'o'], + ['s', 'q', '_:b1', '_:b0'], + ['_:b1', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first', '_:b0.a', '_:b0'], + ['_:b1', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil', '_:b0'], + ['_:b0.a', 'r', 'o2', '_:b0'], + ['_:b0', 'd', 'e']), + ); + it( 'should parse a @forSome statement', shouldParse(parser, '@forSome . .', diff --git a/test/N3Writer-test.js b/test/N3Writer-test.js index e36d57f7..9c2c004d 100644 --- a/test/N3Writer-test.js +++ b/test/N3Writer-test.js @@ -730,6 +730,17 @@ describe('Writer', () => { }); }); + it('should serialize a blank node in an N3 list with a valid label', done => { + const quads = new Parser({ format: 'text/n3' }).parse(' (_:x). _:x .'); + const writer = new Writer(); + writer.addQuads(quads); + writer.end((error, output) => { + // A label such as `_:.x` would fail to reparse (#332) + expect(() => new Parser().parse(output)).not.toThrow(); + done(error); + }); + }); + it( 'should serialize subject and object triples passed by options.listHeads', done => { From cbaeff1cc74a8fa729345bc8967055725c8b88ed Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:38:53 +0100 Subject: [PATCH 2/2] fix: make formula-only blank node scoping opt-in The default keeps the released behaviour of rescoping blank node labels in lists and blank node property lists; the new formulaScopedBlankNodes option enables formula-only scoping until the default flips in a next major version. Co-Authored-By: Claude Fable 5 --- README.md | 10 ++++++++ src/N3Parser.js | 11 +++++---- test/N3Parser-test.js | 54 +++++++++++++++++++++++++++++++++++++------ test/N3Writer-test.js | 16 +++++++++++-- 4 files changed, 78 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index bec8efe2..8365734f 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,16 @@ The parser can output a backwards chaining rule such as `_:q <= _:p.` in two way const parser = new N3.Parser({ isImpliedBy: true }); ``` +In N3 documents, the parser by default rescopes blank node labels +in lists and blank node property lists, in addition to formulas, +so `_:a` inside a list does not co-reference `_:a` outside of it. +The `formulaScopedBlankNodes` flag scopes blank node labels to formulas only, +matching N3's formula-scoped blank node semantics +(this will become the default in the next major version): +```JavaScript +const parser = new N3.Parser({ format: 'N3', formulaScopedBlankNodes: true }); +``` + ### From an RDF stream to quads `N3.Parser` can parse [Node.js streams](http://nodejs.org/api/stream.html) as they grow, diff --git a/src/N3Parser.js b/src/N3Parser.js index e80d7fce..abca9229 100644 --- a/src/N3Parser.js +++ b/src/N3Parser.js @@ -37,6 +37,8 @@ export default class N3Parser { this._lexer = options.lexer || new N3Lexer({ lineMode: isLineMode, n3: isN3, isImpliedBy: this._isImpliedBy }); // Disable explicit quantifiers by default this._explicitQuantifiers = !!options.explicitQuantifiers; + // Disable formula-only blank node scoping by default + this._formulaScopedBlankNodes = !!options.formulaScopedBlankNodes; // Disable parsing of unsupported versions by default this._parseUnsupportedVersions = !!options.parseUnsupportedVersions; this._version = options.version; @@ -88,11 +90,12 @@ export default class N3Parser { // Every new scope resets the predicate direction this._inversePredicate = false; // In N3, blank nodes and quantifiers are scoped to a formula; - // lists and blank node property lists share the enclosing scope - if (type === 'formula') { - // Label the scope with the formula's blank node + // with `formulaScopedBlankNodes`, lists and blank node property lists + // share the enclosing scope instead of rescoping + if (!this._formulaScopedBlankNodes || type === 'formula') { + // Label the scope with the enclosing formula's blank node // (using a dot as separator, as a blank node label cannot start with it) - this._prefixes._ = `${this._graph.value}.`; + this._prefixes._ = (this._graph ? `${this._graph.value}.` : '.'); this._quantified = Object.create(this._quantified); } } diff --git a/test/N3Parser-test.js b/test/N3Parser-test.js index 1a4708db..18ada579 100644 --- a/test/N3Parser-test.js +++ b/test/N3Parser-test.js @@ -2447,6 +2447,7 @@ describe('Parser', () => { describe('A Parser instance for the N3 format', () => { function parser() { return new Parser({ baseIRI: BASE_IRI, format: 'N3' }); } function parserIsImpliedBy() { return new Parser({ baseIRI: BASE_IRI, format: 'N3', isImpliedBy: true }); } + function parserFormulaScoped() { return new Parser({ baseIRI: BASE_IRI, format: 'N3', formulaScopedBlankNodes: true }); } it( 'should parse a single triple', @@ -2609,32 +2610,36 @@ describe('Parser', () => { ['_:b3.a', '_:b3.b', '_:b3.c', '_:b3']), ); + // The tests below pin the default behaviour of rescoping blank node + // labels in lists and blank node property lists (#332); + // the default flips to `formulaScopedBlankNodes` in a next major version (#630) + it( - 'should parse a blank node in a list', + 'should rescope a blank node in a list by default', shouldParse(parser, '

(_:a).', ['s', 'p', '_:b0'], - ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first', '_:b0_a'], + ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first', '_:.a'], ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil']), ); it( - 'should reuse identifiers of blank nodes within and outside of lists', + 'should not reuse identifiers of blank nodes within and outside of lists by default', shouldParse(parser, '

(_:a). _:a .', ['s', 'p', '_:b0'], - ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first', '_:b0_a'], + ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first', '_:.a'], ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil'], ['_:b0_a', 'b', 'c']), ); it( - 'should reuse identifiers of blank nodes within and outside of blank node property lists', + 'should not reuse identifiers of blank nodes within and outside of blank node property lists by default', shouldParse(parser, '_:a

[ _:a ].', ['_:b0_a', 'p', '_:b0'], - ['_:b0', 'q', '_:b0_a']), + ['_:b0', 'q', '_:.a']), ); it( - 'should scope blank nodes in a list to the enclosing formula', + 'should scope blank nodes in a list to the enclosing formula by default', shouldParse(parser, '_:a

. { (_:a). _:a . } .', ['_:b0_a', 'p', 'o'], ['s', 'q', '_:b1', '_:b0'], @@ -2644,6 +2649,41 @@ describe('Parser', () => { ['_:b0', 'd', 'e']), ); + it( + 'should parse a blank node in a list with formulaScopedBlankNodes', + shouldParse(parserFormulaScoped, '

(_:a).', + ['s', 'p', '_:b0'], + ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first', '_:b0_a'], + ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil']), + ); + + it( + 'should reuse identifiers of blank nodes within and outside of lists with formulaScopedBlankNodes', + shouldParse(parserFormulaScoped, '

(_:a). _:a .', + ['s', 'p', '_:b0'], + ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first', '_:b0_a'], + ['_:b0', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil'], + ['_:b0_a', 'b', 'c']), + ); + + it( + 'should reuse identifiers of blank nodes within and outside of blank node property lists with formulaScopedBlankNodes', + shouldParse(parserFormulaScoped, '_:a

[ _:a ].', + ['_:b0_a', 'p', '_:b0'], + ['_:b0', 'q', '_:b0_a']), + ); + + it( + 'should scope blank nodes in a list to the enclosing formula with formulaScopedBlankNodes', + shouldParse(parserFormulaScoped, '_:a

. { (_:a). _:a . } .', + ['_:b0_a', 'p', 'o'], + ['s', 'q', '_:b1', '_:b0'], + ['_:b1', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first', '_:b0.a', '_:b0'], + ['_:b1', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil', '_:b0'], + ['_:b0.a', 'r', 'o2', '_:b0'], + ['_:b0', 'd', 'e']), + ); + it( 'should parse a @forSome statement', shouldParse(parser, '@forSome . .', diff --git a/test/N3Writer-test.js b/test/N3Writer-test.js index 9c2c004d..c528384a 100644 --- a/test/N3Writer-test.js +++ b/test/N3Writer-test.js @@ -730,8 +730,8 @@ describe('Writer', () => { }); }); - it('should serialize a blank node in an N3 list with a valid label', done => { - const quads = new Parser({ format: 'text/n3' }).parse(' (_:x). _:x .'); + it('should serialize a blank node in an N3 list with a valid label when formulaScopedBlankNodes is set', done => { + const quads = new Parser({ format: 'text/n3', formulaScopedBlankNodes: true }).parse(' (_:x). _:x .'); const writer = new Writer(); writer.addQuads(quads); writer.end((error, output) => { @@ -741,6 +741,18 @@ describe('Writer', () => { }); }); + it('should serialize a blank node in an N3 list with an invalid label by default', done => { + const quads = new Parser({ format: 'text/n3' }).parse(' (_:x). _:x .'); + const writer = new Writer(); + writer.addQuads(quads); + writer.end((error, output) => { + // The default rescoping produces the label `_:.x`, + // which fails to reparse (#332; the default flips in #630) + expect(() => new Parser().parse(output)).toThrow(); + done(error); + }); + }); + it( 'should serialize subject and object triples passed by options.listHeads', done => {