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            ']' if in_brackets => {
586                if !current.is_empty() {
587                    path.push(current.clone());
588                    current.clear();
589                }
590                in_brackets = false;
591            }
592            _ => {
593                current.push(ch);
594            }
595        }
596    }
597
598    if !current.is_empty() {
599        path.push(current);
600    }
601
602    path
603}
604
605/// Parse a single field specification from the pattern
606pub fn parse_field(
607    chars: &mut std::iter::Peekable<std::str::Chars>,
608    nesting_depth: usize,
609) -> Result<(FieldSpec, Option<String>), FormatParseError> {
610    let mut spec = FieldSpec::new();
611    let mut field_name = String::new();
612    let mut in_name = true;
613
614    // Parse field name (before colon or conversion)
615    let mut in_brackets = false;
616    while let Some(&ch) = chars.peek() {
617        match ch {
618            ':' => {
619                chars.next();
620                in_name = false;
621                break;
622            }
623            '!' => {
624                chars.next();
625                // Conversion specifier (s, r, a) - skip for now
626                if chars.peek().is_some() {
627                    chars.next();
628                }
629                in_name = false;
630            }
631            '}' => {
632                break;
633            }
634            '[' => {
635                in_brackets = true;
636                field_name.push(ch);
637                chars.next();
638            }
639            ']' => {
640                in_brackets = false;
641                field_name.push(ch);
642                chars.next();
643            }
644            '\'' | '"' => {
645                // Quote characters in field names indicate quoted keys (not supported)
646                if in_brackets {
647                    return Err(FormatParseError::NotImplementedError(
648                        "Quoted keys in field names".to_string(),
649                    ));
650                }
651                // Not in brackets, not a valid name character
652                in_name = false;
653                break;
654            }
655            _ => {
656                // Allow alphanumeric, underscore, hyphen, dot for field names
657                if ch.is_alphanumeric() || ch == '_' || ch == '-' || ch == '.' {
658                    field_name.push(ch);
659                    chars.next();
660                } else {
661                    // Not a valid name character, might be format spec
662                    in_name = false;
663                    break;
664                }
665            }
666        }
667    }
668
669    // Parse format spec (after colon until closing `}` that ends this field)
670    if !in_name {
671        let format_spec = collect_balanced_format_spec(chars)?;
672        let trimmed = format_spec.trim();
673        if is_nested_format_spec_candidate(trimmed) {
674            if nesting_depth >= MAX_NESTED_FORMAT_DEPTH {
675                return Err(FormatParseError::PatternError(
676                    "Nested format patterns exceed max depth (10)".to_string(),
677                ));
678            }
679            spec.field_type = FieldType::Nested;
680            spec.nested_subpattern = Some(trimmed.to_string());
681        } else {
682            parse_format_spec(&format_spec, &mut spec)?;
683        }
684        validate_multiline_mvp(&spec)?;
685    }
686
687    let name = if field_name.is_empty() {
688        None
689    } else {
690        crate::parser::validate_field_name(&field_name).map_err(FormatParseError::PatternError)?;
691        Some(field_name)
692    };
693
694    Ok((spec, name))
695}
696
697/// Parse format specifier string into FieldSpec
698pub fn parse_format_spec(format_spec: &str, spec: &mut FieldSpec) -> Result<(), FormatParseError> {
699    // Format spec: [[fill]align][sign][#][0][width][,][.precision][type]
700    // Examples: "<10", ">", "^5.2f", "+d", "03d", ".2f"
701
702    let mut chars = format_spec.chars().peekable();
703
704    // Parse fill and align (optional)
705    // align can be: '<', '>', '^', '='
706    if let Some(&ch) = chars.peek() {
707        if ch == '<' || ch == '>' || ch == '^' || ch == '=' {
708            spec.alignment = Some(ch);
709            chars.next();
710        } else {
711            // Check if we have fill + align (e.g., "x<")
712            let mut peek_iter = chars.clone();
713            peek_iter.next(); // skip first char
714            if let Some(next_ch) = peek_iter.next() {
715                if next_ch == '<' || next_ch == '>' || next_ch == '^' || next_ch == '=' {
716                    spec.fill = Some(ch);
717                    chars.next(); // consume fill
718                    spec.alignment = Some(next_ch);
719                    chars.next(); // consume align
720                }
721            }
722        }
723    }
724
725    // Parse sign (optional): '+', '-', ' '
726    if let Some(&ch) = chars.peek() {
727        if ch == '+' || ch == '-' || ch == ' ' {
728            spec.sign = Some(ch);
729            chars.next();
730        }
731    }
732
733    // Parse # (alternate form) - skip for now
734    if chars.peek() == Some(&'#') {
735        chars.next();
736    }
737
738    // Parse 0 (zero padding)
739    if chars.peek() == Some(&'0') {
740        spec.zero_pad = true;
741        chars.next();
742    }
743
744    // Parse width (digits)
745    let mut width_str = String::new();
746    while let Some(&ch) = chars.peek() {
747        if ch.is_ascii_digit() {
748            width_str.push(ch);
749            chars.next();
750        } else {
751            break;
752        }
753    }
754    if !width_str.is_empty() {
755        if let Ok(w) = width_str.parse::<usize>() {
756            if w > crate::parser::MAX_WIDTH_PRECISION {
757                return Err(FormatParseError::PatternError(format!(
758                    "Width {} exceeds maximum allowed {}",
759                    w,
760                    crate::parser::MAX_WIDTH_PRECISION
761                )));
762            }
763            spec.width = Some(w);
764        }
765    }
766
767    // Parse comma (thousands separator) - skip for now
768    if chars.peek() == Some(&',') {
769        chars.next();
770    }
771
772    // Parse precision (.digits)
773    if chars.peek() == Some(&'.') {
774        chars.next();
775        let mut precision_str = String::new();
776        while let Some(&ch) = chars.peek() {
777            if ch.is_ascii_digit() {
778                precision_str.push(ch);
779                chars.next();
780            } else {
781                break;
782            }
783        }
784        if !precision_str.is_empty() {
785            if let Ok(p) = precision_str.parse::<usize>() {
786                if p > crate::parser::MAX_WIDTH_PRECISION {
787                    return Err(FormatParseError::PatternError(format!(
788                        "Precision {} exceeds maximum allowed {}",
789                        p,
790                        crate::parser::MAX_WIDTH_PRECISION
791                    )));
792                }
793                spec.precision = Some(p);
794            }
795        }
796    }
797
798    // Remaining characters: type token(s), optional trailing lookarounds (issue #9)
799    let mut type_str = String::new();
800    for ch in chars {
801        type_str.push(ch);
802    }
803
804    if type_str == "%" {
805        spec.field_type = FieldType::Percentage;
806        return Ok(());
807    }
808    if type_str.starts_with('%') {
809        crate::reject_lookaround_in_strftime(&type_str).map_err(FormatParseError::PatternError)?;
810        spec.field_type = FieldType::DateTimeStrftime;
811        spec.strftime_format = Some(type_str.clone());
812        return Ok(());
813    }
814
815    let (type_base, lookaround_tail) = crate::split_type_base_and_lookaround_tail(&type_str);
816    if type_base.is_empty() && !lookaround_tail.is_empty() {
817        return Err(FormatParseError::PatternError(
818            "Type specification must precede lookaround assertions".to_string(),
819        ));
820    }
821
822    // Extract type name (alphanumeric + underscore) from the type base (not from lookarounds)
823    let type_name: String = type_base
824        .chars()
825        .filter(|c| c.is_alphanumeric() || *c == '_')
826        .collect();
827
828    spec.field_type = if type_name.is_empty() {
829        FieldType::String
830    } else if type_name == "ti" {
831        FieldType::DateTimeISO
832    } else if type_name == "te" {
833        FieldType::DateTimeRFC2822
834    } else if type_name == "tg" {
835        FieldType::DateTimeGlobal
836    } else if type_name == "ta" {
837        FieldType::DateTimeUS
838    } else if type_name == "tc" {
839        FieldType::DateTimeCtime
840    } else if type_name == "th" {
841        FieldType::DateTimeHTTP
842    } else if type_name == "tt" {
843        FieldType::DateTimeTime
844    } else if type_name == "ts" {
845        FieldType::DateTimeSystem
846    } else if type_name == "brace" {
847        FieldType::BracedContent
848    } else if type_name == "ml" {
849        FieldType::Multiline
850    } else if type_name == "blk" {
851        FieldType::IndentBlock
852    } else if type_name.len() > 1 {
853        FieldType::Custom(type_name)
854    } else {
855        let type_char = type_name.chars().next().unwrap();
856        spec.original_type_char = Some(type_char);
857        match type_char {
858            's' => FieldType::String,
859            'd' | 'i' => FieldType::Integer,
860            'b' | 'o' | 'x' | 'X' => FieldType::Integer,
861            'n' => FieldType::NumberWithThousands,
862            'f' | 'F' => FieldType::Float,
863            'e' | 'E' => FieldType::Scientific,
864            'g' | 'G' => FieldType::GeneralNumber,
865            'l' => FieldType::Letters,
866            'w' => FieldType::Word,
867            'W' => FieldType::NonLetters,
868            'S' => FieldType::NonWhitespace,
869            'D' => FieldType::NonDigits,
870            c => FieldType::Custom(c.to_string()),
871        }
872    };
873
874    if !lookaround_tail.is_empty() {
875        let (lb, la) = crate::parse_lookaround_tail(lookaround_tail)
876            .map_err(FormatParseError::PatternError)?;
877        match &spec.field_type {
878            FieldType::Integer | FieldType::Float => {
879                spec.regex_lookbehind = if lb.is_empty() { None } else { Some(lb) };
880                spec.regex_lookahead = if la.is_empty() { None } else { Some(la) };
881            }
882            _ => {
883                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()));
884            }
885        }
886    }
887
888    Ok(())
889}
890
891#[cfg(test)]
892mod normalize_field_name_tests {
893    use super::normalize_field_name;
894    use std::collections::HashMap;
895
896    #[test]
897    fn dict_style_brackets_map_to_underscores() {
898        let mut m = HashMap::new();
899        let existing: Vec<Option<String>> = vec![];
900        assert_eq!(
901            normalize_field_name("hello[world]", &mut m, &existing),
902            "hello_world"
903        );
904        assert_eq!(
905            normalize_field_name("hello[foo][baz]", &mut m, &existing),
906            "hello_foo_baz"
907        );
908    }
909
910    #[test]
911    fn deep_nested_brackets_normalize() {
912        let mut m = HashMap::new();
913        assert_eq!(normalize_field_name("a[b[c[d]]]", &mut m, &[]), "a_b_c_d");
914    }
915}