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 f5c288b..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"] @@ -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..7df042c 100644 --- a/src/checkmark.gleam +++ b/src/checkmark.gleam @@ -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 @@ -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) { @@ -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. @@ -64,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. @@ -72,10 +108,31 @@ 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: [ + 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. @@ -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) @@ -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) @@ -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)), @@ -185,7 +266,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,26 +274,17 @@ 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), - ) - - 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( diff --git a/src/checkmark/internal/code_extractor.gleam b/src/checkmark/internal/code_extractor.gleam new file mode 100644 index 0000000..e43ffca --- /dev/null +++ b/src/checkmark/internal/code_extractor.gleam @@ -0,0 +1,134 @@ +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 { + NameNotFound(name: String) + SpanExtractionFailed +} + +pub opaque type File { + 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)) + 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(List(String), ExtractError) { + use function <- result.try(find_function(file, name)) + extract_source(file, function.location) +} + +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)) +} + +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) + |> 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(NameNotFound(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(NameNotFound(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(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 new file mode 100644 index 0000000..95e228b --- /dev/null +++ b/test/checkmark/internal/code_extractor_test.gleam @@ -0,0 +1,89 @@ +import checkmark/internal/code_extractor.{ + type File, extract_function, extract_function_body, extract_type, +} +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([ + "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([]) +} + +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") +} + +fn load_module(lines: List(String)) -> File { + let assert Ok(file) = code_extractor.load(join(lines)) + file +} 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 +```