JAL-3121 round trip GFF attributes including map-valued attributes
[jalview.git] / src / jalview / io / FeaturesFile.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3  * Copyright (C) $$Year-Rel$$ 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
10  * of the License, or (at your option) any later version.
11  *  
12  * Jalview is distributed in the hope that it will be useful, but 
13  * WITHOUT ANY WARRANTY; without even the implied warranty 
14  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 
15  * PURPOSE.  See the GNU General Public License for more details.
16  * 
17  * You should have received a copy of the GNU General Public License
18  * along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
19  * The Jalview Authors are detailed in the 'AUTHORS' file.
20  */
21 package jalview.io;
22
23 import jalview.analysis.AlignmentUtils;
24 import jalview.analysis.SequenceIdMatcher;
25 import jalview.api.AlignViewportI;
26 import jalview.api.FeatureColourI;
27 import jalview.api.FeatureRenderer;
28 import jalview.api.FeaturesSourceI;
29 import jalview.datamodel.AlignedCodonFrame;
30 import jalview.datamodel.Alignment;
31 import jalview.datamodel.AlignmentI;
32 import jalview.datamodel.MappedFeatures;
33 import jalview.datamodel.SequenceDummy;
34 import jalview.datamodel.SequenceFeature;
35 import jalview.datamodel.SequenceI;
36 import jalview.datamodel.features.FeatureMatcherSet;
37 import jalview.datamodel.features.FeatureMatcherSetI;
38 import jalview.gui.Desktop;
39 import jalview.io.gff.GffHelperFactory;
40 import jalview.io.gff.GffHelperI;
41 import jalview.schemes.FeatureColour;
42 import jalview.util.ColorUtils;
43 import jalview.util.MapList;
44 import jalview.util.ParseHtmlBodyAndLinks;
45 import jalview.util.StringUtils;
46
47 import java.awt.Color;
48 import java.io.IOException;
49 import java.util.ArrayList;
50 import java.util.Arrays;
51 import java.util.Collections;
52 import java.util.HashMap;
53 import java.util.LinkedHashMap;
54 import java.util.List;
55 import java.util.Map;
56 import java.util.Map.Entry;
57 import java.util.TreeMap;
58
59 /**
60  * Parses and writes features files, which may be in Jalview, GFF2 or GFF3
61  * format. These are tab-delimited formats but with differences in the use of
62  * columns.
63  * 
64  * A Jalview feature file may define feature colours and then declare that the
65  * remainder of the file is in GFF format with the line 'GFF'.
66  * 
67  * GFF3 files may include alignment mappings for features, which Jalview will
68  * attempt to model, and may include sequence data following a ##FASTA line.
69  * 
70  * 
71  * @author AMW
72  * @author jbprocter
73  * @author gmcarstairs
74  */
75 public class FeaturesFile extends AlignFile implements FeaturesSourceI
76 {
77   /*
78    * map-valued attributes are prefixed with this for output to GFF3;
79    * the prefix is removed if found on reading
80    */
81   public static final String MAP_ATTRIBUTE_PREFIX = "jvmap_";
82
83   private static final String TAB_REGEX = "\\t";
84
85   private static final String STARTGROUP = "STARTGROUP";
86
87   private static final String ENDGROUP = "ENDGROUP";
88
89   private static final String STARTFILTERS = "STARTFILTERS";
90
91   private static final String ENDFILTERS = "ENDFILTERS";
92
93   private static final String ID_NOT_SPECIFIED = "ID_NOT_SPECIFIED";
94
95   private static final String NOTE = "Note";
96
97   protected static final String GFF_VERSION = "##gff-version";
98
99   private AlignmentI lastmatchedAl = null;
100
101   private SequenceIdMatcher matcher = null;
102
103   protected AlignmentI dataset;
104
105   protected int gffVersion;
106
107   /**
108    * Creates a new FeaturesFile object.
109    */
110   public FeaturesFile()
111   {
112   }
113
114   /**
115    * Constructor which does not parse the file immediately
116    * 
117    * @param file
118    * @param paste
119    * @throws IOException
120    */
121   public FeaturesFile(String file, DataSourceType paste)
122           throws IOException
123   {
124     super(false, file, paste);
125   }
126
127   /**
128    * @param source
129    * @throws IOException
130    */
131   public FeaturesFile(FileParse source) throws IOException
132   {
133     super(source);
134   }
135
136   /**
137    * Constructor that optionally parses the file immediately
138    * 
139    * @param parseImmediately
140    * @param file
141    * @param type
142    * @throws IOException
143    */
144   public FeaturesFile(boolean parseImmediately, String file,
145           DataSourceType type) throws IOException
146   {
147     super(parseImmediately, file, type);
148   }
149
150   /**
151    * Parse GFF or sequence features file using case-independent matching,
152    * discarding URLs
153    * 
154    * @param align
155    *          - alignment/dataset containing sequences that are to be annotated
156    * @param colours
157    *          - hashtable to store feature colour definitions
158    * @param removeHTML
159    *          - process html strings into plain text
160    * @return true if features were added
161    */
162   public boolean parse(AlignmentI align,
163           Map<String, FeatureColourI> colours, boolean removeHTML)
164   {
165     return parse(align, colours, removeHTML, false);
166   }
167
168   /**
169    * Extends the default addProperties by also adding peptide-to-cDNA mappings
170    * (if any) derived while parsing a GFF file
171    */
172   @Override
173   public void addProperties(AlignmentI al)
174   {
175     super.addProperties(al);
176     if (dataset != null && dataset.getCodonFrames() != null)
177     {
178       AlignmentI ds = (al.getDataset() == null) ? al : al.getDataset();
179       for (AlignedCodonFrame codons : dataset.getCodonFrames())
180       {
181         ds.addCodonFrame(codons);
182       }
183     }
184   }
185
186   /**
187    * Parse GFF or Jalview format sequence features file
188    * 
189    * @param align
190    *          - alignment/dataset containing sequences that are to be annotated
191    * @param colours
192    *          - map to store feature colour definitions
193    * @param removeHTML
194    *          - process html strings into plain text
195    * @param relaxedIdmatching
196    *          - when true, ID matches to compound sequence IDs are allowed
197    * @return true if features were added
198    */
199   public boolean parse(AlignmentI align,
200           Map<String, FeatureColourI> colours, boolean removeHTML,
201           boolean relaxedIdmatching)
202   {
203     return parse(align, colours, null, removeHTML, relaxedIdmatching);
204   }
205
206   /**
207    * Parse GFF or Jalview format sequence features file
208    * 
209    * @param align
210    *          - alignment/dataset containing sequences that are to be annotated
211    * @param colours
212    *          - map to store feature colour definitions
213    * @param filters
214    *          - map to store feature filter definitions
215    * @param removeHTML
216    *          - process html strings into plain text
217    * @param relaxedIdmatching
218    *          - when true, ID matches to compound sequence IDs are allowed
219    * @return true if features were added
220    */
221   public boolean parse(AlignmentI align,
222           Map<String, FeatureColourI> colours,
223           Map<String, FeatureMatcherSetI> filters, boolean removeHTML,
224           boolean relaxedIdmatching)
225   {
226     Map<String, String> gffProps = new HashMap<>();
227     /*
228      * keep track of any sequences we try to create from the data
229      */
230     List<SequenceI> newseqs = new ArrayList<>();
231
232     String line = null;
233     try
234     {
235       String[] gffColumns;
236       String featureGroup = null;
237
238       while ((line = nextLine()) != null)
239       {
240         // skip comments/process pragmas
241         if (line.length() == 0 || line.startsWith("#"))
242         {
243           if (line.toLowerCase().startsWith("##"))
244           {
245             processGffPragma(line, gffProps, align, newseqs);
246           }
247           continue;
248         }
249
250         gffColumns = line.split(TAB_REGEX);
251         if (gffColumns.length == 1)
252         {
253           if (line.trim().equalsIgnoreCase("GFF"))
254           {
255             /*
256              * Jalview features file with appended GFF
257              * assume GFF2 (though it may declare ##gff-version 3)
258              */
259             gffVersion = 2;
260             continue;
261           }
262         }
263
264         if (gffColumns.length > 0 && gffColumns.length < 4)
265         {
266           /*
267            * if 2 or 3 tokens, we anticipate either 'startgroup', 'endgroup' or
268            * a feature type colour specification
269            */
270           String ft = gffColumns[0];
271           if (ft.equalsIgnoreCase(STARTFILTERS))
272           {
273             parseFilters(filters);
274             continue;
275           }
276           if (ft.equalsIgnoreCase(STARTGROUP))
277           {
278             featureGroup = gffColumns[1];
279           }
280           else if (ft.equalsIgnoreCase(ENDGROUP))
281           {
282             // We should check whether this is the current group,
283             // but at present there's no way of showing more than 1 group
284             featureGroup = null;
285           }
286           else
287           {
288             String colscheme = gffColumns[1];
289             FeatureColourI colour = FeatureColour
290                     .parseJalviewFeatureColour(colscheme);
291             if (colour != null)
292             {
293               colours.put(ft, colour);
294             }
295           }
296           continue;
297         }
298
299         /*
300          * if not a comment, GFF pragma, startgroup, endgroup or feature
301          * colour specification, that just leaves a feature details line
302          * in either Jalview or GFF format
303          */
304         if (gffVersion == 0)
305         {
306           parseJalviewFeature(line, gffColumns, align, colours, removeHTML,
307                   relaxedIdmatching, featureGroup);
308         }
309         else
310         {
311           parseGff(gffColumns, align, relaxedIdmatching, newseqs);
312         }
313       }
314       resetMatcher();
315     } catch (Exception ex)
316     {
317       // should report somewhere useful for UI if necessary
318       warningMessage = ((warningMessage == null) ? "" : warningMessage)
319               + "Parsing error at\n" + line;
320       System.out.println("Error parsing feature file: " + ex + "\n" + line);
321       ex.printStackTrace(System.err);
322       resetMatcher();
323       return false;
324     }
325
326     /*
327      * experimental - add any dummy sequences with features to the alignment
328      * - we need them for Ensembl feature extraction - though maybe not otherwise
329      */
330     for (SequenceI newseq : newseqs)
331     {
332       if (newseq.getFeatures().hasFeatures())
333       {
334         align.addSequence(newseq);
335       }
336     }
337     return true;
338   }
339
340   /**
341    * Reads input lines from STARTFILTERS to ENDFILTERS and adds a feature type
342    * filter to the map for each line parsed. After exit from this method,
343    * nextLine() should return the line after ENDFILTERS (or we are already at
344    * end of file if ENDFILTERS was missing).
345    * 
346    * @param filters
347    * @throws IOException
348    */
349   protected void parseFilters(Map<String, FeatureMatcherSetI> filters)
350           throws IOException
351   {
352     String line;
353     while ((line = nextLine()) != null)
354     {
355       if (line.toUpperCase().startsWith(ENDFILTERS))
356       {
357         return;
358       }
359       String[] tokens = line.split(TAB_REGEX);
360       if (tokens.length != 2)
361       {
362         System.err.println(String.format("Invalid token count %d for %d",
363                 tokens.length, line));
364       }
365       else
366       {
367         String featureType = tokens[0];
368         FeatureMatcherSetI fm = FeatureMatcherSet.fromString(tokens[1]);
369         if (fm != null && filters != null)
370         {
371           filters.put(featureType, fm);
372         }
373       }
374     }
375   }
376
377   /**
378    * Try to parse a Jalview format feature specification and add it as a
379    * sequence feature to any matching sequences in the alignment. Returns true
380    * if successful (a feature was added), or false if not.
381    * 
382    * @param line
383    * @param gffColumns
384    * @param alignment
385    * @param featureColours
386    * @param removeHTML
387    * @param relaxedIdmatching
388    * @param featureGroup
389    */
390   protected boolean parseJalviewFeature(String line, String[] gffColumns,
391           AlignmentI alignment, Map<String, FeatureColourI> featureColours,
392           boolean removeHTML, boolean relaxedIdMatching,
393           String featureGroup)
394   {
395     /*
396      * tokens: description seqid seqIndex start end type [score]
397      */
398     if (gffColumns.length < 6)
399     {
400       System.err.println("Ignoring feature line '" + line
401               + "' with too few columns (" + gffColumns.length + ")");
402       return false;
403     }
404     String desc = gffColumns[0];
405     String seqId = gffColumns[1];
406     SequenceI seq = findSequence(seqId, alignment, null, relaxedIdMatching);
407
408     if (!ID_NOT_SPECIFIED.equals(seqId))
409     {
410       seq = findSequence(seqId, alignment, null, relaxedIdMatching);
411     }
412     else
413     {
414       seqId = null;
415       seq = null;
416       String seqIndex = gffColumns[2];
417       try
418       {
419         int idx = Integer.parseInt(seqIndex);
420         seq = alignment.getSequenceAt(idx);
421       } catch (NumberFormatException ex)
422       {
423         System.err.println("Invalid sequence index: " + seqIndex);
424       }
425     }
426
427     if (seq == null)
428     {
429       System.out.println("Sequence not found: " + line);
430       return false;
431     }
432
433     int startPos = Integer.parseInt(gffColumns[3]);
434     int endPos = Integer.parseInt(gffColumns[4]);
435
436     String ft = gffColumns[5];
437
438     if (!featureColours.containsKey(ft))
439     {
440       /* 
441        * Perhaps an old style groups file with no colours -
442        * synthesize a colour from the feature type
443        */
444       Color colour = ColorUtils.createColourFromName(ft);
445       featureColours.put(ft, new FeatureColour(colour));
446     }
447     SequenceFeature sf = null;
448     if (gffColumns.length > 6)
449     {
450       float score = Float.NaN;
451       try
452       {
453         score = Float.valueOf(gffColumns[6]).floatValue();
454       } catch (NumberFormatException ex)
455       {
456         sf = new SequenceFeature(ft, desc, startPos, endPos, featureGroup);
457       }
458       sf = new SequenceFeature(ft, desc, startPos, endPos, score,
459               featureGroup);
460     }
461     else
462     {
463       sf = new SequenceFeature(ft, desc, startPos, endPos, featureGroup);
464     }
465
466     parseDescriptionHTML(sf, removeHTML);
467
468     seq.addSequenceFeature(sf);
469
470     while (seqId != null
471             && (seq = alignment.findName(seq, seqId, false)) != null)
472     {
473       seq.addSequenceFeature(new SequenceFeature(sf));
474     }
475     return true;
476   }
477
478   /**
479    * clear any temporary handles used to speed up ID matching
480    */
481   protected void resetMatcher()
482   {
483     lastmatchedAl = null;
484     matcher = null;
485   }
486
487   /**
488    * Returns a sequence matching the given id, as follows
489    * <ul>
490    * <li>strict matching is on exact sequence name</li>
491    * <li>relaxed matching allows matching on a token within the sequence name,
492    * or a dbxref</li>
493    * <li>first tries to find a match in the alignment sequences</li>
494    * <li>else tries to find a match in the new sequences already generated while
495    * parsing the features file</li>
496    * <li>else creates a new placeholder sequence, adds it to the new sequences
497    * list, and returns it</li>
498    * </ul>
499    * 
500    * @param seqId
501    * @param align
502    * @param newseqs
503    * @param relaxedIdMatching
504    * 
505    * @return
506    */
507   protected SequenceI findSequence(String seqId, AlignmentI align,
508           List<SequenceI> newseqs, boolean relaxedIdMatching)
509   {
510     // TODO encapsulate in SequenceIdMatcher, share the matcher
511     // with the GffHelper (removing code duplication)
512     SequenceI match = null;
513     if (relaxedIdMatching)
514     {
515       if (lastmatchedAl != align)
516       {
517         lastmatchedAl = align;
518         matcher = new SequenceIdMatcher(align.getSequencesArray());
519         if (newseqs != null)
520         {
521           matcher.addAll(newseqs);
522         }
523       }
524       match = matcher.findIdMatch(seqId);
525     }
526     else
527     {
528       match = align.findName(seqId, true);
529       if (match == null && newseqs != null)
530       {
531         for (SequenceI m : newseqs)
532         {
533           if (seqId.equals(m.getName()))
534           {
535             return m;
536           }
537         }
538       }
539
540     }
541     if (match == null && newseqs != null)
542     {
543       match = new SequenceDummy(seqId);
544       if (relaxedIdMatching)
545       {
546         matcher.addAll(Arrays.asList(new SequenceI[] { match }));
547       }
548       // add dummy sequence to the newseqs list
549       newseqs.add(match);
550     }
551     return match;
552   }
553
554   public void parseDescriptionHTML(SequenceFeature sf, boolean removeHTML)
555   {
556     if (sf.getDescription() == null)
557     {
558       return;
559     }
560     ParseHtmlBodyAndLinks parsed = new ParseHtmlBodyAndLinks(
561             sf.getDescription(), removeHTML, newline);
562
563     if (removeHTML)
564     {
565       sf.setDescription(parsed.getNonHtmlContent());
566     }
567
568     for (String link : parsed.getLinks())
569     {
570       sf.addLink(link);
571     }
572   }
573
574   /**
575    * Returns contents of a Jalview format features file, for visible features, as
576    * filtered by type and group. Features with a null group are displayed if their
577    * feature type is visible. Non-positional features may optionally be included
578    * (with no check on type or group).
579    * 
580    * @param sequences
581    * @param fr
582    * @param includeNonPositional
583    *                               if true, include non-positional features
584    *                               (regardless of group or type)
585    * @param includeComplement
586    *                               if true, include visible complementary
587    *                               (CDS/protein) positional features, with
588    *                               locations converted to local sequence
589    *                               coordinates
590    * @return
591    */
592   public String printJalviewFormat(SequenceI[] sequences,
593           FeatureRenderer fr, boolean includeNonPositional,
594           boolean includeComplement)
595   {
596     Map<String, FeatureColourI> visibleColours = fr
597             .getDisplayedFeatureCols();
598     Map<String, FeatureMatcherSetI> featureFilters = fr.getFeatureFilters();
599
600     /*
601      * write out feature colours (if we know them)
602      */
603     // TODO: decide if feature links should also be written here ?
604     StringBuilder out = new StringBuilder(256);
605     if (visibleColours != null)
606     {
607       for (Entry<String, FeatureColourI> featureColour : visibleColours
608               .entrySet())
609       {
610         FeatureColourI colour = featureColour.getValue();
611         out.append(colour.toJalviewFormat(featureColour.getKey())).append(
612                 newline);
613       }
614     }
615
616     String[] types = visibleColours == null ? new String[0]
617             : visibleColours.keySet()
618                     .toArray(new String[visibleColours.keySet().size()]);
619
620     /*
621      * feature filters if any
622      */
623     outputFeatureFilters(out, visibleColours, featureFilters);
624
625     /*
626      * output features within groups
627      */
628     int count = outputFeaturesByGroup(out, fr, types, sequences,
629             includeNonPositional);
630
631     if (includeComplement)
632     {
633       count += outputComplementFeatures(out, fr, sequences);
634     }
635
636     return count > 0 ? out.toString() : "No Features Visible";
637   }
638
639   /**
640    * Outputs any visible complementary (CDS/peptide) positional features as
641    * Jalview format, within feature group. The coordinates of the linked features
642    * are converted to the corresponding positions of the local sequences.
643    * 
644    * @param out
645    * @param fr
646    * @param sequences
647    * @return
648    */
649   private int outputComplementFeatures(StringBuilder out,
650           FeatureRenderer fr, SequenceI[] sequences)
651   {
652     AlignViewportI comp = fr.getViewport().getCodingComplement();
653     FeatureRenderer fr2 = Desktop.getAlignFrameFor(comp)
654             .getFeatureRenderer();
655
656     /*
657      * bin features by feature group and sequence
658      */
659     Map<String, Map<String, List<SequenceFeature>>> map = new TreeMap<>(
660             String.CASE_INSENSITIVE_ORDER);
661     int count = 0;
662
663     for (SequenceI seq : sequences)
664     {
665       /*
666        * find complementary features
667        */
668       List<SequenceFeature> complementary = findComplementaryFeatures(seq,
669               fr2);
670       String seqName = seq.getName();
671
672       for (SequenceFeature sf : complementary)
673       {
674         String group = sf.getFeatureGroup();
675         if (!map.containsKey(group))
676         {
677           map.put(group, new LinkedHashMap<>()); // preserves sequence order
678         }
679         Map<String, List<SequenceFeature>> groupFeatures = map.get(group);
680         if (!groupFeatures.containsKey(seqName))
681         {
682           groupFeatures.put(seqName, new ArrayList<>());
683         }
684         List<SequenceFeature> foundFeatures = groupFeatures.get(seqName);
685         foundFeatures.add(sf);
686         count++;
687       }
688     }
689
690     /*
691      * output features by group
692      */
693     for (Entry<String, Map<String, List<SequenceFeature>>> groupFeatures : map.entrySet())
694     {
695       out.append(newline);
696       String group = groupFeatures.getKey();
697       if (!"".equals(group))
698       {
699         out.append(STARTGROUP).append(TAB).append(group).append(newline);
700       }
701       Map<String, List<SequenceFeature>> seqFeaturesMap = groupFeatures
702               .getValue();
703       for (Entry<String, List<SequenceFeature>> seqFeatures : seqFeaturesMap
704               .entrySet())
705       {
706         String sequenceName = seqFeatures.getKey();
707         for (SequenceFeature sf : seqFeatures.getValue())
708         {
709           formatJalviewFeature(out, sequenceName, sf);
710         }
711       }
712       if (!"".equals(group))
713       {
714         out.append(ENDGROUP).append(TAB).append(group).append(newline);
715       }
716     }
717
718     return count;
719   }
720
721   /**
722    * Answers a list of mapped features visible in the (CDS/protein) complement,
723    * with feature positions translated to local sequence coordinates
724    * 
725    * @param seq
726    * @param fr2
727    * @return
728    */
729   protected List<SequenceFeature> findComplementaryFeatures(SequenceI seq,
730           FeatureRenderer fr2)
731   {
732     /*
733      * avoid duplication of features (e.g. peptide feature 
734      * at all 3 mapped codon positions)
735      */
736     List<SequenceFeature> found = new ArrayList<>();
737     List<SequenceFeature> complementary = new ArrayList<>();
738
739     for (int pos = seq.getStart(); pos <= seq.getEnd(); pos++)
740     {
741       MappedFeatures mf = fr2.findComplementFeaturesAtResidue(seq, pos);
742
743       if (mf != null)
744       {
745         MapList mapping = mf.mapping.getMap();
746         for (SequenceFeature sf : mf.features)
747         {
748           /*
749            * make a virtual feature with local coordinates
750            */
751           if (!found.contains(sf))
752           {
753             String group = sf.getFeatureGroup();
754             if (group == null)
755             {
756               group = "";
757             }
758             found.add(sf);
759             int begin = sf.getBegin();
760             int end = sf.getEnd();
761             int[] range = mf.mapping.getTo() == seq.getDatasetSequence()
762                     ? mapping.locateInTo(begin, end)
763                     : mapping.locateInFrom(begin, end);
764             SequenceFeature sf2 = new SequenceFeature(sf, range[0],
765                     range[1], group, sf.getScore());
766             complementary.add(sf2);
767           }
768         }
769       }
770     }
771
772     return complementary;
773   }
774
775   /**
776    * Outputs any feature filters defined for visible feature types, sandwiched by
777    * STARTFILTERS and ENDFILTERS lines
778    * 
779    * @param out
780    * @param visible
781    * @param featureFilters
782    */
783   void outputFeatureFilters(StringBuilder out,
784           Map<String, FeatureColourI> visible,
785           Map<String, FeatureMatcherSetI> featureFilters)
786   {
787     if (visible == null || featureFilters == null
788             || featureFilters.isEmpty())
789     {
790       return;
791     }
792
793     boolean first = true;
794     for (String featureType : visible.keySet())
795     {
796       FeatureMatcherSetI filter = featureFilters.get(featureType);
797       if (filter != null)
798       {
799         if (first)
800         {
801           first = false;
802           out.append(newline).append(STARTFILTERS).append(newline);
803         }
804         out.append(featureType).append(TAB).append(filter.toStableString())
805                 .append(newline);
806       }
807     }
808     if (!first)
809     {
810       out.append(ENDFILTERS).append(newline);
811     }
812
813   }
814
815   /**
816    * Appends output of visible sequence features within feature groups to the
817    * output buffer. Groups other than the null or empty group are sandwiched by
818    * STARTGROUP and ENDGROUP lines. Answers the number of features written.
819    * 
820    * @param out
821    * @param fr
822    * @param featureTypes
823    * @param sequences
824    * @param includeNonPositional
825    * @return
826    */
827   private int outputFeaturesByGroup(StringBuilder out,
828           FeatureRenderer fr, String[] featureTypes,
829           SequenceI[] sequences, boolean includeNonPositional)
830   {
831     List<String> featureGroups = fr.getFeatureGroups();
832
833     /*
834      * sort groups alphabetically, and ensure that features with a
835      * null or empty group are output after those in named groups
836      */
837     List<String> sortedGroups = new ArrayList<>(featureGroups);
838     sortedGroups.remove(null);
839     sortedGroups.remove("");
840     Collections.sort(sortedGroups);
841     sortedGroups.add(null);
842     sortedGroups.add("");
843
844     int count = 0;
845     List<String> visibleGroups = fr.getDisplayedFeatureGroups();
846
847     /*
848      * loop over all groups (may be visible or not);
849      * non-positional features are output even if group is not visible
850      */
851     for (String group : sortedGroups)
852     {
853       boolean firstInGroup = true;
854       boolean isNullGroup = group == null || "".equals(group);
855
856       for (int i = 0; i < sequences.length; i++)
857       {
858         String sequenceName = sequences[i].getName();
859         List<SequenceFeature> features = new ArrayList<>();
860
861         /*
862          * get any non-positional features in this group, if wanted
863          * (for any feature type, whether visible or not)
864          */
865         if (includeNonPositional)
866         {
867           features.addAll(sequences[i].getFeatures()
868                   .getFeaturesForGroup(false, group));
869         }
870
871         /*
872          * add positional features for visible feature types, but
873          * (for named groups) only if feature group is visible
874          */
875         if (featureTypes.length > 0
876                 && (isNullGroup || visibleGroups.contains(group)))
877         {
878           features.addAll(sequences[i].getFeatures().getFeaturesForGroup(
879                   true, group, featureTypes));
880         }
881
882         for (SequenceFeature sf : features)
883         {
884           if (sf.isNonPositional() || fr.isVisible(sf))
885           {
886             count++;
887             if (firstInGroup)
888             {
889               out.append(newline);
890               if (!isNullGroup)
891               {
892                 out.append(STARTGROUP).append(TAB).append(group)
893                         .append(newline);
894               }
895             }
896             firstInGroup = false;
897             formatJalviewFeature(out, sequenceName, sf);
898           }
899         }
900       }
901
902       if (!isNullGroup && !firstInGroup)
903       {
904         out.append(ENDGROUP).append(TAB).append(group).append(newline);
905       }
906     }
907     return count;
908   }
909
910   /**
911    * Formats one feature in Jalview format and appends to the string buffer
912    * 
913    * @param out
914    * @param sequenceName
915    * @param sequenceFeature
916    */
917   protected void formatJalviewFeature(
918           StringBuilder out, String sequenceName,
919           SequenceFeature sequenceFeature)
920   {
921     if (sequenceFeature.description == null
922             || sequenceFeature.description.equals(""))
923     {
924       out.append(sequenceFeature.type).append(TAB);
925     }
926     else
927     {
928       if (sequenceFeature.links != null
929               && sequenceFeature.getDescription().indexOf("<html>") == -1)
930       {
931         out.append("<html>");
932       }
933
934       out.append(sequenceFeature.description);
935       if (sequenceFeature.links != null)
936       {
937         for (int l = 0; l < sequenceFeature.links.size(); l++)
938         {
939           String label = sequenceFeature.links.elementAt(l);
940           String href = label.substring(label.indexOf("|") + 1);
941           label = label.substring(0, label.indexOf("|"));
942
943           if (sequenceFeature.description.indexOf(href) == -1)
944           {
945             out.append(" <a href=\"").append(href).append("\">")
946                     .append(label).append("</a>");
947           }
948         }
949
950         if (sequenceFeature.getDescription().indexOf("</html>") == -1)
951         {
952           out.append("</html>");
953         }
954       }
955
956       out.append(TAB);
957     }
958     out.append(sequenceName);
959     out.append("\t-1\t");
960     out.append(sequenceFeature.begin);
961     out.append(TAB);
962     out.append(sequenceFeature.end);
963     out.append(TAB);
964     out.append(sequenceFeature.type);
965     if (!Float.isNaN(sequenceFeature.score))
966     {
967       out.append(TAB);
968       out.append(sequenceFeature.score);
969     }
970     out.append(newline);
971   }
972
973   /**
974    * Parse method that is called when a GFF file is dragged to the desktop
975    */
976   @Override
977   public void parse()
978   {
979     AlignViewportI av = getViewport();
980     if (av != null)
981     {
982       if (av.getAlignment() != null)
983       {
984         dataset = av.getAlignment().getDataset();
985       }
986       if (dataset == null)
987       {
988         // working in the applet context ?
989         dataset = av.getAlignment();
990       }
991     }
992     else
993     {
994       dataset = new Alignment(new SequenceI[] {});
995     }
996
997     Map<String, FeatureColourI> featureColours = new HashMap<>();
998     boolean parseResult = parse(dataset, featureColours, false, true);
999     if (!parseResult)
1000     {
1001       // pass error up somehow
1002     }
1003     if (av != null)
1004     {
1005       // update viewport with the dataset data ?
1006     }
1007     else
1008     {
1009       setSeqs(dataset.getSequencesArray());
1010     }
1011   }
1012
1013   /**
1014    * Implementation of unused abstract method
1015    * 
1016    * @return error message
1017    */
1018   @Override
1019   public String print(SequenceI[] sqs, boolean jvsuffix)
1020   {
1021     System.out.println("Use printGffFormat() or printJalviewFormat()");
1022     return null;
1023   }
1024
1025   /**
1026    * Returns features output in GFF2 format
1027    * 
1028    * @param sequences
1029    *                                       the sequences whose features are to be
1030    *                                       output
1031    * @param visible
1032    *                                       a map whose keys are the type names of
1033    *                                       visible features
1034    * @param visibleFeatureGroups
1035    * @param includeNonPositionalFeatures
1036    * @param includeComplement
1037    * @return
1038    */
1039   public String printGffFormat(SequenceI[] sequences,
1040           FeatureRenderer fr, boolean includeNonPositionalFeatures,
1041           boolean includeComplement)
1042   {
1043     FeatureRenderer fr2 = null;
1044     if (includeComplement)
1045     {
1046       AlignViewportI comp = fr.getViewport().getCodingComplement();
1047       fr2 = Desktop.getAlignFrameFor(comp).getFeatureRenderer();
1048     }
1049
1050     Map<String, FeatureColourI> visibleColours = fr.getDisplayedFeatureCols();
1051
1052     StringBuilder out = new StringBuilder(256);
1053
1054     out.append(String.format("%s %d\n", GFF_VERSION, gffVersion == 0 ? 2 : gffVersion));
1055
1056     String[] types = visibleColours == null ? new String[0]
1057             : visibleColours.keySet()
1058                     .toArray(new String[visibleColours.keySet().size()]);
1059
1060     for (SequenceI seq : sequences)
1061     {
1062       List<SequenceFeature> seqFeatures = new ArrayList<>();
1063       List<SequenceFeature> features = new ArrayList<>();
1064       if (includeNonPositionalFeatures)
1065       {
1066         features.addAll(seq.getFeatures().getNonPositionalFeatures());
1067       }
1068       if (visibleColours != null && !visibleColours.isEmpty())
1069       {
1070         features.addAll(seq.getFeatures().getPositionalFeatures(types));
1071       }
1072       for (SequenceFeature sf : features)
1073       {
1074         if (sf.isNonPositional() || fr.isVisible(sf))
1075         {
1076           /*
1077            * drop features hidden by group visibility, colour threshold,
1078            * or feature filter condition
1079            */
1080           seqFeatures.add(sf);
1081         }
1082       }
1083
1084       if (includeComplement)
1085       {
1086         seqFeatures.addAll(findComplementaryFeatures(seq, fr2));
1087       }
1088
1089       /*
1090        * sort features here if wanted
1091        */
1092       for (SequenceFeature sf : seqFeatures)
1093       {
1094         formatGffFeature(out, seq, sf);
1095         out.append(newline);
1096       }
1097     }
1098
1099     return out.toString();
1100   }
1101
1102   /**
1103    * Formats one feature as GFF and appends to the string buffer
1104    */
1105   private void formatGffFeature(StringBuilder out, SequenceI seq,
1106           SequenceFeature sf)
1107   {
1108     String source = sf.featureGroup;
1109     if (source == null)
1110     {
1111       source = sf.getDescription();
1112     }
1113
1114     out.append(seq.getName());
1115     out.append(TAB);
1116     out.append(source);
1117     out.append(TAB);
1118     out.append(sf.type);
1119     out.append(TAB);
1120     out.append(sf.begin);
1121     out.append(TAB);
1122     out.append(sf.end);
1123     out.append(TAB);
1124     out.append(sf.score);
1125     out.append(TAB);
1126
1127     int strand = sf.getStrand();
1128     out.append(strand == 1 ? "+" : (strand == -1 ? "-" : "."));
1129     out.append(TAB);
1130
1131     String phase = sf.getPhase();
1132     out.append(phase == null ? "." : phase);
1133
1134     if (sf.otherDetails != null && !sf.otherDetails.isEmpty())
1135     {
1136       Map<String, Object> map = sf.otherDetails;
1137       formatAttributes(out, map);
1138     }
1139   }
1140
1141   /**
1142    * A helper method that outputs attributes stored in the map as
1143    * semicolon-delimited values e.g.
1144    * 
1145    * <pre>
1146    * AC_Male=0;AF_NFE=0.00000e 00;Hom_FIN=0;GQ_MEDIAN=9
1147    * </pre>
1148    * 
1149    * A map-valued attribute is formatted as a comma-delimited list within braces,
1150    * for example
1151    * 
1152    * <pre>
1153    * jvmap_CSQ={ALLELE_NUM=1,UNIPARC=UPI0002841053,Feature=ENST00000585561}
1154    * </pre>
1155    * 
1156    * The {@code jvmap_} prefix designates a values map and is removed if the value
1157    * is parsed when read in. (The GFF3 specification allows 'semi-structured data'
1158    * to be represented provided the attribute name begins with a lower case
1159    * letter.)
1160    * 
1161    * @param sb
1162    * @param map
1163    * @see http://gmod.org/wiki/GFF3#GFF3_Format
1164    */
1165   void formatAttributes(StringBuilder sb, Map<String, Object> map)
1166   {
1167     sb.append(TAB);
1168     boolean first = true;
1169     for (String key : map.keySet())
1170     {
1171       if (SequenceFeature.STRAND.equals(key)
1172               || SequenceFeature.PHASE.equals(key))
1173       {
1174         /*
1175          * values stashed in map but output to their own columns
1176          */
1177         continue;
1178       }
1179       {
1180         if (!first)
1181         {
1182           sb.append(";");
1183         }
1184       }
1185       first = false;
1186       Object value = map.get(key);
1187       if (value instanceof Map<?, ?>)
1188       {
1189         formatMapAttribute(sb, key, (Map<?, ?>) value);
1190       }
1191       else
1192       {
1193         String formatted = StringUtils.urlEncode(value.toString(),
1194                 GffHelperI.GFF_ENCODABLE);
1195         sb.append(key).append("=").append(formatted);
1196       }
1197     }
1198   }
1199
1200   /**
1201    * Formats the map entries as
1202    * 
1203    * <pre>
1204    * jvmap_key={key1=value1,key2=value2,...}
1205    * </pre>
1206    * 
1207    * and appends this to the string buffer
1208    * 
1209    * @param sb
1210    * @param key
1211    * @param map
1212    */
1213   private void formatMapAttribute(StringBuilder sb, String key,
1214           Map<?, ?> map)
1215   {
1216     if (map == null || map.isEmpty())
1217     {
1218       return;
1219     }
1220
1221     /*
1222      * AbstractMap.toString would be a shortcut here, but more reliable
1223      * to code the required format in case toString changes in future
1224      */
1225     sb.append(MAP_ATTRIBUTE_PREFIX).append(key).append("={");
1226     boolean first = true;
1227     for (Entry<?, ?> entry : map.entrySet())
1228     {
1229       if (!first)
1230       {
1231         sb.append(",");
1232       }
1233       first = false;
1234       sb.append(entry.getKey().toString()).append("=");
1235       String formatted = StringUtils.urlEncode(entry.getValue().toString(),
1236               GffHelperI.GFF_ENCODABLE);
1237       sb.append(formatted);
1238     }
1239     sb.append("}");
1240   }
1241
1242   /**
1243    * Returns a mapping given list of one or more Align descriptors (exonerate
1244    * format)
1245    * 
1246    * @param alignedRegions
1247    *                         a list of "Align fromStart toStart fromCount"
1248    * @param mapIsFromCdna
1249    *                         if true, 'from' is dna, else 'from' is protein
1250    * @param strand
1251    *                         either 1 (forward) or -1 (reverse)
1252    * @return
1253    * @throws IOException
1254    */
1255   protected MapList constructCodonMappingFromAlign(
1256           List<String> alignedRegions, boolean mapIsFromCdna, int strand)
1257           throws IOException
1258   {
1259     if (strand == 0)
1260     {
1261       throw new IOException(
1262               "Invalid strand for a codon mapping (cannot be 0)");
1263     }
1264     int regions = alignedRegions.size();
1265     // arrays to hold [start, end] for each aligned region
1266     int[] fromRanges = new int[regions * 2]; // from dna
1267     int[] toRanges = new int[regions * 2]; // to protein
1268     int fromRangesIndex = 0;
1269     int toRangesIndex = 0;
1270
1271     for (String range : alignedRegions)
1272     {
1273       /* 
1274        * Align mapFromStart mapToStart mapFromCount
1275        * e.g. if mapIsFromCdna
1276        *     Align 11270 143 120
1277        * means:
1278        *     120 bases from pos 11270 align to pos 143 in peptide
1279        * if !mapIsFromCdna this would instead be
1280        *     Align 143 11270 40 
1281        */
1282       String[] tokens = range.split(" ");
1283       if (tokens.length != 3)
1284       {
1285         throw new IOException("Wrong number of fields for Align");
1286       }
1287       int fromStart = 0;
1288       int toStart = 0;
1289       int fromCount = 0;
1290       try
1291       {
1292         fromStart = Integer.parseInt(tokens[0]);
1293         toStart = Integer.parseInt(tokens[1]);
1294         fromCount = Integer.parseInt(tokens[2]);
1295       } catch (NumberFormatException nfe)
1296       {
1297         throw new IOException(
1298                 "Invalid number in Align field: " + nfe.getMessage());
1299       }
1300
1301       /*
1302        * Jalview always models from dna to protein, so adjust values if the
1303        * GFF mapping is from protein to dna
1304        */
1305       if (!mapIsFromCdna)
1306       {
1307         fromCount *= 3;
1308         int temp = fromStart;
1309         fromStart = toStart;
1310         toStart = temp;
1311       }
1312       fromRanges[fromRangesIndex++] = fromStart;
1313       fromRanges[fromRangesIndex++] = fromStart + strand * (fromCount - 1);
1314
1315       /*
1316        * If a codon has an intron gap, there will be contiguous 'toRanges';
1317        * this is handled for us by the MapList constructor. 
1318        * (It is not clear that exonerate ever generates this case)  
1319        */
1320       toRanges[toRangesIndex++] = toStart;
1321       toRanges[toRangesIndex++] = toStart + (fromCount - 1) / 3;
1322     }
1323
1324     return new MapList(fromRanges, toRanges, 3, 1);
1325   }
1326
1327   /**
1328    * Parse a GFF format feature. This may include creating a 'dummy' sequence to
1329    * hold the feature, or for its mapped sequence, or both, to be resolved
1330    * either later in the GFF file (##FASTA section), or when the user loads
1331    * additional sequences.
1332    * 
1333    * @param gffColumns
1334    * @param alignment
1335    * @param relaxedIdMatching
1336    * @param newseqs
1337    * @return
1338    */
1339   protected SequenceI parseGff(String[] gffColumns, AlignmentI alignment,
1340           boolean relaxedIdMatching, List<SequenceI> newseqs)
1341   {
1342     /*
1343      * GFF: seqid source type start end score strand phase [attributes]
1344      */
1345     if (gffColumns.length < 5)
1346     {
1347       System.err.println("Ignoring GFF feature line with too few columns ("
1348               + gffColumns.length + ")");
1349       return null;
1350     }
1351
1352     /*
1353      * locate referenced sequence in alignment _or_ 
1354      * as a forward or external reference (SequenceDummy)
1355      */
1356     String seqId = gffColumns[0];
1357     SequenceI seq = findSequence(seqId, alignment, newseqs,
1358             relaxedIdMatching);
1359
1360     SequenceFeature sf = null;
1361     GffHelperI helper = GffHelperFactory.getHelper(gffColumns);
1362     if (helper != null)
1363     {
1364       try
1365       {
1366         sf = helper.processGff(seq, gffColumns, alignment, newseqs,
1367                 relaxedIdMatching);
1368         if (sf != null)
1369         {
1370           seq.addSequenceFeature(sf);
1371           while ((seq = alignment.findName(seq, seqId, true)) != null)
1372           {
1373             seq.addSequenceFeature(new SequenceFeature(sf));
1374           }
1375         }
1376       } catch (IOException e)
1377       {
1378         System.err.println("GFF parsing failed with: " + e.getMessage());
1379         return null;
1380       }
1381     }
1382
1383     return seq;
1384   }
1385
1386   /**
1387    * After encountering ##fasta in a GFF3 file, process the remainder of the
1388    * file as FAST sequence data. Any placeholder sequences created during
1389    * feature parsing are updated with the actual sequences.
1390    * 
1391    * @param align
1392    * @param newseqs
1393    * @throws IOException
1394    */
1395   protected void processAsFasta(AlignmentI align, List<SequenceI> newseqs)
1396           throws IOException
1397   {
1398     try
1399     {
1400       mark();
1401     } catch (IOException q)
1402     {
1403     }
1404     FastaFile parser = new FastaFile(this);
1405     List<SequenceI> includedseqs = parser.getSeqs();
1406
1407     SequenceIdMatcher smatcher = new SequenceIdMatcher(newseqs);
1408
1409     /*
1410      * iterate over includedseqs, and replacing matching ones with newseqs
1411      * sequences. Generic iterator not used here because we modify
1412      * includedseqs as we go
1413      */
1414     for (int p = 0, pSize = includedseqs.size(); p < pSize; p++)
1415     {
1416       // search for any dummy seqs that this sequence can be used to update
1417       SequenceI includedSeq = includedseqs.get(p);
1418       SequenceI dummyseq = smatcher.findIdMatch(includedSeq);
1419       if (dummyseq != null && dummyseq instanceof SequenceDummy)
1420       {
1421         // probably have the pattern wrong
1422         // idea is that a flyweight proxy for a sequence ID can be created for
1423         // 1. stable reference creation
1424         // 2. addition of annotation
1425         // 3. future replacement by a real sequence
1426         // current pattern is to create SequenceDummy objects - a convenience
1427         // constructor for a Sequence.
1428         // problem is that when promoted to a real sequence, all references
1429         // need to be updated somehow. We avoid that by keeping the same object.
1430         ((SequenceDummy) dummyseq).become(includedSeq);
1431         dummyseq.createDatasetSequence();
1432
1433         /*
1434          * Update mappings so they are now to the dataset sequence
1435          */
1436         for (AlignedCodonFrame mapping : align.getCodonFrames())
1437         {
1438           mapping.updateToDataset(dummyseq);
1439         }
1440
1441         /*
1442          * replace parsed sequence with the realised forward reference
1443          */
1444         includedseqs.set(p, dummyseq);
1445
1446         /*
1447          * and remove from the newseqs list
1448          */
1449         newseqs.remove(dummyseq);
1450       }
1451     }
1452
1453     /*
1454      * finally add sequences to the dataset
1455      */
1456     for (SequenceI seq : includedseqs)
1457     {
1458       // experimental: mapping-based 'alignment' to query sequence
1459       AlignmentUtils.alignSequenceAs(seq, align,
1460               String.valueOf(align.getGapCharacter()), false, true);
1461
1462       // rename sequences if GFF handler requested this
1463       // TODO a more elegant way e.g. gffHelper.postProcess(newseqs) ?
1464       List<SequenceFeature> sfs = seq.getFeatures().getPositionalFeatures();
1465       if (!sfs.isEmpty())
1466       {
1467         String newName = (String) sfs.get(0).getValue(
1468                 GffHelperI.RENAME_TOKEN);
1469         if (newName != null)
1470         {
1471           seq.setName(newName);
1472         }
1473       }
1474       align.addSequence(seq);
1475     }
1476   }
1477
1478   /**
1479    * Process a ## directive
1480    * 
1481    * @param line
1482    * @param gffProps
1483    * @param align
1484    * @param newseqs
1485    * @throws IOException
1486    */
1487   protected void processGffPragma(String line, Map<String, String> gffProps,
1488           AlignmentI align, List<SequenceI> newseqs) throws IOException
1489   {
1490     line = line.trim();
1491     if ("###".equals(line))
1492     {
1493       // close off any open 'forward references'
1494       return;
1495     }
1496
1497     String[] tokens = line.substring(2).split(" ");
1498     String pragma = tokens[0];
1499     String value = tokens.length == 1 ? null : tokens[1];
1500
1501     if ("gff-version".equalsIgnoreCase(pragma))
1502     {
1503       if (value != null)
1504       {
1505         try
1506         {
1507           // value may be e.g. "3.1.2"
1508           gffVersion = Integer.parseInt(value.split("\\.")[0]);
1509         } catch (NumberFormatException e)
1510         {
1511           // ignore
1512         }
1513       }
1514     }
1515     else if ("sequence-region".equalsIgnoreCase(pragma))
1516     {
1517       // could capture <seqid start end> if wanted here
1518     }
1519     else if ("feature-ontology".equalsIgnoreCase(pragma))
1520     {
1521       // should resolve against the specified feature ontology URI
1522     }
1523     else if ("attribute-ontology".equalsIgnoreCase(pragma))
1524     {
1525       // URI of attribute ontology - not currently used in GFF3
1526     }
1527     else if ("source-ontology".equalsIgnoreCase(pragma))
1528     {
1529       // URI of source ontology - not currently used in GFF3
1530     }
1531     else if ("species-build".equalsIgnoreCase(pragma))
1532     {
1533       // save URI of specific NCBI taxon version of annotations
1534       gffProps.put("species-build", value);
1535     }
1536     else if ("fasta".equalsIgnoreCase(pragma))
1537     {
1538       // process the rest of the file as a fasta file and replace any dummy
1539       // sequence IDs
1540       processAsFasta(align, newseqs);
1541     }
1542     else
1543     {
1544       System.err.println("Ignoring unknown pragma: " + line);
1545     }
1546   }
1547 }