Skip to main content

noyalib/cst/
coerce.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Lossless schema-driven type coercion on the CST path.
5//!
6//! [`coerce_to_schema`] is the comment-preserving counterpart to
7//! [`crate::coerce_to_schema`]. Given a [`Document`] and a JSON
8//! Schema, it walks every type-mismatch the schema reports and
9//! rewrites the offending scalar via the same surgical
10//! [`Document::set`] machinery that powers
11//! [`crate::cst::format`] — only the touched scalar bytes change;
12//! comments, anchors, indentation, and sibling entries survive
13//! byte-faithfully.
14//!
15//! The library-level [`crate::coerce_to_schema`] keeps the simpler
16//! `&mut Value` shape; CLI tooling that needs to round-trip user
17//! files (`noyavalidate --schema --fix`) uses this lossless path
18//! instead.
19//!
20//! # Trade-offs
21//!
22//! - Only string → integer / number / boolean coercions are
23//!   supported (the safe directions, identical to
24//!   [`crate::coerce_to_schema`]).
25//! - The schema must be a JSON Schema 2020-12 document. Same
26//!   contract as [`crate::validate_against_schema`].
27//! - Fixed-point iteration: the function loops until no further
28//!   coercion applies (capped at 1024 iterations to bound work
29//!   on adversarial schemas).
30//!
31//! # Examples
32//!
33//! ```
34//! use noyalib::{from_str, Value};
35//! use noyalib::cst::{coerce_to_schema, parse_document};
36//!
37//! let schema: Value = from_str(
38//!     "type: object\nproperties:\n  port: { type: integer }\n",
39//! ).unwrap();
40//! let mut doc = parse_document("# inline comment\nport: \"8080\"\n").unwrap();
41//! let n = coerce_to_schema(&mut doc, &schema).unwrap();
42//! assert_eq!(n, 1);
43//! let after = doc.to_string();
44//! assert!(after.contains("# inline comment"), "comment must survive");
45//! assert!(after.contains("port: 8080"), "scalar must be coerced");
46//! assert!(!after.contains("\"8080\""), "quotes must be gone");
47//! ```
48
49use crate::cst::document::Document;
50use crate::error::{Error, Result};
51use crate::value::{Number, Value};
52
53/// Apply schema-driven type coercions to `doc` **lossless**ly:
54/// only the bytes of the coerced scalars are rewritten; comments,
55/// indentation, and sibling entries survive byte-faithfully.
56///
57/// Returns the number of coercions applied. `0` means the input
58/// already matches the schema (or no coercion is possible — e.g.
59/// `port: "abc"` against `type: integer`).
60///
61/// # Errors
62///
63/// - Schema does not parse as a valid JSON Schema 2020-12 document.
64/// - The CST `Document::set` operation rejects the spliced
65///   fragment (would only happen on adversarial schemas that
66///   request a coercion to a non-scalar type — guarded against
67///   here, but the error is surfaced if it slips through).
68pub fn coerce_to_schema(doc: &mut Document, schema: &Value) -> Result<usize> {
69    use jsonschema::JsonType;
70    use jsonschema::error::{TypeKind, ValidationErrorKind};
71
72    // Compile the schema once, through the same helper as
73    // `crate::coerce_to_schema`, so the two functions share their
74    // schema-acceptance contract: external `$ref` refused, linear-time
75    // patterns, bounded schema size.
76    let validator = crate::schema_validate::compile_for_coercion(schema, "cst::coerce_to_schema")?;
77
78    let mut applied: usize = 0;
79    let max_iterations = 1024;
80
81    for _ in 0..max_iterations {
82        // Snapshot the document as a Value so we can ask the
83        // validator which paths fail. Cloning the Value is `O(n)`
84        // but the loop terminates the moment no coercion applies,
85        // so the typical cost is one or two passes.
86        let value: Value = crate::parser::parse_one_value(&doc.to_string(), doc.config())?;
87        let instance_json = crate::schema_validate::value_to_json(&value)
88            .map_err(|e| Error::Parse(format!("cst::coerce_to_schema: value -> JSON: {e}")))?;
89
90        let mut applied_this_pass = false;
91        let mut targets: Vec<(String, JsonType)> = Vec::new();
92        for err in validator.iter_errors(&instance_json) {
93            if let ValidationErrorKind::Type {
94                kind: TypeKind::Single(target),
95            } = err.kind()
96            {
97                targets.push((err.instance_path().to_string(), *target));
98            }
99        }
100
101        for (json_pointer, target) in targets {
102            // Walk the value tree along the JSON pointer to
103            // discover whether each segment addresses a mapping
104            // key or a sequence index. The CST's `Document::entry`
105            // path syntax distinguishes them: keys join with `.`,
106            // indices appear as `[N]`.
107            let cst_path = match value_path_from_pointer(&value, &json_pointer) {
108                Some(p) => p,
109                None => continue, // path not reachable — skip
110            };
111
112            // Look up the current scalar; if it's not a string we
113            // cannot coerce (only safe direction is string → typed).
114            let current = match doc.entry(&cst_path).get() {
115                Some(s) => s.to_owned(),
116                None => continue,
117            };
118
119            // Strip surrounding quotes if present so we operate on
120            // the logical scalar value, not the source-form
121            // representation.
122            let logical = strip_quotes(&current);
123            let coerced = match coerce_logical(&logical, target) {
124                Some(v) => v,
125                None => continue,
126            };
127            if coerced == current {
128                continue;
129            }
130
131            doc.entry(&cst_path).set(&coerced)?;
132            applied += 1;
133            applied_this_pass = true;
134        }
135
136        if !applied_this_pass {
137            break;
138        }
139    }
140
141    Ok(applied)
142}
143
144/// Resolve an RFC 6901 JSON pointer (e.g. `"/server/port"`) into
145/// the dot/bracket path syntax `Document::entry` expects (e.g.
146/// `"server.port"`). Returns `None` if any segment is unreachable
147/// or if the addressed location is not a string scalar.
148fn value_path_from_pointer(root: &Value, pointer: &str) -> Option<String> {
149    let segments = parse_json_pointer(pointer);
150    if segments.is_empty() {
151        return None;
152    }
153    let mut cursor = root;
154    let mut out = String::new();
155    for seg in &segments {
156        match cursor {
157            Value::Mapping(m) => {
158                crate::path::push_key(&mut out, seg);
159                cursor = m.get(seg.as_str())?;
160            }
161            Value::Sequence(s) => {
162                let idx: usize = seg.parse().ok()?;
163                use core::fmt::Write as _;
164                let _ = write!(out, "[{idx}]");
165                cursor = s.get(idx)?;
166            }
167            _ => return None,
168        }
169    }
170    if !matches!(cursor, Value::String(_)) {
171        return None;
172    }
173    Some(out)
174}
175
176fn parse_json_pointer(s: &str) -> Vec<String> {
177    if s.is_empty() || s == "/" {
178        return Vec::new();
179    }
180    s.trim_start_matches('/')
181        .split('/')
182        .map(|seg| seg.replace("~1", "/").replace("~0", "~"))
183        .collect()
184}
185
186/// Strip a single layer of `'…'` or `"…"` quotes so the logical
187/// scalar value is exposed for parsing. YAML core-schema strings
188/// without quotes pass through unchanged.
189fn strip_quotes(s: &str) -> String {
190    let bytes = s.as_bytes();
191    if bytes.len() >= 2 {
192        let first = bytes[0];
193        let last = bytes[bytes.len() - 1];
194        if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
195            return s[1..s.len() - 1].to_owned();
196        }
197    }
198    s.to_owned()
199}
200
201/// Try to coerce a logical scalar string into the schema's
202/// expected type. Returns the YAML source-form of the coerced
203/// value if the parse succeeds; `None` otherwise (caller leaves
204/// the original source alone so a follow-up validate can surface
205/// the residue).
206fn coerce_logical(logical: &str, target: jsonschema::JsonType) -> Option<String> {
207    use jsonschema::JsonType;
208    let _ = Number::Integer(0); // ensure Number is in scope for future extension
209    match target {
210        JsonType::Integer => {
211            let n: i64 = logical.parse().ok()?;
212            Some(n.to_string())
213        }
214        JsonType::Number => {
215            let f: f64 = logical.parse().ok()?;
216            // Use the standard noyalib serializer for floats so the
217            // emitted form matches the rest of the document
218            // (`fast-float` / ryu when on, `core::fmt` otherwise).
219            crate::ser::to_string(&Value::Number(Number::Float(f)))
220                .ok()
221                .map(|s| s.trim().to_owned())
222        }
223        JsonType::Boolean => match logical {
224            "true" => Some("true".to_owned()),
225            "false" => Some("false".to_owned()),
226            _ => None,
227        },
228        _ => None,
229    }
230}