Skip to main content

formatparse_core/parser/
pattern.rs

1//! Parse format patterns into regex strings and field specifications (pure Rust).
2
3use crate::error::FormatParseError;
4use crate::types::definitions::{FieldSpec, FieldType};
5use regex;
6use std::collections::HashMap;
7
8/// Maximum recursive depth when compiling nested format patterns (GitHub issue #12).
9pub const MAX_NESTED_FORMAT_DEPTH: usize = 10;
10
11/// Maximum brace nesting **within** one field's format specification (safety cap).
12const MAX_BRACE_DEPTH_IN_FORMAT_SPEC: i32 = 10;
13
14/// Result tuple from [`parse_pattern`]: compiled pattern string, search regex string, field
15/// specs, original and normalized field names, normalized-to-original name map, and whether
16/// `""` may match when every field is a default unconstrained string.
17pub type ParsedPatternParts = (
18    String,
19    String,
20    Vec<FieldSpec>,
21    Vec<Option<String>>,
22    Vec<Option<String>>,
23    HashMap<String, String>,
24    bool,
25);
26
27/// True when `s` contains at least one non-whitespace character (trim is non-empty).
28fn literal_delimits_empty_field(s: &str) -> bool {
29    !s.trim().is_empty()
30}
31
32/// Collect the format-spec substring after `:` until the matching `}` that closes this
33/// field, honoring nested `{`…`}` and doubled `{{` / `}}` escapes (formatparse#12).
34fn collect_balanced_format_spec(
35    chars: &mut std::iter::Peekable<std::str::Chars>,
36) -> Result<String, FormatParseError> {
37    let mut out = String::new();
38    let mut depth = 0i32;
39    loop {
40        let Some(&ch) = chars.peek() else {
41            return Err(FormatParseError::PatternError(
42                "Unclosed '{' in pattern: expected '}' to close the field".to_string(),
43            ));
44        };
45        if ch == '}' && depth == 0 {
46            break;
47        }
48        let c = chars
49            .next()
50            .expect("peek matched a char so next() must succeed");
51        match c {
52            '{' => {
53                if chars.peek() == Some(&'{') {
54                    chars.next();
55                    out.push('{');
56                    out.push('{');
57                } else {
58                    depth += 1;
59                    if depth > MAX_BRACE_DEPTH_IN_FORMAT_SPEC {
60                        return Err(FormatParseError::PatternError(
61                            "Format specification has too many nested '{' (max 10)".to_string(),
62                        ));
63                    }
64                    out.push('{');
65                }
66            }
67            // A lone `}` closes one `{…}` nesting level inside the spec. Do **not** merge two
68            // consecutive `}` into the `}}` escape here: in `{outer:{inner:d}}` the first `}`
69            // closes the inner field and the second closes the outer field (formatparse#12).
70            '}' => {
71                depth -= 1;
72                if depth < 0 {
73                    return Err(FormatParseError::PatternError(
74                        "Unexpected '}' in format specification".to_string(),
75                    ));
76                }
77                out.push('}');
78            }
79            _ => out.push(c),
80        }
81    }
82    Ok(out)
83}
84
85fn brace_balance_valid_for_nested_candidate(s: &str) -> bool {
86    let mut depth = 0i32;
87    let mut it = s.chars().peekable();
88    while let Some(c) = it.next() {
89        match c {
90            '{' => {
91                if it.peek() == Some(&'{') {
92                    it.next();
93                    continue;
94                }
95                depth += 1;
96            }
97            '}' => {
98                depth -= 1;
99                if depth < 0 {
100                    return false;
101                }
102            }
103            _ => {}
104        }
105    }
106    depth == 0
107}
108
109/// True when `trimmed` should be compiled as a nested brace pattern (not a classic
110/// ``[[fill]align]…[type]]`` format spec).
111fn is_nested_format_spec_candidate(trimmed: &str) -> bool {
112    if trimmed.len() < 2 {
113        return false;
114    }
115    if !trimmed.starts_with('{') || trimmed.starts_with("{{") {
116        return false;
117    }
118    if !trimmed.ends_with('}') {
119        return false;
120    }
121    brace_balance_valid_for_nested_candidate(trimmed)
122}
123
124/// Strip a leading ``^`` and trailing ``$`` from an anchored full-pattern regex string.
125fn strip_regex_anchors(anchored: &str) -> String {
126    let s = anchored.strip_prefix('^').unwrap_or(anchored);
127    let s = s.strip_suffix('$').unwrap_or(s);
128    s.to_string()
129}
130
131/// After [`parse_field`], `chars` is at optional whitespace then the closing `}`.
132/// True when there is a non-whitespace literal run after that `}` and before the next unescaped `{`
133/// or end of pattern (formatparse#83). Whitespace-only gaps do not count so ``{} {}`` keeps
134/// non-empty captures for both fields.
135fn has_trailing_literal_before_next_field(mut chars: std::iter::Peekable<std::str::Chars>) -> bool {
136    while chars.peek().is_some_and(|c| c.is_whitespace()) {
137        chars.next();
138    }
139    if chars.next() != Some('}') {
140        return false;
141    }
142    while chars.peek().is_some_and(|c| c.is_whitespace()) {
143        chars.next();
144    }
145    let mut literal = String::new();
146    loop {
147        match chars.next() {
148            None => return literal_delimits_empty_field(&literal),
149            Some('{') => {
150                if chars.peek() == Some(&'{') {
151                    chars.next();
152                    literal.push('{');
153                } else {
154                    return literal_delimits_empty_field(&literal);
155                }
156            }
157            Some('}') => {
158                if chars.peek() == Some(&'}') {
159                    chars.next();
160                    literal.push('}');
161                } else {
162                    literal.push('}');
163                }
164            }
165            Some(c) => literal.push(c),
166        }
167    }
168}
169
170/// Parse a format pattern string into regex parts, field specs, and names
171/// `allow_empty_delimited_default_string`: when false, default string fields always use `.+?`
172/// (used for the unanchored search regex so search/findall do not stop early).
173pub fn parse_pattern(
174    pattern: &str,
175    custom_patterns: &HashMap<String, String>,
176    allow_empty_delimited_default_string: bool,
177    nesting_depth: usize,
178) -> Result<ParsedPatternParts, FormatParseError> {
179    // Pre-allocate with estimated capacity based on pattern length
180    let estimated_fields = pattern.matches('{').count();
181    let mut regex_parts = Vec::with_capacity(estimated_fields * 2);
182    let mut field_specs = Vec::with_capacity(estimated_fields);
183    let mut field_names = Vec::with_capacity(estimated_fields); // Original names
184    let mut normalized_names = Vec::with_capacity(estimated_fields); // Normalized for regex
185    let mut name_mapping = HashMap::with_capacity(estimated_fields); // normalized -> original
186    let mut field_name_specs = HashMap::with_capacity(estimated_fields); // repeated-name type validation
187    let mut field_count: usize = 0;
188    let mut chars: std::iter::Peekable<std::str::Chars> = pattern.chars().peekable();
189    let mut literal = String::new();
190    let mut allows_empty_default_string_match = true;
191
192    while let Some(ch) = chars.next() {
193        match ch {
194            '{' => {
195                // Check for escaped brace
196                if chars.peek() == Some(&'{') {
197                    chars.next();
198                    literal.push('{');
199                    continue;
200                }
201
202                let had_leading_literal = !literal.trim().is_empty();
203
204                // Flush literal part
205                if !literal.is_empty() {
206                    allows_empty_default_string_match = false;
207                    // If literal ends with whitespace, make it flexible to allow multiple spaces
208                    // But use \s+ (one or more) instead of \s* (zero or more) to ensure we consume the space
209                    let escaped = if literal.trim_end() != literal {
210                        // Literal ends with whitespace - replace trailing whitespace with \s+
211                        // to allow one or more spaces (ensures we consume at least one space)
212                        let trimmed = literal.trim_end();
213                        let mut escaped_str = String::with_capacity(trimmed.len() + 4);
214                        escaped_str.push_str(&regex::escape(trimmed));
215                        escaped_str.push_str("\\s+");
216                        escaped_str
217                    } else {
218                        regex::escape(&literal)
219                    };
220                    regex_parts.push(escaped);
221                    literal.clear();
222                }
223
224                // Parse field specification
225                let (mut spec, name) = parse_field(&mut chars, nesting_depth)?;
226
227                if matches!(spec.field_type, FieldType::Nested) {
228                    if nesting_depth >= MAX_NESTED_FORMAT_DEPTH {
229                        return Err(FormatParseError::PatternError(
230                            "Nested format patterns exceed max depth (10)".to_string(),
231                        ));
232                    }
233                    let inner = spec.nested_subpattern.as_ref().ok_or_else(|| {
234                        FormatParseError::PatternError(
235                            "Internal error: nested field missing subpattern".to_string(),
236                        )
237                    })?;
238                    let (inner_anchored, _, _, _, _, _, _) = parse_pattern(
239                        inner,
240                        custom_patterns,
241                        allow_empty_delimited_default_string,
242                        nesting_depth + 1,
243                    )?;
244                    spec.nested_regex_body = Some(strip_regex_anchors(&inner_anchored));
245                }
246
247                if !spec.is_default_unconstrained_string() {
248                    allows_empty_default_string_match = false;
249                }
250
251                let has_trailing_literal = has_trailing_literal_before_next_field(chars.clone());
252
253                // Check if the next field (if any) is empty {} (non-greedy)
254                // This affects width-only string patterns: exact when followed by {}, greedy otherwise
255                let mut peek_chars = chars.clone();
256                let next_field_is_greedy = loop {
257                    // Skip whitespace and consume the expected closing '}'
258                    let mut found_closing = false;
259                    while let Some(&ch) = peek_chars.peek() {
260                        if ch.is_whitespace() {
261                            peek_chars.next();
262                        } else if ch == '}' {
263                            peek_chars.next(); // Consume the closing brace
264                            found_closing = true;
265                            break;
266                        } else {
267                            break;
268                        }
269                    }
270                    if !found_closing {
271                        break None; // No more fields
272                    }
273                    // Skip any whitespace after the closing brace
274                    while let Some(&ch) = peek_chars.peek() {
275                        if ch.is_whitespace() {
276                            peek_chars.next();
277                        } else {
278                            break;
279                        }
280                    }
281                    // Check for opening brace (indicating another field)
282                    if peek_chars.peek() == Some(&'{') {
283                        peek_chars.next();
284                        // Check if it's escaped
285                        if peek_chars.peek() == Some(&'{') {
286                            peek_chars.next();
287                            continue; // Escaped brace, continue
288                        }
289                        // Found a field - check if it's empty {} or has precision
290                        if peek_chars.peek() == Some(&'}') {
291                            // Empty field {} - non-greedy, use exact width
292                            break Some(false);
293                        } else {
294                            // Check if the field has precision (like {:.4})
295                            let mut field_chars = peek_chars.clone();
296                            let mut has_precision = false;
297                            while let Some(&ch) = field_chars.peek() {
298                                if ch == '}' {
299                                    break;
300                                }
301                                if ch == ':' {
302                                    field_chars.next();
303                                    // Check for precision after colon
304                                    while let Some(&next_ch) = field_chars.peek() {
305                                        if next_ch == '}' {
306                                            break;
307                                        }
308                                        if next_ch == '.' {
309                                            has_precision = true;
310                                            break;
311                                        }
312                                        field_chars.next();
313                                    }
314                                    break;
315                                }
316                                field_chars.next();
317                            }
318                            // If next field has precision, it's greedy (so current should be greedy too)
319                            // If next field is empty {}, it's non-greedy (so current should be exact)
320                            break Some(has_precision);
321                        }
322                    } else {
323                        // No more fields - use greedy
324                        break None;
325                    }
326                };
327
328                let allow_empty_delimited = allow_empty_delimited_default_string
329                    && spec.is_default_unconstrained_string()
330                    && (had_leading_literal || has_trailing_literal);
331                let pattern = spec.to_regex_pattern(
332                    custom_patterns,
333                    next_field_is_greedy,
334                    allow_empty_delimited,
335                );
336                let la_raw = spec.regex_lookahead.as_deref().unwrap_or("");
337                let (lb_prefix, body, la_emit) =
338                    crate::rewrite_field_fragments_for_engine_anchor(&pattern, la_raw);
339
340                field_count += 1;
341                if field_count > crate::parser::MAX_FIELDS {
342                    return Err(FormatParseError::PatternError(format!(
343                        "Pattern has more than {} fields",
344                        crate::parser::MAX_FIELDS
345                    )));
346                }
347
348                // Validate repeated field names have same type
349                if let Some(ref original_name) = name {
350                    if let Some(existing_spec) = field_name_specs.get(original_name) {
351                        if !field_specs_match_for_repeat(existing_spec, &spec) {
352                            return Err(FormatParseError::RepeatedNameError(original_name.clone()));
353                        }
354                    } else {
355                        field_name_specs.insert(original_name.clone(), spec.clone());
356                    }
357                }
358
359                // Issue #15 / parse#146: `{name:brace}` — capture text inside `{`…`}` in the input
360                // (non-greedy `.*?`; later pattern literals may force a later `}`). Supports empty `{}`.
361                // Requires a non-numbered name.
362                let group_pattern = if matches!(spec.field_type, FieldType::BracedContent) {
363                    let Some(ref original_name) = name else {
364                        return Err(FormatParseError::PatternError(
365                            "The :brace format requires a named field (e.g. {content:brace})"
366                                .to_string(),
367                        ));
368                    };
369                    if original_name.chars().all(|c| c.is_ascii_digit()) {
370                        return Err(FormatParseError::PatternError(
371                            "The :brace format cannot be used with numbered fields".to_string(),
372                        ));
373                    }
374                    let normalized =
375                        normalize_field_name(original_name, &mut name_mapping, &normalized_names);
376                    format!("\\{{(?P<{}>.*?)\\}}", normalized)
377                } else if let Some(ref original_name) = name {
378                    // Check if field name is numeric (numbered field like {0}, {1}) - these should be positional
379                    let is_numeric = original_name.chars().all(|c| c.is_ascii_digit());
380
381                    if is_numeric {
382                        // Numbered fields are positional (unnamed groups), not named groups
383                        format!("{}{}({}){}", lb_prefix, "", body, la_emit)
384                    } else {
385                        // Normalize name: replace hyphens/dots with underscores, handle collisions
386                        let normalized = normalize_field_name(
387                            original_name,
388                            &mut name_mapping,
389                            &normalized_names,
390                        );
391                        format!("{}{}(?P<{}>{}){}", lb_prefix, "", normalized, body, la_emit)
392                    }
393                } else {
394                    format!("{}{}({}){}", lb_prefix, "", body, la_emit)
395                };
396
397                regex_parts.push(group_pattern);
398
399                // Handle name normalization for regex groups
400                if let Some(ref original_name) = name {
401                    // Check if field name is numeric (numbered field like {0}, {1}) - these should be positional
402                    let is_numeric = original_name.chars().all(|c| c.is_ascii_digit());
403
404                    if is_numeric {
405                        field_names.push(None); // Store as None (positional)
406                        normalized_names.push(None);
407                    } else {
408                        let normalized = normalize_field_name(
409                            original_name,
410                            &mut name_mapping,
411                            &normalized_names,
412                        );
413                        field_names.push(Some(original_name.clone())); // Store original
414                        normalized_names.push(Some(normalized.clone())); // Store normalized
415                        name_mapping.insert(normalized, original_name.clone()); // Map normalized -> original
416                    }
417                } else {
418                    field_names.push(None);
419                    normalized_names.push(None);
420                }
421                field_specs.push(spec);
422
423                // Expect closing brace
424                if chars.next() != Some('}') {
425                    return Err(FormatParseError::PatternError(
426                        "Expected '}' after field specification".to_string(),
427                    ));
428                }
429            }
430            '}' => {
431                // Check for escaped brace
432                if chars.peek() == Some(&'}') {
433                    chars.next();
434                    literal.push('}');
435                    continue;
436                }
437                literal.push('}');
438            }
439            _ => {
440                literal.push(ch);
441            }
442        }
443    }
444
445    // Flush remaining literal
446    if !literal.is_empty() {
447        allows_empty_default_string_match = false;
448        // If literal ends with whitespace, make it flexible to allow multiple spaces
449        let escaped = if literal.trim_end() != literal {
450            // Literal ends with whitespace - replace trailing whitespace with \s*
451            // to allow zero or more spaces (maintains compatibility with exact matches)
452            let trimmed = literal.trim_end();
453            format!("{}\\s*", regex::escape(trimmed))
454        } else {
455            regex::escape(&literal)
456        };
457        regex_parts.push(escaped);
458    }
459
460    let regex_str = regex_parts.join("");
461    let regex_str_with_anchors = format!("^{}$", regex_str);
462    Ok((
463        regex_str_with_anchors,
464        regex_str,
465        field_specs,
466        field_names,
467        normalized_names,
468        name_mapping,
469        allows_empty_default_string_match,
470    ))
471}
472
473/// Normalize field name for use inside `(?P<name>...)` capture groups.
474///
475/// Hyphens and dots become underscores (legacy parse compatibility). Dict-style paths use
476/// `[` / `]` (`person[name]`); only `[` maps to `_`, and closing `]` is omitted so we do not
477/// add a trailing separator (e.g. `hello[world]` → `hello_world`). `[` / `]` are not valid
478/// in Rust/fancy-regex capture group identifiers.
479pub fn normalize_field_name(
480    name: &str,
481    _name_mapping: &mut HashMap<String, String>,
482    existing_normalized: &[Option<String>],
483) -> String {
484    let mut base_normalized = String::with_capacity(name.len());
485    for c in name.chars() {
486        match c {
487            '-' | '.' | '[' => base_normalized.push('_'),
488            ']' => {}
489            _ => base_normalized.push(c),
490        }
491    }
492
493    // Check for collisions with existing normalized names
494    let mut normalized = base_normalized.clone();
495
496    // Find the position of the first underscore to insert additional underscores there
497    let underscore_pos = normalized.find('_');
498
499    // Check if this exact normalized name already exists
500    let mut collision_count = 0;
501    while existing_normalized
502        .iter()
503        .any(|n| n.as_ref().map(|s| s == &normalized).unwrap_or(false))
504    {
505        collision_count += 1;
506        // Insert additional underscores at the first underscore position
507        // For "a_b", collisions become "a__b", "a___b", etc.
508        if let Some(pos) = underscore_pos {
509            let before = &base_normalized[..pos];
510            let after = &base_normalized[pos + 1..];
511            // Total underscores = 1 (base) + collision_count
512            normalized = format!("{}{}{}", before, "_".repeat(1 + collision_count), after);
513        } else {
514            // No underscore found, append underscores (shouldn't happen in practice)
515            normalized = format!("{}{}", base_normalized, "_".repeat(collision_count));
516        }
517    }
518
519    normalized
520}
521
522/// Reject `:ml` / `:blk` combined with numeric-only format specifiers (GitHub issues #8, #69, #70).
523///
524/// Width, precision, alignment, and fill are supported for multiline and indent-block fields;
525/// ``sign``, ``zero_pad``, and ``=`` alignment remain unsupported.
526pub fn validate_multiline_mvp(spec: &FieldSpec) -> Result<(), FormatParseError> {
527    if !matches!(
528        spec.field_type,
529        FieldType::Multiline | FieldType::IndentBlock
530    ) {
531        return Ok(());
532    }
533    if spec.sign.is_some() || spec.zero_pad {
534        return Err(FormatParseError::PatternError(
535            "Multiline types :ml and :blk do not support sign or zero-padding".to_string(),
536        ));
537    }
538    if spec.alignment == Some('=') {
539        return Err(FormatParseError::PatternError(
540            "Multiline types :ml and :blk do not support '=' alignment".to_string(),
541        ));
542    }
543    Ok(())
544}
545
546/// Check if two field types match (for repeated name validation)
547pub fn field_types_match(t1: &FieldType, t2: &FieldType) -> bool {
548    match (t1, t2) {
549        (FieldType::Custom(a), FieldType::Custom(b)) => a == b,
550        (a, b) => std::mem::discriminant(a) == std::mem::discriminant(b),
551    }
552}
553
554/// Whether two field specs are compatible when the same name appears twice.
555pub fn field_specs_match_for_repeat(a: &FieldSpec, b: &FieldSpec) -> bool {
556    if !field_types_match(&a.field_type, &b.field_type) {
557        return false;
558    }
559    match (&a.field_type, &b.field_type) {
560        (FieldType::DateTimeStrftime, FieldType::DateTimeStrftime) => {
561            // Same name may repeat with different strftime fragments (merged at match time).
562            true
563        }
564        (FieldType::Integer, FieldType::Integer) => a.original_type_char == b.original_type_char,
565        (FieldType::Nested, FieldType::Nested) => a.nested_subpattern == b.nested_subpattern,
566        _ => true,
567    }
568}
569
570/// Parse a field name into a path (for dict-style names like "hello[world]" -> ["hello", "world"])
571pub fn parse_field_path(field_name: &str) -> Vec<String> {
572    let mut path = Vec::new();
573    let mut current = String::new();
574    let mut in_brackets = false;
575
576    for ch in field_name.chars() {
577        match ch {
578            '[' => {
579                if !current.is_empty() {
580                    path.push(current.clone());
581                    current.clear();
582                }
583                in_brackets = true;
584            }
585            ']' => {
586                if in_brackets {
587                    if !current.is_empty() {
588                        path.push(current.clone());
589                        current.clear();
590                    }
591                    in_brackets = false;
592                } else {
593                    current.push(ch);
594                }
595            }
596            _ => {
597                current.push(ch);
598            }
599        }
600    }
601
602    if !current.is_empty() {
603        path.push(current);
604    }
605
606    path
607}
608
609/// Parse a single field specification from the pattern
610pub fn parse_field(
611    chars: &mut std::iter::Peekable<std::str::Chars>,
612    nesting_depth: usize,
613) -> Result<(FieldSpec, Option<String>), FormatParseError> {
614    let mut spec = FieldSpec::new();
615    let mut field_name = String::new();
616    let mut in_name = true;
617
618    // Parse field name (before colon or conversion)
619    let mut in_brackets = false;
620    while let Some(&ch) = chars.peek() {
621        match ch {
622            ':' => {
623                chars.next();
624                in_name = false;
625                break;
626            }
627            '!' => {
628                chars.next();
629                // Conversion specifier (s, r, a) - skip for now
630                if chars.peek().is_some() {
631                    chars.next();
632                }
633                in_name = false;
634            }
635            '}' => {
636                break;
637            }
638            '[' => {
639                in_brackets = true;
640                field_name.push(ch);
641                chars.next();
642            }
643            ']' => {
644                in_brackets = false;
645                field_name.push(ch);
646                chars.next();
647            }
648            '\'' | '"' => {
649                // Quote characters in field names indicate quoted keys (not supported)
650                if in_brackets {
651                    return Err(FormatParseError::NotImplementedError(
652                        "Quoted keys in field names".to_string(),
653                    ));
654                }
655                // Not in brackets, not a valid name character
656                in_name = false;
657                break;
658            }
659            _ => {
660                // Allow alphanumeric, underscore, hyphen, dot for field names
661                if ch.is_alphanumeric() || ch == '_' || ch == '-' || ch == '.' {
662                    field_name.push(ch);
663                    chars.next();
664                } else {
665                    // Not a valid name character, might be format spec
666                    in_name = false;
667                    break;
668                }
669            }
670        }
671    }
672
673    // Parse format spec (after colon until closing `}` that ends this field)
674    if !in_name {
675        let format_spec = collect_balanced_format_spec(chars)?;
676        let trimmed = format_spec.trim();
677        if is_nested_format_spec_candidate(trimmed) {
678            if nesting_depth >= MAX_NESTED_FORMAT_DEPTH {
679                return Err(FormatParseError::PatternError(
680                    "Nested format patterns exceed max depth (10)".to_string(),
681                ));
682            }
683            spec.field_type = FieldType::Nested;
684            spec.nested_subpattern = Some(trimmed.to_string());
685        } else {
686            parse_format_spec(&format_spec, &mut spec)?;
687        }
688        validate_multiline_mvp(&spec)?;
689    }
690
691    let name = if field_name.is_empty() {
692        None
693    } else {
694        crate::parser::validate_field_name(&field_name).map_err(FormatParseError::PatternError)?;
695        Some(field_name)
696    };
697
698    Ok((spec, name))
699}
700
701/// Parse format specifier string into FieldSpec
702pub fn parse_format_spec(format_spec: &str, spec: &mut FieldSpec) -> Result<(), FormatParseError> {
703    // Format spec: [[fill]align][sign][#][0][width][,][.precision][type]
704    // Examples: "<10", ">", "^5.2f", "+d", "03d", ".2f"
705
706    let mut chars = format_spec.chars().peekable();
707
708    // Parse fill and align (optional)
709    // align can be: '<', '>', '^', '='
710    if let Some(&ch) = chars.peek() {
711        if ch == '<' || ch == '>' || ch == '^' || ch == '=' {
712            spec.alignment = Some(ch);
713            chars.next();
714        } else {
715            // Check if we have fill + align (e.g., "x<")
716            let mut peek_iter = chars.clone();
717            peek_iter.next(); // skip first char
718            if let Some(next_ch) = peek_iter.next() {
719                if next_ch == '<' || next_ch == '>' || next_ch == '^' || next_ch == '=' {
720                    spec.fill = Some(ch);
721                    chars.next(); // consume fill
722                    spec.alignment = Some(next_ch);
723                    chars.next(); // consume align
724                }
725            }
726        }
727    }
728
729    // Parse sign (optional): '+', '-', ' '
730    if let Some(&ch) = chars.peek() {
731        if ch == '+' || ch == '-' || ch == ' ' {
732            spec.sign = Some(ch);
733            chars.next();
734        }
735    }
736
737    // Parse # (alternate form) - skip for now
738    if chars.peek() == Some(&'#') {
739        chars.next();
740    }
741
742    // Parse 0 (zero padding)
743    if chars.peek() == Some(&'0') {
744        spec.zero_pad = true;
745        chars.next();
746    }
747
748    // Parse width (digits)
749    let mut width_str = String::new();
750    while let Some(&ch) = chars.peek() {
751        if ch.is_ascii_digit() {
752            width_str.push(ch);
753            chars.next();
754        } else {
755            break;
756        }
757    }
758    if !width_str.is_empty() {
759        if let Ok(w) = width_str.parse::<usize>() {
760            if w > crate::parser::MAX_WIDTH_PRECISION {
761                return Err(FormatParseError::PatternError(format!(
762                    "Width {} exceeds maximum allowed {}",
763                    w,
764                    crate::parser::MAX_WIDTH_PRECISION
765                )));
766            }
767            spec.width = Some(w);
768        }
769    }
770
771    // Parse comma (thousands separator) - skip for now
772    if chars.peek() == Some(&',') {
773        chars.next();
774    }
775
776    // Parse precision (.digits)
777    if chars.peek() == Some(&'.') {
778        chars.next();
779        let mut precision_str = String::new();
780        while let Some(&ch) = chars.peek() {
781            if ch.is_ascii_digit() {
782                precision_str.push(ch);
783                chars.next();
784            } else {
785                break;
786            }
787        }
788        if !precision_str.is_empty() {
789            if let Ok(p) = precision_str.parse::<usize>() {
790                if p > crate::parser::MAX_WIDTH_PRECISION {
791                    return Err(FormatParseError::PatternError(format!(
792                        "Precision {} exceeds maximum allowed {}",
793                        p,
794                        crate::parser::MAX_WIDTH_PRECISION
795                    )));
796                }
797                spec.precision = Some(p);
798            }
799        }
800    }
801
802    // Remaining characters: type token(s), optional trailing lookarounds (issue #9)
803    let mut type_str = String::new();
804    for ch in chars {
805        type_str.push(ch);
806    }
807
808    if type_str == "%" {
809        spec.field_type = FieldType::Percentage;
810        return Ok(());
811    }
812    if type_str.starts_with('%') {
813        crate::reject_lookaround_in_strftime(&type_str).map_err(FormatParseError::PatternError)?;
814        spec.field_type = FieldType::DateTimeStrftime;
815        spec.strftime_format = Some(type_str.clone());
816        return Ok(());
817    }
818
819    let (type_base, lookaround_tail) = crate::split_type_base_and_lookaround_tail(&type_str);
820    if type_base.is_empty() && !lookaround_tail.is_empty() {
821        return Err(FormatParseError::PatternError(
822            "Type specification must precede lookaround assertions".to_string(),
823        ));
824    }
825
826    // Extract type name (alphanumeric + underscore) from the type base (not from lookarounds)
827    let type_name: String = type_base
828        .chars()
829        .filter(|c| c.is_alphanumeric() || *c == '_')
830        .collect();
831
832    spec.field_type = if type_name.is_empty() {
833        FieldType::String
834    } else if type_name == "ti" {
835        FieldType::DateTimeISO
836    } else if type_name == "te" {
837        FieldType::DateTimeRFC2822
838    } else if type_name == "tg" {
839        FieldType::DateTimeGlobal
840    } else if type_name == "ta" {
841        FieldType::DateTimeUS
842    } else if type_name == "tc" {
843        FieldType::DateTimeCtime
844    } else if type_name == "th" {
845        FieldType::DateTimeHTTP
846    } else if type_name == "tt" {
847        FieldType::DateTimeTime
848    } else if type_name == "ts" {
849        FieldType::DateTimeSystem
850    } else if type_name == "brace" {
851        FieldType::BracedContent
852    } else if type_name == "ml" {
853        FieldType::Multiline
854    } else if type_name == "blk" {
855        FieldType::IndentBlock
856    } else if type_name.len() > 1 {
857        FieldType::Custom(type_name)
858    } else {
859        let type_char = type_name.chars().next().unwrap();
860        spec.original_type_char = Some(type_char);
861        match type_char {
862            's' => FieldType::String,
863            'd' | 'i' => FieldType::Integer,
864            'b' | 'o' | 'x' | 'X' => FieldType::Integer,
865            'n' => FieldType::NumberWithThousands,
866            'f' | 'F' => FieldType::Float,
867            'e' | 'E' => FieldType::Scientific,
868            'g' | 'G' => FieldType::GeneralNumber,
869            'l' => FieldType::Letters,
870            'w' => FieldType::Word,
871            'W' => FieldType::NonLetters,
872            'S' => FieldType::NonWhitespace,
873            'D' => FieldType::NonDigits,
874            c => FieldType::Custom(c.to_string()),
875        }
876    };
877
878    if !lookaround_tail.is_empty() {
879        let (lb, la) = crate::parse_lookaround_tail(lookaround_tail)
880            .map_err(FormatParseError::PatternError)?;
881        match &spec.field_type {
882            FieldType::Integer | FieldType::Float => {
883                spec.regex_lookbehind = if lb.is_empty() { None } else { Some(lb) };
884                spec.regex_lookahead = if la.is_empty() { None } else { Some(la) };
885            }
886            _ => {
887                return Err(FormatParseError::PatternError("Lookaround assertions are only supported for integer and float format types (d, i, b, o, x, X, f, F)".to_string()));
888            }
889        }
890    }
891
892    Ok(())
893}
894
895#[cfg(test)]
896mod normalize_field_name_tests {
897    use super::normalize_field_name;
898    use std::collections::HashMap;
899
900    #[test]
901    fn dict_style_brackets_map_to_underscores() {
902        let mut m = HashMap::new();
903        let existing: Vec<Option<String>> = vec![];
904        assert_eq!(
905            normalize_field_name("hello[world]", &mut m, &existing),
906            "hello_world"
907        );
908        assert_eq!(
909            normalize_field_name("hello[foo][baz]", &mut m, &existing),
910            "hello_foo_baz"
911        );
912    }
913
914    #[test]
915    fn deep_nested_brackets_normalize() {
916        let mut m = HashMap::new();
917        assert_eq!(normalize_field_name("a[b[c[d]]]", &mut m, &[]), "a_b_c_d");
918    }
919}