use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::iter::Peekable;
use std::str::Chars;
use thiserror::Error;
const MAX_PATH_FIELD_SEGMENTS: usize = 64;
const MAX_SECTION_DEPTH: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TemplateError {
#[error("template nesting exceeds the maximum depth of {limit}")]
NestingTooDeep { limit: usize },
#[error("template literal contains control byte {byte:#04x}")]
ControlByte { byte: u8 },
#[error("template has an unterminated '${{' field (missing '}}')")]
UnterminatedField,
#[error("template has an unclosed '[' section (missing ']')")]
UnclosedSection,
}
#[derive(Debug, Clone)]
pub struct Template {
parts: Vec<Part>,
}
#[derive(Debug, Clone)]
enum Part {
Literal(String),
Field {
names: Vec<String>,
raw: bool,
},
Section(Vec<Part>),
}
impl Template {
pub fn parse(template: &str) -> Result<Template, TemplateError> {
let mut chars = template.chars().peekable();
let parts = parse_parts(&mut chars, 0)?;
Ok(Template { parts })
}
pub fn referenced_fields(&self) -> BTreeSet<String> {
let mut out = BTreeSet::new();
collect_field_names(&self.parts, &mut out);
out
}
pub fn render(
&self,
fields: &BTreeMap<String, &str>,
fallbacks: &BTreeMap<String, String>,
default_fallback: &str,
ext: &str,
) -> String {
let (mut out, _, _) = render_parts(&self.parts, fields, fallbacks, default_fallback, false);
out.push('.');
out.push_str(ext);
out
}
pub fn render_checked(
&self,
fields: &BTreeMap<String, &str>,
fallbacks: &BTreeMap<String, String>,
ext: &str,
) -> Option<String> {
let (mut out, _, top_complete) = render_parts(&self.parts, fields, fallbacks, "", false);
if !top_complete {
return None;
}
out.push('.');
out.push_str(ext);
Some(out)
}
}
fn parse_parts(chars: &mut Peekable<Chars>, depth: usize) -> Result<Vec<Part>, TemplateError> {
let mut parts = Vec::new();
let mut literal = String::new();
let mut closed = false;
while let Some(&c) = chars.peek() {
match c {
']' if depth > 0 => {
chars.next(); closed = true;
break;
}
'[' => {
chars.next();
push_literal(&mut parts, &mut literal);
if depth + 1 > MAX_SECTION_DEPTH {
return Err(TemplateError::NestingTooDeep {
limit: MAX_SECTION_DEPTH,
});
}
let inner = parse_parts(chars, depth + 1)?;
parts.push(Part::Section(inner));
}
'$' => {
chars.next(); match chars.peek() {
Some('[') => {
chars.next();
literal.push('[');
}
Some(']') => {
chars.next();
literal.push(']');
}
Some('{') => {
chars.next();
let names = parse_braced_names(chars)?;
push_literal(&mut parts, &mut literal);
parts.push(Part::Field { names, raw: false });
}
Some('!') => {
chars.next(); if chars.peek() == Some(&'{') {
chars.next(); let names = parse_braced_names(chars)?;
push_literal(&mut parts, &mut literal);
parts.push(Part::Field { names, raw: true });
} else {
literal.push('$');
literal.push('!');
}
}
Some(&nc) if is_field_char(nc) => {
let name = parse_unbraced_name(chars);
push_literal(&mut parts, &mut literal);
parts.push(Part::Field {
names: vec![name],
raw: false,
});
}
_ => literal.push('$'),
}
}
_ => {
if (c as u32) < 0x20 {
return Err(TemplateError::ControlByte { byte: c as u8 });
}
literal.push(c);
chars.next();
}
}
}
push_literal(&mut parts, &mut literal);
if depth > 0 && !closed {
return Err(TemplateError::UnclosedSection);
}
Ok(parts)
}
fn push_literal(parts: &mut Vec<Part>, literal: &mut String) {
if !literal.is_empty() {
parts.push(Part::Literal(std::mem::take(literal)));
}
}
fn parse_braced_names(chars: &mut Peekable<Chars>) -> Result<Vec<String>, TemplateError> {
let mut content = String::new();
let mut closed = false;
for nc in chars.by_ref() {
if nc == '}' {
closed = true;
break;
}
content.push(nc);
}
if !closed {
return Err(TemplateError::UnterminatedField);
}
Ok(content.split('|').map(str::to_ascii_lowercase).collect())
}
fn parse_unbraced_name(chars: &mut Peekable<Chars>) -> String {
let mut name = String::new();
while let Some(&nc) = chars.peek() {
if is_field_char(nc) {
name.push(nc);
chars.next();
} else {
break;
}
}
name.to_ascii_lowercase()
}
fn collect_field_names(parts: &[Part], out: &mut BTreeSet<String>) {
for part in parts {
match part {
Part::Literal(_) => {}
Part::Field { names, .. } => {
for name in names {
out.insert(name.clone());
}
}
Part::Section(inner) => collect_field_names(inner, out),
}
}
}
fn render_parts(
parts: &[Part],
fields: &BTreeMap<String, &str>,
fallbacks: &BTreeMap<String, String>,
default_fallback: &str,
in_section: bool,
) -> (String, bool, bool) {
let mut out = String::new();
let mut any_present = false;
let mut top_complete = true;
for part in parts {
match part {
Part::Literal(lit) => out.push_str(lit),
Part::Field { names, raw: false } => {
if let Some(value) = resolve_plain(names, fields, fallbacks) {
sanitize_into(&mut out, value);
any_present = true;
} else if !in_section {
sanitize_into(&mut out, default_fallback);
top_complete = false;
}
}
Part::Field { names, raw: true } => {
if let Some(path) = resolve_path(names, fields, fallbacks) {
out.push_str(&path);
any_present = true;
} else if !in_section {
sanitize_into(&mut out, default_fallback);
top_complete = false;
}
}
Part::Section(inner) => {
let (text, present, _) =
render_parts(inner, fields, fallbacks, default_fallback, true);
if present {
out.push_str(&text);
any_present = true;
}
}
}
}
(out, any_present, top_complete)
}
fn resolve_plain<'a>(
names: &[String],
fields: &BTreeMap<String, &'a str>,
fallbacks: &'a BTreeMap<String, String>,
) -> Option<&'a str> {
for name in names {
if let Some(v) = fields.get(name).copied().filter(|v| !v.is_empty()) {
return Some(v);
}
if let Some(v) = fallbacks
.get(name)
.map(String::as_str)
.filter(|v| !v.is_empty())
{
return Some(v);
}
}
None
}
fn resolve_path(
names: &[String],
fields: &BTreeMap<String, &str>,
fallbacks: &BTreeMap<String, String>,
) -> Option<String> {
for name in names {
let value = fields
.get(name)
.copied()
.or_else(|| fallbacks.get(name).map(String::as_str));
if let Some(value) = value {
let path = sanitize_path(value);
if !path.is_empty() {
return Some(path);
}
}
}
None
}
fn sanitize_into(out: &mut String, value: &str) {
for c in value.chars() {
if c == '/' || (c as u32) < 0x20 {
out.push('_');
} else {
out.push(c);
}
}
}
fn sanitize_path(value: &str) -> String {
let mut out = String::new();
let mut count = 0usize;
for segment in value.split('/') {
if count == MAX_PATH_FIELD_SEGMENTS {
break;
}
if segment.is_empty() || segment == "." || segment == ".." {
continue;
}
if !out.is_empty() {
out.push('/');
}
sanitize_into(&mut out, segment);
count += 1;
}
out
}
fn is_field_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn referenced_fields_collects_plain_path_section_and_fallback_names() {
let t = Template::parse("$artist/$!{beets_path}/[$disc - ]${title|name}")
.expect("valid template");
let f = t.referenced_fields();
assert!(f.contains("artist"));
assert!(f.contains("beets_path"));
assert!(f.contains("disc"));
assert!(f.contains("title"));
assert!(f.contains("name"));
assert_eq!(f.len(), 5);
}
#[test]
fn nesting_at_limit_parses_one_past_limit_rejected() {
let at_limit = "[".repeat(MAX_SECTION_DEPTH) + &"]".repeat(MAX_SECTION_DEPTH);
assert!(
Template::parse(&at_limit).is_ok(),
"{MAX_SECTION_DEPTH} deep parses"
);
let past_limit = "[".repeat(MAX_SECTION_DEPTH + 1);
assert!(matches!(
Template::parse(&past_limit),
Err(TemplateError::NestingTooDeep { limit }) if limit == MAX_SECTION_DEPTH
));
}
}