Skip to main content

php_lsp/lang/
config.rs

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