xcstrings_mcp/model/
specifier.rs1use 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 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}