В пятницу утром, расслабившись, вы думаете о новых шоу Netflix, которые стоит посмотреть. Подходит ваш начальник, просит вас написать парсер для Systemd unit file. Он ей понадобится к понедельнику.

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

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

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

ПЭГ на помощь

Пока не теряй надежды. Позвольте представить вам Parse Expression Grammer (PEG), простой способ разобраться с синтаксическими анализаторами и сэкономить ваши ценные выходные.

PEG - это удобочитаемый способ написания правил синтаксиса и очень похож на регулярные выражения, который отличается от аналога Контекстно-свободной грамматики, такого как Форма Бэкуса-Наура (BNF), в которой выражения должны быть сокращены до более мелких символов. Извините, Ноам Хомский, может быть, еще какие-то рабочие дни.

Я буду использовать библиотеку синтаксического анализа Rust PEG под названием Pest, что довольно круто. Если вы еще этого не сделали, я подожду, пока вы пойдете установить Rust.

Начиная

Давайте начнем с простой грамматики для синтаксического анализа оператора объявления переменной в стиле JavaScript. Мы начнем с обдумывания набора правил для действительных входных данных.

Заявление о декларации:

  • начинается с ключевого слова var, за которым следует один или несколько идентификаторов.
  • нечувствителен к пробелам
  • заканчивается точкой с запятой (;)

Группа идентификаторов:

  • предшествует var ключевое слово
  • разделяется запятыми
  • нечувствителен к пробелам

Идентификатор:

  • может содержать любое количество цифр, символов и подчеркиваний как в нижнем, так и в верхнем регистрах
  • не может начинаться с цифры
  • не может содержать пробелов

Идентификатор - это термин, что означает, что это нерушимая часть токена. То же самое с ключевыми словами var и точками с запятой.

Вернитесь немного назад в сторону BNF, используя Расширенная грамматика Бэкуса-Наура (EBNF), вы можете формально определить указанную выше грамматику следующим образом:

<alpha>  := 'a' | 'b' | 'c' | 'd' | 'e' | /* ... */ 'z'
         |  'A' | 'B' | 'C' | 'D' | 'E' | /* ... */ 'Z'
<digit>  := 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
<Decl>   := 'var' <Idents> '\n'? ';'
<Idents> := <Ident> ('\n'? ',' <Ident>)*
<Ident>  := <alpha>+ (<alpha> | <digit> | '_')*

Имя правила в верхнем регистре представляет собой неограничивающий символ (т.е. может быть разбито на более мелкие члены). Имя в нижнем регистре представляет термин.

Для краткости мы не используем неявные пробелы в правилах. По сути, между всеми символами, которые вы видите, может существовать один или несколько пробелов.

Давайте вникнем в это! Правила <alpha> и <digit> говорят сами за себя, поэтому мы оставим это на ваше усмотрение.

<Decl> - наиболее сложное правило для оператора объявления, содержащего ключевое слово var, символ <Idents>, необязательную новую строку и заканчивающуюся точкой с запятой. По сути, это правило гласит: 'Любой ввод строки, начинающийся с var, за которым следует один или несколько пробелов, затем подправило <Idents>, за которым следует один или несколько пробелов и, наконец, заканчивающееся одной точкой с запятой, является допустимым, и я с радостью продолжу их.'

<Idents> может быть одним символом <Ident>, за которым следует ноль или несколько пар запятой и <Ident>.

Наконец, <Ident> должен начинаться с одного или нескольких символов, за которыми следует ноль или несколько символов, цифр или подчеркивания.

Хорошо, код, пожалуйста

Держись, юный Анакин! Вы вспыльчивы. И вот, определив грамматику, мы сможем проанализировать эти утверждения:

var foo, bar, baz;
var   foo_1, baRamYu,baz99;
var foo_x
  , baroo
  , bazoo
  ;

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

var 99Problems

Кто-нибудь хочет угадать, что здесь не так? ✋ Оставьте свой ответ в комментарии. (psss, если ваш ответ правильный, я буду следить за вами + 3 лайка к вашему посту 👍)

Введите ржавчину и вредитель

Хорошо, я надеюсь, что у вас уже установлен Rust (в вашем терминале попробуйте набрать which cargo и посмотреть, появится ли он). Начните с создания нового бинарного проекта Rust с

$ cargo new --bin maybe-js; cd maybe-js

В папке проекта откройте файл Cargo.toml и добавьте следующие элементы в dependencies и запустите cargo update, чтобы установить их.

[dependencies]
pest = "2.0"
pest_derive = "2.0"

Как только это будет сделано, cd в src, создайте файл с именем grammar.pest и вставьте в него следующее:

