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_output(recv, &data);
196 Ok(Value::Undef)
197 }
198 "prompt" => {
199 let p = read_hidden(recv, "@@prompt");
200 write_output(recv, &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 // `read_hidden` takes the host, so reading it INSIDE `with_host` borrowed
214 // the same RefCell twice and aborted the process — a Rust panic, not a
215 // throw, so no JS `try` could catch it. Read first, then borrow.
216 "getPrompt" => {
217 let prompt = read_hidden(recv, "@@prompt");
218 Ok(with_host(|h| h.new_str(prompt)))
219 }
220 // Listener registration: stored under `@@listeners[event]` so it is not
221 // lost and chaining returns `this`. node-js does NOT asynchronously emit
222 // `'line'`/`'close'` (no background stdin reader) — use `question` for
223 // real input.
224 "on" | "once" | "addListener" | "prependListener" => {
225 if let (Some(ev), Some(cb)) = (args.first(), args.get(1)) {
226 let event = with_host(|h| h.str_of(ev));
227 store_listener(recv, &event, cb.clone());
228 }
229 Ok(recv.clone())
230 }
231 "removeListener" | "off" | "removeAllListeners" => Ok(recv.clone()),
232 // No interactive loop to tear down / pause; honest no-ops.
233 "close" | "pause" | "resume" => Ok(Value::Undef),
234 _ => Err(crate::host::type_error(&format!(
235 "{method} is not a function"
236 ))),
237 }
238}
239
240/// Read the `key` hidden string property of `recv`.
241fn read_hidden(recv: &Value, key: &str) -> String {
242 with_host(|h| match h.get(recv) {
243 Some(JsObj::Object(p)) => p.get(key).map(|v| h.str_of(v)).unwrap_or_default(),
244 _ => String::new(),
245 })
246}
247
248/// Append `cb` to `recv`'s `@@listeners[event]` array (created on demand).
249fn store_listener(recv: &Value, event: &str, cb: Value) {
250 let listeners = with_host(|h| match h.get(recv) {
251 Some(JsObj::Object(p)) => p.get("@@listeners").cloned(),
252 _ => None,
253 });
254 let Some(listeners) = listeners else { return };
255 with_host(|h| {
256 let arr = match h.get(&listeners) {
257 Some(JsObj::Object(p)) => p.get(event).cloned(),
258 _ => None,
259 };
260 let arr = arr.filter(|a| matches!(h.get(a), Some(JsObj::Array(_))));
261 match arr {
262 Some(a) => {
263 if let Some(JsObj::Array(items)) = h.get_mut(&a) {
264 items.push(cb);
265 }
266 }
267 None => {
268 let a = h.new_array(vec![cb]);
269 if let Some(JsObj::Object(p)) = h.get_mut(&listeners) {
270 p.insert(event.to_string(), a);
271 }
272 }
273 }
274 });
275}
276
277/// Read one line from stdin, stripping the trailing CR/LF. EOF yields "".
278fn read_line() -> String {
279 let mut line = String::new();
280 let _ = io::stdin().read_line(&mut line);
281 while line.ends_with('\n') || line.ends_with('\r') {
282 line.pop();
283 }
284 line
285}
286
287/// Write `s` to real stdout and flush (this is explicit program output — a
288/// readline prompt / write — not informational chatter).
289fn write_stdout(s: &str) {
290 let mut out = io::stdout();
291 let _ = out.write_all(s.as_bytes());
292 let _ = out.flush();
293}
294
295/// Write to the interface's configured `output` stream, falling back to stdout
296/// when it has none.
297///
298/// `createInterface({input, output})` records `@@output` and Node writes
299/// `prompt()` and `write()` THROUGH it — the option exists so a caller can
300/// capture or redirect that text. Both went straight to `io::stdout()` here, so
301/// an interface given its own output printed to the process's stdout anyway and
302/// the supplied stream never saw a byte.
303fn write_output(recv: &Value, s: &str) {
304 let out = opt_prop(recv, "@@output").unwrap_or(Value::Undef);
305 if matches!(out, Value::Obj(_)) {
306 let payload = with_host(|h| h.new_str(s.to_string()));
307 if crate::host::call_method(&out, "write", vec![payload]).is_ok() {
308 return;
309 }
310 }
311 write_stdout(s);
312}
313
314/// An own property of `v` if `v` is a plain object, else `None`.
315fn opt_prop(v: &Value, key: &str) -> Option<Value> {
316 with_host(|h| match h.get(v) {
317 Some(JsObj::Object(p)) => p.get(key).cloned(),
318 _ => None,
319 })
320}