n3.js-messages 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # n3.js-messages
2
2
 
3
+ [![CI](https://github.com/pietercolpaert/n3.js-messages/actions/workflows/ci.yml/badge.svg)](https://github.com/pietercolpaert/n3.js-messages/actions/workflows/ci.yml)
4
+ [![Coverage Status](https://coveralls.io/repos/github/pietercolpaert/n3.js-messages/badge.svg?branch=main)](https://coveralls.io/github/pietercolpaert/n3.js-messages?branch=main)
5
+ [![npm version](https://img.shields.io/npm/v/n3.js-messages.svg)](https://www.npmjs.com/package/n3.js-messages)
6
+ [![license](https://img.shields.io/npm/l/n3.js-messages.svg)](https://github.com/pietercolpaert/n3.js-messages/blob/main/LICENSE)
7
+
3
8
  [RDF Messages](https://w3c-cg.github.io/rsp/spec/messages) for [N3.js](https://github.com/rdfjs/N3.js):
4
9
  parse, stream and write message-delimited **Turtle, TriG, N-Triples and N-Quads**, with ordinary RDF/JS `Quad` objects.
5
10
 
@@ -22,7 +27,7 @@ it is **not a W3C Recommendation**, and may still change. This package implement
22
27
  npm install n3 n3.js-messages
23
28
  ```
24
29
 
25
- Requires Node.js ≥ 18 and `n3` ≥ 2.0.0 < 3. The package works with `import` and `require`, and ships TypeScript declarations.
30
+ Requires Node.js ≥ 18 and `n3` ≥ 2.13.7 < 3. The package works with `import` and `require`, and ships TypeScript declarations.
26
31
 
27
32
  ## Parsing
28
33
 
@@ -98,7 +103,7 @@ await writer.end();
98
103
  const text = writeMessages(messages, { format: 'N-Quads' }); // synchronous, returns a string
99
104
  ```
100
105
 
101
- - Writes `VERSION "1.2-messages"` (`@version "1.2-messages" .` in Turtle/TriG) first, and a delimiter after each message:
106
+ - Writes `VERSION "1.2-messages"` (`@version "1.2-messages".` in Turtle/TriG) first, and a delimiter after each message:
102
107
  `MESSAGE` in N-Triples and N-Quads, `@message .` in Turtle and TriG. A parser gets a message when it is written, and a trailing empty
103
108
  message survives a round trip.
104
109
  - Each message starts from a clean serializer state, so consecutive messages in the same named graph are valid. Prefixes (and `baseIRI` with
@@ -136,17 +141,20 @@ The 12 skipped cases do not apply to RDF syntax parsers and serializers: 7 test
136
141
  Jelly gRPC), and 5 are about NDJSON-LD, a different serialization. The run prints each skipped case with its reason.
137
142
  `npm run test:spec:earl` writes an EARL report.
138
143
 
139
- Beyond the suite there are 334 unit, regression, property and differential tests (100% statement, branch, function and line coverage),
144
+ Beyond the suite there are 312 unit, regression, property and differential tests (100% statement, branch, function and line coverage),
140
145
  including every possible two-chunk split of the fixtures, deterministic randomized chunking, per-byte streaming, and the regressions of
141
146
  the review of N3.js [pull request 586](https://github.com/rdfjs/N3.js/pull/586), which proposed this feature for N3.js itself.
142
147
 
143
148
  ## Relationship to N3.js
144
149
 
145
- - `n3` is a **peer dependency** (`>=2.0.0 <3`), so that an application has one N3.js installation. It is tested against n3 2.0.0 (minimum) and
150
+ - `n3` is a **peer dependency** (`>=2.13.7 <3`), so that an application has one N3.js installation. It is tested against n3 2.13.7 (minimum) and
146
151
  the latest 2.x, and against N3.js `main` in a scheduled, non-blocking job.
147
152
  - This is not a fork. The N3.js maintainers asked for RDF Messages to live in a separate package until the specification matures
148
153
  (they proposed it in the discussion of pull request 586, which first added the feature to N3.js itself); this package is that package.
149
- Where N3.js lacks a hook, [IMPLEMENTATION-NOTES.md](IMPLEMENTATION-NOTES.md) says which, and how it is bridged with public API only.
154
+ - N3.js parses the delimiters itself: the parser is created with `directives: ['message']`, and reports `MESSAGE` and `@message .`
155
+ through `onDirective`, in order with the quads. The writer is a small subclass of `N3.Writer` that announces the log with the
156
+ `version` option and ends every message with `_endStatement()` before it writes the delimiter.
157
+ Blank node scoping per message is done by this package.
150
158
 
151
159
  ## Performance
152
160
 
@@ -156,11 +164,11 @@ ratio-based guardrails against each other and compares with `perf/baseline.json`
156
164
 
157
165
  | | plain N3.js | n3.js-messages |
158
166
  | --- | --- | --- |
159
- | parse 100k quads, one message | 688k | 719k (`parseMessages`), 784k (stream, 64 KiB chunks) |
160
- | parse 10k messages × 5 quads | – | 588k (`parseMessages`), 813k (stream) |
161
- | stream, 16-byte chunks (10k quads) | – | 464k |
162
- | write 100k quads in one message (N-Quads) | – | 1.79M |
163
- | write 10k messages (N-Quads) | – | 1.18M (235k messages/s) |
167
+ | parse 100k quads, one message | 649k | 691k (`parseMessages`), 735k (stream, 64 KiB chunks) |
168
+ | parse 10k messages × 5 quads | – | 564k (`parseMessages`), 655k (stream) |
169
+ | stream, 16-byte chunks (10k quads) | – | 324k |
170
+ | write 100k quads in one message (N-Quads) | – | 1.34M |
171
+ | write 10k messages (N-Quads) | – | 1.10M (221k messages/s) |
164
172
 
165
173
  Streaming 100k messages (17 MB) through `MessageStreamParser` grows the heap by 0.12 MB (after gc).
166
174
 
@@ -173,29 +181,30 @@ through both (`npm run perf`, then `npm run perf:compare`). Throughput in quads
173
181
 
174
182
  | Workload | n3.js-messages | rdf-parser-ts / rdf-writer-ts | Speed of n3.js-messages |
175
183
  | --- | --- | --- | --- |
176
- | Parse a string, one message, 100k quads | 719k | 696k | 1.03× |
177
- | Parse a string, 10k messages × 5 quads | 588k | 691k | 0.85× |
178
- | Parse a string, 10 messages × 10k quads | 680k | 745k | 0.91× |
179
- | Stream, 64 KiB chunks, 100k quads | 784k | 680k | 1.15× |
180
- | Stream, 64 KiB chunks, 10k messages | 813k | 549k | 1.48× |
181
- | Stream, 16-byte chunks, 10k quads | 464k | 254k | 1.83× |
182
- | Stream, 1-character chunks, 1k quads | 117k | 76k | 1.53× |
183
- | Write one message, 100k quads, N-Quads | 1,793k | 1,678k | 1.07× |
184
- | Write one message, 100k quads, TriG | 1,667k | 1,636k | 1.02× |
185
- | Write 10k messages, N-Quads | 1,177k | 1,540k | 0.76× |
186
- | Write 10k messages, TriG | 1,039k | 1,374k | 0.76× |
187
-
188
- - **Streaming is where this package is ahead**: 1.15× to 1.8×, most for tiny chunks. Our scanner and the N3.js lexer work incrementally; by its
184
+ | Parse a string, one message, 100k quads | 691k | 551k | 1.25× |
185
+ | Parse a string, 10k messages × 5 quads | 564k | 546k | 1.03× |
186
+ | Parse a string, 10 messages × 10k quads | 732k | 596k | 1.23× |
187
+ | Stream, 64 KiB chunks, 100k quads | 735k | 638k | 1.15× |
188
+ | Stream, 64 KiB chunks, 10k messages | 655k | 511k | 1.28× |
189
+ | Stream, 16-byte chunks, 10k quads | 324k | 198k | 1.64× |
190
+ | Stream, 1-character chunks, 1k quads | 79k | 61k | 1.29× |
191
+ | Write one message, 100k quads, N-Quads | 1,342k | 1,352k | 0.99× |
192
+ | Write one message, 100k quads, TriG | 1,079k | 1,281k | 0.84× |
193
+ | Write 10k messages, N-Quads | 1,103k | 689k | 1.60× |
194
+ | Write 10k messages, TriG | 829k | 595k | 1.39× |
195
+
196
+ - **Streaming is where this package is furthest ahead**: 1.15× to 1.64×, most for tiny chunks. The N3.js lexer works incrementally; by its
189
197
  documentation, rdf-parser-ts runs its parser over every complete statement prefix of the buffered input. I did not profile it to confirm the cause.
190
- - **Parsing a whole string is faster with rdf-parser-ts** (0.85× to 0.91× for many messages or large ones, parity for one message): `parseMessages` is
191
- N3.js plus a scanner, so it is bounded by the speed of N3.js, which rdf-parser-ts also beats on ordinary documents (by 1.2×).
192
- - **Writing**: on par for one large message; rdf-writer-ts is faster for many small messages (0.76×), since this package creates an N3.js writer per message.
198
+ - **Parsing a whole string** is faster here too (1.03× for many small messages, 1.23× to 1.25× for large ones): `parseMessages` is
199
+ N3.js with an extra directive, and N3.js itself is also ahead of rdf-parser-ts on an ordinary document (1.07×).
200
+ - **Writing**: on par for one large N-Quads message (0.99×); rdf-writer-ts is faster for one large TriG message (0.84×); this package is faster for
201
+ many small messages (1.4× to 1.6×).
193
202
  - **Memory**: both stay flat while streaming 100k messages (17 MB): the heap grows by 0.12 MB here and
194
203
  0.08 MB with rdf-parser-ts.
195
204
 
196
205
  Beyond speed, the choice is mostly about the ecosystem. Choose n3.js-messages if you already use N3.js (one parser and one set of syntax quirks, `n3` as a
197
- shared peer dependency, Node.js 18+, messages as plain `Quad[]`, and a writer that starts every message from a clean state). Choose rdf-parser-ts and rdf-writer-ts if you do not need N3.js, or if parsing whole
198
- documents and writing many tiny messages are your bottleneck (they need Node.js 22+ and use `rdf-data-factory` by default).
206
+ shared peer dependency, Node.js 18+, messages as plain `Quad[]`, and a writer that starts every message from a clean state). Choose rdf-parser-ts and rdf-writer-ts if you do not need N3.js, or if writing
207
+ large TriG messages is your bottleneck (they need Node.js 22+ and use `rdf-data-factory` by default).
199
208
  These are one machine's numbers from a single process: run `npm run perf` on yours; method and caveats are in [perf/README.md](perf/README.md).
200
209
 
201
210
  ## Development
@@ -6,16 +6,11 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.default = void 0;
7
7
  var _events = require("events");
8
8
  var _n = require("n3");
9
- var _MessageScanner = require("./MessageScanner.js");
10
9
  var _formats = require("./formats.js");
11
10
  // **MessageParserCore** is the push-based engine behind all message parsers.
12
- // It feeds scanned text to the public N3.js parser and groups the resulting
13
- // quads into messages.
11
+ // It feeds text to the N3.js parser, which recognizes the message delimiters
12
+ // through its `directives` option, and groups the resulting quads into messages.
14
13
 
15
- // Unrecognizable by construction: a document cannot contain it by accident
16
- function createSentinel() {
17
- return `rdf-messages-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
18
- }
19
14
  class MessageParserCore {
20
15
  // `sink` receives `message(quads, index)`, `end(prefixes)`, `error(error)`
21
16
  // and optionally `quad(quad, messageIndex)`, `prefix(prefix, term)`, `version(label)`
@@ -38,40 +33,47 @@ class MessageParserCore {
38
33
  format: format.name,
39
34
  factory: this._factory,
40
35
  // Version labels are validated here, because N3.js does not know `-messages` labels
41
- parseUnsupportedVersions: true
36
+ parseUnsupportedVersions: true,
37
+ // N3.js reports `MESSAGE` and `@message .` between statements through `onDirective`,
38
+ // in order with the quads, and rejects them anywhere else
39
+ directives: ['message']
42
40
  };
43
41
  if (options.baseIRI) n3Options.baseIRI = options.baseIRI;
44
42
  if (options.version) {
45
43
  this._checkVersion(options.version, true);
46
44
  n3Options.version = (0, _formats.baseVersion)(options.version);
47
45
  }
48
- const sentinel = createSentinel();
49
- this._sentinel = sentinel;
50
- this._scanner = format.lineMode ? new _MessageScanner.LineScanner(sentinel) : new _MessageScanner.TurtleScanner(sentinel);
51
46
  this._input = new _events.EventEmitter();
52
- const callbacks = {
47
+ this._lineMode = format.lineMode;
48
+ // The line of the current token, and of the last delimiter
49
+ this._line = 0;
50
+ this._delimiterLine = 0;
51
+ new _n.Parser(n3Options).parse(this._input, {
53
52
  onQuad: (error, quad, prefixes) => this._onQuad(error, quad, prefixes),
54
53
  onPrefix: (prefix, term) => this._failed || sink.prefix && sink.prefix(prefix, term),
55
- onVersion: label => this._onVersion(label)
56
- };
57
- if (format.lineMode) callbacks.onComment = comment => this._onComment(comment);
58
- new _n.Parser(n3Options).parse(this._input, callbacks);
54
+ onVersion: label => this._failed || this._announce(label),
55
+ onDirective: () => this._failed || this._delimiter(),
56
+ onToken: token => this._onToken(token)
57
+ });
59
58
  }
60
59
 
61
60
  // ### `write` accepts the next chunk of text
62
61
  write(text) {
63
- if (!this._failed && !this._finished && text.length > 0) this._feed(this._scanner.push(text));
62
+ if (!this._failed && !this._finished && text.length > 0) this._input.emit('data', text);
64
63
  }
65
64
 
66
65
  // ### `end` signals the end of the input
67
66
  end() {
68
- if (!this._failed && !this._finished) {
69
- this._feed(this._scanner.end());
70
- this._input.emit('end');
71
- }
67
+ if (!this._failed && !this._finished) this._input.emit('end');
72
68
  }
73
- _feed(text) {
74
- if (text.length > 0) this._input.emit('data', text);
69
+
70
+ // Keeps track of lines; in N-Triples and N-Quads, a delimiter must be on its own line
71
+ _onToken(token) {
72
+ const line = token.line,
73
+ delimiter = token.type === 'MESSAGE';
74
+ if (this._lineMode && !this._failed && (delimiter ? line === this._line : line === this._delimiterLine && token.type !== 'eof')) this._fail(new Error(`Unexpected "MESSAGE" on line ${line}.`));
75
+ if (delimiter) this._delimiterLine = line;
76
+ this._line = line;
75
77
  }
76
78
  _onQuad(error, quad, prefixes) {
77
79
  if (this._failed || this._finished) return;
@@ -89,17 +91,6 @@ class MessageParserCore {
89
91
  this._current.push(quad);
90
92
  if (this._sink.quad) this._sink.quad(quad, this._index);
91
93
  }
92
- _onVersion(label) {
93
- if (this._failed) return;
94
- if (label === this._sentinel) this._delimiter(this._scanner.delimiterLines.shift());else this._announce(label);
95
- }
96
-
97
- // Line mode reports delimiters and versions as comments
98
- _onComment(comment) {
99
- if (this._failed || !comment.startsWith(this._sentinel)) return;
100
- const rest = comment.substring(this._sentinel.length);
101
- if (rest[0] === 'M') this._delimiter(Number(rest.substring(1)));else this._announce(rest.substring(1));
102
- }
103
94
  _announce(label) {
104
95
  const error = this._checkVersion(label);
105
96
  if (error) return this._fail(error);
@@ -117,9 +108,9 @@ class MessageParserCore {
117
108
  if (throwing) throw error;
118
109
  return error;
119
110
  }
120
- _delimiter(line) {
111
+ _delimiter() {
121
112
  if (!this._messages) {
122
- return this._fail(new Error(`Unexpected "MESSAGE" on line ${line}: the document did not announce ` + 'a version with the "-messages" suffix.'));
113
+ return this._fail(new Error(`Unexpected "MESSAGE" on line ${this._line}: the document did not announce ` + 'a version with the "-messages" suffix.'));
123
114
  }
124
115
  this._emitMessage();
125
116
  }
@@ -9,12 +9,20 @@ var _n = require("n3");
9
9
  var _formats = require("./formats.js");
10
10
  // **MessageWriter** writes RDF Message Logs (Turtle, TriG, N-Triples and N-Quads).
11
11
  //
12
- // Every message is written by a fresh N3.js writer, so no serialization state
13
- // (current graph, subject, predicate) leaks from one message into the next.
14
- // Every message is followed by a delimiter (`MESSAGE` in line formats, `@message .` in Turtle and TriG), so that consumers receive
15
- // a message as soon as it is written, and so that trailing empty messages survive
16
- // a round trip (a delimiter before the end of the input does not create a message).
12
+ // One N3.js writer writes the whole log: its `version` option announces the log,
13
+ // and after each message, `_endStatement` finishes the pending statement (and graph block)
14
+ // before a delimiter is written (`MESSAGE` in line formats, `@message .` in Turtle and TriG).
15
+ // That way, consumers receive a message as soon as it is written, no serialization state
16
+ // (current graph, subject, predicate) leaks from one message into the next, and trailing
17
+ // empty messages survive a round trip (a delimiter before the end of the input does not create a message).
17
18
 
19
+ class LogWriter extends _n.Writer {
20
+ // ### `addDelimiter` ends the current message
21
+ addDelimiter() {
22
+ this._endStatement();
23
+ this._write(this._lineMode ? 'MESSAGE\n' : '@message .\n');
24
+ }
25
+ }
18
26
  class MessageWriter {
19
27
  // `outputStream` is optional: without it, `end` returns the output as a string
20
28
  constructor(outputStream, options) {
@@ -30,15 +38,15 @@ class MessageWriter {
30
38
  this._pending = 0;
31
39
  this._waiting = [];
32
40
  this._error = null;
33
- this._muted = false;
34
41
  this._started = false;
35
42
  this._ended = false;
36
43
  this._declared = Object.assign({}, options.prefixes);
37
44
  this._newPrefixes = {};
38
- // The N3.js writers write to this sink, which they never end
45
+ // The N3.js writer writes to this sink, which it never ends
39
46
  this._sink = {
40
47
  write: (chunk, encoding, done) => this._write(chunk, done)
41
48
  };
49
+ this._writer = null;
42
50
  }
43
51
 
44
52
  // ### `addMessage` writes the quads of one message, followed by a delimiter.
@@ -106,8 +114,8 @@ class MessageWriter {
106
114
  done(error || null);
107
115
  }
108
116
  _write(chunk, done) {
109
- if (this._muted || !this._stream) {
110
- if (!this._muted) this._output += chunk;
117
+ if (!this._stream) {
118
+ this._output += chunk;
111
119
  return done && done();
112
120
  }
113
121
  this._pending++;
@@ -126,30 +134,24 @@ class MessageWriter {
126
134
  if (this._pending === 0) this._waiting.splice(0).forEach(callback => this._settle(callback));
127
135
  }
128
136
 
129
- // Announces the RDF Messages version, which makes parsers expect delimiters
137
+ // Announces the RDF Messages version, which makes parsers expect delimiters,
138
+ // and declares the base and prefixes, which stay in effect for all messages
130
139
  _start() {
131
140
  if (!this._started) {
132
141
  this._started = true;
133
- this._write(this._format.lineMode ? `VERSION "${this._version}"\n` : `@version "${this._version}" .\n`);
142
+ const options = this._options;
143
+ this._writer = new LogWriter(this._sink, {
144
+ format: this._format.name,
145
+ end: false,
146
+ version: this._version,
147
+ baseIRI: options.baseIRI,
148
+ writeBase: options.writeBase,
149
+ prefixes: this._declared
150
+ });
134
151
  }
135
152
  }
136
153
  _writeMessage(quads) {
137
- const first = !this._messageWritten;
138
- const options = {
139
- format: this._format.name,
140
- end: false
141
- };
142
- if (this._options.baseIRI) {
143
- options.baseIRI = this._options.baseIRI;
144
- // The base is declared by us, once, as it stays in effect (and older N3.js writers lack `writeBase`)
145
- if (first && this._options.writeBase && !this._format.lineMode) this._write(`@base <${this._options.baseIRI}>.\n`);
146
- }
147
- // Prefixes and base are declared once, as they stay in effect
148
- options.prefixes = this._declared;
149
- this._muted = !first;
150
- const writer = new _n.Writer(this._sink, options);
151
- this._muted = false;
152
- this._messageWritten = true;
154
+ const writer = this._writer;
153
155
  for (const prefix of Object.keys(this._newPrefixes)) {
154
156
  writer.addPrefix(prefix, this._newPrefixes[prefix]);
155
157
  this._declared[prefix] = this._newPrefixes[prefix];
@@ -157,8 +159,7 @@ class MessageWriter {
157
159
  this._newPrefixes = {};
158
160
  const record = error => error && (this._error = this._error || error);
159
161
  for (const quad of quads) writer.addQuad(quad.subject, quad.predicate, quad.object, quad.graph, record);
160
- writer.end(record);
161
- this._write(this._format.lineMode ? 'MESSAGE\n' : '@message .\n');
162
+ writer.addDelimiter();
162
163
  }
163
164
  }
164
165
 
package/lib/index.js CHANGED
@@ -44,4 +44,5 @@ var _MessageStreamParser = _interopRequireDefault(require("./MessageStreamParser
44
44
  var _MessageWriter = _interopRequireWildcard(require("./MessageWriter.js"));
45
45
  var _MessageStreamWriter = _interopRequireDefault(require("./MessageStreamWriter.js"));
46
46
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
47
- function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
47
+ var cache;
48
+ function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var r, c, a = Object.defineProperty, n = { __proto__: null, default: e }; if (Object(e) !== e) return n; if (cache || "function" != typeof WeakMap || (cache = new WeakMap()), cache) { if (cache.has(e)) return cache.get(e); cache.set(e, n); } for (c in e) "default" !== c && {}.hasOwnProperty.call(e, c) && ((r = a && Object.getOwnPropertyDescriptor(e, c)) && (r.get || r.set) ? a(n, c, r) : n[c] = e[c]); return n; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n3.js-messages",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "RDF Messages (message-delimited Turtle, TriG, N-Triples and N-Quads) for N3.js: message-aware parser, streaming parser and writer.",
5
5
  "author": "Pieter Colpaert",
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@
37
37
  "index.d.ts"
38
38
  ],
39
39
  "peerDependencies": {
40
- "n3": ">=2.0.0 <3"
40
+ "n3": ">=2.13.7 <3"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",
@@ -62,9 +62,9 @@
62
62
  "spec:update": "node scripts/update-spec.js"
63
63
  },
64
64
  "devDependencies": {
65
- "@babel/cli": "^7.29.7",
66
- "@babel/core": "^7.29.7",
67
- "@babel/preset-env": "^7.29.7",
65
+ "@babel/cli": "^8.0.6",
66
+ "@babel/core": "^8.0.7",
67
+ "@babel/preset-env": "^8.0.7",
68
68
  "@types/n3": "^1.26.4",
69
69
  "@types/node": "^26.6.4",
70
70
  "babel-jest": "^30.5.2",
@@ -73,11 +73,11 @@
73
73
  "eslint-plugin-jest": "^29.16.6",
74
74
  "globals": "^17.13.0",
75
75
  "jest": "^30.5.2",
76
- "n3": "^2.10.6",
76
+ "n3": "^2.13.8",
77
77
  "rdf-isomorphic": "^2.0.1",
78
78
  "rdf-parser-ts": "^0.4.0",
79
79
  "rdf-writer-ts": "^0.2.1",
80
- "typescript": "^6.0.3"
80
+ "typescript": "^7.0.2"
81
81
  },
82
82
  "jest": {
83
83
  "testMatch": [
@@ -1,16 +1,10 @@
1
1
  // **MessageParserCore** is the push-based engine behind all message parsers.
2
- // It feeds scanned text to the public N3.js parser and groups the resulting
3
- // quads into messages.
2
+ // It feeds text to the N3.js parser, which recognizes the message delimiters
3
+ // through its `directives` option, and groups the resulting quads into messages.
4
4
  import { EventEmitter } from 'events';
5
5
  import { Parser, DataFactory } from 'n3';
6
- import { TurtleScanner, LineScanner } from './MessageScanner.js';
7
6
  import { resolveFormat, isMessagesVersion, baseVersion, isSupportedVersion } from './formats.js';
8
7
 
9
- // Unrecognizable by construction: a document cannot contain it by accident
10
- function createSentinel() {
11
- return `rdf-messages-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
12
- }
13
-
14
8
  export default class MessageParserCore {
15
9
  // `sink` receives `message(quads, index)`, `end(prefixes)`, `error(error)`
16
10
  // and optionally `quad(quad, messageIndex)`, `prefix(prefix, term)`, `version(label)`
@@ -36,6 +30,9 @@ export default class MessageParserCore {
36
30
  factory: this._factory,
37
31
  // Version labels are validated here, because N3.js does not know `-messages` labels
38
32
  parseUnsupportedVersions: true,
33
+ // N3.js reports `MESSAGE` and `@message .` between statements through `onDirective`,
34
+ // in order with the quads, and rejects them anywhere else
35
+ directives: ['message'],
39
36
  };
40
37
  if (options.baseIRI)
41
38
  n3Options.baseIRI = options.baseIRI;
@@ -44,37 +41,41 @@ export default class MessageParserCore {
44
41
  n3Options.version = baseVersion(options.version);
45
42
  }
46
43
 
47
- const sentinel = createSentinel();
48
- this._sentinel = sentinel;
49
- this._scanner = format.lineMode ? new LineScanner(sentinel) : new TurtleScanner(sentinel);
50
44
  this._input = new EventEmitter();
51
- const callbacks = {
45
+ this._lineMode = format.lineMode;
46
+ // The line of the current token, and of the last delimiter
47
+ this._line = 0;
48
+ this._delimiterLine = 0;
49
+ new Parser(n3Options).parse(this._input, {
52
50
  onQuad: (error, quad, prefixes) => this._onQuad(error, quad, prefixes),
53
51
  onPrefix: (prefix, term) => this._failed || sink.prefix && sink.prefix(prefix, term),
54
- onVersion: label => this._onVersion(label),
55
- };
56
- if (format.lineMode)
57
- callbacks.onComment = comment => this._onComment(comment);
58
- new Parser(n3Options).parse(this._input, callbacks);
52
+ onVersion: label => this._failed || this._announce(label),
53
+ onDirective: () => this._failed || this._delimiter(),
54
+ onToken: token => this._onToken(token),
55
+ });
59
56
  }
60
57
 
61
58
  // ### `write` accepts the next chunk of text
62
59
  write(text) {
63
60
  if (!this._failed && !this._finished && text.length > 0)
64
- this._feed(this._scanner.push(text));
61
+ this._input.emit('data', text);
65
62
  }
66
63
 
67
64
  // ### `end` signals the end of the input
68
65
  end() {
69
- if (!this._failed && !this._finished) {
70
- this._feed(this._scanner.end());
66
+ if (!this._failed && !this._finished)
71
67
  this._input.emit('end');
72
- }
73
68
  }
74
69
 
75
- _feed(text) {
76
- if (text.length > 0)
77
- this._input.emit('data', text);
70
+ // Keeps track of lines; in N-Triples and N-Quads, a delimiter must be on its own line
71
+ _onToken(token) {
72
+ const line = token.line, delimiter = token.type === 'MESSAGE';
73
+ if (this._lineMode && !this._failed &&
74
+ (delimiter ? line === this._line : line === this._delimiterLine && token.type !== 'eof'))
75
+ this._fail(new Error(`Unexpected "MESSAGE" on line ${line}.`));
76
+ if (delimiter)
77
+ this._delimiterLine = line;
78
+ this._line = line;
78
79
  }
79
80
 
80
81
  _onQuad(error, quad, prefixes) {
@@ -104,26 +105,6 @@ export default class MessageParserCore {
104
105
  this._sink.quad(quad, this._index);
105
106
  }
106
107
 
107
- _onVersion(label) {
108
- if (this._failed)
109
- return;
110
- if (label === this._sentinel)
111
- this._delimiter(this._scanner.delimiterLines.shift());
112
- else
113
- this._announce(label);
114
- }
115
-
116
- // Line mode reports delimiters and versions as comments
117
- _onComment(comment) {
118
- if (this._failed || !comment.startsWith(this._sentinel))
119
- return;
120
- const rest = comment.substring(this._sentinel.length);
121
- if (rest[0] === 'M')
122
- this._delimiter(Number(rest.substring(1)));
123
- else
124
- this._announce(rest.substring(1));
125
- }
126
-
127
108
  _announce(label) {
128
109
  const error = this._checkVersion(label);
129
110
  if (error)
@@ -147,9 +128,9 @@ export default class MessageParserCore {
147
128
  return error;
148
129
  }
149
130
 
150
- _delimiter(line) {
131
+ _delimiter() {
151
132
  if (!this._messages) {
152
- return this._fail(new Error(`Unexpected "MESSAGE" on line ${line}: the document did not announce ` +
133
+ return this._fail(new Error(`Unexpected "MESSAGE" on line ${this._line}: the document did not announce ` +
153
134
  'a version with the "-messages" suffix.'));
154
135
  }
155
136
  this._emitMessage();
@@ -1,13 +1,22 @@
1
1
  // **MessageWriter** writes RDF Message Logs (Turtle, TriG, N-Triples and N-Quads).
2
2
  //
3
- // Every message is written by a fresh N3.js writer, so no serialization state
4
- // (current graph, subject, predicate) leaks from one message into the next.
5
- // Every message is followed by a delimiter (`MESSAGE` in line formats, `@message .` in Turtle and TriG), so that consumers receive
6
- // a message as soon as it is written, and so that trailing empty messages survive
7
- // a round trip (a delimiter before the end of the input does not create a message).
3
+ // One N3.js writer writes the whole log: its `version` option announces the log,
4
+ // and after each message, `_endStatement` finishes the pending statement (and graph block)
5
+ // before a delimiter is written (`MESSAGE` in line formats, `@message .` in Turtle and TriG).
6
+ // That way, consumers receive a message as soon as it is written, no serialization state
7
+ // (current graph, subject, predicate) leaks from one message into the next, and trailing
8
+ // empty messages survive a round trip (a delimiter before the end of the input does not create a message).
8
9
  import { Writer } from 'n3';
9
10
  import { resolveFormat, isMessagesVersion, baseVersion, isSupportedVersion } from './formats.js';
10
11
 
12
+ class LogWriter extends Writer {
13
+ // ### `addDelimiter` ends the current message
14
+ addDelimiter() {
15
+ this._endStatement();
16
+ this._write(this._lineMode ? 'MESSAGE\n' : '@message .\n');
17
+ }
18
+ }
19
+
11
20
  export default class MessageWriter {
12
21
  // `outputStream` is optional: without it, `end` returns the output as a string
13
22
  constructor(outputStream, options) {
@@ -26,13 +35,13 @@ export default class MessageWriter {
26
35
  this._pending = 0;
27
36
  this._waiting = [];
28
37
  this._error = null;
29
- this._muted = false;
30
38
  this._started = false;
31
39
  this._ended = false;
32
40
  this._declared = Object.assign({}, options.prefixes);
33
41
  this._newPrefixes = {};
34
- // The N3.js writers write to this sink, which they never end
42
+ // The N3.js writer writes to this sink, which it never ends
35
43
  this._sink = { write: (chunk, encoding, done) => this._write(chunk, done) };
44
+ this._writer = null;
36
45
  }
37
46
 
38
47
  // ### `addMessage` writes the quads of one message, followed by a delimiter.
@@ -117,9 +126,8 @@ export default class MessageWriter {
117
126
  }
118
127
 
119
128
  _write(chunk, done) {
120
- if (this._muted || !this._stream) {
121
- if (!this._muted)
122
- this._output += chunk;
129
+ if (!this._stream) {
130
+ this._output += chunk;
123
131
  return done && done();
124
132
  }
125
133
  this._pending++;
@@ -140,29 +148,25 @@ export default class MessageWriter {
140
148
  this._waiting.splice(0).forEach(callback => this._settle(callback));
141
149
  }
142
150
 
143
- // Announces the RDF Messages version, which makes parsers expect delimiters
151
+ // Announces the RDF Messages version, which makes parsers expect delimiters,
152
+ // and declares the base and prefixes, which stay in effect for all messages
144
153
  _start() {
145
154
  if (!this._started) {
146
155
  this._started = true;
147
- this._write(this._format.lineMode ? `VERSION "${this._version}"\n` : `@version "${this._version}" .\n`);
156
+ const options = this._options;
157
+ this._writer = new LogWriter(this._sink, {
158
+ format: this._format.name,
159
+ end: false,
160
+ version: this._version,
161
+ baseIRI: options.baseIRI,
162
+ writeBase: options.writeBase,
163
+ prefixes: this._declared,
164
+ });
148
165
  }
149
166
  }
150
167
 
151
168
  _writeMessage(quads) {
152
- const first = !this._messageWritten;
153
- const options = { format: this._format.name, end: false };
154
- if (this._options.baseIRI) {
155
- options.baseIRI = this._options.baseIRI;
156
- // The base is declared by us, once, as it stays in effect (and older N3.js writers lack `writeBase`)
157
- if (first && this._options.writeBase && !this._format.lineMode)
158
- this._write(`@base <${this._options.baseIRI}>.\n`);
159
- }
160
- // Prefixes and base are declared once, as they stay in effect
161
- options.prefixes = this._declared;
162
- this._muted = !first;
163
- const writer = new Writer(this._sink, options);
164
- this._muted = false;
165
- this._messageWritten = true;
169
+ const writer = this._writer;
166
170
  for (const prefix of Object.keys(this._newPrefixes)) {
167
171
  writer.addPrefix(prefix, this._newPrefixes[prefix]);
168
172
  this._declared[prefix] = this._newPrefixes[prefix];
@@ -171,8 +175,7 @@ export default class MessageWriter {
171
175
  const record = error => error && (this._error = this._error || error);
172
176
  for (const quad of quads)
173
177
  writer.addQuad(quad.subject, quad.predicate, quad.object, quad.graph, record);
174
- writer.end(record);
175
- this._write(this._format.lineMode ? 'MESSAGE\n' : '@message .\n');
178
+ writer.addDelimiter();
176
179
  }
177
180
  }
178
181
 
@@ -1,390 +0,0 @@
1
- // **MessageScanner** finds RDF Message delimiters in a text stream.
2
- //
3
- // N3.js has no extension point for new directives, and its lexer rejects
4
- // `MESSAGE` and `@message`. The scanners in this file therefore sit in front of
5
- // the N3.js lexer: they track just enough lexical context (IRIs, strings,
6
- // long strings, comments, words, braces) to recognize *real* delimiters, and
7
- // rewrite them into text that N3.js can lex and that is easy to recognize
8
- // again in N3.js' public parser callbacks:
9
- //
10
- // - Turtle and TriG: a delimiter becomes `@version "<sentinel>" .`.
11
- // N3.js only accepts version directives at statement boundaries, so the
12
- // N3.js parser itself rejects delimiters in other grammar positions, and
13
- // reports the delimiter through its public `onVersion` callback, exactly in
14
- // order with the emitted quads. The dot matters: the N3.js lexer reads an
15
- // `@` after a literal as a language tag, so a literal that ends a SPARQL-style
16
- // `VERSION "label"` directive would swallow a following `@prefix`.
17
- // For the same reason, SPARQL-style `VERSION "label"` directives in the input
18
- // are rewritten to `@version "label" .`.
19
- // - N-Triples and N-Quads: N3.js has no version directive in line mode.
20
- // The grammar is line-based, so a delimiter or version line becomes the
21
- // comment `#<sentinel>M<line>` / `#<sentinel>V<label>`, which is reported through
22
- // the public `onComment` callback, again in order with the quads.
23
- //
24
- // Delimiters that are not recognized (inside strings, IRIs, comments, prefixed
25
- // names or graph blocks) are passed through unchanged. The scanners never
26
- // throw: whatever they do not rewrite is rejected by N3.js as a syntax error.
27
-
28
- const TOP = 0, IRI = 1, LT = 2, COMMENT = 3, STRING = 4, QUOTE1 = 5, QUOTE2 = 6, LONG = 7, CANDIDATE = 8;
29
-
30
- const MESSAGE = 'MESSAGE', AT_MESSAGE = '@message', VERSION = 'VERSION';
31
-
32
- function isWhitespace(code) {
33
- return code === 32 || code === 9 || code === 10 || code === 13;
34
- }
35
-
36
- // Compares a character to a position of a keyword; `VERSION` is case-insensitive
37
- function matches(keyword, index, code) {
38
- return keyword === VERSION ? String.fromCharCode(code).toUpperCase() === keyword[index] :
39
- code === keyword.charCodeAt(index);
40
- }
41
-
42
- // Characters that may follow `MESSAGE`: whitespace, a comment, or an IRI
43
- function endsMessageKeyword(code) {
44
- return isWhitespace(code) || code === 35 || code === 60;
45
- }
46
-
47
- // Characters that may follow `@message`: whitespace, a comment, or the dot
48
- function endsAtMessageKeyword(code) {
49
- return isWhitespace(code) || code === 35 || code === 46;
50
- }
51
-
52
- // ## Turtle and TriG
53
- export class TurtleScanner {
54
- constructor(sentinel) {
55
- this._sentinel = sentinel;
56
- this._state = TOP;
57
- // Whether the next character can start a new token
58
- this._tokenStart = true;
59
- // Whether we are inside a bare word (prefixed name, number, keyword)
60
- this._inWord = false;
61
- this._wordEscape = false;
62
- this._quote = 0;
63
- this._escaped = false;
64
- this._quoteRun = 0;
65
- this._depth = 0;
66
- this._candidate = '';
67
- this._target = '';
68
- this._candidateLine = 1;
69
- this._beforeCandidate = 32;
70
- this._versionPending = false;
71
- this._line = 1;
72
- this._afterCR = false;
73
- this._started = false;
74
- this._lastOut = 32;
75
- // The line of every rewritten delimiter that was not yet consumed
76
- this.delimiterLines = [];
77
- }
78
-
79
- // ### `push` scans a chunk of text and returns the text to hand to N3.js
80
- push(text) {
81
- let out = '';
82
- let segmentStart = 0;
83
- for (let i = 0; i < text.length; i++) {
84
- const code = text.charCodeAt(i);
85
- // Keep track of the line number (CRLF counts once, also across chunks)
86
- if (code === 10) {
87
- if (!this._afterCR)
88
- this._line++;
89
- this._afterCR = false;
90
- }
91
- else if (code === 13) {
92
- this._line++;
93
- this._afterCR = true;
94
- }
95
- else
96
- this._afterCR = false;
97
-
98
- // A state change may require the same character to be handled again
99
- let again;
100
- do {
101
- again = false;
102
- switch (this._state) {
103
- case TOP:
104
- if (this._scanTop(code)) {
105
- // A possible delimiter starts here: hold it back until it is resolved
106
- out += text.substring(segmentStart, i);
107
- segmentStart = i + 1;
108
- this._candidate = text[i];
109
- this._candidateLine = this._line;
110
- this._beforeCandidate = i > 0 ? text.charCodeAt(i - 1) : this._lastOut;
111
- this._target = code === 77 ? MESSAGE : code === 64 ? AT_MESSAGE : VERSION;
112
- this._state = CANDIDATE;
113
- }
114
- break;
115
- case CANDIDATE: {
116
- const index = this._candidate.length, target = this._target;
117
- if (index < target.length && matches(target, index, code)) {
118
- this._candidate += text[i];
119
- segmentStart = i + 1;
120
- }
121
- else {
122
- const complete = index === target.length &&
123
- (target === AT_MESSAGE ? endsAtMessageKeyword(code) : endsMessageKeyword(code));
124
- out += this._resolveCandidate(complete);
125
- segmentStart = i;
126
- again = true;
127
- }
128
- break;
129
- }
130
- case IRI:
131
- if (code === 62)
132
- this._endToken(true);
133
- break;
134
- case LT:
135
- // `<<` starts a reified triple, everything else an IRI
136
- if (code === 60)
137
- this._endToken(true);
138
- else {
139
- this._state = IRI;
140
- again = true;
141
- }
142
- break;
143
- case COMMENT:
144
- if (code === 10 || code === 13)
145
- this._endToken(true);
146
- break;
147
- case STRING:
148
- if (this._escaped)
149
- this._escaped = false;
150
- else if (code === 92)
151
- this._escaped = true;
152
- else if (code === this._quote) {
153
- this._endToken(false);
154
- if (this._versionPending) {
155
- // Terminate a SPARQL-style version directive, which we turned into an `@version` directive
156
- this._versionPending = false;
157
- out += `${text.substring(segmentStart, i + 1)} .`;
158
- segmentStart = i + 1;
159
- }
160
- }
161
- break;
162
- case QUOTE1:
163
- // A second quote either starts a long string or ends an empty string
164
- if (code === this._quote)
165
- this._state = QUOTE2;
166
- else {
167
- this._state = STRING;
168
- again = true;
169
- }
170
- break;
171
- case QUOTE2:
172
- if (code === this._quote) {
173
- this._state = LONG;
174
- this._escaped = false;
175
- this._quoteRun = 0;
176
- }
177
- else {
178
- this._endToken(false);
179
- again = true;
180
- }
181
- break;
182
- default: // LONG
183
- if (this._escaped) {
184
- this._escaped = false;
185
- this._quoteRun = 0;
186
- }
187
- else if (code === 92) {
188
- this._escaped = true;
189
- this._quoteRun = 0;
190
- }
191
- else if (code === this._quote) {
192
- if (++this._quoteRun === 3)
193
- this._endToken(false);
194
- }
195
- else
196
- this._quoteRun = 0;
197
- }
198
- } while (again);
199
- }
200
- out += text.substring(segmentStart);
201
- return this._remember(out);
202
- }
203
-
204
- // ### `end` resolves any held-back text at the end of the input
205
- end() {
206
- let out = '';
207
- if (this._state === CANDIDATE)
208
- out = this._resolveCandidate(this._candidate.length === this._target.length);
209
- return this._remember(out);
210
- }
211
-
212
- // ### `_scanTop` handles a character between tokens or inside a word.
213
- // It returns true if the character may start a message delimiter.
214
- _scanTop(code) {
215
- // An escape inside a word makes the next character part of the word
216
- if (this._wordEscape) {
217
- this._wordEscape = false;
218
- return false;
219
- }
220
- switch (code) {
221
- case 32: case 9: case 10: case 13:
222
- this._structural();
223
- break;
224
- case 0xFEFF:
225
- // A byte order mark at the very start is not part of any token
226
- if (this._started)
227
- this._word();
228
- break;
229
- case 35: // #
230
- this._state = COMMENT;
231
- this._inWord = false;
232
- break;
233
- case 60: // <
234
- this._state = LT;
235
- this._inWord = false;
236
- break;
237
- case 34: case 39: // " '
238
- this._state = QUOTE1;
239
- this._quote = code;
240
- this._inWord = false;
241
- break;
242
- case 123: // {
243
- this._depth++;
244
- this._structural();
245
- break;
246
- case 125: // }
247
- this._depth = Math.max(0, this._depth - 1);
248
- this._structural();
249
- break;
250
- case 40: case 41: case 91: case 93: case 44: case 59: // ( ) [ ] , ;
251
- this._structural();
252
- break;
253
- case 46: // . ends a statement, unless it is part of a word
254
- if (!this._inWord)
255
- this._structural();
256
- break;
257
- case 92: // \
258
- this._word();
259
- this._wordEscape = true;
260
- break;
261
- case 77: case 64: case 86: case 118: // M @ V v
262
- if (this._tokenStart) {
263
- this._started = true;
264
- return true;
265
- }
266
- this._word();
267
- break;
268
- default:
269
- this._word();
270
- }
271
- this._started = true;
272
- return false;
273
- }
274
-
275
- _structural() {
276
- this._inWord = false;
277
- this._tokenStart = true;
278
- }
279
-
280
- _word() {
281
- this._inWord = true;
282
- this._tokenStart = false;
283
- }
284
-
285
- // ### `_endToken` returns to the top level after an IRI, string or comment
286
- _endToken(tokenStart) {
287
- this._state = TOP;
288
- this._inWord = false;
289
- this._tokenStart = tokenStart;
290
- }
291
-
292
- // ### `_resolveCandidate` decides whether a held-back candidate is a delimiter
293
- // and returns the text to emit for it
294
- _resolveCandidate(complete) {
295
- const candidate = this._candidate;
296
- this._state = TOP;
297
- this._candidate = '';
298
- // A delimiter in a graph block is not rewritten, so that N3.js rejects it
299
- if (!complete || (this._depth > 0 && this._target !== VERSION)) {
300
- this._word();
301
- return candidate;
302
- }
303
- this._structural();
304
- const previous = this._beforeCandidate;
305
- const space = isWhitespace(previous) || previous === 0xFEFF ? '' : ' ';
306
- if (this._target === VERSION) {
307
- this._versionPending = true;
308
- return `${space}@version`;
309
- }
310
- this.delimiterLines.push(this._candidateLine);
311
- return `${space}@version "${this._sentinel}"${candidate === MESSAGE ? ' .' : ''}`;
312
- }
313
-
314
- _remember(out) {
315
- if (out.length > 0)
316
- this._lastOut = out.charCodeAt(out.length - 1);
317
- return out;
318
- }
319
- }
320
-
321
- // ## N-Triples and N-Quads
322
- // Lines of the form `MESSAGE` or `VERSION "label"` are the only directives.
323
- // They cannot be confused with strings or IRIs, as those never span lines.
324
- export class LineScanner {
325
- constructor(sentinel) {
326
- this._sentinel = sentinel;
327
- this._buffer = '';
328
- this._line = 1;
329
- this._messageLine = new RegExp(`^[ \\t]*${MESSAGE}[ \\t]*(?:#.*)?$`);
330
- this._versionLine = /^[ \t]*VERSION[ \t]*(?:"([^"\\]*)"|'([^'\\]*)')[ \t]*(?:#.*)?$/i;
331
- }
332
-
333
- // ### `push` scans a chunk of text and returns the complete lines, rewritten
334
- push(text) {
335
- const data = this._buffer + text;
336
- // A final carriage return waits for a possible line feed, so that CRLF counts as one line break
337
- const limit = data.endsWith('\r') ? data.length - 1 : data.length;
338
- const lastBreak = Math.max(data.lastIndexOf('\n', limit - 1), data.lastIndexOf('\r', limit - 1));
339
- if (lastBreak < 0) {
340
- this._buffer = data;
341
- return '';
342
- }
343
- this._buffer = data.substring(lastBreak + 1);
344
- return this._rewrite(data.substring(0, lastBreak + 1));
345
- }
346
-
347
- // ### `end` returns the last line, which has no line break
348
- end() {
349
- const rest = this._buffer;
350
- this._buffer = '';
351
- return rest.length > 0 ? this._rewrite(rest) : '';
352
- }
353
-
354
- _rewrite(block) {
355
- let out = '';
356
- let copyFrom = 0, lineStart = 0;
357
- const breaks = /\r\n|\n|\r/g;
358
- let match;
359
- do {
360
- match = breaks.exec(block);
361
- const lineEnd = match ? match.index : block.length;
362
- const replacement = this._rewriteLine(block, lineStart, lineEnd);
363
- if (replacement !== null) {
364
- out += block.substring(copyFrom, lineStart) + replacement;
365
- copyFrom = lineEnd;
366
- }
367
- lineStart = match ? breaks.lastIndex : block.length + 1;
368
- if (match)
369
- this._line++;
370
- } while (lineStart <= block.length);
371
- return out + block.substring(copyFrom);
372
- }
373
-
374
- // Only lines that start with `MESSAGE` or `VERSION` need a closer look
375
- _rewriteLine(block, start, end) {
376
- let first = start;
377
- while (first < end && (block[first] === ' ' || block[first] === '\t'))
378
- first++;
379
- const code = block.charCodeAt(first);
380
- if (first === end || (code !== 77 && code !== 86 && code !== 118))
381
- return null;
382
- const line = block.substring(first, end);
383
- if (this._messageLine.test(line))
384
- return `#${this._sentinel}M${this._line}`;
385
- const version = this._versionLine.exec(line);
386
- if (version)
387
- return `#${this._sentinel}V${version[1] === undefined ? version[2] : version[1]}`;
388
- return null;
389
- }
390
- }