use std::error::Error;
use std::fmt::{self, Write as _};
use std::io::{self, Write};
use flash_runtime::background::{JobNoticeKind, LiveJob, LiveJobState};
use flash_runtime::job::JobId;
use crate::completion::CompletionCatalog;
use crate::editor::{
EditorError, EditorEvent, EditorEventSource, EditorExternalPrint, EditorPrompt, LineEditor,
};
use crate::report::HostExit;
/// Control flow requested after evaluating one submitted edit buffer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EvaluationControl {
Continue,
Exit(u8),
}
/// A recoverable diagnostic rendered by the interactive evaluator.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractiveDiagnostic {
rendered: String,
}
impl InteractiveDiagnostic {
#[must_use]
pub fn new(rendered: impl Into<String>) -> Self {
Self {
rendered: rendered.into(),
}
}
#[must_use]
pub fn rendered(&self) -> &str {
&self.rendered
}
}
/// Result of one interactive evaluation attempt.
#[derive(Debug)]
pub enum InteractiveEvaluationError {
/// A source-backed or shell-owned diagnostic after which the session may recover.
Diagnostic(InteractiveDiagnostic),
/// A program-output failure that makes the interactive session unusable.
ProgramOutput(io::Error),
}
impl From<InteractiveDiagnostic> for InteractiveEvaluationError {
fn from(diagnostic: InteractiveDiagnostic) -> Self {
Self::Diagnostic(diagnostic)
}
}
/// Opaque identity of one interactive notice.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct InteractiveNoticeId(u64);
impl InteractiveNoticeId {
/// Build a nonzero identity supplied by the evaluator.
#[must_use]
pub const fn new(value: u64) -> Option<Self> {
if value == 0 { None } else { Some(Self(value)) }
}
/// The evaluator-local numeric identity.
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
/// One complete editor-owned notice awaiting successful presentation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractiveNotice {
id: InteractiveNoticeId,
rendered: String,
}
impl InteractiveNotice {
/// Build one notice from its opaque identity and complete rendered text.
#[must_use]
pub fn new(id: InteractiveNoticeId, rendered: impl Into<String>) -> Self {
Self {
id,
rendered: rendered.into(),
}
}
/// The identity returned to the evaluator after a successful write.
#[must_use]
pub const fn id(&self) -> InteractiveNoticeId {
self.id
}
/// Complete text written by the editor before the next prompt.
#[must_use]
pub fn rendered(&self) -> &str {
&self.rendered
}
}
/// Failure while acknowledging a successfully written interactive notice.
#[derive(Debug)]
pub struct InteractiveNoticeError {
message: String,
source: Option<Box<dyn Error + Send + Sync>>,
}
impl InteractiveNoticeError {
/// Build a notice acknowledgement failure without an underlying source.
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
source: None,
}
}
/// Build a notice acknowledgement failure with an underlying source.
pub fn with_source(
message: impl Into<String>,
source: impl Error + Send + Sync + 'static,
) -> Self {
Self {
message: message.into(),
source: Some(Box::new(source)),
}
}
}
impl fmt::Display for InteractiveNoticeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl Error for InteractiveNoticeError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.source
.as_deref()
.map(|source| source as &(dyn Error + 'static))
}
}
/// Format one structured runtime job notice for prompt-boundary presentation.
#[must_use]
pub fn format_job_notice(job: JobId, kind: &JobNoticeKind, command: &str) -> String {
match kind {
JobNoticeKind::Started { group } => format!("[{}] {group}\n", job.get()),
JobNoticeKind::Stopped => format!("[{}] Stopped {command}\n", job.get()),
JobNoticeKind::Completed => format!("[{}] Done {command}\n", job.get()),
JobNoticeKind::ObservationFailed { message } => {
format!("[{}] Failed {command}: {message}\n", job.get())
}
}
}
/// Render one refusal listing for the live jobs a session would abandon.
#[must_use]
pub fn format_live_jobs(jobs: &[LiveJob]) -> String {
let mut rendered = format!(
"fsh: {} live background job{}\n",
jobs.len(),
if jobs.len() == 1 { "" } else { "s" }
);
for live in jobs {
let state = match live.state() {
LiveJobState::Running => "Running",
LiveJobState::Stopped => "Stopped",
};
let _ = writeln!(
rendered,
"[{}] {state} {}",
live.job().get(),
live.command()
);
let _ = writeln!(rendered, " force: kill --kill %{}", live.job().get());
}
rendered.push_str("fsh: exit again to hang up\n");
rendered
}
/// Whether an interactive session may end, and what to show if it may not.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ExitDecision {
/// The session may end now.
Permitted,
/// The session may not end yet; the text is one complete shell-owned notice.
Refused {
/// One complete refusal notice, newline-terminated.
rendered: String,
},
}
/// Stateful evaluation boundary owned for the lifetime of an interactive session.
pub trait InteractiveEvaluator {
/// Snapshots live completion candidates at one deterministic prompt boundary.
fn completion_catalog(&mut self) -> Option<CompletionCatalog> {
None
}
/// Peek one notice to present before the next prompt.
fn next_notice(&mut self) -> Option<InteractiveNotice> {
None
}
/// Decide whether the session may end.
///
/// Asked at every exit route. A refusal is presented through the editor, so
/// the editor keeps sole ownership of terminal writes at this boundary.
fn request_exit(&mut self) -> ExitDecision {
ExitDecision::Permitted
}
/// Acknowledge one notice only after the editor has written it successfully.
fn acknowledge_notice(
&mut self,
_notice: &InteractiveNotice,
) -> Result<(), InteractiveNoticeError> {
Ok(())
}
/// Unconditionally clean up resources owned by a fatally ending session.
///
/// The returned reports are already ordered and newline-terminated. They
/// are presented by the driver only while the diagnostic stream remains
/// usable.
fn fatal_cleanup(&mut self) -> Vec<String> {
Vec::new()
}
fn evaluate(
&mut self,
source: &str,
output: &mut dyn Write,
) -> Result<EvaluationControl, InteractiveEvaluationError>;
}
/// Normal reason for leaving an interactive session.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InteractiveExit {
EndOfInput,
Requested(u8),
}
/// Fatal failure that prevents an interactive session from continuing.
#[derive(Debug)]
pub enum InteractiveSessionError {
Editor(EditorError),
NoticeAcknowledgement(InteractiveNoticeError),
ProgramOutput(io::Error),
DiagnosticOutput(io::Error),
UnsupportedEditorEvent(&'static str),
}
impl fmt::Display for InteractiveSessionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Editor(error) => write!(formatter, "interactive editor failed: {error}"),
Self::NoticeAcknowledgement(error) => {
write!(
formatter,
"interactive notice acknowledgement failed: {error}"
)
}
Self::ProgramOutput(error) => {
write!(formatter, "interactive command output failed: {error}")
}
Self::DiagnosticOutput(error) => {
write!(formatter, "interactive diagnostic output failed: {error}")
}
Self::UnsupportedEditorEvent(event) => {
write!(
formatter,
"interactive editor event is not supported: {event}"
)
}
}
}
}
impl Error for InteractiveSessionError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Editor(error) => Some(error),
Self::NoticeAcknowledgement(error) => Some(error),
Self::ProgramOutput(error) => Some(error),
Self::DiagnosticOutput(error) => Some(error),
Self::UnsupportedEditorEvent(_) => None,
}
}
}
/// Write and acknowledge every notice the evaluator currently holds.
///
/// Run at the prompt boundary and again before a permitted exit, so a completion
/// observed while the editor owned the prompt is still shown to the user rather
/// than discarded with the session.
fn render_pending_notices(
editor: &mut dyn LineEditor,
evaluator: &mut dyn InteractiveEvaluator,
) -> Result<(), InteractiveSessionError> {
while let Some(notice) = evaluator.next_notice() {
editor
.write_notice(notice.rendered())
.map_err(InteractiveSessionError::Editor)?;
evaluator
.acknowledge_notice(¬ice)
.map_err(InteractiveSessionError::NoticeAcknowledgement)?;
}
Ok(())
}
struct EvaluatorEditorEvents<'evaluator> {
evaluator: &'evaluator mut dyn InteractiveEvaluator,
pending: Option<InteractiveNotice>,
}
impl EditorEventSource for EvaluatorEditorEvents<'_> {
fn next_external_print(&mut self) -> Option<EditorExternalPrint> {
let notice = self.evaluator.next_notice()?;
let event = EditorExternalPrint::new(notice.id().get(), notice.rendered());
self.pending = Some(notice);
Some(event)
}
fn acknowledge_external_print(
&mut self,
event: &EditorExternalPrint,
) -> Result<(), EditorError> {
let notice = self
.pending
.take()
.ok_or_else(|| EditorError::new("external print is not pending"))?;
if notice.id().get() != event.id() {
return Err(EditorError::new(format!(
"external print {} does not match pending notice {}",
event.id(),
notice.id().get()
)));
}
self.evaluator
.acknowledge_notice(¬ice)
.map_err(|error| EditorError::with_source("cannot acknowledge external print", error))
}
}
/// Runs one synchronous interactive session with persistent editor and evaluator state.
pub fn run_interactive_session(
editor: &mut dyn LineEditor,
evaluator: &mut dyn InteractiveEvaluator,
prompt: &EditorPrompt,
program_output: &mut dyn Write,
diagnostic_output: &mut dyn Write,
) -> Result<InteractiveExit, InteractiveSessionError> {
loop {
render_pending_notices(editor, evaluator)?;
if let Some(catalog) = evaluator.completion_catalog() {
editor.set_completion_catalog(catalog);
}
let event = {
let mut events = EvaluatorEditorEvents {
evaluator,
pending: None,
};
editor
.read_line_with_events(prompt, &mut events)
.map_err(InteractiveSessionError::Editor)?
};
match event {
EditorEvent::Submitted(source) => {
let evaluation = evaluator.evaluate(&source, program_output);
if let Err(InteractiveEvaluationError::ProgramOutput(error)) = evaluation {
return Err(InteractiveSessionError::ProgramOutput(error));
}
program_output
.flush()
.map_err(InteractiveSessionError::ProgramOutput)?;
match evaluation {
Ok(EvaluationControl::Continue) => {}
Ok(EvaluationControl::Exit(status)) => match evaluator.request_exit() {
ExitDecision::Permitted => {
render_pending_notices(editor, evaluator)?;
return Ok(InteractiveExit::Requested(status));
}
ExitDecision::Refused { rendered } => editor
.write_notice(&rendered)
.map_err(InteractiveSessionError::Editor)?,
},
Err(InteractiveEvaluationError::Diagnostic(diagnostic)) => {
diagnostic_output
.write_all(diagnostic.rendered().as_bytes())
.map_err(InteractiveSessionError::DiagnosticOutput)?;
diagnostic_output
.flush()
.map_err(InteractiveSessionError::DiagnosticOutput)?;
}
Err(InteractiveEvaluationError::ProgramOutput(_)) => {
unreachable!("program-output failure is returned before the required flush")
}
}
}
EditorEvent::Cancelled => {}
EditorEvent::EndOfInput => match evaluator.request_exit() {
ExitDecision::Permitted => {
render_pending_notices(editor, evaluator)?;
return Ok(InteractiveExit::EndOfInput);
}
ExitDecision::Refused { rendered } => editor
.write_notice(&rendered)
.map_err(InteractiveSessionError::Editor)?,
},
EditorEvent::HostCommand(_) => {
return Err(InteractiveSessionError::UnsupportedEditorEvent(
"host command",
));
}
EditorEvent::ExternalBreak(_) => {
return Err(InteractiveSessionError::UnsupportedEditorEvent(
"external break",
));
}
}
}
}
/// Run an interactive session and map its normal or fatal result to a host exit.
///
/// Fatal cleanup always runs before status 1 is selected. If the diagnostic
/// stream itself failed, it is never reused to report that failure or cleanup
/// results.
pub fn run_interactive_driver(
editor: &mut dyn LineEditor,
evaluator: &mut dyn InteractiveEvaluator,
prompt: &EditorPrompt,
program_output: &mut dyn Write,
diagnostic_output: &mut dyn Write,
) -> HostExit {
match run_interactive_session(editor, evaluator, prompt, program_output, diagnostic_output) {
Ok(InteractiveExit::EndOfInput) => HostExit::Success,
Ok(InteractiveExit::Requested(code)) => HostExit::Code(code),
Err(error) => {
let cleanup_reports = evaluator.fatal_cleanup();
if !matches!(error, InteractiveSessionError::DiagnosticOutput(_)) {
let rendered = render_fatal_report(&cleanup_reports, &error);
let _ = write_required(diagnostic_output, rendered.as_bytes());
}
HostExit::Failure
}
}
}
fn render_fatal_report(cleanup_reports: &[String], error: &InteractiveSessionError) -> String {
let mut rendered = cleanup_reports.concat();
let _ = writeln!(rendered, "fsh: {error}");
let mut source = error.source();
while let Some(cause) = source {
let _ = writeln!(rendered, "fsh: caused by: {cause}");
source = cause.source();
}
rendered
}
fn write_required(output: &mut dyn Write, bytes: &[u8]) -> io::Result<()> {
output.write_all(bytes)?;
output.flush()
}