Skip to main content

solidity_language_server/
config.rs

1use std::path::{Path, PathBuf};
2
3/// Project-level configuration extracted from `foundry.toml`.
4///
5/// This includes both lint settings and compiler settings needed by the
6/// solc runner (solc version, remappings, optimizer, via-IR, EVM version).
7#[derive(Debug, Clone)]
8pub struct FoundryConfig {
9    /// The project root where `foundry.toml` was found.
10    pub root: PathBuf,
11    /// Solc version from `[profile.default] solc = "0.8.26"`.
12    /// `None` means use the system default.
13    pub solc_version: Option<String>,
14    /// Remappings from `[profile.default] remappings = [...]`.
15    /// Empty if not specified (will fall back to `forge remappings`).
16    pub remappings: Vec<String>,
17    /// Whether to compile via the Yul IR pipeline (`via_ir = true`).
18    /// Maps to `"viaIR": true` in the solc standard JSON settings.
19    pub via_ir: bool,
20    /// Whether the optimizer is enabled (`optimizer = true`).
21    pub optimizer: bool,
22    /// Number of optimizer runs (`optimizer_runs = 200`).
23    /// Only meaningful when `optimizer` is `true`.
24    pub optimizer_runs: u64,
25    /// Target EVM version (`evm_version = "cancun"`).
26    /// Maps to `"evmVersion"` in the solc standard JSON settings.
27    /// `None` means use solc's default.
28    pub evm_version: Option<String>,
29    /// Error codes to suppress from diagnostics (`ignored_error_codes = [2394, 5574]`).
30    pub ignored_error_codes: Vec<u64>,
31}
32
33impl Default for FoundryConfig {
34    fn default() -> Self {
35        Self {
36            root: PathBuf::new(),
37            solc_version: None,
38            remappings: Vec::new(),
39            via_ir: false,
40            optimizer: false,
41            optimizer_runs: 200,
42            evm_version: None,
43            ignored_error_codes: Vec::new(),
44        }
45    }
46}
47
48/// Load project configuration from the nearest `foundry.toml`.
49pub fn load_foundry_config(file_path: &Path) -> FoundryConfig {
50    let toml_path = match find_foundry_toml(file_path) {
51        Some(p) => p,
52        None => return FoundryConfig::default(),
53    };
54    load_foundry_config_from_toml(&toml_path)
55}
56
57/// Load project configuration from a known `foundry.toml` path.
58pub fn load_foundry_config_from_toml(toml_path: &Path) -> FoundryConfig {
59    let root = toml_path.parent().unwrap_or(Path::new("")).to_path_buf();
60
61    let content = match std::fs::read_to_string(toml_path) {
62        Ok(c) => c,
63        Err(_) => {
64            return FoundryConfig {
65                root,
66                ..Default::default()
67            };
68        }
69    };
70
71    let table: toml::Table = match content.parse() {
72        Ok(t) => t,
73        Err(_) => {
74            return FoundryConfig {
75                root,
76                ..Default::default()
77            };
78        }
79    };
80
81    let profile_name = std::env::var("FOUNDRY_PROFILE").unwrap_or_else(|_| "default".to_string());
82
83    let profile = table
84        .get("profile")
85        .and_then(|p| p.as_table())
86        .and_then(|p| p.get(&profile_name))
87        .and_then(|p| p.as_table());
88
89    let profile = match profile {
90        Some(p) => p,
91        None => {
92            return FoundryConfig {
93                root,
94                ..Default::default()
95            };
96        }
97    };
98
99    // Parse solc version: `solc = "0.8.26"` or `solc_version = "0.8.26"`
100    let solc_version = profile
101        .get("solc")
102        .or_else(|| profile.get("solc_version"))
103        .and_then(|v| v.as_str())
104        .map(|s| s.to_string());
105
106    // Parse remappings: `remappings = ["ds-test/=lib/...", ...]`
107    let remappings = profile
108        .get("remappings")
109        .and_then(|v| v.as_array())
110        .map(|arr| {
111            arr.iter()
112                .filter_map(|v| v.as_str())
113                .map(|s| s.to_string())
114                .collect()
115        })
116        .unwrap_or_default();
117
118    // Parse via_ir: `via_ir = true`
119    let via_ir = profile
120        .get("via_ir")
121        .and_then(|v| v.as_bool())
122        .unwrap_or(false);
123
124    // Parse optimizer: `optimizer = true`
125    let optimizer = profile
126        .get("optimizer")
127        .and_then(|v| v.as_bool())
128        .unwrap_or(false);
129
130    // Parse optimizer_runs: `optimizer_runs = 200`
131    let optimizer_runs = profile
132        .get("optimizer_runs")
133        .and_then(|v| v.as_integer())
134        .map(|v| v as u64)
135        .unwrap_or(200);
136
137    // Parse evm_version: `evm_version = "cancun"` or `evm_version = "osaka"`
138    let evm_version = profile
139        .get("evm_version")
140        .and_then(|v| v.as_str())
141        .map(|s| s.to_string());
142
143    // Parse ignored_error_codes: `ignored_error_codes = [2394, 6321, 3860, 5574]`
144    let ignored_error_codes = profile
145        .get("ignored_error_codes")
146        .and_then(|v| v.as_array())
147        .map(|arr| {
148            arr.iter()
149                .filter_map(|v| v.as_integer())
150                .map(|v| v as u64)
151                .collect()
152        })
153        .unwrap_or_default();
154
155    FoundryConfig {
156        root,
157        solc_version,
158        remappings,
159        via_ir,
160        optimizer,
161        optimizer_runs,
162        evm_version,
163        ignored_error_codes,
164    }
165}
166
167/// Lint-related configuration extracted from `foundry.toml`.
168#[derive(Debug, Clone)]
169pub struct LintConfig {
170    /// The project root where `foundry.toml` was found.
171    pub root: PathBuf,
172    /// Whether linting is enabled on build (default: true).
173    pub lint_on_build: bool,
174    /// Compiled glob patterns from the `ignore` list.
175    pub ignore_patterns: Vec<glob::Pattern>,
176}
177
178impl Default for LintConfig {
179    fn default() -> Self {
180        Self {
181            root: PathBuf::new(),
182            lint_on_build: true,
183            ignore_patterns: Vec::new(),
184        }
185    }
186}
187
188impl LintConfig {
189    /// Returns `true` if the given file should be linted.
190    ///
191    /// A file is skipped when:
192    /// - `lint_on_build` is `false`, or
193    /// - the file's path (relative to the project root) matches any `ignore` pattern.
194    pub fn should_lint(&self, file_path: &Path) -> bool {
195        if !self.lint_on_build {
196            return false;
197        }
198
199        if self.ignore_patterns.is_empty() {
200            return true;
201        }
202
203        // Build a relative path from the project root so that patterns like
204        // "test/**/*" work correctly.
205        let relative = file_path.strip_prefix(&self.root).unwrap_or(file_path);
206
207        let rel_str = relative.to_string_lossy();
208
209        for pattern in &self.ignore_patterns {
210            if pattern.matches(&rel_str) {
211                return false;
212            }
213        }
214
215        true
216    }
217}
218
219/// Returns the root of the git repository containing `start`, if any.
220///
221/// This mirrors foundry's own `find_git_root` behavior: walk up ancestors
222/// until a directory containing `.git` is found.
223fn find_git_root(start: &Path) -> Option<PathBuf> {
224    let start = if start.is_file() {
225        start.parent()?
226    } else {
227        start
228    };
229    start
230        .ancestors()
231        .find(|p| p.join(".git").exists())
232        .map(Path::to_path_buf)
233}
234
235/// Walk up from `start` to find the nearest `foundry.toml`, stopping at the
236/// git repository root (consistent with foundry's `find_project_root`).
237///
238/// See: <https://github.com/foundry-rs/foundry/blob/5389caefb5bfb035c547dffb4fd0f441a37e5371/crates/config/src/utils.rs#L62>
239pub fn find_foundry_toml(start: &Path) -> Option<PathBuf> {
240    let start_dir = if start.is_file() {
241        start.parent()?
242    } else {
243        start
244    };
245
246    let boundary = find_git_root(start_dir);
247
248    start_dir
249        .ancestors()
250        // Don't look outside of the git repo, matching foundry's behavior.
251        .take_while(|p| {
252            if let Some(boundary) = &boundary {
253                p.starts_with(boundary)
254            } else {
255                true
256            }
257        })
258        .find(|p| p.join("foundry.toml").is_file())
259        .map(|p| p.join("foundry.toml"))
260}
261
262/// Load the lint configuration from the nearest `foundry.toml` relative to
263/// `file_path`. Returns `LintConfig::default()` when no config is found or
264/// the relevant sections are absent.
265pub fn load_lint_config(file_path: &Path) -> LintConfig {
266    let toml_path = match find_foundry_toml(file_path) {
267        Some(p) => p,
268        None => return LintConfig::default(),
269    };
270
271    let root = toml_path.parent().unwrap_or(Path::new("")).to_path_buf();
272
273    let content = match std::fs::read_to_string(&toml_path) {
274        Ok(c) => c,
275        Err(_) => {
276            return LintConfig {
277                root,
278                ..Default::default()
279            };
280        }
281    };
282
283    let table: toml::Table = match content.parse() {
284        Ok(t) => t,
285        Err(_) => {
286            return LintConfig {
287                root,
288                ..Default::default()
289            };
290        }
291    };
292
293    // Determine the active profile (default: "default").
294    let profile_name = std::env::var("FOUNDRY_PROFILE").unwrap_or_else(|_| "default".to_string());
295
296    // Look up [profile.<name>.lint]
297    let lint_table = table
298        .get("profile")
299        .and_then(|p| p.as_table())
300        .and_then(|p| p.get(&profile_name))
301        .and_then(|p| p.as_table())
302        .and_then(|p| p.get("lint"))
303        .and_then(|l| l.as_table());
304
305    let lint_table = match lint_table {
306        Some(t) => t,
307        None => {
308            return LintConfig {
309                root,
310                ..Default::default()
311            };
312        }
313    };
314
315    // Parse lint_on_build (default: true)
316    let lint_on_build = lint_table
317        .get("lint_on_build")
318        .and_then(|v| v.as_bool())
319        .unwrap_or(true);
320
321    // Parse ignore patterns
322    let ignore_patterns = lint_table
323        .get("ignore")
324        .and_then(|v| v.as_array())
325        .map(|arr| {
326            arr.iter()
327                .filter_map(|v| v.as_str())
328                .filter_map(|s| glob::Pattern::new(s).ok())
329                .collect()
330        })
331        .unwrap_or_default();
332
333    LintConfig {
334        root,
335        lint_on_build,
336        ignore_patterns,
337    }
338}
339
340/// Load lint config from a known `foundry.toml` path (used when reloading
341/// after a file-watch notification).
342pub fn load_lint_config_from_toml(toml_path: &Path) -> LintConfig {
343    let root = toml_path.parent().unwrap_or(Path::new("")).to_path_buf();
344
345    let content = match std::fs::read_to_string(toml_path) {
346        Ok(c) => c,
347        Err(_) => {
348            return LintConfig {
349                root,
350                ..Default::default()
351            };
352        }
353    };
354
355    let table: toml::Table = match content.parse() {
356        Ok(t) => t,
357        Err(_) => {
358            return LintConfig {
359                root,
360                ..Default::default()
361            };
362        }
363    };
364
365    let profile_name = std::env::var("FOUNDRY_PROFILE").unwrap_or_else(|_| "default".to_string());
366
367    let lint_table = table
368        .get("profile")
369        .and_then(|p| p.as_table())
370        .and_then(|p| p.get(&profile_name))
371        .and_then(|p| p.as_table())
372        .and_then(|p| p.get("lint"))
373        .and_then(|l| l.as_table());
374
375    let lint_table = match lint_table {
376        Some(t) => t,
377        None => {
378            return LintConfig {
379                root,
380                ..Default::default()
381            };
382        }
383    };
384
385    let lint_on_build = lint_table
386        .get("lint_on_build")
387        .and_then(|v| v.as_bool())
388        .unwrap_or(true);
389
390    let ignore_patterns = lint_table
391        .get("ignore")
392        .and_then(|v| v.as_array())
393        .map(|arr| {
394            arr.iter()
395                .filter_map(|v| v.as_str())
396                .filter_map(|s| glob::Pattern::new(s).ok())
397                .collect()
398        })
399        .unwrap_or_default();
400
401    LintConfig {
402        root,
403        lint_on_build,
404        ignore_patterns,
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use std::fs;
412
413    #[test]
414    fn test_default_config_lints_everything() {
415        let config = LintConfig::default();
416        assert!(config.should_lint(Path::new("test/MyTest.sol")));
417        assert!(config.should_lint(Path::new("src/Token.sol")));
418    }
419
420    #[test]
421    fn test_lint_on_build_false_skips_all() {
422        let config = LintConfig {
423            lint_on_build: false,
424            ..Default::default()
425        };
426        assert!(!config.should_lint(Path::new("src/Token.sol")));
427    }
428
429    #[test]
430    fn test_ignore_pattern_matches() {
431        let config = LintConfig {
432            root: PathBuf::from("/project"),
433            lint_on_build: true,
434            ignore_patterns: vec![glob::Pattern::new("test/**/*").unwrap()],
435        };
436        assert!(!config.should_lint(Path::new("/project/test/MyTest.sol")));
437        assert!(config.should_lint(Path::new("/project/src/Token.sol")));
438    }
439
440    #[test]
441    fn test_multiple_ignore_patterns() {
442        let config = LintConfig {
443            root: PathBuf::from("/project"),
444            lint_on_build: true,
445            ignore_patterns: vec![
446                glob::Pattern::new("test/**/*").unwrap(),
447                glob::Pattern::new("script/**/*").unwrap(),
448            ],
449        };
450        assert!(!config.should_lint(Path::new("/project/test/MyTest.sol")));
451        assert!(!config.should_lint(Path::new("/project/script/Deploy.sol")));
452        assert!(config.should_lint(Path::new("/project/src/Token.sol")));
453    }
454
455    #[test]
456    fn test_load_lint_config_from_toml() {
457        let dir = tempfile::tempdir().unwrap();
458        let toml_path = dir.path().join("foundry.toml");
459        fs::write(
460            &toml_path,
461            r#"
462[profile.default.lint]
463ignore = ["test/**/*"]
464lint_on_build = true
465"#,
466        )
467        .unwrap();
468
469        let config = load_lint_config_from_toml(&toml_path);
470        assert!(config.lint_on_build);
471        assert_eq!(config.ignore_patterns.len(), 1);
472        assert!(!config.should_lint(&dir.path().join("test/MyTest.sol")));
473        assert!(config.should_lint(&dir.path().join("src/Token.sol")));
474    }
475
476    #[test]
477    fn test_load_lint_config_lint_on_build_false() {
478        let dir = tempfile::tempdir().unwrap();
479        let toml_path = dir.path().join("foundry.toml");
480        fs::write(
481            &toml_path,
482            r#"
483[profile.default.lint]
484lint_on_build = false
485"#,
486        )
487        .unwrap();
488
489        let config = load_lint_config_from_toml(&toml_path);
490        assert!(!config.lint_on_build);
491        assert!(!config.should_lint(&dir.path().join("src/Token.sol")));
492    }
493
494    #[test]
495    fn test_load_lint_config_no_lint_section() {
496        let dir = tempfile::tempdir().unwrap();
497        let toml_path = dir.path().join("foundry.toml");
498        fs::write(
499            &toml_path,
500            r#"
501[profile.default]
502src = "src"
503"#,
504        )
505        .unwrap();
506
507        let config = load_lint_config_from_toml(&toml_path);
508        assert!(config.lint_on_build);
509        assert!(config.ignore_patterns.is_empty());
510    }
511
512    #[test]
513    fn test_find_foundry_toml() {
514        let dir = tempfile::tempdir().unwrap();
515        let toml_path = dir.path().join("foundry.toml");
516        fs::write(&toml_path, "[profile.default]").unwrap();
517
518        // Create a nested directory
519        let nested = dir.path().join("src");
520        fs::create_dir_all(&nested).unwrap();
521
522        let found = find_foundry_toml(&nested);
523        assert_eq!(found, Some(toml_path));
524    }
525
526    #[test]
527    fn test_load_lint_config_walks_ancestors() {
528        let dir = tempfile::tempdir().unwrap();
529        let toml_path = dir.path().join("foundry.toml");
530        fs::write(
531            &toml_path,
532            r#"
533[profile.default.lint]
534ignore = ["test/**/*"]
535"#,
536        )
537        .unwrap();
538
539        let nested_file = dir.path().join("src/Token.sol");
540        fs::create_dir_all(dir.path().join("src")).unwrap();
541        fs::write(&nested_file, "// solidity").unwrap();
542
543        let config = load_lint_config(&nested_file);
544        assert_eq!(config.root, dir.path());
545        assert_eq!(config.ignore_patterns.len(), 1);
546    }
547
548    #[test]
549    fn test_find_git_root() {
550        let dir = tempfile::tempdir().unwrap();
551        // Create a fake .git directory
552        fs::create_dir_all(dir.path().join(".git")).unwrap();
553        let nested = dir.path().join("sub/deep");
554        fs::create_dir_all(&nested).unwrap();
555
556        let root = find_git_root(&nested);
557        assert_eq!(root, Some(dir.path().to_path_buf()));
558    }
559
560    #[test]
561    fn test_find_foundry_toml_stops_at_git_boundary() {
562        // Layout:
563        //   tmp/
564        //     foundry.toml          <-- outside git repo, should NOT be found
565        //     repo/
566        //       .git/
567        //       sub/
568        //         [search starts here]
569        let dir = tempfile::tempdir().unwrap();
570
571        // foundry.toml outside the git repo
572        fs::write(dir.path().join("foundry.toml"), "[profile.default]").unwrap();
573
574        // git repo with no foundry.toml
575        let repo = dir.path().join("repo");
576        fs::create_dir_all(repo.join(".git")).unwrap();
577        fs::create_dir_all(repo.join("sub")).unwrap();
578
579        let found = find_foundry_toml(&repo.join("sub"));
580        // Should NOT find the foundry.toml above the .git boundary
581        assert_eq!(found, None);
582    }
583
584    #[test]
585    fn test_find_foundry_toml_within_git_boundary() {
586        // Layout:
587        //   tmp/
588        //     repo/
589        //       .git/
590        //       foundry.toml        <-- inside git repo, should be found
591        //       src/
592        //         [search starts here]
593        let dir = tempfile::tempdir().unwrap();
594        let repo = dir.path().join("repo");
595        fs::create_dir_all(repo.join(".git")).unwrap();
596        fs::create_dir_all(repo.join("src")).unwrap();
597        let toml_path = repo.join("foundry.toml");
598        fs::write(&toml_path, "[profile.default]").unwrap();
599
600        let found = find_foundry_toml(&repo.join("src"));
601        assert_eq!(found, Some(toml_path));
602    }
603
604    #[test]
605    fn test_find_foundry_toml_no_git_repo_still_walks_up() {
606        // When there's no .git directory at all, the search should still
607        // walk up (unbounded), matching foundry's behavior.
608        let dir = tempfile::tempdir().unwrap();
609        let toml_path = dir.path().join("foundry.toml");
610        fs::write(&toml_path, "[profile.default]").unwrap();
611
612        let nested = dir.path().join("a/b/c");
613        fs::create_dir_all(&nested).unwrap();
614
615        let found = find_foundry_toml(&nested);
616        assert_eq!(found, Some(toml_path));
617    }
618
619    // ── Compiler settings parsing ─────────────────────────────────────
620
621    #[test]
622    fn test_load_foundry_config_compiler_settings() {
623        let dir = tempfile::tempdir().unwrap();
624        let toml_path = dir.path().join("foundry.toml");
625        fs::write(
626            &toml_path,
627            r#"
628[profile.default]
629src = "src"
630solc = '0.8.33'
631optimizer = true
632optimizer_runs = 9999999
633via_ir = true
634evm_version = 'osaka'
635ignored_error_codes = [2394, 6321, 3860, 5574, 2424, 8429, 4591]
636"#,
637        )
638        .unwrap();
639
640        let config = load_foundry_config_from_toml(&toml_path);
641        assert_eq!(config.solc_version, Some("0.8.33".to_string()));
642        assert!(config.optimizer);
643        assert_eq!(config.optimizer_runs, 9999999);
644        assert!(config.via_ir);
645        assert_eq!(config.evm_version, Some("osaka".to_string()));
646        assert_eq!(
647            config.ignored_error_codes,
648            vec![2394, 6321, 3860, 5574, 2424, 8429, 4591]
649        );
650    }
651
652    #[test]
653    fn test_load_foundry_config_defaults_when_absent() {
654        let dir = tempfile::tempdir().unwrap();
655        let toml_path = dir.path().join("foundry.toml");
656        fs::write(
657            &toml_path,
658            r#"
659[profile.default]
660src = "src"
661"#,
662        )
663        .unwrap();
664
665        let config = load_foundry_config_from_toml(&toml_path);
666        assert_eq!(config.solc_version, None);
667        assert!(!config.optimizer);
668        assert_eq!(config.optimizer_runs, 200);
669        assert!(!config.via_ir);
670        assert_eq!(config.evm_version, None);
671        assert!(config.ignored_error_codes.is_empty());
672    }
673
674    #[test]
675    fn test_load_foundry_config_partial_settings() {
676        let dir = tempfile::tempdir().unwrap();
677        let toml_path = dir.path().join("foundry.toml");
678        fs::write(
679            &toml_path,
680            r#"
681[profile.default]
682via_ir = true
683evm_version = "cancun"
684"#,
685        )
686        .unwrap();
687
688        let config = load_foundry_config_from_toml(&toml_path);
689        assert!(config.via_ir);
690        assert!(!config.optimizer); // default false
691        assert_eq!(config.optimizer_runs, 200); // default
692        assert_eq!(config.evm_version, Some("cancun".to_string()));
693        assert!(config.ignored_error_codes.is_empty());
694    }
695}