diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 829380c..da50386 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,11 +2,11 @@ # tableauio/loader devcontainer # # Single-stage, multi-arch (amd64 + arm64) image bringing the full -# C++/Go/.NET toolchain plus protobuf at the exact versions CI uses. +# C++/Go/.NET/Node toolchain plus protobuf at the exact versions CI uses. # # All version pins are read from ./versions.env (the single source of # truth shared with make.py and the CI workflows). To bump Go / buf / -# protobuf / .NET / vcpkg-baseline, edit that file — not this one. +# protobuf / .NET / Node / vcpkg-baseline, edit that file — not this one. # # Build context is .devcontainer/ (the directory containing this file), # so `COPY versions.env ...` resolves directly. @@ -209,8 +209,8 @@ ln -s /opt/vcpkg/active/tools/protobuf/protoc /usr/local/bin/protoc EOF # --------------------------------------------------------------------------- -# .NET SDK — apt-based install from the official Microsoft repository. -# Version is read from /opt/versions.env. +# .NET SDK + Node.js — apt-based installs from the official Microsoft and +# NodeSource repositories. Versions are read from /opt/versions.env. # apt-get clean + rm /var/lib/apt/lists at the end keeps the layer small. # --------------------------------------------------------------------------- RUN <&1)" printf ' protoc: %s\n' "$(protoc --version)" printf ' dotnet: %s\n' "$(dotnet --version)" +printf ' node: %s\n' "$(node --version)" diff --git a/.devcontainer/versions.env b/.devcontainer/versions.env index 893983e..169a1b7 100644 --- a/.devcontainer/versions.env +++ b/.devcontainer/versions.env @@ -61,6 +61,9 @@ LEGACY_V3_VCPKG_BASELINE_COMMIT=6245ce44a03f04d19be125ab1bbab578d0933e85 # CI uses `${DOTNET_VERSION}.x` with actions/setup-dotnet. DOTNET_VERSION=8.0 +# Node.js LTS major. NodeSource apt repo is `setup_${NODE_VERSION}.x`. +NODE_VERSION=20 + # CMake version installed by `make.py setup` on Windows (the devcontainer # base image already ships a recent cmake; macOS/Linux use system cmake). CMAKE_VERSION=3.31.8 diff --git a/.github/workflows/release-ts.yml b/.github/workflows/release-ts.yml new file mode 100644 index 0000000..a27fffd --- /dev/null +++ b/.github/workflows/release-ts.yml @@ -0,0 +1,64 @@ +name: Release TypeScript + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + release: + name: Release cmd/protoc-gen-ts-tableau-loader + runs-on: ubuntu-latest + if: startsWith(github.event.release.tag_name, + 'cmd/protoc-gen-ts-tableau-loader/') + strategy: + matrix: + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + exclude: + - goos: linux + goarch: arm64 + - goos: windows + goarch: arm64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Download dependencies + run: | + cd cmd/protoc-gen-ts-tableau-loader + go mod download + - name: Prepare build directory + run: | + mkdir -p build/ + cp README.md build/ + cp LICENSE build/ + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + cd cmd/protoc-gen-ts-tableau-loader + go build -trimpath -o $GITHUB_WORKSPACE/build + - name: Create package + id: package + run: | + PACKAGE_NAME=protoc-gen-ts-tableau-loader.${GITHUB_REF#refs/tags/cmd/protoc-gen-ts-tableau-loader/}.${{ matrix.goos }}.${{ matrix.goarch }}.tar.gz + tar -czvf $PACKAGE_NAME -C build . + echo ::set-output name=name::${PACKAGE_NAME} + - name: Upload asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./${{ steps.package.outputs.name }} + asset_name: ${{ steps.package.outputs.name }} + asset_content_type: application/gzip diff --git a/.github/workflows/testing-ts.yml b/.github/workflows/testing-ts.yml new file mode 100644 index 0000000..48139d4 --- /dev/null +++ b/.github/workflows/testing-ts.yml @@ -0,0 +1,58 @@ +name: Testing TypeScript + +# Trigger on pushes, PRs (excluding documentation changes), and nightly. +on: + push: + branches: [master, main] + pull_request: + schedule: + - cron: 0 0 * * * # daily at 00:00 + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Read pinned versions + uses: ./.github/actions/load-versions + + - name: Install Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + cache: true + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: test/ts-tableau-loader/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install Buf + run: python3 make.py setup --lang go + + - name: Test + run: python3 make.py test --lang ts diff --git a/.gitignore b/.gitignore index 58e3329..f4bab22 100644 --- a/.gitignore +++ b/.gitignore @@ -44,10 +44,10 @@ cmd/protoc-gen-go-tableau-loader/protoc-gen-go-tableau-loader cmd/protoc-gen-csharp-tableau-loader/protoc-gen-csharp-tableau-loader test/go-tableau-loader/go-tableau-loader -_lab/ts/src/protoconf coverage.txt !test/testdata/bin/ test/csharp-tableau-loader/protoconf +test/ts-tableau-loader/protoconf # C# Dev Kit language service cache (VS Code) *.lscache diff --git a/CLAUDE.md b/CLAUDE.md index 25bd1e3..28c665d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,9 @@ python3 make.py test --lang cpp --protobuf-version 3.21.12 # legacy v # C# python3 make.py test --lang csharp # full python3 make.py test --lang csharp -k HubTest.Load # FullyQualifiedName~HubTest.Load + +# TypeScript +python3 make.py test --lang ts # npm install + generate + test ``` GoogleTest is fetched via CMake `FetchContent` — no manual install. diff --git a/README.md b/README.md index 6deee7e..71dbf91 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,18 @@ Release C# version + + TypeScript + + Release TypeScript version + Release Release Go Release C++ Release C# + Release TypeScript Testing @@ -40,6 +46,7 @@ Testing Go Testing C++ Testing C# + Testing TypeScript Testing make.py @@ -54,6 +61,7 @@ | `protoc-gen-go-tableau-loader` | Go | `*.pc.go` | | `protoc-gen-cpp-tableau-loader` | C++17 | `*.pc.h` / `*.pc.cc` | | `protoc-gen-csharp-tableau-loader` | C# (Unity 2022.3 LTS / .NET 8) | `*.pc.cs` | +| `protoc-gen-ts-tableau-loader` | TypeScript (ESM, protobuf-es) | `*.pc.ts` | ## Quick start diff --git a/_lab/ts/buf.gen.yaml b/_lab/ts/buf.gen.yaml deleted file mode 100644 index 2a7518f..0000000 --- a/_lab/ts/buf.gen.yaml +++ /dev/null @@ -1,25 +0,0 @@ -version: v2 -# PoC: mirror the existing per-language buf.gen.yaml pattern, where a "base" -# plugin emits the message types and (later) protoc-gen-tableau-ts wraps them -# into Messager/Hub/index code — exactly like protocolbuffers/go + -# protoc-gen-go-tableau-loader on the Go side. -# -# Here only the base plugin (protobuf-es) runs; it generates the protobuf -# message classes we deserialize tableau's protojson output into. -plugins: - # Run the protobuf-es generator as a BSR remote plugin (mirrors the Go side's - # `remote: buf.build/protocolbuffers/go`). Keep this version in lockstep with - # the @bufbuild/protobuf runtime in package.json: the generated *_pb.ts import - # from @bufbuild/protobuf at runtime, so generator and runtime must match. - - remote: buf.build/bufbuild/es:v2.2.3 - out: src/protoconf - # The proto files use tableau.* extension options, so the generated - # message modules import the tableau/protobuf descriptors; pull those - # dependencies in here (mirrors include_imports in the cpp/csharp - # buf.gen.yaml) instead of passing --include-imports on the CLI. - include_imports: true - opt: - - target=ts - # Emit explicit ".js" extensions on relative imports so the output is - # valid under TS's nodenext/node16 ESM resolution (and plain Node ESM). - - import_extension=js diff --git a/_lab/ts/src/poc.ts b/_lab/ts/src/poc.ts deleted file mode 100644 index 268574d..0000000 --- a/_lab/ts/src/poc.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * PoC — validate that protobuf-es (@bufbuild/protobuf) can faithfully consume - * the protojson that tableau (github.com/tableauio/tableau) emits, so it can - * serve as the codegen base for `protoc-gen-tableau-ts`. - * - * Run: npm run generate && npm run poc - * - * What this exercises (the things that actually decide the selection): - * 1. Canonical proto3 JSON ("protojson") parsing via fromJson(Schema, ...). - * 2. Mixed field-name casing in one file: ItemConf.json has `itemMap`, - * `extTypeList`, `nameList` (camelCase) AND `param_list` (original - * snake_case). proto3 JSON requires BOTH to be accepted — protobuf-es - * does; protobufjs's fromObject does not (it would silently drop one). - * 3. 64-bit ints as JSON strings: ThemeConf.Theme.value is uint64, encoded - * as "1" in JSON, must parse into a bigint. - * 4. Well-known types: ItemConf.Item.expiry is google.protobuf.Timestamp, - * encoded as an RFC-3339 string. - * 5. Binary round-trip (toBinary -> fromBinary) — the `.binpb` load path. - * 6. Re-emitting canonical protojson via toJson (note snake `param_list` - * comes back as camel `paramList`). - */ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { - fromJson, - toJson, - toBinary, - fromBinary, - type JsonValue, -} from "@bufbuild/protobuf"; - -import { ItemConfSchema } from "./protoconf/item_conf_pb.js"; -import { ThemeConfSchema } from "./protoconf/test_conf_pb.js"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const CONF = join(HERE, "../../../test/testdata/conf"); - -let failures = 0; -function check(label: string, cond: unknown): void { - const ok = Boolean(cond); - console.log(` ${ok ? "PASS" : "FAIL"} ${label}`); - if (!ok) failures++; -} - -// JSON.stringify helper that survives bigint (64-bit) fields. -const bigintSafe = (_k: string, v: unknown) => - typeof v === "bigint" ? v.toString() : v; - -function readJson(file: string): JsonValue { - return JSON.parse(readFileSync(join(CONF, file), "utf8")) as JsonValue; -} - -// --- 1) ItemConf: the rich case (maps / enums / WKT / mixed casing) -------- -console.log("\n[ItemConf] protojson -> message"); -const itemConf = fromJson(ItemConfSchema, readJson("ItemConf.json")); -const apple = itemConf.itemMap["1"]; - -check("map parsed; key '1' present", apple !== undefined); -check("camelCase 'name' -> apple", apple?.name === "apple"); -check( - "snake_case 'param_list' accepted -> [1,2,3]", - JSON.stringify(apple?.paramList) === "[1,2,3]", -); -check( - "camelCase 'extTypeList' accepted -> 2 entries", - apple?.extTypeList.length === 2, -); -check( - "nested 'path.nameList' (camel) -> ['icon.png']", - apple?.path?.nameList[0] === "icon.png", -); -check("enum 'type' parsed by name -> non-zero", (apple?.type ?? 0) !== 0); -check( - "WKT Timestamp 'expiry' parsed -> seconds > 0", - (apple?.expiry?.seconds ?? 0n) > 0n, -); - -// --- 2) ThemeConf: uint64-as-string ----------------------------------------- -console.log("\n[ThemeConf] uint64 encoded as JSON string"); -const themeConf = fromJson(ThemeConfSchema, readJson("ThemeConf.json")); -const theme1 = themeConf.themeMap["theme1"]; -check("map key 'theme1' present", theme1 !== undefined); -check('uint64 "1" -> bigint 1n', theme1?.value === 1n); - -// --- 3) binary round-trip (the .binpb load path) ---------------------------- -console.log("\n[ItemConf] binary round-trip"); -const bin = toBinary(ItemConfSchema, itemConf); -const back = fromBinary(ItemConfSchema, bin); -check("toBinary produced bytes", bin.length > 0); -check( - "fromBinary preserves apple.name", - back.itemMap["1"]?.name === "apple", -); -check( - "fromBinary preserves paramList", - JSON.stringify(back.itemMap["1"]?.paramList) === "[1,2,3]", -); - -// --- 4) re-emit canonical protojson ----------------------------------------- -console.log("\n[ItemConf] message -> canonical protojson (toJson)"); -const reJson = toJson(ItemConfSchema, itemConf) as Record; -const reApple = reJson.itemMap["1"]; -check( - "re-emitted with camelCase 'paramList' (was snake in input)", - Array.isArray(reApple.paramList), -); -check( - "re-emitted Timestamp as RFC-3339 string", - typeof reApple.expiry === "string" && reApple.expiry.includes("T"), -); -console.log( - " sample:", - JSON.stringify(reApple, bigintSafe).slice(0, 120) + " ...", -); - -// --- verdict ---------------------------------------------------------------- -console.log( - `\n${failures === 0 ? "ALL CHECKS PASSED" : `${failures} CHECK(S) FAILED`}`, -); -process.exit(failures === 0 ? 0 : 1); diff --git a/cmd/protoc-gen-ts-tableau-loader/barrel.go b/cmd/protoc-gen-ts-tableau-loader/barrel.go new file mode 100644 index 0000000..bbdbb6b --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/barrel.go @@ -0,0 +1,120 @@ +package main + +import ( + "sort" + "strings" + + "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// pbPackages tracks the protobuf packages a single loader file imports and +// emits one namespace import per package, pointing at that package's barrel +// module under the barrel/ subdir: `import * as from +// "barrel/.pc.js"`. The alias is the proto package name (dots -> +// underscores), so generated loaders qualify types exactly like the loaders of +// other languages (e.g. protoconf.ItemConf, base.Hero), keeping cross-language +// naming consistent. +type pbPackages struct { + rel string // relative path prefix from this loader to the barrel dir + order []string // referenced package names, in first-seen order + seen map[string]bool // deduplication set +} + +// newPBPackages creates a registry for a loader whose source-relative output +// prefix is loaderPrefix (e.g. "hero_conf" or "sub/foo"). The number of path +// segments determines how many "../" are needed to reach the loader output +// root; barrel modules live in the barrel/ subdir under that root. +func newPBPackages(loaderPrefix string) *pbPackages { + rel := "./" + if depth := strings.Count(loaderPrefix, "/"); depth > 0 { + rel = strings.Repeat("../", depth) + } + return &pbPackages{rel: rel, seen: map[string]bool{}} +} + +// add registers a proto package (idempotent), preserving first-seen order. +func (p *pbPackages) add(pkg string) { + if p.seen[pkg] { + return + } + p.seen[pkg] = true + p.order = append(p.order, pkg) +} + +// aliasOf returns the namespace alias for a descriptor's owning package. +func (p *pbPackages) aliasOf(d protoreflect.Descriptor) string { + return helper.PackageAlias(string(d.ParentFile().Package())) +} + +// emit writes one namespace import statement per package, in registration order. +func (p *pbPackages) emit(g *protogen.GeneratedFile) { + for _, pkg := range p.order { + alias := helper.PackageAlias(pkg) + g.P(`import * as `, alias, ` from "`, p.rel, `barrel/`, alias, `.pc.js";`) + } +} + +// moduleOf returns the protobuf-es base module specifier for a descriptor's +// parent file (e.g. "../protoconf/base/base_pb.js"), relative to the loader +// output root. +func moduleOf(d protoreflect.Descriptor) string { + return pbImportPath + "/" + strings.TrimSuffix(d.ParentFile().Path(), ".proto") + "_pb.js" +} + +// barrelRegistry collects, per proto package, all generated files (used to fill +// a package's barrel) and tracks which packages are actually referenced by some +// loader (so only those packages get a barrel emitted). +type barrelRegistry struct { + filesByPkg map[string][]*protogen.File // package -> all files of that package + referenced []string // referenced packages, in first-seen order + seen map[string]bool // deduplication set for referenced +} + +// newBarrelRegistry groups every file known to the generator by proto package. +func newBarrelRegistry(gen *protogen.Plugin) *barrelRegistry { + r := &barrelRegistry{filesByPkg: map[string][]*protogen.File{}, seen: map[string]bool{}} + for _, f := range gen.Files { + pkg := string(f.Desc.Package()) + r.filesByPkg[pkg] = append(r.filesByPkg[pkg], f) + } + return r +} + +// markReferenced records that a package is imported by some loader. +func (r *barrelRegistry) markReferenced(pkg string) { + if r.seen[pkg] { + return + } + r.seen[pkg] = true + r.referenced = append(r.referenced, pkg) +} + +// generateBarrels emits one barrel module per referenced proto package into the +// barrel/ subdir of the loader output root. Each barrel re-exports every +// protobuf-es generated module of that package via `export *`, so a loader can +// import the whole package under a single namespace alias. This is +// collision-free because protobuf guarantees fully-qualified names are unique +// within a package, so the wildcard re-exports never clash. Because the barrel +// sits one level under the loader output root, an extra "../" is prepended to +// each module path (which moduleOf computes relative to that root). +func generateBarrels(gen *protogen.Plugin, reg *barrelRegistry) { + for _, pkg := range reg.referenced { + alias := helper.PackageAlias(pkg) + g := gen.NewGeneratedFile("barrel/"+alias+".pc.ts", "") + helper.GenerateFileHeader(gen, nil, g, version) + g.P(`// Barrel for proto package "`, pkg, `": re-exports every protobuf-es`) + g.P("// generated module of this package, so loaders import the whole package") + g.P("// under one namespace alias (import * as ", alias, `), matching the`) + g.P("// package-qualified naming used by loaders of other languages.") + g.P() + files := append([]*protogen.File(nil), reg.filesByPkg[pkg]...) + sort.Slice(files, func(i, j int) bool { + return files[i].Desc.Path() < files[j].Desc.Path() + }) + for _, f := range files { + g.P(`export * from "../`, moduleOf(f.Desc), `";`) + } + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/embed.go b/cmd/protoc-gen-ts-tableau-loader/embed.go new file mode 100644 index 0000000..1b84c30 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/embed.go @@ -0,0 +1,43 @@ +package main + +import ( + "embed" + "path" + "strings" + + "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" + "google.golang.org/protobuf/compiler/protogen" +) + +//go:embed embed/* +var efs embed.FS + +// generateEmbed generates the runtime library files (util/load/messager/...) +// by copying the embedded TypeScript sources verbatim. The templates +// subdirectory is skipped since it is consumed by the hub generator. +func generateEmbed(gen *protogen.Plugin) { + entries, err := efs.ReadDir("embed") + if err != nil { + panic(err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + + g := gen.NewGeneratedFile(entry.Name(), "") + helper.GenerateFileHeader(gen, nil, g, version) + // refer: [embed: embed path on different OS cannot open file](https://github.com/golang/go/issues/45230) + content, err := efs.ReadFile(path.Join("embed", entry.Name())) + if err != nil { + panic(err) + } + // Rewrite the tableau extension descriptor import to honor pb_path: the + // runtime sources are authored assuming the base output is one level up + // ("../tableau/protobuf/..."); rebase it onto the configured pb_path. + text := strings.ReplaceAll(string(content), + `"../tableau/protobuf/tableau_pb.js"`, + `"`+pbImportPath+`/tableau/protobuf/tableau_pb.js"`) + g.P(text) + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/embed/load.pc.ts b/cmd/protoc-gen-ts-tableau-loader/embed/load.pc.ts new file mode 100644 index 0000000..2e185b5 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/embed/load.pc.ts @@ -0,0 +1,267 @@ +import { readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + create, + fromBinary, + fromJson, + type DescMessage, + type JsonValue, + type Message, + type MessageShape, +} from "@bufbuild/protobuf"; +import * as tableaupb from "../tableau/protobuf/tableau_pb.js"; +import { + Format, + format2Ext, + getFormat, + getSheetPatch, + patchMessage, +} from "./util.pc.js"; + +export { Format } from "./util.pc.js"; + +/** + * ReadFunc reads a config file and returns its content. + */ +export type ReadFunc = (path: string) => Uint8Array; + +/** + * LoadFunc loads a messager's content based on the given descriptor, path, + * format, and options. + */ +export type LoadFunc = ( + desc: DescMessage, + path: string, + fmt: Format, + options?: MessagerOptions, +) => Message; + +/** + * LoadMode controls patch loading behavior. + */ +export enum LoadMode { + /** Load all related files (main + patches). Default. */ + ALL = "all", + /** Only load the main file. */ + ONLY_MAIN = "only_main", + /** Only load the patch files. */ + ONLY_PATCH = "only_patch", +} + +/** + * BaseOptions is the common options for both global-level and messager-level options. + */ +export interface BaseOptions { + /** Whether to ignore unknown JSON fields during parsing. */ + ignoreUnknownFields?: boolean; + /** Specify the directory paths for config patching. */ + patchDirs?: string[]; + /** Specify the loading mode for config patching. Default is LoadMode.ALL. */ + mode?: LoadMode; + /** Custom read function to read a config file's content. Default is readFileSync. */ + readFunc?: ReadFunc; + /** Custom load function to load a messager's content. Default is loadMessager. */ + loadFunc?: LoadFunc; +} + +/** + * MessagerOptions defines the options for loading a single messager. + */ +export interface MessagerOptions extends BaseOptions { + /** + * Path maps the messager to a corresponding config file path. If specified, + * then the main messager will be parsed from the file directly, other than + * the specified load dir. + */ + path?: string; + /** + * PatchPaths maps the messager to one or multiple corresponding patch file + * paths. If specified, then the main messager will be patched. + */ + patchPaths?: string[]; +} + +/** + * Options is the global-level options, which contains both global-level and + * messager-level options. + */ +export interface Options extends BaseOptions { + /** + * messagerOptions maps each messager name to a MessagerOptions. If specified, + * then the messager will be parsed with the given options directly. + */ + messagerOptions?: { [name: string]: MessagerOptions }; +} + +/** + * parseMessagerOptionsByName parses messager options with both global-level and + * messager-level options taken into consideration. + */ +export function parseMessagerOptionsByName(options: Options, name: string): MessagerOptions { + const mopts: MessagerOptions = options.messagerOptions?.[name] + ? { ...options.messagerOptions[name] } + : {}; + mopts.ignoreUnknownFields ??= options.ignoreUnknownFields; + mopts.patchDirs ??= options.patchDirs; + mopts.mode ??= options.mode; + mopts.readFunc ??= options.readFunc; + mopts.loadFunc ??= options.loadFunc; + return mopts; +} + +function defaultRead(path: string): Uint8Array { + return new Uint8Array(readFileSync(path)); +} + +/** + * loadMessager loads a protobuf message from the specified file path and format. + */ +export function loadMessager( + desc: DescMessage, + path: string, + fmt: Format, + options?: MessagerOptions, +): Message { + const readFunc = options?.readFunc ?? defaultRead; + let content: Uint8Array; + try { + content = readFunc(path); + } catch (e) { + throw new Error(`failed to read ${path}`, { cause: e }); + } + return unmarshal(content, desc, fmt, options); +} + +/** + * loadMessagerInDir loads a protobuf message from the specified directory and + * format. It resolves the file path based on the message descriptor name. + */ +export function loadMessagerInDir( + desc: Desc, + dir: string, + fmt: Format, + options?: MessagerOptions, +): MessageShape { + let path = ""; + if (options?.path) { + path = options.path; + fmt = getFormat(path); + } + if (path === "") { + path = join(dir, desc.name + format2Ext(fmt)); + } + const sheetPatch = getSheetPatch(desc); + let msg: Message; + if (sheetPatch !== tableaupb.Patch.NONE) { + msg = loadMessagerWithPatch(desc, path, fmt, sheetPatch, options); + } else { + const loadFunc = options?.loadFunc ?? loadMessager; + msg = loadFunc(desc, path, fmt, options); + } + return msg as MessageShape; +} + +/** + * loadMessagerWithPatch loads a protobuf message with patch support. + */ +export function loadMessagerWithPatch( + desc: DescMessage, + path: string, + fmt: Format, + patch: tableaupb.Patch, + options?: MessagerOptions, +): Message { + const mode = options?.mode ?? LoadMode.ALL; + const loadFunc = options?.loadFunc ?? loadMessager; + if (mode === LoadMode.ONLY_MAIN) { + // Ignore patch files when LoadMode.ONLY_MAIN specified. + return loadFunc(desc, path, fmt, options); + } + + let patchPaths: string[] = []; + const explicitPaths = options?.patchPaths && options.patchPaths.length > 0; + if (explicitPaths) { + // PatchPaths takes precedence over PatchDirs. + patchPaths = [...options!.patchPaths!]; + } else if (options?.patchDirs) { + const filename = desc.name + format2Ext(fmt); + for (const patchDir of options.patchDirs) { + patchPaths.push(join(patchDir, filename)); + } + } + + // Filter out non-existing patch files when relying on PatchDirs. + let existedPatchPaths: string[]; + if (explicitPaths) { + // Explicit paths are kept as-is; loadFunc surfaces errors if missing. + existedPatchPaths = patchPaths; + } else { + existedPatchPaths = patchPaths.filter((p) => existsSync(p)); + } + + if (existedPatchPaths.length === 0) { + if (mode === LoadMode.ONLY_PATCH) { + // Return empty message when LoadMode.ONLY_PATCH specified but no valid + // patch file provided. + return create(desc); + } + // No valid patch path provided, then just load from the "main" file. + return loadFunc(desc, path, fmt, options); + } + + switch (patch) { + case tableaupb.Patch.REPLACE: { + // Just use the last "patch" file. + const patchPath = existedPatchPaths[existedPatchPaths.length - 1]!; + return loadFunc(desc, patchPath, getFormat(patchPath), options); + } + case tableaupb.Patch.MERGE: { + let msg: Message; + if (mode !== LoadMode.ONLY_PATCH) { + // Load msg from the "main" file. + msg = loadFunc(desc, path, fmt, options); + } else { + msg = create(desc); + } + for (const patchPath of existedPatchPaths) { + const patchMsg = loadFunc(desc, patchPath, getFormat(patchPath), options); + patchMessage(msg, patchMsg, desc); + } + return msg; + } + default: + throw new Error(`unknown patch type: ${patch}`); + } +} + +/** + * unmarshal parses the given byte content into a protobuf message based on the + * specified format. + */ +export function unmarshal( + content: Uint8Array, + desc: DescMessage, + fmt: Format, + options?: MessagerOptions, +): Message { + switch (fmt) { + case Format.JSON: + try { + const json = JSON.parse(new TextDecoder().decode(content)) as JsonValue; + return fromJson(desc, json, { + ignoreUnknownFields: options?.ignoreUnknownFields ?? false, + }); + } catch (e) { + throw new Error(`failed to parse ${desc.name}.json`, { cause: e }); + } + case Format.BIN: + try { + return fromBinary(desc, content); + } catch (e) { + throw new Error(`failed to parse ${desc.name}.binpb`, { cause: e }); + } + default: + throw new Error(`unknown format: ${fmt}`); + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/embed/messager.pc.ts b/cmd/protoc-gen-ts-tableau-loader/embed/messager.pc.ts new file mode 100644 index 0000000..0aa9161 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/embed/messager.pc.ts @@ -0,0 +1,52 @@ +import { type Message } from "@bufbuild/protobuf"; +import { Format } from "./util.pc.js"; +import { type MessagerOptions } from "./load.pc.js"; +// Type-only import to describe the hub passed to processAfterLoadAll. Importing +// only the type avoids a runtime cycle with the generated hub module. +import type { Hub } from "./hub.pc.js"; + +/** + * Stats contains statistics info about loading. + */ +export interface Stats { + /** Total load time consuming, in milliseconds. */ + durationMs: number; +} + +/** + * Messager is the base class for all generated configuration messagers. + * It is designed for three goals: + * 1. Easy use: simple yet powerful accessors. + * 2. Elegant API: concise and clean functions. + * 3. Extensibility: Map, OrderedMap, Index, OrderedIndex... + */ +export abstract class Messager { + protected loadStats: Stats = { durationMs: 0 }; + + /** getStats returns the loading stats info. */ + getStats(): Stats { + return this.loadStats; + } + + /** name returns the messager's message name. */ + abstract name(): string; + + /** load fills message from file in the specified directory and format. Throws on failure. */ + abstract load(dir: string, fmt: Format, options?: MessagerOptions): void; + + /** message returns the inner protobuf message data. */ + message(): Message | undefined { + return undefined; + } + + /** processAfterLoad is invoked after this messager is loaded. Throws on failure. */ + protected processAfterLoad(): void {} + + /** processAfterLoadAll is invoked after all messagers are loaded. Throws on failure. */ + processAfterLoadAll(_hub: Hub): void {} +} + +/** + * MessagerCtor is the constructor type for a concrete Messager. + */ +export type MessagerCtor = new () => Messager; diff --git a/cmd/protoc-gen-ts-tableau-loader/embed/templates/hub.pc.ts.tpl b/cmd/protoc-gen-ts-tableau-loader/embed/templates/hub.pc.ts.tpl new file mode 100644 index 0000000..28a0d4a --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/embed/templates/hub.pc.ts.tpl @@ -0,0 +1,114 @@ +import { Messager, type MessagerCtor } from "./messager.pc.js"; +import { Format } from "./util.pc.js"; +import { type Options, parseMessagerOptionsByName } from "./load.pc.js"; +{{- range .Imports }} +import { {{ range $i, $n := .Names }}{{ if $i }}, {{ end }}{{ $n }}{{ end }} } from "./{{ .Module }}.js"; +{{- end }} + +/** + * HubOptions is the options for Hub. + */ +export interface HubOptions { + /** + * filter can only filter in certain specific messagers based on the + * condition that you provide. + */ + filter?: (name: string) => boolean; +} + +/** + * Registry manages the registration of all messager generators. + */ +export class Registry { + static readonly registrar: Map = new Map(); + + /** register registers a messager constructor. */ + static register(ctor: MessagerCtor): void { + Registry.registrar.set(new ctor().name(), ctor); + } + + /** init registers all generated messagers. */ + static init(): void { +{{- range .Messagers }} + Registry.register({{ . }}); +{{- end }} + } +} + +Registry.init(); + +/** + * Hub is the messager manager. It manages loading, accessing, and storing + * all configuration messagers. + */ +export class Hub { + private messagerMap: Map = new Map(); + private lastLoadedTime: Date | undefined; + private readonly options?: HubOptions; + + constructor(options?: HubOptions) { + this.options = options; + } + + /** + * load fills messages from files in the specified directory and format. + * Throws an Error (with the underlying cause chained) on failure. + */ + load(dir: string, fmt: Format, options?: Options): void { + const messagerMap = this.newMessagerMap(); + const opts = options ?? {}; + for (const [name, messager] of messagerMap) { + try { + messager.load(dir, fmt, parseMessagerOptionsByName(opts, name)); + } catch (e) { + throw new Error(`load ${name} failed`, { cause: e }); + } + } + const tmpHub = new Hub(); + tmpHub.setMessagerMap(messagerMap); + for (const [name, messager] of messagerMap) { + try { + messager.processAfterLoadAll(tmpHub); + } catch (e) { + throw new Error(`hub call processAfterLoadAll failed, messager: ${name}`, { cause: e }); + } + } + this.setMessagerMap(messagerMap); + } + + /** getMessagerMap returns the current messager map. */ + getMessagerMap(): Map { + return this.messagerMap; + } + + /** setMessagerMap sets the messager map. */ + setMessagerMap(map: Map): void { + this.messagerMap = map; + this.lastLoadedTime = new Date(); + } + + /** getMessager returns the messager by name. */ + getMessager(name: string): Messager | undefined { + return this.messagerMap.get(name); + } + + /** getLastLoadedTime returns the time when hub's messager map was last set. */ + getLastLoadedTime(): Date | undefined { + return this.lastLoadedTime; + } + + private newMessagerMap(): Map { + const messagerMap = new Map(); + for (const [name, ctor] of Registry.registrar) { + if (!this.options?.filter || this.options.filter(name)) { + messagerMap.set(name, new ctor()); + } + } + return messagerMap; + } +{{ range .Messagers }} + /** get{{ . }} returns the {{ . }} messager. */ + get{{ . }}(): {{ . }} | undefined { + return this.messagerMap.get("{{ . }}") as {{ . }} | undefined; + } +{{ end }}} diff --git a/cmd/protoc-gen-ts-tableau-loader/embed/util.pc.ts b/cmd/protoc-gen-ts-tableau-loader/embed/util.pc.ts new file mode 100644 index 0000000..2069c03 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/embed/util.pc.ts @@ -0,0 +1,381 @@ +import { clone, getExtension, type DescField, type DescMessage, type Message } from "@bufbuild/protobuf"; +import * as tableaupb from "../tableau/protobuf/tableau_pb.js"; + +/** + * Format specifies the format of the configuration file. + */ +export enum Format { + UNKNOWN = "unknown", + JSON = "json", + BIN = "bin", +} + +const UNKNOWN_EXT = ".unknown"; +const JSON_EXT = ".json"; +const BIN_EXT = ".binpb"; + +/** + * getFormat returns the Format determined by the file extension of the given path. + */ +export function getFormat(path: string): Format { + if (path.endsWith(JSON_EXT)) return Format.JSON; + if (path.endsWith(BIN_EXT)) return Format.BIN; + return Format.UNKNOWN; +} + +/** + * format2Ext returns the file extension corresponding to the given format. + */ +export function format2Ext(fmt: Format): string { + switch (fmt) { + case Format.JSON: + return JSON_EXT; + case Format.BIN: + return BIN_EXT; + default: + return UNKNOWN_EXT; + } +} + +// AnyMessage is a structural view over a protobuf-es message used by the +// reflection-based patch routines below. +type AnyMessage = Record; + +/** + * getSheetPatch returns the sheet-level patch type for a message descriptor. + */ +export function getSheetPatch(desc: DescMessage): tableaupb.Patch { + const opts = desc.proto.options; + if (!opts) return tableaupb.Patch.NONE; + const ws = getExtension(opts, tableaupb.worksheet); + return ws?.patch ?? tableaupb.Patch.NONE; +} + +function getFieldPatch(fd: DescField): tableaupb.Patch { + const opts = fd.proto.options; + if (!opts) return tableaupb.Patch.NONE; + const fieldOpts = getExtension(opts, tableaupb.field); + return fieldOpts?.prop?.patch ?? tableaupb.Patch.NONE; +} + +function isFieldPopulated(msg: AnyMessage, fd: DescField): boolean { + const v = msg[fd.localName]; + if (v === undefined || v === null) return false; + switch (fd.fieldKind) { + case "list": + return Array.isArray(v) && v.length > 0; + case "map": + return typeof v === "object" && Object.keys(v as object).length > 0; + case "message": + return true; + default: + if (typeof v === "bigint") return v !== 0n; + if (typeof v === "number") return v !== 0; + if (typeof v === "string") return v.length > 0; + if (typeof v === "boolean") return v; + if (v instanceof Uint8Array) return v.length > 0; + return true; + } +} + +function clearField(msg: AnyMessage, fd: DescField): void { + switch (fd.fieldKind) { + case "list": + msg[fd.localName] = []; + break; + case "map": + msg[fd.localName] = {}; + break; + default: + delete msg[fd.localName]; + break; + } +} + +/** + * patchMessage patches src into dst, which must be messages with the same descriptor. + * + * Default mechanism: + * - scalar: populated scalar fields in src are copied to dst. + * - message: populated singular messages in src are merged into dst recursively, + * or replace dst message if PATCH_REPLACE is specified for the field. + * - list: src elements are appended to dst, or replace dst list on PATCH_REPLACE. + * - map: src entries are merged into dst, or replace dst map on PATCH_REPLACE. + */ +export function patchMessage(dst: Message, src: Message, desc: DescMessage): void { + patchMessageInternal(dst as unknown as AnyMessage, src as unknown as AnyMessage, desc); +} + +function patchMessageInternal(dst: AnyMessage, src: AnyMessage, desc: DescMessage): void { + for (const fd of desc.fields) { + if (!isFieldPopulated(src, fd)) continue; + if (getFieldPatch(fd) === tableaupb.Patch.REPLACE) { + clearField(dst, fd); + } + switch (fd.fieldKind) { + case "map": + patchMap(dst, src, fd); + break; + case "list": + patchList(dst, src, fd); + break; + case "message": { + const name = fd.localName; + const srcChild = src[name] as AnyMessage; + const dstChild = dst[name] as AnyMessage | undefined; + if (dstChild === undefined || dstChild === null) { + dst[name] = clone(fd.message, srcChild as never); + } else { + patchMessageInternal(dstChild, srcChild, fd.message); + } + break; + } + default: + dst[fd.localName] = src[fd.localName]; + break; + } + } +} + +function patchList(dst: AnyMessage, src: AnyMessage, fd: DescField): void { + const srcList = src[fd.localName] as unknown[]; + let dstList = dst[fd.localName] as unknown[] | undefined; + if (!Array.isArray(dstList)) { + dstList = []; + dst[fd.localName] = dstList; + } + const isMessage = fd.fieldKind === "list" && fd.listKind === "message"; + for (const item of srcList) { + if (isMessage && fd.message) { + dstList.push(clone(fd.message, item as never)); + } else { + dstList.push(item); + } + } +} + +function patchMap(dst: AnyMessage, src: AnyMessage, fd: DescField): void { + const srcMap = src[fd.localName] as Record; + let dstMap = dst[fd.localName] as Record | undefined; + if (typeof dstMap !== "object" || dstMap === null) { + dstMap = {}; + dst[fd.localName] = dstMap; + } + const isMessageValue = fd.fieldKind === "map" && fd.mapKind === "message"; + for (const [k, v] of Object.entries(srcMap)) { + if (isMessageValue && fd.message) { + const existing = dstMap[k] as AnyMessage | undefined; + if (existing !== undefined && existing !== null) { + // NOTE: this MERGES into the existing value (differs from a simple replace). + patchMessageInternal(existing, v as AnyMessage, fd.message); + } else { + dstMap[k] = clone(fd.message, v as never); + } + } else { + dstMap[k] = v; + } + } +} + +// --------------------------------------------------------------------------- +// Index container runtime +// +// Helpers shared by generated loaders to build index, ordered index and +// ordered map containers. +// --------------------------------------------------------------------------- + +/** + * makeIndexKey serializes a tuple of multi-column index key parts into a unique + * string so it can be used as a Map key with by-value equality (JS Maps compare + * object keys by reference, which would break composite keys). + * + * Each part is tagged with its runtime type to avoid collisions between values + * that stringify to the same text (e.g. the number 1 and the string "1"), and + * parts are joined with a NUL separator that cannot appear in normal strings. + * + * Module-private: it is an implementation detail of TupleKeyMap (the only + * caller). Generated loaders never serialize keys themselves; they go through + * TupleKeyMap, so this is intentionally not exported. + */ +function makeIndexKey(parts: readonly unknown[]): string { + return parts.map((p) => `${typeof p}:${String(p)}`).join("\u0000"); +} + +/** + * compareValues compares two ordered scalar key parts of the same logical type + * (number, bigint, string or boolean), returning a negative/zero/positive + * number suitable for Array.prototype.sort. + */ +export function compareValues(a: unknown, b: unknown): number { + if (typeof a === "bigint" || typeof b === "bigint") { + const x = BigInt(a as never); + const y = BigInt(b as never); + return x < y ? -1 : x > y ? 1 : 0; + } + if (typeof a === "string" || typeof b === "string") { + const x = String(a); + const y = String(b); + return x < y ? -1 : x > y ? 1 : 0; + } + if (typeof a === "boolean" || typeof b === "boolean") { + return (a ? 1 : 0) - (b ? 1 : 0); + } + return (a as number) - (b as number); +} + +/** + * compareTuples compares two equal-length tuples of ordered scalar parts + * lexicographically. + */ +export function compareTuples(a: readonly unknown[], b: readonly unknown[]): number { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) { + const c = compareValues(a[i], b[i]); + if (c !== 0) return c; + } + return a.length - b.length; +} + +/** + * sortMapByKey sorts a Map in place by key using the given comparator and + * returns the same Map instance. JS Maps preserve insertion order, so the + * entries are re-inserted in sorted order (an "ordered map"). Sorting in place + * (rather than returning a new Map) keeps the Map reference stable across + * reloads, so views cached over it (e.g. TupleKeyMap) stay valid. + */ +export function sortMapByKey(map: Map, cmp: (a: K, b: K) => number): Map { + const entries = [...map.entries()].sort((x, y) => cmp(x[0], y[0])); + map.clear(); + for (const [k, v] of entries) map.set(k, v); + return map; +} + +/** + * OrderedMapValue is the value stored at a non-leaf level of a nested ordered + * map. It pairs the next-level ordered (sub-)map with the message value at the + * current level, mirroring the Pair node the Go / C++ / C# + * loaders expose (Go .First/.Second, C# .Item1/.Item2, C++ .first/.second), so + * callers can both descend into the sub-map (.first) and read the level's own + * value (.second). Leaf levels store the plain value directly, without this + * wrapper. + * + * Type parameters: + * - M: the next-level ordered map type (a Map keyed in sorted order). + * - E: the message value at the current level. + */ +export interface OrderedMapValue { + /** first is the next-level ordered (sub-)map, sorted by key. */ + first: M; + /** second is the value at the current level. */ + second: E; +} + +/** + * TupleKeyMap is the multi-column index / ordered-index container. A composite + * key (e.g. [param, extType]) cannot be used directly as a JS Map key (Maps + * compare object keys by reference), so it owns an internal Map keyed by the + * opaque serialized string of the key columns (see makeIndexKey) plus a side + * table mapping that string back to the original key tuple. Lookups and + * iteration accept / return by-value tuple keys, mirroring the structured + * composite keys the Go / C++ / C# loaders expose for multi-column indexes + * (where the map key is a comparable key struct). + * + * Unlike a plain read-only view, it owns its storage and exposes a small build + * API (clear / getOrSet / sortKeys) used by the generated processAfterLoad, so + * a messager holds this single container per multi-column index (or per + * multi-key leveled container) instead of a raw Map, a separate serialized-key + * -> tuple side table, and a cached view. It is mutated in place across + * reloads, so its reference stays stable and callers holding the container + * returned by a finder stay valid. + * + * Type parameters: + * - K: the readonly tuple of key columns (e.g. readonly [id: number, + * name: string]); the generated loaders pass a named alias (e.g. + * ItemConf.Index_AwardItemKey) so lookups / iteration are fully typed. K is + * purely compile-time: the runtime always serializes via makeIndexKey, so + * the key type carries no runtime cost. + * - E: the value type stored under each key tuple. This is a plain tuple-keyed + * map (one key -> one value), so the value is whatever the caller stores: + * a value list (E = V[]) for a leaf multi-column index, or an inner index + * container for a multi-key leveled container. + */ +export class TupleKeyMap { + private readonly map = new Map(); + private readonly tuples = new Map(); + + /** size returns the number of key buckets. */ + get size(): number { + return this.map.size; + } + + /** clear empties the container (called at the start of each (re)build). */ + clear(): void { + this.map.clear(); + this.tuples.clear(); + } + + /** + * getOrSet returns the value stored under the given key columns, creating and + * inserting it via make() on first access (and recording the original key + * tuple then, for sortKeys and by-value key iteration). makeIndexKey is + * computed exactly once. The build side is generator-driven, so keyParts is + * loosely typed; the query side (get/has/keys/entries) stays strongly typed. + */ + getOrSet(keyParts: readonly unknown[], make: () => E): E { + const k = makeIndexKey(keyParts); + let v = this.map.get(k); + if (v === undefined) { + v = make(); + this.map.set(k, v); + this.tuples.set(k, keyParts); + } + return v; + } + + /** + * sortKeys reorders the buckets by comparing their key tuples + * lexicographically (used by ordered indexes). JS Maps preserve insertion + * order, so entries are re-inserted in sorted order. Sorting in place keeps + * the container reference stable across reloads. + */ + sortKeys(): void { + const entries = [...this.map.entries()].sort((x, y) => + compareTuples(this.tuples.get(x[0]) ?? [], this.tuples.get(y[0]) ?? []), + ); + this.map.clear(); + for (const [k, v] of entries) this.map.set(k, v); + } + + /** get returns the value stored under the given key columns, or undefined. */ + get(key: K): E | undefined { + return this.map.get(makeIndexKey(key)); + } + + /** has reports whether the given key columns are present. */ + has(key: K): boolean { + return this.map.has(makeIndexKey(key)); + } + + /** keys iterates the key-column tuples in container (sorted, if ordered) order. */ + *keys(): IterableIterator { + for (const k of this.map.keys()) { + yield (this.tuples.get(k) ?? []) as unknown as K; + } + } + + /** values iterates the values in container order. */ + values(): IterableIterator { + return this.map.values(); + } + + /** entries iterates [keyColumns, value] pairs in container order. */ + *entries(): IterableIterator<[K, E]> { + for (const [k, v] of this.map) { + yield [(this.tuples.get(k) ?? []) as unknown as K, v]; + } + } + + [Symbol.iterator](): IterableIterator<[K, E]> { + return this.entries(); + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/helper/helper.go b/cmd/protoc-gen-ts-tableau-loader/helper/helper.go new file mode 100644 index 0000000..2af2e7b --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/helper/helper.go @@ -0,0 +1,259 @@ +package helper + +import ( + "strings" + + "github.com/iancoleman/strcase" + "github.com/tableauio/loader/internal/genhelper" + "github.com/tableauio/tableau/proto/tableaupb" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +// GenerateFileHeader writes the auto-generated file header comment block, +// including version info and source path. If file is non-nil, the source path +// (or deprecation notice) is included. +func GenerateFileHeader(gen *protogen.Plugin, file *protogen.File, g *protogen.GeneratedFile, version string) { + g.P("// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT.") + g.P("// versions:") + g.P("// - protoc-gen-ts-tableau-loader v", version) + g.P("// - protoc ", genhelper.ProtocVersion(gen)) + genhelper.GenerateSourcePath(file, g) + g.P("/* eslint-disable */") + g.P() +} + +// LocalTypeName returns the protobuf-es local type name for a message or enum, +// which joins the nested type path with underscores after stripping the package +// (e.g. "protoconf.ThemeConf.Theme" -> "ThemeConf_Theme", +// "protoconf.UseEffect.Type" -> "UseEffect_Type"). Works for top-level and +// nested descriptors alike. +func LocalTypeName(d protoreflect.Descriptor) string { + full := string(d.FullName()) + if pkg := string(d.ParentFile().Package()); pkg != "" { + full = strings.TrimPrefix(full, pkg+".") + } + return strings.ReplaceAll(full, ".", "_") +} + +// LocalSchemaName returns the protobuf-es schema export name for any message, +// top-level or nested (e.g. "protoconf.ThemeConf.Theme" -> "ThemeConf_ThemeSchema"). +func LocalSchemaName(d protoreflect.Descriptor) string { + return LocalTypeName(d) + "Schema" +} + +// PackageAlias converts a protobuf package name into a valid TypeScript +// namespace-import alias by replacing dots with underscores (e.g. "foo.bar" -> +// "foo_bar"). This alias is used as the namespace for a package's barrel import +// (import * as ), so generated loaders qualify types exactly like the +// loaders of other languages do (e.g. protoconf.ItemConf, base.Hero), keeping +// cross-language naming consistent. Empty packages fall back to "pb". +func PackageAlias(pkg string) string { + if pkg == "" { + return "pb" + } + return strings.ReplaceAll(pkg, ".", "_") +} + +// ScalarTSType returns the plain TypeScript type for a protobuf scalar kind. +// ok is false for non-scalar kinds (message/group/enum), letting callers handle +// those via alias-qualified named types instead. +func ScalarTSType(kind protoreflect.Kind) (string, bool) { + switch kind { + case protoreflect.BoolKind: + return "boolean", true + case protoreflect.StringKind: + return "string", true + case protoreflect.BytesKind: + return "Uint8Array", true + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, + protoreflect.Uint32Kind, protoreflect.Fixed32Kind, + protoreflect.FloatKind, protoreflect.DoubleKind: + return "number", true + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, + protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return "bigint", true + default: + return "", false + } +} + +// MapKey aliases the cross-language shared key descriptor; see genhelper.MapKey. +// The TypeScript loader no longer needs its own wrapper: the only TS-specific +// behaviour (stringifying 64-bit / bool keys) is derived from Type in IndexExpr. +type MapKey = genhelper.MapKey + +// tsParamFormatter formats a key as a TypeScript parameter declaration +// ("id: number"). +type tsParamFormatter struct{} + +func (tsParamFormatter) FormatParam(key MapKey) string { return key.Name + ": " + key.Type } + +// MapKeySlice is the shared cross-language key slice (see genhelper.MapKeySlice) +// specialized with TypeScript parameter formatting. All slice methods (AddMapKey +// / GenGetParams / GenGetArguments / ...) come from genhelper. +type MapKeySlice = genhelper.MapKeySlice[tsParamFormatter] + +// IndexExpr returns the expression used to index the underlying protobuf-es map +// object. 64-bit ("bigint") and bool ("boolean") keys must be stringified +// because their JS object keys are always stored as strings. +func IndexExpr(k MapKey) string { + if k.Type == "bigint" || k.Type == "boolean" { + return k.Name + ".toString()" + } + return k.Name +} + +// ParseMapKey returns the MapKey metadata (param type) for a map field's key +// descriptor (keyFd must be a map key descriptor). +func ParseMapKey(keyFd protoreflect.FieldDescriptor, name string) MapKey { + key := MapKey{Name: name} + switch keyFd.Kind() { + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, + protoreflect.Uint32Kind, protoreflect.Fixed32Kind: + key.Type = "number" + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, + protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + key.Type = "bigint" + case protoreflect.BoolKind: + key.Type = "boolean" + case protoreflect.StringKind: + key.Type = "string" + default: + // Map keys can only be integral, bool, or string. + key.Type = "string" + } + return key +} + +// ParseLeveledMapPrefix returns the leveled-map name fragment used to build a +// leveled-container key alias, mirroring the Go/C++/C# loaders. For a +// message-valued map it is the value message's name local to the messager +// (joined with "_", e.g. "Activity_Chapter"); for a scalar-valued map it is the +// value kind string. md is the owning messager message. +func ParseLeveledMapPrefix(md protoreflect.MessageDescriptor, mapFd protoreflect.FieldDescriptor) string { + if mapFd.MapValue().Kind() == protoreflect.MessageKind { + localMsgProtoName := strings.TrimPrefix(string(mapFd.MapValue().Message().FullName()), string(md.FullName())+".") + return strings.ReplaceAll(localMsgProtoName, ".", "_") + } + return mapFd.MapValue().Kind().String() +} + +// ParseMapFieldNameAsFuncParam returns a safe lowerCamelCase getter parameter +// name for a map field key. If the map value is a message type, the first field +// name of the value message is used; otherwise the tableau field option "key" +// is used. +func ParseMapFieldNameAsFuncParam(fd protoreflect.FieldDescriptor) string { + opts := fd.Options().(*descriptorpb.FieldOptions) + fdOpts := proto.GetExtension(opts, tableaupb.E_Field).(*tableaupb.FieldOptions) + name := fdOpts.GetKey() + if fd.MapValue().Kind() == protoreflect.MessageKind { + valueFd := fd.MapValue().Message().Fields().Get(0) + name = string(valueFd.Name()) + } + if name == "" { + return "" + } + return escapeIdentifier(strcase.ToLowerCamel(name)) +} + +// FieldLocalName returns the protobuf-es local (JS property) name for a field. +// +// IMPORTANT: protobuf-es derives a field's JS property name with protoCamelCase +// (NOT strcase): it never lowercases the first letter, preserves existing +// capitals, and only capitalizes the letter following an underscore or digit. +// Reserved object properties (constructor/toString/toJSON/valueOf) get a "$" +// suffix. We must match this exactly so generated property access (e.g. for +// index fields like "HTTPServer", "SEASON_RANK", "fight_1v1_") lines up with +// the protobuf-es generated types. +func FieldLocalName(fd protoreflect.FieldDescriptor) string { + return safeObjectProperty(protoCamelCase(string(fd.Name()))) +} + +// protoCamelCase converts a protobuf field name to its protobuf-es local name, +// matching @bufbuild/protobuf's protoCamelCase implementation. +func protoCamelCase(snakeCase string) string { + var b strings.Builder + capNext := false + for _, c := range snakeCase { + switch { + case c == '_': + capNext = true + case c >= '0' && c <= '9': + b.WriteRune(c) + capNext = false + default: + if capNext { + capNext = false + if c >= 'a' && c <= 'z' { + c = c - 'a' + 'A' + } + } + b.WriteRune(c) + } + } + return b.String() +} + +// reservedObjectProperties are JS object properties protobuf-es escapes with a +// trailing "$" to avoid clashing with built-ins. +var reservedObjectProperties = map[string]bool{ + "constructor": true, "toString": true, "toJSON": true, "valueOf": true, +} + +// safeObjectProperty appends "$" to reserved object property names, matching +// protobuf-es's safeObjectProperty. +func safeObjectProperty(name string) string { + if reservedObjectProperties[name] { + return name + "$" + } + return name +} + +// IndexFieldNameAsKeyStructFieldName returns the CamelCase logical name of an +// index field, used to derive parameter names. For list fields the tableau +// field option name is used; otherwise the protobuf field name is used. +func IndexFieldNameAsKeyStructFieldName(fd protoreflect.FieldDescriptor) string { + if fd.IsList() { + opts := fd.Options().(*descriptorpb.FieldOptions) + fdOpts := proto.GetExtension(opts, tableaupb.E_Field).(*tableaupb.FieldOptions) + return strcase.ToCamel(fdOpts.GetName()) + } + return strcase.ToCamel(string(fd.Name())) +} + +// IndexFieldNameAsFuncParam returns a safe lowerCamelCase finder parameter name +// for an index field. +func IndexFieldNameAsFuncParam(fd protoreflect.FieldDescriptor) string { + return escapeIdentifier(strcase.ToLowerCamel(IndexFieldNameAsKeyStructFieldName(fd))) +} + +// TSEmptyValue returns the TypeScript empty/default literal for a field's type, +// used as the fallback when accessing a possibly-absent nested index field. +func TSEmptyValue(fd protoreflect.FieldDescriptor) string { + switch fd.Kind() { + case protoreflect.BoolKind: + return "false" + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, + protoreflect.Uint32Kind, protoreflect.Fixed32Kind, + protoreflect.FloatKind, protoreflect.DoubleKind, protoreflect.EnumKind: + return "0" + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, + protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return "0n" + case protoreflect.StringKind: + return `""` + case protoreflect.BytesKind: + return "new Uint8Array()" + default: + return "undefined" + } +} + +// Indent returns a string of 2*depth spaces, used for indenting generated +// TypeScript code blocks at the specified nesting depth. +func Indent(depth int) string { + return strings.Repeat(" ", depth) +} diff --git a/cmd/protoc-gen-ts-tableau-loader/helper/keyword.go b/cmd/protoc-gen-ts-tableau-loader/helper/keyword.go new file mode 100644 index 0000000..d903a8a --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/helper/keyword.go @@ -0,0 +1,23 @@ +package helper + +// tsReservedWords are TypeScript/JavaScript reserved words that cannot be used +// as bare identifiers for function parameters. +var tsReservedWords = map[string]bool{ + "break": true, "case": true, "catch": true, "class": true, "const": true, + "continue": true, "debugger": true, "default": true, "delete": true, "do": true, + "else": true, "enum": true, "export": true, "extends": true, "false": true, + "finally": true, "for": true, "function": true, "if": true, "import": true, + "in": true, "instanceof": true, "new": true, "null": true, "return": true, + "super": true, "switch": true, "this": true, "throw": true, "true": true, + "try": true, "typeof": true, "var": true, "void": true, "while": true, + "with": true, "let": true, "static": true, "yield": true, "await": true, +} + +// escapeIdentifier escapes a TypeScript reserved word by appending an +// underscore, producing a valid identifier. +func escapeIdentifier(name string) string { + if tsReservedWords[name] { + return name + "_" + } + return name +} diff --git a/cmd/protoc-gen-ts-tableau-loader/hub.go b/cmd/protoc-gen-ts-tableau-loader/hub.go new file mode 100644 index 0000000..45d9e48 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/hub.go @@ -0,0 +1,47 @@ +package main + +import ( + "text/template" + + "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" + "github.com/tableauio/loader/internal/xproto" + "google.golang.org/protobuf/compiler/protogen" +) + +var tpl = template.Must(template.New("").ParseFS(efs, "embed/templates/*")) + +// importEntry describes a single ES module import in the generated hub. +type importEntry struct { + Names []string // messager class names imported from the module + Module string // module specifier (without leading "./" or trailing ".js") +} + +// hubData is the template data for the generated hub file. +type hubData struct { + Imports []importEntry + Messagers []string +} + +// generateHub generates the hub file (registry + hub manager). +func generateHub(gen *protogen.Plugin) { + filename := "hub.pc.ts" + g := gen.NewGeneratedFile(filename, "") + helper.GenerateFileHeader(gen, nil, g, version) + + pfs := xproto.ParseProtoFiles(gen) + var data hubData + for _, pf := range pfs { + if len(pf.Messagers) == 0 { + continue + } + data.Imports = append(data.Imports, importEntry{ + Names: pf.Messagers, + Module: pf.Name + ".pc", + }) + data.Messagers = append(data.Messagers, pf.Messagers...) + } + + if err := tpl.Lookup("hub.pc.ts.tpl").Execute(g, data); err != nil { + panic(err) + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/index.go b/cmd/protoc-gen-ts-tableau-loader/index.go new file mode 100644 index 0000000..0e6ebf3 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/index.go @@ -0,0 +1,952 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" + "github.com/tableauio/loader/internal/index" + "github.com/tableauio/loader/internal/loadutil" + "github.com/tableauio/loader/internal/options" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// indexGen generates index / ordered index / ordered map support for a single +// worksheet messager. +// +// It mirrors the leveled-container model of the Go / C# / C++ generators: an +// index may be declared on a column that lives at any container level, and for +// every map ancestor of that level a "leveled" container is generated so the +// index can be queried scoped to a specific upper map key (e.g. findItem1). +// +// JavaScript Maps compare object keys by reference, so a single upper key +// (k1) buckets into a native Map keyed by its raw value, while composite upper +// keys (k1..ki, i>=2) bucket into a TupleKeyMap keyed by the upper key tuple +// (which serializes it internally via makeIndexKey). +type indexGen struct { + g *protogen.GeneratedFile + descriptor *index.IndexDescriptor + message *protogen.Message + pkgs *pbPackages + + // keys holds, in order, the map key of every ancestor map level whose + // deeper level still needs an index. They are the upper keys used by + // leveled containers/finders. Names are deduplicated (e.g. id -> id3). + keys helper.MapKeySlice +} + +func newIndexGen(g *protogen.GeneratedFile, descriptor *index.IndexDescriptor, message *protogen.Message, pkgs *pbPackages) *indexGen { + x := &indexGen{g: g, descriptor: descriptor, message: message, pkgs: pkgs} + x.initLevelKeys() + return x +} + +// initLevelKeys collects the upper map keys used to build leveled containers. +func (x *indexGen) initLevelKeys() { + for lm := x.descriptor.LevelMessage; lm != nil; lm = lm.NextLevel { + if fd := lm.FD; fd != nil && fd.IsMap() { + // Only collect keys when a deeper level needs an index, because the + // keys are used solely for building leveled (upper-level) containers. + if !lm.NextLevel.NeedGenAnyIndex() { + break + } + paramName := helper.ParseMapFieldNameAsFuncParam(fd) + key := helper.ParseMapKey(fd.MapKey(), paramName) + key.Fd = fd + x.keys = x.keys.AddMapKey(key) + } + } +} + +// firstMapField returns the first map field of a message descriptor, or nil if +// it has none. The ordered map walks the message tree along this first-map-field +// chain (independently of indexes), mirroring the Go / C++ / C# loaders. +func firstMapField(md protoreflect.MessageDescriptor) protoreflect.FieldDescriptor { + for i := 0; i < md.Fields().Len(); i++ { + fd := md.Fields().Get(i) + if fd.IsMap() { + return fd + } + } + return nil +} + +// nextLevelMapField returns the first map field of a map field's value message +// (the next ordered-map level down), or nil if the value is a scalar/enum or a +// message that holds no map (i.e. fd is the leaf level). +func nextLevelMapField(fd protoreflect.FieldDescriptor) protoreflect.FieldDescriptor { + if fd.MapValue().Kind() == protoreflect.MessageKind { + return firstMapField(fd.MapValue().Message()) + } + return nil +} + +func (x *indexGen) needIndex() bool { + return x.descriptor.LevelMessage.NeedGenIndex() && options.NeedGenIndex(x.message.Desc, options.LangTS) +} + +func (x *indexGen) needOrderedIndex() bool { + return x.descriptor.LevelMessage.NeedGenOrderedIndex() && options.NeedGenOrderedIndex(x.message.Desc, options.LangTS) +} + +func (x *indexGen) needOrderedMap() bool { + return firstMapField(x.message.Desc) != nil && options.NeedGenOrderedMap(x.message.Desc, options.LangTS) +} + +// needNestedOrderedMap reports whether the ordered map has 2+ levels, i.e. its +// value nodes are OrderedMapValue pairs (sub-map + value) rather than plain +// leaf values. Used to decide whether the OrderedMapValue runtime type must be +// imported. +func (x *indexGen) needNestedOrderedMap() bool { + return x.needOrderedMap() && nextLevelMapField(firstMapField(x.message.Desc)) != nil +} + +// NeedGenerate reports whether any index/ordered-index/ordered-map code is emitted. +func (x *indexGen) NeedGenerate() bool { + return x.needIndex() || x.needOrderedIndex() || x.needOrderedMap() +} + +// ---------------------------------------------------------------------------- +// Type / name helpers +// ---------------------------------------------------------------------------- + +// valueType returns the package-qualified TypeScript type of an index's value +// message (the map/list value at the index's level). +func (x *indexGen) valueType(idx *index.LevelIndex) string { + return x.pkgs.aliasOf(idx.MD) + "." + helper.LocalTypeName(idx.MD) +} + +// keyTSType returns the TypeScript type used as a single-column index key. +func (x *indexGen) keyTSType(fd protoreflect.FieldDescriptor) string { + switch fd.Kind() { + case protoreflect.EnumKind: + return x.pkgs.aliasOf(fd.Enum()) + "." + helper.LocalTypeName(fd.Enum()) + case protoreflect.MessageKind, protoreflect.GroupKind: + // Only Timestamp/Duration are valid index keys here; keyed by seconds. + return "bigint" + default: + s, _ := helper.ScalarTSType(fd.Kind()) + return s + } +} + +// indexKeyType returns the TypeScript Map key type for an index (single-column +// uses the column's scalar/enum type; multi-column uses a serialized string). +func (x *indexGen) indexKeyType(idx *index.LevelIndex) string { + if len(idx.ColFields) == 1 { + return x.keyTSType(idx.ColFields[0].FD) + } + return "string" +} + +// params builds the finder parameter slice for an index's columns. +func (x *indexGen) params(idx *index.LevelIndex) helper.MapKeySlice { + var keys helper.MapKeySlice + for _, field := range idx.ColFields { + keys = keys.AddMapKey(helper.MapKey{ + Type: x.keyTSType(field.FD), + Name: helper.IndexFieldNameAsFuncParam(field.FD), + }) + } + return keys +} + +// containerField returns the private container field name for index idx at +// leveled depth i (i==0 is the global container). +func (x *indexGen) containerField(idx *index.LevelIndex, ordered bool, i int) string { + base := "index" + idx.Name() + "Map" + if ordered { + base = "orderedIndex" + idx.Name() + "Map" + } + if i == 0 { + return "#" + base + } + return fmt.Sprintf("#%s%d", base, i) +} + +const orderedMapField = "#orderedMap" + +// ---------------------------------------------------------------------------- +// Type aliases +// +// Mirroring the named container types of the C#/C++/Go loaders, every index / +// ordered index / ordered map container gets a readable type alias declared in +// a namespace merged with the messager class (e.g. ItemConf.Index_AwardItemMap). +// The namespace only contains `type` members, so it is fully erased at compile +// time (no runtime overhead) while making the finder signatures self-documenting. +// ---------------------------------------------------------------------------- + +// aliasName returns the bare type-alias name for an index's container (e.g. +// Index_AwardItemMap / OrderedIndex_ParamExtTypeMap), as declared inside the +// messager namespace. +func (x *indexGen) aliasName(idx *index.LevelIndex, ordered bool) string { + if ordered { + return "OrderedIndex_" + idx.Name() + "Map" + } + return "Index_" + idx.Name() + "Map" +} + +// indexType returns the messager-qualified alias used inside the class (e.g. +// ItemConf.Index_AwardItemMap), so field declarations and finder signatures +// reference the readable named type rather than an inline Map/TupleKeyMap. +func (x *indexGen) indexType(idx *index.LevelIndex, ordered bool) string { + return string(x.message.Desc.Name()) + "." + x.aliasName(idx, ordered) +} + +// keyAliasName returns the bare composite-key tuple alias name for a +// multi-column index (e.g. Index_AwardItemKey / OrderedIndex_ParamExtTypeKey), +// declared inside the messager namespace. +func (x *indexGen) keyAliasName(idx *index.LevelIndex, ordered bool) string { + if ordered { + return "OrderedIndex_" + idx.Name() + "Key" + } + return "Index_" + idx.Name() + "Key" +} + +// keyAliasType returns the messager-qualified composite-key alias (e.g. +// ItemConf.Index_AwardItemKey), used to explicitly parameterize TupleKeyMap at +// its (invariant-in-K) construction sites. +func (x *indexGen) keyAliasType(idx *index.LevelIndex, ordered bool) string { + return string(x.message.Desc.Name()) + "." + x.keyAliasName(idx, ordered) +} + +// keyTupleType returns the labeled readonly tuple type of a multi-column +// index's key columns (e.g. "readonly [id: number, name: string]"). The labels +// are purely for editor hints; assignability ignores them. +func (x *indexGen) keyTupleType(idx *index.LevelIndex) string { + var parts []string + for _, field := range idx.ColFields { + parts = append(parts, helper.IndexFieldNameAsFuncParam(field.FD)+": "+x.keyTSType(field.FD)) + } + return "readonly [" + strings.Join(parts, ", ") + "]" +} + +// upperKeyTupleType returns the labeled readonly tuple type of the first i +// upper-level map keys (e.g. "readonly [activityId: bigint, chapterId: +// number]"), used as the right-hand side of the leveled-container key alias. +func (x *indexGen) upperKeyTupleType(i int) string { + var parts []string + for _, k := range x.keys[:i] { + parts = append(parts, k.Name+": "+k.Type) + } + return "readonly [" + strings.Join(parts, ", ") + "]" +} + +// upperKeyAliasName returns the bare composite upper-key tuple alias name for +// the depth-i leveled containers (e.g. LevelIndex_Activity_ChapterKey), +// declared inside the messager namespace. It mirrors the Go loader's +// _LevelIndex_Key struct, the messager prefix being supplied +// by the enclosing namespace. Only meaningful for i>=2 (composite upper keys). +func (x *indexGen) upperKeyAliasName(i int) string { + return "LevelIndex_" + helper.ParseLeveledMapPrefix(x.message.Desc, x.keys[i-1].Fd) + "Key" +} + +// upperKeyAliasType returns the messager-qualified composite upper-key alias +// (e.g. ActivityConf.LevelIndex_Activity_ChapterKey), used to type and +// construct the i>=2 leveled TupleKeyMap containers. +func (x *indexGen) upperKeyAliasType(i int) string { + return string(x.message.Desc.Name()) + "." + x.upperKeyAliasName(i) +} + +// newLeafContainer returns the construction expression for a multi-column +// index's leaf TupleKeyMap (key tuple -> value list), explicitly parameterized +// because TupleKeyMap is invariant in K and constructed empty. +func (x *indexGen) newLeafContainer(idx *index.LevelIndex, ordered bool) string { + return "new TupleKeyMap<" + x.keyAliasType(idx, ordered) + ", " + x.valueType(idx) + "[]>()" +} + +// orderedMapAliasOf returns the bare ordered-map alias name for a map field +// (e.g. OrderedMap_ActivityMap / OrderedMap_protoconf_SectionMap / +// OrderedMap_int32Map), using the same leveled-map prefix as the Go / C++ / C# +// loaders so the cross-language container names line up. +func (x *indexGen) orderedMapAliasOf(mapFd protoreflect.FieldDescriptor) string { + return "OrderedMap_" + helper.ParseLeveledMapPrefix(x.message.Desc, mapFd) + "Map" +} + +// orderedMapValueAliasOf returns the bare ordered-map value (pair) alias name +// for an intermediate map field (e.g. OrderedMap_ActivityValue), used for the +// OrderedMapValue node alias of a non-leaf level. +func (x *indexGen) orderedMapValueAliasOf(mapFd protoreflect.FieldDescriptor) string { + return "OrderedMap_" + helper.ParseLeveledMapPrefix(x.message.Desc, mapFd) + "Value" +} + +// orderedMapTypeOf returns the messager-qualified ordered-map alias for a map +// field (e.g. ActivityConf.OrderedMap_Activity_ChapterMap). +func (x *indexGen) orderedMapTypeOf(mapFd protoreflect.FieldDescriptor) string { + return string(x.message.Desc.Name()) + "." + x.orderedMapAliasOf(mapFd) +} + +// orderedMapType returns the messager-qualified top-level (1st-level) ordered-map +// alias. +func (x *indexGen) orderedMapType() string { + return x.orderedMapTypeOf(firstMapField(x.message.Desc)) +} + +// genOrderedMapAliases recursively emits the nested ordered-map type aliases for +// the first-map-field chain rooted at md. Each level is a Map keyed in sorted +// order; an intermediate level's value is an OrderedMapValue pair (next-level +// sub-map + this level's message value), while the leaf level stores its plain +// value directly — mirroring the TreeMap> / leaf +// TreeMap shape of the Go / C++ / C# loaders. Deeper aliases are +// emitted first so the file reads inner-to-outer like the Go loader (all +// aliases share one namespace, so referencing order does not matter). +func (x *indexGen) genOrderedMapAliases(md protoreflect.MessageDescriptor, depth int) { + fd := firstMapField(md) + if fd == nil { + return + } + nextFd := nextLevelMapField(fd) + if fd.MapValue().Kind() == protoreflect.MessageKind { + x.genOrderedMapAliases(fd.MapValue().Message(), depth+1) + } + k := helper.ParseMapKey(fd.MapKey(), "").Type + alias := x.orderedMapAliasOf(fd) + ordinal := loadutil.Ordinal(depth) + if nextFd != nil { + valueAlias := x.orderedMapValueAliasOf(fd) + nextMap := x.orderedMapAliasOf(nextFd) + curr := mapValueType(fd, x.pkgs) + x.g.P(helper.Indent(1), "/** ", valueAlias, " is a ", ordinal, "-level node: its next-level sub-map (first) plus this level's value (second). */") + x.g.P(helper.Indent(1), "export type ", valueAlias, " = OrderedMapValue<", nextMap, ", ", curr, ">;") + x.g.P(helper.Indent(1), "/** ", alias, " is the ", ordinal, "-level ordered map: key -> node (sorted by key). */") + x.g.P(helper.Indent(1), "export type ", alias, " = Map<", k, ", ", valueAlias, ">;") + return + } + v := mapValueType(fd, x.pkgs) + x.g.P(helper.Indent(1), "/** ", alias, " is the ", ordinal, "-level (leaf) ordered map: key -> value (sorted by key). */") + x.g.P(helper.Indent(1), "export type ", alias, " = Map<", k, ", ", v, ">;") +} + +// GenTypeAliases emits the messager namespace holding the index / ordered index +// / ordered map type aliases. Declaration-merged with the class, it is purely +// type-level and erased at runtime. +func (x *indexGen) GenTypeAliases() { + if !x.NeedGenerate() { + return + } + name := string(x.message.Desc.Name()) + x.g.P() + x.g.P("// Type aliases for the index / ordered index / ordered map containers,") + x.g.P("// mirroring the named container types of the other-language loaders so the") + x.g.P("// finder signatures read clearly (e.g. ", name, ".Index_XxxMap).") + x.g.P("export namespace ", name, " {") + if x.needOrderedMap() { + x.genOrderedMapAliases(x.message.Desc, 1) + } + // Composite upper-key tuple aliases shared by every depth-i (i>=2) leveled + // index / ordered-index container, mirroring the Go loader's + // _LevelIndex_Key structs. A single upper key (depth 1) + // buckets into a native Map by its raw value, so no alias is needed there. + for i := 2; i <= len(x.keys); i++ { + alias := x.upperKeyAliasName(i) + x.g.P(helper.Indent(1), "/** ", alias, " is the composite upper map key (k1..k", i, ") of the ", loadutil.Ordinal(i), "-level leveled containers. */") + x.g.P(helper.Indent(1), "export type ", alias, " = ", x.upperKeyTupleType(i), ";") + } + if x.needIndex() { + x.eachIndex(false, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genAliasDecl(idx, false) + }) + } + if x.needOrderedIndex() { + x.eachIndex(true, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genAliasDecl(idx, true) + }) + } + x.g.P("}") +} + +// genAliasDecl emits one index/ordered-index alias. Single-column indexes alias +// the native Map; multi-column indexes first declare a labeled readonly tuple +// for the composite key (e.g. Index_AwardItemKey = readonly [id: number, +// name: string]) and alias a TupleKeyMap parameterized by it, so the finders' +// keys are fully typed instead of an opaque unknown[]. TupleKeyMap is the +// owning multi-column container (key tuple -> value list), keyed internally by +// the opaque serialized composite key, which is an internal serialization +// detail. +func (x *indexGen) genAliasDecl(idx *index.LevelIndex, ordered bool) { + v := x.valueType(idx) + alias := x.aliasName(idx, ordered) + label := "index" + if ordered { + label = "ordered index" + } + if len(idx.ColFields) > 1 { + keyAlias := x.keyAliasName(idx, ordered) + x.g.P(helper.Indent(1), "/** ", keyAlias, " is the composite key of ", label, ": key(", idx.Index, "). */") + x.g.P(helper.Indent(1), "export type ", keyAlias, " = ", x.keyTupleType(idx), ";") + x.g.P(helper.Indent(1), "/** ", alias, " is the ", label, " map: key(", idx.Index, ") -> values. */") + x.g.P(helper.Indent(1), "export type ", alias, " = TupleKeyMap<", keyAlias, ", ", v, "[]>;") + return + } + x.g.P(helper.Indent(1), "/** ", alias, " is the ", label, " map: key(", idx.Index, ") -> values. */") + x.g.P(helper.Indent(1), "export type ", alias, " = Map<", x.indexKeyType(idx), ", ", v, "[]>;") +} + +// eachIndex iterates every index (or ordered index) across all levels. +func (x *indexGen) eachIndex(ordered bool, fn func(lm *index.LevelMessage, idx *index.LevelIndex)) { + for lm := x.descriptor.LevelMessage; lm != nil; lm = lm.NextLevel { + indexes := lm.Indexes + if ordered { + indexes = lm.OrderedIndexes + } + for _, idx := range indexes { + fn(lm, idx) + } + } +} + +// ---------------------------------------------------------------------------- +// Declarations +// ---------------------------------------------------------------------------- + +// GenDecls emits the private container field declarations. +func (x *indexGen) GenDecls() { + if x.needOrderedMap() { + x.g.P(helper.Indent(1), orderedMapField, ": ", x.orderedMapType(), " = new Map();") + } + if x.needIndex() { + x.eachIndex(false, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genContainerDecls(lm, idx, false) + }) + } + if x.needOrderedIndex() { + x.eachIndex(true, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genContainerDecls(lm, idx, true) + }) + } +} + +func (x *indexGen) genContainerDecls(lm *index.LevelMessage, idx *index.LevelIndex, ordered bool) { + at := x.indexType(idx, ordered) + multi := len(idx.ColFields) > 1 + // The global (0-level) container: single-column indexes use the native Map + // alias; multi-column indexes use the owning leaf TupleKeyMap (key tuple -> + // value list), which holds the serialized-key -> tuple side table + // internally. The field's alias annotation (at) resolves to the fully + // parameterized TupleKeyMap, so the constructor's type arguments are + // inferred from it contextually and need not be repeated on the `new` side. + if multi { + x.g.P(helper.Indent(1), x.containerField(idx, ordered, 0), ": ", at, " = new TupleKeyMap();") + } else { + x.g.P(helper.Indent(1), x.containerField(idx, ordered, 0), ": ", at, " = new Map();") + } + // Leveled containers bucket the global container alias under the upper map + // key(s): a single upper key (i==1) uses a native Map keyed by its raw + // value; composite upper keys (i>=2) use a TupleKeyMap keyed by the upper + // key tuple alias (serialized internally), so no makeIndexKey is needed at + // the call sites. + for i := 1; i < lm.LeveledContainerDepth(); i++ { + if i == 1 { + x.g.P(helper.Indent(1), x.containerField(idx, ordered, i), ": Map<", x.keys[0].Type, ", ", at, "> = new Map();") + } else { + ut := x.upperKeyAliasType(i) + // The field carries the full TupleKeyMap annotation, so the + // constructor's type arguments are inferred from it contextually; + // repeating them on the `new` side would be redundant. + x.g.P(helper.Indent(1), x.containerField(idx, ordered, i), ": TupleKeyMap<", ut, ", ", at, "> = new TupleKeyMap();") + } + } +} + +// ---------------------------------------------------------------------------- +// processAfterLoad body +// ---------------------------------------------------------------------------- + +// GenProcessAfterLoadBody emits the body of the processAfterLoad override +// (statements at indent level 2). +func (x *indexGen) GenProcessAfterLoadBody() { + if x.needOrderedMap() { + x.genOrderedMapLoader() + } + if x.needIndex() { + x.genIndexSection(false) + } + if x.needOrderedIndex() { + x.genIndexSection(true) + } +} + +func (x *indexGen) genOrderedMapLoader() { + x.g.P(helper.Indent(2), "// OrderedMap init.") + x.g.P(helper.Indent(2), "const orderedMap: ", x.orderedMapType(), " = new Map();") + x.genOrderedMapLoaderLevel(x.message.Desc, 1, "orderedMap", "this.#data", 2) + x.g.P(helper.Indent(2), "this.", orderedMapField, " = orderedMap;") +} + +// genOrderedMapLoaderLevel recursively emits the nested loops that fill one +// ordered-map level. It converts each string object-key, and at an intermediate +// level builds the next-level sub-map (recursing) then stores the +// {first: subMap, second: value} node; at the leaf level it stores the plain +// value. Each level is sorted by key once fully built, so iteration is +// ascending at every depth. +func (x *indexGen) genOrderedMapLoaderLevel(md protoreflect.MessageDescriptor, depth int, targetMap, sourceExpr string, indent int) { + fd := firstMapField(md) + field := helper.FieldLocalName(fd) + keyStr := fmt.Sprintf("k%dStr", depth) + keyVar := fmt.Sprintf("k%d", depth) + valVar := fmt.Sprintf("v%d", depth) + nextFd := nextLevelMapField(fd) + x.g.P(helper.Indent(indent), "for (const [", keyStr, ", ", valVar, "] of Object.entries(", sourceExpr, ".", field, ")) {") + x.g.P(helper.Indent(indent+1), "const ", keyVar, " = ", x.mapKeyConv(fd.MapKey(), keyStr), ";") + if nextFd != nil { + subMap := fmt.Sprintf("orderedMap%d", depth+1) + x.g.P(helper.Indent(indent+1), "const ", subMap, ": ", x.orderedMapTypeOf(nextFd), " = new Map();") + x.genOrderedMapLoaderLevel(fd.MapValue().Message(), depth+1, subMap, valVar, indent+1) + x.g.P(helper.Indent(indent+1), targetMap, ".set(", keyVar, ", { first: ", subMap, ", second: ", valVar, " });") + } else { + x.g.P(helper.Indent(indent+1), targetMap, ".set(", keyVar, ", ", valVar, ");") + } + x.g.P(helper.Indent(indent), "}") + x.g.P(helper.Indent(indent), "sortMapByKey(", targetMap, ", compareValues);") +} + +// mapKeyConv converts a string object-key (from Object.entries) into the proper +// map key type. +func (x *indexGen) mapKeyConv(keyFd protoreflect.FieldDescriptor, v string) string { + switch helper.ParseMapKey(keyFd, "").Type { + case "number": + return "Number(" + v + ")" + case "bigint": + return "BigInt(" + v + ")" + case "boolean": + return v + ` === "true"` + default: + return v + } +} + +func (x *indexGen) genIndexSection(ordered bool) { + label := "Index" + if ordered { + label = "OrderedIndex" + } + x.g.P(helper.Indent(2), "// ", label, " init.") + // Clear all containers. + x.eachIndex(ordered, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.g.P(helper.Indent(2), "this.", x.containerField(idx, ordered, 0), ".clear();") + for i := 1; i < lm.LeveledContainerDepth(); i++ { + x.g.P(helper.Indent(2), "this.", x.containerField(idx, ordered, i), ".clear();") + } + }) + // Build containers via nested level traversal. + x.genLevelLoop(x.descriptor.LevelMessage, ordered, "this.#data", 2) + // Sort value lists by sorted columns. + x.eachIndex(ordered, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genValueListSorter(lm, idx, ordered) + }) + // Rebuild ordered maps sorted by key. + if ordered { + x.eachIndex(true, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genOrderedRebuild(lm, idx) + }) + } +} + +// genLevelLoop recursively emits the nested for-loops that walk the level +// hierarchy and build the index containers. +func (x *indexGen) genLevelLoop(lm *index.LevelMessage, ordered bool, parentData string, indent int) { + if lm == nil { + return + } + need := lm.NeedGenIndex() + if ordered { + need = lm.NeedGenOrderedIndex() + } + if !need { + return + } + field := helper.FieldLocalName(lm.FD) + itemVar := fmt.Sprintf("item%d", lm.Depth) + if lm.FD.IsMap() { + needKey := lm.NeedMapKeyForIndex() + if ordered { + needKey = lm.NeedMapKeyForOrderedIndex() + } + if needKey { + keyStr := fmt.Sprintf("k%dStr", lm.MapDepth) + x.g.P(helper.Indent(indent), "for (const [", keyStr, ", ", itemVar, "] of Object.entries(", parentData, ".", field, ")) {") + x.g.P(helper.Indent(indent+1), "const k", lm.MapDepth, " = ", x.mapKeyConv(lm.FD.MapKey(), keyStr), ";") + } else { + x.g.P(helper.Indent(indent), "for (const ", itemVar, " of Object.values(", parentData, ".", field, ")) {") + } + } else { + x.g.P(helper.Indent(indent), "for (const ", itemVar, " of ", parentData, ".", field, " ?? []) {") + } + indexes := lm.Indexes + if ordered { + indexes = lm.OrderedIndexes + } + for _, idx := range indexes { + x.genOneIndexLoader(lm, idx, ordered, indent+1, itemVar) + } + x.genLevelLoop(lm.NextLevel, ordered, itemVar, indent+1) + x.g.P(helper.Indent(indent), "}") +} + +func (x *indexGen) genOneIndexLoader(lm *index.LevelMessage, idx *index.LevelIndex, ordered bool, indent int, itemVar string) { + label := "Index" + if ordered { + label = "OrderedIndex" + } + x.g.P(helper.Indent(indent), "{") + x.g.P(helper.Indent(indent+1), "// ", label, ": ", idx.Index) + if len(idx.ColFields) == 1 { + field := idx.ColFields[0] + access, isList := x.fieldAccess(itemVar, field) + if isList { + x.g.P(helper.Indent(indent+1), "for (const elem of ", access, ") {") + x.g.P(helper.Indent(indent+2), "const key = elem;") + x.emitPushAll(lm, idx, ordered, indent+2, "key", itemVar) + x.g.P(helper.Indent(indent+1), "}") + } else { + x.g.P(helper.Indent(indent+1), "const key = ", access, ";") + x.emitPushAll(lm, idx, ordered, indent+1, "key", itemVar) + } + } else { + x.genMultiCol(lm, idx, ordered, 0, indent+1, itemVar, nil) + } + x.g.P(helper.Indent(indent), "}") +} + +// genMultiCol recursively emits nested loops for list columns and finally +// builds the key-column tuple, which is stored into the leaf TupleKeyMap +// container(s) via getOrSet (the container serializes it and records the tuple +// internally). +func (x *indexGen) genMultiCol(lm *index.LevelMessage, idx *index.LevelIndex, ordered bool, cursor, indent int, itemVar string, parts []string) { + if cursor >= len(idx.ColFields) { + x.g.P(helper.Indent(indent), "const keyParts = [", strings.Join(parts, ", "), "];") + x.emitPushAll(lm, idx, ordered, indent, "keyParts", itemVar) + return + } + field := idx.ColFields[cursor] + access, isList := x.fieldAccess(itemVar, field) + if isList { + loopVar := fmt.Sprintf("indexItem%d", cursor) + x.g.P(helper.Indent(indent), "for (const ", loopVar, " of ", access, ") {") + x.genMultiCol(lm, idx, ordered, cursor+1, indent+1, itemVar, append(append([]string{}, parts...), loopVar)) + x.g.P(helper.Indent(indent), "}") + } else { + x.genMultiCol(lm, idx, ordered, cursor+1, indent, itemVar, append(append([]string{}, parts...), access)) + } +} + +// emitPushAll appends itemVar into the global container and every leveled +// container, bucketed under keyExpr. Multi-column indexes store a value list +// per key tuple in a leaf TupleKeyMap (get-or-create the list via getOrSet, +// then push); single-column indexes push the raw key into a native Map's value +// list. Leveled containers are first scoped to the upper map key(s): a single +// upper key (i==1) indexes a native Map by its raw value, while composite upper +// keys (i>=2) get-or-create the inner container in a TupleKeyMap via getOrSet. +func (x *indexGen) emitPushAll(lm *index.LevelMessage, idx *index.LevelIndex, ordered bool, indent int, keyExpr, itemVar string) { + multi := len(idx.ColFields) > 1 + if multi { + x.g.P(helper.Indent(indent), "this.", x.containerField(idx, ordered, 0), ".getOrSet(", keyExpr, ", () => []).push(", itemVar, ");") + } else { + x.emitPushInto(indent, "this."+x.containerField(idx, ordered, 0), keyExpr, itemVar) + } + for i := 1; i < lm.LeveledContainerDepth(); i++ { + container := x.containerField(idx, ordered, i) + x.g.P(helper.Indent(indent), "{") + if i == 1 { + // Single upper key: native Map keyed by the raw k1 value. + if multi { + x.g.P(helper.Indent(indent+1), "let m = this.", container, ".get(k1);") + x.g.P(helper.Indent(indent+1), "if (!m) { m = ", x.newLeafContainer(idx, ordered), "; this.", container, ".set(k1, m); }") + x.g.P(helper.Indent(indent+1), "m.getOrSet(", keyExpr, ", () => []).push(", itemVar, ");") + } else { + x.g.P(helper.Indent(indent+1), "let map = this.", container, ".get(k1);") + x.g.P(helper.Indent(indent+1), "if (!map) { map = new Map(); this.", container, ".set(k1, map); }") + x.emitPushInto(indent+1, "map", keyExpr, itemVar) + } + } else { + // Composite upper keys: TupleKeyMap keyed by the [k1..ki] tuple. + var ks []string + for j := 1; j <= i; j++ { + ks = append(ks, fmt.Sprintf("k%d", j)) + } + tuple := "[" + strings.Join(ks, ", ") + "]" + if multi { + x.g.P(helper.Indent(indent+1), "const m = this.", container, ".getOrSet(", tuple, ", () => ", x.newLeafContainer(idx, ordered), ");") + x.g.P(helper.Indent(indent+1), "m.getOrSet(", keyExpr, ", () => []).push(", itemVar, ");") + } else { + x.g.P(helper.Indent(indent+1), "const map = this.", container, ".getOrSet(", tuple, ", () => new Map());") + x.emitPushInto(indent+1, "map", keyExpr, itemVar) + } + } + x.g.P(helper.Indent(indent), "}") + } +} + +// emitPushInto pushes itemVar into the value list bucketed under keyExpr in the +// given map expression. +func (x *indexGen) emitPushInto(indent int, mapExpr, keyExpr, itemVar string) { + x.g.P(helper.Indent(indent), "{") + x.g.P(helper.Indent(indent+1), "const list = ", mapExpr, ".get(", keyExpr, ");") + x.g.P(helper.Indent(indent+1), "if (list) { list.push(", itemVar, "); } else { ", mapExpr, ".set(", keyExpr, ", [", itemVar, "]); }") + x.g.P(helper.Indent(indent), "}") +} + +// fieldAccess builds the TypeScript access expression for a level field rooted +// at itemVar, returning the expression and whether the (terminal) field is a +// list (which the caller iterates). +func (x *indexGen) fieldAccess(itemVar string, field *index.LevelField) (string, bool) { + expr := itemVar + needEmpty := len(field.LeveledFDList) > 1 + isTimestamp := false + for i, fd := range field.LeveledFDList { + sep := "." + if i != 0 { + sep = "?." + } + expr += sep + helper.FieldLocalName(fd) + if i == len(field.LeveledFDList)-1 && fd.Message() != nil { + switch fd.Message().FullName() { + case "google.protobuf.Timestamp", "google.protobuf.Duration": + expr += "?.seconds ?? 0n" + isTimestamp = true + needEmpty = false + } + } + } + if field.FD.IsList() { + return expr + " ?? []", true + } + if needEmpty && !isTimestamp { + expr += " ?? " + helper.TSEmptyValue(field.FD) + } + return expr, false +} + +// genValueListSorter emits a comparator for an index's sorted columns and sorts +// every value list (global and leveled) with it. +func (x *indexGen) genValueListSorter(lm *index.LevelMessage, idx *index.LevelIndex, ordered bool) { + if len(idx.SortedColFields) == 0 { + return + } + label := "Index" + if ordered { + label = "OrderedIndex" + } + v := x.valueType(idx) + cmp := "cmp" + idx.Name() + var aParts, bParts []string + for _, field := range idx.SortedColFields { + a, _ := x.fieldAccess("a", field) + b, _ := x.fieldAccess("b", field) + aParts = append(aParts, a) + bParts = append(bParts, b) + } + x.g.P(helper.Indent(2), "// ", label, "(sort): ", idx.Index) + x.g.P(helper.Indent(2), "const ", cmp, " = (a: ", v, ", b: ", v, "): number => compareTuples([", strings.Join(aParts, ", "), "], [", strings.Join(bParts, ", "), "]);") + x.g.P(helper.Indent(2), "for (const list of this.", x.containerField(idx, ordered, 0), ".values()) {") + x.g.P(helper.Indent(3), "list.sort(", cmp, ");") + x.g.P(helper.Indent(2), "}") + for i := 1; i < lm.LeveledContainerDepth(); i++ { + x.g.P(helper.Indent(2), "for (const m of this.", x.containerField(idx, ordered, i), ".values()) {") + x.g.P(helper.Indent(3), "for (const list of m.values()) {") + x.g.P(helper.Indent(4), "list.sort(", cmp, ");") + x.g.P(helper.Indent(3), "}") + x.g.P(helper.Indent(2), "}") + } +} + +// genOrderedRebuild rebuilds an ordered index's global and leveled maps so they +// are sorted by key. Multi-column indexes sort the TupleKeyMap in place by its +// key tuples (sortKeys); single-column indexes re-sort the native Map by key. +func (x *indexGen) genOrderedRebuild(lm *index.LevelMessage, idx *index.LevelIndex) { + multi := len(idx.ColFields) > 1 + c0 := x.containerField(idx, true, 0) + if multi { + x.g.P(helper.Indent(2), "this.", c0, ".sortKeys();") + for i := 1; i < lm.LeveledContainerDepth(); i++ { + ci := x.containerField(idx, true, i) + x.g.P(helper.Indent(2), "for (const m of this.", ci, ".values()) {") + x.g.P(helper.Indent(3), "m.sortKeys();") + x.g.P(helper.Indent(2), "}") + } + return + } + x.g.P(helper.Indent(2), "this.", c0, " = sortMapByKey(this.", c0, ", compareValues);") + for i := 1; i < lm.LeveledContainerDepth(); i++ { + ci := x.containerField(idx, true, i) + x.g.P(helper.Indent(2), "for (const m of this.", ci, ".values()) {") + x.g.P(helper.Indent(3), "sortMapByKey(m, compareValues);") + x.g.P(helper.Indent(2), "}") + } +} + +// ---------------------------------------------------------------------------- +// Getters / finders +// ---------------------------------------------------------------------------- + +// GenGetters emits the ordered-map getter and index/ordered-index finders. +func (x *indexGen) GenGetters() { + if x.needOrderedMap() { + x.genOrderedMapGetters(x.message.Desc, 1, nil) + } + if x.needIndex() { + x.eachIndex(false, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genFinders(lm, idx, false) + }) + } + if x.needOrderedIndex() { + x.eachIndex(true, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genFinders(lm, idx, true) + }) + } +} + +// orderedMapGetterName returns the getter name for the depth-level ordered map: +// getOrderedMap for the 1st level, getOrderedMap1/2/... for deeper levels (the +// suffix is the number of upper keys needed to reach it), mirroring the Go / +// C++ / C# GetOrderedMap / GetOrderedMap1 naming. +func orderedMapGetterName(depth int) string { + if depth == 1 { + return "getOrderedMap" + } + return fmt.Sprintf("getOrderedMap%d", depth-1) +} + +// genOrderedMapGetters recursively emits the ordered-map getters along the +// first-map-field chain. The 1st-level getter returns the whole ordered map; +// each deeper getter takes the accumulated upper keys and descends one level by +// looking the next key up and following the node's .first sub-map, returning +// undefined if any key is absent (matching the TS finder convention). +func (x *indexGen) genOrderedMapGetters(md protoreflect.MessageDescriptor, depth int, keys helper.MapKeySlice) { + fd := firstMapField(md) + if fd == nil { + return + } + mapType := x.orderedMapTypeOf(fd) + getter := orderedMapGetterName(depth) + ordinal := loadutil.Ordinal(depth) + x.g.P() + if depth == 1 { + x.g.P(helper.Indent(1), "/** ", getter, " returns the 1st-level ordered map (sorted by key). */") + x.g.P(helper.Indent(1), getter, "(): ", mapType, " {") + x.g.P(helper.Indent(2), "return this.", orderedMapField, ";") + x.g.P(helper.Indent(1), "}") + } else { + params := keys.GenGetParams() + lastKey := keys[len(keys)-1].Name + x.g.P(helper.Indent(1), "/** ", getter, " returns the ", ordinal, "-level ordered map scoped to the given upper key(s), or undefined. */") + x.g.P(helper.Indent(1), getter, "(", params, "): ", mapType, " | undefined {") + if depth == 2 { + x.g.P(helper.Indent(2), "return this.", orderedMapField, ".get(", lastKey, ")?.first;") + } else { + prevArgs := keys[:len(keys)-1].GenGetArguments() + x.g.P(helper.Indent(2), "return this.", orderedMapGetterName(depth-1), "(", prevArgs, ")?.get(", lastKey, ")?.first;") + } + x.g.P(helper.Indent(1), "}") + } + nextKeys := keys.AddMapKey(helper.ParseMapKey(fd.MapKey(), helper.ParseMapFieldNameAsFuncParam(fd))) + if fd.MapValue().Kind() == protoreflect.MessageKind { + x.genOrderedMapGetters(fd.MapValue().Message(), depth+1, nextKeys) + } +} + +func (x *indexGen) genFinders(lm *index.LevelMessage, idx *index.LevelIndex, ordered bool) { + kind := "index" + if ordered { + kind = "ordered index" + } + name := idx.Name() + v := x.valueType(idx) + at := x.indexType(idx, ordered) + keys := x.params(idx) + params := keys.GenGetParams() + args := keys.GenGetArguments() + + // keyExpr is the key passed to a container's get(): a raw value / native-Map + // key for single-column, or a key-column tuple for the multi-column + // TupleKeyMap (which serializes it internally). + keyExpr := args + multi := len(idx.ColFields) > 1 + if multi { + keyExpr = "[" + args + "]" + } + + container0 := x.containerField(idx, ordered, 0) + + // The map finders return the global / leveled container directly: a native + // Map for single-column indexes, or the owning TupleKeyMap (which exposes + // readable tuple keys) for multi-column indexes. + x.g.P() + x.g.P(helper.Indent(1), "/** find", name, "Map returns the ", kind, " map: key(", idx.Index, ") -> values. */") + x.g.P(helper.Indent(1), "find", name, "Map(): ", at, " {") + x.g.P(helper.Indent(2), "return this.", container0, ";") + x.g.P(helper.Indent(1), "}") + + x.g.P() + x.g.P(helper.Indent(1), "/** find", name, " returns all values for the given key(s), or undefined. */") + x.g.P(helper.Indent(1), "find", name, "(", params, "): ", v, "[] | undefined {") + x.g.P(helper.Indent(2), "return this.", container0, ".get(", keyExpr, ");") + x.g.P(helper.Indent(1), "}") + + x.g.P() + x.g.P(helper.Indent(1), "/** findFirst", name, " returns the first value for the given key(s), or undefined. */") + x.g.P(helper.Indent(1), "findFirst", name, "(", params, "): ", v, " | undefined {") + x.g.P(helper.Indent(2), "return this.find", name, "(", args, ")?.[0];") + x.g.P(helper.Indent(1), "}") + + for i := 1; i < lm.LeveledContainerDepth(); i++ { + container := x.containerField(idx, ordered, i) + upperKeys := x.keys[:i] + upperParams := upperKeys.GenGetParams() + upperArgs := upperKeys.GenGetArguments() + // A single upper key indexes a native Map by its raw value; composite + // upper keys index a TupleKeyMap by the upper key tuple. + upperKeyExpr := upperArgs + if i > 1 { + upperKeyExpr = "[" + upperArgs + "]" + } + ordinal := loadutil.Ordinal(i) + + x.g.P() + x.g.P(helper.Indent(1), "/** find", name, "Map", i, " returns the ", kind, " map scoped to the upper ", ordinal, "-level map key(s). */") + x.g.P(helper.Indent(1), "find", name, "Map", i, "(", upperParams, "): ", at, " | undefined {") + x.g.P(helper.Indent(2), "return this.", container, ".get(", upperKeyExpr, ");") + x.g.P(helper.Indent(1), "}") + + x.g.P() + x.g.P(helper.Indent(1), "/** find", name, i, " returns all values for the given key(s) within the upper ", ordinal, "-level map. */") + x.g.P(helper.Indent(1), "find", name, i, "(", upperParams, ", ", params, "): ", v, "[] | undefined {") + x.g.P(helper.Indent(2), "return this.find", name, "Map", i, "(", upperArgs, ")?.get(", keyExpr, ");") + x.g.P(helper.Indent(1), "}") + + x.g.P() + x.g.P(helper.Indent(1), "/** findFirst", name, i, " returns the first value for the given key(s) within the upper ", ordinal, "-level map. */") + x.g.P(helper.Indent(1), "findFirst", name, i, "(", upperParams, ", ", params, "): ", v, " | undefined {") + x.g.P(helper.Indent(2), "return this.find", name, i, "(", upperArgs, ", ", args, ")?.[0];") + x.g.P(helper.Indent(1), "}") + } +} + +// collectIndexPackages registers the owning packages of every enum/message +// referenced by an index key or value, across all levels, so their barrel +// imports are emitted. +func collectIndexPackages(descriptor *index.IndexDescriptor, pkgs *pbPackages) { + addFields := func(idx *index.LevelIndex) { + pkgs.add(string(idx.MD.ParentFile().Package())) + fields := append(append([]*index.LevelField{}, idx.ColFields...), idx.SortedColFields...) + for _, field := range fields { + // Only enum key fields reference a generated named type. Message + // key fields are exclusively Timestamp/Duration, which are encoded + // as bigint (via .seconds) and never reference the well-known type + // module (protobuf-es serves those from @bufbuild/protobuf/wkt, not + // a generated google/protobuf/*_pb module), so they add no package. + if field.FD.Kind() == protoreflect.EnumKind { + pkgs.add(string(field.FD.Enum().ParentFile().Package())) + } + } + } + for lm := descriptor.LevelMessage; lm != nil; lm = lm.NextLevel { + for _, idx := range lm.Indexes { + addFields(idx) + } + for _, idx := range lm.OrderedIndexes { + addFields(idx) + } + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/main.go b/cmd/protoc-gen-ts-tableau-loader/main.go new file mode 100644 index 0000000..9e3b5fc --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/tableauio/loader/internal/options" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +const version = "0.1.0" + +// pbImportPath is the relative path from the loader output dir to the +// protobuf-es generated base output dir. It is used to build the imports of +// the base message modules (e.g. "/_pb.js") and the +// tableau extension descriptors. Configurable via the "pb_path" plugin option; +// defaults to ".." (loader output nested one level under the base output). +var pbImportPath = ".." + +func main() { + showVersion := flag.Bool("version", false, "print the version and exit") + flag.Parse() + if *showVersion { + fmt.Printf("protoc-gen-ts-tableau-loader %v\n", version) + return + } + + var flags flag.FlagSet + flags.StringVar(&pbImportPath, "pb_path", "..", "relative path from the loader output dir to the protobuf-es base output dir") + + protogen.Options{ + ParamFunc: flags.Set, + }.Run(func(gen *protogen.Plugin) error { + gen.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL | pluginpb.CodeGeneratorResponse_FEATURE_SUPPORTS_EDITIONS) + gen.SupportedEditionsMinimum = descriptorpb.Edition_EDITION_PROTO2 + gen.SupportedEditionsMaximum = descriptorpb.Edition_EDITION_2024 + reg := newBarrelRegistry(gen) + for _, f := range gen.Files { + if !options.NeedGenFile(f) { + continue + } + generateMessager(gen, f, reg) + } + generateHub(gen) + generateEmbed(gen) + generateBarrels(gen, reg) + return nil + }) +} diff --git a/cmd/protoc-gen-ts-tableau-loader/messager.go b/cmd/protoc-gen-ts-tableau-loader/messager.go new file mode 100644 index 0000000..062c82a --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/messager.go @@ -0,0 +1,237 @@ +package main + +import ( + "fmt" + + "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" + "github.com/tableauio/loader/internal/index" + "github.com/tableauio/loader/internal/loadutil" + "github.com/tableauio/loader/internal/options" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// generateMessager generates a loader file corresponding to the protobuf file. +// Each wrapped class extends the Messager base class. +func generateMessager(gen *protogen.Plugin, file *protogen.File, reg *barrelRegistry) { + filename := file.GeneratedFilenamePrefix + ".pc.ts" + g := gen.NewGeneratedFile(filename, "") + helper.GenerateFileHeader(gen, file, g, version) + + var messagers []*protogen.Message + for _, message := range file.Messages { + if options.IsWorksheet(message.Desc) { + messagers = append(messagers, message) + } + } + + // Parse index descriptors up-front: they drive both the extra package + // imports (enum/message index-key types) and whether the index runtime + // helpers need importing. + descriptors := make(map[*protogen.Message]*index.IndexDescriptor, len(messagers)) + needIndexRuntime := false + needOrderedMapValue := false + for _, message := range messagers { + desc := index.ParseIndexDescriptor(message.Desc) + descriptors[message] = desc + ig := newIndexGen(g, desc, message, nil) + if ig.NeedGenerate() { + needIndexRuntime = true + } + if ig.needNestedOrderedMap() { + needOrderedMapValue = true + } + } + + // Runtime imports. The index container runtime (TupleKeyMap & comparators) + // lives alongside Format in util.pc.ts, so it is pulled from the same module + // and only when this file uses an index / ordered-index / ordered-map + // container. + g.P(`import { create } from "@bufbuild/protobuf";`) + g.P(`import { Messager } from "./messager.pc.js";`) + if needIndexRuntime { + if needOrderedMapValue { + g.P(`import { Format, compareValues, compareTuples, sortMapByKey, TupleKeyMap, type OrderedMapValue } from "./util.pc.js";`) + } else { + g.P(`import { Format, compareValues, compareTuples, sortMapByKey, TupleKeyMap } from "./util.pc.js";`) + } + } else { + g.P(`import { Format } from "./util.pc.js";`) + } + g.P(`import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js";`) + // Namespace imports for the protobuf-es generated types, one per proto + // package: the worksheet's own package plus any other package owning a + // map-value message/enum referenced by a getter. Each package is imported + // from its barrel module under an alias equal to the proto package name + // (e.g. import * as protoconf / import * as base), so the loader qualifies + // types exactly like the loaders of other languages (protoconf.ItemConf, + // base.Hero) — consistent cross-language naming, and the alias also sidesteps + // the wrapper-class vs. message-type name collision. + pkgs := newPBPackages(file.GeneratedFilenamePrefix) + pkgs.add(string(file.Desc.Package())) + for _, message := range messagers { + collectValuePackages(message.Desc, pkgs) + collectIndexPackages(descriptors[message], pkgs) + } + pkgs.emit(g) + for _, pkg := range pkgs.order { + reg.markReferenced(pkg) + } + g.P() + + for i, message := range messagers { + if i > 0 { + g.P() + } + genMessage(g, message, descriptors[message], pkgs) + } +} + +// genMessage generates a single messager class definition. +func genMessage(g *protogen.GeneratedFile, message *protogen.Message, descriptor *index.IndexDescriptor, pkgs *pbPackages) { + md := message.Desc + name := string(md.Name()) + alias := pkgs.aliasOf(md) + schema := alias + "." + helper.LocalSchemaName(md) // runtime schema value + dataType := alias + "." + helper.LocalTypeName(md) // message type + idxGen := newIndexGen(g, descriptor, message, pkgs) + + g.P("/**") + g.P(" * ", name, " is a wrapper around protobuf message ", md.FullName(), ".") + g.P(" */") + g.P("export class ", name, " extends Messager {") + g.P(helper.Indent(1), "#data: ", dataType, " = create(", schema, ");") + idxGen.GenDecls() + g.P() + + // name() + g.P(helper.Indent(1), "/** name returns the ", name, "'s message name. */") + g.P(helper.Indent(1), "name(): string {") + g.P(helper.Indent(2), "return ", schema, ".name;") + g.P(helper.Indent(1), "}") + g.P() + + // load() + g.P(helper.Indent(1), "/** load loads ", name, "'s content in the given dir, based on format and messager options. Throws on failure. */") + g.P(helper.Indent(1), "load(dir: string, fmt: Format, options?: MessagerOptions): void {") + g.P(helper.Indent(2), "const start = Date.now();") + g.P(helper.Indent(2), "try {") + g.P(helper.Indent(3), "this.#data = loadMessagerInDir(", schema, ", dir, fmt, options);") + g.P(helper.Indent(2), "} catch (e) {") + g.P(helper.Indent(3), "throw new Error(`failed to load ", name, "`, { cause: e });") + g.P(helper.Indent(2), "}") + g.P(helper.Indent(2), "this.loadStats.durationMs = Date.now() - start;") + g.P(helper.Indent(2), "this.processAfterLoad();") + g.P(helper.Indent(1), "}") + g.P() + + // data() + g.P(helper.Indent(1), "/** data returns the ", name, "'s inner message data. */") + g.P(helper.Indent(1), "data(): ", dataType, " {") + g.P(helper.Indent(2), "return this.#data;") + g.P(helper.Indent(1), "}") + g.P() + + // message() + g.P(helper.Indent(1), "/** message returns the ", name, "'s inner message data. */") + g.P(helper.Indent(1), "override message(): ", dataType, " {") + g.P(helper.Indent(2), "return this.#data;") + g.P(helper.Indent(1), "}") + + // processAfterLoad() override: build index / ordered index / ordered map. + if idxGen.NeedGenerate() { + g.P() + g.P(helper.Indent(1), "/** processAfterLoad builds the index, ordered index and ordered map containers. */") + g.P(helper.Indent(1), "override processAfterLoad(): void {") + idxGen.GenProcessAfterLoadBody() + g.P(helper.Indent(1), "}") + } + + // syntactic sugar for accessing map items + genMapGetters(g, md, 1, nil, pkgs) + + // index / ordered index finders and the ordered map getter + idxGen.GenGetters() + + g.P("}") + + // Type aliases for the index / ordered index / ordered map containers, + // declared in a namespace merged with the class above. + idxGen.GenTypeAliases() +} + +// genMapGetters generates nested map getters (get1/get2/...) for a message. +func genMapGetters(g *protogen.GeneratedFile, md protoreflect.MessageDescriptor, depth int, keys helper.MapKeySlice, pkgs *pbPackages) { + for i := 0; i < md.Fields().Len(); i++ { + fd := md.Fields().Get(i) + if !fd.IsMap() { + continue + } + localName := helper.FieldLocalName(fd) + paramName := helper.ParseMapFieldNameAsFuncParam(fd) + keys = keys.AddMapKey(helper.ParseMapKey(fd.MapKey(), paramName)) + last := keys[len(keys)-1] + + // The value type comes straight from the map value: message/enum values + // use their own package-qualified named type (protoconf.Foo); scalar + // values use the plain TypeScript type. + returnType := mapValueType(fd, pkgs) + " | undefined" + getter := fmt.Sprintf("get%d", depth) + + var access string + if depth == 1 { + access = "this.#data." + localName + "[" + helper.IndexExpr(last) + "]" + } else { + prevArgs := keys[:len(keys)-1].GenGetArguments() + access = fmt.Sprintf("this.get%d(%s)?.%s[%s]", depth-1, prevArgs, localName, helper.IndexExpr(last)) + } + + g.P() + g.P(helper.Indent(1), "/** ", getter, " finds value in the ", loadutil.Ordinal(depth), "-level map; returns undefined if not found. */") + g.P(helper.Indent(1), getter, "(", keys.GenGetParams(), "): ", returnType, " {") + g.P(helper.Indent(2), "return ", access, ";") + g.P(helper.Indent(1), "}") + + if fd.MapValue().Kind() == protoreflect.MessageKind { + genMapGetters(g, fd.MapValue().Message(), depth+1, keys, pkgs) + } + break + } +} + +// mapValueType returns the TypeScript type for a map field's value: a +// package-qualified named type (protoconf.Foo) for message/enum values, or the +// plain scalar type otherwise. +func mapValueType(fd protoreflect.FieldDescriptor, pkgs *pbPackages) string { + v := fd.MapValue() + switch v.Kind() { + case protoreflect.MessageKind, protoreflect.GroupKind: + return pkgs.aliasOf(v.Message()) + "." + helper.LocalTypeName(v.Message()) + case protoreflect.EnumKind: + return pkgs.aliasOf(v.Enum()) + "." + helper.LocalTypeName(v.Enum()) + default: + s, _ := helper.ScalarTSType(v.Kind()) + return s + } +} + +// collectValuePackages walks the same first-map-field chain as genMapGetters and +// registers the owning package of every message/enum map value referenced by a +// getter (including nested messages and those from other proto files), so each +// gets a namespace barrel import. +func collectValuePackages(md protoreflect.MessageDescriptor, pkgs *pbPackages) { + for i := 0; i < md.Fields().Len(); i++ { + fd := md.Fields().Get(i) + if !fd.IsMap() { + continue + } + switch v := fd.MapValue(); v.Kind() { + case protoreflect.MessageKind, protoreflect.GroupKind: + pkgs.add(string(v.Message().ParentFile().Package())) + collectValuePackages(v.Message(), pkgs) + case protoreflect.EnumKind: + pkgs.add(string(v.Enum().ParentFile().Package())) + } + break + } +} diff --git a/make.py b/make.py index fe08944..7658b35 100644 --- a/make.py +++ b/make.py @@ -3,14 +3,14 @@ make.py — single cross-platform entrypoint for the tableauio/loader repo. Consolidates per-language `buf generate` / `cmake` / `go test` / `dotnet test` -recipes into one Python tool that works identically on +/ `npm test` recipes into one Python tool that works identically on native Windows, macOS, Linux, and inside the devcontainer. Usage (high level): - python3 make.py setup [--lang go|cpp|csharp|all] [--dry-run] - python3 make.py generate --lang go|cpp|csharp - python3 make.py build --lang go|cpp|csharp [build flags] - python3 make.py test --lang go|cpp|csharp [build flags] [-k FILTER] [--smoke] + python3 make.py setup [--lang go|cpp|csharp|ts|all] [--dry-run] + python3 make.py generate --lang go|cpp|csharp|ts + python3 make.py build --lang go|cpp|csharp|ts [build flags] + python3 make.py test --lang go|cpp|csharp|ts [build flags] [-k FILTER] [--smoke] python3 make.py clean [--lang ...] [--all] python3 make.py env python3 make.py --version @@ -142,6 +142,10 @@ def _variant_value(self, suffix: str) -> Optional[str]: def dotnet_version(self) -> Optional[str]: return self.raw.get("DOTNET_VERSION") + @property + def node_version(self) -> Optional[str]: + return self.raw.get("NODE_VERSION") + @property def cmake_version(self) -> Optional[str]: return self.raw.get("CMAKE_VERSION") @@ -950,7 +954,7 @@ def hydrate_platform_from_env(plat: Platform) -> None: # --------------------------------------------------------------------------- -LANGS_ALL = ("go", "cpp", "csharp") +LANGS_ALL = ("go", "cpp", "csharp", "ts") def _which(name: str) -> Optional[str]: @@ -1028,8 +1032,8 @@ def _setup_macos(langs: list[str], ctx: "Context") -> int: # Brew packages: only the version-tolerant pieces (cmake, ninja, build # essentials). Go and protobuf are pinned via tarball / vcpkg below; - # buf is pinned via direct download. dotnet@N is pinnable via brew's - # versioned formulae. + # buf is pinned via direct download. dotnet@N and node@N are pinnable + # via brew's versioned formulae. pkgs: list[str] = [] if "cpp" in langs: pkgs.extend(["cmake", "ninja"]) @@ -1037,12 +1041,15 @@ def _setup_macos(langs: list[str], ctx: "Context") -> int: # `dotnet@8` (cask) covers .NET 8.x. Use the major. major = (ctx.versions.dotnet_version or "8.0").split(".")[0] pkgs.append(f"dotnet@{major}") + if "ts" in langs: + pkgs.append(f"node@{ctx.versions.node_version or '20'}") if pkgs: ctx.runner.run(["brew", "update"], check=False) ctx.runner.run(["brew", "install", *pkgs], check=False) - # Pinned Go via official tarball (matches devcontainer). - if "go" in langs or "cpp" in langs or "csharp" in langs: + # Pinned Go via official tarball (matches devcontainer). Required for any + # language: every buf-generate invokes the Go protoc plugins via `go run`. + if "go" in langs or "cpp" in langs or "csharp" in langs or "ts" in langs: _ensure_go_tarball(ctx, "darwin") # Pinned buf via GitHub release. @@ -1094,8 +1101,9 @@ def _setup_linux(langs: list[str], ctx: "Context") -> int: [*sudo_prefix, "dnf", "install", "-y", *base_pkgs], check=False ) - # Pinned Go via official tarball. - if "go" in langs or "cpp" in langs or "csharp" in langs: + # Pinned Go via official tarball. Required for any language: every + # buf-generate invokes the Go protoc plugins via `go run`. + if "go" in langs or "cpp" in langs or "csharp" in langs or "ts" in langs: _ensure_go_tarball(ctx, "linux") # Pinned buf via GitHub release. @@ -1103,6 +1111,8 @@ def _setup_linux(langs: list[str], ctx: "Context") -> int: if "csharp" in langs: _ensure_dotnet_linux(ctx) + if "ts" in langs: + _ensure_node_linux(ctx) # Pinned protobuf via vcpkg (matches devcontainer + Windows). if "cpp" in langs: @@ -1210,6 +1220,46 @@ def _ensure_go_tarball(ctx: "Context", os_label: str) -> None: print(f" export PATH={bin_dir}:$PATH") +def _ensure_node_linux(ctx: "Context") -> None: + """Install Node.js at NODE_VERSION (major) via NodeSource, mirroring the + devcontainer Dockerfile. A `node` already on PATH is accepted as-is — we + don't auto-replace an existing install. + + NodeSource ships distro-specific setup scripts (`deb`/`rpm`) that register + the apt/dnf repo and refresh the package lists; we then install the + `nodejs` package. Both the setup script and the install need root, so they + go through sudo when we're not already uid 0. + """ + if _which("node") is not None: + return + ver = ctx.versions.node_version + if not ver: + print("[warn] NODE_VERSION not set; skipping Node install.", file=sys.stderr) + return + # NodeSource setup scripts are keyed by major only: `setup_.x`. + major = ver.split(".")[0] + apt = _which("apt-get") is not None + dnf = _which("dnf") is not None + if not (apt or dnf): + print( + "[warn] Neither apt-get nor dnf found; install Node manually.", + file=sys.stderr, + ) + return + sudo_prefix = ["sudo"] if os.geteuid() != 0 else [] + repo = "deb" if apt else "rpm" + url = f"https://{repo}.nodesource.com/setup_{major}.x" + script = Path.home() / ".local" / "bin" / "nodesource-setup.sh" + ctx.runner.mkdirp(script.parent) + print(f"[info] Registering NodeSource repo for Node {major}.x") + if not ctx.runner.dry_run: + urllib.request.urlretrieve(url, str(script)) + script.chmod(0o755) + ctx.runner.run([*sudo_prefix, "bash", str(script)], check=False) + pkg_mgr = "apt-get" if apt else "dnf" + ctx.runner.run([*sudo_prefix, pkg_mgr, "install", "-y", "nodejs"], check=False) + + def _setup_windows(langs: list[str], ctx: "Context", skip_vcpkg: bool) -> int: """Windows host setup.""" cache = load_loader_env() @@ -1356,6 +1406,15 @@ def _setup_windows(langs: list[str], ctx: "Context", skip_vcpkg: bool) -> int: check=False, ) + # Step 8: Node.js (only when ts tests are requested). winget's LTS package + # tracks the current LTS line; CI pins an exact version via setup-node, so + # this native-dev install is best-effort (no hard version pin on Windows). + if "ts" in langs and _which("node") is None: + ctx.runner.run( + ["winget", "install", "--id", "OpenJS.NodeJS.LTS", "-e"], + check=False, + ) + save_loader_env(cache, ctx.runner) print("[info] Windows toolchain ready.") print( @@ -1501,6 +1560,8 @@ def _build_or_test(args, ctx: "Context", run_tests: bool) -> int: return _cpp_build_or_test(args, ctx, run_tests) if lang == "csharp": return _csharp_build_or_test(args, ctx, run_tests) + if lang == "ts": + return _ts_build_or_test(args, ctx, run_tests) print(f"[error] unknown --lang {lang}", file=sys.stderr) return 2 @@ -1788,6 +1849,51 @@ def _csharp_build_or_test(args, ctx: "Context", run_tests: bool) -> int: return 0 +# ----- TypeScript ----- + + +def _npm_cmd(ctx: "Context") -> str: + """npm executable name. On Windows npm is a `.cmd` batch shim, which + subprocess (shell=False) only resolves with the explicit extension.""" + return "npm.cmd" if ctx.platform.is_windows else "npm" + + +def _ts_build_or_test(args, ctx: "Context", run_tests: bool) -> int: + """Build/test the TypeScript loader harness (test/ts-tableau-loader). + + Flow mirrors the package.json scripts: + generate -> `buf generate ..` (Go protoc plugins via `go run`) + install -> `npm ci` (lockfile present) or `npm install` + build -> `npm run check` (tsc --noEmit type check) + test -> `npm run check` then `npm run smoke` (tsx tests/smoke.ts) + + Unlike go/cpp/csharp there is no emitted artifact: ESM TS is consumed + directly by tsx (in-memory transpile), so "build" is just a type check. + """ + cwd = _lang_dir(ctx.repo_root, "ts") + npm = _npm_cmd(ctx) + + if not getattr(args, "no_generate", False): + _buf_generate(ctx, "ts") + + # Install deps: prefer the reproducible `npm ci` when a lockfile exists. + install_cmd = ( + [npm, "ci"] if (cwd / "package-lock.json").is_file() else [npm, "install"] + ) + ctx.runner.run(install_cmd, cwd=cwd) + + # Type-check (the closest equivalent to "build" for a noEmit TS project). + ctx.runner.run([npm, "run", "check"], cwd=cwd) + + if not run_tests: + return 0 + + # Smoke test via tsx. The smoke harness runs the full suite (no per-test + # filter), so -k is accepted but ignored for ts. + ctx.runner.run([npm, "run", "smoke"], cwd=cwd) + return 0 + + # ----- clean / env ----- @@ -1809,6 +1915,10 @@ def cmd_clean(args, ctx: "Context") -> int: ctx.runner.rmtree(cwd / "protoconf") elif lang == "go": ctx.runner.rmtree(cwd / "protoconf") + elif lang == "ts": + ctx.runner.rmtree(cwd / "protoconf") + ctx.runner.rmtree(cwd / "tableau") + ctx.runner.rmtree(cwd / "node_modules") return 0 @@ -1836,6 +1946,8 @@ def cmd_env(args, ctx: "Context") -> int: "cmake": _which("cmake"), "ninja": _which("ninja"), "dotnet": _which("dotnet"), + "node": _which("node"), + "npm": _which("npm"), }, "versions_env": ctx.versions.raw, } diff --git a/test/ts-tableau-loader/buf.gen.yaml b/test/ts-tableau-loader/buf.gen.yaml new file mode 100644 index 0000000..80b53a4 --- /dev/null +++ b/test/ts-tableau-loader/buf.gen.yaml @@ -0,0 +1,33 @@ +version: v2 +# Mirror the per-language buf.gen.yaml pattern: a "base" plugin emits the +# protobuf message types, then protoc-gen-ts-tableau-loader wraps them into +# Messager/Hub code (exactly like protocolbuffers/go + +# protoc-gen-go-tableau-loader on the Go side). +plugins: + # protobuf-es generator (BSR remote plugin). Keep this version in lockstep + # with the @bufbuild/protobuf runtime in package.json: the generated *_pb.ts + # import from @bufbuild/protobuf at runtime, so generator and runtime must + # match. + - remote: buf.build/bufbuild/es:v2.2.3 + out: protoconf + # The proto files use tableau.* extension options, so the generated + # message modules import the tableau/protobuf descriptors; pull those in + # here (mirrors include_imports in the cpp/csharp buf.gen.yaml). + include_imports: true + opt: + - target=ts + # Emit explicit ".js" extensions on relative imports so the output is + # valid under TS's nodenext/node16 ESM resolution (and plain Node ESM). + - import_extension=js + # tableau-ts loader generator. Emits *_tableau.ts wrappers + hub.ts + the + # runtime library (util/load/messager) into "tableau". The base protobuf-es + # messages live in the sibling "protoconf" dir, so loader code imports them + # via "../protoconf/_pb.js" (configured by pb_path below). + - local: ["go", "run", "../../cmd/protoc-gen-ts-tableau-loader"] + out: tableau + opt: + - paths=source_relative + # Relative path from this loader output dir to the protobuf-es base + # output dir ("protoconf"), used to build the base message imports. + - pb_path=../protoconf + strategy: all diff --git a/test/ts-tableau-loader/custom/custom_item_conf.ts b/test/ts-tableau-loader/custom/custom_item_conf.ts new file mode 100644 index 0000000..00118a5 --- /dev/null +++ b/test/ts-tableau-loader/custom/custom_item_conf.ts @@ -0,0 +1,52 @@ +// Hand-written (custom) messager. +// +// It mirrors the canonical custom-messager pattern of the other-language +// loaders, demonstrating the cross-messager `processAfterLoadAll` extension +// point: +// - Go: test/go-tableau-loader/customconf/custom_item_conf.go +// - C#: test/csharp-tableau-loader/custom/CustomItemConf.cs +// - C++: test/cpp-tableau-loader/src/hub/custom/item/custom_item_conf.{h,cpp} +// +// Unlike generated messagers, it carries no config file of its own; its `load` +// is a no-op and it derives its data from another messager (ItemConf) once all +// messagers have finished loading. +import { Messager } from "../tableau/messager.pc.js"; +import { Format, type MessagerOptions } from "../tableau/load.pc.js"; +import type { Hub } from "../tableau/hub.pc.js"; +import * as protoconf from "../tableau/barrel/protoconf.pc.js"; + +export const CustomItemConfName = "CustomItemConf"; + +/** + * CustomItemConf is a hand-written messager. It has no backing file and instead + * resolves a "special" item from ItemConf during processAfterLoadAll. + */ +export class CustomItemConf extends Messager { + #specialItemConf: protoconf.ItemConf_Item | undefined; + + /** name returns the CustomItemConf's message name. */ + name(): string { + return CustomItemConfName; + } + + /** load is a no-op: this messager has no file of its own. */ + load(_dir: string, _fmt: Format, _options?: MessagerOptions): void {} + + /** processAfterLoadAll consumes ItemConf's data after all messagers load. */ + override processAfterLoadAll(hub: Hub): void { + const itemConf = hub.getItemConf(); + if (!itemConf) { + throw new Error("hub get ItemConf failed"); + } + const conf = itemConf.get1(1); + if (!conf) { + throw new Error("hub get item 1 failed"); + } + this.#specialItemConf = conf; + } + + /** getSpecialItemName returns the resolved special item's name. */ + getSpecialItemName(): string { + return this.#specialItemConf?.name ?? ""; + } +} diff --git a/_lab/ts/package-lock.json b/test/ts-tableau-loader/package-lock.json similarity index 99% rename from _lab/ts/package-lock.json rename to test/ts-tableau-loader/package-lock.json index 4bd2123..3c43c50 100644 --- a/_lab/ts/package-lock.json +++ b/test/ts-tableau-loader/package-lock.json @@ -1,11 +1,11 @@ { - "name": "tableau-ts-lab", + "name": "tableau-ts-loader-test", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tableau-ts-lab", + "name": "tableau-ts-loader-test", "version": "1.0.0", "dependencies": { "@bufbuild/protobuf": "^2.2.3" diff --git a/_lab/ts/package.json b/test/ts-tableau-loader/package.json similarity index 53% rename from _lab/ts/package.json rename to test/ts-tableau-loader/package.json index af1a74c..71fad11 100644 --- a/_lab/ts/package.json +++ b/test/ts-tableau-loader/package.json @@ -1,12 +1,13 @@ { - "name": "tableau-ts-lab", + "name": "tableau-ts-loader-test", "private": true, "type": "module", "version": "1.0.0", - "description": "PoC: validate protobuf-es as the codegen base for protoc-gen-tableau-ts", + "description": "Test harness for protoc-gen-ts-tableau-loader generated loaders.", "scripts": { - "generate": "buf generate ../../test", - "poc": "tsx src/poc.ts" + "generate": "buf generate ..", + "check": "tsc --noEmit", + "smoke": "tsx tests/smoke.ts" }, "dependencies": { "@bufbuild/protobuf": "^2.2.3" diff --git a/test/ts-tableau-loader/tableau/barrel/base.pc.ts b/test/ts-tableau-loader/tableau/barrel/base.pc.ts new file mode 100644 index 0000000..f7f91b2 --- /dev/null +++ b/test/ts-tableau-loader/tableau/barrel/base.pc.ts @@ -0,0 +1,12 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +/* eslint-disable */ + +// Barrel for proto package "base": re-exports every protobuf-es +// generated module of this package, so loaders import the whole package +// under one namespace alias (import * as base), matching the +// package-qualified naming used by loaders of other languages. + +export * from "../../protoconf/base/base_pb.js"; diff --git a/test/ts-tableau-loader/tableau/barrel/protoconf.pc.ts b/test/ts-tableau-loader/tableau/barrel/protoconf.pc.ts new file mode 100644 index 0000000..de1aaff --- /dev/null +++ b/test/ts-tableau-loader/tableau/barrel/protoconf.pc.ts @@ -0,0 +1,18 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +/* eslint-disable */ + +// Barrel for proto package "protoconf": re-exports every protobuf-es +// generated module of this package, so loaders import the whole package +// under one namespace alias (import * as protoconf), matching the +// package-qualified naming used by loaders of other languages. + +export * from "../../protoconf/common_conf_pb.js"; +export * from "../../protoconf/hero_conf_pb.js"; +export * from "../../protoconf/index_conf_pb.js"; +export * from "../../protoconf/item_conf_pb.js"; +export * from "../../protoconf/patch_conf_pb.js"; +export * from "../../protoconf/test_conf_pb.js"; +export * from "../../protoconf/union_conf_pb.js"; diff --git a/test/ts-tableau-loader/tableau/hero_conf.pc.ts b/test/ts-tableau-loader/tableau/hero_conf.pc.ts new file mode 100644 index 0000000..10c0c40 --- /dev/null +++ b/test/ts-tableau-loader/tableau/hero_conf.pc.ts @@ -0,0 +1,101 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +// source: hero_conf.proto +/* eslint-disable */ + +import { create } from "@bufbuild/protobuf"; +import { Messager } from "./messager.pc.js"; +import { Format } from "./util.pc.js"; +import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; +import * as protoconf from "./barrel/protoconf.pc.js"; +import * as base from "./barrel/base.pc.js"; + +/** + * HeroConf is a wrapper around protobuf message protoconf.HeroConf. + */ +export class HeroConf extends Messager { + #data: protoconf.HeroConf = create(protoconf.HeroConfSchema); + + /** name returns the HeroConf's message name. */ + name(): string { + return protoconf.HeroConfSchema.name; + } + + /** load loads HeroConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.HeroConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load HeroConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the HeroConf's inner message data. */ + data(): protoconf.HeroConf { + return this.#data; + } + + /** message returns the HeroConf's inner message data. */ + override message(): protoconf.HeroConf { + return this.#data; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(name: string): protoconf.HeroConf_Hero | undefined { + return this.#data.heroMap[name]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(name: string, title: string): protoconf.HeroConf_Hero_Attr | undefined { + return this.get1(name)?.attrMap[title]; + } +} + +/** + * HeroBaseConf is a wrapper around protobuf message protoconf.HeroBaseConf. + */ +export class HeroBaseConf extends Messager { + #data: protoconf.HeroBaseConf = create(protoconf.HeroBaseConfSchema); + + /** name returns the HeroBaseConf's message name. */ + name(): string { + return protoconf.HeroBaseConfSchema.name; + } + + /** load loads HeroBaseConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.HeroBaseConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load HeroBaseConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the HeroBaseConf's inner message data. */ + data(): protoconf.HeroBaseConf { + return this.#data; + } + + /** message returns the HeroBaseConf's inner message data. */ + override message(): protoconf.HeroBaseConf { + return this.#data; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(name: string): base.Hero | undefined { + return this.#data.heroMap[name]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(name: string, id: string): base.Item | undefined { + return this.get1(name)?.itemMap[id]; + } +} diff --git a/test/ts-tableau-loader/tableau/hub.pc.ts b/test/ts-tableau-loader/tableau/hub.pc.ts new file mode 100644 index 0000000..e6520f2 --- /dev/null +++ b/test/ts-tableau-loader/tableau/hub.pc.ts @@ -0,0 +1,216 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +/* eslint-disable */ + +import { Messager, type MessagerCtor } from "./messager.pc.js"; +import { Format } from "./util.pc.js"; +import { type Options, parseMessagerOptionsByName } from "./load.pc.js"; +import { HeroConf, HeroBaseConf } from "./hero_conf.pc.js"; +import { FruitConf, Fruit6Conf, Fruit2Conf, Fruit3Conf, Fruit4Conf, Fruit5Conf } from "./index_conf.pc.js"; +import { ItemConf } from "./item_conf.pc.js"; +import { PatchReplaceConf, PatchMergeConf, RecursivePatchConf } from "./patch_conf.pc.js"; +import { ActivityConf, ChapterConf, ThemeConf, TaskConf, StrcaseConf } from "./test_conf.pc.js"; + +/** + * HubOptions is the options for Hub. + */ +export interface HubOptions { + /** + * filter can only filter in certain specific messagers based on the + * condition that you provide. + */ + filter?: (name: string) => boolean; +} + +/** + * Registry manages the registration of all messager generators. + */ +export class Registry { + static readonly registrar: Map = new Map(); + + /** register registers a messager constructor. */ + static register(ctor: MessagerCtor): void { + Registry.registrar.set(new ctor().name(), ctor); + } + + /** init registers all generated messagers. */ + static init(): void { + Registry.register(HeroConf); + Registry.register(HeroBaseConf); + Registry.register(FruitConf); + Registry.register(Fruit6Conf); + Registry.register(Fruit2Conf); + Registry.register(Fruit3Conf); + Registry.register(Fruit4Conf); + Registry.register(Fruit5Conf); + Registry.register(ItemConf); + Registry.register(PatchReplaceConf); + Registry.register(PatchMergeConf); + Registry.register(RecursivePatchConf); + Registry.register(ActivityConf); + Registry.register(ChapterConf); + Registry.register(ThemeConf); + Registry.register(TaskConf); + Registry.register(StrcaseConf); + } +} + +Registry.init(); + +/** + * Hub is the messager manager. It manages loading, accessing, and storing + * all configuration messagers. + */ +export class Hub { + private messagerMap: Map = new Map(); + private lastLoadedTime: Date | undefined; + private readonly options?: HubOptions; + + constructor(options?: HubOptions) { + this.options = options; + } + + /** + * load fills messages from files in the specified directory and format. + * Throws an Error (with the underlying cause chained) on failure. + */ + load(dir: string, fmt: Format, options?: Options): void { + const messagerMap = this.newMessagerMap(); + const opts = options ?? {}; + for (const [name, messager] of messagerMap) { + try { + messager.load(dir, fmt, parseMessagerOptionsByName(opts, name)); + } catch (e) { + throw new Error(`load ${name} failed`, { cause: e }); + } + } + const tmpHub = new Hub(); + tmpHub.setMessagerMap(messagerMap); + for (const [name, messager] of messagerMap) { + try { + messager.processAfterLoadAll(tmpHub); + } catch (e) { + throw new Error(`hub call processAfterLoadAll failed, messager: ${name}`, { cause: e }); + } + } + this.setMessagerMap(messagerMap); + } + + /** getMessagerMap returns the current messager map. */ + getMessagerMap(): Map { + return this.messagerMap; + } + + /** setMessagerMap sets the messager map. */ + setMessagerMap(map: Map): void { + this.messagerMap = map; + this.lastLoadedTime = new Date(); + } + + /** getMessager returns the messager by name. */ + getMessager(name: string): Messager | undefined { + return this.messagerMap.get(name); + } + + /** getLastLoadedTime returns the time when hub's messager map was last set. */ + getLastLoadedTime(): Date | undefined { + return this.lastLoadedTime; + } + + private newMessagerMap(): Map { + const messagerMap = new Map(); + for (const [name, ctor] of Registry.registrar) { + if (!this.options?.filter || this.options.filter(name)) { + messagerMap.set(name, new ctor()); + } + } + return messagerMap; + } + + /** getHeroConf returns the HeroConf messager. */ + getHeroConf(): HeroConf | undefined { + return this.messagerMap.get("HeroConf") as HeroConf | undefined; + } + + /** getHeroBaseConf returns the HeroBaseConf messager. */ + getHeroBaseConf(): HeroBaseConf | undefined { + return this.messagerMap.get("HeroBaseConf") as HeroBaseConf | undefined; + } + + /** getFruitConf returns the FruitConf messager. */ + getFruitConf(): FruitConf | undefined { + return this.messagerMap.get("FruitConf") as FruitConf | undefined; + } + + /** getFruit6Conf returns the Fruit6Conf messager. */ + getFruit6Conf(): Fruit6Conf | undefined { + return this.messagerMap.get("Fruit6Conf") as Fruit6Conf | undefined; + } + + /** getFruit2Conf returns the Fruit2Conf messager. */ + getFruit2Conf(): Fruit2Conf | undefined { + return this.messagerMap.get("Fruit2Conf") as Fruit2Conf | undefined; + } + + /** getFruit3Conf returns the Fruit3Conf messager. */ + getFruit3Conf(): Fruit3Conf | undefined { + return this.messagerMap.get("Fruit3Conf") as Fruit3Conf | undefined; + } + + /** getFruit4Conf returns the Fruit4Conf messager. */ + getFruit4Conf(): Fruit4Conf | undefined { + return this.messagerMap.get("Fruit4Conf") as Fruit4Conf | undefined; + } + + /** getFruit5Conf returns the Fruit5Conf messager. */ + getFruit5Conf(): Fruit5Conf | undefined { + return this.messagerMap.get("Fruit5Conf") as Fruit5Conf | undefined; + } + + /** getItemConf returns the ItemConf messager. */ + getItemConf(): ItemConf | undefined { + return this.messagerMap.get("ItemConf") as ItemConf | undefined; + } + + /** getPatchReplaceConf returns the PatchReplaceConf messager. */ + getPatchReplaceConf(): PatchReplaceConf | undefined { + return this.messagerMap.get("PatchReplaceConf") as PatchReplaceConf | undefined; + } + + /** getPatchMergeConf returns the PatchMergeConf messager. */ + getPatchMergeConf(): PatchMergeConf | undefined { + return this.messagerMap.get("PatchMergeConf") as PatchMergeConf | undefined; + } + + /** getRecursivePatchConf returns the RecursivePatchConf messager. */ + getRecursivePatchConf(): RecursivePatchConf | undefined { + return this.messagerMap.get("RecursivePatchConf") as RecursivePatchConf | undefined; + } + + /** getActivityConf returns the ActivityConf messager. */ + getActivityConf(): ActivityConf | undefined { + return this.messagerMap.get("ActivityConf") as ActivityConf | undefined; + } + + /** getChapterConf returns the ChapterConf messager. */ + getChapterConf(): ChapterConf | undefined { + return this.messagerMap.get("ChapterConf") as ChapterConf | undefined; + } + + /** getThemeConf returns the ThemeConf messager. */ + getThemeConf(): ThemeConf | undefined { + return this.messagerMap.get("ThemeConf") as ThemeConf | undefined; + } + + /** getTaskConf returns the TaskConf messager. */ + getTaskConf(): TaskConf | undefined { + return this.messagerMap.get("TaskConf") as TaskConf | undefined; + } + + /** getStrcaseConf returns the StrcaseConf messager. */ + getStrcaseConf(): StrcaseConf | undefined { + return this.messagerMap.get("StrcaseConf") as StrcaseConf | undefined; + } +} diff --git a/test/ts-tableau-loader/tableau/index_conf.pc.ts b/test/ts-tableau-loader/tableau/index_conf.pc.ts new file mode 100644 index 0000000..367eee6 --- /dev/null +++ b/test/ts-tableau-loader/tableau/index_conf.pc.ts @@ -0,0 +1,1283 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +// source: index_conf.proto +/* eslint-disable */ + +import { create } from "@bufbuild/protobuf"; +import { Messager } from "./messager.pc.js"; +import { Format, compareValues, compareTuples, sortMapByKey, TupleKeyMap } from "./util.pc.js"; +import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; +import * as protoconf from "./barrel/protoconf.pc.js"; + +/** + * FruitConf is a wrapper around protobuf message protoconf.FruitConf. + */ +export class FruitConf extends Messager { + #data: protoconf.FruitConf = create(protoconf.FruitConfSchema); + #indexItemMap: FruitConf.Index_ItemMap = new Map(); + #indexItemMap1: Map = new Map(); + #orderedIndexOrderedFruitMap: FruitConf.OrderedIndex_OrderedFruitMap = new Map(); + #orderedIndexOrderedFruitMap1: Map = new Map(); + + /** name returns the FruitConf's message name. */ + name(): string { + return protoconf.FruitConfSchema.name; + } + + /** load loads FruitConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.FruitConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load FruitConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the FruitConf's inner message data. */ + data(): protoconf.FruitConf { + return this.#data; + } + + /** message returns the FruitConf's inner message data. */ + override message(): protoconf.FruitConf { + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexItemMap.clear(); + this.#indexItemMap1.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of Object.values(item1.itemMap)) { + { + // Index: Price + const key = item2.price; + { + const list = this.#indexItemMap.get(key); + if (list) { list.push(item2); } else { this.#indexItemMap.set(key, [item2]); } + } + { + let map = this.#indexItemMap1.get(k1); + if (!map) { map = new Map(); this.#indexItemMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + } + } + // Index(sort): Price + const cmpItem = (a: protoconf.FruitConf_Fruit_Item, b: protoconf.FruitConf_Fruit_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#indexItemMap.values()) { + list.sort(cmpItem); + } + for (const m of this.#indexItemMap1.values()) { + for (const list of m.values()) { + list.sort(cmpItem); + } + } + // OrderedIndex init. + this.#orderedIndexOrderedFruitMap.clear(); + this.#orderedIndexOrderedFruitMap1.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of Object.values(item1.itemMap)) { + { + // OrderedIndex: Price@OrderedFruit + const key = item2.price; + { + const list = this.#orderedIndexOrderedFruitMap.get(key); + if (list) { list.push(item2); } else { this.#orderedIndexOrderedFruitMap.set(key, [item2]); } + } + { + let map = this.#orderedIndexOrderedFruitMap1.get(k1); + if (!map) { map = new Map(); this.#orderedIndexOrderedFruitMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + } + } + // OrderedIndex(sort): Price@OrderedFruit + const cmpOrderedFruit = (a: protoconf.FruitConf_Fruit_Item, b: protoconf.FruitConf_Fruit_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexOrderedFruitMap.values()) { + list.sort(cmpOrderedFruit); + } + for (const m of this.#orderedIndexOrderedFruitMap1.values()) { + for (const list of m.values()) { + list.sort(cmpOrderedFruit); + } + } + this.#orderedIndexOrderedFruitMap = sortMapByKey(this.#orderedIndexOrderedFruitMap, compareValues); + for (const m of this.#orderedIndexOrderedFruitMap1.values()) { + sortMapByKey(m, compareValues); + } + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(fruitType: number): protoconf.FruitConf_Fruit | undefined { + return this.#data.fruitMap[fruitType]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(fruitType: number, id: number): protoconf.FruitConf_Fruit_Item | undefined { + return this.get1(fruitType)?.itemMap[id]; + } + + /** findItemMap returns the index map: key(Price) -> values. */ + findItemMap(): FruitConf.Index_ItemMap { + return this.#indexItemMap; + } + + /** findItem returns all values for the given key(s), or undefined. */ + findItem(price: number): protoconf.FruitConf_Fruit_Item[] | undefined { + return this.#indexItemMap.get(price); + } + + /** findFirstItem returns the first value for the given key(s), or undefined. */ + findFirstItem(price: number): protoconf.FruitConf_Fruit_Item | undefined { + return this.findItem(price)?.[0]; + } + + /** findItemMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findItemMap1(fruitType: number): FruitConf.Index_ItemMap | undefined { + return this.#indexItemMap1.get(fruitType); + } + + /** findItem1 returns all values for the given key(s) within the upper 1st-level map. */ + findItem1(fruitType: number, price: number): protoconf.FruitConf_Fruit_Item[] | undefined { + return this.findItemMap1(fruitType)?.get(price); + } + + /** findFirstItem1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstItem1(fruitType: number, price: number): protoconf.FruitConf_Fruit_Item | undefined { + return this.findItem1(fruitType, price)?.[0]; + } + + /** findOrderedFruitMap returns the ordered index map: key(Price@OrderedFruit) -> values. */ + findOrderedFruitMap(): FruitConf.OrderedIndex_OrderedFruitMap { + return this.#orderedIndexOrderedFruitMap; + } + + /** findOrderedFruit returns all values for the given key(s), or undefined. */ + findOrderedFruit(price: number): protoconf.FruitConf_Fruit_Item[] | undefined { + return this.#orderedIndexOrderedFruitMap.get(price); + } + + /** findFirstOrderedFruit returns the first value for the given key(s), or undefined. */ + findFirstOrderedFruit(price: number): protoconf.FruitConf_Fruit_Item | undefined { + return this.findOrderedFruit(price)?.[0]; + } + + /** findOrderedFruitMap1 returns the ordered index map scoped to the upper 1st-level map key(s). */ + findOrderedFruitMap1(fruitType: number): FruitConf.OrderedIndex_OrderedFruitMap | undefined { + return this.#orderedIndexOrderedFruitMap1.get(fruitType); + } + + /** findOrderedFruit1 returns all values for the given key(s) within the upper 1st-level map. */ + findOrderedFruit1(fruitType: number, price: number): protoconf.FruitConf_Fruit_Item[] | undefined { + return this.findOrderedFruitMap1(fruitType)?.get(price); + } + + /** findFirstOrderedFruit1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstOrderedFruit1(fruitType: number, price: number): protoconf.FruitConf_Fruit_Item | undefined { + return this.findOrderedFruit1(fruitType, price)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. FruitConf.Index_XxxMap). +export namespace FruitConf { + /** Index_ItemMap is the index map: key(Price) -> values. */ + export type Index_ItemMap = Map; + /** OrderedIndex_OrderedFruitMap is the ordered index map: key(Price@OrderedFruit) -> values. */ + export type OrderedIndex_OrderedFruitMap = Map; +} + +/** + * Fruit6Conf is a wrapper around protobuf message protoconf.Fruit6Conf. + */ +export class Fruit6Conf extends Messager { + #data: protoconf.Fruit6Conf = create(protoconf.Fruit6ConfSchema); + #indexItemMap: Fruit6Conf.Index_ItemMap = new Map(); + #indexItemMap1: Map = new Map(); + #orderedIndexOrderedFruitMap: Fruit6Conf.OrderedIndex_OrderedFruitMap = new Map(); + #orderedIndexOrderedFruitMap1: Map = new Map(); + + /** name returns the Fruit6Conf's message name. */ + name(): string { + return protoconf.Fruit6ConfSchema.name; + } + + /** load loads Fruit6Conf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.Fruit6ConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load Fruit6Conf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the Fruit6Conf's inner message data. */ + data(): protoconf.Fruit6Conf { + return this.#data; + } + + /** message returns the Fruit6Conf's inner message data. */ + override message(): protoconf.Fruit6Conf { + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexItemMap.clear(); + this.#indexItemMap1.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of item1.itemList ?? []) { + { + // Index: Price + const key = item2.price; + { + const list = this.#indexItemMap.get(key); + if (list) { list.push(item2); } else { this.#indexItemMap.set(key, [item2]); } + } + { + let map = this.#indexItemMap1.get(k1); + if (!map) { map = new Map(); this.#indexItemMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + } + } + // Index(sort): Price + const cmpItem = (a: protoconf.Fruit6Conf_Fruit_Item, b: protoconf.Fruit6Conf_Fruit_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#indexItemMap.values()) { + list.sort(cmpItem); + } + for (const m of this.#indexItemMap1.values()) { + for (const list of m.values()) { + list.sort(cmpItem); + } + } + // OrderedIndex init. + this.#orderedIndexOrderedFruitMap.clear(); + this.#orderedIndexOrderedFruitMap1.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of item1.itemList ?? []) { + { + // OrderedIndex: Price@OrderedFruit + const key = item2.price; + { + const list = this.#orderedIndexOrderedFruitMap.get(key); + if (list) { list.push(item2); } else { this.#orderedIndexOrderedFruitMap.set(key, [item2]); } + } + { + let map = this.#orderedIndexOrderedFruitMap1.get(k1); + if (!map) { map = new Map(); this.#orderedIndexOrderedFruitMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + } + } + // OrderedIndex(sort): Price@OrderedFruit + const cmpOrderedFruit = (a: protoconf.Fruit6Conf_Fruit_Item, b: protoconf.Fruit6Conf_Fruit_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexOrderedFruitMap.values()) { + list.sort(cmpOrderedFruit); + } + for (const m of this.#orderedIndexOrderedFruitMap1.values()) { + for (const list of m.values()) { + list.sort(cmpOrderedFruit); + } + } + this.#orderedIndexOrderedFruitMap = sortMapByKey(this.#orderedIndexOrderedFruitMap, compareValues); + for (const m of this.#orderedIndexOrderedFruitMap1.values()) { + sortMapByKey(m, compareValues); + } + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(fruitType: number): protoconf.Fruit6Conf_Fruit | undefined { + return this.#data.fruitMap[fruitType]; + } + + /** findItemMap returns the index map: key(Price) -> values. */ + findItemMap(): Fruit6Conf.Index_ItemMap { + return this.#indexItemMap; + } + + /** findItem returns all values for the given key(s), or undefined. */ + findItem(price: number): protoconf.Fruit6Conf_Fruit_Item[] | undefined { + return this.#indexItemMap.get(price); + } + + /** findFirstItem returns the first value for the given key(s), or undefined. */ + findFirstItem(price: number): protoconf.Fruit6Conf_Fruit_Item | undefined { + return this.findItem(price)?.[0]; + } + + /** findItemMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findItemMap1(fruitType: number): Fruit6Conf.Index_ItemMap | undefined { + return this.#indexItemMap1.get(fruitType); + } + + /** findItem1 returns all values for the given key(s) within the upper 1st-level map. */ + findItem1(fruitType: number, price: number): protoconf.Fruit6Conf_Fruit_Item[] | undefined { + return this.findItemMap1(fruitType)?.get(price); + } + + /** findFirstItem1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstItem1(fruitType: number, price: number): protoconf.Fruit6Conf_Fruit_Item | undefined { + return this.findItem1(fruitType, price)?.[0]; + } + + /** findOrderedFruitMap returns the ordered index map: key(Price@OrderedFruit) -> values. */ + findOrderedFruitMap(): Fruit6Conf.OrderedIndex_OrderedFruitMap { + return this.#orderedIndexOrderedFruitMap; + } + + /** findOrderedFruit returns all values for the given key(s), or undefined. */ + findOrderedFruit(price: number): protoconf.Fruit6Conf_Fruit_Item[] | undefined { + return this.#orderedIndexOrderedFruitMap.get(price); + } + + /** findFirstOrderedFruit returns the first value for the given key(s), or undefined. */ + findFirstOrderedFruit(price: number): protoconf.Fruit6Conf_Fruit_Item | undefined { + return this.findOrderedFruit(price)?.[0]; + } + + /** findOrderedFruitMap1 returns the ordered index map scoped to the upper 1st-level map key(s). */ + findOrderedFruitMap1(fruitType: number): Fruit6Conf.OrderedIndex_OrderedFruitMap | undefined { + return this.#orderedIndexOrderedFruitMap1.get(fruitType); + } + + /** findOrderedFruit1 returns all values for the given key(s) within the upper 1st-level map. */ + findOrderedFruit1(fruitType: number, price: number): protoconf.Fruit6Conf_Fruit_Item[] | undefined { + return this.findOrderedFruitMap1(fruitType)?.get(price); + } + + /** findFirstOrderedFruit1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstOrderedFruit1(fruitType: number, price: number): protoconf.Fruit6Conf_Fruit_Item | undefined { + return this.findOrderedFruit1(fruitType, price)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. Fruit6Conf.Index_XxxMap). +export namespace Fruit6Conf { + /** Index_ItemMap is the index map: key(Price) -> values. */ + export type Index_ItemMap = Map; + /** OrderedIndex_OrderedFruitMap is the ordered index map: key(Price@OrderedFruit) -> values. */ + export type OrderedIndex_OrderedFruitMap = Map; +} + +/** + * Fruit2Conf is a wrapper around protobuf message protoconf.Fruit2Conf. + */ +export class Fruit2Conf extends Messager { + #data: protoconf.Fruit2Conf = create(protoconf.Fruit2ConfSchema); + #indexCountryMap: Fruit2Conf.Index_CountryMap = new Map(); + #indexCountryMap1: Map = new Map(); + #indexAttrMap: Fruit2Conf.Index_AttrMap = new Map(); + #indexAttrMap1: Map = new Map(); + #indexAttrMap2: TupleKeyMap = new TupleKeyMap(); + #orderedIndexItemMap: Fruit2Conf.OrderedIndex_ItemMap = new Map(); + #orderedIndexItemMap1: Map = new Map(); + + /** name returns the Fruit2Conf's message name. */ + name(): string { + return protoconf.Fruit2ConfSchema.name; + } + + /** load loads Fruit2Conf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.Fruit2ConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load Fruit2Conf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the Fruit2Conf's inner message data. */ + data(): protoconf.Fruit2Conf { + return this.#data; + } + + /** message returns the Fruit2Conf's inner message data. */ + override message(): protoconf.Fruit2Conf { + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexCountryMap.clear(); + this.#indexCountryMap1.clear(); + this.#indexAttrMap.clear(); + this.#indexAttrMap1.clear(); + this.#indexAttrMap2.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of item1.countryList ?? []) { + { + // Index: CountryName + const key = item2.name; + { + const list = this.#indexCountryMap.get(key); + if (list) { list.push(item2); } else { this.#indexCountryMap.set(key, [item2]); } + } + { + let map = this.#indexCountryMap1.get(k1); + if (!map) { map = new Map(); this.#indexCountryMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + for (const [k2Str, item3] of Object.entries(item2.itemMap)) { + const k2 = Number(k2Str); + for (const item4 of item3.attrList ?? []) { + { + // Index: CountryItemAttrName + const key = item4.name; + { + const list = this.#indexAttrMap.get(key); + if (list) { list.push(item4); } else { this.#indexAttrMap.set(key, [item4]); } + } + { + let map = this.#indexAttrMap1.get(k1); + if (!map) { map = new Map(); this.#indexAttrMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + { + const map = this.#indexAttrMap2.getOrSet([k1, k2], () => new Map()); + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + } + } + } + } + } + // OrderedIndex init. + this.#orderedIndexItemMap.clear(); + this.#orderedIndexItemMap1.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of item1.countryList ?? []) { + for (const item3 of Object.values(item2.itemMap)) { + { + // OrderedIndex: CountryItemPrice + const key = item3.price; + { + const list = this.#orderedIndexItemMap.get(key); + if (list) { list.push(item3); } else { this.#orderedIndexItemMap.set(key, [item3]); } + } + { + let map = this.#orderedIndexItemMap1.get(k1); + if (!map) { map = new Map(); this.#orderedIndexItemMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item3); } else { map.set(key, [item3]); } + } + } + } + } + } + } + // OrderedIndex(sort): CountryItemPrice + const cmpItem = (a: protoconf.Fruit2Conf_Fruit_Country_Item, b: protoconf.Fruit2Conf_Fruit_Country_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexItemMap.values()) { + list.sort(cmpItem); + } + for (const m of this.#orderedIndexItemMap1.values()) { + for (const list of m.values()) { + list.sort(cmpItem); + } + } + this.#orderedIndexItemMap = sortMapByKey(this.#orderedIndexItemMap, compareValues); + for (const m of this.#orderedIndexItemMap1.values()) { + sortMapByKey(m, compareValues); + } + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(fruitType: number): protoconf.Fruit2Conf_Fruit | undefined { + return this.#data.fruitMap[fruitType]; + } + + /** findCountryMap returns the index map: key(CountryName) -> values. */ + findCountryMap(): Fruit2Conf.Index_CountryMap { + return this.#indexCountryMap; + } + + /** findCountry returns all values for the given key(s), or undefined. */ + findCountry(name: string): protoconf.Fruit2Conf_Fruit_Country[] | undefined { + return this.#indexCountryMap.get(name); + } + + /** findFirstCountry returns the first value for the given key(s), or undefined. */ + findFirstCountry(name: string): protoconf.Fruit2Conf_Fruit_Country | undefined { + return this.findCountry(name)?.[0]; + } + + /** findCountryMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findCountryMap1(fruitType: number): Fruit2Conf.Index_CountryMap | undefined { + return this.#indexCountryMap1.get(fruitType); + } + + /** findCountry1 returns all values for the given key(s) within the upper 1st-level map. */ + findCountry1(fruitType: number, name: string): protoconf.Fruit2Conf_Fruit_Country[] | undefined { + return this.findCountryMap1(fruitType)?.get(name); + } + + /** findFirstCountry1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstCountry1(fruitType: number, name: string): protoconf.Fruit2Conf_Fruit_Country | undefined { + return this.findCountry1(fruitType, name)?.[0]; + } + + /** findAttrMap returns the index map: key(CountryItemAttrName) -> values. */ + findAttrMap(): Fruit2Conf.Index_AttrMap { + return this.#indexAttrMap; + } + + /** findAttr returns all values for the given key(s), or undefined. */ + findAttr(name: string): protoconf.Fruit2Conf_Fruit_Country_Item_Attr[] | undefined { + return this.#indexAttrMap.get(name); + } + + /** findFirstAttr returns the first value for the given key(s), or undefined. */ + findFirstAttr(name: string): protoconf.Fruit2Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr(name)?.[0]; + } + + /** findAttrMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findAttrMap1(fruitType: number): Fruit2Conf.Index_AttrMap | undefined { + return this.#indexAttrMap1.get(fruitType); + } + + /** findAttr1 returns all values for the given key(s) within the upper 1st-level map. */ + findAttr1(fruitType: number, name: string): protoconf.Fruit2Conf_Fruit_Country_Item_Attr[] | undefined { + return this.findAttrMap1(fruitType)?.get(name); + } + + /** findFirstAttr1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstAttr1(fruitType: number, name: string): protoconf.Fruit2Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr1(fruitType, name)?.[0]; + } + + /** findAttrMap2 returns the index map scoped to the upper 2nd-level map key(s). */ + findAttrMap2(fruitType: number, id: number): Fruit2Conf.Index_AttrMap | undefined { + return this.#indexAttrMap2.get([fruitType, id]); + } + + /** findAttr2 returns all values for the given key(s) within the upper 2nd-level map. */ + findAttr2(fruitType: number, id: number, name: string): protoconf.Fruit2Conf_Fruit_Country_Item_Attr[] | undefined { + return this.findAttrMap2(fruitType, id)?.get(name); + } + + /** findFirstAttr2 returns the first value for the given key(s) within the upper 2nd-level map. */ + findFirstAttr2(fruitType: number, id: number, name: string): protoconf.Fruit2Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr2(fruitType, id, name)?.[0]; + } + + /** findItemMap returns the ordered index map: key(CountryItemPrice) -> values. */ + findItemMap(): Fruit2Conf.OrderedIndex_ItemMap { + return this.#orderedIndexItemMap; + } + + /** findItem returns all values for the given key(s), or undefined. */ + findItem(price: number): protoconf.Fruit2Conf_Fruit_Country_Item[] | undefined { + return this.#orderedIndexItemMap.get(price); + } + + /** findFirstItem returns the first value for the given key(s), or undefined. */ + findFirstItem(price: number): protoconf.Fruit2Conf_Fruit_Country_Item | undefined { + return this.findItem(price)?.[0]; + } + + /** findItemMap1 returns the ordered index map scoped to the upper 1st-level map key(s). */ + findItemMap1(fruitType: number): Fruit2Conf.OrderedIndex_ItemMap | undefined { + return this.#orderedIndexItemMap1.get(fruitType); + } + + /** findItem1 returns all values for the given key(s) within the upper 1st-level map. */ + findItem1(fruitType: number, price: number): protoconf.Fruit2Conf_Fruit_Country_Item[] | undefined { + return this.findItemMap1(fruitType)?.get(price); + } + + /** findFirstItem1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstItem1(fruitType: number, price: number): protoconf.Fruit2Conf_Fruit_Country_Item | undefined { + return this.findItem1(fruitType, price)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. Fruit2Conf.Index_XxxMap). +export namespace Fruit2Conf { + /** LevelIndex_Fruit_Country_ItemKey is the composite upper map key (k1..k2) of the 2nd-level leveled containers. */ + export type LevelIndex_Fruit_Country_ItemKey = readonly [fruitType: number, id: number]; + /** Index_CountryMap is the index map: key(CountryName) -> values. */ + export type Index_CountryMap = Map; + /** Index_AttrMap is the index map: key(CountryItemAttrName) -> values. */ + export type Index_AttrMap = Map; + /** OrderedIndex_ItemMap is the ordered index map: key(CountryItemPrice) -> values. */ + export type OrderedIndex_ItemMap = Map; +} + +/** + * Fruit3Conf is a wrapper around protobuf message protoconf.Fruit3Conf. + */ +export class Fruit3Conf extends Messager { + #data: protoconf.Fruit3Conf = create(protoconf.Fruit3ConfSchema); + #indexCountryMap: Fruit3Conf.Index_CountryMap = new Map(); + #indexAttrMap: Fruit3Conf.Index_AttrMap = new Map(); + #indexAttrMap1: Map = new Map(); + #orderedIndexItemMap: Fruit3Conf.OrderedIndex_ItemMap = new Map(); + + /** name returns the Fruit3Conf's message name. */ + name(): string { + return protoconf.Fruit3ConfSchema.name; + } + + /** load loads Fruit3Conf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.Fruit3ConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load Fruit3Conf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the Fruit3Conf's inner message data. */ + data(): protoconf.Fruit3Conf { + return this.#data; + } + + /** message returns the Fruit3Conf's inner message data. */ + override message(): protoconf.Fruit3Conf { + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexCountryMap.clear(); + this.#indexAttrMap.clear(); + this.#indexAttrMap1.clear(); + for (const item1 of this.#data.fruitList ?? []) { + for (const item2 of item1.countryList ?? []) { + { + // Index: CountryName + const key = item2.name; + { + const list = this.#indexCountryMap.get(key); + if (list) { list.push(item2); } else { this.#indexCountryMap.set(key, [item2]); } + } + } + for (const [k1Str, item3] of Object.entries(item2.itemMap)) { + const k1 = Number(k1Str); + for (const item4 of item3.attrList ?? []) { + { + // Index: CountryItemAttrName + const key = item4.name; + { + const list = this.#indexAttrMap.get(key); + if (list) { list.push(item4); } else { this.#indexAttrMap.set(key, [item4]); } + } + { + let map = this.#indexAttrMap1.get(k1); + if (!map) { map = new Map(); this.#indexAttrMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + } + } + } + } + } + // OrderedIndex init. + this.#orderedIndexItemMap.clear(); + for (const item1 of this.#data.fruitList ?? []) { + for (const item2 of item1.countryList ?? []) { + for (const item3 of Object.values(item2.itemMap)) { + { + // OrderedIndex: CountryItemPrice + const key = item3.price; + { + const list = this.#orderedIndexItemMap.get(key); + if (list) { list.push(item3); } else { this.#orderedIndexItemMap.set(key, [item3]); } + } + } + } + } + } + // OrderedIndex(sort): CountryItemPrice + const cmpItem = (a: protoconf.Fruit3Conf_Fruit_Country_Item, b: protoconf.Fruit3Conf_Fruit_Country_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexItemMap.values()) { + list.sort(cmpItem); + } + this.#orderedIndexItemMap = sortMapByKey(this.#orderedIndexItemMap, compareValues); + } + + /** findCountryMap returns the index map: key(CountryName) -> values. */ + findCountryMap(): Fruit3Conf.Index_CountryMap { + return this.#indexCountryMap; + } + + /** findCountry returns all values for the given key(s), or undefined. */ + findCountry(name: string): protoconf.Fruit3Conf_Fruit_Country[] | undefined { + return this.#indexCountryMap.get(name); + } + + /** findFirstCountry returns the first value for the given key(s), or undefined. */ + findFirstCountry(name: string): protoconf.Fruit3Conf_Fruit_Country | undefined { + return this.findCountry(name)?.[0]; + } + + /** findAttrMap returns the index map: key(CountryItemAttrName) -> values. */ + findAttrMap(): Fruit3Conf.Index_AttrMap { + return this.#indexAttrMap; + } + + /** findAttr returns all values for the given key(s), or undefined. */ + findAttr(name: string): protoconf.Fruit3Conf_Fruit_Country_Item_Attr[] | undefined { + return this.#indexAttrMap.get(name); + } + + /** findFirstAttr returns the first value for the given key(s), or undefined. */ + findFirstAttr(name: string): protoconf.Fruit3Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr(name)?.[0]; + } + + /** findAttrMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findAttrMap1(id: number): Fruit3Conf.Index_AttrMap | undefined { + return this.#indexAttrMap1.get(id); + } + + /** findAttr1 returns all values for the given key(s) within the upper 1st-level map. */ + findAttr1(id: number, name: string): protoconf.Fruit3Conf_Fruit_Country_Item_Attr[] | undefined { + return this.findAttrMap1(id)?.get(name); + } + + /** findFirstAttr1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstAttr1(id: number, name: string): protoconf.Fruit3Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr1(id, name)?.[0]; + } + + /** findItemMap returns the ordered index map: key(CountryItemPrice) -> values. */ + findItemMap(): Fruit3Conf.OrderedIndex_ItemMap { + return this.#orderedIndexItemMap; + } + + /** findItem returns all values for the given key(s), or undefined. */ + findItem(price: number): protoconf.Fruit3Conf_Fruit_Country_Item[] | undefined { + return this.#orderedIndexItemMap.get(price); + } + + /** findFirstItem returns the first value for the given key(s), or undefined. */ + findFirstItem(price: number): protoconf.Fruit3Conf_Fruit_Country_Item | undefined { + return this.findItem(price)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. Fruit3Conf.Index_XxxMap). +export namespace Fruit3Conf { + /** Index_CountryMap is the index map: key(CountryName) -> values. */ + export type Index_CountryMap = Map; + /** Index_AttrMap is the index map: key(CountryItemAttrName) -> values. */ + export type Index_AttrMap = Map; + /** OrderedIndex_ItemMap is the ordered index map: key(CountryItemPrice) -> values. */ + export type OrderedIndex_ItemMap = Map; +} + +/** + * Fruit4Conf is a wrapper around protobuf message protoconf.Fruit4Conf. + */ +export class Fruit4Conf extends Messager { + #data: protoconf.Fruit4Conf = create(protoconf.Fruit4ConfSchema); + #indexCountryMap: Fruit4Conf.Index_CountryMap = new Map(); + #indexCountryMap1: Map = new Map(); + #indexAttrMap: Fruit4Conf.Index_AttrMap = new Map(); + #indexAttrMap1: Map = new Map(); + #indexAttrMap2: TupleKeyMap = new TupleKeyMap(); + #indexAttrMap3: TupleKeyMap = new TupleKeyMap(); + #orderedIndexItemMap: Fruit4Conf.OrderedIndex_ItemMap = new Map(); + #orderedIndexItemMap1: Map = new Map(); + #orderedIndexItemMap2: TupleKeyMap = new TupleKeyMap(); + + /** name returns the Fruit4Conf's message name. */ + name(): string { + return protoconf.Fruit4ConfSchema.name; + } + + /** load loads Fruit4Conf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.Fruit4ConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load Fruit4Conf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the Fruit4Conf's inner message data. */ + data(): protoconf.Fruit4Conf { + return this.#data; + } + + /** message returns the Fruit4Conf's inner message data. */ + override message(): protoconf.Fruit4Conf { + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexCountryMap.clear(); + this.#indexCountryMap1.clear(); + this.#indexAttrMap.clear(); + this.#indexAttrMap1.clear(); + this.#indexAttrMap2.clear(); + this.#indexAttrMap3.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const [k2Str, item2] of Object.entries(item1.countryMap)) { + const k2 = Number(k2Str); + { + // Index: CountryName + const key = item2.name; + { + const list = this.#indexCountryMap.get(key); + if (list) { list.push(item2); } else { this.#indexCountryMap.set(key, [item2]); } + } + { + let map = this.#indexCountryMap1.get(k1); + if (!map) { map = new Map(); this.#indexCountryMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + for (const [k3Str, item3] of Object.entries(item2.itemMap)) { + const k3 = Number(k3Str); + for (const item4 of item3.attrList ?? []) { + { + // Index: CountryItemAttrName + const key = item4.name; + { + const list = this.#indexAttrMap.get(key); + if (list) { list.push(item4); } else { this.#indexAttrMap.set(key, [item4]); } + } + { + let map = this.#indexAttrMap1.get(k1); + if (!map) { map = new Map(); this.#indexAttrMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + { + const map = this.#indexAttrMap2.getOrSet([k1, k2], () => new Map()); + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + { + const map = this.#indexAttrMap3.getOrSet([k1, k2, k3], () => new Map()); + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + } + } + } + } + } + // OrderedIndex init. + this.#orderedIndexItemMap.clear(); + this.#orderedIndexItemMap1.clear(); + this.#orderedIndexItemMap2.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const [k2Str, item2] of Object.entries(item1.countryMap)) { + const k2 = Number(k2Str); + for (const item3 of Object.values(item2.itemMap)) { + { + // OrderedIndex: CountryItemPrice + const key = item3.price; + { + const list = this.#orderedIndexItemMap.get(key); + if (list) { list.push(item3); } else { this.#orderedIndexItemMap.set(key, [item3]); } + } + { + let map = this.#orderedIndexItemMap1.get(k1); + if (!map) { map = new Map(); this.#orderedIndexItemMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item3); } else { map.set(key, [item3]); } + } + } + { + const map = this.#orderedIndexItemMap2.getOrSet([k1, k2], () => new Map()); + { + const list = map.get(key); + if (list) { list.push(item3); } else { map.set(key, [item3]); } + } + } + } + } + } + } + // OrderedIndex(sort): CountryItemPrice + const cmpItem = (a: protoconf.Fruit4Conf_Fruit_Country_Item, b: protoconf.Fruit4Conf_Fruit_Country_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexItemMap.values()) { + list.sort(cmpItem); + } + for (const m of this.#orderedIndexItemMap1.values()) { + for (const list of m.values()) { + list.sort(cmpItem); + } + } + for (const m of this.#orderedIndexItemMap2.values()) { + for (const list of m.values()) { + list.sort(cmpItem); + } + } + this.#orderedIndexItemMap = sortMapByKey(this.#orderedIndexItemMap, compareValues); + for (const m of this.#orderedIndexItemMap1.values()) { + sortMapByKey(m, compareValues); + } + for (const m of this.#orderedIndexItemMap2.values()) { + sortMapByKey(m, compareValues); + } + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(fruitType: number): protoconf.Fruit4Conf_Fruit | undefined { + return this.#data.fruitMap[fruitType]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(fruitType: number, id: number): protoconf.Fruit4Conf_Fruit_Country | undefined { + return this.get1(fruitType)?.countryMap[id]; + } + + /** get3 finds value in the 3rd-level map; returns undefined if not found. */ + get3(fruitType: number, id: number, id3: number): protoconf.Fruit4Conf_Fruit_Country_Item | undefined { + return this.get2(fruitType, id)?.itemMap[id3]; + } + + /** findCountryMap returns the index map: key(CountryName) -> values. */ + findCountryMap(): Fruit4Conf.Index_CountryMap { + return this.#indexCountryMap; + } + + /** findCountry returns all values for the given key(s), or undefined. */ + findCountry(name: string): protoconf.Fruit4Conf_Fruit_Country[] | undefined { + return this.#indexCountryMap.get(name); + } + + /** findFirstCountry returns the first value for the given key(s), or undefined. */ + findFirstCountry(name: string): protoconf.Fruit4Conf_Fruit_Country | undefined { + return this.findCountry(name)?.[0]; + } + + /** findCountryMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findCountryMap1(fruitType: number): Fruit4Conf.Index_CountryMap | undefined { + return this.#indexCountryMap1.get(fruitType); + } + + /** findCountry1 returns all values for the given key(s) within the upper 1st-level map. */ + findCountry1(fruitType: number, name: string): protoconf.Fruit4Conf_Fruit_Country[] | undefined { + return this.findCountryMap1(fruitType)?.get(name); + } + + /** findFirstCountry1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstCountry1(fruitType: number, name: string): protoconf.Fruit4Conf_Fruit_Country | undefined { + return this.findCountry1(fruitType, name)?.[0]; + } + + /** findAttrMap returns the index map: key(CountryItemAttrName) -> values. */ + findAttrMap(): Fruit4Conf.Index_AttrMap { + return this.#indexAttrMap; + } + + /** findAttr returns all values for the given key(s), or undefined. */ + findAttr(name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr[] | undefined { + return this.#indexAttrMap.get(name); + } + + /** findFirstAttr returns the first value for the given key(s), or undefined. */ + findFirstAttr(name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr(name)?.[0]; + } + + /** findAttrMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findAttrMap1(fruitType: number): Fruit4Conf.Index_AttrMap | undefined { + return this.#indexAttrMap1.get(fruitType); + } + + /** findAttr1 returns all values for the given key(s) within the upper 1st-level map. */ + findAttr1(fruitType: number, name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr[] | undefined { + return this.findAttrMap1(fruitType)?.get(name); + } + + /** findFirstAttr1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstAttr1(fruitType: number, name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr1(fruitType, name)?.[0]; + } + + /** findAttrMap2 returns the index map scoped to the upper 2nd-level map key(s). */ + findAttrMap2(fruitType: number, id: number): Fruit4Conf.Index_AttrMap | undefined { + return this.#indexAttrMap2.get([fruitType, id]); + } + + /** findAttr2 returns all values for the given key(s) within the upper 2nd-level map. */ + findAttr2(fruitType: number, id: number, name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr[] | undefined { + return this.findAttrMap2(fruitType, id)?.get(name); + } + + /** findFirstAttr2 returns the first value for the given key(s) within the upper 2nd-level map. */ + findFirstAttr2(fruitType: number, id: number, name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr2(fruitType, id, name)?.[0]; + } + + /** findAttrMap3 returns the index map scoped to the upper 3rd-level map key(s). */ + findAttrMap3(fruitType: number, id: number, id3: number): Fruit4Conf.Index_AttrMap | undefined { + return this.#indexAttrMap3.get([fruitType, id, id3]); + } + + /** findAttr3 returns all values for the given key(s) within the upper 3rd-level map. */ + findAttr3(fruitType: number, id: number, id3: number, name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr[] | undefined { + return this.findAttrMap3(fruitType, id, id3)?.get(name); + } + + /** findFirstAttr3 returns the first value for the given key(s) within the upper 3rd-level map. */ + findFirstAttr3(fruitType: number, id: number, id3: number, name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr3(fruitType, id, id3, name)?.[0]; + } + + /** findItemMap returns the ordered index map: key(CountryItemPrice) -> values. */ + findItemMap(): Fruit4Conf.OrderedIndex_ItemMap { + return this.#orderedIndexItemMap; + } + + /** findItem returns all values for the given key(s), or undefined. */ + findItem(price: number): protoconf.Fruit4Conf_Fruit_Country_Item[] | undefined { + return this.#orderedIndexItemMap.get(price); + } + + /** findFirstItem returns the first value for the given key(s), or undefined. */ + findFirstItem(price: number): protoconf.Fruit4Conf_Fruit_Country_Item | undefined { + return this.findItem(price)?.[0]; + } + + /** findItemMap1 returns the ordered index map scoped to the upper 1st-level map key(s). */ + findItemMap1(fruitType: number): Fruit4Conf.OrderedIndex_ItemMap | undefined { + return this.#orderedIndexItemMap1.get(fruitType); + } + + /** findItem1 returns all values for the given key(s) within the upper 1st-level map. */ + findItem1(fruitType: number, price: number): protoconf.Fruit4Conf_Fruit_Country_Item[] | undefined { + return this.findItemMap1(fruitType)?.get(price); + } + + /** findFirstItem1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstItem1(fruitType: number, price: number): protoconf.Fruit4Conf_Fruit_Country_Item | undefined { + return this.findItem1(fruitType, price)?.[0]; + } + + /** findItemMap2 returns the ordered index map scoped to the upper 2nd-level map key(s). */ + findItemMap2(fruitType: number, id: number): Fruit4Conf.OrderedIndex_ItemMap | undefined { + return this.#orderedIndexItemMap2.get([fruitType, id]); + } + + /** findItem2 returns all values for the given key(s) within the upper 2nd-level map. */ + findItem2(fruitType: number, id: number, price: number): protoconf.Fruit4Conf_Fruit_Country_Item[] | undefined { + return this.findItemMap2(fruitType, id)?.get(price); + } + + /** findFirstItem2 returns the first value for the given key(s) within the upper 2nd-level map. */ + findFirstItem2(fruitType: number, id: number, price: number): protoconf.Fruit4Conf_Fruit_Country_Item | undefined { + return this.findItem2(fruitType, id, price)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. Fruit4Conf.Index_XxxMap). +export namespace Fruit4Conf { + /** LevelIndex_Fruit_CountryKey is the composite upper map key (k1..k2) of the 2nd-level leveled containers. */ + export type LevelIndex_Fruit_CountryKey = readonly [fruitType: number, id: number]; + /** LevelIndex_Fruit_Country_ItemKey is the composite upper map key (k1..k3) of the 3rd-level leveled containers. */ + export type LevelIndex_Fruit_Country_ItemKey = readonly [fruitType: number, id: number, id3: number]; + /** Index_CountryMap is the index map: key(CountryName) -> values. */ + export type Index_CountryMap = Map; + /** Index_AttrMap is the index map: key(CountryItemAttrName) -> values. */ + export type Index_AttrMap = Map; + /** OrderedIndex_ItemMap is the ordered index map: key(CountryItemPrice) -> values. */ + export type OrderedIndex_ItemMap = Map; +} + +/** + * Fruit5Conf is a wrapper around protobuf message protoconf.Fruit5Conf. + */ +export class Fruit5Conf extends Messager { + #data: protoconf.Fruit5Conf = create(protoconf.Fruit5ConfSchema); + #indexCountryMap: Fruit5Conf.Index_CountryMap = new Map(); + #indexCountryMap1: Map = new Map(); + + /** name returns the Fruit5Conf's message name. */ + name(): string { + return protoconf.Fruit5ConfSchema.name; + } + + /** load loads Fruit5Conf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.Fruit5ConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load Fruit5Conf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the Fruit5Conf's inner message data. */ + data(): protoconf.Fruit5Conf { + return this.#data; + } + + /** message returns the Fruit5Conf's inner message data. */ + override message(): protoconf.Fruit5Conf { + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexCountryMap.clear(); + this.#indexCountryMap1.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of Object.values(item1.countryMap)) { + { + // Index: CountryName + const key = item2.name; + { + const list = this.#indexCountryMap.get(key); + if (list) { list.push(item2); } else { this.#indexCountryMap.set(key, [item2]); } + } + { + let map = this.#indexCountryMap1.get(k1); + if (!map) { map = new Map(); this.#indexCountryMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + } + } + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(fruitType: number): protoconf.Fruit5Conf_Fruit | undefined { + return this.#data.fruitMap[fruitType]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(fruitType: number, id: number): protoconf.Fruit5Conf_Fruit_Country | undefined { + return this.get1(fruitType)?.countryMap[id]; + } + + /** get3 finds value in the 3rd-level map; returns undefined if not found. */ + get3(fruitType: number, id: number, id3: number): protoconf.Fruit5Conf_Fruit_Country_Item | undefined { + return this.get2(fruitType, id)?.itemMap[id3]; + } + + /** findCountryMap returns the index map: key(CountryName) -> values. */ + findCountryMap(): Fruit5Conf.Index_CountryMap { + return this.#indexCountryMap; + } + + /** findCountry returns all values for the given key(s), or undefined. */ + findCountry(name: string): protoconf.Fruit5Conf_Fruit_Country[] | undefined { + return this.#indexCountryMap.get(name); + } + + /** findFirstCountry returns the first value for the given key(s), or undefined. */ + findFirstCountry(name: string): protoconf.Fruit5Conf_Fruit_Country | undefined { + return this.findCountry(name)?.[0]; + } + + /** findCountryMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findCountryMap1(fruitType: number): Fruit5Conf.Index_CountryMap | undefined { + return this.#indexCountryMap1.get(fruitType); + } + + /** findCountry1 returns all values for the given key(s) within the upper 1st-level map. */ + findCountry1(fruitType: number, name: string): protoconf.Fruit5Conf_Fruit_Country[] | undefined { + return this.findCountryMap1(fruitType)?.get(name); + } + + /** findFirstCountry1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstCountry1(fruitType: number, name: string): protoconf.Fruit5Conf_Fruit_Country | undefined { + return this.findCountry1(fruitType, name)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. Fruit5Conf.Index_XxxMap). +export namespace Fruit5Conf { + /** Index_CountryMap is the index map: key(CountryName) -> values. */ + export type Index_CountryMap = Map; +} diff --git a/test/ts-tableau-loader/tableau/item_conf.pc.ts b/test/ts-tableau-loader/tableau/item_conf.pc.ts new file mode 100644 index 0000000..47cec32 --- /dev/null +++ b/test/ts-tableau-loader/tableau/item_conf.pc.ts @@ -0,0 +1,437 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +// source: item_conf.proto +/* eslint-disable */ + +import { create } from "@bufbuild/protobuf"; +import { Messager } from "./messager.pc.js"; +import { Format, compareValues, compareTuples, sortMapByKey, TupleKeyMap } from "./util.pc.js"; +import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; +import * as protoconf from "./barrel/protoconf.pc.js"; + +/** + * ItemConf is a wrapper around protobuf message protoconf.ItemConf. + */ +export class ItemConf extends Messager { + #data: protoconf.ItemConf = create(protoconf.ItemConfSchema); + #orderedMap: ItemConf.OrderedMap_ItemMap = new Map(); + #indexItemMap: ItemConf.Index_ItemMap = new Map(); + #indexItemInfoMap: ItemConf.Index_ItemInfoMap = new Map(); + #indexItemDefaultInfoMap: ItemConf.Index_ItemDefaultInfoMap = new Map(); + #indexItemExtInfoMap: ItemConf.Index_ItemExtInfoMap = new Map(); + #indexAwardItemMap: ItemConf.Index_AwardItemMap = new TupleKeyMap(); + #indexSpecialItemMap: ItemConf.Index_SpecialItemMap = new TupleKeyMap(); + #indexItemPathDirMap: ItemConf.Index_ItemPathDirMap = new Map(); + #indexItemPathNameMap: ItemConf.Index_ItemPathNameMap = new Map(); + #indexItemPathFriendIDMap: ItemConf.Index_ItemPathFriendIDMap = new Map(); + #indexUseEffectTypeMap: ItemConf.Index_UseEffectTypeMap = new Map(); + #orderedIndexExtTypeMap: ItemConf.OrderedIndex_ExtTypeMap = new Map(); + #orderedIndexParamExtTypeMap: ItemConf.OrderedIndex_ParamExtTypeMap = new TupleKeyMap(); + + /** name returns the ItemConf's message name. */ + name(): string { + return protoconf.ItemConfSchema.name; + } + + /** load loads ItemConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.ItemConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load ItemConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the ItemConf's inner message data. */ + data(): protoconf.ItemConf { + return this.#data; + } + + /** message returns the ItemConf's inner message data. */ + override message(): protoconf.ItemConf { + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // OrderedMap init. + const orderedMap: ItemConf.OrderedMap_ItemMap = new Map(); + for (const [k1Str, v1] of Object.entries(this.#data.itemMap)) { + const k1 = Number(k1Str); + orderedMap.set(k1, v1); + } + sortMapByKey(orderedMap, compareValues); + this.#orderedMap = orderedMap; + // Index init. + this.#indexItemMap.clear(); + this.#indexItemInfoMap.clear(); + this.#indexItemDefaultInfoMap.clear(); + this.#indexItemExtInfoMap.clear(); + this.#indexAwardItemMap.clear(); + this.#indexSpecialItemMap.clear(); + this.#indexItemPathDirMap.clear(); + this.#indexItemPathNameMap.clear(); + this.#indexItemPathFriendIDMap.clear(); + this.#indexUseEffectTypeMap.clear(); + for (const item1 of Object.values(this.#data.itemMap)) { + { + // Index: Type + const key = item1.type; + { + const list = this.#indexItemMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemMap.set(key, [item1]); } + } + } + { + // Index: Param@ItemInfo + for (const elem of item1.paramList ?? []) { + const key = elem; + { + const list = this.#indexItemInfoMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemInfoMap.set(key, [item1]); } + } + } + } + { + // Index: Default@ItemDefaultInfo + const key = item1.default; + { + const list = this.#indexItemDefaultInfoMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemDefaultInfoMap.set(key, [item1]); } + } + } + { + // Index: ExtType@ItemExtInfo + for (const elem of item1.extTypeList ?? []) { + const key = elem; + { + const list = this.#indexItemExtInfoMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemExtInfoMap.set(key, [item1]); } + } + } + } + { + // Index: (ID,Name)@AwardItem + const keyParts = [item1.id, item1.name]; + this.#indexAwardItemMap.getOrSet(keyParts, () => []).push(item1); + } + { + // Index: (ID,Type,Param,ExtType)@SpecialItem + for (const indexItem2 of item1.paramList ?? []) { + for (const indexItem3 of item1.extTypeList ?? []) { + const keyParts = [item1.id, item1.type, indexItem2, indexItem3]; + this.#indexSpecialItemMap.getOrSet(keyParts, () => []).push(item1); + } + } + } + { + // Index: PathDir@ItemPathDir + const key = item1.path?.dir ?? ""; + { + const list = this.#indexItemPathDirMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemPathDirMap.set(key, [item1]); } + } + } + { + // Index: PathName@ItemPathName + for (const elem of item1.path?.nameList ?? []) { + const key = elem; + { + const list = this.#indexItemPathNameMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemPathNameMap.set(key, [item1]); } + } + } + } + { + // Index: PathFriendID@ItemPathFriendID + const key = item1.path?.friend?.id ?? 0; + { + const list = this.#indexItemPathFriendIDMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemPathFriendIDMap.set(key, [item1]); } + } + } + { + // Index: UseEffectType@UseEffectType + const key = item1.useEffect?.type ?? 0; + { + const list = this.#indexUseEffectTypeMap.get(key); + if (list) { list.push(item1); } else { this.#indexUseEffectTypeMap.set(key, [item1]); } + } + } + } + // Index(sort): Param@ItemInfo + const cmpItemInfo = (a: protoconf.ItemConf_Item, b: protoconf.ItemConf_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#indexItemInfoMap.values()) { + list.sort(cmpItemInfo); + } + // Index(sort): (ID,Name)@AwardItem + const cmpAwardItem = (a: protoconf.ItemConf_Item, b: protoconf.ItemConf_Item): number => compareTuples([a.type, a.useEffect?.type ?? 0], [b.type, b.useEffect?.type ?? 0]); + for (const list of this.#indexAwardItemMap.values()) { + list.sort(cmpAwardItem); + } + // OrderedIndex init. + this.#orderedIndexExtTypeMap.clear(); + this.#orderedIndexParamExtTypeMap.clear(); + for (const item1 of Object.values(this.#data.itemMap)) { + { + // OrderedIndex: ExtType@ExtType + for (const elem of item1.extTypeList ?? []) { + const key = elem; + { + const list = this.#orderedIndexExtTypeMap.get(key); + if (list) { list.push(item1); } else { this.#orderedIndexExtTypeMap.set(key, [item1]); } + } + } + } + { + // OrderedIndex: (Param,ExtType)@ParamExtType + for (const indexItem0 of item1.paramList ?? []) { + for (const indexItem1 of item1.extTypeList ?? []) { + const keyParts = [indexItem0, indexItem1]; + this.#orderedIndexParamExtTypeMap.getOrSet(keyParts, () => []).push(item1); + } + } + } + } + // OrderedIndex(sort): (Param,ExtType)@ParamExtType + const cmpParamExtType = (a: protoconf.ItemConf_Item, b: protoconf.ItemConf_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexParamExtTypeMap.values()) { + list.sort(cmpParamExtType); + } + this.#orderedIndexExtTypeMap = sortMapByKey(this.#orderedIndexExtTypeMap, compareValues); + this.#orderedIndexParamExtTypeMap.sortKeys(); + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(id: number): protoconf.ItemConf_Item | undefined { + return this.#data.itemMap[id]; + } + + /** getOrderedMap returns the 1st-level ordered map (sorted by key). */ + getOrderedMap(): ItemConf.OrderedMap_ItemMap { + return this.#orderedMap; + } + + /** findItemMap returns the index map: key(Type) -> values. */ + findItemMap(): ItemConf.Index_ItemMap { + return this.#indexItemMap; + } + + /** findItem returns all values for the given key(s), or undefined. */ + findItem(type: protoconf.FruitType): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemMap.get(type); + } + + /** findFirstItem returns the first value for the given key(s), or undefined. */ + findFirstItem(type: protoconf.FruitType): protoconf.ItemConf_Item | undefined { + return this.findItem(type)?.[0]; + } + + /** findItemInfoMap returns the index map: key(Param@ItemInfo) -> values. */ + findItemInfoMap(): ItemConf.Index_ItemInfoMap { + return this.#indexItemInfoMap; + } + + /** findItemInfo returns all values for the given key(s), or undefined. */ + findItemInfo(param: number): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemInfoMap.get(param); + } + + /** findFirstItemInfo returns the first value for the given key(s), or undefined. */ + findFirstItemInfo(param: number): protoconf.ItemConf_Item | undefined { + return this.findItemInfo(param)?.[0]; + } + + /** findItemDefaultInfoMap returns the index map: key(Default@ItemDefaultInfo) -> values. */ + findItemDefaultInfoMap(): ItemConf.Index_ItemDefaultInfoMap { + return this.#indexItemDefaultInfoMap; + } + + /** findItemDefaultInfo returns all values for the given key(s), or undefined. */ + findItemDefaultInfo(default_: string): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemDefaultInfoMap.get(default_); + } + + /** findFirstItemDefaultInfo returns the first value for the given key(s), or undefined. */ + findFirstItemDefaultInfo(default_: string): protoconf.ItemConf_Item | undefined { + return this.findItemDefaultInfo(default_)?.[0]; + } + + /** findItemExtInfoMap returns the index map: key(ExtType@ItemExtInfo) -> values. */ + findItemExtInfoMap(): ItemConf.Index_ItemExtInfoMap { + return this.#indexItemExtInfoMap; + } + + /** findItemExtInfo returns all values for the given key(s), or undefined. */ + findItemExtInfo(extType: protoconf.FruitType): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemExtInfoMap.get(extType); + } + + /** findFirstItemExtInfo returns the first value for the given key(s), or undefined. */ + findFirstItemExtInfo(extType: protoconf.FruitType): protoconf.ItemConf_Item | undefined { + return this.findItemExtInfo(extType)?.[0]; + } + + /** findAwardItemMap returns the index map: key((ID,Name)@AwardItem) -> values. */ + findAwardItemMap(): ItemConf.Index_AwardItemMap { + return this.#indexAwardItemMap; + } + + /** findAwardItem returns all values for the given key(s), or undefined. */ + findAwardItem(id: number, name: string): protoconf.ItemConf_Item[] | undefined { + return this.#indexAwardItemMap.get([id, name]); + } + + /** findFirstAwardItem returns the first value for the given key(s), or undefined. */ + findFirstAwardItem(id: number, name: string): protoconf.ItemConf_Item | undefined { + return this.findAwardItem(id, name)?.[0]; + } + + /** findSpecialItemMap returns the index map: key((ID,Type,Param,ExtType)@SpecialItem) -> values. */ + findSpecialItemMap(): ItemConf.Index_SpecialItemMap { + return this.#indexSpecialItemMap; + } + + /** findSpecialItem returns all values for the given key(s), or undefined. */ + findSpecialItem(id: number, type: protoconf.FruitType, param: number, extType: protoconf.FruitType): protoconf.ItemConf_Item[] | undefined { + return this.#indexSpecialItemMap.get([id, type, param, extType]); + } + + /** findFirstSpecialItem returns the first value for the given key(s), or undefined. */ + findFirstSpecialItem(id: number, type: protoconf.FruitType, param: number, extType: protoconf.FruitType): protoconf.ItemConf_Item | undefined { + return this.findSpecialItem(id, type, param, extType)?.[0]; + } + + /** findItemPathDirMap returns the index map: key(PathDir@ItemPathDir) -> values. */ + findItemPathDirMap(): ItemConf.Index_ItemPathDirMap { + return this.#indexItemPathDirMap; + } + + /** findItemPathDir returns all values for the given key(s), or undefined. */ + findItemPathDir(dir: string): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemPathDirMap.get(dir); + } + + /** findFirstItemPathDir returns the first value for the given key(s), or undefined. */ + findFirstItemPathDir(dir: string): protoconf.ItemConf_Item | undefined { + return this.findItemPathDir(dir)?.[0]; + } + + /** findItemPathNameMap returns the index map: key(PathName@ItemPathName) -> values. */ + findItemPathNameMap(): ItemConf.Index_ItemPathNameMap { + return this.#indexItemPathNameMap; + } + + /** findItemPathName returns all values for the given key(s), or undefined. */ + findItemPathName(name: string): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemPathNameMap.get(name); + } + + /** findFirstItemPathName returns the first value for the given key(s), or undefined. */ + findFirstItemPathName(name: string): protoconf.ItemConf_Item | undefined { + return this.findItemPathName(name)?.[0]; + } + + /** findItemPathFriendIDMap returns the index map: key(PathFriendID@ItemPathFriendID) -> values. */ + findItemPathFriendIDMap(): ItemConf.Index_ItemPathFriendIDMap { + return this.#indexItemPathFriendIDMap; + } + + /** findItemPathFriendID returns all values for the given key(s), or undefined. */ + findItemPathFriendID(id: number): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemPathFriendIDMap.get(id); + } + + /** findFirstItemPathFriendID returns the first value for the given key(s), or undefined. */ + findFirstItemPathFriendID(id: number): protoconf.ItemConf_Item | undefined { + return this.findItemPathFriendID(id)?.[0]; + } + + /** findUseEffectTypeMap returns the index map: key(UseEffectType@UseEffectType) -> values. */ + findUseEffectTypeMap(): ItemConf.Index_UseEffectTypeMap { + return this.#indexUseEffectTypeMap; + } + + /** findUseEffectType returns all values for the given key(s), or undefined. */ + findUseEffectType(type: protoconf.UseEffect_Type): protoconf.ItemConf_Item[] | undefined { + return this.#indexUseEffectTypeMap.get(type); + } + + /** findFirstUseEffectType returns the first value for the given key(s), or undefined. */ + findFirstUseEffectType(type: protoconf.UseEffect_Type): protoconf.ItemConf_Item | undefined { + return this.findUseEffectType(type)?.[0]; + } + + /** findExtTypeMap returns the ordered index map: key(ExtType@ExtType) -> values. */ + findExtTypeMap(): ItemConf.OrderedIndex_ExtTypeMap { + return this.#orderedIndexExtTypeMap; + } + + /** findExtType returns all values for the given key(s), or undefined. */ + findExtType(extType: protoconf.FruitType): protoconf.ItemConf_Item[] | undefined { + return this.#orderedIndexExtTypeMap.get(extType); + } + + /** findFirstExtType returns the first value for the given key(s), or undefined. */ + findFirstExtType(extType: protoconf.FruitType): protoconf.ItemConf_Item | undefined { + return this.findExtType(extType)?.[0]; + } + + /** findParamExtTypeMap returns the ordered index map: key((Param,ExtType)@ParamExtType) -> values. */ + findParamExtTypeMap(): ItemConf.OrderedIndex_ParamExtTypeMap { + return this.#orderedIndexParamExtTypeMap; + } + + /** findParamExtType returns all values for the given key(s), or undefined. */ + findParamExtType(param: number, extType: protoconf.FruitType): protoconf.ItemConf_Item[] | undefined { + return this.#orderedIndexParamExtTypeMap.get([param, extType]); + } + + /** findFirstParamExtType returns the first value for the given key(s), or undefined. */ + findFirstParamExtType(param: number, extType: protoconf.FruitType): protoconf.ItemConf_Item | undefined { + return this.findParamExtType(param, extType)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. ItemConf.Index_XxxMap). +export namespace ItemConf { + /** OrderedMap_ItemMap is the 1st-level (leaf) ordered map: key -> value (sorted by key). */ + export type OrderedMap_ItemMap = Map; + /** Index_ItemMap is the index map: key(Type) -> values. */ + export type Index_ItemMap = Map; + /** Index_ItemInfoMap is the index map: key(Param@ItemInfo) -> values. */ + export type Index_ItemInfoMap = Map; + /** Index_ItemDefaultInfoMap is the index map: key(Default@ItemDefaultInfo) -> values. */ + export type Index_ItemDefaultInfoMap = Map; + /** Index_ItemExtInfoMap is the index map: key(ExtType@ItemExtInfo) -> values. */ + export type Index_ItemExtInfoMap = Map; + /** Index_AwardItemKey is the composite key of index: key((ID,Name)@AwardItem). */ + export type Index_AwardItemKey = readonly [id: number, name: string]; + /** Index_AwardItemMap is the index map: key((ID,Name)@AwardItem) -> values. */ + export type Index_AwardItemMap = TupleKeyMap; + /** Index_SpecialItemKey is the composite key of index: key((ID,Type,Param,ExtType)@SpecialItem). */ + export type Index_SpecialItemKey = readonly [id: number, type: protoconf.FruitType, param: number, extType: protoconf.FruitType]; + /** Index_SpecialItemMap is the index map: key((ID,Type,Param,ExtType)@SpecialItem) -> values. */ + export type Index_SpecialItemMap = TupleKeyMap; + /** Index_ItemPathDirMap is the index map: key(PathDir@ItemPathDir) -> values. */ + export type Index_ItemPathDirMap = Map; + /** Index_ItemPathNameMap is the index map: key(PathName@ItemPathName) -> values. */ + export type Index_ItemPathNameMap = Map; + /** Index_ItemPathFriendIDMap is the index map: key(PathFriendID@ItemPathFriendID) -> values. */ + export type Index_ItemPathFriendIDMap = Map; + /** Index_UseEffectTypeMap is the index map: key(UseEffectType@UseEffectType) -> values. */ + export type Index_UseEffectTypeMap = Map; + /** OrderedIndex_ExtTypeMap is the ordered index map: key(ExtType@ExtType) -> values. */ + export type OrderedIndex_ExtTypeMap = Map; + /** OrderedIndex_ParamExtTypeKey is the composite key of ordered index: key((Param,ExtType)@ParamExtType). */ + export type OrderedIndex_ParamExtTypeKey = readonly [param: number, extType: protoconf.FruitType]; + /** OrderedIndex_ParamExtTypeMap is the ordered index map: key((Param,ExtType)@ParamExtType) -> values. */ + export type OrderedIndex_ParamExtTypeMap = TupleKeyMap; +} diff --git a/test/ts-tableau-loader/tableau/load.pc.ts b/test/ts-tableau-loader/tableau/load.pc.ts new file mode 100644 index 0000000..7f3f54d --- /dev/null +++ b/test/ts-tableau-loader/tableau/load.pc.ts @@ -0,0 +1,274 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +/* eslint-disable */ + +import { readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + create, + fromBinary, + fromJson, + type DescMessage, + type JsonValue, + type Message, + type MessageShape, +} from "@bufbuild/protobuf"; +import * as tableaupb from "../protoconf/tableau/protobuf/tableau_pb.js"; +import { + Format, + format2Ext, + getFormat, + getSheetPatch, + patchMessage, +} from "./util.pc.js"; + +export { Format } from "./util.pc.js"; + +/** + * ReadFunc reads a config file and returns its content. + */ +export type ReadFunc = (path: string) => Uint8Array; + +/** + * LoadFunc loads a messager's content based on the given descriptor, path, + * format, and options. + */ +export type LoadFunc = ( + desc: DescMessage, + path: string, + fmt: Format, + options?: MessagerOptions, +) => Message; + +/** + * LoadMode controls patch loading behavior. + */ +export enum LoadMode { + /** Load all related files (main + patches). Default. */ + ALL = "all", + /** Only load the main file. */ + ONLY_MAIN = "only_main", + /** Only load the patch files. */ + ONLY_PATCH = "only_patch", +} + +/** + * BaseOptions is the common options for both global-level and messager-level options. + */ +export interface BaseOptions { + /** Whether to ignore unknown JSON fields during parsing. */ + ignoreUnknownFields?: boolean; + /** Specify the directory paths for config patching. */ + patchDirs?: string[]; + /** Specify the loading mode for config patching. Default is LoadMode.ALL. */ + mode?: LoadMode; + /** Custom read function to read a config file's content. Default is readFileSync. */ + readFunc?: ReadFunc; + /** Custom load function to load a messager's content. Default is loadMessager. */ + loadFunc?: LoadFunc; +} + +/** + * MessagerOptions defines the options for loading a single messager. + */ +export interface MessagerOptions extends BaseOptions { + /** + * Path maps the messager to a corresponding config file path. If specified, + * then the main messager will be parsed from the file directly, other than + * the specified load dir. + */ + path?: string; + /** + * PatchPaths maps the messager to one or multiple corresponding patch file + * paths. If specified, then the main messager will be patched. + */ + patchPaths?: string[]; +} + +/** + * Options is the global-level options, which contains both global-level and + * messager-level options. + */ +export interface Options extends BaseOptions { + /** + * messagerOptions maps each messager name to a MessagerOptions. If specified, + * then the messager will be parsed with the given options directly. + */ + messagerOptions?: { [name: string]: MessagerOptions }; +} + +/** + * parseMessagerOptionsByName parses messager options with both global-level and + * messager-level options taken into consideration. + */ +export function parseMessagerOptionsByName(options: Options, name: string): MessagerOptions { + const mopts: MessagerOptions = options.messagerOptions?.[name] + ? { ...options.messagerOptions[name] } + : {}; + mopts.ignoreUnknownFields ??= options.ignoreUnknownFields; + mopts.patchDirs ??= options.patchDirs; + mopts.mode ??= options.mode; + mopts.readFunc ??= options.readFunc; + mopts.loadFunc ??= options.loadFunc; + return mopts; +} + +function defaultRead(path: string): Uint8Array { + return new Uint8Array(readFileSync(path)); +} + +/** + * loadMessager loads a protobuf message from the specified file path and format. + */ +export function loadMessager( + desc: DescMessage, + path: string, + fmt: Format, + options?: MessagerOptions, +): Message { + const readFunc = options?.readFunc ?? defaultRead; + let content: Uint8Array; + try { + content = readFunc(path); + } catch (e) { + throw new Error(`failed to read ${path}`, { cause: e }); + } + return unmarshal(content, desc, fmt, options); +} + +/** + * loadMessagerInDir loads a protobuf message from the specified directory and + * format. It resolves the file path based on the message descriptor name. + */ +export function loadMessagerInDir( + desc: Desc, + dir: string, + fmt: Format, + options?: MessagerOptions, +): MessageShape { + let path = ""; + if (options?.path) { + path = options.path; + fmt = getFormat(path); + } + if (path === "") { + path = join(dir, desc.name + format2Ext(fmt)); + } + const sheetPatch = getSheetPatch(desc); + let msg: Message; + if (sheetPatch !== tableaupb.Patch.NONE) { + msg = loadMessagerWithPatch(desc, path, fmt, sheetPatch, options); + } else { + const loadFunc = options?.loadFunc ?? loadMessager; + msg = loadFunc(desc, path, fmt, options); + } + return msg as MessageShape; +} + +/** + * loadMessagerWithPatch loads a protobuf message with patch support. + */ +export function loadMessagerWithPatch( + desc: DescMessage, + path: string, + fmt: Format, + patch: tableaupb.Patch, + options?: MessagerOptions, +): Message { + const mode = options?.mode ?? LoadMode.ALL; + const loadFunc = options?.loadFunc ?? loadMessager; + if (mode === LoadMode.ONLY_MAIN) { + // Ignore patch files when LoadMode.ONLY_MAIN specified. + return loadFunc(desc, path, fmt, options); + } + + let patchPaths: string[] = []; + const explicitPaths = options?.patchPaths && options.patchPaths.length > 0; + if (explicitPaths) { + // PatchPaths takes precedence over PatchDirs. + patchPaths = [...options!.patchPaths!]; + } else if (options?.patchDirs) { + const filename = desc.name + format2Ext(fmt); + for (const patchDir of options.patchDirs) { + patchPaths.push(join(patchDir, filename)); + } + } + + // Filter out non-existing patch files when relying on PatchDirs. + let existedPatchPaths: string[]; + if (explicitPaths) { + // Explicit paths are kept as-is; loadFunc surfaces errors if missing. + existedPatchPaths = patchPaths; + } else { + existedPatchPaths = patchPaths.filter((p) => existsSync(p)); + } + + if (existedPatchPaths.length === 0) { + if (mode === LoadMode.ONLY_PATCH) { + // Return empty message when LoadMode.ONLY_PATCH specified but no valid + // patch file provided. + return create(desc); + } + // No valid patch path provided, then just load from the "main" file. + return loadFunc(desc, path, fmt, options); + } + + switch (patch) { + case tableaupb.Patch.REPLACE: { + // Just use the last "patch" file. + const patchPath = existedPatchPaths[existedPatchPaths.length - 1]!; + return loadFunc(desc, patchPath, getFormat(patchPath), options); + } + case tableaupb.Patch.MERGE: { + let msg: Message; + if (mode !== LoadMode.ONLY_PATCH) { + // Load msg from the "main" file. + msg = loadFunc(desc, path, fmt, options); + } else { + msg = create(desc); + } + for (const patchPath of existedPatchPaths) { + const patchMsg = loadFunc(desc, patchPath, getFormat(patchPath), options); + patchMessage(msg, patchMsg, desc); + } + return msg; + } + default: + throw new Error(`unknown patch type: ${patch}`); + } +} + +/** + * unmarshal parses the given byte content into a protobuf message based on the + * specified format. + */ +export function unmarshal( + content: Uint8Array, + desc: DescMessage, + fmt: Format, + options?: MessagerOptions, +): Message { + switch (fmt) { + case Format.JSON: + try { + const json = JSON.parse(new TextDecoder().decode(content)) as JsonValue; + return fromJson(desc, json, { + ignoreUnknownFields: options?.ignoreUnknownFields ?? false, + }); + } catch (e) { + throw new Error(`failed to parse ${desc.name}.json`, { cause: e }); + } + case Format.BIN: + try { + return fromBinary(desc, content); + } catch (e) { + throw new Error(`failed to parse ${desc.name}.binpb`, { cause: e }); + } + default: + throw new Error(`unknown format: ${fmt}`); + } +} + diff --git a/test/ts-tableau-loader/tableau/messager.pc.ts b/test/ts-tableau-loader/tableau/messager.pc.ts new file mode 100644 index 0000000..a706e79 --- /dev/null +++ b/test/ts-tableau-loader/tableau/messager.pc.ts @@ -0,0 +1,59 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +/* eslint-disable */ + +import { type Message } from "@bufbuild/protobuf"; +import { Format } from "./util.pc.js"; +import { type MessagerOptions } from "./load.pc.js"; +// Type-only import to describe the hub passed to processAfterLoadAll. Importing +// only the type avoids a runtime cycle with the generated hub module. +import type { Hub } from "./hub.pc.js"; + +/** + * Stats contains statistics info about loading. + */ +export interface Stats { + /** Total load time consuming, in milliseconds. */ + durationMs: number; +} + +/** + * Messager is the base class for all generated configuration messagers. + * It is designed for three goals: + * 1. Easy use: simple yet powerful accessors. + * 2. Elegant API: concise and clean functions. + * 3. Extensibility: Map, OrderedMap, Index, OrderedIndex... + */ +export abstract class Messager { + protected loadStats: Stats = { durationMs: 0 }; + + /** getStats returns the loading stats info. */ + getStats(): Stats { + return this.loadStats; + } + + /** name returns the messager's message name. */ + abstract name(): string; + + /** load fills message from file in the specified directory and format. Throws on failure. */ + abstract load(dir: string, fmt: Format, options?: MessagerOptions): void; + + /** message returns the inner protobuf message data. */ + message(): Message | undefined { + return undefined; + } + + /** processAfterLoad is invoked after this messager is loaded. Throws on failure. */ + protected processAfterLoad(): void {} + + /** processAfterLoadAll is invoked after all messagers are loaded. Throws on failure. */ + processAfterLoadAll(_hub: Hub): void {} +} + +/** + * MessagerCtor is the constructor type for a concrete Messager. + */ +export type MessagerCtor = new () => Messager; + diff --git a/test/ts-tableau-loader/tableau/patch_conf.pc.ts b/test/ts-tableau-loader/tableau/patch_conf.pc.ts new file mode 100644 index 0000000..4b5c6a5 --- /dev/null +++ b/test/ts-tableau-loader/tableau/patch_conf.pc.ts @@ -0,0 +1,139 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +// source: patch_conf.proto +/* eslint-disable */ + +import { create } from "@bufbuild/protobuf"; +import { Messager } from "./messager.pc.js"; +import { Format } from "./util.pc.js"; +import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; +import * as protoconf from "./barrel/protoconf.pc.js"; + +/** + * PatchReplaceConf is a wrapper around protobuf message protoconf.PatchReplaceConf. + */ +export class PatchReplaceConf extends Messager { + #data: protoconf.PatchReplaceConf = create(protoconf.PatchReplaceConfSchema); + + /** name returns the PatchReplaceConf's message name. */ + name(): string { + return protoconf.PatchReplaceConfSchema.name; + } + + /** load loads PatchReplaceConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.PatchReplaceConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load PatchReplaceConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the PatchReplaceConf's inner message data. */ + data(): protoconf.PatchReplaceConf { + return this.#data; + } + + /** message returns the PatchReplaceConf's inner message data. */ + override message(): protoconf.PatchReplaceConf { + return this.#data; + } +} + +/** + * PatchMergeConf is a wrapper around protobuf message protoconf.PatchMergeConf. + */ +export class PatchMergeConf extends Messager { + #data: protoconf.PatchMergeConf = create(protoconf.PatchMergeConfSchema); + + /** name returns the PatchMergeConf's message name. */ + name(): string { + return protoconf.PatchMergeConfSchema.name; + } + + /** load loads PatchMergeConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.PatchMergeConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load PatchMergeConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the PatchMergeConf's inner message data. */ + data(): protoconf.PatchMergeConf { + return this.#data; + } + + /** message returns the PatchMergeConf's inner message data. */ + override message(): protoconf.PatchMergeConf { + return this.#data; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(id: number): protoconf.Item | undefined { + return this.#data.itemMap[id]; + } +} + +/** + * RecursivePatchConf is a wrapper around protobuf message protoconf.RecursivePatchConf. + */ +export class RecursivePatchConf extends Messager { + #data: protoconf.RecursivePatchConf = create(protoconf.RecursivePatchConfSchema); + + /** name returns the RecursivePatchConf's message name. */ + name(): string { + return protoconf.RecursivePatchConfSchema.name; + } + + /** load loads RecursivePatchConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.RecursivePatchConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load RecursivePatchConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the RecursivePatchConf's inner message data. */ + data(): protoconf.RecursivePatchConf { + return this.#data; + } + + /** message returns the RecursivePatchConf's inner message data. */ + override message(): protoconf.RecursivePatchConf { + return this.#data; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(shopId: number): protoconf.RecursivePatchConf_Shop | undefined { + return this.#data.shopMap[shopId]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(shopId: number, goodsId: number): protoconf.RecursivePatchConf_Shop_Goods | undefined { + return this.get1(shopId)?.goodsMap[goodsId]; + } + + /** get3 finds value in the 3rd-level map; returns undefined if not found. */ + get3(shopId: number, goodsId: number, type: number): protoconf.RecursivePatchConf_Shop_Goods_Currency | undefined { + return this.get2(shopId, goodsId)?.currencyMap[type]; + } + + /** get4 finds value in the 4th-level map; returns undefined if not found. */ + get4(shopId: number, goodsId: number, type: number, key4: number): number | undefined { + return this.get3(shopId, goodsId, type)?.valueList[key4]; + } +} diff --git a/test/ts-tableau-loader/tableau/test_conf.pc.ts b/test/ts-tableau-loader/tableau/test_conf.pc.ts new file mode 100644 index 0000000..4e492df --- /dev/null +++ b/test/ts-tableau-loader/tableau/test_conf.pc.ts @@ -0,0 +1,1007 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +// source: test_conf.proto +/* eslint-disable */ + +import { create } from "@bufbuild/protobuf"; +import { Messager } from "./messager.pc.js"; +import { Format, compareValues, compareTuples, sortMapByKey, TupleKeyMap, type OrderedMapValue } from "./util.pc.js"; +import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; +import * as protoconf from "./barrel/protoconf.pc.js"; + +/** + * ActivityConf is a wrapper around protobuf message protoconf.ActivityConf. + */ +export class ActivityConf extends Messager { + #data: protoconf.ActivityConf = create(protoconf.ActivityConfSchema); + #orderedMap: ActivityConf.OrderedMap_ActivityMap = new Map(); + #indexActivityMap: ActivityConf.Index_ActivityMap = new Map(); + #indexChapterMap: ActivityConf.Index_ChapterMap = new Map(); + #indexChapterMap1: Map = new Map(); + #indexNamedChapterMap: ActivityConf.Index_NamedChapterMap = new Map(); + #indexNamedChapterMap1: Map = new Map(); + #indexAwardMap: ActivityConf.Index_AwardMap = new Map(); + #indexAwardMap1: Map = new Map(); + #indexAwardMap2: TupleKeyMap = new TupleKeyMap(); + #indexAwardMap3: TupleKeyMap = new TupleKeyMap(); + + /** name returns the ActivityConf's message name. */ + name(): string { + return protoconf.ActivityConfSchema.name; + } + + /** load loads ActivityConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.ActivityConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load ActivityConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the ActivityConf's inner message data. */ + data(): protoconf.ActivityConf { + return this.#data; + } + + /** message returns the ActivityConf's inner message data. */ + override message(): protoconf.ActivityConf { + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // OrderedMap init. + const orderedMap: ActivityConf.OrderedMap_ActivityMap = new Map(); + for (const [k1Str, v1] of Object.entries(this.#data.activityMap)) { + const k1 = BigInt(k1Str); + const orderedMap2: ActivityConf.OrderedMap_Activity_ChapterMap = new Map(); + for (const [k2Str, v2] of Object.entries(v1.chapterMap)) { + const k2 = Number(k2Str); + const orderedMap3: ActivityConf.OrderedMap_protoconf_SectionMap = new Map(); + for (const [k3Str, v3] of Object.entries(v2.sectionMap)) { + const k3 = Number(k3Str); + const orderedMap4: ActivityConf.OrderedMap_int32Map = new Map(); + for (const [k4Str, v4] of Object.entries(v3.sectionRankMap)) { + const k4 = Number(k4Str); + orderedMap4.set(k4, v4); + } + sortMapByKey(orderedMap4, compareValues); + orderedMap3.set(k3, { first: orderedMap4, second: v3 }); + } + sortMapByKey(orderedMap3, compareValues); + orderedMap2.set(k2, { first: orderedMap3, second: v2 }); + } + sortMapByKey(orderedMap2, compareValues); + orderedMap.set(k1, { first: orderedMap2, second: v1 }); + } + sortMapByKey(orderedMap, compareValues); + this.#orderedMap = orderedMap; + // Index init. + this.#indexActivityMap.clear(); + this.#indexChapterMap.clear(); + this.#indexChapterMap1.clear(); + this.#indexNamedChapterMap.clear(); + this.#indexNamedChapterMap1.clear(); + this.#indexAwardMap.clear(); + this.#indexAwardMap1.clear(); + this.#indexAwardMap2.clear(); + this.#indexAwardMap3.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.activityMap)) { + const k1 = BigInt(k1Str); + { + // Index: ActivityName + const key = item1.activityName; + { + const list = this.#indexActivityMap.get(key); + if (list) { list.push(item1); } else { this.#indexActivityMap.set(key, [item1]); } + } + } + for (const [k2Str, item2] of Object.entries(item1.chapterMap)) { + const k2 = Number(k2Str); + { + // Index: ChapterID + const key = item2.chapterId; + { + const list = this.#indexChapterMap.get(key); + if (list) { list.push(item2); } else { this.#indexChapterMap.set(key, [item2]); } + } + { + let map = this.#indexChapterMap1.get(k1); + if (!map) { map = new Map(); this.#indexChapterMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + { + // Index: ChapterName@NamedChapter + const key = item2.chapterName; + { + const list = this.#indexNamedChapterMap.get(key); + if (list) { list.push(item2); } else { this.#indexNamedChapterMap.set(key, [item2]); } + } + { + let map = this.#indexNamedChapterMap1.get(k1); + if (!map) { map = new Map(); this.#indexNamedChapterMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + for (const [k3Str, item3] of Object.entries(item2.sectionMap)) { + const k3 = Number(k3Str); + for (const item4 of item3.sectionItemList ?? []) { + { + // Index: SectionItemID@Award + const key = item4.id; + { + const list = this.#indexAwardMap.get(key); + if (list) { list.push(item4); } else { this.#indexAwardMap.set(key, [item4]); } + } + { + let map = this.#indexAwardMap1.get(k1); + if (!map) { map = new Map(); this.#indexAwardMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + { + const map = this.#indexAwardMap2.getOrSet([k1, k2], () => new Map()); + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + { + const map = this.#indexAwardMap3.getOrSet([k1, k2, k3], () => new Map()); + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + } + } + } + } + } + // Index(sort): ChapterName@NamedChapter + const cmpNamedChapter = (a: protoconf.ActivityConf_Activity_Chapter, b: protoconf.ActivityConf_Activity_Chapter): number => compareTuples([a.awardId], [b.awardId]); + for (const list of this.#indexNamedChapterMap.values()) { + list.sort(cmpNamedChapter); + } + for (const m of this.#indexNamedChapterMap1.values()) { + for (const list of m.values()) { + list.sort(cmpNamedChapter); + } + } + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(activityId: bigint): protoconf.ActivityConf_Activity | undefined { + return this.#data.activityMap[activityId.toString()]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(activityId: bigint, chapterId: number): protoconf.ActivityConf_Activity_Chapter | undefined { + return this.get1(activityId)?.chapterMap[chapterId]; + } + + /** get3 finds value in the 3rd-level map; returns undefined if not found. */ + get3(activityId: bigint, chapterId: number, sectionId: number): protoconf.Section | undefined { + return this.get2(activityId, chapterId)?.sectionMap[sectionId]; + } + + /** get4 finds value in the 4th-level map; returns undefined if not found. */ + get4(activityId: bigint, chapterId: number, sectionId: number, key4: number): number | undefined { + return this.get3(activityId, chapterId, sectionId)?.sectionRankMap[key4]; + } + + /** getOrderedMap returns the 1st-level ordered map (sorted by key). */ + getOrderedMap(): ActivityConf.OrderedMap_ActivityMap { + return this.#orderedMap; + } + + /** getOrderedMap1 returns the 2nd-level ordered map scoped to the given upper key(s), or undefined. */ + getOrderedMap1(activityId: bigint): ActivityConf.OrderedMap_Activity_ChapterMap | undefined { + return this.#orderedMap.get(activityId)?.first; + } + + /** getOrderedMap2 returns the 3rd-level ordered map scoped to the given upper key(s), or undefined. */ + getOrderedMap2(activityId: bigint, chapterId: number): ActivityConf.OrderedMap_protoconf_SectionMap | undefined { + return this.getOrderedMap1(activityId)?.get(chapterId)?.first; + } + + /** getOrderedMap3 returns the 4th-level ordered map scoped to the given upper key(s), or undefined. */ + getOrderedMap3(activityId: bigint, chapterId: number, sectionId: number): ActivityConf.OrderedMap_int32Map | undefined { + return this.getOrderedMap2(activityId, chapterId)?.get(sectionId)?.first; + } + + /** findActivityMap returns the index map: key(ActivityName) -> values. */ + findActivityMap(): ActivityConf.Index_ActivityMap { + return this.#indexActivityMap; + } + + /** findActivity returns all values for the given key(s), or undefined. */ + findActivity(activityName: string): protoconf.ActivityConf_Activity[] | undefined { + return this.#indexActivityMap.get(activityName); + } + + /** findFirstActivity returns the first value for the given key(s), or undefined. */ + findFirstActivity(activityName: string): protoconf.ActivityConf_Activity | undefined { + return this.findActivity(activityName)?.[0]; + } + + /** findChapterMap returns the index map: key(ChapterID) -> values. */ + findChapterMap(): ActivityConf.Index_ChapterMap { + return this.#indexChapterMap; + } + + /** findChapter returns all values for the given key(s), or undefined. */ + findChapter(chapterId: number): protoconf.ActivityConf_Activity_Chapter[] | undefined { + return this.#indexChapterMap.get(chapterId); + } + + /** findFirstChapter returns the first value for the given key(s), or undefined. */ + findFirstChapter(chapterId: number): protoconf.ActivityConf_Activity_Chapter | undefined { + return this.findChapter(chapterId)?.[0]; + } + + /** findChapterMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findChapterMap1(activityId: bigint): ActivityConf.Index_ChapterMap | undefined { + return this.#indexChapterMap1.get(activityId); + } + + /** findChapter1 returns all values for the given key(s) within the upper 1st-level map. */ + findChapter1(activityId: bigint, chapterId: number): protoconf.ActivityConf_Activity_Chapter[] | undefined { + return this.findChapterMap1(activityId)?.get(chapterId); + } + + /** findFirstChapter1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstChapter1(activityId: bigint, chapterId: number): protoconf.ActivityConf_Activity_Chapter | undefined { + return this.findChapter1(activityId, chapterId)?.[0]; + } + + /** findNamedChapterMap returns the index map: key(ChapterName@NamedChapter) -> values. */ + findNamedChapterMap(): ActivityConf.Index_NamedChapterMap { + return this.#indexNamedChapterMap; + } + + /** findNamedChapter returns all values for the given key(s), or undefined. */ + findNamedChapter(chapterName: string): protoconf.ActivityConf_Activity_Chapter[] | undefined { + return this.#indexNamedChapterMap.get(chapterName); + } + + /** findFirstNamedChapter returns the first value for the given key(s), or undefined. */ + findFirstNamedChapter(chapterName: string): protoconf.ActivityConf_Activity_Chapter | undefined { + return this.findNamedChapter(chapterName)?.[0]; + } + + /** findNamedChapterMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findNamedChapterMap1(activityId: bigint): ActivityConf.Index_NamedChapterMap | undefined { + return this.#indexNamedChapterMap1.get(activityId); + } + + /** findNamedChapter1 returns all values for the given key(s) within the upper 1st-level map. */ + findNamedChapter1(activityId: bigint, chapterName: string): protoconf.ActivityConf_Activity_Chapter[] | undefined { + return this.findNamedChapterMap1(activityId)?.get(chapterName); + } + + /** findFirstNamedChapter1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstNamedChapter1(activityId: bigint, chapterName: string): protoconf.ActivityConf_Activity_Chapter | undefined { + return this.findNamedChapter1(activityId, chapterName)?.[0]; + } + + /** findAwardMap returns the index map: key(SectionItemID@Award) -> values. */ + findAwardMap(): ActivityConf.Index_AwardMap { + return this.#indexAwardMap; + } + + /** findAward returns all values for the given key(s), or undefined. */ + findAward(id: number): protoconf.Section_SectionItem[] | undefined { + return this.#indexAwardMap.get(id); + } + + /** findFirstAward returns the first value for the given key(s), or undefined. */ + findFirstAward(id: number): protoconf.Section_SectionItem | undefined { + return this.findAward(id)?.[0]; + } + + /** findAwardMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findAwardMap1(activityId: bigint): ActivityConf.Index_AwardMap | undefined { + return this.#indexAwardMap1.get(activityId); + } + + /** findAward1 returns all values for the given key(s) within the upper 1st-level map. */ + findAward1(activityId: bigint, id: number): protoconf.Section_SectionItem[] | undefined { + return this.findAwardMap1(activityId)?.get(id); + } + + /** findFirstAward1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstAward1(activityId: bigint, id: number): protoconf.Section_SectionItem | undefined { + return this.findAward1(activityId, id)?.[0]; + } + + /** findAwardMap2 returns the index map scoped to the upper 2nd-level map key(s). */ + findAwardMap2(activityId: bigint, chapterId: number): ActivityConf.Index_AwardMap | undefined { + return this.#indexAwardMap2.get([activityId, chapterId]); + } + + /** findAward2 returns all values for the given key(s) within the upper 2nd-level map. */ + findAward2(activityId: bigint, chapterId: number, id: number): protoconf.Section_SectionItem[] | undefined { + return this.findAwardMap2(activityId, chapterId)?.get(id); + } + + /** findFirstAward2 returns the first value for the given key(s) within the upper 2nd-level map. */ + findFirstAward2(activityId: bigint, chapterId: number, id: number): protoconf.Section_SectionItem | undefined { + return this.findAward2(activityId, chapterId, id)?.[0]; + } + + /** findAwardMap3 returns the index map scoped to the upper 3rd-level map key(s). */ + findAwardMap3(activityId: bigint, chapterId: number, sectionId: number): ActivityConf.Index_AwardMap | undefined { + return this.#indexAwardMap3.get([activityId, chapterId, sectionId]); + } + + /** findAward3 returns all values for the given key(s) within the upper 3rd-level map. */ + findAward3(activityId: bigint, chapterId: number, sectionId: number, id: number): protoconf.Section_SectionItem[] | undefined { + return this.findAwardMap3(activityId, chapterId, sectionId)?.get(id); + } + + /** findFirstAward3 returns the first value for the given key(s) within the upper 3rd-level map. */ + findFirstAward3(activityId: bigint, chapterId: number, sectionId: number, id: number): protoconf.Section_SectionItem | undefined { + return this.findAward3(activityId, chapterId, sectionId, id)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. ActivityConf.Index_XxxMap). +export namespace ActivityConf { + /** OrderedMap_int32Map is the 4th-level (leaf) ordered map: key -> value (sorted by key). */ + export type OrderedMap_int32Map = Map; + /** OrderedMap_protoconf_SectionValue is a 3rd-level node: its next-level sub-map (first) plus this level's value (second). */ + export type OrderedMap_protoconf_SectionValue = OrderedMapValue; + /** OrderedMap_protoconf_SectionMap is the 3rd-level ordered map: key -> node (sorted by key). */ + export type OrderedMap_protoconf_SectionMap = Map; + /** OrderedMap_Activity_ChapterValue is a 2nd-level node: its next-level sub-map (first) plus this level's value (second). */ + export type OrderedMap_Activity_ChapterValue = OrderedMapValue; + /** OrderedMap_Activity_ChapterMap is the 2nd-level ordered map: key -> node (sorted by key). */ + export type OrderedMap_Activity_ChapterMap = Map; + /** OrderedMap_ActivityValue is a 1st-level node: its next-level sub-map (first) plus this level's value (second). */ + export type OrderedMap_ActivityValue = OrderedMapValue; + /** OrderedMap_ActivityMap is the 1st-level ordered map: key -> node (sorted by key). */ + export type OrderedMap_ActivityMap = Map; + /** LevelIndex_Activity_ChapterKey is the composite upper map key (k1..k2) of the 2nd-level leveled containers. */ + export type LevelIndex_Activity_ChapterKey = readonly [activityId: bigint, chapterId: number]; + /** LevelIndex_protoconf_SectionKey is the composite upper map key (k1..k3) of the 3rd-level leveled containers. */ + export type LevelIndex_protoconf_SectionKey = readonly [activityId: bigint, chapterId: number, sectionId: number]; + /** Index_ActivityMap is the index map: key(ActivityName) -> values. */ + export type Index_ActivityMap = Map; + /** Index_ChapterMap is the index map: key(ChapterID) -> values. */ + export type Index_ChapterMap = Map; + /** Index_NamedChapterMap is the index map: key(ChapterName@NamedChapter) -> values. */ + export type Index_NamedChapterMap = Map; + /** Index_AwardMap is the index map: key(SectionItemID@Award) -> values. */ + export type Index_AwardMap = Map; +} + +/** + * ChapterConf is a wrapper around protobuf message protoconf.ChapterConf. + */ +export class ChapterConf extends Messager { + #data: protoconf.ChapterConf = create(protoconf.ChapterConfSchema); + + /** name returns the ChapterConf's message name. */ + name(): string { + return protoconf.ChapterConfSchema.name; + } + + /** load loads ChapterConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.ChapterConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load ChapterConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the ChapterConf's inner message data. */ + data(): protoconf.ChapterConf { + return this.#data; + } + + /** message returns the ChapterConf's inner message data. */ + override message(): protoconf.ChapterConf { + return this.#data; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(id: bigint): protoconf.ChapterConf_Chapter | undefined { + return this.#data.chapterMap[id.toString()]; + } +} + +/** + * ThemeConf is a wrapper around protobuf message protoconf.ThemeConf. + */ +export class ThemeConf extends Messager { + #data: protoconf.ThemeConf = create(protoconf.ThemeConfSchema); + + /** name returns the ThemeConf's message name. */ + name(): string { + return protoconf.ThemeConfSchema.name; + } + + /** load loads ThemeConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.ThemeConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load ThemeConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the ThemeConf's inner message data. */ + data(): protoconf.ThemeConf { + return this.#data; + } + + /** message returns the ThemeConf's inner message data. */ + override message(): protoconf.ThemeConf { + return this.#data; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(name: string): protoconf.ThemeConf_Theme | undefined { + return this.#data.themeMap[name]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(name: string, param: string): string | undefined { + return this.get1(name)?.paramMap[param]; + } +} + +/** + * TaskConf is a wrapper around protobuf message protoconf.TaskConf. + */ +export class TaskConf extends Messager { + #data: protoconf.TaskConf = create(protoconf.TaskConfSchema); + #indexTaskMap: TaskConf.Index_TaskMap = new Map(); + #orderedIndexOrderedTaskMap: TaskConf.OrderedIndex_OrderedTaskMap = new Map(); + #orderedIndexTaskExpiryMap: TaskConf.OrderedIndex_TaskExpiryMap = new Map(); + #orderedIndexSortedTaskExpiryMap: TaskConf.OrderedIndex_SortedTaskExpiryMap = new Map(); + #orderedIndexActivityExpiryMap: TaskConf.OrderedIndex_ActivityExpiryMap = new TupleKeyMap(); + + /** name returns the TaskConf's message name. */ + name(): string { + return protoconf.TaskConfSchema.name; + } + + /** load loads TaskConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.TaskConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load TaskConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the TaskConf's inner message data. */ + data(): protoconf.TaskConf { + return this.#data; + } + + /** message returns the TaskConf's inner message data. */ + override message(): protoconf.TaskConf { + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexTaskMap.clear(); + for (const item1 of Object.values(this.#data.taskMap)) { + { + // Index: ActivityID + const key = item1.activityId; + { + const list = this.#indexTaskMap.get(key); + if (list) { list.push(item1); } else { this.#indexTaskMap.set(key, [item1]); } + } + } + } + // Index(sort): ActivityID + const cmpTask = (a: protoconf.TaskConf_Task, b: protoconf.TaskConf_Task): number => compareTuples([a.goal, a.id], [b.goal, b.id]); + for (const list of this.#indexTaskMap.values()) { + list.sort(cmpTask); + } + // OrderedIndex init. + this.#orderedIndexOrderedTaskMap.clear(); + this.#orderedIndexTaskExpiryMap.clear(); + this.#orderedIndexSortedTaskExpiryMap.clear(); + this.#orderedIndexActivityExpiryMap.clear(); + for (const item1 of Object.values(this.#data.taskMap)) { + { + // OrderedIndex: Goal@OrderedTask + const key = item1.goal; + { + const list = this.#orderedIndexOrderedTaskMap.get(key); + if (list) { list.push(item1); } else { this.#orderedIndexOrderedTaskMap.set(key, [item1]); } + } + } + { + // OrderedIndex: Expiry@TaskExpiry + const key = item1.expiry?.seconds ?? 0n; + { + const list = this.#orderedIndexTaskExpiryMap.get(key); + if (list) { list.push(item1); } else { this.#orderedIndexTaskExpiryMap.set(key, [item1]); } + } + } + { + // OrderedIndex: Expiry@SortedTaskExpiry + const key = item1.expiry?.seconds ?? 0n; + { + const list = this.#orderedIndexSortedTaskExpiryMap.get(key); + if (list) { list.push(item1); } else { this.#orderedIndexSortedTaskExpiryMap.set(key, [item1]); } + } + } + { + // OrderedIndex: (Expiry,ActivityID)@ActivityExpiry + const keyParts = [item1.expiry?.seconds ?? 0n, item1.activityId]; + this.#orderedIndexActivityExpiryMap.getOrSet(keyParts, () => []).push(item1); + } + } + // OrderedIndex(sort): Goal@OrderedTask + const cmpOrderedTask = (a: protoconf.TaskConf_Task, b: protoconf.TaskConf_Task): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexOrderedTaskMap.values()) { + list.sort(cmpOrderedTask); + } + // OrderedIndex(sort): Expiry@SortedTaskExpiry + const cmpSortedTaskExpiry = (a: protoconf.TaskConf_Task, b: protoconf.TaskConf_Task): number => compareTuples([a.goal, a.id], [b.goal, b.id]); + for (const list of this.#orderedIndexSortedTaskExpiryMap.values()) { + list.sort(cmpSortedTaskExpiry); + } + this.#orderedIndexOrderedTaskMap = sortMapByKey(this.#orderedIndexOrderedTaskMap, compareValues); + this.#orderedIndexTaskExpiryMap = sortMapByKey(this.#orderedIndexTaskExpiryMap, compareValues); + this.#orderedIndexSortedTaskExpiryMap = sortMapByKey(this.#orderedIndexSortedTaskExpiryMap, compareValues); + this.#orderedIndexActivityExpiryMap.sortKeys(); + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(id: bigint): protoconf.TaskConf_Task | undefined { + return this.#data.taskMap[id.toString()]; + } + + /** findTaskMap returns the index map: key(ActivityID) -> values. */ + findTaskMap(): TaskConf.Index_TaskMap { + return this.#indexTaskMap; + } + + /** findTask returns all values for the given key(s), or undefined. */ + findTask(activityId: bigint): protoconf.TaskConf_Task[] | undefined { + return this.#indexTaskMap.get(activityId); + } + + /** findFirstTask returns the first value for the given key(s), or undefined. */ + findFirstTask(activityId: bigint): protoconf.TaskConf_Task | undefined { + return this.findTask(activityId)?.[0]; + } + + /** findOrderedTaskMap returns the ordered index map: key(Goal@OrderedTask) -> values. */ + findOrderedTaskMap(): TaskConf.OrderedIndex_OrderedTaskMap { + return this.#orderedIndexOrderedTaskMap; + } + + /** findOrderedTask returns all values for the given key(s), or undefined. */ + findOrderedTask(goal: bigint): protoconf.TaskConf_Task[] | undefined { + return this.#orderedIndexOrderedTaskMap.get(goal); + } + + /** findFirstOrderedTask returns the first value for the given key(s), or undefined. */ + findFirstOrderedTask(goal: bigint): protoconf.TaskConf_Task | undefined { + return this.findOrderedTask(goal)?.[0]; + } + + /** findTaskExpiryMap returns the ordered index map: key(Expiry@TaskExpiry) -> values. */ + findTaskExpiryMap(): TaskConf.OrderedIndex_TaskExpiryMap { + return this.#orderedIndexTaskExpiryMap; + } + + /** findTaskExpiry returns all values for the given key(s), or undefined. */ + findTaskExpiry(expiry: bigint): protoconf.TaskConf_Task[] | undefined { + return this.#orderedIndexTaskExpiryMap.get(expiry); + } + + /** findFirstTaskExpiry returns the first value for the given key(s), or undefined. */ + findFirstTaskExpiry(expiry: bigint): protoconf.TaskConf_Task | undefined { + return this.findTaskExpiry(expiry)?.[0]; + } + + /** findSortedTaskExpiryMap returns the ordered index map: key(Expiry@SortedTaskExpiry) -> values. */ + findSortedTaskExpiryMap(): TaskConf.OrderedIndex_SortedTaskExpiryMap { + return this.#orderedIndexSortedTaskExpiryMap; + } + + /** findSortedTaskExpiry returns all values for the given key(s), or undefined. */ + findSortedTaskExpiry(expiry: bigint): protoconf.TaskConf_Task[] | undefined { + return this.#orderedIndexSortedTaskExpiryMap.get(expiry); + } + + /** findFirstSortedTaskExpiry returns the first value for the given key(s), or undefined. */ + findFirstSortedTaskExpiry(expiry: bigint): protoconf.TaskConf_Task | undefined { + return this.findSortedTaskExpiry(expiry)?.[0]; + } + + /** findActivityExpiryMap returns the ordered index map: key((Expiry,ActivityID)@ActivityExpiry) -> values. */ + findActivityExpiryMap(): TaskConf.OrderedIndex_ActivityExpiryMap { + return this.#orderedIndexActivityExpiryMap; + } + + /** findActivityExpiry returns all values for the given key(s), or undefined. */ + findActivityExpiry(expiry: bigint, activityId: bigint): protoconf.TaskConf_Task[] | undefined { + return this.#orderedIndexActivityExpiryMap.get([expiry, activityId]); + } + + /** findFirstActivityExpiry returns the first value for the given key(s), or undefined. */ + findFirstActivityExpiry(expiry: bigint, activityId: bigint): protoconf.TaskConf_Task | undefined { + return this.findActivityExpiry(expiry, activityId)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. TaskConf.Index_XxxMap). +export namespace TaskConf { + /** Index_TaskMap is the index map: key(ActivityID) -> values. */ + export type Index_TaskMap = Map; + /** OrderedIndex_OrderedTaskMap is the ordered index map: key(Goal@OrderedTask) -> values. */ + export type OrderedIndex_OrderedTaskMap = Map; + /** OrderedIndex_TaskExpiryMap is the ordered index map: key(Expiry@TaskExpiry) -> values. */ + export type OrderedIndex_TaskExpiryMap = Map; + /** OrderedIndex_SortedTaskExpiryMap is the ordered index map: key(Expiry@SortedTaskExpiry) -> values. */ + export type OrderedIndex_SortedTaskExpiryMap = Map; + /** OrderedIndex_ActivityExpiryKey is the composite key of ordered index: key((Expiry,ActivityID)@ActivityExpiry). */ + export type OrderedIndex_ActivityExpiryKey = readonly [expiry: bigint, activityId: bigint]; + /** OrderedIndex_ActivityExpiryMap is the ordered index map: key((Expiry,ActivityID)@ActivityExpiry) -> values. */ + export type OrderedIndex_ActivityExpiryMap = TupleKeyMap; +} + +/** + * StrcaseConf is a wrapper around protobuf message protoconf.StrcaseConf. + */ +export class StrcaseConf extends Messager { + #data: protoconf.StrcaseConf = create(protoconf.StrcaseConfSchema); + #indexIndex1Map: StrcaseConf.Index_Index1Map = new Map(); + #indexIndex2Map: StrcaseConf.Index_Index2Map = new Map(); + #indexIndex3Map: StrcaseConf.Index_Index3Map = new Map(); + #indexIndex4Map: StrcaseConf.Index_Index4Map = new Map(); + #indexIndex5Map: StrcaseConf.Index_Index5Map = new Map(); + #indexIndex6Map: StrcaseConf.Index_Index6Map = new Map(); + #indexIndex7Map: StrcaseConf.Index_Index7Map = new Map(); + #indexIndex8Map: StrcaseConf.Index_Index8Map = new Map(); + #indexIndex9Map: StrcaseConf.Index_Index9Map = new Map(); + #indexIndex10Map: StrcaseConf.Index_Index10Map = new Map(); + + /** name returns the StrcaseConf's message name. */ + name(): string { + return protoconf.StrcaseConfSchema.name; + } + + /** load loads StrcaseConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.#data = loadMessagerInDir(protoconf.StrcaseConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load StrcaseConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the StrcaseConf's inner message data. */ + data(): protoconf.StrcaseConf { + return this.#data; + } + + /** message returns the StrcaseConf's inner message data. */ + override message(): protoconf.StrcaseConf { + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexIndex1Map.clear(); + this.#indexIndex2Map.clear(); + this.#indexIndex3Map.clear(); + this.#indexIndex4Map.clear(); + this.#indexIndex5Map.clear(); + this.#indexIndex6Map.clear(); + this.#indexIndex7Map.clear(); + this.#indexIndex8Map.clear(); + this.#indexIndex9Map.clear(); + this.#indexIndex10Map.clear(); + for (const item1 of Object.values(this.#data.taskMap)) { + { + // Index: HTTPServer@Index1 + const key = item1.HTTPServer; + { + const list = this.#indexIndex1Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex1Map.set(key, [item1]); } + } + } + { + // Index: Fight1v1@Index2 + const key = item1.fight1v1; + { + const list = this.#indexIndex2Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex2Map.set(key, [item1]); } + } + } + { + // Index: SeasonRank@Index3 + const key = item1.SEASONRANK; + { + const list = this.#indexIndex3Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex3Map.set(key, [item1]); } + } + } + { + // Index: UserID@Index4 + const key = item1.userID; + { + const list = this.#indexIndex4Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex4Map.set(key, [item1]); } + } + } + { + // Index: Task@Index5 + const key = item1.task; + { + const list = this.#indexIndex5Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex5Map.set(key, [item1]); } + } + } + { + // Index: V2Ray@Index6 + const key = item1.v2Ray; + { + const list = this.#indexIndex6Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex6Map.set(key, [item1]); } + } + } + { + // Index: X@Index7 + const key = item1.x; + { + const list = this.#indexIndex7Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex7Map.set(key, [item1]); } + } + } + { + // Index: SomeField@Index8 + const key = item1.someField; + { + const list = this.#indexIndex8Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex8Map.set(key, [item1]); } + } + } + { + // Index: XCoordinate@Index9 + const key = item1.xCoordinate; + { + const list = this.#indexIndex9Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex9Map.set(key, [item1]); } + } + } + { + // Index: Class@Index10 + const key = item1.class; + { + const list = this.#indexIndex10Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex10Map.set(key, [item1]); } + } + } + } + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(id: bigint): protoconf.StrcaseConf_Task | undefined { + return this.#data.taskMap[id.toString()]; + } + + /** findIndex1Map returns the index map: key(HTTPServer@Index1) -> values. */ + findIndex1Map(): StrcaseConf.Index_Index1Map { + return this.#indexIndex1Map; + } + + /** findIndex1 returns all values for the given key(s), or undefined. */ + findIndex1(httpserver: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex1Map.get(httpserver); + } + + /** findFirstIndex1 returns the first value for the given key(s), or undefined. */ + findFirstIndex1(httpserver: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex1(httpserver)?.[0]; + } + + /** findIndex2Map returns the index map: key(Fight1v1@Index2) -> values. */ + findIndex2Map(): StrcaseConf.Index_Index2Map { + return this.#indexIndex2Map; + } + + /** findIndex2 returns all values for the given key(s), or undefined. */ + findIndex2(fight1V1: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex2Map.get(fight1V1); + } + + /** findFirstIndex2 returns the first value for the given key(s), or undefined. */ + findFirstIndex2(fight1V1: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex2(fight1V1)?.[0]; + } + + /** findIndex3Map returns the index map: key(SeasonRank@Index3) -> values. */ + findIndex3Map(): StrcaseConf.Index_Index3Map { + return this.#indexIndex3Map; + } + + /** findIndex3 returns all values for the given key(s), or undefined. */ + findIndex3(seasonRank: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex3Map.get(seasonRank); + } + + /** findFirstIndex3 returns the first value for the given key(s), or undefined. */ + findFirstIndex3(seasonRank: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex3(seasonRank)?.[0]; + } + + /** findIndex4Map returns the index map: key(UserID@Index4) -> values. */ + findIndex4Map(): StrcaseConf.Index_Index4Map { + return this.#indexIndex4Map; + } + + /** findIndex4 returns all values for the given key(s), or undefined. */ + findIndex4(userId: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex4Map.get(userId); + } + + /** findFirstIndex4 returns the first value for the given key(s), or undefined. */ + findFirstIndex4(userId: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex4(userId)?.[0]; + } + + /** findIndex5Map returns the index map: key(Task@Index5) -> values. */ + findIndex5Map(): StrcaseConf.Index_Index5Map { + return this.#indexIndex5Map; + } + + /** findIndex5 returns all values for the given key(s), or undefined. */ + findIndex5(task: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex5Map.get(task); + } + + /** findFirstIndex5 returns the first value for the given key(s), or undefined. */ + findFirstIndex5(task: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex5(task)?.[0]; + } + + /** findIndex6Map returns the index map: key(V2Ray@Index6) -> values. */ + findIndex6Map(): StrcaseConf.Index_Index6Map { + return this.#indexIndex6Map; + } + + /** findIndex6 returns all values for the given key(s), or undefined. */ + findIndex6(v2Ray: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex6Map.get(v2Ray); + } + + /** findFirstIndex6 returns the first value for the given key(s), or undefined. */ + findFirstIndex6(v2Ray: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex6(v2Ray)?.[0]; + } + + /** findIndex7Map returns the index map: key(X@Index7) -> values. */ + findIndex7Map(): StrcaseConf.Index_Index7Map { + return this.#indexIndex7Map; + } + + /** findIndex7 returns all values for the given key(s), or undefined. */ + findIndex7(x: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex7Map.get(x); + } + + /** findFirstIndex7 returns the first value for the given key(s), or undefined. */ + findFirstIndex7(x: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex7(x)?.[0]; + } + + /** findIndex8Map returns the index map: key(SomeField@Index8) -> values. */ + findIndex8Map(): StrcaseConf.Index_Index8Map { + return this.#indexIndex8Map; + } + + /** findIndex8 returns all values for the given key(s), or undefined. */ + findIndex8(someField: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex8Map.get(someField); + } + + /** findFirstIndex8 returns the first value for the given key(s), or undefined. */ + findFirstIndex8(someField: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex8(someField)?.[0]; + } + + /** findIndex9Map returns the index map: key(XCoordinate@Index9) -> values. */ + findIndex9Map(): StrcaseConf.Index_Index9Map { + return this.#indexIndex9Map; + } + + /** findIndex9 returns all values for the given key(s), or undefined. */ + findIndex9(xcoordinate: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex9Map.get(xcoordinate); + } + + /** findFirstIndex9 returns the first value for the given key(s), or undefined. */ + findFirstIndex9(xcoordinate: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex9(xcoordinate)?.[0]; + } + + /** findIndex10Map returns the index map: key(Class@Index10) -> values. */ + findIndex10Map(): StrcaseConf.Index_Index10Map { + return this.#indexIndex10Map; + } + + /** findIndex10 returns all values for the given key(s), or undefined. */ + findIndex10(class_: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex10Map.get(class_); + } + + /** findFirstIndex10 returns the first value for the given key(s), or undefined. */ + findFirstIndex10(class_: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex10(class_)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. StrcaseConf.Index_XxxMap). +export namespace StrcaseConf { + /** Index_Index1Map is the index map: key(HTTPServer@Index1) -> values. */ + export type Index_Index1Map = Map; + /** Index_Index2Map is the index map: key(Fight1v1@Index2) -> values. */ + export type Index_Index2Map = Map; + /** Index_Index3Map is the index map: key(SeasonRank@Index3) -> values. */ + export type Index_Index3Map = Map; + /** Index_Index4Map is the index map: key(UserID@Index4) -> values. */ + export type Index_Index4Map = Map; + /** Index_Index5Map is the index map: key(Task@Index5) -> values. */ + export type Index_Index5Map = Map; + /** Index_Index6Map is the index map: key(V2Ray@Index6) -> values. */ + export type Index_Index6Map = Map; + /** Index_Index7Map is the index map: key(X@Index7) -> values. */ + export type Index_Index7Map = Map; + /** Index_Index8Map is the index map: key(SomeField@Index8) -> values. */ + export type Index_Index8Map = Map; + /** Index_Index9Map is the index map: key(XCoordinate@Index9) -> values. */ + export type Index_Index9Map = Map; + /** Index_Index10Map is the index map: key(Class@Index10) -> values. */ + export type Index_Index10Map = Map; +} diff --git a/test/ts-tableau-loader/tableau/util.pc.ts b/test/ts-tableau-loader/tableau/util.pc.ts new file mode 100644 index 0000000..90fac73 --- /dev/null +++ b/test/ts-tableau-loader/tableau/util.pc.ts @@ -0,0 +1,388 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +/* eslint-disable */ + +import { clone, getExtension, type DescField, type DescMessage, type Message } from "@bufbuild/protobuf"; +import * as tableaupb from "../protoconf/tableau/protobuf/tableau_pb.js"; + +/** + * Format specifies the format of the configuration file. + */ +export enum Format { + UNKNOWN = "unknown", + JSON = "json", + BIN = "bin", +} + +const UNKNOWN_EXT = ".unknown"; +const JSON_EXT = ".json"; +const BIN_EXT = ".binpb"; + +/** + * getFormat returns the Format determined by the file extension of the given path. + */ +export function getFormat(path: string): Format { + if (path.endsWith(JSON_EXT)) return Format.JSON; + if (path.endsWith(BIN_EXT)) return Format.BIN; + return Format.UNKNOWN; +} + +/** + * format2Ext returns the file extension corresponding to the given format. + */ +export function format2Ext(fmt: Format): string { + switch (fmt) { + case Format.JSON: + return JSON_EXT; + case Format.BIN: + return BIN_EXT; + default: + return UNKNOWN_EXT; + } +} + +// AnyMessage is a structural view over a protobuf-es message used by the +// reflection-based patch routines below. +type AnyMessage = Record; + +/** + * getSheetPatch returns the sheet-level patch type for a message descriptor. + */ +export function getSheetPatch(desc: DescMessage): tableaupb.Patch { + const opts = desc.proto.options; + if (!opts) return tableaupb.Patch.NONE; + const ws = getExtension(opts, tableaupb.worksheet); + return ws?.patch ?? tableaupb.Patch.NONE; +} + +function getFieldPatch(fd: DescField): tableaupb.Patch { + const opts = fd.proto.options; + if (!opts) return tableaupb.Patch.NONE; + const fieldOpts = getExtension(opts, tableaupb.field); + return fieldOpts?.prop?.patch ?? tableaupb.Patch.NONE; +} + +function isFieldPopulated(msg: AnyMessage, fd: DescField): boolean { + const v = msg[fd.localName]; + if (v === undefined || v === null) return false; + switch (fd.fieldKind) { + case "list": + return Array.isArray(v) && v.length > 0; + case "map": + return typeof v === "object" && Object.keys(v as object).length > 0; + case "message": + return true; + default: + if (typeof v === "bigint") return v !== 0n; + if (typeof v === "number") return v !== 0; + if (typeof v === "string") return v.length > 0; + if (typeof v === "boolean") return v; + if (v instanceof Uint8Array) return v.length > 0; + return true; + } +} + +function clearField(msg: AnyMessage, fd: DescField): void { + switch (fd.fieldKind) { + case "list": + msg[fd.localName] = []; + break; + case "map": + msg[fd.localName] = {}; + break; + default: + delete msg[fd.localName]; + break; + } +} + +/** + * patchMessage patches src into dst, which must be messages with the same descriptor. + * + * Default mechanism: + * - scalar: populated scalar fields in src are copied to dst. + * - message: populated singular messages in src are merged into dst recursively, + * or replace dst message if PATCH_REPLACE is specified for the field. + * - list: src elements are appended to dst, or replace dst list on PATCH_REPLACE. + * - map: src entries are merged into dst, or replace dst map on PATCH_REPLACE. + */ +export function patchMessage(dst: Message, src: Message, desc: DescMessage): void { + patchMessageInternal(dst as unknown as AnyMessage, src as unknown as AnyMessage, desc); +} + +function patchMessageInternal(dst: AnyMessage, src: AnyMessage, desc: DescMessage): void { + for (const fd of desc.fields) { + if (!isFieldPopulated(src, fd)) continue; + if (getFieldPatch(fd) === tableaupb.Patch.REPLACE) { + clearField(dst, fd); + } + switch (fd.fieldKind) { + case "map": + patchMap(dst, src, fd); + break; + case "list": + patchList(dst, src, fd); + break; + case "message": { + const name = fd.localName; + const srcChild = src[name] as AnyMessage; + const dstChild = dst[name] as AnyMessage | undefined; + if (dstChild === undefined || dstChild === null) { + dst[name] = clone(fd.message, srcChild as never); + } else { + patchMessageInternal(dstChild, srcChild, fd.message); + } + break; + } + default: + dst[fd.localName] = src[fd.localName]; + break; + } + } +} + +function patchList(dst: AnyMessage, src: AnyMessage, fd: DescField): void { + const srcList = src[fd.localName] as unknown[]; + let dstList = dst[fd.localName] as unknown[] | undefined; + if (!Array.isArray(dstList)) { + dstList = []; + dst[fd.localName] = dstList; + } + const isMessage = fd.fieldKind === "list" && fd.listKind === "message"; + for (const item of srcList) { + if (isMessage && fd.message) { + dstList.push(clone(fd.message, item as never)); + } else { + dstList.push(item); + } + } +} + +function patchMap(dst: AnyMessage, src: AnyMessage, fd: DescField): void { + const srcMap = src[fd.localName] as Record; + let dstMap = dst[fd.localName] as Record | undefined; + if (typeof dstMap !== "object" || dstMap === null) { + dstMap = {}; + dst[fd.localName] = dstMap; + } + const isMessageValue = fd.fieldKind === "map" && fd.mapKind === "message"; + for (const [k, v] of Object.entries(srcMap)) { + if (isMessageValue && fd.message) { + const existing = dstMap[k] as AnyMessage | undefined; + if (existing !== undefined && existing !== null) { + // NOTE: this MERGES into the existing value (differs from a simple replace). + patchMessageInternal(existing, v as AnyMessage, fd.message); + } else { + dstMap[k] = clone(fd.message, v as never); + } + } else { + dstMap[k] = v; + } + } +} + +// --------------------------------------------------------------------------- +// Index container runtime +// +// Helpers shared by generated loaders to build index, ordered index and +// ordered map containers. +// --------------------------------------------------------------------------- + +/** + * makeIndexKey serializes a tuple of multi-column index key parts into a unique + * string so it can be used as a Map key with by-value equality (JS Maps compare + * object keys by reference, which would break composite keys). + * + * Each part is tagged with its runtime type to avoid collisions between values + * that stringify to the same text (e.g. the number 1 and the string "1"), and + * parts are joined with a NUL separator that cannot appear in normal strings. + * + * Module-private: it is an implementation detail of TupleKeyMap (the only + * caller). Generated loaders never serialize keys themselves; they go through + * TupleKeyMap, so this is intentionally not exported. + */ +function makeIndexKey(parts: readonly unknown[]): string { + return parts.map((p) => `${typeof p}:${String(p)}`).join("\u0000"); +} + +/** + * compareValues compares two ordered scalar key parts of the same logical type + * (number, bigint, string or boolean), returning a negative/zero/positive + * number suitable for Array.prototype.sort. + */ +export function compareValues(a: unknown, b: unknown): number { + if (typeof a === "bigint" || typeof b === "bigint") { + const x = BigInt(a as never); + const y = BigInt(b as never); + return x < y ? -1 : x > y ? 1 : 0; + } + if (typeof a === "string" || typeof b === "string") { + const x = String(a); + const y = String(b); + return x < y ? -1 : x > y ? 1 : 0; + } + if (typeof a === "boolean" || typeof b === "boolean") { + return (a ? 1 : 0) - (b ? 1 : 0); + } + return (a as number) - (b as number); +} + +/** + * compareTuples compares two equal-length tuples of ordered scalar parts + * lexicographically. + */ +export function compareTuples(a: readonly unknown[], b: readonly unknown[]): number { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) { + const c = compareValues(a[i], b[i]); + if (c !== 0) return c; + } + return a.length - b.length; +} + +/** + * sortMapByKey sorts a Map in place by key using the given comparator and + * returns the same Map instance. JS Maps preserve insertion order, so the + * entries are re-inserted in sorted order (an "ordered map"). Sorting in place + * (rather than returning a new Map) keeps the Map reference stable across + * reloads, so views cached over it (e.g. TupleKeyMap) stay valid. + */ +export function sortMapByKey(map: Map, cmp: (a: K, b: K) => number): Map { + const entries = [...map.entries()].sort((x, y) => cmp(x[0], y[0])); + map.clear(); + for (const [k, v] of entries) map.set(k, v); + return map; +} + +/** + * OrderedMapValue is the value stored at a non-leaf level of a nested ordered + * map. It pairs the next-level ordered (sub-)map with the message value at the + * current level, mirroring the Pair node the Go / C++ / C# + * loaders expose (Go .First/.Second, C# .Item1/.Item2, C++ .first/.second), so + * callers can both descend into the sub-map (.first) and read the level's own + * value (.second). Leaf levels store the plain value directly, without this + * wrapper. + * + * Type parameters: + * - M: the next-level ordered map type (a Map keyed in sorted order). + * - E: the message value at the current level. + */ +export interface OrderedMapValue { + /** first is the next-level ordered (sub-)map, sorted by key. */ + first: M; + /** second is the value at the current level. */ + second: E; +} + +/** + * TupleKeyMap is the multi-column index / ordered-index container. A composite + * key (e.g. [param, extType]) cannot be used directly as a JS Map key (Maps + * compare object keys by reference), so it owns an internal Map keyed by the + * opaque serialized string of the key columns (see makeIndexKey) plus a side + * table mapping that string back to the original key tuple. Lookups and + * iteration accept / return by-value tuple keys, mirroring the structured + * composite keys the Go / C++ / C# loaders expose for multi-column indexes + * (where the map key is a comparable key struct). + * + * Unlike a plain read-only view, it owns its storage and exposes a small build + * API (clear / getOrSet / sortKeys) used by the generated processAfterLoad, so + * a messager holds this single container per multi-column index (or per + * multi-key leveled container) instead of a raw Map, a separate serialized-key + * -> tuple side table, and a cached view. It is mutated in place across + * reloads, so its reference stays stable and callers holding the container + * returned by a finder stay valid. + * + * Type parameters: + * - K: the readonly tuple of key columns (e.g. readonly [id: number, + * name: string]); the generated loaders pass a named alias (e.g. + * ItemConf.Index_AwardItemKey) so lookups / iteration are fully typed. K is + * purely compile-time: the runtime always serializes via makeIndexKey, so + * the key type carries no runtime cost. + * - E: the value type stored under each key tuple. This is a plain tuple-keyed + * map (one key -> one value), so the value is whatever the caller stores: + * a value list (E = V[]) for a leaf multi-column index, or an inner index + * container for a multi-key leveled container. + */ +export class TupleKeyMap { + private readonly map = new Map(); + private readonly tuples = new Map(); + + /** size returns the number of key buckets. */ + get size(): number { + return this.map.size; + } + + /** clear empties the container (called at the start of each (re)build). */ + clear(): void { + this.map.clear(); + this.tuples.clear(); + } + + /** + * getOrSet returns the value stored under the given key columns, creating and + * inserting it via make() on first access (and recording the original key + * tuple then, for sortKeys and by-value key iteration). makeIndexKey is + * computed exactly once. The build side is generator-driven, so keyParts is + * loosely typed; the query side (get/has/keys/entries) stays strongly typed. + */ + getOrSet(keyParts: readonly unknown[], make: () => E): E { + const k = makeIndexKey(keyParts); + let v = this.map.get(k); + if (v === undefined) { + v = make(); + this.map.set(k, v); + this.tuples.set(k, keyParts); + } + return v; + } + + /** + * sortKeys reorders the buckets by comparing their key tuples + * lexicographically (used by ordered indexes). JS Maps preserve insertion + * order, so entries are re-inserted in sorted order. Sorting in place keeps + * the container reference stable across reloads. + */ + sortKeys(): void { + const entries = [...this.map.entries()].sort((x, y) => + compareTuples(this.tuples.get(x[0]) ?? [], this.tuples.get(y[0]) ?? []), + ); + this.map.clear(); + for (const [k, v] of entries) this.map.set(k, v); + } + + /** get returns the value stored under the given key columns, or undefined. */ + get(key: K): E | undefined { + return this.map.get(makeIndexKey(key)); + } + + /** has reports whether the given key columns are present. */ + has(key: K): boolean { + return this.map.has(makeIndexKey(key)); + } + + /** keys iterates the key-column tuples in container (sorted, if ordered) order. */ + *keys(): IterableIterator { + for (const k of this.map.keys()) { + yield (this.tuples.get(k) ?? []) as unknown as K; + } + } + + /** values iterates the values in container order. */ + values(): IterableIterator { + return this.map.values(); + } + + /** entries iterates [keyColumns, value] pairs in container order. */ + *entries(): IterableIterator<[K, E]> { + for (const [k, v] of this.map) { + yield [(this.tuples.get(k) ?? []) as unknown as K, v]; + } + } + + [Symbol.iterator](): IterableIterator<[K, E]> { + return this.entries(); + } +} + diff --git a/test/ts-tableau-loader/tests/get.test.ts b/test/ts-tableau-loader/tests/get.test.ts new file mode 100644 index 0000000..60e09c3 --- /dev/null +++ b/test/ts-tableau-loader/tests/get.test.ts @@ -0,0 +1,55 @@ +// Get block: point-lookup getters (map getters). Mirrors the same scenarios in: +// - Go: test/go-tableau-loader/get_test.go +// - C#: test/csharp-tableau-loader/tests/GetTests.cs +// - C++: test/cpp-tableau-loader/tests/get_test.cpp +import { assert, check, fruitTypeApple, prepareHub } from "./harness.js"; + +export function run(): void { + // ---- ItemConf: first-level int-keyed map getter ---- + check("ItemConf map getter", () => { + const item = prepareHub().getItemConf(); + assert.ok(item); + assert.equal(item!.name(), "ItemConf"); + assert.equal(item!.get1(1)?.name, "apple"); + assert.equal(item!.get1(0)?.name, "coin1"); + assert.equal(item!.get1(12345), undefined); + }); + + // ---- ActivityConf: nested map getters with a 64-bit (bigint) outer key ---- + check("ActivityConf nested map getters", () => { + const activity = prepareHub().getActivityConf(); + assert.ok(activity); + assert.equal(activity!.get1(100001n)?.activityName, "活动1"); + assert.equal(activity!.get2(100001n, 1)?.chapterName, "签到活动章1"); + assert.equal(activity!.get3(100001n, 1, 2)?.sectionId, 2); + // sectionRankMap["2001"] === 2 + assert.equal(activity!.get4(100001n, 1, 2, 2001), 2); + // not found + assert.equal(activity!.get3(100001n, 1, 99), undefined); + }); + + // ---- ThemeConf: string-keyed map getter ---- + check("ThemeConf string-keyed map", () => { + const theme = prepareHub().getThemeConf(); + assert.ok(theme); + assert.ok(Object.keys(theme!.data().themeMap).length > 0); + }); + + // ---- FruitConf: leveled point getters ---- + check("FruitConf leveled getters", () => { + const fruit = prepareHub().getFruitConf(); + assert.ok(fruit); + + // get1: found / not found + assert.ok(fruit!.get1(fruitTypeApple)); + assert.equal(fruit!.get1(999), undefined); + + // get2: APPLE -> item 1001, price 10 + const item = fruit!.get2(fruitTypeApple, 1001); + assert.ok(item); + assert.equal(item!.id, 1001); + assert.equal(item!.price, 10); + assert.equal(fruit!.get2(fruitTypeApple, 9999), undefined); + assert.equal(fruit!.get2(999, 1001), undefined); + }); +} diff --git a/test/ts-tableau-loader/tests/harness.ts b/test/ts-tableau-loader/tests/harness.ts new file mode 100644 index 0000000..7266d97 --- /dev/null +++ b/test/ts-tableau-loader/tests/harness.ts @@ -0,0 +1,53 @@ +// Shared harness for the TS tableau loader tests. +// +// The unit tests are split into four blocks, one file each, mirroring the +// Go / C# / C++ test layout: +// - load.test.ts (Load, filter, Bin, Patch) +// - get.test.ts (point-lookup getters) +// - ordered_map.test.ts (getOrderedMap traversal) +// - index.test.ts (find* index finders) +// +// `tests/smoke.ts` is the entry point that runs all four blocks. +import { strict as assert } from "node:assert"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { Hub } from "../tableau/hub.pc.js"; +import { Format } from "../tableau/load.pc.js"; +import * as protoconf from "../tableau/barrel/protoconf.pc.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +export const testdata = join(here, "..", "..", "testdata"); +export const confDir = join(testdata, "conf"); +export const binDir = join(testdata, "bin"); +export const patchConf = join(testdata, "patchconf"); +export const patchConf2 = join(testdata, "patchconf2"); +export const patchResult = join(testdata, "patchresult"); + +// fruitType map keys (== FruitType enum values) used by FruitConf.json. +export const fruitTypeApple = protoconf.FruitType.APPLE; +export const fruitTypeOrange = protoconf.FruitType.ORANGE; +export const fruitTypeBanana = protoconf.FruitType.BANANA; + +export { assert, join }; + +let passed = 0; +export function check(name: string, fn: () => void): void { + fn(); + passed++; + console.log(` ok - ${name}`); +} +export function passedCount(): number { + return passed; +} + +// Shared hub, loaded lazily once and reused across the Get / OrderedMap / Index +// blocks. Mirrors Go's prepareHub helper, C#'s HubFixture and C++'s HubFixture. +let sharedHub: Hub | undefined; +export function prepareHub(): Hub { + if (!sharedHub) { + sharedHub = new Hub(); + sharedHub.load(confDir, Format.JSON, { ignoreUnknownFields: true }); + } + return sharedHub; +} diff --git a/test/ts-tableau-loader/tests/index.test.ts b/test/ts-tableau-loader/tests/index.test.ts new file mode 100644 index 0000000..08b293c --- /dev/null +++ b/test/ts-tableau-loader/tests/index.test.ts @@ -0,0 +1,120 @@ +// Index block: index finders (leveled, ordered and multi-column). Mirrors: +// - Go: test/go-tableau-loader/index_test.go +// - C#: test/csharp-tableau-loader/tests/IndexTests.cs +// - C++: test/cpp-tableau-loader/tests/index_test.cpp +import * as protoconf from "../tableau/barrel/protoconf.pc.js"; + +import { + assert, + check, + fruitTypeApple, + fruitTypeOrange, + fruitTypeBanana, + prepareHub, +} from "./harness.js"; + +export function run(): void { + // ---- FruitConf: leveled index (Price) finders ---- + check("FruitConf index finders", () => { + const fruit = prepareHub().getFruitConf(); + assert.ok(fruit); + + // global index: price -> items + assert.deepEqual(fruit!.findItem(10)?.map((i) => i.id), [1001]); + assert.equal(fruit!.findFirstItem(20)?.id, 1002); + assert.equal(fruit!.findItem(999), undefined); + // 6 items, all unique prices -> 6 entries + assert.equal(fruit!.findItemMap().size, 6); + + // 1st-level scoped index: (fruitType) -> price -> items + assert.deepEqual(fruit!.findItem1(fruitTypeOrange, 15)?.map((i) => i.id), [2001]); + assert.equal(fruit!.findFirstItem1(fruitTypeBanana, 8)?.id, 3001); + assert.equal(fruit!.findItemMap1(fruitTypeApple)?.size, 2); + assert.equal(fruit!.findItemMap1(999), undefined); + assert.equal(fruit!.findItem1(999, 15), undefined); + }); + + // ---- FruitConf: ordered index (Price@OrderedFruit) finders + ascending map ---- + check("FruitConf ordered index finders", () => { + const fruit = prepareHub().getFruitConf(); + assert.ok(fruit); + + assert.deepEqual(fruit!.findOrderedFruit(10)?.map((i) => i.id), [1001]); + assert.equal(fruit!.findFirstOrderedFruit(25)?.id, 2002); + assert.equal(fruit!.findOrderedFruit(999), undefined); + + // ordered map is keyed in ascending price order + const m = fruit!.findOrderedFruitMap(); + assert.equal(m.size, 6); + let prev = -Infinity; + for (const key of m.keys()) { + assert.ok(key >= prev, `ordered map keys not ascending: ${key} after ${prev}`); + prev = key; + } + + // 1st-level scoped ordered index + assert.deepEqual(fruit!.findOrderedFruit1(fruitTypeOrange, 25)?.map((i) => i.id), [2002]); + assert.equal(fruit!.findFirstOrderedFruit1(fruitTypeBanana, 12)?.id, 3002); + assert.equal(fruit!.findOrderedFruit1(999, 25), undefined); + }); + + // ---- ItemConf: single-column index ---- + check("ItemConf findItemInfoMap non-empty", () => { + const item = prepareHub().getItemConf(); + assert.ok(item); + assert.ok(item!.findItemInfoMap().size > 0); + }); + + // ---- ItemConf: multi-column ordered index (ParamExtType) ---- + // Returns a TupleKeyMap whose keys are readable [param, extType] tuples (not + // opaque serialized strings), are sorted ascending, and round-trip through get(). + check("ItemConf multi-column ordered index (TupleKeyMap)", () => { + const item = prepareHub().getItemConf(); + assert.ok(item); + + // apple has param_list [1,2,3] x extTypeList [APPLE, ORANGE] -> 6 buckets. + const m = item!.findParamExtTypeMap(); + assert.equal(m.size, 6); + assert.equal([...m.keys()].length, 6); + + let prevParam = -Infinity; + for (const [key, values] of m) { + // keys are decoded 2-tuples, not opaque serialized strings + assert.equal(key.length, 2); + assert.ok((key[0] as number) >= prevParam, "ParamExtType keys not ascending by param"); + prevParam = key[0] as number; + // get(tuple) round-trips to the very same value list reference + assert.equal(m.get(key), values); + // and agrees with the point-lookup finder + assert.equal( + item!.findParamExtType(key[0] as number, key[1] as protoconf.FruitType), + values, + ); + } + + // a concrete known bucket: (param=1, extType=APPLE) -> apple + assert.deepEqual(m.get([1, fruitTypeApple])?.map((i) => i.name), ["apple"]); + assert.equal(m.get([999, fruitTypeApple]), undefined); + }); + + // ---- ItemConf: non-ordered multi-column index (AwardItem) ---- + // Also exposed as a TupleKeyMap with readable [id, name] tuple keys. + check("ItemConf multi-column index (TupleKeyMap)", () => { + const item = prepareHub().getItemConf(); + assert.ok(item); + + const m = item!.findAwardItemMap(); + for (const [key, values] of m) { + assert.equal(key.length, 2); + assert.equal(m.get(key), values); + assert.equal( + item!.findAwardItem(key[0] as number, key[1] as string), + values, + ); + } + // apple is keyed by (id=1, name="apple") + assert.deepEqual(m.get([1, "apple"])?.map((i) => i.id), [1]); + // findFirst* variant returns the first match for the same key. + assert.equal(item!.findFirstAwardItem(1, "apple")?.id, 1); + }); +} diff --git a/test/ts-tableau-loader/tests/load.test.ts b/test/ts-tableau-loader/tests/load.test.ts new file mode 100644 index 0000000..bd13c81 --- /dev/null +++ b/test/ts-tableau-loader/tests/load.test.ts @@ -0,0 +1,179 @@ +// Load block: hub loading, filter, binary-format loading and patch loading. +// Mirrors the same scenarios in: +// - Go: test/go-tableau-loader/load_test.go +// - C#: test/csharp-tableau-loader/tests/LoadTests.cs +// - C++: test/cpp-tableau-loader/tests/load_test.cpp +import { equals } from "@bufbuild/protobuf"; + +import { Hub, Registry } from "../tableau/hub.pc.js"; +import { Format, LoadMode } from "../tableau/load.pc.js"; +import { + PatchMergeConf, + PatchReplaceConf, + RecursivePatchConf, +} from "../tableau/patch_conf.pc.js"; +import { HeroConf } from "../tableau/hero_conf.pc.js"; +import * as protoconf from "../tableau/barrel/protoconf.pc.js"; + +import { CustomItemConf, CustomItemConfName } from "../custom/custom_item_conf.js"; + +import { + assert, + check, + confDir, + binDir, + patchConf, + patchConf2, + patchResult, + testdata, + join, + prepareHub, +} from "./harness.js"; + +// Register the hand-written CustomItemConf before any hub is built, so the +// shared hub (and any hub loaded afterwards) includes it. Mirrors Go's init() +// Register, C#'s HubFixture registration and C++'s InitCustomMessager. +Registry.register(CustomItemConf); + +export function run(): void { + // ---- Load ---- + + // Hub loads all messagers from a JSON directory. + check("hub.load(conf, JSON)", () => { + const hub = prepareHub(); + assert.ok(hub.getItemConf()); + assert.ok(hub.getActivityConf()); + }); + + // ---- CustomConf ---- + + // CustomItemConf is a hand-written messager: it has no file of its own and + // resolves a "special" item from ItemConf via processAfterLoadAll. Mirrors + // Go's Test_CustomItemConf and C#/C++'s + // CustomItemConf_ProcessAfterLoadAll_ResolvesSpecialItem. + check("CustomItemConf processAfterLoadAll", () => { + const hub = prepareHub(); + const custom = hub.getMessager(CustomItemConfName) as CustomItemConf | undefined; + assert.ok(custom); + // ItemConf item 1 is "apple", so the resolved special item name is non-empty. + assert.notEqual(custom!.getSpecialItemName(), ""); + assert.equal(custom!.getSpecialItemName(), "apple"); + }); + + // Hub typed accessor for an unloaded-by-filter messager returns undefined. + check("Hub filter", () => { + const filtered = new Hub({ filter: (n) => n === "ItemConf" }); + filtered.load(confDir, Format.JSON, { ignoreUnknownFields: true }); + assert.ok(filtered.getItemConf()); + assert.equal(filtered.getActivityConf(), undefined); + }); + + // ---- Bin ---- + + // Binary format load via a single messager. + check("HeroConf BIN load", () => { + const hero = new HeroConf(); + hero.load(binDir, Format.BIN); + assert.equal(hero.name(), "HeroConf"); + }); + + // BIN load from a non-existent directory throws (mirrors C#'s failure case). + check("HeroConf BIN load failure", () => { + const hero = new HeroConf(); + assert.throws(() => hero.load(join(testdata, "no-such-bin-dir"), Format.BIN)); + }); + + // ---- Patch ---- + + // Patch MERGE: main + two patch files merged in order. + check("PatchMergeConf MERGE", () => { + const pm = new PatchMergeConf(); + pm.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + patchPaths: [ + join(patchConf, "PatchMergeConf.json"), + join(patchConf2, "PatchMergeConf.json"), + ], + }); + // Scalar overridden by the first patch. + assert.equal(pm.data().name, "orange"); + // itemMap key 999 contributed by the second patch (merge, not replace). + assert.ok(pm.get1(999)); + assert.ok(pm.get1(1)); + // itemMap merges field-by-field, so the main file's key 2 survives. + assert.ok(pm.get1(2)); + // replaceItemMap has the field-level PATCH_REPLACE option: the last patch + // that carries it fully replaces the field. patchconf2 provides {1, 999}, + // so key 999 is present while the main file's key 2 is dropped (replace, + // not merge). Mirrors Go/C#/C++'s ReplaceItemMap[999] assertion. + assert.ok(pm.data().replaceItemMap[999]); + assert.equal(pm.data().replaceItemMap[2], undefined); + }); + + // LoadMode.ONLY_MAIN ignores the patch files. + check("PatchMergeConf ONLY_MAIN", () => { + const pm = new PatchMergeConf(); + pm.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + mode: LoadMode.ONLY_MAIN, + patchPaths: [ + join(patchConf, "PatchMergeConf.json"), + join(patchConf2, "PatchMergeConf.json"), + ], + }); + assert.equal(pm.data().name, "apple"); + assert.equal(pm.get1(999), undefined); + }); + + // PATCH_MERGE golden: conf + patchconf merged must equal the canonical + // patchresult, checked as a whole (mirrors Go/C#'s proto.Equal comparison). + check("RecursivePatchConf MERGE golden", () => { + const got = new RecursivePatchConf(); + got.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + patchDirs: [patchConf], + }); + + // The golden file is itself a RecursivePatchConf sheet (PATCH_MERGE), so load + // it with no patch sources to get the main file content verbatim. + const expected = new RecursivePatchConf(); + expected.load(patchResult, Format.JSON, { ignoreUnknownFields: true }); + + assert.ok( + equals(protoconf.RecursivePatchConfSchema, got.message(), expected.message()), + "patched RecursivePatchConf does not match the golden patchresult", + ); + }); + + // PATCH_REPLACE: the sheet-level option replaces the whole message with the + // last patch file, rather than merging field-by-field. + check("PatchReplaceConf PATCH_REPLACE", () => { + const pr = new PatchReplaceConf(); + pr.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + patchDirs: [patchConf], + }); + // Main file has name "apple" / priceList [10,100]; the patch fully replaces + // it with name "orange" / priceList [20,200] (replace, not append). + assert.equal(pr.data().name, "orange"); + assert.deepEqual(pr.data().priceList.map(Number), [20, 200]); + }); + + // LoadMode.ONLY_PATCH starts from an empty message and applies patches only. + check("PatchMergeConf ONLY_PATCH", () => { + const pm = new PatchMergeConf(); + pm.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + mode: LoadMode.ONLY_PATCH, + patchPaths: [ + join(patchConf, "PatchMergeConf.json"), + join(patchConf2, "PatchMergeConf.json"), + ], + }); + // Name comes from the first patch (second patch carries no name field), and + // both itemMap entries come purely from the patches, not the main file. + assert.equal(pm.data().name, "orange"); + assert.ok(pm.get1(1)); + assert.ok(pm.get1(999)); + }); +} diff --git a/test/ts-tableau-loader/tests/ordered_map.test.ts b/test/ts-tableau-loader/tests/ordered_map.test.ts new file mode 100644 index 0000000..0231de2 --- /dev/null +++ b/test/ts-tableau-loader/tests/ordered_map.test.ts @@ -0,0 +1,64 @@ +// OrderedMap block: getOrderedMap traversal. Mirrors the same scenarios in: +// - Go: test/go-tableau-loader/ordered_map_test.go +// - C#: test/csharp-tableau-loader/tests/OrderedMapTests.cs +// - C++: test/cpp-tableau-loader/tests/ordered_map_test.cpp +import { assert, check, prepareHub } from "./harness.js"; + +export function run(): void { + // ---- ActivityConf: nested ordered map ---- + // getOrderedMap() exposes the full sorted tree; each node carries its value + // (.second) and the next-level sub-map (.first), and the leveled + // getOrderedMap1/2/3 getters descend it. + check("ActivityConf nested ordered map", () => { + const activity = prepareHub().getActivityConf(); + assert.ok(activity); + + // 1st level: bigint keys, ascending; node.second is the Activity value. + const top = activity!.getOrderedMap(); + assert.ok(top.size > 0); + let prev = -1n; + for (const key of top.keys()) { + assert.ok(key >= prev, `ordered map keys not ascending: ${key} after ${prev}`); + prev = key; + } + assert.equal(top.get(100001n)?.second.activityName, "活动1"); + + // descending via .first equals the scoped getter (2nd level: chapter map). + const chapters = activity!.getOrderedMap1(100001n); + assert.equal(chapters, top.get(100001n)?.first); + assert.equal(chapters?.get(1)?.second.chapterName, "签到活动章1"); + + // 3rd level: section map, scoped to (activityId, chapterId). + const sections = activity!.getOrderedMap2(100001n, 1); + assert.equal(sections, chapters?.get(1)?.first); + + // 4th level (leaf): scalar map sectionRankMap["2001"] === 2, ascending keys. + const ranks = activity!.getOrderedMap3(100001n, 1, 2); + assert.equal(ranks, sections?.get(2)?.first); + assert.equal(ranks?.get(2001), 2); + let prevRank = -Infinity; + for (const key of ranks!.keys()) { + assert.ok(key >= prevRank, `leaf ordered map keys not ascending: ${key} after ${prevRank}`); + prevRank = key; + } + + // missing keys return undefined at every level. + assert.equal(activity!.getOrderedMap1(999n), undefined); + assert.equal(activity!.getOrderedMap3(100001n, 1, 999), undefined); + }); + + // ---- ItemConf: 1st-level ordered map ---- + check("ItemConf ordered map ascending", () => { + const item = prepareHub().getItemConf(); + assert.ok(item); + const orderedMap = item!.getOrderedMap(); + assert.ok(orderedMap.size > 0); + + // keys ascending by id + let prev = -Infinity; + for (const key of orderedMap.keys()) { + assert.ok(key >= prev, `ItemConf ordered map keys not ascending: ${key} after ${prev}`); + prev = key; + } + }); +} diff --git a/test/ts-tableau-loader/tests/smoke.ts b/test/ts-tableau-loader/tests/smoke.ts new file mode 100644 index 0000000..2d429c2 --- /dev/null +++ b/test/ts-tableau-loader/tests/smoke.ts @@ -0,0 +1,19 @@ +// Smoke-test entry point. Runs the four test blocks in order: +// Load -> Get -> OrderedMap -> Index +// Each block lives in its own file and shares the harness in ./harness.ts. +import { run as runLoad } from "./load.test.js"; +import { run as runGet } from "./get.test.js"; +import { run as runOrderedMap } from "./ordered_map.test.js"; +import { run as runIndex } from "./index.test.js"; +import { passedCount } from "./harness.js"; + +console.log("# Load"); +runLoad(); +console.log("# Get"); +runGet(); +console.log("# OrderedMap"); +runOrderedMap(); +console.log("# Index"); +runIndex(); + +console.log(`\nAll ${passedCount()} smoke checks passed.`); diff --git a/_lab/ts/tsconfig.json b/test/ts-tableau-loader/tsconfig.json similarity index 80% rename from _lab/ts/tsconfig.json rename to test/ts-tableau-loader/tsconfig.json index e67bc94..d47a5b2 100644 --- a/_lab/ts/tsconfig.json +++ b/test/ts-tableau-loader/tsconfig.json @@ -3,12 +3,11 @@ "target": "es2022", "module": "nodenext", "moduleResolution": "nodenext", - "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, "types": ["node"] }, - "include": ["src"] + "include": ["tableau", "protoconf", "custom", "tests"] } diff --git a/test_make.py b/test_make.py index a1acfcc..b1425ed 100644 --- a/test_make.py +++ b/test_make.py @@ -59,6 +59,7 @@ def test_typed_accessors(self): assert v.go_version == v.raw["GO_VERSION"] assert v.buf_version == v.raw["BUF_VERSION"] assert v.dotnet_version == v.raw["DOTNET_VERSION"] + assert v.node_version == v.raw["NODE_VERSION"] assert v.cmake_version == v.raw["CMAKE_VERSION"] # default_variant is lowercased. assert v.default_variant == v.raw["DEFAULT_VARIANT"].lower() @@ -68,6 +69,23 @@ def test_typed_accessors(self): assert v.protobuf_version == v.raw[f"{prefix}_PROTOBUF_VERSION"] assert v.vcpkg_baseline_commit == v.raw[f"{prefix}_VCPKG_BASELINE_COMMIT"] + def test_node_version_accessor(self): + # NODE_VERSION feeds the TypeScript harness toolchain (NodeSource apt + # repo `setup_${NODE_VERSION}.x`, brew `node@${NODE_VERSION}`). It's a + # major (e.g. "20"), so 1-3 dot-separated numeric segments are valid. + v = make.Versions.load(REPO_ROOT) + assert v.node_version == v.raw["NODE_VERSION"] + parts = v.node_version.split(".") + assert 1 <= len(parts) <= 3 + for p in parts: + assert p.isdigit(), f"Non-numeric node version segment: {p}" + + def test_node_version_missing_is_none(self): + # A versions.env without NODE_VERSION yields None (accessor is a + # plain .get, so absence is not an error). + v = make.Versions(raw={}) + assert v.node_version is None + def test_variants_enumerates_all_rows(self): v = make.Versions.load(REPO_ROOT) variants = v.variants() @@ -701,6 +719,12 @@ def test_csharp(self): == REPO_ROOT / "test" / "csharp-tableau-loader" ) + def test_ts(self): + assert ( + make._lang_dir(REPO_ROOT, "ts") + == REPO_ROOT / "test" / "ts-tableau-loader" + ) + # --------------------------------------------------------------------------- # Subprocess helpers @@ -750,6 +774,7 @@ def test_version_prints_versions_env(self): "LEGACY_V3_PROTOBUF_VERSION", "LEGACY_V3_VCPKG_BASELINE_COMMIT", "DOTNET_VERSION", + "NODE_VERSION", "CMAKE_VERSION", ): assert key in proc.stdout, f"--version missing {key}" @@ -991,6 +1016,42 @@ def test_test_lang_csharp_filter(self): assert "FullyQualifiedName~HubTest.Load" in proc.stdout +class TestDryRunTs: + def test_test_lang_ts_default(self): + proc = run_make("--dry-run", "test", "--lang", "ts") + assert proc.returncode == 0 + # On Windows npm is the `npm.cmd` batch shim; normalize so the + # assertions below are platform-agnostic. + out = proc.stdout.replace("npm.cmd", "npm") + # Codegen (Go plugins + protobuf-es remote plugin) runs first. + assert "buf generate .." in out + # Deps install via the reproducible `npm ci` (lockfile is committed). + assert "npm ci" in out + # Type-check, then smoke. + assert "npm run check" in out + assert "npm run smoke" in out + + def test_build_lang_ts_skips_smoke(self): + proc = run_make("--dry-run", "build", "--lang", "ts") + assert proc.returncode == 0 + # Normalize the Windows `npm.cmd` shim to `npm`. + out = proc.stdout.replace("npm.cmd", "npm") + # build == type-check only: buf generate + install + check, no smoke. + assert "buf generate .." in out + assert "npm run check" in out + for line in out.splitlines(): + assert "npm run smoke" not in line, f"build must not run smoke: {line}" + + def test_generate_lang_ts(self): + proc = run_make("--dry-run", "generate", "--lang", "ts") + assert proc.returncode == 0 + out = proc.stdout + # generate is codegen only — no npm steps. + assert "buf generate .." in out + for line in out.splitlines(): + assert "npm" not in line, f"generate must not invoke npm: {line}" + + class TestDryRunGenerateAndBuild: def test_generate_lang_go(self): proc = run_make("--dry-run", "generate", "--lang", "go") @@ -1037,14 +1098,24 @@ def test_clean_csharp(self): assert "obj" in out assert "protoconf" in out + def test_clean_ts(self): + proc = run_make("--dry-run", "clean", "--lang", "ts") + assert proc.returncode == 0 + out = proc.stdout + # Wipes generated dirs + installed deps. + assert "protoconf" in out + assert "tableau" in out + assert "node_modules" in out + def test_clean_all(self): proc = run_make("--dry-run", "clean", "--all") assert proc.returncode == 0 out = proc.stdout - # Should mention dirs from at least cpp, csharp, go. + # Should mention dirs from at least cpp, csharp, go, ts. assert "cpp-tableau-loader" in out assert "csharp-tableau-loader" in out assert "go-tableau-loader" in out + assert "ts-tableau-loader" in out # --------------------------------------------------------------------------- @@ -1220,3 +1291,23 @@ def test_dotnet_winget_id_uses_versions_env_major(self, monkeypatch, capsys): out = capsys.readouterr().out assert "Microsoft.DotNet.SDK.9" in out assert "Microsoft.DotNet.SDK.8" not in out + + def test_installs_node_for_ts_lang(self, monkeypatch, capsys): + """--lang ts on Windows must install Node via winget (LTS package) + when no node is already on PATH.""" + ctx = self._windows_ctx(monkeypatch) + args = type("Args", (), {"lang": "ts", "skip_vcpkg": True})() + rc = make.cmd_setup(args, ctx) + assert rc == 0 + out = capsys.readouterr().out + assert "OpenJS.NodeJS.LTS" in out, "--lang ts on Windows must install Node" + + def test_no_node_install_for_non_ts_lang(self, monkeypatch, capsys): + """Node must NOT be installed when ts isn't among the targets — the + winget Node step is gated on `ts in langs`.""" + ctx = self._windows_ctx(monkeypatch) + args = type("Args", (), {"lang": "go", "skip_vcpkg": True})() + rc = make.cmd_setup(args, ctx) + assert rc == 0 + out = capsys.readouterr().out + assert "OpenJS.NodeJS.LTS" not in out