yerba 0.7.5 → 0.8.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: d03e5cb5c88654843c8c86735f91d1e550e81282ccd1c0db8813527c4b4f9898
4
- data.tar.gz: 805516b5ce364c09dab736e562f4a09d312d70b75ceb2c9e22a9e63fad45eb6d
3
+ metadata.gz: ef6a9038318ee4d534952892c7893d4deffc0980da76ad2967996ade55c5c4ed
4
+ data.tar.gz: 4c8ae4131876cbbefdfe0ab13bb2c8624161f07332e05dea0287f4a5eb313040
5
5
  SHA512:
6
- metadata.gz: 9cb2ace3f935a023bdd938cde43010f158efc18fce052a7fe5780cf3f9362779d0bc66a463f60453586b24e0b2e6b2ed180cf7d71d2f99095b57d0eb2adc92d8
7
- data.tar.gz: a4946b677d5b05d2741a09749e540056376dc2d2724f12bc383d722974638576bfdf022a9a02587c75b4167d9d9ae644ddcb45ed6d3f86167423c8d7263c7ba3
6
+ metadata.gz: c8bbc570ef74e8ec8b63cf97ed16d3cd1497ad9d7a77a9b09ace07b3418c9e8f592bcccad2929f33341ecb547411ea2cb3387f905963b86bd5c37e324ff5931f
7
+ data.tar.gz: 751785bf9a917245bf914e30cbf0b2bbcf1608be37c3a3d573a94e1f27fbab8fc245d2fc16efae0582e22e670aa5437975fda12bf8075ec15fc6249f697f0dd3
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.7"
50
+ yerba = "0.8"
51
51
  ```
52
52
 
53
53
  ```rust
@@ -96,8 +96,11 @@ Selectors let you address any node in a YAML document:
96
96
  | `key.nested` | Nested key path | `"database.settings.pool"` |
97
97
  | `[]` | All items in array | `"[].title"` |
98
98
  | `[N]` | Item at index | `"[0].title"` |
99
+ | `*` | All values in a map | `"database.*"` |
99
100
  | `[].key[].nested` | Nested array access | `"[].speakers[].name"` |
100
101
 
102
+ `[]` and `*` are the same idea applied to the two collection types: `[]` matches every item of a sequence, `*` matches every value of a map. Both can be combined with the other patterns, as in `"[].*"` or `"*.city"`.
103
+
101
104
  ### Conditions
102
105
 
103
106
  Conditions filter which items a command operates on:
@@ -109,6 +112,8 @@ Conditions filter which items a command operates on:
109
112
  | `.key contains val` | Substring or member | `".title contains Ruby"` |
110
113
  | `.key not_contains val` | Negated contains | `".title not_contains test"` |
111
114
 
115
+ The selector is relative to each item and starts with `.`. A condition that is malformed, or that uses an absolute selector where each item is tested, is rejected rather than matching nothing, so a typo is an error instead of a command that quietly does nothing.
116
+
112
117
  ---
113
118
 
114
119
  ### `get`
@@ -539,6 +544,7 @@ Available pipeline steps:
539
544
  - `directives` Add or remove the document start marker (`---`), with optional `max` validation
540
545
  - `unique` Find or remove duplicate items in a sequence
541
546
  - `schema` Validate against a JSON schema (with optional `path` for scoping)
547
+ - `final_newline` Enforce exact number of trailing newlines (`count: 1` by default, strips extras)
542
548
 
543
549
  This makes it easy to enforce project-wide YAML conventions in CI:
544
550
 
@@ -700,6 +706,12 @@ Set all matching nodes at once with `all: true`:
700
706
  document.set("[].description", "", all: true)
701
707
  ```
702
708
 
709
+ Setting a scalar over a key whose value is a collection replaces the collection:
710
+
711
+ ```ruby
712
+ document.root["venue"] = "none" # => venue: none
713
+ ```
714
+
703
715
  Insert new keys with positional control:
704
716
 
705
717
  ```ruby
