Что мы собираемся собрать?

Чтобы сосредоточиться на самом языке, мы создадим простое приложение 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.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Taha Shashtari: Building the same app in Gleam and JavaScript

Предыдущая статьяRuby: unless против if
Следующая статьяC++: подробное руководство по выводу массива