Skip to main content

c12_parser/
format.rs

1use once_cell::sync::Lazy;
2use regex::Regex;
3
4/// Information about formatting (indentation and outer whitespace)
5/// captured from the original text.
6#[derive(Clone, Debug)]
7pub struct FormatInfo {
8    pub sample: Option<String>,
9    pub whitespace_start: String,
10    pub whitespace_end: String,
11}
12
13/// Options that control how formatting is detected and preserved.
14#[derive(Clone, Debug)]
15pub struct FormatOptions {
16    /// Explicit indent to use when stringifying. When `None`,
17    /// indentation is auto-detected from the original text (if enabled).
18    pub indent: Option<usize>,
19
20    /// If `false`, indentation from the original text will not be
21    /// auto-detected, even if a sample is present.
22    pub preserve_indentation: bool,
23
24    /// If `false`, leading and trailing whitespace around the value
25    /// will not be preserved.
26    pub preserve_whitespace: bool,
27
28    /// Number of characters to sample from the start of the text
29    /// when detecting indentation.
30    pub sample_size: usize,
31}
32
33impl Default for FormatOptions {
34    fn default() -> Self {
35        Self {
36            indent: None,
37            preserve_indentation: true,
38            preserve_whitespace: true,
39            sample_size: 1024,
40        }
41    }
42}
43
44pub(crate) fn detect_format(text: &str, opts: &FormatOptions) -> FormatInfo {
45    let sample = if opts.indent.is_none() && opts.preserve_indentation {
46        Some(text.chars().take(opts.sample_size).collect::<String>())
47    } else {
48        None
49    };
50
51    static START_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\s+)").unwrap());
52    static END_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(\s+)$").unwrap());
53
54    let (whitespace_start, whitespace_end) = if opts.preserve_whitespace {
55        let ws_start = START_RE
56            .captures(text)
57            .and_then(|c| c.get(0))
58            .map(|m| m.as_str().to_string())
59            .unwrap_or_default();
60        let ws_end = END_RE
61            .captures(text)
62            .and_then(|c| c.get(0))
63            .map(|m| m.as_str().to_string())
64            .unwrap_or_default();
65
66        (ws_start, ws_end)
67    } else {
68        (String::new(), String::new())
69    };
70
71    FormatInfo {
72        sample,
73        whitespace_start,
74        whitespace_end,
75    }
76}
77
78pub(crate) fn compute_indent(info: &FormatInfo, opts: &FormatOptions) -> usize {
79    if let Some(explicit) = opts.indent {
80        return explicit;
81    }
82
83    if let Some(sample) = &info.sample {
84        // Naive indent detection: find the first non-empty line and
85        // count its leading spaces.
86        for line in sample.lines() {
87            let trimmed = line.trim_start();
88            if trimmed.is_empty() {
89                continue;
90            }
91            let indent_len = line.len() - trimmed.len();
92            if indent_len > 0 {
93                return indent_len;
94            }
95        }
96    }
97
98    // Default indent size if nothing else is detected
99    2
100}
101
102/// Removes content from first occurrence of `prefix` (e.g. `#`, `//`) to EOL per line.
103/// Used for normalizing fixtures when comparing output that drops comments.
104#[allow(dead_code)]
105pub(crate) fn strip_line_comments(s: &str, prefix: &str) -> String {
106    let mut out = String::with_capacity(s.len());
107    for (i, line) in s.lines().enumerate() {
108        if i > 0 {
109            out.push('\n');
110        }
111        if let Some(pos) = line.find(prefix) {
112            out.push_str(&line[..pos]);
113        } else {
114            out.push_str(line);
115        }
116    }
117    out
118}
119
120/// A value bundled with its detected formatting information.
121#[derive(Clone, Debug)]
122pub struct Formatted<T> {
123    pub value: T,
124    pub format: FormatInfo,
125}
126
127impl<T> Formatted<T> {
128    pub fn new(text: &str, value: T, opts: &FormatOptions) -> Self {
129        let format = detect_format(text, opts);
130        Self { value, format }
131    }
132}
133
134/// Wraps content with the format's leading and trailing whitespace.
135pub(crate) fn wrap_whitespace(content: &str, info: &FormatInfo) -> String {
136    let cap = info.whitespace_start.len() + content.len() + info.whitespace_end.len();
137    let mut s = String::with_capacity(cap);
138    s.push_str(&info.whitespace_start);
139    s.push_str(content);
140    s.push_str(&info.whitespace_end);
141    s
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn detect_captures_outer_whitespace_and_sample() {
150        let text = "\n  {\"a\": 1}\n\n";
151        let opts = FormatOptions::default();
152        let info = detect_format(text, &opts);
153
154        // 由于使用的是基于正则的 `^(\s+)`,这里会把换行符和紧随其后的两个空格
155        // 一并视为“前导空白”捕获出来。
156        assert_eq!(info.whitespace_start, "\n  ");
157        assert_eq!(info.whitespace_end, "\n\n");
158        assert!(info.sample.is_some());
159        assert!(info.sample.as_ref().unwrap().contains("{\"a\": 1}"));
160    }
161
162    #[test]
163    fn detect_respects_preserve_flags() {
164        let text = "   {\"a\": 1}   ";
165        let mut opts = FormatOptions::default();
166        opts.preserve_whitespace = false;
167        opts.preserve_indentation = false;
168
169        let info = detect_format(text, &opts);
170        assert!(info.sample.is_none());
171        assert!(info.whitespace_start.is_empty());
172        assert!(info.whitespace_end.is_empty());
173    }
174
175    #[test]
176    fn compute_indent_prefers_explicit() {
177        let info = FormatInfo {
178            sample: Some("  key: 1".into()),
179            whitespace_start: String::new(),
180            whitespace_end: String::new(),
181        };
182        let mut opts = FormatOptions::default();
183        opts.indent = Some(4);
184
185        assert_eq!(compute_indent(&info, &opts), 4);
186    }
187
188    #[test]
189    fn compute_indent_detects_from_sample() {
190        let info = FormatInfo {
191            sample: Some("  key: 1\n    child: 2".into()),
192            whitespace_start: String::new(),
193            whitespace_end: String::new(),
194        };
195        let opts = FormatOptions::default();
196
197        assert_eq!(compute_indent(&info, &opts), 2);
198    }
199
200    #[test]
201    fn compute_indent_falls_back_to_default() {
202        let info = FormatInfo {
203            sample: Some("\n\n".into()),
204            whitespace_start: String::new(),
205            whitespace_end: String::new(),
206        };
207        let opts = FormatOptions::default();
208
209        assert_eq!(compute_indent(&info, &opts), 2);
210    }
211}