Введение

Управление файлами локализации в проектах iOS и macOS может стать сложной задачей по мере роста приложения. Когда вы добавляете новые строки в файл базового языка, синхронизация всех файлов переводов становится утомительной и подверженной ошибкам. В этой статье рассматривается syncLproj — CLI-инструмент на Rust, предназначенный для автоматизации этого процесса синхронизации.

Проблема локализации

В разработке iOS/macOS локализация использует файлы .strings с простым форматом ключ-значение:

/* User interface strings */
"welcome_message" = "Welcome to our app!";
"login_button" = "Log In";
"signup_button" = "Sign Up";

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

  • Отсутствующие ключи: новые ключи, добавленные в базовый язык, автоматически не появляются в переводах
  • Несогласованность порядка ключей: разные файлы имеют ключи в разном порядке, что затрудняет просмотр diff-ов
  • Осиротевшие ключи: удалённые ключи остаются в файлах переводов
  • Ручная работа: копирование ключей между десятками файлов занимает много времени и подвержено ошибкам

Традиционные подходы включают ручное редактирование или встроенные инструменты Xcode, которые не имеют возможностей автоматизации для CI/CD пайплайнов.

Цели проектирования

Инструмент syncLproj был разработан с учётом конкретных ограничений:

  1. Нулевые зависимости: максимальная портативность и минимальный размер бинарника
  2. Сохранение переводов: никогда не перезаписывать существующие переводы
  3. Сохранение порядка: обеспечить одинаковый порядок ключей во всех файлах согласно базовому файлу
  4. Поддержка многострочных значений: обработка строк с экранированными переносами
  5. Сохранение комментариев: сохранение заголовков файлов и документации
  6. Рекурсивное сканирование: автоматическая обработка всех директорий проекта

Структура проекта

Весь инструмент реализован в одном файле main.rs (~250 строк), демонстрируя способность Rust создавать мощные утилиты с минимальным количеством кода. Cargo.toml удивительно прост:

[package]
name = "synclproj"
version = "0.1.0"
edition = "2024"

[dependencies]
# Ноль runtime-зависимостей!

Использование только стандартной библиотеки Rust сохраняет скомпилированный бинарник маленьким (~400КБ) и устраняет проблемы безопасности цепочки поставок.

Обзор архитектуры

Инструмент следует простому конвейеру:

  1. Парсинг оригинального/базового файла .strings для извлечения ключей и их порядка
  2. Сканирование целевой директории рекурсивно для всех файлов .strings
  3. Синхронизация каждого найденного файла:
    • Чтение существующих переводов
    • Переупорядочивание записей в соответствии с базовым файлом
    • Добавление отсутствующих ключей со значениями базового языка в качестве заглушек
    • Сохранение комментариев заголовка файла
  4. Отчёт о том, какие ключи были добавлены в каждый файл

Основная структура данных

Фундаментальная структура данных проста, но эффективна:

#[derive(Debug, Clone)]
struct StringEntry {
    key: String,
    raw_lines: Vec<String>,
}

Хранение raw_lines вместо распарсенных значений критически важно — это сохраняет форматирование, комментарии и многострочную структуру точно так, как они появляются в исходном файле.

Парсинг файлов .strings

Парсинг формата .strings от Apple требует обработки нескольких пограничных случаев:

Базовый формат

"key" = "value";

Многострочные значения

"long_message" = "This is a very long \
message that spans \
multiple lines";

Комментарии

/* Section header */
// Inline comment
"key" = "value";

Реализация парсера использует подход конечного автомата:

