DSL на Rust: пишем свой язык для реальных задач

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Зачем Rust-разработчику свой язык? Погружение в мир DSL

Представьте, что вы пишете парсер сложного конфигурационного файла, реализуете игровую логику или описываете бизнес-правила. Каждый раз вы упираетесь в одно и то же: код становится громоздким, а читаемость падает. Что если бы вы могли создать мини-язык, специально заточенный под вашу задачу? Это и есть предметно-ориентированный язык (DSL на Rust).

Rust — идеальная среда для создания DSL. Его система макросов, мощный вывод типов и нулевая стоимость абстракций позволяют строить языки, которые не уступают по производительности рукописному коду. В этой статье мы разберем, как превратить Rust в конструктор собственных языков, и покажем, почему это может быть полезно именно вам.

Что такое DSL и зачем он нужен в Rust?

DSL (Domain-Specific Language) — это язык, созданный для решения задач в конкретной предметной области. В отличие от общего языка программирования, DSL жертвует универсальностью ради выразительности в узкой нише. В экосистеме Rust существуют два основных подхода:

  • Внутренние (embedded) DSL — строятся поверх синтаксиса Rust с помощью макросов и цепочек методов. Пример: библиотека clap для парсинга аргументов командной строки.
  • Внешние (external) DSL — отдельный синтаксис, который парсится в рантайме. Пример: regex или sqlx.

Разработка DSL на Rust особенно актуальна для:

  • Конфигурационных файлов (YAML/TOML как внешние DSL)
  • Игровых движков (описание поведения NPC)
  • Фреймворков для тестирования (свой синтаксис для сценариев)
  • Балансировки между безопасностью и гибкостью

Создаем внутренний DSL на Rust: макросы и цепочки

Начнем с простого — внутреннего DSL для описания HTTP-запросов. Вместо того чтобы писать:

let mut req = Request::new(Method::GET);
req.set_url("https://api.example.com/data");
req.add_header("Authorization", "Bearer token");
req.add_header("Content-Type", "application/json");

Мы хотим получить лаконичный синтаксис:

let req = request! {
    GET "https://api.example.com/data"
    headers {
        "Authorization": "Bearer token",
        "Content-Type": "application/json"
    }
};

Реализуем это с помощью макросов Rust — самого мощного инструмента для создания внутренних DSL:

macro_rules! request {
    ($method:ident $url:expr) => {
        Request::new(stringify!($method).parse().unwrap(), $url)
    };
    ($method:ident $url:expr, headers { $($key:expr => $value:expr),* $(,)? }) => {
        {
            let mut req = Request::new(stringify!($method).parse().unwrap(), $url);
            $(
                req.add_header($key, $value);
            )*
            req
        }
    };
}

Этот подход — классический пример предметно-ориентированного языка на Rust: мы не создаем новый синтаксис, а переопределяем существующий, делая код более читаемым. Обратите внимание: макрос генерирует обычный код Rust на этапе компиляции, поэтому ошибки типов отлавливаются до запуска.

Продвинутый внутренний DSL: строители (Builder pattern)

Макросы — не единственный способ. Часто для создания DSL на Rust используют паттерн Builder с цепочками методов. Рассмотрим пример DSL для SQL-запросов:

let query = Query::select()
    .from("users")
    .where_clause("age > ?")
    .bind(18)
    .order_by("name")
    .build();

Реализация такого DSL проста и элегантна:

struct QueryBuilder {
    select: Vec<String>,
    from: String,
    condition: Option<String>,
    order: Option<String>,
}

impl QueryBuilder {
    fn select() -> Self {
        QueryBuilder {
            select: vec!["*".to_string()],
            from: String::new(),
            condition: None,
            order: None,
        }
    }

    fn from(mut self, table: &str) -> Self {
        self.from = table.to_string();
        self
    }

    fn where_clause(mut self, clause: &str) -> Self {
        self.condition = Some(clause.to_string());
        self
    }

    fn build(self) -> String {
        let mut sql = format!("SELECT {} FROM {}", self.select.join(", "), self.from);
        if let Some(cond) = self.condition {
            sql.push_str(&format!(" WHERE {}", cond));
        }
        sql
    }
}

Этот DSL на Rust использует move-семантику и гарантирует, что все необходимые поля будут заполнены. Типизированный Builder — отличный пример, как язык помогает создавать безопасные API.

Внешние DSL на Rust: комбинаторы парсеров с nom

Когда внутреннего DSL недостаточно (например, нужен свой формат файлов), в игру вступают внешние DSL. Для их реализации в Rust существует замечательная библиотека nom — набор комбинаторов парсеров.

Представьте, что мы хотим создать язык для описания маршрутов веб-приложения:

route "/users/:id" -> get_user
route "/posts" -> list_posts

Парсер на nom будет выглядеть так:

use nom::{
    bytes::complete::{tag, take_until},
    sequence::{preceded, separated_pair},
    IResult,
};

#[derive(Debug)]
struct Route {
    path: String,
    handler: String,
}

fn parse_route(input: &str) -> IResult<&str, Route> {
    let (input, _) = tag("route ")(input)?;
    let (input, path) = take_until(" -> ")(input)?;
    let (input, _) = tag(" -> ")(input)?;
    let (input, handler) = take_until("\n")(input)?;

    Ok((input, Route {
        path: path.to_string(),
        handler: handler.trim().to_string(),
    }))
}

Благодаря nom создание DSL на Rust становится похожим на сборку конструктора: вы комбинируете маленькие парсеры в большие, и каждый из них возвращает IResult — либо успех с остатком строки, либо ошибку с указанием позиции. Это делает разработку DSL не только мощной, но и безопасной.

Почему nom, а не regex?

Регулярные выражения часто используют для парсинга, но они плохо подходят для сложных DSL: их трудно отлаживать, и они медленны. Комбинаторы парсеров, такие как nom, предлагают:

  • Композируемость: маленькие парсеры легко объединяются
  • Читаемые ошибки: nom указывает точное место сбоя
  • Производительность: zero-cost abstractions Rust

DSL на Rust в реальных проектах

Чтобы вдохновить вас, приведу несколько примеров, где предмет

Блоги

Рекомендаций по книгам