Skip to main content

music21/common/
stringtools.rs

1//! music21's `common.stringTools`: names recased and split, text compared
2//! and cleaned, and brackets matched.
3
4use unicode_normalization::UnicodeNormalization;
5use unicode_normalization::char::canonical_combining_class;
6
7use crate::error::{Error, Result};
8
9/// Whether Python's `str.isspace` counts a character as white space, which
10/// is Unicode's white space and the four ASCII separators below it.
11pub fn is_python_space(character: char) -> bool {
12    character.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&character)
13}
14
15/// A string as Python's `repr` writes it, in single quotes unless it holds
16/// one and no double quote.
17///
18/// ```
19/// use music21::common::stringtools::str_repr;
20///
21/// assert_eq!(str_repr("(\n"), "'(\\n'");
22/// assert_eq!(str_repr("it's"), "\"it's\"");
23/// ```
24pub fn str_repr(text: &str) -> String {
25    let quote = if text.contains('\'') && !text.contains('"') {
26        '"'
27    } else {
28        '\''
29    };
30    let mut written = String::new();
31    written.push(quote);
32    for character in text.chars() {
33        match character {
34            '\\' => written.push_str("\\\\"),
35            '\n' => written.push_str("\\n"),
36            '\r' => written.push_str("\\r"),
37            '\t' => written.push_str("\\t"),
38            _ if character == quote => {
39                written.push('\\');
40                written.push(character);
41            }
42            _ if character.is_control() || (is_python_space(character) && character != ' ') => {
43                let code = u32::from(character);
44                if code < 0x100 {
45                    written.push_str(&format!("\\x{code:02x}"));
46                } else if code < 0x10000 {
47                    written.push_str(&format!("\\u{code:04x}"));
48                } else {
49                    written.push_str(&format!("\\U{code:08x}"));
50                }
51            }
52            _ => written.push(character),
53        }
54    }
55    written.push(quote);
56    written
57}
58
59/// Whether two strings are the same once all their white space is taken
60/// out: music21's `whitespaceEqual`.
61///
62/// ```
63/// use music21::common::stringtools::whitespace_equal;
64///
65/// assert!(whitespace_equal("    hello \n there ", "hello there"));
66/// assert!(!whitespace_equal("    hello \n there ", " bye there "));
67/// ```
68pub fn whitespace_equal(a: &str, b: &str) -> bool {
69    a.chars()
70        .filter(|character| !is_python_space(*character))
71        .eq(b.chars().filter(|character| !is_python_space(*character)))
72}
73
74/// The characters of a string that are among `numbers`, and the rest,
75/// each in order: music21's `getNumFromStr`, whose `numbers` are the ten
76/// digits.
77///
78/// ```
79/// use music21::common::stringtools::get_num_from_str;
80///
81/// let (numbers, rest) = get_num_from_str("23a954Hello", "0123456789");
82/// assert_eq!((numbers.as_str(), rest.as_str()), ("23954", "aHello"));
83/// ```
84pub fn get_num_from_str(usr_str: &str, numbers: &str) -> (String, String) {
85    let mut found = String::new();
86    let mut remain = String::new();
87    for ch in usr_str.chars() {
88        if numbers.contains(ch) {
89            found.push(ch);
90        } else {
91            remain.push(ch);
92        }
93    }
94    (found, remain)
95}
96
97/// music21's `common.camelCaseToHyphen`: a camel-cased name as words joined
98/// by `replacement`, lower case. A capital that starts a run of lower-case
99/// letters is set apart first, then any capital following a lower-case
100/// letter or a digit, which is the pair of substitutions music21 makes.
101///
102/// ```
103/// use music21::common::stringtools::camel_case_to_hyphen;
104///
105/// assert_eq!(camel_case_to_hyphen("MovementName", '-'), "movement-name");
106/// assert_eq!(camel_case_to_hyphen("fileName", '_'), "file_name");
107/// ```
108pub fn camel_case_to_hyphen(text: &str, replacement: char) -> String {
109    let characters: Vec<char> = text.chars().collect();
110    let mut first = String::new();
111    let mut index = 0;
112    while index < characters.len() {
113        // The pattern's `.` is any character but a line break.
114        let starts_word = characters[index] != '\n'
115            && characters
116                .get(index + 1)
117                .is_some_and(char::is_ascii_uppercase)
118            && characters
119                .get(index + 2)
120                .is_some_and(char::is_ascii_lowercase);
121        if starts_word {
122            first.push(characters[index]);
123            first.push(replacement);
124            first.push(characters[index + 1]);
125            index += 2;
126            while let Some(letter) = characters
127                .get(index)
128                .filter(|letter| letter.is_ascii_lowercase())
129            {
130                first.push(*letter);
131                index += 1;
132            }
133        } else {
134            first.push(characters[index]);
135            index += 1;
136        }
137    }
138    let characters: Vec<char> = first.chars().collect();
139    let mut second = String::new();
140    let mut index = 0;
141    while index < characters.len() {
142        let joins = (characters[index].is_ascii_lowercase() || characters[index].is_ascii_digit())
143            && characters
144                .get(index + 1)
145                .is_some_and(char::is_ascii_uppercase);
146        second.push(characters[index]);
147        if joins {
148            second.push(replacement);
149            second.push(characters[index + 1]);
150            index += 2;
151        } else {
152            index += 1;
153        }
154    }
155    second.to_lowercase()
156}
157
158/// [`camel_case_to_hyphen`] with music21's checks on the replacement: one
159/// character, and not a capital, which the result would lose.
160///
161/// # Errors
162///
163/// A replacement of more or less than one character, or a capital.
164pub fn camel_case_to_hyphen_checked(text: &str, replacement: &str) -> Result<String> {
165    let mut characters = replacement.chars();
166    let (Some(character), None) = (characters.next(), characters.next()) else {
167        return Err(Error::Value(
168            "Replacement must be a single character.".to_string(),
169        ));
170    };
171    if !character.to_lowercase().eq([character]) {
172        return Err(Error::Value(
173            "Replacement cannot be an uppercase character.".to_string(),
174        ));
175    }
176    Ok(camel_case_to_hyphen(text, character))
177}
178
179/// A word with its first character in title case and the rest lowered, as
180/// Python's `str.capitalize` writes it.
181fn capitalize(word: &str) -> String {
182    let mut characters = word.chars();
183    let Some(first) = characters.next() else {
184        return String::new();
185    };
186    let title = match first {
187        'DŽ' | 'Dž' | 'dž' => "Dž".to_string(),
188        'LJ' | 'Lj' | 'lj' => "Lj".to_string(),
189        'NJ' | 'Nj' | 'nj' => "Nj".to_string(),
190        'DZ' | 'Dz' | 'dz' => "Dz".to_string(),
191        _ => first.to_uppercase().collect(),
192    };
193    title + &characters.as_str().to_lowercase()
194}
195
196/// A `replacement`-joined name, `movement-name`, as a camel-cased one,
197/// `movementName`: music21's `hyphenToCamelCase`.
198///
199/// ```
200/// use music21::common::stringtools::hyphen_to_camel_case;
201///
202/// assert_eq!(hyphen_to_camel_case("movement_name", "_")?, "movementName");
203/// assert_eq!(hyphen_to_camel_case("music-21", "-")?, "music21");
204/// # Ok::<(), music21::Error>(())
205/// ```
206///
207/// # Errors
208///
209/// An empty replacement, which nothing can be split at.
210pub fn hyphen_to_camel_case(text: &str, replacement: &str) -> Result<String> {
211    if replacement.is_empty() {
212        return Err(Error::Value("empty separator".to_string()));
213    }
214    let mut words = text.split(replacement);
215    let mut joined = words.next().unwrap_or_default().to_string();
216    for word in words {
217        joined.push_str(&capitalize(word));
218    }
219    Ok(joined)
220}
221
222/// A camel-cased name, or one of letters and numbers, spaced into words:
223/// music21's `spaceCamelCase`. A space goes before each capital and where
224/// numbers start or stop; each of `keep_together`, spaced out, is joined up
225/// again, and with `replace_underscore` an underscore is a space too.
226/// music21 keeps `PMFC` together where it is given nothing to keep.
227///
228/// ```
229/// use music21::common::stringtools::space_camel_case;
230///
231/// assert_eq!(space_camel_case("opus41no1", true, &["PMFC"]), "opus 41 no 1");
232/// assert_eq!(space_camel_case("PMFC22", true, &["PMFC"]), "PMFC 22");
233/// assert_eq!(space_camel_case("hello_myke", false, &["PMFC"]), "hello_myke");
234/// ```
235pub fn space_camel_case(text: &str, replace_underscore: bool, keep_together: &[&str]) -> String {
236    const NUMBERS: &str = "0123456789.";
237    let mut spaced = String::new();
238    let mut last_is_number = false;
239    for (index, character) in text.chars().enumerate() {
240        let is_number = NUMBERS.contains(character);
241        let first_number = is_number && !last_is_number;
242        let first_letter = !is_number && last_is_number;
243        if index > 0 && (character.is_uppercase() || first_number || first_letter) {
244            spaced.push(' ');
245        }
246        spaced.push(character);
247        last_is_number = is_number;
248    }
249    for kept in keep_together {
250        let letters: Vec<String> = kept.chars().map(String::from).collect();
251        spaced = spaced.replace(&letters.join(" "), kept);
252    }
253    if replace_underscore {
254        spaced = spaced.replace('_', " ");
255    }
256    spaced
257}
258
259/// The MD5 digest of some bytes, in lower-case hexadecimal: music21's
260/// `getMd5`, which hashes a string's UTF-8.
261///
262/// ```
263/// use music21::common::stringtools::md5_hex;
264///
265/// assert_eq!(md5_hex(b"test"), "098f6bcd4621d373cade4e832627b4f6");
266/// ```
267pub fn md5_hex(data: &[u8]) -> String {
268    const SHIFTS: [u32; 64] = [
269        7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5,
270        9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10,
271        15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
272    ];
273    let constants: Vec<u32> = (0..64)
274        .map(|index: i32| (f64::from(index + 1).sin().abs() * 4_294_967_296.0) as u32)
275        .collect();
276    let mut message = data.to_vec();
277    let bit_length = (data.len() as u64).wrapping_mul(8);
278    message.push(0x80);
279    while message.len() % 64 != 56 {
280        message.push(0);
281    }
282    message.extend_from_slice(&bit_length.to_le_bytes());
283    let mut state: [u32; 4] = [0x6745_2301, 0xefcd_ab89, 0x98ba_dcfe, 0x1032_5476];
284    for block in message.chunks(64) {
285        let words: Vec<u32> = block
286            .chunks(4)
287            .map(|word| u32::from_le_bytes([word[0], word[1], word[2], word[3]]))
288            .collect();
289        let [mut a, mut b, mut c, mut d] = state;
290        for index in 0..64 {
291            let (mixed, word) = match index / 16 {
292                0 => ((b & c) | (!b & d), index),
293                1 => ((d & b) | (!d & c), (5 * index + 1) % 16),
294                2 => (b ^ c ^ d, (3 * index + 5) % 16),
295                _ => (c ^ (b | !d), (7 * index) % 16),
296            };
297            let rotated = a
298                .wrapping_add(mixed)
299                .wrapping_add(constants[index])
300                .wrapping_add(words[word])
301                .rotate_left(SHIFTS[index]);
302            (a, d, c) = (d, c, b);
303            b = b.wrapping_add(rotated);
304        }
305        for (held, added) in state.iter_mut().zip([a, b, c, d]) {
306            *held = held.wrapping_add(added);
307        }
308    }
309    state
310        .iter()
311        .flat_map(|word| word.to_le_bytes())
312        .map(|byte| format!("{byte:02x}"))
313        .collect()
314}
315
316/// Text with its accents taken off: music21's `stripAccents`. Each
317/// character is decomposed for compatibility and its combining marks are
318/// dropped; `ß` becomes `ss` and curly quotes straight ones. A character
319/// with nothing to take off, like `音`, is left as it is.
320///
321/// ```
322/// use music21::common::stringtools::strip_accents;
323///
324/// assert_eq!(strip_accents("trés vite"), "tres vite");
325/// assert_eq!(strip_accents("Süss, “êtré”"), "Suss, \"etre\"");
326/// ```
327pub fn strip_accents(text: &str) -> String {
328    text.nfkd()
329        .collect::<String>()
330        .replace('ß', "ss")
331        .replace(['“', '”'], "\"")
332        .replace(['‘', '’'], "'")
333        .chars()
334        .filter(|character| canonical_combining_class(*character) == 0)
335        .collect()
336}
337
338/// A name made safe as a file name: accents stripped, anything else
339/// outside ASCII dropped, and what is not a letter, a digit, `_` or `-`
340/// made `_`, all but a four-character extension: music21's
341/// `normalizeFilename`.
342///
343/// ```
344/// use music21::common::stringtools::normalize_filename;
345///
346/// assert_eq!(
347///     normalize_filename("03-Niccolò all’lessandra.not really.xml"),
348///     "03-Niccolo_all_lessandra_not_really.xml"
349/// );
350/// ```
351pub fn normalize_filename(name: &str) -> String {
352    let characters: Vec<char> = name.chars().collect();
353    let length = characters.len();
354    let (stem, extension) = if length > 5 && characters[length - 4] == '.' {
355        (
356            characters[..length - 4].iter().collect::<String>(),
357            Some(characters[length - 4..].iter().collect::<String>()),
358        )
359    } else {
360        (name.to_string(), None)
361    };
362    let mut normalized: String = strip_accents(&stem)
363        .chars()
364        .filter(char::is_ascii)
365        .map(|character| {
366            if character.is_ascii_alphanumeric() || character == '_' || character == '-' {
367                character
368            } else {
369                '_'
370            }
371        })
372        .collect();
373    if let Some(extension) = extension {
374        normalized.push_str(&extension);
375    }
376    normalized
377}
378
379/// Text with ASCII's punctuation taken out: music21's
380/// `removePunctuation`.
381///
382/// ```
383/// use music21::common::stringtools::remove_punctuation;
384///
385/// assert_eq!(remove_punctuation("This, is! my (face)."), "This is my face");
386/// ```
387pub fn remove_punctuation(text: &str) -> String {
388    text.chars()
389        .filter(|character| !character.is_ascii_punctuation())
390        .collect()
391}
392
393/// A bracketed stretch of text that [`parentheses_match`] found: music21's
394/// `ParenthesesMatch`. Positions count characters.
395#[derive(Clone, Debug, PartialEq, Eq)]
396pub struct ParenthesesMatch {
397    /// Where the text inside starts, after the opening bracket.
398    pub start: usize,
399    /// Where the closing bracket starts.
400    pub end: usize,
401    /// The text between the brackets.
402    pub text: String,
403    /// The matches inside this one, in order.
404    pub nested: Vec<ParenthesesMatch>,
405}
406
407/// Every bracketed stretch of a string, each with those nested in it:
408/// music21's `parenthesesMatch`. A bracket after a backslash is not one,
409/// and either bracket may be longer than a character.
410///
411/// ```
412/// use music21::common::stringtools::parentheses_match;
413///
414/// let found = parentheses_match("[not [mix] very] [well.]", "[", "]")?;
415/// assert_eq!(found[0].text, "not [mix] very");
416/// assert_eq!(found[0].nested[0].text, "mix");
417/// assert_eq!((found[1].start, found[1].end), (18, 23));
418/// # Ok::<(), music21::Error>(())
419/// ```
420///
421/// # Errors
422///
423/// An empty bracket, or one left open or closed that was never opened.
424pub fn parentheses_match(text: &str, open: &str, close: &str) -> Result<Vec<ParenthesesMatch>> {
425    if open.is_empty() || close.is_empty() {
426        return Err(Error::Value(
427            "Neither open nor close can be empty.".to_string(),
428        ));
429    }
430    let characters: Vec<char> = text.chars().collect();
431    let open: Vec<char> = open.chars().collect();
432    let close: Vec<char> = close.chars().collect();
433    let at = |index: usize, bracket: &[char]| {
434        characters.get(index..index + bracket.len()) == Some(bracket)
435    };
436    let mut stack = vec![ParenthesesMatch {
437        start: 0,
438        end: 0,
439        text: String::new(),
440        nested: Vec::new(),
441    }];
442    let mut escaped = false;
443    let mut index = 0;
444    while index < characters.len() {
445        if !escaped && at(index, &open) {
446            stack.push(ParenthesesMatch {
447                start: index + open.len(),
448                end: 0,
449                text: String::new(),
450                nested: Vec::new(),
451            });
452            index += open.len();
453            continue;
454        }
455        if !escaped && at(index, &close) {
456            if stack.len() <= 1 {
457                return Err(Error::Value(format!(
458                    "Closing {} without {} at index {index}.",
459                    str_repr(&close.iter().collect::<String>()),
460                    str_repr(&open.iter().collect::<String>())
461                )));
462            }
463            let mut found = stack.pop().unwrap_or_else(|| unreachable!());
464            found.end = index;
465            found.text = characters[found.start..index].iter().collect();
466            if let Some(outer) = stack.last_mut() {
467                outer.nested.push(found);
468            }
469            index += close.len();
470            continue;
471        }
472        escaped = characters[index] == '\\' && !escaped;
473        index += 1;
474    }
475    if let Some(unclosed) = stack.get(1) {
476        return Err(Error::Value(format!(
477            "Opening {} at index {} was never closed",
478            str_repr(&open.iter().collect::<String>()),
479            unclosed.start - 1
480        )));
481    }
482    Ok(stack.pop().map(|outer| outer.nested).unwrap_or_default())
483}
484
485/// A debugging or warning message as music21's `common.formatStr` makes
486/// one: the parts joined by spaces, ending in a newline.
487///
488/// ```
489/// assert_eq!(music21::common::stringtools::format_str(&["test", "1", "2"]), "test 1 2\n");
490/// ```
491pub fn format_str<S: AsRef<str>>(parts: &[S]) -> String {
492    let joined: Vec<&str> = parts.iter().map(AsRef::as_ref).collect();
493    format!("{}\n", joined.join(" "))
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    /// music21's own examples for `camelCaseToHyphen`.
501    #[test]
502    fn camel_case_is_split_as_music21_splits_it() {
503        assert_eq!(camel_case_to_hyphen("movementName", '-'), "movement-name");
504        assert_eq!(
505            camel_case_to_hyphen("movementNameName", '_'),
506            "movement_name_name"
507        );
508        assert_eq!(camel_case_to_hyphen("fileName", '-'), "file-name");
509        assert_eq!(camel_case_to_hyphen("fileNameABC", '-'), "file-name-abc");
510        assert_eq!(camel_case_to_hyphen("Movement", '-'), "movement");
511        assert_eq!(camel_case_to_hyphen("SnapPizzicato", ' '), "snap pizzicato");
512        assert_eq!(camel_case_to_hyphen("OrganHeel", ' '), "organ heel");
513        assert_eq!(camel_case_to_hyphen("ABCd", '-'), "ab-cd");
514        assert_eq!(camel_case_to_hyphen("AbCdEf", '-'), "ab-cd-ef");
515        assert_eq!(camel_case_to_hyphen("x2Y", '-'), "x2-y");
516    }
517
518    #[test]
519    fn a_line_break_starts_no_word() {
520        assert_eq!(camel_case_to_hyphen("a\nBc", '-'), "a\nbc");
521    }
522
523    #[test]
524    fn replacements_music21_refuses() {
525        assert_eq!(
526            camel_case_to_hyphen_checked("fileName", "NotFound")
527                .unwrap_err()
528                .to_string(),
529            "Value error: Replacement must be a single character."
530        );
531        assert!(camel_case_to_hyphen_checked("fileName", "A").is_err());
532        assert_eq!(
533            camel_case_to_hyphen_checked("fileName", "_").unwrap(),
534            "file_name"
535        );
536        assert!(hyphen_to_camel_case("a-b", "").is_err());
537        assert_eq!(hyphen_to_camel_case("voice", "-").unwrap(), "voice");
538    }
539
540    #[test]
541    fn camel_case_is_spaced_as_music21_spaces_it() {
542        assert_eq!(
543            space_camel_case("thisIsATest", true, &["PMFC"]),
544            "this Is A Test"
545        );
546        assert_eq!(space_camel_case("movement3", true, &["PMFC"]), "movement 3");
547        assert_eq!(
548            space_camel_case("opus23402no219235", true, &["PMFC"]),
549            "opus 23402 no 219235"
550        );
551        assert_eq!(
552            space_camel_case("hello_myke", true, &["PMFC"]),
553            "hello myke"
554        );
555    }
556
557    #[test]
558    fn md5_of_rfc_1321s_examples() {
559        assert_eq!(md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e");
560        assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
561        let long =
562            b"12345678901234567890123456789012345678901234567890123456789012345678901234567890";
563        assert_eq!(md5_hex(long), "57edf4a22be3c955ac49da2e2107b67a");
564    }
565
566    #[test]
567    fn accents_come_off_and_the_rest_stays() {
568        assert_eq!(strip_accents("Muß"), "Muss");
569        assert_eq!(strip_accents("音楽"), "音楽");
570        assert!(whitespace_equal("a\u{1c}b", "ab"));
571    }
572
573    #[test]
574    fn brackets_matched_or_refused() {
575        let found = parentheses_match(
576            r"Bologne wrote (a (whole) (lot) \(of\)) sym\(ph(on)ies\) concertantes.",
577            "(",
578            ")",
579        )
580        .unwrap();
581        assert_eq!((found[0].start, found[0].end), (15, 37));
582        assert_eq!(found[0].nested.len(), 2);
583        assert_eq!(
584            (found[1].start, found[1].end, found[1].text.as_str()),
585            (47, 49, "on")
586        );
587        let found = parentheses_match(
588            "Did you eat <<beans>> today <<Pythagoreas<<?>>>>",
589            "<<",
590            ">>",
591        )
592        .unwrap();
593        assert_eq!(found[1].text, "Pythagoreas<<?>>");
594        assert_eq!(found[1].nested[0].start, 43);
595        let unclosed = parentheses_match("My (parentheses (sometimes (continue", "(", ")");
596        assert_eq!(
597            unclosed.unwrap_err().to_string(),
598            "Value error: Opening '(' at index 3 was never closed"
599        );
600        let unopened = parentheses_match("This is a <bad> example>", "<", ">");
601        assert_eq!(
602            unopened.unwrap_err().to_string(),
603            "Value error: Closing '>' without '<' at index 23."
604        );
605    }
606
607    #[test]
608    fn test_basic() {
609        let (nums, rest) = get_num_from_str("23a", "0123456789");
610        assert_eq!(nums, "23");
611        assert_eq!(rest, "a");
612    }
613
614    #[test]
615    fn test_multiple_numbers() {
616        let (nums, rest) = get_num_from_str("23a954Hello", "0123456789");
617        assert_eq!(nums, "23954");
618        assert_eq!(rest, "aHello");
619    }
620
621    #[test]
622    fn test_empty_string() {
623        let (nums, rest) = get_num_from_str("", "0123456789");
624        assert_eq!(nums, "");
625        assert_eq!(rest, "");
626    }
627
628    #[test]
629    fn test_no_numbers() {
630        let (nums, rest) = get_num_from_str("Hello", "0123456789");
631        assert_eq!(nums, "");
632        assert_eq!(rest, "Hello");
633    }
634}