use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Dialect {
pub namespace: &'static str,
pub region: Option<&'static str>,
pub trailer: Option<&'static str>,
pub scheme: Option<&'static str>,
}
impl Default for Dialect {
fn default() -> Self {
Dialect {
namespace: "tramlines",
region: None,
trailer: None,
scheme: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Marker {
Open(String),
Close,
Trailer,
}
pub(super) fn comment(line: &str) -> Option<&str> {
line.trim()
.strip_prefix("<!--")
.and_then(|s| s.strip_suffix("-->"))
.map(str::trim)
}
fn names(text: &str, ns: &str, name: &str) -> bool {
text.strip_prefix(ns)
.and_then(|t| t.strip_prefix(':'))
.is_some_and(|t| t.starts_with(name))
}
impl Dialect {
pub fn spell(&self, name: &str) -> String {
format!("{}:{name}", self.namespace)
}
pub fn marker(&self, line: &str) -> Option<Marker> {
let inner = comment(line)?;
if let Some(region) = self.region {
if inner == format!("/{}", self.spell(region)) {
return Some(Marker::Close);
}
if let Some(target) = inner.strip_prefix(&self.spell(region)) {
let target = target.trim();
if target.is_empty() || target.contains(char::is_whitespace) {
return None;
}
return Some(Marker::Open(target.to_string()));
}
}
if self.trailer.is_some_and(|t| inner == self.spell(t)) {
return Some(Marker::Trailer);
}
None
}
pub fn is_only_marker(&self, line: &str) -> bool {
let only = format!("{} ", self.spell("only"));
if let Some(inner) = comment(line) {
return inner.starts_with(&only);
}
let t = line.trim();
match t.rfind('#') {
Some(i) => t[i + 1..].trim().starts_with(&only),
None => false,
}
}
pub fn is_guidance_marker(&self, line: &str) -> bool {
if let Some(inner) = comment(line) {
return names(inner, self.namespace, "guidance");
}
line.trim()
.strip_prefix('#')
.is_some_and(|rest| names(rest.trim_start(), self.namespace, "guidance"))
}
pub fn is_optional_marker(&self, line: &str) -> bool {
comment(line) == Some(self.spell("optional").as_str())
}
pub fn is_optional_close(&self, line: &str) -> bool {
comment(line).and_then(|c| c.strip_prefix('/')) == Some(self.spell("optional").as_str())
}
pub fn is_optional_mark(&self, line: &str) -> bool {
self.is_optional_marker(line) || self.is_optional_close(line)
}
pub fn only_marker(&self, line: &str) -> Option<BTreeMap<String, Vec<String>>> {
if !self.is_only_marker(line) {
return None;
}
let only = format!("{} ", self.spell("only"));
let i = line.find(&only)?;
let rest = line[i + only.len()..].trim_end_matches("-->").trim();
let mut map = BTreeMap::new();
for pair in rest.split_whitespace() {
let (k, v) = pair.split_once('=')?;
if k.is_empty() || v.is_empty() {
return None;
}
map.insert(k.to_string(), v.split(',').map(str::to_string).collect());
}
Some(map)
}
}
#[cfg(test)]
mod tests {
use super::*;
const HOST: Dialect = Dialect {
namespace: "host",
region: Some("include"),
trailer: Some("links"),
scheme: Some("host"),
};
#[test]
fn markers() {
let m = |l: &str| HOST.marker(l);
assert_eq!(m("<!-- host:include x -->"), Some(Marker::Open("x".into())));
assert_eq!(
m(" <!--host:include /a/b.rs#r-->"),
Some(Marker::Open("/a/b.rs#r".into()))
);
assert_eq!(m("<!-- /host:include -->"), Some(Marker::Close));
assert_eq!(m("<!-- host:links -->"), Some(Marker::Trailer));
assert_eq!(m("<!-- host:include -->"), None);
assert_eq!(m("<!-- host:include a b -->"), None);
assert_eq!(m("<!-- host:includes x -->"), None);
assert_eq!(m("text <!-- host:links -->"), None);
let plain = Dialect::default();
assert_eq!(plain.marker("<!-- host:include x -->"), None);
assert_eq!(plain.marker("<!-- tramlines:links -->"), None);
}
#[test]
fn only_markers() {
assert!(HOST.is_only_marker("<!-- host:only state=closed -->"));
assert!(HOST.is_only_marker("resolution: none # host:only state=closed"));
assert!(!HOST.is_only_marker("<!-- host:links -->"));
assert!(!HOST.is_only_marker("plain # comment"));
assert!(!HOST.is_only_marker("plain"));
assert!(HOST.is_optional_marker("<!-- host:optional -->"));
assert!(HOST.is_optional_close("<!-- /host:optional -->"));
assert!(HOST.is_optional_mark("<!-- /host:optional -->"));
assert!(!HOST.is_optional_marker("<!-- /host:optional -->"));
assert!(!HOST.is_optional_close("<!-- host:optional -->"));
assert!(!HOST.is_optional_marker("<!-- host:only state=closed -->"));
assert!(!HOST.is_optional_marker("<!-- host:optionally -->"));
assert!(!HOST.is_only_marker("<!-- host:optional -->"));
assert!(!Dialect::default().is_only_marker("<!-- host:only state=closed -->"));
assert!(Dialect::default().is_only_marker("<!-- tramlines:only state=closed -->"));
assert_eq!(
HOST.only_marker("<!-- host:only state=open,done kind=bug -->"),
Some(BTreeMap::from([
("kind".to_string(), vec!["bug".to_string()]),
(
"state".to_string(),
vec!["open".to_string(), "done".to_string()]
),
]))
);
}
#[test]
fn guidance_markers() {
assert!(HOST.is_guidance_marker("<!-- host:guidance Say what the part is for. -->"));
assert!(HOST.is_guidance_marker(" # host:guidance The states, in order."));
assert!(!HOST.is_guidance_marker("<!-- host:guidance spans"));
assert!(!HOST.is_guidance_marker("title: x # host:guidance trailing"));
assert!(!HOST.is_guidance_marker("<!-- host:only state=done -->"));
}
}