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
63pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
64    Some(match method {
65        "createInterface" => Ok(create_interface(args)),
66        // Cursor / line control: emit the ANSI sequence to stdout. Node passes the
67        // target stream as the first arg; node-js writes to the real stdout (the
68        // usual `process.stdout` target). Each returns `true` (write accepted).
69        "cursorTo" => {
70            let x = super::arg_num(args, 1);
71            let y = args.get(2).filter(|v| !matches!(v, Value::Undef));
72            let seq = match y {
73                Some(yv) => format!(
74                    "\x1b[{};{}H",
75                    with_host(|h| h.to_number(yv)) as i64 + 1,
76                    x as i64 + 1
77                ),
78                None => format!("\x1b[{}G", x as i64 + 1),
79            };
80            write_stdout(&seq);
81            Ok(Value::Bool(true))
82        }
83        "moveCursor" => {
84            let dx = super::arg_num(args, 1) as i64;
85            let dy = super::arg_num(args, 2) as i64;
86            let mut seq = String::new();
87            if dx > 0 {
88                seq.push_str(&format!("\x1b[{dx}C"));
89            } else if dx < 0 {
90                seq.push_str(&format!("\x1b[{}D", -dx));
91            }
92            if dy > 0 {
93                seq.push_str(&format!("\x1b[{dy}B"));
94            } else if dy < 0 {
95                seq.push_str(&format!("\x1b[{}A", -dy));
96            }
97            write_stdout(&seq);
98            Ok(Value::Bool(true))
99        }
100        "clearLine" => {
101            let dir = super::arg_num(args, 1);
102            // dir < 0 → to start (1K); dir > 0 → to end (0K); 0 → whole line (2K).
103            let seq = if dir < 0.0 {
104                "\x1b[1K"
105            } else if dir > 0.0 {
106                "\x1b[0K"
107            } else {
108                "\x1b[2K"
109            };
110            write_stdout(seq);
111            Ok(Value::Bool(true))
112        }
113        "clearScreenDown" => {
114            write_stdout("\x1b[0J");
115            Ok(Value::Bool(true))
116        }
117        // `readline.emitKeypressEvents(stream)` normally attaches an input decoder
118        // that makes `stream` emit `'keypress'` events. node-js has no background
119        // TTY reader driving async input events (see the module docs), so there is
120        // nothing to attach: an honest no-op rather than a fake key stream.
121        "emitKeypressEvents" => Ok(Value::Undef),
122        _ => return None,
123    })
124}
125
126/// `new readline.Interface(options | input[, output])` — the class form of
127/// `createInterface`, producing the same `@@native = "Interface"` object.
128/// Requires the parent to route `"Interface"` construction into this fn.
129pub fn construct(args: &[Value]) -> Result<Value, String> {
130    Ok(create_interface(args))
131}
132
133/// A non-function member of the `readline` namespace (reachable via
134/// `namespace_property` IF the parent routes `"readline"` into `stdlib::constant`).
135/// `readline.Interface` is the interface constructor.
136pub fn constant(name: &str) -> Option<Value> {
137    match name {
138        "Interface" => Some(with_host(|h| h.alloc(JsObj::Builtin("Interface".into())))),
139        _ => None,
140    }
141}
142
143/// `readline.createInterface(options | input[, output])` → an Interface object.
144fn create_interface(args: &[Value]) -> Value {
145    // Options object form `{ input, output }` vs positional `(input, output)`.
146    let (input, output) = match args.first() {
147        Some(o) if opt_prop(o, "input").is_some() => (
148            opt_prop(o, "input").unwrap_or(Value::Undef),
149            opt_prop(o, "output").unwrap_or(Value::Undef),
150        ),
151        _ => (
152            args.first().cloned().unwrap_or(Value::Undef),
153            args.get(1).cloned().unwrap_or(Value::Undef),
154        ),
155    };
156    with_host(|h| {
157        let listeners = h.new_object(IndexMap::new());
158        let prompt = h.new_str("> ");
159        let mut m = IndexMap::new();
160        m.insert("@@native".into(), h.new_str("Interface"));
161        m.insert("@@input".into(), input);
162        m.insert("@@output".into(), output);
163        m.insert("@@prompt".into(), prompt);
164        m.insert("@@listeners".into(), listeners);
165        h.new_object(m)
166    })
167}
168
169/// Dispatch a method on an Interface instance (`@@native = "Interface"`).
170pub fn instance_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
171    match method {
172        // REAL synchronous single-line read: write the query, read one stdin line,
173        // invoke the callback with it. Returns undefined (Node's callback form).
174        "question" => {
175            let query = with_host(|h| args.first().map(|v| h.str_of(v)).unwrap_or_default());
176            write_stdout(&query);
177            let line = read_line();
178            // The callback is the last callable argument (Node: `question(q, cb)`
179            // or `question(q, options, cb)`).
180            // The `.find` predicate receives `&&Value`; deref once so `is_callable`
181            // sees a `&Value`. `find` yields `Option<&Value>`, cloned to `Value`.
182            let cb = args
183                .iter()
184                .rev()
185                .find(|v| with_host(|h| is_callable(h, v)))
186                .cloned();
187            if let Some(cb) = cb {
188                let line_val = with_host(|h| h.new_str(line));
189                crate::host::invoke(&cb, vec![line_val], None)?;
190            }
191            Ok(Value::Undef)
192        }
193        "write" => {
194            let data = with_host(|h| args.first().map(|v| h.str_of(v)).unwrap_or_default());
195            write_stdout(&data);
196            Ok(Value::Undef)
197        }
198        "prompt" => {
199            let p = read_hidden(recv, "@@prompt");
200            write_stdout(&p);
201            Ok(Value::Undef)
202        }
203        "setPrompt" => {
204            let p = with_host(|h| args.first().map(|v| h.str_of(v)).unwrap_or_default());
205            with_host(|h| {
206                let pv = h.new_str(p);
207                if let Some(JsObj::Object(m)) = h.get_mut(recv) {
208                    m.insert("@@prompt".into(), pv);
209                }
210            });
211            Ok(Value::Undef)
212        }
213        "getPrompt" => Ok(with_host(|h| h.new_str(read_hidden(recv, "@@prompt")))),
214        // Listener registration: stored under `@@listeners[event]` so it is not
215        // lost and chaining returns `this`. node-js does NOT asynchronously emit
216        // `'line'`/`'close'` (no background stdin reader) — use `question` for
217        // real input.
218        "on" | "once" | "addListener" | "prependListener" => {
219            if let (Some(ev), Some(cb)) = (args.first(), args.get(1)) {
220                let event = with_host(|h| h.str_of(ev));
221                store_listener(recv, &event, cb.clone());
222            }
223            Ok(recv.clone())
224        }
225        "removeListener" | "off" | "removeAllListeners" => Ok(recv.clone()),
226        // No interactive loop to tear down / pause; honest no-ops.
227        "close" | "pause" | "resume" => Ok(Value::Undef),
228        _ => Err(crate::host::type_error(&format!(
229            "{method} is not a function"
230        ))),
231    }
232}
233
234/// Read the `key` hidden string property of `recv`.
235fn read_hidden(recv: &Value, key: &str) -> String {
236    with_host(|h| match h.get(recv) {
237        Some(JsObj::Object(p)) => p.get(key).map(|v| h.str_of(v)).unwrap_or_default(),
238        _ => String::new(),
239    })
240}
241
242/// Append `cb` to `recv`'s `@@listeners[event]` array (created on demand).
243fn store_listener(recv: &Value, event: &str, cb: Value) {
244    let listeners = with_host(|h| match h.get(recv) {
245        Some(JsObj::Object(p)) => p.get("@@listeners").cloned(),
246        _ => None,
247    });
248    let Some(listeners) = listeners else { return };
249    with_host(|h| {
250        let arr = match h.get(&listeners) {
251            Some(JsObj::Object(p)) => p.get(event).cloned(),
252            _ => None,
253        };
254        let arr = arr.filter(|a| matches!(h.get(a), Some(JsObj::Array(_))));
255        match arr {
256            Some(a) => {
257                if let Some(JsObj::Array(items)) = h.get_mut(&a) {
258                    items.push(cb);
259                }
260            }
261            None => {
262                let a = h.new_array(vec![cb]);
263                if let Some(JsObj::Object(p)) = h.get_mut(&listeners) {
264                    p.insert(event.to_string(), a);
265                }
266            }
267        }
268    });
269}
270
271/// Read one line from stdin, stripping the trailing CR/LF. EOF yields "".
272fn read_line() -> String {
273    let mut line = String::new();
274    let _ = io::stdin().read_line(&mut line);
275    while line.ends_with('\n') || line.ends_with('\r') {
276        line.pop();
277    }
278    line
279}
280
281/// Write `s` to real stdout and flush (this is explicit program output — a
282/// readline prompt / write — not informational chatter).
283fn write_stdout(s: &str) {
284    let mut out = io::stdout();
285    let _ = out.write_all(s.as_bytes());
286    let _ = out.flush();
287}
288
289/// An own property of `v` if `v` is a plain object, else `None`.
290fn opt_prop(v: &Value, key: &str) -> Option<Value> {
291    with_host(|h| match h.get(v) {
292        Some(JsObj::Object(p)) => p.get(key).cloned(),
293        _ => None,
294    })
295}