diff --git a/Cargo.lock b/Cargo.lock index c1ba730..cee1154 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,7 +432,7 @@ dependencies = [ [[package]] name = "codegraph-bench" -version = "0.40.1" +version = "0.40.2" dependencies = [ "anyhow", "clap", @@ -447,7 +447,7 @@ dependencies = [ [[package]] name = "codegraph-core" -version = "0.40.1" +version = "0.40.2" dependencies = [ "anyhow", "serde", @@ -463,7 +463,7 @@ dependencies = [ [[package]] name = "codegraph-daemon" -version = "0.40.1" +version = "0.40.2" dependencies = [ "anyhow", "codegraph-core", @@ -483,7 +483,7 @@ dependencies = [ [[package]] name = "codegraph-extract" -version = "0.40.1" +version = "0.40.2" dependencies = [ "anyhow", "codegraph-core", @@ -531,7 +531,7 @@ dependencies = [ [[package]] name = "codegraph-graph" -version = "0.40.1" +version = "0.40.2" dependencies = [ "anyhow", "codegraph-core", @@ -545,7 +545,7 @@ dependencies = [ [[package]] name = "codegraph-mcp" -version = "0.40.1" +version = "0.40.2" dependencies = [ "anyhow", "axum", @@ -568,7 +568,7 @@ dependencies = [ [[package]] name = "codegraph-resolve" -version = "0.40.1" +version = "0.40.2" dependencies = [ "anyhow", "codegraph-core", @@ -582,7 +582,7 @@ dependencies = [ [[package]] name = "codegraph-rs" -version = "0.40.1" +version = "0.40.2" dependencies = [ "anyhow", "assert_cmd", @@ -618,7 +618,7 @@ dependencies = [ [[package]] name = "codegraph-store" -version = "0.40.1" +version = "0.40.2" dependencies = [ "anyhow", "codegraph-core", @@ -630,7 +630,7 @@ dependencies = [ [[package]] name = "codegraph-watch" -version = "0.40.1" +version = "0.40.2" dependencies = [ "anyhow", "codegraph-core", diff --git a/crates/codegraph-resolve/src/frameworks/react.rs b/crates/codegraph-resolve/src/frameworks/react.rs index 48ce505..7131a3e 100644 --- a/crates/codegraph-resolve/src/frameworks/react.rs +++ b/crates/codegraph-resolve/src/frameworks/react.rs @@ -93,8 +93,7 @@ impl FrameworkResolver for ReactResolver { let line = line_of(content, full.start()); let after_start = full.end(); - let after_end = (after_start + 500).min(content.len()); - let after = &content[after_start..after_end]; + let after = byte_window(content, after_start, 500); let has_jsx = after.contains('<') && (after.contains("/>") || after.contains(" (v5/v6) (react.ts:158-198). for tag in route_tag_regex().find_iter(content) { - let window_end = (tag.start() + 400).min(content.len()); - let window = &content[tag.start()..window_end]; + let window = byte_window(content, tag.start(), 400); let Some(path_match) = route_path_attr().captures(window) else { continue; }; @@ -187,8 +185,7 @@ impl FrameworkResolver for ReactResolver { if data_router_regex().is_match(content) { for m in obj_path_regex().captures_iter(content) { let whole = m.get(0).expect("group 0"); - let win_end = (whole.start() + 300).min(content.len()); - let win = &content[whole.start()..win_end]; + let win = byte_window(content, whole.start(), 300); let comp = obj_element_attr() .captures(win) .or_else(|| obj_component_attr().captures(win)) @@ -514,6 +511,14 @@ fn line_of(content: &str, byte_index: usize) -> i64 { content[..byte_index].matches('\n').count() as i64 + 1 } +fn byte_window(content: &str, start: usize, max_bytes: usize) -> &str { + let mut end = start.saturating_add(max_bytes).min(content.len()); + while !content.is_char_boundary(end) { + end -= 1; + } + &content[start..end] +} + fn component_patterns() -> &'static [Regex] { static RE: OnceLock> = OnceLock::new(); RE.get_or_init(|| { @@ -1035,6 +1040,25 @@ mod tests { ); } + #[test] + fn extract_component_handles_utf8_at_lookahead_boundary() { + let mut content = String::from("function Card("); + content.push_str("
"); + content.push_str(&"a".repeat(493)); + content.push('é'); + + let result = ReactResolver + .extract("src/Card.tsx", &content, "") + .expect("extract"); + + assert!( + result + .nodes + .iter() + .any(|n| n.kind == NodeKind::Component && n.name == "Card") + ); + } + #[test] fn extract_component_without_jsx_is_skipped() { // A PascalCase function that returns no JSX must NOT be a component node. @@ -1108,6 +1132,25 @@ mod tests { assert!(result.references.iter().any(|r| r.reference_name == "Home")); } + #[test] + fn extract_route_handles_utf8_at_lookahead_boundary() { + let mut content = String::from("} "); + content.push_str(&"a".repeat(399 - content.len())); + content.push('é'); + + let result = ReactResolver + .extract("src/App.tsx", &content, "") + .expect("extract"); + let route = result + .nodes + .iter() + .find(|n| n.kind == NodeKind::Route) + .expect("route"); + + assert_eq!(route.name, "/utf8"); + assert!(result.references.iter().any(|r| r.reference_name == "Home")); + } + #[test] fn extract_route_without_path_is_skipped() { let content = "}/>"; @@ -1138,6 +1181,28 @@ mod tests { ); } + #[test] + fn extract_data_router_handles_utf8_at_lookahead_boundary() { + let mut content = + String::from("const routes = createBrowserRouter([{ path: '/utf8', element: "); + let path_start = content.find("path:").expect("path attribute"); + let utf8_start = path_start + 299; + content.push_str(&"a".repeat(utf8_start - content.len())); + content.push('é'); + + let result = ReactResolver + .extract("src/routes.tsx", &content, "") + .expect("extract"); + let route = result + .nodes + .iter() + .find(|n| n.kind == NodeKind::Route) + .expect("route"); + + assert_eq!(route.name, "/utf8"); + assert!(result.references.iter().any(|r| r.reference_name == "Home")); + } + #[test] fn extract_data_router_empty_path_becomes_root() { let content = "createBrowserRouter([{ path: '', Component: Index }]);";