From 84d683a28637b1dd7e6b015dba3bc0a691a522cb Mon Sep 17 00:00:00 2001 From: Taiki Date: Mon, 20 Jul 2026 17:49:11 +0200 Subject: [PATCH 1/2] Update the proximity_keyword logic to accept the absence of a separator --- sds/src/ast_utils.rs | 15 ++++++++++++++- sds/src/proximity_keywords/mod.rs | 20 ++++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/sds/src/ast_utils.rs b/sds/src/ast_utils.rs index 089dacbc..29ebe291 100644 --- a/sds/src/ast_utils.rs +++ b/sds/src/ast_utils.rs @@ -1,6 +1,6 @@ use regex_syntax::ast::{ Alternation, Assertion, AssertionKind, Ast, Flag, Flags, FlagsItem, FlagsItemKind, Group, - GroupKind, Literal, LiteralKind, Position, Span, + GroupKind, Literal, LiteralKind, Position, Repetition, RepetitionKind, RepetitionOp, Span, }; pub(crate) fn should_push_word_boundary(c: char) -> bool { @@ -24,6 +24,19 @@ pub(crate) fn any_char(chars: &[char]) -> Ast { }) } +/// Makes `inner` optional (i.e. matches zero or one occurrence of it) +pub(crate) fn optional(inner: Ast) -> Ast { + Ast::Repetition(Repetition { + span: span(), + op: RepetitionOp { + span: span(), + kind: RepetitionKind::ZeroOrOne, + }, + greedy: true, + ast: Box::new(inner), + }) +} + pub(crate) fn literal_ast(c: char) -> Literal { let kind = if regex_syntax::is_meta_character(c) { LiteralKind::Meta diff --git a/sds/src/proximity_keywords/mod.rs b/sds/src/proximity_keywords/mod.rs index 6e2e3860..39169528 100644 --- a/sds/src/proximity_keywords/mod.rs +++ b/sds/src/proximity_keywords/mod.rs @@ -5,7 +5,8 @@ pub use crate::proximity_keywords::excluded_keywords::CompiledExcludedProximityK pub use crate::proximity_keywords::included_keywords::*; use crate::ast_utils::{ - any_char, literal_ast, non_capturing_group, should_push_word_boundary, span, word_boundary, + any_char, literal_ast, non_capturing_group, optional, should_push_word_boundary, span, + word_boundary, }; use crate::proximity_keywords::ProximityKeywordsValidationError::{ EmptyKeyword, InvalidLookAheadCharacterCount, KeywordTooLong, TooManyKeywords, @@ -240,11 +241,22 @@ fn calculate_keyword_content_pattern(keyword: &str) -> Ast { keyword_pattern.push(word_boundary_or_link_char()) } - for c in keyword.chars() { + let char_count = keyword.chars().count(); + for (i, c) in keyword.chars().enumerate() { if MULTI_WORD_KEYWORDS_LINK_CHARS.contains(&c) { // All "link chars" are treated the same, so the regex is built allowing any of them - // interchangeably - keyword_pattern.push(any_char(MULTI_WORD_KEYWORDS_LINK_CHARS)) + // interchangeably. + let separator = any_char(MULTI_WORD_KEYWORDS_LINK_CHARS); + if i == 0 || i == char_count - 1 { + // A link char at the very start/end of the keyword (e.g. "-host") is part of + // the keyword's own literal shape, not a separator between two words, so it + // must stay mandatory. + keyword_pattern.push(separator) + } else { + // An interior separator is optional so that concatenated words + // (e.g. "blable" for the keyword "bla ble") are matched as well. + keyword_pattern.push(optional(separator)) + } } else { keyword_pattern.push(Ast::Literal(literal_ast(c))) } From fccad16f2e351ca28bc88cf8d2611d57ce51d92a Mon Sep 17 00:00:00 2001 From: Taiki Date: Mon, 20 Jul 2026 17:49:34 +0200 Subject: [PATCH 2/2] Add tests --- .../proximity_keywords/included_keywords.rs | 35 +++++++++++++++++++ sds/src/proximity_keywords/mod.rs | 4 +-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/sds/src/proximity_keywords/included_keywords.rs b/sds/src/proximity_keywords/included_keywords.rs index 6ca90482..98deab22 100644 --- a/sds/src/proximity_keywords/included_keywords.rs +++ b/sds/src/proximity_keywords/included_keywords.rs @@ -246,4 +246,39 @@ mod test { vec![0] ); } + + #[test] + fn multi_word_keyword_matches_concatenated_words() { + let keywords = compile_keywords(30, &["bla ble"]); + + assert_eq!( + collect_keyword_matches(keywords.keyword_matches("blable secret")), + vec![0] + ); + assert_eq!( + collect_keyword_matches(keywords.keyword_matches("bla ble secret")), + vec![0] + ); + assert_eq!( + collect_keyword_matches(keywords.keyword_matches("bla_ble secret")), + vec![0] + ); + assert_eq!( + collect_keyword_matches(keywords.keyword_matches("bla.ble secret")), + vec![0] + ); + } + + #[test] + fn leading_and_trailing_link_chars_stay_mandatory() { + // The leading "-" is part of the keyword's own shape, not a word separator, so it + // must not become optional (otherwise "-host" would also match bare "host"). + let keywords = compile_keywords(30, &["-host"]); + + assert_eq!( + collect_keyword_matches(keywords.keyword_matches("-host ping")), + vec![0] + ); + assert!(collect_keyword_matches(keywords.keyword_matches("host ping")).is_empty()); + } } diff --git a/sds/src/proximity_keywords/mod.rs b/sds/src/proximity_keywords/mod.rs index 39169528..5d503efd 100644 --- a/sds/src/proximity_keywords/mod.rs +++ b/sds/src/proximity_keywords/mod.rs @@ -751,7 +751,7 @@ mod test { assert_eq!( content_pattern, - "(?:(?-u:\\b)|(?:_))hello(?:(?-u:\\b)|(?:_))|(?:(?-u:\\b)|(?:_))world\\*|(?:\\-|_|\\.| |/)aws(?:(?-u:\\b)|(?:_))|(?:(?-u:\\b)|(?:_))aws(?:\\-|_|\\.| |/)access(?:(?-u:\\b)|(?:_))" + "(?:(?-u:\\b)|(?:_))hello(?:(?-u:\\b)|(?:_))|(?:(?-u:\\b)|(?:_))world\\*|(?:\\-|_|\\.| |/)aws(?:(?-u:\\b)|(?:_))|(?:(?-u:\\b)|(?:_))aws(?:\\-|_|\\.| |/)?access(?:(?-u:\\b)|(?:_))" ); assert_eq!( path_pattern, @@ -771,7 +771,7 @@ mod test { fn test_calculate_multi_word_keyword_pattern() { assert_eq!( calculate_keyword_content_pattern("multi word-KEYWORD").to_string(), - "(?:(?-u:\\b)|(?:_))multi(?:\\-|_|\\.| |/)word(?:\\-|_|\\.| |/)KEYWORD(?:(?-u:\\b)|(?:_))" + "(?:(?-u:\\b)|(?:_))multi(?:\\-|_|\\.| |/)?word(?:\\-|_|\\.| |/)?KEYWORD(?:(?-u:\\b)|(?:_))" ) }