Skip to main content

agm_core/parser/
header.rs

1//! Header parser: reads the AGM file header and produces a `Header` struct.
2
3use std::sync::LazyLock;
4
5use regex::Regex;
6
7use crate::error::{AgmError, ErrorCode, ErrorLocation};
8use crate::model::file::Header;
9
10use super::fields::{
11    FieldTracker, parse_block, parse_imports, parse_indented_list, skip_field_body,
12};
13use super::lexer::{Line, LineKind};
14use super::structured::parse_load_profiles;
15
16// ---------------------------------------------------------------------------
17// Validation regexes
18// ---------------------------------------------------------------------------
19
20static AGM_VERSION_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[0-9]+\.[0-9]+$").unwrap());
21
22static PACKAGE_RE: LazyLock<Regex> =
23    LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$").unwrap());
24
25// ---------------------------------------------------------------------------
26// Validation helpers
27// ---------------------------------------------------------------------------
28
29fn validate_agm_format(value: &str, line_number: usize, errors: &mut Vec<AgmError>) {
30    if !AGM_VERSION_RE.is_match(value) {
31        errors.push(AgmError::new(
32            ErrorCode::P001,
33            format!("Invalid `agm` format: expected MAJOR.MINOR, got {value:?}"),
34            ErrorLocation::new(None, Some(line_number), None),
35        ));
36    }
37}
38
39fn validate_package_format(value: &str, line_number: usize, errors: &mut Vec<AgmError>) {
40    if !PACKAGE_RE.is_match(value) {
41        errors.push(AgmError::new(
42            ErrorCode::P001,
43            format!("Invalid `package` format: {value:?}"),
44            ErrorLocation::new(None, Some(line_number), None),
45        ));
46    }
47}
48
49fn validate_version_format(value: &str, line_number: usize, errors: &mut Vec<AgmError>) {
50    if semver::Version::parse(value).is_err() {
51        errors.push(AgmError::new(
52            ErrorCode::P001,
53            format!("Invalid `version` (expected semver): {value:?}"),
54            ErrorLocation::new(None, Some(line_number), None),
55        ));
56    }
57}
58
59// ---------------------------------------------------------------------------
60// parse_header
61// ---------------------------------------------------------------------------
62
63/// Parses the header section of an AGM file.
64///
65/// Advances `pos` to the first `NodeDeclaration` or end of `lines`.
66/// Emits errors into `errors`.
67pub fn parse_header(lines: &[Line], pos: &mut usize, errors: &mut Vec<AgmError>) -> Header {
68    let mut tracker = FieldTracker::new();
69
70    let mut agm: Option<String> = None;
71    let mut package: Option<String> = None;
72    let mut version: Option<String> = None;
73    let mut title: Option<String> = None;
74    let mut owner: Option<String> = None;
75    let mut default_load: Option<String> = None;
76    let mut description: Option<String> = None;
77    let mut status: Option<String> = None;
78    let mut target_runtime: Option<String> = None;
79    let mut tags: Option<Vec<String>> = None;
80    let mut imports_raw: Option<Vec<crate::model::imports::ImportEntry>> = None;
81    let mut load_profiles_parsed: Option<
82        std::collections::BTreeMap<String, crate::model::file::LoadProfile>,
83    > = None;
84
85    while *pos < lines.len() {
86        match &lines[*pos].kind.clone() {
87            LineKind::NodeDeclaration(_) => break,
88
89            LineKind::Blank | LineKind::Comment | LineKind::TestExpectHeader(_) => {
90                *pos += 1;
91            }
92
93            LineKind::ScalarField(key, value) => {
94                let key = key.clone();
95                let value = value.clone();
96                let line_number = lines[*pos].number;
97
98                if tracker.track(&key) {
99                    errors.push(AgmError::new(
100                        ErrorCode::P006,
101                        format!("Duplicate field `{key}` in header"),
102                        ErrorLocation::new(None, Some(line_number), None),
103                    ));
104                    *pos += 1;
105                    continue;
106                }
107
108                match key.as_str() {
109                    "agm" => {
110                        validate_agm_format(&value, line_number, errors);
111                        agm = Some(value);
112                    }
113                    "package" => {
114                        validate_package_format(&value, line_number, errors);
115                        package = Some(value);
116                    }
117                    "version" => {
118                        validate_version_format(&value, line_number, errors);
119                        version = Some(value);
120                    }
121                    "title" => title = Some(value),
122                    "owner" => owner = Some(value),
123                    "default_load" => default_load = Some(value),
124                    "description" => description = Some(value),
125                    "status" => status = Some(value),
126                    "target_runtime" => target_runtime = Some(value),
127                    _ => {} // unknown scalar fields are ignored in header
128                }
129                *pos += 1;
130            }
131
132            LineKind::InlineListField(key, items) => {
133                let key = key.clone();
134                let items = items.clone();
135                let line_number = lines[*pos].number;
136
137                if tracker.track(&key) {
138                    errors.push(AgmError::new(
139                        ErrorCode::P006,
140                        format!("Duplicate field `{key}` in header"),
141                        ErrorLocation::new(None, Some(line_number), None),
142                    ));
143                    *pos += 1;
144                    continue;
145                }
146
147                match key.as_str() {
148                    "tags" => tags = Some(items),
149                    "imports" => {
150                        imports_raw = Some(parse_imports(&items, line_number, errors));
151                    }
152                    _ => {} // unknown inline list fields ignored
153                }
154                *pos += 1;
155            }
156
157            LineKind::FieldStart(key) => {
158                let key = key.clone();
159                let line_number = lines[*pos].number;
160
161                if tracker.track(&key) {
162                    errors.push(AgmError::new(
163                        ErrorCode::P006,
164                        format!("Duplicate field `{key}` in header"),
165                        ErrorLocation::new(None, Some(line_number), None),
166                    ));
167                    *pos += 1;
168                    skip_field_body(lines, pos);
169                    continue;
170                }
171
172                *pos += 1; // advance past the FieldStart line
173
174                match key.as_str() {
175                    "description" => {
176                        description = Some(parse_block(lines, pos));
177                    }
178                    "tags" => {
179                        tags = Some(parse_indented_list(lines, pos));
180                    }
181                    "imports" => {
182                        let raw_items = parse_indented_list(lines, pos);
183                        imports_raw = Some(parse_imports(&raw_items, line_number, errors));
184                    }
185                    "load_profiles" => {
186                        load_profiles_parsed = Some(parse_load_profiles(lines, pos, errors));
187                    }
188                    _ => {
189                        skip_field_body(lines, pos);
190                    }
191                }
192            }
193
194            LineKind::BodyMarker => {
195                // Unexpected in header — ignore.
196                *pos += 1;
197            }
198
199            LineKind::ListItem(_) | LineKind::IndentedLine(_) => {
200                errors.push(AgmError::new(
201                    ErrorCode::P003,
202                    format!(
203                        "Unexpected indentation in header at line {}",
204                        lines[*pos].number
205                    ),
206                    ErrorLocation::new(None, Some(lines[*pos].number), None),
207                ));
208                *pos += 1;
209            }
210        }
211    }
212
213    // Check required fields.
214    for field in ["agm", "package", "version"] {
215        let is_missing = match field {
216            "agm" => agm.is_none(),
217            "package" => package.is_none(),
218            "version" => version.is_none(),
219            _ => false,
220        };
221        if is_missing {
222            errors.push(AgmError::new(
223                ErrorCode::P001,
224                format!("Missing required header field: '{field}'"),
225                ErrorLocation::new(None, None, None),
226            ));
227        }
228    }
229
230    Header {
231        agm: agm.unwrap_or_default(),
232        package: package.unwrap_or_default(),
233        version: version.unwrap_or_default(),
234        title,
235        owner,
236        imports: imports_raw,
237        default_load,
238        description,
239        tags,
240        status,
241        load_profiles: load_profiles_parsed,
242        target_runtime,
243    }
244}