use alloc::string::{String, ToString};
use alloc::vec::Vec;
use super::ConfigError;
#[derive(Debug, Clone)]
pub struct ParsedLine {
pub line_no: usize,
pub keyword: String,
pub args: Vec<String>,
}
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 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,
});
}
Ok(out)
}
fn strip_comment(line: &str) -> &str {
let bytes = line.as_bytes();
let mut i = 0;
let mut in_quotes = false;
let mut escape = false;
while i < bytes.len() {
let c = bytes[i];
if escape {
escape = false;
} else if c == b'\\' && in_quotes {
escape = true;
} else if c == b'"' {
in_quotes = !in_quotes;
} else if c == b'#' && !in_quotes {
return &line[..i];
}
i += 1;
}
line
}
fn tokenize_line(line: &str, line_no: usize) -> Result<Vec<String>, ConfigError> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_token = false;
let mut in_quotes = false;
let mut escape = false;
for ch in line.chars() {
if escape {
cur.push(ch);
escape = false;
in_token = true;
continue;
}
if in_quotes {
match ch {
'\\' => escape = true,
'"' => in_quotes = false,
_ => cur.push(ch),
}
continue;
}
match ch {
'"' => {
in_quotes = true;
in_token = true;
}
c if c.is_whitespace() => {
if in_token {
out.push(core::mem::take(&mut cur));
in_token = false;
}
}
c => {
cur.push(c);
in_token = true;
}
}
}
if in_quotes {
return Err(ConfigError::Syntax {
line: line_no,
msg: "unterminated quoted string".into(),
});
}
if in_token {
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 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:?}"),
}
}
}