code-atlas-forge 1.1.0__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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
"""Subconjunto mínimo e honesto de YAML (stdlib-only) — lf-0116.
|
|
2
|
+
|
|
3
|
+
Workflows GitHub Actions usam um subconjunto limitado de YAML: mapas de
|
|
4
|
+
bloco, sequências, escalares plain/single/double, comentários, escalares
|
|
5
|
+
de bloco ``|``/``>`` e coleções flow de linha única. Este parser cobre
|
|
6
|
+
**apenas** esse subconjunto — nunca executa, nunca importa ``yaml``
|
|
7
|
+
(dependência externa proibida: adapters são stdlib por contrato).
|
|
8
|
+
|
|
9
|
+
Construções fora do subconjunto NÃO são fabricadas nem ignoradas em
|
|
10
|
+
silêncio: cada uma vira ``YamlDiagnostic`` posicional — anchors ``&``,
|
|
11
|
+
aliases ``*``, merge keys ``<<:``, tags ``!``, múltiplos documentos,
|
|
12
|
+
coleções flow multiline, tabs em indentação, chaves duplicadas.
|
|
13
|
+
|
|
14
|
+
Sem resolução implícita YAML 1.1: ``on:`` continua a *string* ``"on"``
|
|
15
|
+
(o bug clássico de ``yaml.safe_load`` que lê ``on`` como ``True`` não
|
|
16
|
+
existe aqui — chaves e escalares nunca são re-interpretados como bool).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
from code_atlas.parsers.contracts import SourceSpan
|
|
26
|
+
|
|
27
|
+
YNodeKind = Literal["map", "seq", "scalar", "null"]
|
|
28
|
+
|
|
29
|
+
_DOC_MARKERS = frozenset({"---", "..."})
|
|
30
|
+
# cabeçalho de escalar de bloco: "|" ou ">" + chomping opcional + indicador
|
|
31
|
+
_BLOCK_HEADER = re.compile(r"^([|>])([+-]?)(\d*)\s*(#.*)?$")
|
|
32
|
+
# expressões GitHub ${{ ... }} — sanitizadas em atributos, nunca avaliadas
|
|
33
|
+
_ANCHOR_TOKEN = re.compile(r"^&[^\s,[\]{}]+")
|
|
34
|
+
_ALIAS_TOKEN = re.compile(r"^\*[^\s,[\]{}]+")
|
|
35
|
+
_TAG_TOKEN = re.compile(r"^!(?:[^\s!]+!)?[^\s,[\]{}]*")
|
|
36
|
+
|
|
37
|
+
_ESCAPES = {
|
|
38
|
+
"0": "\0", "a": "\a", "b": "\b", "t": "\t", "n": "\n",
|
|
39
|
+
"v": "\v", "f": "\f", "r": "\r", "e": "\x1b", '"': '"',
|
|
40
|
+
"\\": "\\", "/": "/", "_": "\xa0", "N": "\x85",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class YamlDiagnostic:
|
|
46
|
+
"""Problema estrutural do subconjunto YAML — posicional e honesto."""
|
|
47
|
+
|
|
48
|
+
code: str # ex.: "anchor-unsupported", "expected-key", "tab-indent"
|
|
49
|
+
severity: str # "error" | "warning" | "info"
|
|
50
|
+
message: str
|
|
51
|
+
line: int = 0 # 1-based; 0 = arquivo inteiro
|
|
52
|
+
col: int = 0 # 0-based (char)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class YNode:
|
|
57
|
+
"""Nó da árvore YAML do subconjunto.
|
|
58
|
+
|
|
59
|
+
``value`` só vale para ``kind == "scalar"`` (texto já decodificado
|
|
60
|
+
de quotes; plain scalars ficam como texto — sem coerção de tipo).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
kind: YNodeKind
|
|
64
|
+
span: SourceSpan
|
|
65
|
+
value: str = ""
|
|
66
|
+
style: str = "" # plain|single|double|literal|folded|flow — auditoria
|
|
67
|
+
pairs: tuple[tuple[YNode, YNode], ...] = ()
|
|
68
|
+
items: tuple[YNode, ...] = ()
|
|
69
|
+
|
|
70
|
+
# --- navegação -----------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def get(self, key: str) -> YNode | None:
|
|
73
|
+
"""Último ``key`` do mapa (semântica de override do YAML).
|
|
74
|
+
|
|
75
|
+
Chaves duplicadas são reportadas como diagnóstico pelo parser;
|
|
76
|
+
aqui o override tardio vence, como ``yaml.safe_load``.
|
|
77
|
+
"""
|
|
78
|
+
found: YNode | None = None
|
|
79
|
+
for k, v in self.pairs:
|
|
80
|
+
if k.kind == "scalar" and k.value == key:
|
|
81
|
+
found = v
|
|
82
|
+
return found
|
|
83
|
+
|
|
84
|
+
def keys(self) -> tuple[str, ...]:
|
|
85
|
+
"""Chaves escalares do mapa, na ordem declarada."""
|
|
86
|
+
return tuple(
|
|
87
|
+
k.value for k, _ in self.pairs if k.kind == "scalar"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def scalar_text(self) -> str | None:
|
|
91
|
+
"""Texto do escalar; ``None`` quando o nó não é escalar."""
|
|
92
|
+
return self.value if self.kind == "scalar" else None
|
|
93
|
+
|
|
94
|
+
def scalar_list(self) -> tuple[str, ...]:
|
|
95
|
+
"""Lista de textos: seq de escalares, escalar único ou vazio.
|
|
96
|
+
|
|
97
|
+
Uniformiza ``needs: build`` e ``needs: [a, b]`` sem inventar
|
|
98
|
+
valores — itens não-escalares são omitidos.
|
|
99
|
+
"""
|
|
100
|
+
if self.kind == "scalar":
|
|
101
|
+
return (self.value,) if self.value else ()
|
|
102
|
+
if self.kind == "seq":
|
|
103
|
+
return tuple(
|
|
104
|
+
it.value for it in self.items if it.kind == "scalar"
|
|
105
|
+
)
|
|
106
|
+
return ()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class _Line:
|
|
111
|
+
"""Linha física com posição — base de todos os spans."""
|
|
112
|
+
|
|
113
|
+
num: int # 1-based
|
|
114
|
+
byte0: int # byte offset do início da linha no documento
|
|
115
|
+
raw: str # conteúdo sem newline
|
|
116
|
+
indent: int # colunas de espaço à esquerda (tab conta 1)
|
|
117
|
+
body: str # raw[indent:]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_blank_or_comment(line: _Line) -> bool:
|
|
121
|
+
body = line.body.strip()
|
|
122
|
+
return not body or body.startswith("#")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _cut_comment(text: str) -> str:
|
|
126
|
+
"""Remove trailing comment de escalar plain: `` #`` inicia comentário."""
|
|
127
|
+
for i, ch in enumerate(text):
|
|
128
|
+
if ch == "#" and (i == 0 or text[i - 1] in " \t"):
|
|
129
|
+
return text[:i]
|
|
130
|
+
return text
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _Parser:
|
|
134
|
+
"""Descida recursiva por indentação sobre a tabela de linhas."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, text: str) -> None:
|
|
137
|
+
self.lines: list[_Line] = []
|
|
138
|
+
self.diags: list[YamlDiagnostic] = []
|
|
139
|
+
self._doc_markers_seen = 0
|
|
140
|
+
pos = 0
|
|
141
|
+
for n, rl in enumerate(text.split("\n"), 1):
|
|
142
|
+
body = rl[:-1] if rl.endswith("\r") else rl
|
|
143
|
+
ws_len = len(body) - len(body.lstrip(" \t"))
|
|
144
|
+
ws = body[:ws_len]
|
|
145
|
+
if "\t" in ws:
|
|
146
|
+
self._diag(
|
|
147
|
+
"tab-indent", "error",
|
|
148
|
+
"tab in indentation is unsupported YAML subset", n, 0,
|
|
149
|
+
)
|
|
150
|
+
self.lines.append(
|
|
151
|
+
_Line(n, pos, body, ws_len, body[ws_len:])
|
|
152
|
+
)
|
|
153
|
+
pos += len(rl.encode("utf-8")) + 1
|
|
154
|
+
self.n = len(self.lines)
|
|
155
|
+
|
|
156
|
+
# --- diagnósticos / spans ------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _diag(
|
|
159
|
+
self, code: str, severity: str, message: str,
|
|
160
|
+
line: int = 0, col: int = 0,
|
|
161
|
+
) -> None:
|
|
162
|
+
self.diags.append(
|
|
163
|
+
YamlDiagnostic(code, severity, message, line, col)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _span(
|
|
167
|
+
self, l1: int, c1: int, l2: int, c2: int
|
|
168
|
+
) -> SourceSpan:
|
|
169
|
+
"""Span em bytes: ``l*`` são índices 0-based de linha."""
|
|
170
|
+
a = self.lines[l1]
|
|
171
|
+
b = self.lines[l2]
|
|
172
|
+
return SourceSpan(
|
|
173
|
+
start_byte=a.byte0 + len(a.raw[:c1].encode("utf-8")),
|
|
174
|
+
end_byte=b.byte0 + len(b.raw[:c2].encode("utf-8")),
|
|
175
|
+
start_line=l1 + 1,
|
|
176
|
+
start_col=len(a.raw[:c1].encode("utf-8")),
|
|
177
|
+
end_line=l2 + 1,
|
|
178
|
+
end_col=len(b.raw[:c2].encode("utf-8")),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _eol_span(self, line_idx: int) -> SourceSpan:
|
|
182
|
+
ln = self.lines[line_idx]
|
|
183
|
+
col = len(ln.raw)
|
|
184
|
+
return self._span(line_idx, col, line_idx, col)
|
|
185
|
+
|
|
186
|
+
def _sig(self, i: int) -> int | None:
|
|
187
|
+
"""Próxima linha significativa (não-branca, não-comentário)."""
|
|
188
|
+
while i < self.n:
|
|
189
|
+
if not _is_blank_or_comment(self.lines[i]):
|
|
190
|
+
return i
|
|
191
|
+
i += 1
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
# --- entrada ----------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def parse_document(self) -> YNode | None:
|
|
197
|
+
"""Documento único; marcadores ``---``/``...`` tolerados."""
|
|
198
|
+
i = self._sig(0)
|
|
199
|
+
while i is not None and self.lines[i].body.strip() == "---":
|
|
200
|
+
self._doc_markers_seen += 1
|
|
201
|
+
i = self._sig(i + 1)
|
|
202
|
+
if i is not None and self.lines[i].body.lstrip().startswith("%"):
|
|
203
|
+
while i is not None and self.lines[i].body.lstrip().startswith("%"):
|
|
204
|
+
self._diag(
|
|
205
|
+
"directive-unsupported", "warning",
|
|
206
|
+
"YAML directive ignored", self.lines[i].num,
|
|
207
|
+
)
|
|
208
|
+
i = self._sig(i + 1)
|
|
209
|
+
while i is not None and self.lines[i].body.strip() == "---":
|
|
210
|
+
i = self._sig(i + 1)
|
|
211
|
+
if i is None:
|
|
212
|
+
return None
|
|
213
|
+
node, i2 = self.parse_block(i, self.lines[i].indent)
|
|
214
|
+
# conteúdo remanescente: docs extras ou lixo estrutural
|
|
215
|
+
j = self._sig(i2)
|
|
216
|
+
in_extra_doc = False
|
|
217
|
+
while j is not None:
|
|
218
|
+
stripped = self.lines[j].body.strip()
|
|
219
|
+
if stripped in _DOC_MARKERS:
|
|
220
|
+
if stripped == "---":
|
|
221
|
+
self._diag(
|
|
222
|
+
"multi-document-unsupported", "warning",
|
|
223
|
+
"additional YAML document ignored",
|
|
224
|
+
self.lines[j].num,
|
|
225
|
+
)
|
|
226
|
+
in_extra_doc = True
|
|
227
|
+
else:
|
|
228
|
+
in_extra_doc = False
|
|
229
|
+
elif not in_extra_doc:
|
|
230
|
+
self._diag(
|
|
231
|
+
"unparsed-trailing-content", "error",
|
|
232
|
+
"line could not be attached to the document tree",
|
|
233
|
+
self.lines[j].num, self.lines[j].indent,
|
|
234
|
+
)
|
|
235
|
+
j = self._sig(j + 1)
|
|
236
|
+
return node
|
|
237
|
+
|
|
238
|
+
# --- núcleo -----------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def parse_block(self, i: int, min_indent: int) -> tuple[YNode | None, int]:
|
|
241
|
+
"""Nó de bloco começando em ``i``; ``None`` se nada se aplica."""
|
|
242
|
+
j = self._sig(i)
|
|
243
|
+
if j is None:
|
|
244
|
+
return None, i
|
|
245
|
+
ln = self.lines[j]
|
|
246
|
+
if ln.indent < min_indent:
|
|
247
|
+
return None, i
|
|
248
|
+
if ln.body.strip() in _DOC_MARKERS:
|
|
249
|
+
return None, i
|
|
250
|
+
if self._is_seq_item(ln.body):
|
|
251
|
+
return self._parse_seq(j, ln.indent)
|
|
252
|
+
if self._split_key(ln.body) is not None:
|
|
253
|
+
return self._parse_map(j, ln.indent)
|
|
254
|
+
return self._parse_scalar(j, ln.indent)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _is_seq_item(body: str) -> bool:
|
|
258
|
+
return body == "-" or body.startswith("- ")
|
|
259
|
+
|
|
260
|
+
def _split_key(self, body: str) -> int | None:
|
|
261
|
+
"""Posição do ``:`` separador de ``key:``; ``None`` se não-mapa.
|
|
262
|
+
|
|
263
|
+
Separador YAML: ``:`` seguido de espaço ou fim-de-linha, fora de
|
|
264
|
+
quotes e de coleções flow — ``http://x`` nunca é chave.
|
|
265
|
+
"""
|
|
266
|
+
if not body:
|
|
267
|
+
return None
|
|
268
|
+
if body[0] in "'\"":
|
|
269
|
+
return self._quoted_key_pos(body)
|
|
270
|
+
return self._plain_key_pos(body)
|
|
271
|
+
|
|
272
|
+
def _quoted_key_pos(self, body: str) -> int | None:
|
|
273
|
+
end = self._quoted_end(body, 0)
|
|
274
|
+
if end is None:
|
|
275
|
+
return None
|
|
276
|
+
rest = body[end:]
|
|
277
|
+
if rest.startswith(":") and (len(rest) == 1 or rest[1] == " "):
|
|
278
|
+
return end
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def _is_key_colon(body: str, k: int) -> bool:
|
|
283
|
+
return (
|
|
284
|
+
body[k] == ":"
|
|
285
|
+
and (k + 1 == len(body) or body[k + 1] == " ")
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def _plain_key_pos(self, body: str) -> int | None:
|
|
289
|
+
depth = 0
|
|
290
|
+
for k, ch in enumerate(body):
|
|
291
|
+
if ch in "[{":
|
|
292
|
+
depth += 1
|
|
293
|
+
elif ch in "]}":
|
|
294
|
+
depth -= 1
|
|
295
|
+
elif ch == "#" and k > 0 and body[k - 1] == " ":
|
|
296
|
+
return None # comentário antes de qualquer ':' → escalar
|
|
297
|
+
elif depth == 0 and self._is_key_colon(body, k):
|
|
298
|
+
return k
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
def _parse_map(self, i: int, indent: int) -> tuple[YNode, int]:
|
|
302
|
+
pairs: list[tuple[YNode, YNode]] = []
|
|
303
|
+
first = i
|
|
304
|
+
seen: dict[str, int] = {}
|
|
305
|
+
while True:
|
|
306
|
+
j = self._sig(i)
|
|
307
|
+
if j is None:
|
|
308
|
+
i = self.n
|
|
309
|
+
break
|
|
310
|
+
ln = self.lines[j]
|
|
311
|
+
if ln.indent < indent or ln.body.strip() in _DOC_MARKERS:
|
|
312
|
+
i = j
|
|
313
|
+
break
|
|
314
|
+
if ln.indent > indent:
|
|
315
|
+
self._diag(
|
|
316
|
+
"unexpected-indent", "error",
|
|
317
|
+
"line indented deeper than enclosing map",
|
|
318
|
+
ln.num, ln.indent,
|
|
319
|
+
)
|
|
320
|
+
i = j + 1
|
|
321
|
+
continue
|
|
322
|
+
if self._is_seq_item(ln.body):
|
|
323
|
+
i = j
|
|
324
|
+
break
|
|
325
|
+
pair, i = self._parse_map_pair(j, indent, seen)
|
|
326
|
+
if pair is not None:
|
|
327
|
+
pairs.append(pair)
|
|
328
|
+
return (
|
|
329
|
+
YNode(kind="map", pairs=tuple(pairs),
|
|
330
|
+
span=self._join_span(first, pairs)),
|
|
331
|
+
i,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _parse_map_pair(
|
|
335
|
+
self, j: int, indent: int, seen: dict[str, int]
|
|
336
|
+
) -> tuple[tuple[YNode, YNode] | None, int]:
|
|
337
|
+
"""Uma entrada ``key: value`` (inline ou bloco) na linha ``j``."""
|
|
338
|
+
ln = self.lines[j]
|
|
339
|
+
kpos = self._split_key(ln.body)
|
|
340
|
+
if kpos is None:
|
|
341
|
+
self._diag(
|
|
342
|
+
"expected-key", "error",
|
|
343
|
+
"line inside mapping has no 'key:' separator",
|
|
344
|
+
ln.num, ln.indent,
|
|
345
|
+
)
|
|
346
|
+
return None, j + 1
|
|
347
|
+
raw_key = ln.body[:kpos].strip()
|
|
348
|
+
key = self._decode_key(raw_key, j)
|
|
349
|
+
if key in seen:
|
|
350
|
+
self._diag(
|
|
351
|
+
"duplicate-key", "warning",
|
|
352
|
+
f"duplicate mapping key {key!r}: later entry wins",
|
|
353
|
+
ln.num, ln.indent,
|
|
354
|
+
)
|
|
355
|
+
seen[key] = j
|
|
356
|
+
if key == "<<":
|
|
357
|
+
self._diag(
|
|
358
|
+
"merge-key-unsupported", "warning",
|
|
359
|
+
"merge key '<<' is outside the supported subset",
|
|
360
|
+
ln.num, ln.indent,
|
|
361
|
+
)
|
|
362
|
+
key_node = YNode(
|
|
363
|
+
kind="scalar", value=key, style="key",
|
|
364
|
+
span=self._span(j, ln.indent, j, ln.indent + len(raw_key)),
|
|
365
|
+
)
|
|
366
|
+
rest = ln.body[kpos + 1:]
|
|
367
|
+
stripped = rest.strip()
|
|
368
|
+
if not stripped:
|
|
369
|
+
child, i = self._map_value_block(j, indent)
|
|
370
|
+
return (key_node, child), i
|
|
371
|
+
child, i = self._parse_inline(
|
|
372
|
+
stripped, j,
|
|
373
|
+
ln.indent + kpos + 1 + len(rest) - len(rest.lstrip()),
|
|
374
|
+
indent, j + 1,
|
|
375
|
+
)
|
|
376
|
+
return (key_node, child), i
|
|
377
|
+
|
|
378
|
+
def _map_value_block(
|
|
379
|
+
self, j: int, indent: int
|
|
380
|
+
) -> tuple[YNode, int]:
|
|
381
|
+
"""Valor de ``key:`` vazio: bloco mais fundo ou seq mesmo indent."""
|
|
382
|
+
j2 = self._sig(j + 1)
|
|
383
|
+
if (
|
|
384
|
+
j2 is not None
|
|
385
|
+
and self.lines[j2].indent == indent
|
|
386
|
+
and self._is_seq_item(self.lines[j2].body)
|
|
387
|
+
):
|
|
388
|
+
return self._parse_seq(j2, indent)
|
|
389
|
+
child, i = self.parse_block(j + 1, indent + 1)
|
|
390
|
+
return (
|
|
391
|
+
child if child is not None else self._null_at(j)
|
|
392
|
+
), i
|
|
393
|
+
|
|
394
|
+
def _join_span(
|
|
395
|
+
self, first_line: int, pairs: list[tuple[YNode, YNode]]
|
|
396
|
+
) -> SourceSpan:
|
|
397
|
+
first = self.lines[first_line]
|
|
398
|
+
if pairs:
|
|
399
|
+
last = pairs[-1][1].span
|
|
400
|
+
return SourceSpan(
|
|
401
|
+
start_byte=first.byte0 + len(
|
|
402
|
+
first.raw[: first.indent].encode("utf-8")
|
|
403
|
+
),
|
|
404
|
+
end_byte=last.end_byte,
|
|
405
|
+
start_line=first_line + 1,
|
|
406
|
+
start_col=first.indent,
|
|
407
|
+
end_line=last.end_line,
|
|
408
|
+
end_col=last.end_col,
|
|
409
|
+
)
|
|
410
|
+
return self._eol_span(first_line)
|
|
411
|
+
|
|
412
|
+
def _null_at(self, line_idx: int) -> YNode:
|
|
413
|
+
return YNode(kind="null", span=self._eol_span(line_idx))
|
|
414
|
+
|
|
415
|
+
def _parse_seq(self, i: int, indent: int) -> tuple[YNode, int]:
|
|
416
|
+
items: list[YNode] = []
|
|
417
|
+
first = i
|
|
418
|
+
while True:
|
|
419
|
+
j = self._sig(i)
|
|
420
|
+
if j is None:
|
|
421
|
+
i = self.n
|
|
422
|
+
break
|
|
423
|
+
ln = self.lines[j]
|
|
424
|
+
if (
|
|
425
|
+
ln.indent < indent
|
|
426
|
+
or ln.body.strip() in _DOC_MARKERS
|
|
427
|
+
or not self._is_seq_item(ln.body)
|
|
428
|
+
):
|
|
429
|
+
i = j
|
|
430
|
+
break
|
|
431
|
+
if ln.indent > indent:
|
|
432
|
+
self._diag(
|
|
433
|
+
"unexpected-indent", "error",
|
|
434
|
+
"sequence item indented deeper than sequence",
|
|
435
|
+
ln.num, ln.indent,
|
|
436
|
+
)
|
|
437
|
+
i = j + 1
|
|
438
|
+
continue
|
|
439
|
+
body = ln.body
|
|
440
|
+
after = body[1:].lstrip(" ")
|
|
441
|
+
if not after:
|
|
442
|
+
child, i = self.parse_block(j + 1, indent + 1)
|
|
443
|
+
items.append(child if child is not None else self._null_at(j))
|
|
444
|
+
continue
|
|
445
|
+
# linha virtual: conteúdo após '- ' funciona como bloco
|
|
446
|
+
# próprio cuja indentação é a coluna real do conteúdo.
|
|
447
|
+
vcol = ln.indent + (len(body) - len(after))
|
|
448
|
+
virt = _Line(ln.num, ln.byte0, ln.raw, vcol, ln.raw[vcol:])
|
|
449
|
+
self.lines[j] = virt
|
|
450
|
+
try:
|
|
451
|
+
child, i = self.parse_block(j, vcol)
|
|
452
|
+
finally:
|
|
453
|
+
self.lines[j] = ln
|
|
454
|
+
if i == j:
|
|
455
|
+
# parse_block recusou a linha virtual (ex.: marcador de
|
|
456
|
+
# documento como item) — avançar para nunca ciclar.
|
|
457
|
+
i = j + 1
|
|
458
|
+
items.append(
|
|
459
|
+
child if child is not None else self._null_at(j)
|
|
460
|
+
)
|
|
461
|
+
end = items[-1].span if items else self._eol_span(first)
|
|
462
|
+
start = self.lines[first]
|
|
463
|
+
return (
|
|
464
|
+
YNode(
|
|
465
|
+
kind="seq", items=tuple(items),
|
|
466
|
+
span=SourceSpan(
|
|
467
|
+
start_byte=start.byte0 + start.indent,
|
|
468
|
+
end_byte=end.end_byte,
|
|
469
|
+
start_line=first + 1,
|
|
470
|
+
start_col=start.indent,
|
|
471
|
+
end_line=end.end_line,
|
|
472
|
+
end_col=end.end_col,
|
|
473
|
+
),
|
|
474
|
+
),
|
|
475
|
+
i,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def _parse_scalar(self, i: int, indent: int) -> tuple[YNode, int]:
|
|
479
|
+
"""Escalar de linha única; continuações mais fundas viram fold."""
|
|
480
|
+
ln = self.lines[i]
|
|
481
|
+
body = ln.body
|
|
482
|
+
node, _ = self._inline_scalar(body, i, ln.indent)
|
|
483
|
+
# continuações plain: linhas mais indentadas dobram com ' '
|
|
484
|
+
consumed = i + 1
|
|
485
|
+
extra: list[str] = []
|
|
486
|
+
while True:
|
|
487
|
+
j = self._sig(consumed)
|
|
488
|
+
if j is None:
|
|
489
|
+
consumed = self.n
|
|
490
|
+
break
|
|
491
|
+
cont = self.lines[j]
|
|
492
|
+
if cont.indent <= indent or cont.body.strip() in _DOC_MARKERS:
|
|
493
|
+
consumed = j
|
|
494
|
+
break
|
|
495
|
+
if node.style in ("single", "double"):
|
|
496
|
+
break # quoted multi-line fora do subset
|
|
497
|
+
if self._is_seq_item(cont.body) or self._split_key(cont.body):
|
|
498
|
+
break # estrutura — não é continuação
|
|
499
|
+
extra.append(_cut_comment(cont.body).strip())
|
|
500
|
+
consumed = j + 1
|
|
501
|
+
if extra:
|
|
502
|
+
node = YNode(
|
|
503
|
+
kind="scalar",
|
|
504
|
+
value=" ".join([node.value, *extra]),
|
|
505
|
+
style="plain-folded",
|
|
506
|
+
span=SourceSpan(
|
|
507
|
+
start_byte=node.span.start_byte,
|
|
508
|
+
end_byte=self._eol_span(consumed - 1).end_byte,
|
|
509
|
+
start_line=node.span.start_line,
|
|
510
|
+
start_col=node.span.start_col,
|
|
511
|
+
end_line=consumed,
|
|
512
|
+
end_col=len(self.lines[consumed - 1].raw),
|
|
513
|
+
),
|
|
514
|
+
)
|
|
515
|
+
return node, consumed
|
|
516
|
+
|
|
517
|
+
# --- valores inline ------------------------------------------------
|
|
518
|
+
|
|
519
|
+
def _parse_inline(
|
|
520
|
+
self, text: str, line_idx: int, col: int,
|
|
521
|
+
parent_indent: int, next_i: int,
|
|
522
|
+
) -> tuple[YNode, int]:
|
|
523
|
+
"""Valor na mesma linha do ``key:``/``- `` — escalar, flow ou bloco."""
|
|
524
|
+
head = text[0]
|
|
525
|
+
if head in "|>":
|
|
526
|
+
m = _BLOCK_HEADER.match(text)
|
|
527
|
+
if m is None:
|
|
528
|
+
self._diag(
|
|
529
|
+
"block-scalar-header", "error",
|
|
530
|
+
f"unsupported block scalar header {text!r}",
|
|
531
|
+
self.lines[line_idx].num, col,
|
|
532
|
+
)
|
|
533
|
+
return self._plain_node(text, line_idx, col), next_i
|
|
534
|
+
style = "literal" if m.group(1) == "|" else "folded"
|
|
535
|
+
return self._block_scalar(
|
|
536
|
+
parent_indent, next_i, style, m.group(2), line_idx, col,
|
|
537
|
+
)
|
|
538
|
+
if head == "&":
|
|
539
|
+
self._diag(
|
|
540
|
+
"anchor-unsupported", "warning",
|
|
541
|
+
f"anchor {text.split()[0]!r} outside subset; value kept",
|
|
542
|
+
self.lines[line_idx].num, col,
|
|
543
|
+
)
|
|
544
|
+
m = _ANCHOR_TOKEN.match(text)
|
|
545
|
+
rest = text[m.end():].lstrip() if m else ""
|
|
546
|
+
if not rest:
|
|
547
|
+
child, i2 = self.parse_block(next_i, parent_indent + 1)
|
|
548
|
+
return (
|
|
549
|
+
child if child is not None else self._null_at(line_idx)
|
|
550
|
+
), i2
|
|
551
|
+
return self._plain_node(rest, line_idx, col), next_i
|
|
552
|
+
if head == "*":
|
|
553
|
+
self._diag(
|
|
554
|
+
"alias-unsupported", "error",
|
|
555
|
+
f"alias {text.split()[0]!r} cannot be resolved — no "
|
|
556
|
+
"anchor table; value reported as null",
|
|
557
|
+
self.lines[line_idx].num, col,
|
|
558
|
+
)
|
|
559
|
+
return self._null_at(line_idx), next_i
|
|
560
|
+
if head == "!":
|
|
561
|
+
self._diag(
|
|
562
|
+
"tag-unsupported", "warning",
|
|
563
|
+
f"tag {text.split()[0]!r} outside subset; value kept",
|
|
564
|
+
self.lines[line_idx].num, col,
|
|
565
|
+
)
|
|
566
|
+
m = _TAG_TOKEN.match(text)
|
|
567
|
+
rest = text[m.end():].lstrip() if m else ""
|
|
568
|
+
if not rest:
|
|
569
|
+
child, i2 = self.parse_block(next_i, parent_indent + 1)
|
|
570
|
+
return (
|
|
571
|
+
child if child is not None else self._null_at(line_idx)
|
|
572
|
+
), i2
|
|
573
|
+
return self._plain_node(rest, line_idx, col), next_i
|
|
574
|
+
if head in "[{":
|
|
575
|
+
return self._parse_flow(text, line_idx, col), next_i
|
|
576
|
+
node, _ = self._inline_scalar(text, line_idx, col)
|
|
577
|
+
return node, next_i
|
|
578
|
+
|
|
579
|
+
def _plain_node(self, text: str, line_idx: int, col: int) -> YNode:
|
|
580
|
+
value = _cut_comment(text).strip()
|
|
581
|
+
return YNode(
|
|
582
|
+
kind="scalar", value=value, style="plain",
|
|
583
|
+
span=self._span(line_idx, col, line_idx, col + len(text)),
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
def _inline_scalar(
|
|
587
|
+
self, text: str, line_idx: int, col: int
|
|
588
|
+
) -> tuple[YNode, int]:
|
|
589
|
+
"""Escalar inline: quoted ou plain com comment-cut."""
|
|
590
|
+
if text and text[0] in "'\"":
|
|
591
|
+
value, end = self._decode_quoted(text, 0, line_idx, col)
|
|
592
|
+
trailing = text[end:].strip()
|
|
593
|
+
if trailing and not trailing.startswith("#"):
|
|
594
|
+
self._diag(
|
|
595
|
+
"scalar-trailing-content", "warning",
|
|
596
|
+
"content after closing quote ignored",
|
|
597
|
+
self.lines[line_idx].num, col + end,
|
|
598
|
+
)
|
|
599
|
+
return (
|
|
600
|
+
YNode(
|
|
601
|
+
kind="scalar", value=value,
|
|
602
|
+
style="single" if text[0] == "'" else "double",
|
|
603
|
+
span=self._span(line_idx, col, line_idx, col + end),
|
|
604
|
+
),
|
|
605
|
+
end,
|
|
606
|
+
)
|
|
607
|
+
value = _cut_comment(text).strip()
|
|
608
|
+
return (
|
|
609
|
+
YNode(
|
|
610
|
+
kind="scalar", value=value, style="plain",
|
|
611
|
+
span=self._span(
|
|
612
|
+
line_idx, col, line_idx, col + len(text)
|
|
613
|
+
),
|
|
614
|
+
),
|
|
615
|
+
len(text),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# --- coleções flow --------------------------------------------------
|
|
619
|
+
|
|
620
|
+
def _parse_flow(self, text: str, line_idx: int, col: int) -> YNode:
|
|
621
|
+
node, pos = self._flow_node(text, 0, line_idx, col)
|
|
622
|
+
if node is None:
|
|
623
|
+
return self._null_at(line_idx)
|
|
624
|
+
trailing = text[pos:].strip()
|
|
625
|
+
if trailing and not trailing.startswith("#"):
|
|
626
|
+
self._diag(
|
|
627
|
+
"flow-trailing-content", "error",
|
|
628
|
+
"content after flow collection ignored",
|
|
629
|
+
self.lines[line_idx].num, col + pos,
|
|
630
|
+
)
|
|
631
|
+
return YNode(
|
|
632
|
+
kind=node.kind, value=node.value, style="flow",
|
|
633
|
+
pairs=node.pairs, items=node.items,
|
|
634
|
+
span=self._span(line_idx, col, line_idx, col + pos),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
def _flow_node(
|
|
638
|
+
self, s: str, pos: int, line_idx: int, col: int
|
|
639
|
+
) -> tuple[YNode | None, int]:
|
|
640
|
+
pos = self._flow_ws(s, pos)
|
|
641
|
+
if pos >= len(s):
|
|
642
|
+
self._diag(
|
|
643
|
+
"flow-unexpected-end", "error",
|
|
644
|
+
"flow collection ended unexpectedly",
|
|
645
|
+
self.lines[line_idx].num, col + pos,
|
|
646
|
+
)
|
|
647
|
+
return None, pos
|
|
648
|
+
ch = s[pos]
|
|
649
|
+
if ch == "[":
|
|
650
|
+
return self._flow_seq(s, pos, line_idx, col)
|
|
651
|
+
if ch == "{":
|
|
652
|
+
return self._flow_map(s, pos, line_idx, col)
|
|
653
|
+
if ch in "'\"":
|
|
654
|
+
value, end = self._decode_quoted(s, pos, line_idx, col)
|
|
655
|
+
return (
|
|
656
|
+
YNode(kind="scalar", value=value,
|
|
657
|
+
style="single" if s[pos] == "'" else "double",
|
|
658
|
+
span=self._span(
|
|
659
|
+
line_idx, col + pos, line_idx, col + end)),
|
|
660
|
+
end,
|
|
661
|
+
)
|
|
662
|
+
# escalar plain dentro de flow: até , ] } — ou ':' seguido de
|
|
663
|
+
# separador/espaço (é o separador de chave do flow map)
|
|
664
|
+
start = pos
|
|
665
|
+
while pos < len(s):
|
|
666
|
+
ch = s[pos]
|
|
667
|
+
if ch in ",]}":
|
|
668
|
+
break
|
|
669
|
+
if (
|
|
670
|
+
ch == ":"
|
|
671
|
+
and (pos + 1 == len(s) or s[pos + 1] in " ,]}")
|
|
672
|
+
):
|
|
673
|
+
break
|
|
674
|
+
pos += 1
|
|
675
|
+
raw = s[start:pos].strip()
|
|
676
|
+
return (
|
|
677
|
+
YNode(kind="scalar", value=raw, style="flow",
|
|
678
|
+
span=self._span(
|
|
679
|
+
line_idx, col + start, line_idx, col + pos)),
|
|
680
|
+
pos,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
def _flow_ws(self, s: str, pos: int) -> int:
|
|
684
|
+
while pos < len(s) and s[pos] in " \t":
|
|
685
|
+
pos += 1
|
|
686
|
+
return pos
|
|
687
|
+
|
|
688
|
+
def _flow_seq(
|
|
689
|
+
self, s: str, pos: int, line_idx: int, col: int
|
|
690
|
+
) -> tuple[YNode | None, int]:
|
|
691
|
+
items: list[YNode] = []
|
|
692
|
+
start = pos
|
|
693
|
+
pos += 1
|
|
694
|
+
while True:
|
|
695
|
+
pos = self._flow_ws(s, pos)
|
|
696
|
+
if pos >= len(s):
|
|
697
|
+
self._diag(
|
|
698
|
+
"flow-unclosed", "error",
|
|
699
|
+
"flow sequence missing ']'", self.lines[line_idx].num,
|
|
700
|
+
col + start,
|
|
701
|
+
)
|
|
702
|
+
return (
|
|
703
|
+
YNode(kind="seq", items=tuple(items),
|
|
704
|
+
span=self._span(
|
|
705
|
+
line_idx, col + start, line_idx, col + pos)),
|
|
706
|
+
pos,
|
|
707
|
+
)
|
|
708
|
+
if s[pos] == "]":
|
|
709
|
+
return (
|
|
710
|
+
YNode(kind="seq", items=tuple(items),
|
|
711
|
+
span=self._span(
|
|
712
|
+
line_idx, col + start, line_idx, col + pos + 1)),
|
|
713
|
+
pos + 1,
|
|
714
|
+
)
|
|
715
|
+
if s[pos] == ",":
|
|
716
|
+
pos += 1
|
|
717
|
+
continue
|
|
718
|
+
item, pos = self._flow_node(s, pos, line_idx, col)
|
|
719
|
+
if item is None:
|
|
720
|
+
return (
|
|
721
|
+
YNode(kind="seq", items=tuple(items),
|
|
722
|
+
span=self._span(
|
|
723
|
+
line_idx, col + start, line_idx, col + pos)),
|
|
724
|
+
pos,
|
|
725
|
+
)
|
|
726
|
+
items.append(item)
|
|
727
|
+
|
|
728
|
+
def _flow_map(
|
|
729
|
+
self, s: str, pos: int, line_idx: int, col: int
|
|
730
|
+
) -> tuple[YNode | None, int]:
|
|
731
|
+
pairs: list[tuple[YNode, YNode]] = []
|
|
732
|
+
start = pos
|
|
733
|
+
pos += 1
|
|
734
|
+
while True:
|
|
735
|
+
pos = self._flow_ws(s, pos)
|
|
736
|
+
if pos >= len(s):
|
|
737
|
+
self._diag(
|
|
738
|
+
"flow-unclosed", "error",
|
|
739
|
+
"flow mapping missing '}'", self.lines[line_idx].num,
|
|
740
|
+
col + start,
|
|
741
|
+
)
|
|
742
|
+
return (
|
|
743
|
+
YNode(kind="map", pairs=tuple(pairs),
|
|
744
|
+
span=self._span(
|
|
745
|
+
line_idx, col + start, line_idx, col + pos)),
|
|
746
|
+
pos,
|
|
747
|
+
)
|
|
748
|
+
if s[pos] == "}":
|
|
749
|
+
return (
|
|
750
|
+
YNode(kind="map", pairs=tuple(pairs),
|
|
751
|
+
span=self._span(
|
|
752
|
+
line_idx, col + start, line_idx, col + pos + 1)),
|
|
753
|
+
pos + 1,
|
|
754
|
+
)
|
|
755
|
+
if s[pos] == ",":
|
|
756
|
+
pos += 1
|
|
757
|
+
continue
|
|
758
|
+
# chave flow: escalar simples até ':'
|
|
759
|
+
key_node, pos = self._flow_node(s, pos, line_idx, col)
|
|
760
|
+
if key_node is None:
|
|
761
|
+
break
|
|
762
|
+
pos = self._flow_ws(s, pos)
|
|
763
|
+
if pos < len(s) and s[pos] == ":":
|
|
764
|
+
pos += 1
|
|
765
|
+
pos = self._flow_ws(s, pos)
|
|
766
|
+
if pos < len(s) and s[pos] in ",}":
|
|
767
|
+
val = self._null_at(line_idx)
|
|
768
|
+
else:
|
|
769
|
+
child, pos = self._flow_node(s, pos, line_idx, col)
|
|
770
|
+
val = child if child is not None else self._null_at(line_idx)
|
|
771
|
+
else:
|
|
772
|
+
val = self._null_at(line_idx)
|
|
773
|
+
pairs.append((key_node, val))
|
|
774
|
+
return (
|
|
775
|
+
YNode(kind="map", pairs=tuple(pairs),
|
|
776
|
+
span=self._span(
|
|
777
|
+
line_idx, col + start, line_idx, col + pos)),
|
|
778
|
+
pos,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
# --- escalares de bloco ---------------------------------------------
|
|
782
|
+
|
|
783
|
+
def _block_scalar(
|
|
784
|
+
self, parent_indent: int, i: int, style: str,
|
|
785
|
+
chomp: str, header_line: int, header_col: int,
|
|
786
|
+
) -> tuple[YNode, int]:
|
|
787
|
+
"""``|``/``>`` com chomping; consome linhas mais indentadas.
|
|
788
|
+
|
|
789
|
+
Linhas em branco pertencem ao escalar; comentários dentro dele
|
|
790
|
+
são CONTEÚDO (não são stripados) — ex.: ``run: | # comenta``.
|
|
791
|
+
"""
|
|
792
|
+
vals, j = self._block_scalar_lines(parent_indent, i)
|
|
793
|
+
value = self._block_scalar_text(vals, style, chomp)
|
|
794
|
+
end_line = vals[-1][0] if vals else header_line
|
|
795
|
+
return (
|
|
796
|
+
YNode(
|
|
797
|
+
kind="scalar", value=value, style=style,
|
|
798
|
+
span=self._span(
|
|
799
|
+
header_line, header_col,
|
|
800
|
+
end_line, len(self.lines[end_line].raw),
|
|
801
|
+
),
|
|
802
|
+
),
|
|
803
|
+
j,
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
def _block_scalar_lines(
|
|
807
|
+
self, parent_indent: int, i: int
|
|
808
|
+
) -> tuple[list[tuple[int, str, bool]], int]:
|
|
809
|
+
"""Coleta linhas do escalar: (índice, texto, more-indented)."""
|
|
810
|
+
vals: list[tuple[int, str, bool]] = []
|
|
811
|
+
content_indent = -1
|
|
812
|
+
j = i
|
|
813
|
+
while j < self.n:
|
|
814
|
+
ln = self.lines[j]
|
|
815
|
+
if not ln.body.strip():
|
|
816
|
+
vals.append((j, "", False))
|
|
817
|
+
j += 1
|
|
818
|
+
continue
|
|
819
|
+
if ln.indent <= parent_indent:
|
|
820
|
+
break
|
|
821
|
+
if content_indent < 0:
|
|
822
|
+
content_indent = ln.indent
|
|
823
|
+
if ln.indent < content_indent:
|
|
824
|
+
self._diag(
|
|
825
|
+
"block-scalar-indent", "warning",
|
|
826
|
+
"line less indented than block scalar content",
|
|
827
|
+
ln.num, ln.indent,
|
|
828
|
+
)
|
|
829
|
+
keep = min(ln.indent, content_indent)
|
|
830
|
+
vals.append((j, ln.raw[keep:], ln.indent > content_indent))
|
|
831
|
+
j += 1
|
|
832
|
+
# trailing blanks além do último conteúdo não geram linhas extras
|
|
833
|
+
while vals and vals[-1][1] == "":
|
|
834
|
+
vals.pop()
|
|
835
|
+
return vals, j
|
|
836
|
+
|
|
837
|
+
@staticmethod
|
|
838
|
+
def _block_scalar_text(
|
|
839
|
+
vals: list[tuple[int, str, bool]], style: str, chomp: str
|
|
840
|
+
) -> str:
|
|
841
|
+
if style == "literal":
|
|
842
|
+
text = "\n".join(v for _, v, _ in vals)
|
|
843
|
+
else:
|
|
844
|
+
parts: list[str] = []
|
|
845
|
+
for k, (_, v, more) in enumerate(vals):
|
|
846
|
+
if k > 0:
|
|
847
|
+
prev_blank = vals[k - 1][1] == ""
|
|
848
|
+
prev_more = vals[k - 1][2]
|
|
849
|
+
if v == "" or prev_blank or more or prev_more:
|
|
850
|
+
parts.append("\n")
|
|
851
|
+
else:
|
|
852
|
+
parts.append(" ")
|
|
853
|
+
parts.append(v)
|
|
854
|
+
text = "".join(parts)
|
|
855
|
+
core = text.rstrip("\n")
|
|
856
|
+
if chomp == "-":
|
|
857
|
+
return core
|
|
858
|
+
if chomp == "+":
|
|
859
|
+
return text if text.endswith("\n") or not text else text + "\n"
|
|
860
|
+
return core + ("\n" if core else "")
|
|
861
|
+
|
|
862
|
+
# --- quotes -----------------------------------------------------------
|
|
863
|
+
|
|
864
|
+
def _quoted_end(self, s: str, pos: int) -> int | None:
|
|
865
|
+
"""Posição após a aspa de fechamento; ``None`` se não fecha."""
|
|
866
|
+
q = s[pos]
|
|
867
|
+
pos += 1
|
|
868
|
+
while pos < len(s):
|
|
869
|
+
ch = s[pos]
|
|
870
|
+
if ch == q:
|
|
871
|
+
if q == "'" and pos + 1 < len(s) and s[pos + 1] == "'":
|
|
872
|
+
pos += 2
|
|
873
|
+
continue
|
|
874
|
+
return pos + 1
|
|
875
|
+
if q == '"' and ch == "\\":
|
|
876
|
+
pos += 2
|
|
877
|
+
continue
|
|
878
|
+
pos += 1
|
|
879
|
+
return None
|
|
880
|
+
|
|
881
|
+
def _decode_quoted(
|
|
882
|
+
self, s: str, pos: int, line_idx: int, col: int
|
|
883
|
+
) -> tuple[str, int]:
|
|
884
|
+
q = s[pos]
|
|
885
|
+
out: list[str] = []
|
|
886
|
+
pos += 1
|
|
887
|
+
while pos < len(s):
|
|
888
|
+
ch = s[pos]
|
|
889
|
+
if ch == q:
|
|
890
|
+
if q == "'" and pos + 1 < len(s) and s[pos + 1] == "'":
|
|
891
|
+
out.append("'")
|
|
892
|
+
pos += 2
|
|
893
|
+
continue
|
|
894
|
+
return "".join(out), pos + 1
|
|
895
|
+
if q == '"' and ch == "\\" and pos + 1 < len(s):
|
|
896
|
+
esc = s[pos + 1]
|
|
897
|
+
if esc in _ESCAPES:
|
|
898
|
+
out.append(_ESCAPES[esc])
|
|
899
|
+
else:
|
|
900
|
+
self._diag(
|
|
901
|
+
"unknown-escape", "warning",
|
|
902
|
+
f"unknown escape \\{esc} kept literally",
|
|
903
|
+
self.lines[line_idx].num, col + pos,
|
|
904
|
+
)
|
|
905
|
+
out.append(ch + esc)
|
|
906
|
+
pos += 2
|
|
907
|
+
continue
|
|
908
|
+
out.append(ch)
|
|
909
|
+
pos += 1
|
|
910
|
+
self._diag(
|
|
911
|
+
"unterminated-quote", "error",
|
|
912
|
+
"quoted scalar never closes", self.lines[line_idx].num, col,
|
|
913
|
+
)
|
|
914
|
+
return "".join(out), pos
|
|
915
|
+
|
|
916
|
+
def _decode_key(self, raw: str, line_idx: int) -> str:
|
|
917
|
+
if raw and raw[0] in "'\"":
|
|
918
|
+
value, _ = self._decode_quoted(
|
|
919
|
+
raw, 0, line_idx, self.lines[line_idx].indent
|
|
920
|
+
)
|
|
921
|
+
return value
|
|
922
|
+
return raw
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
@dataclass(frozen=True)
|
|
926
|
+
class MiniYamlResult:
|
|
927
|
+
"""Resultado honesto: árvore + diagnósticos, nunca exceção."""
|
|
928
|
+
|
|
929
|
+
root: YNode | None
|
|
930
|
+
diagnostics: tuple[YamlDiagnostic, ...] = field(
|
|
931
|
+
default_factory=tuple
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def parse(text: str) -> MiniYamlResult:
|
|
936
|
+
"""Parse do subconjunto YAML; falhas viram diagnósticos."""
|
|
937
|
+
parser = _Parser(text)
|
|
938
|
+
try:
|
|
939
|
+
root = parser.parse_document()
|
|
940
|
+
except Exception as exc: # robustez: bug vira diagnóstico, nunca crash
|
|
941
|
+
parser._diag(
|
|
942
|
+
"parser-internal-error", "error",
|
|
943
|
+
f"internal parser failure: {exc}",
|
|
944
|
+
)
|
|
945
|
+
return MiniYamlResult(None, tuple(parser.diags))
|
|
946
|
+
return MiniYamlResult(root, tuple(parser.diags))
|