metrale_grammar/schema/
float_regex.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//
3// Float range -> regex generation — port of
4// `JSONSchemaConverter::{FormatFloat,GenerateFloatRangeRegex}` from
5// `cpp/json_schema_converter.cc`.
6
7use super::range_regex::generate_range_regex;
8
9/// Format a double to at most `precision` fractional digits, trimming
10/// trailing zeros. Port of `FormatFloat`.
11fn format_float(value: f64, precision: i32) -> String {
12    if value == (value as i64) as f64 {
13        return (value as i64).to_string();
14    }
15    let mut result = format!("{value:.*}", precision as usize);
16    if let Some(dot) = result.find('.') {
17        let trimmed_len = result.trim_end_matches('0').len();
18        if trimmed_len > dot {
19            result.truncate(trimmed_len);
20        } else if trimmed_len == dot {
21            result.truncate(dot);
22        }
23    }
24    result
25}
26
27/// Escape the regex-significant `.` in a formatted-float boundary
28/// string so it is treated as a literal decimal point rather than the
29/// wildcard. Port of `EscapeDotForRegex` (upstream commit c4cf39f, #642).
30fn escape_dot_for_regex(s: &str) -> String {
31    let mut result = String::with_capacity(s.len() + 2);
32    for c in s.chars() {
33        if c == '.' {
34            result.push_str("\\.");
35        } else {
36            result.push(c);
37        }
38    }
39    result
40}
41
42/// Strip the leading `^(` and trailing `)$` from an integer range
43/// regex so it can be embedded.
44fn strip_anchors(s: &str) -> String {
45    if s.len() >= 2 {
46        s[1..s.len() - 1].to_string()
47    } else {
48        s.to_string()
49    }
50}
51
52/// Generate a regex matching floats in `[start, end]` inclusive, each
53/// bound `None` meaning unbounded, with at most `precision`
54/// fractional digits. Port of `GenerateFloatRangeRegex`.
55pub fn generate_float_range_regex(start: Option<f64>, end: Option<f64>, precision: i32) -> String {
56    if let (Some(s), Some(e)) = (start, end)
57        && s > e
58    {
59        return "^()$".to_string();
60    }
61    if start.is_none() && end.is_none() {
62        return format!("^-?\\d+(\\.\\d{{1,{precision}}})?$");
63    }
64
65    let mut parts: Vec<String> = Vec::new();
66
67    let mut start_int = 0i64;
68    let mut end_int = 0i64;
69    let mut start_frac = 0.0;
70    let mut end_frac = 0.0;
71    let mut is_start_neg = false;
72    let mut is_end_neg = false;
73
74    if let Some(s) = start {
75        is_start_neg = s < 0.0;
76        start_int = s.floor() as i64;
77        start_frac = s - start_int as f64;
78    }
79    if let Some(e) = end {
80        is_end_neg = e < 0.0;
81        end_int = e.floor() as i64;
82        end_frac = e - end_int as f64;
83    }
84
85    match (start, end) {
86        (Some(s), None) => {
87            let s_str = format_float(s, precision);
88            // Escape the literal `.` so it is not the regex wildcard
89            // (upstream commit c4cf39f, #642).
90            parts.push(escape_dot_for_regex(&s_str));
91            if start_frac > 0.0 {
92                emit_frac_above(&mut parts, &s_str, is_start_neg, precision);
93            }
94            if start_int < i64::MAX - 1 {
95                let ir = strip_anchors(&generate_range_regex(Some(start_int + 1), None));
96                parts.push(format!("{ir}(\\.\\d{{1,{precision}}})?"));
97            }
98        }
99        (None, Some(e)) => {
100            let e_str = format_float(e, precision);
101            // Escape the literal `.` (upstream commit c4cf39f, #642).
102            parts.push(escape_dot_for_regex(&e_str));
103            if end_frac > 0.0 {
104                emit_frac_below(&mut parts, &e_str, is_end_neg, precision);
105            }
106            if end_int > i64::MIN + 1 {
107                let ir = strip_anchors(&generate_range_regex(None, Some(end_int - 1)));
108                parts.push(format!("{ir}(\\.\\d{{1,{precision}}})?"));
109            }
110        }
111        (Some(s), Some(e)) => {
112            let s_str = format_float(s, precision);
113            let e_str = format_float(e, precision);
114            if start_int == end_int {
115                if start_frac == 0.0 && end_frac == 0.0 {
116                    parts.push(start_int.to_string());
117                } else {
118                    // Escape literal `.` in float bounds (upstream c4cf39f, #642).
119                    parts.push(escape_dot_for_regex(&s_str));
120                    if s_str != e_str {
121                        parts.push(escape_dot_for_regex(&e_str));
122                    }
123                }
124            } else {
125                // Escape literal `.` in float bounds (upstream c4cf39f, #642).
126                parts.push(escape_dot_for_regex(&s_str));
127                if s_str != e_str {
128                    parts.push(escape_dot_for_regex(&e_str));
129                }
130                if end_int > start_int + 1 {
131                    let ir = strip_anchors(&generate_range_regex(
132                        Some(start_int + 1),
133                        Some(end_int - 1),
134                    ));
135                    parts.push(format!("{ir}(\\.\\d{{1,{precision}}})?"));
136                }
137                if start_frac > 0.0 {
138                    emit_frac_above(&mut parts, &s_str, is_start_neg, precision);
139                } else {
140                    parts.push(format!("{start_int}\\.\\d{{1,{precision}}}"));
141                }
142                if end_frac > 0.0 {
143                    emit_frac_below(&mut parts, &e_str, is_end_neg, precision);
144                } else {
145                    parts.push(format!("{end_int}\\.\\d{{1,{precision}}}"));
146                }
147            }
148        }
149        (None, None) => unreachable!(),
150    }
151
152    format!("^({})$", parts.join("|"))
153}
154
155/// Emit the patterns covering fractional values strictly greater than
156/// the lower bound's fraction (mirrors the C++ start-side loop).
157fn emit_frac_above(parts: &mut Vec<String>, num_str: &str, is_neg: bool, precision: i32) {
158    let dot = match num_str.find('.') {
159        Some(d) => d,
160        None => return,
161    };
162    let int_part = &num_str[..dot];
163    let frac: Vec<char> = num_str[dot + 1..].chars().collect();
164    for (i, &fc) in frac.iter().enumerate() {
165        if i == 0 {
166            if is_neg {
167                for d in '0'..fc {
168                    parts.push(format!("{int_part}\\.{d}\\d{{0,{}}}", precision - 1));
169                }
170            } else {
171                let mut d = (fc as u8 + 1) as char;
172                while d <= '9' {
173                    parts.push(format!("{int_part}\\.{d}\\d{{0,{}}}", precision - 1));
174                    d = (d as u8 + 1) as char;
175                }
176            }
177        } else {
178            let pref: String = frac[..i].iter().collect();
179            if is_neg {
180                if fc > '0' {
181                    for d in '0'..fc {
182                        parts.push(format!(
183                            "{int_part}\\.{pref}{d}\\d{{0,{}}}",
184                            precision - i as i32 - 1
185                        ));
186                    }
187                }
188            } else {
189                let mut d = (fc as u8 + 1) as char;
190                while d <= '9' {
191                    parts.push(format!(
192                        "{int_part}\\.{pref}{d}\\d{{0,{}}}",
193                        precision - i as i32 - 1
194                    ));
195                    d = (d as u8 + 1) as char;
196                }
197            }
198        }
199    }
200}
201
202/// Emit the patterns covering fractional values strictly less than
203/// the upper bound's fraction (mirrors the C++ end-side loop).
204fn emit_frac_below(parts: &mut Vec<String>, num_str: &str, is_neg: bool, precision: i32) {
205    let dot = match num_str.find('.') {
206        Some(d) => d,
207        None => return,
208    };
209    let int_part = &num_str[..dot];
210    let frac: Vec<char> = num_str[dot + 1..].chars().collect();
211    for (i, &fc) in frac.iter().enumerate() {
212        if i == 0 {
213            if is_neg {
214                let mut d = (fc as u8 + 1) as char;
215                while d <= '9' {
216                    parts.push(format!("{int_part}\\.{d}\\d{{0,{}}}", precision - 1));
217                    d = (d as u8 + 1) as char;
218                }
219            } else {
220                for d in '0'..fc {
221                    parts.push(format!("{int_part}\\.{d}\\d{{0,{}}}", precision - 1));
222                }
223            }
224        } else if is_neg {
225            let pref: String = frac[..i].iter().collect();
226            let mut d = (fc as u8 + 1) as char;
227            while d <= '9' {
228                parts.push(format!(
229                    "{int_part}\\.{pref}{d}\\d{{0,{}}}",
230                    precision - i as i32 - 1
231                ));
232                d = (d as u8 + 1) as char;
233            }
234        } else if fc > '0' {
235            let pref: String = frac[..i].iter().collect();
236            for d in '0'..fc {
237                parts.push(format!(
238                    "{int_part}\\.{pref}{d}\\d{{0,{}}}",
239                    precision - i as i32 - 1
240                ));
241            }
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn unbounded_float() {
252        assert_eq!(
253            generate_float_range_regex(None, None, 6),
254            r"^-?\d+(\.\d{1,6})?$"
255        );
256    }
257
258    #[test]
259    fn inverted_range_is_empty() {
260        assert_eq!(generate_float_range_regex(Some(5.0), Some(1.0), 6), "^()$");
261    }
262
263    #[test]
264    fn whole_number_format() {
265        assert_eq!(format_float(3.0, 6), "3");
266        assert_eq!(format_float(3.5, 6), "3.5");
267        assert_eq!(format_float(3.120000, 6), "3.12");
268    }
269
270    #[test]
271    fn bounded_produces_anchored_regex() {
272        let r = generate_float_range_regex(Some(1.0), Some(10.0), 6);
273        assert!(r.starts_with("^(") && r.ends_with(")$"));
274    }
275
276    #[test]
277    fn escape_dot_helper() {
278        assert_eq!(escape_dot_for_regex("0.5"), r"0\.5");
279        assert_eq!(escape_dot_for_regex("-3.125"), r"-3\.125");
280        assert_eq!(escape_dot_for_regex("42"), "42");
281    }
282
283    #[test]
284    fn float_boundary_dot_is_escaped_not_wildcard() {
285        // Regression for upstream c4cf39f (#642): the decimal point of a
286        // float boundary must be a literal `\.`, never an unescaped `.`
287        // wildcard that would accept `0,5` etc.
288        let r = generate_float_range_regex(Some(0.5), None, 6);
289        assert!(r.contains(r"0\.5"), "expected escaped boundary in {r}");
290        // No bare `.` may appear except as part of an escaped `\.`.
291        let bytes: Vec<char> = r.chars().collect();
292        for (i, &c) in bytes.iter().enumerate() {
293            if c == '.' {
294                assert!(i > 0 && bytes[i - 1] == '\\', "unescaped dot in {r}");
295            }
296        }
297    }
298
299    #[test]
300    fn float_boundary_dot_escaped_both_bounds() {
301        let r = generate_float_range_regex(Some(0.25), Some(0.75), 6);
302        assert!(r.contains(r"0\.25"), "expected escaped start in {r}");
303        assert!(r.contains(r"0\.75"), "expected escaped end in {r}");
304    }
305}