agent-file-tools 0.56.0

Agent File Tools — tree-sitter powered code analysis for AI agents
Documentation
//! Membership, configuration and identity inputs for the existing resolver.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;

use super::join::CallgraphBlob;
use crate::views::{Manifest, ManifestEntry, RelPath};

/// Manifest planes currently store their full blob keys as strings.
pub(crate) type BlobKey = String;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EntryKind {
    Regular,
    Directory,
    Symlink,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct DirEntry {
    pub name: Vec<u8>,
    pub kind: EntryKind,
}

pub(crate) trait ProjectFacts {
    /// Only generation-owned view joins collect consultation provenance.
    fn records_config_facts(&self) -> bool {
        false
    }
    fn config_fact(&self, _rel: &[u8], _name: &str) {}
    fn memo_start(&self, _path: &Path, _kind: &str, _name: &str) {}
    fn memo_finish(&self, _path: &Path, _kind: &str, _name: &str) {}
    fn memo_replay(&self, _path: &Path, _kind: &str, _name: &str) {}
    fn workspace_package(&self, _root: &Path, _name: &str) -> Option<Option<PathBuf>> {
        None
    }
    fn remember_workspace_package(&self, _root: &Path, _name: &str, _value: Option<PathBuf>) {}
    fn workspace_members(&self, _root: &Path) -> Option<Arc<Vec<PathBuf>>> {
        None
    }
    fn remember_workspace_members(&self, _root: &Path, _value: Arc<Vec<PathBuf>>) {}
    fn is_file(&self, rel: &[u8]) -> bool;
    fn is_dir(&self, rel: &[u8]) -> bool;
    fn config_bytes(&self, rel: &[u8]) -> Option<Arc<[u8]>>;
    fn attributed_config_bytes(&self, rel: &[u8]) -> Option<Arc<[u8]>> {
        self.config_bytes(rel)
    }
    fn symlink_target(&self, rel: &[u8]) -> Option<&[u8]>;
    fn canonical(&self, rel: &[u8]) -> Option<Vec<u8>>;
    fn canonical_path(&self, root: &Path, rel: &[u8]) -> Option<PathBuf> {
        Some(root.join(byte_path(&self.canonical(rel)?)))
    }
    fn list_dir(&self, rel: &[u8]) -> Vec<DirEntry>;
}

pub(crate) struct ManifestFacts<'m> {
    pub manifest: &'m Manifest,
    pub blobs: &'m dyn Fn(&BlobKey) -> Option<Arc<[u8]>>,
}

impl ManifestFacts<'_> {
    fn entry(&self, rel: &[u8]) -> Option<&ManifestEntry> {
        self.manifest.get(&RelPath::new(rel.to_vec()).ok()?)
    }
}

impl ProjectFacts for ManifestFacts<'_> {
    fn is_file(&self, rel: &[u8]) -> bool {
        matches!(self.entry(rel), Some(ManifestEntry::Regular { .. }))
    }

    fn is_dir(&self, rel: &[u8]) -> bool {
        let mut prefix = rel.to_vec();
        if !prefix.is_empty() {
            prefix.push(b'/');
        }
        self.manifest.entries().any(|(path, _)| {
            path.as_bytes().starts_with(&prefix) && path.as_bytes().len() > prefix.len()
        })
    }

    fn config_bytes(&self, rel: &[u8]) -> Option<Arc<[u8]>> {
        let path = self.canonical(rel)?;
        let ManifestEntry::Regular { planes, .. } = self.entry(&path)? else {
            return None;
        };
        let bytes = (self.blobs)(planes.callgraph.as_ref()?)?;
        match CallgraphBlob::from_bytes(&bytes).ok()? {
            CallgraphBlob::Config(config) if config.language == "config" => {
                Some(config.source.into())
            }
            _ => None,
        }
    }

    fn symlink_target(&self, rel: &[u8]) -> Option<&[u8]> {
        match self.entry(rel)? {
            ManifestEntry::Symlink { target_bytes } => Some(target_bytes.as_bytes()),
            _ => None,
        }
    }

    fn canonical(&self, rel: &[u8]) -> Option<Vec<u8>> {
        if rel.starts_with(b"/") {
            return None;
        }
        let mut pending = rel
            .split(|b| *b == b'/')
            .map(<[u8]>::to_vec)
            .collect::<std::collections::VecDeque<_>>();
        let mut components = Vec::<Vec<u8>>::new();
        let mut seen = BTreeSet::new();
        let mut followed = 0;
        while let Some(component) = pending.pop_front() {
            match component.as_slice() {
                b"" | b"." => continue,
                b".." => {
                    components.pop()?;
                    continue;
                }
                _ => components.push(component),
            }
            let path = components.join(&b'/');
            if let Some(target) = self.symlink_target(&path) {
                // Bound expanding cycles such as a -> a/a as well as exact cycles.
                followed += 1;
                if target.starts_with(b"/")
                    || followed > 40
                    || !seen.insert((path, pending.clone()))
                {
                    return None;
                }
                components.pop();
                for component in target.split(|b| *b == b'/').rev() {
                    pending.push_front(component.to_vec());
                }
            } else if !pending.is_empty() && !self.is_dir(&path) {
                return None;
            }
        }
        let path = components.join(&b'/');
        (self.entry(&path).is_some() || self.is_dir(&path)).then_some(path)
    }

    fn list_dir(&self, rel: &[u8]) -> Vec<DirEntry> {
        let mut prefix = rel.to_vec();
        if !prefix.is_empty() {
            prefix.push(b'/');
        }
        let mut entries = BTreeMap::new();
        for (path, entry) in self.manifest.entries() {
            let Some(tail) = path.as_bytes().strip_prefix(prefix.as_slice()) else {
                continue;
            };
            if tail.is_empty() {
                continue;
            }
            if let Some(slash) = tail.iter().position(|b| *b == b'/') {
                entries
                    .entry(tail[..slash].to_vec())
                    .or_insert(EntryKind::Directory);
            } else {
                let kind = match entry {
                    ManifestEntry::Regular { .. } => EntryKind::Regular,
                    ManifestEntry::Symlink { .. } => EntryKind::Symlink,
                    // A gitlink identifies a separate tree, not files in this view.
                    ManifestEntry::Gitlink { .. } | ManifestEntry::Synthetic { .. } => continue,
                };
                entries.insert(tail.to_vec(), kind);
            }
        }
        entries
            .into_iter()
            .map(|(name, kind)| DirEntry { name, kind })
            .collect()
    }
}

