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 = get_profile_value(active_profile, default_profile, "ignored_error_codes")
385        .and_then(|v| v.as_array())
386        .map(|arr| {
387            arr.iter()
388                .filter_map(|v| v.as_integer())
389                .map(|v| v as u64)
390                .collect()
391        })
392        .unwrap_or_default();
393
394    FoundryConfig {
395        root,
396        solc_version,
397        remappings,
398        via_ir,
399        optimizer,
400        optimizer_runs,
401        evm_version,
402        ignored_error_codes,
403        sources_dir,
404        libs,
405    }
406}
407
408/// Lint-related configuration extracted from `foundry.toml`.
409#[derive(Debug, Clone)]
410pub struct LintConfig {
411    /// The project root where `foundry.toml` was found.
412    pub root: PathBuf,
413    /// Whether linting is enabled on build (default: true).
414    pub lint_on_build: bool,
415    /// Compiled glob patterns from the `ignore` list.
416    pub ignore_patterns: Vec<glob::Pattern>,
417}
418
419impl Default for LintConfig {
420    fn default() -> Self {
421        Self {
422            root: PathBuf::new(),
423            lint_on_build: true,
424            ignore_patterns: Vec::new(),
425        }
426    }
427}
428
429impl LintConfig {
430    /// Returns `true` if the given file should be linted.
431    ///
432    /// A file is skipped when:
433    /// - `lint_on_build` is `false`, or
434    /// - the file's path (relative to the project root) matches any `ignore` pattern.
435    pub fn should_lint(&self, file_path: &Path) -> bool {
436        if !self.lint_on_build {
437            return false;
438        }
439
440        if self.ignore_patterns.is_empty() {
441            return true;
442        }
443
444        // Build a relative path from the project root so that patterns like
445        // "test/**/*" work correctly.
446        let relative = file_path.strip_prefix(&self.root).unwrap_or(file_path);
447
448        let rel_str = relative.to_string_lossy();
449
450        for pattern in &self.ignore_patterns {
451            if pattern.matches(&rel_str) {
452                return false;
453            }
454        }
455
456        true
457    }
458}
459
460/// Returns the root of the git repository containing `start`, if any.
461///
462/// This mirrors foundry's own `find_git_root` behavior: walk up ancestors
463/// until a directory containing `.git` is found.
464fn find_git_root(start: &Path) -> Option<PathBuf> {
465    let start = if start.is_file() {
466        start.parent()?
467    } else {
468        start
469    };
470    start
471        .ancestors()
472        .find(|p| p.join(".git").exists())
473        .map(Path::to_path_buf)
474}
475
476/// Walk up from `start` to find the nearest `foundry.toml`, stopping at the
477/// git repository root (consistent with foundry's `find_project_root`).
478///
479/// See: <https://github.com/foundry-rs/foundry/blob/5389caefb5bfb035c547dffb4fd0f441a37e5371/crates/config/src/utils.rs#L62>
480pub fn find_foundry_toml(start: &Path) -> Option<PathBuf> {
481    let start_dir = if start.is_file() {
482        start.parent()?
483    } else {
484        start
485    };
486
487    let boundary = find_git_root(start_dir);
488
489    start_dir
490        .ancestors()
491        // Don't look outside of the git repo, matching foundry's behavior.
492        .take_while(|p| {
493            if let Some(boundary) = &boundary {
494                p.starts_with(boundary)
495            } else {
496                true
497            }
498        })
499        .find(|p| p.join("foundry.toml").is_file())
500        .map(|p| p.join("foundry.toml"))
501}
502
503/// Load the lint configuration from the nearest `foundry.toml` relative to
504/// `file_path`. Returns `LintConfig::default()` when no config is found or
505/// the relevant sections are absent.
506pub fn load_lint_config(file_path: &Path) -> LintConfig {
507    let toml_path = match find_foundry_toml(file_path) {
508        Some(p) => p,
509        None => return LintConfig::default(),
510    };
511    load_lint_config_from_toml(&toml_path)
512}
513
514/// Load lint config from a known `foundry.toml` path (used when reloading
515/// after a file-watch notification).
516pub fn load_lint_config_from_toml(toml_path: &Path) -> LintConfig {
517    load_lint_config_from_toml_with_profile_name(toml_path, &active_profile_name())
518}
519
520fn load_lint_config_from_toml_with_profile_name(toml_path: &Path, profile_name: &str) -> LintConfig {
521    let root = toml_path.parent().unwrap_or(Path::new("")).to_path_buf();
522
523    let content = match std::fs::read_to_string(toml_path) {
524        Ok(c) => c,
525        Err(_) => {
526            return LintConfig {
527                root,
528                ..Default::default()
529            };
530        }
531    };
532
533    let table: toml::Table = match content.parse() {
534        Ok(t) => t,
535        Err(_) => {
536            return LintConfig {
537                root,
538                ..Default::default()
539            };
540        }
541    };
542
543    let active_profile = find_profile_table(&table, profile_name);
544    let default_profile = find_profile_table(&table, "default");
545    let lint_table = get_lint_table(active_profile, default_profile);
546
547    let lint_table = match lint_table {
548        Some(t) => t,
549        None => {
550            return LintConfig {
551                root,
552                ..Default::default()
553            };
554        }
555    };
556
557    let lint_on_build = lint_table
558        .get("lint_on_build")
559        .and_then(|v| v.as_bool())
560        .unwrap_or(true);
561
562    let ignore_patterns = lint_table
563        .get("ignore")
564        .and_then(|v| v.as_array())
565        .map(|arr| {
566            arr.iter()
567                .filter_map(|v| v.as_str())
568                .filter_map(|s| glob::Pattern::new(s).ok())
569                .collect()
570        })
571        .unwrap_or_default();
572
573    LintConfig {
574        root,
575        lint_on_build,
576        ignore_patterns,
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use std::fs;
584
585    #[test]
586    fn test_default_config_lints_everything() {
587        let config = LintConfig::default();
588        assert!(config.should_lint(Path::new("test/MyTest.sol")));
589        assert!(config.should_lint(Path::new("src/Token.sol")));
590    }
591
592    #[test]
593    fn test_lint_on_build_false_skips_all() {
594        let config = LintConfig {
595            lint_on_build: false,
596            ..Default::default()
597        };
598        assert!(!config.should_lint(Path::new("src/Token.sol")));
599    }
600
601    #[test]
602    fn test_ignore_pattern_matches() {
603        let config = LintConfig {
604            root: PathBuf::from("/project"),
605            lint_on_build: true,
606            ignore_patterns: vec![glob::Pattern::new("test/**/*").unwrap()],
607        };
608        assert!(!config.should_lint(Path::new("/project/test/MyTest.sol")));
609        assert!(config.should_lint(Path::new("/project/src/Token.sol")));
610    }
611
612    #[test]
613    fn test_multiple_ignore_patterns() {
614        let config = LintConfig {
615            root: PathBuf::from("/project"),
616            lint_on_build: true,
617            ignore_patterns: vec![
618                glob::Pattern::new("test/**/*").unwrap(),
619                glob::Pattern::new("script/**/*").unwrap(),
620            ],
621        };
622        assert!(!config.should_lint(Path::new("/project/test/MyTest.sol")));
623        assert!(!config.should_lint(Path::new("/project/script/Deploy.sol")));
624        assert!(config.should_lint(Path::new("/project/src/Token.sol")));
625    }
626
627    #[test]
628    fn test_load_lint_config_from_toml() {
629        let dir = tempfile::tempdir().unwrap();
630        let toml_path = dir.path().join("foundry.toml");
631        fs::write(
632            &toml_path,
633            r#"
634[profile.default.lint]
635ignore = ["test/**/*"]
636lint_on_build = true
637"#,
638        )
639        .unwrap();
640
641        let config = load_lint_config_from_toml(&toml_path);
642        assert!(config.lint_on_build);
643        assert_eq!(config.ignore_patterns.len(), 1);
644        assert!(!config.should_lint(&dir.path().join("test/MyTest.sol")));
645        assert!(config.should_lint(&dir.path().join("src/Token.sol")));
646    }
647
648    #[test]
649    fn test_load_lint_config_lint_on_build_false() {
650        let dir = tempfile::tempdir().unwrap();
651        let toml_path = dir.path().join("foundry.toml");
652        fs::write(
653            &toml_path,
654            r#"
655[profile.default.lint]
656lint_on_build = false
657"#,
658        )
659        .unwrap();
660
661        let config = load_lint_config_from_toml(&toml_path);
662        assert!(!config.lint_on_build);
663        assert!(!config.should_lint(&dir.path().join("src/Token.sol")));
664    }
665
666    #[test]
667    fn test_load_lint_config_no_lint_section() {
668        let dir = tempfile::tempdir().unwrap();
669        let toml_path = dir.path().join("foundry.toml");
670        fs::write(
671            &toml_path,
672            r#"
673[profile.default]
674src = "src"
675"#,
676        )
677        .unwrap();
678
679        let config = load_lint_config_from_toml(&toml_path);
680        assert!(config.lint_on_build);
681        assert!(config.ignore_patterns.is_empty());
682    }
683
684    #[test]
685    fn test_load_lint_config_falls_back_to_default_lint_section() {
686        let dir = tempfile::tempdir().unwrap();
687        let toml_path = dir.path().join("foundry.toml");
688        fs::write(
689            &toml_path,
690            r#"
691[profile.default.lint]
692ignore = ["test/**/*"]
693lint_on_build = false
694
695[profile.local]
696src = "src"
697"#,
698        )
699        .unwrap();
700
701        let config = load_lint_config_from_toml_with_profile_name(&toml_path, "local");
702        assert!(!config.lint_on_build);
703        assert_eq!(config.ignore_patterns.len(), 1);
704        assert!(!config.should_lint(&dir.path().join("test/MyTest.sol")));
705    }
706
707    #[test]
708    fn test_find_foundry_toml() {
709        let dir = tempfile::tempdir().unwrap();
710        let toml_path = dir.path().join("foundry.toml");
711        fs::write(&toml_path, "[profile.default]").unwrap();
712
713        // Create a nested directory
714        let nested = dir.path().join("src");
715        fs::create_dir_all(&nested).unwrap();
716
717        let found = find_foundry_toml(&nested);
718        assert_eq!(found, Some(toml_path));
719    }
720
721    #[test]
722    fn test_load_lint_config_walks_ancestors() {
723        let dir = tempfile::tempdir().unwrap();
724        let toml_path = dir.path().join("foundry.toml");
725        fs::write(
726            &toml_path,
727            r#"
728[profile.default.lint]
729ignore = ["test/**/*"]
730"#,
731        )
732        .unwrap();
733
734        let nested_file = dir.path().join("src/Token.sol");
735        fs::create_dir_all(dir.path().join("src")).unwrap();
736        fs::write(&nested_file, "// solidity").unwrap();
737
738        let config = load_lint_config(&nested_file);
739        assert_eq!(config.root, dir.path());
740        assert_eq!(config.ignore_patterns.len(), 1);
741    }
742
743    #[test]
744    fn test_find_git_root() {
745        let dir = tempfile::tempdir().unwrap();
746        // Create a fake .git directory
747        fs::create_dir_all(dir.path().join(".git")).unwrap();
748        let nested = dir.path().join("sub/deep");
749        fs::create_dir_all(&nested).unwrap();
750
751        let root = find_git_root(&nested);
752        assert_eq!(root, Some(dir.path().to_path_buf()));
753    }
754
755    #[test]
756    fn test_find_foundry_toml_stops_at_git_boundary() {
757        // Layout:
758        //   tmp/
759        //     foundry.toml          <-- outside git repo, should NOT be found
760        //     repo/
761        //       .git/
762        //       sub/
763        //         [search starts here]
764        let dir = tempfile::tempdir().unwrap();
765
766        // foundry.toml outside the git repo
767        fs::write(dir.path().join("foundry.toml"), "[profile.default]").unwrap();
768
769        // git repo with no foundry.toml
770        let repo = dir.path().join("repo");
771        fs::create_dir_all(repo.join(".git")).unwrap();
772        fs::create_dir_all(repo.join("sub")).unwrap();
773
774        let found = find_foundry_toml(&repo.join("sub"));
775        // Should NOT find the foundry.toml above the .git boundary
776        assert_eq!(found, None);
777    }
778
779    #[test]
780    fn test_find_foundry_toml_within_git_boundary() {
781        // Layout:
782        //   tmp/
783        //     repo/
784        //       .git/
785        //       foundry.toml        <-- inside git repo, should be found
786        //       src/
787        //         [search starts here]
788        let dir = tempfile::tempdir().unwrap();
789        let repo = dir.path().join("repo");
790        fs::create_dir_all(repo.join(".git")).unwrap();
791        fs::create_dir_all(repo.join("src")).unwrap();
792        let toml_path = repo.join("foundry.toml");
793        fs::write(&toml_path, "[profile.default]").unwrap();
794
795        let found = find_foundry_toml(&repo.join("src"));
796        assert_eq!(found, Some(toml_path));
797    }
798
799    #[test]
800    fn test_find_foundry_toml_no_git_repo_still_walks_up() {
801        // When there's no .git directory at all, the search should still
802        // walk up (unbounded), matching foundry's behavior.
803        let dir = tempfile::tempdir().unwrap();
804        let toml_path = dir.path().join("foundry.toml");
805        fs::write(&toml_path, "[profile.default]").unwrap();
806
807        let nested = dir.path().join("a/b/c");
808        fs::create_dir_all(&nested).unwrap();
809
810        let found = find_foundry_toml(&nested);
811        assert_eq!(found, Some(toml_path));
812    }
813
814    // ── Compiler settings parsing ─────────────────────────────────────
815
816    #[test]
817    fn test_load_foundry_config_compiler_settings() {
818        let dir = tempfile::tempdir().unwrap();
819        let toml_path = dir.path().join("foundry.toml");
820        fs::write(
821            &toml_path,
822            r#"
823[profile.default]
824src = "src"
825solc = '0.8.33'
826optimizer = true
827optimizer_runs = 9999999
828via_ir = true
829evm_version = 'osaka'
830ignored_error_codes = [2394, 6321, 3860, 5574, 2424, 8429, 4591]
831"#,
832        )
833        .unwrap();
834
835        let config = load_foundry_config_from_toml(&toml_path);
836        assert_eq!(config.solc_version, Some("0.8.33".to_string()));
837        assert!(config.optimizer);
838        assert_eq!(config.optimizer_runs, 9999999);
839        assert!(config.via_ir);
840        assert_eq!(config.evm_version, Some("osaka".to_string()));
841        assert_eq!(
842            config.ignored_error_codes,
843            vec![2394, 6321, 3860, 5574, 2424, 8429, 4591]
844        );
845    }
846
847    #[test]
848    fn test_load_foundry_config_defaults_when_absent() {
849        let dir = tempfile::tempdir().unwrap();
850        let toml_path = dir.path().join("foundry.toml");
851        fs::write(
852            &toml_path,
853            r#"
854[profile.default]
855src = "src"
856"#,
857        )
858        .unwrap();
859
860        let config = load_foundry_config_from_toml(&toml_path);
861        assert_eq!(config.solc_version, None);
862        assert!(!config.optimizer);
863        assert_eq!(config.optimizer_runs, 200);
864        assert!(!config.via_ir);
865        assert_eq!(config.evm_version, None);
866        assert!(config.ignored_error_codes.is_empty());
867        assert_eq!(config.libs, vec!["lib".to_string()]);
868    }
869
870    #[test]
871    fn test_load_foundry_config_partial_settings() {
872        let dir = tempfile::tempdir().unwrap();
873        let toml_path = dir.path().join("foundry.toml");
874        fs::write(
875            &toml_path,
876            r#"
877[profile.default]
878via_ir = true
879evm_version = "cancun"
880"#,
881        )
882        .unwrap();
883
884        let config = load_foundry_config_from_toml(&toml_path);
885        assert!(config.via_ir);
886        assert!(!config.optimizer); // default false
887        assert_eq!(config.optimizer_runs, 200); // default
888        assert_eq!(config.evm_version, Some("cancun".to_string()));
889        assert!(config.ignored_error_codes.is_empty());
890    }
891
892    #[test]
893    fn test_load_foundry_config_libs() {
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]
900libs = ["lib", "node_modules", "dependencies"]
901"#,
902        )
903        .unwrap();
904
905        let config = load_foundry_config_from_toml(&toml_path);
906        assert_eq!(
907            config.libs,
908            vec![
909                "lib".to_string(),
910                "node_modules".to_string(),
911                "dependencies".to_string()
912            ]
913        );
914    }
915
916    #[test]
917    fn test_load_foundry_config_libs_defaults_when_absent() {
918        let dir = tempfile::tempdir().unwrap();
919        let toml_path = dir.path().join("foundry.toml");
920        fs::write(
921            &toml_path,
922            r#"
923[profile.default]
924src = "src"
925"#,
926        )
927        .unwrap();
928
929        let config = load_foundry_config_from_toml(&toml_path);
930        assert_eq!(config.libs, vec!["lib".to_string()]);
931    }
932
933    #[test]
934    fn test_load_foundry_config_falls_back_to_default_profile_values() {
935        let dir = tempfile::tempdir().unwrap();
936        let toml_path = dir.path().join("foundry.toml");
937        fs::write(
938            &toml_path,
939            r#"
940[profile.default]
941solc = "0.8.33"
942optimizer_runs = 1234
943libs = ["lib", "node_modules"]
944
945[profile.local]
946src = "contracts"
947"#,
948        )
949        .unwrap();
950
951        let config = load_foundry_config_from_toml_with_profile_name(&toml_path, "local");
952        assert_eq!(config.solc_version, Some("0.8.33".to_string()));
953        assert_eq!(config.optimizer_runs, 1234);
954        assert_eq!(
955            config.libs,
956            vec!["lib".to_string(), "node_modules".to_string()]
957        );
958        assert_eq!(config.sources_dir, "contracts".to_string());
959    }
960
961    // ── Settings parsing ──────────────────────────────────────────────
962
963    #[test]
964    fn test_parse_settings_defaults() {
965        let value = serde_json::json!({});
966        let s = parse_settings(&value);
967        assert!(s.inlay_hints.parameters);
968        assert!(s.inlay_hints.gas_estimates);
969        assert!(s.lint.enabled);
970        assert!(s.file_operations.template_on_create);
971        assert!(s.file_operations.update_imports_on_rename);
972        assert!(s.file_operations.update_imports_on_delete);
973        assert!(s.project_index.full_project_scan);
974        assert_eq!(s.project_index.cache_mode, ProjectIndexCacheMode::V2);
975        assert!(!s.project_index.incremental_edit_reindex);
976        assert!(s.lint.severity.is_empty());
977        assert!(s.lint.only.is_empty());
978        assert!(s.lint.exclude.is_empty());
979    }
980
981    #[test]
982    fn test_parse_settings_wrapped() {
983        let value = serde_json::json!({
984            "solidity-language-server": {
985                "inlayHints": { "parameters": false, "gasEstimates": false },
986                "lint": {
987                    "enabled": true,
988                    "severity": ["high", "med"],
989                    "only": ["incorrect-shift"],
990                    "exclude": ["pascal-case-struct", "mixed-case-variable"]
991                },
992                "fileOperations": {
993                    "templateOnCreate": false,
994                    "updateImportsOnRename": false,
995                    "updateImportsOnDelete": false
996                },
997                "projectIndex": {
998                    "fullProjectScan": true,
999                    "cacheMode": "v2",
1000                    "incrementalEditReindex": true
1001                },
1002            }
1003        });
1004        let s = parse_settings(&value);
1005        assert!(!s.inlay_hints.parameters);
1006        assert!(!s.inlay_hints.gas_estimates);
1007        assert!(s.lint.enabled);
1008        assert!(!s.file_operations.template_on_create);
1009        assert!(!s.file_operations.update_imports_on_rename);
1010        assert!(!s.file_operations.update_imports_on_delete);
1011        assert!(s.project_index.full_project_scan);
1012        assert_eq!(s.project_index.cache_mode, ProjectIndexCacheMode::V2);
1013        assert!(s.project_index.incremental_edit_reindex);
1014        assert_eq!(s.lint.severity, vec!["high", "med"]);
1015        assert_eq!(s.lint.only, vec!["incorrect-shift"]);
1016        assert_eq!(
1017            s.lint.exclude,
1018            vec!["pascal-case-struct", "mixed-case-variable"]
1019        );
1020    }
1021
1022    #[test]
1023    fn test_parse_settings_direct() {
1024        let value = serde_json::json!({
1025            "inlayHints": { "parameters": false },
1026            "lint": { "enabled": false },
1027            "fileOperations": {
1028                "templateOnCreate": false,
1029                "updateImportsOnRename": false,
1030                "updateImportsOnDelete": false
1031            },
1032            "projectIndex": {
1033                "fullProjectScan": true,
1034                "cacheMode": "v2",
1035                "incrementalEditReindex": true
1036            }
1037        });
1038        let s = parse_settings(&value);
1039        assert!(!s.inlay_hints.parameters);
1040        assert!(!s.lint.enabled);
1041        assert!(!s.file_operations.template_on_create);
1042        assert!(!s.file_operations.update_imports_on_rename);
1043        assert!(!s.file_operations.update_imports_on_delete);
1044        assert!(s.project_index.full_project_scan);
1045        assert_eq!(s.project_index.cache_mode, ProjectIndexCacheMode::V2);
1046        assert!(s.project_index.incremental_edit_reindex);
1047    }
1048
1049    #[test]
1050    fn test_parse_settings_partial() {
1051        let value = serde_json::json!({
1052            "solidity-language-server": {
1053                "lint": { "exclude": ["unused-import"] }
1054            }
1055        });
1056        let s = parse_settings(&value);
1057        // inlayHints not specified → defaults to true
1058        assert!(s.inlay_hints.parameters);
1059        assert!(s.inlay_hints.gas_estimates);
1060        // lint.enabled not specified → defaults to true
1061        assert!(s.lint.enabled);
1062        assert!(s.file_operations.template_on_create);
1063        assert!(s.file_operations.update_imports_on_rename);
1064        assert!(s.file_operations.update_imports_on_delete);
1065        assert!(s.project_index.full_project_scan);
1066        assert_eq!(s.project_index.cache_mode, ProjectIndexCacheMode::V2);
1067        assert!(!s.project_index.incremental_edit_reindex);
1068        assert!(s.lint.severity.is_empty());
1069        assert!(s.lint.only.is_empty());
1070        assert_eq!(s.lint.exclude, vec!["unused-import"]);
1071    }
1072
1073    #[test]
1074    fn test_parse_settings_empty_wrapped() {
1075        let value = serde_json::json!({
1076            "solidity-language-server": {}
1077        });
1078        let s = parse_settings(&value);
1079        assert!(s.inlay_hints.parameters);
1080        assert!(s.inlay_hints.gas_estimates);
1081        assert!(s.lint.enabled);
1082        assert!(s.file_operations.template_on_create);
1083        assert!(s.file_operations.update_imports_on_rename);
1084        assert!(s.file_operations.update_imports_on_delete);
1085        assert!(s.project_index.full_project_scan);
1086        assert_eq!(s.project_index.cache_mode, ProjectIndexCacheMode::V2);
1087        assert!(!s.project_index.incremental_edit_reindex);
1088        assert!(s.lint.severity.is_empty());
1089        assert!(s.lint.only.is_empty());
1090        assert!(s.lint.exclude.is_empty());
1091    }
1092
1093    #[test]
1094    fn test_parse_settings_project_index_cache_mode_defaults_on_invalid() {
1095        let value = serde_json::json!({
1096            "solidity-language-server": {
1097                "projectIndex": {
1098                    "cacheMode": "bad-mode"
1099                }
1100            }
1101        });
1102        let s = parse_settings(&value);
1103        assert_eq!(s.project_index.cache_mode, ProjectIndexCacheMode::V2);
1104        assert!(!s.project_index.incremental_edit_reindex);
1105    }
1106
1107    #[test]
1108    fn test_parse_settings_severity_only() {
1109        let value = serde_json::json!({
1110            "solidity-language-server": {
1111                "lint": {
1112                    "severity": ["high", "gas"],
1113                    "only": ["incorrect-shift", "asm-keccak256"]
1114                }
1115            }
1116        });
1117        let s = parse_settings(&value);
1118        assert_eq!(s.lint.severity, vec!["high", "gas"]);
1119        assert_eq!(s.lint.only, vec!["incorrect-shift", "asm-keccak256"]);
1120        assert!(s.lint.exclude.is_empty());
1121    }
1122}