Newer
Older
gradle-lectures / buildSrc / src / main / java / nz / stanger / lecture / Lecture.java
package nz.stanger.lecture;

import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.danilopianini.gradle.latex.LatexExtension;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.file.ConfigurableFileTree;
import org.gradle.api.file.Directory;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.Copy;
import org.gradle.api.tasks.Delete;
import org.gradle.api.tasks.Exec;
import org.gradle.api.tasks.TaskContainer;

/**
 * Gradle plugin to implement LaTeX lecture building infrastructure.
 */
public class Lecture implements Plugin<Project> {

    /**
     * Default group for lecture related tasks.
     */
    public static final String LECTURE_TASK_GROUP = "lecture";
    
    // Internal names for tasks.
    private static final String UNNUMBERED_TASK_NAME = "docs";
    private static final String NUMBERED_TASK_NAME = "numbered";
    private static final String IMAGES_TASK_NAME = "images";
    private static final String CLEAN_TASK_NAME = "clean";
    private static final String DEEP_CLEAN_TASK_NAME = "deepClean";
    private static final String DEBUG_TASK_NAME = "debug";
    private static final String PDFLATEX_TASK_NAME = "pdfLatex";
    
    // Name of the lecture plugin extension.
    private static final String LECTURE_EXTENSION_NAME = "lecture";

    // Names of document targets.
    private static final String CONTENT_TARGET_NAME = "content";
    private static final String SLIDES_TARGET_NAME = "slides";
    private static final String HANDOUT_TARGET_NAME = "handout";
    private static final String NOTES_TARGET_NAME = "notes";
    private static final String EXAMPLES_TARGET_NAME = "examples";
    private static final String NUMBERED_TARGET_SUFFIX = "numbered";

    // Prefix of generated lecture document files.
    private static final String LECTURE_PREFIX = "lecture";

    // Only these target names can be used.
    private static final Set<String> VALID_TARGET_NAMES = Set.of(
            SLIDES_TARGET_NAME,
            HANDOUT_TARGET_NAME,
            NOTES_TARGET_NAME,
            EXAMPLES_TARGET_NAME
    );
    
    // Standard directory names.
    private static final String IMAGES_DIRECTORY = "images";
    private static final String PDFS_DIRECTORY = "pdfs";

    // Some handy references that are used a lot.
    private TaskContainer projectTasks;
    private Directory projectDirectory;
    private LectureExtension lectureExtension;
    private LatexExtension latexExtension;

    /**
     * Find directories whose names match a regular expression.
     *
     * Adapted from {@link https://gist.github.com/Ethsaam/2b8dd748fcc25e6973bb}.
     *
     * @param dir the directory to start the search in
     * @param pattern regular expression to match directory names
     * @return the collection of matching directories
     */
    private Collection<File> getListOfDirectories(Directory dir, String pattern) {
        return new ArrayList<>(Arrays.asList(dir.getAsFile().listFiles(file -> file.isDirectory() && file.getName().matches(pattern))));
    }

