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). New in mir 0.22; defaults to `false` so the
26    /// LSP doesn't add noisy warnings to existing workspaces without an
27    /// opt-in. Toggle via `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/// Maximum number of PHP files indexed during a workspace scan.
191/// Prevents excessive memory use on projects with very large vendor trees.
192pub const MAX_INDEXED_FILES: usize = 50_000;
193
194/// Configuration received from the client via `initializationOptions`.
195#[derive(Debug, Clone)]
196pub struct LspConfig {
197    /// PHP version string, e.g. `"8.1"`.  Set explicitly via `initializationOptions`
198    /// or auto-detected from `composer.json` / the `php` binary at startup.
199    pub php_version: Option<String>,
200    /// Glob patterns for paths to exclude from workspace indexing.
201    pub exclude_paths: Vec<String>,
202    /// Glob patterns for paths that must be indexed even if they match an
203    /// `excludePaths` entry.  Patterns are matched against path components
204    /// (same semantics as `excludePaths`).  Example: `["vendor/yiisoft"]`.
205    pub include_paths: Vec<String>,
206    /// Per-category diagnostic toggles.
207    pub diagnostics: DiagnosticsConfig,
208    /// Per-feature capability toggles.
209    pub features: FeaturesConfig,
210    /// Hard cap on the number of PHP files indexed during a workspace scan.
211    /// Defaults to [`MAX_INDEXED_FILES`]. Set lower via `initializationOptions`
212    /// to reduce memory on projects with very large vendor trees.
213    pub max_indexed_files: usize,
214    /// Whether to eagerly index `vendor/` during the workspace scan.
215    ///
216    /// Default `false`: `vendor/` is skipped on scan; vendor files load on
217    /// demand via PSR-4 resolution (composer autoload + per-file parse). This
218    /// keeps `$/php-lsp/indexReady` fast on real-world projects where vendor
219    /// dwarfs the workspace.
220    ///
221    /// Set `true` for full workspace-symbol coverage of vendor and find-
222    /// implementations / type-hierarchy against vendor types — at the cost of
223    /// a slower initial scan.
224    pub index_vendor: bool,
225    /// Emit extra diagnostic log messages on startup: cache hit/miss ratio,
226    /// workspace root paths, and PSR-4 namespace count.
227    /// Enable via `initializationOptions.debug = true`.
228    pub debug: bool,
229}
230
231impl Default for LspConfig {
232    fn default() -> Self {
233        LspConfig {
234            php_version: None,
235            exclude_paths: Vec::new(),
236            include_paths: Vec::new(),
237            diagnostics: DiagnosticsConfig::default(),
238            features: FeaturesConfig::default(),
239            max_indexed_files: MAX_INDEXED_FILES,
240            index_vendor: false,
241            debug: false,
242        }
243    }
244}
245
246impl LspConfig {
247    /// Merge a `.php-lsp.json` value with editor `initializationOptions` /
248    /// `workspace/configuration`. Editor settings win per-key; `excludePaths`
249    /// arrays are **concatenated** (file entries first, editor entries appended)
250    /// rather than replaced, since exclusion patterns are additive.
251    ///
252    /// Hot-reload of `.php-lsp.json` on file change is not supported; the file
253    /// is only read during `initialize` and `did_change_configuration`.
254    pub(crate) fn merge_project_configs(
255        file: Option<&serde_json::Value>,
256        editor: Option<&serde_json::Value>,
257    ) -> serde_json::Value {
258        let mut merged = file
259            .cloned()
260            .unwrap_or(serde_json::Value::Object(Default::default()));
261        let Some(editor_obj) = editor.and_then(|e| e.as_object()) else {
262            return merged;
263        };
264        let merged_obj = merged
265            .as_object_mut()
266            .expect("merged base is always an object");
267        for (key, val) in editor_obj {
268            // Both excludePaths and includePaths are concatenated rather than replaced.
269            if key == "excludePaths" || key == "includePaths" {
270                let file_arr = merged_obj
271                    .get(key)
272                    .and_then(|v| v.as_array())
273                    .cloned()
274                    .unwrap_or_default();
275                let editor_arr = val.as_array().cloned().unwrap_or_default();
276                merged_obj.insert(
277                    key.clone(),
278                    serde_json::Value::Array([file_arr, editor_arr].concat()),
279                );
280            } else {
281                merged_obj.insert(key.clone(), val.clone());
282            }
283        }
284        merged
285    }
286
287    pub(crate) fn from_value(v: &serde_json::Value) -> Self {
288        let mut cfg = LspConfig::default();
289        if let Some(ver) = v.get("phpVersion").and_then(|x| x.as_str()) {
290            if crate::lang::autoload::is_valid_php_version(ver) {
291                cfg.php_version = Some(ver.to_string());
292            } else {
293                // Invalid version: skip environment detection, use the latest stubs.
294                cfg.php_version = Some(crate::lang::autoload::PHP_8_5.to_string());
295            }
296        }
297        if let Some(arr) = v.get("excludePaths").and_then(|x| x.as_array()) {
298            cfg.exclude_paths = arr
299                .iter()
300                .filter_map(|x| x.as_str().map(str::to_string))
301                .collect();
302        }
303        if let Some(arr) = v.get("includePaths").and_then(|x| x.as_array()) {
304            cfg.include_paths = arr
305                .iter()
306                .filter_map(|x| x.as_str().map(str::to_string))
307                .collect();
308        }
309        if let Some(diag_val) = v.get("diagnostics") {
310            cfg.diagnostics = DiagnosticsConfig::from_value(diag_val);
311        }
312        if let Some(feat_val) = v.get("features") {
313            cfg.features = FeaturesConfig::from_value(feat_val);
314        }
315        if let Some(n) = v.get("maxIndexedFiles").and_then(|x| x.as_u64()) {
316            cfg.max_indexed_files = n as usize;
317        }
318        if let Some(b) = v.get("indexVendor").and_then(|x| x.as_bool()) {
319            cfg.index_vendor = b;
320        }
321        if let Some(b) = v.get("debug").and_then(|x| x.as_bool()) {
322            cfg.debug = b;
323        }
324        cfg
325    }
326}