elio 1.5.0

Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support.
Documentation
use crate::preview::appearance::CodePalette;
use ratatui::style::Color;
use std::sync::OnceLock;
use syntect::parsing::Scope;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum SemanticRole {
    Fg,
    Comment,
    String,
    Constant,
    Keyword,
    Function,
    Type,
    Parameter,
    Tag,
    Operator,
    Macro,
    Invalid,
}

struct ScopeSelectors {
    comment: [Scope; 1],
    string: [Scope; 1],
    constant: [Scope; 2],
    keyword: [Scope; 2],
    function: [Scope; 3],
    type_name: [Scope; 4],
    parameter: [Scope; 3],
    shell_variable: [Scope; 4],
    tag: [Scope; 3],
    operator: [Scope; 3],
    macro_name: [Scope; 4],
    invalid: [Scope; 2],
    variable_readwrite: [Scope; 1],
    shell_source: [Scope; 1],
    shell_function_call: [Scope; 1],
    shell_function_arguments: [Scope; 1],
}

pub(super) fn semantic_role_for_token(text: &str, scope_stack: &[Scope]) -> SemanticRole {
    let selectors = scope_selectors();

    if scope_stack_matches(scope_stack, &selectors.invalid) {
        SemanticRole::Invalid
    } else if scope_stack_matches(scope_stack, &selectors.comment) {
        SemanticRole::Comment
    } else if scope_stack_matches(scope_stack, &selectors.string) {
        SemanticRole::String
    } else if scope_stack_matches(scope_stack, &selectors.macro_name) {
        SemanticRole::Macro
    } else if scope_stack_matches(scope_stack, &selectors.shell_variable)
        || scope_stack_matches(scope_stack, &selectors.parameter)
    {
        SemanticRole::Parameter
    } else if scope_stack_matches(scope_stack, &selectors.tag) {
        SemanticRole::Tag
    } else if scope_stack_matches(scope_stack, &selectors.function) {
        SemanticRole::Function
    } else if scope_stack_matches(scope_stack, &selectors.type_name)
        || (scope_stack_matches(scope_stack, &selectors.variable_readwrite)
            && text.chars().next().is_some_and(char::is_uppercase))
    {
        SemanticRole::Type
    } else if scope_stack_matches(scope_stack, &selectors.operator) {
        SemanticRole::Operator
    } else if scope_stack_matches(scope_stack, &selectors.keyword) {
        SemanticRole::Keyword
    } else if scope_stack_matches(scope_stack, &selectors.constant) {
        SemanticRole::Constant
    } else if let Some(role) = shell_semantic_role_from_heuristics(text, scope_stack, selectors) {
        role
    } else {
        SemanticRole::Fg
    }
}

pub(super) fn looks_like_shell_command_name(token: &str) -> bool {
    let mut chars = token.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    if !(first.is_ascii_alphabetic() || first == '_') {
        return false;
    }
    chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
}

pub(super) fn role_color(role: SemanticRole, palette: CodePalette) -> Color {
    match role {
        SemanticRole::Fg => palette.fg,
        SemanticRole::Comment => palette.comment,
        SemanticRole::String => palette.string,
        SemanticRole::Constant => palette.constant,
        SemanticRole::Keyword => palette.keyword,
        SemanticRole::Function => palette.function,
        SemanticRole::Type => palette.r#type,
        SemanticRole::Parameter => palette.parameter,
        SemanticRole::Tag => palette.tag,
        SemanticRole::Operator => palette.operator,
        SemanticRole::Macro => palette.r#macro,
        SemanticRole::Invalid => palette.invalid,
    }
}

fn scope_selectors() -> &'static ScopeSelectors {
    static SELECTORS: OnceLock<ScopeSelectors> = OnceLock::new();
    SELECTORS.get_or_init(|| {
        let scope = |value| Scope::new(value).expect("valid syntect scope selector");
        ScopeSelectors {
            comment: [scope("comment")],
            string: [scope("string")],
            constant: [scope("constant"), scope("support.constant")],
            keyword: [scope("keyword"), scope("storage")],
            function: [
                scope("entity.name.function"),
                scope("support.function"),
                scope("variable.function"),
            ],
            type_name: [
                scope("entity.name.type"),
                scope("entity.name.class"),
                scope("support.type"),
                scope("support.class"),
            ],
            parameter: [
                scope("variable.parameter"),
                scope("entity.other.attribute-name"),
                scope("variable.other.readwrite.assignment"),
            ],
            shell_variable: [
                scope("meta.group.expansion.parameter"),
                scope("punctuation.definition.variable"),
                scope("variable.other.readwrite.shell"),
                scope("variable.language.shell"),
            ],
            tag: [
                scope("entity.name.tag"),
                scope("meta.tag"),
                scope("punctuation.definition.tag"),
            ],
            operator: [
                scope("keyword.operator"),
                scope("punctuation.separator.key-value"),
                scope("punctuation.accessor"),
            ],
            macro_name: [
                scope("entity.name.function.preprocessor"),
                scope("support.function.preprocessor"),
                scope("meta.preprocessor"),
                scope("keyword.directive"),
            ],
            invalid: [scope("invalid"), scope("invalid.deprecated")],
            variable_readwrite: [scope("variable.other.readwrite")],
            shell_source: [scope("source.shell")],
            shell_function_call: [scope("meta.function-call")],
            shell_function_arguments: [scope("meta.function-call.arguments")],
        }
    })
}

fn shell_semantic_role_from_heuristics(
    text: &str,
    scope_stack: &[Scope],
    selectors: &ScopeSelectors,
) -> Option<SemanticRole> {
    if !scope_stack_matches(scope_stack, &selectors.shell_source) {
        return None;
    }

    let token = text.trim();
    if token.is_empty() {
        return None;
    }

    if matches!(
        token,
        "if" | "then"
            | "fi"
            | "for"
            | "do"
            | "done"
            | "case"
            | "esac"
            | "while"
            | "until"
            | "in"
            | "elif"
            | "else"
            | "select"
    ) {
        return Some(SemanticRole::Keyword);
    }

    if matches!(token, "[" | "]" | "test" | "echo" | "printf") {
        return Some(SemanticRole::Function);
    }

    if scope_stack_matches(scope_stack, &selectors.shell_function_call)
        && looks_like_shell_command_name(token)
    {
        return Some(SemanticRole::Function);
    }

    if scope_stack_matches(scope_stack, &selectors.shell_function_arguments)
        && token.starts_with('-')
    {
        return Some(SemanticRole::Parameter);
    }

    if token.starts_with('$') || token.starts_with("${") || token.starts_with("$(") {
        return Some(SemanticRole::Parameter);
    }

    if looks_like_shell_assignment_name(token) {
        return Some(SemanticRole::Parameter);
    }

    None
}

fn looks_like_shell_assignment_name(token: &str) -> bool {
    let mut chars = token.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    if !(first.is_ascii_uppercase() || first == '_') {
        return false;
    }
    chars.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
}

fn scope_stack_matches(scope_stack: &[Scope], selectors: &[Scope]) -> bool {
    scope_stack.iter().rev().any(|scope| {
        selectors
            .iter()
            .any(|selector| selector.is_prefix_of(*scope))
    })
}