call-graph-cli 0.4.0

Interactive call and type hierarchy TUI
Documentation
#![doc = include_str!("README.md")]

use std::{
    fs::{self, OpenOptions},
    io::{ErrorKind, Write},
    path::{Path, PathBuf},
};

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};

pub const PROJECT_CONFIG_FILE: &str = ".cgraph.toml";
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SymbolFilter {
    patterns: Vec<String>,
}

impl SymbolFilter {
    pub fn from_patterns<I, S>(patterns: I) -> Result<Self>
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        let patterns = patterns.into_iter();
        let mut normalized = Vec::with_capacity(patterns.size_hint().0);
        for pattern in patterns {
            let pattern = pattern.into();
            let pattern = pattern.trim();
            if pattern.is_empty() {
                bail!("symbol filter contains an empty pattern");
            }
            if !normalized.iter().any(|existing| existing == pattern) {
                normalized.push(pattern.to_owned());
            }
        }
        Ok(Self {
            patterns: normalized,
        })
    }

    pub fn is_ignored(&self, symbol_name: &str) -> bool {
        self.patterns
            .iter()
            .any(|pattern| wildcard_matches(pattern, symbol_name))
    }
}

fn wildcard_matches(pattern: &str, candidate: &str) -> bool {
    let pattern = pattern.chars().collect::<Vec<_>>();
    let candidate = candidate.chars().collect::<Vec<_>>();
    let mut previous = vec![false; candidate.len() + 1];
    previous[0] = true;
    for pattern_character in pattern {
        let mut current = vec![false; candidate.len() + 1];
        if pattern_character == '*' {
            current[0] = previous[0];
            for index in 1..=candidate.len() {
                current[index] = previous[index] || current[index - 1];
            }
        } else {
            for index in 1..=candidate.len() {
                current[index] = previous[index - 1] && candidate[index - 1] == pattern_character;
            }
        }
        previous = current;
    }
    previous[candidate.len()]
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProjectConfig {
    pub symbol_filter: SymbolFilter,
    pub workspace_only: bool,
    pub lsp: Option<LspSettings>,
}

impl Default for ProjectConfig {
    fn default() -> Self {
        Self {
            symbol_filter: SymbolFilter::default(),
            workspace_only: true,
            lsp: None,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(default = "LspSettings::empty", deny_unknown_fields)]
pub struct LspSettings {
    #[serde(default = "missing_name", deserialize_with = "deserialize_name")]
    pub name: String,
    pub command: String,
    pub args: Vec<String>,
    pub file_extensions: Option<Vec<String>>,
}

impl LspSettings {
    fn empty() -> Self {
        Self {
            name: String::new(),
            command: String::new(),
            args: Vec::new(),
            file_extensions: None,
        }
    }

    fn template() -> Self {
        Self::default()
    }

    fn normalize(mut self) -> Result<Self> {
        self.command = self.command.trim().to_owned();
        if self.command.is_empty() {
            bail!("lsp.command must not be empty");
        }
        self.name = if self.name == missing_name() {
            Path::new(&self.command)
                .file_name()
                .and_then(|name| name.to_str())
                .unwrap_or(&self.command)
                .trim_end_matches(".exe")
                .to_owned()
        } else {
            self.name.trim().to_owned()
        };
        if self.args.iter().any(String::is_empty) {
            bail!("lsp.args must not contain empty arguments");
        }
        self.file_extensions = self
            .file_extensions
            .take()
            .map(normalize_file_extensions)
            .transpose()?;
        Ok(self)
    }
}

impl Default for LspSettings {
    fn default() -> Self {
        Self {
            name: "rust-analyzer".to_owned(),
            command: "rust-analyzer".to_owned(),
            args: Vec::new(),
            file_extensions: Some(vec!["rs".to_owned()]),
        }
    }
}

fn missing_name() -> String {
    "__cgraph_missing_name__".to_owned()
}

fn deserialize_name<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let name = String::deserialize(deserializer)?;
    if name.trim().is_empty() {
        return Err(serde::de::Error::custom("lsp.name must not be empty"));
    }
    Ok(name)
}

fn project_config_template() -> String {
    let lsp = toml::to_string(&LspSettings::template())
        .expect("default LSP settings must serialize to TOML");
    let commented_lsp = lsp
        .lines()
        .map(|line| format!("# {line}\n"))
        .collect::<String>();
    format!(
        "# Optional language-server command.\n# When omitted, cgraph selects rust-analyzer, clangd or pyrefly by project markers.\n#[lsp]\n# name identifies the server profile; command is the executable to run.\n{commented_lsp}[filters]\n# Keep discovered symbols inside the project root.\nworkspace_only = true\n# Full symbol names; * matches any number of characters.\nsymbols = []\n"
    )
}

impl ProjectConfig {
    pub fn path(workspace_root: &Path) -> PathBuf {
        workspace_root.join(PROJECT_CONFIG_FILE)
    }

    pub fn create_if_missing(workspace_root: &Path) -> Result<PathBuf> {
        let path = Self::path(workspace_root);
        match OpenOptions::new().write(true).create_new(true).open(&path) {
            Ok(mut file) => file
                .write_all(project_config_template().as_bytes())
                .with_context(|| {
                    format!("failed to initialize project config {}", path.display())
                })?,
            Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
            Err(error) => {
                return Err(error).with_context(|| {
                    format!("failed to create project config {}", path.display())
                });
            }
        }
        Ok(path)
    }

    pub fn load(workspace_root: &Path) -> Result<Self> {
        let path = Self::path(workspace_root);
        let contents = match fs::read_to_string(&path) {
            Ok(contents) => contents,
            Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Self::default()),
            Err(error) => {
                return Err(error)
                    .with_context(|| format!("failed to read project config {}", path.display()));
            }
        };
        let raw = toml::from_str::<RawProjectConfig>(&contents)
            .with_context(|| format!("failed to parse project config {}", path.display()))?;
        Ok(Self {
            symbol_filter: SymbolFilter::from_patterns(raw.filters.symbols)
                .with_context(|| format!("{} contains invalid filters.symbols", path.display()))?,
            workspace_only: raw.filters.workspace_only,
            lsp: raw
                .lsp
                .map(LspSettings::normalize)
                .transpose()
                .with_context(|| {
                    format!("{} contains invalid lsp configuration", path.display())
                })?,
        })
    }
}

#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawProjectConfig {
    lsp: Option<LspSettings>,
    filters: RawFilters,
}

