yerba 0.6.1 → 0.7.0

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: bb111282dd32e6a5bf4f79fc20fb71b1e4f40f56424b27ee74d14e581ca4f645
4
- data.tar.gz: 6e3e5e7066e624e3263beaee6c76cebfc02c4ab645c282bfc8760264f5e19ae3
3
+ metadata.gz: 9b0696495b8437f9deea398cba7c4e6af00a8983ffa0cb519492aca22426ba46
4
+ data.tar.gz: 42daabe671e951b350286a5ec6a63690466aff8f8b8828ebd8e570e8fc8758d3
5
5
  SHA512:
6
- metadata.gz: 6d489126e1fbec945069ad81e6b16775a562133364a9a196061d7024248c155ef72e11c73ffcd292bf69ddf601545ba32087cce155d64f3673a91487b2a0a0d1
7
- data.tar.gz: 75403eab424bfd79654703fc43640ee45f5e8b9f0142805e4fce938bb27c5bc510046d602ccd1a8c2be082c749a29454fd15aae2f1b2de90c05c6d22bd04dd0a
6
+ metadata.gz: 2e245101d150f74c63f49a8caaa7e78f7966f25934ee6678a42629b4633758d6f74e3e638892dd4972ec11f0f2c734f93df85debebe809abcbaad19ca67380f5
7
+ data.tar.gz: 289f195705e6d427bb6f263b8dc826b1107923cdd7022594d2806e47906f6acac3011ae9fb40802e6313bda281d66662168a93c525d5868e14e9435a99758f8b
data/README.md CHANGED
@@ -47,7 +47,7 @@ Use `yerba` as a library in your Rust project:
47
47
 
48
48
  ```toml
49
49
  [dependencies]
50
- yerba = "0.5"
50
+ yerba = "0.7"
51
51
  ```
52
52
 
53
53
  ```rust
@@ -476,49 +476,58 @@ yerba check
476
476
  yerba check path/to/file.yml
477
477
  ```
478
478
 
479
- Each rule specifies a file glob and a list of steps to run in order:
479
+ A Yerbafile supports a global `pipeline` that runs on all matching files, plus per-rule pipelines for file-specific steps. Global steps run first, then per-rule steps refine or override:
480
480
 
481
481
  ```yaml
482
+ files: "data/**/*.yml"
483
+
484
+ pipeline:
485
+ - directives:
486
+ max: 1
487
+ ensure: true
488
+
489
+ - collection_style:
490
+ style: block
491
+
492
+ - sequence_indent:
493
+ style: indented
494
+
495
+ - quote_style:
496
+ key_style: plain
497
+ value_style: double
498
+
482
499
  rules:
483
- - files: "config/**/*.yml"
500
+ - files: "data/**/videos.yml"
484
501
  pipeline:
485
502
  - quote_style:
486
- key_style: plain
487
- value_style: double
503
+ path: "[].speakers"
504
+ value_style: plain
488
505
 
489
506
  - sort_keys:
490
- path: ""
507
+ path: "[]"
491
508
  order:
492
509
  - id
493
510
  - title
494
- - description
495
-
496
- - blank_lines:
497
- count: 1
511
+ - speakers
498
512
 
499
513
  - files: "data/speakers.yml"
500
514
  pipeline:
501
- - quote_style:
502
- key_style: plain
503
- value_style: double
504
-
505
515
  - sort_keys:
506
- path: ""
516
+ path: "[]"
507
517
  order:
508
518
  - name
509
519
  - slug
510
520
  - github
511
- - twitter
512
- - website
513
521
 
514
522
  - sort:
515
- path: ""
516
523
  by: name
517
524
  ```
518
525
 
519
526
  Available pipeline steps:
520
527
 
521
528
  - `quote_style` Enforce quote style on keys and/or values, optionally scoped by path
529
+ - `collection_style` Enforce flow or block style on collections
530
+ - `sequence_indent` Enforce compact or indented sequence style
522
531
  - `sort_keys` Reorder keys to match a predefined list
523
532
  - `sort` Sort sequence items by field(s)
524
533
  - `blank_lines` Enforce blank lines between sequence entries
