Skip to main content

fits_header/
parse.rs

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