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::cmp::Ordering;
use std::io::Write;
use std::path::Path;

use code_moniker_core::core::code_graph::{DefRecord, RefRecord};
use code_moniker_core::core::kinds::{KIND_COMMENT, KIND_LOCAL, KIND_PARAM};
use code_moniker_core::core::moniker::Moniker;
use code_moniker_workspace::environment;

use crate::args::{ExtractArgs, OutputFormat, OutputMode};
use crate::{Exit, language_kinds, page, path_to_lang};

mod directory;
pub(crate) mod filter;
mod format;

pub use filter::{MatchSet, Predicate, RefMatch};

fn unknown_kinds_error(
	unknown: &[String],
	langs: &[code_moniker_core::lang::Lang],
	known: &std::collections::BTreeSet<&'static str>,
) -> anyhow::Error {
	let lang_tags: Vec<&str> = langs.iter().map(|l| l.tag()).collect();
	let known_list: Vec<&str> = known.iter().copied().collect();
	anyhow::anyhow!(
		"unknown --kind {} (langs in scope: {}; known kinds: {})",
		unknown.join(", "),
		lang_tags.join(", "),
		known_list.join(", "),
	)
}

pub fn run<W1: Write, W2: Write>(args: &ExtractArgs, stdout: &mut W1, stderr: &mut W2) -> Exit {
	match extract_inner(args, stdout, stderr) {
		Ok(any) => {
			if any {
				Exit::Match
			} else {
				Exit::NoMatch
			}
		}
		Err(e) => {
			let _ = writeln!(stderr, "code-moniker: {e:#}");
			Exit::UsageError
		}
	}
}

fn extract_inner<W1: Write, W2: Write>(
	args: &ExtractArgs,
	stdout: &mut W1,
	stderr: &mut W2,
) -> anyhow::Result<bool> {
	let path: &Path = &args.path;
	let scheme = args
		.scheme
		.as_deref()
		.unwrap_or(crate::DEFAULT_SCHEME)
		.to_string();
	let meta = std::fs::metadata(path)
		.map_err(|e| anyhow::anyhow!("cannot stat {}: {e}", path.display()))?;
	if meta.is_dir() {
		return directory::run(args, stdout, stderr, path, &scheme);
	}
	if !args.path_filter.is_empty() {
		anyhow::bail!("--path requires a directory extraction root");
	}
	let lang = path_to_lang(path)?;
	let predicates = args.compiled_predicates(&scheme)?;
	let names = filter::compile_name_filters(&args.name)?;
	let known = language_kinds::known_kinds(std::iter::once(&lang));
	let unknown = language_kinds::unknown_kinds(&args.kind, &known);
	if !unknown.is_empty() {
		return Err(unknown_kinds_error(&unknown, &[lang], &known));
	}
	let sources = environment::discover_sources(&[path.to_path_buf()], args.project.clone())?;
	let file = sources
		.files
		.first()
		.ok_or_else(|| anyhow::anyhow!("cannot read {}", path.display()))?;
	let ctx = &sources.roots[file.source].ctx;
	let (graph, extracted_source) = environment::load_or_extract_source(
		&file.path,
		&file.anchor,
		file.lang,
		args.cache.as_deref(),
		ctx,
	)
	.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?;
	let source = match extracted_source {
		Some(s) => s,
		None => std::fs::read_to_string(path)
			.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?,
	};
	let matches = filter::filter(&graph, &predicates, &args.kind, &names, &args.shape);
	match args.mode() {
		OutputMode::Default => {
			let visible;
			let page_source = if uses_tree_visibility(args) {
				visible = tree_visible_match_set(&matches, args);
				&visible
			} else {
				&matches
			};
			let (paged, page) = paginate_match_set(page_source, args, &scheme)?;
			match args.format {
				OutputFormat::Text => format::write_text(stdout, &paged, args, &scheme)?,
				OutputFormat::Tsv => format::write_tsv(stdout, &paged, &source, args, &scheme)?,
				OutputFormat::Json => format::write_json(
					stdout,
					&paged,
					&source,
					args,
					format::JsonContext {
						lang: file.lang,
						path,
						scheme: &scheme,
						page: &page,
					},
				)?,
				#[cfg(feature = "pretty")]
				OutputFormat::Tree => crate::tree::write_tree(stdout, &paged, &source, args, &scheme)?,
			}
			write_page_notice(stderr, args, &page)?;
			Ok(page.emitted > 0)
		}
		OutputMode::Count => {
			let n = matches.defs.len() + matches.refs.len();
			writeln!(stdout, "{n}")?;
			Ok(n > 0)
		}
		OutputMode::Quiet => Ok(!matches.defs.is_empty() || !matches.refs.is_empty()),
	}
}

