Skip to main content

solidity_language_server/
config.rs

1use serde::Deserialize;
2use std::path::{Path, PathBuf};
3
4// ── LSP Settings (from editor / initializationOptions) ─────────────────
5
6/// Top-level settings object sent by the editor.
7///
8/// Editors wrap settings under the server name key:
9/// ```lua
10/// settings = {
11///   ["solidity-language-server"] = {
12///     inlayHints = { parameters = true },
13///     lint = { enabled = true, exclude = { "pascal-case-struct" } },
14///   },
15/// }
16/// ```
17///
18/// All fields use `Option` with `#[serde(default)]` so that missing keys
19/// keep their defaults — the editor only needs to send overrides.
20#[derive(Debug, Clone, Deserialize)]
21#[serde(rename_all = "camelCase")]
22#[derive(Default)]
23pub struct Settings {
24    #[serde(default)]
25    pub inlay_hints: InlayHintsSettings,
26    #[serde(default)]
27    pub lint: LintSettings,
28}
29
30/// Inlay-hint settings.
31#[derive(Debug, Clone, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct InlayHintsSettings {
34    /// Show parameter-name hints on function/event/struct calls.
35    #[serde(default = "default_true")]
36    pub parameters: bool,
37    /// Show gas estimate hints on functions/contracts annotated with
38    /// `@custom:lsp-enable gas-estimates`.
39    #[serde(default = "default_true")]
40    pub gas_estimates: bool,
41}
42
43impl Default for InlayHintsSettings {
44    fn default() -> Self {
45        Self {
46            parameters: true,
47            gas_estimates: true,
48        }
49    }
50}
51
52/// Lint settings (overrides foundry.toml when provided).
53#[derive(Debug, Clone, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct LintSettings {
56    /// Master toggle for forge-lint diagnostics.
57    #[serde(default = "default_true")]
58    pub enabled: bool,
59    /// Filter lints by severity (e.g. `["high", "med", "gas"]`).
60    /// Maps to `forge lint --severity high --severity med --severity gas`.
61    /// Empty means all severities.
62    #[serde(default)]
63    pub severity: Vec<String>,
64    /// Run only specific lint rules by ID (e.g. `["incorrect-shift", "unchecked-call"]`).
65    /// Maps to `forge lint --only-lint incorrect-shift --only-lint unchecked-call`.
66    /// Empty means all rules.
67    #[serde(default)]
68    pub only: Vec<String>,
69    /// Lint rule names to exclude from diagnostics (post-hoc filtering).
70    /// These are filtered after `forge lint` runs.
71    #[serde(default)]
72    pub exclude: Vec<String>,
73}
74
75impl Default for LintSettings {
76    fn default() -> Self {
77        Self {
78            enabled: true,
79            severity: Vec::new(),
80            only: Vec::new(),
81            exclude: Vec::new(),
82        }
83    }
84}
85
86fn default_true() -> bool {
87    true
88}
89
90/// Try to parse `Settings` from a `serde_json::Value`.
91///
92/// Handles both direct settings objects and the wrapped form where the
93/// editor nests under `"solidity-language-server"`:
94/// ```json
95/// { "solidity-language-server": { "inlayHints": { ... } } }
96/// ```
97pub fn parse_settings(value: &serde_json::Value) -> Settings {
98    // Try the wrapped form first
99    if let Some(inner) = value.get("solidity-language-server")
100        && let Ok(s) = serde_json::from_value::<Settings>(inner.clone())
101    {
102        return s;
103    }
104    // Try direct form
105    serde_json::from_value::<Settings>(value.clone()).unwrap_or_default()
106}
107
108/// Project-level configuration extracted from `foundry.toml`.
109///
110/// This includes both lint settings and compiler settings needed by the
111/// solc runner (solc version, remappings, optimizer, via-IR, EVM version).
112#[derive(Debug, Clone)]
113pub struct FoundryConfig {
114    /// The project root where `foundry.toml` was found.
115    pub root: PathBuf,
116    /// Solc version from `[profile.default] solc = "0.8.26"`.
117    /// `None` means use the system default.
118    pub solc_version: Option<String>,
119    /// Remappings from `[profile.default] remappings = [...]`.
120    /// Empty if not specified (will fall back to `forge remappings`).
121    pub remappings: Vec<String>,
122    /// Whether to compile via the Yul IR pipeline (`via_ir = true`).
123    /// Maps to `"viaIR": true` in the solc standard JSON settings.
124    pub via_ir: bool,
125    /// Whether the optimizer is enabled (`optimizer = true`).
126    pub optimizer: bool,
127    /// Number of optimizer runs (`optimizer_runs = 200`).
128    /// Only meaningful when `optimizer` is `true`.
129    pub optimizer_runs: u64,
130    /// Target EVM version (`evm_version = "cancun"`).
131    /// Maps to `"evmVersion"` in the solc standard JSON settings.
132    /// `None` means use solc's default.
133    pub evm_version: Option<String>,
134    /// Error codes to suppress from diagnostics (`ignored_error_codes = [2394, 5574]`).
135    pub ignored_error_codes: Vec<u64>,
136    /// Source directory relative to `root` (default: `src` for Foundry, `contracts` for Hardhat).
137    pub sources_dir: String,
138}
139
140impl Default for FoundryConfig {
141    fn default() -> Self {
142        Self {
143            root: PathBuf::new(),
144            solc_version: None,
145            remappings: Vec::new(),
146            via_ir: false,
147            optimizer: false,
148            optimizer_runs: 200,
149            evm_version: None,
150            ignored_error_codes: Vec::new(),
151            sources_dir: "src".to_string(),
152        }
153    }
154}
155
156/// Load project configuration from the nearest `foundry.toml`.
157pub fn load_foundry_config(file_path: &Path) -> FoundryConfig {
158    let toml_path = match find_foundry_toml(file_path) {
159        Some(p) => p,
160        None => return FoundryConfig::default(),
161    };
162    load_foundry_config_from_toml(&toml_path)
163}
164
165/// Load project configuration from a known `foundry.toml` path.
166pub fn load_foundry_config_from_toml(toml_path: &Path) -> FoundryConfig {
167    let root = toml_path.parent().unwrap_or(Path::new("")).to_path_buf();
168
169    let content = match std::fs::read_to_string(toml_path) {
170        Ok(c) => c,
171        Err(_) => {
172            return FoundryConfig {
173                root,
174                ..Default::default()
175            };
176        }
177    };
178
179    let table: toml::Table = match content.parse() {
180        Ok(t) => t,
181        Err(_) => {
182            return FoundryConfig {
183                root,
184                ..Default::default()
185            };
186        }
187    };
188
189    let profile_name = std::env::var("FOUNDRY_PROFILE").unwrap_or_else(|_| "default".to_string());
190
191    let profile = table
192        .get("profile")
193        .and_then(|p| p.as_table())
194        .and_then(|p| p.get(&profile_name))
195        .and_then(|p| p.as_table());
196
197    let profile = match profile {
198        Some(p) => p,
199        None => {
200            return FoundryConfig {
201                root,
202                ..Default::default()
203            };
204        }
205    };
206
207    // Parse solc version: `solc = "0.8.26"` or `solc_version = "0.8.26"`
208    let solc_version = profile
209        .get("solc")
210        .or_else(|| profile.get("solc_version"))
211        .and_then(|v| v.as_str())
212        .map(|s| s.to_string());
213
214    // Parse remappings: `remappings = ["ds-test/=lib/...", ...]`
215    let remappings = profile
216        .get("remappings")
217        .and_then(|v| v.as_array())
218        .map(|arr| {
219            arr.iter()
220                .filter_map(|v| v.as_str())
221                .map(|s| s.to_string())
222                .collect()
223        })
224        .unwrap_or_default();
225
226    // Parse via_ir: `via_ir = true`
227    let via_ir = profile
228        .get("via_ir")
229        .and_then(|v| v.as_bool())
230        .unwrap_or(false);
231
232    // Parse optimizer: `optimizer = true`
233    let optimizer = profile
234        .get("optimizer")
235        .and_then(|v| v.as_bool())
236        .unwrap_or(false);
237
238    // Parse optimizer_runs: `optimizer_runs = 200`
239    let optimizer_runs = profile
240        .get("optimizer_runs")
241        .and_then(|v| v.as_integer())
242        .map(|v| v as u64)
243        .unwrap_or(200);
244
245    // Parse evm_version: `evm_version = "cancun"` or `evm_version = "osaka"`
246    let evm_version = profile
247        .get("evm_version")
248        .and_then(|v| v.as_str())
249        .map(|s| s.to_string());
250
251    // Parse src: `src = "contracts"` (default: "src")
252    let sources_dir = profile
253        .get("src")
254        .and_then(|v| v.as_str())
255        .map(|s| s.to_string())
256        .unwrap_or_else(|| "src".to_string());
257
258    // Parse ignored_error_codes: `ignored_error_codes = [2394, 6321, 3860, 5574]`
259    let ignored_error_codes = profile
260        .get("ignored_error_codes")
261        .and_then(|v| v.as_array())
262        .map(|arr| {
263            arr.iter()
264                .filter_map(|v| v.as_integer())
265                .map(|v| v as u64)
266                .collect()
267        })
268        .unwrap_or_default();
269
270    FoundryConfig {
271        root,
272        solc_version,
273        remappings,
274        via_ir,
275        optimizer,
276        optimizer_runs,
277        evm_version,
278        ignored_error_codes,
279        sources_dir,
280    }
281}
282
283/// Lint-related configuration extracted from `foundry.toml`.
284#[derive(Debug, Clone)]
285pub struct LintConfig {
286    /// The project root where `foundry.toml` was found.
287    pub root: PathBuf,
288    /// Whether linting is enabled on build (default: true).
289    pub lint_on_build: bool,
290    /// Compiled glob patterns from the `ignore` list.
291    pub ignore_patterns: Vec<glob::Pattern>,
292}
293
294impl Default for LintConfig {
295    fn default() -> Self {
296        Self {
297            root: PathBuf::new(),
298            lint_on_build: true,
299            ignore_patterns: Vec::new(),
300        }
301    }
302}
303
304impl LintConfig {
305    /// Returns `true` if the given file should be linted.
306    ///
307    /// A file is skipped when:
308    /// - `lint_on_build` is `false`, or
309    /// - the file's path (relative to the project root) matches any `ignore` pattern.
310    pub fn should_lint(&self, file_path: &Path) -> bool {
311        if !self.lint_on_build {
312            return false;
313        }
314
315        if self.ignore_patterns.is_empty() {
316            return true;
317        }
318
319        // Build a relative path from the project root so that patterns like
320        // "test/**/*" work correctly.
321        let relative = file_path.strip_prefix(&self.root).unwrap_or(file_path);
322
323        let rel_str = relative.to_string_lossy();
324
325        for pattern in &self.ignore_patterns {
326            if pattern.matches(&rel_str) {
327                return false;
328            }
329        }
330
331        true
332    }
333}
334
335/// Returns the root of the git repository containing `start`, if any.
336///
337/// This mirrors foundry's own `find_git_root` behavior: walk up ancestors
338/// until a directory containing `.git` is found.
339fn find_git_root(start: &Path) -> Option<PathBuf> {
340    let start = if start.is_file() {
341        start.parent()?
342    } else {
343        start
344    };
345    start
346        .ancestors()
347        .find(|p| p.join(".git").exists())
348        .map(Path::to_path_buf)
349}
350
351/// Walk up from `start` to find the nearest `foundry.toml`, stopping at the
352/// git repository root (consistent with foundry's `find_project_root`).
353///
354/// See: <https://github.com/foundry-rs/foundry/blob/5389caefb5bfb035c547dffb4fd0f441a37e5371/crates/config/src/utils.rs#L62>
355pub fn find_foundry_toml(start: &Path) -> Option<PathBuf> {
356    let start_dir = if start.is_file() {
357        start.parent()?
358    } else {
359        start
360    };
361
362    let boundary = find_git_root(start_dir);
363
364    start_dir
365        .ancestors()
366        // Don't look outside of the git repo, matching foundry's behavior.
367        .take_while(|p| {
368            if let Some(boundary) = &boundary {
369                p.starts_with(boundary)
370            } else {
371                true
372            }
373        })
374        .find(|p| p.join("foundry.toml").is_file())
375        .map(|p| p.join("foundry.toml"))
376}
377
378/// Load the lint configuration from the nearest `foundry.toml` relative to
379/// `file_path`. Returns `LintConfig::default()` when no config is found or
380/// the relevant sections are absent.
381pub fn load_lint_config(file_path: &Path) -> LintConfig {
382    let toml_path = match find_foundry_toml(file_path) {
383        Some(p) => p,
384        None => return LintConfig::default(),
385    };
386
387    let root = toml_path.parent().unwrap_or(Path::new("")).to_path_buf();
388
389    let content = match std::fs::read_to_string(&toml_path) {
390        Ok(c) => c,
391        Err(_) => {
392            return LintConfig {
393                root,
394                ..Default::default()
395            };
396        }
397    };
398
399    let table: toml::Table = match content.parse() {
400        Ok(t) => t,
401        Err(_) => {
402            return LintConfig {
403                root,
404                ..Default::default()
405            };
406        }
407    };
408
409    // Determine the active profile (default: "default").
410    let profile_name = std::env::var("FOUNDRY_PROFILE").unwrap_or_else(|_| "default".to_string());
411
412    // Look up [profile.<name>.lint]
413    let lint_table = table
414        .get("profile")
415        .and_then(|p| p.as_table())
416        .and_then(|p| p.get(&profile_name))
417        .and_then(|p| p.as_table())
418        .and_then(|p| p.get("lint"))
419        .and_then(|l| l.as_table());
420
421    let lint_table = match lint_table {
422        Some(t) => t,
423        None => {
424            return LintConfig {
425                root,
426                ..Default::default()
427            };
428        }
429    };
430
431    // Parse lint_on_build (default: true)
432    let lint_on_build = lint_table
433        .get("lint_on_build")
434        .and_then(|v| v.as_bool())
435        .unwrap_or(true);
436
437    // Parse ignore patterns
438    let ignore_patterns = lint_table
439        .get("ignore")
440        .and_then(|v| v.as_array())
441        .map(|arr| {
442            arr.iter()
443                .filter_map(|v| v.as_str())
444                .filter_map(|s| glob::Pattern::new(s).ok())
445                .collect()
446        })
447        .unwrap_or_default();
448
449    LintConfig {
450        root,
451        lint_on_build,
452        ignore_patterns,
453    }
454}
455
456/// Load lint config from a known `foundry.toml` path (used when reloading
457/// after a file-watch notification).
458pub fn load_lint_config_from_toml(toml_path: &Path) -> LintConfig {
459    let root = toml_path.parent().unwrap_or(Path::new("")).to_path_buf();
460
461    let content = match std::fs::read_to_string(toml_path) {
462        Ok(c) => c,
463        Err(_) => {
464            return LintConfig {
465                root,
466                ..Default::default()
467            };
468        }
469    };
470
471    let table: toml::Table = match content.parse() {
472        Ok(t) => t,
473        Err(_) => {
474            return LintConfig {
475                root,
476                ..Default::default()
477            };
478        }
479    };
480
481    let profile_name = std::env::var("FOUNDRY_PROFILE").unwrap_or_else(|_| "default".to_string());
482
483    let lint_table = table
484        .get("profile")
485        .and_then(|p| p.as_table())
486        .and_then(|p| p.get(&profile_name))
487        .and_then(|p| p.as_table())
488        .and_then(|p| p.get("lint"))
489        .and_then(|l| l.as_table());
490
491    let lint_table = match lint_table {
492        Some(t) => t,
493        None => {
494            return LintConfig {
495                root,
496                ..Default::default()
497            };
498        }
499    };
500
501    let lint_on_build = lint_table
502        .get("lint_on_build")
503        .and_then(|v| v.as_bool())
504        .unwrap_or(true);
505
506    let ignore_patterns = lint_table
507        .get("ignore")
508        .and_then(|v| v.as_array())
509        .map(|arr| {
510            arr.iter()
511                .filter_map(|v| v.as_str())
512                .filter_map(|s| glob::Pattern::new(s).ok())
513                .collect()
514        })
515        .unwrap_or_default();
516
517    LintConfig {
518        root,
519        lint_on_build,
520        ignore_patterns,
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use std::fs;
528
529    #[test]
530    fn test_default_config_lints_everything() {
531        let config = LintConfig::default();
532        assert!(config.should_lint(Path::new("test/MyTest.sol")));
533        assert!(config.should_lint(Path::new("src/Token.sol")));
534    }
535
536    #[test]
537    fn test_lint_on_build_false_skips_all() {
538        let config = LintConfig {
539            lint_on_build: false,
540            ..Default::default()
541        };
542        assert!(!config.should_lint(Path::new("src/Token.sol")));
543    }
544
545    #[test]
546    fn test_ignore_pattern_matches() {
547        let config = LintConfig {
548            root: PathBuf::from("/project"),
549            lint_on_build: true,
550            ignore_patterns: vec![glob::Pattern::new("test/**/*").unwrap()],
551        };
552        assert!(!config.should_lint(Path::new("/project/test/MyTest.sol")));
553        assert!(config.should_lint(Path::new("/project/src/Token.sol")));
554    }
555
556    #[test]
557    fn test_multiple_ignore_patterns() {
558        let config = LintConfig {
559            root: PathBuf::from("/project"),
560            lint_on_build: true,
561            ignore_patterns: vec![
562                glob::Pattern::new("test/**/*").unwrap(),
563                glob::Pattern::new("script/**/*").unwrap(),
564            ],
565        };
566        assert!(!config.should_lint(Path::new("/project/test/MyTest.sol")));
567        assert!(!config.should_lint(Path::new("/project/script/Deploy.sol")));
568        assert!(config.should_lint(Path::new("/project/src/Token.sol")));
569    }
570
571    #[test]
572    fn test_load_lint_config_from_toml() {
573        let dir = tempfile::tempdir().unwrap();
574        let toml_path = dir.path().join("foundry.toml");
575        fs::write(
576            &toml_path,
577            r#"
578[profile.default.lint]
579ignore = ["test/**/*"]
580lint_on_build = true
581"#,
582        )
583        .unwrap();
584
585        let config = load_lint_config_from_toml(&toml_path);
586        assert!(config.lint_on_build);
587        assert_eq!(config.ignore_patterns.len(), 1);
588        assert!(!config.should_lint(&dir.path().join("test/MyTest.sol")));
589        assert!(config.should_lint(&dir.path().join("src/Token.sol")));
590    }
591
592    #[test]
593    fn test_load_lint_config_lint_on_build_false() {
594        let dir = tempfile::tempdir().unwrap();
595        let toml_path = dir.path().join("foundry.toml");
596        fs::write(
597            &toml_path,
598            r#"
599[profile.default.lint]
600lint_on_build = false
601"#,
602        )
603        .unwrap();
604
605        let config = load_lint_config_from_toml(&toml_path);
606        assert!(!config.lint_on_build);
607        assert!(!config.should_lint(&dir.path().join("src/Token.sol")));
608    }
609
610    #[test]
611    fn test_load_lint_config_no_lint_section() {
612        let dir = tempfile::tempdir().unwrap();
613        let toml_path = dir.path().join("foundry.toml");
614        fs::write(
615            &toml_path,
616            r#"
617[profile.default]
618src = "src"
619"#,
620        )
621        .unwrap();
622
623        let config = load_lint_config_from_toml(&toml_path);
624        assert!(config.lint_on_build);
625        assert!(config.ignore_patterns.is_empty());
626    }
627
628    #[test]
629    fn test_find_foundry_toml() {
630        let dir = tempfile::tempdir().unwrap();
631        let toml_path = dir.path().join("foundry.toml");
632        fs::write(&toml_path, "[profile.default]").unwrap();
633
634        // Create a nested directory
635        let nested = dir.path().join("src");
636        fs::create_dir_all(&nested).unwrap();
637
638        let found = find_foundry_toml(&nested);
639        assert_eq!(found, Some(toml_path));
640    }
641
642    #[test]
643    fn test_load_lint_config_walks_ancestors() {
644        let dir = tempfile::tempdir().unwrap();
645        let toml_path = dir.path().join("foundry.toml");
646        fs::write(
647            &toml_path,
648            r#"
649[profile.default.lint]
650ignore = ["test/**/*"]
651"#,
652        )
653        .unwrap();
654
655        let nested_file = dir.path().join("src/Token.sol");
656        fs::create_dir_all(dir.path().join("src")).unwrap();
657        fs::write(&nested_file, "// solidity").unwrap();
658
659        let config = load_lint_config(&nested_file);
660        assert_eq!(config.root, dir.path());
661        assert_eq!(config.ignore_patterns.len(), 1);
662    }
663
664    #[test]
665    fn test_find_git_root() {
666        let dir = tempfile::tempdir().unwrap();
667        // Create a fake .git directory
668        fs::create_dir_all(dir.path().join(".git")).unwrap();
669        let nested = dir.path().join("sub/deep");
670        fs::create_dir_all(&nested).unwrap();
671
672        let root = find_git_root(&nested);
673        assert_eq!(root, Some(dir.path().to_path_buf()));
674    }
675
676    #[test]
677    fn test_find_foundry_toml_stops_at_git_boundary() {
678        // Layout:
679        //   tmp/
680        //     foundry.toml          <-- outside git repo, should NOT be found
681        //     repo/
682        //       .git/
683        //       sub/
684        //         [search starts here]
685        let dir = tempfile::tempdir().unwrap();
686
687        // foundry.toml outside the git repo
688        fs::write(dir.path().join("foundry.toml"), "[profile.default]").unwrap();
689
690        // git repo with no foundry.toml
691        let repo = dir.path().join("repo");
692        fs::create_dir_all(repo.join(".git")).unwrap();
693        fs::create_dir_all(repo.join("sub")).unwrap();
694
695        let found = find_foundry_toml(&repo.join("sub"));
696        // Should NOT find the foundry.toml above the .git boundary
697        assert_eq!(found, None);
698    }
699
700    #[test]
701    fn test_find_foundry_toml_within_git_boundary() {
702        // Layout:
703        //   tmp/
704        //     repo/
705        //       .git/
706        //       foundry.toml        <-- inside git repo, should be found
707        //       src/
708        //         [search starts here]
709        let dir = tempfile::tempdir().unwrap();
710        let repo = dir.path().join("repo");
711        fs::create_dir_all(repo.join(".git")).unwrap();
712        fs::create_dir_all(repo.join("src")).unwrap();
713        let toml_path = repo.join("foundry.toml");
714        fs::write(&toml_path, "[profile.default]").unwrap();
715
716        let found = find_foundry_toml(&repo.join("src"));
717        assert_eq!(found, Some(toml_path));
718    }
719
720    #[test]
721    fn test_find_foundry_toml_no_git_repo_still_walks_up() {
722        // When there's no .git directory at all, the search should still
723        // walk up (unbounded), matching foundry's behavior.
724        let dir = tempfile::tempdir().unwrap();
725        let toml_path = dir.path().join("foundry.toml");
726        fs::write(&toml_path, "[profile.default]").unwrap();
727
728        let nested = dir.path().join("a/b/c");
729        fs::create_dir_all(&nested).unwrap();
730
731        let found = find_foundry_toml(&nested);
732        assert_eq!(found, Some(toml_path));
733    }
734
735    // ── Compiler settings parsing ─────────────────────────────────────
736
737    #[test]
738    fn test_load_foundry_config_compiler_settings() {
739        let dir = tempfile::tempdir().unwrap();
740        let toml_path = dir.path().join("foundry.toml");
741        fs::write(
742            &toml_path,
743            r#"
744[profile.default]
745src = "src"
746solc = '0.8.33'
747optimizer = true
748optimizer_runs = 9999999
749via_ir = true
750evm_version = 'osaka'
751ignored_error_codes = [2394, 6321, 3860, 5574, 2424, 8429, 4591]
752"#,
753        )
754        .unwrap();
755
756        let config = load_foundry_config_from_toml(&toml_path);
757        assert_eq!(config.solc_version, Some("0.8.33".to_string()));
758        assert!(config.optimizer);
759        assert_eq!(config.optimizer_runs, 9999999);
760        assert!(config.via_ir);
761        assert_eq!(config.evm_version, Some("osaka".to_string()));
762        assert_eq!(
763            config.ignored_error_codes,
764            vec![2394, 6321, 3860, 5574, 2424, 8429, 4591]
765        );
766    }
767
768    #[test]
769    fn test_load_foundry_config_defaults_when_absent() {
770        let dir = tempfile::tempdir().unwrap();
771        let toml_path = dir.path().join("foundry.toml");
772        fs::write(
773            &toml_path,
774            r#"
775[profile.default]
776src = "src"
777"#,
778        )
779        .unwrap();
780
781        let config = load_foundry_config_from_toml(&toml_path);
782        assert_eq!(config.solc_version, None);
783        assert!(!config.optimizer);
784        assert_eq!(config.optimizer_runs, 200);
785        assert!(!config.via_ir);
786        assert_eq!(config.evm_version, None);
787        assert!(config.ignored_error_codes.is_empty());
788    }
789
790    #[test]
791    fn test_load_foundry_config_partial_settings() {
792        let dir = tempfile::tempdir().unwrap();
793        let toml_path = dir.path().join("foundry.toml");
794        fs::write(
795            &toml_path,
796            r#"
797[profile.default]
798via_ir = true
799evm_version = "cancun"
800"#,
801        )
802        .unwrap();
803
804        let config = load_foundry_config_from_toml(&toml_path);
805        assert!(config.via_ir);
806        assert!(!config.optimizer); // default false
807        assert_eq!(config.optimizer_runs, 200); // default
808        assert_eq!(config.evm_version, Some("cancun".to_string()));
809        assert!(config.ignored_error_codes.is_empty());
810    }
811
812    // ── Settings parsing ──────────────────────────────────────────────
813
814    #[test]
815    fn test_parse_settings_defaults() {
816        let value = serde_json::json!({});
817        let s = parse_settings(&value);
818        assert!(s.inlay_hints.parameters);
819        assert!(s.inlay_hints.gas_estimates);
820        assert!(s.lint.enabled);
821        assert!(s.lint.severity.is_empty());
822        assert!(s.lint.only.is_empty());
823        assert!(s.lint.exclude.is_empty());
824    }
825
826    #[test]
827    fn test_parse_settings_wrapped() {
828        let value = serde_json::json!({
829            "solidity-language-server": {
830                "inlayHints": { "parameters": false, "gasEstimates": false },
831                "lint": {
832                    "enabled": true,
833                    "severity": ["high", "med"],
834                    "only": ["incorrect-shift"],
835                    "exclude": ["pascal-case-struct", "mixed-case-variable"]
836                }
837            }
838        });
839        let s = parse_settings(&value);
840        assert!(!s.inlay_hints.parameters);
841        assert!(!s.inlay_hints.gas_estimates);
842        assert!(s.lint.enabled);
843        assert_eq!(s.lint.severity, vec!["high", "med"]);
844        assert_eq!(s.lint.only, vec!["incorrect-shift"]);
845        assert_eq!(
846            s.lint.exclude,
847            vec!["pascal-case-struct", "mixed-case-variable"]
848        );
849    }
850
851    #[test]
852    fn test_parse_settings_direct() {
853        let value = serde_json::json!({
854            "inlayHints": { "parameters": false },
855            "lint": { "enabled": false }
856        });
857        let s = parse_settings(&value);
858        assert!(!s.inlay_hints.parameters);
859        assert!(!s.lint.enabled);
860    }
861
862    #[test]
863    fn test_parse_settings_partial() {
864        let value = serde_json::json!({
865            "solidity-language-server": {
866                "lint": { "exclude": ["unused-import"] }
867            }
868        });
869        let s = parse_settings(&value);
870        // inlayHints not specified → defaults to true
871        assert!(s.inlay_hints.parameters);
872        assert!(s.inlay_hints.gas_estimates);
873        // lint.enabled not specified → defaults to true
874        assert!(s.lint.enabled);
875        assert!(s.lint.severity.is_empty());
876        assert!(s.lint.only.is_empty());
877        assert_eq!(s.lint.exclude, vec!["unused-import"]);
878    }
879
880    #[test]
881    fn test_parse_settings_empty_wrapped() {
882        let value = serde_json::json!({
883            "solidity-language-server": {}
884        });
885        let s = parse_settings(&value);
886        assert!(s.inlay_hints.parameters);
887        assert!(s.inlay_hints.gas_estimates);
888        assert!(s.lint.enabled);
889        assert!(s.lint.severity.is_empty());
890        assert!(s.lint.only.is_empty());
891        assert!(s.lint.exclude.is_empty());
892    }
893
894    #[test]
895    fn test_parse_settings_severity_only() {
896        let value = serde_json::json!({
897            "solidity-language-server": {
898                "lint": {
899                    "severity": ["high", "gas"],
900                    "only": ["incorrect-shift", "asm-keccak256"]
901                }
902            }
903        });
904        let s = parse_settings(&value);
905        assert_eq!(s.lint.severity, vec!["high", "gas"]);
906        assert_eq!(s.lint.only, vec!["incorrect-shift", "asm-keccak256"]);
907        assert!(s.lint.exclude.is_empty());
908    }
909}