Skip to main content

khive_text/
identifier.rs

1//! Script/identifier detection and splitting for code-aware tokenization.
2
3/// Returns true if `text` looks like a code identifier (no whitespace, ≥1 ASCII letter, structural boundary present).
4pub fn is_identifier(text: &str) -> bool {
5    if text.chars().any(|c| c.is_whitespace()) {
6        return false;
7    }
8    if !text.chars().any(|c| c.is_ascii_alphabetic()) {
9        return false;
10    }
11
12    let chars: Vec<char> = text.chars().collect();
13    let n = chars.len();
14
15    // Single-char identifiers: just a letter, no boundary possible
16    if n < 2 {
17        return false;
18    }
19
20    // Check for explicit separator characters
21    for &c in &chars {
22        if matches!(c, '_' | '-' | '/' | '.' | ':') {
23            return true;
24        }
25    }
26
27    // Check for camelCase (lowercase followed by uppercase) or digit-letter boundaries
28    for i in 0..n - 1 {
29        let a = chars[i];
30        let b = chars[i + 1];
31        if (a.is_ascii_lowercase() && b.is_ascii_uppercase())
32            || (a.is_ascii_alphabetic() && b.is_ascii_digit())
33            || (a.is_ascii_digit() && b.is_ascii_alphabetic())
34        {
35            return true;
36        }
37    }
38
39    false
40}
41
42/// Split `text` on separators (`_`, `-`, `.`, `/`, `::`) and camelCase/digit boundaries,
43/// returning lowercase parts of at least `min_part_len` characters.
44pub fn split_identifier(text: &str, min_part_len: usize) -> Vec<String> {
45    // Collect chars, then split on separators / boundaries
46    let chars: Vec<char> = text.chars().collect();
47    let mut parts: Vec<String> = Vec::new();
48    let mut current = String::new();
49
50    let n = chars.len();
51    let mut i = 0;
52    while i < n {
53        let c = chars[i];
54
55        // Skip separator characters
56        if matches!(c, '_' | '-' | '/' | '.') {
57            if !current.is_empty() {
58                parts.push(current.clone());
59                current.clear();
60            }
61            i += 1;
62            continue;
63        }
64
65        // Handle '::' as a two-char separator
66        if c == ':' && i + 1 < n && chars[i + 1] == ':' {
67            if !current.is_empty() {
68                parts.push(current.clone());
69                current.clear();
70            }
71            i += 2;
72            continue;
73        }
74        // Lone ':' — treat as separator too
75        if c == ':' {
76            if !current.is_empty() {
77                parts.push(current.clone());
78                current.clear();
79            }
80            i += 1;
81            continue;
82        }
83
84        // Detect camelCase: lower→upper boundary
85        if !current.is_empty() && c.is_ascii_uppercase() {
86            let prev = chars[i - 1];
87            if prev.is_ascii_lowercase() {
88                // e.g. camelCase: split before 'C'
89                parts.push(current.clone());
90                current.clear();
91            } else if prev.is_ascii_uppercase() && i + 1 < n && chars[i + 1].is_ascii_lowercase() {
92                // e.g. "GPTModel": push accumulated "GP", keep "T" for new part
93                parts.push(current.clone());
94                current.clear();
95            }
96        }
97
98        // Detect digit→letter and letter→digit transitions
99        if !current.is_empty() {
100            let prev = chars[i - 1];
101            if (prev.is_ascii_digit() && c.is_ascii_alphabetic())
102                || (prev.is_ascii_alphabetic() && c.is_ascii_digit())
103            {
104                parts.push(current.clone());
105                current.clear();
106            }
107        }
108
109        current.push(c);
110        i += 1;
111    }
112
113    if !current.is_empty() {
114        parts.push(current);
115    }
116
117    // Lowercase and filter by min_part_len (character count, not byte length)
118    let min_part_len = min_part_len.max(1);
119    parts
120        .into_iter()
121        .map(|p| p.to_lowercase())
122        .filter(|p| p.chars().count() >= min_part_len)
123        .collect()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    // --- is_identifier ---
131
132    #[test]
133    fn camel_case_is_identifier() {
134        assert!(is_identifier("camelCase"));
135        assert!(is_identifier("myVariableName"));
136    }
137
138    #[test]
139    fn snake_case_is_identifier() {
140        assert!(is_identifier("snake_case"));
141        assert!(is_identifier("MY_CONST"));
142    }
143
144    #[test]
145    fn hyphenated_is_identifier() {
146        assert!(is_identifier("kebab-case"));
147        assert!(is_identifier("GPT-4"));
148    }
149
150    #[test]
151    fn path_separators_are_identifier() {
152        assert!(is_identifier("a/b"));
153        assert!(is_identifier("pkg::Type"));
154    }
155
156    #[test]
157    fn plain_word_is_not_identifier() {
158        // "plain" has no boundary — single run of lowercase, no separator
159        assert!(!is_identifier("plain"));
160    }
161
162    #[test]
163    fn has_whitespace_is_not_identifier() {
164        assert!(!is_identifier("hello world"));
165        assert!(!is_identifier("foo bar"));
166    }
167
168    #[test]
169    fn no_ascii_letter_not_identifier() {
170        assert!(!is_identifier("123"));
171        assert!(!is_identifier("_"));
172    }
173
174    // --- split_identifier ---
175
176    #[test]
177    fn camel_case_split() {
178        assert_eq!(split_identifier("camelCase", 1), vec!["camel", "case"]);
179    }
180
181    #[test]
182    fn snake_case_split() {
183        assert_eq!(split_identifier("snake_case", 1), vec!["snake", "case"]);
184    }
185
186    #[test]
187    fn gpt4_split() {
188        assert_eq!(split_identifier("GPT-4", 1), vec!["gpt", "4"]);
189    }
190
191    #[test]
192    fn lora_split() {
193        assert_eq!(split_identifier("LoRA", 1), vec!["lo", "ra"]);
194    }
195
196    #[test]
197    fn plain_single_part() {
198        assert_eq!(split_identifier("plain", 1), vec!["plain"]);
199    }
200
201    #[test]
202    fn bm25f_split() {
203        // "BM25F": B+M (uppercase run), 2+5 (digits), F (letter)
204        let parts = split_identifier("BM25F", 1);
205        assert_eq!(parts, vec!["bm", "25", "f"]);
206    }
207
208    #[test]
209    fn double_colon_separator() {
210        assert_eq!(split_identifier("pkg::Type", 1), vec!["pkg", "type"]);
211    }
212
213    #[test]
214    fn dot_separator() {
215        assert_eq!(
216            split_identifier("com.example.Foo", 1),
217            vec!["com", "example", "foo"]
218        );
219    }
220
221    #[test]
222    fn slash_separator() {
223        assert_eq!(split_identifier("a/b/c", 1), vec!["a", "b", "c"]);
224    }
225
226    #[test]
227    fn min_part_len_filters_short() {
228        // "GPT-4": parts ["gpt", "4"] — "4" has len 1, filtered when min=2
229        let parts = split_identifier("GPT-4", 2);
230        assert_eq!(parts, vec!["gpt"]);
231    }
232
233    #[test]
234    fn split_identifier_filters_unicode_min_part_len_by_chars() {
235        // "\u{4F60}" (你) is 1 char but 3 UTF-8 bytes; must be filtered by
236        // char count, not byte length, when min_part_len == 2.
237        let parts = split_identifier("foo_\u{4F60}", 2);
238        assert_eq!(parts, vec!["foo"]);
239
240        // A two-character non-ASCII part is kept at min_part_len == 2.
241        let parts = split_identifier("foo_\u{4F60}\u{597D}", 2);
242        assert_eq!(parts, vec!["foo", "\u{4F60}\u{597D}"]);
243    }
244
245    #[test]
246    fn mixed_case_and_digits() {
247        // "v2Api" -> ["v", "2", "api"] with min=1; min=2 drops "v" and "2"
248        let parts = split_identifier("v2Api", 2);
249        assert_eq!(parts, vec!["api"]);
250    }
251
252    #[test]
253    fn all_uppercase_run_then_lower() {
254        // "XMLParser" -> "XML" split before "P" (uppercase before lowercase)
255        // => ["xml", "parser"]
256        let parts = split_identifier("XMLParser", 1);
257        assert_eq!(parts, vec!["xml", "parser"]);
258    }
259}