#![doc = include_str!("README.md")]
use std::{
fs::{self, OpenOptions},
io::{ErrorKind, Write},
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
pub const PROJECT_CONFIG_FILE: &str = ".cgraph.toml";
const PROJECT_CONFIG_TEMPLATE: &str = "[filters]\n\
# Keep discovered symbols inside the project root.\n\
workspace_only = true\n\
# Full symbol names; * matches any number of characters.\n\
symbols = []\n";
#[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,
}
impl Default for ProjectConfig {
fn default() -> Self {
Self {
symbol_filter: SymbolFilter::default(),
workspace_only: true,
}
}
}
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,
})
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawProjectConfig {
filters: RawFilters,
}
#[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::{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());
fs::write(
&path,
"[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!(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::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
}
}