"use strict";
const readline = require("readline");
const pkg = require("../package");
const fs = require("fs");
const os = require("os");
const KILO_TAB_STOP = 8;
const MULTI_BYTE = 2;
const SEARCHABLE_CHARS = /^[\ta-z0-9!"#$%&'()*+,./:;<=>?@[\]\\ ^_`{|}~-]$/ui;
const MODE = { NORMAL: 0, INSERT: 1, SEARCH: 2, COMMAND: 3 };
/**
* @classdesc This is Kilo class
* @constructor
*/
class Kilo {
/**
* JavaScript port for kilo.c
* @param {Array} argv process.argv
* @constructor
* <ul>
* <li>initialize all E</li>
* <li>this.buf = ''</li>
* <li>set timeout after 5 sec statusmsg will be dismissed</li>
* </ul>
*/
constructor(argv) {
readline.emitKeypressEvents(process.stdin);
this.E = { // editorConfig
cx: 0, // cursor position x
cy: 0, // cursor position y
rx: 0, // rendered cursor position x
rowoff: 0, // row offset
coloff: 0, // column offset
erow: [], // editing row
render: [], // rendering row
screenrows: process.stdout.rows - 2, // screen size(rows) - status bar and message bar
screencols: process.stdout.columns, // screen size(columns)
statusmsg: "", // status message
dirty: 0 // modified flag
};
if (argv && argv.length > 0) {
this.E.filename = argv[0];
}
this.editorSetStatusMessage("HELP): k:up/j:down/l:right/h:left | i:insert | /:search | :w save/ :q quit/ :wq save & quit");
this.backup = {}; // for undo
this.mode = MODE.NORMAL; // mode;
this.sx = [];// searched cursor position x
this.sy = []; // searched cursor position y
this.si = 0; // searched index
this.abuf = ""; // for draw
this.scbuf = ""; // for search
this.ybuf = ""; // for yank
this.prev = ""; // for 2 setp commnd ex) dd yy
}
/**
* exit if some error happened
* @param {string} e dying message
* @param {int} status exit status default: 1
* @returns {void}
*
*/
die(e, status) {
this.abuf = "";
process.stdout.cursorTo(0, 0);
process.stdout.clearScreenDown();
Kilo.disableRawMode();
console.error(e);
process.exit(typeof status === "undefined" ? 1 : parseInt(status, 10));
}
/**
* set TTY rowmode
* @returns {void}
*
*
*/
static enableRawMode() {
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
}
/**
* clear TTY rowmode
* @returns {void}
*
*
*/
static disableRawMode() {
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
process.stdin.resume();
}
}
/**
* open this.E.filename
* @description
* <ul>
* <li>set erow // editor low</li>
* <li>set render // for rendering low</li>
* </ul>
* @throws {Error} - ENOENT: no such file or directory, open this.filename
* @returns {void}
*
*/
editorOpen() {
this.E.erow = fs.readFileSync(this.E.filename, "utf8").trim().split(os.EOL);
this.editorUpdateRow();
this.E.dirty = 0;
}
/**
* save to this.E.filename
* @returns {void}
*/
editorSave() {
const erows = this.E.erow.join(os.EOL);
try {
fs.writeFileSync(this.E.filename, erows);
this.editorSetStatusMessage(`${erows.length} bytes written to disk`);
this.E.dirty = 0;
} catch (e) {
this.editorSetStatusMessage(`${e.name}:${e.message}`);
}
}
/**
* show status message in 5 secs
* @param {string} message status message
* @returns {void}
*/
editorSetStatusMessage(message) {
this.E.statusmsg = message;
setTimeout(() => {
this.E.statusmsg = "";
}, 5000);
}
/**
* update render
* @todo handle multibyte properly
* @returns {void}
*/
editorUpdateRow() {
this.E.render = this.E.erow.map(str => str.replace(/\t/ug, " ".repeat(KILO_TAB_STOP)));
}
/**
* insert single char
* @param {char} c char which will be inserted
* @todo handle multibyte properly
* @returns {void}
*/
editorInsertChar(c) {
this.backup = JSON.stringify(this.E);
if (this.E.cy === this.E.erow.length) {
this.editorInsertRow();
}
let pos = this.E.cx;
const row = this.E.erow[this.E.cy];
if (this.E.cx < 0 || this.E.cx > row.length) {
pos = row.length;
}
this.E.erow[this.E.cy] = `${row.slice(0, pos)}${c}${row.slice(pos)}`;
this.editorUpdateRow();
this.editorMoveCursor("right");
this.E.dirty++;
}
/**
* insert one row
* @param {string} insert string which will be inserted
* @todo handle multibyte properly
* @returns {void}
*/
editorInsertRow(insert) {
this.backup = JSON.stringify(this.E);
this.E.erow.splice(this.E.cy, 0, insert || "");
this.E.dirty++;
}
/**
* delete single char
* @todo handle multibyte properly
* @returns {void}
*/
editorDelChar() {
if (this.E.erow.length === 0) {
return;
}
if (this.E.erow.length <= this.E.cy) {
return;
}
const row = this.E.erow[this.E.cy];
const newRow = `${row.slice(0, this.E.cx)}${row.slice(this.E.cx + 1)}`;
if (newRow.length > 0) {
this.E.erow[this.E.cy] = newRow;
} else {
this.E.erow.splice(this.E.cy, 1);
if (this.E.erow.length > 0 && this.E.cy > 0) {
this.E.cy--;
}
}
this.editorUpdateRow();
this.E.dirty++;
}
/**
* calculate erow cx -> rx
* @param {string} row target row
* @param {int} cx - target cx
* @description
* <ul>
* <li>- treat \t</li>
* <li>- treat unicode multibyte characters</li>
* </ul>
* @returns {int} rx - rx position
* @todo handle multibyte properly
*/
static editorRowCxToRx(row, cx) {
const chars = row.match(/./ug);
let rx = 0;
for (let j = 0; j < cx; j++) {
if (chars[j] === "\t") { // tab
rx += (KILO_TAB_STOP - 1) - (rx % KILO_TAB_STOP);
}
if (/%[89ABab]/ug.test(encodeURIComponent(chars[j]))) { // multibyte
rx += (MULTI_BYTE - 1) - (rx % MULTI_BYTE);
}
rx++;
}
return rx;
}
/**
* calculate scrolling offset
* @description
* <ul>
* <li>handle rowoff</li>
* <li>handle coloff</li>
* </ul>
* @returns {void}
*
*/
editorScroll() {
this.E.rx = 0;
if (this.E.cy < this.E.erow.length) { // calculate rx if cursor is in the file
this.E.rx = Kilo.editorRowCxToRx(this.E.erow[this.E.cy], this.E.cx);
}
if (this.E.cy < this.E.rowoff) { // move cy to the top of the window if cy is off the top of the scrolling window
this.E.rowoff = this.E.cy;
}
if (this.E.cy >= this.E.rowoff + this.E.screenrows) { // move cy to the bottom of the window if cy is off the bottom of the scrolling window
this.E.rowoff = this.E.cy - this.E.screenrows + 1;
}
if (this.E.rx < this.E.coloff) { // move rx to the left of the window if rx is off the left of the scrolling window
this.E.coloff = this.E.rx;
}
if (this.E.rx >= this.E.coloff + this.E.screencols) { // move rx to the right of the window if rx is off the right of the scrolling window
this.E.coloff = this.E.rx - this.E.screencols + 1;
}
}
/**
* refresh screen
* @description
* <ul>
* <li>hide cursor</li>
* <li>draw rows (file contents)</li>
* <li>draw status bar</li>
* <li>draw message bar</li>
* <li>set cursor proper position (rx,cy)</li>
* <li>show cursor</li>
* </ul>
* @returns {void}
*
*/
editorRefreshScreen() {
this.editorScroll();
this.abuf += "\x1b[?25l"; // hide cursor
this.abuf += "\x1b[H"; // set ursor 0,0
this.editorDrawRows();
this.editorDrawStatusBar();
this.editorDrawMessageBar();
this.abuf += `\x1b[${(this.E.cy - this.E.rowoff) + 1};${(this.E.rx - this.E.coloff) + 1}H`; // set cursor position
this.abuf += "\x1b[?25h"; // show cursor
process.stdout.write(this.abuf, this.abuf.length);
this.abuf = "";
}
/**
* handle key action
* @param {string} str captured str (not used in this class)
* @param {Object} key captured key information
* @throws {Error}
* @returns {void}
*/
editorReadKey(str, key) {
try {
if (key.meta) {
this.mode = MODE.NORMAL;
} else {
let command = "";
if (this.mode === MODE.NORMAL) {
if (typeof key.name === "undefined") { // not insert and not alphabet
switch (key.sequence) {
case "$":
this.editorMoveCursor("end");
break;
case "^":
this.editorMoveCursor("home");
break;
case "/":
this.mode = MODE.SEARCH;
this.sx = [];
this.sy = [];
this.scbuf = "";
this.si = 0;
command = `/${this.scbuf}`;
break;
case ":":
this.mode = MODE.COMMAND;
this.scbuf = "";
command = `:${this.scbuf}`;
break;
default:
break;
}
} else { // not insert and alphabet
this.editorMoveCursor(key.name, key.sequence);
}
this.prev = key.name;
} else if (this.mode === MODE.INSERT) { // insert mode
if (SEARCHABLE_CHARS.test(key.sequence)) {
this.editorInsertChar(key.sequence);
} else if (key.name === "return") {
this.editorInsertRow();
this.editorMoveCursor("home");
this.editorMoveCursor("down");
this.editorUpdateRow();
} else {
this.editorMoveCursor(key.name, key.sequence);
}
} else if (this.mode === MODE.SEARCH) {
switch (key.name) {
case "return":
this.mode = MODE.NORMAL;
case "backspace":
case "delete":
this.scbuf = "";
break;
case "right":
case "up":
this.si = (this.si + 1) % this.sx.length;
break;
case "left":
case "down":
this.si--;
if (this.si < 0) {
this.si = this.sx.length - 1;
}
break;
default:
if (SEARCHABLE_CHARS.test(key.sequence)) {
this.scbuf += key.sequence;
this.sx = [];
this.sy = [];
this.si = 0;
this.E.erow.forEach((r, y) => {
[...r.matchAll(new RegExp(this.scbuf.replace(/[.*+?^${}()|[\]\\]/ug, "\\$&"), "ugi"))].forEach(m => {
this.sx.push(m.index);
this.sy.push(y);
});
});
}
}
if (this.sx.length > this.si) {
this.E.cx = this.sx[this.si];
this.E.cy = this.sy[this.si];
}
command = `/${this.scbuf} (${this.sx.length}) found <-prev:next->)`;
} else if (this.mode === MODE.COMMAND) {
if (key.name === "return") {
switch (this.scbuf) {
case "w":
this.editorSave();
this.editorRefreshScreen();
this.scbuf = "";
return;
case "wq":
this.editorSave();
this.editorRefreshScreen();
this.die("BYE", 0);
this.scbuf = "";
return;
case "q":
this.die("BYE", 0);
break;
default:
break;
}
this.scbuf = "";
command = `:${this.scbuf}`;
} else if (key.name === "delete" || key.name === "backspace") {
if (this.scbuf.length <= 0) {
this.mode = MODE.NORMAL;
command = "";
} else {
this.scbuf = this.scbuf.slice(0, -1);
command = `:${this.scbuf}`;
}
} else if (SEARCHABLE_CHARS.test(key.sequence)) {
this.scbuf += key.sequence;
command = `:${this.scbuf}`;
}
}
this.editorSetStatusMessage(`${command} (${this.E.cx}:${this.E.cy}) -- ${Object.keys(MODE)[this.mode]} --`);
}
this.editorRefreshScreen();
} catch (e) {
this.die(e);
}
}
/**
* resize terminal
* @returns {void}
*/
editorResize() {
this.E.screenrows = process.stdout.rows - 2; // status bar and message bar
this.E.screencols = process.stdout.columns;
this.editorRefreshScreen();
}
/**
* handle key action for cursor movement
* @param {string} name key.name
* @param {string} sequence key.sequence
* @returns {void}
*/
editorMoveCursor(name, sequence) {
const row = (this.E.cy >= this.E.erow.length) ? false : this.E.erow[this.E.cy];
switch (name) {
case "home":
case "0":
this.E.cx = 0;
break;
case "end":
if (this.E.cy < this.E.erow.length) {
this.E.cx = this.E.erow[this.E.cy].length;
}
break;
case "backspace":
this.editorMoveCursor("left");
case "delete":
case "x":
this.backup = JSON.stringify(this.E);
this.editorDelChar();
if (row !== false && row.length === 0) {
this.editorMoveCursor("down");
}
break;
case "a":
this.editorMoveCursor("right");
case "insert":
case "i":
this.mode = MODE.INSERT;
this.editorSetStatusMessage(`(${this.E.cx}:${this.E.cy}) - -- INSERT --`);
this.editorRefreshScreen();
break;
case "o":
if (sequence === "O") {
this.editorInsertRow();
this.E.cx = 0;
this.editorUpdateRow();
} else {
this.editorMoveCursor("down");
this.editorInsertRow();
this.E.cx = 0;
this.editorUpdateRow();
}
this.mode = MODE.INSERT;
break;
case "u":
{
const backupu = this.E;
this.E = JSON.parse(this.backup);
this.backup = JSON.stringify(backupu);
}
break;
case "y":
if (row !== false && this.prev === "y") { // yy yank
this.ybuf = row;
}
break;
case "g":
if (sequence === "G") {
this.E.cx = 0;
this.E.cy = this.E.erow.length > 0 ? this.E.erow.length - 1 : 0;
} else if (this.prev === "g") { // gg to top
this.E.cx = 0;
this.E.cy = 0;
}
break;
case "d":
if (row !== false) { // dd delete a row
if (this.prev === "d") { // dd delete a row
this.backup = JSON.stringify(this.E);
this.E.cx = 0;
this.ybuf = row;
[...Array(row.length)].map(() => this.editorDelChar());
} else if (sequence === "D") {
this.backup = JSON.stringify(this.E);
[...Array(row.length - this.E.cx)].map(() => this.editorDelChar());
}
}
break;
case "p":
this.editorMoveCursor("down");
this.editorInsertRow(this.ybuf);
this.editorUpdateRow();
break;
case "pageup":
this.E.cy = this.E.rowoff;
case "pagedown":
if (name === "pagedown") {
this.E.cy = this.E.rowoff + this.E.screenrows - 1;
if (this.E.cy > this.E.erow.length) {
this.E.cy = this.E.erow.length;
}
}
[...Array(this.E.screenrows)].map(() => this.editorMoveCursor(name === "pageup" ? "up" : "down"));
break;
case "h":
case "left":
if (this.E.cx > 0) {
this.E.cx--;
} else if (this.E.cy > 0) {
this.E.cy--;
this.E.cx = this.E.erow[this.E.cy].length;
}
break;
case "l":
case "right":
if (row && this.E.cx < row.length) {
this.E.cx++;
} else if (row !== false && this.E.cx === row.length) {
this.E.cy++;
this.E.cx = 0;
}
break;
case "k":
case "up":
if (this.E.cy > 0) {
this.E.cy--;
}
break;
case "j":
case "down":
case "return":
if (this.E.cy < this.E.erow.length) {
this.E.cy++;
}
break;
default:
break;
}
const rowlen = (this.E.cy >= this.E.erow.length) ? 0 : this.E.erow[this.E.cy].length;
if (this.E.cx > rowlen) {
this.E.cx = rowlen;
}
}
/**
* drow status bar
* @returns {void}
*/
editorDrawStatusBar() {
this.abuf += "\x1b[7m"; // invert the colors. (usually black -> white)
const status = `${this.E.filename ? this.E.filename : "[No Name]"} - ${this.E.erow.length} lines ${this.E.dirty > 0 ? "(modified)" : ""}`;
const rstatus = `${parseInt((this.E.cy + 1) / this.E.erow.length * 100, 10)}% ${this.E.cy + 1}/${this.E.erow.length}`;
let len = status.length;
if (len > this.E.screencols) {
len = this.E.screencols;
}
this.abuf += status.slice(0, len);
while (len < this.E.screencols) {
if (this.E.screencols - len === rstatus.length) {
this.abuf += rstatus;
break;
} else {
this.abuf += " ";
len++;
}
}
this.abuf += "\x1b[m"; // revert the colors. (usually white -> black)
this.abuf += os.EOL;
}
/**
* drow message bar
* @returns {void}
*/
editorDrawMessageBar() {
this.abuf += "\x1b[K"; // remove all chars after the cursor position
this.abuf += this.E.statusmsg.length > this.E.screencols ? this.E.statusmsg.slice(0, this.E.screencols) : this.E.statusmsg;
}
/**
* drow file contents
* @returns {void}
*/
editorDrawRows() {
[...Array(this.E.screenrows)].forEach((_, y) => {
const filerow = y + this.E.rowoff;
if (filerow >= this.E.erow.length) {
if (this.E.erow.length === 0 && y === parseInt(this.E.screenrows / 3, 10)) {
const welcome = `Kilo editor -- version ${pkg.version}`;
let welcomelen = welcome.length;
if (welcomelen > this.E.screencols) {
welcomelen = this.E.screencols;
}
let padding = parseInt((this.E.screencols - welcomelen) / 2, 10);
if (padding > 0) {
this.abuf += "~";
padding--;
}
[...Array(padding)].forEach(() => {
this.abuf += " ";
});
this.abuf += welcome;
} else {
this.abuf += "~";
}
} else {
const row = this.E.render[filerow];
let len = row.length - this.E.coloff;
if (len < 0) {
len = 0;
}
if (len > this.E.screencols) {
len = this.E.screencols;
}
// syntax high right
this.abuf += Kilo.editorUpdateSyntax(row.slice(this.E.coloff, this.E.coloff + len));
}
this.abuf += "\x1b[K"; // remove all chars after the cursor position
this.abuf += os.EOL;
});
}
/**
* syntax highlighting
* @param {string} row one row
* @returns {string} - highlighted string
*/
static editorUpdateSyntax(row) {
return row
.replace(/([0-9]+)/ug // number
, (match, p1) => `\x1b[31m${p1}\x1b[39m`)
.replace(/(')([^']*)(')/ug // operator
, (match, p1, p2, p3) => `\x1b[35m${p1}${p2}${p3}\x1b[39m`) // string quote
.replace(/(")([^"]*)(")/ug // operator
, (match, p1, p2, p3) => `\x1b[35m${p1}${p2}${p3}\x1b[39m`) // string quote
.replace(/(&{1,2}|[-*+\\|?<>;:=!])/ug // operator
, (match, p1) => `\x1b[36m${p1}\x1b[39m`)
.replace(/\b(typeof|try|let|const|constructor|require|this|new|undefined|static)\b/ug // keyword
, (match, p1) => `\x1b[32m${p1}\x1b[39m`)
.replace(/\b(break|case|catch|continue|debugger|default|delete|do|else|finally|for|function|if|while|in|instanceof|new|return|switch)\b/ug // reserved
, (match, p1) => `\x1b[33m${p1}\x1b[39m`)
.replace(/(\/\/.*$)/ug // comment out
, (match, p1) => `\x1b[36m${p1}\x1b[39m`);
}
/**
* main function
* @returns {void}
*/
main() {
try {
Kilo.enableRawMode();
if (typeof this.E.filename !== "undefined") {
this.editorOpen();
}
this.editorRefreshScreen();
process.stdin.on("keypress", this.editorReadKey.bind(this));
process.stdout.on("resize", this.editorResize.bind(this));
} catch (e) {
this.die(e);
}
}
}
if (typeof module !== "undefined") {
module.exports = Kilo;
}