rusty_repl 0.3.0

REPL library with customisable prompts and clean terminal management.
Documentation
//! A simple line-based highlighter for `reedline` that styles lines beginning with specific keywords.
//!
//! This highlighter is designed for REPL-like environments where certain leading words
//! (e.g. commands such as `ls`, `cd`, `help`) should be visually distinct.
//!
//! It preserves all leading whitespace so that visual alignment and cursor positions
//! remain consistent within interactive line editors. Matching is **token-based**:
//! only the first word in the line (after any leading spaces) is compared against
//! the configured keyword list, and the keyword must match *exactly* — partial
//! prefixes like `"lss"` will not match `"ls"`.
//!
//! # Example
//! ```ignore
//! use nu_ansi_term::Color;
//! use reedline::{Highlighter, StyledText};
//! use crate::repl::highlighter::KeywordHighlighter; // replace with actual crate path
//!
//! let highlighter = KeywordHighlighter::new(["ls", "cd"], Color::Cyan);
//!
//! let result = highlighter.highlight("  ls -la", 0);
//! // The "ls" token will be highlighted in cyan + bold,
//! // while the leading spaces and rest of the line remain neutral.
//! ```

use nu_ansi_term::{Color, Style};
use reedline::{Highlighter, StyledText};

/// Highlights lines that begin with any of a predefined set of keywords.
///
/// The [`KeywordHighlighter`] inspects only the **first token** of each line
/// (ignoring leading whitespace). If that token exactly matches one of the
/// configured keywords, it is rendered using a special [`Style`] (by default,
/// bold text with the configured foreground color).
///
/// All other text in the line, including leading whitespace and trailing content,
/// is rendered in a neutral style.
///
/// This component is intended for use with [`reedline`](https://docs.rs/reedline)
/// but can be repurposed for other terminal-based contexts.
pub struct KeywordHighlighter {
    keywords: Vec<String>,
    keyword_style: Style,
    neutral_style: Style,
}

impl KeywordHighlighter {
    /// Creates a new [`KeywordHighlighter`] from a collection of keywords.
    ///
    /// # Parameters
    /// - `keywords`: Any iterable of string-like items representing keywords to highlight.
    /// - `foreground`: The ANSI foreground [`Color`] used for highlighting.
    ///
    /// Each keyword is converted into an owned `String` internally.
    ///
    /// # Example
    /// ```ignore
    /// use nu_ansi_term::Color;
    /// use crate::repl::highlighter::KeywordHighlighter;
    ///
    /// let highlighter = KeywordHighlighter::new(["help", "exit"], Color::Green);
    /// ```
    pub fn new(keywords: &[String], foreground: Color) -> Self {
        Self {
            keywords: keywords.to_vec(),
            keyword_style: Style::default().fg(foreground).bold(),
            neutral_style: Style::default(),
        }
    }

    /// Splits a line into its leading whitespace and the remaining content.
    ///
    /// This function **does not trim** the line globally; instead, it returns
    /// two string slices that together make up the original input.
    ///
    /// This design preserves exact byte offsets and ensures that cursor positions
    /// in interactive editors like `reedline` remain correct.
    ///
    /// # Returns
    /// A tuple `(leading_whitespace, remainder_of_line)`.
    ///
    /// # Example
    ///
    /// ```ignore
    /// use crate::repl::highlighter::KeywordHighlighter;
    /// let h = KeywordHighlighter::new(["dummy"], nu_ansi_term::Color::Blue);
    /// let (ws, rest) = h.split_leading_whitespace("   echo hello");
    /// assert_eq!(ws, "   ");
    /// assert_eq!(rest, "echo hello");
    /// ```
    fn split_leading_whitespace<'a>(&self, line: &'a str) -> (&'a str, &'a str) {
        let trimmed = line.trim_start();
        let leading_ws_len = line.len() - trimmed.len();
        line.split_at(leading_ws_len)
    }

    /// Extracts the first whitespace-delimited token from the given content.
    ///
    /// Returns `None` if the input is empty or contains only whitespace.
    ///
    /// # Example
    /// ```ignore
    /// use crate::repl::highlighter::KeywordHighlighter;
    /// let h = KeywordHighlighter::new(["echo"], nu_ansi_term::Color::Yellow);
    /// assert_eq!(h.first_token("echo hello"), Some("echo"));
    /// assert_eq!(h.first_token("   "), None);
    /// ```
    fn first_token<'a>(&self, content: &'a str) -> Option<&'a str> {
        content.split_whitespace().next()
    }

    /// Checks whether a given token matches any configured keyword.
    ///
    /// This performs an **exact** string match and does not consider case
    /// or punctuation boundaries.
    fn is_keyword(&self, token: &str) -> bool {
        self.keywords.contains(&token.to_string())
    }
}

impl Highlighter for KeywordHighlighter {
    /// Highlights a single line of input according to the configured keywords.
    ///
    /// The highlighter:
    /// 1. Preserves all leading whitespace (not trimmed).
    /// 2. Extracts the first token (word) after whitespace.
    /// 3. If that token matches a keyword, styles it with `keyword_style`.
    /// 4. Styles the rest of the line (and any whitespace) neutrally.
    ///
    /// Matching is **token-based** — keywords must be exact and occur
    /// at the start of the trimmed content.
    /// For example:
    /// - `"ls -la"` → highlights `"ls"`
    /// - `"  ls -la"` → highlights `"ls"`
    /// - `"lss"` → not highlighted (no exact match)
    ///
    /// # Parameters
    /// - `line`: The input line to highlight.
    /// - `_cursor`: The cursor position (unused by this implementation).
    ///
    /// # Returns
    /// A [`StyledText`] object containing all styled fragments.
    fn highlight(&self, line: &str, _cursor: usize) -> StyledText {
        let mut styled = StyledText::new();

        let (leading_ws, content) = self.split_leading_whitespace(line);

        if let Some(token) = self.first_token(content) {
            if self.is_keyword(token) {
                let (_, rest) = content.split_at(token.len());

                if !leading_ws.is_empty() {
                    styled.push((self.neutral_style, leading_ws.to_owned()));
                }

                styled.push((self.keyword_style, token.to_owned()));

                if !rest.is_empty() {
                    styled.push((self.neutral_style, rest.to_owned()));
                }

                return styled;
            }
        }
        // No keyword match: treat entire line as neutral text
        styled.push((self.neutral_style, line.to_owned()));
        styled
    }
}