diff --git a/rust/lithe-core/src/languages/spring.rs b/rust/lithe-core/src/languages/spring.rs index f5b773253..1c974f38a 100644 --- a/rust/lithe-core/src/languages/spring.rs +++ b/rust/lithe-core/src/languages/spring.rs @@ -12,7 +12,7 @@ use std::collections::{HashMap, HashSet}; use std::fs::{self, File}; use std::io::Read; use std::path::{Component, Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{LazyLock, Mutex, OnceLock}; use zip::ZipArchive; const MAX_METADATA_ARCHIVES: usize = 20_000; @@ -20,6 +20,16 @@ const MAX_METADATA_ARCHIVES: usize = 20_000; static REPOSITORY_METADATA_CACHE: OnceLock>>> = OnceLock::new(); +/// One Java parameter declaration: optional annotations, an optional `final`, +/// the type, and the parameter name. Record components and constructor +/// parameters share this grammar, so they must not drift into two patterns. +static JAVA_PARAMETER_DECLARATION: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?:@[A-Za-z0-9_$.]+(?:\([^)]*\))?\s+)*(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)$", + ) + .expect("literal pattern is valid") +}); + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Workspace paths and an optional trusted dependency repository to index. @@ -126,12 +136,14 @@ pub fn spring_index(request: SpringIndexRequest) -> Result Vec { - let annotation = - Regex::new(r#"@Value\s*\(\s*[\"']\$\{\s*([^}:\s]+)(?::[^}]*)?\s*\}[\"']\s*\)"#).unwrap(); + static ANNOTATION: LazyLock = LazyLock::new(|| { + Regex::new(r#"@Value\s*\(\s*[\"']\$\{\s*([^}:\s]+)(?::[^}]*)?\s*\}[\"']\s*\)"#) + .expect("literal pattern is valid") + }); let mut references = Vec::new(); for (path, source) in sources { for (index, line) in source.lines().enumerate() { - for capture in annotation.captures_iter(line) { + for capture in ANNOTATION.captures_iter(line) { let Some(key) = capture.get(1) else { continue }; references.push(SpringPropertyReferenceResponse { key: canonical_property_name(key.as_str()), @@ -391,10 +403,12 @@ fn append_configuration_properties( sources: &[(String, String)], properties: &mut Vec, ) { - let annotation = Regex::new( - r#"(?s)@ConfigurationProperties\s*\(\s*(?:prefix\s*=\s*)?[\"']([^\"']+)[\"'][^)]*\).*?\b(?:class|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)"#, - ) - .unwrap(); + static ANNOTATION: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(?s)@ConfigurationProperties\s*\(\s*(?:prefix\s*=\s*)?[\"']([^\"']+)[\"'][^)]*\).*?\b(?:class|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)"#, + ) + .expect("literal pattern is valid") + }); let mut types = HashMap::new(); for (path, source) in sources { for value in parse_configuration_types(path, source) { @@ -402,7 +416,7 @@ fn append_configuration_properties( } } for (_, source) in sources { - for capture in annotation.captures_iter(source) { + for capture in ANNOTATION.captures_iter(source) { let Some(prefix) = capture.get(1) else { continue; }; @@ -437,17 +451,21 @@ struct ConfigurationType { } fn parse_configuration_types(path: &str, source: &str) -> Vec { - let declaration = - Regex::new(r"\b(?:class|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)(?:\s*\(([^)]*)\))?").unwrap(); - let field = Regex::new( - r"(?:private|protected|public)\s+(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?, ]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:=\s*([^;]+))?;", - ) - .unwrap(); + static DECLARATION: LazyLock = LazyLock::new(|| { + Regex::new(r"\b(?:class|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)(?:\s*\(([^)]*)\))?") + .expect("literal pattern is valid") + }); + static FIELD: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?:private|protected|public)\s+(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?, ]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:=\s*([^;]+))?;", + ) + .expect("literal pattern is valid") + }); let mut types = Vec::::new(); let mut stack = Vec::::new(); let mut depth = 0isize; for (index, line) in source.lines().enumerate() { - if let Some(capture) = declaration.captures(line) { + if let Some(capture) = DECLARATION.captures(line) { let name = capture.get(1).unwrap(); let body_depth = depth + brace_delta(line); let mut value = ConfigurationType { @@ -467,7 +485,7 @@ fn parse_configuration_types(path: &str, source: &str) -> Vec stack.push(types.len() - 1); } else if let Some(type_index) = stack.last().copied() { if depth == types[type_index].body_depth { - if let Some(capture) = field.captures(line) { + if let Some(capture) = FIELD.captures(line) { let name = capture.get(2).unwrap(); types[type_index].fields.push(ConfigurationField { name: name.as_str().to_string(), @@ -499,14 +517,10 @@ fn parse_record_components( line: &str, components: &str, ) -> Vec { - let component = Regex::new( - r"(?:@[A-Za-z0-9_$.]+(?:\([^)]*\))?\s+)*(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)$", - ) - .unwrap(); split_parameters(components) .into_iter() .filter_map(|value| { - let capture = component.captures(value.trim())?; + let capture = JAVA_PARAMETER_DECLARATION.captures(value.trim())?; let name = capture.get(2)?; Some(ConfigurationField { name: name.as_str().to_string(), @@ -913,19 +927,25 @@ fn bean_index( Vec, Vec, ) { - let type_declaration = - Regex::new(r"\b(class|interface|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)([^\{]*)").unwrap(); - let method = Regex::new( - r"(?:public|protected|private)?\s*(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(", - ) - .unwrap(); - let field = Regex::new( - r"(?:private|protected|public)\s+(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)", - ) - .unwrap(); + static TYPE_DECLARATION: LazyLock = LazyLock::new(|| { + Regex::new(r"\b(class|interface|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)([^\{]*)") + .expect("literal pattern is valid") + }); + static METHOD: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?:public|protected|private)?\s*(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(", + ) + .expect("literal pattern is valid") + }); + static FIELD: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?:private|protected|public)\s+(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)", + ) + .expect("literal pattern is valid") + }); let mut supertypes = HashMap::>::new(); for (_, source) in sources { - for capture in type_declaration.captures_iter(source) { + for capture in TYPE_DECLARATION.captures_iter(source) { let Some(name) = capture.get(2) else { continue }; let tail = capture .get(3) @@ -939,18 +959,20 @@ fn bean_index( let mut raw_injections = Vec::new(); for (path, source) in sources { let lines = source.lines().collect::>(); - let source_type = type_declaration + let source_type = TYPE_DECLARATION .captures(source) .and_then(|capture| capture.get(2)) .map(|value| value.as_str().to_string()); - let constructor_count = source_type.as_deref().map_or(0, |name| { - constructor_regex(name) - .map(|pattern| pattern.captures_iter(source).count()) - .unwrap_or(0) - }); + // The constructor pattern depends on the declaring type, so it cannot be + // a file-independent constant, but it is identical for every line of one + // source and must not be rebuilt inside the line loop below. + let constructor_pattern = source_type.as_deref().and_then(constructor_regex); + let constructor_count = constructor_pattern + .as_ref() + .map_or(0, |pattern| pattern.captures_iter(source).count()); for (index, line) in lines.iter().enumerate() { let context = annotation_context(&lines, index); - if let Some(capture) = type_declaration.captures(line) { + if let Some(capture) = TYPE_DECLARATION.captures(line) { if has_component_annotation(&context) { let name = capture.get(2).unwrap(); let default_name = lower_camel(name.as_str()); @@ -969,12 +991,12 @@ fn bean_index( }, names, assignable_types: assignable_types(name.as_str(), &supertypes), - primary: has_annotation(&context, "Primary"), + primary: SpringAnnotation::Primary.is_present(&context), }); } } - if has_annotation(&context, "Bean") { - if let Some(capture) = method.captures(line) { + if SpringAnnotation::Bean.is_present(&context) { + if let Some(capture) = METHOD.captures(line) { let type_name = simple_type(capture.get(1).unwrap().as_str()); let declaration_name = capture.get(2).unwrap(); let aliases = bean_names(&context); @@ -997,12 +1019,12 @@ fn bean_index( }, names, assignable_types: assignable_types(&type_name, &supertypes), - primary: has_annotation(&context, "Primary"), + primary: SpringAnnotation::Primary.is_present(&context), }); } } if is_injection_context(&context) { - if let Some(capture) = field.captures(line) { + if let Some(capture) = FIELD.captures(line) { let type_name = simple_type(capture.get(1).unwrap().as_str()); let name = capture.get(2).unwrap(); raw_injections.push(RawInjection { @@ -1014,26 +1036,24 @@ fn bean_index( }); } } - if let Some(type_name) = source_type.as_deref() { - let Some(pattern) = constructor_regex(type_name) else { - continue; - }; - let Some(opening) = pattern.find(line).map(|value| value.end() - 1) else { - continue; - }; - if !is_injection_context(&context) && constructor_count != 1 { - continue; - } - let Some(closing) = line.rfind(')').filter(|value| *value > opening) else { - continue; - }; - raw_injections.extend(parse_constructor_injections( - path, - index + 1, - line, - &line[opening + 1..closing], - )); + let Some(pattern) = constructor_pattern.as_ref() else { + continue; + }; + let Some(opening) = pattern.find(line).map(|value| value.end() - 1) else { + continue; + }; + if !is_injection_context(&context) && constructor_count != 1 { + continue; } + let Some(closing) = line.rfind(')').filter(|value| *value > opening) else { + continue; + }; + raw_injections.extend(parse_constructor_injections( + path, + index + 1, + line, + &line[opening + 1..closing], + )); } } @@ -1138,39 +1158,120 @@ fn annotation_context(lines: &[&str], index: usize) -> String { values.join(" ") } -fn has_annotation(context: &str, name: &str) -> bool { +/// Matches `@Name` only when the name is not a prefix of a longer annotation, +/// so `@Bean` does not match `@BeanFactory`. +fn annotation_boundary_pattern(name: &str) -> Regex { Regex::new(&format!(r"@{}(?:\s|\(|$)", regex::escape(name))) - .unwrap() - .is_match(context) + .expect("an escaped annotation name is a valid pattern") +} + +/// Declares the Spring annotations this module recognizes exactly once, and +/// derives the type, the spelling, the full list, and the compiled pattern from +/// that one declaration. +/// +/// Detection runs several times for every line of every Java source, so each +/// pattern is compiled once for the process. `pattern` matches on `self` instead +/// of looking the annotation up in a table, which leaves the compiler to reject +/// a case that was added without a pattern. +macro_rules! spring_annotations { + ($($variant:ident => $name:literal),+ $(,)?) => { + #[derive(Clone, Copy)] + pub(crate) enum SpringAnnotation { + $($variant),+ + } + + impl SpringAnnotation { + /// Every recognized annotation, in declaration order. Production + /// code reaches a pattern through a case rather than this list. + #[cfg(test)] + pub(crate) const ALL: &'static [Self] = &[$(Self::$variant),+]; + + pub(crate) fn name(self) -> &'static str { + match self { + $(Self::$variant => $name),+ + } + } + + pub(crate) fn pattern(self) -> &'static Regex { + match self { + $(Self::$variant => { + static PATTERN: LazyLock = + LazyLock::new(|| annotation_boundary_pattern($name)); + &PATTERN + })+ + } + } + } + }; +} + +spring_annotations! { + Autowired => "Autowired", + Bean => "Bean", + Component => "Component", + Configuration => "Configuration", + Controller => "Controller", + Inject => "Inject", + Primary => "Primary", + Repository => "Repository", + Resource => "Resource", + RestController => "RestController", + Service => "Service", +} + +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. + const COMPONENTS: [Self; 6] = [ + Self::Component, + Self::Service, + Self::Repository, + Self::Controller, + Self::RestController, + Self::Configuration, + ]; + + /// Annotations that mark a field or constructor parameter for injection. + const INJECTIONS: [Self; 3] = [Self::Autowired, Self::Inject, Self::Resource]; + + pub(crate) fn is_present(self, context: &str) -> bool { + self.pattern().is_match(context) + } } fn has_component_annotation(context: &str) -> bool { - [ - "Component", - "Service", - "Repository", - "Controller", - "RestController", - "Configuration", - ] - .iter() - .any(|name| has_annotation(context, name)) + SpringAnnotation::COMPONENTS + .iter() + .any(|annotation| annotation.is_present(context)) } fn component_name(context: &str) -> Option { - let annotation = Regex::new( - r#"@(Component|Service|Repository|Controller|RestController|Configuration)\s*\([^\)]*[\"']([^\"']+)[\"']"#, - ) - .unwrap(); - annotation + // 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()) } fn qualifier_names(context: &str) -> Vec { - let pattern = Regex::new(r#"@Qualifier\s*\(\s*[\"']([^\"']+)[\"']\s*\)"#).unwrap(); - pattern + static PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r#"@Qualifier\s*\(\s*[\"']([^\"']+)[\"']\s*\)"#) + .expect("literal pattern is valid") + }); + PATTERN .captures_iter(context) .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) .collect() @@ -1186,21 +1287,27 @@ fn bean_names(context: &str) -> Vec { } fn quoted_values(value: &str) -> Vec { - let pattern = Regex::new(r#"[\"']([^\"']*)[\"']"#).unwrap(); - pattern + static PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r#"[\"']([^\"']*)[\"']"#).expect("literal pattern is valid")); + PATTERN .captures_iter(value) .filter_map(|capture| capture.get(1).map(|item| item.as_str().to_string())) .collect() } fn declared_supertypes(tail: &str) -> Vec { + // Indexed by the fixed keyword order below, which the result order depends on. + static PATTERNS: LazyLock<[Regex; 2]> = LazyLock::new(|| { + ["extends", "implements"].map(|keyword| { + Regex::new(&format!( + r"\b{}\s+([^\{{]+?)(?:\b(?:extends|implements)\b|$)", + keyword + )) + .expect("literal keyword produces a valid pattern") + }) + }); let mut values = Vec::new(); - for keyword in ["extends", "implements"] { - let pattern = Regex::new(&format!( - r"\b{}\s+([^\{{]+?)(?:\b(?:extends|implements)\b|$)", - keyword - )) - .unwrap(); + for pattern in PATTERNS.iter() { if let Some(capture) = pattern.captures(tail) { values.extend( capture[1] @@ -1228,15 +1335,18 @@ fn assignable_types(type_name: &str, supertypes: &HashMap>) } fn is_injection_context(context: &str) -> bool { - ["Autowired", "Inject", "Resource"] + SpringAnnotation::INJECTIONS .iter() - .any(|name| has_annotation(context, name)) + .any(|annotation| annotation.is_present(context)) } fn injection_qualifier(context: &str) -> Option { qualifier_names(context).into_iter().next().or_else(|| { - let resource = Regex::new(r#"@Resource\s*\([^\)]*name\s*=\s*[\"']([^\"']+)[\"']"#).unwrap(); - resource + static RESOURCE: LazyLock = LazyLock::new(|| { + Regex::new(r#"@Resource\s*\([^\)]*name\s*=\s*[\"']([^\"']+)[\"']"#) + .expect("literal pattern is valid") + }); + RESOURCE .captures(context) .and_then(|capture| capture.get(1)) .map(|value| value.as_str().to_string()) @@ -1260,11 +1370,7 @@ fn parse_constructor_injections( split_parameters(parameters) .into_iter() .filter_map(|parameter| { - let declaration = Regex::new( - r"(?:@[A-Za-z0-9_$.]+(?:\([^)]*\))?\s+)*(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)$", - ) - .unwrap(); - let capture = declaration.captures(parameter.trim())?; + let capture = JAVA_PARAMETER_DECLARATION.captures(parameter.trim())?; let variable = capture.get(2)?; Some(RawInjection { path: path.to_string(), @@ -1299,14 +1405,21 @@ fn split_parameters(value: &str) -> Vec<&str> { } fn endpoint_index(sources: &[(String, String)]) -> Vec { - let class = Regex::new(r"\bclass\s+([A-Za-z_$][A-Za-z0-9_$]*)").unwrap(); - let method = Regex::new(r"[A-Za-z0-9_$.<>?]+\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(").unwrap(); + static CLASS: LazyLock = LazyLock::new(|| { + Regex::new(r"\bclass\s+([A-Za-z_$][A-Za-z0-9_$]*)").expect("literal pattern is valid") + }); + static METHOD: LazyLock = LazyLock::new(|| { + Regex::new(r"[A-Za-z0-9_$.<>?]+\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(") + .expect("literal pattern is valid") + }); let mut endpoints = Vec::new(); for (path, source) in sources { - if !has_annotation(source, "Controller") && !has_annotation(source, "RestController") { + if !SpringAnnotation::Controller.is_present(source) + && !SpringAnnotation::RestController.is_present(source) + { continue; } - let controller = class + let controller = CLASS .captures(source) .and_then(|capture| capture.get(1)) .map(|value| value.as_str().to_string()) @@ -1328,12 +1441,12 @@ fn endpoint_index(sources: &[(String, String)]) -> Vec { let declaration = declaration_index .and_then(|value| lines.get(value).copied()) .unwrap_or_default(); - if annotation.contains("@RequestMapping") && class.is_match(declaration) { + if annotation.contains("@RequestMapping") && CLASS.is_match(declaration) { base_routes = routes; index = annotation_end + 1; continue; } - let method_name = method + let method_name = METHOD .captures(declaration) .and_then(|capture| capture.get(1)) .map(|value| value.as_str()) @@ -1377,9 +1490,11 @@ fn mapping(annotation_text: &str) -> Option<(Vec, Vec)> { } } if annotation_text.contains("@RequestMapping") { - let method_pattern = - Regex::new(r"RequestMethod\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE)").unwrap(); - let mut methods = method_pattern + static METHOD_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"RequestMethod\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE)") + .expect("literal pattern is valid") + }); + let mut methods = METHOD_PATTERN .captures_iter(annotation_text) .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) .collect::>(); @@ -1394,8 +1509,11 @@ fn mapping(annotation_text: &str) -> Option<(Vec, Vec)> { } fn annotation_routes(annotation: &str) -> Vec { - let named = Regex::new(r#"(?:value|path)\s*=\s*(\{[^}]*\}|[\"'][^\"']*[\"'])"#).unwrap(); - let expression = named + static NAMED: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?:value|path)\s*=\s*(\{[^}]*\}|[\"'][^\"']*[\"'])"#) + .expect("literal pattern is valid") + }); + let expression = NAMED .captures(annotation) .and_then(|capture| capture.get(1)) .map(|value| value.as_str()) @@ -1481,3 +1599,81 @@ fn lower_camel(value: &str) -> String { .map(|first| first.to_lowercase().collect::() + characters.as_str()) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + /// `spring_annotations!` derives the type, the spelling, `ALL`, and the + /// compiled pattern from one declaration, so a case cannot exist without a + /// pattern. This covers the boundary each entry has to keep. + #[test] + fn every_supported_annotation_has_a_compiled_boundary_pattern() { + for annotation in SpringAnnotation::ALL.iter().copied() { + let name = annotation.name(); + assert!( + annotation.is_present(&format!("@{name} public class Demo")), + "{name} should match its own annotation" + ); + assert!( + annotation.is_present(&format!("@{name}(\"value\")")), + "{name} should match when it carries arguments" + ); + assert!( + annotation.is_present(&format!("@{name}")), + "{name} should match at the end of a context" + ); + assert!( + !annotation.is_present(&format!("@{name}Extended public class Demo")), + "{name} must not match a longer annotation sharing its prefix" + ); + } + + let names = SpringAnnotation::ALL + .iter() + .map(|annotation| annotation.name()) + .collect::>(); + assert_eq!( + names.len(), + SpringAnnotation::ALL.len(), + "every case needs a distinct annotation name" + ); + } + + /// Component detection and component naming must recognize the same + /// annotations, so both read COMPONENTS instead of repeating the list. + #[test] + fn component_detection_and_naming_recognize_the_same_annotations() { + for annotation in SpringAnnotation::COMPONENTS { + let name = annotation.name(); + assert!( + has_component_annotation(&format!("@{name}\npublic class Demo")), + "{name} should be detected as a component annotation" + ); + assert_eq!( + component_name(&format!("@{name}(\"custom\")\npublic class Demo")).as_deref(), + Some("custom"), + "{name} should expose its declared bean name" + ); + } + + assert!(!has_component_annotation("@Bean\npublic Clock clock()")); + assert_eq!(component_name("@Qualifier(\"custom\")"), None); + } + + /// Record components and constructor parameters share one pattern, so a + /// declaration parsed by one path must be parsed identically by the other. + #[test] + fn record_components_and_constructor_parameters_share_one_declaration_pattern() { + let parameters = "@Qualifier(\"stripe\") final PaymentService payments"; + let fields = parse_record_components("Demo.java", 1, parameters, parameters); + let injections = parse_constructor_injections("Demo.java", 1, parameters, parameters); + + assert_eq!(fields.len(), 1); + assert_eq!(injections.len(), 1); + assert_eq!(fields[0].name, "payments"); + assert_eq!(fields[0].type_name, "PaymentService"); + assert_eq!(injections[0].type_name, "PaymentService"); + } +} diff --git a/rust/lithe-core/src/tests/spring.rs b/rust/lithe-core/src/tests/spring.rs index 592b6c3c8..3b5cc7e89 100644 --- a/rust/lithe-core/src/tests/spring.rs +++ b/rust/lithe-core/src/tests/spring.rs @@ -288,6 +288,65 @@ public class ApiController { fs::remove_dir_all(root).expect("Spring fixture should be removable"); } +/// Annotation detection matches `@Name` only when the name ends at whitespace, +/// an argument list, or the end of the context. Caching the compiled patterns +/// must not turn a prefix such as `@Bean` into a match for `@BeanFactory`. +#[test] +fn spring_index_does_not_treat_longer_annotations_as_recognized_ones() { + let root = temporary_root("spring-annotation-boundary"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("RealConfig.java"), + r#"package demo; +@Configuration +public class RealConfig { + @Bean + public Clock clock() { return null; } + @BeanFactory + public Clock decoyClock() { return null; } +} +"#, + ) + .expect("configuration fixture should be writable"); + fs::write( + java.join("DecoyService.java"), + "package demo;\n@ServiceLocator\npublic class DecoyService {}\n", + ) + .expect("decoy fixture should be writable"); + fs::write( + java.join("DecoyController.java"), + "package demo;\n@RestControllerAdvice\npublic class DecoyController {\n @GetMapping(\"/decoy\")\n public String decoy() { return \"\"; }\n}\n", + ) + .expect("decoy controller fixture should be writable"); + + let paths = [ + "src/main/java/demo/RealConfig.java", + "src/main/java/demo/DecoyService.java", + "src/main/java/demo/DecoyController.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(&"clock"), "{response}"); + assert!(names.contains(&"realConfig"), "{response}"); + assert!(!names.contains(&"decoyClock"), "{response}"); + assert!(!names.contains(&"decoyService"), "{response}"); + + // @RestControllerAdvice is not @RestController, so no route is collected. + assert!( + response["data"]["endpoints"].as_array().unwrap().is_empty(), + "{response}" + ); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + #[test] fn spring_dependency_metadata_cache_refresh_is_explicit() { let root = temporary_root("spring-metadata-cache");