Skip to content

Commit a88673b

Browse files
Error on $<num><non_num> capture replacement names (#258)
* Mostly working capture name validation * Improve inputs for property tests * Fix advancing when passing over escaped dollar signs * Switch to inline snapshot captures * Cleanup invalid capture error formatting code
1 parent a98100f commit a88673b

File tree

10 files changed

+901
-82
lines changed

10 files changed

+901
-82
lines changed

Cargo.lock

+300-17
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+5
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ clap.workspace = true
4343
assert_cmd = "2.0.12"
4444
anyhow = "1.0.75"
4545
clap_mangen = "0.2.14"
46+
proptest = "1.3.1"
47+
console = "0.15.7"
48+
insta = "1.34.0"
49+
ansi-to-html = "0.1.3"
50+
regex-automata = "0.4.3"
4651

4752
[profile.release]
4853
opt-level = 3
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 3a23ade8355ca034558ea8635e4ea2ee96ecb38b7b1cb9a854509d7633d45795 # shrinks to s = ""
8+
cc 8c8d1e7497465f26416bddb7607df0de1fce48d098653eeabac0ad2aeba1fa0a # shrinks to s = "$0$0a"
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc cfacd65058c8dae0ac7b91c56b8096c36ef68cb35d67262debebac005ea9c677 # shrinks to s = ""
8+
cc 61e5dc6ce0314cde48b5cbc839fbf46a49fcf8d0ba02cfeecdcbff52fca8c786 # shrinks to s = "$a"
9+
cc 8e5fd9dbb58ae762a751349749320664715056ef63aad58215397e87ee42c722 # shrinks to s = "$$"
10+
cc 37c2e41ceeddbecbc4e574f82b58a4007923027ad1a6756bf2f547aa3f748d13 # shrinks to s = "$$0"

src/error.rs

+4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use std::{
33
path::PathBuf,
44
};
55

6+
use crate::replacer::InvalidReplaceCapture;
7+
68
#[derive(thiserror::Error)]
79
pub enum Error {
810
#[error("invalid regex {0}")]
@@ -15,6 +17,8 @@ pub enum Error {
1517
InvalidPath(PathBuf),
1618
#[error("failed processing files:\n{0}")]
1719
FailedProcessing(FailedJobs),
20+
#[error("{0}")]
21+
InvalidReplaceCapture(#[from] InvalidReplaceCapture),
1822
}
1923

2024
pub struct FailedJobs(Vec<(PathBuf, Error)>);

src/main.rs

+11-1
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,23 @@ mod input;
55
pub(crate) mod replacer;
66
pub(crate) mod utils;
77

8+
use std::process;
9+
810
pub(crate) use self::input::{App, Source};
11+
use ansi_term::{Color, Style};
912
pub(crate) use error::{Error, Result};
1013
use replacer::Replacer;
1114

1215
use clap::Parser;
1316

14-
fn main() -> Result<()> {
17+
fn main() {
18+
if let Err(e) = try_main() {
19+
eprintln!("{}: {}", Style::from(Color::Red).bold().paint("error"), e);
20+
process::exit(1);
21+
}
22+
}
23+
24+
fn try_main() -> Result<()> {
1525
let options = cli::Options::parse();
1626

1727
let source = if options.recursive {

src/replacer.rs src/replacer/mod.rs

+11-64
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
use std::{fs, fs::File, io::prelude::*, path::Path};
2+
13
use crate::{utils, Error, Result};
4+
25
use regex::bytes::Regex;
3-
use std::{fs, fs::File, io::prelude::*, path::Path};
6+
7+
#[cfg(test)]
8+
mod tests;
9+
mod validate;
10+
11+
pub use validate::{validate_replace, InvalidReplaceCapture};
412

513
pub(crate) struct Replacer {
614
regex: Regex,
@@ -20,6 +28,8 @@ impl Replacer {
2028
let (look_for, replace_with) = if is_literal {
2129
(regex::escape(&look_for), replace_with.into_bytes())
2230
} else {
31+
validate_replace(&replace_with)?;
32+
2333
(
2434
look_for,
2535
utils::unescape(&replace_with)
@@ -154,66 +164,3 @@ impl Replacer {
154164
Ok(())
155165
}
156166
}
157-
158-
#[cfg(test)]
159-
mod tests {
160-
use super::*;
161-
162-
fn replace(
163-
look_for: impl Into<String>,
164-
replace_with: impl Into<String>,
165-
literal: bool,
166-
flags: Option<&'static str>,
167-
src: &'static str,
168-
target: &'static str,
169-
) {
170-
let replacer = Replacer::new(
171-
look_for.into(),
172-
replace_with.into(),
173-
literal,
174-
flags.map(ToOwned::to_owned),
175-
None,
176-
)
177-
.unwrap();
178-
assert_eq!(
179-
std::str::from_utf8(&replacer.replace(src.as_bytes())),
180-
Ok(target)
181-
);
182-
}
183-
184-
#[test]
185-
fn default_global() {
186-
replace("a", "b", false, None, "aaa", "bbb");
187-
}
188-
189-
#[test]
190-
fn escaped_char_preservation() {
191-
replace("a", "b", false, None, "a\\n", "b\\n");
192-
}
193-
194-
#[test]
195-
fn case_sensitive_default() {
196-
replace("abc", "x", false, None, "abcABC", "xABC");
197-
replace("abc", "x", true, None, "abcABC", "xABC");
198-
}
199-
200-
#[test]
201-
fn sanity_check_literal_replacements() {
202-
replace("((special[]))", "x", true, None, "((special[]))y", "xy");
203-
}
204-
205-
#[test]
206-
fn unescape_regex_replacements() {
207-
replace("test", r"\n", false, None, "testtest", "\n\n");
208-
}
209-
210-
#[test]
211-
fn no_unescape_literal_replacements() {
212-
replace("test", r"\n", true, None, "testtest", r"\n\n");
213-
}
214-
215-
#[test]
216-
fn full_word_replace() {
217-
replace("abc", "def", false, Some("w"), "abcd abc", "abcd def");
218-
}
219-
}

src/replacer/tests.rs

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
use super::*;
2+
3+
use proptest::prelude::*;
4+
5+
proptest! {
6+
#[test]
7+
fn validate_doesnt_panic(s in r"(\PC*\$?){0,5}") {
8+
let _ = validate::validate_replace(&s);
9+
}
10+
11+
// $ followed by a digit and a non-ident char or an ident char
12+
#[test]
13+
fn validate_ok(s in r"([^\$]*(\$([0-9][^a-zA-Z_0-9\$]|a-zA-Z_))?){0,5}") {
14+
validate::validate_replace(&s).unwrap();
15+
}
16+
17+
// Force at least one $ followed by a digit and an ident char
18+
#[test]
19+
fn validate_err(s in r"[^\$]*?\$[0-9][a-zA-Z_]\PC*") {
20+
validate::validate_replace(&s).unwrap_err();
21+
}
22+
}
23+
24+
fn replace(
25+
look_for: impl Into<String>,
26+
replace_with: impl Into<String>,
27+
literal: bool,
28+
flags: Option<&'static str>,
29+
src: &'static str,
30+
target: &'static str,
31+
) {
32+
let replacer = Replacer::new(
33+
look_for.into(),
34+
replace_with.into(),
35+
literal,
36+
flags.map(ToOwned::to_owned),
37+
None,
38+
)
39+
.unwrap();
40+
assert_eq!(
41+
std::str::from_utf8(&replacer.replace(src.as_bytes())),
42+
Ok(target)
43+
);
44+
}
45+
46+
#[test]
47+
fn default_global() {
48+
replace("a", "b", false, None, "aaa", "bbb");
49+
}
50+
51+
#[test]
52+
fn escaped_char_preservation() {
53+
replace("a", "b", false, None, "a\\n", "b\\n");
54+
}
55+
56+
#[test]
57+
fn case_sensitive_default() {
58+
replace("abc", "x", false, None, "abcABC", "xABC");
59+
replace("abc", "x", true, None, "abcABC", "xABC");
60+
}
61+
62+
#[test]
63+
fn sanity_check_literal_replacements() {
64+
replace("((special[]))", "x", true, None, "((special[]))y", "xy");
65+
}
66+
67+
#[test]
68+
fn unescape_regex_replacements() {
69+
replace("test", r"\n", false, None, "testtest", "\n\n");
70+
}
71+
72+
#[test]
73+
fn no_unescape_literal_replacements() {
74+
replace("test", r"\n", true, None, "testtest", r"\n\n");
75+
}
76+
77+
#[test]
78+
fn full_word_replace() {
79+
replace("abc", "def", false, Some("w"), "abcd abc", "abcd def");
80+
}

0 commit comments

Comments
 (0)