diff --git a/docs/design/BindGrammarViaCatalog.md b/docs/design/BindGrammarViaCatalog.md new file mode 100644 index 00000000..28e1aeaf --- /dev/null +++ b/docs/design/BindGrammarViaCatalog.md @@ -0,0 +1,459 @@ +# Design: Bind Grammar via XML Catalog + +Issue: https://github.com/redhat-developer/vscode-xml/issues/838 + +## 1. XML Catalog Spec Overview (OASIS XML Catalogs) + +An XML Catalog is a mapping file that redirects XML identifiers (public IDs, system IDs, URIs) to local resources. The catalog file itself is XML with namespace `urn:oasis:names:tc:entity:xmlns:xml:catalog`. + +### 1.1 Catalog File Structure + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 1.2 Entry Types Summary + +| Entry Type | Key Attribute(s) | Target | Use Case | +|---|---|---|---| +| `public` | `publicId` | `uri` | Map a PUBLIC identifier to a local DTD | +| `system` | `systemId` | `uri` | Map a SYSTEM identifier to a local resource | +| `uri` | `name` | `uri` | Map a namespace URI or schema location to a local file | +| `rewriteSystem` | `systemIdStartString` | `rewritePrefix` | Redirect a URL prefix (system IDs) | +| `rewriteURI` | `uriStartString` | `rewritePrefix` | Redirect a URL prefix (URIs) | +| `systemSuffix` | `systemIdSuffix` | `uri` | Match system IDs by suffix | +| `uriSuffix` | `uriSuffix` | `uri` | Match URIs by suffix | +| `delegatePublic` | `publicIdStartString` | `catalog` | Delegate to another catalog by public ID prefix | +| `delegateSystem` | `systemIdStartString` | `catalog` | Delegate to another catalog by system ID prefix | +| `delegateURI` | `uriStartString` | `catalog` | Delegate to another catalog by URI prefix | +| `nextCatalog` | — | `catalog` | Chain another catalog after this one | +| `group` | `prefer`, `xml:base` | (children) | Group entries with shared settings | + +### 1.3 Resolution Algorithm + +When an XML parser encounters an external identifier (e.g. a DOCTYPE), the catalog resolves it in this order: + +1. **system** — exact match on system ID +2. **rewriteSystem** — longest matching prefix on system ID +3. **delegateSystem** — longest matching prefix, delegate to sub-catalog +4. **public** — exact match on public ID (if `prefer="public"` or no system ID) +5. **delegatePublic** — longest matching prefix on public ID +6. **nextCatalog** — try chained catalogs +7. No match → use original identifier + +For URI resolution (`uri`, `rewriteURI`, `delegateURI`), a similar algorithm applies. + +### 1.4 The `prefer` Attribute + +Controls whether public or system identifiers take priority when both exist: +- `prefer="public"` (default): public ID lookup happens even when system ID exists +- `prefer="system"`: system ID lookup takes precedence; public ID used only as fallback + +### 1.5 Relevant Entry Types for Binding + +For the "Bind to grammar" wizard, only **direct mapping entries** are useful to present to users: + +| Entry Type | Relevant? | Why | +|---|---|---| +| `public` | **Yes** | User picks a PUBLIC ID → generates `` | +| `system` | **Yes** | User picks a SYSTEM ID → generates `` | +| `uri` | **Yes** | User picks a URI/namespace → generates `xsi:schemaLocation` or `xsi:noNamespaceSchemaLocation` | +| `rewriteSystem/URI` | No | Infrastructure-level, not user-facing identifiers | +| `systemSuffix/uriSuffix` | No | Pattern-based, not specific entries | +| `delegate*` | No | Points to other catalogs, not to grammars | +| `nextCatalog` | **Indirect** | Must be followed to discover entries in chained catalogs | +| `group` | **Indirect** | Contains entries that should be included | + +## 2. Current Architecture + +### 2.1 Current "Bind to grammar/schema" Flow + +``` +User clicks CodeLens "Bind to grammar/schema..." + │ + ▼ +[Client] xml.open.binding.wizard command + │ + ▼ +Step 1: QuickPick "Local" or "Remote" + │ + ▼ +Step 2: File picker (local) or InputBox (remote URL) + │ + ▼ +Step 3: QuickPick binding type: + ├── "Standard (xsi, DOCTYPE)" → bindingType = "standard" + ├── "XML Model association" → bindingType = "xml-model" + └── "File association" → bindingType = "fileAssociation" + │ + ▼ +[Client → Server] xml.associate.grammar.insert(docURI, grammarURI, bindingType) + │ + ▼ +[Server] Generates TextDocumentEdit based on grammar type: + ├── XSD + targetNamespace → xmlns + xsi:schemaLocation + ├── XSD (no namespace) → xsi:noNamespaceSchemaLocation + ├── DTD → + └── xml-model → + │ + ▼ +[Client] Applies edit to document +``` + +### 2.2 Key Gap: No PUBLIC DOCTYPE Support + +The current `AssociateGrammarCommand` only generates ``. There is no support for ``. + +### 2.3 Catalog Entry Model Gap + +`CatalogEntry` / `URICatalogEntry` only exposes the `uri` attribute (the target). The `publicId`, `systemId`, and `name` attributes are NOT exposed in the model. They'd need to be extracted from the `DOMElement` via `getEntryElement()`. + +### 2.4 No Catalog Entry Enumeration + +The Xerces `XMLCatalogResolver` provides resolution (`resolvePublic`, `resolveSystem`, `resolveURI`) but cannot enumerate all entries. To list entries, we must parse catalog files directly via `CatalogUtils.getCatalogEntries()` and follow `nextCatalog` chains. + +## 3. Use Cases + +### UC1: Bind from existing catalog entry +**Precondition**: One or more catalogs are registered in `xml.catalogs` with entries. +**User**: Clicks "Bind to grammar/schema...", selects "Catalog entry", picks an entry from the list. +**Result**: Appropriate DOCTYPE/namespace is generated in the XML. + +### UC2: Bind with new catalog entry (catalog exists) +**Precondition**: A catalog is registered but doesn't have the desired entry. +**User**: Clicks "Bind to grammar/schema...", selects "Catalog entry", then "Add new entry...", picks a grammar file, specifies identifier, selects target catalog. +**Result**: Entry is added to catalog, XML is bound. + +### UC3: Bind with new catalog (no catalog exists) +**Precondition**: No catalog is registered. +**User**: Clicks "Bind to grammar/schema...", selects "Catalog entry", creates a new catalog, adds an entry. +**Result**: Catalog file is created, registered in settings, entry added, XML is bound. + +### UC4: Large catalogs (DITA, DocBook) +**Precondition**: Catalogs with hundreds of entries (e.g. DITA-OT has 200+ public entries). +**User**: Needs to find the right entry quickly. +**Requirement**: Searchable/filterable list with meaningful labels. + +## 4. Proposed Wizard Flow + +### 4.1 Modified Step 1: Grammar Source + +Add "Catalog entry" as a third option alongside local/remote: + +``` +How do you want to bind the grammar? + + Local file... + Remote URL... + Catalog entry... ← NEW +``` + +### 4.2 Catalog Entry Flow + +``` +"Catalog entry" selected + │ + ▼ + ┌─ Catalogs registered with entries? ──┐ + │ YES │ NO + ▼ ▼ +Select entry (QuickPick) "No catalog registered" + │ │ + │ entries listed + ┌───┴────┐ + │ "Add new entry..." │ │ + │ Create Select existing + │ catalog catalog file + │ │ │ + │ ▼ ▼ + │ File save File open + │ dialog dialog + │ │ │ + │ └───┬────┘ + │ │ + │ Register in + │ xml.catalogs + │ │ + │ ┌────────────────────────┘ + │ ▼ + │ "Add new entry" flow + │ │ + │ ▼ + │ Select grammar file + │ (local or remote) + │ │ + │ ▼ + │ Select entry type + │ (auto-suggested based on grammar type) + │ │ + │ ▼ + │ Enter identifier + │ (publicId / systemId / name) + │ │ + │ ▼ + │ Select target catalog + │ (if multiple registered) + │ │ + │ ▼ + │ Add entry to catalog + │ │ + ├──────────────┘ + ▼ +Generate XML binding +based on entry type + grammar type +``` + +### 4.3 Entry Selection UI (UC4 - Large Catalogs) + +For catalogs with many entries, use a QuickPick with: +- **Type-to-filter** search (built-in VS Code QuickPick feature) +- **Grouping** by catalog file (using QuickPick separators) +- **Labels** showing: entry type icon + identifier +- **Description** showing: resolved URI (grammar file) +- **Detail** showing: catalog file path + +``` +Select a catalog entry to bind + + ── catalog.xml ────────────────────────────────────── + PUBLIC -//OASIS//DTD DITA 2.0 Base Topic//EN basetopic.dtd + PUBLIC -//OASIS//DTD DITA 2.0 Base Map//EN basemap.dtd + PUBLIC -//OASIS//DTD DITA 2.0 Subject Scheme//EN subjectScheme.dtd + ... + + ── schemas/catalog.xml ────────────────────────────── + URI http://maven.apache.org/POM/4.0.0 maven-4.0.0.xsd + SYSTEM http://example.com/config.xsd config.xsd + ... + + ── Add new catalog entry... ───────────────────────── +``` + +### 4.4 Entry Type Auto-Suggestion + +When creating a new entry, suggest the entry type based on grammar: + +| Grammar Type | Suggested Entry Type | Reason | +|---|---|---| +| DTD (`.dtd`) | `public` | DTDs traditionally use PUBLIC identifiers | +| XSD (`.xsd`) | `uri` | XSD uses namespace URIs, mapped via `` | +| RNG (`.rng`, `.rnc`) | `uri` | Similar to XSD | + +### 4.5 Generated XML Binding + +| Entry Type | Grammar | Generated XML | +|---|---|---| +| `public` | DTD | `` | +| `system` | DTD | `` | +| `uri` | XSD (with ns) | `xmlns="{name}" xmlns:xsi="..." xsi:schemaLocation="{name} {name}"` | +| `uri` | XSD (no ns) | `xsi:noNamespaceSchemaLocation="{name}"` | +| `uri` | RNG | `` | +| `system` | XSD | `xsi:noNamespaceSchemaLocation="{systemId}"` | + +> **Note on PUBLIC DOCTYPE**: The `uri` attribute from the catalog entry is used as the SYSTEM identifier in the DOCTYPE. This mirrors how standards like DITA reference their DTDs: +> ```xml +> +> ``` +> The catalog entry `` provides both the publicId and the system ID fallback. + +## 5. Implementation Plan + +### 5.1 Server-Side Changes (lemminx) + +#### 5.1.1 Extend CatalogEntry Model + +Expose identifier attributes from catalog entries: + +```java +// New class or extension of URICatalogEntry +public class BindableCatalogEntry { + private String catalogURI; // which catalog file + private String entryType; // "public", "system", "uri" + private String identifier; // publicId, systemId, or name + private String targetURI; // resolved uri + private String grammarType; // "xsd", "dtd", "rng" (detected from uri extension) +} +``` + +#### 5.1.2 New Command: `xml.catalog.entries.bindable` + +Returns all bindable catalog entries across all registered catalogs. + +**Parameters**: none (uses registered catalogs from settings) + +**Algorithm**: +1. For each catalog in `xml.catalogs`: + a. Parse the catalog XML file + b. For each `public`, `system`, `uri` entry: extract identifier + target URI + c. For each `group`: recurse into children + d. For each `nextCatalog`: follow chain and recurse (with cycle detection) +2. Detect grammar type from resolved URI extension +3. Return as `BindableCatalogEntry[]` + +**Response** (JSON): +```json +[ + { + "catalogURI": "file:///path/to/catalog.xml", + "entryType": "public", + "identifier": "-//OASIS//DTD DITA 2.0 Base Topic//EN", + "targetURI": "file:///path/to/basetopic.dtd", + "grammarType": "dtd" + }, + { + "catalogURI": "file:///path/to/schemas/catalog.xml", + "entryType": "uri", + "identifier": "http://maven.apache.org/POM/4.0.0", + "targetURI": "file:///path/to/maven-4.0.0.xsd", + "grammarType": "xsd" + } +] +``` + +#### 5.1.3 New Command: `xml.catalog.entry.add` + +Adds a new entry to an existing catalog file. + +**Parameters**: `[catalogURI, entryType, identifier, grammarURI]` + +**Returns**: `TextDocumentEdit` that inserts the new entry element into the catalog. + +Example: `xml.catalog.entry.add("file:///catalog.xml", "public", "-//My//DTD//EN", "file:///my.dtd")` would insert `` into catalog.xml. + +#### 5.1.4 New Command: `xml.catalog.create` + +Creates a new empty catalog file. + +**Parameters**: `[catalogURI]` + +**Returns**: `CreateFile` + `TextDocumentEdit` with the catalog skeleton: +```xml + + + +``` + +#### 5.1.5 Extend `xml.associate.grammar.insert` + +Add support for catalog-based binding. + +**New parameter variant**: `[documentURI, bindingType, entryType, identifier, systemId?]` + +Where `bindingType = "catalog"`. + +**New generated edits**: +- `public` entry → `` +- `system` entry → `` +- `uri` entry (XSD with ns) → `xmlns="{identifier}"` + `xsi:schemaLocation` +- `uri` entry (XSD no ns) → `xsi:noNamespaceSchemaLocation="{identifier}"` +- `uri` entry (RNG) → `` + +### 5.2 Client-Side Changes (vscode-xml) + +#### 5.2.1 Modify `grammarAssociationCommand()` + +Add "Catalog entry" to the first QuickPick step. + +#### 5.2.2 New: `showCatalogEntryPicker()` + +Calls `xml.catalog.entries.bindable`, displays results in a QuickPick with: +- Separators per catalog file +- Type icon + identifier as label +- Resolved URI as description +- "Add new catalog entry..." at the bottom + +#### 5.2.3 New: `createCatalogEntryFlow()` + +Sub-wizard for creating a new catalog entry: +1. Pick grammar file (reuse existing local/remote picker) +2. Pick entry type (with auto-suggestion) +3. Input identifier +4. Pick target catalog (if multiple) or create new +5. Call `xml.catalog.entry.add` +6. Call `xml.associate.grammar.insert` with catalog binding + +#### 5.2.4 New: `createCatalogFlow()` + +Sub-wizard for creating a new catalog: +1. File save dialog for catalog location +2. Call `xml.catalog.create` +3. Update `xml.catalogs` setting (via `ConfigurationItemEdit` or direct VS Code API) + +## 6. Design Decisions + +### D1: Root Element for DOCTYPE +**Decision**: Use the current root element name from the XML document. The CodeLens appears on the root element, so it always exists. For empty XML files (issue #819), use a placeholder. + +### D2: System ID in PUBLIC DOCTYPE +**Decision**: Use the `uri` attribute from the catalog entry (e.g. `"basetopic.dtd"`) as the SYSTEM identifier in ``. This mirrors how DITA and DocBook DOCTYPEs work. + +### D3: `nextCatalog` Chain Depth +**Decision**: Follow all `nextCatalog` chains recursively with cycle detection. DITA-OT has deep chains and users expect to see all entries regardless of catalog organization. + +### D4: `rewriteSystem` / `rewriteURI` Entries +**Decision**: Not exposed in the wizard. These are infrastructure-level prefix rewriting rules, not user-facing identifiers for specific grammars. + +### D5: Entry Type Selection for New Entries +**Decision**: Auto-detect based on grammar type (DTD → `public`, XSD/RNG → `uri`) with option to override. Most users won't know the difference between entry types. + +### D6: Multiple Catalogs - Where to Add Entry? +**Decision**: Ask which catalog if more than one is registered. Skip the step if only one catalog exists. + +### D7: Catalog Entry URI - Relative or Absolute? +**Decision**: Relative to the catalog file (e.g. `"schemas/my.xsd"`). Relative paths are more portable and follow standard practice. + +## 7. Phased Implementation + +### Phase 1: Pick from Existing Catalog Entries (UC1) +- Server: `xml.catalog.entries.bindable` command +- Server: Extend `xml.associate.grammar.insert` for PUBLIC DOCTYPE +- Client: Add "Catalog entry" to wizard, entry picker UI +- **Scope**: Read-only — no catalog creation or modification + +### Phase 2: Create New Catalog Entry (UC2) +- Server: `xml.catalog.entry.add` command +- Client: "Add new entry" sub-flow +- **Scope**: Modifies existing catalogs + +### Phase 3: Create New Catalog (UC3) +- Server: `xml.catalog.create` command +- Client: "Create catalog" sub-flow, settings registration +- **Scope**: Creates new files, modifies settings + +### Phase 4: UX Polish (UC4) +- Improve QuickPick for large catalogs (grouping, icons) +- Add "recently used" entries +- Add catalog entry preview (show what will be generated) diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index ad98ec16..4cf2e809 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')) { @@ -315,6 +323,310 @@ function addToValueToSettingArray(key: string, value: T): void { 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: "$(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 { + 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; + const workEdits = new WorkspaceEdit(); + for (const edit of lspTextDocumentEdit.edits) { + if ('newText' in edit) { + workEdits.replace(documentURI, languageClient.protocol2CodeConverter.asRange(edit.range), edit.newText); + } + } + await workspace.applyEdit(workEdits); + } 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)); + + 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; + + 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); + } + } + await workspace.applyEdit(workEdits); + const catalogDoc = workspace.textDocuments.find(d => d.uri.toString() === catalogFileUri.toString()); + if (catalogDoc) { + await catalogDoc.save(); + } + } + } 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); +} + /** * Register commands used for associating grammar file (XSD,DTD) to a given XML file for command menu and CodeLens * 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