use std::future::Future; use std::io::{self, BufRead, IsTerminal, Write}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver}; use std::task::Poll; use std::thread; use std::time::Duration; use avril_core::{Error, Runtime}; use futures::StreamExt; use futures::channel::mpsc::{self as async_mpsc, UnboundedReceiver, UnboundedSender}; use futures::future::poll_fn; use oxc::allocator::Allocator; use oxc::ast::ast::{Expression, ImportAttributeKey, ImportDeclarationSpecifier, Statement}; use oxc::parser::Parser; use oxc::span::{GetSpan, SourceType}; use reedline::Signal; use serde_json::{Value, json}; use url::Url; mod line_editor; enum Input { Line(String), Eof, Cancel, Interrupt, Error(String), } enum Activity { Input(Option), RuntimeError(Error), StalledEvaluation, Background, } struct InterruptState { active: Arc, interrupted: Arc, handle: avril_core::v8::IsolateHandle, } pub(crate) fn run( runtime: &mut Runtime, referrer: &str, stdio: &avril_ext_stdio::Stdio, host: &tokio::runtime::Handle, reporter: Option<&crate::progress::Reporter>, ) -> Result<(), Error> { let source_url = Url::from_file_path(referrer) .map_err(|_| Error::JavaScript(format!("Invalid REPL referrer: {referrer}")))? .to_string(); runtime.enable_repl()?; let active = Arc::new(AtomicBool::new(false)); let interrupted = stdio.interrupt_flag(); let handle = runtime.isolate_handle(); let (input_sender, input_receiver) = async_mpsc::unbounded(); let (request, requests) = mpsc::channel::<()>(); let terminal = io::stdin().is_terminal() && io::stderr().is_terminal(); let input_thread_sender = input_sender.clone(); let input_thread = thread::Builder::new() .name("avril:repl-input".into()) .spawn(move || read_input(terminal, requests, input_thread_sender)) .map_err(|error| Error::JavaScript(error.to_string())); let signal_active = Arc::clone(&active); let signal_interrupted = Arc::clone(&interrupted); let signal_handle = handle.clone(); let signal_sender = input_sender; let result = match input_thread { Err(error) => Err(error), Ok(_input_thread) => { // Route Ctrl-C through the shared chaining registry instead of // ctrlc's overwriting handler, so REPL listeners observe the // interrupt rather than being clobbered by it. let on_interrupt = move || { signal_interrupted.store(true, Ordering::SeqCst); let _ = signal_sender.unbounded_send(Input::Interrupt); if signal_active.load(Ordering::SeqCst) { // A blocking runSync cannot observe V8 termination, so // stop exactly the subprocess it is waiting on. Other // session work is left alone. avril_ext_process::Tree::global().stop_sync_processes(); signal_handle.terminate_execution(); } }; #[cfg(unix)] avril_ext_process::Tree::global() .claim_interrupt(host, Arc::new(on_interrupt)) .map_err(|error| Error::JavaScript(error.to_string()))?; #[cfg(not(unix))] ctrlc::set_handler(on_interrupt) .map_err(|error| Error::JavaScript(error.to_string()))?; run_session(Repl { runtime, host, source_url: &source_url, request, input: input_receiver, interrupts: InterruptState { active, interrupted, handle }, stdio, terminal, reporter, }) } }; let disabled = runtime.disable_repl(); result?; disabled } /// One interactive session's runtime, input plumbing, and terminal state. struct Repl<'a> { runtime: &'a mut Runtime, host: &'a tokio::runtime::Handle, source_url: &'a str, request: mpsc::Sender<()>, input: UnboundedReceiver, interrupts: InterruptState, stdio: &'a avril_ext_stdio::Stdio, terminal: bool, reporter: Option<&'a crate::progress::Reporter>, } fn run_session( Repl { runtime, host, source_url, request, input, interrupts, stdio, terminal, reporter, }: Repl<'_>, ) -> Result<(), Error> { let mut input = input; runtime.execute_script::<()>("", "globalThis._ = undefined")?; let InterruptState { active, interrupted, handle } = &interrupts; let mut source = String::new(); let mut input_pending = false; 'session: loop { if !input_pending { request .send(()) .map_err(|error| Error::JavaScript(error.to_string()))?; input_pending = true; } let line = loop { match host.block_on(next_activity(runtime, &mut input, active, None, terminal)) { Activity::Input(Some(Input::Line(line))) => { input_pending = false; if interrupted.swap(false, Ordering::SeqCst) { handle.cancel_terminate_execution(); source.clear(); continue 'session; } break Some(line); } Activity::Input(Some(Input::Eof)) | Activity::Input(None) => break None, Activity::Input(Some(Input::Cancel)) => { interrupted.store(false, Ordering::SeqCst); handle.cancel_terminate_execution(); source.clear(); input_pending = false; continue 'session; } Activity::Input(Some(Input::Interrupt)) => { interrupted.store(false, Ordering::SeqCst); handle.cancel_terminate_execution(); source.clear(); continue 'session; } Activity::Input(Some(Input::Error(error))) => { return Err(Error::JavaScript(error)); } Activity::RuntimeError(error) => { if interrupted.swap(false, Ordering::SeqCst) { handle.cancel_terminate_execution(); eprintln!("Evaluation interrupted"); source.clear(); } else { eprintln!("{error}"); } } Activity::Background | Activity::StalledEvaluation => {} } }; let Some(line) = line else { break; }; if source.is_empty() { match line.trim() { ".exit" | ".quit" => break, ".help" => { println!( "Enter JavaScript; incomplete expressions continue on the next line.\n.help Show this help\n.exit Exit the REPL" ); continue; } _ => {} } } if line.trim().is_empty() && source.is_empty() { continue; } source.push_str(&line); source.push('\n'); if is_incomplete(&source) { continue; } let expression = prepare_imports(&prepare_expression(&source)); source.clear(); let shown = Session { runtime: &mut *runtime, host, input: &mut input, interrupts: &interrupts, stdio, terminal, reporter, } .show(&expression, source_url); runtime.release_repl_objects()?; if shown? == Waited::Eof { break; } } Ok(()) } // Results go through avril:std's inspect so they match console.log and honor // custom inspectors; the inspector can only reach it from the guest realm. const INSPECT: &str = r#"async function (value, colors, remember) { if (remember) this._ = value; const { inspect } = await import("avril:std"); return inspect(value, { colors }); }"#; #[derive(PartialEq)] enum Waited { Response(String), Abandoned, Eof, } struct Session<'a> { runtime: &'a mut Runtime, host: &'a tokio::runtime::Handle, input: &'a mut UnboundedReceiver, interrupts: &'a InterruptState, stdio: &'a avril_ext_stdio::Stdio, terminal: bool, reporter: Option<&'a crate::progress::Reporter>, } struct ProgressScope<'a>(&'a crate::progress::Reporter); impl Drop for ProgressScope<'_> { fn drop(&mut self) { self.0.scope(false); } } impl Session<'_> { fn show(&mut self, expression: &str, source_url: &str) -> Result { let response = match self.run(|runtime| runtime.evaluate_repl(expression, source_url))? { Waited::Response(response) => response, waited => return Ok(waited), }; let (value, stderr) = match completion(&response) { Ok(completion) => completion, Err(message) => { eprintln!("{message}"); return Ok(Waited::Abandoned); } }; let colors = json!({ "value": avril_ext_console::colors(stderr) }); let remember = json!({ "value": !stderr }); let response = match self .run(|runtime| runtime.call_repl_function(INSPECT, &[value, colors, remember]))? { Waited::Response(response) => response, waited => return Ok(waited), }; let text = inspected(&response); if stderr { eprintln!("{text}"); } else { println!("{text}"); } io::stdout() .flush() .map_err(|error| Error::JavaScript(error.to_string()))?; Ok(Waited::Abandoned) } /// Dispatch an inspector command and drive the event loop until it answers, /// unless the user interrupts it or input ends first. fn run( &mut self, dispatch: impl FnOnce(&mut Runtime) -> Result, ) -> Result { let progress_scope = self.reporter.map(|reporter| { reporter.scope(true); ProgressScope(reporter) }); let InterruptState { active, interrupted, handle } = self.interrupts; active.store(true, Ordering::SeqCst); if interrupted.swap(false, Ordering::SeqCst) { active.store(false, Ordering::SeqCst); handle.cancel_terminate_execution(); return Ok(Waited::Abandoned); } let dispatched = dispatch(self.runtime); active.store(false, Ordering::SeqCst); let id = match dispatched { Ok(id) => id, Err(_) if interrupted.swap(false, Ordering::SeqCst) => { handle.cancel_terminate_execution(); drop(progress_scope); eprintln!("Evaluation interrupted"); return Ok(Waited::Abandoned); } Err(error) => return Err(error), }; loop { if let Some(response) = self.runtime.take_repl_response(id) { self.stdio .flush_interruptible(|| interrupted.load(Ordering::SeqCst)) .map_err(|error| Error::JavaScript(error.to_string()))?; if interrupted.swap(false, Ordering::SeqCst) { handle.cancel_terminate_execution(); drop(progress_scope); eprintln!("Evaluation interrupted"); return Ok(Waited::Abandoned); } return Ok(Waited::Response(response)); } match self.host.block_on(next_activity( self.runtime, self.input, active, Some(id), self.terminal, )) { Activity::Input(Some(Input::Interrupt | Input::Cancel)) => { if interrupted.swap(false, Ordering::SeqCst) { self.runtime.terminate_repl_execution()?; self.abandon(id); drop(progress_scope); eprintln!("Evaluation interrupted"); return Ok(Waited::Abandoned); } } Activity::RuntimeError(error) => { if interrupted.swap(false, Ordering::SeqCst) { self.abandon(id); drop(progress_scope); eprintln!("Evaluation interrupted"); return Ok(Waited::Abandoned); } if let Some(reporter) = self.reporter { reporter.flush(); } eprintln!("{error}"); } Activity::Input(Some(Input::Error(error))) => { return Err(Error::JavaScript(error)); } Activity::Input(Some(Input::Eof)) | Activity::Input(None) => { self.runtime.terminate_repl_execution()?; self.abandon(id); return Ok(Waited::Eof); } Activity::StalledEvaluation => { self.runtime.terminate_repl_execution()?; self.abandon(id); drop(progress_scope); eprintln!("REPL evaluation is still pending with no runnable host work"); return Ok(Waited::Abandoned); } Activity::Input(Some(Input::Line(_))) | Activity::Background => {} } } } fn abandon(&mut self, id: i32) { self.interrupts.handle.cancel_terminate_execution(); let _ = self.runtime.take_repl_response(id); } } fn next_activity<'a>( runtime: &'a mut Runtime, input: &'a mut UnboundedReceiver, active: &'a AtomicBool, evaluation: Option, terminal: bool, ) -> impl Future + 'a { let mut background_delay = None; poll_fn(move |context| { if evaluation.is_some_and(|id| runtime.has_repl_response(id)) { return Poll::Ready(Activity::Background); } if let Poll::Ready(input) = input.poll_next_unpin(context) { return Poll::Ready(Activity::Input(input)); } let background_pending = runtime.has_pending_background_tasks(); active.store(true, Ordering::SeqCst); let result = runtime.poll_event_loop(context); active.store(false, Ordering::SeqCst); match result { Err(error) => return Poll::Ready(Activity::RuntimeError(error)), Ok(true) => return Poll::Ready(Activity::Background), Ok(false) => {} } // The checkpoint can finish an inspector evaluation without a native completion. if evaluation.is_some_and(|id| runtime.has_repl_response(id)) { return Poll::Ready(Activity::Background); } // The default V8 platform does not notify the host about foreground tasks. if background_pending || runtime.has_pending_background_tasks() { let delay = background_delay .get_or_insert_with(|| Box::pin(tokio::time::sleep(Duration::from_millis(20)))); if delay.as_mut().poll(context).is_ready() { return Poll::Ready(Activity::Background); } } else { background_delay = None; if !terminal && evaluation.is_some() && !runtime.has_pending_work() { return Poll::Ready(Activity::StalledEvaluation); } } Poll::Pending }) } fn read_input(terminal: bool, requests: Receiver<()>, input: UnboundedSender) { if terminal { let mut editor = line_editor::create(); for () in requests { let line = match editor.read_line(&line_editor::Prompt) { Ok(Signal::Success(line)) => Input::Line(line), Ok(Signal::CtrlC) => Input::Cancel, Ok(Signal::CtrlD) => Input::Eof, Err(error) => Input::Error(error.to_string()), }; let eof = matches!(line, Input::Eof | Input::Error(_)); if input.unbounded_send(line).is_err() || eof { break; } } return; } let stdin = io::stdin(); let mut stdin = stdin.lock(); for _ in requests { let mut line = String::new(); let line = loop { match stdin.read_line(&mut line) { Ok(0) => break Input::Eof, Ok(_) => { break Input::Line(line.trim_end_matches(['\r', '\n']).to_owned()); } Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, Err(error) => break Input::Error(error.to_string()), } }; let eof = matches!(line, Input::Eof | Input::Error(_)); if input.unbounded_send(line).is_err() || eof { break; } } } fn is_incomplete(source: &str) -> bool { let allocator = Allocator::default(); let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse(); if parsed.diagnostics.is_empty() || prepare_expression(source) != source { return false; } let incomplete = |candidate: &str| { let allocator = Allocator::default(); let parsed = Parser::new(&allocator, candidate, SourceType::mjs()).parse(); let eof = candidate.len(); !parsed.diagnostics.is_empty() && parsed.diagnostics.iter().all(|error| { let message = error.message.to_ascii_lowercase(); let at_end = error .labels .iter() .any(|label| label.offset().saturating_add(label.len()) as usize >= eof); if message.contains("unterminated string") { error.labels.iter().any(|label| { candidate.as_bytes().get(label.offset() as usize) == Some(&b'`') }) || candidate.ends_with("\\\n") } else { at_end && !message.contains("unterminated regular expression") } }) }; incomplete(source) || incomplete(&format!("({source}")) } fn prepare_expression(source: &str) -> String { let wrapped = format!("({source})"); let allocator = Allocator::default(); let parsed = Parser::new(&allocator, &wrapped, SourceType::mjs()).parse(); if parsed.diagnostics.is_empty() && matches!( parsed.program.body.as_slice(), [Statement::ExpressionStatement(statement)] if matches!(statement.expression.without_parentheses(), Expression::ObjectExpression(_)) ) { return wrapped; } source.to_owned() } fn prepare_imports(source: &str) -> String { let allocator = Allocator::default(); let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse(); if !parsed.diagnostics.is_empty() { return source.to_owned(); } let mut result = String::new(); let mut offset = 0; for statement in &parsed.program.body { let Statement::ImportDeclaration(import) = statement else { continue; }; if import.phase.is_some() { return source.to_owned(); } let mut properties = Vec::new(); let mut namespace = None; for specifier in import.specifiers.iter().flatten() { match specifier { ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => { properties.push(format!("\"default\": {}", specifier.local.name)); } ImportDeclarationSpecifier::ImportSpecifier(specifier) => { let span = specifier.imported.span(); let key = &source[span.start as usize..span.end as usize]; properties.push(format!("{key}: {}", specifier.local.name)); } ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { namespace = Some(specifier.local.name.as_str()); } } } let mut options = String::new(); if let Some(clause) = &import.with_clause { let attributes = clause .with_entries .iter() .map(|attribute| { let key = match &attribute.key { ImportAttributeKey::Identifier(identifier) => { serde_json::to_string(identifier.name.as_str()).unwrap() } ImportAttributeKey::StringLiteral(literal) => source [literal.span.start as usize..literal.span.end as usize] .to_owned(), }; let value = &source [attribute.value.span.start as usize..attribute.value.span.end as usize]; format!("[{key}]: {value}") }) .collect::>() .join(", "); options = format!(", {{ with: {{ {attributes} }} }}"); } let specifier = &source[import.source.span.start as usize..import.source.span.end as usize]; let initializer = format!("await import({specifier}{options})"); let mut declarations = Vec::new(); if let Some(namespace) = namespace { declarations.push(format!("{namespace} = {initializer}")); } if !properties.is_empty() || namespace.is_none() { declarations.push(format!( "{{ {} }} = {}", properties.join(", "), namespace.unwrap_or(&initializer) )); } let start = import.span.start as usize; let end = import.span.end as usize; result.push_str(&source[offset..start]); result.push_str(&format!("const {};", declarations.join(", "))); // Keep diagnostics for subsequent statements on their original lines. result.extend( source[start..end] .chars() .filter(|ch| matches!(ch, '\r' | '\n' | '\u{2028}' | '\u{2029}')), ); offset = end; } result.push_str(&source[offset..]); result } /// An evaluation's completion or thrown value as an inspector `CallArgument`, /// and whether it was thrown. fn completion(response: &str) -> Result<(Value, bool), String> { let response = serde_json::from_str::(response) .map_err(|_| "Invalid V8 inspector response".to_owned())?; if let Some(error) = response.get("error") { return Err(error["message"] .as_str() .unwrap_or("Inspector error") .to_owned()); } let result = &response["result"]; if let Some(exception) = result.get("exceptionDetails") { return match exception.get("exception") { Some(thrown) => Ok((call_argument(thrown), true)), None => Err(exception["text"] .as_str() .unwrap_or("JavaScript exception") .to_owned()), }; } Ok((call_argument(&result["result"]), false)) } fn call_argument(remote: &Value) -> Value { for key in ["objectId", "unserializableValue", "value"] { if let Some(value) = remote.get(key) { return json!({ key: value }); } } json!({}) } fn inspected(response: &str) -> String { let response = serde_json::from_str::(response).unwrap_or_default(); let result = &response["result"]; result["result"]["value"] .as_str() .or_else(|| result["exceptionDetails"]["exception"]["description"].as_str()) .or_else(|| response["error"]["message"].as_str()) .unwrap_or("Inspection failed") .to_owned() } #[cfg(test)] mod tests { use super::{is_incomplete, prepare_expression, prepare_imports}; #[test] fn multiline_completeness_uses_the_javascript_parser() { assert!(is_incomplete("function greet() {\n")); assert!(is_incomplete("const value = {\n answer: 42,\n")); assert!(!is_incomplete("const value = 42;\n")); assert!(!is_incomplete("const = 42;\n")); assert!(is_incomplete("const value =\n")); assert!(is_incomplete("{\n answer: 42,\n")); assert!(is_incomplete("`brace {\n")); assert!(is_incomplete("/* comment\n")); assert!(!is_incomplete("'broken\n")); assert!(!is_incomplete("/broken\n")); assert!(is_incomplete("import {\n inspect as show,\n")); assert!(is_incomplete("import { inspect } from\n")); assert!(!is_incomplete("import { inspect } from 'avril:std'\n")); } #[test] fn imports_preserve_all_javascript_line_terminators() { for newline in ["\n", "\r", "\r\n", "\u{2028}", "\u{2029}"] { let source = format!( "import {{{newline}inspect{newline}}} from 'avril:std';{newline}throw Error('original line');" ); let prepared = prepare_imports(&source); let original_line = source .split(newline) .position(|line| line.contains("throw Error")) .unwrap(); let prepared_line = prepared .split(newline) .position(|line| line.contains("throw Error")) .unwrap(); assert_eq!(prepared_line, original_line, "{newline:?}"); } } #[test] fn object_literals_are_evaluated_as_expressions() { assert_eq!(prepare_expression("{ answer: 42 }\n"), "({ answer: 42 }\n)"); assert_eq!( prepare_expression("/* comment */ { answer: 42 }\n"), "(/* comment */ { answer: 42 }\n)" ); assert_eq!(prepare_expression("if (true) {}\n"), "if (true) {}\n"); } }