Skip to main content

pa_types/
cigar.rs

1/*!
2Cigar string types.
3
4Uses SAM cigar conventions. See <https://timd.one/blog/genomics/cigar.php> (table below).
5
6In particular, an "insertion" is a character in the pattern/query (2nd `Pos` coordinate) that is not in the text/reference (first `Pos` coordinate), and opposite for deletions.
7
8We never output `M` characters, but do parse these into `=` and `X`, which need the corresponding text/pattern string to be resolved.
9
10Undetermined bases (`N`), clipping (`S`, `H`), and padding (`P`) are not supported.
11
12```text
13+----------------+----------------+----------------------------------------------+---------------+---------------+
14| Symbol         | Name           | Brief Description                            | Consumes Query| Consumes Ref  |
15+----------------+----------------+----------------------------------------------+---------------+---------------+
16| M              | Match          | No insertion or deletions, bases may differ  | ✓             | ✓             |
17| I              | Insertion      | Additional base in query (not in reference)  | ✓             | ✗             |
18| D              | Deletion       | Query is missing base from reference         | ✗             | ✓             |
19| =              | Equal          | No insertions or deletions, bases agree      | ✓             | ✓             |
20| X              | Not Equal      | No insertions or deletions, bases differ     | ✓             | ✓             |
21| N              | None           | No query bases to align (spliced read)       | ✗             | ✓             |
22| S              | Soft-Clipped   | Bases on end of read not aligned but stored  | ✓             | ✗             |
23| H              | Hard-Clipped   | Bases on end of read not aligned, not stored | ✗             | ✗             |
24| P              | Padding        | Neither read nor reference has a base        | ✗             | ✗             |
25+----------------+----------------+----------------------------------------------+---------------+---------------+
26```
27
28*/
29
30use itertools::Itertools;
31use serde::{Deserialize, Serialize};
32use std::fmt::Write;
33
34use crate::*;
35
36/// A single cigar string character.
37#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)]
38pub enum CigarOp {
39    /// `=`
40    Match,
41    /// `X`
42    Sub,
43    /// `D`
44    Del,
45    /// `I`
46    Ins,
47}
48
49/// A cigar string character with the corresponding characters from text and pattern.
50#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)]
51pub enum CigarOpChars {
52    /// The text character of the matching pair.
53    Match(u8),
54    /// The mismatching text and pattern character, in this order.
55    Sub(u8, u8),
56    /// The text character that is missing from the pattern.
57    Del(u8),
58    /// The pattern character that is missing from the text.
59    Ins(u8),
60}
61
62/// A single repeated cigar element, e.g. `5=` or `3I`.
63#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)]
64pub struct CigarElem {
65    pub op: CigarOp,
66    pub cnt: I,
67}
68
69impl CigarElem {
70    pub fn new(op: CigarOp, cnt: I) -> Self {
71        Self { op, cnt }
72    }
73}
74
75/// Main type representing a Cigar string.
76///
77/// Adjacent equal operations are typically merged into [`CigarElem`] with `cnt>1`.
78// This is similar to https://docs.rs/bio/1.0.0/bio/alignment/struct.Alignment.html,
79// but more specific for our use case.
80#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)]
81pub struct Cigar {
82    pub ops: Vec<CigarElem>,
83}
84
85impl CigarOp {
86    /// Convert to one of `=XID`.
87    pub fn to_char(&self) -> char {
88        match self {
89            CigarOp::Match => '=',
90            CigarOp::Sub => 'X',
91            CigarOp::Ins => 'I',
92            CigarOp::Del => 'D',
93        }
94    }
95
96    /// Convert operation to `(text_pos, pattern_pos)` delta/step of path indices.
97    #[inline(always)]
98    pub fn delta(&self) -> Pos {
99        match self {
100            CigarOp::Match | CigarOp::Sub => Pos(1, 1),
101            CigarOp::Del => Pos(1, 0), // deletion: consumes ref/text only
102            CigarOp::Ins => Pos(0, 1), // insertion: consumes pattern/query only
103        }
104    }
105
106    /// The edit-distance cost of the operation.
107    ///
108    /// 0 for `Match`, 1 otherwise.
109    #[inline(always)]
110    pub fn edit_cost(&self) -> Cost {
111        match self {
112            CigarOp::Match => 0,
113            _ => 1,
114        }
115    }
116
117    /// Converts path delta `(text_pos, pattern_pos)` to `CigarOp`.
118    ///
119    /// Ignores [`CigarOp::Sub`]: (1,1) is always [`CigarOp::Match`].
120    #[inline(always)]
121    pub fn from_delta(delta: Pos) -> Self {
122        match delta {
123            Pos(0, 1) => CigarOp::Ins,
124            Pos(1, 0) => CigarOp::Del,
125            Pos(1, 1) => CigarOp::Match,
126            _ => panic!("Invalid delta: {:?}", delta),
127        }
128    }
129}
130
131// Mostly to make verify/resolve matches clearer while still using the delta mapping
132impl std::ops::Mul<I> for Pos {
133    type Output = Pos;
134    fn mul(self, rhs: I) -> Pos {
135        Pos(self.0 * rhs, self.1 * rhs)
136    }
137}
138
139impl From<u8> for CigarOp {
140    /// Convert from `=MXID` to `CigarOp`.
141    ///
142    /// `M` is interpreted as `=` ([`CigarOp::Match`]) and should be resolved into `=` or `X` ([`CigarOp::Sub`]) via `resolve_matches`.
143    fn from(op: u8) -> Self {
144        match op {
145            b'=' | b'M' => CigarOp::Match,
146            b'X' => CigarOp::Sub,
147            b'I' => CigarOp::Ins,
148            b'D' => CigarOp::Del,
149            _ => panic!("Invalid CigarOp"),
150        }
151    }
152}
153
154impl ToString for Cigar {
155    /// Format the cigar to a `String`.
156    fn to_string(&self) -> String {
157        let mut s = String::new();
158        for elem in &self.ops {
159            write!(&mut s, "{}{}", elem.cnt, elem.op.to_char()).unwrap();
160        }
161        s
162    }
163}
164
165impl Cigar {
166    /// Concatenate the single-characters ops.
167    pub fn from_ops(ops: impl Iterator<Item = CigarOp>) -> Self {
168        Cigar {
169            ops: ops
170                .chunk_by(|&op| op)
171                .into_iter()
172                .map(|(op, group)| CigarElem::new(op, group.count() as _))
173                .collect(),
174        }
175    }
176
177    /// Create Cigar from path and corresponding sequences.
178    ///
179    /// Path must have `(text_pos, pattern_pos)` pairs.
180    /// To distinguish between match and sub it uses simple
181    /// equality (i.e. c1==c2) via `resolve_matches`, so IUPAC matching is not supported.
182    pub fn from_path(text: Seq, pattern: Seq, path: &Path) -> Cigar {
183        if path[0] != Pos(0, 0) {
184            panic!("Path must start at (0,0)!");
185        }
186        Self::resolve_matches(
187            path.iter()
188                .tuple_windows()
189                .map(|(&text_pos, &pattern_pos)| {
190                    CigarElem::new(CigarOp::from_delta(pattern_pos - text_pos), 1)
191                }),
192            text,
193            pattern,
194        )
195    }
196
197    /// Return the diff from pattern to text.
198    pub fn to_char_pairs<'s>(&'s self, text: &'s [u8], pattern: &'s [u8]) -> Vec<CigarOpChars> {
199        let mut pos = Pos(0, 0);
200        // let fix_case = !(b'A' ^ b'a');
201        let mut out = vec![];
202        for el in &self.ops {
203            for _ in 0..el.cnt {
204                let c = match el.op {
205                    CigarOp::Match => {
206                        // NOTE: IUPAC characters can be matching even when they're not equal.
207                        // assert_eq!(
208                        //     (pattern[pos.0 as usize] & fix_case) as char,
209                        //     (text[pos.1 as usize] & fix_case) as char,
210                        //     "mismatch for {pos:?}"
211                        // );
212                        CigarOpChars::Match(text[pos.0 as usize])
213                    }
214                    CigarOp::Sub => {
215                        // NOTE: ASCII characters can be mismatching when only differing in case.
216                        assert_ne!(
217                            text[pos.0 as usize] as char,
218                            pattern[pos.1 as usize] as char,
219                            "cigar {:?}\npattern {:?}\ntext    {:?}\nmismatch for {pos:?}",
220                            self.to_string(),
221                            String::from_utf8_lossy(pattern),
222                            String::from_utf8_lossy(text)
223                        );
224                        CigarOpChars::Sub(text[pos.0 as usize], pattern[pos.1 as usize])
225                    }
226                    CigarOp::Del => {
227                        // Note deletion consumes text hence text slice
228                        CigarOpChars::Del(text[pos.0 as usize])
229                    }
230                    CigarOp::Ins => {
231                        // Note insertion consumes pattern hence pattern slice
232                        CigarOpChars::Ins(pattern[pos.1 as usize])
233                    }
234                };
235                out.push(c);
236                pos += el.op.delta();
237            }
238        }
239        out
240    }
241
242    /// Get the `Path` corresponding to this [`Cigar`].
243    pub fn to_path(&self) -> Path {
244        let mut pos = Pos(0, 0);
245        let mut path = vec![pos];
246        for el in &self.ops {
247            for _ in 0..el.cnt {
248                pos += el.op.delta();
249                path.push(pos);
250            }
251        }
252        path
253    }
254
255    /// Get the `Path` and the alignment `Cost` to each position.
256    pub fn to_path_with_costs(&self, cm: CostModel) -> Vec<(Pos, Cost)> {
257        let mut pos = Pos(0, 0);
258        let mut cost = 0;
259        let mut path = vec![(pos, cost)];
260
261        for el in &self.ops {
262            match el.op {
263                CigarOp::Match => {
264                    for _ in 0..el.cnt {
265                        pos += el.op.delta();
266                        path.push((pos, cost));
267                    }
268                }
269                CigarOp::Sub => {
270                    for _ in 0..el.cnt {
271                        pos += el.op.delta();
272                        cost += cm.sub;
273                        path.push((pos, cost));
274                    }
275                }
276                CigarOp::Ins => {
277                    for len in 1..=(el.cnt as Cost) {
278                        pos += el.op.delta();
279                        path.push((pos, cost + cm.ins(len)));
280                    }
281                    cost += cm.ins(el.cnt);
282                }
283                CigarOp::Del => {
284                    for len in 1..=(el.cnt as Cost) {
285                        pos += el.op.delta();
286                        path.push((pos, cost + cm.del(len)));
287                    }
288                    cost += cm.del(el.cnt);
289                }
290            }
291        }
292        path
293    }
294
295    /// Push a [`CigarOp`] to the cigar.
296    pub fn push(&mut self, op: CigarOp) {
297        if let Some(s) = self.ops.last_mut() {
298            if s.op == op {
299                s.cnt += 1;
300                return;
301            }
302        }
303        self.ops.push(CigarElem { op, cnt: 1 });
304    }
305
306    /// Pop a single [`CigarOp`] from the cigar.
307    pub fn pop_op(&mut self) -> Option<CigarOp> {
308        while let Some(elem) = self.ops.last_mut() {
309            let op = elem.op;
310            assert!(elem.cnt > 0);
311            elem.cnt -= 1;
312            if elem.cnt == 0 {
313                self.ops.pop();
314            }
315            return Some(op);
316        }
317        None
318    }
319
320    /// Push a [`CigarElem`] to the cigar.
321    pub fn push_elem(&mut self, e: CigarElem) {
322        if let Some(s) = self.ops.last_mut() {
323            if s.op == e.op {
324                s.cnt += e.cnt;
325                return;
326            }
327        }
328        self.ops.push(e);
329    }
330
331    /// Push `cnt` matches.
332    pub fn push_matches(&mut self, cnt: I) {
333        if let Some(s) = self.ops.last_mut() {
334            if s.op == CigarOp::Match {
335                s.cnt += cnt;
336                return;
337            }
338        }
339        self.ops.push(CigarElem {
340            op: CigarOp::Match,
341            cnt: cnt as _,
342        });
343    }
344
345    /// Check that the cigar is valid between `text` and `pattern` and return the cost.
346    pub fn verify(&self, cm: &CostModel, text: Seq, pattern: Seq) -> Result<Cost, &str> {
347        let mut pos = Pos(0, 0);
348        let mut cost: Cost = 0;
349
350        for &CigarElem { op, cnt } in &self.ops {
351            match op {
352                CigarOp::Match => {
353                    for _ in 0..cnt {
354                        if text.get(pos.0 as usize) != pattern.get(pos.1 as usize) {
355                            return Err("Expected match but found substitution.");
356                        }
357                        pos += op.delta();
358                    }
359                }
360                CigarOp::Sub => {
361                    for _ in 0..cnt {
362                        if text.get(pos.0 as usize) == pattern.get(pos.1 as usize) {
363                            return Err("Expected substitution but found match.");
364                        }
365                        pos += op.delta();
366                        cost += cm.sub;
367                    }
368                }
369                CigarOp::Ins => {
370                    cost += cm.open + cnt as Cost * cm.extend;
371                    pos += op.delta() * cnt;
372                }
373                CigarOp::Del => {
374                    cost += cm.open + cnt as Cost * cm.extend;
375                    pos += op.delta() * cnt;
376                }
377            }
378        }
379        if pos != Pos(text.len() as I, pattern.len() as I) {
380            return Err("Wrong alignment length.");
381        }
382
383        Ok(cost)
384    }
385
386    /// Splits all 'M'/[`CigarOp::Match`] into matches (`=`) and substitutions (`X`), and joins consecutive equal elements.
387    pub fn resolve_matches(ops: impl Iterator<Item = CigarElem>, text: Seq, pattern: Seq) -> Self {
388        let mut pos = Pos(0, 0);
389        let mut c = Cigar { ops: vec![] };
390        for CigarElem { op, cnt } in ops {
391            match op {
392                CigarOp::Match => {
393                    for _ in 0..cnt {
394                        c.push(if text[pos.0 as usize] == pattern[pos.1 as usize] {
395                            CigarOp::Match
396                        } else {
397                            CigarOp::Sub
398                        });
399                        pos += op.delta();
400                    }
401                    continue;
402                }
403                _ => {
404                    pos += op.delta() * cnt;
405                }
406            };
407            c.push_elem(CigarElem { op, cnt });
408        }
409        c
410    }
411
412    /// A simpler parsing function that only parses strings of characters `M=XID`, without preceding counts.
413    /// Consecutive characters are grouped, and `M` and `=` chars are resolved into `=` and `X`.
414    pub fn parse_without_counts(s: &str, text: Seq, pattern: Seq) -> Self {
415        Self::resolve_matches(
416            s.as_bytes().iter().map(|&op| CigarElem {
417                op: op.into(),
418                cnt: 1,
419            }),
420            text,
421            pattern,
422        )
423    }
424
425    /// A simpler parsing function that only parses strings of characters `MXID`, without preceding counts.
426    /// Consecutive characters are grouped. `M` chars are *not* resolved and assumed to mean `=`.
427    pub fn parse_without_resolving(s: &str) -> Self {
428        let mut c = Cigar { ops: vec![] };
429        for &op in s.as_bytes() {
430            c.push(op.into())
431        }
432        c
433    }
434
435    /// Parse a Cigar string with optional counts
436    pub fn from_string(s: &str) -> Self {
437        let mut c = Cigar { ops: vec![] };
438        for slice in s.as_bytes().split_inclusive(|b| !b.is_ascii_digit()) {
439            let (&op, cnt_bytes) = slice.split_last().expect("Cigar string cannot be empty");
440            let cnt = if cnt_bytes.is_empty() {
441                1
442            } else {
443                unsafe { std::str::from_utf8_unchecked(cnt_bytes) }
444                    .parse()
445                    .expect("Invalid Cigar count")
446            };
447            c.push_elem(CigarElem { op: op.into(), cnt });
448        }
449        c
450    }
451
452    /// A more generic (and slower) parsing function that also allows optional counts, e.g. `5M2X3M`.
453    /// Consecutive characters are grouped, and `M` and `=` chars are resolved into `=` and `X`.
454    pub fn parse(s: &str, text: Seq, pattern: Seq) -> Self {
455        Self::resolve_matches(
456            s.as_bytes()
457                .split_inclusive(|pattern| pattern.is_ascii_alphabetic())
458                .map(|pattern_slice| {
459                    let (&op, cnt) = pattern_slice.split_last().unwrap();
460                    let cnt = if cnt.is_empty() {
461                        1
462                    } else {
463                        unsafe { std::str::from_utf8_unchecked(cnt) }
464                            .parse()
465                            .unwrap()
466                    };
467                    CigarElem { op: op.into(), cnt }
468                }),
469            text,
470            pattern,
471        )
472    }
473
474    /// Clear the internal vector.
475    pub fn clear(&mut self) {
476        self.ops.clear();
477    }
478
479    /// Reverse the cigar string, for `rc(text)` and `rc(pattern)`.
480    pub fn reverse(&mut self) {
481        self.ops.reverse();
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_delta() {
491        for op in [CigarOp::Match, CigarOp::Del, CigarOp::Ins] {
492            assert_eq!(CigarOp::from_delta(op.delta()), op);
493        }
494        assert_eq!(CigarOp::from_delta(Pos(1, 1)), CigarOp::Match);
495        //  assert_eq!(CigarOp::from_delta(Pos(1, 1)), CigarOp::Sub);
496        assert_eq!(CigarOp::from_delta(Pos(1, 0)), CigarOp::Del); // Consume text
497        assert_eq!(CigarOp::from_delta(Pos(0, 1)), CigarOp::Ins); // Consume pattern
498    }
499
500    #[test]
501    fn test_valid_eq() {
502        let c = Cigar::from_path(b"ab", b"aa", &vec![Pos(0, 0), Pos(1, 1), Pos(2, 2)]);
503        assert_eq!(c.to_string(), "1=1X");
504    }
505
506    #[test]
507    fn test_invalid_end_length() {
508        // (0,1)is invalid, text is longer than path
509        let c = Cigar::from_path(b"ab", b"aa", &vec![Pos(0, 0), Pos(0, 1)]);
510        assert!(c.verify(&CostModel::unit(), b"ab", b"aa").is_err());
511    }
512
513    #[test]
514    fn to_string() {
515        let c = Cigar {
516            ops: vec![
517                CigarElem {
518                    op: CigarOp::Ins,
519                    cnt: 1,
520                },
521                CigarElem {
522                    op: CigarOp::Match,
523                    cnt: 2,
524                },
525            ],
526        };
527        assert_eq!(c.to_string(), "1I2=");
528    }
529
530    #[test]
531    fn from_path() {
532        let c = Cigar::from_path(
533            b"aaa",
534            b"aabc",
535            &vec![Pos(0, 0), Pos(1, 1), Pos(2, 2), Pos(3, 3), Pos(3, 4)],
536        );
537        assert_eq!(c.to_string(), "2=1X1I");
538    }
539
540    #[test]
541    fn from_string_with_count() {
542        let c = Cigar::from_string("24=");
543        assert_eq!(c.ops.len(), 1);
544        assert_eq!(c.ops[0], CigarElem::new(CigarOp::Match, 24));
545    }
546
547    #[test]
548    fn from_string_mixed_ops() {
549        let c = Cigar::from_string("2=3I1X");
550        assert_eq!(
551            c.ops,
552            vec![
553                CigarElem::new(CigarOp::Match, 2),
554                CigarElem::new(CigarOp::Ins, 3),
555                CigarElem::new(CigarOp::Sub, 1)
556            ]
557        );
558    }
559
560    #[test]
561    fn from_string_no_counts() {
562        let c = Cigar::from_string("=XIDDD");
563        assert_eq!(
564            c.ops,
565            vec![
566                CigarElem::new(CigarOp::Match, 1),
567                CigarElem::new(CigarOp::Sub, 1),
568                CigarElem::new(CigarOp::Ins, 1),
569                CigarElem::new(CigarOp::Del, 3),
570            ]
571        );
572    }
573
574    #[test]
575    #[rustfmt::skip]
576    fn push_to_path() {
577        let mut c = Cigar::default();
578                                // 0 0
579        c.push(CigarOp::Match); // 1  1
580        c.push(CigarOp::Del);   // 2  1  (text +1)
581        c.push(CigarOp::Ins);   // 2  2  (pattern +1)
582        c.push(CigarOp::Sub);   // 3  3
583
584        assert_eq!(
585            c.to_path(),
586            [
587                Pos(0, 0),
588                Pos(1, 1),
589                Pos(2, 1),
590                Pos(2, 2),
591                Pos(3, 3),
592            ]
593        );
594    }
595
596    #[test]
597    fn to_char_pairs_all_match() {
598        let c = Cigar::from_string("3=");
599        let pairs = c.to_char_pairs(b"aaa", b"aaa");
600        assert_eq!(
601            pairs,
602            vec![
603                CigarOpChars::Match(b'a'),
604                CigarOpChars::Match(b'a'),
605                CigarOpChars::Match(b'a'),
606            ]
607        );
608    }
609
610    #[test]
611    fn to_char_pairs_sub() {
612        let c = Cigar::from_string("1X");
613        let pairs = c.to_char_pairs(b"a", b"c");
614        assert_eq!(pairs, vec![CigarOpChars::Sub(b'a', b'c')]);
615    }
616
617    #[test]
618    fn to_char_pairs_ins() {
619        let c = Cigar::from_string("1=1I1=");
620        let pairs = c.to_char_pairs(b"ac", b"abc");
621        assert_eq!(
622            pairs,
623            vec![
624                CigarOpChars::Match(b'a'),
625                CigarOpChars::Ins(b'b'),
626                CigarOpChars::Match(b'c'),
627            ]
628        );
629    }
630
631    #[test]
632    fn to_char_pairs_del() {
633        let c = Cigar::from_string("1=1D1=");
634        let pairs = c.to_char_pairs(b"abc", b"ac");
635        assert_eq!(
636            pairs,
637            vec![
638                CigarOpChars::Match(b'a'),
639                CigarOpChars::Del(b'b'),
640                CigarOpChars::Match(b'c'),
641            ]
642        );
643    }
644
645    #[test]
646    fn to_char_pairs_mixed() {
647        let c = Cigar::from_string("2=1X1I");
648        let pairs = c.to_char_pairs(b"abZd", b"abYc");
649        assert_eq!(
650            pairs,
651            vec![
652                CigarOpChars::Match(b'a'),
653                CigarOpChars::Match(b'b'),
654                CigarOpChars::Sub(b'Z', b'Y'),
655                CigarOpChars::Ins(b'c'),
656            ]
657        );
658    }
659}