From be51fa506e81963dd8b94957f8370c58ef6216e0 Mon Sep 17 00:00:00 2001 From: Corey Williamson Date: Thu, 9 Feb 2023 01:24:40 -0600 Subject: [PATCH 1/3] util.str: add gfind --- build/cyan/util.lua | 13 +++++++++++++ src/cyan/util.tl | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/build/cyan/util.lua b/build/cyan/util.lua index 23404a0..e03345d 100644 --- a/build/cyan/util.lua +++ b/build/cyan/util.lua @@ -173,6 +173,19 @@ function str.split(s, del, no_patt) end end +function str.gfind(s, patt, no_magic) + local prev_idx = 0 + return function() + prev_idx = prev_idx + 1 + local a, b = s:find(patt, prev_idx, no_magic) + if not a then + return nil, nil + end + prev_idx = b + 1 + return a, b + end +end + local function esc_char(c) return "%" .. c end diff --git a/src/cyan/util.tl b/src/cyan/util.tl index 5b15061..53ddaef 100644 --- a/src/cyan/util.tl +++ b/src/cyan/util.tl @@ -173,6 +173,19 @@ function str.split(s: string, del: string, no_patt: boolean): function(): string end end +function str.gfind(s: string, patt: string, no_magic: boolean): function(): integer, integer + local prev_idx = 0 + return function(): integer, integer + prev_idx = prev_idx + 1 + local a, b = s:find(patt, prev_idx, no_magic) + if not a then + return nil, nil + end + prev_idx = b + 1 + return a, b + end +end + local function esc_char(c: string): string return "%" .. c end From d1e9d1725ba694b569068caeb2e527d3a9f6ae66 Mon Sep 17 00:00:00 2001 From: Corey Williamson Date: Thu, 9 Feb 2023 01:24:52 -0600 Subject: [PATCH 2/3] log: allow completely disabling all logging --- build/cyan/log.lua | 10 +++++++--- src/cyan/log.tl | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/build/cyan/log.lua b/build/cyan/log.lua index 33696c1..f3ffa41 100644 --- a/build/cyan/log.lua +++ b/build/cyan/log.lua @@ -65,7 +65,7 @@ local verbosity_to_int = { debug = 3, } -local verbosity = "normal" +local current_verbosity = verbosity_to_int.normal local prefix_padding = 10 local inspect @@ -141,7 +141,7 @@ local Logger = {} function Logger:should_log() local threshold = self.verbosity_threshold and verbosity_to_int[self.verbosity_threshold] or -math.huge - return verbosity_to_int[verbosity] >= threshold + return current_verbosity >= threshold end local function do_log( @@ -290,7 +290,11 @@ local log = { function log.set_verbosity(level) - verbosity = level + current_verbosity = verbosity_to_int[level] +end + +function log.disable() + current_verbosity = -1 end diff --git a/src/cyan/log.tl b/src/cyan/log.tl index b371556..74459a7 100644 --- a/src/cyan/log.tl +++ b/src/cyan/log.tl @@ -65,7 +65,7 @@ local verbosity_to_int : {Verbosity:integer} = { debug = 3, } -local verbosity: Verbosity = "normal" +local current_verbosity: integer = verbosity_to_int.normal local prefix_padding: integer = 10 local inspect: function(any): string @@ -141,7 +141,7 @@ end --- loggers verbosity threshold. function Logger:should_log(): boolean local threshold = self.verbosity_threshold and verbosity_to_int[self.verbosity_threshold] or -math.huge - return verbosity_to_int[verbosity] >= threshold + return current_verbosity >= threshold end local function do_log( @@ -290,7 +290,11 @@ local log = { ---@desc --- Globally set the verbosity of the logging module. function log.set_verbosity(level: Verbosity) - verbosity = level + current_verbosity = verbosity_to_int[level] +end + +function log.disable() + current_verbosity = -1 end ---@desc From 14f4fc4ce068fa5bf7765c00840df0edfa0978f5 Mon Sep 17 00:00:00 2001 From: Corey Williamson Date: Thu, 9 Feb 2023 01:25:32 -0600 Subject: [PATCH 3/3] Structured output proof of concept This introduces an 'event' api where events can be 'emitted' by commands, which goes through the following pipeline: - a set of callbacks are registered with each command (similar to script hooks) by name - when an event is emitted, one of these names must be provided - the data is fed to the registered callback, which returns a `Report` which describes how to log and serialize the data - this report is taken by the event api and depending on whether the structured output flag is set will either - serialize the data to json (with a very basic hand rolled encoder) and write it to stdout - log it with the given `Logger` The basic structure is as such: command.new { name = "my-cool-command", description = ":D", exec = function(): integer event.emit("cool_event", { adjective = "cool" }) event.emit("cool_event", { adjective = "neat" }) return 0 end, events = { cool_event = function(params: {string:any}): event.Report return { log_format = "This is from a %(adjective) event happening", parameters = { adjective = params.adjective }, }, end, }, } Which with unstructured output will produce $ cyan my-cool-command Info This is from a cool event happening Info This is from a neat event happening But with structured output will produce $ cyan --structured-output my-cool-command {"event":"cool_event","data":{"adjective":"cool"}} {"event":"cool_event","data":{"adjective":"neat"}} This commit adds a little bit of this as a proof of concept to the current build command, but not for everything that `build` can log. But running $ cyan build --update-all --structured-output --no-script will produce nicely structured output, and some data massaging with jq is easy enough. The following will produce an array of files that were written by build $ cyan build --update-all --structured-output --no-script | jq -n '[inputs | select(.event == "wrote_file") | .data.file]' --- build/cyan/cli.lua | 4 + build/cyan/command.lua | 3 + build/cyan/commands/build.lua | 48 +++++++-- build/cyan/event.lua | 181 ++++++++++++++++++++++++++++++++++ build/cyan/eventtypes.lua | 19 ++++ cyan-dev-1.rockspec | 4 + src/cyan/cli.tl | 4 + src/cyan/command.tl | 3 + src/cyan/commands/build.tl | 48 +++++++-- src/cyan/event.tl | 181 ++++++++++++++++++++++++++++++++++ src/cyan/eventtypes.tl | 19 ++++ 11 files changed, 500 insertions(+), 14 deletions(-) create mode 100644 build/cyan/event.lua create mode 100644 build/cyan/eventtypes.lua create mode 100644 src/cyan/event.tl create mode 100644 src/cyan/eventtypes.tl diff --git a/build/cyan/cli.lua b/build/cyan/cli.lua index 4a4f093..b0eb325 100644 --- a/build/cyan/cli.lua +++ b/build/cyan/cli.lua @@ -5,6 +5,7 @@ local _tl_compat; if (tonumber((_VERSION or ''):match('[%d.]*$')) or 0) < 5.3 th local argparse = require("argparse") local tl = require("tl") +local event = require("cyan.event") local command = require("cyan.command") local common = require("cyan.tlcommon") local config = require("cyan.config") @@ -19,6 +20,9 @@ util.tab.keys, util.tab.from, util.tab.sort_in_place, util.tab.ivalues local parser = argparse("cyan", "The Teal build system") parser:add_help(false) +parser:flag("--structured-output", "Make all command output structured json rather than human readable"): +action(function() event.set_structured(true) end) + parser:option("-l --preload", "Execute the equivalent of require('modulename') before processing Teal files."): argname(""): count("*") diff --git a/build/cyan/command.lua b/build/cyan/command.lua index c615f04..e4bfecd 100644 --- a/build/cyan/command.lua +++ b/build/cyan/command.lua @@ -10,6 +10,7 @@ local fs = require("cyan.fs") local log = require("cyan.log") local util = require("cyan.util") + local merge_list, sort, from, keys, contains = util.tab.merge_list, util.tab.sort_in_place, util.tab.from, util.tab.keys, util.tab.contains @@ -38,6 +39,7 @@ local Args = {} + local CommandFn = {} @@ -51,6 +53,7 @@ local Command = {} + local command = { running = nil, Command = Command, diff --git a/build/cyan/commands/build.lua b/build/cyan/commands/build.lua index 8ca8272..89adde8 100644 --- a/build/cyan/commands/build.lua +++ b/build/cyan/commands/build.lua @@ -7,6 +7,7 @@ local command = require("cyan.command") local common = require("cyan.tlcommon") local config = require("cyan.config") local cs = require("cyan.colorstring") +local event = require("cyan.event") local fs = require("cyan.fs") local graph = require("cyan.graph") local log = require("cyan.log") @@ -173,7 +174,7 @@ local function build(args, loaded_config, starting_dir) return end - log.info("Type checked ", disp_path) + event.emit("type_checked_file", { file = path }, log.info) local is_lua = select(2, fs.extension_split(path)) == ".lua" if compile and not (is_lua and dont_write_lua_files) then local ok, err = n.output:mk_parent_dirs() @@ -211,16 +212,16 @@ local function build(args, loaded_config, starting_dir) local n, ast = node_ast[1], node_ast[2] local fh, err = io.open(n.output:to_real_path(), "w") if not fh then - log.err("Error opening file ", display_filename(n.output), ": ", err) + event.emit("open_file_error", { file = n.output:to_real_path(), message = err }, log.err) exit = 1 else local generated, gen_err = common.compile_ast(ast, loaded_config.gen_target) if generated then fh:write(generated, "\n") fh:close() - log.info("Wrote ", display_filename(n.output)) + event.emit("wrote_file", { file = n.output:to_real_path() }, log.info) else - log.err("Error when generating lua for ", display_filename(n.output), "\n", gen_err) + event.emit("generate_lua_error", { file = n.output:to_real_path(), message = gen_err }, log.err) exit = 1 end end @@ -265,13 +266,12 @@ local function build(args, loaded_config, starting_dir) local cwd = fs.cwd() local function prune(p, kind) local file = build_dir .. p - local disp = display_filename(file) local real = file:relative_to(cwd) local ok, err = os.remove(real:to_real_path()) if ok then - log.info("Pruned ", kind, " ", disp) + event.emit("pruned_file", { kind = kind, file = real:to_real_path() }, log.info) else - log.err("Unable to prune ", kind, " '", disp, "': ", err) + event.emit("pruned_file_error", { kind = kind, file = real:to_real_path(), message = err }, log.err) end end for _, p in ipairs(unexpected_files) do @@ -313,4 +313,38 @@ command.new({ cmd:flag("-p --prune", "Remove any unexpected files in the build directory.") end, script_hooks = { "pre", "post", "file_updated" }, + events = { + type_checked_file = function(params) + return { log_format = "Type checked %(file filename)", parameters = params } + end, + wrote_file = function(params) + return { log_format = "Wrote %(file filename)", parameters = params } + end, + generate_lua_error = function(params) + return { + tag = "error", + log_format = "Error when generating lua for %(file filename)\n%(message)", + parameters = params, + } + end, + open_file_error = function(params) + return { + tag = "error", + log_format = "Error opening file %(file filename): %(message)", + parameters = params, + } + end, + pruned_file = function(params) + return { + log_format = "Pruned %(kind) %(file filename)", + parameters = params, + } + end, + pruned_file_error = function(params) + return { + log_format = "Unable to prune %(kind) %(file filename): %(message)", + parameters = params, + } + end, + }, }) diff --git a/build/cyan/event.lua b/build/cyan/event.lua new file mode 100644 index 0000000..0f9aa62 --- /dev/null +++ b/build/cyan/event.lua @@ -0,0 +1,181 @@ +local _tl_compat; if (tonumber((_VERSION or ''):match('[%d.]*$')) or 0) < 5.3 then local p, m = true, require('compat53.module'); if p then _tl_compat = m end end; local assert = _tl_compat and _tl_compat.assert or assert; local io = _tl_compat and _tl_compat.io or io; local ipairs = _tl_compat and _tl_compat.ipairs or ipairs; local math = _tl_compat and _tl_compat.math or math; local string = _tl_compat and _tl_compat.string or string; local table = _tl_compat and _tl_compat.table or table; local _tl_table_unpack = unpack or table.unpack; local command = require("cyan.command") +local log = require("cyan.log") +local cs = require("cyan.colorstring") +local util = require("cyan.util") +local gfind = util.str.gfind +local keys = util.tab.keys +local ts = require("cyan.eventtypes") + +local display_kinds = { + filename = true, +} + +local Handler = ts.Handler +local Report = ts.Report +local FormatSpecifier = ts.FormatSpecifier +local DisplayKind = ts.DisplayKind + +local event = { + Handler = Handler, + Report = Report, + FormatSpecifier = FormatSpecifier, + DisplayKind = DisplayKind, +} + +function event.expand_log_format(log_format) + local result = {} + local function add(a, b) + local str = log_format:sub(a, b) + if #str > 0 then + table.insert(result, str) + end + end + + local last_index = 1 + local iter = gfind(log_format, "%%%b()") + while last_index <= #log_format do + local s, e = iter() + add(last_index, (s or 0) - 1) + if not s then + break + end + + local specifier = log_format:sub(s + 2, e - 1) + local key, rest = specifier:match("^%s*([^%s]+)%s*([^%s]*)$") + local display_kind = display_kinds[rest] and rest or nil + table.insert(result, { + key = key, + display_kind = display_kind, + }) + last_index = e + 1 + end + + return result +end + +local structured = false +function event.set_structured(to) + structured = to + if to then + log.disable() + end +end + +function event.is_structured() + return structured +end + +function event.emit(name, params, logger) + assert(command.running, "Attempt to emit event with no running command") + local err_msg = "Command '" .. command.running.name .. "' emitted an unregistered event: '" .. name .. "'" + assert(command.running.events, err_msg) + local f = assert(command.running.events[name], err_msg) + local report = f(params) + + if structured then + local seen_tables = {} + local function is_int(x) + return type(x) == "number" and x == math.floor(x) + end + local function put(value) + local t = type(value) + + assert(t ~= "userdata", "Attempt to serialize userdata") + assert(t ~= "function", "Attempt to serialize function") + + if t == "table" then + if seen_tables[value] then + error("Attempt to serialize nested table", 2) + end + seen_tables[value] = true + + local used_keys = {} + + + + + local ordered_keys = {} + local first = true + local only_integer_keys = true + local highest_integer_key = 0 + for k in keys(value) do + if is_int(k) then + highest_integer_key = math.floor(math.max(k, highest_integer_key)) + else + only_integer_keys = false + end + if not (type(k) == "string" or is_int(k)) then + error("Bad table key for serialization (" .. type(k) .. ")", 2) + end + local str_key = ("%q"):format(tostring(k)) + if used_keys[str_key] then + error("Duplicate object key " .. str_key, 2) + end + table.insert(ordered_keys, { str_key = str_key, actual_key = k }) + end + table.sort(ordered_keys, function(a, b) + return a.str_key < b.str_key + end) + + if only_integer_keys then + io.stdout:write("[") + for i = 1, highest_integer_key do + if i > 1 then + io.stdout:write(",") + end + put((value)[i]) + end + io.stdout:write("]") + else + io.stdout:write("{") + for _, pair in ipairs(ordered_keys) do + if first then + first = false + else + io.stdout:write(",") + end + io.stdout:write(pair.str_key, ":") + put((value)[pair.actual_key]) + end + io.stdout:write("}") + end + seen_tables[false] = true + elseif type(value) == "string" then + io.stdout:write(("%q"):format(value)) + elseif value == nil then + io.stdout:write("null") + else + io.stdout:write(tostring(value)) + end + end + + io.stdout:write(("{\"event\":%q,"):format(name)) + if report.tag then + io.stdout:write(("\"tag\":%q,"):format(report.tag)) + end + + io.stdout:write("\"data\":") + put(params) + io.stdout:write("}\n") + else + logger = logger or log.info + + local buf = {} + local chunks = event.expand_log_format(report.log_format) + for i, v in ipairs(chunks) do + if type(v) == "string" then + buf[i] = v + else + if v.display_kind == "filename" then + buf[i] = cs.highlight(cs.colors.file, logger.inspector(report.parameters[v.key])) + else + buf[i] = report.parameters[v.key] + end + end + end + + logger(_tl_table_unpack(buf)) + end +end + +return event diff --git a/build/cyan/eventtypes.lua b/build/cyan/eventtypes.lua new file mode 100644 index 0000000..0930a3d --- /dev/null +++ b/build/cyan/eventtypes.lua @@ -0,0 +1,19 @@ +local t = {Report = {}, FormatSpecifier = {}, } + + + + + + + + + + + + + + + + + +return t diff --git a/cyan-dev-1.rockspec b/cyan-dev-1.rockspec index 5e6c2e2..2027441 100644 --- a/cyan-dev-1.rockspec +++ b/cyan-dev-1.rockspec @@ -29,6 +29,8 @@ build = { ["cyan.commands.run"] = "build/cyan/commands/run.lua", ["cyan.commands.warnings"] = "build/cyan/commands/warnings.lua", ["cyan.config"] = "build/cyan/config.lua", + ["cyan.event"] = "build/cyan/event.lua", + ["cyan.eventtypes"] = "build/cyan/eventtypes.lua", ["cyan.fs.init"] = "build/cyan/fs/init.lua", ["cyan.fs.path"] = "build/cyan/fs/path.lua", ["cyan.graph"] = "build/cyan/graph.lua", @@ -51,6 +53,8 @@ build = { ["cyan.commands.run"] = "src/cyan/commands/run.tl", ["cyan.commands.warnings"] = "src/cyan/commands/warnings.tl", ["cyan.config"] = "src/cyan/config.tl", + ["cyan.event"] = "src/cyan/event.tl", + ["cyan.eventtypes"] = "src/cyan/eventtypes.tl", ["cyan.fs.init"] = "src/cyan/fs/init.tl", ["cyan.fs.path"] = "src/cyan/fs/path.tl", ["cyan.graph"] = "src/cyan/graph.tl", diff --git a/src/cyan/cli.tl b/src/cyan/cli.tl index 50576a0..28c0130 100644 --- a/src/cyan/cli.tl +++ b/src/cyan/cli.tl @@ -5,6 +5,7 @@ local argparse = require("argparse") local tl = require("tl") +local event = require("cyan.event") local command = require("cyan.command") local common = require("cyan.tlcommon") local config = require("cyan.config") @@ -19,6 +20,9 @@ local keys , from , sort , ivalues local parser = argparse("cyan", "The Teal build system") parser:add_help(false) +parser:flag("--structured-output", "Make all command output structured json rather than human readable") + :action(function() event.set_structured(true) end) + parser:option("-l --preload", "Execute the equivalent of require('modulename') before processing Teal files.") :argname("") :count("*") diff --git a/src/cyan/command.tl b/src/cyan/command.tl index a19f93f..2d162c5 100644 --- a/src/cyan/command.tl +++ b/src/cyan/command.tl @@ -9,6 +9,7 @@ local config = require("cyan.config") local fs = require("cyan.fs") local log = require("cyan.log") local util = require("cyan.util") +local type eventtypes = require("cyan.eventtypes") local merge_list , sort , from , keys , contains = util.tab.merge_list, util.tab.sort_in_place, util.tab.from, util.tab.keys, util.tab.contains @@ -21,6 +22,7 @@ local record Args gen_compat: tl.CompatMode gen_target: tl.TargetMode quiet: boolean + structured_output: boolean global_env_def: string verbosity: log.Verbosity @@ -50,6 +52,7 @@ local record Command argparse: function(argparse.Command) script_hooks: {string} exec: CommandFn + events: {string:eventtypes.Handler} end local command = { running: Command = nil, diff --git a/src/cyan/commands/build.tl b/src/cyan/commands/build.tl index bb953d0..f1644ed 100644 --- a/src/cyan/commands/build.tl +++ b/src/cyan/commands/build.tl @@ -7,6 +7,7 @@ local command = require("cyan.command") local common = require("cyan.tlcommon") local config = require("cyan.config") local cs = require("cyan.colorstring") +local event = require("cyan.event") local fs = require("cyan.fs") local graph = require("cyan.graph") local log = require("cyan.log") @@ -173,7 +174,7 @@ local function build(args: command.Args, loaded_config: config.Config, starting_ return end - log.info("Type checked ", disp_path) + event.emit("type_checked_file", { file = path }, log.info) local is_lua = select(2, fs.extension_split(path)) == ".lua" if compile and not (is_lua and dont_write_lua_files) then local ok , err = n.output:mk_parent_dirs() @@ -211,16 +212,16 @@ local function build(args: command.Args, loaded_config: config.Config, starting_ local n , ast = node_ast[1], node_ast[2] local fh , err = io.open(n.output:to_real_path(), "w") if not fh then - log.err("Error opening file ", display_filename(n.output), ": ", err) + event.emit("open_file_error", { file = n.output:to_real_path(), message = err }, log.err) exit = 1 else local generated , gen_err = common.compile_ast(ast, loaded_config.gen_target) if generated then fh:write(generated, "\n") fh:close() - log.info("Wrote ", display_filename(n.output)) + event.emit("wrote_file", { file = n.output:to_real_path() }, log.info) else - log.err("Error when generating lua for ", display_filename(n.output), "\n", gen_err) + event.emit("generate_lua_error", { file = n.output:to_real_path(), message = gen_err }, log.err) exit = 1 end end @@ -265,13 +266,12 @@ local function build(args: command.Args, loaded_config: config.Config, starting_ local cwd = fs.cwd() local function prune(p: fs.Path, kind: string) local file = build_dir .. p - local disp = display_filename(file) local real = file:relative_to(cwd) local ok , err = os.remove(real:to_real_path()) if ok then - log.info("Pruned ", kind, " ", disp) + event.emit("pruned_file", { kind = kind, file = real:to_real_path() }, log.info) else - log.err("Unable to prune ", kind, " '", disp, "': ", err) + event.emit("pruned_file_error", { kind = kind, file = real:to_real_path(), message = err }, log.err) end end for _, p in ipairs(unexpected_files) do @@ -313,4 +313,38 @@ command.new{ cmd:flag("-p --prune", "Remove any unexpected files in the build directory.") end, script_hooks = { "pre", "post", "file_updated" }, + events = { + type_checked_file = function(params: {string:any}): event.Report + return { log_format = "Type checked %(file filename)", parameters = params } + end, + wrote_file = function(params: {string:any}): event.Report + return { log_format = "Wrote %(file filename)", parameters = params } + end, + generate_lua_error = function(params: {string:any}): event.Report + return { + tag = "error", + log_format = "Error when generating lua for %(file filename)\n%(message)", + parameters = params, + } + end, + open_file_error = function(params: {string:any}): event.Report + return { + tag = "error", + log_format = "Error opening file %(file filename): %(message)", + parameters = params, + } + end, + pruned_file = function(params: {string:any}): event.Report + return { + log_format = "Pruned %(kind) %(file filename)", + parameters = params, + } + end, + pruned_file_error = function(params: {string:any}): event.Report + return { + log_format = "Unable to prune %(kind) %(file filename): %(message)", + parameters = params, + } + end, + }, } diff --git a/src/cyan/event.tl b/src/cyan/event.tl new file mode 100644 index 0000000..922d0dd --- /dev/null +++ b/src/cyan/event.tl @@ -0,0 +1,181 @@ +local command = require("cyan.command") +local log = require("cyan.log") +local cs = require("cyan.colorstring") +local util = require("cyan.util") +local gfind = util.str.gfind +local keys = util.tab.keys +local type ts = require("cyan.eventtypes") + +local display_kinds: {string:boolean} = { + filename = true, +} + +local type Handler = ts.Handler +local type Report = ts.Report +local type FormatSpecifier = ts.FormatSpecifier +local type DisplayKind = ts.DisplayKind + +local event = { + Handler = Handler, + Report = Report, + FormatSpecifier = FormatSpecifier, + DisplayKind = DisplayKind, +} + +function event.expand_log_format(log_format: string): {string | FormatSpecifier} + local result : {string | FormatSpecifier} = {} + local function add(a: integer, b: integer) + local str = log_format:sub(a, b) + if #str > 0 then + table.insert(result, str) + end + end + + local last_index = 1 + local iter = gfind(log_format, "%%%b()") + while last_index <= #log_format do + local s , e = iter() + add(last_index, (s or 0) - 1) + if not s then + break + end + + local specifier = log_format:sub(s + 2, e - 1) + local key, rest = specifier:match("^%s*([^%s]+)%s*([^%s]*)$") + local display_kind = display_kinds[rest] and rest as DisplayKind or nil + table.insert(result, { + key = key, + display_kind = display_kind, + } as FormatSpecifier) + last_index = e + 1 + end + + return result +end + +local structured = false +function event.set_structured(to: boolean) + structured = to + if to then + log.disable() + end +end + +function event.is_structured(): boolean + return structured +end + +function event.emit(name: string, params: {string:any}, logger: log.Logger) + assert(command.running, "Attempt to emit event with no running command") + local err_msg = "Command '" .. command.running.name .. "' emitted an unregistered event: '" .. name .. "'" + assert(command.running.events, err_msg) + local f = assert(command.running.events[name], err_msg) + local report = f(params) + + if structured then + local seen_tables : {any:boolean} = {} + local function is_int(x: any): boolean + return x is number and x == math.floor(x) + end + local function put(value: any) + local t = type(value) + + assert(t ~= "userdata", "Attempt to serialize userdata") + assert(t ~= "function", "Attempt to serialize function") + + if t == "table" then + if seen_tables[value] then + error("Attempt to serialize nested table", 2) + end + seen_tables[value] = true + -- for now, assume all tables should be output as objects + local used_keys: {string:boolean} = {} + local record KeyPair + str_key: string + actual_key: any + end + local ordered_keys: {KeyPair} = {} + local first = true + local only_integer_keys = true + local highest_integer_key = 0 + for k in keys(value as table) do + if is_int(k) then + highest_integer_key = math.floor(math.max(k as number, highest_integer_key)) + else + only_integer_keys = false + end + if not (k is string or is_int(k)) then + error("Bad table key for serialization (" .. type(k) .. ")", 2) + end + local str_key = ("%q"):format(tostring(k)) + if used_keys[str_key] then + error("Duplicate object key " .. str_key, 2) + end + table.insert(ordered_keys, { str_key = str_key, actual_key = k }) + end + table.sort(ordered_keys, function(a: KeyPair, b: KeyPair): boolean + return a.str_key < b.str_key + end) + + if only_integer_keys then + io.stdout:write("[") + for i = 1, highest_integer_key do + if i > 1 then + io.stdout:write(",") + end + put((value as {any})[i]) + end + io.stdout:write("]") + else + io.stdout:write("{") + for _, pair in ipairs(ordered_keys) do + if first then + first = false + else + io.stdout:write(",") + end + io.stdout:write(pair.str_key, ":") + put((value as table)[pair.actual_key]) + end + io.stdout:write("}") + end + seen_tables[false] = true + elseif value is string then + io.stdout:write(("%q"):format(value)) + elseif value is nil then + io.stdout:write("null") + else + io.stdout:write(tostring(value)) + end + end + + io.stdout:write(("{\"event\":%q,"):format(name)) + if report.tag then + io.stdout:write(("\"tag\":%q,"):format(report.tag)) + end + -- io.stdout:write(("\"log\":%q,"):format(report.log_format)) + io.stdout:write("\"data\":") + put(params) + io.stdout:write("}\n") + else + logger = logger or log.info + + local buf : {any} = {} + local chunks = event.expand_log_format(report.log_format) + for i, v in ipairs(chunks) do + if v is string then + buf[i] = v + else + if v.display_kind == "filename" then + buf[i] = cs.highlight(cs.colors.file, logger.inspector(report.parameters[v.key])) + else + buf[i] = report.parameters[v.key] + end + end + end + + logger(table.unpack(buf)) + end +end + +return event diff --git a/src/cyan/eventtypes.tl b/src/cyan/eventtypes.tl new file mode 100644 index 0000000..2d77c6f --- /dev/null +++ b/src/cyan/eventtypes.tl @@ -0,0 +1,19 @@ +local record t + record Report + tag: string + parameters: {string:any} + log_format: string + end + + type Handler = function({string:any}): Report + + record FormatSpecifier + key: string + display_kind: DisplayKind + end + + enum DisplayKind + "filename" + end +end +return t