Skip to main content

locus_core_rs/parsing/
state_machine.rs

1use super::ast::{AstLayer, SttpAst};
2use super::lexer::{Token, TokenKind, tokenize};
3use super::lexicon::{LAYER_ORDER, LAYER_STOP_MARKER, LayerKind};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum ParserState {
7    Start,
8    InProvenance,
9    InEnvelope,
10    InContent,
11    InMetrics,
12    Done,
13    Error,
14}
15
16#[derive(Debug, Clone)]
17pub struct LayerParseOutcome<'a> {
18    pub provenance: Option<&'a str>,
19    pub provenance_span: Option<super::lexer::Span>,
20    pub envelope: Option<&'a str>,
21    pub envelope_span: Option<super::lexer::Span>,
22    pub content: Option<&'a str>,
23    pub content_span: Option<super::lexer::Span>,
24    pub metrics: Option<&'a str>,
25    pub metrics_span: Option<super::lexer::Span>,
26    pub strict_spine: bool,
27    pub state: ParserState,
28    pub diagnostics: Vec<String>,
29}
30
31pub struct SttpLayerStateMachine;
32
33impl SttpLayerStateMachine {
34    pub fn parse<'a>(raw: &'a str) -> LayerParseOutcome<'a> {
35        let mut diagnostics = Vec::new();
36        let tokens = tokenize(raw);
37
38        let provenance = extract_layer(raw, &tokens, LayerKind::Provenance);
39        let envelope = extract_layer(raw, &tokens, LayerKind::Envelope);
40        let content = extract_layer(raw, &tokens, LayerKind::Content);
41        let metrics = extract_layer(raw, &tokens, LayerKind::Metrics);
42
43        let provenance_span = provenance.as_ref().map(|v| v.span);
44        let envelope_span = envelope.as_ref().map(|v| v.span);
45        let content_span = content.as_ref().map(|v| v.span);
46        let metrics_span = metrics.as_ref().map(|v| v.span);
47
48        let provenance = provenance.as_ref().map(|v| v.slice);
49        let envelope = envelope.as_ref().map(|v| v.slice);
50        let content = content.as_ref().map(|v| v.slice);
51        let metrics = metrics.as_ref().map(|v| v.slice);
52
53        let strict_spine = is_strict_spine(raw);
54        if !strict_spine {
55            diagnostics.push("non_strict_spine_recovered_tolerantly".to_string());
56        }
57
58        if provenance.is_none() {
59            diagnostics.push("missing_layer_provenance".to_string());
60        }
61        if envelope.is_none() {
62            diagnostics.push("missing_layer_envelope".to_string());
63        }
64        if content.is_none() {
65            diagnostics.push("missing_layer_content".to_string());
66        }
67        if metrics.is_none() {
68            diagnostics.push("missing_layer_metrics".to_string());
69        }
70
71        let state = if metrics.is_some() {
72            ParserState::Done
73        } else if content.is_some() {
74            ParserState::InContent
75        } else if envelope.is_some() {
76            ParserState::InEnvelope
77        } else if provenance.is_some() {
78            ParserState::InProvenance
79        } else {
80            ParserState::Error
81        };
82
83        LayerParseOutcome {
84            provenance,
85            provenance_span,
86            envelope,
87            envelope_span,
88            content,
89            content_span,
90            metrics,
91            metrics_span,
92            strict_spine,
93            state,
94            diagnostics,
95        }
96    }
97
98    pub fn to_ast(raw: &str, parsed: &LayerParseOutcome<'_>) -> SttpAst {
99        let make_layer = |kind: LayerKind, source: Option<&str>| {
100            source.and_then(|slice| {
101                let start = raw.find(slice)?;
102                let end = start + slice.len();
103                Some(AstLayer {
104                    kind,
105                    source: slice.to_string(),
106                    start,
107                    end,
108                })
109            })
110        };
111
112        SttpAst {
113            provenance: make_layer(LayerKind::Provenance, parsed.provenance),
114            envelope: make_layer(LayerKind::Envelope, parsed.envelope),
115            content: make_layer(LayerKind::Content, parsed.content),
116            metrics: make_layer(LayerKind::Metrics, parsed.metrics),
117            strict_spine: parsed.strict_spine,
118        }
119    }
120}
121
122#[derive(Debug, Clone, Copy)]
123struct LayerSlice<'a> {
124    slice: &'a str,
125    span: super::lexer::Span,
126}
127
128fn is_strict_spine(raw: &str) -> bool {
129    let mut last = 0usize;
130    for kind in LAYER_ORDER {
131        let marker = kind.marker();
132        let Some(relative) = raw[last..].find(marker) else {
133            return false;
134        };
135        last += relative + marker.len();
136    }
137
138    true
139}
140
141fn extract_layer<'a>(raw: &'a str, tokens: &[Token], kind: LayerKind) -> Option<LayerSlice<'a>> {
142    let layer_start_kind = layer_start_token_kind(kind);
143    let start_token_index = tokens.iter().position(|t| t.kind == layer_start_kind)?;
144    let start_token = tokens[start_token_index];
145    let start = tokens[start_token_index].span.start;
146
147    let end = tokens
148        .iter()
149        .skip(start_token_index + 1)
150        .find(|t| t.kind == TokenKind::LayerEnd)
151        .map(|t| t.span.end)
152        .or_else(|| {
153            let value_start = start + kind.marker().len();
154            raw[value_start..]
155                .find(LAYER_STOP_MARKER)
156                .map(|relative| value_start + relative + LAYER_STOP_MARKER.len())
157        })?;
158
159    let slice = raw.get(start..end)?;
160    Some(LayerSlice {
161        slice,
162        span: super::lexer::Span {
163            start,
164            end,
165            line: start_token.span.line,
166            column: start_token.span.column,
167        },
168    })
169}
170
171fn layer_start_token_kind(kind: LayerKind) -> TokenKind {
172    match kind {
173        LayerKind::Provenance => TokenKind::ProvenanceStart,
174        LayerKind::Envelope => TokenKind::EnvelopeStart,
175        LayerKind::Content => TokenKind::ContentStart,
176        LayerKind::Metrics => TokenKind::MetricsStart,
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn should_detect_strict_spine() {
186        let raw = r#"
187⊕⟨ { trigger: manual } ⟩
188⦿⟨ { timestamp: "2026-03-05T06:30:00Z", tier: raw } ⟩
189◈⟨ { a(.99): b } ⟩
190⍉⟨ { rho: 0.1, kappa: 0.2, psi: 0.3 } ⟩
191"#;
192
193        let parsed = SttpLayerStateMachine::parse(raw);
194        assert!(parsed.strict_spine);
195        assert_eq!(parsed.state, ParserState::Done);
196        assert!(parsed.provenance.is_some());
197        assert!(parsed.envelope.is_some());
198        assert!(parsed.content.is_some());
199        assert!(parsed.metrics.is_some());
200    }
201
202    #[test]
203    fn should_recover_with_missing_layer() {
204        let raw = r#"
205⊕⟨ { trigger: manual } ⟩
206⦿⟨ { timestamp: "2026-03-05T06:30:00Z", tier: raw } ⟩
207⍉⟨ { rho: 0.1, kappa: 0.2, psi: 0.3 } ⟩
208"#;
209
210        let parsed = SttpLayerStateMachine::parse(raw);
211        assert!(!parsed.strict_spine);
212        assert_eq!(parsed.state, ParserState::Done);
213        assert!(parsed.content.is_none());
214        assert!(parsed.metrics.is_some());
215        assert!(parsed.diagnostics.len() >= 2);
216        assert!(
217            parsed
218                .diagnostics
219                .iter()
220                .any(|d| d == "missing_layer_content")
221        );
222    }
223
224    #[test]
225    fn should_project_ast_with_spans() {
226        let raw = r#"
227⊕⟨ { trigger: manual } ⟩
228⦿⟨ { timestamp: "2026-03-05T06:30:00Z", tier: raw } ⟩
229◈⟨ { topic(.99): x } ⟩
230⍉⟨ { rho: 0.1, kappa: 0.2, psi: 0.3 } ⟩
231"#;
232
233        let parsed = SttpLayerStateMachine::parse(raw);
234        let ast = SttpLayerStateMachine::to_ast(raw, &parsed);
235
236        assert!(ast.provenance.is_some());
237        assert!(ast.envelope.is_some());
238        assert!(ast.content.is_some());
239        assert!(ast.metrics.is_some());
240
241        let content = ast.content.expect("content layer should exist");
242        assert!(content.start < content.end);
243        assert!(content.source.contains("topic(.99)"));
244    }
245}