use super::write_quoted;
use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
use crate::source::SourceSpan;
use crate::syntax::SyntaxDocument;
use std::fmt;
use yaml_edit::{ScalarType, ScalarValue, YamlFile};
pub const EDIT_SOURCE_MISMATCH: DiagnosticCode = DiagnosticCode::new("compose.edit.source-mismatch");
pub const EDIT_TARGET_NOT_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.edit.target-not-scalar");
pub const EDIT_OVERLAP: DiagnosticCode = DiagnosticCode::new("compose.edit.overlap");
pub const EDIT_UNSUPPORTED_SCALAR_STYLE: DiagnosticCode = DiagnosticCode::new("compose.edit.unsupported-scalar-style");
pub const EDIT_INVALID_NUMBER: DiagnosticCode = DiagnosticCode::new("compose.edit.invalid-number");
#[derive(Clone, PartialEq, Eq)]
pub struct ReplacementScalar {
kind: ReplacementKind,
sensitive: bool,
}
#[derive(Clone, PartialEq, Eq)]
enum ReplacementKind {
String(String),
Boolean(bool),
Number(String),
Null,
}
impl ReplacementScalar {
#[must_use]
pub fn string(value: impl Into<String>) -> Self {
Self {
kind: ReplacementKind::String(value.into()),
sensitive: false,
}
}
#[must_use]
pub fn sensitive_string(value: impl Into<String>) -> Self {
Self {
kind: ReplacementKind::String(value.into()),
sensitive: true,
}
}
#[must_use]
pub const fn boolean(value: bool) -> Self {
Self {
kind: ReplacementKind::Boolean(value),
sensitive: false,
}
}
#[must_use]
pub fn number(value: impl Into<String>) -> Self {
Self {
kind: ReplacementKind::Number(value.into()),
sensitive: false,
}
}
#[must_use]
pub const fn null() -> Self {
Self {
kind: ReplacementKind::Null,
sensitive: false,
}
}
#[must_use]
pub const fn is_sensitive(&self) -> bool {
self.sensitive
}
}
impl fmt::Debug for ReplacementScalar {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut debug = formatter.debug_struct("ReplacementScalar");
match &self.kind {
ReplacementKind::String(value) => {
debug.field("kind", &"String");
debug.field("value", &if self.sensitive { "<redacted>" } else { value });
}
ReplacementKind::Boolean(value) => {
debug.field("kind", &"Boolean");
debug.field("value", value);
}
ReplacementKind::Number(value) => {
debug.field("kind", &"Number");
debug.field("value", value);
}
ReplacementKind::Null => {
debug.field("kind", &"Null");
}
}
debug.field("sensitive", &self.sensitive).finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ScalarEdit {
span: SourceSpan,
replacement: ReplacementScalar,
}
impl ScalarEdit {
#[must_use]
pub const fn new(span: SourceSpan, replacement: ReplacementScalar) -> Self {
Self { span, replacement }
}
#[must_use]
pub const fn span(&self) -> SourceSpan {
self.span
}
#[must_use]
pub const fn replacement(&self) -> &ReplacementScalar {
&self.replacement
}
}
impl fmt::Debug for ScalarEdit {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ScalarEdit")
.field("span", &self.span)
.field("replacement", &self.replacement)
.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct PreservationEditResult {
output: String,
diagnostics: Vec<Diagnostic>,
sensitive: bool,
}
impl PreservationEditResult {
#[must_use]
pub fn output(&self) -> &str {
&self.output
}
#[must_use]
pub fn into_output(self) -> String {
self.output
}
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
#[must_use]
pub fn is_valid(&self) -> bool {
self.diagnostics
.iter()
.all(|diagnostic| diagnostic.severity() != Severity::Error)
}
#[must_use]
pub const fn is_sensitive(&self) -> bool {
self.sensitive
}
}
impl fmt::Debug for PreservationEditResult {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PreservationEditResult")
.field(
"output",
&if self.sensitive {
"<redacted>"
} else {
self.output.as_str()
},
)
.field("diagnostics", &self.diagnostics)
.field("sensitive", &self.sensitive)
.finish()
}
}
#[must_use]
pub fn apply_preservation_edits(document: &SyntaxDocument, edits: &[ScalarEdit]) -> PreservationEditResult {
let editable = document.editable_value_scalars();
let mut diagnostics = Vec::new();
let mut planned = Vec::new();
for edit in edits {
if edit.span.source_id() != document.source_id() {
diagnostics.push(error(
EDIT_SOURCE_MISMATCH,
edit.span,
"edit target belongs to another source document",
"source does not match the edited document",
));
continue;
}
let Some(target) = editable.iter().find(|target| target.span == edit.span) else {
diagnostics.push(error(
EDIT_TARGET_NOT_SCALAR,
edit.span,
"edit target is not exactly one YAML value scalar",
"expected an exact value-scalar span",
));
continue;
};
if unsupported_style(&target.raw) {
diagnostics.push(error(
EDIT_UNSUPPORTED_SCALAR_STYLE,
edit.span,
"authored scalar style is not supported for preservation editing",
"block and multiline scalars require a dedicated edit operation",
));
continue;
}
let Some(replacement) = render_replacement(&edit.replacement, &target.raw) else {
diagnostics.push(error(
EDIT_INVALID_NUMBER,
edit.span,
"numeric replacement is not one complete YAML number scalar",
"invalid numeric replacement",
));
continue;
};
planned.push(PlannedEdit {
span: edit.span,
replacement,
sensitive: edit.replacement.sensitive,
});
}
planned.sort_by_key(|edit| (edit.span.start(), edit.span.end()));
for pair in planned.windows(2) {
if pair[1].span.start() < pair[0].span.end() || pair[1].span == pair[0].span {
diagnostics.push(
Diagnostic::new(
EDIT_OVERLAP,
Severity::Error,
"preservation edits must target disjoint source ranges",
)
.with_label(DiagnosticLabel::primary(pair[1].span, "overlapping edit"))
.with_label(DiagnosticLabel::secondary(pair[0].span, "earlier edit")),
);
}
}
if diagnostics
.iter()
.any(|diagnostic| diagnostic.severity() == Severity::Error)
{
return PreservationEditResult {
output: document.source_text().to_owned(),
diagnostics,
sensitive: false,
};
}
let sensitive = planned.iter().any(|edit| edit.sensitive);
let mut output = document.source_text().to_owned();
for edit in planned.iter().rev() {
output.replace_range(edit.span.range(), &edit.replacement);
}
PreservationEditResult {
output,
diagnostics,
sensitive,
}
}
#[derive(Debug)]
struct PlannedEdit {
span: SourceSpan,
replacement: String,
sensitive: bool,
}
fn error(code: DiagnosticCode, span: SourceSpan, message: &'static str, label: &'static str) -> Diagnostic {
Diagnostic::new(code, Severity::Error, message).with_label(DiagnosticLabel::primary(span, label))
}
fn unsupported_style(raw: &str) -> bool {
raw.starts_with('|') || raw.starts_with('>') || raw.contains(['\n', '\r'])
}
fn render_replacement(replacement: &ReplacementScalar, authored: &str) -> Option<String> {
match &replacement.kind {
ReplacementKind::String(value) => Some(render_string(value, authored)),
ReplacementKind::Boolean(value) => Some(value.to_string()),
ReplacementKind::Number(value) if scalar_has_type(value, &[ScalarType::Integer, ScalarType::Float]) => {
Some(value.clone())
}
ReplacementKind::Number(_) => None,
ReplacementKind::Null => Some("null".to_owned()),
}
}
fn render_string(value: &str, authored: &str) -> String {
if authored.starts_with('"') {
return double_quoted(value);
}
if authored.starts_with('\'') && single_quoted_safe(value) {
return format!("'{}'", value.replace('\'', "''"));
}
if plain_string_safe(value) {
return value.to_owned();
}
double_quoted(value)
}
fn single_quoted_safe(value: &str) -> bool {
!value
.chars()
.any(|character| character.is_control() || matches!(character, '\u{85}' | '\u{2028}' | '\u{2029}' | '\u{feff}'))
}
fn plain_string_safe(value: &str) -> bool {
!value.is_empty() && !value.contains(['\n', '\r']) && scalar_has_type(value, &[ScalarType::String])
}
fn scalar_has_type(value: &str, expected: &[ScalarType]) -> bool {
let parse = YamlFile::parse(value);
if !parse.ok() {
return false;
}
let file = parse.tree();
let Some(document) = file.document() else {
return false;
};
let Some(scalar) = document.as_scalar() else {
return false;
};
let position = scalar.byte_range();
position.start == 0
&& position.end as usize == value.len()
&& expected.contains(&ScalarValue::from_scalar(&scalar).scalar_type())
}
fn double_quoted(value: &str) -> String {
let mut output = String::new();
write_quoted(&mut output, value);
output
}