gshell 1.0.1

gshell is a shell for people who live in the terminal. It pairs familiar Unix behavior with a tighter core, fast interaction, and an interface built to stay out of the way.
Documentation
pub mod highlighter;
pub mod validator;

use std::sync::{Arc, RwLock};

use nu_ansi_term::Style;
use reedline::{
    ColumnarMenu, Completer, EditCommand, Editor, FileBackedHistory, KeyCode, KeyModifiers, Menu,
    MenuBuilder, MenuEvent, Painter, Reedline, ReedlineEvent, ReedlineMenu, Signal, Suggestion, Vi,
    default_vi_insert_keybindings, default_vi_normal_keybindings,
};

use crate::{
    completion::{ShellCompleter, ShellHinter},
    history::{HistoryConfig, should_record_history_entry},
    parser::{ParsedCommand, Parser},
    prompt::{ConfiguredPromptRenderer, ReedlinePromptAdapter},
    runtime::Executor,
    shell::{ExitCode, SharedShellState, ShellAction, ShellError, ShellResult},
    ui::{
        highlighter::{HighlighterPalette, ShellHighlighter},
        validator::ParserValidator,
    },
};

pub struct Repl<E> {
    line_editor: Reedline,
    core: ReplCore<E>,
    menu_prompt: Arc<RwLock<String>>,
}

struct PromptAwareColumnarMenu {
    inner: ColumnarMenu,
    prompt: Arc<RwLock<String>>,
    indicator: String,
}

impl PromptAwareColumnarMenu {
    fn new(inner: ColumnarMenu, prompt: Arc<RwLock<String>>) -> Self {
        Self {
            inner,
            prompt,
            indicator: String::new(),
        }
    }

    fn refresh_indicator(&mut self) {
        let prompt = self
            .prompt
            .read()
            .expect("menu prompt lock should not be poisoned");
        self.indicator = format!("{prompt}| ");
    }
}

impl Menu for PromptAwareColumnarMenu {
    fn settings(&self) -> &reedline::MenuSettings {
        self.inner.settings()
    }

    fn indicator(&self) -> &str {
        &self.indicator
    }

    fn is_active(&self) -> bool {
        self.inner.is_active()
    }

    fn menu_event(&mut self, event: MenuEvent) {
        if matches!(event, MenuEvent::Activate(_) | MenuEvent::Edit(_)) {
            self.refresh_indicator();
        }
        self.inner.menu_event(event);
    }

    fn can_quick_complete(&self) -> bool {
        self.inner.can_quick_complete()
    }

    fn can_partially_complete(
        &mut self,
        values_updated: bool,
        editor: &mut Editor,
        completer: &mut dyn Completer,
    ) -> bool {
        self.inner
            .can_partially_complete(values_updated, editor, completer)
    }

    fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) {
        self.inner.update_values(editor, completer);
    }

    fn update_working_details(
        &mut self,
        editor: &mut Editor,
        completer: &mut dyn Completer,
        painter: &Painter,
    ) {
        self.inner
            .update_working_details(editor, completer, painter);
    }

    fn replace_in_buffer(&self, editor: &mut Editor) {
        self.inner.replace_in_buffer(editor);
    }

    fn menu_required_lines(&self, terminal_columns: u16) -> u16 {
        self.inner.menu_required_lines(terminal_columns)
    }

    fn menu_string(&self, available_lines: u16, use_ansi_coloring: bool) -> String {
        self.inner.menu_string(available_lines, use_ansi_coloring)
    }

    fn min_rows(&self) -> u16 {
        self.inner.min_rows()
    }

    fn get_values(&self) -> &[Suggestion] {
        self.inner.get_values()
    }

    fn set_cursor_pos(&mut self, cursor_pos: (u16, u16)) {
        self.inner.set_cursor_pos(cursor_pos);
    }
}

