jm

Odin for scripts: small packages and a runner, on core: only
Log | Files | Refs | README

prelude.odin (12865B)


      1 /*
      2 Package prelude is the first import of every script.
      3 
      4 	main :: proc() {
      5 		context = prelude.init()
      6 		cfg := must(os.read_entire_file_from_path("cfg.json", context.allocator))
      7 		if len(cfg) == 0 {
      8 			die("empty config")
      9 		}
     10 	}
     11 
     12 init picks the allocator and the logger, opens the audit log, and returns the
     13 context the script should run under. finish runs at the end of the caller's
     14 scope through @(deferred_none), writes the exit line, and reports allocator
     15 issues when the debug allocator is in use.
     16 
     17 Allocation policy: a growing virtual arena is context.allocator. Scripts never
     18 free; the process exit reclaims everything. Set Options.debug (or ODIN_DEBUG,
     19 or the environment variable ODIN_SCRIPT_DEBUG=1) to swap in the debug
     20 allocator from jm:debug, which reports overflow, double free, and write after
     21 free at exit.
     22 
     23 Logging: one logfmt line per record in <log_dir>/<name>/<name>.log, plus a
     24 human line on stderr for warning and above. die appends one line to
     25 <log_dir>/deaths.log so every failed run across every script is auditable
     26 from a single file. log_dir defaults to os.user_log_dir()/odin, which the
     27 doc comments in core/os/user.odin place at:
     28 
     29 	Linux:   ~/.local/state/odin
     30 	macOS:   ~/Library/Logs/odin
     31 	Windows: %LOCALAPPDATA%\odin
     32 
     33 Nothing in this package writes to stdout; stdout belongs to the script's data.
     34 */
     35 package prelude
     36 
     37 import "base:intrinsics"
     38 import "base:runtime"
     39 import "core:fmt"
     40 import "core:log"
     41 import "core:mem"
     42 import "core:mem/virtual"
     43 import "core:os"
     44 import "core:path/filepath"
     45 import "core:strings"
     46 import "core:time"
     47 
     48 import "jm:debug"
     49 
     50 Options :: struct {
     51 	// Program name used in log paths and lines. Default: basename of os.args[0].
     52 	name:          string,
     53 	// Use the debug allocator. Default: ODIN_DEBUG or ODIN_SCRIPT_DEBUG=1.
     54 	debug:         Maybe(bool),
     55 	// Print the debug allocator report even when it found no issues.
     56 	report_clean:  bool,
     57 	// Root of the log tree. Default: os.user_log_dir()/odin.
     58 	log_dir:       string,
     59 	// Skip the log file and deaths.log entirely; stderr only.
     60 	no_log_file:   bool,
     61 	// Lowest level echoed to stderr. Default: Warning, or ODIN_LOG=debug|info|warning|error.
     62 	console_level: Maybe(log.Level),
     63 	// Lowest level written to the log file. Default: Debug.
     64 	file_level:    Maybe(log.Level),
     65 	// Rotate the log file to .1 when it exceeds this many bytes. Default: 8 MiB.
     66 	max_log_bytes: i64,
     67 }
     68 
     69 DEFAULT_MAX_LOG_BYTES :: 8 * 1024 * 1024
     70 
     71 State :: struct {
     72 	name:          string,
     73 	arena:         virtual.Arena,
     74 	dbg:           debug.Allocator,
     75 	debugging:     bool,
     76 	report_clean:  bool,
     77 	allocator:     mem.Allocator, // what the script runs under
     78 	internals:     mem.Allocator, // heap; owns everything in this struct
     79 	log_file:      ^os.File,
     80 	log_path:      string,
     81 	deaths_path:   string,
     82 	console_level: log.Level,
     83 	file_level:    log.Level,
     84 	started:       time.Tick,
     85 	initialised:   bool,
     86 	finished:      bool,
     87 }
     88 
     89 state: State
     90 
     91 // init prepares the allocator and logger and returns the context to run under.
     92 // Call it once at the top of main as `context = prelude.init()`. finish runs
     93 // automatically when the calling scope ends.
     94 @(deferred_none=finish)
     95 init :: proc(opts := Options{}, loc := #caller_location) -> runtime.Context {
     96 	assert(!state.initialised, "prelude.init called twice", loc)
     97 	state.initialised = true
     98 	state.internals = runtime.heap_allocator()
     99 	state.started = time.tick_now()
    100 	state.report_clean = opts.report_clean
    101 
    102 	name := opts.name
    103 	if name == "" {
    104 		name = default_name(state.internals)
    105 	}
    106 	state.name = strings.clone(name, state.internals)
    107 
    108 	state.debugging = opts.debug.? or_else (ODIN_DEBUG || env_truthy("ODIN_SCRIPT_DEBUG"))
    109 	if state.debugging {
    110 		debug.init(&state.dbg, state.internals, state.internals)
    111 		state.allocator = debug.allocator(&state.dbg)
    112 	} else {
    113 		if err := virtual.arena_init_growing(&state.arena); err != nil {
    114 			// No arena: fall back to the heap rather than fail before main starts.
    115 			state.allocator = state.internals
    116 		} else {
    117 			state.allocator = virtual.arena_allocator(&state.arena)
    118 		}
    119 	}
    120 
    121 	state.console_level = opts.console_level.? or_else console_level_from_env()
    122 	state.file_level = opts.file_level.? or_else log.Level.Debug
    123 
    124 	if !opts.no_log_file {
    125 		open_log_file(opts)
    126 	}
    127 	platform_init()
    128 
    129 	ctx := context
    130 	ctx.allocator = state.allocator
    131 	ctx.logger = log.Logger {
    132 		procedure    = logger_proc,
    133 		data         = &state,
    134 		lowest_level = min(state.console_level, state.file_level),
    135 		options      = {},
    136 	}
    137 
    138 	context = ctx
    139 	log.debugf("event=start pid=%d cwd=%q args=%q", os.get_pid(), cwd(), args_string())
    140 	return ctx
    141 }
    142 
    143 // finish writes the exit line and tears down. It runs on scope exit via the
    144 // deferred attribute on init; exit and die call it explicitly.
    145 finish :: proc() {
    146 	finish_with_code(0)
    147 }
    148 
    149 // exit ends the script with code, writing the exit line first.
    150 exit :: proc(code: int) -> ! {
    151 	finish_with_code(code)
    152 	os.exit(code)
    153 }
    154 
    155 // die logs a fatal message to the script log, stderr, and deaths.log, then
    156 // exits with code 1.
    157 die :: proc(format: string, args: ..any, loc := #caller_location) -> ! {
    158 	msg := fmt.tprintf(format, ..args)
    159 	if state.initialised {
    160 		log.log(.Fatal, msg, location = loc)
    161 		record_death(msg, loc)
    162 	} else {
    163 		fmt.eprintf("%s: fatal: %s\n", default_name(context.temp_allocator), msg)
    164 	}
    165 	exit(1)
    166 }
    167 
    168 // must unwraps a (value, ok) or (value, err) pair, or checks a bare ok or
    169 // err, and dies with the call site when the check fails.
    170 //
    171 //	f := must(os.open(path))
    172 //	n := must(strconv.parse_int(s))
    173 //	must(os.make_directory_all(dir))
    174 must :: proc {
    175 	must_ok,
    176 	must_err,
    177 	must_bool,
    178 	must_error,
    179 }
    180 
    181 @(require_results)
    182 must_ok :: proc(v: $T, ok: bool, loc := #caller_location) -> T {
    183 	if !ok {
    184 		die("must: check failed", loc = loc)
    185 	}
    186 	return v
    187 }
    188 
    189 @(require_results)
    190 must_err :: proc(v: $T, err: $E, loc := #caller_location) -> T where !intrinsics.type_is_boolean(E) {
    191 	if failed(err) {
    192 		die("must: %v", err, loc = loc)
    193 	}
    194 	return v
    195 }
    196 
    197 must_bool :: proc(ok: bool, loc := #caller_location) {
    198 	if !ok {
    199 		die("must: check failed", loc = loc)
    200 	}
    201 }
    202 
    203 must_error :: proc(err: $E, loc := #caller_location) where !intrinsics.type_is_boolean(E) {
    204 	if failed(err) {
    205 		die("must: %v", err, loc = loc)
    206 	}
    207 }
    208 
    209 // failed reports whether an error value of any common shape is set: a union
    210 // (nil when clear), an enum (zero when clear), or a string (empty when clear).
    211 failed :: proc(err: $E) -> bool {
    212 	when intrinsics.type_is_union(E) {
    213 		return err != nil
    214 	} else when intrinsics.type_is_enum(E) {
    215 		return err != E(0)
    216 	} else when intrinsics.type_is_string(E) {
    217 		return len(err) > 0
    218 	} else when intrinsics.type_is_pointer(E) {
    219 		return err != nil
    220 	} else {
    221 		#panic("prelude.must: unsupported error type")
    222 	}
    223 }
    224 
    225 // env returns the environment variable or def when unset or empty.
    226 env :: proc(key: string, def := "", allocator := context.allocator) -> string {
    227 	v, found := os.lookup_env(key, allocator)
    228 	if !found || v == "" {
    229 		return def
    230 	}
    231 	return v
    232 }
    233 
    234 // args returns the script arguments without the program name.
    235 args :: proc() -> []string {
    236 	if len(os.args) == 0 {
    237 		return nil
    238 	}
    239 	return os.args[1:]
    240 }
    241 
    242 // name returns the program name init settled on.
    243 name :: proc() -> string {
    244 	return state.name
    245 }
    246 
    247 // log_path returns the script's log file path, or "" when logging to a file is off.
    248 log_path :: proc() -> string {
    249 	return state.log_path
    250 }
    251 
    252 // ---- internals ----------------------------------------------------------
    253 
    254 finish_with_code :: proc(code: int) {
    255 	if !state.initialised || state.finished {
    256 		return
    257 	}
    258 	state.finished = true
    259 	dur := time.tick_since(state.started)
    260 	log.debugf("event=exit code=%d dur=%v", code, dur)
    261 	if state.log_file != nil {
    262 		os.flush(state.log_file)
    263 		os.close(state.log_file)
    264 		state.log_file = nil
    265 	}
    266 	if state.debugging {
    267 		if debug.issue_count(&state.dbg) > 0 || state.report_clean {
    268 			debug.report(&state.dbg)
    269 		}
    270 		debug.destroy(&state.dbg)
    271 	}
    272 }
    273 
    274 default_name :: proc(allocator: mem.Allocator) -> string {
    275 	if len(os.args) == 0 {
    276 		return "odin-script"
    277 	}
    278 	stem := filepath.stem(os.args[0])
    279 	return strings.clone(stem, allocator)
    280 }
    281 
    282 env_truthy :: proc(key: string) -> bool {
    283 	v, found := os.lookup_env(key, context.temp_allocator)
    284 	if !found {
    285 		return false
    286 	}
    287 	switch strings.to_lower(v, context.temp_allocator) {
    288 	case "1", "true", "yes", "on":
    289 		return true
    290 	}
    291 	return false
    292 }
    293 
    294 console_level_from_env :: proc() -> log.Level {
    295 	v, found := os.lookup_env("ODIN_LOG", context.temp_allocator)
    296 	if !found {
    297 		return .Warning
    298 	}
    299 	switch strings.to_lower(v, context.temp_allocator) {
    300 	case "debug":
    301 		return .Debug
    302 	case "info":
    303 		return .Info
    304 	case "warn", "warning":
    305 		return .Warning
    306 	case "error":
    307 		return .Error
    308 	case "fatal":
    309 		return .Fatal
    310 	}
    311 	return .Warning
    312 }
    313 
    314 open_log_file :: proc(opts: Options) {
    315 	root := opts.log_dir
    316 	if root == "" {
    317 		base, err := os.user_log_dir(context.temp_allocator)
    318 		if err != nil {
    319 			fmt.eprintf("%s: warning: no log directory: %v\n", state.name, err)
    320 			return
    321 		}
    322 		root = join(base, "odin")
    323 	}
    324 	dir := join(root, state.name)
    325 	if err := os.make_directory_all(dir); err != nil && !(err == os.General_Error.Exist && os.is_dir(dir)) {
    326 		fmt.eprintf("%s: warning: cannot create %s: %v\n", state.name, dir, err)
    327 		return
    328 	}
    329 	file_name := strings.concatenate({state.name, ".log"}, context.temp_allocator)
    330 	state.log_path = join(dir, file_name, state.internals)
    331 	state.deaths_path = join(root, "deaths.log", state.internals)
    332 
    333 	max_bytes := opts.max_log_bytes
    334 	if max_bytes <= 0 {
    335 		max_bytes = DEFAULT_MAX_LOG_BYTES
    336 	}
    337 	rotate(state.log_path, max_bytes)
    338 
    339 	f, err := os.open(state.log_path, {.Write, .Append, .Create}, LOG_PERMISSIONS)
    340 	if err != nil {
    341 		fmt.eprintf("%s: warning: cannot open %s: %v\n", state.name, state.log_path, err)
    342 		return
    343 	}
    344 	state.log_file = f
    345 }
    346 
    347 LOG_PERMISSIONS :: os.Permissions_Read_All + {.Write_User}
    348 
    349 // rotate moves path to path.1 when it exceeds max_bytes. os.rename replaces an
    350 // existing target on every platform (core:os uses MOVEFILE_REPLACE_EXISTING on
    351 // Windows), so the previous .1 needs no separate removal.
    352 rotate :: proc(path: string, max_bytes: i64) {
    353 	info, err := os.stat(path, context.temp_allocator)
    354 	if err != nil || info.size <= max_bytes {
    355 		return
    356 	}
    357 	old := strings.concatenate({path, ".1"}, context.temp_allocator)
    358 	os.rename(path, old)
    359 }
    360 
    361 record_death :: proc(msg: string, loc: runtime.Source_Code_Location) {
    362 	if state.deaths_path == "" {
    363 		return
    364 	}
    365 	f, err := os.open(state.deaths_path, {.Write, .Append, .Create}, LOG_PERMISSIONS)
    366 	if err != nil {
    367 		return
    368 	}
    369 	defer os.close(f)
    370 	b := strings.builder_make(context.temp_allocator)
    371 	write_logfmt_line(&b, .Fatal, msg, loc)
    372 	os.write_string(f, strings.to_string(b))
    373 }
    374 
    375 logger_proc :: proc(data: rawptr, level: log.Level, text: string, options: log.Options, location := #caller_location) {
    376 	st := (^State)(data)
    377 	if st.log_file != nil && level >= st.file_level {
    378 		b := strings.builder_make(context.temp_allocator)
    379 		write_logfmt_line(&b, level, text, location)
    380 		os.write_string(st.log_file, strings.to_string(b))
    381 	}
    382 	if level >= st.console_level {
    383 		if level >= .Error {
    384 			fmt.eprintf("%s: %s: %s:%d: %s\n", st.name, level_name(level), filepath.base(location.file_path), location.line, text)
    385 		} else {
    386 			fmt.eprintf("%s: %s: %s\n", st.name, level_name(level), text)
    387 		}
    388 	}
    389 }
    390 
    391 write_logfmt_line :: proc(b: ^strings.Builder, level: log.Level, text: string, loc: runtime.Source_Code_Location) {
    392 	stamp, _ := time.time_to_rfc3339(time.now(), 0, false, context.temp_allocator)
    393 	fmt.sbprintf(b, "t=%s lvl=%s prog=%s loc=%s:%d ", stamp, level_name(level), state.name, filepath.base(loc.file_path), loc.line)
    394 	// Text that is already logfmt (event=... from this package) goes through as is.
    395 	if strings.has_prefix(text, "event=") {
    396 		strings.write_string(b, text)
    397 	} else {
    398 		strings.write_string(b, "msg=")
    399 		write_quoted(b, text)
    400 	}
    401 	strings.write_byte(b, '\n')
    402 }
    403 
    404 write_quoted :: proc(b: ^strings.Builder, s: string) {
    405 	strings.write_byte(b, '"')
    406 	for c in s {
    407 		switch c {
    408 		case '"':
    409 			strings.write_string(b, `\"`)
    410 		case '\\':
    411 			strings.write_string(b, `\\`)
    412 		case '\n':
    413 			strings.write_string(b, `\n`)
    414 		case '\r':
    415 			strings.write_string(b, `\r`)
    416 		case '\t':
    417 			strings.write_string(b, `\t`)
    418 		case:
    419 			strings.write_rune(b, c)
    420 		}
    421 	}
    422 	strings.write_byte(b, '"')
    423 }
    424 
    425 level_name :: proc(level: log.Level) -> string {
    426 	switch level {
    427 	case .Debug:
    428 		return "debug"
    429 	case .Info:
    430 		return "info"
    431 	case .Warning:
    432 		return "warning"
    433 	case .Error:
    434 		return "error"
    435 	case .Fatal:
    436 		return "fatal"
    437 	}
    438 	return "info"
    439 }
    440 
    441 cwd :: proc() -> string {
    442 	dir, err := os.get_working_directory(context.temp_allocator)
    443 	if err != nil {
    444 		return ""
    445 	}
    446 	return dir
    447 }
    448 
    449 args_string :: proc() -> string {
    450 	return strings.join(os.args, " ", context.temp_allocator)
    451 }
    452 
    453 join :: proc(a, b: string, allocator := context.temp_allocator) -> string {
    454 	s, _ := filepath.join({a, b}, allocator)
    455 	return s
    456 }