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