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 matches_now_gerund(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) || matches_now_gerund(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
}
const SIDE_EFFECT_CATEGORIES: &[&[&str]] = &[
&[
"shipped",
"released",
"release complete",
"release is shipped",
"release was completed",
"release is done",
],
&[
"pushed to origin",
"pushed to remote",
"tag pushed",
"pushed (",
"push status",
"git push",
],
&[
"tag created",
"created and pushed",
"tagged v",
"tag `v",
"created` and pushed",
],
&[
"bumped to",
"version bump",
"version bumped",
"cargo.toml bumped",
"bumped cargo",
],
&[
"changelog updated",
"changelog entry",
"entry inserted",
"new entry inserted",
],
&[
"posts appended",
"posts appended to",
"release posts",
"published to",
"posted to",
],
];
pub fn count_unbacked_side_effect_claims(text: &str) -> usize {
let lower = text.to_lowercase();
SIDE_EFFECT_CATEGORIES
.iter()
.filter(|category| category.iter().any(|phrase| lower.contains(phrase)))
.count()
}
pub fn claims_unbacked_side_effects(text: &str) -> bool {
count_unbacked_side_effect_claims(text) >= 2
}
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
}
fn max_lang_regex_count(
lower: &str,
regex_of: impl Fn(&phantom_lang::LangConfig) -> &str,
) -> usize {
phantom_lang::all_langs()
.iter()
.filter_map(|lang| {
let re = regex_of(lang);
(!re.is_empty())
.then(|| Regex::new(re).ok().map(|r| r.find_iter(lower).count()))
.flatten()
})
.max()
.unwrap_or(0)
}
fn any_lang_regex_match(text: &str, regex_of: impl Fn(&phantom_lang::LangConfig) -> &str) -> bool {
phantom_lang::all_langs().iter().any(|lang| {
let re = regex_of(lang);
!re.is_empty() && Regex::new(re).map(|r| r.is_match(text)).unwrap_or(false)
})
}
pub fn has_phantom_tool_intent(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.len() < 40 {
return false;
}
let lower = trimmed.to_lowercase();
if max_lang_regex_count(&lower, |l| &l.now_imperative_re) >= 2 {
return true;
}
if max_lang_regex_count(&lower, |l| &l.numbered_steps_re) >= 2 {
return true;
}
if max_lang_regex_count(&lower, |l| &l.past_tense_standalone_re) >= 2 {
return true;
}
if phantom_lang::all_langs()
.iter()
.any(|l| lang_completion_match(&lower, &l.completion_claims))
{
return true;
}
if any_lang_regex_match(trimmed, |l| &l.gerund_re) {
return true;
}
if any_lang_regex_match(trimmed, |l| &l.trailing_colon_re) {
return true;
}
if lang_intent_match_any(&lower) {
let path = any_lang_regex_match(trimmed, |l| &l.path_re)
|| any_lang_regex_match(trimmed, |l| &l.ext_re)
|| any_lang_regex_match(trimmed, |l| &l.backtick_code_re);
if path {
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
}
pub(crate) fn matches_now_gerund(text: &str) -> bool {
let text = strip_inline_directives(text);
phantom_lang::all_langs().iter().any(|lang| {
!lang.gerund_re.is_empty()
&& Regex::new(&lang.gerund_re)
.map(|re| re.is_match(&text))
.unwrap_or(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))
}
const BARE_COMPLETION_PHRASES: &[&str] = &[
"done",
"all done",
"ready",
"all ready",
"ready to go",
"good to go",
"finished",
"all finished",
"complete",
"completed",
"task complete",
"task completed",
"all set",
"all good",
"taken care of",
"handled",
"sorted",
"there you go",
"here you go",
];
pub fn is_bare_completion_only(text: &str) -> bool {
let cleaned = strip_inline_directives(text);
if cleaned.contains("```") {
return false;
}
let trimmed = cleaned.trim();
if trimmed.chars().count() > 48 {
return false;
}
let normalized: String = trimmed
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { ' ' })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
BARE_COMPLETION_PHRASES.contains(&normalized.as_str())
}
pub fn is_delivery_intent(text: &str) -> bool {
let lower = text.to_lowercase();
let body = lower.trim_start();
let body = if body.starts_with('[') {
body.split_once(']')
.map(|x| x.1)
.unwrap_or(body)
.trim_start()
} else {
body
};
const QUESTION_OPENERS: &[&str] = &[
"did ", "do ", "does ", "is ", "are ", "was ", "were ", "have ", "has ", "had ", "can ",
"could ", "will ", "would ", "should ", "what ", "where ", "when ", "why ", "how ",
"which ", "who ",
];
if QUESTION_OPENERS.iter().any(|q| body.starts_with(q)) {
return false;
}
let head: String = body.chars().take(1000).collect();
let leading_word = |w: &str| -> bool {
let needle = format!(" {w} ");
head.starts_with(&format!("{w} ")) || head.contains(&needle)
};
const DELIVERY_VERBS: &[&str] = &[
"create",
"build",
"generate",
"write",
"implement",
"produce",
"make",
"draft",
"compose",
"design",
"provide",
];
DELIVERY_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("://")
}