1use crate::error::Result;
4use crate::header::Header;
5use crate::record::{RecordKind, Value};
6use crate::CARD_LEN;
7
8#[deprecated(note = "use Header::parse")]
10pub fn parse(bytes: &[u8]) -> Result<Header> {
11 parse_header(bytes)
12}
13
14pub(crate) fn parse_header(bytes: &[u8]) -> Result<Header> {
20 let cards: Vec<[u8; CARD_LEN]> = bytes
21 .chunks_exact(CARD_LEN)
22 .map(|c| {
23 let mut a = [b' '; CARD_LEN];
24 a.copy_from_slice(c);
25 a
26 })
27 .collect();
28
29 let mut records = Vec::new();
30 let mut i = 0;
31 while i < cards.len() {
32 let card = cards[i];
33 let card_str = String::from_utf8_lossy(&card).into_owned();
34 let keyword = card_str[..8].trim().to_string();
35
36 if keyword == "END" {
37 break;
38 }
39
40 let is_value = card.get(8) == Some(&b'=') && card.get(9) == Some(&b' ');
41
42 if is_value {
43 let field = card_str[10..].trim_start();
44 if field.starts_with('\'') {
45 let (mut content, mut comment, mut cont) = parse_string_field(field);
47 let mut raw = vec![card];
48 while cont && i + 1 < cards.len() {
49 let next = cards[i + 1];
50 let next_kw = String::from_utf8_lossy(&next[..8]).trim().to_string();
51 if next_kw != "CONTINUE" {
52 break;
53 }
54 content.pop();
56 let next_str = String::from_utf8_lossy(&next).into_owned();
57 let nf = next_str[10..].trim_start();
58 let (piece, c2, cont2) = parse_string_field(nf);
59 content.push_str(&piece);
60 if c2.is_some() {
61 comment = c2;
62 }
63 cont = cont2;
64 raw.push(next);
65 i += 1;
66 }
67 let content = content.trim_end().to_string();
68 records.push(crate::record::Record::from_raw(
69 RecordKind::Value {
70 keyword,
71 value: Value::Str(content),
72 comment,
73 },
74 raw,
75 ));
76 } else {
77 let (token, comment) = split_comment(field);
78 records.push(crate::record::Record::from_raw(
79 RecordKind::Value {
80 keyword,
81 value: Value::Literal(token.trim().to_string()),
82 comment,
83 },
84 vec![card],
85 ));
86 }
87 } else if keyword == "COMMENT" || keyword == "HISTORY" {
88 let text = card_str[8..].trim_end().to_string();
89 records.push(crate::record::Record::from_raw(
90 RecordKind::Commentary { keyword, text },
91 vec![card],
92 ));
93 } else if keyword.is_empty() {
94 let rest = &card_str[8..];
95 if rest.trim().is_empty() {
96 records.push(crate::record::Record::from_raw(
97 RecordKind::Opaque {
98 text: String::new(),
99 },
100 vec![card],
101 ));
102 } else {
103 records.push(crate::record::Record::from_raw(
104 RecordKind::Commentary {
105 keyword,
106 text: rest.trim_end().to_string(),
107 },
108 vec![card],
109 ));
110 }
111 } else {
112 records.push(crate::record::Record::from_raw(
114 RecordKind::Opaque {
115 text: card_str.trim_end().to_string(),
116 },
117 vec![card],
118 ));
119 }
120
121 i += 1;
122 }
123
124 Ok(Header::from_records(records))
125}
126
127fn parse_string_field(field: &str) -> (String, Option<String>, bool) {
130 let rest = match field.strip_prefix('\'') {
131 Some(r) => r,
132 None => return (String::new(), None, false),
133 };
134 let chars: Vec<char> = rest.chars().collect();
135 let mut content = String::new();
136 let mut k = 0;
137 while k < chars.len() {
138 if chars[k] == '\'' {
139 if chars.get(k + 1) == Some(&'\'') {
140 content.push('\'');
141 k += 2;
142 continue;
143 }
144 k += 1;
145 break;
146 }
147 content.push(chars[k]);
148 k += 1;
149 }
150 let remainder: String = chars[k..].iter().collect();
151 let comment = extract_comment(&remainder);
152 let cont = content.ends_with('&');
154 (content, comment, cont)
155}
156
157fn extract_comment(s: &str) -> Option<String> {
159 let idx = s.find('/')?;
160 let c = s[idx + 1..].trim().to_string();
161 (!c.is_empty()).then_some(c)
162}
163
164fn split_comment(s: &str) -> (&str, Option<String>) {
166 match s.find('/') {
167 Some(idx) => (&s[..idx], extract_comment(&s[idx..])),
168 None => (s, None),
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::record::RecordKind;
176
177 fn block(cards: &[&str]) -> Vec<u8> {
178 let mut out = Vec::new();
179 for c in cards {
180 let mut b = c.as_bytes().to_vec();
181 b.resize(CARD_LEN, b' ');
182 out.extend(b);
183 }
184 let mut end = b"END".to_vec();
185 end.resize(CARD_LEN, b' ');
186 out.extend(end);
187 out
188 }
189
190 #[test]
191 fn string_field_plain_with_comment() {
192 let (content, comment, cont) = parse_string_field("'abc ' / a note");
193 assert_eq!(content, "abc ");
194 assert_eq!(comment.as_deref(), Some("a note"));
195 assert!(!cont);
196 }
197
198 #[test]
199 fn string_field_unescapes_doubled_quotes() {
200 let (content, _, _) = parse_string_field("'ab''c'");
201 assert_eq!(content, "ab'c");
202 }
203
204 #[test]
205 fn string_field_continuation_marker() {
206 let (content, comment, cont) = parse_string_field("'abc&'");
207 assert_eq!(content, "abc&");
209 assert_eq!(comment, None);
210 assert!(cont);
211 }
212
213 #[test]
214 fn string_field_without_quote_is_empty() {
215 assert_eq!(parse_string_field("T"), (String::new(), None, false));
216 }
217
218 #[test]
219 fn comment_extraction() {
220 assert_eq!(extract_comment(" / hi"), Some("hi".to_string()));
221 assert_eq!(extract_comment(" / "), None, "empty comment is None");
222 assert_eq!(extract_comment("no slash"), None);
223 let (token, comment) = split_comment("T / yes");
224 assert_eq!(token.trim(), "T");
225 assert_eq!(comment.as_deref(), Some("yes"));
226 let (token, comment) = split_comment("42");
227 assert_eq!(token, "42");
228 assert_eq!(comment, None);
229 }
230
231 #[test]
232 fn hierarch_and_unrecognized_are_opaque() {
233 let h = parse_header(&block(&["HIERARCH ESO DET DIT = 10.0", "JUNK CARD"])).unwrap();
234 assert_eq!(h.cards().len(), 2);
235 for r in h.cards() {
236 assert!(matches!(r.kind, RecordKind::Opaque { .. }));
237 assert_eq!(r.keyword(), None);
238 }
239 }
240
241 #[test]
242 fn fully_blank_card_is_opaque_blank_with_text_is_commentary() {
243 let h = parse_header(&block(&["", " some annotation"])).unwrap();
244 assert!(matches!(
245 h.cards()[0].kind,
246 RecordKind::Opaque { ref text } if text.is_empty()
247 ));
248 assert!(matches!(
249 h.cards()[1].kind,
250 RecordKind::Commentary { ref keyword, ref text }
251 if keyword.is_empty() && text == "some annotation"
252 ));
253 }
254
255 #[test]
256 fn stray_continue_is_opaque() {
257 let h = parse_header(&block(&["OBJECT = 'X'", "CONTINUE 'orphan'"])).unwrap();
259 assert_eq!(h.get_str("OBJECT").unwrap(), Some("X"));
260 assert!(matches!(h.cards()[1].kind, RecordKind::Opaque { .. }));
261 }
262
263 #[test]
264 fn continue_comment_comes_from_last_card() {
265 let h = parse_header(&block(&["LONG = 'aaa&'", "CONTINUE 'bbb' / tail note"])).unwrap();
266 assert_eq!(h.get_str("LONG").unwrap(), Some("aaabbb"));
267 assert_eq!(h.cards()[0].comment(), Some("tail note"));
268 }
269
270 #[test]
271 fn continue_run_at_end_of_input_keeps_marker() {
272 let h = parse_header(&block(&["LONG = 'aaa&'"])).unwrap();
274 assert_eq!(h.get_str("LONG").unwrap(), Some("aaa&"));
275 }
276
277 #[test]
278 fn trailing_partial_card_is_dropped() {
279 let mut bytes = block(&["OBJECT = 'X'"]);
280 bytes.extend_from_slice(b"GAIN = 1"); let h = parse_header(&bytes).unwrap();
282 assert_eq!(h.get_str("OBJECT").unwrap(), Some("X"));
283 assert_eq!(h.count("GAIN"), 0);
284 }
285
286 #[test]
287 fn non_utf8_bytes_parse_lossily() {
288 let mut cards = block(&["OBJECT = 'X'"]);
289 cards[15] = 0xff; let h = parse_header(&cards).unwrap();
291 assert_eq!(h.cards().len(), 1, "card is retained, lossily decoded");
292 }
293
294 #[test]
295 fn value_needs_equals_space() {
296 let h = parse_header(&block(&["WEIRD =X"])).unwrap();
298 assert!(matches!(h.cards()[0].kind, RecordKind::Opaque { .. }));
299 }
300}