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(¤t);
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}