1mod extent;
32mod lang;
33mod query;
34mod walk;
35
36#[cfg(test)]
37mod tests;
38
39use cyberbrain_core::{Error, Result, Slash};
40use std::collections::BTreeMap;
41use std::path::{Path, PathBuf};
42use std::time::{Duration, Instant};
43
44pub const IGNORE_FILE: &str = ".cyberbrainignore";
46
47pub const DEFAULT_MAX_FILE_BYTES: u64 = 1024 * 1024;
50
51const SNIPPET_CHARS: usize = 160;
53
54#[derive(Debug, Clone)]
55pub struct FindOptions {
56 pub max_file_bytes: u64,
57 pub honour_gitignore: bool,
60 pub include_hidden: bool,
62 pub exclude: Vec<PathBuf>,
65}
66
67impl Default for FindOptions {
68 fn default() -> Self {
69 Self {
70 max_file_bytes: DEFAULT_MAX_FILE_BYTES,
71 honour_gitignore: true,
72 include_hidden: false,
73 exclude: Vec::new(),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum Language {
80 Rust,
81 Python,
82 JavaScript,
83 TypeScript,
84 Go,
85 Sql,
86 Toml,
87 Yaml,
88 Json,
89 Markdown,
90}
91
92impl Language {
93 pub fn as_str(self) -> &'static str {
94 match self {
95 Language::Rust => "rust",
96 Language::Python => "python",
97 Language::JavaScript => "javascript",
98 Language::TypeScript => "typescript",
99 Language::Go => "go",
100 Language::Sql => "sql",
101 Language::Toml => "toml",
102 Language::Yaml => "yaml",
103 Language::Json => "json",
104 Language::Markdown => "markdown",
105 }
106 }
107
108 pub fn of_path(path: &Path) -> Option<Language> {
111 let ext = path.extension()?.to_str()?.to_ascii_lowercase();
112 Some(match ext.as_str() {
113 "rs" => Language::Rust,
114 "py" | "pyi" | "pyw" => Language::Python,
115 "js" | "mjs" | "cjs" | "jsx" => Language::JavaScript,
116 "ts" | "mts" | "cts" | "tsx" => Language::TypeScript,
117 "go" => Language::Go,
118 "sql" | "psql" | "pgsql" => Language::Sql,
119 "toml" => Language::Toml,
120 "yaml" | "yml" => Language::Yaml,
121 "json" | "jsonc" | "json5" => Language::Json,
122 "md" | "markdown" | "mdx" => Language::Markdown,
123 _ => return None,
124 })
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
130pub enum DefKind {
131 Function,
132 Method,
133 Class,
134 Struct,
135 Enum,
136 Union,
137 Trait,
138 Interface,
139 TypeAlias,
140 Impl,
141 Module,
142 Namespace,
143 Macro,
144 Const,
145 Static,
146 Variable,
147 Table,
149 View,
151 Index,
153 Trigger,
155 Schema,
157 Section,
159 Key,
161 Heading,
163}
164
165impl DefKind {
166 pub fn as_str(self) -> &'static str {
167 match self {
168 DefKind::Function => "function",
169 DefKind::Method => "method",
170 DefKind::Class => "class",
171 DefKind::Struct => "struct",
172 DefKind::Enum => "enum",
173 DefKind::Union => "union",
174 DefKind::Trait => "trait",
175 DefKind::Interface => "interface",
176 DefKind::TypeAlias => "type",
177 DefKind::Impl => "impl",
178 DefKind::Module => "module",
179 DefKind::Namespace => "namespace",
180 DefKind::Macro => "macro",
181 DefKind::Const => "const",
182 DefKind::Static => "static",
183 DefKind::Variable => "variable",
184 DefKind::Table => "table",
185 DefKind::View => "view",
186 DefKind::Index => "index",
187 DefKind::Trigger => "trigger",
188 DefKind::Schema => "schema",
189 DefKind::Section => "section",
190 DefKind::Key => "key",
191 DefKind::Heading => "heading",
192 }
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct Definition {
200 pub path: String,
202 pub language: Language,
203 pub kind: DefKind,
204 pub name: String,
205 pub scope: Option<String>,
208 pub line: u32,
209 pub start_line: u32,
210 pub end_line: u32,
211 pub snippet: String,
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
217pub enum MatchKind {
218 Exact,
219 CaseInsensitive,
220 Contains,
221}
222
223impl MatchKind {
224 pub fn as_str(self) -> &'static str {
225 match self {
226 MatchKind::Exact => "exact",
227 MatchKind::CaseInsensitive => "case-insensitive",
228 MatchKind::Contains => "contains",
229 }
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct Hit {
235 pub def: Definition,
236 pub matched: MatchKind,
237}
238
239#[derive(Debug, Clone, Default, PartialEq, Eq)]
242pub struct Skipped {
243 pub ignored_entries: usize,
246 pub gitignored_entries: usize,
248 pub hidden_entries: usize,
250 pub excluded_entries: usize,
252 pub symlinks: usize,
254 pub lockfiles: usize,
256 pub too_large: usize,
258 pub binary: usize,
260 pub unsupported: usize,
262 pub unsupported_by_extension: BTreeMap<String, usize>,
264 pub unreadable: Vec<(String, String)>,
266}
267
268#[derive(Debug, Clone)]
270pub struct FindResult {
271 pub symbol: String,
273 pub name: String,
275 pub scope: Option<String>,
277 pub root: PathBuf,
279 pub hits: Vec<Hit>,
281 pub matched_total: usize,
283 pub truncated: bool,
284 pub limit: usize,
285 pub files_scanned: usize,
287 pub bytes_scanned: u64,
288 pub definitions_indexed: usize,
290 pub skipped: Skipped,
291 pub ignore_files: Vec<String>,
293 pub caveats: Vec<String>,
296 pub elapsed: Duration,
297}
298
299#[derive(Debug, Clone)]
301pub struct Scan {
302 pub root: PathBuf,
303 pub definitions: Vec<Definition>,
304 pub files_scanned: usize,
305 pub bytes_scanned: u64,
306 pub skipped: Skipped,
307 pub ignore_files: Vec<String>,
308 pub elapsed: Duration,
309}
310
311fn snippet_of(line: &str) -> String {
312 let t = line.trim();
313 if t.chars().count() <= SNIPPET_CHARS {
314 return t.to_string();
315 }
316 let mut s: String = t.chars().take(SNIPPET_CHARS - 1).collect();
317 s.push('…');
318 s
319}
320
321pub fn scan(root: &Path, opts: &FindOptions) -> Result<Scan> {
323 let started = Instant::now();
324 let mut walk = walk::Walk::new(root, opts)?;
325 let mut definitions: Vec<Definition> = Vec::new();
326 walk.run(&mut |file| {
327 let lines: Vec<&str> = file.text.lines().collect();
328 for d in lang::extract(file.language, file.text) {
329 debug_assert!(d.start <= d.line && d.line <= d.end && d.end < lines.len().max(1));
330 definitions.push(Definition {
331 path: file.rel.to_string(),
332 language: file.language,
333 kind: d.kind,
334 name: d.name,
335 scope: d.scope,
336 line: (d.line + 1) as u32,
337 start_line: (d.start + 1) as u32,
338 end_line: (d.end + 1) as u32,
339 snippet: snippet_of(lines.get(d.line).copied().unwrap_or("")),
340 });
341 }
342 })?;
343 Ok(Scan {
344 root: walk.root().to_path_buf(),
345 definitions,
346 files_scanned: walk.files_scanned,
347 bytes_scanned: walk.bytes_scanned,
348 skipped: walk.skipped,
349 ignore_files: walk.ignore_files,
350 elapsed: started.elapsed(),
351 })
352}
353
354pub fn find(root: &Path, symbol: &str, limit: usize, opts: &FindOptions) -> Result<FindResult> {
360 let q = query::parse(symbol);
361 if q.name.is_empty() {
362 return Err(Error::Config(
363 "find: the symbol is empty; give a name such as `open` or `App::open`".into(),
364 ));
365 }
366 if limit == 0 {
367 return Err(Error::Config(
368 "find: --limit 0 would return nothing and say nothing; use 1 or more".into(),
369 ));
370 }
371 let scan = scan(root, opts)?;
372 let mut caveats = Vec::new();
373
374 let mut hits = query::matches(&scan.definitions, &q, true);
375 let mut scope_used = q.scope.clone();
376 if hits.is_empty()
377 && let Some(s) = &q.scope
378 {
379 hits = query::matches(&scan.definitions, &q, false);
380 if !hits.is_empty() {
381 caveats.push(format!(
382 "no definition of `{}` inside a scope matching `{s}`; showing every `{}` instead",
383 q.name, q.name
384 ));
385 }
386 scope_used = None;
387 }
388 query::rank(&mut hits);
389
390 let matched_total = hits.len();
391 let truncated = matched_total > limit;
392 hits.truncate(limit);
393
394 if q.name.chars().count() < query::MIN_CONTAINS_LEN {
395 caveats.push(format!(
396 "`{}` is shorter than {} characters, so only exact and case-insensitive name matches were considered",
397 q.name,
398 query::MIN_CONTAINS_LEN
399 ));
400 }
401 let root_ignore = scan.ignore_files.iter().any(|f| f == IGNORE_FILE);
402 if !root_ignore {
403 caveats.push(format!(
404 "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",
405 Slash(&scan.root)
406 ));
407 }
408 let files: std::collections::BTreeSet<&str> = hits
409 .iter()
410 .filter(|h| h.matched == MatchKind::Exact)
411 .map(|h| h.def.path.as_str())
412 .collect();
413 if files.len() > 1 {
414 caveats.push(format!(
415 "`{}` is defined in {} files ({}); if one is a copy, add it to {IGNORE_FILE}",
416 q.name,
417 files.len(),
418 files.iter().copied().collect::<Vec<_>>().join(", ")
419 ));
420 }
421 if truncated {
422 caveats.push(format!(
423 "showing {limit} of {matched_total} matching definitions; raise --limit to see the rest"
424 ));
425 }
426 if scan.skipped.too_large > 0 {
427 caveats.push(format!(
428 "{} file(s) over {} bytes were not read",
429 scan.skipped.too_large, opts.max_file_bytes
430 ));
431 }
432
433 Ok(FindResult {
434 symbol: symbol.to_string(),
435 name: q.name,
436 scope: scope_used,
437 root: scan.root,
438 hits,
439 matched_total,
440 truncated,
441 limit,
442 files_scanned: scan.files_scanned,
443 bytes_scanned: scan.bytes_scanned,
444 definitions_indexed: scan.definitions.len(),
445 skipped: scan.skipped,
446 ignore_files: scan.ignore_files,
447 caveats,
448 elapsed: scan.elapsed,
449 })
450}