diff --git a/CHANGELOG.md b/CHANGELOG.md index afbd02a..8f2371c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v3.0.1 2025-12-08 + +### Fixed +- Fixes handling empty lines when using Gleam code snippets. + ## v3.0.0 2025-12-07 This is a mostly backwards compatible release, diff --git a/README.md b/README.md index e8119bb..eebf966 100644 --- a/README.md +++ b/README.md @@ -3,27 +3,54 @@ [![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 either checking that code in markdown files -matches content in files, or to automatically update it. +Checkmark helps you keep your markdown code snippets up to date! +You can either check that snippets match sources, +or automatically update them. -## Example +A source (of truth) can be one of the following: +* An entire file (any language) +* A full Gleam function definition +* A Gleam function body +* A Gleam type definition -```sh +A target to check or update can be: +* A code snippet in a markdown file +* A markdown code snippet in a Gleam comment + +All types of sources and targets can be freely mixed and matched. + +## Examples + +Add the dependencies required by the example: + +```sh deps gleam add --dev checkmark simplifile envoy ``` -```gleam +Update a markdown file: + +```gleam markdown import checkmark import envoy import simplifile pub fn example_test() { - assert checkmark.new(simplifile.read, simplifile.write) + let checker = checkmark.new(simplifile.read, simplifile.write) + + let assert Ok(snippets) = + checkmark.load_snippet_source(checker, "./test/doc_snippets_test.gleam") + + assert checker |> checkmark.document("README.md") - |> checkmark.should_contain_contents_of("./example.sh", tagged: "sh") + |> checkmark.should_contain_contents_of("./example.sh", tagged: "sh deps") |> checkmark.should_contain_contents_of( "./test/example_test.gleam", - tagged: "gleam", + tagged: "gleam markdown", + ) + |> checkmark.should_contain_snippet_from( + snippets, + checkmark.FunctionBody("update_docs_test"), + tagged: "update comments", ) // Update locally, check on CI |> checkmark.check_or_update( @@ -33,6 +60,33 @@ pub fn example_test() { } ``` +Update comments in code: + +```gleam update comments +let checker = checkmark.new(simplifile.read, simplifile.write) + +let assert Ok(snippets) = + checkmark.load_snippet_source(checker, "./test/doc_snippets_test.gleam") + +assert checker + |> checkmark.comments_in("./src/checkmark.gleam") + |> checkmark.should_contain_snippet_from( + snippets, + checkmark.FunctionBody("contents_of_example"), + tagged: "contents_of", + ) + |> checkmark.should_contain_snippet_from( + snippets, + checkmark.FunctionBody("snippet_from_example"), + tagged: "snippet_from", + ) + // Update locally, check on CI + |> checkmark.check_or_update( + when: envoy.get("GITHUB_WORKFLOW") == Error(Nil), + ) + == Ok(Nil) +``` + These examples are, in fact, kept up to date and checked using checkmark! Further documentation can be found at . diff --git a/src/checkmark.gleam b/src/checkmark.gleam index 7df042c..3fde8bc 100644 --- a/src/checkmark.gleam +++ b/src/checkmark.gleam @@ -1,5 +1,4 @@ -//// Link code in files with code blocks in markdown or comments, -//// and check that they are up to date, or update them automatically. +//// Keep code blocks in markdown files or Gleam comments up to date. import checkmark/internal/code_extractor import checkmark/internal/lines.{to_lines} @@ -12,7 +11,7 @@ import gleam/string import splitter /// Contains the configuration for checking files. -/// Not tied to a single file, may contain more configuration later. +/// Not tied to a single file. /// The error type depends on the IO library used. pub opaque type Checker(e) { Checker( @@ -32,20 +31,27 @@ pub opaque type File(e) { ) } -/// A gleam source file, from which snippets can be loaded. +/// A Gleam source file, from which snippets can be loaded. pub opaque type CodeSnippetSource { CodeSnippetSource(filename: String, file: code_extractor.File) } -/// Any error that can happen during checking or updating a markdown file. +/// Any error that can happen while using `checkmark`. /// The error type depends on the IO library used. pub type CheckError(e) { + /// Could not read a source or target file. CouldNotReadFile(error: e) + /// While updating, could not write a file. CouldNotWriteFile(error: e) + /// Could not find a code snippet with the given tag. TagNotFound(tag: String) + /// Found multiple code blocks with the same tag. MultipleTagsFound(tag: String, lines: List(Int)) + /// While checking, the content didn't match expectations. ContentDidNotMatch(tag: String) + /// Could not load a code segment from a Gleam source file. FailedToLoadCodeSegment(file: String, reason: String) + /// Could not parse a Gleam source file as a snippet source. CouldNotParseSnippetSource } @@ -59,9 +65,14 @@ type Expectation { ) } +/// Specifies a code segment to extract from a Gleam source file. pub type CodeSegment { + /// The full definition of the function with the given name. Function(name: String) + /// The body of the function with the given name. + /// Content is unindented based on the indentation of the first line. FunctionBody(name: String) + /// The full type definition of the type with the given name. TypeDefinition(name: String) } @@ -73,23 +84,29 @@ pub fn new( Checker(read_file, write_file) } -/// Configures a markdown file to be checked or updated. @deprecated("Use document instead") pub fn file(checker: Checker(e), filename: String) -> File(e) { document(checker, filename) } -/// Configures a markdown document to be checked or updated. +/// Starts configuring a markdown file to be checked or updated. +/// See [`should_contain_contents_of`](#should_contain_contents_of) +/// and [`should_contain_snippet_from`](#should_contain_snippet_from) +/// to configure the content. pub fn document(checker: Checker(e), filename: String) -> File(e) { File(filename, checker, False, []) } -/// Configures comments in a gleam source file to be checked or updated. +/// Starts configuring code blocks in comments of +/// a Gleam source file to be checked or updated. +/// See [`should_contain_contents_of`](#should_contain_contents_of) +/// and [`should_contain_snippet_from`](#should_contain_snippet_from) +/// to configure the content. pub fn comments_in(checker: Checker(e), filename: String) -> File(e) { File(filename, checker, True, []) } -/// Loads a gleam source file to extract snippets from. +/// Loads a Gleam source file to extract snippets from. pub fn load_snippet_source( checker: Checker(e), filename: String, @@ -101,10 +118,18 @@ pub fn load_snippet_source( } /// Specify that the file should contain the contents of another file as a code block. -/// The tag is what comes after the block fence. -/// e.g. "```gleam 1" would match the tag "gleam 1". -/// Whitespace is trimmed off the tag. -/// Note that you still need to call `check`, `update` or `check_or_update` after this, +/// For example +/// +/// ```gleam contents_of +/// checker +/// |> checkmark.document("README.md") +/// |> checkmark.should_contain_contents_of("./example.sh", tagged: "sh deps") +/// ``` +/// +/// will replace the code block starting with ```` ```sh deps```` +/// with the contents of `example.sh`. +/// Whitespace is trimmed off the tag when matching it. +/// Note that you still need to call `check`, `update` or `check_or_update` after this - /// this function only adds to the configuration. pub fn should_contain_contents_of( file: File(e), @@ -117,11 +142,23 @@ pub fn should_contain_contents_of( ]) } -/// Specify that the file should contain a code snippet from a Gleam source file. -/// The tag is what comes after the block fence and `gleam`. -/// e.g. "```gleam 1" would match the tag "1". -/// Whitespace is trimmed off the tag. -/// Note that you still need to call `check`, `update` or `check_or_update` after this, +/// Specify that the file should contain a code snippet from another file. +/// For example +/// +/// ```gleam snippet_from +/// checker +/// |> checkmark.document("README.md") +/// |> checkmark.should_contain_snippet_from( +/// snippets, +/// checkmark.Function("wibble"), +/// tagged: "wibble", +/// ) +/// ``` +/// +/// will replace the code block starting with ```` ```gleam wibble```` +/// with the full definition of the `wibble` function. +/// Whitespace is trimmed off the tag when matching it. +/// Note that you still need to call `check`, `update` or `check_or_update` after this - /// this function only adds to the configuration. pub fn should_contain_snippet_from( file: File(e), @@ -146,8 +183,10 @@ pub fn check_or_update( } } -/// Checks that the markdown or Gleam source code file -/// contains code blocks that match the content as specified. +/// Checks that the target file contain the expected code blocks. +/// See [`should_contain_contents_of`](#should_contain_contents_of) +/// and [`should_contain_snippet_from`](#should_contain_snippet_from) +/// to configure the content. pub fn check(file: File(e)) -> Result(Nil, List(CheckError(e))) { use contents <- result.try(parse_file(file)) let results = { @@ -164,7 +203,10 @@ pub fn check(file: File(e)) -> Result(Nil, List(CheckError(e))) { } } -/// Updates the code blocks in the markdown file or Gleam source file from the specified files. +/// Updates the code blocks in the target file. +/// See [`should_contain_contents_of`](#should_contain_contents_of) +/// and [`should_contain_snippet_from`](#should_contain_snippet_from) +/// to configure the content. pub fn update(file: File(e)) -> Result(Nil, List(CheckError(e))) { use contents <- result.try(parse_file(file)) let results = { diff --git a/src/checkmark/internal/code_extractor.gleam b/src/checkmark/internal/code_extractor.gleam index e43ffca..71e5d7a 100644 --- a/src/checkmark/internal/code_extractor.gleam +++ b/src/checkmark/internal/code_extractor.gleam @@ -4,7 +4,6 @@ import gleam/bit_array import gleam/bool import gleam/list import gleam/result -import gleam/string import splitter.{type Splitter} pub type ExtractError { @@ -66,7 +65,14 @@ fn unindent(indented: List(String)) -> List(String) { |> result.unwrap(0) use line <- list.map(indented) - string.drop_start(line, indent_amount) + remove_space(line, indent_amount) +} + +fn remove_space(string: String, up_to: Int) -> String { + case up_to, string { + _, " " <> rest if up_to > 0 -> remove_space(rest, up_to - 1) + _, _ -> string + } } fn indent_amount(string: String, amount: Int) -> Int { diff --git a/test/checkmark/internal/code_extractor_test.gleam b/test/checkmark/internal/code_extractor_test.gleam index 95e228b..d2db6e1 100644 --- a/test/checkmark/internal/code_extractor_test.gleam +++ b/test/checkmark/internal/code_extractor_test.gleam @@ -9,6 +9,7 @@ pub fn extract_function_test() { "import gleam/result", "", "pub fn main() {", + "", " Ok(42)", "}", "", @@ -17,6 +18,7 @@ pub fn extract_function_test() { assert extract_function(module, "main") == Ok([ "pub fn main() {\n", + "\n", " Ok(42)\n", "}\n", ]) @@ -30,6 +32,8 @@ pub fn extract_function_body_test() { "pub fn main() {", " case True {", " True -> False", + "// weird indent", + "", " False -> True", " }", "}", @@ -40,6 +44,8 @@ pub fn extract_function_body_test() { == Ok([ "case True {\n", " True -> False\n", + "// weird indent\n", + "\n", " False -> True\n", "}\n", ]) diff --git a/test/doc_snippets_test.gleam b/test/doc_snippets_test.gleam new file mode 100644 index 0000000..e487486 --- /dev/null +++ b/test/doc_snippets_test.gleam @@ -0,0 +1,47 @@ +import checkmark +import envoy +import simplifile + +pub fn update_docs_test() { + let checker = checkmark.new(simplifile.read, simplifile.write) + + let assert Ok(snippets) = + checkmark.load_snippet_source(checker, "./test/doc_snippets_test.gleam") + + assert checker + |> checkmark.comments_in("./src/checkmark.gleam") + |> checkmark.should_contain_snippet_from( + snippets, + checkmark.FunctionBody("contents_of_example"), + tagged: "contents_of", + ) + |> checkmark.should_contain_snippet_from( + snippets, + checkmark.FunctionBody("snippet_from_example"), + tagged: "snippet_from", + ) + // Update locally, check on CI + |> checkmark.check_or_update( + when: envoy.get("GITHUB_WORKFLOW") == Error(Nil), + ) + == Ok(Nil) +} + +pub fn contents_of_example(checker: checkmark.Checker(a)) -> checkmark.File(a) { + checker + |> checkmark.document("README.md") + |> checkmark.should_contain_contents_of("./example.sh", tagged: "sh deps") +} + +pub fn snippet_from_example( + checker: checkmark.Checker(a), + snippets: checkmark.CodeSnippetSource, +) -> checkmark.File(a) { + checker + |> checkmark.document("README.md") + |> checkmark.should_contain_snippet_from( + snippets, + checkmark.Function("wibble"), + tagged: "wibble", + ) +} diff --git a/test/example_test.gleam b/test/example_test.gleam index b4bad13..f39b9a2 100644 --- a/test/example_test.gleam +++ b/test/example_test.gleam @@ -3,12 +3,22 @@ import envoy import simplifile pub fn example_test() { - assert checkmark.new(simplifile.read, simplifile.write) + let checker = checkmark.new(simplifile.read, simplifile.write) + + let assert Ok(snippets) = + checkmark.load_snippet_source(checker, "./test/doc_snippets_test.gleam") + + assert checker |> checkmark.document("README.md") - |> checkmark.should_contain_contents_of("./example.sh", tagged: "sh") + |> checkmark.should_contain_contents_of("./example.sh", tagged: "sh deps") |> checkmark.should_contain_contents_of( "./test/example_test.gleam", - tagged: "gleam", + tagged: "gleam markdown", + ) + |> checkmark.should_contain_snippet_from( + snippets, + checkmark.FunctionBody("update_docs_test"), + tagged: "update comments", ) // Update locally, check on CI |> checkmark.check_or_update(