Skip to main content

active_core/
ir.rs

1//! Intermediate Representation and Dependency Graph
2
3use indexmap::IndexMap;
4use petgraph::graph::{DiGraph, NodeIndex};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use crate::diag::{Diagnostic, Span};
9use crate::types::{ActiveType, Constraint, Unit};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub struct NodeId(pub u32);
13
14#[derive(Debug, Clone)]
15pub struct Module {
16    pub arena: Vec<Node>,
17    pub bindings: HashMap<String, NodeId>,
18    pub graph: DiGraph<NodeId, ()>,
19    pub node_to_graph: HashMap<NodeId, NodeIndex>,
20    pub root: Option<NodeId>,
21    pub schemas: HashMap<String, NodeId>,
22    pub diagnostics: Vec<Diagnostic>,
23    pub source_name: String,
24    /// Source spans for nodes, recorded by the parser so evaluation-time
25    /// diagnostics can point at real positions.
26    pub node_spans: HashMap<NodeId, Span>,
27    /// Source file per node, parallel to `node_spans` (C-025 includes).
28    pub node_files: HashMap<NodeId, String>,
29    /// Healing diagnostics (H0xx): repairs the parser applied or declined,
30    /// with confidence scores. Separate from `diagnostics` so callers can
31    /// apply mode-dependent gating.
32    pub healing: Vec<Diagnostic>,
33    /// Comments with their normative attachment (spec/dual-syntax.md §7-§8),
34    /// recorded so the formatter can re-emit them losslessly.
35    pub trivia: Vec<TriviaComment>,
36    /// The layout style the source predominantly used (for Preserve mode).
37    pub source_style: Style,
38    /// Include statements in source order (spec §2.7). The formatter
39    /// re-emits them; evaluation merges them per the defaults rule, so
40    /// the composed tree does not need them.
41    pub includes: Vec<IncludeStmt>,
42    /// Named profiles in source order (spec §2.8, C-027). Never exported
43    /// directly; the selected one merges into the root value at
44    /// evaluation.
45    pub profiles: IndexMap<String, NodeId>,
46    /// Structural patch directives in source order (spec §2.9, C-028).
47    /// Applied to the evaluated value tree after profile merge.
48    pub patches: Vec<NodeId>,
49    /// The document schema tree (spec §2.10, C-030): the root `schema`
50    /// table and every nested one, extracted from value fields at parse.
51    /// None when the document declares no schema. C-031 validates
52    /// against it.
53    pub schema: Option<NodeId>,
54    /// Parsed `# @…` annotations by annotated node (spec/annotations.md,
55    /// C-033): intent documentation, constraint predicates, and
56    /// immutability flags for validators, the language server, and AI
57    /// tooling. Comments themselves stay in `trivia` for the formatter.
58    pub annotations: HashMap<NodeId, Vec<Annotation>>,
59    /// Dependency declarations by scope table (spec/linter.md §2,
60    /// C-042): `depends_on` keys holding string arrays are extracted
61    /// from value fields at parse (never exported) and recorded here
62    /// against their scope's table node.
63    pub depends_on: HashMap<NodeId, DependsOn>,
64}
65
66/// A parsed `# @…` annotation attached to a node (spec/annotations.md).
67#[derive(Debug, Clone)]
68pub struct Annotation {
69    pub kind: AnnotationKind,
70    /// Dotted path of the annotated key (scope path + key; bare key at
71    /// root; binding name for `let`s) — the path-based enforcement
72    /// passes (patches, constraints) work on paths.
73    pub path: String,
74    /// The annotation comment's span (diagnostics and LSP).
75    pub span: Span,
76}
77
78/// Annotation payloads (spec/annotations.md §2–§4).
79#[derive(Debug, Clone)]
80pub enum AnnotationKind {
81    /// Documentation only, never enforced.
82    Intent(String),
83    /// Boolean predicate with `value` bound to the annotated value.
84    /// `source` is the raw payload (quoted in T006); `expr` is the
85    /// parsed tree with `value` rewritten to the annotated node.
86    Constraint { source: String, expr: NodeId },
87    /// Agent-immutable flag; `false` behaves as no annotation.
88    AiImmutable(bool),
89}
90
91/// A parsed `depends_on` block for one table scope
92/// (spec/linter.md §2).
93#[derive(Debug, Clone)]
94pub struct DependsOn {
95    /// Declared dependency paths, dotted from the document root.
96    pub paths: Vec<String>,
97    /// The `depends_on` key's span (diagnostics).
98    pub span: Span,
99}
100#[derive(Debug, Clone)]
101pub struct IncludeStmt {
102    /// Literal path as written.
103    pub path: String,
104    /// Optional `as alias` namespace.
105    pub alias: Option<String>,
106    /// Whether the included keys stay out of the exported tree.
107    pub private: bool,
108    /// Scope path where the statement appeared (empty = document root).
109    pub scope_path: Vec<String>,
110    /// Source line of the statement, for stable formatter emission.
111    pub line: u32,
112}
113
114/// One field's schema: a type plus its constraint arguments (spec §2.10,
115/// C-030). Constraint values are expression nodes so bounds may use
116/// literals, env refs, and arithmetic; C-031 evaluates them during
117/// validation. `default` is parsed and stored; C-032 applies it.
118/// `deprecated` is static text (C-042): `None` is live, `Some("")` is
119/// a bare `deprecated = true`, `Some(msg)` carries the migration
120/// message.
121#[derive(Debug, Clone)]
122pub struct SchemaField {
123    pub typ: ActiveType,
124    pub min: Option<NodeId>,
125    pub max: Option<NodeId>,
126    pub min_len: Option<NodeId>,
127    pub max_len: Option<NodeId>,
128    pub pattern: Option<NodeId>,
129    pub default: Option<NodeId>,
130    pub deprecated: Option<String>,
131    pub span: Option<crate::diag::Span>,
132}
133
134impl SchemaField {
135    /// A field with no constraint arguments.
136    pub fn bare(typ: ActiveType, span: Option<crate::diag::Span>) -> Self {
137        Self {
138            typ,
139            min: None,
140            max: None,
141            min_len: None,
142            max_len: None,
143            pattern: None,
144            default: None,
145            deprecated: None,
146            span,
147        }
148    }
149}
150
151/// Layout style for output (spec/dual-syntax.md §8).
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum Style {
154    /// `[name]` headers and `key = value`; nested tables as `name { … }`.
155    Brace,
156    /// `key:` headers with two-space indentation.
157    Indent,
158    /// Re-emit in the style the source used.
159    Preserve,
160}
161
162/// Where a comment belongs (spec/dual-syntax.md §7.2). Every comment
163/// receives exactly one attachment.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum Attachment {
166    /// Own-line comment(s) before a statement, identified by key.
167    Leading(String),
168    /// After code on the same line as a statement.
169    Trailing(String),
170    /// No following statement in that block.
171    TrailingBlock(String),
172    /// No following statement in the document.
173    TrailingDocument,
174}
175
176/// A source comment retained for lossless round-tripping.
177#[derive(Debug, Clone)]
178pub struct TriviaComment {
179    pub text: String,
180    pub span: Span,
181    pub attachment: Attachment,
182    /// Source file (includes parse as their own documents, C-025):
183    /// line numbers repeat across files, so attachment scans match
184    /// (line, file) — without this, comments in an included file
185    /// attach to same-numbered lines of the including document.
186    pub file: Option<String>,
187}
188
189impl Module {
190    pub fn new(source_name: impl Into<String>) -> Self {
191        Self {
192            arena: Vec::new(),
193            bindings: HashMap::new(),
194            graph: DiGraph::new(),
195            node_to_graph: HashMap::new(),
196            root: None,
197            schemas: HashMap::new(),
198            diagnostics: Vec::new(),
199            source_name: source_name.into(),
200            node_spans: HashMap::new(),
201            node_files: HashMap::new(),
202            healing: Vec::new(),
203            trivia: Vec::new(),
204            source_style: Style::Brace,
205            includes: Vec::new(),
206            profiles: IndexMap::new(),
207            patches: Vec::new(),
208            schema: None,
209            annotations: HashMap::new(),
210            depends_on: HashMap::new(),
211        }
212    }
213
214    pub fn add_node(&mut self, node: Node) -> NodeId {
215        let id = NodeId(self.arena.len() as u32);
216        self.arena.push(node);
217        let idx = self.graph.add_node(id);
218        self.node_to_graph.insert(id, idx);
219        id
220    }
221
222    pub fn add_edge(&mut self, from: NodeId, to: NodeId) {
223        if let (Some(&f), Some(&t)) = (self.node_to_graph.get(&from), self.node_to_graph.get(&to)) {
224            // Edge direction: from dependency → dependent
225            // so topological order evaluates dependencies first
226            self.graph.add_edge(f, t, ());
227        }
228    }
229
230    pub fn get(&self, id: NodeId) -> Option<&Node> {
231        self.arena.get(id.0 as usize)
232    }
233
234    pub fn get_mut(&mut self, id: NodeId) -> Option<&mut Node> {
235        self.arena.get_mut(id.0 as usize)
236    }
237
238    /// Record the source span of a node for evaluation-time diagnostics.
239    pub fn record_span(&mut self, id: NodeId, span: Span) {
240        self.node_spans.insert(id, span);
241    }
242
243    /// Record the source file of a node (includes, C-025).
244    pub fn record_file(&mut self, id: NodeId, file: String) {
245        self.node_files.insert(id, file);
246    }
247
248    /// Dotted paths for value nodes walked from the document root
249    /// (`server.port`), plus binding names for `let` values. Unmapped
250    /// nodes (schema internals, constraint expressions, orphans) are
251    /// absent: callers skip what they cannot name rather than
252    /// guessing. Shared by the linter and the language server (C-043
253    /// hover/definition).
254    pub fn value_paths(&self) -> HashMap<NodeId, String> {
255        let mut paths = HashMap::new();
256        for (name, id) in &self.bindings {
257            paths.insert(*id, name.clone());
258        }
259        if let Some(root) = self.root {
260            let mut stack = vec![(root, String::new())];
261            while let Some((id, path)) = stack.pop() {
262                if let Some(Node::Table { fields }) = self.get(id) {
263                    for (key, child) in fields {
264                        let child_path = if path.is_empty() {
265                            key.clone()
266                        } else {
267                            format!("{path}.{key}")
268                        };
269                        paths.entry(*child).or_insert_with(|| child_path.clone());
270                        stack.push((*child, child_path));
271                    }
272                }
273            }
274        }
275        paths
276    }
277
278    /// Resolve a dotted path from the document root to a value-table
279    /// node (C-042): each segment indexes a `Node::Table`'s fields.
280    /// A single segment additionally resolves `let` bindings (table
281    /// fields win ties). Returns None for missing keys, non-table
282    /// hops, and a missing root. Shared by graph construction
283    /// (`depends_on` edges) and the linter (dangling-path detection),
284    /// so both agree on what resolves.
285    pub fn resolve_value_path(&self, path: &str) -> Option<NodeId> {
286        if let Some(root) = self.root {
287            let mut id = root;
288            let mut found = true;
289            for segment in path.split('.') {
290                match self.get(id) {
291                    Some(Node::Table { fields }) => match fields.get(segment) {
292                        Some(child) => id = *child,
293                        None => {
294                            found = false;
295                            break;
296                        }
297                    },
298                    _ => {
299                        found = false;
300                        break;
301                    }
302                }
303            }
304            if found {
305                return Some(id);
306            }
307        }
308        if !path.contains('.') {
309            return self.bindings.get(path).copied();
310        }
311        None
312    }
313}
314
315#[derive(Debug, Clone)]
316pub enum Node {
317    Literal(Value),
318    Env {
319        name: String,
320        default: Option<NodeId>,
321    },
322    Secret {
323        locator: NodeId,
324    },
325    Ref(NodeId),
326    Ident(String), // unresolved name, resolved during graph build
327    Interpolate {
328        parts: Vec<InterpPart>,
329    },
330    Binary {
331        op: BinOp,
332        lhs: NodeId,
333        rhs: NodeId,
334    },
335    Unary {
336        op: UnOp,
337        expr: NodeId,
338    },
339    Array {
340        elements: Vec<NodeId>,
341    },
342    Table {
343        fields: IndexMap<String, NodeId>,
344    },
345    Quantity {
346        value: NodeId,
347        unit: Unit,
348    },
349    ApplyConstraint {
350        target: NodeId,
351        constraint: Constraint,
352    },
353    /// A structural patch directive (spec §2.9, C-028): one operation
354    /// applied to the evaluated value tree after evaluation (and after
355    /// profile merge), before schema validation. The value expressions
356    /// are ordinary graph nodes, so patches are dependency-aware.
357    PatchDirective {
358        op: PatchOp,
359    },
360    /// One field's schema (spec §2.10, C-030). The constraint arguments
361    /// are expression nodes evaluated with the document; C-031 reads
362    /// their values during validation. Edges run argument → field.
363    SchemaField {
364        field: SchemaField,
365    },
366    /// A table of named schemas (spec §2.10). Fields hold SchemaField
367    /// or nested SchemaTable nodes. Extracted from value fields at
368    /// parse, so schema tables never export as data.
369    SchemaTable {
370        fields: IndexMap<String, NodeId>,
371        closed: bool,
372    },
373    /// An array element schema (spec §2.10): `element` is a SchemaField
374    /// node carrying the full element constraints.
375    SchemaArray {
376        element: NodeId,
377    },
378    /// A multi-line string carrying a semantic tag (`@prompt`, `@policy`,
379    /// `@template`). The tag is tool treatment only — evaluation is
380    /// transparent to the inner string value (spec §1.4.1).
381    Semantic {
382        tag: SemanticTag,
383        inner: NodeId,
384    },
385}
386
387/// Semantic-block tag for multi-line strings (spec §1.4.1).
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum SemanticTag {
390    Prompt,
391    Policy,
392    Template,
393}
394
395impl SemanticTag {
396    pub fn as_str(&self) -> &'static str {
397        match self {
398            SemanticTag::Prompt => "prompt",
399            SemanticTag::Policy => "policy",
400            SemanticTag::Template => "template",
401        }
402    }
403
404    pub fn parse_tag(s: &str) -> Option<Self> {
405        match s {
406            "prompt" => Some(SemanticTag::Prompt),
407            "policy" => Some(SemanticTag::Policy),
408            "template" => Some(SemanticTag::Template),
409            _ => None,
410        }
411    }
412}
413
414#[derive(Debug, Clone)]
415pub enum InterpPart {
416    Literal(String),
417    Expr(NodeId),
418}
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
421pub enum BinOp {
422    Add,
423    Sub,
424    Mul,
425    Div,
426    Eq,
427    Ne,
428    Lt,
429    Le,
430    Gt,
431    Ge,
432    And,
433    Or,
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
437pub enum UnOp {
438    Neg,
439    Not,
440}
441
442#[derive(Clone, Deserialize)]
443pub enum Value {
444    Null,
445    Bool(bool),
446    Int(i64),
447    Float(f64),
448    String(String),
449    DurationMs(u64),     // normalized to milliseconds for simplicity in alpha
450    Quantity(f64, Unit), // rate/custom units retain their dimension
451    Array(Vec<Value>),
452    Table(IndexMap<String, Value>),
453    Secret(String), // in production this is zeroized; here plain for demo
454}
455
456impl std::fmt::Debug for Value {
457    /// Redacting formatter (spec/secrets.md §3): secrets render as
458    /// `Secret(***)` at any depth, so generic `{:?}` logging,
459    /// assertion output, and error messages can never leak secret
460    /// material. Every other variant keeps its derived shape exactly.
461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462        match self {
463            Value::Secret(_) => write!(f, "Secret(***)"),
464            Value::Array(items) => {
465                write!(f, "[")?;
466                for (i, item) in items.iter().enumerate() {
467                    if i > 0 {
468                        write!(f, ", ")?;
469                    }
470                    write!(f, "{item:?}")?;
471                }
472                write!(f, "]")
473            }
474            Value::Table(map) => {
475                write!(f, "{{")?;
476                for (i, (k, val)) in map.iter().enumerate() {
477                    if i > 0 {
478                        write!(f, ", ")?;
479                    }
480                    write!(f, "{k:?}: {val:?}")?;
481                }
482                write!(f, "}}")
483            }
484            Value::Null => write!(f, "Null"),
485            Value::Bool(b) => write!(f, "{b:?}"),
486            Value::Int(i) => write!(f, "{i:?}"),
487            Value::Float(x) => write!(f, "{x:?}"),
488            Value::String(s) => write!(f, "{s:?}"),
489            Value::DurationMs(ms) => write!(f, "DurationMs({ms})"),
490            Value::Quantity(n, unit) => write!(f, "Quantity({n:?}, {unit:?})"),
491        }
492    }
493}
494
495impl Serialize for Value {
496    /// Redacting serializer (spec/secrets.md §3): serializing a Value
497    /// directly (`serde_json::to_string` and friends) can never emit
498    /// secret material — secrets become the export marker, mirroring
499    /// `convert::value_to_json` (kept in sync by hand; ir cannot depend
500    /// on convert). Deserialization stays derived: the input direction
501    /// cannot leak.
502    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
503        let json = match self {
504            Value::Null => serde_json::Value::Null,
505            Value::Bool(b) => serde_json::Value::from(*b),
506            Value::Int(i) => serde_json::Value::from(*i),
507            Value::Float(x) => serde_json::Value::from(*x),
508            Value::String(s) => serde_json::Value::from(s.clone()),
509            Value::DurationMs(ms) => serde_json::json!({ "ms": ms }),
510            Value::Quantity(n, unit) => serde_json::json!({ unit.suffix(): n }),
511            Value::Array(items) => serde_json::Value::Array(
512                items
513                    .iter()
514                    .map(|v| serde_json::to_value(v).unwrap_or(serde_json::Value::Null))
515                    .collect(),
516            ),
517            Value::Table(map) => {
518                let mut obj = serde_json::Map::new();
519                for (k, val) in map {
520                    obj.insert(
521                        k.clone(),
522                        serde_json::to_value(val).unwrap_or(serde_json::Value::Null),
523                    );
524                }
525                serde_json::Value::Object(obj)
526            }
527            Value::Secret(_) => serde_json::Value::from("***SECRET***"),
528        };
529        json.serialize(serializer)
530    }
531}
532
533impl Value {
534    pub fn as_str(&self) -> Option<&str> {
535        match self {
536            Value::String(s) => Some(s),
537            _ => None,
538        }
539    }
540
541    pub fn as_int(&self) -> Option<i64> {
542        match self {
543            Value::Int(i) => Some(*i),
544            _ => None,
545        }
546    }
547}
548
549#[derive(Debug, Clone)]
550pub enum PatchOp {
551    Set { path: String, value: NodeId },
552    Remove { path: String },
553    Rename { from: String, to: String },
554}