diff --git a/CHANGELOG.md b/CHANGELOG.md index 93266a68f..ebbdbdc59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - fix: vector package should return generic type in computations [#628](https://github.com/hypermodeinc/modus/pull/628) - chore: Delete extraneous copy of Anthropic model interface [#631](https://github.com/hypermodeinc/modus/pull/631) +- feat: Add `DynamicMap` type [#638](https://github.com/hypermodeinc/modus/pull/638) ## 2024-11-27 - CLI 0.14.0 diff --git a/cspell.json b/cspell.json index e5481db0f..7125a49aa 100644 --- a/cspell.json +++ b/cspell.json @@ -40,6 +40,7 @@ "dotproduct", "dsname", "dspc", + "dynamicmap", "embedder", "embedders", "envfiles", diff --git a/sdk/assemblyscript/src/assembly/__tests__/dynamicmap.spec.ts b/sdk/assemblyscript/src/assembly/__tests__/dynamicmap.spec.ts new file mode 100644 index 000000000..820a3a033 --- /dev/null +++ b/sdk/assemblyscript/src/assembly/__tests__/dynamicmap.spec.ts @@ -0,0 +1,116 @@ +/* + * Copyright 2024 Hypermode Inc. + * Licensed under the terms of the Apache License, Version 2.0 + * See the LICENSE file that accompanied this code for further details. + * + * SPDX-FileCopyrightText: 2024 Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { JSON } from "json-as"; +import { expect, it, run } from "as-test"; +import { DynamicMap } from "../dynamicmap"; + + +@json +class Obj { + foo: string = ""; +} + +it("should set values", () => { + const m = new DynamicMap(); + m.set("a", 42); + m.set("b", "hello"); + m.set("c", [1, 2, 3]); + m.set("d", true); + m.set("e", null); + m.set("f", 3.14); + m.set("g", { foo: "bar" } as Obj); + + const json = JSON.stringify(m); + expect(json).toBe( + '{"a":42,"b":"hello","c":[1,2,3],"d":true,"e":null,"f":3.14,"g":{"foo":"bar"}}', + ); +}); + +it("should get values", () => { + const m = JSON.parse( + '{"a":42,"b":"hello","c":[1,2,3],"d":true,"e":null,"f":3.14,"g":{"foo":"bar"}}', + ); + expect(m.get("a")).toBe(42); + expect(m.get("b")).toBe("hello"); + expect(m.get("c")).toBe([1, 2, 3]); + expect(m.get("d")).toBe(true); + expect(m.get("e")).toBe(null); + expect(m.get("f")).toBe(3.14); + + const obj = m.get("g"); + expect(obj.foo).toBe("bar"); +}); + +it("should get size", () => { + const m = new DynamicMap(); + expect(m.size).toBe(0); + m.set("a", 42); + expect(m.size).toBe(1); + m.set("b", "hello"); + expect(m.size).toBe(2); +}); + +it("should test existence of keys", () => { + const m = new DynamicMap(); + expect(m.has("a")).toBe(false); + m.set("a", 42); + expect(m.has("a")).toBe(true); + expect(m.has("b")).toBe(false); + m.set("b", "hello"); + expect(m.has("b")).toBe(true); +}); + +it("should delete keys", () => { + const m = new DynamicMap(); + m.set("a", 42); + m.set("b", "hello"); + expect(m.size).toBe(2); + expect(m.has("a")).toBe(true); + expect(m.has("b")).toBe(true); + m.delete("a"); + expect(m.size).toBe(1); + expect(m.has("a")).toBe(false); + expect(m.has("b")).toBe(true); + m.delete("b"); + expect(m.size).toBe(0); + expect(m.has("a")).toBe(false); + expect(m.has("b")).toBe(false); +}); + +it("should clear", () => { + const m = new DynamicMap(); + m.set("a", 42); + m.set("b", "hello"); + expect(m.size).toBe(2); + m.clear(); + expect(m.size).toBe(0); + expect(m.has("a")).toBe(false); + expect(m.has("b")).toBe(false); +}); + +it("should iterate keys", () => { + const m = new DynamicMap(); + m.set("a", 42); + m.set("b", "hello"); + m.set("c", [1, 2, 3]); + const keys = m.keys(); + expect(keys).toBe(["a", "b", "c"]); +}); + +it("should iterate raw values", () => { + const m = new DynamicMap(); + m.set("a", 42); + m.set("b", "hello"); + m.set("c", [1, 2, 3]); + const values = m.values(); + expect(values).toBe(["42", '"hello"', "[1,2,3]"]); +}); + +run(); diff --git a/sdk/assemblyscript/src/assembly/dynamicmap.ts b/sdk/assemblyscript/src/assembly/dynamicmap.ts new file mode 100644 index 000000000..794fdae86 --- /dev/null +++ b/sdk/assemblyscript/src/assembly/dynamicmap.ts @@ -0,0 +1,217 @@ +/* + * Copyright 2024 Hypermode Inc. + * Licensed under the terms of the Apache License, Version 2.0 + * See the LICENSE file that accompanied this code for further details. + * + * SPDX-FileCopyrightText: 2024 Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { JSON } from "json-as"; + +export class DynamicMap { + private data: Map = new Map(); + + public get size(): i32 { + return this.data.size; + } + + public has(key: string): bool { + return this.data.has(key); + } + + public get(key: string): T { + return JSON.parse(this.data.get(key)); + } + + public set(key: string, value: T): void { + if (value == null) { + this.data.set(key, "null"); + } else { + this.data.set(key, JSON.stringify(value)); + } + } + + public delete(key: string): bool { + return this.data.delete(key); + } + + public clear(): void { + this.data.clear(); + } + + public keys(): string[] { + return this.data.keys(); + } + + public values(): JSON.Raw[] { + return this.data.values(); + } + + __INITIALIZE(): this { + return this; + } + + __SERIALIZE(): string { + // This would be ideal, but doesn't work: + // return JSON.stringify(this.data); + // So instead, we have to do construct the JSON manually. + // + // TODO: Update this to use JSON.stringify once it's supported in json-as. + // https://github.com/JairusSW/as-json/issues/98 + + const segments: string[] = []; + const keys = this.data.keys(); + const values = this.data.values(); + + for (let i = 0; i < this.data.size; i++) { + const key = JSON.stringify(keys[i]); + const value = values[i]; // already in JSON + segments.push(`${key}:${value}`); + } + + return `{${segments.join(",")}}`; + } + + /* eslint-disable @typescript-eslint/no-unused-vars */ + __DESERIALIZE( + data: string, + key_start: i32, + key_end: i32, + value_start: i32, + value_end: i32, + ): boolean { + // This would be ideal, but doesn't work: + // this.data = JSON.parse>(data); + // So instead, we have to parse the JSON manually. + // + // TODO: Update this to use JSON.parse once it's supported in json-as. + // https://github.com/JairusSW/as-json/issues/98 + + this.data = parseJsonToRawMap(data); + + return true; + } +} + +// Everything below this line is a workaround for JSON.parse not working with JSON.Raw values. +// (It was generated via AI and may not be optimized.) + +class ParseResult { + constructor( + public result: string, + public nextIndex: i32, + ) {} +} + +function parseJsonToRawMap(jsonString: string): Map { + const map = new Map(); + let i: i32 = 0; + const length: i32 = jsonString.length; + + function skipWhitespace(index: i32, input: string): i32 { + while ( + index < input.length && + (input.charCodeAt(index) === 32 || // space + input.charCodeAt(index) === 9 || // tab + input.charCodeAt(index) === 10 || // newline + input.charCodeAt(index) === 13) // carriage return + ) { + index++; + } + return index; + } + + function readString(index: i32, input: string): ParseResult { + if (input.charCodeAt(index) !== 34) { + throw new Error("Expected '\"' at position " + index.toString()); + } + index++; + let result = ""; + while (index < input.length) { + const charCode = input.charCodeAt(index); + if (charCode === 34) { + return new ParseResult(result, index + 1); + } + if (charCode === 92) { + index++; + if (index < input.length) { + result += String.fromCharCode(input.charCodeAt(index)); + } + } else { + result += String.fromCharCode(charCode); + } + index++; + } + throw new Error("Unterminated string"); + } + + function readValue(index: i32, input: string): ParseResult { + const start: i32 = index; + let braceCount: i32 = 0; + let bracketCount: i32 = 0; + + while (index < input.length) { + const char = input.charAt(index); + if ( + braceCount === 0 && + bracketCount === 0 && + (char === "," || char === "}") + ) { + break; + } + if (char === "{") braceCount++; + else if (char === "}") braceCount--; + else if (char === "[") bracketCount++; + else if (char === "]") bracketCount--; + index++; + } + + const result = input.substring(start, index).trim(); + return new ParseResult(result, index); + } + + if (jsonString.charCodeAt(i) !== 123) { + throw new Error("Expected '{' at the beginning of JSON"); + } + i++; + + while (i < length) { + i = skipWhitespace(i, jsonString); + + const keyResult = readString(i, jsonString); + const key = keyResult.result; + i = keyResult.nextIndex; + + i = skipWhitespace(i, jsonString); + if (jsonString.charCodeAt(i) !== 58) { + throw new Error("Expected ':' after key at position " + i.toString()); + } + i++; + + i = skipWhitespace(i, jsonString); + + const valueResult = readValue(i, jsonString); + const value = valueResult.result; + i = valueResult.nextIndex; + + map.set(key, value); + + i = skipWhitespace(i, jsonString); + if (jsonString.charCodeAt(i) === 44) { + i++; + } else if (jsonString.charCodeAt(i) === 125) { + i++; + break; + } else { + throw new Error( + "Unexpected character '" + + jsonString.charAt(i) + + "' at position " + + i.toString(), + ); + } + } + + return map; +} diff --git a/sdk/assemblyscript/src/assembly/index.ts b/sdk/assemblyscript/src/assembly/index.ts index 6d7e636e8..add6f6d52 100644 --- a/sdk/assemblyscript/src/assembly/index.ts +++ b/sdk/assemblyscript/src/assembly/index.ts @@ -30,3 +30,8 @@ export { vectors }; import * as auth from "./auth"; export { auth }; + +import * as utils from "./utils"; +export { utils }; + +export * from "./dynamicmap"; diff --git a/sdk/assemblyscript/src/tests/dynamicmap.run.ts b/sdk/assemblyscript/src/tests/dynamicmap.run.ts new file mode 100644 index 000000000..10f6c4fe4 --- /dev/null +++ b/sdk/assemblyscript/src/tests/dynamicmap.run.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Hypermode Inc. + * Licensed under the terms of the Apache License, Version 2.0 + * See the LICENSE file that accompanied this code for further details. + * + * SPDX-FileCopyrightText: 2024 Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from "fs"; +import { instantiate } from "../build/dynamicmap.spec.js"; +const binary = readFileSync("./build/dynamicmap.spec.wasm"); +const module = new WebAssembly.Module(binary); +instantiate(module, { + env: {}, +});