dolang-shell-modules 0.1.0

Stock embedded Do modules for the shell.
import args compile load progress strand term
  fs:
    - Path
  glob:
    - Glob
  time:
    - timeout

let err_style = term.style bold: true fg: :BRIGHT_RED:
let info_style = term.style fg: :YELLOW:
let pass_style = term.style fg: :GREEN:
let name_style = term.style dim: true
let note_style = term.style bold: true

let collect_key = strand.Key()

def parse_jobs value
  let jobs = try
    int $value
  catch std.RuntimeError: _
    throw args.Error "--jobs expects an integer"
  if (jobs < 1)
    throw args.Error "--jobs must be at least 1"
  jobs

def parse_timeout value
  let secs = try
    float $value
  catch std.RuntimeError: _
    throw args.Error "--timeout expects a number"
  if !(secs >= 0)
    throw args.Error "--timeout must be non-negative"
  secs

class ModuleCompileError: std.RuntimeError
  pub field path output

  def (init) self path output
    self.path = path
    self.output = output

def module_name path
  let name = str $path.without_ext()
  name.replace "\\" "/"

class Harness
  field module_jobs jobs filter timeout
  field errors = []
  field success total = 0

  def (init) self :jobs :module_jobs :filter :timeout
    self.#module_jobs = strand.Resource $module_jobs
    self.#jobs = strand.Resource $jobs
    self.#timeout = timeout
    self.#filter = try
        Glob $filter
    catch std.RuntimeError: _
        throw args.Error "invalid --filter glob: $filter"

  def matches_filter self path test
     self.#filter.matches "$(module_name path)/$test"

  def record_test self path name
    self.#total = (self.#total + 1)
    if !term.have_terminal
      echo "run: $(module_name path)/$name"

  def record_success self
    self.#success = (self.#success + 1)

  def with_timeout self func
    if (self.#timeout != nil)
      timeout $self.#timeout $func
    else
      func()

  def record_error self err bt path test = nil
    self.#errors.push [err, bt, path, test]

  def run_test self path name func
    self.#record_test $path $name
    try
      self.#with_timeout $func
      self.#record_success()
    catch std.RuntimeError: e
      self.#record_error $e $(strand.error_backtrace()) $path $name

  def run_module self path tests
    progress.show message: (module_name path) total: $tests.len do |w|
      strand.fork
        for name func = tests
          do self.#jobs.with do
            progress.show message: $name do |_|
              self.#run_test $path $name $func
              w.delta()

  def run_file self path
    let source = path.read b
    let compiled = compile.compile $path $source
    let warnings = compiled.diagnostics.iter().any do |d| (d.severity == :WARNING:)
    if (!compiled.ok || warnings)
      throw ModuleCompileError $path $compiled

    collect_key.value = []
    self.#with_timeout do load.run $compiled.bytecode
    let tests = collect_key.value
    collect_key.value = nil
    tests = [...tests.iter().filter(do |test| self.#matches_filter path test[0])]
    self.#run_module $path $tests

  def report self
    let rc = 0
    let skipped = 0
    for e bt path test = self.#errors
      let module = module_name $path
      if (type e Skip)
        if test
          print (info_style "skipped") ": " (name_style "$module/$test")
          if e.message
            print " (" (note_style e.message) ")"
          echo()
          skipped = (skipped + 1)
        else
          print (info_style "skipped") ": " (name_style "$module")
          if e.message
            print " (" (note_style e.message) ")"
          echo()
      else if (type e ModuleCompileError)
        rc = 1
        print (err_style "failed") ": " (name_style "$module")
          " (" (note_style "compilation failed") ")\n"
        for diag = e.output.diagnostics
          echo $diag.render().indent(2)
      else
        rc = 1
        if test
          print (err_style "failed") ": " (name_style "$module/$test") "\n"
        else
          print (err_style "failed") ": " (name_style "$module") "\n"
        echo $ term.render_error(e, backtrace: bt).indent(2)
    print (pass_style "passed") ": $(self.#success)/$(self.#total - skipped)"
    if skipped
      print " ($skipped " (info_style "skipped") ")"
    echo()
    rc

  pub def run self files
    progress.with do
      progress.show message: modules total: $files.len do |w|
        strand.fork
          for path = files
            do self.#module_jobs.with do
              try
                self.#run_file $path
              catch std.RuntimeError: e
                self.#record_error $e $(strand.error_backtrace()) $path
              w.delta()
    self.#report()

class Fixture
  field wrap func

  def (init) self wrap func
    self.#wrap = wrap
    self.#func = func

  def (call) self
    self.#wrap $self.#func

  def (str) self
    str $self.#func

# Defines a test fixture.  `wrap` should be a function which accepts a block
# and invokes it with surrounding setup and teardown logic.
#
# # Returns
#
# A function suitable for use as a decorator on a test function.
#
# # Example
#
# ```
# #[fixture]
# def example_fixture block
#   setup()
#   try
#     block()
#   finally
#     teardown()
#
# #[test]
# #[example_fixture]
# def example_test()
#   do_something()
# ```
pub def fixture wrap
  do |func| Fixture $wrap $func

# Register a test case that runs `func` with no arguments.  `name`, if unspecified,
# defaults to the `str` coercion of `func`, which for `def`s is simply the function
# name.  This function is suitable for use as a decorator:
#
# ```
# #[test]
# def the_test()
#   do_something()
#
# #[test name: "alternate_name"]
# def overriden_name()
#   do_something_else()
# ```
#
# # Returns
# If `func` is not specified, returns a function that takes the test case function instead
# (for use as a decorator). Otherwise, returns `func`
pub def test
  :name = nil
  func = nil
do
  if (func == nil)
    return do |func| test :name $func
  collect_key.value.push [name || str(func), func]
  func

pub class Assert: std.RuntimeError
  pub field message = nil

  def (init) self message
    self.message = message

  def (str) self
    if (self.message != nil)
      "assertion failed: $(self.message)"
    else
      "assertion failed"

# Exception thrown to skip a test case.
#
# Throw this (or call `skip()`) from a test function to mark it as skipped
# rather than failed.
pub class Skip: std.RuntimeError
  # Skip message, or `nil` is none specified
  pub field message = nil

  def (init) self message = nil
    self.message = message

# Skip the current test with an optional message.
pub def skip msg = nil
  throw Skip $msg

# Assert that `cond` is truthy, throwing an error with optional `msg` on failure.
pub def assert cond msg = nil
  if !cond
    throw Assert $msg

# Assert that `cond` is falsy, throwing an error with optional `msg` on failure.
pub def assert_not cond msg = nil
  if !cond
    throw Assert $msg

# Assert that `left == right`, throwing an error with optional `msg` on failure.
pub def assert_eq left right msg = nil
  if (left != right)
    if msg
      throw Assert "$(dbg left) == $(dbg right): $msg"
    else
      throw Assert "$(dbg left) == $(dbg right)"

# Assert that `left != right`, throwing an error with optional `msg` on failure.
pub def assert_ne left right msg = nil
  if (left == right)
    if msg
      throw Assert "$(dbg left) != $(dbg right): $msg"
    else
      throw Assert "$(dbg left) != $(dbg right)"

# Assert that `block` throws an error of `type`, throwing an error with optional `msg`
# otherwise.  The caught error is returned for further inspection if desired.
pub def assert_throws type :msg = nil block
  try
    block()
  catch type: e
    return e
  if msg
    throw Assert "$msg ($type thrown)"
  else
    throw Assert "$type thrown"

# Assert that `value` is of type `expected` (including a subtype), throwing an
# error with optional `msg` otherwise.
pub def assert_type expected value msg = nil
  if !(type value expected)
    if msg
      throw Assert "$msg ($value @ $(type value) <: $expected)"
    else
      throw Assert "$value @ $(type value) <: $expected"

pub def main()
  args.with
    help: Run Do unit test modules.
    - opt: jobs
      short: j
      type: $parse_jobs
      default: 8
      meta: N
      help: Max concurrent tests (among all modules)
    - opt: module-jobs
      type: $parse_jobs
      default: 4
      meta: N
      help: Max concurrent modules
    - opt: filter
      default: **
      help: Glob filter for test names in `stem/test` form
    - opt: timeout
      type: $parse_timeout
      default: 600
      meta: SECS
      help: Max seconds for each test and module load.
    - arg: paths
      type: $Path
      collect: true
      help: Test directories or files to run.
    do |p|
      let :filter :jobs :module_jobs :timeout :paths ... = p
      let harness = Harness :jobs :module_jobs :filter :timeout
      let files = []
      for path = paths
        if (path.metadata().type == :DIR:)
          files.push ...path.glob("**/*.dol")
        else
          files.push $path
      exit $ harness.run $files