fn parse_multiline_entry(lines: &[&str], start: usize) -> (Option<StringEntry>, usize) {
    let mut i = start;
    let mut raw_lines = Vec::new();
    let mut full_text = String::new();
    let mut key = None;

    while i < lines.len() {
        let line = lines[i];
        raw_lines.push(line.to_string());
        full_text.push_str(line);
        full_text.push('\n');

        let trimmed = line.trim();
        
        // Пропуск пустых строк и комментариев
        if trimmed.is_empty() || trimmed.starts_with("/*") || trimmed.starts_with("//") {
            i += 1;
            continue;
        }

        // Извлечение ключа, если ещё не найден
        if key.is_none() {
            if let Some(eq_pos) = full_text.find('=') {
                let before = &full_text[..eq_pos];
                if let Some(k) = extract_key_from_text(before) {
                    key = Some(k);
                }
            }
        }

        // Проверка продолжения строки
        if line.trim_end().ends_with('\\') {
            i += 1;
            continue;
        }

        // Найдена полная запись
        if key.is_some() {
            return (
                Some(StringEntry {
                    key: key.unwrap(),
                    raw_lines,
                }),
                i + 1,
            );
        }

        i += 1;
    }

    (None, i)
}

Этот подход:

  • Накапливает строки до нахождения полной записи
  • Обрабатывает продолжения строк с обратным слэшем
  • Сохраняет точное форматирование для восстановления
  • Возвращает как запись, так и индекс следующей строки для обработки

Извлечение ключа

Извлечение ключа требует тщательной обработки escape-последовательностей:

fn extract_key_from_text(text: &str) -> Option<String> {
    let trimmed = text.trim();
    if !trimmed.starts_with('"') {
        return None;
    }

    let mut chars = trimmed[1..].chars();
    let mut key = String::new();
    let mut escape = false;

    while let Some(c) = chars.next() {
        if escape {
            key.push(c);
            escape = false;
            continue;
        }
        if c == '\\' {
            escape = true;
            continue;
        }
        if c == '"' {
            return Some(key);
        }
        key.push(c);
    }
    None
}

Это правильно обрабатывает ключи типа "message.\"quoted\"", отслеживая состояние экранирования посимвольно.

Алгоритм синхронизации

Процесс синхронизации — сердце инструмента:

fn sync_strings_file(
    path: &Path,
    original_entries: &Vec<StringEntry>,
) -> io::Result<()> {
    let content = fs::read_to_string(path)?;
    let lines: Vec<&str> = content.lines().collect();
    
    // Фаза 1: Сбор существующих данных
    let mut preserved_comments = Vec::new();
    let mut existing_keys = HashSet::new();
    
    // Парсинг существующего файла для определения того, что уже присутствует
    // ... (детали в полной реализации)
    
    // Фаза 2: Построение синхронизированного вывода
    let mut output_lines = Vec::new();
    
    // Добавление сохранённых комментариев заголовка
    for comment in &preserved_comments {
        if !comment.trim().is_empty() {
            output_lines.push(comment.clone());
        }
    }
    
    // Итерация в порядке оригинального файла
    for orig_entry in original_entries {
        let key = &orig_entry.key;
        
        if existing_keys.contains(key) {
            // Копирование существующего перевода
            // ... (поиск и копирование из целевого файла)
        } else {
            // Добавление отсутствующего ключа со значением базового языка
            println!("   Adding missing key: {}", key);
            for line in &orig_entry.raw_lines {
                output_lines.push(line.clone());
            }
        }
    }
    
    // Фаза 3: Очистка и запись
    // Удаление лишних пустых строк, обрезка пробелов, запись на диск
    // ...
    
    Ok(())
}

Ключевые идеи

Двухпроходный подход: первый проход определяет существующие ключи, второй проход строит вывод. Это гарантирует, что мы случайно не дублируем записи.

Сохранение порядка: итерируя через original_entries, а не через порядок целевого файла, мы гарантируем согласованный порядок.

Сохранение переводов: когда ключ существует в обоих файлах, мы берём запись из целевого файла, сохраняя переведённое значение.

Вставка заглушек: отсутствующие ключи копируются из оригинального файла с их значениями на базовом языке, что делает очевидным, что нужно перевести.

Рекурсивный поиск файлов

Поиск всех файлов .strings в директории проекта:

