use std::io::Write;
use std::path::Path;
use std::time::Instant;
use anyhow::{Context, Result};
use serde::Serialize;
use tree_sitter::{Query as TsQuery, StreamingIterator};
use tree_sitter_language_pack::{Node, get_language, get_parser, has_language};
use crate::cli::{GlobalArgs, QueryArgs};
use crate::output::NdjsonWriter;
#[derive(Debug, Serialize)]
struct QuerySummary {
r#type: &'static str,
path: String,
language: String,
matches: usize,
total_nodes: usize,
elapsed_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum QueryType {
KindFilter,
SExpression,
}
fn classify_pattern(pattern: &str) -> QueryType {
if pattern.contains('(') || pattern.contains(')') || pattern.contains('@') {
QueryType::SExpression
} else {
QueryType::KindFilter
}
}
#[tracing::instrument(skip_all, fields(command = "query"))]
pub fn cmd_query(
args: &QueryArgs,
global: &GlobalArgs,
writer: &mut NdjsonWriter<impl Write>,
) -> Result<()> {
let start = Instant::now();
let workspace = global.resolve_workspace()?;
let validated = crate::path_safety::validate_path(&args.path, &workspace)?;
if !validated.exists() {
return Err(crate::error::AtomwriteError::NotFound { path: validated }.into());
}
let content =
crate::file_io::read_file_bytes(&validated, global.effective_max_filesize())?;
let lang_name = resolve_language_name(args.language.as_deref(), &validated, &content)?;
let mut parser = get_parser(&lang_name)
.with_context(|| format!("failed to load parser for language {lang_name}"))?;
let tree = parser
.parse(std::str::from_utf8(&content).unwrap_or(""))
.or_else(|| parser.parse_bytes(&content))
.with_context(|| format!("parser returned no tree for {lang_name}"))?;
let root = tree.root_node();
let mut match_count = 0usize;
let mut node_count = 0usize;
let show_positions = args.positions;
if args.kinds {
let mut kind_counts: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
walk_kinds(&root, &mut kind_counts, &mut node_count);
for (kind, count) in &kind_counts {
writer.write_event(&crate::ndjson_types::QueryKindEvent {
r#type: "query_kind",
path: validated.display().to_string(),
language: lang_name.clone(),
kind: kind.clone(),
count: *count,
})?;
}
match_count = kind_counts.len();
} else if let Some(pattern) = args.query.as_deref() {
match classify_pattern(pattern) {
QueryType::KindFilter => {
walk_kind_filter(
&root,
&content,
&validated,
&lang_name,
pattern,
show_positions,
writer,
&mut match_count,
&mut node_count,
)?;
}
QueryType::SExpression => {
let lang = get_language(&lang_name).with_context(|| {
format!("failed to load Language for S-expression: {lang_name}")
})?;
walk_sexpr(
&root,
&content,
&validated,
&lang_name,
&lang,
pattern,
show_positions,
writer,
&mut match_count,
&mut node_count,
)?;
}
}
} else if args.tree {
walk_tree(
&root,
&content,
&validated,
&lang_name,
show_positions,
writer,
&mut match_count,
&mut node_count,
)?;
} else {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: "must specify one of --query <KIND>, --tree, or --kinds".into(),
}
.into());
}
let elapsed_ms = start.elapsed().as_millis() as u64;
writer.write_event(&QuerySummary {
r#type: "query_summary",
path: validated.display().to_string(),
language: lang_name,
matches: match_count,
total_nodes: node_count,
elapsed_ms,
})?;
Ok(())
}
pub(crate) fn resolve_language_name(
override_lang: Option<&str>,
path: &Path,
content: &[u8],
) -> Result<String> {
if let Some(name) = override_lang {
if !has_language(name) {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: format!("unsupported language override: {name}"),
}
.into());
}
return Ok(name.to_owned());
}
match crate::syntax_check::detect_language_name(path, content) {
Some(name) => Ok(name),
None => Err(crate::error::AtomwriteError::InvalidInput {
reason: format!(
"could not detect language for {}; pass --language <LANG>",
path.display()
),
}
.into()),
}
}
fn node_text(source: &[u8], start: usize, end: usize) -> String {
let end = end.min(source.len());
let raw = source.get(start..end).unwrap_or(&[]);
let s = String::from_utf8_lossy(raw);
let cleaned: String = s
.chars()
.filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
.take(240)
.collect();
if cleaned.is_empty() {
"<empty>".to_owned()
} else {
cleaned
}
}
fn walk_kinds(
root: &Node,
kind_counts: &mut std::collections::BTreeMap<String, usize>,
node_count: &mut usize,
) {
let mut stack: Vec<Node> = Vec::with_capacity(64);
stack.push(root.clone());
while let Some(node) = stack.pop() {
*kind_counts.entry(node.kind().to_owned()).or_insert(0) += 1;
*node_count += 1;
let count = node.child_count() as u32;
for i in (0..count).rev() {
if let Some(child) = node.child(i) {
stack.push(child);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn walk_kind_filter(
root: &Node,
source: &[u8],
path: &Path,
lang_name: &str,
wanted: &str,
show_positions: bool,
writer: &mut NdjsonWriter<impl Write>,
match_count: &mut usize,
node_count: &mut usize,
) -> Result<()> {
let mut stack: Vec<Node> = vec![root.clone()];
while let Some(node) = stack.pop() {
*node_count += 1;
let kind = node.kind();
if kind == wanted {
let start = node.start_position();
let end = node.end_position();
writer.write_event(&crate::ndjson_types::QueryMatchEvent {
r#type: "query_match",
path: path.display().to_string(),
language: lang_name.to_owned(),
kind: kind.to_owned(),
is_named: node.is_named(),
text: node_text(source, node.start_byte(), node.end_byte()),
capture_name: None,
start_byte: show_positions.then_some(node.start_byte()),
end_byte: show_positions.then_some(node.end_byte()),
start_line: show_positions.then_some(start.row + 1),
start_column: show_positions.then_some(start.column + 1),
end_line: show_positions.then_some(end.row + 1),
end_column: show_positions.then_some(end.column + 1),
})?;
*match_count += 1;
}
let count = node.child_count() as u32;
for i in (0..count).rev() {
if let Some(child) = node.child(i) {
stack.push(child);
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn walk_sexpr(
root: &Node,
source: &[u8],
path: &Path,
lang_name: &str,
lang: &tree_sitter::Language,
pattern: &str,
show_positions: bool,
writer: &mut NdjsonWriter<impl Write>,
match_count: &mut usize,
node_count: &mut usize,
) -> Result<()> {
let _ = root; let mut parser = tree_sitter::Parser::new();
parser
.set_language(lang)
.with_context(|| format!("failed to set language for S-expression query on {lang_name}"))?;
let tree = parser.parse(source, None).with_context(|| {
format!("parser returned no tree for S-expression query on {lang_name}")
})?;
let fresh_root = tree.root_node();
let ts_query =
TsQuery::new(lang, pattern).with_context(|| format!("invalid S-expression: {pattern}"))?;
let mut cursor = tree_sitter::QueryCursor::new();
let capture_names = ts_query.capture_names();
let mut matches = cursor.matches(&ts_query, fresh_root, source);
while let Some(m) = matches.next() {
for capture in m.captures {
*node_count += 1;
let node = capture.node;
let capture_name = capture_names
.get(capture.index as usize)
.copied()
.unwrap_or("");
let start = node.start_position();
let end = node.end_position();
writer.write_event(&crate::ndjson_types::QueryMatchEvent {
r#type: "query_match",
path: path.display().to_string(),
language: lang_name.to_owned(),
kind: node.kind().to_owned(),
is_named: node.is_named(),
text: node_text(source, node.start_byte(), node.end_byte()),
capture_name: Some(capture_name.to_owned()),
start_byte: show_positions.then_some(node.start_byte()),
end_byte: show_positions.then_some(node.end_byte()),
start_line: show_positions.then_some(start.row + 1),
start_column: show_positions.then_some(start.column + 1),
end_line: show_positions.then_some(end.row + 1),
end_column: show_positions.then_some(end.column + 1),
})?;
*match_count += 1;
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn walk_tree(
root: &Node,
source: &[u8],
path: &Path,
lang_name: &str,
show_positions: bool,
writer: &mut NdjsonWriter<impl Write>,
match_count: &mut usize,
node_count: &mut usize,
) -> Result<()> {
let mut stack: Vec<Node> = vec![root.clone()];
while let Some(node) = stack.pop() {
*node_count += 1;
if node.is_named() {
let start = node.start_position();
let end = node.end_position();
writer.write_event(&crate::ndjson_types::QueryMatchEvent {
r#type: "query_match",
path: path.display().to_string(),
language: lang_name.to_owned(),
kind: node.kind().to_owned(),
is_named: true,
text: node_text(source, node.start_byte(), node.end_byte()),
capture_name: None,
start_byte: show_positions.then_some(node.start_byte()),
end_byte: show_positions.then_some(node.end_byte()),
start_line: show_positions.then_some(start.row + 1),
start_column: show_positions.then_some(start.column + 1),
end_line: show_positions.then_some(end.row + 1),
end_column: show_positions.then_some(end.column + 1),
})?;
*match_count += 1;
}
let count = node.child_count() as u32;
for i in (0..count).rev() {
if let Some(child) = node.child(i) {
stack.push(child);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn node_text_truncates_long_input() {
let s = node_text(b"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 0, 500);
assert_eq!(s.len(), 240);
}
#[test]
fn node_text_handles_empty() {
let s = node_text(b"hello", 3, 3);
assert_eq!(s, "<empty>");
}
}