yerba 0.1.2 → 0.2.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +492 -15
  3. data/ext/yerba/extconf.rb +87 -30
  4. data/ext/yerba/include/yerba.h +168 -0
  5. data/ext/yerba/yerba.c +752 -0
  6. data/lib/yerba/collection.rb +31 -0
  7. data/lib/yerba/document.rb +59 -0
  8. data/lib/yerba/formatting.rb +18 -0
  9. data/lib/yerba/location.rb +5 -0
  10. data/lib/yerba/map.rb +166 -0
  11. data/lib/yerba/scalar.rb +85 -0
  12. data/lib/yerba/sequence.rb +182 -0
  13. data/lib/yerba/version.rb +1 -1
  14. data/lib/yerba.rb +30 -4
  15. data/rust/Cargo.lock +378 -2
  16. data/rust/Cargo.toml +5 -1
  17. data/rust/build.rs +11 -0
  18. data/rust/cbindgen.toml +27 -0
  19. data/rust/src/commands/apply.rs +5 -0
  20. data/rust/src/commands/blank_lines.rs +58 -0
  21. data/rust/src/commands/check.rs +5 -0
  22. data/rust/src/commands/delete.rs +35 -0
  23. data/rust/src/commands/get.rs +194 -0
  24. data/rust/src/commands/init.rs +89 -0
  25. data/rust/src/commands/insert.rs +106 -0
  26. data/rust/src/commands/mate.rs +55 -0
  27. data/rust/src/commands/mod.rs +349 -0
  28. data/rust/src/commands/move_item.rs +54 -0
  29. data/rust/src/commands/move_key.rs +87 -0
  30. data/rust/src/commands/quote_style.rs +62 -0
  31. data/rust/src/commands/remove.rs +35 -0
  32. data/rust/src/commands/rename.rs +37 -0
  33. data/rust/src/commands/set.rs +59 -0
  34. data/rust/src/commands/sort.rs +52 -0
  35. data/rust/src/commands/sort_keys.rs +62 -0
  36. data/rust/src/commands/version.rs +8 -0
  37. data/rust/src/document.rs +764 -333
  38. data/rust/src/error.rs +0 -5
  39. data/rust/src/ffi.rs +991 -0
  40. data/rust/src/json.rs +49 -90
  41. data/rust/src/lib.rs +9 -2
  42. data/rust/src/main.rs +55 -843
  43. data/rust/src/selector.rs +241 -0
  44. data/rust/src/syntax.rs +97 -21
  45. data/rust/src/yaml_writer.rs +89 -0
  46. data/rust/src/yerbafile.rs +11 -126
  47. data/yerba.gemspec +4 -0
  48. metadata +33 -1
data/rust/src/main.rs CHANGED
@@ -1,860 +1,72 @@
1
- mod json;
1
+ mod commands;
2
2
 
3
- use std::fs;
4
- use std::process;
3
+ use std::sync::LazyLock;
5
4
 
6
- use clap::{Parser, Subcommand};
5
+ use clap::builder::styling::{AnsiColor, Effects, Styles};
6
+ use clap::Parser;
7
7
  use indoc::indoc;
8
8
 
