use std::path::PathBuf;
use super::Command;
use crate::editor::window::Axis;
pub fn parse(input: &str) -> Result<Command, String> {
let input = input.trim();
if input.is_empty() {
return Err("empty command".to_string());
}
if let Ok(line) = input.parse::<usize>() {
return Ok(Command::GotoLine(line.saturating_sub(1)));
}
if (input.starts_with('s') || input.starts_with("%s"))
&& let Some(command) = parse_substitute(input)
{
return Ok(command);
}
let (head, rest) = match input.split_once(char::is_whitespace) {
Some((head, rest)) => (head, rest.trim()),
None => (input, ""),
};
let force = head.ends_with('!');
let name = head.trim_end_matches('!');
let argument = (!rest.is_empty()).then(|| rest.to_string());
match name {
"w" | "write" => Ok(Command::Write(argument.map(PathBuf::from))),
"q" | "quit" => Ok(Command::Quit { force }),
"wq" | "x" => Ok(Command::WriteQuit { force }),
"e" | "edit" => match argument {
Some(path) => Ok(Command::Edit {
path: PathBuf::from(path),
force,
}),
None if force => Ok(Command::Reload),
None => Err("usage: :e <path>".to_string()),
},
"touch" => required_path(argument, "touch").map(Command::CreateFile),
"mkdir" => required_path(argument, "mkdir").map(Command::CreateDirectory),
"bn" | "bnext" => Ok(Command::CycleBuffer { forward: true }),
"bp" | "bprev" => Ok(Command::CycleBuffer { forward: false }),
"sp" | "split" | "new" => Ok(Command::Split {
axis: Axis::Vertical,
}),
"vs" | "vsp" | "vsplit" | "vnew" => Ok(Command::Split {
axis: Axis::Horizontal,
}),
"clo" | "close" => Ok(Command::CloseWindow),
"on" | "only" => Ok(Command::OnlyWindow),
"theme" => argument.map_or_else(
|| Err("usage: :theme <name>".to_string()),
|name| Ok(Command::Theme(name)),
),
"set" => parse_set(rest),
"help" | "h" => Ok(Command::Help),
other => Err(format!("unknown command: {other}")),
}
}
fn required_path(argument: Option<String>, command: &str) -> Result<PathBuf, String> {
argument
.map(PathBuf::from)
.ok_or_else(|| format!("usage: :{command} <path>"))
}
fn parse_set(rest: &str) -> Result<Command, String> {
let mut parts = rest.split_whitespace();
let Some(key) = parts.next() else {
return Err("usage: :set <option> [value]".to_string());
};
let (key, default) = match key.strip_prefix("no") {
Some(stripped) if parts.clone().next().is_none() => (stripped, "false"),
_ => (key, "true"),
};
Ok(Command::Set {
key: key.to_string(),
value: parts.next().unwrap_or(default).to_string(),
})
}
fn parse_substitute(input: &str) -> Option<Command> {
let whole_file = input.starts_with('%');
let body = input.strip_prefix('%').unwrap_or(input).strip_prefix('s')?;
let delimiter = body.chars().next()?;
if delimiter.is_alphanumeric() {
return None;
}
let mut fields = vec![String::new()];
let mut escaped = false;
for ch in body.chars().skip(1) {
if escaped {
if ch != delimiter {
fields.last_mut()?.push('\\');
}
fields.last_mut()?.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
fields.push(String::new());
} else {
fields.last_mut()?.push(ch);
}
}
let pattern = fields.first()?.clone();
if pattern.is_empty() {
return None;
}
let replacement = fields.get(1).cloned().unwrap_or_default();
let all = fields.get(2).is_some_and(|flags| flags.contains('g'));
Some(Command::Substitute {
pattern,
replacement,
all,
whole_file,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_with_and_without_a_path() {
assert_eq!(parse("w"), Ok(Command::Write(None)));
assert_eq!(
parse("w out.rs"),
Ok(Command::Write(Some(PathBuf::from("out.rs"))))
);
}
#[test]
fn the_bang_suffix_forces_the_command() {
assert_eq!(parse("q!"), Ok(Command::Quit { force: true }));
assert_eq!(parse("q"), Ok(Command::Quit { force: false }));
}
#[test]
fn edit_without_a_path_only_works_as_a_reload() {
assert_eq!(parse("e!"), Ok(Command::Reload));
assert!(parse("e").is_err());
}
#[test]
fn creation_commands_require_a_path() {
assert_eq!(
parse("touch src/new file.rs"),
Ok(Command::CreateFile(PathBuf::from("src/new file.rs")))
);
assert_eq!(
parse("mkdir src/widgets"),
Ok(Command::CreateDirectory(PathBuf::from("src/widgets")))
);
assert_eq!(parse("touch"), Err("usage: :touch <path>".into()));
assert_eq!(parse("mkdir"), Err("usage: :mkdir <path>".into()));
}
#[test]
fn window_commands_are_not_mistaken_for_a_substitution() {
assert_eq!(
parse("sp"),
Ok(Command::Split {
axis: Axis::Vertical
})
);
assert_eq!(
parse("split"),
Ok(Command::Split {
axis: Axis::Vertical
})
);
assert_eq!(
parse("vsplit"),
Ok(Command::Split {
axis: Axis::Horizontal
})
);
assert_eq!(parse("close"), Ok(Command::CloseWindow));
assert_eq!(parse("only"), Ok(Command::OnlyWindow));
}
#[test]
fn a_bare_number_jumps_to_a_line() {
assert_eq!(parse("42"), Ok(Command::GotoLine(41)));
assert_eq!(parse("1"), Ok(Command::GotoLine(0)));
}
#[test]
fn set_accepts_a_value_a_flag_and_a_negated_flag() {
assert_eq!(
parse("set tab_width 2"),
Ok(Command::Set {
key: "tab_width".to_string(),
value: "2".to_string()
})
);
assert_eq!(
parse("set number"),
Ok(Command::Set {
key: "number".to_string(),
value: "true".to_string()
})
);
assert_eq!(
parse("set nonumber"),
Ok(Command::Set {
key: "number".to_string(),
value: "false".to_string()
})
);
}
#[test]
fn substitute_reads_pattern_replacement_and_flags() {
assert_eq!(
parse("%s/foo/bar/g"),
Ok(Command::Substitute {
pattern: "foo".to_string(),
replacement: "bar".to_string(),
all: true,
whole_file: true,
})
);
assert_eq!(
parse("s/foo/bar"),
Ok(Command::Substitute {
pattern: "foo".to_string(),
replacement: "bar".to_string(),
all: false,
whole_file: false,
})
);
}
#[test]
fn substitute_keeps_regex_escapes_but_unescapes_the_delimiter() {
assert_eq!(
parse(r"%s/\d+\/x/n/"),
Ok(Command::Substitute {
pattern: r"\d+/x".to_string(),
replacement: "n".to_string(),
all: false,
whole_file: true,
})
);
}
#[test]
fn set_is_not_mistaken_for_a_substitution() {
assert!(matches!(parse("set number"), Ok(Command::Set { .. })));
}
#[test]
fn unknown_commands_are_reported_by_name() {
assert_eq!(
parse("frobnicate"),
Err("unknown command: frobnicate".into())
);
}
}