PlaceholderHooker.java

1
package pro.verron.officestamper.api;
2
3
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
4
import org.docx4j.utils.TraversalUtilVisitor;
5
import org.docx4j.wml.P;
6
import pro.verron.officestamper.utils.wml.WmlUtils;
7
8
import java.util.ArrayList;
9
import java.util.Collection;
10
import java.util.LinkedHashMap;
11
import java.util.List;
12
import java.util.Map;
13
import java.util.Optional;
14
import java.util.SequencedMap;
15
16
import static java.util.Comparator.comparingInt;
17
import static pro.verron.officestamper.utils.wml.WmlUtils.asString;
18
import static pro.verron.officestamper.utils.wml.WmlUtils.insertSmartTag;
19
20
/// The [PlaceholderHooker] class is a pre-processor that prepares inline placeholders in a
21
/// [WordprocessingMLPackage] document. It searches for placeholders introduced by one of the configured opening
22
/// delimiters and wraps them with a smart tag, so the OfficeStamper engine can process them later.
23
///
24
/// ## Brace balancing
25
///
26
/// A placeholder ends at the closing brace that *balances* its opening brace, not at the first closing brace
27
/// encountered. Braces nested inside the placeholder are therefore part of the expression, which is what makes SpEL
28
/// inline lists and inline maps usable as placeholders:
29
///
30
/// ```text
31
/// ${ {1, 2, 3} }              -> expression " {1, 2, 3} "   (a SpEL inline list)
32
/// ${ {'a': 1, 'b': 2} }       -> expression " {'a': 1, 'b': 2} " (a SpEL inline map)
33
/// ${ {1, 2, 3}.?[#this > 1] } -> expression " {1, 2, 3}.?[#this > 1] "
34
/// ```
35
///
36
/// ## Malformed placeholders
37
///
38
/// An opening delimiter that is never balanced by a closing brace is *malformed*. Rather than leaving the stray
39
/// delimiter behind — and rather than letting the text that follows it be interpreted as further placeholders — the
40
/// malformed placeholder spans the remainder of the paragraph and is captured verbatim, delimiters included. The
41
/// engine then fails to parse it and hands it to the configured [ExceptionResolver], so the failure is reported
42
/// through the usual channel instead of silently corrupting the output.
43
///
44
/// ## Single pass
45
///
46
/// All delimiters are matched in one left-to-right pass, and scanning resumes *after* each placeholder that has been
47
/// wrapped. Consequently a placeholder nested inside another one is part of the outer expression and is never wrapped
48
/// on its own.
49
public class PlaceholderHooker
50
        implements PreProcessor {
51
52
    private static final char OPENING_BRACE = '{';
53
    private static final char CLOSING_BRACE = '}';
54
55
    private final SequencedMap<String, String> elementByOpening;
56
57
    /// Constructs a new [PlaceholderHooker] recognizing a single opening delimiter.
58
    ///
59
    /// @param opening the literal opening delimiter of a placeholder, for instance `${` or `#{`. It must end with
60
    ///         an opening brace.
61
    /// @param element the name of the smart tag type to wrap matching placeholders with.
62
    public PlaceholderHooker(String opening, String element) {
63
        this(Map.of(opening, element));
64
    }
65
66
    /// Constructs a new [PlaceholderHooker] recognizing several opening delimiters in a single pass.
67
    ///
68
    /// @param elementByOpening the smart tag type to use for each literal opening delimiter. Every delimiter must
69
    ///         end with an opening brace. When several delimiters could match at the same position, the longest one
70
    ///         wins.
71
    public PlaceholderHooker(Map<String, String> elementByOpening) {
72
        this.elementByOpening = elementByOpening.entrySet()
73
                                                .stream()
74 1 1. lambda$new$0 : replaced int return with 0 for pro/verron/officestamper/api/PlaceholderHooker::lambda$new$0 → NO_COVERAGE
                                                .sorted(comparingInt((Map.Entry<String, String> e) -> e.getKey()
75
                                                                                                       .length())
76
                                                        .reversed())
77
                                                .collect(LinkedHashMap::new,
78
                                                        (map, e) -> map.put(validate(e.getKey()), e.getValue()),
79
                                                        LinkedHashMap::putAll);
80
    }
81
82
    private static String validate(String opening) {
83 3 1. validate : negated conditional → KILLED
2. validate : negated conditional → KILLED
3. validate : Replaced integer subtraction with addition → KILLED
        if (opening.isEmpty() || opening.charAt(opening.length() - 1) != OPENING_BRACE)
84
            throw new OfficeStamperException(
85
                    "A placeholder opening delimiter must end with '%c', but was '%s'".formatted(OPENING_BRACE,
86
                            opening));
87 1 1. validate : replaced return value with "" for pro/verron/officestamper/api/PlaceholderHooker::validate → KILLED
        return opening;
88
    }
89
90
    @Override
91
    public void process(WordprocessingMLPackage document) {
92
        var visitor = new ParagraphCollector(elementByOpening.keySet());
93 1 1. process : removed call to pro/verron/officestamper/utils/wml/WmlUtils::visitDocument → KILLED
        WmlUtils.visitDocument(document, visitor);
94 1 1. process : removed call to pro/verron/officestamper/api/PlaceholderHooker::hook → KILLED
        for (var paragraph : visitor.paragraphs()) hook(paragraph);
95
    }
96
97
    /// Wraps every placeholder of the given paragraph with a smart tag, in a single left-to-right pass.
98
    private void hook(P paragraph) {
99
        var text = asString(paragraph);
100
        var cursor = 0;
101 2 1. hook : changed conditional boundary → SURVIVED
2. hook : negated conditional → KILLED
        while (cursor < text.length()) {
102
            var opening = openingAt(text, cursor);
103 1 1. hook : negated conditional → KILLED
            if (opening.isEmpty()) {
104 1 1. hook : Changed increment from 1 to -1 → TIMED_OUT
                cursor++;
105
                continue;
106
            }
107
            var delimiter = opening.get();
108
            var placeholder = scan(text, cursor, delimiter);
109
            var expression = placeholder.expression();
110 1 1. hook : removed call to pro/verron/officestamper/utils/wml/WmlUtils::insertSmartTag → KILLED
            insertSmartTag(elementByOpening.get(delimiter), paragraph, expression, cursor, placeholder.end());
111
            // The tag holds exactly the expression, delimiters stripped, so scanning resumes right after it.
112 1 1. hook : Replaced integer addition with subtraction → TIMED_OUT
            cursor += expression.length();
113
            text = asString(paragraph);
114
        }
115
    }
116
117
    /// Returns the opening delimiter starting at the given index, if any.
118
    private Optional<String> openingAt(String text, int index) {
119 1 1. openingAt : replaced return value with Optional.empty for pro/verron/officestamper/api/PlaceholderHooker::openingAt → KILLED
        return elementByOpening.keySet()
120
                               .stream()
121 2 1. lambda$openingAt$0 : replaced boolean return with false for pro/verron/officestamper/api/PlaceholderHooker::lambda$openingAt$0 → KILLED
2. lambda$openingAt$0 : replaced boolean return with true for pro/verron/officestamper/api/PlaceholderHooker::lambda$openingAt$0 → KILLED
                               .filter(opening -> text.startsWith(opening, index))
122
                               .findFirst();
123
    }
124
125
    /// Scans a placeholder starting at `start`, balancing nested braces.
126
    ///
127
    /// When the opening brace is balanced, the placeholder stops right after the matching closing brace and the
128
    /// expression excludes both delimiters. When it is never balanced, the placeholder is malformed: it spans the rest
129
    /// of the text and the expression keeps the delimiters, so that the engine reports it as unparseable.
130
    private static Placeholder scan(String text, int start, String opening) {
131
        var depth = 1;
132 3 1. scan : Replaced integer addition with subtraction → KILLED
2. scan : changed conditional boundary → KILLED
3. scan : negated conditional → KILLED
        for (var index = start + opening.length(); index < text.length(); index++) {
133
            var character = text.charAt(index);
134 2 1. scan : negated conditional → KILLED
2. scan : Changed increment from 1 to -1 → KILLED
            if (character == OPENING_BRACE) depth++;
135 3 1. scan : negated conditional → KILLED
2. scan : negated conditional → KILLED
3. scan : Changed increment from -1 to 1 → KILLED
            else if (character == CLOSING_BRACE && --depth == 0)
136 3 1. scan : Replaced integer addition with subtraction → KILLED
2. scan : replaced return value with null for pro/verron/officestamper/api/PlaceholderHooker::scan → KILLED
3. scan : Replaced integer addition with subtraction → KILLED
                return new Placeholder(index + 1, text.substring(start + opening.length(), index));
137
        }
138 1 1. scan : replaced return value with null for pro/verron/officestamper/api/PlaceholderHooker::scan → KILLED
        return new Placeholder(text.length(), text.substring(start));
139
    }
140
141
    /// A placeholder located in a paragraph's text.
142
    ///
143
    /// @param end the index right after the placeholder.
144
    /// @param expression the expression the smart tag will carry.
145
    private record Placeholder(int end, String expression) {}
146
147
    /// A [TraversalUtilVisitor] implementation that collects the paragraphs possibly holding a placeholder.
148
    ///
149
    /// This class is used to traverse a document and collect all paragraph elements ([P]) containing at least one of
150
    /// the given opening delimiters. The collected paragraphs can be retrieved using the [#paragraphs()] method.
151
    public static class ParagraphCollector
152
            extends TraversalUtilVisitor<P> {
153
154
        private final List<String> openings;
155
        private final List<P> results = new ArrayList<>();
156
157
        /// Constructs a new [ParagraphCollector] with the specified opening delimiters.
158
        ///
159
        /// @param openings the opening delimiters to look for in paragraphs
160
        public ParagraphCollector(Collection<String> openings) {
161
            this.openings = List.copyOf(openings);
162
        }
163
164
        @Override
165
        public void apply(P element) {
166
            var string = asString(element);
167
            if (openings.stream()
168 1 1. apply : negated conditional → KILLED
                        .anyMatch(string::contains)) {
169
                results.add(element);
170
            }
171
        }
172
173
        /// Returns the list of collected paragraphs possibly holding a placeholder.
174
        ///
175
        /// @return a list of paragraphs containing at least one opening delimiter
176
        public List<P> paragraphs() {
177 1 1. paragraphs : replaced return value with Collections.emptyList for pro/verron/officestamper/api/PlaceholderHooker$ParagraphCollector::paragraphs → KILLED
            return results;
178
        }
179
    }
180
}

