use crate::filter::Filter;
use crate::store;
use anyhow::{Result, bail};
use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator};
pub const MAX_MATCHES: usize = 200;
pub const MAX_FILES: usize = 2000;
#[derive(Debug, Clone, serde::Serialize)]
pub struct Match {
pub path: String,
pub capture: String,
pub start_line: u32,
pub end_line: u32,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Matches {
pub language: String,
pub matches: Vec<Match>,
pub files: usize,
pub truncated: bool,
}
pub fn run(
db: &rusqlite::Connection,
language: &str,
source: &str,
filter: &Filter,
) -> Result<Matches> {
let language = language.trim().to_ascii_lowercase();
let Some(grammar) = crate::graph::language_grammar(&language) else {
bail!(
"no grammar for {language:?}; the languages with one are {}",
crate::graph::languages().join(", ")
);
};
let query = Query::new(&grammar, source)
.map_err(|e| anyhow::anyhow!("that is not a valid tree-sitter pattern: {e:?}"))?;
let names = query.capture_names().to_vec();
let scoped = filter.and_language(&language)?;
let files = store::file_rows(db, scoped.groups(), store::FileSort::Path, false, i64::MAX)?;
let mut parser = Parser::new();
parser.set_language(&grammar)?;
let mut out = Vec::new();
let mut parsed = 0usize;
let mut truncated = false;
for file in files.iter().take(MAX_FILES) {
if out.len() >= MAX_MATCHES {
truncated = true;
break;
}
let Some(text) = file_text(db, &file.path)? else {
continue;
};
let Some(tree) = parser.parse(&text, None) else {
continue;
};
parsed += 1;
let mut cursor = QueryCursor::new();
let mut hits = cursor.matches(&query, tree.root_node(), text.as_bytes());
while let Some(m) = hits.next() {
for capture in m.captures() {
if out.len() >= MAX_MATCHES {
truncated = true;
break;
}
let node = capture.node;
out.push(Match {
path: file.path.clone(),
capture: names
.get(capture.index as usize)
.map(|n| n.to_string())
.unwrap_or_default(),
start_line: node.start_position().row as u32 + 1,
end_line: node.end_position().row as u32 + 1,
text: first_line(&text[node.byte_range()]),
store: None,
});
}
}
}
if files.len() > MAX_FILES {
truncated = true;
}
Ok(Matches {
language,
matches: out,
files: parsed,
truncated,
})
}
fn file_text(db: &rusqlite::Connection, path: &str) -> Result<Option<String>> {
let chunks = store::chunks_overlapping(db, path, 1, u32::MAX)?;
if chunks.is_empty() {
return Ok(None);
}
let mut lines: Vec<(u32, String)> = Vec::new();
for chunk in &chunks {
for (offset, line) in chunk.text.lines().enumerate() {
let number = chunk.start_line + offset as u32;
if lines.iter().any(|(n, _)| *n == number) {
continue;
}
lines.push((number, line.to_string()));
}
}
lines.sort_by_key(|(number, _)| *number);
if lines.first().map(|(n, _)| *n) != Some(1) {
return Ok(None);
}
Ok(Some(
lines
.into_iter()
.map(|(_, line)| line)
.collect::<Vec<_>>()
.join("\n"),
))
}
fn first_line(raw: &str) -> String {
const WIDTH: usize = 120;
let line = raw.lines().next().unwrap_or("").trim();
if line.chars().count() <= WIDTH {
return line.to_string();
}
line.chars().take(WIDTH).collect::<String>() + "…"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_invalid_pattern_is_an_error_rather_than_an_empty_answer() {
let db = rusqlite::Connection::open_in_memory().unwrap();
db.execute_batch(store::SCHEMA).unwrap();
let err = run(&db, "rust", "(this is not a pattern", &Filter::default())
.expect_err("an unbalanced pattern does not compile");
assert!(
err.to_string().contains("not a valid tree-sitter pattern"),
"{err}"
);
}
#[test]
fn every_advertised_language_compiles_a_pattern() {
let db = rusqlite::Connection::open_in_memory().unwrap();
db.execute_batch(store::SCHEMA).unwrap();
let mut broken: Vec<String> = Vec::new();
for entry in crate::filter::LANGUAGES {
if !crate::graph::has_graph(entry.name) {
continue;
}
if let Err(e) = run(&db, entry.name, "(_) @node", &Filter::default()) {
broken.push(format!("{}: {e}", entry.name));
}
}
assert!(broken.is_empty(), "{}", broken.join("\n"));
}
#[test]
fn a_language_with_no_grammar_says_which_have_one() {
let db = rusqlite::Connection::open_in_memory().unwrap();
db.execute_batch(store::SCHEMA).unwrap();
let err = run(&db, "cobol", "(identifier) @x", &Filter::default())
.expect_err("cobol carries no grammar in this release");
assert!(err.to_string().contains("rust"), "{err}");
}
}