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}