use regex::Regex;
use std::sync::LazyLock;
use super::phantom_lang;
pub fn has_phantom_tool_intent_no_tools(text: &str) -> bool {
let cleaned = strip_inline_directives(text);
let trimmed = cleaned.trim();
let lead = prose_lead_in(trimmed);
if lead.is_empty() {
return false;
}
if matches_work_announcement(lead) {
return true;
}
if trimmed.len() < 20 {
return false;
}
let lower = lead.to_lowercase();
if lang_intent_match_any(&lower) {
return true;
}
let lang = phantom_lang::detect_language(trimmed);
has_past_tense_action_claim(&lower, &lang.action_verbs)
}
fn has_past_tense_action_claim(lower: &str, action_verbs: &[String]) -> bool {
for raw_sentence in lower.split(['.', '\n', '!']) {
let s = raw_sentence.trim();
if s.is_empty() || s.len() > 80 {
continue;
}
let words: Vec<&str> = s
.split_whitespace()
.take(4)
.map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
.collect();
for verb in action_verbs {
if let Some(pos) = words.iter().position(|w| w == verb) {
const AUX: &[&str] = &[
"is", "are", "was", "were", "been", "be", "being", "gets", "get", "got",
"está", "están", "foi", "é", "est", "sont",
];
let passive = pos > 0 && AUX.contains(&words[pos - 1]);
if !passive {
return true;
}
}
}
}
false
}
pub fn has_investigative_intent(text: &str) -> bool {
let lower = text.to_lowercase();
lang_intent_match_any(&lower)
}
pub fn has_forward_intent_post_success(text: &str) -> bool {
let cleaned = strip_inline_directives(text);
let trimmed = cleaned.trim();
if trimmed.len() < 20 {
return false;
}
let lead = prose_lead_in(trimmed);
if lead.is_empty() {
return false;
}
let lower = lead.to_lowercase();
lang_intent_match_any(&lower) || matches_work_announcement(lead)
}
fn strip_inline_directives(text: &str) -> String {
static DIRECTIVE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<<[^<>\n]{1,120}>>").expect("directive regex"));
DIRECTIVE_RE.replace_all(text, "").into_owned()
}
pub fn mentions_registered_tool(text: &str, tool_names: &[String]) -> bool {
let lower = text.to_lowercase();
for name in tool_names {
if !name.contains('_') {
continue;
}
let mut from = 0;
while let Some(rel) = lower[from..].find(name.as_str()) {
let start = from + rel;
let end = start + name.len();
let before_ok = start == 0
|| !lower[..start]
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric() || c == '_');
let after_ok = end == lower.len()
|| !lower[end..]
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '_');
if before_ok && after_ok {
return true;
}
from = end;
}
}
false
}
pub fn count_intent_line_starts(text: &str) -> usize {
let lang = phantom_lang::detect_language(text);
if lang.line_start_re.is_empty() {
return 0;
}
let re = Regex::new(&lang.line_start_re).unwrap_or_else(|_| {
Regex::new(r"$^").unwrap() });
re.find_iter(text).count()
}
pub const STUCK_INTENT_LOOP_THRESHOLD: usize = 3;
pub fn max_repeated_intent_line(text: &str) -> usize {
let lang = phantom_lang::detect_language(text);
if lang.line_start_re.is_empty() {
return 0;
}
let Ok(re) = Regex::new(&lang.line_start_re) else {
return 0;
};
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut max = 0;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if re.find(trimmed).map(|m| m.start() == 0).unwrap_or(false) {
let norm = trimmed
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let c = counts.entry(norm).or_insert(0);
*c += 1;
max = max.max(*c);
}
}
max
}
pub fn is_stuck_in_intent_loop(text: &str) -> bool {
max_repeated_intent_line(text) >= STUCK_INTENT_LOOP_THRESHOLD
}
pub fn has_phantom_tool_intent(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.len() < 40 {
return false;
}
let lower = trimmed.to_lowercase();
let lang = phantom_lang::detect_language(trimmed);
if !lang.now_imperative_re.is_empty()
&& let Ok(re) = Regex::new(&lang.now_imperative_re)
&& re.find_iter(&lower).count() >= 2
{
return true;
}
if !lang.numbered_steps_re.is_empty()
&& let Ok(re) = Regex::new(&lang.numbered_steps_re)
&& re.find_iter(&lower).count() >= 2
{
return true;
}
if !lang.past_tense_standalone_re.is_empty()
&& let Ok(re) = Regex::new(&lang.past_tense_standalone_re)
&& re.find_iter(&lower).count() >= 2
{
return true;
}
if lang_completion_match(&lower, &lang.completion_claims) {
return true;
}
if !lang.gerund_re.is_empty()
&& let Ok(re) = Regex::new(&lang.gerund_re)
&& re.is_match(trimmed)
{
return true;
}
if !lang.trailing_colon_re.is_empty()
&& let Ok(re) = Regex::new(&lang.trailing_colon_re)
&& re.is_match(trimmed)
{
return true;
}
let has_intent = lang_intent_match(&lower, &lang.intent_phrases);
if has_intent {
let path_match = !lang.path_re.is_empty()
&& Regex::new(&lang.path_re)
.map(|re| re.is_match(trimmed))
.unwrap_or(false);
let ext_match = !lang.ext_re.is_empty()
&& Regex::new(&lang.ext_re)
.map(|re| re.is_match(trimmed))
.unwrap_or(false);
let backtick_match = !lang.backtick_code_re.is_empty()
&& Regex::new(&lang.backtick_code_re)
.map(|re| re.is_match(trimmed))
.unwrap_or(false);
if path_match || ext_match || backtick_match {
return true;
}
}
false
}
fn lang_intent_match(lower: &str, phrases: &[String]) -> bool {
phrases.iter().any(|p| lower.contains(p.as_str()))
}
fn lang_intent_match_any(lower: &str) -> bool {
phantom_lang::all_langs()
.iter()
.any(|lang| lang_intent_match(lower, &lang.intent_phrases))
}
pub(crate) fn matches_work_announcement(lead: &str) -> bool {
let lead = strip_inline_directives(lead);
let lead = lead.trim();
phantom_lang::all_langs().iter().any(|lang| {
!lang.work_announcement_re.is_empty()
&& Regex::new(&lang.work_announcement_re)
.map(|re| announcement_matches_anywhere(&re, lead))
.unwrap_or(false)
})
}
fn announcement_matches_anywhere(re: &Regex, lead: &str) -> bool {
let mut starts: Vec<usize> = vec![0];
let mut after_ender = false;
for (idx, ch) in lead.char_indices() {
if after_ender && !ch.is_whitespace() {
starts.push(idx);
after_ender = false;
}
if matches!(ch, '.' | '!' | '?' | '\n' | '…') {
after_ender = true;
}
}
for &start in &starts {
let suffix = &lead[start..];
if re.is_match(suffix) {
return true;
}
let window_end = suffix
.char_indices()
.take_while(|(i, _)| *i <= 48)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
if let Some(comma) = suffix[..window_end].find(", ")
&& re.is_match(&suffix[comma + 2..])
{
return true;
}
}
false
}
fn lang_completion_match(lower: &str, claims: &[String]) -> bool {
claims.iter().any(|c| lower.contains(c.as_str()))
}
fn prose_lead_in(text: &str) -> &str {
let mut byte_offset: usize = 0;
for (idx, line) in text.lines().enumerate() {
let trimmed_line = line.trim_start();
let is_structural = trimmed_line.starts_with("```")
|| (trimmed_line.starts_with('|') && trimmed_line.contains('|'))
|| trimmed_line.starts_with("- ")
|| trimmed_line.starts_with("* ")
|| trimmed_line.starts_with("• ")
|| (trimmed_line
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit())
&& trimmed_line.contains(". "));
if is_structural {
return text[..byte_offset].trim_end();
}
if idx >= 6 {
break;
}
byte_offset += line.len() + 1;
}
text
}
pub fn is_analysis_intent(text: &str) -> bool {
let lower = text.to_lowercase();
let body = lower.rsplit('\n').next().unwrap_or(&lower);
let head: String = body.chars().take(200).collect();
let leading_word = |w: &str| -> bool {
let needle = format!(" {w} ");
if head.starts_with(&format!("{w} ")) {
return true;
}
head.contains(&needle)
};
const ANALYSIS_VERBS: &[&str] = &[
"audit",
"review",
"compare",
"explain",
"summarise",
"summarize",
"check",
"describe",
"analyse",
"analyze",
"find",
"look up",
"look at",
"what does",
"how does",
"why does",
"what is",
"what are",
"tell me",
"show me",
"investigate",
"diagnose",
];
ANALYSIS_VERBS.iter().any(|v| leading_word(v))
}
pub fn looks_truncated_mid_sentence(text: &str) -> bool {
let trimmed = text.trim_end();
if trimmed.chars().count() < 40 {
return false;
}
if trimmed.ends_with("```") {
return false;
}
if trimmed.ends_with('|') {
return false;
}
if ends_with_url(trimmed) {
return false;
}
let last = match trimmed.chars().next_back() {
Some(c) => c,
None => return false,
};
if last.is_alphanumeric() {
return true;
}
matches!(
last,
',' | ';' | ':' | '-' | '(' | '[' | '{' | '<' | '/' | '\\' | '&' | '@' | '#'
)
}
fn ends_with_url(text: &str) -> bool {
let trimmed = text.trim_end();
let boundary = trimmed
.rfind(|c: char| c.is_whitespace() || matches!(c, '(' | '[' | '{' | '<' | '"' | '\''))
.map(|i| i + 1)
.unwrap_or(0);
let tail = &trimmed[boundary..];
tail.contains("://")
}