use std::process::ExitCode;
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
use crate::native::check::{self, CheckOptions, OutputFormat};
use crate::native::error::{self, Envelope};
const ABOUT: &str = "The SurrealQL language server, for editors";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const JSON_HELP: &str = "Emit machine-readable JSON on stdout";
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(disable_version_flag = true)]
#[command(args_conflicts_with_subcommands = true)]
pub struct Cli {
#[arg(long, short = 'v', short_alias = 'V', help = "Print version")]
pub version: bool,
#[arg(long, short = 'j', hide = true, requires = "version", help = JSON_HELP)]
pub json: bool,
#[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,
}
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
pub struct VersionReport {
pub arch: &'static str,
pub os: &'static str,
pub version: &'static str,
}
impl VersionReport {
pub fn current() -> Self {
Self {
arch: std::env::consts::ARCH,
os: std::env::consts::OS,
version: VERSION,
}
}
}
pub fn version_line() -> String {
let VersionReport { arch, os, version } = VersionReport::current();
format!("{version} for {os} on {arch}")
}
fn print_version(json: bool) {
if json {
let report = serde_json::to_string_pretty(&VersionReport::current())
.expect("the version report serializes");
println!("{report}");
} else {
println!("{}", version_line());
}
}
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);
}
let program = command
.get_bin_name()
.unwrap_or_else(|| command.get_name())
.to_owned();
let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
Self::parse_with(&mut command, argv.iter().cloned())
.unwrap_or_else(|err| parse_failure(&err, &command, &argv, &program))
}
fn parse_with(
command: &mut clap::Command,
args: impl IntoIterator<Item = impl Into<std::ffi::OsString> + Clone>,
) -> Result<Self, clap::Error> {
use clap::error::ErrorKind;
let cli = Self::from_arg_matches(&command.try_get_matches_from_mut(args)?)?;
if let Some(Command::Check(options)) = &cli.command {
let refusal = if options.stdin_filename.is_some() && !options.stdin {
Some((
ErrorKind::MissingRequiredArgument,
"`--stdin-filename` needs `--stdin`",
))
} else if options.json && options.format == Some(OutputFormat::Text) {
Some((
ErrorKind::ArgumentConflict,
"the argument '--json' cannot be used with '--format text'",
))
} else {
None
};
if let Some((kind, message)) = refusal {
let check = command
.find_subcommand_mut("check")
.expect("`check` is a subcommand");
return Err(check.error(kind, message));
}
}
Ok(cli)
}
}
fn parse_failure(
err: &clap::Error,
command: &clap::Command,
argv: &[std::ffi::OsString],
program: &str,
) -> ! {
if err.use_stderr() && error::asks_for_json(command, argv) {
let envelope = Envelope::usage(err, program);
envelope.eprint();
std::process::exit(i32::from(envelope.exit_code));
}
err.exit()
}
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}");
}));
let cli = Cli::from_env(name);
if cli.version {
print_version(cli.json);
return ExitCode::SUCCESS;
}
match cli.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::*;
fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
let argv = std::iter::once("surreal-lsp").chain(args.iter().copied());
Cli::parse_with(&mut 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(&mut 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.output_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.output_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 check_json_is_format_json() {
for flag in ["--json", "-j"] {
let options = check_options(&["a.surql", flag]);
assert_eq!(options.output_format(), OutputFormat::Json, "{flag}");
}
let options = check_options(&["a.surql", "--format", "json"]);
assert_eq!(options.output_format(), OutputFormat::Json);
let options = check_options(&["a.surql", "--format", "text"]);
assert_eq!(options.output_format(), OutputFormat::Text);
let err = parse(&["check", "a.surql", "--json", "--format", "text"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
assert_eq!(err.exit_code(), 2);
}
#[test]
fn check_json_with_format_json_is_json() {
for args in [
&["a.surql", "--json", "--format", "json"][..],
&["a.surql", "--format", "json", "-j"],
&["a.surql", "--format=json", "--json"],
] {
assert_eq!(
check_options(args).output_format(),
OutputFormat::Json,
"{args:?}"
);
}
}
#[test]
fn check_help_offers_json_and_hides_format() {
let err = parse(&["check", "--help"]).unwrap_err();
let help = err.to_string();
assert!(
help.contains("-j, --json") && help.contains(JSON_HELP),
"{help}"
);
assert!(!help.contains("--format"), "{help}");
}
#[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_eq!(
version_line(),
format!(
"{VERSION} for {} on {}",
std::env::consts::OS,
std::env::consts::ARCH
)
);
}
#[test]
fn the_version_flag_takes_json_in_either_order() {
for (args, json) in [
(&["--version"][..], false),
(&["-v"], false),
(&["-V"], false),
(&["--version", "--json"], true),
(&["--json", "--version"], true),
(&["-v", "-j"], true),
(&["-j", "-v"], true),
(&["-vj"], true),
(&["-V", "--json"], true),
] {
for parse in [parse, parse_launched] {
let cli = parse(args).expect("version");
assert!(cli.version, "{args:?}");
assert_eq!(cli.json, json, "{args:?}");
assert!(cli.command.is_none(), "{args:?}");
}
}
}
#[test]
fn a_top_level_json_needs_the_version_flag() {
for args in [&["--json"][..], &["-j"]] {
let err = parse(args).unwrap_err();
assert_eq!(
err.kind(),
clap::error::ErrorKind::MissingRequiredArgument,
"{args:?}"
);
assert_eq!(err.exit_code(), 2);
}
}
#[test]
fn version_is_not_a_command() {
let err = parse(&["version"]).unwrap_err();
assert_eq!(err.exit_code(), 2);
}
#[test]
fn help_lists_the_version_flag_only() {
let help = parse(&["--help"]).unwrap_err().to_string();
assert!(help.contains("-v, --version"), "{help}");
assert!(!help.contains("-V"), "{help}");
assert!(!help.contains("--json"), "{help}");
assert!(!help.contains(" version"), "{help}");
}
#[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);
}
}