Mutations

74

1.1
Location : lambda$new$0
Killed by : none
replaced int return with 0 for pro/verron/officestamper/api/PlaceholderHooker::lambda$new$0 → NO_COVERAGE

83

1.1
Location : validate
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
negated conditional → KILLED

2.2
Location : validate
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
negated conditional → KILLED

3.3
Location : validate
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
Replaced integer subtraction with addition → KILLED

87

1.1
Location : validate
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
replaced return value with "" for pro/verron/officestamper/api/PlaceholderHooker::validate → KILLED

93

1.1
Location : process
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
removed call to pro/verron/officestamper/utils/wml/WmlUtils::visitDocument → KILLED

94

1.1
Location : process
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
removed call to pro/verron/officestamper/api/PlaceholderHooker::hook → KILLED

101

1.1
Location : hook
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
negated conditional → KILLED

2.2
Location : hook
Killed by : none
changed conditional boundary → SURVIVED
Covering tests

103

1.1
Location : hook
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
negated conditional → KILLED

104

1.1
Location : hook
Killed by : none
Changed increment from 1 to -1 → TIMED_OUT

110

1.1
Location : hook
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
removed call to pro/verron/officestamper/utils/wml/WmlUtils::insertSmartTag → KILLED

112

1.1
Location : hook
Killed by : none
Replaced integer addition with subtraction → TIMED_OUT

