code-moniker-check 0.5.0

Rules engine for code-moniker: DSL, rule config/profiles, evaluation over the symbol graph, and suppression.
Documentation
use super::ast::*;
use super::atom::build_atom;
use super::cursor::{self, ParseResult, ParserState};
use super::domain::{parse_domain_ident, reject_pair_domain};
use super::error::ParseError;

pub(super) fn try_parse_collection_subset_atom<'a>(
	state: ParserState<'a>,
) -> ParseResult<'a, Option<Atom>> {
	let state = cursor::skip_ws(state);
	let (raw_start, raw) = cursor::peek_atom_text(&state);
	let Some((op_idx, op_len)) = find_top_level_subset(raw) else {
		return Ok((None, state));
	};
	let lhs_src = raw[..op_idx].trim();
	let rhs_src = raw[op_idx + op_len..].trim();
	if lhs_src.is_empty() || rhs_src.is_empty() {
		return Err(cursor::bail(
			&state,
			"collection subset requires `<collection> subset <collection>`",
		));
	}
	let lhs = parse_collection_expr_full(
		lhs_src,
		cursor::scheme(&state),
		cursor::allowed_kinds(&state),
		cursor::raw(&state),
		cursor::pair_bindings_allowed(&state),
	)?;
	let rhs = parse_collection_expr_full(
		rhs_src,
		cursor::scheme(&state),
		cursor::allowed_kinds(&state),
		cursor::raw(&state),
		cursor::pair_bindings_allowed(&state),
	)?;
	let (_raw_start, _raw, atom_state) = cursor::take_atom_text(state);
	let atom = build_atom(
		LhsExpr::Collection(lhs),
		Op::Subset,
		Rhs::Collection(rhs),
		cursor::slice_from(&atom_state, raw_start).to_string(),
		cursor::raw(&atom_state),
	)?;
	Ok((Some(atom), atom_state))
}

pub(super) fn parse_collection_call_body<'a>(
	state: ParserState<'a>,
	name: &str,
) -> ParseResult<'a, CollectionExpr> {
	if cursor::peek_byte(&state) != Some(b'(') {
		return Err(cursor::bail(&state, format!("expected `(` after `{name}`")));
	}
	let state = cursor::advance(state, 1);
	let (expr, state) = parse_collection_expr(state)?;
	let state = cursor::skip_ws(state);
	if cursor::peek_byte(&state) != Some(b')') {
		return Err(cursor::bail(
			&state,
			format!(
				"missing `)` for `{name}` at byte {}",
				cursor::position(&state)
			),
		));
	}
	Ok((expr, cursor::advance(state, 1)))
}

fn parse_collection_expr<'a>(state: ParserState<'a>) -> ParseResult<'a, CollectionExpr> {
	let (mut expr, mut state) = parse_collection_primary(state)?;
	loop {
		state = cursor::skip_ws(state);
		let (op, next_state) = eat_collection_op(state);
		state = next_state;
		let Some(op) = op else {
			break;
		};
		let (right, next_state) = parse_collection_primary(state)?;
		state = next_state;
		expr = CollectionExpr::Binary {
			op,
			left: Box::new(expr),
			right: Box::new(right),
		};
	}
	Ok((expr, state))
}

fn parse_collection_primary<'a>(state: ParserState<'a>) -> ParseResult<'a, CollectionExpr> {
	let state = cursor::skip_ws(state);
	if cursor::starts_with(&state, "unique(") {
		let state = cursor::advance(state, "unique".len());
		let (expr, state) = parse_collection_call_body(state, "unique")?;
		return Ok((CollectionExpr::Unique(Box::new(expr)), state));
	}
	if cursor::peek_byte(&state) == Some(b'(') {
		let state = cursor::advance(state, 1);
		let (expr, state) = parse_collection_expr(state)?;
		let state = cursor::skip_ws(state);
		if cursor::peek_byte(&state) != Some(b')') {
			return Err(cursor::bail(
				&state,
				format!(
					"missing `)` in collection expression at byte {}",
					cursor::position(&state)
				),
			));
		}
		return Ok((expr, cursor::advance(state, 1)));
	}
	let (expr, state) = try_parse_pair_collection_projection(state)?;
	if let Some(expr) = expr {
		return Ok((expr, state));
	}
	let (domain, mut state) = parse_domain_ident(state)?;
	reject_pair_domain(&state, &domain, "collection projections")?;
	let mut path = Vec::new();
	loop {
		state = cursor::skip_ws(state);
		if cursor::peek_byte(&state) != Some(b'.') {
			break;
		}
		let (segment, next_state) = cursor::take_projection_segment(cursor::advance(state, 1));
		state = next_state;
		if segment.is_empty() {
			return Err(cursor::bail(
				&state,
				format!(
					"expected collection projection segment at byte {}",
					cursor::position(&state)
				),
			));
		}
		path.push(segment.to_string());
	}
	validate_collection_projection_path(&domain, &path, cursor::raw(&state))?;
	Ok((
		CollectionExpr::Projection(CollectionProjection { domain, path }),
		state,
	))
}