alpha = { 'a'..'z' | 'A'..'Z' }
digit = { '0'..'9' }
underscore = { "_" }
newline = _{ "\n" | "\r" }
WHITESPACE = _{ " " }
declaration = { "var" ~ !newline ~ idents ~ newline? ~ ";" }
idents = { ident ~ (newline? ~ "," ~ ident)* }
ident = @{ !digit ~ (alpha | digit | underscore)+ }

Теперь, если бы я привлекал ваше внимание в течение последних нескольких минут, несложно было бы предположить, что здесь происходит. (О, нет? В любом случае ... поехали)

Первые пять - это все термины. Это наборы допустимых значений. | называется оператором выбора, что похоже на «или-иначе».

first | or_else

При сопоставлении выражения выбора выполняется попытка first. Если первое совпадение выполнено успешно, все выражение выполняется немедленно. Однако, если first завершится неудачей, следующей будет предпринята попытка or_else.

Правило newline имеет причудливую _ перед скобкой, которая в Pest означает «молчаливый» - мы просто не хотим, чтобы оно было частью наших проанализированных токенов, но, тем не менее, это часть допустимого синтаксиса.

Правило WHITESPACE занимает особое место в Пеште. Если вы его определили, Pest будет автоматически вставлять неявные оптические пробелы (в соответствии с WHITESPACE правилом, которое вы определяете) между всеми символами. Опять же, _ говорит, что мы хотим отключить его, поскольку мы не хотим, чтобы тонны пробелов были частью нашего синтаксического дерева.

Правило declaration очень похоже на аналог EBNF, который мы узнали ранее. Тильды ('~') просто означают 'а затем'. Правило начинается с ключевого слова 'var', за которым следует все, что не является новой строкой ('!' Делает то, что вы интуитивно догадались - отрицание правила), за которым следует подправило idents, необязательный символ новой строки и заканчивается точкой с запятой.

Правило idents снова похоже на пример EBNF. Это либо одиночный ident, за которым следует ноль или несколько ident, разделенных запятыми.

Правило ident немного особенное. Символ «@», известный как Atomic, перед скобкой означает: «Я не хочу, чтобы к этому правилу применялись неявные пробелы». Мы точно не хотим включать пробелы в имя нашей переменной. Более того, пометка правила как атомарного таким образом трактует правило как термин, подавляя внутренние правила сопоставления. Любые внутренние правила отбрасываются.

string_lit = { "\"" ~ inner ~ "\"" }
inner = { ASCII_ALPHANUMERIC* }

Обратите внимание, что ASCII_ALPHANUMERIC - удобное встроенное правило в Pest для любых символов и цифр ASCII.

Если мы проанализируем строку «hello» с помощью этого правила, это даст сначала узел string_lit, который, в свою очередь, имеет узел inner, содержащий строку 'hello' без кавычек.

Добавление символа «@» перед скобкой string_lit:

string_lit = @{ "\"" ~ inner ~ "\"" }
inner = { ASCII_ALPHANUMERIC* }

У нас получится плоский узел string_lit, содержащий '\' hello \ '.

Аналогичный символ «$», известный как Составной атом, защищает неявные пробелы внутри правила. Разница в том, что он позволяет внутренним правилам сопоставления обрабатывать как обычно.

Часть !digit защищает правило от продолжения, если оно начинается с числа. В противном случае можно использовать любую одну или несколько комбинаций символов, цифр и подчеркиваний.

Но подождите, а где код?

Черт возьми, мой умный исследователь! Кажется, ты загоняешь меня в угол на каждом шагу. Да, это был вовсе не код, а грамматическое определение Pest. Теперь нам нужно написать код на Rust для синтаксического анализа текста. Запустите src/main.rs и добавьте следующее:

/// You need to do this to use macro
extern crate pest;                                                                                                                                                                                   
#[macro_use]                                                                                                                                                                                         
extern crate pest_derive;                                                                                                                                                                            
/// 1. Import modules                                                                                                                                                                                            
use std::fs;                                                                                                                                                                                         
use pest::Parser;                                                                                                                                                                                    
use pest::iterators::Pair;                                                                                                                                                                           
/// 2. Define a "marker" struct and add a path to our grammar file.                                                                                                                                                                                                 
#[derive(Parser)]                                                                                                                                                                                    
#[grammar = "grammar.pest"]                                                                                                                                                                            
struct IdentParser; 
/// 3. Print the detail of a current Pair and optional divider
fn print_pair(pair: &Pair<Rule>, hard_divider: bool) {
    println!("Rule: {:?}", pair.as_rule());
    println!("Span: {:?}", pair.as_span());
    println!("Text: {:?}", pair.as_str());
    if hard_divider {
        println!("{:=>60}", "");
    } else {
        println!("{:->60}", "");
    }
}
fn main() {
    /// 4. Parse a sample string input
    let pair = IdentParser::parse(Rule::declaration, "var foo1, bar_99, fooBar;")
        .expect("unsuccessful parse")
        .next().unwrap();
    print_pair(&pair, true);
    /// 5. Iterate over the "inner" Pairs
    for inner_pair in pair.into_inner() {
        print_pair(&inner_pair, true);
        match inner_pair.as_rule() {
            /// 6. If we match an idents rule...
            Rule::idents =>  {
                /// 7. Iterate over another inner Pairs
                for inner_inner_pair in inner_pair.into_inner() {
                    match inner_inner_pair.as_rule() {
                        /// 8. The term ident is the last level
                        Rule::ident => {
                            print_pair(&inner_inner_pair, false);
                        }
                        _ => unreachable!(),
                    }
                }
            }
            _ => unreachable!(),
        }
    }
}

