diff --git a/manifest.toml b/manifest.toml index af4e2b5..57af45f 100644 --- a/manifest.toml +++ b/manifest.toml @@ -2,13 +2,13 @@ # You typically do not need to edit this file packages = [ - { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { 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 = "gleam_erlang", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F91CE62A2D011FA13341F3723DB7DB118541AAA5FE7311BD2716D018F01EF9E3" }, - { name = "gleam_stdlib", version = "0.62.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "DC8872BC0B8550F6E22F0F698CFE7F1E4BDA7312FDEB40D6C3F44C5B706C8310" }, - { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, - { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, - { name = "splitter", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "128FC521EE33B0012E3E64D5B55168586BC1B9C8D7B0D0CA223B68B0D770A547" }, + { 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 = "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] diff --git a/src/checkmark.gleam b/src/checkmark.gleam index 14397e7..47ef1f4 100644 --- a/src/checkmark.gleam +++ b/src/checkmark.gleam @@ -4,7 +4,7 @@ import checkmark/internal/parser.{type Fence} import gleam/dict.{type Dict} import gleam/list -import gleam/option.{type Option} +import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import splitter @@ -130,19 +130,19 @@ pub fn update(file: File(e)) -> Result(Nil, List(CheckError(e))) { fn render_replacements( contents: List(parser.Section), - replacements: Dict(String, String), + replacements: Dict(String, List(String)), ) -> String { contents - |> list.map(fn(section) { + |> list.flat_map(fn(section) { case section { - parser.FencedCode(content:, start_fence:, end_fence:, prefix:, ..) -> { + parser.FencedCode(lines:, start_fence:, end_fence:, prefix:, ..) -> { case dict.get(replacements, string.trim(start_fence.info)) { Ok(replacement) -> render_code(start_fence, prefix, replacement, end_fence) - Error(_) -> render_code(start_fence, prefix, content, end_fence) + Error(_) -> render_code(start_fence, prefix, lines, end_fence) } } - parser.Other(_, content) -> content + parser.Other(lines:, ..) -> lines } }) |> string.join("") @@ -151,42 +151,31 @@ fn render_replacements( fn render_code( start_fence: Fence, prefix: String, - content: String, + content: List(String), end_fence: Option(Fence), -) -> String { - // This is not very optimized, but we probably don't need to care... +) -> List(String) { + let end_fence = option.map(end_fence, render_fence(prefix, _)) + let without_end_fence = case start_fence.indent, prefix, end_fence { + 0, "", None -> content + 0, "", Some(end_fence) -> list.append(content, [end_fence]) + amount, _, _ -> { + let indent = prefix <> string.repeat(" ", amount) + let content = + content + |> list.fold([], fn(lines, line) { + [string.append(indent, line), ..lines] + }) - let content = case start_fence.indent, prefix { - 0, "" -> content - amount, _ -> - indent( - splitter.new(["\n", "\r\n"]), - prefix <> string.repeat(" ", amount), - 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(prefix, end_fence) - } -} + let content = case end_fence { + None -> content + Some(fence) -> [fence, ..content] + } -fn indent( - line_ends: splitter.Splitter, - with: String, - rest: String, - parts: List(String), -) -> String { - case rest { - "" -> parts |> list.reverse |> string.join("") - _ -> { - let #(content, ending, rest) = splitter.split(line_ends, rest) - indent(line_ends, with, rest, [ending, content, with, ..parts]) + list.reverse(content) } } + + [render_fence(prefix, start_fence), ..without_end_fence] } fn render_fence(prefix: String, fence: Fence) -> String { @@ -204,13 +193,31 @@ fn parse_file( fn read_file( checker: Checker(e), filename: String, -) -> Result(String, CheckError(e)) { - checker.read(filename) |> result.map_error(CouldNotReadFile) +) -> Result(List(String), CheckError(e)) { + use content <- result.map( + checker.read(filename) + |> result.map_error(CouldNotReadFile), + ) + + 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: String, + expected: List(String), tag: String, ) -> Result(Nil, CheckError(e)) { use snippet <- result.try(find_match(content, tag, [])) @@ -221,7 +228,7 @@ fn check_one( } type Snippet { - Snippet(content: String, start_fence: Fence, end_fence: Option(Fence)) + Snippet(content: List(String), start_fence: Fence, end_fence: Option(Fence)) } fn find_match( @@ -242,12 +249,12 @@ fn find_match( } [ - parser.FencedCode(start_line:, content:, start_fence:, end_fence:, ..), + parser.FencedCode(line_number:, lines:, start_fence:, end_fence:, ..), ..rest ] -> { case string.trim(start_fence.info) == tag { True -> { - let result = #(start_line, Snippet(content, start_fence, end_fence)) + let result = #(line_number, Snippet(lines, 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 513038b..1330b0e 100644 --- a/src/checkmark/internal/parser.gleam +++ b/src/checkmark/internal/parser.gleam @@ -1,15 +1,13 @@ -import gleam/bool import gleam/list import gleam/option.{type Option, None, Some} import gleam/string -import splitter.{type Splitter} pub type Section { - Other(start_line: Int, content: String) + Other(line_number: Int, lines: List(String)) FencedCode( - start_line: Int, + line_number: Int, prefix: String, - content: String, + lines: List(String), start_fence: Fence, end_fence: Option(Fence), ) @@ -20,80 +18,71 @@ pub type Fence { } type SectionBuilder { - OtherBuilder(start_line: Int, parts: List(String)) + OtherBuilder(line_number: Int, lines: List(String)) FencedCodeBuilder( - start_line: Int, - parts: List(String), + line_number: Int, + lines: List(String), prefix: String, start_fence: Fence, ) } type LineContent { - LineContent(prefix: String, content: String, ending: String) + LineContent(prefix: String, content: String) } -fn initial_content(line: LineContent) -> List(String) { - [line.ending, line.content, line.prefix] +fn to_string_with_prefix(line: LineContent) -> String { + line.prefix <> line.content } -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, search_in_comments, 1, [], None, line, ending, rest) +pub fn parse(lines: List(String), search_in_comments: Bool) -> List(Section) { + case lines { + [first, ..rest] -> parse_lines(search_in_comments, 1, [], None, first, rest) + [] -> [] + } } fn add_parts(builder: SectionBuilder, line: LineContent) { case builder { FencedCodeBuilder(..) -> - FencedCodeBuilder(..builder, parts: [ - line.ending, - line.content, - ..builder.parts - ]) + FencedCodeBuilder(..builder, lines: [line.content, ..builder.lines]) OtherBuilder(..) -> - OtherBuilder(..builder, parts: [ - line.ending, - line.content, - line.prefix, - ..builder.parts + OtherBuilder(..builder, lines: [ + to_string_with_prefix(line), + ..builder.lines ]) } } fn to_section(builder: SectionBuilder, end_fence: Option(Fence)) { case builder { - FencedCodeBuilder(start_line:, parts:, start_fence:, prefix:) -> + FencedCodeBuilder(line_number:, lines:, start_fence:, prefix:) -> FencedCode( - start_line, - parts_to_string(parts, start_fence.indent), + line_number, + strip_indent(lines, start_fence.indent) |> list.reverse(), prefix:, start_fence:, end_fence:, ) - OtherBuilder(start_line:, parts:) -> - Other(start_line, parts_to_string(parts, 0)) + OtherBuilder(line_number:, lines:) -> + Other(line_number, list.reverse(lines)) } } fn parse_lines( - splitter: Splitter, search_in_comments: Bool, line_number: Int, sections: List(Section), current_builder: Option(SectionBuilder), line: String, - ending: String, - rest: String, + rest: List(String), ) -> List(Section) { let #(prefix, line, is_comment) = case search_in_comments { True -> parse_comment(line) False -> #("", line, False) } - let line = LineContent(prefix, line, ending) + let line = LineContent(prefix, line) // Check special cases for comments let #(sections, current_section) = case search_in_comments && !is_comment { @@ -102,14 +91,14 @@ fn parse_lines( case current_builder { None -> #( sections, - Some(OtherBuilder(line_number, initial_content(line))), + Some(OtherBuilder(line_number, [to_string_with_prefix(line)])), ) Some(builder) -> case builder { // End code block if comment ends FencedCodeBuilder(..) -> #( [to_section(builder, None), ..sections], - Some(OtherBuilder(line_number, initial_content(line))), + Some(OtherBuilder(line_number, [to_string_with_prefix(line)])), ) // Otherwise add to current @@ -123,7 +112,7 @@ fn parse_lines( // No fence, add to current, or start new builder: None -> { let current_section = case current_builder { - None -> OtherBuilder(line_number, initial_content(line)) + None -> OtherBuilder(line_number, [to_string_with_prefix(line)]) Some(builder) -> add_parts(builder, line) } #(sections, Some(current_section)) @@ -171,7 +160,7 @@ fn parse_lines( case rest { // Nothing left, finalize the current builder, if any: - "" -> { + [] -> { let sections = case current_section { None -> sections Some(builder) -> [to_section(builder, None), ..sections] @@ -180,16 +169,13 @@ fn parse_lines( } // More to parse, recurse on next line: - rest -> { - let #(content, ending, rest) = splitter.split(splitter, rest) + [next_line, ..rest] -> { parse_lines( - splitter, search_in_comments, line_number + 1, sections, current_section, - content, - ending, + next_line, rest, ) } @@ -200,11 +186,9 @@ fn should_close(start: Fence, end: Fence) { string.starts_with(end.fence, start.fence) } -fn parts_to_string(parts: List(String), indent: Int) -> String { - parts +fn strip_indent(lines: List(String), indent: Int) -> List(String) { + lines |> list.map(remove_indent_up_to(_, indent)) - |> list.reverse - |> string.join("") } fn remove_indent_up_to(string: String, indent: Int) -> String { @@ -230,31 +214,32 @@ fn parse_fence(line: LineContent) -> Option(Fence) { // A fenced code block begins with a code fence, // preceded by up to three spaces of indentation. 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)) + "```" <> rest -> Some(build_fence("`", 0, 3, rest)) + " ```" <> rest -> Some(build_fence("`", 1, 3, rest)) + " ```" <> rest -> Some(build_fence("`", 2, 3, rest)) + " ```" <> rest -> Some(build_fence("`", 3, 3, rest)) + "~~~" <> rest -> Some(build_fence("~", 0, 3, rest)) + " ~~~" <> rest -> Some(build_fence("~", 1, 3, rest)) + " ~~~" <> rest -> Some(build_fence("~", 2, 3, rest)) + " ~~~" <> rest -> Some(build_fence("~", 3, 3, rest)) _ -> None } } +/// Figures out how many characters are in the fence, and builds a fence fn build_fence( character: String, indent: Int, delimiters: Int, rest: String, - original: LineContent, ) -> Fence { case string.pop_grapheme(rest) { Ok(#(first, rest)) if first == character -> - build_fence(character, indent, delimiters + 1, rest, original) + build_fence(character, indent, delimiters + 1, rest) + _ -> { let fence = string.repeat(character, delimiters) - Fence(fence, rest <> original.ending, indent) + Fence(fence:, info: rest, indent:) } } } diff --git a/test/checkmark/internal/parser_test.gleam b/test/checkmark/internal/parser_test.gleam index f2acf08..e00e9ad 100644 --- a/test/checkmark/internal/parser_test.gleam +++ b/test/checkmark/internal/parser_test.gleam @@ -1,10 +1,5 @@ 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") -} fn comment_agnostic(body: fn(Bool) -> Nil) -> Nil { body(False) @@ -13,270 +8,241 @@ fn comment_agnostic(body: fn(Bool) -> Nil) -> Nil { pub fn empty_string_test() { use in_comments <- comment_agnostic() - assert parser.parse("", in_comments) == [] + assert parser.parse([], in_comments) == [] } pub fn no_snippets_test() { use in_comments <- comment_agnostic() - let text = - lines([ - "one", - "two\r", - "three", - ]) + let text = [ + "one\n", + "two\r\n", + "three\n", + ] assert parser.parse(text, in_comments) == [parser.Other(1, text)] } pub fn basic_snippet_test() { assert parser.parse( - lines([ - "start", - "```gleam", - "code\r", - "more_code", - "``` ", - "rest", - ]), + [ + "start\n", + "```gleam\n", + "code\r\n", + "more_code\n", + "``` \n", + "rest\n", + ], False, ) == [ - Other(1, "start\n"), + Other(1, ["start\n"]), FencedCode( 2, "", - lines([ - "code\r", - "more_code", - "", - ]), + [ + "code\r\n", + "more_code\n", + ], Fence("```", "gleam\n", 0), Some(Fence("```", " \n", 0)), ), - Other(6, "rest"), + Other(6, ["rest\n"]), ] } pub fn doc_comment_test() { assert parser.parse( - lines([ - "pub const answer = 42", - "", - "/// start", - "/// ```gleam", - "/// code\r", - "/// more_code", - "/// ``` ", - "pub const answer_str = \"*\"", - ]), + [ + "pub const answer = 42\n", + "\n", + "/// start\n", + "/// ```gleam\n", + "/// code\r\n", + "/// more_code\n", + "/// ``` \n", + "pub const answer_str = \"*\"\n", + ], True, ) == [ - Other( - 1, - lines([ - "pub const answer = 42", - "", - "/// start", - "", - ]), - ), + Other(1, [ + "pub const answer = 42\n", + "\n", + "/// start\n", + ]), FencedCode( 4, "/// ", - lines([ - "code\r", - "more_code", - "", - ]), + [ + "code\r\n", + "more_code\n", + ], Fence("```", "gleam\n", 0), Some(Fence("```", " \n", 0)), ), - Other(8, "pub const answer_str = \"*\""), + Other(8, ["pub const answer_str = \"*\"\n"]), ] } pub fn module_comment_test() { assert parser.parse( - lines([ - "//// start", - "//// ```gleam", - "//// code", - "//// ``` ", - "//// rest", - ]), + [ + "//// start\n", + "//// ```gleam\n", + "//// code\n", + "//// ``` \n", + "//// rest\n", + ], True, ) == [ - Other( - 1, - lines([ - "//// start", - "", - ]), - ), + Other(1, [ + "//// start\n", + ]), FencedCode( 2, "//// ", - lines([ - "code", - "", - ]), + [ + "code\n", + ], Fence("```", "gleam\n", 0), Some(Fence("```", " \n", 0)), ), - Other(5, "//// rest"), + Other(5, ["//// rest\n"]), ] } pub fn unfinished_comment_block_test() { assert parser.parse( - lines([ - "//// start", - "//// ```gleam", - "//// code", - "rest", - ]), + [ + "//// start\n", + "//// ```gleam\n", + "//// code\n", + "rest\n", + ], True, ) == [ - Other( - 1, - lines([ - "//// start", - "", - ]), - ), + Other(1, [ + "//// start\n", + ]), FencedCode( 2, "//// ", - lines([ - "code", - "", - ]), + [ + "code\n", + ], Fence("```", "gleam\n", 0), None, ), - Other(4, "rest"), + Other(4, ["rest\n"]), ] } pub fn indented_snippet_test() { assert parser.parse( - lines([ - " ```gleam", - " code", - " more_code", - " not indented enough", - " ```", - ]), + [ + " ```gleam\n", + " code\n", + " more_code\n", + " not indented enough\n", + " ```\n", + ], False, ) == [ FencedCode( 1, "", - lines([ - "code", - " more_code", - "not indented enough", - "", - ]), + [ + "code\n", + " more_code\n", + "not indented enough\n", + ], Fence("```", "gleam\n", 3), - Some(Fence("```", "", 2)), + Some(Fence("```", "\n", 2)), ), ] } pub fn non_matching_fences_test() { assert parser.parse( - lines([ - "````", - "```", - "~~~", - "code", - "````", - ]), + [ + "````\n", + "```\n", + "~~~\n", + "code\n", + "````\n", + ], False, ) == [ FencedCode( 1, "", - lines([ - "```", - "~~~", - "code", - "", - ]), + [ + "```\n", + "~~~\n", + "code\n", + ], Fence("````", "\n", 0), - Some(Fence("````", "", 0)), + Some(Fence("````", "\n", 0)), ), ] } pub fn missing_end_fence_test() { assert parser.parse( - lines([ - "```", - "code", - ]), + [ + "```\n", + "code\n", + ], False, ) - == [FencedCode(1, "", "code", Fence("```", "\n", 0), None)] + == [FencedCode(1, "", ["code\n"], Fence("```", "\n", 0), None)] } pub fn empty_fence_test() { - assert parser.parse("```info", False) - == [FencedCode(1, "", "", Fence("```", "info", 0), None)] + assert parser.parse(["```info\n"], False) + == [FencedCode(1, "", [], Fence("```", "info\n", 0), None)] } pub fn emtpy_line_preservation_test() { assert parser.parse( - lines([ - "", - "text", - "", - "```", - "", - "code", - "", - "```", - "", - "", - "", - ]), + [ + "\n", + "text\n", + "\n", + "```\n", + "\n", + "code\n", + "\n", + "```\n", + "\n", + "\n", + ], False, ) == [ - Other( - 1, - lines([ - "", - "text", - "", - "", - ]), - ), + Other(1, [ + "\n", + "text\n", + "\n", + ]), FencedCode( 4, "", - lines([ - "", - "code", - "", - "", - ]), + [ + "\n", + "code\n", + "\n", + ], Fence("```", "\n", 0), Some(Fence("```", "\n", 0)), ), - Other( - 9, - lines([ - "", - "", - "", - ]), - ), + Other(9, [ + "\n", + "\n", + ]), ] }