🍯 Glaze

package main

import "core:encoding/json"
import "core:flags"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:slice"
import "core:strings"
import "core:sync/chan"
import "core:sys/posix"
import "core:thread"
import "core:time"

import "core:mem"

import "./vtty"

Package_Json :: struct {
	name:       string,
	workspaces: []string,
	scripts:    map[string]string,
}

IGNORED_FILEPATHS :: []string{"node_modules", ".git", "dist", "out", "tmp", "tmp-output"}

read_package_json :: proc(filepath: string) -> (Package_Json, bool) {
	package_json: Package_Json
	raw_json, err := os.read_entire_file(filepath, context.allocator)
	if err != nil {
		return package_json, false
	}
	defer delete(raw_json)

	if err := json.unmarshal(
		raw_json,
		&package_json,
		json.DEFAULT_SPECIFICATION,
		context.temp_allocator,
	); err != nil {
		return {}, false
	}
	defer free_all(context.temp_allocator)

	workspaces := make([]string, len(package_json.workspaces))
	for s, i in package_json.workspaces {
		workspaces[i] = strings.clone(s)
	}
	scripts := make(map[string]string)
	for k, v in package_json.scripts {
		scripts[strings.clone(k)] = strings.clone(v)
	}
	return {name = strings.clone(package_json.name), workspaces = workspaces, scripts = scripts},
		true
}

destroy_package_json :: proc(package_json: Package_Json) {
	delete(package_json.name)
	for _, i in package_json.workspaces {
		delete(package_json.workspaces[i])
	}
	delete(package_json.workspaces)
	for k, v in package_json.scripts {
		delete(k)
		delete(v)
	}
	delete(package_json.scripts)
}

collect_workspaces :: proc(script: string) -> map[string]Package_Json {
	root_package_json: Package_Json
	ok: bool
	root_package_json, ok = read_package_json("package.json")
	if !ok {
		fmt.printf("failed reading root package.json\n")
		os.exit(-1)
	}
	defer destroy_package_json(root_package_json)

	workspace_rules := root_package_json.workspaces
	package_jsons := make([dynamic]string)
	defer delete(package_jsons)

	w := filepath.walker_create(".")
	defer filepath.walker_destroy(&w)

	for info in filepath.walker_walk(&w) {
		_, err := os.walker_error(&w)
		if err != nil {
			fmt.printf("error walking %v\n", err)
			os.exit(-1)
		}

		curr_dir, _ := os.get_working_directory(context.allocator)
		rel_path, _ := filepath.rel(curr_dir, info.fullpath)
		defer delete(rel_path)
		defer delete(curr_dir)

		if rel_path == "package.json" {
			// NOTE: Ignore root package.json
			continue
		}

		if slice.contains(IGNORED_FILEPATHS, info.name) {
			continue
		}

		if info.name == "package.json" {
			dir := filepath.dir(rel_path)
			defer delete(dir)

			matched_rule := false
			for r in workspace_rules {
				if (strings.contains(r, "*") && strings.has_prefix(rel_path, r[:len(r) - 2])) ||
				   r == dir {
					matched_rule = true
				}
			}
			if !matched_rule {
				continue
			}

			append(&package_jsons, strings.clone(rel_path))
		}
	}

	workspaces := make(map[string]Package_Json)
	for json_path, i in package_jsons {
		package_json: Package_Json
		package_json, ok = read_package_json(json_path)
		if !ok {
			continue
		}

		if script in package_json.scripts {
			workspaces[json_path] = package_json
		} else {
			destroy_package_json(package_json)
			delete(package_jsons[i])
		}
	}

	return workspaces
}

Worker_Data :: struct {
	workspace: string,
	script:    string,
}

Run_Result :: struct {
	workspace: string,
	result:    bool,
}

Worker :: struct {
	pool_channel:   chan.Chan(Worker_Data, .Recv),
	result_channel: chan.Chan(Run_Result, .Send),
}

worker_func :: proc(w: Worker) {
	for {
		data, ok := chan.recv(w.pool_channel)
		if !ok {
			break
		}
		result := execute_npm_script(data.workspace, data.script)
		chan.send(w.result_channel, Run_Result{workspace = data.workspace, result = result})
	}
}

Npm_Scripts_Worker :: struct {
	workspaces:   map[string]Package_Json,
	script:       string,
	pool_channel: chan.Chan(Worker_Data, .Send),
}

execute_npm_scripts_worker :: proc(data: ^Npm_Scripts_Worker) {
	defer free(data)

	for _, w in data.workspaces {
		if ok := chan.send(
			data.pool_channel,
			Worker_Data{workspace = strings.clone(w.name), script = strings.clone(data.script)},
		); !ok {
			break
		}
	}
}

