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