links-notation 0.27.1 → 0.30.0

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/index.d.ts CHANGED
@@ -24,6 +24,22 @@ export class Link {
24
24
  */
25
25
  values: Link[];
26
26
 
27
+ /**
28
+ * The ID exactly as the parsed document wrote it, quotes, escapes and
29
+ * literal syntax included: `'1'` for a reference written `'1'`, `1` for one
30
+ * written `1`. Null for a link without an ID, a link built in code, a link
31
+ * made by indentation path combination, or a parser in AST mode
32
+ * (`syntax: false`). Equality, formatting and serialization ignore it.
33
+ * @throws {TypeError} When set to anything but a string or null
34
+ */
35
+ rawId: string | null;
36
+
37
+ /**
38
+ * The delimiter the ID was written between (`'`, `"` or `` ` ``), read from
39
+ * `rawId`; null for a bare or literal reference, or no `rawId`.
40
+ */
41
+ readonly quote: "'" | '"' | '`' | null;
42
+
27
43
  /**
28
44
  * Create a new Link
29
45
  * @param id - Optional identifier for the link
@@ -123,6 +139,56 @@ export function escapeReference(
123
139
  /** Decode a complete escaped reference, including n-quotes and versioned literals. */
124
140
  export function unescapeReference(reference: string): string;
125
141
 
142
+ /**
143
+ * The delimiter one reference was written between, read from the reference as
144
+ * written: `'`, `"` or `` ` `` for a complete quoted reference, otherwise null.
145
+ */
146
+ export function referenceQuote(
147
+ raw: string | null | undefined
148
+ ): "'" | '"' | '`' | null;
149
+
150
+ /** The kinds of nodes and tokens of a concrete syntax tree. */
151
+ export const CstKind: Readonly<{
152
+ DOCUMENT: 'document';
153
+ LINE: 'line';
154
+ GROUP: 'group';
155
+ WHITESPACE: 'whitespace';
156
+ NEWLINE: 'newline';
157
+ COMMENT: 'comment';
158
+ OPEN_PAREN: 'open_paren';
159
+ CLOSE_PAREN: 'close_paren';
160
+ COLON: 'colon';
161
+ REFERENCE: 'reference';
162
+ }>;
163
+
164
+ export type CstKindName = (typeof CstKind)[keyof typeof CstKind];
165
+
166
+ /**
167
+ * One node or token of a lossless concrete syntax tree: every character of
168
+ * the document belongs to exactly one token, so the document is the text of
169
+ * the tree.
170
+ */
171
+ export class CstNode {
172
+ /** The kind of this node or token. */
173
+ kind: CstKindName;
174
+ /** The children of a `document`, `line` or `group` node; empty for a token. */
175
+ children: CstNode[];
176
+ /** What a reference token means; null for every other kind. */
177
+ value: string | null;
178
+ /** The delimiter of a quoted reference token; null otherwise. */
179
+ quote: "'" | '"' | '`' | null;
180
+ /** Whether this is a token rather than a node. */
181
+ readonly isToken: boolean;
182
+ /** The exact text this node or token was read from. */
183
+ readonly text: string;
184
+ /** The exact text this node or token was read from. */
185
+ toString(): string;
186
+ /** Every token of this subtree, in document order. */
187
+ tokens(): CstNode[];
188
+ /** The tree as an S-expression: `(kind child ...)` and `kind:"text"`. */
189
+ toSExpression(): string;
190
+ }
191
+
126
192
  /**
127
193
  * Parser options for configuring the parser behavior
128
194
  */
@@ -150,6 +216,12 @@ export interface ParserOptions {
150
216
  * comment (default: true)
151
217
  */
152
218
  comments?: boolean;
219
+
220
+ /**
221
+ * If false (AST mode), parsed links do not keep how their IDs were written:
222
+ * `rawId` and `quote` stay null (default: true)
223
+ */
224
+ syntax?: boolean;
153
225
  }
154
226
 
155
227
  /**
@@ -173,6 +245,11 @@ export class Parser {
173
245
  */
174
246
  comments: boolean;
175
247
 
248
+ /**
249
+ * Whether parsed links keep how their IDs were written (`rawId`)
250
+ */
251
+ syntax: boolean;
252
+
176
253
  /**
177
254
  * Create a new Parser instance
178
255
  * @param options - Parser options
@@ -189,6 +266,13 @@ export class Parser {
189
266
 
190
267
  /** Read the indentation tree without expanding it into path combinations. */
191
268
  parseGroups(input: string): IndentedGroup[];
269
+
270
+ /**
271
+ * Read the document as a lossless concrete syntax tree, so that
272
+ * `parseCst(input).toString() === input`.
273
+ * @throws {ParseError} When `parse(input)` would throw
274
+ */
275
+ parseCst(input: string): CstNode;
192
276
  }
