JAL-1499 tweak output format for Description
[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.AlignmentI;
22 import jalview.datamodel.Sequence;
23 import jalview.datamodel.SequenceI;
24
25 import java.io.IOException;
26 import java.util.LinkedHashMap;
27 import java.util.Map;
28 import java.util.Map.Entry;
29 import java.util.Set;
30
31 /**
32  * A parser for input or output of MEGA format files. <br>
33  * <br>
34  * Tamura K, Stecher G, Peterson D, Filipski A, and Kumar S (2013) MEGA6:
35  * Molecular Evolutionary Genetics Analysis Version 6.0. Molecular Biology and
36  * Evolution 30: 2725-2729. <br>
37  * <br>
38  * 
39  * MEGA file format is supported as described in
40  * http://www.megasoftware.net/manual.pdf <br>
41  * Limitations:
42  * <ul>
43  * <li>nested comments (marked by [ ]) are accepted but not preserved</li>
44  * <li>to be completed</li>
45  * </ul>
46  * 
47  * @see http://www.megasoftware.net/
48  */
49 public class MegaFile extends AlignFile
50 {
51   private static final int DEFAULT_LINE_LENGTH = 60;
52
53   private static final String INDENT = "    ";
54
55   private static final String N_SITES = "NSites";
56
57   private static final String N_SEQS = "NSeqs";
58
59   private static final String MISSING = "Missing";
60
61   private static final String IDENTICAL = "Identical";
62
63   private static final String INDEL = "Indel";
64
65   private static final String CODETABLE = "CodeTable";
66
67   private static final String PROTEIN = "Protein";
68
69   private static final String NUCLEOTIDE = "Nucleotide";
70
71   private static final String DATATYPE = "DataType";
72
73   private static final char COMMENT_START = '[';
74
75   private static final char COMMENT_END = ']';
76
77   private static final String HASHSIGN = "#";
78
79   private static final String SEMICOLON = ";";
80
81   private static final String BANG = "!";
82
83   private static final String EQUALS = "=";
84
85   private static final String MEGA_ID = HASHSIGN + "MEGA";
86
87   private static final String TITLE = "Title";
88
89   private static final String FORMAT = "Format";
90
91   private static final String DESCRIPTION = "Description";
92
93   private static final String GENE = "Gene";
94
95   private static final String DOMAIN = "Domain";
96
97   /*
98    * names of properties to save to the alignment (may affect eventual output
99    * format)
100    */
101   static final String PROP_TITLE = "MEGA_TITLE";
102
103   static final String PROP_INTERLEAVED = "MEGA_INTERLEAVED";
104
105   static final String PROP_DESCRIPTION = "MEGA_DESCRIPTION";
106
107   static final String PROP_CODETABLE = "MEGA_CODETABLE";
108
109   static final String PROP_IDENTITY = "MEGA_IDENTITY";
110
111   static final String PROP_MISSING = "MEGA_MISSING";
112
113   static final String PROP_DATATYPE = "MEGA_DATATYPE";
114
115   // number of bases per line of file (value is inferred)
116   static final String PROP_LINELENGTH = "MEGA_LINELENGTH";
117
118   // TODO: need a controlled name for Gene as a feature if we want to be able to
119   // output the MEGA file with !Gene headers
120   // WTF do we do if the sequences get realigned?
121
122   // initial size for sequence data buffer
123   private static final int SEQBUFFERSIZE = 256;
124
125   private static final String SPACE = " ";
126
127   /*
128    * number of sequence positions output per line
129    */
130   private int positionsPerLine;
131
132   private String title;
133
134   // gap character may be explicitly declared, if not we infer it
135   private Character gapCharacter;
136
137   // this can be True, False or null (meaning not asserted in file)
138   private Boolean nucleotide;
139
140   // set once we have seen one block of interleaved data
141   private boolean firstDataBlockRead = false;
142
143   // this can be True, False or null (meaning we don't know yet)
144   private Boolean interleaved;
145
146   public MegaFile()
147   {
148   }
149
150   public MegaFile(String inFile, String type) throws IOException
151   {
152     super(inFile, type);
153   }
154
155   public MegaFile(FileParse source) throws IOException
156   {
157     super(source);
158   }
159
160   /**
161    * Parse the input stream.
162    */
163   @Override
164   public void parse() throws IOException
165   {
166     /*
167      * Read and process MEGA and Title/Format/Description headers if present.
168      * Returns the first data line following the headers.
169      */
170     String dataLine = parseHeaderLines();
171
172     /*
173      * Temporary store of {sequenceId, positionData} while parsing interleaved
174      * sequences; sequences are maintained in the order in which they are added
175      * i.e. read in the file
176      */
177     Map<String, StringBuilder> seqData = new LinkedHashMap<String, StringBuilder>();
178
179     /*
180      * The id of the sequence being read (for non-interleaved)
181      */
182     String currentId = "";
183
184     while (dataLine != null)
185     {
186       dataLine = dataLine.trim();
187       if (dataLine.length() > 0)
188       {
189         if (dataLine.startsWith(BANG + GENE))
190         {
191           parseGene(dataLine);
192         }
193         else if (dataLine.startsWith(BANG + DOMAIN))
194         {
195           parseDomain(dataLine);
196         }
197         else
198         {
199           currentId = parseDataLine(dataLine, seqData, currentId);
200         }
201       }
202       else if (!seqData.isEmpty())
203       {
204         /*
205          * Blank line after processing some data...
206          */
207         this.firstDataBlockRead = true;
208       }
209       dataLine = nextNonCommentLine();
210     }
211
212     // remember the (longest) line length read in, so we can output the same
213     setAlignmentProperty(PROP_LINELENGTH, String.valueOf(positionsPerLine));
214
215     setSequences(seqData);
216   }
217
218   /**
219    * Parse a !Gene command line
220    * 
221    * @param dataLine
222    */
223   protected void parseGene(String dataLine)
224   {
225   }
226
227   /**
228    * Parse a !Domain command line
229    * 
230    * @param dataLine
231    */
232   private void parseDomain(String dataLine)
233   {
234   }
235
236   /**
237    * Returns the next line that is not a comment, or null at end of file.
238    * Comments in MEGA are within [ ] brackets, and may be nested.
239    * 
240    * @return
241    * @throws IOException
242    */
243   protected String nextNonCommentLine() throws IOException
244   {
245     return nextNonCommentLine(0);
246   }
247
248   /**
249    * Returns the next line that is not a comment, or null at end of file.
250    * Comments in MEGA are within [ ] brackets, and may be nested.
251    * 
252    * @param depth
253    *          current depth of nesting of comments while parsing
254    * @return
255    * @throws IOException
256    */
257   protected String nextNonCommentLine(final int depth) throws IOException
258   {
259     String data = null;
260     data = nextLine();
261     if (data == null)
262     {
263       if (depth > 0)
264       {
265         System.err.println("Warning: unterminated comment in data file");
266       }
267       return data;
268     }
269     int leftBracket = data.indexOf(COMMENT_START);
270
271     /*
272      * reject unnested comment following data on the same line
273      */
274     if (depth == 0 && leftBracket > 0)
275     {
276       throw new FileFormatException(
277               "Can't parse comment following data at " + data);
278     }
279
280     /*
281      * If we are in a (possibly nested) comment after parsing this line, keep
282      * reading recursively until the comment has unwound
283      */
284     int newDepth = commentDepth(data, depth);
285     if (newDepth > 0)
286     {
287       return nextNonCommentLine(newDepth);
288     }
289     else
290     {
291       /*
292        * not in a comment by end of this line; return what is left (or the next
293        * line if that is empty)
294        */
295       String nonCommentPart = getNonCommentContent(data, depth);
296       // if (nonCommentPart.length() > 0)
297       // {
298         return nonCommentPart;
299       // }
300       // return nextNonCommentLine(0);
301     }
302   }
303
304   /**
305    * Returns what is left of the input data after removing any comments, whether
306    * 'in progress' from preceding lines, or embedded in the current line
307    * 
308    * @param data
309    *          input data
310    * @param depth
311    *          nested depth of comments pending termination
312    * @return
313    * @throws FileFormatException
314    */
315   protected static String getNonCommentContent(String data, int depth)
316           throws FileFormatException
317   {
318     int len = data.length();
319     StringBuilder result = new StringBuilder(len);
320     for (int i = 0; i < len; i++)
321     {
322       char c = data.charAt(i);
323       switch (c)
324       {
325       case COMMENT_START:
326         depth++;
327         break;
328
329       case COMMENT_END:
330         if (depth > 0)
331         {
332           depth--;
333         }
334         else
335         {
336           result.append(c);
337         }
338         break;
339
340       default:
341         if (depth == 0)
342         {
343           result.append(c);
344         }
345       }
346     }
347     return result.toString();
348   }
349
350   /**
351    * Calculates new depth of comment after parsing an input line i.e. the excess
352    * of opening '[' over closing ']' characters. Any excess ']' are ignored (not
353    * treated as comment delimiters).
354    * 
355    * @param data
356    *          input line
357    * @param depth
358    *          current comment nested depth before parsing the line
359    * @return new depth after parsing the line
360    */
361   protected static int commentDepth(CharSequence data, int depth)
362   {
363     int newDepth = depth;
364     int len = data.length();
365     for (int i = 0; i < len; i++)
366     {
367       char c = data.charAt(i);
368       if (c == COMMENT_START)
369       {
370         newDepth++;
371       }
372       else if (c == COMMENT_END && newDepth > 0)
373       {
374         newDepth--;
375       }
376     }
377     return newDepth;
378   }
379
380   /**
381    * Convert the parsed sequence strings to objects and store them in the model.
382    * 
383    * @param seqData
384    */
385   protected void setSequences(Map<String, StringBuilder> seqData)
386   {
387     Set<Entry<String, StringBuilder>> datasets = seqData.entrySet();
388
389     for (Entry<String, StringBuilder> dataset : datasets)
390     {
391       String sequenceId = dataset.getKey();
392       StringBuilder characters = dataset.getValue();
393       SequenceI s = new Sequence(sequenceId, new String(characters));
394       this.seqs.addElement(s);
395     }
396   }
397
398   /**
399    * Process one line of sequence data. If it has no sequence identifier, append
400    * to the current id's sequence. Else parse out the sequence id and append the
401    * data (if any) to that id's sequence. Returns the sequence id (implicit or
402    * explicit) for this line.
403    * 
404    * @param dataLine
405    * @param seqData
406    * @param currentid
407    * @return
408    * @throws IOException
409    */
410   protected String parseDataLine(String dataLine,
411           Map<String, StringBuilder> seqData, String currentId)
412           throws IOException
413   {
414     String seqId = getSequenceId(dataLine);
415     if (seqId == null)
416     {
417       /*
418        * Just character data
419        */
420       parseNoninterleavedDataLine(dataLine, seqData, currentId);
421       return currentId;
422     }
423     else if ((HASHSIGN + seqId).trim().equals(dataLine.trim()))
424     {
425       /*
426        * Sequence id only - header line for noninterleaved data
427        */
428       return seqId;
429     }
430     else
431     {
432       /*
433        * Sequence id followed by data
434        */
435       parseInterleavedDataLine(dataLine, seqData, seqId);
436       return seqId;
437     }
438   }
439
440   /**
441    * Add a line of sequence data to the buffer for the given sequence id. Start
442    * a new one if we haven't seen it before.
443    * 
444    * @param dataLine
445    * @param seqData
446    * @param currentId
447    * @throws IOException
448    */
449   protected void parseNoninterleavedDataLine(String dataLine,
450           Map<String, StringBuilder> seqData, String currentId)
451           throws IOException
452   {
453     if (currentId == null)
454     {
455       /*
456        * Oops. Data but no sequence id context.
457        */
458       throw new IOException("No sequence id context at: " + dataLine);
459     }
460
461     assertInterleaved(false, dataLine);
462
463     StringBuilder sb = getSequenceDataBuffer(seqData, currentId);
464
465     /*
466      * Add the current line of data to the sequence.
467      */
468     sb.append(dataLine);
469
470     setPositionsPerLine(Math.max(positionsPerLine, dataLine.length()));
471   }
472
473   /**
474    * Get the sequence data for this sequence id, starting a new one if
475    * necessary.
476    * 
477    * @param seqData
478    * @param currentId
479    * @return
480    */
481   protected StringBuilder getSequenceDataBuffer(
482           Map<String, StringBuilder> seqData, String currentId)
483   {
484     StringBuilder sb = seqData.get(currentId);
485     if (sb == null)
486     {
487       // first data met for this sequence id, start a new buffer
488       sb = new StringBuilder(SEQBUFFERSIZE);
489       seqData.put(currentId, sb);
490     }
491     return sb;
492   }
493
494   /**
495    * Parse one line of interleaved data e.g.
496    * 
497    * <pre>
498    * #TheSeqId CGATCGCATGCA
499    * </pre>
500    * 
501    * @param dataLine
502    * @param seqData
503    * @param seqId
504    * @throws IOException
505    */
506   protected void parseInterleavedDataLine(String dataLine,
507           Map<String, StringBuilder> seqData, String seqId)
508           throws IOException
509   {
510     /*
511      * New sequence found in second or later data block - error.
512      */
513     if (this.firstDataBlockRead && !seqData.containsKey(seqId))
514     {
515       throw new IOException(
516               "Parse error: misplaced new sequence starting at " + dataLine);
517     }
518
519     StringBuilder sb = getSequenceDataBuffer(seqData, seqId);
520     String data = dataLine.substring(seqId.length() + 1).trim();
521
522     /*
523      * Do nothing if this line is _only_ a sequence id with no data following.
524      * 
525      * Remove any internal spaces
526      */
527     if (data != null && data.length() > 0)
528     {
529       if (data.indexOf(SPACE) != -1)
530       {
531         data = data.replace(SPACE, "");
532       }
533       sb.append(data);
534       setPositionsPerLine(Math.max(positionsPerLine, data.length()));
535       assertInterleaved(true, dataLine);
536     }
537   }
538
539   /**
540    * If the line begins with (e.g.) "#abcde " then returns "abcde" as the
541    * identifier. Else returns null.
542    * 
543    * @param dataLine
544    * @return
545    */
546   public static String getSequenceId(String dataLine)
547   {
548     // TODO refactor to a StringUtils type class
549     if (dataLine != null)
550     {
551       if (dataLine.startsWith(HASHSIGN))
552       {
553         int spacePos = dataLine.indexOf(" ");
554         return (spacePos == -1 ? dataLine.substring(1) : dataLine
555                 .substring(1, spacePos));
556       }
557     }
558     return null;
559   }
560
561   /**
562    * Read the #MEGA and Title/Format/Description header lines (if present).
563    * 
564    * Save as alignment properties in case useful.
565    * 
566    * @return the next non-blank line following the header lines.
567    * @throws IOException
568    */
569   protected String parseHeaderLines() throws IOException
570   {
571     String inputLine = null;
572     while ((inputLine = nextNonCommentLine()) != null)
573     {
574       inputLine = inputLine.trim();
575
576       /*
577        * skip blank lines
578        */
579       if (inputLine.length() == 0)
580       {
581         continue;
582       }
583
584       if (inputLine.toUpperCase().startsWith(MEGA_ID))
585       {
586         continue;
587       }
588
589       if (isTitle(inputLine))
590       {
591         this.title = getValue(inputLine);
592         setAlignmentProperty(PROP_TITLE, title);
593       }
594       else if (inputLine.startsWith(BANG + DESCRIPTION))
595       {
596         parseDescription(inputLine);
597       }
598
599       else if (inputLine.startsWith(BANG + FORMAT))
600       {
601         parseFormat(inputLine);
602       }
603       else if (!inputLine.toUpperCase().startsWith(MEGA_ID))
604       {
605
606         /*
607          * Return the first 'data line' i.e. one that is not blank, #MEGA or
608          * TITLE:
609          */
610         break;
611       }
612     }
613     return inputLine;
614   }
615
616   /**
617    * Parse a !Format statement. This may be multiline, and is ended by a
618    * semicolon.
619    * 
620    * @param inputLine
621    * @throws IOException
622    */
623   protected void parseFormat(String inputLine) throws IOException
624   {
625     while (inputLine != null)
626     {
627       parseFormatLine(inputLine);
628       if (inputLine.endsWith(SEMICOLON))
629       {
630         break;
631       }
632       inputLine = nextNonCommentLine();
633     }
634   }
635
636   /**
637    * Parse one line of a !Format statement. This may contain one or more
638    * keyword=value pairs.
639    * 
640    * @param inputLine
641    * @throws FileFormatException
642    */
643   protected void parseFormatLine(String inputLine)
644           throws FileFormatException
645   {
646     if (inputLine.startsWith(BANG + FORMAT))
647     {
648       inputLine = inputLine.substring((BANG + FORMAT).length());
649     }
650     if (inputLine.endsWith(SEMICOLON))
651     {
652       inputLine = inputLine.substring(0, inputLine.length() - 1);
653     }
654     if (inputLine.length() == 0)
655     {
656       return;
657     }
658     String[] tokens = inputLine.trim().split("\\s"); // any whitespace
659     for (String token : tokens)
660     {
661       parseFormatKeyword(token);
662     }
663   }
664
665   /**
666    * Parse a Keyword=Value token. Possible keywords are
667    * <ul>
668    * <li>DataType= DNA, RNA, Nucleotide, Protein</li>
669    * <li>DataFormat= Interleaved, ?</li>
670    * <li>NSeqs= number of sequences (synonym NTaxa)</li>
671    * <li>NSites= number of bases / residues</li>
672    * <li>Property= Exon (or Coding), Intron (or Noncoding), End (of domain)</li>
673    * <li>Indel= gap character</li>
674    * <li>Identical= identity character (to first sequence) (synonym MatchChar)</li>
675    * <li>Missing= missing data character</li>
676    * <li>CodeTable= Standard, other (MEGA supports various)</li>
677    * </ul>
678    * 
679    * @param token
680    * @throws FileFormatException
681    *           if an unrecognised keyword or value is encountered
682    */
683   protected void parseFormatKeyword(String token)
684           throws FileFormatException
685   {
686     String msg = "Unrecognised Format command: " + token;
687     String[] bits = token.split(EQUALS);
688     if (bits.length != 2)
689     {
690       throw new FileFormatException(msg);
691     }
692     String keyword = bits[0];
693     String value = bits[1];
694
695     /*
696      * Jalview will work out whether nucleotide or not anyway
697      */
698     if (keyword.equalsIgnoreCase(DATATYPE))
699     {
700       if (value.equalsIgnoreCase("DNA") || value.equalsIgnoreCase("RNA")
701               || value.equalsIgnoreCase("Nucleotide"))
702       {
703         this.nucleotide = true;
704         // alignment computes whether or not it is nucleotide when created
705       }
706       else if (value.equalsIgnoreCase(PROTEIN))
707       {
708         this.nucleotide = false;
709       }
710       else
711       {
712         throw new FileFormatException(msg);
713       }
714       setAlignmentProperty(PROP_DATATYPE, value);
715     }
716
717     /*
718      * accept non-Standard code table but save in case we want to disable
719      * 'translate as cDNA'
720      */
721     else if (keyword.equalsIgnoreCase(CODETABLE))
722     {
723       setAlignmentProperty(PROP_CODETABLE, value);
724     }
725
726     /*
727      * save gap char to set later on alignment once created
728      */
729     else if (keyword.equalsIgnoreCase(INDEL))
730     {
731       this.gapCharacter = value.charAt(0);
732     }
733
734     else if (keyword.equalsIgnoreCase(IDENTICAL)
735             || keyword.equalsIgnoreCase("MatchChar"))
736     {
737       setAlignmentProperty(PROP_IDENTITY, value);
738       if (!".".equals(value))
739       {
740         System.err.println("Warning: " + token
741                 + " not supported, Jalview uses '.' for identity");
742       }
743     }
744
745     else if (keyword.equalsIgnoreCase(MISSING))
746     {
747       setAlignmentProperty(PROP_MISSING, value);
748       System.err.println("Warning: " + token + " not supported");
749     }
750
751     else if (keyword.equalsIgnoreCase("Property"))
752     {
753       // TODO: figure out what to do with this
754       // can it appear more than once in a file?
755       setAlignmentProperty(PROP_MISSING, value);
756     }
757
758     else if (!keyword.equalsIgnoreCase(N_SEQS)
759             && !keyword.equalsIgnoreCase(N_SITES))
760     {
761       System.err.println("Warning: " + msg);
762     }
763   }
764
765   /**
766    * Returns the trimmed data on the line following either whitespace or '=',
767    * with any trailing semi-colon removed<br>
768    * So
769    * <ul>
770    * <li>Hello World</li>
771    * <li>!Hello: \tWorld;</li>
772    * <li>!Hello=World</li>
773    * <ul>
774    * should all return "World"
775    * 
776    * @param inputLine
777    * @return
778    */
779   protected static String getValue(String inputLine)
780   {
781     if (inputLine == null)
782     {
783       return null;
784     }
785     String value = null;
786     String s = inputLine.replaceAll("\t", " ").trim();
787
788     /*
789      * KEYWORD = VALUE should return VALUE
790      */
791     int equalsPos = s.indexOf("=");
792     if (equalsPos >= 0)
793     {
794       value = s.substring(equalsPos + 1);
795     }
796     else
797     {
798       int spacePos = s.indexOf(' ');
799       value = spacePos == -1 ? "" : s.substring(spacePos + 1);
800     }
801     value = value.trim();
802     if (value.endsWith(SEMICOLON))
803     {
804       value = value.substring(0, value.length() - 1).trim();
805     }
806     return value;
807   }
808
809   /**
810    * Returns true if the input line starts with "TITLE" or "!TITLE" (not case
811    * sensitive). The latter is the official format, some older data file
812    * examples have it without the !.
813    * 
814    * @param inputLine
815    * @return
816    */
817   protected static boolean isTitle(String inputLine)
818   {
819     if (inputLine == null)
820     {
821       return false;
822     }
823     String upper = inputLine.toUpperCase();
824     return (upper.startsWith(TITLE.toUpperCase()) || upper.startsWith(BANG
825             + TITLE.toUpperCase()));
826   }
827
828   /**
829    * Reads lines until terminated by semicolon, appending each to the
830    * Description property value.
831    * 
832    * @throws IOException
833    */
834   protected void parseDescription(String firstDescriptionLine)
835           throws IOException
836   {
837     StringBuilder desc = new StringBuilder(256);
838     String line = getValue(firstDescriptionLine);
839     while (line != null)
840     {
841       if (line.endsWith(SEMICOLON))
842       {
843         desc.append(line.substring(0, line.length() - 1));
844         break;
845       }
846       else if (line.length() > 0)
847       {
848         desc.append(line).append(newline);
849       }
850       line = nextNonCommentLine();
851     }
852     setAlignmentProperty(PROP_DESCRIPTION, desc.toString());
853   }
854
855   /**
856    * Returns the alignment sequences in Mega format.
857    */
858   @Override
859   public String print()
860   {
861     return MEGA_ID + newline + print(getSeqsAsArray());
862   }
863
864   /**
865    * Write out the alignment sequences in Mega format - interleaved unless
866    * explicitly noninterleaved.
867    */
868   protected String print(SequenceI[] s)
869   {
870     String result;
871     if (this.interleaved != null && !this.interleaved)
872     {
873       result = printNonInterleaved(s);
874     }
875     else
876     {
877       result = printInterleaved(s);
878     }
879     return result;
880   }
881
882   /**
883    * Print to string in Interleaved format - blocks of next N characters of each
884    * sequence in turn.
885    * 
886    * @param s
887    */
888   protected String printInterleaved(SequenceI[] s)
889   {
890     int maxIdLength = getMaxIdLength(s);
891     int maxSequenceLength = getMaxSequenceLength(s);
892     int numLines = maxSequenceLength / positionsPerLine + 3; // approx
893
894     /*
895      * Size a buffer to hold the whole output
896      */
897     StringBuilder sb = new StringBuilder(numLines
898             * (maxIdLength + 2 + positionsPerLine));
899
900     int numDataBlocks = (maxSequenceLength - 1) / positionsPerLine + 1;
901     int spaceEvery = this.nucleotide != null && this.nucleotide ? 3 : 10;
902     int chunksPerLine = (positionsPerLine + spaceEvery - 1) / spaceEvery;
903
904     /*
905      * Output as: #Seqid CGT AGC ACT ... or blocks of 10 for peptide
906      */
907     int from = 0;
908     for (int i = 0; i < numDataBlocks; i++)
909     {
910       sb.append(newline);
911       boolean first = true;
912       int advancedBy = 0;
913       for (SequenceI seq : s)
914       {
915         int seqFrom = from;
916         String seqId = String.format("#%-" + maxIdLength + "s",
917                 seq.getName());
918
919         /*
920          * output next line for this sequence
921          */
922         sb.append(seqId);
923         int lastPos = seqFrom + positionsPerLine; // exclusive
924         for (int j = 0; j < chunksPerLine; j++)
925         {
926           char[] subSequence = seq.getSequence(seqFrom,
927                   Math.min(lastPos, seqFrom + spaceEvery));
928           if (subSequence.length > 0)
929           {
930             sb.append(SPACE).append(subSequence);
931           }
932           seqFrom += subSequence.length;
933           if (first)
934           {
935             // all sequences should be the same length in MEGA
936             advancedBy += subSequence.length;
937           }
938         }
939         sb.append(newline);
940         first = false;
941       }
942       from += advancedBy;
943     }
944
945     return new String(sb);
946   }
947
948   /**
949    * Outputs to string the MEGA header and any other known and relevant
950    * alignment properties
951    * 
952    * @param al
953    */
954   protected String printHeaders(AlignmentI al)
955   {
956     StringBuilder sb = new StringBuilder(128);
957     sb.append(MEGA_ID).append(newline);
958     String propertyValue = (String) al.getProperty(PROP_TITLE);
959     if (propertyValue != null)
960     {
961       sb.append(BANG).append(TITLE).append(SPACE)
962 .append(propertyValue)
963               .append(SEMICOLON)
964               .append(newline);
965     }
966     propertyValue = (String) al.getProperty(PROP_DESCRIPTION);
967     if (propertyValue != null)
968     {
969       sb.append(BANG).append(DESCRIPTION).append(newline)
970               .append(propertyValue).append(SEMICOLON)
971               .append(newline);
972     }
973
974     /*
975      * !Format DataType CodeTable
976      */
977     sb.append(BANG).append(FORMAT).append(newline);
978     String dataType = (String) al.getProperty(PROP_DATATYPE);
979     if (dataType == null)
980     {
981       dataType = al.isNucleotide() ? NUCLEOTIDE : PROTEIN;
982     }
983     sb.append(INDENT).append(DATATYPE).append(EQUALS).append(dataType);
984     String codeTable = (String) al.getProperty(PROP_CODETABLE);
985     sb.append(SPACE).append(CODETABLE).append(EQUALS)
986             .append(codeTable == null ? "Standard" : codeTable)
987             .append(newline);
988     
989     /*
990      * !Format NSeqs NSites
991      * NSites the length of any sequence (they should all be the same), excluding
992      * gaps?!?
993      */
994     sb.append(INDENT).append(N_SEQS).append(EQUALS).append(al.getHeight());
995     SequenceI seq = al.getSequenceAt(0);
996     sb.append(SPACE).append(N_SITES).append(EQUALS)
997             .append(seq.getEnd() - seq.getStart() + 1);
998     sb.append(newline);
999
1000     /*
1001      * !Format Indel Identical Missing
1002      */
1003     sb.append(INDENT);
1004     sb.append(INDEL).append(EQUALS).append(al.getGapCharacter());
1005     String identity = (String) al.getProperty(PROP_IDENTITY);
1006     if (identity != null)
1007     {
1008       sb.append(SPACE).append(IDENTICAL).append(EQUALS).append(identity);
1009     }
1010     String missing = (String) al.getProperty(PROP_MISSING);
1011     if (missing != null)
1012     {
1013       sb.append(SPACE).append(MISSING).append(EQUALS).append(missing);
1014     }
1015     sb.append(SEMICOLON).append(newline);
1016
1017     return sb.toString();
1018   }
1019
1020   /**
1021    * Get the longest sequence id (to allow aligned printout).
1022    * 
1023    * @param s
1024    * @return
1025    */
1026   protected static int getMaxIdLength(SequenceI[] s)
1027   {
1028     // TODO pull up for reuse
1029     int maxLength = 0;
1030     for (SequenceI seq : s)
1031     {
1032       int len = seq.getName().length();
1033       if (len > maxLength)
1034       {
1035         maxLength = len;
1036       }
1037     }
1038     return maxLength;
1039   }
1040
1041   /**
1042    * Get the longest sequence length
1043    * 
1044    * @param s
1045    * @return
1046    */
1047   protected static int getMaxSequenceLength(SequenceI[] s)
1048   {
1049     // TODO pull up for reuse
1050     int maxLength = 0;
1051     for (SequenceI seq : s)
1052     {
1053       int len = seq.getLength();
1054       if (len > maxLength)
1055       {
1056         maxLength = len;
1057       }
1058     }
1059     return maxLength;
1060   }
1061
1062   /**
1063    * Print to string in noninterleaved format - all of each sequence in turn, in
1064    * blocks of 50 characters.
1065    * 
1066    * @param s
1067    * @return
1068    */
1069   protected String printNonInterleaved(SequenceI[] s)
1070   {
1071     int maxSequenceLength = getMaxSequenceLength(s);
1072     // approx
1073     int numLines = maxSequenceLength / positionsPerLine + 2 + s.length;
1074
1075     /*
1076      * Roughly size a buffer to hold the whole output
1077      */
1078     StringBuilder sb = new StringBuilder(numLines * positionsPerLine);
1079
1080     int spaceEvery = this.nucleotide != null && this.nucleotide ? 3 : 10;
1081     int chunksPerLine = positionsPerLine / spaceEvery;
1082     for (SequenceI seq : s)
1083     {
1084       sb.append(newline);
1085       sb.append(HASHSIGN + seq.getName()).append(newline);
1086       int startPos = 0;
1087       while (startPos < seq.getLength())
1088       {
1089         boolean firstChunk = true;
1090         /*
1091          * print next line for this sequence
1092          */
1093         int lastPos = startPos + positionsPerLine; // exclusive
1094         for (int j = 0; j < chunksPerLine; j++)
1095         {
1096           char[] subSequence = seq.getSequence(startPos,
1097                   Math.min(lastPos, startPos + positionsPerLine));
1098           if (subSequence.length > 0)
1099           {
1100             if (!firstChunk)
1101             {
1102               sb.append(SPACE);
1103             }
1104             sb.append(subSequence);
1105             firstChunk = false;
1106           }
1107           startPos += subSequence.length;
1108         }
1109         sb.append(newline);
1110       }
1111     }
1112
1113     return new String(sb);
1114   }
1115
1116   /**
1117    * Flag this file as interleaved or not, based on data format. Throws an
1118    * exception if has previously been determined to be otherwise.
1119    * 
1120    * @param isIt
1121    * @param dataLine
1122    * @throws IOException
1123    */
1124   protected void assertInterleaved(boolean isIt, String dataLine)
1125           throws FileFormatException
1126   {
1127     if (this.interleaved != null && isIt != this.interleaved.booleanValue())
1128     {
1129       throw new FileFormatException(
1130               "Parse error: mix of interleaved and noninterleaved detected, at line: "
1131                       + dataLine);
1132     }
1133     this.interleaved = new Boolean(isIt);
1134     setAlignmentProperty(PROP_INTERLEAVED, interleaved.toString());
1135   }
1136
1137   public boolean isInterleaved()
1138   {
1139     return this.interleaved == null ? false : this.interleaved
1140             .booleanValue();
1141   }
1142
1143   /**
1144    * Adds saved parsed values either as alignment properties, or (in some cases)
1145    * as specific member fields of the alignment
1146    */
1147   @Override
1148   public void addProperties(AlignmentI al)
1149   {
1150     super.addProperties(al);
1151     if (this.gapCharacter != null)
1152     {
1153       al.setGapCharacter(gapCharacter);
1154     }
1155     
1156     /*
1157      * warn if e.g. DataType=DNA but data is protein (or vice versa)
1158      */
1159     if (this.nucleotide != null && this.nucleotide != al.isNucleotide()) {
1160       System.err.println("Warning: " + this.title + " declared "
1161               + (nucleotide ? "" : " not ") + "nucleotide but it is"
1162               + (nucleotide ? " not" : ""));
1163     }
1164   }
1165
1166   /**
1167    * Print the given alignment in MEGA format. If the alignment was created by
1168    * parsing a MEGA file, it should have properties set (e.g. Title) which can
1169    * influence the output.
1170    */
1171   @Override
1172   public String print(AlignmentI al)
1173   {
1174     this.nucleotide = al.isNucleotide();
1175     String lineLength = (String) al.getProperty(PROP_LINELENGTH);
1176     this.positionsPerLine = lineLength == null ? DEFAULT_LINE_LENGTH : Integer
1177             .parseInt(lineLength);
1178     return printHeaders(al) + print(al.getSequencesArray());
1179   }
1180
1181   /**
1182    * Returns the number of sequence positions output per line
1183    * 
1184    * @return
1185    */
1186   public int getPositionsPerLine()
1187   {
1188     return positionsPerLine;
1189   }
1190
1191   /**
1192    * Sets the number of sequence positions output per line. Note these will be
1193    * formatted in blocks of 3 (nucleotide) or 10 (peptide).
1194    * 
1195    * @param p
1196    */
1197   public void setPositionsPerLine(int p)
1198   {
1199     this.positionsPerLine = p;
1200   }
1201 }