Skip to content

Commit 7ed04bf

Browse files
smessieRubenVerborgh
authored andcommitted
feat: Support writing relative IRIs with dot segments
1 parent 30174ff commit 7ed04bf

File tree

6 files changed

+4948
-11
lines changed

6 files changed

+4948
-11
lines changed

.eslintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@
232232
'import/first': 2,
233233
'import/group-exports': 0,
234234
'import/imports-first': 2,
235-
'import/max-dependencies': 2,
235+
'import/max-dependencies': [2, {"max": 15}],
236236
'import/newline-after-import': 2,
237237
'import/no-anonymous-default-export': 0,
238238
'import/no-default-export': 0,

src/BaseIRI.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
export default class BaseIRI {
2+
constructor(iri) {
3+
if (iri.startsWith('file://')) {
4+
// Base IRIs starting with file:// are not supported. Silently fail.
5+
return;
6+
}
7+
8+
// Generate regex for baseIRI with optional groups for segments
9+
// Stage 0: find the first scheme delimiter -> stage 1
10+
// Stage 1: find the next /, ?, or #. '/' -> new segment -> stage 1, '?' -> stage 2, '#' -> update end to position before hash -> stage 3, none -> stage 3
11+
// Stage 2: find the next #. '#' -> update end to position before hash -> stage 3, none -> stage 3
12+
// Stage 3: find the end of the string, add '$' to this last segment
13+
this._baseSubstitutions = {};
14+
let baseIRIRegex = '';
15+
let segmentsCount = 0;
16+
let stage = 0;
17+
const slashPositions = [];
18+
let i = 0;
19+
let containsQuery = false;
20+
// Stage 0
21+
const match = /:\/{0,2}/.exec(iri);
22+
if (match) {
23+
baseIRIRegex += escapeRegex(iri.substring(0, match.index + match[0].length));
24+
i = match.index + match[0].length;
25+
stage = 1;
26+
}
27+
else {
28+
// Base IRI should contain a scheme followed by its delimiter (e.g., http://). Silently fail.
29+
return;
30+
}
31+
32+
if (/\/\.{0,2}\//.test(iri.substring(i))) {
33+
// Base IRIs containing `//`, `/./`, or `/../` are not supported. Silently fail.
34+
return;
35+
}
36+
37+
let end = iri.length;
38+
while (stage === 1 && i < end) {
39+
// Stage 1
40+
const match = /[/?#]/.exec(iri.substring(i));
41+
if (match) {
42+
if (match[0] === '#') {
43+
// Stop at this hash.
44+
end = i + match.index;
45+
stage = 3;
46+
}
47+
else {
48+
baseIRIRegex += escapeRegex(iri.substring(i, i + match.index + 1));
49+
baseIRIRegex += '(';
50+
segmentsCount++;
51+
if (match[0] === '/') {
52+
slashPositions.push(i + match.index);
53+
}
54+
else {
55+
this._baseSubstitutions[i + match.index] = '?';
56+
containsQuery = true;
57+
stage = 2;
58+
}
59+
i += match.index + 1;
60+
}
61+
}
62+
else {
63+
stage = 3;
64+
}
65+
}
66+
if (stage === 2) {
67+
// Stage 2
68+
const match = /#/.exec(iri.substring(i));
69+
if (match) {
70+
// Stop at this hash.
71+
end = i + match.index;
72+
}
73+
stage = 3;
74+
}
75+
if (stage === 3) {
76+
// Stage 3
77+
baseIRIRegex += escapeRegex(iri.substring(i, end));
78+
if (containsQuery) {
79+
baseIRIRegex += '(#|$)';
80+
}
81+
else {
82+
baseIRIRegex += '([?#]|$)';
83+
}
84+
i = end;
85+
}
86+
87+
// Complete the optional groups for the segments
88+
baseIRIRegex += ')?'.repeat(segmentsCount);
89+
90+
// Precalculate the rest of the substitutions
91+
if (this._baseSubstitutions[end - 1] === undefined) {
92+
this._baseSubstitutions[end - 1] = '';
93+
}
94+
for (let i = 0; i < slashPositions.length; i++) {
95+
this._baseSubstitutions[slashPositions[i]] = '../'.repeat(slashPositions.length - i - 1);
96+
}
97+
this._baseSubstitutions[slashPositions[slashPositions.length - 1]] = './';
98+
99+
// Set the baseMatcher
100+
this._baseMatcher = new RegExp(baseIRIRegex);
101+
this._baseLength = end;
102+
this.value = iri.substring(0, end);
103+
}
104+
105+
toRelative(iri) {
106+
if (iri.startsWith('file://')) {
107+
return iri;
108+
}
109+
const delimiterMatch = /:\/{0,2}/.exec(iri);
110+
if (!delimiterMatch || /\/\.{0,2}\//.test(iri.substring(delimiterMatch.index + delimiterMatch[0].length))) {
111+
return iri;
112+
}
113+
const match = this._baseMatcher.exec(iri);
114+
if (!match) {
115+
return iri;
116+
}
117+
const length = match[0].length;
118+
if (length === this._baseLength && length === iri.length) {
119+
return '';
120+
}
121+
let substitution = this._baseSubstitutions[length - 1];
122+
if (substitution !== undefined) {
123+
const substr = iri.substring(length);
124+
if (substitution === './' && substr && ((!substr.startsWith('#') && !substr.startsWith('?')) || length === this._baseLength)) {
125+
substitution = '';
126+
}
127+
return substitution + substr;
128+
}
129+
// Matched the [?#], so make sure to add the delimiter
130+
return iri.substring(length - 1);
131+
}
132+
}
133+
134+
function escapeRegex(regex) {
135+
return regex.replace(/[\]\/\(\)\*\+\?\.\\\$]/g, '\\$&');
136+
}

