LocationParser.java

/*-
 * ========================LICENSE_START=================================
 * flyway-core
 * ========================================================================
 * Copyright (C) 2010 - 2025 Red Gate Software Ltd
 * ========================================================================
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * =========================LICENSE_END==================================
 */
package org.flywaydb.core.internal.scanner;

import static org.flywaydb.core.internal.scanner.ClasspathLocationHandler.CLASSPATH_PREFIX;
import static org.flywaydb.core.internal.scanner.filesystem.FilesystemLocationHandler.FILESYSTEM_PREFIX;

import java.util.function.Function;
import java.util.regex.Pattern;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.FlywayException;
import org.flywaydb.core.api.Location;
import org.flywaydb.core.internal.util.Pair;

public class LocationParser {

    private static final String LOCATION_SEPARATOR = ":";
    private static final Pattern FILE_PATH_WITH_DRIVE_PATTERN = Pattern.compile("^[A-Za-z]:[\\\\/].*");
    private static final Pattern FILE_URL_PATTERN = Pattern.compile("^file:[\\\\/]{3}.*");

    public static FileLocation parseFileLocation(final String descriptor) {
        final String normalizedDescriptor = descriptor.trim();
        final Pair<String, String> parsedDescriptor = parseDescriptor(normalizedDescriptor, FILESYSTEM_PREFIX);
        final String prefix = parsedDescriptor.getLeft();
        final String path = parsedDescriptor.getRight();

        return new FileLocation(prefix, path);
    }

    public static Location parseLocation(final String descriptor) {
        final String normalizedDescriptor = descriptor.trim();
        final Pair<String, String> parsedDescriptor = parseDescriptor(normalizedDescriptor, CLASSPATH_PREFIX);
        final String prefix = parsedDescriptor.getLeft();
        final String rawPath = parsedDescriptor.getRight();

        final ReadOnlyLocationHandler locationHandler = Flyway.configure()
            .getPluginRegister()
            .getInstancesOf(ReadOnlyLocationHandler.class)
            .stream()
            .filter(x -> x.canHandlePrefix(prefix))
            .findFirst()
            .orElseThrow(() -> new FlywayException(
                "Unknown prefix for location (should be one of filesystem:, classpath:, gcs:, or s3:): "
                    + normalizedDescriptor));

        return locationHandler.handlesWildcards() && containsWildcards(rawPath) ? parseWildcardLocation(rawPath,
            prefix,
            locationHandler.getPathSeparator(),
            locationHandler::normalizePath) : Location.fromPath(prefix, locationHandler.normalizePath(rawPath));
    }

    private static Pair<String, String> parseDescriptor(final String descriptor, final String defaultPrefix) {
        final String prefix;
        final String path;
        if (descriptor.contains(LOCATION_SEPARATOR)
            && !FILE_PATH_WITH_DRIVE_PATTERN.matcher(descriptor).matches()
            && !FILE_URL_PATTERN.matcher(descriptor).matches()) {
            prefix = descriptor.substring(0, descriptor.indexOf(LOCATION_SEPARATOR) + 1);
            path = descriptor.substring(descriptor.indexOf(LOCATION_SEPARATOR) + 1);
        } else {
            prefix = defaultPrefix;
            path = descriptor;
        }
        return Pair.of(prefix, path);
    }

    private static boolean containsWildcards(final String rawPath) {
        return rawPath.contains("*") || rawPath.contains("?");
    }

    /**
     * Process the rawPath into a rootPath and a regex. Supported wildcards: **: Match any 0 or more directories *:
     * Match any sequence of non-separator characters ?: Match any single character
     */
    private static Location parseWildcardLocation(final String rawPath,
        final String prefix,
        final String separator,
        final Function<? super String, String> normalizePath) {
        // we need to figure out the root, and create the regex
        final String escapedSeparator = separator.replace("\\", "\\\\").replace("/", "\\/");

        // split on either of the path separators
        final String[] pathSplit = rawPath.split("[\\\\/]");

        final StringBuilder rootPart = new StringBuilder();
        final StringBuilder patternPart = new StringBuilder();

        boolean endsInFile = false;
        boolean skipSeparator = false;
        boolean inPattern = false;
        for (final String pathPart : pathSplit) {
            endsInFile = false;

            if (pathPart.contains("*") || pathPart.contains("?")) {
                inPattern = true;
            }

            if (inPattern) {
                if (skipSeparator) {
                    skipSeparator = false;
                } else {
                    patternPart.append("/");
                }

                String regex;
                if ("**".equals(pathPart)) {
                    regex = "([^/]+/)*?";

                    // this pattern contains the ending separator, so make sure we skip appending it after
                    skipSeparator = true;
                } else {
                    endsInFile = pathPart.contains(".");

                    regex = pathPart;
                    regex = regex.replace(".", "\\.");
                    regex = regex.replace("?", "[^/]");
                    regex = regex.replace("*", "[^/]+?");
                }

                patternPart.append(regex);
            } else {
                rootPart.append(separator).append(pathPart);
            }
        }

        // We always append a separator before each part, so ensure we skip it when setting the final rootPath
        final String rootPath = normalizePath.apply(!rootPart.isEmpty() ? rootPart.substring(1) : "");

        // Again, skip first separator
        String pattern = patternPart.substring(1);

        // Replace the temporary / with the actual escaped separator
        pattern = pattern.replace("/", escapedSeparator);

        // Prepend the rootPath if it is non-empty
        if (!rootPart.isEmpty()) {
            pattern = rootPath.replace(separator, escapedSeparator) + escapedSeparator + pattern;
        }

        // if the path did not end in a file, then append the file match pattern
        if (!endsInFile) {
            pattern = pattern + escapedSeparator + "(?<relpath>.*)";
        }

        final Pattern pathRegex = Pattern.compile(pattern);
        return Location.fromWildcardPath(prefix, rootPath, rawPath, pathRegex);
    }
}