execute_npm_script :: proc(workspace, script: string) -> bool {
	command := strings.clone_to_cstring(
		fmt.tprintf("npm run %s -w %s --if-present 2>&1", script, workspace),
	)
	defer delete(command)

	fp := posix.popen(command, "r")
	if fp == nil {
		fmt.printf("popen failure\n")
		return false
	}

	output: [1024]byte
	for posix.fgets(raw_data(output[:]), len(output), fp) != nil {
		// posix.printf("%s", &output)
		_ = output
	}

	status := posix.pclose(fp)
	if status == -1 {
		// fmt.printf("pclose failure: %v\n", status)
		return false
	} else if status < -1 || status > 0 {
		// fmt.printf("command failed: %v\n", status)
		return false
	}
	return true
}

Options :: struct {
	script:     string `args:"pos=0,required" usage:"The npm script to run"`,
	root:       string `usage:"The root path to use (default ./)"`,
	concurrent: int `usage:"The number of concurrent scripts to run (defaults to num_cpu - 1)"`,
}

main :: proc() {
	when ODIN_DEBUG {
		track: mem.Tracking_Allocator
		mem.tracking_allocator_init(&track, context.allocator)
		context.allocator = mem.tracking_allocator(&track)
		defer {
			if len(track.allocation_map) > 0 {
				fmt.eprintf("=== %v allocations not freed: ===\n", len(track.allocation_map))
				for _, entry in track.allocation_map {
					fmt.eprintf("- %v bytes @ %v\n", entry.size, entry.location)
				}
			}
			mem.tracking_allocator_destroy(&track)
		}
	} else {
		// NOTE: Dummy code to let it ignore the mem library
		_ = mem.nil_allocator()
	}

	opt: Options
	style: flags.Parsing_Style = .Odin
	flags.parse_or_exit(&opt, os.args, style)
	if opt.root == "" {
		opt.root = "./"
	}
	if !strings.has_suffix(opt.root, "/") {
		opt.root = fmt.tprintf("%s/", opt.root)
	}
	if opt.concurrent == 0 {
		num_cpus := get_hardware_concurrency()
		opt.concurrent = num_cpus - 1 if num_cpus > 1 else 4
	}

	os.set_working_directory(opt.root)

	console: vtty.Console
	vtty.create(&console)
	defer {
		vtty.write(&console, vtty.RESET_FORMAT)
		vtty.write(&console, vtty.CURSOR_SHOW)
		vtty.destroy(&console)
	}
	vtty.write(&console, vtty.CURSOR_HIDE)

	when ODIN_OS == .Windows {
		windows.SetConsoleMode(
			windows.GetStdHandle(windows.STD_OUTPUT_HANDLE),
			windows.ENABLE_PROCESSED_OUTPUT | windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING,
		)
		windows.SetConsoleOutputCP(windows.CP_UTF8)
	}

	workspaces := collect_workspaces(opt.script)
	if len(workspaces) == 0 {
		fmt.printf("Nothing to do (everything filtered?)\n")
		os.exit(-1)
	}
	defer {
		for k, w in workspaces {
			destroy_package_json(w)
			delete(k)
		}
		delete(workspaces)
	}

	fmt.printf(
		"Running \"%s\" over %d workspaces using %d workers\n",
		opt.script,
		len(workspaces) - 1,
		opt.concurrent,
	)

	start := time.tick_now()
	pool_channel, _ := chan.create(chan.Chan(Worker_Data), context.allocator)
	defer chan.destroy(pool_channel)
	result_channel, _ := chan.create_buffered(chan.Chan(Run_Result), 100, context.allocator)
	defer chan.destroy(result_channel)

	for _ in 0 ..< opt.concurrent {
		thread.run_with_poly_data(
			Worker {
				pool_channel = chan.as_recv(pool_channel),
				result_channel = chan.as_send(result_channel),
			},
			worker_func,
		)
	}

	npm_scripts_data := new(Npm_Scripts_Worker)
	npm_scripts_data^ = Npm_Scripts_Worker {
		workspaces   = workspaces,
		script       = opt.script,
		pool_channel = chan.as_send(pool_channel),
	}
	thread.run_with_poly_data(npm_scripts_data, execute_npm_scripts_worker)

	current_glyph := 0
	spinner := []rune{'ᚠ', 'ᚢ', 'ᚦ', 'ᚨ', 'ᚱ', 'ᚲ', 'ᚷ', 'ᚹ'}
	has_error: bool
	finished := 0
	for {
		result, ok := chan.try_recv(result_channel)
		if ok {
			finished += 1
			has_error = has_error if result.result else false
			fmt.printf(
				"\r%s %s (%d/%d)\n",
				"🟢" if result.result else "🔴",
				result.workspace,
				finished,
				len(workspaces) - 1,
			)
			if finished >= len(workspaces) - 1 {
				break
			}
		}

		fmt.printf("\r%c", spinner[current_glyph])
		current_glyph = (current_glyph + 1) % len(spinner)

		time.sleep(time.Millisecond * 100)
	}

	buf: [time.MIN_HMS_LEN]u8
	fmt.printf(
		"Finished%s in %s!\n",
		" (with errors)" if has_error else "",
		time.to_string_hms(time.tick_since(start), buf[:]),
	)
}