1#![warn(missing_docs)]
2use std::path::PathBuf;
8#[cfg(not(target_arch = "wasm32"))]
9use std::process::Command;
10
11#[derive(Debug, Clone)]
16pub struct ServerConfig {
17 pub inlay_hints_enabled: bool,
19 pub inlay_hints_parameter_hints: bool,
21 pub inlay_hints_type_hints: bool,
23 pub inlay_hints_chained_hints: bool,
25 pub inlay_hints_max_length: usize,
27
28 pub test_runner_enabled: bool,
30 pub test_runner_command: String,
32 pub test_runner_args: Vec<String>,
34 pub test_runner_timeout: u64,
36
37 pub telemetry_enabled: bool,
39
40 pub perlcritic_enabled: bool,
46
47 pub perlcritic_severity: u8,
52
53 pub perlcritic_profile: Option<String>,
58
59 pub ai_completion: AiCompletionConfig,
61}
62
63#[derive(Debug, Clone)]
69pub struct AiCompletionConfig {
70 pub enabled: bool,
72 pub provider: String,
74 pub endpoint: String,
76 pub model: String,
78 pub api_key_env: String,
80 pub timeout_ms: u64,
82 pub max_output_tokens: u32,
84 pub rate_limit_rps: f64,
86 pub max_inflight: u32,
88 pub fallback: bool,
90 pub streaming: AiStreamingConfig,
92}
93
94#[derive(Debug, Clone)]
96pub struct AiStreamingConfig {
97 pub enabled: bool,
99 pub update_debounce_ms: u64,
101}
102
103impl Default for AiCompletionConfig {
104 fn default() -> Self {
105 Self {
106 enabled: false,
107 provider: "openai_compat".to_string(),
108 endpoint: String::new(),
109 model: "gpt-4o-mini".to_string(),
110 api_key_env: "OPENAI_API_KEY".to_string(),
111 timeout_ms: 1800,
112 max_output_tokens: 64,
113 rate_limit_rps: 1.0,
114 max_inflight: 1,
115 fallback: true,
116 streaming: AiStreamingConfig::default(),
117 }
118 }
119}
120
121impl Default for AiStreamingConfig {
122 fn default() -> Self {
123 Self { enabled: true, update_debounce_ms: 60 }
124 }
125}
126
127impl Default for ServerConfig {
128 fn default() -> Self {
129 Self {
130 inlay_hints_enabled: true,
131 inlay_hints_parameter_hints: true,
132 inlay_hints_type_hints: true,
133 inlay_hints_chained_hints: false,
134 inlay_hints_max_length: 30,
135 test_runner_enabled: true,
136 test_runner_command: "perl".to_string(),
137 test_runner_args: vec![],
138 test_runner_timeout: 60000,
139 telemetry_enabled: false,
140 perlcritic_enabled: false,
141 perlcritic_severity: 3,
142 perlcritic_profile: None,
143 ai_completion: AiCompletionConfig::default(),
144 }
145 }
146}
147
148impl ServerConfig {
149 pub fn update_from_value(&mut self, settings: &serde_json::Value) {
151 if let Some(inlay) = settings.get("inlayHints") {
152 if let Some(enabled) = inlay.get("enabled").and_then(|v| v.as_bool()) {
153 self.inlay_hints_enabled = enabled;
154 }
155 if let Some(param) = inlay.get("parameterHints").and_then(|v| v.as_bool()) {
156 self.inlay_hints_parameter_hints = param;
157 }
158 if let Some(type_hints) = inlay.get("typeHints").and_then(|v| v.as_bool()) {
159 self.inlay_hints_type_hints = type_hints;
160 }
161 if let Some(chained) = inlay.get("chainedHints").and_then(|v| v.as_bool()) {
162 self.inlay_hints_chained_hints = chained;
163 }
164 if let Some(max_len) = inlay.get("maxLength").and_then(|v| v.as_u64()) {
165 self.inlay_hints_max_length = max_len as usize;
166 }
167 }
168
169 if let Some(test) = settings.get("testRunner") {
170 if let Some(enabled) = test.get("enabled").and_then(|v| v.as_bool()) {
171 self.test_runner_enabled = enabled;
172 }
173 if let Some(cmd) = test.get("command").and_then(|v| v.as_str()) {
174 self.test_runner_command = cmd.to_string();
175 }
176 if let Some(args) = test.get("args").and_then(|v| v.as_array()) {
177 self.test_runner_args =
178 args.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
179 }
180 if let Some(timeout) = test.get("timeout").and_then(|v| v.as_u64()) {
181 self.test_runner_timeout = timeout;
182 }
183 }
184
185 if let Some(telemetry) = settings.get("telemetry")
186 && let Some(enabled) = telemetry.get("enabled").and_then(|v| v.as_bool())
187 {
188 self.telemetry_enabled = enabled;
189 }
190
191 if let Some(critic) = settings.get("perlcritic") {
192 if let Some(enabled) = critic.get("enabled").and_then(|v| v.as_bool()) {
193 self.perlcritic_enabled = enabled;
194 }
195 if let Some(severity) = critic.get("severity").and_then(|v| v.as_u64()) {
196 self.perlcritic_severity = severity.clamp(1, 5) as u8;
197 }
198 if let Some(profile) = critic.get("profile").and_then(|v| v.as_str()) {
199 self.perlcritic_profile = Some(profile.to_string());
200 }
201 }
202
203 if let Some(ai) = settings.get("aiCompletion") {
204 if let Some(enabled) = ai.get("enabled").and_then(|v| v.as_bool()) {
205 self.ai_completion.enabled = enabled;
206 }
207 if let Some(provider) = ai.get("provider").and_then(|v| v.as_str()) {
208 self.ai_completion.provider = provider.to_string();
209 }
210 if let Some(endpoint) = ai.get("endpoint").and_then(|v| v.as_str()) {
211 self.ai_completion.endpoint = endpoint.to_string();
212 }
213 if let Some(model) = ai.get("model").and_then(|v| v.as_str()) {
214 self.ai_completion.model = model.to_string();
215 }
216 if let Some(key_env) = ai.get("apiKeyEnv").and_then(|v| v.as_str()) {
217 self.ai_completion.api_key_env = key_env.to_string();
218 }
219 if let Some(timeout) = ai.get("timeoutMs").and_then(|v| v.as_u64()) {
220 self.ai_completion.timeout_ms = timeout;
221 }
222 if let Some(tokens) = ai.get("maxOutputTokens").and_then(|v| v.as_u64()) {
223 self.ai_completion.max_output_tokens = tokens as u32;
224 }
225 if let Some(rps) = ai.get("rateLimitRps").and_then(|v| v.as_f64()) {
226 self.ai_completion.rate_limit_rps = rps;
227 }
228 if let Some(inflight) = ai.get("maxInflight").and_then(|v| v.as_u64()) {
229 self.ai_completion.max_inflight = inflight as u32;
230 }
231 if let Some(fallback) = ai.get("fallback").and_then(|v| v.as_bool()) {
232 self.ai_completion.fallback = fallback;
233 }
234 if let Some(streaming) = ai.get("streaming") {
235 if let Some(enabled) = streaming.get("enabled").and_then(|v| v.as_bool()) {
236 self.ai_completion.streaming.enabled = enabled;
237 }
238 if let Some(debounce) = streaming.get("updateDebounceMs").and_then(|v| v.as_u64()) {
239 self.ai_completion.streaming.update_debounce_ms = debounce;
240 }
241 }
242 }
243 }
244}
245
246#[derive(Debug, Clone)]
251pub struct WorkspaceConfig {
252 pub include_paths: Vec<String>,
255
256 pub use_system_inc: bool,
259
260 system_inc_cache: Option<Vec<PathBuf>>,
262
263 pub resolution_timeout_ms: u64,
266}
267
268impl Default for WorkspaceConfig {
269 fn default() -> Self {
270 Self {
271 include_paths: vec!["lib".to_string(), ".".to_string(), "local/lib/perl5".to_string()],
272 use_system_inc: false,
273 system_inc_cache: None,
274 resolution_timeout_ms: 50,
275 }
276 }
277}
278
279impl WorkspaceConfig {
280 pub fn update_from_value(&mut self, settings: &serde_json::Value) {
282 if let Some(workspace) = settings.get("workspace") {
283 if let Some(paths) = workspace.get("includePaths").and_then(|v| v.as_array()) {
284 self.include_paths =
285 paths.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
286 }
287 if let Some(use_inc) = workspace.get("useSystemInc").and_then(|v| v.as_bool()) {
288 if use_inc != self.use_system_inc {
289 self.system_inc_cache = None;
290 }
291 self.use_system_inc = use_inc;
292 }
293 if let Some(timeout) = workspace.get("resolutionTimeout").and_then(|v| v.as_u64()) {
294 self.resolution_timeout_ms = timeout;
295 }
296 }
297 }
298
299 pub fn get_system_inc(&mut self) -> &[PathBuf] {
301 if !self.use_system_inc {
302 return &[];
303 }
304
305 if self.system_inc_cache.is_none() {
306 self.system_inc_cache = Some(Self::fetch_perl_inc());
307 }
308
309 self.system_inc_cache.as_deref().unwrap_or(&[])
310 }
311
312 #[cfg(not(target_arch = "wasm32"))]
313 fn fetch_perl_inc() -> Vec<PathBuf> {
314 let output = Command::new("perl").args(["-e", "print join(\"\\n\", @INC)"]).output();
315
316 match output {
317 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
318 .lines()
319 .filter(|line| !line.is_empty() && *line != ".")
320 .map(PathBuf::from)
321 .collect(),
322 _ => Vec::new(),
323 }
324 }
325
326 #[cfg(target_arch = "wasm32")]
327 fn fetch_perl_inc() -> Vec<PathBuf> {
328 Vec::new()
329 }
330}
331
332#[non_exhaustive]
343#[derive(Debug, Clone, Default, serde::Deserialize)]
344#[serde(default)]
345pub struct ProjectConfig {
346 pub perl: ProjectPerlConfig,
348 pub diagnostics: ProjectDiagnosticsConfig,
350 pub features: ProjectFeaturesConfig,
352 pub ai_completion: ProjectAiCompletionConfig,
354}
355
356#[derive(Debug, Clone, Default, serde::Deserialize)]
358#[serde(default)]
359pub struct ProjectPerlConfig {
360 pub include_paths: Vec<String>,
362 pub version: Option<String>,
365}
366
367#[derive(Debug, Clone, Default, serde::Deserialize)]
369#[serde(default)]
370pub struct ProjectDiagnosticsConfig {
371 pub perlcritic: Option<bool>,
373 pub perlcritic_severity: Option<u8>,
375}
376
377#[derive(Debug, Clone, Default, serde::Deserialize)]
379#[serde(default)]
380pub struct ProjectFeaturesConfig {
381 pub inlay_hints: Option<bool>,
383}
384
385#[derive(Debug, Clone, Default, serde::Deserialize)]
387#[serde(default)]
388pub struct ProjectAiCompletionConfig {
389 pub enabled: Option<bool>,
391 pub provider: Option<String>,
393 pub endpoint: Option<String>,
395 pub model: Option<String>,
397 pub api_key_env: Option<String>,
399}
400
401pub fn load_project_config(
406 workspace_root: &std::path::Path,
407) -> Result<Option<ProjectConfig>, String> {
408 let path = workspace_root.join(".perl-lsp.toml");
409 match std::fs::read_to_string(&path) {
410 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
411 Err(e) => Err(format!("Failed to read .perl-lsp.toml: {}", e)),
412 Ok(content) => toml::from_str::<ProjectConfig>(&content)
413 .map(Some)
414 .map_err(|e| format!(".perl-lsp.toml parse error: {}", e)),
415 }
416}
417
418impl ProjectConfig {
419 pub fn apply_to_server_config(&self, config: &mut ServerConfig) {
424 if let Some(enabled) = self.diagnostics.perlcritic {
425 config.perlcritic_enabled = enabled;
426 }
427 if let Some(severity) = self.diagnostics.perlcritic_severity {
428 config.perlcritic_severity = severity.clamp(1, 5);
429 }
430 if let Some(hints) = self.features.inlay_hints {
431 config.inlay_hints_enabled = hints;
432 }
433 if let Some(enabled) = self.ai_completion.enabled {
434 config.ai_completion.enabled = enabled;
435 }
436 if let Some(ref provider) = self.ai_completion.provider {
437 config.ai_completion.provider = provider.clone();
438 }
439 if let Some(ref endpoint) = self.ai_completion.endpoint {
440 config.ai_completion.endpoint = endpoint.clone();
441 }
442 if let Some(ref model) = self.ai_completion.model {
443 config.ai_completion.model = model.clone();
444 }
445 if let Some(ref key_env) = self.ai_completion.api_key_env {
446 config.ai_completion.api_key_env = key_env.clone();
447 }
448 }
449
450 pub fn apply_to_workspace_config(&self, config: &mut WorkspaceConfig) {
455 if !self.perl.include_paths.is_empty() {
456 config.include_paths = self.perl.include_paths.clone();
457 }
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::{ServerConfig, WorkspaceConfig};
464 use serde_json::json;
465
466 #[test]
469 fn server_config_default_inlay_hints_enabled() {
470 let config = ServerConfig::default();
471 assert!(config.inlay_hints_enabled, "inlay hints enabled by default");
472 assert!(config.inlay_hints_parameter_hints, "parameter hints enabled by default");
473 assert!(config.inlay_hints_type_hints, "type hints enabled by default");
474 assert!(!config.inlay_hints_chained_hints, "chained hints disabled by default");
475 assert_eq!(config.inlay_hints_max_length, 30);
476 }
477
478 #[test]
479 fn server_config_default_test_runner() {
480 let config = ServerConfig::default();
481 assert!(config.test_runner_enabled, "test runner enabled by default");
482 assert_eq!(config.test_runner_command, "perl");
483 assert!(config.test_runner_args.is_empty(), "no default test runner args");
484 assert_eq!(config.test_runner_timeout, 60000);
485 }
486
487 #[test]
488 fn server_config_default_telemetry_disabled() {
489 let config = ServerConfig::default();
490 assert!(!config.telemetry_enabled, "telemetry disabled by default");
491 }
492
493 #[test]
494 fn server_config_default_perlcritic_disabled() {
495 let config = ServerConfig::default();
496 assert!(!config.perlcritic_enabled, "perlcritic disabled by default (opt-in)");
497 }
498
499 #[test]
500 fn server_config_perlcritic_enabled_via_update() {
501 let mut config = ServerConfig::default();
502 config.update_from_value(&json!({
503 "perlcritic": { "enabled": true }
504 }));
505 assert!(config.perlcritic_enabled);
506 }
507
508 #[test]
511 fn server_config_updates_selected_fields() {
512 let mut config = ServerConfig::default();
513 config.update_from_value(&json!({
514 "inlayHints": { "enabled": false, "maxLength": 42 },
515 "testRunner": { "enabled": false, "command": "prove", "args": ["-l"] },
516 "telemetry": { "enabled": true }
517 }));
518
519 assert!(!config.inlay_hints_enabled);
520 assert_eq!(config.inlay_hints_max_length, 42);
521 assert!(!config.test_runner_enabled);
522 assert_eq!(config.test_runner_command, "prove");
523 assert_eq!(config.test_runner_args, vec!["-l"]);
524 assert!(config.telemetry_enabled);
525 }
526
527 #[test]
528 fn server_config_partial_update_leaves_unspecified_fields_unchanged() {
529 let mut config = ServerConfig::default();
530 config.update_from_value(&json!({
532 "inlayHints": { "enabled": false }
533 }));
534 assert!(!config.inlay_hints_enabled, "updated field changes");
535 assert!(config.inlay_hints_parameter_hints, "unspecified field unchanged");
536 assert_eq!(config.inlay_hints_max_length, 30, "unspecified field unchanged");
537 assert_eq!(config.test_runner_command, "perl", "unrelated section unchanged");
538 }
539
540 #[test]
541 fn server_config_empty_update_leaves_all_defaults_unchanged() {
542 let mut config = ServerConfig::default();
543 config.update_from_value(&json!({}));
544 assert!(config.inlay_hints_enabled);
545 assert_eq!(config.test_runner_command, "perl");
546 assert!(!config.telemetry_enabled);
547 }
548
549 #[test]
550 fn server_config_test_runner_timeout_updated() {
551 let mut config = ServerConfig::default();
552 config.update_from_value(&json!({
553 "testRunner": { "timeout": 30000 }
554 }));
555 assert_eq!(config.test_runner_timeout, 30000);
556 }
557
558 #[test]
561 fn server_config_default_perlcritic_severity_is_three() {
562 let config = ServerConfig::default();
563 assert_eq!(config.perlcritic_severity, 3, "default severity should be 3 (Harsh)");
564 }
565
566 #[test]
567 fn server_config_default_perlcritic_profile_is_none() {
568 let config = ServerConfig::default();
569 assert!(config.perlcritic_profile.is_none(), "profile is None by default");
570 }
571
572 #[test]
573 fn server_config_perlcritic_severity_updated_via_settings() {
574 let mut config = ServerConfig::default();
575 config.update_from_value(&json!({ "perlcritic": { "severity": 1 } }));
576 assert_eq!(config.perlcritic_severity, 1);
577 }
578
579 #[test]
580 fn server_config_perlcritic_severity_clamped_to_five() {
581 let mut config = ServerConfig::default();
582 config.update_from_value(&json!({ "perlcritic": { "severity": 99 } }));
583 assert_eq!(config.perlcritic_severity, 5, "severity clamped to max 5");
584 }
585
586 #[test]
587 fn server_config_perlcritic_severity_clamped_to_one() {
588 let mut config = ServerConfig::default();
589 config.update_from_value(&json!({ "perlcritic": { "severity": 0 } }));
590 assert_eq!(config.perlcritic_severity, 1, "severity clamped to min 1");
591 }
592
593 #[test]
594 fn server_config_perlcritic_profile_updated_via_settings() {
595 let mut config = ServerConfig::default();
596 config.update_from_value(&json!({ "perlcritic": { "profile": "/path/to/.perlcriticrc" } }));
597 assert_eq!(config.perlcritic_profile, Some("/path/to/.perlcriticrc".to_string()));
598 }
599
600 #[test]
601 fn server_config_perlcritic_all_fields_together() {
602 let mut config = ServerConfig::default();
603 config.update_from_value(&json!({
604 "perlcritic": {
605 "enabled": true,
606 "severity": 2,
607 "profile": "/workspace/.perlcriticrc"
608 }
609 }));
610 assert!(config.perlcritic_enabled);
611 assert_eq!(config.perlcritic_severity, 2);
612 assert_eq!(config.perlcritic_profile, Some("/workspace/.perlcriticrc".to_string()));
613 }
614
615 #[test]
616 fn server_config_perlcritic_partial_update_preserves_other_fields() {
617 let mut config = ServerConfig::default();
618 config.update_from_value(&json!({ "perlcritic": { "enabled": true } }));
619 assert_eq!(config.perlcritic_severity, 3);
621 assert!(config.perlcritic_profile.is_none());
622 }
623
624 #[test]
627 fn workspace_config_defaults_include_common_paths() {
628 let config = WorkspaceConfig::default();
629 assert_eq!(config.include_paths, vec!["lib", ".", "local/lib/perl5"]);
630 assert!(!config.use_system_inc);
631 assert_eq!(config.resolution_timeout_ms, 50);
632 }
633
634 #[test]
637 fn workspace_config_updates_include_paths() {
638 let mut config = WorkspaceConfig::default();
639 config.update_from_value(&json!({
640 "workspace": { "includePaths": ["/custom/lib", "/other/lib"] }
641 }));
642 assert_eq!(config.include_paths, vec!["/custom/lib", "/other/lib"]);
643 }
644
645 #[test]
646 fn workspace_config_updates_resolution_timeout() {
647 let mut config = WorkspaceConfig::default();
648 config.update_from_value(&json!({
649 "workspace": { "resolutionTimeout": 100 }
650 }));
651 assert_eq!(config.resolution_timeout_ms, 100);
652 }
653
654 #[test]
655 fn workspace_config_empty_update_leaves_defaults() {
656 let mut config = WorkspaceConfig::default();
657 config.update_from_value(&json!({}));
658 assert_eq!(config.include_paths, vec!["lib", ".", "local/lib/perl5"]);
659 assert!(!config.use_system_inc);
660 }
661
662 #[test]
665 fn workspace_config_get_system_inc_returns_empty_when_disabled() {
666 let mut config = WorkspaceConfig::default();
667 let inc = config.get_system_inc();
669 assert!(inc.is_empty(), "system inc is empty when use_system_inc=false");
670 }
671
672 #[test]
675 fn server_config_default_ai_completion_disabled() {
676 let config = ServerConfig::default();
677 assert!(!config.ai_completion.enabled, "AI completion disabled by default");
678 assert_eq!(config.ai_completion.provider, "openai_compat");
679 assert!(config.ai_completion.endpoint.is_empty());
680 assert_eq!(config.ai_completion.timeout_ms, 1800);
681 assert_eq!(config.ai_completion.max_output_tokens, 64);
682 assert!(config.ai_completion.fallback);
683 assert!(config.ai_completion.streaming.enabled);
684 assert_eq!(config.ai_completion.streaming.update_debounce_ms, 60);
685 }
686
687 #[test]
688 fn server_config_ai_completion_updated_via_settings() {
689 let mut config = ServerConfig::default();
690 config.update_from_value(&json!({
691 "aiCompletion": {
692 "enabled": true,
693 "provider": "openai_compat",
694 "endpoint": "https://api.openai.com/v1/chat/completions",
695 "model": "gpt-4o",
696 "apiKeyEnv": "MY_KEY",
697 "timeoutMs": 3000,
698 "maxOutputTokens": 128,
699 "rateLimitRps": 2.0,
700 "maxInflight": 2,
701 "fallback": false,
702 "streaming": {
703 "enabled": false,
704 "updateDebounceMs": 100
705 }
706 }
707 }));
708 assert!(config.ai_completion.enabled);
709 assert_eq!(config.ai_completion.endpoint, "https://api.openai.com/v1/chat/completions");
710 assert_eq!(config.ai_completion.model, "gpt-4o");
711 assert_eq!(config.ai_completion.api_key_env, "MY_KEY");
712 assert_eq!(config.ai_completion.timeout_ms, 3000);
713 assert_eq!(config.ai_completion.max_output_tokens, 128);
714 assert!(!config.ai_completion.fallback);
715 assert!(!config.ai_completion.streaming.enabled);
716 assert_eq!(config.ai_completion.streaming.update_debounce_ms, 100);
717 }
718}