From dd5c4460895ebdb29e7124f4ab2ec60dd5d4ed1f Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 16:23:09 +0200 Subject: [PATCH 1/8] Make tests more readable --- test/checkmark/internal/parser_test.gleam | 110 +++++++++++++++++++--- 1 file changed, 98 insertions(+), 12 deletions(-) diff --git a/test/checkmark/internal/parser_test.gleam b/test/checkmark/internal/parser_test.gleam index 3b5282d..c2b970c 100644 --- a/test/checkmark/internal/parser_test.gleam +++ b/test/checkmark/internal/parser_test.gleam @@ -1,22 +1,45 @@ import checkmark/internal/parser.{Fence, FencedCode, Other} import gleam/option.{None, Some} +import gleam/string + +fn lines(lines: List(String)) -> String { + lines |> string.join("\n") +} pub fn empty_string_test() { assert parser.parse("") == [] } pub fn no_snippets_test() { - let text = "one\ntwo\r\nthree" + let text = + lines([ + "one", + "two\r", + "three", + ]) assert parser.parse(text) == [parser.Other(1, text)] } pub fn basic_snippet_test() { - assert parser.parse("start\n```gleam\ncode\r\nmore_code\n``` \nrest") + assert parser.parse( + lines([ + "start", + "```gleam", + "code\r", + "more_code", + "``` ", + "rest", + ]), + ) == [ Other(1, "start\n"), FencedCode( 2, - "code\r\nmore_code\n", + lines([ + "code\r", + "more_code", + "", + ]), Fence("```", "gleam\n", 0), Some(Fence("```", " \n", 0)), ), @@ -26,12 +49,23 @@ pub fn basic_snippet_test() { pub fn indented_snippet_test() { assert parser.parse( - " ```gleam\n code\n more_code\n not indented enough\n ```", + lines([ + " ```gleam", + " code", + " more_code", + " not indented enough", + " ```", + ]), ) == [ FencedCode( 1, - "code\n more_code\nnot indented enough\n", + lines([ + "code", + " more_code", + "not indented enough", + "", + ]), Fence("```", "gleam\n", 3), Some(Fence("```", "", 2)), ), @@ -39,11 +73,24 @@ pub fn indented_snippet_test() { } pub fn non_matching_fences_test() { - assert parser.parse("````\n```\n~~~\ncode\n````") + assert parser.parse( + lines([ + "````", + "```", + "~~~", + "code", + "````", + ]), + ) == [ FencedCode( 1, - "```\n~~~\ncode\n", + lines([ + "```", + "~~~", + "code", + "", + ]), Fence("````", "\n", 0), Some(Fence("````", "", 0)), ), @@ -51,7 +98,12 @@ pub fn non_matching_fences_test() { } pub fn missing_end_fence_test() { - assert parser.parse("```\ncode") + assert parser.parse( + lines([ + "```", + "code", + ]), + ) == [FencedCode(1, "code", Fence("```", "\n", 0), None)] } @@ -61,15 +113,49 @@ pub fn empty_fence_test() { } pub fn emtpy_line_preservation_test() { - assert parser.parse("\ntext\n\n```\n\ncode\n\n```\n\n\n") + assert parser.parse( + lines([ + "", + "text", + "", + "```", + "", + "code", + "", + "```", + "", + "", + "", + ]), + ) == [ - Other(1, "\ntext\n\n"), + Other( + 1, + lines([ + "", + "text", + "", + "", + ]), + ), FencedCode( 4, - "\ncode\n\n", + lines([ + "", + "code", + "", + "", + ]), Fence("```", "\n", 0), Some(Fence("```", "\n", 0)), ), - Other(9, "\n\n"), + Other( + 9, + lines([ + "", + "", + "", + ]), + ), ] } From 49dde352c9e0ea12a047b533b4289fe651a584be Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 16:27:31 +0200 Subject: [PATCH 2/8] Add unused argument for parsing in comments --- src/checkmark.gleam | 2 +- src/checkmark/internal/parser.gleam | 2 +- test/checkmark/internal/parser_test.gleam | 19 ++++++++++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/checkmark.gleam b/src/checkmark.gleam index e641012..23633d4 100644 --- a/src/checkmark.gleam +++ b/src/checkmark.gleam @@ -180,7 +180,7 @@ fn parse_file( ) -> Result(List(parser.Section), List(CheckError(e))) { read_file(file.checker, file.name) |> result.map_error(list.wrap) - |> result.map(parser.parse) + |> result.map(parser.parse(_, False)) } fn read_file( diff --git a/src/checkmark/internal/parser.gleam b/src/checkmark/internal/parser.gleam index 14b0911..0cbac78 100644 --- a/src/checkmark/internal/parser.gleam +++ b/src/checkmark/internal/parser.gleam @@ -23,7 +23,7 @@ type SectionBuilder { FencedCodeBuilder(start_line: Int, parts: List(String), start_fence: Fence) } -pub fn parse(content: String) -> List(Section) { +pub fn parse(content: String, in_comments: Bool) -> List(Section) { use <- bool.guard(when: content == "", return: []) let line_ends = splitter.new(["\n", "\r\n"]) diff --git a/test/checkmark/internal/parser_test.gleam b/test/checkmark/internal/parser_test.gleam index c2b970c..32f552d 100644 --- a/test/checkmark/internal/parser_test.gleam +++ b/test/checkmark/internal/parser_test.gleam @@ -6,18 +6,26 @@ fn lines(lines: List(String)) -> String { lines |> string.join("\n") } +fn comment_agnostic(body: fn(Bool) -> Nil) -> Nil { + body(False) + body(True) +} + pub fn empty_string_test() { - assert parser.parse("") == [] + use in_comments <- comment_agnostic() + assert parser.parse("", in_comments) == [] } pub fn no_snippets_test() { + use in_comments <- comment_agnostic() + let text = lines([ "one", "two\r", "three", ]) - assert parser.parse(text) == [parser.Other(1, text)] + assert parser.parse(text, in_comments) == [parser.Other(1, text)] } pub fn basic_snippet_test() { @@ -30,6 +38,7 @@ pub fn basic_snippet_test() { "``` ", "rest", ]), + False, ) == [ Other(1, "start\n"), @@ -56,6 +65,7 @@ pub fn indented_snippet_test() { " not indented enough", " ```", ]), + False, ) == [ FencedCode( @@ -81,6 +91,7 @@ pub fn non_matching_fences_test() { "code", "````", ]), + False, ) == [ FencedCode( @@ -103,12 +114,13 @@ pub fn missing_end_fence_test() { "```", "code", ]), + False, ) == [FencedCode(1, "code", Fence("```", "\n", 0), None)] } pub fn empty_fence_test() { - assert parser.parse("```info") + assert parser.parse("```info", False) == [FencedCode(1, "", Fence("```", "info", 0), None)] } @@ -127,6 +139,7 @@ pub fn emtpy_line_preservation_test() { "", "", ]), + False, ) == [ Other( From 278d0bd03558b3b94a63bae39e218b6ca76a898c Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 17:19:00 +0200 Subject: [PATCH 3/8] Clarify code, add unused argument (and not yet passing test) --- src/checkmark/internal/parser.gleam | 118 +++++++++++----------- test/checkmark/internal/parser_test.gleam | 37 +++++++ 2 files changed, 97 insertions(+), 58 deletions(-) diff --git a/src/checkmark/internal/parser.gleam b/src/checkmark/internal/parser.gleam index 0cbac78..675966d 100644 --- a/src/checkmark/internal/parser.gleam +++ b/src/checkmark/internal/parser.gleam @@ -28,96 +28,98 @@ pub fn parse(content: String, in_comments: Bool) -> List(Section) { let line_ends = splitter.new(["\n", "\r\n"]) let #(line, ending, rest) = splitter.split(line_ends, content) - parse_lines(line_ends, 1, [], None, line, ending, rest) + parse_lines(line_ends, in_comments, 1, [], None, line, ending, rest) +} + +fn add_parts(builder: SectionBuilder, line: String, ending: String) { + let parts = [ending, line, ..builder.parts] + case builder { + FencedCodeBuilder(..) -> FencedCodeBuilder(..builder, parts:) + OtherBuilder(..) -> OtherBuilder(..builder, parts:) + } +} + +fn to_section(builder: SectionBuilder, end_fence: Option(Fence)) { + case builder { + FencedCodeBuilder(start_line:, parts:, start_fence:) -> + FencedCode( + start_line, + parts_to_string(parts, start_fence.indent), + start_fence, + end_fence, + ) + + OtherBuilder(start_line:, parts:) -> + Other(start_line, parts_to_string(parts, 0)) + } } fn parse_lines( splitter: Splitter, - line: Int, + in_comments: Bool, + line_number: Int, sections: List(Section), - current_section: Option(SectionBuilder), - content: String, + current_builder: Option(SectionBuilder), + line: String, ending: String, rest: String, -) { - let #(sections, current_section) = case parse_fence(content, ending) { +) -> List(Section) { + let #(sections, current_section) = case parse_fence(line, ending) { + // No fence, add to current, or start new builder: None -> { - let current_section = case current_section { - None -> OtherBuilder(line, [ending, content]) - Some(builder) -> { - let parts = [ending, content, ..builder.parts] - case builder { - FencedCodeBuilder(line, _, start_fence:) -> - FencedCodeBuilder(line, parts, start_fence) - OtherBuilder(line, _) -> OtherBuilder(line, parts) - } - } + let current_section = case current_builder { + None -> OtherBuilder(line_number, [ending, line]) + Some(builder) -> add_parts(builder, line, ending) } #(sections, Some(current_section)) } - Some(fence) -> - case current_section { - None -> #(sections, Some(FencedCodeBuilder(line, [], fence))) - Some(OtherBuilder(prev_line, parts)) -> #( - [Other(prev_line, parts_to_string(parts, 0)), ..sections], - Some(FencedCodeBuilder(line, [], fence)), + // Found fence: + Some(current_fence) -> { + case current_builder { + // No current builder, start new fenced code: + None -> #( + sections, + Some(FencedCodeBuilder(line_number, [], current_fence)), + ) + + // Other content: finalize it, start new fenced code + Some(OtherBuilder(..) as other_builder) -> #( + [to_section(other_builder, None), ..sections], + Some(FencedCodeBuilder(line_number, [], current_fence)), ) - Some(FencedCodeBuilder(start_line:, parts:, start_fence:)) -> - case should_close(start_fence, fence) { + + // Fenced code, check if it should be closed or not: + Some(FencedCodeBuilder(start_fence:, ..) as fence_builder) -> + case should_close(start_fence, current_fence) { True -> #( - [ - FencedCode( - start_line, - parts_to_string(parts, start_fence.indent), - start_fence, - Some(fence), - ), - ..sections - ], + [to_section(fence_builder, Some(current_fence)), ..sections], None, ) - False -> #( - sections, - Some(FencedCodeBuilder( - start_line, - [ending, content, ..parts], - start_fence, - )), - ) + + False -> #(sections, Some(add_parts(fence_builder, line, ending))) } } + } } case rest { + // Nothing left, finalize the current builder, if any: "" -> { let sections = case current_section { None -> sections - Some(builder) -> - case builder { - FencedCodeBuilder(start_line:, start_fence:, parts:) -> [ - FencedCode( - start_line, - parts_to_string(parts, start_fence.indent), - start_fence, - None, - ), - ..sections - ] - OtherBuilder(start_line:, parts:) -> [ - Other(start_line, parts_to_string(parts, 0)), - ..sections - ] - } + Some(builder) -> [to_section(builder, None), ..sections] } list.reverse(sections) } + // More to parse, recurse on next line: rest -> { let #(content, ending, rest) = splitter.split(splitter, rest) parse_lines( splitter, - line + 1, + in_comments, + line_number + 1, sections, current_section, content, diff --git a/test/checkmark/internal/parser_test.gleam b/test/checkmark/internal/parser_test.gleam index 32f552d..5d45cde 100644 --- a/test/checkmark/internal/parser_test.gleam +++ b/test/checkmark/internal/parser_test.gleam @@ -56,6 +56,43 @@ pub fn basic_snippet_test() { ] } +pub fn doc_comment_test_does_not_work_yet() { + assert parser.parse( + lines([ + "pub const answer = 42", + "", + "/// start", + "/// ```gleam", + "/// code\r", + "/// more_code", + "/// ``` ", + "pub const answer_str = \"*\"", + ]), + True, + ) + == [ + Other( + 1, + lines([ + "pub const answer = 42", + "", + "/// start", + ]), + ), + FencedCode( + 4, + lines([ + "code\r", + "more_code", + "", + ]), + Fence("/// ```", "gleam\n", 0), + Some(Fence("/// ```", " \n", 0)), + ), + Other(6, "rest"), + ] +} + pub fn indented_snippet_test() { assert parser.parse( lines([ From f3a255918fcaeefbf529c4f28328522e324f8ff2 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 18:24:33 +0200 Subject: [PATCH 4/8] Add option to only parse in comments --- src/checkmark.gleam | 9 +- src/checkmark/internal/parser.gleam | 180 ++++++++++++++++------ test/checkmark/internal/parser_test.gleam | 83 +++++++++- 3 files changed, 212 insertions(+), 60 deletions(-) diff --git a/src/checkmark.gleam b/src/checkmark.gleam index 23633d4..f562f4c 100644 --- a/src/checkmark.gleam +++ b/src/checkmark.gleam @@ -119,7 +119,7 @@ fn render_replacements( contents |> list.map(fn(section) { case section { - parser.FencedCode(_, content, start_fence, end_fence) -> { + parser.FencedCode(content:, start_fence:, end_fence:, ..) -> { case dict.get(replacements, string.trim(start_fence.info)) { Ok(replacement) -> render_code(start_fence, replacement, end_fence) Error(_) -> render_code(start_fence, content, end_fence) @@ -223,10 +223,13 @@ fn find_match( )) } - [parser.FencedCode(line, content, start_fence, end_fence), ..rest] -> { + [ + parser.FencedCode(start_line:, content:, start_fence:, end_fence:, ..), + ..rest + ] -> { case string.trim(start_fence.info) == tag { True -> { - let result = #(line, Snippet(content, start_fence, end_fence)) + let result = #(start_line, Snippet(content, start_fence, end_fence)) find_match(rest, tag, [result, ..found]) } False -> find_match(rest, tag, found) diff --git a/src/checkmark/internal/parser.gleam b/src/checkmark/internal/parser.gleam index 675966d..513038b 100644 --- a/src/checkmark/internal/parser.gleam +++ b/src/checkmark/internal/parser.gleam @@ -8,6 +8,7 @@ pub type Section { Other(start_line: Int, content: String) FencedCode( start_line: Int, + prefix: String, content: String, start_fence: Fence, end_fence: Option(Fence), @@ -20,33 +21,57 @@ pub type Fence { type SectionBuilder { OtherBuilder(start_line: Int, parts: List(String)) - FencedCodeBuilder(start_line: Int, parts: List(String), start_fence: Fence) + FencedCodeBuilder( + start_line: Int, + parts: List(String), + prefix: String, + start_fence: Fence, + ) +} + +type LineContent { + LineContent(prefix: String, content: String, ending: String) +} + +fn initial_content(line: LineContent) -> List(String) { + [line.ending, line.content, line.prefix] } -pub fn parse(content: String, in_comments: Bool) -> List(Section) { +pub fn parse(content: String, search_in_comments: Bool) -> List(Section) { use <- bool.guard(when: content == "", return: []) let line_ends = splitter.new(["\n", "\r\n"]) let #(line, ending, rest) = splitter.split(line_ends, content) - parse_lines(line_ends, in_comments, 1, [], None, line, ending, rest) + parse_lines(line_ends, search_in_comments, 1, [], None, line, ending, rest) } -fn add_parts(builder: SectionBuilder, line: String, ending: String) { - let parts = [ending, line, ..builder.parts] +fn add_parts(builder: SectionBuilder, line: LineContent) { case builder { - FencedCodeBuilder(..) -> FencedCodeBuilder(..builder, parts:) - OtherBuilder(..) -> OtherBuilder(..builder, parts:) + FencedCodeBuilder(..) -> + FencedCodeBuilder(..builder, parts: [ + line.ending, + line.content, + ..builder.parts + ]) + OtherBuilder(..) -> + OtherBuilder(..builder, parts: [ + line.ending, + line.content, + line.prefix, + ..builder.parts + ]) } } fn to_section(builder: SectionBuilder, end_fence: Option(Fence)) { case builder { - FencedCodeBuilder(start_line:, parts:, start_fence:) -> + FencedCodeBuilder(start_line:, parts:, start_fence:, prefix:) -> FencedCode( start_line, parts_to_string(parts, start_fence.indent), - start_fence, - end_fence, + prefix:, + start_fence:, + end_fence:, ) OtherBuilder(start_line:, parts:) -> @@ -56,7 +81,7 @@ fn to_section(builder: SectionBuilder, end_fence: Option(Fence)) { fn parse_lines( splitter: Splitter, - in_comments: Bool, + search_in_comments: Bool, line_number: Int, sections: List(Section), current_builder: Option(SectionBuilder), @@ -64,43 +89,84 @@ fn parse_lines( ending: String, rest: String, ) -> List(Section) { - let #(sections, current_section) = case parse_fence(line, ending) { - // No fence, add to current, or start new builder: - None -> { - let current_section = case current_builder { - None -> OtherBuilder(line_number, [ending, line]) - Some(builder) -> add_parts(builder, line, ending) - } - #(sections, Some(current_section)) - } + let #(prefix, line, is_comment) = case search_in_comments { + True -> parse_comment(line) + False -> #("", line, False) + } + let line = LineContent(prefix, line, ending) - // Found fence: - Some(current_fence) -> { + // Check special cases for comments + let #(sections, current_section) = case search_in_comments && !is_comment { + // Line should NOT be included in code blocks, as it is not a comment + True -> case current_builder { - // No current builder, start new fenced code: None -> #( sections, - Some(FencedCodeBuilder(line_number, [], current_fence)), + Some(OtherBuilder(line_number, initial_content(line))), ) + Some(builder) -> + case builder { + // End code block if comment ends + FencedCodeBuilder(..) -> #( + [to_section(builder, None), ..sections], + Some(OtherBuilder(line_number, initial_content(line))), + ) - // Other content: finalize it, start new fenced code - Some(OtherBuilder(..) as other_builder) -> #( - [to_section(other_builder, None), ..sections], - Some(FencedCodeBuilder(line_number, [], current_fence)), - ) + // Otherwise add to current + OtherBuilder(..) -> #(sections, Some(add_parts(builder, line))) + } + } - // Fenced code, check if it should be closed or not: - Some(FencedCodeBuilder(start_fence:, ..) as fence_builder) -> - case should_close(start_fence, current_fence) { - True -> #( - [to_section(fence_builder, Some(current_fence)), ..sections], - None, + // Otherwise follow normal parsing + False -> + case parse_fence(line) { + // No fence, add to current, or start new builder: + None -> { + let current_section = case current_builder { + None -> OtherBuilder(line_number, initial_content(line)) + Some(builder) -> add_parts(builder, line) + } + #(sections, Some(current_section)) + } + + // Found fence: + Some(current_fence) -> { + case current_builder { + // No current builder, start new fenced code: + None -> #( + sections, + Some(FencedCodeBuilder( + line_number, + [], + line.prefix, + current_fence, + )), + ) + + // Other content: finalize it, start new fenced code + Some(OtherBuilder(..) as other_builder) -> #( + [to_section(other_builder, None), ..sections], + Some(FencedCodeBuilder( + line_number, + [], + line.prefix, + current_fence, + )), ) - False -> #(sections, Some(add_parts(fence_builder, line, ending))) + // Fenced code, check if it should be closed or not: + Some(FencedCodeBuilder(start_fence:, ..) as fence_builder) -> + case should_close(start_fence, current_fence) { + True -> #( + [to_section(fence_builder, Some(current_fence)), ..sections], + None, + ) + + False -> #(sections, Some(add_parts(fence_builder, line))) + } } + } } - } } case rest { @@ -118,7 +184,7 @@ fn parse_lines( let #(content, ending, rest) = splitter.split(splitter, rest) parse_lines( splitter, - in_comments, + search_in_comments, line_number + 1, sections, current_section, @@ -149,20 +215,29 @@ fn remove_indent_up_to(string: String, indent: Int) -> String { } } -fn parse_fence(line: String, ending: String) -> Option(Fence) { +/// Checks for a doc or module comment, and splits it into a prefix if found. +fn parse_comment(line: String) -> #(String, String, Bool) { + case line { + "/// " <> rest -> #("/// ", rest, True) + "//// " <> rest -> #("//// ", rest, True) + _ -> #("", line, False) + } +} + +fn parse_fence(line: LineContent) -> Option(Fence) { // A code fence is a sequence of at least three consecutive backtick // characters (`) or tildes (~). (Tildes and backticks cannot be mixed.) // A fenced code block begins with a code fence, // preceded by up to three spaces of indentation. - case line { - "```" <> rest -> Some(build_fence("`", 0, 3, rest, ending)) - " ```" <> rest -> Some(build_fence("`", 1, 3, rest, ending)) - " ```" <> rest -> Some(build_fence("`", 2, 3, rest, ending)) - " ```" <> rest -> Some(build_fence("`", 3, 3, rest, ending)) - "~~~" <> rest -> Some(build_fence("~", 0, 3, rest, ending)) - " ~~~" <> rest -> Some(build_fence("~", 1, 3, rest, ending)) - " ~~~" <> rest -> Some(build_fence("~", 2, 3, rest, ending)) - " ~~~" <> rest -> Some(build_fence("~", 3, 3, rest, ending)) + case line.content { + "```" <> rest -> Some(build_fence("`", 0, 3, rest, line)) + " ```" <> rest -> Some(build_fence("`", 1, 3, rest, line)) + " ```" <> rest -> Some(build_fence("`", 2, 3, rest, line)) + " ```" <> rest -> Some(build_fence("`", 3, 3, rest, line)) + "~~~" <> rest -> Some(build_fence("~", 0, 3, rest, line)) + " ~~~" <> rest -> Some(build_fence("~", 1, 3, rest, line)) + " ~~~" <> rest -> Some(build_fence("~", 2, 3, rest, line)) + " ~~~" <> rest -> Some(build_fence("~", 3, 3, rest, line)) _ -> None } } @@ -172,11 +247,14 @@ fn build_fence( indent: Int, delimiters: Int, rest: String, - ending: String, + original: LineContent, ) -> Fence { case string.pop_grapheme(rest) { Ok(#(first, rest)) if first == character -> - build_fence(character, indent, delimiters + 1, rest, ending) - _ -> Fence(string.repeat(character, delimiters), rest <> ending, indent) + build_fence(character, indent, delimiters + 1, rest, original) + _ -> { + let fence = string.repeat(character, delimiters) + Fence(fence, rest <> original.ending, indent) + } } } diff --git a/test/checkmark/internal/parser_test.gleam b/test/checkmark/internal/parser_test.gleam index 5d45cde..f2acf08 100644 --- a/test/checkmark/internal/parser_test.gleam +++ b/test/checkmark/internal/parser_test.gleam @@ -44,6 +44,7 @@ pub fn basic_snippet_test() { Other(1, "start\n"), FencedCode( 2, + "", lines([ "code\r", "more_code", @@ -56,7 +57,7 @@ pub fn basic_snippet_test() { ] } -pub fn doc_comment_test_does_not_work_yet() { +pub fn doc_comment_test() { assert parser.parse( lines([ "pub const answer = 42", @@ -77,19 +78,86 @@ pub fn doc_comment_test_does_not_work_yet() { "pub const answer = 42", "", "/// start", + "", ]), ), FencedCode( 4, + "/// ", lines([ "code\r", "more_code", "", ]), - Fence("/// ```", "gleam\n", 0), - Some(Fence("/// ```", " \n", 0)), + Fence("```", "gleam\n", 0), + Some(Fence("```", " \n", 0)), ), - Other(6, "rest"), + Other(8, "pub const answer_str = \"*\""), + ] +} + +pub fn module_comment_test() { + assert parser.parse( + lines([ + "//// start", + "//// ```gleam", + "//// code", + "//// ``` ", + "//// rest", + ]), + True, + ) + == [ + Other( + 1, + lines([ + "//// start", + "", + ]), + ), + FencedCode( + 2, + "//// ", + lines([ + "code", + "", + ]), + Fence("```", "gleam\n", 0), + Some(Fence("```", " \n", 0)), + ), + Other(5, "//// rest"), + ] +} + +pub fn unfinished_comment_block_test() { + assert parser.parse( + lines([ + "//// start", + "//// ```gleam", + "//// code", + "rest", + ]), + True, + ) + == [ + Other( + 1, + lines([ + "//// start", + "", + ]), + ), + FencedCode( + 2, + "//// ", + lines([ + "code", + "", + ]), + Fence("```", "gleam\n", 0), + None, + ), + Other(4, "rest"), ] } @@ -107,6 +175,7 @@ pub fn indented_snippet_test() { == [ FencedCode( 1, + "", lines([ "code", " more_code", @@ -133,6 +202,7 @@ pub fn non_matching_fences_test() { == [ FencedCode( 1, + "", lines([ "```", "~~~", @@ -153,12 +223,12 @@ pub fn missing_end_fence_test() { ]), False, ) - == [FencedCode(1, "code", Fence("```", "\n", 0), None)] + == [FencedCode(1, "", "code", Fence("```", "\n", 0), None)] } pub fn empty_fence_test() { assert parser.parse("```info", False) - == [FencedCode(1, "", Fence("```", "info", 0), None)] + == [FencedCode(1, "", "", Fence("```", "info", 0), None)] } pub fn emtpy_line_preservation_test() { @@ -190,6 +260,7 @@ pub fn emtpy_line_preservation_test() { ), FencedCode( 4, + "", lines([ "", "code", From 6299ab6128b1a6fa6f9efe1a2608f6cef18c3d76 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 18:32:33 +0200 Subject: [PATCH 5/8] Deprecate 'file' --- README.md | 2 +- src/checkmark.gleam | 6 ++++++ test/checkmark_test.gleam | 8 ++++---- test/example_test.gleam | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a1cf208..e8119bb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ import simplifile pub fn example_test() { assert checkmark.new(simplifile.read, simplifile.write) - |> checkmark.file("README.md") + |> checkmark.document("README.md") |> checkmark.should_contain_contents_of("./example.sh", tagged: "sh") |> checkmark.should_contain_contents_of( "./test/example_test.gleam", diff --git a/src/checkmark.gleam b/src/checkmark.gleam index f562f4c..77c3247 100644 --- a/src/checkmark.gleam +++ b/src/checkmark.gleam @@ -44,7 +44,13 @@ pub fn new( } /// 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. +pub fn document(checker: Checker(e), filename: String) -> File(e) { File(filename, checker, []) } diff --git a/test/checkmark_test.gleam b/test/checkmark_test.gleam index 929b0f3..a05ba09 100644 --- a/test/checkmark_test.gleam +++ b/test/checkmark_test.gleam @@ -9,7 +9,7 @@ pub fn main() { pub fn check_existing_file_test() { assert checkmark.new(simplifile.read, simplifile.write) - |> checkmark.file("./test/test.md") + |> checkmark.document("./test/test.md") |> checkmark.should_contain_contents_of( "./test/test_content.txt", tagged: "multiple", @@ -28,14 +28,14 @@ pub fn check_existing_file_test() { pub fn check_missing_markdown_file_test() { assert checkmark.new(simplifile.read, simplifile.write) - |> checkmark.file("this-file-does-not-exist") + |> checkmark.document("this-file-does-not-exist") |> checkmark.check() == Error([CouldNotReadFile(simplifile.Enoent)]) } pub fn check_missing_source_file_test() { assert checkmark.new(simplifile.read, simplifile.write) - |> checkmark.file("./test/test.md") + |> checkmark.document("./test/test.md") |> checkmark.should_contain_contents_of("this-file-does-not-exist", "") |> checkmark.check() == Error([CouldNotReadFile(simplifile.Enoent)]) @@ -49,7 +49,7 @@ pub fn update_test() { } assert checkmark.new(simplifile.read, write) - |> checkmark.file("./test/update.md") + |> checkmark.document("./test/update.md") |> checkmark.should_contain_contents_of("./test/test_content.txt", "update") |> checkmark.update() == Ok(Nil) diff --git a/test/example_test.gleam b/test/example_test.gleam index 52a8171..b4bad13 100644 --- a/test/example_test.gleam +++ b/test/example_test.gleam @@ -4,7 +4,7 @@ import simplifile pub fn example_test() { assert checkmark.new(simplifile.read, simplifile.write) - |> checkmark.file("README.md") + |> checkmark.document("README.md") |> checkmark.should_contain_contents_of("./example.sh", tagged: "sh") |> checkmark.should_contain_contents_of( "./test/example_test.gleam", From 4c79be7669a98ce6b00122f7cdef0ac559912d34 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 18:50:49 +0200 Subject: [PATCH 6/8] Add support for replacing code in comments --- src/checkmark.gleam | 40 +++++++++++++++++++++------------ test/checkmark_test.gleam | 27 +++++++++++++++++++++- test/expected_updated.gleam.txt | 12 ++++++++++ test/test.gleam.txt | 10 +++++++++ 4 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 test/expected_updated.gleam.txt create mode 100644 test/test.gleam.txt diff --git a/src/checkmark.gleam b/src/checkmark.gleam index 77c3247..14397e7 100644 --- a/src/checkmark.gleam +++ b/src/checkmark.gleam @@ -22,7 +22,12 @@ pub opaque type Checker(e) { /// 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) { - File(name: String, checker: Checker(e), expectations: List(#(String, String))) + File( + name: String, + checker: Checker(e), + check_in_comments: Bool, + expectations: List(#(String, String)), + ) } /// Any error that can happen during checking or updating a markdown file. @@ -51,7 +56,12 @@ pub fn file(checker: Checker(e), filename: String) -> File(e) { /// Configures a markdown document to be checked or updated. pub fn document(checker: Checker(e), filename: String) -> File(e) { - File(filename, checker, []) + File(filename, checker, False, []) +} + +/// Configures comments in a gleam source file to be checked or updated. +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. @@ -125,10 +135,11 @@ fn render_replacements( contents |> list.map(fn(section) { case section { - parser.FencedCode(content:, start_fence:, end_fence:, ..) -> { + parser.FencedCode(content:, start_fence:, end_fence:, prefix:, ..) -> { case dict.get(replacements, string.trim(start_fence.info)) { - Ok(replacement) -> render_code(start_fence, replacement, end_fence) - Error(_) -> render_code(start_fence, content, end_fence) + Ok(replacement) -> + render_code(start_fence, prefix, replacement, end_fence) + Error(_) -> render_code(start_fence, prefix, content, end_fence) } } parser.Other(_, content) -> content @@ -139,26 +150,27 @@ fn render_replacements( fn render_code( start_fence: Fence, + prefix: String, content: String, end_fence: Option(Fence), ) -> String { // This is not very optimized, but we probably don't need to care... - let content = case start_fence.indent { - 0 -> content - amount -> + let content = case start_fence.indent, prefix { + 0, "" -> content + amount, _ -> indent( splitter.new(["\n", "\r\n"]), - string.repeat(" ", amount), + prefix <> string.repeat(" ", amount), content, [], ) } - let without_end = render_fence(start_fence) <> content + let without_end = render_fence(prefix, start_fence) <> content case end_fence { option.None -> without_end - option.Some(end_fence) -> without_end <> render_fence(end_fence) + option.Some(end_fence) -> without_end <> render_fence(prefix, end_fence) } } @@ -177,8 +189,8 @@ fn indent( } } -fn render_fence(fence: Fence) -> String { - string.repeat(" ", fence.indent) <> fence.fence <> fence.info +fn render_fence(prefix: String, fence: Fence) -> String { + prefix <> string.repeat(" ", fence.indent) <> fence.fence <> fence.info } fn parse_file( @@ -186,7 +198,7 @@ fn parse_file( ) -> Result(List(parser.Section), List(CheckError(e))) { read_file(file.checker, file.name) |> result.map_error(list.wrap) - |> result.map(parser.parse(_, False)) + |> result.map(parser.parse(_, file.check_in_comments)) } fn read_file( diff --git a/test/checkmark_test.gleam b/test/checkmark_test.gleam index a05ba09..ea444fe 100644 --- a/test/checkmark_test.gleam +++ b/test/checkmark_test.gleam @@ -41,7 +41,7 @@ pub fn check_missing_source_file_test() { == Error([CouldNotReadFile(simplifile.Enoent)]) } -pub fn update_test() { +pub fn update_markdown_test() { let self = process.new_subject() let write = fn(_, content) { process.send(self, content) @@ -58,3 +58,28 @@ pub fn update_test() { let assert Ok(expected) = simplifile.read("./test/expected_updated.md") assert written == expected } + +pub fn update_code_test() { + let self = process.new_subject() + let write = fn(_, content) { + process.send(self, content) + Ok(Nil) + } + + assert checkmark.new(simplifile.read, write) + |> checkmark.comments_in("./test/test.gleam.txt") + |> checkmark.should_contain_contents_of( + "./test/test_content.txt", + "gleam module", + ) + |> checkmark.should_contain_contents_of( + "./test/test_content.txt", + "gleam value", + ) + |> checkmark.update() + == Ok(Nil) + + let assert Ok(written) = process.receive(self, 0) + let assert Ok(expected) = simplifile.read("./test/expected_updated.gleam.txt") + assert written == expected +} diff --git a/test/expected_updated.gleam.txt b/test/expected_updated.gleam.txt new file mode 100644 index 0000000..23d1dbf --- /dev/null +++ b/test/expected_updated.gleam.txt @@ -0,0 +1,12 @@ +//// Module +//// ```gleam module +//// test +//// content +//// ``` + +/// Value +/// ```gleam value +/// test +/// content +/// ``` +pub const meaning = 42 diff --git a/test/test.gleam.txt b/test/test.gleam.txt new file mode 100644 index 0000000..e179626 --- /dev/null +++ b/test/test.gleam.txt @@ -0,0 +1,10 @@ +//// Module +//// ```gleam module +//// should be replaced +//// ``` + +/// Value +/// ```gleam value +/// should be replaced +/// ``` +pub const meaning = 42 From 738168ac4cda41d17c85dd1dcfecb464e4b51ae4 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 18:58:51 +0200 Subject: [PATCH 7/8] Update changelog, refactor repetitive tests --- CHANGELOG.md | 10 +++++++ test/checkmark_test.gleam | 55 +++++++++++++++++++-------------------- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e297a9..21c8532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Deprecated +- `file` was deprecated for clarity reasons. Use `document` instead. + +### Added + +- Adds support for replacing snippets in code comments, + using `comments_in` instead of `document`. + ## v2.0.0 - 2025-08-02 This is a complete rewrite. Instead of checking or running gleam code via checkmark, diff --git a/test/checkmark_test.gleam b/test/checkmark_test.gleam index ea444fe..086bba5 100644 --- a/test/checkmark_test.gleam +++ b/test/checkmark_test.gleam @@ -1,4 +1,4 @@ -import checkmark.{CouldNotReadFile, MultipleTagsFound, TagNotFound} +import checkmark.{type Checker, CouldNotReadFile, MultipleTagsFound, TagNotFound} import gleam/erlang/process import gleeunit import simplifile @@ -42,44 +42,43 @@ pub fn check_missing_source_file_test() { } pub fn update_markdown_test() { - let self = process.new_subject() - let write = fn(_, content) { - process.send(self, content) - Ok(Nil) - } + use checker <- update_test("./test/expected_updated.md") - assert checkmark.new(simplifile.read, write) - |> checkmark.document("./test/update.md") - |> checkmark.should_contain_contents_of("./test/test_content.txt", "update") - |> checkmark.update() - == Ok(Nil) - - let assert Ok(written) = process.receive(self, 0) - let assert Ok(expected) = simplifile.read("./test/expected_updated.md") - assert written == expected + checker + |> checkmark.document("./test/update.md") + |> checkmark.should_contain_contents_of("./test/test_content.txt", "update") + |> checkmark.update() } pub fn update_code_test() { + use checker <- update_test("./test/expected_updated.gleam.txt") + + checker + |> checkmark.comments_in("./test/test.gleam.txt") + |> checkmark.should_contain_contents_of( + "./test/test_content.txt", + "gleam module", + ) + |> checkmark.should_contain_contents_of( + "./test/test_content.txt", + "gleam value", + ) + |> checkmark.update() +} + +fn update_test( + expected: String, + using: fn(Checker(simplifile.FileError)) -> Result(Nil, a), +) -> Nil { let self = process.new_subject() let write = fn(_, content) { process.send(self, content) Ok(Nil) } - assert checkmark.new(simplifile.read, write) - |> checkmark.comments_in("./test/test.gleam.txt") - |> checkmark.should_contain_contents_of( - "./test/test_content.txt", - "gleam module", - ) - |> checkmark.should_contain_contents_of( - "./test/test_content.txt", - "gleam value", - ) - |> checkmark.update() - == Ok(Nil) + assert using(checkmark.new(simplifile.read, write)) == Ok(Nil) let assert Ok(written) = process.receive(self, 0) - let assert Ok(expected) = simplifile.read("./test/expected_updated.gleam.txt") + let assert Ok(expected) = simplifile.read(expected) assert written == expected } From 6f120267469feb0540b0192f46dcaba2fe32ea00 Mon Sep 17 00:00:00 2001 From: Sakari Bergen Date: Sun, 7 Dec 2025 19:05:27 +0200 Subject: [PATCH 8/8] Update gleam version in GHA --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 02fab06..73665d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: - uses: erlef/setup-beam@v1 with: otp-version: "26.0.2" - gleam-version: "1.11.1" + gleam-version: "1.13.0" rebar3-version: "3" - run: gleam deps download - run: gleam build --warnings-as-errors