JAL-1499 parse !Gene statements to SequenceFeature
[jalview.git] / src / jalview / io / MegaFile.java
index af9889b..e4079c0 100644 (file)
@@ -20,10 +20,15 @@ package jalview.io;
 
 import jalview.datamodel.AlignmentI;
 import jalview.datamodel.Sequence;
+import jalview.datamodel.SequenceFeature;
 import jalview.datamodel.SequenceI;
 
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Set;
@@ -131,8 +136,11 @@ public class MegaFile extends AlignFile
 
   private String title;
 
-  // gap character may be explicitly declared, if not we infer it
-  private Character gapCharacter;
+  // gap character may be explicitly declared, default is -
+  private char gapCharacter = '-';
+
+  // identity character if declared
+  private char identityCharacter = 0;
 
   // this can be True, False or null (meaning not asserted in file)
   private Boolean nucleotide;
@@ -143,6 +151,34 @@ public class MegaFile extends AlignFile
   // this can be True, False or null (meaning we don't know yet)
   private Boolean interleaved;
 
+  // write end of line positions as a comment
+  private boolean writePositionNumbers = true;
+
+  // id of sequence being processed
+  private String currentSequenceId;
+
+  /*
+   * Temporary store of {sequenceId, positionData} while parsing interleaved
+   * sequences; sequences are maintained in the order in which they are added
+   * i.e. read in the file
+   */
+  Map<String, StringBuilder> seqData;
+  
+  // number of residues read (so far) per sequence
+  Map<String, Integer> residuesRead;
+  
+  // start residue (base 1) per sequence of current feature
+  Map<String, Integer> featureStart;
+  
+  // feature (Gene/Domain) if any we are parsing
+  private String currentFeature;
+
+  // feature type (Gene/Domain) if any we are parsing
+  private String currentFeatureType;
+
+  // map of SequenceFeature's by sequence id
+  Map<String, List<SequenceFeature>> sequenceFeatures;
+
   public MegaFile()
   {
   }
@@ -163,6 +199,11 @@ public class MegaFile extends AlignFile
   @Override
   public void parse() throws IOException
   {
+    gapCharacter = '-';
+    sequenceFeatures = new HashMap<String, List<SequenceFeature>>();
+    featureStart = new HashMap<String, Integer>();
+    residuesRead = new HashMap<String, Integer>();
+
     /*
      * Read and process MEGA and Title/Format/Description headers if present.
      * Returns the first data line following the headers.
@@ -170,16 +211,15 @@ public class MegaFile extends AlignFile
     String dataLine = parseHeaderLines();
 
     /*
-     * Temporary store of {sequenceId, positionData} while parsing interleaved
-     * sequences; sequences are maintained in the order in which they are added
-     * i.e. read in the file
+     * order-preserving map to hold sequences by id as they are built up during
+     * parsing
      */
-    Map<String, StringBuilder> seqData = new LinkedHashMap<String, StringBuilder>();
+    seqData = new LinkedHashMap<String, StringBuilder>();
 
     /*
      * The id of the sequence being read (for non-interleaved)
      */
-    String currentId = "";
+    currentSequenceId = "";
 
     while (dataLine != null)
     {
@@ -188,15 +228,15 @@ public class MegaFile extends AlignFile
       {
         if (dataLine.startsWith(BANG + GENE))
         {
-          parseGene(dataLine);
+          parseFeature(GENE, dataLine);
         }
         else if (dataLine.startsWith(BANG + DOMAIN))
         {
-          parseDomain(dataLine);
+          parseFeature(DOMAIN, dataLine);
         }
         else
         {
-          currentId = parseDataLine(dataLine, seqData, currentId);
+          currentSequenceId = parseDataLine(dataLine);
         }
       }
       else if (!seqData.isEmpty())
@@ -204,7 +244,7 @@ public class MegaFile extends AlignFile
         /*
          * Blank line after processing some data...
          */
-        this.firstDataBlockRead = true;
+        endOfDataBlock();
       }
       dataLine = nextNonCommentLine();
     }
@@ -212,16 +252,88 @@ public class MegaFile extends AlignFile
     // remember the (longest) line length read in, so we can output the same
     setAlignmentProperty(PROP_LINELENGTH, String.valueOf(positionsPerLine));
 