@@ -828,6 +840,24 @@ document["database"].collection_style = :flow # => database: {host: localhost,
828
840
  document["database"].collection_style = :block # => database:\n host: localhost\n port: 5432
829
841
  ```
830
842
 
843
+ Flow collections are read and iterated like block ones, and their scalars can be
844
+ changed in place:
845
+
846
+ ```ruby
847
+ document = Yerba::Document.parse("tags: [ruby, rails]\n")
848
+
849
+ document["tags"].map(&:value) # => ["ruby", "rails"]
850
+ document["tags[1]"].value = "crystal" # => tags: [ruby, crystal]
851
+ ```
852
+
853
+ Adding or removing entries is not supported yet, and raises rather than
854
+ reformatting the collection. Switch to block style first:
855
+
856
+ ```ruby
857
+ document["tags"].collection_style = :block
858
+ document.delete("tags[0]")
859
+ ```
860
+
831
861
  ### Sequence Indent
832
862
 
833
863
  Control whether sequence items are indented under their key or at the same level:
@@ -892,17 +922,52 @@ document.locations("[].speakers[]")
892
922
 
893
923
  ### Wildcard Access
894
924
 
895
- When `[]` receives a wildcard selector (containing `[]`), it returns an array of nodes instead of a single node:
925
+ When `[]` receives a wildcard selector, it returns an array of nodes instead of a single node. `[]` matches every item of a sequence and `*` matches every value of a map:
896
926
 
897
927
  ```ruby
898
928
  document["[].title"] # => [Yerba::Scalar, Yerba::Scalar, ...]
899
929
  document["[].speakers[]"] # => [Yerba::Scalar, Yerba::Scalar, ...]
900
930
  document["items[].name"] # => [Yerba::Scalar, Yerba::Scalar, ...]
901
931
 
932
+ document["database.*"] # => [Yerba::Scalar, Yerba::Scalar, ...]
933
+ document["*"] # => every value of the root map
934
+ document["[].*"] # => every value of every item
935
+
902
936
  document["[].title"].each { |scalar| puts scalar.value }
903
937
  document["[].title"].each { |scalar| scalar.value = "Updated" }