pub(super) fn uses_tree_visibility(args: &ExtractArgs) -> bool {
	#[cfg(feature = "pretty")]
	{
		args.format == OutputFormat::Tree
	}
	#[cfg(not(feature = "pretty"))]
	{
		let _ = args;
		false
	}
}

fn tree_visible_match_set<'g>(matches: &MatchSet<'g>, args: &ExtractArgs) -> MatchSet<'g> {
	if !args.kind.is_empty() {
		return MatchSet {
			defs: matches.defs.clone(),
			refs: matches
				.refs
				.iter()
				.map(|r| RefMatch {
					record: r.record,
					source: r.source,
				})
				.collect(),
		};
	}
	MatchSet {
		defs: matches
			.defs
			.iter()
			.copied()
			.filter(|def| tree_visible_def_kind(&def.kind))
			.collect(),
		refs: Vec::new(),
	}
}

pub(super) fn tree_visible_def_kind(kind: &[u8]) -> bool {
	!matches!(kind, KIND_LOCAL | KIND_PARAM | KIND_COMMENT)
}

struct MatchItem<'g> {
	cursor: Moniker,
	record: MatchRecord<'g>,
}

enum MatchRecord<'g> {
	Def(&'g DefRecord),
	Ref {
		record: &'g RefRecord,
		source: &'g Moniker,
	},
}

impl<'g> MatchItem<'g> {
	fn cursor_moniker(&self) -> &Moniker {
		&self.cursor
	}

	fn rank(&self) -> u8 {
		match &self.record {
			MatchRecord::Def(_) => 0,
			MatchRecord::Ref { .. } => 1,
		}
	}

	fn source_bytes(&self) -> &'g [u8] {
		match &self.record {
			MatchRecord::Def(_) => &[],
			MatchRecord::Ref { source, .. } => source.as_encoded(),
		}
	}

	fn kind(&self) -> &'g [u8] {
		match &self.record {
			MatchRecord::Def(def) => &def.kind,
			MatchRecord::Ref { record, .. } => &record.kind,
		}
	}

	fn position(&self) -> Option<(u32, u32)> {
		match &self.record {
			MatchRecord::Def(def) => def.position,
			MatchRecord::Ref { record, .. } => record.position,
		}
	}
}

fn cmp_match_item(a: &MatchItem<'_>, b: &MatchItem<'_>) -> Ordering {
	a.cursor_moniker()
		.as_encoded()
		.cmp(b.cursor_moniker().as_encoded())
		.then_with(|| a.rank().cmp(&b.rank()))
		.then_with(|| a.source_bytes().cmp(b.source_bytes()))
		.then_with(|| a.kind().cmp(b.kind()))
		.then_with(|| a.position().cmp(&b.position()))
}

fn paginate_match_set<'g>(
	matches: &MatchSet<'g>,
	args: &ExtractArgs,
	scheme: &str,
) -> anyhow::Result<(MatchSet<'g>, page::PageInfo)> {
	let spec = page::PageSpec::from_args(args, scheme)?;
	let mut items = Vec::with_capacity(matches.defs.len() + matches.refs.len());
	let mut ordinal = 0usize;
	for &def in &matches.defs {
		let cursor = page::def_cursor_moniker(&def.moniker, &[], &def.kind, def.position, ordinal);
		if spec.allows(&cursor) {
			items.push(MatchItem {
				cursor,
				record: MatchRecord::Def(def),
			});
		}
		ordinal += 1;
	}
	for r in &matches.refs {
		let cursor = page::ref_cursor_moniker(
			&r.record.target,
			&[],
			r.source,
			&r.record.kind,
			r.record.position,
			ordinal,
		);
		if spec.allows(&cursor) {
			items.push(MatchItem {
				cursor,
				record: MatchRecord::Ref {
					record: r.record,
					source: r.source,
				},
			});
		}
		ordinal += 1;
	}
	items.sort_by(cmp_match_item);

	let total = items.len();
	let page_len = spec.page_len(total);
	let last = page_len
		.checked_sub(1)
		.and_then(|idx| items.get(idx))
		.map(MatchItem::cursor_moniker);
	let info = spec.info(total, page_len, last, scheme);
	let mut defs = Vec::new();
	let mut refs = Vec::new();
	for item in items.into_iter().take(page_len) {
		match item.record {
			MatchRecord::Def(def) => defs.push(def),
			MatchRecord::Ref { record, source } => refs.push(RefMatch { record, source }),
		}
	}
	Ok((MatchSet { defs, refs }, info))
}

pub(super) fn write_page_notice<W: Write>(
	stderr: &mut W,
	args: &ExtractArgs,
	page: &page::PageInfo,
) -> std::io::Result<()> {
	if args.format == OutputFormat::Json {
		return Ok(());
	}
	if let Some(cursor) = &page.next_cursor {
		writeln!(
			stderr,
			"code-moniker: ... {} more results, use --after '{}' or --all",
			page.remaining, cursor
		)?;
	}
	Ok(())
}