Skip to main content

papyri_lang/utils/
text.rs

1//! This module contains helper functions for text or string operations.
2
3use std::rc::Rc;
4use deunicode;
5use htmlentity::entity;
6use indexmap::IndexSet;
7
8/// Indicates whether the given string is a valid name, matching `[a-zA-Z_][a-zA-Z0-9_]*`.
9pub fn is_identifier(s: &str) -> bool {
10    let mut s_chars = s.chars();
11    matches!(s_chars.next(), Some(c) if is_ident_start(c))
12        && s_chars.all(is_ident_cont)
13}
14
15/// Indicates whether the given character is allowed as the first character of
16/// an identifier, i.e. whether it matches `[a-zA-Z_]`.
17pub fn is_ident_start(c: char) -> bool {
18    c.is_ascii_alphabetic() || c == '_'
19}
20
21/// Indicates whether the given character is allowed after the first character
22/// in an identifier, i.e. whether it matches `[a-zA-Z0-9_]`.
23pub fn is_ident_cont(c: char) -> bool {
24    c.is_ascii_alphanumeric() || c == '_'
25}
26
27/// Returns a usually-reasonable approximation of the given string, using only
28/// valid identifier characters. The result matches `[a-zA-Z_][a-zA-Z0-9_]*`
29/// and has length at most `max_len` (which should be at least 1).
30pub fn make_identifier(s: &str, max_len: usize) -> String {
31    let s = deunicode::deunicode(s);
32    let mut s_chars = s.chars();
33    let mut id = "".to_string();
34    match s_chars.next() {
35        Some(c) if c.is_ascii_alphabetic() => id.push(c),
36        _ => id.push('_'),
37    }
38    for c in s_chars {
39        if id.len() >= max_len {
40            break;
41        } else if c.is_ascii_alphanumeric() {
42            id.push(c);
43        } else if !id.ends_with('_') {
44            id += "_";
45        }
46    }
47    id
48}
49
50/// Indicates whether the given string contains any of the characters '*', '?',
51/// '\[' or '\]', suggesting it may be a glob pattern.
52pub fn looks_like_glob(s: &str) -> bool {
53    s.contains(|c| matches!(c, '*' | '?' | '[' | ']'))
54}
55
56/// Indicates whether the given string is all ASCII whitespace. A non-breaking
57/// space character is not ASCII whitespace.
58pub fn is_whitespace(s: &str) -> bool {
59    s.chars().all(|c| c.is_ascii_whitespace())
60}
61
62/// Returns either the empty string, or the string "s", to pluralise a word
63/// given a quantity.
64pub fn pluralise(quantity: u32) -> &'static str {
65    if quantity == 1 { "" } else { "s" }
66}
67
68/// Encodes the characters `<`, `>` and `&` in a string as HTML entities. If
69/// `escape_quotes` is true, the characters `'` and `"` are additionally encoded.
70pub fn encode_entities(s: &str, escape_quotes: bool) -> String {
71    entity::encode(
72        s,
73        if escape_quotes { entity::EntitySet::SpecialChars } else { entity::EntitySet::Html },
74        entity::EncodeType::NamedOrHex,
75    ).into_iter().collect()
76}
77
78/// Strips indentation from the start of each line of the given string. The
79/// indentation of the first line with any non-whitespace characters is removed
80/// from all lines. Leading and trailing whitespace of the whole string is also
81/// removed.
82pub fn fix_indentation(s: &str) -> String {
83    let mut indentation_to_remove: Option<&str> = None;
84    let mut out = "".to_string();
85    for line in s.trim_end().lines() {
86        match indentation_to_remove {
87            Some(indentation) => {
88                if let Some(stripped) = line.strip_prefix(indentation) {
89                    out += stripped;
90                } else {
91                    out += line.trim_start();
92                }
93                out += "\n";
94            },
95            None => {
96                if let Some((index, _)) = line.chars().enumerate().find(|(_, c)| !c.is_whitespace()) {
97                    // shortcut if there is no indentation
98                    if index == 0 { return s.trim().to_string(); }
99                    
100                    indentation_to_remove = Some(&line[..index]);
101                    out += &line[index..];
102                    out += "\n";
103                }
104            },
105        }
106    }
107    out
108}
109
110/// If the given source has multiple lines and the first line is a valid
111/// identifier, then this function returns a pair of that identifier and the
112/// remainder of the source with indentation stripped. Otherwise, the pair
113/// returned is the default and the whole source.
114pub fn get_source_language_hint<'a>(src: &'a str, default: &'a str) -> (&'a str, &'a str) {
115    let Some(k) = src.find('\n') else {
116        return (default, src);
117    };
118    
119    let first_line = src[..k].trim_end();
120    if is_identifier(first_line) {
121        (first_line, &src[k + 1..])
122    } else {
123        (default, src)
124    }
125}
126
127/// A generator of unique string IDs. It converts arbitrary strings into valid
128/// identifiers; IDs generated will be distinct until the `clear()` method is
129/// called.
130pub struct UniqueIDGenerator {
131    ids_used: IndexSet<Rc<str>, fxhash::FxBuildHasher>,
132}
133
134impl Default for UniqueIDGenerator {
135    fn default() -> UniqueIDGenerator {
136        UniqueIDGenerator::new()
137    }
138}
139
140impl UniqueIDGenerator {
141    /// Creates a new unique ID generator.
142    pub fn new() -> UniqueIDGenerator {
143        UniqueIDGenerator {
144            ids_used: IndexSet::default(),
145        }
146    }
147    
148    /// Clears this generator, allowing previously-issued IDs to be reused.
149    pub fn clear(&mut self) {
150        self.ids_used.clear();
151    }
152    
153    /// Returns a valid identifier based on the given `id_base` string, which
154    /// is distinct from all other identifiers returned by this generator since
155    /// the last call to `clear()`.
156    /// 
157    /// The identifier is lowercase, and its length is at most `max_len` unless
158    /// it is necessary to exceed that length to ensure uniqueness.
159    pub fn get_unique_id(&mut self, id_base: &str, max_len: usize) -> Rc<str> {
160        let mut id = if !is_identifier(id_base) {
161            make_identifier(id_base, max_len)
162        } else if id_base.len() > max_len {
163            id_base[..max_len].to_string()
164        } else {
165            id_base.to_string()
166        };
167        id.make_ascii_lowercase();
168        
169        let id: Rc<str> = Rc::from(id);
170        if self.ids_used.insert(id.clone()) { return id; }
171        
172        let mut id_base = id_base;
173        if id_base.len() + 2 > max_len && max_len >= 3 {
174            id_base = &id_base[..max_len - 2];
175        }
176        
177        let mut counter = 2;
178        let id: Rc<str> = loop {
179            let id = format!("{id_base}_{counter}");
180            if !self.ids_used.contains(id.as_str()) {
181                break Rc::from(id);
182            }
183            
184            counter += 1;
185            // check if we're about to overflow
186            if id.len() >= max_len && id_base.len() > 1 && id.trim_end_matches('9').ends_with('_') {
187                id_base = &id_base[..id_base.len() - 1];
188            }
189        };
190        
191        self.ids_used.insert(id.clone());
192        id
193    }
194}