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, Serialize};
11
12pub const PROJECT_CONFIG_FILE: &str = ".cgraph.toml";
13#[derive(Clone, Debug, Default, Eq, PartialEq)]
14pub struct SymbolFilter {
15 patterns: Vec<String>,
16}
17
18impl SymbolFilter {
19 pub fn from_patterns<I, S>(patterns: I) -> Result<Self>
20 where
21 I: IntoIterator<Item = S>,
22 S: Into<String>,
23 {
24 let patterns = patterns.into_iter();
25 let mut normalized = Vec::with_capacity(patterns.size_hint().0);
26 for pattern in patterns {
27 let pattern = pattern.into();
28 let pattern = pattern.trim();
29 if pattern.is_empty() {
30 bail!("symbol filter contains an empty pattern");
31 }
32 if !normalized.iter().any(|existing| existing == pattern) {
33 normalized.push(pattern.to_owned());
34 }
35 }
36 Ok(Self {
37 patterns: normalized,
38 })
39 }
40
41 pub fn is_ignored(&self, symbol_name: &str) -> bool {
42 self.patterns
43 .iter()
44 .any(|pattern| wildcard_matches(pattern, symbol_name))
45 }
46}
47
48fn wildcard_matches(pattern: &str, candidate: &str) -> bool {
49 let pattern = pattern.chars().collect::<Vec<_>>();
50 let candidate = candidate.chars().collect::<Vec<_>>();
51 let mut previous = vec![false; candidate.len() + 1];
52 previous[0] = true;
53 for pattern_character in pattern {
54 let mut current = vec![false; candidate.len() + 1];
55 if pattern_character == '*' {
56 current[0] = previous[0];
57 for index in 1..=candidate.len() {
58 current[index] = previous[index] || current[index - 1];
59 }
60 } else {
61 for index in 1..=candidate.len() {
62 current[index] = previous[index - 1] && candidate[index - 1] == pattern_character;
63 }
64 }
65 previous = current;
66 }
67 previous[candidate.len()]
68}
69
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct ProjectConfig {
72 pub symbol_filter: SymbolFilter,
73 pub workspace_only: bool,
74 pub lsp: Option<LspSettings>,
75}
76
77impl Default for ProjectConfig {
78 fn default() -> Self {
79 Self {
80 symbol_filter: SymbolFilter::default(),
81 workspace_only: true,
82 lsp: None,
83 }
84 }
85}
86
87#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
88#[serde(default = "LspSettings::empty", deny_unknown_fields)]
89pub struct LspSettings {
90 #[serde(default = "missing_name", deserialize_with = "deserialize_name")]
91 pub name: String,
92 pub command: String,
93 pub args: Vec<String>,
94 pub file_extensions: Option<Vec<String>>,
95}
96
97impl LspSettings {
98 fn empty() -> Self {
99 Self {
100 name: String::new(),
101 command: String::new(),
102 args: Vec::new(),
103 file_extensions: None,
104 }
105 }
106
107 fn template() -> Self {
108 Self::default()
109 }
110
111 fn normalize(mut self) -> Result<Self> {
112 self.command = self.command.trim().to_owned();
113 if self.command.is_empty() {
114 bail!("lsp.command must not be empty");
115 }
116 self.name = if self.name == missing_name() {
117 Path::new(&self.command)
118 .file_name()
119 .and_then(|name| name.to_str())
120 .unwrap_or(&self.command)
121 .trim_end_matches(".exe")
122 .to_owned()
123 } else {
124 self.name.trim().to_owned()
125 };
126 if self.args.iter().any(String::is_empty) {
127 bail!("lsp.args must not contain empty arguments");
128 }
129 self.file_extensions = self
130 .file_extensions
131 .take()
132 .map(normalize_file_extensions)
133 .transpose()?;
134 Ok(self)
135 }
136}
137
138impl Default for LspSettings {
139 fn default() -> Self {
140 Self {
141 name: "rust-analyzer".to_owned(),
142 command: "rust-analyzer".to_owned(),
143 args: Vec::new(),
144 file_extensions: Some(vec!["rs".to_owned()]),
145 }
146 }
147}
148
149fn missing_name() -> String {
150 "__cgraph_missing_name__".to_owned()
151}
152
153fn deserialize_name<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
154where
155 D: serde::Deserializer<'de>,
156{
157 let name = String::deserialize(deserializer)?;
158 if name.trim().is_empty() {
159 return Err(serde::de::Error::custom("lsp.name must not be empty"));
160 }
161 Ok(name)
162}
163
164fn project_config_template() -> String {
165 let lsp = toml::to_string(&LspSettings::template())
166 .expect("default LSP settings must serialize to TOML");
167 let commented_lsp = lsp
168 .lines()
169 .map(|line| format!("# {line}\n"))
170 .collect::<String>();
171 format!(
172 "# 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"
173 )
174}
175
176impl ProjectConfig {
177 pub fn path(workspace_root: &Path) -> PathBuf {
178 workspace_root.join(PROJECT_CONFIG_FILE)
179 }
180
181 pub fn create_if_missing(workspace_root: &Path) -> Result<PathBuf> {
182 let path = Self::path(workspace_root);
183 match OpenOptions::new().write(true).create_new(true).open(&path) {
184 Ok(mut file) => file
185 .write_all(project_config_template().as_bytes())
186 .with_context(|| {
187 format!("failed to initialize project config {}", path.display())
188 })?,
189 Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
190 Err(error) => {
191 return Err(error).with_context(|| {
192 format!("failed to create project config {}", path.display())
193 });
194 }
195 }
196 Ok(path)
197 }
198
199 pub fn load(workspace_root: &Path) -> Result<Self> {
200 let path = Self::path(workspace_root);
201 let contents = match fs::read_to_string(&path) {
202 Ok(contents) => contents,
203 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Self::default()),
204 Err(error) => {
205 return Err(error)
206 .with_context(|| format!("failed to read project config {}", path.display()));
207 }
208 };
209 let raw = toml::from_str::<RawProjectConfig>(&contents)
210 .with_context(|| format!("failed to parse project config {}", path.display()))?;
211 Ok(Self {
212 symbol_filter: SymbolFilter::from_patterns(raw.filters.symbols)
213 .with_context(|| format!("{} contains invalid filters.symbols", path.display()))?,
214 workspace_only: raw.filters.workspace_only,
215 lsp: raw
216 .lsp
217 .map(LspSettings::normalize)
218 .transpose()
219 .with_context(|| {
220 format!("{} contains invalid lsp configuration", path.display())
221 })?,
222 })
223 }
224}
225
226#[derive(Debug, Default, Deserialize)]
227#[serde(default, deny_unknown_fields)]
228struct RawProjectConfig {
229 lsp: Option<LspSettings>,
230 filters: RawFilters,
231}
232
233fn normalize_file_extensions(extensions: Vec<String>) -> Result<Vec<String>> {
234 if extensions.is_empty() {
235 bail!("lsp.file_extensions must contain at least one extension");
236 }
237
238 let mut normalized = Vec::with_capacity(extensions.len());
239 for extension in extensions {
240 let extension = extension.trim().trim_start_matches('.').to_lowercase();
241 if extension.is_empty() {
242 bail!("lsp.file_extensions must not contain empty extensions");
243 }
244 if extension.contains(['/', '\\', '*']) || extension.contains('.') {
245 bail!(
246 "lsp.file_extensions entries must be plain extensions without paths or wildcards"
247 );
248 }
249 if !normalized.contains(&extension) {
250 normalized.push(extension);
251 }
252 }
253 Ok(normalized)
254}
255
256#[derive(Debug, Deserialize)]
257#[serde(default, deny_unknown_fields)]
258struct RawFilters {
259 symbols: Vec<String>,
260 workspace_only: bool,
261}
262
263impl Default for RawFilters {
264 fn default() -> Self {
265 Self {
266 symbols: Vec::new(),
267 workspace_only: true,
268 }
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use std::{
275 fs,
276 path::PathBuf,
277 time::{SystemTime, UNIX_EPOCH},
278 };
279
280 use super::{LspSettings, ProjectConfig, SymbolFilter};
281
282 #[test]
283 fn loads_and_normalizes_project_local_symbol_filters() {
284 let workspace = temporary_workspace("load");
285 assert_eq!(ProjectConfig::load(&workspace).unwrap(), Default::default());
286 assert!(ProjectConfig::load(&workspace).unwrap().workspace_only);
287 let path = ProjectConfig::create_if_missing(&workspace).unwrap();
288 assert_eq!(path, workspace.join(".cgraph.toml"));
289 assert_eq!(ProjectConfig::load(&workspace).unwrap(), Default::default());
290 let template = fs::read_to_string(&path).unwrap();
291 assert!(template.contains("# name = \"rust-analyzer\""));
292 assert!(template.contains("# file_extensions = [\"rs\"]"));
293 fs::write(
294 &path,
295 "[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",
296 )
297 .unwrap();
298 ProjectConfig::create_if_missing(&workspace).unwrap();
299 assert!(fs::read_to_string(&path).unwrap().contains("*::into"));
300
301 let config = ProjectConfig::load(&workspace).unwrap();
302
303 assert!(!config.workspace_only);
304 assert_eq!(
305 config.lsp,
306 Some(LspSettings {
307 name: "rust-analyzer".to_owned(),
308 command: "/usr/bin/rust-analyzer".to_owned(),
309 args: vec!["--log-file=/tmp/ra.log".to_owned()],
310 file_extensions: Some(vec!["rs".to_owned()]),
311 })
312 );
313 fs::write(
314 &path,
315 "[lsp]\ncommand = \"/usr/bin/clangd\"\n\n[filters]\nworkspace_only = false\nsymbols = [\"*::into\", \"Option::is_some\", \"*::Some\"]\n",
316 )
317 .unwrap();
318 let config = ProjectConfig::load(&workspace).unwrap();
319 assert_eq!(config.lsp.map(|lsp| lsp.name), Some("clangd".to_owned()));
320 assert!(config.symbol_filter.is_ignored("Vec::into"));
321 assert!(config.symbol_filter.is_ignored("Option::is_some"));
322 assert!(config.symbol_filter.is_ignored("Option::Some"));
323 assert!(!config.symbol_filter.is_ignored("is_some"));
324 assert!(!config.symbol_filter.is_ignored("Option::some"));
325 assert!(
326 SymbolFilter::from_patterns(["*选*::方*"])
327 .unwrap()
328 .is_ignored("可选项::方法")
329 );
330 fs::remove_dir_all(workspace).unwrap();
331 }
332
333 #[test]
334 fn rejects_invalid_or_empty_filter_entries() {
335 let workspace = temporary_workspace("invalid");
336 fs::write(
337 workspace.join(".cgraph.toml"),
338 "[filters]\nsymbols = [\" \"]\n",
339 )
340 .unwrap();
341
342 let error = ProjectConfig::load(&workspace).unwrap_err();
343
344 assert!(format!("{error:#}").contains("empty pattern"));
345 fs::write(
346 workspace.join(".cgraph.toml"),
347 "[filters]\nsymbols = []\nunknown = true\n",
348 )
349 .unwrap();
350 let error = ProjectConfig::load(&workspace).unwrap_err();
351 assert!(format!("{error:#}").contains("unknown field"));
352 fs::write(
353 workspace.join(".cgraph.toml"),
354 "[lsp]\nargs = [\"--foo\"]\n",
355 )
356 .unwrap();
357 let error = ProjectConfig::load(&workspace).unwrap_err();
358 assert!(format!("{error:#}").contains("lsp.command must not be empty"));
359 fs::write(
360 workspace.join(".cgraph.toml"),
361 "[lsp]\nname = \" \"\ncommand = \"clangd\"\n",
362 )
363 .unwrap();
364 let error = ProjectConfig::load(&workspace).unwrap_err();
365 assert!(format!("{error:#}").contains("lsp.name must not be empty"));
366 fs::write(
367 workspace.join(".cgraph.toml"),
368 "[lsp]\ncommand = \"clangd\"\nfile_extensions = []\n",
369 )
370 .unwrap();
371 let error = ProjectConfig::load(&workspace).unwrap_err();
372 assert!(format!("{error:#}").contains("must contain at least one extension"));
373 fs::write(
374 workspace.join(".cgraph.toml"),
375 "[lsp]\ncommand = \"clangd\"\nfile_extensions = [\"src/*.cpp\"]\n",
376 )
377 .unwrap();
378 let error = ProjectConfig::load(&workspace).unwrap_err();
379 assert!(format!("{error:#}").contains("without paths or wildcards"));
380 fs::remove_dir_all(workspace).unwrap();
381 }
382
383 fn temporary_workspace(name: &str) -> PathBuf {
384 let unique = SystemTime::now()
385 .duration_since(UNIX_EPOCH)
386 .unwrap()
387 .as_nanos();
388 let workspace = std::env::temp_dir().join(format!("cgraph-config-{name}-{unique}"));
389 fs::create_dir(&workspace).unwrap();
390 workspace
391 }
392}