Skip to main content

perl_lsp_launcher/
lib.rs

1#![warn(missing_docs)]
2//! CLI and startup configuration primitives for the Perl LSP binary.
3//!
4//! This crate extracts the runtime launch decision surface into a dedicated crate so
5//! feature profiles, transport mode semantics, and BDD-grid interoperability stay in one
6//! place and remain stable across binaries.
7
8#![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();
28/// Keeps the non-blocking file writer alive for the process lifetime.
29static LOG_FILE_GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = OnceLock::new();
30
31/// Default port used by socket transport.
32pub const DEFAULT_LSP_PORT: u16 = 9257;
33
34/// Returns whether runtime logging should be enabled.
35///
36/// Logging activates when the CLI explicitly requests it or when
37/// `PERL_LSP_LOG`/`RUST_LOG` is already set in the environment, which keeps
38/// environment-driven tracing behavior consistent with the historical Perl LSP
39/// binary contract.
40pub 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
48/// Resolve the effective tracing filter for the current process.
49///
50/// Environment overrides take precedence; otherwise the returned filter uses
51/// `explicit_default_filter` when logging was requested explicitly and
52/// `implicit_default_filter` when logging is only enabled via default behavior.
53pub 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
67/// Initialize tracing once for the current process.
68///
69/// When `PERL_LSP_LOG_FILE` is set, logs are written to a daily-rotated file
70/// (max 5 files) **in addition to** stderr. Invalid `RUST_LOG` values fall
71/// back to `default_filter`.
72pub 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 PERL_LSP_LOG_FILE is set, add a rolling file appender alongside stderr.
81        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            // Fall through to stderr-only if file appender fails to build.
114        }
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
125/// Emit a consistent startup log line for server binaries.
126///
127/// When a `startup_report` is provided, phase-level timing is logged at `debug`
128/// level and the total startup time at `info` level, enabling profiling without
129/// adding noise to normal output.
130pub 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/// Transport options shared by server binaries.
159#[derive(Args, Debug, Clone)]
160pub struct TransportArgs {
161    /// Use stdio for communication (default)
162    #[arg(long, default_value_t = false, conflicts_with = "socket")]
163    pub stdio: bool,
164
165    /// Use TCP socket for communication
166    #[arg(long, conflicts_with = "stdio")]
167    pub socket: bool,
168
169    /// Port to listen on (for socket mode)
170    #[arg(long)]
171    pub port: Option<u16>,
172}
173
174impl TransportArgs {
175    /// Returns the resolved transport mode.
176    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/// Command line arguments for the Perl LSP binary.
186#[derive(Parser, Debug, Clone)]
187#[command(name = "perl-lsp", version, about = "Perl Language Server", long_about = None)]
188pub struct LspArgs {
189    /// Transport configuration (stdio or socket).
190    #[command(flatten)]
191    pub transport: TransportArgs,
192
193    /// Enable logging to stderr
194    #[arg(long)]
195    pub log: bool,
196
197    /// Quick health check (prints 'ok `<version>`')
198    #[arg(long)]
199    pub health: bool,
200
201    /// Show server info (version, features, coverage)
202    #[arg(long)]
203    pub info: bool,
204
205    /// Validate Perl files and report parse errors (batch mode)
206    #[arg(long)]
207    pub check: bool,
208
209    /// Scan a project directory and report parsability summary
210    #[arg(long, conflicts_with = "check")]
211    pub check_project: Option<Option<String>>,
212
213    /// Generate shell completions (bash, zsh, fish, powershell)
214    #[arg(long)]
215    pub completion: Option<String>,
216
217    /// Output features catalog as JSON
218    #[arg(long)]
219    pub features_json: bool,
220
221    /// Set feature profile
222    #[arg(long)]
223    pub feature_profile: Option<String>,
224
225    /// Files to check (used with --check)
226    #[arg(trailing_var_arg = true, requires = "check")]
227    pub files: Vec<String>,
228}
229
230/// How the server should connect to the editor or test client.
231#[derive(Debug, Clone, Copy, Eq, PartialEq)]
232pub enum TransportMode {
233    /// Use stdio transport (JSON-RPC over stdin/stdout).
234    Stdio,
235    /// Use TCP socket transport.
236    Socket {
237        /// TCP port to bind.
238        port: u16,
239    },
240}
241
242impl TransportMode {
243    /// Human-friendly label for logging.
244    pub const fn label(self) -> &'static str {
245        match self {
246            Self::Stdio => "stdio",
247            Self::Socket { .. } => "socket",
248        }
249    }
250
251    /// TCP port used by the transport, if any.
252    pub const fn port(self) -> Option<u16> {
253        match self {
254            Self::Stdio => None,
255            Self::Socket { port } => Some(port),
256        }
257    }
258
259    /// Returns true for TCP socket mode.
260    pub const fn is_socket(self) -> bool {
261        matches!(self, Self::Socket { .. })
262    }
263}
264
265/// Runtime action selected by CLI parsing.
266#[derive(Debug, Clone, Eq, PartialEq)]
267pub enum LaunchAction {
268    /// Start a running server.
269    Run,
270    /// Print quick health status.
271    Health,
272    /// Show server info (version, features, coverage).
273    Info,
274    /// Validate Perl files in batch mode.
275    Check,
276    /// Scan a project directory and report parsability summary.
277    CheckProject {
278        /// Directory to scan (defaults to ".").
279        dir: String,
280    },
281    /// Generate shell completions for a given shell.
282    Completion {
283        /// Target shell (bash, zsh, fish, powershell).
284        shell: String,
285    },
286    /// Print version information.
287    Version,
288    /// Print profile-scoped feature catalog JSON.
289    FeaturesJson,
290    /// Print CLI help output.
291    Help,
292}
293
294/// Canonical launch configuration consumed by the server runtime.
295#[derive(Debug, Clone)]
296pub struct LaunchConfig {
297    /// Transport used by the server.
298    pub transport: TransportMode,
299    /// Whether to emit startup logs.
300    pub enable_logging: bool,
301    /// Effective feature profile selected by CLI/default policy.
302    pub feature_profile: FeatureProfile,
303}
304
305impl LaunchConfig {
306    /// Create a default launch configuration for a given feature profile.
307    pub const fn new(feature_profile: FeatureProfile) -> Self {
308        Self { transport: TransportMode::Stdio, enable_logging: false, feature_profile }
309    }
310
311    /// JSON payload describing profile-scoped advertised feature grid entries.
312    pub fn features_json(&self) -> String {
313        to_json_for_profile(self.feature_profile)
314    }
315
316    /// Feature IDs advertised for this profile under current catalog policy.
317    pub fn advertised_feature_ids(&self) -> Vec<&'static str> {
318        catalog_advertised_feature_ids(self.feature_profile)
319    }
320}
321
322/// Fully resolved launch request.
323#[derive(Debug, Clone)]
324pub struct LaunchPlan {
325    /// Requested runtime action.
326    pub action: LaunchAction,
327    /// Config to use when action is [`LaunchAction::Run`].
328    pub config: LaunchConfig,
329    /// Trailing file paths (used for `--check` mode).
330    pub files: Vec<String>,
331}
332
333/// Parse-time errors emitted by the CLI parser.
334#[derive(Debug, Clone)]
335pub enum LaunchParseError {
336    /// Unknown CLI token.
337    UnknownOption {
338        /// Unknown token passed on CLI.
339        option: String,
340    },
341    /// A flag was missing its required value.
342    MissingValue {
343        /// Flag that needs a value.
344        option: String,
345    },
346    /// Invalid profile token.
347    InvalidFeatureProfile {
348        /// Raw profile token from CLI.
349        raw_profile: String,
350    },
351    /// Invalid TCP port value.
352    InvalidPort {
353        /// Raw port token from CLI.
354        raw_port: String,
355        /// Parse failure details.
356        reason: String,
357    },
358    /// Invalid shell name for completions.
359    InvalidShell {
360        /// Raw shell token from CLI.
361        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
390/// Parse command line arguments for the Perl LSP launcher.
391pub 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
538/// Human-readable CLI help text shared by CLI consumers.
539pub 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
590/// Generate shell completion script for the given shell name.
591///
592/// Returns `None` for unknown shell names.
593pub 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
713/// Format a colored health status line.
714///
715/// When `use_color` is true, "ok" is wrapped in ANSI green and the version
716/// is shown in bold. Callers should pass `use_color = true` only when stdout
717/// is a terminal (output goes to stdout, not stderr).
718pub 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
726/// Format the `--info` output block.
727///
728/// `version`, `git_tag`, `exe_path` are supplied by the binary crate.
729pub 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
758/// Format the one-line process-start banner written to stderr before the LSP handshake.
759///
760/// The `is_socket` parameter controls whether the transport hint reads "socket" or "stdio".
761/// Callers should pass `is_socket = true` when the server is started in TCP socket mode.
762///
763/// Suppressible at the call site via `startup_banner()` which checks `PERL_LSP_QUIET`.
764pub 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
770/// Emit the process-start banner to stderr.
771///
772/// Fires before the LSP handshake begins. Writes directly to stderr, not through
773/// tracing, so it is visible regardless of whether `--log` is active.
774/// Suppressed when `PERL_LSP_QUIET` is set in the environment.
775pub 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
782/// Produce a user-friendly message when the TCP port is already in use.
783pub 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        // init_logging is guarded by Once, so calling it multiple times is safe.
811        // This test verifies the stderr-only path does not panic.
812        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        // Set the env var for this test — init_logging is Once-guarded so the
823        // file path may not actually be used if another test already initialized,
824        // but this must not panic regardless.
825        // SAFETY: test-only, single-threaded access to this env var.
826        unsafe {
827            std::env::set_var("PERL_LSP_LOG_FILE", log_path.to_str().unwrap_or_default());
828        }
829        super::init_logging("debug");
830        // SAFETY: test-only cleanup.
831        unsafe {
832            std::env::remove_var("PERL_LSP_LOG_FILE");
833        }
834
835        // Cleanup
836        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    // ── --info flag ───────────────────────────────────────────────
900
901    #[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    // ── --check flag ──────────────────────────────────────────────
908
909    #[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    // ── --completion flag ─────────────────────────────────────────
916
917    #[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    // ── shell_completion function ─────────────────────────────────
954
955    #[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    // ── format_health_output ──────────────────────────────────────
981
982    #[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    // ── format_info_output ────────────────────────────────────────
999
1000    #[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    // ── port_in_use_message ───────────────────────────────────────
1017
1018    #[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    // ── help text new entries ─────────────────────────────────────
1028
1029    #[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    // -- --check-project flag -----------------------------------------
1048
1049    #[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    // ── InvalidShell error ────────────────────────────────────────
1068
1069    #[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    // ── format_startup_banner ─────────────────────────────────────
1078
1079    #[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        // Save previous value to avoid test pollution even if test panics.
1115        let previous = std::env::var_os("PERL_LSP_QUIET");
1116
1117        // SAFETY: test-only env var manipulation; previous value is restored after test.
1118        unsafe {
1119            std::env::set_var("PERL_LSP_QUIET", "1");
1120        }
1121
1122        // startup_banner must not panic when PERL_LSP_QUIET is set.
1123        // The transport argument must propagate through without crashing.
1124        super::startup_banner(
1125            "0.12.0",
1126            super::FeatureProfile::current(),
1127            super::TransportMode::Stdio,
1128        );
1129
1130        // SAFETY: restore previous value.
1131        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        // Verify that format_startup_banner reads the is_socket flag, not an env var.
1140        // Socket transport must show "socket"; stdio must show "stdio".
1141        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}