193
277
 
194
278
  /** A parsed line and the immediately indented lines below it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.27.1",
3
+ "version": "0.30.0",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.d.ts",
@@ -32,10 +32,13 @@
32
32
  },
33
33
  "homepage": "https://github.com/link-foundation/links-notation#readme",
34
34
  "devDependencies": {
35
- "bun-types": "^1.4.3",
36
- "eslint": "^10.13.0",
37
- "peggy": "^5.1.0",
35
+ "bun-types": "^1.4.4",
36
+ "eslint": "^10.14.0",
37
+ "peggy": "^5.1.1",
38
38
  "prettier": "^3.9.10",
39
39
  "typescript": "^7.0.2"
40
+ },
41
+ "overrides": {
42
+ "minimatch": "^11.0.4"
40
43
  }
41
44
  }
package/src/Cst.js ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The concrete syntax tree of a Links Notation document.
3
+ *
4
+ * Every character of a document belongs to exactly one token, so the text of
5
+ * the tree is the document itself, comments, blank lines, indentation, quotes
6
+ * and line endings included. A document is a list of `line` nodes; a line holds
7
+ * the tokens up to and including its line ending, and a parenthesized `group`
8
+ * node holds its own parentheses and the lines written between them. A group is
9
+ * part of the line it opens on, however many lines it spans.
10
+ *
11
+ * The tree records how a document was written; `Parser.parse()` reads what it
12
+ * means. They agree because a document is parsed before its tree is built, and
13
+ * the tokens are read with the same rules the grammar uses: comments are found
14
+ * by {@link stripComments}, delimited references by {@link DelimitedReferences}
15
+ * and versioned literals by the same pattern.
16
+ */
17
+
18
+ import { stripComments } from './comments.js';
19
+ import { DelimitedReferences, QUOTES } from './quotes.js';
20
+ import {
21
+ decodeReferenceLiteral,
22
+ referenceLiteralEnd as literalEnd,
23
+ } from './ReferenceLiteral.js';
24
+
25
+ /** The kinds of nodes and tokens of a concrete syntax tree. */
26
+ export const CstKind = Object.freeze({
27
+ /** The whole document: its lines. */
28
+ DOCUMENT: 'document',
29
+ /** One line: its tokens and groups, up to and including its line ending. */
30
+ LINE: 'line',
31
+ /** A parenthesized group: `(`, the lines inside it, and `)`. */
32
+ GROUP: 'group',
33
+ /** A run of spaces and tabs. */
34
+ WHITESPACE: 'whitespace',
35
+ /** One line ending: `\n`, `\r\n` or `\r`. */
36
+ NEWLINE: 'newline',
37
+ /** A comment, from `#` up to the line ending. */
38
+ COMMENT: 'comment',
39
+ /** `(` */
40
+ OPEN_PAREN: 'open_paren',
41
+ /** `)` */
42
+ CLOSE_PAREN: 'close_paren',
43
+ /** `:` */
44
+ COLON: 'colon',
45
+ /** A bare, quoted or versioned literal reference. */
46
+ REFERENCE: 'reference',
47
+ });
48
+
49
+ const NODE_KINDS = new Set([CstKind.DOCUMENT, CstKind.LINE, CstKind.GROUP]);
50
+
51
+ /** One node or token of a concrete syntax tree. */
52
+ export class CstNode {
53
+ #text;
54
+
55
+ /**
56
+ * @param {string} kind - One of {@link CstKind}
57
+ * @param {Object} parts - The token's text, or the node's children
58
+ * @param {string} [parts.text] - A token's text exactly as written
59
+ * @param {CstNode[]} [parts.children] - A node's children
60
+ * @param {string|null} [parts.value] - What a reference token means
61
+ * @param {string|null} [parts.quote] - The delimiter of a quoted reference
62
+ */
63
+ constructor(
64
+ kind,
65
+ { text = '', children = [], value = null, quote = null } = {}
66
+ ) {
67
+ /** One of {@link CstKind}. */
68
+ this.kind = kind;
69
+ /** The children of a node, in document order; empty for a token. */
70
+ this.children = children;
71
+ /**
72
+ * What a reference token means, its delimiters and escapes resolved (the
73
+ * ID a parsed link has); null for every other node and token.
74
+ */
75
+ this.value = value;
76
+ /**
77
+ * The delimiter a quoted reference token was written between (`'`, `"` or
78
+ * `` ` ``); null for a bare or literal reference and every other kind.
79
+ */
80
+ this.quote = quote;
81
+ this.#text = text;
82
+ }
83
+
84
+ /** Whether this is a token rather than a node with children. */
85
+ get isToken() {
86
+ return !NODE_KINDS.has(this.kind);
87
+ }
88
+
89
+ /** The exact text this node or token was read from. */
90
+ get text() {
91
+ if (this.isToken) return this.#text;
92
+ return this.children.map((child) => child.text).join('');
93
+ }
94
+
95
+ /** The exact text this node or token was read from. */
96
+ toString() {
97
+ return this.text;
98
+ }
99
+
100
+ /**
101
+ * Every token of this subtree, in document order.
102
+ * @returns {CstNode[]}
103
+ */
104
+ tokens() {
105
+ if (this.isToken) return [this];
106
+ return this.children.flatMap((child) => child.tokens());
107
+ }
108
+
109
+ /**
110
+ * The tree as an S-expression: `(kind child ...)` for a node and
111
+ * `kind:"text"` for a token, the text escaped as a JSON string would escape
112
+ * `\`, `"`, line feeds, carriage returns and tabs. Two trees are the same
113
+ * exactly when their S-expressions are.
114
+ * @returns {string}
115
+ * @example
116
+ * new Parser().parseCst("a '1'").toSExpression();
117
+ * // (document (line reference:"a" whitespace:" " reference:"'1'"))
118
+ */
119
+ toSExpression() {
120
+ if (this.isToken) return `${this.kind}:${quoteText(this.#text)}`;
121
+ return `(${[this.kind, ...this.children.map((child) => child.toSExpression())].join(' ')})`;
122
+ }
123
+ }
124
+
125
+ const ESCAPES = {
126
+ '\\': '\\\\',
127
+ '"': '\\"',
128
+ '\n': '\\n',
129
+ '\r': '\\r',
130
+ '\t': '\\t',
131
+ };
132
+
133
+ function quoteText(text) {
134
+ return `"${text.replace(/[\\"\n\r\t]/g, (character) => ESCAPES[character])}"`;
135
+ }
136
+
137
+ /** What ends a bare reference. */
138
+ const BARE_REFERENCE_END = new Set([' ', '\t', '\r', '\n', '(', ':', ')']);
139
+
140
+ /**
141
+ * The concrete syntax tree of a document that has already been parsed.
142
+ * @param {string} input - The document as written
143
+ * @param {Object} options
144
+ * @param {boolean} [options.comments=true] - Whether `#` starts a comment
145
+ * @returns {CstNode} The `document` node
146
+ */
147
+ export function buildCst(input, { comments = true } = {}) {
148
+ // Tokens end where they end in the document the grammar reads: the one with
149
+ // its comments blanked, which keeps every position of the one written.
150
+ // Delimited references are read, as the grammar reads them, in the document
151
+ // as written, so a comment never changes what a reference holds.
152
+ const prepared = comments ? stripComments(input) : input;
153
+ const references = new DelimitedReferences(input);
154
+ const document = new CstNode(CstKind.DOCUMENT);
155
+ // The open containers, innermost last, each with the line being filled.
156
+ const containers = [{ node: document, line: null }];
157
+
158
+ const add = (token) => {
159
+ const container = containers[containers.length - 1];
160
+ if (container.line === null) {
161
+ container.line = new CstNode(CstKind.LINE);
162
+ container.node.children.push(container.line);
163
+ }
164
+ container.line.children.push(token);
165
+ };
166
+ const token = (kind, start, end, parts = {}) =>
167
+ new CstNode(kind, { text: input.slice(start, end), ...parts });
168
+
169
+ let position = 0;
170
+ while (position < input.length) {
171
+ const character = input[position];
172
+ let end = position + 1;
173
+
174
+ if (comments && character === '#' && prepared[position] !== '#') {
175
+ // A blanked `#` opens a comment, which runs to the line ending.
176
+ while (end < input.length && input[end] !== '\n' && input[end] !== '\r') {
177
+ end++;
178
+ }
179
+ add(token(CstKind.COMMENT, position, end));
180
+ } else if (character === '\r' || character === '\n') {
181
+ if (character === '\r' && input[end] === '\n') end++;
182
+ add(token(CstKind.NEWLINE, position, end));
183
+ containers[containers.length - 1].line = null;
184
+ } else if (character === ' ' || character === '\t') {
185
+ while (
186
+ end < input.length &&
187
+ (input[end] === ' ' || input[end] === '\t')
188
+ ) {
189
+ end++;
190
+ }
191
+ add(token(CstKind.WHITESPACE, position, end));
192
+ } else if (character === '(') {
193
+ const group = new CstNode(CstKind.GROUP, {
194
+ children: [token(CstKind.OPEN_PAREN, position, end)],
195
+ });
196
+ add(group);
197
+ containers.push({ node: group, line: null });
198
+ } else if (character === ')') {
199
+ if (containers.length === 1) {
200
+ throw new SyntaxError(`Unbalanced ")" at offset ${position}`);
201
+ }
202
+ containers
203
+ .pop()
204
+ .node.children.push(token(CstKind.CLOSE_PAREN, position, end));
205
+ } else if (character === ':') {
206
+ add(token(CstKind.COLON, position, end));
207
+ } else {
208
+ end = referenceEnd(prepared, position, references);
209
+ const text = input.slice(position, end);
210
+ const quoted = QUOTES.includes(character) && references.readAt(position);
211
+ add(
212
+ token(CstKind.REFERENCE, position, end, {
213
+ value: quoted
214
+ ? quoted.value
215
+ : character === '~' && isLiteral(prepared, position, end)
216
+ ? decodeReferenceLiteral(text)
217
+ : text,
218
+ quote: quoted ? character : null,
219
+ })
220
+ );
221
+ }
222
+ position = end;
223
+ }
224
+
225
+ if (containers.length !== 1) {
226
+ throw new SyntaxError('Unbalanced "(" at the end of the document');
227
+ }
228
+ return document;
229
+ }
230
+
231
+ /** Where the reference that starts at `start` ends, read as the grammar reads it. */
232
+ function referenceEnd(prepared, start, references) {
233
+ const literal = literalEnd(prepared, start);
234
+ if (literal !== -1) return literal;
235
+ if (QUOTES.includes(prepared[start])) {
236
+ const end = references.endAt(start);
237
+ if (end !== null) return end;
238
+ }
239
+ let end = start + 1;
240
+ while (end < prepared.length && !BARE_REFERENCE_END.has(prepared[end])) end++;
241
+ return end;
242
+ }
243
+
244
+ function isLiteral(prepared, start, end) {
245
+ return literalEnd(prepared, start) === end;
246
+ }
package/src/Link.js CHANGED
@@ -1,6 +1,16 @@
1
- import { escapeReference, unescapeReference } from './references.js';
1
+ import {
2
+ escapeReference,
3
+ referenceQuote,
4
+ unescapeReference,
5
+ } from './references.js';
2
6
 
