use std::collections::{BTreeMap, BTreeSet};
use ferrox_models::grammar::json_schema::GrammarBuilder;
use crate::ApiError;
pub(super) fn text_excluding(
builder: &mut GrammarBuilder,
prefix: &str,
forbidden: &str,
) -> Result<String, ApiError> {
let chars: Vec<char> = forbidden.chars().collect();
if chars.is_empty() {
return Ok(builder.add_rule(prefix, r#""""#));
}
let failure = kmp_failure(&chars);
let alphabet: BTreeSet<char> = chars.iter().copied().collect();
let name_of = |state: usize| {
if state == 0 {
prefix.to_string()
} else {
format!("{prefix}-{state}")
}
};
for state in 0..chars.len() {
let mut buckets: BTreeMap<usize, Vec<char>> = BTreeMap::new();
let mut specific: Vec<char> = Vec::new();
for &c in &alphabet {
let next = step(&chars, &failure, state, c);
if next == chars.len() {
specific.push(c);
} else if next != 0 {
buckets.entry(next).or_default().push(c);
specific.push(c);
}
}
let mut alternatives = vec![String::new()];
for (next, group) in &buckets {
alternatives.push(format!("{} {}", char_class(group, false), name_of(*next)));
}
alternatives.push(format!("{} {}", char_class(&specific, true), name_of(0)));
let name = name_of(state);
let got = builder.add_rule(&name, &alternatives.join(" | "));
if got != name {
return Err(super::internal(format!(
"tool-call grammar: the rule {name:?} that holds text excluding {forbidden:?} was \
renamed to {got:?}, so its own states would reference the wrong rule"
)));
}
}
Ok(name_of(0))
}
fn kmp_failure(chars: &[char]) -> Vec<usize> {
let mut failure = vec![0usize; chars.len()];
let mut k = 0usize;
for i in 1..chars.len() {
while k > 0 && chars[i] != chars[k] {
k = failure[k - 1];
}
if chars[i] == chars[k] {
k += 1;
}
failure[i] = k;
}
failure
}
fn step(chars: &[char], failure: &[usize], state: usize, c: char) -> usize {
let mut state = state;
loop {
if chars[state] == c {
return state + 1;
}
if state == 0 {
return 0;
}
state = failure[state - 1];
}
}
fn char_class(chars: &[char], negated: bool) -> String {
let mut out = String::from("[");
if negated {
out.push('^');
}
for &c in chars {
match c {
'\r' => out.push_str("\\r"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'"' => out.push_str("\\\""),
'-' => out.push_str("\\x2D"),
']' => out.push_str("\\]"),
'[' => out.push_str("\\["),
'\\' => out.push_str("\\\\"),
'^' => out.push_str("\\x5E"),
c => out.push(c),
}
}
out.push(']');
out
}
#[cfg(test)]
mod tests {
use super::*;
use ferrox_models::grammar::Grammar;
fn accepts(forbidden: &str, text: &str) -> bool {
let mut builder = GrammarBuilder::new();
let body = text_excluding(&mut builder, "not", forbidden).expect("a rule");
builder.add_rule("root", &format!("{body} \"END\""));
let grammar = Grammar::from_str_with_root(&builder.finish().expect("grammar"), "root")
.expect("compiles");
let mut g = grammar.clone();
let whole = format!("{text}END");
if g.accept_token(0, whole.as_bytes()).is_err() {
return false;
}
g.allows_eog()
}
#[test]
fn only_the_forbidden_literal_is_refused() {
assert!(accepts("</parameter>", "plain text"));
assert!(accepts("</parameter>", "<html><body>a < b</body></html>"));
assert!(accepts(
"</parameter>",
"</param> </parameters> <parameter>"
));
assert!(accepts("</parameter>", ""));
assert!(!accepts("</parameter>", "before</parameter>after"));
assert!(!accepts("</parameter>", "</parameter>"));
}
#[test]
fn a_restarted_partial_match_still_completes_the_literal() {
assert!(!accepts("ab", "aab"));
assert!(accepts("ab", "aa"));
assert!(!accepts("aa", "baaa"));
assert!(accepts("aa", "aba"));
assert!(!accepts("aba", "xxababa"));
assert!(accepts("aba", "xxabb"));
}
#[test]
fn a_multi_byte_literal_is_excluded_by_codepoint() {
assert!(accepts("</|DSML|parameter>", "値 with a | in it"));
assert!(accepts("</|DSML|parameter>", "</|DSML|invoke>"));
assert!(!accepts("</|DSML|parameter>", "x</|DSML|parameter>y"));
}
#[test]
fn a_literal_of_class_metacharacters_still_compiles() {
for forbidden in ["]-^", "[\\]", "\"a\"", "\n\t"] {
assert!(
accepts(forbidden, "harmless"),
"{forbidden:?} should compile and accept text without it"
);
assert!(
!accepts(forbidden, forbidden),
"{forbidden:?} should exclude itself"
);
}
}
}