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