Разработка простого интерактивного отладчика для nodejs за 5 мин

Всем привет! В этой статье расскажу, как собрать интерактивный отладчик для 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 работать не будет :disappointed:. И это определенно нужно исправить. Для этого давайте заюзаем 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;
    };
};

Вот и все :wink:

P.S. English version is here.

1 лайк

Нету преамбулы - поэтому не совсем понятно зачем писать столько кода для реализации простого debug mode.