yerba 0.4.2-x86_64-linux-gnu → 0.5.0-x86_64-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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +241 -53
  3. data/exe/x86_64-linux-gnu/yerba +0 -0
  4. data/ext/yerba/include/yerba.h +13 -1
  5. data/ext/yerba/yerba.c +239 -113
  6. data/lib/yerba/3.2/yerba.so +0 -0
  7. data/lib/yerba/3.3/yerba.so +0 -0
  8. data/lib/yerba/3.4/yerba.so +0 -0
  9. data/lib/yerba/4.0/yerba.so +0 -0
  10. data/lib/yerba/document.rb +54 -18
  11. data/lib/yerba/map.rb +55 -43
  12. data/lib/yerba/node.rb +58 -0
  13. data/lib/yerba/scalar.rb +20 -23
  14. data/lib/yerba/sequence.rb +88 -55
  15. data/lib/yerba/version.rb +1 -1
  16. data/lib/yerba.rb +2 -0
  17. data/rust/Cargo.lock +1110 -25
  18. data/rust/Cargo.toml +2 -1
  19. data/rust/src/commands/delete.rs +1 -1
  20. data/rust/src/commands/get.rs +47 -12
  21. data/rust/src/commands/insert.rs +1 -1
  22. data/rust/src/commands/location.rs +56 -0
  23. data/rust/src/commands/mod.rs +33 -5
  24. data/rust/src/commands/remove.rs +1 -1
  25. data/rust/src/commands/rename.rs +1 -1
  26. data/rust/src/commands/schema.rs +84 -0
  27. data/rust/src/commands/set.rs +1 -1
  28. data/rust/src/commands/unique.rs +80 -0
  29. data/rust/src/document/condition.rs +17 -1
  30. data/rust/src/document/get.rs +254 -23
  31. data/rust/src/document/mod.rs +90 -12
  32. data/rust/src/document/schema.rs +73 -0
  33. data/rust/src/document/set.rs +1 -1
  34. data/rust/src/document/sort.rs +19 -13
  35. data/rust/src/document/style.rs +3 -3
  36. data/rust/src/document/unique.rs +86 -0
  37. data/rust/src/error.rs +78 -0
  38. data/rust/src/ffi.rs +89 -9
  39. data/rust/src/lib.rs +5 -10
  40. data/rust/src/main.rs +2 -0
  41. data/rust/src/schema.rs +93 -0
  42. data/rust/src/syntax.rs +91 -31
  43. data/rust/src/yerbafile.rs +107 -18
  44. metadata +9 -2