Ничего страшного, если ты здесь почти не понимаешь. Давайте запустим его с cargo run в каталоге вашего проекта и посмотрим на распечатанный результат.

Rule: declaration
Span: Span { str: "var foo1, bar_99, fooBarBaz;", start: 0, end: 28 }
Text: "var foo1, bar_99, fooBarBaz;"
============================================================
Rule: idents
Span: Span { str: "foo1, bar_99, fooBarBaz", start: 4, end: 27 }
Text: "foo1, bar_99, fooBarBaz"
============================================================
Rule: ident
Span: Span { str: "foo1", start: 4, end: 8 }
Text: "foo1"
------------------------------------------------------------
Rule: ident
Span: Span { str: "bar_99", start: 10, end: 16 }
Text: "bar_99"
------------------------------------------------------------
Rule: ident
Span: Span { str: "fooBarBaz", start: 18, end: 27 }
Text: "fooBarBaz"
-----------------------------------------------------------------

Самая важная концепция здесь - это Pair. Он представляет собой совпадающую пару токенов или, что эквивалентно, составной текст, которому успешно сопоставлено именованное правило.

Мы часто используем Pairs для:

  • Определение того, какое правило привело к Pair
  • Использование Pair как сырого &str
  • Проверка внутренних именованных подправил, которые создали Pair
let pair = Parser::parse(Rule::enclosed, "(..6472..) and more text")
    .unwrap().next().unwrap();
assert_eq!(pair.as_rule(), Rule::enclosed);
assert_eq!(pair.as_str(), "(..6472..)");
let inner_rules = pair.into_inner();
println!("{}", inner_rules); // --> [number(3, 7)]

Pair может иметь ноль, одно или несколько внутренних правил. Для максимальной гибкости Pair::into_inner() возвращает Pairs, который является типом итератора для каждой пары.

💡 Pair::into_inner() - очень распространенная идиома при работе с Pest. Убедитесь, что вы понимаете, что такое Pair.

Давайте разберем Systemd

Пришло время приступить к работе. Вот пример файла модуля Systemd:

[Unit]
Description=Nginx
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/home/ec2-user/.local/bin
Environment=LD_LIBRARY_PATH=/usr/local/lib
Environment=PKG_CONFIG_PATH=/usr/local/lib/pkgconfig
ExecStart=/usr/local/sbin/nginx-runner.sh
Restart=on-failure
RestartSec=0
KillMode=process
[Install]
WantedBy=multi-user.target

Файл сгруппирован по разделам, каждый из которых имеет имя, заключенное в пару квадратных скобок. Каждый раздел содержит ноль или более пар имени и значения свойства, разделенных знаком равенства «=».

Давайте попробуем конкретизировать набор правил. Создайте новый проект ржавчины с cargo new --bin systemd-parser, затем создайте файл с именем src/grammar.pest со следующими правилами:

/// Implicit white spaces are ok.
WHITESPACE = _{ " " }
/// Set of characters permited 
char = { ASCII_ALPHANUMERIC | "." | "_" | "/" | "-"  }
/// name is one or more chars. Note that white spaces are allowed.
name = { char+ }
// value can be zero or more char, plus = and : for path variables.
value = { (char | "=" | ":" )* }
/// section is a name, enclosed by square brackets.
section = { "[" ~ name ~ "]" }
/// property pair is a name and value, separated by an equal sign.
property = { name ~ "=" ~ value }
/// A Systemd unit file structure
file = {
    SOI ~
    ((section | property)? ~ NEWLINE)* ~
    EOI
}

В файле main.rs начните со следующего:

extern crate pest;
#[macro_use]
extern crate pest_derive;
use std::fs;
use std::env::current_dir;
use std::collections::HashMap;
use pest::Parser;
#[derive(Parser)]
#[grammar = "grammar.pest"]
struct SystemdParser;
/// Implement a simple AST representation
#[derive(Debug, Clone)]
pub enum SystemdValue {
    List(Vec<String>),
    Str(String),
}
// ...

