Skip to content

Commit bc04f4c

Browse files
authored
Implement .tomeignore (#53) (#64)
Adds support for avoiding command and completion parsing for directories & files using a gitignore-like .tomeignore specification. Maintains backwards compatibility by continuing to ignore directories entirely if they contain an empty .tomeignore, while leveraging the ignore crate for robust performance and standard exclusion rules for anything defined explicitly. Fixes #53.
1 parent baa97ed commit bc04f4c

6 files changed

Lines changed: 230 additions & 29 deletions

File tree

Cargo.lock

Lines changed: 152 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ license = "MIT"
77

88
[dependencies]
99
clap = { version = "4.4.7", features = ["derive"] }
10+
ignore = "0.4.25"
1011
lazy_static = { version = "1.4.0" }
1112

1213
[dev-dependencies]

docs/authoring-scripts.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ The following files are automatically ignored by tome:
8181

8282
- Files that start with a `.` (dot-prefix)
8383
- Files without the executable bit set (unless they have a `.source` suffix)
84+
- Files or directories matched by a `.tomeignore` file
85+
86+
### Using `.tomeignore`
87+
88+
Tome natively supports a `.tomeignore` file in your root scripts directory. This file uses standard `.gitignore` syntax to specify files and directories that should be excluded from command discovery and completions.
89+
90+
**Backwards Compatibility / Legacy Behavior:**
91+
If an empty `.tomeignore` file is placed inside a directory, tome will completely ignore that directory, serving as a simple exclusion marker.
92+
93+
**Performance Note:**
94+
While `.tomeignore` adds powerful filtering rules, it can add slight parsing overhead during command execution and discovery. However, benchmarks show that this overhead is generally negligible (adding only ~0.2 milliseconds per execution on a complex repository of 2,500 scripts). It is strictly recommended to use the non-executable file approach or dot-prefixed (`.name`) directories only when you require absolute maximum performance without global ignores.
8495

8596
## Adding help text
8697

src/commands/complete.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ pub fn complete(
3535
let remaining_args: Vec<_> = args_peekable.collect();
3636
return match is_file {
3737
false => {
38+
let ignorer = directory::TomeIgnorer::new(std::path::Path::new(command_directory_path));
3839
let paths_raw: io::Result<_> = fs::read_dir(target.to_str().unwrap());
3940
let mut paths: Vec<_> = match paths_raw {
4041
Err(_a) => return Err("Invalid argument to completion".to_string()),
@@ -43,7 +44,7 @@ pub fn complete(
4344
.filter_map(|r| match r {
4445
Ok(path_buf) => {
4546
let path = path_buf.path();
46-
if path.is_dir() && !directory::is_tome_script_directory(&path) {
47+
if ignorer.is_ignored(&path) {
4748
return None;
4849
}
4950
if path.is_file() && !script::is_tome_script(&path) {

src/commands/help.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@ fn help_all_in_dir(root: &str, dir: &str, list_label: &str) -> Result<String, St
7373
})
7474
.collect();
7575

76+
let ignorer = super::super::directory::TomeIgnorer::new(std::path::Path::new(root));
7677
let commands_and_scripts =
77-
scan_directory(dir, &mut vec![]).map_err(|e| echo_error(&format!("{}", e)))?;
78+
scan_directory(dir, &mut vec![], &ignorer).map_err(|e| echo_error(&format!("{}", e)))?;
7879
let commands_with_help: Vec<_> = commands_and_scripts
7980
.iter()
8081
.map(|(command, script)| {

src/directory.rs

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,80 @@
11
use super::script;
2-
use std::{fs::read_dir, io, path::Path};
2+
use std::{fs, io, path::Path};
3+
4+
pub struct TomeIgnorer {
5+
gitignore: ignore::gitignore::Gitignore,
6+
}
7+
8+
impl TomeIgnorer {
9+
pub fn new(root: &Path) -> Self {
10+
let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
11+
let tomeignore_path = root.join(".tomeignore");
12+
if tomeignore_path.exists() {
13+
if let Ok(metadata) = fs::metadata(&tomeignore_path) {
14+
if metadata.len() > 0 {
15+
builder.add(tomeignore_path);
16+
}
17+
}
18+
}
19+
let gitignore = builder.build().unwrap_or(ignore::gitignore::Gitignore::empty());
20+
Self { gitignore }
21+
}
22+
23+
pub fn is_ignored(&self, path: &Path) -> bool {
24+
// ignore dot directories and files
25+
if path.file_name()
26+
.unwrap_or_default()
27+
.to_str()
28+
.unwrap_or_default()
29+
.starts_with('.') {
30+
return true;
31+
}
32+
33+
// old behavior fallback: if there's an empty .tomeignore in a directory, ignore that directory
34+
if path.is_dir() {
35+
let tomeignore_path = path.join(".tomeignore");
36+
if tomeignore_path.exists() {
37+
if let Ok(metadata) = fs::metadata(&tomeignore_path) {
38+
if metadata.len() == 0 {
39+
return true;
40+
}
41+
}
42+
}
43+
}
44+
45+
self.gitignore.matched_path_or_any_parents(path, path.is_dir()).is_ignore()
46+
}
47+
}
348

449
/// scan a directory for all files,
550
/// consuming each one as a script.
6-
/// returns the invocation
751
pub fn scan_directory(
852
root: &str,
953
previous_commands: &mut Vec<String>,
54+
ignorer: &TomeIgnorer,
1055
) -> io::Result<Vec<(String, script::Script)>> {
1156
let mut result = vec![];
12-
let paths: Vec<_> = read_dir(root).unwrap().map(|r| r.unwrap()).collect();
13-
// paths.sort_by_key(|f| f.path());
57+
let paths_raw = fs::read_dir(root);
58+
if paths_raw.is_err() {
59+
return Ok(vec![]);
60+
}
61+
let paths: Vec<_> = paths_raw.unwrap().map(|r| r.unwrap()).collect();
1462
for entry in paths {
1563
let path = entry.path();
64+
65+
if ignorer.is_ignored(&path) {
66+
continue;
67+
}
68+
1669
let file_name = path.file_name().unwrap_or_default().to_str().unwrap_or_default();
1770
let command_name = script::strip_source_suffix(file_name).to_string();
1871
previous_commands.push(command_name);
1972
if path.is_dir() {
20-
if is_tome_script_directory(&path) {
21-
result.extend(scan_directory(
22-
path.as_path().to_str().unwrap_or_default(),
23-
previous_commands,
24-
)?);
25-
}
73+
result.extend(scan_directory(
74+
path.as_path().to_str().unwrap_or_default(),
75+
previous_commands,
76+
ignorer,
77+
)?);
2678
} else if script::is_tome_script(&path) {
2779
result.push((
2880
previous_commands.join(" "),
@@ -34,19 +86,3 @@ pub fn scan_directory(
3486
Ok(result)
3587
}
3688

37-
/// returns if this directory should be considered by tome
38-
pub fn is_tome_script_directory(dir: &Path) -> bool {
39-
let mut tomeignore_location = dir.to_path_buf();
40-
// ignore dot directories
41-
if tomeignore_location
42-
.file_name()
43-
.unwrap_or_default()
44-
.to_str()
45-
.unwrap_or_default()
46-
.starts_with('.')
47-
{
48-
return false;
49-
}
50-
tomeignore_location.push(".tomeignore");
51-
!tomeignore_location.exists()
52-
}

0 commit comments

Comments
 (0)