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
118    parts
119        .into_iter()
120        .map(|p| p.to_lowercase())
121        .filter(|p| p.len() >= min_part_len.max(1))
122        .collect()
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    // --- is_identifier ---
130
131    #[test]
132    fn camel_case_is_identifier() {
133        assert!(is_identifier("camelCase"));
134        assert!(is_identifier("myVariableName"));
135    }
136
137    #[test]
138    fn snake_case_is_identifier() {
139        assert!(is_identifier("snake_case"));
140        assert!(is_identifier("MY_CONST"));
141    }
142
143    #[test]
144    fn hyphenated_is_identifier() {
145        assert!(is_identifier("kebab-case"));
146        assert!(is_identifier("GPT-4"));
147    }
148
149    #[test]
150    fn path_separators_are_identifier() {
151        assert!(is_identifier("a/b"));
152        assert!(is_identifier("pkg::Type"));
153    }
154
155    #[test]
156    fn plain_word_is_not_identifier() {
157        // "plain" has no boundary — single run of lowercase, no separator
158        assert!(!is_identifier("plain"));
159    }
160
161    #[test]
162    fn has_whitespace_is_not_identifier() {
163        assert!(!is_identifier("hello world"));
164        assert!(!is_identifier("foo bar"));
165    }
166
167    #[test]
168    fn no_ascii_letter_not_identifier() {
169        assert!(!is_identifier("123"));
170        assert!(!is_identifier("_"));
171    }
172
173    // --- split_identifier ---
174
175    #[test]
176    fn camel_case_split() {
177        assert_eq!(split_identifier("camelCase", 1), vec!["camel", "case"]);
178    }
179
180    #[test]
181    fn snake_case_split() {
182        assert_eq!(split_identifier("snake_case", 1), vec!["snake", "case"]);
183    }
184
185    #[test]
186    fn gpt4_split() {
187        assert_eq!(split_identifier("GPT-4", 1), vec!["gpt", "4"]);
188    }
189
190    #[test]
191    fn lora_split() {
192        assert_eq!(split_identifier("LoRA", 1), vec!["lo", "ra"]);
193    }
194
195    #[test]
196    fn plain_single_part() {
197        assert_eq!(split_identifier("plain", 1), vec!["plain"]);
198    }
199
200    #[test]
201    fn bm25f_split() {
202        // "BM25F": B+M (uppercase run), 2+5 (digits), F (letter)
203        let parts = split_identifier("BM25F", 1);
204        assert_eq!(parts, vec!["bm", "25", "f"]);
205    }
206
207    #[test]
208    fn double_colon_separator() {
209        assert_eq!(split_identifier("pkg::Type", 1), vec!["pkg", "type"]);
210    }
211
212    #[test]
213    fn dot_separator() {
214        assert_eq!(
215            split_identifier("com.example.Foo", 1),
216            vec!["com", "example", "foo"]
217        );
218    }
219
220    #[test]
221    fn slash_separator() {
222        assert_eq!(split_identifier("a/b/c", 1), vec!["a", "b", "c"]);
223    }
224
225    #[test]
226    fn min_part_len_filters_short() {
227        // "GPT-4": parts ["gpt", "4"] — "4" has len 1, filtered when min=2
228        let parts = split_identifier("GPT-4", 2);
229        assert_eq!(parts, vec!["gpt"]);
230    }
231
232    #[test]
233    fn mixed_case_and_digits() {
234        // "v2Api" -> ["v", "2", "api"] with min=1; min=2 drops "v" and "2"
235        let parts = split_identifier("v2Api", 2);
236        assert_eq!(parts, vec!["api"]);
237    }
238
239    #[test]
240    fn all_uppercase_run_then_lower() {
241        // "XMLParser" -> "XML" split before "P" (uppercase before lowercase)
242        // => ["xml", "parser"]
243        let parts = split_identifier("XMLParser", 1);
244        assert_eq!(parts, vec!["xml", "parser"]);
245    }
246}