From f9a804e1524fc53fe017a1f41df9c3bf722dde92 Mon Sep 17 00:00:00 2001 From: azerr Date: Mon, 14 Sep 2026 12:22:45 +0200 Subject: [PATCH] Add Generate XML from Grammar support Co-Authored-By: Claude Opus 4.6 --- README.md | 2 + docs/GenerateXMLFromGrammar.md | 297 +++++++++++++++++++++++++ docs/README.md | 2 + package.json | 86 ++++++- src/commands/clientCommandConstants.ts | 7 +- src/commands/registerCommands.ts | 184 +++++++++++++++ src/commands/serverCommandConstants.ts | 12 +- src/extension.ts | 3 + 8 files changed, 587 insertions(+), 6 deletions(-) create mode 100644 docs/GenerateXMLFromGrammar.md diff --git a/README.md b/README.md index e703e3a6..882e076d 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ This VS Code extension provides support for creating and editing XML documents, * Code actions * Schema Caching * [Minify XML](https://github.com/redhat-developer/vscode-xml/blob/main/docs/Commands.md#minify-xml-document) + * [Generate XML from Grammar](https://github.com/redhat-developer/vscode-xml/blob/main/docs/GenerateXMLFromGrammar.md#generate-xml-from-grammar) + * [Generate Schema from XML](https://github.com/redhat-developer/vscode-xml/blob/main/docs/BindingWithGrammar.md#binding-with-new-grammar) See the [changelog](CHANGELOG.md) for the latest release. diff --git a/docs/GenerateXMLFromGrammar.md b/docs/GenerateXMLFromGrammar.md new file mode 100644 index 00000000..1c09c51c --- /dev/null +++ b/docs/GenerateXMLFromGrammar.md @@ -0,0 +1,297 @@ +# Generate XML from Grammar + +[vscode-xml](https://github.com/redhat-developer/vscode-xml) can generate a complete, well-formed XML document from a grammar file (XSD, DTD, RelaxNG, or RNC). The generated XML includes proper grammar binding, all declared elements and attributes, and type-aware default values. + +## How to use + +### From the Command Palette + +Open the command palette with `Ctrl+Shift+P` and run: + +**XML: Generate XML from Grammar** + +This opens a wizard that lets you: + +1. **Select a grammar file** (`.xsd`, `.dtd`, `.rng`, or `.rnc`) +2. **Choose a root element** from the grammar's declared elements +3. A new XML document is generated and opened in the editor + +### From the Context Menu + +Right-click on a grammar file in the **Explorer** or in the **Editor** and select **Generate XML from Grammar**. This skips the grammar selection step and goes directly to root element selection. + +## Supported Grammar Types + +### XSD (XML Schema) + +Generates XML with `xsi:schemaLocation` or `xsi:noNamespaceSchemaLocation` binding: + +```xml + + + 2026-01-01 + 0 + + + + +``` + +### DTD + +Generates XML with `` binding: + +```xml + + + + + + + +``` + +### RelaxNG (.rng) + +Generates XML with `` processing instruction: + +```xml + + + + + + + + +``` + +### RelaxNG Compact (.rnc) + +Same as RelaxNG, using the compact syntax file: + +```xml + + + + + + + + +``` + +## Type-Aware Default Values + +For XSD grammars, the generator produces valid default values based on the declared type, so the generated XML passes validation without manual edits: + +| XSD Type | Generated Value | +|----------|----------------| +| `xs:string` | *(empty)* | +| `xs:boolean` | `true` | +| `xs:date` | `2026-01-01` | +| `xs:dateTime` | `2026-01-01T00:00:00` | +| `xs:time` | `00:00:00` | +| `xs:integer`, `xs:int`, `xs:long`, `xs:short` | `0` | +| `xs:decimal`, `xs:float`, `xs:double` | `0` | +| `xs:positiveInteger` | `1` | +| `xs:negativeInteger` | `-1` | +| `xs:duration` | `P1D` | +| `xs:gYear` | `2026` | +| Enumeration types | First enumeration value | + +## Settings + +Generation behavior can be configured through VS Code settings: + +| Setting | Default | Description | +|---------|---------|-------------| +| `xml.generation.maxDepth` | `10` | Maximum depth for nested element generation | +| `xml.generation.optionalElements` | `true` | Generate optional elements (minOccurs=0) | +| `xml.generation.typeDefaults` | `true` | Generate type-aware default values (e.g. `0` for `xs:integer`, `2026-01-01` for `xs:date`) | + +The following examples use `maven-4.0.0.xsd` (the Maven POM schema) to illustrate each setting. + +### `xml.generation.maxDepth` + +Controls the maximum depth for nested element generation. The Maven POM schema is deeply nested (`project` → `build` → `plugins` → `plugin` → `executions` → ...). + +With `maxDepth: 10` (default), all levels are generated, producing a very large file: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +With `maxDepth: 2`, only the first two levels are generated — a much more manageable starting point: + +```xml + + + + + + + + + + + + + +``` + +### `xml.generation.optionalElements` + +Controls whether optional elements (`minOccurs="0"`) are generated. In the Maven POM schema, most elements are optional — only `modelVersion` is required. + +With `optionalElements: true` (default) — all elements are generated (parent, groupId, dependencies, build, etc.): + +```xml + + + + + + + + + + + + + + + + + + + + +``` + +With `optionalElements: false` — only the required `modelVersion` is generated: + +```xml + + + +``` + +### `xml.generation.typeDefaults` + +Controls whether type-aware default values are generated (e.g., `true` for `xs:boolean`, `0` for `xs:integer`, `2026-01-01` for `xs:date`). + +With `typeDefaults: true` (default) — typed elements and attributes get valid defaults: + +```xml + + + 0 + 2026-01-01 + +``` + +With `typeDefaults: false` — all values are left empty: + +```xml + + + + + +``` + +> **Note:** The Maven POM schema uses mostly `xs:string` types, so `typeDefaults` has minimal effect on it. This setting is most useful for schemas with numeric, date, or boolean types. + +### Example in `settings.json` + +```json +{ + "xml.generation.maxDepth": 5, + "xml.generation.optionalElements": false, + "xml.generation.typeDefaults": false +} +``` + +## Profiles + +Generation profiles let you customize settings per grammar. Each profile has a `pattern` (glob matched against the grammar URI) and overrides the global settings. The first matching profile wins. + +| Property | Type | Description | +|----------|------|-------------| +| `pattern` | string | Glob pattern matched against the grammar URI (required) | +| `maxDepth` | integer | Override maximum depth | +| `optionalElements` | boolean | Override optional elements generation | +| `typeDefaults` | boolean | Override type-aware default values generation | + +Example: generating from `spring-beans-3.0.xsd` with a profile that limits depth and skips optional elements: + +```json +{ + "xml.generation.profiles": [ + { + "pattern": "**/*spring-beans*.xsd", + "maxDepth": 3, + "optionalElements": false, + "typeDefaults": false + }, + { + "pattern": "**/*maven*.xsd", + "maxDepth": 2 + } + ] +} +``` + +When generating from a grammar whose URI matches `**/*spring-beans*.xsd`, the profile settings override the global defaults. Other grammars use the global settings unless they match another profile. + +## Features + +- Generates **all elements** (required and optional) with proper nesting +- Generates **required attributes** with type-appropriate default values +- Handles **namespace prefixes** for elements from imported schemas +- Generates **enumeration defaults** (first value) for both elements and attributes +- Handles **xs:choice** content models: single choice generates the first alternative, repeatable choice (`maxOccurs > 1`) generates all alternatives +- Supports **abstract types** with `xsi:type` attribute +- Respects **formatting settings** (tab size, spaces vs tabs, split attributes, etc.) +- **Path relativization**: grammar paths are converted to relative paths when saving the generated document diff --git a/docs/README.md b/docs/README.md index 14b7f52c..07ea0aad 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,8 @@ Welcome to the [vscode-xml](https://github.com/redhat-developer/vscode-xml) docu * [Features](Features.md#features): Notable info and demos on features available to use. * [Proxy](Proxy.md#proxy): Instructions for setting up vscode-xml to work behind a proxy. * [Binding With Grammar](BindingWithGrammar.md#binding-with-grammar): Extension feature to bind an XML document to a grammar/schema file. + * [Generate XML from Grammar](GenerateXMLFromGrammar.md#generate-xml-from-grammar): Generate a complete XML document from a grammar file (XSD, DTD, RelaxNG, RNC). + * [Generate Schema from XML](BindingWithGrammar.md#binding-with-new-grammar): Generate an XSD, DTD, or RelaxNG schema from an existing XML document. ## Developer Guide diff --git a/package.json b/package.json index 2117707b..d3cc2b99 100644 --- a/package.json +++ b/package.json @@ -269,6 +269,58 @@ "markdownDescription": "Minimize the closing tag after folding. Default is `false`.", "scope": "window" }, + "xml.generation.maxDepth": { + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 100, + "markdownDescription": "Maximum depth for nested element generation when using **Generate XML from Grammar**. Default is `10`. See [here](command:xml.open.docs?%5B%7B%22page%22%3A%22GenerateXMLFromGrammar%22%2C%22section%22%3A%22settings%22%7D%5D) for more information.", + "scope": "window" + }, + "xml.generation.optionalElements": { + "type": "boolean", + "default": false, + "markdownDescription": "Generate optional elements (minOccurs=0) when using **Generate XML from Grammar**. Default is `false`. See [here](command:xml.open.docs?%5B%7B%22page%22%3A%22GenerateXMLFromGrammar%22%2C%22section%22%3A%22settings%22%7D%5D) for more information.", + "scope": "window" + }, + "xml.generation.typeDefaults": { + "type": "boolean", + "default": true, + "markdownDescription": "Generate type-aware default values (e.g. `0` for `xs:integer`, `2026-01-01` for `xs:date`) when using **Generate XML from Grammar**. Default is `true`. See [here](command:xml.open.docs?%5B%7B%22page%22%3A%22GenerateXMLFromGrammar%22%2C%22section%22%3A%22settings%22%7D%5D) for more information.", + "scope": "window" + }, + "xml.generation.profiles": { + "type": "array", + "default": [], + "items": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern matched against the grammar URI (e.g. \"**/*spring-beans*.xsd\")." + }, + "maxDepth": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Maximum depth for nested element generation." + }, + "optionalElements": { + "type": "boolean", + "description": "Whether to generate optional elements." + }, + "typeDefaults": { + "type": "boolean", + "description": "Whether to generate type-aware default values." + } + }, + "required": [ + "pattern" + ] + }, + "markdownDescription": "Per-grammar generation profiles. Each profile has a `pattern` (glob matched against the grammar URI) and overrides global generation settings. The first matching profile wins. See [here](command:xml.open.docs?%5B%7B%22page%22%3A%22GenerateXMLFromGrammar%22%2C%22section%22%3A%22profiles%22%7D%5D) for more information.\n\nExample:\n```json\n[{\n \"pattern\": \"**/*spring-beans*.xsd\",\n \"maxDepth\": 5,\n \"optionalElements\": false\n}]\n```", + "scope": "window" + }, "xml.format.enabled": { "type": "boolean", "default": true, @@ -857,6 +909,11 @@ "command": "xml.minify", "title": "Minify XML Document", "category": "XML" + }, + { + "command": "xml.generate.fromGrammar", + "title": "Generate XML from Grammar", + "category": "XML" } ], "menus": { @@ -896,28 +953,49 @@ { "command": "xml.minify", "when": "editorLangId in xml.supportedLanguageIds && XMLLSReady" + }, + { + "command": "xml.generate.fromGrammar", + "when": "XMLLSReady" } ], "editor/context": [ { "command": "xml.refactor.surround.with.tags", "when": "editorLangId in xml.supportedLanguageIds && XMLLSReady", - "group": "1_modification" + "group": "0_xml@1" }, { "command": "xml.refactor.surround.with.comments", "when": "editorLangId in xml.supportedLanguageIds && XMLLSReady", - "group": "1_modification" + "group": "0_xml@2" }, { "command": "xml.refactor.surround.with.cdata", "when": "editorLangId in xml.supportedLanguageIds && XMLLSReady", - "group": "1_modification" + "group": "0_xml@3" }, { "command": "xml.refactor.surround.with.ignoreFormatting", "when": "editorLangId in xml.supportedLanguageIds && XMLLSReady", - "group": "1_modification" + "group": "0_xml@4" + }, + { + "command": "xml.generate.fromGrammar", + "when": "resourceExtname in xml.grammarFileExtensions && XMLLSReady", + "group": "0_xml@5" + }, + { + "command": "xml.minify", + "when": "editorLangId in xml.supportedLanguageIds && XMLLSReady", + "group": "0_xml@6" + } + ], + "explorer/context": [ + { + "command": "xml.generate.fromGrammar", + "when": "resourceExtname in xml.grammarFileExtensions && XMLLSReady", + "group": "0_xml" } ] }, diff --git a/src/commands/clientCommandConstants.ts b/src/commands/clientCommandConstants.ts index 936a2b0f..427e1454 100644 --- a/src/commands/clientCommandConstants.ts +++ b/src/commands/clientCommandConstants.ts @@ -81,4 +81,9 @@ export const EXECUTE_WORKSPACE_COMMAND = 'xml.workspace.executeCommand'; /** * Command to minify XML document. */ - export const MINIFY_DOCUMENT = 'xml.minify'; \ No newline at end of file + export const MINIFY_DOCUMENT = 'xml.minify'; + +/** + * VSCode client command to generate XML from a grammar file. + */ +export const GENERATE_XML_FROM_GRAMMAR = 'xml.generate.fromGrammar'; \ No newline at end of file diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index dfba3db2..4c1ccb36 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { commands, ConfigurationTarget, env, ExtensionContext, OpenDialogOptions, Position, QuickPickItem, SnippetString, TextDocument, Uri, window, workspace, WorkspaceEdit, Selection } from "vscode"; @@ -34,6 +35,7 @@ export async function registerClientServerCommands(context: ExtensionContext, la registerRefactorCommands(context, languageClient); registerMinifyCommand(context, languageClient); registerAssociationCommands(context, languageClient); + registerGenerateXMLCommands(context, languageClient); registerRestartLanguageServerCommand(context, languageClient); registerConfigurationUpdateCommand(); @@ -518,3 +520,185 @@ function registerMinifyCommand(context: ExtensionContext, languageClient: Langua } })); } + +interface RootElementInfo { + name: string; + namespace: string; +} + +/** + * Register commands for generating XML from grammar files + * + * @param context the extension context + * @param languageClient the language server client + */ +function registerGenerateXMLCommands(context: ExtensionContext, languageClient: LanguageClient) { + context.subscriptions.push(commands.registerCommand(ClientCommandConstants.GENERATE_XML_FROM_GRAMMAR, async (grammarFileUri?: Uri) => { + await generateXMLFromGrammarCommand(grammarFileUri, context); + })); +} + +/** + * Multi-step wizard to generate an XML document from a grammar (XSD, DTD, RelaxNG, RNC). + * + * When invoked from explorer context menu, the grammar URI is passed directly (skips step 1). + * When invoked from command palette, step 1 asks user to select a grammar file. + * + * @param grammarFileUri optional grammar file URI (from context menu) + */ +async function generateXMLFromGrammarCommand(grammarFileUri: Uri | undefined, context: ExtensionContext) { + let grammarURI: string; + + if (grammarFileUri) { + // Invoked from context menu on a grammar file — skip step 1 + grammarURI = grammarFileUri.toString(); + } else { + // Invoked from command palette — step 1: select grammar file + const grammarType = await window.showQuickPick( + [{ label: "local" }, { label: "remote" }], + { placeHolder: "Select grammar source" } + ); + if (!grammarType) return; + + if (grammarType.label === 'remote') { + let predefinedUrl = await env.clipboard.readText(); + if (!predefinedUrl || !predefinedUrl.startsWith('http')) { + predefinedUrl = ''; + } + const inputUrl = await window.showInputBox({ + title: 'Enter grammar URL (XSD, DTD, RNG, RNC)', + value: predefinedUrl + }); + if (!inputUrl) return; + grammarURI = inputUrl; + } else { + const options: OpenDialogOptions = { + canSelectMany: false, + openLabel: 'Select grammar file', + filters: { + 'Grammar files': ['xsd', 'dtd', 'rng', 'rnc'] + } + }; + const fileUri = await window.showOpenDialog(options); + if (!fileUri || !fileUri[0]) return; + grammarURI = fileUri[0].toString(); + } + } + + if (!grammarURI) return; + + // Step 2: List root elements from the grammar + let rootElements: RootElementInfo[]; + try { + rootElements = await commands.executeCommand( + ClientCommandConstants.EXECUTE_WORKSPACE_COMMAND, + ServerCommandConstants.LIST_ROOT_ELEMENTS, + grammarURI + ); + } catch (error) { + window.showErrorMessage('Error listing root elements: ' + error.message); + return; + } + + if (!rootElements || rootElements.length === 0) { + window.showWarningMessage('No root elements found in the selected grammar.'); + return; + } + + // If only one root element, skip the selection step + let selectedRootElement: string; + if (rootElements.length === 1) { + selectedRootElement = rootElements[0].name; + } else { + const items: QuickPickItem[] = rootElements.map(e => ({ + label: e.name, + description: e.namespace || '' + })); + const picked = await window.showQuickPick(items, { placeHolder: 'Select root element' }); + if (!picked) return; + selectedRootElement = picked.label; + } + + // Step 3: Generate XML content (server resolves settings from configuration) + let xmlContent: string; + try { + xmlContent = await commands.executeCommand( + ClientCommandConstants.EXECUTE_WORKSPACE_COMMAND, + ServerCommandConstants.GENERATE_XML, + grammarURI, + selectedRootElement + ); + } catch (error) { + window.showErrorMessage('Error generating XML: ' + error.message); + return; + } + + if (!xmlContent) { + window.showWarningMessage('Failed to generate XML content.'); + return; + } + + // Open the generated XML in a new untitled document named after the root element. + // Build a full path so the Save dialog suggests the right directory. + let baseDir: string; + if (grammarURI.startsWith('file:')) { + baseDir = path.dirname(Uri.parse(grammarURI).fsPath); + } else if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { + baseDir = workspace.workspaceFolders[0].uri.fsPath; + } else { + baseDir = ''; + } + let suggestedName = selectedRootElement + '.xml'; + let suggestedPath = baseDir ? path.join(baseDir, suggestedName) : suggestedName; + let untitledUri = Uri.file(suggestedPath).with({ scheme: 'untitled' }); + let counter = 0; + while (workspace.textDocuments.some(d => d.uri.toString() === untitledUri.toString()) || + (baseDir && fs.existsSync(path.join(baseDir, suggestedName)))) { + counter++; + suggestedName = selectedRootElement + '-' + counter + '.xml'; + suggestedPath = baseDir ? path.join(baseDir, suggestedName) : suggestedName; + untitledUri = Uri.file(suggestedPath).with({ scheme: 'untitled' }); + } + const doc = await workspace.openTextDocument(untitledUri); + const editor = await window.showTextDocument(doc); + await editor.edit(editBuilder => { + editBuilder.insert(new Position(0, 0), xmlContent); + }); + + // After saving an untitled document, replace absolute grammar file URI with relative path + if (grammarURI.startsWith('file:') && doc.isUntitled) { + const untitledUri = doc.uri.toString(); + const grammarFsPath = Uri.parse(grammarURI).fsPath; + const saveDisposable = workspace.onDidSaveTextDocument(async (savedDoc) => { + const content = savedDoc.getText(); + if (!content.includes(grammarURI)) { + return; + } + saveDisposable.dispose(); + closeDisposable.dispose(); + const savedDir = path.dirname(savedDoc.uri.fsPath); + const relativePath = path.relative(savedDir, grammarFsPath).replace(/\\/g, '/'); + const edit = new WorkspaceEdit(); + let startIndex = 0; + while (true) { + const idx = content.indexOf(grammarURI, startIndex); + if (idx === -1) break; + const startPos = savedDoc.positionAt(idx); + const endPos = savedDoc.positionAt(idx + grammarURI.length); + edit.replace(savedDoc.uri, new vscode.Range(startPos, endPos), relativePath); + startIndex = idx + grammarURI.length; + } + await workspace.applyEdit(edit); + await savedDoc.save(); + }); + // Clean up if the untitled document is closed without saving + const closeDisposable = workspace.onDidCloseTextDocument((closedDoc) => { + if (closedDoc.uri.toString() === untitledUri) { + saveDisposable.dispose(); + closeDisposable.dispose(); + } + }); + context.subscriptions.push(saveDisposable, closeDisposable); + } +} + diff --git a/src/commands/serverCommandConstants.ts b/src/commands/serverCommandConstants.ts index 669788e0..45390880 100644 --- a/src/commands/serverCommandConstants.ts +++ b/src/commands/serverCommandConstants.ts @@ -44,4 +44,14 @@ export const CHECK_FILE_PATTERN = "xml.check.file.pattern"; /** * Command to minify XML document */ - export const MINIFY_DOCUMENT = "xml.minify.document"; \ No newline at end of file + export const MINIFY_DOCUMENT = "xml.minify.document"; + +/** + * Command to list root elements from a grammar + */ +export const LIST_ROOT_ELEMENTS = "xml.grammar.listRootElements"; + +/** + * Command to generate XML from a grammar and root element + */ +export const GENERATE_XML = "xml.grammar.generate"; \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 87c80cf4..4bbcdaa9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -43,6 +43,9 @@ export async function activate(context: ExtensionContext): Promise