1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.databind.codegen;
7   
8   import java.net.URI;
9   import java.net.URISyntaxException;
10  import java.util.ArrayList;
11  import java.util.List;
12  import java.util.Locale;
13  import java.util.Map;
14  import java.util.Set;
15  
16  import dev.metaschema.core.util.ObjectUtils;
17  import edu.umd.cs.findbugs.annotations.NonNull;
18  
19  /**
20   * A variety of utility methods for normalizing Java class related names.
21   */
22  public final class ClassUtils {
23    private static final Map<String, String> JAVA_NAME_MAPPER = Map.ofEntries(
24        Map.entry("Class", "Clazz"));
25  
26    private ClassUtils() {
27      // disable construction
28    }
29  
30    /**
31     * Transforms the provided name into a string suitable for use as a Java
32     * property name.
33     *
34     * @param name
35     *          the name of an information element definition
36     * @return a Java property name
37     */
38    @SuppressWarnings("null")
39    @NonNull
40    public static String toPropertyName(@NonNull String name) {
41      String property = upperCamelCase(name);
42      return JAVA_NAME_MAPPER.getOrDefault(property, property);
43    }
44  
45    /**
46     * Transforms the provided name into a string suitable for use as a Java
47     * variable name.
48     *
49     * @param name
50     *          the name of an information element definition
51     * @return a Java variable name
52     */
53    @NonNull
54    public static String toVariableName(@NonNull String name) {
55      return lowerCamelCase(name);
56    }
57  
58    /**
59     * Transforms the provided name into a string suitable for use as a Java class
60     * name.
61     *
62     * @param name
63     *          the name of an information element definition
64     * @return a Java variable name
65     */
66    @NonNull
67    public static String toClassName(@NonNull String name) {
68      return upperCamelCase(name);
69    }
70  
71    /**
72     * Transforms the provided name into a string suitable for use as a Java package
73     * name.
74     *
75     * @param namespace
76     *          a namespace URI to convert to a package name
77     * @return a Java package name
78     */
79    @NonNull
80    public static String toPackageName(@NonNull String namespace) {
81      return getPackageFromNamespace(namespace);
82    }
83  
84    /**
85     * Converts a string to UpperCamelCase.
86     * <p>
87     * Splits on common separators (hyphen, underscore, period, whitespace) and
88     * capitalizes the first letter of each word.
89     *
90     * @param name
91     *          the name to convert
92     * @return the name in UpperCamelCase
93     */
94    @NonNull
95    private static String upperCamelCase(@NonNull String name) {
96      List<String> words = splitWords(name);
97      StringBuilder sb = new StringBuilder();
98      for (String word : words) {
99        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 }