yerba 0.4.0-x86_64-linux-gnu → 0.4.1-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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59ee2e4bf45907cea3492eeed2eafa105586d9569984d84bd4e18b53c3705aa9
4
- data.tar.gz: de41d93b4378c86a57581cfe7c3d4c2179ff3c98fc35e759f5458d8388eb302c
3
+ metadata.gz: 3c63edced27fb5d271865615abddb05ee8c028d4205fe01ddf1debfef105b962
4
+ data.tar.gz: 56f52e28f3360c7dcec764e1878b68f90b3d80c4b24ab59e15e664da3c9e848c
5
5
  SHA512:
6
- metadata.gz: 3f2bc07a7c3aa8f3e66beabb9ec302b1f2d42b6ee75c399ff71970c857dcb58cda25b2f4e49c5cfe6713b002e07c47fa23f79f5f14af6e6051253e461cb8610b
7
- data.tar.gz: 64ab2f256c556c820d544bfce04c145ee83988ff7b3a84f5328cb47df8cb231910de7045be9ac4adbd407e5fed65a44b7ea303b22dacf7c1cef3ad14aecc70cd
6
+ metadata.gz: 4b5257dcd6171cb8982c2a2839cc79faf0020bd39f740e6d4646e3c293f2b067f7effe497e7385632c35f9a55ac00edfd8eb21f3121a47b413bb350270091f55
7
+ data.tar.gz: 56377b990d5379a88233a2d50f39952662d295f919011f1480ae4137e85f9e1d03c5830784e9a4f088295c5f04a8e003e332c8e946bc0158f198db3594f06991
data/README.md CHANGED
@@ -413,7 +413,10 @@ Use `yerba init` to create one, then `yerba apply` to apply all rules, or `yerba
413
413
  ```bash
414
414
  yerba init
415
415
  yerba apply
416
+ yerba apply path/to/file.yml
417
+
416
418
  yerba check
