use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use clap::builder::{PossibleValuesParser, TypedValueParser};
use clap::{Args, ValueEnum};
use ls_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Uri};
use serde::Serialize;
use walkdir::WalkDir;
use crate::config::ServerSettings;
use crate::core::client::WorkspaceLoader;
use crate::core::server::build_version;
use crate::native::error::{self, Envelope};
use crate::native::workspace_fs::{
FilesystemWorkspaceLoader, MAX_FILE_SIZE_BYTES, MAX_WORKSPACE_FILES, should_descend,
};
use crate::semantic::analyzer::analyze_document_with_limit;
use crate::semantic::types::{
DocumentAnalysis, LiveMetadataSnapshot, MergedSemanticModel, SymbolOrigin,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
Text,
Json,
}
#[derive(Debug, PartialEq, Eq, Args)]
pub struct CheckOptions {
#[arg(value_name = "PATHS", required_unless_present = "stdin")]
pub paths: Vec<PathBuf>,
#[arg(long, conflicts_with = "paths")]
pub stdin: bool,
#[arg(long, value_name = "PATH")]
pub stdin_filename: Option<PathBuf>,
#[arg(long = "workspace", value_name = "DIR")]
pub workspace_dirs: Vec<PathBuf>,
#[arg(long, short = 'j', help = crate::native::cli::JSON_HELP)]
pub json: bool,
#[arg(long, value_enum, hide = true)]
pub format: Option<OutputFormat>,
#[arg(long, value_name = "FILE")]
pub config: Option<PathBuf>,
#[arg(long = "param", value_name = "NAME")]
pub params: Vec<String>,
#[arg(
long,
value_name = "SEVERITY",
default_value = "error",
value_parser = PossibleValuesParser::new(["error", "warning", "info", "hint"])
.map(|severity| fail_on_rank(&severity)),
)]
pub fail_on: u8,
}
impl CheckOptions {
pub fn output_format(&self) -> OutputFormat {
if self.json {
OutputFormat::Json
} else {
self.format.unwrap_or(OutputFormat::Text)
}
}
}
fn fail_on_rank(severity: &str) -> u8 {
match severity {
"warning" => 2,
"info" => 3,
"hint" => 4,
_ => 1,
}
}
#[derive(Debug, Serialize)]
pub struct FileReport {
pub path: String,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Summary {
pub files_checked: usize,
pub errors: usize,
pub warnings: usize,
pub information: usize,
pub hints: usize,
}
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScanReport {
pub walk_errors: usize,
pub skipped_oversize: usize,
pub skipped_unreadable: usize,
pub file_cap_hit: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckReport {
pub version: String,
pub files: Vec<FileReport>,
pub summary: Summary,
pub scan: ScanReport,
pub config_warnings: Vec<String>,
pub exit_code: u8,
}
pub fn render_json(report: &CheckReport) -> String {
let mut out = serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string());
out.push('\n');
out
}
pub fn render_text(report: &CheckReport) -> String {
let mut out = String::new();
for file in &report.files {
for diagnostic in &file.diagnostics {
let line = diagnostic.range.start.line + 1;
let column = diagnostic.range.start.character + 1;
let severity = severity_word(severity_rank(diagnostic));
let code = match &diagnostic.code {
Some(NumberOrString::String(code)) => code.clone(),
Some(NumberOrString::Number(code)) => code.to_string(),
None => String::new(),
};
out.push_str(&format!(
"{}:{line}:{column} {severity}[{code}]: {}\n",
file.path, diagnostic.message
));
}
}
let mut tallies = vec![
plural(report.summary.errors, "error"),
plural(report.summary.warnings, "warning"),
];
if report.summary.information > 0 {
tallies.push(format!("{} information", report.summary.information));
}
if report.summary.hints > 0 {
tallies.push(plural(report.summary.hints, "hint"));
}
out.push_str(&format!(
"{} in {}\n",
tallies.join(", "),
plural(report.summary.files_checked, "file")
));
out
}
fn plural(count: usize, noun: &str) -> String {
if count == 1 {
format!("1 {noun}")
} else {
format!("{count} {noun}s")
}
}
fn severity_rank(diagnostic: &Diagnostic) -> u8 {
match diagnostic.severity {
Some(DiagnosticSeverity::WARNING) => 2,
Some(DiagnosticSeverity::INFORMATION) => 3,
Some(DiagnosticSeverity::HINT) => 4,
_ => 1,
}
}
fn severity_word(rank: u8) -> &'static str {
match rank {
1 => "error",
2 => "warning",
3 => "information",
_ => "hint",
}
}
struct Target {
uri: Uri,
display: String,
text: String,
}
struct Targets {
targets: Vec<Target>,
scan: ScanReport,
target_skipped: bool,
}
pub async fn run(options: CheckOptions) -> ExitCode {
let config_value = match &options.config {
Some(path) => match fs::read_to_string(path) {
Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
Ok(value) => Some(value),
Err(error) => {
let message = format!("`{}` is not valid JSON: {error}", path.display());
return cannot_check(&options, Failure::new(error::INVALID, message));
}
},
Err(error) => return cannot_check(&options, Failure::unreadable(path, &error)),
},
None => None,
};
let (mut settings, config_warnings) =
ServerSettings::from_sources_with_warnings(config_value.as_ref(), None);
settings
.analysis
.external_params
.extend(options.params.iter().cloned());
let mut index = FilesystemWorkspaceLoader::new()
.load(&options.workspace_dirs)
.await;
let context_scan = index.scan_stats;
let collected = match collect_targets(&options) {
Ok(collected) => collected,
Err(failure) => return cannot_check(&options, failure),
};
let mut analyses: Vec<(String, Arc<DocumentAnalysis>)> = Vec::new();
for target in collected.targets {
let Some(analysis) = analyze_document_with_limit(
target.uri.clone(),
target.text,
SymbolOrigin::Local,
settings.analysis.max_syntax_diagnostics,
) else {
let message = format!("cannot analyze `{}`", target.display);
return cannot_check(&options, Failure::new(error::UNKNOWN, message));
};
let analysis = Arc::new(analysis);
index.documents.insert(target.uri, Arc::clone(&analysis));
analyses.push((target.display, analysis));
}
let model = MergedSemanticModel::build(&index, &LiveMetadataSnapshot::default());
analyses.sort_by(|a, b| a.0.cmp(&b.0));
let mut summary = Summary {
files_checked: analyses.len(),
..Summary::default()
};
let mut files = Vec::with_capacity(analyses.len());
let mut worst_rank: u8 = u8::MAX;
for (display, analysis) in analyses {
let mut diagnostics = model.document_diagnostics(&analysis, &settings);
diagnostics.sort_by(|a, b| {
let key = |d: &Diagnostic| {
(
d.range.start.line,
d.range.start.character,
code_of(d),
d.message.clone(),
)
};
key(a).cmp(&key(b))
});
for diagnostic in &diagnostics {
let rank = severity_rank(diagnostic);
worst_rank = worst_rank.min(rank);
match rank {
1 => summary.errors += 1,
2 => summary.warnings += 1,
3 => summary.information += 1,
_ => summary.hints += 1,
}
}
files.push(FileReport {
path: display,
diagnostics,
});
}
let exit_code = if collected.target_skipped {
2
} else if worst_rank <= options.fail_on {
1
} else {
0
};
let report = CheckReport {
version: build_version(),
files,
summary,
scan: ScanReport {
walk_errors: collected.scan.walk_errors + context_scan.walk_errors,
skipped_oversize: collected.scan.skipped_oversize + context_scan.skipped_oversize,
skipped_unreadable: collected.scan.skipped_unreadable + context_scan.skipped_unreadable,
file_cap_hit: collected.scan.file_cap_hit || context_scan.file_cap_hit,
},
config_warnings,
exit_code,
};
for warning in &report.config_warnings {
eprintln!("warning: {warning}");
}
report_scan_losses(&report.scan, collected.target_skipped);
match options.output_format() {
OutputFormat::Text => print!("{}", render_text(&report)),
OutputFormat::Json => print!("{}", render_json(&report)),
}
ExitCode::from(exit_code)
}
const CANNOT_CHECK: u8 = 2;
struct Failure {
kind: &'static str,
message: String,
}
impl Failure {
fn new(kind: &'static str, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
fn unreadable(path: &Path, error: &std::io::Error) -> Self {
let message = format!("cannot read `{}`: {error}", path.display());
Self::new(io_kind(error), message)
}
}
fn io_kind(error: &std::io::Error) -> &'static str {
match error.kind() {
std::io::ErrorKind::NotFound => error::NOT_FOUND,
_ => error::UNKNOWN,
}
}
fn cannot_check(options: &CheckOptions, failure: Failure) -> ExitCode {
match options.output_format() {
OutputFormat::Json => Envelope::new(failure.kind, failure.message, CANNOT_CHECK).eprint(),
OutputFormat::Text => eprintln!("error: {}", failure.message),
}
ExitCode::from(CANNOT_CHECK)
}
fn code_of(diagnostic: &Diagnostic) -> String {
match &diagnostic.code {
Some(NumberOrString::String(code)) => code.clone(),
Some(NumberOrString::Number(code)) => code.to_string(),
None => String::new(),
}
}
fn report_scan_losses(scan: &ScanReport, target_skipped: bool) {
if scan.walk_errors == 0
&& scan.skipped_oversize == 0
&& scan.skipped_unreadable == 0
&& !scan.file_cap_hit
{
return;
}
let detail = format!(
"{} walk error(s), {} oversize, {} unreadable, file cap hit: {}",
scan.walk_errors, scan.skipped_oversize, scan.skipped_unreadable, scan.file_cap_hit
);
if target_skipped {
eprintln!("error: the scan skipped target files ({detail})");
} else {
eprintln!("warning: the scan skipped context files ({detail})");
}
}
fn collect_targets(options: &CheckOptions) -> Result<Targets, Failure> {
let mut collected = Targets {
targets: Vec::new(),
scan: ScanReport::default(),
target_skipped: false,
};
let mut seen = std::collections::HashSet::new();
if options.stdin {
let mut text = String::new();
use std::io::Read;
std::io::stdin()
.read_to_string(&mut text)
.map_err(|error| {
Failure::new(io_kind(&error), format!("cannot read stdin: {error}"))
})?;
let (uri, display) = match &options.stdin_filename {
Some(path) => {
let absolute = std::path::absolute(path).map_err(|error| {
let message = format!("cannot resolve `{}`: {error}", path.display());
Failure::new(io_kind(&error), message)
})?;
let uri = Uri::from_file_path(&absolute).ok_or_else(|| {
let message = format!("cannot make a URI for `{}`", absolute.display());
Failure::new(error::INVALID, message)
})?;
(uri, display_path(path))
}
None => (
"untitled:stdin"
.parse::<Uri>()
.map_err(|_| Failure::new(error::UNKNOWN, "cannot make the stdin URI"))?,
"<stdin>".to_string(),
),
};
collected.targets.push(Target { uri, display, text });
return Ok(collected);
}
for path in &options.paths {
let metadata = fs::metadata(path).map_err(|error| Failure::unreadable(path, &error))?;
if metadata.is_dir() {
walk_target_dir(path, &mut collected, &mut seen);
} else {
if metadata.len() > MAX_FILE_SIZE_BYTES {
let message = format!(
"`{}` is over the {} MB limit",
path.display(),
MAX_FILE_SIZE_BYTES / (1024 * 1024)
);
return Err(Failure::new(error::INVALID, message));
}
let text =
fs::read_to_string(path).map_err(|error| Failure::unreadable(path, &error))?;
push_target(path, text, &mut collected, &mut seen).map_err(|()| {
let message = format!("cannot make a URI for `{}`", path.display());
Failure::new(error::INVALID, message)
})?;
}
}
Ok(collected)
}
fn walk_target_dir(dir: &Path, collected: &mut Targets, seen: &mut std::collections::HashSet<Uri>) {
for entry in WalkDir::new(dir)
.into_iter()
.filter_entry(|entry| should_descend(entry.path()))
{
let entry = match entry {
Ok(entry) => entry,
Err(_) => {
collected.scan.walk_errors += 1;
collected.target_skipped = true;
continue;
}
};
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if !matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("surql" | "surrealql")
) {
continue;
}
if collected.targets.len() >= MAX_WORKSPACE_FILES {
collected.scan.file_cap_hit = true;
collected.target_skipped = true;
return;
}
if entry
.metadata()
.map(|meta| meta.len() > MAX_FILE_SIZE_BYTES)
.unwrap_or(false)
{
collected.scan.skipped_oversize += 1;
collected.target_skipped = true;
continue;
}
let Ok(text) = fs::read_to_string(path) else {
collected.scan.skipped_unreadable += 1;
collected.target_skipped = true;
continue;
};
if push_target(path, text, collected, seen).is_err() {
collected.scan.skipped_unreadable += 1;
collected.target_skipped = true;
}
}
}
fn push_target(
path: &Path,
text: String,
collected: &mut Targets,
seen: &mut std::collections::HashSet<Uri>,
) -> Result<(), ()> {
let absolute = std::path::absolute(path).map_err(|_| ())?;
let uri = Uri::from_file_path(&absolute).ok_or(())?;
if seen.insert(uri.clone()) {
collected.targets.push(Target {
uri,
display: display_path(path),
text,
});
}
Ok(())
}
fn display_path(path: &Path) -> String {
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
match std::env::current_dir()
.ok()
.and_then(|cwd| absolute.strip_prefix(cwd).ok().map(Path::to_path_buf))
{
Some(relative) => relative.display().to_string(),
None => absolute.display().to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ls_types::{Position, Range};
fn sample_report() -> CheckReport {
let diagnostic = Diagnostic {
range: Range {
start: Position {
line: 2,
character: 14,
},
end: Position {
line: 2,
character: 17,
},
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("unknown-table".to_string())),
source: Some("surreal-language-server".to_string()),
message: "Table `usr` is not defined.".to_string(),
..Diagnostic::default()
};
CheckReport {
version: "test".to_string(),
files: vec![FileReport {
path: "schema/users.surql".to_string(),
diagnostics: vec![diagnostic],
}],
summary: Summary {
files_checked: 1,
errors: 1,
..Summary::default()
},
scan: ScanReport {
skipped_oversize: 1,
..ScanReport::default()
},
config_warnings: vec![],
exit_code: 1,
}
}
#[test]
fn text_output_is_one_based_and_carries_the_code() {
let text = render_text(&sample_report());
assert!(
text.contains(
"schema/users.surql:3:15 error[unknown-table]: Table `usr` is not defined."
),
"got: {text}"
);
assert!(
text.contains("1 error, 0 warnings in 1 file"),
"got: {text}"
);
}
#[test]
fn json_output_surfaces_the_scan_losses() {
let json = render_json(&sample_report());
let value: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(value["scan"]["skippedOversize"], 1);
assert_eq!(value["exitCode"], 1);
assert_eq!(
value["files"][0]["diagnostics"][0]["range"]["start"]["line"],
2
);
assert_eq!(value["files"][0]["diagnostics"][0]["code"], "unknown-table");
}
}