clapcmd 0.3.3

A readline wrapper that allows for creating custom interactive shells, similar to python's cmd module
Documentation
use std::fs::{self, DirEntry};
use std::path::{Path, PathBuf};

use rustyline::completion::{Completer, Pair};
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Context, Helper};

use crate::handler::Handler;
use crate::shell_parser::{escape_word, split, unescape_word};

pub type Dispatcher<S> = Vec<Handler<S>>;

pub struct ClapCmdHelper<State = ()>
where
    State: Clone,
{
    pub(crate) dispatcher: Dispatcher<State>,
    pub(crate) state: Option<State>,
}

impl<State> ClapCmdHelper<State>
where
    State: Clone,
{
    pub(crate) fn new(state: Option<State>) -> Self {
        ClapCmdHelper {
            dispatcher: Dispatcher::new(),
            state,
        }
    }

    pub(crate) fn get_state(&self) -> Option<&State> {
        self.state.as_ref()
    }

    pub(crate) fn get_state_mut(&mut self) -> Option<&mut State> {
        self.state.as_mut()
    }

    pub(crate) fn set_state(&mut self, new_state: Option<State>) {
        self.state = new_state;
    }
}
impl<State: Clone> Helper for ClapCmdHelper<State> {}
impl<State: Clone> Hinter for ClapCmdHelper<State> {
    type Hint = String;

    fn hint(&self, _line: &str, _pos: usize, _ctx: &Context) -> Option<Self::Hint> {
        None
    }
}

