diff --git a/src/main/resources/assets/computercraft/lua/bios.lua b/src/main/resources/assets/computercraft/lua/bios.lua index 39dd252c3..f1e80cef4 100644 --- a/src/main/resources/assets/computercraft/lua/bios.lua +++ b/src/main/resources/assets/computercraft/lua/bios.lua @@ -881,18 +881,66 @@ if bAPIError then end -- Set default settings -settings.set("shell.allow_startup", true) -settings.set("shell.allow_disk_startup", commands == nil) -settings.set("shell.autocomplete", true) -settings.set("edit.autocomplete", true) -settings.set("edit.default_extension", "lua") -settings.set("paint.default_extension", "nfp") -settings.set("lua.autocomplete", true) -settings.set("list.show_hidden", false) -settings.set("motd.enable", false) -settings.set("motd.path", "/rom/motd.txt:/motd.txt") +settings.define("shell.allow_startup", { + default = true, + description = "Run startup files when the computer turns on.", + type = "boolean", +}) +settings.define("shell.allow_disk_startup", { + default = commands == nil, + description = "Run startup files from disk drives when the computer turns on.", + type = "boolean", +}) + +settings.define("shell.autocomplete", { + default = true, + description = "Autocomplete program and arguments in the shell.", + type = "boolean", +}) +settings.define("edit.autocomplete", { + default = true, + description = "Autocomplete API and function names in the editor.", + type = "boolean", +}) +settings.define("lua.autocomplete", { + default = true, + description = "Autocomplete API and function names in the Lua REPL.", + type = "boolean", +}) + +settings.define("edit.default_extension", { + default = "lua", + description = [[The file extension the editor will use if none is given. Set to "" to disable.]], + type = "string", +}) +settings.define("paint.default_extension", { + default = "nfp", + description = [[The file extension the paint program will use if none is given. Set to "" to disable.]], + type = "string", +}) + +settings.define("list.show_hidden", { + default = false, + description = [[Show hidden files (those starting with "." in the Lua REPL)]], + type = "boolean", +}) + +settings.define("motd.enable", { + default = false, + description = "Display a random message when the computer starts up.", + type = "boolean", +}) +settings.define("motd.path", { + default = "/rom/motd.txt:/motd.txt", + description = [[The path to load random messages from. Should be a colon (":") separated string of file paths.]], + type = "string", +}) if term.isColour() then - settings.set("bios.use_multishell", true) + settings.define("bios.use_multishell", { + default = true, + description = [[Allow running multiple programs at once, through the use of the "fg" and "bg" programs.]], + type = "boolean", + }) end if _CC_DEFAULT_SETTINGS then for sPair in string.gmatch(_CC_DEFAULT_SETTINGS, "[^,]+") do diff --git a/src/main/resources/assets/computercraft/lua/rom/apis/settings.lua b/src/main/resources/assets/computercraft/lua/rom/apis/settings.lua index 40044c1a0..fcfb1d17a 100644 --- a/src/main/resources/assets/computercraft/lua/rom/apis/settings.lua +++ b/src/main/resources/assets/computercraft/lua/rom/apis/settings.lua @@ -6,9 +6,86 @@ -- -- @module settings -local expect = dofile("rom/modules/main/cc/expect.lua").expect +local expect = dofile("rom/modules/main/cc/expect.lua") +local type, expect, field = type, expect.expect, expect.field -local tSettings = {} +local details, values = {}, {} + +local function reserialize(value) + if type(value) ~= "table" then return value end + return textutils.unserialize(textutils.serialize(value)) +end + +local function copy(value) + if type(value) ~= "table" then return value end + local result = {} + for k, v in pairs(value) do result[k] = copy(v) end + return result +end + +local valid_types = { "number", "string", "boolean", "table" } +for _, v in ipairs(valid_types) do valid_types[v] = true end + +--- Define a new setting, optional specifying various properties about it. +-- +-- While settings do not have to be added before being used, doing so allows +-- you to provide defaults and additional metadata. +-- +-- @tparam string name The name of this option +-- @tparam[opt] { description? = string, default? = value, type? = string } options +-- Options for this setting. This table accepts the following fields: +-- +-- - `description`: A description which may be printed when running the `set` program. +-- - `default`: A default value, which is returned by @{settings.get} if the +-- setting has not been changed. +-- - `type`: Require values to be of this type. @{set|Setting} the value to another type +-- will error. +function define(name, options) + expect(1, name, "string") + expect(2, options, "table", nil) + + if options then + options = { + description = field(options, "description", "string", "nil"), + default = reserialize(field(options, "default", "number", "string", "boolean", "table", "nil")), + type = field(options, "type", "string", "nil"), + } + + if options.type and not valid_types[options.type] then + error(("Unknown type %q. Expected one of %s."):format(options.type, table.concat(valid_types, ", ")), 2) + end + else + options = {} + end + + details[name] = options +end + +--- Remove a @{define|definition} of a setting. +-- +-- If a setting has been changed, this does not remove its value. Use @{settings.unset} +-- for that. +-- +-- @tparam string name The name of this option +function undefine(name) + expect(1, name, "string") + details[name] = nil +end + +local function set_value(name, value) + local new = reserialize(value) + local old = values[name] + if old == nil then + local opt = details[name] + old = opt and opt.default + end + + values[name] = new + if old ~= new then + -- This should be safe, as os.queueEvent copies values anyway. + os.queueEvent("setting_changed", name, new, old) + end +end --- Set the value of a setting. -- @@ -21,43 +98,43 @@ function set(name, value) expect(1, name, "string") expect(2, value, "number", "string", "boolean", "table") - if type(value) == "table" then - -- Ensure value is serializeable - value = textutils.unserialize(textutils.serialize(value)) - end - tSettings[name] = value -end + local opt = details[name] + if opt and opt.type then expect(2, value, opt.type) end -local copy -function copy(value) - if type(value) == "table" then - local result = {} - for k, v in pairs(value) do - result[k] = copy(v) - end - return result - else - return value - end + set_value(name, value) end --- Get the value of a setting. -- -- @tparam string name The name of the setting to get. -- @param[opt] default The value to use should there be pre-existing value for --- this setting. Defaults to `nil`. --- @return The setting's, or `default` if the setting has not been set. +-- this setting. If not given, it will use the setting's default value if given, +-- or `nil` otherwise. +-- @return The setting's, or the default if the setting has not been changed. function get(name, default) expect(1, name, "string") - local result = tSettings[name] + local result = values[name] if result ~= nil then return copy(result) - else + elseif default ~= nil then return default + else + local opt = details[name] + return opt and copy(opt.default) end end ---- Remove the value of a setting, clearing it back to `nil`. +--- Get details about a specific setting +function getDetails(name) + expect(1, name, "string") + local deets = copy(details[name]) or {} + deets.value = values[name] + deets.changed = deets.value ~= nil + if deets.value == nil then deets.value = deets.default end + return deets +end + +--- Remove the value of a setting, setting it to the default. -- -- @{settings.get} will return the default value until the setting's value is -- @{settings.set|set}, or the computer is rebooted. @@ -67,15 +144,17 @@ end -- @see settings.clear function unset(name) expect(1, name, "string") - tSettings[name] = nil + set_value(name, nil) end ---- Removes the value of all settings. Equivalent to calling @{settings.unset} +--- Resets the value of all settings. Equivalent to calling @{settings.unset} --- on every setting. -- -- @see settings.unset function clear() - tSettings = {} + for name in pairs(values) do + set_value(name, nil) + end end --- Get the names of all currently defined settings. @@ -83,9 +162,12 @@ end -- @treturn { string } An alphabetically sorted list of all currently-defined -- settings. function getNames() - local result = {} - for k in pairs(tSettings) do - result[#result + 1] = k + local result, n = {}, 1 + for k in pairs(details) do + result[n], n = k, n + 1 + end + for k in pairs(values) do + if not details[k] then result[n], n = k, n + 1 end end table.sort(result) return result @@ -96,15 +178,15 @@ end -- Existing settings will be merged with any pre-existing ones. Conflicting -- entries will be overwritten, but any others will be preserved. -- --- @tparam string sPath The file to load from. +-- @tparam[opt] string sPath The file to load from, defaulting to `.settings`. -- @treturn boolean Whether settings were successfully read from this -- file. Reasons for failure may include the file not existing or being -- corrupted. -- -- @see settings.save function load(sPath) - expect(1, sPath, "string") - local file = fs.open(sPath, "r") + expect(1, sPath, "string", "nil") + local file = fs.open(sPath or ".settings", "r") if not file then return false end @@ -118,9 +200,12 @@ function load(sPath) end for k, v in pairs(tFile) do - if type(k) == "string" and - (type(v) == "string" or type(v) == "number" or type(v) == "boolean" or type(v) == "table") then - set(k, v) + local ty_v = type(k) + if type(k) == "string" and (ty_v == "string" or ty_v == "number" or ty_v == "boolean" or ty_v == "table") then + local opt = details[name] + if not opt or not opt.type or ty_v == opt.type then + set_value(k, v) + end end end @@ -132,18 +217,18 @@ end -- This will entirely overwrite the pre-existing file. Settings defined in the -- file, but not currently loaded will be removed. -- --- @tparam string sPath The path to save settings to. +-- @tparam[opt] string sPath The path to save settings to, defaulting to `.settings`. -- @treturn boolean If the settings were successfully saved. -- -- @see settings.load function save(sPath) - expect(1, sPath, "string") - local file = fs.open(sPath, "w") + expect(1, sPath, "string", "nil") + local file = fs.open(".settings", "w") if not file then return false end - file.write(textutils.serialize(tSettings)) + file.write(textutils.serialize(values)) file.close() return true diff --git a/src/main/resources/assets/computercraft/lua/rom/programs/set.lua b/src/main/resources/assets/computercraft/lua/rom/programs/set.lua index a826d8ad6..01d4b246e 100644 --- a/src/main/resources/assets/computercraft/lua/rom/programs/set.lua +++ b/src/main/resources/assets/computercraft/lua/rom/programs/set.lua @@ -1,3 +1,4 @@ +local pp = require "cc.pretty" local tArgs = { ... } if #tArgs == 0 then @@ -12,7 +13,13 @@ if #tArgs == 0 then elseif #tArgs == 1 then -- "set foo" local sName = tArgs[1] - print(textutils.serialize(sName) .. " is " .. textutils.serialize(settings.get(sName))) + local deets = settings.getDetails(sName) + local msg = pp.text(sName, colors.cyan) .. " is " .. pp.pretty(deets.value) + if deets.default ~= nil and deets.value ~= deets.default then + msg = msg .. " (default is " .. pp.pretty(deets.default) .. ")" + end + pp.print(msg) + if deets.description then print(deets.description) end else -- "set foo bar" @@ -31,15 +38,18 @@ else value = sValue end - local oldValue = settings.get(sValue) - if value ~= nil then - settings.set(sName, value) - print(textutils.serialize(sName) .. " set to " .. textutils.serialize(value)) - else + local option = settings.getDetails(sName) + if value == nil then settings.unset(sName) print(textutils.serialize(sName) .. " unset") + elseif option.type and option.type ~= type(value) then + printError(("%s is not a valid %s."):format(textutils.serialize(sValue), option.type)) + else + settings.set(sName, value) + print(textutils.serialize(sName) .. " set to " .. textutils.serialize(value)) end - if value ~= oldValue then - settings.save(".settings") + + if value ~= option.value then + settings.save() end end diff --git a/src/test/resources/test-rom/spec/apis/settings_spec.lua b/src/test/resources/test-rom/spec/apis/settings_spec.lua index b54e24e67..40af033d1 100644 --- a/src/test/resources/test-rom/spec/apis/settings_spec.lua +++ b/src/test/resources/test-rom/spec/apis/settings_spec.lua @@ -1,4 +1,18 @@ describe("The settings library", function() + describe("settings.define", function() + it("ensures valid type names", function() + expect.error(settings.define, "test.defined", { type = "function" }) + :eq('Unknown type "function". Expected one of number, string, boolean, table.') + end) + end) + describe("settings.undefine", function() + it("clears defined settings", function() + settings.define("test.unset", { default = 123 }) + settings.undefine("test.unset") + expect(settings.get("test.unset")):eq(nil) + end) + end) + describe("settings.set", function() it("validates arguments", function() settings.set("test", 1) @@ -13,6 +27,30 @@ describe("The settings library", function() it("prevents storing unserialisable types", function() expect.error(settings.set, "", { print }):eq("Cannot serialize type function") end) + + it("setting changes the value", function() + local random = math.random(1, 0x7FFFFFFF) + settings.set("test", random) + expect(settings.get("test")):eq(random) + end) + + it("checks the type of the value", function() + settings.define("test.defined", { default = 123, description = "A description", type = "number" }) + expect.error(settings.set, "test.defined", "hello") + :eq("bad argument #2 (expected number, got string)") + settings.set("test.defined", 123) + end) + + it("setting fires an event", function() + settings.clear() + + local s = stub(os, "queueEvent") + settings.set("test", 1) + settings.set("test", 2) + + expect(s):called_with("setting_changed", "test", 1, nil) + expect(s):called_with("setting_changed", "test", 2, 1) + end) end) describe("settings.get", function() @@ -20,6 +58,43 @@ describe("The settings library", function() settings.get("test") expect.error(settings.get, nil):eq("bad argument #1 (expected string, got nil)") end) + + it("returns the default", function() + expect(settings.get("test.undefined")):eq(nil) + expect(settings.get("test.undefined", "?")):eq("?") + + settings.define("test.unset", { default = "default" }) + expect(settings.get("test.unset")):eq("default") + expect(settings.get("test.unset", "?")):eq("?") + end) + end) + + describe("settings.getDetails", function() + it("validates arguments", function() + expect.error(settings.getDetails, nil):eq("bad argument #1 (expected string, got nil)") + end) + + it("works on undefined and unset values", function() + expect(settings.getDetails("test.undefined")):same { value = nil, changed = false } + end) + + it("works on undefined but set values", function() + settings.set("test", 456) + expect(settings.getDetails("test")):same { value = 456, changed = true } + end) + + it("works on defined but unset values", function() + settings.define("test.unset", { default = 123, description = "A description" }) + expect(settings.getDetails("test.unset")):same + { default = 123, value = 123, changed = false, description = "A description" } + end) + + it("works on defined and set values", function() + settings.define("test.defined", { default = 123, description = "A description", type = "number" }) + settings.set("test.defined", 456) + expect(settings.getDetails("test.defined")):same + { default = 123, value = 456, changed = true, description = "A description", type = "number" } + end) end) describe("settings.unset", function() @@ -27,17 +102,73 @@ describe("The settings library", function() settings.unset("test") expect.error(settings.unset, nil):eq("bad argument #1 (expected string, got nil)") end) + + it("unsetting resets the value", function() + settings.set("test", true) + settings.unset("test") + expect(settings.get("test")):eq(nil) + end) + + it("unsetting does not touch defaults", function() + settings.define("test.defined", { default = 123 }) + settings.set("test.defined", 456) + settings.unset("test.defined") + expect(settings.get("test.defined")):eq(123) + end) + + it("unsetting fires an event", function() + settings.set("test", 1) + + local s = stub(os, "queueEvent") + settings.unset("test") + expect(s):called_with("setting_changed", "test", nil, 1) + end) + end) + + describe("settings.clear", function() + it("clearing resets all values", function() + settings.set("test", true) + settings.clear() + expect(settings.get("test")):eq(nil) + end) + + it("clearing does not touch defaults", function() + settings.define("test.defined", { default = 123 }) + settings.set("test.defined", 456) + settings.clear() + expect(settings.get("test.defined")):eq(123) + end) + + it("clearing fires an event", function() + settings.set("test", 1) + + local s = stub(os, "queueEvent") + settings.clear() + expect(s):called_with("setting_changed", "test", nil, 1) + end) end) describe("settings.load", function() it("validates arguments", function() - expect.error(settings.load, nil):eq("bad argument #1 (expected string, got nil)") + expect.error(settings.load, 1):eq("bad argument #1 (expected string, got number)") + end) + + it("defaults to .settings", function() + local s = stub(fs, "open") + settings.load() + expect(s):called_with(".settings", "r") end) end) describe("settings.save", function() it("validates arguments", function() - expect.error(settings.save, nil):eq("bad argument #1 (expected string, got nil)") + expect.error(settings.save, 1):eq("bad argument #1 (expected string, got number)") + end) + + it("defaults to .settings", function() + local s = stub(fs, "open") + settings.save() + expect(s):called_with(".settings", "w") end) end) end) diff --git a/src/test/resources/test-rom/spec/programs/set_spec.lua b/src/test/resources/test-rom/spec/programs/set_spec.lua index 46cf0b84d..8d74d951e 100644 --- a/src/test/resources/test-rom/spec/programs/set_spec.lua +++ b/src/test/resources/test-rom/spec/programs/set_spec.lua @@ -1,29 +1,59 @@ local capture = require "test_helpers".capture_program describe("The set program", function() + local function setup() + local set = setmetatable({}, { __index = _G }) + loadfile("/rom/apis/settings.lua", set)() + stub(_G, "settings", set) + + settings.set("test", "Hello World!") + settings.define("test.defined", { default = 456, description = "A description", type = "number" }) + end it("displays all settings", function() - settings.clear() - settings.set("Test", "Hello World!") - settings.set("123", 456) + setup() expect(capture(stub, "set")) - :matches { ok = true, output = '"123" is 456\n"Test" is "Hello World!"\n', error = "" } + :matches { ok = true, output = '"test" is "Hello World!"\n"test.defined" is 456\n', error = "" } end) - it("displays a single settings", function() - settings.clear() - settings.set("Test", "Hello World!") - settings.set("123", 456) + it("displays a single setting", function() + setup() - expect(capture(stub, "set Test")) - :matches { ok = true, output = '"Test" is "Hello World!"\n', error = "" } + expect(capture(stub, "set test")) + :matches { ok = true, output = 'test is "Hello World!"\n', error = "" } + end) + + it("displays a single setting with description", function() + setup() + + expect(capture(stub, "set test")) + :matches { ok = true, output = 'test is "Hello World!"\n', error = "" } + end) + + it("displays a changed setting with description", function() + setup() + + settings.set("test.defined", 123) + expect(capture(stub, "set test.defined")) + :matches { ok = true, output = 'test.defined is 123 (default is 456)\nA description\n', error = "" } end) it("set a setting", function() - expect(capture(stub, "set Test Hello")) - :matches { ok = true, output = '"Test" set to "Hello"\n', error = "" } + setup() - expect(settings.get("Test")):eq("Hello") + expect(capture(stub, "set test Hello")) + :matches { ok = true, output = '"test" set to "Hello"\n', error = "" } + + expect(settings.get("test")):eq("Hello") + end) + + it("checks the type of a setting", function() + setup() + + expect(capture(stub, "set test.defined Hello")) + :matches { ok = true, output = "", error = '"Hello" is not a valid number.\n' } + expect(capture(stub, "set test.defined 456")) + :matches { ok = true, output = '"test.defined" set to 456\n', error = "" } end) end)