use prov::ContentFormat;
use prov::twig::{self, Editor, MarkdownExtensions};
pub const MARKER: &str = "vis";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
Parse(String),
Edit(String),
Residue {
found: String,
},
Malformed {
found: String,
},
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(e) => write!(f, "body could not be parsed for visibility filtering: {e}"),
Self::Edit(e) => write!(f, "visibility filter could not edit the body: {e}"),
Self::Malformed { found } => write!(
f,
"a `{MARKER}` region could not be read as one ({found}) — an inline region \
nested inside another inline region is not supported; use a block region \
(`:::{MARKER}`) for the outer one"
),
Self::Residue { found } => write!(
f,
"a `{MARKER}` marker survived visibility filtering ({found}) — refusing to \
publish a body whose audience regions were not all resolved"
),
}
}
}
impl std::error::Error for Error {}
#[derive(Debug, Clone, Copy)]
pub enum Audience<'a> {
All,
Only(&'a [&'a str]),
}
impl Audience<'_> {
fn admits(&self, declared: &[String]) -> bool {
match self {
Self::All => true,
Self::Only(wanted) => declared.iter().any(|d| {
let d = d.trim();
wanted.iter().any(|w| d.eq_ignore_ascii_case(w.trim()))
}),
}
}
}
pub fn has_visibility_directives(body: &str) -> bool {
body.contains(MARKER)
}
struct Region {
span: std::ops::Range<usize>,
content: Option<std::ops::Range<usize>>,
declared: Vec<String>,
}
fn region_of(node: &twig::FlatNode) -> Option<Region> {
if !matches!(node.kind, twig::Kind::Container) {
return None;
}
let classes: Vec<String> = node
.attrs
.iter()
.find(|(k, _)| k == "class")
.and_then(|(_, v)| v.as_deref())
.map(|v| v.split_whitespace().map(str::to_string).collect())
.unwrap_or_default();
let named = node.name.as_deref() == Some(MARKER);
let classed = classes.iter().any(|c| c == MARKER);
if !named && !classed {
return None;
}
Some(Region {
span: node.span.clone(),
content: node.content_span.clone(),
declared: classes.into_iter().filter(|c| c != MARKER).collect(),
})
}
pub fn filter_body(
body: &str,
format: ContentFormat,
audience: Audience<'_>,
) -> Result<String, Error> {
if !has_visibility_directives(body) {
return Ok(body.to_string());
}
let mut editor = Editor::new_ext(body.as_bytes(), twig_format(format), extensions(format))
.map_err(|e| Error::Parse(format!("{e:?}")))?;
loop {
let nodes = editor.nodes().map_err(|e| Error::Parse(format!("{e:?}")))?;
let Some(region) = nodes.iter().find_map(region_of) else {
break;
};
if region.content.is_none() && region.declared.is_empty() {
let source = editor
.source_str()
.map_err(|e| Error::Edit(format!("{e:?}")))?;
return Err(Error::Malformed {
found: source
.get(region.span.clone())
.unwrap_or("?")
.replace('\n', "\\n"),
});
}
let replacement = if audience.admits(®ion.declared) {
match ®ion.content {
Some(interior) => {
let source = editor
.source_str()
.map_err(|e| Error::Edit(format!("{e:?}")))?;
source
.get(interior.clone())
.ok_or_else(|| {
Error::Edit(format!("interior {interior:?} is not a char boundary"))
})?
.to_string()
}
None => String::new(),
}
} else {
String::new()
};
let start = attrs_aware_start(
&editor
.source_str()
.map_err(|e| Error::Edit(format!("{e:?}")))?,
region.span.start,
);
editor
.edit_range(start, region.span.end, &replacement)
.map_err(|e| Error::Edit(format!("{e:?}")))?;
}
let out = editor
.source_str()
.map_err(|e| Error::Edit(format!("{e:?}")))?;
residue_check(&out, format)?;
Ok(out)
}
fn attrs_aware_start(source: &str, span_start: usize) -> usize {
let before = &source[..span_start];
let Some(trimmed) = before.strip_suffix('\n') else {
return span_start;
};
let line_start = trimmed.rfind('\n').map(|i| i + 1).unwrap_or(0);
let line = trimmed[line_start..].trim();
if line.starts_with('{') && line.ends_with('}') && line.len() > 1 {
line_start
} else {
span_start
}
}
fn residue_check(out: &str, format: ContentFormat) -> Result<(), Error> {
let code = prov::code_spans(out, format).unwrap_or_default();
let in_code = |at: usize| code.iter().any(|s| s.contains(&at));
for spelling in [":::vis", "::vis", ":vis["] {
let mut from = 0;
while let Some(rel) = out[from..].find(spelling) {
let at = from + rel;
if !in_code(at) {
let end = out[at..]
.char_indices()
.nth(40)
.map_or(out.len(), |(i, _)| at + i);
return Err(Error::Residue {
found: out[at..end].replace('\n', "\\n"),
});
}
from = at + spelling.len();
}
}
Ok(())
}
fn twig_format(format: ContentFormat) -> twig::Format {
match format {
ContentFormat::Markdown => twig::Format::Markdown,
ContentFormat::Djot => twig::Format::Djot,
ContentFormat::Html => twig::Format::Html,
}
}
fn extensions(format: ContentFormat) -> MarkdownExtensions {
MarkdownExtensions {
directives: matches!(format, ContentFormat::Markdown),
..MarkdownExtensions::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn only<'a>(a: &'a [&'a str]) -> Audience<'a> {
Audience::Only(a)
}
#[test]
fn markdown_keeps_the_matching_region_and_drops_the_rest() {
let body = ":::vis{.public}\nSeen\n:::\n\n:::vis{.family}\nHidden\n:::\n";
let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
assert!(out.contains("Seen"), "{out:?}");
assert!(!out.contains("Hidden"), "{out:?}");
}
#[test]
fn a_region_declaring_several_audiences_matches_any_of_them() {
let body = ":::vis{.family .friends}\nBoth\n:::\n";
for who in ["family", "friends"] {
let out = filter_body(body, ContentFormat::Markdown, only(&[who])).unwrap();
assert!(out.contains("Both"), "{who}: {out:?}");
}
let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
assert!(!out.contains("Both"), "{out:?}");
}
#[test]
fn inline_regions_filter_too() {
let body = "a :vis[keep]{.public} b :vis[drop]{.family} c\n";
let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
assert!(out.contains("keep"), "{out:?}");
assert!(!out.contains("drop"), "{out:?}");
}
#[test]
fn a_marker_inside_a_code_span_is_prose() {
let body = "Write `:::vis{.family}` to scope a region.\n\n:::vis{.family}\nHidden\n:::\n";
let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
assert!(out.contains("Write `:::vis{.family}`"), "{out:?}");
assert!(!out.contains("Hidden"), "{out:?}");
}
#[test]
fn nested_regions_resolve_from_the_inside_out() {
let body = ":::: vis{.public}\nouter\n\n:::vis{.family}\ninner\n:::\n::::\n";
let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
assert!(out.contains("outer"), "{out:?}");
assert!(!out.contains("inner"), "{out:?}");
}
#[test]
fn html_regions_filter_by_class() {
let body = "<div class=\"vis public\">Seen</div>\n<div class=\"vis family\">Hidden</div>\n";
let out = filter_body(body, ContentFormat::Html, only(&["public"])).unwrap();
assert!(out.contains("Seen"), "{out:?}");
assert!(!out.contains("Hidden"), "{out:?}");
}
#[test]
fn djot_regions_take_their_attribute_line_with_them() {
let body = "{.vis .family}\n:::\nHidden\n:::\n";
let out = filter_body(body, ContentFormat::Djot, only(&["public"])).unwrap();
assert!(!out.contains("Hidden"), "{out:?}");
assert!(!out.contains("family"), "the audience name leaked: {out:?}");
}
#[test]
fn all_keeps_every_region_and_removes_every_marker() {
let body = ":::vis{.public}\nA\n:::\n\n:::vis{.family}\nB\n:::\n";
let out = filter_body(body, ContentFormat::Markdown, Audience::All).unwrap();
assert!(out.contains('A') && out.contains('B'), "{out:?}");
assert!(!out.contains("vis"), "{out:?}");
}
#[test]
fn an_empty_audience_list_keeps_nothing() {
let body = ":::vis{.public}\nA\n:::\n";
let out = filter_body(body, ContentFormat::Markdown, only(&[])).unwrap();
assert!(!out.contains('A'), "{out:?}");
}
#[test]
fn a_body_with_no_regions_is_returned_unchanged() {
let body = "# Title\n\nJust prose.\n";
let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
assert_eq!(out, body);
}
#[test]
fn the_old_bare_key_spelling_drops_rather_than_leaks() {
let body = ":::vis{public}\nHidden\n:::\n";
let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
assert!(
!out.contains("Hidden"),
"old spelling must not publish: {out:?}"
);
}
#[test]
fn a_nested_inline_region_is_refused_rather_than_half_filtered() {
let body = "A :vis[secret :vis[inner]{.public} end]{.family}\n";
let err = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap_err();
assert!(matches!(err, Error::Malformed { .. }), "{err:?}");
}
#[test]
fn nested_block_regions_are_not_affected_by_that_gap() {
let body = ":::: vis{.public}\nouter\n\n:::vis{.family}\ninner\n:::\n::::\n";
let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
assert!(out.contains("outer") && !out.contains("inner"), "{out:?}");
}
#[test]
fn a_surviving_marker_is_refused() {
let err = residue_check(
"text\n:::vis{.family}\nHidden\n:::\n",
ContentFormat::Markdown,
)
.unwrap_err();
assert!(matches!(err, Error::Residue { .. }), "{err:?}");
}
}