}} parsedYaml Parsed YAML data.
+ * @returns {string} HTML string.
+ */
+function renderFormHtml(fileName, schemaInfo, parsedYaml) {
+ const scalarFields = Array.from(parsedYaml.entries.entries())
+ .filter(([, entry]) => entry.kind === "scalar")
+ .map(([key, entry]) => {
+ const propertySchema = schemaInfo.properties[key] || {};
+ const displayName = propertySchema.title || key;
+ const escapedKey = escapeHtml(key);
+ const escapedDisplayName = escapeHtml(displayName);
+ const escapedValue = escapeHtml(unquoteScalar(entry.value || ""));
+ const required = schemaInfo.required.includes(key) ? "required" : "";
+ const metadataHint = renderFieldHint(propertySchema, false);
+ const enumValues = Array.isArray(propertySchema.enumValues) ? propertySchema.enumValues : [];
+ const inputControl = enumValues.length > 0
+ ? `
+
+ `
+ : ``;
+ return `
+
+ `;
+ })
+ .join("\n");
+
+ const arrayFields = Array.from(parsedYaml.entries.entries())
+ .filter(([, entry]) => entry.kind === "array")
+ .map(([key, entry]) => {
+ const propertySchema = schemaInfo.properties[key] || {};
+ const displayName = propertySchema.title || key;
+ const escapedKey = escapeHtml(key);
+ const escapedDisplayName = escapeHtml(displayName);
+ const escapedValue = escapeHtml((entry.items || [])
+ .map((item) => unquoteScalar(item.raw))
+ .join("\n"));
+ const required = schemaInfo.required.includes(key) ? "required" : "";
+ const itemType = propertySchema.itemType
+ ? `array<${escapeHtml(propertySchema.itemType)}>`
+ : "array";
+ const metadataHint = renderFieldHint(propertySchema, true);
+
+ return `
+
+ `;
+ })
+ .join("\n");
+
+ const unsupportedFields = Array.from(parsedYaml.entries.entries())
+ .filter(([, entry]) => entry.kind !== "scalar" && entry.kind !== "array")
+ .map(([key, entry]) => `
+
+ ${escapeHtml(key)}: ${escapeHtml(entry.kind)} fields are currently raw-YAML-only.
+
+ `)
+ .join("\n");
+
+ const schemaStatus = schemaInfo.exists
+ ? `Schema: ${escapeHtml(schemaInfo.schemaPath)}`
+ : `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`;
+
+ const editableContent = [scalarFields, arrayFields].filter((content) => content.length > 0).join("\n");
+ const unsupportedSection = unsupportedFields.length > 0
+ ? `${unsupportedFields}
`
+ : "";
+ const emptyState = editableContent.length > 0
+ ? `${editableContent}${unsupportedSection}`
+ : "No editable top-level scalar or scalar-array fields were detected. Use raw YAML for nested objects or complex arrays.
";
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emptyState}
+
+
+`;
+}
+
+/**
+ * Render human-facing metadata hints for one schema field.
+ *
+ * @param {{description?: string, defaultValue?: string, enumValues?: string[], itemEnumValues?: string[], refTable?: string}} propertySchema Property schema metadata.
+ * @param {boolean} isArrayField Whether the field is an array.
+ * @returns {string} HTML fragment.
+ */
+function renderFieldHint(propertySchema, isArrayField) {
+ const hints = [];
+
+ if (propertySchema.description) {
+ hints.push(escapeHtml(propertySchema.description));
+ }
+
+ if (propertySchema.defaultValue) {
+ hints.push(`Default: ${escapeHtml(propertySchema.defaultValue)}`);
+ }
+
+ const enumValues = isArrayField ? propertySchema.itemEnumValues : propertySchema.enumValues;
+ if (Array.isArray(enumValues) && enumValues.length > 0) {
+ hints.push(`Allowed: ${escapeHtml(enumValues.join(", "))}`);
+ }
+
+ if (propertySchema.refTable) {
+ hints.push(`Ref table: ${escapeHtml(propertySchema.refTable)}`);
+ }
+
+ if (hints.length === 0) {
+ return "";
+ }
+
+ return `${hints.join(" · ")}`;
+}
+
+/**
+ * Prompt for one batch-edit field value.
+ *
+ * @param {{key: string, type: string, itemType?: string, title?: string, description?: string, defaultValue?: string, enumValues?: string[], itemEnumValues?: string[], refTable?: string, inputKind: "scalar" | "array", required: boolean}} field Editable field descriptor.
+ * @returns {Promise} User input, or undefined when cancelled.
+ */
+async function promptBatchFieldValue(field) {
+ if (field.inputKind === "array") {
+ const hintParts = [];
+ if (field.itemEnumValues && field.itemEnumValues.length > 0) {
+ hintParts.push(`Allowed items: ${field.itemEnumValues.join(", ")}`);
+ }
+
+ if (field.defaultValue) {
+ hintParts.push(`Default: ${field.defaultValue}`);
+ }
+
+ return vscode.window.showInputBox({
+ title: `Batch Edit Array: ${field.title || field.key}`,
+ prompt: `Enter comma-separated items for '${field.key}' (expected array<${field.itemType}>). Leave empty to clear the array.`,
+ placeHolder: hintParts.join(" | "),
+ ignoreFocusOut: true
+ });
+ }
+
+ if (field.enumValues && field.enumValues.length > 0) {
+ const picked = await vscode.window.showQuickPick(
+ field.enumValues.map((value) => ({
+ label: value,
+ description: value === field.defaultValue ? "default" : undefined
+ })),
+ {
+ title: `Batch Edit Field: ${field.title || field.key}`,
+ placeHolder: `Select a value for '${field.key}'.`
+ });
+ return picked ? picked.label : undefined;
+ }
+
+ return vscode.window.showInputBox({
+ title: `Batch Edit Field: ${field.title || field.key}`,
+ prompt: `Enter the new value for '${field.key}' (expected ${field.type}).`,
+ placeHolder: [
+ field.description || "",
+ field.defaultValue ? `Default: ${field.defaultValue}` : "",
+ field.refTable ? `Ref table: ${field.refTable}` : ""
+ ].filter((part) => part.length > 0).join(" | ") || undefined,
+ ignoreFocusOut: true
+ });
+}
+
+/**
+ * Enumerate all YAML files recursively.
+ *
+ * @param {string} rootPath Root path.
+ * @returns {string[]} YAML file paths.
+ */
+function enumerateYamlFiles(rootPath) {
+ const results = [];
+
+ for (const entry of fs.readdirSync(rootPath, {withFileTypes: true})) {
+ const fullPath = path.join(rootPath, entry.name);
+ if (entry.isDirectory()) {
+ results.push(...enumerateYamlFiles(fullPath));
+ continue;
+ }
+
+ if (entry.isFile() && isYamlPath(entry.name)) {
+ results.push(fullPath);
+ }
+ }
+
+ return results;
+}
+
+/**
+ * Check whether a path is a YAML file.
+ *
+ * @param {string} filePath File path.
+ * @returns {boolean} True for YAML files.
+ */
+function isYamlPath(filePath) {
+ return filePath.endsWith(".yaml") || filePath.endsWith(".yml");
+}
+
+/**
+ * Resolve the first workspace root.
+ *
+ * @returns {vscode.WorkspaceFolder | undefined} Workspace root.
+ */
+function getWorkspaceRoot() {
+ const folders = vscode.workspace.workspaceFolders;
+ return folders && folders.length > 0 ? folders[0] : undefined;
+}
+
+/**
+ * Resolve the configured config root.
+ *
+ * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
+ * @returns {vscode.Uri | undefined} Config root URI.
+ */
+function getConfigRoot(workspaceRoot) {
+ const relativePath = vscode.workspace.getConfiguration("gframeworkConfig")
+ .get("configPath", "config");
+ return vscode.Uri.joinPath(workspaceRoot.uri, relativePath);
+}
+
+/**
+ * Resolve the configured schemas root.
+ *
+ * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
+ * @returns {vscode.Uri | undefined} Schema root URI.
+ */
+function getSchemasRoot(workspaceRoot) {
+ const relativePath = vscode.workspace.getConfiguration("gframeworkConfig")
+ .get("schemasPath", "schemas");
+ return vscode.Uri.joinPath(workspaceRoot.uri, relativePath);
+}
+
+/**
+ * Resolve the matching schema URI for a config file.
+ *
+ * @param {vscode.Uri} configUri Config file URI.
+ * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
+ * @returns {vscode.Uri | undefined} Schema URI.
+ */
+function getSchemaUriForConfigFile(configUri, workspaceRoot) {
+ const configRoot = getConfigRoot(workspaceRoot);
+ const schemaRoot = getSchemasRoot(workspaceRoot);
+ if (!configRoot || !schemaRoot) {
+ return undefined;
+ }
+
+ const relativePath = path.relative(configRoot.fsPath, configUri.fsPath);
+ const segments = relativePath.split(path.sep);
+ if (segments.length === 0 || !segments[0]) {
+ return undefined;
+ }
+
+ return vscode.Uri.joinPath(schemaRoot, `${segments[0]}.schema.json`);
+}
+
+/**
+ * Check whether a URI is inside the configured config root.
+ *
+ * @param {vscode.Uri} uri File URI.
+ * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
+ * @returns {boolean} True when the file belongs to the config tree.
+ */
+function isConfigFile(uri, workspaceRoot) {
+ const configRoot = getConfigRoot(workspaceRoot);
+ if (!configRoot) {
+ return false;
+ }
+
+ const relativePath = path.relative(configRoot.fsPath, uri.fsPath);
+ return !relativePath.startsWith("..") && !path.isAbsolute(relativePath) && isYamlPath(uri.fsPath);
+}
+
+/**
+ * Escape HTML text.
+ *
+ * @param {string} value Raw string.
+ * @returns {string} Escaped string.
+ */
+function escapeHtml(value) {
+ return String(value)
+ .replace(/&/gu, "&")
+ .replace(//gu, ">")
+ .replace(/"/gu, """)
+ .replace(/'/gu, "'");
+}
+
+/**
+ * Convert raw textarea payloads into scalar-array items.
+ *
+ * @param {Record} arrays Raw array editor payload.
+ * @returns {Record} Parsed array updates.
+ */
+function parseArrayFieldPayload(arrays) {
+ const parsed = {};
+
+ for (const [key, value] of Object.entries(arrays)) {
+ parsed[key] = String(value)
+ .split(/\r?\n/u)
+ .map((item) => item.trim())
+ .filter((item) => item.length > 0);
+ }
+
+ return parsed;
+}
+
+module.exports = {
+ activate,
+ deactivate
+};
diff --git a/tools/vscode-config-extension/test/configValidation.test.js b/tools/vscode-config-extension/test/configValidation.test.js
new file mode 100644
index 0000000..072b8c6
--- /dev/null
+++ b/tools/vscode-config-extension/test/configValidation.test.js
@@ -0,0 +1,301 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const {
+ applyFormUpdates,
+ applyScalarUpdates,
+ getEditableSchemaFields,
+ parseBatchArrayValue,
+ parseSchemaContent,
+ parseTopLevelYaml,
+ validateParsedConfig
+} = require("../src/configValidation");
+
+test("parseSchemaContent should capture scalar and array property metadata", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": {
+ "type": "integer",
+ "title": "Monster Id",
+ "description": "Primary monster key.",
+ "default": 1
+ },
+ "name": {
+ "type": "string",
+ "enum": ["Slime", "Goblin"]
+ },
+ "dropRates": {
+ "type": "array",
+ "description": "Drop rate list.",
+ "items": {
+ "type": "integer",
+ "enum": [1, 2, 3]
+ }
+ }
+ }
+ }
+ `);
+
+ assert.deepEqual(schema.required, ["id", "name"]);
+ assert.deepEqual(schema.properties, {
+ id: {
+ type: "integer",
+ title: "Monster Id",
+ description: "Primary monster key.",
+ defaultValue: "1",
+ enumValues: undefined,
+ refTable: undefined
+ },
+ name: {
+ type: "string",
+ title: undefined,
+ description: undefined,
+ defaultValue: undefined,
+ enumValues: ["Slime", "Goblin"],
+ refTable: undefined
+ },
+ dropRates: {
+ type: "array",
+ itemType: "integer",
+ title: undefined,
+ description: "Drop rate list.",
+ defaultValue: undefined,
+ refTable: undefined,
+ itemEnumValues: ["1", "2", "3"]
+ }
+ });
+});
+
+test("validateParsedConfig should report missing and unknown properties", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+id: 1
+title: Slime
+`);
+
+ const diagnostics = validateParsedConfig(schema, yaml);
+
+ assert.equal(diagnostics.length, 2);
+ assert.equal(diagnostics[0].severity, "error");
+ assert.match(diagnostics[0].message, /name/u);
+ assert.equal(diagnostics[1].severity, "error");
+ assert.match(diagnostics[1].message, /title/u);
+});
+
+test("validateParsedConfig should report array item type mismatches", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "dropRates": {
+ "type": "array",
+ "items": { "type": "integer" }
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+dropRates:
+ - 1
+ - potion
+`);
+
+ const diagnostics = validateParsedConfig(schema, yaml);
+
+ assert.equal(diagnostics.length, 1);
+ assert.equal(diagnostics[0].severity, "error");
+ assert.match(diagnostics[0].message, /dropRates/u);
+});
+
+test("validateParsedConfig should report scalar enum mismatches", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "rarity": {
+ "type": "string",
+ "enum": ["common", "rare"]
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+rarity: epic
+`);
+
+ const diagnostics = validateParsedConfig(schema, yaml);
+
+ assert.equal(diagnostics.length, 1);
+ assert.match(diagnostics[0].message, /common, rare/u);
+});
+
+test("validateParsedConfig should report array item enum mismatches", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["fire", "ice"]
+ }
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+tags:
+ - fire
+ - poison
+`);
+
+ const diagnostics = validateParsedConfig(schema, yaml);
+
+ assert.equal(diagnostics.length, 1);
+ assert.match(diagnostics[0].message, /fire, ice/u);
+});
+
+test("parseTopLevelYaml should classify nested mappings as object entries", () => {
+ const yaml = parseTopLevelYaml(`
+reward:
+ gold: 10
+name: Slime
+`);
+
+ assert.equal(yaml.entries.get("reward").kind, "object");
+ assert.equal(yaml.entries.get("name").kind, "scalar");
+});
+
+test("applyScalarUpdates should update top-level scalars and append new keys", () => {
+ const updated = applyScalarUpdates(
+ [
+ "id: 1",
+ "name: Slime",
+ "dropRates:",
+ " - 1"
+ ].join("\n"),
+ {
+ name: "Goblin",
+ hp: "25"
+ });
+
+ assert.match(updated, /^name: Goblin$/mu);
+ assert.match(updated, /^hp: 25$/mu);
+ assert.match(updated, /^ - 1$/mu);
+});
+
+test("applyFormUpdates should replace top-level scalar arrays and preserve unrelated content", () => {
+ const updated = applyFormUpdates(
+ [
+ "id: 1",
+ "name: Slime",
+ "dropItems:",
+ " - potion",
+ " - slime_gel",
+ "reward:",
+ " gold: 10"
+ ].join("\n"),
+ {
+ scalars: {
+ name: "Goblin"
+ },
+ arrays: {
+ dropItems: ["bomb", "hi potion"]
+ }
+ });
+
+ assert.match(updated, /^name: Goblin$/mu);
+ assert.match(updated, /^dropItems:$/mu);
+ assert.match(updated, /^ - bomb$/mu);
+ assert.match(updated, /^ - hi potion$/mu);
+ assert.match(updated, /^reward:$/mu);
+ assert.match(updated, /^ gold: 10$/mu);
+});
+
+test("getEditableSchemaFields should expose only scalar and scalar-array properties", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "required": ["id", "dropItems"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": {
+ "type": "string",
+ "title": "Monster Name",
+ "description": "Display name."
+ },
+ "reward": { "type": "object" },
+ "dropItems": {
+ "type": "array",
+ "description": "Drop ids.",
+ "items": {
+ "type": "string",
+ "enum": ["potion", "bomb"]
+ }
+ },
+ "waypoints": {
+ "type": "array",
+ "items": { "type": "object" }
+ }
+ }
+ }
+ `);
+
+ assert.deepEqual(getEditableSchemaFields(schema), [
+ {
+ key: "dropItems",
+ type: "array",
+ itemType: "string",
+ title: undefined,
+ description: "Drop ids.",
+ defaultValue: undefined,
+ itemEnumValues: ["potion", "bomb"],
+ refTable: undefined,
+ inputKind: "array",
+ required: true
+ },
+ {
+ key: "id",
+ type: "integer",
+ title: undefined,
+ description: undefined,
+ defaultValue: undefined,
+ enumValues: undefined,
+ refTable: undefined,
+ inputKind: "scalar",
+ required: true
+ },
+ {
+ key: "name",
+ type: "string",
+ title: "Monster Name",
+ description: "Display name.",
+ defaultValue: undefined,
+ enumValues: undefined,
+ refTable: undefined,
+ inputKind: "scalar",
+ required: false
+ }
+ ]);
+});
+
+test("parseBatchArrayValue should split comma-separated items and drop empty segments", () => {
+ assert.deepEqual(parseBatchArrayValue(" potion, hi potion , ,bomb "), [
+ "potion",
+ "hi potion",
+ "bomb"
+ ]);
+ assert.deepEqual(parseBatchArrayValue(""), []);
+});