Line data Source code
1 : /* SPDX-License-Identifier: GPL-3.0-or-later */
2 : /* Copyright 2026 Peter Csaszar */
3 :
4 : /**
5 : * @file readline.c
6 : * @brief Custom interactive line editor built on top of terminal.h.
7 : */
8 :
9 : #include "readline.h"
10 : #include "platform/terminal.h"
11 :
12 : #include <stdio.h>
13 : #include <string.h>
14 : #include <stdlib.h>
15 : #include <unistd.h>
16 :
17 : /* ---- History ---- */
18 :
19 23 : void rl_history_init(LineHistory *h) {
20 23 : if (!h) return;
21 22 : memset(h, 0, sizeof(*h));
22 : }
23 :
24 279 : void rl_history_add(LineHistory *h, const char *line) {
25 279 : if (!h || !line || line[0] == '\0') return;
26 :
27 : /* Ignore duplicate of the most recent entry */
28 277 : if (h->count > 0) {
29 266 : int last = (h->head - 1 + RL_HISTORY_MAX) % RL_HISTORY_MAX;
30 266 : if (strcmp(h->entries[last], line) == 0) return;
31 : }
32 :
33 275 : strncpy(h->entries[h->head], line, RL_HISTORY_ENTRY_MAX - 1);
34 275 : h->entries[h->head][RL_HISTORY_ENTRY_MAX - 1] = '\0';
35 275 : h->head = (h->head + 1) % RL_HISTORY_MAX;
36 275 : if (h->count < RL_HISTORY_MAX) h->count++;
37 : }
38 :
39 : /* ---- Internal: get history entry by reverse index ----
40 : *
41 : * index 0 = most recent, 1 = second most recent, etc.
42 : * Returns NULL if out of range.
43 : */
44 2 : static const char *history_get(const LineHistory *h, int index) {
45 2 : if (!h || index < 0 || index >= h->count) return NULL;
46 2 : int pos = (h->head - 1 - index + RL_HISTORY_MAX * 2) % RL_HISTORY_MAX;
47 2 : return h->entries[pos];
48 : }
49 :
50 : /* ---- Internal: line editor state ---- */
51 :
52 : typedef struct {
53 : char *buf; /* output buffer (caller-supplied) */
54 : size_t size; /* buf capacity in bytes */
55 : size_t len; /* current content length (excl. NUL) */
56 : size_t cur; /* cursor position (0 .. len) */
57 : const char *prompt;
58 : int prompt_len;
59 : } LineState;
60 :
61 : /* ---- Internal: redraw the current line ---- */
62 :
63 68 : static void redraw(const LineState *s) {
64 : /* Move cursor to beginning of line, clear to end, rewrite */
65 68 : fputs("\r", stdout);
66 68 : fputs(s->prompt, stdout);
67 68 : fwrite(s->buf, 1, s->len, stdout);
68 : /* Clear any leftover characters from a previous longer line */
69 68 : fputs("\033[K", stdout);
70 : /* Reposition cursor */
71 68 : size_t cursor_col = (size_t)s->prompt_len + s->cur;
72 : /* Move to column cursor_col (1-based): \r then CUF */
73 68 : if (cursor_col > 0) {
74 68 : printf("\r\033[%zuC", cursor_col);
75 : } else {
76 0 : fputs("\r", stdout);
77 : }
78 68 : fflush(stdout);
79 68 : }
80 :
81 : /* ---- Internal: insert a character at cursor ---- */
82 :
83 56 : static void insert_char(LineState *s, char c) {
84 56 : if (s->len + 1 >= s->size) return; /* no space */
85 : /* Shift right */
86 56 : memmove(s->buf + s->cur + 1, s->buf + s->cur, s->len - s->cur);
87 56 : s->buf[s->cur] = c;
88 56 : s->len++;
89 56 : s->cur++;
90 56 : s->buf[s->len] = '\0';
91 : }
92 :
93 : /* ---- Internal: delete character before cursor (Backspace) ---- */
94 :
95 1 : static void delete_before(LineState *s) {
96 1 : if (s->cur == 0) return;
97 1 : memmove(s->buf + s->cur - 1, s->buf + s->cur, s->len - s->cur);
98 1 : s->cur--;
99 1 : s->len--;
100 1 : s->buf[s->len] = '\0';
101 : }
102 :
103 : /* ---- Internal: delete character at cursor (Delete / Ctrl-D) ---- */
104 :
105 0 : static void delete_at(LineState *s) {
106 0 : if (s->cur >= s->len) return;
107 0 : memmove(s->buf + s->cur, s->buf + s->cur + 1, s->len - s->cur - 1);
108 0 : s->len--;
109 0 : s->buf[s->len] = '\0';
110 : }
111 :
112 : /* ---- Internal: delete previous word (Ctrl-W) ---- */
113 :
114 1 : static void delete_prev_word(LineState *s) {
115 1 : if (s->cur == 0) return;
116 1 : size_t end = s->cur;
117 : /* Skip trailing spaces */
118 1 : while (s->cur > 0 && s->buf[s->cur - 1] == ' ') s->cur--;
119 : /* Delete word characters */
120 4 : while (s->cur > 0 && s->buf[s->cur - 1] != ' ') s->cur--;
121 1 : size_t deleted = end - s->cur;
122 1 : memmove(s->buf + s->cur, s->buf + end, s->len - end);
123 1 : s->len -= deleted;
124 1 : s->buf[s->len] = '\0';
125 : }
126 :
127 : /* ---- Internal: kill to end of line (Ctrl-K) ---- */
128 :
129 1 : static void kill_to_end(LineState *s) {
130 1 : s->len = s->cur;
131 1 : s->buf[s->len] = '\0';
132 1 : }
133 :
134 : /* ---- rl_readline: non-TTY fallback ----
135 : *
136 : * Uses read(STDIN_FILENO) directly to bypass FILE* buffering, which is
137 : * important when the caller has dup2'd a pipe or file into STDIN_FILENO.
138 : */
139 4 : static int readline_nontty(const char *prompt, char *buf, size_t size) {
140 4 : if (prompt && *prompt) {
141 1 : fputs(prompt, stdout);
142 1 : fflush(stdout);
143 : }
144 4 : size_t len = 0;
145 26 : while (len + 1 < size) {
146 : unsigned char c;
147 26 : ssize_t n = read(STDIN_FILENO, &c, 1);
148 26 : if (n <= 0) {
149 1 : if (len == 0) return -1; /* EOF with no data */
150 3 : break;
151 : }
152 25 : if (c == '\n') break;
153 22 : if (c == '\r') continue; /* skip CR in CR+LF sequences */
154 21 : buf[len++] = (char)c;
155 : }
156 3 : buf[len] = '\0';
157 3 : return (int)len;
158 : }
159 :
160 : /* ---- rl_readline: interactive (TTY) path ---- */
161 :
162 27 : int rl_readline(const char *prompt, char *buf, size_t size,
163 : LineHistory *history) {
164 27 : if (!buf || size == 0) return -1;
165 :
166 : /* Non-TTY fallback */
167 25 : if (!terminal_is_tty(STDIN_FILENO)) {
168 4 : return readline_nontty(prompt, buf, size);
169 : }
170 :
171 42 : RAII_TERM_RAW TermRawState *raw = terminal_raw_enter();
172 21 : if (!raw) {
173 : /* Raw mode failed — fall back to plain read */
174 0 : return readline_nontty(prompt, buf, size);
175 : }
176 :
177 : LineState s;
178 21 : s.buf = buf;
179 21 : s.size = size;
180 21 : s.len = 0;
181 21 : s.cur = 0;
182 21 : s.prompt = prompt ? prompt : "";
183 21 : s.prompt_len = prompt ? (int)strlen(prompt) : 0;
184 21 : buf[0] = '\0';
185 :
186 : /* History navigation state */
187 21 : int hist_idx = -1; /* -1 = not navigating */
188 : char saved_line[RL_HISTORY_ENTRY_MAX]; /* saved current edit when navigating */
189 21 : saved_line[0] = '\0';
190 :
191 : /* Initial prompt */
192 21 : fputs(s.prompt, stdout);
193 21 : fflush(stdout);
194 :
195 68 : for (;;) {
196 89 : TermKey key = terminal_read_key();
197 :
198 89 : switch (key) {
199 10 : case TERM_KEY_ENTER:
200 : /* Submit */
201 10 : buf[s.len] = '\0';
202 10 : fputs("\r\n", stdout);
203 10 : fflush(stdout);
204 10 : return (int)s.len;
205 :
206 1 : case TERM_KEY_QUIT:
207 : /* Ctrl-C: discard and signal abort */
208 1 : fputs("\r\n", stdout);
209 1 : fflush(stdout);
210 1 : buf[0] = '\0';
211 1 : return -1;
212 :
213 10 : case TERM_KEY_CTRL_D:
214 10 : if (s.len == 0) {
215 : /* EOF on empty line */
216 10 : fputs("\r\n", stdout);
217 10 : fflush(stdout);
218 10 : return -1;
219 : }
220 0 : delete_at(&s);
221 0 : break;
222 :
223 1 : case TERM_KEY_BACK:
224 1 : delete_before(&s);
225 1 : break;
226 :
227 0 : case TERM_KEY_DELETE:
228 0 : delete_at(&s);
229 0 : break;
230 :
231 4 : case TERM_KEY_LEFT:
232 4 : if (s.cur > 0) s.cur--;
233 4 : break;
234 :
235 0 : case TERM_KEY_RIGHT:
236 0 : if (s.cur < s.len) s.cur++;
237 0 : break;
238 :
239 1 : case TERM_KEY_HOME:
240 : case TERM_KEY_CTRL_A:
241 1 : s.cur = 0;
242 1 : break;
243 :
244 1 : case TERM_KEY_END:
245 : case TERM_KEY_CTRL_E:
246 1 : s.cur = s.len;
247 1 : break;
248 :
249 1 : case TERM_KEY_CTRL_K:
250 1 : kill_to_end(&s);
251 1 : break;
252 :
253 1 : case TERM_KEY_CTRL_W:
254 1 : delete_prev_word(&s);
255 1 : break;
256 :
257 2 : case TERM_KEY_PREV_LINE: /* Up — older history */
258 2 : if (!history || history->count == 0) break;
259 2 : if (hist_idx == -1) {
260 : /* Save current edit */
261 2 : strncpy(saved_line, buf, RL_HISTORY_ENTRY_MAX - 1);
262 2 : saved_line[RL_HISTORY_ENTRY_MAX - 1] = '\0';
263 : }
264 2 : if (hist_idx + 1 < history->count) {
265 2 : hist_idx++;
266 2 : const char *entry = history_get(history, hist_idx);
267 2 : if (entry) {
268 2 : strncpy(buf, entry, size - 1);
269 2 : buf[size - 1] = '\0';
270 2 : s.len = strlen(buf);
271 2 : s.cur = s.len;
272 : }
273 : }
274 2 : break;
275 :
276 1 : case TERM_KEY_NEXT_LINE: /* Down — newer history / current edit */
277 1 : if (!history || hist_idx == -1) break;
278 1 : hist_idx--;
279 1 : if (hist_idx < 0) {
280 : /* Restore saved edit */
281 1 : hist_idx = -1;
282 1 : strncpy(buf, saved_line, size - 1);
283 1 : buf[size - 1] = '\0';
284 1 : s.len = strlen(buf);
285 1 : s.cur = s.len;
286 : } else {
287 0 : const char *entry = history_get(history, hist_idx);
288 0 : if (entry) {
289 0 : strncpy(buf, entry, size - 1);
290 0 : buf[size - 1] = '\0';
291 0 : s.len = strlen(buf);
292 0 : s.cur = s.len;
293 : }
294 : }
295 1 : break;
296 :
297 56 : case TERM_KEY_IGNORE: {
298 56 : int ch = terminal_last_printable();
299 56 : if (ch >= 32 && ch <= 126) {
300 56 : insert_char(&s, (char)ch);
301 : }
302 56 : break;
303 : }
304 :
305 0 : default:
306 : /* TERM_KEY_ESC, TERM_KEY_PREV_PAGE, TERM_KEY_NEXT_PAGE — ignore */
307 0 : break;
308 : }
309 :
310 68 : redraw(&s);
311 : }
312 : }
|