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}