use ratatui::style::{Color, Modifier, Style};
use crate::ast::analyzer::{analyze, AnalyzeError};
use crate::ast::raw::{self, ParseError};
use crate::source::FileId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticSeverity {
Error,
Warning,
Hint,
}
impl DiagnosticSeverity {
pub fn gutter_icon(&self) -> &'static str {
match self {
DiagnosticSeverity::Error => "●",
DiagnosticSeverity::Warning => "▲",
DiagnosticSeverity::Hint => "◆",
}
}
pub fn style(&self) -> Style {
match self {
DiagnosticSeverity::Error => Style::default()
.fg(Color::Rgb(220, 50, 47)) .add_modifier(Modifier::BOLD),
DiagnosticSeverity::Warning => Style::default()
.fg(Color::Rgb(203, 75, 22)) .add_modifier(Modifier::BOLD),
DiagnosticSeverity::Hint => Style::default()
.fg(Color::Rgb(38, 139, 210)) .add_modifier(Modifier::ITALIC),
}
}
pub fn underline_style(&self) -> Style {
match self {
DiagnosticSeverity::Error => Style::default()
.fg(Color::Rgb(220, 50, 47))
.add_modifier(Modifier::UNDERLINED),
DiagnosticSeverity::Warning => Style::default()
.fg(Color::Rgb(203, 75, 22))
.add_modifier(Modifier::UNDERLINED),
DiagnosticSeverity::Hint => Style::default()
.fg(Color::Rgb(38, 139, 210))
.add_modifier(Modifier::UNDERLINED),
}
}
}
#[derive(Debug, Clone)]
pub struct TuiDiagnostic {
pub code: String,
pub message: String,
pub severity: DiagnosticSeverity,
pub start_line: usize,
pub start_col: usize,
pub end_line: usize,
pub end_col: usize,
}
impl TuiDiagnostic {
pub fn from_analyze_error(error: &AnalyzeError, source: &str) -> Self {
let (start_line, start_col) = offset_to_line_col(error.span.start.into(), source);
let (end_line, end_col) = offset_to_line_col(error.span.end.into(), source);
Self {
code: error.kind.code().to_string(),
message: error.message.clone(),
severity: DiagnosticSeverity::Error, start_line,
start_col,
end_line,
end_col,
}
}
pub fn from_parse_error(error: &ParseError, source: &str) -> Self {
let (start_line, start_col) = offset_to_line_col(error.span.start.into(), source);
let (end_line, end_col) = offset_to_line_col(error.span.end.into(), source);
Self {
code: error.kind.code().to_string(),
message: error.message.clone(),
severity: DiagnosticSeverity::Error,
start_line,
start_col,
end_line,
end_col,
}
}
pub fn affects_line(&self, line: usize) -> bool {
line >= self.start_line && line <= self.end_line
}
pub fn column_range_for_line(&self, line: usize) -> Option<(usize, usize)> {
if !self.affects_line(line) {
return None;
}
let start = if line == self.start_line {
self.start_col
} else {
0
};
let end = if line == self.end_line {
self.end_col
} else {
usize::MAX };
Some((start, end))
}
pub fn display_message(&self) -> String {
format!("[{}] {}", self.code, self.message)
}
pub fn status_message(&self) -> String {
format!(
"[{}] line {}:{} - {}",
self.code,
self.start_line + 1,
self.start_col + 1,
self.message
)
}
}
fn offset_to_line_col(offset: usize, source: &str) -> (usize, usize) {
let mut line = 0;
let mut col = 0;
for (i, ch) in source.char_indices() {
if i >= offset {
break;
}
if ch == '\n' {
line += 1;
col = 0;
} else {
col += 1;
}
}
(line, col)
}
#[derive(Debug, Default)]
pub struct DiagnosticsEngine {
diagnostics: Vec<TuiDiagnostic>,
last_text_hash: u64,
}
impl DiagnosticsEngine {
pub fn new() -> Self {
Self {
diagnostics: Vec::new(),
last_text_hash: 0,
}
}
pub fn analyze(&mut self, source: &str) -> bool {
let hash = xxhash_rust::xxh3::xxh3_64(source.as_bytes());
if hash == self.last_text_hash {
return false;
}
self.last_text_hash = hash;
self.diagnostics.clear();
let file_id = FileId(0);
match raw::parse(source, file_id) {
Ok(raw_workflow) => {
let result = analyze(raw_workflow);
for error in &result.errors {
self.diagnostics
.push(TuiDiagnostic::from_analyze_error(error, source));
}
}
Err(parse_error) => {
self.diagnostics
.push(TuiDiagnostic::from_parse_error(&parse_error, source));
}
}
true
}
pub fn diagnostics(&self) -> &[TuiDiagnostic] {
&self.diagnostics
}
pub fn diagnostics_for_line(&self, line: usize) -> Vec<&TuiDiagnostic> {
self.diagnostics
.iter()
.filter(|d| d.affects_line(line))
.collect()
}
pub fn has_diagnostics_on_line(&self, line: usize) -> bool {
self.diagnostics.iter().any(|d| d.affects_line(line))
}
pub fn most_severe_on_line(&self, line: usize) -> Option<&TuiDiagnostic> {
self.diagnostics_for_line(line)
.into_iter()
.min_by_key(|d| match d.severity {
DiagnosticSeverity::Error => 0,
DiagnosticSeverity::Warning => 1,
DiagnosticSeverity::Hint => 2,
})
}
pub fn error_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == DiagnosticSeverity::Error)
.count()
}
pub fn warning_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == DiagnosticSeverity::Warning)
.count()
}
pub fn clear(&mut self) {
self.diagnostics.clear();
self.last_text_hash = 0;
}
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.severity == DiagnosticSeverity::Error)
}
pub fn first_error(&self) -> Option<&TuiDiagnostic> {
self.diagnostics
.iter()
.find(|d| d.severity == DiagnosticSeverity::Error)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_offset_to_line_col() {
let source = "line1\nline2\nline3";
assert_eq!(offset_to_line_col(0, source), (0, 0));
assert_eq!(offset_to_line_col(3, source), (0, 3));
assert_eq!(offset_to_line_col(6, source), (1, 0)); assert_eq!(offset_to_line_col(8, source), (1, 2)); assert_eq!(offset_to_line_col(12, source), (2, 0)); }
#[test]
fn test_diagnostic_affects_line() {
let diag = TuiDiagnostic {
code: "NIKA-140".to_string(),
message: "Test error".to_string(),
severity: DiagnosticSeverity::Error,
start_line: 2,
start_col: 5,
end_line: 4,
end_col: 10,
};
assert!(!diag.affects_line(0));
assert!(!diag.affects_line(1));
assert!(diag.affects_line(2));
assert!(diag.affects_line(3));
assert!(diag.affects_line(4));
assert!(!diag.affects_line(5));
}
#[test]
fn test_diagnostic_column_range() {
let diag = TuiDiagnostic {
code: "NIKA-140".to_string(),
message: "Test error".to_string(),
severity: DiagnosticSeverity::Error,
start_line: 2,
start_col: 5,
end_line: 4,
end_col: 10,
};
assert_eq!(diag.column_range_for_line(1), None);
assert_eq!(diag.column_range_for_line(2), Some((5, usize::MAX)));
assert_eq!(diag.column_range_for_line(3), Some((0, usize::MAX)));
assert_eq!(diag.column_range_for_line(4), Some((0, 10)));
assert_eq!(diag.column_range_for_line(5), None);
}
#[test]
fn test_single_line_diagnostic_column_range() {
let diag = TuiDiagnostic {
code: "NIKA-140".to_string(),
message: "Test error".to_string(),
severity: DiagnosticSeverity::Error,
start_line: 3,
start_col: 5,
end_line: 3,
end_col: 15,
};
assert_eq!(diag.column_range_for_line(3), Some((5, 15)));
}
#[test]
fn test_diagnostics_engine_empty() {
let engine = DiagnosticsEngine::new();
assert!(engine.diagnostics().is_empty());
assert!(!engine.has_errors());
assert_eq!(engine.error_count(), 0);
}
#[test]
fn test_diagnostics_engine_valid_workflow() {
let mut engine = DiagnosticsEngine::new();
let yaml = r#"schema: nika/workflow@0.12
workflow: test
tasks:
- id: step1
infer: "Hello"
"#;
engine.analyze(yaml);
assert!(
!engine.has_errors(),
"Should have no errors: {:?}",
engine.diagnostics()
);
}
#[test]
fn test_diagnostics_engine_duplicate_task() {
let mut engine = DiagnosticsEngine::new();
let yaml = r#"schema: nika/workflow@0.12
workflow: test
tasks:
- id: step1
infer: "Hello"
- id: step1
exec: "echo duplicate"
"#;
engine.analyze(yaml);
assert!(engine.has_errors());
assert!(engine.diagnostics().iter().any(|d| d.code == "NIKA-141"));
}
#[test]
fn test_diagnostics_engine_parse_error() {
let mut engine = DiagnosticsEngine::new();
let yaml = "schema: nika/workflow@0.12\ntasks: [unclosed";
engine.analyze(yaml);
assert!(engine.has_errors());
assert!(engine.diagnostics().iter().any(|d| d.code == "NIKA-160"));
}
#[test]
fn test_severity_styles() {
let error_style = DiagnosticSeverity::Error.style();
let warning_style = DiagnosticSeverity::Warning.style();
let hint_style = DiagnosticSeverity::Hint.style();
assert!(error_style.fg.is_some());
assert!(warning_style.fg.is_some());
assert!(hint_style.fg.is_some());
}
#[test]
fn test_gutter_icons() {
assert_eq!(DiagnosticSeverity::Error.gutter_icon(), "●");
assert_eq!(DiagnosticSeverity::Warning.gutter_icon(), "▲");
assert_eq!(DiagnosticSeverity::Hint.gutter_icon(), "◆");
}
#[test]
fn test_diagnostics_engine_caching() {
let mut engine = DiagnosticsEngine::new();
let yaml = r#"schema: nika/workflow@0.12
workflow: test
tasks:
- id: step1
infer: "Hello"
"#;
let changed1 = engine.analyze(yaml);
assert!(changed1);
let changed2 = engine.analyze(yaml);
assert!(!changed2);
let changed3 = engine.analyze("schema: nika/workflow@0.12\n");
assert!(changed3);
}
#[test]
fn test_most_severe_on_line() {
let mut engine = DiagnosticsEngine::new();
let yaml = r#"schema: nika/workflow@0.12
workflow: test
tasks:
- id: step1
infer: "Hello"
"#;
engine.analyze(yaml);
assert!(engine.most_severe_on_line(0).is_none());
}
}