Try   HackMD

研讀筆記 linenoise

antirez/linenoise

linenoise

這個 function 是整個程式主要的 API,一開始會檢查是否是 tty,然後在看終端機是否有支援,有支援了話就會呼叫 linenoiseRaw

char *linenoise(const char *prompt) { char buf[LINENOISE_MAX_LINE]; int count; if (!isatty(STDIN_FILENO)) { /* Not a tty: read from file / pipe. In this mode we don't want any * limit to the line size, so we call a function to handle that. */ return linenoiseNoTTY(); } else if (isUnsupportedTerm()) { size_t len; printf("%s",prompt); fflush(stdout); if (fgets(buf,LINENOISE_MAX_LINE,stdin) == NULL) return NULL; len = strlen(buf); while(len && (buf[len-1] == '\n' || buf[len-1] == '\r')) { len--; buf[len] = '\0'; } return strdup(buf); } else { count = linenoiseRaw(buf,LINENOISE_MAX_LINE,prompt); if (count == -1) return NULL; return strdup(buf); } }

linenoiseRaw

這個函式會先呼叫 enableRawMode,然後呼叫 linenoiseEdit 進入編輯模式,最後會呼叫 disableRawMode

/* This function calls the line editing function linenoiseEdit() using * the STDIN file descriptor set in raw mode. */ static int linenoiseRaw(char *buf, size_t buflen, const char *prompt) { int count; if (buflen == 0) { errno = EINVAL; return -1; } if (enableRawMode(STDIN_FILENO) == -1) return -1; count = linenoiseEdit(STDIN_FILENO, STDOUT_FILENO, buf, buflen, prompt); disableRawMode(STDIN_FILENO); printf("\n"); return count; }

enableRawMode

這邊要先了解到 Terminal mode,不同的模式下面會決定終端機要如何轉換(interpreted)字元。

  • cooked mode 又稱 canonical mode,一般 linux terminal 的模式,在 data 傳入程式之前會先被預處理。
  • raw mode 又稱 non-canonical mode,會直接把 data 傳到執行程式,不會對任何特殊字元預先做轉換。

一般的終端機是 line-based system,輸入的字元會被放到 buffer 直到收到 carriage return (Enter or Return),這個叫做 cooked,會允許特殊字元被先處理,可以看 stty(1) 像是 Ctrl+D, Ctrl+S, Backspace 這些都是,終端機的驅動會先 "cooks" 處理這些字元。

再來看看 termios 這個結構,是用來設定終端機的屬性,這邊會先用 tcgetattr 拿到原本終端機的屬性並且保存到 orig_termios,最後在 disableRawMode 的時候透過 tcsetattrorig_termios 設定回去。

static int enableRawMode(int fd) { struct termios raw; if (!isatty(STDIN_FILENO)) goto fatal; if (!atexit_registered) { atexit(linenoiseAtExit); atexit_registered = 1; } if (tcgetattr(fd,&orig_termios) == -1) goto fatal; raw = orig_termios; /* modify the original mode */ /* input modes: no break, no CR to NL, no parity check, no strip char, * no start/stop output control. */ raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); /* output modes - disable post processing */ raw.c_oflag &= ~(OPOST); /* control modes - set 8 bit chars */ raw.c_cflag |= (CS8); /* local modes - choing off, canonical off, no extended functions, * no signal chars (^Z,^C) */ raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG); /* control chars - set return condition: min number of bytes and timer. * We want read to return every single byte, without timeout. */ raw.c_cc[VMIN] = 1; raw.c_cc[VTIME] = 0; /* 1 byte, no timer */ /* put terminal in raw mode after flushing */ if (tcsetattr(fd,TCSAFLUSH,&raw) < 0) goto fatal; rawmode = 1; return 0; fatal: errno = ENOTTY; return -1; }

linenoiseEdit

這邊會用到一個結構 linenoiseState 用來記錄下一些在命令列編輯時的特定資訊,一開始會先對這些結構初始化。

