Skip to main content

cyberbrain_code/
lib.rs

1//! Cyberbrain code index. See docs/SPEC.md §10.
2//!
3//! `find(root, symbol, limit, opts)` walks a project tree, extracts *definitions* with
4//! per-language heuristics ([`lang`]), matches them against the symbol ([`query`]) and
5//! returns line ranges the caller reads instead of whole files.
6//!
7//! # No persisted index, and why
8//!
9//! The index is rebuilt on every call. Measured in release mode on this crate's own tree
10//! (131 files, 1.4 MiB, 2 688 definitions once `target/` and `node_modules/` are
11//! gitignored): 14 ms median, of which 3.7 ms is the walk and reading, about 4.5 ms is
12//! regex matching and the rest is lexing for line ranges. A FastAPI backend of 48 files
13//! took 4 ms, a Next.js frontend of 97 files 9 ms. A cache keyed on path, size and mtime
14//! would still have to `stat` every file to validate itself, so it could only save the
15//! 10 ms that are not the walk, and it would add the one failure this feature must not
16//! have: a line range that no longer points at the symbol, handed to a caller who then
17//! reads the wrong slice without knowing. A fresh scan cannot be stale. If a tree ever
18//! grows to where the scan is felt (roughly 100 ms per ten thousand source files), a
19//! cache validated by size+mtime is the next step, and the split into walk / extract /
20//! match leaves room for it. `find` is not a hook event; a hook that called it would
21//! spend its whole 15 ms budget (SPEC §9.1) on this tree, which is a reason for the
22//! hook not to call it, not for a cache.
23//!
24//! # Counts name their boundary (SPEC §14.3)
25//!
26//! `files_scanned` is files whose text reached an extractor. Every other file is under a
27//! reason in [`Skipped`], and a directory the ignore rules kept the walk out of counts
28//! once as an entry that was *not entered* — the files inside were never looked at, so
29//! no number claims to know how many there were.
30
31mod 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
44/// The ignore file honoured at every level of the tree (SPEC §10). gitignore syntax.
45pub const IGNORE_FILE: &str = ".cyberbrainignore";
46
47/// Files over this size are not read. A source file this big is generated or minified,
48/// and a hit inside it is not a slice anybody reads.
49pub const DEFAULT_MAX_FILE_BYTES: u64 = 1024 * 1024;
50
51/// Snippet length cap, in characters.
52const SNIPPET_CHARS: usize = 160;
53
54#[derive(Debug, Clone)]
55pub struct FindOptions {
56    pub max_file_bytes: u64,
57    /// Honour `.gitignore` files as well as `.cyberbrainignore`. On by default; a tree's
58    /// `target/` and `node_modules/` are exactly what the operator meant by them.
59    pub honour_gitignore: bool,
60    /// Descend into dot-directories and read dot-files. Off by default.
61    pub include_hidden: bool,
62    /// Directories never entered regardless of rules. The binary passes the store here:
63    /// its notes are `recall`'s to search.
64    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    /// By extension. `None` means no extractor exists for the file and it is counted as
109    /// unsupported, never silently dropped.
110    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/// What kind of definition a hit is. Serialised by [`DefKind::as_str`].
129#[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    /// SQL `CREATE TABLE`.
148    Table,
149    /// SQL `CREATE VIEW`.
150    View,
151    /// SQL `CREATE INDEX`.
152    Index,
153    /// SQL `CREATE TRIGGER`.
154    Trigger,
155    /// SQL `CREATE SCHEMA`.
156    Schema,
157    /// TOML `[table]`.
158    Section,
159    /// TOML / YAML / JSON key.
160    Key,
161    /// Markdown heading.
162    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/// One definition in the tree. Line numbers are 1-based and `start_line..=end_line` is
197/// inclusive; `line` is the line that names the symbol and always lies inside the range.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct Definition {
200    /// Root-relative, forward slashes on every platform.
201    pub path: String,
202    pub language: Language,
203    pub kind: DefKind,
204    pub name: String,
205    /// The enclosing named thing, when there is one: the `impl` target or class of a
206    /// method, the table of a TOML key, the dotted parent path of a YAML/JSON key.
207    pub scope: Option<String>,
208    pub line: u32,
209    pub start_line: u32,
210    pub end_line: u32,
211    /// The defining line, trimmed, at most [`SNIPPET_CHARS`] characters.
212    pub snippet: String,
213}
214
215/// How a hit matched the query. Ordered best first.
216#[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/// Everything the walk declined, by reason. Every field names the side of the boundary
240/// it counts.
241#[derive(Debug, Clone, Default, PartialEq, Eq)]
242pub struct Skipped {
243    /// Entries matched by a `.cyberbrainignore` rule. A directory counts once and was
244    /// not entered.
245    pub ignored_entries: usize,
246    /// Entries matched by a `.gitignore` rule. Same counting.
247    pub gitignored_entries: usize,
248    /// Dot-files and dot-directories, not entered.
249    pub hidden_entries: usize,
250    /// Directories in [`FindOptions::exclude`] (the store), not entered.
251    pub excluded_entries: usize,
252    /// Symbolic links, never followed.
253    pub symlinks: usize,
254    /// Lockfiles by name (`Cargo.lock`, `package-lock.json`, ...), not read.
255    pub lockfiles: usize,
256    /// Over [`FindOptions::max_file_bytes`], not read.
257    pub too_large: usize,
258    /// A NUL byte in the first 8 KiB, not parsed.
259    pub binary: usize,
260    /// No extractor for the extension, not read.
261    pub unsupported: usize,
262    /// The unsupported ones by extension, so "why is my `.java` not found" answers itself.
263    pub unsupported_by_extension: BTreeMap<String, usize>,
264    /// `(root-relative path, error)` for entries the filesystem refused.
265    pub unreadable: Vec<(String, String)>,
266}
267
268/// The result of one `find`.
269#[derive(Debug, Clone)]
270pub struct FindResult {
271    /// The symbol as given.
272    pub symbol: String,
273    /// The name part after scope splitting (`find` for `App::find`).
274    pub name: String,
275    /// The scope part, if the symbol had one and it was used to filter.
276    pub scope: Option<String>,
277    /// Absolute path of the tree that was scanned.
278    pub root: PathBuf,
279    /// Hits, best first, at most `limit`.
280    pub hits: Vec<Hit>,
281    /// Hits before truncation.
282    pub matched_total: usize,
283    pub truncated: bool,
284    pub limit: usize,
285    /// Files whose text reached an extractor.
286    pub files_scanned: usize,
287    pub bytes_scanned: u64,
288    /// Definitions extracted across all scanned files, matched or not.
289    pub definitions_indexed: usize,
290    pub skipped: Skipped,
291    /// Root-relative paths of the ignore files that were honoured, in walk order.
292    pub ignore_files: Vec<String>,
293    /// What the counts cannot say: no ignore file, a symbol defined in several files, a
294    /// scope that matched nothing. Never empty out of politeness (SPEC §7, same rule).
295    pub caveats: Vec<String>,
296    pub elapsed: Duration,
297}
298
299/// Every definition in the tree, for callers that want the whole index.
300#[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
321/// Walk `root` and extract every definition. Never stale: reads the files as they are now.
322pub 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
354/// `cyberbrain find <symbol>` (SPEC §10).
355///
356/// Errors are user errors (exit code 1): an empty symbol, a zero limit, a root that is
357/// not a directory, an ignore file that does not parse. Nothing else fails the call —
358/// an unreadable file is a count, not an error.
359pub 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}