pinel 0.8.0

A lightweight editor built with rust
use std::collections::HashSet;

use crate::autocomplete::{
    context::CompletionContext,
    language::LanguageDefinitions,
    scoring::FuzzyScorer,
    types::{Suggestion, SuggestionKind},
};

/// Main autocomplete engine with fuzzy matching, context awareness,
/// and language-specific suggestions.
pub struct Autocomplete {
    pub active: bool,
    pub suggestions: Vec<Suggestion>,
    pub selected_index: usize,
    pub trigger_position: usize,
    pub prefix: String,

    language_defs: LanguageDefinitions,
    recent_identifiers: Vec<String>,
    max_recent: usize,
}

impl Default for Autocomplete {
    fn default() -> Self {
        Self::new()
    }
}

impl Autocomplete {
    pub fn new() -> Self {
        Self {
            active: false,
            suggestions: Vec::new(),
            selected_index: 0,
            trigger_position: 0,
            prefix: String::new(),
            language_defs: LanguageDefinitions::new(),
            recent_identifiers: Vec::new(),
            max_recent: 100,
        }
    }

    pub fn get_current_word(text: &str, cursor_pos: usize) -> (String, usize) {
        let cursor_pos = cursor_pos.min(text.len());

        if cursor_pos == 0 {
            return (String::new(), 0);
        }

        let before_cursor = &text[..cursor_pos];
        let mut word_start = cursor_pos;

        for (i, ch) in before_cursor.char_indices().rev() {
            if ch.is_alphanumeric() || ch == '_' {
                word_start = i;
            } else {
                break;
            }
        }

        let current_word = text[word_start..cursor_pos].to_string();
        (current_word, word_start)
    }

    pub fn extract_identifiers(&mut self, text: &str) -> HashSet<String> {
        let mut identifiers = HashSet::new();
        let mut current_word = String::new();

        for ch in text.chars() {
            if ch.is_alphanumeric() || ch == '_' {
                current_word.push(ch);
            } else {
                if !current_word.is_empty()
                    && current_word.len() > 1
                    && !current_word.chars().next().unwrap().is_numeric()
                {
                    identifiers.insert(current_word.clone());

                    if !self.recent_identifiers.contains(&current_word) {
                        self.recent_identifiers.push(current_word.clone());
                        if self.recent_identifiers.len() > self.max_recent {
                            self.recent_identifiers.remove(0);
                        }
                    }
                }
                current_word.clear();
            }
        }

        if !current_word.is_empty() && current_word.len() > 1 {
            identifiers.insert(current_word);
        }

        identifiers
    }

    fn infer_identifier_kind(
        &self,
        text: &str,
        identifier: &str,
        context: &CompletionContext,
    ) -> SuggestionKind {
        if context.is_member_access {
            if context.is_function_call {
                return SuggestionKind::Method;
            }
            return SuggestionKind::Property;
        }

        if text.contains(&format!("{}(", identifier)) {
            return SuggestionKind::Function;
        }

        if identifier.chars().next().is_some_and(|c| c.is_uppercase()) {
            return SuggestionKind::Type;
        }

        if identifier.chars().all(|c| c.is_uppercase() || c == '_') {
            return SuggestionKind::Constant;
        }

        SuggestionKind::Variable
    }

    /// Detect the language from a file extension string (e.g. "rs" -> "rust")
    pub fn detect_language(ext: &str) -> Option<String> {
        match ext {
            "rs" => Some("rust".to_string()),
            "js" | "mjs" | "cjs" => Some("javascript".to_string()),
            "ts" => Some("typescript".to_string()),
            "tsx" | "jsx" => Some("typescript".to_string()),
            "py" => Some("python".to_string()),
            _ => None,
        }
    }

