From eca6e80c28ad7b35b0959c8016f210d8b8e48eea Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 21:23:14 +0200 Subject: [PATCH 1/6] Start building code extractor --- gleam.toml | 1 + manifest.toml | 3 ++ src/checkmark.gleam | 20 ++++++---- src/checkmark/internal/code_extractor.gleam | 39 +++++++++++++++++++ .../internal/code_extractor_test.gleam | 32 +++++++++++++++ 5 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 src/checkmark/internal/code_extractor.gleam create mode 100644 test/checkmark/internal/code_extractor_test.gleam diff --git a/gleam.toml b/gleam.toml index f5c288b..ee2fb48 100644 --- a/gleam.toml +++ b/gleam.toml @@ -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" diff --git a/manifest.toml b/manifest.toml index 57af45f..3598413 100644 --- a/manifest.toml +++ b/manifest.toml @@ -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" } diff --git a/src/checkmark.gleam b/src/checkmark.gleam index 47ef1f4..4f45321 100644 --- a/src/checkmark.gleam +++ b/src/checkmark.gleam @@ -94,7 +94,7 @@ 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)) + use expected <- result.try(read_lines(file.checker, filename)) check_one(contents, expected, tag) } @@ -112,7 +112,7 @@ pub fn update(file: File(e)) -> Result(Nil, List(CheckError(e))) { 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)) + use expected <- result.try(read_lines(file.checker, filename)) Ok(#(tag, expected)) } @@ -185,7 +185,7 @@ 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)) } @@ -193,12 +193,16 @@ fn parse_file( fn read_file( checker: Checker(e), filename: String, -) -> Result(List(String), CheckError(e)) { - use content <- result.map( - checker.read(filename) - |> result.map_error(CouldNotReadFile), - ) +) -> Result(String, CheckError(e)) { + checker.read(filename) + |> result.map_error(CouldNotReadFile) +} +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, []) } diff --git a/src/checkmark/internal/code_extractor.gleam b/src/checkmark/internal/code_extractor.gleam new file mode 100644 index 0000000..03f781b --- /dev/null +++ b/src/checkmark/internal/code_extractor.gleam @@ -0,0 +1,39 @@ +import glance.{type Module, type Span} +import gleam/bit_array +import gleam/list +import gleam/result + +pub type ExtractError { + FunctionNotFound(name: String) + SpanExtractionFailed +} + +pub opaque type File { + Source(text: BitArray, module: Module) +} + +pub fn load(source: String) -> Result(File, Nil) { + use module <- result.map(glance.module(source) |> result.replace_error(Nil)) + Source(bit_array.from_string(source), module) +} + +pub fn extract_function( + file: File, + name: String, +) -> Result(String, ExtractError) { + use function <- result.try( + file.module.functions + |> list.map(fn(definition) { definition.definition }) + |> list.find(fn(f) { f.name == name }) + |> result.replace_error(FunctionNotFound(name)), + ) + + extract_source(file, function.location) +} + +fn extract_source(file: File, span: Span) -> Result(String, ExtractError) { + file.text + |> bit_array.slice(span.start, span.end - span.start) + |> result.try(bit_array.to_string) + |> result.replace_error(SpanExtractionFailed) +} diff --git a/test/checkmark/internal/code_extractor_test.gleam b/test/checkmark/internal/code_extractor_test.gleam new file mode 100644 index 0000000..e07356f --- /dev/null +++ b/test/checkmark/internal/code_extractor_test.gleam @@ -0,0 +1,32 @@ +import checkmark/internal/code_extractor.{type File, extract_function} +import gleam/string + +pub fn extract_function_test() { + let module = + load_module([ + "import gleam/result", + "", + "pub fn main() {", + " Ok(42)", + "}", + "", + ]) + + assert extract_function(module, "main") + == Ok( + join([ + "pub fn main() {", + " Ok(42)", + "}", + ]), + ) +} + +fn join(lines: List(String)) -> String { + string.join(lines, "\n") +} + +fn load_module(lines: List(String)) -> File { + let assert Ok(file) = code_extractor.load(join(lines)) + file +} From 35cb2c490a4d0d23c8c9b6ece963a35f7205efd8 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 22:22:43 +0200 Subject: [PATCH 2/6] Add function body extraction --- src/checkmark.gleam | 14 +-- src/checkmark/internal/code_extractor.gleam | 103 +++++++++++++++--- src/checkmark/internal/lines.gleam | 15 +++ .../internal/code_extractor_test.gleam | 52 +++++++-- 4 files changed, 150 insertions(+), 34 deletions(-) create mode 100644 src/checkmark/internal/lines.gleam diff --git a/src/checkmark.gleam b/src/checkmark.gleam index 4f45321..d9ccd0e 100644 --- a/src/checkmark.gleam +++ b/src/checkmark.gleam @@ -1,6 +1,7 @@ //// Link code in files with code blocks in markdown, //// and check that they are up to date, or update them automatically. +import checkmark/internal/lines.{to_lines} import checkmark/internal/parser.{type Fence} import gleam/dict.{type Dict} import gleam/list @@ -206,19 +207,6 @@ fn read_lines( splitter.new(["\n", "\r\n"]) |> to_lines(content, []) } -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 check_one( content: List(parser.Section), expected: List(String), diff --git a/src/checkmark/internal/code_extractor.gleam b/src/checkmark/internal/code_extractor.gleam index 03f781b..56d6793 100644 --- a/src/checkmark/internal/code_extractor.gleam +++ b/src/checkmark/internal/code_extractor.gleam @@ -1,7 +1,11 @@ -import glance.{type Module, type Span} +import checkmark/internal/lines.{to_lines} +import glance.{type Module, type Span, Span} import gleam/bit_array +import gleam/bool import gleam/list import gleam/result +import gleam/string +import splitter.{type Splitter} pub type ExtractError { FunctionNotFound(name: String) @@ -9,31 +13,104 @@ pub type ExtractError { } pub opaque type File { - Source(text: BitArray, module: Module) + Source(text: BitArray, module: Module, line_ends: Splitter) } pub fn load(source: String) -> Result(File, Nil) { use module <- result.map(glance.module(source) |> result.replace_error(Nil)) - Source(bit_array.from_string(source), module) + let line_ends = splitter.new(["\n", "\r\n"]) + Source(bit_array.from_string(source), module, line_ends:) } pub fn extract_function( file: File, name: String, -) -> Result(String, ExtractError) { - use function <- result.try( - file.module.functions - |> list.map(fn(definition) { definition.definition }) - |> list.find(fn(f) { f.name == name }) - |> result.replace_error(FunctionNotFound(name)), - ) - +) -> Result(List(String), ExtractError) { + use function <- result.try(find_function(file, name)) extract_source(file, function.location) } -fn extract_source(file: File, span: Span) -> Result(String, ExtractError) { +pub fn extract_function_body( + file: File, + name: String, +) -> Result(List(String), ExtractError) { + use function <- result.try(find_function(file, name)) + let span = + list.first(function.body) + |> result.map(fn(first) { + let assert Ok(last) = list.last(function.body) + Span(statement_span(first).start, statement_span(last).end) + }) + + use indented <- result.try(case span { + // no statements! + Error(_) -> Ok([]) + Ok(span) -> extract_source(file, span) + }) + + Ok(unindent(indented)) +} + +fn unindent(indented: List(String)) -> List(String) { + let indent_amount = + list.first(indented) + |> result.map(indent_amount(_, 0)) + |> result.unwrap(0) + + use line <- list.map(indented) + string.drop_start(line, indent_amount) +} + +fn indent_amount(string: String, amount: Int) -> Int { + case string { + " " <> rest -> indent_amount(rest, amount + 1) + _ -> amount + } +} + +fn statement_span(statement: glance.Statement) -> Span { + case statement { + glance.Expression(expr) -> expr.location + glance.Assert(location:, ..) -> location + glance.Assignment(location:, ..) -> location + glance.Use(location:, ..) -> location + } +} + +fn find_function( + file: File, + name: String, +) -> Result(glance.Function, ExtractError) { + file.module.functions + |> list.map(fn(definition) { definition.definition }) + |> list.find(fn(f) { f.name == name }) + |> result.replace_error(FunctionNotFound(name)) +} + +fn extract_source(file: File, span: Span) -> Result(List(String), ExtractError) { + let start = include_leading_space(file.text, span.start) + let end = include_trailing_space(file.text, span.end) + file.text - |> bit_array.slice(span.start, span.end - span.start) + |> bit_array.slice(start, end - start) |> result.try(bit_array.to_string) + |> result.map(to_lines(file.line_ends, _, [])) |> result.replace_error(SpanExtractionFailed) } + +fn include_leading_space(bits: BitArray, position: Int) -> Int { + use <- bool.guard(position == 0, position) + let checked = position - 1 + case bit_array.slice(bits, checked, 1) { + Ok(<<" ">>) -> include_leading_space(bits, checked) + _ -> position + } +} + +fn include_trailing_space(bits: BitArray, position: Int) -> Int { + case bit_array.slice(bits, position, 1) { + Ok(<<" ">>) | Ok(<<"\r">>) | Ok(<<"\n">>) -> + include_trailing_space(bits, position + 1) + _ -> position + } +} diff --git a/src/checkmark/internal/lines.gleam b/src/checkmark/internal/lines.gleam new file mode 100644 index 0000000..eab6d2f --- /dev/null +++ b/src/checkmark/internal/lines.gleam @@ -0,0 +1,15 @@ +import gleam/list +import splitter + +pub 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) + } +} diff --git a/test/checkmark/internal/code_extractor_test.gleam b/test/checkmark/internal/code_extractor_test.gleam index e07356f..57bebcf 100644 --- a/test/checkmark/internal/code_extractor_test.gleam +++ b/test/checkmark/internal/code_extractor_test.gleam @@ -1,4 +1,6 @@ -import checkmark/internal/code_extractor.{type File, extract_function} +import checkmark/internal/code_extractor.{ + type File, extract_function, extract_function_body, +} import gleam/string pub fn extract_function_test() { @@ -13,13 +15,47 @@ pub fn extract_function_test() { ]) assert extract_function(module, "main") - == Ok( - join([ - "pub fn main() {", - " Ok(42)", - "}", - ]), - ) + == Ok([ + "pub fn main() {\n", + " Ok(42)\n", + "}\n", + ]) +} + +pub fn extract_function_body_test() { + let module = + load_module([ + "import gleam/result", + "", + "pub fn main() {", + " case True {", + " True -> False", + " False -> True", + " }", + "}", + "", + ]) + + assert extract_function_body(module, "main") + == Ok([ + "case True {\n", + " True -> False\n", + " False -> True\n", + "}\n", + ]) +} + +pub fn extract_function_empty_body_test() { + let module = + load_module([ + "import gleam/result", + "", + "pub fn main() {", + "}", + "", + ]) + + assert extract_function_body(module, "main") == Ok([]) } fn join(lines: List(String)) -> String { From a037c33463cf64a8a8d42b742db1183b2baa5f92 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 22:26:39 +0200 Subject: [PATCH 3/6] Add type extraction --- src/checkmark/internal/code_extractor.gleam | 18 +++++++++++++++ .../internal/code_extractor_test.gleam | 23 ++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/checkmark/internal/code_extractor.gleam b/src/checkmark/internal/code_extractor.gleam index 56d6793..78e9d30 100644 --- a/src/checkmark/internal/code_extractor.gleam +++ b/src/checkmark/internal/code_extractor.gleam @@ -51,6 +51,14 @@ pub fn extract_function_body( Ok(unindent(indented)) } +pub fn extract_type( + file: File, + name: String, +) -> Result(List(String), ExtractError) { + use function <- result.try(find_type(file, name)) + extract_source(file, function.location) +} + fn unindent(indented: List(String)) -> List(String) { let indent_amount = list.first(indented) @@ -87,6 +95,16 @@ fn find_function( |> result.replace_error(FunctionNotFound(name)) } +fn find_type( + file: File, + name: String, +) -> Result(glance.CustomType, ExtractError) { + file.module.custom_types + |> list.map(fn(definition) { definition.definition }) + |> list.find(fn(f) { f.name == name }) + |> result.replace_error(FunctionNotFound(name)) +} + fn extract_source(file: File, span: Span) -> Result(List(String), ExtractError) { let start = include_leading_space(file.text, span.start) let end = include_trailing_space(file.text, span.end) diff --git a/test/checkmark/internal/code_extractor_test.gleam b/test/checkmark/internal/code_extractor_test.gleam index 57bebcf..95e228b 100644 --- a/test/checkmark/internal/code_extractor_test.gleam +++ b/test/checkmark/internal/code_extractor_test.gleam @@ -1,5 +1,5 @@ import checkmark/internal/code_extractor.{ - type File, extract_function, extract_function_body, + type File, extract_function, extract_function_body, extract_type, } import gleam/string @@ -58,6 +58,27 @@ pub fn extract_function_empty_body_test() { assert extract_function_body(module, "main") == Ok([]) } +pub fn extract_type_test() { + let module = + load_module([ + "import gleam/result", + "", + "type Wibble {", + " Wibble", + " Wobble", + "}", + "", + ]) + + assert extract_type(module, "Wibble") + == Ok([ + "type Wibble {\n", + " Wibble\n", + " Wobble\n", + "}\n", + ]) +} + fn join(lines: List(String)) -> String { string.join(lines, "\n") } From e6219c740df7550a3cefccc7bda055d5e8d6c0ab Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 22:43:15 +0200 Subject: [PATCH 4/6] Prepare for source code expectations --- src/checkmark.gleam | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/checkmark.gleam b/src/checkmark.gleam index d9ccd0e..e14fb72 100644 --- a/src/checkmark.gleam +++ b/src/checkmark.gleam @@ -20,6 +20,10 @@ pub opaque type Checker(e) { ) } +type Expectation { + ContentsOfFile(tag: String, filename: String) +} + /// A markdown 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) { @@ -27,7 +31,7 @@ pub opaque type File(e) { name: String, checker: Checker(e), check_in_comments: Bool, - expectations: List(#(String, String)), + expectations: List(Expectation), ) } @@ -73,10 +77,13 @@ pub fn comments_in(checker: Checker(e), filename: String) -> File(e) { /// 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: [#(source, tag), ..file.expectations]) + File(..file, expectations: [ + ContentsOfFile(tag:, filename:), + ..file.expectations + ]) } /// Convenience function for either checking or updating depending on a boolean. @@ -90,13 +97,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_lines(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) @@ -111,10 +119,9 @@ pub fn check(file: File(e)) -> Result(Nil, List(CheckError(e))) { 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_lines(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) @@ -129,6 +136,15 @@ 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) + } +} + fn render_replacements( contents: List(parser.Section), replacements: Dict(String, List(String)), From 00b4242821f0cd369b90cfc2947bfd7016ad80a3 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 23:31:09 +0200 Subject: [PATCH 5/6] snippet replacment --- src/checkmark.gleam | 80 ++++++++++++++++++--- src/checkmark/internal/code_extractor.gleam | 6 +- test/checkmark_test.gleam | 31 ++++++++ test/expected_snippets.md | 16 +++++ test/snippet_source.gleam.txt | 7 ++ test/snippets.md | 11 +++ 6 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 test/expected_snippets.md create mode 100644 test/snippet_source.gleam.txt create mode 100644 test/snippets.md diff --git a/src/checkmark.gleam b/src/checkmark.gleam index e14fb72..7df042c 100644 --- a/src/checkmark.gleam +++ b/src/checkmark.gleam @@ -1,6 +1,7 @@ -//// 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} @@ -20,11 +21,7 @@ pub opaque type Checker(e) { ) } -type Expectation { - ContentsOfFile(tag: String, filename: String) -} - -/// 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( @@ -35,6 +32,11 @@ pub opaque type File(e) { ) } +/// 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) { @@ -43,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. @@ -69,7 +89,18 @@ 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. @@ -86,6 +117,24 @@ 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, +/// 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: [ + CodeSegment("gleam " <> tag, source.filename, source.file, segment), + ..file.expectations + ]) +} + /// Convenience function for either checking or updating depending on a boolean. pub fn check_or_update( file: File(e), @@ -115,7 +164,7 @@ 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 = { @@ -142,6 +191,21 @@ fn get_expected_lines( ) -> 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:) + }) + } } } diff --git a/src/checkmark/internal/code_extractor.gleam b/src/checkmark/internal/code_extractor.gleam index 78e9d30..e43ffca 100644 --- a/src/checkmark/internal/code_extractor.gleam +++ b/src/checkmark/internal/code_extractor.gleam @@ -8,7 +8,7 @@ import gleam/string import splitter.{type Splitter} pub type ExtractError { - FunctionNotFound(name: String) + NameNotFound(name: String) SpanExtractionFailed } @@ -92,7 +92,7 @@ fn find_function( file.module.functions |> list.map(fn(definition) { definition.definition }) |> list.find(fn(f) { f.name == name }) - |> result.replace_error(FunctionNotFound(name)) + |> result.replace_error(NameNotFound(name)) } fn find_type( @@ -102,7 +102,7 @@ fn find_type( file.module.custom_types |> list.map(fn(definition) { definition.definition }) |> list.find(fn(f) { f.name == name }) - |> result.replace_error(FunctionNotFound(name)) + |> result.replace_error(NameNotFound(name)) } fn extract_source(file: File, span: Span) -> Result(List(String), ExtractError) { diff --git a/test/checkmark_test.gleam b/test/checkmark_test.gleam index 086bba5..a8344a7 100644 --- a/test/checkmark_test.gleam +++ b/test/checkmark_test.gleam @@ -41,6 +41,12 @@ pub fn check_missing_source_file_test() { == Error([CouldNotReadFile(simplifile.Enoent)]) } +pub fn invalid_snippet_source_test() { + assert checkmark.new(simplifile.read, simplifile.write) + |> checkmark.load_snippet_source("./test/test.md") + == Error(checkmark.CouldNotParseSnippetSource) +} + pub fn update_markdown_test() { use checker <- update_test("./test/expected_updated.md") @@ -66,6 +72,31 @@ pub fn update_code_test() { |> checkmark.update() } +pub fn update_from_snippets_test() { + use checker <- update_test("./test/expected_snippets.md") + let assert Ok(snippets) = + checkmark.load_snippet_source(checker, "./test/snippet_source.gleam.txt") + + checker + |> checkmark.document("./test/snippets.md") + |> checkmark.should_contain_snippet_from( + snippets, + checkmark.Function("main"), + "function", + ) + |> checkmark.should_contain_snippet_from( + snippets, + checkmark.FunctionBody("main"), + "function body", + ) + |> checkmark.should_contain_snippet_from( + snippets, + checkmark.TypeDefinition("Wibble"), + "type", + ) + |> checkmark.update() +} + fn update_test( expected: String, using: fn(Checker(simplifile.FileError)) -> Result(Nil, a), diff --git a/test/expected_snippets.md b/test/expected_snippets.md new file mode 100644 index 0000000..16897ee --- /dev/null +++ b/test/expected_snippets.md @@ -0,0 +1,16 @@ +```gleam function +pub fn main() { + todo +} +``` + +```gleam function body +todo +``` + +```gleam type +pub type Wibble { + Wibble + Wobble +} +``` diff --git a/test/snippet_source.gleam.txt b/test/snippet_source.gleam.txt new file mode 100644 index 0000000..076dbfc --- /dev/null +++ b/test/snippet_source.gleam.txt @@ -0,0 +1,7 @@ +pub fn main() { + todo +} +pub type Wibble { + Wibble + Wobble +} diff --git a/test/snippets.md b/test/snippets.md new file mode 100644 index 0000000..56ce878 --- /dev/null +++ b/test/snippets.md @@ -0,0 +1,11 @@ +```gleam function +should be replaced +``` + +```gleam function body +should be replaced +``` + +```gleam type +should be replaced +``` From 78b0a9c3d5f2a31a1b7e257d47d5961a8affdb8f Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 23:38:25 +0200 Subject: [PATCH 6/6] Update CHANGELOG, bump version --- CHANGELOG.md | 7 ++++++- gleam.toml | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21c8532..afbd02a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/gleam.toml b/gleam.toml index ee2fb48..38d2bd4 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "checkmark" -version = "2.0.0" +version = "3.0.0" description = "Gleam markdown snippet validator" licences = ["Apache-2.0"]