use regex::Regex;
use std::sync::LazyLock;
pub struct TextSeg<'a> {
pub text: String,
pub file_path: Option<&'a str>,
}
pub fn segment_line_path(line: &str) -> Vec<TextSeg<'_>> {
static RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?x)
# Path with directory separator (permissive extension)
~?[a-zA-Z0-9_@\-\./]+/[a-zA-Z0-9_@\-\.]+\.[a-zA-Z0-9]{2,5}
|
# Standalone filename: allow chained dot segments so files like pcss.config.js
# or lightningcss.config.js are detected as a single file path.
# Post-filter rejects matches followed by continuation characters (hyphen, underscore, dot, alnum)
# to avoid false positives on model-version patterns like gpt-5.some-preview or gpt-5.2026-02-12.
[a-zA-Z0-9_@\-]+(?:\.[a-zA-Z0-9_@\-]+)*\.[a-zA-Z][a-zA-Z0-9]{0,4}
|
# Dotfiles (with optional chained extensions): .env, .gitignore, .env.local
\.[a-zA-Z][a-zA-Z0-9_\-]*(?:\.[a-zA-Z][a-zA-Z0-9]*)*
"#,
)
.expect("Failed to compile segment_line_path regex")
});
let re = &*RE;
let mut segments = Vec::new();
let mut last_idx = 0;
for m in re.find_iter(line) {
let start = m.start();
let end = m.end();
let text = &line[start..end];
if !text.contains('/') && !text.starts_with('.') {
let next_byte = line.as_bytes().get(end).copied();
if let Some(b) = next_byte
&& (b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
{
continue;
}
}
if start > last_idx {
segments.push(TextSeg {
text: line[last_idx..start].to_string(),
file_path: None,
});
}
segments.push(TextSeg {
text: text.to_string(),
file_path: Some(text),
});
last_idx = end;
}
if last_idx < line.len() {
segments.push(TextSeg {
text: line[last_idx..].to_string(),
file_path: None,
});
}
if segments.is_empty() && !line.is_empty() {
segments.push(TextSeg {
text: line.to_string(),
file_path: None,
});
}
segments
}
#[cfg(test)]
mod tests {
type Result<T> = core::result::Result<T, Box<dyn std::error::Error>>;
use super::*;
#[test]
fn test_text_helpers_segment_line_path_with_slash() -> Result<()> {
let line = "See src/main.rs for details";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].text, "See ");
assert!(segs[0].file_path.is_none());
assert_eq!(segs[1].text, "src/main.rs");
assert_eq!(segs[1].file_path, Some("src/main.rs"));
assert_eq!(segs[2].text, " for details");
assert!(segs[2].file_path.is_none());
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_tilde_prefix() -> Result<()> {
let line = "Check ~/work/app/src/main.rs now";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].text, "Check ");
assert_eq!(segs[1].text, "~/work/app/src/main.rs");
assert_eq!(segs[1].file_path, Some("~/work/app/src/main.rs"));
assert_eq!(segs[2].text, " now");
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_standalone_filename() -> Result<()> {
let line = "Edit tsconfig.json please";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].text, "Edit ");
assert_eq!(segs[1].text, "tsconfig.json");
assert_eq!(segs[1].file_path, Some("tsconfig.json"));
assert_eq!(segs[2].text, " please");
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_cargo_toml() -> Result<()> {
let line = "Update Cargo.toml";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].text, "Update ");
assert_eq!(segs[1].text, "Cargo.toml");
assert_eq!(segs[1].file_path, Some("Cargo.toml"));
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_multi_dot_js_config_filename() -> Result<()> {
let line = "Update pcss.config.js please";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].text, "Update ");
assert_eq!(segs[1].text, "pcss.config.js");
assert_eq!(segs[1].file_path, Some("pcss.config.js"));
assert_eq!(segs[2].text, " please");
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_rolldown_config_js() -> Result<()> {
let line = "See rolldown.config.js for setup";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].text, "See ");
assert_eq!(segs[1].text, "rolldown.config.js");
assert_eq!(segs[1].file_path, Some("rolldown.config.js"));
assert_eq!(segs[2].text, " for setup");
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_lightningcss_config_js() -> Result<()> {
let line = "See lightningcss.config.js for setup";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].text, "See ");
assert_eq!(segs[1].text, "lightningcss.config.js");
assert_eq!(segs[1].file_path, Some("lightningcss.config.js"));
assert_eq!(segs[2].text, " for setup");
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_dotfile_simple() -> Result<()> {
let line = "See .gitignore for exclusions";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].text, "See ");
assert_eq!(segs[1].text, ".gitignore");
assert_eq!(segs[1].file_path, Some(".gitignore"));
assert_eq!(segs[2].text, " for exclusions");
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_dotfile_multi_ext() -> Result<()> {
let line = "Check .env.local for overrides";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].text, "Check ");
assert_eq!(segs[1].text, ".env.local");
assert_eq!(segs[1].file_path, Some(".env.local"));
assert_eq!(segs[2].text, " for overrides");
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_dotenv() -> Result<()> {
let line = "Load .env vars";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].text, "Load ");
assert_eq!(segs[1].text, ".env");
assert_eq!(segs[1].file_path, Some(".env"));
assert_eq!(segs[2].text, " vars");
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_tilde_config() -> Result<()> {
let line = "Edit ~/.config/tool/config.toml";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].text, "Edit ");
assert_eq!(segs[1].text, "~/.config/tool/config.toml");
assert_eq!(segs[1].file_path, Some("~/.config/tool/config.toml"));
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_no_match() -> Result<()> {
let line = "No files here at all";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].text, "No files here at all");
assert!(segs[0].file_path.is_none());
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_model_version_numeric_suffix_not_matched() -> Result<()> {
let line = "Use gpt-5.4 for this run";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].text, "Use gpt-5.4 for this run");
assert!(segs[0].file_path.is_none());
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_model_date_suffix_not_matched() -> Result<()> {
let line = "Use gpt-5.2026-02-12 for this run";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].text, "Use gpt-5.2026-02-12 for this run");
assert!(segs[0].file_path.is_none());
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_model_preview_date_suffix_not_matched() -> Result<()> {
let line = "Use gpt-5.2026-02-12-preview for this run";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].text, "Use gpt-5.2026-02-12-preview for this run");
assert!(segs[0].file_path.is_none());
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_model_hyphenated_suffix_not_matched() -> Result<()> {
let line = "Use gpt-5.some-preview for this run";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].text, "Use gpt-5.some-preview for this run");
assert!(segs[0].file_path.is_none());
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_model_name_not_partially_matched() -> Result<()> {
let line = "Use gpt-5.6 for this run";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].text, "Use gpt-5.6 for this run");
assert!(segs[0].file_path.is_none());
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_model_date_not_partially_matched() -> Result<()> {
let line = "Use gpt-5.2026-02-12 for this run";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].text, "Use gpt-5.2026-02-12 for this run");
assert!(segs[0].file_path.is_none());
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_model_preview_not_partially_matched() -> Result<()> {
let line = "Use gpt-5.2026-02-12-preview for this run";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].text, "Use gpt-5.2026-02-12-preview for this run");
assert!(segs[0].file_path.is_none());
Ok(())
}
#[test]
fn test_text_helpers_segment_line_path_model_hyphenated_suffix_not_partially_matched() -> Result<()> {
let line = "Use gpt-5.some-preview for this run";
let segs = segment_line_path(line);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].text, "Use gpt-5.some-preview for this run");
assert!(segs[0].file_path.is_none());
Ok(())
}
}