use alloc::string::{String, ToString};
use alloc::vec::Vec;
use super::ConfigError;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ParsedLine {
pub line_no: usize,
pub keyword: String,
pub args: Vec<String>,
pub rest: String,
pub file: Option<String>,
}
impl ParsedLine {
pub fn location(&self) -> String {
match &self.file {
Some(f) => alloc::format!("{f} line {}", self.line_no),
None => alloc::format!("line {}", self.line_no),
}
}
}
pub(crate) const INCLUDE_BEGIN: &str = "=include-begin";
pub(crate) const INCLUDE_END: &str = "=include-end";
pub fn tokenize(src: &str) -> Result<Vec<ParsedLine>, ConfigError> {
let mut out = Vec::new();
for (idx, raw) in src.lines().enumerate() {
let line_no = idx + 1;
let stripped = strip_comment(raw);
let trimmed = stripped.trim();
if trimmed.is_empty() {
continue;
}
let mut toks = tokenize_line(trimmed, line_no)?;
if toks.is_empty() {
continue;
}
let kw_end = trimmed
.find(|c: char| c.is_whitespace() || c == '=')
.unwrap_or(trimmed.len());
let rest = trimmed[kw_end..]
.trim_start_matches(|c: char| c.is_whitespace() || c == '=')
.to_string();
let mut keyword = toks.remove(0);
if let Some((kw, rest)) = keyword.split_once('=') {
let rest_str = rest.to_string();
keyword = kw.to_string();
if !rest_str.is_empty() {
toks.insert(0, rest_str);
}
}
keyword.make_ascii_lowercase();
if !toks.is_empty() {
if toks[0] == "=" {
toks.remove(0);
} else if let Some(stripped_arg) = toks[0].strip_prefix('=') {
let s = stripped_arg.to_string();
if s.is_empty() {
toks.remove(0);
} else {
toks[0] = s;
}
}
}
out.push(ParsedLine {
line_no,
keyword,
args: toks,
rest,
file: None,
});
}
Ok(out)
}
fn strip_comment(line: &str) -> &str {
let b = line.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i] == b' ' || b[i] == b'\t' {
i += 1;
continue;
}
if b[i] == b'#' {
return &line[..i];
}
let mut quote = 0u8;
while i < b.len() {
match b[i] {
b'\\'
if i + 1 < b.len()
&& (matches!(b[i + 1], b'\'' | b'"' | b'\\')
|| (quote == 0 && b[i + 1] == b' ')) =>
{
i += 1;
}
b' ' | b'\t' if quote == 0 => break,
b'"' | b'\'' if quote == 0 => quote = b[i],
c if quote != 0 && c == quote => quote = 0,
_ => {}
}
i += 1;
}
}
line
}
fn tokenize_line(line: &str, line_no: usize) -> Result<Vec<String>, ConfigError> {
let chars: Vec<char> = line.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == ' ' || chars[i] == '\t' {
i += 1;
continue;
}
let mut cur = String::new();
let mut quote: Option<char> = None;
while i < chars.len() {
let c = chars[i];
match c {
'\\' => match chars.get(i + 1) {
Some(&n)
if n == '\'' || n == '"' || n == '\\' || (quote.is_none() && n == ' ') =>
{
cur.push(n);
i += 1;
}
_ => cur.push(c),
},
' ' | '\t' if quote.is_none() => break,
'"' | '\'' if quote.is_none() => quote = Some(c),
_ if quote == Some(c) => quote = None,
_ => cur.push(c),
}
i += 1;
}
if quote.is_some() {
return Err(ConfigError::Syntax {
line: line_no,
msg: "unterminated quoted string".into(),
});
}
out.push(cur);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skips_blank_and_comment_lines() {
let p = tokenize("# comment\n\n \n# another\n").unwrap();
assert!(p.is_empty());
}
#[test]
fn lowercases_keyword() {
let p = tokenize("HostName example.com").unwrap();
assert_eq!(p.len(), 1);
assert_eq!(p[0].keyword, "hostname");
assert_eq!(p[0].args, vec!["example.com"]);
}
#[test]
fn tokenizes_like_openssh_argv_split() {
let p = tokenize(
r#"ProxyCommand env X=\"\" 'a b' c\ d e#f # comment
Host foo#bar
"#,
)
.unwrap();
assert_eq!(p[0].args, vec!["env", r#"X="""#, "a b", "c d", "e#f"]);
assert_eq!(p[0].rest, r#"env X=\"\" 'a b' c\ d e#f"#);
assert_eq!(p[1].args, vec!["foo#bar"]);
assert!(tokenize("Host 'unterminated\n").is_err());
}
#[test]
fn accepts_equals_separator() {
let cases = ["Port=2222", "Port =2222", "Port= 2222", "Port = 2222"];
for src in cases {
let p = tokenize(src).unwrap();
assert_eq!(p.len(), 1, "case {src:?}");
assert_eq!(p[0].keyword, "port", "case {src:?}");
assert_eq!(p[0].args, vec!["2222"], "case {src:?}");
}
}
#[test]
fn quoted_value_preserves_whitespace() {
let p = tokenize(r#"User "alice bob""#).unwrap();
assert_eq!(p[0].args, vec!["alice bob"]);
}
#[test]
fn backslash_escape_in_quotes() {
let p = tokenize(r#"User "a\"b\\c""#).unwrap();
assert_eq!(p[0].args, vec!["a\"b\\c"]);
}
#[test]
fn trailing_comment_after_value() {
let p = tokenize("Port 22 # default").unwrap();
assert_eq!(p[0].args, vec!["22"]);
}
#[test]
fn quoted_hash_is_not_comment() {
let p = tokenize(r#"Host "weird#name""#).unwrap();
assert_eq!(p[0].args, vec!["weird#name"]);
}
#[test]
fn multiple_args() {
let p = tokenize("LocalForward 8080 example.com:80").unwrap();
assert_eq!(p[0].args, vec!["8080", "example.com:80"]);
}
#[test]
fn unterminated_quote_errors() {
let err = tokenize(r#"Host "unterminated"#).unwrap_err();
match err {
ConfigError::Syntax { line, .. } => assert_eq!(line, 1),
_ => panic!("wrong error: {err:?}"),
}
}
}