links-notation 0.27.1__tar.gz → 0.28.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.27.1/links_notation.egg-info → links_notation-0.28.0}/PKG-INFO +53 -3
  2. {links_notation-0.27.1 → links_notation-0.28.0}/README.md +52 -2
  3. {links_notation-0.27.1 → links_notation-0.28.0}/README.ru.md +53 -2
  4. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation/__init__.py +6 -0
  5. links_notation-0.28.0/links_notation/cst.py +251 -0
  6. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation/link.py +31 -0
  7. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation/parser.py +90 -34
  8. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation/quotes.py +25 -0
  9. {links_notation-0.27.1 → links_notation-0.28.0/links_notation.egg-info}/PKG-INFO +53 -3
  10. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation.egg-info/SOURCES.txt +4 -0
  11. {links_notation-0.27.1 → links_notation-0.28.0}/pyproject.toml +1 -1
  12. links_notation-0.28.0/tests/test_cst.py +124 -0
  13. links_notation-0.28.0/tests/test_shared_syntax_parity.py +40 -0
  14. links_notation-0.28.0/tests/test_syntax_metadata.py +116 -0
  15. {links_notation-0.27.1 → links_notation-0.28.0}/LICENSE +0 -0
  16. {links_notation-0.27.1 → links_notation-0.28.0}/MANIFEST.in +0 -0
  17. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation/binary.py +0 -0
  18. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation/comments.py +0 -0
  19. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation/format_config.py +0 -0
  20. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation/formatter.py +0 -0
  21. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation/reference_literal.py +0 -0
  22. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation/stream_parser.py +0 -0
  23. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation.egg-info/dependency_links.txt +0 -0
  24. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation.egg-info/requires.txt +0 -0
  25. {links_notation-0.27.1 → links_notation-0.28.0}/links_notation.egg-info/top_level.txt +0 -0
  26. {links_notation-0.27.1 → links_notation-0.28.0}/setup.cfg +0 -0
  27. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_api.py +0 -0
  28. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_binary.py +0 -0
  29. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_comments.py +0 -0
  30. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_edge_case_parser.py +0 -0
  31. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_empty_reference.py +0 -0
  32. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_format_config.py +0 -0
  33. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_indentation_consistency.py +0 -0
  34. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_indented_id_nested_values.py +0 -0
  35. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_indented_id_syntax.py +0 -0
  36. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_link.py +0 -0
  37. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_links_group.py +0 -0
  38. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_mixed_indentation_modes.py +0 -0
  39. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_multi_quote_parser.py +0 -0
  40. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_multiline_parser.py +0 -0
  41. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_multiline_quoted_string.py +0 -0
  42. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_nested_indentation.py +0 -0
  43. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_nested_parser.py +0 -0
  44. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_nested_self_reference.py +0 -0
  45. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_nesting_limit.py +0 -0
  46. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_reference_fidelity.py +0 -0
  47. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_shared_text_parity.py +0 -0
  48. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_single_line_parser.py +0 -0
  49. {links_notation-0.27.1 → links_notation-0.28.0}/tests/test_stream_parser.py +0 -0
  50. {links_notation-0.27.1 → links_notation-0.28.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.27.1
3
+ Version: 0.28.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
@@ -111,6 +111,50 @@ links = parser.parse(text)
111
111
  # Produces: (3: papa loves mama)
112
112
  ```
113
113
 
114
+ ### How a Document Was Written (CST)
115
+
116
+ Parsing keeps how every ID was written. Quoting never changes a reference:
117
+ `1` and `'1'` are the same reference, so equality, `str()`, `repr()` and
118
+ `format()` stay as they were. Each parsed link also carries `raw_id`, the ID
119
+ exactly as written, and `quote`, the delimiter it was written between:
120
+
121
+ ```python
122
+ from links_notation import Parser
123
+
124
+ [link] = Parser().parse("(t 1 '1')")
125
+ _, bare, quoted = link.values
126
+ bare == quoted # True: the same reference
127
+ (bare.raw_id, bare.quote) # ('1', None)
128
+ (quoted.raw_id, quoted.quote) # ("'1'", "'")
129
+ link.format() # '(t 1 1)'
130
+ ```
131
+
132
+ `raw_id` is `None` for links without an ID, links built in code, and links
133
+ made by indentation path combination. Use `Parser(syntax=False)` (AST mode) to
134
+ leave it out. `reference_quote(raw)` reads the delimiter of one written
135
+ reference.
136
+
137
+ `parse_cst(input)` returns the lossless concrete syntax tree. Every character,
138
+ including comments, blank lines, indentation, quotes and line endings, belongs
139
+ to exactly one token, so the original text is always recovered:
140
+
141
+ ```python
142
+ cst = Parser().parse_cst("(t 1 '1') # note\n")
143
+ str(cst) # "(t 1 '1') # note\n"
144
+ [(token.kind, token.text) for token in cst.tokens()]
145
+ # [('open_paren', '('), ('reference', 't'), ('whitespace', ' '), ...]
146
+ cst.to_s_expression()
147
+ # (document (line (group open_paren:"(" (line reference:"t" ...)
148
+ # close_paren:")") whitespace:" " comment:"# note" newline:"\n"))
149
+ ```
150
+
151
+ A `document` node holds `line` nodes. A line holds its tokens up to and
152
+ including its line ending. A `group` node holds `(`, the lines inside it, and
153
+ `)`. Reference tokens carry `value` (what the reference means) and `quote`.
154
+ `parse_cst` parses the document first, so it refuses what `parse` refuses,
155
+ and it also refuses unbalanced parentheses. See the
156
+ [issue 364 case study](../docs/case-studies/issue-364/README.md).
157
+
114
158
  ### Streaming Parsing
115
159
 
116
160
  `StreamParser` accepts arbitrary chunks and emits only complete top-level
@@ -136,12 +180,15 @@ stream also exposes `position`, `drain()`, `reset()`, and a buffer-size limit.
136
180
 
137
181
  The main parser class for Links Notation.
138
182
 
139
- - `__init__(max_input_size: int = 10 * 1024 * 1024, max_depth: int = DEFAULT_MAX_DEPTH, comments: bool = True)`:
183
+ - `__init__(max_input_size: int = 10 * 1024 * 1024, max_depth: int = DEFAULT_MAX_DEPTH, comments: bool = True, syntax: bool = True)`:
140
184
  Create a parser; with `comments=False` a `#` is an ordinary character instead
141
- of the start of a comment
185
+ of the start of a comment; with `syntax=False` (AST mode) parsed links leave
186
+ `raw_id` and `quote` as `None`
142
187
  - `parse(input_text: str) -> List[Link]`: Parse Links Notation text into Link
143
188
  objects; raises `ParseError` when the text does not parse or nests links
144
189
  deeper than `max_depth`
190
+ - `parse_cst(input_text: str) -> CstNode`: Read the lossless concrete syntax
191
+ tree; `str()` of it is the input
145
192
  - `DEFAULT_MAX_DEPTH`: The default `max_depth`, 64, the same in every
146
193
  implementation
147
194
 
@@ -181,6 +228,9 @@ Represents a link in Links Notation.
181
228
  - `format(less_parentheses: bool = False) -> str`: Format as string
182
229
  - `simplify() -> Link`: Simplify link structure
183
230
  - `combine(other: Link) -> Link`: Combine with another link
231
+ - `raw_id`: The ID exactly as the parsed document wrote it, or `None`; ignored
232
+ by `==`, `str()`, `repr()` and `format()`
233
+ - `quote`: `'`, `"` or `` ` `` when `raw_id` is quoted, otherwise `None`
184
234
 
185
235
  ### format_links
186
236
 
@@ -77,6 +77,50 @@ links = parser.parse(text)
77
77
  # Produces: (3: papa loves mama)
78
78
  ```
79
79
 
80
+ ### How a Document Was Written (CST)
81
+
82
+ Parsing keeps how every ID was written. Quoting never changes a reference:
83
+ `1` and `'1'` are the same reference, so equality, `str()`, `repr()` and
84
+ `format()` stay as they were. Each parsed link also carries `raw_id`, the ID
85
+ exactly as written, and `quote`, the delimiter it was written between:
86
+
87
+ ```python
88
+ from links_notation import Parser
89
+
90
+ [link] = Parser().parse("(t 1 '1')")
91
+ _, bare, quoted = link.values
92
+ bare == quoted # True: the same reference
93
+ (bare.raw_id, bare.quote) # ('1', None)
94
+ (quoted.raw_id, quoted.quote) # ("'1'", "'")
95
+ link.format() # '(t 1 1)'
96
+ ```
97
+
98
+ `raw_id` is `None` for links without an ID, links built in code, and links
99
+ made by indentation path combination. Use `Parser(syntax=False)` (AST mode) to
100
+ leave it out. `reference_quote(raw)` reads the delimiter of one written
101
+ reference.
102
+
103
+ `parse_cst(input)` returns the lossless concrete syntax tree. Every character,
104
+ including comments, blank lines, indentation, quotes and line endings, belongs
105
+ to exactly one token, so the original text is always recovered:
106
+
107
+ ```python
108
+ cst = Parser().parse_cst("(t 1 '1') # note\n")
109
+ str(cst) # "(t 1 '1') # note\n"
110
+ [(token.kind, token.text) for token in cst.tokens()]
111
+ # [('open_paren', '('), ('reference', 't'), ('whitespace', ' '), ...]
112
+ cst.to_s_expression()
113
+ # (document (line (group open_paren:"(" (line reference:"t" ...)
114
+ # close_paren:")") whitespace:" " comment:"# note" newline:"\n"))
115
+ ```
116
+
117
+ A `document` node holds `line` nodes. A line holds its tokens up to and
118
+ including its line ending. A `group` node holds `(`, the lines inside it, and
119
+ `)`. Reference tokens carry `value` (what the reference means) and `quote`.
120
+ `parse_cst` parses the document first, so it refuses what `parse` refuses,
121
+ and it also refuses unbalanced parentheses. See the
122
+ [issue 364 case study](../docs/case-studies/issue-364/README.md).
123
+
80
124
  ### Streaming Parsing
81
125
 
82
126
  `StreamParser` accepts arbitrary chunks and emits only complete top-level
@@ -102,12 +146,15 @@ stream also exposes `position`, `drain()`, `reset()`, and a buffer-size limit.
102
146
 
103
147
  The main parser class for Links Notation.
104
148
 
105
- - `__init__(max_input_size: int = 10 * 1024 * 1024, max_depth: int = DEFAULT_MAX_DEPTH, comments: bool = True)`:
149
+ - `__init__(max_input_size: int = 10 * 1024 * 1024, max_depth: int = DEFAULT_MAX_DEPTH, comments: bool = True, syntax: bool = True)`:
106
150
  Create a parser; with `comments=False` a `#` is an ordinary character instead
107
- of the start of a comment
151
+ of the start of a comment; with `syntax=False` (AST mode) parsed links leave
152
+ `raw_id` and `quote` as `None`
108
153
  - `parse(input_text: str) -> List[Link]`: Parse Links Notation text into Link
109
154
  objects; raises `ParseError` when the text does not parse or nests links
110
155
  deeper than `max_depth`
156
+ - `parse_cst(input_text: str) -> CstNode`: Read the lossless concrete syntax
157
+ tree; `str()` of it is the input
111
158
  - `DEFAULT_MAX_DEPTH`: The default `max_depth`, 64, the same in every
112
159
  implementation
113
160
 
@@ -147,6 +194,9 @@ Represents a link in Links Notation.
147
194
  - `format(less_parentheses: bool = False) -> str`: Format as string
148
195
  - `simplify() -> Link`: Simplify link structure
149
196
  - `combine(other: Link) -> Link`: Combine with another link
197
+ - `raw_id`: The ID exactly as the parsed document wrote it, or `None`; ignored
198
+ by `==`, `str()`, `repr()` and `format()`
199
+ - `quote`: `'`, `"` or `` ` `` when `raw_id` is quoted, otherwise `None`
150
200
 
151
201
  ### format_links
152
202
 
@@ -77,6 +77,51 @@ links = parser.parse(text)
77
77
  # Результат: (3: папа любит маму)
78
78
  ```
79
79
 
80
+ ### Как документ был записан (CST)
81
+
82
+ Парсер сохраняет, как была записана каждая ссылка. Кавычки не меняют ссылку:
83
+ `1` и `'1'` - одна и та же ссылка, поэтому сравнение, `str()`, `repr()` и
84
+ `format()` остаются прежними. Каждая разобранная связь хранит `raw_id` - ID в
85
+ точности так, как он записан, и `quote` - кавычку, в которую он заключён:
86
+
87
+ ```python
88
+ from links_notation import Parser
89
+
90
+ [link] = Parser().parse("(t 1 '1')")
91
+ _, bare, quoted = link.values
92
+ bare == quoted # True: это одна ссылка
93
+ (bare.raw_id, bare.quote) # ('1', None)
94
+ (quoted.raw_id, quoted.quote) # ("'1'", "'")
95
+ link.format() # '(t 1 1)'
96
+ ```
97
+
98
+ `raw_id` равен `None` у связей без ID, у связей, созданных в коде, и у связей,
99
+ построенных объединением путей по отступам. `Parser(syntax=False)` (режим AST)
100
+ не сохраняет его. `reference_quote(raw)` возвращает кавычку одной записанной
101
+ ссылки.
102
+
103
+ `parse_cst(input)` возвращает конкретное синтаксическое дерево без потерь:
104
+ каждый символ, включая комментарии, пустые строки, отступы, кавычки и
105
+ переводы строк, принадлежит ровно одному токену, поэтому исходный текст
106
+ всегда восстанавливается:
107
+
108
+ ```python
109
+ cst = Parser().parse_cst("(t 1 '1') # note\n")
110
+ str(cst) # "(t 1 '1') # note\n"
111
+ [(token.kind, token.text) for token in cst.tokens()]
112
+ # [('open_paren', '('), ('reference', 't'), ('whitespace', ' '), ...]
113
+ cst.to_s_expression()
114
+ # (document (line (group open_paren:"(" (line reference:"t" ...)
115
+ # close_paren:")") whitespace:" " comment:"# note" newline:"\n"))
116
+ ```
117
+
118
+ Узел `document` содержит узлы `line`; строка содержит свои токены вместе с
119
+ переводом строки; узел `group` содержит `(`, строки внутри и `)`. Токены
120
+ ссылок хранят `value` (что означает ссылка) и `quote`. `parse_cst` сначала
121
+ разбирает документ, поэтому отклоняет то, что отклоняет `parse`, а также
122
+ несбалансированные скобки. Подробности - в
123
+ [разборе issue 364](../docs/case-studies/issue-364/README.md).
124
+
80
125
  ### Потоковый разбор
81
126
 
82
127
  `StreamParser` принимает произвольные порции данных и выдаёт только завершённые
@@ -103,12 +148,15 @@ for link in parse_chunks(["одна свя", "зь\nвторая связь"]):
103
148
 
104
149
  Основной класс парсера для Links Notation.
105
150
 
106
- - `__init__(max_input_size: int = 10 * 1024 * 1024, max_depth: int = DEFAULT_MAX_DEPTH, comments: bool = True)`:
151
+ - `__init__(max_input_size: int = 10 * 1024 * 1024, max_depth: int = DEFAULT_MAX_DEPTH, comments: bool = True, syntax: bool = True)`:
107
152
  создать парсер; при `comments=False` `#` — обычный символ, а не начало
108
- комментария
153
+ комментария; при `syntax=False` (режим AST) у разобранных связей `raw_id` и
154
+ `quote` равны `None`
109
155
  - `parse(input_text: str) -> List[Link]`: Парсинг текста Links Notation в
110
156
  объекты Link; выбрасывает `ParseError`, если текст не удалось разобрать или
111
157
  связи в нем вложены глубже `max_depth`
158
+ - `parse_cst(input_text: str) -> CstNode`: конкретное синтаксическое дерево без
159
+ потерь; `str()` от него равен входному тексту
112
160
  - `DEFAULT_MAX_DEPTH`: значение `max_depth` по умолчанию, 64, одинаковое во всех
113
161
  реализациях
114
162
 
@@ -149,6 +197,9 @@ Nesting too deep at line 1, column 4: nesting depth exceeds the maximum of 3
149
197
  - `format(less_parentheses: bool = False) -> str`: Форматирование в строку
150
198
  - `simplify() -> Link`: Упрощение структуры связи
151
199
  - `combine(other: Link) -> Link`: Объединение с другой связью
200
+ - `raw_id`: ID в точности так, как он записан в разобранном документе, или
201
+ `None`; не учитывается в `==`, `str()`, `repr()` и `format()`
202
+ - `quote`: `'`, `"` или `` ` ``, если `raw_id` записан в кавычках, иначе `None`
152
203
 
153
204
  ### format_links
154
205
 
@@ -22,10 +22,12 @@ from .binary import (
22
22
  from .binary import format_document as format_binary_document
23
23
  from .binary import format_reference as format_binary_reference
24
24
  from .comments import strip_comments
25
+ from .cst import CstKind, CstNode, build_cst
25
26
  from .format_config import FormatConfig
26
27
  from .formatter import format_links
27
28
  from .link import Link
28
29
  from .parser import DEFAULT_MAX_DEPTH, ParseError, Parser
30
+ from .quotes import reference_quote
29
31
  from .reference_literal import decode_reference_literal, encode_reference_literal
30
32
  from .stream_parser import StreamParseError, StreamParser, StreamPosition, parse_async_chunks, parse_chunks
31
33
 
@@ -78,5 +80,9 @@ __all__ = [
78
80
  "parse_chunks",
79
81
  "FormatConfig",
80
82
  "strip_comments",
83
+ "CstKind",
84
+ "CstNode",
85
+ "build_cst",
86
+ "reference_quote",
81
87
  "__version__",
82
88
  ]
@@ -0,0 +1,251 @@
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 the
5
+ tree is the document itself, comments, blank lines, indentation, quotes and
6
+ line endings included. A document is a list of ``line`` nodes; a line holds the
7
+ tokens up to and including its line ending, and a parenthesized ``group`` node
8
+ holds its own parentheses and the lines written between them. A group is part
9
+ of the line it opens on, however many lines it spans.
10
+
11
+ The tree records how a document was written; :meth:`Parser.parse` reads what it
12
+ means. They agree because a document is parsed before its tree is built (see
13
+ :meth:`Parser.parse_cst`), and the tokens are read with the same rules the
14
+ parser uses: comments are found by :func:`strip_comments`, delimited references
15
+ by the same reader, and versioned literals by the same pattern.
16
+ """
17
+
18
+ import re
19
+ from typing import List, Optional, Tuple
20
+
21
+ from .comments import strip_comments
22
+ from .parser import ParseError
23
+ from .quotes import QUOTE_CHARS, _parse_quoted_string_at
24
+ from .reference_literal import decode_reference_literal
25
+
26
+
27
+ class CstKind:
28
+ """The kinds of nodes and tokens of a concrete syntax tree."""
29
+
30
+ #: The whole document: its lines.
31
+ DOCUMENT = "document"
32
+ #: One line: its tokens and groups, up to and including its line ending.
33
+ LINE = "line"
34
+ #: A parenthesized group: ``(``, the lines inside it, and ``)``.
35
+ GROUP = "group"
36
+ #: A run of spaces and tabs.
37
+ WHITESPACE = "whitespace"
38
+ #: One line ending: LF, CRLF or CR.
39
+ NEWLINE = "newline"
40
+ #: A comment, from ``#`` up to the line ending.
41
+ COMMENT = "comment"
42
+ #: ``(``
43
+ OPEN_PAREN = "open_paren"
44
+ #: ``)``
45
+ CLOSE_PAREN = "close_paren"
46
+ #: ``:``
47
+ COLON = "colon"
48
+ #: A bare, quoted or versioned literal reference.
49
+ REFERENCE = "reference"
50
+
51
+
52
+ _NODE_KINDS = frozenset((CstKind.DOCUMENT, CstKind.LINE, CstKind.GROUP))
53
+
54
+ #: How a token's text is escaped in an S-expression.
55
+ _ESCAPES = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t"}
56
+ _ESCAPED = re.compile(r'[\\"\n\r\t]')
57
+
58
+ #: A versioned literal.
59
+ _LITERAL = re.compile(r"~[0-9]+\{[^} \t\r\n()]*\}")
60
+
61
+ #: What ends a bare reference.
62
+ _BARE_REFERENCE_END = frozenset(" \t\r\n(:)")
63
+
64
+
65
+ class CstNode:
66
+ """One node or token of a concrete syntax tree."""
67
+
68
+ def __init__(
69
+ self,
70
+ kind: str,
71
+ text: str = "",
72
+ children: Optional[List["CstNode"]] = None,
73
+ value: Optional[str] = None,
74
+ quote: Optional[str] = None,
75
+ ):
76
+ """
77
+ Initialize a node or token.
78
+
79
+ Args:
80
+ kind: One of the :class:`CstKind` values
81
+ text: A token's text exactly as written
82
+ children: A node's children, in document order
83
+ value: What a reference token means
84
+ quote: The delimiter of a quoted reference token
85
+ """
86
+ #: One of the :class:`CstKind` values.
87
+ self.kind = kind
88
+ #: The children of a node, in document order; empty for a token.
89
+ self.children: List["CstNode"] = children if children is not None else []
90
+ #: What a reference token means, its delimiters and escapes resolved
91
+ #: (the ID a parsed link has); None for every other node and token.
92
+ self.value = value
93
+ #: The delimiter a quoted reference token was written between (``'``,
94
+ #: ``"`` or a backtick); None for a bare or literal reference and every
95
+ #: other kind.
96
+ self.quote = quote
97
+ self._text = text
98
+
99
+ @property
100
+ def is_token(self) -> bool:
101
+ """Whether this is a token rather than a node with children."""
102
+ return self.kind not in _NODE_KINDS
103
+
104
+ @property
105
+ def text(self) -> str:
106
+ """The exact text this node or token was read from."""
107
+ if self.is_token:
108
+ return self._text
109
+ return "".join(child.text for child in self.children)
110
+
111
+ def __str__(self) -> str:
112
+ """The exact text this node or token was read from."""
113
+ return self.text
114
+
115
+ def __repr__(self) -> str:
116
+ """Developer-friendly representation."""
117
+ return f"CstNode({self.to_s_expression()})"
118
+
119
+ def tokens(self) -> List["CstNode"]:
120
+ """Every token of this subtree, in document order."""
121
+ if self.is_token:
122
+ return [self]
123
+ return [token for child in self.children for token in child.tokens()]
124
+
125
+ def to_s_expression(self) -> str:
126
+ """
127
+ The tree as an S-expression.
128
+
129
+ A node is written ``(kind child ...)`` and a token ``kind:"text"``, the
130
+ text escaped as a JSON string would escape ``\\``, ``"``, line feeds,
131
+ carriage returns and tabs. Two trees are the same exactly when their
132
+ S-expressions are.
133
+
134
+ >>> from links_notation import Parser
135
+ >>> Parser().parse_cst("a '1'").to_s_expression()
136
+ '(document (line reference:"a" whitespace:" " reference:"\\'1\\'"))'
137
+ """
138
+ if self.is_token:
139
+ return f"{self.kind}:{_quote_text(self._text)}"
140
+ return "(" + " ".join([self.kind] + [child.to_s_expression() for child in self.children]) + ")"
141
+
142
+
143
+ def _quote_text(text: str) -> str:
144
+ """Write ``text`` between double quotes, escaped."""
145
+ return '"' + _ESCAPED.sub(lambda match: _ESCAPES[match.group()], text) + '"'
146
+
147
+
148
+ class _Container:
149
+ """An open document or group, with the line being filled in it."""
150
+
151
+ def __init__(self, node: CstNode):
152
+ self.node = node
153
+ self.line: Optional[CstNode] = None
154
+
155
+
156
+ def build_cst(input_text: str, comments: bool = True) -> CstNode:
157
+ """
158
+ The concrete syntax tree of a document.
159
+
160
+ The document is not validated here; :meth:`Parser.parse_cst` parses it
161
+ first, so use that unless the document is already known to parse.
162
+
163
+ Args:
164
+ input_text: The document as written
165
+ comments: Whether ``#`` starts a comment (default: True)
166
+
167
+ Returns:
168
+ The ``document`` node
169
+
170
+ Raises:
171
+ ParseError: If the parentheses are unbalanced
172
+ """
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.
175
+ prepared = strip_comments(input_text) if comments else input_text
176
+ document = CstNode(CstKind.DOCUMENT)
177
+ # The open containers, innermost last.
178
+ containers = [_Container(document)]
179
+
180
+ def add(node: CstNode) -> None:
181
+ container = containers[-1]
182
+ if container.line is None:
183
+ container.line = CstNode(CstKind.LINE)
184
+ container.node.children.append(container.line)
185
+ container.line.children.append(node)
186
+
187
+ def token(kind: str, start: int, end: int) -> CstNode:
188
+ return CstNode(kind, text=input_text[start:end])
189
+
190
+ length = len(input_text)
191
+ position = 0
192
+ while position < length:
193
+ character = input_text[position]
194
+ end = position + 1
195
+
196
+ if comments and character == "#" and prepared[position] != "#":
197
+ # A blanked "#" opens a comment, which runs to the line ending.
198
+ while end < length and input_text[end] not in ("\n", "\r"):
199
+ end += 1
200
+ add(token(CstKind.COMMENT, position, end))
201
+ elif character in ("\r", "\n"):
202
+ if character == "\r" and end < length and input_text[end] == "\n":
203
+ end += 1
204
+ add(token(CstKind.NEWLINE, position, end))
205
+ containers[-1].line = None
206
+ elif character in (" ", "\t"):
207
+ while end < length and input_text[end] in (" ", "\t"):
208
+ end += 1
209
+ add(token(CstKind.WHITESPACE, position, end))
210
+ elif character == "(":
211
+ group = CstNode(CstKind.GROUP, children=[token(CstKind.OPEN_PAREN, position, end)])
212
+ add(group)
213
+ containers.append(_Container(group))
214
+ elif character == ")":
215
+ if len(containers) == 1:
216
+ raise ParseError(f'Unbalanced ")" at offset {position}', offset=position)
217
+ containers.pop().node.children.append(token(CstKind.CLOSE_PAREN, position, end))
218
+ elif character == ":":
219
+ add(token(CstKind.COLON, position, end))
220
+ else:
221
+ reference, end = _reference(input_text, prepared, position)
222
+ add(reference)
223
+ position = end
224
+
225
+ if len(containers) != 1:
226
+ raise ParseError('Unbalanced "(" at the end of the document')
227
+ return document
228
+
229
+
230
+ def _reference(input_text: str, prepared: str, start: int) -> Tuple[CstNode, int]:
231
+ """
232
+ The reference token that starts at ``start``, read as the parser reads it,
233
+ and where it ends.
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)
243
+ if quoted is not None:
244
+ value, end = quoted
245
+ reference = CstNode(CstKind.REFERENCE, text=input_text[start:end], value=value, quote=input_text[start])
246
+ return reference, end
247
+ end = start + 1
248
+ while end < len(prepared) and prepared[end] not in _BARE_REFERENCE_END:
249
+ end += 1
250
+ text = input_text[start:end]
251
+ return CstNode(CstKind.REFERENCE, text=text, value=text), end
@@ -4,6 +4,7 @@ Link class representing a Lino link with optional ID and values.
4
4
 
5
5
  from typing import TYPE_CHECKING, List, Optional, Union
6
6
 
7
+ from .quotes import reference_quote
7
8
  from .reference_literal import format_reference
8
9
 
9
10
  if TYPE_CHECKING:
@@ -31,6 +32,36 @@ class Link:
31
32
  self.id = link_id
32
33
  self.values = values if values is not None else []
33
34
  self._is_from_path_combination = False
35
+ self._raw_id: Optional[str] = None
36
+
37
+ @property
38
+ def raw_id(self) -> Optional[str]:
39
+ """
40
+ The ID exactly as the parsed document wrote it: ``'1'`` for a quoted
41
+ ``1``, ``1`` for a bare one, ``~1{31}`` for a versioned literal.
42
+
43
+ None for a link without an ID, a link built in code, a link made by
44
+ combining the lines of an indented document, and every link read by a
45
+ parser created with ``syntax=False``. It takes no part in equality,
46
+ ``str()``, ``repr()`` or ``format()``.
47
+ """
48
+ return self._raw_id
49
+
50
+ @raw_id.setter
51
+ def raw_id(self, raw: Optional[str]) -> None:
52
+ if raw is not None and not isinstance(raw, str):
53
+ raise TypeError("raw_id must be a string or None")
54
+ self._raw_id = raw
55
+
56
+ @property
57
+ def quote(self) -> Optional[str]:
58
+ """
59
+ The delimiter the ID was written between: ``'``, ``"`` or a backtick
60
+ when it was quoted, and None when it was bare, a versioned literal, or
61
+ not read from a document (see :attr:`raw_id`). ``1`` and ``'1'`` stay
62
+ equal links; this only says how each was written.
63
+ """
64
+ return reference_quote(self._raw_id)
34
65
 
35
66
  def __str__(self) -> str:
36
67
  """String representation using standard formatting."""