Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/fable-library-py/fable_library/List.fs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ type LinkedList<'T> =

loop 0 xs

override xs.ToString() =
"[" + System.String.Join("; ", xs) + "]"
override xs.ToString() = structuredCollectionToString "[" xs "]"

override xs.Equals(other: obj) =
if obj.ReferenceEquals(xs, other) then
Expand Down
27 changes: 27 additions & 0 deletions src/fable-library-py/fable_library/Native.fs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,33 @@ type Cons<'T>(arrayType: string) =
[<Emit("$0.allocate($1)")>]
member _.Allocate(len: int) = nativeOnly

/// Renders a collection the way F#'s structured formatting (%A) does, quoting
/// string elements: e.g. `structuredCollectionToString "[" xs "]"` -> `["a"; "b"]`
///
/// Notes:
/// `inline` and the manual `for` loop (rather than `Seq.map`) are deliberate:
/// they keep the generated code free of extra module imports, which otherwise
/// caused a circular import (`list` <-> `seq_native`).
let inline structuredCollectionToString (opening: string) (xs: 'T seq) (closing: string) : string =
let mutable result = opening
let mutable first = true

let toStringQuoted (x: 'T) : string =
match box x with
| :? string as s -> "\"" + s + "\""
| _ -> string x

for x in xs do
result <-
(if first then
result
else
result + "; ")
+ toStringQuoted x

first <- false

result + closing

module Helpers =
let arrayFrom (xs: 'T seq) : 'T[] = Array.ofSeq xs
Expand Down
2 changes: 1 addition & 1 deletion src/fable-library-py/fable_library/Set.fs
Original file line number Diff line number Diff line change
Expand Up @@ -934,7 +934,7 @@ type Set<[<EqualityConditionalOn>] 'T when 'T: comparison>(comparer: IComparer<'
// Set(comparer, SetTree.ofArray comparer arr)

override this.ToString() =
"set [" + System.String.Join("; ", this) + "]"
structuredCollectionToString "set [" this "]"

// [<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
// [<RequireQualifiedAccess>]
Expand Down
15 changes: 13 additions & 2 deletions src/fable-library-py/fable_library/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,22 @@ def record_equals[T](self: T, other: T) -> bool:
return self == other


def _field_to_string(value: object) -> str:
# F#'s structured formatting quotes strings, e.g. `{ Name = "John" }`.
if isinstance(value, str):
return f'"{value}"'
return str(value)


def record_to_string(self: Record) -> str:
if hasattr(self, "__slots__"):
return "{ " + "\n ".join(map(lambda slot: slot + " = " + str(getattr(self, slot)), self.__slots__)) + " }"
return (
"{ "
+ "\n ".join(map(lambda slot: slot + " = " + _field_to_string(getattr(self, slot)), self.__slots__))
+ " }"
)
else:
return "{ " + "\n ".join(map(lambda kv: kv[0] + " = " + str(kv[1]), self.__dict__.items())) + " }"
return "{ " + "\n ".join(map(lambda kv: kv[0] + " = " + _field_to_string(kv[1]), self.__dict__.items())) + " }"


def record_get_hashcode(self: Record) -> int:
Expand Down
33 changes: 27 additions & 6 deletions src/fable-library-py/src/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

use crate::array::FSharpArray;
use pyo3::prelude::*;
use pyo3::types::PyAny;
use pyo3::types::{PyAny, PyString};
use regex::Regex;
use std::sync::LazyLock;

Expand Down Expand Up @@ -113,6 +113,10 @@ mod printf {
input: String,
/// Accumulated arguments with type annotations
args: Vec<String>,
/// Parallel to `args`: whether each argument was originally a Python `str`.
/// Used so that `%A` (structured formatting) can quote bare strings while
/// `%s`/`%O` leave them unquoted.
arg_is_str: Vec<bool>,
/// Optional continuation function to apply to the final formatted string
continuation: Option<Py<PyAny>>,
/// Cached count of format placeholders (computed once at creation)
Expand All @@ -124,6 +128,7 @@ mod printf {
Python::attach(|py| Self {
input: self.input.clone(),
args: self.args.clone(),
arg_is_str: self.arg_is_str.clone(),
continuation: self.continuation.as_ref().map(|c| c.clone_ref(py)),
placeholder_count: self.placeholder_count,
})
Expand All @@ -138,6 +143,7 @@ mod printf {
Self {
input,
args: Vec::new(),
arg_is_str: Vec::new(),
continuation: None,
placeholder_count,
}
Expand Down Expand Up @@ -172,6 +178,9 @@ mod printf {
}
};

// Remember whether the original argument was a string so that `%A`
// can quote it (structured formatting) while `%s`/`%O` do not.
new_format.arg_is_str.push(arg.is_instance_of::<PyString>());
new_format.args.push(arg_str);

// Use cached placeholder count instead of recomputing
Expand Down Expand Up @@ -274,9 +283,10 @@ mod printf {

// Parse type information if present (only for known type annotations)
let (value_str, type_info) = parse_type_annotation(arg_value);
let is_str = self.arg_is_str.get(arg_index).copied().unwrap_or(false);

let formatted_value =
format_value(value_str, type_info, format_type, flags, precision)?;
format_value(value_str, type_info, format_type, flags, precision, is_str)?;

// Apply padding if width is specified
let final_value = apply_padding(&formatted_value, width, flags);
Expand All @@ -286,7 +296,8 @@ mod printf {
}

// Handle remaining simple patterns that the regex might have missed
handle_remaining_patterns(&mut result, &self.args[arg_index..]);
let remaining_is_str: &[bool] = self.arg_is_str.get(arg_index..).unwrap_or(&[]);
handle_remaining_patterns(&mut result, &self.args[arg_index..], remaining_is_str);

// Handle %% escape sequences at the end
result = result.replace("%%", "%");
Expand Down Expand Up @@ -356,13 +367,18 @@ mod printf {
format_type: &str,
flags: &str,
precision: Option<i32>,
is_str: bool,
) -> PyResult<String> {
match format_type {
"d" | "i" => format_integer(value_str, flags),
"f" | "F" => format_float(value_str, flags, precision),
"g" | "G" => format_general(value_str, flags),
"x" => format_hex_lower(value_str, type_info),
"X" => format_hex_upper(value_str, type_info),
// `%A` uses structured formatting, which quotes a bare string
// (e.g. `"test"`). Containers already quote their nested strings in
// their own `__str__`, so this only affects the top-level value.
"A" if is_str => Ok(format!("\"{value_str}\"")),
"s" | "A" => Ok(value_str.to_string()),
"O" => Ok(value_str.to_string()), // Object display
"b" => format_boolean(value_str),
Expand Down Expand Up @@ -552,8 +568,8 @@ mod printf {
}

/// Handle remaining simple patterns
fn handle_remaining_patterns(result: &mut String, remaining_args: &[String]) {
for arg in remaining_args {
fn handle_remaining_patterns(result: &mut String, remaining_args: &[String], remaining_is_str: &[bool]) {
for (i, arg) in remaining_args.iter().enumerate() {
if let Some(pos) = result.find("%s") {
result.replace_range(pos..pos + 2, arg);
} else if let Some(pos) = result.find("%d") {
Expand All @@ -565,7 +581,12 @@ mod printf {
} else if let Some(pos) = result.find("%i") {
result.replace_range(pos..pos + 2, arg);
} else if let Some(pos) = result.find("%A") {
result.replace_range(pos..pos + 2, arg);
let is_str = remaining_is_str.get(i).copied().unwrap_or(false);
if is_str {
result.replace_range(pos..pos + 2, &format!("\"{arg}\""));
} else {
result.replace_range(pos..pos + 2, arg);
}
} else {
break;
}
Expand Down
3 changes: 1 addition & 2 deletions src/fable-library-ts/List.fs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ type LinkedList<'T> =

loop 0 xs

override xs.ToString() =
"[" + System.String.Join("; ", xs) + "]"
override xs.ToString() = structuredCollectionToString "[" xs "]"

override xs.Equals(other: obj) =
if obj.ReferenceEquals(xs, other) then
Expand Down
2 changes: 2 additions & 0 deletions src/fable-library-ts/Map.fs
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,8 @@ type Map<[<EqualityConditionalOn>] 'Key, [<EqualityConditionalOn; ComparisonCond
m |> Seq.iter (fun p -> f p.Value p.Key m)

override this.ToString() =
// Note: .NET's FSharpMap.ToString renders keys/values with `%O` (not `%A`),
// so strings are intentionally *not* quoted here (e.g. `map [(Age, 12)]`).
let inline toStr (kv: KeyValuePair<'Key, 'Value>) =
System.String.Format("({0}, {1})", kv.Key, kv.Value)

Expand Down
28 changes: 28 additions & 0 deletions src/fable-library-ts/Native.fs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,34 @@ type Cons<'T> =
[<Emit("new $0($1)")>]
abstract Allocate: len: int -> 'T[]

/// Renders a collection the way F#'s structured formatting (%A) does, quoting
/// string elements: e.g. `structuredCollectionToString "[" xs "]"` -> `["a"; "b"]`
///
/// Notes:
/// `inline` and the manual `for` loop (rather than `Seq.map`) are deliberate:
/// they keep the generated code free of extra module imports, which otherwise
/// caused a circular import (`list` <-> `seq_native`).
let inline structuredCollectionToString (opening: string) (xs: 'T seq) (closing: string) : string =
let mutable result = opening
let mutable first = true

let toStringQuoted (x: 'T) : string =
match box x with
| :? string as s -> "\"" + s + "\""
| _ -> string x

for x in xs do
result <-
(if first then
result
else
result + "; ")
+ toStringQuoted x

first <- false

result + closing

module Helpers =
[<Emit("$0")>]
let inline internal asArray (a: ResizeArray<'T>) : 'T[] = nativeOnly
Expand Down
2 changes: 1 addition & 1 deletion src/fable-library-ts/Set.fs
Original file line number Diff line number Diff line change
Expand Up @@ -951,7 +951,7 @@ type Set<[<EqualityConditionalOn>] 'T when 'T: comparison>(comparer: IComparer<'
// Set(comparer, SetTree.ofArray comparer arr)

override this.ToString() =
"set [" + System.String.Join("; ", this) + "]"
structuredCollectionToString "set [" this "]"

// [<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
// [<RequireQualifiedAccess>]
Expand Down
2 changes: 2 additions & 0 deletions src/fable-library-ts/String.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ function formatReplacement(rep: any, flags: any, padLength: any, precision: any,
}
} else if (rep instanceof Date) {
rep = dateToString(rep);
} else if (format === "A" && typeof rep === "string") {
rep = "\"" + rep + "\"";
} else {
rep = toString(rep);
}
Expand Down
26 changes: 13 additions & 13 deletions src/fable-library-ts/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,25 @@ export function seqToString<T>(self: Iterable<T>): string {
let str = "[";
for (const x of self) {
if (count === 0) {
str += toString(x);
str += toStringQuoted(x);
} else if (count === 100) {
str += "; ...";
break;
} else {
str += "; " + toString(x);
str += "; " + toStringQuoted(x);
}
count++;
}
return str + "]";
}

// Structured (%A-style) rendering of a value used as an element/field of a
// container. F#'s structured formatting (which records, unions, lists, etc.
// use in their ToString) quotes strings, e.g. `["a"; "b"]` and `{ Name = "John" }`.
function toStringQuoted(x: any, callStack = 0): string {
return typeof x === "string" ? "\"" + x + "\"" : toString(x, callStack);
}

export function toString(x: any, callStack = 0): string {
if (x != null && typeof x === "object") {
if (typeof x.toString === "function" && x.toString !== Object.prototype.toString) {
Expand All @@ -31,31 +38,24 @@ export function toString(x: any, callStack = 0): string {
const cons = Object.getPrototypeOf(x)?.constructor;
return cons === Object && callStack < 10
// Same format as recordToString
? "{ " + Object.entries(x).map(([k, v]) => k + " = " + toString(v, callStack + 1)).join("\n ") + " }"
? "{ " + Object.entries(x).map(([k, v]) => k + " = " + toStringQuoted(v, callStack + 1)).join("\n ") + " }"
: cons?.name ?? "";
}
}
return String(x);
}

export function unionToString(name: string, fields: any[]) {
function unionFieldToString(x: any): string {
if (typeof x === "string") {
return '"' + x + '"';
}
return toString(x);
}

if (fields.length === 0) {
return name;
} else {
let fieldStr;
let withParens = true;
if (fields.length === 1) {
fieldStr = unionFieldToString(fields[0]);
fieldStr = toStringQuoted(fields[0]);
withParens = fieldStr.indexOf(" ") >= 0;
} else {
fieldStr = fields.map((x: any) => unionFieldToString(x)).join(", ");
fieldStr = fields.map((x: any) => toStringQuoted(x)).join(", ");
}
return name + (withParens ? " (" : " ") + fieldStr + (withParens ? ")" : "");
}
Expand Down Expand Up @@ -119,7 +119,7 @@ function recordToJSON<T>(self: T) {
}

function recordToString<T>(self: T) {
return "{ " + Object.entries(self as any).map(([k, v]) => k + " = " + toString(v)).join("\n ") + " }";
return "{ " + Object.entries(self as any).map(([k, v]) => k + " = " + toStringQuoted(v)).join("\n ") + " }";
}

function recordGetHashCode<T>(self: T) {
Expand Down
27 changes: 23 additions & 4 deletions tests/Js/Main/StringTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,25 @@ let tests = testList "Strings" [
testCase "Fix #2398: Exception when two successive string format placeholders and value of first one ends in `%`" <| fun () ->
sprintf "%c%s" '%' "text" |> equal "%text"

// See https://github.com/fable-compiler/Fable/issues/4732
testCase "sprintf \"%A\" quotes strings" <| fun () ->
sprintf "%A" "test" |> equal "\"test\""
sprintf "%A" "" |> equal "\"\""
// %O and %s don't quote a bare string
sprintf "%O" "test" |> equal "test"
sprintf "%s" "test" |> equal "test"

// See https://github.com/fable-compiler/Fable/issues/4732
testCase "sprintf \"%A\" wraps strings without escaping" <| fun () ->
// F#'s %A only wraps a string in quotes; it does NOT escape embedded
// double-quotes, backslashes, or control characters (verified on .NET).
sprintf "%A" "a\"b" |> equal "\"a\"b\""
sprintf "%A" "a\\b" |> equal "\"a\\b\""
sprintf "%A" "a\nb" |> equal "\"a\nb\""
sprintf "%A" "a\tb" |> equal "\"a\tb\""
// Same behavior when the string is nested inside a container
sprintf "%A" [ "a\"b" ] |> equal "[\"a\"b\"]"

testCase "Unions with sprintf %A" <| fun () ->
Bar(1,5) |> sprintf "%A" |> equal "Bar (1, 5)"
Foo1 4.5 |> sprintf "%A" |> equal "Foo1 4.5"
Expand Down Expand Up @@ -350,8 +369,8 @@ let tests = testList "Strings" [
testCase "Printf %A works with anonymous records" <| fun () -> // See #4029
let person = {| FirstName = "John"; LastName = "Doe" |}
let s = sprintf "%A" person
System.Text.RegularExpressions.Regex.Replace(s.Replace("\"", ""), @"\s+", " ")
|> equal """{ FirstName = John LastName = Doe }"""
System.Text.RegularExpressions.Regex.Replace(s, @"\s+", " ")
|> equal """{ FirstName = "John" LastName = "Doe" }"""

testCase "Interpolated strings keep empty lines" <| fun () ->
let s1 = $"""1
Expand Down Expand Up @@ -403,11 +422,11 @@ let tests = testList "Strings" [

testCase "sprintf \"%A\" with lists works" <| fun () ->
let xs = ["Hi"; "Hello"; "Hola"]
(sprintf "%A" xs).Replace("\"", "") |> equal "[Hi; Hello; Hola]"
sprintf "%A" xs |> equal "[\"Hi\"; \"Hello\"; \"Hola\"]"

testCase "sprintf \"%A\" with nested lists works" <| fun () ->
let xs = [["Hi"]; ["Hello"]; ["Hola"]]
(sprintf "%A" xs).Replace("\"", "") |> equal "[[Hi]; [Hello]; [Hola]]"
sprintf "%A" xs |> equal "[[\"Hi\"]; [\"Hello\"]; [\"Hola\"]]"

testCase "sprintf \"%A\" with sequences works" <| fun () ->
let xs = seq { "Hi"; "Hello"; "Hola" }
Expand Down
Loading
Loading