-    setSequences(seqData);
+    deriveSequences();
+  }
+
+  /**
+   * Post-processing after reading one block of interleaved data
+   */
+  protected void endOfDataBlock()
+  {
+    this.firstDataBlockRead = true;
+    // TODO:
+    // (initialise and) populate arrays of sequence length so far (excluding
+    // gaps)
+    // On change or end of a denoted Gene or Domain, add sequence features for
+    // it
   }
 
   /**
-   * Parse a !Gene command line
+   * Parse a !Gene or !Domain command line
    * 
+   * @param featureType
    * @param dataLine
    */
-  protected void parseGene(String dataLine)
+  protected void parseFeature(String featureType, String dataLine)
+  {
+    String featureName = getValue(dataLine);
+    // TODO parse !Gene=xyx Property=end; ???
+    if (this.currentFeature != null)
+    {
+      endSequenceFeature();
+    }
+    startSequenceFeature(featureName, featureType);
+  }
+
+  /**
+   * Start processing a new feature
+   * 
+   * @param featureName
+   */
+  protected void startSequenceFeature(String featureName, String featureType)
+  {
+    currentFeature = featureName;
+    currentFeatureType = featureType;
+
+    /*
+     * If the feature name precedes all sequences, we will know in
+     * endSequenceFeature that it starts with residue 1; otherwise note now
+     * where it starts in each sequence
+     */
+    if (!residuesRead.isEmpty())
+    {
+      for (Entry<String, Integer> entry : residuesRead.entrySet())
+      {
+        String seqId = entry.getKey();
+        Integer nextResidue = entry.getValue() + 1;
+        featureStart.put(seqId, nextResidue);
+      }
+    }
+  }
+
+  /**
+   * Add a SequenceFeature for the current feature to each sequence, using the
+   * current feature start/end values per sequence
+   */
+  protected void endSequenceFeature()
   {
+    Iterator<String> seqids = this.seqData.keySet().iterator();
+    while (seqids.hasNext())
+    {
+      String seqid = seqids.next();
+      Integer startAt = featureStart.get(seqid);
+      int sfstart = startAt == null ? 1 : startAt.intValue();
+      int sfend = residuesRead.get(seqid);
+      if (sfend >= sfstart)
+      {
+        /*
+         * don't add feature if entirely gapped in the sequence
+         */
+        SequenceFeature sf = new SequenceFeature(currentFeature,
+                currentFeatureType, sfstart, sfend, 0f, null);
+        sequenceFeatures.get(seqid).add(sf);
+      }
+    }
   }
 
   /**
@@ -246,8 +358,10 @@ public class MegaFile extends AlignFile
   }
 
   /**
-   * Returns the next line that is not a comment, or null at end of file.
-   * Comments in MEGA are within [ ] brackets, and may be nested.
+   * Returns the next non-comment line (or part line), or null at end of file.
+   * Comments in MEGA are within [ ] brackets, and may be nested. They may occur
+   * anywhere within a line (for example at the end with position numbers); this
+   * method returns the line with any comments removed.
    * 
    * @param depth
    *          current depth of nesting of comments while parsing
@@ -266,16 +380,6 @@ public class MegaFile extends AlignFile
       }
       return data;
     }
-    int leftBracket = data.indexOf(COMMENT_START);
-
-    /*
-     * reject unnested comment following data on the same line
-     */
-    if (depth == 0 && leftBracket > 0)
-    {
-      throw new FileFormatException(
-              "Can't parse comment following data at " + data);
-    }
 
     /*
      * If we are in a (possibly nested) comment after parsing this line, keep
@@ -289,15 +393,10 @@ public class MegaFile extends AlignFile
     else
     {
       /*
-       * not in a comment by end of this line; return what is left (or the next
-       * line if that is empty)
+       * not in a comment by end of this line; return what is left
        */
       String nonCommentPart = getNonCommentContent(data, depth);
-      // if (nonCommentPart.length() > 0)
-      // {
-        return nonCommentPart;
-      // }
-      // return nextNonCommentLine(0);
+      return nonCommentPart;
     }
   }
 
