Skip to main content

gdck_format/
literal.rs

1//! Normalising the text of literal tokens.
2//!
3//! These are the style-guide rules that no amount of clever line breaking
4//! produces on its own, because they are about the spelling of a token rather
5//! than its placement.
6//!
7//! This module is public so the linter can report a badly spelled literal and
8//! offer the formatter's own rewrite as the fix. Two implementations that
9//! disagreed would show up as `gdck lint --fix` producing something
10//! `gdck format` then changed again.
11
12/// Rewrite a number literal to the style guide's spelling.
13///
14/// Two rules apply: hexadecimal letters are lowercase, and a float always has
15/// a digit on each side of the point. Digit separators are deliberately left
16/// alone — the guide suggests them for large numbers but calls the threshold a
17/// generality, so inserting or removing them is a judgement a formatter should
18/// not make.
19#[must_use]
20pub fn normalize_number(text: &str) -> String {
21    if let Some(rest) = strip_radix_prefix(text) {
22        let (prefix, digits) = text.split_at(text.len() - rest.len());
23        return format!(
24            "{}{}",
25            prefix.to_ascii_lowercase(),
26            digits.to_ascii_lowercase()
27        );
28    }
29
30    // Decimal or float. Split off any exponent before touching the point, so
31    // that `1.e5` gets the same treatment as `1.`.
32    // A radix prefix has already been handled, so any `e` here is an exponent.
33    let (mantissa, exponent) = match text.find(['e', 'E']) {
34        Some(index) => text.split_at(index),
35        None => (text, ""),
36    };
37
38    let mut mantissa = mantissa.to_string();
39    if mantissa.starts_with('.') {
40        mantissa.insert(0, '0');
41    }
42    if mantissa.ends_with('.') {
43        mantissa.push('0');
44    }
45    format!("{mantissa}{exponent}")
46}
47
48fn strip_radix_prefix(text: &str) -> Option<&str> {
49    let bytes = text.as_bytes();
50    if bytes.len() < 3 || bytes[0] != b'0' {
51        return None;
52    }
53    match bytes[1] {
54        b'x' | b'X' | b'b' | b'B' => Some(&text[2..]),
55        _ => None,
56    }
57}
58
59/// Rewrite a string literal to use the quote style that needs fewer escapes.
60///
61/// The guide prefers double quotes, allows single quotes when they avoid
62/// escapes, and prefers double quotes on a tie. Applies to plain strings and
63/// to the `&` and `^` prefixed `StringName` and `NodePath` forms, whose quoted
64/// part follows the same rules.
65///
66/// Raw and triple-quoted strings are returned unchanged: in a raw string the
67/// backslash is not reliably an escape, and a triple-quoted string may contain
68/// bare quotes whose meaning depends on position.
69#[must_use]
70pub fn normalize_string(text: &str) -> String {
71    let Some(quote_at) = text.find(['"', '\'']) else {
72        // `$Node/Path` and `%Unique` have no quoted part.
73        return text.to_string();
74    };
75    let (prefix, quoted) = text.split_at(quote_at);
76
77    if prefix.contains(['r', 'R']) {
78        return text.to_string();
79    }
80
81    let quote = quoted.as_bytes()[0] as char;
82    let triple = [quote; 3].iter().collect::<String>();
83    if quoted.starts_with(&triple) {
84        return text.to_string();
85    }
86
87    // An unterminated literal cannot appear in a tree the formatter accepts,
88    // but leaving it alone is cheaper than proving that here.
89    if quoted.len() < 2 || !quoted.ends_with(quote) {
90        return text.to_string();
91    }
92    let body = &quoted[1..quoted.len() - 1];
93
94    let units = split_units(body);
95    let doubles = units.iter().filter(|unit| represents(unit, '"')).count();
96    let singles = units.iter().filter(|unit| represents(unit, '\'')).count();
97
98    // Ties go to double quotes, which is what the guide asks for.
99    let target = if doubles <= singles { '"' } else { '\'' };
100
101    let mut out = String::with_capacity(text.len());
102    out.push_str(prefix);
103    out.push(target);
104    for unit in &units {
105        if represents(unit, target) {
106            out.push('\\');
107            out.push(target);
108        } else if represents(unit, '"') {
109            out.push('"');
110        } else if represents(unit, '\'') {
111            out.push('\'');
112        } else {
113            out.push_str(unit);
114        }
115    }
116    out.push(target);
117    out
118}
119
120/// Split a string body into escape sequences and single characters.
121///
122/// Keeping `\n` and friends as one unit is what lets the quote style change
123/// without disturbing any other escape.
124fn split_units(body: &str) -> Vec<&str> {
125    let mut units = Vec::new();
126    let mut chars = body.char_indices();
127    while let Some((start, c)) = chars.next() {
128        if c == '\\'
129            && let Some((next_start, next)) = chars.next()
130        {
131            units.push(&body[start..next_start + next.len_utf8()]);
132            continue;
133        }
134        units.push(&body[start..start + c.len_utf8()]);
135    }
136    units
137}
138
139/// Whether a unit is the given quote character, escaped or not.
140fn represents(unit: &str, quote: char) -> bool {
141    let bare = unit.len() == quote.len_utf8() && unit.starts_with(quote);
142    let escaped = unit.starts_with('\\') && unit.ends_with(quote) && unit.chars().count() == 2;
143    bare || escaped
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn floats_get_a_digit_on_both_sides() {
152        // The style guide's own good/bad pair.
153        assert_eq!(normalize_number(".234"), "0.234");
154        assert_eq!(normalize_number("13."), "13.0");
155        assert_eq!(normalize_number("0.234"), "0.234");
156        assert_eq!(normalize_number("13.0"), "13.0");
157    }
158
159    #[test]
160    fn hexadecimal_letters_are_lowercased() {
161        assert_eq!(normalize_number("0xFB8C0B"), "0xfb8c0b");
162        assert_eq!(normalize_number("0Xfb8c0b"), "0xfb8c0b");
163        assert_eq!(normalize_number("0xffff_f8f8_0000"), "0xffff_f8f8_0000");
164    }
165
166    #[test]
167    fn digit_separators_are_left_alone() {
168        assert_eq!(normalize_number("1_234_567_890"), "1_234_567_890");
169        assert_eq!(normalize_number("12345"), "12345");
170        assert_eq!(normalize_number("12_345"), "12_345");
171    }
172
173    #[test]
174    fn an_exponent_does_not_confuse_the_point_rules() {
175        assert_eq!(normalize_number("1.e5"), "1.0e5");
176        assert_eq!(normalize_number("1.5e-3"), "1.5e-3");
177    }
178
179    #[test]
180    fn binary_literals_keep_their_separators() {
181        assert_eq!(normalize_number("0b1101_0010_1010"), "0b1101_0010_1010");
182    }
183
184    #[test]
185    fn quote_choice_follows_the_style_guide_samples() {
186        // Every case in the guide's "Quotes" example.
187        assert_eq!(normalize_string(r#""hello world""#), r#""hello world""#);
188        assert_eq!(normalize_string(r#""hello 'world'""#), r#""hello 'world'""#);
189        assert_eq!(normalize_string(r#"'hello "world"'"#), r#"'hello "world"'"#);
190        assert_eq!(
191            normalize_string(r#""'hello' \"world\"""#),
192            r#""'hello' \"world\"""#
193        );
194    }
195
196    #[test]
197    fn single_quotes_become_double_when_that_costs_nothing() {
198        assert_eq!(normalize_string("'plain'"), r#""plain""#);
199        assert_eq!(normalize_string(r"'it\'s'"), r#""it's""#);
200    }
201
202    #[test]
203    fn double_quotes_become_single_when_that_removes_escapes() {
204        assert_eq!(normalize_string(r#""say \"hi\"""#), r#"'say "hi"'"#);
205    }
206
207    #[test]
208    fn a_tie_prefers_double_quotes() {
209        assert_eq!(normalize_string(r#"'\'a\' "b"'"#), r#""'a' \"b\"""#);
210    }
211
212    #[test]
213    fn other_escapes_survive_a_quote_change() {
214        assert_eq!(normalize_string(r"'a\nb\tc'"), r#""a\nb\tc""#);
215        assert_eq!(normalize_string(r"'\\'"), r#""\\""#);
216    }
217
218    #[test]
219    fn raw_and_triple_quoted_strings_are_left_alone() {
220        assert_eq!(normalize_string(r"r'raw'"), r"r'raw'");
221        assert_eq!(normalize_string(r"'''triple'''"), r"'''triple'''");
222        assert_eq!(normalize_string(r#""""triple""""#), r#""""triple""""#);
223    }
224
225    #[test]
226    fn prefixed_string_forms_keep_their_sigil() {
227        assert_eq!(normalize_string("&'name'"), r#"&"name""#);
228        assert_eq!(normalize_string("^'path'"), r#"^"path""#);
229        // A node path written without quotes has nothing to normalise.
230        assert_eq!(normalize_string("$Node/Path"), "$Node/Path");
231    }
232}