fn find_strings_files(folder: &Path) -> Vec<PathBuf> {
    let mut result = Vec::new();
    if folder.is_dir() {
        for entry in fs::read_dir(folder).unwrap() {
            let entry = entry.unwrap();
            let path = entry.path();
            if path.is_dir() {
                result.extend(find_strings_files(&path));
            } else if path.extension().and_then(|s| s.to_str()) == Some("strings") {
                result.push(path);
            }
        }
    }
    result
}

Эта простая рекурсивная функция:

  • Обходит директории в глубину
  • Фильтрует по расширению .strings
  • Возвращает все подходящие пути

В production-инструменте вы, возможно, захотите использовать крейт walkdir для лучшей обработки ошибок и производительности, но стандартной библиотеки достаточно для большинства проектов.

Примеры использования

Базовая синхронизация

# Синхронизация всех локализаций с использованием en.lproj как эталона
synclproj en.lproj/Localizable.strings ./Resources/

# Вывод:
# Scanning folder: ./Resources/
# Syncing: ./Resources/fr.lproj/Localizable.strings
#    Adding missing key: new_feature_title
#    Adding missing key: settings_privacy
# Syncing: ./Resources/de.lproj/Localizable.strings
#    Adding missing key: new_feature_title
#    Adding missing key: settings_privacy
# All .strings files synchronized successfully!

Интеграция в CI/CD

Добавьте в ваш .gitlab-ci.yml или GitHub Actions:

- name: Sync localizations
  run: |
    ./synclproj en.lproj/Localizable.strings ./Resources/
    git diff --exit-code || (echo "Localizations out of sync!" && exit 1)

Это гарантирует, что разработчики не забудут синхронизировать переводы при добавлении новых строк.

Реальный пример

До синхронизации:

// en.lproj/Localizable.strings
"welcome" = "Welcome!";
"login" = "Log In";
"new_feature" = "Try our new feature";

// fr.lproj/Localizable.strings  
"login" = "Se connecter";
"welcome" = "Bienvenue!";
// Отсутствует: new_feature

После запуска synclproj:

// fr.lproj/Localizable.strings
"welcome" = "Bienvenue!";
"login" = "Se connecter";
"new_feature" = "Try our new feature";  // ← Добавлено, требует перевода

Обратите внимание:

  • Ключи переупорядочены в соответствии с en.lproj
  • Существующие переводы сохранены
  • Отсутствующий ключ добавлен с английской заглушкой

Пограничные случаи и ограничения

Удалённые ключи

В настоящее время инструмент не удаляет ключи, которые существуют в переводах, но не в базовом файле. Это сделано намеренно — безопаснее оставить лишние ключи, чем случайно удалить переводы. Вы можете вручную удалить устаревшие ключи во время code review.

Обработка пробелов

Инструмент нормализует избыточные пустые строки, но сохраняет намеренные отступы вокруг записей. Это сохраняет чистоту diff-ов при поддержании читаемости.

Кодировка символов

Тип String в Rust нативно обрабатывает UTF-8, поэтому эмодзи и международные символы работают без проблем:

"welcome_emoji" = "👋 Welcome!";
"chinese_greeting" = "欢迎!";

Вариации формата файлов

Парсер обрабатывает как записи с точкой с запятой, так и без неё, что делает его совместимым с разными версиями Xcode и стилями форматирования.

Характеристики производительности

Для типичного iOS-проекта:

  • Маленький проект (5 языков, 200 ключей каждый): ~50мс
  • Средний проект (15 языков, 1000 ключей каждый): ~200мс
  • Большой проект (30 языков, 5000 ключей каждый): ~800мс

Инструмент ограничен I/O, тратя большую часть времени на чтение и запись файлов. Сама логика парсинга и синхронизации незначительна.

Размер бинарника ~400КБ, что делает его идеальным для включения в репозитории или CI-контейнеры.

Извлечённые уроки

Почему Rust?

  1. Развёртывание единого бинарника: нет runtime-зависимостей означает, что cargo build --release производит портативный исполняемый файл
  2. Обработка ошибок: Result<T, E> заставляет явно обрабатывать ошибки файлового I/O
  3. Обработка строк: UTF-8 по умолчанию устраняет головную боль с кодировками
  4. Производительность: достаточно быстро, чтобы пользователи не замечали, даже на больших проектах