impl<State: Clone> Highlighter for ClapCmdHelper<State> {}
impl<State: Clone> Validator for ClapCmdHelper<State> {}
impl<State: Clone> Completer for ClapCmdHelper<State> {
    type Candidate = Pair;
    fn complete(
        &self,
        line: &str,
        pos: usize,
        _ctx: &Context,
    ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
        let mut matches = vec![];
        let statements = split(&line[..pos]);

        let Some(statement) = statements.last() else {
            return Ok((pos, matches));
        };
        let words = &statement.words;
        let Some(curr_word) = &words.last() else {
            return Ok((pos, matches));
        };

        let curr_word = &unescape_word(line, curr_word);

        // search through commands/subcommands until we have found the current command
        let mut current_command = None;
        let mut current_commands = self
            .dispatcher
            .iter()
            .map(|command| &command.command)
            .collect::<Vec<&clap::Command>>();
        let mut i_current_command = None;
        for (i, word) in words.iter().enumerate() {
            let mut found = false;
            let word = unescape_word(line, word);
            for command in &current_commands {
                if word == command.get_name() {
                    current_command = Some(<&clap::Command>::clone(command));
                    i_current_command = Some(i);
                    found = true;
                    break;
                }
            }
            // quit early if we're done with subcommands
            if !found {
                break;
            }
            current_commands =
                current_command.map_or(vec![], |command| command.get_subcommands().collect());
        }
        let i_last_command_word = i_current_command.map_or(0, |i| i + 1);

        // if we are completing a command/subcommand
        if words.len() - 1 == i_last_command_word {
            for command in &current_commands {
                let name = command.get_name();
                if let Some(stripped) = name.strip_prefix(curr_word) {
                    let completion = escape_word(stripped);
                    let pair = Pair {
                        display: completion.clone(),
                        replacement: completion,
                    };
                    matches.push(pair);
                }
            }
        }

        if current_command.is_none() {
            return Ok((pos, matches));
        };
        let current_command = current_command.expect("current_command should never be none here");

        // handle when the current word is a long option
        if let Some(curr_arg) = curr_word.strip_prefix("--") {
            let args = current_command.get_arguments();
            for arg in args {
                let longs = arg.get_long_and_visible_aliases().unwrap_or_default();
                for long in longs {
                    if let Some(stripped) = long.strip_prefix(curr_arg) {
                        let completion = escape_word(stripped);
                        let pair = Pair {
                            display: completion.clone(),
                            replacement: completion.clone(),
                        };
                        matches.push(pair);
                    }
                }
            }
            return Ok((pos, matches));
        }

        // handle values based on ValueHints and ValueParsers
        if !curr_word.starts_with('-') {
            if words.len() < 3 {
                // early return because not enough words to have an value to hint (need three):
                // `command --option value`
                return Ok((pos, matches));
            }
            // pull out previous word for convenience
            let prev_word = &words[words.len() - 2];
            let prev_word = &line[prev_word.start..prev_word.end];
            if !prev_word.starts_with('-') {
                // early return if the previous word isn't an option
                return Ok((pos, matches));
            }

            let args = current_command.get_arguments();

            let add_values_from_parser = |arg: &clap::Arg| {
                let possible_values = arg.get_value_parser().possible_values();
                if possible_values.is_none() {
                    return vec![];
                }
                let possible_values = possible_values.unwrap();
                possible_values
                    .flat_map(|value| {
                        let mut matches = vec![];
                        for name in value.get_name_and_aliases() {
                            if let Some(stripped) = name.strip_prefix(curr_word) {
                                let completion = escape_word(stripped);
                                matches.push(Pair {
                                    display: completion.clone(),
                                    replacement: completion.clone(),
                                });
                            }
                        }
                        matches
                    })
                    .collect()
            };

            let add_values_from_hint = |arg: &clap::Arg| {
                let value_hint = arg.get_value_hint();
                let add_path = |filter: fn(&DirEntry) -> bool| {
                    let mut path = PathBuf::from(curr_word);

                    // add a filename if the path ends with /(slash) so that parent
                    // returns the correct parent
                    if curr_word.ends_with('/') {
                        path.push("ignore");
                    }
                    let parent = path.parent().unwrap_or_else(|| Path::new(""));

                    // use the current directory if there is no parent
                    let parent = if parent.to_string_lossy() == "" {
                        Path::new(".")
                    } else {
                        parent
                    };
                    let entries = fs::read_dir(parent);
                    if entries.is_err() {
                        return vec![];
                    }
                    let entries = entries.unwrap();
                    entries
                        .flat_map(|value| {
                            let mut matches = vec![];
                            if value.is_err() {
                                return matches;
                            }
                            let value = value.unwrap();

                            // call our filter to remove certain values based on hint type
                            if filter(&value) {
                                return matches;
                            }

                            // default: use the path as is
                            if let Some(stripped) =
                                value.path().to_string_lossy().strip_prefix(curr_word)
                            {
                                let completion = escape_word(stripped);
                                matches.push(Pair {
                                    display: completion.clone(),
                                    replacement: completion,
                                });

                            // remove the leading ./ if we added it during path
                            // resolution
                            } else if let Some(stripped) =
                                value.path().to_string_lossy().strip_prefix("./")
                            {
                                if let Some(stripped) = stripped.strip_prefix(curr_word) {
                                    let completion = escape_word(stripped);
                                    matches.push(Pair {
                                        display: completion.clone(),
                                        replacement: completion,
                                    });
                                }
                            }
                            matches
                        })
                        .collect()
                };
                match value_hint {
                    clap::ValueHint::AnyPath => add_path(|_| false),
                    clap::ValueHint::DirPath => add_path(|entry: &DirEntry| {
                        let metadata = entry.metadata();
                        if metadata.is_err() {
                            return true;
                        }
                        !metadata.unwrap().is_dir()
                    }),
                    clap::ValueHint::FilePath => add_path(|entry: &DirEntry| {
                        let metadata = entry.metadata();
                        if metadata.is_err() {
                            return true;
                        }
                        !metadata.unwrap().is_file()
                    }),
                    _ => vec![],
                }
            };

            // iterate through previous word and add matches
            if prev_word.starts_with("--") {
                for arg in args {
                    for long in arg.get_long_and_visible_aliases().unwrap_or_default() {
                        if long == &prev_word[2..] {
                            matches.extend(add_values_from_parser(arg));
                            matches.extend(add_values_from_hint(arg));
                        }
                    }
                }
            } else if prev_word.starts_with('-') {
                for arg in args {
                    for short in arg.get_short_and_visible_aliases().unwrap_or_default() {
                        if short == prev_word.chars().last().unwrap_or_default() {
                            matches.extend(add_values_from_parser(arg));
                            matches.extend(add_values_from_hint(arg));
                        }
                    }
                }
            }
        }
        Ok((pos, matches))
    }
}