001/*
002 * SPDX-License-Identifier: Apache-2.0
003 *
004 * Copyright 2023-2026 The Enola <https://enola.dev> Authors
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License");
007 * you may not use this file except in compliance with the License.
008 * You may obtain a copy of the License at
009 *
010 *     https://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package dev.enola.common.markdown.exec;
019
020import static java.nio.charset.StandardCharsets.UTF_8;
021
022import com.google.common.io.Files;
023
024import dev.enola.common.exec.vorburger.ExpectedExitCode;
025import dev.enola.common.exec.vorburger.Runner;
026import dev.enola.common.exec.vorburger.VorburgerExecRunner;
027
028import java.io.File;
029import java.io.IOException;
030import java.nio.file.Path;
031import java.time.Duration;
032import java.util.stream.Collectors;
033
034public class ExecMD {
035
036    private static final String LS = System.lineSeparator();
037    private final Runner runner = new VorburgerExecRunner();
038
039    public void process(File mdFile, boolean inplace)
040            throws IOException, MarkdownProcessingException {
041        var markdownIn = Files.asCharSource(mdFile, UTF_8).read();
042        var directory = mdFile.getAbsoluteFile().getParentFile();
043        var output = process(directory.toPath(), markdownIn);
044
045        var script = new File(directory, "script");
046        Files.asCharSink(script, UTF_8).write(output.script);
047
048        if (inplace) {
049            Files.asCharSink(mdFile, UTF_8).write(output.markdown);
050        } else {
051            System.out.println(output.markdown);
052        }
053    }
054
055    Pair process(Path dir, String markdown) throws MarkdownProcessingException, IOException {
056        var outScript = new StringBuilder();
057        var outMD = new StringBuilder();
058        var markdownLines = markdown.lines().collect(Collectors.toList());
059        var iterator = markdownLines.iterator();
060        while (iterator.hasNext()) {
061            var line = iterator.next();
062            if (!line.startsWith("```bash")) {
063                outMD.append(line);
064                outMD.append(LS);
065                continue;
066            }
067            var preamble = line;
068            if (!iterator.hasNext()) {
069                throw new MarkdownProcessingException("```bash cannot be last line!");
070            }
071            // TODO https://github.com/squidfunk/mkdocs-material/issues/5473
072            // outMD.append(line);
073            outMD.append("```bash");
074            outMD.append(LS);
075            var command = new StringBuilder();
076            var commandLine = iterator.next().trim();
077            if (!commandLine.startsWith("$ ")) {
078                throw new MarkdownProcessingException(
079                        "First line after ```bash must start with '$ ' (with space) and a command"
080                                + " to execute!");
081            }
082            outMD.append(commandLine);
083            outMD.append(LS);
084            command.append(commandLine.substring(2));
085            command.append(LS);
086            while (commandLine.trim().endsWith("\\")) {
087                commandLine = iterator.next();
088                command.append(commandLine);
089                command.append(LS);
090                outMD.append(commandLine);
091                outMD.append(LS);
092            }
093            while (iterator.hasNext() && !commandLine.trim().endsWith("```")) {
094                commandLine = iterator.next();
095                // Do *NOT* out.append(commandLine) - because we want to skip existing output!
096            }
097
098            exec(dir, preamble, command.toString(), outScript, outMD);
099            if (!endsWith(outMD, '\n')) outMD.append("\n");
100            outMD.append("```\n");
101        }
102
103        outScript.append("sleep ${SLEEP:-7}\n");
104
105        var pair = new Pair();
106        pair.markdown = outMD.toString();
107        pair.script = outScript.toString();
108        return pair;
109    }
110
111    private boolean endsWith(CharSequence cs, char trailing) {
112        return cs.charAt(cs.length() - 1) == trailing;
113    }
114
115    int exec(Path dir, String preamble, String command, Appendable script, Appendable md)
116            throws MarkdownProcessingException, IOException {
117
118        if (!preamble.startsWith("```bash")) throw new IllegalArgumentException(preamble);
119        preamble = preamble.substring("```bash".length()).trim();
120
121        ExpectedExitCode expectedExitCode;
122        if (preamble.startsWith("$?")) {
123            expectedExitCode = ExpectedExitCode.FAIL;
124            preamble = preamble.substring("$?".length()).trim();
125        } else if (preamble.startsWith("$%")) {
126            expectedExitCode = ExpectedExitCode.IGNORE;
127            preamble = preamble.substring("$%".length()).trim();
128        } else expectedExitCode = ExpectedExitCode.SUCCESS;
129
130        String fullCommand;
131        script.append(":CWD=$(pwd)\n");
132        if (!preamble.trim().isEmpty()) {
133            script.append(":");
134            script.append(preamble);
135            script.append("\n");
136
137            fullCommand = preamble + " && " + command;
138        } else {
139            fullCommand = command;
140        }
141        script.append(command);
142        script.append("\n");
143        script.append(":cd $CWD\n");
144        script.append("\n\n");
145
146        // TODO Allow current hard-coded timeout to be configured in MD preamble, or CLI option?
147        Duration timeout = Duration.ofSeconds(7);
148
149        try {
150            return runner.bash(expectedExitCode, dir, fullCommand, md, timeout);
151        } catch (Exception e) {
152            throw new MarkdownProcessingException(
153                    "exec failed (use ```bash $? marker if that's expected): " + fullCommand, e);
154        }
155    }
156
157    static class Pair {
158        String markdown;
159        String script;
160    }
161}