pub struct ReplCore<E> {
    parser: Parser,
    executor: E,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplFlow {
    Continue,
    Break,
}

impl<E> Repl<E>
where
    E: Executor<ParsedCommand>,
{
    pub async fn new(executor: E, state: SharedShellState) -> Self {
        state
            .write()
            .await
            .runtime_services_mut()
            .set_output_sink(Some(Arc::new(|output| {
                if !output.stdout.is_empty() {
                    print!("{}", output.stdout);
                }

                if !output.stderr.is_empty() {
                    eprint!("{}", output.stderr);
                }
            })));

        let menu_prompt = Arc::new(RwLock::new(String::new()));
        let history = build_history(state.clone()).await;
        let highlighter_palette = {
            let guard = state.read().await;
            let config = guard.runtime_services().highlighter_config();
            HighlighterPalette::new(
                config.command_color(),
                config.builtin_color(),
                config.argument_color(),
                config.flag_color(),
                config.operator_color(),
                config.redirect_color(),
            )
        };
        let hint_style = {
            let guard = state.read().await;
            let config = guard.runtime_services().highlighter_config();
            Style::new().fg(config.hint_color())
        };

        let completer = Box::new(ShellCompleter::new(state.clone()));
        let hinter = Box::new(ShellHinter::default().with_style(hint_style));

        let completion_menu = Box::new(PromptAwareColumnarMenu::new(
            ColumnarMenu::default()
                .with_name("completion_menu")
                .with_text_style(Style::new()),
            menu_prompt.clone(),
        ));

        let mut insert_keybindings = default_vi_insert_keybindings();
        let normal_keybindings = default_vi_normal_keybindings();

        insert_keybindings.add_binding(
            KeyModifiers::NONE,
            KeyCode::Tab,
            ReedlineEvent::UntilFound(vec![
                ReedlineEvent::Menu("completion_menu".to_string()),
                ReedlineEvent::MenuNext,
            ]),
        );

        insert_keybindings.add_binding(
            KeyModifiers::SHIFT,
            KeyCode::BackTab,
            ReedlineEvent::MenuPrevious,
        );

        insert_keybindings.add_binding(
            KeyModifiers::NONE,
            KeyCode::Right,
            ReedlineEvent::UntilFound(vec![
                ReedlineEvent::HistoryHintComplete,
                ReedlineEvent::MenuRight,
                ReedlineEvent::Edit(vec![EditCommand::MoveRight { select: false }]),
            ]),
        );

        let edit_mode = Box::new(Vi::new(insert_keybindings, normal_keybindings));

        let base_editor = Reedline::create()
            .with_validator(Box::new(ParserValidator::default()))
            .with_completer(completer)
            .with_hinter(hinter)
            .with_menu(ReedlineMenu::EngineCompleter(completion_menu))
            .with_edit_mode(edit_mode)
            .with_highlighter(Box::new(ShellHighlighter::new(highlighter_palette)));

        let line_editor = match history {
            Ok(history) => base_editor.with_history(Box::new(history)),
            Err(err) => {
                eprintln!("warning: failed to initialize history: {err}");
                base_editor
            }
        };

        Self {
            line_editor,
            core: ReplCore::new(executor),
            menu_prompt,
        }
    }

    pub async fn run(&mut self, state: SharedShellState) -> ShellResult<()> {
        crate::runtime::initialize_interactive_shell().await?;

        let renderer = Arc::new(ConfiguredPromptRenderer::new());
        let mut prompt =
            ReedlinePromptAdapter::with_menu_prompt(renderer, self.menu_prompt.clone());

        loop {
            crate::runtime::refresh_job_statuses(state.clone()).await?;
            prompt.refresh(state.clone()).await;

            let signal = match self.line_editor.read_line(&prompt) {
                Ok(signal) => signal,
                Err(err) => {
                    eprintln!("reedline error: {err}");
                    state.write().await.set_last_exit_status(ExitCode::FAILURE);
                    continue;
                }
            };

            if matches!(
                self.core.handle_signal(signal, state.clone()).await,
                ReplFlow::Break
            ) {
                break;
            }
        }

        Ok(())
    }
}

impl<E> ReplCore<E>
where
    E: Executor<ParsedCommand>,
{
    pub fn new(executor: E) -> Self {
        Self {
            parser: Parser::default(),
            executor,
        }
    }

    pub async fn handle_signal(&self, signal: Signal, state: SharedShellState) -> ReplFlow {
        match signal {
            Signal::Success(buf) => {
                let command = match self.parser.parse(&buf) {
                    Ok(cmd) => cmd,
                    Err(err) => {
                        eprintln!("{err}");
                        state.write().await.set_last_exit_status(ExitCode::FAILURE);
                        return ReplFlow::Continue;
                    }
                };

                if matches!(command, ParsedCommand::Empty) {
                    return ReplFlow::Continue;
                }

                if should_record_history_entry(&buf) {
                    state
                        .write()
                        .await
                        .history_mut()
                        .push(buf.trim().to_string());
                }

                match self.executor.execute(state.clone(), &command).await {
                    Ok(ShellAction::Continue(output)) => {
                        if !output.stdout.is_empty() {
                            print!("{}", output.stdout);
                        }

                        if !output.stderr.is_empty() {
                            eprint!("{}", output.stderr);
                        }

                        state.write().await.set_last_exit_status(output.exit_code);
                    }
                    Ok(ShellAction::Exit(code)) => {
                        state.write().await.set_last_exit_status(code);
                        return ReplFlow::Break;
                    }
                    Err(err) => {
                        eprintln!("{err}");
                        state.write().await.set_last_exit_status(ExitCode::FAILURE);
                    }
                }

                ReplFlow::Continue
            }
            Signal::CtrlC => {
                state.write().await.set_last_exit_status(ExitCode::FAILURE);
                println!();
                ReplFlow::Continue
            }
            Signal::CtrlD => {
                println!();
                ReplFlow::Break
            }
        }
    }
}

async fn build_history(state: SharedShellState) -> ShellResult<FileBackedHistory> {
    let config = HistoryConfig::resolve_default()?;
    config.ensure_parent_dir()?;

    let history = FileBackedHistory::with_file(1_000, config.path().to_path_buf())
        .map_err(|err| ShellError::message(format!("history init failed: {err}")))?;

    let entries = std::fs::read_to_string(config.path())
        .map(|content| content.lines().map(ToOwned::to_owned).collect::<Vec<_>>())
        .unwrap_or_default();

    state.write().await.history_mut().set_entries(entries);

    Ok(history)
}