Skip to main content

xcstrings_mcp/model/
specifier.rs

1use std::sync::LazyLock;
2
3use regex::Regex;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7static SPECIFIER_RE: LazyLock<Regex> = LazyLock::new(|| {
8    Regex::new(r"%((\d+)\$)?[-+ 0#']*(\d+|\*)?(\.\d+|\.\*)?([hlqLzt]{0,2})[diouxXeEfgGaAcspn@]")
9        .expect("specifier regex is valid")
10});
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
13pub struct FormatSpecifier {
14    pub raw: String,
15    pub position: Option<u32>,
16    pub conversion: char,
17    pub length_modifier: Option<String>,
18}
19
20impl FormatSpecifier {
21    pub(crate) fn is_compatible_with(&self, other: &Self) -> bool {
22        self.conversion == other.conversion && self.length_modifier == other.length_modifier
23    }
24}
25
26pub(crate) fn extract_specifiers(text: &str) -> Vec<FormatSpecifier> {
27    // Replace %% with placeholder to avoid matching literal percent signs
28    let cleaned = text.replace("%%", "\x00\x00");
29
30    SPECIFIER_RE
31        .find_iter(&cleaned)
32        .map(|m| {
33            let raw = &text[m.start()..m.end()];
34            let caps = SPECIFIER_RE.captures(raw).expect("regex already matched");
35
36            let position = caps.get(2).and_then(|p| p.as_str().parse::<u32>().ok());
37
38            let conversion = raw
39                .chars()
40                .last()
41                .expect("regex guarantees at least one char");
42
43            let length_modifier = caps
44                .get(5)
45                .map(|lm| lm.as_str())
46                .filter(|s| !s.is_empty())
47                .map(|s| s.to_string());
48
49            FormatSpecifier {
50                raw: raw.to_string(),
51                position,
52                conversion,
53                length_modifier,
54            }
55        })
56        .collect()
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    #[test]
64    fn test_simple_specifier() {
65        let specs = extract_specifiers("%@");
66        assert_eq!(specs.len(), 1);
67        assert_eq!(specs[0].conversion, '@');
68        assert_eq!(specs[0].position, None);
69    }
70
71    #[test]
72    fn test_positional_specifier() {
73        let specs = extract_specifiers("%1$@");
74        assert_eq!(specs.len(), 1);
75        assert_eq!(specs[0].position, Some(1));
76        assert_eq!(specs[0].conversion, '@');
77    }
78
79    #[test]
80    fn test_multiple_specifiers() {
81        let specs = extract_specifiers("%1$@ has %2$lld items");
82        assert_eq!(specs.len(), 2);
83        assert_eq!(specs[0].position, Some(1));
84        assert_eq!(specs[0].conversion, '@');
85        assert_eq!(specs[1].position, Some(2));
86        assert_eq!(specs[1].conversion, 'd');
87        assert_eq!(specs[1].length_modifier, Some("ll".to_string()));
88    }
89
90    #[test]
91    fn test_percent_escape() {
92        let specs = extract_specifiers("100%% done");
93        assert!(specs.is_empty());
94    }
95
96    #[test]
97    fn test_float_specifier() {
98        let specs = extract_specifiers("%.2f");
99        assert_eq!(specs.len(), 1);
100        assert_eq!(specs[0].conversion, 'f');
101        assert_eq!(specs[0].position, None);
102    }
103
104    #[test]
105    fn test_complex() {
106        let specs = extract_specifiers("%1$@ has %2$lld (%.2f%%)");
107        assert_eq!(specs.len(), 3);
108        assert_eq!(specs[0].raw, "%1$@");
109        assert_eq!(specs[1].raw, "%2$lld");
110        assert_eq!(specs[2].raw, "%.2f");
111    }
112}