diff options
Diffstat (limited to 'cli/tools/repl.rs')
-rw-r--r-- | cli/tools/repl.rs | 612 |
1 files changed, 612 insertions, 0 deletions
diff --git a/cli/tools/repl.rs b/cli/tools/repl.rs new file mode 100644 index 000000000..be85dc813 --- /dev/null +++ b/cli/tools/repl.rs @@ -0,0 +1,612 @@ +// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. + +use crate::colors; +use crate::inspector::InspectorSession; +use crate::program_state::ProgramState; +use crate::worker::MainWorker; +use crate::worker::Worker; +use deno_core::error::AnyError; +use deno_core::serde_json::json; +use deno_core::serde_json::Value; +use regex::Captures; +use regex::Regex; +use rustyline::completion::Completer; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::validate::ValidationContext; +use rustyline::validate::ValidationResult; +use rustyline::validate::Validator; +use rustyline::Context; +use rustyline::Editor; +use rustyline_derive::{Helper, Hinter}; +use std::borrow::Cow; +use std::sync::mpsc::channel; +use std::sync::mpsc::sync_channel; +use std::sync::mpsc::Receiver; +use std::sync::mpsc::Sender; +use std::sync::mpsc::SyncSender; +use std::sync::Arc; +use std::sync::Mutex; + +// Provides helpers to the editor like validation for multi-line edits, completion candidates for +// tab completion. +#[derive(Helper, Hinter)] +struct Helper { + context_id: u64, + message_tx: SyncSender<(String, Option<Value>)>, + response_rx: Receiver<Result<Value, AnyError>>, + highlighter: LineHighlighter, +} + +impl Helper { + fn post_message( + &self, + method: &str, + params: Option<Value>, + ) -> Result<Value, AnyError> { + self.message_tx.send((method.to_string(), params))?; + self.response_rx.recv()? + } +} + +fn is_word_boundary(c: char) -> bool { + if c == '.' { + false + } else { + char::is_ascii_whitespace(&c) || char::is_ascii_punctuation(&c) + } +} + +impl Completer for Helper { + type Candidate = String; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> Result<(usize, Vec<String>), ReadlineError> { + let start = line[..pos].rfind(is_word_boundary).map_or_else(|| 0, |i| i); + let end = line[pos..] + .rfind(is_word_boundary) + .map_or_else(|| pos, |i| pos + i); + + let word = &line[start..end]; + let word = word.strip_prefix(is_word_boundary).unwrap_or(word); + let word = word.strip_suffix(is_word_boundary).unwrap_or(word); + + let fallback = format!(".{}", word); + + let (prefix, suffix) = match word.rfind('.') { + Some(index) => word.split_at(index), + None => ("globalThis", fallback.as_str()), + }; + + let evaluate_response = self + .post_message( + "Runtime.evaluate", + Some(json!({ + "contextId": self.context_id, + "expression": prefix, + "throwOnSideEffect": true, + "timeout": 200, + })), + ) + .unwrap(); + + if evaluate_response.get("exceptionDetails").is_some() { + let candidates = Vec::new(); + return Ok((pos, candidates)); + } + + if let Some(result) = evaluate_response.get("result") { + if let Some(object_id) = result.get("objectId") { + let get_properties_response = self + .post_message( + "Runtime.getProperties", + Some(json!({ + "objectId": object_id, + })), + ) + .unwrap(); + + if let Some(result) = get_properties_response.get("result") { + let candidates = result + .as_array() + .unwrap() + .iter() + .map(|r| r.get("name").unwrap().as_str().unwrap().to_string()) + .filter(|r| r.starts_with(&suffix[1..])) + .collect(); + + return Ok((pos - (suffix.len() - 1), candidates)); + } + } + } + + Ok((pos, Vec::new())) + } +} + +impl Validator for Helper { + fn validate( + &self, + ctx: &mut ValidationContext, + ) -> Result<ValidationResult, ReadlineError> { + let mut stack: Vec<char> = Vec::new(); + let mut literal: Option<char> = None; + let mut escape: bool = false; + + for c in ctx.input().chars() { + if escape { + escape = false; + continue; + } + + if c == '\\' { + escape = true; + continue; + } + + if let Some(v) = literal { + if c == v { + literal = None + } + + continue; + } else { + literal = match c { + '`' | '"' | '/' | '\'' => Some(c), + _ => None, + }; + } + + match c { + '(' | '[' | '{' => stack.push(c), + ')' | ']' | '}' => match (stack.pop(), c) { + (Some('('), ')') | (Some('['), ']') | (Some('{'), '}') => {} + (Some(left), _) => { + return Ok(ValidationResult::Invalid(Some(format!( + "Mismatched pairs: {:?} is not properly closed", + left + )))) + } + (None, _) => { + // While technically invalid when unpaired, it should be V8's task to output error instead. + // Thus marked as valid with no info. + return Ok(ValidationResult::Valid(None)); + } + }, + _ => {} + } + } + + if !stack.is_empty() || literal == Some('`') { + return Ok(ValidationResult::Incomplete); + } + + Ok(ValidationResult::Valid(None)) + } +} + +impl Highlighter for Helper { + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + hint.into() + } + + fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> { + self.highlighter.highlight(line, pos) + } + + fn highlight_candidate<'c>( + &self, + candidate: &'c str, + _completion: rustyline::CompletionType, + ) -> Cow<'c, str> { + self.highlighter.highlight(candidate, 0) + } + + fn highlight_char(&self, line: &str, _: usize) -> bool { + !line.is_empty() + } +} + +struct LineHighlighter { + regex: Regex, +} + +impl LineHighlighter { + fn new() -> Self { + let regex = Regex::new( + r#"(?x) + (?P<comment>(?:/\*[\s\S]*?\*/|//[^\n]*)) | + (?P<string>(?:"([^"\\]|\\.)*"|'([^'\\]|\\.)*'|`([^`\\]|\\.)*`)) | + (?P<regexp>/(?:(?:\\/|[^\n/]))*?/[gimsuy]*) | + (?P<number>\b\d+(?:\.\d+)?(?:e[+-]?\d+)*n?\b) | + (?P<infinity>\b(?:Infinity|NaN)\b) | + (?P<hexnumber>\b0x[a-fA-F0-9]+\b) | + (?P<octalnumber>\b0o[0-7]+\b) | + (?P<binarynumber>\b0b[01]+\b) | + (?P<boolean>\b(?:true|false)\b) | + (?P<null>\b(?:null)\b) | + (?P<undefined>\b(?:undefined)\b) | + (?P<keyword>\b(?:await|async|var|let|for|if|else|in|of|class|const|function|yield|return|with|case|break|switch|import|export|new|while|do|throw|catch|this)\b) | + "#, + ) + .unwrap(); + + Self { regex } + } +} + +impl Highlighter for LineHighlighter { + fn highlight<'l>(&self, line: &'l str, _: usize) -> Cow<'l, str> { + self + .regex + .replace_all(&line.to_string(), |caps: &Captures<'_>| { + if let Some(cap) = caps.name("comment") { + colors::gray(cap.as_str()).to_string() + } else if let Some(cap) = caps.name("string") { + colors::green(cap.as_str()).to_string() + } else if let Some(cap) = caps.name("regexp") { + colors::red(cap.as_str()).to_string() + } else if let Some(cap) = caps.name("number") { + colors::yellow(cap.as_str()).to_string() + } else if let Some(cap) = caps.name("boolean") { + colors::yellow(cap.as_str()).to_string() + } else if let Some(cap) = caps.name("null") { + colors::yellow(cap.as_str()).to_string() + } else if let Some(cap) = caps.name("undefined") { + colors::gray(cap.as_str()).to_string() + } else if let Some(cap) = caps.name("keyword") { + colors::cyan(cap.as_str()).to_string() + } else if let Some(cap) = caps.name("infinity") { + colors::yellow(cap.as_str()).to_string() + } else if let Some(cap) = caps.name("classes") { + colors::green_bold(cap.as_str()).to_string() + } else if let Some(cap) = caps.name("hexnumber") { + colors::yellow(cap.as_str()).to_string() + } else if let Some(cap) = caps.name("octalnumber") { + colors::yellow(cap.as_str()).to_string() + } else if let Some(cap) = caps.name("binarynumber") { + colors::yellow(cap.as_str()).to_string() + } else { + caps[0].to_string() + } + }) + .to_string() + .into() + } +} + +async fn post_message_and_poll( + worker: &mut Worker, + session: &mut InspectorSession, + method: &str, + params: Option<Value>, +) -> Result<Value, AnyError> { + let response = session.post_message(method, params); + tokio::pin!(response); + + loop { + tokio::select! { + result = &mut response => { + return result + } + + _ = worker.run_event_loop() => { + // A zero delay is long enough to yield the thread in order to prevent the loop from + // running hot for messages that are taking longer to resolve like for example an + // evaluation of top level await. + tokio::time::delay_for(tokio::time::Duration::from_millis(0)).await; + } + } + } +} + +async fn read_line_and_poll( + worker: &mut Worker, + session: &mut InspectorSession, + message_rx: &Receiver<(String, Option<Value>)>, + response_tx: &Sender<Result<Value, AnyError>>, + editor: Arc<Mutex<Editor<Helper>>>, +) -> Result<String, ReadlineError> { + let mut line = + tokio::task::spawn_blocking(move || editor.lock().unwrap().readline("> ")); + + let mut poll_worker = true; + + loop { + for (method, params) in message_rx.try_iter() { + response_tx + .send(session.post_message(&method, params).await) + .unwrap(); + } + + // Because an inspector websocket client may choose to connect at anytime when we have an + // inspector server we need to keep polling the worker to pick up new connections. + let mut timeout = + tokio::time::delay_for(tokio::time::Duration::from_millis(100)); + + tokio::select! { + result = &mut line => { + return result.unwrap(); + } + _ = worker.run_event_loop(), if poll_worker => { + poll_worker = false; + } + _ = &mut timeout => { + poll_worker = true + } + } + } +} + +static PRELUDE: &str = r#" +Object.defineProperty(globalThis, "_", { + configurable: true, + get: () => Deno[Deno.internal].lastEvalResult, + set: (value) => { + Object.defineProperty(globalThis, "_", { + value: value, + writable: true, + enumerable: true, + configurable: true, + }); + console.log("Last evaluation result is no longer saved to _."); + }, +}); + +Object.defineProperty(globalThis, "_error", { + configurable: true, + get: () => Deno[Deno.internal].lastThrownError, + set: (value) => { + Object.defineProperty(globalThis, "_error", { + value: value, + writable: true, + enumerable: true, + configurable: true, + }); + + console.log("Last thrown error is no longer saved to _error."); + }, +}); +"#; + +async fn inject_prelude( + worker: &mut MainWorker, + session: &mut InspectorSession, + context_id: u64, +) -> Result<(), AnyError> { + post_message_and_poll( + worker, + session, + "Runtime.evaluate", + Some(json!({ + "expression": PRELUDE, + "contextId": context_id, + })), + ) + .await?; + + Ok(()) +} + +pub async fn is_closing( + worker: &mut MainWorker, + session: &mut InspectorSession, + context_id: u64, +) -> Result<bool, AnyError> { + let closed = post_message_and_poll( + worker, + session, + "Runtime.evaluate", + Some(json!({ + "expression": "(globalThis.closed)", + "contextId": context_id, + })), + ) + .await? + .get("result") + .unwrap() + .get("value") + .unwrap() + .as_bool() + .unwrap(); + + Ok(closed) +} + +pub async fn run( + program_state: &ProgramState, + mut worker: MainWorker, +) -> Result<(), AnyError> { + let mut session = worker.create_inspector_session(); + + let history_file = program_state.dir.root.join("deno_history.txt"); + + post_message_and_poll(&mut *worker, &mut session, "Runtime.enable", None) + .await?; + + // Enabling the runtime domain will always send trigger one executionContextCreated for each + // context the inspector knows about so we grab the execution context from that since + // our inspector does not support a default context (0 is an invalid context id). + let mut context_id: u64 = 0; + for notification in session.notifications() { + let method = notification.get("method").unwrap().as_str().unwrap(); + let params = notification.get("params").unwrap(); + + if method == "Runtime.executionContextCreated" { + context_id = params + .get("context") + .unwrap() + .get("id") + .unwrap() + .as_u64() + .unwrap(); + } + } + + let (message_tx, message_rx) = sync_channel(1); + let (response_tx, response_rx) = channel(); + + let helper = Helper { + context_id, + message_tx, + response_rx, + highlighter: LineHighlighter::new(), + }; + + let editor = Arc::new(Mutex::new(Editor::new())); + + editor.lock().unwrap().set_helper(Some(helper)); + + editor + .lock() + .unwrap() + .load_history(history_file.to_str().unwrap()) + .unwrap_or(()); + + println!("Deno {}", crate::version::DENO); + println!("exit using ctrl+d or close()"); + + inject_prelude(&mut worker, &mut session, context_id).await?; + + while !is_closing(&mut worker, &mut session, context_id).await? { + let line = read_line_and_poll( + &mut *worker, + &mut session, + &message_rx, + &response_tx, + editor.clone(), + ) + .await; + match line { + Ok(line) => { + // It is a bit unexpected that { "foo": "bar" } is interpreted as a block + // statement rather than an object literal so we interpret it as an expression statement + // to match the behavior found in a typical prompt including browser developer tools. + let wrapped_line = if line.trim_start().starts_with('{') + && !line.trim_end().ends_with(';') + { + format!("({})", &line) + } else { + line.clone() + }; + + let evaluate_response = post_message_and_poll( + &mut *worker, + &mut session, + "Runtime.evaluate", + Some(json!({ + "expression": format!("'use strict'; void 0;\n{}", &wrapped_line), + "contextId": context_id, + "replMode": true, + })), + ) + .await?; + + // If that fails, we retry it without wrapping in parens letting the error bubble up to the + // user if it is still an error. + let evaluate_response = + if evaluate_response.get("exceptionDetails").is_some() + && wrapped_line != line + { + post_message_and_poll( + &mut *worker, + &mut session, + "Runtime.evaluate", + Some(json!({ + "expression": format!("'use strict'; void 0;\n{}", &line), + "contextId": context_id, + "replMode": true, + })), + ) + .await? + } else { + evaluate_response + }; + + let evaluate_result = evaluate_response.get("result").unwrap(); + let evaluate_exception_details = + evaluate_response.get("exceptionDetails"); + + if evaluate_exception_details.is_some() { + post_message_and_poll( + &mut *worker, + &mut session, + "Runtime.callFunctionOn", + Some(json!({ + "executionContextId": context_id, + "functionDeclaration": "function (object) { Deno[Deno.internal].lastThrownError = object; }", + "arguments": [ + evaluate_result, + ], + })), + ).await?; + } else { + post_message_and_poll( + &mut *worker, + &mut session, + "Runtime.callFunctionOn", + Some(json!({ + "executionContextId": context_id, + "functionDeclaration": "function (object) { Deno[Deno.internal].lastEvalResult = object; }", + "arguments": [ + evaluate_result, + ], + })), + ).await?; + } + + // TODO(caspervonb) we should investigate using previews here but to keep things + // consistent with the previous implementation we just get the preview result from + // Deno.inspectArgs. + let inspect_response = + post_message_and_poll( + &mut *worker, + &mut session, + "Runtime.callFunctionOn", + Some(json!({ + "executionContextId": context_id, + "functionDeclaration": "function (object) { return Deno[Deno.internal].inspectArgs(['%o', object], { colors: !Deno.noColor }); }", + "arguments": [ + evaluate_result, + ], + })), + ).await?; + + let inspect_result = inspect_response.get("result").unwrap(); + + let value = inspect_result.get("value").unwrap().as_str().unwrap(); + let output = match evaluate_exception_details { + Some(_) => format!("Uncaught {}", value), + None => value.to_string(), + }; + + println!("{}", output); + + editor.lock().unwrap().add_history_entry(line.as_str()); + } + Err(ReadlineError::Interrupted) => { + println!("exit using ctrl+d or close()"); + continue; + } + Err(ReadlineError::Eof) => { + break; + } + Err(err) => { + println!("Error: {:?}", err); + break; + } + } + } + + std::fs::create_dir_all(history_file.parent().unwrap())?; + editor + .lock() + .unwrap() + .save_history(history_file.to_str().unwrap())?; + + Ok(()) +} |