1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
use crate::error::{BraceError, Result};
use std::collections::HashSet;
pub(crate) mod expansion;
mod normalise;
pub mod ppb;
mod trie;
use expansion::{compute_reprs, expand_braces};
use normalise::{find_common_suffix, normalise_separators, validate_separators};
use trie::build_trie;
/// Configuration for brace expansion
///
/// Controls how paths are compressed into brace notation. For example,
/// `["a/b.rs", "a/c.rs"]` can become `"a/{b,c}.rs"` depending on settings.
#[derive(Debug, Clone)]
pub struct BraceConfig {
/// Path separator to use (default: `"/"`).
///
/// This separator is used to split paths into segments and will be the
/// separator in the output. When `allow_mixed_separators` is false, input
/// paths with different separators will cause an error.
pub path_separator: String,
/// Maximum nesting depth of braces (default: `5`).
///
/// Limits how deeply braces can be nested to prevent performance issues.
/// When the limit is reached, remaining items are kept as full subpaths.
///
/// # Examples
/// With `max_depth = 2`:
/// - `["a/b/c/1", "a/b/c/2", "a/b/d/3"]` → `"a/b/{c/{1,2},d/3}"`
///
/// With `max_depth = 1`:
/// - `["a/b/c/1", "a/b/c/2", "a/b/d/3"]` → `"a/b/{c/1,c/2,d/3}"`
pub max_depth: usize,
/// Maximum number of items allowed in a single brace group (default: `None`).
///
/// When set, splits large brace groups into multiple groups to prevent
/// extremely long output. If a brace would exceed this size, it's split
/// into multiple braces each within the limit.
///
/// # Example
/// With `max_brace_size = Some(2)`:
/// - `"a/{b,c,d}"` → `"a/{b,c} a/{d}"`
pub max_brace_size: Option<usize>,
/// Allow splitting within filename stems (default: `false`).
///
/// When enabled, common prefixes within path segments (not just at
/// separator boundaries) can be factored out.
///
/// # Examples
/// When `true`:
/// - `["foo/bar.rs", "foo/baz.rs"]` → `"foo/ba{r,z}.rs"`
///
/// When `false`:
/// - `["foo/bar.rs", "foo/baz.rs"]` → `"foo/{bar,baz}.rs"`
pub allow_stem_split: bool,
/// Allow splitting path segments to factor out common prefixes (default: `true`).
///
/// When enabled, allows factoring out segments even when one path is a
/// prefix of another, creating braces with empty alternatives.
///
/// # Examples
/// When `true`:
/// - `["a/b", "a/b/c"]` → `"a/b{,/c}"`
///
/// When `false`:
/// - `["a/b", "a/b/c"]` → `"a/{b,b/c}"`
pub allow_segment_split: bool,
/// Sort items within brace groups alphabetically (default: `false`).
///
/// When enabled, items within each brace group are sorted. When disabled,
/// items appear in their order from the input (unless
/// `preserve_order_within_braces` is also false).
///
/// # Examples
/// When `true`:
/// - `["z.rs", "b.rs"]` → `"{b,z}.rs"`
///
/// When `false` (with `preserve_order_within_braces = true`):
/// - `["z.rs", "b.rs"]` → `"{z,b}.rs"`
pub sort_items: bool,
/// Disallow braces with empty alternatives (default: `false`).
///
/// When enabled, prevents output like `"a/b{,/c}"` by outputting paths
/// separately instead.
///
/// # Examples
/// When `true`:
/// - `["a/b", "a/b/c"]` → `"a/b a/b/c"` (space-separated)
///
/// When `false`:
/// - `["a/b", "a/b/c"]` → `"a/b{,/c}"`
pub disallow_empty_braces: bool,
/// Preserve input order within braces when not sorting (default: `false`).
///
/// When `false` and `sort_items` is also `false`, items may still be
/// reordered for consistency. When `true`, the exact input order is maintained.
pub preserve_order_within_braces: bool,
/// Allow and normalize mixed path separators in input (default: `false`).
///
/// When `false`, input paths with separators different from `path_separator`
/// will cause an error. When `true`, all separators are normalized to
/// `path_separator`.
pub allow_mixed_separators: bool,
/// Remove duplicate paths from input before processing (default: `true`).
///
/// When enabled, duplicate paths are removed. When disabled, duplicates
/// are preserved in the output.
pub deduplicate_inputs: bool,
/// Expand existing braces in input before reprocessing (default: `false`).
///
/// When `false`, input containing brace syntax will cause an error (prevents
/// injection attacks). When `true`, existing braces are expanded and then
/// re-compressed according to the current configuration.
///
/// # Example
/// When `true`:
/// - Input: `"a/{b,c}.rs"` → Expanded to `["a/b.rs", "a/c.rs"]` → Reprocessed
pub reprocess_braces: bool,
/// Highlight braces with colors (default: `false`).
/// Only available with the `highlight` feature enabled.
#[cfg(feature = "highlight")]
pub highlight: bool,
}
impl Default for BraceConfig {
fn default() -> Self {
Self {
path_separator: "/".to_string(),
max_depth: 5,
max_brace_size: None,
allow_stem_split: false,
allow_segment_split: true,
sort_items: false,
disallow_empty_braces: false,
preserve_order_within_braces: false,
allow_mixed_separators: false,
deduplicate_inputs: true,
reprocess_braces: false,
#[cfg(feature = "highlight")]
highlight: false,
}
}
}
/// Public entry: expand paths into braces
pub fn brace_paths(paths: &[impl AsRef<str>], config: &BraceConfig) -> Result<String> {
if paths.is_empty() {
return Err(BraceError::EmptyInput);
}
// Convert to owned strings
let mut paths: Vec<String> = paths.iter().map(|p| p.as_ref().to_string()).collect();
// Normalize separators
if !config.allow_mixed_separators {
validate_separators(&paths, &config.path_separator)?;
} else {
paths = paths
.into_iter()
.map(|p| normalise_separators(&p, &config.path_separator))
.collect();
}
// Handle braces in input if reprocess disabled
if !config.reprocess_braces && paths.iter().any(|p| p.contains('{') || p.contains('}')) {
return Err(BraceError::InvalidBraceInput {
path: paths
.iter()
.find(|p| p.contains('{') || p.contains('}'))
.unwrap()
.clone(),
reason: "reprocess_braces is disabled".to_string(),
});
}
if config.reprocess_braces {
paths = paths.into_iter().flat_map(|p| expand_braces(&p)).collect();
}
// Deduplicate while preserving order (only if enabled)
if config.deduplicate_inputs {
let mut seen = HashSet::new();
let mut ordered = Vec::with_capacity(paths.len());
for p in paths.into_iter() {
if seen.insert(p.clone()) {
ordered.push(p);
}
}
paths = ordered;
}
// Strip common suffix for cleaner braces
let common_suffix = find_common_suffix(&paths);
let stripped_paths: Vec<String> = if !common_suffix.is_empty() {
paths
.iter()
.map(|s| s.strip_suffix(&common_suffix).unwrap_or(s).to_string())
.collect()
} else {
paths.clone()
};
let (nodes, root_idx) = build_trie(&stripped_paths, &config.path_separator, config);
// Compute representations
let (reprs, _) = compute_reprs(&nodes, root_idx, &config.path_separator, config);
let mut result = reprs.get(&root_idx).cloned().unwrap_or_default();
if !common_suffix.is_empty() {
result.push_str(&common_suffix);
}
#[cfg(feature = "cli")]
if config.highlight {
result = crate::highlight::highlight_braces(&result);
}
Ok(result)
}