fn normalize_file_extensions(extensions: Vec<String>) -> Result<Vec<String>> {
    if extensions.is_empty() {
        bail!("lsp.file_extensions must contain at least one extension");
    }

    let mut normalized = Vec::with_capacity(extensions.len());
    for extension in extensions {
        let extension = extension.trim().trim_start_matches('.').to_lowercase();
        if extension.is_empty() {
            bail!("lsp.file_extensions must not contain empty extensions");
        }
        if extension.contains(['/', '\\', '*']) || extension.contains('.') {
            bail!(
                "lsp.file_extensions entries must be plain extensions without paths or wildcards"
            );
        }
        if !normalized.contains(&extension) {
            normalized.push(extension);
        }
    }
    Ok(normalized)
}

#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawFilters {
    symbols: Vec<String>,
    workspace_only: bool,
}

impl Default for RawFilters {
    fn default() -> Self {
        Self {
            symbols: Vec::new(),
            workspace_only: true,
        }
    }
}

#[cfg(test)]
mod tests {
    use std::{
        fs,
        path::PathBuf,
        time::{SystemTime, UNIX_EPOCH},
    };

    use super::{LspSettings, ProjectConfig, SymbolFilter};

    #[test]
    fn loads_and_normalizes_project_local_symbol_filters() {
        let workspace = temporary_workspace("load");
        assert_eq!(ProjectConfig::load(&workspace).unwrap(), Default::default());
        assert!(ProjectConfig::load(&workspace).unwrap().workspace_only);
        let path = ProjectConfig::create_if_missing(&workspace).unwrap();
        assert_eq!(path, workspace.join(".cgraph.toml"));
        assert_eq!(ProjectConfig::load(&workspace).unwrap(), Default::default());
        let template = fs::read_to_string(&path).unwrap();
        assert!(template.contains("# name = \"rust-analyzer\""));
        assert!(template.contains("# file_extensions = [\"rs\"]"));
        fs::write(
            &path,
            "[lsp]\nname = \" rust-analyzer \"\ncommand = \" /usr/bin/rust-analyzer \"\nargs = [\"--log-file=/tmp/ra.log\"]\nfile_extensions = [\".RS\", \" rs \", \"RS\"]\n\n[filters]\nworkspace_only = false\nsymbols = [\"*::into\", \" Option::is_some \", \"*::into\", \"*::Some\"]\n",
        )
        .unwrap();
        ProjectConfig::create_if_missing(&workspace).unwrap();
        assert!(fs::read_to_string(&path).unwrap().contains("*::into"));

        let config = ProjectConfig::load(&workspace).unwrap();

        assert!(!config.workspace_only);
        assert_eq!(
            config.lsp,
            Some(LspSettings {
                name: "rust-analyzer".to_owned(),
                command: "/usr/bin/rust-analyzer".to_owned(),
                args: vec!["--log-file=/tmp/ra.log".to_owned()],
                file_extensions: Some(vec!["rs".to_owned()]),
            })
        );
        fs::write(
            &path,
            "[lsp]\ncommand = \"/usr/bin/clangd\"\n\n[filters]\nworkspace_only = false\nsymbols = [\"*::into\", \"Option::is_some\", \"*::Some\"]\n",
        )
        .unwrap();
        let config = ProjectConfig::load(&workspace).unwrap();
        assert_eq!(config.lsp.map(|lsp| lsp.name), Some("clangd".to_owned()));
        assert!(config.symbol_filter.is_ignored("Vec::into"));
        assert!(config.symbol_filter.is_ignored("Option::is_some"));
        assert!(config.symbol_filter.is_ignored("Option::Some"));
        assert!(!config.symbol_filter.is_ignored("is_some"));
        assert!(!config.symbol_filter.is_ignored("Option::some"));
        assert!(
            SymbolFilter::from_patterns(["*选*::方*"])
                .unwrap()
                .is_ignored("可选项::方法")
        );
        fs::remove_dir_all(workspace).unwrap();
    }

