001/*
002 * Copyright 2017-2022 original authors
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 * https://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 io.micronaut.maven;
017
018import io.methvin.watcher.DirectoryChangeEvent;
019import io.methvin.watcher.DirectoryWatcher;
020import io.micronaut.maven.aot.AotAnalysisMojo;
021import io.micronaut.maven.services.CompilerService;
022import io.micronaut.maven.services.DependencyResolutionService;
023import io.micronaut.maven.services.ExecutorService;
024import io.micronaut.maven.testresources.AbstractTestResourcesMojo;
025import io.micronaut.maven.testresources.TestResourcesHelper;
026import io.micronaut.testresources.buildtools.ServerSettings;
027import io.micronaut.testresources.buildtools.ServerUtils;
028import org.apache.maven.execution.MavenSession;
029import org.apache.maven.model.Build;
030import org.apache.maven.model.FileSet;
031import org.apache.maven.plugin.BuildPluginManager;
032import org.apache.maven.plugin.MojoExecutionException;
033import org.apache.maven.plugins.annotations.Execute;
034import org.apache.maven.plugins.annotations.LifecyclePhase;
035import org.apache.maven.plugins.annotations.Mojo;
036import org.apache.maven.plugins.annotations.Parameter;
037import org.apache.maven.plugins.annotations.ResolutionScope;
038import org.apache.maven.project.MavenProject;
039import org.apache.maven.project.ProjectBuilder;
040import org.apache.maven.project.ProjectBuildingException;
041import org.apache.maven.project.ProjectBuildingRequest;
042import org.apache.maven.project.ProjectBuildingResult;
043import org.apache.maven.toolchain.ToolchainManager;
044import org.codehaus.plexus.util.AbstractScanner;
045import org.codehaus.plexus.util.cli.CommandLineUtils;
046import org.eclipse.aether.graph.Dependency;
047import org.eclipse.aether.util.artifact.JavaScopes;
048
049import javax.inject.Inject;
050import java.io.File;
051import java.nio.file.Files;
052import java.nio.file.InvalidPathException;
053import java.nio.file.Path;
054import java.util.ArrayList;
055import java.util.Arrays;
056import java.util.Collections;
057import java.util.List;
058import java.util.Optional;
059import java.util.concurrent.atomic.AtomicBoolean;
060import java.util.concurrent.locks.ReentrantLock;
061import java.util.stream.Collectors;
062
063import static io.micronaut.maven.MojoUtils.findJavaExecutable;
064import static io.micronaut.maven.MojoUtils.hasMicronautMavenPlugin;
065import static io.micronaut.maven.MojoUtils.THIS_PLUGIN;
066import static java.nio.file.Files.isDirectory;
067import static java.nio.file.Files.isReadable;
068import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
069
070/**
071 * <p>Executes a Micronaut application in development mode.</p>
072 *
073 * <p>It watches for changes in the project tree. If there are changes in the {@code pom.xml} file, dependencies will be reloaded. If
074 * the changes are anywhere underneath {@code src/main}, it will recompile the project and restart the application.</p>
075 *
076 * <p>The plugin can handle changes in all the languages supported by Micronaut: Java, Kotlin and Groovy.</p>
077 *
078 * @author Álvaro Sánchez-Mariscal
079 * @since 1.0.0
080 */
081@SuppressWarnings("unused")
082@Mojo(name = "run", requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, defaultPhase = LifecyclePhase.PREPARE_PACKAGE, aggregator = true)
083@Execute(phase = LifecyclePhase.PROCESS_CLASSES)
084public class RunMojo extends AbstractTestResourcesMojo {
085
086    public static final String MN_APP_ARGS = "mn.appArgs";
087    public static final String EXEC_MAIN_CLASS = "${exec.mainClass}";
088    public static final String RESOURCES_DIR = "src/main/resources";
089
090    private static final List<String> RELEVANT_SRC_DIRS = List.of("resources", "java", "kotlin", "groovy");
091    private static final int LAST_COMPILATION_THRESHOLD = 500;
092    private static final List<String> DEFAULT_EXCLUDES;
093
094    static {
095        DEFAULT_EXCLUDES = new ArrayList<>();
096        Collections.addAll(DEFAULT_EXCLUDES, AbstractScanner.DEFAULTEXCLUDES);
097        Collections.addAll(DEFAULT_EXCLUDES, "**/.idea/**", "**/src/test/**");
098    }
099
100    private final MavenSession mavenSession;
101    private final ProjectBuilder projectBuilder;
102    private final ToolchainManager toolchainManager;
103    private final String javaExecutable;
104    private final DependencyResolutionService dependencyResolutionService;
105    private final CompilerService compilerService;
106    private final ExecutorService executorService;
107
108    /**
109     * The project's target directory.
110     */
111    private File targetDirectory;
112
113    /**
114     * The main class of the application, as defined in the
115     * <a href="https://www.mojohaus.org/exec-maven-plugin/java-mojo.html#mainClass">Exec Maven Plugin</a>.
116     */
117    @Parameter(defaultValue = EXEC_MAIN_CLASS)
118    private String mainClass;
119
120    /**
121     * Whether to start the Micronaut application in debug mode.
122     */
123    @Parameter(property = "mn.debug", defaultValue = "false")
124    private boolean debug;
125
126    /**
127     * Whether to suspend the execution of the application when running in debug mode.
128     */
129    @Parameter(property = "mn.debug.suspend", defaultValue = "false")
130    private boolean debugSuspend;
131
132    /**
133     * The port where remote debuggers can be attached to.
134     */
135    @Parameter(property = "mn.debug.port", defaultValue = "5005")
136    private int debugPort;
137
138    /**
139     * The host where remote debuggers can connect.
140     */
141    @Parameter(property = "mn.debug.host", defaultValue = "127.0.0.1")
142    private String debugHost;
143
144    /**
145     * List of inclusion/exclusion paths that should not trigger an application restart.
146     * For example, you can exclude a particular directory from being watched by adding the following
147     * configuration:
148     * <pre>
149     *     &lt;watches&gt;
150     *         &lt;watch&gt;
151     *             &lt;directory&gt;.some-dir&lt;/directory&gt;
152     *             &lt;excludes&gt;
153     *                 &lt;exclude&gt;**&#47;*&lt;/exclude&gt;
154     *             &lt;/excludes&gt;
155     *         &lt;/watch&gt;
156     *     &lt;/watches&gt;
157     * </pre>
158     * Check the
159     * <a href="https://maven.apache.org/ref/3.3.9/maven-model/apidocs/org/apache/maven/model/FileSet.html">FileSet</a>
160     * documentation for more details.
161     *
162     * @see <a href="https://maven.apache.org/ref/3.3.9/maven-model/apidocs/org/apache/maven/model/FileSet.html">FileSet</a>
163     */
164    @Parameter
165    private List<FileSet> watches;
166
167    /**
168     * <p>List of additional arguments that will be passed to the JVM process, such as Java agent properties.</p>
169     *
170     * <p>When using the command line, user properties will be passed through, eg: <code>mnv mn:run -Dmicronaut.environments=dev</code>.</p>
171     */
172    @Parameter(property = "mn.jvmArgs")
173    private String jvmArguments;
174
175    /**
176     * List of additional arguments that will be passed to the application, after the class name.
177     */
178    @Parameter(property = MN_APP_ARGS)
179    private String appArguments;
180
181    /**
182     * Whether to watch for changes, or finish the execution after the first run.
183     */
184    @Parameter(property = "mn.watch", defaultValue = "true")
185    private boolean watchForChanges;
186
187    /**
188     * Whether to enable or disable Micronaut AOT.
189     */
190    @Parameter(property = "micronaut.aot.enabled", defaultValue = "false")
191    private boolean aotEnabled;
192
193    // These 2 flags are used in the context of watching for changes
194    // the first one makes sure that only one recompilation is processed at a time
195    private final AtomicBoolean recompileRequested = new AtomicBoolean();
196    // the second one makes sure that we wait for the server to be started before we restart it
197    // otherwise a process may be kept alive
198    private final ReentrantLock restartLock = new ReentrantLock();
199
200    private MavenProject runnableProject;
201    private DirectoryWatcher directoryWatcher;
202    private volatile Process process;
203    private String classpath;
204    private int classpathHash;
205    private long lastCompilation;
206    private TestResourcesHelper testResourcesHelper;
207
208    @SuppressWarnings("CdiInjectionPointsInspection")
209    @Inject
210    public RunMojo(MavenSession mavenSession,
211                   BuildPluginManager pluginManager,
212                   ProjectBuilder projectBuilder,
213                   ToolchainManager toolchainManager,
214                   CompilerService compilerService,
215                   ExecutorService executorService,
216                   DependencyResolutionService dependencyResolutionService) {
217        this.mavenSession = mavenSession;
218        this.projectBuilder = projectBuilder;
219        this.toolchainManager = toolchainManager;
220        this.compilerService = compilerService;
221        this.executorService = executorService;
222        this.javaExecutable = findJavaExecutable(toolchainManager, mavenSession);
223        this.dependencyResolutionService = dependencyResolutionService;
224    }
225
226    @Override
227    public void execute() throws MojoExecutionException {
228        try {
229            initialize();
230        } catch (Exception e) {
231            throw new MojoExecutionException(e.getMessage());
232        }
233
234        try {
235            maybeStartTestResourcesServer();
236            runApplication();
237            Thread shutdownHook = new Thread(this::killProcess);
238            Runtime.getRuntime().addShutdownHook(shutdownHook);
239
240            if (process != null && process.isAlive()) {
241                if (watchForChanges) {
242                    var pathsToWatch = new ArrayList<Path>();
243                    for (FileSet fs : watches) {
244                        var directory = runnableProject.getBasedir().toPath().resolve(fs.getDirectory()).toAbsolutePath();
245                        if (Files.exists(directory)) {
246                            pathsToWatch.add(directory);
247                            //If neither includes nor excludes, add a default include
248                            if ((fs.getIncludes() == null || fs.getIncludes().isEmpty()) && (fs.getExcludes() == null || fs.getExcludes().isEmpty())) {
249                                fs.addInclude("**/*");
250                            }
251                        }
252                    }
253
254                    this.directoryWatcher = DirectoryWatcher
255                        .builder()
256                        .paths(pathsToWatch)
257                        .listener(this::handleEvent)
258                        .build();
259
260                    // We use the working directory as the root path, because
261                    // the top-level project information may not be what we
262                    // expect, in particular if we run from a submodule, or
263                    // that we run from root but with the "-pl" option.
264                    // We can safely do this because it's only used to display
265                    // information about paths being watched to the user, the
266                    // actual paths are unchanged.
267                    Path root = Path.of(".").toAbsolutePath();
268                    List<Path> pathList = pathsToWatch.stream()
269                        .map(root::relativize)
270                        .filter(s -> !s.toString().isEmpty())
271                        .filter(Files::exists)
272                        .sorted()
273                        .toList();
274                    getLog().info("👀 Watching for changes in " + pathList);
275                    this.directoryWatcher.watch();
276                } else if (process != null && process.isAlive()) {
277                    process.waitFor();
278                }
279            }
280        } catch (InterruptedException e) {
281            Thread.currentThread().interrupt();
282        } catch (Exception e) {
283            if (getLog().isDebugEnabled()) {
284                getLog().debug("Exception while watching for changes", e);
285            }
286            throw new MojoExecutionException("Exception while watching for changes", e);
287        } finally {
288            killProcess();
289            cleanup();
290        }
291    }
292
293    protected final void initialize() {
294        final MavenProject currentProject = mavenSession.getCurrentProject();
295        if (hasMicronautMavenPlugin(currentProject)) {
296            runnableProject = currentProject;
297        } else {
298            final List<MavenProject> projectsWithPlugin = mavenSession.getProjects().stream()
299                .filter(MojoUtils::hasMicronautMavenPlugin)
300                .toList();
301            if (projectsWithPlugin.size() == 1) {
302                runnableProject = projectsWithPlugin.get(0);
303                log.info("Running project %s".formatted(runnableProject.getArtifactId()));
304            } else {
305                throw new IllegalStateException("The Micronaut Maven Plugin is declared in the following projects: %s. Please specify the project to run with the -pl option."
306                    .formatted(projectsWithPlugin.stream().map(MavenProject::getArtifactId).toList()));
307            }
308        }
309        this.targetDirectory = new File(runnableProject.getBuild().getDirectory());
310        this.testResourcesHelper = new TestResourcesHelper(testResourcesEnabled, shared, buildDirectory, explicitPort,
311                clientTimeout, serverIdleTimeoutMinutes, runnableProject, mavenSession, dependencyResolutionService,
312                toolchainManager, testResourcesVersion, classpathInference, testResourcesDependencies,
313                sharedServerNamespace, debugServer, false, testResourcesSystemProperties);
314        resolveDependencies();
315        if (watches == null) {
316            watches = new ArrayList<>();
317        }
318        // watch pom.xml file changes
319        mavenSession.getAllProjects().stream()
320            .filter(this::isDependencyOfRunnableProject)
321            .map(MavenProject::getBasedir)
322            .map(File::toPath)
323            .forEach(path -> {
324                var fileSet = new FileSet();
325                fileSet.setDirectory(path.toString());
326                fileSet.addInclude("pom.xml");
327                watches.add(fileSet);
328            });
329        // Add the default watch paths
330        mavenSession.getAllProjects().stream()
331            .filter(this::isDependencyOfRunnableProject)
332            .flatMap(p -> {
333                var basedir = p.getBasedir().toPath();
334                return RELEVANT_SRC_DIRS.stream().map(dir -> basedir.resolve("src/main/" + dir));
335            })
336            .forEach(path -> {
337                var fileSet = new FileSet();
338                fileSet.setDirectory(path.toString());
339                fileSet.addInclude("**/*");
340                watches.add(fileSet);
341            });
342    }
343
344    private boolean isDependencyOfRunnableProject(MavenProject mavenProject) {
345        return mavenProject.equals(runnableProject) || runnableProject.getDependencies().stream()
346            .anyMatch(d -> d.getGroupId().equals(mavenProject.getGroupId()) && d.getArtifactId().equals(mavenProject.getArtifactId()));
347    }
348
349    protected final void setWatches(List<FileSet> watches) {
350        this.watches = watches;
351    }
352
353    final void handleEvent(DirectoryChangeEvent event) {
354        Path path = event.path();
355        Path parent = path.getParent();
356        Path projectRootDirectory = mavenSession.getTopLevelProject().getBasedir().toPath();
357
358        if (matches(path)) {
359            if (getLog().isInfoEnabled()) {
360                getLog().info(String.format("📝 Detected change in %s. Recompiling/restarting...", projectRootDirectory.relativize(path)));
361            }
362            boolean compiledOk = compileProject();
363            if (compiledOk) {
364                try {
365                    runApplication();
366                } catch (Exception e) {
367                    getLog().error("Unable to run application: " + e.getMessage(), e);
368                }
369            }
370        }
371    }
372
373    private boolean matches(Path path) {
374        // Apply default exclusions
375        if (isDefaultExcluded(path) || isDirectory(path, NOFOLLOW_LINKS) || !isReadable(path) || hasBeenCompiledRecently()) {
376            return false;
377        }
378
379        Path projectRootDirectory = mavenSession.getTopLevelProject().getBasedir().toPath();
380
381        String relativePath = projectRootDirectory.relativize(path).toString();
382
383        boolean matches = false;
384        for (FileSet fileSet : watches) {
385            if (fileSet.getIncludes() != null && !fileSet.getIncludes().isEmpty()) {
386                var directory = new File(fileSet.getDirectory());
387                if (directory.exists() && path.getParent().startsWith(directory.getAbsolutePath())) {
388                    for (String includePattern : fileSet.getIncludes()) {
389                        if (pathMatches(includePattern, path) || patternEquals(path, includePattern, directory)) {
390                            matches = true;
391                            if (getLog().isDebugEnabled()) {
392                                getLog().debug("Path [" + relativePath + "] matched the include pattern [" + includePattern + "] of the directory [" + fileSet.getDirectory() + "]");
393                            }
394                            break;
395                        }
396                    }
397                }
398            }
399            if (matches) {
400                break;
401            }
402        }
403
404        // Finally, process excludes only if the path is matching
405        if (matches) {
406            for (FileSet fileSet : watches) {
407                if (fileSet.getExcludes() != null && !fileSet.getExcludes().isEmpty()) {
408                    File directory = new File(fileSet.getDirectory());
409                    if (directory.exists() && path.getParent().startsWith(directory.getAbsolutePath())) {
410                        for (String excludePattern : fileSet.getExcludes()) {
411                            if (pathMatches(excludePattern, path) || patternEquals(path, excludePattern, directory)) {
412                                matches = false;
413                                if (getLog().isDebugEnabled()) {
414                                    getLog().debug("Path [" + relativePath + "] matched the exclude pattern [" + excludePattern + "] of the directory [" + fileSet.getDirectory() + "]");
415                                }
416                                break;
417                            }
418                        }
419                    }
420                }
421                if (!matches) {
422                    break;
423                }
424            }
425        }
426
427        return matches;
428    }
429
430    private boolean isDefaultExcluded(Path path) {
431        boolean excludeTargetDirectory = true;
432        if (this.watches != null && !this.watches.isEmpty()) {
433            for (FileSet fileSet : this.watches) {
434                if (fileSet.getDirectory().equals(this.targetDirectory.getName())) {
435                    excludeTargetDirectory = false;
436                }
437            }
438        }
439        return (excludeTargetDirectory && path.startsWith(targetDirectory.getAbsolutePath())) ||
440            DEFAULT_EXCLUDES.stream()
441                .anyMatch(excludePattern -> pathMatches(excludePattern, path));
442    }
443
444    private boolean hasBeenCompiledRecently() {
445        return (System.currentTimeMillis() - lastCompilation) < LAST_COMPILATION_THRESHOLD;
446    }
447
448    private void cleanup() {
449        if (getLog().isDebugEnabled()) {
450            getLog().debug("Cleaning up");
451        }
452        try {
453            directoryWatcher.close();
454            maybeStopTestResourcesServer();
455        } catch (Exception e) {
456            // Do nothing
457        }
458    }
459
460    private boolean rebuildMavenProject() {
461        boolean success = true;
462        try {
463            ProjectBuildingRequest projectBuildingRequest = mavenSession.getProjectBuildingRequest();
464            projectBuildingRequest.setResolveDependencies(true);
465            ProjectBuildingResult build = projectBuilder.build(runnableProject.getArtifact(), projectBuildingRequest);
466            MavenProject project = build.getProject();
467            runnableProject = project;
468            mavenSession.setCurrentProject(project);
469        } catch (ProjectBuildingException e) {
470            success = false;
471            if (getLog().isWarnEnabled()) {
472                getLog().warn("Error while trying to build the Maven project model", e);
473            }
474        }
475        return success;
476    }
477
478    private boolean resolveDependencies() {
479        try {
480            List<Dependency> dependencies = compilerService.resolveDependencies(runnableProject, true, JavaScopes.PROVIDED, JavaScopes.COMPILE, JavaScopes.RUNTIME);
481            if (dependencies.isEmpty()) {
482                return false;
483            } else {
484                this.classpath = compilerService.buildClasspath(dependencies);
485                return true;
486            }
487        } finally {
488            if (classpath != null) {
489                this.classpathHash = this.classpath.hashCode();
490            }
491        }
492    }
493
494    private boolean classpathHasChanged() {
495        int oldClasspathHash = this.classpathHash;
496        this.classpathHash = this.classpath.hashCode();
497        return oldClasspathHash != classpathHash;
498
499    }
500
501    /**
502     * Runs or restarts the application. Only visible for testing, shouldn't
503     * be called directly.
504     *
505     * @throws Exception if something goes wrong while starting the application
506     */
507    protected void runApplication() throws Exception {
508        if (restartLock.getQueueLength() >= 1) {
509            // if there's more than one restart request, we'll handle them all at once
510            return;
511        }
512        restartLock.lock();
513        try {
514            runAotIfNeeded();
515            final String reactorClasses = mavenSession.getAllProjects().stream()
516                    .filter(this::isDependencyOfRunnableProject)
517                    .map(MavenProject::getBuild)
518                    .map(Build::getOutputDirectory)
519                    .collect(Collectors.joining(File.pathSeparator));
520            String classpathArgument = String.join(File.pathSeparator, reactorClasses, this.classpath);
521
522            var args = new ArrayList<String>();
523            args.add(javaExecutable);
524
525            if (debug) {
526                String suspend = debugSuspend ? "y" : "n";
527                args.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=" + suspend + ",address=" + debugHost + ":" + debugPort);
528            }
529
530            if (testResourcesEnabled) {
531                Path testResourcesSettingsDirectory = shared ? ServerUtils.getDefaultSharedSettingsPath(sharedServerNamespace) :
532                    AbstractTestResourcesMojo.serverSettingsDirectoryOf(targetDirectory.toPath());
533                Optional<ServerSettings> serverSettings = ServerUtils.readServerSettings(testResourcesSettingsDirectory);
534                serverSettings.ifPresent(settings -> testResourcesHelper.computeSystemProperties(settings)
535                    .forEach((k, v) -> args.add("-D" + k + "=" + v)));
536            }
537
538            if (jvmArguments != null && !jvmArguments.isEmpty()) {
539                final String[] strings = CommandLineUtils.translateCommandline(jvmArguments);
540                args.addAll(Arrays.asList(strings));
541            }
542
543            if (!mavenSession.getUserProperties().isEmpty()) {
544                mavenSession.getUserProperties().forEach((k, v) -> args.add("-D" + k + "=" + v));
545            }
546
547            if (mainClass == null) {
548                mainClass = runnableProject.getProperties().getProperty("exec.mainClass");
549            }
550
551            args.add("-classpath");
552            args.add(classpathArgument);
553            args.add("-XX:TieredStopAtLevel=1");
554            args.add("-Dcom.sun.management.jmxremote");
555            args.add(mainClass);
556
557            if (appArguments != null && !appArguments.isEmpty()) {
558                final String[] strings = CommandLineUtils.translateCommandline(appArguments);
559                args.addAll(Arrays.asList(strings));
560            }
561
562            if (getLog().isDebugEnabled()) {
563                getLog().debug("Running " + String.join(" ", args));
564            }
565
566            killProcess();
567            process = new ProcessBuilder(args)
568                .inheritIO()
569                .directory(targetDirectory)
570                .start();
571        } finally {
572            restartLock.unlock();
573        }
574    }
575
576    private void runAotIfNeeded() {
577        if (aotEnabled) {
578            try {
579                executorService.executeGoal(THIS_PLUGIN, AotAnalysisMojo.NAME);
580            } catch (MojoExecutionException e) {
581                getLog().error(e.getMessage());
582            }
583        }
584    }
585
586    private void maybeStartTestResourcesServer() throws MojoExecutionException {
587        testResourcesHelper.start();
588    }
589
590    private void maybeStopTestResourcesServer() throws MojoExecutionException {
591        testResourcesHelper.stop();
592    }
593
594    private boolean compileProject() {
595        // There can be multiple changes detected at the same time, so we want
596        // to keep only one compilation request
597        if (recompileRequested.get()) {
598            return false;
599        }
600        recompileRequested.set(true);
601        try {
602            return doCompile();
603        } finally {
604            recompileRequested.set(false);
605        }
606    }
607
608    private boolean doCompile() {
609        Optional<Long> lastCompilationMillis = compilerService.compileProject();
610        lastCompilationMillis.ifPresent(lc -> this.lastCompilation = lc);
611        return lastCompilationMillis.isPresent();
612    }
613
614    private void killProcess() {
615        if (process != null && process.isAlive()) {
616            if (getLog().isDebugEnabled()) {
617                getLog().debug("Stopping the background process");
618            }
619            process.destroy();
620            try {
621                process.waitFor();
622            } catch (InterruptedException e) {
623                process.destroyForcibly();
624                Thread.currentThread().interrupt();
625            }
626        }
627    }
628
629    private static String normalize(Path path) {
630        return path.toString().replace('\\', '/');
631    }
632
633    private static boolean pathMatches(String pattern, Path path) {
634        return AbstractScanner.match(pattern, normalize(path));
635    }
636
637    private static boolean patternEquals(Path path, String includePattern, File directory) {
638        try {
639            var testPath = normalize(directory.toPath().resolve(includePattern).toAbsolutePath());
640            return testPath.equals(normalize(path.toAbsolutePath()));
641        } catch (InvalidPathException ex) {
642            return false;
643        }
644    }
645
646}