@@ -379,10 +478,8 @@ public class MegaFile extends AlignFile
 
   /**
    * Convert the parsed sequence strings to objects and store them in the model.
-   * 
-   * @param seqData
    */
-  protected void setSequences(Map<String, StringBuilder> seqData)
+  protected void deriveSequences()
   {
     Set<Entry<String, StringBuilder>> datasets = seqData.entrySet();
 
@@ -392,6 +489,14 @@ public class MegaFile extends AlignFile
       StringBuilder characters = dataset.getValue();
       SequenceI s = new Sequence(sequenceId, new String(characters));
       this.seqs.addElement(s);
+
+      /*
+       * and add any derived sequence features to the sequence
+       */
+      for (SequenceFeature sf : sequenceFeatures.get(sequenceId))
+      {
+        s.addSequenceFeature(sf);
+      }
     }
   }
 
@@ -402,13 +507,10 @@ public class MegaFile extends AlignFile
    * explicit) for this line.
    * 
    * @param dataLine
-   * @param seqData
-   * @param currentid
    * @return
    * @throws IOException
    */
-  protected String parseDataLine(String dataLine,
-          Map<String, StringBuilder> seqData, String currentId)
+  protected String parseDataLine(String dataLine)
           throws IOException
   {
     String seqId = getSequenceId(dataLine);
@@ -417,8 +519,8 @@ public class MegaFile extends AlignFile
       /*
        * Just character data
        */
-      parseNoninterleavedDataLine(dataLine, seqData, currentId);
-      return currentId;
+      parseNoninterleavedDataLine(dataLine);
+      return currentSequenceId;
     }
     else if ((HASHSIGN + seqId).trim().equals(dataLine.trim()))
     {
@@ -432,7 +534,7 @@ public class MegaFile extends AlignFile
       /*
        * Sequence id followed by data
        */
-      parseInterleavedDataLine(dataLine, seqData, seqId);
+      parseInterleavedDataLine(dataLine, seqId);
       return seqId;
     }
   }
@@ -442,15 +544,12 @@ public class MegaFile extends AlignFile
    * a new one if we haven't seen it before.
    * 
    * @param dataLine
-   * @param seqData
-   * @param currentId
    * @throws IOException
    */
-  protected void parseNoninterleavedDataLine(String dataLine,
-          Map<String, StringBuilder> seqData, String currentId)
+  protected void parseNoninterleavedDataLine(String dataLine)
           throws IOException
   {
-    if (currentId == null)
+    if (currentSequenceId == null)
     {
       /*
        * Oops. Data but no sequence id context.
@@ -460,12 +559,7 @@ public class MegaFile extends AlignFile
 
     assertInterleaved(false, dataLine);
 
-    StringBuilder sb = getSequenceDataBuffer(seqData, currentId);
-
-    /*
-     * Add the current line of data to the sequence.
-     */
-    sb.append(dataLine);
+    dataLine = addSequenceData(currentSequenceId, dataLine);
 
     setPositionsPerLine(Math.max(positionsPerLine, dataLine.length()));
   }
@@ -474,12 +568,10 @@ public class MegaFile extends AlignFile
    * Get the sequence data for this sequence id, starting a new one if
    * necessary.
    * 
-   * @param seqData
    * @param currentId
    * @return
    */
-  protected StringBuilder getSequenceDataBuffer(
-          Map<String, StringBuilder> seqData, String currentId)
+  protected StringBuilder getSequenceDataBuffer(String currentId)
   {
     StringBuilder sb = seqData.get(currentId);
     if (sb == null)
@@ -487,6 +579,9 @@ public class MegaFile extends AlignFile
       // first data met for this sequence id, start a new buffer
       sb = new StringBuilder(SEQBUFFERSIZE);
       seqData.put(currentId, sb);
+
+      // and a placeholder for any SequenceFeature found
+      sequenceFeatures.put(currentId, new ArrayList<SequenceFeature>());
     }
     return sb;
   }
@@ -499,44 +594,92 @@ public class MegaFile extends AlignFile
    * </pre>
    * 
    * @param dataLine
-   * @param seqData
    * @param seqId
-   * @throws IOException
+   * @throws FileFormatException
    */
