use std::{
io::Read as _,
path::{Path, PathBuf},
};
use anyhow::Context as _;
use ignore::{DirEntry, Walk};
use swiftide_core::{Loader, indexing::IndexingStream, indexing::TextNode};
use tracing::{Span, debug_span, instrument};
#[derive(Clone, Debug)]
pub struct FileLoader {
pub(crate) root: PathBuf,
pub(crate) extensions: Option<Vec<String>>,
}
impl FileLoader {
pub fn new(root: impl AsRef<Path>) -> Self {
Self {
root: root.as_ref().to_path_buf(),
extensions: None,
}
}
#[must_use]
pub fn with_extensions(mut self, extensions: &[impl AsRef<str>]) -> Self {
let existing = self.extensions.get_or_insert_default();
existing.extend(extensions.iter().map(|ext| ext.as_ref().to_string()));
self
}
pub fn list_nodes(&self) -> Vec<TextNode> {
self.iter().filter_map(Result::ok).collect()
}
pub fn iter(&self) -> impl Iterator<Item = anyhow::Result<TextNode>> + use<> {
Iter::new(&self.root, self.extensions.clone()).fuse()
}
}
struct Iter {
walk: Walk,
include_extensions: Option<Vec<String>>,
span: Span,
}
impl Iterator for Iter {
type Item = anyhow::Result<TextNode>;
fn next(&mut self) -> Option<Self::Item> {
let _span = self.span.enter();
loop {
let entry = self.walk.next()?;
let entry = match entry {
Ok(entry) => entry,
Err(err) => return Some(Err(err.into())),
};
if let Some(node) = self.load(&entry) {
return Some(node);
}
}
}
}
impl Iter {
fn new(root: &Path, include_extensions: Option<Vec<String>>) -> Self {
let span = debug_span!("file_loader", root = %root.display());
tracing::debug!(parent: &span, extensions = ?include_extensions, "Loading files");
Self {
walk: Walk::new(root),
include_extensions,
span,
}
}
#[instrument(skip_all, fields(path = %entry.path().display()))]
fn load(&self, entry: &DirEntry) -> Option<anyhow::Result<TextNode>> {
if entry.file_type().is_some_and(|ft| !ft.is_file()) {
return None;
}
if let Some(extensions) = &self.include_extensions {
let Some(extension) = entry.path().extension() else {
tracing::trace!("Skipping file without extension");
return None;
};
let extension = extension.to_string_lossy();
if !extensions.iter().any(|ext| ext == &extension) {
tracing::trace!("Skipping file with extension {extension}");
return None;
}
}
tracing::debug!("Loading file");
match read_node(entry) {
Ok(node) => {
tracing::debug!(node_id = %node.id(), "Loaded file");
Some(Ok(node))
}
Err(err) => {
tracing::error!(error = %err, "Failed to load file");
Some(Err(err))
}
}
}
}
fn read_node(entry: &DirEntry) -> anyhow::Result<TextNode> {
let mut file = fs_err::File::open(entry.path()).context("Failed to open file")?;
let mut buf = vec![];
file.read_to_end(&mut buf).context("Failed to read file")?;
let content = String::from_utf8_lossy(&buf);
let original_size = content.len();
TextNode::builder()
.path(entry.path())
.chunk(content)
.original_size(original_size)
.build()
}
impl Loader for FileLoader {
type Output = String;
fn into_stream(self) -> IndexingStream<String> {
IndexingStream::iter(self.iter())
}
fn into_stream_boxed(self: Box<Self>) -> IndexingStream<String> {
self.into_stream()
}
}
#[cfg(test)]
mod test {
use tokio_stream::StreamExt as _;
use super::*;
#[test]
fn test_with_extensions() {
let loader = FileLoader::new("/tmp").with_extensions(&["rs"]);
assert_eq!(loader.extensions, Some(vec!["rs".to_string()]));
}
#[tokio::test]
async fn test_ignores_invalid_utf8() {
let tempdir = temp_dir::TempDir::new().unwrap();
fs_err::write(tempdir.child("invalid.txt"), [0x80, 0x80, 0x80]).unwrap();
let loader = FileLoader::new(tempdir.path()).with_extensions(&["txt"]);
let result = loader.into_stream().collect::<Vec<_>>().await;
assert_eq!(result.len(), 1);
let first = result.first().unwrap();
assert_eq!(first.as_ref().unwrap().chunk, "���".to_string());
}
}