langtools/test/tools/javac/modules/AnnotationProcessing.java
changeset 42815 050370edaade
parent 42407 f3702cff2933
child 42823 58864b03c7b9
--- a/langtools/test/tools/javac/modules/AnnotationProcessing.java	Tue Dec 13 10:48:18 2016 +0100
+++ b/langtools/test/tools/javac/modules/AnnotationProcessing.java	Tue Dec 13 10:49:28 2016 +0100
@@ -33,23 +33,35 @@
  * @run main AnnotationProcessing
  */
 
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.io.Writer;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.Arrays;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.concurrent.Callable;
 import java.util.stream.Collectors;
 
 import javax.annotation.processing.AbstractProcessor;
+import javax.annotation.processing.Filer;
+import javax.annotation.processing.FilerException;
 import javax.annotation.processing.Messager;
+import javax.annotation.processing.ProcessingEnvironment;
 import javax.annotation.processing.RoundEnvironment;
 import javax.annotation.processing.SupportedAnnotationTypes;
 import javax.annotation.processing.SupportedOptions;
 import javax.lang.model.SourceVersion;
 import javax.lang.model.element.Element;
+import javax.lang.model.element.ElementKind;
 import javax.lang.model.element.ModuleElement;
 import javax.lang.model.element.ModuleElement.ProvidesDirective;
 import javax.lang.model.element.ModuleElement.UsesDirective;
@@ -60,10 +72,20 @@
 import javax.lang.model.util.ElementFilter;
 import javax.lang.model.util.ElementScanner9;
 import javax.tools.Diagnostic.Kind;
+import javax.tools.FileObject;
+import javax.tools.JavaCompiler;
+import javax.tools.JavaCompiler.CompilationTask;
+import javax.tools.JavaFileManager;
+import javax.tools.JavaFileManager.Location;
+import javax.tools.JavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
 
 import toolbox.JavacTask;
 import toolbox.Task;
 import toolbox.Task.Mode;
