@gjsify/readline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,360 @@
1
+ // Node.js readline module for GJS
2
+ // Reference: Node.js lib/readline.js
3
+
4
+ import { EventEmitter } from 'node:events';
5
+ import type { Readable, Writable } from 'node:stream';
6
+
7
+ export interface InterfaceOptions {
8
+ input?: Readable;
9
+ output?: Writable;
10
+ prompt?: string;
11
+ terminal?: boolean;
12
+ historySize?: number;
13
+ completer?: (line: string, callback: (err: Error | null, result: [string[], string]) => void) => void;
14
+ crlfDelay?: number;
15
+ removeHistoryDuplicates?: boolean;
16
+ escapeCodeTimeout?: number;
17
+ tabSize?: number;
18
+ }
19
+
20
+ /**
21
+ * readline.Interface — reads lines from a Readable stream.
22
+ */
23
+ export class Interface extends EventEmitter {
24
+ terminal: boolean;
25
+ line = '';
26
+ cursor = 0;
27
+
28
+ private _input: Readable | null;
29
+ private _output: Writable | null;
30
+ private _prompt: string;
31
+ private _closed = false;
32
+ private _paused = false;
33
+ history: string[];
34
+ private _historySize: number;
35
+ private _crlfDelay: number;
36
+ private _lineBuffer = '';
37
+ private _questionCallback: ((answer: string) => void) | null = null;
38
+
39
+ constructor(input?: Readable | InterfaceOptions, output?: Writable) {
40
+ super();
41
+
42
+ let opts: InterfaceOptions;
43
+ if (input && typeof input === 'object' && !('read' in input && typeof input.read === 'function')) {
44
+ opts = input as InterfaceOptions;
45
+ } else {
46
+ opts = { input: input as Readable, output };
47
+ }
48
+
49
+ this._input = opts.input || null;
50
+ this._output = opts.output || null;
51
+ this._prompt = opts.prompt || '> ';
52
+ this.terminal = opts.terminal ?? (this._output !== null);
53
+ this._historySize = opts.historySize ?? 30;
54
+ this.history = [];
55
+ this._crlfDelay = opts.crlfDelay ?? 100;
56
+
57
+ if (this._input) {
58
+ this._input.on('data', (chunk: Buffer | string) => this._onData(chunk));
59
+ this._input.on('end', () => this._onEnd());
60
+ this._input.on('error', (err: Error) => this.emit('error', err));
61
+
62
+ if ('setEncoding' in this._input && typeof this._input.setEncoding === 'function') {
63
+ this._input.setEncoding('utf8');
64
+ }
65
+ }
66
+ }
67
+
68
+ private _onData(chunk: Buffer | string): void {
69
+ if (this._closed || this._paused) return;
70
+
71
+ const str = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
72
+ this._lineBuffer += str;
73
+
74
+ // Process lines separated by \n, \r\n, or standalone \r
75
+ const lineEnd = /\r\n|\r|\n/;
76
+ let m: RegExpExecArray | null;
77
+ while ((m = lineEnd.exec(this._lineBuffer)) !== null) {
78
+ const line = this._lineBuffer.substring(0, m.index);
79
+ this._lineBuffer = this._lineBuffer.substring(m.index + m[0].length);
80
+ this._onLine(line);
81
+ }
82
+ }
83
+
84
+ private _onLine(line: string): void {
85
+ // Add to history
86
+ if (line.length > 0 && this._historySize > 0) {
87
+ if (this.history.length === 0 || this.history[0] !== line) {
88
+ this.history.unshift(line);
89
+ if (this.history.length > this._historySize) {
90
+ this.history.pop();
91
+ }
92
+ }
93
+ }
94
+
95
+ if (this._questionCallback) {
96
+ const cb = this._questionCallback;
97
+ this._questionCallback = null;
98
+ cb(line);
99
+ }
100
+
101
+ this.emit('line', line);
102
+ }
103
+
104
+ private _onEnd(): void {
105
+ // Emit remaining buffer as last line
106
+ if (this._lineBuffer.length > 0) {
107
+ this._onLine(this._lineBuffer);
108
+ this._lineBuffer = '';
109
+ }
110
+ this.close();
111
+ }
112
+
113
+ /** Set the prompt string. */
114
+ setPrompt(prompt: string): void {
115
+ this._prompt = prompt;
116
+ }
117
+
118
+ /** Get the current prompt string. */
119
+ getPrompt(): string {
120
+ return this._prompt;
121
+ }
122
+
123
+ /** Write the prompt to the output stream. */
124
+ prompt(preserveCursor?: boolean): void {
125
+ if (this._closed) {
126
+ throw new Error('readline was closed');
127
+ }
128
+ if (this._output) {
129
+ this._output.write(this._prompt);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Display the query and wait for user input.
135
+ */
136
+ question(query: string, callback: (answer: string) => void): void;
137
+ question(query: string, options: Record<string, unknown>, callback: (answer: string) => void): void;
138
+ question(query: string, optionsOrCallback: Record<string, unknown> | ((answer: string) => void), callback?: (answer: string) => void): void {
139
+ if (this._closed) {
140
+ throw new Error('readline was closed');
141
+ }
142
+
143
+ const cb = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback!;
144
+
145
+ this._questionCallback = cb;
146
+
147
+ if (this._output) {
148
+ this._output.write(query);
149
+ }
150
+ }
151
+
152
+ /** Write data to the output stream. */
153
+ write(data: string | Buffer | null, key?: { ctrl?: boolean; meta?: boolean; shift?: boolean; name?: string }): void {
154
+ if (this._closed) return;
155
+
156
+ if (data !== null && data !== undefined) {
157
+ if (this._output) {
158
+ this._output.write(data);
159
+ }
160
+ }
161
+ }
162
+
163
+ /** Close the interface. */
164
+ close(): void {
165
+ if (this._closed) return;
166
+ this._closed = true;
167
+
168
+ if (this._input) {
169
+ this._input.removeAllListeners('data');
170
+ this._input.removeAllListeners('end');
171
+ }
172
+
173
+ this.emit('close');
174
+ }
175
+
176
+ /** Pause the input stream. */
177
+ pause(): this {
178
+ if (this._closed) return this;
179
+ this._paused = true;
180
+
181
+ if (this._input && 'pause' in this._input && typeof this._input.pause === 'function') {
182
+ this._input.pause();
183
+ }
184
+
185
+ this.emit('pause');
186
+ return this;
187
+ }
188
+
189
+ /** Resume the input stream. */
190
+ resume(): this {
191
+ if (this._closed) return this;
192
+ this._paused = false;
193
+
194
+ if (this._input && 'resume' in this._input && typeof this._input.resume === 'function') {
195
+ this._input.resume();
196
+ }
197
+
198
+ this.emit('resume');
199
+ return this;
200
+ }
201
+
202
+ /** Get the current line content. */
203
+ getCursorPos(): { rows: number; cols: number } {
204
+ return { rows: 0, cols: this.cursor };
205
+ }
206
+
207
+ [Symbol.asyncIterator](): AsyncIterableIterator<string> {
208
+ const lines: string[] = [];
209
+ let resolve: ((value: IteratorResult<string>) => void) | null = null;
210
+ let done = false;
211
+
212
+ this.on('line', (line: string) => {
213
+ if (resolve) {
214
+ const r = resolve;
215
+ resolve = null;
216
+ r({ value: line, done: false });
217
+ } else {
218
+ lines.push(line);
219
+ }
220
+ });
221
+
222
+ this.on('close', () => {
223
+ done = true;
224
+ if (resolve) {
225
+ const r = resolve;
226
+ resolve = null;
227
+ r({ value: undefined as unknown as string, done: true });
228
+ }
229
+ });
230
+
231
+ return {
232
+ next(): Promise<IteratorResult<string>> {
233
+ if (lines.length > 0) {
234
+ return Promise.resolve({ value: lines.shift()!, done: false });
235
+ }
236
+ if (done) {
237
+ return Promise.resolve({ value: undefined as unknown as string, done: true });
238
+ }
239
+ return new Promise<IteratorResult<string>>((r) => { resolve = r; });
240
+ },
241
+ return(): Promise<IteratorResult<string>> {
242
+ done = true;
243
+ return Promise.resolve({ value: undefined as unknown as string, done: true });
244
+ },
245
+ [Symbol.asyncIterator]() { return this; },
246
+ };
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Create a readline Interface.
252
+ */
253
+ export function createInterface(input?: Readable | InterfaceOptions, output?: Writable, completer?: InterfaceOptions['completer'], terminal?: boolean): Interface {
254
+ if (typeof input === 'object' && input !== null && !('read' in input && typeof input.read === 'function')) {
255
+ return new Interface(input);
256
+ }
257
+ return new Interface({ input: input as Readable, output, completer, terminal });
258
+ }
259
+
260
+ // --- Terminal utility functions ---
261
+
262
+ /**
263
+ * Clear the current line of a TTY stream.
264
+ * dir: -1 = to the left, 1 = to the right, 0 = entire line
265
+ */
266
+ export function clearLine(stream: Writable, dir: number, callback?: () => void): boolean {
267
+ if (!stream || typeof stream.write !== 'function') {
268
+ if (callback) callback();
269
+ return true;
270
+ }
271
+
272
+ const code = dir < 0 ? '\x1b[1K' : dir > 0 ? '\x1b[0K' : '\x1b[2K';
273
+ return stream.write(code, callback);
274
+ }
275
+
276
+ /**
277
+ * Clear from cursor to end of screen.
278
+ */
279
+ export function clearScreenDown(stream: Writable, callback?: () => void): boolean {
280
+ if (!stream || typeof stream.write !== 'function') {
281
+ if (callback) callback();
282
+ return true;
283
+ }
284
+
285
+ return stream.write('\x1b[0J', callback);
286
+ }
287
+
288
+ /**
289
+ * Move cursor to the specified position.
290
+ */
291
+ export function cursorTo(
292
+ stream: Writable,
293
+ x: number,
294
+ y?: number | (() => void),
295
+ callback?: () => void,
296
+ ): boolean {
297
+ if (!stream || typeof stream.write !== 'function') {
298
+ if (typeof y === 'function') y();
299
+ else if (callback) callback();
300
+ return true;
301
+ }
302
+
303
+ if (typeof y === 'function') {
304
+ callback = y;
305
+ y = undefined;
306
+ }
307
+
308
+ const code = typeof y === 'number'
309
+ ? `\x1b[${y + 1};${x + 1}H`
310
+ : `\x1b[${x + 1}G`;
311
+
312
+ return stream.write(code, callback);
313
+ }
314
+
315
+ /**
316
+ * Move cursor relative to its current position.
317
+ */
318
+ export function moveCursor(
319
+ stream: Writable,
320
+ dx: number,
321
+ dy: number,
322
+ callback?: () => void,
323
+ ): boolean {
324
+ if (!stream || typeof stream.write !== 'function') {
325
+ if (callback) callback();
326
+ return true;
327
+ }
328
+
329
+ let code = '';
330
+ if (dx > 0) code += `\x1b[${dx}C`;
331
+ else if (dx < 0) code += `\x1b[${-dx}D`;
332
+ if (dy > 0) code += `\x1b[${dy}B`;
333
+ else if (dy < 0) code += `\x1b[${-dy}A`;
334
+
335
+ if (code) {
336
+ return stream.write(code, callback);
337
+ }
338
+
339
+ if (callback) callback();
340
+ return true;
341
+ }
342
+
343
+ /**
344
+ * Enable keypress events on a stream (no-op in GJS — full implementation
345
+ * requires raw terminal mode which depends on the input stream type).
346
+ */
347
+ export function emitKeypressEvents(_stream: Readable, _interface?: Interface): void {
348
+ // Keypress event emission requires raw terminal mode.
349
+ // This is a best-effort no-op; real keypress detection needs platform-specific code.
350
+ }
351
+
352
+ export default {
353
+ Interface,
354
+ createInterface,
355
+ clearLine,
356
+ clearScreenDown,
357
+ cursorTo,
358
+ moveCursor,
359
+ emitKeypressEvents,
360
+ };
@@ -0,0 +1,122 @@
1
+ // Tests for readline/promises module
2
+ // Reference: Node.js lib/readline/promises.js
3
+
4
+ import { describe, it, expect } from '@gjsify/unit';
5
+ import { createInterface, Interface } from 'node:readline/promises';
6
+ import { Readable, Writable, PassThrough } from 'node:stream';
7
+
8
+ export default async () => {
9
+ await describe('readline/promises', async () => {
10
+ await it('should export createInterface function', async () => {
11
+ expect(typeof createInterface).toBe('function');
12
+ });
13
+
14
+ await it('should export Interface class', async () => {
15
+ expect(Interface).toBeDefined();
16
+ });
17
+
18
+ await it('should create an interface with input stream', async () => {
19
+ const input = new PassThrough();
20
+ const rl = createInterface({ input });
21
+ expect(rl).toBeDefined();
22
+ expect(rl instanceof Interface).toBe(true);
23
+ rl.close();
24
+ });
25
+
26
+ await it('should create an interface with input and output', async () => {
27
+ const input = new PassThrough();
28
+ const output = new PassThrough();
29
+ const rl = createInterface({ input, output });
30
+ expect(rl).toBeDefined();
31
+ rl.close();
32
+ });
33
+
34
+ await it('should read lines via async iterator', async () => {
35
+ const input = new PassThrough();
36
+ const rl = createInterface({ input });
37
+
38
+ const lines: string[] = [];
39
+ const done = (async () => {
40
+ for await (const line of rl) {
41
+ lines.push(line);
42
+ }
43
+ })();
44
+
45
+ input.write('hello\n');
46
+ input.write('world\n');
47
+ input.end();
48
+
49
+ await done;
50
+ expect(lines.length).toBe(2);
51
+ expect(lines[0]).toBe('hello');
52
+ expect(lines[1]).toBe('world');
53
+ });
54
+
55
+ await it('question should return a promise', async () => {
56
+ const input = new PassThrough();
57
+ const output = new PassThrough();
58
+ const rl = createInterface({ input, output });
59
+
60
+ // Schedule the answer
61
+ setTimeout(() => {
62
+ input.write('my answer\n');
63
+ }, 10);
64
+
65
+ const answer = await rl.question('What? ');
66
+ expect(answer).toBe('my answer');
67
+ rl.close();
68
+ });
69
+
70
+ await it('should handle multiple questions sequentially', async () => {
71
+ const input = new PassThrough();
72
+ const output = new PassThrough();
73
+ const rl = createInterface({ input, output });
74
+
75
+ setTimeout(() => {
76
+ input.write('first\n');
77
+ }, 10);
78
+
79
+ const answer1 = await rl.question('Q1? ');
80
+ expect(answer1).toBe('first');
81
+
82
+ setTimeout(() => {
83
+ input.write('second\n');
84
+ }, 10);
85
+
86
+ const answer2 = await rl.question('Q2? ');
87
+ expect(answer2).toBe('second');
88
+
89
+ rl.close();
90
+ });
91
+
92
+ await it('should close properly', async () => {
93
+ const input = new PassThrough();
94
+ const rl = createInterface({ input });
95
+
96
+ let closed = false;
97
+ rl.on('close', () => { closed = true; });
98
+ rl.close();
99
+
100
+ // Give event loop a tick
101
+ await new Promise<void>((resolve) => setTimeout(resolve, 10));
102
+ expect(closed).toBe(true);
103
+ });
104
+
105
+ await it('should emit line events', async () => {
106
+ const input = new PassThrough();
107
+ const rl = createInterface({ input });
108
+
109
+ const lines: string[] = [];
110
+ rl.on('line', (line: string) => lines.push(line));
111
+
112
+ input.write('line1\nline2\n');
113
+ input.end();
114
+
115
+ // Wait for processing
116
+ await new Promise<void>((resolve) => rl.on('close', resolve));
117
+ expect(lines.length).toBe(2);
118
+ expect(lines[0]).toBe('line1');
119
+ expect(lines[1]).toBe('line2');
120
+ });
121
+ });
122
+ };
@@ -0,0 +1,35 @@
1
+ // readline/promises — Promise-based readline API
2
+ // Reference: Node.js lib/readline/promises.js
3
+
4
+ import { Interface as BaseInterface, createInterface as baseCreateInterface } from './index.js';
5
+ import type { InterfaceOptions } from './index.js';
6
+ import type { Readable, Writable } from 'node:stream';
7
+
8
+ /**
9
+ * Promise-based readline Interface.
10
+ */
11
+ export class Interface extends BaseInterface {
12
+ /** Ask a question and return the answer as a Promise. */
13
+ question(query: string, options?: any): Promise<string> {
14
+ return new Promise<string>((resolve) => {
15
+ super.question(query, resolve);
16
+ });
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Create a promise-based readline Interface.
22
+ */
23
+ export function createInterface(input?: Readable | InterfaceOptions, output?: Writable): Interface {
24
+ if (typeof input === 'object' && input !== null && !('read' in input && typeof (input as any).read === 'function')) {
25
+ const opts = input as InterfaceOptions;
26
+ const rl = new Interface(opts);
27
+ return rl;
28
+ }
29
+ return new Interface({ input: input as Readable, output });
30
+ }
31
+
32
+ export default {
33
+ Interface,
34
+ createInterface,
35
+ };
package/src/test.mts ADDED
@@ -0,0 +1,4 @@
1
+ import { run } from '@gjsify/unit';
2
+ import testSuite from './index.spec.js';
3
+ import promisesTestSuite from './promises.spec.js';
4
+ run({ testSuite, promisesTestSuite });
package/tsconfig.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "ESNext",
4
+ "target": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "types": [
7
+ "node"
8
+ ],
9
+ "experimentalDecorators": true,
10
+ "emitDeclarationOnly": true,
11
+ "declaration": true,
12
+ "allowImportingTsExtensions": true,
13
+ "outDir": "lib",
14
+ "rootDir": "src",
15
+ "declarationDir": "lib/types",
16
+ "composite": true,
17
+ "skipLibCheck": true,
18
+ "allowJs": true,
19
+ "checkJs": false,
20
+ "strict": false
21
+ },
22
+ "include": [
23
+ "src/**/*.ts"
24
+ ],
25
+ "exclude": [
26
+ "src/test.ts",
27
+ "src/test.mts",
28
+ "src/**/*.spec.ts",
29
+ "src/**/*.spec.mts"
30
+ ]
31
+ }