src/N3Writer.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import namespaces from './IRIs';
33
import { default as N3DataFactory, Term } from './N3DataFactory';
44
import { isDefaultGraph } from './N3Util';
5+
import BaseIRI from './BaseIRI';
56

67
const DEFAULTGRAPH = N3DataFactory.defaultGraph();
78

@@ -58,9 +59,7 @@ export default class N3Writer {
5859
this._prefixIRIs = Object.create(null);
5960
options.prefixes && this.addPrefixes(options.prefixes);
6061
if (options.baseIRI) {
61-
this._baseMatcher = new RegExp(`^${escapeRegex(options.baseIRI)
62-
}${options.baseIRI.endsWith('/') ? '' : '[#?]'}`);
63-
this._baseLength = options.baseIRI.length;
62+
this._baseIri = new BaseIRI(options.baseIRI);
6463
}
6564
}
6665
else {
@@ -153,8 +152,9 @@ export default class N3Writer {
153152
}
154153
let iri = entity.value;
155154
// Use relative IRIs if requested and possible
156-
if (this._baseMatcher && this._baseMatcher.test(iri))
157-
iri = iri.substr(this._baseLength);
155+
if (this._baseIri) {
156+
iri = this._baseIri.toRelative(iri);
157+
}
158158
// Escape special characters
159159
if (escape.test(iri))
160160
iri = iri.replace(escapeAll, characterReplacer);

src/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Reasoner, { getRulesFromDataset } from './N3Reasoner';
77
import StreamParser from './N3StreamParser';
88
import StreamWriter from './N3StreamWriter';
99
import * as Util from './N3Util';
10+
import BaseIRI from './BaseIRI';
1011

1112
import {
1213
default as DataFactory,
@@ -36,6 +37,7 @@ export {
3637
StreamWriter,
3738
Util,
3839
Reasoner,
40+
BaseIRI,
3941

4042
DataFactory,
4143

@@ -65,6 +67,7 @@ export default {
6567
StreamWriter,
6668
Util,
6769
Reasoner,
70+
BaseIRI,
6871

6972
DataFactory,
7073

test/BaseIRI-test.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { BaseIRI } from '../src';
2+
3+
describe('BaseIRI', () => {
4+
describe('A BaseIRI instance', () => {
5+
it('should relativize http://', () => {
6+
const baseIri = new BaseIRI('http://example.org/foo/');
7+
8+
const iri = `${baseIri.value}baz`;
9+
const relativized = baseIri.toRelative(iri);
10+
11+
expect(relativized).toBe('baz');
12+
});
13+
14+
it('should relativize https://', () => {
15+
const baseIri = new BaseIRI('https://example.org/foo/');
16+
17+
const iri = `${baseIri.value}baz`;
18+
const relativized = baseIri.toRelative(iri);
19+
20+
expect(relativized).toBe('baz');
21+
});
22+
23+
it('should not relativize when initializing with a file scheme IRI', () => {
24+
const baseIri = new BaseIRI('file:///tmp/foo/bar');
25+
26+
const iri = `${baseIri.value}/baz`;
27+
const relativized = baseIri.toRelative(iri);
28+
29+
expect(relativized).toBe(iri);
30+
});
31+
32+
it('should not relativize when initializing with a IRI without scheme', () => {
33+
const baseIri = new BaseIRI('/tmp/foo/bar');
34+
35+
const iri = `${baseIri.value}/baz`;
36+
const relativized = baseIri.toRelative(iri);
37+
38+
expect(relativized).toBe(iri);
39+
});
40+
41+
it('should not relativize when initializing with a IRI containing `//`', () => {
42+
const baseIri = new BaseIRI('http://example.org/foo//bar');
43+
44+
const iri = `${baseIri.value}/baz`;
45+
const relativized = baseIri.toRelative(iri);
46+
47+
expect(relativized).toBe(iri);
48+
});
49+
50+
it('should not relativize when initializing with a IRI containing `/./`', () => {
51+
const baseIri = new BaseIRI('http://example.org/foo/./bar');
52+
53+
const iri = `${baseIri.value}/baz`;
54+
const relativized = baseIri.toRelative(iri);
55+
56+
expect(relativized).toBe(iri);
57+
});
58+
59+
it('should not relativize when initializing with a IRI containing `/../`', () => {
60+
const baseIri = new BaseIRI('http://example.org/foo/../bar');
61+
62+
const iri = `${baseIri.value}/baz`;
63+
const relativized = baseIri.toRelative(iri);
64+
65+
expect(relativized).toBe(iri);
66+
});
67+
68+
it('should not relativize a IRI with file scheme', () => {
69+
const baseIri = new BaseIRI('http://example.org/foo/');
70+
71+
const iri = 'file:///tmp/foo/bar';
72+
const relativized = baseIri.toRelative(iri);
73+
74+
expect(relativized).toBe(iri);
75+
});
76+
77+
it('should not relativize a IRI containing `//`', () => {
78+
const baseIri = new BaseIRI('http://example.org/foo/');
79+
80+
const iri = 'http://example.org/foo//bar';
81+
const relativized = baseIri.toRelative(iri);
82+
83+
expect(relativized).toBe(iri);
84+
});
85+
86+
it('should not relativize a IRI containing `/./`', () => {
87+
const baseIri = new BaseIRI('http://example.org/foo/');
88+
89+
const iri = 'http://example.org/foo/./bar';
90+
const relativized = baseIri.toRelative(iri);
91+
92+
expect(relativized).toBe(iri);
93+
});
94+
95+
it('should not relativize a IRI containing `/../`', () => {
96+
const baseIri = new BaseIRI('http://example.org/foo/');
97+
98+
const iri = 'http://example.org/foo/../bar';
99+
const relativized = baseIri.toRelative(iri);
100+
101+
expect(relativized).toBe(iri);
102+
});
103+
});
104+
});

0 commit comments

Comments
 (0)