Todo-лист для командной строки на Deno
- среда, 24 июня 2020 г. в 00:33:41
Вы уже наверняка слышали про Deno и, скорее всего, прочитали пару-тройку обзоров. В рамках своей статьи я предлагаю испытать Deno на практике, написав приложение для командной строки, попутно размышляя о преимуществах и возможных способах его применения.
Приложение для терминала позволит взглянуть на стандартную библиотеку, познакомиться с вводом и выводом без использования фреймворков.

Основные преимущества, такие как безопасность, TypeScript «из коробки», встроенные инструменты, обширная стандартная библиотека, а также возможность импортировать и запускать скрипты по URL, выгодно отличают Deno от Node и Bash. С помощью Deno мы можем написать скрипт, залить на GitHub Gist и запускать при необходимости командой deno run https://path.to/script.ts, не заботясь о зависимостях и настройке окружения.
Для начала работы нужно только установить Deno одним из предложенных способов https://deno.land/#installation.
Todo-лист должен уметь:
Практически любая консольная утилита управляется с помощью передаваемых при запуске аргументов. В Deno они лежат массивом в Deno.args. А, к примеру, в Node мы бы использовали process.argv, в котором помимо аргументов находятся строки node и путь к скрипту.
// deno run args.ts first second
console.log(Deno.args) // [ "first", "second" ]В простых случаях Deno.args будет достаточно, а в более сложных можно воспользоваться парсером из стандартной библиотеки: https://deno.land/std/flags или любым другим на выбор.
Реализуем первую команду – вывод информации.
Создадим главный файл нашего приложения – todo.ts. Лучше назвать файл осмысленно, так как в будущем он понадобится нам для запуска и установки.
Далее создадим файл /src/ui.ts, в котором будем хранить элементы «пользовательского интерфейса». Добавим константу help, содержащую информационный текст.
export const help = `todo help - to show available commands`;В todo.ts напишем свитч для обработки команд и выведем справку с помощью console.log().
#!/usr/bin/env deno run
import { help } from "./src/ui.ts";
const [command, ...args] = Deno.args;
switch (command) {
case "help":
console.log(help);
}Проверим работу команды:
deno run todo.ts help
Для того, чтобы при каждом запуске не писать deno run, добавим шебанг #!.
#!/usr/bin/env deno runРазрешим запуск файла:
chmod +x todo.ts
Теперь для вывода справки можно использовать:
./todo.ts help
В листинге выше мы использовали console.log(), но, мне кажется, идеологически log – это инструмент разработчика, и реализовать с его помощью полноценный интерфейс не получится.
В Deno за стандартный вывод отвечает Deno.stdout, за ввод, соответственно, Deno.stdin.
/** A handle for `stdin`. */
export const stdin: Reader & ReaderSync & Closer & { rid: number };
/** A handle for `stdout`. */
export const stdout: Writer & WriterSync & Closer & { rid: number };И stdin, и stdout типизированы пересечением интерфейсов и объекта, содержащего rid, – идентификатор ресурса.
Более подробно рассмотрим stdout и выведем «Hello world!» в консоль.
const textEncoder = new TextEncoder();
const message: string = "Hello world!\n";
const encodedMessage: Uint8Array = textEncoder.encode(message);
await Deno.stdout.write(encodedMessage);Запустим скрипт hello.ts:
deno run hello.ts
На экране появляется наш «Hello world!».
export interface Writer {
write(p: Uint8Array): Promise<number>;
}Функция Deno.stdout.write принимает на вход массив байтов и возвращает количество записанных байтов. Deno.stdout.writeSync работает точно так же, только синхронно.
Строку необходимо предварительно закодировать с помощью объекта TextEncoder.
Можно не создавать TextEncoder, а импортировать функцию encode из стандартной библиотеки, которая делает то же самое, точно так же.
import { encode } from 'https://deno.land/std/encoding/utf8.ts';Воспользуемся полученными знаниями и создадим файл terminal.ts с двумя функциями print и printLines. Последняя выводит строку и переводит каретку.
import { encode } from "https://deno.land/std/encoding/utf8.ts";
export async function print(message: string) {
await Deno.stdout.write(encode(message));
}
export async function printLines(message: string) {
await print(message + "\n");
}Заменим console.log на printLines.
#!/usr/bin/env deno run
import { help } from "./src/ui.ts";
import { printLines } from "./src/Terminal.ts";
const [command, ...args] = Deno.args;
switch (command) {
case "help":
await printLines(help);
}В Deno для чтения файла используется функция Deno.readFile и её синхронный аналог Deno.readFileSync.
function Deno.readFile(path: string | URL): Promise<Uint8Array>const decoder = new TextDecoder("utf-8");
const data = await Deno.readFile("hello.txt");
console.log(decoder.decode(data));Для записи в файл соответственно используются Deno.writeFile и Deno.writeFileSync.
function Deno.writeFile(path: string | URL, data: Uint8Array, options?: WriteFileOptions): Promise<void>const encoder = new TextEncoder();
const data = encoder.encode("Hello world\n");
await Deno.writeFile("hello1.txt", data); // overwrite "hello1.txt" or create itВ стандартной библиотеке Deno содержатся функции, позволяющие упростить взаимодействие с файловой системой: https://deno.land/std/fs. Например, код выше можно заменить вызовами функций readFileStr и writeFileStr.
Создадим интерфейс Task в src/Task.ts
export interface Task {
title: string;
isDone: boolean;
}и класс Todo в файле src/Todo.ts.
import {
writeJsonSync,
readJsonSync,
} from "https://deno.land/std/fs/mod.ts";
import { Task } from "./Task.ts";
export class Todo {
tasks: Task[] = [];
constructor(private file: string = "tasks.json") {
this.open();
}
open() {
try {
this.tasks = readJsonSync(this.file) as Task[];
} catch (e) {
console.log(e);
this.tasks = [];
}
}
save() {
writeJsonSync(this.file, this.tasks, { spaces: 2 });
}
}Как можно заметить, для чтения и записи данных я использовал специальные функции из стандартной библиотеки для работы с json-файлами.
Для реализации команды todo add "implement add command" добавляем в класс Todo метод add.
add(title: string) {
const task: Task = {
title,
isDone: false
};
this.tasks.push(task);
this.save();
}Так как мы добавили чтение и запись в файл, необходимо добавить права --allow-read --allow-write.
#!/usr/bin/env deno run --allow-read --allow-write
import { help } from "./src/ui.ts";
import { printLines } from "./src/terminal.ts";
import { Todo } from "./src/Todo.ts";
const [command, ...args] = Deno.args;
const todo = new Todo();
switch (command) {
case "help":
await printLines(help);
break;
case "add":
todo.add(args[0]);
}В файл src/ui.ts добавляем функцию для форматирования списка задач.
import { Task } from "./Task.ts";
import { green, red, yellow, bold } from "https://deno.land/std/fmt/colors.ts";
export function formatTaskList(tasks: Task[]): string {
const title = `${bold("TODO LIST:")}`;
const list = tasks.map((task, index) => {
const number = yellow(index.toString());
const checkbox = task.isDone ? green("[*]") : red("[ ]");
return `${number} ${checkbox} ${task.title}`;
});
const lines = [
title,
...list,
];
return lines.join("\n");
}Для задания стиля и цвета текста используем функции форматирования из https://deno.land/std/fmt/colors.ts.
Добавляем метод list в класс Todo и команду ls в свитч.
async list() {
await printLines(formatTaskList(this.tasks));
}case "ls":
await todo.list();
break;Таким же образом добавляем остальные методы и команды.
done(index: number): void {
const task = this.tasks[index];
if (task) {
task.isDone = true;
this.save();
}
}
undone(index: number): void {
const task = this.tasks[index];
if (task) {
task.isDone = false;
this.save();
}
}
edit(index: number, title: string) {
const task = this.tasks[index];
if (task) {
task.title = title;
this.save();
}
}
remove(index: number) {
this.tasks.splice(index);
this.save();
}case "edit": // todo edit 1 "edit second task"
await todo.edit(parseInt(args[0], 10), args[1]);
break;
case "done" : // todo done 1
todo.done(parseInt(args[0], 10));
break;
case "undone" : // todo undone 1
todo.undone(parseInt(args[0], 10));
break;
case "remove" : // todo remove 1
todo.remove(parseInt(args[0], 10));
break;Реализованные команды отвечают первым трём пунктам наших требований.
Интерактивный режим подразумевает, что программа общается с пользователем, не закрываясь после выполнения команды.
Создадим в свитче кейс по умолчанию,
default:
await todo.interactive();а в классе Todo асинхронный метод interactive.
async interactive() {
while (true) {
// show list
// read keypress
// do action
}
}Метод interactive содержит бесконечный цикл, в котором выводится пользовательский интерфейс, ожидается ответ пользователя и, в зависимости от нажатой клавиши, совершается действие.
Для управления курсором в интерактивном режиме необходимо импортировать функции из библиотеки cursor.
Добавляем права для доступа к энвайронменту, так как библиотека проверяет, в каком окружении программа запущена --allow-env.
import { clearDown, goUp, goLeft } from "https://denopkg.com/iamnathanj/cursor@v2.0.0/mod.ts";В файле terminal.ts создадим функцию printInteractive.
export async function printInteractive(message: string) {
const lines = message.split("\n");
const numberOfLines = lines.length;
const lengthOfLastLine = lines[numberOfLines - 1].length;
await clearDown();
await print(message);
await goLeft(lengthOfLastLine);
if (numberOfLines > 1) {
await goUp(numberOfLines - 1);
}
}Она выводит текст на экран и возвращает курсор в начало. При следующем вызове всё стирается и печатается заново. Для того чтобы квадрат курсора не мешался, его можно скрыть функцией hideCursor.
Добавим в файл ui.ts константу toolbar, в которой будут перечислены доступные действия.
const toolbar = `${yellow("d")}one ${yellow("a")}dd ${yellow("e")}dit ${yellow("r")}emove`;В функцию formatTaskList добавляем параметр showToolbar:
export function formatTaskList(tasks: Task[], showToolbar: boolean): string {
// ...
if (showToolbar) {
lines.push(toolbar);
}В зависимости от флага showToolbar мы будем показывать или скрывать подсказку.
Дорабатываем метод list:
async list(interactive: boolean = false) {
if (interactive) {
await printInteractive(formatTaskList(this.tasks, true));
} else {
await printLines(formatTaskList(this.tasks, false));
}
}Вывод реализован, перейдём к вводу.
Интерфейс ввода, аналогично выводу работает с Uint8Array, поэтому для декодирования используется TextDecoder.
const textDecoder = new TextDecoder();
const buffer: Uint8Array = new Uint8Array(1024);
const n: number = <number>await Deno.stdin.read(buffer);
const message: string = textDecoder.decode(buffer.subarray(0, n));Создаём буфер размером, например, в 1024 байта. Передаём его в функцию Deno.stdin.read и ждём пока пользователь закончит ввод. Как только будет нажата клавиша enter, функция наполнит буфер и вернёт количество прочитанных байтов. Стоит отметить, что перевод строки «\n» будет в конце прочитанной последовательности. Обрезаем лишнее и декодируем полученную строку. Синхронная функция Deno.stdin.readSync работает аналогично.
Для того чтобы получить информацию о нажатии клавиш, необходимо воспользоваться функцией Deno.setRaw. На текущий момент она доступна под флагом --unstable. setRaw и позволяет получить символы по одному, без обработки. В функцию необходимо передать идентификатор ресурса и флаг. Как только мы воспользовались этим режимом, нажатие CTRL+C перестаёт приводить к закрытию программы, и это поведение нужно реализовать самостоятельно.
Deno.setRaw(Deno.stdin.rid, true);
const length = <number> await Deno.stdin.read(buffer);
Deno.setRaw(Deno.stdin.rid, false);В файл terminal.ts добавляем функцию readKeypress:
export async function readKeypress(): Promise<string> {
const buffer = new Uint8Array(1024);
Deno.setRaw(Deno.stdin.rid, true);
const length = <number> await Deno.stdin.read(buffer);
Deno.setRaw(Deno.stdin.rid, false);
return decode(buffer.subarray(0, length));
}В метод interactive добавляем возможность выйти из приложения.
async interactive() {
while (true) {
await this.list(true);
const key = await readKeypress();
if (key == "\u0003") { // ctrl-c
Deno.exit();
}
}
}Не отходя далеко от ввода текста, реализуем добавление задачи в интерактивном режиме.
В файл terminal.ts добавляем 2 функции:
export async function readLine(): Promise<string> {
const buffer = new Uint8Array(1024);
const length = <number> await Deno.stdin.read(buffer);
return decode(buffer.subarray(0, length - 1));
}
export async function prompt(question: string): Promise<string> {
await clearDown();
await print(question);
const answer = await readLine();
await goLeft(question.length + answer.length);
await goUp(1);
return answer;
}Функция readLine позволяет прочитать одну строку, исключая последний символ, – перевод строки.
Функция prompt очищает экран, печатает вопрос, читает ввод пользователя, а затем перемещает курсор в начало, аналогично функции printInteractive.
В метод interactive добавляем обработку клавиши «а»:
if (key === "a") {
const title = await prompt("Add task: ");
if (title) {
await this.add(title);
}
}Добавим поддержку стрелок клавиатуры. Для того чтобы понимать, какой элемент сейчас выбран, добавляем свойство currentIndex в класс Todo:
private currentIndex: number = 0;А в функции formatTaskList активную строку выделяем жирным и красим в жёлтый:
export function formatTaskList(
tasks: Task[],
showToolbar: boolean = false,
activeIndex?: number,
): string {
const title = `${bold("TODO LIST:")}`;
const list = tasks.map((task, index) => {
// ...
const isActive = activeIndex === index;
const title = isActive ? bold(yellow(task.title)) : task.title;
return `${number} ${checkbox} ${title}`;
});
// ...
}В Todo создаём методы, которые будут изменять currentIndex:
up() {
this.currentIndex = this.currentIndex === 0
? this.tasks.length - 1
: this.currentIndex - 1;
}
down() {
this.currentIndex = this.currentIndex === this.tasks.length - 1
? 0
: this.currentIndex + 1;
}В метод interactive добавляем обработку стрелок:
if (key === "\u001B\u005B\u0041" || key === "\u001B\u005B\u0044") { // вверх или влево
this.up();
} else if (key === "\u001B\u005B\u0042" || key === "\u001B\u005B\u0043") { // вниз или вправо
this.down();
}Аналогичным образом добавляем возможность отмечать выполненные задачи:
toggle(index: number = this.currentIndex) {
const task = this.tasks[index];
if (task) {
task.isDone = !task.isDone;
this.save();
}
}if (key === "d" || key === " ") {
this.toggle();
} else if ('0' <= key && key <= '9') {
this.toggle(parseInt(key, 10));
} Дорабатываем удаление:
remove(index: number = this.currentIndex) {
if (index === this.tasks.length - 1) {
this.up();
}
this.tasks.splice(index, 1);
this.save();
}if (key === "r") {
this.remove();
} И, наконец, редактирование:
if (key === "e") {
if (!this.tasks[this.currentIndex]) {
return;
}
const title = await prompt("Edit task (" + this.tasks[this.currentIndex].title + "): ");
if (title) {
this.edit(this.currentIndex, title);
}
}Проверяем работу приложения:
deno run --allow-read --allow-write --allow-env --unstable ./todo.tsили
./todo.tsДля того что бы наша программа была доступна в терминале, воспользуемся замечательной командой deno install.
deno install --allow-read --allow-write --allow-env --unstable ./todo.tsУстановка, так же как и запуск, может производиться по URL.
deno install --allow-read --allow-write --allow-env --unstable https://raw.githubusercontent.com/dmitriytat/todo/master/todo.tsИмя файла будет использовано как название программы по умолчанию, но с помощью опции -n/--name можно задать другое.
После чего todo можно использовать в командной строке.
Последняя фича, которую мы использовали, но ещё не обсудили – импорт зависимостей. В отличие от Node, в Deno подход к импортированию несколько иной: предполагается, что зависимости импортируются по URL. В этом подходе, на мой взгляд, есть как достоинства, так и недостатки.
При разработке однофайловых скриптов, возможность импортировать по URL является огромным плюсом, но как только мы разработаем что-то большее, уследить за версиями станет труднее.
Для управления сложностью существует два основных решения: файл dep.ts и файл с картой импортов.
В первом случае мы делаем реэкспорт библиотек, собирая все импорты в одном месте.
export { green, red, yellow, bold } from "https://deno.land/std@0.55.0/fmt/colors.ts";
export { decode, encode } from "https://deno.land/std@v0.55.0/encoding/utf8.ts";
export { clearDown, goUp, goLeft } from "https://denopkg.com/iamnathanj/cursor@v2.0.0/mod.ts"; Во втором случае в JSON файле мы пишем alias:
import_map.json
{
"imports": {
"fmt/": "https://deno.land/std@0.55.0/fmt/"
}
}color.ts
import { red } from "fmt/colors.ts";
console.log(red("hello world"));При запуске программы указываем путь до карты и флаг --unstable, так как фича не готова для продакшена.
deno run --importmap=import_map.json --unstable color.ts.В целом, работой Deno я доволен. Новый инструмент позволяет не тратить время на настройку окружения и сразу приступать к написанию кода, что оказалось очень удобно.
Таким образом, я могу рекомендовать Deno для написания небольших скриптов и программ, упрощающих жизнь разработчика, но пока воздержался бы от использования Deno для создания продакшен-приложений.