О чём статья

Эта статья посвящена тому, как я делал библиотеку локализации на Rust. Фокус будет на изменении проекта от минимального решения для себя до полноценной библиотеки.

Термины

В рамках статьи я буду использовать:

  • локализация - перевод

  • локаль - язык

  • выражение - значение (в начале работы &'static str), которое меняется в зависимости от локали

Они введены, чтобы было проще сопоставлять код и его описание.

Как появилась идея

Всё началось с того, что я решил погрузиться в Rust и для практики решил сделать GUI-приложение. В процессе работы я решил добавить поддержку нескольких языков. Для этого я придумал систему, в которой выражения хранятся в виде массивов, а локаль представлена как usize, и в момент локализации происходит простое обращение по индексу.

Решение показалось мне удачным, но я предположил, что подобный подход уже реализован в какой-нибудь библиотеке. Я начал искать и обнаружил, что все рассмотренные решения основаны на JSON, YAML или TOML (из них наиболее близкой по идее оказалась rust-i18n). В итоге я решил оформить свой подход в виде отдельной библиотеки.

Начало работы

Решение, написанное для моего приложения
use lib::system::get_locale;
use std::sync::LazyLock;
use strum::EnumCount;
use strum_macros::EnumCount;

pub type Expression = [&'static str; Locale::COUNT];

#[derive(Clone, EnumCount)]
pub enum Locale {
    RU,
    EN,
    ES,
}

pub static CURRENT_LOCALE: LazyLock<Locale> = LazyLock::new(|| match get_locale().as_str() {
    "ru" => Locale::RU,
    "es" => Locale::ES,
    _ => Locale::EN,
});

macro_rules! localize {
    ($pack: ident::$expression: ident) => {
        lib::locale::$pack::$expression[(*lib::locale::CURRENT_LOCALE).clone() as usize]
    };
}

В этом варианте локаль бралась из системных настроек и не менялась во время выполнения программы, а путь к выражениям был жёстко задан в коде. Для первой версии библиотеки нужно было решить эти две проблемы, а также сделать удобный способ создания enum Locale.

Для этого была придумана архитектура с тремя макросами:

Макрос для инициализации локали
macro_rules! init_locale {
    ($($variant: ident),+) => {
        use core::sync::atomic::{AtomicUsize, Ordering};
        use core::mem::transmute;

        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
        #[repr(usize)]
        pub enum Locale {
            $($variant),+
        }

        impl Locale {
            pub const COUNT: usize = [$(stringify!($variant)),+].len();
        }

        pub type Expression = [&'static str; Locale::COUNT];

        pub static LOCALE: AtomicUsize = AtomicUsize::new(0);

        #[inline]
        pub fn get_locale() -> Locale {
            unsafe {
                transmute(match LOCALE.load(Ordering::Relaxed) {
                    locale if locale < Locale::COUNT => locale,
                    _ => 0,
                })
            }
        }

        #[inline]
        pub fn set_locale(locale: Locale) {
            LOCALE.store(locale as usize, Ordering::Relaxed)
        }
    };
}
Макрос для создания выражения
macro_rules! expression {
    ($name: ident => {$($lang: ident: $expression: expr),+}) => {
        pub static $name: [&str; Locale::COUNT] = {
            let mut expression = [""; Locale::COUNT];

            $(
                expression[Locale::$lang as usize] = $expression;
            )+

            expression
        };
    };
}
Макрос локализации
macro_rules! localize {
    ($expression: path) => {
        $expression[get_locale() as usize]
    };
}

В результате получилось no_std- совместимое решение. По сравнению с исходным вариантом в нём появились следующие изменения:

  • enum Locale теперь repr(usize)

  • Добавлен макрос, которому достаточно передать список локалей - он сгенерирует enum Locale

  • Текущая локаль стала храниться как AtomicUsize, что позволяет безопасно использовать библиотеку в многопоточных приложениях. Преобразование из Locale в usize на этом этапе выполнялось через transmute, для предотвращения неопределённого поведения была добавлена проверка диапазона

  • Добавлен макрос для создания выражений, чтобы не создавать выражения вручную и избежать ошибок в порядке локалей

  • В макрос локализации можно передавать произвольный путь к выражению

  • Полностью убраны зависимости от сторонних crate

В таком виде была опубликована 1.0.0 версия.

Формирование плана по доработке решения

После релиза я опубликовал посты на Reddit, Rust-форуме и в Rust-сервере Discord. Целью было собрать фидбек и понять, какие задачи стоит решать в первую очередь. По итогам обсуждений сформировался следующий список:

  • Возможность передавать локаль в локализацию вручную, например для серверных сценариев. Изначально я не учёл этот кейс, так как в первую очередь ориентировался на приложения

  • Возможность подставлять значения в выражения. Эту функциональность я и так планировал добавить

  • Убрать unsafe код. Я сомневался в необходимости transmute с самого начала, поэтому поставил задачу избавиться от него

  • Возможность создавать выражения пачками, а не по одному. Получив это предложение, я удивился, что не подумал об этом сразу

Также поступали предложения добавить поддержку склонений по родам, числительным и т.д., однако такие задачи не входят в цели библиотеки.

Помимо фидбека, я сформировал собственный список идей для доработки:

  • Locale должен поддерживать все стандартные traits (на тот момент были реализованы почти все, но, например, отсутствовал Display)

  • Преобразование между Locale и usize, Locale и &str должно быть максимально гибким

  • Исключить любые возможные ошибки при создании выражений

  • Добавить поддержку различных способов сериализации и десериализации

  • Добавить возможность задавать подписи вариантам локали

Итоговая библиотека

Инициализация без хранилища локали

init_locale!
($($variant: ident),+ $(,)? $(; [$($derive: path),+])?) => {
    localize_it::init_locale!($($variant => stringify!($variant)),+ $(; [$($derive),+])?);
};

($($variant: ident => $label: expr),+ $(,)? $(; [$($derive: path),+])?) => {
    #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash $(,$($derive),+)?)]
    #[repr(usize)]
    pub enum Locale {
        #[default]
        $($variant),+
    }

    impl Locale {
        pub const COUNT: usize = [$(stringify!($variant)),+].len();
        pub const VARIANTS: [Self; Self::COUNT] = [$(Self::$variant),+];
        pub const LABELS: [&'static str; Self::COUNT] = [$($label),+];
        localize_it::__init_locale_default_const!($($variant),+);

        #[inline]
        pub fn iter() -> impl Iterator<Item = (Self, &'static str)> {
            Self::iter_variants().zip(Self::iter_labels())
        }

        #[inline]
        pub fn iter_variants() -> impl Iterator<Item = Self> {
            Self::VARIANTS.into_iter()
        }

        #[inline]
        pub fn iter_labels() -> impl Iterator<Item = &'static str> {
            Self::LABELS.into_iter()
        }

        #[inline]
        pub const fn to_usize(self) -> usize {
            self as usize
        }

        #[inline]
        pub const fn from_usize(value: usize) -> Option<Self> {
            match value {
                $(
                    _ if value == Self::$variant.to_usize() => Some(Self::$variant),
                )+
                _ => None,
            }
        }

        #[inline]
        pub fn from_usize_or_default(value: usize) -> Self {
            Self::from_usize(value).unwrap_or_default()
        }

        #[inline]
        pub const fn to_str(self) -> &'static str {
            match self {
                $(
                    Self::$variant => stringify!($variant),
                )+
            }
        }

        #[inline]
        pub fn from_str(str: &str) -> Option<Self> {
            match str {
                $(
                    stringify!($variant) => Some(Self::$variant),
                )+
                _ => None,
            }
        }

        #[inline]
        pub fn from_str_or_default(str: &str) -> Self {
            Self::from_str(str).unwrap_or_default()
        }

        #[inline]
        pub const fn from_caseless_str(str: &str) -> Option<Self> {
            match str {
                $(
                    _ if str.eq_ignore_ascii_case(stringify!($variant)) => Some(Self::$variant),
                )+
                _ => None,
            }
        }

        #[inline]
        pub fn from_caseless_str_or_default(str: &str) -> Self {
            Self::from_caseless_str(str).unwrap_or_default()
        }
    }

    impl core::fmt::Display for Locale {
        #[inline]
        fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
            formatter.write_str(self.to_str())
        }
    }

    impl From<Locale> for usize {
        #[inline]
        fn from(locale: Locale) -> Self {
            locale.to_usize()
        }
    }

    impl TryFrom<usize> for Locale {
        type Error = &'static str;

        #[inline]
        fn try_from(value: usize) -> Result<Self, Self::Error> {
            Self::from_usize(value).ok_or("Invalid numeric value for Locale")
        }
    }

    impl From<Locale> for &str {
        #[inline]
        fn from(locale: Locale) -> Self {
            locale.to_str()
        }
    }

    impl core::str::FromStr for Locale {
        type Err = &'static str;

        #[inline]
        fn from_str(str: &str) -> Result<Self, Self::Err> {
            Self::from_str(str).ok_or("Invalid locale identifier")
        }
    }

    impl TryFrom<&str> for Locale {
        type Error = &'static str;

        #[inline]
        fn try_from(str: &str) -> Result<Self, Self::Error> {
            Self::from_str(str).ok_or("Invalid locale identifier")
        }
    }
};

По функционалу в нём появились следующие возможности:

  • Возможность указывать подписи локалей с тем же синтаксисом, что и при создании выражений. Эта функциональность опциональна и не требует писать лишний код, если подписи не нужны

  • Возможность передавать свои derive, например для поддержки serde

  • Реализации traits Default (первый вариант локали), Display и FromStr

  • Константы для доступа к вариантам, их подписям, default-локали. Default::default недоступен в compile-time, константа решает эту проблему. Она создаётся через вспомогательный макрос, который выбирает первую локаль из списка всех локалей

  • Реализованы итераторы по вариантам локалей, подписям, а также по обоим массивам одновременно (например, для отображения списка локалей пользователю)

  • Все возможные варианты для преобразования между usize и Locale, а также &str и Locale включая варианты с игнорированием регистра

  • Полностью убран unsafe - оказалось, что transmute и match в данном случае компилируются в идентичный код, поэтому использование unsafe не имеет смысла (проверял через Compiler Explorer)

Вариант с хранилищем локали

init_locale_with_storage!
($($variant: ident),+ $(,)? $(; [$($derive: path),+])?) => {
    localize_it::init_locale_with_storage!($($variant => stringify!($variant)),+ $(; [$($derive),+])?);
};

($($variant: ident => $label: expr),+ $(,)? $(; [$($derive: path),+])?) => {
    localize_it::init_locale!($($variant => $label),+ $(; [$($derive),+])?);

    mod storage {
        use super::Locale;
        use core::sync::atomic::{AtomicUsize, Ordering};

        static CURRENT_LOCALE: AtomicUsize = AtomicUsize::new(Locale::DEFAULT.to_usize());

        #[inline]
        pub fn get_locale() -> Locale {
            Locale::from_usize_or_default(CURRENT_LOCALE.load(Ordering::Relaxed))
        }

        #[inline]
        pub fn set_locale(locale: Locale) {
            CURRENT_LOCALE.store(locale.to_usize(), Ordering::Relaxed)
        }

        #[inline]
        pub fn get_locale_as_usize() -> usize {
            get_locale().to_usize()
        }

        #[inline]
        pub fn set_locale_from_usize(value: usize) {
            set_locale(Locale::from_usize_or_default(value))
        }

        #[inline]
        pub fn get_locale_as_str() -> &'static str {
            get_locale().to_str()
        }

        #[inline]
        pub fn set_locale_from_str(str: &str) {
            set_locale(Locale::from_str_or_default(str))
        }

        #[inline]
        pub fn set_locale_from_caseless_str(str: &str) {
            set_locale(Locale::from_caseless_str_or_default(str))
        }
    }

    pub use storage::*;
}

