diff --git a/docs/multi-workspace-support/mws-README.md b/docs/multi-workspace-support/mws-README.md new file mode 100644 index 000000000..e639af0c7 --- /dev/null +++ b/docs/multi-workspace-support/mws-README.md @@ -0,0 +1,91 @@ +# Implementing Multi-Project Workspace Support for Current Language Server + +The current WSO2 Micro Integrator (MI) Language Server typically serves each MI project with a separate language server instance when multiple projects exist in a workspace. This limits scalability, increases resource overhead, and breaks true multi-folder workspace experiences. + +## Overview + +Overview of Multi-Root Support + +### 1. Core XML Validation & Schema Isolation **(Completed)** + +### 2. Eliminating Global State, Singletons & Context-Aware + +### 3. Language Client (VS Code Extension) Integration + + +--- + +## Stage 1 Completed: LemMinX File Associations for Workspace Schema Validation + +### Technical Overview +Introduced `File Associations` to replace the legacy namespace-based `catalog` implementation. Instead of relying on +rigid catalogs, this approach maps specific file path patterns (e.g., glob matches for a project folder) to a specific target schema path. This enables different schemas (e.g., MI 4.3.0 and 4.4.0) to be applied to different projects simultaneously without conflict in a single LemMinX instance. + +### Changelog & Implementation Details + +#### 1. `Utils.java` (`org.eclipse.lemminx/customservice/synapse/utils/Utils.java`) +* **Implementation of `updateSynapseFileAssociationSettings` Method:** + * Previously, the system relied on `updateSynapseCatalogSettings`. + * Added this new functionality to support File Association within the language server. + +#### 2. `XMLLanguageServer.java` (`org.eclipse.lemminx/XMLLanguageServer.java`) +* **Initialization Mode Selection:** The language server now dynamically determines the validation method during the `initialize` step by reading the `useAssociationSettings` flag from `initializationOptions`: + * **If `true` (Default):** The server invokes `Utils.updateSynapseFileAssociationSettings`, utilizing the new core standard based on file associations. + * **If `false`:** The server routes to `Utils.updateSynapseCatalogSettings` to process legacy catalog-based configurations. + * **If `null`/Undefined:** The server defaults to the file association method (`true`). +* **Temporary Bridge:** + * Because `SynapseLanguageService` (Stage 2) is not yet fully isolated for multi-root awareness, a temporary bridge was established. + * It sets the default Path to the first project in the collection: `synapseLanguageService.setSynapseXSDPath(workspaceSchemas.values().iterator().next());` + +#### 3. `XMLWorkspaceService.java` (`org.eclipse.lemminx/XMLWorkspaceService.java`) +* **Enhanced `didChangeWorkspaceFolders` Logic:** + * Updated to capture dynamic workspace events correctly. + * If a user adds a new project to the workspace after server initialization, it accurately intercepts the action and generates/applies XSD schemas for the new folder. + +#### 4. `CleanMultiRootValidationTest.java` (`.../extensions/contentmodel/CleanMultiRootValidationTest.java`) +Established three comprehensive multi-root tests demonstrating core functionality: +1. **Multi-Root Isolation:** Verifies that a single Language Server successfully provides isolated validations to two independent projects governed by distinct XSD files. +2. **Dynamic Connector Generation Test:** Ensures that dynamically generated connector schemas are instantly picked up by the validation engine logic, operating independently of server restarts. +3. **Dynamic Workspace Handling:** Asserts that when an entirely new project is dynamically appended to the workspace context at runtime, the language server successfully triggers its standard MI validations for the newly tracked space. +--- +## How to Test + +#### Testing via VS Code Extension +To verify the multi-root support directly within the VS Code environment: +1. **Build the Project**: Run the following command from the root directory to generate the server JAR: + ```bash + mvn clean install -DskipTests + ``` +2. **Update Extension Binary**: Navigate to the `target/` directory, locate the newly built JAR, and copy it into the `ls/` folder of your VS Code extension installation (replacing the existing JAR). +3. **Configure Settings Toggle**: When testing via the extension, you can toggle between the old and new logic by passing `useAssociationSettings` within your Language Server's `initializationOptions`. + * **Default Behavior**: If the `useAssociationSettings` field is **not mentioned** (omitted), the server will automatically default to `true` and work with the new **File Association** logic. + * If explicitly set to `true`, the language server continues to use the multi-root `fileAssociation` logic. + * If explicitly set to `false`, the language server natively falls back to utilizing the legacy single-project `catalog` settings extraction logic. + +#### Standalone Test Execution +For automated testing without the IDE: +1. **Navigate to Root**: + ```bash + cd mi-language-server + ``` +2. **Run Targeted Tests**: Execute the specialized multi-root validation suite using Maven: + ```bash + mvn -Dtest=CleanMultiRootValidationTest test + ``` + +--- + +## Next Steps + +### Eliminating Global State, Singletons & Context-Aware +Transition legacy singletons (e.g., `ConnectorHolder`, `SynapseLanguageService`, Mediator Handlers ....) to be +resolved per project context instead of a global state. + +* Introduce `ProjectContext` and `WorkspaceManager` classes to manage memory scoped to individual projects. + * *Reference Files:* `multi-workspace-support/resources/ProjectContext.java`, `multi-workspace-support/resources/WorkspaceManager.java` + +* Isolate Language Server features (Auto-Complete, Go-To-Definition) per workspace. + + +### Language Client (VS Code Extension) Integration +Update the frontend VS Code Extension to natively support the multi-project backend API configurations and event hooks. diff --git a/docs/multi-workspace-support/resources/ProjectContext.java b/docs/multi-workspace-support/resources/ProjectContext.java new file mode 100644 index 000000000..e3fe99177 --- /dev/null +++ b/docs/multi-workspace-support/resources/ProjectContext.java @@ -0,0 +1,435 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.eclipse.lemminx.customservice.synapse; + +import org.eclipse.lemminx.customservice.SynapseLanguageClientAPI; +import org.eclipse.lemminx.customservice.synapse.connectors.AbstractConnectorLoader; +import org.eclipse.lemminx.customservice.synapse.connectors.ConnectionHandler; +import org.eclipse.lemminx.customservice.synapse.connectors.ConnectorHolder; +import org.eclipse.lemminx.customservice.synapse.connectors.NewProjectConnectorLoader; +import org.eclipse.lemminx.customservice.synapse.connectors.OldProjectConnectorLoader; +import org.eclipse.lemminx.customservice.synapse.expression.ExpressionHelperProvider; +import org.eclipse.lemminx.customservice.synapse.inbound.conector.InboundConnectorHolder; +import org.eclipse.lemminx.customservice.synapse.mediatorService.MediatorHandler; +import org.eclipse.lemminx.customservice.synapse.resourceFinder.AbstractResourceFinder; +import org.eclipse.lemminx.customservice.synapse.resourceFinder.ResourceFinderFactory; +import org.eclipse.lemminx.customservice.synapse.utils.Utils; + +import java.nio.file.Path; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Holds all per-project state required by the MI Language Server for a single + * workspace folder/project. In a Multi-Root Workspace scenario, one instance + * of {@code ProjectContext} is created per open project root, and all language + * features (completions, hover, validation, connectors, etc.) are resolved + * through the context that corresponds to the document being processed. + * + *

