Skip to main content

sealmap_model/
source.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::io;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8use crate::hash::{ContentHash, normalise_newlines};
9use crate::path::SourcePath;
10use crate::sym::SymbolId;
11
12/// Metadata about one analysed source file. The file's text is not stored in
13/// the model; only its hash, so the model stays small and the corpus can
14/// detect drift.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct SourceFile {
17    /// Normalised path relative to the codebase root.
18    pub path: SourcePath,
19    /// Language tag of the adapter that read it, e.g. `rust`.
20    pub language: String,
21    /// Id of the module this file defines.
22    pub module: SymbolId,
23    /// Hash of the file text (newline-normalised).
24    pub hash: ContentHash,
25    /// Number of lines.
26    pub lines: u32,
27}
28
29impl SourceFile {
30    /// Describe `text` as the file at `path`.
31    pub fn new(path: SourcePath, language: impl Into<String>, module: SymbolId, text: &str) -> Self {
32        Self {
33            path,
34            language: language.into(),
35            module,
36            hash: ContentHash::of_text(text),
37            lines: text.lines().count() as u32,
38        }
39    }
40}
41
42/// Options for [`SourceSet::load_dir`].
43#[derive(Debug, Clone)]
44pub struct LoadOptions {
45    /// File extensions to include, without the dot (`["rs"]`).
46    pub extensions: Vec<String>,
47    /// Directory names skipped wherever they appear.
48    pub skip_dirs: Vec<String>,
49    /// Files larger than this are skipped (they are almost always generated).
50    pub max_file_bytes: u64,
51    /// Skip hidden files and directories (names starting with `.`).
52    pub skip_hidden: bool,
53    /// Honour `.gitignore` and `.ignore` files found under the root (not
54    /// above it, and never the user's global excludes, so the result still
55    /// depends only on the bytes under the root). On by default: ignored
56    /// trees are typically vendored registries, build output or caches.
57    pub respect_ignore_files: bool,
58}
59
60impl Default for LoadOptions {
61    fn default() -> Self {
62        Self {
63            extensions: vec!["rs".into()],
64            skip_dirs: ["target", "node_modules", "vendor", "dist", "build", "out"].map(String::from).to_vec(),
65            max_file_bytes: 2 * 1024 * 1024,
66            skip_hidden: true,
67            respect_ignore_files: true,
68        }
69    }
70}
71
72/// An in-memory set of source files keyed by [`SourcePath`].
73///
74/// Language adapters read from a `SourceSet`, never from the file system directly.
75/// That keeps them pure (same set in, same model out), makes them trivially
76/// testable, and lets embedders feed sources from anywhere: a git object
77/// store, an editor buffer, a network service.
78///
79/// Text is stored with line endings normalised to `\n`.
80///
81/// ```
82/// use sealmap_model::SourceSet;
83///
84/// let mut set = SourceSet::new();
85/// set.insert("src/lib.rs", "pub mod a;\r\n").unwrap();
86/// set.insert("src/a.rs", "pub fn f() {}").unwrap();
87///
88/// // Iteration is always in path order, regardless of insertion order.
89/// let paths: Vec<_> = set.iter().map(|(p, _)| p.as_str()).collect();
90/// assert_eq!(paths, ["src/a.rs", "src/lib.rs"]);
91/// assert_eq!(set.get_str("src/lib.rs"), Some("pub mod a;\n"));
92/// ```
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct SourceSet {
95    files: BTreeMap<SourcePath, String>,
96}
97
98impl SourceSet {
99    /// An empty set.
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Insert or replace a file. The path is normalised; text newlines are
105    /// normalised to `\n`.
106    pub fn insert(&mut self, path: impl AsRef<str>, text: impl AsRef<str>) -> Result<(), crate::PathError> {
107        let path = SourcePath::new(path)?;
108        self.files.insert(path, normalise_newlines(text.as_ref()).into_owned());
109        Ok(())
110    }
111
112    /// Text of the file at `path`.
113    pub fn get(&self, path: &SourcePath) -> Option<&str> {
114        self.files.get(path).map(String::as_str)
115    }
116
117    /// Text of the file at a raw path string (normalised first).
118    pub fn get_str(&self, path: &str) -> Option<&str> {
119        SourcePath::new(path).ok().and_then(|p| self.get(&p))
120    }
121
122    /// Files in path order.
123    pub fn iter(&self) -> impl Iterator<Item = (&SourcePath, &str)> {
124        self.files.iter().map(|(p, t)| (p, t.as_str()))
125    }
126
127    /// Paths in order.
128    pub fn paths(&self) -> impl Iterator<Item = &SourcePath> {
129        self.files.keys()
130    }
131
132    /// Number of files.
133    pub fn len(&self) -> usize {
134        self.files.len()
135    }
136
137    /// `true` if empty.
138    pub fn is_empty(&self) -> bool {
139        self.files.is_empty()
140    }
141
142    /// Keep only files for which `keep` returns `true`.
143    pub fn retain(&mut self, mut keep: impl FnMut(&SourcePath) -> bool) {
144        self.files.retain(|p, _| keep(p));
145    }
146
147    /// Recursively load every matching file under `root`.
148    ///
149    /// Symlinks are not followed and files that are not valid UTF-8 are
150    /// skipped, so the result depends only on the bytes under `root`.
151    pub fn load_dir(root: &Path, options: &LoadOptions) -> io::Result<Self> {
152        let mut set = Self::new();
153        let skip_dirs = options.skip_dirs.clone();
154        let walker = ignore::WalkBuilder::new(root)
155            .hidden(options.skip_hidden)
156            .parents(false)
157            .ignore(options.respect_ignore_files)
158            .git_ignore(options.respect_ignore_files)
159            .git_global(false)
160            .git_exclude(false)
161            .require_git(false)
162            .follow_links(false)
163            .filter_entry(move |e| {
164                !(e.file_type().is_some_and(|t| t.is_dir())
165                    && e.depth() > 0
166                    && skip_dirs.iter().any(|d| e.file_name() == d.as_str()))
167            })
168            .build();
169        for entry in walker {
170            let entry = entry.map_err(|e| io::Error::other(e.to_string()))?;
171            if !entry.file_type().is_some_and(|t| t.is_file()) {
172                continue;
173            }
174            let path = entry.path();
175            let ext_ok =
176                path.extension().and_then(|e| e.to_str()).is_some_and(|e| options.extensions.iter().any(|x| x == e));
177            if !ext_ok || entry.metadata().map_err(|e| io::Error::other(e.to_string()))?.len() > options.max_file_bytes
178            {
179                continue;
180            }
181            let Ok(text) = fs::read_to_string(path) else { continue };
182            let Ok(rel) = SourcePath::relative_to(path, root) else { continue };
183            set.files.insert(rel, normalise_newlines(&text).into_owned());
184        }
185        Ok(set)
186    }
187}