Он также создаёт Locale через описанный выше макрос и предоставляет функции для установки и получения текущей локали. Отличия хранилища минимальны:

  • Переменная-хранилище изолирована от прямого доступа и может использоваться только через предоставленные функции

  • Добавлена возможность устанавливать и получать текущую локаль как usize или &str

Создание выражений

Добавлен макрос expressions!, который позволяет создавать выражения пачками, не вызывая expression! для каждого из них. Я рассматривал вариант оставить только его, однако в итоге решил сохранить оба макроса: один - для одиночных выражений, второй - для массового объявления.

expression!
(
    $name: ident => {
        $(
            $lang: ident: $expression: expr
        ),+ $(,)?
    }
) => {
    localize_it::expression!($name: &'static str => {$($lang: $expression),+});
};

(
    $name: ident: $content_type: ty => {
        $(
            $lang: ident: $content: expr
        ),+ $(,)?
    }
) => {
    pub static $name: [$content_type; Locale::COUNT] = {
        let mut expression = [$($content),+];
        let mut empty = [true; Locale::COUNT];

        $(
            let i = Locale::$lang.to_usize();

            if empty[i] {
                expression[i] = $content;
                empty[i] = false;
            } else {
                panic!(concat!(
                    "Initialize Error: Locale variant ",
                    stringify!($lang),
                    " is duplicated"
                ));
            }
        )+

        expression
    };
};

В создании выражений наиболее важные изменения:

  • Теперь можно указывать тип значения, хранящегося в выражении. Это может быть любой compile-time тип, что позволяет не только форматировать строки, но и добавлять пользовательскую логику. При этом тип указывать необязательно - если выражение имеет тип &'static str, то можно пропускать его объявление

  • Если указана несуществующая локаль, локаль отсутствует или дублируется, это приводит к ошибке на этапе компиляции

Решение последнего пункта потребовало отдельного внимания. В предыдущей версии массив выражений инициализировался пустыми строками, но при поддержке произвольных типов такой подход невозможен: даже Default не подходит, так как, например, у функций его нет. Первой идеей было создать массив через std::mem::MaybeUninit::uninit().assume_init() и далее заполнить его. Поскольку присутствовали проверки неинициализированная память инициализировалась. Однако использование unsafe хотелось избежать. В итоге я пришёл к более простому и надёжному решению - создавать массив непосредственно из переданных элементов в том порядке, в котором они указаны, а после переопределять их в нужном порядке. Такой подход позволил убрать отдельную проверку пропуска вариантов локали, оставив только проверку на дубли, объединённую с установкой элементов в нужном порядке.

Локализация

Локализация по-прежнему представляет собой простейшую операцию взятия по индексу, однако теперь добавлена возможность вызывать выражения как функции.

localize!
($expression: path $(as ($($arg: expr),* $(,)?))?) => {
    $expression[get_locale().to_usize()]$(($($arg),*))?
};

($expression: path $(as ($($arg: expr),* $(,)?))?, $locale: expr) => {
    $expression[$locale.to_usize()]$(($($arg),*))?
};

Изначально я хотел использовать синтаксис localize!(EXPRESSION(arg1, arg2)), но в макросах Rust после path параметра нельзя использовать скобки. В итоге был выбран вариант localize!(EXPRESSION as (arg1, arg2)). Использование as здесь логично по смыслу поскольку выражение используется как вызываемое.

Итог

Таким образом получилась гибкая и быстрая система локализации, подходящая как для использования на сервере, так и для GUI-приложений. Она работает в no_std, не требует аллокаций во время выполнения, не имеет зависимостей и выполняет все проверки на этапе компиляции.

Пример использования из README к библиотеке:

use localize_it::{expressions, init_locale_with_storage, localize};
use std::io::{stdin, stdout, Write};

// Определение доступных локалей
init_locale_with_storage!(EN, RU);

// Объявление простых выражений и выражений-функций
expressions!(
    ENTER_LANGUAGE => {
        EN: "Enter EN or RU: ",
        RU: "Введите EN или RU: ",
    },
    ENTER_YOU_NAME => {
        EN: "Please, enter your name: ",
        RU: "Пожалуйста, введите ваше имя: ",
    },
    HELLO: fn (&str) -> String => {
        EN: |name: &str| format!("Hello, {name}!"),
        RU: |name: &str| format!("Привет, {name}!"),
    },
);

// Вспомогательная функция упрощающая ввод
fn input() -> String {
    let mut temp = String::new();

    stdout().flush().unwrap();
    stdin().read_line(&mut temp).unwrap();
    temp.trim().to_string()
}

fn main() {
    // Ручная установка локали
    print!("{}", localize!(ENTER_LANGUAGE, Locale::EN));

    let lang = input();

    // Установка текущей локали
    set_locale(Locale::from_str_caseless_or_default(&lang));

    // Автоматическое использование локали
    print!("{}", localize!(ENTER_YOU_NAME));

    let name = input();

    // Вызов выражения-функции
    println!("{}", localize!(HELLO as (&name)));
}

Дальнейшие планы

  • Сделать возможность разделять объявление выражений по принципу 1 файл - 1 язык

  • Добавить поддержку создания локалей с JSON, YAML, TOML - для упрощения интеграции с существующими решениями

Ссылки: