Skip to main content

cgraph/config/
mod.rs

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}