1#[derive(Debug, Clone)]
7pub struct DiagnosticsConfig {
8 pub enabled: bool,
10 pub undefined_variables: bool,
12 pub undefined_functions: bool,
14 pub undefined_classes: bool,
16 pub arity_errors: bool,
18 pub type_errors: bool,
20 pub deprecated_calls: bool,
22 pub duplicate_declarations: bool,
24 pub unused_symbols: bool,
29 pub missing_types: bool,
33 pub mixed_usage: bool,
37 pub disabled: Vec<String>,
39 pub exclude_paths: Vec<String>,
42}
43
44impl Default for DiagnosticsConfig {
45 fn default() -> Self {
46 DiagnosticsConfig {
47 enabled: true,
48 undefined_variables: true,
49 undefined_functions: true,
50 undefined_classes: true,
51 arity_errors: true,
52 type_errors: true,
53 deprecated_calls: true,
54 duplicate_declarations: true,
55 unused_symbols: false,
56 missing_types: false,
57 mixed_usage: false,
58 disabled: Vec::new(),
59 exclude_paths: Vec::new(),
60 }
61 }
62}
63
64impl DiagnosticsConfig {
65 #[cfg(test)]
68 pub fn all_enabled() -> Self {
69 DiagnosticsConfig {
70 enabled: true,
71 ..DiagnosticsConfig::default()
72 }
73 }
74
75 pub(crate) fn from_value(v: &serde_json::Value) -> Self {
76 let mut cfg = DiagnosticsConfig::default();
77 let Some(obj) = v.as_object() else { return cfg };
78 let flag = |key: &str| obj.get(key).and_then(|x| x.as_bool()).unwrap_or(true);
79 cfg.enabled = obj.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true);
80 cfg.undefined_variables = flag("undefinedVariables");
81 cfg.undefined_functions = flag("undefinedFunctions");
82 cfg.undefined_classes = flag("undefinedClasses");
83 cfg.arity_errors = flag("arityErrors");
84 cfg.type_errors = flag("typeErrors");
85 cfg.deprecated_calls = flag("deprecatedCalls");
86 cfg.duplicate_declarations = flag("duplicateDeclarations");
87 cfg.unused_symbols = obj
88 .get("unusedSymbols")
89 .and_then(|x| x.as_bool())
90 .unwrap_or(false);
91 cfg.missing_types = obj
92 .get("missingTypes")
93 .and_then(|x| x.as_bool())
94 .unwrap_or(false);
95 cfg.mixed_usage = obj
96 .get("mixedUsage")
97 .and_then(|x| x.as_bool())
98 .unwrap_or(false);
99 let strings = |v: Option<&serde_json::Value>| -> Vec<String> {
100 v.and_then(|x| x.as_array())
101 .map(|arr| {
102 arr.iter()
103 .filter_map(|x| x.as_str().map(str::to_string))
104 .collect()
105 })
106 .unwrap_or_default()
107 };
108 cfg.disabled = strings(obj.get("disabled"));
109 cfg.exclude_paths = strings(obj.get("excludePaths"));
110 cfg
111 }
112}
113
114#[derive(Debug, Clone)]
117pub struct FeaturesConfig {
118 pub completion: bool,
119 pub hover: bool,
120 pub definition: bool,
121 pub declaration: bool,
122 pub references: bool,
123 pub document_symbols: bool,
124 pub workspace_symbols: bool,
125 pub rename: bool,
126 pub signature_help: bool,
127 pub inlay_hints: bool,
128 pub semantic_tokens: bool,
129 pub selection_range: bool,
130 pub call_hierarchy: bool,
131 pub document_highlight: bool,
132 pub implementation: bool,
133 pub code_action: bool,
134 pub type_definition: bool,
135 pub code_lens: bool,
136 pub formatting: bool,
137 pub range_formatting: bool,
138 pub on_type_formatting: bool,
139 pub document_link: bool,
140 pub linked_editing_range: bool,
141 pub inline_values: bool,
142}
143
144impl Default for FeaturesConfig {
145 fn default() -> Self {
146 FeaturesConfig {
147 completion: true,
148 hover: true,
149 definition: true,
150 declaration: true,
151 references: true,
152 document_symbols: true,
153 workspace_symbols: true,
154 rename: true,
155 signature_help: true,
156 inlay_hints: true,
157 semantic_tokens: true,
158 selection_range: true,
159 call_hierarchy: true,
160 document_highlight: true,
161 implementation: true,
162 code_action: true,
163 type_definition: true,
164 code_lens: true,
165 formatting: true,
166 range_formatting: true,
167 on_type_formatting: true,
168 document_link: true,
169 linked_editing_range: true,
170 inline_values: true,
171 }
172 }
173}
174
175impl FeaturesConfig {
176 pub(crate) fn from_value(v: &serde_json::Value) -> Self {
177 let mut cfg = FeaturesConfig::default();
178 let Some(obj) = v.as_object() else { return cfg };
179 let flag = |key: &str| obj.get(key).and_then(|x| x.as_bool()).unwrap_or(true);
180 cfg.completion = flag("completion");
181 cfg.hover = flag("hover");
182 cfg.definition = flag("definition");
183 cfg.declaration = flag("declaration");
184 cfg.references = flag("references");
185 cfg.document_symbols = flag("documentSymbols");
186 cfg.workspace_symbols = flag("workspaceSymbols");
187 cfg.rename = flag("rename");
188 cfg.signature_help = flag("signatureHelp");
189 cfg.inlay_hints = flag("inlayHints");
190 cfg.semantic_tokens = flag("semanticTokens");
191 cfg.selection_range = flag("selectionRange");
192 cfg.call_hierarchy = flag("callHierarchy");
193 cfg.document_highlight = flag("documentHighlight");
194 cfg.implementation = flag("implementation");
195 cfg.code_action = flag("codeAction");
196 cfg.type_definition = flag("typeDefinition");
197 cfg.code_lens = flag("codeLens");
198 cfg.formatting = flag("formatting");
199 cfg.range_formatting = flag("rangeFormatting");
200 cfg.on_type_formatting = flag("onTypeFormatting");
201 cfg.document_link = flag("documentLink");
202 cfg.linked_editing_range = flag("linkedEditingRange");
203 cfg.inline_values = flag("inlineValues");
204 cfg
205 }
206}
207
208#[derive(Debug, Clone)]
213pub struct PhpstanConfig {
214 pub enabled: bool,
215 pub bin_path: String,
218 pub config_path: Option<String>,
221}
222
223impl Default for PhpstanConfig {
224 fn default() -> Self {
225 PhpstanConfig {
226 enabled: false,
227 bin_path: "phpstan".to_string(),
228 config_path: None,
229 }
230 }
231}
232
233impl PhpstanConfig {
234 pub(crate) fn from_value(v: &serde_json::Value) -> Self {
235 let mut cfg = PhpstanConfig::default();
236 let Some(obj) = v.as_object() else { return cfg };
237 cfg.enabled = obj
238 .get("enabled")
239 .and_then(|x| x.as_bool())
240 .unwrap_or(false);
241 if let Some(s) = obj.get("binPath").and_then(|x| x.as_str()) {
242 cfg.bin_path = s.to_string();
243 }
244 if let Some(s) = obj.get("configPath").and_then(|x| x.as_str()) {
245 cfg.config_path = Some(s.to_string());
246 }
247 cfg
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct PhpcsConfig {
254 pub enabled: bool,
255 pub bin_path: String,
258 pub standard: Option<String>,
261}
262
263impl Default for PhpcsConfig {
264 fn default() -> Self {
265 PhpcsConfig {
266 enabled: false,
267 bin_path: "phpcs".to_string(),
268 standard: None,
269 }
270 }
271}
272
273impl PhpcsConfig {
274 pub(crate) fn from_value(v: &serde_json::Value) -> Self {
275 let mut cfg = PhpcsConfig::default();
276 let Some(obj) = v.as_object() else { return cfg };
277 cfg.enabled = obj
278 .get("enabled")
279 .and_then(|x| x.as_bool())
280 .unwrap_or(false);
281 if let Some(s) = obj.get("binPath").and_then(|x| x.as_str()) {
282 cfg.bin_path = s.to_string();
283 }
284 if let Some(s) = obj.get("standard").and_then(|x| x.as_str()) {
285 cfg.standard = Some(s.to_string());
286 }
287 cfg
288 }
289}
290
291#[derive(Debug, Clone, Default)]
295pub struct ExternalToolsConfig {
296 pub phpstan: PhpstanConfig,
297 pub phpcs: PhpcsConfig,
298}
299
300impl ExternalToolsConfig {
301 pub(crate) fn from_value(v: &serde_json::Value) -> Self {
302 let mut cfg = ExternalToolsConfig::default();
303 let Some(obj) = v.as_object() else { return cfg };
304 if let Some(v) = obj.get("phpstan") {
305 cfg.phpstan = PhpstanConfig::from_value(v);
306 }
307 if let Some(v) = obj.get("phpcs") {
308 cfg.phpcs = PhpcsConfig::from_value(v);
309 }
310 cfg
311 }
312}
313
314pub const MAX_INDEXED_FILES: usize = 50_000;
317
318#[derive(Debug, Clone)]
320pub struct LspConfig {
321 pub php_version: Option<String>,
324 pub exclude_paths: Vec<String>,
326 pub include_paths: Vec<String>,
330 pub stub_dirs: Vec<String>,
336 pub diagnostics: DiagnosticsConfig,
338 pub features: FeaturesConfig,
340 pub max_indexed_files: usize,
344 pub index_vendor: bool,
370 pub debug: bool,
374 pub debounce_ms: u64,
379 pub warm_analysis: bool,
385 pub cache_path: Option<std::path::PathBuf>,
391 pub flush_interval_ms: u64,
397 pub external_tools: ExternalToolsConfig,
400 pub indexed_extensions: Vec<String>,
409}
410
411impl Default for LspConfig {
412 fn default() -> Self {
413 LspConfig {
414 php_version: None,
415 exclude_paths: Vec::new(),
416 include_paths: Vec::new(),
417 stub_dirs: Vec::new(),
418 diagnostics: DiagnosticsConfig::default(),
419 features: FeaturesConfig::default(),
420 max_indexed_files: MAX_INDEXED_FILES,
421 index_vendor: true,
422 debug: false,
423 debounce_ms: 100,
424 warm_analysis: true,
425 cache_path: None,
426 flush_interval_ms: 20_000,
427 external_tools: ExternalToolsConfig::default(),
428 indexed_extensions: vec!["php".to_string()],
429 }
430 }
431}
432
433impl LspConfig {
434 pub fn php_file_extensions(&self) -> mir_analyzer::PhpFileExtensions {
435 mir_analyzer::PhpFileExtensions::new(&self.indexed_extensions)
436 }
437
438 pub(crate) fn merge_project_configs(
447 file: Option<&serde_json::Value>,
448 editor: Option<&serde_json::Value>,
449 ) -> serde_json::Value {
450 let mut merged = file
451 .cloned()
452 .unwrap_or(serde_json::Value::Object(Default::default()));
453 let Some(editor_obj) = editor.and_then(|e| e.as_object()) else {
454 return merged;
455 };
456 let merged_obj = merged
457 .as_object_mut()
458 .expect("merged base is always an object");
459 for (key, val) in editor_obj {
460 if key == "excludePaths" || key == "includePaths" || key == "stubDirs" {
462 let file_arr = merged_obj
463 .get(key)
464 .and_then(|v| v.as_array())
465 .cloned()
466 .unwrap_or_default();
467 let editor_arr = val.as_array().cloned().unwrap_or_default();
468 merged_obj.insert(
469 key.clone(),
470 serde_json::Value::Array([file_arr, editor_arr].concat()),
471 );
472 } else {
473 merged_obj.insert(key.clone(), val.clone());
474 }
475 }
476 merged
477 }
478
479 pub(crate) fn from_value(v: &serde_json::Value) -> Self {
480 let mut cfg = LspConfig::default();
481 if let Some(ver) = v.get("phpVersion").and_then(|x| x.as_str()) {
482 if crate::lang::autoload::is_valid_php_version(ver) {
483 cfg.php_version = Some(ver.to_string());
484 } else {
485 cfg.php_version = Some(crate::lang::autoload::PHP_8_5.to_string());
487 }
488 }
489 if let Some(arr) = v.get("excludePaths").and_then(|x| x.as_array()) {
490 cfg.exclude_paths = arr
491 .iter()
492 .filter_map(|x| x.as_str().map(str::to_string))
493 .collect();
494 }
495 if let Some(arr) = v.get("includePaths").and_then(|x| x.as_array()) {
496 cfg.include_paths = arr
497 .iter()
498 .filter_map(|x| x.as_str().map(str::to_string))
499 .collect();
500 }
501 if let Some(arr) = v.get("stubDirs").and_then(|x| x.as_array()) {
502 cfg.stub_dirs = arr
503 .iter()
504 .filter_map(|x| x.as_str().map(str::to_string))
505 .collect();
506 }
507 if let Some(diag_val) = v.get("diagnostics") {
508 cfg.diagnostics = DiagnosticsConfig::from_value(diag_val);
509 }
510 if let Some(feat_val) = v.get("features") {
511 cfg.features = FeaturesConfig::from_value(feat_val);
512 }
513 if let Some(n) = v.get("maxIndexedFiles").and_then(|x| x.as_u64()) {
514 cfg.max_indexed_files = n as usize;
515 }
516 if let Some(b) = v.get("indexVendor").and_then(|x| x.as_bool()) {
517 cfg.index_vendor = b;
518 }
519 if let Some(b) = v.get("debug").and_then(|x| x.as_bool()) {
520 cfg.debug = b;
521 }
522 if let Some(n) = v.get("debounceMs").and_then(|x| x.as_u64()) {
523 cfg.debounce_ms = n.max(1);
524 }
525 if let Some(b) = v.get("warmAnalysis").and_then(|x| x.as_bool()) {
526 cfg.warm_analysis = b;
527 }
528 if let Some(s) = v.get("cachePath").and_then(|x| x.as_str()) {
529 cfg.cache_path = Some(std::path::PathBuf::from(s));
530 }
531 if let Some(n) = v
532 .get("analysisCacheFlushIntervalMs")
533 .and_then(|x| x.as_u64())
534 {
535 cfg.flush_interval_ms = n.max(1);
536 }
537 if let Some(v) = v.get("externalTools") {
538 cfg.external_tools = ExternalToolsConfig::from_value(v);
539 }
540 if let Some(arr) = v.get("indexedExtensions").and_then(|x| x.as_array()) {
541 let exts: Vec<String> = arr
542 .iter()
543 .filter_map(|x| x.as_str())
544 .map(|s| s.trim_start_matches('.').to_string())
545 .filter(|s| !s.is_empty())
546 .collect();
547 if !exts.is_empty() {
548 cfg.indexed_extensions = exts;
549 }
550 }
551 cfg
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[test]
567 fn default_config_matches_expected_snapshot() {
568 expect_test::expect![[r#"
569 LspConfig {
570 php_version: None,
571 exclude_paths: [],
572 include_paths: [],
573 stub_dirs: [],
574 diagnostics: DiagnosticsConfig {
575 enabled: true,
576 undefined_variables: true,
577 undefined_functions: true,
578 undefined_classes: true,
579 arity_errors: true,
580 type_errors: true,
581 deprecated_calls: true,
582 duplicate_declarations: true,
583 unused_symbols: false,
584 missing_types: false,
585 mixed_usage: false,
586 disabled: [],
587 exclude_paths: [],
588 },
589 features: FeaturesConfig {
590 completion: true,
591 hover: true,
592 definition: true,
593 declaration: true,
594 references: true,
595 document_symbols: true,
596 workspace_symbols: true,
597 rename: true,
598 signature_help: true,
599 inlay_hints: true,
600 semantic_tokens: true,
601 selection_range: true,
602 call_hierarchy: true,
603 document_highlight: true,
604 implementation: true,
605 code_action: true,
606 type_definition: true,
607 code_lens: true,
608 formatting: true,
609 range_formatting: true,
610 on_type_formatting: true,
611 document_link: true,
612 linked_editing_range: true,
613 inline_values: true,
614 },
615 max_indexed_files: 50000,
616 index_vendor: true,
617 debug: false,
618 debounce_ms: 100,
619 warm_analysis: true,
620 cache_path: None,
621 flush_interval_ms: 20000,
622 external_tools: ExternalToolsConfig {
623 phpstan: PhpstanConfig {
624 enabled: false,
625 bin_path: "phpstan",
626 config_path: None,
627 },
628 phpcs: PhpcsConfig {
629 enabled: false,
630 bin_path: "phpcs",
631 standard: None,
632 },
633 },
634 indexed_extensions: [
635 "php",
636 ],
637 }"#]]
638 .assert_eq(&format!("{:#?}", LspConfig::default()));
639 }
640
641 #[test]
650 fn every_config_key_is_documented() {
651 let source = include_str!("config.rs");
652 let docs = include_str!("../../documentation/src/content/docs/configuration.md");
653
654 let mut keys: Vec<&str> = Vec::new();
655 for marker in ["get(\"", "flag(\""] {
656 let mut rest = source;
657 while let Some(idx) = rest.find(marker) {
658 rest = &rest[idx + marker.len()..];
659 if let Some(end) = rest.find('"') {
660 keys.push(&rest[..end]);
661 }
662 }
663 }
664 keys.sort_unstable();
665 keys.dedup();
666
667 let undocumented: Vec<&str> = keys
668 .into_iter()
669 .filter(|k| !docs.contains(&format!("`{k}`")))
670 .collect();
671 assert!(
672 undocumented.is_empty(),
673 "config keys read by LspConfig::from_value but missing from \
674 documentation/src/content/docs/configuration.md: {undocumented:?}"
675 );
676 }
677
678 #[test]
679 fn stub_dirs_parses_from_initialization_options() {
680 let v = serde_json::json!({"stubDirs": ["stubs", "/abs/other-stubs"]});
681 let cfg = LspConfig::from_value(&v);
682 assert_eq!(cfg.stub_dirs, vec!["stubs", "/abs/other-stubs"]);
683 }
684
685 #[test]
686 fn stub_dirs_absent_defaults_to_empty() {
687 let cfg = LspConfig::from_value(&serde_json::json!({}));
688 assert!(cfg.stub_dirs.is_empty());
689 }
690
691 #[test]
695 fn stub_dirs_concatenates_file_and_editor_config() {
696 let file = serde_json::json!({"stubDirs": ["project-stubs"]});
697 let editor = serde_json::json!({"stubDirs": ["personal-stubs"]});
698 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
699 let cfg = LspConfig::from_value(&merged);
700 assert_eq!(cfg.stub_dirs, vec!["project-stubs", "personal-stubs"]);
701 }
702}