+import toolbox.Task.OutputKind;
 
 public class AnnotationProcessing extends ModuleTestBase {
 
@@ -135,6 +157,7 @@
     public static final class AP extends AbstractProcessor {
 
         private Map<String, List<String>> module2ExpectedEnclosedElements;
+        private Set<String> seenModules = new HashSet<>();
 
         @Override
         public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
@@ -181,12 +204,16 @@
                               .map(p -> p.getQualifiedName().toString())
                               .collect(Collectors.toList());
 
-                assertEquals(module2ExpectedEnclosedElements.remove(module.getQualifiedName().toString()),
+                String moduleName = module.getQualifiedName().toString();
+
+                assertEquals(module2ExpectedEnclosedElements.get(moduleName),
                              actualElements);
+
+                seenModules.add(moduleName);
             }
 
             if (roundEnv.processingOver()) {
-                assertEquals(true, module2ExpectedEnclosedElements.isEmpty());
+                assertEquals(module2ExpectedEnclosedElements.keySet(), seenModules);
             }
 
             return false;
@@ -374,6 +401,617 @@
 
     }
 
+    @Test
+    public void testModuleInRootElements(Path base) throws Exception {
+        Path moduleSrc = base.resolve("module-src");
+        Path m1 = moduleSrc.resolve("m1");
+
+        Path classes = base.resolve("classes");
+
+        Files.createDirectories(classes);
+
+        tb.writeJavaFiles(m1,
+                          "module m1 { exports api; }",
+                          "package api; public class Api { }");
+
+        List<String> log = new JavacTask(tb)
+                .options("-processor", ModuleInRootElementsAP.class.getName())
+                .outdir(classes)
+                .files(findJavaFiles(moduleSrc))
+                .run()
+                .writeAll()
+                .getOutputLines(Task.OutputKind.STDERR);
+
+        assertEquals(Arrays.asList("module: m1"), log);
+    }
+
+    @SupportedAnnotationTypes("*")
+    public static final class ModuleInRootElementsAP extends AbstractProcessor {
+
+        @Override
+        public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+            roundEnv.getRootElements()
+                    .stream()
+                    .filter(el -> el.getKind() == ElementKind.MODULE)
+                    .forEach(mod -> System.err.println("module: " + mod.getSimpleName()));
+
+            return false;
+        }
+
+        @Override
+        public SourceVersion getSupportedSourceVersion() {
+            return SourceVersion.latest();
+        }
+
+    }
+
+    @Test
+    public void testAnnotationsInModuleInfo(Path base) throws Exception {
+        Path moduleSrc = base.resolve("module-src");
+        Path m1 = moduleSrc.resolve("m1");
+
+        tb.writeJavaFiles(m1,
+                          "@Deprecated module m1 { }");
+
+        Path m2 = moduleSrc.resolve("m2");
+
+        tb.writeJavaFiles(m2,
+                          "@SuppressWarnings(\"\") module m2 { }");
+
+        Path classes = base.resolve("classes");
+
+        Files.createDirectories(classes);
+
+        List<String> log = new JavacTask(tb)
+                .options("-processor", AnnotationsInModuleInfoPrint.class.getName())
+                .outdir(classes)
+                .files(findJavaFiles(m1))
+                .run()
+                .writeAll()
+                .getOutputLines(Task.OutputKind.DIRECT);
+
+        List<String> expectedLog = Arrays.asList("Note: AP Invoked",
+                                                 "Note: AP Invoked");
+
+        assertEquals(expectedLog, log);
+
+        new JavacTask(tb)
+            .options("-processor", AnnotationsInModuleInfoFail.class.getName())
+            .outdir(classes)
+            .files(findJavaFiles(m2))
+            .run()
+            .writeAll();
+    }
+
+    @SupportedAnnotationTypes("java.lang.Deprecated")
+    public static final class AnnotationsInModuleInfoPrint extends AbstractProcessor {
+
+        @Override
+        public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+            processingEnv.getMessager().printMessage(Kind.NOTE, "AP Invoked");
+            return false;
+        }
+
+        @Override
+        public SourceVersion getSupportedSourceVersion() {
+            return SourceVersion.latest();
+        }
+
+    }
+
+    @SupportedAnnotationTypes("java.lang.Deprecated")
+    public static final class AnnotationsInModuleInfoFail extends AbstractProcessor {
+
+        @Override
+        public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+            throw new AssertionError();
+        }
+
+        @Override
+        public SourceVersion getSupportedSourceVersion() {
+            return SourceVersion.latest();
+        }
+
+    }
+
+    @Test
+    public void testGenerateInMultiModeAPI(Path base) throws Exception {
+        Path moduleSrc = base.resolve("module-src");
+        Path classes = base.resolve("classes");
+
+        Files.createDirectories(classes);
+
+        Path m1 = moduleSrc.resolve("m1");
+
+        tb.writeJavaFiles(m1,
+                          "module m1 { exports api1; }",
+                          "package api1; public class Api { GenApi ga; impl.Impl i; }");
+
+        writeFile("1", m1, "api1", "api");
+        writeFile("1", m1, "impl", "impl");
+
+        Path m2 = moduleSrc.resolve("m2");
+
+        tb.writeJavaFiles(m2,
+                          "module m2 { requires m1; exports api2; }",
+                          "package api2; public class Api { api1.GenApi ga1; GenApi qa2; impl.Impl i;}");
+
+        writeFile("2", m2, "api2", "api");
+        writeFile("2", m2, "impl", "impl");
+
+        for (FileType fileType : FileType.values()) {
+            if (Files.isDirectory(classes)) {
+                tb.cleanDirectory(classes);
+            } else {
+                Files.createDirectories(classes);
+            }
+
+            new JavacTask(tb)
+              .options("-processor", MultiModeAPITestAP.class.getName(),
+                       "--module-source-path", moduleSrc.toString(),
+                       "-Afiletype=" + fileType.name())
+              .outdir(classes)
+              .files(findJavaFiles(moduleSrc))
+              .run()
+              .writeAll();
+
+            assertFileExists(classes, "m1", "api1", "GenApi.class");
+            assertFileExists(classes, "m1", "impl", "Impl.class");
+            assertFileExists(classes, "m1", "api1", "gen1");
+            assertFileExists(classes, "m2", "api2", "GenApi.class");
+            assertFileExists(classes, "m2", "impl", "Impl.class");
+            assertFileExists(classes, "m2", "api2", "gen1");
+        }
+    }
+
+    enum FileType {
+        SOURCE,
+        CLASS;
+    }
+
+    public static abstract class GeneratingAP extends AbstractProcessor {
+
+        void createSource(CreateFileObject file, String name, String content) {
+            try (Writer out = file.create().openWriter()) {
+                out.write(content);
+            } catch (IOException ex) {
+                throw new IllegalStateException(ex);
+            }
+        }
+
+        void createClass(CreateFileObject file, String name, String content) {
+            String fileNameStub = name.replace(".", File.separator);
+
+            try (OutputStream out = file.create().openOutputStream()) {
+                Path scratch = Files.createDirectories(Paths.get(""));
+                Path scratchSrc = scratch.resolve(fileNameStub + ".java").toAbsolutePath();
+
+                Files.createDirectories(scratchSrc.getParent());
+
+                try (Writer w = Files.newBufferedWriter(scratchSrc)) {
+                    w.write(content);
+                }
+
+                Path scratchClasses = scratch.resolve("classes");
+
+                Files.createDirectories(scratchClasses);
+
+                JavaCompiler comp = ToolProvider.getSystemJavaCompiler();
+                try (StandardJavaFileManager fm = comp.getStandardFileManager(null, null, null)) {
+                    List<String> options = Arrays.asList("-d", scratchClasses.toString());
+                    Iterable<? extends JavaFileObject> files = fm.getJavaFileObjects(scratchSrc);
+                    CompilationTask task = comp.getTask(null, fm, null, options, null, files);
+
+                    if (!task.call()) {
+                        throw new AssertionError("compilation failed");
+                    }
+                }
+
+                Path classfile = scratchClasses.resolve(fileNameStub + ".class");
+
+                Files.copy(classfile, out);
+            } catch (IOException ex) {
+                throw new IllegalStateException(ex);
+            }
+        }
+
+        void doReadResource(CreateFileObject file, String expectedContent) {
+            try {
+                StringBuilder actualContent = new StringBuilder();
+
+                try (Reader r = file.create().openReader(true)) {
+                    int read;
+
+                    while ((read = r.read()) != (-1)) {
+                        actualContent.append((char) read);
+                    }
+
+                }
+
+                assertEquals(expectedContent, actualContent.toString());
+            } catch (IOException ex) {
+                throw new IllegalStateException(ex);
+            }
+        }
+
+        public interface CreateFileObject {
+            public FileObject create() throws IOException;
+        }
+
+        void expectFilerException(Callable<Object> c) {
+            try {
+                c.call();
+                throw new AssertionError("Expected exception not thrown");
+            } catch (FilerException ex) {
+                //expected
+            } catch (Exception ex) {
+                throw new IllegalStateException(ex);
+            }
+        }
+
+        @Override
+        public SourceVersion getSupportedSourceVersion() {
+            return SourceVersion.latest();
+        }
+
+    }
+
+    @SupportedAnnotationTypes("*")
+    @SupportedOptions({"filetype", "modulename"})
+    public static final class MultiModeAPITestAP extends GeneratingAP {
+
+        int round;
+
+        @Override
+        public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+            if (round++ != 0)
+                return false;
+
+            createClass("m1", "api1.GenApi", "package api1; public class GenApi {}");
+            createClass("m1", "impl.Impl", "package impl; public class Impl {}");
+            createClass("m2", "api2.GenApi", "package api2; public class GenApi {}");
+            createClass("m2", "impl.Impl", "package impl; public class Impl {}");
+
+            createResource("m1", "api1", "gen1");
+            createResource("m2", "api2", "gen1");
+
+            readResource("m1", "api1", "api", "1");
+            readResource("m1", "impl", "impl", "1");
+            readResource("m2", "api2", "api", "2");
+            readResource("m2", "impl", "impl", "2");
+
+            Filer filer = processingEnv.getFiler();
+
+            expectFilerException(() -> filer.createSourceFile("fail.Fail"));
+            expectFilerException(() -> filer.createClassFile("fail.Fail"));
+            expectFilerException(() -> filer.createResource(StandardLocation.CLASS_OUTPUT, "fail", "fail"));
+            expectFilerException(() -> filer.getResource(StandardLocation.MODULE_SOURCE_PATH, "fail", "fail"));
+
+            //must not generate to unnamed package:
+            expectFilerException(() -> filer.createSourceFile("m1/Fail"));
+            expectFilerException(() -> filer.createClassFile("m1/Fail"));
+
+            //cannot generate resources to modules that are not root modules:
+            expectFilerException(() -> filer.createSourceFile("java.base/fail.Fail"));
+            expectFilerException(() -> filer.createClassFile("java.base/fail.Fail"));
+            expectFilerException(() -> filer.createResource(StandardLocation.CLASS_OUTPUT, "java.base/fail", "Fail"));
+
+            return false;
+        }
+
+        void createClass(String expectedModule, String name, String content) {
+            Filer filer = processingEnv.getFiler();
+            FileType filetype = FileType.valueOf(processingEnv.getOptions().getOrDefault("filetype", ""));
+
+            switch (filetype) {
+                case SOURCE:
+                    createSource(() -> filer.createSourceFile(expectedModule + "/" + name), name, content);
+                    break;
+                case CLASS:
+                    createClass(() -> filer.createClassFile(expectedModule + "/" + name), name, content);
+                    break;
+                default:
+                    throw new AssertionError("Unexpected filetype: " + filetype);
+            }
+        }
+
+        void createResource(String expectedModule, String pkg, String relName) {
+            try {
+                Filer filer = processingEnv.getFiler();
+
+                filer.createResource(StandardLocation.CLASS_OUTPUT, expectedModule + "/" + pkg, relName)
+                     .openOutputStream()
+                     .close();
+            } catch (IOException ex) {
+                throw new IllegalStateException(ex);
+            }
+        }
+
+        void readResource(String expectedModule, String pkg, String relName, String expectedContent) {
+            Filer filer = processingEnv.getFiler();
+
+            doReadResource(() -> filer.getResource(StandardLocation.MODULE_SOURCE_PATH, expectedModule + "/" + pkg, relName),
+                           expectedContent);
+        }
+
+    }
+
+    @Test
+    public void testGenerateInSingleNameModeAPI(Path base) throws Exception {
+        Path classes = base.resolve("classes");
+
+        Files.createDirectories(classes);
+
+        Path m1 = base.resolve("module-src");
+
+        tb.writeJavaFiles(m1,
+                          "module m1 { }");
+
+        writeFile("3", m1, "impl", "resource");
+
+        new JavacTask(tb)
+          .options("-processor", SingleNameModeAPITestAP.class.getName(),
+                   "-sourcepath", m1.toString())
+          .outdir(classes)
+          .files(findJavaFiles(m1))
+          .run()
+          .writeAll();
+
+        assertFileExists(classes, "impl", "Impl1.class");
+        assertFileExists(classes, "impl", "Impl2.class");
+        assertFileExists(classes, "impl", "Impl3");
+        assertFileExists(classes, "impl", "Impl4.class");
+        assertFileExists(classes, "impl", "Impl5.class");
+        assertFileExists(classes, "impl", "Impl6");
+        assertFileExists(classes, "impl", "Impl7.class");
+        assertFileExists(classes, "impl", "Impl8.class");
+        assertFileExists(classes, "impl", "Impl9");
+    }
+
+
+    @SupportedAnnotationTypes("*")
+    public static final class SingleNameModeAPITestAP extends GeneratingAP {
+
+        int round;
+
+        @Override
+        public synchronized void init(ProcessingEnvironment processingEnv) {
+            super.init(processingEnv);
+        }
+
+        @Override
+        public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+            if (round++ != 0)
+                return false;
+
+            Filer filer = processingEnv.getFiler();
+
+            createSource(() -> filer.createSourceFile("impl.Impl1"), "impl.Impl1", "package impl; class Impl1 {}");
+            createClass(() -> filer.createClassFile("impl.Impl2"), "impl.Impl2", "package impl; class Impl2 {}");
+            createSource(() -> filer.createResource(StandardLocation.CLASS_OUTPUT, "impl", "Impl3"), "impl.Impl3", "");
+            doReadResource(() -> filer.getResource(StandardLocation.SOURCE_PATH, "impl", "resource"), "3");
+
+            createSource(() -> filer.createSourceFile("m1/impl.Impl4"), "impl.Impl4", "package impl; class Impl4 {}");
+            createClass(() -> filer.createClassFile("m1/impl.Impl5"), "impl.Impl5", "package impl; class Impl5 {}");
+            createSource(() -> filer.createResource(StandardLocation.CLASS_OUTPUT, "m1/impl", "Impl6"), "impl.Impl6", "");
+            doReadResource(() -> filer.getResource(StandardLocation.SOURCE_PATH, "m1/impl", "resource"), "3");
+
+            TypeElement jlObject = processingEnv.getElementUtils().getTypeElement("java.lang.Object");
+
+            //"broken" originating element:
+            createSource(() -> filer.createSourceFile("impl.Impl7", jlObject), "impl.Impl7", "package impl; class Impl7 {}");
+            createClass(() -> filer.createClassFile("impl.Impl8", jlObject), "impl.Impl8", "package impl; class Impl8 {}");
+            createSource(() -> filer.createResource(StandardLocation.CLASS_OUTPUT, "impl", "Impl9", jlObject), "impl.Impl9", "");
+
+            //must not generate to unnamed package:
+            expectFilerException(() -> filer.createSourceFile("Fail"));
+            expectFilerException(() -> filer.createClassFile("Fail"));
+            expectFilerException(() -> filer.createSourceFile("m1/Fail"));
+            expectFilerException(() -> filer.createClassFile("m1/Fail"));
+
+            //cannot generate resources to modules that are not root modules:
+            expectFilerException(() -> filer.createSourceFile("java.base/fail.Fail"));
+            expectFilerException(() -> filer.createClassFile("java.base/fail.Fail"));
+            expectFilerException(() -> filer.createResource(StandardLocation.CLASS_OUTPUT, "java.base/fail", "Fail"));
+
+            return false;
+        }
+
+    }
+
+    @Test
+    public void testGenerateInUnnamedModeAPI(Path base) throws Exception {
+        Path classes = base.resolve("classes");
+
+        Files.createDirectories(classes);
+
+        Path src = base.resolve("src");
+
+        tb.writeJavaFiles(src,
+                          "class T {}");
+
+        new JavacTask(tb)
+          .options("-processor", UnnamedModeAPITestAP.class.getName(),
+                   "-sourcepath", src.toString())
+          .outdir(classes)
+          .files(findJavaFiles(src))
+          .run()
+          .writeAll();
+
+        assertFileExists(classes, "Impl1.class");
+        assertFileExists(classes, "Impl2.class");
+    }
+
+    @Test
+    public void testGenerateInNoModeAPI(Path base) throws Exception {
+        Path classes = base.resolve("classes");
+
+        Files.createDirectories(classes);
+
+        Path src = base.resolve("src");
+
+        tb.writeJavaFiles(src,
+                          "class T {}");
+
+        new JavacTask(tb)
+          .options("-processor", UnnamedModeAPITestAP.class.getName(),
+                   "-source", "8", "-target", "8",
+                   "-sourcepath", src.toString())
+          .outdir(classes)
+          .files(findJavaFiles(src))
+          .run()
+          .writeAll();
+
+        assertFileExists(classes, "Impl1.class");
+        assertFileExists(classes, "Impl2.class");
+    }
+
+    @SupportedAnnotationTypes("*")
+    public static final class UnnamedModeAPITestAP extends GeneratingAP {
+
+        int round;
+
+        @Override
+        public synchronized void init(ProcessingEnvironment processingEnv) {
+            super.init(processingEnv);
+        }
+
+        @Override
+        public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+            if (round++ != 0)
+                return false;
+
+            Filer filer = processingEnv.getFiler();
+
+            //must not generate to unnamed package:
+            createSource(() -> filer.createSourceFile("Impl1"), "Impl1", "class Impl1 {}");
+            createClass(() -> filer.createClassFile("Impl2"), "Impl2", "class Impl2 {}");
+
+            return false;
+        }
+
+    }
+
+    @Test
+    public void testDisambiguateAnnotations(Path base) throws Exception {
+        Path classes = base.resolve("classes");
+
+        Files.createDirectories(classes);
+
+        Path src = base.resolve("src");
+        Path m1 = src.resolve("m1");
+
+        tb.writeJavaFiles(m1,
+                          "module m1 { exports api; }",
+                          "package api; public @interface A {}",
+                          "package api; public @interface B {}");
+
+        Path m2 = src.resolve("m2");
+
+        tb.writeJavaFiles(m2,
+                          "module m2 { exports api; }",
+                          "package api; public @interface A {}",
+                          "package api; public @interface B {}");
+
+        Path m3 = src.resolve("m3");
+
+        tb.writeJavaFiles(m3,
+                          "module m3 { requires m1; }",
+                          "package impl; import api.*; @A @B public class T {}");
+
+        Path m4 = src.resolve("m4");
+
+        tb.writeJavaFiles(m4,
+                          "module m4 { requires m2; }",
+                          "package impl; import api.*; @A @B public class T {}");
+
+        List<String> log;
+        List<String> expected;
+
+        log = new JavacTask(tb)
+            .options("-processor", SelectAnnotationATestAP.class.getName() + "," + SelectAnnotationBTestAP.class.getName(),
+                     "--module-source-path", src.toString(),
+                     "-m", "m1,m2")
+            .outdir(classes)
+            .run()
+            .writeAll()
+            .getOutputLines(OutputKind.STDERR);
+
+        expected = Arrays.asList("");
+
+        if (!expected.equals(log)) {
+            throw new AssertionError("Output does not match; output: " + log);
+        }
+
+        log = new JavacTask(tb)
+            .options("-processor", SelectAnnotationATestAP.class.getName() + "," + SelectAnnotationBTestAP.class.getName(),
+                     "--module-source-path", src.toString(),
+                     "-m", "m3")
+            .outdir(classes)
+            .run()
+            .writeAll()
+            .getOutputLines(OutputKind.STDERR);
+
+        expected = Arrays.asList("SelectAnnotationBTestAP",
+                                 "SelectAnnotationBTestAP");
+
+        if (!expected.equals(log)) {
+            throw new AssertionError("Output does not match; output: " + log);
+        }
+
+        log = new JavacTask(tb)
+            .options("-processor", SelectAnnotationATestAP.class.getName() + "," + SelectAnnotationBTestAP.class.getName(),
+                     "--module-source-path", src.toString(),
+                     "-m", "m4")
+            .outdir(classes)
+            .run()
+            .writeAll()
+            .getOutputLines(OutputKind.STDERR);
+
+        expected = Arrays.asList("SelectAnnotationATestAP",
+                                 "SelectAnnotationBTestAP",
+                                 "SelectAnnotationATestAP",
+                                 "SelectAnnotationBTestAP");
+
+        if (!expected.equals(log)) {
+            throw new AssertionError("Output does not match; output: " + log);
+        }
+    }
+
+    @SupportedAnnotationTypes("m2/api.A")
+    public static final class SelectAnnotationATestAP extends AbstractProcessor {
+
+        @Override
+        public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+            System.err.println("SelectAnnotationATestAP");
+
+            return false;
+        }
+
+    }
+
+    @SupportedAnnotationTypes("api.B")
+    public static final class SelectAnnotationBTestAP extends AbstractProcessor {
+
+        @Override
+        public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+            System.err.println("SelectAnnotationBTestAP");
+
+            return false;
+        }
+
+    }
+
+    private static void writeFile(String content, Path base, String... pathElements) throws IOException {
+        Path file = resolveFile(base, pathElements);
+
+        Files.createDirectories(file.getParent());
+
+        try (Writer out = Files.newBufferedWriter(file)) {
+            out.append(content);
+        }
+    }
+
     private static void assertNonNull(String msg, Object val) {
         if (val == null) {
             throw new AssertionError(msg);
@@ -392,4 +1030,22 @@
         }
     }
 
+    private static void assertFileExists(Path base, String... pathElements) {
+        Path file = resolveFile(base, pathElements);
+
+        if (!Files.exists(file)) {
+            throw new AssertionError("Expected file: " + file + " exist, but it does not.");
+        }
+    }
+
+    static Path resolveFile(Path base, String... pathElements) {
+        Path file = base;
+
+        for (String el : pathElements) {
+            file = file.resolve(el);
+        }
+
+        return file;
+    }
+
 }