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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Changelog

## Unreleased
## v3.0.0 2025-12-07

This is a mostly backwards compatible release,
as the only breaking change is adding new error variants.

### Deprecated
- `file` was deprecated for clarity reasons. Use `document` instead.
Expand All @@ -9,6 +12,8 @@

- Adds support for replacing snippets in code comments,
using `comments_in` instead of `document`.
- Adds support using functions, function bodies, or type definitions
as content to be checked or updated.

## v2.0.0 - 2025-08-02

Expand Down
3 changes: 2 additions & 1 deletion gleam.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name = "checkmark"
version = "2.0.0"
version = "3.0.0"

description = "Gleam markdown snippet validator"
licences = ["Apache-2.0"]
Expand All @@ -9,6 +9,7 @@ internal_modules = ["checkmark/internal/*"]
[dependencies]
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
splitter = ">= 1.0.0 and < 2.0.0"
glance = ">= 6.0.0 and < 7.0.0"

[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"
Expand Down
3 changes: 3 additions & 0 deletions manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@
packages = [
{ name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" },
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
{ name = "glance", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "49E0ED4793BB3F56C3E5ED00528D70CAE21D263F70A735604124B95C5F62E2DB" },
{ name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
{ name = "gleam_stdlib", version = "0.67.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6CE3E4189A8B8EC2F73AB61A2FBDE49F159D6C9C61C49E3B3082E439F260D3D0" },
{ name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
{ name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" },
{ name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" },
{ name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" },
]

[requirements]
envoy = { version = ">= 1.0.2 and < 2.0.0" }
glance = { version = ">= 6.0.0 and < 7.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" }
Expand Down
140 changes: 106 additions & 34 deletions src/checkmark.gleam
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//// Link code in files with code blocks in markdown,
//// Link code in files with code blocks in markdown or comments,
//// and check that they are up to date, or update them automatically.

import checkmark/internal/code_extractor
import checkmark/internal/lines.{to_lines}
import checkmark/internal/parser.{type Fence}
import gleam/dict.{type Dict}
import gleam/list
Expand All @@ -19,17 +21,22 @@ pub opaque type Checker(e) {
)
}

/// A markdown file to check, which can be linked to snippets in multiple other files.
/// A markdown or source file to check, which can be linked to snippets in multiple other files.
/// The error type depends on the IO library used.
pub opaque type File(e) {
File(
name: String,
checker: Checker(e),
check_in_comments: Bool,
expectations: List(#(String, String)),
expectations: List(Expectation),
)
}

/// 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.
/// The error type depends on the IO library used.
pub type CheckError(e) {
Expand All @@ -38,6 +45,24 @@ pub type CheckError(e) {
TagNotFound(tag: String)
MultipleTagsFound(tag: String, lines: List(Int))
ContentDidNotMatch(tag: String)
FailedToLoadCodeSegment(file: String, reason: String)
CouldNotParseSnippetSource
}

type Expectation {
ContentsOfFile(tag: String, filename: String)
CodeSegment(
tag: String,
filename: String,
file: code_extractor.File,
segment: CodeSegment,
)
}

pub type CodeSegment {
Function(name: String)
FunctionBody(name: String)
TypeDefinition(name: String)
}

/// Builds a new checker with the provided file IO functions.
Expand All @@ -64,18 +89,50 @@ pub fn comments_in(checker: Checker(e), filename: String) -> File(e) {
File(filename, checker, True, [])
}

/// Specify that the markdown file should contain the contents of another file as a code block.
/// Loads a gleam source file to extract snippets from.
pub fn load_snippet_source(
checker: Checker(e),
filename: String,
) -> Result(CodeSnippetSource, CheckError(e)) {
use content <- result.try(read_file(checker, filename))
code_extractor.load(content)
|> result.replace_error(CouldNotParseSnippetSource)
|> result.map(CodeSnippetSource(filename, _))
}

/// 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,
/// this function only adds to the configuration.
pub fn should_contain_contents_of(
file: File(e),
source: String,
filename: String,
tagged tag: String,
) -> File(e) {
File(..file, expectations: [
ContentsOfFile(tag:, filename:),
..file.expectations
])
}

/// 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,
/// this function only adds to the configuration.
pub fn should_contain_snippet_from(
file: File(e),
source: CodeSnippetSource,
segment: CodeSegment,
tagged tag: String,
) -> File(e) {
File(..file, expectations: [#(source, tag), ..file.expectations])
File(..file, expectations: [
CodeSegment("gleam " <> tag, source.filename, source.file, segment),
..file.expectations
])
}

/// Convenience function for either checking or updating depending on a boolean.
Expand All @@ -89,13 +146,14 @@ pub fn check_or_update(
}
}

/// Checks that the markdown file contains code blocks that match the content of the specified files.
/// Checks that the markdown or Gleam source code file
/// contains code blocks that match the content as specified.
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)
use expectation <- list.map(file.expectations)
use lines <- result.try(get_expected_lines(file.checker, expectation))
check_one(contents, lines, expectation.tag)
}

let #(_, errors) = result.partition(results)
Expand All @@ -106,14 +164,13 @@ pub fn check(file: File(e)) -> Result(Nil, List(CheckError(e))) {
}
}

/// Updates the code blocks in the markdown file from the specified files.
/// Updates the code blocks in the markdown file or Gleam source file from the specified files.
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))
use expectation <- list.map(file.expectations)
use lines <- result.try(get_expected_lines(file.checker, expectation))
Ok(#(expectation.tag, lines))
}

let #(replacements, errors) = result.partition(results)
Expand All @@ -128,6 +185,30 @@ pub fn update(file: File(e)) -> Result(Nil, List(CheckError(e))) {
}
}

fn get_expected_lines(
checker: Checker(e),
expectation: Expectation,
) -> Result(List(String), CheckError(e)) {
case expectation {
ContentsOfFile(filename:, ..) -> read_lines(checker, filename)
CodeSegment(file:, segment:, ..) -> {
case segment {
Function(name:) -> code_extractor.extract_function(file, name)
FunctionBody(name:) -> code_extractor.extract_function_body(file, name)
TypeDefinition(name:) -> code_extractor.extract_type(file, name)
}
|> result.map_error(fn(e) {
let reason = case e {
code_extractor.NameNotFound(name:) -> "Could not find " <> name
code_extractor.SpanExtractionFailed ->
"Extracting snippet failed, please report a bug!"
}
FailedToLoadCodeSegment(file: expectation.filename, reason:)
})
}
}
}

fn render_replacements(
contents: List(parser.Section),
replacements: Dict(String, List(String)),
Expand Down Expand Up @@ -185,34 +266,25 @@ fn render_fence(prefix: String, fence: Fence) -> String {
fn parse_file(
file: File(e),
) -> Result(List(parser.Section), List(CheckError(e))) {
read_file(file.checker, file.name)
read_lines(file.checker, file.name)
|> result.map_error(list.wrap)
|> result.map(parser.parse(_, file.check_in_comments))
}

fn read_file(
checker: Checker(e),
filename: String,
) -> Result(List(String), CheckError(e)) {
use content <- result.map(
checker.read(filename)
|> result.map_error(CouldNotReadFile),
)

splitter.new(["\n", "\r\n"]) |> to_lines(content, [])
) -> Result(String, CheckError(e)) {
checker.read(filename)
|> result.map_error(CouldNotReadFile)
}

fn to_lines(
splitter: splitter.Splitter,
content: String,
lines: List(String),
) -> List(String) {
let #(line, rest) = splitter.split_after(splitter, content)
let lines = [line, ..lines]
case rest {
"" -> list.reverse(lines)
_ -> to_lines(splitter, rest, lines)
}
fn read_lines(
checker: Checker(e),
filename: String,
) -> Result(List(String), CheckError(e)) {
use content <- result.map(read_file(checker, filename))
splitter.new(["\n", "\r\n"]) |> to_lines(content, [])
}

fn check_one(
Expand Down
Loading