View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.cling.invoker.mvnup.goals;
20  
21  import java.util.HashMap;
22  import java.util.Iterator;
23  import java.util.List;
24  import java.util.Map;
25  import java.util.Set;
26  
27  import org.codehaus.plexus.util.StringUtils;
28  import org.jdom2.Content;
29  import org.jdom2.Element;
30  import org.jdom2.Namespace;
31  import org.jdom2.Parent;
32  import org.jdom2.Text;
33  
34  import static java.util.Arrays.asList;
35  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.Indentation;
36  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.ARTIFACT_ID;
37  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.BUILD;
38  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.CI_MANAGEMENT;
39  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.CLASSIFIER;
40  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.CONFIGURATION;
41  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.CONTRIBUTORS;
42  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.DEFAULT_GOAL;
43  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.DEPENDENCIES;
44  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.DEPENDENCY;
45  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.DEPENDENCY_MANAGEMENT;
46  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.DESCRIPTION;
47  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.DEVELOPERS;
48  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.DIRECTORY;
49  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.DISTRIBUTION_MANAGEMENT;
50  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.EXCLUSIONS;
51  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.EXECUTIONS;
52  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.EXTENSIONS;
53  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.FINAL_NAME;
54  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.GOALS;
55  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.GROUP_ID;
56  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.INCEPTION_YEAR;
57  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.INHERITED;
58  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.ISSUE_MANAGEMENT;
59  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.LICENSES;
60  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.MAILING_LISTS;
61  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.MODEL_VERSION;
62  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.MODULES;
63  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.NAME;
64  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.OPTIONAL;
65  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.ORGANIZATION;
66  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.OUTPUT_DIRECTORY;
67  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PACKAGING;
68  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PARENT;
69  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PLUGIN;
70  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PLUGINS;
71  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PLUGIN_MANAGEMENT;
72  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PLUGIN_REPOSITORIES;
73  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PREREQUISITES;
74  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PROFILES;
75  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PROPERTIES;
76  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.REPORTING;
77  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.REPOSITORIES;
78  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.SCM;
79  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.SCOPE;
80  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.SCRIPT_SOURCE_DIRECTORY;
81  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.SOURCE_DIRECTORY;
82  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.SYSTEM_PATH;
83  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.TEST_OUTPUT_DIRECTORY;
84  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.TEST_SOURCE_DIRECTORY;
85  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.TYPE;
86  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.URL;
87  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.VERSION;
88  import static org.jdom2.filter.Filters.textOnly;
89  
90  /**
91   * Utility class for JDOM operations.
92   */
93  public class JDomUtils {
94  
95      // Element ordering configuration
96      private static final Map<String, List<String>> ELEMENT_ORDER = new HashMap<>();
97  
98      static {
99          // Project element order
100         ELEMENT_ORDER.put(
101                 "project",
102                 asList(
103                         MODEL_VERSION,
104                         "",
105                         PARENT,
106                         "",
107                         GROUP_ID,
108                         ARTIFACT_ID,
109                         VERSION,
110                         PACKAGING,
111                         "",
112                         NAME,
113                         DESCRIPTION,
114                         URL,
115                         INCEPTION_YEAR,
116                         ORGANIZATION,
117                         LICENSES,
118                         "",
119                         DEVELOPERS,
120                         CONTRIBUTORS,
121                         "",
122                         MAILING_LISTS,
123                         "",
124                         PREREQUISITES,
125                         "",
126                         MODULES,
127                         "",
128                         SCM,
129                         ISSUE_MANAGEMENT,
130                         CI_MANAGEMENT,
131                         DISTRIBUTION_MANAGEMENT,
132                         "",
133                         PROPERTIES,
134                         "",
135                         DEPENDENCY_MANAGEMENT,
136                         DEPENDENCIES,
137                         "",
138                         REPOSITORIES,
139                         PLUGIN_REPOSITORIES,
140                         "",
141                         BUILD,
142                         "",
143                         REPORTING,
144                         "",
145                         PROFILES));
146 
147         // Build element order
148         ELEMENT_ORDER.put(
149                 BUILD,
150                 asList(
151                         DEFAULT_GOAL,
152                         DIRECTORY,
153                         FINAL_NAME,
154                         SOURCE_DIRECTORY,
155                         SCRIPT_SOURCE_DIRECTORY,
156                         TEST_SOURCE_DIRECTORY,
157                         OUTPUT_DIRECTORY,
158                         TEST_OUTPUT_DIRECTORY,
159                         EXTENSIONS,
160                         "",
161                         PLUGIN_MANAGEMENT,
162                         PLUGINS));
163 
164         // Plugin element order
165         ELEMENT_ORDER.put(
166                 PLUGIN,
167                 asList(
168                         GROUP_ID,
169                         ARTIFACT_ID,
170                         VERSION,
171                         EXTENSIONS,
172                         EXECUTIONS,
173                         DEPENDENCIES,
174                         GOALS,
175                         INHERITED,
176                         CONFIGURATION));
177 
178         // Dependency element order
179         ELEMENT_ORDER.put(
180                 DEPENDENCY,
181                 asList(GROUP_ID, ARTIFACT_ID, VERSION, CLASSIFIER, TYPE, SCOPE, SYSTEM_PATH, OPTIONAL, EXCLUSIONS));
182     }
183 
184     private JDomUtils() {
185         // noop
186     }
187 
188     /**
189      * Inserts a new child element to the given root element. The position where the element is inserted is calculated
190      * using the element order configuration. When no order is defined for the element, the new element is append as
191      * last element (before the closing tag of the root element). In the root element, the new element is always
192      * prepended by a text element containing a linebreak followed by the indentation characters. The indentation
193      * characters are (tried to be) detected from the root element (see {@link #detectIndentation(Element)} ).
194      *
195      * @param name the name of the new element.
196      * @param root the root element.
197      * @return the new element.
198      */
199     public static Element insertNewElement(String name, Element root) {
200         return insertNewElement(name, root, calcNewElementIndex(name, root));
201     }
202 
203     /**
204      * Inserts a new child element to the given root element at the given index.
205      * For details see {@link #insertNewElement(String, Element)}
206      *
207      * @param name  the name of the new element.
208      * @param root  the root element.
209      * @param index the index where the element should be inserted.
210      * @return the new element.
211      */
212     public static Element insertNewElement(String name, Element root, int index) {
213         String indent = detectIndentation(root);
214         Element newElement = createElement(name, root.getNamespace());
215 
216         // If the parent element only has minimal content (just closing tag indentation),
217         // we need to handle it specially to avoid creating whitespace-only lines
218         boolean parentHasMinimalContent = root.getContentSize() == 1
219                 && root.getContent(0) instanceof Text
220                 && ((Text) root.getContent(0)).getText().trim().isEmpty();
221 
222         if (parentHasMinimalContent) {
223             // Remove the minimal content and let addAppropriateSpacing handle the formatting
224             root.removeContent();
225             index = 0; // Reset index since we removed content
226         }
227 
228         root.addContent(index, newElement);
229         addAppropriateSpacing(root, index, name, indent);
230 
231         // Ensure both the parent and new element have proper closing tag formatting
232         ensureProperClosingTagFormatting(root);
233         ensureProperClosingTagFormatting(newElement);
234 
235         return newElement;
236     }
237 
238     /**
239      * Creates a new element with proper formatting.
240      * This method ensures that both the opening and closing tags are properly indented.
241      */
242     private static Element createElement(String name, Namespace namespace) {
243         Element newElement = new Element(name, namespace);
244 
245         // Add minimal content to prevent self-closing tag and ensure proper formatting
246         // This will be handled by ensureProperClosingTagFormatting
247         newElement.addContent(new Text(""));
248 
249         return newElement;
250     }
251 
252     /**
253      * Adds appropriate spacing before the inserted element.
254      */
255     private static void addAppropriateSpacing(Element root, int index, String elementName, String indent) {
256         // Find the preceding element name for spacing logic
257         String prependingElementName = "";
258         if (index > 0) {
259             Content prevContent = root.getContent(index - 1);
260             if (prevContent instanceof Element) {
261                 prependingElementName = ((Element) prevContent).getName();
262             }
263         }
264 
265         if (isBlankLineBetweenElements(prependingElementName, elementName, root)) {
266             // Add a completely empty line followed by proper indentation
267             // We need to be careful to ensure the empty line has no spaces
268             root.addContent(index, new Text("\n")); // End current line
269             root.addContent(index + 1, new Text("\n" + indent)); // Empty line + indentation for next element
270         } else {
271             root.addContent(index, new Text("\n" + indent));
272         }
273     }
274 
275     /**
276      * Ensures that the parent element has proper closing tag formatting.
277      * This method checks if the last content of the element is properly indented
278      * and adds appropriate whitespace if needed.
279      */
280     private static void ensureProperClosingTagFormatting(Element parent) {
281         List<Content> contents = parent.getContent();
282 
283         // Get the parent's indentation level
284         String parentIndent = detectParentIndentation(parent);
285 
286         // If the element is empty or only contains empty text nodes, handle it specially
287         if (contents.isEmpty()
288                 || (contents.size() == 1
289                         && contents.get(0) instanceof Text
290                         && ((Text) contents.get(0)).getText().trim().isEmpty())) {
291             // For empty elements, add minimal content to ensure proper formatting
292             // We add just a newline and parent indentation, which will be the closing tag line
293             parent.removeContent();
294             parent.addContent(new Text("\n" + parentIndent));
295             return;
296         }
297 
298         // Check if the last content is a Text node with proper indentation
299         Content lastContent = contents.get(contents.size() - 1);
300         if (lastContent instanceof Text) {
301             String text = ((Text) lastContent).getText();
302             // If the last text doesn't end with proper indentation for the closing tag
303             if (!text.endsWith("\n" + parentIndent)) {
304                 // If it's only whitespace, replace it; otherwise append
305                 if (text.trim().isEmpty()) {
306                     parent.removeContent(lastContent);
307                     parent.addContent(new Text("\n" + parentIndent));
308                 } else {
309                     // Append proper indentation
310                     parent.addContent(new Text("\n" + parentIndent));
311                 }
312             }
313         } else {
314             // If the last content is not a text node, add proper indentation for closing tag
315             parent.addContent(new Text("\n" + parentIndent));
316         }
317     }
318 
319     /**
320      * Detects the indentation level of the parent element.
321      */
322     private static String detectParentIndentation(Element element) {
323         Parent parent = element.getParent();
324         if (parent instanceof Element) {
325             return detectIndentation((Element) parent);
326         }
327         return "";
328     }
329 
330     /**
331      * Inserts a new content element with the given name and text content.
332      *
333      * @param parent the parent element
334      * @param name the name of the new element
335      * @param content the text content
336      * @return the new element
337      */
338     public static Element insertContentElement(Element parent, String name, String content) {
339         Element element = insertNewElement(name, parent);
340         element.setText(content);
341         return element;
342     }
343 
344     /**
345      * Detects the indentation used for a given element by analyzing its parent's content.
346      * This method examines the whitespace preceding the element to determine the indentation pattern.
347      * It supports different indentation styles (2 spaces, 4 spaces, tabs, etc.).
348      *
349      * @param element the element to analyze
350      * @return the detected indentation or a default indentation if none can be detected.
351      */
352     public static String detectIndentation(Element element) {
353         // First try to detect from the current element
354         for (Iterator<Text> iterator = element.getContent(textOnly()).iterator(); iterator.hasNext(); ) {
355             String text = iterator.next().getText();
356             int lastLsIndex = StringUtils.lastIndexOfAny(text, new String[] {"\n", "\r"});
357             if (lastLsIndex > -1) {
358                 String indent = text.substring(lastLsIndex + 1);
359                 if (iterator.hasNext()) {
360                     // This should be the indentation of a child element.
361                     return indent;
362                 } else {
363                     // This should be the indentation of the elements end tag.
364                     String baseIndent = detectBaseIndentationUnit(element);
365                     return indent + baseIndent;
366                 }
367             }
368         }
369 
370         Parent parent = element.getParent();
371         if (parent instanceof Element) {
372             String baseIndent = detectBaseIndentationUnit(element);
373             return detectIndentation((Element) parent) + baseIndent;
374         }
375 
376         return "";
377     }
378 
379     /**
380      * Detects the base indentation unit used in the document by analyzing indentation patterns.
381      * This method traverses the document tree to find the most common indentation style.
382      *
383      * @param element any element in the document to analyze
384      * @return the detected base indentation unit (e.g., "  ", "    ", "\t")
385      */
386     public static String detectBaseIndentationUnit(Element element) {
387         // Find the root element to analyze the entire document
388         Element root = element;
389         while (root.getParent() instanceof Element) {
390             root = (Element) root.getParent();
391         }
392 
393         // Collect indentation samples from the document
394         Map<String, Integer> indentationCounts = new HashMap<>();
395         collectIndentationSamples(root, indentationCounts, "");
396 
397         // Analyze the collected samples to determine the base unit
398         return analyzeIndentationPattern(indentationCounts);
399     }
400 
401     /**
402      * Recursively collects indentation samples from the document tree.
403      */
404     private static void collectIndentationSamples(
405             Element element, Map<String, Integer> indentationCounts, String parentIndent) {
406         for (Iterator<Text> iterator = element.getContent(textOnly()).iterator(); iterator.hasNext(); ) {
407             String text = iterator.next().getText();
408             int lastLsIndex = StringUtils.lastIndexOfAny(text, new String[] {"\n", "\r"});
409             if (lastLsIndex > -1) {
410                 String indent = text.substring(lastLsIndex + 1);
411                 if (iterator.hasNext() && !indent.isEmpty()) {
412                     // This is indentation before a child element
413                     if (indent.length() > parentIndent.length()) {
414                         String indentDiff = indent.substring(parentIndent.length());
415                         indentationCounts.merge(indentDiff, 1, Integer::sum);
416                     }
417                 }
418             }
419         }
420 
421         // Recursively analyze child elements
422         for (Element child : element.getChildren()) {
423             String childIndent = detectIndentationForElement(element, child);
424             if (childIndent != null && childIndent.length() > parentIndent.length()) {
425                 String indentDiff = childIndent.substring(parentIndent.length());
426                 indentationCounts.merge(indentDiff, 1, Integer::sum);
427                 collectIndentationSamples(child, indentationCounts, childIndent);
428             }
429         }
430     }
431 
432     /**
433      * Detects the indentation used for a specific child element.
434      */
435     private static String detectIndentationForElement(Element parent, Element child) {
436         int childIndex = parent.indexOf(child);
437         if (childIndex > 0) {
438             Content prevContent = parent.getContent(childIndex - 1);
439             if (prevContent instanceof Text) {
440                 String text = ((Text) prevContent).getText();
441                 int lastLsIndex = StringUtils.lastIndexOfAny(text, new String[] {"\n", "\r"});
442                 if (lastLsIndex > -1) {
443                     return text.substring(lastLsIndex + 1);
444                 }
445             }
446         }
447         return null;
448     }
449 
450     /**
451      * Analyzes the collected indentation patterns to determine the most likely base unit.
452      */
453     private static String analyzeIndentationPattern(Map<String, Integer> indentationCounts) {
454         if (indentationCounts.isEmpty()) {
455             return Indentation.TWO_SPACES; // Default to 2 spaces
456         }
457 
458         // Find the most common indentation pattern
459         String mostCommon = indentationCounts.entrySet().stream()
460                 .max(Map.Entry.comparingByValue())
461                 .map(Map.Entry::getKey)
462                 .orElse(Indentation.TWO_SPACES);
463 
464         // Validate and normalize the detected pattern
465         if (mostCommon.matches("^\\s+$")) { // Only whitespace characters
466             return mostCommon;
467         }
468 
469         // If we have mixed patterns, try to find a common base unit
470         Set<String> patterns = indentationCounts.keySet();
471 
472         // Check for common patterns
473         if (patterns.stream().anyMatch(p -> p.equals(Indentation.FOUR_SPACES))) {
474             return Indentation.FOUR_SPACES; // 4 spaces
475         }
476         if (patterns.stream().anyMatch(p -> p.equals(Indentation.TAB))) {
477             return Indentation.TAB; // Tab
478         }
479         if (patterns.stream().anyMatch(p -> p.equals(Indentation.TWO_SPACES))) {
480             return Indentation.TWO_SPACES; // 2 spaces
481         }
482 
483         // Fallback to the most common pattern or default
484         return mostCommon.isEmpty() ? Indentation.TWO_SPACES : mostCommon;
485     }
486 
487     /**
488      * Calculates the index where a new element with the given name should be inserted.
489      */
490     private static int calcNewElementIndex(String elementName, Element parent) {
491         List<String> elementOrder = ELEMENT_ORDER.get(parent.getName());
492         if (elementOrder == null || elementOrder.isEmpty()) {
493             return parent.getContentSize();
494         }
495 
496         int targetIndex = elementOrder.indexOf(elementName);
497         if (targetIndex == -1) {
498             return parent.getContentSize();
499         }
500 
501         // Find the position to insert based on element order
502         List<Content> contents = parent.getContent();
503         for (int i = contents.size() - 1; i >= 0; i--) {
504             Content content = contents.get(i);
505             if (content instanceof Element element) {
506                 int currentIndex = elementOrder.indexOf(element.getName());
507                 if (currentIndex != -1 && currentIndex <= targetIndex) {
508                     return i + 1;
509                 }
510             }
511         }
512 
513         return 0;
514     }
515 
516     /**
517      * Checks if there should be a blank line between two elements.
518      * This method determines spacing based on the element order configuration.
519      * Empty strings in the element order indicate where blank lines should be placed.
520      */
521     private static boolean isBlankLineBetweenElements(
522             String prependingElementName, String elementName, Element parent) {
523         List<String> elementOrder = ELEMENT_ORDER.get(parent.getName());
524         if (elementOrder == null || elementOrder.isEmpty()) {
525             return false;
526         }
527 
528         int prependingIndex = elementOrder.indexOf(prependingElementName);
529         int currentIndex = elementOrder.indexOf(elementName);
530 
531         if (prependingIndex == -1 || currentIndex == -1) {
532             return false;
533         }
534 
535         // Check if there's an empty string between the two elements in the order
536         for (int i = prependingIndex + 1; i < currentIndex; i++) {
537             if (elementOrder.get(i).isEmpty()) {
538                 return true;
539             }
540         }
541 
542         return false;
543     }
544 }