• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

evolvedbinary / elemental / 982

29 Apr 2025 08:34PM UTC coverage: 56.409% (+0.007%) from 56.402%
982

push

circleci

adamretter
[feature] Improve README.md badges

28451 of 55847 branches covered (50.94%)

Branch coverage included in aggregate %.

77468 of 131924 relevant lines covered (58.72%)

0.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

68.34
/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java
1
/*
2
 * Elemental
3
 * Copyright (C) 2024, Evolved Binary Ltd
4
 *
5
 * admin@evolvedbinary.com
6
 * https://www.evolvedbinary.com | https://www.elemental.xyz
7
 *
8
 * Use of this software is governed by the Business Source License 1.1
9
 * included in the LICENSE file and at www.mariadb.com/bsl11.
10
 *
11
 * Change Date: 2028-04-27
12
 *
13
 * On the date above, in accordance with the Business Source License, use
14
 * of this software will be governed by the Apache License, Version 2.0.
15
 *
16
 * Additional Use Grant: Production use of the Licensed Work for a permitted
17
 * purpose. A Permitted Purpose is any purpose other than a Competing Use.
18
 * A Competing Use means making the Software available to others in a commercial
19
 * product or service that: substitutes for the Software; substitutes for any
20
 * other product or service we offer using the Software that exists as of the
21
 * date we make the Software available; or offers the same or substantially
22
 * similar functionality as the Software.
23
 *
24
 * NOTE: Parts of this file contain code from 'The BaseX Team'.
25
 *       The original license header is included below.
26
 *
27
 * =====================================================================
28
 *
29
 * Copyright (c) 2005-20 BaseX Team
30
 * All rights reserved.
31
 *
32
 * The BSD 3-Clause License
33
 *
34
 * Redistribution and use in source and binary forms, with or without
35
 * modification, are permitted provided that the following conditions
36
 * are met:
37
 *
38
 * 1. Redistributions of source code must retain the above copyright
39
 *    notice, this list of conditions and the following disclaimer.
40
 *
41
 * 2. Redistributions in binary form must reproduce the above copyright
42
 *    notice, this list of conditions and the following disclaimer in the
43
 *    documentation and/or other materials provided with the distribution.
44
 *
45
 * 3. Neither the name of the copyright holders nor the names of its
46
 *    contributors may be used to endorse or promote products derived
47
 *    from this software without specific prior written permission.
48
 *
49
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
50
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
51
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
52
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
53
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
54
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
55
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
56
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
57
 * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
58
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
59
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
60
 */
61
package org.exist.xquery.functions.fn;
62

63
import com.evolvedbinary.j8fu.tuple.Tuple2;
64
import org.exist.dom.QName;
65
import org.exist.util.CodePointString;
66
import org.exist.xquery.*;
67
import org.exist.xquery.value.*;
68

69
import javax.annotation.Nullable;
70
import java.lang.String;
71
import java.math.BigDecimal;
72
import java.math.MathContext;
73
import java.math.RoundingMode;
74
import java.util.Arrays;
75
import java.util.Optional;
76

77
import static com.evolvedbinary.j8fu.tuple.Tuple.Tuple;
78
import static org.exist.xquery.FunctionDSL.*;
79
import static org.exist.xquery.functions.fn.FnModule.functionSignatures;
80

81

82
/**
83
 * Implements fn:format-number as per W3C XPath and XQuery Functions and Operators 3.1
84
 *
85
 * fn:format-number($value as numeric?, $picture as xs:string) as xs:string
86
 * fn:format-number($value as numeric?, $picture as xs:string, $decimal-format-name as xs:string) as xs:string
87
 *
88
 * @author <a href="mailto:adam@evolvedbinary.com">Adam Retter</a>
89
 */
