1212from enum import Enum
1313from typing import Generator , Iterator , Sequence
1414
15- # Maps HIR variable to its HIR output .
16- VarOutputDict = dict [str , Sequence [str ]]
15+ # Maps HIR variable to (actual_output, expected_output) tuples .
16+ VarOutputDict = dict [str , tuple [ Sequence [str ], Sequence [ str ] | None ]]
1717
1818# Maps all test cases to their variables.
1919TestOutputDict = dict [str , VarOutputDict ]
@@ -108,7 +108,7 @@ def parse_stdout(stdout: str) -> tuple[str, SuiteOutputDict]:
108108
109109 failed_tests = collections .defaultdict (lambda : {})
110110 line_iter = timestamp_stripped_lineiter (iter (stdout .split ("\n " )))
111- test_dict : dict [str , list [str ]] = {}
111+ test_dict : dict [str , tuple [ list [str ], Sequence [ str ] | None ]] = {}
112112 test_name : tuple [str , str ] = ("??" , "??" )
113113 for line in line_iter :
114114 if m := VERSION_RE .match (line ):
@@ -130,15 +130,21 @@ def parse_stdout(stdout: str) -> tuple[str, SuiteOutputDict]:
130130 if not m :
131131 raise RuntimeError (f"Unexpected line '{ line } ' after actual text" )
132132 varname = m [1 ]
133+ if varname .endswith (".c_str()" ):
134+ varname = varname [:- 8 ]
133135 if varname in test_dict :
134136 raise RuntimeError (
135137 f"Duplicate expect variable name '{ varname } ' in { test_name [0 ]} .{ test_name [1 ]} "
136138 )
137- test_dict [varname ] = actual_text
138- failed_tests [test_name [0 ]][test_name [1 ]] = test_dict
139139
140- # Skip the "Which is: ..." line after the expect variable name.
141- next (line_iter )
140+ # Capture old expected text from second "Which is:" line
141+ expected_line = next (line_iter )
142+ expected_m = ACTUAL_TEXT_RE .match (expected_line )
143+ expected_text : Sequence [str ] | None = None
144+ if expected_m :
145+ expected_text = unescape_gtest_string (expected_m [1 ]).split ("\n " )
146+ test_dict [varname ] = (actual_text , expected_text )
147+ failed_tests [test_name [0 ]][test_name [1 ]] = test_dict
142148
143149 if py_version == unknown_version :
144150 raise RuntimeError ("Couldn't figure out Python version from test output" )
@@ -258,7 +264,7 @@ def expect(exp: str, actual: str | None = None) -> None:
258264 new_lines .append (expected_version )
259265 # For text HIR tests, there should only be one element in the
260266 # failed test dict.
261- hir_lines = next (iter (failed_tests [test_case ].values ()))
267+ hir_lines = next (iter (failed_tests [test_case ].values ()))[ 0 ]
262268 new_lines += hir_lines
263269 while not peek_line ().startswith ("---" ):
264270 next_line ()
@@ -333,6 +339,9 @@ def version_compare(v1: str, v2: str) -> int:
333339
334340
335341CPP_EXPECTED_START_RE : re .Pattern [str ] = re .compile (r"^( const char\* ([^ ]+) =)" )
342+ CPP_FMT_FORMAT_START_RE : re .Pattern [str ] = re .compile (
343+ r"^( (auto|std::string) ([^ ]+) = fmt::format\()$"
344+ )
336345CPP_EXPECTED_END = ')";'
337346CPP_TEST_END = "}"
338347
@@ -355,6 +364,85 @@ class State(Enum):
355364 # Active while skipping lines of an expected variable.
356365 SKIP_EXPECTED = 3
357366
367+ # Active while collecting lines of a fmt::format raw string.
368+ COLLECT_FMT_RAW_STRING = 4
369+
370+
371+ _PLACEHOLDER = "\x00 PLACEHOLDER\x00 "
372+
373+
374+ def escape_literal_braces (s : str ) -> str :
375+ """Escape { -> {{ and } -> }} for use in fmt::format templates."""
376+ return s .replace ("{" , "{{" ).replace ("}" , "}}" )
377+
378+
379+ def re_templatize (
380+ old_template_lines : Sequence [str ],
381+ old_expanded_lines : Sequence [str ],
382+ new_actual_lines : Sequence [str ],
383+ ) -> list [str ] | None :
384+ """Re-apply {} placeholders from old_template into new_actual output.
385+
386+ Uses the old format template and its gtest-reported expanded form to
387+ discover what {} placeholders expanded to, then replaces those concrete
388+ values in the new actual output with {} placeholders.
389+
390+ Returns None if the old template cannot be matched against the old expanded
391+ output (e.g. if the format changed too much).
392+ """
393+ old_template = "\n " .join (old_template_lines )
394+ old_expanded = "\n " .join (old_expanded_lines )
395+ new_actual = "\n " .join (new_actual_lines )
396+
397+ # Build a regex from the old template:
398+ # - {{ -> literal {
399+ # - }} -> literal }
400+ # - {} -> capture group (.+?)
401+ # - everything else is escaped
402+ parts = []
403+ expansions_count = 0
404+ i = 0
405+ while i < len (old_template ):
406+ if i + 1 < len (old_template ) and old_template [i : i + 2 ] == "{{" :
407+ parts .append (re .escape ("{" ))
408+ i += 2
409+ elif i + 1 < len (old_template ) and old_template [i : i + 2 ] == "}}" :
410+ parts .append (re .escape ("}" ))
411+ i += 2
412+ elif i + 1 < len (old_template ) and old_template [i : i + 2 ] == "{}" :
413+ parts .append ("(.+?)" )
414+ expansions_count += 1
415+ i += 2
416+ else :
417+ parts .append (re .escape (old_template [i ]))
418+ i += 1
419+
420+ if expansions_count == 0 :
421+ # No placeholders to re-templatize; just return the new actual lines
422+ # with literal braces escaped.
423+ return escape_literal_braces (new_actual ).split ("\n " )
424+
425+ pattern = "" .join (parts )
426+ m = re .match (pattern , old_expanded , re .DOTALL )
427+ if m is None :
428+ return None
429+
430+ # Extract what each {} expanded to
431+ expansion_values = list (m .groups ())
432+
433+ # Replace each expansion value in the new actual output with a sentinel
434+ result = new_actual
435+ for val in expansion_values :
436+ result = result .replace (val , _PLACEHOLDER , 1 )
437+
438+ # Escape remaining literal braces
439+ result = escape_literal_braces (result )
440+
441+ # Replace sentinels with {}
442+ result = result .replace (_PLACEHOLDER , "{}" )
443+
444+ return result .split ("\n " )
445+
358446
359447# pyre-ignore[30]
360448def update_cpp_tests ( # noqa: C901
@@ -403,7 +491,14 @@ def expect_empty_test_dict() -> None:
403491 in_version_block = None
404492 non_version_pp_depth = 0
405493 needs_to_close_upgraded_block = False
494+ fmt_collecting_template = False
495+ fmt_old_template_lines : list [str ] = []
496+ fmt_raw_prefix = ""
497+ fmt_varname = ""
498+ fmt_actual_lines : Sequence [str ] = []
499+ fmt_expected_lines : Sequence [str ] | None = None
406500 new_lines = []
501+ suite_name = test_name = ""
407502 for lineno , line in enumerate (old_lines , 1 ): # noqa: B007
408503 m = CPP_TEST_NAME_RE .match (line )
409504 if m is not None :
@@ -428,6 +523,53 @@ def expect_empty_test_dict() -> None:
428523 new_lines .append (line )
429524 continue
430525
526+ # Handle COLLECT_FMT_RAW_STRING before preprocessor handling
527+ # to avoid misinterpreting raw string content as #if directives.
528+ if state is State .COLLECT_FMT_RAW_STRING :
529+ if fmt_collecting_template :
530+ # We're inside the raw string collecting template lines.
531+ # The raw string ends with )" optionally followed by , and whitespace.
532+ end_m = re .match (r'^(\s*\)"\s*,?\s*)$' , line )
533+ if end_m :
534+ # Re-templatize the new actual output
535+ new_template_lines = None
536+ if fmt_expected_lines is not None :
537+ new_template_lines = re_templatize (
538+ fmt_old_template_lines ,
539+ fmt_expected_lines ,
540+ fmt_actual_lines ,
541+ )
542+ if new_template_lines is None :
543+ print (
544+ f" Warning: couldn't re-templatize { fmt_varname } "
545+ f"in { suite_name } .{ test_name } , using escaped actual output"
546+ )
547+ new_template_lines = escape_literal_braces (
548+ "\n " .join (fmt_actual_lines )
549+ ).split ("\n " )
550+
551+ # Output: R"( prefix + new template + )" suffix
552+ new_lines .append (fmt_raw_prefix + new_template_lines [0 ])
553+ new_lines += new_template_lines [1 :]
554+ new_lines .append (line ) # the )" line (preserved as-is)
555+ state = State .PROCESS_FAILED_TEST
556+ else :
557+ fmt_old_template_lines .append (line )
558+ else :
559+ # First line in COLLECT_FMT_RAW_STRING: look for R"( prefix
560+ raw_m = re .match (r'^(\s*R"\()(.*)' , line )
561+ if raw_m :
562+ fmt_raw_prefix = raw_m [1 ]
563+ first_content = raw_m [2 ]
564+ fmt_old_template_lines = []
565+ if first_content :
566+ fmt_old_template_lines .append (first_content )
567+ fmt_collecting_template = True
568+ else :
569+ # Not a raw string start, pass through
570+ new_lines .append (line )
571+ continue
572+
431573 # Dynamically detect version-specific preprocessor directives
432574 detected_version = parse_version_from_line (line )
433575 if detected_version is not None :
@@ -479,11 +621,12 @@ def expect_empty_test_dict() -> None:
479621 decl = m [1 ]
480622 varname = m [2 ]
481623
482- actual_lines = test_dict .pop (varname , None )
483- if actual_lines is None :
624+ entry = test_dict .pop (varname , None )
625+ if entry is None :
484626 # This test has multiple expected variables, and this one is OK.
485627 new_lines .append (line )
486628 continue
629+ actual_lines , _expected_lines = entry
487630
488631 # Handle upgrading existing version block to accommodate new version
489632 if (
@@ -549,6 +692,36 @@ def expect_empty_test_dict() -> None:
549692 state = State .PROCESS_FAILED_TEST
550693 continue
551694
695+ m = CPP_FMT_FORMAT_START_RE .match (line )
696+ if m is not None :
697+ expect_state (State .PROCESS_FAILED_TEST )
698+ fmt_varname = m [2 ]
699+
700+ entry = test_dict .pop (fmt_varname , None )
701+ if entry is None :
702+ # This test has multiple expected variables, and this one is OK.
703+ new_lines .append (line )
704+ continue
705+ fmt_actual_lines , fmt_expected_lines = entry
706+
707+ # For version block mismatches, pass through unchanged
708+ if in_version_block is not None and in_version_block != py_version :
709+ if version_compare (py_version , in_version_block ) > 0 :
710+ print (
711+ f" Warning: fmt::format version upgrade not yet supported "
712+ f"for { fmt_varname } in { suite_name } .{ test_name } "
713+ )
714+ new_lines .append (line )
715+ continue
716+
717+ # Enter COLLECT_FMT_RAW_STRING state
718+ new_lines .append (line )
719+ fmt_collecting_template = False
720+ fmt_old_template_lines = []
721+ fmt_raw_prefix = ""
722+ state = State .COLLECT_FMT_RAW_STRING
723+ continue
724+
552725 new_lines .append (line )
553726
554727 if line == CPP_EXPECTED_END and needs_to_close_upgraded_block :
0 commit comments