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