commit 1e7ef02e63862ba646b70d601b9a265cc485c82c Author: Grant Horner Date: Thu Dec 18 11:34:12 2025 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..693222e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.dSYM \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..75f2ad5 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# comb + +This is a project to enable literate programming in Markdown files. diff --git a/comb.odin b/comb.odin new file mode 100644 index 0000000..38d6776 --- /dev/null +++ b/comb.odin @@ -0,0 +1,42 @@ +package comb + +import "core:log" +import "core:fmt" +import "core:strings" +import "core:flags" +import "core:os/os2" + +example_1 :: string(#load("example_1.md")) + +CLI_Options :: struct { + file_path: string `args:"pos=0,required" usage:"Input file"` +} + +main :: proc() { + context.logger = log.create_console_logger() + opt: CLI_Options + flags.parse_or_exit(&opt, os2.args) + // TODO: handle error + data, _ := os2.read_entire_file(opt.file_path, context.allocator) + defer delete(data) + content := string(data) + // TODO: handle error + blocks, _ := parse_code_blocks(content) + defer for b in blocks do destroy_code_block(b) + if len(blocks) == 0 { + log.info("No code blocks detected.") + return + } + for block, i in blocks { + log.infof("Executing block #%v (%v), starting on line %v...", i, block.language, block.line_start) + stdout, stderr, err := eval_code_block(block) + defer delete(stdout) + defer delete(stderr) + if err != nil { + log.errorf("Failed to execute code block: %v", err) + return + } + log.infof("Stdout: %v", stdout) + log.infof("Stderr: %v", stderr) + } +} diff --git a/comb.sublime-project b/comb.sublime-project new file mode 100644 index 0000000..3885ad6 --- /dev/null +++ b/comb.sublime-project @@ -0,0 +1,82 @@ +{ + "build_systems": [ + { + "cmd": [ + "odin", + "build", + ".", + "-debug", + "-o:none" + ], + "name": "build comb", + "selector": "source.odin", + "working_dir": "${project_path}", + "file_regex": "^\\s*(\\S[^\\(]+)\\((\\d+):(\\d+)\\) (.*)" + }, + { + "shell_cmd": "odin build . -debug -o:none && ./comb", + "name": "build and run", + "selector": "source.odin", + "working_dir": "${project_path}", + "file_regex": "^\\s*(\\S[^\\(]+)\\((\\d+):(\\d+)\\) (.*)" + }, + { + "cmd": [ + "odin", + "run", + ".", + "-debug", + "-o:none", + ], + "name": "run comb", + "selector": "source.odin", + "working_dir": "${project_path}", + "file_regex": "^\\s*(\\S[^\\(]+)\\((\\d+):(\\d+)\\) (.*)" + }, + { + "cmd": [ + "odin", + "test", + ".", + "-debug", + "-o:none", + "-define:ODIN_TEST_GO_TO_ERROR=true" + ], + "name": "test comb", + "selector": "source.odin", + "working_dir": "${project_path}", + "file_regex": "^\\s*(\\S[^\\(]+)\\((\\d+):(\\d+)\\):(.*)" + } + ], + "folders": [ + { + "path": ".", + "folder_exclude_patterns": [ + "*bin*", + "*.dSYM" + ], + "binary_file_patterns": [ + "*.bin" + ] + }, + { + "path": "/opt/homebrew/Cellar/odin/2025-12a/libexec/core", + }, + { + "path": "/opt/homebrew/Cellar/odin/2025-12a/libexec/base", + }, + { + "path": "/opt/homebrew/Cellar/odin/2025-12a/libexec/vendor/raylib", + }, + ], + "debugger_configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Launch", + "program": "${project_path}/comb", + "args": [], + "cwd": "${project_path}" + }, + ], +} diff --git a/comb.sublime-workspace b/comb.sublime-workspace new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/comb.sublime-workspace @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/eval_code_blocks.odin b/eval_code_blocks.odin new file mode 100644 index 0000000..12ee8a8 --- /dev/null +++ b/eval_code_blocks.odin @@ -0,0 +1,92 @@ +#+feature dynamic-literals +package comb + +import os "core:os/os2" +import "core:strings" +import tt "core:testing" + +LangInfo :: struct { + command: []string, + file_extension: string, +} + +lang_info_map := map[string]LangInfo { + "python" = {command = {"python3", "-c"}, file_extension = ".py"}, + "javascript" = {command = {"node", "-e"}, file_extension = ".js"}, + "bash" = {command = {"bash", "-c"}, file_extension = ".sh"}, +} + +EvalError :: union #shared_nil { + enum { + None = 0, + Unknown_Language, + }, + os.Error +} + +eval_code_block :: proc( + cb: Code_Block, + allocator := context.allocator, + temp_allocator := context.temp_allocator, +) -> ( + stdout: string, + stderr: string, + err: EvalError, +) { + info, ok := lang_info_map[cb.language] + if !ok { + err = .Unknown_Language + return + } + + command := make([dynamic]string, allocator = temp_allocator) + reserve(&command, len(info.command) + 1) + for c in info.command { + append(&command, c) + } + append(&command, cb.text) + + env, _ := os.environ(temp_allocator) + process_desc := os.Process_Desc { + command = command[:], + env = env, + } + state, byte_stdout, byte_stderr, os_err := os.process_exec(process_desc, allocator) + if os_err != nil { + delete(byte_stdout) + delete(byte_stderr) + err = os_err + return + } + stdout = string(byte_stdout) + stderr = string(byte_stderr) + return +} + +@(test) +can_eval_code_blocks_python :: proc(t: ^tt.T) { + cb := Code_Block{ + language="python", + text="print('hello world')" + } + defer free_all(context.temp_allocator) + stdout, stderr := eval_code_block(cb) or_else tt.fail_now(t, "should be able to execute code block") + defer delete(stdout) + defer delete(stderr) + tt.expect_value(t, strings.trim_space(stdout), "hello world") + tt.expect_value(t, strings.trim_space(stderr), "") +} + +@(test) +can_eval_code_blocks_javascript :: proc(t: ^tt.T) { + cb := Code_Block{ + language="javascript", + text="console.log('hello world')" + } + defer free_all(context.temp_allocator) + stdout, stderr := eval_code_block(cb) or_else tt.fail_now(t, "should be able to execute code block") + defer delete(stdout) + defer delete(stderr) + tt.expect_value(t, strings.trim_space(stdout), "hello world") + tt.expect_value(t, strings.trim_space(stderr), "") +} diff --git a/example_1.md b/example_1.md new file mode 100644 index 0000000..510cc44 --- /dev/null +++ b/example_1.md @@ -0,0 +1,13 @@ +# hello + +```python +print("Hello world") +``` + +```javascript {Foo=bar} +console.log("hello") +``` + +```bash {Foo=bar, Bazz=quq} +echo "hi" +``` \ No newline at end of file diff --git a/parse_code_blocks.odin b/parse_code_blocks.odin new file mode 100644 index 0000000..fb76160 --- /dev/null +++ b/parse_code_blocks.odin @@ -0,0 +1,137 @@ +package comb + +import "core:strings" +import tt "core:testing" + +Code_Block :: struct { + line_start: int, + line_end: int, + char_start: int, + char_end: int, + language: string, + tags: map[string]string, + text: string, +} + +destroy_code_block :: proc(cb: Code_Block) { + delete(cb.tags) +} + +ParseError :: enum { + None = 0, + Incomplete_Code_Block, + Invalid_Tag, +} + +parse_code_blocks :: proc(s: string) -> (blocks: [dynamic]Code_Block, err: ParseError) { + it := s + in_code_block := false + current_block := Code_Block{} + line_index := 0 + char_index := 0 + for line in strings.split_lines_iterator(&it) { + defer line_index += 1 + // Add +1 for \n character + defer char_index += len(line) + 1 + trimmed_line := strings.trim_space(line) + strings.starts_with(trimmed_line, "```") or_continue + in_code_block = !in_code_block + // We just finished parsing a code block + if !in_code_block { + current_block.line_end = line_index + current_block.char_end = char_index - 1 + current_block.text = s[current_block.char_start:current_block.char_end] + append(&blocks, current_block) + current_block = {} + continue + } + + // It looks like we're starting a code block, but there's no language specified + // That means we shouldn't try to extract it + if len(trimmed_line) == 3 { + in_code_block = false + continue + } + + current_block.line_start = line_index + current_block.char_start = char_index + len(line) + 1 + remaining := strings.trim_space(trimmed_line[3:]) + first_space := strings.index_rune(remaining, ' ') + // There are no keys after the language name + if first_space == -1 { + current_block.language = remaining + continue + } + + current_block.language = remaining[:first_space] + // The +1 is safe because we know there are characters after the space (or else it would have been trimmed) + tag_str := remaining[first_space + 1:] + opening_curly := strings.index_rune(tag_str, '{') + closing_curly := strings.last_index_byte(tag_str, '}') + if closing_curly == opening_curly + 1 do continue + + // TODO: allow curlies if they're in strings + if opening_curly == -1 || closing_curly == -1 || closing_curly < opening_curly { + // TODO: improve error reporting + return blocks, .Invalid_Tag + } + tag_content_str := strings.trim_space(tag_str[opening_curly + 1:closing_curly]) + if tag_content_str == "" do continue + + tag_content_str_it := tag_content_str + for pair in strings.split_iterator(&tag_content_str_it, ",") { + pair := strings.trim_space(pair) + key_start, key_end, value_start := 0, 0, 0 + value_end := len(pair) + pair_loop: for c, i in pair { + if c == '=' { + key_end = i + value_start = i + 1 + current_block.tags[string(pair[key_start:key_end])] = string( + pair[value_start:value_end], + ) + break pair_loop + } + } + } + } + + // TODO: improve error reporting + if in_code_block do return blocks, .Incomplete_Code_Block + + return blocks, .None +} + + +@(test) +parse_blocks_correctly :: proc(t: ^tt.T) { + blocks, err := parse_code_blocks(example_1) + defer delete(blocks) + defer for cb in blocks do destroy_code_block(cb) + tt.expect(t, err == nil, "parse_blocks_correctly should not error") + tt.expect_value(t, len(blocks), 3) + { + b := blocks[0] + tt.expect_value(t, b.language, "python") + tt.expect_value(t, b.text, `print("Hello world")`) + } + + { + b := blocks[1] + tt.expect_value(t, len(b.tags), 1) + val := + b.tags["Foo"] or_else tt.fail_now(t, "the second code block should have a `Foo` key") + tt.expect_value(t, val, "bar") + } + + { + b := blocks[2] + tt.expect_value(t, len(b.tags), 2) + val := + b.tags["Foo"] or_else tt.fail_now(t, "the second code block should have a `Foo` key") + tt.expect_value(t, val, "bar") + val = + b.tags["Bazz"] or_else tt.fail_now(t, "the second code block should have a `Bazz` key") + tt.expect_value(t, val, "quq") + } +}