1#![cfg_attr(target_family = "wasm", allow(dead_code))]
3
4#[derive(Debug, Clone)]
10pub struct DiagnosticsConfig {
11 pub enabled: bool,
13 pub undefined_variables: bool,
15 pub undefined_functions: bool,
17 pub undefined_classes: bool,
19 pub arity_errors: bool,
21 pub type_errors: bool,
23 pub deprecated_calls: bool,
25 pub duplicate_declarations: bool,
27 pub unused_symbols: bool,
32 pub missing_types: bool,
36 pub mixed_usage: bool,
40 pub disabled: Vec<String>,
42 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 #[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#[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#[derive(Debug, Clone)]
216pub struct PhpstanConfig {
217 pub enabled: bool,
218 pub bin_path: String,
221 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#[derive(Debug, Clone)]
256pub struct PhpcsConfig {
257 pub enabled: bool,
258 pub bin_path: String,
261 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#[derive(Debug, Clone)]
298pub struct ExternalToolsConfig {
299 pub phpstan: PhpstanConfig,
300 pub phpcs: PhpcsConfig,
301 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
332pub const MAX_INDEXED_FILES: usize = 50_000;
335
336pub const EDITOR_ONLY_KEYS: &[&str] = &["externalTools", "cachePath"];
340
341#[derive(Debug, Clone)]
343pub struct LspConfig {
344 pub php_version: Option<String>,
347 pub exclude_paths: Vec<String>,
349 pub include_paths: Vec<String>,
353 pub stub_dirs: Vec<String>,
359 pub diagnostics: DiagnosticsConfig,
361 pub features: FeaturesConfig,
363 pub max_indexed_files: usize,
367 pub index_vendor: bool,
393 pub debug: bool,
397 pub debounce_ms: u64,
402 pub warm_analysis: bool,
408 pub cache_path: Option<std::path::PathBuf>,
414 pub flush_interval_ms: u64,
420 pub external_tools: ExternalToolsConfig,
423 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 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 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 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 #[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 #[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 #[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}