use std::process::ExitCode;
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
use crate::native::check::{self, CheckOptions};
const ABOUT: &str = "The SurrealQL language server, for editors";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const LONG_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("SURREALQL_LS_BUILD"),
")"
);
const CHECK_EXIT_CODES: &str = "\
Exit codes:
0 ran to completion, nothing at or above --fail-on
1 ran to completion, diagnostics at or above --fail-on
2 usage error, unreadable input, or a skipped target file";
#[derive(Parser, Debug)]
#[command(name = "surreal-lsp", bin_name = "surreal-lsp")]
#[command(about = ABOUT, long_about = None)]
#[command(version = VERSION, long_version = LONG_VERSION)]
#[command(args_conflicts_with_subcommands = true)]
pub struct Cli {
#[arg(long, hide = true)]
pub stdio: bool,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Stdio(StdioOptions),
#[command(after_help = CHECK_EXIT_CODES)]
Check(CheckOptions),
}
#[derive(clap::Args, Debug)]
pub struct StdioOptions {
#[arg(long = "stdio", hide = true)]
pub stdio: bool,
}
impl Cli {
pub fn from_env(name: &'static str) -> Self {
let mut command = Self::command().name(name).bin_name(name);
if let Some(invoked) = invoked_as(std::env::var(INVOKED_AS_VAR).ok().as_deref()) {
command = command.bin_name(invoked);
}
if launched(std::env::var_os(LAUNCHER_VAR).as_deref()) {
command = launcher_flow(command);
}
Self::parse_with(command, std::env::args_os()).unwrap_or_else(|err| err.exit())
}
fn parse_with(
mut command: clap::Command,
args: impl IntoIterator<Item = impl Into<std::ffi::OsString> + Clone>,
) -> Result<Self, clap::Error> {
let cli = Self::from_arg_matches(&command.try_get_matches_from_mut(args)?)?;
if let Some(Command::Check(options)) = &cli.command
&& options.stdin_filename.is_some()
&& !options.stdin
{
let check = command
.find_subcommand_mut("check")
.expect("`check` is a subcommand");
return Err(check.error(
clap::error::ErrorKind::MissingRequiredArgument,
"`--stdin-filename` needs `--stdin`",
));
}
Ok(cli)
}
}
pub const INVOKED_AS_VAR: &str = "SURREAL_INVOKED_AS";
pub const LAUNCHER_VAR: &str = "SURREAL_CLI_LAUNCHER";
fn launched(raw: Option<&std::ffi::OsStr>) -> bool {
raw.is_some_and(|value| !value.is_empty())
}
fn launcher_flow(command: clap::Command) -> clap::Command {
let bin = command
.get_bin_name()
.unwrap_or_else(|| command.get_name())
.to_owned();
command
.arg_required_else_help(true)
.override_usage(format!("{bin} <COMMAND>"))
}
fn invoked_as(raw: Option<&str>) -> Option<String> {
let name = raw?.trim();
let plausible = !name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.'));
plausible.then(|| name.to_owned())
}
pub async fn run(name: &'static str) -> ExitCode {
std::panic::set_hook(Box::new(move |info| {
eprintln!("{name} panicked: {info}");
}));
match Cli::from_env(name).command {
None | Some(Command::Stdio(_)) => {
serve().await;
ExitCode::SUCCESS
}
Some(Command::Check(options)) => check::run(options).await,
}
}
async fn serve() {
use tower_lsp_server::{LspService, Server};
use crate::native::Backend;
let (service, socket) = LspService::new(Backend::new);
Server::new(tokio::io::stdin(), tokio::io::stdout(), socket)
.serve(service)
.await;
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::native::check::OutputFormat;
fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
let argv = std::iter::once("surreal-lsp").chain(args.iter().copied());
Cli::parse_with(Cli::command(), argv)
}
fn parse_launched(args: &[&str]) -> Result<Cli, clap::Error> {
let argv = std::iter::once("surreal-lsp").chain(args.iter().copied());
Cli::parse_with(launcher_flow(Cli::command()), argv)
}
fn check_options(args: &[&str]) -> CheckOptions {
let mut argv = vec!["check"];
argv.extend_from_slice(args);
match parse(&argv).expect("parse").command {
Some(Command::Check(options)) => options,
other => panic!("expected check, got {other:?}"),
}
}
#[test]
fn the_command_tree_is_well_formed() {
Cli::command().debug_assert();
}
#[test]
fn no_command_and_stdio_both_serve() {
assert!(parse(&[]).expect("bare").command.is_none());
let cli = parse(&["--stdio"]).expect("--stdio");
assert!(cli.stdio);
assert!(cli.command.is_none());
}
#[test]
fn the_stdio_command_serves() {
for parse in [parse, parse_launched] {
for args in [&["stdio"][..], &["stdio", "--stdio"]] {
let cli = parse(args).expect("stdio");
assert!(matches!(cli.command, Some(Command::Stdio(_))), "{args:?}");
}
}
}
#[test]
fn under_the_launcher_no_command_prints_help() {
let err = parse_launched(&[]).unwrap_err();
assert_eq!(
err.kind(),
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
);
let help = err.to_string();
assert!(help.contains("stdio"), "{help}");
assert!(help.contains("check"), "{help}");
assert!(help.contains("Usage: surreal-lsp <COMMAND>\n"), "{help}");
assert!(!help.contains("--stdio"), "{help}");
}
#[test]
fn under_the_launcher_the_legacy_flag_still_serves() {
let cli = parse_launched(&["--stdio"]).expect("--stdio");
assert!(cli.stdio);
assert!(cli.command.is_none());
}
#[test]
fn only_a_non_empty_launcher_variable_counts() {
use std::ffi::OsStr;
assert!(launched(Some(OsStr::new("/usr/local/bin/surreal"))));
assert!(!launched(Some(OsStr::new(""))));
assert!(!launched(None));
}
#[test]
fn an_unknown_argument_is_a_usage_error() {
let err = parse(&["--frobnicate"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
assert_eq!(err.exit_code(), 2);
let err = parse(&["serve"]).unwrap_err();
assert_eq!(err.exit_code(), 2);
}
#[test]
fn check_defaults_are_text_format_and_fail_on_error() {
let options = check_options(&["queries.surql"]);
assert_eq!(options.format, OutputFormat::Text);
assert_eq!(options.fail_on, 1);
assert_eq!(options.paths, vec![PathBuf::from("queries.surql")]);
}
#[test]
fn every_check_flag_parses() {
let options = check_options(&[
"--stdin",
"--stdin-filename",
"buffer.surql",
"--workspace",
"schema",
"--workspace",
"more",
"--format",
"json",
"--config",
"surql.json",
"--param",
"id",
"--param",
"limit",
"--fail-on",
"warning",
]);
assert!(options.stdin);
assert_eq!(options.stdin_filename, Some(PathBuf::from("buffer.surql")));
assert_eq!(
options.workspace_dirs,
vec![PathBuf::from("schema"), PathBuf::from("more")]
);
assert_eq!(options.format, OutputFormat::Json);
assert_eq!(options.config, Some(PathBuf::from("surql.json")));
assert_eq!(options.params, vec!["id", "limit"]);
assert_eq!(options.fail_on, 2);
}
#[test]
fn every_fail_on_severity_has_its_rank() {
for (word, rank) in [("error", 1), ("warning", 2), ("info", 3), ("hint", 4)] {
assert_eq!(check_options(&["a.surql", "--fail-on", word]).fail_on, rank);
}
}
#[test]
fn check_usage_errors_name_the_fault_and_exit_two() {
let fault = |args: &[&str]| {
let mut argv = vec!["check"];
argv.extend_from_slice(args);
let err = parse(&argv).unwrap_err();
assert_eq!(err.exit_code(), 2, "{args:?}");
err.to_string()
};
assert!(fault(&[]).contains("PATHS"));
assert!(fault(&["--stdin", "a.surql"]).contains("--stdin"));
assert!(fault(&["--stdin-filename", "a.surql", "a.surql"]).contains("--stdin"));
assert!(fault(&["--nope", "a.surql"]).contains("--nope"));
assert!(fault(&["--format"]).contains("--format"));
assert!(fault(&["--format", "yaml", "a.surql"]).contains("yaml"));
assert!(fault(&["--fail-on", "fatal", "a.surql"]).contains("fatal"));
}
#[test]
fn help_is_not_a_usage_error() {
for args in [
&["--help"][..],
&["-h"],
&["check", "--help"],
&["check", "-h"],
] {
let err = parse(args).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
assert_eq!(err.exit_code(), 0);
}
}
#[test]
fn the_version_line_leads_with_the_release() {
assert!(LONG_VERSION.starts_with(&format!("{VERSION} (")));
assert_eq!(LONG_VERSION, crate::core::server::build_version());
}
#[test]
fn a_launcher_name_is_used_only_when_it_is_plausible() {
assert_eq!(invoked_as(Some("surreal lsp")), Some("surreal lsp".into()));
assert_eq!(
invoked_as(Some(" surreal lsp ")),
Some("surreal lsp".into())
);
assert_eq!(invoked_as(None), None);
assert_eq!(invoked_as(Some("")), None);
assert_eq!(invoked_as(Some("surreal\nlsp")), None);
assert_eq!(invoked_as(Some("surreal \x1b[31mlsp")), None);
assert_eq!(invoked_as(Some(&"x".repeat(65))), None);
}
}