Skip to content

Commit 0020444

Browse files
rdf/turtle: emit a space before ';' and '.' terminators (JavaScriptSolidServer#419) (JavaScriptSolidServer#420)
* rdf/turtle: emit a space before `;` and `.` terminators (JavaScriptSolidServer#419) Match the de-facto Turtle style used in W3C 1.1 spec examples, Apache Jena's RIOT writer, and most hand-authored Turtle in the Solid / linked-data ecosystem: a space between the previous token and the statement terminator. Before: <s> foaf:name "Alice"; foaf:age 30. After: <s> foaf:name "Alice" ; foaf:age 30 . n3.js's writer hardcodes the no-space form (`;\n next`) and exposes no config knob. Approach: a literal-aware post-pass on the writer's output. The naïve `\S;\n` → `\S ;\n` regex is unsafe — string literals (especially triple-quoted) can contain `;\n` or `.\n` internally, and inserting a space inside a literal would silently CHANGE the literal's value. So: 1. Stash every string literal AND every <IRI> into placeholders using a multi-character non-token-boundary sentinel bracketed by NULs (n3.js escapes raw NUL inside literals and never emits one in real Turtle output, so the sentinel can't collide with real content). 2. Apply the spacing regex to the redacted output. Now `;`/`.` only appear as actual statement terminators because all literal/IRI internals are hidden. 3. Restore placeholders. Stash order matters — triple-quoted before single-quoted, else `"""` looks like an empty `""` followed by `"` to the single-quoted regex. Same for triple-vs-single apostrophe. Tests: - "emits a space before ; and . terminators": positive - "does NOT add a space inside literals containing ; or .": safety regression — the post-pass MUST NOT corrupt literal values like `"foo;bar"`, `"has.dot"`, `"a;b.c"`. - "does NOT add a space inside an IRI containing ;": safety regression for IRIs like `<https://example.test/path;with;semis>`. Test count: 770 → 777 in full suite (+7: 3 JavaScriptSolidServer#419 tests + 4 JavaScriptSolidServer#416/JavaScriptSolidServer#417/JavaScriptSolidServer#415 follow-ups since main). * Address copilot pass 1 on JavaScriptSolidServer#420 — assert by value, not by lexical form The two safety regression tests for JavaScriptSolidServer#419 hardcoded that N3 serializes string literals with double quotes (`"foo;bar"`) and IRIs as `<...>`. That made them sensitive to N3 writer formatting choices — single quotes, long-string `"""..."""`, IRI escaping, etc. — even when the underlying literal value would still be preserved correctly. Fix: parse the emitted Turtle back to quads and assert the literal/IRI VALUES, not their lexical form. The properties the tests actually want to enforce are: - "foo;bar" round-trips as a literal whose value is foo;bar (no inserted space) - <https://example.test/path;with;semis> round-trips as a NamedNode with that exact IRI Both are now assertion-by-value. Resilient against any future N3 upgrade that changes quote style, IRI escaping, or the long- string threshold. Test count: same 777. * Address copilot pass 2 on JavaScriptSolidServer#420 — pin terminator presence The "emits a space before ; and ." test asserted no offending terminators (without preceding whitespace) but didn't assert the terminators were actually present. If a future N3 upgrade ever switched to the "one triple per statement, no ; continuations" output style, the negative assertions would pass vacuously and the test would stop exercising the spacing behavior at all. Fix: pin the presence of at least one ` ;` and one ` .` (with preceding whitespace) before checking for offending forms. Now the test fails loudly if either disappears. Test count: same 777. * Address copilot pass 3 on JavaScriptSolidServer#420 — require literal space, not any whitespace The pass-2 assertions used `\s` / `[\s]` patterns, which would treat `\n;`, `\t;`, etc. as acceptable too. The JavaScriptSolidServer#419 intent is specifically a single SPACE before the terminator (matching W3C Turtle 1.1 examples and Apache Jena's RIOT writer): `value ;` / `value .` If a future N3 upgrade ever emitted `\n;` or `\t;`, the pass-2 test would have passed despite the visually-different output. Fix: tighten the assertions to require a literal ` ;` / ` .` (a single space). Both presence-checks and the offending-form negative checks use ` ` (space) explicitly instead of `\s`. Test count: same 777.
1 parent d783ffd commit 0020444

2 files changed

Lines changed: 89 additions & 0 deletions

File tree

src/rdf/turtle.js

2.35 KB
Binary file not shown.

test/turtle.test.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,95 @@ describe('turtle converter — unit (#320 follow-ups)', () => {
153153
`node n3 should appear:\n${content}`);
154154
});
155155

156+
it('emits a space before `;` and `.` terminators (#419)', async () => {
157+
// The de-facto convention in W3C spec examples and Apache Jena
158+
// RIOT is to separate the statement-terminator from the previous
159+
// token by a space. n3.js packs them; JSS post-processes the
160+
// output to add the space.
161+
//
162+
// Use TWO predicates on the same subject so n3.js emits a `;`
163+
// continuation (multiple triples on one subject). If a future
164+
// N3 upgrade ever switched to "one triple per statement" style
165+
// and dropped `;` entirely, this test would otherwise pass
166+
// vacuously. The presence-of-terminator assertions below pin
167+
// that behavior.
168+
const doc = {
169+
'@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' },
170+
'@id': 'https://example.test/alice',
171+
'foaf:name': 'Alice',
172+
'foaf:age': 30,
173+
};
174+
const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
175+
// Pin "at least one ` ;` and one ` .` exists" with a literal
176+
// SPACE — not just any whitespace. The intended output style
177+
// (matching W3C Turtle 1.1 spec examples) is a single space
178+
// separator: `value ;` / `value .`. Allowing `\n;` or `\t;`
179+
// would let the test pass on visually-different output.
180+
assert.match(content, / ;/, `output must contain at least one " ;" terminator (space-prefixed), got:\n${content}`);
181+
assert.match(content, / \.(?:\s|$)/, `output must contain at least one " ." terminator (space-prefixed), got:\n${content}`);
182+
// Every `;` must be preceded by a literal space. Same for `.`
183+
// at end-of-statement. Reject anything else (newline, tab,
184+
// packed-against-token).
185+
const offendingSemi = /[^ ];/.test(content);
186+
const offendingDot = /[^ ]\.\s*$/m.test(content) || /[^ ]\.\n/.test(content);
187+
assert.ok(!offendingSemi, `every ; must be preceded by a single space, got:\n${content}`);
188+
assert.ok(!offendingDot, `every . at line/doc end must be preceded by a single space, got:\n${content}`);
189+
});
190+
191+
it('does NOT add a space inside literals containing `;` or `.` (#419 safety)', async () => {
192+
// Critical correctness test: the post-pass must NOT corrupt
193+
// literal values. A literal "foo;bar" with the post-pass naïvely
194+
// applied would become "foo ;bar" — silent data corruption.
195+
//
196+
// Assert by VALUE, not by lexical form. A quote-agnostic parser
197+
// round-trip survives any future N3 writer style change
198+
// (single vs double quotes, long-string `"""..."""`, etc.).
199+
const doc = {
200+
'@context': { 'ex': 'https://example.test/ns#' },
201+
'@id': 'https://example.test/s',
202+
'ex:semicolonInside': 'foo;bar',
203+
'ex:dotInside': 'has.dot',
204+
'ex:both': 'a;b.c',
205+
};
206+
const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
207+
// Round-trip: parse the emitted Turtle, walk the quads, assert
208+
// the literal values came back exactly as authored.
209+
const { Parser } = await import('n3');
210+
const parser = new Parser({ baseIRI: 'https://example.test/' });
211+
const quads = parser.parse(content);
212+
const objectsByPredicate = new Map();
213+
for (const q of quads) {
214+
if (q.object.termType !== 'Literal') continue;
215+
objectsByPredicate.set(q.predicate.value, q.object.value);
216+
}
217+
assert.strictEqual(objectsByPredicate.get('https://example.test/ns#semicolonInside'), 'foo;bar',
218+
`literal value for ex:semicolonInside must be "foo;bar"`);
219+
assert.strictEqual(objectsByPredicate.get('https://example.test/ns#dotInside'), 'has.dot',
220+
`literal value for ex:dotInside must be "has.dot"`);
221+
assert.strictEqual(objectsByPredicate.get('https://example.test/ns#both'), 'a;b.c',
222+
`literal value for ex:both must be "a;b.c"`);
223+
});
224+
225+
it('does NOT add a space inside an IRI containing `;` (#419 safety)', async () => {
226+
// An IRI's content is bracketed by `<...>` — the post-pass
227+
// shouldn't touch what's inside. Assert by value via parser
228+
// round-trip so we don't depend on N3 writer formatting.
229+
const doc = {
230+
'@context': { 'ex': 'https://example.test/ns#' },
231+
'@id': 'https://example.test/s',
232+
'ex:rel': { '@id': 'https://example.test/path;with;semis' },
233+
};
234+
const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
235+
const { Parser } = await import('n3');
236+
const parser = new Parser({ baseIRI: 'https://example.test/' });
237+
const quads = parser.parse(content);
238+
const rels = quads
239+
.filter(q => q.predicate.value === 'https://example.test/ns#rel')
240+
.map(q => q.object.value);
241+
assert.deepStrictEqual(rels, ['https://example.test/path;with;semis'],
242+
`IRI value must round-trip with internal ; intact`);
243+
});
244+
156245
it('cyclical nested node reference does not hang', async () => {
157246
// Two nested nodes reference each other. BFS must not loop.
158247
const a = { '@id': 'https://example.test/a', 'ex:knows': null };

0 commit comments

Comments
 (0)