1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2025-01-26 00:46:54 +00:00

Some redesigning of the settings API (#408)

- The store is now split into two sections:
   - A list of possible options, with some metadata about them.
   - A list of values which have been changed.
 - settings.define can be used to register a new option. We have
   migrated all existing options over to use it. This can be used to
   define a default value, description, and a type the setting must have
   (such as `string` or `boolean).

 - settings.{set,unset,clear,load,store} operate using this value list.
   This means that only values which have been changed are stored to
   disk.
   Furthermore, clearing/unsetting will reset to the /default/ value,
   rather than removing entirely.

 - The set program will now display descriptions.

 - settings.{load,save} now default to `.settings` if no path is given.
This commit is contained in:
Jonathan Coates 2020-04-21 11:37:56 +01:00 committed by GitHub
parent 11bf601db9
commit 1fc0214857
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 378 additions and 74 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)