119

1.1
Location : openingAt
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
replaced return value with Optional.empty for pro/verron/officestamper/api/PlaceholderHooker::openingAt → KILLED

121

1.1
Location : lambda$openingAt$0
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
replaced boolean return with false for pro/verron/officestamper/api/PlaceholderHooker::lambda$openingAt$0 → KILLED

2.2
Location : lambda$openingAt$0
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
replaced boolean return with true for pro/verron/officestamper/api/PlaceholderHooker::lambda$openingAt$0 → KILLED

132

1.1
Location : scan
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
Replaced integer addition with subtraction → KILLED

2.2
Location : scan
Killed by : pro.verron.officestamper.test.SpelInjectionTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.SpelInjectionTest]/[method:malformedPlaceholderIsResolvedByResolver()]
changed conditional boundary → KILLED

3.3
Location : scan
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
negated conditional → KILLED

134

1.1
Location : scan
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
negated conditional → KILLED

2.2
Location : scan
Killed by : pro.verron.officestamper.test.SpelInjectionTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.SpelInjectionTest]/[method:inlineMapPlaceholderResolves()]
Changed increment from 1 to -1 → KILLED

135

1.1
Location : scan
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
negated conditional → KILLED

2.2
Location : scan
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
negated conditional → KILLED

3.3
Location : scan
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
Changed increment from -1 to 1 → KILLED

136

1.1
Location : scan
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
Replaced integer addition with subtraction → KILLED

2.2
Location : scan
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
replaced return value with null for pro/verron/officestamper/api/PlaceholderHooker::scan → KILLED

3.3
Location : scan
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
Replaced integer addition with subtraction → KILLED

138

1.1
Location : scan
Killed by : pro.verron.officestamper.test.SpelInjectionTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.SpelInjectionTest]/[method:malformedPlaceholderIsResolvedByResolver()]
replaced return value with null for pro/verron/officestamper/api/PlaceholderHooker::scan → KILLED

168

1.1
Location : apply
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
negated conditional → KILLED

177

1.1
Location : paragraphs
Killed by : pro.verron.officestamper.test.PlaceholderPreprocessorTest.[engine:junit-jupiter]/[class:pro.verron.officestamper.test.PlaceholderPreprocessorTest]/[method:process()]
replaced return value with Collections.emptyList for pro/verron/officestamper/api/PlaceholderHooker$ParagraphCollector::paragraphs → KILLED

Active mutators

Tests examined


Report generated by PIT 1.25.5 support