419
+ yerba check path/to/file.yml
417
420
  ```
418
421
 
419
422
  Each rule specifies a file glob and a list of steps to run in order:
Binary file
@@ -121,6 +121,10 @@ struct YerbaResult yerba_document_insert_object(struct Document *document,
121
121
 
122
122
  struct YerbaResult yerba_document_delete(struct Document *document, const char *path);
123
123
 
124
+ struct YerbaResult yerba_document_insert_objects(struct Document *document,
125
+ const char *path,
126
+ const char *json);
127
+
124
128
  struct YerbaResult yerba_document_remove(struct Document *document,
125
129
  const char *path,
126
130
  const char *value);
@@ -161,6 +165,15 @@ struct YerbaResult yerba_document_blank_lines(struct Document *document,
161
165
  const char *path,
162
166
  uintptr_t count);
163
167
 
168
+ /**
169
+ * Caller must free with yerba_string_free.
170
+ */
171
+ char *yerba_yerbafile_find(const char *directory);
172
+
173
+ struct YerbaResult yerba_document_apply_yerbafile(struct Document *document,
174
+ const char *file_path,
175
+ const char *yerbafile_path);
176
+
164
177
  char *yerba_document_to_string(const struct Document *document);
165
178
 
166
179
  void yerba_string_free(char *s);
data/ext/yerba/yerba.c CHANGED
@@ -488,6 +488,17 @@ static VALUE document_insert_object(int argc, VALUE *argv, VALUE self) {
488
488
  return self;
489
489
  }
490
490
 
491
+ /* document.insert_objects(path, array) */
492
+ static VALUE document_insert_objects(VALUE self, VALUE path, VALUE array) {
493
+ struct Document *document = get_document(self);
494
+ VALUE json_string = rb_funcall(rb_path2class("JSON"), rb_intern("generate"), 1, array);
495
+
496
+ YerbaResult result = yerba_document_insert_objects(document, StringValueCStr(path), StringValueCStr(json_string));
497
+ check_result(result);
498
+
499
+ return self;
500
+ }
501
+
491
502
  /* document.delete(path, condition: nil) */
492
503
  static VALUE document_delete(int argc, VALUE *argv, VALUE self) {
493
504
  VALUE path, opts;
@@ -671,6 +682,23 @@ static VALUE document_blank_lines(VALUE self, VALUE path, VALUE count) {
671
682
  return self;
672
683
  }
673
684
 
685
+ /* document.apply_yerbafile(yerbafile_path = nil) */
686
+ static VALUE document_apply_yerbafile(int argc, VALUE *argv, VALUE self) {
687
+ VALUE yerbafile_path;
688
+ rb_scan_args(argc, argv, "01", &yerbafile_path);
689
+
690
+ struct Document *document = get_document(self);
691
+ VALUE file_path = rb_iv_get(self, "@path");
692
+
693
+ const char *file_path_str = NIL_P(file_path) ? "" : StringValueCStr(file_path);
694
+ const char *yerbafile_path_str = NIL_P(yerbafile_path) ? NULL : StringValueCStr(yerbafile_path);
695
+
696
+ YerbaResult result = yerba_document_apply_yerbafile(document, file_path_str, yerbafile_path_str);
697
+ check_result(result);
698
+
699
+ return self;
700
+ }
701
+
674
702
  /* document.to_s */
675
703
  static VALUE document_to_s(VALUE self) {
676
704
  struct Document *document = get_document(self);
@@ -773,6 +801,22 @@ static VALUE collection_s_find(int argc, VALUE *argv, VALUE self) {
773
801
  return rb_funcall(rb_path2class("JSON"), rb_intern("parse"), 1, json_string);
774
802
  }
775
803
 
804
+ /* Yerbafile.locate(directory = nil) → path string or nil */
805
+ static VALUE yerbafile_s_locate(int argc, VALUE *argv, VALUE klass) {
806
+ VALUE directory;
807
+ rb_scan_args(argc, argv, "01", &directory);
808
+
809
+ const char *dir = NIL_P(directory) ? NULL : StringValueCStr(directory);
810
+ char *result = yerba_yerbafile_find(dir);
811
+
812
+ if (!result) return Qnil;
813
+
814
+ VALUE path = make_utf8_string(result);
815
+ yerba_string_free(result);
816
+
817
+ return path;
818
+ }
819
+
776
820
  void Init_yerba(void) {
777
821
  rb_require("json");
778
822
 
@@ -803,6 +847,7 @@ void Init_yerba(void) {
803
847
  rb_define_method(rb_cDocument, "set", document_set, -1);
804
848
  rb_define_method(rb_cDocument, "insert", document_insert, -1);
805
849
  rb_define_method(rb_cDocument, "insert_object", document_insert_object, -1);
850
+ rb_define_method(rb_cDocument, "insert_objects", document_insert_objects, 2);
806
851
  rb_define_method(rb_cDocument, "delete", document_delete, -1);
807
852
  rb_define_method(rb_cDocument, "remove", document_remove, 2);
808
853
  rb_define_method(rb_cDocument, "remove_at", document_remove_at, 2);
@@ -811,8 +856,12 @@ void Init_yerba(void) {
811
856
  rb_define_method(rb_cDocument, "sort_keys", document_sort_keys, 2);
812
857
  rb_define_method(rb_cDocument, "quote_style", document_quote_style, -1);
813
858
  rb_define_method(rb_cDocument, "blank_lines", document_blank_lines, 2);
859
+ rb_define_method(rb_cDocument, "apply_yerbafile", document_apply_yerbafile, -1);
814
860
  rb_define_method(rb_cDocument, "to_s", document_to_s, 0);
815
- rb_define_method(rb_cDocument, "save!", document_save, 0);
861
+ rb_define_method(rb_cDocument, "write!", document_save, 0);
816
862
  rb_define_method(rb_cDocument, "changed?", document_changed_p, 0);
817
863
  rb_define_method(rb_cDocument, "path", document_path, 0);
864
+
865
+ VALUE rb_cYerbafile = rb_define_class_under(rb_mYerba, "Yerbafile", rb_cObject);
866
+ rb_define_singleton_method(rb_cYerbafile, "locate", yerbafile_s_locate, -1);
818
867
  }
Binary file
Binary file
Binary file
Binary file
@@ -37,7 +37,7 @@ module Yerba
37
37
  each do |document|
38
38
  next unless document.sequence?
39
39
 
40
- results.concat(document.root.where(...))
40
+ results.concat(document.root.where(...).to_a)
41
41
  end
42
42
 
43
43
  results
@@ -2,8 +2,10 @@
2
2
 
3
3
  module Yerba
4
4
  class Document
5
+ ROOT_SELECTOR = ""
6
+
5
7
  def root
6
- self[""]
8
+ self[ROOT_SELECTOR]
7
9
  end
8
10
 
9
11
  def map?
@@ -15,11 +17,11 @@ module Yerba
15
17
  end
16
18
 
17
19
  def to_h
18
- get_value("")
20
+ get_value(ROOT_SELECTOR)
19
21
  end
20
22
 
21
23
  def to_a
22
- get_value("")
24
+ get_value(ROOT_SELECTOR)
23
25
  end
24
26
 
25
27
  def to_yaml
@@ -64,6 +66,30 @@ module Yerba
64
66
  root << item
65
67
  end
66
68
 
69
+ def concat(items)
70
+ root.concat(items)
71
+ end
72
+
73
+ def save!(apply: false)
74
+ Yerbafile.apply!(self, apply) if apply
75
+ write!
76
+
77
+ self
78
+ end
79
+
80
+ def apply!(yerbafile = nil)
81
+ apply(yerbafile)
82
+ write! if changed?
83
+
84
+ self
85
+ end
86
+
87
+ def apply(yerbafile = nil)
88
+ Yerbafile.apply!(self, yerbafile)
89
+
90
+ self
91
+ end
92
+
67
93
  def inspect
68
94
  if path
69
95
  "#<Yerba::Document path=#{path.inspect}>"
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yerba
4
+ class QueryResult
5
+ include Enumerable
6
+
7
+ def initialize(sequence, indices)
8
+ @sequence = sequence
9
+ @indices = indices
10
+ end
11
+
12
+ def each
13
+ return enum_for(:each) unless block_given?
14
+
15
+ @indices.each { |index| yield @sequence[index] }
16
+ end
17
+
18
+ def [](position)
19
+ index = @indices[position]
20
+
21
+ @sequence[index] if index
22
+ end
23
+
24
+ def first
25
+ self[0]
26
+ end
27
+
28
+ def last
29
+ self[-1]
30
+ end
31
+
32
+ def length
33
+ @indices.length
34
+ end
35
+ alias size length
36
+ alias count length
37
+
38
+ def empty?
39
+ @indices.empty?
40
+ end
41
+
42
+ def indices
43
+ @indices.dup
44
+ end
45
+
46
+ def inspect
47
+ "#<Yerba::QueryResult length=#{length}>"
48
+ end
49
+ end
50
+ end
@@ -55,6 +55,24 @@ module Yerba
55
55
  self
56
56
  end
57
57
 
58
+ def concat(items)
59
+ if @document
60
+ hashes = items.map do |item|
61
+ case item
62
+ when Map then item.to_hash
63
+ when Hash then item
64
+ else { value: item.to_s }
65
+ end
66
+ end
67
+
68
+ @document.insert_objects(@selector, hashes)
69
+ else
70
+ @data.concat(items)
71
+ end
72
+
73
+ self
74
+ end
75
+
58
76
  def each
59
77
  return enum_for(:each) unless block_given?
60
78
 
@@ -68,7 +86,7 @@ module Yerba
68
86
  end
69
87
 
70
88
  def where(selector = nil, value = nil, **criteria)
71
- indices_of(selector, value, **criteria).map { |index| self[index] }
89
+ QueryResult.new(self, indices_of(selector, value, **criteria))
72
90
  end
73
91
 
74
92
  def pluck(*fields)
data/lib/yerba/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yerba
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
5
5
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yerba
4
+ class Yerbafile
5
+ attr_reader :path
6
+
7
+ def initialize(path)
8
+ @path = File.expand_path(path)
9
+
10
+ raise Yerba::Error, "Yerbafile not found: #{path}" unless File.exist?(@path)
11
+ end
12
+
13
+ def self.find(directory = Dir.pwd)
14
+ path = locate(directory)
15
+
16
+ path ? new(path) : nil
17
+ end
18
+
19
+ def self.find!(directory = Dir.pwd)
20
+ find(directory) || raise(Yerba::Error, "No Yerbafile found")
21
+ end
22
+
23
+ def self.resolve(yerbafile = nil)
24
+ case yerbafile
25
+ when Yerbafile then yerbafile
26
+ when String then new(yerbafile)
27
+ when true, nil then find!
28
+ end
29
+ end
30
+
31
+ def self.apply!(document, yerbafile = nil)
32
+ resolve(yerbafile).apply(document)
33
+ end
34
+
35
+ def apply(document)
36
+ document.apply_yerbafile(@path)
37
+
38
+ document
39
+ end
40
+
41
+ def inspect
42
+ "#<Yerba::Yerbafile path=#{@path.inspect}>"
43
+ end
44
+ end
45
+ end
data/lib/yerba.rb CHANGED
@@ -8,8 +8,10 @@ require_relative "yerba/formatting"
8
8
  require_relative "yerba/scalar"
9
9
  require_relative "yerba/map"
10
10
  require_relative "yerba/sequence"
11
+ require_relative "yerba/query_result"
11
12
  require_relative "yerba/document"
12
13
  require_relative "yerba/collection"
14
+ require_relative "yerba/yerbafile"
13
15
 
14
16
  begin
15
17
  major, minor, = RUBY_VERSION.split(".")
data/rust/Cargo.lock CHANGED
@@ -472,6 +472,7 @@ version = "1.0.149"
472
472
  source = "registry+https://github.com/rust-lang/crates.io-index"
473
473
  checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
474
474
  dependencies = [
475
+ "indexmap",
475
476
  "itoa",
476
477
  "memchr",
477
478
  "serde",
@@ -784,7 +785,7 @@ dependencies = [
784
785
 
785
786
  [[package]]
786
787
  name = "yerba"
787
- version = "0.4.0"
788
+ version = "0.4.1"
788
789
  dependencies = [
789
790
  "cbindgen",
790
791
  "clap",
@@ -795,6 +796,7 @@ dependencies = [
795
796
  "serde",
796
797
  "serde_json",
797
798
  "serde_yaml",
799
+ "tempfile",
798
800
  "yaml_parser",
799
801
  ]
800
802
 
data/rust/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "yerba"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  edition = "2021"
5
5
  authors = ["Marco Roth <marco.roth@intergga.ch>"]
6
6
  description = "YAML Editing and Refactoring with Better Accuracy"
@@ -25,7 +25,7 @@ rowan = "0.16"
25
25
  glob = "0.3"
26
26
  serde = { version = "1", features = ["derive"] }
27
27
  serde_yaml = "0.9"
28
- serde_json = "1"
28
+ serde_json = { version = "1", features = ["preserve_order"] }
29
29
  clap = { version = "4", features = ["derive"] }
30
30
  indoc = "2"
31
31
  rayon = "1"
@@ -34,3 +34,4 @@ rayon = "1"
34
34
  cbindgen = "0.28"
35
35
 
36
36
  [dev-dependencies]
37
+ tempfile = "3"
@@ -1,5 +1,14 @@
1
1
  use super::run_yerbafile;
2
2
 
3
- pub fn run() {
4
- run_yerbafile(true);
3
+ #[derive(clap::Args)]
4
+ #[command(about = "Apply Yerbafile rules to all matching files (or specific files)")]
5
+ pub struct Args {
6
+ /// Specific files to apply rules to (applies to all if omitted)
7
+ files: Vec<String>,
8
+ }
9
+
10
+ impl Args {
11
+ pub fn run(self) {
12
+ run_yerbafile(true, self.files);
13
+ }
5
14
  }
@@ -1,5 +1,14 @@
1
1
  use super::run_yerbafile;
2
2
 
3
- pub fn run() {
4
- run_yerbafile(false);
3
+ #[derive(clap::Args)]
4
+ #[command(about = "Check if files match Yerbafile rules (exits 1 if changes needed)")]
5
+ pub struct Args {
6
+ /// Specific files to check (checks all if omitted)
7
+ files: Vec<String>,
8
+ }
9
+
10
+ impl Args {
11
+ pub fn run(self) {
12
+ run_yerbafile(false, self.files);
13
+ }
5
14
  }
@@ -156,10 +156,8 @@ pub enum Command {
156
156
  Selectors(selectors::Args),
157
157
  #[command(about = "Create a new Yerbafile in the current directory")]
158
158
  Init,
159
- #[command(about = "Apply all rules from the Yerbafile and write changes")]
160
- Apply,
161
- #[command(about = "Check if all files match Yerbafile rules (exits 1 if not)")]
162
- Check,
159
+ Apply(apply::Args),
160
+ Check(check::Args),
163
161
  #[command(about = "Print the yerba version")]
164
162
  Version,
165
163
  #[command(about = "\u{1f9c9}")]
@@ -184,15 +182,15 @@ impl Command {
184
182
  Command::Directives(args) => args.run(),
185
183
  Command::Selectors(args) => args.run(),
186
184
  Command::Init => init::run(),
187
- Command::Apply => apply::run(),
188
- Command::Check => check::run(),
185
+ Command::Apply(args) => args.run(),
186
+ Command::Check(args) => args.run(),
189
187
  Command::Version => version::run(),
190
188
  Command::Mate => mate::run(),
191
189
  }
192
190
  }
193
191
  }
194
192
 
195
- pub(crate) fn run_yerbafile(write: bool) {
193
+ pub(crate) fn run_yerbafile(write: bool, files: Vec<String>) {
196
194
  use color::*;
197
195
 
198
196
  let yerbafile_path = yerba::Yerbafile::find().unwrap_or_else(|| {
@@ -207,7 +205,12 @@ pub(crate) fn run_yerbafile(write: bool) {
207
205
 
208
206
  eprintln!("🧉 {BOLD}Using{RESET} {}", yerbafile_path.display());
209
207
 
210
- let results = yerbafile.apply(write);
208
+ let results = if files.is_empty() {
209
+ yerbafile.apply(write)
210
+ } else {
211
+ files.iter().flat_map(|file| yerbafile.apply_file(file, write)).collect()
212
+ };
213
+
211
214
  let mut has_changes = false;
212
215
  let mut has_errors = false;
213
216
 
@@ -52,6 +52,76 @@ impl Document {
52
52
  self.insert_into(dot_path, &yaml_text, position)
53
53
  }
54
54
 
55
+ pub fn insert_objects(&mut self, dot_path: &str, json_values: &[serde_json::Value]) -> Result<(), YerbaError> {
56
+ if json_values.is_empty() {
57
+ return Ok(());
58
+ }
59
+
60
+ let quote_style = self.detect_sequence_quote_style(dot_path);
61
+ let current_node = self.navigate(dot_path)?;
62
+
63
+ let sequence = current_node
64
+ .descendants()
65
+ .find_map(BlockSeq::cast)
66
+ .ok_or_else(|| YerbaError::NotASequence(dot_path.to_string()))?;
67
+
68
+ let entries: Vec<_> = sequence.entries().collect();
69
+
70
+ if entries.is_empty() {
71
+ return Err(YerbaError::SelectorNotFound(dot_path.to_string()));
72
+ }
73
+
74
+ let indent = entries
75
+ .get(1)
76
+ .or(entries.first())
77
+ .map(|entry| preceding_whitespace_indent(entry.syntax()))
78
+ .unwrap_or_default();
79
+
80
+ let mut new_text = String::new();
81
+
82
+ for json_value in json_values {
83
+ let yaml_text = crate::yaml_writer::json_to_yaml_text(json_value, &quote_style, 0);
84
+
85
+ let new_item = if yaml_text.contains('\n') {
86
+ let item_indent = format!("{} ", indent);
87
+ let lines: Vec<&str> = yaml_text.split('\n').collect();
88
+
89
+ let min_indent = lines
90
+ .iter()
91
+ .skip(1)
92
+ .filter(|line| !line.trim().is_empty())
93
+ .map(|line| line.len() - line.trim_start().len())
94
+ .min()
95
+ .unwrap_or(0);
96
+
97
+ let indented: Vec<String> = lines
98
+ .iter()
99
+ .enumerate()
100
+ .map(|(index, line)| {
101
+ if index == 0 {
102
+ line.to_string()
103
+ } else if line.trim().is_empty() {
104
+ String::new()
105
+ } else {
106
+ let relative = &line[min_indent..];
107
+ format!("{}{}", item_indent, relative)
108
+ }
109
+ })
110
+ .collect();
111
+
112
+ format!("- {}", indented.join("\n"))
113
+ } else {
114
+ format!("- {}", yaml_text)
115
+ };
116
+
117
+ new_text.push_str(&format!("\n{}{}", indent, new_item));
118
+ }
119
+
120
+ let last_entry = entries.last().unwrap();
121
+
122
+ self.insert_after_node(last_entry.syntax(), &new_text)
123
+ }
124
+
55
125
  pub fn insert_into(&mut self, dot_path: &str, value: &str, position: InsertPosition) -> Result<(), YerbaError> {
56
126
  Self::validate_path(dot_path)?;
57
127
 
@@ -33,7 +33,17 @@ impl Document {
33
33
  }
34
34
 
35
35
  for node in nodes.into_iter().rev() {
36
- if let Some(scalar_token) = find_scalar_token(&node) {
36
+ if let Some(block_scalar) = node.descendants().find(|child| child.kind() == SyntaxKind::BLOCK_SCALAR) {
37
+ let new_text = if value.is_empty() {
38
+ "\"\"".to_string()
39
+ } else if value.contains('\n') {
40
+ format!("|-\n {}", value.replace('\n', "\n "))
41
+ } else {
42
+ format!("\"{}\"", value.replace('"', "\\\""))
43
+ };
44
+
45
+ self.apply_edit(block_scalar.text_range(), &new_text)?;
46
+ } else if let Some(scalar_token) = find_scalar_token(&node) {
37
47
  let new_text = format_scalar_value(value, scalar_token.kind());
38
48
 
39
49
  self.replace_token(&scalar_token, &new_text)?;
@@ -109,9 +109,7 @@ impl Document {
109
109
  }
110
110
 
111
111
  pub fn validate_sort_keys(&self, dot_path: &str, key_order: &[&str]) -> Result<(), YerbaError> {
112
- if dot_path == "[]" || dot_path.ends_with(".[]") {
113
- let sequence_path = if dot_path == "[]" { "" } else { &dot_path[..dot_path.len() - 3] };
114
-
112
+ if let Some(sequence_path) = strip_bracket_suffix(dot_path) {
115
113
  return self.validate_each_sort_keys(sequence_path, key_order);
116
114
  }
117
115
 
@@ -136,9 +134,7 @@ impl Document {
136
134
  }
137
135
 
138
136
  pub fn sort_keys(&mut self, dot_path: &str, key_order: &[&str]) -> Result<(), YerbaError> {
139
- if dot_path == "[]" || dot_path.ends_with(".[]") {
140
- let sequence_path = if dot_path == "[]" { "" } else { &dot_path[..dot_path.len() - 3] };
141
-
137
+ if let Some(sequence_path) = strip_bracket_suffix(dot_path) {
142
138
  return self.sort_each_keys(sequence_path, key_order);
143
139
  }
144
140
 
@@ -200,72 +196,85 @@ impl Document {
200
196
  }
201
197
 
202
198
  pub fn sort_each_keys(&mut self, dot_path: &str, key_order: &[&str]) -> Result<(), YerbaError> {
203
- let current_node = self.navigate(dot_path)?;
204
-
205
- let sequence = match current_node.descendants().find_map(BlockSeq::cast) {
206
- Some(sequence) => sequence,
207
- None => return Ok(()),
199
+ let nodes = if dot_path.is_empty() {
200
+ match self.navigate(dot_path) {
201
+ Ok(node) => vec![node],
202
+ Err(_) => return Ok(()),
203
+ }
204
+ } else {
205
+ let found = self.navigate_all(dot_path);
206
+ if found.is_empty() {
207
+ return Ok(());
208
+ }
209
+ found
208
210
  };
209
211
 
210
212
  let mut edits: Vec<(TextRange, String)> = Vec::new();
211
213
 
212
- for entry in sequence.entries() {
213
- let entry_node = entry.syntax();
214
-
215
- let map = match entry_node.descendants().find_map(BlockMap::cast) {
216
- Some(map) => map,
214
+ for current_node in &nodes {
215
+ let sequence = match current_node.descendants().find_map(BlockSeq::cast) {
216
+ Some(sequence) => sequence,
217
217
  None => continue,
218
218
  };
219
219
 
220
- let entries: Vec<_> = map.entries().collect();
220
+ for entry in sequence.entries() {
221
+ let entry_node = entry.syntax();
221
222
 
222
- if entries.len() <= 1 {
223
- continue;
224
- }
223
+ let map = match entry_node.descendants().find_map(BlockMap::cast) {
224
+ Some(map) => map,
225
+ None => continue,
226
+ };
225
227
 
226
- let (groups, group_range) = collect_groups_with_range(map.syntax());
228
+ let entries: Vec<_> = map.entries().collect();
227
229
 
228
- let mut keyed: Vec<(String, EntryGroup)> = entries
229
- .iter()
230
- .zip(groups)
231
- .map(|(entry, group)| {
232
- let key_name = entry.key().and_then(|key_node| extract_scalar_text(key_node.syntax())).unwrap_or_default();
233
- (key_name, group)
234
- })
235
- .collect();
230
+ if entries.len() <= 1 {
231
+ continue;
232
+ }
236
233
 
237
- let original_keys: Vec<String> = keyed.iter().map(|(key, _)| key.clone()).collect();
234
+ let (groups, group_range) = collect_groups_with_range(map.syntax());
235
+
236
+ let mut keyed: Vec<(String, EntryGroup)> = entries
237
+ .iter()
238
+ .zip(groups)
239
+ .map(|(entry, group)| {
240
+ let key_name = entry.key().and_then(|key_node| extract_scalar_text(key_node.syntax())).unwrap_or_default();
241
+ (key_name, group)
242
+ })
243
+ .collect();
238
244
 
239
- keyed.sort_by(|(key_a, _), (key_b, _)| {
240
- let position_a = key_order.iter().position(|&key| key == key_a);
241
- let position_b = key_order.iter().position(|&key| key == key_b);
245
+ let original_keys: Vec<String> = keyed.iter().map(|(key, _)| key.clone()).collect();
242
246
 
243
- match (position_a, position_b) {
244
- (Some(a), Some(b)) => a.cmp(&b),
245
- (Some(_), None) => std::cmp::Ordering::Less,
246
- (None, Some(_)) => std::cmp::Ordering::Greater,
247
+ keyed.sort_by(|(key_a, _), (key_b, _)| {
248
+ let position_a = key_order.iter().position(|&key| key == key_a);
249
+ let position_b = key_order.iter().position(|&key| key == key_b);
247
250
 
248
- (None, None) => {
249
- let original_a = original_keys.iter().position(|key| key == key_a).unwrap();
250
- let original_b = original_keys.iter().position(|key| key == key_b).unwrap();
251
+ match (position_a, position_b) {
252
+ (Some(a), Some(b)) => a.cmp(&b),
253
+ (Some(_), None) => std::cmp::Ordering::Less,
254
+ (None, Some(_)) => std::cmp::Ordering::Greater,
251
255
 
252
- original_a.cmp(&original_b)
256
+ (None, None) => {
257
+ let original_a = original_keys.iter().position(|key| key == key_a).unwrap();
258
+ let original_b = original_keys.iter().position(|key| key == key_b).unwrap();
259
+
260
+ original_a.cmp(&original_b)
261
+ }
253
262
  }
254
- }
255
- });
263
+ });
256
264
 
257
- let sorted_keys: Vec<&str> = keyed.iter().map(|(key, _)| key.as_str()).collect();
258
- let orig_refs: Vec<&str> = original_keys.iter().map(|key| key.as_str()).collect();
265
+ let sorted_keys: Vec<&str> = keyed.iter().map(|(key, _)| key.as_str()).collect();
266
+ let orig_refs: Vec<&str> = original_keys.iter().map(|key| key.as_str()).collect();
259
267
 
260
- if sorted_keys == orig_refs {
261
- continue;
262
- }
268
+ if sorted_keys == orig_refs {
269
+ continue;
270
+ }
263
271
 
264
- let indent = entries.get(1).map(|entry| preceding_whitespace_indent(entry.syntax())).unwrap_or_default();
272
+ let indent = entries.get(1).map(|entry| preceding_whitespace_indent(entry.syntax())).unwrap_or_default();
265
273
 
266
- let sorted_groups: Vec<EntryGroup> = keyed.into_iter().map(|(_, group)| group).collect();
267
- let map_text = rebuild_from_groups(&sorted_groups, &indent, false);
268
- edits.push((group_range, map_text));
274
+ let sorted_groups: Vec<EntryGroup> = keyed.into_iter().map(|(_, group)| group).collect();
275
+ let map_text = rebuild_from_groups(&sorted_groups, &indent, false);
276
+ edits.push((group_range, map_text));
277
+ }
269
278
  }
270
279
 
271
280
  if edits.is_empty() {
@@ -292,21 +301,34 @@ impl Document {
292
301
  }
293
302
 
294
303
  pub fn validate_each_sort_keys(&self, dot_path: &str, key_order: &[&str]) -> Result<(), YerbaError> {
295
- let current_node = self.navigate(dot_path)?;
296
-
297
- let sequence = match current_node.descendants().find_map(BlockSeq::cast) {
298
- Some(sequence) => sequence,
299
- None => return Ok(()),
304
+ let nodes = if dot_path.is_empty() {
305
+ match self.navigate(dot_path) {
306
+ Ok(node) => vec![node],
307
+ Err(_) => return Ok(()),
308
+ }
309
+ } else {
310
+ let found = self.navigate_all(dot_path);
311
+ if found.is_empty() {
312
+ return Ok(());
313
+ }
314
+ found
300
315
  };
301
316
 
302
317
  let mut all_unknown: Vec<String> = Vec::new();
303
318
 
304
- for entry in sequence.entries() {
305
- if let Some(map) = entry.syntax().descendants().find_map(BlockMap::cast) {
306
- for map_entry in map.entries() {
307
- if let Some(key_name) = map_entry.key().and_then(|key_node| extract_scalar_text(key_node.syntax())) {
308
- if !key_order.contains(&key_name.as_str()) && !all_unknown.contains(&key_name) {
309
- all_unknown.push(key_name);
319
+ for current_node in &nodes {
320
+ let sequence = match current_node.descendants().find_map(BlockSeq::cast) {
321
+ Some(sequence) => sequence,
322
+ None => continue,
323
+ };
324
+
325
+ for entry in sequence.entries() {
326
+ if let Some(map) = entry.syntax().descendants().find_map(BlockMap::cast) {
327
+ for map_entry in map.entries() {
328
+ if let Some(key_name) = map_entry.key().and_then(|key_node| extract_scalar_text(key_node.syntax())) {
329
+ if !key_order.contains(&key_name.as_str()) && !all_unknown.contains(&key_name) {
330
+ all_unknown.push(key_name);
331
+ }
310
332
  }
311
333
  }
312
334
  }
@@ -605,3 +627,13 @@ impl Document {
605
627
  Ok(())
606
628
  }
607
629
  }
630
+
631
+ fn strip_bracket_suffix(path: &str) -> Option<&str> {
632
+ if path == "[]" {
633
+ Some("")
634
+ } else if let Some(stripped) = path.strip_suffix(".[]") {
635
+ Some(stripped)
636
+ } else {
637
+ path.strip_suffix("[]")
638
+ }
639
+ }
data/rust/src/ffi.rs CHANGED
@@ -477,6 +477,23 @@ pub unsafe extern "C" fn yerba_document_delete(document: *mut Document, path: *c
477
477
  }
478
478
  }
479
479
 
480
+ #[no_mangle]
481
+ pub unsafe extern "C" fn yerba_document_insert_objects(document: *mut Document, path: *const c_char, json: *const c_char) -> YerbaResult {
482
+ let document = &mut *document;
483
+ let selector_string = CStr::from_ptr(path).to_str().unwrap_or("");
484
+ let json_string = CStr::from_ptr(json).to_str().unwrap_or("");
485
+
486
+ let json_values: Vec<serde_json::Value> = match serde_json::from_str(json_string) {
487
+ Ok(values) => values,
488
+ Err(e) => return YerbaResult::err(&format!("Invalid JSON: {}", e)),
489
+ };
490
+
491
+ match document.insert_objects(selector_string, &json_values) {
492
+ Ok(()) => YerbaResult::ok(),
493
+ Err(e) => YerbaResult::err(&e.to_string()),
494
+ }
495
+ }
496
+
480
497
  #[no_mangle]
481
498
  pub unsafe extern "C" fn yerba_document_remove(document: *mut Document, path: *const c_char, value: *const c_char) -> YerbaResult {
482
499
  let document = &mut *document;
@@ -609,6 +626,55 @@ pub unsafe extern "C" fn yerba_document_blank_lines(document: *mut Document, pat
609
626
  }
610
627
  }
611
628
 
629
+ /// Caller must free with yerba_string_free.
630
+ #[no_mangle]
631
+ pub unsafe extern "C" fn yerba_yerbafile_find(directory: *const c_char) -> *mut c_char {
632
+ let start = if directory.is_null() {
633
+ std::env::current_dir().ok()
634
+ } else {
635
+ CStr::from_ptr(directory).to_str().ok().map(std::path::PathBuf::from)
636
+ };
637
+
638
+ let path = match start {
639
+ Some(dir) => crate::Yerbafile::find_from(dir),
640
+ None => None,
641
+ };
642
+
643
+ match path {
644
+ Some(path) => CString::new(path.to_string_lossy().to_string()).unwrap_or_default().into_raw(),
645
+ None => ptr::null_mut(),
646
+ }
647
+ }
648
+
649
+ #[no_mangle]
650
+ pub unsafe extern "C" fn yerba_document_apply_yerbafile(document: *mut Document, file_path: *const c_char, yerbafile_path: *const c_char) -> YerbaResult {
651
+ let document = &mut *document;
652
+ let file_path_string = CStr::from_ptr(file_path).to_str().unwrap_or("");
653
+
654
+ let yerbafile = if yerbafile_path.is_null() {
655
+ match crate::Yerbafile::find() {
656
+ Some(path) => match crate::Yerbafile::load(&path) {
657
+ Ok(yerbafile) => yerbafile,
658
+ Err(e) => return YerbaResult::err(&e.to_string()),
659
+ },
660
+
661
+ None => return YerbaResult::err("No Yerbafile found"),
662
+ }
663
+ } else {
664
+ let path = CStr::from_ptr(yerbafile_path).to_str().unwrap_or("");
665
+
666
+ match crate::Yerbafile::load(path) {
667
+ Ok(yerbafile) => yerbafile,
668
+ Err(e) => return YerbaResult::err(&e.to_string()),
669
+ }
670
+ };
671
+
672
+ match yerbafile.apply_to_document(document, file_path_string) {
673
+ Ok(_) => YerbaResult::ok(),
674
+ Err(e) => YerbaResult::err(&e.to_string()),
675
+ }
676
+ }
677
+
612
678
  #[no_mangle]
613
679
  pub unsafe extern "C" fn yerba_document_to_string(document: *const Document) -> *mut c_char {
614
680
  let document = &*document;
data/rust/src/main.rs CHANGED
@@ -31,7 +31,9 @@ static HELP: LazyLock<String> = LazyLock::new(|| {
31
31
  Yerbafile:
32
32
  yerba init Create a new Yerbafile in the current directory
33
33
  yerba check Check if all files match the rules (exits 1 if not)
34
+ yerba check <file> Check a specific file against matching rules
34
35
  yerba apply Apply all rules and write changes
36
+ yerba apply <file> Apply rules to a specific file
35
37
 
36
38
  Examples:
37
39
  yerba get config.yml "database.host"
@@ -195,9 +195,13 @@ impl Yerbafile {
195
195
  }
196
196
 
197
197
  pub fn find() -> Option<PathBuf> {
198
+ Self::find_from(std::env::current_dir().ok()?)
199
+ }
200
+
201
+ pub fn find_from(start: impl AsRef<Path>) -> Option<PathBuf> {
198
202
  let candidates = ["Yerbafile", "Yerbafile.yml", "Yerbafile.yaml", ".yerbafile"];
199
203
 
200
- let mut directory = std::env::current_dir().ok()?;
204
+ let mut directory = start.as_ref().to_path_buf();
201
205
 
202
206
  loop {
203
207
  for candidate in &candidates {
@@ -356,6 +360,48 @@ impl Yerbafile {
356
360
  error: None,
357
361
  }
358
362
  }
363
+
364
+ pub fn apply_file(&self, file: &str, write: bool) -> Vec<RuleResult> {
365
+ let mut results = Vec::new();
366
+
367
+ for rule in &self.rules {
368
+ if let Ok(pattern) = glob::Pattern::new(&rule.files) {
369
+ if !pattern.matches(file) && !pattern.matches_path(Path::new(file)) {
370
+ continue;
371
+ }
372
+ } else {
373
+ continue;
374
+ }
375
+
376
+ results.push(self.apply_pipeline_to_file(rule, file, write));
377
+ }
378
+
379
+ results
380
+ }
381
+
382
+ pub fn apply_to_document(&self, document: &mut Document, file_path: &str) -> Result<bool, YerbaError> {
383
+ let original = document.to_string();
384
+
385
+ for rule in &self.rules {
386
+ if !file_path.is_empty() {
387
+ if let Ok(pattern) = glob::Pattern::new(&rule.files) {
388
+ if !pattern.matches(file_path) && !pattern.matches_path(Path::new(file_path)) {
389
+ continue;
390
+ }
391
+ } else {
392
+ continue;
393
+ }
394
+ }
395
+
396
+ let base_path = rule.path.as_deref();
397
+
398
+ for step in &rule.pipeline {
399
+ execute_step(document, step, base_path)?;
400
+ }
401
+ }
402
+
403
+ Ok(document.to_string() != original)
404
+ }
359
405
  }
360
406
 
361
407
  fn execute_step(document: &mut Document, step: &PipelineStep, base_path: Option<&str>) -> Result<(), YerbaError> {
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yerba
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - Marco Roth
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-04 00:00:00.000000000 Z
11
+ date: 2026-05-05 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A CLI tool for editing YAML while preserving structure, comments, and
14
14
  format.
@@ -36,9 +36,11 @@ files:
36
36
  - lib/yerba/formatting.rb
37
37
  - lib/yerba/location.rb
38
38
  - lib/yerba/map.rb
39
+ - lib/yerba/query_result.rb
39
40
  - lib/yerba/scalar.rb
40
41
  - lib/yerba/sequence.rb
41
42
  - lib/yerba/version.rb
43
+ - lib/yerba/yerbafile.rb
42
44
  - rust/Cargo.lock
43
45
  - rust/Cargo.toml
44
46
  - rust/build.rs