001/*
002 * Extra Bnd Repository Plugins
003 * Copyright (C) 2019  Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package de.mnl.osgi.bnd.repository.maven.idxmvn;
020
021import aQute.bnd.http.HttpClient;
022import aQute.bnd.osgi.repository.ResourcesRepository;
023import aQute.bnd.osgi.repository.XMLResourceGenerator;
024import aQute.bnd.osgi.repository.XMLResourceParser;
025import aQute.maven.api.Program;
026import aQute.maven.api.Revision;
027import aQute.maven.provider.MavenBackingRepository;
028import aQute.service.reporter.Reporter;
029import de.mnl.osgi.bnd.maven.BoundRevision;
030import de.mnl.osgi.bnd.maven.CompositeMavenRepository.BinaryLocation;
031import de.mnl.osgi.bnd.maven.MavenResource;
032import de.mnl.osgi.bnd.maven.MavenResourceException;
033import de.mnl.osgi.bnd.maven.MavenVersion;
034import de.mnl.osgi.bnd.maven.MavenVersionRange;
035import de.mnl.osgi.bnd.maven.MavenVersionSpecification;
036
037import static de.mnl.osgi.bnd.maven.RepositoryUtils.rethrow;
038import static de.mnl.osgi.bnd.maven.RepositoryUtils.runIgnoring;
039import static de.mnl.osgi.bnd.maven.RepositoryUtils.unthrow;
040
041import java.io.File;
042import java.io.IOException;
043import java.io.InputStream;
044import java.io.Writer;
045import java.net.URI;
046import java.nio.charset.Charset;
047import java.nio.file.Files;
048import java.nio.file.Path;
049import java.util.ArrayList;
050import java.util.Collection;
051import java.util.Collections;
052import java.util.Comparator;
053import java.util.HashSet;
054import java.util.List;
055import java.util.Map;
056import java.util.Optional;
057import java.util.Properties;
058import java.util.Set;
059import java.util.concurrent.CompletableFuture;
060import java.util.concurrent.ConcurrentHashMap;
061import java.util.concurrent.ConcurrentMap;
062import java.util.concurrent.ExecutionException;
063import java.util.function.Supplier;
064import java.util.regex.Matcher;
065import java.util.regex.Pattern;
066import java.util.stream.Collectors;
067import java.util.stream.Stream;
068
069import org.apache.maven.model.Dependency;
070import org.osgi.resource.Capability;
071import org.osgi.resource.Resource;
072import org.slf4j.Logger;
073import org.slf4j.LoggerFactory;
074
075/**
076 * A repository with artifacts from a single group.
077 */
078@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyFields",
079    "PMD.ExcessiveImports" })
080public class MavenGroupRepository extends ResourcesRepository {
081
082    private static final Logger LOG = LoggerFactory.getLogger(
083        MavenGroupRepository.class);
084    private static final String PROP_UNAVAILABLE = "_NOT_AVAILABLE_";
085
086    /** Indexing state. */
087    private enum IndexingState {
088        NONE, INDEXING, INDEXED, EXCLUDED, EXCL_BY_DEP
089    }
090
091    private final String groupId;
092    private boolean requested;
093    private final IndexedMavenRepository indexedRepository;
094    private final HttpClient client;
095    private final Reporter reporter;
096    private Path groupDir;
097    private Path groupPropsPath;
098    private Path groupIndexPath;
099    private final Properties groupProps;
100    private final Map<String, String> propQueryCache
101        = new ConcurrentHashMap<>();
102    private final ConcurrentMap<Revision, IndexingState> indexingState
103        = new ConcurrentHashMap<>();
104    private ResourcesRepository backupRepo;
105    private Writer indexingLog;
106    private Map<Revision, List<String>> loggedMessages
107        = new ConcurrentHashMap<>();
108
109    @SuppressWarnings("PMD.FieldNamingConventions")
110    private static final Pattern hrefPattern = Pattern.compile(
111        "<[aA]\\s+(?:[^>]*?\\s+)?href=(?<quote>[\"'])"
112            + ":?(?<href>[a-zA-Z].*?)\\k<quote>");
113
114    /**
115     * Instantiates a new representation of group data backed
116     * by the specified directory. 
117     *
118     * @param groupId the maven groupId indexed by this repository
119     * @param directory the directory used to persist data
120     * @param requested if it is a requested group id
121     * @param indexedRepository the indexed maven repository
122     * @param client the client used for remote access
123     * @param reporter the reporter
124     * @throws IOException Signals that an I/O exception has occurred.
125     */
126    @SuppressWarnings({ "PMD.ConfusingTernary",
127        "PMD.AvoidCatchingGenericException", "PMD.AvoidDuplicateLiterals" })
128    public MavenGroupRepository(String groupId, Path directory,
129            boolean requested, IndexedMavenRepository indexedRepository,
130            HttpClient client, Reporter reporter) throws IOException {
131        this.groupId = groupId;
132        this.requested = requested;
133        this.indexedRepository = indexedRepository;
134        this.client = client;
135        this.reporter = reporter;
136
137        // Prepare directory and files
138        updatePaths(directory);
139        groupProps = new Properties();
140        if (groupPropsPath.toFile().canRead()) {
141            try (InputStream input = Files.newInputStream(groupPropsPath)) {
142                groupProps.load(input);
143            }
144        }
145
146        // Prepare OSGi repository
147        if (groupIndexPath.toFile().canRead()) {
148            try (XMLResourceParser parser
149                = new XMLResourceParser(groupIndexPath.toFile())) {
150                addAll(parser.parse());
151            } catch (Exception e) { // NOPMD
152                reporter.warning("Cannot parse %s, ignored: %s", groupIndexPath,
153                    e.getMessage());
154            }
155        }
156        // Cache revisions for faster checks.
157        for (Capability cap : findProvider(
158            newRequirementBuilder("bnd.info").build())) {
159            indexingState.put(
160                Revision.valueOf((String) cap.getAttributes().get("from")),
161                IndexingState.INDEXED);
162        }
163        LOG.debug("Created group repository for {}.", groupId);
164    }
165
166    private void updatePaths(Path directory) {
167        if (!directory.toFile().exists()) {
168            directory.toFile().mkdir();
169        }
170        groupDir = directory;
171        groupPropsPath = groupDir.resolve("group.properties");
172        groupIndexPath = groupDir.resolve("index.xml");
173    }
174
175    private IndexingState indexingState(Revision revision) {
176        return indexingState.getOrDefault(revision, IndexingState.NONE);
177    }
178
179    /**
180     * Checks if is requested.
181     *
182     * @return the requested
183     */
184    public final boolean isRequested() {
185        return requested;
186    }
187
188    /**
189     * Writes all changes to persistent storage and removes
190     * any backup information prepared by {@link #reset(Path, boolean)}.
191     *
192     * @throws IOException Signals that an I/O exception has occurred.
193     */
194    public void flush() throws IOException {
195        boolean indexChanged = true;
196        if (backupRepo != null) {
197            Set<Resource> oldSet = new HashSet<>(backupRepo.getResources());
198            Set<Resource> newSet = new HashSet<>(getResources());
199            if (newSet.equals(oldSet)) {
200                indexChanged = false;
201            }
202        }
203        if (indexChanged) {
204            XMLResourceGenerator generator = new XMLResourceGenerator();
205            generator.resources(getResources());
206            generator.name(indexedRepository.mavenRepository().name());
207            try {
208                generator.save(groupIndexPath.toFile());
209            } catch (IOException e) {
210                reporter.exception(e, "Cannot save %s.", groupIndexPath);
211            }
212        }
213        backupRepo = null;
214        if (indexingLog != null) {
215            rethrow(IOException.class, () -> loggedMessages.entrySet().stream()
216                .sorted(Map.Entry.comparingByKey())
217                .flatMap(e -> e.getValue().stream())
218                .forEach(msg -> unthrow(() -> indexingLog
219                    .write(msg + System.lineSeparator()))));
220            indexingLog.close();
221            indexingLog = null;
222        }
223        loggedMessages.clear();
224        indexingState.clear();
225    }
226
227    /**
228     * Removes the group directory if empty.
229     *
230     * @return true, if removed
231     * @throws IOException Signals that an I/O exception has occurred.
232     */
233    public boolean removeIfRedundant() throws IOException {
234        if (isRequested() || !groupProps.isEmpty()
235            || !getResources().isEmpty()) {
236            return false;
237        }
238        // Nothing in this group
239        Files.walk(groupDir)
240            .sorted(Comparator.reverseOrder())
241            .map(Path::toFile)
242            .forEach(File::delete);
243        return true;
244    }
245
246    /**
247     * Returns the group id.
248     *
249     * @return the groupId
250     */
251    @SuppressWarnings("PMD.ShortMethodName")
252    public final String id() {
253        return groupId;
254    }
255
256    /**
257     * Clears this repository and updates the path and the requested 
258     * flag. Keeps the current content as backup for reuse in a 
259     * subsequent call to {@link #reload()}.
260     *
261     * @param directory this group's directory
262     * @param requested whether this is a requested group
263     */
264    public void reset(Path directory, boolean requested) {
265        synchronized (this) {
266            // Update basic properties
267            this.requested = requested;
268            if (!groupDir.equals(directory)) {
269                updatePaths(directory);
270                backupRepo = null;
271            } else {
272                // Save current content and clear.
273                backupRepo = new ResourcesRepository(getResources());
274            }
275            set(Collections.emptyList());
276            // Clear and reload properties
277            groupProps.clear();
278            if (groupPropsPath.toFile().canRead()) {
279                try (InputStream input = Files.newInputStream(groupPropsPath)) {
280                    propQueryCache.clear();
281                    groupProps.load(input);
282                } catch (IOException e) {
283                    reporter.warning("Problem reading %s (ignored): %s",
284                        groupPropsPath, e.getMessage());
285                }
286            }
287            // Clear remaining caches.
288            indexingState.clear();
289            propQueryCache.clear();
290        }
291    }
292
293    /**
294     * Returns the excluded versions of the specified artifact id.
295     *
296     * @param artifactId the artifact id
297     * @return the maven version range
298     */
299    public MavenVersionRange excludedRange(String artifactId) {
300        // Check if excluded by rule.
301        return Optional
302            .ofNullable(searchProperty(artifactId, "exclude"))
303            .map(MavenVersionRange::parseRange)
304            .orElse(MavenVersionRange.NONE);
305    }
306
307    /**
308     * Reload the repository. Requested repositories retrieve
309     * the list of known artifactIds from the remote repository 
310     * and add the versions. For versions already in the repository, 
311     * the backup information is re-used.
312     *
313     * @throws IOException Signals that an I/O exception has occurred.
314     */
315    @SuppressWarnings({ "PMD.AvoidReassigningLoopVariables",
316        "PMD.AvoidCatchingGenericException", "PMD.AvoidDuplicateLiterals",
317        "PMD.AvoidInstantiatingObjectsInLoops",
318        "PMD.AvoidThrowingRawExceptionTypes", "PMD.PreserveStackTrace" })
319    public void reload() throws IOException {
320        if (indexedRepository.logIndexing()) {
321            indexingLog = Files.newBufferedWriter(
322                groupDir.resolve("indexing.log"), Charset.defaultCharset());
323        } else {
324            // Don't keep out-dated log files, it's irritating.
325            groupDir.resolve("indexing.log").toFile().delete();
326        }
327        loggedMessages.clear();
328        if (!isRequested()) {
329            // Will be filled with dependencies only
330            return;
331        }
332        // Actively filled.
333        synchronized (this) {
334            if (backupRepo == null) {
335                backupRepo = new ResourcesRepository(getResources());
336            }
337            propQueryCache.clear();
338        }
339        try {
340            CompletableFuture<Void> actions
341                = CompletableFuture.allOf(findArtifactIds().stream().map(
342                    artifactId -> loadProgram(Program.valueOf(groupId,
343                        artifactId)))
344                    .toArray(CompletableFuture[]::new));
345            actions.get();
346        } catch (ExecutionException e) {
347            if (e.getCause() instanceof IOException) {
348                throw (IOException) e.getCause();
349            }
350            throw new RuntimeException(e.getCause());
351        } catch (InterruptedException e) {
352            throw new RuntimeException(e);
353        }
354    }
355
356    private CompletableFuture<Void> loadProgram(Program program) {
357        // Get revisions of program and process.
358        CompletableFuture<Void> result = new CompletableFuture<>();
359        IndexedMavenRepository.programLoaders.submit(() -> {
360            Stream<CompletableFuture<Void>> revLoaderStream;
361            try {
362                LOG.debug("Getting list of revisions of {}.", program);
363                revLoaderStream = indexedRepository.mavenRepository()
364                    .findRevisions(program)
365                    .map(revision -> loadRevision(revision));
366            } catch (IOException e) {
367                reporter.exception(e,
368                    "Failed to get list of revisions of %s: %s", program,
369                    e.getMessage());
370                result.complete(null);
371                return;
372            }
373            // Ignore exceptions from loadRevision, there won't be any.
374            revLoaderStream.forEach(
375                revLoad -> unthrow(() -> revLoad.handle((res, thrw) -> {
376                    return null;
377                }).get()));
378            result.complete(null);
379        });
380        return result;
381    }
382
383    /**
384     * Load the revision asynchronously. Calling 
385     * {@link CompletableFuture#get()} never returns an exception. 
386     *
387     * @param revision the revision
388     * @return the completable future
389     */
390    @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
391        "PMD.AvoidDuplicateLiterals",
392        "PMD.PositionLiteralsFirstInComparisons" })
393    private CompletableFuture<Void> loadRevision(BoundRevision revision) {
394        return CompletableFuture.runAsync(() -> {
395            if (!isSelected(revision.unbound())) {
396                logIndexing(revision.unbound(),
397                    () -> String.format("%s not selected for indexing.",
398                        revision.unbound()));
399                return;
400            }
401            IndexingState curState = indexingState.putIfAbsent(
402                revision.unbound(), IndexingState.INDEXING);
403            if (curState != null && curState != IndexingState.INDEXING) {
404                // Already handled (as dependency)
405                logIndexing(revision.unbound(), () -> String.format(
406                    "%s in list (already handled as dependency).",
407                    revision.unbound()));
408                return;
409            }
410            LOG.debug("Loading revision {}.", revision.unbound());
411            logIndexing(revision.unbound(),
412                () -> String.format("%s in list, indexing...",
413                    revision.unbound()));
414            try {
415                MavenResource resource = indexedRepository.mavenRepository()
416                    .resource(revision, BinaryLocation.REMOTE);
417                Set<MavenResource> allDeps = new HashSet<>();
418                if (!isIndexable(resource, allDeps)) {
419                    return;
420                }
421                addResourceAndDependencies(resource, allDeps);
422            } catch (Exception e) {
423                // Load "in isolation", don't propagate individual failure.
424                reporter.exception(e, "Failed to load revision %s: %s",
425                    revision, e.getMessage());
426                logIndexing(revision.unbound(), () -> String.format(
427                    "%s failed to load: %s.", revision.unbound(),
428                    e.getMessage()));
429            }
430        }, IndexedMavenRepository.revisionLoaders);
431    }
432
433    @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
434        "PMD.AvoidInstantiatingObjectsInLoops" })
435    private Collection<String> findArtifactIds() {
436        Set<String> result = new HashSet<>();
437        for (MavenBackingRepository repo : indexedRepository.mavenRepository()
438            .backing()) {
439            URI groupUri = null;
440            try {
441                groupUri
442                    = repo.toURI("").resolve(groupId.replace('.', '/') + "/");
443                String page = client.build().headers("User-Agent", "Bnd")
444                    .get(String.class)
445                    .go(groupUri);
446                if (page == null) {
447                    continue;
448                }
449                Matcher matcher = hrefPattern.matcher(page);
450                while (matcher.find()) {
451                    URI programUri = groupUri.resolve(matcher.group("href"));
452                    String artifactId = programUri.getPath()
453                        .substring(groupUri.getPath().length());
454                    if (artifactId.endsWith("/")) {
455                        artifactId
456                            = artifactId.substring(0, artifactId.length() - 1);
457                    }
458                    result.add(artifactId);
459                }
460            } catch (Exception e) {
461                reporter.warning("Problem retrieving %s, skipped: %s", groupUri,
462                    e.getMessage());
463            }
464        }
465        return result;
466    }
467
468    private boolean isSelected(Revision revision) {
469        MavenVersionRange acceptedRange = Optional.ofNullable(
470            searchProperty(revision.artifact, "versions"))
471            .map(MavenVersionRange::parseRange).orElse(null);
472        MavenVersionRange forcedRange = Optional.ofNullable(
473            searchProperty(revision.artifact, "forcedVersions"))
474            .map(MavenVersionRange::parseRange).orElse(null);
475        if (acceptedRange == null && forcedRange == null) {
476            // It is to be added by default (i.e. nothing is specified).
477            return true;
478        }
479        return acceptedRange != null
480            && acceptedRange.includes(MavenVersion.from(revision.version))
481            || forcedRange != null
482                && forcedRange.includes(MavenVersion.from(revision.version));
483    }
484
485    private boolean isForced(Revision revision) {
486        return Optional.ofNullable(
487            searchProperty(revision.artifact, "forcedVersions"))
488            .map(MavenVersionRange::parseRange)
489            .map(range -> range.includes(MavenVersion.from(revision.version)))
490            .orElse(false);
491    }
492
493    private void addResourceAndDependencies(MavenResource resource,
494            Set<MavenResource> allDeps) {
495        addResource(resource);
496        // Add the dependencies found while checking to the index.
497        try {
498            rethrow(IOException.class, () -> allDeps.stream()
499                .forEach(res -> runIgnoring(() -> indexedRepository
500                    .getOrCreateGroupRepository(res.revision().group)
501                    .addResource(res))));
502        } catch (Exception e) {
503            // Load "in isolation", don't propagate individual failure.
504            reporter.exception(e, "Failed to add resource %s: %s",
505                resource.revision(), e.getMessage());
506            logIndexing(resource.revision(), () -> String.format(
507                "%s failed to load.", resource.revision()));
508        }
509    }
510
511    /**
512     * Checks if the given revision is indexable, considering its 
513     * dependencies. A revision is indexable if its dependencies aren't
514     * excluded.
515     *
516     * @param resource the resource to check
517     * @param dependencies revisions that must additionally be included
518     *    when the checked revision is added
519     * @return true, if the revision can be added to the index
520     */
521    @SuppressWarnings({ "PMD.LongVariable", "PMD.NPathComplexity",
522        "PMD.AvoidCatchingGenericException" })
523    private boolean isIndexable(MavenResource resource,
524            Set<MavenResource> dependencies) {
525        Revision revision = resource.revision();
526        if (!revision.group.equals(groupId)) {
527            throw new IllegalArgumentException("Wrong groupId "
528                + revision.group + " (must be " + groupId + ").");
529        }
530        // Check if already decided.
531        switch (indexingState(revision)) {
532        case INDEXED:
533            return true;
534        case EXCLUDED:
535        case EXCL_BY_DEP:
536            return false;
537        default:
538            break;
539        }
540        // Check if excluded by rule.
541        if (excludedRange(revision.artifact)
542            .includes(MavenVersion.from(revision.version))) {
543            if (indexingState.replace(revision, IndexingState.INDEXING,
544                IndexingState.EXCLUDED)) {
545                logIndexing(revision,
546                    () -> String.format("%s is excluded by rule.", revision));
547            }
548            return false;
549        }
550        boolean isForced = isForced(resource.revision());
551        // Check whether excluded because dependency is excluded
552        List<Dependency> deps = evaluateDependencies(resource);
553        for (Dependency dep : deps) {
554            MavenGroupRepository depRepo;
555            try {
556                depRepo = indexedRepository
557                    .getOrCreateGroupRepository(dep.getGroupId());
558            } catch (Exception e) {
559                // Failing to get a dependency is no reason to fail.
560                continue;
561            }
562            Optional<MavenResource> optRes = depRepo.dependencyToResource(dep);
563            if (!optRes.isPresent()) {
564                // Failing to get a dependency is no reason to fail.
565                continue;
566            }
567            MavenResource depResource = optRes.get();
568            // Watch out to use the proper repository in the code following!
569            depRepo.logIndexing(depResource.revision(),
570                () -> String.format("%s is dependency of %s, indexing...",
571                    depResource.revision(), revision));
572            depRepo.indexingState.putIfAbsent(depResource.revision(),
573                IndexingState.INDEXING);
574            Set<MavenResource> collectedDeps = new HashSet<>();
575            if (!depRepo.isIndexable(depResource, collectedDeps)) {
576                // Note that the revision which was checked is not indexable
577                // due to a dependency that is not indexable (unless forced)
578                if (!isForced) {
579                    if (indexingState.replace(revision, IndexingState.INDEXING,
580                        IndexingState.EXCL_BY_DEP)) {
581                        logIndexing(revision, () -> String.format(
582                            "%s not indexable, depends on %s.",
583                            revision, depResource.revision()));
584                    }
585                    return false;
586                }
587            }
588            if (depRepo.isSelected(depResource.revision())) {
589                // Has just been indexed as dependency, but would have
590                // been indexed (as selected) anyway, so add independent
591                // of outcome of dependency evaluation.
592                depRepo.addResourceAndDependencies(depResource, collectedDeps);
593            } else {
594                dependencies.add(depResource);
595                dependencies.addAll(collectedDeps);
596            }
597        }
598        return true;
599    }
600
601    private List<Dependency> evaluateDependencies(MavenResource resource) {
602        List<Dependency> deps;
603        try {
604            deps = resource.dependencies();
605        } catch (Exception e) {
606            // Failing to get the dependencies is no reason to fail.
607            return Collections.emptyList();
608        }
609        if (!deps.isEmpty()) {
610            logIndexing(resource.revision(),
611                () -> String.format("%s has dependencies: %s",
612                    resource.revision(), deps.stream()
613                        .map(d -> d.getGroupId() + ":" + d.getArtifactId() + ":"
614                            + d.getVersion())
615                        .collect(Collectors.joining(", "))));
616        }
617        return deps;
618    }
619
620    private Optional<MavenResource> dependencyToResource(Dependency dep) {
621        Program depPgm = Program.valueOf(dep.getGroupId(), dep.getArtifactId());
622        try {
623            return indexedRepository.mavenRepository().resource(
624                depPgm, narrowVersion(depPgm, MavenVersionSpecification
625                    .from(dep.getVersion())),
626                BinaryLocation.REMOTE);
627        } catch (Exception e) {
628            // Failing to get a dependency is no reason to fail.
629            return Optional.empty();
630        }
631    }
632
633    private MavenVersionSpecification narrowVersion(
634            Program program, MavenVersionSpecification version)
635            throws IOException {
636        if (version instanceof MavenVersion) {
637            return version;
638        }
639        // Restrict range to allowed
640        MavenVersionRange excluded = excludedRange(program.artifact);
641        return excluded.complement().restrict((MavenVersionRange) version);
642    }
643
644    /**
645     * Adds the specified revision.
646     *
647     * @param revision the revision to add
648     */
649    private void addResource(MavenResource resource) {
650        synchronized (indexingState) {
651            if (indexingState(resource.revision()) == IndexingState.INDEXED) {
652                return;
653            }
654            try {
655                add(resource.asResource());
656                logIndexing(resource.revision(),
657                    () -> String.format("%s indexed.", resource.revision()));
658            } catch (MavenResourceException e) {
659                reporter.exception(e, "Failed to get %s as resource.",
660                    resource.toString());
661                logIndexing(resource.revision(),
662                    () -> String.format("%s could not be index: %s.",
663                        resource.revision(), e.getMessage()));
664            }
665            indexingState.put(resource.revision(), IndexingState.INDEXED);
666        }
667    }
668
669    /* package */ Optional<Resource> searchInBackup(Revision revision) {
670        if (backupRepo == null) {
671            return Optional.empty();
672        }
673        return backupRepo.findProvider(
674            backupRepo.newRequirementBuilder("bnd.info")
675                .addDirective("filter",
676                    String.format("(from=%s)", revision.toString()))
677                .build())
678            .stream().findFirst().map(Capability::getResource);
679    }
680
681    @SuppressWarnings("PMD.CompareObjectsWithEquals")
682    private String searchProperty(String artifactId, String qualifier) {
683        String queryKey = artifactId + ";" + qualifier;
684        // Attempt to get from cache.
685        String prop = propQueryCache.get(queryKey);
686        // Special value, because ConcurrentHashMap cannot have null values.
687        if (prop == PROP_UNAVAILABLE) {
688            return null;
689        }
690        // Found in cache return.
691        if (prop != null) {
692            return prop;
693        }
694        // Try query key unmodified
695        prop = groupProps.getProperty(queryKey);
696        if (prop == null) {
697            // Try <id>.*, successively removing trailing parts.
698            String rest = artifactId;
699            while (true) {
700                prop = groupProps.getProperty(rest + ".*;" + qualifier);
701                if (prop != null) {
702                    break;
703                }
704                int lastDot = rest.lastIndexOf('.');
705                if (lastDot < 0) {
706                    break;
707                }
708                rest = rest.substring(0, lastDot);
709            }
710        }
711        if (prop == null) {
712            prop = groupProps.getProperty(qualifier);
713        }
714        propQueryCache.put(queryKey, prop == null ? PROP_UNAVAILABLE : prop);
715        return prop;
716    }
717
718    private void logIndexing(Revision revision, Supplier<String> msgSupplier) {
719        loggedMessages.computeIfAbsent(revision, rev -> new ArrayList<>())
720            .add(msgSupplier.get());
721    }
722
723    /*
724     * (non-Javadoc)
725     * 
726     * @see java.lang.Object#toString()
727     */
728    @Override
729    public String toString() {
730        return "MavenGroupRepository [groupId=" + groupId + "]";
731    }
732}