use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct TemplateContext {
pub workspace: PathBuf,
pub current_file: Option<PathBuf>,
pub cursor_line: Option<usize>,
pub cursor_col: Option<usize>,
pub selection: Option<String>,
}
impl TemplateContext {
pub fn workspace_only(workspace: PathBuf) -> Self {
Self {
workspace,
current_file: None,
cursor_line: None,
cursor_col: None,
selection: None,
}
}
}
pub fn expand(template: &str, ctx: &TemplateContext) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find("{{") {
out.push_str(&rest[..open]);
let after_open = &rest[open + 2..];
if let Some(close) = after_open.find("}}") {
let token = &after_open[..close];
let replaced = substitute(token, ctx);
match replaced {
Some(value) => out.push_str(&value),
None => {
out.push_str("{{");
out.push_str(token);
out.push_str("}}");
}
}
rest = &after_open[close + 2..];
} else {
out.push_str("{{");
rest = after_open;
}
}
out.push_str(rest);
out
}
fn substitute(token: &str, ctx: &TemplateContext) -> Option<String> {
if token.starts_with("prompt:") {
return None;
}
match token {
"workspace" => Some(ctx.workspace.to_string_lossy().into_owned()),
"workspace_name" => Some(
ctx.workspace
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
),
"current_file" => Some(
ctx.current_file
.as_ref()
.map(|p| relative_or_abs(p, &ctx.workspace))
.unwrap_or_default(),
),
"current_file_abs" => Some(
ctx.current_file
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
),
"current_file_dir" => Some(
ctx.current_file
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
),
"cursor_line" => Some(ctx.cursor_line.map(|n| n.to_string()).unwrap_or_default()),
"cursor_col" => Some(ctx.cursor_col.map(|n| n.to_string()).unwrap_or_default()),
"selection" => Some(ctx.selection.clone().unwrap_or_default()),
_ => None,
}
}
fn relative_or_abs(p: &Path, workspace: &Path) -> String {
match p.strip_prefix(workspace) {
Ok(rel) => rel.to_string_lossy().into_owned(),
Err(_) => p.to_string_lossy().into_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> TemplateContext {
TemplateContext {
workspace: PathBuf::from("/proj/foo"),
current_file: Some(PathBuf::from("/proj/foo/src/main.rs")),
cursor_line: Some(42),
cursor_col: Some(7),
selection: Some("hello".to_string()),
}
}
#[test]
fn expands_workspace() {
assert_eq!(expand("cd {{workspace}}", &ctx()), "cd /proj/foo");
}
#[test]
fn expands_workspace_name() {
assert_eq!(expand("{{workspace_name}}", &ctx()), "foo");
}
#[test]
fn expands_current_file_relative_to_workspace() {
assert_eq!(expand("{{current_file}}", &ctx()), "src/main.rs");
}
#[test]
fn expands_current_file_abs() {
assert_eq!(
expand("{{current_file_abs}}", &ctx()),
"/proj/foo/src/main.rs"
);
}
#[test]
fn expands_current_file_dir() {
assert_eq!(expand("{{current_file_dir}}", &ctx()), "/proj/foo/src");
}
#[test]
fn expands_cursor_position() {
assert_eq!(
expand("{{current_file}}:{{cursor_line}}:{{cursor_col}}", &ctx()),
"src/main.rs:42:7"
);
}
#[test]
fn expands_selection() {
assert_eq!(expand("echo {{selection}}", &ctx()), "echo hello");
}
#[test]
fn empty_current_file_when_none() {
let mut c = ctx();
c.current_file = None;
assert_eq!(expand("code {{current_file}}", &c), "code ");
}
#[test]
fn unknown_token_stays_literal() {
assert_eq!(expand("run {{typo}} now", &ctx()), "run {{typo}} now");
}
#[test]
fn prompt_token_stays_literal() {
assert_eq!(
expand("diff {{prompt:target}}", &ctx()),
"diff {{prompt:target}}"
);
}
#[test]
fn unmatched_open_brace_stays_literal() {
assert_eq!(expand("weird {{ no close", &ctx()), "weird {{ no close");
}
#[test]
fn no_tokens_passes_through() {
assert_eq!(expand("just a plain string", &ctx()), "just a plain string");
}
#[test]
fn empty_input() {
assert_eq!(expand("", &ctx()), "");
}
#[test]
fn multiple_same_token() {
assert_eq!(
expand("{{workspace}}/{{workspace}}", &ctx()),
"/proj/foo//proj/foo"
);
}
#[test]
fn current_file_outside_workspace_falls_back_to_abs() {
let mut c = ctx();
c.current_file = Some(PathBuf::from("/tmp/outside.rs"));
assert_eq!(expand("{{current_file}}", &c), "/tmp/outside.rs");
}
#[test]
fn workspace_only_context_leaves_editor_vars_empty() {
let c = TemplateContext::workspace_only(PathBuf::from("/proj/bar"));
assert_eq!(
expand("{{workspace}} {{current_file}} {{cursor_line}}", &c),
"/proj/bar "
);
}
}