├── .busted ├── lineinput-scm-0.rockspec ├── test.lua ├── spec └── unidecode_spec.lua ├── lineinput └── unidecode.lua └── lineinput.lua /.busted: -------------------------------------------------------------------------------- 1 | -- vim:set ft=lua: 2 | return { 3 | _all = { 4 | verbose = true, 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lineinput-scm-0.rockspec: -------------------------------------------------------------------------------- 1 | package = "lineinput" 2 | version = "scm-0" 3 | description = { 4 | summary = "Terminal line-based input", 5 | homepage = "https://github.com/aperezdc/lua-lineinput", 6 | license = "MIT/X11", 7 | } 8 | source = { 9 | url = "git://github.com/aperezdc/lua-lineinput", 10 | } 11 | dependencies = { 12 | "lua >= 5.1", 13 | "luaposix ~> 33", 14 | "bit32", 15 | } 16 | build = { 17 | type = "builtin", 18 | lineinput = "lineinput.lua", 19 | } 20 | -------------------------------------------------------------------------------- /test.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- lnmain.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local lineinput = require "lineinput" 10 | 11 | local input = lineinput(io.stdout.write, io.stdout.flush, io.stdout) 12 | input:wrap(function () 13 | while true do 14 | local line, status 15 | input:start("input: ") 16 | repeat 17 | status, line = input:feed(io.read(1)) 18 | until status 19 | 20 | if status == lineinput.DONE then 21 | print(string.format("\n\rline: %q\r", line)) 22 | elseif status == lineinput.EOF then 23 | print("\n\rEOF\r") 24 | break 25 | elseif status == lineinput.INT then 26 | print(string.format("\n\rline: %q (interrupted)\r", line)) 27 | end 28 | end 29 | end) 30 | -------------------------------------------------------------------------------- /spec/unidecode_spec.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- unidecode_spec.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local function iter_bytes(s) 10 | return coroutine.wrap(function () 11 | for i = 1, #s do 12 | coroutine.yield(string.byte(s, i)) 13 | end 14 | end) 15 | end 16 | 17 | describe("lineinput.unidecode()", function () 18 | local unidecode = require "lineinput.unidecode" 19 | 20 | it("returns 7-bit ASCII unmodified", function () 21 | local reader = spy.new(function () end) 22 | for i = 0, 127 do 23 | assert.is_equal(string.char(i), unidecode(reader, i)) 24 | end 25 | -- We are passing the first byte, reader function won't be called. 26 | assert.spy(reader).called(0) 27 | end) 28 | 29 | it("accepts a couroutine reader", function () 30 | for i = 0, 127 do 31 | local reader = coroutine.wrap(function () 32 | coroutine.yield(i) 33 | end) 34 | assert.is_equal(string.char(i), unidecode(reader)) 35 | end 36 | end) 37 | 38 | it("errors on out-of-bounds bytes", function () 39 | local oob_bytes = 40 | { 192, 193, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255 } 41 | for _, byte in ipairs(oob_bytes) do 42 | assert.message(string.format("byte %d (0x%02X)", byte, byte)) 43 | .error_matches(function () unidecode(nil, byte) end, 44 | "^Invalid UTF8") 45 | end 46 | end) 47 | end) 48 | -------------------------------------------------------------------------------- /lineinput/unidecode.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- unidecode.lua 3 | -- Copyright (C) 2016 Adrian Perez 4 | -- 5 | -- This module implements a UTF8 reader which works by reading a byte at 6 | -- a time from a provided "read" callback function, and detects invalid 7 | -- input sequences. The "read" callback can be coroutine.resume() in 8 | -- order to drive the parser externally. 9 | -- 10 | -- Distributed under terms of the MIT license. 11 | -- 12 | 13 | local error, s_char, s_format = error, string.char, string.format 14 | 15 | local function tail(c, c1, c2, c3) 16 | if c >= 0x80 or c <= 0xBF then 17 | return c 18 | end 19 | if c3 then 20 | error(s_format("Invalid UTF8-4 sequence: U+%02x%02x%02x%02x", c1, c2, c3, c)) 21 | elseif c2 then 22 | error(s_format("Invalid UTF8-3 sequence: U+%02x%02x%02x", c1, c2, c)) 23 | else 24 | error(s_format("Invalid UTF8-2 sequence: U+%02x%02x", c1, c)) 25 | end 26 | end 27 | 28 | local function decode(nextbyte, c1) 29 | if c1 == nil then 30 | c1 = nextbyte() 31 | end 32 | 33 | if c1 >= 0x00 and c1 <= 0x7F then -- UTF8-1 34 | return s_char(c1), 1 35 | end 36 | 37 | if c1 >= 0xC2 and c1 <= 0xDF then -- UTF8-2 38 | return s_char(c1, tail(nextbyte(), c1)) 39 | end 40 | 41 | if c1 == 0xE0 then -- UTF8-3 42 | local c2 = nextbyte() 43 | if c2 >= 0xA0 and c2 <= 0xBF then 44 | return s_char(c1, c2, tail(nextbyte(), c1, c2)) 45 | end 46 | error(s_format("Invalid UTF8-3 sequence: U+%02x%02x..", c1, c2)) 47 | elseif c1 == 0xED then 48 | local c2 = nextbyte() 49 | if c2 >= 0x80 and c2 <= 0x9F then 50 | return s_char(c1, c2, tail(nextbyte(), c1, c2)) 51 | end 52 | error(s_format("Invalid UTF8-3 sequence: U+%02x%02x..", c1, c2)) 53 | elseif c1 >= 0xE1 and c1 <= 0xEC then 54 | local c2 = tail(nextbyte(), c1) 55 | return s_char(c1, c2, tail(nextbyte(), c1, c2)) 56 | elseif c1 >= 0xEE and c1 <= 0xEF then 57 | local c2 = tail(nextbyte(), c1) 58 | return s_char(c1, c2, tail(nextbyte(), c1, c2)) 59 | end 60 | 61 | if c1 == 0xF0 then -- UTF8-4 62 | local c2 = nextbyte() 63 | if c2 >= 0x90 and c2 <= 0xBF then 64 | local c3 = tail(nextbyte(), c1, c2) 65 | return s_char(c1, c2, c3, tail(nextbyte(), c1, c2, c3)) 66 | end 67 | error(s_format("Invalid UTF8-4 sequence: U+%02x%02x..", c1, c2)) 68 | elseif c1 == 0xF4 then 69 | local c2 = nextbyte() 70 | if c2 >= 0x80 and c2 <= 0x8F then 71 | local c3 = tail(nextbyte(), c1, c2) 72 | return s_char(c1, c2, c3, tail(nextbyte(), c1, c2, c3)) 73 | end 74 | error(s_format("Invalid UTF8-4 sequence: U+%02x%02x..", c1, c2)) 75 | elseif c1 >= 0xF1 and c1 <= 0xF3 then 76 | local c2 = tail(nextbyte(), c1) 77 | local c3 = tail(nextbyte(), c1, c2) 78 | return s_char(c1, c2, c3, tail(nextbyte(), c1, c2, c3)) 79 | end 80 | 81 | error(s_format("Invalid UTF8-? sequence: U+%02x..", c1)) 82 | end 83 | 84 | return decode 85 | -------------------------------------------------------------------------------- /lineinput.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- lineinput.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local P = require "posix" 10 | local bit = require "bit32" 11 | 12 | local error, assert, tonumber = error, assert, tonumber 13 | local setmetatable, type, pairs = setmetatable, type, pairs 14 | local co_create, co_yield = coroutine.create, coroutine.yield 15 | local co_resume = coroutine.resume 16 | local t_insert, t_concat = table.insert, table.concat 17 | local d_traceback = debug.traceback 18 | local min, max = math.min, math.max 19 | local sprintf = string.format 20 | 21 | local refresh_line 22 | 23 | local dprintf = (function () 24 | local env_var = os.getenv("LINEINPUT_DEBUG") 25 | if env_var and #env_var > 0 and env_var ~= "0" then 26 | return function (self, fmt, ...) 27 | self:tty_write("\r\x1B[K[lineinput] ") 28 | self:tty_write(fmt:format(...)) 29 | self:tty_write("\r\n") 30 | self:tty_flush() 31 | refresh_line(self) 32 | end 33 | else 34 | return function (...) end 35 | end 36 | end)() 37 | 38 | local unsupported_terminal = { 39 | dumb = true, 40 | cons25 = true, 41 | emacs = true, 42 | } 43 | 44 | local NULL = 0 45 | local CTRL_A = 1 46 | local CTRL_B = 2 47 | local CTRL_C = 3 48 | local CTRL_D = 4 49 | local CTRL_E = 5 50 | local CTRL_F = 6 51 | local CTRL_H = 8 52 | local TAB = 9 53 | local CTRL_K = 11 54 | local CTRL_L = 12 55 | local ENTER = 13 56 | local CTRL_N = 14 57 | local CTRL_P = 16 58 | local CTRL_T = 20 59 | local CTRL_U = 21 60 | local CTRL_W = 23 61 | local ESCAPE = 27 62 | local BACKSPACE = 127 63 | local BYTE_0 = ("0"):byte() 64 | local BYTE_9 = ("9"):byte() 65 | 66 | -- XXX: This is very simple, recursive, and it does not handle cycles. 67 | -- Do not use with very deeply nested tables or tables with cycles. 68 | local function deepcopy(t) 69 | assert(type(t) == "table", "Parameter #1: table expected") 70 | local n = {} 71 | for name, value in pairs(t) do 72 | n[name] = (type(value) == "table") and deepcopy(value) or value 73 | end 74 | return n 75 | end 76 | 77 | local function enable_tty_raw(fd) 78 | if not P.isatty(fd) then 79 | error(P.errno(P.ENOTTY)) 80 | end 81 | 82 | local term_state = P.tcgetattr(fd) 83 | local term_raw = deepcopy(term_state) 84 | 85 | -- Input modes: no break, no CR to NL, no parity check, no strip char, 86 | -- no start/stop output control. 87 | term_raw.iflag = bit.band(term_raw.iflag, bit.bnot( 88 | bit.bor(P.BRKINT, P.ICRNL, P.INPCK, P.ISTRIP, P.IXON))) 89 | -- Output modes: disable postprocessing 90 | term_raw.oflag = bit.band(term_raw.oflag, bit.bnot(P.OPOST)) 91 | -- Control modes: use 8-bit characters. 92 | term_raw.cflag = bit.bor(term_raw.cflag, P.CS8) 93 | -- Local modes: echo off, canononical off, no extended functions, 94 | -- no signal characters (Ctrl-Z, Ctrl-C) 95 | term_raw.lflag = bit.band(term_raw.lflag, bit.bnot( 96 | bit.bor(P.ECHO, P.ICANON, P.IEXTEN, P.ISIG))) 97 | -- Return condition: no timeout, one byte at a time 98 | term_raw.cc[P.VTIME] = 0 99 | term_raw.cc[P.VMIN] = 1 100 | 101 | if P.tcsetattr(fd, P.TCSAFLUSH, term_raw) ~= 0 then 102 | return nil, P.errno() 103 | end 104 | return term_state 105 | end 106 | 107 | local function do_write_nofd(self, bytes) return self.do_write(bytes) end 108 | local function do_flush_nofd(self) return self.do_flush() end 109 | local function do_flush_noop(self) end 110 | 111 | local State = { 112 | INT = CTRL_C, 113 | EOF = CTRL_D, 114 | DONE = ENTER, 115 | } 116 | State.__index = State 117 | 118 | setmetatable(State, { __call = function (self, write, flush, fd) 119 | local s = setmetatable({ 120 | do_write = write, 121 | do_flush = flush, 122 | use_fd = fd, 123 | buf = "", 124 | prompt = "", 125 | pos = 1, -- Current cursor position. 126 | cols = -1, -- Number of columns in the terminal. 127 | ttystate = false, -- Saved TTY state. 128 | }, State) 129 | if not fd then 130 | s.do_write = do_write_nofd 131 | s.do_flush = do_flush_nofd 132 | end 133 | if not flush then 134 | s.do_flush = do_flush_noop 135 | end 136 | return s 137 | end }) 138 | 139 | function State:tty_write(bytes) 140 | -- dprintf(self, ":write(%q)", bytes) 141 | return self.do_write(self.use_fd, bytes) 142 | end 143 | 144 | function State:tty_flush() 145 | self.do_flush(self.use_fd) 146 | end 147 | 148 | function State:tty_configure(fd) 149 | if self.ttystate then 150 | return true 151 | end 152 | if fd == nil then 153 | fd = self.use_fd 154 | end 155 | if type(fd) ~= "number" then 156 | fd = P.fileno(fd) 157 | end 158 | local state, err = enable_tty_raw(fd) 159 | if state then 160 | self.ttystate = state 161 | return true 162 | end 163 | return false, err 164 | end 165 | 166 | function State:tty_restore(fd) 167 | if not self.ttystate then 168 | return true 169 | end 170 | if fd == nil then 171 | fd = self.use_fd 172 | end 173 | if type(fd) ~= "number" then 174 | fd = P.fileno(fd) 175 | end 176 | if P.tcsetattr(fd, P.TCSAFLUSH, self.ttystate) ~= 0 then 177 | return false, P.errno() 178 | end 179 | self.ttystate = false 180 | return true 181 | end 182 | 183 | -- No "local", this was forward-declared 184 | refresh_line = function (self) 185 | local leftpos, pos = 1, self.pos 186 | while #self.prompt + pos >= self.cols do 187 | leftpos, pos = leftpos + 1, pos - 1 188 | end 189 | local rightpos = #self.buf 190 | while #self.prompt + rightpos > self.cols do 191 | rightpos = rightpos - 1 192 | end 193 | self:tty_write(sprintf("\r%s%s\x1B[K\r\x1B[%dC", 194 | self.prompt, 195 | self.buf:sub(leftpos, rightpos), 196 | #self.prompt + self.pos - 1)) 197 | self:tty_flush() 198 | end 199 | 200 | function State:beep() 201 | self:tty_write("\x1B[H\x1B[2J") 202 | self:tty_flush() 203 | end 204 | 205 | function State:move_left() 206 | if self.pos > 1 then 207 | self.pos = self.pos - 1 208 | refresh_line(self) 209 | end 210 | end 211 | 212 | function State:move_right() 213 | if self.pos <= #self.buf then 214 | self.pos = self.pos + 1 215 | refresh_line(self) 216 | end 217 | end 218 | 219 | function State:move_home() 220 | if self.pos > 1 then 221 | self.pos = 1 222 | refresh_line(self) 223 | end 224 | end 225 | 226 | function State:move_end() 227 | if self.pos < #self.buf then 228 | self.pos = #self.buf + 1 229 | refresh_line(self) 230 | end 231 | end 232 | 233 | function State:edit_delete() 234 | if #self.buf > 0 and self.pos <= #self.buf then 235 | self.buf = self.buf:sub(1, self.pos - 1) .. self.buf:sub(self.pos + 1, -1) 236 | refresh_line(self) 237 | end 238 | end 239 | 240 | function State:edit_backspace() 241 | if self.pos > 1 and #self.buf > 0 then 242 | if self.pos > #self.buf then 243 | self.buf = self.buf:sub(1, -2) 244 | else 245 | self.buf = self.buf:sub(1, self.pos - 2) .. self.buf:sub(self.pos, -1) 246 | end 247 | self.pos = self.pos - 1 248 | refresh_line(self) 249 | end 250 | end 251 | 252 | function State:insert(input) 253 | if self.pos == #self.buf then -- Append input. 254 | self.buf = self.buf .. input 255 | elseif self.pos == 1 then -- Prepend input. 256 | self.buf = input .. self.buf 257 | else -- Insert in the middle 258 | self.buf = self.buf:sub(1, self.pos) .. input .. self.buf:sub(self.pos + 1, -1) 259 | end 260 | self.pos = self.pos + 1 261 | refresh_line(self) 262 | end 263 | 264 | local function xpcall_traceback(errmsg) 265 | local tb = d_traceback(nil, nil, 2) 266 | return errmsg and (errmsg .. "\n" .. tb) or tb 267 | end 268 | 269 | function State:wrap(f, ...) 270 | local ok, err = self:tty_configure() 271 | if not ok then 272 | error(err) 273 | end 274 | local ok, err = xpcall(f, xpcall_traceback, ...) 275 | self:tty_restore() 276 | self:tty_write("\r\x1B[2K") 277 | self:tty_flush() 278 | if not ok then 279 | error(err) 280 | end 281 | end 282 | 283 | -- Response: ESC [ rows ; cols R 284 | local cursor_position_response = "^%\x1B%[%d+;(%d+)R$" 285 | 286 | local function query_columns(self) 287 | local buf = "" 288 | 289 | -- Read current cursor position, to restore it later 290 | self:tty_write("\x1B[6n") 291 | local saved_col 292 | while true do 293 | saved_col = buf:match(cursor_position_response) 294 | -- dprintf(self, "query_columns: buf=%q, row=%s, col=%s", buf, saved_row, saved_col) 295 | if saved_col then 296 | saved_col = tonumber(saved_col) 297 | buf = "" -- Clear buffer 298 | break 299 | end 300 | buf = buf .. co_yield() 301 | end 302 | 303 | -- Move to a column far, far away. The new position has the number of columns. 304 | self:tty_write("\x1B[999C\x1B[6n") 305 | local col 306 | while true do 307 | col = buf:match(cursor_position_response) 308 | -- dprintf(self, "query_columns: buf=%q, row=%s, col=%s", buf, row, col) 309 | if col then 310 | col = tonumber(col) 311 | break 312 | end 313 | buf = buf .. co_yield() 314 | end 315 | 316 | -- Restore position, return number of colums 317 | self:tty_write(sprintf("\x1B[%dD", col - saved_col)) 318 | dprintf(self, "query_columns -> %d", col) 319 | return col 320 | end 321 | 322 | local function handle_input(self) 323 | self:tty_write("\r") 324 | if #self.prompt < self.cols then 325 | self:tty_write(self.prompt) 326 | end 327 | self:tty_flush() 328 | while true do 329 | local input = co_yield() 330 | local byte = input:byte() 331 | if byte == ENTER or byte == CTRL_C then 332 | return byte, self.buf 333 | end 334 | if byte == BACKSPACE or input == 8 then 335 | self:edit_backspace() 336 | elseif byte == CTRL_D then 337 | if #self.buf > 0 then 338 | self:edit_delete() 339 | else 340 | return CTRL_D 341 | end 342 | elseif byte == CTRL_T then 343 | -- Swap current character with previous. 344 | local prevchar = self.buf:sub(self.pos - 1, 1) 345 | local curchar = self.buf:sub(self.pos, 1) 346 | self.buf = self.buf:sub(1, self.pos - 2) 347 | .. curchar .. prevchar 348 | .. self.buf:sub(self.pos, -1) 349 | refresh_line(self) 350 | elseif byte == CTRL_B then 351 | self:move_left() 352 | elseif byte == CTRL_F then 353 | self:move_right() 354 | elseif byte == CTRL_U then 355 | -- Delete the whole line. 356 | self.buf = "" 357 | self.pos = 1 358 | refresh_line(self) 359 | elseif byte == CTRL_K then 360 | -- Delete from current position to end of line. 361 | self.buf = self.buf:sub(1, self.pos) 362 | refresh_line(self) 363 | elseif byte == CTRL_A then 364 | self:move_home() 365 | elseif byte == CTRL_E then 366 | self:move_end() 367 | elseif byte == ESCAPE then 368 | -- Read the next two bytes representing of the escape sequence. 369 | local ch1 = co_yield() 370 | local ch2 = co_yield() 371 | if ch1 == "[" then -- ESC [ sequences 372 | local byte2 = ch2:byte() 373 | if byte2 >= BYTE_0 and byte2 <= BYTE_9 then 374 | -- Extended escape, read one additional character. 375 | local ch3 = co_yield() 376 | dprintf(self, "escape sequence: [%c%s", byte2, ch3) 377 | if ch3 == "~" then -- ESC [ NUM ~ 378 | -- Others to consider: 379 | -- PageUp: ESC [5~ 380 | -- PageDown: ESC [6~ 381 | if ch2 == "1" then self:move_home() 382 | elseif ch2 == "3" then self:edit_delete() 383 | elseif ch2 == "4" then self:move_end() 384 | end 385 | elseif ch3 == ";" then -- ESC [ NUM ; CHAR CHAR 386 | if ch2 == "1" then -- ESC [1; CHAR CHAR 387 | local ch4 = co_yield() 388 | if ch4 == "3" or ch4 == "4" or ch4 == "5" or ch4 == "6" then 389 | -- ESC [1; MOD CHAR 390 | -- MOD is: 391 | -- 3 for Alt 392 | -- 4 for Alt-Shift 393 | -- 5 for Ctrl 394 | -- 6 for Ctrl-Shift 395 | -- 8 for Ctrl-Alt-Shift 396 | local ch5 = co_yield() 397 | if ch5 == "A" then 398 | -- ${modifiers}-Up 399 | elseif ch5 == "B" then 400 | -- Ctrl-Down 401 | elseif ch5 == "C" then 402 | -- Ctrl-Right 403 | elseif ch5 == "D" then 404 | -- Ctrl-Left 405 | else 406 | dprintf(self, "unhandled escape: [1;5%s", ch5) 407 | end 408 | else 409 | dprintf(self, "unhandled escape: [1;%s", ch4) 410 | end 411 | else 412 | dprintf(self, "unhandled escape: [%s;", ch2) 413 | end 414 | end 415 | else 416 | dprintf(self, "escape sequence: [%c", byte2) 417 | if ch2 == "A" then 418 | -- TODO: Up 419 | elseif ch2 == "B" then 420 | -- TODO: Down 421 | elseif ch2 == "C" then self:move_right() 422 | elseif ch2 == "D" then self:move_left() 423 | elseif ch2 == "H" then self:move_home() 424 | elseif ch2 == "F" then self:move_end() 425 | end 426 | end 427 | else 428 | dprintf(self, "escape sequence: %s%s (unhandled)", ch1, ch2) 429 | end 430 | elseif byte >= 32 then 431 | self:insert(input) 432 | end 433 | dprintf(self, "buf = %q", self.buf) 434 | end 435 | end 436 | 437 | function State:start(prompt) 438 | if prompt then 439 | self.prompt = prompt 440 | end 441 | -- Reset state 442 | self.buf = "" 443 | self.pos = 1 444 | self.cols = -1 445 | self._coro = co_create(query_columns) 446 | assert(co_resume(self._coro, self)) 447 | end 448 | 449 | function State:feed(input) 450 | dprintf(self, "feed(%q), coro=%s", input, self._coro) 451 | local ok, status, line = co_resume(self._coro, input) 452 | dprintf(self, "feed --> yielded=%s, status=%s, pos=%s", ok, status, self.pos) 453 | if not ok then 454 | error(status) 455 | end 456 | if status and self.cols < 0 then 457 | self.cols = status 458 | self._coro = co_create(handle_input) 459 | assert(co_resume(self._coro, self)) 460 | return nil 461 | end 462 | return status, line 463 | end 464 | 465 | 466 | return State 467 | --------------------------------------------------------------------------------