@@ -0,0 +1,86 @@
1
+ use yaml_parser::ast::BlockSeq;
2
+
3
+ use rowan::ast::AstNode;
4
+
5
+ use crate::document::{extract_scalar_text, navigate_from_node, Document};
6
+ use crate::error::YerbaError;
7
+
8
+ #[derive(Debug, Clone)]
9
+ pub struct DuplicateInfo {
10
+ pub value: String,
11
+ pub line: usize,
12
+ }
13
+
14
+ impl Document {
15
+ pub fn unique(&mut self, dot_path: &str, by: &str, remove: bool) -> Result<Vec<DuplicateInfo>, YerbaError> {
16
+ self.unique_with_options(dot_path, by, remove, false)
17
+ }
18
+
19
+ pub fn unique_with_options(&mut self, dot_path: &str, by: &str, remove: bool, allow_blank_duplicates: bool) -> Result<Vec<DuplicateInfo>, YerbaError> {
20
+ let current_node = self.navigate(dot_path)?;
21
+ let source = self.root.text().to_string();
22
+
23
+ let sequence = match current_node.descendants().find_map(BlockSeq::cast) {
24
+ Some(sequence) => sequence,
25
+ None => return Ok(Vec::new()),
26
+ };
27
+
28
+ let entries: Vec<_> = sequence.entries().collect();
29
+
30
+ if entries.len() <= 1 {
31
+ return Ok(Vec::new());
32
+ }
33
+
34
+ let by_is_scalar = by == ".";
35
+
36
+ let labels: Vec<(Option<String>, usize)> = entries
37
+ .iter()
38
+ .map(|entry| {
39
+ let offset: usize = entry.syntax().text_range().start().into();
40
+ let line = source[..offset].matches('\n').count() + 1;
41
+
42
+ let value = if by_is_scalar {
43
+ Some(entry.flow().and_then(|flow| extract_scalar_text(flow.syntax())).unwrap_or_default())
44
+ } else {
45
+ let field = by.strip_prefix('.').unwrap_or(by);
46
+ let nodes = navigate_from_node(entry.syntax(), field);
47
+
48
+ nodes.first().and_then(extract_scalar_text)
49
+ };
50
+
51
+ (value, line)
52
+ })
53
+ .collect();
54
+
55
+ let mut seen = std::collections::HashSet::new();
56
+ let mut duplicate_indices: Vec<usize> = Vec::new();
57
+ let mut duplicates: Vec<DuplicateInfo> = Vec::new();
58
+
59
+ for (index, (label, line)) in labels.iter().enumerate() {
60
+ let label = match label {
61
+ None => continue,
62
+ Some(value) => value,
63
+ };
64
+
65
+ if allow_blank_duplicates && label.is_empty() {
66
+ continue;
67
+ }
68
+
69
+ if !seen.insert(label.clone()) {
70
+ duplicate_indices.push(index);
71
+ duplicates.push(DuplicateInfo {
72
+ value: label.clone(),
73
+ line: *line,
74
+ });
75
+ }
76
+ }
77
+
78
+ if remove && !duplicate_indices.is_empty() {
79
+ for &index in duplicate_indices.iter().rev() {
80
+ self.remove_at(dot_path, index)?;
81
+ }
82
+ }
83
+
84
+ Ok(duplicates)
85
+ }
86
+ }
data/rust/src/error.rs CHANGED
@@ -7,6 +7,8 @@ pub enum YerbaError {
7
7
  NotASequence(String),
8
8
  IndexOutOfBounds(usize, usize),
9
9
  UnknownKeys(Vec<String>),
10
+ DuplicateValues(Vec<crate::DuplicateInfo>),
11
+ SchemaValidation(Vec<crate::schema::ValidationError>),
10
12
  DuplicateKey {
11
13
  key: String,
12
14
  first_line: usize,
@@ -48,10 +50,26 @@ impl std::fmt::Display for YerbaError {
48
50
  )
49
51
  }
50
52
 
53
+ YerbaError::DuplicateValues(duplicates) => {
54
+ let noun = if duplicates.len() == 1 { "duplicate" } else { "duplicates" };
55
+ let details: Vec<String> = duplicates
56
+ .iter()
57
+ .map(|duplicate| format!("\"{}\" (line {})", duplicate.value, duplicate.line))
58
+ .collect();
59
+
60
+ write!(f, "found {} {}: {}", duplicates.len(), noun, details.join(", "))
61
+ }
62
+
51
63
  YerbaError::IndexOutOfBounds(index, length) => {
52
64
  write!(f, "index {} out of bounds (length {})", index, length)
53
65
  }
54
66
 
67
+ YerbaError::SchemaValidation(errors) => {
68
+ let details: Vec<String> = errors.iter().map(|error| error.to_string()).collect();
69
+
70
+ write!(f, "schema validation failed:\n{}", details.join("\n"))
71
+ }
72
+
55
73
  YerbaError::UnknownKeys(keys) => {
56
74
  let suggestion = keys.iter().map(|key| format!("\"{}\"", key)).collect::<Vec<_>>().join(", ");
57
75
 
@@ -71,3 +89,63 @@ impl From<std::io::Error> for YerbaError {
71
89
  YerbaError::IoError(err)
72
90
  }
73
91
  }
92
+
93
+ pub struct GitHubAnnotation {
94
+ pub level: &'static str,
95
+ pub file: String,
96
+ pub line: Option<usize>,
97
+ pub message: String,
98
+ }
99
+
100
+ impl std::fmt::Display for GitHubAnnotation {
101
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102
+ match self.line {
103
+ Some(line) => write!(f, "::{}file={},line={}::{}", self.level, self.file, line, self.message),
104
+ None => write!(f, "::{}file={}::{}", self.level, self.file, self.message),
105
+ }
106
+ }
107
+ }
108
+
109
+ pub trait GitHubAnnotations {
110
+ fn github_annotations(&self, file: &str) -> Vec<GitHubAnnotation>;
111
+ }
112
+
113
+ impl GitHubAnnotations for YerbaError {
114
+ fn github_annotations(&self, file: &str) -> Vec<GitHubAnnotation> {
115
+ match self {
116
+ YerbaError::DuplicateValues(duplicates) => duplicates
117
+ .iter()
118
+ .map(|duplicate| GitHubAnnotation {
119
+ level: "error ",
120
+ file: file.to_string(),
121
+ line: Some(duplicate.line),
122
+ message: format!("duplicate: \"{}\"", duplicate.value),
123
+ })
124
+ .collect(),
125
+
126
+ YerbaError::SchemaValidation(errors) => errors
127
+ .iter()
128
+ .map(|error| GitHubAnnotation {
129
+ level: "error ",
130
+ file: file.to_string(),
131
+ line: error.line,
132
+ message: error.to_string(),
133
+ })
134
+ .collect(),
135
+
136
+ YerbaError::DuplicateKey { key, duplicate_line, .. } => vec![GitHubAnnotation {
137
+ level: "error ",
138
+ file: file.to_string(),
139
+ line: Some(*duplicate_line),
140
+ message: format!("duplicate key: \"{}\"", key),
141
+ }],
142
+
143
+ _ => vec![GitHubAnnotation {
144
+ level: "error ",
145
+ file: file.to_string(),
146
+ line: None,
147
+ message: self.to_string(),
148
+ }],
149
+ }
150
+ }
151
+ }
data/rust/src/ffi.rs CHANGED
@@ -298,13 +298,22 @@ pub unsafe extern "C" fn yerba_document_get_value(document: *const Document, pat
298
298
 
299
299
  /// Caller must free with yerba_string_free.
300
300
  #[no_mangle]
301
- pub unsafe extern "C" fn yerba_document_get_values(document: *const Document, path: *const c_char) -> *mut c_char {
301
+ pub unsafe extern "C" fn yerba_document_selectors(document: *const Document) -> *mut c_char {
302
+ let document = &*document;
303
+
304
+ let selectors = document.selectors();
305
+ let json_string = serde_json::to_string(&selectors).unwrap_or_else(|_| "[]".to_string());
306
+
307
+ CString::new(json_string).unwrap_or_default().into_raw()
308
+ }
309
+
310
+ #[no_mangle]
311
+ pub unsafe extern "C" fn yerba_document_resolve_selectors(document: *const Document, path: *const c_char) -> *mut c_char {
302
312
  let document = &*document;
303
313
  let selector_string = CStr::from_ptr(path).to_str().unwrap_or("");
304
314
 
305
- let values = document.get_values(selector_string);
306
- let json_values: Vec<serde_json::Value> = values.iter().map(crate::json::yaml_to_json).collect();
307
- let json_string = serde_json::to_string(&json_values).unwrap_or_else(|_| "[]".to_string());
315
+ let selectors = document.resolve_selectors(selector_string);
316
+ let json_string = serde_json::to_string(&selectors).unwrap_or_else(|_| "[]".to_string());
308
317
 
309
318
  CString::new(json_string).unwrap_or_default().into_raw()
310
319
  }
@@ -357,6 +366,13 @@ pub unsafe extern "C" fn yerba_document_exists(document: *const Document, path:
357
366
  document.exists(selector_string)
358
367
  }
359
368
 
369
+ #[no_mangle]
370
+ pub unsafe extern "C" fn yerba_document_valid_selector(document: *const Document, path: *const c_char) -> bool {
371
+ let document = &*document;
372
+ let selector_string = CStr::from_ptr(path).to_str().unwrap_or("");
373
+ document.is_valid_selector(selector_string)
374
+ }
375
+
360
376
  #[no_mangle]
361
377
  pub unsafe extern "C" fn yerba_document_find(document: *const Document, path: *const c_char, condition: *const c_char, select: *const c_char) -> *mut c_char {
362
378
  let document = &*document;
@@ -404,13 +420,19 @@ pub unsafe extern "C" fn yerba_document_insert(
404
420
  document: *mut Document,
405
421
  path: *const c_char,
406
422
  value: *const c_char,
423
+ value_type: YerbaValueType,
407
424
  before: *const c_char,
408
425
  after: *const c_char,
409
426
  at: i64,
410
427
  ) -> YerbaResult {
411
428
  let document = &mut *document;
412
429
  let selector_string = CStr::from_ptr(path).to_str().unwrap_or("");
413
- let value_string = CStr::from_ptr(value).to_str().unwrap_or("");
430
+ let raw_value = CStr::from_ptr(value).to_str().unwrap_or("");
431
+
432
+ let value_string = match value_type {
433
+ YerbaValueType::String => crate::syntax::quote_if_needed(raw_value),
434
+ _ => raw_value.to_string(),
435
+ };
414
436
 
415
437
  let position = if at >= 0 {
416
438
  InsertPosition::At(at as usize)
@@ -424,7 +446,7 @@ pub unsafe extern "C" fn yerba_document_insert(
424
446
  InsertPosition::Last
425
447
  };
426
448
 
427
- match document.insert_into(selector_string, value_string, position) {
449
+ match document.insert_into(selector_string, &value_string, position) {
428
450
  Ok(()) => YerbaResult::ok(),
429
451
  Err(e) => YerbaResult::err(&e.to_string()),
430
452
  }
@@ -626,6 +648,44 @@ pub unsafe extern "C" fn yerba_document_blank_lines(document: *mut Document, pat
626
648
  }
627
649
  }
628
650
 
651
+ /// Caller must free with yerba_string_free.
652
+ #[no_mangle]
653
+ pub unsafe extern "C" fn yerba_document_validate_schema(document: *const Document, schema_json: *const c_char, selector: *const c_char) -> *mut c_char {
654
+ let document = &*document;
655
+ let schema_string = CStr::from_ptr(schema_json).to_str().unwrap_or("");
656
+ let selector_string = if selector.is_null() { None } else { CStr::from_ptr(selector).to_str().ok() };
657
+
658
+ let schema: serde_json::Value = match serde_json::from_str(schema_string) {
659
+ Ok(schema) => schema,
660
+ Err(e) => {
661
+ let error = serde_json::json!([{"message": format!("invalid schema: {}", e), "path": "", "line": null}]);
662
+ return CString::new(error.to_string()).unwrap_or_default().into_raw();
663
+ }
664
+ };
665
+
666
+ let errors = document.validate_schema(&schema, false, selector_string);
667
+
668
+ if errors.is_empty() {
669
+ return ptr::null_mut();
670
+ }
671
+
672
+ let json_errors: Vec<serde_json::Value> = errors
673
+ .iter()
674
+ .map(|error| {
675
+ serde_json::json!({
676
+ "message": error.message,
677
+ "path": error.path,
678
+ "line": error.line,
679
+ "item_label": error.item_label,
680
+ })
681
+ })
682
+ .collect();
683
+
684
+ CString::new(serde_json::to_string(&json_errors).unwrap_or_else(|_| "[]".to_string()))
685
+ .unwrap_or_default()
686
+ .into_raw()
687
+ }
688
+
629
689
  /// Caller must free with yerba_string_free.
630
690
  #[no_mangle]
631
691
  pub unsafe extern "C" fn yerba_yerbafile_find(directory: *const c_char) -> *mut c_char {
@@ -716,11 +776,31 @@ pub unsafe extern "C" fn yerba_get_result_free(result: YerbaGetResult) {
716
776
  pub unsafe extern "C" fn yerba_glob_get(glob_pattern: *const c_char, path: *const c_char) -> YerbaTypedList {
717
777
  let pattern = CStr::from_ptr(glob_pattern).to_str().unwrap_or("");
718
778
  let selector_string = CStr::from_ptr(path).to_str().unwrap_or("");
719
- let scalars = crate::glob_get(pattern, selector_string);
779
+ let nodes = crate::glob_get(pattern, selector_string);
720
780
 
721
- let results: Vec<serde_json::Value> = scalars
781
+ let results: Vec<serde_json::Value> = nodes
722
782
  .iter()
723
- .map(|scalar| serde_json::json!({"text": scalar.text, "type": detect_yaml_type(scalar) as u8}))
783
+ .map(|node| {
784
+ let mut value = serde_json::json!({
785
+ "node_type": node.node_type,
786
+ "selector": node.selector,
787
+ "line": node.line,
788
+ });
789
+
790
+ if let Some(file_path) = &node.file_path {
791
+ value["file_path"] = serde_json::json!(file_path);
792
+ }
793
+
794
+ if let Some(text) = &node.text {
795
+ value["text"] = serde_json::json!(text);
796
+ }
797
+
798
+ if let Some(vt) = node.value_type {
799
+ value["type"] = serde_json::json!(vt as u8);
800
+ }
801
+
802
+ value
803
+ })
724
804
  .collect();
725
805
 
726
806
  let length = results.len();
data/rust/src/lib.rs CHANGED
@@ -1,15 +1,16 @@
1
1
  pub mod didyoumean;
2
2
  mod document;
3
- mod error;
3
+ pub mod error;
4
4
  pub mod ffi;
5
5
  pub mod json;
6
6
  mod quote_style;
7
+ pub mod schema;
7
8
  pub mod selector;
8
9
  mod syntax;
9
10
  mod yaml_writer;
10
11
  pub mod yerbafile;
11
12
 
12
- pub use document::{collect_selectors, Document, InsertPosition, Location, NodeInfo, NodeType, SortField};
13
+ pub use document::{collect_selectors, Document, DuplicateInfo, InsertPosition, LocatedNode, Location, NodeInfo, NodeType, SortField};
13
14
  pub use error::YerbaError;
14
15
  pub use quote_style::{KeyStyle, QuoteStyle};
15
16
  pub use selector::Selector;
@@ -29,11 +30,9 @@ pub fn parse_file(path: impl AsRef<std::path::Path>) -> Result<Document, YerbaEr
29
30
  Document::parse_file(path)
30
31
  }
31
32
 
32
- pub fn glob_get(pattern: &str, selector: &str) -> Vec<ScalarValue> {
33
+ pub fn glob_get(pattern: &str, selector: &str) -> Vec<document::LocatedNode> {
33
34
  use rayon::prelude::*;
34
35
 
35
- let parsed_selector = Selector::parse(selector);
36
-
37
36
  let files = match glob::glob(pattern) {
38
37
  Ok(paths) => paths.filter_map(|p| p.ok()).collect::<Vec<_>>(),
39
38
  Err(_) => return vec![],
@@ -45,11 +44,7 @@ pub fn glob_get(pattern: &str, selector: &str) -> Vec<ScalarValue> {
45
44
  let mut results = Vec::new();
46
45
 
47
46
  if let Ok(document) = Document::parse_file(file) {
48
- if parsed_selector.has_wildcard() {
49
- results.extend(document.get_all_typed(selector));
50
- } else if let Some(scalar) = document.get_typed(selector) {
51
- results.push(scalar);
52
- }
47
+ results.extend(document.get_all_located(selector));
53
48
  }
54
49
 
55
50
  results
data/rust/src/main.rs CHANGED
@@ -48,7 +48,9 @@ static HELP: LazyLock<String> = LazyLock::new(|| {
48
48
  yerba sort-keys config.yml "database" "id,host,port,name"
49
49
  yerba quote-style "data/**/*.yml" --values double
50
50
  yerba sort videos.yml "[]" --by ".id" --order "talk-3,talk-1,talk-2"
51
+ yerba schema "data/**/*.yml" --schema schema.json
51
52
  yerba selectors videos.yml
53
+ yerba location videos.yml "[0].title"
52
54
  "#})
53
55
  });
54
56
 
@@ -0,0 +1,93 @@
1
+ use std::fs;
2
+ use std::path::Path;
3
+
4
+ use crate::error::YerbaError;
5
+
6
+ #[derive(Debug)]
7
+ pub struct ValidationError {
8
+ pub path: String,
9
+ pub message: String,
10
+ pub item_label: Option<String>,
11
+ pub line: Option<usize>,
12
+ }
13
+
14
+ impl std::fmt::Display for ValidationError {
15
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16
+ let location = match self.line {
17
+ Some(line) => format!(" (line {})", line),
18
+ None => String::new(),
19
+ };
20
+
21
+ match &self.item_label {
22
+ Some(label) => write!(f, "{} at {} ({}){}", self.message, self.path, label, location),
23
+ None => write!(f, "{} at {}{}", self.message, self.path, location),
24
+ }
25
+ }
26
+ }
27
+
28
+ pub fn validate_value(value: &serde_json::Value, schema: &serde_json::Value) -> Vec<ValidationError> {
29
+ let compiled = match jsonschema::draft7::new(schema) {
30
+ Ok(compiled) => compiled,
31
+ Err(error) => {
32
+ return vec![ValidationError {
33
+ path: String::new(),
34
+ message: format!("invalid schema: {}", error),
35
+ item_label: None,
36
+ line: None,
37
+ }];
38
+ }
39
+ };
40
+
41
+ compiled
42
+ .iter_errors(value)
43
+ .map(|error| ValidationError {
44
+ path: error.instance_path.to_string(),
45
+ message: error.to_string(),
46
+ item_label: None,
47
+ line: None,
48
+ })
49
+ .collect()
50
+ }
51
+
52
+ pub fn validate_array(items: &[serde_json::Value], schema: &serde_json::Value) -> Vec<ValidationError> {
53
+ let compiled = match jsonschema::draft7::new(schema) {
54
+ Ok(compiled) => compiled,
55
+ Err(error) => {
56
+ return vec![ValidationError {
57
+ path: String::new(),
58
+ message: format!("invalid schema: {}", error),
59
+ item_label: None,
60
+ line: None,
61
+ }];
62
+ }
63
+ };
64
+
65
+ let mut errors = Vec::new();
66
+
67
+ for (index, item) in items.iter().enumerate() {
68
+ let item_label = item
69
+ .get("name")
70
+ .or_else(|| item.get("title"))
71
+ .or_else(|| item.get("id"))
72
+ .and_then(|value| value.as_str())
73
+ .map(|string| string.to_string())
74
+ .unwrap_or_else(|| format!("index {}", index));
75
+
76
+ for error in compiled.iter_errors(item) {
77
+ errors.push(ValidationError {
78
+ path: format!("/{}{}", index, error.instance_path),
79
+ message: error.to_string(),
80
+ item_label: Some(item_label.clone()),
81
+ line: None,
82
+ });
83
+ }
84
+ }
85
+
86
+ errors
87
+ }
88
+
89
+ pub fn load_schema(path: impl AsRef<Path>) -> Result<serde_json::Value, YerbaError> {
90
+ let content = fs::read_to_string(path.as_ref())?;
91
+
92
+ serde_json::from_str(&content).map_err(|error| YerbaError::ParseError(format!("invalid JSON schema: {}", error)))
93
+ }
data/rust/src/syntax.rs CHANGED
@@ -8,6 +8,9 @@ use yaml_parser::{SyntaxKind, SyntaxNode, SyntaxToken};
8
8
  pub struct ScalarValue {
9
9
  pub text: String,
10
10
  pub kind: SyntaxKind,
11
+ pub file_path: Option<String>,
12
+ pub selector: Option<String>,
13
+ pub line: Option<usize>,
11
14
  }
12
15
 
13
16
  #[repr(C)]
@@ -29,25 +32,44 @@ pub fn detect_yaml_type(scalar: &ScalarValue) -> YerbaValueType {
29
32
  }
30
33
 
31
34
  pub fn extract_scalar(node: &SyntaxNode) -> Option<ScalarValue> {
32
- let token = find_scalar_token(node)?;
35
+ if let Some(token) = find_scalar_token(node) {
36
+ let text = match token.kind() {
37
+ SyntaxKind::PLAIN_SCALAR => token.text().to_string(),
33
38
 
34
- let text = match token.kind() {
35
- SyntaxKind::PLAIN_SCALAR => token.text().to_string(),
39
+ SyntaxKind::DOUBLE_QUOTED_SCALAR => {
40
+ let raw = token.text();
41
+ unescape_double_quoted(&raw[1..raw.len() - 1])
42
+ }
36
43
 
37
- SyntaxKind::DOUBLE_QUOTED_SCALAR => {
38
- let raw = token.text();
39
- unescape_double_quoted(&raw[1..raw.len() - 1])
40
- }
44
+ SyntaxKind::SINGLE_QUOTED_SCALAR => {
45
+ let raw = token.text();
46
+ unescape_single_quoted(&raw[1..raw.len() - 1])
47
+ }
41
48
 
42
- SyntaxKind::SINGLE_QUOTED_SCALAR => {
43
- let raw = token.text();
44
- unescape_single_quoted(&raw[1..raw.len() - 1])
45
- }
49
+ _ => return None,
50
+ };
46
51
 
47
- _ => return None,
48
- };
52
+ return Some(ScalarValue {
53
+ text,
54
+ kind: token.kind(),
55
+ file_path: None,
56
+ selector: None,
57
+ line: None,
58
+ });
59
+ }
49
60
 
50
- Some(ScalarValue { text, kind: token.kind() })
61
+ let block_token = node
62
+ .descendants_with_tokens()
63
+ .filter_map(|element| element.into_token())
64
+ .find(|token| token.kind() == SyntaxKind::BLOCK_SCALAR_TEXT)?;
65
+
66
+ Some(ScalarValue {
67
+ text: dedent_block_scalar(block_token.text()),
68
+ kind: SyntaxKind::BLOCK_SCALAR_TEXT,
69
+ file_path: None,
70
+ selector: None,
71
+ line: None,
72
+ })
51
73
  }
52
74
 
53
75
  pub fn is_map_key(token: &SyntaxToken) -> bool {
@@ -89,28 +111,61 @@ pub fn format_scalar_value(value: &str, kind: SyntaxKind) -> String {
89
111
  }
90
112
  }
91
113
 
92
- pub fn extract_scalar_text(node: &SyntaxNode) -> Option<String> {
93
- let token = find_scalar_token(node)?;
114
+ pub fn quote_if_needed(value: &str) -> String {
115
+ if is_yaml_non_string(value) {
116
+ format_scalar_value(value, SyntaxKind::DOUBLE_QUOTED_SCALAR)
117
+ } else {
118
+ value.to_string()
119
+ }
120
+ }
94
121
 
95
- match token.kind() {
96
- SyntaxKind::PLAIN_SCALAR => Some(token.text().to_string()),
122
+ pub fn extract_scalar_text(node: &SyntaxNode) -> Option<String> {
123
+ if let Some(token) = find_scalar_token(node) {
124
+ return match token.kind() {
125
+ SyntaxKind::PLAIN_SCALAR => Some(token.text().to_string()),
97
126
 
98
- SyntaxKind::DOUBLE_QUOTED_SCALAR => {
99
- let text = token.text();
100
- let inner = &text[1..text.len() - 1];
127
+ SyntaxKind::DOUBLE_QUOTED_SCALAR => {
128
+ let text = token.text();
129
+ let inner = &text[1..text.len() - 1];
101
130
 
102
- Some(unescape_double_quoted(inner))
103
- }
131
+ Some(unescape_double_quoted(inner))
132
+ }
104
133
 
105
- SyntaxKind::SINGLE_QUOTED_SCALAR => {
106
- let text = token.text();
107
- let inner = &text[1..text.len() - 1];
134
+ SyntaxKind::SINGLE_QUOTED_SCALAR => {
135
+ let text = token.text();
136
+ let inner = &text[1..text.len() - 1];
108
137
 
109
- Some(unescape_single_quoted(inner))
110
- }
138
+ Some(unescape_single_quoted(inner))
139
+ }
111
140
 
112
- _ => None,
141
+ _ => None,
142
+ };
113
143
  }
144
+
145
+ let block_scalar_text = node
146
+ .descendants_with_tokens()
147
+ .filter_map(|element| element.into_token())
148
+ .find(|token| token.kind() == SyntaxKind::BLOCK_SCALAR_TEXT)?;
149
+
150
+ Some(dedent_block_scalar(block_scalar_text.text()))
151
+ }
152
+
153
+ pub fn dedent_block_scalar(text: &str) -> String {
154
+ let lines: Vec<&str> = text.lines().collect();
155
+ let min_indent = lines
156
+ .iter()
157
+ .filter(|line| !line.trim().is_empty())
158
+ .map(|line| line.len() - line.trim_start().len())
159
+ .min()
160
+ .unwrap_or(0);
161
+
162
+ let dedented: String = lines
163
+ .iter()
164
+ .map(|line| if line.len() >= min_indent { &line[min_indent..] } else { line.trim() })
165
+ .collect::<Vec<_>>()
166
+ .join("\n");
167
+
168
+ dedented.trim().to_string()
114
169
  }
115
170
 
116
171
  pub fn unescape_double_quoted(text: &str) -> String {
@@ -260,8 +315,13 @@ pub fn detect_yaml_type_from_plain(value: &str) -> YerbaValueType {
260
315
  return YerbaValueType::Integer;
261
316
  }
262
317
 
263
- // Octal (0o...) and hex (0x...)
264
- if value.starts_with("0x") || value.starts_with("0X") || value.starts_with("0o") || value.starts_with("0O") {
318
+ // Hex (0x...) only valid hex digits after prefix
319
+ if (value.starts_with("0x") || value.starts_with("0X")) && value.len() > 2 && value[2..].chars().all(|c| c.is_ascii_hexdigit()) {
320
+ return YerbaValueType::Integer;
321
+ }
322
+
323
+ // Octal (0o...) — only valid octal digits after prefix
324
+ if (value.starts_with("0o") || value.starts_with("0O")) && value.len() > 2 && value[2..].chars().all(|c| matches!(c, '0'..='7')) {
265
325
  return YerbaValueType::Integer;
266
326
  }
267
327