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