From 656a55f0b0f470945767a169e4dfd4a5cdbc353c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Sun, 30 Aug 2026 09:57:30 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(core):=20=E9=9A=94=E7=A6=BB=20Spring=20?= =?UTF-8?q?Bean=20=E5=90=8D=E7=A7=B0=EF=BC=8C=E9=81=BF=E5=85=8D=E8=A2=AB?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E6=B3=A8=E8=A7=A3=E5=92=8C=E7=9B=B8=E9=82=BB?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E6=B1=A1=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 检测阶段已用注解边界确认 Bean,命名却仍用朴素前缀搜索,会从 BeanFactory 读到 decoy。 组件名称的宽捕获也会跨过右括号读到相邻注解。现在只从源码最靠前的准确注解参数里取值。 Co-authored-by: Cursor --- rust/lithe-core/src/languages/spring.rs | 99 ++++++++++++++++++------- rust/lithe-core/src/tests/spring.rs | 75 +++++++++++++++++++ 2 files changed, 148 insertions(+), 26 deletions(-) diff --git a/rust/lithe-core/src/languages/spring.rs b/rust/lithe-core/src/languages/spring.rs index 9ea7c5f0e..b8340d937 100644 --- a/rust/lithe-core/src/languages/spring.rs +++ b/rust/lithe-core/src/languages/spring.rs @@ -1186,6 +1186,7 @@ macro_rules! spring_annotations { #[cfg(test)] pub(crate) const ALL: &'static [Self] = &[$(Self::$variant),+]; + #[cfg(test)] pub(crate) fn name(self) -> &'static str { match self { $(Self::$variant => $name),+ @@ -1278,9 +1279,10 @@ spring_mapping_annotations! { } impl SpringAnnotation { - /// Annotations that declare a Spring component on a type declaration. The - /// order also drives the alternation in [`component_name`], so changing it - /// changes which annotation wins on a type carrying several of them. + /// Annotations that declare a Spring component on a type declaration. + /// Detection and naming share this closed set so they cannot drift; when + /// several are present, the leftmost source match wins rather than this + /// array's declaration order. const COMPONENTS: [Self; 6] = [ Self::Component, Self::Service, @@ -1304,24 +1306,29 @@ fn has_component_annotation(context: &str) -> bool { .any(|annotation| annotation.is_present(context)) } +/// Returns the explicit name of the earliest component annotation in source +/// order. The value is read only from that annotation's own argument list so a +/// later neighbor such as `@Component("c")` cannot pollute `@Service("s")`. fn component_name(context: &str) -> Option { - // Built from COMPONENTS so the recognized set cannot diverge from the one - // has_component_annotation uses. - static ANNOTATION: LazyLock = LazyLock::new(|| { - let alternation = SpringAnnotation::COMPONENTS - .iter() - .map(|annotation| regex::escape(annotation.name())) - .collect::>() - .join("|"); - Regex::new(&format!( - r#"@({alternation})\s*\([^\)]*[\"']([^\"']+)[\"']"# - )) - .expect("escaped annotation names produce a valid pattern") - }); - ANNOTATION - .captures(context) - .and_then(|capture| capture.get(2)) - .map(|value| value.as_str().to_string()) + let start = earliest_component_annotation_start(context)?; + quoted_values(isolate_annotation_at(context, start)) + .into_iter() + .next() +} + +/// Locates every exact component annotation with the cached boundary patterns +/// and keeps the leftmost source start. Array order in [`SpringAnnotation::COMPONENTS`] +/// is not a priority. +fn earliest_component_annotation_start(context: &str) -> Option { + SpringAnnotation::COMPONENTS + .iter() + .filter_map(|annotation| { + annotation + .pattern() + .find(context) + .map(|found| found.start()) + }) + .min() } fn qualifier_names(context: &str) -> Vec { @@ -1335,13 +1342,13 @@ fn qualifier_names(context: &str) -> Vec { .collect() } +/// Returns declared `@Bean` aliases from the exact `@Bean` annotation only. +/// A prefix decoy such as `@BeanFactory("decoy")` cannot supply the name. fn bean_names(context: &str) -> Vec { - let Some(start) = context.find("@Bean") else { + let Some(found) = SpringAnnotation::Bean.pattern().find(context) else { return Vec::new(); }; - let remaining = &context[start..]; - let end = remaining.find(')').unwrap_or(remaining.len()); - quoted_values(&remaining[..end]) + quoted_values(isolate_annotation_at(context, found.start())) } fn quoted_values(value: &str) -> Vec { @@ -1573,8 +1580,8 @@ fn find_mapping_annotation(text: &str) -> Option<(SpringMappingAnnotation, &str) } /// Slices one annotation starting at `start` (`@Name` followed by an optional -/// argument list) so route extraction cannot read string literals from a -/// neighboring decoy. +/// argument list) so name or route extraction cannot read string literals from +/// a neighboring decoy. /// /// A later annotation's `(` is not this annotation's argument list. After the /// name, only whitespace may appear before `(`. @@ -1786,6 +1793,46 @@ mod tests { assert_eq!(component_name("@Qualifier(\"custom\")"), None); } + /// `@BeanFactory` shares a prefix with `@Bean`, so a naive `@Bean` search + /// would take the decoy name. Naming must reuse the cached boundary match. + #[test] + fn bean_names_uses_the_exact_bean_annotation_not_a_prefix() { + assert_eq!( + bean_names(r#"@BeanFactory("decoy") @Bean("real")"#), + vec!["real".to_string()] + ); + assert_eq!(bean_names(r#"@BeanFactory("decoy")"#), Vec::::new()); + assert_eq!(bean_names(r#"@Bean("real")"#), vec!["real".to_string()]); + assert_eq!(bean_names("@Bean"), Vec::::new()); + assert_eq!( + bean_names(r#"@BeanFactory("decoy") @Bean"#), + Vec::::new() + ); + } + + /// A wide capture can start at the first closing quote and swallow + /// `) @Component(`. Isolation plus source-position selection must keep + /// the first annotation's own value, independent of COMPONENTS order. + #[test] + fn component_name_selects_the_earliest_annotation_and_isolates_its_value() { + assert_eq!( + component_name(r#"@Service("s") @Component("c")"#).as_deref(), + Some("s") + ); + assert_eq!( + component_name(r#"@Component("c") @Service("s")"#).as_deref(), + Some("c") + ); + assert_ne!( + component_name(r#"@Service("s") @Component("c")"#).as_deref(), + Some(") @Component(") + ); + assert_eq!(component_name("@Service @Component"), None); + assert_eq!(component_name(r#"@Service @Component("c")"#), None); + assert_eq!(component_name("@Service"), None); + assert_eq!(component_name("@ServiceLocator(\"x\")"), None); + } + /// Record components and constructor parameters share one pattern, so a /// declaration parsed by one path must be parsed identically by the other. #[test] diff --git a/rust/lithe-core/src/tests/spring.rs b/rust/lithe-core/src/tests/spring.rs index 6efa760d6..57bb6dfcc 100644 --- a/rust/lithe-core/src/tests/spring.rs +++ b/rust/lithe-core/src/tests/spring.rs @@ -347,6 +347,81 @@ public class RealConfig { fs::remove_dir_all(root).expect("Spring fixture should be removable"); } +/// Bean and component names must come from the exact annotation's own +/// argument list. A prefix decoy or a later neighbor cannot supply the name. +#[test] +fn spring_index_isolates_bean_and_component_names_from_neighbor_annotations() { + let root = temporary_root("spring-bean-name-isolation"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("ClockConfig.java"), + r#"package demo; +@Configuration +public class ClockConfig { + @BeanFactory("decoy") @Bean("real") + public Clock clock() { return null; } + @BeanFactory("decoyOnly") + public Clock decoyClock() { return null; } + @Bean + public Clock unnamedClock() { return null; } +} +"#, + ) + .expect("bean name fixture should be writable"); + fs::write( + java.join("Demo.java"), + "package demo;\n@Service(\"s\") @Component(\"c\")\npublic class Demo {}\n", + ) + .expect("component name fixture should be writable"); + fs::write( + java.join("Ordered.java"), + "package demo;\n@Component(\"c\") @Service(\"s\")\npublic class Ordered {}\n", + ) + .expect("reversed component fixture should be writable"); + + let paths = [ + "src/main/java/demo/ClockConfig.java", + "src/main/java/demo/Demo.java", + "src/main/java/demo/Ordered.java", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + assert_eq!(response["ok"], true, "{response}"); + + let beans = response["data"]["beans"].as_array().unwrap(); + let names = beans + .iter() + .map(|value| value["name"].as_str().unwrap()) + .collect::>(); + assert!(names.contains(&"real"), "{response}"); + assert!(names.contains(&"unnamedClock"), "{response}"); + assert!(names.contains(&"clockConfig"), "{response}"); + assert!(names.contains(&"s"), "{response}"); + assert!(names.contains(&"c"), "{response}"); + assert!(!names.contains(&"decoy"), "{response}"); + assert!(!names.contains(&"decoyOnly"), "{response}"); + assert!(!names.contains(&"decoyClock"), "{response}"); + assert!(!names.contains(&") @Component("), "{response}"); + + let demo = beans + .iter() + .find(|value| value["typeName"] == "Demo") + .unwrap_or_else(|| panic!("missing Demo bean: {response}")); + assert_eq!(demo["name"], "s"); + let ordered = beans + .iter() + .find(|value| value["typeName"] == "Ordered") + .unwrap_or_else(|| panic!("missing Ordered bean: {response}")); + assert_eq!(ordered["name"], "c"); + let clock = beans + .iter() + .find(|value| value["name"] == "real" && value["kind"] == "beanMethod") + .unwrap_or_else(|| panic!("missing named Clock bean: {response}")); + assert_eq!(clock["typeName"], "Clock"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + /// Custom annotations that only share a Mapping prefix must not become /// endpoints or class-level base routes; exact Spring Mapping names still do. #[test] From 65a8ea0af4e1ea39e41efd1f0bd8b2a637815527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Sun, 30 Aug 2026 10:18:08 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(core):=20=E6=89=AB=E6=8F=8F=E6=B3=A8?= =?UTF-8?q?=E8=A7=A3=E6=8B=AC=E5=8F=B7=E6=97=B6=E8=B7=B3=E8=BF=87=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E4=B8=B2=E5=AD=97=E9=9D=A2=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isolate_annotation_at 把字符串里的括号也计入深度,遇到 @Bean("foo(") 会吞掉相邻注解。 括号扫描现在跟踪引号和转义,只对注解语法中的括号计数。 Co-authored-by: Cursor --- rust/lithe-core/src/languages/spring.rs | 50 ++++++++++++++++++++++++- rust/lithe-core/src/tests/spring.rs | 9 +++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/rust/lithe-core/src/languages/spring.rs b/rust/lithe-core/src/languages/spring.rs index b8340d937..ed245f2e1 100644 --- a/rust/lithe-core/src/languages/spring.rs +++ b/rust/lithe-core/src/languages/spring.rs @@ -1584,7 +1584,8 @@ fn find_mapping_annotation(text: &str) -> Option<(SpringMappingAnnotation, &str) /// a neighboring decoy. /// /// A later annotation's `(` is not this annotation's argument list. After the -/// name, only whitespace may appear before `(`. +/// name, only whitespace may appear before `(`. Parentheses inside quoted +/// strings, including escaped quotes, do not change the argument-list depth. fn isolate_annotation_at(text: &str, start: usize) -> &str { let rest = &text[start..]; let name_end = rest @@ -1607,8 +1608,25 @@ fn isolate_annotation_at(text: &str, start: usize) -> &str { } let open_index = name_end + whitespace_len; let mut depth = 0isize; + let mut in_string = None; + let mut escaped = false; for (index, character) in rest[open_index..].char_indices() { + if let Some(quote) = in_string { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if character == quote { + in_string = None; + } + continue; + } match character { + '"' | '\'' => in_string = Some(character), '(' => depth += 1, ')' => { depth -= 1; @@ -1810,6 +1828,36 @@ mod tests { ); } + /// A `(` inside a string is not the annotation argument list. Counting it + /// would swallow the real closer and pull a later neighbor into the aliases. + #[test] + fn isolate_annotation_at_ignores_parentheses_inside_strings() { + assert_eq!( + isolate_annotation_at(r#"@Bean("foo(") @Bean("bar")"#, 0), + r#"@Bean("foo(")"# + ); + assert_eq!( + isolate_annotation_at(r#"@Bean('foo(') @Service("s")"#, 0), + r#"@Bean('foo(')"# + ); + assert_eq!( + isolate_annotation_at(r#"@Bean("foo\")") @Bean("bar")"#, 0), + r#"@Bean("foo\")")"# + ); + assert_eq!( + bean_names(r#"@Bean("foo(") @Bean("bar")"#), + vec!["foo(".to_string()] + ); + assert_eq!( + component_name(r#"@Service("s(") @Component("c")"#).as_deref(), + Some("s(") + ); + let mapping_with_paren = mapping(r#"@GetMapping("/foo(") @PostMapping("/bar")"#) + .expect("GetMapping with a parenthesis in its route should still isolate"); + assert_eq!(mapping_with_paren.0, SpringMappingAnnotation::GetMapping); + assert_eq!(mapping_with_paren.2, vec!["/foo(".to_string()]); + } + /// A wide capture can start at the first closing quote and swallow /// `) @Component(`. Isolation plus source-position selection must keep /// the first annotation's own value, independent of COMPONENTS order. diff --git a/rust/lithe-core/src/tests/spring.rs b/rust/lithe-core/src/tests/spring.rs index 57bb6dfcc..70c546cf6 100644 --- a/rust/lithe-core/src/tests/spring.rs +++ b/rust/lithe-core/src/tests/spring.rs @@ -361,6 +361,8 @@ fn spring_index_isolates_bean_and_component_names_from_neighbor_annotations() { public class ClockConfig { @BeanFactory("decoy") @Bean("real") public Clock clock() { return null; } + @Bean("foo(") @Bean("bar") + public Clock parenClock() { return null; } @BeanFactory("decoyOnly") public Clock decoyClock() { return null; } @Bean @@ -394,6 +396,7 @@ public class ClockConfig { .map(|value| value["name"].as_str().unwrap()) .collect::>(); assert!(names.contains(&"real"), "{response}"); + assert!(names.contains(&"foo("), "{response}"); assert!(names.contains(&"unnamedClock"), "{response}"); assert!(names.contains(&"clockConfig"), "{response}"); assert!(names.contains(&"s"), "{response}"); @@ -401,6 +404,7 @@ public class ClockConfig { assert!(!names.contains(&"decoy"), "{response}"); assert!(!names.contains(&"decoyOnly"), "{response}"); assert!(!names.contains(&"decoyClock"), "{response}"); + assert!(!names.contains(&"bar"), "{response}"); assert!(!names.contains(&") @Component("), "{response}"); let demo = beans @@ -418,6 +422,11 @@ public class ClockConfig { .find(|value| value["name"] == "real" && value["kind"] == "beanMethod") .unwrap_or_else(|| panic!("missing named Clock bean: {response}")); assert_eq!(clock["typeName"], "Clock"); + let paren = beans + .iter() + .find(|value| value["name"] == "foo(" && value["kind"] == "beanMethod") + .unwrap_or_else(|| panic!("missing parenthesis Clock bean: {response}")); + assert_eq!(paren["typeName"], "Clock"); fs::remove_dir_all(root).expect("Spring fixture should be removable"); } From a68b875575856d9fad9d8aa401f661d76f6ee35d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Sun, 30 Aug 2026 13:06:18 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(core):=20=E7=A9=BA=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=B3=A8=E8=A7=A3=E5=80=BC=E5=9B=9E=E9=80=80=E5=88=B0=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=20Bean=20=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quoted_values 会接受空字符串,@Component("") 变成 Some("") 后不再走类名回退。 现在忽略空捕获,与旧的非空捕获行为一致,避免空名称和异常 ID。 Co-authored-by: Cursor --- rust/lithe-core/src/languages/spring.rs | 22 ++++++++++- rust/lithe-core/src/tests/spring.rs | 50 +++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/rust/lithe-core/src/languages/spring.rs b/rust/lithe-core/src/languages/spring.rs index ed245f2e1..e752599d4 100644 --- a/rust/lithe-core/src/languages/spring.rs +++ b/rust/lithe-core/src/languages/spring.rs @@ -1309,11 +1309,13 @@ fn has_component_annotation(context: &str) -> bool { /// Returns the explicit name of the earliest component annotation in source /// order. The value is read only from that annotation's own argument list so a /// later neighbor such as `@Component("c")` cannot pollute `@Service("s")`. +/// An empty string such as `@Component("")` is not a name; callers then use +/// the default type name. fn component_name(context: &str) -> Option { let start = earliest_component_annotation_start(context)?; quoted_values(isolate_annotation_at(context, start)) .into_iter() - .next() + .find(|value| !value.is_empty()) } /// Locates every exact component annotation with the cached boundary patterns @@ -1349,6 +1351,9 @@ fn bean_names(context: &str) -> Vec { return Vec::new(); }; quoted_values(isolate_annotation_at(context, found.start())) + .into_iter() + .filter(|value| !value.is_empty()) + .collect() } fn quoted_values(value: &str) -> Vec { @@ -1881,6 +1886,21 @@ mod tests { assert_eq!(component_name("@ServiceLocator(\"x\")"), None); } + /// `quoted_values` accepts empty captures. An empty annotation value is not + /// an explicit bean name, so naming must return None and let bean_index use + /// the default type or method name. + #[test] + fn empty_annotation_values_are_not_explicit_bean_names() { + assert_eq!(component_name(r#"@Component("")"#), None); + assert_eq!(component_name(r#"@Service('')"#), None); + assert_eq!( + component_name(r#"@Component("") @Service("s")"#), + None, + "an empty leftmost name must not take a later neighbor" + ); + assert_eq!(bean_names(r#"@Bean("")"#), Vec::::new()); + } + /// Record components and constructor parameters share one pattern, so a /// declaration parsed by one path must be parsed identically by the other. #[test] diff --git a/rust/lithe-core/src/tests/spring.rs b/rust/lithe-core/src/tests/spring.rs index 70c546cf6..6afdd4a2a 100644 --- a/rust/lithe-core/src/tests/spring.rs +++ b/rust/lithe-core/src/tests/spring.rs @@ -431,6 +431,56 @@ public class ClockConfig { fs::remove_dir_all(root).expect("Spring fixture should be removable"); } +/// An empty `@Component("")` value is not a bean name. Indexing must fall back +/// to the default type name instead of recording an empty id. +#[test] +fn spring_index_falls_back_when_a_component_name_is_an_empty_string() { + let root = temporary_root("spring-empty-component-name"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("EmptyName.java"), + "package demo;\n@Component(\"\")\npublic class EmptyName {}\n", + ) + .expect("empty component fixture should be writable"); + fs::write( + java.join("Neighbor.java"), + "package demo;\n@Component(\"\") @Service(\"s\")\npublic class Neighbor {}\n", + ) + .expect("empty-then-neighbor fixture should be writable"); + + let response = execute_spring( + &root, + &[ + "src/main/java/demo/EmptyName.java", + "src/main/java/demo/Neighbor.java", + ], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + + let beans = response["data"]["beans"].as_array().unwrap(); + let empty_name = beans + .iter() + .find(|value| value["typeName"] == "EmptyName") + .unwrap_or_else(|| panic!("missing EmptyName bean: {response}")); + assert_eq!(empty_name["name"], "emptyName"); + assert!( + empty_name["id"].as_str().unwrap().ends_with(":emptyName"), + "{response}" + ); + let neighbor = beans + .iter() + .find(|value| value["typeName"] == "Neighbor") + .unwrap_or_else(|| panic!("missing Neighbor bean: {response}")); + assert_eq!(neighbor["name"], "neighbor"); + assert_ne!(empty_name["name"], ""); + assert_ne!(neighbor["name"], ""); + assert_ne!(neighbor["name"], "s"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + /// Custom annotations that only share a Mapping prefix must not become /// endpoints or class-level base routes; exact Spring Mapping names still do. #[test]