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}