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 39b3992d7f5..4c2805dc663 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 @@ -219,6 +219,8 @@ imaginaryTokenDefinitions PREVIOUS_ITEM NEXT_ITEM WINDOW_VARS + DECIMAL_FORMAT_DECL + DEF_DECIMAL_FORMAT_DECL ; // === XPointer === @@ -283,7 +285,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) @@ -338,6 +340,9 @@ setter { #setter= #(#[DEF_FUNCTION_NS_DECL, "defaultFunctionNSDecl"], deff); } | "order"^ "empty"! ( "greatest" | "least" ) + | + "decimal-format"! ( dfDefProperty )* + { #setter = #(#[DEF_DECIMAL_FORMAT_DECL, "defaultDecimalFormatDecl"], #setter); } ) | ( "declare" "boundary-space" ) => @@ -357,9 +362,30 @@ setter | ( "declare" "namespace" ) => namespaceDecl + | + ( "declare" "decimal-format" ) => + decimalFormatDecl ) ; +decimalFormatDecl +{ String eq = null; } +: + decl:"declare"! "decimal-format"! eq=eqName! ( dfDefProperty )* + { + #decimalFormatDecl = #(#[DECIMAL_FORMAT_DECL, eq], #decimalFormatDecl); + #decimalFormatDecl.copyLexInfo(#decl); + } + ; + +dfDefProperty +: + ( "decimal-separator"^ | "grouping-separator"^ | "infinity"^ | "minus-sign"^ + | "NaN"^ | "percent"^ | "per-mille"^ | "zero-digit"^ | "digit"^ + | "pattern-separator"^ | "exponent-separator"^ ) + EQ! STRING_LITERAL + ; + preserveMode : ( "preserve" | "no-preserve" ) @@ -2367,6 +2393,30 @@ reservedKeywords returns [String name] "next" { name = "next"; } | "when" { name = "when"; } + | + "decimal-format" { name = "decimal-format"; } + | + "decimal-separator" { name = "decimal-separator"; } + | + "grouping-separator" { name = "grouping-separator"; } + | + "infinity" { name = "infinity"; } + | + "minus-sign" { name = "minus-sign"; } + | + "NaN" { name = "NaN"; } + | + "percent" { name = "percent"; } + | + "per-mille" { name = "per-mille"; } + | + "zero-digit" { name = "zero-digit"; } + | + "digit" { name = "digit"; } + | + "pattern-separator" { name = "pattern-separator"; } + | + "exponent-separator" { name = "exponent-separator"; } ; 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 6deddf948a2..5fbbfcc055b 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 @@ -236,6 +236,117 @@ options { return variableName; } } + + private static String dfRequireSingleChar(final AST node, final String propName, final String value) throws XPathException { + if (value.codePointCount(0, value.length()) != 1) { + throw new XPathException(node.getLine(), node.getColumn(), ErrorCodes.XQST0098, + "The value of decimal-format property '" + propName + "' must be a single character, but got: \"" + value + "\""); + } + return value; + } + + private static void dfValidateZeroDigit(final AST node, final String value) throws XPathException { + final int cp = value.codePointAt(0); + if (Character.getType(cp) != Character.DECIMAL_DIGIT_NUMBER || Character.getNumericValue(cp) != 0) { + throw new XPathException(node.getLine(), node.getColumn(), ErrorCodes.XQST0098, + "The value of decimal-format property 'zero-digit' must be a Unicode digit with numeric value zero, but got: \"" + value + "\""); + } + } + + private static void dfValidateDistinctPictureChars(final AST node, final DecimalFormat df) throws XPathException { + // The 8 single-character picture-string properties must all have distinct values + final int[] chars = { df.decimalSeparator, df.groupingSeparator, df.percent, df.perMille, + df.zeroDigit, df.digit, df.patternSeparator, df.exponentSeparator }; + final String[] names = { "decimal-separator", "grouping-separator", "percent", "per-mille", + "zero-digit", "digit", "pattern-separator", "exponent-separator" }; + for (int i = 0; i < chars.length; i++) { + for (int j = i + 1; j < chars.length; j++) { + if (chars[i] == chars[j]) { + throw new XPathException(node.getLine(), node.getColumn(), ErrorCodes.XQST0098, + "Decimal-format properties '" + names[i] + "' and '" + names[j] + + "' must have distinct values, but both are: '" + new String(Character.toChars(chars[i])) + "'"); + } + } + } + } + + private DecimalFormat processDecimalFormatProperties(final AST parentNode) throws XPathException { + // Start with UNNAMED defaults + int decimalSeparator = DecimalFormat.UNNAMED.decimalSeparator; + int exponentSeparator = DecimalFormat.UNNAMED.exponentSeparator; + int groupingSeparator = DecimalFormat.UNNAMED.groupingSeparator; + int percent = DecimalFormat.UNNAMED.percent; + int perMille = DecimalFormat.UNNAMED.perMille; + int zeroDigit = DecimalFormat.UNNAMED.zeroDigit; + int digit = DecimalFormat.UNNAMED.digit; + int patternSeparator = DecimalFormat.UNNAMED.patternSeparator; + String infinity = DecimalFormat.UNNAMED.infinity; + String nan = DecimalFormat.UNNAMED.NaN; + int minusSign = DecimalFormat.UNNAMED.minusSign; + + AST child = parentNode.getFirstChild(); + while (child != null) { + final String propName = child.getText(); + final AST valueNode = child.getFirstChild(); + if (valueNode == null) { + child = child.getNextSibling(); + continue; + } + final String value = valueNode.getText(); + + switch (propName) { + case "decimal-separator" -> { + dfRequireSingleChar(child, propName, value); + decimalSeparator = value.codePointAt(0); + } + case "grouping-separator" -> { + dfRequireSingleChar(child, propName, value); + groupingSeparator = value.codePointAt(0); + } + case "infinity" -> infinity = value; + case "minus-sign" -> { + dfRequireSingleChar(child, propName, value); + minusSign = value.codePointAt(0); + } + case "NaN" -> nan = value; + case "percent" -> { + dfRequireSingleChar(child, propName, value); + percent = value.codePointAt(0); + } + case "per-mille" -> { + dfRequireSingleChar(child, propName, value); + perMille = value.codePointAt(0); + } + case "zero-digit" -> { + dfRequireSingleChar(child, propName, value); + dfValidateZeroDigit(child, value); + zeroDigit = value.codePointAt(0); + } + case "digit" -> { + dfRequireSingleChar(child, propName, value); + digit = value.codePointAt(0); + } + case "pattern-separator" -> { + dfRequireSingleChar(child, propName, value); + patternSeparator = value.codePointAt(0); + } + case "exponent-separator" -> { + dfRequireSingleChar(child, propName, value); + exponentSeparator = value.codePointAt(0); + } + default -> { } + } + child = child.getNextSibling(); + } + + final DecimalFormat df = new DecimalFormat( + decimalSeparator, exponentSeparator, groupingSeparator, + percent, perMille, zeroDigit, digit, + patternSeparator, infinity, nan, minusSign + ); + dfValidateDistinctPictureChars(parentNode, df); + return df; + } } xpointer [PathExpr path] @@ -367,6 +478,8 @@ throws PermissionDeniedException, EXistException, XPathException boolean baseuri = false; boolean ordering = false; boolean construction = false; + Set declaredDecimalFormats = new HashSet(); + boolean defaultDecimalFormatDeclared = false; }: ( @@ -665,6 +778,35 @@ throws PermissionDeniedException, EXistException, XPathException ) ) | + #( + dfDecl:DECIMAL_FORMAT_DECL (.)* + { + final QName dfQName; + try { + dfQName = QName.parse(staticContext, dfDecl.getText(), null); + } catch (final IllegalQNameException iqe) { + throw new XPathException(dfDecl.getLine(), dfDecl.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix in decimal format name: " + dfDecl.getText()); + } + final String dfKey = dfQName.getNamespaceURI() + ":" + dfQName.getLocalPart(); + if (declaredDecimalFormats.contains(dfKey)) + throw new XPathException(dfDecl, ErrorCodes.XQST0097, "Duplicate decimal format declaration: " + dfDecl.getText()); + declaredDecimalFormats.add(dfKey); + final DecimalFormat df = processDecimalFormatProperties(dfDecl); + context.setStaticDecimalFormat(dfQName, df); + } + ) + | + #( + defDfDecl:DEF_DECIMAL_FORMAT_DECL (.)* + { + if (defaultDecimalFormatDeclared) + throw new XPathException(defDfDecl, ErrorCodes.XQST0097, "Duplicate default decimal format declaration."); + defaultDecimalFormatDeclared = true; + final DecimalFormat df = processDecimalFormatProperties(defDfDecl); + context.setDefaultStaticDecimalFormat(df); + } + ) + | functionDecl [path] | importDecl [path] 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 5457589fd80..6cb57a14818 100644 --- a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java +++ b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java @@ -129,6 +129,12 @@ public class ErrorCodes { public static final ErrorCode XQST0094 = new W3CErrorCode("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."); + public static final ErrorCode XQST0097 = new W3CErrorCode("XQST0097", + "It is a static error to have more than one decimal-format declaration with the same name, " + + "or more than one default decimal-format declaration, in the same module."); + public static final ErrorCode XQST0098 = new W3CErrorCode("XQST0098", + "It is a static error if the properties representing characters used in a picture string " + + "do not each have distinct values, or if a property value is not valid for its property."); public static final ErrorCode XQST0118 = new W3CErrorCode("XQST0118", "It is a static error if an element constructor uses a name in the end tag that does not match the name in the start tag."); public static final ErrorCode XQST0125 = new W3CErrorCode("XQST0125", "It is a static error if an inline function expression is annotated as %public or %private."); 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 8a92b08a51e..99d54d3c6e9 100644 --- a/exist-core/src/main/java/org/exist/xquery/XQueryContext.java +++ b/exist-core/src/main/java/org/exist/xquery/XQueryContext.java @@ -420,6 +420,9 @@ public class XQueryContext implements BinaryValueManager, Context { * HTTP context. */ private @Nullable HttpContext httpContext = null; + /** + * Sentinel QName for the default (unnamed) decimal format per XQuery 3.1 ยง4.10. + */ private static final QName UNNAMED_DECIMAL_FORMAT = new QName("__UNNAMED__", Function.BUILTIN_FUNCTION_NS); private final Map staticDecimalFormats = hashMap(Tuple(UNNAMED_DECIMAL_FORMAT, DecimalFormat.UNNAMED)); @@ -2956,6 +2959,10 @@ public void setStaticDecimalFormat(final QName qnDecimalFormat, final DecimalFor staticDecimalFormats.put(qnDecimalFormat, decimalFormat); } + public void setDefaultStaticDecimalFormat(final DecimalFormat decimalFormat) { + staticDecimalFormats.put(UNNAMED_DECIMAL_FORMAT, decimalFormat); + } + public Map getCachedUriCollectionResults() { return cachedUriCollectionResults; } diff --git a/exist-core/src/test/xquery/numbers/format-numbers.xql b/exist-core/src/test/xquery/numbers/format-numbers.xql index 23080940618..8746b4bf059 100644 --- a/exist-core/src/test/xquery/numbers/format-numbers.xql +++ b/exist-core/src/test/xquery/numbers/format-numbers.xql @@ -316,4 +316,104 @@ declare %test:assertEquals("1.235e-10") function fd:exponent-fails($number as xs:double, $picture as xs:string) { format-number($number, $picture) +}; + +(:~ + : Named decimal-format with European conventions (comma as decimal separator, dot as grouping separator). + :) +declare + %test:assertEquals("1.234,50") +function fd:named-decimal-format-eu() { + util:eval(' + declare namespace local = "http://local"; + declare decimal-format local:eu decimal-separator = "," grouping-separator = "."; + format-number(1234.5, "#.##0,00", "local:eu") + ') +}; + +(:~ + : Default decimal-format with European conventions. + :) +declare + %test:assertEquals("1.234,50") +function fd:default-decimal-format-eu() { + util:eval(' + declare default decimal-format decimal-separator = "," grouping-separator = "."; + format-number(1234.5, "#.##0,00") + ') +}; + +(:~ + : Custom NaN property. + :) +declare + %test:assertEquals("not a number") +function fd:custom-nan() { + util:eval(' + declare default decimal-format NaN = "not a number"; + format-number(number(''NaN''), "#.00") + ') +}; + +(:~ + : Custom infinity property. + :) +declare + %test:assertEquals("INFINITY") +function fd:custom-infinity() { + util:eval(' + declare default decimal-format infinity = "INFINITY"; + format-number(1 div 0e0, "#.00") + ') +}; + +(:~ + : Error: duplicate named decimal-format declaration. + :) +declare + %test:assertError("XQST0097") +function fd:error-duplicate-named-decimal-format() { + util:eval(' + declare namespace local = "http://local"; + declare decimal-format local:eu decimal-separator = "," grouping-separator = "."; + declare decimal-format local:eu decimal-separator = "," grouping-separator = "."; + format-number(1234.5, "#.##0,00", "local:eu") + ') +}; + +(:~ + : Error: duplicate default decimal-format declaration. + :) +declare + %test:assertError("XQST0097") +function fd:error-duplicate-default-decimal-format() { + util:eval(' + declare default decimal-format decimal-separator = "," grouping-separator = "."; + declare default decimal-format decimal-separator = "," grouping-separator = "."; + format-number(1234.5, "#.##0,00") + ') +}; + +(:~ + : Error: non-distinct property values (decimal-separator and grouping-separator are the same). + :) +declare + %test:assertError("XQST0098") +function fd:error-non-distinct-properties() { + util:eval(' + declare default decimal-format decimal-separator = "." grouping-separator = "."; + format-number(1234.5, "#.##0.00") + ') +}; + +(:~ + : Error: invalid zero-digit value. + :) +declare + %test:assertError("XQST0098") +function fd:error-invalid-zero-digit() { + util:eval(' + declare default decimal-format zero-digit = "A"; + format-number(1234.5, "#,##0.00") + ') }; \ No newline at end of file