diff --git a/exist-core/pom.xml b/exist-core/pom.xml index 377bea02d7..f38e489555 100644 --- a/exist-core/pom.xml +++ b/exist-core/pom.xml @@ -791,6 +791,7 @@ src/test/resources/standalone-webapp/WEB-INF/web.xml src/test/xquery/tail-recursion.xml src/test/xquery/maps/maps.xqm + src/test/xquery/numbers/format-numbers.xql src/test/xquery/util/util.xml src/test/xquery/xquery3/parse-xml.xqm src/test/xquery/xquery3/serialize.xql @@ -1102,6 +1103,7 @@ src/test/java/org/exist/xquery/CleanupTest.java src/test/java/org/exist/xquery/ConstructedNodesRecoveryTest.java src/main/java/org/exist/xquery/Context.java + src/main/java/org/exist/xquery/DecimalFormat.java src/main/java/org/exist/xquery/DeferredFunctionCall.java src/main/java/org/exist/xquery/DynamicCardinalityCheck.java src/main/java/org/exist/xquery/DynamicTypeCheck.java @@ -1284,6 +1286,7 @@ src/main/java/org/exist/xquery/functions/xmldb/XMLDBStore.java src/main/java/org/exist/xquery/functions/xmldb/XMLDBXUpdate.java src/test/java/org/exist/xquery/functions/xquery3/TryCatchTest.java + src/main/antlr/org/exist/xquery/parser/XQuery.g src/main/antlr/org/exist/xquery/parser/XQueryTree.g src/test/java/org/exist/xquery/update/AbstractUpdateTest.java src/test/java/org/exist/xquery/update/IndexIntegrationTest.java @@ -1374,6 +1377,7 @@ src/test/xquery/tail-recursion.xml src/test/xquery/type-promotion.xqm src/test/xquery/maps/maps.xqm + src/test/xquery/numbers/format-numbers.xql src/test/xquery/securitymanager/acl.xqm src/test/xquery/util/util.xml src/test/xquery/xqsuite/xqsuite-assertions-dynamic.xqm @@ -1781,6 +1785,7 @@ src/test/java/org/exist/xquery/CleanupTest.java src/test/java/org/exist/xquery/ConstructedNodesRecoveryTest.java src/main/java/org/exist/xquery/Context.java + src/main/java/org/exist/xquery/DecimalFormat.java src/main/java/org/exist/xquery/DeferredFunctionCall.java src/main/java/org/exist/xquery/DynamicCardinalityCheck.java src/main/java/org/exist/xquery/DynamicTypeCheck.java @@ -1989,6 +1994,7 @@ src/main/java/org/exist/xquery/functions/xmldb/XMLDBXUpdate.java src/test/java/org/exist/xquery/functions/xquery3/SerializeTest.java src/test/java/org/exist/xquery/functions/xquery3/TryCatchTest.java + src/main/antlr/org/exist/xquery/parser/XQuery.g src/main/antlr/org/exist/xquery/parser/XQueryTree.g src/main/java/org/exist/xquery/pragmas/TimePragma.java src/test/java/org/exist/xquery/update/AbstractUpdateTest.java diff --git a/exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g b/exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g index bc59b416c8..d9c7a228fb 100644 --- a/exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g +++ b/exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g @@ -1,4 +1,28 @@ /* + * Elemental + * Copyright (C) 2024, Evolved Binary Ltd + * + * admin@evolvedbinary.com + * https://www.evolvedbinary.com | https://www.elemental.xyz + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * NOTE: Parts of this file contain code from 'The eXist-db Authors'. + * The original license header is included below. + * + * ===================================================================== + * * eXist-db Open Source Native XML Database * Copyright (C) 2001 The eXist-db Authors * @@ -150,6 +174,8 @@ imaginaryTokenDefinitions NAMESPACE_DECL DEF_NAMESPACE_DECL DEF_COLLATION_DECL + DECIMAL_FORMAT_DECL + DEFAULT_DECIMAL_FORMAT DEF_FUNCTION_NS_DECL CONTEXT_ITEM_DECL ANNOT_DECL @@ -256,7 +282,7 @@ prolog throws XPathException ( importDecl | - ( "declare" ( "default" | "boundary-space" | "ordering" | "construction" | "base-uri" | "copy-namespaces" | "namespace" ) ) => + ( "declare" ( "default" | "boundary-space" | "ordering" | "construction" | "base-uri" | "copy-namespaces" | "namespace" | "decimal-format" ) ) => s:setter { if(!inSetters) @@ -295,10 +321,44 @@ versionDecl throws XPathException { #versionDecl = #(#[VERSION_DECL, v.getText()], enc); } ; +dfPropertyName +: + "decimal-separator" + | "grouping-separator" + | "infinity" + | "minus-sign" + | "NaN" + | "percent" + | "per-mille" + | "zero-digit" + | "digit" + | "pattern-separator" + | "exponent-separator" + ; + +decimalFormatDecl +{ String dfName = null; } +: + "declare"! + ( + "default" "decimal-format" + ( dfPropertyName EQ! STRING_LITERAL )* + { + ## = #( #[DECIMAL_FORMAT_DECL, "DECIMAL_FORMAT_DECL"], #[DEFAULT_DECIMAL_FORMAT, "DEFAULT_DECIMAL_FORMAT"], ## ); + } + | + "decimal-format" eqName + ( dfPropertyName EQ! STRING_LITERAL )* + { + ## = #( #[DECIMAL_FORMAT_DECL, "DECIMAL_FORMAT_DECL"], ## ); + } + ) +; + setter : ( - ( "declare" "default" ) => + ( "declare" "default" ( "collation" | "element" | "function" | "order" ) ) => "declare"! "default"! ( "collation"! defc:STRING_LITERAL @@ -330,6 +390,8 @@ setter | ( "declare" "namespace" ) => namespaceDecl + | ( "declare" ( "default" )? "decimal-format" ) => + decimalFormatDecl ) ; diff --git a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g index b7c04757f4..14a547257c 100644 --- a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g +++ b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g @@ -106,6 +106,7 @@ options { protected Set importedModules = new HashSet<>(); protected Set importedModuleFunctions = null; protected Set importedModuleVariables = null; + private boolean hasDefaultDecimalFormat = false; public XQueryTreeParser(XQueryContext context) { this(context, null); @@ -511,6 +512,76 @@ throws PermissionDeniedException, EXistException, XPathException } ) | + #( + DECIMAL_FORMAT_DECL + { + final XQueryAST root = (XQueryAST) _t; // points to DECIMAL_FORMAT_DECL + // first sibling is either DEFAULT_DECIMAL_FORMAT (default) or EQNAME (named) + final XQueryAST dfName = (XQueryAST) root.getNextSibling(); + + final QName qnDfName; + if ("default".equals(dfName.getText())) { + qnDfName = XQueryContext.UNNAMED_DECIMAL_FORMAT; + if (hasDefaultDecimalFormat) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.W3CErrorCode.XQST0111.getErrorCode(), "Query prolog cannot contain two default decimal format declarations."); + } else { + hasDefaultDecimalFormat = true; + } + } else { + try { + qnDfName = QName.parse(staticContext, dfName.getText(), null); + } catch (final IllegalQNameException iqe) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + dfName.getText()); + } + + if (staticContext.getStaticDecimalFormat(qnDfName) != null) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.W3CErrorCode.XQST0111.getErrorCode(), "Query prolog cannot contain two decimal format declarations with the same name: " + dfName.getText()); + } + } + + // position current at the first property name for the decimal format + XQueryAST current = (XQueryAST) dfName.getNextSibling(); + if ("default".equals(dfName.getText())) { + current = (XQueryAST) current.getNextSibling(); + } + + final Map dfProperties = new HashMap<>(); + + while (current != null) { + final XQueryAST pname = current; + final XQueryAST pval = (XQueryAST) current.getNextSibling(); + + if (pval == null) { + break; + } + + final String pn = pname.getText(); + String pv = pval.getText(); + if (pv.length() >= 2 && (pv.startsWith("\"") || pv.startsWith("'"))) { + pv = pv.substring(1, pv.length() - 1); + } + if (dfProperties.put(pn, pv) != null) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.W3CErrorCode.XQST0114.getErrorCode(), "Decimal format: " + dfName.getText() + " defines the property: " + pn + " more than once."); + } + + current = (XQueryAST) pval.getNextSibling(); + } + + final DecimalFormat df; + try { + df = DecimalFormat.fromProperties(dfProperties); + } catch (final IllegalArgumentException ex) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.W3CErrorCode.XQST0097.getErrorCode(), ex.getMessage() + " within the picture string of the decimal format: " + dfName.getText() + "."); + } + if (!df.checkDistinctCharacters()) { + throw new XPathException(dfName.getLine(), dfName.getColumn(), ErrorCodes.W3CErrorCode.XQST0098.getErrorCode(), "Characters within the picture string of the decimal format: " + dfName.getText() + " are not distinct."); + } + + staticContext.setStaticDecimalFormat(qnDfName, df); + context.setStaticDecimalFormat(qnDfName, df); + } + ) + | #( qname:GLOBAL_VAR { diff --git a/exist-core/src/main/java/org/exist/util/CodePointString.java b/exist-core/src/main/java/org/exist/util/CodePointString.java index d97b362d46..f2083d1c1b 100644 --- a/exist-core/src/main/java/org/exist/util/CodePointString.java +++ b/exist-core/src/main/java/org/exist/util/CodePointString.java @@ -353,14 +353,14 @@ public CodePointString insert(final int[] indexes, final int codePoint) { * @return this */ public CodePointString removeFirst(final int codePoint) { - int idx = -1; - for (int i = 0; i < codePoints.length; i++) { - if (codePoints[i] == codePoint) { - idx = i; - break; - } - } + final int idx = indexOf(codePoint); + return removeChar(idx); + } + /** + * Removes the codepoint at the specified index. + */ + public CodePointString removeChar(final int idx) { if (idx > -1) { final int[] newCodePoints = new int[codePoints.length - 1]; diff --git a/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java b/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java index 46a54962ad..b9cd96c72a 100644 --- a/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java +++ b/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java @@ -1,4 +1,28 @@ /* + * Elemental + * Copyright (C) 2024, Evolved Binary Ltd + * + * admin@evolvedbinary.com + * https://www.evolvedbinary.com | https://www.elemental.xyz + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * NOTE: Parts of this file contain code from 'The eXist-db Authors'. + * The original license header is included below. + * + * ===================================================================== + * * eXist-db Open Source Native XML Database * Copyright (C) 2001 The eXist-db Authors * @@ -21,6 +45,8 @@ */ package org.exist.xquery; +import java.util.Map; + /** * Data class for a Decimal Format. * @@ -79,4 +105,135 @@ public DecimalFormat(final int decimalSeparator, final int exponentSeparator, fi this.NaN = NaN; this.minusSign = minusSign; } + + /** + * Checks that the characters used in a picture string have distinct values. + * + * @return true if all the characters are distinct, false otherwise. + */ + public boolean checkDistinctCharacters() { + final int[] characters = new int[] { + decimalSeparator, + exponentSeparator, + groupingSeparator, + percent, + perMille, + zeroDigit, + digit, + patternSeparator + }; + + for (int i = 0; i < characters.length; i++) { + final int c = characters[i]; + for (int j = i + 1; j < characters.length; j++) { + final int o = characters[j]; + if (c == o) { + return false; + } + } + } + + return true; + } + + /** + * Constructs a Decimal Format from a map of decimal format properties. + * + * @param properties the properties for the decimal format. + * + * @return the Decimal Format. + * + * @throws IllegalArgumentException if any of the properties are invalid. + */ + public static DecimalFormat fromProperties(final Map properties) throws IllegalArgumentException { + int decimalSeparator = UNNAMED.decimalSeparator; + int exponentSeparator = UNNAMED.exponentSeparator; + int groupingSeparator = UNNAMED.groupingSeparator; + int percent = UNNAMED.percent; + int perMille = UNNAMED.perMille; + int zeroDigit = UNNAMED.zeroDigit; + int digit = UNNAMED.digit; + int patternSeparator = UNNAMED.patternSeparator; + String infinity = UNNAMED.infinity; + String NaN = UNNAMED.NaN; + int minusSign = UNNAMED.minusSign; + + for (final Map.Entry property : properties.entrySet()) { + final String value = property.getValue(); + switch (property.getKey()) { + case "decimal-separator": + if (value.length() != 1) { + throw new IllegalArgumentException("decimal-separator must be a single character"); + } + decimalSeparator = value.charAt(0); + break; + + case "exponent-separator": + if (value.length() != 1) { + throw new IllegalArgumentException("exponent-separator must be a single character"); + } + exponentSeparator = value.charAt(0); + break; + + case "grouping-separator": + if (value.length() != 1) { + throw new IllegalArgumentException("groupung-separator must be a single character"); + } + groupingSeparator = value.charAt(0); + break; + + case "percent": + if (value.length() != 1) { + throw new IllegalArgumentException("percent must be a single character"); + } + percent = value.charAt(0); + break; + + case "per-mille": + if (value.length() != 1) { + throw new IllegalArgumentException("per-mille must be a single character"); + } + perMille = value.charAt(0); + break; + + case "zero-digit": + if (value.length() != 1) { + throw new IllegalArgumentException("zero-digit must be a single character"); + } + zeroDigit = value.charAt(0); + break; + + case "digit": + if (value.length() != 1) { + throw new IllegalArgumentException("digit must be a single character"); + } + digit = value.charAt(0); + break; + + case "pattern-separator": + if (value.length() != 1) { + throw new IllegalArgumentException("pattern-separator must be a single character"); + } + patternSeparator = value.charAt(0); + break; + + case "infinity": + infinity = value; + break; + + case "NaN": + NaN = value; + break; + + case "minus-sign": + if (value.length() != 1) { + throw new IllegalArgumentException("minus-sign must be a single character"); + } + minusSign = value.charAt(0); + break; + } + } + + return new DecimalFormat(decimalSeparator, exponentSeparator, groupingSeparator, percent, perMille, zeroDigit, digit, patternSeparator, infinity, NaN, minusSign); + } } diff --git a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java index bacca169ef..6183cc35b2 100644 --- a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java +++ b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java @@ -220,9 +220,13 @@ public enum W3CErrorCode implements IErrorCode { XQDY0092 ("An implementation MAY raise a dynamic error if a constructed attribute named xml:space has a value other than preserve or default."), XQST0093 ("It is a static error to import a module M1 if there exists a sequence of modules M1 ... Mi ... M1 such that each module directly depends on the next module in the sequence (informally, if M1 depends on itself through some chain of module dependencies.)"), XQST0094 ("The name of each grouping variable must be equal (by the eq operator on expanded QNames) to the name of a variable in the input tuple stream."), + XQST0097 ("It is a static error for a decimal-format to specify a value that is not valid for a given property, as described in statically known decimal formats"), + XQST0098 ("It is a static error if, for any named or unnamed decimal format, the properties representing characters used in a picture string do not each have distinct values. The following properties represent characters used in a picture string: decimal-separator, exponent-separator, grouping-separator, percent, per-mille, the family of ten decimal digits starting with zero-digit, digit, and pattern-separator."), XQDY0101 ("An error is raised if a computed namespace constructor attempts to do any of the following: Bind the prefix xml to some namespace URI other than http://www.w3.org/XML/1998/namespace. Bind a prefix other than xml to the namespace URI http://www.w3.org/XML/1998/namespace. Bind the prefix xmlns to any namespace URI. Bind a prefix to the namespace URI http://www.w3.org/2000/xmlns/. Bind any prefix (including the empty prefix) to a zero-length namespace URI."), XQDY0102 ("If the name of an element in an element constructor is in no namespace, creating a default namespace for that element using a computed namespace constructor is an error."), XQST0103 ("All variables in a window clause must have distinct names."), + XQST0111 ("It is a static error for a query prolog to contain two decimal formats with the same name, or to contain two default decimal formats."), + XQST0114 ("It is a static error for a decimal format declaration to define the same property more than once."), XQDY0137 ("No two keys in a map may have the same key value"), XQDY0138 ("Position n does not exist in this array"), XUDY0023 ("It is a dynamic error if an insert, replace, or rename expression affects an element node by introducing a new namespace binding that conflicts with one of its existing namespace bindings."), diff --git a/exist-core/src/main/java/org/exist/xquery/XQueryContext.java b/exist-core/src/main/java/org/exist/xquery/XQueryContext.java index be3f62d21f..1eb9ff65b6 100644 --- a/exist-core/src/main/java/org/exist/xquery/XQueryContext.java +++ b/exist-core/src/main/java/org/exist/xquery/XQueryContext.java @@ -443,7 +443,7 @@ public class XQueryContext implements BinaryValueManager, Context { */ @Nullable private HttpContext httpContext = null; - private static final QName UNNAMED_DECIMAL_FORMAT = new QName("__UNNAMED__", Namespaces.XPATH_FUNCTIONS_NS); + public static final QName UNNAMED_DECIMAL_FORMAT = new QName("__UNNAMED__", Namespaces.XPATH_FUNCTIONS_NS); private final Map staticDecimalFormats = hashMap(Tuple(UNNAMED_DECIMAL_FORMAT, DecimalFormat.UNNAMED)); diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java index 6f3a08bcb0..b272a524b9 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java @@ -331,6 +331,10 @@ private Tuple2> analyzePictureString(final Deci analyzePassiveChar(decimalFormat, c, capturePrefix, subPicture); } + if (state == AnalyzeState.INTEGER_PART) { + subPicture.incrementIntegerPartExtent(); + } + break; // end of INTEGER_PART @@ -401,7 +405,7 @@ private Tuple2> analyzePictureString(final Deci subPicture.clearSuffix(); subPicture.incrementMaximumFractionalPartSize(); - } else if (c == decimalFormat.patternSeparator) { + } else if (c == decimalFormat.patternSeparator) { capturePrefix = false; subPicture.clearSuffix(); @@ -442,10 +446,9 @@ private Tuple2> analyzePictureString(final Deci break; // end of FRACTIONAL_PART - case EXPONENT_PART: + if (c == decimalFormat.decimalSeparator - || c == decimalFormat.exponentSeparator || c == decimalFormat.groupingSeparator || c == decimalFormat.digit) { capturePrefix = false; @@ -453,6 +456,45 @@ private Tuple2> analyzePictureString(final Deci throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture cannot have any active characters following the exponent-separator-sign"); + } else if (c == decimalFormat.exponentSeparator) { + + /* + A character that matches the exponent-separator property is treated as an + exponent-separator-sign if it is both preceded and followed within the + sub-picture by an active character. + */ + + // we need to peek at the next char to determine if it is active + final boolean nextIsActive; + if (idx + 1 < pictureString.length()) { + nextIsActive = isActiveChar(decimalFormat, pictureString.codePointAt(idx + 1)); + } else { + nextIsActive = false; + } + + if (isActiveChar(decimalFormat, prevChar) && nextIsActive) { + // this is an exponent-separator-sign... but we already have one + capturePrefix = false; + subPicture.clearSuffix(); + + throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture in $picture cannot have any active characters following the exponent-separator-sign"); + + } else { + // just another passive character + + /* passive character */ + analyzePassiveChar(decimalFormat, c, capturePrefix, subPicture); + + if (subPicture.hasPercent()) { + throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture cannot contain a percent character as it already has an exponent separator sign."); + } + + if (subPicture.hasPerMille()) { + throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture cannot contain a per-mille character as it already has an exponent separator sign."); + } + } + + } else if (c == decimalFormat.patternSeparator) { capturePrefix = false; subPicture.clearSuffix(); @@ -484,6 +526,14 @@ private Tuple2> analyzePictureString(final Deci } else { /* passive character */ analyzePassiveChar(decimalFormat, c, capturePrefix, subPicture); + + if (subPicture.hasPercent()) { + throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture cannot contain a percent character as it already has an exponent separator sign."); + } + + if (subPicture.hasPerMille()) { + throw new XPathException(this, ErrorCodes.FODF1310, "format-number() sub-picture cannot contain a per-mille character as it already has an exponent separator sign."); + } } break; // end of EXPONENT_PART @@ -601,7 +651,7 @@ private String format(final NumericValue number, final DecimalFormat decimalForm } } - adjustedNumber = new DecimalValue(this, adjustedNumber.convertTo(Type.DECIMAL).toJavaObject(BigDecimal.class).multiply(BigDecimal.ONE, MathContext.DECIMAL64)).round(new IntegerValue(this, subPicture.getMaximumFractionalPartSize())).abs(); + adjustedNumber = new DecimalValue(this, adjustedNumber.convertTo(Type.DECIMAL).toJavaObject(BigDecimal.class).multiply(BigDecimal.ONE, MathContext.DECIMAL128)).round(new IntegerValue(this, subPicture.getMaximumFractionalPartSize())).abs(); /* we can now start formatting for display */ @@ -706,17 +756,24 @@ private String format(final NumericValue number, final DecimalFormat decimalForm // Rule 12 - strip decimal separator if unneeded if (!subPicture.hasDecimalSeparator() || fractLen == 0) { - formatted.removeFirst(decimalFormat.decimalSeparator); + // decimal separator must be the rightmost character in the string + final int rightMostIndex = formatted.length() - 1; + final int rightMost = formatted.codePointAt(rightMostIndex); + if (rightMost == decimalFormat.decimalSeparator) { + formatted.removeChar(rightMostIndex); + } } // Rule 13 - add exponent if exists final int minimumExponentSize = subPicture.getMinimumExponentSize(); if (minimumExponentSize > 0) { formatted.append(decimalFormat.exponentSeparator); - if (exp < 0) { - formatted.append(decimalFormat.minusSign); - } + final boolean negativeExp = exp < 0; + if (negativeExp) { + // negative exponent, make positive + exp *= -1; + } final CodePointString expStr = new CodePointString(String.valueOf(exp)); final int expPadLen = subPicture.getMinimumExponentSize() - expStr.length(); @@ -724,6 +781,10 @@ private String format(final NumericValue number, final DecimalFormat decimalForm expStr.leftPad(decimalFormat.zeroDigit, expPadLen); } + if (negativeExp) { + // restore the minus sign for the negative exponent in the output + formatted.append('-'); + } formatted.append(expStr); } @@ -739,6 +800,8 @@ private String format(final NumericValue number, final DecimalFormat decimalForm * See https://www.w3.org/TR/xpath-functions-31/#analyzing-picture-string */ private static class SubPicture { + private int integerPartStartIdx = 0; + private int integerPartLength = 0; private int[] integerPartGroupingPositions; private int minimumIntegerPartSize; private int scalingFactor; @@ -758,6 +821,8 @@ private static class SubPicture { public SubPicture copy() { final SubPicture copy = new SubPicture(); + copy.integerPartStartIdx = integerPartStartIdx; + copy.integerPartLength = integerPartLength; copy.integerPartGroupingPositions = integerPartGroupingPositions == null ? null : Arrays.copyOf(integerPartGroupingPositions, integerPartGroupingPositions.length); copy.minimumIntegerPartSize = minimumIntegerPartSize; copy.scalingFactor = scalingFactor; @@ -808,7 +873,7 @@ public void incrementIntegerPartGroupingPosition() { * @return the value of G if regular, or -1 if irregular */ public int integerPartGroupingPositionsAreRegular() { - // There is an least one grouping-separator in the integer part of the sub-picture. + // There is at least one grouping-separator in the integer part of the sub-picture. if (integerPartGroupingPositions.length > 0) { // There is a positive integer G (the grouping size) such that the position of every grouping-separator @@ -834,23 +899,23 @@ public int integerPartGroupingPositionsAreRegular() { return -1; } - // Every position in the integer part of the sub-picture that is a positive integer multiple of G is - // occupied by a grouping-separator. - final int largestGroupPosition = integerPartGroupingPositions[integerPartGroupingPositions.length - 1]; - int m = 2; - for (int p = g; p <= largestGroupPosition; p = g * m++) { + // Check that every position in the integer part of the sub-picture that is a positive integer multiple + // of G is occupied by a grouping-separator. + // We can test this by determining if the leftmost group (the group to the left of the leftmost + // separator) is not larger than G. - boolean isGroupSeparator = false; - for (final int integerPartGroupingPosition : integerPartGroupingPositions) { - if (integerPartGroupingPosition == p) { - isGroupSeparator = true; - break; - } - } + // Calculate total active characters: integerPartLength includes all characters (digits + separators), + // so we subtract the number of separators to get just the active characters. + final int totalActiveCharacters = integerPartLength - integerPartGroupingPositions.length; - if (!isGroupSeparator) { - return -1; - } + // The leftmost separator is always at index 0, and its numberOfCharacters tells us how many active + // characters are to the right of it. Therefore, the leftmost group size is the remaining characters. + final int leftmostGroupSize = totalActiveCharacters - integerPartGroupingPositions[0]; + + // If the leftmost group is larger than G, it means that there should have been another separator + // within it (at position G from the right of the leftmost group), but there isn't... so it's irregular! + if (leftmostGroupSize > g) { + return -1; } return g; @@ -859,6 +924,10 @@ public int integerPartGroupingPositionsAreRegular() { return -1; } + public void incrementIntegerPartExtent() { + integerPartLength++; + } + public void incrementMinimumIntegerPartSize() { minimumIntegerPartSize++; } diff --git a/exist-core/src/test/xquery/numbers/format-numbers.xql b/exist-core/src/test/xquery/numbers/format-numbers.xql index 68a83792d3..59b0a08d3d 100644 --- a/exist-core/src/test/xquery/numbers/format-numbers.xql +++ b/exist-core/src/test/xquery/numbers/format-numbers.xql @@ -1,4 +1,28 @@ (: + : Elemental + : Copyright (C) 2024, Evolved Binary Ltd + : + : admin@evolvedbinary.com + : https://www.evolvedbinary.com | https://www.elemental.xyz + : + : This library is free software; you can redistribute it and/or + : modify it under the terms of the GNU Lesser General Public + : License as published by the Free Software Foundation; version 2.1. + : + : This library is distributed in the hope that it will be useful, + : but WITHOUT ANY WARRANTY; without even the implied warranty of + : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + : Lesser General Public License for more details. + : + : You should have received a copy of the GNU Lesser General Public + : License along with this library; if not, write to the Free Software + : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + : + : NOTE: Parts of this file contain code from 'The eXist-db Authors'. + : The original license header is included below. + : + : ===================================================================== + : : eXist-db Open Source Native XML Database : Copyright (C) 2001 The eXist-db Authors : @@ -303,4 +327,17 @@ declare %test:assertEquals("0.0") function fd:decimal-zeros($picture as xs:string) { format-number(0, $picture) +}; + +declare + %test:args("1.234567E10") + %test:assertEquals("1.235e10") + %test:args("1.234567E-10") + %test:assertEquals("1.235e-10") + %test:args("0.00000000123456") + %test:assertEquals("1.235e-9") + %test:args("1.234567e-10") + %test:assertEquals("1.235e-10") +function fd:negative-exponent($value as xs:numeric) { + format-number($value, "0.000e0") }; \ No newline at end of file