yini-parser 0.1.0a1__py3-none-any.whl
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.
- yini_parser/__init__.py +14 -0
- yini_parser/api/__init__.py +13 -0
- yini_parser/api/errors.py +15 -0
- yini_parser/api/load.py +55 -0
- yini_parser/api/warnings.py +53 -0
- yini_parser/core/__init__.py +1 -0
- yini_parser/core/section_headers.py +134 -0
- yini_parser/core/validator.py +157 -0
- yini_parser/core/value_decoders.py +197 -0
- yini_parser/core/yini_builder_visitor.py +526 -0
- yini_parser/grammar/__init__.py +1 -0
- yini_parser/grammar/generated/YiniLexer.py +402 -0
- yini_parser/grammar/generated/YiniParser.py +2173 -0
- yini_parser/grammar/generated/YiniParserVisitor.py +158 -0
- yini_parser/utils/__init__.py +6 -0
- yini_parser/utils/antlr.py +8 -0
- yini_parser/utils/text.py +40 -0
- yini_parser-0.1.0a1.dist-info/METADATA +124 -0
- yini_parser-0.1.0a1.dist-info/RECORD +22 -0
- yini_parser-0.1.0a1.dist-info/WHEEL +5 -0
- yini_parser-0.1.0a1.dist-info/licenses/LICENSE +201 -0
- yini_parser-0.1.0a1.dist-info/top_level.txt +1 -0
yini_parser/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# src/yini_parser/__init__.py
|
|
2
|
+
"""Public API for the yini_parser package."""
|
|
3
|
+
|
|
4
|
+
from .api import YiniParseError, load, loads
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
So users can write:
|
|
8
|
+
from yini_parser import load, loads, YiniParseError
|
|
9
|
+
|
|
10
|
+
Instead:
|
|
11
|
+
from yini_parser.api import load, loads, YiniParseError
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__all__ = ["YiniParseError", "load", "loads"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# src/yini_parser/api/__init__.py
|
|
2
|
+
"""Public API helpers for parsing YINI documents."""
|
|
3
|
+
|
|
4
|
+
from .errors import YiniParseError
|
|
5
|
+
from .warnings import YiniParseWarning
|
|
6
|
+
from .load import load, loads
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"load",
|
|
10
|
+
"loads",
|
|
11
|
+
"YiniParseError",
|
|
12
|
+
"YiniParseWarning",
|
|
13
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# src/yini_parser/api/errors.py
|
|
2
|
+
|
|
3
|
+
class YiniParseError(Exception):
|
|
4
|
+
def __init__(self, message: str, line: int | None = None, column: int | None = None):
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
self.message = message
|
|
7
|
+
self.line = line
|
|
8
|
+
self.column = column
|
|
9
|
+
|
|
10
|
+
def __str__(self) -> str:
|
|
11
|
+
if self.line is not None and self.column is not None:
|
|
12
|
+
return f"{self.message} (line {self.line}, column {self.column})"
|
|
13
|
+
if self.line is not None:
|
|
14
|
+
return f"{self.message} (line {self.line})"
|
|
15
|
+
return self.message
|
yini_parser/api/load.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# src/yini_parser/api/load.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from antlr4 import CommonTokenStream, FileStream, InputStream
|
|
8
|
+
|
|
9
|
+
from yini_parser.api.errors import YiniParseError
|
|
10
|
+
|
|
11
|
+
from ..core.yini_builder_visitor import YiniBuilderVisitor
|
|
12
|
+
from ..grammar.generated.YiniLexer import YiniLexer
|
|
13
|
+
from ..grammar.generated.YiniParser import YiniParser
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def loads(text: str, strict: bool=False) -> dict[str, Any]:
|
|
17
|
+
"""
|
|
18
|
+
Parse YINI text and return the resulting Python dictionary.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
input_stream = InputStream(text)
|
|
22
|
+
return _parse_input_stream(input_stream, strict=strict)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load(path: str, strict: bool=False) -> dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
Parse a YINI file from disk and return the resulting Python dictionary.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
file_path = Path(path)
|
|
31
|
+
input_stream = FileStream(str(file_path), encoding="utf-8")
|
|
32
|
+
return _parse_input_stream(input_stream, strict=strict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_input_stream(
|
|
36
|
+
input_stream: InputStream | FileStream,
|
|
37
|
+
strict: bool
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
lexer = YiniLexer(input_stream)
|
|
40
|
+
stream = CommonTokenStream(lexer)
|
|
41
|
+
parser = YiniParser(stream)
|
|
42
|
+
|
|
43
|
+
tree = parser.yini()
|
|
44
|
+
|
|
45
|
+
if parser.getNumberOfSyntaxErrors() > 0:
|
|
46
|
+
# raise ValueError(f"Failed to parse YINI input: {parser.getNumberOfSyntaxErrors()} syntax error(s).")
|
|
47
|
+
raise YiniParseError(f"Failed to parse YINI input: {parser.getNumberOfSyntaxErrors()} syntax error(s).")
|
|
48
|
+
|
|
49
|
+
visitor = YiniBuilderVisitor(strict=strict)
|
|
50
|
+
result = visitor.visit(tree)
|
|
51
|
+
|
|
52
|
+
if not isinstance(result, dict):
|
|
53
|
+
raise TypeError(f"Expected parsed result to be a dict, got {type(result).__name__}.")
|
|
54
|
+
|
|
55
|
+
return result
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# src/yini_parser/api/warnings.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class YiniParseWarning(Warning):
|
|
7
|
+
"""
|
|
8
|
+
Warning raised for non-fatal YINI parse issues.
|
|
9
|
+
|
|
10
|
+
A YiniParseWarning represents a problem that was detected while parsing,
|
|
11
|
+
but which does not prevent a result from being produced.
|
|
12
|
+
|
|
13
|
+
Typical examples:
|
|
14
|
+
- Duplicate keys ignored in lenient mode.
|
|
15
|
+
- Duplicate sections ignored in lenient mode.
|
|
16
|
+
- Key/section name collisions handled by lenient-mode policy.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
line: int | None = None,
|
|
23
|
+
column: int | None = None,
|
|
24
|
+
code: str | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.message = message
|
|
28
|
+
self.line = line
|
|
29
|
+
self.column = column
|
|
30
|
+
self.code = code
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
location = self._format_location()
|
|
34
|
+
|
|
35
|
+
if self.code is not None and location is not None:
|
|
36
|
+
return f"{self.message} [{self.code}] {location}"
|
|
37
|
+
|
|
38
|
+
if self.code is not None:
|
|
39
|
+
return f"{self.message} [{self.code}]"
|
|
40
|
+
|
|
41
|
+
if location is not None:
|
|
42
|
+
return f"{self.message} {location}"
|
|
43
|
+
|
|
44
|
+
return self.message
|
|
45
|
+
|
|
46
|
+
def _format_location(self) -> str | None:
|
|
47
|
+
if self.line is not None and self.column is not None:
|
|
48
|
+
return f"(line {self.line}, column {self.column})"
|
|
49
|
+
|
|
50
|
+
if self.line is not None:
|
|
51
|
+
return f"(line {self.line})"
|
|
52
|
+
|
|
53
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This file marks this directory as a Python package so it can be imported (as a package/module).
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# src/yini_parser/core/section_headers.py
|
|
2
|
+
from ..api.errors import YiniParseError
|
|
3
|
+
from ..utils.text import strip_backticks
|
|
4
|
+
|
|
5
|
+
def parse_section_head(
|
|
6
|
+
raw_text: str,
|
|
7
|
+
*,
|
|
8
|
+
line: int | None = None,
|
|
9
|
+
column: int | None = None,
|
|
10
|
+
) -> tuple[int, str]:
|
|
11
|
+
"""
|
|
12
|
+
Parses a SECTION_HEAD token text like:
|
|
13
|
+
"^ App\\n"
|
|
14
|
+
"^^ Server\\n"
|
|
15
|
+
"^7 DeepSection\\n"
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
(level, name)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
text = raw_text.strip()
|
|
22
|
+
text = text.splitlines()[0].strip()
|
|
23
|
+
text = _strip_section_tail_comment(text)
|
|
24
|
+
|
|
25
|
+
if not text:
|
|
26
|
+
raise YiniParseError(
|
|
27
|
+
"Invalid section header: the header is empty.",
|
|
28
|
+
line=line,
|
|
29
|
+
column=column,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
marker = text[0]
|
|
33
|
+
|
|
34
|
+
if marker not in {"^", "<", "§"}:
|
|
35
|
+
raise YiniParseError(
|
|
36
|
+
f"Invalid section header: {marker!r} is not a valid section marker. "
|
|
37
|
+
"Use one of: '^', '<', or '§'.",
|
|
38
|
+
line=line,
|
|
39
|
+
column=column,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Numeric shorthand form, for example: ^7 SectionName.
|
|
43
|
+
if len(text) >= 2 and text[1].isdigit():
|
|
44
|
+
i = 1
|
|
45
|
+
j = i
|
|
46
|
+
|
|
47
|
+
while j < len(text) and text[j].isdigit():
|
|
48
|
+
j += 1
|
|
49
|
+
|
|
50
|
+
level_text = text[i:j]
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
level = int(level_text)
|
|
54
|
+
except ValueError:
|
|
55
|
+
raise YiniParseError(
|
|
56
|
+
f"Invalid section level: {level_text!r} is not a valid number.",
|
|
57
|
+
line=line,
|
|
58
|
+
column=column,
|
|
59
|
+
) from None
|
|
60
|
+
|
|
61
|
+
name = text[j:].strip()
|
|
62
|
+
|
|
63
|
+
else:
|
|
64
|
+
# Repeated/basic form, for example:
|
|
65
|
+
# ^^ Section
|
|
66
|
+
# ^_^ Section
|
|
67
|
+
# ^_^_^ Section
|
|
68
|
+
i = 0
|
|
69
|
+
level = 0
|
|
70
|
+
expecting_marker = True
|
|
71
|
+
|
|
72
|
+
while i < len(text):
|
|
73
|
+
ch = text[i]
|
|
74
|
+
|
|
75
|
+
if ch == marker:
|
|
76
|
+
level += 1
|
|
77
|
+
expecting_marker = False
|
|
78
|
+
i += 1
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
if ch == "_" and not expecting_marker:
|
|
82
|
+
if i + 1 < len(text) and text[i + 1] == marker:
|
|
83
|
+
expecting_marker = True
|
|
84
|
+
i += 1
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
name = text[i:].strip()
|
|
90
|
+
|
|
91
|
+
if not name:
|
|
92
|
+
raise YiniParseError(
|
|
93
|
+
f"Missing section name after section marker {marker!r}.",
|
|
94
|
+
line=line,
|
|
95
|
+
column=column,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return level, strip_backticks(name)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _strip_section_tail_comment(text: str) -> str:
|
|
102
|
+
in_single = False
|
|
103
|
+
in_double = False
|
|
104
|
+
in_backtick = False
|
|
105
|
+
|
|
106
|
+
i = 0
|
|
107
|
+
while i < len(text):
|
|
108
|
+
ch = text[i]
|
|
109
|
+
|
|
110
|
+
if ch == "`" and not in_single and not in_double:
|
|
111
|
+
in_backtick = not in_backtick
|
|
112
|
+
i += 1
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
if ch == "'" and not in_double and not in_backtick:
|
|
116
|
+
in_single = not in_single
|
|
117
|
+
i += 1
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
if ch == '"' and not in_single and not in_backtick:
|
|
121
|
+
in_double = not in_double
|
|
122
|
+
i += 1
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
if not in_single and not in_double and not in_backtick:
|
|
126
|
+
if ch == "#":
|
|
127
|
+
return text[:i].rstrip()
|
|
128
|
+
|
|
129
|
+
if ch == "/" and i + 1 < len(text) and text[i + 1] == "/":
|
|
130
|
+
return text[:i].rstrip()
|
|
131
|
+
|
|
132
|
+
i += 1
|
|
133
|
+
|
|
134
|
+
return text.rstrip()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# src/yini_parser/core/validator.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
from ..api.errors import YiniParseError
|
|
8
|
+
from ..api.warnings import YiniParseWarning
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class YiniValidator:
|
|
12
|
+
"""
|
|
13
|
+
Handles validation policy for strict and lenient parsing.
|
|
14
|
+
|
|
15
|
+
In strict mode, conflicts are errors.
|
|
16
|
+
In lenient mode, conflicts are warnings and the first definition wins.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, strict: bool = False) -> None:
|
|
20
|
+
self.strict = strict
|
|
21
|
+
|
|
22
|
+
def handle_duplicate_key(
|
|
23
|
+
self,
|
|
24
|
+
key: str,
|
|
25
|
+
*,
|
|
26
|
+
line: int | None = None,
|
|
27
|
+
column: int | None = None,
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
Handles duplicate keys.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
True -> caller may keep/replace the value
|
|
34
|
+
False -> caller should ignore the new value
|
|
35
|
+
"""
|
|
36
|
+
message = (
|
|
37
|
+
f"Duplicate key {key!r} ignored. "
|
|
38
|
+
"The first value is kept."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if self.strict:
|
|
42
|
+
raise YiniParseError(
|
|
43
|
+
f"Duplicate key {key!r} is not allowed in strict mode.",
|
|
44
|
+
line=line,
|
|
45
|
+
column=column,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
self._warn(
|
|
49
|
+
message,
|
|
50
|
+
line=line,
|
|
51
|
+
column=column,
|
|
52
|
+
code="duplicate-key",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def handle_duplicate_section(
|
|
58
|
+
self,
|
|
59
|
+
name: str,
|
|
60
|
+
*,
|
|
61
|
+
line: int | None = None,
|
|
62
|
+
column: int | None = None,
|
|
63
|
+
) -> bool:
|
|
64
|
+
"""
|
|
65
|
+
Handles duplicate sections.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
True -> caller may reuse/merge the existing section
|
|
69
|
+
False -> caller should ignore the new section block
|
|
70
|
+
"""
|
|
71
|
+
message = (
|
|
72
|
+
f"Duplicate section {name!r} ignored. "
|
|
73
|
+
"The first section is kept."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if self.strict:
|
|
77
|
+
raise YiniParseError(
|
|
78
|
+
f"Duplicate section {name!r} is not allowed in strict mode.",
|
|
79
|
+
line=line,
|
|
80
|
+
column=column,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
self._warn(
|
|
84
|
+
message,
|
|
85
|
+
line=line,
|
|
86
|
+
column=column,
|
|
87
|
+
code="duplicate-section",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def handle_key_section_collision(
|
|
93
|
+
self,
|
|
94
|
+
name: str,
|
|
95
|
+
existing_kind: str,
|
|
96
|
+
incoming_kind: str,
|
|
97
|
+
*,
|
|
98
|
+
line: int | None = None,
|
|
99
|
+
column: int | None = None,
|
|
100
|
+
) -> bool:
|
|
101
|
+
"""
|
|
102
|
+
Handles name collisions between keys and sections.
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
app = "demo"
|
|
106
|
+
^ app
|
|
107
|
+
|
|
108
|
+
or:
|
|
109
|
+
|
|
110
|
+
^ app
|
|
111
|
+
app = "demo"
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
True -> caller may accept the incoming definition
|
|
115
|
+
False -> caller should ignore the incoming definition
|
|
116
|
+
"""
|
|
117
|
+
message = (
|
|
118
|
+
f"Name collision for {name!r} ignored. "
|
|
119
|
+
f"A {existing_kind} with this name already exists, so the incoming "
|
|
120
|
+
f"{incoming_kind} was ignored."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if self.strict:
|
|
124
|
+
raise YiniParseError(
|
|
125
|
+
f"Name collision for {name!r}. "
|
|
126
|
+
f"A {existing_kind} with this name already exists, so it cannot "
|
|
127
|
+
f"also be used as a {incoming_kind} in strict mode.",
|
|
128
|
+
line=line,
|
|
129
|
+
column=column,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
self._warn(
|
|
133
|
+
message,
|
|
134
|
+
line=line,
|
|
135
|
+
column=column,
|
|
136
|
+
code="key-section-collision",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
def _warn(
|
|
142
|
+
self,
|
|
143
|
+
message: str,
|
|
144
|
+
*,
|
|
145
|
+
line: int | None = None,
|
|
146
|
+
column: int | None = None,
|
|
147
|
+
code: str | None = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
warnings.warn(
|
|
150
|
+
YiniParseWarning(
|
|
151
|
+
message,
|
|
152
|
+
line=line,
|
|
153
|
+
column=column,
|
|
154
|
+
code=code,
|
|
155
|
+
),
|
|
156
|
+
stacklevel=3,
|
|
157
|
+
)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# src/yini_parser/core/value_decoders.py
|
|
2
|
+
from ..api.errors import YiniParseError
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
- Parsers reads raw/source text and recognizes its structure as tokens.
|
|
6
|
+
- Decoders converts tokens into its runtime value.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def decode_string_token(
|
|
10
|
+
token_text: str,
|
|
11
|
+
*,
|
|
12
|
+
line: int | None = None,
|
|
13
|
+
column: int | None = None,
|
|
14
|
+
) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Minimal first-pass string decoding.
|
|
17
|
+
|
|
18
|
+
Handles:
|
|
19
|
+
- Optional prefixes: R/r and C/c.
|
|
20
|
+
- Single/double quoted strings.
|
|
21
|
+
- Triple-quoted strings.
|
|
22
|
+
- Simple quote stripping.
|
|
23
|
+
|
|
24
|
+
Unprefixed strings are treated as raw strings.
|
|
25
|
+
C-prefixed strings decode escape sequences.
|
|
26
|
+
"""
|
|
27
|
+
text = token_text
|
|
28
|
+
|
|
29
|
+
if not text:
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
prefix = ""
|
|
33
|
+
|
|
34
|
+
if len(text) >= 2 and text[0] in "RrCc" and text[1] in {'"', "'"}:
|
|
35
|
+
prefix = text[0]
|
|
36
|
+
text = text[1:]
|
|
37
|
+
elif len(text) >= 4 and text[0] in "RrCc" and text[1:4] == '"""':
|
|
38
|
+
prefix = text[0]
|
|
39
|
+
text = text[1:]
|
|
40
|
+
|
|
41
|
+
# Triple-quoted string.
|
|
42
|
+
if text.startswith('"""') and text.endswith('"""') and len(text) >= 6:
|
|
43
|
+
inner = text[3:-3]
|
|
44
|
+
|
|
45
|
+
if prefix in {"C", "c"}:
|
|
46
|
+
return _decode_classic_string(
|
|
47
|
+
inner,
|
|
48
|
+
line=line,
|
|
49
|
+
column=column,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return inner
|
|
53
|
+
|
|
54
|
+
# Single-quoted or double-quoted string.
|
|
55
|
+
if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
|
|
56
|
+
inner = text[1:-1]
|
|
57
|
+
|
|
58
|
+
# Raw and unprefixed strings: return as-is.
|
|
59
|
+
if prefix in {"", "R", "r"}:
|
|
60
|
+
return inner
|
|
61
|
+
|
|
62
|
+
# Classic strings: decode escapes.
|
|
63
|
+
if prefix in {"C", "c"}:
|
|
64
|
+
return _decode_classic_string(
|
|
65
|
+
inner,
|
|
66
|
+
line=line,
|
|
67
|
+
column=column,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return inner
|
|
71
|
+
|
|
72
|
+
raise YiniParseError(
|
|
73
|
+
f"Invalid string literal: {token_text!r}",
|
|
74
|
+
line=line,
|
|
75
|
+
column=column,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_number_literal(text, line=None, column=None):
|
|
80
|
+
# text = ctx.getText().strip()
|
|
81
|
+
# line, column = self._ctx_location(ctx)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
sign = 1
|
|
85
|
+
body = text
|
|
86
|
+
|
|
87
|
+
if body.startswith("-"):
|
|
88
|
+
sign = -1
|
|
89
|
+
body = body[1:]
|
|
90
|
+
elif body.startswith("+"):
|
|
91
|
+
body = body[1:]
|
|
92
|
+
|
|
93
|
+
lowered = body.lower()
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
By spec: hex:FF_AA // Shall work.
|
|
97
|
+
hex: FF_AA // INVALID!
|
|
98
|
+
"""
|
|
99
|
+
if lowered.startswith("hex:"):
|
|
100
|
+
cleaned = body.split(":", 1)[1].strip().replace("_", "")
|
|
101
|
+
return sign * int(cleaned, 16)
|
|
102
|
+
|
|
103
|
+
if lowered.startswith("0x"):
|
|
104
|
+
return sign * int(body[2:].replace("_", ""), 16)
|
|
105
|
+
|
|
106
|
+
if lowered.startswith("0b"):
|
|
107
|
+
return sign * int(body[2:].replace("_", ""), 2)
|
|
108
|
+
|
|
109
|
+
if lowered.startswith("%"):
|
|
110
|
+
return sign * int(body[1:].replace("_", ""), 2)
|
|
111
|
+
|
|
112
|
+
if lowered.startswith("0o"):
|
|
113
|
+
return sign * int(body[2:].replace("_", ""), 8)
|
|
114
|
+
|
|
115
|
+
if lowered.startswith("0z"):
|
|
116
|
+
return sign * _parse_duodecimal(
|
|
117
|
+
body[2:].replace("_", ""),
|
|
118
|
+
line=line,
|
|
119
|
+
column=column,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
decimal_text = text.replace("_", "")
|
|
123
|
+
|
|
124
|
+
if any(ch in decimal_text for ch in ".eE"):
|
|
125
|
+
return float(decimal_text)
|
|
126
|
+
|
|
127
|
+
return int(decimal_text, 10)
|
|
128
|
+
|
|
129
|
+
except YiniParseError:
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
except ValueError:
|
|
133
|
+
raise YiniParseError(
|
|
134
|
+
f"Invalid number literal: {text!r}",
|
|
135
|
+
line=line,
|
|
136
|
+
column=column,
|
|
137
|
+
) from None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _decode_classic_string(
|
|
141
|
+
inner: str,
|
|
142
|
+
*,
|
|
143
|
+
line: int | None = None,
|
|
144
|
+
column: int | None = None,
|
|
145
|
+
) -> str:
|
|
146
|
+
try:
|
|
147
|
+
return bytes(inner, "utf-8").decode("unicode_escape")
|
|
148
|
+
except UnicodeDecodeError as exc:
|
|
149
|
+
raise YiniParseError(
|
|
150
|
+
f"Invalid string escape sequence: {exc.reason}.",
|
|
151
|
+
line=line,
|
|
152
|
+
column=column,
|
|
153
|
+
) from None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_duodecimal(
|
|
157
|
+
text: str,
|
|
158
|
+
*,
|
|
159
|
+
line: int | None = None,
|
|
160
|
+
column: int | None = None,
|
|
161
|
+
) -> int:
|
|
162
|
+
value = 0
|
|
163
|
+
|
|
164
|
+
if not text:
|
|
165
|
+
raise YiniParseError(
|
|
166
|
+
"Invalid duodecimal number: missing digits after '0z'.",
|
|
167
|
+
line=line,
|
|
168
|
+
column=column,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
for ch in text:
|
|
172
|
+
if ch.isdigit():
|
|
173
|
+
digit = int(ch)
|
|
174
|
+
else:
|
|
175
|
+
lowered = ch.lower()
|
|
176
|
+
|
|
177
|
+
if lowered in {"a", "x"}:
|
|
178
|
+
digit = 10
|
|
179
|
+
elif lowered in {"b", "e"}:
|
|
180
|
+
digit = 11
|
|
181
|
+
else:
|
|
182
|
+
raise YiniParseError(
|
|
183
|
+
f"Invalid duodecimal number: {ch!r} is not a valid base-12 digit.",
|
|
184
|
+
line=line,
|
|
185
|
+
column=column,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if digit >= 12:
|
|
189
|
+
raise YiniParseError(
|
|
190
|
+
f"Invalid duodecimal number: {ch!r} is not a valid base-12 digit.",
|
|
191
|
+
line=line,
|
|
192
|
+
column=column,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
value = value * 12 + digit
|
|
196
|
+
|
|
197
|
+
return value
|