links-notation 0.28.0__tar.gz → 0.30.0__tar.gz

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.
Files changed (50) hide show
  1. {links_notation-0.28.0/links_notation.egg-info → links_notation-0.30.0}/PKG-INFO +3 -3
  2. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/comments.py +39 -8
  3. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/cst.py +12 -14
  4. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/parser.py +53 -41
  5. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/quotes.py +33 -8
  6. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/reference_literal.py +9 -0
  7. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/stream_parser.py +31 -13
  8. {links_notation-0.28.0 → links_notation-0.30.0/links_notation.egg-info}/PKG-INFO +3 -3
  9. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation.egg-info/requires.txt +2 -2
  10. {links_notation-0.28.0 → links_notation-0.30.0}/pyproject.toml +4 -4
  11. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_comments.py +29 -0
  12. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_stream_parser.py +10 -0
  13. {links_notation-0.28.0 → links_notation-0.30.0}/LICENSE +0 -0
  14. {links_notation-0.28.0 → links_notation-0.30.0}/MANIFEST.in +0 -0
  15. {links_notation-0.28.0 → links_notation-0.30.0}/README.md +0 -0
  16. {links_notation-0.28.0 → links_notation-0.30.0}/README.ru.md +0 -0
  17. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/__init__.py +0 -0
  18. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/binary.py +0 -0
  19. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/format_config.py +0 -0
  20. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/formatter.py +0 -0
  21. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/link.py +0 -0
  22. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation.egg-info/SOURCES.txt +0 -0
  23. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation.egg-info/dependency_links.txt +0 -0
  24. {links_notation-0.28.0 → links_notation-0.30.0}/links_notation.egg-info/top_level.txt +0 -0
  25. {links_notation-0.28.0 → links_notation-0.30.0}/setup.cfg +0 -0
  26. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_api.py +0 -0
  27. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_binary.py +0 -0
  28. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_cst.py +0 -0
  29. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_edge_case_parser.py +0 -0
  30. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_empty_reference.py +0 -0
  31. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_format_config.py +0 -0
  32. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_indentation_consistency.py +0 -0
  33. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_indented_id_nested_values.py +0 -0
  34. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_indented_id_syntax.py +0 -0
  35. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_link.py +0 -0
  36. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_links_group.py +0 -0
  37. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_mixed_indentation_modes.py +0 -0
  38. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_multi_quote_parser.py +0 -0
  39. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_multiline_parser.py +0 -0
  40. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_multiline_quoted_string.py +0 -0
  41. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_nested_indentation.py +0 -0
  42. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_nested_parser.py +0 -0
  43. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_nested_self_reference.py +0 -0
  44. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_nesting_limit.py +0 -0
  45. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_reference_fidelity.py +0 -0
  46. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_shared_syntax_parity.py +0 -0
  47. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_shared_text_parity.py +0 -0
  48. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_single_line_parser.py +0 -0
  49. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_syntax_metadata.py +0 -0
  50. {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: links-notation
3
- Version: 0.28.0
3
+ Version: 0.30.0
4
4
  Summary: Python implementation of the Links Notation parser
5
5
  Author-email: "Link.Foundation" <drakonard@gmail.com>
6
6
  License-Expression: Unlicense
@@ -22,12 +22,12 @@ Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Provides-Extra: test
25
- Requires-Dist: pytest>=9.1.1; extra == "test"
25
+ Requires-Dist: pytest>=9.2.0; extra == "test"
26
26
  Requires-Dist: pytest-timeout>=2.4.0; extra == "test"
27
27
  Provides-Extra: lint
28
28
  Requires-Dist: black>=26.10.1; extra == "lint"
29
29
  Requires-Dist: isort>=9.0.2; extra == "lint"
30
- Requires-Dist: flake8>=7.4.1; extra == "lint"
30
+ Requires-Dist: flake8>=7.5.0; extra == "lint"
31
31
  Provides-Extra: dev
32
32
  Requires-Dist: links-notation[lint,test]; extra == "dev"
33
33
  Dynamic: license-file
@@ -13,12 +13,15 @@ still points at what the reader can see.
13
13
  """
14
14
 
15
15
  from .quotes import QUOTE_CHARS, _parse_quoted_string_at
16
+ from .reference_literal import reference_literal_end
16
17
 
17
18
  #: The character that starts a comment.
18
19
  COMMENT = "#"
19
20
 
20
- #: What a delimited reference may start after.
21
- BEFORE_REFERENCE = (" ", "\t", "\n", "\r", "(", ":")
21
+ #: What ends a token, so that what follows it starts a new one. A delimited
22
+ #: reference or a literal may start at the start of any token, and also right
23
+ #: after a delimited reference or a literal, as the parser reads them.
24
+ SEPARATORS = (" ", "\t", "\n", "\r", "(", ":", ")")
22
25
 
23
26
  #: What a comment may start after.
24
27
  BEFORE_COMMENT = (" ", "\t", "\n", "\r")
@@ -28,7 +31,9 @@ def strip_comments(document: str) -> str:
28
31
  """
29
32
  Return ``document`` with every comment blanked out.
30
33
 
31
- The result has the same length as the document it was given.
34
+ The result has the same length as the document it was given. Tokens are
35
+ read as the parser reads them, so a ``#`` inside a delimited reference that
36
+ follows a group, another reference or a literal stays content (issue #377).
32
37
 
33
38
  >>> strip_comments("a: b # why\\n")
34
39
  'a: b \\n'
@@ -36,18 +41,16 @@ def strip_comments(document: str) -> str:
36
41
  '"# kept"\\n'
37
42
  >>> strip_comments("issue#1047\\n")
38
43
  'issue#1047\\n'
44
+ >>> strip_comments('(a)" #b"\\n')
45
+ '(a)" #b"\\n'
39
46
  """
40
47
  blanked = None
41
48
  position = 0
49
+ token_start = True
42
50
 
43
51
  while position < len(document):
44
52
  char = document[position]
45
53
 
46
- if char in QUOTE_CHARS and _follows(document, position, BEFORE_REFERENCE):
47
- parsed = _parse_quoted_string_at(document, position)
48
- position = position + 1 if parsed is None else parsed[1]
49
- continue
50
-
51
54
  if char == COMMENT and _follows(document, position, BEFORE_COMMENT):
52
55
  if blanked is None:
53
56
  blanked = list(document)
@@ -56,11 +59,39 @@ def strip_comments(document: str) -> str:
56
59
  position += 1
57
60
  continue
58
61
 
62
+ if char in SEPARATORS:
63
+ token_start = True
64
+ position += 1
65
+ continue
66
+
67
+ if token_start:
68
+ end = delimited_token_end(document, position)
69
+ if end is not None:
70
+ position = end
71
+ continue
72
+
73
+ token_start = False
59
74
  position += 1
60
75
 
61
76
  return document if blanked is None else "".join(blanked)
62
77
 
63
78
 
79
+ def delimited_token_end(document: str, start: int):
80
+ """
81
+ Where the versioned literal or delimited reference that starts at ``start``
82
+ ends, or None when neither can be read there. What follows one starts a new
83
+ token.
84
+ """
85
+ literal = reference_literal_end(document, start)
86
+ if literal != -1:
87
+ return literal
88
+ if document[start] in QUOTE_CHARS:
89
+ parsed = _parse_quoted_string_at(document, start)
90
+ if parsed is not None:
91
+ return parsed[1]
92
+ return None
93
+
94
+
64
95
  def _follows(document: str, position: int, allowed) -> bool:
65
96
  """
66
97
  Report whether the character before ``position`` is one of ``allowed``, the
@@ -21,7 +21,7 @@ from typing import List, Optional, Tuple
21
21
  from .comments import strip_comments
22
22
  from .parser import ParseError
23
23
  from .quotes import QUOTE_CHARS, _parse_quoted_string_at
24
- from .reference_literal import decode_reference_literal
24
+ from .reference_literal import decode_reference_literal, reference_literal_end
25
25
 
26
26
 
27
27
  class CstKind:
@@ -55,9 +55,6 @@ _NODE_KINDS = frozenset((CstKind.DOCUMENT, CstKind.LINE, CstKind.GROUP))
55
55
  _ESCAPES = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t"}
56
56
  _ESCAPED = re.compile(r'[\\"\n\r\t]')
57
57
 
58
- #: A versioned literal.
59
- _LITERAL = re.compile(r"~[0-9]+\{[^} \t\r\n()]*\}")
60
-
61
58
  #: What ends a bare reference.
62
59
  _BARE_REFERENCE_END = frozenset(" \t\r\n(:)")
63
60
 
@@ -170,8 +167,11 @@ def build_cst(input_text: str, comments: bool = True) -> CstNode:
170
167
  Raises:
171
168
  ParseError: If the parentheses are unbalanced
172
169
  """
173
- # References are read in the document the parser reads: the one with its
174
- # comments blanked, which keeps every position of the one written.
170
+ # Bare references end where they end in the document the parser reads: the
171
+ # one with its comments blanked, which keeps every position of the one
172
+ # written. Delimited references and literals are read, as the parser reads
173
+ # them, in the document as written, so a comment never changes what a
174
+ # reference holds (issue #377).
175
175
  prepared = strip_comments(input_text) if comments else input_text
176
176
  document = CstNode(CstKind.DOCUMENT)
177
177
  # The open containers, innermost last.
@@ -232,14 +232,12 @@ def _reference(input_text: str, prepared: str, start: int) -> Tuple[CstNode, int
232
232
  The reference token that starts at ``start``, read as the parser reads it,
233
233
  and where it ends.
234
234
  """
235
- if prepared[start] == "~":
236
- literal = _LITERAL.match(prepared, start)
237
- if literal is not None:
238
- end = literal.end()
239
- text = input_text[start:end]
240
- return CstNode(CstKind.REFERENCE, text=text, value=decode_reference_literal(text)), end
241
- if prepared[start] in QUOTE_CHARS:
242
- quoted = _parse_quoted_string_at(prepared, start)
235
+ end = reference_literal_end(input_text, start)
236
+ if end != -1:
237
+ text = input_text[start:end]
238
+ return CstNode(CstKind.REFERENCE, text=text, value=decode_reference_literal(text)), end
239
+ if input_text[start] in QUOTE_CHARS:
240
+ quoted = _parse_quoted_string_at(input_text, start)
243
241
  if quoted is not None:
244
242
  value, end = quoted
245
243
  reference = CstNode(CstKind.REFERENCE, text=input_text[start:end], value=value, quote=input_text[start])
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
11
11
  from .comments import strip_comments
12
12
  from .link import Link
13
13
  from .quotes import _parse_quoted_string_at
14
- from .reference_literal import decode_reference_literal
14
+ from .reference_literal import decode_reference_literal, reference_literal_end
15
15
 
16
16
  if TYPE_CHECKING:
17
17
  from .cst import CstNode
@@ -274,14 +274,29 @@ class Parser:
274
274
  self.parse(input_text)
275
275
  return build_cst(input_text, comments=self.comments)
276
276
 
277
- def _skip_quoted_string(self, text: str, start: int) -> int:
277
+ def _quoted_at(self, text: str, start: int, offset: int) -> Optional[tuple]:
278
278
  """
279
- Skip over the quoted string starting at start.
279
+ Read the delimited reference at ``start`` in ``text``, a part of the
280
+ document that starts at ``offset``: ``(value, end)`` with ``end`` in
281
+ ``text``, or None.
282
+
283
+ The reference is read in the document as written, within the extent of
284
+ ``text``. ``text`` comes from the document with its comments blanked,
285
+ which keeps every position, and blanking a comment must not change what
286
+ a reference holds or how far it reaches (issue #377).
287
+ """
288
+ parsed = _parse_quoted_string_at(self.source, offset + start, offset + len(text))
289
+ return None if parsed is None else (parsed[0], parsed[1] - offset)
290
+
291
+ def _skip_quoted_string(self, text: str, start: int, offset: int) -> int:
292
+ """
293
+ Skip over the quoted string starting at start, ``text`` starting at
294
+ ``offset`` in the document.
280
295
 
281
296
  Returns the position right after the closing quotes, or -1 when text
282
297
  does not start a terminated quoted string.
283
298
  """
284
- parsed = _parse_quoted_string_at(text, start)
299
+ parsed = self._quoted_at(text, start, offset)
285
300
  return -1 if parsed is None else parsed[1]
286
301
 
287
302
  def _split_lines_respecting_quotes(self, text: str, base: int) -> Tuple[List[str], List[int]]:
@@ -323,7 +338,7 @@ class Parser:
323
338
  position = i + 1
324
339
  else:
325
340
  # A quoted string is opaque: newlines inside it are content
326
- end = self._skip_quoted_string(text, i)
341
+ end = self._skip_quoted_string(text, i, base)
327
342
  position = end if end > i else i + 1
328
343
  counted = position
329
344
 
@@ -434,7 +449,7 @@ class Parser:
434
449
  def _parse_line_content(self, content: str, offset: int) -> Dict:
435
450
  """Parse the content of a single line, which starts at ``offset``."""
436
451
  # A whole parenthesized group: (id: values), (values) or a nested document
437
- if content.startswith("(") and self._find_matching_paren(content, 0) == len(content) - 1:
452
+ if content.startswith("(") and self._find_matching_paren(content, 0, offset) == len(content) - 1:
438
453
  return self._parse_parenthesized(content[1:-1], offset)
439
454
 
440
455
  # Try indented ID syntax: id:
@@ -443,7 +458,7 @@ class Parser:
443
458
  return {"id": ref, "raw_id": raw, "values": [], "is_indented_id": True}
444
459
 
445
460
  # Try single-line link: id: values
446
- colon_pos = self._find_colon_outside_quotes(content)
461
+ colon_pos = self._find_colon_outside_quotes(content, offset)
447
462
  if colon_pos >= 0:
448
463
  after_colon = content[colon_pos + 1 :]
449
464
  values_part = after_colon.strip()
@@ -496,9 +511,10 @@ class Parser:
496
511
  self.context_depth = saved_context_depth
497
512
  self.depth = saved_depth
498
513
 
499
- def _find_matching_paren(self, text: str, start: int) -> int:
514
+ def _find_matching_paren(self, text: str, start: int, offset: int) -> int:
500
515
  """
501
- Find the position of the parenthesis closing the one at start.
516
+ Find the position of the parenthesis closing the one at start, ``text``
517
+ starting at ``offset`` in the document.
502
518
 
503
519
  Quoted strings are skipped, so parentheses inside them are ignored.
504
520
  Returns -1 when the group is not closed, or when start is not at an
@@ -525,16 +541,17 @@ class Parser:
525
541
  return run_start + depth - 1
526
542
  depth -= run_end - run_start
527
543
  else:
528
- end = self._skip_quoted_string(text, run_start)
544
+ end = self._skip_quoted_string(text, run_start, offset)
529
545
  if end > run_start:
530
546
  position = end
531
547
  break
532
548
  else:
533
549
  return -1
534
550
 
535
- def _find_colon_outside_quotes(self, text: str) -> int:
551
+ def _find_colon_outside_quotes(self, text: str, offset: int) -> int:
536
552
  """
537
- Find the position of a colon that's not inside quotes or parentheses.
553
+ Find the position of a colon that's not inside quotes or parentheses,
554
+ ``text`` starting at ``offset`` in the document.
538
555
 
539
556
  This is crucial for correctly parsing nested self-referenced objects.
540
557
  For example, in: ((str key) (obj_1: dict ...))
@@ -558,7 +575,7 @@ class Parser:
558
575
  return i
559
576
  position = i + 1
560
577
  else:
561
- end = self._skip_quoted_string(text, i)
578
+ end = self._skip_quoted_string(text, i, offset)
562
579
  position = end if end > i else i + 1
563
580
  counted = position
564
581
 
@@ -578,7 +595,7 @@ class Parser:
578
595
  break
579
596
 
580
597
  # Try to extract the next value
581
- value_end, value_text = self._extract_next_value(text, i)
598
+ value_end, value_text = self._extract_next_value(text, i, offset)
582
599
  if value_text and value_text.strip():
583
600
  values.append(self._parse_value(value_text, offset + i))
584
601
  if value_end == i:
@@ -589,9 +606,10 @@ class Parser:
589
606
 
590
607
  return values
591
608
 
592
- def _extract_next_value(self, text: str, start: int) -> tuple:
609
+ def _extract_next_value(self, text: str, start: int, offset: int) -> tuple:
593
610
  """
594
- Extract the next value from text starting at start position.
611
+ Extract the next value from text starting at start position, ``text``
612
+ starting at ``offset`` in the document.
595
613
  Returns (end_position, value_text).
596
614
  """
597
615
  if start >= len(text):
@@ -599,14 +617,20 @@ class Parser:
599
617
 
600
618
  # Check if this starts with a delimited reference (any N quotes, or a
601
619
  # bare delimiter pair standing for the empty reference)
602
- quoted = _parse_quoted_string_at(text, start)
620
+ quoted = self._quoted_at(text, start, offset)
603
621
  if quoted is not None:
604
622
  _, end = quoted
605
623
  return (end, text[start:end])
606
624
 
625
+ # A versioned literal is a whole token: what follows it starts the
626
+ # next one, as the reference grammar (js/src/grammar.pegjs) reads it
627
+ literal = reference_literal_end(text, start)
628
+ if literal != -1:
629
+ return (literal, text[start:literal])
630
+
607
631
  # Check if this starts with a parenthesized expression
608
632
  if text[start] == "(":
609
- end = self._find_matching_paren(text, start)
633
+ end = self._find_matching_paren(text, start, offset)
610
634
  if end >= 0:
611
635
  return (end + 1, text[start : end + 1])
612
636
  return (len(text), text[start:])
@@ -625,44 +649,32 @@ class Parser:
625
649
  def _parse_value(self, value: str, offset: int) -> Dict:
626
650
  """Parse a single value (could be a reference or nested link) starting at ``offset``."""
627
651
  # Nested link in parentheses
628
- if value.startswith("(") and self._find_matching_paren(value, 0) == len(value) - 1:
652
+ if value.startswith("(") and self._find_matching_paren(value, 0, offset) == len(value) - 1:
629
653
  return self._parse_parenthesized(value[1:-1], offset)
630
654
 
631
655
  # Simple reference
632
656
  ref, raw = self._read_reference(value, offset)
633
657
  return {"id": ref, "raw_id": raw}
634
658
 
635
- def _extract_reference(self, text: str) -> str:
636
- """Extract reference, handling quoted strings with escaping support."""
637
- text = text.strip()
638
-
639
- if re.match(r"~[0-9]+\{", text):
640
- return decode_reference_literal(text)
641
-
642
- # Try delimited references (any N quotes, or a bare delimiter pair)
643
- quoted = _parse_quoted_string_at(text, 0)
644
- if quoted is not None:
645
- return quoted[0]
646
-
647
- # Unquoted
648
- return text
649
-
650
659
  def _read_reference(self, text: str, offset: int) -> Tuple[str, str]:
651
660
  """
652
661
  Read the reference ``text`` written at ``offset`` (surrounding
653
662
  whitespace allowed): what it means, and the text it was written as.
654
663
 
655
- The text is sliced from the document as the caller wrote it, which has
656
- the same positions as the one with its comments blanked out.
664
+ The reference is read in the document as the caller wrote it, which
665
+ has the same positions as the one with its comments blanked out.
657
666
  """
658
667
  reference = text.strip()
659
668
  start = offset + len(text) - len(text.lstrip())
660
669
  end = start + len(reference)
661
- if not re.match(r"~[0-9]+\{", reference):
662
- quoted = _parse_quoted_string_at(reference, 0)
663
- if quoted is not None:
664
- end = start + quoted[1]
665
- return self._extract_reference(reference), self.source[start:end]
670
+ if re.match(r"~[0-9]+\{", reference):
671
+ return decode_reference_literal(reference), self.source[start:end]
672
+ # Delimited references (any N quotes, or a bare delimiter pair)
673
+ quoted = self._quoted_at(reference, 0, start)
674
+ if quoted is not None:
675
+ return quoted[0], self.source[start : start + quoted[1]]
676
+ # Unquoted
677
+ return reference, self.source[start:end]
666
678
 
667
679
  def _with_raw_id(self, link: Link, item: Dict) -> Link:
668
680
  """
@@ -36,9 +36,10 @@ def _is_substantive_body(content: str) -> bool:
36
36
  return has_visible and depth == 0
37
37
 
38
38
 
39
- def _parse_quoted_string_at(text: str, start: int) -> Optional[tuple]:
39
+ def _parse_quoted_string_at(text: str, start: int, stop: Optional[int] = None) -> Optional[tuple]:
40
40
  """
41
- Parse the delimited reference that starts at ``start``.
41
+ Parse the delimited reference that starts at ``start``, reading no further
42
+ than ``stop`` (the end of ``text`` by default).
42
43
 
43
44
  Any number N of quotes opens and closes the string, 2*N quotes are an
44
45
  escaped quote sequence. A run of an even number of delimiters that does not
@@ -50,7 +51,8 @@ def _parse_quoted_string_at(text: str, start: int) -> Optional[tuple]:
50
51
  right after the closing quotes, or ``None`` when ``text`` does not start a
51
52
  delimited reference.
52
53
  """
53
- if start >= len(text):
54
+ limit = len(text) if stop is None else stop
55
+ if start >= limit:
54
56
  return None
55
57
 
56
58
  quote_char = text[start]
@@ -59,7 +61,7 @@ def _parse_quoted_string_at(text: str, start: int) -> Optional[tuple]:
59
61
 
60
62
  quote_count = 0
61
63
  pos = start
62
- while pos < len(text) and text[pos] == quote_char:
64
+ while pos < limit and text[pos] == quote_char:
63
65
  quote_count += 1
64
66
  pos += 1
65
67
 
@@ -70,14 +72,14 @@ def _parse_quoted_string_at(text: str, start: int) -> Optional[tuple]:
70
72
  escape_seq = quote_char * (quote_count * 2)
71
73
  content = []
72
74
 
73
- while pos < len(text):
74
- if text.startswith(escape_seq, pos):
75
+ while pos < limit:
76
+ if text.startswith(escape_seq, pos, limit):
75
77
  content.append(open_close)
76
78
  pos += len(escape_seq)
77
79
  continue
78
- if text.startswith(open_close, pos):
80
+ if text.startswith(open_close, pos, limit):
79
81
  after_close = pos + quote_count
80
- if after_close >= len(text) or text[after_close] != quote_char:
82
+ if after_close >= limit or text[after_close] != quote_char:
81
83
  value = "".join(content)
82
84
  if is_even_run and not _is_substantive_body(value):
83
85
  return empty_reference
@@ -88,6 +90,29 @@ def _parse_quoted_string_at(text: str, start: int) -> Optional[tuple]:
88
90
  return empty_reference
89
91
 
90
92
 
93
+ def _closing_run_follows(text: str, start: int) -> bool:
94
+ """
95
+ Report whether a run of delimiters after the one opening at ``start``
96
+ closes it. An even run nothing closes is the empty reference, until a
97
+ closing run is written after it.
98
+ """
99
+ quote_char = text[start]
100
+ count = 0
101
+ while start + count < len(text) and text[start + count] == quote_char:
102
+ count += 1
103
+ pos = start + count
104
+ while True:
105
+ pos = text.find(quote_char, pos)
106
+ if pos < 0:
107
+ return False
108
+ length = 0
109
+ while pos + length < len(text) and text[pos + length] == quote_char:
110
+ length += 1
111
+ if (length // count) % 2 == 1:
112
+ return True
113
+ pos += length
114
+
115
+
91
116
  def reference_quote(raw: Optional[str]) -> Optional[str]:
92
117
  """
93
118
  The delimiter a reference was written between, read from the reference as
@@ -2,6 +2,15 @@
2
2
 
3
3
  import re
4
4
 
5
+ #: A versioned literal ``~<digits>{<payload>}``, as the parser reads one.
6
+ _LITERAL = re.compile(r"~[0-9]+\{[^} \t\r\n()]*\}")
7
+
8
+
9
+ def reference_literal_end(text: str, start: int) -> int:
10
+ """Where the versioned literal that starts at ``start`` ends, or -1 if none starts there."""
11
+ match = _LITERAL.match(text, start)
12
+ return -1 if match is None else match.end()
13
+
5
14
 
6
15
  def encode_reference_literal(text: str) -> str:
7
16
  """Encode exact text as a versioned UTF-8 hexadecimal literal."""
@@ -5,7 +5,8 @@ from typing import AsyncIterable, AsyncIterator, Callable, Iterable, Iterator, L
5
5
 
6
6
  from .link import Link
7
7
  from .parser import DEFAULT_MAX_DEPTH, ParseError, Parser
8
- from .quotes import QUOTE_CHARS, _parse_quoted_string_at
8
+ from .quotes import QUOTE_CHARS, _closing_run_follows, _parse_quoted_string_at
9
+ from .reference_literal import reference_literal_end
9
10
 
10
11
 
11
12
  @dataclass(frozen=True)
@@ -210,30 +211,47 @@ def _leading_spaces(line: str) -> int:
210
211
 
211
212
 
212
213
  def _structurally_complete(document: str, comments: bool) -> bool:
214
+ # Tokens are read as the comment reader and the parser read them: a
215
+ # reference may start after a separator, and right after a delimited
216
+ # reference or a literal (issue #377).
213
217
  depth = 0
214
218
  position = 0
215
- before_reference = " \t\n\r(:"
219
+ token_start = True
220
+ separators = " \t\n\r(:)"
216
221
  before_comment = " \t\n\r"
217
222
 
218
223
  while position < len(document):
219
224
  character = document[position]
220
225
  previous = None if position == 0 else document[position - 1]
221
- if character in QUOTE_CHARS and (previous is None or previous in before_reference):
222
- parsed = _parse_quoted_string_at(document, position)
223
- if parsed is None:
224
- return False
225
- position = parsed[1]
226
- continue
227
226
  if comments and character == "#" and (previous is None or previous in before_comment):
228
227
  newline = document.find("\n", position)
229
228
  if newline < 0:
230
229
  break
231
- position = newline + 1
230
+ position = newline
231
+ continue
232
+ if character in separators:
233
+ if character == "(":
234
+ depth += 1
235
+ elif character == ")":
236
+ depth -= 1
237
+ token_start = True
238
+ position += 1
239
+ continue
240
+ if token_start and character in QUOTE_CHARS:
241
+ # A reference nothing closes yet may be closed by a later line,
242
+ # which changes what it holds: an even run is the empty reference
243
+ # only when no run in the whole document closes it.
244
+ parsed = _parse_quoted_string_at(document, position)
245
+ if parsed is None or not _closing_run_follows(document, position):
246
+ return False
247
+ position = parsed[1]
232
248
  continue
233
- if character == "(":
234
- depth += 1
235
- elif character == ")":
236
- depth -= 1
249
+ if token_start:
250
+ end = reference_literal_end(document, position)
251
+ if end != -1:
252
+ position = end
253
+ continue
254
+ token_start = False
237
255
  position += 1
238
256
 
239
257
  return depth == 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: links-notation
3
- Version: 0.28.0
3
+ Version: 0.30.0
4
4
  Summary: Python implementation of the Links Notation parser
5
5
  Author-email: "Link.Foundation" <drakonard@gmail.com>
6
6
  License-Expression: Unlicense
@@ -22,12 +22,12 @@ Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Provides-Extra: test
25
- Requires-Dist: pytest>=9.1.1; extra == "test"
25
+ Requires-Dist: pytest>=9.2.0; extra == "test"
26
26
  Requires-Dist: pytest-timeout>=2.4.0; extra == "test"
27
27
  Provides-Extra: lint
28
28
  Requires-Dist: black>=26.10.1; extra == "lint"
29
29
  Requires-Dist: isort>=9.0.2; extra == "lint"
30
- Requires-Dist: flake8>=7.4.1; extra == "lint"
30
+ Requires-Dist: flake8>=7.5.0; extra == "lint"
31
31
  Provides-Extra: dev
32
32
  Requires-Dist: links-notation[lint,test]; extra == "dev"
33
33
  Dynamic: license-file
@@ -5,8 +5,8 @@ links-notation[lint,test]
5
5
  [lint]
6
6
  black>=26.10.1
7
7
  isort>=9.0.2
8
- flake8>=7.4.1
8
+ flake8>=7.5.0
9
9
 
10
10
  [test]
11
- pytest>=9.1.1
11
+ pytest>=9.2.0
12
12
  pytest-timeout>=2.4.0
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools>=84.0.0"]
2
+ requires = ["setuptools>=84.2.1"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "links-notation"
7
- version = "0.28.0"
7
+ version = "0.30.0"
8
8
  description = "Python implementation of the Links Notation parser"
9
9
  readme = "README.md"
10
10
  license = "Unlicense"
@@ -29,8 +29,8 @@ requires-python = ">=3.11"
29
29
  dependencies = []
30
30
 
31
31
  [project.optional-dependencies]
32
- test = ["pytest>=9.1.1", "pytest-timeout>=2.4.0"]
33
- lint = ["black>=26.10.1", "isort>=9.0.2", "flake8>=7.4.1"]
32
+ test = ["pytest>=9.2.0", "pytest-timeout>=2.4.0"]
33
+ lint = ["black>=26.10.1", "isort>=9.0.2", "flake8>=7.5.0"]
34
34
  dev = ["links-notation[test,lint]"]
35
35
 
36
36
  [project.urls]
@@ -122,3 +122,32 @@ class TestFormattingAReferenceThatBeginsWithAHash:
122
122
  assert Link.escape_reference("issue#1047") == "issue#1047"
123
123
  assert Link.escape_reference("#") == "'#'"
124
124
  assert Link.escape_reference("#ff0000") == "'#ff0000'"
125
+
126
+
127
+ class TestReferencesTheCommentReaderSkips:
128
+ """
129
+ Issue #377: the comment reader and the parser agree on where every
130
+ delimited reference starts and how far it reaches.
131
+ """
132
+
133
+ def test_a_hash_inside_a_reference_that_follows_a_group_is_content(self):
134
+ assert rendered('(a)" #b"\n') == "((<a>) < #b>)"
135
+
136
+ def test_a_hash_inside_a_reference_that_follows_another_reference_is_content(self):
137
+ assert rendered("\"x\"' #y'\n") == "(<x> < #y>)"
138
+ assert rendered('```q```"" #c\n"""\n') == '(<q> < #c\n">)'
139
+
140
+ def test_a_hash_inside_a_reference_that_follows_a_literal_is_content(self):
141
+ assert rendered('~1{62}" #b"\n') == "(<b> < #b>)"
142
+
143
+ def test_a_reference_is_read_in_the_document_as_written(self):
144
+ # The body of the first ``""`` straddles a parenthesis, so it is the
145
+ # empty reference and the ``#`` after it opens a comment. Blanking that
146
+ # comment must not turn the ``""`` into a reference holding the blanks.
147
+ assert rendered('"" a #)b\n""\n') == "(<> <a>)\n(<>)"
148
+
149
+ def test_the_comment_reader_skips_the_references_the_parser_reads(self):
150
+ assert strip_comments('(a)" #b"\n') == '(a)" #b"\n'
151
+ assert strip_comments("\"x\"' #y'\n") == "\"x\"' #y'\n"
152
+ assert strip_comments('~1{62}" #b"\n') == '~1{62}" #b"\n'
153
+ assert strip_comments('a"b #c"\n') == 'a"b \n'
@@ -81,3 +81,13 @@ def test_rejects_writes_after_finish():
81
81
  stream.finish("one")
82
82
  with pytest.raises(RuntimeError, match="after finish"):
83
83
  stream.write("two")
84
+
85
+
86
+ def test_waits_for_the_line_that_closes_an_even_run_of_quotes():
87
+ # ``""`` is the empty reference only when nothing closes it, and here the
88
+ # next line does, so the first line cannot be emitted on its own (#377).
89
+ document = 'a "" b\n"" c\n'
90
+ stream = StreamParser()
91
+ for character in document:
92
+ assert stream.write(character) == []
93
+ assert format_links(stream.finish()) == format_links(Parser().parse(document))
File without changes