001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package dev.metaschema.databind.codegen;
007
008import java.net.URI;
009import java.net.URISyntaxException;
010import java.util.ArrayList;
011import java.util.List;
012import java.util.Locale;
013import java.util.Map;
014import java.util.Set;
015
016import dev.metaschema.core.util.ObjectUtils;
017import edu.umd.cs.findbugs.annotations.NonNull;
018
019/**
020 * A variety of utility methods for normalizing Java class related names.
021 */
022public final class ClassUtils {
023  private static final Map<String, String> JAVA_NAME_MAPPER = Map.ofEntries(
024      Map.entry("Class", "Clazz"));
025
026  private ClassUtils() {
027    // disable construction
028  }
029
030  /**
031   * Transforms the provided name into a string suitable for use as a Java
032   * property name.
033   *
034   * @param name
035   *          the name of an information element definition
036   * @return a Java property name
037   */
038  @SuppressWarnings("null")
039  @NonNull
040  public static String toPropertyName(@NonNull String name) {
041    String property = upperCamelCase(name);
042    return JAVA_NAME_MAPPER.getOrDefault(property, property);
043  }
044
045  /**
046   * Transforms the provided name into a string suitable for use as a Java
047   * variable name.
048   *
049   * @param name
050   *          the name of an information element definition
051   * @return a Java variable name
052   */
053  @NonNull
054  public static String toVariableName(@NonNull String name) {
055    return lowerCamelCase(name);
056  }
057
058  /**
059   * Transforms the provided name into a string suitable for use as a Java class
060   * name.
061   *
062   * @param name
063   *          the name of an information element definition
064   * @return a Java variable name
065   */
066  @NonNull
067  public static String toClassName(@NonNull String name) {
068    return upperCamelCase(name);
069  }
070
071  /**
072   * Transforms the provided name into a string suitable for use as a Java package
073   * name.
074   *
075   * @param namespace
076   *          a namespace URI to convert to a package name
077   * @return a Java package name
078   */
079  @NonNull
080  public static String toPackageName(@NonNull String namespace) {
081    return getPackageFromNamespace(namespace);
082  }
083
084  /**
085   * Converts a string to UpperCamelCase.
086   * <p>
087   * Splits on common separators (hyphen, underscore, period, whitespace) and
088   * capitalizes the first letter of each word.
089   *
090   * @param name
091   *          the name to convert
092   * @return the name in UpperCamelCase
093   */
094  @NonNull
095  private static String upperCamelCase(@NonNull String name) {
096    List<String> words = splitWords(name);
097    StringBuilder sb = new StringBuilder();
098    for (String word : words) {
099      if (!word.isEmpty()) {
100        sb.append(Character.toUpperCase(word.charAt(0)));
101        if (word.length() > 1) {
102          sb.append(word.substring(1).toLowerCase(Locale.ROOT));
103        }
104      }
105    }
106    return sb.length() > 0 ? ObjectUtils.notNull(sb.toString()) : name;
107  }
108
109  /**
110   * Converts a string to lowerCamelCase.
111   * <p>
112   * Splits on common separators (hyphen, underscore, period, whitespace) and
113   * capitalizes the first letter of each word except the first.
114   *
115   * @param name
116   *          the name to convert
117   * @return the name in lowerCamelCase
118   */
119  @NonNull
120  private static String lowerCamelCase(@NonNull String name) {
121    List<String> words = splitWords(name);
122    StringBuilder sb = new StringBuilder();
123    boolean first = true;
124    for (String word : words) {
125      if (!word.isEmpty()) {
126        if (first) {
127          sb.append(word.toLowerCase(Locale.ROOT));
128          first = false;
129        } else {
130          sb.append(Character.toUpperCase(word.charAt(0)));
131          if (word.length() > 1) {
132            sb.append(word.substring(1).toLowerCase(Locale.ROOT));
133          }
134        }
135      }
136    }
137    return sb.length() > 0 ? ObjectUtils.notNull(sb.toString()) : name;
138  }
139
140  /**
141   * Splits a name into words based on common separators.
142   * <p>
143   * Handles hyphens, underscores, periods, and whitespace as word separators.
144   * Also splits on camelCase boundaries.
145   *
146   * @param name
147   *          the name to split
148   * @return a list of words
149   */
150  @NonNull
151  private static List<String> splitWords(@NonNull String name) {
152    List<String> words = new ArrayList<>();
153    StringBuilder currentWord = new StringBuilder();
154
155    for (int i = 0; i < name.length(); i++) {
156      char ch = name.charAt(i);
157      if (ch == '-' || ch == '_' || ch == '.' || Character.isWhitespace(ch)) {
158        // Separator found, end current word
159        if (currentWord.length() > 0) {
160          words.add(currentWord.toString());
161          currentWord = new StringBuilder();
162        }
163      } else if (Character.isUpperCase(ch) && currentWord.length() > 0
164          && Character.isLowerCase(currentWord.charAt(currentWord.length() - 1))) {
165        // CamelCase boundary
166        words.add(currentWord.toString());
167        currentWord = new StringBuilder();
168        currentWord.append(ch);
169      } else {
170        currentWord.append(ch);
171      }
172    }
173    if (currentWord.length() > 0) {
174      words.add(currentWord.toString());
175    }
176    return words;
177  }
178
179  /**
180   * Converts a namespace URI to a Java package name.
181   * <p>
182   * Based on the standard URI to package name algorithm:
183   * <ol>
184   * <li>Extract the host and reverse it (e.g., "csrc.nist.gov" becomes
185   * "gov.nist.csrc")</li>
186   * <li>Append the path segments, replacing separators with dots</li>
187   * <li>Remove or escape invalid package name characters</li>
188   * </ol>
189   *
190   * @param namespace
191   *          the namespace URI
192   * @return a valid Java package name
193   */
194  @NonNull
195  private static String getPackageFromNamespace(@NonNull String namespace) {
196    try {
197      URI uri = new URI(namespace);
198      StringBuilder sb = new StringBuilder();
199
200      // Process the host (reverse the domain name)
201      String host = uri.getHost();
202      if (host != null && !host.isEmpty()) {
203        String[] hostParts = host.split("\\.");
204        for (int i = hostParts.length - 1; i >= 0; i--) {
205          if (sb.length() > 0) {
206            sb.append('.');
207          }
208          sb.append(normalizePackagePart(ObjectUtils.notNull(hostParts[i])));
209        }
210      }
211
212      // Process the path
213      String path = uri.getPath();
214      if (path != null && !path.isEmpty()) {
215        String[] pathParts = path.split("/");
216        for (String part : pathParts) {
217          if (!part.isEmpty()) {
218            if (sb.length() > 0) {
219              sb.append('.');
220            }
221            sb.append(normalizePackagePart(part));
222          }
223        }
224      }
225
226      return sb.length() > 0 ? ObjectUtils.notNull(sb.toString()) : "generated";
227    } catch (@SuppressWarnings("unused") URISyntaxException ex) {
228      // Fall back to a simple approach if URI parsing fails
229      return normalizePackagePart(ObjectUtils.notNull(namespace.replaceAll("[^a-zA-Z0-9]", "_")));
230    }
231  }
232
233  /**
234   * Normalizes a string to be a valid Java package name part.
235   * <p>
236   * Replaces or removes invalid characters and ensures the result is a valid
237   * identifier.
238   *
239   * @param part
240   *          the package name part to normalize
241   * @return a valid package name part
242   */
243  @NonNull
244  private static String normalizePackagePart(@NonNull String part) {
245    if (part.isEmpty()) {
246      return "_";
247    }
248
249    StringBuilder sb = new StringBuilder();
250    for (int i = 0; i < part.length(); i++) {
251      char ch = part.charAt(i);
252      if (i == 0) {
253        if (Character.isJavaIdentifierStart(ch)) {
254          sb.append(Character.toLowerCase(ch));
255        } else if (Character.isDigit(ch)) {
256          sb.append('_');
257          sb.append(ch);
258        } else {
259          sb.append('_');
260        }
261      } else {
262        if (Character.isJavaIdentifierPart(ch)) {
263          sb.append(Character.toLowerCase(ch));
264        } else if (ch == '-' || ch == '.') {
265          // Common separators become underscores
266          sb.append('_');
267        }
268        // else skip invalid characters
269      }
270    }
271
272    String result = ObjectUtils.notNull(sb.toString());
273    // Handle Java reserved words
274    if (isJavaReservedWord(result)) {
275      return "_" + result;
276    }
277    return result.isEmpty() ? "_" : result;
278  }
279
280  /**
281   * Java reserved words and contextual keywords that cannot be used as
282   * identifiers.
283   * <p>
284   * Includes both traditional reserved words and contextual keywords introduced
285   * in later Java versions (var, yield, record, sealed, permits) for forward
286   * compatibility.
287   */
288  private static final Set<String> JAVA_RESERVED_WORDS = Set.of(
289      "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", "const",
290      "continue", "default", "do", "double", "else", "enum", "extends", "final", "finally", "float",
291      "for", "goto", "if", "implements", "import", "instanceof", "int", "interface", "long", "native",
292      "new", "package", "private", "protected", "public", "return", "short", "static", "strictfp",
293      "super", "switch", "synchronized", "this", "throw", "throws", "transient", "try", "void",
294      "volatile", "while", "true", "false", "null",
295      // Contextual keywords (Java 9+)
296      "module", "var", "yield", "record", "sealed", "permits");
297
298  /**
299   * Checks if the given string is a Java reserved word.
300   *
301   * @param word
302   *          the word to check
303   * @return {@code true} if the word is a Java reserved word
304   */
305  private static boolean isJavaReservedWord(@NonNull String word) {
306    return JAVA_RESERVED_WORDS.contains(word);
307  }
308}