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}
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 #[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#[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#[derive(Debug, Clone)]
195pub struct PhpstanConfig {
196 pub enabled: bool,
197 pub bin_path: String,
200 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#[derive(Debug, Clone)]
235pub struct PhpcsConfig {
236 pub enabled: bool,
237 pub bin_path: String,
240 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#[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
296pub const MAX_INDEXED_FILES: usize = 50_000;
299
300#[derive(Debug, Clone)]
302pub struct LspConfig {
303 pub php_version: Option<String>,
306 pub exclude_paths: Vec<String>,
308 pub include_paths: Vec<String>,
312 pub stub_dirs: Vec<String>,
318 pub diagnostics: DiagnosticsConfig,
320 pub features: FeaturesConfig,
322 pub max_indexed_files: usize,
326 pub index_vendor: bool,
352 pub debug: bool,
356 pub debounce_ms: u64,
361 pub warm_analysis: bool,
367 pub cache_path: Option<std::path::PathBuf>,
373 pub flush_interval_ms: u64,
379 pub external_tools: ExternalToolsConfig,
382 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 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 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 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 #[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 #[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 #[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}