From 2825eb7edd842ac43e889e7fc6e90302f64e6374 Mon Sep 17 00:00:00 2001 From: fenghp Date: Fri, 28 Aug 2026 16:56:41 +0800 Subject: [PATCH 1/4] perf(core): compile Spring index patterns once instead of per source line bean_index rebuilt regular expressions inside its per-line loop: five on every line through has_annotation, is_injection_context, and constructor_regex, plus seven more on each type declaration. Over a workspace with thousands of Java sources that reached roughly ten million compilations. Hold the fixed patterns in function-scoped LazyLock statics, cache the patterns for the annotations this module recognizes, and hoist the type-dependent constructor pattern out of the line loop. The scan is still full and the traversal order is unchanged. On a 7774-file Maven workspace this takes spring.index from 491.74s to 2.40s with a byte-identical response: same 3519348 bytes and SHA-256 across 16 properties, 327 values, 111 property references, 1870 diagnostics, 2338 beans, 4479 injections, and 4103 endpoints. Refs #299 --- rust/lithe-core/src/languages/spring.rs | 248 ++++++++++++++++-------- rust/lithe-core/src/tests/spring.rs | 59 ++++++ 2 files changed, 228 insertions(+), 79 deletions(-) diff --git a/rust/lithe-core/src/languages/spring.rs b/rust/lithe-core/src/languages/spring.rs index f5b773253..3cf8bd041 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; @@ -126,8 +126,11 @@ 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 annotation = &*ANNOTATION; let mut references = Vec::new(); for (path, source) in sources { for (index, line) in source.lines().enumerate() { @@ -391,10 +394,13 @@ 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 annotation = &*ANNOTATION; let mut types = HashMap::new(); for (path, source) in sources { for value in parse_configuration_types(path, source) { @@ -437,12 +443,18 @@ 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 declaration = &*DECLARATION; + let field = &*FIELD; let mut types = Vec::::new(); let mut stack = Vec::::new(); let mut depth = 0isize; @@ -499,10 +511,13 @@ 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(); + static COMPONENT: 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") + }); + let component = &*COMPONENT; split_parameters(components) .into_iter() .filter_map(|value| { @@ -913,16 +928,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 type_declaration = &*TYPE_DECLARATION; + let method = &*METHOD; + let field = &*FIELD; let mut supertypes = HashMap::>::new(); for (_, source) in sources { for capture in type_declaration.captures_iter(source) { @@ -943,11 +967,13 @@ fn bean_index( .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) { @@ -1014,26 +1040,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,10 +1162,44 @@ 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_regex(name: &str) -> Regex { Regex::new(&format!(r"@{}(?:\s|\(|$)", regex::escape(name))) - .unwrap() - .is_match(context) + .expect("an escaped annotation name is a valid pattern") +} + +/// `has_annotation` runs several times for every line of every Java source, so +/// the patterns for the annotations this module recognizes are compiled once. +fn cached_annotation_regex(name: &str) -> Option<&'static Regex> { + static PATTERNS: LazyLock> = LazyLock::new(|| { + [ + "Autowired", + "Bean", + "Component", + "Configuration", + "Controller", + "Inject", + "Primary", + "Repository", + "Resource", + "RestController", + "Service", + ] + .into_iter() + .map(|name| (name, annotation_regex(name))) + .collect() + }); + PATTERNS.get(name) +} + +fn has_annotation(context: &str, name: &str) -> bool { + match cached_annotation_regex(name) { + Some(pattern) => pattern.is_match(context), + // The cache lists today's callers. Compiling on demand keeps the helper + // correct if a caller starts recognizing another annotation. + None => annotation_regex(name).is_match(context), + } } fn has_component_annotation(context: &str) -> bool { @@ -1158,10 +1216,13 @@ fn has_component_annotation(context: &str) -> bool { } fn component_name(context: &str) -> Option { - let annotation = Regex::new( - r#"@(Component|Service|Repository|Controller|RestController|Configuration)\s*\([^\)]*[\"']([^\"']+)[\"']"#, - ) - .unwrap(); + static ANNOTATION: LazyLock = LazyLock::new(|| { + Regex::new( + r#"@(Component|Service|Repository|Controller|RestController|Configuration)\s*\([^\)]*[\"']([^\"']+)[\"']"#, + ) + .expect("literal pattern is valid") + }); + let annotation = &*ANNOTATION; annotation .captures(context) .and_then(|capture| capture.get(2)) @@ -1169,7 +1230,11 @@ fn component_name(context: &str) -> Option { } fn qualifier_names(context: &str) -> Vec { - let pattern = Regex::new(r#"@Qualifier\s*\(\s*[\"']([^\"']+)[\"']\s*\)"#).unwrap(); + static PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r#"@Qualifier\s*\(\s*[\"']([^\"']+)[\"']\s*\)"#) + .expect("literal pattern is valid") + }); + let pattern = &*PATTERN; pattern .captures_iter(context) .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) @@ -1186,7 +1251,9 @@ fn bean_names(context: &str) -> Vec { } fn quoted_values(value: &str) -> Vec { - let pattern = Regex::new(r#"[\"']([^\"']*)[\"']"#).unwrap(); + static PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r#"[\"']([^\"']*)[\"']"#).expect("literal pattern is valid")); + let pattern = &*PATTERN; pattern .captures_iter(value) .filter_map(|capture| capture.get(1).map(|item| item.as_str().to_string())) @@ -1194,13 +1261,18 @@ fn quoted_values(value: &str) -> Vec { } 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] @@ -1235,7 +1307,11 @@ fn is_injection_context(context: &str) -> bool { 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(); + static RESOURCE: LazyLock = LazyLock::new(|| { + Regex::new(r#"@Resource\s*\([^\)]*name\s*=\s*[\"']([^\"']+)[\"']"#) + .expect("literal pattern is valid") + }); + let resource = &*RESOURCE; resource .captures(context) .and_then(|capture| capture.get(1)) @@ -1257,14 +1333,16 @@ fn parse_constructor_injections( line: &str, parameters: &str, ) -> Vec { + static 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") + }); 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 = DECLARATION.captures(parameter.trim())?; let variable = capture.get(2)?; Some(RawInjection { path: path.to_string(), @@ -1299,8 +1377,15 @@ 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 class = &*CLASS; + let method = &*METHOD; let mut endpoints = Vec::new(); for (path, source) in sources { if !has_annotation(source, "Controller") && !has_annotation(source, "RestController") { @@ -1377,9 +1462,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 +1481,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()) 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"); From e8d8838cf7f4b75bbae0ead30e66b0cb00256e41 Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 09:35:37 +0800 Subject: [PATCH 2/4] =?UTF-8?q?refactor(core):=20=E7=94=A8=E5=B0=81?= =?UTF-8?q?=E9=97=AD=E7=9A=84=20SpringAnnotation=20=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E6=B3=A8=E8=A7=A3=E8=AF=86=E5=88=AB=E4=B8=8E?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E5=8F=82=E6=95=B0=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审指出两处会让性能修复无声回退或让解析行为分叉的设计。 注解缓存集合原先与各调用方独立维护,新增 has_annotation(context, "...") 而忘记 同步缓存时,功能和测试都照常通过,但那一处会退回逐次编译正则。改为封闭的 SpringAnnotation 类型:模式表由 ALL 构建,调用方只能引用具体的 case,即时编译的 fallback 已移除。component_name 的注解候选也改为从 COMPONENTS 生成,不再重复罗列。 parse_record_components 与 parse_constructor_injections 原先各自维护一份完全相同 的 Java 参数声明正则。合并为模块级的 JAVA_PARAMETER_DECLARATION。 补三个内联测试:每个受支持注解都有编译好的模式且保持前缀边界;组件识别与命名 认同同一组注解;两条参数解析路径对同一声明得到一致结果。 行为等价性重新验证:dev860/Backend/Service(7774 个 Java 文件)上 spring.index 的完整响应 SHA-256 与本 PR 之前和改动前三轮完全一致,仍为 3519348 字节。 Refs #299 --- rust/lithe-core/src/languages/spring.rs | 276 ++++++++++++++++++------ 1 file changed, 205 insertions(+), 71 deletions(-) diff --git a/rust/lithe-core/src/languages/spring.rs b/rust/lithe-core/src/languages/spring.rs index 3cf8bd041..df5a24eb0 100644 --- a/rust/lithe-core/src/languages/spring.rs +++ b/rust/lithe-core/src/languages/spring.rs @@ -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. @@ -511,17 +521,10 @@ fn parse_record_components( line: &str, components: &str, ) -> Vec { - static COMPONENT: 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") - }); - let component = &*COMPONENT; 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(), @@ -995,11 +998,11 @@ 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 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(); @@ -1023,7 +1026,7 @@ fn bean_index( }, names, assignable_types: assignable_types(&type_name, &supertypes), - primary: has_annotation(&context, "Primary"), + primary: SpringAnnotation::Primary.is_present(&context), }); } } @@ -1162,68 +1165,124 @@ fn annotation_context(lines: &[&str], index: usize) -> String { values.join(" ") } -/// Matches `@Name` only when the name is not a prefix of a longer annotation, -/// so `@Bean` does not match `@BeanFactory`. -fn annotation_regex(name: &str) -> Regex { - Regex::new(&format!(r"@{}(?:\s|\(|$)", regex::escape(name))) - .expect("an escaped annotation name is a valid pattern") +/// The Spring annotations this module recognizes. +/// +/// A closed type keeps the compiled pattern table and every call site in +/// agreement. Adding an annotation means adding a case, which the exhaustive +/// [`SpringAnnotation::name`] match forces the author to complete, so detection +/// can never silently fall back to compiling a pattern on every source line. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub(crate) enum SpringAnnotation { + Autowired, + Bean, + Component, + Configuration, + Controller, + Inject, + Primary, + Repository, + Resource, + RestController, + Service, } -/// `has_annotation` runs several times for every line of every Java source, so -/// the patterns for the annotations this module recognizes are compiled once. -fn cached_annotation_regex(name: &str) -> Option<&'static Regex> { - static PATTERNS: LazyLock> = LazyLock::new(|| { - [ - "Autowired", - "Bean", - "Component", - "Configuration", - "Controller", - "Inject", - "Primary", - "Repository", - "Resource", - "RestController", - "Service", - ] - .into_iter() - .map(|name| (name, annotation_regex(name))) - .collect() - }); - PATTERNS.get(name) -} +impl SpringAnnotation { + /// Every recognized annotation. The pattern table is built from this list. + pub(crate) const ALL: [Self; 11] = [ + Self::Autowired, + Self::Bean, + Self::Component, + Self::Configuration, + Self::Controller, + Self::Inject, + Self::Primary, + Self::Repository, + Self::Resource, + Self::RestController, + Self::Service, + ]; + + /// 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 name(self) -> &'static str { + match self { + Self::Autowired => "Autowired", + Self::Bean => "Bean", + Self::Component => "Component", + Self::Configuration => "Configuration", + Self::Controller => "Controller", + Self::Inject => "Inject", + Self::Primary => "Primary", + Self::Repository => "Repository", + Self::Resource => "Resource", + Self::RestController => "RestController", + Self::Service => "Service", + } + } -fn has_annotation(context: &str, name: &str) -> bool { - match cached_annotation_regex(name) { - Some(pattern) => pattern.is_match(context), - // The cache lists today's callers. Compiling on demand keeps the helper - // correct if a caller starts recognizing another annotation. - None => annotation_regex(name).is_match(context), + /// Matches `@Name` only when the name is not a prefix of a longer + /// annotation, so `@Bean` does not match `@BeanFactory`. + /// + /// Detection runs several times for every line of every Java source, so the + /// patterns are compiled once for the process. + pub(crate) fn pattern(self) -> &'static Regex { + static PATTERNS: LazyLock> = LazyLock::new(|| { + SpringAnnotation::ALL + .into_iter() + .map(|annotation| { + let pattern = Regex::new(&format!( + r"@{}(?:\s|\(|$)", + regex::escape(annotation.name()) + )) + .expect("an escaped annotation name is a valid pattern"); + (annotation, pattern) + }) + .collect() + }); + PATTERNS + .get(&self) + .expect("the table is built from ALL, which lists every case") + } + + 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 { + // Built from COMPONENTS so the recognized set cannot diverge from the one + // has_component_annotation uses. static ANNOTATION: LazyLock = LazyLock::new(|| { - Regex::new( - r#"@(Component|Service|Repository|Controller|RestController|Configuration)\s*\([^\)]*[\"']([^\"']+)[\"']"#, - ) - .expect("literal pattern is valid") + 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") }); - let annotation = &*ANNOTATION; - annotation + ANNOTATION .captures(context) .and_then(|capture| capture.get(2)) .map(|value| value.as_str().to_string()) @@ -1300,9 +1359,9 @@ 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 { @@ -1333,16 +1392,10 @@ fn parse_constructor_injections( line: &str, parameters: &str, ) -> Vec { - static 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") - }); split_parameters(parameters) .into_iter() .filter_map(|parameter| { - 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(), @@ -1388,7 +1441,9 @@ fn endpoint_index(sources: &[(String, String)]) -> Vec { let method = &*METHOD; 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 @@ -1571,3 +1626,82 @@ 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; + + /// The pattern table is built from `ALL`, so a case added to the type but + /// omitted from that list panics on first use instead of quietly compiling a + /// pattern on every source line. This covers the table and the boundary each + /// entry has to keep. + #[test] + fn every_supported_annotation_has_a_compiled_boundary_pattern() { + for annotation in SpringAnnotation::ALL { + 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"); + } +} From 9728693aba44b50ee15df9c445e29095b41caa8d Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 12:55:29 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(core):=20=E7=94=B1=E5=8D=95?= =?UTF-8?q?=E4=B8=80=E5=A3=B0=E6=98=8E=E7=94=9F=E6=88=90=20Spring=20?= =?UTF-8?q?=E6=B3=A8=E8=A7=A3=E7=B1=BB=E5=9E=8B=E3=80=81=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E4=B8=8E=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮仍然是两份名单:枚举和 ALL。加了新 case 但漏掉 ALL 时编译与测试都会过, 运行到 pattern() 才 panic。 改用 spring_annotations! 宏,从同一份 Variant => "Name" 声明生成枚举、name()、 ALL 与 pattern()。pattern() 对 self 做穷尽 match,各 case 返回自己的 LazyLock,不再查表,因此漏掉模式的 case 会被编译器拒绝,运行期也不再有 可 panic 的查表路径。ALL 现在只服务测试,标记为 cfg(test) 以免留下未使用的 生产代码。 Refs #299 --- rust/lithe-core/src/languages/spring.rs | 140 +++++++++++------------- 1 file changed, 62 insertions(+), 78 deletions(-) diff --git a/rust/lithe-core/src/languages/spring.rs b/rust/lithe-core/src/languages/spring.rs index df5a24eb0..4fe37bbd9 100644 --- a/rust/lithe-core/src/languages/spring.rs +++ b/rust/lithe-core/src/languages/spring.rs @@ -1165,43 +1165,68 @@ fn annotation_context(lines: &[&str], index: usize) -> String { values.join(" ") } -/// The Spring annotations this module recognizes. +/// 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))) + .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. /// -/// A closed type keeps the compiled pattern table and every call site in -/// agreement. Adding an annotation means adding a case, which the exhaustive -/// [`SpringAnnotation::name`] match forces the author to complete, so detection -/// can never silently fall back to compiling a pattern on every source line. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] -pub(crate) enum SpringAnnotation { - Autowired, - Bean, - Component, - Configuration, - Controller, - Inject, - Primary, - Repository, - Resource, - RestController, - Service, +/// 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, PartialEq, Eq, Hash, Debug)] + 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 + })+ + } + } + } + }; } -impl SpringAnnotation { - /// Every recognized annotation. The pattern table is built from this list. - pub(crate) const ALL: [Self; 11] = [ - Self::Autowired, - Self::Bean, - Self::Component, - Self::Configuration, - Self::Controller, - Self::Inject, - Self::Primary, - Self::Repository, - Self::Resource, - Self::RestController, - Self::Service, - ]; +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. @@ -1217,46 +1242,6 @@ impl SpringAnnotation { /// Annotations that mark a field or constructor parameter for injection. const INJECTIONS: [Self; 3] = [Self::Autowired, Self::Inject, Self::Resource]; - pub(crate) fn name(self) -> &'static str { - match self { - Self::Autowired => "Autowired", - Self::Bean => "Bean", - Self::Component => "Component", - Self::Configuration => "Configuration", - Self::Controller => "Controller", - Self::Inject => "Inject", - Self::Primary => "Primary", - Self::Repository => "Repository", - Self::Resource => "Resource", - Self::RestController => "RestController", - Self::Service => "Service", - } - } - - /// Matches `@Name` only when the name is not a prefix of a longer - /// annotation, so `@Bean` does not match `@BeanFactory`. - /// - /// Detection runs several times for every line of every Java source, so the - /// patterns are compiled once for the process. - pub(crate) fn pattern(self) -> &'static Regex { - static PATTERNS: LazyLock> = LazyLock::new(|| { - SpringAnnotation::ALL - .into_iter() - .map(|annotation| { - let pattern = Regex::new(&format!( - r"@{}(?:\s|\(|$)", - regex::escape(annotation.name()) - )) - .expect("an escaped annotation name is a valid pattern"); - (annotation, pattern) - }) - .collect() - }); - PATTERNS - .get(&self) - .expect("the table is built from ALL, which lists every case") - } - pub(crate) fn is_present(self, context: &str) -> bool { self.pattern().is_match(context) } @@ -1632,13 +1617,12 @@ mod tests { use super::*; use std::collections::HashSet; - /// The pattern table is built from `ALL`, so a case added to the type but - /// omitted from that list panics on first use instead of quietly compiling a - /// pattern on every source line. This covers the table and the boundary each - /// entry has to keep. + /// `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 { + for annotation in SpringAnnotation::ALL.iter().copied() { let name = annotation.name(); assert!( annotation.is_present(&format!("@{name} public class Demo")), From fe5691704d27df2564be911456b1f48d029d3796 Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 14:28:43 +0800 Subject: [PATCH 4/4] =?UTF-8?q?refactor(core):=20=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=BC=96=E8=AF=91=E5=A5=BD=E7=9A=84=E9=9D=99?= =?UTF-8?q?=E6=80=81=E6=A8=A1=E5=BC=8F=E5=B9=B6=E6=94=B6=E6=95=9B=E6=B3=A8?= =?UTF-8?q?=E8=A7=A3=E7=B1=BB=E5=9E=8B=E7=9A=84=20derive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自查发现两处收尾不干净。 上一版为压小 diff 保留了 12 行 let x = &*STATIC 中转,但同一文件里 JAVA_PARAMETER_DECLARATION 与 component_name 已经直接使用静态名,形成了两种 并存的写法。改为一律直接使用,中转行全部删除。 SpringAnnotation 的 PartialEq、Eq、Hash 是上一版 HashMap 查表的残留,改成穷尽 match 后已无消费方,Debug 也没有格式化点在用。收敛为 Clone、Copy。 行为等价性重新验证:dev860/Backend/Service 上 spring.index 的完整响应 SHA-256 与最初基线仍然一致(3519348 字节)。重命名涉及 field、method、class、pattern 等常见标识符,字节比对是确认没有误伤的依据。 Refs #299 --- rust/lithe-core/src/languages/spring.rs | 44 +++++++++---------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/rust/lithe-core/src/languages/spring.rs b/rust/lithe-core/src/languages/spring.rs index 4fe37bbd9..1c974f38a 100644 --- a/rust/lithe-core/src/languages/spring.rs +++ b/rust/lithe-core/src/languages/spring.rs @@ -140,11 +140,10 @@ fn property_reference_index(sources: &[(String, String)]) -> Vec Vec ) .expect("literal pattern is valid") }); - let declaration = &*DECLARATION; - let field = &*FIELD; 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 { @@ -489,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(), @@ -947,12 +943,9 @@ fn bean_index( ) .expect("literal pattern is valid") }); - let type_declaration = &*TYPE_DECLARATION; - let method = &*METHOD; - let field = &*FIELD; 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) @@ -966,7 +959,7 @@ 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()); @@ -979,7 +972,7 @@ fn bean_index( .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()); @@ -1003,7 +996,7 @@ fn bean_index( } } if SpringAnnotation::Bean.is_present(&context) { - if let Some(capture) = method.captures(line) { + 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); @@ -1031,7 +1024,7 @@ fn bean_index( } } 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 { @@ -1182,7 +1175,7 @@ fn annotation_boundary_pattern(name: &str) -> Regex { /// a case that was added without a pattern. macro_rules! spring_annotations { ($($variant:ident => $name:literal),+ $(,)?) => { - #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] + #[derive(Clone, Copy)] pub(crate) enum SpringAnnotation { $($variant),+ } @@ -1278,8 +1271,7 @@ fn qualifier_names(context: &str) -> Vec { Regex::new(r#"@Qualifier\s*\(\s*[\"']([^\"']+)[\"']\s*\)"#) .expect("literal pattern is valid") }); - let pattern = &*PATTERN; - pattern + PATTERN .captures_iter(context) .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) .collect() @@ -1297,8 +1289,7 @@ fn bean_names(context: &str) -> Vec { fn quoted_values(value: &str) -> Vec { static PATTERN: LazyLock = LazyLock::new(|| Regex::new(r#"[\"']([^\"']*)[\"']"#).expect("literal pattern is valid")); - let pattern = &*PATTERN; - pattern + PATTERN .captures_iter(value) .filter_map(|capture| capture.get(1).map(|item| item.as_str().to_string())) .collect() @@ -1355,8 +1346,7 @@ fn injection_qualifier(context: &str) -> Option { Regex::new(r#"@Resource\s*\([^\)]*name\s*=\s*[\"']([^\"']+)[\"']"#) .expect("literal pattern is valid") }); - let resource = &*RESOURCE; - resource + RESOURCE .captures(context) .and_then(|capture| capture.get(1)) .map(|value| value.as_str().to_string()) @@ -1422,8 +1412,6 @@ fn endpoint_index(sources: &[(String, String)]) -> Vec { Regex::new(r"[A-Za-z0-9_$.<>?]+\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(") .expect("literal pattern is valid") }); - let class = &*CLASS; - let method = &*METHOD; let mut endpoints = Vec::new(); for (path, source) in sources { if !SpringAnnotation::Controller.is_present(source) @@ -1431,7 +1419,7 @@ fn endpoint_index(sources: &[(String, String)]) -> Vec { { continue; } - let controller = class + let controller = CLASS .captures(source) .and_then(|capture| capture.get(1)) .map(|value| value.as_str().to_string()) @@ -1453,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())