struct linenoiseState { int ifd; /* Terminal stdin file descriptor. */ int ofd; /* Terminal stdout file descriptor. */ char *buf; /* Edited line buffer. */ size_t buflen; /* Edited line buffer size. */ const char *prompt; /* Prompt to display. */ size_t plen; /* Prompt length. */ size_t pos; /* Current cursor position. */ size_t oldpos; /* Previous refresh cursor position. */ size_t len; /* Current edited line length. */ size_t cols; /* Number of columns in terminal. */ size_t maxrows; /* Maximum num of rows used so far (multiline mode) */ int history_index; /* The history index we are currently editing. */ };

這個主要就是用在編輯命令列上的文字,可以看到第 5 行這邊是先把 prompt 的文字寫到終端機,也就是 hello>。接著進入迴圈,每次迴圈第 10 行都會去 read 1 個字元放到變數 c,下面就會對這個字元做對應的動作。

第 12 行先判斷 c == 9 在 ASCII 當中 9 代表 tab,所以這邊如果是使用者輸入 tab,如果有註冊 autocomplete 的 callback function 就會執行 auto complete,呼叫 completeLine

completeLine 做完後會回傳最後一個收到的字元,再往下看到 switch,會對應不同的輸入進行不同的行為,如果是一般的輸入字元的話會呼叫 linenoiseEditInsert,插入一個字元到游標目前位置,其他對應的動作如下。

  • linenoiseEditBackspace
  • linenoiseEditDelete
  • linenoiseEditMoveLeft
  • linenoiseEditMoveRight
  • linenoiseEditMoveHome
  • linenoiseEditMoveEnd
  • linenoiseEditHistoryNext
  • linenoiseClearScreen
