use super::lexer::TokenLine;
use crate::error::AnvilError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HostPattern {
pub(crate) pattern: String,
pub(crate) negated: bool,
}
impl HostPattern {
pub(crate) fn parse(token: &str) -> Self {
if let Some(rest) = token.strip_prefix('!') {
Self {
pattern: rest.to_owned(),
negated: true,
}
} else {
Self {
pattern: token.to_owned(),
negated: false,
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BlockKind {
Global,
Host(Vec<HostPattern>),
Match,
}
#[derive(Debug, Clone)]
pub(crate) struct Directive {
pub(crate) keyword: String,
pub(crate) args: Vec<String>,
pub(crate) file: std::path::PathBuf,
pub(crate) line_no: u32,
}
#[derive(Debug, Clone)]
pub(crate) struct HostBlock {
pub(crate) kind: BlockKind,
pub(crate) directives: Vec<Directive>,
}
pub(crate) fn parse(tokens: Vec<TokenLine>) -> Result<Vec<HostBlock>, AnvilError> {
let mut blocks: Vec<HostBlock> = vec![HostBlock {
kind: BlockKind::Global,
directives: Vec::new(),
}];
for tok in tokens {
match tok.keyword.as_str() {
"host" => {
if tok.args.is_empty() {
return Err(AnvilError::invalid_config(format!(
"ssh_config: `Host` directive at {}:{} has no patterns",
tok.file.display(),
tok.line_no,
)));
}
let patterns: Vec<HostPattern> =
tok.args.iter().map(|s| HostPattern::parse(s)).collect();
blocks.push(HostBlock {
kind: BlockKind::Host(patterns),
directives: Vec::new(),
});
}
"match" => {
log::warn!(
"ssh_config: `Match` blocks are deferred to v1.1; ignoring section at {}:{}",
tok.file.display(),
tok.line_no,
);
blocks.push(HostBlock {
kind: BlockKind::Match,
directives: Vec::new(),
});
}
_ => {
let last = blocks
.last_mut()
.expect("blocks invariant: at least one block exists");
last.directives.push(Directive {
keyword: tok.keyword,
args: tok.args,
file: tok.file,
line_no: tok.line_no,
});
}
}
}
Ok(blocks)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ssh_config::lexer::tokenize;
use std::path::PathBuf;
fn parse_str(input: &str) -> Vec<HostBlock> {
let tokens = tokenize(input, &PathBuf::from("test")).expect("tokenize");
parse(tokens).expect("parse")
}
#[test]
fn empty_input_yields_only_global_block() {
let blocks = parse_str("");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].kind, BlockKind::Global);
assert!(blocks[0].directives.is_empty());
}
#[test]
fn directives_before_first_host_go_to_global() {
let input = "User defaultuser\nIdentityFile ~/.ssh/global_id\n";
let blocks = parse_str(input);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].kind, BlockKind::Global);
assert_eq!(blocks[0].directives.len(), 2);
assert_eq!(blocks[0].directives[0].keyword, "user");
}
#[test]
fn host_block_starts_new_section() {
let input = "Host gh\n HostName github.com\n User git\n";
let blocks = parse_str(input);
assert_eq!(blocks.len(), 2);
assert!(matches!(blocks[1].kind, BlockKind::Host(_)));
assert_eq!(blocks[1].directives.len(), 2);
assert_eq!(blocks[1].directives[0].keyword, "hostname");
}
#[test]
fn host_pattern_negation() {
let input = "Host !work *\n";
let blocks = parse_str(input);
assert_eq!(blocks.len(), 2);
let BlockKind::Host(patterns) = &blocks[1].kind else {
panic!("expected Host kind");
};
assert_eq!(patterns.len(), 2);
assert_eq!(
patterns[0],
HostPattern {
pattern: "work".to_owned(),
negated: true,
},
);
assert_eq!(
patterns[1],
HostPattern {
pattern: "*".to_owned(),
negated: false,
},
);
}
#[test]
fn multiple_host_blocks_in_order() {
let input = "Host a\n User u1\nHost b\n User u2\n";
let blocks = parse_str(input);
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[1].directives[0].args, vec!["u1"]);
assert_eq!(blocks[2].directives[0].args, vec!["u2"]);
}
#[test]
fn host_with_no_arguments_is_an_error() {
let tokens = tokenize("Host\n", &PathBuf::from("t")).expect("tokenize");
let err = parse(tokens).expect_err("should fail");
assert!(format!("{err}").contains("no patterns"));
}
#[test]
fn match_block_is_recognized_but_ignored() {
let input = "Match host gh\n User x\nHost y\n User y\n";
let blocks = parse_str(input);
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[1].kind, BlockKind::Match);
assert_eq!(blocks[1].directives.len(), 1);
assert_eq!(blocks[1].directives[0].args, vec!["x"]);
let BlockKind::Host(_) = &blocks[2].kind else {
panic!("expected Host kind for blocks[2]");
};
assert_eq!(blocks[2].directives[0].args, vec!["y"]);
}
#[test]
fn directive_provenance_is_preserved() {
let input = "# header\nHost gh\n User git\n";
let blocks = parse_str(input);
assert_eq!(blocks[1].directives[0].line_no, 3);
assert_eq!(blocks[1].directives[0].file, PathBuf::from("test"));
}
}