use super::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub(crate) enum ChangedScope {
Symbol,
Hunk,
}
pub(crate) struct ResolvedDiffFilter {
pub(crate) patch: DiffPatchLineMap,
pub(crate) scope: ChangedScope,
pub(crate) explicit_ranges: bool,
}
pub(crate) fn resolve_diff_filter(
project_root: &Path,
options: &AnalysisOptions,
files: &[SourceFile],
) -> Result<Option<ResolvedDiffFilter>, String> {
let Some(selection) = &options.diff else {
return Ok(None);
};
let filter = match selection {
DiffSelection::Patch { path, scope } => ResolvedDiffFilter {
patch: parse_patch_selection(project_root, path)?,
scope: *scope,
explicit_ranges: false,
},
DiffSelection::Git { mode, scope } => ResolvedDiffFilter {
patch: git_diff_patch(project_root, mode, &options.paths)?,
scope: *scope,
explicit_ranges: false,
},
DiffSelection::ExplicitRanges { ranges, scope } => ResolvedDiffFilter {
patch: explicit_ranges_patch(ranges, files)?,
scope: *scope,
explicit_ranges: true,
},
};
Ok(Some(filter))
}
fn parse_patch_selection(project_root: &Path, path: &Path) -> Result<DiffPatchLineMap, String> {
let patch_text = read_diff_patch(project_root, path)?;
let patch = parse_unified_diff(&patch_text);
if !patch.saw_hunk && patch.changed_files().is_empty() {
return Err(format!(
"--diff-patch {} is not a parseable unified diff",
path.display()
));
}
Ok(patch)
}
pub(crate) fn apply_diff_file_selection(
discovery: &mut DiscoveryResult,
diff_filter: Option<&ResolvedDiffFilter>,
) {
let Some(diff_filter) = diff_filter else {
return;
};
if diff_filter.explicit_ranges {
return;
}
let changed = diff_filter.patch.changed_files();
discovery
.files
.retain(|file| changed.contains(&file.display_path));
}
pub(crate) fn explicit_ranges_patch(
ranges: &str,
files: &[SourceFile],
) -> Result<DiffPatchLineMap, String> {
let lines = parse_changed_ranges(ranges)?;
let mut line_map = DiffPatchLineMap {
saw_hunk: true,
..DiffPatchLineMap::default()
};
for file in files {
line_map
.lines_by_file
.insert(file.display_path.clone(), lines.clone());
}
Ok(line_map)
}
pub(crate) fn git_diff_patch(
project_root: &Path,
mode: &str,
paths: &[PathBuf],
) -> Result<DiffPatchLineMap, String> {
let mut prefix = vec!["diff", "--no-ext-diff", "--no-textconv", "--unified=0"];
match mode {
"working-tree" => prefix.push("HEAD"),
"staged" => prefix.push("--cached"),
"unstaged" => {}
base => prefix.push(base),
}
let patch = git_output(project_root, &git_args_with_paths(&prefix, paths))?;
let mut parsed = parse_unified_diff(&patch);
if mode == "working-tree" {
for path in git_untracked_files(project_root, paths)? {
parsed.whole_files.insert(path.clone());
parsed.lines_by_file.entry(path).or_default();
}
}
Ok(parsed)
}
fn parse_changed_ranges(ranges: &str) -> Result<BTreeSet<usize>, String> {
let mut lines = BTreeSet::new();
for raw_part in ranges.split(',') {
let part = raw_part.trim();
if part.is_empty() {
continue;
}
let (start, end) = match part.split_once('-') {
Some((start, end)) => (
parse_positive_line(start, part)?,
parse_positive_line(end, part)?,
),
None => {
let line = parse_positive_line(part, part)?;
(line, line)
}
};
if end < start {
return Err(format!(
"invalid changed range `{part}`: end must be >= start"
));
}
lines.extend(start..=end);
}
if lines.is_empty() {
return Err("--changed-ranges must include at least one line or range".to_string());
}
Ok(lines)
}
fn parse_positive_line(raw: &str, original: &str) -> Result<usize, String> {
let value = raw.parse::<usize>().map_err(|_| {
format!("invalid changed range `{original}`: line numbers must be integers")
})?;
if value == 0 {
return Err(format!(
"invalid changed range `{original}`: line numbers must be >= 1"
));
}
Ok(value)
}
pub(crate) fn git_args_with_paths(prefix: &[&str], paths: &[PathBuf]) -> Vec<String> {
prefix
.iter()
.map(|value| value.to_string())
.chain(std::iter::once("--".to_string()))
.chain(paths.iter().map(|path| path.display().to_string()))
.collect()
}
fn git_command(project_root: &Path, args: &[String]) -> std::process::Command {
let mut command = std::process::Command::new("git");
command
.arg("--no-pager")
.arg("-C")
.arg(project_root)
.arg("-c")
.arg("core.hooksPath=/dev/null")
.args(args)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_ATTR_NOSYSTEM", "1")
.env_remove("GIT_EXTERNAL_DIFF")
.env_remove("GIT_PAGER");
command
}
pub(crate) fn git_output(project_root: &Path, args: &[String]) -> Result<String, String> {
let output = git_output_bytes(project_root, args)?;
Ok(String::from_utf8_lossy(&output).to_string())
}
pub(crate) fn git_output_bytes(project_root: &Path, args: &[String]) -> Result<Vec<u8>, String> {
git_output_bytes_with_stdin(project_root, args, &[])
}
pub(crate) fn git_output_bytes_with_stdin(
project_root: &Path,
args: &[String],
stdin: &[u8],
) -> Result<Vec<u8>, String> {
let mut command = git_command(project_root, args);
let subcommand = args.first().map(String::as_str).unwrap_or("command");
if stdin.is_empty() {
let output = command
.output()
.map_err(|error| format!("unable to execute git {subcommand}: {error}"))?;
return git_stdout_or_error(output);
}
let mut child = command
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|error| format!("unable to execute git {subcommand}: {error}"))?;
let mut child_stdin = child
.stdin
.take()
.ok_or_else(|| "unable to open git stdin".to_string())?;
let payload = stdin.to_vec();
let writer = std::thread::spawn(move || child_stdin.write_all(&payload));
let output = child
.wait_with_output()
.map_err(|error| format!("unable to read git output: {error}"))?;
let _ = writer.join();
git_stdout_or_error(output)
}
fn git_stdout_or_error(output: std::process::Output) -> Result<Vec<u8>, String> {
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
Ok(output.stdout)
}
fn git_untracked_files(project_root: &Path, paths: &[PathBuf]) -> Result<Vec<String>, String> {
let output = git_output(
project_root,
&git_args_with_paths(&["ls-files", "--others", "--exclude-standard"], paths),
)?;
Ok(output
.lines()
.map(normalize_report_path)
.filter(|path| !path.is_empty())
.collect())
}
pub(crate) fn patch_intersects_finding_with_scope(
finding: &Finding,
patch: &DiffPatchLineMap,
changed_files: &BTreeSet<String>,
function_blocks_by_file: &BTreeMap<String, Vec<FunctionBlock>>,
scope: ChangedScope,
) -> bool {
if patch_intersects_finding(finding, patch, changed_files) {
return true;
}
if scope == ChangedScope::Hunk {
return false;
}
let Some(line) = finding.line else {
return false;
};
let file_path = normalize_report_path(&finding.file_path);
let Some(blocks) = function_blocks_by_file.get(&file_path) else {
return false;
};
let Some(block) = enclosing_block(line, finding.symbol.as_deref(), blocks) else {
return false;
};
let block_end = block.start_line + block.line_count.saturating_sub(1);
patch_range_intersects(patch, &file_path, block.start_line, block_end)
}
fn enclosing_block<'a>(
line: usize,
symbol: Option<&str>,
blocks: &'a [FunctionBlock],
) -> Option<&'a FunctionBlock> {
blocks
.iter()
.filter(|block| {
let end = block.start_line + block.line_count.saturating_sub(1);
line >= block.start_line && line <= end && symbol_matches(symbol, &block.name)
})
.min_by_key(|block| block.line_count)
.or_else(|| {
blocks
.iter()
.filter(|block| {
let end = block.start_line + block.line_count.saturating_sub(1);
line >= block.start_line && line <= end
})
.min_by_key(|block| block.line_count)
})
}
fn symbol_matches(symbol: Option<&str>, block_name: &str) -> bool {
symbol.is_none_or(|symbol| symbol == block_name || symbol.ends_with(&format!(".{block_name}")))
}