yerba 0.8.1-arm-linux-gnu → 0.9.0-arm-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,13 +8,13 @@ use super::{output, parse_file, resolve_files, run_op};
8
8
 
9
9
  static EXAMPLES: LazyLock<String> = LazyLock::new(|| {
10
10
  colorize_examples(indoc! {r#"
11
- yerba insert config.yml "database.ssl" true
12
- yerba insert config.yml "database.ssl" true --after "host"
13
- yerba insert config.yml "database.ssl" true --before "port"
11
+ yerba insert config.yml "database.ssl" true --plain
12
+ yerba insert config.yml "database.ssl" true --plain --after "host"
13
+ yerba insert config.yml "database.ssl" true --plain --before "port"
14
14
  yerba insert config.yml "tags" "yaml"
15
15
  yerba insert config.yml "tags" "yaml" --at 0
16
16
  yerba insert config.yml "tags" "yaml" --after "ruby"
17
- yerba insert speakers.yml "" "name: Bob" --after ".name == Alice"
17
+ yerba insert speakers.yml "" "name: Bob" --plain --after ".name == Alice"
18
18
  yerba insert videos.yml "[0].speakers" "Diana" --before ".name == Charlie"
19
19
  yerba insert videos.yml "" --from "new_talk.yml" --after ".id == first-talk"
20
20
  "#})
@@ -39,6 +39,8 @@ pub struct Args {
39
39
  after: Option<String>,
40
40
  #[arg(long)]
41
41
  at: Option<usize>,
42
+ #[arg(long, help = "Write the value as raw YAML, for literals like null, true or 42 and for map or sequence fragments")]
43
+ plain: bool,
42
44
  #[arg(long)]
43
45
  dry_run: bool,
44
46
  }
@@ -67,8 +69,12 @@ impl Args {
67
69
  .trim()
68
70
  .to_string()
69
71
  }
70
- } else if let Some(val) = self.value {
71
- val
72
+ } else if let Some(value) = self.value {
73
+ if self.plain {
74
+ value
75
+ } else {
76
+ yerba::quote_scalar(&value)
77
+ }
72
78
  } else {
73
79
  eprintln!("{} either a value argument or --from is required", ui::failure("Error:"));
74
80
 
@@ -188,11 +188,10 @@ pub(crate) fn run_yerbafile(write: bool, files: Vec<String>) {
188
188
  process::exit(1);
189
189
  });
190
190
 
191
- eprintln!("{}", ui::banner());
191
+ eprintln!("\n{}\n", ui::banner());
192
192
 
193
193
  eprintln!(
194
- "{}{} {}\n",
195
- ui::INDENT,
194
+ "{} {}\n",
196
195
  ui::strong("Using Yerbafile rules"),
197
196
  ui::subtle(format!("(from {})", yerbafile_path.display()))
198
197
  );
@@ -11,8 +11,11 @@ static EXAMPLES: LazyLock<String> = LazyLock::new(|| {
11
11
  yerba set config.yml "database.host" "0.0.0.0" --if-exists
12
12
  yerba set config.yml "database.host" "0.0.0.0" --condition ".port == 5432"
13
13
  yerba set videos.yml "[0].title" "New Title"
14
+ yerba set videos.yml "[].title" "New Title" --condition ".id == talk-1"
14
15
  yerba set "data/**/event.yml" "website" "" --if-exists
15
16
  yerba set videos.yml "[].description" "" --all
17
+ yerba set config.yml "database.replica" null --plain
18
+ yerba set videos.yml "[].published" false --plain --all
16
19
  "#})
17
20
  });
18
21
 
@@ -35,6 +38,8 @@ pub struct Args {
35
38
  condition: Option<String>,
36
39
  #[arg(long)]
37
40
  all: bool,
41
+ #[arg(long, help = "Write the value unquoted, for YAML literals like null, true or 42")]
42
+ plain: bool,
38
43
  #[arg(long)]
39
44
  dry_run: bool,
40
45
  }
@@ -53,6 +58,9 @@ impl Args {
53
58
  }
54
59
  }
55
60
 
61
+ let leaf = self.selector.rsplit_once('.').map(|(_, leaf)| leaf).unwrap_or(&self.selector);
62
+ let per_item = yerba::Selector::parse(parent_path).has_brackets();
63
+
56
64
  for resolved_file in resolve_files(&self.file) {
57
65
  let mut document = parse_file(&resolved_file);
58
66
 
@@ -61,16 +69,18 @@ impl Args {
61
69
  } else if self.if_missing {
62
70
  !document.exists(&self.selector)
63
71
  } else if let Some(condition) = &self.condition {
64
- document.evaluate_condition(parent_path, condition).unwrap_or(false)
72
+ per_item || document.evaluate_condition(parent_path, condition).unwrap_or(false)
65
73
  } else {
66
74
  true
67
75
  };
68
76
 
69
77
  if should_set {
70
- let result = if self.all {
71
- document.set_all(&self.selector, &self.value)
72
- } else {
73
- document.set(&self.selector, &self.value)
78
+ let result = match &self.condition {
79
+ Some(condition) if per_item => document.set_where(parent_path, leaf, &self.value, condition, self.all, self.plain).map(|_| ()),
80
+ _ if self.all && self.plain => document.set_all_plain(&self.selector, &self.value),
81
+ _ if self.all => document.set_all(&self.selector, &self.value),
82
+ _ if self.plain => document.set_plain(&self.selector, &self.value),
83
+ _ => document.set(&self.selector, &self.value),
74
84
  };
75
85
 
76
86
  run_op_with_hint(
@@ -110,10 +110,9 @@ pub fn strong(text: impl std::fmt::Display) -> String {
110
110
  }
111
111
 
112
112
  pub fn banner() -> String {
113
- format!("🧉 {} {}", Style::fg(GREEN).bold().paint("yerba"), subtle(format!("v{}", yerba::version())))
113
+ format!("{} 🧉 {}", strong("Yerba"), subtle(format!("v{}", yerba::version())))
114
114
  }
115
115
 
116
- pub const INDENT: &str = " ";
117
116
  pub const STATUS_INDENT: &str = " ";
118
117
 
119
118
  pub mod glyph {
@@ -3,7 +3,7 @@ use super::ui;
3
3
  pub fn run() {
4
4
  println!("{}", ui::banner());
5
5
  println!(
6
- " {}{} {}{} {}{} {}{} {}{}",
6
+ "{}{} {}{} {}{} {}{} {}{}",
7
7
  ui::strong("Y"),
8
8
  ui::subtle("AML"),
9
9
  ui::strong("E"),
@@ -89,6 +89,14 @@ impl Document {
89
89
  }
90
90
 
91
91
  pub fn insert_into(&mut self, dot_path: &str, value: &str, position: InsertPosition) -> Result<(), YerbaError> {
92
+ self.insert_with(dot_path, value, position, false)
93
+ }
94
+
95
+ pub fn insert_fragment_into(&mut self, dot_path: &str, value: &str, position: InsertPosition) -> Result<(), YerbaError> {
96
+ self.insert_with(dot_path, value, position, true)
97
+ }
98
+
99
+ fn insert_with(&mut self, dot_path: &str, value: &str, position: InsertPosition, fragment: bool) -> Result<(), YerbaError> {
92
100
  Self::validate_path(dot_path)?;
93
101
  self.refuse_flow_target(dot_path)?;
94
102
 
@@ -180,7 +188,7 @@ impl Document {
180
188
  };
181
189
 
182
190
  let indent = " ".repeat(start_col);
183
- let new_entry_text = Self::format_map_entry(key, value, &indent);
191
+ let new_entry_text = Self::format_map_entry(key, value, &indent, fragment);
184
192
 
185
193
  match &position {
186
194
  InsertPosition::After(target_key) => {
@@ -218,7 +226,7 @@ impl Document {
218
226
  return Ok(());
219
227
  }
220
228
 
221
- self.insert_map_key(parent_path, key, value, position)
229
+ self.insert_map_key(parent_path, key, value, position, fragment)
222
230
  }
223
231
 
224
232
  fn insert_sequence_item(&mut self, dot_path: &str, value: &str, position: InsertPosition) -> Result<(), YerbaError> {
@@ -339,7 +347,7 @@ impl Document {
339
347
  }
340
348
  }
341
349
 
342
- fn insert_map_key(&mut self, dot_path: &str, key: &str, value: &str, position: InsertPosition) -> Result<(), YerbaError> {
350
+ fn insert_map_key(&mut self, dot_path: &str, key: &str, value: &str, position: InsertPosition, fragment: bool) -> Result<(), YerbaError> {
343
351
  let current_node = self.navigate(dot_path)?;
344
352
 
345
353
  let map = match find_block_map(&current_node) {
@@ -378,7 +386,7 @@ impl Document {
378
386
  .map(|entry| preceding_whitespace_indent(entry.syntax()))
379
387
  .unwrap_or_default();
380
388
 
381
- let new_entry_text = Self::format_map_entry(key, value, &indent);
389
+ let new_entry_text = Self::format_map_entry(key, value, &indent, fragment);
382
390
 
383
391
  match position {
384
392
  InsertPosition::Last => {
@@ -422,7 +430,7 @@ impl Document {
422
430
  self.insert_after_node(target_entry.syntax(), &new_text)
423
431
  }
424
432
 
425
- InsertPosition::BeforeCondition(_) | InsertPosition::AfterCondition(_) => self.insert_map_key(dot_path, key, value, InsertPosition::Last),
433
+ InsertPosition::BeforeCondition(_) | InsertPosition::AfterCondition(_) => self.insert_map_key(dot_path, key, value, InsertPosition::Last, fragment),
426
434
 
427
435
  InsertPosition::FromSortOrder(order) => {
428
436
  let new_key_position = order.iter().position(|ordered_key| ordered_key == key);
@@ -447,7 +455,7 @@ impl Document {
447
455
  None => InsertPosition::Last,
448
456
  };
449
457
 
450
- self.insert_map_key(dot_path, key, value, resolved)
458
+ self.insert_map_key(dot_path, key, value, resolved, fragment)
451
459
  }
452
460
  }
453
461
  }
@@ -566,15 +574,15 @@ impl Document {
566
574
  Some((trailing[comment_start..].to_string(), rowan::TextSize::from((start + line_end) as u32)))
567
575
  }
568
576
 
569
- fn format_map_entry(key: &str, value: &str, indent: &str) -> String {
570
- let is_block_value = value.contains('\n') || value.starts_with("- ");
577
+ fn format_map_entry(key: &str, value: &str, indent: &str, fragment: bool) -> String {
578
+ let is_block_value = value.contains('\n') || value.starts_with("- ") || (fragment && is_block_collection(value));
571
579
 
572
580
  if !is_block_value {
573
- if !crate::syntax::is_valid_inline_value(value) {
574
- return format!("{}: {}", key, crate::syntax::quote_if_needed(value));
581
+ if crate::syntax::is_inline_scalar_safe(value) {
582
+ return format!("{}: {}", key, value);
575
583
  }
576
584
 
577
- return format!("{}: {}", key, value);
585
+ return format!("{}: {}", key, crate::syntax::quote_scalar(value));
578
586
  }
579
587
 
580
588
  let value_indent = format!("{} ", indent);
@@ -636,3 +644,7 @@ impl Document {
636
644
  }
637
645
  }
638
646
  }
647
+
648
+ fn is_block_collection(value: &str) -> bool {
649
+ !crate::syntax::is_flow_collection(value) && !crate::syntax::is_quoted_scalar(value) && !crate::syntax::is_plain_safe(value)
650
+ }
@@ -1,7 +1,17 @@
1
1
  use super::*;
2
2
 
3
+ fn is_plain_writable(value: &str) -> bool {
4
+ crate::syntax::is_plain_safe(value) && !crate::syntax::is_yaml_non_string(value)
5
+ }
6
+
3
7
  fn scalar_replacement_text(value: &str, kind: SyntaxKind) -> String {
4
- if kind == SyntaxKind::PLAIN_SCALAR && !crate::syntax::is_inline_scalar_safe(value) {
8
+ let representable = match kind {
9
+ SyntaxKind::PLAIN_SCALAR => is_plain_writable(value),
10
+ SyntaxKind::SINGLE_QUOTED_SCALAR => crate::syntax::is_single_quotable(value),
11
+ _ => true,
12
+ };
13
+
14
+ if !representable {
5
15
  return format_scalar_value(value, SyntaxKind::DOUBLE_QUOTED_SCALAR);
6
16
  }
7
17
 
@@ -62,6 +72,34 @@ fn value_span(node: &SyntaxNode) -> Option<ValueSpan> {
62
72
  })
63
73
  }
64
74
 
75
+ fn scalar_edit(source: &str, node: &SyntaxNode, value: &str, plain: bool) -> Option<(TextRange, String)> {
76
+ if let Some(block_scalar) = node.descendants().find(|child| child.kind() == SyntaxKind::BLOCK_SCALAR) {
77
+ let replacement = if plain {
78
+ value.to_string()
79
+ } else {
80
+ Document::block_scalar_replacement(source, &block_scalar, value)
81
+ };
82
+
83
+ return Some((block_scalar.text_range(), replacement));
84
+ }
85
+
86
+ if holds_collection(node) {
87
+ let replacement = if plain { value.to_string() } else { crate::syntax::quote_scalar(value) };
88
+
89
+ return value_span(node).map(|span| (span.range, replacement_text(&span, &replacement)));
90
+ }
91
+
92
+ let scalar_token = find_scalar_token(node)?;
93
+
94
+ let replacement = if plain {
95
+ value.to_string()
96
+ } else {
97
+ scalar_replacement_text(value, scalar_token.kind())
98
+ };
99
+
100
+ Some((scalar_token.text_range(), replacement))
101
+ }
102
+
65
103
  fn replacement_text(span: &ValueSpan, value: &str) -> String {
66
104
  let mut text = if span.separated { format!(" {}", value) } else { value.to_string() };
67
105
 
@@ -75,51 +113,70 @@ fn replacement_text(span: &ValueSpan, value: &str) -> String {
75
113
 
76
114
  impl Document {
77
115
  pub fn set(&mut self, dot_path: &str, value: &str) -> Result<(), YerbaError> {
78
- let current_node = self.navigate(dot_path)?;
116
+ self.set_with(dot_path, value, false)
117
+ }
79
118
 
80
- if let Some(block_scalar) = current_node.descendants().find(|node| node.kind() == SyntaxKind::BLOCK_SCALAR) {
81
- let source = self.source_text();
82
- let new_text = Self::block_scalar_replacement(&source, &block_scalar, value);
119
+ pub fn set_all(&mut self, dot_path: &str, value: &str) -> Result<(), YerbaError> {
120
+ self.set_all_with(dot_path, value, false)
121
+ }
83
122
 
84
- return self.apply_edit(block_scalar.text_range(), &new_text);
85
- }
123
+ pub fn set_all_plain(&mut self, dot_path: &str, value: &str) -> Result<(), YerbaError> {
124
+ self.set_all_with(dot_path, value, true)
125
+ }
86
126
 
87
- if holds_collection(&current_node) {
88
- let span = value_span(&current_node).ok_or_else(|| YerbaError::SelectorNotFound(dot_path.to_string()))?;
127
+ fn set_all_with(&mut self, dot_path: &str, value: &str, plain: bool) -> Result<(), YerbaError> {
128
+ let nodes = self.navigate_all_compact(dot_path);
89
129
 
90
- return self.apply_edit(span.range, &replacement_text(&span, value));
130
+ if nodes.is_empty() {
131
+ return Err(YerbaError::SelectorNotFound(dot_path.to_string()));
91
132
  }
92
133
 
93
- let scalar_token = find_scalar_token(&current_node).ok_or_else(|| YerbaError::SelectorNotFound(dot_path.to_string()))?;
94
-
95
- let new_text = scalar_replacement_text(value, scalar_token.kind());
134
+ let source = self.source_text();
135
+ let edits = nodes.iter().filter_map(|node| scalar_edit(&source, node, value, plain)).collect();
96
136
 
97
- self.replace_token(&scalar_token, &new_text)
137
+ self.apply_edits(edits)
98
138
  }
99
139
 
100
- pub fn set_all(&mut self, dot_path: &str, value: &str) -> Result<(), YerbaError> {
101
- let nodes = self.navigate_all_compact(dot_path);
140
+ pub fn set_where(&mut self, container_path: &str, relative_path: &str, value: &str, condition: &str, all: bool, plain: bool) -> Result<usize, YerbaError> {
141
+ validate_item_condition(condition)?;
102
142
 
103
- if nodes.is_empty() {
104
- return Err(YerbaError::SelectorNotFound(dot_path.to_string()));
143
+ let items = self.navigate_all_compact(container_path);
144
+
145
+ if items.is_empty() {
146
+ return Err(YerbaError::SelectorNotFound(container_path.to_string()));
105
147
  }
106
148
 
107
149
  let source = self.source_text();
108
- let mut edits: Vec<(TextRange, String)> = Vec::new();
109
-
110
- for node in nodes {
111
- if let Some(block_scalar) = node.descendants().find(|child| child.kind() == SyntaxKind::BLOCK_SCALAR) {
112
- edits.push((block_scalar.text_range(), Self::block_scalar_replacement(&source, &block_scalar, value)));
113
- } else if holds_collection(&node) {
114
- if let Some(span) = value_span(&node) {
115
- edits.push((span.range, replacement_text(&span, value)));
116
- }
117
- } else if let Some(scalar_token) = find_scalar_token(&node) {
118
- edits.push((scalar_token.text_range(), scalar_replacement_text(value, scalar_token.kind())));
150
+ let mut targets: Vec<SyntaxNode> = Vec::new();
151
+
152
+ for item in &items {
153
+ if !self.evaluate_condition_on_node(item, condition) {
154
+ continue;
119
155
  }
156
+
157
+ targets.extend(navigate_from_node(item, relative_path));
120
158
  }
121
159
 
122
- self.apply_edits(edits)
160
+ if targets.is_empty() {
161
+ return Ok(0);
162
+ }
163
+
164
+ if !all && targets.len() > 1 {
165
+ let selector = if container_path.is_empty() {
166
+ relative_path.to_string()
167
+ } else {
168
+ format!("{}.{}", container_path, relative_path)
169
+ };
170
+
171
+ return Err(YerbaError::AmbiguousSelector(selector, targets.len()));
172
+ }
173
+
174
+ let edits: Vec<(TextRange, String)> = targets.iter().filter_map(|node| scalar_edit(&source, node, value, plain)).collect();
175
+ let count = edits.len();
176
+
177
+ self.apply_edits(edits)?;
178
+
179
+ Ok(count)
123
180
  }
124
181
 
125
182
  pub fn set_scalar_style(&mut self, dot_path: &str, style: &QuoteStyle) -> Result<(), YerbaError> {
@@ -144,22 +201,39 @@ impl Document {
144
201
  }
145
202
 
146
203
  pub fn set_plain(&mut self, dot_path: &str, value: &str) -> Result<(), YerbaError> {
204
+ self.set_with(dot_path, value, true)
205
+ }
206
+
207
+ fn set_with(&mut self, dot_path: &str, value: &str, plain: bool) -> Result<(), YerbaError> {
147
208
  let current_node = self.navigate(dot_path)?;
148
209
 
149
210
  if let Some(block_scalar) = current_node.descendants().find(|node| node.kind() == SyntaxKind::BLOCK_SCALAR) {
150
- let range = block_scalar.text_range();
151
- return self.apply_edit(range, value);
211
+ if plain {
212
+ return self.apply_edit(block_scalar.text_range(), value);
213
+ }
214
+
215
+ let source = self.source_text();
216
+ let new_text = Self::block_scalar_replacement(&source, &block_scalar, value);
217
+
218
+ return self.apply_edit(block_scalar.text_range(), &new_text);
152
219
  }
153
220
 
154
221
  if holds_collection(&current_node) {
155
222
  let span = value_span(&current_node).ok_or_else(|| YerbaError::SelectorNotFound(dot_path.to_string()))?;
223
+ let replacement = if plain { value.to_string() } else { crate::syntax::quote_scalar(value) };
156
224
 
157
- return self.apply_edit(span.range, &replacement_text(&span, value));
225
+ return self.apply_edit(span.range, &replacement_text(&span, &replacement));
158
226
  }
159
227
 
160
228
  let scalar_token = find_scalar_token(&current_node).ok_or_else(|| YerbaError::SelectorNotFound(dot_path.to_string()))?;
161
229
 
162
- self.replace_token(&scalar_token, value)
230
+ if plain {
231
+ return self.replace_token(&scalar_token, value);
232
+ }
233
+
234
+ let new_text = scalar_replacement_text(value, scalar_token.kind());
235
+
236
+ self.replace_token(&scalar_token, &new_text)
163
237
  }
164
238
 
165
239
  fn block_scalar_replacement(source: &str, block_scalar: &SyntaxNode, value: &str) -> String {
@@ -7,6 +7,13 @@ pub struct StyleEnforcement {
7
7
  pub value_style: Option<QuoteStyle>,
8
8
  }
9
9
 
10
+ #[derive(Debug, Clone, Default)]
11
+ pub struct BlankLineOptions {
12
+ pub before: Vec<String>,
13
+ pub after: Vec<String>,
14
+ pub skip_empty: bool,
15
+ }
16
+
10
17
  impl Document {
11
18
  pub fn enforce_styles(&mut self, enforcement: &StyleEnforcement) -> Result<(), YerbaError> {
12
19
  let source = self.source_text();
@@ -588,6 +595,10 @@ impl Document {
588
595
  }
589
596
 
590
597
  pub fn enforce_blank_lines(&mut self, dot_path: &str, blank_lines: usize) -> Result<(), YerbaError> {
598
+ self.enforce_blank_lines_with(dot_path, blank_lines, &BlankLineOptions::default())
599
+ }
600
+
601
+ pub fn enforce_blank_lines_with(&mut self, dot_path: &str, blank_lines: usize, options: &BlankLineOptions) -> Result<(), YerbaError> {
591
602
  let nodes = if dot_path.contains('[') {
592
603
  self.navigate_all_compact(dot_path)
593
604
  } else {
@@ -597,14 +608,44 @@ impl Document {
597
608
  let mut edits: Vec<(TextRange, String)> = Vec::new();
598
609
 
599
610
  for current_node in &nodes {
600
- let entry_nodes: Vec<SyntaxNode> = match first_collection(current_node) {
601
- Some(FirstCollection::Sequence(sequence)) => sequence.entries().map(|entry| entry.syntax().clone()).collect(),
602
- Some(FirstCollection::Map(map)) => map.entries().map(|entry| entry.syntax().clone()).collect(),
611
+ let entries: Vec<BlankLineEntry> = match first_collection(current_node) {
612
+ Some(FirstCollection::Sequence(sequence)) => sequence
613
+ .entries()
614
+ .map(|entry| BlankLineEntry {
615
+ is_empty: is_empty_value(&node_to_yaml_value(entry.syntax())),
616
+ node: entry.syntax().clone(),
617
+ key: None,
618
+ })
619
+ .collect(),
620
+
621
+ Some(FirstCollection::Map(map)) => map
622
+ .entries()
623
+ .map(|entry| {
624
+ let value = entry
625
+ .value()
626
+ .map(|value_node| node_to_yaml_value(value_node.syntax()))
627
+ .unwrap_or(yaml_serde::Value::Null);
628
+
629
+ BlankLineEntry {
630
+ key: entry.key().and_then(|key_node| extract_scalar_text(key_node.syntax())),
631
+ is_empty: is_empty_value(&value),
632
+ node: entry.syntax().clone(),
633
+ }
634
+ })
635
+ .collect(),
636
+
603
637
  None => continue,
604
638
  };
605
639
 
606
- for entry_node in entry_nodes.iter().skip(1) {
607
- collect_blank_line_edits(entry_node, blank_lines, &mut edits);
640
+ for index in 1..entries.len() {
641
+ let entry = &entries[index];
642
+ let previous = &entries[index - 1];
643
+
644
+ if !should_adjust_gap(entry, previous, options) {
645
+ continue;
646
+ }
647
+
648
+ collect_blank_line_edits(&entry.node, blank_lines, &mut edits);
608
649
  }
609
650
  }
610
651
 
@@ -738,6 +779,41 @@ impl Document {
738
779
  self.enforce_quotes_at(style, None)
739
780
  }
740
781
 
782
+ pub fn decode_escapes(&mut self, dot_path: Option<&str>) -> Result<usize, YerbaError> {
783
+ let scope_ranges: Vec<TextRange> = match dot_path {
784
+ Some(path) if !path.is_empty() => self.navigate_all_compact(path).iter().map(|node| node.text_range()).collect(),
785
+ _ => vec![self.root.text_range()],
786
+ };
787
+
788
+ let mut edits: Vec<(TextRange, String)> = Vec::new();
789
+
790
+ for element in self.root.descendants_with_tokens() {
791
+ let Some(token) = element.into_token() else { continue };
792
+
793
+ if token.kind() != SyntaxKind::DOUBLE_QUOTED_SCALAR {
794
+ continue;
795
+ }
796
+
797
+ if !scope_ranges.iter().any(|range| range.contains_range(token.text_range())) {
798
+ continue;
799
+ }
800
+
801
+ let Some(value) = raw_scalar_value(&token) else { continue };
802
+
803
+ let rewritten = format_scalar_value(&value, SyntaxKind::DOUBLE_QUOTED_SCALAR);
804
+
805
+ if rewritten != token.text() {
806
+ edits.push((token.text_range(), rewritten));
807
+ }
808
+ }
809
+
810
+ let count = edits.len();
811
+
812
+ self.apply_edits(edits)?;
813
+
814
+ Ok(count)
815
+ }
816
+
741
817
  pub fn enforce_quotes_at(&mut self, style: &QuoteStyle, dot_path: Option<&str>) -> Result<Vec<String>, YerbaError> {
742
818
  let source = self.source_text();
743
819
 
@@ -819,11 +895,7 @@ impl Document {
819
895
  let is_multiline = trimmed.contains('\n');
820
896
 
821
897
  let new_text = match style {
822
- QuoteStyle::Double => {
823
- let escaped = trimmed.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
824
-
825
- format!("\"{}\"", escaped)
826
- }
898
+ QuoteStyle::Double => format_scalar_value(trimmed, SyntaxKind::DOUBLE_QUOTED_SCALAR),
827
899
 
828
900
  QuoteStyle::Single => {
829
901
  if is_multiline {
@@ -879,7 +951,7 @@ impl Document {
879
951
 
880
952
  let offset: usize = token.text_range().start().into();
881
953
  let line_prefix = &source[line_start_at(&source, offset)..offset];
882
- let indent = line_prefix.len() - line_prefix.trim_start().len() + 2;
954
+ let indent = block_scalar_indent(line_prefix);
883
955
  let indent_str = " ".repeat(indent);
884
956
  let header = style.block_header();
885
957
 
@@ -932,6 +1004,54 @@ impl Document {
932
1004
  }
933
1005
  }
934
1006
 
1007
+ struct BlankLineEntry {
1008
+ node: SyntaxNode,
1009
+ key: Option<String>,
1010
+ is_empty: bool,
1011
+ }
1012
+
1013
+ fn should_adjust_gap(entry: &BlankLineEntry, previous: &BlankLineEntry, options: &BlankLineOptions) -> bool {
1014
+ let usable = |candidate: &BlankLineEntry| !options.skip_empty || !candidate.is_empty;
1015
+
1016
+ if options.before.is_empty() && options.after.is_empty() {
1017
+ return usable(entry);
1018
+ }
1019
+
1020
+ (matches_key(entry.key.as_deref(), &options.before) && usable(entry)) || (matches_key(previous.key.as_deref(), &options.after) && usable(previous))
1021
+ }
1022
+
1023
+ fn matches_key(key: Option<&str>, keys: &[String]) -> bool {
1024
+ match key {
1025
+ Some(key) => keys.iter().any(|wanted| wanted == key),
1026
+ None => false,
1027
+ }
1028
+ }
1029
+
1030
+ fn is_empty_value(value: &yaml_serde::Value) -> bool {
1031
+ match value {
1032
+ yaml_serde::Value::Null => true,
1033
+ yaml_serde::Value::String(text) => text.is_empty(),
1034
+ yaml_serde::Value::Sequence(entries) => entries.is_empty(),
1035
+ yaml_serde::Value::Mapping(entries) => entries.is_empty(),
1036
+ _ => false,
1037
+ }
1038
+ }
1039
+
1040
+ fn block_scalar_indent(line_prefix: &str) -> usize {
1041
+ let mut column = 0;
1042
+ let bytes = line_prefix.as_bytes();
1043
+
1044
+ while column < bytes.len() {
1045
+ match bytes[column] {
1046
+ b' ' => column += 1,
1047
+ b'-' if bytes.get(column + 1) == Some(&b' ') => column += 2,
1048
+ _ => break,
1049
+ }
1050
+ }
1051
+
1052
+ column + 2
1053
+ }
1054
+
935
1055
  fn inline_quote_replacement(raw_value: &str, current_kind: SyntaxKind, style: &QuoteStyle) -> Option<String> {
936
1056
  match style {
937
1057
  QuoteStyle::Double => {
data/rust/src/error.rs CHANGED
@@ -10,6 +10,7 @@ pub enum YerbaError {
10
10
  IndexOutOfBounds(usize, usize),
11
11
  UnknownKeys(Vec<String>),
12
12
  DuplicateValues(Vec<crate::DuplicateInfo>),
13
+ ControlCharacters(Vec<crate::ControlCharacter>),
13
14
  #[cfg(feature = "schema")]
14
15
  SchemaValidation(Vec<crate::schema::ValidationError>),
15
16
  DuplicateKey {
@@ -75,6 +76,19 @@ impl std::fmt::Display for YerbaError {
75
76
  write!(f, "found {} {}: {}", duplicates.len(), noun, details.join(", "))
76
77
  }
77
78
 
79
+ YerbaError::ControlCharacters(found) => {
80
+ let noun = if found.len() == 1 { "character" } else { "characters" };
81
+ let details: Vec<String> = found.iter().map(|control| format!(" {}", control)).collect();
82
+
83
+ write!(
84
+ f,
85
+ "found {} control {} that YAML parsers reject:\n{}\n\n Control characters must be removed, or escaped as \\xNN inside a double-quoted value.\n",
86
+ found.len(),
87
+ noun,
88
+ details.join("\n")
89
+ )
90
+ }
91
+
78
92
  YerbaError::IndexOutOfBounds(index, length) => {
79
93
  write!(f, "index {} out of bounds (length {})", index, length)
80
94
  }
@@ -150,6 +164,16 @@ impl GitHubAnnotations for YerbaError {
150
164
  })
151
165
  .collect(),
152
166
 
167
+ YerbaError::ControlCharacters(found) => found
168
+ .iter()
169
+ .map(|control| GitHubAnnotation {
170
+ level: "error ",
171
+ file: file.to_string(),
172
+ line: Some(control.line),
173
+ message: format!("control character U+{:04X} at column {}", control.character as u32, control.column),
174
+ })
175
+ .collect(),
176
+
153
177
  YerbaError::DuplicateKey { key, duplicate_line, .. } => vec![GitHubAnnotation {
154
178
  level: "error ",
155
179
  file: file.to_string(),