Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
70 changes: 62 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 <https://hexdocs.pm/checkmark>.
84 changes: 63 additions & 21 deletions src/checkmark.gleam
Original file line number Diff line number Diff line change
@@ -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}
Expand All @@ -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(
Expand All @@ -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
}

Expand All @@ -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)
}

Expand All @@ -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,
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand Down
10 changes: 8 additions & 2 deletions src/checkmark/internal/code_extractor.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions test/checkmark/internal/code_extractor_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub fn extract_function_test() {
"import gleam/result",
"",
"pub fn main() {",
"",
" Ok(42)",
"}",
"",
Expand All @@ -17,6 +18,7 @@ pub fn extract_function_test() {
assert extract_function(module, "main")
== Ok([
"pub fn main() {\n",
"\n",
" Ok(42)\n",
"}\n",
])
Expand All @@ -30,6 +32,8 @@ pub fn extract_function_body_test() {
"pub fn main() {",
" case True {",
" True -> False",
"// weird indent",
"",
" False -> True",
" }",
"}",
Expand All @@ -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",
])
Expand Down
47 changes: 47 additions & 0 deletions test/doc_snippets_test.gleam
Original file line number Diff line number Diff line change
@@ -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",
)
}
Loading