//// 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 import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import splitter /// Contains the configuration for checking files. /// Not tied to a single file, may contain more configuration later. /// The error type depends on the IO library used. pub opaque type Checker(e) { Checker( read: fn(String) -> Result(String, e), write: fn(String, String) -> Result(Nil, e), ) } /// 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(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) { CouldNotReadFile(error: e) CouldNotWriteFile(error: 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. pub fn new( read_file: fn(String) -> Result(String, e), write_file: fn(String, String) -> Result(Nil, e), ) -> Checker(e) { Checker(read_file, write_file) } /// 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, 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, []) } /// Loads a gleam source file to extract snippets from. pub fn load_snippet_source( checker: Checker(e), filename: String, ) -> Result(CodeSnippetSource, CheckError(e)) { use content <- result.try(read_file(checker, filename)) code_extractor.load(content) |> result.replace_error(CouldNotParseSnippetSource) |> result.map(CodeSnippetSource(filename, _)) } /// Specify that the file should contain the contents of another file as a code block. /// The tag is what comes after the block fence. /// e.g. "```gleam 1" would match the tag "gleam 1". /// Whitespace is trimmed off the tag. /// Note that you still need to call `check`, `update` or `check_or_update` after this, /// this function only adds to the configuration. pub fn should_contain_contents_of( file: File(e), 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: [ 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), when should_update: Bool, ) -> Result(Nil, List(CheckError(e))) { case should_update { True -> update(file) False -> check(file) } } /// 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 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) case errors { [] -> Ok(Nil) _ -> Error(errors) } } /// 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 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) case errors { [] -> { let contents = render_replacements(contents, dict.from_list(replacements)) file.checker.write(file.name, contents) |> result.map_error(fn(e) { [CouldNotWriteFile(e)] }) } _ -> Error(errors) } } 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)), ) -> String { contents |> list.flat_map(fn(section) { case section { 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, lines, end_fence) } } parser.Other(lines:, ..) -> lines } }) |> string.join("") } fn render_code( start_fence: Fence, prefix: String, content: List(String), end_fence: Option(Fence), ) -> 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 end_fence { None -> content Some(fence) -> [fence, ..content] } list.reverse(content) } } [render_fence(prefix, start_fence), ..without_end_fence] } fn render_fence(prefix: String, fence: Fence) -> String { prefix <> string.repeat(" ", fence.indent) <> fence.fence <> fence.info } fn parse_file( file: File(e), ) -> Result(List(parser.Section), List(CheckError(e))) { read_lines(file.checker, file.name) |> result.map_error(list.wrap) |> result.map(parser.parse(_, file.check_in_comments)) } fn read_file( checker: Checker(e), filename: String, ) -> Result(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, []) } fn check_one( content: List(parser.Section), expected: List(String), tag: String, ) -> Result(Nil, CheckError(e)) { use snippet <- result.try(find_match(content, tag, [])) case snippet.content == expected { True -> Ok(Nil) False -> Error(ContentDidNotMatch(tag)) } } type Snippet { Snippet(content: List(String), start_fence: Fence, end_fence: Option(Fence)) } fn find_match( content: List(parser.Section), tag: String, found: List(#(Int, Snippet)), ) -> Result(Snippet, CheckError(e)) { case content { [] -> case found { [] -> Error(TagNotFound(tag)) [#(_, snippet)] -> Ok(snippet) _ -> Error(MultipleTagsFound( tag, found |> list.reverse |> list.map(fn(pair) { pair.0 }), )) } [ parser.FencedCode(line_number:, lines:, start_fence:, end_fence:, ..), ..rest ] -> { case string.trim(start_fence.info) == tag { True -> { let result = #(line_number, Snippet(lines, start_fence, end_fence)) find_match(rest, tag, [result, ..found]) } False -> find_match(rest, tag, found) } } [_, ..rest] -> find_match(rest, tag, found) } }