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, skip eager full-project scanning for faster startup.
121    #[serde(default)]
122    pub full_project_scan: bool,
123    /// Persistent reference cache mode:
124    /// - `v2` (default): per-file shard cache
125    /// - `auto`: alias to v2 for compatibility
126    #[serde(default)]
127    pub cache_mode: ProjectIndexCacheMode,
128    /// If true, use an aggressive scoped reindex strategy on dirty sync:
129    /// recompile only reverse-import affected files from recent changes.
130    /// Falls back to full-project reindex on failure.
131    #[serde(default)]
132    pub incremental_edit_reindex: bool,
133}
134
135#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
136#[serde(rename_all = "lowercase")]
137pub enum ProjectIndexCacheMode {
138    Auto,
139    #[default]
140    V2,
141}
142
143impl Default for ProjectIndexSettings {
144    fn default() -> Self {
145        Self {
146            full_project_scan: true,
147            cache_mode: ProjectIndexCacheMode::V2,
148            incremental_edit_reindex: false,
149        }
150    }
151}
152
153fn default_true() -> bool {
154    true
155}
156
157fn active_profile_name() -> String {
158    std::env::var("FOUNDRY_PROFILE").unwrap_or_else(|_| "default".to_string())
159}
160
161fn find_profile_table<'a>(table: &'a toml::Table, profile_name: &str) -> Option<&'a toml::Table> {
162    table
163        .get("profile")
164        .and_then(|p| p.as_table())
165        .and_then(|profiles| profiles.get(profile_name))
166        .and_then(|p| p.as_table())
167}
168
169fn get_profile_value<'a>(
170    active_profile: Option<&'a toml::Table>,
171    default_profile: Option<&'a toml::Table>,
172    key: &str,
173) -> Option<&'a toml::Value> {
174    active_profile
175        .and_then(|p| p.get(key))
176        .or_else(|| default_profile.and_then(|p| p.get(key)))
177}
178
179fn get_lint_table<'a>(
180    active_profile: Option<&'a toml::Table>,
181    default_profile: Option<&'a toml::Table>,
182) -> Option<&'a toml::Table> {
183    active_profile
184        .and_then(|p| p.get("lint"))
185        .and_then(|l| l.as_table())
186        .or_else(|| {
187            default_profile
188                .and_then(|p| p.get("lint"))
189                .and_then(|l| l.as_table())
190        })
191}
192
193/// Try to parse `Settings` from a `serde_json::Value`.
194///
195/// Handles both direct settings objects and the wrapped form where the
196/// editor nests under `"solidity-language-server"`:
197/// ```json
198/// { "solidity-language-server": { "inlayHints": { ... } } }
199/// ```
200pub fn parse_settings(value: &serde_json::Value) -> Settings {
201    // Try the wrapped form first
202    if let Some(inner) = value.get("solidity-language-server")
203        && let Ok(s) = serde_json::from_value::<Settings>(inner.clone())
204    {
205        return s;
206    }
207    // Try direct form
208    serde_json::from_value::<Settings>(value.clone()).unwrap_or_default()
209}
210
211/// Project-level configuration extracted from `foundry.toml`.
212///
213/// This includes both lint settings and compiler settings needed by the
214/// solc runner (solc version, remappings, optimizer, via-IR, EVM version).
215#[derive(Debug, Clone)]
216pub struct FoundryConfig {
217    /// The project root where `foundry.toml` was found.
218    pub root: PathBuf,
219    /// Solc version from `[profile.default] solc = "0.8.26"`.
220    /// `None` means use the system default.
221    pub solc_version: Option<String>,
222    /// Remappings from `[profile.default] remappings = [...]`.
223    /// Empty if not specified (will fall back to `forge remappings`).
224    pub remappings: Vec<String>,
225    /// Whether to compile via the Yul IR pipeline (`via_ir = true`).
226    /// Maps to `"viaIR": true` in the solc standard JSON settings.
227    pub via_ir: bool,
228    /// Whether the optimizer is enabled (`optimizer = true`).
229    pub optimizer: bool,
230    /// Number of optimizer runs (`optimizer_runs = 200`).
231    /// Only meaningful when `optimizer` is `true`.
232    pub optimizer_runs: u64,
233    /// Target EVM version (`evm_version = "cancun"`).
234    /// Maps to `"evmVersion"` in the solc standard JSON settings.
235    /// `None` means use solc's default.
236    pub evm_version: Option<String>,
237    /// Error codes to suppress from diagnostics (`ignored_error_codes = [2394, 5574]`).
238    pub ignored_error_codes: Vec<u64>,
239    /// Source directory relative to `root` (default: `src` for Foundry, `contracts` for Hardhat).
240    pub sources_dir: String,
241    /// Library directories to exclude from project-wide indexing.
242    /// Parsed from `libs = ["lib"]` in foundry.toml (default: `["lib"]`).
243    pub libs: Vec<String>,
244}
245
246impl Default for FoundryConfig {
247    fn default() -> Self {
248        Self {
249            root: PathBuf::new(),
250            solc_version: None,
251            remappings: Vec::new(),
252            via_ir: false,
253            optimizer: false,
254            optimizer_runs: 200,
255            evm_version: None,
256            ignored_error_codes: Vec::new(),
257            sources_dir: "src".to_string(),
258            libs: vec!["lib".to_string()],
259        }
260    }
261}
262
263/// Load project configuration from the nearest `foundry.toml`.
264///
265/// When no `foundry.toml` is found, returns a default config with `root` set
266/// to the nearest git root or the file's parent directory.  This ensures that
267/// bare Solidity projects (Hardhat, node_modules, loose files) still get a
268/// usable project root for solc invocation.
269pub fn load_foundry_config(file_path: &Path) -> FoundryConfig {
270    let toml_path = match find_foundry_toml(file_path) {
271        Some(p) => p,
272        None => {
273            let start = if file_path.is_file() {
274                file_path.parent().unwrap_or(file_path)
275            } else {
276                file_path
277            };
278            let root = find_git_root(start).unwrap_or_else(|| start.to_path_buf());
279            return FoundryConfig {
280                root,
281                ..Default::default()
282            };
283        }
284    };
285    load_foundry_config_from_toml(&toml_path)
286}
287
288/// Load project configuration from a known `foundry.toml` path.
289pub fn load_foundry_config_from_toml(toml_path: &Path) -> FoundryConfig {
290    load_foundry_config_from_toml_with_profile_name(toml_path, &active_profile_name())
291}
292
293fn load_foundry_config_from_toml_with_profile_name(
294    toml_path: &Path,
295    profile_name: &str,
296) -> FoundryConfig {
297    let root = toml_path.parent().unwrap_or(Path::new("")).to_path_buf();
298
299    let content = match std::fs::read_to_string(toml_path) {
300        Ok(c) => c,
301        Err(_) => {
302            return FoundryConfig {
303                root,
304                ..Default::default()
305            };
306        }
307    };
308
309    let table: toml::Table = match content.parse() {
310        Ok(t) => t,
311        Err(_) => {
312            return FoundryConfig {
313                root,
314                ..Default::default()
315            };
316        }
317    };
318
319    let active_profile = find_profile_table(&table, profile_name);
320    let default_profile = find_profile_table(&table, "default");
321    if active_profile.is_none() && default_profile.is_none() {
322        return FoundryConfig {
323            root,
324            ..Default::default()
325        };
326    }
327
328    // Parse solc version: `solc = "0.8.26"` or `solc_version = "0.8.26"`
329    let solc_version = get_profile_value(active_profile, default_profile, "solc")
330        .or_else(|| get_profile_value(active_profile, default_profile, "solc_version"))
331        .and_then(|v| v.as_str())
332        .map(|s| s.to_string());
333
334    // Parse remappings: `remappings = ["ds-test/=lib/...", ...]`
335    let remappings = get_profile_value(active_profile, default_profile, "remappings")
336        .and_then(|v| v.as_array())
337        .map(|arr| {
338            arr.iter()
339                .filter_map(|v| v.as_str())
340                .map(|s| s.to_string())
341                .collect()
342        })
343        .unwrap_or_default();
344
345    // Parse via_ir: `via_ir = true`
346    let via_ir = get_profile_value(active_profile, default_profile, "via_ir")
347        .and_then(|v| v.as_bool())
348        .unwrap_or(false);
349
350    // Parse optimizer: `optimizer = true`
351    let optimizer = get_profile_value(active_profile, default_profile, "optimizer")
352        .and_then(|v| v.as_bool())
353        .unwrap_or(false);
354
355    // Parse optimizer_runs: `optimizer_runs = 200`
356    let optimizer_runs = get_profile_value(active_profile, default_profile, "optimizer_runs")
357        .and_then(|v| v.as_integer())
358        .map(|v| v as u64)
359        .unwrap_or(200);
360
361    // Parse evm_version: `evm_version = "cancun"` or `evm_version = "osaka"`
362    let evm_version = get_profile_value(active_profile, default_profile, "evm_version")
363        .and_then(|v| v.as_str())
364        .map(|s| s.to_string());
365
366    // Parse src: `src = "contracts"` (default: "src")
367    let sources_dir = get_profile_value(active_profile, default_profile, "src")
368        .and_then(|v| v.as_str())
369        .map(|s| s.to_string())
370        .unwrap_or_else(|| "src".to_string());
371
372    // Parse libs: `libs = ["lib", "node_modules"]` (default: ["lib"])
373    let libs = get_profile_value(active_profile, default_profile, "libs")
374        .and_then(|v| v.as_array())
375        .map(|arr| {
376            arr.iter()
377                .filter_map(|v| v.as_str())
378                .map(|s| s.to_string())
379                .collect()
380        })
381        .unwrap_or_else(|| vec!["lib".to_string()]);
382
383    // Parse ignored_error_codes: `ignored_error_codes = [2394, 6321, 3860, 5574]`
384    let ignored_error_codes =
385        get_profile_value(active_profile, default_profile, "ignored_error_codes")
386            .and_then(|v| v.as_array())
387            .map(|arr| {
388                arr.iter()
389                    .filter_map(|v| v.as_integer())
390                    .map(|v| v as u64)
391                    .collect()
392            })
393            .unwrap_or_default();
394
395    FoundryConfig {
396        root,
397        solc_version,
398        remappings,
399        via_ir,
400        optimizer,
401        optimizer_runs,
402        evm_version,
403        ignored_error_codes,
404        sources_dir,
405        libs,
406    }
407}
408
409/// Lint-related configuration extracted from `foundry.toml`.
410#[derive(Debug, Clone)]
411pub struct LintConfig {
412    /// The project root where `foundry.toml` was found.
413    pub root: PathBuf,
414    /// Whether linting is enabled on build (default: true).
415    pub lint_on_build: bool,
416    /// Compiled glob patterns from the `ignore` list.
417    pub ignore_patterns: Vec<glob::Pattern>,
418}
419
420impl Default for LintConfig {
421    fn default() -> Self {
422        Self {
423            root: PathBuf::new(),
424            lint_on_build: true,
425            ignore_patterns: Vec::new(),
426        }
427    }
428}
429
430impl LintConfig {
431    /// Returns `true` if the given file should be linted.
432    ///
433    /// A file is skipped when:
434    /// - `lint_on_build` is `false`, or
435    /// - the file's path (relative to the project root) matches any `ignore` pattern.
436    pub fn should_lint(&self, file_path: &Path) -> bool {
437        if !self.lint_on_build {
438            return false;
439        }
440
441        if self.ignore_patterns.is_empty() {
442            return true;
443        }
444
445        // Build a relative path from the project root so that patterns like
446        // "test/**/*" work correctly.
447        let relative = file_path.strip_prefix(&self.root).unwrap_or(file_path);
448
449        let rel_str = relative.to_string_lossy();
450
451        for pattern in &self.ignore_patterns {
452            if pattern.matches(&rel_str) {
453                return false;
454            }
455        }
456
457        true
458    }
459}
460
461/// Returns the root of the git repository containing `start`, if any.
462///
463/// This mirrors foundry's own `find_git_root` behavior: walk up ancestors
464/// until a directory containing `.git` is found.
465fn find_git_root(start: &Path) -> Option<PathBuf> {
466    let start = if start.is_file() {
467        start.parent()?
468    } else {
469        start
470    };
471    start
472        .ancestors()
473        .find(|p| p.join(".git").exists())
474        .map(Path::to_path_buf)
475}
476
477/// Walk up from `start` to find the nearest `foundry.toml`, stopping at the
478/// git repository root (consistent with foundry's `find_project_root`).
479///
480/// See: <https://github.com/foundry-rs/foundry/blob/5389caefb5bfb035c547dffb4fd0f441a37e5371/crates/config/src/utils.rs#L62>
481pub fn find_foundry_toml(start: &Path) -> Option<PathBuf> {
482    let start_dir = if start.is_file() {
483        start.parent()?
484    } else {
485        start
486    };
487
488    let boundary = find_git_root(start_dir);
489
490    start_dir
491        .ancestors()
492        // Don't look outside of the git repo, matching foundry's behavior.
493        .take_while(|p| {
494            if let Some(boundary) = &boundary {
495                p.starts_with(boundary)
496            } else {
497                true
498            }
499        })
500        .find(|p| p.join("foundry.toml").is_file())
501        .map(|p| p.join("foundry.toml"))
502}
503
504/// Load the lint configuration from the nearest `foundry.toml` relative to
505/// `file_path`. Returns `LintConfig::default()` when no config is found or
506/// the relevant sections are absent.
507pub fn load_lint_config(file_path: &Path) -> LintConfig {
508    let toml_path = match find_foundry_toml(file_path) {
509        Some(p) => p,
510        None => return LintConfig::default(),
511    };
512    load_lint_config_from_toml(&toml_path)
513}
514
515/// Load lint config from a known `foundry.toml` path (used when reloading
516/// after a file-watch notification).
517pub fn load_lint_config_from_toml(toml_path: &Path) -> LintConfig {
518    load_lint_config_from_toml_with_profile_name(toml_path, &active_profile_name())
519}
520
521fn load_lint_config_from_toml_with_profile_name(
522    toml_path: &Path,
523    profile_name: &str,
524) -> LintConfig {
525    let root = toml_path.parent().unwrap_or(Path::new("")).to_path_buf();
526
527    let content = match std::fs::read_to_string(toml_path) {
528        Ok(c) => c,
529        Err(_) => {
530            return LintConfig {
531                root,
532                ..Default::default()
533            };
534        }
535    };
536
537    let table: toml::Table = match content.parse() {
538        Ok(t) => t,
539        Err(_) => {
540            return LintConfig {
541                root,
542                ..Default::default()
543            };
544        }
545    };
546
547    let active_profile = find_profile_table(&table, profile_name);
548    let default_profile = find_profile_table(&table, "default");
549    let lint_table = get_lint_table(active_profile, default_profile);
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_load_lint_config_falls_back_to_default_lint_section() {
690        let dir = tempfile::tempdir().unwrap();
691        let toml_path = dir.path().join("foundry.toml");
692        fs::write(
693            &toml_path,
694            r#"
695[profile.default.lint]
696ignore = ["test/**/*"]
697lint_on_build = false
698
699[profile.local]
700src = "src"
701"#,
702        )
703        .unwrap();
704
705        let config = load_lint_config_from_toml_with_profile_name(&toml_path, "local");
706        assert!(!config.lint_on_build);
707        assert_eq!(config.ignore_patterns.len(), 1);
708        assert!(!config.should_lint(&dir.path().join("test/MyTest.sol")));
709    }
710
711    #[test]
712    fn test_find_foundry_toml() {
713        let dir = tempfile::tempdir().unwrap();
714        let toml_path = dir.path().join("foundry.toml");
715        fs::write(&toml_path, "[profile.default]").unwrap();
716
717        // Create a nested directory
718        let nested = dir.path().join("src");
719        fs::create_dir_all(&nested).unwrap();
720
721        let found = find_foundry_toml(&nested);
722        assert_eq!(found, Some(toml_path));
723    }
724
725    #[test]
726    fn test_load_lint_config_walks_ancestors() {
727        let dir = tempfile::tempdir().unwrap();
728        let toml_path = dir.path().join("foundry.toml");
729        fs::write(
730            &toml_path,
731            r#"
732[profile.default.lint]
733ignore = ["test/**/*"]
734"#,
735        )
736        .unwrap();
737
738        let nested_file = dir.path().join("src/Token.sol");
739        fs::create_dir_all(dir.path().join("src")).unwrap();
740        fs::write(&nested_file, "// solidity").unwrap();
741
742        let config = load_lint_config(&nested_file);
743        assert_eq!(config.root, dir.path());
744        assert_eq!(config.ignore_patterns.len(), 1);
745    }
746
747    #[test]
748    fn test_find_git_root() {
749        let dir = tempfile::tempdir().unwrap();
750        // Create a fake .git directory
751        fs::create_dir_all(dir.path().join(".git")).unwrap();
752        let nested = dir.path().join("sub/deep");
753        fs::create_dir_all(&nested).unwrap();
754
755        let root = find_git_root(&nested);
756        assert_eq!(root, Some(dir.path().to_path_buf()));
757    }
758
759    #[test]
760    fn test_find_foundry_toml_stops_at_git_boundary() {
761        // Layout:
762        //   tmp/
763        //     foundry.toml          <-- outside git repo, should NOT be found
764        //     repo/
765        //       .git/
766        //       sub/
767        //         [search starts here]
768        let dir = tempfile::tempdir().unwrap();
769
770        // foundry.toml outside the git repo
771        fs::write(dir.path().join("foundry.toml"), "[profile.default]").unwrap();
772
773        // git repo with no foundry.toml
774        let repo = dir.path().join("repo");
775        fs::create_dir_all(repo.join(".git")).unwrap();
776        fs::create_dir_all(repo.join("sub")).unwrap();
777
778        let found = find_foundry_toml(&repo.join("sub"));
779        // Should NOT find the foundry.toml above the .git boundary
780        assert_eq!(found, None);
781    }
782
783    #[test]
784    fn test_find_foundry_toml_within_git_boundary() {
785        // Layout:
786        //   tmp/
787        //     repo/
788        //       .git/
789        //       foundry.toml        <-- inside git repo, should be found
790        //       src/
791        //         [search starts here]
792        let dir = tempfile::tempdir().unwrap();
793        let repo = dir.path().join("repo");
794        fs::create_dir_all(repo.join(".git")).unwrap();
795        fs::create_dir_all(repo.join("src")).unwrap();
796        let toml_path = repo.join("foundry.toml");
797        fs::write(&toml_path, "[profile.default]").unwrap();
798
799        let found = find_foundry_toml(&repo.join("src"));
800        assert_eq!(found, Some(toml_path));
801    }
802
803    #[test]
804    fn test_find_foundry_toml_no_git_repo_still_walks_up() {
805        // When there's no .git directory at all, the search should still
806        // walk up (unbounded), matching foundry's behavior.
807        let dir = tempfile::tempdir().unwrap();
808        let toml_path = dir.path().join("foundry.toml");
809        fs::write(&toml_path, "[profile.default]").unwrap();
810
811        let nested = dir.path().join("a/b/c");
812        fs::create_dir_all(&nested).unwrap();
813
814        let found = find_foundry_toml(&nested);
815        assert_eq!(found, Some(toml_path));
816    }
817
818    // ── Compiler settings parsing ─────────────────────────────────────
819
820    #[test]
821    fn test_load_foundry_config_compiler_settings() {
822        let dir = tempfile::tempdir().unwrap();
823        let toml_path = dir.path().join("foundry.toml");
824        fs::write(
825            &toml_path,
826            r#"
827[profile.default]
828src = "src"
829solc = '0.8.33'
830optimizer = true
831optimizer_runs = 9999999
832via_ir = true
833evm_version = 'osaka'
834ignored_error_codes = [2394, 6321, 3860, 5574, 2424, 8429, 4591]
835"#,
836        )
837        .unwrap();
838
839        let config = load_foundry_config_from_toml(&toml_path);
840        assert_eq!(config.solc_version, Some("0.8.33".to_string()));
841        assert!(config.optimizer);
842        assert_eq!(config.optimizer_runs, 9999999);
843        assert!(config.via_ir);
844        assert_eq!(config.evm_version, Some("osaka".to_string()));
845        assert_eq!(
846            config.ignored_error_codes,
847            vec![2394, 6321, 3860, 5574, 2424, 8429, 4591]
848        );
849    }
850
851    #[test]
852    fn test_load_foundry_config_defaults_when_absent() {
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]
859src = "src"
860"#,
861        )
862        .unwrap();
863
864        let config = load_foundry_config_from_toml(&toml_path);
865        assert_eq!(config.solc_version, None);
866        assert!(!config.optimizer);
867        assert_eq!(config.optimizer_runs, 200);
868        assert!(!config.via_ir);
869        assert_eq!(config.evm_version, None);
870        assert!(config.ignored_error_codes.is_empty());
871        assert_eq!(config.libs, vec!["lib".to_string()]);
872    }
873
874    #[test]
875    fn test_load_foundry_config_partial_settings() {
876        let dir = tempfile::tempdir().unwrap();
877        let toml_path = dir.path().join("foundry.toml");
878        fs::write(
879            &toml_path,
880            r#"
881[profile.default]
882via_ir = true
883evm_version = "cancun"
884"#,
885        )
886        .unwrap();
887
888        let config = load_foundry_config_from_toml(&toml_path);
889        assert!(config.via_ir);
890        assert!(!config.optimizer); // default false
891        assert_eq!(config.optimizer_runs, 200); // default
892        assert_eq!(config.evm_version, Some("cancun".to_string()));
893        assert!(config.ignored_error_codes.is_empty());
894    }
895
896    #[test]
897    fn test_load_foundry_config_libs() {
898        let dir = tempfile::tempdir().unwrap();
899        let toml_path = dir.path().join("foundry.toml");
900        fs::write(
901            &toml_path,
902            r#"
903[profile.default]
904libs = ["lib", "node_modules", "dependencies"]
905"#,
906        )
907        .unwrap();
908
909        let config = load_foundry_config_from_toml(&toml_path);
910        assert_eq!(
911            config.libs,
912            vec![
913                "lib".to_string(),
914                "node_modules".to_string(),
915                "dependencies".to_string()
916            ]
917        );
918    }
919
920    #[test]
921    fn test_load_foundry_config_libs_defaults_when_absent() {
922        let dir = tempfile::tempdir().unwrap();
923        let toml_path = dir.path().join("foundry.toml");
924        fs::write(
925            &toml_path,
926            r#"
927[profile.default]
928src = "src"
929"#,
930        )
931        .unwrap();
932
933        let config = load_foundry_config_from_toml(&toml_path);
934        assert_eq!(config.libs, vec!["lib".to_string()]);
935    }
936
937    #[test]
938    fn test_load_foundry_config_falls_back_to_default_profile_values() {
939        let dir = tempfile::tempdir().unwrap();
940        let toml_path = dir.path().join("foundry.toml");
941        fs::write(
942            &toml_path,
943            r#"
944[profile.default]
945solc = "0.8.33"
946optimizer_runs = 1234
947libs = ["lib", "node_modules"]
948
949[profile.local]
950src = "contracts"
951"#,
952        )
953        .unwrap();
954
955        let config = load_foundry_config_from_toml_with_profile_name(&toml_path, "local");
956        assert_eq!(config.solc_version, Some("0.8.33".to_string()));
957        assert_eq!(config.optimizer_runs, 1234);
958        assert_eq!(
959            config.libs,
960            vec!["lib".to_string(), "node_modules".to_string()]
961        );
962        assert_eq!(config.sources_dir, "contracts".to_string());
963    }
964
965    // ── Settings parsing ──────────────────────────────────────────────
966
967    #[test]
968    fn test_parse_settings_defaults() {
969        let value = serde_json::json!({});
970        let s = parse_settings(&value);
971        assert!(s.inlay_hints.parameters);
972        assert!(s.inlay_hints.gas_estimates);
973        assert!(s.lint.enabled);
974        assert!(s.file_operations.template_on_create);
975        assert!(s.file_operations.update_imports_on_rename);
976        assert!(s.file_operations.update_imports_on_delete);
977        assert!(s.project_index.full_project_scan);
978        assert_eq!(s.project_index.cache_mode, ProjectIndexCacheMode::V2);
979        assert!(!s.project_index.incremental_edit_reindex);
980        assert!(s.lint.severity.is_empty());
981        assert!(s.lint.only.is_empty());
982        assert!(s.lint.exclude.is_empty());
983    }
984
985    #[test]
986    fn test_parse_settings_wrapped() {
987        let value = serde_json::json!({
988            "solidity-language-server": {
989                "inlayHints": { "parameters": false, "gasEstimates": false },
990                "lint": {
991                    "enabled": true,
992                    "severity": ["high", "med"],
993                    "only": ["incorrect-shift"],
994                    "exclude": ["pascal-case-struct", "mixed-case-variable"]
995                },
996                "fileOperations": {
997                    "templateOnCreate": false,
998                    "updateImportsOnRename": false,
999                    "updateImportsOnDelete": false
1000                },
1001                "projectIndex": {
1002                    "fullProjectScan": true,
1003                    "cacheMode": "v2",
1004                    "incrementalEditReindex": true
1005                },
1006            }
1007        });
1008        let s = parse_settings(&value);
1009        assert!(!s.inlay_hints.parameters);
1010        assert!(!s.inlay_hints.gas_estimates);
1011        assert!(s.lint.enabled);
1012        assert!(!s.file_operations.template_on_create);
1013        assert!(!s.file_operations.update_imports_on_rename);
1014        assert!(!s.file_operations.update_imports_on_delete);
1015        assert!(s.project_index.full_project_scan);
1016        assert_eq!(s.project_index.cache_mode, ProjectIndexCacheMode::V2);
1017        assert!(s.project_index.incremental_edit_reindex);
1018        assert_eq!(s.lint.severity, vec!["high", "med"]);
1019        assert_eq!(s.lint.only, vec!["incorrect-shift"]);
1020        assert_eq!(
1021            s.lint.exclude,
1022            vec!["pascal-case-struct", "mixed-case-variable"]
1023        );
1024    }
1025
1026    #[test]
1027    fn test_parse_settings_direct() {
1028        let value = serde_json::json!({
1029            "inlayHints": { "parameters": false },
1030            "lint": { "enabled": false },
1031            "fileOperations": {
1032                "templateOnCreate": false,
1033                "updateImportsOnRename": false,
1034                "updateImportsOnDelete": false
1035            },
1036            "projectIndex": {
1037                "fullProjectScan": true,
1038                "cacheMode": "v2",
1039                "incrementalEditReindex": true
1040            }
1041        });
1042        let s = parse_settings(&value);
1043        assert!(!s.inlay_hints.parameters);
1044        assert!(!s.lint.enabled);
1045        assert!(!s.file_operations.template_on_create);
1046        assert!(!s.file_operations.update_imports_on_rename);
1047        assert!(!s.file_operations.update_imports_on_delete);
1048        assert!(s.project_index.full_project_scan);
1049        assert_eq!(s.project_index.cache_mode, ProjectIndexCacheMode::V2);
1050        assert!(s.project_index.incremental_edit_reindex);
1051    }
1052
1053    #[test]
1054    fn test_parse_settings_partial() {
1055        let value = serde_json::json!({
1056            "solidity-language-server": {
1057                "lint": { "exclude": ["unused-import"] }
1058            }
1059        });
1060        let s = parse_settings(&value);
1061        // inlayHints not specified → defaults to true
1062        assert!(s.inlay_hints.parameters);
1063        assert!(s.inlay_hints.gas_estimates);
1064        // lint.enabled not specified → defaults to true
1065        assert!(s.lint.enabled);
1066        assert!(s.file_operations.template_on_create);
1067        assert!(s.file_operations.update_imports_on_rename);
1068        assert!(s.file_operations.update_imports_on_delete);
1069        assert!(s.project_index.full_project_scan);
1070        assert_eq!(s.project_index.cache_mode, ProjectIndexCacheMode::V2);
1071        assert!(!s.project_index.incremental_edit_reindex);
1072        assert!(s.lint.severity.is_empty());
1073        assert!(s.lint.only.is_empty());
1074        assert_eq!(s.lint.exclude, vec!["unused-import"]);
1075    }
1076
1077    #[test]
1078    fn test_parse_settings_empty_wrapped() {
1079        let value = serde_json::json!({
1080            "solidity-language-server": {}
1081        });
1082        let s = parse_settings(&value);
1083        assert!(s.inlay_hints.parameters);
1084        assert!(s.inlay_hints.gas_estimates);
1085        assert!(s.lint.enabled);
1086        assert!(s.file_operations.template_on_create);
1087        assert!(s.file_operations.update_imports_on_rename);
1088        assert!(s.file_operations.update_imports_on_delete);
1089        assert!(s.project_index.full_project_scan);
1090        assert_eq!(s.project_index.cache_mode, ProjectIndexCacheMode::V2);
1091        assert!(!s.project_index.incremental_edit_reindex);
1092        assert!(s.lint.severity.is_empty());
1093        assert!(s.lint.only.is_empty());
1094        assert!(s.lint.exclude.is_empty());
1095    }
1096
1097    #[test]
1098    fn test_parse_settings_project_index_cache_mode_defaults_on_invalid() {
1099        let value = serde_json::json!({
1100            "solidity-language-server": {
1101                "projectIndex": {
1102                    "cacheMode": "bad-mode"
1103                }
1104            }
1105        });
1106        let s = parse_settings(&value);
1107        assert_eq!(s.project_index.cache_mode, ProjectIndexCacheMode::V2);
1108        assert!(!s.project_index.incremental_edit_reindex);
1109    }
1110
1111    #[test]
1112    fn test_parse_settings_severity_only() {
1113        let value = serde_json::json!({
1114            "solidity-language-server": {
1115                "lint": {
1116                    "severity": ["high", "gas"],
1117                    "only": ["incorrect-shift", "asm-keccak256"]
1118                }
1119            }
1120        });
1121        let s = parse_settings(&value);
1122        assert_eq!(s.lint.severity, vec!["high", "gas"]);
1123        assert_eq!(s.lint.only, vec!["incorrect-shift", "asm-keccak256"]);
1124        assert!(s.lint.exclude.is_empty());
1125    }
1126}