001/**
002 * Copyright (C) 2006-2024 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}