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;
017
018import static java.util.Arrays.asList;
019import static java.util.Collections.emptyList;
020import static java.util.Comparator.comparing;
021import static java.util.Optional.ofNullable;
022import static java.util.stream.Collectors.joining;
023import static java.util.stream.Collectors.toList;
024import static lombok.AccessLevel.PRIVATE;
025
026import java.io.BufferedReader;
027import java.io.BufferedWriter;
028import java.io.File;
029import java.io.FileWriter;
030import java.io.IOException;
031import java.io.InputStream;
032import java.io.InputStreamReader;
033import java.io.Writer;
034import java.lang.reflect.Array;
035import java.nio.charset.StandardCharsets;
036import java.util.ArrayList;
037import java.util.Arrays;
038import java.util.Collection;
039import java.util.Collections;
040import java.util.HashMap;
041import java.util.HashSet;
042import java.util.List;
043import java.util.Locale;
044import java.util.Map;
045import java.util.NoSuchElementException;
046import java.util.Set;
047import java.util.SortedSet;
048import java.util.StringTokenizer;
049import java.util.TreeSet;
050import java.util.stream.Collectors;
051import java.util.stream.Stream;
052
053import org.apache.xbean.finder.AnnotationFinder;
054import org.talend.sdk.component.api.meta.Documentation;
055import org.talend.sdk.component.runtime.internationalization.ParameterBundle;
056import org.talend.sdk.component.runtime.manager.ParameterMeta;
057import org.talend.sdk.component.runtime.manager.reflect.Constructors;
058import org.talend.sdk.component.runtime.manager.reflect.ParameterModelService;
059import org.talend.sdk.component.runtime.manager.reflect.parameterenricher.BaseParameterEnricher;
060import org.talend.sdk.component.runtime.manager.reflect.parameterenricher.ConditionParameterEnricher;
061import org.talend.sdk.component.runtime.manager.reflect.parameterenricher.ConfigurationTypeParameterEnricher;
062import org.talend.sdk.component.runtime.manager.reflect.parameterenricher.DocumentationParameterEnricher;
063import org.talend.sdk.component.runtime.manager.reflect.parameterenricher.UiParameterEnricher;
064import org.talend.sdk.component.runtime.manager.service.LocalConfigurationService;
065import org.talend.sdk.component.runtime.manager.util.DefaultValueInspector;
066import org.talend.sdk.component.runtime.manager.xbean.registry.EnrichedPropertyEditorRegistry;
067import org.talend.sdk.component.tools.ComponentHelper.Component;
068
069import lombok.AllArgsConstructor;
070import lombok.Data;
071import lombok.Getter;
072import lombok.RequiredArgsConstructor;
073
074public abstract class DocBaseGenerator extends BaseTask {
075
076    private final DefaultValueInspector defaultValueInspector = new DefaultValueInspector();
077
078    @Getter
079    private final Locale locale;
080
081    private final AbsolutePathResolver resolver = new AbsolutePathResolver();
082
083    protected final Log log;
084
085    protected final File output;
086
087    private final ParameterModelService parameterModelService = new ParameterModelService(
088            asList(new DocumentationParameterEnricher(), new ConditionParameterEnricher(),
089                    new ConfigurationTypeParameterEnricher(), new UiParameterEnricher()),
090            new EnrichedPropertyEditorRegistry()) {
091    };
092
093    DocBaseGenerator(final File[] classes, final Locale locale, final Object log, final File output) {
094        super(classes);
095        this.locale = locale;
096        this.output = output;
097        try {
098            this.log = Log.class.isInstance(log) ? Log.class.cast(log) : new ReflectiveLog(log);
099        } catch (final NoSuchMethodException e) {
100            throw new IllegalArgumentException(e);
101        }
102    }
103
104    @Override
105    public final void run() {
106        final Locale oldLocale = Locale.getDefault();
107        final boolean shouldSwitchLocale =
108                oldLocale != Locale.ROOT && !ofNullable(oldLocale.getLanguage()).orElse("").equals("en");
109        if (shouldSwitchLocale) {
110            Locale.setDefault(Locale.ROOT);
111        }
112        try {
113            doRun();
114        } finally {
115            if (shouldSwitchLocale) {
116                Locale.setDefault(oldLocale);
117            }
118        }
119    }
120
121    protected abstract void doRun();
122
123    protected Stream<ComponentDescription> components() {
124        final AnnotationFinder finder = newFinder();
125        return findComponents(finder).map(component -> {
126            final Collection<ParameterMeta> parameterMetas = parameterModelService
127                    .buildParameterMetas(Constructors.findConstructor(component),
128                            ofNullable(component.getPackage()).map(Package::getName).orElse(""),
129                            new BaseParameterEnricher.Context(new LocalConfigurationService(emptyList(), "tools")));
130            final Component componentMeta = ComponentHelper
131                    .componentMarkers()
132                    .filter(component::isAnnotationPresent)
133                    .map(component::getAnnotation)
134                    .map(ComponentHelper::asComponent)
135                    .findFirst()
136                    .orElseThrow(NoSuchElementException::new);
137            String family = "";
138            try {
139                family = ComponentHelper.findFamily(componentMeta, component);
140            } catch (final IllegalArgumentException iae) {
141                // skip for doc
142            }
143            return new ComponentDescription(component, family, componentMeta.name(), getDoc(component),
144                    sort(parameterMetas), resolver, defaultValueInspector, locale, emptyDefaultValue());
145        });
146    }
147
148    protected Stream<Class<?>> findComponents(final AnnotationFinder finder) {
149        return ComponentHelper.componentMarkers().flatMap(a -> finder.findAnnotatedClasses(a).stream());
150    }
151
152    private String getDoc(final Class<?> component) {
153        final Collection<String> docKeys = Stream
154                .of(getComponentPrefix(component), component.getSimpleName())
155                .map(it -> it + "._documentation")
156                .collect(toList());
157        return ofNullable(findResourceBundle(component))
158                .map(bundle -> docKeys
159                        .stream()
160                        .filter(bundle::containsKey)
161                        .map(bundle::getString)
162                        .findFirst()
163                        .map(v -> v + "\n\n")
164                        .orElse(null))
165                .orElseGet(() -> ofNullable(component.getAnnotation(Documentation.class))
166                        .map(Documentation::value)
167                        .map(v -> v + "\n\n")
168                        .orElse(""));
169    }
170
171    private String getComponentPrefix(final Class<?> component) {
172        try {
173            return ComponentHelper
174                    .components(component)
175                    .map(c -> ComponentHelper.findFamily(c, component) + "." + c.name())
176                    .orElseGet(component::getSimpleName);
177        } catch (final RuntimeException e) {
178            return component.getSimpleName();
179        }
180    }
181
182    private Collection<ParameterMeta> sort(final Collection<ParameterMeta> parameterMetas) {
183        return parameterMetas.stream().sorted(comparing(ParameterMeta::getPath)).collect(toList());
184    }
185
186    protected void write(final File output, final String content) {
187        ensureParentExists(output);
188        try (final Writer writer = new BufferedWriter(new FileWriter(output))) {
189            writer.write(content);
190        } catch (IOException e) {
191            throw new IllegalStateException(e);
192        }
193    }
194
195    protected void ensureParentExists(final File output) {
196        if (!output.getParentFile().isDirectory() && !output.getParentFile().mkdirs()) {
197            throw new IllegalStateException("Can't create " + output.getParentFile());
198        }
199    }
200
201    protected String emptyDefaultValue() {
202        return "-";
203    }
204
205    private static class AbsolutePathResolver {
206
207        // ensure it is aligned with org.talend.sdk.component.studio.model.parameter.SettingsCreator.computeTargetPath()
208        // and org.talend.sdk.component.form.internal.converter.impl.widget.path.AbsolutePathResolver
209        public String resolveProperty(final String propPath, final String paramRef) {
210            return doResolveProperty(propPath, normalizeParamRef(paramRef));
211        }
212
213        private String normalizeParamRef(final String paramRef) {
214            return (!paramRef.contains(".") ? "../" : "") + paramRef;
215        }
216
217        private String doResolveProperty(final String propPath, final String paramRef) {
218            if (".".equals(paramRef)) {
219                return propPath;
220            }
221            if (paramRef.startsWith("..")) {
222                String current = propPath;
223                String ref = paramRef;
224                while (ref.startsWith("..")) {
225                    int lastDot = current.lastIndexOf('.');
226                    if (lastDot < 0) {
227                        lastDot = 0;
228                    }
229                    current = current.substring(0, lastDot);
230                    ref = ref.substring("..".length(), ref.length());
231                    if (ref.startsWith("/")) {
232                        ref = ref.substring(1);
233                    }
234                    if (current.isEmpty()) {
235                        break;
236                    }
237                }
238                return Stream.of(current, ref.replace('/', '.')).filter(it -> !it.isEmpty()).collect(joining("."));
239            }
240            if (paramRef.startsWith(".") || paramRef.startsWith("./")) {
241                return propPath + '.' + paramRef.replaceFirst("\\./?", "").replace('/', '.');
242            }
243            return paramRef;
244        }
245    }
246
247    @Data
248    @AllArgsConstructor(access = PRIVATE)
249    protected static class Condition {
250
251        private final String target;
252
253        private final String path;
254
255        private final String value;
256
257        private final boolean negate;
258
259        private final String strategy;
260    }
261
262    @Data
263    protected static class UIInfo {
264
265        private final String section;
266
267        private final Set<String> nestedLayout = new HashSet<>();
268
269        public UIInfo(final String section, final Collection<String> layouts) {
270            this.section = section;
271            nestedLayout.addAll(layouts);
272        }
273
274        public void addNestedLayouts(final Collection<String> layouts) {
275            nestedLayout.addAll(layouts);
276        }
277    }
278
279    @Data
280    @RequiredArgsConstructor(access = PRIVATE)
281    protected static class Param implements Comparable<Param> {
282
283        private final String displayName;
284
285        private final String documentation;
286
287        private final String defaultValue;
288
289        private final String type;
290
291        private final String fullPath;
292
293        private final Conditions conditions;
294
295        private final String section;
296
297        private UIInfo uiInfo;
298
299        private final SortedSet<Param> nested = new TreeSet<>();
300
301        public void addNested(final Param p) {
302            nested.add(p);
303        }
304
305        public int compareTo(final Param p) {
306            return this.getDisplayName().compareTo(p.getDisplayName());
307        }
308
309        public boolean isComplex() {
310            return this.nested.size() > 0;
311        }
312
313        public boolean isSection() {
314            return isComplex() && section != null && !section.isEmpty();
315        }
316
317        public String getSectionName() {
318            return "datastore".equals(section) ? "connection" : section;
319        }
320
321    }
322
323    @Data
324    @AllArgsConstructor(access = PRIVATE)
325    public static class Conditions {
326
327        private final String path;
328
329        private final String operator;
330
331        private final Collection<Condition> conditions;
332    }
333
334    @Data
335    @AllArgsConstructor(access = PRIVATE)
336    protected static class ComponentDescription {
337
338        private final Class<?> type;
339
340        private final String family;
341
342        private final String name;
343
344        private final String documentation;
345
346        private final Collection<ParameterMeta> parameters;
347
348        private final AbsolutePathResolver resolver;
349
350        private final DefaultValueInspector defaultValueInspector;
351
352        private final Locale locale;
353
354        private final String emptyDefaultValue;
355
356        private Map<String, UIInfo> getUIParamByPath(final Collection<ParameterMeta> parameters) {
357            Map<String, UIInfo> uiparams = new HashMap<>();
358            recurseUIParam(parameters, uiparams, "", null, null);
359
360            return uiparams;
361        }
362
363        private Set<String> recurseUIParam(final Collection<ParameterMeta> params, final Map<String, UIInfo> uiparams,
364                final String currentSectionName, final ParameterMeta parent, final Collection<String> parentLayouts) {
365            Set<String> layouts = new HashSet<>();
366            for (ParameterMeta param : params) {
367                String path = param.getPath();
368                String sectionType = param.getMetadata().get("tcomp::configurationtype::type");
369                if (sectionType == null) {
370                    sectionType = currentSectionName;
371                }
372                // String layout = getLayout(parent, layoutParent, param);
373                Collection<String> paramLayouts = getPropertiesByLayout(parent, param.getName(), parentLayouts);
374                layouts.addAll(paramLayouts);
375
376                UIInfo uiInfo = new UIInfo(currentSectionName, paramLayouts);
377                uiparams.put(path, uiInfo);
378                if (param.getNestedParameters().size() > 0) {
379                    Set<String> subLayouts =
380                            recurseUIParam(param.getNestedParameters(), uiparams, sectionType, param, paramLayouts);
381                    // uiInfo.addNestedLayouts(subLayouts);
382                }
383            }
384
385            return layouts;
386        }
387
388        private Collection<String> getPropertiesByLayout(final ParameterMeta parent, final String param,
389                final Collection<String> parentLayouts) {
390            final Collection<String> layouts = new TreeSet<>();
391
392            if (parent == null) {
393                return Arrays.asList("tcomp::ui::gridlayout::Main::value", "tcomp::ui::gridlayout::Advanced::value");
394            }
395
396            Collection<String> definedLayouts = parent
397                    .getMetadata()
398                    .keySet()
399                    .stream()
400                    .filter(k -> k.startsWith("tcomp::ui::gridlayout::"))
401                    .collect(Collectors.toList());
402            if (definedLayouts.isEmpty()) {
403                // If no layout defined, we take main if exists in parent
404                if (parentLayouts.contains("tcomp::ui::gridlayout::Main::value")) {
405                    return Arrays.asList("tcomp::ui::gridlayout::Main::value");
406                }
407            }
408
409            definedLayouts
410                    .stream()
411                    .filter(l -> parentLayouts.contains(l)) // A property is visible in a layout only if its parent is
412                                                            // also visible in this layout
413                    .forEach(k -> {
414                        String layoutConfig = parent.getMetadata().get(k);
415                        if (layoutConfig != null) {
416                            boolean isInThisLayout = Collections
417                                    .list(new StringTokenizer(layoutConfig, "|"))
418                                    .stream()
419                                    .flatMap(p -> Collections.list(new StringTokenizer(p.toString(), ",")).stream())
420                                    .collect(Collectors.toList())
421                                    .contains(param);
422
423                            if (isInThisLayout) {
424                                layouts.add(k);
425                            }
426                        }
427                    });
428
429            return layouts;
430        }
431
432        Map<String, Map<String, List<Param>>> getParametersWithUInfo() {
433            final Map<String, Map<String, List<Param>>> params = new HashMap<>();
434            final Map<String, UIInfo> uiInfos = getUIParamByPath(parameters);
435
436            for (Param p : toParams()) {
437                setParametersWithUInfoRecurs(p, uiInfos, params);
438            }
439
440            return params;
441        }
442
443        void setParametersWithUInfoRecurs(final Param p, final Map<String, UIInfo> uiInfos,
444                final Map<String, Map<String, List<Param>>> params) {
445            UIInfo info = uiInfos.get(p.fullPath);
446            p.setUiInfo(info);
447
448            Map<String, List<Param>> section = params.get(info.getSection());
449            if (section == null) {
450                section = new HashMap<>();
451                params.put(info.getSection(), section);
452            }
453
454            for (String l : info.getNestedLayout()) {
455                List<Param> layout = section.get(l);
456                if (layout == null) {
457                    layout = new ArrayList<>();
458                    section.put(l, layout);
459                }
460
461                layout.add(p);
462            }
463
464            for (Param n : p.getNested()) {
465                setParametersWithUInfoRecurs(n, uiInfos, params);
466            }
467        }
468
469        private Collection<Param> toParams() {
470            Collection<Param> params = new ArrayList<>();
471            for (ParameterMeta p : parameters) {
472                Param tp = toParamWithNested(p, null, null, new HashMap<>());
473                params.add(tp);
474            }
475
476            return params;
477        }
478
479        Stream<Param> parameters() {
480            return mapParameters(parameters, null, null, new HashMap<>());
481        }
482
483        private Stream<Param> mapParameters(final Collection<ParameterMeta> parameterMetas,
484                final DefaultValueInspector.Instance parentInstance, final ParameterBundle parentBundle,
485                final Map<String, String> types) {
486            return parameterMetas.stream().flatMap(p -> {
487                final DefaultValueInspector.Instance instance = defaultValueInspector
488                        .createDemoInstance(
489                                ofNullable(parentInstance).map(DefaultValueInspector.Instance::getValue).orElse(null),
490                                p);
491                return Stream
492                        .concat(Stream.of(toParam(p, instance, parentBundle, types)),
493                                mapParameters(p.getNestedParameters(), instance, findBundle(p), types));
494            });
495        }
496
497        private Param toParamWithNested(final ParameterMeta p, final DefaultValueInspector.Instance parentInstance,
498                final ParameterBundle parent, final Map<String, String> types) {
499            final ParameterBundle bundle = findBundle(p);
500            final String type = findEnclosingConfigurationType(p, types);
501
502            final DefaultValueInspector.Instance instance = defaultValueInspector
503                    .createDemoInstance(
504                            ofNullable(parentInstance).map(DefaultValueInspector.Instance::getValue).orElse(null), p);
505
506            Param param = new Param(bundle.displayName(parent).orElse(p.getName()),
507                    bundle.documentation(parent).orElseGet(() -> findDocumentation(p)),
508                    ofNullable(findDefault(p, instance)).orElse(emptyDefaultValue), ofNullable(type).orElse("-"),
509                    p.getPath(), createConditions(p.getPath(), p.getMetadata()),
510                    p.getMetadata().get("tcomp::configurationtype::type"));
511
512            for (ParameterMeta child : p.getNestedParameters()) {
513                Param np = toParamWithNested(child, instance, bundle, types);
514                param.addNested(np);
515            }
516
517            return param;
518        }
519
520        private Param toParam(final ParameterMeta p, final DefaultValueInspector.Instance instance,
521                final ParameterBundle parent, final Map<String, String> types) {
522            final ParameterBundle bundle = findBundle(p);
523            final String type = findEnclosingConfigurationType(p, types);
524            return new Param(bundle.displayName(parent).orElse(p.getName()),
525                    bundle.documentation(parent).orElseGet(() -> findDocumentation(p)),
526                    ofNullable(findDefault(p, instance)).orElse(emptyDefaultValue), ofNullable(type).orElse("-"),
527                    p.getPath(), createConditions(p.getPath(), p.getMetadata()), "Not computed");
528        }
529
530        private ParameterBundle findBundle(final ParameterMeta p) {
531            return p.findBundle(Thread.currentThread().getContextClassLoader(), locale);
532        }
533
534        private String findEnclosingConfigurationType(final ParameterMeta p, final Map<String, String> types) {
535            String type = p.getMetadata().get("tcomp::configurationtype::type");
536            if (type != null) {
537                types.put(p.getPath(), type);
538            } else { // try to find the closest parent
539                String currentPath = p.getPath();
540                while (type == null) {
541                    final int sep = currentPath.lastIndexOf('.');
542                    if (sep < 0) {
543                        break;
544                    }
545                    currentPath = currentPath.substring(0, sep);
546                    type = types.get(currentPath);
547                }
548            }
549            return type;
550        }
551
552        private String findDefault(final ParameterMeta p, final DefaultValueInspector.Instance instance) {
553            final String defVal = p.getMetadata().get("tcomp::ui::defaultvalue::value");
554            if (defVal != null) { // todo: revise if we need to know if it is a ui default or an instance default
555                return defVal;
556            }
557
558            if (instance == null || instance.getValue() == null || instance.isCreated()) {
559                return null;
560            }
561
562            switch (p.getType()) {
563            case NUMBER:
564            case BOOLEAN:
565            case STRING:
566            case ENUM:
567                return ofNullable(instance.getValue())
568                        .map(String::valueOf)
569                        .map(it -> it.isEmpty() ? "<empty>" : it)
570                        .orElse(null);
571            case ARRAY:
572                return String
573                        .valueOf(Collection.class.isInstance(instance.getValue())
574                                ? Collection.class.cast(instance.getValue()).size()
575                                : Array.getLength(instance.getValue()));
576            case OBJECT:
577            default:
578                return null;
579            }
580        }
581
582        private Conditions createConditions(final String path, final Map<String, String> metadata) {
583            final String globalOperator = metadata.getOrDefault("tcomp::condition::ifs::operator", "AND");
584            final Collection<Condition> conditionEntries =
585                    metadata.keySet().stream().filter(it -> it.startsWith("tcomp::condition::if::target")).map(it -> {
586                        final String target = metadata.get(it);
587                        return new Condition(target, resolver.doResolveProperty(path, target),
588                                metadata.get(it.replace("::target", "::value")),
589                                Boolean.parseBoolean(metadata.get(it.replace("::target", "::negate"))),
590                                metadata.get(it.replace("::target", "::evaluationStrategy")));
591                    }).collect(toList());
592            return new Conditions(path, globalOperator, conditionEntries);
593        }
594
595        private String findDocumentation(final ParameterMeta p) {
596            final String inline = p.getMetadata().get("tcomp::documentation::value");
597            if (inline != null) {
598                if (inline.startsWith("resource:")) {
599                    final InputStream stream = Thread
600                            .currentThread()
601                            .getContextClassLoader()
602                            .getResourceAsStream(inline.substring("resource:".length()));
603                    if (stream != null) {
604                        try (final BufferedReader reader =
605                                new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
606
607                            return reader.lines().collect(joining("\n"));
608                        } catch (final IOException e) {
609                            throw new IllegalArgumentException("Bad resource: '" + inline + "'", e);
610                        }
611                    } else {
612                        throw new IllegalArgumentException("No resource: '" + inline + "'");
613                    }
614
615                }
616                return inline;
617            }
618            return p.getName() + " configuration";
619        }
620    }
621}