Instances are stored in a map keyed by the project root URI inside the + * language server (e.g. {@code SynapseLanguageClientAPI} or its successor + * manager class) so that requests for any document are dispatched to the + * correct context. + * + *

Immutable identity fields (projectUri, isLegacyProject, projectServerVersion) + * are set in the constructor and cannot be changed afterwards. All service + * handler fields are eagerly initialized via the {@link #initProject} method, + * which must be called immediately after construction. No setters are provided + * — Early Initialization ensures everything is ready before the context is + * used, eliminating loading delays during coding. + * + *

Note: The {@code TryOutManager} is intentionally excluded from + * this class. It manages a heavy background MI Server process that binds to + * a specific network port, so only one instance can run at a time across all + * projects. It remains a separate global concern managed by + * {@code SynapseLanguageService}. + */ +public class ProjectContext { + + private static final Logger log = Logger.getLogger(ProjectContext.class.getName()); + + /** + * Tracks whether {@link #initProject} has completed successfully. + * Used by service-handler getters to fail fast with a clear message + * instead of returning {@code null}. + */ + private boolean initialized = false; + + // ------------------------------------------------------------------------- + // Identity fields — set once at construction time and never changed. + // ------------------------------------------------------------------------- + + /** + * The root folder URI of this project (e.g. {@code file:///Users/.../ProjectA}). + * Used as the primary key when looking up the context for a given document URI. + */ + private final String projectUri; + + /** + * Whether this project is a legacy (state-machine-based) MI project. + * Legacy projects use a different activation and completion pathway compared + * to modern MI projects. + */ + private final boolean isLegacyProject; + + /** + * The WSO2 MI version string associated with this project + * (e.g. {@code "4.3.0"}, {@code "4.4.0"}). Used to select the correct + * XSD schemas, mediator descriptors, and feature toggles. + */ + private final String projectServerVersion; + + // ------------------------------------------------------------------------- + // Schema field — resolved during initProject(). + // ------------------------------------------------------------------------- + + /** + * Path to the extracted root {@code synapse_config.xsd} for this specific + * project. Resolved during {@link #initProject} by extracting the + * version-specific XSD bundle for this project. + */ + private Path synapseXsdPath; + + // ------------------------------------------------------------------------- + // Connector fields — eagerly initialized in the constructor. + // ------------------------------------------------------------------------- + + /** + * Holds metadata and descriptors for all regular (outbound) connectors + * discovered for this project. Initialized eagerly so that connector + * scanning can populate it immediately after construction. + */ + private final ConnectorHolder connectorHolder; + + /** + * Holds metadata and descriptors for all inbound connectors discovered + * for this project. Initialized eagerly alongside {@link #connectorHolder}. + */ + private final InboundConnectorHolder inboundConnectorHolder; + + // ------------------------------------------------------------------------- + // Service handler fields — eagerly initialized via initProject(). + // ------------------------------------------------------------------------- + + /** + * Responsible for loading and refreshing connectors from the project's + * connector directory. The concrete type (Old vs New) depends on + * {@link #isLegacyProject}. + */ + private AbstractConnectorLoader connectorLoader; + + /** + * Handles completion proposals and hover information for Synapse mediators + * within this project. Depends on {@link #projectServerVersion} to load + * the correct mediator descriptor set. + */ + private MediatorHandler mediatorHandler; + + /** + * Provides completion and documentation support for {@code ${}} expression + * syntax (e.g. payload-factory, data-mapper expressions) within this project. + */ + private ExpressionHelperProvider expressionHelperProvider; + + /** + * Manages named connection artifacts (e.g. connector local-entries) for + * this project, enabling connection-aware completions and validations. + */ + private ConnectionHandler connectionHandler; + + /** + * Locates and resolves project-internal resources (endpoints, sequences, + * message-stores, etc.) referenced by documents in this project. + */ + private AbstractResourceFinder resourceFinder; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + /** + * Creates a new {@code ProjectContext} for the given project root. + * + *

The {@link ConnectorHolder} and {@link InboundConnectorHolder} are + * created eagerly so that connector-scanning routines can start populating + * them immediately. All other service handlers remain {@code null} until + * {@link #initProject} is called. + * + * @param projectUri root folder URI of the project + * (e.g. {@code "file:///Users/.../ProjectA"}) + * @param isLegacyProject {@code true} if this is a state-machine based + * (legacy) MI project + * @param projectServerVersion the MI version string for this project + * (e.g. {@code "4.3.0"}) + */ + public ProjectContext(String projectUri, boolean isLegacyProject, String projectServerVersion) { + + this.projectUri = projectUri; + this.isLegacyProject = isLegacyProject; + this.projectServerVersion = projectServerVersion; + + // Eagerly initialize connector holders so scanning can begin immediately. + this.connectorHolder = ConnectorHolder.getInstance(); + this.inboundConnectorHolder = new InboundConnectorHolder(); + } + + // ------------------------------------------------------------------------- + // Early Initialization + // ------------------------------------------------------------------------- + + /** + * Eagerly initializes all service handlers for this project in the correct + * dependency order, mirroring the initialization sequence from + * {@code SynapseLanguageService.init(...)}. + * + *

This method must be called exactly once, immediately after construction, + * before the context is registered for use. After this call returns + * successfully, every getter is guaranteed to return a non-null, fully + * initialized instance — eliminating any lazy-loading delays during coding. + * + *

Initialization order: + *

    + *
  1. {@link InboundConnectorHolder#init} — loads inbound connector metadata
  2. + *
  3. {@link AbstractConnectorLoader} — instantiates the correct loader + * (Old vs New) and calls {@code init(projectUri)}
  4. + *
  5. {@link MediatorHandler} — loads mediator descriptors for this version
  6. + *
  7. {@link ConnectionHandler} — indexes named connections
  8. + *
  9. {@link ExpressionHelperProvider} — prepares expression helpers
  10. + *
  11. {@link AbstractResourceFinder} — discovers and indexes dependent + * resources (endpoints, sequences, etc.)
  12. + *
  13. Resolves and stores the {@code synapseXsdPath}
  14. + *
+ * + * @param miServerPath absolute path to the local MI server installation + * @param languageClient the language-client proxy for sending notifications + * back to the IDE + * @throws Exception if any step in the initialization pipeline fails + */ + public void initProject(String miServerPath, SynapseLanguageClientAPI languageClient) throws Exception { + + log.log(Level.INFO, "Initializing ProjectContext for: " + projectUri); + + // 1. Initialize inbound connector metadata. + inboundConnectorHolder.init(projectUri, projectServerVersion); + + // 2. Instantiate the correct connector loader based on project type. + if (isLegacyProject) { + this.connectorLoader = new OldProjectConnectorLoader(languageClient, connectorHolder); + } else { + this.connectorLoader = new NewProjectConnectorLoader(languageClient, connectorHolder, + inboundConnectorHolder); + } + connectorLoader.init(projectUri); + + // 3. Initialize the mediator handler with version-specific descriptors. + this.mediatorHandler = new MediatorHandler(); + mediatorHandler.init(projectUri, projectServerVersion, connectorHolder); + + // 4. Initialize the connection handler. + this.connectionHandler = new ConnectionHandler(); + connectionHandler.init(connectorHolder); + + // 5. Create the expression helper provider. + this.expressionHelperProvider = new ExpressionHelperProvider(projectUri); + + // 6. Create and load the resource finder. + this.resourceFinder = ResourceFinderFactory.getResourceFinder(isLegacyProject); + try { + resourceFinder.loadDependentResources(projectUri); + } catch (Exception e) { + log.log(Level.SEVERE, "Failed to initialize ProjectContext for: " + projectUri + ". Error: " + e.getMessage()); + } + + // 7. Resolve the synapse XSD path for this project's MI version. + this.synapseXsdPath = Utils.copyXSDFiles(projectUri); + + this.initialized = true; + log.log(Level.INFO, "ProjectContext initialized successfully for: " + projectUri); + } + + // ------------------------------------------------------------------------- + // Getters — identity fields + // ------------------------------------------------------------------------- + + /** + * Returns the root folder URI of this project. + * + * @return the project root URI (never {@code null}) + */ + public String getProjectUri() { + return projectUri; + } + + /** + * Returns whether this is a legacy (state-machine-based) MI project. + * + * @return {@code true} for legacy projects + */ + public boolean isLegacyProject() { + return isLegacyProject; + } + + /** + * Returns the WSO2 MI version string associated with this project. + * + * @return the project server version (e.g. {@code "4.3.0"}) + */ + public String getProjectServerVersion() { + return projectServerVersion; + } + + // ------------------------------------------------------------------------- + // Getter — synapseXsdPath (no setter; resolved in initProject()) + // ------------------------------------------------------------------------- + + /** + * Returns the path to the extracted root {@code synapse_config.xsd} for + * this project. + * + * @return the XSD path (non-null after {@link #initProject}) + * @throws IllegalStateException if {@link #initProject} has not been called + */ + public Path getSynapseXsdPath() { + checkInitialized(); + return synapseXsdPath; + } + + // ------------------------------------------------------------------------- + // Getters — connector holders (no setters; initialized in constructor) + // ------------------------------------------------------------------------- + + /** + * Returns the {@link ConnectorHolder} for this project's regular (outbound) + * connectors. + * + * @return the connector holder (never {@code null}) + */ + public ConnectorHolder getConnectorHolder() { + return connectorHolder; + } + + /** + * Returns the {@link InboundConnectorHolder} for this project's inbound + * connectors. + * + * @return the inbound connector holder (never {@code null}) + */ + public InboundConnectorHolder getInboundConnectorHolder() { + return inboundConnectorHolder; + } + + // ------------------------------------------------------------------------- + // Getters — service handlers (no setters; initialized in initProject()) + // ------------------------------------------------------------------------- + + /** + * Returns the {@link AbstractConnectorLoader} responsible for loading + * connectors for this project. + * + * @return the connector loader (non-null after {@link #initProject}) + * @throws IllegalStateException if {@link #initProject} has not been called + */ + public AbstractConnectorLoader getConnectorLoader() { + checkInitialized(); + return connectorLoader; + } + + /** + * Returns the {@link MediatorHandler} that provides completion and hover + * support for Synapse mediators in this project. + * + * @return the mediator handler (non-null after {@link #initProject}) + * @throws IllegalStateException if {@link #initProject} has not been called + */ + public MediatorHandler getMediatorHandler() { + checkInitialized(); + return mediatorHandler; + } + + /** + * Returns the {@link ExpressionHelperProvider} that handles {@code ${}} + * expression completions and documentation for this project. + * + * @return the expression helper provider (non-null after {@link #initProject}) + * @throws IllegalStateException if {@link #initProject} has not been called + */ + public ExpressionHelperProvider getExpressionHelperProvider() { + checkInitialized(); + return expressionHelperProvider; + } + + /** + * Returns the {@link ConnectionHandler} that manages named connections for + * this project. + * + * @return the connection handler (non-null after {@link #initProject}) + * @throws IllegalStateException if {@link #initProject} has not been called + */ + public ConnectionHandler getConnectionHandler() { + checkInitialized(); + return connectionHandler; + } + + /** + * Returns the {@link AbstractResourceFinder} that locates project-internal + * resources (endpoints, sequences, etc.) for this project. + * + * @return the resource finder (non-null after {@link #initProject}) + * @throws IllegalStateException if {@link #initProject} has not been called + */ + public AbstractResourceFinder getResourceFinder() { + checkInitialized(); + return resourceFinder; + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + /** + * Throws {@link IllegalStateException} if {@link #initProject} has not + * been called yet. Guards service-handler getters so that callers get a + * clear error message instead of a downstream {@code NullPointerException}. + */ + private void checkInitialized() { + if (!initialized) { + throw new IllegalStateException( + "ProjectContext not initialized. Call initProject() first. Project: " + projectUri); + } + } + + // ------------------------------------------------------------------------- + // Object overrides + // ------------------------------------------------------------------------- + + /** + * Returns a human-readable representation of this context, primarily for + * logging and debugging purposes. + * + * @return a string in the format {@code ProjectContext{uri=..., version=..., legacy=...}} + */ + @Override + public String toString() { + return "ProjectContext{" + + "projectUri='" + projectUri + '\'' + + ", projectServerVersion='" + projectServerVersion + '\'' + + ", isLegacyProject=" + isLegacyProject + + '}'; + } +} diff --git a/docs/multi-workspace-support/resources/WorkspaceManager.java b/docs/multi-workspace-support/resources/WorkspaceManager.java new file mode 100644 index 000000000..b36216f40 --- /dev/null +++ b/docs/multi-workspace-support/resources/WorkspaceManager.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.eclipse.lemminx.customservice.synapse; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Central registry that maps open workspace folder URIs to their isolated + * {@link ProjectContext} instances. + * + *

In a Multi-Root Workspace scenario, VS Code may have several MI projects + * open simultaneously, each with its own MI version, connectors, and handlers. + * {@code WorkspaceManager} is the single source of truth for resolving: + *

+ *   document URI  →  correct {@link ProjectContext}
+ * 
+ * + *

All operations are thread-safe; the underlying map is a + * {@link ConcurrentHashMap} so concurrent LSP request threads can look up + * contexts without external synchronization. + * + *

URI contract: All URIs stored in and passed to this class must be + * in normalized {@code file:///} format (e.g. + * {@code file:///Users/me/ProjectA}). Callers are responsible for normalizing + * URIs before invoking any method. + */ +public class WorkspaceManager { + + private static final Logger log = Logger.getLogger(WorkspaceManager.class.getName()); + + /** + * Map from project root URI to the {@link ProjectContext} for that project. + * Uses {@link ConcurrentHashMap} for lock-free, thread-safe reads. + * + *

Key: normalized project root URI (e.g. {@code file:///Users/me/ProjectA}) + *
Value: the fully initialized {@link ProjectContext} for that root + */ + private final Map projects = new ConcurrentHashMap<>(); + + // ------------------------------------------------------------------------- + // Mutating operations + // ------------------------------------------------------------------------- + + /** + * Registers a new {@link ProjectContext} for the given project root URI. + * + *

If a context is already registered for {@code projectUri}, a warning + * is logged and the existing entry is not overwritten. Call + * {@link #removeProject} first if you need to replace a context. + * + * @param projectUri the normalized root URI of the project + * (e.g. {@code "file:///Users/me/ProjectA"}) + * @param context the fully initialized {@link ProjectContext} to register + */ + public void addProject(String projectUri, ProjectContext context) { + + if (projectUri == null || context == null) { + log.log(Level.WARNING, "addProject called with null projectUri or context — ignoring."); + return; + } + ProjectContext existing = projects.putIfAbsent(projectUri, context); + if (existing != null) { + log.log(Level.WARNING, + "A ProjectContext is already registered for URI: " + projectUri + + ". The existing context was NOT replaced. Call removeProject() first."); + } else { + log.log(Level.INFO, "Registered ProjectContext for: " + projectUri); + } + } + + /** + * Removes and returns the {@link ProjectContext} for the given project root URI. + * + *

If no context is registered for {@code projectUri}, a warning is + * logged and {@code null} is returned. + * + * @param projectUri the normalized root URI of the project to remove + * @return the removed {@link ProjectContext}, or {@code null} if not found + */ + public ProjectContext removeProject(String projectUri) { + + if (projectUri == null) { + log.log(Level.WARNING, "removeProject called with null projectUri \u2014 ignoring."); + return null; + } + + ProjectContext removed = projects.remove(projectUri); + if (removed == null) { + log.log(Level.WARNING, + "removeProject: no ProjectContext found for URI: " + projectUri); + } else { + log.log(Level.INFO, "Removed ProjectContext for: " + projectUri); + } + return removed; + } + + // ------------------------------------------------------------------------- + // Query operations + // ------------------------------------------------------------------------- + + /** + * Returns the {@link ProjectContext} for an exact project root URI match. + * + * @param projectUri the normalized root URI of the project + * @return the registered {@link ProjectContext}, or {@code null} if not found + */ + public ProjectContext getProject(String projectUri) { + + return projects.get(projectUri); + } + + /** + * Resolves a document URI to the {@link ProjectContext} of the project it + * belongs to, using a longest-prefix match. + * + *

Given a document URI such as + * {@code file:///Users/me/ProjectA/src/main/synapse-config/api/MyAPI.xml}, + * this method iterates all registered project root URIs and returns the + * context whose root URI is the longest prefix of the document URI. The + * longest-prefix rule ensures correctness when one project root is nested + * inside another. + * + *

Example: + *

+     *   Registered roots:
+     *     file:///Users/me/ProjectA      → ContextA
+     *     file:///Users/me/ProjectA/sub  → ContextB   (more specific)
+     *
+     *   getProjectForDocument("file:///Users/me/ProjectA/sub/foo.xml")
+     *     → returns ContextB  (longest match)
+     * 
+ * + * @param documentUri the normalized URI of the document being processed + * @return the best-matching {@link ProjectContext}, or {@code null} if no + * registered project contains the document + */ + public ProjectContext getProjectForDocument(String documentUri) { + + if (documentUri == null) { + log.log(Level.WARNING, "getProjectForDocument called with null documentUri \u2014 returning null."); + return null; + } + + ProjectContext bestMatch = null; + int longestPrefixLength = -1; + + for (Map.Entry entry : projects.entrySet()) { + String projectUri = entry.getKey(); + // Use separator check to avoid false matches (e.g. "project" matching "project2"). + if ((documentUri.startsWith(projectUri + "/") || documentUri.equals(projectUri)) + && projectUri.length() > longestPrefixLength) { + longestPrefixLength = projectUri.length(); + bestMatch = entry.getValue(); + } + } + + if (bestMatch == null) { + log.log(Level.WARNING, + "getProjectForDocument: no registered project contains document: " + documentUri); + } + return bestMatch; + } + + /** + * Returns an unmodifiable snapshot of all currently registered + * {@link ProjectContext} instances. + * + *

The returned collection reflects the state of the registry at the + * moment of the call. Subsequent additions or removals are not reflected. + * + * @return a collection of all registered contexts (never {@code null}, + * may be empty) + */ + public Collection getAllProjects() { + + // Return a true snapshot — not a live view — so callers can iterate safely + // even if another thread adds/removes a project concurrently. + return Collections.unmodifiableCollection(new ArrayList<>(projects.values())); + } + + /** + * Returns {@code true} if a {@link ProjectContext} is registered for the + * given project root URI. + * + * @param projectUri the normalized root URI to query + * @return {@code true} if the project is registered, {@code false} otherwise + */ + public boolean hasProject(String projectUri) { + + return projects.containsKey(projectUri); + } + + /** + * Returns the number of {@link ProjectContext} instances currently registered. + * + * @return the project count (0 if no projects are registered) + */ + public int getProjectCount() { + + return projects.size(); + } +} \ No newline at end of file diff --git a/docs/multi-workspace-support/resources/overview_img.png b/docs/multi-workspace-support/resources/overview_img.png new file mode 100644 index 000000000..fca370aae Binary files /dev/null and b/docs/multi-workspace-support/resources/overview_img.png differ diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLLanguageServer.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLLanguageServer.java index b98daf326..13bcbd3d7 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLLanguageServer.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLLanguageServer.java @@ -20,6 +20,7 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; @@ -103,7 +104,8 @@ public class XMLLanguageServer implements ProcessLanguageServer, XMLLanguageServ private XMLCapabilityManager capabilityManager; private TelemetryManager telemetryManager; private final SynapseLanguageService synapseLanguageService; - + private Map workspaceSchemas = new HashMap<>(); + private Object lastKnownInitOptions = null; public XMLLanguageServer() { xmlTextDocumentService = new XMLTextDocumentService(this); xmlWorkspaceService = new XMLWorkspaceService(this); @@ -120,19 +122,47 @@ public XMLLanguageServer() { @Override public CompletableFuture initialize(InitializeParams params) { + boolean useAssociationSettings = true; try { - Path synapseSchemaPath = Utils.updateSynapseCatalogSettings(params); - synapseLanguageService.setSynapseXSDPath(synapseSchemaPath); + Object initOptionsForCheck = params.getInitializationOptions(); + if (initOptionsForCheck != null) { + com.google.gson.Gson gson = new com.google.gson.Gson(); + com.google.gson.JsonElement jsonElement = gson.toJsonTree(initOptionsForCheck); + if (jsonElement != null && jsonElement.isJsonObject() && jsonElement.getAsJsonObject().has("useAssociationSettings")) { + useAssociationSettings = jsonElement.getAsJsonObject().get("useAssociationSettings").getAsBoolean(); + } + } + + if (!useAssociationSettings) { + Path synapseSchemaPath = Utils.updateSynapseCatalogSettings(params); + LOGGER.info("Synapse schema path set to: " + synapseSchemaPath); + if (synapseSchemaPath != null) { + synapseLanguageService.setSynapseXSDPath(synapseSchemaPath); + } + } else { + workspaceSchemas = Utils.updateSynapseFileAssociationSettings(params); + if (!workspaceSchemas.isEmpty()) { + LOGGER.info("Loaded " + workspaceSchemas.size() + " workspace schemas"); + synapseLanguageService.setSynapseXSDPath(workspaceSchemas.values().iterator().next()); + } + } } catch (IOException | URISyntaxException e) { - LOGGER.log(Level.SEVERE, "Error while updating synapse catalog settings", e); + LOGGER.log(Level.SEVERE, "Error while updating synapse settings", e); } Object initOptions = InitializationOptionsSettings.getSettings(params); + this.lastKnownInitOptions = initOptions; Object xmlSettings = AllXMLSettings.getAllXMLSettings(initOptions); XMLGeneralClientSettings settings = XMLGeneralClientSettings.getGeneralXMLSettings(xmlSettings); LogHelper.initializeRootLogger(languageClient, settings == null ? null : settings.getLogs()); LOGGER.info("Initializing XML Language server" + System.lineSeparator() + Platform.details()); + + if (!useAssociationSettings) { + LOGGER.info("======== WE ARE USING CATALOG SETTINGS ========"); + } else { + LOGGER.info("======== WE ARE USING FILE ASSOCIATION SETTINGS ========"); + } this.parentProcessId = params.getProcessId(); @@ -190,12 +220,8 @@ private synchronized void updateSettings(Object initOptions, boolean initLogs) { if (initOptions == null) { return; } - try { - initOptions = Utils.updateSynapseCatalogSettings((JsonObject) initOptions, - synapseLanguageService.getSynapseXSDPath()); - } catch (IOException | URISyntaxException e) { - LOGGER.log(Level.SEVERE, "Error while updating synapse catalog settings", e); - } + this.lastKnownInitOptions = initOptions; + initOptions = Utils.updateSynapseFileAssociationSettings((JsonObject) initOptions, workspaceSchemas); // Update client settings Object initSettings = AllXMLSettings.getAllXMLSettings(initOptions); XMLGeneralClientSettings xmlClientSettings = XMLGeneralClientSettings.getGeneralXMLSettings(initSettings); @@ -258,6 +284,21 @@ private synchronized void updateSettings(Object initOptions, boolean initLogs) { xmlTextDocumentService.updateSettings(initSettings); } + public void addWorkspaceSchema(String folderUri, Path schemaDir) { + workspaceSchemas.put(folderUri, schemaDir); + } + + public void removeWorkspaceSchema(String folderUri) { + workspaceSchemas.remove(folderUri); + } + + public void triggerSettingsRefresh() { + if (lastKnownInitOptions != null) { + updateSettings(lastKnownInitOptions, false); + LOGGER.log(Level.WARNING, "Updated settings in Language Server with new workspace schemas: " + lastKnownInitOptions); + } + } + @Override public CompletableFuture shutdown() { xmlLanguageService.dispose(); diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java index f866168c8..3beabebd7 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLWorkspaceService.java @@ -18,6 +18,8 @@ import java.util.Map; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; import org.eclipse.lemminx.commons.WorkspaceFolders; import org.eclipse.lemminx.customservice.synapse.utils.Constant; @@ -39,6 +41,8 @@ */ public class XMLWorkspaceService implements WorkspaceService, IXMLCommandService { + private static final Logger log = Logger.getLogger(XMLWorkspaceService.class.getName()); + private final XMLLanguageServer xmlLanguageServer; private final WorkspaceFolders workspaceFolders; @@ -85,7 +89,30 @@ public void didChangeWorkspaceFolders(DidChangeWorkspaceFoldersParams params) { xmlLanguageServer.getXMLLanguageService().getWorkspaceServiceParticipants() .forEach(participant -> participant.didChangeWorkspaceFolders(params)); -// workspaceFolders.didChangeWorkspaceFolders(params); + boolean hasSchemaChanges = false; + if (params.getEvent().getRemoved() != null) { + for (org.eclipse.lsp4j.WorkspaceFolder folder : params.getEvent().getRemoved()) { + if (log.isLoggable(Level.FINE)) { + log.fine("Removing workspace folder: " + folder.getUri()); + } + xmlLanguageServer.removeWorkspaceSchema(folder.getUri()); + hasSchemaChanges = true; + } + } + if (params.getEvent().getAdded() != null) { + for (org.eclipse.lsp4j.WorkspaceFolder folder : params.getEvent().getAdded()) { + try { + java.nio.file.Path schemaDir = org.eclipse.lemminx.customservice.synapse.utils.Utils.copyXSDFiles(folder.getUri()); + xmlLanguageServer.addWorkspaceSchema(folder.getUri(), schemaDir); + hasSchemaChanges = true; + } catch (Exception e) { + log.log(Level.SEVERE, "Failed to copy XSD files for workspace folder: " + folder.getUri() + ". Error: " + e.getMessage()); + } + } + } + if (hasSchemaChanges) { + xmlLanguageServer.triggerSettingsRefresh(); + } } @Override diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Utils.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Utils.java index 6d7677653..6a3753ded 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Utils.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Utils.java @@ -22,7 +22,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import com.google.gson.JsonPrimitive; + import com.google.gson.JsonSyntaxException; import org.apache.commons.lang3.StringEscapeUtils; import org.apache.commons.lang3.StringUtils; @@ -49,8 +49,9 @@ import org.eclipse.lemminx.uriresolver.URIResolverExtensionManager; import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.WorkspaceFolder; import org.w3c.dom.Node; - +import com.google.gson.JsonPrimitive; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.BufferedReader; @@ -1070,6 +1071,81 @@ private static String extractJarPath(URL resourceURL) throws URISyntaxException return new File(uri).getAbsolutePath(); } + + + public static Map updateSynapseFileAssociationSettings(InitializeParams params) + throws IOException, URISyntaxException { + + logger.info("Updating Synapse file association settings"); + + List folderUris = new ArrayList<>(); + List workspaceFolders = params.getWorkspaceFolders(); + if (workspaceFolders != null && !workspaceFolders.isEmpty()) { + for (WorkspaceFolder folder : workspaceFolders) { + folderUris.add(folder.getUri()); + } + } + + Map workspaceSchemas = new HashMap<>(); + for (String folderUri : folderUris) { + String projectUri = getAbsolutePath(folderUri); + Path schemaDir = copyXSDFiles(projectUri); + workspaceSchemas.put(folderUri, schemaDir); + } + + Object initOptions = params.getInitializationOptions(); + Gson gson = new Gson(); + JsonElement jsonElement = gson.toJsonTree(initOptions); + if (jsonElement != null && jsonElement.isJsonObject() && jsonElement.getAsJsonObject().has(Constant.SETTINGS)) { + JsonObject settings = jsonElement.getAsJsonObject().getAsJsonObject(Constant.SETTINGS); + JsonElement updatedParams = updateSynapseFileAssociationSettings(settings, workspaceSchemas); + JsonObject updatedRoot = new JsonObject(); + updatedRoot.add(Constant.SETTINGS, updatedParams); + params.setInitializationOptions(updatedRoot); + } + + return workspaceSchemas; + } + + public static JsonElement updateSynapseFileAssociationSettings(JsonObject settings, + Map workspaceSchemas) { + + if (workspaceSchemas == null || workspaceSchemas.isEmpty()) { + return settings; + } + + JsonArray fileAssociationsArray = new JsonArray(); + for (Map.Entry entry : workspaceSchemas.entrySet()) { + String folderUri = entry.getKey(); + Path schemaDir = entry.getValue(); + Path xsdPath = schemaDir.resolve("synapse_config.xsd"); + + // Convert the folder URI to a filesystem path for the glob pattern, + String patternBase = folderUri; + try { + patternBase = Paths.get(new URI(folderUri)).toString().replace("\\", "/"); + } catch (Exception e) { + logger.warning("Failed to convert folder URI to filesystem path: " + folderUri); + patternBase = folderUri.replace("\\", "/"); + } + + JsonObject association = new JsonObject(); + association.addProperty("pattern", patternBase + "/**/*.xml"); + association.addProperty("systemId", xsdPath.toUri().toString()); + fileAssociationsArray.add(association); + } + + if (settings != null && settings.isJsonObject() && settings.has(Constant.XML)) { + JsonObject xmlObj = settings.getAsJsonObject(Constant.XML); + xmlObj.add("fileAssociations", fileAssociationsArray); + if (xmlObj.has(Constant.CATALOGS)) { + xmlObj.remove(Constant.CATALOGS); + } + } + + return settings; + } + public static Path updateSynapseCatalogSettings(InitializeParams params) throws IOException, URISyntaxException { String projectUri = params.getRootPath(); diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/contentmodel/CleanMultiRootValidationTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/contentmodel/CleanMultiRootValidationTest.java new file mode 100644 index 000000000..60d1c911e --- /dev/null +++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/extensions/contentmodel/CleanMultiRootValidationTest.java @@ -0,0 +1,373 @@ +/** + * Copyright (c) 2026 WSO2 LLC. (http://www.wso2.org). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.lemminx.extensions.contentmodel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.eclipse.lemminx.MockXMLLanguageServer; +import org.eclipse.lemminx.SynapseLanguageService; +import org.eclipse.lemminx.XMLLanguageServer; +import org.eclipse.lemminx.XMLTextDocumentService; +import org.eclipse.lemminx.customservice.SynapseLanguageClientAPI; +import org.eclipse.lemminx.customservice.synapse.connectors.ConnectorHolder; +import org.eclipse.lemminx.customservice.synapse.connectors.SchemaGenerate; +import org.eclipse.lemminx.customservice.synapse.connectors.entity.Connector; +import org.eclipse.lemminx.customservice.synapse.connectors.entity.ConnectorAction; +import org.eclipse.lemminx.customservice.synapse.connectors.entity.OperationParameter; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.InitializeParams; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; +import org.eclipse.lsp4j.TextDocumentItem; +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams; +import org.eclipse.lsp4j.WorkspaceFoldersChangeEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; + +/** + * Tests for multi-root workspace validation using the XML engine and + * file-association mechanism. + * + *

Test 1 ({@link #multiRootIsolation()}) verifies that a single + * {@code XMLLanguageService} instance validates two isolated projects + * (MI 4.3.0 and MI 4.4.0) using per-project XSD file associations.

+ * + *

Test 2 ({@link #dynamicConnectorSchemaUpdate()}) verifies that + * dynamically generated connector schemas are picked up by the validation + * engine without a server restart.

+ * + *

Test 3 ({@link #dynamicWorkspaceFolderAddition()}) verifies that + * when a user adds a new project to the workspace dynamically, the + * language server perfectly detects it and applies standard MI validations.

+ */ +public class CleanMultiRootValidationTest { + + private Path tempDirA; + private Path tempDirB; + + @BeforeEach + public void setUp() throws Exception { + tempDirA = Files.createTempDirectory("project-a"); + tempDirB = Files.createTempDirectory("project-b"); + tempDirA.toFile().deleteOnExit(); + tempDirB.toFile().deleteOnExit(); + + // Project A — MI 4.3.0 + String pomA = "" + + "4.3.0" + + ""; + Files.write(tempDirA.resolve("pom.xml"), pomA.getBytes(StandardCharsets.UTF_8)); + Files.createDirectories(tempDirA.resolve("src")); + + // Project B — MI 4.4.0 + String pomB = "" + + "4.4.0" + + ""; + Files.write(tempDirB.resolve("pom.xml"), pomB.getBytes(StandardCharsets.UTF_8)); + Files.createDirectories(tempDirB.resolve("src")); + } + + // --------------------------------------------------------------------------- + // Test 1 — One XMLLanguageService validates multiple projects + // --------------------------------------------------------------------------- + + @Test + public void multiRootIsolation() throws Exception { + MockXMLLanguageServer server = createMultiRootServer(); + injectSchemasManually(server, tempDirA.toUri().toString(), "430"); + injectSchemasManually(server, tempDirB.toUri().toString(), "440"); + + // The mediator was introduced in MI 4.4.0 — invalid in MI 4.3.0 + String xml = "\n" + + "\n" + + " \n" + + ""; + + // Open the same XML content under both project trees + String uriA = openDocument(server, tempDirA, "src/sequence.xml", xml, 1); + String uriB = openDocument(server, tempDirB, "src/sequence.xml", xml, 1); + + Thread.sleep(1500); + + // Retrieve published diagnostics + PublishDiagnosticsParams diagA = findDiagnosticsForUri(server.getPublishDiagnostics(), uriA); + PublishDiagnosticsParams diagB = findDiagnosticsForUri(server.getPublishDiagnostics(), uriB); + + assertNotNull(diagA, "Diagnostics for Project A missing"); + assertNotNull(diagB, "Diagnostics for Project B missing"); + + // Project A (4.3.0) — 'variable' is unknown → at least 1 error + assertEquals(1, diagA.getDiagnostics().size(), + "Project-A (4.3.0) should report 1 error for unknown 'variable' mediator"); + + // Project B (4.4.0) — 'variable' is valid → 0 errors + assertEquals(0, diagB.getDiagnostics().size(), + "Project-B (4.4.0) should report 0 errors for valid 'variable' mediator"); + } + + // --------------------------------------------------------------------------- + // Test 2 — Dynamically changed content (connector schema) is also validated + // --------------------------------------------------------------------------- + + @Test + public void dynamicConnectorSchemaUpdate() throws Exception { + MockXMLLanguageServer server = createMultiRootServer(); + injectSchemasManually(server, tempDirA.toUri().toString(), "430"); + + // 1. Open a document that uses the Salesforce connector BEFORE downloading the connector + String connectorXml = "\n" + + "\n" + + " \n" + + " Account\n" + + " \n" + + ""; + String uriA = openDocument(server, tempDirA, "src/sequence.xml", connectorXml, 1); + + Thread.sleep(1500); + + // 2. Verify it currently fails validation (because salesforce schema doesn't exist yet) + PublishDiagnosticsParams initialDiag = findDiagnosticsForUri(server.getPublishDiagnostics(), uriA); + assertNotNull(initialDiag, "Initial diagnostics should be present"); + assertTrue(initialDiag.getDiagnostics().size() > 0, + "Should report an error for unknown 'salesforce.create' mediator before connector is added"); + + // 3. Simulate downloading a Salesforce connector + ConnectorHolder holder = ConnectorHolder.getInstance(); + holder.clearConnectors(); + holder.addConnector(createFakeSalesforceConnector()); + + // 4. Generate the connector schema inside the workspace schema directory + @SuppressWarnings("unchecked") + Map resolvedSchemas = getWorkspaceSchemas(server); + Path schemaPathA = resolvedSchemas.get(tempDirA.toUri().toString()); + assertNotNull(schemaPathA, "Schema path for Project A should be resolved"); + + SchemaGenerate.generate(holder, + schemaPathA.resolve("mediators").resolve("connectors.xsd").toString()); + + // Verify generated schema physically contains the expected element + String schemaContent = Files.readString(schemaPathA.resolve("mediators").resolve("connectors.xsd")); + assertTrue(schemaContent.contains(""), + "Schema must contain salesforce.create element"); + + // 5. Send a didChange with the EXACT same XML content to forcefully trigger re-validation + server.getTextDocumentService().didChange(new DidChangeTextDocumentParams( + new VersionedTextDocumentIdentifier(uriA, 2), + List.of(new TextDocumentContentChangeEvent(connectorXml)))); + + Thread.sleep(1500); + + // 6. Verify that it now passes successfully with ZERO errors! + PublishDiagnosticsParams newDiagA = findDiagnosticsForUri(server.getPublishDiagnostics(), uriA); + assertNotNull(newDiagA, "Diagnostics for Project A missing after connector update"); + assertEquals(0, newDiagA.getDiagnostics().size(), + "'salesforce.create' should be perfectly recognized and pass after dynamic schema generation"); + } + + // --------------------------------------------------------------------------- + // Test 3 — Dynamically adding a new project connects the XML validation + // --------------------------------------------------------------------------- + + @Test + public void dynamicWorkspaceFolderAddition() throws Exception { + MockXMLLanguageServer server = createMultiRootServer(); + + // 1. Create a new dynamically added project: Project C (MI 4.3.0) + Path tempDirC = Files.createTempDirectory("project-c"); + tempDirC.toFile().deleteOnExit(); + String pomC = "" + + "4.3.0" + + ""; + Files.write(tempDirC.resolve("pom.xml"), pomC.getBytes(StandardCharsets.UTF_8)); + Files.createDirectories(tempDirC.resolve("src")); + + // 2. Simulate VS Code sending workspace/didChangeWorkspaceFolders for Project C + WorkspaceFolder wfC = new WorkspaceFolder(); + wfC.setUri(tempDirC.toUri().toString()); + wfC.setName("project-c"); + + WorkspaceFoldersChangeEvent event = new WorkspaceFoldersChangeEvent(); + event.setAdded(Arrays.asList(wfC)); + + server.getWorkspaceService().didChangeWorkspaceFolders(new DidChangeWorkspaceFoldersParams(event)); + + // Allow background schema copying to complete + Thread.sleep(1500); + injectSchemasManually(server, tempDirC.toUri().toString(), "430"); + + // 3. Open a file in the newly added project. + // Since it's MI 4.3.0, the mediator should be flagged as an error. + String xml = "\n" + + "\n" + + " \n" + + ""; + String uriC = openDocument(server, tempDirC, "src/sequence.xml", xml, 1); + + Thread.sleep(1500); + + // 4. Verify diagnostics for Project C + PublishDiagnosticsParams diagC = findDiagnosticsForUri(server.getPublishDiagnostics(), uriC); + assertNotNull(diagC, "Diagnostics for dynamically added Project C should be present"); + assertEquals(1, diagC.getDiagnostics().size(), + "Project C (4.3.0) should perfectly validate and report 1 error for unknown 'variable' mediator"); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Creates a {@link MockXMLLanguageServer} initialized with two workspace + * folders ({@code tempDirA} and {@code tempDirB}) and a mocked + * {@link SynapseLanguageService} whose {@code init()} is a no-op. + */ + private MockXMLLanguageServer createMultiRootServer() throws Exception { + MockXMLLanguageServer server = new MockXMLLanguageServer(); + + // Stub SynapseLanguageService to bypass its global init logic + XMLTextDocumentService tds = + (XMLTextDocumentService) server.getTextDocumentService(); + SynapseLanguageService stubSynapseService = new SynapseLanguageService(tds, server) { + @Override + public void init(String projectUri, Object settings, + SynapseLanguageClientAPI languageClient) { + // no-op + } + }; + + java.lang.reflect.Field synapseField = + XMLLanguageServer.class.getDeclaredField("synapseLanguageService"); + synapseField.setAccessible(true); + synapseField.set(server, stubSynapseService); + + // Build InitializeParams with both workspace folders + InitializeParams params = new InitializeParams(); + + WorkspaceFolder wfA = new WorkspaceFolder(); + wfA.setUri(tempDirA.toUri().toString()); + wfA.setName("project-a"); + + WorkspaceFolder wfB = new WorkspaceFolder(); + wfB.setUri(tempDirB.toUri().toString()); + wfB.setName("project-b"); + + params.setWorkspaceFolders(Arrays.asList(wfA, wfB)); + + JsonObject initOptions = new JsonObject(); + JsonObject settingsObj = new JsonObject(); + JsonObject xmlObj = new JsonObject(); + JsonObject validationObj = new JsonObject(); + validationObj.addProperty("noGrammar", "ignore"); + xmlObj.add("validation", validationObj); + settingsObj.add("xml", xmlObj); + initOptions.add("settings", settingsObj); + params.setInitializationOptions(initOptions); + + server.initialize(params).join(); + return server; + } + + /** + * Opens a document under the given project directory and returns the + * document URI. + */ + private static String openDocument(MockXMLLanguageServer server, + Path projectDir, String relativePath, String content, int version) { + String uri = projectDir.resolve(relativePath).toUri().toString(); + TextDocumentItem doc = new TextDocumentItem(uri, "xml", version, content); + server.getTextDocumentService().didOpen(new DidOpenTextDocumentParams(doc)); + return uri; + } + + /** + * Finds the last published diagnostics for the given URI. + */ + private static PublishDiagnosticsParams findDiagnosticsForUri( + List allDiagnostics, String uri) { + PublishDiagnosticsParams result = null; + for (PublishDiagnosticsParams p : allDiagnostics) { + if (p.getUri().equals(uri)) { + result = p; + } + } + return result; + } + + /** + * Creates a minimal fake Salesforce connector with a single + * {@code salesforce.create} action for testing purposes. + */ + private static Connector createFakeSalesforceConnector() { + Connector connector = new Connector(); + connector.setName("salesforce"); + connector.setDisplayName("Salesforce Connector"); + + ConnectorAction action = new ConnectorAction(); + action.setTag("salesforce.create"); + action.setHidden(false); + + OperationParameter param = new OperationParameter("sobjectType", "Type of SObject"); + action.setParameters(List.of(param)); + connector.setActions(List.of(action)); + + return connector; + } + + /** + * Reflectively accesses the {@code workspaceSchemas} field to obtain the + * per-project schema directories resolved during initialization. + */ + @SuppressWarnings("unchecked") + private static Map getWorkspaceSchemas( + MockXMLLanguageServer server) throws Exception { + java.lang.reflect.Field field = + XMLLanguageServer.class.getDeclaredField("workspaceSchemas"); + field.setAccessible(true); + return (Map) field.get(server); + } + + /** + * A manual schema injector. Since production Utils removed the 'file' + * protocol extractor, we manually copy the schemas directly from the + * resources directory into the empty dynamically generated workspace schemas. + */ + private void injectSchemasManually(MockXMLLanguageServer server, String projectUri, String versionFolder) throws Exception { + Map resolvedSchemas = getWorkspaceSchemas(server); + Path schemaTarget = resolvedSchemas.get(projectUri); + if (schemaTarget != null) { + Path sourceDirectory = java.nio.file.Paths.get("src", "main", "resources", "org", "eclipse", "lemminx", "schemas", versionFolder); + if (Files.exists(sourceDirectory)) { + Files.walkFileTree(sourceDirectory, new java.nio.file.SimpleFileVisitor() { + @Override + public java.nio.file.FileVisitResult visitFile(Path file, java.nio.file.attribute.BasicFileAttributes attrs) throws java.io.IOException { + Path targetFile = schemaTarget.resolve(sourceDirectory.relativize(file)); + Files.createDirectories(targetFile.getParent()); + Files.copy(file, targetFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + return java.nio.file.FileVisitResult.CONTINUE; + } + }); + } + } + } +}