use crate::source::Span;
use miette::{Diagnostic, SourceSpan};
#[derive(Debug, Clone, PartialEq)]
pub struct AnalyzeError {
pub kind: AnalyzeErrorKind,
pub span: Span,
pub message: String,
pub suggestion: Option<String>,
pub note: Option<String>,
}
impl AnalyzeError {
pub fn new(kind: AnalyzeErrorKind, span: Span, message: impl Into<String>) -> Self {
Self {
kind,
span,
message: message.into(),
suggestion: None,
note: None,
}
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestion = Some(suggestion.into());
self
}
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
pub fn unknown_task(span: Span, name: &str, suggestion: Option<&str>) -> Self {
let mut err = Self::new(
AnalyzeErrorKind::UnknownTask,
span,
format!("unknown task '{}'", name),
);
if let Some(s) = suggestion {
err = err.with_suggestion(format!("did you mean '{}'?", s));
}
err
}
pub fn duplicate_task(span: Span, name: &str, first_span: Span) -> Self {
Self::new(
AnalyzeErrorKind::DuplicateTask,
span,
format!("duplicate task id '{}'", name),
)
.with_note(format!("first defined at {:?}", first_span))
}
pub fn invalid_schema(span: Span, schema: &str, suggestion: Option<&str>) -> Self {
let mut err = Self::new(
AnalyzeErrorKind::InvalidSchema,
span,
format!("invalid schema version '{}'", schema),
);
if let Some(s) = suggestion {
err = err.with_suggestion(format!("did you mean '{}'?", s));
}
err
}
pub fn cyclic_dependency(span: Span, cycle: &[&str]) -> Self {
Self::new(
AnalyzeErrorKind::CyclicDependency,
span,
format!("cyclic dependency detected: {}", cycle.join(" → ")),
)
}
pub fn invalid_value(span: Span, field: &str, value: &str, expected: &str) -> Self {
Self::new(
AnalyzeErrorKind::InvalidValue,
span,
format!("invalid {} '{}', expected {}", field, value, expected),
)
}
pub fn missing_field(span: Span, field: &str) -> Self {
Self::new(
AnalyzeErrorKind::MissingField,
span,
format!("missing required field '{}'", field),
)
}
pub fn unsupported_feature(
span: Span,
feature: &str,
current_version: &str,
required_version: &str,
) -> Self {
Self::new(
AnalyzeErrorKind::UnsupportedFeature,
span,
format!(
"'{}' requires schema version {} or later, but workflow uses {}",
feature, required_version, current_version
),
)
.with_suggestion(format!("upgrade to schema: {}", required_version))
}
pub fn invalid_binding(span: Span, input: &str, reason: &str) -> Self {
Self::new(
AnalyzeErrorKind::InvalidBinding,
span,
format!("invalid binding expression '{}': {}", input, reason),
)
}
}
impl std::fmt::Display for AnalyzeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)?;
if let Some(ref suggestion) = self.suggestion {
write!(f, " ({})", suggestion)?;
}
Ok(())
}
}
impl std::error::Error for AnalyzeError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnalyzeErrorKind {
UnknownTask,
DuplicateTask,
InvalidSchema,
CyclicDependency,
InvalidValue,
MissingField,
UnsupportedFeature,
InvalidBinding,
}
impl AnalyzeErrorKind {
pub fn code(&self) -> &'static str {
match self {
Self::UnknownTask => "NIKA-140",
Self::DuplicateTask => "NIKA-141",
Self::InvalidSchema => "NIKA-142",
Self::CyclicDependency => "NIKA-143",
Self::InvalidValue => "NIKA-144",
Self::MissingField => "NIKA-145",
Self::UnsupportedFeature => "NIKA-149",
Self::InvalidBinding => "NIKA-151",
}
}
}
#[derive(Debug)]
pub struct AnalyzeResult<T> {
pub value: Option<T>,
pub errors: Vec<AnalyzeError>,
pub warnings: Vec<AnalyzeError>,
}
impl<T> AnalyzeResult<T> {
pub fn ok(value: T) -> Self {
Self {
value: Some(value),
errors: Vec::new(),
warnings: Vec::new(),
}
}
pub fn err(errors: Vec<AnalyzeError>) -> Self {
Self {
value: None,
errors,
warnings: Vec::new(),
}
}
pub fn is_ok(&self) -> bool {
self.errors.is_empty() && self.value.is_some()
}
pub fn is_err(&self) -> bool {
!self.errors.is_empty()
}
pub fn into_result(self) -> Result<T, Vec<AnalyzeError>> {
if self.errors.is_empty() {
self.value.ok_or_else(Vec::new)
} else {
Err(self.errors)
}
}
pub fn add_error(&mut self, error: AnalyzeError) {
self.errors.push(error);
}
pub fn add_warning(&mut self, warning: AnalyzeError) {
self.warnings.push(warning);
}
}
#[derive(Debug)]
pub struct RichAnalyzeError {
error: AnalyzeError,
source_code: String,
filename: String,
label_span: SourceSpan,
}
impl RichAnalyzeError {
pub fn new(
error: AnalyzeError,
source_code: impl Into<String>,
filename: impl Into<String>,
) -> Self {
let source = source_code.into();
let label_span = SourceSpan::new(error.span.start.as_usize().into(), error.span.len());
Self {
error,
source_code: source,
filename: filename.into(),
label_span,
}
}
pub fn error(&self) -> &AnalyzeError {
&self.error
}
pub fn source(&self) -> &str {
&self.source_code
}
pub fn filename(&self) -> &str {
&self.filename
}
}
impl std::fmt::Display for RichAnalyzeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.error.kind.code(), self.error.message)
}
}
impl std::error::Error for RichAnalyzeError {}
impl Diagnostic for RichAnalyzeError {
fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
Some(Box::new(format!(
"nika::{}",
self.error.kind.code().to_lowercase().replace('-', "_")
)))
}
fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
self.error
.suggestion
.as_ref()
.map(|s| Box::new(s.clone()) as Box<dyn std::fmt::Display>)
}
fn url<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
Some(Box::new(format!(
"https://nika.sh/errors/{}",
self.error.kind.code()
)))
}
fn source_code(&self) -> Option<&dyn miette::SourceCode> {
Some(&self.source_code)
}
fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
let label =
miette::LabeledSpan::new_with_span(Some(self.error.message.clone()), self.label_span);
Some(Box::new(std::iter::once(label)))
}
fn related<'a>(&'a self) -> Option<Box<dyn Iterator<Item = &'a dyn Diagnostic> + 'a>> {
None
}
fn diagnostic_source(&self) -> Option<&dyn Diagnostic> {
None
}
}
pub fn to_rich_diagnostics(
errors: Vec<AnalyzeError>,
source_code: &str,
filename: &str,
) -> Vec<RichAnalyzeError> {
errors
.into_iter()
.map(|e| RichAnalyzeError::new(e, source_code.to_string(), filename.to_string()))
.collect()
}
pub fn format_error(error: &AnalyzeError, source_code: &str, filename: &str) -> String {
let rich = RichAnalyzeError::new(error.clone(), source_code, filename);
format!("{:?}", miette::Report::new(rich))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source::FileId;
fn make_span(start: u32, end: u32) -> Span {
Span::new(FileId(0), start, end)
}
#[test]
fn test_analyze_error_display() {
let err = AnalyzeError::unknown_task(make_span(10, 20), "taks1", Some("task1"));
assert_eq!(
format!("{}", err),
"unknown task 'taks1' (did you mean 'task1'?)"
);
}
#[test]
fn test_analyze_error_duplicate() {
let err = AnalyzeError::duplicate_task(make_span(100, 110), "my-task", make_span(10, 20));
assert!(format!("{}", err).contains("duplicate task id 'my-task'"));
}
#[test]
fn test_analyze_error_cyclic() {
let err = AnalyzeError::cyclic_dependency(make_span(0, 10), &["a", "b", "c", "a"]);
assert!(format!("{}", err).contains("a → b → c → a"));
}
#[test]
fn test_error_kind_codes() {
assert_eq!(AnalyzeErrorKind::UnknownTask.code(), "NIKA-140");
assert_eq!(AnalyzeErrorKind::DuplicateTask.code(), "NIKA-141");
assert_eq!(AnalyzeErrorKind::InvalidSchema.code(), "NIKA-142");
assert_eq!(AnalyzeErrorKind::CyclicDependency.code(), "NIKA-143");
assert_eq!(AnalyzeErrorKind::InvalidValue.code(), "NIKA-144");
assert_eq!(AnalyzeErrorKind::MissingField.code(), "NIKA-145");
assert_eq!(AnalyzeErrorKind::UnsupportedFeature.code(), "NIKA-149");
assert_eq!(AnalyzeErrorKind::InvalidBinding.code(), "NIKA-151");
}
#[test]
fn test_analyze_result_ok() {
let result: AnalyzeResult<i32> = AnalyzeResult::ok(42);
assert!(result.is_ok());
assert!(!result.is_err());
assert_eq!(result.into_result(), Ok(42));
}
#[test]
fn test_analyze_result_err() {
let errors = vec![AnalyzeError::new(
AnalyzeErrorKind::UnknownTask,
make_span(0, 5),
"test error",
)];
let result: AnalyzeResult<i32> = AnalyzeResult::err(errors);
assert!(!result.is_ok());
assert!(result.is_err());
assert!(result.into_result().is_err());
}
#[test]
fn test_rich_error_creation() {
let source = "tasks:\n - id: step1\n with:\n data: taks1\n";
let err = AnalyzeError::unknown_task(make_span(40, 45), "taks1", Some("task1"));
let rich = RichAnalyzeError::new(err, source, "test.nika.yaml");
assert_eq!(rich.filename(), "test.nika.yaml");
assert_eq!(rich.source(), source);
assert_eq!(rich.error().kind, AnalyzeErrorKind::UnknownTask);
}
#[test]
fn test_rich_error_display() {
let err = AnalyzeError::unknown_task(make_span(10, 15), "taks1", Some("task1"));
let rich = RichAnalyzeError::new(err, "some source", "test.yaml");
let display = format!("{}", rich);
assert!(display.contains("NIKA-140"));
assert!(display.contains("unknown task 'taks1'"));
}
#[test]
fn test_rich_error_diagnostic_code() {
let err = AnalyzeError::unknown_task(make_span(0, 5), "x", None);
let rich = RichAnalyzeError::new(err, "source", "file.yaml");
let code = Diagnostic::code(&rich).unwrap();
assert_eq!(format!("{}", code), "nika::nika_140");
}
#[test]
fn test_rich_error_diagnostic_help() {
let err = AnalyzeError::unknown_task(make_span(0, 5), "taks1", Some("task1"));
let rich = RichAnalyzeError::new(err, "source", "file.yaml");
let help = Diagnostic::help(&rich).unwrap();
assert_eq!(format!("{}", help), "did you mean 'task1'?");
}
#[test]
fn test_rich_error_diagnostic_no_help() {
let err = AnalyzeError::unknown_task(make_span(0, 5), "x", None);
let rich = RichAnalyzeError::new(err, "source", "file.yaml");
assert!(Diagnostic::help(&rich).is_none());
}
#[test]
fn test_rich_error_diagnostic_url() {
let err = AnalyzeError::new(AnalyzeErrorKind::CyclicDependency, make_span(0, 5), "cycle");
let rich = RichAnalyzeError::new(err, "source", "file.yaml");
let url = Diagnostic::url(&rich).unwrap();
assert_eq!(format!("{}", url), "https://nika.sh/errors/NIKA-143");
}
#[test]
fn test_rich_error_has_source_code() {
let source = "schema: nika/workflow@0.12\ntasks:\n - id: test\n";
let err = AnalyzeError::missing_field(make_span(30, 40), "infer");
let rich = RichAnalyzeError::new(err, source, "test.yaml");
assert!(Diagnostic::source_code(&rich).is_some());
}
#[test]
fn test_rich_error_labels() {
let err = AnalyzeError::unknown_task(make_span(10, 20), "bad_task", None);
let rich = RichAnalyzeError::new(err, "0123456789bad_task90", "test.yaml");
let labels: Vec<_> = Diagnostic::labels(&rich).unwrap().collect();
assert_eq!(labels.len(), 1);
let span = labels[0].inner();
assert_eq!(span.offset(), 10);
assert_eq!(span.len(), 10);
}
#[test]
fn test_to_rich_diagnostics() {
let errors = vec![
AnalyzeError::unknown_task(make_span(10, 15), "a", None),
AnalyzeError::duplicate_task(make_span(20, 25), "b", make_span(0, 5)),
];
let source = "0123456789abcdefghijklmnopqrstuvwxyz";
let rich = to_rich_diagnostics(errors, source, "test.yaml");
assert_eq!(rich.len(), 2);
assert_eq!(rich[0].error().kind, AnalyzeErrorKind::UnknownTask);
assert_eq!(rich[1].error().kind, AnalyzeErrorKind::DuplicateTask);
}
#[test]
fn test_format_error_output() {
let source = r#"schema: "nika/workflow@0.12"
tasks:
- id: step1
infer: "Hello"
- id: step2
with:
data: taks1
infer: "Process {{with.data}}"
"#;
let err = AnalyzeError::unknown_task(make_span(96, 101), "taks1", Some("task1"));
let output = format_error(&err, source, "workflow.nika.yaml");
assert!(output.contains("NIKA-140") || output.contains("nika_140"));
assert!(output.contains("unknown task"));
}
#[test]
fn test_rich_error_all_kinds() {
let kinds = [
(AnalyzeErrorKind::UnknownTask, "NIKA-140"),
(AnalyzeErrorKind::DuplicateTask, "NIKA-141"),
(AnalyzeErrorKind::InvalidSchema, "NIKA-142"),
(AnalyzeErrorKind::CyclicDependency, "NIKA-143"),
(AnalyzeErrorKind::InvalidValue, "NIKA-144"),
(AnalyzeErrorKind::MissingField, "NIKA-145"),
(AnalyzeErrorKind::UnsupportedFeature, "NIKA-149"),
(AnalyzeErrorKind::InvalidBinding, "NIKA-151"),
];
for (kind, expected_code) in kinds {
let err = AnalyzeError::new(kind, make_span(0, 5), "test message");
let rich = RichAnalyzeError::new(err, "source", "file.yaml");
let code = Diagnostic::code(&rich).unwrap();
let code_str = format!("{}", code);
assert!(
code_str.contains(&expected_code.to_lowercase().replace('-', "_")),
"Expected code {} in {}, got {}",
expected_code,
kind.code(),
code_str
);
}
}
}