#[cfg(unix)]
pub(crate) fn path_bytes(path: &Path) -> Vec<u8> {
    use std::os::unix::ffi::OsStrExt;
    path.as_os_str().as_bytes().to_vec()
}
#[cfg(not(unix))]
pub(crate) fn path_bytes(path: &Path) -> Vec<u8> {
    path.to_string_lossy().replace('\\', "/").into_bytes()
}
#[cfg(unix)]
pub(crate) fn byte_path(bytes: &[u8]) -> PathBuf {
    use std::os::unix::ffi::OsStrExt;
    PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
}
#[cfg(not(unix))]
pub(crate) fn byte_path(bytes: &[u8]) -> PathBuf {
    PathBuf::from(String::from_utf8_lossy(bytes).as_ref())
}

/// Adapts the resolver's existing absolute paths to project-relative byte keys.
pub(crate) struct FactPaths<'a> {
    pub root: &'a Path,
    pub facts: &'a dyn ProjectFacts,
}
impl FactPaths<'_> {
    /// Test the recording capability before allocating relative path bytes, so
    /// the disk resolver's default hooks do no work and allocate nothing.
    pub fn config_fact(&self, path: &Path, name: &str) {
        if self.facts.records_config_facts() {
            if let Some(rel) = self.rel(path) {
                self.facts.config_fact(&rel, name);
            }
        }
    }
    pub fn config_field(&self, dir: &Path, input: &str, name: &str) {
        if self.facts.records_config_facts() {
            self.config_fact(&dir.join(input), name);
        }
    }
    fn rel(&self, path: &Path) -> Option<Vec<u8>> {
        Some(path_bytes(path.strip_prefix(self.root).unwrap_or(path)))
    }
    pub fn is_file(&self, path: &Path) -> bool {
        self.rel(path)
            .and_then(|rel| self.facts.canonical(&rel))
            .is_some_and(|rel| self.facts.is_file(&rel))
    }
    pub fn is_dir(&self, path: &Path) -> bool {
        self.rel(path)
            .and_then(|rel| self.facts.canonical(&rel))
            .is_some_and(|rel| self.facts.is_dir(&rel))
    }
    /// Opaque reads remain distinguishable from the field-instrumented readers.
    pub fn attributed_bytes(&self, path: &Path) -> Option<Arc<[u8]>> {
        self.facts.attributed_config_bytes(&self.rel(path)?)
    }
    pub fn canonical(&self, path: &Path) -> Option<PathBuf> {
        self.facts.canonical_path(self.root, &self.rel(path)?)
    }
    pub fn list_dir(&self, path: &Path) -> Vec<DirEntry> {
        self.rel(path)
            .map(|rel| self.facts.list_dir(&rel))
            .unwrap_or_default()
    }
}