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}