В качестве первого шага после первоначального импорта и настройки мы определяем перечисление SystemdValue как простое представление типа данных в файле Systemd. SystemdValue::Str(String) фиксирует одно значение свойства, а SystemdValue::List(Vec<String>) фиксирует несколько значений свойств с повторяющимся именем ключа свойства. Например, в предыдущем файле nginx.service есть несколько свойств Environment.

Вот функция main:

fn main() {
    // Read and parse the unit file.
    let unparsed_file = fs::read_to_string("nginx.service")
        .expect("cannot read file");
    let file = SystemdParser::parse(Rule::file, &unparsed_file).expect("fail to parse")
        .next()
        .unwrap();
    // Create a fresh HashMap to store the data.
    let mut properties: HashMap<String, HashMap<String, SystemdValue>> = HashMap::new();
    // These two mutable variables will be used to store
    // section name and property key name.
    let mut current_section_name = String::new();
    let mut current_key_name = String::new();
    // Iterate over the file line-by-line.
    for line in file.into_inner() {
        match line.as_rule() {
            Rule::section => {
                // Update the current_section_name
                let mut inner_rules = line.into_inner();
                current_section_name = inner_rules.next().unwrap().as_str().to_string();
            }
            Rule::property => {
                let mut inner_rules = line.into_inner();
                // Get a sub map of properties with the current_section_name key, or create new.
                let section = properties.entry(current_section_name.clone()).or_default();
                // Get the current property name and value.
                let name = inner_rules.next().unwrap().as_str().to_string();
                let value = inner_rules.next().unwrap().as_str().to_string();
                // If the property name already exists...
                if name == current_key_name {
                    // Get the section of the map with the key name, or insert a new SytemdValue::List.
                    let entry = section.entry(current_key_name.clone()).or_insert(SystemdValue::List(vec![]));
                    // Push the value onto the inner vector of SystemdValue::List.
                    if let SystemdValue::List(ent) = entry {
                        ent.push(value);
                    }
                } else {
                    // Create a new SystemdValue::List and save it under name key.
                    let entry = section.entry(name.clone()).or_insert(SystemdValue::List(vec![]));
                    // Push the current value onto the vector, then set the
                    // current_key_name to the current name.
                    if let SystemdValue::List(ent) = entry {
                        ent.push(value);
                    }
                    current_key_name = name;
                }
            }
            Rule::EOI => (),
            _ => unreachable!(),
        }
    }
}

Все это хорошо, но мы не использовали SystemdValue::Str в коде. Чтобы код оставался чистым, мы решили рассматривать каждое свойство как HashMap<String, SystemdValue::List(Vec<String>), где ключ карты является ключом свойства, а вектор String хранит список значений свойств. Если значение отсутствует, вектор пуст. Если есть одно значение, этот вектор содержит это единственное значение и так далее.

Чтобы немного упростить использование API, мы напишем небольшую вспомогательную функцию для обработки всех однозначных Systemd::List(Vec<String>) и преобразования их в Systemd::Str(String).

// Iterate over the nested maps, and convert empty and 
// single-element `SystemdValue::List<Vec<String>>` to 
// `SystemdValue::Str(String)`.
fn pre_process_map(map: &mut HashMap<String, HashMap<String, SystemdValue>>) {
    for (_, value) in map.into_iter() {
        for (_, v) in value.into_iter() {
            if let SystemdValue::List(vs) = v {
                if vs.len() == 0 {
                    let v_ = SystemdValue::Str(String::new());
                    *v = v_.clone();
                } else if vs.len() == 1 {
                    let v_ = SystemdValue::Str((vs[0]).clone());
                    *v = v_.clone();
                }
            }
        }
    }
}

Теперь мы готовы распечатать его для всеобщего обозрения!

fn main() {
    // Our main code 
    pre_process_map(properties);
    println!("{:#?}", properties);
}

Бум! Поздравляю! Вы только что написали парсер файлов Systemd плюс мини-JS. 🤯 Теперь у вас будет немного свободного времени, чтобы повеселиться в пятницу вечером. Еще днем ​​вы можете даже придумать, как сериализовать файл модуля Systemd в JSON, чтобы произвести впечатление на своего босса в понедельник.

Вы можете ознакомиться с кодом парсера, реализованным в виде библиотеки в этом репо:

Jochasinga / системный парсер

Я также на DEV и Twitter, если вы рядом! Приходите поздороваться 👋🏾

Если вам нравится, как я помог спасти ваши выходные, не стесняйтесь аплодировать этой публикации. Пожалуйста, подписывайтесь на меня, чтобы получить больше таких хакерских мемов-радостей, как этот. Все вопросы и ответы приветствуются! Просто оставьте свой комментарий, и я постараюсь ответить.