StructurizrDslParser.java

package com.taxonomy.catalog.service.importer;

import com.taxonomy.dsl.mapping.ExternalElement;
import com.taxonomy.dsl.mapping.ExternalRelation;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Parses Structurizr DSL files into {@link ExternalElement}s and {@link ExternalRelation}s.
 *
 * <p>Supports the core Structurizr DSL syntax:
 * <pre>
 * workspace {
 *   model {
 *     user = person "User" "A user"
 *     sys = softwareSystem "My System" {
 *       webapp = container "Web App" "Main frontend" "React"
 *       api = container "API" "REST backend" "Spring Boot" {
 *         ctrl = component "Controller" "Handles requests" "Java"
 *       }
 *     }
 *     user -> sys "Uses"
 *   }
 * }
 * </pre>
 */
public class StructurizrDslParser implements ExternalParser {

    /** Pattern: identifier = elementType "name" ["description"] ["technology"] */
    private static final Pattern ELEMENT_PATTERN = Pattern.compile(
            "^\\s*(\\w+)\\s*=\\s*(person|softwareSystem|softwaresystem|container|component|deploymentNode|infrastructureNode|containerInstance)\\s+\"([^\"]*)\"(?:\\s+\"([^\"]*)\")?(?:\\s+\"([^\"]*)\")?",
            Pattern.CASE_INSENSITIVE
    );

    /** Pattern: source -> target "description" ["technology"] */
    private static final Pattern RELATION_PATTERN = Pattern.compile(
            "^\\s*(\\w+)\\s*->\\s*(\\w+)\\s+\"([^\"]*)\"(?:\\s+\"([^\"]*)\")?");

    @Override
    public String fileFormat() {
        return "dsl";
    }

    @Override
    public ParsedExternalModel parse(InputStream input) throws Exception {
        List<ExternalElement> elements = new ArrayList<>();
        List<ExternalRelation> relations = new ArrayList<>();
        Map<String, String> identifierTypes = new LinkedHashMap<>();

        // Track container context for nesting
        Deque<String> containerStack = new ArrayDeque<>();

        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(input, StandardCharsets.UTF_8))) {

            String line;
            while ((line = reader.readLine()) != null) {
                String trimmed = line.trim();

                // Skip comments and empty lines
                if (trimmed.isEmpty() || trimmed.startsWith("//") || trimmed.startsWith("#")) {
                    continue;
                }

                // Check for element definition
                Matcher elemMatcher = ELEMENT_PATTERN.matcher(trimmed);
                if (elemMatcher.find()) {
                    String id = elemMatcher.group(1);
                    String rawType = elemMatcher.group(2);
                    String name = elemMatcher.group(3);
                    String description = elemMatcher.group(4);
                    String technology = elemMatcher.group(5);

                    String normalizedType = normalizeType(rawType);
                    identifierTypes.put(id, normalizedType);

                    Map<String, String> props = new LinkedHashMap<>();
                    if (technology != null && !technology.isEmpty()) {
                        props.put("technology", technology);
                    }

                    elements.add(new ExternalElement(id, normalizedType, name, description, props));

                    // Create CONTAINS relation if inside a container
                    if (!containerStack.isEmpty()) {
                        String parentId = containerStack.peek();
                        relations.add(new ExternalRelation(parentId, id, "Contains", Map.of()));
                    }

                    // If line ends with {, push onto container stack
                    if (trimmed.endsWith("{")) {
                        containerStack.push(id);
                    }
                    continue;
                }

                // Check for relationship definition
                Matcher relMatcher = RELATION_PATTERN.matcher(trimmed);
                if (relMatcher.find()) {
                    String sourceId = relMatcher.group(1);
                    String targetId = relMatcher.group(2);
                    String description = relMatcher.group(3);
                    String technology = relMatcher.group(4);

                    // Determine relation type from description
                    String relType = inferRelationType(description);
                    Map<String, String> props = new LinkedHashMap<>();
                    if (technology != null && !technology.isEmpty()) {
                        props.put("technology", technology);
                    }
                    if (description != null && !description.isEmpty()) {
                        props.put("description", description);
                    }

                    relations.add(new ExternalRelation(sourceId, targetId, relType, props));
                    continue;
                }

                // Track braces for container context
                if (trimmed.endsWith("{") && !containerStack.isEmpty()) {
                    // Block opening that's not an element definition — could be a section like "model {" or "views {"
                    // Don't push unless it's an element
                } else if (trimmed.equals("}")) {
                    if (!containerStack.isEmpty()) {
                        containerStack.pop();
                    }
                }
            }
        }

        return new ParsedExternalModel(elements, relations);
    }

    private String normalizeType(String rawType) {
        return switch (rawType.toLowerCase(Locale.ROOT)) {
            case "person" -> "Person";
            case "softwaresystem" -> "SoftwareSystem";
            case "container" -> "Container";
            case "component" -> "Component";
            case "deploymentnode" -> "DeploymentNode";
            case "infrastructurenode" -> "InfrastructureNode";
            case "containerinstance" -> "ContainerInstance";
            default -> rawType;
        };
    }

    private String inferRelationType(String description) {
        if (description == null) return "Uses";
        String lower = description.toLowerCase(Locale.ROOT);
        if (lower.contains("depends")) return "DependsOn";
        if (lower.contains("delivers") || lower.contains("sends")) return "Delivers";
        if (lower.contains("reads") || lower.contains("fetches")) return "Uses";
        if (lower.contains("writes") || lower.contains("stores")) return "Delivers";
        return "Uses";
    }
}