1#![doc = include_str!("README.md")]
2
3use std::{
4 fs::{self, OpenOptions},
5 io::{ErrorKind, Write},
6 path::{Path, PathBuf},
7};
8
9use anyhow::{Context, Result, bail};
10use serde::Deserialize;
11
12pub const PROJECT_CONFIG_FILE: &str = ".cgraph.toml";
13const PROJECT_CONFIG_TEMPLATE: &str =
14 "# Full symbol names; * matches any number of characters.\n[filters]\nsymbols = []\n";
15
16#[derive(Clone, Debug, Default, Eq, PartialEq)]
17pub struct SymbolFilter {
18 patterns: Vec<String>,
19}
20
21impl SymbolFilter {
22 pub fn from_patterns<I, S>(patterns: I) -> Result<Self>
23 where
24 I: IntoIterator<Item = S>,
25 S: Into<String>,
26 {
27 let patterns = patterns.into_iter();
28 let mut normalized = Vec::with_capacity(patterns.size_hint().0);
29 for pattern in patterns {
30 let pattern = pattern.into();
31 let pattern = pattern.trim();
32 if pattern.is_empty() {
33 bail!("symbol filter contains an empty pattern");
34 }
35 if !normalized.iter().any(|existing| existing == pattern) {
36 normalized.push(pattern.to_owned());
37 }
38 }
39 Ok(Self {
40 patterns: normalized,
41 })
42 }
43
44 pub fn is_ignored(&self, symbol_name: &str) -> bool {
45 self.patterns
46 .iter()
47 .any(|pattern| wildcard_matches(pattern, symbol_name))
48 }
49}
50
51fn wildcard_matches(pattern: &str, candidate: &str) -> bool {
52 let pattern = pattern.chars().collect::<Vec<_>>();
53 let candidate = candidate.chars().collect::<Vec<_>>();
54 let mut previous = vec![false; candidate.len() + 1];
55 previous[0] = true;
56 for pattern_character in pattern {
57 let mut current = vec![false; candidate.len() + 1];
58 if pattern_character == '*' {
59 current[0] = previous[0];
60 for index in 1..=candidate.len() {
61 current[index] = previous[index] || current[index - 1];
62 }
63 } else {
64 for index in 1..=candidate.len() {
65 current[index] = previous[index - 1] && candidate[index - 1] == pattern_character;
66 }
67 }
68 previous = current;
69 }
70 previous[candidate.len()]
71}
72
73#[derive(Clone, Debug, Default, Eq, PartialEq)]
74pub struct ProjectConfig {
75 pub symbol_filter: SymbolFilter,
76}
77
78impl ProjectConfig {
79 pub fn path(workspace_root: &Path) -> PathBuf {
80 workspace_root.join(PROJECT_CONFIG_FILE)
81 }
82
83 pub fn create_if_missing(workspace_root: &Path) -> Result<PathBuf> {
84 let path = Self::path(workspace_root);
85 match OpenOptions::new().write(true).create_new(true).open(&path) {
86 Ok(mut file) => file
87 .write_all(PROJECT_CONFIG_TEMPLATE.as_bytes())
88 .with_context(|| {
89 format!("failed to initialize project config {}", path.display())
90 })?,
91 Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
92 Err(error) => {
93 return Err(error).with_context(|| {
94 format!("failed to create project config {}", path.display())
95 });
96 }
97 }
98 Ok(path)
99 }
100
101 pub fn load(workspace_root: &Path) -> Result<Self> {
102 let path = Self::path(workspace_root);
103 let contents = match fs::read_to_string(&path) {
104 Ok(contents) => contents,
105 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Self::default()),
106 Err(error) => {
107 return Err(error)
108 .with_context(|| format!("failed to read project config {}", path.display()));
109 }
110 };
111 let raw = toml::from_str::<RawProjectConfig>(&contents)
112 .with_context(|| format!("failed to parse project config {}", path.display()))?;
113 Ok(Self {
114 symbol_filter: SymbolFilter::from_patterns(raw.filters.symbols)
115 .with_context(|| format!("{} contains invalid filters.symbols", path.display()))?,
116 })
117 }
118}
119
120#[derive(Debug, Default, Deserialize)]
121#[serde(default, deny_unknown_fields)]
122struct RawProjectConfig {
123 filters: RawFilters,
124}
125
126#[derive(Debug, Default, Deserialize)]
127#[serde(default, deny_unknown_fields)]
128struct RawFilters {
129 symbols: Vec<String>,
130}
131
132#[cfg(test)]
133mod tests {
134 use std::{
135 fs,
136 path::PathBuf,
137 time::{SystemTime, UNIX_EPOCH},
138 };
139
140 use super::{ProjectConfig, SymbolFilter};
141
142 #[test]
143 fn loads_and_normalizes_project_local_symbol_filters() {
144 let workspace = temporary_workspace("load");
145 assert_eq!(ProjectConfig::load(&workspace).unwrap(), Default::default());
146 let path = ProjectConfig::create_if_missing(&workspace).unwrap();
147 assert_eq!(path, workspace.join(".cgraph.toml"));
148 assert_eq!(ProjectConfig::load(&workspace).unwrap(), Default::default());
149 fs::write(
150 &path,
151 "[filters]\nsymbols = [\"*::into\", \" Option::is_some \", \"*::into\", \"*::Some\"]\n",
152 )
153 .unwrap();
154 ProjectConfig::create_if_missing(&workspace).unwrap();
155 assert!(fs::read_to_string(&path).unwrap().contains("*::into"));
156
157 let config = ProjectConfig::load(&workspace).unwrap();
158
159 assert!(config.symbol_filter.is_ignored("Vec::into"));
160 assert!(config.symbol_filter.is_ignored("Option::is_some"));
161 assert!(config.symbol_filter.is_ignored("Option::Some"));
162 assert!(!config.symbol_filter.is_ignored("is_some"));
163 assert!(!config.symbol_filter.is_ignored("Option::some"));
164 assert!(
165 SymbolFilter::from_patterns(["*选*::方*"])
166 .unwrap()
167 .is_ignored("可选项::方法")
168 );
169 fs::remove_dir_all(workspace).unwrap();
170 }
171
172 #[test]
173 fn rejects_invalid_or_empty_filter_entries() {
174 let workspace = temporary_workspace("invalid");
175 fs::write(
176 workspace.join(".cgraph.toml"),
177 "[filters]\nsymbols = [\" \"]\n",
178 )
179 .unwrap();
180
181 let error = ProjectConfig::load(&workspace).unwrap_err();
182
183 assert!(format!("{error:#}").contains("empty pattern"));
184 fs::write(
185 workspace.join(".cgraph.toml"),
186 "[filters]\nsymbols = []\nunknown = true\n",
187 )
188 .unwrap();
189 let error = ProjectConfig::load(&workspace).unwrap_err();
190 assert!(format!("{error:#}").contains("unknown field"));
191 fs::remove_dir_all(workspace).unwrap();
192 }
193
194 fn temporary_workspace(name: &str) -> PathBuf {
195 let unique = SystemTime::now()
196 .duration_since(UNIX_EPOCH)
197 .unwrap()
198 .as_nanos();
199 let workspace = std::env::temp_dir().join(format!("cgraph-config-{name}-{unique}"));
200 fs::create_dir(&workspace).unwrap();
201 workspace
202 }
203}