static int linenoiseEdit(int stdin_fd, int stdout_fd, char *buf, size_t buflen, const char *prompt) { struct linenoiseState l; ... if (write(l.ofd,prompt,l.plen) == -1) return -1; while(1) { char c; int nread; char seq[3]; nread = read(l.ifd,&c,1); if (nread <= 0) return l.len; if (c == 9 && completionCallback != NULL) { c = completeLine(&l); /* Return on errors */ if (c < 0) return l.len; /* Read next character when 0 */ if (c == 0) continue; } switch(c) { ... } } return l.len;

completeLine

第 6 行會呼叫 completionCallback 也就是一開始註冊的 callback function,可以先看到 example.c 第 45 行,有先透過 linenoiseSetCompletionCallback 先註冊好 callback function completion,所以在這邊呼叫 completionCallback 就是呼叫 completion

第 7 行開始判斷 lc 是否為 0,如果是 0 代表現在輸入的這個 buffer 沒有 match 到任何 completion 的規則,就會呼叫 linenoiseBeep

如果有的話就會進入到 while 迴圈當中,這邊就會根據輸入的字元做對應的動作,按一次 tab 就會去 lc.cvec 裡面的字串做 auto complete,如果還有就會繼續補下去,lc.cvec 裡面的全部做完會回到 i 會回到 0,就會是原本的輸入。

如果輸入是 escape 或是其他字元,while 迴圈就會停止,並且把最後收到的字元 return

void completion(const char *buf, linenoiseCompletions *lc) { if (buf[0] == 'h') { linenoiseAddCompletion(lc,"hello"); linenoiseAddCompletion(lc,"hello there"); } }
static int completeLine(struct linenoiseState *ls) { linenoiseCompletions lc = { 0, NULL }; int nread, nwritten; char c = 0; completionCallback(ls->buf,&lc); if (lc.len == 0) { linenoiseBeep(); } else { size_t stop = 0, i = 0; while(!stop) { /* Show completion or original buffer */ if (i < lc.len) { struct linenoiseState saved = *ls; ls->len = ls->pos = strlen(lc.cvec[i]); ls->buf = lc.cvec[i]; refreshLine(ls); ls->len = saved.len; ls->pos = saved.pos; ls->buf = saved.buf; } else { refreshLine(ls); } nread = read(ls->ifd,&c,1); if (nread <= 0) { freeCompletions(&lc); return -1; } switch(c) { case 9: /* tab */ i = (i+1) % (lc.len+1); if (i == lc.len) linenoiseBeep(); break; case 27: /* escape */ /* Re-show original buffer */ if (i < lc.len) refreshLine(ls); stop = 1; break; default: /* Update buffer and return */ if (i < lc.len) { nwritten = snprintf(ls->buf,ls->buflen,"%s",lc.cvec[i]); ls->len = ls->pos = nwritten; } stop = 1; break; } } } freeCompletions(&lc); return c; /* Return last read character */ }

linenoiseAddCompletion

這個函式式用來建立 auto complete 的內容,會透過剛剛在 completion 當中呼叫給定的字串來建立。這邊用到一個結構 linenoiseCompletions,變數 cvec 是指標的指標,用來把拿到的字串存起來,所以這邊開頭為 h 的輸入就會有 "hello"、"hello there" 兩個 auto complete。

typedef struct linenoiseCompletions { size_t len; char **cvec; } linenoiseCompletions;
void linenoiseAddCompletion(linenoiseCompletions *lc, const char *str) { size_t len = strlen(str); char *copy, **cvec; copy = malloc(len+1); if (copy == NULL) return; memcpy(copy,str,len+1); cvec = realloc(lc->cvec,sizeof(char*)*(lc->len+1)); if (cvec == NULL) { free(copy); return; } lc->cvec = cvec; lc->cvec[lc->len++] = copy; }

refreshLine

這個是用來更新目前的 buffer 內容、游標位置到正確的位置。

有兩種模式 MultiLineSingleLine,可以在一開始的時候指定,如果沒有指定了話會是 SingleLine mode,所以這邊就會進到 refreshSingleLine

struct abuf { char *b; int len; }; static void refreshSingleLine(struct linenoiseState *l) { char seq[64]; size_t plen = strlen(l->prompt); int fd = l->ofd; char *buf = l->buf; size_t len = l->len; size_t pos = l->pos; struct abuf ab; while((plen+pos) >= l->cols) { buf++; len--; pos--; } while (plen+len > l->cols) { len--; } abInit(&ab); /* Cursor to left edge */ snprintf(seq,64,"\r"); abAppend(&ab,seq,strlen(seq)); /* Write the prompt and the current buffer content */ abAppend(&ab,l->prompt,strlen(l->prompt)); if (maskmode == 1) { while (len--) abAppend(&ab,"*",1); } else { abAppend(&ab,buf,len); } /* Show hits if any. */ refreshShowHints(&ab,l,plen); /* Erase to right */ snprintf(seq,64,"\x1b[0K"); abAppend(&ab,seq,strlen(seq)); /* Move cursor to original position. */ snprintf(seq,64,"\r\x1b[%dC", (int)(pos+plen)); abAppend(&ab,seq,strlen(seq)); if (write(fd,ab.b,ab.len) == -1) {} /* Can't recover from write error. */ abFree(&ab); }

這邊有用到一個結構 abuf 是用來 append buffer,目的是要一次更新完到終端機上,避免文字在螢幕上閃爍。
這邊還要先了解到 ANSI escape code 是一種制定的標準,還可以搭配 VT100、ANSI 碼說明。用來控制終端機的游標位置、顏色等,大多數已 ESC 跳脫字元和 [ 字元開始,後面跟著參數,可以看 wiki 的說明如下。

The general format for an ANSI-compliant escape sequence is defined by ANSI X3.41 (equivalent to ECMA-35 or ISO/IEC 2022). The ESC (27 / hex 0x1B / oct 033) is followed by zero or more intermediate "I" bytes between hex 0x20 and 0x2F inclusive, followed by a final "F" byte between 0x30 and 0x7E inclusive

一般的格式是由 ESC(二進位 27 十六進為 0x1B) 開頭,第二個位元則是 0x40–0x5F (ASCII @A–Z[\]^_) 範圍內的字元,最後一個則是範圍 0x30-0x7E。

snprintf(seq,64,"\r");
abAppend(&ab,seq,strlen(seq));

這邊一開始先用 \r carriage return 把游標位置回到最左邊。

abAppend(&ab,l->prompt,strlen(l->prompt));
abAppend(&ab,buf,len);

這邊是把 prompt 和原本的 buffer 內容寫回去。

snprintf(seq,64,"\x1b[0K");
abAppend(&ab,seq,strlen(seq));

這邊的 0K 對應到的動作是 EL (Erase in Line),n 為 0 的時候會從游標的位置開始清除到最後行尾。

snprintf(seq,64,"\r\x1b[%dC", (int)(pos+plen));
abAppend(&ab,seq,strlen(seq));

這邊則是先把游標先移到最左,這邊的動作是 CUF (Cursor Forward),是把游標向右移動 n 個,然後 %d 這邊是吃 pos+plen,也就是從最左邊移動到原本的游標位置。

if (write(fd,ab.b,ab.len) == -1) {} /* Can't recover from write error. */

最後一併 write 到終端機輸出。

refreshShowHints

這邊會呼叫到 hintsCallback,這個 callback function 也是在一開始就指定的,所以對應 example.c 的 function 就是 hints,會設定好顏色、粗細,回傳 hint 的字串。

char *hints(const char *buf, int *color, int *bold) { if (!strcasecmp(buf,"hello")) { *color = 35; *bold = 0; return " World"; } return NULL; }
void refreshShowHints(struct abuf *ab, struct linenoiseState *l, int plen) { char seq[64]; if (hintsCallback && plen+l->len < l->cols) { int color = -1, bold = 0; char *hint = hintsCallback(l->buf,&color,&bold); if (hint) { int hintlen = strlen(hint); int hintmaxlen = l->cols-(plen+l->len); if (hintlen > hintmaxlen) hintlen = hintmaxlen; if (bold == 1 && color == -1) color = 37; if (color != -1 || bold != 0) snprintf(seq,64,"\033[%d;%d;49m",bold,color); else seq[0] = '\0'; abAppend(ab,seq,strlen(seq)); abAppend(ab,hint,hintlen); if (color != -1 || bold != 0) abAppend(ab,"\033[0m",4); /* Call the function to free the hint returned. */ if (freeHintsCallback) freeHintsCallback(hint); } } }

linenoiseHistory 相關

這是為了記錄下每次出入的指令,作法是用指標的指標 history 記錄下來每次輸入的字串。

static int history_max_len = LINENOISE_DEFAULT_HISTORY_MAX_LEN; static int history_len = 0; static char **history = NULL;

最一開始啟動的時候會呼叫 linenoiseHistoryLoad,去從實體檔案當中將內容讀出來到記憶體,

linenoiseHistoryLoad("history.txt"); /* Load the history at startup */

之後在每次輸入完指令都會呼叫 linenoiseHistoryAdd 新增一筆,然後用 linenoiseHistorySave 存到實體檔案中。

while((line = linenoise("hello> ")) != NULL) { /* Do something with the string. */ if (line[0] != '\0' && line[0] != '/') { printf("echo: '%s'\n", line); linenoiseHistoryAdd(line); /* Add to the history. */ linenoiseHistorySave("history.txt"); /* Save the history on disk. */ } else if (!strncmp(line,"/historylen",11)) { /* The "/historylen" command will change the history len. */ int len = atoi(line+11); linenoiseHistorySetMaxLen(len); } else if (!strncmp(line, "/mask", 5)) { linenoiseMaskModeEnable(); } else if (!strncmp(line, "/unmask", 7)) { linenoiseMaskModeDisable(); } else if (line[0] == '/') { printf("Unreconized command: %s\n", line); } free(line); }

然後透過 linenoiseEditHistoryNext 把當前的輸入改成上一個或下一個 history 的內容。