#[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,
});
}
fn report_stdout_write_error(state: &mut ShellState, err: std::io::Error) -> std::io::Error {
state.note_stdout_write_error(&err);
emit_diagnostic(
state,
DiagnosticKind::Error,
DiagnosticCategory::Execution,
"execute.stdout_write",
&state.prefixed_message(format!("write stdout failed: {err}")),
None,
None,
);
err
}
pub(super) fn shell_out(state: &mut ShellState, msg: &str) -> std::io::Result<()> {
state
.stdout_fd
.write_str(msg)
.map_err(|err| report_stdout_write_error(state, err))
}
pub(super) fn shell_out_bytes(state: &mut ShellState, msg: &[u8]) -> std::io::Result<()> {
state
.stdout_fd
.write_all(msg)
.map_err(|err| report_stdout_write_error(state, err))
}
pub(super) fn shell_outln(state: &mut ShellState, msg: &str) -> std::io::Result<()> {
state
.stdout_fd
.write_line(msg)
.map_err(|err| report_stdout_write_error(state, err))
}
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"]
);
}
}