@@ -527,10 +536,9 @@ Available pipeline steps:
527
536
  - `delete` Remove a key (supports conditions)
528
537
  - `rename` Rename a key
529
538
  - `remove` Remove an item from a sequence
530
- - `directives` Add or remove the document start marker (`---`)
539
+ - `directives` Add or remove the document start marker (`---`), with optional `max` validation
531
540
  - `unique` Find or remove duplicate items in a sequence
532
541
  - `schema` Validate against a JSON schema (with optional `path` for scoping)
533
- - `get` Read a value and store it as a variable for subsequent steps
534
542
 
535
543
  This makes it easy to enforce project-wide YAML conventions in CI:
536
544
 
@@ -558,6 +566,65 @@ document = Yerba.parse(<<~YAML)
558
566
  YAML
559
567
  ```
560
568
 
569
+ ### Creating Documents
570
+
571
+ Build documents from Ruby objects:
572
+
573
+ ```ruby
574
+ document = Yerba::Document.from({ name: "Alice", tags: ["ruby", "rails"] })
575
+ document = Yerba::Document.from([{ id: "talk-1", title: "First Talk" }])
576
+ ```
577
+
578
+ Build incrementally using `Document.new` and `root=`:
579
+
580
+ ```ruby
581
+ document = Yerba::Document.new
582
+ document.root = {}
583
+
584
+ document["name"] = "Event 123"
585
+ document["kind"] = "conference"
586
+ document["tags"] = ["ruby", "rails"]
587
+ document["config"] = { host: "localhost", port: 5432 }
588
+
589
+ document.save_to!("event.yml")
590
+ ```
591
+
592
+ ```yaml
593
+ ---
594
+ name: Event 123
595
+ kind: conference
596
+ tags:
597
+ - ruby
598
+ - rails
599
+ config:
600
+ host: localhost
601
+ port: 5432
602
+ ```
603
+
604
+ Or start with `Document.from` and keep building:
605
+
606
+ ```ruby
607
+ document = Yerba::Document.from({ name: "Event 123" })
608
+
609
+ document["tags"] = []
610
+ document["tags"] << "ruby"
611
+ document["tags"] << "rails"
612
+ ```
613
+
614
+ Build a sequence document and append entries:
615
+
616
+ ```ruby
617
+ document = Yerba::Document.new
618
+ document.root = []
619
+
620
+ document << { id: "talk-1", title: "First Talk", speakers: ["Alice"] }
621
+ document << { id: "talk-2", title: "Second Talk", speakers: ["Bob", "Carol"] }
622
+
623
+ document.save_to!("videos.yml")
624
+ ```
625
+
626
+ `document["key"]` and `document << item` are shortcuts for `document.root["key"]` and `document.root << item`.
627
+
561
628
  ### Reading Values
562
629
 
563
630
  Use bracket notation (`[]`) to navigate the document. Returns typed node objects (`Scalar`, `Map`, or `Sequence`) that are live references — mutations flow back to the document.
@@ -639,6 +706,27 @@ Insert new keys with positional control:
639
706
  document["database"].insert("ssl", true, after: "host")
640
707
  ```
641
708
 
709
+ Set arrays and hashes as values, they default to block style:
710
+
711
+ ```ruby
712
+ document["database"]["tags"] = ["ruby", "rails"]
713
+ # tags:
714
+ # - ruby
715
+ # - rails
716
+
717
+ document["database"]["settings"] = { pool: 5, timeout: 30 }
718
+ # settings:
719
+ # pool: 5
720
+ # timeout: 30
721
+ ```
722
+
723
+ Use `set` with `style: :flow` for inline formatting:
724
+
725
+ ```ruby
726
+ document.root.set("tags", ["ruby", "rails"], style: :flow)
727
+ # tags: [ruby, rails]
728
+ ```
729
+
642
730
  Work with sequences using familiar Ruby patterns:
643
731
 
644
732
  ```ruby
@@ -723,6 +811,40 @@ scalar.quote_style # => :double
723
811
  scalar.quote_style = :single
724
812
  ```
725
813
 
