diff --git a/docs/BindingWithGrammar.md b/docs/BindingWithGrammar.md index 7e2737a0..ee449d9a 100644 --- a/docs/BindingWithGrammar.md +++ b/docs/BindingWithGrammar.md @@ -1,6 +1,6 @@ # Binding with Grammar -[vscode-xml](https://github.com/redhat-developer/vscode-xml) provides support for automatic XML schema/grammar binding through generation and/or using an existing `.xsd/.dtd` file. +[vscode-xml](https://github.com/redhat-developer/vscode-xml) provides support for automatic XML schema/grammar binding through generation and/or using an existing `.xsd/.dtd/.rng/.rnc` file. > **Tip:** You can also [generate a grammar from an XML document](GenerateGrammarFromXML.md) or [generate an XML document from a grammar](GenerateXMLFromGrammar.md). @@ -8,88 +8,226 @@ [Validation with XSD grammar](Validation.md#validation-with-xsd-grammar) and [Validation with DTD grammar](Validation.md#validation-with-dtd-grammar) provide more information on manual binding of an XML document. -In the following sections, we would like to bind the XML document `foo.xml`: -```xml - - - -``` -with an associated grammar file. +The binding wizard can be triggered by [Command](#command), [CodeLens](#codelens), or [Quick Fix](#quick-fix). + +In the following sections, we would like to bind the XML document `team.xml` with a grammar file. -## Binding with Existing Grammar +Consider an XSD document, `team.xsd` (in the same directory as `team.xml`), that declares **two root elements** (`person` and `team`): -Consider an XSD document, `foo.xsd` (in the same directory as `foo.xml`), defined as follows : -```xsd +```xml - + + + + + + + + + - + + ``` -and a DTD document, `foo.dtd` (in the same directory as `foo.xml`), defined as follows: + +a DTD document, `team.dtd` (in the same directory as `team.xml`): + ```dtd - - + + + + ``` -Either file can be bound using the [XML Binding Wizard](#the-xml-binding-wizard), which can be triggered by [Command](#command), [CodeLens](#codelens) or [Quick Fix](#quick-fix). +and a RelaxNG document, `team.rng` (in the same directory as `team.xml`): -### The XML Binding Wizard +```xml + + + + + + + + + + + + + + + + + + + + + + + +``` -After opening the binding wizard using one of the methods mentioned above, a dropdown with the following 2 options will appear at the Command Palette: +or equivalently in RelaxNG Compact syntax, `team.rnc`: + +```rnc +start = person | team + +person = element person { + element name { text }, + element age { text } +} + +team = element team { + element name { text }, + person+ +} +``` + +After opening the binding wizard, a dropdown with 3 options appears at the Command Palette: ![Binding Wizard Dropdown](./images/BindingWithGrammar/BindingWizardDropdown.png) -1. Standard (xsi, DOCTYPE) +For Standard and XML Model options, a file explorer lets you select the grammar file: + +![Binding Wizard File Explorer](./images/BindingWithGrammar/BindingWizardFileExplorer.png) + +## Standard (xsi, DOCTYPE) + +### XSD binding + +If the selected file is an XSD file, this option binds the grammar by adding the `xmlns:xsi` (with the value `http://www.w3.org/2001/XMLSchema-instance`) and `xsi:noNamespaceSchemaLocation` (with the path to the grammar file) attributes to the root tag. + +Since `team.xsd` declares **two root elements** (`person` and `team`), vscode-xml uses a **SnippetTextEdit** with a [choice placeholder](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#snippet_syntax), letting you pick the root element directly in the editor: + +![Snippet Choice XSI](./images/BindingWithGrammar/SnippetChoiceXSI.png) + +After selecting `person`: +```xml + +``` + +> **Note:** If the XSD declares a single root element, a regular TextEdit is used directly without the choice dropdown. + +### With targetNamespace + +If the XSD declares a `targetNamespace` with `elementFormDefault="qualified"`: +```xml + +``` +then the binding uses `xmlns` and `xsi:schemaLocation` instead: +```xml + +``` + +### DTD binding + +If the selected file is a DTD file, this option adds a `` declaration at the beginning of the document: + +![Snippet Choice DOCTYPE](./images/BindingWithGrammar/SnippetChoiceDOCTYPE.png) + +After selecting `team`: +```xml + + +``` + +## XML Model association + +This option binds the grammar by adding an `` processing instruction at the beginning of the document: -If the selected file is an XSD file, this option will bind the grammar file by adding the `xmlns:xsi` (with the value as "http://www.w3.org/2001/XMLSchema-instance") and `xsi:noNamespaceSchemaLocation` (with the value as the path to the grammar file) attributes to the root tag: +![Snippet Choice XML Model](./images/BindingWithGrammar/SnippetChoiceXMLModel.png) + +After selecting `person` with an XSD file: ```xml - - - + + ``` -If the XSD file contains a `targetNamespace` attribute, with an `elementFormDefault` attribute with value "qualified" in the `xs:schema` tag, as follows: -```xsd - +The same applies when selecting a DTD file: +```xml + + ``` -then the XML document will have the `targetNamespace` set as a `xmlns` attribute and an additional attribute for `xsi:schemaLocation`: + +### RelaxNG binding + +When selecting a RelaxNG file (`.rng` or `.rnc`), the `` processing instruction: + +For a `.rng` (XML syntax) file: ```xml - - - + + ``` -If the selected file is a DTD file, this option will bind the grammar file by adding a ` SYSTEM "">` tag at the beginning of the document: +For a `.rnc` (Compact syntax) file: ```xml - - - - + + ``` -2. XML Model association +> **Note:** RelaxNG grammars can only be bound via XML Model or Catalog entry, not via Standard (xsi, DOCTYPE). + +## Catalog entry + +This option binds the XML document through an [XML Catalog](https://www.oasis-open.org/committees/entity/spec-2001-08-06.html). An XML Catalog is a mapping file that redirects XML identifiers (public IDs, system IDs, URIs) to local grammar resources, allowing you to manage grammar bindings centrally rather than embedding paths in each XML file. + +When you select this option, the wizard presents two choices: + +### Pick an existing catalog entry -This option will bind the grammar file by adding the `xml-model` tag at the beginning of the document, with an `href` value as the path to the grammar file: +If your workspace has registered catalogs (via the `xml.catalogs` setting) that already contain entries, they are listed grouped by catalog file. Selecting an entry inserts the appropriate binding in your XML document (DOCTYPE for public/system entries, xsi:schemaLocation for XSD entries). + +![Catalog Entries List](./images/BindingWithGrammar/BindingWizardCatalogEntries.png) + +With multiple root elements, the choice placeholder lets you select the root element, just like with Standard and XML Model options. + +### Add a new catalog entry + +If no existing entry matches, select "Add new catalog entry..." at the bottom of the list. + +![Catalog Add New Entry](./images/BindingWithGrammar/BindingWizardCatalogAddNewEntry.png) + +The wizard will: + +1. Ask which catalog should contain the new entry (an existing registered catalog, browse for another catalog file, or create a new catalog file) + +![Catalog Selection](./images/BindingWithGrammar/BindingWizardCatalogSelect.png) + +2. Ask for the entry type (`public`, `system`, or `uri`) +3. Ask for the identifier (the public ID, system ID, or namespace URI) +4. Let you select the grammar file + +The entry is added to the catalog file, the catalog is registered in `xml.catalogs` if needed, and the XML document is bound using the catalog entry. + +For example, with a `public` catalog entry, the resulting binding is a PUBLIC DOCTYPE: +```xml + + +``` + +With a `uri` catalog entry pointing to an XSD with a target namespace, the result uses `xsi:schemaLocation`: ```xml - - - - + ``` -The same applies when selecting `foo.dtd`. -After selecting an option, you can select the desired grammar file using your file explorer: +> **Note:** The generated catalog entry respects your formatting settings (indentation, tab/spaces, space before empty close tag). -![Binding Wizard File Explorer](./images/BindingWithGrammar/BindingWizardFileExplorer.png) +## Triggers + +The binding wizard can be triggered in three ways: ### Command @@ -97,15 +235,13 @@ The command "XML: Bind to grammar/schema file" can be executed from the VSCode c ![Bind To Grammar Command](./images/BindingWithGrammar/BindToGrammarCommand.png) -If the current XML document is not bound using an existing grammar/schema, the command will have the user select a existing `.xsd/.dtd` grammar file to bind to the XML document. - -Otherwise, an error will be thrown on the client if the XML document already has a bound grammar/schema. +If the current XML document already has a bound grammar/schema, an error will be shown. ### CodeLens -When [CodeLenses](./Preferences.md#code-lens) are enabled, an unbound XML document will display a CodeLens above the root element as follows: +When [CodeLenses](./Preferences.md#code-lens) are enabled, an unbound XML document will display a CodeLens above the root element: -![Bind To Grammar Command](./images/BindingWithGrammar/BindToGrammarCodeLens.png) +![Bind To Grammar CodeLens](./images/BindingWithGrammar/BindToGrammarCodeLens.png) ### Quick Fix @@ -113,7 +249,7 @@ For any unbound XML document, a Quick Fix suggestion will appear beside the root ![Bind To Grammar CodeAction](./images/BindingWithGrammar/BindToGrammarCodeAction.png) -With the cursor on the first opening tag, use `Ctrl + .`, `Quick Fix...` or the lightbulb that appears and select the "Bind to existing grammar/schema" +With the cursor on the first opening tag, use `Ctrl + .`, `Quick Fix...` or the lightbulb that appears and select "Bind to existing grammar/schema": ![Bind To Grammar CodeAction Dropdown](./images/BindingWithGrammar/BindToGrammarCodeActionDropdown.png) diff --git a/docs/Features/DTDFeatures.md b/docs/Features/DTDFeatures.md index 0b4c8b60..f92fefc8 100644 --- a/docs/Features/DTDFeatures.md +++ b/docs/Features/DTDFeatures.md @@ -1,5 +1,9 @@ # DTD Features +## Binding + +See [Binding with Grammar](../BindingWithGrammar.md#dtd-binding) to associate an XML file with a DTD using the binding wizard. + ## CodeLens Support CodeLens is supported for `.dtd` files, which can show where an element declaration is referenced. diff --git a/docs/Features/RelaxNGFeatures.md b/docs/Features/RelaxNGFeatures.md index 71ad79cf..71aa3882 100644 --- a/docs/Features/RelaxNGFeatures.md +++ b/docs/Features/RelaxNGFeatures.md @@ -13,6 +13,7 @@ It supports both: You can associate your XML file with `RelaxNG grammar` (`file` or `URL`) using : + * the [Binding Wizard](../BindingWithGrammar.md#relaxng-binding) (XML Model or Catalog entry) * an `xml-model` processing instruction ```xml @@ -147,4 +148,4 @@ The following file, `addressBook.xml`, references `addressBook.rng` using the `x When an unbound XML file is open, a RELAX NG schema can be generated from the opening tag in the XML file. -See: [Generate RELAX NG from XML](../BindingWithGrammar.md#generate-relax-ng-from-xml). +See: [Generate Grammar from XML](../GenerateGrammarFromXML.md). diff --git a/docs/Features/XMLCatalogFeatures.md b/docs/Features/XMLCatalogFeatures.md index 12d6b638..581afaae 100644 --- a/docs/Features/XMLCatalogFeatures.md +++ b/docs/Features/XMLCatalogFeatures.md @@ -1,5 +1,9 @@ # XML Catalog Features +## Binding with Catalog + +The [Binding Wizard](../BindingWithGrammar.md#catalog-entry) supports binding an XML document to a grammar through an XML Catalog entry. You can pick an existing catalog entry or add a new one directly from the wizard. + ## Validation XML Catalog is supported with both DTD and XSD. Please see here for more information on the usage of [XML Catalog With DTD](../Validation.md#xml-catalog-with-dtd) and [XML Catalog With XSD](../Validation.md#xml-catalog-with-xsd). diff --git a/docs/Features/XSDFeatures.md b/docs/Features/XSDFeatures.md index 45117c6f..31253edd 100644 --- a/docs/Features/XSDFeatures.md +++ b/docs/Features/XSDFeatures.md @@ -1,5 +1,9 @@ # XSD Features +## Binding + +See [Binding with Grammar](../BindingWithGrammar.md#xsd-binding) to associate an XML file with an XSD using the binding wizard. + ## CodeLens Support CodeLens is supported for `.xsd` files, which can show where a type is referenced. diff --git a/docs/images/BindingWithGrammar/BindingWizardCatalogAddNewEntry.png b/docs/images/BindingWithGrammar/BindingWizardCatalogAddNewEntry.png new file mode 100644 index 00000000..fe46c90e Binary files /dev/null and b/docs/images/BindingWithGrammar/BindingWizardCatalogAddNewEntry.png differ diff --git a/docs/images/BindingWithGrammar/BindingWizardCatalogEntries.png b/docs/images/BindingWithGrammar/BindingWizardCatalogEntries.png new file mode 100644 index 00000000..20ba951f Binary files /dev/null and b/docs/images/BindingWithGrammar/BindingWizardCatalogEntries.png differ diff --git a/docs/images/BindingWithGrammar/BindingWizardCatalogSelect.png b/docs/images/BindingWithGrammar/BindingWizardCatalogSelect.png new file mode 100644 index 00000000..14095d4d Binary files /dev/null and b/docs/images/BindingWithGrammar/BindingWizardCatalogSelect.png differ diff --git a/docs/images/BindingWithGrammar/BindingWizardDemo.gif b/docs/images/BindingWithGrammar/BindingWizardDemo.gif index 3c2102bd..df8ceaf3 100644 Binary files a/docs/images/BindingWithGrammar/BindingWizardDemo.gif and b/docs/images/BindingWithGrammar/BindingWizardDemo.gif differ diff --git a/docs/images/BindingWithGrammar/BindingWizardDropdown.png b/docs/images/BindingWithGrammar/BindingWizardDropdown.png index f80c94b1..651cb84d 100644 Binary files a/docs/images/BindingWithGrammar/BindingWizardDropdown.png and b/docs/images/BindingWithGrammar/BindingWizardDropdown.png differ diff --git a/docs/images/BindingWithGrammar/BindingWizardFileExplorer.png b/docs/images/BindingWithGrammar/BindingWizardFileExplorer.png index 9dd3be42..dec1315a 100644 Binary files a/docs/images/BindingWithGrammar/BindingWizardFileExplorer.png and b/docs/images/BindingWithGrammar/BindingWizardFileExplorer.png differ diff --git a/docs/images/BindingWithGrammar/SnippetChoiceDOCTYPE.png b/docs/images/BindingWithGrammar/SnippetChoiceDOCTYPE.png new file mode 100644 index 00000000..24e1777a Binary files /dev/null and b/docs/images/BindingWithGrammar/SnippetChoiceDOCTYPE.png differ diff --git a/docs/images/BindingWithGrammar/SnippetChoiceXMLModel.png b/docs/images/BindingWithGrammar/SnippetChoiceXMLModel.png new file mode 100644 index 00000000..9b034c39 Binary files /dev/null and b/docs/images/BindingWithGrammar/SnippetChoiceXMLModel.png differ diff --git a/docs/images/BindingWithGrammar/SnippetChoiceXSI.png b/docs/images/BindingWithGrammar/SnippetChoiceXSI.png new file mode 100644 index 00000000..b6568099 Binary files /dev/null and b/docs/images/BindingWithGrammar/SnippetChoiceXSI.png differ diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index ad98ec16..aa986076 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -182,12 +182,20 @@ for (const entry of bindingTypes.entries()) { async function grammarAssociationCommand(documentURI: Uri, languageClient: LanguageClient) { // A click on Bind to grammar/schema... has been processed in the XML document which is not bound to a grammar - // step 1: select local / remote Uri type + // step 1: select local / remote / catalog Uri type let grammarURI; - const uriType = await window.showQuickPick([{ "label": "local" }, { "label": "remote" }], { placeHolder: "Binding type" }); + const uriType = await window.showQuickPick([ + { label: "local", description: "Select a grammar file from the local file system" }, + { label: "remote", description: "Enter a remote URL for the grammar" }, + { label: "Catalog entry", description: "Bind using an XML catalog entry" } + ], { placeHolder: "How do you want to bind the grammar?" }); if (!uriType) { return; } + if (uriType.label === 'Catalog entry') { + await catalogBindingFlow(documentURI, languageClient); + return; + } if (uriType.label === 'remote') { let predefinedUrl = await env.clipboard.readText(); if (!predefinedUrl || !predefinedUrl.startsWith('http')) { @@ -289,15 +297,30 @@ async function bindWithFileAssociation(documentURI: Uri, grammarURI: Uri, docume async function bindWithStandard(documentURI: Uri, grammarURI: Uri, bindingType: string, languageClient: LanguageClient) { const identifier = TextDocumentIdentifier.create(documentURI.toString()); const result = await commands.executeCommand(ServerCommandConstants.ASSOCIATE_GRAMMAR_INSERT, identifier, grammarURI.toString(), bindingType); - // Insert the proper syntax for binding const lspTextDocumentEdit = result; - const workEdits = new WorkspaceEdit(); + await applyTextDocumentEdit(documentURI, lspTextDocumentEdit, languageClient); +} + +/** + * Apply a TextDocumentEdit from the server, handling both plain TextEdit + * (with newText) and SnippetTextEdit (with snippet) for cursor placeholders. + */ +async function applyTextDocumentEdit(documentURI: Uri, lspTextDocumentEdit: TextDocumentEdit, languageClient: LanguageClient) { + const edits: (vscode.TextEdit | vscode.SnippetTextEdit)[] = []; for (const edit of lspTextDocumentEdit.edits) { - if ('newText' in edit) { - workEdits.replace(documentURI, languageClient.protocol2CodeConverter.asRange(edit.range), edit.newText); + const r = (edit as any).range; + const range = new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character); + if ('snippet' in edit) { + edits.push(new vscode.SnippetTextEdit(range, new SnippetString((edit as any).snippet.value))); + } else if ('newText' in edit) { + edits.push(new vscode.TextEdit(range, (edit as TextEdit).newText)); } } - workspace.applyEdit(workEdits); // apply the edits + if (edits.length > 0) { + const workEdits = new WorkspaceEdit(); + workEdits.set(documentURI, edits); + await workspace.applyEdit(workEdits); + } } /** @@ -306,13 +329,322 @@ async function bindWithStandard(documentURI: Uri, grammarURI: Uri, bindingType: * @param key the filename/path of the xml document * @param value the object to add to the config */ -function addToValueToSettingArray(key: string, value: T): void { +async function addToValueToSettingArray(key: string, value: T): Promise { const settingArray: T[] = workspace.getConfiguration().get(key, []); if (settingArray.includes(value)) { return; } settingArray.push(value); - workspace.getConfiguration().update(key, settingArray, ConfigurationTarget.Workspace); + await workspace.getConfiguration().update(key, settingArray, ConfigurationTarget.Workspace); +} + +/** + * Bindable catalog entry returned by the server + */ +interface BindableCatalogEntry { + catalogURI: string; + entryType: string; + identifier: string; + targetURI: string; + uriValue: string; + grammarType: string; +} + +/** + * Flow for binding an XML document to a grammar via an XML catalog entry + */ +async function catalogBindingFlow(documentURI: Uri, languageClient: LanguageClient) { + let entries: BindableCatalogEntry[]; + try { + entries = await commands.executeCommand( + ClientCommandConstants.EXECUTE_WORKSPACE_COMMAND, + ServerCommandConstants.LIST_BINDABLE_CATALOG_ENTRIES + ); + } catch (error) { + window.showErrorMessage('Error listing catalog entries: ' + error.message); + return; + } + + if (!entries || entries.length === 0) { + const choice = await window.showQuickPick([ + { label: "Create new catalog...", description: "Create a new XML catalog file and add an entry" }, + { label: "Select existing catalog...", description: "Select an existing catalog file and add an entry" } + ], { placeHolder: "No catalog entries found" }); + if (!choice) return; + + let catalogURI: string; + if (choice.label === "Create new catalog...") { + catalogURI = await createCatalogFlow(); + } else { + catalogURI = await selectExistingCatalogFlow(); + } + if (!catalogURI) return; + + await addCatalogEntryFlow(catalogURI, documentURI, languageClient); + return; + } + + const items: (QuickPickItem & { entry?: BindableCatalogEntry })[] = []; + + const entriesByCatalog = new Map(); + for (const entry of entries) { + const existing = entriesByCatalog.get(entry.catalogURI) || []; + existing.push(entry); + entriesByCatalog.set(entry.catalogURI, existing); + } + + for (const [catURI, catalogEntries] of entriesByCatalog) { + items.push({ + label: getFileName(catURI), + kind: vscode.QuickPickItemKind.Separator + } as any); + + for (const entry of catalogEntries) { + items.push({ + label: `${entry.entryType.toUpperCase()} ${entry.identifier}`, + description: getFileName(entry.targetURI), + detail: entry.grammarType.toUpperCase(), + entry: entry + }); + } + } + + items.push({ + label: '', + kind: vscode.QuickPickItemKind.Separator + } as any); + items.push({ + label: "$(add) Add new catalog entry...", + description: "Create a new entry in a catalog file" + }); + + const picked = await window.showQuickPick(items, { + placeHolder: "Select a catalog entry to bind", + matchOnDescription: true, + matchOnDetail: true + }); + + if (!picked) return; + + if (!picked.entry) { + const catalogOptions: QuickPickItem[] = []; + for (const cat of entriesByCatalog.keys()) { + catalogOptions.push({ label: getFileName(cat), description: cat }); + } + catalogOptions.push({ label: "$(folder-opened) Select existing catalog...", description: "Browse for a catalog file" }); + catalogOptions.push({ label: "$(add) Create new catalog...", description: "Create a new catalog file" }); + + const catalogPick = await window.showQuickPick(catalogOptions, { + placeHolder: "Which catalog should contain the new entry?" + }); + if (!catalogPick) return; + + let catalogURI: string; + if (catalogPick.label.includes("Create new catalog")) { + catalogURI = await createCatalogFlow(); + } else if (catalogPick.label.includes("Select existing catalog")) { + catalogURI = await selectExistingCatalogFlow(); + } else { + catalogURI = catalogPick.description; + } + if (!catalogURI) return; + + await addCatalogEntryFlow(catalogURI, documentURI, languageClient); + return; + } + + await bindWithCatalogEntry(documentURI, picked.entry, languageClient); +} + +async function bindWithCatalogEntry(documentURI: Uri, entry: BindableCatalogEntry, languageClient: LanguageClient) { + const identifier = TextDocumentIdentifier.create(documentURI.toString()); + try { + const result = await commands.executeCommand( + ClientCommandConstants.EXECUTE_WORKSPACE_COMMAND, + ServerCommandConstants.ASSOCIATE_GRAMMAR_INSERT, + identifier, + entry.entryType, + "catalog", + entry.identifier, + entry.targetURI, + entry.uriValue + ); + const lspTextDocumentEdit = result; + await applyTextDocumentEdit(documentURI, lspTextDocumentEdit, languageClient); + } catch (error) { + window.showErrorMessage('Error binding grammar via catalog: ' + error.message); + } +} + +async function createCatalogFlow(): Promise { + const saveOptions: vscode.SaveDialogOptions = { + saveLabel: 'Create Catalog', + filters: { 'XML Catalog': ['xml'] }, + defaultUri: workspace.workspaceFolders?.[0]?.uri + }; + const fileUri = await window.showSaveDialog(saveOptions); + if (!fileUri) return undefined; + + let catalogContent: string; + try { + catalogContent = await commands.executeCommand( + ClientCommandConstants.EXECUTE_WORKSPACE_COMMAND, + ServerCommandConstants.CREATE_CATALOG, + fileUri.toString() + ); + } catch (error) { + catalogContent = '\n\n\n'; + } + + const encoder = new TextEncoder(); + await workspace.fs.writeFile(fileUri, encoder.encode(catalogContent)); + + await addToValueToSettingArray('xml.catalogs', fileUri.fsPath); + + const doc = await workspace.openTextDocument(fileUri); + await window.showTextDocument(doc, { preview: false }); + + return fileUri.toString(); +} + +async function selectExistingCatalogFlow(): Promise { + const options: OpenDialogOptions = { + canSelectMany: false, + openLabel: 'Select XML Catalog', + filters: { 'XML files': ['xml'] } + }; + const fileUri = await window.showOpenDialog(options); + if (!fileUri || !fileUri[0]) return undefined; + + await addToValueToSettingArray('xml.catalogs', fileUri[0].fsPath); + + return fileUri[0].toString(); +} + +async function addCatalogEntryFlow(catalogURI: string, documentURI: Uri, languageClient: LanguageClient) { + let grammarURI: string; + const uriType = await window.showQuickPick( + [{ label: "local" }, { label: "remote" }], + { placeHolder: "Select grammar source" } + ); + if (!uriType) return; + + if (uriType.label === 'remote') { + let predefinedUrl = await env.clipboard.readText(); + if (!predefinedUrl || !predefinedUrl.startsWith('http')) { + predefinedUrl = ''; + } + grammarURI = await window.showInputBox({ + title: 'Enter grammar URL', + value: predefinedUrl + }); + } 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; + + const grammarPath = grammarURI.toLowerCase(); + const defaultEntryType = grammarPath.endsWith('.dtd') ? 'public' : 'uri'; + + const entryTypeOptions: QuickPickItem[] = [ + { label: 'public', description: 'Map a PUBLIC identifier to the grammar (recommended for DTDs)' }, + { label: 'system', description: 'Map a SYSTEM identifier to the grammar' }, + { label: 'uri', description: 'Map a URI/namespace to the grammar (recommended for XSD/RNG)' } + ]; + const reorderedOptions = entryTypeOptions.sort((a, b) => { + if (a.label === defaultEntryType) return -1; + if (b.label === defaultEntryType) return 1; + return 0; + }); + + const pickedEntryType = await window.showQuickPick(reorderedOptions, { + placeHolder: 'Select catalog entry type' + }); + if (!pickedEntryType) return; + const entryType = pickedEntryType.label; + + let identifierPrompt: string; + let identifierPlaceholder: string; + switch (entryType) { + case 'public': + identifierPrompt = 'Enter the PUBLIC identifier'; + identifierPlaceholder = '-//Example//DTD My Document//EN'; + break; + case 'system': + identifierPrompt = 'Enter the SYSTEM identifier'; + identifierPlaceholder = 'http://example.com/schema.dtd'; + break; + default: + identifierPrompt = 'Enter the URI/namespace'; + identifierPlaceholder = 'http://example.com/schema'; + break; + } + const identifier = await window.showInputBox({ + title: identifierPrompt, + placeHolder: identifierPlaceholder, + prompt: identifierPrompt, + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return 'Identifier cannot be empty'; + } + return null; + } + }); + if (!identifier) return; + + try { + const result = await commands.executeCommand( + ClientCommandConstants.EXECUTE_WORKSPACE_COMMAND, + ServerCommandConstants.ADD_CATALOG_ENTRY, + catalogURI, + entryType, + identifier, + grammarURI + ); + if (result) { + const lspTextDocumentEdit = result; + const catalogFileUri = Uri.parse(lspTextDocumentEdit.textDocument.uri); + const workEdits = new WorkspaceEdit(); + for (const edit of lspTextDocumentEdit.edits) { + if ('newText' in edit) { + workEdits.replace(catalogFileUri, languageClient.protocol2CodeConverter.asRange(edit.range), edit.newText); + } + } + const applied = await workspace.applyEdit(workEdits); + if (!applied) { + window.showWarningMessage('Failed to apply catalog entry edit.'); + return; + } + const catalogDoc = workspace.textDocuments.find(d => d.uri.toString() === catalogFileUri.toString()); + if (catalogDoc) { + await catalogDoc.save(); + if (catalogDoc.isDirty) { + window.showWarningMessage('Failed to save the catalog file. The catalog entry was not persisted.'); + return; + } + } + } + } catch (error) { + window.showErrorMessage('Error adding catalog entry: ' + error.message); + return; + } + + let grammarType = 'dtd'; + if (grammarURI.toLowerCase().endsWith('.xsd')) grammarType = 'xsd'; + else if (grammarURI.toLowerCase().endsWith('.rng')) grammarType = 'rng'; + else if (grammarURI.toLowerCase().endsWith('.rnc')) grammarType = 'rnc'; + + const entry: BindableCatalogEntry = { + catalogURI, entryType, identifier, targetURI: grammarURI, uriValue: grammarURI, grammarType + }; + await bindWithCatalogEntry(documentURI, entry, languageClient); } /** diff --git a/src/commands/serverCommandConstants.ts b/src/commands/serverCommandConstants.ts index f392d9f8..ffb6c3dd 100644 --- a/src/commands/serverCommandConstants.ts +++ b/src/commands/serverCommandConstants.ts @@ -59,4 +59,19 @@ export const GENERATE_XML = "xml.grammar.generate"; /** * Command to generate a grammar (XSD, DTD, RelaxNG) from an XML document */ -export const GENERATE_GRAMMAR_FROM_XML = "xml.grammar.generateFromXml"; \ No newline at end of file +export const GENERATE_GRAMMAR_FROM_XML = "xml.grammar.generateFromXml"; + +/** + * Command to list all bindable catalog entries + */ +export const LIST_BINDABLE_CATALOG_ENTRIES = "xml.catalog.entries.bindable"; + +/** + * Command to add an entry to a catalog file + */ +export const ADD_CATALOG_ENTRY = "xml.catalog.entry.add"; + +/** + * Command to create a new catalog file + */ +export const CREATE_CATALOG = "xml.catalog.create"; \ No newline at end of file