Всем привет! В этой статье расскажу, как собрать интерактивный отладчик для nodejs
за 4 шага.
Насколько я знаю, в nodejs
нет отладчика такого же простого в использовании как python pdb
, когда в любом месте кода можно просто поставить брейкпоинт и вывалиться в отладочную консоль, Можно использовать node inspect или node-inspector. Но в обоих случаях требуется запускать node
через обертку или доп. аргумент. Это неудобно в случае, если вы запускаете не голый node
, а какую-то утилиту ее использующую, например тест-раннер. А хочется простоты и удобства питонячего мира.
Если вы используете python
, то вам должна быть знакома чудесная функция set_trace()
, которая переводит ваш скрипт в интерактивный режим. Поскольку современный nodejs
поддерживает async / await
нативно, давайте сделаем подобный отладчик для nodejs
.
Шаг 1 - Просто интерактивный отладчик
var readline = require("readline");
var util = require("util");
require("colors");
module.exports = async function () {
console.log("interactive mode".yellow);
var rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
var isFinished = false;
while (!isFinished) {
isFinished = await new Promise((resolve, reject) => {
rl.question("> ".red, answer => {
if (answer === "exit") {
console.log("emergency exit".red)
process.exit(1);
};
if (answer === "go") {
console.log("continue execution".green);
resolve(true);
return;
};
if (["help", "h"].includes(answer)) {
console.log(("In interactive mode you may execute any nodejs code.\n" +
"Also next commands are available:\n" +
"- h, help - show interactive mode help;\n" +
"- go - continue code execution;\n" +
"- exit - finish current nodejs process;").white);
resolve(false);
return;
};
Promise
.resolve()
.then(() => eval(answer))
.then(result => console.log(util.format(result).yellow))
.catch(e => console.log(util.format(e).red))
.then(() => resolve(false));
});
});
};
};
Неплохо, неправда ли? Теперь мы можем вызвать await our_debugger()
внутри какой-нибудь async
функции и выполнить какие-нибудь команды интерактивно. Очень удобно, особенно в тестировании.
Шаг 2 - Автодополнение кода
Но давайте двигаться дальше. Каждый уважающий себя интерактивный отладчик (или консоль) может делать автодополнение кода. К счастью, readline
частично берет это на себя, нужно лишь передать функцию completer
. Давайте сделаем это.
var readline = require("readline");
var util = require("util");
require("colors");
var complete = line => {
var tokens = line.split(/ /).filter(i => i);
if (!tokens.length) return [[], line];
var targetToken = tokens[tokens.length - 1];
var namespace = global;
var filterPrefix = targetToken;
var targetObject;
if (targetToken.includes(".")) {
targetObject = targetToken.split('.');
filterPrefix = targetObject.pop();
targetObject = targetObject.join(".");
if (!targetObject) return [[], line];
try {
namespace = eval(targetObject);
} catch (e) {
return [[], line];
};
};
var completions = Object
.getOwnPropertyNames(namespace)
.filter(i => i.startsWith(filterPrefix));
if (targetObject) {
completions = completions.map(i => targetObject + "." + i);
};
return [completions, line];
};
module.exports = async function () {
console.log("interactive mode".yellow);
var rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
completer: complete,
});
var isFinished = false;
while (!isFinished) {
isFinished = await new Promise((resolve, reject) => {
rl.question("> ".red, answer => {
if (answer === "exit") {
console.log("emergency exit".red)
process.exit(1);
};
if (answer === "go") {
console.log("continue execution".green);
resolve(true);
return;
};
if (["help", "h"].includes(answer)) {
console.log(("In interactive mode you may execute any nodejs code.\n" +
"Also next commands are available:\n" +
"- h, help - show interactive mode help;\n" +
"- go - continue code execution;\n" +
"- exit - finish current nodejs process;").white);
resolve(false);
return;
};
Promise
.resolve()
.then(() => eval(answer))
.then(result => console.log(util.format(result).yellow))
.catch(e => console.log(util.format(e).red))
.then(() => resolve(false));
});
});
};
};
Гораздо лучше. Теперь мы может использовать TAB
для автодополнения кода доступными вариантами.
###Шаг 3 - Присвоение переменных
По поводу присвоения переменных в нашей консоли. Мы можем сделать так x = 5
и переменная будет сохранена для дальнейшего использования. Но var x = 5
работать не будет . И это определенно нужно исправить. Для этого давайте заюзаем ast
парсер, чтобы анализировать код. nodejs
не имеет встроенного модуля для парсинга кода в ast
дерево, поэтому используем стороннюю библиотеку espree.
var readline = require("readline");
var util = require("util");
require("colors");
var espree = require("espree");
var complete = line => {
var tokens = line.split(/ /).filter(i => i);
if (!tokens.length) return [[], line];
var targetToken = tokens[tokens.length - 1];
var namespace = global;
var filterPrefix = targetToken;
var targetObject;
if (targetToken.includes(".")) {
targetObject = targetToken.split('.');
filterPrefix = targetObject.pop();
targetObject = targetObject.join(".");
if (!targetObject) return [[], line];
try {
namespace = eval(targetObject);
} catch (e) {
return [[], line];
};
};
var completions = Object
.getOwnPropertyNames(namespace)
.filter(i => i.startsWith(filterPrefix));
if (targetObject) {
completions = completions.map(i => targetObject + "." + i);
};
return [completions, line];
};
module.exports = async function () {
console.log("interactive mode".yellow);
var rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
completer: complete,
});
var origGlobals = {};
var isFinished = false;
while (!isFinished) {
isFinished = await new Promise((resolve, reject) => {
rl.question("> ".red, answer => {
if (answer === "exit") {
console.log("emergency exit".red)
process.exit(1);
};
if (answer === "go") {
console.log("continue execution".green);
resolve(true);
return;
};
if (["help", "h"].includes(answer)) {
console.log(("In interactive mode you may execute any nodejs code.\n" +
"Also next commands are available:\n" +
"- h, help - show interactive mode help;\n" +
"- go - continue code execution;\n" +
"- exit - finish current nodejs process;").white);
resolve(false);
return;
};
var ast, varName;
try {
ast = espree.parse(answer, { ecmaVersion: 9 });
varName = ast.body[0].expression.left.name;
} catch (e) {
try {
varName = ast.body[0].declarations[0].id.name;
} catch (e) {};
};
Promise
.resolve()
.then(() => {
var result = eval(answer);
if (varName) {
if (!Object.keys(origGlobals).includes(varName)) {
origGlobals[varName] = global[varName];
};
global[varName] = eval(varName);
};
return result;
})
.then(result => console.log(util.format(result).yellow))
.catch(e => console.log(util.format(e).red))
.then(() => resolve(false));
});
});
};
for (var [k, v] of Object.entries(origGlobals)) {
global[k] = v;
};
};
Ок, теперь это просто сделать x = 5
или var x = 5
. Но забавная вещь - невозможно выполнить let x = 5
или const x = 5
из-за ограничений языка для функции eval
. Например:
$ node
> eval('let x = 5')
undefined
> x
ReferenceError: x is not defined
Шаг 4 - Подсветка кода
И вишенка на торте: подсветка кода как в ipdb.set_trace(). Давайте использовать библиотеку cli-highlight для этого. Для того, чтобы обеспечить интерактивную подсветку кода мы запатчим метод _ttyWrite
в для оригинального модуля readline
.
Но здесь есть один хитрый момент. Подсветка кода означает дополнительные скрытые символы, и если мы будем использовать стрелки для перемещения курсора, навигация будет нарушена (попробуйте код ниже без следующего хака). На мой взгляд, очень сложно отслеживать и фиксировать положение курсора в подсвеченном коде. Так что давайте сделаем такой хак:
- когда мы вводим код с начала в нашем отладчике, он будет подсвечен;
- когда мы используем левую стрелку, чтобы изменить что-то внутри набранного кода, подсветка будет отключена, чтобы не сломать позицию курсора;
- когда курсор будет перемещен в конец строки опять, подсветка будет снова включена.
var readline = require("readline");
var util = require("util");
var colors = require("colors");
var espree = require("espree");
var highlight = require("cli-highlight").highlight;
var complete = line => {
line = colors.strip(line);
var tokens = line.split(/ /).filter(i => i);
if (!tokens.length) return [[], line];
var targetToken = tokens[tokens.length - 1];
var namespace = global;
var filterPrefix = targetToken;
var targetObject;
if (targetToken.includes(".")) {
targetObject = targetToken.split('.');
filterPrefix = targetObject.pop();
targetObject = targetObject.join(".");
if (!targetObject) return [[], line];
try {
namespace = eval(targetObject);
} catch (e) {
return [[], line];
};
};
var completions = Object
.getOwnPropertyNames(namespace)
.filter(i => i.startsWith(filterPrefix));
if (targetObject) {
completions = completions.map(i => targetObject + "." + i);
};
return [completions, line];
};
module.exports = async function () {
console.log("interactive mode".yellow);
var rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
completer: complete,
});
var ttyWrite = rl._ttyWrite;
rl._ttyWrite = function (s, key) {
if (this.cursor <= this.line.length) {
this.line = colors.strip(this.line);
if (this.cursor > this.line.length) {
this._moveCursor(+Infinity);
};
};
ttyWrite.call(this, s, key);
if (this.cursor < this.line.length) {
this.line = colors.strip(this.line);
if (this.cursor > this.line.length) {
this._moveCursor(+Infinity);
};
} else {
this.line = highlight(colors.strip(this.line), { language: "js" });
this._moveCursor(+Infinity);
};
if (key.name !== "return") {
this._refreshLine();
};
};
var origGlobals = {};
var isFinished = false;
while (!isFinished) {
isFinished = await new Promise((resolve, reject) => {
rl.question("> ".red, answer => {
answer = colors.strip(answer);
if (answer === "exit") {
console.log("emergency exit".red)
process.exit(1);
};
if (answer === "go") {
console.log("continue execution".green);
resolve(true);
return;
};
if (["help", "h"].includes(answer)) {
console.log(("In interactive mode you may execute any nodejs code.\n" +
"Also next commands are available:\n" +
"- h, help - show interactive mode help;\n" +
"- go - continue code execution;\n" +
"- exit - finish current nodejs process;").white);
resolve(false);
return;
};
var ast, varName;
try {
ast = espree.parse(answer, { ecmaVersion: 9 });
varName = ast.body[0].expression.left.name;
} catch (e) {
try {
varName = ast.body[0].declarations[0].id.name;
} catch (e) {};
};
Promise
.resolve()
.then(() => {
var result = eval(answer);
if (varName) {
if (!Object.keys(origGlobals).includes(varName)) {
origGlobals[varName] = global[varName];
};
global[varName] = eval(varName);
};
return result;
})
.then(result => console.log(util.format(result).yellow))
.catch(e => console.log(util.format(e).red))
.then(() => resolve(false));
});
});
};
for (var [k, v] of Object.entries(origGlobals)) {
global[k] = v;
};
};
Вот и все
P.S. English version is here.