814
+ ### Collection Style
815
+
816
+ Read and change how collections are rendered, flow (inline) or block (multi-line):
817
+
818
+ ```ruby
819
+ document["tags"].collection_style # => :block or :flow
820
+ document["tags"].collection_style = :flow # => tags: [ruby, rails]
821
+ document["tags"].collection_style = :block # => tags:\n - ruby\n - rails
822
+ ```
823
+
824
+ Works on both sequences and maps:
825
+
826
+ ```ruby
827
+ document["database"].collection_style = :flow # => database: {host: localhost, port: 5432}
828
+ document["database"].collection_style = :block # => database:\n host: localhost\n port: 5432
829
+ ```
830
+
831
+ ### Sequence Indent
832
+
833
+ Control whether sequence items are indented under their key or at the same level:
834
+
835
+ ```ruby
836
+ document["tags"].sequence_indent # => :indented or :compact
837
+ document["tags"].sequence_indent = :compact # compact style
838
+ document["tags"].sequence_indent = :indented # indented style
839
+ ```
840
+
841
+ ```yaml
842
+ # :compact # :indented
843
+ tags: tags:
844
+ - ruby - ruby
845
+ - rails - rails
846
+ ```
847
+
726
848
  ### Location
727
849
 
728
850
  Get the precise location (line, column, byte offset) of any selector in a document:
@@ -835,6 +957,12 @@ Write changes back to the original file:
835
957
  document.save!
836
958
  ```
837
959
 
960
+ Save to a new path:
961
+
962
+ ```ruby
963
+ document.save_to!("output.yml")
964
+ ```
965
+
838
966
  Or render the document as a string without writing to disk:
839
967
 
840
968
  ```ruby
data/ext/yerba/yerba.c CHANGED
@@ -110,9 +110,20 @@ static int should_proceed(struct Document *document, VALUE opts) {
110
110
  }
111
111
 
112
112
  /* Document.new(path) */
