links-notation 0.21.2 → 0.22.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
@@ -106,6 +106,12 @@ export class Link {
106
106
  /**
107
107
  * Parser options for configuring the parser behavior
108
108
  */
109
+ /**
110
+ * How deep links may nest when nothing says otherwise (64). The same limit
111
+ * applies in every Links Notation implementation.
112
+ */
113
+ export const DEFAULT_MAX_DEPTH: number;
114
+
109
115
  export interface ParserOptions {
110
116
  /**
111
117
  * Maximum input size in bytes (default: 10MB)
@@ -113,7 +119,9 @@ export interface ParserOptions {
113
119
  maxInputSize?: number;
114
120
 
115
121
  /**
116
- * Maximum nesting depth (default: 1000)
122
+ * How deep links may nest: every parenthesized group and every indentation
123
+ * level is one level. A document nested deeper is refused with a ParseError
124
+ * whose maxDepth is set (default: 64)
117
125
  */
118
126
  maxDepth?: number;
119
127
 
@@ -293,6 +301,12 @@ export class ParseError extends Error {
293
301
  * The position the generated parser reported
294
302
  */
295
303
  location: ParseErrorLocation;
304
+
305
+ /**
306
+ * The deepest nesting allowed, when the document is nested deeper than
307
+ * that; null for any other error
308
+ */
309
+ maxDepth: number | null;
296
310
  }
297
311
 
298
312
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.21.2",
3
+ "version": "0.22.0",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.d.ts",
package/src/ParseError.js CHANGED
@@ -12,6 +12,9 @@ const ELLIPSIS = '...';
12
12
  * message it writes says what it expected without saying where. This error puts
13
13
  * the line and the column in the message as well, and quotes the offending line
14
14
  * with a caret under it, the way the Rust and C# ports do.
15
+ *
16
+ * A document nested deeper than the parser's maxDepth is refused with this
17
+ * error too; then maxDepth says how deep the nesting may go.
15
18
  */
16
19
  export class ParseError extends Error {
17
20
  /**
@@ -24,7 +27,9 @@ export class ParseError extends Error {
24
27
  const lineText = lineAt(input, start.offset);
25
28
  const summary = `line ${start.line}, column ${start.column}: ${error.message}`;
26
29
  const snippet = quote(start.line, lineText, start.column);
27
- super(`Syntax error at ${summary}\n${snippet}`);
30
+ const maxDepth = error?.maxDepth ?? null;
31
+ const kind = maxDepth === null ? 'Syntax error' : 'Nesting too deep';
32
+ super(`${kind} at ${summary}\n${snippet}`);
28
33
 
29
34
  this.name = 'ParseError';
30
35
  /** @type {Error} The error the generated parser threw */
@@ -43,6 +48,8 @@ export class ParseError extends Error {
43
48
  this.lineText = lineText;
44
49
  /** @type {string} The offending line with a caret under the offending column */
45
50
  this.snippet = snippet;
51
+ /** @type {number|null} The deepest nesting allowed, when the document is nested deeper; null for any other error */
52
+ this.maxDepth = maxDepth;
46
53
  }
47
54
  }
48
55
 
package/src/Parser.js CHANGED
@@ -3,17 +3,24 @@ import { ParseError } from './ParseError.js';
3
3
  import { stripComments } from './comments.js';
4
4
  import * as parserModule from './parser-generated.js';
5
5
 
6
+ /**
7
+ * How deep links may nest when nothing says otherwise. The parser recurses once
8
+ * per level, and the same limit applies in every Links Notation implementation,
9
+ * so a document one of them reads is not too deep for another.
10
+ */
11
+ export const DEFAULT_MAX_DEPTH = 64;
12
+
6
13
  export class Parser {
7
14
  /**
8
15
  * Create a new Parser instance
9
16
  * @param {Object} options - Parser options
10
17
  * @param {number} options.maxInputSize - Maximum input size in bytes (default: 10MB)
11
- * @param {number} options.maxDepth - Maximum nesting depth (default: 1000)
18
+ * @param {number} options.maxDepth - How deep links may nest: every parenthesized group and every indentation level is one level (default: 64)
12
19
  * @param {boolean} options.comments - If false, read `#` as an ordinary character instead of the start of a comment (default: true)
13
20
  */
14
21
  constructor(options = {}) {
15
22
  this.maxInputSize = options.maxInputSize || 10 * 1024 * 1024; // 10MB default
16
- this.maxDepth = options.maxDepth || 1000;
23
+ this.maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
17
24
  this.comments = options.comments ?? true;
18
25
  }
19
26
 
@@ -40,7 +47,9 @@ export class Parser {
40
47
  const prepared = this.comments ? stripComments(input) : input;
41
48
 
42
49
  try {
43
- const rawResult = parserModule.parse(prepared);
50
+ const rawResult = parserModule.parse(prepared, {
51
+ maxDepth: this.maxDepth,
52
+ });
44
53
  return this.transformResult(rawResult);
45
54
  } catch (error) {
46
55
  // A syntax error knows where it stopped; anything else is passed on with
package/src/grammar.pegjs CHANGED
@@ -9,16 +9,37 @@
9
9
  // group opens a nested context that starts fresh at indentation level zero,
10
10
  // so line breaks and indentation mean the same thing at every depth.
11
11
  let contextStack = [];
12
+ // How deeply the current context is nested: the enclosing groups and the
13
+ // indentation levels around them. Every level is a level of recursion, so
14
+ // links nested deeper than maxDepth are refused rather than recursed into
15
+ // until the stack overflows.
16
+ let contextDepth = 0;
17
+ const maxDepth = options.maxDepth ?? Infinity;
18
+ // Lines already found unreadable, keyed by where they start and whether they
19
+ // are inside a parenthesised group. Reading a line depends on nothing else: a
20
+ // group starts a fresh indentation context, and indentation only decides
21
+ // which lines become children, so a line that could not be read once never
22
+ // can be. A line that does not parse as the first child of the line above it
23
+ // is tried again as a sibling at every enclosing indentation level, and
24
+ // without this each of those attempts would read the whole line again.
25
+ let unreadableLines = new Set();
12
26
 
13
27
  function resetState() {
14
28
  indentationStack = [0];
15
29
  baseIndentation = null;
16
30
  contextStack = [];
31
+ contextDepth = 0;
32
+ unreadableLines = new Set();
17
33
  return true;
18
34
  }
19
35
 
36
+ function lineKey(position) {
37
+ return contextStack.length > 0 ? -1 - position : position;
38
+ }
39
+
20
40
  function enterNestedContext() {
21
- contextStack.push({ indentationStack, baseIndentation });
41
+ contextStack.push({ indentationStack, baseIndentation, contextDepth });
42
+ contextDepth = depth() + 1;
22
43
  indentationStack = [0];
23
44
  baseIndentation = null;
24
45
  return true;
@@ -29,10 +50,32 @@
29
50
  if (saved) {
30
51
  indentationStack = saved.indentationStack;
31
52
  baseIndentation = saved.baseIndentation;
53
+ contextDepth = saved.contextDepth;
32
54
  }
33
55
  return true;
34
56
  }
35
57
 
58
+ // The nesting depth of the line being read: every enclosing group and every
59
+ // indentation level is one level, and the lines of a document are at level 0.
60
+ function depth() {
61
+ return contextDepth + indentationStack.length - 1;
62
+ }
63
+
64
+ // Refuses links at the given depth when it is deeper than maxDepth. The error
65
+ // is thrown rather than returned as a failed match, so no alternative is
66
+ // tried in its place, and says where the nesting got too deep.
67
+ function checkDepth(levels, where) {
68
+ if (levels <= maxDepth) {
69
+ return true;
70
+ }
71
+ try {
72
+ error(`nesting depth exceeds the maximum of ${maxDepth}`, where);
73
+ } catch (tooDeep) {
74
+ tooDeep.maxDepth = maxDepth;
75
+ throw tooDeep;
76
+ }
77
+ }
78
+
36
79
  function isInsideNestedContext() {
37
80
  return contextStack.length > 0;
38
81
  }
@@ -100,14 +143,42 @@ firstLine = SET_BASE_INDENTATION l:element { return l; }
100
143
 
101
144
  line = CHECK_INDENTATION l:element { return l; }
102
145
 
103
- element = e:anyLink PUSH_INDENTATION l:links {
146
+ // A line is read once, whether or not indented children follow it: reading it
147
+ // again after looking for children doubled the work at every level of nesting.
148
+ // Only a line that parsed counts towards the depth, so trailing spaces indented
149
+ // past the limit are still read as the whitespace they are.
150
+ element = &{ return !unreadableLines.has(lineKey(offset())); }
151
+ start:HERE e:anyLink &{ return checkDepth(depth(), start); }
152
+ saved:SAVE_INDENTATION l:(PUSH_INDENTATION @links)? {
153
+ if (l === null) {
154
+ // No child line followed the indentation. Forget what looking for one
155
+ // pushed, so the next line is compared with this line's indentation.
156
+ indentationStack = saved;
157
+ return e;
158
+ }
104
159
  return Object.assign({}, e, { children: l });
105
160
  }
106
- / e:anyLink { return e; }
161
+ // Remember that this line could not be read. The predicate always fails, so
162
+ // the "." is never reached; it only tells Peggy that this alternative cannot
163
+ // succeed without consuming input.
164
+ / &{ unreadableLines.add(lineKey(offset())); return false; } .
107
165
 
108
166
  referenceOrLink = l:multiLineAnyLink { return l; } / i:reference { return { id: i }; }
109
167
 
110
- anyLink = ml:multiLineAnyLink eol { return ml; } / il:indentedIdLink { return il; } / sl:singleLineAnyLink { return sl; }
168
+ // A line that starts with a parenthesised group reads that group once and then
169
+ // branches on what follows it: the end of the line makes the group the whole
170
+ // link, and more values make it the first value of a value link. Neither an
171
+ // indented ID nor a single-line link can start with a parenthesis, so nothing
172
+ // else is tried for such a line; trying them read the group again, doubling
173
+ // the work at every level of nesting.
174
+ anyLink = &"(" @groupLink / !"(" @(indentedIdLink / singleLineAnyLink)
175
+
176
+ groupLink = g:multiLineAnyLink rest:groupLinkRest {
177
+ return rest === null ? g : { values: [g].concat(rest) };
178
+ }
179
+
180
+ groupLinkRest = eol { return null; }
181
+ / @singleLineValueAndWhitespace* eol
111
182
 
112
183
  multiLineAnyLink = nestedGroup
113
184
 
@@ -117,16 +188,23 @@ singleLineAnyLink = fl:singleLineLink eol { return fl; }
117
188
  // A parenthesised group opens a nested context that follows exactly the same
118
189
  // rules as the root of the document: line breaks separate links and
119
190
  // indentation nests them, starting fresh at indentation level zero.
120
- nestedGroup = "(" ENTER_NESTED_CONTEXT body:nestedGroupBody {
191
+ nestedGroup = start:HERE "(" &{ return checkDepth(depth() + 1, start); }
192
+ ENTER_NESTED_CONTEXT body:nestedGroupBody {
121
193
  exitNestedContext();
122
194
  return body;
123
195
  }
196
+ // The group was opened but its body did not parse: restore the context it
197
+ // was opened in before failing, just as a parsed group does.
198
+ / "(" &{ exitNestedContext(); return false; }
124
199
 
125
200
  nestedGroupBody = skipEmptyLines l:links _ ")" { return { nested: l }; }
126
201
  / _ ")" { return { nested: [] }; }
127
202
 
128
203
  ENTER_NESTED_CONTEXT = &{ return enterNestedContext(); }
129
204
 
205
+ // Where the parser is, to say where nesting got too deep.
206
+ HERE = "" { return location(); }
207
+
130
208
  singleLineValueAndWhitespace = __ value:referenceOrLink { return value; }
131
209
 
132
210
  singleLineValues = list:singleLineValueAndWhitespace+ { return list; }
@@ -193,6 +271,8 @@ backtickQuotedUniversal = &'`' &{
193
271
  consumeBacktick = c:. cs:consumeBacktickMore* { return [c].concat(cs).join(''); }
194
272
  consumeBacktickMore = &{ return parsedLength > 1 && (parsedLength--, true); } c:. { return c; }
195
273
 
274
+ SAVE_INDENTATION = "" { return indentationStack.slice(); }
275
+
196
276
  SET_BASE_INDENTATION = spaces:" "* { setBaseIndentation(spaces); }
197
277
 
198
278
  PUSH_INDENTATION = spaces:" "* &{ return normalizeIndentation(spaces) > getCurrentIndentation(); } { pushIndentation(spaces); }
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { Link, formatLinks } from './Link.js';
2
2
  export { LinksGroup } from './LinksGroup.js';
3
- export { Parser } from './Parser.js';
3
+ export { Parser, DEFAULT_MAX_DEPTH } from './Parser.js';
4
4
  export { StreamParseError, StreamParser } from './StreamParser.js';
5
5
  export { ParseError } from './ParseError.js';
6
6
  export { FormatConfig } from './FormatConfig.js';