    /**
     * Applies the plugin.
     * 
     * @param project the project to apply the plugin to
     */
    @Override
    public void apply(Project project) {
        projectTasks = project.getTasks();
        projectDirectory = project.getLayout().getProjectDirectory();

        project.getPluginManager().apply("org.danilopianini.gradle-latex");

        // Plugin extensions.
        lectureExtension = project.getExtensions().create(LECTURE_EXTENSION_NAME, LectureExtension.class);
        latexExtension = project.getExtensions().getByType(LatexExtension.class);

        // Configure LaTeX extension.
        latexExtension.getPdfLatexCommand().set("xelatex");

        /**
         * Set plugin defaults.
         *
         * Supported document targets.
         */
        lectureExtension.getDocTargets().convention(VALID_TARGET_NAMES);

        // Validate document target configuration. Must be afterEvaluate() to
        // ensure everything has been finalised.
        project.afterEvaluate(p -> {
            lectureExtension.getDocTargets().get().stream().forEach(target -> {
                if (!VALID_TARGET_NAMES.contains(target)) {
                    throw new RuntimeException("unsupported target name " + target);
                }
            });
        });

        // Supported targets (numbered documents). Automatically derived from
        // LectureExtension.docTargets.
        Provider<Set<String>> numberedDocTargets = lectureExtension.getDocTargets().map(
                docTargets -> docTargets.stream().map(target -> target + "." + NUMBERED_TARGET_SUFFIX).collect(Collectors.toSet())
        );

        // Standard lecture prefix string.
        lectureExtension.getDocPrefix().convention(LECTURE_PREFIX);

        lectureExtension.getLectureNum().convention(project.getName().substring(0, 2));

        // Standard document names (unnumbered). Automatically derived from
        // LectureExtension.docTargets, plus "content".
        Provider<Map<String, String>> docNames = lectureExtension.getDocTargets().map(docTargets -> {
            Map<String, String> names = new HashMap<>();
            docTargets.forEach(target -> names.put(target, lectureExtension.getDocPrefix().get() + "_" + target));
            names.put(CONTENT_TARGET_NAME, lectureExtension.getDocPrefix().get() + "_" + CONTENT_TARGET_NAME);
            return names;
        });

        // Standard document names (numbered). Automatically derived from
        // LectureExtension.docTargets, plus "content".
        Provider<Map<String, String>> numberedDocNames = lectureExtension.getDocTargets().map(docTargets -> {
            Map<String, String> names = new HashMap<>();
            docTargets.forEach(target -> names.put(target, lectureExtension.getDocPrefix().get() + "_" + lectureExtension.getLectureNum().get() + "_" + target));
            names.put(CONTENT_TARGET_NAME, lectureExtension.getDocPrefix().get() + "_" + lectureExtension.getLectureNum().get() + "_" + CONTENT_TARGET_NAME);
            return names;
        });

        lectureExtension.getImageDir().convention(projectDirectory.dir(IMAGES_DIRECTORY));

        lectureExtension.getPdfDir().convention(projectDirectory.dir(PDFS_DIRECTORY));

        // slidesImages defaults to empty
        lectureExtension.getSlidesFiles().convention(
                lectureExtension.getDocPrefix().map(
                        (String prefix) -> Set.of(
                                prefix + "_" + CONTENT_TARGET_NAME + ".tex",
                                "paper_init.tex",
                                "lecturedates.tex",
                                "doc_init.tex"
                        )
                )
        );

        // examplesImages defaults to empty
        lectureExtension.getExamplesFiles().convention(
                lectureExtension.getDocPrefix().map((String prefix) -> {
                    if (lectureExtension.getDocTargets().get().contains(EXAMPLES_TARGET_NAME)) {
                        return Set.of(
                                prefix + "_" + EXAMPLES_TARGET_NAME + ".tex",
                                "paper_init.tex",
                                "lecturedates.tex",
                                "doc_init.tex"
                        );
                    } else {
                        return Set.of();
                    }
                })
        );

        // Document dependencies. Automatically derived from slidesImages,
        // slidesFiles, examplesImages, and examplesFiles. Images are mapped to
        // imageDir. The slides, handout, and notes will always share the same
        // dependencies. Only the examples will be different.
        Provider<Set<String>> slideDependencies = lectureExtension.getSlidesImages().map(slideImages -> {
            Set<String> slideDeps = new HashSet<>();
            slideDeps.addAll(slideImages.stream().map(image -> lectureExtension.getImageDir().get() + "/" + image).collect(Collectors.toSet()));
            slideDeps.addAll(lectureExtension.getSlidesFiles().get());
            return slideDeps;
        });

        Provider<Map<String, List<String>>> docDependencies = lectureExtension.getDocTargets().map(docTargets -> {
            Map<String, List<String>> docDeps = new HashMap<>();
            docTargets.forEach(target -> {
                if (target.equals(EXAMPLES_TARGET_NAME)) {
                    List<String> deps = lectureExtension.getExamplesFiles().get().stream().collect(Collectors.toList());
                    deps.addAll(lectureExtension.getExamplesImages().get().stream()
                            .map(image -> lectureExtension.getImageDir().get() + "/" + image)
                            .collect(Collectors.toList())
                    );
                    docDeps.put(target, deps);
                } else {
                    docDeps.put(target, slideDependencies.get().stream().collect(Collectors.toList()));
                }
            });
            return docDeps;
        });

        lectureExtension.getCleanFiles().convention(Set.of(
                "*.aux",
                "*.dvi",
                "*.fdb_latexmk",
                "*.fls",
                "*.head",
                "*.listing",
                "*.log",
                "*.nav",
                "*.out",
                "*.pyg",
                "*.snm",
                "*.synctex.gz",
                "*.tmp",
                "*.toc",
                "*.vrb",
                "*.xdv"
        ));
        // These are regular expressions, NOT glob patterns.
        lectureExtension.getCleanDirs().convention(Set.of(
                "_minted.*"
        ));

        lectureExtension.getLatexFlags().convention(Set.of(
                "-shell-escape",
                "-synctex=1",
                "-interaction=nonstopmode",
                "-halt-on-error",
                "-file-line-error"
        ));

        /**
         * Tasks.
         *
         * It doesn't always seem to be strictly necessary, but it seems safer
         * in general to assume that all tasks need to be afterEvaluate(). It's
         * certainly necessary for generated tasks that derive from
         * configuration variables.
         */
        project.afterEvaluate(p -> {

            project.setDefaultTasks(Arrays.asList(UNNUMBERED_TASK_NAME, NUMBERED_TASK_NAME));

            // All unnumbered documents.
            projectTasks.register(UNNUMBERED_TASK_NAME, task -> {
                task.setDescription("Build all documents (draft, unnumbered).");
                task.setGroup(LECTURE_TASK_GROUP);
                task.dependsOn(lectureExtension.getDocTargets());
            });

            // Generate tasks for each of the unnumbered document targets.
            // All generated tasks that derive from configuration variables must be
            // afterEvaluate() to ensure everything has been finalised.
            lectureExtension.getDocTargets().get().forEach(target -> {
                projectTasks.register(target, task -> {
                    task.setDescription("Build " + target + " document (draft, unnumbered).");
                    task.setGroup(LECTURE_TASK_GROUP);
                    task.dependsOn(PDFLATEX_TASK_NAME + "." + docNames.get().get(target));
                });
            });

            // All numbered documents.
            projectTasks.register(NUMBERED_TASK_NAME, task -> {
                task.setDescription("Build all documents (final, numbered).");
                task.setGroup(LECTURE_TASK_GROUP);
                task.dependsOn(numberedDocTargets);
            });

            // Generate tasks for each of the numbered document targets.
            numberedDocTargets.get().forEach(target -> {
                projectTasks.register(target, Copy.class, task -> {
                    String baseTarget = target.replace("." + NUMBERED_TARGET_SUFFIX, "");
                    task.setDescription("Build " + baseTarget + " document (final, numbered).");
                    task.setGroup(LECTURE_TASK_GROUP);

                    task.from(project.getTasksByName(PDFLATEX_TASK_NAME + "." + docNames.get().get(baseTarget), true));
                    task.into(lectureExtension.getPdfDir());

                    // The LaTeX tasks include the .aux file in their outputs.
                    // Exclude or they will also be copied into the PDFs directory.
                    task.exclude("*.aux");
                    task.rename(src -> src.replace("_", "_" + lectureExtension.getLectureNum().get() + "_"));
                });
            });

            // Generate LaTeX build tasks for each of the targets. This uses the
            // string invocation method supplied by the LaTeX plugin.
            lectureExtension.getDocTargets().get().forEach(target -> {
                latexExtension.invoke(docNames.get().get(target), (dsl) -> {
                    dsl.setExtraArguments(lectureExtension.getLatexFlags().get());
                    dsl.setWatching(docDependencies.get().get(target));
                    return null; // workaround for Kotlin Unit (void) return
                });
            });

            // All generated images. Depends on any additional image generation
            // sub-tasks.
            projectTasks.register(IMAGES_TASK_NAME, task -> {
                task.setDescription("Generate images.");
                task.setGroup(LECTURE_TASK_GROUP);
                task.setDependsOn(projectTasks.withType(GenerateImageFromSourceTask.class));
            });

            // Ensure that the LaTeX tasks depend on the images task. This implies
            // that *all* LaTeX documents dependent on *all* generated images,
            // even when a document doesn't actually depend on a specific image.
            // This would be too much of a hassle to fix given the relatively
            // small benefit.
            projectTasks.matching(task -> task.getName().startsWith(PDFLATEX_TASK_NAME)).forEach(pdfLatexTask -> {
                pdfLatexTask.dependsOn(projectTasks.getByName(IMAGES_TASK_NAME));
            });

            // Remove files specified in cleanFiles and directories specified
            // in cleanDirs.
            projectTasks.register(CLEAN_TASK_NAME, Delete.class, task -> {
                task.setDescription("Clean up intermediate files.");
                task.setGroup(LECTURE_TASK_GROUP);

                ConfigurableFileTree tree = project.fileTree(projectDirectory);
                tree.include(lectureExtension.getCleanFiles().get());
                task.delete(tree);

                lectureExtension.getCleanDirs().get().forEach(
                        pattern -> task.delete(getListOfDirectories(projectDirectory, pattern))
                );
            });

            // Remove everything not under version control. Unfortunately this *doesn't*
            // include the .gradle directory :(, but in hindsight that's not surprising.
            projectTasks.register(DEEP_CLEAN_TASK_NAME, Exec.class, task -> {
                task.setDescription("Remove all files not in Git.");
                task.setGroup(LECTURE_TASK_GROUP);
                task.setCommandLine("git", "clean", "-fXd");
            });

        });

        // Debugging information.
        projectTasks.register(DEBUG_TASK_NAME, task -> {
            task.setDescription("Print debugging information.");
            task.setGroup(LECTURE_TASK_GROUP);
            task.doLast(s -> {
                System.out.println("project name = <" + project.getName() + ">");
                System.out.println("project directory = <" + projectDirectory + ">");
                System.out.println("docTargets = <" + lectureExtension.getDocTargets().get().toString() + ">");
                System.out.println("numberedDocTargets = <" + numberedDocTargets.get().toString() + ">");
                System.out.println("docPrefix = <" + lectureExtension.getDocPrefix().get() + ">");
                System.out.println("lectureNum = <" + lectureExtension.getLectureNum().get() + ">");
                System.out.println("docNames = <" + docNames.get().toString() + ">");
                System.out.println("numberedDocNames = <" + numberedDocNames.get().toString() + ">");
                System.out.println("imageDir = <" + lectureExtension.getImageDir().get().getAsFile().getName() + ">");
                System.out.println("pdfDir = <" + lectureExtension.getPdfDir().get().getAsFile().getName() + ">");
                System.out.println("slidesImages = <" + lectureExtension.getSlidesImages().get().toString() + ">");
                System.out.println("slidesFiles = <" + lectureExtension.getSlidesFiles().get().toString() + ">");
                System.out.println("examplesImages = <" + lectureExtension.getExamplesImages().get().toString() + ">");
                System.out.println("examplesFiles = <" + lectureExtension.getExamplesFiles().get().toString() + ">");
                System.out.println("slideDependencies = <" + slideDependencies.get().toString() + ">");
                System.out.println("docDependencies = <" + docDependencies.get().toString() + ">");
                System.out.println("cleanFiles = <" + lectureExtension.getCleanFiles().get().toString() + ">");
                System.out.println("cleanDirs = <" + lectureExtension.getCleanDirs().get().toString() + ">");
                System.out.println("latexFlags = <" + lectureExtension.getLatexFlags().get().toString() + ">");
                System.out.println("latex.terminalEmulator = <" + latexExtension.getTerminalEmulator().get() + ">");
                System.out.println("latex.waitTime = <" + latexExtension.getWaitTime().get() + ">");
                System.out.println("latex.waitUnit = <" + latexExtension.getWaitUnit().get() + ">");
                System.out.println("latex.pdfLatexCommand = <" + latexExtension.getPdfLatexCommand().get() + ">");
                System.out.println("latex.bibTexCommand = <" + latexExtension.getBibTexCommand().get() + ">");
                System.out.println("latex.inkscapeCommand = <" + latexExtension.getInkscapeCommand().get() + ">");
                System.out.println("latex.gitLatexdiffCommand = <" + latexExtension.getGitLatexdiffCommand().get() + ">");
            });
        });
    }
}