Skip to main content

opy_rs/hir/
mod.rs

1//! Opy HIR v2 — the OPY semantic model owned by `opy-rs`.
2//!
3//! The wire contract is the `wright/opy-hir` protocol, major version 2
4//! (produced as `2.0.0`), specified in `docs/hir/opy-hir-v2.md`. This module provides the serde
5//! protocol types, envelope and structural validation, and a deterministic
6//! debug dump.
7//!
8//! Ingestion order follows the spec (§8): envelope identity/version first,
9//! then unknown-node-kind rejection, then deserialization, then invariant
10//! validation. Every failure is a structured [`HirError`].
11
12pub mod dump;
13pub mod error;
14pub mod types;
15mod validate;
16pub(crate) mod visit;
17
18pub use error::HirError;
19pub use types::{
20    Annotation, AnnotationArg, Declaration, DictEntry, DirectiveRecord, DirectiveValue, Event,
21    Expr, Generator, OptimizationState, Position, PreprocessingSnapshot, PreprocessingState,
22    Program, Protocol, Rule, RuleEntry, Settings, SettingsListElement, SettingsNode, SourceFile,
23    Span, Stmt, SwitchArm, TranslationState, default_var_index,
24};
25
26use serde_json::Value;
27
28/// Parse and validate an Opy HIR v2 payload from a JSON string.
29///
30/// Returns a structured [`HirError`] for malformed JSON, an unsupported
31/// protocol identity or major version, unknown node kinds, or invariant
32/// violations.
33pub fn parse_str(input: &str) -> Result<Program, HirError> {
34    let value: Value = serde_json::from_str(input)?;
35    parse_value(value)
36}
37
38/// Parse and validate an Opy HIR v2 payload from a JSON value.
39pub fn parse_value(value: Value) -> Result<Program, HirError> {
40    validate::check_envelope(&value)?;
41    validate::check_unknown_kinds(&value)?;
42    let program: Program = serde_json::from_value(value)?;
43    program.validate()?;
44    Ok(program)
45}
46
47impl Program {
48    /// Validate structural invariants of this program (spans, identifiers,
49    /// references). Envelope and node-kind checks are performed by
50    /// [`parse_str`]/[`parse_value`].
51    pub fn validate(&self) -> Result<(), HirError> {
52        validate::validate_program(self)
53    }
54
55    /// Render a deterministic debug dump suitable for tests and issue
56    /// reports.
57    pub fn dump(&self) -> String {
58        dump::dump(self)
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use serde_json::json;
65
66    use super::parse_value;
67    use super::validate::check_envelope;
68
69    #[test]
70    fn v2_envelope_is_accepted_and_v1_is_rejected_before_body_inspection() {
71        let v2 = json!({
72            "protocol": { "name": "wright/opy-hir", "version": "2.0.0" }
73        });
74        assert!(check_envelope(&v2).is_ok());
75
76        let v1 = json!({
77            "protocol": { "name": "wright/opy-hir", "version": "1.1.0" },
78            "rules": [{ "kind": "malformed-v1-body" }]
79        });
80        let error = check_envelope(&v1).expect_err("v1 payload must not enter the v2 parser");
81        assert_eq!(error.code(), "incompatible-protocol");
82    }
83
84    #[test]
85    fn expression_errors_precede_invalid_statement_structure() {
86        let payload = json!({
87            "protocol": { "name": "wright/opy-hir", "version": "2.0.0" },
88            "generator": { "name": "test", "version": "0", "frontend": "test" },
89            "files": [],
90            "rules": [{
91                "name": "test",
92                "event": { "name": "Ongoing - Global", "args": [] },
93                "conditions": [],
94                "actions": [{
95                    "kind": "delete",
96                    "target": { "kind": "globalVar", "name": "missing" }
97                }]
98            }]
99        });
100
101        let program: super::Program = serde_json::from_value(payload.clone()).unwrap();
102        assert_eq!(
103            program.validate().unwrap_err().code(),
104            "unresolved-reference"
105        );
106        assert_eq!(
107            parse_value(payload).unwrap_err().code(),
108            "unresolved-reference"
109        );
110    }
111
112    #[test]
113    fn unknown_conditional_condition_kind_preserves_unsupported_node_span() {
114        let error = parse_value(json!({
115            "protocol": { "name": "wright/opy-hir", "version": "2.0.0" },
116            "rules": [{
117                "event": { "args": [] },
118                "conditions": [],
119                "actions": [{
120                    "kind": "expr",
121                    "expr": {
122                        "kind": "conditional",
123                        "thenValue": { "kind": "number", "value": 1 },
124                        "condition": {
125                            "kind": "future-expression",
126                            "span": {
127                                "file": 0,
128                                "start": { "line": 4, "col": 12 },
129                                "end": { "line": 4, "col": 20 }
130                            }
131                        },
132                        "elseValue": { "kind": "number", "value": 0 }
133                    }
134                }]
135            }]
136        }))
137        .expect_err("unknown kinds nested in conditional conditions must be rejected");
138
139        assert_eq!(error.code(), "unsupported-node");
140        assert_eq!(error.message(), "unsupported node kind 'future-expression'");
141        assert_eq!(error.span().unwrap().start.line, 4);
142        assert_eq!(error.span().unwrap().start.col, 12);
143    }
144}