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(""));
if !has_jsx {
continue;
@@ -152,8 +151,7 @@ impl FrameworkResolver for ReactResolver {
// React Router (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 }]);";