links-notation 0.27.1__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.
- {links_notation-0.27.1/links_notation.egg-info → links_notation-0.30.0}/PKG-INFO +55 -5
- {links_notation-0.27.1 → links_notation-0.30.0}/README.md +52 -2
- {links_notation-0.27.1 → links_notation-0.30.0}/README.ru.md +53 -2
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation/__init__.py +6 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation/comments.py +39 -8
- links_notation-0.30.0/links_notation/cst.py +249 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation/link.py +31 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation/parser.py +132 -64
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation/quotes.py +58 -8
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation/reference_literal.py +9 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation/stream_parser.py +31 -13
- {links_notation-0.27.1 → links_notation-0.30.0/links_notation.egg-info}/PKG-INFO +55 -5
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation.egg-info/SOURCES.txt +4 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation.egg-info/requires.txt +2 -2
- {links_notation-0.27.1 → links_notation-0.30.0}/pyproject.toml +4 -4
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_comments.py +29 -0
- links_notation-0.30.0/tests/test_cst.py +124 -0
- links_notation-0.30.0/tests/test_shared_syntax_parity.py +40 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_stream_parser.py +10 -0
- links_notation-0.30.0/tests/test_syntax_metadata.py +116 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/LICENSE +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/MANIFEST.in +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation/binary.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation/format_config.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation/formatter.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation.egg-info/dependency_links.txt +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/links_notation.egg-info/top_level.txt +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/setup.cfg +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_api.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_binary.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_edge_case_parser.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_empty_reference.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_format_config.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_indentation_consistency.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_indented_id_nested_values.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_indented_id_syntax.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_link.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_links_group.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_mixed_indentation_modes.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_multi_quote_parser.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_multiline_parser.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_multiline_quoted_string.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_nested_indentation.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_nested_parser.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_nested_self_reference.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_nesting_limit.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_reference_fidelity.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_shared_text_parity.py +0 -0
- {links_notation-0.27.1 → links_notation-0.30.0}/tests/test_single_line_parser.py +0 -0
- {links_notation-0.27.1 → 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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
]
|
|
@@ -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
|
|
21
|
-
|
|
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
|
|
@@ -0,0 +1,249 @@
|
|
|
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, reference_literal_end
|
|
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
|
+
#: What ends a bare reference.
|
|
59
|
+
_BARE_REFERENCE_END = frozenset(" \t\r\n(:)")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CstNode:
|
|
63
|
+
"""One node or token of a concrete syntax tree."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
kind: str,
|
|
68
|
+
text: str = "",
|
|
69
|
+
children: Optional[List["CstNode"]] = None,
|
|
70
|
+
value: Optional[str] = None,
|
|
71
|
+
quote: Optional[str] = None,
|
|
72
|
+
):
|
|
73
|
+
"""
|
|
74
|
+
Initialize a node or token.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
kind: One of the :class:`CstKind` values
|
|
78
|
+
text: A token's text exactly as written
|
|
79
|
+
children: A node's children, in document order
|
|
80
|
+
value: What a reference token means
|
|
81
|
+
quote: The delimiter of a quoted reference token
|
|
82
|
+
"""
|
|
83
|
+
#: One of the :class:`CstKind` values.
|
|
84
|
+
self.kind = kind
|
|
85
|
+
#: The children of a node, in document order; empty for a token.
|
|
86
|
+
self.children: List["CstNode"] = children if children is not None else []
|
|
87
|
+
#: What a reference token means, its delimiters and escapes resolved
|
|
88
|
+
#: (the ID a parsed link has); None for every other node and token.
|
|
89
|
+
self.value = value
|
|
90
|
+
#: The delimiter a quoted reference token was written between (``'``,
|
|
91
|
+
#: ``"`` or a backtick); None for a bare or literal reference and every
|
|
92
|
+
#: other kind.
|
|
93
|
+
self.quote = quote
|
|
94
|
+
self._text = text
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def is_token(self) -> bool:
|
|
98
|
+
"""Whether this is a token rather than a node with children."""
|
|
99
|
+
return self.kind not in _NODE_KINDS
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def text(self) -> str:
|
|
103
|
+
"""The exact text this node or token was read from."""
|
|
104
|
+
if self.is_token:
|
|
105
|
+
return self._text
|
|
106
|
+
return "".join(child.text for child in self.children)
|
|
107
|
+
|
|
108
|
+
def __str__(self) -> str:
|
|
109
|
+
"""The exact text this node or token was read from."""
|
|
110
|
+
return self.text
|
|
111
|
+
|
|
112
|
+
def __repr__(self) -> str:
|
|
113
|
+
"""Developer-friendly representation."""
|
|
114
|
+
return f"CstNode({self.to_s_expression()})"
|
|
115
|
+
|
|
116
|
+
def tokens(self) -> List["CstNode"]:
|
|
117
|
+
"""Every token of this subtree, in document order."""
|
|
118
|
+
if self.is_token:
|
|
119
|
+
return [self]
|
|
120
|
+
return [token for child in self.children for token in child.tokens()]
|
|
121
|
+
|
|
122
|
+
def to_s_expression(self) -> str:
|
|
123
|
+
"""
|
|
124
|
+
The tree as an S-expression.
|
|
125
|
+
|
|
126
|
+
A node is written ``(kind child ...)`` and a token ``kind:"text"``, the
|
|
127
|
+
text escaped as a JSON string would escape ``\\``, ``"``, line feeds,
|
|
128
|
+
carriage returns and tabs. Two trees are the same exactly when their
|
|
129
|
+
S-expressions are.
|
|
130
|
+
|
|
131
|
+
>>> from links_notation import Parser
|
|
132
|
+
>>> Parser().parse_cst("a '1'").to_s_expression()
|
|
133
|
+
'(document (line reference:"a" whitespace:" " reference:"\\'1\\'"))'
|
|
134
|
+
"""
|
|
135
|
+
if self.is_token:
|
|
136
|
+
return f"{self.kind}:{_quote_text(self._text)}"
|
|
137
|
+
return "(" + " ".join([self.kind] + [child.to_s_expression() for child in self.children]) + ")"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _quote_text(text: str) -> str:
|
|
141
|
+
"""Write ``text`` between double quotes, escaped."""
|
|
142
|
+
return '"' + _ESCAPED.sub(lambda match: _ESCAPES[match.group()], text) + '"'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _Container:
|
|
146
|
+
"""An open document or group, with the line being filled in it."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, node: CstNode):
|
|
149
|
+
self.node = node
|
|
150
|
+
self.line: Optional[CstNode] = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def build_cst(input_text: str, comments: bool = True) -> CstNode:
|
|
154
|
+
"""
|
|
155
|
+
The concrete syntax tree of a document.
|
|
156
|
+
|
|
157
|
+
The document is not validated here; :meth:`Parser.parse_cst` parses it
|
|
158
|
+
first, so use that unless the document is already known to parse.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
input_text: The document as written
|
|
162
|
+
comments: Whether ``#`` starts a comment (default: True)
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
The ``document`` node
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ParseError: If the parentheses are unbalanced
|
|
169
|
+
"""
|
|
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
|
+
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
|
+
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)
|
|
241
|
+
if quoted is not None:
|
|
242
|
+
value, end = quoted
|
|
243
|
+
reference = CstNode(CstKind.REFERENCE, text=input_text[start:end], value=value, quote=input_text[start])
|
|
244
|
+
return reference, end
|
|
245
|
+
end = start + 1
|
|
246
|
+
while end < len(prepared) and prepared[end] not in _BARE_REFERENCE_END:
|
|
247
|
+
end += 1
|
|
248
|
+
text = input_text[start:end]
|
|
249
|
+
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."""
|