-  protected void parseInterleavedDataLine(String dataLine,
-          Map<String, StringBuilder> seqData, String seqId)
-          throws IOException
+  protected void parseInterleavedDataLine(String dataLine, String seqId)
+          throws FileFormatException
   {
     /*
      * New sequence found in second or later data block - error.
      */
     if (this.firstDataBlockRead && !seqData.containsKey(seqId))
     {
-      throw new IOException(
+      throw new FileFormatException(
               "Parse error: misplaced new sequence starting at " + dataLine);
     }
 
-    StringBuilder sb = getSequenceDataBuffer(seqData, seqId);
     String data = dataLine.substring(seqId.length() + 1).trim();
 
     /*
      * Do nothing if this line is _only_ a sequence id with no data following.
-     * 
-     * Remove any internal spaces
      */
     if (data != null && data.length() > 0)
     {
-      if (data.indexOf(SPACE) != -1)
-      {
-        data = data.replace(SPACE, "");
-      }
-      sb.append(data);
+      data = addSequenceData(seqId, data);
       setPositionsPerLine(Math.max(positionsPerLine, data.length()));
       assertInterleaved(true, dataLine);
     }
   }
 
   /**
+   * Remove spaces, and replace identity symbol, before appending the sequence
+   * data to the buffer for the sequence id. Returns the reformatted added data.
+   * Also updates a count of residues read for the sequence.
+   * 
+   * @param seqId
+   * @param data
+   * @return
+   */
+  protected String addSequenceData(String seqId, String data)
+  {
+    StringBuilder sb = getSequenceDataBuffer(seqId);
+    int len = sb.length();
+    String formatted = data.replace(SPACE, "");
+
+    /*
+     * If sequence contains '.' or other identity symbol; replace these with the
+     * same position from the first (reference) sequence
+     */
+    int nonGapped = 0;
+    StringBuilder referenceSequence = seqData.values().iterator().next();
+    StringBuilder sb1 = new StringBuilder(formatted.length());
+    for (int i = 0; i < formatted.length(); i++)
+    {
+      char nextChar = formatted.charAt(i);
+      if (nextChar != gapCharacter)
+      {
+        nonGapped++;
+      }
+      if (nextChar == identityCharacter
+              && len + i < referenceSequence.length())
+      {
+        sb1.append(referenceSequence.charAt(len + i));
+      }
+      else
+      {
+        sb1.append(nextChar);
+      }
+    }
+    formatted = sb1.toString();
+
+    data = formatted;
+    sb.append(data);
+
+    /*
+     * increment residue count for the sequence
+     */
+    if (nonGapped > 0)
+    {
+      Integer residueCount = residuesRead.get(seqId);
+      residuesRead.put(seqId, nonGapped
+              + (residueCount == null ? 0 : residueCount));
+    }
+
+    return data;
+  }
+
+  /**
    * If the line begins with (e.g.) "#abcde " then returns "abcde" as the
    * identifier. Else returns null.
    * 
@@ -735,6 +878,7 @@ public class MegaFile extends AlignFile
             || keyword.equalsIgnoreCase("MatchChar"))
     {
       setAlignmentProperty(PROP_IDENTITY, value);
+      this.identityCharacter = value.charAt(0);
       if (!".".equals(value))
       {
         System.err.println("Warning: " + token
@@ -835,19 +979,23 @@ public class MegaFile extends AlignFile
           throws IOException
   {
     StringBuilder desc = new StringBuilder(256);
-    String line = getValue(firstDescriptionLine);
-    while (line != null)
+    desc.append(getValue(firstDescriptionLine));
+    if (!firstDescriptionLine.endsWith(SEMICOLON))
     {
-      if (line.endsWith(SEMICOLON))
+      String line = nextNonCommentLine();
+      while (line != null)
       {
-        desc.append(line.substring(0, line.length() - 1));
-        break;
-      }
-      else if (line.length() > 0)
-      {
-        desc.append(line).append(newline);
+        if (line.endsWith(SEMICOLON))
+        {
+          desc.append(line.substring(0, line.length() - 1));
+          break;
+        }
+        else if (line.length() > 0)
+        {
+          desc.append(line).append(newline);
+        }
+        line = nextNonCommentLine();
       }
-      line = nextNonCommentLine();
     }
     setAlignmentProperty(PROP_DESCRIPTION, desc.toString());
   }
@@ -891,17 +1039,17 @@ public class MegaFile extends AlignFile
     int maxSequenceLength = getMaxSequenceLength(s);
     int numLines = maxSequenceLength / positionsPerLine + 3; // approx
 
-    /*
-     * Size a buffer to hold the whole output
-     */
-    StringBuilder sb = new StringBuilder(numLines
-            * (maxIdLength + 2 + positionsPerLine));
-
     int numDataBlocks = (maxSequenceLength - 1) / positionsPerLine + 1;
     int spaceEvery = this.nucleotide != null && this.nucleotide ? 3 : 10;
     int chunksPerLine = (positionsPerLine + spaceEvery - 1) / spaceEvery;
 
     /*
+     * Roughly size a buffer to hold the whole output
+     */
+    StringBuilder sb = new StringBuilder(numLines
+            * (maxIdLength + positionsPerLine + chunksPerLine + 10));
+
+    /*
      * Output as: #Seqid CGT AGC ACT ... or blocks of 10 for peptide
      */
     int from = 0;
@@ -936,6 +1084,12 @@ public class MegaFile extends AlignFile
             advancedBy += subSequence.length;
           }
         }
