use crate::ast::Span;
use ariadne::{Color, Label, Report, ReportKind, Source};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ErrorCode {
UnexpectedChar, UnterminatedString, UnterminatedComment, InvalidEscape, InvalidNumber,
UnexpectedToken, ExpectedExpression, ExpectedIdentifier, ExpectedType, UnclosedDelimiter,
TypeMismatch, UndefinedVariable, TaintViolation, NotCallable, ArityMismatch, UnknownField, MissingRequiredParam, InvalidFieldAccess, InvalidOperator, UnknownModule, AssertionFailed, PreferredOverride, SecurityViolation, NestedUnsafe, UnknownParameter, ShadowBanned,
TaintedArgument, TaintedAccess, CriticalPortExposed, PortExposed,
Generic, }
impl ErrorCode {
pub fn code(&self) -> &'static str {
match self {
ErrorCode::UnexpectedChar => "E0001",
ErrorCode::UnterminatedString => "E0002",
ErrorCode::UnterminatedComment => "E0003",
ErrorCode::InvalidEscape => "E0004",
ErrorCode::InvalidNumber => "E0005",
ErrorCode::UnexpectedToken => "E0100",
ErrorCode::ExpectedExpression => "E0101",
ErrorCode::ExpectedIdentifier => "E0102",
ErrorCode::ExpectedType => "E0103",
ErrorCode::UnclosedDelimiter => "E0104",
ErrorCode::TypeMismatch => "E0200",
ErrorCode::UndefinedVariable => "E0201",
ErrorCode::TaintViolation => "E0202",
ErrorCode::NotCallable => "E0203",
ErrorCode::ArityMismatch => "E0204",
ErrorCode::UnknownField => "E0205",
ErrorCode::InvalidFieldAccess => "E0206",
ErrorCode::InvalidOperator => "E0207",
ErrorCode::UnknownModule => "E0208",
ErrorCode::MissingRequiredParam => "E0209",
ErrorCode::AssertionFailed => "E0210",
ErrorCode::PreferredOverride => "E0211",
ErrorCode::SecurityViolation => "E0212",
ErrorCode::NestedUnsafe => "E0213",
ErrorCode::UnknownParameter => "E0214",
ErrorCode::ShadowBanned => "E0215",
ErrorCode::TaintedArgument => "E0300",
ErrorCode::TaintedAccess => "E0301",
ErrorCode::CriticalPortExposed => "E0302",
ErrorCode::PortExposed => "E0303",
ErrorCode::Generic => "E9999",
}
}
pub fn title(&self) -> &'static str {
match self {
ErrorCode::UnexpectedChar => "unexpected character",
ErrorCode::UnterminatedString => "unterminated string literal",
ErrorCode::UnterminatedComment => "unterminated block comment",
ErrorCode::InvalidEscape => "invalid escape sequence",
ErrorCode::InvalidNumber => "invalid number",
ErrorCode::UnexpectedToken => "unexpected token",
ErrorCode::ExpectedExpression => "expected expression",
ErrorCode::ExpectedIdentifier => "expected identifier",
ErrorCode::ExpectedType => "expected type",
ErrorCode::UnclosedDelimiter => "unclosed delimiter",
ErrorCode::TypeMismatch => "type mismatch",
ErrorCode::UndefinedVariable => "undefined variable",
ErrorCode::TaintViolation => "taint violation",
ErrorCode::NotCallable => "cannot call non-function",
ErrorCode::ArityMismatch => "wrong number of arguments",
ErrorCode::UnknownField => "unknown field",
ErrorCode::InvalidFieldAccess => "invalid field access",
ErrorCode::InvalidOperator => "invalid operator for types",
ErrorCode::UnknownModule => "unknown module",
ErrorCode::MissingRequiredParam => "missing required parameter",
ErrorCode::AssertionFailed => "assertion failed",
ErrorCode::PreferredOverride => "preferred setting overridden",
ErrorCode::SecurityViolation => "security parameter requires unsafe",
ErrorCode::NestedUnsafe => "nested unsafe blocks not allowed",
ErrorCode::UnknownParameter => "unknown parameter",
ErrorCode::ShadowBanned => "shadow banned resource",
ErrorCode::TaintedArgument => "tainted value in secure context",
ErrorCode::TaintedAccess => "accessing tainted value outside unsafe",
ErrorCode::CriticalPortExposed => "critical port exposed to internet",
ErrorCode::PortExposed => "port exposed to internet",
ErrorCode::Generic => "error",
}
}
pub fn explanation(&self) -> Option<&'static str> {
match self {
ErrorCode::TaintViolation | ErrorCode::TaintedArgument | ErrorCode::TaintedAccess => {
Some("Unverified<T> wraps imported infrastructure that hasn't been security-audited. \
Use unsafe(\"reason\") { ... } to explicitly acknowledge the risk.")
}
ErrorCode::UndefinedVariable => {
Some("Variables must be declared with 'val' before use.")
}
ErrorCode::ArityMismatch => {
Some("Functions must be called with the correct number of arguments.")
}
_ => None,
}
}
}
impl std::fmt::Display for ErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.code())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Diagnostic {
pub kind: DiagnosticKind,
pub code: ErrorCode,
pub message: String,
pub span: Option<Span>,
pub filename: Option<String>,
pub labels: Vec<DiagnosticLabel>,
pub notes: Vec<String>,
pub help: Option<String>,
pub suggestion: Option<Suggestion>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DiagnosticKind {
Error,
Warning,
Info,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticLabel {
pub span: Span,
pub message: String,
pub is_primary: bool,
}
impl DiagnosticLabel {
pub fn primary(span: Span, message: impl Into<String>) -> Self {
Self {
span,
message: message.into(),
is_primary: true,
}
}
pub fn secondary(span: Span, message: impl Into<String>) -> Self {
Self {
span,
message: message.into(),
is_primary: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Suggestion {
pub span: Span,
pub replacement: String,
pub message: String,
}
impl Diagnostic {
pub fn error(message: impl Into<String>) -> Self {
Self {
kind: DiagnosticKind::Error,
code: ErrorCode::Generic,
message: message.into(),
span: None,
filename: None,
labels: Vec::new(),
notes: Vec::new(),
help: None,
suggestion: None,
}
}
pub fn error_at(message: impl Into<String>, span: Span, filename: impl Into<String>) -> Self {
Self {
kind: DiagnosticKind::Error,
code: ErrorCode::Generic,
message: message.into(),
span: Some(span),
filename: Some(filename.into()),
labels: vec![DiagnosticLabel {
span,
message: String::new(),
is_primary: true,
}],
notes: Vec::new(),
help: None,
suggestion: None,
}
}
pub fn warning(message: impl Into<String>) -> Self {
Self {
kind: DiagnosticKind::Warning,
code: ErrorCode::Generic,
message: message.into(),
span: None,
filename: None,
labels: Vec::new(),
notes: Vec::new(),
help: None,
suggestion: None,
}
}
pub fn warning_at(message: impl Into<String>, span: Span, filename: impl Into<String>) -> Self {
Self {
kind: DiagnosticKind::Warning,
code: ErrorCode::Generic,
message: message.into(),
span: Some(span),
filename: Some(filename.into()),
labels: vec![DiagnosticLabel {
span,
message: String::new(),
is_primary: true,
}],
notes: Vec::new(),
help: None,
suggestion: None,
}
}
pub fn with_code(mut self, code: ErrorCode) -> Self {
self.code = code;
if let Some(explanation) = code.explanation() {
if !self.notes.iter().any(|n| n == explanation) {
self.notes.push(explanation.to_string());
}
}
self
}
pub fn with_primary_label(mut self, message: impl Into<String>) -> Self {
if let Some(span) = self.span {
self.labels.retain(|l| !l.is_primary);
self.labels.push(DiagnosticLabel::primary(span, message));
}
self
}
pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Self {
self.labels.push(DiagnosticLabel::secondary(span, message));
self
}
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.notes.push(note.into());
self
}
pub fn with_help(mut self, help: impl Into<String>) -> Self {
self.help = Some(help.into());
self
}
pub fn with_suggestion(
mut self,
span: Span,
replacement: impl Into<String>,
message: impl Into<String>,
) -> Self {
self.suggestion = Some(Suggestion {
span,
replacement: replacement.into(),
message: message.into(),
});
self
}
pub fn print(&self, source: &str) {
let filename = self.filename.as_deref().unwrap_or("<unknown>");
let report_kind = match self.kind {
DiagnosticKind::Error => ReportKind::Custom("error", Color::Red),
DiagnosticKind::Warning => ReportKind::Custom("warning", Color::Yellow),
DiagnosticKind::Info => ReportKind::Custom("info", Color::Blue),
};
let message = if self.code != ErrorCode::Generic {
format!("[{}]: {}", self.code.code(), self.message)
} else {
self.message.clone()
};
let offset = self
.labels
.iter()
.find(|l| l.is_primary)
.map(|l| l.span.start as usize)
.unwrap_or(0);
let mut builder = Report::build(report_kind, filename, offset).with_message(&message);
for label in &self.labels {
let color = if label.is_primary {
match self.kind {
DiagnosticKind::Error => Color::Red,
DiagnosticKind::Warning => Color::Yellow,
DiagnosticKind::Info => Color::Blue,
}
} else {
Color::Cyan
};
let mut ariadne_label = Label::new((filename, label.span.to_range())).with_color(color);
if !label.message.is_empty() {
ariadne_label = ariadne_label.with_message(&label.message);
}
builder = builder.with_label(ariadne_label);
}
for note in &self.notes {
builder = builder.with_note(note);
}
if let Some(help) = &self.help {
builder = builder.with_help(help);
}
builder
.finish()
.print((filename, Source::from(source)))
.unwrap();
if let Some(suggestion) = &self.suggestion {
eprintln!(" suggestion: {}", suggestion.message);
eprintln!(" {}", suggestion.replacement);
}
}
pub fn to_string_simple(&self) -> String {
let code = if self.code != ErrorCode::Generic {
format!("[{}] ", self.code.code())
} else {
String::new()
};
format!(
"{}{}: {}",
match self.kind {
DiagnosticKind::Error => "error",
DiagnosticKind::Warning => "warning",
DiagnosticKind::Info => "info",
},
code,
self.message
)
}
}
pub fn print_diagnostics(diagnostics: &[Diagnostic], source: &str) {
for diagnostic in diagnostics {
diagnostic.print(source);
}
}
pub struct DiagnosticBuilder;
impl DiagnosticBuilder {
pub fn type_mismatch(expected: &str, found: &str, span: Span, filename: &str) -> Diagnostic {
if expected == "Tags" {
return Diagnostic::error_at("tag values must be strings".to_string(), span, filename)
.with_code(ErrorCode::TypeMismatch)
.with_primary_label("contains non-string value")
.with_help("AWS tags require all values to be strings, e.g. { count: \"42\" }");
}
Diagnostic::error_at(
format!("expected `{}`, found `{}`", expected, found),
span,
filename,
)
.with_code(ErrorCode::TypeMismatch)
.with_primary_label(format!("expected `{}`", expected))
}
pub fn undefined_variable(
name: &str,
span: Span,
filename: &str,
similar: Option<&str>,
) -> Diagnostic {
let mut diag = Diagnostic::error_at(
format!("cannot find variable `{}` in this scope", name),
span,
filename,
)
.with_code(ErrorCode::UndefinedVariable)
.with_primary_label("not found in this scope");
if let Some(similar_name) = similar {
diag = diag.with_help(format!("did you mean `{}`?", similar_name));
}
diag
}
pub fn taint_violation(
tainted_type: &str,
expected_type: &str,
span: Span,
filename: &str,
) -> Diagnostic {
Diagnostic::error_at(
format!(
"cannot use `{}` where `{}` is expected",
tainted_type, expected_type
),
span,
filename,
)
.with_code(ErrorCode::TaintViolation)
.with_primary_label(format!("this has type `{}`", tainted_type))
.with_help("wrap the operation in an unsafe block:\n\n unsafe(\"TICKET-XXX: reason\") {\n // your code here\n }")
}
pub fn tainted_argument(
param_name: &str,
tainted_type: &str,
expected_type: &str,
span: Span,
filename: &str,
) -> Diagnostic {
Diagnostic::error_at(
format!(
"cannot pass tainted value to parameter `{}`",
param_name
),
span,
filename,
)
.with_code(ErrorCode::TaintedArgument)
.with_primary_label(format!("this has type `{}`", tainted_type))
.with_note(format!("parameter `{}` expects `{}`, but got `{}`", param_name, expected_type, tainted_type))
.with_help("wrap in unsafe block to acknowledge the risk:\n\n unsafe(\"TICKET-XXX: verified secure\") {\n // function call here\n }")
}
pub fn tainted_access(field: &str, base_type: &str, span: Span, filename: &str) -> Diagnostic {
Diagnostic::error_at(
format!("cannot access `{}` on tainted value outside unsafe block", field),
span,
filename,
)
.with_code(ErrorCode::TaintedAccess)
.with_primary_label(format!("`{}` is tainted (type: `{}`)", field, base_type))
.with_help("use unsafe block to access tainted values:\n\n unsafe(\"TICKET-XXX: reason\") {\n legacy.bucket\n }")
}
pub fn arity_mismatch(expected: usize, found: usize, span: Span, filename: &str) -> Diagnostic {
let plural_expected = if expected == 1 {
"argument"
} else {
"arguments"
};
let plural_found = if found == 1 { "was" } else { "were" };
Diagnostic::error_at(
format!(
"function expects {} {}, but {} {} supplied",
expected, plural_expected, found, plural_found
),
span,
filename,
)
.with_code(ErrorCode::ArityMismatch)
.with_primary_label(format!("expected {} {}", expected, plural_expected))
}
pub fn not_callable(typ: &str, span: Span, filename: &str) -> Diagnostic {
Diagnostic::error_at(
format!("cannot call value of type `{}`", typ),
span,
filename,
)
.with_code(ErrorCode::NotCallable)
.with_primary_label("not a function")
.with_note("only functions can be called with ()")
}
pub fn unknown_field(field: &str, base_type: &str, span: Span, filename: &str) -> Diagnostic {
Diagnostic::error_at(
format!("no field `{}` on type `{}`", field, base_type),
span,
filename,
)
.with_code(ErrorCode::UnknownField)
.with_primary_label(format!("`{}` has no field `{}`", base_type, field))
}
pub fn invalid_operator(
op: &str,
left_type: &str,
right_type: &str,
span: Span,
filename: &str,
) -> Diagnostic {
Diagnostic::error_at(
format!(
"cannot apply `{}` to `{}` and `{}`",
op, left_type, right_type
),
span,
filename,
)
.with_code(ErrorCode::InvalidOperator)
.with_primary_label(format!("operator `{}` not valid for these types", op))
}
pub fn unexpected_char(ch: char, span: Span, filename: &str) -> Diagnostic {
Diagnostic::error_at(format!("unexpected character `{}`", ch), span, filename)
.with_code(ErrorCode::UnexpectedChar)
.with_primary_label("unexpected here")
}
pub fn unterminated_string(span: Span, filename: &str) -> Diagnostic {
Diagnostic::error_at("unterminated string literal", span, filename)
.with_code(ErrorCode::UnterminatedString)
.with_primary_label("string starts here but never ends")
.with_help("add a closing `\"` to terminate the string")
}
pub fn unexpected_token(expected: &str, found: &str, span: Span, filename: &str) -> Diagnostic {
Diagnostic::error_at(
format!("expected {}, found {}", expected, found),
span,
filename,
)
.with_code(ErrorCode::UnexpectedToken)
.with_primary_label(format!("expected {}", expected))
}
pub fn assertion_failed(message: &str, span: Span, filename: &str) -> Diagnostic {
Diagnostic::error_at(format!("assertion failed: {}", message), span, filename)
.with_code(ErrorCode::AssertionFailed)
.with_primary_label("assertion evaluated to false")
}
}
#[allow(clippy::needless_range_loop)]
pub fn levenshtein_distance(a: &str, b: &str) -> usize {
let a_len = a.chars().count();
let b_len = b.chars().count();
if a_len == 0 {
return b_len;
}
if b_len == 0 {
return a_len;
}
let mut matrix = vec![vec![0usize; b_len + 1]; a_len + 1];
for i in 0..=a_len {
matrix[i][0] = i;
}
for j in 0..=b_len {
matrix[0][j] = j;
}
for (i, a_char) in a.chars().enumerate() {
for (j, b_char) in b.chars().enumerate() {
let cost = if a_char == b_char { 0 } else { 1 };
matrix[i + 1][j + 1] = (matrix[i][j + 1] + 1)
.min(matrix[i + 1][j] + 1)
.min(matrix[i][j] + cost);
}
}
matrix[a_len][b_len]
}
pub fn find_similar<'a>(
target: &str,
candidates: &[&'a str],
max_distance: usize,
) -> Option<&'a str> {
candidates
.iter()
.map(|&c| (c, levenshtein_distance(target, c)))
.filter(|(_, d)| *d <= max_distance && *d > 0)
.min_by_key(|(_, d)| *d)
.map(|(c, _)| c)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_codes() {
assert_eq!(ErrorCode::TypeMismatch.code(), "E0200");
assert_eq!(ErrorCode::UndefinedVariable.code(), "E0201");
assert_eq!(ErrorCode::TaintViolation.code(), "E0202");
}
#[test]
fn test_diagnostic_with_code() {
let span = Span::new(0, 10, 1, 1);
let diag = Diagnostic::error_at("test", span, "test.hk").with_code(ErrorCode::TypeMismatch);
assert_eq!(diag.code, ErrorCode::TypeMismatch);
assert!(diag.to_string_simple().contains("E0200"));
}
#[test]
fn test_diagnostic_builder() {
let span = Span::new(0, 10, 1, 1);
let diag = DiagnosticBuilder::type_mismatch("String", "Number", span, "test.hk");
assert_eq!(diag.code, ErrorCode::TypeMismatch);
assert!(diag.message.contains("String"));
assert!(diag.message.contains("Number"));
}
#[test]
fn test_levenshtein_distance() {
assert_eq!(levenshtein_distance("bucket", "bucekt"), 2);
assert_eq!(levenshtein_distance("hello", "hello"), 0);
assert_eq!(levenshtein_distance("abc", "xyz"), 3);
}
#[test]
fn test_find_similar() {
let candidates = vec!["bucket", "vpc", "subnet", "security"];
assert_eq!(find_similar("bucekt", &candidates, 3), Some("bucket"));
assert_eq!(find_similar("vps", &candidates, 2), Some("vpc"));
assert_eq!(find_similar("xyz", &candidates, 2), None);
}
#[test]
fn test_diagnostic_with_notes() {
let span = Span::new(0, 10, 1, 1);
let diag = Diagnostic::error_at("test", span, "test.hk")
.with_note("this is a note")
.with_note("another note");
assert_eq!(diag.notes.len(), 2);
}
#[test]
fn test_undefined_variable_with_suggestion() {
let span = Span::new(0, 10, 1, 1);
let diag = DiagnosticBuilder::undefined_variable("bucekt", span, "test.hk", Some("bucket"));
assert!(diag.help.as_ref().unwrap().contains("bucket"));
}
}