mod extent;
mod lang;
mod query;
mod walk;
#[cfg(test)]
mod tests;
use cyberbrain_core::{Error, Result, Slash};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub const IGNORE_FILE: &str = ".cyberbrainignore";
pub const DEFAULT_MAX_FILE_BYTES: u64 = 1024 * 1024;
const SNIPPET_CHARS: usize = 160;
#[derive(Debug, Clone)]
pub struct FindOptions {
pub max_file_bytes: u64,
pub honour_gitignore: bool,
pub include_hidden: bool,
pub exclude: Vec<PathBuf>,
}
impl Default for FindOptions {
fn default() -> Self {
Self {
max_file_bytes: DEFAULT_MAX_FILE_BYTES,
honour_gitignore: true,
include_hidden: false,
exclude: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Language {
Rust,
Python,
JavaScript,
TypeScript,
Go,
Sql,
Toml,
Yaml,
Json,
Markdown,
}
impl Language {
pub fn as_str(self) -> &'static str {
match self {
Language::Rust => "rust",
Language::Python => "python",
Language::JavaScript => "javascript",
Language::TypeScript => "typescript",
Language::Go => "go",
Language::Sql => "sql",
Language::Toml => "toml",
Language::Yaml => "yaml",
Language::Json => "json",
Language::Markdown => "markdown",
}
}
pub fn of_path(path: &Path) -> Option<Language> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"rs" => Language::Rust,
"py" | "pyi" | "pyw" => Language::Python,
"js" | "mjs" | "cjs" | "jsx" => Language::JavaScript,
"ts" | "mts" | "cts" | "tsx" => Language::TypeScript,
"go" => Language::Go,
"sql" | "psql" | "pgsql" => Language::Sql,
"toml" => Language::Toml,
"yaml" | "yml" => Language::Yaml,
"json" | "jsonc" | "json5" => Language::Json,
"md" | "markdown" | "mdx" => Language::Markdown,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DefKind {
Function,
Method,
Class,
Struct,
Enum,
Union,
Trait,
Interface,
TypeAlias,
Impl,
Module,
Namespace,
Macro,
Const,
Static,
Variable,
Table,
View,
Index,
Trigger,
Schema,
Section,
Key,
Heading,
}
impl DefKind {
pub fn as_str(self) -> &'static str {
match self {
DefKind::Function => "function",
DefKind::Method => "method",
DefKind::Class => "class",
DefKind::Struct => "struct",
DefKind::Enum => "enum",
DefKind::Union => "union",
DefKind::Trait => "trait",
DefKind::Interface => "interface",
DefKind::TypeAlias => "type",
DefKind::Impl => "impl",
DefKind::Module => "module",
DefKind::Namespace => "namespace",
DefKind::Macro => "macro",
DefKind::Const => "const",
DefKind::Static => "static",
DefKind::Variable => "variable",
DefKind::Table => "table",
DefKind::View => "view",
DefKind::Index => "index",
DefKind::Trigger => "trigger",
DefKind::Schema => "schema",
DefKind::Section => "section",
DefKind::Key => "key",
DefKind::Heading => "heading",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Definition {
pub path: String,
pub language: Language,
pub kind: DefKind,
pub name: String,
pub scope: Option<String>,
pub line: u32,
pub start_line: u32,
pub end_line: u32,
pub snippet: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum MatchKind {
Exact,
CaseInsensitive,
Contains,
}
impl MatchKind {
pub fn as_str(self) -> &'static str {
match self {
MatchKind::Exact => "exact",
MatchKind::CaseInsensitive => "case-insensitive",
MatchKind::Contains => "contains",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hit {
pub def: Definition,
pub matched: MatchKind,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Skipped {
pub ignored_entries: usize,
pub gitignored_entries: usize,
pub hidden_entries: usize,
pub excluded_entries: usize,
pub symlinks: usize,
pub lockfiles: usize,
pub too_large: usize,
pub binary: usize,
pub unsupported: usize,
pub unsupported_by_extension: BTreeMap<String, usize>,
pub unreadable: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
pub struct FindResult {
pub symbol: String,
pub name: String,
pub scope: Option<String>,
pub root: PathBuf,
pub hits: Vec<Hit>,
pub matched_total: usize,
pub truncated: bool,
pub limit: usize,
pub files_scanned: usize,
pub bytes_scanned: u64,
pub definitions_indexed: usize,
pub skipped: Skipped,
pub ignore_files: Vec<String>,
pub caveats: Vec<String>,
pub elapsed: Duration,
}
#[derive(Debug, Clone)]
pub struct Scan {
pub root: PathBuf,
pub definitions: Vec<Definition>,
pub files_scanned: usize,
pub bytes_scanned: u64,
pub skipped: Skipped,
pub ignore_files: Vec<String>,
pub elapsed: Duration,
}
fn snippet_of(line: &str) -> String {
let t = line.trim();
if t.chars().count() <= SNIPPET_CHARS {
return t.to_string();
}
let mut s: String = t.chars().take(SNIPPET_CHARS - 1).collect();
s.push('…');
s
}
pub fn scan(root: &Path, opts: &FindOptions) -> Result<Scan> {
let started = Instant::now();
let mut walk = walk::Walk::new(root, opts)?;
let mut definitions: Vec<Definition> = Vec::new();
walk.run(&mut |file| {
let lines: Vec<&str> = file.text.lines().collect();
for d in lang::extract(file.language, file.text) {
debug_assert!(d.start <= d.line && d.line <= d.end && d.end < lines.len().max(1));
definitions.push(Definition {
path: file.rel.to_string(),
language: file.language,
kind: d.kind,
name: d.name,
scope: d.scope,
line: (d.line + 1) as u32,
start_line: (d.start + 1) as u32,
end_line: (d.end + 1) as u32,
snippet: snippet_of(lines.get(d.line).copied().unwrap_or("")),
});
}
})?;
Ok(Scan {
root: walk.root().to_path_buf(),
definitions,
files_scanned: walk.files_scanned,
bytes_scanned: walk.bytes_scanned,
skipped: walk.skipped,
ignore_files: walk.ignore_files,
elapsed: started.elapsed(),
})
}
pub fn find(root: &Path, symbol: &str, limit: usize, opts: &FindOptions) -> Result<FindResult> {
let q = query::parse(symbol);
if q.name.is_empty() {
return Err(Error::Config(
"find: the symbol is empty; give a name such as `open` or `App::open`".into(),
));
}
if limit == 0 {
return Err(Error::Config(
"find: --limit 0 would return nothing and say nothing; use 1 or more".into(),
));
}
let scan = scan(root, opts)?;
let mut caveats = Vec::new();
let mut hits = query::matches(&scan.definitions, &q, true);
let mut scope_used = q.scope.clone();
if hits.is_empty()
&& let Some(s) = &q.scope
{
hits = query::matches(&scan.definitions, &q, false);
if !hits.is_empty() {
caveats.push(format!(
"no definition of `{}` inside a scope matching `{s}`; showing every `{}` instead",
q.name, q.name
));
}
scope_used = None;
}
query::rank(&mut hits);
let matched_total = hits.len();
let truncated = matched_total > limit;
hits.truncate(limit);
if q.name.chars().count() < query::MIN_CONTAINS_LEN {
caveats.push(format!(
"`{}` is shorter than {} characters, so only exact and case-insensitive name matches were considered",
q.name,
query::MIN_CONTAINS_LEN
));
}
let root_ignore = scan.ignore_files.iter().any(|f| f == IGNORE_FILE);
if !root_ignore {
caveats.push(format!(
"no {IGNORE_FILE} at {}; every tree not hidden or gitignored was scanned, so a vendored or archived copy of the project would be listed alongside the live one",
Slash(&scan.root)
));
}
let files: std::collections::BTreeSet<&str> = hits
.iter()
.filter(|h| h.matched == MatchKind::Exact)
.map(|h| h.def.path.as_str())
.collect();
if files.len() > 1 {
caveats.push(format!(
"`{}` is defined in {} files ({}); if one is a copy, add it to {IGNORE_FILE}",
q.name,
files.len(),
files.iter().copied().collect::<Vec<_>>().join(", ")
));
}
if truncated {
caveats.push(format!(
"showing {limit} of {matched_total} matching definitions; raise --limit to see the rest"
));
}
if scan.skipped.too_large > 0 {
caveats.push(format!(
"{} file(s) over {} bytes were not read",
scan.skipped.too_large, opts.max_file_bytes
));
}
Ok(FindResult {
symbol: symbol.to_string(),
name: q.name,
scope: scope_used,
root: scan.root,
hits,
matched_total,
truncated,
limit,
files_scanned: scan.files_scanned,
bytes_scanned: scan.bytes_scanned,
definitions_indexed: scan.definitions.len(),
skipped: scan.skipped,
ignore_files: scan.ignore_files,
caveats,
elapsed: scan.elapsed,
})
}