fn try_parse_pair_collection_projection<'a>(
	state: ParserState<'a>,
) -> ParseResult<'a, Option<CollectionExpr>> {
	let side = if cursor::starts_with(&state, "a.") {
		PairSide::A
	} else if cursor::starts_with(&state, "b.") {
		PairSide::B
	} else {
		return Ok((None, state));
	};
	if !cursor::pair_bindings_allowed(&state) {
		return Err(cursor::bail(
			&state,
			"pair-bound collection projections are only valid inside `pairs(...)` filters",
		));
	}
	let state = cursor::advance(state, 2);
	let (domain, mut state) = parse_domain_ident(state)?;
	reject_pair_domain(&state, &domain, "pair collection projections")?;
	let mut path = Vec::new();
	loop {
		state = cursor::skip_ws(state);
		if cursor::peek_byte(&state) != Some(b'.') {
			break;
		}
		let (segment, next_state) = cursor::take_projection_segment(cursor::advance(state, 1));
		state = next_state;
		if segment.is_empty() {
			return Err(cursor::bail(
				&state,
				format!(
					"expected pair collection projection segment at byte {}",
					cursor::position(&state)
				),
			));
		}
		path.push(segment.to_string());
	}
	validate_collection_projection_path(&domain, &path, cursor::raw(&state))?;
	Ok((
		Some(CollectionExpr::PairProjection(PairCollectionProjection {
			side,
			domain,
			path,
		})),
		state,
	))
}

fn eat_collection_op<'a>(state: ParserState<'a>) -> (Option<CollectionOp>, ParserState<'a>) {
	let (matched, state) = cursor::eat_keyword(state, "intersect");
	if matched {
		(Some(CollectionOp::Intersect), state)
	} else {
		let (matched, state) = cursor::eat_keyword(state, "union");
		if matched {
			(Some(CollectionOp::Union), state)
		} else {
			let (matched, state) = cursor::eat_keyword(state, "diff");
			if matched {
				(Some(CollectionOp::Difference), state)
			} else {
				(None, state)
			}
		}
	}
}

fn validate_collection_projection_path(
	domain: &Domain,
	path: &[String],
	full: &str,
) -> Result<(), ParseError> {
	let valid = match domain {
		Domain::Children(_) | Domain::ChildrenByShape(_) | Domain::Descendants(_) => {
			valid_def_collection_path(path)
		}
		Domain::OutRefs
		| Domain::InRefs
		| Domain::SourceOutRefs
		| Domain::SourceInRefs
		| Domain::SourceAncestorOutRefs
		| Domain::SourceAncestorInRefs => valid_ref_collection_path(path),
		Domain::Segments => valid_segment_collection_path(path),
		Domain::Pairs(_) => false,
	};
	if valid {
		Ok(())
	} else {
		Err(ParseError::BadExpr {
			expr: full.to_string(),
			msg: format!("unknown collection projection `{}`", path.join(".")),
		})
	}
}

fn valid_def_collection_path(path: &[String]) -> bool {
	match path {
		[] => true,
		[one] => matches!(
			one.as_str(),
			"self"
				| "name" | "kind"
				| "shape" | "visibility"
				| "lines" | "start_line"
				| "end_line" | "start_byte"
				| "end_byte" | "depth"
				| "parent"
		),
		[parent, child] if parent == "parent" => {
			matches!(child.as_str(), "name" | "kind" | "shape")
		}
		[nested, rest @ ..] if nested == "out_refs" || nested == "in_refs" => {
			valid_ref_collection_path(rest)
		}
		_ => false,
	}
}

fn valid_ref_collection_path(path: &[String]) -> bool {
	match path {
		[] => true,
		[one] => matches!(
			one.as_str(),
			"kind" | "start_line" | "end_line" | "start_byte" | "end_byte" | "source" | "target"
		),
		[side, projection] if side == "source" || side == "target" => {
			matches!(
				projection.as_str(),
				"name" | "kind" | "shape" | "visibility" | "parent"
			)
		}
		_ => false,
	}
}

fn valid_segment_collection_path(path: &[String]) -> bool {
	matches!(path, [one] if one == "kind" || one == "name")
}

pub(super) fn parse_collection_rhs(
	s: &str,
	scheme: &str,
	allowed_kinds: &[&str],
	full: &str,
	pair_bindings_allowed: bool,
) -> Result<CollectionExpr, ParseError> {
	parse_collection_expr_full(s, scheme, allowed_kinds, full, pair_bindings_allowed)
}

fn parse_collection_expr_full(
	s: &str,
	scheme: &str,
	allowed_kinds: &[&str],
	full: &str,
	pair_bindings_allowed: bool,
) -> Result<CollectionExpr, ParseError> {
	let state = cursor::with_pair_bindings_allowed(
		ParserState::new(s, scheme, allowed_kinds, full),
		pair_bindings_allowed,
	);
	let (expr, state) = parse_collection_expr(state)?;
	let state = cursor::skip_ws(state);
	if !cursor::is_at_end(&state) {
		return Err(ParseError::BadExpr {
			expr: full.to_string(),
			msg: format!(
				"trailing input in collection expression `{}`",
				cursor::rest(&state)
			),
		});
	}
	Ok(expr)
}

fn find_top_level_subset(input: &str) -> Option<(usize, usize)> {
	let mut depth: i32 = 0;
	let mut in_string: Option<char> = None;
	for (idx, ch) in input.char_indices() {
		if let Some(q) = in_string {
			if ch == q {
				in_string = None;
			}
			continue;
		}
		match ch {
			'\'' | '"' => in_string = Some(ch),
			'(' => depth += 1,
			')' => depth -= 1,
			_ if depth == 0 && keyword_at(input, idx, "subset") => {
				return Some((idx, "subset".len()));
			}
			_ => {}
		}
	}
	None
}

fn keyword_at(input: &str, idx: usize, keyword: &str) -> bool {
	if !input[idx..].starts_with(keyword) {
		return false;
	}
	let before = &input[..idx];
	let after = &input[idx + keyword.len()..];
	let before_ok = before
		.chars()
		.next_back()
		.is_some_and(|ch| ch.is_ascii_whitespace());
	let after_ok = after
		.chars()
		.next()
		.is_some_and(|ch| ch.is_ascii_whitespace() || ch == '(');
	before_ok && after_ok
}