+        // write last position as a comment
+        if (writePositionNumbers)
+        {
+          sb.append(SPACE).append(COMMENT_START).append(from + advancedBy)
+                  .append(COMMENT_END);
+        }
         sb.append(newline);
         first = false;
       }
@@ -958,10 +1112,8 @@ public class MegaFile extends AlignFile
     String propertyValue = (String) al.getProperty(PROP_TITLE);
     if (propertyValue != null)
     {
-      sb.append(BANG).append(TITLE).append(SPACE)
-.append(propertyValue)
-              .append(SEMICOLON)
-              .append(newline);
+      sb.append(BANG).append(TITLE).append(SPACE).append(propertyValue)
+              .append(SEMICOLON).append(newline);
     }
     propertyValue = (String) al.getProperty(PROP_DESCRIPTION);
     if (propertyValue != null)
@@ -987,14 +1139,12 @@ public class MegaFile extends AlignFile
             .append(newline);
     
     /*
-     * !Format NSeqs NSites
-     * NSites the length of any sequence (they should all be the same), excluding
-     * gaps?!?
+     * !Format NSeqs NSites (the length of sequences - they should all be the
+     * same - including gaps)
      */
     sb.append(INDENT).append(N_SEQS).append(EQUALS).append(al.getHeight());
-    SequenceI seq = al.getSequenceAt(0);
     sb.append(SPACE).append(N_SITES).append(EQUALS)
-            .append(seq.getEnd() - seq.getStart() + 1);
+            .append(String.valueOf(al.getWidth()));
     sb.append(newline);
 
     /*
@@ -1148,10 +1298,7 @@ public class MegaFile extends AlignFile
   public void addProperties(AlignmentI al)
   {
     super.addProperties(al);
-    if (this.gapCharacter != null)
-    {
-      al.setGapCharacter(gapCharacter);
-    }
+    al.setGapCharacter(gapCharacter);
     
     /*
      * warn if e.g. DataType=DNA but data is protein (or vice versa)
@@ -1172,10 +1319,27 @@ public class MegaFile extends AlignFile
   public String print(AlignmentI al)
   {
     this.nucleotide = al.isNucleotide();
+
     String lineLength = (String) al.getProperty(PROP_LINELENGTH);
     this.positionsPerLine = lineLength == null ? DEFAULT_LINE_LENGTH : Integer
             .parseInt(lineLength);
-    return printHeaders(al) + print(al.getSequencesArray());
+
+    /*
+     * round down to a multiple of 3 positions per line for nucleotide
+     */
+    if (nucleotide)
+    {
+      positionsPerLine = positionsPerLine - (positionsPerLine % 3);
+    }
+
+    String interleave = (String) al.getProperty(PROP_INTERLEAVED);
+    if (interleave != null)
+    {
+      this.interleaved = Boolean.valueOf(interleave);
+    }
+
+    String headers = printHeaders(al);
+    return headers + print(al.getSequencesArray());
   }
 
   /**