JAL-1499 patch from Mungo Carstairs
[jalview.git] / src / jalview / io / MegaFile.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer (Version 2.8.0b1)
3  * Copyright (C) 2014 The Jalview Authors
4  * 
5  * This file is part of Jalview.
6  * 
7  * Jalview is free software: you can redistribute it and/or
8  * modify it under the terms of the GNU General Public License 
9  * as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
10  *  
11  * Jalview is distributed in the hope that it will be useful, but 
12  * WITHOUT ANY WARRANTY; without even the implied warranty 
13  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 
14  * PURPOSE.  See the GNU General Public License for more details.
15  * 
16  * You should have received a copy of the GNU General Public License along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
17  * The Jalview Authors are detailed in the 'AUTHORS' file.
18  */
19 package jalview.io;
20
21 import jalview.datamodel.Sequence;
22 import jalview.datamodel.SequenceI;
23
24 import java.io.IOException;
25 import java.util.LinkedHashMap;
26 import java.util.Map;
27 import java.util.Map.Entry;
28 import java.util.Set;
29
30 public class MegaFile extends AlignFile
31 {
32   /*
33    * Simple file format as at
34    * http://www.hiv.lanl.gov/content/sequence/HelpDocs/SEQsamples.html
35    * 
36    * Fancy file format as at
37    * http://primerdigital.com/fastpcr/images/Drosophila_Adh.txt
38    */
39   public enum FileFormat
40   {
41     SIMPLE, FANCY
42   }
43
44   private static final String HASHSIGN = "#"; // TODO: public constants file
45
46   private static final String COLON = ":";
47
48   private static final String BANG = "!";
49
50   private static final String EQUALS = "=";
51
52   private static final String MEGA_ID = HASHSIGN + "MEGA";
53
54   public static final String PROP_TITLE = "TITLE";
55
56   public static final String PROP_FORMAT = "Format";
57
58   public static final String PROP_DESCRIPTION = "Description";
59
60   public static final String PROP_GENE = "Gene";
61
62   public static final String PROP_INTERLEAVED = "Interleaved";
63
64   // initial size for sequence data buffer
65   private static final int SEQBUFFERSIZE = 256;
66
67   private static final String SPACE = " ";
68
69   private static final int POSITIONS_PER_LINE = 50;
70
71   // this can be True, False or null (meaning we don't know yet)
72   private Boolean interleaved;
73
74   // set once we have seen one block of interleaved data
75   private boolean firstDataBlockRead = false;
76
77   private FileFormat fileFormat;
78
79   public MegaFile()
80   {
81   }
82
83   public MegaFile(String inFile, String type) throws IOException
84   {
85     super(inFile, type);
86   }
87
88   public MegaFile(FileParse source) throws IOException
89   {
90     super(source);
91   }
92
93   /**
94    * Parse the input stream.
95    */
96   @Override
97   public void parse() throws IOException
98   {
99     /*
100      * Read MEGA and Title/Format/Description/Gene headers if present. These are
101      * saved as alignment properties. Returns the first sequence data line
102      */
103     String dataLine = parseHeaderLines();
104
105     /*
106      * If we didn't positively identify as 'fancy format', assume 'simple
107      * format'
108      */
109     if (this.fileFormat == null)
110     {
111       setFileFormat(FileFormat.SIMPLE);
112     }
113
114     /*
115      * Temporary store of {sequenceId, positionData} while parsing appending
116      */
117     Map<String, StringBuilder> seqData = new LinkedHashMap<String, StringBuilder>();
118
119     /*
120      * The id of the sequence being read (for non-interleaved)
121      */
122     String currentId = "";
123
124     while (dataLine != null)
125     {
126       dataLine = dataLine.trim();
127       if (dataLine.length() > 0)
128       {
129         currentId = parseDataLine(dataLine, seqData, currentId);
130       }
131       else if (!seqData.isEmpty())
132       {
133         /*
134          * Blank line after processing some data...
135          */
136         this.firstDataBlockRead = true;
137       }
138       dataLine = nextLine();
139     }
140
141     setSequences(seqData);
142   }
143
144   /**
145    * Convert the parsed sequence strings to objects and store them in the model.
146    * 
147    * @param seqData
148    */
149   protected void setSequences(Map<String, StringBuilder> seqData)
150   {
151     Set<Entry<String, StringBuilder>> datasets = seqData.entrySet();
152
153     for (Entry<String, StringBuilder> dataset : datasets)
154     {
155       String sequenceId = dataset.getKey();
156       StringBuilder characters = dataset.getValue();
157       SequenceI s = new Sequence(sequenceId, new String(characters));
158       this.seqs.addElement(s);
159     }
160   }
161
162   /**
163    * Process one line of sequence data. If it has no sequence identifier, append
164    * to the current id's sequence. Else parse out the sequence id and append the
165    * data (if any) to that id's sequence. Returns the sequence id (implicit or
166    * explicit) for this line.
167    * 
168    * @param dataLine
169    * @param seqData
170    * @param currentid
171    * @return
172    * @throws IOException
173    */
174   protected String parseDataLine(String dataLine,
175           Map<String, StringBuilder> seqData, String currentId)
176           throws IOException
177   {
178     String seqId = getSequenceId(dataLine);
179     if (seqId == null)
180     {
181       /*
182        * Just character data
183        */
184       parseNoninterleavedDataLine(dataLine, seqData, currentId);
185       return currentId;
186     }
187     else if ((HASHSIGN + seqId).trim().equals(dataLine.trim()))
188     {
189       /*
190        * Sequence id only - header line for noninterleaved data
191        */
192       return seqId;
193     }
194     else
195     {
196       /*
197        * Sequence id followed by data
198        */
199       parseInterleavedDataLine(dataLine, seqData, seqId);
200       return seqId;
201     }
202   }
203
204   /**
205    * Add a line of sequence data to the buffer for the given sequence id. Start
206    * a new one if we haven't seen it before.
207    * 
208    * @param dataLine
209    * @param seqData
210    * @param currentId
211    * @throws IOException
212    */
213   protected void parseNoninterleavedDataLine(String dataLine,
214           Map<String, StringBuilder> seqData, String currentId)
215           throws IOException
216   {
217     if (currentId == null)
218     {
219       /*
220        * Oops. Data but no sequence id context.
221        */
222       throw new IOException("No sequence id context at: " + dataLine);
223     }
224
225     assertInterleaved(false, dataLine);
226
227     StringBuilder sb = getSequenceDataBuffer(seqData, currentId);
228
229     /*
230      * Add the current line of data to the sequence.
231      */
232     sb.append(dataLine);
233   }
234
235   /**
236    * Get the sequence data for this sequence id, starting a new one if
237    * necessary.
238    * 
239    * @param seqData
240    * @param currentId
241    * @return
242    */
243   protected StringBuilder getSequenceDataBuffer(
244           Map<String, StringBuilder> seqData, String currentId)
245   {
246     StringBuilder sb = seqData.get(currentId);
247     if (sb == null)
248     {
249       // first data met for this sequence id, start a new buffer
250       sb = new StringBuilder(SEQBUFFERSIZE);
251       seqData.put(currentId, sb);
252     }
253     return sb;
254   }
255
256   /**
257    * Parse one line of interleaved data e.g.
258    * 
259    * <pre>
260    * #TheSeqId CGATCGCATGCA
261    * </pre>
262    * 
263    * @param dataLine
264    * @param seqData
265    * @param seqId
266    * @throws IOException
267    */
268   protected void parseInterleavedDataLine(String dataLine,
269           Map<String, StringBuilder> seqData, String seqId)
270           throws IOException
271   {
272     /*
273      * New sequence found in second or later data block - error.
274      */
275     if (this.firstDataBlockRead && !seqData.containsKey(seqId))
276     {
277       throw new IOException(
278               "Parse error: misplaced new sequence starting at " + dataLine);
279     }
280
281     StringBuilder sb = getSequenceDataBuffer(seqData, seqId);
282     String data = dataLine.substring(seqId.length() + 1).trim();
283
284     /*
285      * Do nothing if this line is _only_ a sequence id with no data following.
286      * 
287      * Remove any internal spaces (present in the 'fancy' file format)
288      */
289     if (data != null && data.length() > 0)
290     {
291       if (data.indexOf(SPACE) != -1)
292       {
293         data = data.replace(SPACE, "");
294       }
295       sb.append(data);
296       assertInterleaved(true, dataLine);
297     }
298   }
299
300   /**
301    * If the line begins with (e.g.) "#abcde " then returns "abcde" as the
302    * identifier. Else returns null.
303    * 
304    * @param dataLine
305    * @return
306    */
307   public static String getSequenceId(String dataLine)
308   {
309     // TODO refactor to a StringUtils type class
310     if (dataLine != null)
311     {
312       if (dataLine.startsWith(HASHSIGN))
313       {
314         int spacePos = dataLine.indexOf(" ");
315         return (spacePos == -1 ? dataLine.substring(1) : dataLine
316                 .substring(1, spacePos));
317       }
318     }
319     return null;
320   }
321
322   /**
323    * Read the #MEGA and Title/Format/Description/Gene header lines (if present).
324    * 
325    * Save as annotation properties in case useful.
326    * 
327    * @return the next non-blank line following the header lines.
328    * @throws IOException
329    */
330   protected String parseHeaderLines() throws IOException
331   {
332     String inputLine = null;
333     while ((inputLine = nextLine()) != null)
334     {
335       inputLine = inputLine.trim();
336
337       /*
338        * skip blank lines
339        */
340       if (inputLine.length() == 0)
341       {
342         continue;
343       }
344
345       if (inputLine.startsWith(BANG))
346       {
347         setFileFormat(FileFormat.FANCY);
348       }
349
350       if (inputLine.startsWith(BANG + PROP_DESCRIPTION))
351       {
352         parseDescriptionLines();
353       }
354
355       else if (isPropertyLine(inputLine))
356       {
357         /*
358          * If a property is matched, parse and save it.
359          */
360         String[] property_value = parsePropertyValue(inputLine);
361         setAlignmentProperty(property_value[0], property_value[1]);
362       }
363       else if (!inputLine.toUpperCase().startsWith(MEGA_ID))
364       {
365
366         /*
367          * Return the first 'data line' i.e. one that is not blank, #MEGA or
368          * TITLE:
369          */
370         break;
371       }
372     }
373     return inputLine;
374   }
375
376   /**
377    * Read following lines until blank, appending each to the Description
378    * property value.
379    * 
380    * Assumes the !Description line itself does not include description text.
381    * 
382    * Assumes the description is followed by a blank line (else we will consume
383    * one too many).
384    * 
385    * @throws IOException
386    */
387   protected void parseDescriptionLines() throws IOException
388   {
389     StringBuilder desc = new StringBuilder(256);
390     String line = null;
391     while ((line = nextLine()) != null) {
392       if ("".equals(line.trim()))
393       {
394         break;
395       }
396       desc.append(line).append(newline);
397     }
398     setAlignmentProperty(PROP_DESCRIPTION, desc.toString());
399   }
400
401   /**
402    * Test whether the line holds an expected property declaration.
403    * 
404    * @param inputLine
405    * @return
406    */
407   protected boolean isPropertyLine(String inputLine)
408   {
409     if (lineMatchesFlag(inputLine, PROP_TITLE, BANG, COLON)
410             || lineMatchesFlag(inputLine, PROP_FORMAT, BANG, COLON)
411             || lineMatchesFlag(inputLine, PROP_DESCRIPTION, BANG, COLON)
412             || lineMatchesFlag(inputLine, PROP_GENE, BANG, COLON))
413     {
414       return true;
415     }
416     return false;
417   }
418
419   /**
420    * Helper method that extract the name and value of a property, assuming the
421    * first space or equals sign is the separator.
422    * 
423    * Thus "Description: Melanogaster" or "!Description=Melanogaster" both return
424    * {"Description", "Melanogaster"}.
425    * 
426    * Returns an empty value string if no space or equals sign is present.
427    * 
428    * @param s
429    * @return
430    */
431   public static String[] parsePropertyValue(String s)
432   {
433     // TODO refactor to a string utils helper class (or find equivalent)
434     // TODO handle other cases e.g. "Description = Melanogaster"
435     String propertyName = s;
436     String value = "";
437
438     int separatorPos = -1;
439
440     if (s != null)
441     {
442       int spacePos = s.indexOf(SPACE);
443       int eqPos = s.indexOf(EQUALS);
444       if (spacePos == -1 && eqPos > -1)
445       {
446         separatorPos = eqPos;
447       }
448       else if (spacePos > -1 && eqPos == -1)
449       {
450         separatorPos = spacePos;
451       }
452       else if (spacePos > -1 && eqPos > -1)
453       {
454         separatorPos = Math.min(spacePos, eqPos);
455       }
456     }
457     if (separatorPos > -1)
458     {
459       value = s.substring(separatorPos + 1);
460       propertyName = s.substring(0, separatorPos);
461     }
462
463     /*
464      * finally strip any leading / trailing chars from property name
465      */
466     if (propertyName.startsWith(BANG))
467     {
468       propertyName = propertyName.substring(1);
469     }
470     if (propertyName.endsWith(COLON))
471     {
472       propertyName = propertyName.substring(0, propertyName.length() - 1);
473     }
474
475     return new String[]
476     { propertyName, value };
477   }
478
479   /**
480    * Test whether a line starts with the specified flag field followed by a
481    * space (or nothing).
482    * 
483    * Here we accept an optional prefix and suffix on the flag, and the check is
484    * not case-sensitive. So these would match for "Title"
485    * 
486    * <pre>
487    * Title Melanogaster
488    * Title=Melanogaster
489    * TITLE Melanogaster
490    * TITLE=Melanogaster
491    * !Title Melanogaster
492    * !Title=Melanogaster
493    * !TITLE Melanogaster
494    * !TITLE=Melanogaster
495    * Title: Melanogaster
496    * Title:=Melanogaster
497    * TITLE: Melanogaster
498    * TITLE:=Melanogaster
499    * !Title: Melanogaster
500    * !Title:=Melanogaster
501    * !TITLE: Melanogaster
502    * !TITLE:=Melanogaster
503    * Title
504    * TITLE
505    * !Title
506    * !TITLE
507    * </pre>
508    * 
509    * @param line
510    * @param flag
511    * @param prefix
512    * @param suffix
513    * @return
514    */
515   public static boolean lineMatchesFlag(String line, String flag, String prefix, String suffix)
516   {
517     // TODO refactor to a string utils helper class
518     boolean result = false;
519     if (line != null && flag != null) {
520       String lineUpper = line.toUpperCase().trim();
521       String flagUpper = flag.toUpperCase();
522       
523       // skip prefix character e.g. ! before attempting match
524       if (lineUpper.startsWith(prefix)) {
525         lineUpper = lineUpper.substring(1);
526       }
527       
528       // test for flag + SPACE or flag + EQUALS, with or without suffix
529       if (lineUpper.startsWith(flagUpper + SPACE)
530               || lineUpper.startsWith(flagUpper + EQUALS)
531               || lineUpper.startsWith(flagUpper + suffix + SPACE)
532               || lineUpper.startsWith(flagUpper + suffix + EQUALS))
533       {
534         result = true;
535       }
536       else
537       {
538         // test for exact match i.e. flag only on this line
539         if (lineUpper.equals(flagUpper)
540                 || lineUpper.startsWith(flagUpper + suffix))
541         {
542           result = true;
543         }
544       }
545     }
546     return result;
547   }
548
549   /**
550    * Write out the alignment sequences in Mega format.
551    */
552   @Override
553   public String print()
554   {
555     return print(getSeqsAsArray());
556   }
557
558   /**
559    * Write out the alignment sequences in Mega format - interleaved unless
560    * explicitly noninterleaved.
561    */
562   public String print(SequenceI[] s)
563   {
564     // TODO: is there a way to preserve the 'interleaved' property so it can
565     // affect output?
566
567     String result = null;
568     if (this.fileFormat == FileFormat.FANCY)
569     {
570       result = printInterleavedCodons(s);
571     }
572     else if (this.interleaved != null && !this.interleaved)
573     {
574       result = printNonInterleaved(s);
575     }
576     else
577     {
578       result = printInterleaved(s);
579     }
580     return result;
581   }
582
583   /**
584    * Print the sequences in interleaved format, each row 15 space-separated
585    * triplets.
586    * 
587    * @param s
588    * @return
589    */
590   protected String printInterleavedCodons(SequenceI[] s)
591   {
592     // TODO not coded yet - defaulting to the 'simple' format output
593     return printInterleaved(s);
594   }
595
596   /**
597    * Print to string in Interleaved format - blocks of next 50 characters of
598    * each sequence in turn.
599    * 
600    * @param s
601    */
602   protected String printInterleaved(SequenceI[] s)
603   {
604     int maxIdLength = getMaxIdLength(s);
605     int maxSequenceLength = getMaxSequenceLength(s);
606     int numLines = maxSequenceLength / POSITIONS_PER_LINE + 3; // approx
607
608     /*
609      * Size a buffer to hold the whole output
610      */
611     StringBuilder sb = new StringBuilder(numLines
612             * (maxIdLength + 2 + POSITIONS_PER_LINE));
613     printHeaders(sb, FileFormat.SIMPLE);
614
615     int numDataBlocks = (maxSequenceLength - 1) / POSITIONS_PER_LINE + 1;
616     for (int i = 0; i < numDataBlocks; i++)
617     {
618       sb.append(newline);
619       for (SequenceI seq : s)
620       {
621
622         String seqId = String.format("#%-" + maxIdLength + "s ",
623                 seq.getName());
624         char[] subSequence = seq.getSequence(i * POSITIONS_PER_LINE,
625                 (i + 1) * POSITIONS_PER_LINE);
626         sb.append(seqId);
627         sb.append(subSequence);
628         sb.append(newline);
629       }
630     }
631
632     return new String(sb);
633   }
634
635   /**
636    * Append the MEGA header and any other known properties
637    * 
638    * @param sb
639    */
640   private void printHeaders(StringBuilder sb, FileFormat format)
641   {
642     sb.append(MEGA_ID);
643     sb.append(newline);
644     /*
645      * 
646      */
647     Set<Entry<Object, Object>> props = getAlignmentProperties();
648     if (props != null)
649     {
650       for (Entry<Object, Object> prop : props)
651       {
652         Object key = prop.getKey();
653         Object value = prop.getValue();
654         if (key instanceof String && value instanceof String)
655         {
656           if (format == FileFormat.FANCY)
657           {
658             sb.append(BANG).append(key).append(SPACE).append(value);
659           }
660           else
661           {
662             sb.append(key).append(COLON).append(SPACE).append(value);
663           }
664           sb.append(newline);
665         }
666       }
667     }
668   }
669
670   /**
671    * Get the longest sequence id (to allow aligned printout).
672    * 
673    * @param s
674    * @return
675    */
676   protected static int getMaxIdLength(SequenceI[] s)
677   {
678     // TODO pull up for reuse
679     int maxLength = 0;
680     for (SequenceI seq : s)
681     {
682       int len = seq.getName().length();
683       if (len > maxLength)
684       {
685         maxLength = len;
686       }
687     }
688     return maxLength;
689   }
690
691   /**
692    * Get the longest sequence length
693    * 
694    * @param s
695    * @return
696    */
697   protected static int getMaxSequenceLength(SequenceI[] s)
698   {
699     // TODO pull up for reuse
700     int maxLength = 0;
701     for (SequenceI seq : s)
702     {
703       int len = seq.getLength();
704       if (len > maxLength)
705       {
706         maxLength = len;
707       }
708     }
709     return maxLength;
710   }
711
712   /**
713    * Print to string in noninterleaved format - all of each sequence in turn, in
714    * blocks of 50 characters.
715    * 
716    * @param s
717    * @return
718    */
719   protected String printNonInterleaved(SequenceI[] s)
720   {
721     int maxSequenceLength = getMaxSequenceLength(s);
722     // approx
723     int numLines = maxSequenceLength / POSITIONS_PER_LINE + 2 + s.length;
724
725     /*
726      * Roughly size a buffer to hold the whole output
727      */
728     StringBuilder sb = new StringBuilder(numLines * POSITIONS_PER_LINE);
729     printHeaders(sb, FileFormat.SIMPLE);
730
731     for (SequenceI seq : s)
732     {
733       sb.append(newline);
734       sb.append(HASHSIGN + seq.getName()).append(newline);
735       int startPos = 0;
736       while (startPos <= seq.getLength())
737       {
738         char[] subSequence = seq.getSequence(startPos, startPos
739                 + POSITIONS_PER_LINE);
740         sb.append(subSequence);
741         sb.append(newline);
742         startPos += POSITIONS_PER_LINE;
743       }
744     }
745
746     return new String(sb);
747   }
748
749   /**
750    * Flag this file as interleaved or not, based on data format. Throws an
751    * exception if has previously been determined to be otherwise.
752    * 
753    * @param isIt
754    * @param dataLine
755    * @throws IOException
756    */
757   protected void assertInterleaved(boolean isIt, String dataLine)
758           throws IOException
759   {
760     if (this.interleaved != null && isIt != this.interleaved.booleanValue())
761     {
762       throw new IOException(
763               "Parse error: mix of interleaved and noninterleaved detected, at line: "
764                       + dataLine);
765     }
766     this.interleaved = new Boolean(isIt);
767   }
768
769   public boolean isInterleaved()
770   {
771     return this.interleaved == null ? false : this.interleaved
772             .booleanValue();
773   }
774
775   public FileFormat getFileFormat()
776   {
777     return this.fileFormat;
778   }
779
780   public void setFileFormat(FileFormat fileFormat)
781   {
782     this.fileFormat = fileFormat;
783   }
784 }