1#![warn(missing_docs)]
2#![deny(unsafe_code)]
9
10use std::error::Error;
11use std::fmt;
12use std::io;
13use std::io::IsTerminal;
14use std::sync::{Once, OnceLock};
15
16use clap::{Args, Parser};
17pub mod timing;
18pub use perl_lsp_feature_governance::{
19 FeatureProfile, catalog_advertised_feature_ids, compliance_percent_for_profile,
20 to_json_for_profile, trackable_feature_count_for_grid,
21};
22use perl_lsp_feature_governance::{feature_profile_supported_tokens, parse_feature_profile_arg};
23pub use timing::{StartupReport, StartupTimer};
24use tracing_subscriber::prelude::*;
25use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
26
27static LOGGING_INIT: Once = Once::new();
28static LOG_FILE_GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = OnceLock::new();
30
31pub const DEFAULT_LSP_PORT: u16 = 9257;
33
34pub fn should_enable_logging(explicit_flag: bool) -> bool {
41 explicit_flag || logging_env_directive().is_some()
42}
43
44fn logging_env_directive() -> Option<String> {
45 std::env::var("PERL_LSP_LOG").ok().or_else(|| std::env::var("RUST_LOG").ok())
46}
47
48pub fn logging_filter(
54 explicit_flag: bool,
55 explicit_default_filter: &str,
56 implicit_default_filter: &str,
57) -> String {
58 logging_env_directive().unwrap_or_else(|| {
59 if explicit_flag {
60 explicit_default_filter.to_string()
61 } else {
62 implicit_default_filter.to_string()
63 }
64 })
65}
66
67pub fn init_logging(default_filter: &str) {
73 LOGGING_INIT.call_once(|| {
74 let filter = EnvFilter::try_from_default_env()
75 .or_else(|_| EnvFilter::try_new(default_filter))
76 .unwrap_or_else(|_| EnvFilter::new("info"));
77
78 let use_ansi = std::env::var("NO_COLOR").is_err() && io::stderr().is_terminal();
79
80 if let Ok(log_path) = std::env::var("PERL_LSP_LOG_FILE") {
82 let path = std::path::Path::new(&log_path);
83 let log_dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
84 let log_file_prefix = path.file_name().and_then(|f| f.to_str()).unwrap_or("perl-lsp");
85
86 if let Ok(file_appender) = tracing_appender::rolling::RollingFileAppender::builder()
87 .rotation(tracing_appender::rolling::Rotation::DAILY)
88 .filename_prefix(log_file_prefix)
89 .max_log_files(5)
90 .build(log_dir)
91 {
92 let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
93 let _ = LOG_FILE_GUARD.set(guard);
94
95 let stderr_layer = tracing_subscriber::fmt::layer()
96 .with_writer(io::stderr)
97 .with_ansi(use_ansi)
98 .with_target(true);
99
100 let file_layer = tracing_subscriber::fmt::layer()
101 .with_writer(non_blocking)
102 .with_ansi(false)
103 .with_target(true);
104
105 let _ = tracing_subscriber::registry()
106 .with(filter)
107 .with(stderr_layer)
108 .with(file_layer)
109 .try_init();
110
111 return;
112 }
113 }
115
116 let _ = tracing_fmt()
117 .with_env_filter(filter)
118 .with_writer(io::stderr)
119 .with_ansi(use_ansi)
120 .with_target(true)
121 .try_init();
122 });
123}
124
125pub fn log_server_startup(
131 server_name: &str,
132 version: &str,
133 transport: TransportMode,
134 feature_profile: Option<FeatureProfile>,
135 startup_report: Option<&StartupReport>,
136) {
137 tracing::info!(server = server_name, version, transport = transport.label(), "server starting");
138
139 if let Some(port) = transport.port() {
140 tracing::info!(server = server_name, port, "listening port configured");
141 }
142
143 if let Some(profile) = feature_profile {
144 let feature_count = catalog_advertised_feature_ids(profile).len();
145 tracing::info!(
146 server = server_name,
147 feature_profile = profile.as_str(),
148 features = feature_count,
149 "feature profile active"
150 );
151 }
152
153 if let Some(report) = startup_report {
154 report.log();
155 }
156}
157
158#[derive(Args, Debug, Clone)]
160pub struct TransportArgs {
161 #[arg(long, default_value_t = false, conflicts_with = "socket")]
163 pub stdio: bool,
164
165 #[arg(long, conflicts_with = "stdio")]
167 pub socket: bool,
168
169 #[arg(long)]
171 pub port: Option<u16>,
172}
173
174impl TransportArgs {
175 pub fn mode(&self) -> TransportMode {
177 if self.socket || self.port.is_some() {
178 TransportMode::Socket { port: self.port.unwrap_or(DEFAULT_LSP_PORT) }
179 } else {
180 TransportMode::Stdio
181 }
182 }
183}
184
185#[derive(Parser, Debug, Clone)]
187#[command(name = "perl-lsp", version, about = "Perl Language Server", long_about = None)]
188pub struct LspArgs {
189 #[command(flatten)]
191 pub transport: TransportArgs,
192
193 #[arg(long)]
195 pub log: bool,
196
197 #[arg(long)]
199 pub health: bool,
200
201 #[arg(long)]
203 pub info: bool,
204
205 #[arg(long)]
207 pub check: bool,
208
209 #[arg(long, conflicts_with = "check")]
211 pub check_project: Option<Option<String>>,
212
213 #[arg(long)]
215 pub completion: Option<String>,
216
217 #[arg(long)]
219 pub features_json: bool,
220
221 #[arg(long)]
223 pub feature_profile: Option<String>,
224
225 #[arg(trailing_var_arg = true, requires = "check")]
227 pub files: Vec<String>,
228}
229
230#[derive(Debug, Clone, Copy, Eq, PartialEq)]
232pub enum TransportMode {
233 Stdio,
235 Socket {
237 port: u16,
239 },
240}
241
242impl TransportMode {
243 pub const fn label(self) -> &'static str {
245 match self {
246 Self::Stdio => "stdio",
247 Self::Socket { .. } => "socket",
248 }
249 }
250
251 pub const fn port(self) -> Option<u16> {
253 match self {
254 Self::Stdio => None,
255 Self::Socket { port } => Some(port),
256 }
257 }
258
259 pub const fn is_socket(self) -> bool {
261 matches!(self, Self::Socket { .. })
262 }
263}
264
265#[derive(Debug, Clone, Eq, PartialEq)]
267pub enum LaunchAction {
268 Run,
270 Health,
272 Info,
274 Check,
276 CheckProject {
278 dir: String,
280 },
281 Completion {
283 shell: String,
285 },
286 Version,
288 FeaturesJson,
290 Help,
292}
293
294#[derive(Debug, Clone)]
296pub struct LaunchConfig {
297 pub transport: TransportMode,
299 pub enable_logging: bool,
301 pub feature_profile: FeatureProfile,
303}
304
305impl LaunchConfig {
306 pub const fn new(feature_profile: FeatureProfile) -> Self {
308 Self { transport: TransportMode::Stdio, enable_logging: false, feature_profile }
309 }
310
311 pub fn features_json(&self) -> String {
313 to_json_for_profile(self.feature_profile)
314 }
315
316 pub fn advertised_feature_ids(&self) -> Vec<&'static str> {
318 catalog_advertised_feature_ids(self.feature_profile)
319 }
320}
321
322#[derive(Debug, Clone)]
324pub struct LaunchPlan {
325 pub action: LaunchAction,
327 pub config: LaunchConfig,
329 pub files: Vec<String>,
331}
332
333#[derive(Debug, Clone)]
335pub enum LaunchParseError {
336 UnknownOption {
338 option: String,
340 },
341 MissingValue {
343 option: String,
345 },
346 InvalidFeatureProfile {
348 raw_profile: String,
350 },
351 InvalidPort {
353 raw_port: String,
355 reason: String,
357 },
358 InvalidShell {
360 raw_shell: String,
362 },
363}
364
365impl fmt::Display for LaunchParseError {
366 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367 match self {
368 Self::UnknownOption { option } => {
369 write!(f, "Unknown option: {option}")
370 }
371 Self::MissingValue { option } => {
372 write!(f, "Missing value for {option}")
373 }
374 Self::InvalidFeatureProfile { raw_profile } => {
375 let supported = feature_profile_supported_tokens().join(", ");
376 write!(f, "Invalid feature profile: {raw_profile}. Supported: {supported}",)
377 }
378 Self::InvalidPort { raw_port, reason } => {
379 write!(f, "Invalid port value: {raw_port}. {reason}")
380 }
381 Self::InvalidShell { raw_shell } => {
382 write!(f, "Unknown shell: {raw_shell}. Supported: bash, zsh, fish, powershell")
383 }
384 }
385 }
386}
387
388impl Error for LaunchParseError {}
389
390pub fn parse_args<I>(args: I) -> Result<LaunchPlan, LaunchParseError>
392where
393 I: IntoIterator,
394 I::Item: Into<std::ffi::OsString> + Clone,
395{
396 let collected_args: Vec<std::ffi::OsString> = args.into_iter().map(Into::into).collect();
397 prevalidate_cli_values(&collected_args)?;
398
399 match LspArgs::try_parse_from(collected_args) {
400 Ok(parsed_args) => {
401 let mut config = LaunchConfig::new(FeatureProfile::current());
402
403 config.transport = parsed_args.transport.mode();
404 config.enable_logging = parsed_args.log;
405
406 if let Some(raw_profile) = parsed_args.feature_profile {
407 config.feature_profile = parse_feature_profile(&raw_profile)?;
408 }
409
410 let action = if parsed_args.health {
411 LaunchAction::Health
412 } else if parsed_args.info {
413 LaunchAction::Info
414 } else if parsed_args.check {
415 LaunchAction::Check
416 } else if let Some(maybe_dir) = parsed_args.check_project {
417 let dir = maybe_dir.unwrap_or_else(|| ".".to_string());
418 LaunchAction::CheckProject { dir }
419 } else if let Some(shell) = parsed_args.completion {
420 LaunchAction::Completion { shell }
421 } else if parsed_args.features_json {
422 LaunchAction::FeaturesJson
423 } else {
424 LaunchAction::Run
425 };
426
427 Ok(LaunchPlan { action, config, files: parsed_args.files })
428 }
429 Err(err) => {
430 let is_help = err.kind() == clap::error::ErrorKind::DisplayHelp
431 || err.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand;
432 let is_version = err.kind() == clap::error::ErrorKind::DisplayVersion;
433
434 if is_help {
435 return Ok(LaunchPlan {
436 action: LaunchAction::Help,
437 config: LaunchConfig::new(FeatureProfile::current()),
438 files: Vec::new(),
439 });
440 } else if is_version {
441 return Ok(LaunchPlan {
442 action: LaunchAction::Version,
443 config: LaunchConfig::new(FeatureProfile::current()),
444 files: Vec::new(),
445 });
446 }
447
448 Err(LaunchParseError::UnknownOption { option: err.to_string() })
449 }
450 }
451}
452
453fn prevalidate_cli_values(args: &[std::ffi::OsString]) -> Result<(), LaunchParseError> {
454 let mut index = 1usize;
455
456 while index < args.len() {
457 let token = args[index].to_string_lossy();
458
459 if token == "--port" {
460 let next = args.get(index + 1).map(|value| value.to_string_lossy().to_string());
461 let Some(raw_port) = next else {
462 return Err(LaunchParseError::MissingValue { option: "--port".to_string() });
463 };
464
465 if raw_port.starts_with("--") {
466 return Err(LaunchParseError::MissingValue { option: "--port".to_string() });
467 }
468
469 raw_port.parse::<u16>().map_err(|reason| LaunchParseError::InvalidPort {
470 raw_port: raw_port.clone(),
471 reason: reason.to_string(),
472 })?;
473
474 index += 2;
475 continue;
476 }
477
478 if let Some(raw_port) = token.strip_prefix("--port=") {
479 if raw_port.is_empty() {
480 return Err(LaunchParseError::MissingValue { option: "--port".to_string() });
481 }
482
483 raw_port.parse::<u16>().map_err(|reason| LaunchParseError::InvalidPort {
484 raw_port: raw_port.to_string(),
485 reason: reason.to_string(),
486 })?;
487 }
488
489 if token == "--completion" {
490 let next = args.get(index + 1).map(|value| value.to_string_lossy().to_string());
491 let Some(raw_shell) = next else {
492 return Err(LaunchParseError::MissingValue { option: "--completion".to_string() });
493 };
494
495 if raw_shell.starts_with("--") {
496 return Err(LaunchParseError::MissingValue { option: "--completion".to_string() });
497 }
498
499 match raw_shell.as_str() {
500 "bash" | "zsh" | "fish" | "powershell" => {}
501 _ => {
502 return Err(LaunchParseError::InvalidShell { raw_shell });
503 }
504 }
505
506 index += 2;
507 continue;
508 }
509
510 if token == "--feature-profile" {
511 let next = args.get(index + 1).map(|value| value.to_string_lossy().to_string());
512 let Some(raw_profile) = next else {
513 return Err(LaunchParseError::MissingValue {
514 option: "--feature-profile".to_string(),
515 });
516 };
517
518 if raw_profile.starts_with("--") {
519 return Err(LaunchParseError::MissingValue {
520 option: "--feature-profile".to_string(),
521 });
522 }
523
524 index += 2;
525 continue;
526 }
527
528 if token == "--feature-profile=" {
529 return Err(LaunchParseError::MissingValue { option: "--feature-profile".to_string() });
530 }
531
532 index += 1;
533 }
534
535 Ok(())
536}
537
538pub fn help_text() -> String {
540 let supported_profiles = feature_profile_supported_tokens().join(", ");
541
542 let mut out = String::with_capacity(1024);
543 out.push_str("Perl Language Server\n");
544 out.push('\n');
545 out.push_str("Usage: perl-lsp [options]\n");
546 out.push_str(" perl-lsp --check <file.pl> [file2.pm ...]\n");
547 out.push_str(" perl-lsp --check-project [dir]\n");
548 out.push('\n');
549 out.push_str("Server options:\n");
550 out.push_str(" --stdio Use stdio for communication (default)\n");
551 out.push_str(" --socket Use TCP socket for communication\n");
552 out.push_str(&format!(
553 " --port <port> Port to listen on (default: {DEFAULT_LSP_PORT})\n"
554 ));
555 out.push_str(" --log Enable logging to stderr\n");
556 out.push_str(" --feature-profile <name>\n");
557 out.push_str(&format!(" Set feature profile ({supported_profiles})\n"));
558 out.push('\n');
559 out.push_str("Diagnostic options:\n");
560 out.push_str(" --health Quick health check (prints 'ok <version>')\n");
561 out.push_str(" --info Show version, features, and coverage info\n");
562 out.push_str(" --version Show version information\n");
563 out.push_str(" --features-json Output features catalog as JSON\n");
564 out.push('\n');
565 out.push_str("Tool options:\n");
566 out.push_str(" --check <files...> Validate Perl files and report parse errors\n");
567 out.push_str(" --check-project [dir] Scan project directory for parsability report\n");
568 out.push_str(" --completion <shell> Generate shell completions (bash, zsh, fish)\n");
569 out.push_str(" --help Show this help message\n");
570 out.push('\n');
571 out.push_str("Examples:\n");
572 out.push_str(" perl-lsp --stdio # stdio mode (default)\n");
573 out.push_str(" perl-lsp --stdio --log # with logging\n");
574 out.push_str(" perl-lsp --socket --port 9257 # TCP socket mode\n");
575 out.push_str(" perl-lsp --stdio --feature-profile=prod # production profile\n");
576 out.push_str(" perl-lsp --check lib/MyModule.pm # syntax check\n");
577 out.push_str(" perl-lsp --check-project lib/ # project scan\n");
578 out.push_str(" perl-lsp --info # server information\n");
579 out.push_str(" perl-lsp --completion bash >> ~/.bashrc # install completions\n");
580 out.push('\n');
581 out.push_str("Environment:\n");
582 out.push_str(" PERL_LSP_LOG=1 Enable logging (alternative to --log)\n");
583 out.push_str(" PERL_LSP_LOG_FILE=<path>\n");
584 out.push_str(" Also log to a daily-rotated file (max 5 files)\n");
585 out.push_str(" RUST_LOG=<filter> Set tracing filter (e.g. perl_lsp=debug)\n");
586 out.push_str(" NO_COLOR=1 Disable colored output\n");
587 out
588}
589
590pub fn shell_completion(shell: &str) -> Option<&'static str> {
594 match shell {
595 "bash" => Some(BASH_COMPLETION),
596 "zsh" => Some(ZSH_COMPLETION),
597 "fish" => Some(FISH_COMPLETION),
598 "powershell" => Some(POWERSHELL_COMPLETION),
599 _ => None,
600 }
601}
602
603const BASH_COMPLETION: &str = r#"_perl_lsp() {
604 local cur prev opts
605 COMPREPLY=()
606 cur="${COMP_WORDS[COMP_CWORD]}"
607 prev="${COMP_WORDS[COMP_CWORD-1]}"
608 opts="--stdio --socket --port --log --health --info --check --check-project --version --features-json --feature-profile --completion --help"
609
610 case "${prev}" in
611 --port)
612 return 0
613 ;;
614 --feature-profile)
615 COMPREPLY=( $(compgen -W "ga-lock ga prod production all auto" -- "${cur}") )
616 return 0
617 ;;
618 --completion)
619 COMPREPLY=( $(compgen -W "bash zsh fish powershell" -- "${cur}") )
620 return 0
621 ;;
622 --check)
623 COMPREPLY=( $(compgen -f -X '!*.pl' -- "${cur}") $(compgen -f -X '!*.pm' -- "${cur}") $(compgen -f -X '!*.t' -- "${cur}") $(compgen -d -- "${cur}") )
624 return 0
625 ;;
626 esac
627
628 if [[ "${cur}" == -* ]]; then
629 COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
630 return 0
631 fi
632}
633complete -F _perl_lsp perl-lsp
634"#;
635
636const ZSH_COMPLETION: &str = r#"#compdef perl-lsp
637
638_perl-lsp() {
639 _arguments \
640 '--stdio[Use stdio for communication (default)]' \
641 '--socket[Use TCP socket for communication]' \
642 '--port[Port to listen on]:port:' \
643 '--log[Enable logging to stderr]' \
644 '--health[Quick health check]' \
645 '--info[Show server info]' \
646 '--check[Validate Perl files]:file:_files -g "*.{pl,pm,t}"' \
647 '--check-project[Scan project directory for parsability report]:dir:_directories' \
648 '--version[Show version information]' \
649 '--features-json[Output features catalog as JSON]' \
650 '--feature-profile[Set feature profile]:profile:(ga-lock ga prod production all auto)' \
651 '--completion[Generate shell completions]:shell:(bash zsh fish powershell)' \
652 '--help[Show help message]' \
653 '*:file:_files -g "*.{pl,pm,t}"'
654}
655
656_perl-lsp "$@"
657"#;
658
659const FISH_COMPLETION: &str = r#"complete -c perl-lsp -l stdio -d 'Use stdio for communication (default)'
660complete -c perl-lsp -l socket -d 'Use TCP socket for communication'
661complete -c perl-lsp -l port -x -d 'Port to listen on'
662complete -c perl-lsp -l log -d 'Enable logging to stderr'
663complete -c perl-lsp -l health -d 'Quick health check'
664complete -c perl-lsp -l info -d 'Show server info'
665complete -c perl-lsp -l check -F -d 'Validate Perl files'
666complete -c perl-lsp -l check-project -d 'Scan project directory for parsability report'
667complete -c perl-lsp -l version -d 'Show version information'
668complete -c perl-lsp -l features-json -d 'Output features catalog as JSON'
669complete -c perl-lsp -l feature-profile -x -a 'ga-lock ga prod production all auto' -d 'Set feature profile'
670complete -c perl-lsp -l completion -x -a 'bash zsh fish powershell' -d 'Generate shell completions'
671complete -c perl-lsp -l help -d 'Show help message'
672"#;
673
674const POWERSHELL_COMPLETION: &str = r#"Register-ArgumentCompleter -Native -CommandName perl-lsp -ScriptBlock {
675 param($wordToComplete, $commandAst, $cursorPosition)
676
677 $options = @(
678 [CompletionResult]::new('--stdio', '--stdio', 'ParameterName', 'Use stdio for communication (default)')
679 [CompletionResult]::new('--socket', '--socket', 'ParameterName', 'Use TCP socket for communication')
680 [CompletionResult]::new('--port', '--port', 'ParameterName', 'Port to listen on')
681 [CompletionResult]::new('--log', '--log', 'ParameterName', 'Enable logging to stderr')
682 [CompletionResult]::new('--health', '--health', 'ParameterName', 'Quick health check')
683 [CompletionResult]::new('--info', '--info', 'ParameterName', 'Show server info')
684 [CompletionResult]::new('--check', '--check', 'ParameterName', 'Validate Perl files')
685 [CompletionResult]::new('--check-project', '--check-project', 'ParameterName', 'Scan project directory for parsability report')
686 [CompletionResult]::new('--version', '--version', 'ParameterName', 'Show version information')
687 [CompletionResult]::new('--features-json', '--features-json', 'ParameterName', 'Output features catalog as JSON')
688 [CompletionResult]::new('--feature-profile', '--feature-profile', 'ParameterName', 'Set feature profile')
689 [CompletionResult]::new('--completion', '--completion', 'ParameterName', 'Generate shell completions')
690 [CompletionResult]::new('--help', '--help', 'ParameterName', 'Show help message')
691 )
692
693 $elements = $commandAst.CommandElements
694 $prevWord = if ($elements.Count -ge 2) { $elements[$elements.Count - 2].Extent.Text } else { '' }
695
696 switch ($prevWord) {
697 '--completion' {
698 @('bash', 'zsh', 'fish', 'powershell') | Where-Object { $_ -like "$wordToComplete*" } |
699 ForEach-Object { [CompletionResult]::new($_, $_, 'ParameterValue', $_) }
700 return
701 }
702 '--feature-profile' {
703 @('ga-lock', 'ga', 'prod', 'production', 'all', 'auto') | Where-Object { $_ -like "$wordToComplete*" } |
704 ForEach-Object { [CompletionResult]::new($_, $_, 'ParameterValue', $_) }
705 return
706 }
707 }
708
709 $options | Where-Object { $_.CompletionText -like "$wordToComplete*" }
710}
711"#;
712
713pub fn format_health_output(version: &str, use_color: bool) -> String {
719 if use_color {
720 format!("\x1b[32;1mok\x1b[0m \x1b[1m{version}\x1b[0m")
721 } else {
722 format!("ok {version}")
723 }
724}
725
726pub fn format_info_output(
730 version: &str,
731 git_tag: &str,
732 exe_path: &str,
733 profile: FeatureProfile,
734 use_color: bool,
735) -> String {
736 let feature_count = catalog_advertised_feature_ids(profile).len();
737 let spec_total = trackable_feature_count_for_grid();
738 let coverage = compliance_percent_for_profile(profile);
739
740 let mut out = String::with_capacity(256);
741
742 if use_color {
743 out.push_str(&format!("\x1b[1mperl-lsp\x1b[0m {version}\n"));
744 } else {
745 out.push_str(&format!("perl-lsp {version}\n"));
746 }
747 out.push_str(&format!("Git tag: {git_tag}\n"));
748 out.push_str("Parser: perl-parser v3 (recursive descent)\n");
749 out.push_str(&format!("Profile: {}\n", profile.as_str()));
750 out.push_str(&format!("Features: {feature_count}/{feature_count} active (100%)\n"));
751 out.push_str(&format!("LSP spec coverage: {feature_count}/{spec_total} ({coverage:.0}%)\n"));
752 out.push_str(&format!("Executable: {exe_path}\n"));
753 out.push_str("\nTip: run with --log or set PERL_LSP_LOG=1 for diagnostics\n");
754
755 out
756}
757
758pub fn format_startup_banner(version: &str, profile: FeatureProfile, is_socket: bool) -> String {
765 let feature_count = catalog_advertised_feature_ids(profile).len();
766 let transport_hint = if is_socket { "socket" } else { "stdio" };
767 format!("perl-lsp v{version} starting ({transport_hint}, {feature_count} features)")
768}
769
770pub fn startup_banner(version: &str, profile: FeatureProfile, transport: TransportMode) {
776 if std::env::var("PERL_LSP_QUIET").is_ok() {
777 return;
778 }
779 eprintln!("{}", format_startup_banner(version, profile, transport.is_socket()));
780}
781
782pub fn port_in_use_message(port: u16) -> String {
784 let alt1 = port.wrapping_add(1);
785 let alt2 = port.wrapping_add(10);
786 format!(
787 "Port {port} is already in use. Another instance of perl-lsp may be running.\n\
788 Try a different port:\n\
789 \n\
790 \x20 perl-lsp --socket --port {alt1}\n\
791 \x20 perl-lsp --socket --port {alt2}\n\
792 \n\
793 Or stop the existing process using port {port}."
794 )
795}
796
797fn parse_feature_profile(raw_profile: &str) -> Result<FeatureProfile, LaunchParseError> {
798 parse_feature_profile_arg(raw_profile).map_err(|_| LaunchParseError::InvalidFeatureProfile {
799 raw_profile: raw_profile.to_string(),
800 })
801}
802
803#[cfg(test)]
804mod tests {
805 use super::{DEFAULT_LSP_PORT, LaunchAction, TransportMode, parse_args};
806 use perl_tdd_support::must;
807
808 #[test]
809 fn init_logging_does_not_panic_without_log_file() {
810 super::init_logging("warn");
813 }
814
815 #[test]
816 #[allow(unsafe_code)]
817 fn init_logging_does_not_panic_with_log_file() {
818 let dir = std::env::temp_dir().join("perl-lsp-test-log-rotation");
819 let _ = std::fs::create_dir_all(&dir);
820 let log_path = dir.join("test.log");
821
822 unsafe {
827 std::env::set_var("PERL_LSP_LOG_FILE", log_path.to_str().unwrap_or_default());
828 }
829 super::init_logging("debug");
830 unsafe {
832 std::env::remove_var("PERL_LSP_LOG_FILE");
833 }
834
835 let _ = std::fs::remove_dir_all(&dir);
837 }
838
839 #[test]
840 fn help_mentions_log_file_env_var() {
841 let text = super::help_text();
842 assert!(text.contains("PERL_LSP_LOG_FILE"));
843 }
844
845 #[test]
846 fn parse_defaults_to_stdio_with_current_profile() {
847 let plan = must(parse_args(["perl-lsp"]));
848
849 assert_eq!(plan.action, LaunchAction::Run);
850 assert_eq!(plan.config.transport, TransportMode::Stdio);
851 assert!(!plan.config.enable_logging);
852 assert_eq!(plan.config.feature_profile, super::FeatureProfile::current());
853 }
854
855 #[test]
856 fn parse_socket_and_port_options() {
857 let plan = must(parse_args(["perl-lsp", "--socket", "--port", "8123"]));
858 assert_eq!(plan.config.transport, TransportMode::Socket { port: 8123 });
859
860 let plan = must(parse_args(["perl-lsp", "--port", "8123", "--socket"]));
861 assert_eq!(plan.config.transport, TransportMode::Socket { port: 8123 });
862 }
863
864 #[test]
865 fn parse_port_implies_socket() {
866 let plan = must(parse_args(["perl-lsp", "--port", "8080"]));
867 assert_eq!(plan.config.transport, TransportMode::Socket { port: 8080 });
868 }
869
870 #[test]
871 fn parse_feature_profile_aliases() {
872 let plan = must(parse_args(["perl-lsp", "--feature-profile", "ga_lock"]));
873 assert_eq!(plan.config.feature_profile.as_str(), "ga-lock");
874
875 let plan = must(parse_args(["perl-lsp", "--feature-profile=all"]));
876 assert_eq!(plan.config.feature_profile.as_str(), "all");
877 }
878
879 #[test]
880 fn parse_help_is_terminal_action() {
881 let plan = must(parse_args(["perl-lsp", "--help"]));
882 assert_eq!(plan.action, LaunchAction::Help);
883 assert_eq!(plan.config.transport, TransportMode::Stdio);
884 }
885
886 #[test]
887 fn parse_features_json_has_transport_defaults() {
888 let plan = must(parse_args(["perl-lsp", "--features-json"]));
889 assert_eq!(plan.action, LaunchAction::FeaturesJson);
890 assert_eq!(plan.config.transport, TransportMode::Stdio);
891 }
892
893 #[test]
894 fn help_mentions_default_port() {
895 let text = super::help_text();
896 assert!(text.contains(&DEFAULT_LSP_PORT.to_string()));
897 }
898
899 #[test]
902 fn parse_info_flag_sets_info_action() {
903 let plan = must(parse_args(["perl-lsp", "--info"]));
904 assert_eq!(plan.action, LaunchAction::Info);
905 }
906
907 #[test]
910 fn parse_check_flag_sets_check_action() {
911 let plan = must(parse_args(["perl-lsp", "--check"]));
912 assert_eq!(plan.action, LaunchAction::Check);
913 }
914
915 #[test]
918 fn parse_completion_bash() {
919 let plan = must(parse_args(["perl-lsp", "--completion", "bash"]));
920 assert_eq!(plan.action, LaunchAction::Completion { shell: "bash".to_string() });
921 }
922
923 #[test]
924 fn parse_completion_zsh() {
925 let plan = must(parse_args(["perl-lsp", "--completion", "zsh"]));
926 assert_eq!(plan.action, LaunchAction::Completion { shell: "zsh".to_string() });
927 }
928
929 #[test]
930 fn parse_completion_fish() {
931 let plan = must(parse_args(["perl-lsp", "--completion", "fish"]));
932 assert_eq!(plan.action, LaunchAction::Completion { shell: "fish".to_string() });
933 }
934
935 #[test]
936 fn parse_completion_powershell() {
937 let plan = must(parse_args(["perl-lsp", "--completion", "powershell"]));
938 assert_eq!(plan.action, LaunchAction::Completion { shell: "powershell".to_string() });
939 }
940
941 #[test]
942 fn parse_completion_unknown_shell_errors() {
943 let result = parse_args(["perl-lsp", "--completion", "nushell"]);
944 assert!(result.is_err());
945 }
946
947 #[test]
948 fn parse_completion_missing_value_errors() {
949 let result = parse_args(["perl-lsp", "--completion"]);
950 assert!(result.is_err());
951 }
952
953 #[test]
956 fn shell_completion_bash_is_nonempty() {
957 assert!(super::shell_completion("bash").is_some());
958 }
959
960 #[test]
961 fn shell_completion_zsh_is_nonempty() {
962 assert!(super::shell_completion("zsh").is_some());
963 }
964
965 #[test]
966 fn shell_completion_fish_is_nonempty() {
967 assert!(super::shell_completion("fish").is_some());
968 }
969
970 #[test]
971 fn shell_completion_powershell_is_nonempty() {
972 assert!(super::shell_completion("powershell").is_some());
973 }
974
975 #[test]
976 fn shell_completion_unknown_is_none() {
977 assert!(super::shell_completion("nushell").is_none());
978 }
979
980 #[test]
983 fn health_output_plain_contains_ok_and_version() {
984 let out = super::format_health_output("0.10.0", false);
985 assert!(out.contains("ok"));
986 assert!(out.contains("0.10.0"));
987 assert!(!out.contains("\x1b["));
988 }
989
990 #[test]
991 fn health_output_colored_contains_ansi() {
992 let out = super::format_health_output("0.10.0", true);
993 assert!(out.contains("\x1b[32;1m"));
994 assert!(out.contains("ok"));
995 assert!(out.contains("0.10.0"));
996 }
997
998 #[test]
1001 fn info_output_contains_essential_fields() {
1002 let out = super::format_info_output(
1003 "0.10.0",
1004 "v0.10.0",
1005 "/usr/bin/perl-lsp",
1006 super::FeatureProfile::current(),
1007 false,
1008 );
1009 assert!(out.contains("0.10.0"));
1010 assert!(out.contains("perl-parser v3"));
1011 assert!(out.contains("active (100%)"));
1012 assert!(out.contains("LSP spec coverage:"));
1013 assert!(out.contains("/usr/bin/perl-lsp"));
1014 }
1015
1016 #[test]
1019 fn port_in_use_message_suggests_alternatives() {
1020 let msg = super::port_in_use_message(9257);
1021 assert!(msg.contains("9257"));
1022 assert!(msg.contains("9258"));
1023 assert!(msg.contains("9267"));
1024 assert!(msg.contains("already in use"));
1025 }
1026
1027 #[test]
1030 fn help_mentions_info_flag() {
1031 let text = super::help_text();
1032 assert!(text.contains("--info"));
1033 }
1034
1035 #[test]
1036 fn help_mentions_check_flag() {
1037 let text = super::help_text();
1038 assert!(text.contains("--check"));
1039 }
1040
1041 #[test]
1042 fn help_mentions_completion_flag() {
1043 let text = super::help_text();
1044 assert!(text.contains("--completion"));
1045 }
1046
1047 #[test]
1050 fn parse_check_project_no_dir_defaults_to_dot() {
1051 let plan = must(parse_args(["perl-lsp", "--check-project"]));
1052 assert_eq!(plan.action, LaunchAction::CheckProject { dir: ".".to_string() });
1053 }
1054
1055 #[test]
1056 fn parse_check_project_with_dir() {
1057 let plan = must(parse_args(["perl-lsp", "--check-project", "lib/"]));
1058 assert_eq!(plan.action, LaunchAction::CheckProject { dir: "lib/".to_string() });
1059 }
1060
1061 #[test]
1062 fn help_mentions_check_project_flag() {
1063 let text = super::help_text();
1064 assert!(text.contains("--check-project"));
1065 }
1066
1067 #[test]
1070 fn error_display_invalid_shell() {
1071 let err = super::LaunchParseError::InvalidShell { raw_shell: "tcsh".to_string() };
1072 let msg = format!("{err}");
1073 assert!(msg.contains("tcsh"));
1074 assert!(msg.contains("bash"));
1075 }
1076
1077 #[test]
1080 fn startup_banner_contains_version() {
1081 let out = super::format_startup_banner("0.12.0", super::FeatureProfile::current(), false);
1082 assert!(out.contains("perl-lsp"), "banner must contain 'perl-lsp'");
1083 assert!(out.contains("0.12.0"), "banner must contain version");
1084 assert!(out.contains("starting"), "banner must contain 'starting'");
1085 }
1086
1087 #[test]
1088 fn startup_banner_contains_feature_count() {
1089 let profile = super::FeatureProfile::current();
1090 let feature_count = super::catalog_advertised_feature_ids(profile).len();
1091 let out = super::format_startup_banner("0.12.0", profile, false);
1092 assert!(feature_count > 0, "feature count must be positive");
1093 assert!(
1094 out.contains(&feature_count.to_string()),
1095 "banner must contain feature count ({feature_count})"
1096 );
1097 }
1098
1099 #[test]
1100 fn startup_banner_stdio_transport_hint() {
1101 let out = super::format_startup_banner("0.12.0", super::FeatureProfile::current(), false);
1102 assert!(out.contains("stdio"), "banner must show transport hint 'stdio'");
1103 }
1104
1105 #[test]
1106 fn startup_banner_socket_transport_hint() {
1107 let out = super::format_startup_banner("0.12.0", super::FeatureProfile::current(), true);
1108 assert!(out.contains("socket"), "banner must show transport hint 'socket'");
1109 }
1110
1111 #[test]
1112 #[allow(unsafe_code)]
1113 fn startup_banner_suppressed_by_quiet_env() {
1114 let previous = std::env::var_os("PERL_LSP_QUIET");
1116
1117 unsafe {
1119 std::env::set_var("PERL_LSP_QUIET", "1");
1120 }
1121
1122 super::startup_banner(
1125 "0.12.0",
1126 super::FeatureProfile::current(),
1127 super::TransportMode::Stdio,
1128 );
1129
1130 match previous {
1132 Some(value) => unsafe { std::env::set_var("PERL_LSP_QUIET", value) },
1133 None => unsafe { std::env::remove_var("PERL_LSP_QUIET") },
1134 }
1135 }
1136
1137 #[test]
1138 fn startup_banner_socket_transport_derived_from_transport_mode() {
1139 let socket_banner = super::format_startup_banner(
1142 "0.12.0",
1143 super::FeatureProfile::current(),
1144 super::TransportMode::Socket { port: 9257 }.is_socket(),
1145 );
1146 assert!(
1147 socket_banner.contains("socket"),
1148 "socket transport must appear in banner: {socket_banner}"
1149 );
1150 assert!(
1151 !socket_banner.contains("stdio"),
1152 "socket banner must not show stdio: {socket_banner}"
1153 );
1154
1155 let stdio_banner = super::format_startup_banner(
1156 "0.12.0",
1157 super::FeatureProfile::current(),
1158 super::TransportMode::Stdio.is_socket(),
1159 );
1160 assert!(
1161 stdio_banner.contains("stdio"),
1162 "stdio transport must appear in banner: {stdio_banner}"
1163 );
1164 assert!(
1165 !stdio_banner.contains("socket"),
1166 "stdio banner must not show socket: {stdio_banner}"
1167 );
1168 }
1169}