113
- static VALUE document_initialize(VALUE self, VALUE path) {
114
- const char *file_path = StringValueCStr(path);
115
- YerbaParseResult result = yerba_document_parse_file(file_path);
113
+ static VALUE document_initialize(int argc, VALUE *argv, VALUE self) {
114
+ VALUE path;
115
+ rb_scan_args(argc, argv, "01", &path);
116
+
117
+ YerbaParseResult result;
118
+
119
+ if (NIL_P(path)) {
120
+ result = yerba_document_parse("---\n");
121
+ rb_iv_set(self, "@path", Qnil);
122
+ } else {
123
+ const char *file_path = StringValueCStr(path);
124
+ result = yerba_document_parse_file(file_path);
125
+ rb_iv_set(self, "@path", path);
126
+ }
116
127
 
117
128
  if (!result.document) {
118
129
  VALUE message = make_utf8_string(result.error);
@@ -122,7 +133,29 @@ static VALUE document_initialize(VALUE self, VALUE path) {
122
133
  }
123
134
 
124
135
  RTYPEDDATA_DATA(self) = result.document;
125
- rb_iv_set(self, "@path", path);
136
+
137
+ return self;
138
+ }
139
+
140
+ /* document.replace_content!(content) — re-parse from YAML string, keeping the same Ruby object */
141
+ static VALUE document_replace_content(VALUE self, VALUE content) {
142
+ const char *yaml_content = StringValueCStr(content);
143
+ YerbaParseResult result = yerba_document_parse(yaml_content);
144
+
145
+ if (!result.document) {
146
+ VALUE message = make_utf8_string(result.error);
147
+ yerba_string_free(result.error);
148
+
149
+ rb_raise(rb_eParseError, "%s", StringValueCStr(message));
150
+ }
151
+
152
+ struct Document *old_document = get_document(self);
153
+
154
+ if (old_document) {
155
+ yerba_document_free(old_document);
156
+ }
157
+
158
+ RTYPEDDATA_DATA(self) = result.document;
126
159
 
127
160
  return self;
128
161
  }
@@ -1024,7 +1057,8 @@ void Init_yerba(void) {
1024
1057
  rb_cDocument = rb_define_class_under(rb_mYerba, "Document", rb_cObject);
1025
1058
 
1026
1059
  rb_define_alloc_func(rb_cDocument, document_alloc);
1027
- rb_define_method(rb_cDocument, "initialize", document_initialize, 1);
1060
+ rb_define_method(rb_cDocument, "initialize", document_initialize, -1);
1061
+ rb_define_method(rb_cDocument, "replace_content!", document_replace_content, 1);
1028
1062
  rb_define_singleton_method(rb_cDocument, "parse", document_s_parse, 1);
1029
1063
  rb_define_method(rb_cDocument, "[]", document_bracket, 1);
1030
1064
  rb_define_method(rb_cDocument, "node_at", document_bracket, 1);
@@ -12,6 +12,12 @@ module Yerba
12
12
  @cache = nil
13
13
  end
14
14
 
15
+ def self.from(object, path: nil)
16
+ document = parse(Formatting.to_yaml_document(object))
17
+ document.instance_variable_set(:@path, path) if path
18
+ document
19
+ end
20
+
15
21
  def selector
16
22
  ROOT_SELECTOR
17
23
  end
@@ -20,7 +26,19 @@ module Yerba
20
26
  self[ROOT_SELECTOR]
21
27
  end
22
28
 
29
+ def root=(value)
30
+ replace_content!(Formatting.to_yaml_document(value))
31
+ end
32
+
23
33
  def []=(key, value)
34
+ if root.is_a?(Scalar)
35
+ raise Error, "document root is not set. Use `document.root = {}` or `document.root = []` first"
36
+ end
37
+
38
+ if root.is_a?(Sequence)
39
+ raise Error, "document root is a Sequence, not a Map. Use `document << item` to append, or `document.root = {}` to switch to a Map"
40
+ end
41
+
24
42
  root[key] = value
25
43
  end
26
44
 
@@ -44,6 +62,11 @@ module Yerba
44
62
  to_s
45
63
  end
46
64
 
65
+ def save_to!(path)
66
+ @path = path
67
+ save!
68
+ end
69
+
47
70
  def dig(*keys)
48
71
  keys.reduce(self) { |node, key| node.nil? ? nil : node[key] }
49
72
  end
@@ -67,6 +90,14 @@ module Yerba
67
90
  end
68
91
 
69
92
  def <<(item)
93
+ if root.is_a?(Scalar)
94
+ raise Error, "document root is not set. Use `document.root = []` first"
95
+ end
96
+
97
+ if root.is_a?(Map)
98
+ raise Error, "document root is a Map, not a Sequence. Use `document[\"key\"] = value` to set keys, or `document.root = []` to switch to a Sequence"
99
+ end
100
+
70
101
  root << item
71
102
  end
72
103
 
@@ -75,5 +75,16 @@ module Yerba
75
75
  else value.to_s
76
76
  end
77
77
  end
78
+
79
+ def self.to_yaml_document(value)
80
+ case value
81
+ when Array
82
+ value.empty? ? "---\n[]\n" : "---\n#{to_block_yaml_value(value)}\n"
83
+ when Hash
84
+ value.empty? ? "---\n{}\n" : "---\n#{to_block_yaml_value(value)}\n"
85
+ else
86
+ raise ArgumentError, "expected Array or Hash, got #{value.class}"
87
+ end
88
+ end
78
89
  end
79
90
  end
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.6.1"
4
+ VERSION = "0.7.0"
5
5
  end
data/rust/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "yerba"
3
- version = "0.6.1"
3
+ version = "0.7.0"
4
4
  edition = "2021"
5
5
  authors = ["Marco Roth <marco.roth@intergga.ch>"]
6
6
  description = "YAML Editing and Refactoring with Better Accuracy"
@@ -154,7 +154,22 @@ impl Document {
154
154
 
155
155
  let map = match node.descendants().find_map(BlockMap::cast) {
156
156
  Some(map) => map,
157
- None => continue,
157
+ None => {
158
+ let has_empty_flow_map = node
159
+ .descendants()
160
+ .any(|descendant| descendant.kind() == SyntaxKind::FLOW_MAP && !descendant.descendants().any(|child| child.kind() == SyntaxKind::FLOW_MAP_ENTRY));
161
+
162
+ if has_empty_flow_map {
163
+ let selectors = self.resolve_selectors(parent_path);
164
+
165
+ if let Some(selector) = selectors.get(i) {
166
+ self.replace_empty_inline_map(selector, key, value)?;
167
+ continue;
168
+ }
169
+ }
170
+
171
+ continue;
172
+ }
158
173
  };
159
174
 
160
175
  if find_entry_by_key(&map, key).is_some() {
@@ -178,7 +193,35 @@ impl Document {
178
193
  };
179
194
 
180
195
  let indent = " ".repeat(start_col);
181
- let new_entry_text = format!("{}: {}", key, value);
196
+
197
+ let is_block_value = value.contains('\n') || value.starts_with("- ");
198
+ let new_entry_text = if is_block_value {
199
+ let value_indent = format!("{} ", indent);
200
+ let lines: Vec<&str> = value.lines().collect();
201
+
202
+ let min_indent = lines
203
+ .iter()
204
+ .filter(|line| !line.trim().is_empty())
205
+ .map(|line| line.len() - line.trim_start().len())
206
+ .min()
207
+ .unwrap_or(0);
208
+
209
+ let indented_lines: Vec<String> = lines
210
+ .iter()
211
+ .map(|line| {
212
+ if line.trim().is_empty() {
213
+ String::new()
214
+ } else {
215
+ let relative = &line[min_indent..];
216
+ format!("{}{}", value_indent, relative)
217
+ }
218
+ })
219
+ .collect();
220
+
221
+ format!("{}:\n{}", key, indented_lines.join("\n"))
222
+ } else {
223
+ format!("{}: {}", key, value)
224
+ };
182
225
 
183
226
  match &position {
184
227
  InsertPosition::After(target_key) => {
@@ -370,10 +413,22 @@ impl Document {
370
413
  fn insert_map_key(&mut self, dot_path: &str, key: &str, value: &str, position: InsertPosition) -> Result<(), YerbaError> {
371
414
  let current_node = self.navigate(dot_path)?;
372
415
 
373
- let map = current_node
374
- .descendants()
375
- .find_map(BlockMap::cast)
376
- .ok_or_else(|| YerbaError::SelectorNotFound(dot_path.to_string()))?;
416
+ let map = match current_node.descendants().find_map(BlockMap::cast) {
417
+ Some(map) => map,
418
+ None => {
419
+ let flow_map = current_node.descendants().find(|descendant| descendant.kind() == SyntaxKind::FLOW_MAP);
420
+
421
+ if let Some(flow_map_node) = flow_map {
422
+ let is_empty = !flow_map_node.descendants().any(|descendant| descendant.kind() == SyntaxKind::FLOW_MAP_ENTRY);
423
+
424
+ if is_empty {
425
+ return self.replace_empty_inline_map(dot_path, key, value);
426
+ }
427
+ }
428
+
429
+ return Err(YerbaError::SelectorNotFound(dot_path.to_string()));
430
+ }
431
+ };
377
432
 
378
433
  let entries: Vec<_> = map.entries().collect();
379
434
 
@@ -495,21 +550,93 @@ impl Document {
495
550
  }
496
551
  }
497
552
 
553
+ fn replace_empty_inline_map(&mut self, dot_path: &str, key: &str, value: &str) -> Result<(), YerbaError> {
554
+ let current_node = self.navigate(dot_path)?;
555
+
556
+ if dot_path.is_empty() {
557
+ let flow_map = current_node
558
+ .descendants()
559
+ .find(|descendant| descendant.kind() == SyntaxKind::FLOW_MAP)
560
+ .ok_or_else(|| YerbaError::SelectorNotFound(dot_path.to_string()))?;
561
+
562
+ let range = flow_map.text_range();
563
+
564
+ let source = self.to_string();
565
+ let start: usize = range.start().into();
566
+ let before = &source[..start];
567
+ let trimmed_length = before.trim_end_matches([' ', '\n']).len();
568
+
569
+ let adjusted_range = TextRange::new(rowan::TextSize::from(trimmed_length as u32), range.end());
570
+ let replacement = format!("\n{}: {}", key, value);
571
+
572
+ return self.apply_edit(adjusted_range, &replacement);
573
+ }
574
+
575
+ let (parent_path, map_key) = dot_path.rsplit_once('.').unwrap_or(("", dot_path));
576
+ let parent_node = self.navigate(parent_path)?;
577
+
578
+ let map = parent_node
579
+ .descendants()
580
+ .find_map(BlockMap::cast)
581
+ .ok_or_else(|| YerbaError::SelectorNotFound(dot_path.to_string()))?;
582
+
583
+ let entry = find_entry_by_key(&map, map_key).ok_or_else(|| YerbaError::SelectorNotFound(dot_path.to_string()))?;
584
+ let entry_indent = preceding_whitespace_indent(entry.syntax());
585
+ let child_indent = format!("{} ", entry_indent);
586
+ let new_entry_text = format!("{}{}: {}", child_indent, key, value);
587
+
588
+ let mut range = current_node.text_range();
589
+
590
+ if let Some(previous) = current_node.prev_sibling_or_token().and_then(|element| element.into_token()) {
591
+ if previous.kind() == SyntaxKind::WHITESPACE && !previous.text().contains('\n') {
592
+ range = TextRange::new(previous.text_range().start(), range.end());
593
+ }
594
+ }
595
+
596
+ let replacement = format!("\n{}", new_entry_text);
597
+
598
+ self.apply_edit(range, &replacement)
599
+ }
600
+
498
601
  fn replace_empty_inline_sequence(&mut self, dot_path: &str, value: &str) -> Result<(), YerbaError> {
602
+ if dot_path.is_empty() {
603
+ let current_node = self.navigate(dot_path)?;
604
+ let flow_seq = current_node
605
+ .descendants()
606
+ .find(|descendant| descendant.kind() == SyntaxKind::FLOW_SEQ)
607
+ .ok_or_else(|| YerbaError::NotASequence(dot_path.to_string()))?;
608
+
609
+ let new_item = Self::format_sequence_item(value, "");
610
+ let range = flow_seq.text_range();
611
+ let source = self.to_string();
612
+ let start: usize = range.start().into();
613
+ let before = &source[..start];
614
+
615
+ let trimmed_length = before.trim_end_matches([' ', '\n']).len();
616
+ let adjusted_start = trimmed_length;
617
+
618
+ let adjusted_range = TextRange::new(rowan::TextSize::from(adjusted_start as u32), range.end());
619
+ let replacement = format!("\n{}", new_item);
620
+
621
+ return self.apply_edit(adjusted_range, &replacement);
622
+ }
623
+
499
624
  let (parent_path, key) = dot_path.rsplit_once('.').unwrap_or(("", dot_path));
500
625
  let parent_node = self.navigate(parent_path)?;
626
+
501
627
  let map = parent_node
502
628
  .descendants()
503
629
  .find_map(BlockMap::cast)
504
630
  .ok_or_else(|| YerbaError::NotASequence(dot_path.to_string()))?;
505
- let entry = find_entry_by_key(&map, key).ok_or_else(|| YerbaError::SelectorNotFound(dot_path.to_string()))?;
506
631
 
632
+ let entry = find_entry_by_key(&map, key).ok_or_else(|| YerbaError::SelectorNotFound(dot_path.to_string()))?;
507
633
  let item_indent = format!("{} ", preceding_whitespace_indent(entry.syntax()));
508
634
  let new_item = Self::format_sequence_item(value, &item_indent);
509
635
  let current_node = self.navigate(dot_path)?;
510
- let mut range = current_node.text_range();
511
636
  let inline_comment = self.trailing_inline_comment(&current_node);
512
637
 
638
+ let mut range = current_node.text_range();
639
+
513
640
  if let Some(previous) = current_node.prev_sibling_or_token().and_then(|element| element.into_token()) {
514
641
  if previous.kind() == SyntaxKind::WHITESPACE && !previous.text().contains('\n') {
515
642
  range = TextRange::new(previous.text_range().start(), range.end());
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yerba
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marco Roth