Skip to main content

etdl_parser/
spanned.rs

1//! Source-position tracking for ETDL documents.
2//!
3//! The typed AST produced by [`crate::parse_document`] carries no source
4//! positions. This module parses the same document a second time with `saphyr`
5//! (a position-aware YAML 1.2 parser) and builds a [`SpanIndex`] that records
6//! the location of every semantic element — sections, tree/node/gate/basic-event
7//! definitions, fields, and identifier reference value tokens.
8//!
9//! The index is keyed by the *serde output JSON path* (e.g.
10//! `event_trees.OrderFulfillment.nodes.InventoryCheckBarrier.branches[0].next`)
11//! so it can be injected directly into the AST serialization produced by
12//! [`crate::parse_document`].
13//!
14//! All line/column numbers are **0-based** (LSP convention). `start`/`end` are
15//! **character offsets** into the original document (not UTF-16 code units).
16
17use saphyr::LoadableYamlNode;
18use saphyr::MarkedYaml;
19use serde::Serialize;
20use serde_json::Value;
21
22use crate::ast::EtlDocument;
23
24/// A half-open `[start, end)` span into the source document.
25///
26/// Offsets are 0-based character offsets; `line`/`column`/`end_line`/`end_column`
27/// are 0-based line/column numbers (LSP convention).
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29pub struct Span {
30    pub start: u32,
31    pub end: u32,
32    pub line: u32,
33    pub column: u32,
34    pub end_line: u32,
35    pub end_column: u32,
36}
37
38/// The kind of a recorded element, matching the `kind` field of `find_span`.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "lowercase")]
41pub enum ElementKind {
42    Section,
43    Definition,
44    Field,
45    Reference,
46}
47
48impl ElementKind {
49    fn rank(self) -> u8 {
50        match self {
51            ElementKind::Reference => 4,
52            ElementKind::Field => 3,
53            ElementKind::Definition => 2,
54            ElementKind::Section => 1,
55        }
56    }
57}
58
59/// A component of an index path: either a map key or a sequence index.
60#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61pub enum PathPart {
62    Key(String),
63    Index(usize),
64}
65
66/// A path into the serde output JSON (see module docs).
67pub type PathKey = Vec<PathPart>;
68
69/// One recorded element in the [`SpanIndex`].
70#[derive(Debug, Clone, Serialize)]
71pub struct IndexedElement {
72    #[serde(rename = "kind")]
73    pub kind: ElementKind,
74    pub name: String,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub field: Option<String>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub tree: Option<String>,
79    pub span: Span,
80    /// For definitions: the span of the name token itself (used to anchor
81    /// go-to-definition and to hit-test the identifier).
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub key_span: Option<Span>,
84    /// Structural depth in the document (used for find_span tie-breaking).
85    #[serde(skip)]
86    pub depth: usize,
87    /// The index path of this element (not serialized).
88    #[serde(skip)]
89    pub path: PathKey,
90}
91
92/// A structured locator for a semantic element, used by the validator to attach
93/// positions to diagnostics. Field names use the *serde output* naming
94/// (e.g. `on_failure`, `root_cause`).
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum SpanKey {
97    Section(&'static str),
98    Tree {
99        tree: String,
100    },
101    FaultTree {
102        tree: String,
103    },
104    InitiatingEvent {
105        tree: String,
106        field: &'static str,
107    },
108    TopEvent {
109        tree: String,
110        field: &'static str,
111    },
112    Node {
113        tree: String,
114        id: String,
115    },
116    NodeField {
117        tree: String,
118        id: String,
119        field: &'static str,
120    },
121    BranchField {
122        tree: String,
123        id: String,
124        branch: usize,
125        field: &'static str,
126    },
127    Gate {
128        tree: String,
129        id: String,
130    },
131    GateField {
132        tree: String,
133        id: String,
134        field: &'static str,
135    },
136    GateInput {
137        tree: String,
138        id: String,
139        idx: usize,
140    },
141    BasicEvent {
142        tree: String,
143        id: String,
144    },
145    BasicEventField {
146        tree: String,
147        id: String,
148        field: &'static str,
149    },
150    Transfer {
151        tree: String,
152        id: String,
153        field: &'static str,
154    },
155    ImportAlias {
156        alias: String,
157    },
158}
159
160impl SpanKey {
161    fn path(&self) -> PathKey {
162        let key = |s: &str| PathPart::Key(s.to_string());
163        match self {
164            SpanKey::Section(s) => vec![key(s)],
165            SpanKey::Tree { tree } => vec![key("event_trees"), key(tree)],
166            SpanKey::FaultTree { tree } => vec![key("fault_trees"), key(tree)],
167            SpanKey::InitiatingEvent { tree, field } => vec![
168                key("event_trees"),
169                key(tree),
170                key("initiating_event"),
171                key(field),
172            ],
173            SpanKey::TopEvent { tree, field } => {
174                vec![key("fault_trees"), key(tree), key("top_event"), key(field)]
175            }
176            SpanKey::Node { tree, id } => {
177                vec![key("event_trees"), key(tree), key("nodes"), key(id)]
178            }
179            SpanKey::NodeField { tree, id, field } => {
180                let mut p = SpanKey::Node {
181                    tree: tree.clone(),
182                    id: id.clone(),
183                }
184                .path();
185                p.push(key(field));
186                p
187            }
188            SpanKey::BranchField {
189                tree,
190                id,
191                branch,
192                field,
193            } => {
194                let mut p = SpanKey::Node {
195                    tree: tree.clone(),
196                    id: id.clone(),
197                }
198                .path();
199                p.push(key("branches"));
200                p.push(PathPart::Index(*branch));
201                p.push(key(field));
202                p
203            }
204            SpanKey::Gate { tree, id } => {
205                vec![key("fault_trees"), key(tree), key("gates"), key(id)]
206            }
207            SpanKey::GateField { tree, id, field } => {
208                let mut p = SpanKey::Gate {
209                    tree: tree.clone(),
210                    id: id.clone(),
211                }
212                .path();
213                p.push(key(field));
214                p
215            }
216            SpanKey::GateInput { tree, id, idx } => {
217                let mut p = SpanKey::Gate {
218                    tree: tree.clone(),
219                    id: id.clone(),
220                }
221                .path();
222                p.push(key("inputs"));
223                p.push(PathPart::Index(*idx));
224                p
225            }
226            SpanKey::BasicEvent { tree, id } => {
227                vec![key("fault_trees"), key(tree), key("basic_events"), key(id)]
228            }
229            SpanKey::BasicEventField { tree, id, field } => {
230                let mut p = SpanKey::BasicEvent {
231                    tree: tree.clone(),
232                    id: id.clone(),
233                }
234                .path();
235                p.push(key(field));
236                p
237            }
238            SpanKey::Transfer { tree, id, field } => vec![
239                key("fault_trees"),
240                key(tree),
241                key("transfers"),
242                key(id),
243                key(field),
244            ],
245            SpanKey::ImportAlias { alias } => vec![key("asyncapi_imports"), key(alias)],
246        }
247    }
248}
249
250/// A source-position index over an ETDL document.
251#[derive(Debug, Default, Clone)]
252pub struct SpanIndex {
253    pub elements: Vec<IndexedElement>,
254    by_path: std::collections::HashMap<PathKey, usize>,
255    /// `(tree, id)` -> indices of all definition + reference elements sharing
256    /// that identity (used for go-to-definition / find-references).
257    by_identity: std::collections::HashMap<(String, String), Vec<usize>>,
258}
259
260impl SpanIndex {
261    /// Resolve a [`SpanKey`] to the recorded element.
262    pub fn resolve(&self, key: &SpanKey) -> Option<&IndexedElement> {
263        self.by_path.get(&key.path()).map(|&i| &self.elements[i])
264    }
265
266    /// Return the deepest element whose span (or name-token span) contains the
267    /// given 0-based character offset. "Deepest" = the smallest containing span,
268    /// preferring reference > field > definition > section on ties.
269    pub fn find_deepest(&self, offset: u32) -> Option<&IndexedElement> {
270        let mut best: Option<&IndexedElement> = None;
271        let mut best_size: u64 = u64::MAX;
272        let mut best_rank: u8 = 0;
273        let mut best_depth: usize = 0;
274        for el in &self.elements {
275            let mut size: Option<u64> = None;
276            if el.span.start <= offset && offset < el.span.end {
277                size = Some((el.span.end - el.span.start) as u64);
278            }
279            if let Some(ks) = &el.key_span {
280                if ks.start <= offset && offset < ks.end {
281                    let s = (ks.end - ks.start) as u64;
282                    if size.is_none_or(|cur| s < cur) {
283                        size = Some(s);
284                    }
285                }
286            }
287            if let Some(size) = size {
288                let better = size < best_size
289                    || (size == best_size
290                        && (el.kind.rank() > best_rank
291                            || (el.kind.rank() == best_rank && el.depth > best_depth)));
292                if better {
293                    best = Some(el);
294                    best_size = size;
295                    best_rank = el.kind.rank();
296                    best_depth = el.depth;
297                }
298            }
299        }
300        best
301    }
302
303    /// All elements sharing the given identity `(tree, id)`.
304    pub fn by_identity(&self, tree: &str, id: &str) -> Vec<&IndexedElement> {
305        self.by_identity
306            .get(&(tree.to_string(), id.to_string()))
307            .map(|v| v.iter().map(|&i| &self.elements[i]).collect())
308            .unwrap_or_default()
309    }
310
311    /// The single definition element for `(tree, id)`, if any.
312    pub fn definition(&self, tree: &str, id: &str) -> Option<&IndexedElement> {
313        self.by_identity
314            .get(&(tree.to_string(), id.to_string()))
315            .and_then(|v| {
316                v.iter()
317                    .find(|&&i| self.elements[i].kind == ElementKind::Definition)
318                    .map(|&i| &self.elements[i])
319            })
320    }
321}
322
323/// A detected duplicate identifier under a `nodes`/`gates`/`basicEvents` map.
324#[derive(Debug, Clone)]
325pub struct DuplicateId {
326    pub tree: String,
327    pub kind: String,
328    pub id: String,
329    pub span: Span,
330}
331
332/// Parse the document with `serde_yaml` (producing the typed AST) and build a
333/// [`SpanIndex`] over the same content.
334pub fn parse_document_with_spans(content: &str) -> Result<(EtlDocument, SpanIndex), String> {
335    let doc = crate::parse_document(content)?;
336    let index = build_span_index(content)?;
337    Ok((doc, index))
338}
339
340/// Build a [`SpanIndex`] over an ETDL document.
341pub fn build_span_index(content: &str) -> Result<SpanIndex, String> {
342    // `MarkedYaml::load_from_str` drives saphyr's char-iterator `BufferedInput`,
343    // whose end-of-stream sentinel (`'\0'`) is misclassified as ordinary content
344    // by saphyr-parser's `is_yaml_non_break` (checks `is_break`, not `is_breakz`).
345    // A token left unterminated at true EOF (e.g. a bare `%` directive with
346    // nothing after it) then loops forever appending `'\0'` to an unbounded
347    // buffer. `Parser::new_from_str`'s `&str`-backed `StrInput` doesn't share
348    // that bug (bounds are checked against the real buffer length), so we go
349    // through it instead — same as `detect_duplicate_ids` below already does.
350    let mut parser = saphyr_parser::Parser::new_from_str(content);
351    let docs = MarkedYaml::load_from_parser(&mut parser).map_err(|e| e.to_string())?;
352    let root = docs.first().ok_or("empty ETDL document")?;
353    let mut builder = Builder::new(content);
354    builder.walk_root(root);
355    Ok(builder.index)
356}
357
358/// Inject `span` objects into a serialized AST, wrapping scalar leaves that have
359/// spans as `{ "value": ..., "span": ... }`.
360pub fn inject_spans(value: &mut Value, index: &SpanIndex) {
361    let mut path: PathKey = Vec::new();
362    walk_inject(value, index, &mut path);
363}
364
365fn walk_inject(value: &mut Value, index: &SpanIndex, path: &mut PathKey) {
366    if let Some(&idx) = index.by_path.get(path) {
367        let el = &index.elements[idx];
368        let span = serde_json::to_value(el.span).unwrap_or_default();
369        match value {
370            Value::Object(map) => {
371                map.insert("span".to_string(), span);
372            }
373            Value::Array(_) => {
374                // Collection spans attach to a wrapping object only.
375            }
376            _ => {
377                let inner = std::mem::replace(value, Value::Null);
378                let mut map = serde_json::Map::new();
379                map.insert("value".to_string(), inner);
380                map.insert("span".to_string(), span);
381                *value = Value::Object(map);
382            }
383        }
384    }
385    match value {
386        Value::Object(map) => {
387            for (k, v) in map.iter_mut() {
388                path.push(PathPart::Key(k.clone()));
389                walk_inject(v, index, path);
390                path.pop();
391            }
392        }
393        Value::Array(arr) => {
394            for (i, v) in arr.iter_mut().enumerate() {
395                path.push(PathPart::Index(i));
396                walk_inject(v, index, path);
397                path.pop();
398            }
399        }
400        _ => {}
401    }
402}
403
404/// Detect duplicate ids under `nodes`/`gates`/`basicEvents` maps using saphyr's
405/// low-level event stream (duplicate YAML keys are collapsed before the typed
406/// AST is built, so they must be caught here).
407pub fn detect_duplicate_ids(content: &str) -> Result<Vec<DuplicateId>, String> {
408    use saphyr_parser::{Event, Parser};
409
410    #[derive(Clone, Copy, PartialEq)]
411    enum Container {
412        Mapping,
413        Sequence,
414    }
415
416    #[derive(Default)]
417    struct MapCtx {
418        expect_key: bool,
419        seen: std::collections::BTreeMap<String, usize>,
420        path: Vec<String>,
421        kind: Option<String>,
422        tree: Option<String>,
423    }
424
425    let mut parser = Parser::new_from_str(content);
426    let mut duplicates = Vec::new();
427    let line_map = LineMap::new(content);
428    let mut maps: Vec<MapCtx> = Vec::new();
429    let mut containers: Vec<Container> = Vec::new();
430
431    while let Some(res) = parser.next_event() {
432        let (ev, span) = res.map_err(|e| e.to_string())?;
433        match ev {
434            Event::MappingStart(..) => {
435                if let Some(parent) = maps.last_mut() {
436                    // A mapping value consumes the key it follows.
437                    parent.expect_key = true;
438                }
439                let parent_path = maps.last().map(|c| c.path.clone()).unwrap_or_default();
440                let mut ctx = MapCtx {
441                    expect_key: true,
442                    path: parent_path,
443                    ..Default::default()
444                };
445                if let Some(last) = ctx.path.last() {
446                    let section_idx = ctx
447                        .path
448                        .iter()
449                        .position(|p| p == "eventTrees" || p == "faultTrees");
450                    if let Some(section_idx) = section_idx {
451                        let section = ctx.path[section_idx].as_str();
452                        let tree = ctx.path.get(section_idx + 1).cloned();
453                        match (section, last.as_str()) {
454                            ("eventTrees", "nodes") => {
455                                ctx.kind = Some("node".to_string());
456                                ctx.tree = tree;
457                            }
458                            ("faultTrees", "gates") => {
459                                ctx.kind = Some("gate".to_string());
460                                ctx.tree = tree;
461                            }
462                            ("faultTrees", "basicEvents") => {
463                                ctx.kind = Some("basicEvent".to_string());
464                                ctx.tree = tree;
465                            }
466                            _ => {}
467                        }
468                    }
469                }
470                maps.push(ctx);
471                containers.push(Container::Mapping);
472            }
473            Event::MappingEnd => {
474                maps.pop();
475                containers.pop();
476            }
477            Event::SequenceStart(..) => {
478                if let Some(parent) = maps.last_mut() {
479                    parent.expect_key = true;
480                }
481                containers.push(Container::Sequence);
482            }
483            Event::SequenceEnd => {
484                containers.pop();
485            }
486            Event::Scalar(v, ..) => {
487                if containers.last() != Some(&Container::Mapping) {
488                    continue;
489                }
490                let Some(ctx) = maps.last_mut() else { continue };
491                if ctx.expect_key {
492                    let key = v.to_string();
493                    if let (Some(kind), Some(tree)) = (ctx.kind.clone(), ctx.tree.clone()) {
494                        if ctx.seen.contains_key(&key) {
495                            duplicates.push(DuplicateId {
496                                tree,
497                                kind,
498                                id: key.clone(),
499                                span: line_map.span_of(span.start.index(), span.end.index()),
500                            });
501                        } else {
502                            ctx.seen.insert(key.clone(), span.start.index());
503                        }
504                    }
505                    ctx.path.push(key);
506                    ctx.expect_key = false;
507                } else {
508                    ctx.path.pop();
509                    ctx.expect_key = true;
510                }
511            }
512            _ => {}
513        }
514    }
515
516    Ok(duplicates)
517}
518
519// ---------------------------------------------------------------------------
520// Index builder
521// ---------------------------------------------------------------------------
522
523struct Builder<'a> {
524    content: &'a str,
525    index: SpanIndex,
526    line_map: LineMap<'a>,
527}
528
529impl<'a> Builder<'a> {
530    fn new(content: &'a str) -> Self {
531        Builder {
532            content,
533            index: SpanIndex::default(),
534            line_map: LineMap::new(content),
535        }
536    }
537
538    fn span(&self, start: usize, end: usize) -> Span {
539        self.line_map.span_of(start, end)
540    }
541
542    /// Span covering the token of a scalar value (strips surrounding quotes).
543    fn value_span(&self, node: &MarkedYaml) -> Span {
544        let (s, e) = byte_range(node);
545        let (ts, te) = token_span(self.content, s, e, &node_str(node));
546        self.span(ts, te)
547    }
548
549    fn add(&mut self, el: IndexedElement) {
550        let identity = match el.kind {
551            ElementKind::Definition | ElementKind::Reference => {
552                Some((el.tree.clone().unwrap_or_default(), el.name.clone()))
553            }
554            _ => None,
555        };
556        let idx = if let Some(&existing) = self.index.by_path.get(&el.path) {
557            self.index.elements[existing] = el;
558            existing
559        } else {
560            let idx = self.index.elements.len();
561            self.index.by_path.insert(el.path.clone(), idx);
562            self.index.elements.push(el);
563            idx
564        };
565        if let Some(id) = identity {
566            self.index.by_identity.entry(id).or_default().push(idx);
567        }
568    }
569}
570
571// --- span / token helpers -------------------------------------------------
572
573fn node_str(n: &MarkedYaml) -> String {
574    n.data.as_str().map(|s| s.to_string()).unwrap_or_default()
575}
576
577fn byte_range(n: &MarkedYaml) -> (usize, usize) {
578    (n.span.start.index(), n.span.end.index())
579}
580
581/// Compute the exact token span for a scalar value, stripping surrounding
582/// quotes/whitespace by locating the decoded value inside the reported region.
583fn token_span(content: &str, start: usize, end: usize, value: &str) -> (usize, usize) {
584    let lo = start.min(content.len());
585    let hi = end.min(content.len());
586    let hay = &content[lo..hi];
587    if !value.is_empty() {
588        if let Some(rel) = hay.find(value) {
589            return (lo + rel, lo + rel + value.len());
590        }
591    }
592    (lo, hi)
593}
594
595/// Translate an input key to its serde output name (kept unchanged when unknown).
596fn out_name(key: &str) -> String {
597    match key {
598        "eventTrees" => "event_trees",
599        "faultTrees" => "fault_trees",
600        "basicEvents" => "basic_events",
601        "initiatingEvent" => "initiating_event",
602        "topEvent" => "top_event",
603        "onFailure" => "on_failure",
604        "onFailureProbabilitySource" => "on_failure_probability_source",
605        "probabilityOfSuccess" => "probability_of_success",
606        "probabilityOfFailure" => "probability_of_failure",
607        "probabilitySource" => "probability_source",
608        "rootCause" => "root_cause",
609        "inhibitCondition" => "inhibit_condition",
610        "retryPolicy" => "retry_policy",
611        "timeoutMs" => "timeout_ms",
612        "maxAttempts" => "max_attempts",
613        "backoffMs" => "backoff_ms",
614        "backoffStrategy" => "backoff_strategy",
615        "failureRate" => "failure_rate",
616        "missionTime" => "mission_time",
617        "eventType" => "event_type",
618        other => other,
619    }
620    .to_string()
621}
622
623// --- schema walker ---------------------------------------------------------
624
625impl<'a> Builder<'a> {
626    fn walk_root(&mut self, root: &MarkedYaml) {
627        let Some(map) = root.data.as_mapping() else {
628            return;
629        };
630        for (k, v) in map {
631            let key = node_str(k);
632            let out = out_name(&key);
633            let (ks, _ke) = byte_range(k);
634            let (_vs, ve) = byte_range(v);
635            let path = vec![PathPart::Key(out.clone())];
636            self.add(IndexedElement {
637                kind: ElementKind::Section,
638                name: out.clone(),
639                field: None,
640                tree: None,
641                span: self.span(ks, ve),
642                key_span: Some(self.span(ks, ks + key.len())),
643                path: path.clone(),
644                depth: 1,
645            });
646            match key.as_str() {
647                "info" => self.walk_info(v, &out),
648                "asyncapi_imports" => self.walk_imports(v, &out),
649                "components" => self.walk_components(v, &out),
650                "eventTrees" => self.walk_event_trees(v, &out),
651                "faultTrees" => self.walk_fault_trees(v, &out),
652                _ => {}
653            }
654        }
655    }
656
657    fn walk_info(&mut self, node: &MarkedYaml, base: &str) {
658        let Some(map) = node.data.as_mapping() else {
659            return;
660        };
661        for (k, v) in map {
662            let key = node_str(k);
663            let out = out_name(&key);
664            let (ks, _ke) = byte_range(k);
665            let path = vec![PathPart::Key(base.to_string()), PathPart::Key(out.clone())];
666            self.add(IndexedElement {
667                kind: ElementKind::Field,
668                name: node_str(v),
669                field: Some(out.clone()),
670                tree: None,
671                span: self.span(ks, byte_range(v).1),
672                key_span: Some(self.span(ks, ks + key.len())),
673                path,
674                depth: 2,
675            });
676        }
677    }
678
679    fn walk_imports(&mut self, node: &MarkedYaml, base: &str) {
680        let Some(map) = node.data.as_mapping() else {
681            return;
682        };
683        for (k, v) in map {
684            let alias = node_str(k);
685            let (ks, _ke) = byte_range(k);
686            let path = vec![
687                PathPart::Key(base.to_string()),
688                PathPart::Key(alias.clone()),
689            ];
690            self.add(IndexedElement {
691                kind: ElementKind::Field,
692                name: alias.clone(),
693                field: Some(alias.clone()),
694                tree: None,
695                span: self.span(ks, byte_range(v).1),
696                key_span: Some(self.span(ks, ks + alias.len())),
697                path,
698                depth: 2,
699            });
700        }
701    }
702
703    fn walk_event_trees(&mut self, node: &MarkedYaml, base: &str) {
704        let Some(map) = node.data.as_mapping() else {
705            return;
706        };
707        for (tk, tv) in map {
708            let tree = node_str(tk);
709            let (ks, _ke) = byte_range(tk);
710            let (_, ve) = byte_range(tv);
711            let path = vec![PathPart::Key(base.to_string()), PathPart::Key(tree.clone())];
712            self.add(IndexedElement {
713                kind: ElementKind::Definition,
714                name: tree.clone(),
715                field: None,
716                tree: Some(tree.clone()),
717                span: self.span(ks, ve),
718                key_span: Some(self.span(ks, ks + tree.len())),
719                path: path.clone(),
720                depth: 2,
721            });
722            self.walk_event_tree_fields(tv, base, &tree, &path);
723        }
724    }
725
726    fn walk_event_tree_fields(
727        &mut self,
728        node: &MarkedYaml,
729        base: &str,
730        tree: &str,
731        tree_path: &PathKey,
732    ) {
733        let Some(map) = node.data.as_mapping() else {
734            return;
735        };
736        for (k, v) in map {
737            let key = node_str(k);
738            let (ks, _ke) = byte_range(k);
739            let (_, ve) = byte_range(v);
740            let mut path = tree_path.clone();
741            match key.as_str() {
742                "initiatingEvent" => {
743                    path.push(PathPart::Key("initiating_event".to_string()));
744                    self.add(IndexedElement {
745                        kind: ElementKind::Field,
746                        name: "initiatingEvent".to_string(),
747                        field: Some("initiating_event".to_string()),
748                        tree: Some(tree.to_string()),
749                        span: self.span(ks, ve),
750                        key_span: Some(self.span(ks, ks + key.len())),
751                        path: path.clone(),
752                        depth: 3,
753                    });
754                    self.walk_initiating_event(v, tree, &path);
755                }
756                "nodes" => {
757                    path.push(PathPart::Key("nodes".to_string()));
758                    self.add(IndexedElement {
759                        kind: ElementKind::Field,
760                        name: "nodes".to_string(),
761                        field: Some("nodes".to_string()),
762                        tree: Some(tree.to_string()),
763                        span: self.span(ks, ve),
764                        key_span: Some(self.span(ks, ks + key.len())),
765                        path: path.clone(),
766                        depth: 3,
767                    });
768                    self.walk_nodes(v, base, tree, &path);
769                }
770                "description" => {
771                    path.push(PathPart::Key("description".to_string()));
772                    self.add(IndexedElement {
773                        kind: ElementKind::Field,
774                        name: node_str(v),
775                        field: Some("description".to_string()),
776                        tree: Some(tree.to_string()),
777                        span: self.span(ks, ve),
778                        key_span: Some(self.span(ks, ks + key.len())),
779                        path,
780                        depth: 3,
781                    });
782                }
783                _ => {}
784            }
785        }
786    }
787
788    fn walk_initiating_event(&mut self, node: &MarkedYaml, tree: &str, base_path: &PathKey) {
789        self.walk_scalar_map(
790            node,
791            tree,
792            base_path,
793            &[("id", None), ("message", Some(true)), ("next", Some(true))],
794        );
795    }
796
797    fn walk_nodes(&mut self, node: &MarkedYaml, base: &str, tree: &str, nodes_path: &PathKey) {
798        let Some(map) = node.data.as_mapping() else {
799            return;
800        };
801        for (nk, nv) in map {
802            let nid = node_str(nk);
803            let (ks, _ke) = byte_range(nk);
804            let (_, ve) = byte_range(nv);
805            let mut path = nodes_path.clone();
806            path.push(PathPart::Key(nid.clone()));
807            self.add(IndexedElement {
808                kind: ElementKind::Definition,
809                name: nid.clone(),
810                field: None,
811                tree: Some(tree.to_string()),
812                span: self.span(ks, ve),
813                key_span: Some(self.span(ks, ks + nid.len())),
814                path: path.clone(),
815                depth: 4,
816            });
817            let Some(fields) = nv.data.as_mapping() else {
818                continue;
819            };
820            for (k, v) in fields {
821                let key = node_str(k);
822                let (fks, _fke) = byte_range(k);
823                let mut fpath = path.clone();
824                let field = out_name(&key);
825                fpath.push(PathPart::Key(field.clone()));
826                let is_ref = matches!(
827                    key.as_str(),
828                    "next"
829                        | "onFailure"
830                        | "onFailureProbabilitySource"
831                        | "emits"
832                        | "channel"
833                        | "message"
834                );
835                if is_ref {
836                    self.add(IndexedElement {
837                        kind: ElementKind::Reference,
838                        name: node_str(v),
839                        field: Some(field.clone()),
840                        tree: Some(tree.to_string()),
841                        span: self.value_span(v),
842                        key_span: None,
843                        path: fpath,
844                        depth: 5,
845                    });
846                } else if key == "branches" {
847                    self.add(IndexedElement {
848                        kind: ElementKind::Field,
849                        name: "branches".to_string(),
850                        field: Some("branches".to_string()),
851                        tree: Some(tree.to_string()),
852                        span: self.span(fks, byte_range(v).1),
853                        key_span: Some(self.span(fks, fks + key.len())),
854                        path: fpath.clone(),
855                        depth: 5,
856                    });
857                    self.walk_branches(v, tree, &fpath);
858                } else {
859                    self.add(IndexedElement {
860                        kind: ElementKind::Field,
861                        name: node_str(v),
862                        field: Some(field.clone()),
863                        tree: Some(tree.to_string()),
864                        span: self.span(fks, byte_range(v).1),
865                        key_span: Some(self.span(fks, fks + key.len())),
866                        path: fpath,
867                        depth: 5,
868                    });
869                }
870            }
871            let _ = base;
872        }
873    }
874
875    fn walk_branches(&mut self, node: &MarkedYaml, tree: &str, branches_path: &PathKey) {
876        let Some(seq) = node.data.as_vec() else {
877            return;
878        };
879        for (i, bv) in seq.iter().enumerate() {
880            let (bs, be) = byte_range(bv);
881            let mut bpath = branches_path.clone();
882            bpath.push(PathPart::Index(i));
883            self.add(IndexedElement {
884                kind: ElementKind::Field,
885                name: format!("branches[{}]", i),
886                field: Some("branches".to_string()),
887                tree: Some(tree.to_string()),
888                span: self.span(bs, be),
889                key_span: None,
890                path: bpath.clone(),
891                depth: 6,
892            });
893            let Some(map) = bv.data.as_mapping() else {
894                continue;
895            };
896            for (k, v) in map {
897                let key = node_str(k);
898                let (ks, _ke) = byte_range(k);
899                let mut fpath = bpath.clone();
900                let field = out_name(&key);
901                fpath.push(PathPart::Key(field.clone()));
902                let is_ref = matches!(key.as_str(), "next" | "probabilitySource");
903                let kind = if is_ref {
904                    ElementKind::Reference
905                } else {
906                    ElementKind::Field
907                };
908                self.add(IndexedElement {
909                    kind,
910                    name: node_str(v),
911                    field: Some(field.clone()),
912                    tree: Some(tree.to_string()),
913                    span: if is_ref {
914                        self.value_span(v)
915                    } else {
916                        self.span(ks, byte_range(v).1)
917                    },
918                    key_span: None,
919                    path: fpath,
920                    depth: 7,
921                });
922            }
923        }
924    }
925
926    /// Generic walker for a mapping whose values are scalars, with a list of
927    /// fields and whether each is a reference / definition.
928    fn walk_scalar_map(
929        &mut self,
930        node: &MarkedYaml,
931        tree: &str,
932        base_path: &PathKey,
933        spec: &[(&str, Option<bool>)],
934    ) {
935        let Some(map) = node.data.as_mapping() else {
936            return;
937        };
938        for (k, v) in map {
939            let key = node_str(k);
940            let field = out_name(&key);
941            if let Some((_, is_ref)) = spec.iter().find(|(f, _)| *f == key.as_str()) {
942                let (ks, _ke) = byte_range(k);
943                let mut path = base_path.clone();
944                path.push(PathPart::Key(field.clone()));
945                let kind = if is_ref.unwrap_or(false) {
946                    ElementKind::Reference
947                } else {
948                    ElementKind::Definition
949                };
950                self.add(IndexedElement {
951                    kind,
952                    name: node_str(v),
953                    field: Some(field.clone()),
954                    tree: Some(tree.to_string()),
955                    span: if kind == ElementKind::Reference {
956                        self.value_span(v)
957                    } else {
958                        self.span(ks, byte_range(v).1)
959                    },
960                    key_span: None,
961                    path,
962                    depth: 4,
963                });
964            }
965        }
966    }
967
968    fn walk_fault_trees(&mut self, node: &MarkedYaml, base: &str) {
969        let Some(map) = node.data.as_mapping() else {
970            return;
971        };
972        for (tk, tv) in map {
973            let tree = node_str(tk);
974            let (ks, _ke) = byte_range(tk);
975            let (_, ve) = byte_range(tv);
976            let path = vec![PathPart::Key(base.to_string()), PathPart::Key(tree.clone())];
977            self.add(IndexedElement {
978                kind: ElementKind::Definition,
979                name: tree.clone(),
980                field: None,
981                tree: Some(tree.clone()),
982                span: self.span(ks, ve),
983                key_span: Some(self.span(ks, ks + tree.len())),
984                path: path.clone(),
985                depth: 2,
986            });
987            self.walk_fault_tree_fields(tv, base, &tree, &path);
988        }
989    }
990
991    fn walk_fault_tree_fields(
992        &mut self,
993        node: &MarkedYaml,
994        base: &str,
995        tree: &str,
996        tree_path: &PathKey,
997    ) {
998        let Some(map) = node.data.as_mapping() else {
999            return;
1000        };
1001        for (k, v) in map {
1002            let key = node_str(k);
1003            let (ks, _ke) = byte_range(k);
1004            let (_, ve) = byte_range(v);
1005            let mut path = tree_path.clone();
1006            match key.as_str() {
1007                "topEvent" => {
1008                    path.push(PathPart::Key("top_event".to_string()));
1009                    self.add(IndexedElement {
1010                        kind: ElementKind::Field,
1011                        name: "topEvent".to_string(),
1012                        field: Some("top_event".to_string()),
1013                        tree: Some(tree.to_string()),
1014                        span: self.span(ks, ve),
1015                        key_span: Some(self.span(ks, ks + key.len())),
1016                        path: path.clone(),
1017                        depth: 3,
1018                    });
1019                    self.walk_top_event(v, tree, &path);
1020                }
1021                "gates" => {
1022                    path.push(PathPart::Key("gates".to_string()));
1023                    self.add(IndexedElement {
1024                        kind: ElementKind::Field,
1025                        name: "gates".to_string(),
1026                        field: Some("gates".to_string()),
1027                        tree: Some(tree.to_string()),
1028                        span: self.span(ks, ve),
1029                        key_span: Some(self.span(ks, ks + key.len())),
1030                        path: path.clone(),
1031                        depth: 3,
1032                    });
1033                    self.walk_gates(v, base, tree, &path);
1034                }
1035                "basicEvents" => {
1036                    let field = "basic_events";
1037                    path.push(PathPart::Key(field.to_string()));
1038                    self.add(IndexedElement {
1039                        kind: ElementKind::Field,
1040                        name: "basicEvents".to_string(),
1041                        field: Some(field.to_string()),
1042                        tree: Some(tree.to_string()),
1043                        span: self.span(ks, ve),
1044                        key_span: Some(self.span(ks, ks + key.len())),
1045                        path: path.clone(),
1046                        depth: 3,
1047                    });
1048                    self.walk_basic_events(v, base, tree, &path);
1049                }
1050                "transfers" => {
1051                    path.push(PathPart::Key("transfers".to_string()));
1052                    self.add(IndexedElement {
1053                        kind: ElementKind::Field,
1054                        name: "transfers".to_string(),
1055                        field: Some("transfers".to_string()),
1056                        tree: Some(tree.to_string()),
1057                        span: self.span(ks, ve),
1058                        key_span: Some(self.span(ks, ks + key.len())),
1059                        path: path.clone(),
1060                        depth: 3,
1061                    });
1062                    self.walk_transfers(v, base, tree, &path);
1063                }
1064                "description" => {
1065                    path.push(PathPart::Key("description".to_string()));
1066                    self.add(IndexedElement {
1067                        kind: ElementKind::Field,
1068                        name: node_str(v),
1069                        field: Some("description".to_string()),
1070                        tree: Some(tree.to_string()),
1071                        span: self.span(ks, ve),
1072                        key_span: Some(self.span(ks, ks + key.len())),
1073                        path,
1074                        depth: 3,
1075                    });
1076                }
1077                _ => {}
1078            }
1079        }
1080    }
1081
1082    fn walk_top_event(&mut self, node: &MarkedYaml, tree: &str, base_path: &PathKey) {
1083        let Some(map) = node.data.as_mapping() else {
1084            return;
1085        };
1086        for (k, v) in map {
1087            let key = node_str(k);
1088            let (ks, _ke) = byte_range(k);
1089            let field = out_name(&key);
1090            let mut path = base_path.clone();
1091            path.push(PathPart::Key(field.clone()));
1092            let is_ref = matches!(key.as_str(), "message" | "rootCause");
1093            let kind = if is_ref {
1094                ElementKind::Reference
1095            } else {
1096                ElementKind::Field
1097            };
1098            self.add(IndexedElement {
1099                kind,
1100                name: node_str(v),
1101                field: Some(field.clone()),
1102                tree: Some(tree.to_string()),
1103                span: if is_ref {
1104                    self.value_span(v)
1105                } else {
1106                    self.span(ks, byte_range(v).1)
1107                },
1108                key_span: None,
1109                path,
1110                depth: 4,
1111            });
1112        }
1113    }
1114
1115    fn walk_gates(&mut self, node: &MarkedYaml, base: &str, tree: &str, gates_path: &PathKey) {
1116        let Some(map) = node.data.as_mapping() else {
1117            return;
1118        };
1119        for (gk, gv) in map {
1120            let gid = node_str(gk);
1121            let (ks, _ke) = byte_range(gk);
1122            let (_, ve) = byte_range(gv);
1123            let mut path = gates_path.clone();
1124            path.push(PathPart::Key(gid.clone()));
1125            self.add(IndexedElement {
1126                kind: ElementKind::Definition,
1127                name: gid.clone(),
1128                field: None,
1129                tree: Some(tree.to_string()),
1130                span: self.span(ks, ve),
1131                key_span: Some(self.span(ks, ks + gid.len())),
1132                path: path.clone(),
1133                depth: 4,
1134            });
1135            let Some(fields) = gv.data.as_mapping() else {
1136                continue;
1137            };
1138            for (k, v) in fields {
1139                let key = node_str(k);
1140                let (fks, _fke) = byte_range(k);
1141                let field = out_name(&key);
1142                let mut fpath = path.clone();
1143                fpath.push(PathPart::Key(field.clone()));
1144                match key.as_str() {
1145                    "inputs" => {
1146                        self.add(IndexedElement {
1147                            kind: ElementKind::Field,
1148                            name: node_str(v),
1149                            field: Some("inputs".to_string()),
1150                            tree: Some(tree.to_string()),
1151                            span: self.span(fks, byte_range(v).1),
1152                            key_span: Some(self.span(fks, fks + key.len())),
1153                            path: fpath.clone(),
1154                            depth: 5,
1155                        });
1156                        if let Some(seq) = v.data.as_vec() {
1157                            for (i, item) in seq.iter().enumerate() {
1158                                let mut ipath = fpath.clone();
1159                                ipath.push(PathPart::Index(i));
1160                                self.add(IndexedElement {
1161                                    kind: ElementKind::Reference,
1162                                    name: node_str(item),
1163                                    field: Some("inputs".to_string()),
1164                                    tree: Some(tree.to_string()),
1165                                    span: self.value_span(item),
1166                                    key_span: None,
1167                                    path: ipath,
1168                                    depth: 6,
1169                                });
1170                            }
1171                        }
1172                    }
1173                    _ => {
1174                        self.add(IndexedElement {
1175                            kind: ElementKind::Field,
1176                            name: node_str(v),
1177                            field: Some(field.clone()),
1178                            tree: Some(tree.to_string()),
1179                            span: self.span(fks, byte_range(v).1),
1180                            key_span: Some(self.span(fks, fks + key.len())),
1181                            path: fpath,
1182                            depth: 5,
1183                        });
1184                    }
1185                }
1186            }
1187            let _ = base;
1188        }
1189    }
1190
1191    fn walk_basic_events(
1192        &mut self,
1193        node: &MarkedYaml,
1194        base: &str,
1195        tree: &str,
1196        events_path: &PathKey,
1197    ) {
1198        let Some(map) = node.data.as_mapping() else {
1199            return;
1200        };
1201        for (ek, ev) in map {
1202            let eid = node_str(ek);
1203            let (ks, _ke) = byte_range(ek);
1204            let (_, ve) = byte_range(ev);
1205            let mut path = events_path.clone();
1206            path.push(PathPart::Key(eid.clone()));
1207            self.add(IndexedElement {
1208                kind: ElementKind::Definition,
1209                name: eid.clone(),
1210                field: None,
1211                tree: Some(tree.to_string()),
1212                span: self.span(ks, ve),
1213                key_span: Some(self.span(ks, ks + eid.len())),
1214                path: path.clone(),
1215                depth: 4,
1216            });
1217            let Some(fields) = ev.data.as_mapping() else {
1218                continue;
1219            };
1220            for (k, v) in fields {
1221                let key = node_str(k);
1222                let (fks, _fke) = byte_range(k);
1223                let field = out_name(&key);
1224                let mut fpath = path.clone();
1225                fpath.push(PathPart::Key(field.clone()));
1226                let is_ref = key == "message";
1227                let kind = if is_ref {
1228                    ElementKind::Reference
1229                } else {
1230                    ElementKind::Field
1231                };
1232                self.add(IndexedElement {
1233                    kind,
1234                    name: node_str(v),
1235                    field: Some(field.clone()),
1236                    tree: Some(tree.to_string()),
1237                    span: if is_ref {
1238                        self.value_span(v)
1239                    } else {
1240                        self.span(fks, byte_range(v).1)
1241                    },
1242                    key_span: None,
1243                    path: fpath,
1244                    depth: 5,
1245                });
1246            }
1247            let _ = base;
1248        }
1249    }
1250
1251    fn walk_transfers(
1252        &mut self,
1253        node: &MarkedYaml,
1254        base: &str,
1255        tree: &str,
1256        transfers_path: &PathKey,
1257    ) {
1258        let Some(map) = node.data.as_mapping() else {
1259            return;
1260        };
1261        for (tk, tv) in map {
1262            let tid = node_str(tk);
1263            let (ks, _ke) = byte_range(tk);
1264            let (_, ve) = byte_range(tv);
1265            let mut path = transfers_path.clone();
1266            path.push(PathPart::Key(tid.clone()));
1267            self.add(IndexedElement {
1268                kind: ElementKind::Definition,
1269                name: tid.clone(),
1270                field: None,
1271                tree: Some(tree.to_string()),
1272                span: self.span(ks, ve),
1273                key_span: Some(self.span(ks, ks + tid.len())),
1274                path: path.clone(),
1275                depth: 4,
1276            });
1277            let Some(fields) = tv.data.as_mapping() else {
1278                continue;
1279            };
1280            for (k, v) in fields {
1281                let key = node_str(k);
1282                let (fks, _fke) = byte_range(k);
1283                let field = out_name(&key);
1284                let mut fpath = path.clone();
1285                fpath.push(PathPart::Key(field.clone()));
1286                self.add(IndexedElement {
1287                    kind: ElementKind::Field,
1288                    name: node_str(v),
1289                    field: Some(field.clone()),
1290                    tree: Some(tree.to_string()),
1291                    span: self.span(fks, byte_range(v).1),
1292                    key_span: None,
1293                    path: fpath,
1294                    depth: 5,
1295                });
1296            }
1297            let _ = base;
1298        }
1299    }
1300
1301    fn walk_components(&mut self, node: &MarkedYaml, base: &str) {
1302        let Some(map) = node.data.as_mapping() else {
1303            return;
1304        };
1305        for (k, v) in map {
1306            let key = node_str(k);
1307            let (ks, _ke) = byte_range(k);
1308            let field = out_name(&key);
1309            let path = vec![
1310                PathPart::Key(base.to_string()),
1311                PathPart::Key(field.clone()),
1312            ];
1313            self.add(IndexedElement {
1314                kind: ElementKind::Field,
1315                name: key.clone(),
1316                field: Some(field.clone()),
1317                tree: None,
1318                span: self.span(ks, byte_range(v).1),
1319                key_span: Some(self.span(ks, ks + key.len())),
1320                path: path.clone(),
1321                depth: 2,
1322            });
1323            let Some(items) = v.data.as_mapping() else {
1324                continue;
1325            };
1326            for (ik, iv) in items {
1327                let iid = node_str(ik);
1328                let (iks, _ike) = byte_range(ik);
1329                let mut ipath = path.clone();
1330                ipath.push(PathPart::Key(iid.clone()));
1331                self.add(IndexedElement {
1332                    kind: ElementKind::Definition,
1333                    name: iid.clone(),
1334                    field: None,
1335                    tree: Some(iid.clone()),
1336                    span: self.span(iks, byte_range(iv).1),
1337                    key_span: Some(self.span(iks, iks + iid.len())),
1338                    path: ipath.clone(),
1339                    depth: 3,
1340                });
1341                let Some(fields) = iv.data.as_mapping() else {
1342                    continue;
1343                };
1344                for (fk, fv) in fields {
1345                    let fkey = node_str(fk);
1346                    let (fks, _fke) = byte_range(fk);
1347                    let fname = out_name(&fkey);
1348                    let mut fpath = ipath.clone();
1349                    fpath.push(PathPart::Key(fname.clone()));
1350                    let is_ref = matches!(
1351                        fkey.as_str(),
1352                        "next" | "onFailure" | "message" | "channel" | "emits" | "inputs"
1353                    );
1354                    let kind = if is_ref {
1355                        ElementKind::Reference
1356                    } else {
1357                        ElementKind::Field
1358                    };
1359                    self.add(IndexedElement {
1360                        kind,
1361                        name: node_str(fv),
1362                        field: Some(fname.clone()),
1363                        tree: Some(iid.clone()),
1364                        span: if is_ref {
1365                            self.value_span(fv)
1366                        } else {
1367                            self.span(fks, byte_range(fv).1)
1368                        },
1369                        key_span: None,
1370                        path: fpath,
1371                        depth: 4,
1372                    });
1373                }
1374            }
1375        }
1376    }
1377}
1378
1379// ---------------------------------------------------------------------------
1380// Position helpers
1381// ---------------------------------------------------------------------------
1382
1383struct LineMap<'a> {
1384    content: &'a str,
1385    line_starts: Vec<usize>,
1386}
1387
1388impl<'a> LineMap<'a> {
1389    fn new(content: &'a str) -> Self {
1390        let mut line_starts = vec![0];
1391        for (i, b) in content.bytes().enumerate() {
1392            if b == b'\n' {
1393                line_starts.push(i + 1);
1394            }
1395        }
1396        LineMap {
1397            content,
1398            line_starts,
1399        }
1400    }
1401
1402    fn line_of(&self, byte: usize) -> usize {
1403        match self.line_starts.binary_search(&byte) {
1404            Ok(i) => i,
1405            Err(i) => i.saturating_sub(1),
1406        }
1407        .min(self.line_starts.len().saturating_sub(1))
1408    }
1409
1410    fn char_offset(&self, byte: usize) -> usize {
1411        let byte = byte.min(self.content.len());
1412        // Only count complete chars; a mid-codepoint byte boundary is clamped
1413        // back to the nearest char boundary so slicing never panics.
1414        let byte = self.nearest_char_boundary(byte);
1415        self.content[..byte].chars().count()
1416    }
1417
1418    /// Clamp `byte` to the nearest preceding UTF-8 char boundary.
1419    fn nearest_char_boundary(&self, byte: usize) -> usize {
1420        let byte = byte.min(self.content.len());
1421        let bytes = self.content.as_bytes();
1422        let mut b = byte;
1423        // A position is a char boundary unless it lands on a continuation byte
1424        // (0x80..=0xBF). Walk back while `b` itself is a continuation byte.
1425        while b > 0 && b < bytes.len() && (bytes[b] & 0xC0) == 0x80 {
1426            b -= 1;
1427        }
1428        b
1429    }
1430
1431    fn span_of(&self, start: usize, end: usize) -> Span {
1432        let start = self.nearest_char_boundary(start);
1433        let end = self.nearest_char_boundary(end);
1434        let line = self.line_of(start);
1435        let line_start = self.nearest_char_boundary(self.line_starts[line]);
1436        let line_start = line_start.min(start);
1437        let column = self.content[line_start..start].chars().count();
1438        let end_line = self.line_of(end);
1439        let end_line_start = self.nearest_char_boundary(self.line_starts[end_line]);
1440        let end_line_start = end_line_start.min(end);
1441        let end_column = self.content[end_line_start..end].chars().count();
1442        Span {
1443            start: self.char_offset(start) as u32,
1444            end: self.char_offset(end) as u32,
1445            line: line as u32,
1446            column: column as u32,
1447            end_line: end_line as u32,
1448            end_column: end_column as u32,
1449        }
1450    }
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455    use super::*;
1456    use serde_json::json;
1457
1458    const FIXTURE: &str = include_str!("../tests/fixtures/order-fulfillment.etdl");
1459
1460    #[test]
1461    fn index_covers_sections_and_definitions() {
1462        let index = build_span_index(FIXTURE).unwrap();
1463        let event_trees = index
1464            .resolve(&SpanKey::Section("event_trees"))
1465            .expect("event_trees section");
1466        assert_eq!(event_trees.kind, ElementKind::Section);
1467        assert_eq!(event_trees.span.line, 11); // "eventTrees:" is 0-based line 11
1468
1469        let tree = index
1470            .resolve(&SpanKey::Tree {
1471                tree: "OrderFulfillment".to_string(),
1472            })
1473            .expect("tree definition");
1474        assert_eq!(tree.kind, ElementKind::Definition);
1475        assert_eq!(tree.span.line, 12);
1476
1477        let node = index
1478            .resolve(&SpanKey::Node {
1479                tree: "OrderFulfillment".to_string(),
1480                id: "InventoryCheckBarrier".to_string(),
1481            })
1482            .expect("node definition");
1483        assert_eq!(node.span.line, 20);
1484        let key_span = node.key_span.expect("key span");
1485        assert_eq!(key_span.line, 20);
1486        assert_eq!(key_span.column, 6); // 0-based column of the node name
1487    }
1488
1489    #[test]
1490    fn index_covers_references() {
1491        let index = build_span_index(FIXTURE).unwrap();
1492        let next = index
1493            .resolve(&SpanKey::NodeField {
1494                tree: "OrderFulfillment".to_string(),
1495                id: "ProcessPaymentOperation".to_string(),
1496                field: "next",
1497            })
1498            .expect("next reference");
1499        assert_eq!(next.kind, ElementKind::Reference);
1500        assert_eq!(next.name, "FulfillmentConsequence");
1501        assert_eq!(next.span.line, 39);
1502
1503        let message = index
1504            .resolve(&SpanKey::InitiatingEvent {
1505                tree: "OrderFulfillment".to_string(),
1506                field: "message",
1507            })
1508            .expect("initiatingEvent.message reference");
1509        assert_eq!(message.kind, ElementKind::Reference);
1510        assert_eq!(message.name, "orders_api#/components/messages/OrderPlaced");
1511
1512        let gate_input = index
1513            .resolve(&SpanKey::GateInput {
1514                tree: "PaymentGatewayFailure".to_string(),
1515                id: "GatewayUnavailableOrRejected".to_string(),
1516                idx: 1,
1517            })
1518            .expect("gate input reference");
1519        assert_eq!(gate_input.name, "ChargeRejected");
1520
1521        let root_cause = index
1522            .resolve(&SpanKey::TopEvent {
1523                tree: "PaymentGatewayFailure".to_string(),
1524                field: "root_cause",
1525            })
1526            .expect("rootCause reference");
1527        assert_eq!(root_cause.name, "GatewayUnavailableOrRejected");
1528    }
1529
1530    #[test]
1531    fn find_deepest_resolves_reference_tokens() {
1532        let index = build_span_index(FIXTURE).unwrap();
1533        // Line "        next: FulfillmentConsequence" is 0-based line 39.
1534        let line = FIXTURE.lines().nth(39).unwrap();
1535        let byte_offset = FIXTURE.lines().take(39).map(|l| l.len() + 1).sum::<usize>()
1536            + line.find("FulfillmentConsequence").unwrap();
1537        let char_offset = FIXTURE[..byte_offset].chars().count() as u32;
1538
1539        let el = index.find_deepest(char_offset).expect("found");
1540        assert_eq!(el.kind, ElementKind::Reference);
1541        assert_eq!(el.name, "FulfillmentConsequence");
1542        assert_eq!(el.field.as_deref(), Some("next"));
1543        assert_eq!(el.tree.as_deref(), Some("OrderFulfillment"));
1544        assert!(el.span.start <= char_offset && char_offset < el.span.end);
1545    }
1546
1547    #[test]
1548    fn inject_spans_wraps_scalars() {
1549        let (doc, index) = parse_document_with_spans(FIXTURE).unwrap();
1550        let mut value = serde_json::to_value(&doc).unwrap();
1551        inject_spans(&mut value, &index);
1552
1553        let event_trees = value.get("event_trees").expect("event_trees");
1554        assert!(event_trees.get("span").is_some(), "section span attached");
1555        let tree = &event_trees["OrderFulfillment"];
1556        assert!(tree.get("span").is_some());
1557        let node = &tree["nodes"]["InventoryCheckBarrier"];
1558        assert!(node.get("span").is_some(), "node block span attached");
1559
1560        // Scalar reference wrapped as { value, span }.
1561        let op = &tree["nodes"]["ProcessPaymentOperation"];
1562        let next = &op["next"];
1563        assert!(next.is_object());
1564        assert_eq!(next["value"], "FulfillmentConsequence");
1565        assert!(next.get("span").is_some());
1566
1567        // A scalar without a recorded span stays plain (retryPolicy internals).
1568        assert_eq!(op["retry_policy"]["max_attempts"], 3);
1569        assert_eq!(op["action"]["value"], "execute");
1570        assert!(op["action"].get("span").is_some());
1571    }
1572
1573    #[test]
1574    fn duplicate_ids_are_detected() {
1575        let yaml = r#"
1576etdl: "1.0.0"
1577info:
1578  title: "T"
1579  version: "1.0.0"
1580  domain: "D"
1581asyncapi_imports: {}
1582eventTrees:
1583  T:
1584    initiatingEvent:
1585      id: I
1586      message: "a#/m"
1587      next: N
1588    nodes:
1589      N:
1590        type: barrier
1591        branches:
1592          - outcome: ok
1593            condition: "default"
1594            next: M
1595      M:
1596        type: consequence
1597        operation: terminate
1598      N:
1599        type: consequence
1600        operation: terminate
1601"#;
1602        let dups = detect_duplicate_ids(yaml).unwrap();
1603        assert_eq!(dups.len(), 1, "expected one duplicate node id");
1604        assert_eq!(dups[0].kind, "node");
1605        assert_eq!(dups[0].id, "N");
1606        assert_eq!(dups[0].tree, "T");
1607    }
1608
1609    #[test]
1610    fn span_key_resolves_to_path() {
1611        let index = build_span_index(FIXTURE).unwrap();
1612        let branch = index
1613            .resolve(&SpanKey::BranchField {
1614                tree: "OrderFulfillment".to_string(),
1615                id: "InventoryCheckBarrier".to_string(),
1616                branch: 1,
1617                field: "next",
1618            })
1619            .expect("branch next reference");
1620        assert_eq!(branch.name, "OutOfStockConsequence");
1621        let _ = json!({});
1622    }
1623}