Что мы собираемся собрать?
Чтобы сосредоточиться на самом языке, мы создадим простое приложение todo с интерфейсом командной строки, с которым знакомы все. Вот видео о том, как будет выглядеть приложение.
Мы реализуем четыре команды: list для вывода списка всех задач, add для добавления новой задачи, done для пометки нескольких задач как выполненных и clear, чтобы удалить все задачи.
Прежде чем продолжить, советую вам просмотреть код на GitHub, чтобы лучше понимать, как мы собираемся его создавать.
Чтобы сделать объяснение предельно ясным, я разобью статью на разделы, где каждые два раздела покажут, как реализовать что-то на JavaScript, а затем на Gleam.
Сначала нужно договориться о том, как хранятся задачи:
- Все задачи сохраняются в формате
[ ] заголовок задачи. - Пометка о том, что задача выполнена, —
*в квадратных скобках, например[*] заголовок задачи. - Файл задач всегда должен заканчиваться пустой новой строкой
\n.
JavaScript: создать новый проект
В этом примере я буду использовать модули Node v20 и ESM. И не буду использовать сторонние библиотеки, но все же хорошая практика — создать новый файл package.json.
В качестве менеджера пакетов я использую PNPM, но вы можете использовать любой предпочитаемый. Итак, чтобы создать package.json, я могу просто запустить pnpm init, и он создаст его с некоторыми значениями по умолчанию.
Затем в корневом каталоге создаем todo.mjs. Вот и все!
Gleam: создать новый проект
Сначала убедитесь, что на вашем компьютере установлен Gleam. Чтобы узнать, как это сделать, ознакомьтесь с документацией.
После этого запустите gleam new gleam_todos. Команда создаст новый проект.
Далее установим необходимые зависимости. Нам понадобится argv для доступа к аргументам командной строки, simplifile для работы с файлами и gleam_community_ansi для форматирования текста с помощью escape-последовательностей ANSI.
Чтобы установить их, запустите:
gleam add argv simplifile gleam_community_ansi
JavaScript: получить аргументы команды
Поскольку я не собираюсь использовать здесь какую-либо стороннюю библиотеку, то просто получу аргументы, используя process.argv.
Я создам для этого вспомогательную функцию под названием getArgs().
function getArgs() {
const [, , command, ...args] = process.argv
return {
command,
args
}
}
Затем воспользуюсь ею в точке входа приложения. Поместим ее в функцию под названием main(). Обратите внимание, что не обязательно делать это в .mjs, но давайте придерживаться версии Gleam.
// файл, где будут храниться todo
const FILE_PATH = './my.todo'
async function main() {
const { command, args } = getArgs()
switch (command) {
// Здесь обработка команды
}
}
function getArgs() {
// ...
}
main()
Gleam: получить аргументы команды
Откройте src/gleam_todos.gleam и введите следующее.
import gleam/io
import argv
// файл, где будут храниться todo
const file_path = "my.todo"
pub fn main() {
case argv.load().arguments {
_ -> Ok(Nil)
}
}
Вызов argv.load().arguments вернет список аргументов команды.
Например, если команда add "first todo", это будет ["add", "first todo"].
В Gleam вместо оператора switch используется сопоставление с образцом.
Сейчас, когда ничего не найдено, мы возвращаем Ok(Nil).
JavaScript: добавить todo
Реализуем это в функции под названием addTodo. Она должна содержать путь к файлу, где хранятся задачи, и заголовок новой задачи.
async function addTodo(filePath, todoTitle) {
// реализуем это спустя какое-то время
}
Прежде чем я покажу вам его код, нам нужно реализовать вспомогательную функцию для обработки файловых операций. В Node для правильной работы с файлами нам нужно получить доступ к дескриптору файла с корректными флагами и правильно закрыть его после того, как мы закончим с ним.
Чтобы упростить задачу, инкапсулируем все это в функцию useTodoFile.
async function useTodoFile(filePath, callback) {
// обработка файла
let fd
try {
fd = await fs.open(filePath, 'a+')
return await callback(fd)
} finally {
fd?.close()
}
}
С этого момента все файловые операции мы будем выполнять с помощью этой функции. Первым аргументом будет путь к файлу, а вторым — обратный вызов, где мы реализуем код.
Здесь мы используем fs, поэтому нужно импортировать его вверху.
import * as fs from 'node:fs/promises'
Теперь вернемся к функции addTodo.
async function addTodo(filePath, todoTitle) {
return await useTodoFile(filePath, async (fd) => {
fd.appendFile(`[ ] ${todoTitle}\n`)
})
}
Мы определили функцию, но нигде ее не использовали ее. Сделаем это в main().
async function main() {
const { command, args } = getArgs()
switch (command) {
case 'add':
await addTodo(FILE_PATH, args[0])
break
}
}
Gleam: добавить todo
Сначала импортируем simplifile и gleam/result вверху.
import simplifile
import gleam/result
Теперь реализуем функцию add_todo.
pub fn add_todo(file_path: String, title: String) -> Result(Nil, Nil) {
simplifile.append(file_path, "[ ] " <> title <> "\n")
|> result.replace(Nil)
|> result.nil_error
}
Чтобы добавить новую задачу в файл, мы используем simplifile.append. Поскольку функция ожидает возврата Result(Nil, Nil), я передал результат simplifile.append в result.replace(Nil) чтобы установить Nil в поле Ok и result.nil_error, чтобы установить Nil в поле Error.
Поначалу это может сбить с толку, если вы не знакомы с результатами в Gleam. Но как только вы их изучите, то увидите, насколько мощная в Gleam обработка ошибок.
На этом этапе я советую изучить о конвейерах и результатах.
Далее вызовем эту функцию из main().
pub fn main() {
case argv.load().arguments {
["add", title] -> add_todo(file_path, title)
_ -> Ok(Nil)
}
}
JavaScript: clear todo
Это самая простая функция добавления. Итак, определим ее.
async function clearTodos(filePath) {
return await useTodoFile(filePath, async (fd) => {
await fd.truncate()
})
}
И используем в main().
async function main() {
const { command, args } = getArgs()
switch (command) {
case 'add':
await addTodo(FILE_PATH, args[0])
break
case 'clear':
await clearTodos(FILE_PATH)
break
}
}
Gleam: очистить todo
Чтобы очистить файл с помощью simplifile, нужно просто вызвать simplifile.write с пустой строкой.
pub fn clear_todos(file_path: String) -> Result(Nil, Nil) {
simplifile.write(file_path, "")
|> result.replace(Nil)
|> result.nil_error
}
Теперь вызовем ее в main().
pub fn main() {
case argv.load().arguments {
["add", title] -> add_todo(file_path, title)
["clear"] -> clear_todos(file_path)
_ -> Ok(Nil)
}
}
JavaScript: перечислить todo
При перечислении задач необходимо отформатировать их, заменив [ ] порядковым номером и перечеркнув задачу, если она выполнена.
Сначала нужно получить каждую строку в файле, отформатировать ее, а затем снова соединить строки и сохранить их в файле.
Вот функция:
async function formattedTodos(filePath) {
return await useTodoFile(filePath, async (fd) => {
const content = await fd.readFile('utf8')
return content
.split('\n')
.filter((title) => title)
.map((title, index) => {
const isDone = /^\[\*\]/.test(title)
const rawTitle = title.match(/^\[[\s*]\]\s(.+)/)?.[1]
return `${index + 1} ${isDone ? strikethrough(rawTitle) : rawTitle}`
})
.join('\n')
})
}
function strikethrough(text) {
return `\x1b[9m${text}\x1b[29m`
}
В данном случае мы используем escape-коды ANSI. Вот почему к тексту добавляются префикс и суффикс некоторых символов, как в коде выше.
Теперь вызовем эту функцию из main().
async function main() {
const { command, args } = getArgs()
switch (command) {
case 'add':
await addTodo(FILE_PATH, args[0])
break
case 'clear':
await clearTodos(FILE_PATH)
break
case 'list':
console.log(await formattedTodos(FILE_PATH))
break
}
}
Gleam: перечислить todo
На Gleam мы собираемся сделать то же самое, но код, на мой взгляд, намного чище.
import gleam/io
import argv
import simplifile
import gleam/string
import gleam/list
import gleam/int
import gleam/result
import gleam_community/ansi
// ...
pub fn formatted_todos(file_path: String) -> Result(String, Nil) {
simplifile.read(file_path)
|> result.map(split_to_lines)
|> result.map(format_todo_lines)
|> result.map(join_lines)
|> result.nil_error
}
fn split_to_lines(content: String) -> List(String) {
string.split(content, "\n")
}
fn join_lines(lines: List(String)) -> String {
string.join(lines, "\n")
}
fn format_todo_lines(lines: List(String)) -> List(String) {
list.index_map(lines, fn(line, index) {
case line {
"[ ] " <> rest -> int.to_string(index + 1) <> " " <> rest
"[*] " <> rest -> int.to_string(index + 1) <> " " <> ansi.strikethrough(rest)
_ -> line
}
})
}
Функция, которую мы собираемся использовать в main(), — formatted_todos. Благодаря конвейерам, сопоставлению с образцом и хорошему синтаксису Gleam мне удалось разделить операции на четкие этапы.
Основная часть работы выполняется в format_todo_line. Она берет список строк в файле, а затем, используя сопоставление с образцом, заменяет [ ] и [*] порядковым номером. Также обратите внимание, как я использовал пакет ansi, чтобы перечеркнуть выполненную задачу.
Теперь вызовем функцию из main().
pub fn main() {
case argv.load().arguments {
["add", title] -> add_todo(file_path, title)
["clear"] -> clear_todos(file_path)
["list"] -> {
case formatted_todos(file_path) {
Ok(todos) -> Ok(io.println(todos))
_ -> Ok(Nil)
}
}
_ -> Ok(Nil)
}
}
Единственное отличие от других команд заключается в том, что нам нужно вывести результат на экран с помощью io.println. Но поскольку Gleam при использовании результатов требует обработки ошибок, чтобы вывести результат, нужно сначала сопоставить Ok.
JavaScript: отметить задачи выполненными
Чтобы пометить задачи как выполненные, нужно реализовать функцию markDone, которая принимает путь к файлу и список идентификаторов, которые нужно пометить как выполненные.
Чтобы пометить задачу как выполненную, нужно просто поместить * в [] перед ее названием.
Чтобы сделать это в коде, нам нужно разбить файл на строки, пройти по ним, обновить только те строки, которые указаны в массиве идентификаторов, а затем сохранить строки обратно в файл.
async function markDone(filePath, ids) {
return await useTodoFile(filePath, async (fd) => {
const content = await fd.readFile('utf8')
const lines = content.split('\n')
ids.forEach((id) => {
lines[id - 1] = lines[id - 1].replace(/^\[\s\]/, '[*]')
})
const updatedContent = lines.join('\n')
await fd.truncate()
await fd.writeFile(updatedContent)
})
}
Строка id здесь означает строку index + 1. Поэтому, чтобы получить строку, мне пришлось вычесть из идентификатора единицу.
Далее вызовем его из main().
async function main() {
const { command, args } = getArgs()
switch (command) {
case 'add':
await addTodo(FILE_PATH, args[0])
break
case 'clear':
await clearTodos(FILE_PATH)
break
case 'list':
console.log(await formattedTodos(FILE_PATH))
break
case 'done':
await markDone(FILE_PATH, args)
break
}
}
Gleam: отметить задачи выполненными
Функция mark_done в Gleam аналогична функции formatted_todos за исключением того, что мы отмечаем задачи как выполненные и сохраняем результат в файл.
pub fn mark_done(file_path: String, ids: List(Int)) -> Result(Nil, Nil) {
simplifile.read(file_path)
|> result.map(split_to_lines)
|> result.map(fn(lines) { mark_todo_done(lines, ids) })
|> result.map(join_lines)
|> result.try(fn(content) { simplifile.write(file_path, content) })
|> result.nil_error
}
fn mark_todo_done(lines: List(String), ids: List(Int)) -> List(String){
list.index_map(lines, fn(line, index) {
case line {
"[ ]" <> rest -> {
case list.contains(ids, index + 1) {
True -> "[*]" <> rest
False -> line
}
}
_ -> line
}
})
}
В mark_todo_done я просматриваю строки, а затем сопоставляю их, чтобы увидеть, начинается ли задача с [ ]. Если да, то проверяю, есть ли строка index + 1 в списке ids. Если да, то отмечаю задачу как выполненную; в противном случае возвращаю строку как есть.
Теперь обновим main(), чтобы использовать функцию.
pub fn main() {
case argv.load().arguments {
["add", title] -> add_todo(file_path, title)
["clear"] -> clear_todos(file_path)
["list"] -> {
case formatted_todos(file_path) {
Ok(todos) -> Ok(io.println(todos))
_ -> Ok(Nil)
}
}
["done", ..ids] -> mark_done(file_path, list.map(ids, fn(id) { result.unwrap(int.parse(id), 0) }))
_ -> Ok(Nil)
}
}
Разберем этот код.
["done", ..ids] -> mark_done(
file_path,
list.map(ids, fn(id) {
result.unwrap(int.parse(id), 0)
}
))
В первой строке мы сопоставляем команду Done и получаем переданные идентификаторы. В этом случае вызываем mark_done и передаем file_path, а затем — список ids после преобразования строк в целые числа.
result.unwrap получает значение в Ok, которое возвращается int.parse(id). Если синтаксический анализ не удался, как значение по умолчанию вернем 0. В реально работающем коде лучше сопоставить случай Error и обработать его. Но для простоты оставим так.
JavaScript: запустить приложение
Чтобы запустить приложение в Node, нужно выполнить node todo.mjs [команда]. Итак, чтобы добавить новую задачу, запустим команду:
node todo.mjs add "first todo"
Затем, чтобы увидеть, как приложение работает:
node todo.mjs list
И так далее.
Gleam: запустить приложение
Чтобы запустить приложение в Gleam, выполните gleam run [команда].
Чтобы добавить новую задачу:
gleam run add "first todo"
И чтобы показать:
gleam run list
И так далее.
Вердикт
Оба языка великолепны. В конце концов мне удалось написать то же приложение, и оно работает так, как ожидалось. Однако из-за красивого и лаконичного синтаксиса, обработки ошибок и простых, но мощных зависимостей мне больше нравилось писать версию на Gleam.
Читайте также:
- 7 ключевых вопросов на собеседовании по JavaScript
- Мой первый опыт работы с языком Gleam
- Пакеты NPM: что это такое, откуда они взялись и когда их использовать
Читайте нас в Telegram, VK и Дзен
Перевод статьи Taha Shashtari: Building the same app in Gleam and JavaScript





