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