use crate::blocks::{Block, BlockWithContext, FileBlocks, every_block, parse_file};
use crate::fs::FileSystem;
use crate::repo_path::RepoPath;
use crate::validators::{
self, ValidationReport, ValidatorDetector, ValidatorSync, ValidatorType, Violation,
ViolationRange,
};
use anyhow::{Context, anyhow};
use regex::Regex;
use serde::Serialize;
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
pub(crate) struct SameAsValidator<Fs: FileSystem> {
file_system: Arc<Fs>,
}
impl<Fs: FileSystem + 'static> SameAsValidator<Fs> {
pub(super) fn new(file_system: Arc<Fs>) -> Self {
Self { file_system }
}
}
impl<Fs: FileSystem + 'static> ValidatorSync for SameAsValidator<Fs> {
fn validate(
&self,
context: Arc<validators::ValidationContext>,
) -> anyhow::Result<ValidationReport> {
let mut report = ValidationReport::default();
let mut cache: HashMap<RepoPath, FileBlocks> = HashMap::new();
for (file_path, file_blocks) in &context.blocks {
for bwc in &file_blocks.blocks_with_context {
let Some(same_as) = bwc.block.attributes.get("same-as") else {
continue;
};
let mode = parse_mode(&bwc.block)?;
let format = parse_format(&bwc.block)?;
let source_items = canonicalize(
extract_items(&bwc.block, &file_blocks.file_content)?,
&format,
);
let references =
validators::parse_block_references(same_as).with_context(|| {
format!(
"invalid same-as reference on block {}:{} at line {}",
file_path,
bwc.block.name_display(),
bwc.block.start_tag_position_range.start().line,
)
})?;
let mut block_violations = Vec::new();
for (target_file_opt, target_name) in references {
let target_file = target_file_opt.unwrap_or_else(|| file_path.clone());
let target_items = resolve_target_items(
&context,
self.file_system.as_ref(),
&mut cache,
&target_file,
&target_name,
)?;
let Some(target_items) = target_items else {
block_violations.push(create_violation(
file_path,
&bwc.block,
&target_file,
&target_name,
"target block not found",
)?);
continue;
};
let reason = match &source_items {
Err(reason) => Some(reason.clone()),
Ok(source) => match canonicalize(target_items, &format) {
Err(reason) => Some(reason),
Ok(target) => disagreement(source, &target, &mode),
},
};
if let Some(reason) = reason {
block_violations.push(create_violation(
file_path,
&bwc.block,
&target_file,
&target_name,
&reason,
)?);
}
}
report.add_all(file_path, &bwc.block, block_violations);
}
}
Ok(report)
}
}
fn extract_items(block: &Block, file_content: &str) -> anyhow::Result<Vec<String>> {
let content = block.content(file_content);
let Some(pattern) = block.attributes.get("same-as-pattern") else {
return Ok(vec![normalize_content(content)]);
};
let regex = Regex::new(pattern)
.map_err(|e| anyhow!("same-as-pattern is not a valid regex ({pattern}): {e}"))?;
Ok(content
.lines()
.filter_map(|line| {
let captures = regex.captures(line.trim())?;
let matched = captures.name("value").or_else(|| captures.get(0))?;
Some(matched.as_str().to_string())
})
.collect())
}
fn normalize_content(content: &str) -> String {
content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.fold(String::new(), |mut normalized, line| {
if !normalized.is_empty() {
normalized.push('\n');
}
normalized.push_str(line);
normalized
})
}
fn extract_named(file_blocks: &FileBlocks, name: &str) -> anyhow::Result<Option<Vec<String>>> {
for bwc in &file_blocks.blocks_with_context {
if bwc.block.name() == Some(name) {
return Ok(Some(extract_items(&bwc.block, &file_blocks.file_content)?));
}
}
Ok(None)
}
fn resolve_target_items<Fs: FileSystem>(
context: &validators::ValidationContext,
file_system: &Fs,
cache: &mut HashMap<RepoPath, FileBlocks>,
target_file: &RepoPath,
target_name: &str,
) -> anyhow::Result<Option<Vec<String>>> {
if let Some(file_blocks) = context.blocks.get(target_file)
&& let Some(items) = extract_named(file_blocks, target_name)?
{
return Ok(Some(items));
}
let file_blocks = match cache.entry(target_file.clone()) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
let parsed = parse_file(
file_system,
target_file,
&[],
every_block,
context.parsers(),
&HashMap::new(),
)?
.ok_or_else(|| {
anyhow!(
"same-as target file format is unsupported: {}",
target_file.display()
)
})?;
entry.insert(parsed)
}
};
extract_named(file_blocks, target_name)
}
enum Format {
Verbatim,
Numeric,
}
fn parse_format(block: &Block) -> anyhow::Result<Format> {
match block.attributes.get("same-as-format").map(String::as_str) {
None => Ok(Format::Verbatim),
Some("numeric") => Ok(Format::Numeric),
Some(other) => Err(anyhow!(
"invalid same-as-format \"{other}\" (expected numeric)"
)),
}
}
fn canonicalize(items: Vec<String>, format: &Format) -> Result<Vec<String>, String> {
match format {
Format::Verbatim => Ok(items),
Format::Numeric => items
.into_iter()
.map(|item| {
item.parse::<f64>()
.map(|number| number.to_string())
.map_err(|_| format!("same-as-format=numeric but \"{item}\" is not a number"))
})
.collect(),
}
}
enum Mode {
Set,
Sequence,
Single,
Subset,
}
fn parse_mode(block: &Block) -> anyhow::Result<Mode> {
match block.attributes.get("same-as-mode").map(String::as_str) {
None | Some("set") => Ok(Mode::Set),
Some("sequence") => Ok(Mode::Sequence),
Some("single") => Ok(Mode::Single),
Some("subset") => Ok(Mode::Subset),
Some(other) => Err(anyhow!(
"invalid same-as-mode \"{other}\" (expected set, sequence, single, or subset)"
)),
}
}
fn disagreement(source: &[String], target: &[String], mode: &Mode) -> Option<String> {
match mode {
Mode::Single => {
if source.len() != 1 || target.len() != 1 {
return Some(format!(
"same-as-mode=single requires exactly one value per side (got {} and {})",
source.len(),
target.len()
));
}
(source[0] != target[0]).then(|| format!("{} != {}", source[0], target[0]))
}
Mode::Sequence => (source != target).then(|| format!("{source:?} != {target:?}")),
Mode::Set => {
let source_set: HashSet<&String> = source.iter().collect();
let target_set: HashSet<&String> = target.iter().collect();
(source_set != target_set).then(|| format!("{source:?} != {target:?}"))
}
Mode::Subset => {
let target_set: HashSet<&String> = target.iter().collect();
let missing: Vec<&String> = source
.iter()
.filter(|item| !target_set.contains(item))
.collect();
(!missing.is_empty()).then(|| format!("not a subset; missing from target: {missing:?}"))
}
}
}
#[derive(Serialize)]
struct SameAsViolation<'a> {
target_file: &'a RepoPath,
target_name: &'a str,
reason: &'a str,
}
fn create_violation(
file_path: &RepoPath,
block: &Block,
target_file: &RepoPath,
target_name: &str,
reason: &str,
) -> anyhow::Result<Violation> {
let line = block.start_tag_position_range.start().line;
let message = format!(
"Block {}:{} at line {} disagrees with {}:{}: {reason}",
file_path.display(),
block.name_display(),
line,
target_file.display(),
target_name,
);
Ok(Violation::new(
ViolationRange::new(
block.start_tag_position_range.start().clone(),
block.start_tag_position_range.end().clone(),
),
"same-as".to_string(),
message,
block.severity()?,
Some(serde_json::to_value(SameAsViolation {
target_file,
target_name,
reason,
})?),
))
}
pub(crate) struct SameAsValidatorDetector();
impl SameAsValidatorDetector {
pub fn new() -> Self {
Self()
}
}
impl<Fs: FileSystem + 'static> ValidatorDetector<Fs> for SameAsValidatorDetector {
fn detect(
&self,
block_with_context: &BlockWithContext,
file_system: &Arc<Fs>,
) -> anyhow::Result<Option<ValidatorType>> {
if block_with_context.block.attributes.contains_key("same-as") {
Ok(Some(ValidatorType::Sync(Box::new(SameAsValidator::new(
Arc::clone(file_system),
)))))
} else {
Ok(None)
}
}
}
#[cfg(test)]
mod validate_tests {
use super::*;
use crate::diff_parser::LineChange;
use crate::fs::test_utils::FakeFileSystem;
use crate::repo_path::RepoPath;
use crate::test_utils::validation_context;
use crate::test_utils::validation_context_with_changes;
use crate::test_utils::{checked_lines, merge_validation_contexts, violation_count};
fn validator(files: &[(&str, &str)]) -> SameAsValidator<FakeFileSystem> {
let map = files
.iter()
.map(|(p, c)| (p.to_string(), c.to_string()))
.collect();
SameAsValidator::new(Arc::new(FakeFileSystem::new(map)))
}
#[test]
fn block_with_same_as_attribute_runs_without_violations() -> anyhow::Result<()> {
let context = validation_context(
"config.py",
"# <block same-as=\":b\">\nvalue = 10\n# </block>\n# <block name=\"b\">\nvalue = 10\n# </block>",
);
assert!(validator(&[]).validate(context)?.violations.is_empty());
Ok(())
}
#[test]
fn same_file_equal_content_passes() -> anyhow::Result<()> {
let context = validation_context(
"config.py",
"# <block same-as=\":b\">\nvalue = 10\n# </block>\n# <block name=\"b\">\nvalue = 10\n# </block>",
);
assert!(validator(&[]).validate(context)?.violations.is_empty());
Ok(())
}
#[test]
fn same_file_differing_content_fails() -> anyhow::Result<()> {
let context = validation_context(
"config.py",
"# <block same-as=\":b\">\nvalue = 10\n# </block>\n# <block name=\"b\">\nvalue = 20\n# </block>",
);
let violations = validator(&[]).validate(context)?.violations;
let file = violations
.get(&RepoPath::from_reference("config.py")?)
.unwrap();
assert_eq!(file.len(), 1);
assert_eq!(file[0].code, "same-as");
Ok(())
}
#[test]
fn cross_file_both_in_scope_compares() -> anyhow::Result<()> {
let context = merge_validation_contexts(vec![
validation_context("a.rs", "// <block same-as=\"b.md:doc\">\nX\n// </block>"),
validation_context(
"b.md",
"[//]: # (<block name=\"doc\">)\n\nX\n\n[//]: # (</block>)",
),
]);
assert!(validator(&[]).validate(context)?.violations.is_empty());
Ok(())
}
#[test]
fn missing_target_block_reports_violation() -> anyhow::Result<()> {
let source = "# <block same-as=\":nope\">\nvalue = 10\n# </block>";
let context = validation_context("config.py", source);
let violations = validator(&[("config.py", source)])
.validate(context)?
.violations;
assert_eq!(
violations
.get(&RepoPath::from_reference("config.py")?)
.unwrap()
.len(),
1
);
Ok(())
}
#[test]
fn resolves_out_of_scope_target_from_injected_fs() -> anyhow::Result<()> {
let context = validation_context("a.rs", "// <block same-as=\"b.md:doc\">\nX\n// </block>");
let v = validator(&[(
"b.md",
"[//]: # (<block name=\"doc\">)\n\nX\n\n[//]: # (</block>)",
)]);
assert!(v.validate(context)?.violations.is_empty());
Ok(())
}
#[test]
fn in_scope_file_with_unmodified_sibling_target_resolves_from_disk() -> anyhow::Result<()> {
let source = "# <block same-as=\":b\">\nvalue = 10\n# </block>\n# <block name=\"b\">\nvalue = 10\n# </block>";
let context = validation_context_with_changes(
"config.py",
source,
vec![LineChange {
line: 2,
ranges: None,
}],
);
let v = validator(&[("config.py", source)]);
assert!(v.validate(context)?.violations.is_empty());
Ok(())
}
#[test]
fn pattern_set_equality_ignores_order() -> anyhow::Result<()> {
let context = merge_validation_contexts(vec![
validation_context(
"a.rs",
"// <block same-as=\"b.md:langs\" same-as-pattern=\"(?P<value>[a-z]+)\">\ngo\nrust\n// </block>",
),
validation_context(
"b.md",
"[//]: # (<block name=\"langs\" same-as-pattern=\"[a-z]+\">)\n\nrust\ngo\n\n[//]: # (</block>)",
),
]);
assert!(validator(&[]).validate(context)?.violations.is_empty());
Ok(())
}
#[test]
fn pattern_set_mismatch_fails() -> anyhow::Result<()> {
let context = merge_validation_contexts(vec![
validation_context(
"a.rs",
"// <block same-as=\"b.md:langs\" same-as-pattern=\"(?P<value>[a-z]+)\">\ngo\nrust\n// </block>",
),
validation_context(
"b.md",
"[//]: # (<block name=\"langs\" same-as-pattern=\"[a-z]+\">)\n\ngo\n\n[//]: # (</block>)",
),
]);
assert_eq!(validator(&[]).validate(context)?.violations.len(), 1);
Ok(())
}
#[test]
fn sequence_mode_is_order_sensitive() -> anyhow::Result<()> {
let context = merge_validation_contexts(vec![
validation_context(
"a.rs",
"// <block same-as=\"b.md:l\" same-as-pattern=\"(?P<value>[a-z]+)\" same-as-mode=\"sequence\">\ngo\nrust\n// </block>",
),
validation_context(
"b.md",
"[//]: # (<block name=\"l\" same-as-pattern=\"[a-z]+\">)\n\nrust\ngo\n\n[//]: # (</block>)",
),
]);
assert_eq!(validator(&[]).validate(context)?.violations.len(), 1);
Ok(())
}
#[test]
fn single_mode_extra_value_reports_violation() -> anyhow::Result<()> {
let context = validation_context(
"a.rs",
"// <block same-as=\":b\" same-as-pattern=\"(?P<value>[0-9]+)\" same-as-mode=\"single\">\n1\n2\n// </block>\n// <block name=\"b\">\n1\n// </block>",
);
let violations = validator(&[]).validate(context)?.violations;
let file = violations.get(&RepoPath::from_reference("a.rs")?).unwrap();
assert_eq!(file.len(), 1);
assert_eq!(file[0].code, "same-as");
Ok(())
}
#[test]
fn numeric_format_ignores_representation() -> anyhow::Result<()> {
let context = merge_validation_contexts(vec![
validation_context(
"a.yaml",
"# <block same-as=\"b.rs:port\" same-as-pattern=\"(?P<value>[0-9]+)\" same-as-format=\"numeric\" same-as-mode=\"single\">\nport: 8080\n# </block>",
),
validation_context(
"b.rs",
"// <block name=\"port\" same-as-pattern=\"(?P<value>[0-9.]+)\">\n8080.0\n// </block>",
),
]);
assert!(validator(&[]).validate(context)?.violations.is_empty());
Ok(())
}
#[test]
fn numeric_format_non_number_reports_violation() -> anyhow::Result<()> {
let context = validation_context(
"a.rs",
"// <block same-as=\":b\" same-as-pattern=\"(?P<value>\\w+)\" same-as-format=\"numeric\">\nabc\n// </block>\n// <block name=\"b\">\n1\n// </block>",
);
let violations = validator(&[]).validate(context)?.violations;
let file = violations.get(&RepoPath::from_reference("a.rs")?).unwrap();
assert_eq!(file.len(), 1);
assert_eq!(file[0].code, "same-as");
Ok(())
}
#[test]
fn unknown_format_value_errors() -> anyhow::Result<()> {
let context = validation_context(
"a.rs",
"// <block same-as=\":b\" same-as-format=\"number\">\n1\n// </block>\n// <block name=\"b\">\n1\n// </block>",
);
assert!(validator(&[]).validate(context).is_err());
Ok(())
}
#[test]
fn subset_mode_passes_when_contained() -> anyhow::Result<()> {
let context = merge_validation_contexts(vec![
validation_context(
"test.rs",
"// <block same-as=\"src.rs:vars\" same-as-mode=\"subset\" same-as-pattern=\"(?P<value>[A-Z_]+)\">\nAPI_KEY\nAPI_URL\n// </block>",
),
validation_context(
"src.rs",
"// <block name=\"vars\" same-as-pattern=\"(?P<value>[A-Z_]+)\">\nAPI_KEY\nAPI_URL\n// </block>",
),
]);
assert!(validator(&[]).validate(context)?.violations.is_empty());
Ok(())
}
#[test]
fn subset_mode_fails_when_not_contained() -> anyhow::Result<()> {
let context = merge_validation_contexts(vec![
validation_context(
"test.rs",
"// <block same-as=\"src.rs:vars\" same-as-mode=\"subset\" same-as-pattern=\"(?P<value>[A-Z_]+)\">\nAPI_KEY\nEXTRA\n// </block>",
),
validation_context(
"src.rs",
"// <block name=\"vars\" same-as-pattern=\"(?P<value>[A-Z_]+)\">\nAPI_KEY\n// </block>",
),
]);
let violations = validator(&[]).validate(context)?.violations;
let file = violations
.get(&RepoPath::from_reference("test.rs")?)
.unwrap();
assert_eq!(file.len(), 1);
assert_eq!(file[0].code, "same-as");
Ok(())
}
#[test]
fn subset_mode_is_directional() -> anyhow::Result<()> {
let context = merge_validation_contexts(vec![
validation_context(
"test.rs",
"// <block same-as=\"src.rs:vars\" same-as-mode=\"subset\" same-as-pattern=\"(?P<value>[A-Z_]+)\">\nAPI_KEY\nAPI_URL\n// </block>",
),
validation_context(
"src.rs",
"// <block name=\"vars\" same-as-pattern=\"(?P<value>[A-Z_]+)\">\nAPI_KEY\n// </block>",
),
]);
assert_eq!(validator(&[]).validate(context)?.violations.len(), 1);
Ok(())
}
#[test]
fn validate_records_a_check_for_every_examined_block() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block name="source" same-as=":target">
alpha
# </block>
# <block name="target">
alpha
# </block>"#,
);
let report = validator(&[]).validate(context)?;
assert_eq!(checked_lines(&report), vec![1]);
assert_eq!(violation_count(&report), 0);
Ok(())
}
}