Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 59 additions & 24 deletions src/sed/fast_regex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,29 +95,32 @@ pub struct LiteralMatcher {
}

impl LiteralMatcher {
/// Construct a new matcher based on a needle possible with anchors.
pub fn new(needle: impl AsRef<[u8]>) -> Self {
let needle_bytes = needle.as_ref();
if needle_bytes[0] == b'^' && needle_bytes[needle_bytes.len() - 1] == b'$' {
LiteralMatcher {
match_type: AnchoredMatch::Both,
needle: needle_bytes[1..needle_bytes.len() - 1].into(),
}
} else if needle_bytes[0] == b'^' {
LiteralMatcher {
match_type: AnchoredMatch::Begin,
needle: needle_bytes[1..needle_bytes.len()].into(),
}
} else if needle_bytes[needle_bytes.len() - 1] == b'$' {
LiteralMatcher {
match_type: AnchoredMatch::End,
needle: needle_bytes[0..needle_bytes.len() - 1].into(),
}
} else {
LiteralMatcher {
match_type: AnchoredMatch::Free,
needle: needle_bytes.into(),
}
/// Construct a new matcher based on a literal pattern with possible
/// anchors and backslash escapes. An escaped `\^` or `\$` stands for
/// itself rather than an anchor.
pub fn new(pattern: impl AsRef<[u8]>) -> Self {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add a few LiteralMatcher::new unit tests too (\^a, a\$, ^\^, a\\$) :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added one for each of those, \^a, a\$, ^\^ and a\\$, checking both what matches and where.

let mut pattern = pattern.as_ref();
let begin = pattern.first() == Some(&b'^');
if begin {
pattern = &pattern[1..];
}
// A trailing `$` is an anchor unless an odd number of backslashes
// precedes it.
let end = pattern.split_last().is_some_and(|(&last, rest)| {
last == b'$' && rest.iter().rev().take_while(|&&b| b == b'\\').count() % 2 == 0
});
if end {
pattern = &pattern[..pattern.len() - 1];
}
let match_type = match (begin, end) {
(true, true) => AnchoredMatch::Both,
(true, false) => AnchoredMatch::Begin,
(false, true) => AnchoredMatch::End,
(false, false) => AnchoredMatch::Free,
};
LiteralMatcher {
match_type,
needle: remove_escapes(pattern).into(),
}
}

Expand Down Expand Up @@ -293,7 +296,6 @@ impl Regex {
))
}
} else {
let pattern = remove_escapes(pattern);
Ok(Self::Literal(LiteralMatcher::new(pattern)))
}
}
Expand Down Expand Up @@ -926,6 +928,39 @@ mod tests {
assert!(!matcher.is_match(b"nada"));
}

#[test]
fn test_literal_matcher_escaped_caret_is_literal() {
let matcher = LiteralMatcher::new(r"\^a");
assert!(matcher.is_match(b"x^a"));
assert!(!matcher.is_match(b"a"));
assert_eq!(matcher.find(b"x^a"), Some((1, 3, &b"^a"[..])));
}

#[test]
fn test_literal_matcher_escaped_dollar_is_literal() {
let matcher = LiteralMatcher::new(r"a\$");
assert!(matcher.is_match(b"a$x"));
assert!(!matcher.is_match(b"xa"));
assert_eq!(matcher.find(b"a$x"), Some((0, 2, &b"a$"[..])));
}

#[test]
fn test_literal_matcher_anchor_then_escaped_caret() {
let matcher = LiteralMatcher::new(r"^\^");
assert!(matcher.is_match(b"^a"));
assert!(!matcher.is_match(b"a^"));
assert!(!matcher.is_match(b"a"));
}

#[test]
fn test_literal_matcher_escaped_backslash_then_anchor() {
let matcher = LiteralMatcher::new(r"a\\$");
assert!(matcher.is_match(br"xa\"));
assert!(!matcher.is_match(br"a\x"));
assert!(!matcher.is_match(b"xa"));
assert_eq!(matcher.find(br"xa\"), Some((1, 3, &br"a\"[..])));
}

#[test]
fn test_literal_matcher_utf8_match() {
let matcher = LiteralMatcher::new("✓"); // U+2713 CHECK MARK (3 bytes)
Expand Down
15 changes: 15 additions & 0 deletions tests/by-util/test_sed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,21 @@ fn test_word_boundaries_without_other_regex_characters() {
.stdout_is("bar\n<bar\n");
}

#[test]
fn test_escaped_anchors_in_literal_pattern() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you please run the GNU testsuite to check that nothing regresses?
setup: git clone https://github.com/mirror/sed.git ../gnu.sed
run: ./util/run-gnu-testsuite.sh

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran it with ./util/run-gnu-testsuite.sh on main and on this branch and got identical results for every test, so nothing regresses. The GNU testsuite job on the PR also says no new failures vs main.

// \^ and \$ match a literal ^ and $, not the start or end of the line.
new_ucmd!()
.args(&["-e", r"s/\^/X/", "-e", r"s/\$/Y/"])
.pipe_in("a^b$c\n")
.succeeds()
.stdout_is("aXbYc\n");
new_ucmd!()
.args(&["-E", "-e", r"s/^a\$/X/", "-e", r"s/b\\$/Y/"])
.pipe_in("a$b\\\na\n")
.succeeds()
.stdout_is("XY\na\n");
}

#[test]
fn test_unknown_escape_is_the_bare_character() {
new_ucmd!()
Expand Down
Loading