yerba 0.4.2 → 0.5.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.
@@ -70,20 +70,22 @@ impl Args {
70
70
  for resolved_file in &files {
71
71
  let document = parse_file(resolved_file);
72
72
 
73
- if normalized_condition.is_none() && !document.exists(&self.selector) {
73
+ if !document.exists(&self.selector) {
74
74
  if is_glob {
75
75
  continue;
76
76
  }
77
77
 
78
78
  use super::color::*;
79
79
 
80
- eprintln!("{RED}Error:{RESET} selector \"{}\" not found in {}", self.selector, resolved_file);
80
+ eprintln!("{RED}Error:{RESET} selector \"{}\" not found in {}", self.selector, self.file);
81
81
 
82
- show_similar_selectors(resolved_file, &document, &self.selector);
82
+ show_similar_selectors(&self.file, &document, &self.selector);
83
83
  process::exit(1);
84
84
  }
85
85
 
86
86
  if let Some(fields) = &select_fields {
87
+ let mut missing_field = false;
88
+
87
89
  for field in fields {
88
90
  let field_trimmed = field.trim().trim_start_matches('.');
89
91
  let full_selector = if search_path_string.is_empty() {
@@ -93,24 +95,49 @@ impl Args {
93
95
  };
94
96
 
95
97
  if !document.exists(&full_selector) {
98
+ if is_glob {
99
+ missing_field = true;
100
+ break;
101
+ }
102
+
96
103
  use super::color::*;
97
- eprintln!("{RED}Error:{RESET} select field \"{}\" not found in {}", field.trim(), resolved_file);
98
- show_similar_selectors(resolved_file, &document, &full_selector);
104
+ eprintln!("{RED}Error:{RESET} select field \"{}\" not found in {}", field.trim(), self.file);
105
+ show_similar_selectors(&self.file, &document, &full_selector);
99
106
  process::exit(1);
100
107
  }
101
108
  }
109
+
110
+ if missing_field {
111
+ continue;
112
+ }
102
113
  }
103
114
 
104
- let values: Vec<serde_yaml::Value> = if let Some(condition) = &normalized_condition {
105
- document.filter(&search_path_string, condition)
115
+ let (values, selectors, lines): (Vec<serde_yaml::Value>, Vec<String>, Vec<usize>) = if select_fields.is_some() {
116
+ if let Some(condition) = &normalized_condition {
117
+ let triples = document.filter_with_selectors(&search_path_string, condition);
118
+ let (values, rest): (Vec<_>, Vec<_>) = triples.into_iter().map(|(v, s, l)| (v, (s, l))).unzip();
119
+ let (selectors, lines): (Vec<_>, Vec<_>) = rest.into_iter().unzip();
120
+
121
+ (values, selectors, lines)
122
+ } else {
123
+ let located = document.get_all_located(&search_path_string);
124
+ let selectors = located.iter().map(|n| n.selector.clone()).collect();
125
+ let lines = located.iter().map(|n| n.line).collect();
126
+ let values = document.get_values(&search_path_string);
127
+ let values: Vec<_> = values.into_iter().filter(|v| !v.is_null()).collect();
128
+
129
+ (values, selectors, lines)
130
+ }
131
+ } else if let Some(condition) = &normalized_condition {
132
+ (document.filter(&search_path_string, condition), Vec::new(), Vec::new())
106
133
  } else {
107
- document.get_values(&search_path_string)
134
+ (document.get_values(&search_path_string), Vec::new(), Vec::new())
108
135
  };
109
136
 
110
- for value in values {
137
+ for (index, value) in values.iter().enumerate() {
111
138
  if let Some(field) = &extract_field {
112
139
  let field_string = field.to_selector_string();
113
- let json_value = yerba::json::resolve_select_field(&value, &field_string);
140
+ let json_value = yerba::json::resolve_select_field(value, &field_string);
114
141
 
115
142
  if field.ends_with_bracket() {
116
143
  if let serde_json::Value::Array(items) = json_value {
@@ -128,8 +155,16 @@ impl Args {
128
155
 
129
156
  result.insert("__file".to_string(), serde_json::Value::String(resolved_file.clone()));
130
157
 
158
+ if let Some(selector) = selectors.get(index) {
159
+ result.insert("__selector".to_string(), serde_json::Value::String(selector.clone()));
160
+ }
161
+
162
+ if let Some(&line) = lines.get(index) {
163
+ result.insert("__line".to_string(), serde_json::Value::Number(line.into()));
164
+ }
165
+
131
166
  for field in fields {
132
- let json_value = yerba::json::resolve_select_field(&value, field);
167
+ let json_value = yerba::json::resolve_select_field(value, field);
133
168
  let json_key = yerba::json::select_field_key(field);
134
169
 
135
170
  result.insert(json_key, json_value);
@@ -140,7 +175,7 @@ impl Args {
140
175
  continue;
141
176
  }
142
177
 
143
- all_results.push(yerba::json::yaml_to_json(&value));
178
+ all_results.push(yerba::json::yaml_to_json(value));
144
179
  }
145
180
  }
146
181
 
@@ -103,7 +103,7 @@ impl Args {
103
103
  let mut document = parse_file(&resolved_file);
104
104
  let result = document.insert_into(&self.selector, &resolved_value, position.clone());
105
105
 
106
- run_op(&resolved_file, &document, result);
106
+ run_op(&self.file, &document, result);
107
107
  output(&resolved_file, &document, self.dry_run);
108
108
  }
109
109
  }
@@ -0,0 +1,56 @@
1
+ use std::process;
2
+ use std::sync::LazyLock;
3
+
4
+ use indoc::indoc;
5
+
6
+ use super::colorize_examples;
7
+ use super::parse_file;
8
+
9
+ static EXAMPLES: LazyLock<String> = LazyLock::new(|| {
10
+ colorize_examples(indoc! {r#"
11
+ yerba location config.yml "database.host"
12
+ yerba location videos.yml "[0]"
13
+ yerba location videos.yml "[0].title"
14
+ yerba location videos.yml "[0].speakers"
15
+ "#})
16
+ });
17
+
18
+ #[derive(clap::Args)]
19
+ #[command(
20
+ about = "Show the location (line, column, offset) of a selector",
21
+ arg_required_else_help = true,
22
+ after_help = EXAMPLES.as_str()
23
+ )]
24
+ pub struct Args {
25
+ file: String,
26
+ selector: String,
27
+ }
28
+
29
+ impl Args {
30
+ pub fn run(self) {
31
+ use super::color::*;
32
+
33
+ let document = parse_file(&self.file);
34
+ let info = document.get_node_info(&self.selector);
35
+
36
+ if info.location.start_line == 0 && info.location.end_line == 0 {
37
+ eprintln!("{RED}Error:{RESET} selector \"{}\" not found in {}", self.selector, self.file);
38
+
39
+ super::show_similar_selectors(&self.file, &document, &self.selector);
40
+ process::exit(1);
41
+ }
42
+
43
+ let json = serde_json::json!({
44
+ "selector": self.selector,
45
+ "file": self.file,
46
+ "start_line": info.location.start_line,
47
+ "start_column": info.location.start_column,
48
+ "end_line": info.location.end_line,
49
+ "end_column": info.location.end_column,
50
+ "start_offset": info.location.start_offset,
51
+ "end_offset": info.location.end_offset,
52
+ });
53
+
54
+ println!("{}", serde_json::to_string_pretty(&json).unwrap_or_default());
55
+ }
56
+ }
@@ -6,16 +6,19 @@ pub mod directives;
6
6
  pub mod get;
7
7
  pub mod init;
8
8
  pub mod insert;
9
+ pub mod location;
9
10
  pub mod mate;
10
11
  pub mod move_item;
11
12
  pub mod move_key;
12
13
  pub mod quote_style;
13
14
  pub mod remove;
14
15
  pub mod rename;
16
+ pub mod schema;
15
17
  pub mod selectors;
16
18
  pub mod set;
17
19
  pub mod sort;
18
20
  pub mod sort_keys;
21
+ pub mod unique;
19
22
  pub mod version;
20
23
 
21
24
  use std::fs;
@@ -23,6 +26,10 @@ use std::process;
23
26
 
24
27
  use clap::Subcommand;
25
28
 
29
+ pub(crate) fn is_github_actions() -> bool {
30
+ std::env::var("GITHUB_ACTIONS").is_ok()
31
+ }
32
+
26
33
  pub(crate) mod color {
27
34
  pub const GREEN: &str = "\x1b[32m";
28
35
  pub const RED: &str = "\x1b[31m";
@@ -153,6 +160,9 @@ pub enum Command {
153
160
  QuoteStyle(quote_style::Args),
154
161
  BlankLines(blank_lines::Args),
155
162
  Directives(directives::Args),
163
+ Unique(unique::Args),
164
+ Location(location::Args),
165
+ Schema(schema::Args),
156
166
  Selectors(selectors::Args),
157
167
  #[command(about = "Create a new Yerbafile in the current directory")]
158
168
  Init,
@@ -180,6 +190,9 @@ impl Command {
180
190
  Command::QuoteStyle(args) => args.run(),
181
191
  Command::BlankLines(args) => args.run(),
182
192
  Command::Directives(args) => args.run(),
193
+ Command::Unique(args) => args.run(),
194
+ Command::Location(args) => args.run(),
195
+ Command::Schema(args) => args.run(),
183
196
  Command::Selectors(args) => args.run(),
184
197
  Command::Init => init::run(),
185
198
  Command::Apply(args) => args.run(),
@@ -214,15 +227,30 @@ pub(crate) fn run_yerbafile(write: bool, files: Vec<String>) {
214
227
  let mut has_changes = false;
215
228
  let mut has_errors = false;
216
229
 
230
+ let github = is_github_actions();
231
+
217
232
  for result in &results {
218
233
  if let Some(error) = &result.error {
219
234
  eprintln!(" {RED}error:{RESET} {} {DIM}—{RESET} {}", result.file, error);
235
+
236
+ if github {
237
+ use yerba::error::GitHubAnnotations;
238
+
239
+ for annotation in error.github_annotations(&result.file) {
240
+ eprintln!("{}", annotation);
241
+ }
242
+ }
243
+
220
244
  has_errors = true;
221
245
  } else if result.changed {
222
246
  if write {
223
247
  eprintln!(" {GREEN}updated:{RESET} {}", result.file);
224
248
  } else {
225
249
  eprintln!(" {YELLOW}would change:{RESET} {}", result.file);
250
+
251
+ if github {
252
+ eprintln!("::error file={}::File does not match Yerbafile rules", result.file);
253
+ }
226
254
  }
227
255
 
228
256
  has_changes = true;
@@ -333,18 +361,18 @@ pub(crate) fn parse_file(file: &str) -> yerba::Document {
333
361
  })
334
362
  }
335
363
 
336
- pub(crate) fn run_op(file: &str, document: &yerba::Document, result: Result<(), yerba::YerbaError>) {
337
- run_op_with_hint(file, document, result, None);
364
+ pub(crate) fn run_op(display_file: &str, document: &yerba::Document, result: Result<(), yerba::YerbaError>) {
365
+ run_op_with_hint(display_file, document, result, None);
338
366
  }
339
367
 
340
- pub(crate) fn run_op_with_hint(file: &str, document: &yerba::Document, result: Result<(), yerba::YerbaError>, hint: Option<&str>) {
368
+ pub(crate) fn run_op_with_hint(display_file: &str, document: &yerba::Document, result: Result<(), yerba::YerbaError>, hint: Option<&str>) {
341
369
  use color::*;
342
370
 
343
371
  if let Err(error) = result {
344
372
  if let yerba::YerbaError::SelectorNotFound(selector) = &error {
345
- eprintln!("{RED}Error:{RESET} selector \"{selector}\" not found in {file}");
373
+ eprintln!("{RED}Error:{RESET} selector \"{selector}\" not found in {display_file}");
346
374
 
347
- show_similar_selectors(file, document, selector);
375
+ show_similar_selectors(display_file, document, selector);
348
376
  } else {
349
377
  eprintln!("{RED}Error:{RESET} {}", error);
350
378
  }
@@ -32,7 +32,7 @@ impl Args {
32
32
  let mut document = parse_file(&resolved_file);
33
33
  let result = document.remove(&self.selector, &self.value);
34
34
 
35
- run_op(&resolved_file, &document, result);
35
+ run_op(&self.file, &document, result);
36
36
  output(&resolved_file, &document, self.dry_run);
37
37
  }
38
38
  }
@@ -34,7 +34,7 @@ impl Args {
34
34
  let mut document = parse_file(&resolved_file);
35
35
  let result = document.rename(&self.source, &self.destination);
36
36
 
37
- run_op(&resolved_file, &document, result);
37
+ run_op(&self.file, &document, result);
38
38
  output(&resolved_file, &document, self.dry_run);
39
39
  }
40
40
  }
@@ -0,0 +1,84 @@
1
+ use std::process;
2
+
3
+ use super::color::*;
4
+ use super::resolve_files;
5
+
6
+ #[derive(clap::Args)]
7
+ #[command(
8
+ about = "Validate YAML files against a JSON schema",
9
+ after_help = colorize_examples(indoc::indoc! {r#"
10
+ yerba schema data/speakers.yml --schema lib/schemas/speaker_schema.json
11
+ yerba schema "data/**/videos.yml" --schema lib/schemas/video_schema.json
12
+ yerba schema data/config.yml --schema schema.json --selector "database"
13
+ "#})
14
+ )]
15
+ pub struct Args {
16
+ /// YAML file(s) to validate (supports globs)
17
+ file: String,
18
+ /// Path to the JSON schema file
19
+ #[arg(long)]
20
+ schema: String,
21
+ /// Selector to scope validation (e.g. "[]", "tiers[]")
22
+ #[arg(long)]
23
+ selector: Option<String>,
24
+ }
25
+
26
+ fn colorize_examples(text: &str) -> String {
27
+ super::colorize_examples(text)
28
+ }
29
+
30
+ impl Args {
31
+ pub fn run(self) {
32
+ let schema = match yerba::schema::load_schema(&self.schema) {
33
+ Ok(schema) => schema,
34
+ Err(error) => {
35
+ eprintln!("{RED}Error:{RESET} {}", error);
36
+ process::exit(1);
37
+ }
38
+ };
39
+
40
+ let files = resolve_files(&self.file);
41
+
42
+ if files.is_empty() {
43
+ eprintln!("{RED}Error:{RESET} no files matching: {}", self.file);
44
+ process::exit(1);
45
+ }
46
+
47
+ let mut has_errors = false;
48
+ let selector = self.selector.as_deref();
49
+
50
+ for file in &files {
51
+ let document = match yerba::Document::parse_file(file) {
52
+ Ok(document) => document,
53
+ Err(error) => {
54
+ eprintln!(" {RED}error:{RESET} {} {DIM}—{RESET} {}", file, error);
55
+ has_errors = true;
56
+ continue;
57
+ }
58
+ };
59
+
60
+ let errors = document.validate_schema(&schema, false, selector);
61
+
62
+ if errors.is_empty() {
63
+ continue;
64
+ }
65
+
66
+ has_errors = true;
67
+
68
+ let relative = file.strip_prefix("./").unwrap_or(file);
69
+ eprintln!(" {RED}error:{RESET} {relative}");
70
+
71
+ for error in &errors {
72
+ eprintln!(" {error}");
73
+ }
74
+
75
+ eprintln!();
76
+ }
77
+
78
+ if has_errors {
79
+ process::exit(1);
80
+ } else {
81
+ eprintln!("{GREEN}All {count} files valid.{RESET}", count = files.len());
82
+ }
83
+ }
84
+ }
@@ -63,7 +63,7 @@ impl Args {
63
63
  };
64
64
 
65
65
  run_op_with_hint(
66
- &resolved_file,
66
+ &self.file,
67
67
  &document,
68
68
  result,
69
69
  Some("Use --if-exists to skip files where the selector is missing"),
@@ -0,0 +1,80 @@
1
+ use std::process;
2
+ use std::sync::LazyLock;
3
+
4
+ use indoc::indoc;
5
+
6
+ use super::colorize_examples;
7
+ use super::{output, parse_file, resolve_files};
8
+
9
+ static EXAMPLES: LazyLock<String> = LazyLock::new(|| {
10
+ colorize_examples(indoc! {r#"
11
+ yerba unique config.yml "tags"
12
+ yerba unique videos.yml --by ".id"
13
+ yerba unique speakers.yml --by ".name"
14
+ yerba unique speakers.yml --by ".name" --remove
15
+ "#})
16
+ });
17
+
18
+ #[derive(clap::Args)]
19
+ #[command(
20
+ about = "Find or remove duplicate items in a sequence",
21
+ arg_required_else_help = true,
22
+ after_help = EXAMPLES.as_str()
23
+ )]
24
+ pub struct Args {
25
+ file: String,
26
+ /// Selector (optional — omit for root-level sequence)
27
+ selector: Option<String>,
28
+ /// Field to check for uniqueness (omit for scalar arrays)
29
+ #[arg(long)]
30
+ by: Option<String>,
31
+ /// Remove duplicate items (default: report only)
32
+ #[arg(long)]
33
+ remove: bool,
34
+ #[arg(long)]
35
+ dry_run: bool,
36
+ }
37
+
38
+ impl Args {
39
+ pub fn run(self) {
40
+ use super::color::*;
41
+
42
+ let selector = self.selector.as_deref().unwrap_or("");
43
+ let by = self.by.as_deref().unwrap_or(".");
44
+
45
+ for resolved_file in resolve_files(&self.file) {
46
+ let mut document = parse_file(&resolved_file);
47
+
48
+ match document.unique(selector, by, self.remove) {
49
+ Ok(duplicates) => {
50
+ let noun = if duplicates.len() == 1 { "duplicate" } else { "duplicates" };
51
+
52
+ if duplicates.is_empty() {
53
+ eprintln!("{GREEN}No duplicates found{RESET} in {}", resolved_file);
54
+ } else if self.remove {
55
+ eprintln!("{YELLOW}Removed {} {noun}{RESET} from {}", duplicates.len(), resolved_file);
56
+
57
+ for duplicate in &duplicates {
58
+ eprintln!(" {DIM}line {}: {} == {}{RESET}", duplicate.line, by, duplicate.value);
59
+ }
60
+
61
+ output(&resolved_file, &document, self.dry_run);
62
+ } else {
63
+ eprintln!("{RED}Found {} {noun}{RESET} in {}", duplicates.len(), resolved_file);
64
+
65
+ for duplicate in &duplicates {
66
+ eprintln!(" {DIM}line {}: {} == {}{RESET}", duplicate.line, by, duplicate.value);
67
+ }
68
+
69
+ process::exit(1);
70
+ }
71
+ }
72
+
73
+ Err(error) => {
74
+ eprintln!("{RED}Error:{RESET} {}", error);
75
+ process::exit(1);
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
@@ -3,13 +3,29 @@ use super::*;
3
3
  impl Document {
4
4
  pub fn filter(&self, dot_path: &str, condition: &str) -> Vec<serde_yaml::Value> {
5
5
  self
6
- .navigate_all(dot_path)
6
+ .navigate_all_compact(dot_path)
7
7
  .iter()
8
8
  .filter(|node| self.evaluate_condition_on_node(node, condition))
9
9
  .map(node_to_yaml_value)
10
10
  .collect()
11
11
  }
12
12
 
13
+ pub fn filter_with_selectors(&self, dot_path: &str, condition: &str) -> Vec<(serde_yaml::Value, String, usize)> {
14
+ let source = self.root.text().to_string();
15
+
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 = source[..offset].matches('\n').count() + 1;
23
+
24
+ (node_to_yaml_value(node), super::get::node_selector(node), line)
25
+ })
26
+ .collect()
27
+ }
28
+
13
29
  pub(super) fn evaluate_condition_on_node(&self, node: &SyntaxNode, condition: &str) -> bool {
14
30
  let condition = condition.trim();
15
31