#![allow(
clippy::allow_attributes,
clippy::allow_attributes_without_reason,
unused_assignments
)]
use super::{ManifestName, ManifestSource};
use crate::localization::{self, LocalizedMessage, keys};
use crate::manifest::hints::YAML_HINTS;
use miette::{Diagnostic, NamedSource, SourceSpan};
use serde_saphyr::{Error as YamlError, Location};
use thiserror::Error;
fn location_to_index(src: &ManifestSource, loc: Location) -> usize {
byte_index(src, loc)
}
fn byte_index(src: &ManifestSource, loc: Location) -> usize {
byte_index_components(src.as_ref(), loc.line(), loc.column())
}
fn byte_index_components(src: &str, line: u64, column: u64) -> usize {
let target_line = usize::try_from(line.saturating_sub(1)).unwrap_or(usize::MAX);
let target_column = usize::try_from(column.saturating_sub(1)).unwrap_or(usize::MAX);
let mut offset = 0usize;
for (idx, segment) in src.split_inclusive('\n').enumerate() {
if idx == target_line {
let without_newline = segment.strip_suffix('\n').unwrap_or(segment);
let cleaned_line = without_newline
.strip_suffix('\r')
.unwrap_or(without_newline);
let column_offset = cleaned_line
.char_indices()
.nth(target_column)
.map_or(cleaned_line.len(), |(byte_idx, _)| byte_idx);
return offset + column_offset;
}
offset += segment.len();
}
src.len()
}
fn to_span(src: &ManifestSource, loc: Location) -> SourceSpan {
let at = location_to_index(src, loc);
let bytes = src.as_ref().as_bytes();
let is_line_break = |b: u8| b == b'\n' || b == b'\r';
let (start, end) = match bytes.get(at) {
Some(&b) if !is_line_break(b) => (at, at + 1),
_ => {
let start = if at > 0 && bytes.get(at - 1).is_some_and(|p| !is_line_break(*p)) {
at - 1
} else {
at
};
(start, at)
}
};
let len = end.saturating_sub(start);
#[expect(clippy::useless_conversion, reason = "future-proof span length type")]
SourceSpan::new(start.into(), len.into())
}
#[derive(Debug, Error, Diagnostic)]
#[error("{message}")]
#[diagnostic(code(netsuke::yaml::parse))]
struct YamlDiagnostic {
#[source_code]
src: NamedSource<String>,
#[label("{label}")]
span: Option<SourceSpan>,
label: LocalizedMessage,
#[help]
help: Option<LocalizedMessage>,
#[source]
source: YamlError,
message: LocalizedMessage,
}
fn has_tab_indent(src: &ManifestSource, location: Option<Location>) -> bool {
let Some(actual_loc) = location else {
return false;
};
let line_idx = usize::try_from(actual_loc.line().saturating_sub(1)).unwrap_or(usize::MAX);
let line = src.as_ref().lines().nth(line_idx).unwrap_or("");
line.chars()
.take_while(|c| c.is_whitespace())
.any(|c| c == '\t')
}
fn hint_for(
err_str: &str,
src: &ManifestSource,
loc: Option<Location>,
) -> Option<LocalizedMessage> {
if has_tab_indent(src, loc) {
return Some(localization::message(keys::MANIFEST_YAML_HINT_TABS));
}
let lower = err_str.to_lowercase();
YAML_HINTS
.iter()
.find(|(needle, _)| lower.contains(*needle))
.map(|(_, key)| localization::message(key))
}
#[must_use]
pub fn map_yaml_error(
err: YamlError,
src: &ManifestSource,
name: &ManifestName,
) -> Box<dyn Diagnostic + Send + Sync + 'static> {
let loc = err.location();
let (line, col, span) = loc.map_or((1, 1, None), |l| {
(l.line(), l.column(), Some(to_span(src, l)))
});
let err_str = err.to_string();
let hint = hint_for(&err_str, src, loc);
let message = localization::message(keys::MANIFEST_YAML_PARSE)
.with_arg("line", line)
.with_arg("column", col)
.with_arg("details", err_str);
Box::new(YamlDiagnostic {
src: NamedSource::new(name.as_ref(), src.as_ref().to_owned()),
span,
label: localization::message(keys::MANIFEST_YAML_LABEL),
help: hint,
source: err,
message,
})
}
#[cfg(test)]
fn expected_offset(src: &str, column: u64) -> usize {
src.chars()
.take(usize::try_from(column.saturating_sub(1)).unwrap_or(usize::MAX))
.map(char::len_utf8)
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::{Context, Result, anyhow, ensure};
use std::error::Error as StdError;
#[test]
fn map_yaml_error_includes_tab_hint() -> Result<()> {
let src = ManifestSource::from("\tkey: \"unterminated");
let Err(err) = serde_saphyr::from_str::<crate::manifest::ManifestValue>(src.as_ref())
else {
return Err(anyhow!(
"expected YAML parse error for source {:?}",
src.as_str()
));
};
let name = ManifestName::from("test");
let diag = map_yaml_error(err, &src, &name);
let yaml_diag = (&*diag as &(dyn StdError + 'static))
.downcast_ref::<YamlDiagnostic>()
.ok_or_else(|| anyhow!("expected YAML diagnostic"))?;
let expected = localization::message(keys::MANIFEST_YAML_HINT_TABS).to_string();
let help = yaml_diag
.help
.as_ref()
.map(|m: &LocalizedMessage| m.to_string())
.unwrap_or_default();
ensure!(help == expected, "message missing tab hint: {help}");
Ok(())
}
#[test]
fn map_yaml_error_defaults_location_when_missing() -> Result<()> {
let src = ManifestSource::from("foo: [1");
let err = serde_saphyr::Error::Eof {
location: serde_saphyr::Location::UNKNOWN,
};
let details = err.to_string();
let name = ManifestName::from("test");
let diag = map_yaml_error(err, &src, &name);
let expected = localization::message(keys::MANIFEST_YAML_PARSE)
.with_arg("line", 1)
.with_arg("column", 1)
.with_arg("details", details)
.to_string();
ensure!(
diag.to_string() == expected,
"diagnostic should default to line 1 column 1"
);
Ok(())
}
#[test]
fn map_yaml_error_span_skips_carriage_return() -> Result<()> {
let src = ManifestSource::from("targets:\r\n - name: hi\r\n command echo\r\n");
let Err(err) = serde_saphyr::from_str::<crate::manifest::ManifestValue>(src.as_ref())
else {
return Err(anyhow!("expected parse error for carriage-return input"));
};
let name = ManifestName::from("test");
let diag = map_yaml_error(err, &src, &name);
let yaml_diag = (&*diag as &(dyn StdError + 'static))
.downcast_ref::<YamlDiagnostic>()
.ok_or_else(|| anyhow!("expected YAML diagnostic"))?;
let span = yaml_diag.span.context("span present")?;
let offset = span.offset();
if let Some(byte) = src.as_ref().as_bytes().get(offset) {
ensure!(*byte != b'\r', "span should skip carriage returns");
}
Ok(())
}
#[test]
fn location_to_index_handles_utf8() -> Result<()> {
let src = ManifestSource::from("café: [\n");
let Err(err) = serde_saphyr::from_str::<crate::manifest::ManifestValue>(src.as_ref())
else {
return Err(anyhow!("expected parse error for UTF-8 test"));
};
let loc = err.location().context("location present")?;
let idx = location_to_index(&src, loc);
ensure!(
src.as_ref().is_char_boundary(idx),
"index {idx} should align to char boundary"
);
let e_idx = src
.as_ref()
.find('é')
.ok_or_else(|| anyhow!("source should contain 'é'"))?;
ensure!(idx > e_idx, "index {idx} must follow é at {e_idx}");
ensure!(
idx <= src.as_ref().len(),
"index {idx} should fall within source length {}",
src.as_ref().len()
);
Ok(())
}
}
#[cfg(test)]
mod byte_index_tests {
use super::{byte_index_components, expected_offset};
#[test]
fn byte_index_accounts_for_multibyte_characters() {
let line = "emoji: 😀value";
let column = 9; let offset = byte_index_components(line, 1, column);
assert_eq!(offset, expected_offset(line, column));
}
#[test]
fn byte_index_clamps_past_line_end() {
let line = "short";
let column = 42;
let offset = byte_index_components(line, 1, column);
assert_eq!(offset, line.len());
}
#[test]
fn byte_index_advances_over_previous_lines() {
let src = "one\ntwo\nthree";
let column = 3; let offset = byte_index_components(src, 3, column);
let expected = "one\ntwo\n".len() + expected_offset("three", column);
assert_eq!(offset, expected);
}
#[test]
fn byte_index_handles_crlf_lines() {
let src = "one\r\ntwo\r\nthree";
let column = 2; let offset = byte_index_components(src, 2, column);
let expected = "one\r\n".len() + expected_offset("two", column);
assert_eq!(offset, expected);
}
}