904
938
  ```
905
939
 
940
+ Each node knows the key it belongs to, which is how a map value can be traced back to its name:
941
+
942
+ ```ruby
943
+ document["database.*"].map { |node| [node.key.value, node.value] }
944
+ # => [["host", "localhost"], ["port", 5432]]
945
+ ```
946
+
947
+ `get_all` resolves the same selectors in a single walk of the document, rather than looking each node up separately. It is the faster choice when reading many nodes, and returns nodes that are still connected to the document:
948
+
949
+ ```ruby
950
+ document.get_all("[].title") # => [Yerba::Scalar, Yerba::Scalar, ...]
951
+ document.get_all("database.*") # => [Yerba::Scalar, Yerba::Scalar, ...]
952
+
953
+ document.get_all("[].title").first.value = "Updated"
954
+ document.save!
955
+ ```
956
+
957
+ Reading values rather than nodes keeps a positional `nil` where a branch does not match, while resolving nodes skips it:
958
+
959
+ ```ruby
960
+ document.value_at("*.city") # => [nil, "Berlin", nil]
961
+ document.get_all("*.city").map(&:value) # => ["Berlin"]
962
+ ```
963
+
964
+ Wildcards address more than one node, so they cannot be written through. `set`, `insert` and `delete` raise rather than change every match at once:
965
+
966
+ ```ruby
967
+ document.set("database.*", "x") # => raises Yerba::Error
968
+ document.delete("database.*") # => raises Yerba::Error
969
+ ```
970
+
906
971
  ### Collections
907
972
 
908
973
  Operate on multiple files matching a glob pattern:
@@ -89,6 +89,10 @@ char *yerba_document_selectors(const struct Document *document);
89
89
 
90
90
  char *yerba_document_resolve_selectors(const struct Document *document, const char *path);
91
91
 
92
+ bool yerba_selector_has_wildcard(const char *path);
93
+
94
+ char *yerba_document_keys(const struct Document *document, const char *path);
95
+
92
96
  char *yerba_document_get_quote_style(const struct Document *document, const char *path);
93
97
 
94
98
  struct YerbaResult yerba_document_set_quote_style(struct Document *document,
@@ -107,6 +111,8 @@ struct YerbaResult yerba_document_set_sequence_indent(struct Document *document,
107
111
  const char *path,
108
112
  const char *style);
109
113
 
114
+ char *yerba_validate_condition(const char *condition, bool item_context);
115
+
110
116
  bool yerba_document_evaluate_condition(const struct Document *document,
111
117
  const char *parent_path,
112
118
  const char *condition);
@@ -213,6 +219,8 @@ void yerba_get_result_free(struct YerbaGetResult result);
213
219
 
214
220
  struct YerbaTypedList yerba_glob_get(const char *glob_pattern, const char *path);
215
221
 
222
+ struct YerbaTypedList yerba_document_get_all(const struct Document *document, const char *path);
223
+
216
224
  struct YerbaTypedList yerba_glob_find(const char *glob_pattern,
217
225
  const char *path,
218
226
  const char *condition,
data/ext/yerba/yerba.c CHANGED
@@ -98,6 +98,19 @@ static VALUE typed_value_to_ruby(YerbaTypedValue typed_value) {
98
98
  return result;
99
99
  }
100
100
 
101
+ static void check_condition(const char *condition, bool item_context) {
102
+ if (!condition) return;
103
+
104
+ char *error = yerba_validate_condition(condition, item_context);
105
+
106
+ if (!error) return;
107
+
108
+ VALUE message = make_utf8_string(error);
109
+ yerba_string_free(error);
110
+
111
+ rb_raise(rb_eError, "%s", StringValueCStr(message));
112
+ }
113
+
101
114
  static int should_proceed(struct Document *document, VALUE opts) {
102
115
  if (NIL_P(opts)) return 1;
103
116
  VALUE v_condition = rb_hash_aref(opts, ID2SYM(rb_intern("condition")));
@@ -106,6 +119,8 @@ static int should_proceed(struct Document *document, VALUE opts) {
106
119
  VALUE v_path = rb_hash_aref(opts, ID2SYM(rb_intern("condition_path")));
107
120
  const char *parent_path = NIL_P(v_path) ? "" : StringValueCStr(v_path);
108
121
 
122
+ check_condition(StringValueCStr(v_condition), parent_path[0] != '\0');
123
+
109
124
  return yerba_document_evaluate_condition(document, parent_path, StringValueCStr(v_condition));
110
125
  }
111
126
 
@@ -256,8 +271,7 @@ static VALUE document_bracket(VALUE self, VALUE path) {
256
271
 
257
272
  const char *selector = StringValueCStr(path);
258
273
 
259
- /* Wildcard: resolve to concrete selectors and return array of nodes */
260
- if (strstr(selector, "[]") != NULL) {
274
+ if (yerba_selector_has_wildcard(selector)) {
261
275
  char *json = yerba_document_resolve_selectors(document, selector);
262
276
 
263
277
  if (!json) return rb_ary_new();
@@ -338,6 +352,19 @@ static VALUE document_selectors(VALUE self) {
338
352
  return rb_funcall(rb_path2class("JSON"), rb_intern("parse"), 1, json_string);
339
353
  }
340
354
 
355
+ /* document.keys_at(selector) → ["host", "port", ...] */
356
+ static VALUE document_keys_at(VALUE self, VALUE path) {
357
+ struct Document *document = get_document(self);
358
+ char *json = yerba_document_keys(document, StringValueCStr(path));
359
+
360
+ if (!json) return rb_ary_new();
361
+
362
+ VALUE json_string = make_utf8_string(json);
363
+ yerba_string_free(json);
364
+
365
+ return rb_funcall(rb_path2class("JSON"), rb_intern("parse"), 1, json_string);
366
+ }
367
+
341
368
  /* document.locations(selector) → [Yerba::Location, ...] */
342
369
  static VALUE document_locations(VALUE self, VALUE path) {
343
370
  struct Document *document = get_document(self);
@@ -508,6 +535,8 @@ static VALUE document_condition_p(int argc, VALUE *argv, VALUE self) {
508
535
 
509
536
  struct Document *document = get_document(self);
510
537
 
538
+ check_condition(StringValueCStr(condition), parent_path[0] != '\0');
539
+
511
540
  return yerba_document_evaluate_condition(document, parent_path, StringValueCStr(condition)) ? Qtrue : Qfalse;
512
541
  }
513
542
 
@@ -528,6 +557,9 @@ static VALUE document_find(int argc, VALUE *argv, VALUE self) {
528
557
  }
529
558
 
530
559
  struct Document *document = get_document(self);
560
+
561
+ check_condition(condition, true);
562
+
531
563
  char *json = yerba_document_find(document, StringValueCStr(path), condition, select);
532
564
 
533
565
  if (!json) return rb_ary_new();
@@ -959,12 +991,7 @@ static VALUE document_path(VALUE self) {
959
991
  return rb_iv_get(self, "@path");
960
992
  }
961
993
 
962
- /* Collection.get(glob, selector) → [Yerba::Scalar|Map|Sequence, ...] */
963
- static VALUE collection_s_get(VALUE self, VALUE pattern, VALUE path) {
964
- (void) self;
965
-
966
- YerbaTypedList result = yerba_glob_get(StringValueCStr(pattern), StringValueCStr(path));
967
-
994
+ static VALUE located_list_to_ruby(YerbaTypedList result, VALUE document) {
968
995
  if (!result.json) return rb_ary_new();
969
996
 
970
997
  VALUE json_string = make_utf8_string(result.json);
@@ -992,6 +1019,23 @@ static VALUE collection_s_get(VALUE self, VALUE pattern, VALUE path) {
992
1019
  if (!NIL_P(file_path)) rb_hash_aset(kwargs, ID2SYM(rb_intern("file_path")), file_path);
993
1020
  if (!NIL_P(line)) rb_hash_aset(kwargs, ID2SYM(rb_intern("line")), line);
994
1021
  if (!NIL_P(location)) rb_hash_aset(kwargs, ID2SYM(rb_intern("location")), location);
1022
+ if (!NIL_P(document)) rb_hash_aset(kwargs, ID2SYM(rb_intern("document")), document);
1023
+
1024
+ VALUE key_name = rb_hash_aref(item, rb_str_new_cstr("key_name"));
1025
+
1026
+ if (!NIL_P(key_name)) {
1027
+ VALUE key_location = rb_hash_aref(item, rb_str_new_cstr("key_location"));
1028
+ VALUE key_kwargs = rb_hash_new();
1029
+
1030
+ rb_hash_aset(key_kwargs, ID2SYM(rb_intern("selector")), Qnil);
1031
+ rb_hash_aset(key_kwargs, ID2SYM(rb_intern("value")), key_name);
1032
+ if (!NIL_P(key_location)) rb_hash_aset(key_kwargs, ID2SYM(rb_intern("location")), key_location);
1033
+
1034
+ VALUE key_args[1] = { key_kwargs };
1035
+ VALUE key = rb_funcallv_kw(rb_path2class("Yerba::Scalar"), rb_intern("from"), 1, key_args, RB_PASS_KEYWORDS);
1036
+
1037
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("key")), key);
1038
+ }
995
1039
 
996
1040
  if (strcmp(type_str, "scalar") == 0) {
997
1041
  VALUE text = rb_hash_aref(item, rb_str_new_cstr("text"));
@@ -1015,6 +1059,20 @@ static VALUE collection_s_get(VALUE self, VALUE pattern, VALUE path) {
1015
1059
  return array;
1016
1060
  }
1017
1061
 
1062
+ /* Collection.get(glob, selector) → [Yerba::Scalar|Map|Sequence, ...] */
1063
+ static VALUE collection_s_get(VALUE self, VALUE pattern, VALUE path) {
1064
+ (void) self;
1065
+
1066
+ return located_list_to_ruby(yerba_glob_get(StringValueCStr(pattern), StringValueCStr(path)), Qnil);
1067
+ }
1068
+
1069
+ /* document.get_all(selector) → [Yerba::Scalar|Map|Sequence, ...] */
1070
+ static VALUE document_get_all(VALUE self, VALUE path) {
1071
+ struct Document *document = get_document(self);
1072
+
1073
+ return located_list_to_ruby(yerba_document_get_all(document, StringValueCStr(path)), self);
1074
+ }
1075
+
1018
1076
  /* Collection.find(glob, path, condition: nil, select: nil) */
1019
1077
  static VALUE collection_s_find(int argc, VALUE *argv, VALUE self) {
1020
1078
  (void)self;
@@ -1032,6 +1090,8 @@ static VALUE collection_s_find(int argc, VALUE *argv, VALUE self) {
1032
1090
  if (!NIL_P(v_select)) select = StringValueCStr(v_select);
1033
1091
  }
1034
1092
 
1093
+ check_condition(condition, true);
1094
+
1035
1095
  YerbaTypedList result = yerba_glob_find(StringValueCStr(pattern), StringValueCStr(path), condition, select);
1036
1096
 
1037
1097
  if (!result.json) return rb_ary_new();
@@ -1080,9 +1140,11 @@ void Init_yerba(void) {
1080
1140
  rb_define_method(rb_cDocument, "[]", document_bracket, 1);
1081
1141
  rb_define_method(rb_cDocument, "node_at", document_bracket, 1);
1082
1142
  rb_define_method(rb_cDocument, "value_at", document_get_value, 1);
1143
+ rb_define_method(rb_cDocument, "get_all", document_get_all, 1);
1083
1144
  rb_define_method(rb_cDocument, "location", document_location, -1);
1084
1145
  rb_define_method(rb_cDocument, "locations", document_locations, 1);
1085
1146
  rb_define_method(rb_cDocument, "selectors", document_selectors, 0);
1147
+ rb_define_method(rb_cDocument, "keys_at", document_keys_at, 1);
1086
1148
  rb_define_method(rb_cDocument, "resolve_selectors", document_resolve_selectors, 1);
1087
1149
  rb_define_method(rb_cDocument, "get_quote_style", document_get_quote_style, 1);
1088
1150
  rb_define_method(rb_cDocument, "set_quote_style", document_set_quote_style, 2);
data/lib/yerba/map.rb CHANGED
@@ -77,24 +77,29 @@ module Yerba
77
77
  end
78
78
 
79
79
  def keys
80
- if connected?
81
- results = document.find(@selector)
82
- return [] unless results.is_a?(Array) && results.first.is_a?(Hash)
80
+ return @data.keys unless connected?
83
81
 
84
- results.first.keys
85
- else
86
- @data.keys
87
- end
82
+ document.keys_at(@selector)
88
83
  end
89
84
 
90
85
  def each(&)
91
86
  return enum_for(:each) unless block_given?
92
87
 
93
- if connected?
94
- keys.each { |key| yield key, self[key] }
95
- else
88
+ unless connected?
96
89
  @data.each(&)
90
+ return self
97
91
  end
92
+
93
+ names = keys
94
+ values = document.get_all(@selector.empty? ? "*" : "#{@selector}.*")
95
+
96
+ if values.length == names.length && values.all?(&:key)
97
+ values.each { |value| yield value.key.value, value }
98
+ else
99
+ names.each { |key| yield key, self[key] }
100
+ end
101
+
102
+ self
98
103
  end
99
104
 
100
105
  def fetch(key)
data/lib/yerba/node.rb CHANGED
@@ -36,10 +36,10 @@ module Yerba
36
36
  instance
37
37
  end
38
38
 
39
- def from(file_path:, selector:, line: nil, location: nil, **attributes)
39
+ def from(selector:, file_path: nil, line: nil, location: nil, document: nil, key: nil, **attributes)
40
40
  instance = allocate
41
41
 
42
- instance.send(:init_node, nil, selector, coerce_location(location), nil, file_path, line)
42
+ instance.send(:init_node, document, selector, coerce_location(location), key, file_path, line)
43
43
  instance.send(:init_from, **attributes) if instance.respond_to?(:init_from, true)
44
44
 
45
45
  instance
@@ -90,10 +90,22 @@ module Yerba
90
90
  self
91
91
  end
92
92
 
93
- def each
93
+ def each(&)
94
94
  return enum_for(:each) unless block_given?
95
95
 
96
+ if connected?
97
+ items = document.get_all("#{@selector}[]")
98
+
99
+ if items.length == length
100
+ items.each(&)
101
+
102
+ return self
103
+ end
104
+ end
105
+
96
106
  length.times { |index| yield self[index] }
107
+
108
+ self
97
109
  end
98
110
 
99
111
  def find_by(selector = nil, value = nil, **criteria)
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.7.5"
4
+ VERSION = "0.8.0"
5
5
  end
data/rust/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "yerba"
3
- version = "0.7.5"
3
+ version = "0.8.0"
4
4
  edition = "2021"
5
5
  authors = ["Marco Roth <marco.roth@intergga.ch>"]
6
6
  description = "YAML Editing and Refactoring with Better Accuracy"
@@ -62,6 +62,16 @@ impl Args {
62
62
  condition.clone()
63
63
  });
64
64
 
65
+ if let Some(condition) = &normalized_condition {
66
+ if let Err(error) = yerba::validate_item_condition(condition) {
67
+ use super::color::*;
68
+
69
+ eprintln!("{RED}Error:{RESET} {}", error);
70
+
71
+ process::exit(1);
72
+ }
73
+ }
74
+
65
75
  let search_path_string = search_path.to_selector_string();
66
76
  let mut all_results: Vec<serde_json::Value> = Vec::new();
67
77
  let files = resolve_files(&self.file);
@@ -114,7 +124,7 @@ impl Args {
114
124
 
115
125
  let (values, selectors, lines): (Vec<yaml_serde::Value>, Vec<String>, Vec<usize>) = if select_fields.is_some() {
116
126
  if let Some(condition) = &normalized_condition {
117
- let triples = document.filter_with_selectors(&search_path_string, condition);
127
+ let triples = document.filter_with_selectors(&search_path_string, condition).unwrap_or_default();
118
128
  let (values, rest): (Vec<_>, Vec<_>) = triples.into_iter().map(|(v, s, l)| (v, (s, l))).unzip();
119
129
  let (selectors, lines): (Vec<_>, Vec<_>) = rest.into_iter().unzip();
120
130
 
@@ -129,7 +139,7 @@ impl Args {
129
139
  (values, selectors, lines)
130
140
  }
131
141
  } else if let Some(condition) = &normalized_condition {
132
- (document.filter(&search_path_string, condition), Vec::new(), Vec::new())
142
+ (document.filter(&search_path_string, condition).unwrap_or_default(), Vec::new(), Vec::new())
133
143
  } else {
134
144
  (document.get_values(&search_path_string), Vec::new(), Vec::new())
135
145
  };
@@ -8,15 +8,15 @@ use super::{output, parse_file, resolve_files, run_op};
8
8
  static EXAMPLES: LazyLock<String> = LazyLock::new(|| {
9
9
  colorize_examples(indoc! {r#"
10
10
  yerba rename config.yml "database.host" "database.hostname"
11
- yerba rename config.yml "database.host" "hostname"
12
- yerba rename config.yml "database.host" "settings.db_host"
13
11
  yerba rename videos.yml "[0].old_name" "[0].name"
12
+ yerba rename config.yml "database.host" "settings.db_host" # moves the key to another map
13
+ yerba rename config.yml "database.host" "hostname" # moves the key to the document root
14
14
  "#})
15
15
  });
16
16
 
17
17
  #[derive(clap::Args)]
18
18
  #[command(
19
- about = "Rename a key in a map (preserves value and position)",
19
+ about = "Rename a key, or move it to another map (the destination is a full selector path)",
20
20
  arg_required_else_help = true,
21
21
  after_help = EXAMPLES.as_str()
22
22
  )]
@@ -42,6 +42,16 @@ impl Args {
42
42
  pub fn run(self) {
43
43
  let parent_path = self.selector.rsplit_once('.').map(|(parent, _)| parent).unwrap_or("");
44
44
 
45
+ if let Some(condition) = &self.condition {
46
+ if let Err(error) = yerba::validate_condition(condition) {
47
+ use super::color::*;
48
+
49
+ eprintln!("{RED}Error:{RESET} {}", error);
50
+
51
+ std::process::exit(1);
52
+ }
53
+ }
54
+
45
55
  for resolved_file in resolve_files(&self.file) {
46
56
  let mut document = parse_file(&resolved_file);
47
57
 
@@ -50,7 +60,7 @@ impl Args {
50
60
  } else if self.if_missing {
51
61
  !document.exists(&self.selector)
52
62
  } else if let Some(condition) = &self.condition {
53
- document.evaluate_condition(parent_path, condition)
63
+ document.evaluate_condition(parent_path, condition).unwrap_or(false)
54
64
  } else {
55
65
  true
56
66
  };
@@ -1,29 +1,83 @@
1
1
  use super::*;
2
2
 
3
+ const CONDITION_OPERATORS: &str = "expected `<selector> <operator> <value>`, where operator is one of ==, !=, contains, not_contains";
4
+
5
+ pub fn validate_condition(condition: &str) -> Result<(), YerbaError> {
6
+ let trimmed = condition.trim();
7
+
8
+ if trimmed.is_empty() {
9
+ return Err(YerbaError::InvalidCondition(condition.to_string(), "condition is empty".to_string()));
10
+ }
11
+
12
+ let Some((left, _operator, _right)) = parse_condition(trimmed) else {
13
+ return Err(YerbaError::InvalidCondition(condition.to_string(), CONDITION_OPERATORS.to_string()));
14
+ };
15
+
16
+ if crate::selector::Selector::parse(&left).is_empty() {
17
+ return Err(YerbaError::InvalidCondition(
18
+ condition.to_string(),
19
+ "condition is missing a selector on the left of the operator".to_string(),
20
+ ));
21
+ }
22
+
23
+ Ok(())
24
+ }
25
+
26
+ pub fn validate_item_condition(condition: &str) -> Result<(), YerbaError> {
27
+ validate_condition(condition)?;
28
+
29
+ let trimmed = condition.trim();
30
+
31
+ let Some((left, _operator, _right)) = parse_condition(trimmed) else {
32
+ return Ok(());
33
+ };
34
+
35
+ if !crate::selector::Selector::parse(&left).is_relative() {
36
+ return Err(YerbaError::InvalidCondition(
37
+ condition.to_string(),
38
+ format!(
39
+ "selector \"{}\" is absolute, but this condition is tested against each item, so it must be relative and start with `.` — did you mean \".{}\"?",
40
+ left.trim(),
41
+ left.trim()
42
+ ),
43
+ ));
44
+ }
45
+
46
+ Ok(())
47
+ }
48
+
3
49
  impl Document {
4
- pub fn filter(&self, dot_path: &str, condition: &str) -> Vec<yaml_serde::Value> {
5
- self
6
- .navigate_all_compact(dot_path)
7
- .iter()
8
- .filter(|node| self.evaluate_condition_on_node(node, condition))
9
- .map(node_to_yaml_value)
10
- .collect()
50
+ pub fn filter(&self, dot_path: &str, condition: &str) -> Result<Vec<yaml_serde::Value>, YerbaError> {
51
+ validate_item_condition(condition)?;
52
+
53
+ Ok(
54
+ self
55
+ .navigate_all_compact(dot_path)
56
+ .iter()
57
+ .filter(|node| self.evaluate_condition_on_node(node, condition))
58
+ .map(node_to_yaml_value)
59
+ .collect(),
60
+ )
11
61
  }
12
62
 
13
- pub fn filter_with_selectors(&self, dot_path: &str, condition: &str) -> Vec<(yaml_serde::Value, String, usize)> {
63
+ pub fn filter_with_selectors(&self, dot_path: &str, condition: &str) -> Result<Vec<(yaml_serde::Value, String, usize)>, YerbaError> {
64
+ validate_item_condition(condition)?;
65
+
14
66
  let source = self.source_text();
15
67
 
16
- self
17
- .navigate_all_compact(dot_path)
18
- .iter()
19
- .filter(|node| self.evaluate_condition_on_node(node, condition))
20
- .map(|node| {
21
- let offset: usize = node.text_range().start().into();
22
- let line = line_at(&source, offset);
23
-
24
- (node_to_yaml_value(node), super::get::node_selector(node), line)
25
- })
26
- .collect()
68
+ Ok(
69
+ self
70
+ .navigate_all_compact(dot_path)
71
+ .iter()
72
+ .filter(|node| self.evaluate_condition_on_node(node, condition))
73
+ .map(|node| {
74
+ let offset: usize = node.text_range().start().into();
75
+ let line = line_at(&source, offset);
76
+
77
+ (node_to_yaml_value(node), super::get::node_selector(node), line)
78
+ })
79
+ .collect(),
80
+ )
27
81
  }
28
82
 
29
83
  pub(super) fn evaluate_condition_on_node(&self, node: &SyntaxNode, condition: &str) -> bool {
@@ -84,31 +138,32 @@ impl Document {
84
138
  }
85
139
  }
86
140
 
87
- pub fn evaluate_condition(&self, parent_path: &str, condition: &str) -> bool {
141
+ pub fn evaluate_condition(&self, parent_path: &str, condition: &str) -> Result<bool, YerbaError> {
142
+ if parent_path.is_empty() {
143
+ validate_condition(condition)?;
144
+ } else {
145
+ validate_item_condition(condition)?;
146
+ }
147
+
88
148
  let condition = condition.trim();
89
149
 
90
150
  let (left, operator, right) = match parse_condition(condition) {
91
151
  Some(parts) => parts,
92
- None => return false,
152
+ None => return Ok(false),
93
153
  };
94
154
 
95
155
  let path = crate::selector::Selector::parse(&left);
156
+ let path_string = path.to_selector_string();
96
157
 
97
- let full_path = if path.is_relative() {
98
- let path_string = path.to_selector_string();
99
-
100
- if parent_path.is_empty() {
101
- path_string
102
- } else {
103
- format!("{}.{}", parent_path, path_string)
104
- }
158
+ let full_path = if parent_path.is_empty() {
159
+ path_string
105
160
  } else {
106
- path.to_selector_string()
161
+ format!("{}.{}", parent_path, path_string)
107
162
  };
108
163
 
109
164
  let has_brackets = crate::selector::Selector::parse(&full_path).has_brackets();
110
165
 
111
- match operator {
166
+ let result = match operator {
112
167
  "==" => {
113
168
  if has_brackets {
114
169
  self.get_all(&full_path).iter().any(|value| value == &right)
@@ -150,6 +205,8 @@ impl Document {
150
205
  }
151
206
  }
152
207
  _ => false,
153
- }
208
+ };
209
+
210
+ Ok(result)
154
211
  }
155
212
  }