    pub fn trigger(&mut self, text: &str, cursor_pos: usize, language: Option<&str>) {
        let (prefix, start_pos) = Self::get_current_word(text, cursor_pos);

        if prefix.is_empty() {
            self.active = false;
            return;
        }

        self.prefix = prefix.clone();
        self.trigger_position = start_pos;

        let context = CompletionContext::analyze(text, cursor_pos);
        let mut all_suggestions = Vec::new();

        if context.should_show_keywords() {
            self.add_keyword_suggestions(&prefix, language, &mut all_suggestions);
        }

        self.add_type_suggestions(&prefix, language, &context, &mut all_suggestions);
        self.add_identifier_suggestions(text, &prefix, &context, &mut all_suggestions);

        all_suggestions.sort_by(|a, b| {
            b.score
                .partial_cmp(&a.score)
                .unwrap_or(std::cmp::Ordering::Equal)
                .then_with(|| a.text.cmp(&b.text))
        });

        let mut seen = HashSet::new();
        all_suggestions.retain(|s| seen.insert(s.text.clone()));
        all_suggestions.truncate(20);

        self.suggestions = all_suggestions;
        self.selected_index = 0;
        self.active = !self.suggestions.is_empty();
    }

    fn add_keyword_suggestions(
        &self,
        prefix: &str,
        language: Option<&str>,
        suggestions: &mut Vec<Suggestion>,
    ) {
        let keywords = if let Some(lang) = language {
            self.language_defs.get_keywords(lang)
        } else {
            self.language_defs.get_all_keywords()
        };

        for keyword in keywords {
            let score = FuzzyScorer::score(&keyword, prefix);
            if score > 0.0 && keyword != prefix {
                suggestions.push(Suggestion::with_score(
                    keyword,
                    SuggestionKind::Keyword,
                    score,
                ));
            }
        }
    }

    fn add_type_suggestions(
        &self,
        prefix: &str,
        language: Option<&str>,
        context: &CompletionContext,
        suggestions: &mut Vec<Suggestion>,
    ) {
        let types = if let Some(lang) = language {
            self.language_defs.get_types(lang)
        } else {
            self.language_defs.get_all_types()
        };

        for type_name in types {
            let mut score = FuzzyScorer::score(&type_name, prefix);
            score = FuzzyScorer::apply_context_boost(score, &SuggestionKind::Type, context);

            if score > 0.0 && type_name != prefix {
                suggestions.push(Suggestion::with_score(
                    type_name,
                    SuggestionKind::Type,
                    score,
                ));
            }
        }
    }

    fn add_identifier_suggestions(
        &mut self,
        text: &str,
        prefix: &str,
        context: &CompletionContext,
        suggestions: &mut Vec<Suggestion>,
    ) {
        for identifier in &self.recent_identifiers.clone() {
            let mut score = FuzzyScorer::score(identifier, prefix);
            score = FuzzyScorer::apply_recency_boost(score, true);

            if score > 100.0 && identifier != prefix {
                let kind = self.infer_identifier_kind(text, identifier, context);
                score = FuzzyScorer::apply_context_boost(score, &kind, context);

                suggestions.push(Suggestion::with_score(identifier.clone(), kind, score));
            }
        }

        let identifiers = self.extract_identifiers(text);
        for identifier in identifiers {
            let score = FuzzyScorer::score(&identifier, prefix);
            if score > 0.0 && identifier != prefix {
                let kind = self.infer_identifier_kind(text, &identifier, context);
                let adjusted_score = FuzzyScorer::apply_context_boost(score, &kind, context);

                suggestions.push(Suggestion::with_score(identifier, kind, adjusted_score));
            }
        }
    }

    pub fn select_next(&mut self) {
        if !self.suggestions.is_empty() {
            self.selected_index = (self.selected_index + 1) % self.suggestions.len();
        }
    }

    pub fn select_previous(&mut self) {
        if !self.suggestions.is_empty() {
            if self.selected_index == 0 {
                self.selected_index = self.suggestions.len() - 1;
            } else {
                self.selected_index -= 1;
            }
        }
    }

    pub fn get_selected(&self) -> Option<&Suggestion> {
        if self.active && self.selected_index < self.suggestions.len() {
            Some(&self.suggestions[self.selected_index])
        } else {
            None
        }
    }

    pub fn cancel(&mut self) {
        self.active = false;
        self.suggestions.clear();
        self.selected_index = 0;
    }
}