use std::fs;
use std::io::Read;
use crate::dap_json::{Json, render_json};
use crate::diagnostics::diagnostic::Diagnostic;
use crate::diagnostics::emitter::render_report;
use crate::diagnostics::error_code::ErrorCode;
use crate::diagnostics::severity::Severity;
use crate::diagnostics::span::Span;
pub fn lint_source(source: &str) -> Vec<Diagnostic> {
let parsed = match crate::compiler::parse(source) {
Ok(parsed) => parsed,
Err(err) => return vec![syntax_error(&err)],
};
let mut diagnostics = crate::sema::analyze_with_spans(&parsed.stmts, source, &parsed.spans);
diagnostics.sort_by_key(|d| {
d.primary_label
.as_ref()
.map(|l| (l.span.start.line, l.span.start.column))
.unwrap_or((0, 0))
});
diagnostics.dedup_by(|a, b| a.code == b.code && a.message == b.message);
diagnostics
}
pub fn syntax_error(err: &str) -> Diagnostic {
let (line, message) = err
.strip_prefix("[line ")
.and_then(|rest| rest.split_once("] "))
.and_then(|(line, message)| Some((line.parse::<u32>().ok()?, message)))
.unwrap_or((1, err));
Diagnostic::error(message)
.with_code(ErrorCode::E0050)
.with_label(Span::line(line.max(1)), "syntax error")
}
pub fn diagnostics_json(diagnostics: &[Diagnostic]) -> String {
let items = diagnostics
.iter()
.map(|d| {
let span = d.primary_label.as_ref().map(|l| l.span).unwrap_or_default();
let mut fields = vec![
(
"severity".to_string(),
Json::Str(d.severity.as_str().to_string()),
),
(
"code".to_string(),
d.code
.map(|c| Json::Str(c.as_str().to_string()))
.unwrap_or(Json::Null),
),
("message".to_string(), Json::Str(d.message.clone())),
("line".to_string(), Json::Num(span.start.line.max(1) as f64)),
(
"column".to_string(),
Json::Num(span.start.column.max(1) as f64),
),
(
"endLine".to_string(),
Json::Num(span.end.line.max(span.start.line).max(1) as f64),
),
(
"endColumn".to_string(),
Json::Num(span.end.column.max(span.start.column).max(1) as f64),
),
];
if let Some(help) = &d.help {
fields.push(("help".to_string(), Json::Str(help.clone())));
}
Json::Obj(fields)
})
.collect();
render_json(&Json::Arr(items))
}
pub fn run_cli(args: &[String]) -> Result<(), String> {
let json = args.iter().any(|a| a == "--json");
let stdin = args.iter().any(|a| a == "--stdin");
let path = args
.iter()
.find(|a| !a.starts_with("--"))
.ok_or("Usage: neyuki lint [--json] [--stdin] <path>")?;
let source = if stdin {
let mut source = String::new();
std::io::stdin()
.read_to_string(&mut source)
.map_err(|err| format!("failed to read stdin: {}", err))?;
source
} else {
fs::read_to_string(path).map_err(|err| format!("failed to read '{}': {}", path, err))?
};
let diagnostics = lint_source(&source);
let has_errors = diagnostics.iter().any(|d| d.severity == Severity::Error);
if json {
println!("{}", diagnostics_json(&diagnostics));
} else if diagnostics.is_empty() {
println!("No issues found in '{}'.", path);
} else {
eprintln!("{}", render_report(path, &source, &diagnostics));
}
if has_errors && !json {
Err(format!("lint found errors in '{}'", path))
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_source_has_no_diagnostics() {
assert!(lint_source("local x = 10\nreturn x + 1").is_empty());
}
#[test]
fn reports_unused_locals_where_they_are() {
let diags = lint_source("local a = 1\nlocal unused = 42\nreturn a");
let unused = diags
.iter()
.find(|d| d.code == Some(ErrorCode::W0001))
.expect("unused warning");
let span = unused.primary_label.as_ref().unwrap().span;
assert_eq!((span.start.line, span.start.column), (2, 7));
}
#[test]
fn reports_undefined_names_and_type_errors_on_their_lines() {
let diags = lint_source(
"local x = 1\nprint(y + x)\nlocal function f(): number\n return \"s\"\nend\nf()",
);
let lines: Vec<(Option<ErrorCode>, u32)> = diags
.iter()
.map(|d| (d.code, d.primary_label.as_ref().unwrap().span.start.line))
.collect();
assert!(lines.contains(&(Some(ErrorCode::E0001), 2)), "{:?}", lines);
assert!(lines.contains(&(Some(ErrorCode::E0003), 4)), "{:?}", lines);
}
#[test]
fn syntax_errors_keep_their_line() {
let diags = lint_source("local x = 1\nprint(x\n");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, Some(ErrorCode::E0050));
assert!(diags[0].primary_label.as_ref().unwrap().span.start.line >= 2);
}
fn codes(source: &str) -> Vec<ErrorCode> {
lint_source(source).iter().filter_map(|d| d.code).collect()
}
#[test]
fn methods_and_fields_declare_no_locals() {
let source = "local t = {}\n\
function t.f(self: any): number return 1 end\n\
function t:g() return 2 end\n\
return t";
assert!(codes(source).is_empty(), "{:?}", lint_source(source));
assert!(codes("function missing.f() end").contains(&ErrorCode::E0001));
}
#[test]
fn varargs_read_in_an_expression_are_used() {
assert!(codes("local function f(...: string) return {...} end\nf()").is_empty());
assert!(codes("local function f(...: any) return 1 end\nf()").contains(&ErrorCode::W0002));
}
#[test]
fn empty_tuple_return_type_allows_bare_return() {
assert!(codes("local function f(): () return end\nf()").is_empty());
}
#[test]
fn named_types_accept_tables_and_defaults() {
let source = "local function f(options: Options?): Options\n\
options = options ?? {}\n\
return options\n\
end\nf()";
assert!(codes(source).is_empty(), "{:?}", lint_source(source));
let source = "local function f(r: Request): Response return r end\nf({})";
assert!(codes(source).contains(&ErrorCode::E0003));
let source = "local function f(o: Options?): number return o end\nf()";
assert!(codes(source).contains(&ErrorCode::E0003));
}
#[test]
fn nested_unions_are_flattened_for_assignment() {
let source = "local function f(x: string | number | nil): string | number\n\
x = x ?? \"l\"\n\
return x\n\
end\nf()";
assert!(codes(source).is_empty(), "{:?}", lint_source(source));
let source = "local function f(x: string | number | nil): string | number\n\
return x\n\
end\nf()";
assert!(codes(source).contains(&ErrorCode::E0003));
}
#[test]
fn function_expressions_have_their_own_returns() {
let source = "local function f(): string\n\
local cmp = function(a: string, b: string): boolean return a < b end\n\
cmp(\"a\", \"b\")\n\
return \"s\"\n\
end\nf()";
assert!(codes(source).is_empty(), "{:?}", lint_source(source));
}
#[test]
fn function_expressions_have_their_parameter_types() {
let ok = "local function f(): (number) -> number\n\
return function(n: number): number return n end\n\
end\nf()";
assert!(codes(ok).is_empty(), "{:?}", lint_source(ok));
let variadic = "local function f(): (...any) -> number\n\
return function(...: any): number return select(\"#\", ...) end\n\
end\nf()";
assert!(codes(variadic).is_empty(), "{:?}", lint_source(variadic));
let wrong = "local function f(): (number) -> number\n\
return function(a: number, b: number): number return a + b end\n\
end\nf()";
assert!(codes(wrong).contains(&ErrorCode::E0003));
}
#[test]
fn trailing_optional_arguments_may_be_left_out() {
let decl = "local function f(a: string, b: string?) return a .. (b ?? \"\") end\n";
assert!(codes(&format!("{}f(\"x\")", decl)).is_empty());
assert!(codes(&format!("{}f(\"x\", \"y\")", decl)).is_empty());
assert!(codes(&format!("{}f()", decl)).contains(&ErrorCode::E0004));
assert!(codes(&format!("{}f(\"x\", \"y\", \"z\")", decl)).contains(&ErrorCode::E0004));
}
#[test]
fn bundled_libraries_lint_clean() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/lib");
let mut checked = 0;
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().is_some_and(|e| e == "nyk") {
let source = std::fs::read_to_string(&path).unwrap();
let diags = lint_source(&source);
assert!(
diags.is_empty(),
"{}",
render_report(&path.display().to_string(), &source, &diags)
);
checked += 1;
}
}
assert!(checked > 0);
}
#[test]
fn json_output_is_an_array_of_positions() {
let json = diagnostics_json(&lint_source("local unused = 1"));
assert!(
json.starts_with('[') && json.contains("\"code\":\"W0001\""),
"{}",
json
);
assert!(json.contains("\"line\":1"), "{}", json);
}
}