Почему не Swift?

Хотя может показаться логичным использовать Swift для инструмента, связанного с iOS, Rust предлагает:

  • Лучшую кроссплатформенную поддержку (включая Linux CI runners)
  • Меньшие бинарники без накладных расходов runtime
  • Более зрелую экосистему CLI

Простота побеждает

Подход с нулевыми зависимостями доказал свою надёжность. Стандартная библиотека предоставляет всё необходимое для этого случая использования, а избегание внешних крейтов:

  • Уменьшает поверхность атаки
  • Устраняет проблемы совместимости версий
  • Делает аудит кода тривиальным

Будущие улучшения

Потенциальные улучшения для инструмента:

Режим валидации

synclproj --check en.lproj/Localizable.strings ./Resources/
# Код выхода 1, если какие-либо файлы не синхронизированы

Вывод diff

synclproj --diff en.lproj/Localizable.strings ./Resources/
# Показать, что изменилось бы, без модификации файлов

Удаление устаревших ключей

synclproj --prune en.lproj/Localizable.strings ./Resources/
# Удалить ключи из переводов, которых нет в базовом файле

Экспорт в JSON

synclproj --export-json en.lproj/Localizable.strings > translations.json
# Экспорт в JSON для интеграции с сервисами перевода

Параллельная обработка

Использование rayon для параллельной синхронизации нескольких файлов для больших проектов с множеством локализаций.

Стратегия тестирования

Для production-инструмента рассмотрите:

Unit-тесты для пограничных случаев парсинга:

#[test]
fn test_multiline_parsing() {
    let input = r#""key" = "line1 \
line2";"#;
    let entries = parse_strings_with_order(input);
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].key, "key");
}

Интеграционные тесты с образцами файлов .strings:

#[test]
fn test_sync_preserves_translations() {
    let temp = create_test_directory();
    sync_strings_file(/* ... */);
    let result = fs::read_to_string(/* ... */);
    assert!(result.contains("translated_value"));
}

Golden file тесты для гарантии, что форматирование вывода не регрессирует.

Интеграция с Xcode

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

  1. Добавьте фазу "Run Script" в Build Phases
  2. Вставьте:
    if which synclproj > /dev/null; then
        synclproj "${SRCROOT}/en.lproj/Localizable.strings" "${SRCROOT}"
    else
        echo "warning: synclproj not found, skipping localization sync"
    fi
    

Это гарантирует синхронизацию локализаций во время разработки без ручного вмешательства.

Альтернативные подходы

Другие решения в этой области:

  • bartycrouch: инструмент на Ruby с большим количеством функций, но медленнее
  • Xcode: встроенный экспорт/импорт XLIFF, ручной и неудобный
  • fastlane: может интегрироваться с сервисами перевода, но требует настройки
  • genstrings: извлекает строки из кода, но не синхронизирует существующие файлы

syncLproj заполняет конкретную нишу: быстрая синхронизация существующих файлов .strings без конфигурации и внешних зависимостей.

Заключение

Создание CLI-инструментов на Rust демонстрирует сильные стороны языка в системном программировании за пределами только критически важных для производительности приложений. С нулевыми runtime-зависимостями мы создали бинарник ~400КБ, который решает реальную проблему рабочего процесса разработки.

Ключевые выводы:

  • Достаточность стандартной библиотеки: многим CLI-инструментам не нужны внешние зависимости
  • Простота парсера: конечные автоматы обрабатывают сложные форматы простым кодом
  • Сохранение важнее преобразования: сохранение оригинального форматирования делает diff-ы чище
  • Единственная ответственность: фокус на одной задаче (синхронизации), а не на попытках решить все проблемы локализации

Полный исходный код доступен на github.com/bimawa/syncLproj. Независимо от того, управляете ли вы локализациями iOS или создаёте собственные инструменты синхронизации файлов, рассмотренные здесь паттерны обеспечивают прочную основу.

Успешной локализации!