В пятницу утром, расслабившись, вы думаете о новых шоу 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
. Он представляет собой совпадающую пару токенов или, что эквивалентно, составной текст, которому успешно сопоставлено именованное правило.
Мы часто используем Pair
s для:
- Определение того, какое правило привело к
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, если вы рядом! Приходите поздороваться 👋🏾
Если вам нравится, как я помог спасти ваши выходные, не стесняйтесь аплодировать этой публикации. Пожалуйста, подписывайтесь на меня, чтобы получить больше таких хакерских мемов-радостей, как этот. Все вопросы и ответы приветствуются! Просто оставьте свой комментарий, и я постараюсь ответить.