use crate::blocks::{Block, BlockWithContext, FileBlocks, every_block, parse_file};
use crate::fs::FileSystem;
use crate::repo_path::RepoPath;
use crate::validators;
use crate::validators::{
BlockReference, ValidationReport, ValidatorType, Violation, ViolationRange,
};
use anyhow::{Context, anyhow};
use serde::Serialize;
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::sync::Arc;
pub(crate) struct AffectsValidator<Fs: FileSystem> {
file_system: Arc<Fs>,
}
impl<Fs: FileSystem + 'static> AffectsValidator<Fs> {
pub(super) fn new(file_system: Arc<Fs>) -> Self {
Self { file_system }
}
}
#[derive(Serialize)]
struct AffectsViolation<'a> {
affected_block_file_path: &'a RepoPath,
#[serde(skip_serializing_if = "Option::is_none")]
affected_block_name: Option<&'a str>,
}
impl<Fs: FileSystem + 'static> validators::ValidatorSync for AffectsValidator<Fs> {
fn validate(
&self,
context: Arc<validators::ValidationContext>,
) -> anyhow::Result<ValidationReport> {
let mut targets = TargetIndex::new(&context, self.file_system.as_ref());
let mut report = ValidationReport::default();
for (file_path, file_blocks) in &context.blocks {
for block_with_context in &file_blocks.blocks_with_context {
let Some(affects) = block_with_context.block.attributes.get("affects") else {
continue;
};
let violations =
reference_violations(&mut targets, file_path, block_with_context, affects)?;
report.add_all(file_path, &block_with_context.block, violations);
}
}
Ok(report)
}
}
struct TargetIndex<'a, Fs: FileSystem> {
context: &'a validators::ValidationContext,
file_system: &'a Fs,
in_scope: HashMap<&'a RepoPath, HashMap<String, bool>>,
read: HashMap<RepoPath, HashMap<String, bool>>,
}
impl<'a, Fs: FileSystem> TargetIndex<'a, Fs> {
fn new(context: &'a validators::ValidationContext, file_system: &'a Fs) -> Self {
Self {
context,
file_system,
in_scope: context
.blocks
.iter()
.map(|(file_path, file_blocks)| (file_path, name_index(file_blocks)))
.collect(),
read: HashMap::new(),
}
}
fn block_modified(
&mut self,
target_file: &RepoPath,
target_name: &str,
) -> anyhow::Result<Option<bool>> {
if let Some(index) = self.in_scope.get(target_file)
&& let Some(&modified) = index.get(target_name)
{
return Ok(Some(modified));
}
let line_changes = self.context.line_changes_for(target_file).unwrap_or(&[]);
let index = match self.read.entry(target_file.clone()) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
let parsed = parse_file(
self.file_system,
target_file.as_path(),
line_changes,
every_block,
self.context.parsers(),
self.context.extra_file_extensions(),
)?
.ok_or_else(|| {
anyhow!(
"affects target file format is unsupported: {}",
target_file.display()
)
})?;
entry.insert(name_index(&parsed))
}
};
Ok(index.get(target_name).copied())
}
fn file_modified(&self, target_file: &RepoPath) -> bool {
self.context.line_changes_for(target_file).is_some()
}
fn file_exists(&self, target_file: &RepoPath) -> bool {
self.file_system.exists(target_file.as_path())
}
}
fn reference_violations<Fs: FileSystem>(
targets: &mut TargetIndex<'_, Fs>,
file_path: &RepoPath,
block_with_context: &BlockWithContext,
affects: &str,
) -> anyhow::Result<Vec<Violation>> {
let mut violations = Vec::new();
for reference in parse_references(file_path, &block_with_context.block, affects)? {
let violation = match reference {
BlockReference::Block { file, name } => block_reference_violation(
targets,
file_path,
block_with_context,
&file.unwrap_or_else(|| file_path.clone()),
&name,
)?,
BlockReference::File(target_file) => {
file_reference_violation(targets, file_path, block_with_context, &target_file)?
}
};
violations.extend(violation);
}
Ok(violations)
}
fn parse_references(
file_path: &RepoPath,
block: &Block,
affects: &str,
) -> anyhow::Result<Vec<BlockReference>> {
validators::parse_block_references(affects).with_context(|| {
format!(
"invalid affects reference on block {}:{} at line {}",
file_path,
block.name_display(),
block.start_tag_position_range.start().line,
)
})
}
fn block_reference_violation<Fs: FileSystem>(
targets: &mut TargetIndex<'_, Fs>,
file_path: &RepoPath,
block_with_context: &BlockWithContext,
target_file: &RepoPath,
target_name: &str,
) -> anyhow::Result<Option<Violation>> {
let Some(target_modified) = targets.block_modified(target_file, target_name)? else {
return Ok(Some(dangling_reference_violation(
file_path,
&block_with_context.block,
target_file,
Some(target_name),
)?));
};
if block_with_context.is_content_modified && !target_modified {
return Ok(Some(create_violation(
file_path,
&block_with_context.block,
target_file,
Some(target_name),
)?));
}
Ok(None)
}
fn file_reference_violation<Fs: FileSystem>(
targets: &TargetIndex<'_, Fs>,
file_path: &RepoPath,
block_with_context: &BlockWithContext,
target_file: &RepoPath,
) -> anyhow::Result<Option<Violation>> {
if !targets.file_exists(target_file) {
return Err(anyhow!(
"affects target file does not exist: {}",
target_file.display()
));
}
if block_with_context.is_content_modified && !targets.file_modified(target_file) {
return Ok(Some(create_violation(
file_path,
&block_with_context.block,
target_file,
None,
)?));
}
Ok(None)
}
fn name_index(file_blocks: &FileBlocks) -> HashMap<String, bool> {
let mut index = HashMap::new();
for block in &file_blocks.blocks_with_context {
if let Some(name) = block.block.name() {
index
.entry(name.to_string())
.or_insert(block.is_content_modified);
}
}
index
}
pub(crate) struct AffectsValidatorDetector();
impl AffectsValidatorDetector {
pub fn new() -> Self {
Self {}
}
}
impl<Fs: FileSystem + 'static> validators::ValidatorDetector<Fs> for AffectsValidatorDetector {
fn detect(
&self,
block_with_context: &BlockWithContext,
file_system: &Arc<Fs>,
) -> anyhow::Result<Option<ValidatorType>> {
if block_with_context.block.attributes.contains_key("affects") {
Ok(Some(ValidatorType::Sync(Box::new(AffectsValidator::new(
Arc::clone(file_system),
)))))
} else {
Ok(None)
}
}
}
fn create_violation(
modified_block_file_path: &RepoPath,
modified_block: &Block,
affected_block_file_path: &RepoPath,
affected_block_name: Option<&str>,
) -> anyhow::Result<Violation> {
let message = format!(
"Block {}:{} at line {} is modified, but {} is not",
modified_block_file_path.display(),
modified_block.name_display(),
modified_block.start_tag_position_range.start().line,
target_display(affected_block_file_path, affected_block_name),
);
affects_violation(
modified_block,
affected_block_file_path,
affected_block_name,
message,
)
}
fn dangling_reference_violation(
referencing_block_file_path: &RepoPath,
referencing_block: &Block,
target_file_path: &RepoPath,
target_name: Option<&str>,
) -> anyhow::Result<Violation> {
let message = format!(
"Block {}:{} at line {} references {}, which does not exist",
referencing_block_file_path.display(),
referencing_block.name_display(),
referencing_block.start_tag_position_range.start().line,
target_display(target_file_path, target_name),
);
affects_violation(referencing_block, target_file_path, target_name, message)
}
fn target_display(file_path: &RepoPath, name: Option<&str>) -> String {
match name {
Some(name) => format!("{}:{}", file_path.display(), name),
None => format!("file {}", file_path.display()),
}
}
fn affects_violation(
block: &Block,
affected_block_file_path: &RepoPath,
affected_block_name: Option<&str>,
message: String,
) -> anyhow::Result<Violation> {
let details = serde_json::to_value(AffectsViolation {
affected_block_file_path,
affected_block_name,
})
.context("failed to serialize AffectsViolation block")?;
Ok(Violation::new(
ViolationRange::new(
block.start_tag_position_range.start().clone(),
block.start_tag_position_range.end().clone(),
),
"affects".to_string(),
message,
block.severity()?,
Some(details),
))
}
#[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::{
checked_lines, merge_validation_contexts, validation_context,
validation_context_with_changes, violation_count,
};
use crate::validators::ValidatorSync;
fn validator(files: &[(&str, &str)]) -> AffectsValidator<FakeFileSystem> {
let map = files
.iter()
.map(|(p, c)| (p.to_string(), c.to_string()))
.collect();
AffectsValidator::new(Arc::new(FakeFileSystem::new(map)))
}
fn two_file_system() -> FakeFileSystem {
FakeFileSystem::new(HashMap::from([
(
"source.py".to_string(),
"# <block name=\"s\" affects=\"target.py:t\">\nvalue = 2\n# </block>".to_string(),
),
(
"target.py".to_string(),
"# <block name=\"t\">\nvalue = 2\n# </block>".to_string(),
),
]))
}
fn context_scoped_to_source(
file_system: &FakeFileSystem,
line_changes: HashMap<RepoPath, Vec<LineChange>>,
) -> anyhow::Result<Arc<validators::ValidationContext>> {
let parsers = crate::language_parsers::language_parsers()?;
let parsed = crate::blocks::parse_blocks(
&line_changes,
crate::blocks::ScanMode::OnlyChanged,
file_system,
&crate::fs::test_utils::FakePathChecker::allow_only("source.py"),
&parsers,
&HashMap::new(),
)?;
assert!(
!parsed
.blocks
.contains_key(&RepoPath::from_reference("target.py")?),
"the glob must keep the target out of the validated set"
);
Ok(Arc::new(validators::ValidationContext::new(
parsed.blocks,
parsers,
line_changes,
HashMap::new(),
)))
}
#[test]
fn modified_block_with_unmodified_targets_returns_violations() -> anyhow::Result<()> {
let validator = validator(&[]);
let context = merge_validation_contexts(vec![
validation_context(
"file1.py",
r#"# <block affects="file2.py:foo">
print("first")
# </block>
# <block affects="file3.py:bar">
print("second")
# </block>
"#,
),
validation_context_with_changes(
"file2.py",
r#"# <block name="foo">
print("file2")
# </block>
"#,
vec![LineChange {
line: 1, ranges: Some(vec![3..8, 10..15]),
}],
),
validation_context_with_changes(
"file3.py",
r#"# <block name="bar">
print("file3")
# </block>
"#,
vec![LineChange {
line: 1, ranges: Some(vec![3..8, 10..15]),
}],
),
]);
let violations = validator.validate(context)?.violations;
assert_eq!(violations.len(), 1);
let file1_violations = violations
.get(&RepoPath::from_reference("file1.py")?)
.unwrap();
assert_eq!(file1_violations.len(), 2);
assert_eq!(
file1_violations[0].message,
"Block file1.py:(unnamed) at line 1 is modified, but file2.py:foo is not"
);
assert_eq!(
file1_violations[1].message,
"Block file1.py:(unnamed) at line 5 is modified, but file3.py:bar is not"
);
Ok(())
}
#[test]
fn modified_block_with_modified_targets_returns_no_violations() -> anyhow::Result<()> {
let validator = validator(&[]);
let context = merge_validation_contexts(vec![
validation_context(
"file1.py",
r#"# <block affects="file2.py:foo">
print("first")
# </block>
# <block affects="file3.py:bar">
print("second")
# </block>
"#,
),
validation_context(
"file2.py",
r#"# <block name="foo">
print("foo")
# </block>
"#,
),
validation_context(
"file3.py",
r#"# <block name="bar">
print("bar")
# </block>
"#,
),
]);
let violations = validator.validate(context)?.violations;
assert!(violations.is_empty());
Ok(())
}
#[test]
fn modified_block_with_one_unmodified_target_returns_a_violation() -> anyhow::Result<()> {
let file2 = r#"# <block name="buzz" affects="file1.py:bar">
print("not-buzz")
# </block>
print("hello")
"#;
let validator = validator(&[("file2.py", file2)]);
let context = merge_validation_contexts(vec![
validation_context(
"file1.py",
r#"# <block name="foo" affects=":bar, file2.py:buzz">
print("foo")
# </block>
# <block name="bar" affects=":foo">
print("bar")
# </block>
"#,
),
validation_context_with_changes(
"file2.py",
file2,
vec![LineChange {
line: 4, ranges: None,
}],
),
]);
let violations = validator.validate(context)?.violations;
assert_eq!(violations.len(), 1);
let file1_violations = violations
.get(&RepoPath::from_reference("file1.py")?)
.unwrap();
assert_eq!(file1_violations.len(), 1);
assert_eq!(
file1_violations[0].message,
"Block file1.py:foo at line 1 is modified, but file2.py:buzz is not"
);
Ok(())
}
#[test]
fn modified_block_with_multiple_modified_targets_returns_no_violations() -> anyhow::Result<()> {
let validator = validator(&[]);
let context = merge_validation_contexts(vec![
validation_context(
"file1.py",
r#"# <block name="foo" affects=":bar, file2.py:buzz">
print("foo")
# </block>
# <block name="bar" affects=":foo">
print("bar")
# </block>
"#,
),
validation_context(
"file2.py",
r#"# <block name="buzz" affects="file1.py:bar">
print("buzz")
# </block>
"#,
),
]);
let violations = validator.validate(context)?.violations;
assert!(violations.is_empty());
Ok(())
}
#[test]
fn modified_block_with_an_unmodified_target_in_the_same_file_returns_a_violation()
-> anyhow::Result<()> {
let source = r#"# <block affects=":foo">
print("first")
# </block>
# <block name="foo">
print("second")
# </block>
"#;
let validator = validator(&[("file1.py", source)]);
let context = validation_context_with_changes(
"file1.py",
source,
vec![LineChange {
line: 2,
ranges: None,
}],
);
let violations = validator.validate(context)?.violations;
assert_eq!(violations.len(), 1);
let file1_violations = violations
.get(&RepoPath::from_reference("file1.py")?)
.unwrap();
assert_eq!(file1_violations.len(), 1);
assert_eq!(
file1_violations[0].message,
"Block file1.py:(unnamed) at line 1 is modified, but file1.py:foo is not"
);
Ok(())
}
#[test]
fn block_with_unmodified_content_returns_no_violations() -> anyhow::Result<()> {
let validator = validator(&[]);
let context = merge_validation_contexts(vec![
validation_context_with_changes(
"file1.py",
r#"# <block affects="file2.py:foo">
pass
# </block>
# <block affects="file3.py:bar">
pass
# </block>
"#,
vec![
LineChange {
line: 1,
ranges: Some(vec![0..10, 12..15]),
}, LineChange {
line: 7,
ranges: None,
}, ],
),
validation_context_with_changes(
"file2.py",
r#"# <block name="foo">
pass
# </block>
"#,
vec![LineChange {
line: 1,
ranges: Some(vec![0..4, 6..10]),
}], ),
validation_context_with_changes(
"file3.py",
r#"# <block name="bar">
pass
# </block>
"#,
vec![LineChange {
line: 3,
ranges: None,
}], ),
]);
let violations = validator.validate(context)?.violations;
assert!(violations.is_empty());
Ok(())
}
#[test]
fn unmodified_target_outside_the_globs_returns_a_violation() -> anyhow::Result<()> {
let file_system = two_file_system();
let line_changes = HashMap::from([(
RepoPath::from_reference("source.py")?,
vec![LineChange {
line: 2,
ranges: None,
}],
)]);
let context = context_scoped_to_source(&file_system, line_changes)?;
let violations = AffectsValidator::new(Arc::new(file_system))
.validate(context)?
.violations;
assert_eq!(violations.len(), 1);
Ok(())
}
#[test]
fn modified_target_outside_the_globs_returns_no_violations() -> anyhow::Result<()> {
let file_system = two_file_system();
let line_changes = HashMap::from([
(
RepoPath::from_reference("source.py")?,
vec![LineChange {
line: 2,
ranges: None,
}],
),
(
RepoPath::from_reference("target.py")?,
vec![LineChange {
line: 2,
ranges: None,
}],
),
]);
let context = context_scoped_to_source(&file_system, line_changes)?;
let violations = AffectsValidator::new(Arc::new(file_system))
.validate(context)?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[test]
fn blocks_with_cyclic_references_partly_modified_returns_violations() -> anyhow::Result<()> {
let contents = r#"# <block name="foo" affects=":bar">
print("foo")
# </block>
# <block name="bar" affects=":foo">
pass
# </block>
"#;
let validator = validator(&[("file1.py", contents)]);
let line_changes = vec![
LineChange {
line: 2,
ranges: None,
}, LineChange {
line: 4, ranges: None,
},
];
let context = validation_context_with_changes("file1.py", contents, line_changes);
let violations = validator.validate(context)?.violations;
assert!(!violations.is_empty());
Ok(())
}
#[test]
fn blocks_with_cyclic_references_all_modified_returns_no_violations() -> anyhow::Result<()> {
let validator = validator(&[]);
let context = validation_context(
"file1.py",
r#"# <block name="foo" affects=":bar">
print("foo")
# </block>
# <block name="bar" affects=":foo">
print("bar")
# </block>
"#,
);
let violations = validator.validate(context)?.violations;
assert!(violations.is_empty());
Ok(())
}
#[test]
fn reference_with_a_leading_current_directory_returns_no_violations() -> anyhow::Result<()> {
let context = merge_validation_contexts(vec![
validation_context(
"source.py",
"# <block name=\"s\" affects=\"./target.py:t\">\nvalue = 2\n# </block>",
),
validation_context("target.py", "# <block name=\"t\">\nvalue = 2\n# </block>"),
]);
assert!(validator(&[]).validate(context)?.violations.is_empty());
Ok(())
}
#[test]
fn blocks_without_an_affects_attribute_returns_no_violations() -> anyhow::Result<()> {
let validator = validator(&[]);
let context = validation_context(
"file1.py",
r#"# <block name="foo">
pass
# </block>
"#,
);
let violations = validator.validate(context)?.violations;
assert!(violations.is_empty());
Ok(())
}
#[test]
fn modified_block_referencing_a_nonexistent_target_reports_a_dangling_reference()
-> anyhow::Result<()> {
let source = "# <block name=\"s\" affects=\":nope\">\nvalue = 1\n# </block>";
let context = validation_context("file.py", source);
let violations = validator(&[("file.py", source)])
.validate(context)?
.violations;
let file = violations
.get(&RepoPath::from_reference("file.py")?)
.unwrap();
assert_eq!(file.len(), 1);
assert!(
file[0].message.contains("does not exist"),
"expected a dangling-reference message, got: {}",
file[0].message
);
Ok(())
}
#[test]
fn unmodified_block_referencing_a_deleted_target_returns_a_violation() -> anyhow::Result<()> {
let source = "# <block name=\"s\" affects=\":nope\">\nvalue = 1\n# </block>";
let context = validation_context_with_changes(
"file.py",
source,
vec![LineChange {
line: 1,
ranges: Some(vec![3..8, 10..15]),
}],
);
let violations = validator(&[("file.py", source)])
.validate(context)?
.violations;
let file = violations
.get(&RepoPath::from_reference("file.py")?)
.unwrap();
assert_eq!(file.len(), 1);
assert!(
file[0].message.contains("does not exist"),
"expected a dangling-reference message, got: {}",
file[0].message
);
Ok(())
}
#[test]
fn reference_to_a_missing_target_file_fails_the_run() -> anyhow::Result<()> {
let context = validation_context(
"source.py",
"# <block name=\"s\" affects=\"gone.py:t\">\nvalue = 1\n# </block>",
);
assert!(validator(&[]).validate(context).is_err());
Ok(())
}
fn context_with_changed_files(
files: &[(&str, &str)],
changed_files: &[&str],
) -> anyhow::Result<Arc<validators::ValidationContext>> {
let file_system = FakeFileSystem::new(
files
.iter()
.map(|(path, contents)| (path.to_string(), contents.to_string()))
.collect(),
);
let mut line_changes = HashMap::new();
for changed_file in changed_files {
let contents = files
.iter()
.find(|(path, _)| path == changed_file)
.map(|(_, contents)| *contents)
.unwrap_or_default();
line_changes.insert(
RepoPath::from_reference(changed_file)?,
contents
.lines()
.enumerate()
.map(|(index, _)| LineChange {
line: index + 1,
ranges: None,
})
.collect(),
);
}
let parsers = crate::language_parsers::language_parsers()?;
let parsed = crate::blocks::parse_blocks(
&line_changes,
crate::blocks::ScanMode::All,
&file_system,
&crate::fs::test_utils::FakePathChecker::allow_all(),
&parsers,
&HashMap::new(),
)?;
Ok(Arc::new(validators::ValidationContext::new(
parsed.blocks,
parsers,
line_changes,
HashMap::new(),
)))
}
const WHOLE_FILE_REFERENCE_FILES: [(&str, &str); 2] = [
(
"source.py",
"# <block name=\"s\" affects=\"config.json\">\nvalue = 2\n# </block>",
),
("config.json", "{\"value\": 2}\n"),
];
#[test]
fn modified_block_with_an_unmodified_whole_file_target_returns_a_violation()
-> anyhow::Result<()> {
let context = context_with_changed_files(&WHOLE_FILE_REFERENCE_FILES, &["source.py"])?;
let violations = validator(&WHOLE_FILE_REFERENCE_FILES)
.validate(context)?
.violations;
let source_violations = violations
.get(&RepoPath::from_reference("source.py")?)
.unwrap();
assert_eq!(source_violations.len(), 1);
assert_eq!(
source_violations[0].message,
"Block source.py:s at line 1 is modified, but file config.json is not"
);
assert_eq!(
source_violations[0].data,
Some(serde_json::json!({"affected_block_file_path": "config.json"}))
);
Ok(())
}
#[test]
fn modified_block_with_a_modified_whole_file_target_returns_no_violations() -> anyhow::Result<()>
{
let context =
context_with_changed_files(&WHOLE_FILE_REFERENCE_FILES, &["source.py", "config.json"])?;
let report = validator(&WHOLE_FILE_REFERENCE_FILES).validate(context)?;
assert_eq!(violation_count(&report), 0);
Ok(())
}
#[test]
fn unmodified_block_with_an_unmodified_whole_file_target_returns_no_violations()
-> anyhow::Result<()> {
let context = context_with_changed_files(&WHOLE_FILE_REFERENCE_FILES, &[])?;
let report = validator(&WHOLE_FILE_REFERENCE_FILES).validate(context)?;
assert_eq!(violation_count(&report), 0);
Ok(())
}
#[test]
fn whole_file_reference_to_a_missing_file_returns_an_error() -> anyhow::Result<()> {
let files = [(
"source.py",
"# <block name=\"s\" affects=\"gone.json\">\nvalue = 1\n# </block>",
)];
let context = context_with_changed_files(&files, &[])?;
let error = validator(&files).validate(context).unwrap_err();
assert_eq!(
error.to_string(),
"affects target file does not exist: gone.json"
);
Ok(())
}
#[test]
fn modified_block_with_affects_records_one_check() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block name="source" affects=":target">
a = 1
# </block>
# <block name="target">
b = 2
# </block>"#,
);
let report = validator(&[]).validate(context)?;
assert_eq!(checked_lines(&report), vec![1]);
Ok(())
}
}