use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigAst {
pub nodes: Vec<ConfigNode>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Separator {
Space,
Equals,
}
impl Separator {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Space => " ",
Self::Equals => "=",
}
}
}
const DEFAULT_INDENT: &str = " ";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectiveData {
pub keyword: String,
pub separator: Separator,
pub value: String,
pub comment: Option<String>,
pub indent: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostBlockData {
pub header: String,
pub patterns: Vec<String>,
pub nodes: Vec<ConfigNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchBlockData {
pub header: String,
pub criteria: String,
pub nodes: Vec<ConfigNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ConfigNode {
BlankLine,
Comment {
text: String,
indent: String,
},
Directive(Box<DirectiveData>),
HostBlock(Box<HostBlockData>),
MatchBlock(Box<MatchBlockData>),
}
impl ConfigAst {
#[must_use]
pub fn to_string_lossless(&self) -> String {
let mut out = String::new();
for node in &self.nodes {
node.render(&mut out, 0);
}
out
}
}
impl ConfigNode {
fn render(&self, out: &mut String, indent_level: usize) {
let computed_prefix = DEFAULT_INDENT.repeat(indent_level);
match self {
Self::BlankLine => {
out.push('\n');
}
Self::Comment { text, indent } => {
if indent.is_empty() && indent_level > 0 {
out.push_str(&computed_prefix);
} else {
out.push_str(indent);
}
out.push_str(text);
out.push('\n');
}
Self::Directive(d) => {
if d.indent.is_empty() && indent_level > 0 {
out.push_str(&computed_prefix);
} else {
out.push_str(&d.indent);
}
out.push_str(&d.keyword);
out.push_str(d.separator.as_str());
out.push_str(&d.value);
if let Some(ref c) = d.comment {
out.push_str(" #");
out.push_str(c);
}
out.push('\n');
}
Self::HostBlock(b) => {
out.push_str(&computed_prefix);
out.push_str(&b.header);
out.push('\n');
for child in &b.nodes {
child.render(out, indent_level + 1);
}
}
Self::MatchBlock(b) => {
out.push_str(&computed_prefix);
out.push_str(&b.header);
out.push('\n');
for child in &b.nodes {
child.render(out, indent_level + 1);
}
}
}
}
#[must_use]
pub fn as_host_block(&self) -> Option<(&[String], &[Self])> {
match self {
Self::HostBlock(b) => Some((&b.patterns, &b.nodes)),
_ => None,
}
}
pub fn as_host_block_mut(&mut self) -> Option<&mut Vec<Self>> {
match self {
Self::HostBlock(b) => Some(&mut b.nodes),
_ => None,
}
}
#[must_use]
pub fn as_directive(&self) -> Option<(&str, &str)> {
match self {
Self::Directive(d) => Some((&d.keyword, &d.value)),
_ => None,
}
}
pub fn as_directive_mut(&mut self) -> Option<(&mut String, &mut String)> {
match self {
Self::Directive(d) => Some((&mut d.keyword, &mut d.value)),
_ => None,
}
}
}
#[must_use]
pub fn parse(input: &str) -> ConfigAst {
let mut nodes = Vec::new();
let mut lines = input.lines().peekable();
while let Some(line) = lines.next() {
let trimmed = line.trim();
let indent = line_indent(line);
if trimmed.is_empty() {
nodes.push(ConfigNode::BlankLine);
continue;
}
if trimmed.starts_with('#') {
nodes.push(ConfigNode::Comment {
text: trimmed.to_owned(),
indent: indent.to_owned(),
});
continue;
}
let (keyword, separator, rest) = parse_directive_parts(trimmed);
if keyword.eq_ignore_ascii_case("host") {
let patterns = parse_patterns(rest);
let header = line.trim().to_owned();
let inner = parse_block_body(&mut lines);
nodes.push(ConfigNode::HostBlock(Box::new(HostBlockData {
header,
patterns,
nodes: inner,
})));
} else if keyword.eq_ignore_ascii_case("match") {
let header = line.trim().to_owned();
let criteria = rest.to_owned();
let inner = parse_block_body(&mut lines);
nodes.push(ConfigNode::MatchBlock(Box::new(MatchBlockData {
header,
criteria,
nodes: inner,
})));
} else {
let (value, comment) = split_trailing_comment(rest);
nodes.push(ConfigNode::Directive(Box::new(DirectiveData {
keyword: keyword.to_owned(),
separator,
value,
comment,
indent: indent.to_owned(),
})));
}
}
ConfigAst { nodes }
}
fn line_indent(line: &str) -> &str {
let end = line
.find(|c: char| !c.is_whitespace())
.unwrap_or(line.len());
&line[..end]
}
fn parse_block_body<'a, I>(lines: &mut std::iter::Peekable<I>) -> Vec<ConfigNode>
where
I: Iterator<Item = &'a str>,
{
let mut body = Vec::new();
while let Some(line) = lines.peek() {
if !line.starts_with(' ') && !line.starts_with('\t') {
break;
}
let Some(line) = lines.next() else {
break;
};
let trimmed = line.trim();
let indent = line_indent(line);
if trimmed.is_empty() {
body.push(ConfigNode::BlankLine);
continue;
}
if trimmed.starts_with('#') {
body.push(ConfigNode::Comment {
text: trimmed.to_owned(),
indent: indent.to_owned(),
});
continue;
}
let (keyword, separator, rest) = parse_directive_parts(trimmed);
let (value, comment) = split_trailing_comment(rest);
body.push(ConfigNode::Directive(Box::new(DirectiveData {
keyword: keyword.to_owned(),
separator,
value,
comment,
indent: indent.to_owned(),
})));
}
body
}
pub(crate) fn parse_directive_parts(line: &str) -> (&str, Separator, &str) {
let ws_pos = line.find(|c: char| c.is_whitespace());
if let Some(eq_pos) = line.find('=') {
let before_eq_has_space = line[..eq_pos].contains(' ') || line[..eq_pos].contains('\t');
if !before_eq_has_space {
let keyword = line[..eq_pos].trim();
let rest = line[eq_pos + 1..].trim();
return (keyword, Separator::Equals, rest);
}
}
if let Some(ws) = ws_pos {
let keyword = &line[..ws];
let rest = line[ws..].trim_start();
return (keyword, Separator::Space, rest);
}
(line, Separator::Space, "")
}
fn parse_patterns(value: &str) -> Vec<String> {
value.split_whitespace().map(str::to_owned).collect()
}
pub(crate) fn split_trailing_comment(value: &str) -> (String, Option<String>) {
let mut in_double = false;
let mut in_single = false;
let mut comment_start = None;
for (i, ch) in value.char_indices() {
if ch == '"' && !in_single {
in_double = !in_double;
} else if ch == '\'' && !in_double {
in_single = !in_single;
} else if ch == '#' && !in_double && !in_single {
comment_start = Some(i);
break;
}
}
match comment_start {
Some(pos) => {
let val = value[..pos].trim_end().to_owned();
let comment = value[pos + 1..].trim().to_owned();
(val, Some(comment))
}
None => (value.to_owned(), None),
}
}
#[cfg(test)]
#[path = "ast.test.rs"]
mod tests;