3
7
  export class Link {
8
+ /**
9
+ * The ID exactly as the parsed document wrote it, kept out of the link's own
10
+ * enumerable properties so that comparing or serializing links is unchanged.
11
+ */
12
+ #rawId = null;
13
+
4
14
  /**
5
15
  * Create a new Link
6
16
  * @param {string|null} id - Optional identifier for the link
@@ -21,6 +31,36 @@ export class Link {
21
31
  }
22
32
  }
23
33
 
34
+ /**
35
+ * The ID exactly as the parsed document wrote it: `'1'` for a quoted `1`,
36
+ * `1` for a bare one, `~1{31}` for a versioned literal. Null for a link
37
+ * without an ID, a link built in code, a link made by combining the lines of
38
+ * an indented document, and every link read by a parser created with
39
+ * `syntax: false`. It takes no part in `equals()` or `format()`.
40
+ * @type {string|null}
41
+ */
42
+ get rawId() {
43
+ return this.#rawId;
44
+ }
45
+
46
+ set rawId(raw) {
47
+ if (raw !== null && raw !== undefined && typeof raw !== 'string') {
48
+ throw new TypeError('rawId must be a string or null');
49
+ }
50
+ this.#rawId = raw ?? null;
51
+ }
52
+
53
+ /**
54
+ * The delimiter the ID was written between: `'`, `"` or `` ` `` when it was
55
+ * quoted, and null when it was bare, a versioned literal, or not read from a
56
+ * document (see {@link Link#rawId}). `1` and `'1'` stay equal links; this
57
+ * only says how each was written.
58
+ * @type {string|null}
59
+ */
60
+ get quote() {
61
+ return referenceQuote(this.#rawId);
62
+ }
63
+
24
64
  /**
25
65
  * Convert link to string representation
26
66
  * @returns {string} String representation of the link
package/src/Parser.js CHANGED
@@ -2,6 +2,7 @@ import { Link } from './Link.js';
2
2
  import { LinksGroup } from './LinksGroup.js';
3
3
  import { ParseError } from './ParseError.js';
4
4
  import { stripComments } from './comments.js';
5
+ import { buildCst } from './Cst.js';
5
6
  import * as parserModule from './parser-generated.js';
6
7
 
7
8
  /**
@@ -18,11 +19,13 @@ export class Parser {
18
19
  * @param {number} options.maxInputSize - Maximum input size in bytes (default: 10MB)
19
20
  * @param {number} options.maxDepth - How deep links may nest: every parenthesized group and every indentation level is one level (default: 64)
20
21
  * @param {boolean} options.comments - If false, read `#` as an ordinary character instead of the start of a comment (default: true)
22
+ * @param {boolean} options.syntax - If false, read links as a bare abstract syntax tree: they do not record how their IDs were written, so `rawId` and `quote` are null (default: true)
21
23
  */
22
24
  constructor(options = {}) {
23
25
  this.maxInputSize = options.maxInputSize || 10 * 1024 * 1024; // 10MB default
24
26
  this.maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
25
27
  this.comments = options.comments ?? true;
28
+ this.syntax = options.syntax ?? true;
26
29
  }
27
30
 
28
31
  /**
@@ -48,6 +51,20 @@ export class Parser {
48
51
  );
49
52
  }
50
53
 
54
+ /**
55
+ * Read the document as a lossless concrete syntax tree: every character of
56
+ * the input, comments and whitespace included, belongs to exactly one token,
57
+ * so `parseCst(input).toString() === input`. The document is first parsed
58
+ * with this parser's options, so it is refused exactly when `parse()` would
59
+ * refuse it.
60
+ * @param {string} input - The Lino document
61
+ * @returns {CstNode} The `document` node
62
+ */
63
+ parseCst(input) {
64
+ this.parse(input);
65
+ return buildCst(input, { comments: this.comments });
66
+ }
67
+
51
68
  _transformGroup(item) {
52
69
  return new LinksGroup(
53
70
  this.transformLink(item),
@@ -74,6 +91,7 @@ export class Parser {
74
91
  try {
75
92
  const rawResult = parserModule.parse(prepared, {
76
93
  maxDepth: this.maxDepth,
94
+ source: input,
77
95
  });
78
96
  return transform(rawResult);
79
97
  } catch (error) {
@@ -119,6 +137,7 @@ export class Parser {
119
137
  );
120
138
  const linkWithChildren = {
121
139
  id: item.id,
140
+ rawId: item.rawId,
122
141
  values: childValues,
123
142
  };
124
143
  const currentLink = this.transformLink(linkWithChildren);
@@ -163,18 +182,24 @@ export class Parser {
163
182
  transformIndentedValue(item) {
164
183
  const children = item.children || [];
165
184
  if (children.length && item.id != null && !item.values?.length) {
166
- return new Link(
167
- item.id,
168
- children.map((child) => this.transformIndentedValue(child))
185
+ return this._withRawId(
186
+ new Link(
187
+ item.id,
188
+ children.map((child) => this.transformIndentedValue(child))
189
+ ),
190
+ item
169
191
  );
170
192
  }
171
193
 
172
194
  const current = this.transformLink(item);
173
195
  if (children.length) {
174
- return new Link(current.id, [
175
- ...current.values,
176
- ...children.map((child) => this.transformIndentedValue(child)),
177
- ]);
196
+ return this._withRawId(
197
+ new Link(current.id, [
198
+ ...current.values,
199
+ ...children.map((child) => this.transformIndentedValue(child)),
200
+ ]),
201
+ item
202
+ );
178
203
  }
179
204
  if (
180
205
  item.id == null &&
@@ -252,18 +277,32 @@ export class Parser {
252
277
 
253
278
  // Handle simple reference objects like {id: 'a'}
254
279
  if (item.id !== undefined && !item.values && !item.children) {
255
- return new Link(item.id);
280
+ return this._withRawId(new Link(item.id), item);
256
281
  }
257
282
 
258
283
  // For items with values, create a link with those values
259
284
  if (item.values && Array.isArray(item.values)) {
260
285
  // Create a link with id (if present) and transformed values
261
- const link = new Link(item.id ?? null, []);
286
+ const link = this._withRawId(new Link(item.id ?? null, []), item);
262
287
  link.values = item.values.map((v) => this.transformLink(v));
263
288
  return link;
264
289
  }
265
290
 
266
291
  // Default case
267
- return new Link(item.id ?? null, []);
292
+ return this._withRawId(new Link(item.id ?? null, []), item);
293
+ }
294
+
295
+ /**
296
+ * Record how the parsed item wrote its ID, unless this parser reads a bare
297
+ * abstract syntax tree.
298
+ * @param {Link} link - The link made from the item
299
+ * @param {Object} item - The parsed item, with the `rawId` the grammar read
300
+ * @returns {Link} The same link
301
+ */
302
+ _withRawId(link, item) {
303
+ if (this.syntax && link.id !== null && typeof item.rawId === 'string') {
304
+ link.rawId = item.rawId;
305
+ }
306
+ return link;
268
307
  }
269
308
  }
@@ -5,6 +5,28 @@ export function hasReferenceLiteralPrefix(text, start = 0) {
5
5
  return /^~[0-9]+\{/.test(text.slice(start));
6
6
  }
7
7
 
8
+ /** What a versioned literal's payload cannot contain. */
9
+ const LITERAL_PAYLOAD_END = new Set(['}', ' ', '\t', '\r', '\n', '(', ')']);
10
+
11
+ /**
12
+ * Where the versioned literal `~<digits>{<payload>}` that starts at `start`
13
+ * ends, or -1 if none starts there. A single forward scan, so it stays linear
14
+ * on any input (a regular expression here was flagged as a ReDoS risk).
15
+ */
16
+ export function referenceLiteralEnd(text, start) {
17
+ if (text[start] !== '~') return -1;
18
+ let index = start + 1;
19
+ while (index < text.length && text[index] >= '0' && text[index] <= '9') {
20
+ index++;
21
+ }
22
+ if (index === start + 1 || text[index] !== '{') return -1;
23
+ index++;
24
+ while (index < text.length && !LITERAL_PAYLOAD_END.has(text[index])) {
25
+ index++;
26
+ }
27
+ return text[index] === '}' ? index + 1 : -1;
28
+ }
29
+
8
30
  /** Encode exact Unicode text as a version 1 UTF-8 hexadecimal reference. */
9
31
  export function encodeReferenceLiteral(text) {
10
32
  if (typeof text !== 'string' || !text.isWellFormed()) {
@@ -1,6 +1,7 @@
1
1
  import { Emitter } from './Emitter.js';
2
2
  import { Parser } from './Parser.js';
3
3
  import { DelimitedReferences } from './quotes.js';
4
+ import { referenceLiteralEnd } from './ReferenceLiteral.js';
4
5
 
5
6
  const DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
6
7
 
@@ -246,26 +247,20 @@ function countNewlines(value) {
246
247
  }
247
248
 
248
249
  function structurallyComplete(document, comments) {
250
+ // Tokens are read as the comment reader and the grammar read them: a
251
+ // reference may start after a separator, and right after a delimited
252
+ // reference or a literal (issue #377).
249
253
  const quotes = ['"', "'", '`'];
250
- const beforeReference = [' ', '\t', '\n', '\r', '(', ':'];
254
+ const separators = [' ', '\t', '\n', '\r', '(', ':', ')'];
251
255
  const beforeComment = [' ', '\t', '\n', '\r'];
252
256
  const references = new DelimitedReferences(document);
253
257
  let depth = 0;
258
+ let tokenStart = true;
254
259
 
255
260
  for (let position = 0; position < document.length; position++) {
256
261
  const character = document[position];
257
262
  const previous = position === 0 ? null : document[position - 1];
258
263
 
259
- if (
260
- quotes.includes(character) &&
261
- (previous === null || beforeReference.includes(previous))
262
- ) {
263
- const end = references.endAt(position);
264
- if (end === null) return false;
265
- position = end - 1;
266
- continue;
267
- }
268
-
269
264
  if (
270
265
  comments &&
271
266
  character === '#' &&
@@ -274,11 +269,36 @@ function structurallyComplete(document, comments) {
274
269
  const newline = document.indexOf('\n', position);
275
270
  if (newline === -1) return true;
276
271
  position = newline;
272
+ tokenStart = true;
273
+ continue;
274
+ }
275
+
276
+ if (separators.includes(character)) {
277
+ if (character === '(') depth += 1;
278
+ if (character === ')') depth -= 1;
279
+ tokenStart = true;
280
+ continue;
281
+ }
282
+
283
+ if (tokenStart && quotes.includes(character)) {
284
+ // A reference nothing closes yet may be closed by a later line, which
285
+ // changes what it holds: an even run is the empty reference only when
286
+ // no run in the whole document closes it.
287
+ const reading = references.readAt(position);
288
+ if (reading === null || !reading.closed) return false;
289
+ position += reading.length - 1;
277
290
  continue;
278
291
  }
279
292
 
280
- if (character === '(') depth += 1;
281
- if (character === ')') depth -= 1;
293
+ if (tokenStart) {
294
+ const end = referenceLiteralEnd(document, position);
295
+ if (end !== -1) {
296
+ position = end - 1;
297
+ continue;
298
+ }
299
+ }
300
+
301
+ tokenStart = false;
282
302
  }
283
303
 
284
304
  return depth === 0;