001/** 002 * Copyright (C) 2006-2025 Talend Inc. - www.talend.com 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.talend.sdk.component.tools.validator; 017 018import static java.util.Arrays.asList; 019import static java.util.Optional.ofNullable; 020import static java.util.stream.Collectors.toList; 021import static java.util.stream.Stream.of; 022 023import java.io.BufferedWriter; 024import java.io.File; 025import java.io.FileWriter; 026import java.io.IOException; 027import java.lang.annotation.Annotation; 028import java.lang.invoke.MethodType; 029import java.lang.reflect.Field; 030import java.lang.reflect.InvocationTargetException; 031import java.lang.reflect.Modifier; 032import java.nio.file.Files; 033import java.nio.file.Path; 034import java.nio.file.Paths; 035import java.time.ZonedDateTime; 036import java.util.ArrayList; 037import java.util.Arrays; 038import java.util.Collection; 039import java.util.Collections; 040import java.util.Date; 041import java.util.List; 042import java.util.Map; 043import java.util.Objects; 044import java.util.ResourceBundle; 045import java.util.stream.Collectors; 046import java.util.stream.Stream; 047 048import org.apache.xbean.finder.AnnotationFinder; 049import org.talend.sdk.component.api.configuration.Option; 050import org.talend.sdk.component.api.internationalization.Internationalized; 051import org.talend.sdk.component.api.service.ActionType; 052import org.talend.sdk.component.api.service.asyncvalidation.AsyncValidation; 053import org.talend.sdk.component.api.service.completion.DynamicValues; 054import org.talend.sdk.component.api.service.completion.Suggestions; 055import org.talend.sdk.component.api.service.healthcheck.HealthCheck; 056import org.talend.sdk.component.api.service.schema.DiscoverSchema; 057import org.talend.sdk.component.api.service.update.Update; 058import org.talend.sdk.component.tools.ComponentHelper; 059import org.talend.sdk.component.tools.validator.Validators.ValidatorHelper; 060 061import lombok.Data; 062import lombok.extern.slf4j.Slf4j; 063 064@Slf4j 065public class InternationalizationValidator implements Validator { 066 067 private final Validators.ValidatorHelper helper; 068 069 private final File sourceRoot; 070 071 private final boolean validatePlaceholder; 072 073 private final boolean autofix; 074 075 public InternationalizationValidator(final ValidatorHelper helper, final File sourceRoot, 076 final boolean validatePlaceholder, final boolean autofix) { 077 this.helper = helper; 078 this.sourceRoot = sourceRoot; 079 this.validatePlaceholder = validatePlaceholder; 080 this.autofix = autofix; 081 } 082 083 @Override 084 public Stream<String> validate(final AnnotationFinder finder, final List<Class<?>> components) { 085 final Stream<String> bundlesError = components 086 .stream() // 087 .map(this::validateComponentResourceBundle) // 088 .filter(Objects::nonNull) // 089 .sorted(); 090 091 List<Fix> toFix = new ArrayList<>(); 092 // enum 093 final List<Field> optionsFields = finder.findAnnotatedFields(Option.class); 094 final Stream<String> missingDisplayNameEnum = optionsFields 095 .stream() 096 .map(Field::getType) // 097 .filter(Class::isEnum) // 098 .distinct() // 099 .flatMap(enumType -> of(enumType.getFields()) // 100 .filter(f -> Modifier.isStatic(f.getModifiers()) && Modifier.isFinal(f.getModifiers())) // 101 .filter(f -> hasNoBundleEntry(enumType, f, "_displayName")) // 102 .peek(f -> { 103 if (this.autofix) { 104 toFix.add(new Fix(f, "._displayName", sourceRoot, true)); 105 } 106 }) 107 .map(f -> "Missing key " + enumType.getSimpleName() + "." + f.getName() 108 + "._displayName in " + enumType + " resource bundle")) // 109 .sorted(); 110 111 // others - just logged for now, we can add it to errors if we encounter it too often 112 final List<String> missingOptionTranslations = Stream.concat( 113 optionsFields 114 .stream() 115 .distinct() 116 .filter(this::fieldIsWithoutKey) 117 .peek(f -> { 118 if (this.autofix) { 119 toFix.add(new Fix(f, "._displayName", sourceRoot, true)); 120 } 121 }) 122 .map(f -> " " + f.getDeclaringClass().getSimpleName() + "." + f.getName() + "._displayName = <" 123 + f.getName() + ">") 124 .sorted() 125 .distinct(), 126 missingDisplayNameEnum) 127 .collect(Collectors.toList()); 128 129 List<String> missingPlaceholderTranslations = Collections.emptyList(); 130 if (this.validatePlaceholder) { 131 missingPlaceholderTranslations = optionsFields 132 .stream() 133 .distinct() 134 .filter(e -> this.fieldIsWithoutKey(e, 135 Arrays.asList(String.class, Character.class, Integer.class, Double.class, Long.class, 136 Float.class, Date.class, ZonedDateTime.class), 137 "._placeholder")) 138 .peek(f -> { 139 if (this.autofix) { 140 toFix.add(new Fix(f, "._placeholder", this.sourceRoot, false)); 141 } 142 }) 143 .map(f -> " " + f.getDeclaringClass().getSimpleName() + "." + f.getName() + "._placeholder = ") 144 .sorted() 145 .distinct() 146 .collect(Collectors.toList()); 147 } 148 149 if (this.autofix && !toFix.isEmpty()) { 150 this.fixLocales(toFix); 151 } 152 153 final Stream<String> missingDisplayName; 154 if (missingOptionTranslations != null && !missingOptionTranslations.isEmpty() && !this.autofix) { 155 final String missingMsg = missingOptionTranslations 156 .stream() 157 .collect(Collectors.joining("\n", "Missing _displayName resource bundle entries:\n", "")); 158 missingDisplayName = Stream.of(missingMsg); 159 } else { 160 missingDisplayName = Stream.empty(); 161 } 162 163 final Stream<String> missingPlaceholder; 164 if (missingPlaceholderTranslations != null && !missingPlaceholderTranslations.isEmpty() & !this.autofix) { 165 final String missingMsg = missingPlaceholderTranslations 166 .stream() 167 .collect(Collectors.joining("\n", "Missing _placeholder resource bundle entries:\n", "")); 168 missingPlaceholder = Stream.of(missingMsg); 169 } else { 170 missingPlaceholder = Stream.empty(); 171 } 172 173 final List<String> internationalizedErrors = new ArrayList<>(); 174 for (final Class<?> i : finder.findAnnotatedClasses(Internationalized.class)) { 175 final ResourceBundle resourceBundle = helper.findResourceBundle(i); 176 if (resourceBundle != null) { 177 final Collection<Collection<String>> keys = of(i.getMethods()) 178 .filter(m -> m.getDeclaringClass() != Object.class) 179 .map(m -> asList(i.getName() + "." + m.getName(), i.getSimpleName() + "." + m.getName())) 180 .collect(Collectors.toSet()); 181 keys 182 .stream() 183 .filter(ks -> ks.stream().noneMatch(resourceBundle::containsKey)) 184 .map(k -> "Missing key " + k.iterator().next() + " in " + i + " resource bundle") 185 .sorted() 186 .forEach(internationalizedErrors::add); 187 188 resourceBundle 189 .keySet() 190 .stream() 191 .filter(k -> (k.startsWith(i.getName() + ".") || k.startsWith(i.getSimpleName() + ".")) 192 && keys.stream().noneMatch(ks -> ks.contains(k))) 193 .map(k -> "Key " + k + " from " + i + " is no more used") 194 .sorted() 195 .forEach(internationalizedErrors::add); 196 } else { 197 internationalizedErrors.add("No resource bundle for " + i); 198 } 199 } 200 Stream<String> actionsErrors = this.missingActionComment(finder); 201 202 Stream<String> result = Stream 203 .of(bundlesError, missingDisplayName, missingPlaceholder, 204 internationalizedErrors.stream(), actionsErrors) 205 .reduce(Stream::concat) 206 .orElseGet(Stream::empty); 207 208 if (this.autofix) { 209 List<String> forLogs = result.collect(toList()); 210 String resultAutoFix = forLogs.stream() 211 .collect(Collectors.joining("\n", "Automatically fixed missing labels:\n", 212 "\n\nPlease, check changes and disable '-Dtalend.validation.internationalization.autofix=false' / " 213 + "'<validateInternationalizationAutoFix>false</>'property.\n\n")); 214 log.info(resultAutoFix); 215 216 result = forLogs.stream(); 217 } 218 219 return result; 220 } 221 222 private Stream<String> missingActionComment(final AnnotationFinder finder) { 223 return this 224 .getActionsStream() // Annotation of ActionType 225 .flatMap(action -> finder.findAnnotatedMethods(action).stream()) // 226 .map(action -> { 227 final Annotation actionAnnotation = Stream 228 .of(action.getAnnotations()) 229 .filter(a -> a.annotationType().isAnnotationPresent(ActionType.class)) 230 .findFirst() 231 .orElseThrow(() -> new IllegalArgumentException("No action annotation on " + action)); 232 final String key; 233 try { 234 final Class<? extends Annotation> annotationType = actionAnnotation.annotationType(); 235 key = "${family}.actions." + annotationType.getAnnotation(ActionType.class).value() + "." 236 + annotationType.getMethod("value").invoke(actionAnnotation).toString() 237 + "._displayName"; 238 } catch (final IllegalAccessException | NoSuchMethodException | InvocationTargetException e) { 239 return null; 240 } 241 return helper.validateFamilyI18nKey(action.getDeclaringClass(), key); 242 }) 243 .filter(Objects::nonNull); 244 } 245 246 private Stream<Class<? extends Annotation>> getActionsStream() { 247 return of(AsyncValidation.class, DynamicValues.class, HealthCheck.class, DiscoverSchema.class, 248 Suggestions.class, Update.class); 249 } 250 251 private String validateComponentResourceBundle(final Class<?> component) { 252 final String baseName = ofNullable(component.getPackage()).map(p -> p.getName() + ".").orElse("") + "Messages"; 253 final ResourceBundle bundle = helper.findResourceBundle(component); 254 if (bundle == null) { 255 return "No resource bundle for " + component.getName() + ", you should create a " 256 + baseName.replace('.', '/') + ".properties at least."; 257 } 258 259 final String prefix = this.findPrefix(component); 260 final Collection<String> missingKeys = 261 of("_displayName").map(n -> prefix + "." + n).filter(k -> !bundle.containsKey(k)).collect(toList()); 262 if (!missingKeys.isEmpty()) { 263 return baseName + " is missing the key(s): " + String.join("\n", missingKeys); 264 } 265 return null; 266 } 267 268 private String findPrefix(final Class<?> component) { 269 return ComponentHelper 270 .components(component) 271 .map(c -> ComponentHelper.findFamily(c, component) + "." + c.name()) 272 .orElseThrow(() -> new IllegalStateException(component.getName())); 273 } 274 275 private boolean hasNoBundleEntry(final Class<?> enumType, final Field f, final String keyName) { 276 final ResourceBundle bundle = findBundleFor(enumType, f); 277 final String key = enumType.getSimpleName() + "." + f.getName() + "." + keyName; 278 return bundle == null || !bundle.containsKey(key); 279 } 280 281 private ResourceBundle findBundleFor(final Class<?> enumType, final Field f) { 282 return ofNullable(this.helper.findResourceBundle(enumType)) 283 .orElseGet(() -> this.helper.findResourceBundle(f.getDeclaringClass())); 284 } 285 286 private boolean fieldIsWithoutKey(final Field field) { 287 return this.fieldIsWithoutKey(field, Collections.emptyList(), "._displayName"); 288 } 289 290 private boolean fieldIsWithoutKey(final Field field, final List<Class> types, final String suffix) { 291 Class<?> tmpFieldType = field.getType(); 292 if (tmpFieldType.isPrimitive()) { 293 tmpFieldType = MethodType.methodType(tmpFieldType).wrap().returnType(); 294 } 295 final Class fieldType = tmpFieldType; 296 if (!types.isEmpty() && !types.contains(tmpFieldType)) { 297 return false; 298 } 299 final ResourceBundle bundle = ofNullable(helper.findResourceBundle(field.getDeclaringClass())) 300 .orElseGet(() -> helper.findResourceBundle(fieldType)); 301 final String key = field.getDeclaringClass().getSimpleName() + "." + field.getName() + suffix; 302 return bundle == null || !bundle.containsKey(key); 303 } 304 305 private void fixLocales(final List<Fix> toFix) { 306 Map<Path, List<Fix>> fixByPath = toFix.stream().collect(Collectors.groupingBy(Fix::getDestinationFile)); 307 for (Path p : fixByPath.keySet()) { 308 try { 309 Files.createDirectories(p.getParent()); 310 if (!Files.exists(p)) { 311 Files.createFile(p); 312 } 313 } catch (IOException e) { 314 throw new RuntimeException(String.format("Can't create resource file '%s' : %s", p, e.getMessage()), e); 315 } 316 317 List<Fix> fixes = fixByPath.get(p); 318 try (BufferedWriter writer = new BufferedWriter(new FileWriter(p.toFile(), true))) { 319 for (Fix f : fixes) { 320 writer.newLine(); 321 writer.write(f.key); 322 } 323 } catch (IOException e) { 324 throw new RuntimeException(String.format("Can't fix internationalization file: '%s'", p), e); 325 } 326 327 } 328 } 329 330 @Data 331 private static class Fix { 332 333 private final String key; 334 335 private final Path destinationFile; 336 337 public Fix(final Field field, final String suffix, final File sourceRoot, final boolean defaultValue) { 338 this.key = computeKey(field, suffix, defaultValue); 339 this.destinationFile = computeDestinationFile(field, sourceRoot); 340 } 341 342 private String computeKey(final Field field, final String suffix, final boolean defaultValue) { 343 String s = field.getDeclaringClass().getSimpleName() + "." + field.getName() + suffix + " = "; 344 if (defaultValue) { 345 s += "<" + field.getName() + ">"; 346 } 347 348 return s; 349 } 350 351 private Path computeDestinationFile(final Field field, final File sourceRoot) { 352 String packageName = field.getDeclaringClass().getPackage().getName(); 353 Path path = Paths.get(sourceRoot.getAbsolutePath()) 354 .resolve("src") 355 .resolve("main") 356 .resolve("resources") 357 .resolve(Paths.get(packageName.replaceAll("\\.", "/"))) 358 .resolve("Messages.properties"); 359 return path; 360 } 361 } 362 363}