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 java.nio.file.Files.isDirectory;
066import static java.nio.file.Files.isReadable;
067import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
068
069/**
070 * <p>Executes a Micronaut application in development mode.</p>
071 *
072 * <p>It watches for changes in the project tree. If there are changes in the {@code pom.xml} file, dependencies will be reloaded. If
073 * the changes are anywhere underneath {@code src/main}, it will recompile the project and restart the application.</p>
074 *
075 * <p>The plugin can handle changes in all the languages supported by Micronaut: Java, Kotlin and Groovy.</p>
076 *
077 * @author Álvaro Sánchez-Mariscal
078 * @since 1.0.0
079 */
080@SuppressWarnings("unused")
081@Mojo(name = "run", requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, defaultPhase = LifecyclePhase.PREPARE_PACKAGE, aggregator = true)
082@Execute(phase = LifecyclePhase.PROCESS_CLASSES)
083public class RunMojo extends AbstractTestResourcesMojo {
084
085    public static final String MN_APP_ARGS = "mn.appArgs";
086    public static final String EXEC_MAIN_CLASS = "${exec.mainClass}";
087    public static final String RESOURCES_DIR = "src/main/resources";
088    public static final String THIS_PLUGIN = "io.micronaut.maven:micronaut-maven-plugin";
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        compileProject();
344    }
345
346    private boolean isDependencyOfRunnableProject(MavenProject mavenProject) {
347        return mavenProject.equals(runnableProject) || runnableProject.getDependencies().stream()
348            .anyMatch(d -> d.getGroupId().equals(mavenProject.getGroupId()) && d.getArtifactId().equals(mavenProject.getArtifactId()));
349    }
350
351    protected final void setWatches(List<FileSet> watches) {
352        this.watches = watches;
353    }
354
355    final void handleEvent(DirectoryChangeEvent event) {
356        Path path = event.path();
357        Path parent = path.getParent();
358        Path projectRootDirectory = mavenSession.getTopLevelProject().getBasedir().toPath();
359
360        if (matches(path)) {
361            if (getLog().isInfoEnabled()) {
362                getLog().info(String.format("📝 Detected change in %s. Recompiling/restarting...", projectRootDirectory.relativize(path)));
363            }
364            boolean compiledOk = compileProject();
365            if (compiledOk) {
366                try {
367                    runApplication();
368                } catch (Exception e) {
369                    getLog().error("Unable to run application: " + e.getMessage(), e);
370                }
371            }
372        }
373    }
374
375    private boolean matches(Path path) {
376        // Apply default exclusions
377        if (isDefaultExcluded(path) || isDirectory(path, NOFOLLOW_LINKS) || !isReadable(path) || hasBeenCompiledRecently()) {
378            return false;
379        }
380
381        Path projectRootDirectory = mavenSession.getTopLevelProject().getBasedir().toPath();
382
383        String relativePath = projectRootDirectory.relativize(path).toString();
384
385        boolean matches = false;
386        for (FileSet fileSet : watches) {
387            if (fileSet.getIncludes() != null && !fileSet.getIncludes().isEmpty()) {
388                var directory = new File(fileSet.getDirectory());
389                if (directory.exists() && path.getParent().startsWith(directory.getAbsolutePath())) {
390                    for (String includePattern : fileSet.getIncludes()) {
391                        if (pathMatches(includePattern, path) || patternEquals(path, includePattern, directory)) {
392                            matches = true;
393                            if (getLog().isDebugEnabled()) {
394                                getLog().debug("Path [" + relativePath + "] matched the include pattern [" + includePattern + "] of the directory [" + fileSet.getDirectory() + "]");
395                            }
396                            break;
397                        }
398                    }
399                }
400            }
401            if (matches) {
402                break;
403            }
404        }
405
406        // Finally, process excludes only if the path is matching
407        if (matches) {
408            for (FileSet fileSet : watches) {
409                if (fileSet.getExcludes() != null && !fileSet.getExcludes().isEmpty()) {
410                    File directory = new File(fileSet.getDirectory());
411                    if (directory.exists() && path.getParent().startsWith(directory.getAbsolutePath())) {
412                        for (String excludePattern : fileSet.getExcludes()) {
413                            if (pathMatches(excludePattern, path) || patternEquals(path, excludePattern, directory)) {
414                                matches = false;
415                                if (getLog().isDebugEnabled()) {
416                                    getLog().debug("Path [" + relativePath + "] matched the exclude pattern [" + excludePattern + "] of the directory [" + fileSet.getDirectory() + "]");
417                                }
418                                break;
419                            }
420                        }
421                    }
422                }
423                if (!matches) {
424                    break;
425                }
426            }
427        }
428
429        return matches;
430    }
431
432    private boolean isDefaultExcluded(Path path) {
433        boolean excludeTargetDirectory = true;
434        if (this.watches != null && !this.watches.isEmpty()) {
435            for (FileSet fileSet : this.watches) {
436                if (fileSet.getDirectory().equals(this.targetDirectory.getName())) {
437                    excludeTargetDirectory = false;
438                }
439            }
440        }
441        return (excludeTargetDirectory && path.startsWith(targetDirectory.getAbsolutePath())) ||
442            DEFAULT_EXCLUDES.stream()
443                .anyMatch(excludePattern -> pathMatches(excludePattern, path));
444    }
445
446    private boolean hasBeenCompiledRecently() {
447        return (System.currentTimeMillis() - lastCompilation) < LAST_COMPILATION_THRESHOLD;
448    }
449
450    private void cleanup() {
451        if (getLog().isDebugEnabled()) {
452            getLog().debug("Cleaning up");
453        }
454        try {
455            directoryWatcher.close();
456            maybeStopTestResourcesServer();
457        } catch (Exception e) {
458            // Do nothing
459        }
460    }
461
462    private boolean rebuildMavenProject() {
463        boolean success = true;
464        try {
465            ProjectBuildingRequest projectBuildingRequest = mavenSession.getProjectBuildingRequest();
466            projectBuildingRequest.setResolveDependencies(true);
467            ProjectBuildingResult build = projectBuilder.build(runnableProject.getArtifact(), projectBuildingRequest);
468            MavenProject project = build.getProject();
469            runnableProject = project;
470            mavenSession.setCurrentProject(project);
471        } catch (ProjectBuildingException e) {
472            success = false;
473            if (getLog().isWarnEnabled()) {
474                getLog().warn("Error while trying to build the Maven project model", e);
475            }
476        }
477        return success;
478    }
479
480    private boolean resolveDependencies() {
481        try {
482            List<Dependency> dependencies = compilerService.resolveDependencies(runnableProject, JavaScopes.PROVIDED, JavaScopes.COMPILE, JavaScopes.RUNTIME);
483            if (dependencies.isEmpty()) {
484                return false;
485            } else {
486                this.classpath = compilerService.buildClasspath(dependencies);
487                return true;
488            }
489        } finally {
490            if (classpath != null) {
491                this.classpathHash = this.classpath.hashCode();
492            }
493        }
494    }
495
496    private boolean classpathHasChanged() {
497        int oldClasspathHash = this.classpathHash;
498        this.classpathHash = this.classpath.hashCode();
499        return oldClasspathHash != classpathHash;
500
501    }
502
503    /**
504     * Runs or restarts the application. Only visible for testing, shouldn't
505     * be called directly.
506     *
507     * @throws Exception if something goes wrong while starting the application
508     */
509    protected void runApplication() throws Exception {
510        if (restartLock.getQueueLength() >= 1) {
511            // if there's more than one restart request, we'll handle them all at once
512            return;
513        }
514        restartLock.lock();
515        try {
516            runAotIfNeeded();
517            final String reactorClasses = mavenSession.getAllProjects().stream()
518                    .map(MavenProject::getBuild)
519                    .map(Build::getOutputDirectory)
520                    .collect(Collectors.joining(File.pathSeparator));
521            String classpathArgument = String.join(File.pathSeparator, reactorClasses, this.classpath);
522
523            var args = new ArrayList<String>();
524            args.add(javaExecutable);
525
526            if (debug) {
527                String suspend = debugSuspend ? "y" : "n";
528                args.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=" + suspend + ",address=" + debugHost + ":" + debugPort);
529            }
530
531            if (testResourcesEnabled) {
532                Path testResourcesSettingsDirectory = shared ? ServerUtils.getDefaultSharedSettingsPath(sharedServerNamespace) :
533                    AbstractTestResourcesMojo.serverSettingsDirectoryOf(targetDirectory.toPath());
534                Optional<ServerSettings> serverSettings = ServerUtils.readServerSettings(testResourcesSettingsDirectory);
535                serverSettings.ifPresent(settings -> testResourcesHelper.computeSystemProperties(settings)
536                    .forEach((k, v) -> args.add("-D" + k + "=" + v)));
537            }
538
539            if (jvmArguments != null && !jvmArguments.isEmpty()) {
540                final String[] strings = CommandLineUtils.translateCommandline(jvmArguments);
541                args.addAll(Arrays.asList(strings));
542            }
543
544            if (!mavenSession.getUserProperties().isEmpty()) {
545                mavenSession.getUserProperties().forEach((k, v) -> args.add("-D" + k + "=" + v));
546            }
547
548            if (mainClass == null) {
549                mainClass = runnableProject.getProperties().getProperty("exec.mainClass");
550            }
551
552            args.add("-classpath");
553            args.add(classpathArgument);
554            args.add("-XX:TieredStopAtLevel=1");
555            args.add("-Dcom.sun.management.jmxremote");
556            args.add(mainClass);
557
558            if (appArguments != null && !appArguments.isEmpty()) {
559                final String[] strings = CommandLineUtils.translateCommandline(appArguments);
560                args.addAll(Arrays.asList(strings));
561            }
562
563            if (getLog().isDebugEnabled()) {
564                getLog().debug("Running " + String.join(" ", args));
565            }
566
567            killProcess();
568            process = new ProcessBuilder(args)
569                .inheritIO()
570                .directory(targetDirectory)
571                .start();
572        } finally {
573            restartLock.unlock();
574        }
575    }
576
577    private void runAotIfNeeded() {
578        if (aotEnabled) {
579            try {
580                executorService.executeGoal(THIS_PLUGIN, AotAnalysisMojo.NAME);
581            } catch (MojoExecutionException e) {
582                getLog().error(e.getMessage());
583            }
584        }
585    }
586
587    private void maybeStartTestResourcesServer() throws MojoExecutionException {
588        testResourcesHelper.start();
589    }
590
591    private void maybeStopTestResourcesServer() throws MojoExecutionException {
592        testResourcesHelper.stop(false);
593    }
594
595    private boolean compileProject() {
596        // There can be multiple changes detected at the same time, so we want
597        // to keep only one compilation request
598        if (recompileRequested.get()) {
599            return false;
600        }
601        recompileRequested.set(true);
602        try {
603            return doCompile();
604        } finally {
605            recompileRequested.set(false);
606        }
607    }
608
609    private boolean doCompile() {
610        Optional<Long> lastCompilationMillis = compilerService.compileProject();
611        lastCompilationMillis.ifPresent(lc -> this.lastCompilation = lc);
612        return lastCompilationMillis.isPresent();
613    }
614
615    private void killProcess() {
616        if (process != null && process.isAlive()) {
617            if (getLog().isDebugEnabled()) {
618                getLog().debug("Stopping the background process");
619            }
620            process.destroy();
621            try {
622                process.waitFor();
623            } catch (InterruptedException e) {
624                process.destroyForcibly();
625                Thread.currentThread().interrupt();
626            }
627        }
628    }
629
630    private static String normalize(Path path) {
631        return path.toString().replace('\\', '/');
632    }
633
634    private static boolean pathMatches(String pattern, Path path) {
635        return AbstractScanner.match(pattern, normalize(path));
636    }
637
638    private static boolean patternEquals(Path path, String includePattern, File directory) {
639        try {
640            var testPath = normalize(directory.toPath().resolve(includePattern).toAbsolutePath());
641            return testPath.equals(normalize(path.toAbsolutePath()));
642        } catch (InvalidPathException ex) {
643            return false;
644        }
645    }
646
647}