dolang-shell-modules 0.1.0

Stock embedded Do modules for the shell.
import compile digest fs load std shell

# Convenience helpers for the common “compile if needed, cache, then execute”
# workflow.
#
# Cached bytecode is stored under `fs.cache_dir()`, scoped by application name.
# If `app:` is omitted, the scope is derived from `shell.program`.

# Derive a default cache application name from `shell.program`.
#
# - script path: basename without the last extension
# - `-m` module: module name string
# - unset / REPL: `dolang`
def default_app()
  let p = shell.program
  if (p == nil)
    "dolang"
  else if (type p fs.Path)
    (p.stem || p.name || "dolang")
  else
    p

def normalize_app app
  app = (app || default_app()).replace("/", "_").replace("\\", "_")
  if (app == "")
    throw std.RuntimeError "cache app name must not be empty"
  app

def module_name path name
  if (name != nil)
    name
  else
    let stem = path.stem
    if (stem == nil)
      throw std.RuntimeError "can't derive module name from path: $path"
    stem

def inject_keys inject
  let keys = [...inject.keys()]
  if !(keys.iter().all do |k| type k sym)
    throw std.TypeError "inject key: expected `sym`"
  keys

def cache_key path mode name prelude inject
  let state = digest.Blake3()
  state.update $ str $ path.absolute().normalize()
  state.update "\0"
  state.update $mode
  state.update "\0"
  state.update (name || "")
  state.update "\0"
  state.update (str prelude)
  state.update "\0"
  state.update (str $ inject_keys inject)
  state.digest().hex()

def cache_root app
  let path = (fs.cache_dir() / normalize_app app  / "bytecode")
  path.create_dir all: true
  path

def cache_path path mode name app prelude inject
  let key = cache_key $path $mode $name $prelude $inject
  (cache_root app / "$key.dolc")

def synthetic_module_name key
  "\$$key\$"

def cache_usable cache path
  (cache.exists() && path.metadata().modified < cache.metadata().modified)

def compile_prelude prelude synthetic inject
  {...prelude, $synthetic: inject_keys inject}

def compile_script path prelude synthetic inject
  let source = path.read()
  let prelude = compile_prelude $prelude $synthetic $inject
  if (prelude == nil)
    compile.compile $path $source
  else
    compile.compile $path $source prelude: $prelude

def compile_module path name prelude synthetic inject
  let source = path.read()
  let prelude = compile_prelude $prelude $synthetic $inject
  if (prelude == nil)
    compile.compile $path $source module: $name
  else
    compile.compile $path $source module: $name prelude: $prelude

def injected_module inject
  record ...inject

def run_loaded bytecode synthetic inject
  if (synthetic == nil)
    load.run $bytecode
  else
    let module = injected_module $inject
    let handle = load.import_handler do |name|
      if (name == synthetic)
        module
      else
        throw std.ImportError(name)
    try
      load.run $bytecode
    finally
      handle.unregister()

def compile_cached path mode name cache prelude synthetic inject
  let compiled = if (mode == "script")
    compile_script $path $prelude $synthetic $inject
  else
    compile_module $path $name $prelude $synthetic $inject
  for diag = compiled.diagnostics
    echo $diag.render()
  if !compiled.ok
    throw std.CompileError "failed to compile $(path.relative())"
  cache.write $compiled.bytecode
  run_loaded $compiled.bytecode $synthetic $inject

def exec_cached path mode name app prelude inject
  if !(type inject dict)
    throw std.TypeError "inject: expected `dict`"
  path = fs.Path $path
  let key = cache_key $path $mode $name $prelude $inject
  let cache = cache_path $path $mode $name $app $prelude $inject
  let synthetic = synthetic_module_name $key
  if cache_usable $cache $path
    try
      return run_loaded (cache.read "b") $synthetic $inject
    catch std.BytecodeError: _
      nil
  compile_cached $path $mode $name $cache $prelude $synthetic $inject

# Compile `path` in script mode if needed, cache the bytecode, and execute it.
#
# `app` scopes the cache directory under `fs.cache_dir()`. When omitted, the
# scope defaults from `shell.program`.
#
# `prelude` is passed through to `compile.compile`.
#
# `inject` supplies globally scoped symbol/value bindings during compilation
# and execution.
#
# # Example
#
# ```
# import exec
#
# exec.run "./tool.dol"
# exec.run "./tool.dol" app: "my-tool"
# exec.run "./tool.dol" inject: {answer: 42}
# ```
pub def run path :app = nil :prelude = {} :inject = {}
  exec_cached $path script nil $app $prelude $inject

# Compile `path` in module mode if needed, cache the bytecode, and return the
# loaded module object.
#
# `name` defaults to the source file stem when omitted.
#
# `app` scopes the cache directory under `fs.cache_dir()`. When omitted, the
# scope defaults from `shell.program`.
#
# `prelude` is passed through to `compile.compile`.
#
# `inject` supplies globally scoped symbol/value bindings during compilation
# and execution.
#
# # Example
#
# ```
# import exec
#
# let mod = exec.module "./lib/build.dol"
# mod.main()
#
# let plugin = exec.module "./plugin.dol" name: "plugins.build"
# let injected = exec.module "./plugin.dol" inject: {answer: 42}
# ```
pub def module path :name = nil :app = nil :prelude = {} :inject = {}
  exec_cached $path module (module_name (fs.Path path) name) $app $prelude $inject