9
+ const STYLES: Styles = Styles::styled()
10
+ .header(AnsiColor::Green.on_default().effects(Effects::BOLD))
11
+ .usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
12
+ .literal(AnsiColor::Cyan.on_default())
13
+ .placeholder(AnsiColor::Yellow.on_default())
14
+ .valid(AnsiColor::Green.on_default());
15
+
16
+ static HELP: LazyLock<String> = LazyLock::new(|| {
17
+ commands::colorize_help(indoc! {r#"
18
+ Selectors:
19
+ key A single key "database.host"
20
+ key.nested Nested key path "database.settings.pool"
21
+ [] All items in array "[].title"
22
+ [N] Item at index "[0].title"
23
+ [].key[].nested Nested array access "[].speakers[].name"
24
+
25
+ Conditions:
26
+ .key == value Equality ".kind == keynote"
27
+ .key != value Inequality ".status != draft"
28
+ .key contains val Substring or member ".title contains Ruby"
29
+ .key not_contains Negated contains ".title not_contains test"
30
+
31
+ Yerbafile:
32
+ yerba init Create a new Yerbafile in the current directory
33
+ yerba check Check if all files match the rules (exits 1 if not)
34
+ yerba apply Apply all rules and write changes
35
+
36
+ Examples:
37
+ yerba get config.yml "database.host"
38
+ yerba get videos.yml "[0].title"
39
+ yerba get videos.yml "[]" --select ".title,.speakers"
40
+ yerba get "data/**/videos.yml" "[]" --condition ".kind == keynote" --select ".id,.title"
41
+ yerba set config.yml "database.host" "0.0.0.0"
42
+ yerba insert config.yml "tags" "yaml" --after "ruby"
43
+ yerba insert speakers.yml "" --from "speaker.yml" --after ".name == Alice"
44
+ yerba delete config.yml "database.pool"
45
+ yerba move videos.yml "" ".id == talk-2" --after ".id == talk-1"
46
+ yerba sort-keys config.yml "database" "id,host,port,name"
47
+ yerba quote-style "data/**/*.yml" --values double
48
+ "#})
49
+ });
50
+
9
51
  #[derive(Parser)]
10
52
  #[command(
11
53
  name = "yerba",
12
54
  version = yerba::version(),
55
+ disable_version_flag = true,
56
+ styles = STYLES,
13
57
  about = "Yerba 🧉 YAML Editing and Refactoring with Better Accuracy",
14
58
  arg_required_else_help = true,
15
- override_usage = "yerba [command] [options]",
59
+ override_usage = "\x1b[1myerba\x1b[0m <command> <file> <selector> [options]",
16
60
  disable_help_subcommand = true,
17
- after_help = indoc! {r#"
18
- Examples:
19
- yerba get config.yml database.host
20
- yerba set config.yml database.host 0.0.0.0
21
- yerba insert config.yml database.ssl true --after host
22
- yerba delete config.yml database.pool
23
- yerba find "data/**/videos.yml" "[]" --condition '.kind == keynote' --select 'id,title'
24
- yerba sort-keys config.yml database 'id,host,port,name'
25
- yerba quote-style "data/**/*.yml" double
26
- yerba apply
27
- "#}
61
+ after_help = HELP.as_str()
28
62
  )]
29
- struct Cli {
63
+ #[allow(clippy::upper_case_acronyms)]
64
+ struct CLI {
30
65
  #[command(subcommand)]
31
- command: Command,
32
- }
33
-
34
- #[derive(Subcommand)]
35
- enum Command {
36
- #[command(
37
- about = "Get values at a path (single or multi-value with [] brackets)",
38
- arg_required_else_help = true,
39
- after_help = indoc! {r#"
40
- Examples:
41
- yerba get config.yml database.host
42
- yerba get config.yml database.host --condition '.port == 5432'
43
- yerba get videos.yml "[].title"
44
- yerba get "data/**/videos.yml" "[].speakers[].name"
45
- yerba get videos.yml "[0].title"
46
- "#}
47
- )]
48
- Get {
49
- file: String,
50
- path: String,
51
- #[arg(long)]
52
- condition: Option<String>,
53
- },
54
-
55
- #[command(
56
- about = "Find and filter items with conditions, output as JSON or raw YAML",
57
- arg_required_else_help = true,
58
- after_help = indoc! {r#"
59
- Examples:
60
- yerba find "data/**/videos.yml" "[]" --condition '.kind == keynote'
61
- yerba find "data/**/videos.yml" "[]" --select 'id,title' --condition '.kind == keynote'
62
- yerba find "data/**/videos.yml" "[]" --select 'id,title,speakers[0].name'
63
- yerba find "data/**/videos.yml" "[]" --condition '.title contains Ruby' --raw
64
- yerba find "data/**/videos.yml" "[]" --condition '.speakers contains "Matz"'
65
- "#}
66
- )]
67
- Find {
68
- file: String,
69
- path: String,
70
- #[arg(long)]
71
- condition: Option<String>,
72
- /// Comma-separated fields to include (supports dot paths like speakers[].name)
73
- #[arg(long)]
74
- select: Option<String>,
75
- /// Output raw YAML instead of JSON
76
- #[arg(long)]
77
- raw: bool,
78
- },
79
-
80
- #[command(
81
- about = "Update an existing value at a path (preserves quote style)",
82
- arg_required_else_help = true,
83
- after_help = indoc! {r#"
84
- Examples:
85
- yerba set config.yml database.host 0.0.0.0
86
- yerba set config.yml database.host 0.0.0.0 --if-exists
87
- yerba set config.yml database.host 0.0.0.0 --condition '.port == 5432'
88
- yerba set config.yml database.host 0.0.0.0 --dry-run
89
- yerba set "data/**/event.yml" website "" --if-exists
90
- "#}
91
- )]
92
- Set {
93
- file: String,
94
- path: String,
95
- value: String,
96
- #[arg(long)]
97
- if_exists: bool,
98
- #[arg(long)]
99
- if_missing: bool,
100
- #[arg(long)]
101
- condition: Option<String>,
102
- #[arg(long)]
103
- dry_run: bool,
104
- },
105
-
106
- #[command(
107
- about = "Insert a new key into a map or item into a sequence",
108
- arg_required_else_help = true,
109
- after_help = indoc! {r#"
110
- Examples:
111
- yerba insert config.yml database.ssl true
112
- yerba insert config.yml database.ssl true --after host
113
- yerba insert config.yml database.ssl true --before port
114
- yerba insert config.yml tags yaml
115
- yerba insert config.yml tags yaml --at 0
116
- yerba insert config.yml tags yaml --after ruby
117
- "#}
118
- )]
119
- Insert {
120
- file: String,
121
- path: String,
122
- value: String,
123
- #[arg(long)]
124
- before: Option<String>,
125
- #[arg(long)]
126
- after: Option<String>,
127
- #[arg(long)]
128
- at: Option<usize>,
129
- #[arg(long)]
130
- dry_run: bool,
131
- },
132
-
133
- #[command(
134
- about = "Rename a key in a map (preserves value and position)",
135
- arg_required_else_help = true,
136
- after_help = indoc! {"
137
- Examples:
138
- yerba rename config.yml database.host database.hostname
139
- yerba rename config.yml database.host hostname
140
- yerba rename config.yml database.host settings.db_host
141
- "}
142
- )]
143
- Rename {
144
- file: String,
145
- source: String,
146
- destination: String,
147
- #[arg(long)]
148
- dry_run: bool,
149
- },
150
-
151
- #[command(
152
- about = "Delete a key and its value from a map",
153
- arg_required_else_help = true,
154
- after_help = indoc! {"
155
- Examples:
156
- yerba delete config.yml database.pool
157
- yerba delete config.yml database.pool --dry-run
158
- "}
159
- )]
160
- Delete {
161
- file: String,
162
- path: String,
163
- #[arg(long)]
164
- dry_run: bool,
165
- },
166
-
167
- #[command(
168
- about = "Remove an item from a sequence by its value",
169
- arg_required_else_help = true,
170
- after_help = indoc! {"
171
- Examples:
172
- yerba remove config.yml tags rust
173
- "}
174
- )]
175
- Remove {
176
- file: String,
177
- path: String,
178
- value: String,
179
- #[arg(long)]
180
- dry_run: bool,
181
- },
182
-
183
- #[command(
184
- about = "Move a sequence item to a new position",
185
- arg_required_else_help = true,
186
- after_help = indoc! {"
187
- Examples:
188
- yerba move config.yml tags rust --before ruby
189
- yerba move config.yml tags rust --after yaml
190
- yerba move config.yml tags 2 --to 0
191
- "}
192
- )]
193
- Move {
194
- file: String,
195
- path: String,
196
- item: String,
197
- #[arg(long)]
198
- before: Option<String>,
199
- #[arg(long)]
200
- after: Option<String>,
201
- #[arg(long)]
202
- to: Option<usize>,
203
- #[arg(long)]
204
- dry_run: bool,
205
- },
206
-
207
- #[command(
208
- about = "Move a key to a new position within a map",
209
- arg_required_else_help = true,
210
- after_help = indoc! {"
211
- Examples:
212
- yerba move-key config.yml database.pool --before database.host
213
- yerba move-key config.yml database.name --to 0
214
- "}
215
- )]
216
- MoveKey {
217
- file: String,
218
- path: String,
219
- #[arg(long)]
220
- before: Option<String>,
221
- #[arg(long)]
222
- after: Option<String>,
223
- #[arg(long)]
224
- to: Option<usize>,
225
- #[arg(long)]
226
- dry_run: bool,
227
- },
228
-
229
- #[command(
230
- about = "Sort keys in a map by a predefined order (aborts on unknown keys)",
231
- arg_required_else_help = true,
232
- after_help = indoc! {r#"
233
- Examples:
234
- yerba sort-keys config.yml database 'host,port,name,pool'
235
- yerba sort-keys "data/**/event.yml" "" "id,title,kind,location"
236
- yerba sort-keys "data/**/videos.yml" "[]" "id,title,speakers"
237
- yerba sort-keys config.yml database 'host,port' --dry-run
238
- "#}
239
- )]
240
- SortKeys {
241
- file: String,
242
- path: String,
243
- /// Comma-separated key order
244
- order: String,
245
- #[arg(long)]
246
- dry_run: bool,
247
- },
248
-
249
- #[command(
250
- about = "Sort items in a sequence by field(s)",
251
- arg_required_else_help = true,
252
- after_help = indoc! {r#"
253
- Examples:
254
- yerba sort config.yml tags
255
- yerba sort videos.yml "" --by title
256
- yerba sort videos.yml "" --by "date:desc,title"
257
- yerba sort videos.yml "[].speakers"
258
- yerba sort videos.yml "[].speakers" --by name
259
- yerba sort videos.yml "" --by "kind,date:desc,title" --dry-run
260
- "#}
261
- )]
262
- Sort {
263
- file: String,
264
- path: String,
265
- /// Comma-separated sort fields, optionally with :desc (e.g. "date:desc,title")
266
- #[arg(long)]
267
- by: Option<String>,
268
- /// Case-sensitive sort (default: case-insensitive)
269
- #[arg(long)]
270
- case_sensitive: bool,
271
- #[arg(long)]
272
- dry_run: bool,
273
- },
274
-
275
- #[command(
276
- about = "Enforce a consistent quote style on values, keys, or both",
277
- arg_required_else_help = true,
278
- after_help = indoc! {"
279
- Examples:
280
- yerba quote-style config.yml double
281
- yerba quote-style config.yml plain --keys
282
- yerba quote-style config.yml double --all
283
- yerba quote-style config.yml single --path database.host
284
- "}
285
- )]
286
- QuoteStyle {
287
- file: String,
288
- /// Quote style
289
- style: yerba::QuoteStyle,
290
- /// Scope to a specific path
291
- #[arg(long)]
292
- path: Option<String>,
293
- /// Apply to keys only
294
- #[arg(long)]
295
- keys: bool,
296
- /// Apply to both keys and values
297
- #[arg(long)]
298
- all: bool,
299
- #[arg(long)]
300
- dry_run: bool,
301
- },
302
-
303
- #[command(
304
- about = "Enforce blank lines between sequence entries",
305
- arg_required_else_help = true,
306
- after_help = indoc! {r#"
307
- Examples:
308
- yerba blank-lines videos.yml "" 1
309
- yerba blank-lines "data/**/videos.yml" "[]" 1
310
- yerba blank-lines config.yml tags 0
311
- "#}
312
- )]
313
- BlankLines {
314
- file: String,
315
- path: String,
316
- /// Number of blank lines between entries (0 = no blanks, 1 = one empty line)
317
- count: usize,
318
- #[arg(long)]
319
- dry_run: bool,
320
- },
321
-
322
- #[command(about = "Apply all rules from the Yerbafile and write changes")]
323
- Apply,
324
- #[command(about = "Check if all files match Yerbafile rules (exits 1 if not)")]
325
- Check,
326
- #[command(about = "Print the yerba version")]
327
- Version,
66
+ command: commands::Command,
328
67
  }
