diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75d65c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.dub +docs.json +__dummy.html +docs/ +/md +md.so +md.dylib +md.dll +md.a +md.lib +md-test-* +*.exe +*.o +*.obj +*.lst diff --git a/README.md b/README.md new file mode 100644 index 0000000..c33ae0a --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# md + +Markdownのコードブロックを実行するツールです。 + +READMEに書かれたサンプルが動作するかどうかをCIなどで確かめることができます。 + +## 実行方法 + +`dub run md -- README.md` といった形式で実行できます。 + +この `README.md` も実行可能です。 + + +## 機能概要 + +言語が `d` と指定されているコードブロックが実行されます。 + +以下のように複数のブロックがある場合、それらが結合されて実行されます。 + +__1ブロック目__ + +```d +import std; + +auto message = "Hello, " ~ "Markdown!"; +``` + +__2ブロック目__ + +```d +writeln(message); // Hello, Markdown! +``` + +### 除外設定 + +以下のように `disabled` と指定したコードブロックは実行されません。 + +~~~ +```d disabled +``` +~~~ + +__実行されないブロック__ + +```d disabled +throw new Exception("disabled"); +``` + +### 名前指定 + +コードブロックに対して、以下のような名前を指定することで独立したスコープを与えることができます。 +離れた位置に書かれていても、同じ名前を与えると1つのブロックとして結合されます。 + +~~~ +```d name=test +``` +~~~ + +```d name=test +import std; + +auto buf = iota(10).array(); +writeln(buf); +``` + +`name` 指定がない場合は `main` という名前として扱われます。 + +### 独立実行 + +1つのコードブロックを他のブロックと結合せず、独立して実行させるためには `single` という属性を付与します。 + +~~~ +```d single +``` +~~~ + +__他のブロックと結合しない例__ + +```d single +import std; + +auto message = "single code block"; +writeln(message); +``` + +### 既定のパッケージ参照 + +ライブラリのREADMEなどをサポートするため、実行時のカレントディレクトリがdubパッケージであった場合、自動的に `dub` プロジェクトとしての依存関係が追加されます。 + +たとえば、 `dub.sdl` と同じディレクトリにある本READMEの場合、内部で使っている `commands.main` を `import` することができます。 + +```d name=package_ref +import commands.main; +import std.stdio; + +writeln("current package: ", loadCurrentProjectName()); +``` + +### グローバル宣言 + +通常のコードブロックはサンプル用の記述を想定し、 `void main() {}` の中に書かれたものとして実行されます。(前後に `void main() {` と `}` が補われたソースが生成されます) + +コードブロックを1つのソースファイルとして解釈させる場合、コードブロックに `global` という設定を追加します。これは `single` を指定した場合と同様、他のコードブロックとは結合されません。 + +~~~ +```d global +``` +~~~ + +__1つのソースとして実行される例__ + +```d global +import std; + +void main() +{ + writeln("Hello, Markdown!"); +} +``` + + +## その他 + +### 実行時の仕組み + +tempディレクトリに `.md` ディレクトリを作り、dubのシングルファイル形式のソースを生成、 `dub run --single md_xxx.md` といったコマンドで実行します。 + +### 制限 + +#### UFCS + +通常のコードブロックでは、 `void main() {}` で囲んだソースを生成します。 +関数定義がグローバル関数ではないため、UFCSは動作しません + +__UFCSが解決されず動かない例__ + +```d name=ufcs_error +auto sum(R)(R range) +{ + import std.range : ElementType; + + alias E = ElementType!R; + auto result = E(0); + foreach (x; range) + { + result += x; + } + return result; +} + +auto arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +auto result = arr.sum(); +``` + +global設定を行い、main関数を適切に書くことで動作します。 + +__UFCSのためglobal指定を追加した例__ + +```d global +auto sum(R)(R range) +{ + import std.range : ElementType; + + alias E = ElementType!R; + auto result = E(0); + foreach (x; range) + { + result += x; + } + return result; +} + +void main() +{ + auto arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + auto result = arr.sum(); +} +``` \ No newline at end of file diff --git a/dub.sdl b/dub.sdl new file mode 100644 index 0000000..a59b8ea --- /dev/null +++ b/dub.sdl @@ -0,0 +1,6 @@ +name "md" +description "A tool for executing sources in Markdown." +authors "lempiji" +license "MIT" +dependency "jcli" version="~>0.12.0" +dependency "commonmark-d" version="~>1.0.8" diff --git a/dub.selections.json b/dub.selections.json new file mode 100644 index 0000000..3bae06f --- /dev/null +++ b/dub.selections.json @@ -0,0 +1,9 @@ +{ + "fileVersion": 1, + "versions": { + "commonmark-d": "1.0.8", + "jcli": "0.12.0", + "jioc": "0.2.0", + "silly": "1.0.2" + } +} diff --git a/source/app.d b/source/app.d new file mode 100644 index 0000000..ba629d9 --- /dev/null +++ b/source/app.d @@ -0,0 +1,10 @@ +module app; + +import commands.main; +import jcli; + +int main(string[] args) +{ + auto executor = new CommandLineInterface!(commands.main); + return executor.parseAndExecute(args); +} diff --git a/source/commands/main.d b/source/commands/main.d new file mode 100644 index 0000000..5d5737d --- /dev/null +++ b/source/commands/main.d @@ -0,0 +1,384 @@ +module commands.main; + +import commonmarkd.md4c; +import jcli; +import std.array; +import std.container.array : Array; +import std.stdio; +import std.typecons; +import std.conv; +import std.range; + +@CommandDefault("Execute code block in markdown.") +struct DefaultCommand +{ + @CommandPositionalArg(0, "file", "Markdown file (.md)") + Nullable!string file; + + @CommandArgGroup("Options") + { + @CommandNamedArg("quiet|q", "Only print warnings and errors") + Nullable!bool quiet; + + @CommandNamedArg("verbose|v", "Print diagnostic output") + Nullable!bool verbose; + } + + int onExecute() + { + auto packageName = loadCurrentProjectName(); + if (verbose.isTrue) + writeln("packageName: ", packageName); + + string filepath = !file.isNull ? file.get() : "README.md"; + auto result = parseMarkdown(filepath); + + Appender!(string)[string] blocks; + string[] singleBlocks; + string[] globalBlocks; + foreach (block; result.blocks) + { + if (block.lang == "d" || block.lang == "D") + { + if (isDisabledBlock(block)) + continue; + if (isSingleBlock(block)) + { + singleBlocks ~= block.code[].to!string(); + continue; + } + if (isGlobalBlock(block)) + { + globalBlocks ~= block.code[].to!string(); + continue; + } + + auto name = getBlockName(block); + if (!(name in blocks)) + { + blocks[name] = appender!string; + } + blocks[name].put(block.code[]); + } + } + + foreach (key, value; blocks) + { + if (quiet.isNull || !quiet.get()) + writeln("begin: ", key); + scope (exit) + if (quiet.isNull || !quiet.get()) + writeln("end: ", key); + + evaluate(value.data, packageName, BlockType.Single, verbose.isTrue); + } + + foreach (i, source; singleBlocks) + { + if (quiet.isNull || !quiet.get()) + writeln("begin single: ", i); + scope (exit) + if (quiet.isNull || !quiet.get()) + writeln("end single: ", i); + + evaluate(source, packageName, BlockType.Single, verbose.isTrue); + } + + foreach (i, source; globalBlocks) + { + if (quiet.isNull || !quiet.get()) + writeln("begin global :", i); + scope (exit) + if (quiet.isNull || !quiet.get()) + writeln("end global :", i); + + evaluate(source, packageName, BlockType.Global, verbose.isTrue); + } + + return 0; + } +} + +struct ParseResult +{ + int status; + Code[] blocks; +} + +ParseResult parseMarkdown(in const(char)[] filepath) +{ + import std.file : readText; + + auto text = readText(filepath); + + MD_PARSER parser; + parser.enter_block = (MD_BLOCKTYPE type, void* detail, void* userdata) { + CodeAggregator* aggregator = cast(CodeAggregator*) userdata; + return aggregator.enterBlock(type, detail); + }; + parser.leave_block = (MD_BLOCKTYPE type, void* detail, void* userdata) { + CodeAggregator* aggregator = cast(CodeAggregator*) userdata; + return aggregator.leaveBlock(type, detail); + }; + parser.enter_span = (MD_BLOCKTYPE type, void*, void*) { + // debug writeln("enter_span: ", type); + return 0; + }; + parser.leave_span = (MD_BLOCKTYPE type, void*, void*) { + // debug writeln("leave_span: ", type); + return 0; + }; + parser.text = (MD_TEXTTYPE type, const(MD_CHAR*) text, MD_SIZE size, void* userdata) { + CodeAggregator* aggregator = cast(CodeAggregator*) userdata; + return aggregator.text(type, text, size); + }; + + CodeAggregator aggregator; + auto status = md_parse(text.ptr, cast(uint) text.length, &parser, &aggregator); + + return ParseResult(status, aggregator.codes[].array()); +} + +struct Code +{ + const(char)[] lang; + const(char)[] info; + Array!char code; +} + +struct CodeAggregator +{ + bool isCode; + Code current; + Array!Code codes; + + int enterBlock()(MD_BLOCKTYPE type, void* detail) + { + isCode = type == MD_BLOCK_CODE; + if (isCode && detail !is null) + { + MD_BLOCK_CODE_DETAIL* data = cast(MD_BLOCK_CODE_DETAIL*) detail; + setAttribute(current.lang, data.lang, 0); + setAttribute(current.info, data.info, data.lang.size + 1); + current.code.clear(); + } + return 0; + } + + int leaveBlock()(MD_BLOCKTYPE type, void* detail) + { + if (isCode) + { + codes.insertBack(current); + } + isCode = false; + return 0; + } + + int text()(MD_TEXTTYPE type, const(MD_CHAR*) text, MD_SIZE size) + { + if (isCode && size != 0) + { + current.code.reserve(size); + foreach (i; 0 .. size) + { + current.code.insertBack(text[i]); + } + } + return 0; + } +} + +void setAttribute()(ref const(char)[] data, MD_ATTRIBUTE attr, size_t offset = 0) nothrow @nogc +{ + import std.algorithm : min; + + if (attr.text !is null && attr.size != 0) + { + offset = min(offset, attr.size); + data = attr.text[offset .. attr.size]; + } + else + data = null; +} + +bool isDisabledBlock(const ref Code code) +{ + import std.regex : regex, matchFirst; + + auto pat = regex(`(?<=^|\s)disabled(?=\s|$)`); + if (auto m = matchFirst(code.info, pat)) + { + return true; + } + return false; +} + +bool isSingleBlock(const ref Code code) +{ + import std.regex : regex, matchFirst; + + auto pat = regex(`(?<=^|\s)single(?=\s|$)`); + if (auto m = matchFirst(code.info, pat)) + { + return true; + } + return false; +} + +bool isGlobalBlock(const ref Code code) +{ + import std.regex : regex, matchFirst; + + auto pat = regex(`(?<=^|\s)global(?=\s|$)`); + if (auto m = matchFirst(code.info, pat)) + { + return true; + } + return false; +} + +string getBlockName(const ref Code code) +{ + import std.regex : regex, matchFirst; + + auto pat = regex(`(?<=^|\s)name=(\w+)(?=\s|$)`); + if (auto m = matchFirst(code.info, pat)) + { + if (m[1].length != 0) + return m[1].idup; + } + return "main"; +} + +enum BlockType +{ + Single, + Global, +} + +void evaluate(string source, string packageName, BlockType type, bool verbose) +{ + import std.stdio : stdin, stdout, stderr; + import std.process : spawnProcess, wait; + + string header; + if (packageName) + { + import std.format : format; + import std.file : getcwd; + + header = format!`/+ dub.sdl: + dependency "%s" path="%s" ++/ +`(packageName, escapeSystemPath(getcwd())); + } + + import std.file : tempDir, write, remove, mkdirRecurse, chdir; + import std.path : buildNormalizedPath; + import std : toHexString, to, text; + + auto workDir = buildNormalizedPath(tempDir(), ".md"); + mkdirRecurse(workDir); + + import std.digest.murmurhash : MurmurHash3; + import std.string : representation; + + MurmurHash3!128 hasher; + hasher.start(); + hasher.put(source.representation); + hasher.put(packageName.representation); + auto hash = hasher.finish(); + + auto moduleName = text("md_", hash.toHexString()); + auto filename = moduleName ~ ".d"; + auto tempFilePath = buildNormalizedPath(workDir, filename); + if (verbose) + { + writeln("tempFilePath: ", tempFilePath); + writeln("tempFileName: ", filename); + } + + { + auto sourceFile = File(tempFilePath, "w"); + sourceFile.writeln(header); + if (type == BlockType.Single) + { + sourceFile.writeln("module ", moduleName, ";"); + sourceFile.writeln("void main() {"); + } + sourceFile.writeln(source); + if (type == BlockType.Single) + { + sourceFile.writeln("}"); + } + sourceFile.flush(); + } + + string[] args = [ + "dub", "run", "--quiet", "--single", "--root", workDir, filename + ]; + if (verbose) + writeln("dub args: ", args); + + auto result = spawnProcess(args, stdin, stdout); + wait(result); +} + +string loadCurrentProjectName() +{ + import std.file : exists; + + if (exists("dub.json")) + { + import std.json : parseJSON; + import std.file : readText; + + auto jsonText = readText("dub.json"); + auto json = parseJSON(jsonText); + + return json["name"].get!string(); + } + + if (exists("dub.sdl")) + { + import std.regex : ctRegex, matchFirst; + + enum pattern = ctRegex!"^name \"(\\w+)\"$"; + auto f = File("dub.sdl", "r"); + foreach (line; f.byLine()) + { + if (auto m = matchFirst(line, pattern)) + { + import std.conv : to; + + return m[1].to!string(); + } + } + } + + return null; +} + +string escapeSystemPath(string path) +{ + import std.path : dirSeparator; + import std.array : replace; + + version (Windows) + { + return replace(path, dirSeparator, dirSeparator ~ dirSeparator); + } + else + { + return path; + } +} + +bool isTrue(in Nullable!bool value) +{ + if (value.isNull) + return false; + + return value.get(); +}