90
public class FnFormatNumbers extends BasicFunction {
91

92
    private static final FunctionParameterSequenceType FS_PARAM_VALUE = optParam("value", Type.NUMERIC, "The number to format");
1✔
93
    private static final FunctionParameterSequenceType FS_PARAM_PICTURE = param("picture", Type.STRING, "The picture string to use for formatting. To understand the picture string syntax, see: https://www.w3.org/TR/xpath-functions-31/#func-format-number");
1✔
94

95
    private static final String FS_FORMAT_NUMBER_NAME = "format-number";
96
    static final FunctionSignature[] FS_FORMAT_NUMBER = functionSignatures(
1✔
97
            FS_FORMAT_NUMBER_NAME,
1✔
98
            "Returns a string containing a number formatted according to a given picture string, taking account of decimal formats specified in the static context.",
1✔
99
            returns(Type.STRING, "The formatted string representation of the supplied number"),
1✔
100
            arities(
1✔
101
                    arity(
1✔
102
                            FS_PARAM_VALUE,
1✔
103
                            FS_PARAM_PICTURE
1✔
104
                    ),
105
                    arity(
1✔
106
                            FS_PARAM_VALUE,
1✔
107
                            FS_PARAM_PICTURE,
1✔
108
                            optParam("decimal-format-name", Type.STRING, "The name (as an EQName) of a decimal format to use.")
1✔
109
                    )
110
            )
111
    );
1✔
112

113
    public FnFormatNumbers(final XQueryContext context, final FunctionSignature signature) {
114
        super(context, signature);
1✔
115
    }
1✔
116

117
    @Override
118
    public Sequence eval(final Sequence[] args, final Sequence contextSequence)
119
            throws XPathException {
120

121
        // get the decimal format
122
        final QName qnDecimalFormat;
123
        if (args.length == 3 && !args[2].isEmpty()) {
1!
124
            final String decimalFormatName = args[2].itemAt(0).getStringValue().trim();
×
125
            try {
126
                qnDecimalFormat = QName.parse(context, decimalFormatName);
×
127
            } catch (final QName.IllegalQNameException e) {
×
128
                throw new XPathException(this, ErrorCodes.FODF1280, "Invalid decimal format QName.", args[2], e);
×
129
            }
130
        } else {
131
            qnDecimalFormat = null;
1✔
132
        }
133
        final DecimalFormat decimalFormat = context.getStaticDecimalFormat(qnDecimalFormat);
1✔
134
        if (decimalFormat == null) {
1!
135
            throw new XPathException(this, ErrorCodes.FODF1280, "No known decimal format of that name.", args[2]);
×
136
        }
137

138
        final NumericValue number;
139
        if (args[0].isEmpty()) {
1!
140
            number = new DoubleValue(this, Double.NaN);
×
141
        } else if (context.isBackwardsCompatible() && !Type.subTypeOfUnion(args[0].getItemType(), Type.NUMERIC)) {
1!
142
            number = new DoubleValue(this, Double.NaN);
×
143
        } else {
×
144
            number = (NumericValue) args[0].itemAt(0);
1✔
145
        }
146

147
        final CodePointString pictureString = new CodePointString(args[1].itemAt(0).getStringValue());
1✔
148

149
        final Tuple2<SubPicture, Optional<SubPicture>> subPictures = analyzePictureString(decimalFormat, pictureString);
1✔
150
        final String value = format(number, decimalFormat, subPictures);
1✔
151
        return new StringValue(this, value);
1✔
152
    }
153

154
    enum AnalyzeState {
1✔
155
        MANTISSA_PART,
1✔
156
        INTEGER_PART,
1✔
157
        FRACTIONAL_PART,
1✔
158
        EXPONENT_PART
1✔
159
    }
160

161
    /**
162
     * Analyzes a picture-string sent to fn:format-number.
163
     *
164
     * See https://www.w3.org/TR/xpath-functions-31/#syntax-of-picture-string
165
     * See https://www.w3.org/TR/xpath-functions-31/#analyzing-picture-string
166
     *
167
     * @param decimalFormat the decimal format to use
168
     * @param pictureString the picture-string
169
     *
170
     * @return A tuple containing one or two sub-pictures
171
     *
172
     * @throws XPathException if the picture-string is invalid
173
     */
174
    private Tuple2<SubPicture, Optional<SubPicture>> analyzePictureString(final DecimalFormat decimalFormat, final CodePointString pictureString) throws XPathException {
175
        if (pictureString.length() == 0) {
1!
176
            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() $picture string is zero-length");
×
177
        }
178

179
        final SubPicture firstSubPicture = new SubPicture();
1✔
180
        @Nullable SubPicture secondSubPicture = null;
1✔
181

182
        SubPicture subPicture = firstSubPicture;
1✔
183

184
        AnalyzeState state = AnalyzeState.INTEGER_PART;  // we start in the integer part of the mantissa
1✔
185
        int idx = 0;
1✔
186

187
        boolean capturePrefix = true;
1✔
188

189
        // we need two characters of look-behind to be able to detect
190
        // various invalid sub-pictures:
191
        //   1) active-passive-active characters
192
        //   2) grouping-separator character that appears adjacent to a decimal-separator character
193
        int prevPrevChar = '\0';
1✔
194
        int prevChar = '\0';
1✔
195

196
        for (; idx < pictureString.length(); idx++) {
1✔
197
            final int c = pictureString.codePointAt(idx);
1✔
198

199
            if (isActiveChar(decimalFormat, prevPrevChar) && (!isActiveChar(decimalFormat, prevChar)) && isActiveChar(decimalFormat, c)) {
1✔
200
                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture sub-picture must not contain a passive character that is preceded by an active character and that is followed by another active character");
1✔
201
            }
202

203
            switch (state) {
1!
204

205
                case INTEGER_PART:
206
                    /* active characters */
207
                    if (c == decimalFormat.decimalSeparator) {
1✔
208
                        capturePrefix = false;
1✔
209
                        subPicture.clearSuffix();
1✔
210

211
                        if (prevChar == decimalFormat.groupingSeparator) {
1✔
212
                            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture must not contain a grouping-separator character that appears adjacent to a decimal-separator character.");
1✔
213
                        }
214

215
                        subPicture.setHasDecimalSeparator(true);
1✔
216
                        state = AnalyzeState.FRACTIONAL_PART;
1✔
217
                    } else if (c == decimalFormat.exponentSeparator) {
1✔
218
                        /*
219
                        A character that matches the exponent-separator property is treated as an
220
                        exponent-separator-sign if it is both preceded and followed within the
221
                        sub-picture by an active character.
222
                        */
223

224
                        // we need to peek at the next char to determine if it is active
225
                        final boolean nextIsActive;
226
                        if (idx + 1 < pictureString.length()) {
1!
227
                            nextIsActive = isActiveChar(decimalFormat, pictureString.codePointAt(idx + 1));
1✔
228
                        } else {
1✔
229
                            nextIsActive = false;
×
230
                        }
231

232
                        if (isActiveChar(decimalFormat, prevChar) && nextIsActive) {
1!
233
                            // this is an exponent-separator-sign
234

235
                            capturePrefix = false;
1✔
236
                            subPicture.clearSuffix();
1✔
237

238
                            if (subPicture.hasPercent()) {
1!
239
                                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture cannot contain an exponent separator sign as it already has a percent character.");
×
240
                            }
241

242
                            if (subPicture.hasPerMille()) {
1!
243
                                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture cannot contain an exponent separator sign as it already has a per-mille character.");
×
244
                            }
245

246
                            state = AnalyzeState.EXPONENT_PART;
1✔
247

248
                        } else {
1✔
249
                            // just another passive char
250
                            analyzePassiveChar(decimalFormat, c, capturePrefix, subPicture);
1✔
251
                        }
252

253
                    } else if (c == decimalFormat.groupingSeparator) {
1✔
254
                        capturePrefix = false;
1✔
255
                        subPicture.clearSuffix();
1✔
256

257
                        if (prevChar == decimalFormat.decimalSeparator) {
1!
258
                            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture must not contain a grouping-separator character that appears adjacent to a decimal-separator character.");
×
259
                        }
260

261
                        if (prevChar == decimalFormat.groupingSeparator) {
1✔
262
                            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture must not contain two adjacent instances of the grouping-separator character.");
1✔
263
                        }
264

265
                        subPicture.newIntegerPartGroupingPosition();
1✔
266
                    } else if (c == decimalFormat.digit) {
1✔
267
                        capturePrefix = false;
1✔
268
                        subPicture.clearSuffix();
1✔
269

270
                        if (isDecimalDigit(decimalFormat, prevChar)) {
1✔
271
                            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture must not contain a member of the decimal digit family that is followed by an instance of the optional digit character within its integer part.");
1✔
272
                        }
273

274
                        subPicture.incrementIntegerPartGroupingPosition();
1✔
275
                        subPicture.setHasIntegerOptionalDigit(true);
1✔
276
                    } else if (c == decimalFormat.patternSeparator) {
1✔
277
                        capturePrefix = false;
1✔
278
                        subPicture.clearSuffix();
1✔
279

280
                        if (subPicture == secondSubPicture) {
1✔
281
                            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() $picture string contains more than two sub-pictures");
1✔
282
                        } else {
283
                            // store/check any outstanding picture state
284
                            if (!(subPicture.hasIntegerOptionalDigit() || subPicture.getMinimumIntegerPartSize() > 0 || subPicture.getMaximumFractionalPartSize() > 0)) {
1!
285
                                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() mantissa part of sub-picture in $picture must contain at least one character that is either an optional digit character or a member of the decimal digit family");
×
286
                            }
287

288
                            if (prevChar == decimalFormat.groupingSeparator) {
1✔
289
                                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture must not contain a grouping-separator character in the absence of a decimal-separator character, at the end of the integer part.");
1✔
290
                            }
291

292
                            // switch to 2nd sub-picture
293
                            secondSubPicture = new SubPicture();
1✔
294
                            subPicture = secondSubPicture;
1✔
295

296
                            // reset analyze state
297
                            state = AnalyzeState.INTEGER_PART;
1✔
298
                            prevPrevChar = '\0';
1✔
299
                            prevChar = '\0';
1✔
300
                            capturePrefix = true;
1✔
301
                        }
302
                    } else if (isDecimalDigit(decimalFormat, c)) {  // decimal digit family
1✔
303
                        capturePrefix = false;
1✔
304
                        subPicture.clearSuffix();
1✔
305

306
                        subPicture.incrementIntegerPartGroupingPosition();
1✔
307
                        subPicture.incrementMinimumIntegerPartSize();
1✔
308
                        subPicture.incrementScalingFactor();
1✔
309

310
                    } else {
1✔
311
                        /* passive character */
312
                        analyzePassiveChar(decimalFormat, c, capturePrefix, subPicture);
1✔
313
                    }
314

315
                    break;  // end of INTEGER_PART
1✔
316

317

318

319
                case FRACTIONAL_PART:
320
                    /* active characters */
321
                    if (c == decimalFormat.decimalSeparator) {
1✔
322
                        capturePrefix = false;
1✔
323
                        subPicture.clearSuffix();
1✔
324

325
                        if (prevChar == decimalFormat.groupingSeparator) {
1!
326
                            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture must not contain a grouping-separator character that appears adjacent to a decimal-separator character.");
×
327
                        }
328

329
                        subPicture.setHasDecimalSeparator(true);
1✔
330
                        throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture string contains more than one decimal-separator characters");
1✔
331
                    } else if (c == decimalFormat.exponentSeparator) {
1!
332
                        /*
333
                        A character that matches the exponent-separator property is treated as an
334
                        exponent-separator-sign if it is both preceded and followed within the
335
                        sub-picture by an active character.
336
                        */
337

338
                        // we need to peek at the next char to determine if it is active
339
                        final boolean nextIsActive;
340
                        if (idx + 1 < pictureString.length()) {
×
341
                            nextIsActive = isActiveChar(decimalFormat, pictureString.codePointAt(idx + 1));
×
342
                        } else {
×
343
                            nextIsActive = false;
×
344
                        }
345

346
                        if (isActiveChar(decimalFormat, prevChar) && nextIsActive) {
×
347
                            // this is an exponent-separator-sign
348

349
                            capturePrefix = false;
×
350
                            subPicture.clearSuffix();
×
351

352
                            if (subPicture.hasPercent()) {
×
353
                                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture cannot contain an exponent separator sign as it already has a percent character.");
×
354
                            }
355

356
                            if (subPicture.hasPerMille()) {
×
357
                                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture cannot contain an exponent separator sign as it already has a per-mille character.");
×
358
                            }
359

360
                            state = AnalyzeState.EXPONENT_PART;
×
361

362
                        } else {
×
363
                            // just another passive char
364
                            analyzePassiveChar(decimalFormat, c, capturePrefix, subPicture);
×
365
                        }
366

367
                    } else if (c == decimalFormat.groupingSeparator) {
1✔
368
                        capturePrefix = false;
1✔
369
                        subPicture.clearSuffix();
1✔
370

371
                        if (prevChar == decimalFormat.decimalSeparator) {
1✔
372
                            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture must not contain a grouping-separator character that appears adjacent to a decimal-separator character.");
1✔
373
                        }
374

375
                        if (prevChar == decimalFormat.groupingSeparator) {
1!
376
                            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture must not contain two adjacent instances of the grouping-separator character.");
×
377
                        }
378

379
                        subPicture.newFractionalPartGroupingPosition();
1✔
380
                    } else if (c == decimalFormat.digit) {
1✔
381
                        capturePrefix = false;
1✔
382
                        subPicture.clearSuffix();
1✔
383

384
                        subPicture.incrementMaximumFractionalPartSize();
1✔
385
                    }  else if (c == decimalFormat.patternSeparator) {
1✔
386
                        capturePrefix = false;
1✔
387
                        subPicture.clearSuffix();
1✔
388

389
                        if (subPicture == secondSubPicture) {
1!
390
                            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() $picture string contains more than two sub-pictures");
×
391
                        } else {
392
                            // store/check any outstanding picture state
393
                            if (!(subPicture.hasIntegerOptionalDigit() || subPicture.getMinimumIntegerPartSize() > 0 || subPicture.getMaximumFractionalPartSize() > 0)) {
1!
394
                                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() mantissa part of sub-picture in $picture must contain at least one character that is either an optional digit character or a member of the decimal digit family");
1✔
395
                            }
396

397
                            // switch to 2nd sub-picture
398
                            secondSubPicture = new SubPicture();
1✔
399
                            subPicture = secondSubPicture;
1✔
400

401
                            // reset analyze state
402
                            state = AnalyzeState.INTEGER_PART;
1✔
403
                            prevPrevChar = '\0';
1✔
404
                            prevChar = '\0';
1✔
405
                            capturePrefix = true;
1✔
406
                        }
407
                    } else if (isDecimalDigit(decimalFormat, c)) {  // decimal digit family
1✔
408
                        capturePrefix = false;
1✔
409
                        subPicture.clearSuffix();
1✔
410

411
                        if (prevChar == decimalFormat.digit) {
1✔
412
                            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture must not contain an instance of the optional digit character that is followed by a member of the decimal digit family within its fractional part.");
1✔
413
                        }
414

415
                        subPicture.incrementMinimumFractionalPartSize();
1✔
416
                        subPicture.incrementMaximumFractionalPartSize();
1✔
417

418
                    } else {
1✔
419
                        /* passive character */
420
                        analyzePassiveChar(decimalFormat, c, capturePrefix, subPicture);
1✔
421
                    }
422

423
                    break;  // end of FRACTIONAL_PART
1✔
424

425

426

427
                case EXPONENT_PART:
428
                    if (c == decimalFormat.decimalSeparator
1✔
429
                            || c == decimalFormat.exponentSeparator
1!
430
                            || c == decimalFormat.groupingSeparator
×
431
                            || c == decimalFormat.digit) {
×
432
                        capturePrefix = false;
1✔
433
                        subPicture.clearSuffix();
1✔
434

435
                        throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture cannot have any active characters following the exponent-separator-sign");
1✔
436

437
                    }  else if (c == decimalFormat.patternSeparator) {
×
438
                        capturePrefix = false;
×
439
                        subPicture.clearSuffix();
×
440

441
                        if (subPicture == secondSubPicture) {
×
442
                            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() $picture string contains more than two sub-pictures");
×
443
                        } else {
444
                            // store/check any outstanding picture state
445
                            if (!(subPicture.hasIntegerOptionalDigit() || subPicture.getMinimumIntegerPartSize() > 0 || subPicture.getMaximumFractionalPartSize() > 0)) {
×
446
                                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() mantissa part of sub-picture in $picture must contain at least one character that is either an optional digit character or a member of the decimal digit family");
×
447
                            }
448

449
                            // switch to 2nd sub-picture
450
                            secondSubPicture = new SubPicture();
×
451
                            subPicture = secondSubPicture;
×
452

453
                            // reset analyze state
454
                            state = AnalyzeState.INTEGER_PART;
×
455
                            prevPrevChar = '\0';
×
456
                            prevChar = '\0';
×
457
                            capturePrefix = true;
×
458
                        }
459
                    } else if (isDecimalDigit(decimalFormat, c)) {  // decimal digit family
×
460
                        capturePrefix = false;
×
461
                        subPicture.clearSuffix();
×
462

463
                        subPicture.incrementMinimumExponentSize();
×
464

465
                    } else {
×
466
                        /* passive character */
467
                        analyzePassiveChar(decimalFormat, c, capturePrefix, subPicture);
×
468
                    }
469

470
                    break;  // end of EXPONENT_PART
471
            }
472

473
            if (c != decimalFormat.patternSeparator) {
1✔
474
                prevPrevChar = prevChar;
1✔
475
                prevChar = c;
1✔
476
            }
477
        }
478

479
        if (!(subPicture.hasIntegerOptionalDigit() || subPicture.getMinimumIntegerPartSize() > 0 || subPicture.getMaximumFractionalPartSize() > 0)) {
1!
480
            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() mantissa part of sub-picture in $picture must contain at least one character that is either an optional digit character or a member of the decimal digit family");
1✔
481
        }
482

483
        if ((!subPicture.hasDecimalSeparator()) && prevChar == decimalFormat.groupingSeparator) {
1✔
484
            throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture must not contain a grouping-separator character in the absence of a decimal-separator character, at the end of the integer part.");
1✔
485
        }
486

487
        return Tuple(firstSubPicture.adjust(), Optional.ofNullable(secondSubPicture).map(SubPicture::adjust));
1✔
488
    }
489

490
    private void analyzePassiveChar(final DecimalFormat decimalFormat, final int c, final boolean capturePrefix, final SubPicture subPicture) throws XPathException {
491
        if (capturePrefix) {
1!
492
            subPicture.appendPrefix(c);
×
493
        }
494
        subPicture.appendSuffix(c);
1✔
495

496
        if (c == decimalFormat.percent) {
1✔
497
            if (subPicture.hasPercent()) {
1✔
498
                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture string contains more than one percent character");
1✔
499
            } else if (subPicture.hasPerMille()) {
1✔
500
                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture string cannot contain a per-mille character and a percent character");
1✔
501
            }
502
            subPicture.setHasPercent(true);
1✔
503
        } else if (c == decimalFormat.perMille) {
1✔
504
            if (subPicture.hasPerMille()) {
1✔
505
                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture string contains more than one per-mille character");
1✔
506
            } else if (subPicture.hasPercent()) {
1✔
507
                throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture string cannot contain a percent character and a per-mille character");
1✔
508
            }
509
            subPicture.setHasPerMille(true);
1✔
510
        }
511
    }
1✔
512

513
    private static boolean isDecimalDigit(final DecimalFormat decimalFormat, final int c) {
514
        return c >= decimalFormat.zeroDigit && c <= decimalFormat.zeroDigit + 9;
1✔
515
    }
516

517
    private static boolean isActiveChar(final DecimalFormat decimalFormat, final int c) {
518
        return c == decimalFormat.decimalSeparator
1✔
519
                || c == decimalFormat.exponentSeparator
1✔
520
                || c == decimalFormat.groupingSeparator
1✔
521
                || c == decimalFormat.digit
1✔
522
                || c == decimalFormat.patternSeparator
1!
523
                || isDecimalDigit(decimalFormat, c);
1✔
524
    }
525

526
    private String format(final NumericValue number, final DecimalFormat decimalFormat, final Tuple2<SubPicture, Optional<SubPicture>> subPictures) throws XPathException {
527

528
        // Rule 1: return NaN for NaN
529
        if (number.isNaN()) {
1!
530
            return decimalFormat.NaN;
×
531
        }
532

533
        // Rule 2: should we use the positive or negative sub-picture
534
        final SubPicture subPicture;
535
        if (number.isNegative()) {
1!
536
            subPicture = subPictures._2.orElseGet(() -> subPictures._1.copy().negate(decimalFormat));
×
537
        } else {
×
538
            subPicture = subPictures._1;
1✔
539
        }
540

541
        /*
542
        In the rules below, the positive sub-picture and its associated variables are used if the input number is positive,
543
        and the negative sub-picture and its associated variables are used if it is negative. For xs:double and xs:float,
544
        negative zero is taken as negative, positive zero as positive. For xs:decimal and xs:integer, the positive
545
        sub-picture is used for zero.
546
         */
547

548
        // Rule 3: adjust for percent or permille
549
        NumericValue adjustedNumber;
550
        if (subPicture.hasPercent()) {
1✔
551
            adjustedNumber = (NumericValue) number.mult(new IntegerValue(this, 100));
1✔
552
        } else if(subPicture.hasPerMille()) {
1!
553
            adjustedNumber = (NumericValue) number.mult(new IntegerValue(this, 1000));
×
554
        } else {
×
555
            adjustedNumber = number;
1✔
556
        }
557

558
        // Rule 4: return infinity for infinity
559
        if (adjustedNumber.isInfinite()) {
1!
560
            return subPicture.getPrefixString() + decimalFormat.infinity + subPicture.getSuffixString();
×
561
        }
562

563
        // Rule 5 and 6: adjust for exponent
564
        // Rule 5 and 6 were modified from BaseX code, Copyright BaseX Team 2005-19, BSD License
565
        int exp = 0;
1✔
566
        if (subPicture.getMinimumExponentSize() > 0 && !number.isZero()) {
1!
567
            BigDecimal dec = number.convertTo(Type.DECIMAL).toJavaObject(BigDecimal.class).abs().stripTrailingZeros();
×
568
            int scl = 0;
×
569
            if (dec.compareTo(BigDecimal.ONE) >= 0) {
×
570
                scl = dec.setScale(0, RoundingMode.HALF_DOWN).precision();
×
571
            } else {
×
572
                while (dec.compareTo(BigDecimal.ONE) < 0) {
×
573
                    dec = dec.multiply(BigDecimal.TEN);
×
574
                    scl--;
×
575
                }
576
                scl++;
×
577
            }
578
            exp = scl - subPicture.getScalingFactor();
×
579
            if (exp != 0) {
×
580
                final BigDecimal n = BigDecimal.TEN.pow(Math.abs(exp));
×
581
                adjustedNumber = (NumericValue) adjustedNumber.mult(new DecimalValue(this, exp > 0 ? BigDecimal.ONE.divide(n, MathContext.DECIMAL64) : n));
×
582
            }
583
        }
584

585
        adjustedNumber = new DecimalValue(this, adjustedNumber.convertTo(Type.DECIMAL).toJavaObject(BigDecimal.class).multiply(BigDecimal.ONE, MathContext.DECIMAL64)).round(new IntegerValue(this, subPicture.getMaximumFractionalPartSize())).abs();
1✔
586

587
        /* we can now start formatting for display */
588

589
        // Rule 7 - must always contain a decimal-separator, and it must contain no leading zeroes and no trailing zeroes.
590
        final CodePointString formatted = new CodePointString(adjustedNumber
1✔
591
                .toJavaObject(BigDecimal.class)
1✔
592
                .toPlainString());
1✔
593

594
        formatted.replaceFirst('.', decimalFormat.decimalSeparator);
1✔
595
        // this string must always contain a decimal-separator
596
        if (!formatted.contains(decimalFormat.decimalSeparator)) {
1✔
597
            formatted.append(decimalFormat.decimalSeparator);
1✔
598
        }
599
        // must contain no leading zeroes and no trailing zeroes
600
        formatted.leftTrim('0');
1✔
601
        formatted.rightTrim('0');
1✔
602

603
        // covert to using the digits in the decimal digit family to represent the ten decimal digits
604
        if (decimalFormat.zeroDigit != '0') {
1!
605
            formatted.transform('0', '9', decimalFormat.zeroDigit);
×
606
        }
607

608

609
        int idxDecimalSeparator = formatted.indexOf(decimalFormat.decimalSeparator);
1✔
610

611
        // Rule 8 - Left pad
612
        int intLength =  idxDecimalSeparator > -1 ? idxDecimalSeparator : formatted.length();
1!
613
        final int leftPadLen = subPicture.getMinimumIntegerPartSize() - intLength;
1✔
614
        if (leftPadLen > 0) {
1✔
615
            formatted.leftPad(decimalFormat.zeroDigit, leftPadLen);
1✔
616

617
            idxDecimalSeparator = formatted.indexOf(decimalFormat.decimalSeparator);
1✔
618
            intLength =  idxDecimalSeparator > -1 ? idxDecimalSeparator : formatted.length();
1!
619
        }
620

621
        // Rule 9 - Right pad
622
        int fractLen =  idxDecimalSeparator > -1 ?  formatted.length() - (idxDecimalSeparator + 1) : 0;
1!
623
        final int rightPadLen = subPicture.getMinimumFractionalPartSize() - fractLen;
1✔
624
        if (rightPadLen > 0) {
1✔
625
            formatted.rightPad(decimalFormat.zeroDigit, rightPadLen);
1✔
626

627
            idxDecimalSeparator = formatted.indexOf(decimalFormat.decimalSeparator);
1✔
628
            fractLen =  idxDecimalSeparator > -1 ?  formatted.length() - (idxDecimalSeparator + 1) : 0;
1!
629
        }
630

631
        // Rule 10 - Integer part groupings
632
        @Nullable final int[] integerPartGroupingPositions = subPicture.getIntegerPartGroupingPositions();
1✔
633
        if (integerPartGroupingPositions != null) {
1✔
634
            final int g = subPicture.integerPartGroupingPositionsAreRegular();
1✔
635
            if (g > -1) {
1!
636
                // regular grouping
637
                int m = intLength / g;
1✔
638
                if (intLength % g == 0) {
1✔
639
                    m--; // prevents a group separator being inserted at index 0
1✔
640
                }
641
                if (m > -1) {
1!
642
                    final int[] relGroupingOffsets = new int[m];
1✔
643
                    for (; m > 0; m--) {
1✔
644
                        final int groupingIdx = idxDecimalSeparator - (m * g);
1✔
645
                        relGroupingOffsets[m - 1] = groupingIdx;
1✔
646
                    }
647
                    formatted.insert(relGroupingOffsets, decimalFormat.groupingSeparator);
1✔
648

649
                    idxDecimalSeparator = formatted.indexOf(decimalFormat.decimalSeparator);
1✔
650
                }
651
            } else {
1✔
652
                // non-regular grouping
653
                final int[] relGroupingOffsets = new int[integerPartGroupingPositions.length];
×
654
                for (int i = 0; i < integerPartGroupingPositions.length; i++) {
×
655
                    final int integerPartGroupingPosition = integerPartGroupingPositions[i];
×
656
                    final int groupingIdx = idxDecimalSeparator - integerPartGroupingPosition;
×
657
                    relGroupingOffsets[i] = groupingIdx;
×
658
                }
659
                formatted.insert(relGroupingOffsets, decimalFormat.groupingSeparator);
×
660

661
                idxDecimalSeparator = formatted.indexOf(decimalFormat.decimalSeparator);
×
662

663
            }
664
        }
665

666
        // Rule 11 - Fractional part groupings
667
        @Nullable final int[] fractionalPartGroupingPositions = subPicture.getFractionalPartGroupingPositions();
1✔
668
        if (fractionalPartGroupingPositions != null) {
1✔
669
            int[] relGroupingOffsets = new int[0];
1✔
670
            for (int i = 0; i < fractionalPartGroupingPositions.length; i++) {
1✔
671
                final int fractionalPartGroupingPosition = fractionalPartGroupingPositions[i];
1✔
672
                final int groupingIdx = idxDecimalSeparator + 1 + fractionalPartGroupingPosition;
1✔
673
                if (groupingIdx <= formatted.length()) {
1✔
674
                    relGroupingOffsets = Arrays.copyOf(relGroupingOffsets, relGroupingOffsets.length + 1);
1✔
675
                    relGroupingOffsets[i] = groupingIdx;
1✔
676
                } else {
677
                    break;
678
                }
679
            }
680

681
            if (relGroupingOffsets.length > 0) {
1✔
682
                formatted.insert(relGroupingOffsets, decimalFormat.groupingSeparator);
1✔
683
            }
684

685
            fractLen =  idxDecimalSeparator > -1 ?  formatted.length() - (idxDecimalSeparator + 1) : 0;
1!
686
        }
687

688
        // Rule 12 - strip decimal separator if unneeded
689
        if (!subPicture.hasDecimalSeparator() || fractLen == 0) {
1!
690
            formatted.removeFirst(decimalFormat.decimalSeparator);
1✔
691
        }
692

693
        // Rule 13 - add exponent if exists
694
        final int minimumExponentSize = subPicture.getMinimumExponentSize();
1✔
695
        if (minimumExponentSize > 0) {
1!
696
            formatted.append(decimalFormat.exponentSeparator);
×
697
            if (exp < 0) {
×
698
                formatted.append(decimalFormat.minusSign);
×
699
            }
700

701
            final CodePointString expStr = new CodePointString(String.valueOf(exp));
×
702

703
            final int expPadLen = subPicture.getMinimumExponentSize() - expStr.length();
×
704
            if (expPadLen > 0) {
×
705
                expStr.leftPad(decimalFormat.zeroDigit, expPadLen);
×
706
            }
707

708
            formatted.append(expStr);
×
709
        }
710

711
        // Rule 14 - concatenate prefix, formatted number, and suffix
712
        final String result = subPicture.getPrefixString() + formatted.toString() + subPicture.getSuffixString();
1✔
713

714
        return result;
1✔
715
    }
716

717
    /**
718
     * Data class for a SubPicture.
719
     *
720
     * See https://www.w3.org/TR/xpath-functions-31/#analyzing-picture-string
721
     */
722
    private static class SubPicture {
1✔
723
        private int[] integerPartGroupingPositions;
724
        private int minimumIntegerPartSize;
725
        private int scalingFactor;
726
        private StringBuilder prefix;
727
        private int[] fractionalPartGroupingPositions;
728
        private int minimumFractionalPartSize;
729
        private int maximumFractionalPartSize;
730
        private int minimumExponentSize;
731
        private StringBuilder suffix;
732

733
        // state needed for adjustment
734
        private boolean hasIntegerOptionalDigit = false;
1✔
735
        private boolean hasPercent = false;
1✔
736
        private boolean hasPerMille = false;
1✔
737
        private boolean hasDecimalSeparator = false;
1✔
738

739
        public SubPicture copy() {
740
            final SubPicture copy = new SubPicture();
×
741

742
            copy.integerPartGroupingPositions = integerPartGroupingPositions == null ? null : Arrays.copyOf(integerPartGroupingPositions, integerPartGroupingPositions.length);
×
743
            copy.minimumIntegerPartSize = minimumIntegerPartSize;
×
744
            copy.scalingFactor = scalingFactor;
×
745
            copy.prefix = prefix == null ? null : new StringBuilder(prefix);
×
746
            copy.fractionalPartGroupingPositions = fractionalPartGroupingPositions == null ? null : Arrays.copyOf(fractionalPartGroupingPositions, fractionalPartGroupingPositions.length);
×
747
            copy.minimumFractionalPartSize = minimumFractionalPartSize;
×
748
            copy.maximumFractionalPartSize = maximumFractionalPartSize;
×
749
            copy.minimumExponentSize = minimumExponentSize;
×
750
            copy.suffix = suffix == null ? null : new StringBuilder(suffix);
×
751

752
            copy.hasIntegerOptionalDigit = hasIntegerOptionalDigit;
×
753
            copy.hasPercent = hasPercent;
×
754
            copy.hasPerMille = hasPerMille;
×
755
            copy.hasDecimalSeparator = hasDecimalSeparator;
×
756

757
            return copy;
×
758
        }
759

760
        public SubPicture negate(final DecimalFormat decimalFormat) {
761
            this.prefix = new StringBuilder().appendCodePoint(decimalFormat.minusSign).append(getPrefixString());
×
762
            return this;
×
763
        }
764

765
        public void newIntegerPartGroupingPosition() {
766
            if (integerPartGroupingPositions == null) {
1✔
767
                integerPartGroupingPositions = new int[1];
1✔
768
            } else {
1✔
769
                integerPartGroupingPositions = Arrays.copyOf(integerPartGroupingPositions, integerPartGroupingPositions.length + 1);
1✔
770
            }
771
        }
1✔
772

773
        public void incrementIntegerPartGroupingPosition() {
774
            if (integerPartGroupingPositions == null) {
1✔
775
                return;
1✔
776
            }
777
            for (int i = 0; i < integerPartGroupingPositions.length; i++) {
1✔
778
                integerPartGroupingPositions[i]++;
1✔
779
            }
780
        }
1✔
781

782
        public @Nullable int[] getIntegerPartGroupingPositions() {
783
            return integerPartGroupingPositions;
1✔
784
        }
785

786
        /**
787
         * Determines if the <code>integer-part-grouping-positions</code> are regular.
788
         *
789
         * @return the value of G if regular, or -1 if irregular
790
         */
791
        public int integerPartGroupingPositionsAreRegular() {
792
            // There is an least one grouping-separator in the integer part of the sub-picture.
793
            if (integerPartGroupingPositions.length > 0) {
1!
794

795
                // There is a positive integer G (the grouping size) such that the position of every grouping-separator
796
                // in the integer part of the sub-picture is a positive integer multiple of G.
797
                final int smallestGroupPosition = integerPartGroupingPositions[integerPartGroupingPositions.length - 1];
1✔
798
                int g = smallestGroupPosition;
1✔
799
                boolean divisible = false;
1✔
800
                for (; g > 0; g--) {
1!
801
                    divisible = false;
1✔
802
                    for (final int integerPartGroupingPosition : integerPartGroupingPositions) {
1✔
803
                        divisible = integerPartGroupingPosition % g == 0;
1!
804
                        if (!divisible) {
1!
805
                            break;
×
806
                        }
807
                    }
808

809
                    if (divisible) {
1!
810
                        break;
1✔
811
                    }
812
                }
813

814
                if (!divisible) {
1!
815
                    return -1;
×
816
                }
817

818
                // Every position in the integer part of the sub-picture that is a positive integer multiple of G is
819
                // occupied by a grouping-separator.
820
                final int largestGroupPosition = integerPartGroupingPositions[integerPartGroupingPositions.length - 1];
1✔
821
                int m = 2;
1✔
822
                for (int p = g; p <= largestGroupPosition; p = g * m++) {
1✔
823

824
                    boolean isGroupSeparator = false;
1✔
825
                    for (final int integerPartGroupingPosition : integerPartGroupingPositions) {
1!
826
                        if (integerPartGroupingPosition == p) {
1✔
827
                            isGroupSeparator = true;
1✔
828
                            break;
1✔
829
                        }
830
                    }
831

832
                    if (!isGroupSeparator) {
1!
833
                        return -1;
×
834
                    }
835
                }
836

837
                return g;
1✔
838
            }
839

840
            return -1;
×
841
        }
842

843
        public void incrementMinimumIntegerPartSize() {
844
            minimumIntegerPartSize++;
1✔
845
        }
1✔
846

847
        public int getMinimumIntegerPartSize() {
848
            return minimumIntegerPartSize;
1✔
849
        }
850

851
        public void incrementScalingFactor() {
852
            scalingFactor++;
1✔
853
        }
1✔
854

855
        public int getScalingFactor() {
856
            return scalingFactor;
×
857
        }
858

859
        public void appendPrefix(final int c) {
860
            if (prefix == null) {
×
861
                prefix = new StringBuilder().appendCodePoint(c);
×
862
            } else {
×
863
                prefix = prefix.appendCodePoint(c);
×
864
            }
865
        }
×
866

867
        public String getPrefixString() {
868
            if (prefix == null) {
1!
869
                return "";
1✔
870
            } else {
871
                return prefix.toString();
×
872
            }
873
        }
874

875
        public void newFractionalPartGroupingPosition() {
876
            if (fractionalPartGroupingPositions == null) {
1✔
877
                fractionalPartGroupingPositions = new int[1];
1✔
878
            } else {
1✔
879
                fractionalPartGroupingPositions = Arrays.copyOf(fractionalPartGroupingPositions, fractionalPartGroupingPositions.length + 1);
1✔
880
            }
881

882
            fractionalPartGroupingPositions[fractionalPartGroupingPositions.length - 1] = maximumFractionalPartSize;
1✔
883
        }
1✔
884

885
        public @Nullable int[] getFractionalPartGroupingPositions() {
886
            return fractionalPartGroupingPositions;
1✔
887
        }
888

889
        public void incrementMinimumFractionalPartSize() {
890
            minimumFractionalPartSize++;
1✔
891
        }
1✔
892

893
        public int getMinimumFractionalPartSize() {
894
            return minimumFractionalPartSize;
1✔
895
        }
896

897
        public void incrementMaximumFractionalPartSize() {
898
            maximumFractionalPartSize++;
1✔
899
        }
1✔
900

901
        public int getMaximumFractionalPartSize() {
902
            return maximumFractionalPartSize;
1✔
903
        }
904

905
        public void incrementMinimumExponentSize() {
906
            minimumExponentSize++;
×
907
        }
×
908

909
        public int getMinimumExponentSize() {
910
            return minimumExponentSize;
1✔
911
        }
912

913
        public void appendSuffix(final int c) {
914
            if (suffix == null) {
1✔
915
                suffix = new StringBuilder().appendCodePoint(c);
1✔
916
            } else {
1✔
917
                suffix = suffix.appendCodePoint(c);
1✔
918
            }
919
        }
1✔
920

921
        public void clearSuffix() {
922
            if (suffix != null) {
1!
923
               suffix.setLength(0);
×
924
            }
925
        }
1✔
926

927
        public String getSuffixString() {
928
            if (suffix == null) {
1✔
929
                return "";
1✔
930
            } else {
931
                return suffix.toString();
1✔
932
            }
933
        }
934

935
        public void setHasIntegerOptionalDigit(final boolean hasIntegerOptionalDigit) {
936
            this.hasIntegerOptionalDigit = hasIntegerOptionalDigit;
1✔
937
        }
1✔
938

939
        public boolean hasIntegerOptionalDigit() {
940
            return hasIntegerOptionalDigit;
1✔
941
        }
942

943
        public void setHasPercent(final boolean hasPercent) {
944
            this.hasPercent = hasPercent;
1✔
945
        }
1✔
946

947
        public boolean hasPercent() {
948
            return hasPercent;
1✔
949
        }
950

951
        public void setHasPerMille(final boolean hasPerMille) {
952
            this.hasPerMille = hasPerMille;
1✔
953
        }
1✔
954

955
        public boolean hasPerMille() {
956
            return hasPerMille;
1✔
957
        }
958

959
        public void setHasDecimalSeparator(final boolean hasDecimalSeparator) {
960
            this.hasDecimalSeparator = hasDecimalSeparator;
1✔
961
        }
1✔
962

963
        public boolean hasDecimalSeparator() {
964
            return hasDecimalSeparator;
1✔
965
        }
966

967
        public SubPicture adjust() {
968
            if (minimumIntegerPartSize == 0 && maximumFractionalPartSize == 0) {
1!
969
                if (minimumExponentSize > 0) {
×
970
                    minimumFractionalPartSize = 1;
×
971
                    maximumFractionalPartSize = 1;
×
972
                } else {
×
973
                    minimumIntegerPartSize = 1;
×
974
                }
975
            }
976

977
            if (minimumExponentSize > 0  && minimumIntegerPartSize == 0 && hasIntegerOptionalDigit) {
1!
978
                minimumIntegerPartSize = 1;
×
979
            }
980

981
            if (minimumIntegerPartSize == 0 && minimumFractionalPartSize == 0) {
1!
982
                minimumFractionalPartSize = 1;
1✔
983
            }
984

985
            return this;
1✔
986
        }
987
    }
988
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc