reovim_plugin_completion/source/
buffer.rs

1//! Buffer words completion source
2//!
3//! Provides completion candidates from words in the current buffer.
4//! Updated to implement the new SourceSupport trait.
5
6#![allow(dead_code)] // Methods are part of public API
7
8use std::{collections::HashSet, future::Future, pin::Pin};
9
10use reovim_core::completion::{CompletionContext, CompletionItem};
11
12use crate::registry::SourceSupport;
13
14/// Completion source that provides words from the current buffer
15pub struct BufferWordsSource {
16    /// Minimum word length to include
17    min_word_length: usize,
18}
19
20impl BufferWordsSource {
21    /// Create a new buffer words source
22    #[must_use]
23    pub const fn new() -> Self {
24        Self { min_word_length: 2 }
25    }
26
27    /// Set minimum word length
28    #[must_use]
29    pub const fn with_min_word_length(mut self, len: usize) -> Self {
30        self.min_word_length = len;
31        self
32    }
33
34    /// Check if a character is a word character
35    fn is_word_char(ch: char) -> bool {
36        ch.is_alphanumeric() || ch == '_'
37    }
38
39    /// Extract words from buffer content, excluding the word at current position
40    fn extract_words(
41        &self,
42        content: &str,
43        current_row: u32,
44        word_start_col: u32,
45        current_col: u32,
46    ) -> Vec<String> {
47        let mut words = HashSet::new();
48
49        for (line_idx, line) in content.lines().enumerate() {
50            let chars: Vec<char> = line.chars().collect();
51            let mut i = 0;
52
53            while i < chars.len() {
54                // Skip non-word characters
55                if !Self::is_word_char(chars[i]) {
56                    i += 1;
57                    continue;
58                }
59
60                // Found start of a word
61                let word_start = i;
62                while i < chars.len() && Self::is_word_char(chars[i]) {
63                    i += 1;
64                }
65                let word_end = i;
66
67                // Check if this is the word being typed (skip it)
68                // Only exclude if we're actually typing something (word_start_col < current_col)
69                let is_current_word = word_start_col < current_col
70                    && line_idx == current_row as usize
71                    && word_start <= word_start_col as usize
72                    && word_end >= current_col as usize;
73
74                if !is_current_word {
75                    let word: String = chars[word_start..word_end].iter().collect();
76                    if word.len() >= self.min_word_length {
77                        words.insert(word);
78                    }
79                }
80            }
81        }
82
83        let mut result: Vec<_> = words.into_iter().collect();
84        result.sort();
85        result
86    }
87}
88
89impl Default for BufferWordsSource {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95impl SourceSupport for BufferWordsSource {
96    fn source_id(&self) -> &'static str {
97        "buffer_words"
98    }
99
100    fn priority(&self) -> u32 {
101        100 // Default priority for buffer source
102    }
103
104    fn complete<'a>(
105        &'a self,
106        ctx: &'a CompletionContext,
107        content: &'a str,
108    ) -> Pin<Box<dyn Future<Output = Vec<CompletionItem>> + Send + 'a>> {
109        Box::pin(async move {
110            let words =
111                self.extract_words(content, ctx.cursor_row, ctx.word_start_col, ctx.cursor_col);
112
113            words
114                .into_iter()
115                .map(|word| CompletionItem::new(word, self.source_id()))
116                .collect()
117        })
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn make_context(prefix: &str, row: u32, col: u32, word_start: u32) -> CompletionContext {
126        CompletionContext::new(0, row, col, String::new(), prefix.to_string(), word_start)
127    }
128
129    #[tokio::test]
130    async fn test_extract_words_basic() {
131        let source = BufferWordsSource::new();
132        let content = "hello world foo bar";
133        let ctx = make_context("", 0, 0, 0);
134
135        let items = source.complete(&ctx, content).await;
136        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
137
138        assert!(labels.contains(&"hello"));
139        assert!(labels.contains(&"world"));
140        assert!(labels.contains(&"foo"));
141        assert!(labels.contains(&"bar"));
142    }
143
144    #[tokio::test]
145    async fn test_extract_words_multiline() {
146        let source = BufferWordsSource::new();
147        let content = "first line\nsecond line\nthird line";
148        let ctx = make_context("", 0, 0, 0);
149
150        let items = source.complete(&ctx, content).await;
151        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
152
153        assert!(labels.contains(&"first"));
154        assert!(labels.contains(&"second"));
155        assert!(labels.contains(&"third"));
156        assert!(labels.contains(&"line"));
157    }
158
159    #[tokio::test]
160    async fn test_excludes_current_word() {
161        let source = BufferWordsSource::new();
162        let content = "hello hel world";
163        // Cursor is at position after "hel" (the word being typed)
164        let ctx = make_context("hel", 0, 9, 6);
165
166        let items = source.complete(&ctx, content).await;
167        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
168
169        assert!(labels.contains(&"hello"));
170        assert!(labels.contains(&"world"));
171        // Should not contain "hel" as that's the word being typed
172    }
173
174    #[tokio::test]
175    async fn test_min_word_length() {
176        let source = BufferWordsSource::new().with_min_word_length(4);
177        let content = "a ab abc abcd abcde";
178        let ctx = make_context("", 0, 0, 0);
179
180        let items = source.complete(&ctx, content).await;
181        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
182
183        assert!(!labels.contains(&"a"));
184        assert!(!labels.contains(&"ab"));
185        assert!(!labels.contains(&"abc"));
186        assert!(labels.contains(&"abcd"));
187        assert!(labels.contains(&"abcde"));
188    }
189
190    #[tokio::test]
191    async fn test_deduplicates_words() {
192        let source = BufferWordsSource::new();
193        let content = "hello hello hello world world";
194        let ctx = make_context("", 0, 0, 0);
195
196        let items = source.complete(&ctx, content).await;
197
198        // Should only have unique words
199        assert_eq!(items.len(), 2);
200    }
201
202    #[tokio::test]
203    async fn test_handles_underscores() {
204        let source = BufferWordsSource::new();
205        let content = "snake_case camelCase _private __dunder__";
206        let ctx = make_context("", 0, 0, 0);
207
208        let items = source.complete(&ctx, content).await;
209        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
210
211        assert!(labels.contains(&"snake_case"));
212        assert!(labels.contains(&"camelCase"));
213        assert!(labels.contains(&"_private"));
214        assert!(labels.contains(&"__dunder__"));
215    }
216
217    #[tokio::test]
218    async fn test_handles_numbers() {
219        let source = BufferWordsSource::new();
220        let content = "var1 var2 123 abc123";
221        let ctx = make_context("", 0, 0, 0);
222
223        let items = source.complete(&ctx, content).await;
224        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
225
226        assert!(labels.contains(&"var1"));
227        assert!(labels.contains(&"var2"));
228        assert!(labels.contains(&"123"));
229        assert!(labels.contains(&"abc123"));
230    }
231
232    #[tokio::test]
233    async fn test_empty_buffer() {
234        let source = BufferWordsSource::new();
235        let content = "";
236        let ctx = make_context("", 0, 0, 0);
237
238        let items = source.complete(&ctx, content).await;
239        assert!(items.is_empty());
240    }
241
242    #[tokio::test]
243    async fn test_whitespace_only_buffer() {
244        let source = BufferWordsSource::new();
245        let content = "   \n\t\n   ";
246        let ctx = make_context("", 0, 0, 0);
247
248        let items = source.complete(&ctx, content).await;
249        assert!(items.is_empty());
250    }
251
252    #[tokio::test]
253    async fn test_unicode_words() {
254        let source = BufferWordsSource::new();
255        let content = "日本語 hello 中文 world こんにちは";
256        let ctx = make_context("", 0, 0, 0);
257
258        let items = source.complete(&ctx, content).await;
259        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
260
261        assert!(labels.contains(&"日本語"));
262        assert!(labels.contains(&"hello"));
263        assert!(labels.contains(&"中文"));
264        assert!(labels.contains(&"world"));
265        assert!(labels.contains(&"こんにちは"));
266    }
267
268    #[tokio::test]
269    async fn test_unicode_mixed_with_ascii() {
270        let source = BufferWordsSource::new();
271        let content = "变量名1 variable2 函数名_test";
272        let ctx = make_context("", 0, 0, 0);
273
274        let items = source.complete(&ctx, content).await;
275        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
276
277        assert!(labels.contains(&"变量名1"));
278        assert!(labels.contains(&"variable2"));
279        assert!(labels.contains(&"函数名_test"));
280    }
281
282    #[tokio::test]
283    async fn test_emoji_handling() {
284        let source = BufferWordsSource::new();
285        // Emojis should break word boundaries
286        let content = "hello🎉world test";
287        let ctx = make_context("", 0, 0, 0);
288
289        let items = source.complete(&ctx, content).await;
290        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
291
292        // "hello" and "world" should be separate words (emoji breaks them)
293        assert!(labels.contains(&"hello"));
294        assert!(labels.contains(&"world"));
295        assert!(labels.contains(&"test"));
296    }
297
298    #[tokio::test]
299    async fn test_very_long_line() {
300        let source = BufferWordsSource::new();
301        // Create a very long line with many words
302        let words: Vec<String> = (0..1000).map(|i| format!("word{}", i)).collect();
303        let content = words.join(" ");
304        let ctx = make_context("", 0, 0, 0);
305
306        let items = source.complete(&ctx, content.as_str()).await;
307
308        // Should contain all 1000 unique words
309        assert_eq!(items.len(), 1000);
310    }
311
312    #[tokio::test]
313    async fn test_special_characters_as_word_boundaries() {
314        let source = BufferWordsSource::new();
315        let content = "foo.bar baz::qux hello->world test[0]";
316        let ctx = make_context("", 0, 0, 0);
317
318        let items = source.complete(&ctx, content).await;
319        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
320
321        // Each should be split at special characters
322        assert!(labels.contains(&"foo"));
323        assert!(labels.contains(&"bar"));
324        assert!(labels.contains(&"baz"));
325        assert!(labels.contains(&"qux"));
326        assert!(labels.contains(&"hello"));
327        assert!(labels.contains(&"world"));
328        assert!(labels.contains(&"test"));
329    }
330
331    #[tokio::test]
332    async fn test_single_char_words_filtered() {
333        let source = BufferWordsSource::new(); // min_word_length = 2
334        let content = "a b c ab abc";
335        let ctx = make_context("", 0, 0, 0);
336
337        let items = source.complete(&ctx, content).await;
338        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
339
340        // Single char words should be filtered out (min_word_length = 2)
341        assert!(!labels.contains(&"a"));
342        assert!(!labels.contains(&"b"));
343        assert!(!labels.contains(&"c"));
344        assert!(labels.contains(&"ab"));
345        assert!(labels.contains(&"abc"));
346    }
347
348    #[tokio::test]
349    async fn test_cursor_at_beginning_of_word() {
350        let source = BufferWordsSource::new();
351        let content = "hello world";
352        // Cursor at column 0, word_start at 0, cursor_col at 0 (no prefix typed)
353        let ctx = make_context("", 0, 0, 0);
354
355        let items = source.complete(&ctx, content).await;
356        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
357
358        // Both words should be included
359        assert!(labels.contains(&"hello"));
360        assert!(labels.contains(&"world"));
361    }
362
363    #[tokio::test]
364    async fn test_cursor_in_middle_of_word() {
365        let source = BufferWordsSource::new();
366        let content = "hello world testing";
367        // Cursor is in the middle of "world" at row 0, col 8, word_start 6
368        let ctx = make_context("wo", 0, 8, 6);
369
370        let items = source.complete(&ctx, content).await;
371        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
372
373        // "world" should not be included (it's the word being typed)
374        assert!(!labels.contains(&"world"));
375        assert!(labels.contains(&"hello"));
376        assert!(labels.contains(&"testing"));
377    }
378
379    #[tokio::test]
380    async fn test_multiline_with_empty_lines() {
381        let source = BufferWordsSource::new();
382        let content = "first\n\nsecond\n\n\nthird";
383        let ctx = make_context("", 0, 0, 0);
384
385        let items = source.complete(&ctx, content).await;
386        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
387
388        assert!(labels.contains(&"first"));
389        assert!(labels.contains(&"second"));
390        assert!(labels.contains(&"third"));
391        assert_eq!(items.len(), 3);
392    }
393
394    #[tokio::test]
395    async fn test_tabs_as_word_boundaries() {
396        let source = BufferWordsSource::new();
397        let content = "foo\tbar\tbaz";
398        let ctx = make_context("", 0, 0, 0);
399
400        let items = source.complete(&ctx, content).await;
401        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
402
403        assert!(labels.contains(&"foo"));
404        assert!(labels.contains(&"bar"));
405        assert!(labels.contains(&"baz"));
406    }
407
408    #[tokio::test]
409    async fn test_words_with_trailing_numbers() {
410        let source = BufferWordsSource::new();
411        let content = "test1 test2 test3 test123";
412        let ctx = make_context("", 0, 0, 0);
413
414        let items = source.complete(&ctx, content).await;
415        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
416
417        assert!(labels.contains(&"test1"));
418        assert!(labels.contains(&"test2"));
419        assert!(labels.contains(&"test3"));
420        assert!(labels.contains(&"test123"));
421        assert_eq!(items.len(), 4);
422    }
423
424    #[tokio::test]
425    async fn test_source_id() {
426        let source = BufferWordsSource::new();
427        assert_eq!(source.source_id(), "buffer_words");
428    }
429
430    #[tokio::test]
431    async fn test_priority() {
432        let source = BufferWordsSource::new();
433        assert_eq!(source.priority(), 100);
434    }
435
436    #[tokio::test]
437    async fn test_completion_item_has_correct_source() {
438        let source = BufferWordsSource::new();
439        let content = "hello world";
440        let ctx = make_context("", 0, 0, 0);
441
442        let items = source.complete(&ctx, content).await;
443
444        for item in &items {
445            assert_eq!(item.source, "buffer_words");
446        }
447    }
448
449    #[tokio::test]
450    async fn test_same_word_different_lines_deduplicated() {
451        let source = BufferWordsSource::new();
452        let content = "function\nfunction\nfunction";
453        let ctx = make_context("", 0, 0, 0);
454
455        let items = source.complete(&ctx, content).await;
456
457        // Should only have one "function" item
458        assert_eq!(items.len(), 1);
459        assert_eq!(items[0].label, "function");
460    }
461}