Skip to main content

nodejs/stdlib/
readline.rs

1//! Node `readline` module — a pragmatic, synchronous interface.
2//!
3//! Node's real `readline` is event-driven over a stream: it registers `'line'`,
4//! `'close'`, and keypress handlers and fires them asynchronously as the input
5//! stream produces data. node-js has no interactive stdin loop at this layer (the
6//! event loop drives timers/promises/I-O-thread tasks, not a live TTY reader), so
7//! this module implements the parts that CAN be honest without one:
8//!
9//! * REAL: `interface.question(query, cb)` writes `query` to stdout and reads
10//!   exactly ONE line from stdin synchronously (`std::io::stdin().read_line`),
11//!   then invokes `cb(line)` with the trimmed line. This is a genuine blocking
12//!   read, matching the observable result of Node's `question` for a single
13//!   prompt.
14//! * REAL: `interface.write(data)` writes to stdout; `prompt()` writes the stored
15//!   prompt; `setPrompt`/`getPrompt` manage it; the module cursor helpers
16//!   (`cursorTo`/`moveCursor`/`clearLine`/`clearScreenDown`) emit the
17//!   corresponding ANSI control sequences to stdout.
18//! * NOT MODELED (documented, never faked): the asynchronous `'line'`/`'close'`
19//!   event streaming. `interface.on('line', cb)` accepts and stores the listener
20//!   (so it is not lost and chaining returns `this`), but node-js never
21//!   asynchronously emits `'line'` — there is no background stdin reader. Use
22//!   `question` for real line input. `close()`/`pause()`/`resume()` are no-ops.
23//!
24//! An Interface is a plain object tagged `@@native = "Interface"` carrying the
25//! passed `@@input`/`@@output` streams (kept for fidelity; the real read/write
26//! always uses process stdin/stdout), the current `@@prompt`, and a hidden
27//! `@@listeners` object of registered event callbacks.
28
29use crate::host::{is_callable, with_host, JsObj};
30use fusevm::Value;
31use indexmap::IndexMap;
32use std::io::{self, Write};
33
34pub const METHODS: &[&str] = &[
35    "createInterface",
36    "clearLine",
37    "clearScreenDown",
38    "cursorTo",
39    "moveCursor",
40    "emitKeypressEvents",
41];
42
43/// Methods dispatched on an `@@native = "Interface"` object (reported to the
44/// parent for `instance_has_method` wiring).
45pub const INTERFACE_METHODS: &[&str] = &[
46    "question",
47    "write",
48    "close",
49    "pause",
50    "resume",
51    "prompt",
52    "setPrompt",
53    "getPrompt",
54    "on",
55    "once",
56    "addListener",
57    "prependListener",
58    "removeListener",
59    "off",
60    "removeAllListeners",
61];
62
63/// The `readline/promises` surface: the same module, whose `createInterface`
64/// builds an Interface with a promise-returning `question`.
65pub const PROMISES_METHODS: &[&str] = &["createInterface"];
66
67pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
68    Some(match method {
69        "createInterface" => Ok(create_interface(args, false)),
70        // Cursor / line control: emit the ANSI sequence to stdout. Node passes the
71        // target stream as the first arg; node-js writes to the real stdout (the
72        // usual `process.stdout` target). Each returns `true` (write accepted).
73        "cursorTo" => {
74            let x = super::arg_num(args, 1);
75            let y = args.get(2).filter(|v| !matches!(v, Value::Undef));
76            let seq = match y {
77                Some(yv) => format!(
78                    "\x1b[{};{}H",
79                    with_host(|h| h.to_number(yv)) as i64 + 1,
80                    x as i64 + 1
81                ),
82                None => format!("\x1b[{}G", x as i64 + 1),
83            };
84            write_stdout(&seq);
85            Ok(Value::Bool(true))
86        }
87        "moveCursor" => {
88            let dx = super::arg_num(args, 1) as i64;
89            let dy = super::arg_num(args, 2) as i64;
90            let mut seq = String::new();
91            if dx > 0 {
92                seq.push_str(&format!("\x1b[{dx}C"));
93            } else if dx < 0 {
94                seq.push_str(&format!("\x1b[{}D", -dx));
95            }
96            if dy > 0 {
97                seq.push_str(&format!("\x1b[{dy}B"));
98            } else if dy < 0 {
99                seq.push_str(&format!("\x1b[{}A", -dy));
100            }
101            write_stdout(&seq);
102            Ok(Value::Bool(true))
103        }
104        "clearLine" => {
105            let dir = super::arg_num(args, 1);
106            // dir < 0 → to start (1K); dir > 0 → to end (0K); 0 → whole line (2K).
107            let seq = if dir < 0.0 {
108                "\x1b[1K"
109            } else if dir > 0.0 {
110                "\x1b[0K"
111            } else {
112                "\x1b[2K"
113            };
114            write_stdout(seq);
115            Ok(Value::Bool(true))
116        }
117        "clearScreenDown" => {
118            write_stdout("\x1b[0J");
119            Ok(Value::Bool(true))
120        }
121        // `readline.emitKeypressEvents(stream)` normally attaches an input decoder
122        // that makes `stream` emit `'keypress'` events. node-js has no background
123        // TTY reader driving async input events (see the module docs), so there is
124        // nothing to attach: an honest no-op rather than a fake key stream.
125        "emitKeypressEvents" => Ok(Value::Undef),
126        _ => return None,
127    })
128}
129
130/// `new readline.Interface(options | input[, output])` — the class form of
131/// `createInterface`, producing the same `@@native = "Interface"` object.
132/// Requires the parent to route `"Interface"` construction into this fn.
133pub fn construct(args: &[Value]) -> Result<Value, String> {
134    Ok(create_interface(args, false))
135}
136
137/// A non-function member of the `readline` namespace (reachable via
138/// `namespace_property` IF the parent routes `"readline"` into `stdlib::constant`).
139/// `readline.Interface` is the interface constructor.
140pub fn constant(name: &str) -> Option<Value> {
141    match name {
142        "Interface" => Some(with_host(|h| h.alloc(JsObj::Builtin("Interface".into())))),
143        _ => None,
144    }
145}
146
147/// `readline.createInterface(options | input[, output])` → an Interface object.
148/// `require('readline/promises').<method>`.
149pub fn promises_call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
150    match method {
151        "createInterface" => Some(Ok(create_interface(args, true))),
152        _ => None,
153    }
154}
155
156fn create_interface(args: &[Value], promises: bool) -> Value {
157    // Options object form `{ input, output }` vs positional `(input, output)`.
158    let (input, output) = match args.first() {
159        Some(o) if opt_prop(o, "input").is_some() => (
160            opt_prop(o, "input").unwrap_or(Value::Undef),
161            opt_prop(o, "output").unwrap_or(Value::Undef),
162        ),
163        _ => (
164            args.first().cloned().unwrap_or(Value::Undef),
165            args.get(1).cloned().unwrap_or(Value::Undef),
166        ),
167    };
168    with_host(|h| {
169        let listeners = h.new_object(IndexMap::new());
170        let prompt = h.new_str("> ");
171        let mut m = IndexMap::new();
172        m.insert("@@native".into(), h.new_str("Interface"));
173        m.insert("@@input".into(), input);
174        m.insert("@@output".into(), output);
175        m.insert("@@prompt".into(), prompt);
176        m.insert("@@listeners".into(), listeners);
177        if promises {
178            let flag = h.new_str("1");
179            m.insert("@@promises".into(), flag);
180        }
181        h.new_object(m)
182    })
183}
184
185/// Dispatch a method on an Interface instance (`@@native = "Interface"`).
186pub fn instance_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
187    match method {
188        // REAL synchronous single-line read: write the query, read one stdin line,
189        // invoke the callback with it. Returns undefined (Node's callback form).
190        "question" => {
191            let query = with_host(|h| args.first().map(|v| h.str_of(v)).unwrap_or_default());
192            write_stdout(&query);
193            let line = read_line();
194            // `require('readline/promises')` builds an Interface whose
195            // `question` RESOLVES with the line instead of taking a callback.
196            // The two forms are the same read; only the handoff differs.
197            if read_hidden(recv, "@@promises") == "1" {
198                let line_val = with_host(|h| h.new_str(line));
199                return crate::builtins::promise_resolve_pub(line_val);
200            }
201            // The callback is the last callable argument (Node: `question(q, cb)`
202            // or `question(q, options, cb)`).
203            // The `.find` predicate receives `&&Value`; deref once so `is_callable`
204            // sees a `&Value`. `find` yields `Option<&Value>`, cloned to `Value`.
205            let cb = args
206                .iter()
207                .rev()
208                .find(|v| with_host(|h| is_callable(h, v)))
209                .cloned();
210            if let Some(cb) = cb {
211                let line_val = with_host(|h| h.new_str(line));
212                crate::host::invoke(&cb, vec![line_val], None)?;
213            }
214            Ok(Value::Undef)
215        }
216        "write" => {
217            let data = with_host(|h| args.first().map(|v| h.str_of(v)).unwrap_or_default());
218            write_output(recv, &data);
219            Ok(Value::Undef)
220        }
221        "prompt" => {
222            let p = read_hidden(recv, "@@prompt");
223            write_output(recv, &p);
224            Ok(Value::Undef)
225        }
226        "setPrompt" => {
227            let p = with_host(|h| args.first().map(|v| h.str_of(v)).unwrap_or_default());
228            with_host(|h| {
229                let pv = h.new_str(p);
230                if let Some(JsObj::Object(m)) = h.get_mut(recv) {
231                    m.insert("@@prompt".into(), pv);
232                }
233            });
234            Ok(Value::Undef)
235        }
236        // `read_hidden` takes the host, so reading it INSIDE `with_host` borrowed
237        // the same RefCell twice and aborted the process — a Rust panic, not a
238        // throw, so no JS `try` could catch it. Read first, then borrow.
239        "getPrompt" => {
240            let prompt = read_hidden(recv, "@@prompt");
241            Ok(with_host(|h| h.new_str(prompt)))
242        }
243        // Listener registration: stored under `@@listeners[event]` so it is not
244        // lost and chaining returns `this`. node-js does NOT asynchronously emit
245        // `'line'`/`'close'` (no background stdin reader) — use `question` for
246        // real input.
247        "on" | "once" | "addListener" | "prependListener" => {
248            if let (Some(ev), Some(cb)) = (args.first(), args.get(1)) {
249                let event = with_host(|h| h.str_of(ev));
250                store_listener(recv, &event, cb.clone());
251            }
252            Ok(recv.clone())
253        }
254        "removeListener" | "off" | "removeAllListeners" => Ok(recv.clone()),
255        // No interactive loop to tear down / pause; honest no-ops.
256        "close" | "pause" | "resume" => Ok(Value::Undef),
257        _ => Err(crate::host::type_error(&format!(
258            "{method} is not a function"
259        ))),
260    }
261}
262
263/// Read the `key` hidden string property of `recv`.
264fn read_hidden(recv: &Value, key: &str) -> String {
265    with_host(|h| match h.get(recv) {
266        Some(JsObj::Object(p)) => p.get(key).map(|v| h.str_of(v)).unwrap_or_default(),
267        _ => String::new(),
268    })
269}
270
271/// Append `cb` to `recv`'s `@@listeners[event]` array (created on demand).
272fn store_listener(recv: &Value, event: &str, cb: Value) {
273    let listeners = with_host(|h| match h.get(recv) {
274        Some(JsObj::Object(p)) => p.get("@@listeners").cloned(),
275        _ => None,
276    });
277    let Some(listeners) = listeners else { return };
278    with_host(|h| {
279        let arr = match h.get(&listeners) {
280            Some(JsObj::Object(p)) => p.get(event).cloned(),
281            _ => None,
282        };
283        let arr = arr.filter(|a| matches!(h.get(a), Some(JsObj::Array(_))));
284        match arr {
285            Some(a) => {
286                if let Some(JsObj::Array(items)) = h.get_mut(&a) {
287                    items.push(cb);
288                }
289            }
290            None => {
291                let a = h.new_array(vec![cb]);
292                if let Some(JsObj::Object(p)) = h.get_mut(&listeners) {
293                    p.insert(event.to_string(), a);
294                }
295            }
296        }
297    });
298}
299
300/// Read one line from stdin, stripping the trailing CR/LF. EOF yields "".
301fn read_line() -> String {
302    let mut line = String::new();
303    let _ = io::stdin().read_line(&mut line);
304    while line.ends_with('\n') || line.ends_with('\r') {
305        line.pop();
306    }
307    line
308}
309
310/// Write `s` to real stdout and flush (this is explicit program output — a
311/// readline prompt / write — not informational chatter).
312fn write_stdout(s: &str) {
313    let mut out = io::stdout();
314    let _ = out.write_all(s.as_bytes());
315    let _ = out.flush();
316}
317
318/// Write to the interface's configured `output` stream, falling back to stdout
319/// when it has none.
320///
321/// `createInterface({input, output})` records `@@output` and Node writes
322/// `prompt()` and `write()` THROUGH it — the option exists so a caller can
323/// capture or redirect that text. Both went straight to `io::stdout()` here, so
324/// an interface given its own output printed to the process's stdout anyway and
325/// the supplied stream never saw a byte.
326fn write_output(recv: &Value, s: &str) {
327    let out = opt_prop(recv, "@@output").unwrap_or(Value::Undef);
328    if matches!(out, Value::Obj(_)) {
329        let payload = with_host(|h| h.new_str(s.to_string()));
330        if crate::host::call_method(&out, "write", vec![payload]).is_ok() {
331            return;
332        }
333    }
334    write_stdout(s);
335}
336
337/// An own property of `v` if `v` is a plain object, else `None`.
338fn opt_prop(v: &Value, key: &str) -> Option<Value> {
339    with_host(|h| match h.get(v) {
340        Some(JsObj::Object(p)) => p.get(key).cloned(),
341        _ => None,
342    })
343}