@@ -12,6 +12,19 @@ use rustpython_literal::format::Case;
1212
1313use crate :: wtf8:: { CodePoint , Wtf8 , Wtf8Buf } ;
1414
15+ /// Locale information for 'n' format specifier.
16+ /// Contains thousands separator, decimal point, and grouping pattern
17+ /// from the C library's `localeconv()`.
18+ #[ derive( Clone , Debug ) ]
19+ pub struct LocaleInfo {
20+ pub thousands_sep : String ,
21+ pub decimal_point : String ,
22+ /// Grouping pattern from `lconv.grouping`.
23+ /// Each element is a group size. The last non-zero element repeats.
24+ /// e.g. `[3, 0]` means groups of 3 repeating forever.
25+ pub grouping : Vec < u8 > ,
26+ }
27+
1528trait FormatParse {
1629 fn parse ( text : & Wtf8 ) -> ( Option < Self > , & Wtf8 )
1730 where
@@ -460,6 +473,189 @@ impl FormatSpec {
460473 }
461474 }
462475
476+ /// Returns true if this format spec uses the locale-aware 'n' format type.
477+ pub fn has_locale_format ( & self ) -> bool {
478+ matches ! ( self . format_type, Some ( FormatType :: Number ( Case :: Lower ) ) )
479+ }
480+
481+ /// Insert locale-aware thousands separators into an integer string.
482+ /// Follows CPython's GroupGenerator logic for variable-width grouping.
483+ fn insert_locale_grouping ( int_part : & str , locale : & LocaleInfo ) -> String {
484+ if locale. grouping . is_empty ( ) || locale. thousands_sep . is_empty ( ) || int_part. len ( ) <= 1 {
485+ return int_part. to_string ( ) ;
486+ }
487+
488+ let mut group_idx = 0 ;
489+ let mut group_size = locale. grouping [ 0 ] as usize ;
490+
491+ if group_size == 0 {
492+ return int_part. to_string ( ) ;
493+ }
494+
495+ // Collect groups of digits from right to left
496+ let len = int_part. len ( ) ;
497+ let mut groups: Vec < & str > = Vec :: new ( ) ;
498+ let mut pos = len;
499+
500+ loop {
501+ if pos <= group_size {
502+ groups. push ( & int_part[ ..pos] ) ;
503+ break ;
504+ }
505+
506+ groups. push ( & int_part[ pos - group_size..pos] ) ;
507+ pos -= group_size;
508+
509+ // Advance to next group size
510+ if group_idx + 1 < locale. grouping . len ( ) {
511+ let next = locale. grouping [ group_idx + 1 ] as usize ;
512+ if next != 0 {
513+ group_size = next;
514+ group_idx += 1 ;
515+ }
516+ // 0 means repeat previous group size forever
517+ }
518+ }
519+
520+ // Groups were collected right-to-left, reverse to get left-to-right
521+ groups. reverse ( ) ;
522+ groups. join ( & locale. thousands_sep )
523+ }
524+
525+ /// Apply locale-aware grouping and decimal point replacement to a formatted number.
526+ fn apply_locale_formatting ( magnitude_str : String , locale : & LocaleInfo ) -> String {
527+ let mut parts = magnitude_str. splitn ( 2 , '.' ) ;
528+ let int_part = parts. next ( ) . unwrap ( ) ;
529+ let grouped = Self :: insert_locale_grouping ( int_part, locale) ;
530+
531+ if let Some ( frac_part) = parts. next ( ) {
532+ format ! ( "{grouped}{}{frac_part}" , locale. decimal_point)
533+ } else {
534+ grouped
535+ }
536+ }
537+
538+ /// Format an integer with locale-aware 'n' format.
539+ pub fn format_int_locale (
540+ & self ,
541+ num : & BigInt ,
542+ locale : & LocaleInfo ,
543+ ) -> Result < String , FormatSpecError > {
544+ self . validate_format ( FormatType :: Decimal ) ?;
545+ let magnitude = num. abs ( ) ;
546+
547+ let raw_magnitude_str = match self . format_type {
548+ Some ( FormatType :: Number ( Case :: Lower ) ) => self . format_int_radix ( magnitude, 10 ) ,
549+ _ => return self . format_int ( num) ,
550+ } ?;
551+
552+ let magnitude_str = Self :: apply_locale_formatting ( raw_magnitude_str, locale) ;
553+
554+ let format_sign = self . sign . unwrap_or ( FormatSign :: Minus ) ;
555+ let sign_str = match num. sign ( ) {
556+ Sign :: Minus => "-" ,
557+ _ => match format_sign {
558+ FormatSign :: Plus => "+" ,
559+ FormatSign :: Minus => "" ,
560+ FormatSign :: MinusOrSpace => " " ,
561+ } ,
562+ } ;
563+
564+ self . format_sign_and_align ( & AsciiStr :: new ( & magnitude_str) , sign_str, FormatAlign :: Right )
565+ }
566+
567+ /// Format a float with locale-aware 'n' format.
568+ pub fn format_float_locale (
569+ & self ,
570+ num : f64 ,
571+ locale : & LocaleInfo ,
572+ ) -> Result < String , FormatSpecError > {
573+ self . validate_format ( FormatType :: FixedPoint ( Case :: Lower ) ) ?;
574+ let precision = self . precision . unwrap_or ( 6 ) ;
575+ let magnitude = num. abs ( ) ;
576+
577+ let raw_magnitude_str = match & self . format_type {
578+ Some ( FormatType :: Number ( case) ) => {
579+ let precision = if precision == 0 { 1 } else { precision } ;
580+ Ok ( float:: format_general (
581+ precision,
582+ magnitude,
583+ * case,
584+ self . alternate_form ,
585+ false ,
586+ ) )
587+ }
588+ _ => return self . format_float ( num) ,
589+ } ?;
590+
591+ let magnitude_str = Self :: apply_locale_formatting ( raw_magnitude_str, locale) ;
592+
593+ let format_sign = self . sign . unwrap_or ( FormatSign :: Minus ) ;
594+ let sign_str = if num. is_sign_negative ( ) && !num. is_nan ( ) {
595+ "-"
596+ } else {
597+ match format_sign {
598+ FormatSign :: Plus => "+" ,
599+ FormatSign :: Minus => "" ,
600+ FormatSign :: MinusOrSpace => " " ,
601+ }
602+ } ;
603+
604+ self . format_sign_and_align ( & AsciiStr :: new ( & magnitude_str) , sign_str, FormatAlign :: Right )
605+ }
606+
607+ /// Format a complex number with locale-aware 'n' format.
608+ pub fn format_complex_locale (
609+ & self ,
610+ num : & Complex64 ,
611+ locale : & LocaleInfo ,
612+ ) -> Result < String , FormatSpecError > {
613+ // Reuse format_complex_re_im with 'g' type to get the base formatted parts,
614+ // then apply locale grouping. This matches CPython's format_complex_internal:
615+ // 'n' → 'g', add_parens=0, skip_re=0.
616+ let locale_spec = FormatSpec {
617+ format_type : Some ( FormatType :: GeneralFormat ( Case :: Lower ) ) ,
618+ ..* self
619+ } ;
620+ let ( formatted_re, formatted_im) = locale_spec. format_complex_re_im ( num) ?;
621+
622+ // Apply locale grouping to both parts
623+ let grouped_re = if formatted_re. is_empty ( ) {
624+ formatted_re
625+ } else {
626+ // Split sign from magnitude, apply grouping, recombine
627+ let ( sign, mag) = if formatted_re. starts_with ( '-' )
628+ || formatted_re. starts_with ( '+' )
629+ || formatted_re. starts_with ( ' ' )
630+ {
631+ formatted_re. split_at ( 1 )
632+ } else {
633+ ( "" , formatted_re. as_str ( ) )
634+ } ;
635+ format ! (
636+ "{sign}{}" ,
637+ Self :: apply_locale_formatting( mag. to_string( ) , locale)
638+ )
639+ } ;
640+
641+ // formatted_im is like "+1234j" or "-1234j" or "1234j"
642+ // Split sign, magnitude, and 'j' suffix
643+ let im_str = & formatted_im;
644+ let ( im_sign, im_rest) = if im_str. starts_with ( '+' ) || im_str. starts_with ( '-' ) {
645+ im_str. split_at ( 1 )
646+ } else {
647+ ( "" , im_str. as_str ( ) )
648+ } ;
649+ let im_mag = im_rest. strip_suffix ( 'j' ) . unwrap_or ( im_rest) ;
650+ let im_grouped = Self :: apply_locale_formatting ( im_mag. to_string ( ) , locale) ;
651+ let grouped_im = format ! ( "{im_sign}{im_grouped}j" ) ;
652+
653+ // No parentheses for 'n' format (CPython: add_parens=0)
654+ let magnitude_str = format ! ( "{grouped_re}{grouped_im}" ) ;
655+
656+ self . format_sign_and_align ( & AsciiStr :: new ( & magnitude_str) , "" , FormatAlign :: Right )
657+ }
658+
463659 pub fn format_bool ( & self , input : bool ) -> Result < String , FormatSpecError > {
464660 let x = u8:: from ( input) ;
465661 match & self . format_type {
0 commit comments