use crate::model::{Line, LineKind, Span};
pub fn lex(input: &str) -> Vec<Line> {
input
.lines()
.enumerate()
.map(|(i, raw)| {
let line_num = i + 1; let trimmed = raw.trim();
let kind = if trimmed.is_empty() {
LineKind::Empty
} else if trimmed.starts_with('#') {
LineKind::Comment(trimmed.to_string())
} else {
parse_directive(trimmed)
};
Line {
kind,
span: Span::new(line_num),
}
})
.collect()
}
fn parse_directive(line: &str) -> LineKind {
let line = strip_inline_comment(line);
if let Some(eq_pos) = line.find('=') {
let key = line[..eq_pos].trim();
let value = line[eq_pos + 1..].trim();
if !key.contains(' ') {
return LineKind::Directive {
key: key.to_string(),
value: value.to_string(),
};
}
}
if let Some(space_pos) = line.find(|c: char| c.is_whitespace()) {
let key = line[..space_pos].trim();
let value = line[space_pos..].trim();
LineKind::Directive {
key: key.to_string(),
value: value.to_string(),
}
} else {
LineKind::Directive {
key: line.to_string(),
value: String::new(),
}
}
}
fn strip_inline_comment(line: &str) -> &str {
let mut in_quote = false;
let mut escape_next = false;
for (i, ch) in line.char_indices() {
if escape_next {
escape_next = false;
continue;
}
match ch {
'\\' if in_quote => escape_next = true,
'"' => in_quote = !in_quote,
'#' if !in_quote => {
return line[..i].trim_end();
}
_ => {}
}
}
line
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input() {
let lines = lex("");
assert_eq!(lines.len(), 0);
}
#[test]
fn empty_line() {
let lines = lex("\n");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].kind, LineKind::Empty);
}
#[test]
fn comment_line() {
let lines = lex("# this is a comment");
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].kind,
LineKind::Comment("# this is a comment".into())
);
}
#[test]
fn directive_space_separated() {
let lines = lex("Host foo");
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].kind,
LineKind::Directive {
key: "Host".into(),
value: "foo".into(),
}
);
}
#[test]
fn directive_equals_no_spaces() {
let lines = lex("IdentityFile=~/.ssh/id_ed25519");
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].kind,
LineKind::Directive {
key: "IdentityFile".into(),
value: "~/.ssh/id_ed25519".into(),
}
);
}
#[test]
fn directive_equals_with_spaces() {
let lines = lex("IdentityFile = ~/.ssh/id_ed25519");
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].kind,
LineKind::Directive {
key: "IdentityFile".into(),
value: "~/.ssh/id_ed25519".into(),
}
);
}
#[test]
fn directive_with_leading_whitespace() {
let lines = lex(" User alice");
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].kind,
LineKind::Directive {
key: "User".into(),
value: "alice".into(),
}
);
}
#[test]
fn span_line_numbers_correct() {
let input = "Host foo\n User bar\n\n# comment";
let lines = lex(input);
assert_eq!(lines.len(), 4);
assert_eq!(lines[0].span.line, 1);
assert_eq!(lines[1].span.line, 2);
assert_eq!(lines[2].span.line, 3);
assert_eq!(lines[3].span.line, 4);
}
#[test]
fn mixed_content() {
let input = "# header\nHost github.com\n IdentityFile ~/.ssh/gh\n User git";
let lines = lex(input);
assert_eq!(lines.len(), 4);
assert!(matches!(lines[0].kind, LineKind::Comment(_)));
assert!(matches!(
lines[1].kind,
LineKind::Directive {
ref key,
ref value
} if key == "Host" && value == "github.com"
));
assert!(matches!(
lines[2].kind,
LineKind::Directive {
ref key,
ref value
} if key == "IdentityFile" && value == "~/.ssh/gh"
));
assert!(matches!(
lines[3].kind,
LineKind::Directive {
ref key,
ref value
} if key == "User" && value == "git"
));
}
#[test]
fn comment_after_directive() {
let lines = lex("IdentityFile ~/.ssh/id_ed25519 # personal key");
assert_eq!(lines.len(), 1);
assert!(matches!(
lines[0].kind,
LineKind::Directive {
ref key,
ref value
} if key == "IdentityFile" && value == "~/.ssh/id_ed25519"
));
}
#[test]
fn quoted_value_with_spaces() {
let lines = lex("ProxyCommand \"ssh -W %h:%p jump\"");
assert_eq!(lines.len(), 1);
assert!(matches!(
lines[0].kind,
LineKind::Directive {
ref key,
ref value
} if key == "ProxyCommand" && value == "\"ssh -W %h:%p jump\""
));
}
#[test]
fn multiple_host_patterns() {
let lines = lex("Host github.com gitlab.com *.corp");
assert_eq!(lines.len(), 1);
assert!(matches!(
lines[0].kind,
LineKind::Directive {
ref key,
ref value
} if key == "Host" && value == "github.com gitlab.com *.corp"
));
}
#[test]
fn multiple_include_patterns() {
let lines = lex("Include ~/.ssh/conf.d/*.conf ~/.ssh/extra.conf");
assert_eq!(lines.len(), 1);
assert!(matches!(
lines[0].kind,
LineKind::Directive {
ref key,
ref value
} if key == "Include" && value == "~/.ssh/conf.d/*.conf ~/.ssh/extra.conf"
));
}
}