mxsh 0.1.0

Embeddable POSIX-style shell parser and runtime
Documentation
#[cfg(any(feature = "frontend", test))]
use std::fs::OpenOptions;
#[cfg(any(feature = "frontend", test))]
use std::io::Write;
#[cfg(any(feature = "frontend", test))]
use std::path::PathBuf;

use crate::ast::{Position, Range};
use crate::embed::{DiagnosticCategory, DiagnosticKind, ShellDiagnostic};

#[cfg(any(feature = "frontend", test))]
use super::OPT_NOLOG;
use super::{OPT_VI, ShellState};

fn default_diagnostic_source(state: &ShellState) -> Option<String> {
    state
        .current_source
        .clone()
        .or_else(|| Some(state.shell_name().to_string()))
}

pub(super) fn range_for_line(line: u32) -> Range {
    let pos = Position {
        offset: 0,
        line,
        column: 1,
    };
    Range {
        begin: pos,
        end: pos,
    }
}

pub(super) fn emit_diagnostic(
    state: &ShellState,
    kind: DiagnosticKind,
    category: DiagnosticCategory,
    code: &'static str,
    msg: &str,
    source: Option<String>,
    range: Option<Range>,
) {
    state.record_diagnostic(ShellDiagnostic {
        kind,
        category,
        code,
        message: msg.to_string(),
        source: source.or_else(|| default_diagnostic_source(state)),
        range,
    });
}

pub(super) fn shell_out(state: &ShellState, msg: &str) {
    if let Err(err) = state.stdout_fd.write_str(msg) {
        emit_diagnostic(
            state,
            DiagnosticKind::Error,
            DiagnosticCategory::Execution,
            "execute.stdout_write",
            &state.prefixed_message(format!("write stdout failed: {err}")),
            None,
            None,
        );
    }
}

pub(super) fn shell_outln(state: &ShellState, msg: &str) {
    if let Err(err) = state.stdout_fd.write_line(msg) {
        emit_diagnostic(
            state,
            DiagnosticKind::Error,
            DiagnosticCategory::Execution,
            "execute.stdout_write",
            &state.prefixed_message(format!("write stdout failed: {err}")),
            None,
            None,
        );
    }
}

pub(super) fn shell_errln(state: &ShellState, msg: &str) {
    let _ = state.stderr_fd.write_line(msg);
    emit_diagnostic(
        state,
        DiagnosticKind::Error,
        DiagnosticCategory::Execution,
        "execute.error",
        msg,
        None,
        None,
    );
}

pub(super) fn shell_warnln(state: &ShellState, msg: &str) {
    let _ = state.stderr_fd.write_line(msg);
    emit_diagnostic(
        state,
        DiagnosticKind::Warning,
        DiagnosticCategory::Execution,
        "execute.warning",
        msg,
        None,
        None,
    );
}

pub(crate) fn maybe_warn_vi_unsupported(state: &mut ShellState) {
    if state.interactive && state.has_option(OPT_VI) && !state.warned_vi_unsupported {
        shell_warnln(
            state,
            "set -o vi: line-editing mode is not available in this frontend",
        );
        state.warned_vi_unsupported = true;
    }
}

#[cfg(any(feature = "frontend", test))]
pub(super) fn append_history_line(state: &ShellState, line: &str) {
    if !state.interactive || state.has_option(OPT_NOLOG) {
        return;
    }
    if line.trim().is_empty() {
        return;
    }
    if let Some(history_appender) = state.definition.history_appender.as_ref() {
        (history_appender.as_ref())(line);
        return;
    }
    let Some(path) = history_path(state) else {
        return;
    };
    if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
        let _ = writeln!(file, "{line}");
    }
}

#[cfg(any(feature = "frontend", test))]
fn history_path(state: &ShellState) -> Option<PathBuf> {
    if let Some(path) = state.definition.history_path.clone() {
        return Some(path);
    }
    if !state
        .definition
        .security_policy
        .allow_implicit_history_file()
    {
        return None;
    }
    for name in [state.definition.identity.history_env_var(), "HISTFILE"] {
        if let Some(path) = state.env_get(name)
            && !path.trim().is_empty()
        {
            return Some(PathBuf::from(path));
        }
    }
    state
        .env_get("HOME")
        .map(|home| PathBuf::from(home).join(state.definition.identity.default_history_file()))
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::PathBuf;
    use std::sync::atomic::{AtomicUsize, Ordering};

    use super::*;

    fn temp_path(label: &str) -> PathBuf {
        static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
        std::env::temp_dir().join(format!(
            "mxsh-{label}-{}-{}",
            std::process::id(),
            NEXT_ID.fetch_add(1, Ordering::Relaxed)
        ))
    }

    #[test]
    fn history_path_prefers_mxsh_history_file_over_histfile() {
        let preferred = temp_path("history-preferred");
        let fallback = temp_path("history-fallback");
        let mut state = ShellState::new();
        state.interactive = true;
        state.env_set("HOME", std::env::temp_dir().display().to_string(), 0);
        state.env_set("HISTFILE", fallback.display().to_string(), 0);
        state.env_set("MXSH_HISTORY_FILE", preferred.display().to_string(), 0);

        append_history_line(&state, "echo preferred");

        assert_eq!(
            fs::read_to_string(&preferred).expect("preferred history should exist"),
            "echo preferred\n"
        );
        assert!(!fallback.exists(), "expected HISTFILE to remain unused");
        let _ = fs::remove_file(preferred);
    }

    #[test]
    fn whitespace_only_input_is_not_written_to_history() {
        let path = temp_path("history-whitespace");
        let mut state = ShellState::new();
        state.interactive = true;
        state.env_set("MXSH_HISTORY_FILE", path.display().to_string(), 0);

        append_history_line(&state, " \t ");

        assert!(!path.exists(), "expected no history output for blank input");
    }

    #[test]
    fn nolog_suppresses_history_output() {
        let path = temp_path("history-nolog");
        let mut state = ShellState::new();
        state.interactive = true;
        state.options |= OPT_NOLOG;
        state.env_set("MXSH_HISTORY_FILE", path.display().to_string(), 0);

        append_history_line(&state, "echo hidden");

        assert!(
            !path.exists(),
            "expected no history output when nolog is set"
        );
    }

    #[test]
    fn custom_history_appender_overrides_file_history() {
        let path = temp_path("history-sink");
        let lines = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
        let captured = lines.clone();
        let mut state = ShellState::new();
        state.interactive = true;
        state.env_set("MXSH_HISTORY_FILE", path.display().to_string(), 0);
        std::sync::Arc::make_mut(&mut state.definition).history_appender =
            Some(std::sync::Arc::new(move |line| {
                captured
                    .lock()
                    .expect("history appender lock")
                    .push(line.to_string());
            }));

        append_history_line(&state, "echo sink");

        assert!(
            !path.exists(),
            "expected history sink to prevent file writes"
        );
        assert_eq!(
            lines.lock().expect("history appender lock").as_slice(),
            ["echo sink"]
        );
    }
}