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