Skip to main content

php_lsp/lang/
config.rs

1/// Per-category diagnostic toggle flags.
2/// The master `enabled` switch defaults to `true`. Individual category flags
3/// also default to `true`, so all diagnostics are on out of the box; set
4/// `initializationOptions.diagnostics.enabled = false` to silence everything,
5/// or turn off specific categories individually.
6#[derive(Debug, Clone)]
7pub struct DiagnosticsConfig {
8    /// Master switch: when `false`, no diagnostics are emitted. Defaults to `true`.
9    pub enabled: bool,
10    /// Undefined variable references.
11    pub undefined_variables: bool,
12    /// Calls to undefined functions.
13    pub undefined_functions: bool,
14    /// References to undefined classes / interfaces / traits.
15    pub undefined_classes: bool,
16    /// Wrong number of arguments passed to a function.
17    pub arity_errors: bool,
18    /// Return-type mismatches.
19    pub type_errors: bool,
20    /// Calls to `@deprecated` members.
21    pub deprecated_calls: bool,
22    /// Duplicate class / function declarations.
23    pub duplicate_declarations: bool,
24    /// Unused-symbol warnings (unused variables / parameters / methods /
25    /// properties / functions). Defaults to `false` so the LSP doesn't add
26    /// noisy warnings to existing workspaces without an opt-in. Toggle via
27    /// `diagnostics.unusedSymbols` in initializationOptions.
28    pub unused_symbols: bool,
29    /// Missing type annotations on interface methods and class properties
30    /// (MissingReturnType, MissingParamType, MissingPropertyType). Off by
31    /// default; opt in via `diagnostics.missingTypes`.
32    pub missing_types: bool,
33    /// Mixed-type usage lints: passing `mixed` to a typed parameter, assigning
34    /// mixed to a typed property, array/property access on mixed, etc. Off by
35    /// default; opt in via `diagnostics.mixedUsage`.
36    pub mixed_usage: bool,
37    /// Issue names (the diagnostic `code`, e.g. `MissingThrowsDocblock`) that are never emitted.
38    pub disabled: Vec<String>,
39    /// Patterns (same syntax as the top-level `excludePaths`) for files that never get diagnostics,
40    /// even when open; indexing is unaffected.
41    pub exclude_paths: Vec<String>,
42}
43
44impl Default for DiagnosticsConfig {
45    fn default() -> Self {
46        DiagnosticsConfig {
47            enabled: true,
48            undefined_variables: true,
49            undefined_functions: true,
50            undefined_classes: true,
51            arity_errors: true,
52            type_errors: true,
53            deprecated_calls: true,
54            duplicate_declarations: true,
55            unused_symbols: false,
56            missing_types: false,
57            mixed_usage: false,
58            disabled: Vec::new(),
59            exclude_paths: Vec::new(),
60        }
61    }
62}
63
64impl DiagnosticsConfig {
65    /// All categories on. Used in tests and by clients that explicitly enable
66    /// diagnostics without overriding individual flags.
67    #[cfg(test)]
68    pub fn all_enabled() -> Self {
69        DiagnosticsConfig {
70            enabled: true,
71            ..DiagnosticsConfig::default()
72        }
73    }
74
75    pub(crate) fn from_value(v: &serde_json::Value) -> Self {
76        let mut cfg = DiagnosticsConfig::default();
77        let Some(obj) = v.as_object() else { return cfg };
78        let flag = |key: &str| obj.get(key).and_then(|x| x.as_bool()).unwrap_or(true);
79        cfg.enabled = obj.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true);
80        cfg.undefined_variables = flag("undefinedVariables");
81        cfg.undefined_functions = flag("undefinedFunctions");
82        cfg.undefined_classes = flag("undefinedClasses");
83        cfg.arity_errors = flag("arityErrors");
84        cfg.type_errors = flag("typeErrors");
85        cfg.deprecated_calls = flag("deprecatedCalls");
86        cfg.duplicate_declarations = flag("duplicateDeclarations");
87        cfg.unused_symbols = obj
88            .get("unusedSymbols")
89            .and_then(|x| x.as_bool())
90            .unwrap_or(false);
91        cfg.missing_types = obj
92            .get("missingTypes")
93            .and_then(|x| x.as_bool())
94            .unwrap_or(false);
95        cfg.mixed_usage = obj
96            .get("mixedUsage")
97            .and_then(|x| x.as_bool())
98            .unwrap_or(false);
99        let strings = |v: Option<&serde_json::Value>| -> Vec<String> {
100            v.and_then(|x| x.as_array())
101                .map(|arr| {
102                    arr.iter()
103                        .filter_map(|x| x.as_str().map(str::to_string))
104                        .collect()
105                })
106                .unwrap_or_default()
107        };
108        cfg.disabled = strings(obj.get("disabled"));
109        cfg.exclude_paths = strings(obj.get("excludePaths"));
110        cfg
111    }
112}
113
114/// Per-feature capability toggles. All default to `true` (enabled).
115/// Set `initializationOptions.features.<name> = false` to suppress a capability.
116#[derive(Debug, Clone)]
117pub struct FeaturesConfig {
118    pub completion: bool,
119    pub hover: bool,
120    pub definition: bool,
121    pub declaration: bool,
122    pub references: bool,
123    pub document_symbols: bool,
124    pub workspace_symbols: bool,
125    pub rename: bool,
126    pub signature_help: bool,
127    pub inlay_hints: bool,
128    pub semantic_tokens: bool,
129    pub selection_range: bool,
130    pub call_hierarchy: bool,
131    pub document_highlight: bool,
132    pub implementation: bool,
133    pub code_action: bool,
134    pub type_definition: bool,
135    pub code_lens: bool,
136    pub formatting: bool,
137    pub range_formatting: bool,
138    pub on_type_formatting: bool,
139    pub document_link: bool,
140    pub linked_editing_range: bool,
141    pub inline_values: bool,
142}
143
144impl Default for FeaturesConfig {
145    fn default() -> Self {
146        FeaturesConfig {
147            completion: true,
148            hover: true,
149            definition: true,
150            declaration: true,
151            references: true,
152            document_symbols: true,
153            workspace_symbols: true,
154            rename: true,
155            signature_help: true,
156            inlay_hints: true,
157            semantic_tokens: true,
158            selection_range: true,
159            call_hierarchy: true,
160            document_highlight: true,
161            implementation: true,
162            code_action: true,
163            type_definition: true,
164            code_lens: true,
165            formatting: true,
166            range_formatting: true,
167            on_type_formatting: true,
168            document_link: true,
169            linked_editing_range: true,
170            inline_values: true,
171        }
172    }
173}
174
175impl FeaturesConfig {
176    pub(crate) fn from_value(v: &serde_json::Value) -> Self {
177        let mut cfg = FeaturesConfig::default();
178        let Some(obj) = v.as_object() else { return cfg };
179        let flag = |key: &str| obj.get(key).and_then(|x| x.as_bool()).unwrap_or(true);
180        cfg.completion = flag("completion");
181        cfg.hover = flag("hover");
182        cfg.definition = flag("definition");
183        cfg.declaration = flag("declaration");
184        cfg.references = flag("references");
185        cfg.document_symbols = flag("documentSymbols");
186        cfg.workspace_symbols = flag("workspaceSymbols");
187        cfg.rename = flag("rename");
188        cfg.signature_help = flag("signatureHelp");
189        cfg.inlay_hints = flag("inlayHints");
190        cfg.semantic_tokens = flag("semanticTokens");
191        cfg.selection_range = flag("selectionRange");
192        cfg.call_hierarchy = flag("callHierarchy");
193        cfg.document_highlight = flag("documentHighlight");
194        cfg.implementation = flag("implementation");
195        cfg.code_action = flag("codeAction");
196        cfg.type_definition = flag("typeDefinition");
197        cfg.code_lens = flag("codeLens");
198        cfg.formatting = flag("formatting");
199        cfg.range_formatting = flag("rangeFormatting");
200        cfg.on_type_formatting = flag("onTypeFormatting");
201        cfg.document_link = flag("documentLink");
202        cfg.linked_editing_range = flag("linkedEditingRange");
203        cfg.inline_values = flag("inlineValues");
204        cfg
205    }
206}
207
208/// PHPStan integration. Off by default: running a project's own PHPStan
209/// installation takes far longer than the built-in analyzer (seconds, not
210/// milliseconds) and requires a project-specific config, so it's an explicit
211/// opt-in rather than something that fires for every workspace.
212#[derive(Debug, Clone)]
213pub struct PhpstanConfig {
214    pub enabled: bool,
215    /// Executable name or path. Defaults to `phpstan` resolved via `$PATH`;
216    /// set to e.g. `vendor/bin/phpstan` for a project-local install.
217    pub bin_path: String,
218    /// Passed as `-c <path>` when set. Otherwise PHPStan uses its own
219    /// discovery (`phpstan.neon` / `phpstan.neon.dist` in the workspace root).
220    pub config_path: Option<String>,
221}
222
223impl Default for PhpstanConfig {
224    fn default() -> Self {
225        PhpstanConfig {
226            enabled: false,
227            bin_path: "phpstan".to_string(),
228            config_path: None,
229        }
230    }
231}
232
233impl PhpstanConfig {
234    pub(crate) fn from_value(v: &serde_json::Value) -> Self {
235        let mut cfg = PhpstanConfig::default();
236        let Some(obj) = v.as_object() else { return cfg };
237        cfg.enabled = obj
238            .get("enabled")
239            .and_then(|x| x.as_bool())
240            .unwrap_or(false);
241        if let Some(s) = obj.get("binPath").and_then(|x| x.as_str()) {
242            cfg.bin_path = s.to_string();
243        }
244        if let Some(s) = obj.get("configPath").and_then(|x| x.as_str()) {
245            cfg.config_path = Some(s.to_string());
246        }
247        cfg
248    }
249}
250
251/// PHPCS integration. Off by default, same rationale as [`PhpstanConfig`].
252#[derive(Debug, Clone)]
253pub struct PhpcsConfig {
254    pub enabled: bool,
255    /// Executable name or path. Defaults to `phpcs` resolved via `$PATH`;
256    /// set to e.g. `vendor/bin/phpcs` for a project-local install.
257    pub bin_path: String,
258    /// Passed as `--standard=<value>` when set (e.g. `"PSR12"`). Otherwise
259    /// PHPCS uses its own default/ruleset discovery.
260    pub standard: Option<String>,
261}
262
263impl Default for PhpcsConfig {
264    fn default() -> Self {
265        PhpcsConfig {
266            enabled: false,
267            bin_path: "phpcs".to_string(),
268            standard: None,
269        }
270    }
271}
272
273impl PhpcsConfig {
274    pub(crate) fn from_value(v: &serde_json::Value) -> Self {
275        let mut cfg = PhpcsConfig::default();
276        let Some(obj) = v.as_object() else { return cfg };
277        cfg.enabled = obj
278            .get("enabled")
279            .and_then(|x| x.as_bool())
280            .unwrap_or(false);
281        if let Some(s) = obj.get("binPath").and_then(|x| x.as_str()) {
282            cfg.bin_path = s.to_string();
283        }
284        if let Some(s) = obj.get("standard").and_then(|x| x.as_str()) {
285            cfg.standard = Some(s.to_string());
286        }
287        cfg
288    }
289}
290
291/// External static-analysis tools run as child processes and merged into
292/// published diagnostics, in addition to (not instead of) the built-in
293/// analyzer. See [`PhpstanConfig`] / [`PhpcsConfig`] for why they default off.
294#[derive(Debug, Clone, Default)]
295pub struct ExternalToolsConfig {
296    pub phpstan: PhpstanConfig,
297    pub phpcs: PhpcsConfig,
298}
299
300impl ExternalToolsConfig {
301    pub(crate) fn from_value(v: &serde_json::Value) -> Self {
302        let mut cfg = ExternalToolsConfig::default();
303        let Some(obj) = v.as_object() else { return cfg };
304        if let Some(v) = obj.get("phpstan") {
305            cfg.phpstan = PhpstanConfig::from_value(v);
306        }
307        if let Some(v) = obj.get("phpcs") {
308            cfg.phpcs = PhpcsConfig::from_value(v);
309        }
310        cfg
311    }
312}
313
314/// Maximum number of PHP files indexed during a workspace scan.
315/// Prevents excessive memory use on projects with very large vendor trees.
316pub const MAX_INDEXED_FILES: usize = 50_000;
317
318/// Configuration received from the client via `initializationOptions`.
319#[derive(Debug, Clone)]
320pub struct LspConfig {
321    /// PHP version string, e.g. `"8.1"`.  Set explicitly via `initializationOptions`
322    /// or auto-detected from `composer.json` / the `php` binary at startup.
323    pub php_version: Option<String>,
324    /// Glob patterns for paths to exclude from workspace indexing.
325    pub exclude_paths: Vec<String>,
326    /// Glob patterns for paths that must be indexed even if they match an
327    /// `excludePaths` entry.  Patterns are matched against path components
328    /// (same semantics as `excludePaths`).  Example: `["vendor/yiisoft"]`.
329    pub include_paths: Vec<String>,
330    /// Directories of user-supplied PHP stub files to load in addition to the
331    /// bundled built-ins. Each entry is resolved relative to the workspace
332    /// root if not already absolute. Every `.php` file found (recursively) is
333    /// registered as a read-only, highest-precedence symbol source — useful
334    /// for extensions or frameworks the bundled stubs don't cover.
335    pub stub_dirs: Vec<String>,
336    /// Per-category diagnostic toggles.
337    pub diagnostics: DiagnosticsConfig,
338    /// Per-feature capability toggles.
339    pub features: FeaturesConfig,
340    /// Hard cap on the number of PHP files indexed during a workspace scan.
341    /// Defaults to [`MAX_INDEXED_FILES`]. Set lower via `initializationOptions`
342    /// to reduce memory on projects with very large vendor trees.
343    pub max_indexed_files: usize,
344    /// Whether to eagerly index `vendor/` during the workspace scan.
345    ///
346    /// Default `true`: `vendor/` is walked and declaration-extracted
347    /// (name/namespace/signature, no type inference) alongside the rest of
348    /// the workspace, so bare-name completion, workspace symbols, and
349    /// find-implementations/type-hierarchy see vendor classes without extra
350    /// configuration (issues #240, #246). This scan is I/O-bound, not
351    /// CPU-bound, and cheap relative to the fear it usually gets: on real
352    /// fixtures the walk+read+parse+extract for ~5k files runs in ~200ms, and
353    /// unchanged files replay from the on-disk content-hash cache
354    /// (`index/cache.rs`) on subsequent starts. What eager vendor indexing
355    /// does NOT do is pull vendor into the background `warm_analysis_sweep`
356    /// (full type analysis for find-references/rename) — that sweep skips
357    /// vendor files unconditionally (see `document_store::is_vendor_uri`)
358    /// since sweeping the whole vendor tree buys nothing lasting (the
359    /// analysis-result cache is capped, see `ANALYSIS_CACHE_CAP`) and would
360    /// multiply background CPU cost by vendor's file count. A vendor file
361    /// still gets fully analyzed on demand — when a request touches it
362    /// directly, or when it's referenced by a currently-open file (which the
363    /// sweep prioritizes regardless of this flag).
364    ///
365    /// Set `false` to skip `vendor/` entirely on scan (old default): vendor
366    /// files then load only on demand via PSR-4 resolution (composer
367    /// autoload + per-file parse). Useful for very large vendor trees where
368    /// even the cheap declaration scan isn't worth paying for.
369    pub index_vendor: bool,
370    /// Emit extra diagnostic log messages on startup: cache hit/miss ratio,
371    /// workspace root paths, and PSR-4 namespace count.
372    /// Enable via `initializationOptions.debug = true`.
373    pub debug: bool,
374    /// Debounce delay in milliseconds between the last `textDocument/didChange`
375    /// and the parse + analysis run. Defaults to 100 ms. Set lower for
376    /// fast machines / Neovim users; set higher for slow machines or large
377    /// files to reduce thrashing.
378    pub debounce_ms: u64,
379    /// Background-analyze the workspace after indexing (and re-warm after
380    /// edits settle) so find-references / rename answer from warm memos
381    /// instead of paying a cold per-file analysis at request time. Default
382    /// `true`; set `warmAnalysis: false` to trade slower references for a
383    /// smaller resident footprint.
384    pub warm_analysis: bool,
385    /// Override the on-disk cache directory. When set, used verbatim (no
386    /// schema-version or workspace-hash subdirectories appended). Primarily
387    /// useful in tests and CI environments with non-standard cache locations.
388    /// When absent, falls back to the platform default (`$XDG_CACHE_HOME` /
389    /// `$HOME/.cache` on Unix, `%LOCALAPPDATA%` on Windows).
390    pub cache_path: Option<std::path::PathBuf>,
391    /// How often the background loop persists staged analysis-cache postings
392    /// (reference postings from an in-progress warm sweep or an on-demand
393    /// query freshness pass) to disk. Bounds data loss on an unclean exit
394    /// (crash, kill — anything that skips `shutdown`) to roughly one
395    /// interval. Defaults to 20 s; lower mainly useful in tests.
396    pub flush_interval_ms: u64,
397    /// External static-analysis tool integration (PHPStan / PHPCS). Both
398    /// default off; see [`ExternalToolsConfig`].
399    pub external_tools: ExternalToolsConfig,
400    /// File extensions (without the leading dot) the server treats as
401    /// PHP-relevant for file-operation registration and the
402    /// `workspace/didChangeWatchedFiles` watcher glob. Defaults to
403    /// `["php"]`; add `"phpt"` to also register rename/create/delete
404    /// notifications and the file watcher for `.phpt` test files. Renames of
405    /// files with an extension outside this list are not forwarded to the
406    /// server by a spec-compliant client and skip the rename-triggered
407    /// refresh-request fan-out in `handle_did_rename_files`.
408    pub indexed_extensions: Vec<String>,
409}
410
411impl Default for LspConfig {
412    fn default() -> Self {
413        LspConfig {
414            php_version: None,
415            exclude_paths: Vec::new(),
416            include_paths: Vec::new(),
417            stub_dirs: Vec::new(),
418            diagnostics: DiagnosticsConfig::default(),
419            features: FeaturesConfig::default(),
420            max_indexed_files: MAX_INDEXED_FILES,
421            index_vendor: true,
422            debug: false,
423            debounce_ms: 100,
424            warm_analysis: true,
425            cache_path: None,
426            flush_interval_ms: 20_000,
427            external_tools: ExternalToolsConfig::default(),
428            indexed_extensions: vec!["php".to_string()],
429        }
430    }
431}
432
433impl LspConfig {
434    pub fn php_file_extensions(&self) -> mir_analyzer::PhpFileExtensions {
435        mir_analyzer::PhpFileExtensions::new(&self.indexed_extensions)
436    }
437
438    /// Merge a `.php-lsp.json` value with editor `initializationOptions` /
439    /// `workspace/configuration`. Editor settings win per-key; `excludePaths`,
440    /// `includePaths`, and `stubDirs` arrays are **concatenated** (file entries
441    /// first, editor entries appended) rather than replaced, since exclusion
442    /// patterns and stub directories are both additive.
443    ///
444    /// Hot-reload of `.php-lsp.json` on file change is not supported; the file
445    /// is only read during `initialize` and `did_change_configuration`.
446    pub(crate) fn merge_project_configs(
447        file: Option<&serde_json::Value>,
448        editor: Option<&serde_json::Value>,
449    ) -> serde_json::Value {
450        let mut merged = file
451            .cloned()
452            .unwrap_or(serde_json::Value::Object(Default::default()));
453        let Some(editor_obj) = editor.and_then(|e| e.as_object()) else {
454            return merged;
455        };
456        let merged_obj = merged
457            .as_object_mut()
458            .expect("merged base is always an object");
459        for (key, val) in editor_obj {
460            // excludePaths/includePaths/stubDirs are concatenated rather than replaced.
461            if key == "excludePaths" || key == "includePaths" || key == "stubDirs" {
462                let file_arr = merged_obj
463                    .get(key)
464                    .and_then(|v| v.as_array())
465                    .cloned()
466                    .unwrap_or_default();
467                let editor_arr = val.as_array().cloned().unwrap_or_default();
468                merged_obj.insert(
469                    key.clone(),
470                    serde_json::Value::Array([file_arr, editor_arr].concat()),
471                );
472            } else {
473                merged_obj.insert(key.clone(), val.clone());
474            }
475        }
476        merged
477    }
478
479    pub(crate) fn from_value(v: &serde_json::Value) -> Self {
480        let mut cfg = LspConfig::default();
481        if let Some(ver) = v.get("phpVersion").and_then(|x| x.as_str()) {
482            if crate::lang::autoload::is_valid_php_version(ver) {
483                cfg.php_version = Some(ver.to_string());
484            } else {
485                // Invalid version: skip environment detection, use the latest stubs.
486                cfg.php_version = Some(crate::lang::autoload::PHP_8_5.to_string());
487            }
488        }
489        if let Some(arr) = v.get("excludePaths").and_then(|x| x.as_array()) {
490            cfg.exclude_paths = arr
491                .iter()
492                .filter_map(|x| x.as_str().map(str::to_string))
493                .collect();
494        }
495        if let Some(arr) = v.get("includePaths").and_then(|x| x.as_array()) {
496            cfg.include_paths = arr
497                .iter()
498                .filter_map(|x| x.as_str().map(str::to_string))
499                .collect();
500        }
501        if let Some(arr) = v.get("stubDirs").and_then(|x| x.as_array()) {
502            cfg.stub_dirs = arr
503                .iter()
504                .filter_map(|x| x.as_str().map(str::to_string))
505                .collect();
506        }
507        if let Some(diag_val) = v.get("diagnostics") {
508            cfg.diagnostics = DiagnosticsConfig::from_value(diag_val);
509        }
510        if let Some(feat_val) = v.get("features") {
511            cfg.features = FeaturesConfig::from_value(feat_val);
512        }
513        if let Some(n) = v.get("maxIndexedFiles").and_then(|x| x.as_u64()) {
514            cfg.max_indexed_files = n as usize;
515        }
516        if let Some(b) = v.get("indexVendor").and_then(|x| x.as_bool()) {
517            cfg.index_vendor = b;
518        }
519        if let Some(b) = v.get("debug").and_then(|x| x.as_bool()) {
520            cfg.debug = b;
521        }
522        if let Some(n) = v.get("debounceMs").and_then(|x| x.as_u64()) {
523            cfg.debounce_ms = n.max(1);
524        }
525        if let Some(b) = v.get("warmAnalysis").and_then(|x| x.as_bool()) {
526            cfg.warm_analysis = b;
527        }
528        if let Some(s) = v.get("cachePath").and_then(|x| x.as_str()) {
529            cfg.cache_path = Some(std::path::PathBuf::from(s));
530        }
531        if let Some(n) = v
532            .get("analysisCacheFlushIntervalMs")
533            .and_then(|x| x.as_u64())
534        {
535            cfg.flush_interval_ms = n.max(1);
536        }
537        if let Some(v) = v.get("externalTools") {
538            cfg.external_tools = ExternalToolsConfig::from_value(v);
539        }
540        if let Some(arr) = v.get("indexedExtensions").and_then(|x| x.as_array()) {
541            let exts: Vec<String> = arr
542                .iter()
543                .filter_map(|x| x.as_str())
544                .map(|s| s.trim_start_matches('.').to_string())
545                .filter(|s| !s.is_empty())
546                .collect();
547            if !exts.is_empty() {
548                cfg.indexed_extensions = exts;
549            }
550        }
551        cfg
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    /// Snapshot of every default value `LspConfig` (and its nested
560    /// `DiagnosticsConfig`/`FeaturesConfig`) ships with. A default flip like
561    /// `index_vendor: false -> true` (issue #246 fix) shows up here as a diff
562    /// instead of silently changing behavior for every user who never sets
563    /// the option explicitly — the snapshot forces a conscious
564    /// `UPDATE_EXPECT=1` step, which is exactly the point where the author
565    /// should also be checking every test/doc that assumed the old default.
566    #[test]
567    fn default_config_matches_expected_snapshot() {
568        expect_test::expect![[r#"
569            LspConfig {
570                php_version: None,
571                exclude_paths: [],
572                include_paths: [],
573                stub_dirs: [],
574                diagnostics: DiagnosticsConfig {
575                    enabled: true,
576                    undefined_variables: true,
577                    undefined_functions: true,
578                    undefined_classes: true,
579                    arity_errors: true,
580                    type_errors: true,
581                    deprecated_calls: true,
582                    duplicate_declarations: true,
583                    unused_symbols: false,
584                    missing_types: false,
585                    mixed_usage: false,
586                    disabled: [],
587                    exclude_paths: [],
588                },
589                features: FeaturesConfig {
590                    completion: true,
591                    hover: true,
592                    definition: true,
593                    declaration: true,
594                    references: true,
595                    document_symbols: true,
596                    workspace_symbols: true,
597                    rename: true,
598                    signature_help: true,
599                    inlay_hints: true,
600                    semantic_tokens: true,
601                    selection_range: true,
602                    call_hierarchy: true,
603                    document_highlight: true,
604                    implementation: true,
605                    code_action: true,
606                    type_definition: true,
607                    code_lens: true,
608                    formatting: true,
609                    range_formatting: true,
610                    on_type_formatting: true,
611                    document_link: true,
612                    linked_editing_range: true,
613                    inline_values: true,
614                },
615                max_indexed_files: 50000,
616                index_vendor: true,
617                debug: false,
618                debounce_ms: 100,
619                warm_analysis: true,
620                cache_path: None,
621                flush_interval_ms: 20000,
622                external_tools: ExternalToolsConfig {
623                    phpstan: PhpstanConfig {
624                        enabled: false,
625                        bin_path: "phpstan",
626                        config_path: None,
627                    },
628                    phpcs: PhpcsConfig {
629                        enabled: false,
630                        bin_path: "phpcs",
631                        standard: None,
632                    },
633                },
634                indexed_extensions: [
635                    "php",
636                ],
637            }"#]]
638        .assert_eq(&format!("{:#?}", LspConfig::default()));
639    }
640
641    /// Every key `LspConfig::from_value`/`DiagnosticsConfig::from_value`/
642    /// `FeaturesConfig::from_value` actually reads — scraped from this
643    /// file's own JSON-object-key lookups, so a newly wired-up option is
644    /// caught automatically, no hand-maintained list to fall out of sync —
645    /// must appear somewhere in `documentation/src/content/docs/configuration.md`.
646    /// This is the gap that let `indexVendor` ship real, tested, and
647    /// completely undocumented for months (issue #246): a user had no way
648    /// to discover the one setting that fixed their exact problem.
649    #[test]
650    fn every_config_key_is_documented() {
651        let source = include_str!("config.rs");
652        let docs = include_str!("../../documentation/src/content/docs/configuration.md");
653
654        let mut keys: Vec<&str> = Vec::new();
655        for marker in ["get(\"", "flag(\""] {
656            let mut rest = source;
657            while let Some(idx) = rest.find(marker) {
658                rest = &rest[idx + marker.len()..];
659                if let Some(end) = rest.find('"') {
660                    keys.push(&rest[..end]);
661                }
662            }
663        }
664        keys.sort_unstable();
665        keys.dedup();
666
667        let undocumented: Vec<&str> = keys
668            .into_iter()
669            .filter(|k| !docs.contains(&format!("`{k}`")))
670            .collect();
671        assert!(
672            undocumented.is_empty(),
673            "config keys read by LspConfig::from_value but missing from \
674             documentation/src/content/docs/configuration.md: {undocumented:?}"
675        );
676    }
677
678    #[test]
679    fn stub_dirs_parses_from_initialization_options() {
680        let v = serde_json::json!({"stubDirs": ["stubs", "/abs/other-stubs"]});
681        let cfg = LspConfig::from_value(&v);
682        assert_eq!(cfg.stub_dirs, vec!["stubs", "/abs/other-stubs"]);
683    }
684
685    #[test]
686    fn stub_dirs_absent_defaults_to_empty() {
687        let cfg = LspConfig::from_value(&serde_json::json!({}));
688        assert!(cfg.stub_dirs.is_empty());
689    }
690
691    /// `stubDirs` is additive like `excludePaths`/`includePaths`: a project's
692    /// `.php-lsp.json` and an editor's `initializationOptions` both listing
693    /// directories must combine, not have one replace the other.
694    #[test]
695    fn stub_dirs_concatenates_file_and_editor_config() {
696        let file = serde_json::json!({"stubDirs": ["project-stubs"]});
697        let editor = serde_json::json!({"stubDirs": ["personal-stubs"]});
698        let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
699        let cfg = LspConfig::from_value(&merged);
700        assert_eq!(cfg.stub_dirs, vec!["project-stubs", "personal-stubs"]);
701    }
702}