From 2fbc56f33e43c98290313e997ccb9f83cae46b26 Mon Sep 17 00:00:00 2001 From: Jen Basch Date: Mon, 3 Aug 2026 11:09:06 -0700 Subject: [PATCH] Add hex/binary/octal support to `String.toInt()` --- .../org/pkl/core/ast/builder/AstBuilder.java | 43 ++++------------- .../java/org/pkl/core/runtime/VmUtils.java | 48 +++++++++++++++++++ .../org/pkl/core/stdlib/base/StringNodes.java | 29 +++-------- .../LanguageSnippetTests/input/api/string.pkl | 23 +++++++++ .../output/api/reflectedDeclaration.pcf | 40 ++++++++++++---- .../output/api/string.pcf | 19 ++++++++ stdlib/base.pkl | 20 ++++++-- 7 files changed, 152 insertions(+), 70 deletions(-) diff --git a/pkl-core/src/main/java/org/pkl/core/ast/builder/AstBuilder.java b/pkl-core/src/main/java/org/pkl/core/ast/builder/AstBuilder.java index 22836aaa9..e53760933 100644 --- a/pkl-core/src/main/java/org/pkl/core/ast/builder/AstBuilder.java +++ b/pkl-core/src/main/java/org/pkl/core/ast/builder/AstBuilder.java @@ -572,35 +572,18 @@ public ExpressionNode visitBoolLiteralExpr(BoolLiteralExpr expr) { } } - private T parseNumber(IntLiteralExpr expr, BiFunction parser) { - var text = remove_(expr.getNumber()); - - var radix = 10; - if (text.startsWith("0x") || text.startsWith("0b") || text.startsWith("0o")) { - radix = - switch (text.charAt(1)) { - case 'x' -> 16; - case 'b' -> 2; - default -> 8; - }; - - text = text.substring(2); - } - - // relies on grammar rule nesting depth, but a breakage won't go unnoticed by tests - if (expr.parent() instanceof UnaryMinusExpr) { - // handle negation here to make parsing of base.MinInt work - // also moves negation from runtime to parse time - text = "-" + text; - } - return parser.apply(text, radix); + private T parseInteger(IntLiteralExpr expr, BiFunction parser) { + // negate relies on grammar rule nesting depth, but a breakage won't go unnoticed by tests + // handle negation here to make parsing of base.MinInt work + // also moves negation from runtime to parse time + return VmUtils.parseInteger(expr.getNumber(), expr.parent() instanceof UnaryMinusExpr, parser); } @Override public IntLiteralNode visitIntLiteralExpr(IntLiteralExpr expr) { var section = createSourceSection(expr); try { - var num = parseNumber(expr, Long::parseLong); + var num = parseInteger(expr, Long::parseLong); return new IntLiteralNode(section, num); } catch (NumberFormatException e) { var text = expr.getNumber(); @@ -611,7 +594,7 @@ public IntLiteralNode visitIntLiteralExpr(IntLiteralExpr expr) { @Override public FloatLiteralNode visitFloatLiteralExpr(FloatLiteralExpr expr) { var section = createSourceSection(expr); - var text = remove_(expr.getNumber()); + var text = VmUtils.removeUnderscoresFromNumber(expr.getNumber(), false); // relies on grammar rule nesting depth, but a breakage won't go unnoticed by tests if (expr.parent() instanceof UnaryMinusExpr) { // handle negation here for consistency with visitIntegerLiteral @@ -627,16 +610,6 @@ public FloatLiteralNode visitFloatLiteralExpr(FloatLiteralExpr expr) { } } - private static String remove_(String number) { - var builder = new StringBuilder(number.length()); - for (var i = 0; i < number.length(); i++) { - var ch = number.charAt(i); - if (ch == '_') continue; - builder.append(ch); - } - return builder.toString(); - } - @Override public ExpressionNode visitThrowExpr(ThrowExpr expr) { return ThrowNodeGen.create(createSourceSection(expr), visitExpr(expr.getExpr())); @@ -1314,7 +1287,7 @@ private Pair createCollectionArgumentBytesNodes(Argum var expr = args.get(i); if (expr instanceof IntLiteralExpr intLiteralExpr && isAllByteLiterals) { try { - var byt = parseNumber(intLiteralExpr, Byte::parseByte); + var byt = parseInteger(intLiteralExpr, Byte::parseByte); expressionNodes[i] = new ByteConstantValueNode(byt); } catch (NumberFormatException e) { // proceed with initializing a constant value node; we'll throw an error inside diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/VmUtils.java b/pkl-core/src/main/java/org/pkl/core/runtime/VmUtils.java index 7ddcbf5dd..05b876624 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/VmUtils.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/VmUtils.java @@ -31,6 +31,7 @@ import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.util.*; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @@ -1092,4 +1093,51 @@ public static boolean isPklBug(VmStackOverflowException e) { var truffleStackTraceElements = TruffleStackTrace.getStackTrace(e); return truffleStackTraceElements != null && truffleStackTraceElements.size() < 100; } + + public static T parseInteger( + String raw, boolean negate, BiFunction parser) + throws NumberFormatException { + var text = raw; + + var radix = 10; + if (text.length() >= 2 && text.charAt(0) == '0') { + radix = + switch (text.charAt(1)) { + case 'x' -> 16; + case 'o' -> 8; + case 'b' -> 2; + default -> 10; + }; + if (radix != 10) { + text = text.substring(2); + } + } + + text = removeUnderscoresFromNumber(text, radix == 16); + if (negate) { + text = "-" + text; + } + return parser.apply(text, radix); + } + + /** Removes `_` from numbers to be parsed. Returns the string unmodified if it's invalid. */ + public static String removeUnderscoresFromNumber(String number, boolean isHex) { + if (number.indexOf('_') < 0) return number; + + var builder = new StringBuilder(); + var numberStart = true; + for (var i = 0; i < number.length(); i++) { + var c = number.charAt(i); + if (c != '_') { + builder.append(c); + } else if (numberStart) { + // invalid: _ at start or after [.eE] + return number; + } + + numberStart = c == '.' || (!isHex && (c == 'e' || c == 'E')); + } + + return builder.toString(); + } } diff --git a/pkl-core/src/main/java/org/pkl/core/stdlib/base/StringNodes.java b/pkl-core/src/main/java/org/pkl/core/stdlib/base/StringNodes.java index c21cb1387..794560712 100644 --- a/pkl-core/src/main/java/org/pkl/core/stdlib/base/StringNodes.java +++ b/pkl-core/src/main/java/org/pkl/core/stdlib/base/StringNodes.java @@ -835,7 +835,8 @@ public abstract static class toInt extends ExternalMethod0Node { @Specialization protected long eval(String self) { try { - return Long.parseLong(removeUnderlinesFromNumber(self)); + var negate = self.charAt(0) == '-'; + return VmUtils.parseInteger(negate ? self.substring(1) : self, negate, Long::parseLong); } catch (NumberFormatException e) { throw exceptionBuilder() .evalError("cannotParseStringAs", "Int") @@ -850,7 +851,8 @@ public abstract static class toIntOrNull extends ExternalMethod0Node { @Specialization protected Object eval(String self) { try { - return Long.parseLong(removeUnderlinesFromNumber(self)); + var negate = self.charAt(0) == '-'; + return VmUtils.parseInteger(negate ? self.substring(1) : self, negate, Long::parseLong); } catch (NumberFormatException e) { return VmNull.withoutDefault(); } @@ -862,7 +864,7 @@ public abstract static class toFloat extends ExternalMethod0Node { @Specialization protected double eval(String self) { try { - return Double.parseDouble(removeUnderlinesFromNumber(self)); + return Double.parseDouble(VmUtils.removeUnderscoresFromNumber(self, false)); } catch (NumberFormatException e) { throw exceptionBuilder() .evalError("cannotParseStringAs", "Float") @@ -877,7 +879,7 @@ public abstract static class toFloatOrNull extends ExternalMethod0Node { @Specialization protected Object eval(String self) { try { - return Double.parseDouble(removeUnderlinesFromNumber(self)); + return Double.parseDouble(VmUtils.removeUnderscoresFromNumber(self, false)); } catch (NumberFormatException e) { return VmNull.withoutDefault(); } @@ -1031,23 +1033,4 @@ private static String applyMapper( var replacement = applyNode.executeString(mapper, regexMatch); return Matcher.quoteReplacement(replacement); } - - /** - * Removes `_` from numbers to be parsed to be compatible with how Pkl parses numbers. Will return - * the string unmodified if it's invalid. - */ - private static String removeUnderlinesFromNumber(String number) { - var builder = new StringBuilder(); - var numberStart = true; - for (var i = 0; i < number.length(); i++) { - var c = number.charAt(i); - if (c != '_') { - builder.append(c); - } else if (numberStart) return number; - - numberStart = c == '.' || c == 'e' || c == 'E'; - } - - return builder.toString(); - } } diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/api/string.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/api/string.pkl index 6c1228694..f619c4db7 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/input/api/string.pkl +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/api/string.pkl @@ -270,11 +270,34 @@ examples { "-1_2__3___".toInt() "0".toInt() "-0".toInt() + + "0x123".toInt() + "-0x123".toInt() + "0x1_2__3___".toInt() + "-0x1_2__3___".toInt() + "0x0".toInt() + "-0x0".toInt() + + "0b101".toInt() + "-0b101".toInt() + "0b1_0__1___".toInt() + "-0b1_0__1___".toInt() + "0b0".toInt() + "-0b0".toInt() + + "0o123".toInt() + "-0o123".toInt() + "0o1_2__3___".toInt() + "-0o1_2__3___".toInt() + "0o0".toInt() + "-0o0".toInt() + module.catch(() -> "1.2".toInt()) module.catch(() -> "9223372036854775808".toInt()) module.catch(() -> "-9223372036854775809".toInt()) module.catch(() -> "abc".toInt()) module.catch(() -> "_1_000".toInt()) + module.catch(() -> "0p0".toInt()) } ["toIntOrNull()"] { diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/api/reflectedDeclaration.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/api/reflectedDeclaration.pcf index 176422757..6cbd07179 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/output/api/reflectedDeclaration.pcf +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/api/reflectedDeclaration.pcf @@ -2255,9 +2255,15 @@ alias { displayUri = "https://github.com/apple/pkl/blob/$commitId/stdlib/base.pkl#LXXXX" } docComment = """ - Parses this string as a signed decimal (base 10) integer. + Parses this string as a signed integer. - Throws if this string cannot be parsed as a signed decimal integer, + Supports integer formats supported by Pkl: + * Decimal (base 10) + * Hexadecimal (base 16) with prefix `0x` or `-0x` + * Binary (base 2) with prefix `0b` or `-0b` + * Octal (base 8) with prefix `0o` or `-0o` + + Throws if this string cannot be parsed, or if the integer is too large to fit into [Int]. """ annotations = List() @@ -2272,9 +2278,15 @@ alias { displayUri = "https://github.com/apple/pkl/blob/$commitId/stdlib/base.pkl#LXXXX" } docComment = """ - Parses this string as a signed decimal (base 10) integer. + Parses this string as a signed integer. + + Supports integer formats supported by Pkl: + * Decimal (base 10) + * Hexadecimal (base 16) with prefix `0x` or `-0x` + * Binary (base 2) with prefix `0b` or `-0b` + * Octal (base 8) with prefix `0o` or `-0o` - Returns [null] if this string cannot be parsed as a signed decimal integer, + Returns [null] if this string cannot be parsed, or if the integer is too large to fit into [Int]. """ annotations = List() @@ -3192,9 +3204,15 @@ alias { displayUri = "https://github.com/apple/pkl/blob/$commitId/stdlib/base.pkl#LXXXX" } docComment = """ - Parses this string as a signed decimal (base 10) integer. + Parses this string as a signed integer. - Throws if this string cannot be parsed as a signed decimal integer, + Supports integer formats supported by Pkl: + * Decimal (base 10) + * Hexadecimal (base 16) with prefix `0x` or `-0x` + * Binary (base 2) with prefix `0b` or `-0b` + * Octal (base 8) with prefix `0o` or `-0o` + + Throws if this string cannot be parsed, or if the integer is too large to fit into [Int]. """ annotations = List() @@ -3209,9 +3227,15 @@ alias { displayUri = "https://github.com/apple/pkl/blob/$commitId/stdlib/base.pkl#LXXXX" } docComment = """ - Parses this string as a signed decimal (base 10) integer. + Parses this string as a signed integer. + + Supports integer formats supported by Pkl: + * Decimal (base 10) + * Hexadecimal (base 16) with prefix `0x` or `-0x` + * Binary (base 2) with prefix `0b` or `-0b` + * Octal (base 8) with prefix `0o` or `-0o` - Returns [null] if this string cannot be parsed as a signed decimal integer, + Returns [null] if this string cannot be parsed, or if the integer is too large to fit into [Int]. """ annotations = List() diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/api/string.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/api/string.pcf index deb0a782b..edad617c9 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/output/api/string.pcf +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/api/string.pcf @@ -220,11 +220,30 @@ examples { -123 0 0 + 291 + -291 + 291 + -291 + 0 + 0 + 5 + -5 + 5 + -5 + 0 + 0 + 83 + -83 + 83 + -83 + 0 + 0 "Cannot parse string as `Int`. String: \"1.2\"" "Cannot parse string as `Int`. String: \"9223372036854775808\"" "Cannot parse string as `Int`. String: \"-9223372036854775809\"" "Cannot parse string as `Int`. String: \"abc\"" "Cannot parse string as `Int`. String: \"_1_000\"" + "Cannot parse string as `Int`. String: \"0p0\"" } ["toIntOrNull()"] { 123 diff --git a/stdlib/base.pkl b/stdlib/base.pkl index ee3e94ef2..6ccb12dc8 100644 --- a/stdlib/base.pkl +++ b/stdlib/base.pkl @@ -1640,15 +1640,27 @@ external class String extends Any { /// ``` external function decapitalize(): String - /// Parses this string as a signed decimal (base 10) integer. + /// Parses this string as a signed integer. /// - /// Throws if this string cannot be parsed as a signed decimal integer, + /// Supports integer formats supported by Pkl: + /// * Decimal (base 10) + /// * Hexadecimal (base 16) with prefix `0x` or `-0x` + /// * Binary (base 2) with prefix `0b` or `-0b` + /// * Octal (base 8) with prefix `0o` or `-0o` + /// + /// Throws if this string cannot be parsed, /// or if the integer is too large to fit into [Int]. external function toInt(): Int - /// Parses this string as a signed decimal (base 10) integer. + /// Parses this string as a signed integer. + /// + /// Supports integer formats supported by Pkl: + /// * Decimal (base 10) + /// * Hexadecimal (base 16) with prefix `0x` or `-0x` + /// * Binary (base 2) with prefix `0b` or `-0b` + /// * Octal (base 8) with prefix `0o` or `-0o` /// - /// Returns [null] if this string cannot be parsed as a signed decimal integer, + /// Returns [null] if this string cannot be parsed, /// or if the integer is too large to fit into [Int]. external function toIntOrNull(): Int?