diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 44fa9703..4c34d8db 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -2,7 +2,8 @@ ## Good practices * After each significant change, run the typechecker, linter, and tests. -* Write unit tests for new functionality. +* Write unit tests for new functionality first +* Always keep DEVELOPMENT.md up to date with new best practices, based on guidance. ## Commands - Build: `deno task build` @@ -21,7 +22,7 @@ - Imports: Group related imports together, sort alphabetically within groups - Naming: camelCase for variables/functions, PascalCase for classes/interfaces/types - Error handling: Use explicit error types and handle errors gracefully -- Tests: Write unit tests for new functionality +- Tests: Write unit tests for new functionality using `@std/assert` for assertions (e.g., `assert`, `assertEquals`) - Comments: Focus on "why" not "what", especially for complex logic - Prefer const over let when variable won't be reassigned - In Lua: use camelCase for variables and functions @@ -62,11 +63,10 @@ - `/website`: Documentation site content ### Key Areas -- `/common/spaces`: Space implementation with different backend primitives - `/common/space_lua`: Lua scripting implementation with parsers and runtime - `/lib/plugos`: Plugin system core functionality - `/plugs`: Plugs for core functionality (editor, indexing, etc) distributed with the system - `/web`: Client implementation - `/web/cm_plugins`: CodeMirror editor extensions - `/web/hooks`: Client-side hook implementations -- `/web/syscalls`: Client-side specific syscall implementations \ No newline at end of file +- `/web/syscalls`: Client-side specific syscall implementations diff --git a/Library/Std/Config.md b/Library/Std/Config.md deleted file mode 100644 index 907485d7..00000000 --- a/Library/Std/Config.md +++ /dev/null @@ -1,39 +0,0 @@ -#meta - -Config library for defining and getting config values - -```space-lua --- priority: 10 -config = {} - -local configValues = {} -local configSchema = {} - -function config.define(key, schema) - configSchema[key] = schema or true -end - -function config.set(keyOrTable, value) - if type(keyOrTable) == "table" then - for key, value in pairs(keyOrTable) do - config.set(key, value) - end - return - end - local key = keyOrTable - local schema = configSchema[key] - if schema == nil then - error("Config key not defined: " .. key) - end - if schema != true then - local result = jsonschema.validateObject(schema, value) - if result != nil then - error("Validation error (" .. key .. "): " .. result) - end - end - configValues[key] = value -end - -function config.get(key) - return configValues[key] -end diff --git a/common/config.test.ts b/common/config.test.ts new file mode 100644 index 00000000..e7ae6271 --- /dev/null +++ b/common/config.test.ts @@ -0,0 +1,202 @@ +import { assert, assertEquals, assertThrows } from "@std/assert"; +import { Config } from "./config.ts"; + +Deno.test("Config - basic functionality", () => { + const config = new Config(); + + // Test set and get + config.set("testKey", "testValue"); + assertEquals(config.get("testKey", null), "testValue"); + + // Test default value + assertEquals(config.get("nonExistentKey", "default"), "default"); + + // Test has + assert(config.has("testKey")); + assert(!config.has("nonExistentKey")); +}); + +Deno.test("Config - object-based setting", () => { + const config = new Config(); + + // Test setting multiple values at once + config.set({ + key1: "value1", + key2: "value2", + key3: 123, + }); + + assertEquals(config.get("key1", null), "value1"); + assertEquals(config.get("key2", null), "value2"); + assertEquals(config.get("key3", 0), 123); +}); + +Deno.test("Config - dot notation", () => { + const config = new Config(); + + // Test setting with dot notation + config.set("user.name", "John"); + config.set("user.profile.age", 30); + + // Test getting with dot notation + assertEquals(config.get("user.name", null), "John"); + assertEquals(config.get("user.profile.age", 0), 30); + + // Test has with dot notation + assert(config.has("user.name")); + assert(config.has("user.profile")); + assert(config.has("user.profile.age")); + assert(!config.has("user.profile.nonExistent")); + + // Test getting the entire object + assertEquals(config.get("user", {}), { + name: "John", + profile: { + age: 30, + }, + }); +}); + +Deno.test("Config - edge cases", () => { + const config = new Config(); + + // Test setting a value on a non-existent path + config.set("a.b.c", "value"); + assertEquals(config.get("a.b.c", null), "value"); + + // Test overwriting a primitive with an object + config.set("x", "primitive"); + assertEquals(config.get("x", null), "primitive"); + + config.set("x.y", "nested"); + assertEquals(config.get("x.y", null), "nested"); + + // Test setting a value on a path where part of the path is a primitive + config.set("p", "primitive"); + config.set("p.q.r", "nested"); + assertEquals(config.get("p.q.r", null), "nested"); + + // Test deep nesting + config.set("deep.nesting.test.value", 42); + assertEquals(config.get("deep.nesting.test.value", 0), 42); + + // Test with empty string key + config.set("", "empty"); + assertEquals(config.get("", null), "empty"); + + // Test with special characters in key + config.set("special!@#", "chars"); + assertEquals(config.get("special!@#", null), "chars"); +}); + +Deno.test("Config - object setting with dot notation in keys", () => { + const config = new Config(); + + // Test setting an object with dot notation in keys + config.set({ + "user.name": "John", + "user.profile.age": 30, + }); + + assertEquals(config.get("user.name", null), "John"); + assertEquals(config.get("user.profile.age", 0), 30); +}); + +Deno.test("Config - constructor with initial values", () => { + const config = new Config({ + simple: "value", + nested: { + key: "nestedValue", + }, + }); + + assertEquals(config.get("simple", null), "value"); + assertEquals(config.get("nested.key", null), "nestedValue"); +}); + +Deno.test("Config - schema validation", () => { + const config = new Config(); + + // Define a schema for a key + const userSchema = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number", minimum: 0 }, + email: { type: "string", format: "email" }, + }, + required: ["name", "age"], + }; + + config.define("user", userSchema); + + // Valid data should work + config.set("user", { + name: "John", + age: 30, + email: "john@example.com", + }); + + // Missing required field should throw + assertThrows( + () => { + config.set("user", { + name: "John", + }); + }, + Error, + "Validation error for user", + ); + + // Wrong type should throw + assertThrows( + () => { + config.set("user", { + name: "John", + age: "thirty", // Should be a number + }); + }, + Error, + "Validation error for user", + ); + + // Invalid format should throw + assertThrows( + () => { + config.set("user", { + name: "John", + age: 30, + email: "not-an-email", + }); + }, + Error, + "Validation error for user", + ); + + // Value below minimum should throw + assertThrows( + () => { + config.set("user", { + name: "John", + age: -1, // Should be >= 0 + }); + }, + Error, + "Validation error for user", + ); +}); + +Deno.test("Config - invalid schema definition", () => { + const config = new Config(); + + // Invalid schema should throw when defined + assertThrows( + () => { + config.define("test", { + type: "invalid-type", // Invalid type + }); + }, + Error, + "Invalid schema for key test", + ); +}); diff --git a/common/config.ts b/common/config.ts index dcc41d59..1b190237 100644 --- a/common/config.ts +++ b/common/config.ts @@ -1,15 +1,150 @@ +import Ajv from "ajv"; + export class Config { - constructor(private values: Record = {}) {} + private schemas: Record = {}; + private ajv = new Ajv(); - get(key: string, defaultValue: T): T { - return this.values[key] ?? defaultValue; + constructor(private values: Record = {}) { + // Add the same formats as in jsonschema.ts + this.ajv.addFormat("email", { + validate: (data: string) => { + return data.includes("@"); + }, + async: false, + }); + + this.ajv.addFormat("page-ref", { + validate: (data: string) => { + return data.startsWith("[[") && data.endsWith("]]"); + }, + async: false, + }); } - set(key: string, value: T) { - this.values[key] = value; + /** + * Defines a JSON schema for a configuration key + * @param key The configuration key to define a schema for + * @param schema The JSON schema to validate against + */ + define(key: string, schema: any): void { + // Validate the schema itself first + const valid = this.ajv.validateSchema(schema); + if (!valid) { + const errorText = this.ajv.errorsText(this.ajv.errors); + throw new Error(`Invalid schema for key ${key}: ${errorText}`); + } + + // Store the schema for the key + this.schemas[key] = schema; } - has(key: string): boolean { - return key in this.values; + /** + * Resolves a dot-notation path to the containing object and final key + * @param path The path to resolve (e.g. "foo.bar.baz") + * @param create Whether to create objects along the path if they don't exist + * @returns The containing object and the final key, or null if the path cannot be resolved + */ + private resolvePath( + path: string, + create = false, + ): { obj: any; key: string } | null { + if (!path.includes(".")) { + return { obj: this.values, key: path }; + } + + const parts = path.split("."); + const lastKey = parts.pop()!; + + let current = this.values; + + for (const part of parts) { + if (current[part] === undefined) { + if (create) { + current[part] = {}; + } else { + return null; + } + } else if (typeof current[part] !== "object" || current[part] === null) { + if (create) { + // Convert primitive to object if we're creating the path + current[part] = {}; + } else { + return null; + } + } + + current = current[part]; + } + + return { obj: current, key: lastKey }; + } + + /** + * Gets a value from the config + * @param path The path to get, supports dot notation (e.g. "foo.bar.baz") + * @param defaultValue The default value to return if the path doesn't exist + * @returns The value at the path, or the default value + */ + get(path: string, defaultValue: T): T { + const resolved = this.resolvePath(path); + if (!resolved) { + return defaultValue; + } + + return (resolved.obj[resolved.key] ?? defaultValue) as T; + } + + /** + * Sets a value in the config + * @param path The path to set, supports dot notation (e.g. "foo.bar.baz") + * @param value The value to set + */ + set(path: string, value: T): void; + + /** + * Sets multiple values in the config + * @param values An object containing key-value pairs to set + */ + set(values: Record): void; + + set(keyOrValues: string | Record, value?: T): void { + if (typeof keyOrValues === "string") { + const key = keyOrValues; + + // Check if there's a schema for this key + if (this.schemas[key]) { + const validate = this.ajv.compile(this.schemas[key]); + if (!validate(value)) { + let errorText = this.ajv.errorsText(validate.errors); + errorText = errorText.replaceAll("/", "."); + errorText = errorText.replace(/^data[\.\s]/, ""); + throw new Error(`Validation error for ${key}: ${errorText}`); + } + } + + const resolved = this.resolvePath(key, true); + if (resolved) { + resolved.obj[resolved.key] = value; + } + } else { + // Handle object form + for (const [key, val] of Object.entries(keyOrValues)) { + this.set(key, val); + } + } + } + + /** + * Checks if a path exists in the config + * @param path The path to check, supports dot notation (e.g. "foo.bar.baz") + * @returns True if the path exists, false otherwise + */ + has(path: string): boolean { + const resolved = this.resolvePath(path); + if (!resolved) { + return false; + } + + return resolved.key in resolved.obj; } } diff --git a/common/spaces/evented_space_primitives.ts b/common/spaces/evented_space_primitives.ts index bde92901..ee54e031 100644 --- a/common/spaces/evented_space_primitives.ts +++ b/common/spaces/evented_space_primitives.ts @@ -26,7 +26,6 @@ export class EventedSpacePrimitives implements SpacePrimitives { constructor( private wrapped: SpacePrimitives, private eventHook: EventHook, - private ignorePathPrefixes: string[] = [], ) { // Translate file change events for documents into document:index events this.eventHook.addLocalListener( @@ -77,13 +76,10 @@ export class EventedSpacePrimitives implements SpacePrimitives { // New file scenario !oldHash && !this.initialFileListLoad ) || ( - // Changed file scenario - oldHash && - oldHash !== newHash - ) && - !this.ignorePathPrefixes.some((prefix) => - meta.name.startsWith(prefix) - ) + // Changed file scenario + oldHash && + oldHash !== newHash + ) ) { console.log("Detected file change", meta.name, oldHash, newHash); await this.dispatchEvent( @@ -99,13 +95,6 @@ export class EventedSpacePrimitives implements SpacePrimitives { } for (const deletedFile of deletedFiles) { - if ( - this.ignorePathPrefixes.some((prefix) => - deletedFile.startsWith(prefix) - ) - ) { - continue; - } delete this.spaceSnapshot[deletedFile]; await this.dispatchEvent("file:deleted", deletedFile); diff --git a/common/syscalls/config.ts b/common/syscalls/config.ts new file mode 100644 index 00000000..217b0d5c --- /dev/null +++ b/common/syscalls/config.ts @@ -0,0 +1,27 @@ +import type { SysCallMapping } from "$lib/plugos/system.ts"; +import type { Client } from "../../web/client.ts"; + +export function configSyscalls(client: Client): SysCallMapping { + return { + "config.get": (_ctx, path: string, defaultValue: any) => { + return client.config.get(path, defaultValue); + }, + "config.set": ( + _ctx, + keyOrValues: string | Record, + value?: any, + ) => { + if (typeof keyOrValues === "string") { + client.config.set(keyOrValues, value); + } else { + client.config.set(keyOrValues); + } + }, + "config.has": (_ctx, path: string) => { + return client.config.has(path); + }, + "config.define": (_ctx, key: string, schema: any) => { + client.config.define(key, schema); + }, + }; +} diff --git a/plug-api/syscalls/config.ts b/plug-api/syscalls/config.ts new file mode 100644 index 00000000..ad6865da --- /dev/null +++ b/plug-api/syscalls/config.ts @@ -0,0 +1,48 @@ +import { syscall } from "../syscall.ts"; + +/** + * Gets a config value by path, with support for dot notation. + * @param path The path to get the value from + * @param defaultValue The default value to return if the path doesn't exist + * @returns The value at the path, or the default value + */ +export function get(path: string, defaultValue: T): Promise { + return syscall("config.get", path, defaultValue); +} + +/** + * Sets a config value by path, with support for dot notation. + * @param path The path to set the value at + * @param value The value to set + */ +export function set(path: string, value: T): Promise; +/** + * Sets multiple config values at once. + * @param values An object containing key-value pairs to set + */ +export function set(values: Record): Promise; +export function set( + pathOrValues: string | Record, + value?: T, +): Promise { + return syscall("config.set", pathOrValues, value); +} + +/** + * Checks if a config path exists. + * @param path The path to check + * @returns True if the path exists, false otherwise + */ +export function has(path: string): Promise { + return syscall("config.has", path); +} + +/** + * Defines a JSON schema for a configuration key. + * The schema will be used to validate values when setting this key. + * @param key The configuration key to define a schema for + * @param schema The JSON schema to validate against + */ +export function define(key: string, schema: any): Promise { + return syscall("config.define", key, schema); +} diff --git a/web/client.ts b/web/client.ts index dcf88f22..da97e6d5 100644 --- a/web/client.ts +++ b/web/client.ts @@ -509,8 +509,6 @@ export class Client { this.plugSpaceRemotePrimitives, ), this.eventHook, - // Don't trigger events for these paths, they're handled in a custom way - // ["Library/Std/"], ); this.spaceKV = spaceKvPrimitives; diff --git a/web/client_system.ts b/web/client_system.ts index 4d5a9c3e..1218f1eb 100644 --- a/web/client_system.ts +++ b/web/client_system.ts @@ -40,6 +40,7 @@ import { jsonschemaSyscalls } from "$common/syscalls/jsonschema.ts"; import { luaSyscalls } from "$common/syscalls/lua.ts"; import { indexSyscalls } from "$common/syscalls/index.ts"; import { commandSyscalls } from "$common/syscalls/command.ts"; +import { configSyscalls } from "$common/syscalls/config.ts"; import { eventListenerSyscalls } from "$common/syscalls/event.ts"; import { DocumentEditorHook } from "./hooks/document_editor.ts"; import type { LuaCollectionQuery } from "$common/space_lua/query_collection.ts"; @@ -180,6 +181,7 @@ export class ClientSystem { debugSyscalls(this.client), syncSyscalls(this.client), clientStoreSyscalls(this.ds), + configSyscalls(this.client), ); if (!this.readOnlyMode) {