329
68
 
330
69
  fn main() {
331
- let cli = Cli::parse();
332
-
333
- match cli.command {
334
- Command::Get { file, path, condition } => {
335
- for resolved_file in resolve_files(&file) {
336
- let document = parse_file(&resolved_file);
337
-
338
- if let Some(condition) = &condition {
339
- let parent_path = path.rsplit_once('.').map(|(parent, _)| parent).unwrap_or("");
340
-
341
- if !document.evaluate_condition(parent_path, condition) {
342
- continue;
343
- }
344
- }
345
-
346
- let values = document.get_all(&path);
347
-
348
- if values.is_empty() && !path.contains('[') && !document.exists(&path) {
349
- eprintln!("Path not found: {}", path);
350
- process::exit(1);
351
- }
352
-
353
- for value in values {
354
- println!("{}", value);
355
- }
356
- }
357
- }
358
-
359
- Command::Find {
360
- file,
361
- path,
362
- condition,
363
- select,
364
- raw,
365
- } => {
366
- let select_fields: Option<Vec<&str>> = select.as_deref().map(|fields| fields.split(',').collect());
367
-
368
- if raw {
369
- for resolved_file in resolve_files(&file) {
370
- let document = parse_file(&resolved_file);
371
-
372
- let matches = match &condition {
373
- Some(condition) => document.find_items(&path, condition),
374
- None => document.find_all(&path),
375
- };
376
-
377
- for (index, item) in matches.iter().enumerate() {
378
- if index > 0 {
379
- println!();
380
- }
381
-
382
- eprintln!("# {}:{}", resolved_file, item.line);
383
- println!("{}", item.text);
384
- }
385
- }
386
- } else {
387
- let mut all_results: Vec<serde_json::Value> = Vec::new();
388
-
389
- for resolved_file in resolve_files(&file) {
390
- let document = parse_file(&resolved_file);
391
-
392
- let matches = match &condition {
393
- Some(condition) => document.find_items(&path, condition),
394
- None => document.find_all(&path),
395
- };
396
-
397
- for item in &matches {
398
- let yaml_with_dash = format!("- {}", item.text.trim_start_matches("- "));
399
-
400
- if let Ok(parsed) = serde_yaml::from_str::<Vec<serde_yaml::Value>>(&yaml_with_dash) {
401
- for value in parsed {
402
- let mut result = serde_json::Map::new();
403
-
404
- result.insert("__file".to_string(), serde_json::Value::String(resolved_file.clone()));
405
-
406
- result.insert("__line".to_string(), serde_json::Value::Number(item.line.into()));
407
-
408
- match &select_fields {
409
- Some(fields) => {
410
- for field in fields {
411
- let json_value = json::resolve_select_field(&value, field);
412
- let json_key = json::select_field_key(field);
413
-
414
- result.insert(json_key, json_value);
415
- }
416
- }
417
-
418
- None => {
419
- if let serde_yaml::Value::Mapping(map) = &value {
420
- for (key, yaml_value) in map {
421
- let json_key = match key {
422
- serde_yaml::Value::String(string) => string.clone(),
423
- _ => format!("{:?}", key),
424
- };
425
-
426
- result.insert(json_key, json::yaml_to_json(yaml_value));
427
- }
428
- }
429
- }
430
- }
431
-
432
- all_results.push(serde_json::Value::Object(result));
433
- }
434
- }
435
- }
436
- }
437
-
438
- println!(
439
- "{}",
440
- serde_json::to_string_pretty(&all_results).unwrap_or_else(|_| "[]".to_string())
441
- );
442
- }
443
- }
444
-
445
- Command::Set {
446
- file,
447
- path,
448
- value,
449
- if_exists,
450
- if_missing,
451
- condition,
452
- dry_run,
453
- } => {
454
- let mut document = parse_file(&file);
455
- let parent_path = path.rsplit_once('.').map(|(parent, _)| parent).unwrap_or("");
456
-
457
- let should_set = if if_exists {
458
- document.exists(&path)
459
- } else if if_missing {
460
- !document.exists(&path)
461
- } else if let Some(condition) = &condition {
462
- document.evaluate_condition(parent_path, condition)
463
- } else {
464
- true
465
- };
466
-
467
- if should_set {
468
- run(|| document.set(&path, &value));
469
- }
470
-
471
- output(&file, &document, dry_run);
472
- }
473
-
474
- Command::Insert {
475
- file,
476
- path,
477
- value,
478
- before,
479
- after,
480
- at,
481
- dry_run,
482
- } => {
483
- let parent_path = path.rsplit_once('.').map(|(parent, _)| parent).unwrap_or("");
484
-
485
- let position = if let Some(index) = at {
486
- yerba::InsertPosition::At(index)
487
- } else if let Some(target) = before {
488
- yerba::InsertPosition::Before(target)
489
- } else if let Some(target) = after {
490
- yerba::InsertPosition::After(target)
491
- } else {
492
- yerba::Yerbafile::find()
493
- .and_then(|yerbafile_path| yerba::Yerbafile::load(&yerbafile_path).ok())
494
- .and_then(|yerbafile| yerbafile.sort_order_for(&file, parent_path))
495
- .map(yerba::InsertPosition::FromSortOrder)
496
- .unwrap_or(yerba::InsertPosition::Last)
497
- };
498
-
499
- let mut document = parse_file(&file);
500
- run(|| document.insert_into(&path, &value, position));
501
- output(&file, &document, dry_run);
502
- }
503
-
504
- Command::Rename {
505
- file,
506
- source,
507
- destination,
508
- dry_run,
509
- } => {
510
- let mut document = parse_file(&file);
511
- run(|| document.rename(&source, &destination));
512
- output(&file, &document, dry_run);
513
- }
514
-
515
- Command::Delete { file, path, dry_run } => {
516
- let mut document = parse_file(&file);
517
- run(|| document.delete(&path));
518
- output(&file, &document, dry_run);
519
- }
520
-
521
- Command::Remove {
522
- file,
523
- path,
524
- value,
525
- dry_run,
526
- } => {
527
- let mut document = parse_file(&file);
528
- run(|| document.remove(&path, &value));
529
- output(&file, &document, dry_run);
530
- }
531
-
532
- Command::Move {
533
- file,
534
- path,
535
- item,
536
- before,
537
- after,
538
- to,
539
- dry_run,
540
- } => {
541
- let mut document = parse_file(&file);
542
- let (from_index, to_index) = resolve_move_indexes(
543
- &document,
544
- &path,
545
- &item,
546
- before,
547
- after,
548
- to,
549
- |document, path, reference| document.resolve_sequence_index(path, reference),
550
- );
551
-
552
- run(|| document.move_item(&path, from_index, to_index));
553
- output(&file, &document, dry_run);
554
- }
555
-
556
- Command::MoveKey {
557
- file,
558
- path,
559
- before,
560
- after,
561
- to,
562
- dry_run,
563
- } => {
564
- let (parent_path, key) = path.rsplit_once('.').unwrap_or(("", &path));
565
-
566
- let mut document = parse_file(&file);
567
-
568
- let before_key = before.map(|target| {
569
- let (target_parent, target_key) = target.rsplit_once('.').unwrap_or(("", &target));
570
-
571
- if target_parent != parent_path {
572
- eprintln!(
573
- "Error: cannot move key across different maps ({} → {})\n\n Use 'yerba rename' to relocate keys to a different path.",
574
- path, target
575
- );
576
-
577
- process::exit(1);
578
- }
579
-
580
- target_key.to_string()
581
- });
582
-
583
- let after_key = after.map(|target| {
584
- let (target_parent, target_key) = target.rsplit_once('.').unwrap_or(("", &target));
585
-
586
- if target_parent != parent_path {
587
- eprintln!(
588
- "Error: cannot move key across different maps ({} → {})\n\n Use 'yerba rename' to relocate keys to a different path.",
589
- path, target
590
- );
591
-
592
- process::exit(1);
593
- }
594
-
595
- target_key.to_string()
596
- });
597
-
598
- let (from_index, to_index) = resolve_move_indexes(
599
- &document,
600
- parent_path,
601
- key,
602
- before_key,
603
- after_key,
604
- to,
605
- |document, parent_path, reference| document.resolve_key_index(parent_path, reference),
606
- );
607
-
608
- run(|| document.move_key(parent_path, from_index, to_index));
609
- output(&file, &document, dry_run);
610
- }
611
-
612
- Command::SortKeys {
613
- file,
614
- path,
615
- order,
616
- dry_run,
617
- } => {
618
- let key_order: Vec<&str> = order.split(',').collect();
619
- let files = resolve_files(&file);
620
-
621
- let mut has_errors = false;
622
-
623
- for resolved_file in &files {
624
- let document = parse_file(resolved_file);
625
-
626
- if let Err(error) = document.validate_sort_keys(&path, &key_order) {
627
- eprintln!("Error in {}: {}", resolved_file, error);
628
- has_errors = true;
629
- }
630
- }
631
-
632
- if has_errors {
633
- process::exit(1);
634
- }
635
-
636
- for resolved_file in &files {
637
- let mut document = parse_file(resolved_file);
638
-
639
- if document.sort_keys(&path, &key_order).is_ok() {
640
- output(resolved_file, &document, dry_run);
641
- }
642
- }
643
- }
644
-
645
- Command::QuoteStyle {
646
- file,
647
- style,
648
- path,
649
- keys,
650
- all,
651
- dry_run,
652
- } => {
653
- let dot_path = path.as_deref();
654
-
655
- for resolved_file in resolve_files(&file) {
656
- let mut document = parse_file(&resolved_file);
657
-
658
- if keys {
659
- let _ = document.enforce_key_style(&style, dot_path);
660
- } else if all {
661
- let _ = document.enforce_key_style(&style, dot_path);
662
- let _ = document.enforce_quotes_at(&style, dot_path);
663
- } else {
664
- let _ = document.enforce_quotes_at(&style, dot_path);
665
- }
666
-
667
- output(&resolved_file, &document, dry_run);
668
- }
669
- }
670
-
671
- Command::Sort {
672
- file,
673
- path,
674
- by,
675
- case_sensitive,
676
- dry_run,
677
- } => {
678
- let sort_fields = by.as_deref().map(yerba::SortField::parse_list).unwrap_or_default();
679
-
680
- for resolved_file in resolve_files(&file) {
681
- let mut document = parse_file(&resolved_file);
682
-
683
- if document.sort_items(&path, &sort_fields, case_sensitive).is_ok() {
684
- output(&resolved_file, &document, dry_run);
685
- }
686
- }
687
- }
688
-
689
- Command::BlankLines {
690
- file,
691
- path,
692
- count,
693
- dry_run,
694
- } => {
695
- for resolved_file in resolve_files(&file) {
696
- let mut document = parse_file(&resolved_file);
697
-
698
- if document.enforce_blank_lines(&path, count).is_ok() {
699
- output(&resolved_file, &document, dry_run);
700
- }
701
- }
702
- }
703
-
704
- Command::Apply => run_yerbafile(true),
705
- Command::Check => run_yerbafile(false),
706
-
707
- Command::Version => {
708
- println!("🧉 yerba v{}", yerba::version());
709
- }
710
- }
711
- }
712
-
713
- fn run_yerbafile(write: bool) {
714
- let yerbafile_path = yerba::Yerbafile::find().unwrap_or_else(|| {
715
- eprintln!("No Yerbafile found. Create one in the current directory or a parent.");
716
- process::exit(1);
717
- });
718
-
719
- let yerbafile = yerba::Yerbafile::load(&yerbafile_path).unwrap_or_else(|error| {
720
- eprintln!("Error loading {}: {}", yerbafile_path.display(), error);
721
- process::exit(1);
722
- });
723
-
724
- eprintln!("🧉 Using {}", yerbafile_path.display());
725
-
726
- let results = yerbafile.apply(write);
727
- let mut has_changes = false;
728
- let mut has_errors = false;
729
-
730
- for result in &results {
731
- if let Some(error) = &result.error {
732
- eprintln!(" error: {} — {}", result.file, error);
733
- has_errors = true;
734
- } else if result.changed {
735
- if write {
736
- eprintln!(" updated: {}", result.file);
737
- } else {
738
- eprintln!(" would change: {}", result.file);
739
- }
740
-
741
- has_changes = true;
742
- }
743
- }
744
-
745
- if !has_changes && !has_errors {
746
- eprintln!(" All files match the rules.");
747
- }
748
-
749
- if !write && has_changes {
750
- process::exit(1);
751
- }
752
-
753
- if has_errors {
754
- process::exit(1);
755
- }
756
- }
757
-
758
- fn resolve_move_indexes(
759
- document: &yerba::Document,
760
- path: &str,
761
- item: &str,
762
- before: Option<String>,
763
- after: Option<String>,
764
- to: Option<usize>,
765
- resolve: impl Fn(&yerba::Document, &str, &str) -> Result<usize, yerba::YerbaError>,
766
- ) -> (usize, usize) {
767
- let from_index = resolve(document, path, item).unwrap_or_else(|error| {
768
- eprintln!("Error: {}", error);
769
- process::exit(1);
770
- });
771
-
772
- let to_index = if let Some(index) = to {
773
- index
774
- } else if let Some(target) = &before {
775
- let target_index = resolve(document, path, target).unwrap_or_else(|error| {
776
- eprintln!("Error: {}", error);
777
- process::exit(1);
778
- });
779
-
780
- if from_index < target_index {
781
- target_index - 1
782
- } else {
783
- target_index
784
- }
785
- } else if let Some(target) = &after {
786
- let target_index = resolve(document, path, target).unwrap_or_else(|error| {
787
- eprintln!("Error: {}", error);
788
- process::exit(1);
789
- });
790
-
791
- if from_index <= target_index {
792
- target_index
793
- } else {
794
- target_index + 1
795
- }
796
- } else {
797
- eprintln!("Error: specify --before, --after, or --to");
798
- process::exit(1);
799
- };
800
-
801
- (from_index, to_index)
802
- }
803
-
804
- fn resolve_files(pattern: &str) -> Vec<String> {
805
- if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
806
- let paths: Vec<String> = glob::glob(pattern)
807
- .unwrap_or_else(|error| {
808
- eprintln!("Invalid glob pattern '{}': {}", pattern, error);
809
- process::exit(1);
810
- })
811
- .filter_map(|entry| entry.ok())
812
- .map(|path| path.to_string_lossy().to_string())
813
- .collect();
814
-
815
- if paths.is_empty() {
816
- eprintln!("No files matched pattern: {}", pattern);
817
- process::exit(1);
818
- }
819
-
820
- paths
821
- } else {
822
- vec![pattern.to_string()]
823
- }
824
- }
825
-
826
- fn parse_file(file: &str) -> yerba::Document {
827
- yerba::parse_file(file).unwrap_or_else(|error| {
828
- match &error {
829
- yerba::YerbaError::IoError(io_error) => match io_error.kind() {
830
- std::io::ErrorKind::NotFound => eprintln!("Error: file not found: {}", file),
831
- std::io::ErrorKind::PermissionDenied => {
832
- eprintln!("Error: permission denied: {}", file)
833
- }
834
- _ => eprintln!("Error reading {}: {}", file, io_error),
835
- },
836
- _ => eprintln!("Error parsing {}: {}", file, error),
837
- }
838
-
839
- process::exit(1);
840
- })
841
- }
842
-
843
- fn run(operation: impl FnOnce() -> Result<(), yerba::YerbaError>) {
844
- operation().unwrap_or_else(|error| {
845
- eprintln!("Error: {}", error);
846
- process::exit(1);
847
- });
848
- }
849
-
850
- fn output(file: &str, document: &yerba::Document, dry_run: bool) {
851
- if dry_run {
852
- println!("--- {}", file);
853
- print!("{}", document);
854
- } else {
855
- fs::write(file, document.to_string()).unwrap_or_else(|error| {
856
- eprintln!("Error writing {}: {}", file, error);
857
- process::exit(1);
858
- });
859
- }
70
+ let cli = CLI::parse();
71
+ cli.command.run();
860
72
  }