Skip to main content

fits_header/
parse.rs

1//! Parse a FITS header unit from raw bytes.
2
3use crate::error::Result;
4use crate::header::Header;
5use crate::record::{RecordKind, Value};
6use crate::CARD_LEN;
7
8/// Deprecated free-function alias for [`Header::parse`](crate::Header::parse); use that instead.
9#[deprecated(note = "use Header::parse")]
10pub fn parse(bytes: &[u8]) -> Result<Header> {
11    parse_header(bytes)
12}
13
14/// Parse one FITS header unit from raw bytes.
15///
16/// Reads 80-byte cards in order, stops at `END`, and retains every card (including commentary,
17/// `HIERARCH`, and unrecognized cards) so untouched cards serialize verbatim. `CONTINUE` runs are
18/// reassembled into a single logical value.
19pub(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                // String value — may continue across CONTINUE cards.
46                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                    // Confirmed continuation: drop the '&' marker before appending the next piece.
55                    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            // HIERARCH / non-standard / stray CONTINUE.
113            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
127/// Parse a quoted string field: return the unescaped content (trailing `&` continuation marker
128/// removed), any inline comment, and whether the value continues.
129fn 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    // `content` keeps a trailing '&'; the caller drops it only when a CONTINUE card follows.
153    let cont = content.ends_with('&');
154    (content, comment, cont)
155}
156
157/// A comment after a value: text following the first `/`.
158fn 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
164/// Split a literal field into its token and optional comment.
165fn 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        // The '&' stays in the content; the caller drops it only when CONTINUE follows.
208        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        // CONTINUE without a preceding '&'-terminated string is not a value card.
258        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        // '&' with no following CONTINUE card: marker is literal content.
273        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"); // 11 bytes, not a full card
281        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; // inside the value field
290        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        // '=' not followed by a space is not a value indicator.
297        let h = parse_header(&block(&["WEIRD   =X"])).unwrap();
298        assert!(matches!(h.cards()[0].kind, RecordKind::Opaque { .. }));
299    }
300}