Current section
Files
Jump to
Current section
Files
src/checkmark.gleam
import exception
import filepath
import gleam/function
import gleam/list
import gleam/result.{try}
import gleam/string
import kirala/markdown/parser
import shellout
import simplifile
import temporary
/// Specifies the `gleam` CLI operation to run on each snippet
pub type Operation {
/// Will run `gleam check` on the code
Check
/// Will run `gleam build` on the code
Build
/// Will run `gleam run` on the code
Run
}
/// Indicates a check on an individual snippet failed
pub type CheckError {
/// Running the check failed before we could get the result from `gleam`.
/// The string value might currently be a bit cryptic, it should be improved.
RunFailed(String)
/// We were able to run the `gleam` operation, but it failed with the given output.
CheckFailed(String)
}
/// Type alias for a result of checking a single snippet.
pub type CheckResult =
Result(Nil, CheckError)
/// Configuration for checking a markdown file.
pub opaque type CheckConfig {
CheckConfig(
filename: String,
filter: fn(String) -> Bool,
operation: Operation,
)
}
/// Constructs a new check configuration with the defaults of
/// running `gleam check` on all snippets in the README.md file.
pub fn new() -> CheckConfig {
CheckConfig("README.md", fn(_) { True }, Check)
}
/// Specifies the markdown file to check (default is README.md)
pub fn snippets_in(config: CheckConfig, filename: String) -> CheckConfig {
CheckConfig(..config, filename: filename)
}
/// Filters snippets by their content. E.g. `string.starts_with(_, "import")`
pub fn filtering(config: CheckConfig, filter: fn(String) -> Bool) -> CheckConfig {
CheckConfig(..config, filter: filter)
}
/// Uses the given operation, default is `Check`
pub fn using(config: CheckConfig, operation: Operation) -> CheckConfig {
CheckConfig(..config, operation: operation)
}
/// Runs checks in the current package,
/// writing into temporary file at `src/{filename}`.
/// The result will be `Error` if the whole operation failed (e.g. couldn't read snippets)
/// or `Ok` with a list of results for each snippet found in the file.
pub fn check_in_current_package(
config: CheckConfig,
as_file filename: String,
) -> Result(List(CheckResult), String) {
use snippet <- check_snippets_in(config.filename, config.filter)
check_in_dir(snippet, ".", filename, False, config.operation)
}
/// Runs checks in a temporarily set up package,
/// installing the given dependencies using `gleam add`.
/// The result will be `Error` if the whole operation failed (e.g. couldn't read snippets)
/// or `Ok` with a list of results for each snippet found in the file.
pub fn check_in_tmp_package(
config: CheckConfig,
dependencies: List(String),
) -> Result(List(CheckResult), String) {
use temp_dir <- with_tempdir()
use package_dir <- try(set_up_package(temp_dir, dependencies))
use snippet <- check_snippets_in(config.filename, config.filter)
check_in_dir(
snippet,
package_dir,
package_name <> ".gleam",
True,
config.operation,
)
}
fn set_up_package(
tmp_dir: String,
dependencies: List(String),
) -> Result(String, String) {
use _ <- try(run_gleam(
in: tmp_dir,
with: ["new", package_name],
fail_as: function.identity,
))
let package_dir = filepath.join(tmp_dir, package_name)
use _ <- try(case dependencies {
[] -> Ok(Nil)
_ ->
run_gleam(
in: package_dir,
with: ["add", ..dependencies],
fail_as: function.identity,
)
})
Ok(package_dir)
}
fn check_snippets_in(
filename: String,
filter: fn(String) -> Bool,
check: fn(String) -> CheckResult,
) -> Result(List(CheckResult), String) {
use content <- try(
simplifile.read(from: filename) |> result.map_error(string.inspect),
)
Ok(extract_gleam_code(content, filter) |> list.map(check))
}
fn extract_gleam_code(
markdown: String,
filter: fn(String) -> Bool,
) -> List(String) {
let tokens = parser.parse_all(markdown)
use token <- list.filter_map(tokens)
case token {
parser.CodeBlock("gleam", _, code) -> {
case filter(code) {
True -> Ok(code)
_ -> Error(Nil)
}
}
_ -> Error(Nil)
}
}
const package_name = "checkmark_tmp"
fn check_in_dir(
code: String,
package_dir: String,
filename: String,
allow_overwrite: Bool,
operation: Operation,
) -> CheckResult {
let source_dir = filepath.join(package_dir, "src")
let source_file = filepath.join(source_dir, filename)
use file <- with_tempfile(source_file, allow_overwrite)
use _ <- try(
simplifile.write(to: file, contents: code)
|> result.map_error(fn(e) { RunFailed(string.inspect(e)) }),
)
case string.split(filename, ".") {
[] -> Error(CheckFailed("Invalid source file name: " <> filename))
[module, ..] ->
run_gleam(
with: to_args(operation, module),
in: package_dir,
fail_as: CheckFailed,
)
}
}
fn with_tempdir(
operation: fn(String) -> Result(List(CheckResult), String),
) -> Result(List(CheckResult), String) {
{
use temp_dir <- temporary.create(temporary.directory())
operation(temp_dir)
}
|> result.map_error(string.inspect)
|> result.flatten
}
fn with_tempfile(
path: String,
allow_overwrite: Bool,
operation: fn(String) -> CheckResult,
) -> CheckResult {
use _ <- try(create_file(path, allow_overwrite))
use <- exception.defer(fn() { simplifile.delete(path) })
operation(path)
}
fn create_file(path: String, allow_overwrite: Bool) -> CheckResult {
case allow_overwrite, simplifile.create_file(path) {
True, Error(simplifile.Eexist) -> Ok(Nil)
_, Ok(_) -> Ok(Nil)
_, e -> Error(RunFailed(string.inspect(e)))
}
}
fn run_gleam(
in directory: String,
with args: List(String),
fail_as make_error: fn(String) -> e,
) -> Result(Nil, e) {
case shellout.command("gleam", with: args, in: directory, opt: []) {
Ok(_) -> Ok(Nil)
Error(#(_, e)) -> Error(make_error(e))
}
}
fn to_args(op: Operation, module: String) -> List(String) {
case op {
Build -> ["build"]
Check -> ["check"]
Run -> ["run", "--module", module]
}
}