diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index be97e7a..02fab06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,9 +15,9 @@ jobs: - uses: erlef/setup-beam@v1 with: otp-version: "26.0.2" - gleam-version: "1.5.1" + gleam-version: "1.11.1" rebar3-version: "3" - # elixir-version: "1.15.4" - run: gleam deps download + - run: gleam build --warnings-as-errors - run: gleam test - run: gleam format --check src test diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b03869..1e297a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v2.0.0 - 2025-08-02 + +This is a complete rewrite. Instead of checking or running gleam code via checkmark, +checkmark can now either check that a snippet matches the content in a file, +or update the markdown file with the content. + +Dependencies have also been simplified, and the parsing of the markdown file +no longer uses the package it used to, as it had some issues in it, +and doesn't seem to be actively maintained. + ## v1.0.0 - 2024-11-25 ### Added @@ -10,4 +20,4 @@ ## v0.1.0 - 2024-11-10 -Initial release \ No newline at end of file +Initial release diff --git a/README.md b/README.md index b623150..c975d61 100644 --- a/README.md +++ b/README.md @@ -3,33 +3,38 @@ [![Package Version](https://img.shields.io/hexpm/v/checkmark)](https://hex.pm/packages/checkmark) [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/checkmark/) -Checkmark is a library for checking that gleam code in markdown files -type checks, builds, or runs successfully. +Checkmark is a library for either checking that code in markdown files +matches content in files, or to automatically update it. ## Example ```sh gleam add checkmark +gleam add simplifile +gleam add envoy ``` ```gleam import checkmark -import gleam/string - -pub fn main() { - // Checks that a single gleam code block in README.md that starts with "import" - // passes type checks, by creating the temporary file `checkmark_tmp.gleam` - // Checking in a temporary project is also supported. - checkmark.new() - |> checkmark.snippets_in("README.md") - |> checkmark.filtering(string.starts_with(_, "import")) - |> checkmark.check_in_current_package("checkmark_tmp.gleam") - |> checkmark.print_failures(panic_if_failed: True) +import envoy +import simplifile + +pub fn example_test() { + assert checkmark.new(simplifile.read, simplifile.write) + |> checkmark.file("README.md") + |> checkmark.should_contain_contents_of("./example.sh", tagged: "sh") + |> checkmark.should_contain_contents_of( + "./test/example_test.gleam", + tagged: "gleam", + ) + // Update locally, check on CI + |> checkmark.check_or_update( + when: envoy.get("GITHUB_WORKFLOW") == Error(Nil), + ) + == Ok(Nil) } ``` -Note that you could assert the results from `check_in...` functions, -but on Erlang, you will most probably get very ugly output. -`print_failures` will pretty-print the failures to stderr and panic if requested. +These examples are, in fact, kept up to date and checked using checkmark! Further documentation can be found at . diff --git a/example.sh b/example.sh new file mode 100644 index 0000000..d54d970 --- /dev/null +++ b/example.sh @@ -0,0 +1,3 @@ +gleam add checkmark +gleam add simplifile +gleam add envoy diff --git a/gleam.toml b/gleam.toml index 2c90e98..2d3a078 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "checkmark" -version = "1.0.0" +version = "2.0.0" description = "Gleam markdown snippet validator" licences = ["Apache-2.0"] @@ -8,12 +8,10 @@ internal_modules = ["test_overwrite"] [dependencies] gleam_stdlib = ">= 0.34.0 and < 2.0.0" -kirala_markdown = ">= 1.0.2 and < 2.0.0" -simplifile = ">= 2.2.0 and < 3.0.0" -temporary = ">= 1.0.0 and < 2.0.0" -shellout = ">= 1.6.0 and < 2.0.0" -filepath = ">= 1.0.0 and < 2.0.0" -exception = ">= 2.0.0 and < 3.0.0" +splitter = ">= 1.0.0 and < 2.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" +simplifile = ">= 2.2.0 and < 3.0.0" +gleam_erlang = ">= 1.2.0 and < 2.0.0" +envoy = ">= 1.0.2 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 5c781f9..af4e2b5 100644 --- a/manifest.toml +++ b/manifest.toml @@ -3,23 +3,18 @@ packages = [ { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, - { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, - { name = "filepath", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "67A6D15FB39EEB69DD31F8C145BB5A421790581BD6AA14B33D64D5A55DBD6587" }, - { name = "gleam_crypto", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "8AE56026B3E05EBB1F076778478A762E9EB62B31AEEB4285755452F397029D22" }, - { name = "gleam_stdlib", version = "0.43.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "69EF22E78FDCA9097CBE7DF91C05B2A8B5436826D9F66680D879182C0860A747" }, - { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, - { name = "kirala_markdown", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "kirala_markdown", source = "hex", outer_checksum = "A3E1578F5C33540087902BE54831F653AED2ABD2ACDDE498ADCA7AA3A7EEE7CC" }, - { name = "shellout", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "E2FCD18957F0E9F67E1F497FC9FF57393392F8A9BAEAEA4779541DE7A68DD7E0" }, - { name = "simplifile", version = "2.2.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0DFABEF7DC7A9E2FF4BB27B108034E60C81BEBFCB7AB816B9E7E18ED4503ACD8" }, - { name = "temporary", version = "1.0.0", build_tools = ["gleam"], requirements = ["envoy", "exception", "filepath", "gleam_crypto", "gleam_stdlib", "simplifile"], otp_app = "temporary", source = "hex", outer_checksum = "51C0FEF4D72CE7CA507BD188B21C1F00695B3D5B09D7DFE38240BFD3A8E1E9B3" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_erlang", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F91CE62A2D011FA13341F3723DB7DB118541AAA5FE7311BD2716D018F01EF9E3" }, + { name = "gleam_stdlib", version = "0.62.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "DC8872BC0B8550F6E22F0F698CFE7F1E4BDA7312FDEB40D6C3F44C5B706C8310" }, + { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, + { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, + { name = "splitter", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "128FC521EE33B0012E3E64D5B55168586BC1B9C8D7B0D0CA223B68B0D770A547" }, ] [requirements] -exception = { version = ">= 2.0.0 and < 3.0.0" } -filepath = { version = ">= 1.0.0 and < 2.0.0" } +envoy = { version = ">= 1.0.2 and < 2.0.0" } +gleam_erlang = { version = ">= 1.2.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } -kirala_markdown = { version = ">= 1.0.2 and < 2.0.0" } -shellout = { version = ">= 1.6.0 and < 2.0.0" } simplifile = { version = ">= 2.2.0 and < 3.0.0" } -temporary = { version = ">= 1.0.0 and < 2.0.0" } +splitter = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/src/checkmark.gleam b/src/checkmark.gleam index 29846e8..314f9d7 100644 --- a/src/checkmark.gleam +++ b/src/checkmark.gleam @@ -1,288 +1,217 @@ -import exception -import filepath -import gleam/function -import gleam/io +import checkmark/internal/parser.{type Fence} +import gleam/dict.{type Dict} import gleam/list -import gleam/option.{type Option, None, Some} -import gleam/result.{try} +import gleam/option.{type Option} +import gleam/result import gleam/string -import kirala/markdown/parser -import shellout -import simplifile -import temporary +import splitter -/// Specifies the `gleam` CLI operation to run on each snippet -pub type Operation { - /// Will run `gleam check` on the code - Check - /// Will run `gleam build` on the code - Build - /// Will run `gleam run` on the code - Run +pub opaque type Checker(e) { + Checker( + read: fn(String) -> Result(String, e), + write: fn(String, String) -> Result(Nil, e), + ) } -/// Indicates a check on an individual snippet failed -pub type CheckError { - /// Running the check failed before we could get the result from `gleam`. - /// The string value might currently be a bit cryptic, it should be improved. - RunFailed(String) - /// We were able to run the `gleam` operation, but it failed with the given output. - CheckFailed(String) +pub opaque type File(e) { + File(name: String, checker: Checker(e), expectations: List(#(String, String))) } -/// Type alias for a result of checking a single snippet. -pub type CheckResult = - Result(Nil, CheckError) - -/// Configuration for checking a markdown file. -pub opaque type CheckConfig { - CheckConfig( - filename: String, - filter: fn(String) -> Bool, - operation: Operation, - ) +pub type CheckError(e) { + CouldNotReadFile(error: e) + CouldNotWriteFile(error: e) + TagNotFound(tag: String) + MultipleTagsFound(tag: String, lines: List(Int)) + ContentDidNotMatch(tag: String) } -/// Constructs a new check configuration with the defaults of -/// running `gleam check` on all snippets in the README.md file. -pub fn new() -> CheckConfig { - CheckConfig("README.md", fn(_) { True }, Check) +pub fn new( + read_file: fn(String) -> Result(String, e), + write_file: fn(String, String) -> Result(Nil, e), +) -> Checker(e) { + Checker(read_file, write_file) } -/// Specifies the markdown file to check (default is README.md) -pub fn snippets_in(config: CheckConfig, filename: String) -> CheckConfig { - CheckConfig(..config, filename: filename) +pub fn file(checker: Checker(e), filename: String) -> File(e) { + File(filename, checker, []) } -/// Filters snippets by their content. E.g. `string.starts_with(_, "import")` -pub fn filtering(config: CheckConfig, filter: fn(String) -> Bool) -> CheckConfig { - CheckConfig(..config, filter: filter) +pub fn should_contain_contents_of( + file: File(e), + source: String, + tagged tag: String, +) -> File(e) { + File(..file, expectations: [#(source, tag), ..file.expectations]) } -/// Uses the given operation, default is `Check` -pub fn using(config: CheckConfig, operation: Operation) -> CheckConfig { - CheckConfig(..config, operation: operation) +pub fn check_or_update( + file: File(e), + when should_update: Bool, +) -> Result(Nil, List(CheckError(e))) { + case should_update { + True -> update(file) + False -> check(file) + } } -/// Runs checks in the current package, -/// writing into temporary file at `src/{filename}`. -/// The result will be `Error` if the whole operation failed (e.g. couldn't read snippets) -/// or `Ok` with a list of results for each snippet found in the file. -pub fn check_in_current_package( - config: CheckConfig, - as_file filename: String, -) -> Result(List(CheckResult), String) { - use snippet <- check_snippets_in(config.filename, config.filter) - check_in_dir(snippet, ".", filename, False, config.operation) -} +pub fn check(file: File(e)) -> Result(Nil, List(CheckError(e))) { + use contents <- result.try(parse_file(file)) + let results = { + use #(filename, tag) <- list.map(file.expectations) + use expected <- result.try(read_file(file.checker, filename)) + check_one(contents, expected, tag) + } -/// Runs checks in a temporarily set up package, -/// installing the given dependencies using `gleam add`. -/// The result will be `Error` if the whole operation failed (e.g. couldn't read snippets) -/// or `Ok` with a list of results for each snippet found in the file. -pub fn check_in_tmp_package( - config: CheckConfig, - dependencies: List(String), -) -> Result(List(CheckResult), String) { - use temp_dir <- with_tempdir() - use package_dir <- try(set_up_package(temp_dir, dependencies)) - use snippet <- check_snippets_in(config.filename, config.filter) + let #(_, errors) = result.partition(results) - check_in_dir( - snippet, - package_dir, - package_name <> ".gleam", - True, - config.operation, - ) + case errors { + [] -> Ok(Nil) + _ -> Error(errors) + } } -/// Prints errors to stderr, as using `panic` or `let assert` -/// doesn't usually print nicely on erlang -/// (the data is shown as binary, -/// as the compiler output contains lost non-ASCII characters). -/// Optionally panic, if `panic_if_failed` is `True`. -pub fn print_failures( - result: Result(List(CheckResult), String), - panic_if_failed panic_if_failed: Bool, -) -> Nil { - let error_messages = case result { - Ok(results) -> { - case list.any(results, result.is_error) { - True -> summarize_results(results) - False -> None - } - } - Error(error) -> Some("Failed to run: " <> error) +pub fn update(file: File(e)) -> Result(Nil, List(CheckError(e))) { + use contents <- result.try(parse_file(file)) + let results = { + use #(filename, tag) <- list.map(file.expectations) + use _ <- result.try(find_match(contents, tag, [])) + use expected <- result.try(read_file(file.checker, filename)) + Ok(#(tag, expected)) } - case error_messages { - Some(errors) -> { - io.println_error(errors) - case panic_if_failed { - True -> panic as "checkmark checks failed!" - False -> Nil - } + let #(replacements, errors) = result.partition(results) + + case errors { + [] -> { + let contents = render_replacements(contents, dict.from_list(replacements)) + file.checker.write(file.name, contents) + |> result.map_error(fn(e) { [CouldNotWriteFile(e)] }) } - None -> Nil + _ -> Error(errors) } } -fn summarize_results(results: List(CheckResult)) -> Option(String) { - Some({ - let indexed = - list.range(1, list.length(results)) - |> list.zip(results) - { - use #(index, result) <- list.flat_map(indexed) - - let #(status, error) = case result { - Error(e) -> - case e { - CheckFailed(e) -> #("Check failed!", Some(e)) - RunFailed(e) -> #("Failed to run check!", Some(e)) - } - Ok(_) -> #("Check passed!", None) +fn render_replacements( + contents: List(parser.Section), + replacements: Dict(String, String), +) -> String { + contents + |> list.map(fn(section) { + case section { + parser.FencedCode(_, content, start_fence, end_fence) -> { + case dict.get(replacements, string.trim(start_fence.info)) { + Ok(replacement) -> render_code(start_fence, replacement, end_fence) + Error(_) -> render_code(start_fence, content, end_fence) + } } - - [ - Some(""), - Some("Snippet " <> string.inspect(index) <> ": " <> status), - Some("-----------------------------------------"), - error, - ] + parser.Other(_, content) -> content } - |> option.values - |> string.join("\n") }) -} - -fn set_up_package( - tmp_dir: String, - dependencies: List(String), -) -> Result(String, String) { - use _ <- try(run_gleam( - in: tmp_dir, - with: ["new", package_name], - fail_as: function.identity, - )) - - let package_dir = filepath.join(tmp_dir, package_name) - use _ <- try(case dependencies { - [] -> Ok(Nil) - _ -> - run_gleam( - in: package_dir, - with: ["add", ..dependencies], - fail_as: function.identity, + |> string.join("") +} + +fn render_code( + start_fence: Fence, + content: String, + end_fence: Option(Fence), +) -> String { + // This is not very optimized, but we probably don't need to care... + + let content = case start_fence.indent { + 0 -> content + amount -> + indent( + splitter.new(["\n", "\r\n"]), + string.repeat(" ", amount), + content, + [], ) - }) - - Ok(package_dir) -} - -fn check_snippets_in( - filename: String, - filter: fn(String) -> Bool, - check: fn(String) -> CheckResult, -) -> Result(List(CheckResult), String) { - use content <- try( - simplifile.read(from: filename) |> result.map_error(string.inspect), - ) - - Ok(extract_gleam_code(content, filter) |> list.map(check)) -} - -fn extract_gleam_code( - markdown: String, - filter: fn(String) -> Bool, -) -> List(String) { - let tokens = parser.parse_all(markdown) - use token <- list.filter_map(tokens) - case token { - parser.CodeBlock("gleam", _, code) -> { - case filter(code) { - True -> Ok(code) - _ -> Error(Nil) - } - } - _ -> Error(Nil) } -} - -const package_name = "checkmark_tmp" - -fn check_in_dir( - code: String, - package_dir: String, - filename: String, - allow_overwrite: Bool, - operation: Operation, -) -> CheckResult { - let source_dir = filepath.join(package_dir, "src") - let source_file = filepath.join(source_dir, filename) - use file <- with_tempfile(source_file, allow_overwrite) - use _ <- try( - simplifile.write(to: file, contents: code) - |> result.map_error(fn(e) { RunFailed(string.inspect(e)) }), - ) - - case string.split(filename, ".") { - [] -> Error(CheckFailed("Invalid source file name: " <> filename)) - [module, ..] -> - run_gleam( - with: to_args(operation, module), - in: package_dir, - fail_as: CheckFailed, - ) + let without_end = render_fence(start_fence) <> content + case end_fence { + option.None -> without_end + option.Some(end_fence) -> without_end <> render_fence(end_fence) } } -fn with_tempdir( - operation: fn(String) -> Result(List(CheckResult), String), -) -> Result(List(CheckResult), String) { - { - use temp_dir <- temporary.create(temporary.directory()) - operation(temp_dir) +fn indent( + line_ends: splitter.Splitter, + with: String, + rest: String, + parts: List(String), +) -> String { + case rest { + "" -> parts |> list.reverse |> string.join("") + _ -> { + let #(content, ending, rest) = splitter.split(line_ends, rest) + indent(line_ends, with, rest, [ending, content, with, ..parts]) + } } - |> result.map_error(string.inspect) - |> result.flatten } -fn with_tempfile( - path: String, - allow_overwrite: Bool, - operation: fn(String) -> CheckResult, -) -> CheckResult { - use _ <- try(create_file(path, allow_overwrite)) - use <- exception.defer(fn() { simplifile.delete(path) }) - operation(path) +fn render_fence(fence: Fence) -> String { + string.repeat(" ", fence.indent) <> fence.fence <> fence.info } -fn create_file(path: String, allow_overwrite: Bool) -> CheckResult { - case simplifile.create_file(path) { - Error(simplifile.Eexist) if allow_overwrite -> Ok(Nil) - Ok(_) -> Ok(Nil) - Error(e) -> Error(RunFailed(string.inspect(e))) - } +fn parse_file( + file: File(e), +) -> Result(List(parser.Section), List(CheckError(e))) { + read_file(file.checker, file.name) + |> result.map_error(list.wrap) + |> result.map(parser.parse) } -fn run_gleam( - in directory: String, - with args: List(String), - fail_as make_error: fn(String) -> e, -) -> Result(Nil, e) { - case shellout.command("gleam", with: args, in: directory, opt: []) { - Ok(_) -> Ok(Nil) - Error(#(_, e)) -> Error(make_error(e)) +fn read_file( + checker: Checker(e), + filename: String, +) -> Result(String, CheckError(e)) { + checker.read(filename) |> result.map_error(CouldNotReadFile) +} + +fn check_one( + content: List(parser.Section), + expected: String, + tag: String, +) -> Result(Nil, CheckError(e)) { + use snippet <- result.try(find_match(content, tag, [])) + case snippet.content == expected { + True -> Ok(Nil) + False -> Error(ContentDidNotMatch(tag)) } } -fn to_args(op: Operation, module: String) -> List(String) { - case op { - Build -> ["build"] - Check -> ["check"] - Run -> ["run", "--module", module] +type Snippet { + Snippet(content: String, start_fence: Fence, end_fence: Option(Fence)) +} + +fn find_match( + content: List(parser.Section), + tag: String, + found: List(#(Int, Snippet)), +) -> Result(Snippet, CheckError(e)) { + case content { + [] -> + case found { + [] -> Error(TagNotFound(tag)) + [#(_, snippet)] -> Ok(snippet) + _ -> + Error(MultipleTagsFound( + tag, + found |> list.reverse |> list.map(fn(pair) { pair.0 }), + )) + } + + [parser.FencedCode(line, content, start_fence, end_fence), ..rest] -> { + case string.trim(start_fence.info) == tag { + True -> { + let result = #(line, Snippet(content, start_fence, end_fence)) + find_match(rest, tag, [result, ..found]) + } + False -> find_match(rest, tag, found) + } + } + + [_, ..rest] -> find_match(rest, tag, found) } } diff --git a/src/checkmark/internal/parser.gleam b/src/checkmark/internal/parser.gleam new file mode 100644 index 0000000..14b0911 --- /dev/null +++ b/src/checkmark/internal/parser.gleam @@ -0,0 +1,180 @@ +import gleam/bool +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string +import splitter.{type Splitter} + +pub type Section { + Other(start_line: Int, content: String) + FencedCode( + start_line: Int, + content: String, + start_fence: Fence, + end_fence: Option(Fence), + ) +} + +pub type Fence { + Fence(fence: String, info: String, indent: Int) +} + +type SectionBuilder { + OtherBuilder(start_line: Int, parts: List(String)) + FencedCodeBuilder(start_line: Int, parts: List(String), start_fence: Fence) +} + +pub fn parse(content: String) -> List(Section) { + use <- bool.guard(when: content == "", return: []) + + let line_ends = splitter.new(["\n", "\r\n"]) + let #(line, ending, rest) = splitter.split(line_ends, content) + parse_lines(line_ends, 1, [], None, line, ending, rest) +} + +fn parse_lines( + splitter: Splitter, + line: Int, + sections: List(Section), + current_section: Option(SectionBuilder), + content: String, + ending: String, + rest: String, +) { + let #(sections, current_section) = case parse_fence(content, ending) { + None -> { + let current_section = case current_section { + None -> OtherBuilder(line, [ending, content]) + Some(builder) -> { + let parts = [ending, content, ..builder.parts] + case builder { + FencedCodeBuilder(line, _, start_fence:) -> + FencedCodeBuilder(line, parts, start_fence) + OtherBuilder(line, _) -> OtherBuilder(line, parts) + } + } + } + #(sections, Some(current_section)) + } + + Some(fence) -> + case current_section { + None -> #(sections, Some(FencedCodeBuilder(line, [], fence))) + Some(OtherBuilder(prev_line, parts)) -> #( + [Other(prev_line, parts_to_string(parts, 0)), ..sections], + Some(FencedCodeBuilder(line, [], fence)), + ) + Some(FencedCodeBuilder(start_line:, parts:, start_fence:)) -> + case should_close(start_fence, fence) { + True -> #( + [ + FencedCode( + start_line, + parts_to_string(parts, start_fence.indent), + start_fence, + Some(fence), + ), + ..sections + ], + None, + ) + False -> #( + sections, + Some(FencedCodeBuilder( + start_line, + [ending, content, ..parts], + start_fence, + )), + ) + } + } + } + + case rest { + "" -> { + let sections = case current_section { + None -> sections + Some(builder) -> + case builder { + FencedCodeBuilder(start_line:, start_fence:, parts:) -> [ + FencedCode( + start_line, + parts_to_string(parts, start_fence.indent), + start_fence, + None, + ), + ..sections + ] + OtherBuilder(start_line:, parts:) -> [ + Other(start_line, parts_to_string(parts, 0)), + ..sections + ] + } + } + list.reverse(sections) + } + + rest -> { + let #(content, ending, rest) = splitter.split(splitter, rest) + parse_lines( + splitter, + line + 1, + sections, + current_section, + content, + ending, + rest, + ) + } + } +} + +fn should_close(start: Fence, end: Fence) { + string.starts_with(end.fence, start.fence) +} + +fn parts_to_string(parts: List(String), indent: Int) -> String { + parts + |> list.map(remove_indent_up_to(_, indent)) + |> list.reverse + |> string.join("") +} + +fn remove_indent_up_to(string: String, indent: Int) -> String { + case indent, string { + 0, _ -> string + _, " " <> rest -> remove_indent_up_to(rest, indent - 1) + _, _ -> string + } +} + +fn parse_fence(line: String, ending: String) -> Option(Fence) { + // A code fence is a sequence of at least three consecutive backtick + // characters (`) or tildes (~). (Tildes and backticks cannot be mixed.) + // A fenced code block begins with a code fence, + // preceded by up to three spaces of indentation. + case line { + "```" <> rest -> Some(build_fence("`", 0, 3, rest, ending)) + " ```" <> rest -> Some(build_fence("`", 1, 3, rest, ending)) + " ```" <> rest -> Some(build_fence("`", 2, 3, rest, ending)) + " ```" <> rest -> Some(build_fence("`", 3, 3, rest, ending)) + "~~~" <> rest -> Some(build_fence("~", 0, 3, rest, ending)) + " ~~~" <> rest -> Some(build_fence("~", 1, 3, rest, ending)) + " ~~~" <> rest -> Some(build_fence("~", 2, 3, rest, ending)) + " ~~~" <> rest -> Some(build_fence("~", 3, 3, rest, ending)) + _ -> None + } +} + +fn build_fence( + character: String, + indent: Int, + delimiters: Int, + rest: String, + ending: String, +) -> Fence { + case string.pop_grapheme(rest) { + Ok(#(first, rest)) if first == character -> + build_fence(character, indent, delimiters + 1, rest, ending) + _ -> Fence(string.repeat(character, delimiters), rest <> ending, indent) + } +} diff --git a/src/test_overwrite.gleam b/src/test_overwrite.gleam deleted file mode 100644 index 630dc6c..0000000 --- a/src/test_overwrite.gleam +++ /dev/null @@ -1 +0,0 @@ -// This file exists only to test that local files can't be overwritten diff --git a/test/checkmark/internal/parser_test.gleam b/test/checkmark/internal/parser_test.gleam new file mode 100644 index 0000000..3b5282d --- /dev/null +++ b/test/checkmark/internal/parser_test.gleam @@ -0,0 +1,75 @@ +import checkmark/internal/parser.{Fence, FencedCode, Other} +import gleam/option.{None, Some} + +pub fn empty_string_test() { + assert parser.parse("") == [] +} + +pub fn no_snippets_test() { + let text = "one\ntwo\r\nthree" + assert parser.parse(text) == [parser.Other(1, text)] +} + +pub fn basic_snippet_test() { + assert parser.parse("start\n```gleam\ncode\r\nmore_code\n``` \nrest") + == [ + Other(1, "start\n"), + FencedCode( + 2, + "code\r\nmore_code\n", + Fence("```", "gleam\n", 0), + Some(Fence("```", " \n", 0)), + ), + Other(6, "rest"), + ] +} + +pub fn indented_snippet_test() { + assert parser.parse( + " ```gleam\n code\n more_code\n not indented enough\n ```", + ) + == [ + FencedCode( + 1, + "code\n more_code\nnot indented enough\n", + Fence("```", "gleam\n", 3), + Some(Fence("```", "", 2)), + ), + ] +} + +pub fn non_matching_fences_test() { + assert parser.parse("````\n```\n~~~\ncode\n````") + == [ + FencedCode( + 1, + "```\n~~~\ncode\n", + Fence("````", "\n", 0), + Some(Fence("````", "", 0)), + ), + ] +} + +pub fn missing_end_fence_test() { + assert parser.parse("```\ncode") + == [FencedCode(1, "code", Fence("```", "\n", 0), None)] +} + +pub fn empty_fence_test() { + assert parser.parse("```info") + == [FencedCode(1, "", Fence("```", "info", 0), None)] +} + +pub fn emtpy_line_preservation_test() { + assert parser.parse("\ntext\n\n```\n\ncode\n\n```\n\n\n") + == [ + Other(1, "\ntext\n\n"), + FencedCode( + 4, + "\ncode\n\n", + Fence("```", "\n", 0), + Some(Fence("```", "\n", 0)), + ), + Other(9, "\n\n"), + ] +} diff --git a/test/checkmark_test.gleam b/test/checkmark_test.gleam index 392f9a2..929b0f3 100644 --- a/test/checkmark_test.gleam +++ b/test/checkmark_test.gleam @@ -1,82 +1,60 @@ -import checkmark -import filepath -import gleam/string +import checkmark.{CouldNotReadFile, MultipleTagsFound, TagNotFound} +import gleam/erlang/process import gleeunit +import simplifile pub fn main() { gleeunit.main() } -const default_file = "checkmark_tmp.gleam" - -pub fn check_our_readme_test() { - checkmark.new() - |> checkmark.check_in_current_package(default_file) - |> checkmark.print_failures(panic_if_failed: True) -} - -pub fn check_ok_local_test() { - let assert Ok([Ok(_)]) = - checkmark.new() - |> checkmark.snippets_in(test_asset("ok.md")) - |> checkmark.check_in_current_package(default_file) -} - -pub fn check_ok_isolated_test() { - let assert Ok([Ok(_)]) = - checkmark.new() - |> checkmark.snippets_in(test_asset("ok.md")) - |> checkmark.check_in_tmp_package(["filepath"]) -} - -pub fn check_local_overwrite_not_allowed() { - let assert Ok([Error(_)]) = - checkmark.new() - |> checkmark.snippets_in(test_asset("ok.md")) - |> checkmark.check_in_current_package("test_overwrite.gleam") -} - -pub fn check_ok_and_failure_test() { - let assert Ok([Ok(_), Error(checkmark.CheckFailed(_))]) = - checkmark.new() - |> checkmark.snippets_in(test_asset("ok_and_failure.md")) - |> checkmark.check_in_current_package(default_file) -} - -pub fn check_runtime_failure_local_test() { - let assert Ok([Error(checkmark.CheckFailed(error))]) = - checkmark.new() - |> checkmark.using(checkmark.Run) - |> checkmark.snippets_in(test_asset("runtime_failure.md")) - |> checkmark.check_in_current_package(default_file) - let assert True = string.contains(error, "My Panic") -} - -pub fn check_runtime_failure_isolated_test() { - let assert Ok([Error(checkmark.CheckFailed(error))]) = - checkmark.new() - |> checkmark.using(checkmark.Run) - |> checkmark.snippets_in(test_asset("runtime_failure.md")) - |> checkmark.check_in_tmp_package([]) - let assert True = string.contains(error, "My Panic") -} - -pub fn check_type_failure_test() { - let assert Ok([Error(checkmark.CheckFailed(error))]) = - checkmark.new() - |> checkmark.snippets_in(test_asset("type_failure.md")) - |> checkmark.check_in_current_package(default_file) - let assert True = string.contains(error, "Type mismatch") -} - -pub fn check_filter_test() { - let assert Ok([Ok(_)]) = - checkmark.new() - |> checkmark.snippets_in(test_asset("with_and_without_import.md")) - |> checkmark.filtering(string.starts_with(_, "import")) - |> checkmark.check_in_current_package(default_file) -} - -fn test_asset(filename: String) -> String { - filepath.join("test", filename) +pub fn check_existing_file_test() { + assert checkmark.new(simplifile.read, simplifile.write) + |> checkmark.file("./test/test.md") + |> checkmark.should_contain_contents_of( + "./test/test_content.txt", + tagged: "multiple", + ) + |> checkmark.should_contain_contents_of( + "./test/test_content.txt", + tagged: "single", + ) + |> checkmark.should_contain_contents_of( + "./test/test_content.txt", + tagged: "not_present", + ) + |> checkmark.check() + == Error([MultipleTagsFound("multiple", [1, 6]), TagNotFound("not_present")]) +} + +pub fn check_missing_markdown_file_test() { + assert checkmark.new(simplifile.read, simplifile.write) + |> checkmark.file("this-file-does-not-exist") + |> checkmark.check() + == Error([CouldNotReadFile(simplifile.Enoent)]) +} + +pub fn check_missing_source_file_test() { + assert checkmark.new(simplifile.read, simplifile.write) + |> checkmark.file("./test/test.md") + |> checkmark.should_contain_contents_of("this-file-does-not-exist", "") + |> checkmark.check() + == Error([CouldNotReadFile(simplifile.Enoent)]) +} + +pub fn update_test() { + let self = process.new_subject() + let write = fn(_, content) { + process.send(self, content) + Ok(Nil) + } + + assert checkmark.new(simplifile.read, write) + |> checkmark.file("./test/update.md") + |> checkmark.should_contain_contents_of("./test/test_content.txt", "update") + |> checkmark.update() + == Ok(Nil) + + let assert Ok(written) = process.receive(self, 0) + let assert Ok(expected) = simplifile.read("./test/expected_updated.md") + assert written == expected } diff --git a/test/example_test.gleam b/test/example_test.gleam new file mode 100644 index 0000000..52a8171 --- /dev/null +++ b/test/example_test.gleam @@ -0,0 +1,18 @@ +import checkmark +import envoy +import simplifile + +pub fn example_test() { + assert checkmark.new(simplifile.read, simplifile.write) + |> checkmark.file("README.md") + |> checkmark.should_contain_contents_of("./example.sh", tagged: "sh") + |> checkmark.should_contain_contents_of( + "./test/example_test.gleam", + tagged: "gleam", + ) + // Update locally, check on CI + |> checkmark.check_or_update( + when: envoy.get("GITHUB_WORKFLOW") == Error(Nil), + ) + == Ok(Nil) +} diff --git a/test/expected_updated.md b/test/expected_updated.md new file mode 100644 index 0000000..afad713 --- /dev/null +++ b/test/expected_updated.md @@ -0,0 +1,14 @@ +Here's some text. + + ```update + test + content + ```` + +Here's some more text. + +``` not-replaced +This should not be replaced. +``` + +And some more text. diff --git a/test/ok.md b/test/ok.md deleted file mode 100644 index aef179a..0000000 --- a/test/ok.md +++ /dev/null @@ -1,10 +0,0 @@ -# This is my readme - -And here is some Gleam code: -```gleam -import filepath - -pub fn main() { - let path = filepath.join("/home/lucy", "pokemon-cards") -} -``` \ No newline at end of file diff --git a/test/ok_and_failure.md b/test/ok_and_failure.md deleted file mode 100644 index 6f27015..0000000 --- a/test/ok_and_failure.md +++ /dev/null @@ -1,19 +0,0 @@ -# This is my readme - -And here is some Gleam code that's ok: -```gleam -import filepath - -pub fn main() { - let path = filepath.join("/home/lucy", "pokemon-cards") -} -``` - -And here is some Gleam code that's not type check: -```gleam -import gleam/list - -pub fn main() { - list.filter([], 42) -} -``` \ No newline at end of file diff --git a/test/runtime_failure.md b/test/runtime_failure.md deleted file mode 100644 index 7e7a903..0000000 --- a/test/runtime_failure.md +++ /dev/null @@ -1,8 +0,0 @@ -# This is my readme - -And here is some Gleam code: -```gleam -pub fn main() { - panic as "My Panic" -} -``` \ No newline at end of file diff --git a/test/test.md b/test/test.md new file mode 100644 index 0000000..b8f5e25 --- /dev/null +++ b/test/test.md @@ -0,0 +1,15 @@ +```multiple +test +content +``` + +~~~~ multiple +test +content +~~~~ + +```single +test +content +``` + diff --git a/test/test_content.txt b/test/test_content.txt new file mode 100644 index 0000000..3a42665 --- /dev/null +++ b/test/test_content.txt @@ -0,0 +1,2 @@ +test +content diff --git a/test/type_failure.md b/test/type_failure.md deleted file mode 100644 index 3eabeb7..0000000 --- a/test/type_failure.md +++ /dev/null @@ -1,10 +0,0 @@ -# This is my readme - -And here is some Gleam code that doesn't type check: -```gleam -import gleam/list - -pub fn main() { - list.filter([], 42) -} -``` \ No newline at end of file diff --git a/test/update.md b/test/update.md new file mode 100644 index 0000000..1862836 --- /dev/null +++ b/test/update.md @@ -0,0 +1,13 @@ +Here's some text. + + ```update + This snippet is out of date + ```` + +Here's some more text. + +``` not-replaced +This should not be replaced. +``` + +And some more text. diff --git a/test/with_and_without_import.md b/test/with_and_without_import.md deleted file mode 100644 index 6694d35..0000000 --- a/test/with_and_without_import.md +++ /dev/null @@ -1,17 +0,0 @@ -# This is my readme - -And here is some Gleam code that's ok: -```gleam -import filepath - -pub fn main() { - let path = filepath.join("/home/lucy", "pokemon-cards") -} -``` - -And here is some Gleam code that does not start with import: -```gleam -pub fn main() { - list.filter([], 42) -} -``` \ No newline at end of file