tui-pages 0.8.12

Core for TUI apps with multiple pages
Documentation
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerCommandQuery {
    tokens: Vec<String>,
    clauses: Vec<PickerCommandClause>,
    ends_with_whitespace: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerCommandClause {
    command: Option<String>,
    argument: Option<String>,
    selection_query: Vec<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickerCommandArgument {
    None,
    Required,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerCommandSpec {
    name: String,
    aliases: Vec<String>,
    argument: PickerCommandArgument,
}

impl PickerCommandQuery {
    pub fn parse(query: &str) -> Self {
        Self::parse_with_specs(query, &[])
    }

    pub fn parse_with_specs(query: &str, specs: &[PickerCommandSpec]) -> Self {
        let tokens = query
            .split_whitespace()
            .map(str::to_string)
            .collect::<Vec<_>>();
        let mut clauses = Vec::new();
        let mut index = 0usize;

        while index < tokens.len() {
            let token = &tokens[index];
            if let Some(command) = token.strip_prefix('%').filter(|command| !command.is_empty()) {
                index += 1;
                let argument_kind = specs
                    .iter()
                    .find(|spec| spec.matches(command))
                    .map(|spec| spec.argument)
                    .unwrap_or(PickerCommandArgument::None);
                let argument = if argument_kind == PickerCommandArgument::Required
                    && index < tokens.len()
                    && !tokens[index].starts_with('%')
                {
                    let argument = tokens[index].clone();
                    index += 1;
                    Some(argument)
                } else {
                    None
                };
                let mut selection_query = Vec::new();
                while index < tokens.len() && !tokens[index].starts_with('%') {
                    selection_query.push(tokens[index].clone());
                    index += 1;
                }
                clauses.push(PickerCommandClause {
                    command: Some(command.to_string()),
                    argument,
                    selection_query,
                });
                continue;
            }

            let mut selection_query = Vec::new();
            if token.starts_with('%') {
                selection_query.push(token.clone());
                index += 1;
            } else {
                while index < tokens.len() && !tokens[index].starts_with('%') {
                    selection_query.push(tokens[index].clone());
                    index += 1;
                }
            }
            clauses.push(PickerCommandClause {
                command: None,
                argument: None,
                selection_query,
            });
        }

        Self {
            tokens,
            clauses,
            ends_with_whitespace: query.chars().last().is_some_and(char::is_whitespace),
        }
    }

    pub fn tokens(&self) -> &[String] {
        &self.tokens
    }

    pub fn clauses(&self) -> &[PickerCommandClause] {
        &self.clauses
    }

    pub fn ends_with_whitespace(&self) -> bool {
        self.ends_with_whitespace
    }

    pub fn has_command(&self, names: &[&str]) -> bool {
        self.clauses.iter().any(|clause| clause.is_command(names))
    }

    pub fn first_command_value(&self, names: &[&str]) -> Option<&str> {
        self.clauses
            .iter()
            .find(|clause| clause.is_command(names))
            .and_then(PickerCommandClause::argument)
    }
}

impl PickerCommandSpec {
    pub fn new(name: impl Into<String>, argument: PickerCommandArgument) -> Self {
        Self {
            name: name.into(),
            aliases: Vec::new(),
            argument,
        }
    }

    pub fn with_aliases<I, S>(mut self, aliases: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.aliases = aliases.into_iter().map(Into::into).collect();
        self
    }

    fn matches(&self, command: &str) -> bool {
        self.name == command || self.aliases.iter().any(|alias| alias == command)
    }
}

impl PickerCommandClause {
    pub fn command(&self) -> Option<&str> {
        self.command.as_deref()
    }

    pub fn argument(&self) -> Option<&str> {
        self.argument.as_deref()
    }

    pub fn argument_or_empty(&self) -> &str {
        self.argument.as_deref().unwrap_or("")
    }

    pub fn selection_query(&self) -> &[String] {
        &self.selection_query
    }

    pub fn selection_query_text(&self) -> String {
        self.selection_query.join(" ")
    }

    pub fn terms(&self) -> Vec<&str> {
        self.argument
            .iter()
            .map(String::as_str)
            .chain(self.selection_query.iter().map(String::as_str))
            .collect()
    }

    pub fn is_command(&self, names: &[&str]) -> bool {
        self.command
            .as_deref()
            .is_some_and(|command| names.iter().any(|name| command == *name))
    }
}

pub fn parse_picker_command_query(query: &str) -> PickerCommandQuery {
    PickerCommandQuery::parse(query)
}

pub fn parse_picker_command_query_with_specs(
    query: &str,
    specs: &[PickerCommandSpec],
) -> PickerCommandQuery {
    PickerCommandQuery::parse_with_specs(query, specs)
}

#[cfg(test)]
mod tests {
    use super::{
        PickerCommandArgument, PickerCommandSpec, parse_picker_command_query,
        parse_picker_command_query_with_specs,
    };

    #[test]
    fn command_clause_collects_values_until_next_command() {
        let parsed = parse_picker_command_query_with_specs(
            "acme %column name %data bratislava",
            &[
                PickerCommandSpec::new("column", PickerCommandArgument::Required),
                PickerCommandSpec::new("data", PickerCommandArgument::None),
            ],
        );
        let tokens = parsed
            .tokens()
            .iter()
            .map(String::as_str)
            .collect::<Vec<_>>();

        assert_eq!(tokens, vec!["acme", "%column", "name", "%data", "bratislava"]);
        assert_eq!(parsed.clauses().len(), 3);
        assert_eq!(parsed.clauses()[0].command(), None);
        assert_eq!(clause_terms(&parsed, 0), vec!["acme"]);
        assert!(parsed.clauses()[1].is_command(&["column", "col"]));
        assert_eq!(parsed.clauses()[1].argument(), Some("name"));
        assert!(parsed.clauses()[1].selection_query().is_empty());
        assert!(parsed.clauses()[2].is_command(&["data"]));
        assert_eq!(parsed.clauses()[2].argument(), None);
        assert_eq!(clause_selection_query(&parsed, 2), vec!["bratislava"]);
    }

    #[test]
    fn extra_terms_after_command_argument_are_selection_query() {
        let parsed = parse_picker_command_query_with_specs(
            "%column city bratislava",
            &[PickerCommandSpec::new("column", PickerCommandArgument::Required)],
        );

        assert_eq!(parsed.clauses()[0].argument(), Some("city"));
        assert_eq!(clause_selection_query(&parsed, 0), vec!["bratislava"]);
    }

    #[test]
    fn commands_without_argument_treat_first_value_as_selection_query() {
        let parsed = parse_picker_command_query_with_specs(
            "%data bratislava",
            &[PickerCommandSpec::new("data", PickerCommandArgument::None)],
        );

        assert_eq!(parsed.clauses()[0].argument(), None);
        assert_eq!(clause_selection_query(&parsed, 0), vec!["bratislava"]);
    }

    #[test]
    fn records_trailing_whitespace() {
        let parsed = parse_picker_command_query("%target customer_id ");

        assert!(parsed.ends_with_whitespace());
    }

    #[test]
    fn standalone_percent_is_plain_text() {
        let parsed = parse_picker_command_query("% %data value");

        assert_eq!(parsed.clauses()[0].command(), None);
        assert_eq!(clause_terms(&parsed, 0), vec!["%"]);
        assert!(parsed.clauses()[1].is_command(&["data"]));
    }

    fn clause_terms(
        parsed: &super::PickerCommandQuery,
        index: usize,
    ) -> Vec<&str> {
        parsed.clauses()[index].terms()
    }

    fn clause_selection_query(
        parsed: &super::PickerCommandQuery,
        index: usize,
    ) -> Vec<&str> {
        parsed.clauses()[index]
            .selection_query()
            .iter()
            .map(String::as_str)
            .collect()
    }
}