code-moniker 0.4.0

Standalone CLI / linter for the code-moniker symbol graph: file and directory probes, project-wide architecture rules.
Documentation
use std::io::Write;

use code_moniker_workspace::changes::semantic::model::{RefChange, SemanticKind, SymbolChange};
use code_moniker_workspace::changes::semantic::review::{FileFacts, SemanticReview};

use super::{change_primary_path, facts_primary_path, is_pure_move};

pub fn write_review(
	out: &mut impl Write,
	review: &SemanticReview,
	detail_refs: bool,
) -> anyhow::Result<()> {
	writeln!(out, "scope {}", review.scope)?;
	writeln!(out, "{}", summary_line(review))?;
	for facts in &review.files {
		writeln!(out)?;
		writeln!(out, "{}", file_heading(facts))?;
		if !facts.analyzable {
			continue;
		}
		write_file_symbols(out, review, facts)?;
		write_file_refs(out, review, facts, detail_refs)?;
		write_residual(out, facts)?;
	}
	for diagnostic in &review.diagnostics {
		writeln!(out, "diagnostic: {diagnostic}")?;
	}
	Ok(())
}

fn summary_line(review: &SemanticReview) -> String {
	let analyzable = review.files.iter().filter(|facts| facts.analyzable).count();
	let retargeted = review
		.ref_changes
		.iter()
		.filter(|change| change.kind.is_retarget())
		.count();
	let residual = review
		.files
		.iter()
		.filter(|facts| !facts.coverage.explained())
		.count();
	format!(
		"files {} ({} analyzable) | symbol facts {} | ref facts {} ({} retargeted) | residual files {}",
		review.files.len(),
		analyzable,
		review.symbol_changes.len(),
		review.ref_changes.len(),
		retargeted,
		residual
	)
}

fn file_heading(facts: &FileFacts) -> String {
	let path = match (&facts.rollup.old_path, &facts.rollup.new_path) {
		(Some(old), Some(new)) if old != new => {
			format!("{} -> {}", old.display(), new.display())
		}
		(_, Some(new)) => new.display().to_string(),
		(Some(old), None) => old.display().to_string(),
		(None, None) => "<unknown>".to_string(),
	};
	let suffix = if facts.analyzable {
		""
	} else {
		" (not analyzable)"
	};
	format!("{path}  {}{suffix}", facts.rollup.disposition.label())
}

fn write_file_symbols(
	out: &mut impl Write,
	review: &SemanticReview,
	facts: &FileFacts,
) -> anyhow::Result<()> {
	let Some(primary) = facts_primary_path(facts) else {
		return Ok(());
	};
	let mut pure_moves = 0usize;
	for change in &review.symbol_changes {
		if change_primary_path(change) != primary {
			continue;
		}
		if is_pure_move(change) {
			pure_moves += 1;
			continue;
		}
		writeln!(out, "  {}", symbol_line(change))?;
	}
	if pure_moves > 0 {
		writeln!(out, "  = {pure_moves} symbol(s) moved, no other facts")?;
	}
	Ok(())
}

fn symbol_line(change: &SymbolChange) -> String {
	let side = change
		.new
		.as_ref()
		.or(change.old.as_ref())
		.expect("a change has at least one side");
	let subject = match (change.kind, &change.old, &change.new) {
		(SemanticKind::Renamed | SemanticKind::SignatureChanged, Some(old), Some(new)) => {
			format!("{} {} -> {}", old.kind, old.name, new.name)
		}
		_ => format!("{} {}", side.kind, side.name),
	};
	let mut line = format!(
		"{} {subject}  {}",
		kind_marker(change.kind),
		change.kind.label()
	);
	line.push_str(&facet_suffix(change));
	if change.confidence.label() != "certain" {
		line.push_str(&format!(" [{}]", change.confidence.label()));
	}
	line
}

fn kind_marker(kind: SemanticKind) -> char {
	match kind {
		SemanticKind::Added => '+',
		SemanticKind::Removed => '-',
		SemanticKind::BodyModified => '~',
		SemanticKind::SignatureChanged => '#',
		SemanticKind::Renamed => '>',
		SemanticKind::Moved => '=',
		SemanticKind::AttributeChanged => '!',
	}
}

fn facet_suffix(change: &SymbolChange) -> String {
	let mut extras = Vec::new();
	if change.facets.body_changed && change.kind != SemanticKind::BodyModified {
		extras.push("body");
	}
	if change.facets.signature_changed && change.kind != SemanticKind::SignatureChanged {
		extras.push("signature");
	}
	if change.facets.visibility_changed {
		extras.push("visibility");
	}
	if change.facets.header_changed {
		extras.push("header");
	}
	if extras.is_empty() {
		String::new()
	} else {
		format!(" +{}", extras.join(" +"))
	}
}

fn write_file_refs(
	out: &mut impl Write,
	review: &SemanticReview,
	facts: &FileFacts,
	detail_refs: bool,
) -> anyhow::Result<()> {
	let Some(primary) = facts_primary_path(facts) else {
		return Ok(());
	};
	let file_refs: Vec<&RefChange> = review
		.ref_changes
		.iter()
		.filter(|change| change.file_path == primary)
		.collect();
	if file_refs.is_empty() {
		return Ok(());
	}
	if detail_refs {
		for change in &file_refs {
			writeln!(out, "  {}", ref_line(change))?;
		}
		return Ok(());
	}
	let retargeted = file_refs
		.iter()
		.filter(|change| change.kind.is_retarget())
		.count();
	let other = file_refs.len() - retargeted;
	let mut parts = Vec::new();
	if retargeted > 0 {
		parts.push(format!("{retargeted} retargeted"));
	}
	if other > 0 {
		parts.push(format!("{other} added/removed"));
	}
	writeln!(out, "  refs: {} (--refs for detail)", parts.join(", "))?;
	Ok(())
}

fn ref_line(change: &RefChange) -> String {
	let lines = change
		.new_line_range
		.or(change.old_line_range)
		.map(|(start, _)| format!(" L{start}"))
		.unwrap_or_default();
	format!("{} {}{lines}", change.kind.label(), change.ref_kind)
}

fn write_residual(out: &mut impl Write, facts: &FileFacts) -> anyhow::Result<()> {
	if facts.coverage.explained() {
		return Ok(());
	}
	writeln!(
		out,
		"  residual: old {} | new {}",
		spans_text(&facts.coverage.old_residual),
		spans_text(&facts.coverage.new_residual)
	)?;
	Ok(())
}

fn spans_text(spans: &[(u32, u32)]) -> String {
	if spans.is_empty() {
		return "-".to_string();
	}
	spans
		.iter()
		.map(|(start, end)| {
			if start == end {
				format!("{start}")
			} else {
				format!("{start}-{end}")
			}
		})
		.collect::<Vec<_>>()
		.join(",")
}