    #[test]
    fn rejects_invalid_or_empty_filter_entries() {
        let workspace = temporary_workspace("invalid");
        fs::write(
            workspace.join(".cgraph.toml"),
            "[filters]\nsymbols = [\"  \"]\n",
        )
        .unwrap();

        let error = ProjectConfig::load(&workspace).unwrap_err();

        assert!(format!("{error:#}").contains("empty pattern"));
        fs::write(
            workspace.join(".cgraph.toml"),
            "[filters]\nsymbols = []\nunknown = true\n",
        )
        .unwrap();
        let error = ProjectConfig::load(&workspace).unwrap_err();
        assert!(format!("{error:#}").contains("unknown field"));
        fs::write(
            workspace.join(".cgraph.toml"),
            "[lsp]\nargs = [\"--foo\"]\n",
        )
        .unwrap();
        let error = ProjectConfig::load(&workspace).unwrap_err();
        assert!(format!("{error:#}").contains("lsp.command must not be empty"));
        fs::write(
            workspace.join(".cgraph.toml"),
            "[lsp]\nname = \"  \"\ncommand = \"clangd\"\n",
        )
        .unwrap();
        let error = ProjectConfig::load(&workspace).unwrap_err();
        assert!(format!("{error:#}").contains("lsp.name must not be empty"));
        fs::write(
            workspace.join(".cgraph.toml"),
            "[lsp]\ncommand = \"clangd\"\nfile_extensions = []\n",
        )
        .unwrap();
        let error = ProjectConfig::load(&workspace).unwrap_err();
        assert!(format!("{error:#}").contains("must contain at least one extension"));
        fs::write(
            workspace.join(".cgraph.toml"),
            "[lsp]\ncommand = \"clangd\"\nfile_extensions = [\"src/*.cpp\"]\n",
        )
        .unwrap();
        let error = ProjectConfig::load(&workspace).unwrap_err();
        assert!(format!("{error:#}").contains("without paths or wildcards"));
        fs::remove_dir_all(workspace).unwrap();
    }

    fn temporary_workspace(name: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let workspace = std::env::temp_dir().join(format!("cgraph-config-{name}-{unique}"));
        fs::create_dir(&workspace).unwrap();
        workspace
    }
}