Merge branch 'features/sequenceFeatureRefactor' into develop
authorgmungoc <g.m.carstairs@dundee.ac.uk>
Mon, 23 Mar 2015 10:00:41 +0000 (10:00 +0000)
committergmungoc <g.m.carstairs@dundee.ac.uk>
Mon, 23 Mar 2015 10:00:41 +0000 (10:00 +0000)
Conflicts:
src/jalview/analysis/AlignmentSorter.java
src/jalview/analysis/Dna.java
src/jalview/bin/JalviewLite.java
src/jalview/gui/PopupMenu.java
src/jalview/renderer/seqfeatures/FeatureRenderer.java
test/jalview/datamodel/SequenceTest.java
and minor unit test refactoring

31 files changed:
1  2 
src/jalview/analysis/AlignmentSorter.java
src/jalview/analysis/AlignmentUtils.java
src/jalview/analysis/Dna.java
src/jalview/bin/JalviewLite.java
src/jalview/controller/AlignViewController.java
src/jalview/ext/paradise/Annotate3D.java
src/jalview/ext/rbvi/chimera/ChimeraCommands.java
src/jalview/gui/AlignmentPanel.java
src/jalview/gui/AnnotationLabels.java
src/jalview/gui/FeatureSettings.java
src/jalview/gui/IdPanel.java
src/jalview/gui/Jalview2XML.java
src/jalview/gui/PopupMenu.java
src/jalview/gui/SequenceFetcher.java
src/jalview/io/BioJsHTMLOutput.java
src/jalview/io/SequenceAnnotationReport.java
src/jalview/io/StockholmFile.java
src/jalview/renderer/seqfeatures/FeatureRenderer.java
src/jalview/util/ParseHtmlBodyAndLinks.java
src/jalview/util/StringUtils.java
src/jalview/ws/rest/InputType.java
src/jalview/ws/rest/RestServiceDescription.java
test/jalview/analysis/CrossRefTest.java
test/jalview/datamodel/SequenceTest.java
test/jalview/ext/paradise/TestAnnotate3D.java
test/jalview/util/MappingUtilsTest.java
test/jalview/util/StringUtilsTest.java
test/jalview/ws/jabaws/JpredJabaStructExportImport.java
test/jalview/ws/jabaws/RNAStructExportImport.java
test/jalview/ws/rest/ShmmrRSBSService.java
test/jalview/ws/seqfetcher/DbRefFetcherTest.java

@@@ -175,885 -159,4 +175,885 @@@ public class AlignmentUtil
      }
      return result;
    }
 +
 +  /**
 +   * Returns a map of lists of sequences in the alignment, keyed by sequence
 +   * name. For use in mapping between different alignment views of the same
 +   * sequences.
 +   * 
 +   * @see jalview.datamodel.AlignmentI#getSequencesByName()
 +   */
 +  public static Map<String, List<SequenceI>> getSequencesByName(
 +          AlignmentI al)
 +  {
 +    Map<String, List<SequenceI>> theMap = new LinkedHashMap<String, List<SequenceI>>();
 +    for (SequenceI seq : al.getSequences())
 +    {
 +      String name = seq.getName();
 +      if (name != null)
 +      {
 +        List<SequenceI> seqs = theMap.get(name);
 +        if (seqs == null)
 +        {
 +          seqs = new ArrayList<SequenceI>();
 +          theMap.put(name, seqs);
 +        }
 +        seqs.add(seq);
 +      }
 +    }
 +    return theMap;
 +  }
 +
 +  /**
 +   * Build mapping of protein to cDNA alignment. Mappings are made between
 +   * sequences where the cDNA translates to the protein sequence. Any new
 +   * mappings are added to the protein alignment. Returns true if any mappings
 +   * either already exist or were added, else false.
 +   * 
 +   * @param proteinAlignment
 +   * @param cdnaAlignment
 +   * @return
 +   */
 +  public static boolean mapProteinToCdna(
 +          final AlignmentI proteinAlignment,
 +          final AlignmentI cdnaAlignment)
 +  {
 +    if (proteinAlignment == null || cdnaAlignment == null)
 +    {
 +      return false;
 +    }
 +
 +    Set<SequenceI> mappedDna = new HashSet<SequenceI>();
 +    Set<SequenceI> mappedProtein = new HashSet<SequenceI>();
 +
 +    /*
 +     * First pass - map sequences where cross-references exist. This include
 +     * 1-to-many mappings to support, for example, variant cDNA.
 +     */
 +    boolean mappingPerformed = mapProteinToCdna(proteinAlignment,
 +            cdnaAlignment, mappedDna, mappedProtein, true);
 +
 +    /*
 +     * Second pass - map sequences where no cross-references exist. This only
 +     * does 1-to-1 mappings and assumes corresponding sequences are in the same
 +     * order in the alignments.
 +     */
 +    mappingPerformed |= mapProteinToCdna(proteinAlignment, cdnaAlignment,
 +            mappedDna, mappedProtein, false);
 +    return mappingPerformed;
 +  }
 +
 +  /**
 +   * Make mappings between compatible sequences (where the cDNA translation
 +   * matches the protein).
 +   * 
 +   * @param proteinAlignment
 +   * @param cdnaAlignment
 +   * @param mappedDna
 +   *          a set of mapped DNA sequences (to add to)
 +   * @param mappedProtein
 +   *          a set of mapped Protein sequences (to add to)
 +   * @param xrefsOnly
 +   *          if true, only map sequences where xrefs exist
 +   * @return
 +   */
 +  protected static boolean mapProteinToCdna(
 +          final AlignmentI proteinAlignment,
 +          final AlignmentI cdnaAlignment, Set<SequenceI> mappedDna,
 +          Set<SequenceI> mappedProtein, boolean xrefsOnly)
 +  {
 +    boolean mappingPerformed = false;
 +    List<SequenceI> thisSeqs = proteinAlignment.getSequences();
 +    for (SequenceI aaSeq : thisSeqs)
 +    {
 +      boolean proteinMapped = false;
 +      AlignedCodonFrame acf = new AlignedCodonFrame();
 +
 +      for (SequenceI cdnaSeq : cdnaAlignment.getSequences())
 +      {
 +        /*
 +         * Always try to map if sequences have xref to each other; this supports
 +         * variant cDNA or alternative splicing for a protein sequence.
 +         * 
 +         * If no xrefs, try to map progressively, assuming that alignments have
 +         * mappable sequences in corresponding order. These are not
 +         * many-to-many, as that would risk mixing species with similar cDNA
 +         * sequences.
 +         */
 +        if (xrefsOnly && !CrossRef.haveCrossRef(aaSeq, cdnaSeq))
 +        {
 +          continue;
 +        }
 +
 +        /*
 +         * Don't map non-xrefd sequences more than once each. This heuristic
 +         * allows us to pair up similar sequences in ordered alignments.
 +         */
 +        if (!xrefsOnly
 +                && (mappedProtein.contains(aaSeq) || mappedDna
 +                        .contains(cdnaSeq)))
 +        {
 +          continue;
 +        }
 +        if (!mappingExists(proteinAlignment.getCodonFrames(),
 +                aaSeq.getDatasetSequence(), cdnaSeq.getDatasetSequence()))
 +        {
 +          MapList map = mapProteinToCdna(aaSeq, cdnaSeq);
 +          if (map != null)
 +          {
 +            acf.addMap(cdnaSeq, aaSeq, map);
 +            mappingPerformed = true;
 +            proteinMapped = true;
 +            mappedDna.add(cdnaSeq);
 +            mappedProtein.add(aaSeq);
 +          }
 +        }
 +      }
 +      if (proteinMapped)
 +      {
 +        proteinAlignment.addCodonFrame(acf);
 +      }
 +    }
 +    return mappingPerformed;
 +  }
 +
 +  /**
 +   * Answers true if the mappings include one between the given (dataset)
 +   * sequences.
 +   */
 +  public static boolean mappingExists(Set<AlignedCodonFrame> set,
 +          SequenceI aaSeq, SequenceI cdnaSeq)
 +  {
 +    if (set != null)
 +    {
 +      for (AlignedCodonFrame acf : set)
 +      {
 +        if (cdnaSeq == acf.getDnaForAaSeq(aaSeq))
 +        {
 +          return true;
 +        }
 +      }
 +    }
 +    return false;
 +  }
 +
 +  /**
 +   * Build a mapping (if possible) of a protein to a cDNA sequence. The cDNA
 +   * must be three times the length of the protein, possibly after ignoring
 +   * start and/or stop codons, and must translate to the protein. Returns null
 +   * if no mapping is determined.
 +   * 
 +   * @param proteinSeqs
 +   * @param cdnaSeq
 +   * @return
 +   */
 +  public static MapList mapProteinToCdna(SequenceI proteinSeq,
 +          SequenceI cdnaSeq)
 +  {
 +    /*
 +     * Here we handle either dataset sequence set (desktop) or absent (applet).
 +     * Use only the char[] form of the sequence to avoid creating possibly large
 +     * String objects.
 +     */
 +    final SequenceI proteinDataset = proteinSeq.getDatasetSequence();
 +    char[] aaSeqChars = proteinDataset != null ? proteinDataset
 +            .getSequence() : proteinSeq.getSequence();
 +    final SequenceI cdnaDataset = cdnaSeq.getDatasetSequence();
 +    char[] cdnaSeqChars = cdnaDataset != null ? cdnaDataset.getSequence()
 +            : cdnaSeq.getSequence();
 +    if (aaSeqChars == null || cdnaSeqChars == null)
 +    {
 +      return null;
 +    }
 +
 +    /*
 +     * cdnaStart/End, proteinStartEnd are base 1 (for dataset sequence mapping)
 +     */
 +    final int mappedLength = 3 * aaSeqChars.length;
 +    int cdnaLength = cdnaSeqChars.length;
 +    int cdnaStart = 1;
 +    int cdnaEnd = cdnaLength;
 +    final int proteinStart = 1;
 +    final int proteinEnd = aaSeqChars.length;
 +
 +    /*
 +     * If lengths don't match, try ignoring stop codon.
 +     */
 +    if (cdnaLength != mappedLength && cdnaLength > 2)
 +    {
 +      String lastCodon = String.valueOf(cdnaSeqChars, cdnaLength - 3, 3)
 +              .toUpperCase();
 +      for (String stop : ResidueProperties.STOP)
 +      {
 +        if (lastCodon.equals(stop))
 +        {
 +          cdnaEnd -= 3;
 +          cdnaLength -= 3;
 +          break;
 +        }
 +      }
 +    }
 +
 +    /*
 +     * If lengths still don't match, try ignoring start codon.
 +     */
 +    if (cdnaLength != mappedLength
 +            && cdnaLength > 2
 +            && String.valueOf(cdnaSeqChars, 0, 3).toUpperCase()
 +                    .equals(
 +                    ResidueProperties.START))
 +    {
 +      cdnaStart += 3;
 +      cdnaLength -= 3;
 +    }
 +
 +    if (cdnaLength != mappedLength)
 +    {
 +      return null;
 +    }
 +    if (!translatesAs(cdnaSeqChars, cdnaStart - 1, aaSeqChars))
 +    {
 +      return null;
 +    }
 +    MapList map = new MapList(new int[]
 +    { cdnaStart, cdnaEnd }, new int[]
 +    { proteinStart, proteinEnd }, 3, 1);
 +    return map;
 +  }
 +
 +  /**
 +   * Test whether the given cdna sequence, starting at the given offset,
 +   * translates to the given amino acid sequence, using the standard translation
 +   * table. Designed to fail fast i.e. as soon as a mismatch position is found.
 +   * 
 +   * @param cdnaSeqChars
 +   * @param cdnaStart
 +   * @param aaSeqChars
 +   * @return
 +   */
 +  protected static boolean translatesAs(char[] cdnaSeqChars, int cdnaStart,
 +          char[] aaSeqChars)
 +  {
 +    int aaResidue = 0;
 +    for (int i = cdnaStart; i < cdnaSeqChars.length - 2
 +            && aaResidue < aaSeqChars.length; i += 3, aaResidue++)
 +    {
 +      String codon = String.valueOf(cdnaSeqChars, i, 3);
 +      final String translated = ResidueProperties.codonTranslate(
 +              codon);
 +      /*
 +       * ? allow X in protein to match untranslatable in dna ?
 +       */
 +      final char aaRes = aaSeqChars[aaResidue];
 +      if ((translated == null || "STOP".equals(translated)) && aaRes == 'X')
 +      {
 +        continue;
 +      }
 +      if (translated == null
 +              || !(aaRes == translated.charAt(0)))
 +      {
 +        // debug
-         System.out.println(("Mismatch at " + i + "/" + aaResidue + ": "
-                 + codon + "(" + translated + ") != " + aaRes));
++        // System.out.println(("Mismatch at " + i + "/" + aaResidue + ": "
++        // + codon + "(" + translated + ") != " + aaRes));
 +        return false;
 +      }
 +    }
 +    // fail if we didn't match all of the aa sequence
 +    return (aaResidue == aaSeqChars.length);
 +  }
 +
 +  /**
 +   * Align sequence 'seq' to match the alignment of a mapped sequence. Note this
 +   * currently assumes that we are aligning cDNA to match protein.
 +   * 
 +   * @param seq
 +   *          the sequence to be realigned
 +   * @param al
 +   *          the alignment whose sequence alignment is to be 'copied'
 +   * @param gap
 +   *          character string represent a gap in the realigned sequence
 +   * @param preserveUnmappedGaps
 +   * @param preserveMappedGaps
 +   * @return true if the sequence was realigned, false if it could not be
 +   */
 +  public static boolean alignSequenceAs(SequenceI seq, AlignmentI al,
 +          String gap, boolean preserveMappedGaps,
 +          boolean preserveUnmappedGaps)
 +  {
 +    /*
 +     * Get any mappings from the source alignment to the target (dataset) sequence.
 +     */
 +    // TODO there may be one AlignedCodonFrame per dataset sequence, or one with
 +    // all mappings. Would it help to constrain this?
 +    List<AlignedCodonFrame> mappings = al.getCodonFrame(seq);
 +    if (mappings == null || mappings.isEmpty())
 +    {
 +      return false;
 +    }
 +  
 +    /*
 +     * Locate the aligned source sequence whose dataset sequence is mapped. We
 +     * just take the first match here (as we can't align cDNA like more than one
 +     * protein sequence).
 +     */
 +    SequenceI alignFrom = null;
 +    AlignedCodonFrame mapping = null;
 +    for (AlignedCodonFrame mp : mappings)
 +    {
 +      alignFrom = mp.findAlignedSequence(seq.getDatasetSequence(), al);
 +      if (alignFrom != null)
 +      {
 +        mapping = mp;
 +        break;
 +      }
 +    }
 +  
 +    if (alignFrom == null)
 +    {
 +      return false;
 +    }
 +    alignSequenceAs(seq, alignFrom, mapping, gap, al.getGapCharacter(),
 +            preserveMappedGaps, preserveUnmappedGaps);
 +    return true;
 +  }
 +
 +  /**
 +   * Align sequence 'alignTo' the same way as 'alignFrom', using the mapping to
 +   * match residues and codons. Flags control whether existing gaps in unmapped
 +   * (intron) and mapped (exon) regions are preserved or not. Gaps linking intro
 +   * and exon are only retained if both flags are set.
 +   * 
 +   * @param alignTo
 +   * @param alignFrom
 +   * @param mapping
 +   * @param myGap
 +   * @param sourceGap
 +   * @param preserveUnmappedGaps
 +   * @param preserveMappedGaps
 +   */
 +  public static void alignSequenceAs(SequenceI alignTo,
 +          SequenceI alignFrom,
 +          AlignedCodonFrame mapping, String myGap, char sourceGap,
 +          boolean preserveMappedGaps, boolean preserveUnmappedGaps)
 +  {
 +    // TODO generalise to work for Protein-Protein, dna-dna, dna-protein
 +    final char[] thisSeq = alignTo.getSequence();
 +    final char[] thatAligned = alignFrom.getSequence();
 +    StringBuilder thisAligned = new StringBuilder(2 * thisSeq.length);
 +  
 +    // aligned and dataset sequence positions, all base zero
 +    int thisSeqPos = 0;
 +    int sourceDsPos = 0;
 +
 +    int basesWritten = 0;
 +    char myGapChar = myGap.charAt(0);
 +    int ratio = myGap.length();
 +
 +    /*
 +     * Traverse the aligned protein sequence.
 +     */
 +    int sourceGapMappedLength = 0;
 +    boolean inExon = false;
 +    for (char sourceChar : thatAligned)
 +    {
 +      if (sourceChar == sourceGap)
 +      {
 +        sourceGapMappedLength += ratio;
 +        continue;
 +      }
 +
 +      /*
 +       * Found a residue. Locate its mapped codon (start) position.
 +       */
 +      sourceDsPos++;
 +      // Note mapping positions are base 1, our sequence positions base 0
 +      int[] mappedPos = mapping.getMappedRegion(alignTo, alignFrom,
 +              sourceDsPos);
 +      if (mappedPos == null)
 +      {
 +        /*
 +         * Abort realignment if unmapped protein. Or could ignore it??
 +         */
 +        System.err.println("Can't align: no codon mapping to residue "
 +                + sourceDsPos + "(" + sourceChar + ")");
 +        return;
 +      }
 +
 +      int mappedCodonStart = mappedPos[0]; // position (1...) of codon start
 +      int mappedCodonEnd = mappedPos[mappedPos.length - 1]; // codon end pos
 +      StringBuilder trailingCopiedGap = new StringBuilder();
 +
 +      /*
 +       * Copy dna sequence up to and including this codon. Optionally, include
 +       * gaps before the codon starts (in introns) and/or after the codon starts
 +       * (in exons).
 +       * 
 +       * Note this only works for 'linear' splicing, not reverse or interleaved.
 +       * But then 'align dna as protein' doesn't make much sense otherwise.
 +       */
 +      int intronLength = 0;
 +      while (basesWritten < mappedCodonEnd && thisSeqPos < thisSeq.length)
 +      {
 +        final char c = thisSeq[thisSeqPos++];
 +        if (c != myGapChar)
 +        {
 +          basesWritten++;
 +
 +          if (basesWritten < mappedCodonStart)
 +          {
 +            /*
 +             * Found an unmapped (intron) base. First add in any preceding gaps
 +             * (if wanted).
 +             */
 +            if (preserveUnmappedGaps && trailingCopiedGap.length() > 0)
 +            {
 +              thisAligned.append(trailingCopiedGap.toString());
 +              intronLength += trailingCopiedGap.length();
 +              trailingCopiedGap = new StringBuilder();
 +            }
 +            intronLength++;
 +            inExon = false;
 +          }
 +          else
 +          {
 +            final boolean startOfCodon = basesWritten == mappedCodonStart;
 +            int gapsToAdd = calculateGapsToInsert(preserveMappedGaps,
 +                    preserveUnmappedGaps, sourceGapMappedLength, inExon,
 +                    trailingCopiedGap.length(), intronLength, startOfCodon);
 +            for (int i = 0; i < gapsToAdd; i++)
 +            {
 +              thisAligned.append(myGapChar);
 +            }
 +            sourceGapMappedLength = 0;
 +            inExon = true;
 +          }
 +          thisAligned.append(c);
 +          trailingCopiedGap = new StringBuilder();
 +        }
 +        else
 +        {
 +          if (inExon && preserveMappedGaps)
 +          {
 +            trailingCopiedGap.append(myGapChar);
 +          }
 +          else if (!inExon && preserveUnmappedGaps)
 +          {
 +            trailingCopiedGap.append(myGapChar);
 +          }
 +        }
 +      }
 +    }
 +
 +    /*
 +     * At end of protein sequence. Copy any remaining dna sequence, optionally
 +     * including (intron) gaps. We do not copy trailing gaps in protein.
 +     */
 +    while (thisSeqPos < thisSeq.length)
 +    {
 +      final char c = thisSeq[thisSeqPos++];
 +      if (c != myGapChar || preserveUnmappedGaps)
 +      {
 +        thisAligned.append(c);
 +      }
 +    }
 +
 +    /*
 +     * All done aligning, set the aligned sequence.
 +     */
 +    alignTo.setSequence(new String(thisAligned));
 +  }
 +
 +  /**
 +   * Helper method to work out how many gaps to insert when realigning.
 +   * 
 +   * @param preserveMappedGaps
 +   * @param preserveUnmappedGaps
 +   * @param sourceGapMappedLength
 +   * @param inExon
 +   * @param trailingCopiedGap
 +   * @param intronLength
 +   * @param startOfCodon
 +   * @return
 +   */
 +  protected static int calculateGapsToInsert(boolean preserveMappedGaps,
 +          boolean preserveUnmappedGaps, int sourceGapMappedLength,
 +          boolean inExon, int trailingGapLength,
 +          int intronLength, final boolean startOfCodon)
 +  {
 +    int gapsToAdd = 0;
 +    if (startOfCodon)
 +    {
 +      /*
 +       * Reached start of codon. Ignore trailing gaps in intron unless we are
 +       * preserving gaps in both exon and intron. Ignore them anyway if the
 +       * protein alignment introduces a gap at least as large as the intronic
 +       * region.
 +       */
 +      if (inExon && !preserveMappedGaps)
 +      {
 +        trailingGapLength = 0;
 +      }
 +      if (!inExon && !(preserveMappedGaps && preserveUnmappedGaps))
 +      {
 +        trailingGapLength = 0;
 +      }
 +      if (inExon)
 +      {
 +        gapsToAdd = Math.max(sourceGapMappedLength, trailingGapLength);
 +      }
 +      else
 +      {
 +        if (intronLength + trailingGapLength <= sourceGapMappedLength)
 +        {
 +          gapsToAdd = sourceGapMappedLength - intronLength;
 +        }
 +        else
 +        {
 +          gapsToAdd = Math.min(intronLength + trailingGapLength
 +                  - sourceGapMappedLength, trailingGapLength);
 +        }
 +      }
 +    }
 +    else
 +    {
 +      /*
 +       * second or third base of codon; check for any gaps in dna
 +       */
 +      if (!preserveMappedGaps)
 +      {
 +        trailingGapLength = 0;
 +      }
 +      gapsToAdd = Math.max(sourceGapMappedLength, trailingGapLength);
 +    }
 +    return gapsToAdd;
 +  }
 +
 +  /**
 +   * Returns a list of sequences mapped from the given sequences and aligned
 +   * (gapped) in the same way. For example, the cDNA for aligned protein, where
 +   * a single gap in protein generates three gaps in cDNA.
 +   * 
 +   * @param sequences
 +   * @param gapCharacter
 +   * @param mappings
 +   * @return
 +   */
 +  public static List<SequenceI> getAlignedTranslation(
 +          List<SequenceI> sequences, char gapCharacter,
 +          Set<AlignedCodonFrame> mappings)
 +  {
 +    List<SequenceI> alignedSeqs = new ArrayList<SequenceI>();
 +
 +    for (SequenceI seq : sequences)
 +    {
 +      List<SequenceI> mapped = getAlignedTranslation(seq, gapCharacter,
 +              mappings);
 +      alignedSeqs.addAll(mapped);
 +    }
 +    return alignedSeqs;
 +  }
 +
 +  /**
 +   * Returns sequences aligned 'like' the source sequence, as mapped by the
 +   * given mappings. Normally we expect zero or one 'mapped' sequences, but this
 +   * will support 1-to-many as well.
 +   * 
 +   * @param seq
 +   * @param gapCharacter
 +   * @param mappings
 +   * @return
 +   */
 +  protected static List<SequenceI> getAlignedTranslation(SequenceI seq,
 +          char gapCharacter, Set<AlignedCodonFrame> mappings)
 +  {
 +    List<SequenceI> result = new ArrayList<SequenceI>();
 +    for (AlignedCodonFrame mapping : mappings)
 +    {
 +      if (mapping.involvesSequence(seq))
 +      {
 +        SequenceI mapped = getAlignedTranslation(seq, gapCharacter, mapping);
 +        if (mapped != null)
 +        {
 +          result.add(mapped);
 +        }
 +      }
 +    }
 +    return result;
 +  }
 +
 +  /**
 +   * Returns the translation of 'seq' (as held in the mapping) with
 +   * corresponding alignment (gaps).
 +   * 
 +   * @param seq
 +   * @param gapCharacter
 +   * @param mapping
 +   * @return
 +   */
 +  protected static SequenceI getAlignedTranslation(SequenceI seq,
 +          char gapCharacter, AlignedCodonFrame mapping)
 +  {
 +    String gap = String.valueOf(gapCharacter);
 +    boolean toDna = false;
 +    int fromRatio = 1;
 +    SequenceI mapTo = mapping.getDnaForAaSeq(seq);
 +    if (mapTo != null)
 +    {
 +      // mapping is from protein to nucleotide
 +      toDna = true;
 +      // should ideally get gap count ratio from mapping
 +      gap = String.valueOf(new char[]
 +      { gapCharacter, gapCharacter, gapCharacter });
 +    }
 +    else
 +    {
 +      // mapping is from nucleotide to protein
 +      mapTo = mapping.getAaForDnaSeq(seq);
 +      fromRatio = 3;
 +    }
 +    StringBuilder newseq = new StringBuilder(seq.getLength()
 +            * (toDna ? 3 : 1));
 +
 +    int residueNo = 0; // in seq, base 1
 +    int[] phrase = new int[fromRatio];
 +    int phraseOffset = 0;
 +    int gapWidth = 0;
 +    boolean first = true;
 +    final Sequence alignedSeq = new Sequence("", "");
 +
 +    for (char c : seq.getSequence())
 +    {
 +      if (c == gapCharacter)
 +      {
 +        gapWidth++;
 +        if (gapWidth >= fromRatio)
 +        {
 +          newseq.append(gap);
 +          gapWidth = 0;
 +        }
 +      }
 +      else
 +      {
 +        phrase[phraseOffset++] = residueNo + 1;
 +        if (phraseOffset == fromRatio)
 +        {
 +          /*
 +           * Have read a whole codon (or protein residue), now translate: map
 +           * source phrase to positions in target sequence add characters at
 +           * these positions to newseq Note mapping positions are base 1, our
 +           * sequence positions base 0.
 +           */
 +          SearchResults sr = new SearchResults();
 +          for (int pos : phrase)
 +          {
 +            mapping.markMappedRegion(seq, pos, sr);
 +          }
 +          newseq.append(sr.toString());
 +          if (first)
 +          {
 +            first = false;
 +            // Hack: Copy sequence dataset, name and description from
 +            // SearchResults.match[0].sequence
 +            // TODO? carry over sequence names from original 'complement'
 +            // alignment
 +            SequenceI mappedTo = sr.getResultSequence(0);
 +            alignedSeq.setName(mappedTo.getName());
 +            alignedSeq.setDescription(mappedTo.getDescription());
 +            alignedSeq.setDatasetSequence(mappedTo);
 +          }
 +          phraseOffset = 0;
 +        }
 +        residueNo++;
 +      }
 +    }
 +    alignedSeq.setSequence(newseq.toString());
 +    return alignedSeq;
 +  }
 +
 +  /**
 +   * Realigns the given protein to match the alignment of the dna, using codon
 +   * mappings to translate aligned codon positions to protein residues.
 +   * 
 +   * @param protein
 +   *          the alignment whose sequences are realigned by this method
 +   * @param dna
 +   *          the dna alignment whose alignment we are 'copying'
 +   * @return the number of sequences that were realigned
 +   */
 +  public static int alignProteinAsDna(AlignmentI protein, AlignmentI dna)
 +  {
 +    Set<AlignedCodonFrame> mappings = protein.getCodonFrames();
 +
 +    /*
 +     * Map will hold, for each aligned codon position e.g. [3, 5, 6], a map of
 +     * {dnaSequence, {proteinSequence, codonProduct}} at that position. The
 +     * comparator keeps the codon positions ordered.
 +     */
 +    Map<AlignedCodon, Map<SequenceI, String>> alignedCodons = new TreeMap<AlignedCodon, Map<SequenceI, String>>(
 +            new CodonComparator());
 +    for (SequenceI dnaSeq : dna.getSequences())
 +    {
 +      for (AlignedCodonFrame mapping : mappings)
 +      {
 +        Mapping seqMap = mapping.getMappingForSequence(dnaSeq);
 +        SequenceI prot = mapping.findAlignedSequence(
 +                dnaSeq.getDatasetSequence(), protein);
 +        if (prot != null)
 +        {
 +          addCodonPositions(dnaSeq, prot, protein.getGapCharacter(),
 +                  seqMap, alignedCodons);
 +        }
 +      }
 +    }
 +    return alignProteinAs(protein, alignedCodons);
 +  }
 +
 +  /**
 +   * Update the aligned protein sequences to match the codon alignments given in
 +   * the map.
 +   * 
 +   * @param protein
 +   * @param alignedCodons
 +   *          an ordered map of codon positions (columns), with sequence/peptide
 +   *          values present in each column
 +   * @return
 +   */
 +  protected static int alignProteinAs(AlignmentI protein,
 +          Map<AlignedCodon, Map<SequenceI, String>> alignedCodons)
 +  {
 +    /*
 +     * Prefill aligned sequences with gaps before inserting aligned protein
 +     * residues.
 +     */
 +    int alignedWidth = alignedCodons.size();
 +    char[] gaps = new char[alignedWidth];
 +    Arrays.fill(gaps, protein.getGapCharacter());
 +    String allGaps = String.valueOf(gaps);
 +    for (SequenceI seq : protein.getSequences())
 +    {
 +      seq.setSequence(allGaps);
 +    }
 +
 +    int column = 0;
 +    for (AlignedCodon codon : alignedCodons.keySet())
 +    {
 +      final Map<SequenceI, String> columnResidues = alignedCodons.get(codon);
 +      for (Entry<SequenceI, String> entry : columnResidues
 +              .entrySet())
 +      {
 +        // place translated codon at its column position in sequence
 +        entry.getKey().getSequence()[column] = entry.getValue().charAt(0);
 +      }
 +      column++;
 +    }
 +    return 0;
 +  }
 +
 +  /**
 +   * Populate the map of aligned codons by traversing the given sequence
 +   * mapping, locating the aligned positions of mapped codons, and adding those
 +   * positions and their translation products to the map.
 +   * 
 +   * @param dna
 +   *          the aligned sequence we are mapping from
 +   * @param protein
 +   *          the sequence to be aligned to the codons
 +   * @param gapChar
 +   *          the gap character in the dna sequence
 +   * @param seqMap
 +   *          a mapping to a sequence translation
 +   * @param alignedCodons
 +   *          the map we are building up
 +   */
 +  static void addCodonPositions(SequenceI dna, SequenceI protein,
 +          char gapChar,
 +          Mapping seqMap,
 +          Map<AlignedCodon, Map<SequenceI, String>> alignedCodons)
 +  {
 +    Iterator<AlignedCodon> codons = seqMap.getCodonIterator(dna, gapChar);
 +    while (codons.hasNext())
 +    {
 +      AlignedCodon codon = codons.next();
 +      Map<SequenceI, String> seqProduct = alignedCodons.get(codon);
 +      if (seqProduct == null)
 +      {
 +        seqProduct = new HashMap<SequenceI, String>();
 +        alignedCodons.put(codon, seqProduct);
 +      }
 +      seqProduct.put(protein, codon.product);
 +    }
 +  }
 +
 +  /**
 +   * Returns true if a cDNA/Protein mapping either exists, or could be made,
 +   * between at least one pair of sequences in the two alignments. Currently,
 +   * the logic is:
 +   * <ul>
 +   * <li>One alignment must be nucleotide, and the other protein</li>
 +   * <li>At least one pair of sequences must be already mapped, or mappable</li>
 +   * <li>Mappable means the nucleotide translation matches the protein sequence</li>
 +   * <li>The translation may ignore start and stop codons if present in the
 +   * nucleotide</li>
 +   * </ul>
 +   * 
 +   * @param al1
 +   * @param al2
 +   * @return
 +   */
 +  public static boolean isMappable(AlignmentI al1, AlignmentI al2)
 +  {
 +    /*
 +     * Require one nucleotide and one protein
 +     */
 +    if (al1.isNucleotide() == al2.isNucleotide())
 +    {
 +      return false;
 +    }
 +    AlignmentI dna = al1.isNucleotide() ? al1 : al2;
 +    AlignmentI protein = dna == al1 ? al2 : al1;
 +    Set<AlignedCodonFrame> mappings = protein.getCodonFrames();
 +    for (SequenceI dnaSeq : dna.getSequences())
 +    {
 +      for (SequenceI proteinSeq : protein.getSequences())
 +      {
 +        if (isMappable(dnaSeq, proteinSeq, mappings))
 +        {
 +          return true;
 +        }
 +      }
 +    }
 +    return false;
 +  }
 +
 +  /**
 +   * Returns true if the dna sequence is mapped, or could be mapped, to the
 +   * protein sequence.
 +   * 
 +   * @param dnaSeq
 +   * @param proteinSeq
 +   * @param mappings
 +   * @return
 +   */
 +  public static boolean isMappable(SequenceI dnaSeq, SequenceI proteinSeq,
 +          Set<AlignedCodonFrame> mappings)
 +  {
 +    SequenceI dnaDs = dnaSeq.getDatasetSequence() == null ? dnaSeq : dnaSeq.getDatasetSequence();
 +    SequenceI proteinDs = proteinSeq.getDatasetSequence() == null ? proteinSeq
 +            : proteinSeq.getDatasetSequence();
 +    
 +    /*
 +     * Already mapped?
 +     */
 +    for (AlignedCodonFrame mapping : mappings) {
 +      if ( proteinDs == mapping.getAaForDnaSeq(dnaDs)) {
 +        return true;
 +      }
 +    }
 +
 +    /*
 +     * Just try to make a mapping (it is not yet stored), test whether
 +     * successful.
 +     */
 +    return mapProteinToCdna(proteinDs, dnaDs) != null;
 +  }
  }
@@@ -778,14 -789,13 +778,13 @@@ public class Dn
     *          indicating if they are displayed.
     */
    private static void transferCodedFeatures(SequenceI dna, SequenceI pep,
 -          MapList map, Hashtable featureTypes, Hashtable featureGroups)
 +          MapList map, Map<String, Object> featureTypes,
 +          Map<String, Boolean> featureGroups)
    {
-     SequenceFeature[] sfs = (dna.getDatasetSequence() != null ? dna
-             .getDatasetSequence() : dna).getSequenceFeatures();
 -    SequenceFeature[] sf = dna.getSequenceFeatures();
++    SequenceFeature[] sfs = dna.getSequenceFeatures();
      Boolean fgstate;
 -    jalview.datamodel.DBRefEntry[] dnarefs = jalview.util.DBRefUtils
 -            .selectRefs(dna.getDBRef(),
 -                    jalview.datamodel.DBRefSource.DNACODINGDBS);
 +    DBRefEntry[] dnarefs = DBRefUtils.selectRefs(dna.getDBRef(),
 +            DBRefSource.DNACODINGDBS);
      if (dnarefs != null)
      {
        // intersect with pep
@@@ -1808,16 -1809,9 +1808,10 @@@ public class JalviewLite extends Apple
        return file;
      }
  
-     // public LoadingThread(String _file, JalviewLite _applet)
-     // {
-     // this._file = _file;
-     // applet = _applet;
-     // }
 -    public LoadingThread(String _file, JalviewLite _applet)
 +    public LoadingThread(String file, String file2, JalviewLite _applet)
      {
 -      this._file = _file;
 +      this._file = file;
 +      this._file2 = file2;
        applet = _applet;
      }
  
@@@ -23,6 -23,6 +23,7 @@@ package jalview.ext.paradise
  import jalview.util.MessageManager;
  import jalview.ws.HttpClientUtils;
  
++import java.io.BufferedReader;
  import java.io.IOException;
  import java.io.InputStreamReader;
  import java.io.Reader;
@@@ -136,7 -138,7 +137,8 @@@ public class Annotate3
      // return processJsonResponseFor(HttpClientUtils.doHttpUrlPost(twoDtoolsURL,
      // vals));
      ArrayList<Reader> readers = new ArrayList<Reader>();
-     readers.add(HttpClientUtils.doHttpUrlPost(twoDtoolsURL, vals, 0, 0));
 -    readers.add(HttpClientUtils.doHttpUrlPost(twoDtoolsURL, vals));
++    final BufferedReader postResponse = HttpClientUtils.doHttpUrlPost(twoDtoolsURL, vals, 0, 0);
++    readers.add(postResponse);
      return readers.iterator();
  
    }
   */
  package jalview.ext.rbvi.chimera;
  
++import java.awt.Color;
++import java.util.ArrayList;
++import java.util.LinkedHashMap;
++import java.util.List;
++import java.util.Map;
++import java.util.TreeMap;
++
  import jalview.api.FeatureRenderer;
  import jalview.api.SequenceRenderer;
  import jalview.datamodel.AlignmentI;
@@@ -30,13 -30,13 +37,6 @@@ import jalview.structure.StructureSelec
  import jalview.util.ColorUtils;
  import jalview.util.Comparison;
  
--import java.awt.Color;
--import java.util.ArrayList;
--import java.util.LinkedHashMap;
--import java.util.List;
--import java.util.Map;
--import java.util.TreeMap;
--
  /**
   * Routines for generating Chimera commands for Jalview/Chimera binding
   * 
@@@ -121,6 -121,6 +121,7 @@@ public class ChimeraCommand
          final Map<String, List<int[]>> modelData = colourData.get(model);
          for (String chain : modelData.keySet())
          {
++          boolean hasChain = !"".equals(chain.trim());
            for (int[] range : modelData.get(chain))
            {
              if (!firstPositionForModel)
              {
                sb.append(range[0]).append("-").append(range[1]);
              }
--            sb.append(".").append(chain);
++            if (hasChain)
++            {
++              sb.append(".").append(chain);
++            }
              firstPositionForModel = false;
            }
          }
@@@ -1323,11 -1302,10 +1323,10 @@@ public class AlignmentPanel extends GAl
  
          for (s = 0; s < sSize; s++)
          {
 -          sy = s * av.charHeight + scaleHeight;
 +          sy = s * av.getCharHeight() + scaleHeight;
  
            SequenceI seq = av.getAlignment().getSequenceAt(s);
-           SequenceFeature[] features = seq.getDatasetSequence()
-                   .getSequenceFeatures();
+           SequenceFeature[] features = seq.getSequenceFeatures();
            SequenceGroup[] groups = av.getAlignment().findAllGroups(seq);
            for (res = 0; res < alwidth; res++)
            {
@@@ -67,6 -67,6 +67,8 @@@ import javax.swing.ToolTipManager
  public class AnnotationLabels extends JPanel implements MouseListener,
          MouseMotionListener, ActionListener
  {
++  private static final Pattern LEFT_ANGLE_BRACKET_PATTERN = Pattern.compile("<");
++
    String TOGGLE_LABELSCALE = MessageManager.getString("label.scale_label_to_column");
  
    String ADDNEW = MessageManager.getString("label.add_new_row");
                  || (desc.substring(0, 6).toLowerCase().indexOf("<html>") < 0))
          {
            // clean the description ready for embedding in html
--          desc = new StringBuffer(Pattern.compile("<").matcher(desc)
++          desc = new StringBuffer(LEFT_ANGLE_BRACKET_PATTERN.matcher(desc)
                    .replaceAll("&lt;"));
            desc.insert(0, "<html>");
          }
Simple merge
Simple merge
Simple merge
   */
  package jalview.gui;
  
++import java.awt.Color;
++import java.awt.event.ActionEvent;
++import java.awt.event.ActionListener;
++import java.util.ArrayList;
++import java.util.Arrays;
++import java.util.Collection;
++import java.util.Collections;
++import java.util.Hashtable;
++import java.util.LinkedHashMap;
++import java.util.List;
++import java.util.Map;
++import java.util.TreeMap;
++import java.util.Vector;
++
++import javax.swing.ButtonGroup;
++import javax.swing.JCheckBoxMenuItem;
++import javax.swing.JColorChooser;
++import javax.swing.JMenu;
++import javax.swing.JMenuItem;
++import javax.swing.JOptionPane;
++import javax.swing.JPopupMenu;
++import javax.swing.JRadioButtonMenuItem;
++
  import jalview.analysis.AAFrequency;
  import jalview.analysis.AlignmentAnnotationUtils;
  import jalview.analysis.Conservation;
@@@ -58,29 -58,29 +81,6 @@@ import jalview.util.GroupUrlLink.UrlStr
  import jalview.util.MessageManager;
  import jalview.util.UrlLink;
  
--import java.awt.Color;
--import java.awt.event.ActionEvent;
--import java.awt.event.ActionListener;
--import java.util.ArrayList;
--import java.util.Arrays;
--import java.util.Collection;
--import java.util.Collections;
--import java.util.Hashtable;
--import java.util.LinkedHashMap;
--import java.util.List;
--import java.util.Map;
--import java.util.TreeMap;
--import java.util.Vector;
--
--import javax.swing.ButtonGroup;
--import javax.swing.JCheckBoxMenuItem;
--import javax.swing.JColorChooser;
--import javax.swing.JMenu;
--import javax.swing.JMenuItem;
--import javax.swing.JOptionPane;
--import javax.swing.JPopupMenu;
--import javax.swing.JRadioButtonMenuItem;
--
  /**
   * DOCUMENT ME!
   * 
Simple merge
Simple merge
   */
  package jalview.io;
  
 -import jalview.datamodel.DBRefEntry;
 -import jalview.datamodel.SequenceFeature;
 -import jalview.datamodel.SequenceI;
 -import jalview.util.UrlLink;
  import java.util.ArrayList;
  import java.util.Hashtable;
  import java.util.List;
Simple merge
@@@ -99,7 -77,7 +77,6 @@@ public class FeatureRenderer extend
          charOffset = (av_charWidth - fm.charWidth(s)) / 2;
          g.drawString(String.valueOf(s), charOffset
                  + (av_charWidth * (i - start)), pady);
--
        }
      }
    }
          charOffset = (av_charWidth - fm.charWidth(s)) / 2;
          g.drawString(String.valueOf(s), charOffset
                  + (av_charWidth * (i - start)), pady);
--
        }
      }
    }
        // current feature to render
        for (sfindex = 0; sfindex < sfSize; sfindex++)
        {
-         if (!sequenceFeatures[sfindex].type.equals(type))
 -        if (!lastSequenceFeatures[sfindex].type.equals(type))
++        final SequenceFeature sequenceFeature = lastSequenceFeatures[sfindex];
++        if (!sequenceFeature.type.equals(type))
          {
            continue;
          }
  
          if (featureGroups != null
-                 && sequenceFeatures[sfindex].featureGroup != null
-                 && sequenceFeatures[sfindex].featureGroup.length() != 0
-                 && featureGroups
-                         .containsKey(sequenceFeatures[sfindex].featureGroup)
-                 && !featureGroups
-                         .get(sequenceFeatures[sfindex].featureGroup)
 -                && lastSequenceFeatures[sfindex].featureGroup != null
 -                && lastSequenceFeatures[sfindex].featureGroup.length() != 0
 -                && featureGroups
 -                        .containsKey(lastSequenceFeatures[sfindex].featureGroup)
 -                && !featureGroups
 -.get(
 -                        lastSequenceFeatures[sfindex].featureGroup)
++                && sequenceFeature.featureGroup != null
++                && sequenceFeature.featureGroup.length() != 0
++                && featureGroups.containsKey(sequenceFeature.featureGroup)
++                && !featureGroups.get(sequenceFeature.featureGroup)
                          .booleanValue())
          {
            continue;
          }
  
          if (!offscreenRender
-                 && (sequenceFeatures[sfindex].getBegin() > epos || sequenceFeatures[sfindex]
 -                && (lastSequenceFeatures[sfindex].getBegin() > epos || lastSequenceFeatures[sfindex]
++                && (sequenceFeature.getBegin() > epos || sequenceFeature
                          .getEnd() < spos))
          {
            continue;
  
          if (offscreenRender && offscreenImage == null)
          {
-           if (sequenceFeatures[sfindex].begin <= start
-                   && sequenceFeatures[sfindex].end >= start)
 -          if (lastSequenceFeatures[sfindex].begin <= start
 -                  && lastSequenceFeatures[sfindex].end >= start)
++          if (sequenceFeature.begin <= start
++                  && sequenceFeature.end >= start)
            {
              // this is passed out to the overview and other sequence renderers
              // (e.g. molecule viewer) to get displayed colour for rendered
              // sequence
--            currentColour = new Integer(
-                     getColour(sequenceFeatures[sfindex]).getRGB());
 -getColour(
 -                    lastSequenceFeatures[sfindex]).getRGB());
++            currentColour = new Integer(getColour(sequenceFeature).getRGB());
              // used to be retreived from av.featuresDisplayed
              // currentColour = av.featuresDisplayed
              // .get(sequenceFeatures[sfindex].type);
  
            }
          }
-         else if (sequenceFeatures[sfindex].type.equals("disulfide bond"))
 -        else if (lastSequenceFeatures[sfindex].type
 -                .equals("disulfide bond"))
++        else if (sequenceFeature.type.equals("disulfide bond"))
          {
--
--          renderFeature(g, seq,
-                   seq.findIndex(sequenceFeatures[sfindex].begin) - 1,
-                   seq.findIndex(sequenceFeatures[sfindex].begin) - 1,
-                   getColour(sequenceFeatures[sfindex])
 -                  seq.findIndex(lastSequenceFeatures[sfindex].begin) - 1,
 -                  seq.findIndex(lastSequenceFeatures[sfindex].begin) - 1,
 -                  getColour(lastSequenceFeatures[sfindex])
++          renderFeature(g, seq, seq.findIndex(sequenceFeature.begin) - 1,
++                  seq.findIndex(sequenceFeature.begin) - 1,
++                  getColour(sequenceFeature)
                    // new Color(((Integer) av.featuresDisplayed
                    // .get(sequenceFeatures[sfindex].type)).intValue())
                    , start, end, y1);
--          renderFeature(g, seq,
-                   seq.findIndex(sequenceFeatures[sfindex].end) - 1,
-                   seq.findIndex(sequenceFeatures[sfindex].end) - 1,
-                   getColour(sequenceFeatures[sfindex])
 -                  seq.findIndex(lastSequenceFeatures[sfindex].end) - 1,
 -                  seq.findIndex(lastSequenceFeatures[sfindex].end) - 1,
 -                  getColour(lastSequenceFeatures[sfindex])
++          renderFeature(g, seq, seq.findIndex(sequenceFeature.end) - 1,
++                  seq.findIndex(sequenceFeature.end) - 1,
++                  getColour(sequenceFeature)
                    // new Color(((Integer) av.featuresDisplayed
                    // .get(sequenceFeatures[sfindex].type)).intValue())
                    , start, end, y1);
  
          }
-         else if (showFeature(sequenceFeatures[sfindex]))
 -        else if (showFeature(lastSequenceFeatures[sfindex]))
++        else if (showFeature(sequenceFeature))
          {
            if (av_isShowSeqFeatureHeight
-                   && sequenceFeatures[sfindex].score != Float.NaN)
 -                  && lastSequenceFeatures[sfindex].score != Float.NaN)
++                  && sequenceFeature.score != Float.NaN)
            {
              renderScoreFeature(g, seq,
-                     seq.findIndex(sequenceFeatures[sfindex].begin) - 1,
-                     seq.findIndex(sequenceFeatures[sfindex].end) - 1,
-                     getColour(sequenceFeatures[sfindex]), start, end, y1,
-                     normaliseScore(sequenceFeatures[sfindex]));
 -                    seq.findIndex(lastSequenceFeatures[sfindex].begin) - 1,
 -                    seq.findIndex(lastSequenceFeatures[sfindex].end) - 1,
 -                    getColour(lastSequenceFeatures[sfindex]), start, end,
 -                    y1, normaliseScore(lastSequenceFeatures[sfindex]));
++                    seq.findIndex(sequenceFeature.begin) - 1,
++                    seq.findIndex(sequenceFeature.end) - 1,
++                    getColour(sequenceFeature), start, end, y1,
++                    normaliseScore(sequenceFeature));
            }
            else
            {
--            renderFeature(g, seq,
-                     seq.findIndex(sequenceFeatures[sfindex].begin) - 1,
-                     seq.findIndex(sequenceFeatures[sfindex].end) - 1,
-                     getColour(sequenceFeatures[sfindex]), start, end, y1);
 -                    seq.findIndex(lastSequenceFeatures[sfindex].begin) - 1,
 -                    seq.findIndex(lastSequenceFeatures[sfindex].end) - 1,
 -                    getColour(lastSequenceFeatures[sfindex]), start, end,
 -                    y1);
++            renderFeature(g, seq, seq.findIndex(sequenceFeature.begin) - 1,
++                    seq.findIndex(sequenceFeature.end) - 1,
++                    getColour(sequenceFeature), start, end, y1);
            }
          }
  
@@@ -32,6 -32,6 +32,8 @@@ import java.util.regex.Pattern
   */
  public class ParseHtmlBodyAndLinks
  {
++  private static final Pattern LEFT_ANGLE_BRACKET_PATTERN = Pattern.compile("<");
++
    String orig = null;
  
    public String getOrig()
      {
        // instead of parsing the html into plaintext
        // clean the description ready for embedding in html
--      sb = new StringBuffer(Pattern.compile("<").matcher(description)
++      sb = new StringBuffer(LEFT_ANGLE_BRACKET_PATTERN.matcher(description)
                .replaceAll("&lt;"));
  
      }
index 0544864,0000000..1325ce5
mode 100644,000000..100644
--- /dev/null
@@@ -1,106 -1,0 +1,233 @@@
 +package jalview.util;
 +
++import java.util.ArrayList;
++import java.util.List;
++import java.util.regex.Pattern;
++
 +
 +public class StringUtils
 +{
++  private static final Pattern DELIMITERS_PATTERN = Pattern.compile(".*='[^']*(?!')");
++
++  private static final boolean DEBUG = false;
 +
 +  /**
 +   * Returns a new character array, after inserting characters into the given
 +   * character array.
 +   * 
 +   * @param in
 +   *          the character array to insert into
 +   * @param position
 +   *          the 0-based position for insertion
 +   * @param count
 +   *          the number of characters to insert
 +   * @param ch
 +   *          the character to insert
 +   */
 +  public static final char[] insertCharAt(char[] in, int position,
 +          int count,
 +          char ch)
 +  {
 +    char[] tmp = new char[in.length + count];
 +  
 +    if (position >= in.length)
 +    {
 +      System.arraycopy(in, 0, tmp, 0, in.length);
 +      position = in.length;
 +    }
 +    else
 +    {
 +      System.arraycopy(in, 0, tmp, 0, position);
 +    }
 +  
 +    int index = position;
 +    while (count > 0)
 +    {
 +      tmp[index++] = ch;
 +      count--;
 +    }
 +  
 +    if (position < in.length)
 +    {
 +      System.arraycopy(in, position, tmp, index,
 +              in.length - position);
 +    }
 +  
 +    return tmp;
 +  }
 +
 +  /**
 +   * Delete
 +   * 
 +   * @param in
 +   * @param from
 +   * @param to
 +   * @return
 +   */
 +  public static final char[] deleteChars(char[] in, int from, int to)
 +  {
 +    if (from >= in.length)
 +    {
 +      return in;
 +    }
 +
 +    char[] tmp;
 +
 +    if (to >= in.length)
 +    {
 +      tmp = new char[from];
 +      System.arraycopy(in, 0, tmp, 0, from);
 +      to = in.length;
 +    }
 +    else
 +    {
 +      tmp = new char[in.length - to + from];
 +      System.arraycopy(in, 0, tmp, 0, from);
 +      System.arraycopy(in, to, tmp, from, in.length - to);
 +    }
 +    return tmp;
 +  }
 +
 +  /**
 +   * Returns the last part of 'input' after the last occurrence of 'token'. For
 +   * example to extract only the filename from a full path or URL.
 +   * 
 +   * @param input
 +   * @param token
 +   *          a delimiter which must be in regular expression format
 +   * @return
 +   */
 +  public static String getLastToken(String input, String token)
 +  {
 +    if (input == null)
 +    {
 +      return null;
 +    }
 +    if (token == null)
 +    {
 +      return input;
 +    }
 +    String[] st = input.split(token);
 +    return st[st.length - 1];
 +  }
++
++  /**
++   * Parses the input string into components separated by the delimiter. Unlike
++   * String.split(), this method will ignore occurrences of the delimiter which
++   * are nested within single quotes in name-value pair values, e.g. a='b,c'.
++   * 
++   * @param input
++   * @param delimiter
++   * @return elements separated by separator
++   */
++  public static String[] separatorListToArray(String input, String delimiter)
++  {
++    int seplen = delimiter.length();
++    if (input == null || input.equals("") || input.equals(delimiter))
++    {
++      return null;
++    }
++    List<String> jv = new ArrayList<String>();
++    int cp = 0, pos, escape;
++    boolean wasescaped = false, wasquoted = false;
++    String lstitem = null;
++    while ((pos = input.indexOf(delimiter, cp)) >= cp)
++    {
++      escape = (pos > 0 && input.charAt(pos - 1) == '\\') ? -1 : 0;
++      if (wasescaped || wasquoted)
++      {
++        // append to previous pos
++        jv.set(jv.size() - 1,
++                lstitem = lstitem + delimiter
++                        + input.substring(cp, pos + escape));
++      }
++      else
++      {
++        jv.add(lstitem = input.substring(cp, pos + escape));
++      }
++      cp = pos + seplen;
++      wasescaped = escape == -1;
++      // last separator may be in an unmatched quote
++      wasquoted = DELIMITERS_PATTERN.matcher(lstitem).matches();
++    }
++    if (cp < input.length())
++    {
++      String c = input.substring(cp);
++      if (wasescaped || wasquoted)
++      {
++        // append final separator
++        jv.set(jv.size() - 1, lstitem + delimiter + c);
++      }
++      else
++      {
++        if (!c.equals(delimiter))
++        {
++          jv.add(c);
++        }
++      }
++    }
++    if (jv.size() > 0)
++    {
++      String[] v = jv.toArray(new String[jv.size()]);
++      jv.clear();
++      if (DEBUG)
++      {
++        System.err.println("Array from '" + delimiter
++                + "' separated List:\n" + v.length);
++        for (int i = 0; i < v.length; i++)
++        {
++          System.err.println("item " + i + " '" + v[i] + "'");
++        }
++      }
++      return v;
++    }
++    if (DEBUG)
++    {
++      System.err.println("Empty Array from '" + delimiter
++              + "' separated List");
++    }
++    return null;
++  }
++
++  /**
++   * Returns a string which contains the list elements delimited by the
++   * separator. Null items are ignored. If the input is null or has length zero,
++   * a single delimiter is returned.
++   * 
++   * @param list
++   * @param separator
++   * @return concatenated string
++   */
++  public static String arrayToSeparatorList(String[] list, String separator)
++  {
++    StringBuffer v = new StringBuffer();
++    if (list != null && list.length > 0)
++    {
++      for (int i = 0, iSize = list.length; i < iSize; i++)
++      {
++        if (list[i] != null)
++        {
++          if (v.length() > 0)
++          {
++            v.append(separator);
++          }
++          // TODO - escape any separator values in list[i]
++          v.append(list[i]);
++        }
++      }
++      if (DEBUG)
++      {
++        System.err.println("Returning '" + separator
++                + "' separated List:\n");
++        System.err.println(v);
++      }
++      return v.toString();
++    }
++    if (DEBUG)
++    {
++      System.err.println("Returning empty '" + separator
++              + "' separated List\n");
++    }
++    return "" + separator;
++  }
 +}
@@@ -46,6 -46,6 +46,8 @@@ import org.apache.http.entity.mime.cont
   */
  public abstract class InputType
  {
++  private static final Pattern URL_PATTERN = Pattern.compile("^([^=]+)=?'?([^']*)?'?");
++
    /**
     * not used yet
     */
      boolean valid = true;
      for (String tok : tokenstring)
      {
--      Matcher mtch = Pattern.compile("^([^=]+)=?'?([^']*)?'?").matcher(tok);
++      Matcher mtch = URL_PATTERN.matcher(tok);
        if (mtch.find())
        {
          try
   */
  package jalview.ws.rest;
  
--import jalview.datamodel.SequenceI;
--import jalview.io.packed.DataProvider.JvDataType;
--import jalview.ws.rest.params.Alignment;
--import jalview.ws.rest.params.AnnotationFile;
--import jalview.ws.rest.params.SeqGroupIndexVector;
--
  import java.net.URL;
  import java.util.ArrayList;
  import java.util.HashMap;
@@@ -37,8 -37,8 +31,17 @@@ import java.util.StringTokenizer
  import java.util.regex.Matcher;
  import java.util.regex.Pattern;
  
++import jalview.datamodel.SequenceI;
++import jalview.io.packed.DataProvider.JvDataType;
++import jalview.util.StringUtils;
++import jalview.ws.rest.params.Alignment;
++import jalview.ws.rest.params.AnnotationFile;
++import jalview.ws.rest.params.SeqGroupIndexVector;
++
  public class RestServiceDescription
  {
++  private static final Pattern PARAM_ENCODED_URL_PATTERN = Pattern.compile("([?&])([A-Za-z0-9_]+)=\\$([^$]+)\\$");
++
    /**
     * create a new rest service description ready to be configured
     */
      return invalidMessage == null;
    }
  
--  private static boolean debug = false;
--
--  /**
--   * parse the string into a list
--   * 
--   * @param list
--   * @param separator
--   * @return elements separated by separator
--   */
--  public static String[] separatorListToArray(String list, String separator)
--  {
--    int seplen = separator.length();
--    if (list == null || list.equals("") || list.equals(separator))
-     {
--      return null;
-     }
--    java.util.ArrayList<String> jv = new ArrayList<String>();
--    int cp = 0, pos, escape;
--    boolean wasescaped = false, wasquoted = false;
--    String lstitem = null;
--    while ((pos = list.indexOf(separator, cp)) >= cp)
--    {
--
--      escape = (pos > 0 && list.charAt(pos - 1) == '\\') ? -1 : 0;
--      if (wasescaped || wasquoted)
--      {
--        // append to previous pos
--        jv.set(jv.size() - 1,
--                lstitem = lstitem + separator
--                        + list.substring(cp, pos + escape));
-       }
-       else
-       {
-         jv.add(lstitem = list.substring(cp, pos + escape));
-       }
-       cp = pos + seplen;
-       wasescaped = escape == -1;
-       // last separator may be in an unmatched quote
-       wasquoted = (java.util.regex.Pattern.matches(".*='[^']*(?!')",
-               lstitem));
-     }
-     if (cp < list.length())
-     {
-       String c = list.substring(cp);
-       if (wasescaped || wasquoted)
-       {
-         // append final separator
-         jv.set(jv.size() - 1, lstitem + separator + c);
-       }
-       else
-       {
-         if (!c.equals(separator))
-         {
-           jv.add(c);
-         }
-       }
-     }
-     if (jv.size() > 0)
-     {
-       String[] v = jv.toArray(new String[jv.size()]);
-       jv.clear();
-       if (debug)
-       {
-         System.err.println("Array from '" + separator
-                 + "' separated List:\n" + v.length);
-         for (int i = 0; i < v.length; i++)
-         {
-           System.err.println("item " + i + " '" + v[i] + "'");
-         }
-       }
-       return v;
-     }
-     if (debug)
-     {
-       System.err.println("Empty Array from '" + separator
-               + "' separated List");
-     }
-     return null;
-   }
--
-   /**
-    * concatenate the list with separator
-    * 
-    * @param list
-    * @param separator
-    * @return concatenated string
-    */
-   public static String arrayToSeparatorList(String[] list, String separator)
-   {
-     StringBuffer v = new StringBuffer();
-     if (list != null && list.length > 0)
-     {
-       for (int i = 0, iSize = list.length; i < iSize; i++)
-       {
-         if (list[i] != null)
-         {
-           if (v.length() > 0)
-           {
-             v.append(separator);
-           }
-           // TODO - escape any separator values in list[i]
-           v.append(list[i]);
-         }
--      }
-       if (debug)
-       {
-         System.err.println("Returning '" + separator
-                 + "' separated List:\n");
-         System.err.println(v);
-       }
-       return v.toString();
-     }
-     if (debug)
-     {
-       System.err.println("Returning empty '" + separator
-               + "' separated List\n");
-     }
-     return "" + separator;
-   }
 -      else
 -      {
 -        jv.add(lstitem = list.substring(cp, pos + escape));
 -      }
 -      cp = pos + seplen;
 -      wasescaped = escape == -1;
 -      if (!wasescaped)
 -      {
 -        // last separator may be in an unmatched quote
 -        if (java.util.regex.Pattern.matches("('[^']*')*[^']*'", lstitem))
 -        {
 -          wasquoted = true;
 -        }
 -      }
 -
 -    }
 -    if (cp < list.length())
 -    {
 -      String c = list.substring(cp);
 -      if (wasescaped || wasquoted)
 -      {
 -        // append final separator
 -        jv.set(jv.size() - 1, lstitem + separator + c);
 -      }
 -      else
 -      {
 -        if (!c.equals(separator))
 -        {
 -          jv.add(c);
 -        }
 -      }
 -    }
 -    if (jv.size() > 0)
 -    {
 -      String[] v = jv.toArray(new String[jv.size()]);
 -      jv.clear();
 -      if (debug)
 -      {
 -        System.err.println("Array from '" + separator
 -                + "' separated List:\n" + v.length);
 -        for (int i = 0; i < v.length; i++)
 -        {
 -          System.err.println("item " + i + " '" + v[i] + "'");
 -        }
 -      }
 -      return v;
 -    }
 -    if (debug)
 -    {
 -      System.err.println("Empty Array from '" + separator
 -              + "' separated List");
 -    }
 -    return null;
 -  }
 -
 -  /**
 -   * concatenate the list with separator
 -   * 
 -   * @param list
 -   * @param separator
 -   * @return concatenated string
 -   */
 -  public static String arrayToSeparatorList(String[] list, String separator)
 -  {
 -    StringBuffer v = new StringBuffer();
 -    if (list != null && list.length > 0)
 -    {
 -      for (int i = 0, iSize = list.length; i < iSize; i++)
 -      {
 -        if (list[i] != null)
 -        {
 -          if (v.length() > 0)
 -          {
 -            v.append(separator);
 -          }
 -          // TODO - escape any separator values in list[i]
 -          v.append(list[i]);
 -        }
 -      }
 -      if (debug)
 -      {
 -        System.err.println("Returning '" + separator
 -                + "' separated List:\n");
 -        System.err.println(v);
 -      }
 -      return v.toString();
 -    }
 -    if (debug)
 -    {
 -      System.err.println("Returning empty '" + separator
 -              + "' separated List\n");
 -    }
 -    return "" + separator;
 -  }
 -
    /**
     * parse a string containing a list of service properties and configure the
     * service description
    private boolean configureFromServiceInputProperties(String propList,
            StringBuffer warnings)
    {
--    String[] props = separatorListToArray(propList, ",");
++    String[] props = StringUtils.separatorListToArray(propList, ",");
      if (props == null)
      {
        return true;
      ;
      vls.add(new String("gapCharacter='" + gapCharacter + "'"));
      vls.add(new String("returns='" + _genOutputFormatString() + "'"));
--    return arrayToSeparatorList(vls.toArray(new String[0]), ",");
++    return StringUtils.arrayToSeparatorList(vls.toArray(new String[0]), ",");
    }
  
    public String toString()
    public boolean configureFromEncodedString(String encoding,
            StringBuffer warnings)
    {
--    String[] list = separatorListToArray(encoding, "|");
++    String[] list = StringUtils.separatorListToArray(encoding, "|");
  
      int nextpos = parseServiceList(list, warnings, 0);
      if (nextpos > 0)
              url.append("$");
              url.append(param.getValue().getURLtokenPrefix());
              url.append(":");
--            url.append(arrayToSeparatorList(vals.toArray(new String[0]),
++            url.append(StringUtils.arrayToSeparatorList(vals.toArray(new String[0]),
                      ","));
              url.append("$");
            }
      boolean valid = true;
      int lastp = 0;
      String url = new String();
--    Matcher prms = Pattern.compile("([?&])([A-Za-z0-9_]+)=\\$([^$]+)\\$")
++    Matcher prms = PARAM_ENCODED_URL_PATTERN
              .matcher(ipurl);
      Map<String, InputType> iparams = new Hashtable<String, InputType>();
      InputType jinput;
          if (iprm.equalsIgnoreCase(jinput.getURLtokenPrefix()))
          {
            ArrayList<String> al = new ArrayList<String>();
--          for (String prprm : separatorListToArray(iprmparams, ","))
++          for (String prprm : StringUtils.separatorListToArray(iprmparams, ","))
            {
              // hack to ensure that strings like "sep=','" containing unescaped
              // commas as values are concatenated
    public static List<RestServiceDescription> parseDescriptions(
            String services) throws Exception
    {
--    String[] list = separatorListToArray(services, "|");
++    String[] list = StringUtils.separatorListToArray(services, "|");
      List<RestServiceDescription> svcparsed = new ArrayList<RestServiceDescription>();
      int p = 0, lastp = 0;
      StringBuffer warnings = new StringBuffer();
@@@ -247,7 -247,7 +247,6 @@@ public class SequenceTes
      assertEquals("zABCDEF", seq.getSequenceAsString());
      seq.insertCharAt(2, 2, 'x');
      assertEquals("zAxxBCDEF", seq.getSequenceAsString());
-     
 -
      // for static method see StringUtilsTest
    }
  
  package jalview.ext.paradise;
  
  import static org.junit.Assert.assertTrue;
--import jalview.datamodel.AlignmentI;
--import jalview.datamodel.SequenceI;
--import jalview.io.FastaFile;
--import jalview.io.FormatAdapter;
  
  import java.io.BufferedReader;
  import java.io.File;
@@@ -35,9 -35,9 +31,13 @@@ import org.junit.Assert
  import org.junit.Test;
  
  import MCview.PDBfile;
--
  import compbio.util.FileUtil;
  
++import jalview.datamodel.AlignmentI;
++import jalview.datamodel.SequenceI;
++import jalview.io.FastaFile;
++import jalview.io.FormatAdapter;
++
  public class TestAnnotate3D
  {
  
          sb.append(line + "\n");
        }
        assertTrue("No data returned by Annotate3D", sb.length() > 0);
--      AlignmentI al = new FormatAdapter().readFile(sb.toString(),
++      final String lines = sb.toString();
++      AlignmentI al = new FormatAdapter().readFile(lines,
                FormatAdapter.PASTE, "RNAML");
        if (al == null || al.getHeight() == 0)
        {
--        System.out.println(sb.toString());
++        System.out.println(lines);
        }
        assertTrue("No alignment returned.", al != null);
        assertTrue("No sequences in returned alignment.", al.getHeight() > 0);
              String sq_ = new String(sq.getSequence()).toLowerCase();
              for (SequenceI _struseq : pdbf.getSeqsAsArray())
              {
--              if (new String(_struseq.getSequence()).toLowerCase().equals(
++              final String lowerCase = new String(_struseq.getSequence()).toLowerCase();
++              if (lowerCase.equals(
                        sq_))
                {
                  struseq = _struseq;
index f1ea01c,0000000..f32e7ff
mode 100644,000000..100644
--- /dev/null
@@@ -1,426 -1,0 +1,402 @@@
 +package jalview.util;
 +
 +import static org.junit.Assert.assertEquals;
 +import static org.junit.Assert.assertSame;
 +import static org.junit.Assert.assertTrue;
 +import static org.junit.Assert.fail;
 +import jalview.api.AlignViewportI;
 +import jalview.datamodel.AlignedCodonFrame;
 +import jalview.datamodel.Alignment;
 +import jalview.datamodel.AlignmentI;
 +import jalview.datamodel.ColumnSelection;
 +import jalview.datamodel.SearchResults;
 +import jalview.datamodel.SearchResults.Match;
 +import jalview.datamodel.Sequence;
 +import jalview.datamodel.SequenceGroup;
 +import jalview.gui.AlignViewport;
 +import jalview.io.AppletFormatAdapter;
 +import jalview.io.FormatAdapter;
 +
 +import java.awt.Color;
 +import java.io.IOException;
 +import java.util.Arrays;
 +import java.util.Collections;
 +import java.util.Set;
 +
 +import org.junit.Test;
 +
 +public class MappingUtilsTest
 +{
 +  private AlignViewportI dnaView;
 +  private AlignViewportI proteinView;
 +
 +  /**
 +   * Simple test of mapping with no intron involved.
 +   */
 +  @Test
 +  public void testBuildSearchResults()
 +  {
 +    final Sequence seq1 = new Sequence("Seq1", "C-G-TA-GC");
 +    seq1.createDatasetSequence();
 +
 +    final Sequence aseq1 = new Sequence("Seq1", "-P-R");
 +    aseq1.createDatasetSequence();
 +
 +    /*
 +     * Map dna bases 1-6 to protein residues 1-2
 +     */
 +    AlignedCodonFrame acf = new AlignedCodonFrame();
 +    MapList map = new MapList(new int[]
 +    { 1, 6 }, new int[]
 +    { 1, 2 }, 3, 1);
 +    acf.addMap(seq1.getDatasetSequence(), aseq1.getDatasetSequence(), map);
 +    Set<AlignedCodonFrame> acfList = Collections.singleton(acf);
 +
 +    /*
 +     * Check protein residue 1 maps to codon 1-3, 2 to codon 4-6
 +     */
 +    SearchResults sr = MappingUtils.buildSearchResults(aseq1, 1, acfList);
 +    assertEquals(1, sr.getResults().size());
 +    Match m = sr.getResults().get(0);
 +    assertEquals(seq1.getDatasetSequence(), m.getSequence());
 +    assertEquals(1, m.getStart());
 +    assertEquals(3, m.getEnd());
 +    sr = MappingUtils.buildSearchResults(aseq1, 2, acfList);
 +    assertEquals(1, sr.getResults().size());
 +    m = sr.getResults().get(0);
 +    assertEquals(seq1.getDatasetSequence(), m.getSequence());
 +    assertEquals(4, m.getStart());
 +    assertEquals(6, m.getEnd());
 +
 +    /*
 +     * Check inverse mappings, from codons 1-3, 4-6 to protein 1, 2
 +     */
 +    for (int i = 1; i < 7; i++)
 +    {
 +      sr = MappingUtils.buildSearchResults(seq1, i, acfList);
 +      assertEquals(1, sr.getResults().size());
 +      m = sr.getResults().get(0);
 +      assertEquals(aseq1.getDatasetSequence(), m.getSequence());
 +      int residue = i > 3 ? 2 : 1;
 +      assertEquals(residue, m.getStart());
 +      assertEquals(residue, m.getEnd());
 +    }
 +  }
 +
 +  /**
 +   * Simple test of mapping with introns involved.
 +   */
 +  @Test
 +  public void testBuildSearchResults_withIntro()
 +  {
 +    final Sequence seq1 = new Sequence("Seq1", "C-G-TAGA-GCAGCTT");
 +    seq1.createDatasetSequence();
 +  
 +    final Sequence aseq1 = new Sequence("Seq1", "-P-R");
 +    aseq1.createDatasetSequence();
 +  
 +    /*
 +     * Map dna bases [2, 4, 5], [7, 9, 11] to protein residues 1 and 2
 +     */
 +    AlignedCodonFrame acf = new AlignedCodonFrame();
 +    MapList map = new MapList(new int[]
 +    { 2, 2, 4, 5, 7, 7, 9, 9, 11, 11 }, new int[]
 +    { 1, 2 }, 3, 1);
 +    acf.addMap(seq1.getDatasetSequence(), aseq1.getDatasetSequence(), map);
 +    Set<AlignedCodonFrame> acfList = Collections.singleton(acf);
 +  
 +    /*
 +     * Check protein residue 1 maps to [2, 4, 5]
 +     */
 +    SearchResults sr = MappingUtils.buildSearchResults(aseq1, 1, acfList);
 +    assertEquals(2, sr.getResults().size());
 +    Match m = sr.getResults().get(0);
 +    assertEquals(seq1.getDatasetSequence(), m.getSequence());
 +    assertEquals(2, m.getStart());
 +    assertEquals(2, m.getEnd());
 +    m = sr.getResults().get(1);
 +    assertEquals(seq1.getDatasetSequence(), m.getSequence());
 +    assertEquals(4, m.getStart());
 +    assertEquals(5, m.getEnd());
 +
 +    /*
 +     * Check protein residue 2 maps to [7, 9, 11]
 +     */
 +    sr = MappingUtils.buildSearchResults(aseq1, 2, acfList);
 +    assertEquals(3, sr.getResults().size());
 +    m = sr.getResults().get(0);
 +    assertEquals(seq1.getDatasetSequence(), m.getSequence());
 +    assertEquals(7, m.getStart());
 +    assertEquals(7, m.getEnd());
 +    m = sr.getResults().get(1);
 +    assertEquals(seq1.getDatasetSequence(), m.getSequence());
 +    assertEquals(9, m.getStart());
 +    assertEquals(9, m.getEnd());
 +    m = sr.getResults().get(2);
 +    assertEquals(seq1.getDatasetSequence(), m.getSequence());
 +    assertEquals(11, m.getStart());
 +    assertEquals(11, m.getEnd());
 +  
 +    /*
 +     * Check inverse mappings, from codons to protein
 +     */
 +    for (int i = 1; i < 14; i++)
 +    {
 +      sr = MappingUtils.buildSearchResults(seq1, i, acfList);
 +      int residue = (i == 2 || i == 4 || i == 5) ? 1 : (i == 7 || i == 9
 +              || i == 11 ? 2 : 0);
 +      if (residue == 0)
 +      {
 +        assertEquals(0, sr.getResults().size());
 +        continue;
 +      }
 +      assertEquals(1, sr.getResults().size());
 +      m = sr.getResults().get(0);
 +      assertEquals(aseq1.getDatasetSequence(), m.getSequence());
 +      assertEquals(residue, m.getStart());
 +      assertEquals(residue, m.getEnd());
 +    }
 +  }
 +
 +  /**
 +   * Test mapping a sequence group.
 +   * 
 +   * @throws IOException
 +   */
 +  @Test
 +  public void testMapSequenceGroup() throws IOException
 +  {
 +    /*
 +     * Set up dna and protein Seq1/2/3 with mappings (held on the protein
 +     * viewport).
 +     */
 +    AlignmentI cdna = loadAlignment(">Seq1\nACG\n>Seq2\nTGA\n>Seq3\nTAC\n",
 +            "FASTA");
 +    cdna.setDataset(null);
 +    AlignmentI protein = loadAlignment(">Seq1\nK\n>Seq2\nL\n>Seq3\nQ\n",
 +            "FASTA");
 +    protein.setDataset(null);
 +    AlignedCodonFrame acf = new AlignedCodonFrame();
 +    MapList map = new MapList(new int[]
 +    { 1, 3 }, new int[]
 +    { 1, 1 }, 3, 1);
 +    for (int seq = 0; seq < 3; seq++)
 +    {
 +      acf.addMap(cdna.getSequenceAt(seq).getDatasetSequence(), protein
 +              .getSequenceAt(seq).getDatasetSequence(), map);
 +    }
 +    Set<AlignedCodonFrame> acfList = Collections.singleton(acf);
 +
 +    AlignViewportI dnaView = new AlignViewport(cdna);
 +    AlignViewportI proteinView = new AlignViewport(protein);
 +    protein.setCodonFrames(acfList);
 +
 +    /*
 +     * Select Seq1 and Seq3 in the protein
 +     */
 +    SequenceGroup sg = new SequenceGroup();
 +    sg.setColourText(true);
 +    sg.setIdColour(Color.GREEN);
 +    sg.setOutlineColour(Color.LIGHT_GRAY);
 +    sg.addSequence(protein.getSequenceAt(0), false);
 +    sg.addSequence(protein.getSequenceAt(2), false);
 +
 +    /*
 +     * Verify the mapped sequence group in dna
 +     */
 +    SequenceGroup mappedGroup = MappingUtils.mapSequenceGroup(sg, proteinView, dnaView);
 +    assertTrue(mappedGroup.getColourText());
 +    assertSame(sg.getIdColour(), mappedGroup.getIdColour());
 +    assertSame(sg.getOutlineColour(), mappedGroup.getOutlineColour());
 +    assertEquals(2, mappedGroup.getSequences().size());
 +    assertSame(cdna.getSequenceAt(0), mappedGroup.getSequences().get(0));
 +    assertSame(cdna.getSequenceAt(2), mappedGroup.getSequences().get(1));
 +
 +    /*
 +     * Verify mapping sequence group from dna to protein
 +     */
 +    sg.clear();
 +    sg.addSequence(cdna.getSequenceAt(1), false);
 +    sg.addSequence(cdna.getSequenceAt(0), false);
 +    mappedGroup = MappingUtils.mapSequenceGroup(sg, dnaView, proteinView);
 +    assertTrue(mappedGroup.getColourText());
 +    assertSame(sg.getIdColour(), mappedGroup.getIdColour());
 +    assertSame(sg.getOutlineColour(), mappedGroup.getOutlineColour());
 +    assertEquals(2, mappedGroup.getSequences().size());
 +    assertSame(protein.getSequenceAt(1), mappedGroup.getSequences().get(0));
 +    assertSame(protein.getSequenceAt(0), mappedGroup.getSequences().get(1));
 +  }
 +
 +  /**
 +   * Helper method to load an alignment and ensure dataset sequences are set up.
 +   * 
 +   * @param data
 +   * @param format
 +   *          TODO
 +   * @return
 +   * @throws IOException
 +   */
 +  protected AlignmentI loadAlignment(final String data, String format)
 +          throws IOException
 +  {
 +    Alignment a = new FormatAdapter().readFile(data,
 +            AppletFormatAdapter.PASTE, format);
 +    a.setDataset(null);
 +    return a;
 +  }
 +
 +  /**
 +   * Test mapping a column selection in protein to its dna equivalent
 +   * 
 +   * @throws IOException
 +   */
 +  @Test
 +  public void testMapColumnSelection_proteinToDna() throws IOException
 +  {
 +    setupMappedAlignments();
 +  
 +    ColumnSelection colsel = new ColumnSelection();
 +
 +    /*
 +     * Column 0 in protein picks up Seq2/L, Seq3/G which map to cols 0-4 and 0-3
 +     * in dna respectively, overall 0-4
 +     */
 +    colsel.addElement(0);
 +    ColumnSelection cs = MappingUtils.mapColumnSelection(colsel,
 +            proteinView, dnaView);
 +    assertEquals("[0, 1, 2, 3, 4]", cs.getSelected().toString());
 +
 +    /*
 +     * Column 1 in protein picks up Seq1/K which maps to cols 0-3 in dna
 +     */
 +    colsel.clear();
 +    colsel.addElement(1);
 +    cs = MappingUtils.mapColumnSelection(colsel, proteinView, dnaView);
 +    assertEquals("[0, 1, 2, 3]", cs.getSelected().toString());
 +
 +    /*
 +     * Column 2 in protein picks up gaps only - no mapping
 +     */
 +    colsel.clear();
 +    colsel.addElement(2);
 +    cs = MappingUtils.mapColumnSelection(colsel, proteinView, dnaView);
 +    assertEquals("[]", cs.getSelected().toString());
 +
 +    /*
 +     * Column 3 in protein picks up Seq1/P, Seq2/Q, Seq3/S which map to columns
 +     * 6-9, 6-10, 5-8 respectively, overall to 5-10
 +     */
 +    colsel.clear();
 +    colsel.addElement(3);
 +    cs = MappingUtils.mapColumnSelection(colsel, proteinView, dnaView);
 +    assertEquals("[5, 6, 7, 8, 9, 10]", cs.getSelected().toString());
 +
 +    /*
 +     * Combine selection of columns 1 and 3 to get a discontiguous mapped
 +     * selection
 +     */
 +    colsel.clear();
 +    colsel.addElement(1);
 +    colsel.addElement(3);
 +    cs = MappingUtils.mapColumnSelection(colsel, proteinView, dnaView);
 +    assertEquals("[0, 1, 2, 3, 5, 6, 7, 8, 9, 10]", cs.getSelected()
 +            .toString());
 +  }
 +
 +  /**
 +   * @throws IOException
 +   */
 +  protected void setupMappedAlignments() throws IOException
 +  {
 +    /*
 +     * Set up dna and protein Seq1/2/3 with mappings (held on the protein
 +     * viewport). Lower case for introns.
 +     */
 +    AlignmentI cdna = loadAlignment(">Seq1\nAC-GctGtC-T\n"
 +            + ">Seq2\nTc-GA-G-T-Tc\n" + ">Seq3\nTtTT-AaCGg-\n",
 +            "FASTA");
 +    cdna.setDataset(null);
 +    AlignmentI protein = loadAlignment(
 +            ">Seq1\n-K-P\n>Seq2\nL--Q\n>Seq3\nG--S\n",
 +            "FASTA");
 +    protein.setDataset(null);
 +    AlignedCodonFrame acf = new AlignedCodonFrame();
 +    MapList map = new MapList(new int[]
 +    { 1, 3, 6, 6, 8, 9 }, new int[]
 +    { 1, 2 }, 3, 1);
 +    acf.addMap(cdna.getSequenceAt(0).getDatasetSequence(), protein
 +            .getSequenceAt(0).getDatasetSequence(), map);
 +    map = new MapList(new int[]
 +    { 1, 1, 3, 4, 5, 7 }, new int[]
 +    { 1, 2 }, 3, 1);
 +    acf.addMap(cdna.getSequenceAt(1).getDatasetSequence(), protein
 +            .getSequenceAt(1).getDatasetSequence(), map);
 +    map = new MapList(new int[]
 +    { 1, 1, 3, 4, 5, 5, 7, 8 }, new int[]
 +    { 1, 2 }, 3, 1);
 +    acf.addMap(cdna.getSequenceAt(2).getDatasetSequence(), protein
 +            .getSequenceAt(2).getDatasetSequence(), map);
 +    Set<AlignedCodonFrame> acfList = Collections.singleton(acf);
 +  
 +    dnaView = new AlignViewport(cdna);
 +    proteinView = new AlignViewport(protein);
 +    protein.setCodonFrames(acfList);
 +  }
 +
 +  /**
-    * Test mapping a column selection including hidden columns
-    * 
-    * @throws IOException
-    */
-   @Test
-   public void testMapColumnSelection_hiddenColumns() throws IOException
-   {
-     setupMappedAlignments();
-     ColumnSelection colsel = new ColumnSelection();
-   
-     /*
-      * Column 0 in protein picks up Seq2/L, Seq3/G which map to cols 0-4 and 0-3
-      * in dna respectively, overall 0-4
-      */
-     colsel.addElement(0);
-     ColumnSelection cs = MappingUtils.mapColumnSelection(colsel,
-             proteinView, dnaView);
-     assertEquals("[0, 1, 2, 3, 4]", cs.getSelected().toString());
-     fail("write me");
-   }
-   /**
 +   * Test mapping a column selection in dna to its protein equivalent
 +   * 
 +   * @throws IOException
 +   */
 +  @Test
 +  public void testMapColumnSelection_dnaToProtein() throws IOException
 +  {
 +    setupMappedAlignments();
 +  
 +    ColumnSelection colsel = new ColumnSelection();
 +  
 +    /*
 +     * Column 0 in dna picks up first bases which map to residue 1, columns 0-1
 +     * in protein.
 +     */
 +    colsel.addElement(0);
 +    ColumnSelection cs = MappingUtils.mapColumnSelection(colsel, dnaView,
 +            proteinView);
 +    assertEquals("[0, 1]", cs.getSelected().toString());
 +
 +    /*
 +     * Columns 3-5 in dna map to the first residues in protein Seq1, Seq2, and
 +     * the first two in Seq3. Overall to columns 0, 1, 3 (col2 is all gaps).
 +     */
 +    colsel.addElement(3);
 +    colsel.addElement(4);
 +    colsel.addElement(5);
 +    cs = MappingUtils.mapColumnSelection(colsel, dnaView, proteinView);
 +    assertEquals("[0, 1, 3]", cs.getSelected().toString());
 +  }
 +
 +  /**
 +   * Tests for the method that converts a series of [start, end] ranges to
 +   * single positions
 +   */
 +  @Test
 +  public void testFlattenRanges()
 +  {
 +    assertEquals("[1, 2, 3, 4]",
 +            Arrays.toString(MappingUtils.flattenRanges(new int[]
 +            { 1, 4 })));
 +    assertEquals("[1, 2, 3, 4]",
 +            Arrays.toString(MappingUtils.flattenRanges(new int[]
 +            { 1, 2, 3, 4 })));
 +    assertEquals("[1, 2, 3, 4]",
 +            Arrays.toString(MappingUtils.flattenRanges(new int[]
 +            { 1, 1, 2, 2, 3, 3, 4, 4 })));
 +    assertEquals("[1, 2, 3, 4, 7, 8, 9, 12]",
 +            Arrays.toString(MappingUtils.flattenRanges(new int[]
 +            { 1, 4, 7, 9, 12, 12 })));
 +    // unpaired start position is ignored:
 +    assertEquals("[1, 2, 3, 4, 7, 8, 9, 12]",
 +            Arrays.toString(MappingUtils.flattenRanges(new int[]
 +            { 1, 4, 7, 9, 12, 12, 15 })));
 +  }
 +}
index 22a4130,0000000..6930e40
mode 100644,000000..100644
--- /dev/null
@@@ -1,71 -1,0 +1,109 @@@
 +package jalview.util;
 +
 +import static org.junit.Assert.assertEquals;
 +import static org.junit.Assert.assertNull;
 +import static org.junit.Assert.assertTrue;
 +
 +import java.util.Arrays;
 +
 +import org.junit.Test;
 +
 +public class StringUtilsTest
 +{
 +
 +  @Test
 +  public void testInsertCharAt()
 +  {
 +    char[] c1 = "ABC".toCharArray();
 +    char[] expected = new char[]
 +    { 'A', 'B', 'C', 'w', 'w' };
 +    assertTrue(Arrays.equals(expected,
 +            StringUtils.insertCharAt(c1, 3, 2, 'w')));
 +    expected = new char[]
 +    { 'A', 'B', 'C', 'w', 'w' };
 +    assertTrue(Arrays.equals(expected,
 +            StringUtils.insertCharAt(c1, 4, 2, 'w')));
 +    assertTrue(Arrays.equals(expected,
 +            StringUtils.insertCharAt(c1, 5, 2, 'w')));
 +    assertTrue(Arrays.equals(expected,
 +            StringUtils.insertCharAt(c1, 6, 2, 'w')));
 +    assertTrue(Arrays.equals(expected,
 +            StringUtils.insertCharAt(c1, 7, 2, 'w')));
 +  }
 +
 +  @Test
 +  public void testDeleteChars()
 +  {
 +    char[] c1 = "ABC".toCharArray();
 +
 +    // delete second position
 +    assertTrue(Arrays.equals(new char[]
 +    { 'A', 'C' }, StringUtils.deleteChars(c1, 1, 2)));
 +
 +    // delete positions 1 and 2
 +    assertTrue(Arrays.equals(new char[]
 +    { 'C' }, StringUtils.deleteChars(c1, 0, 2)));
 +
 +    // delete positions 1-3
 +    assertTrue(Arrays.equals(new char[]
 +    {}, StringUtils.deleteChars(c1, 0, 3)));
 +
 +    // delete position 3
 +    assertTrue(Arrays.equals(new char[]
 +    { 'A', 'B' }, StringUtils.deleteChars(c1, 2, 3)));
 +
 +    // out of range deletion is ignore
 +    assertTrue(Arrays.equals(c1, StringUtils.deleteChars(c1, 3, 4)));
 +  }
 +
 +  @Test
 +  public void testGetLastToken()
 +  {
 +    assertNull(StringUtils.getLastToken(null, null));
 +    assertNull(StringUtils.getLastToken(null, "/"));
 +    assertEquals("a", StringUtils.getLastToken("a", null));
 +
 +    assertEquals("abc", StringUtils.getLastToken("abc", "/"));
 +    assertEquals("c", StringUtils.getLastToken("abc", "b"));
 +    assertEquals("file1.dat", StringUtils.getLastToken(
 +            "file://localhost:8080/data/examples/file1.dat", "/"));
 +  }
++
++  @Test
++  public void testSeparatorListToArray()
++  {
++    String[] result = StringUtils.separatorListToArray(
++            "foo=',',min='foo',max='1,2,3',fa=','", ",");
++    assertEquals("[foo=',', min='foo', max='1,2,3', fa=',']",
++            Arrays.toString(result));
++    /*
++     * Comma nested in '' is not treated as delimiter; tokens are not trimmed
++     */
++    result = StringUtils.separatorListToArray("minsize='2', sep=','", ",");
++    assertEquals("[minsize='2',  sep=',']", Arrays.toString(result));
++    
++    /*
++     * String delimited by | containing a quoted | (should not be treated as
++     * delimiter)
++     */
++    assertEquals("[abc='|'d, ef, g]", Arrays.toString(StringUtils
++            .separatorListToArray("abc='|'d|ef|g", "|")));
++  }
++
++  @Test
++  public void testArrayToSeparatorList()
++  {
++    assertEquals("*", StringUtils.arrayToSeparatorList(null, "*"));
++    assertEquals("*", StringUtils.arrayToSeparatorList(new String[]
++    {}, "*"));
++    assertEquals("a*bc*cde", StringUtils.arrayToSeparatorList(new String[]
++    { "a", "bc", "cde" }, "*"));
++    assertEquals("a*cde", StringUtils.arrayToSeparatorList(new String[]
++    { "a", null, "cde" }, "*"));
++    assertEquals("a**cde", StringUtils.arrayToSeparatorList(new String[]
++    { "a", "", "cde" }, "*"));
++    // delimiter within token is not (yet) escaped
++    assertEquals("a*b*c*cde", StringUtils.arrayToSeparatorList(new String[]
++    { "a", "b*c", "cde" }, "*"));
++  }
 +}
@@@ -23,17 -23,17 +23,6 @@@ package jalview.ws.jabaws
  import static org.junit.Assert.assertNotNull;
  import static org.junit.Assert.assertTrue;
  import static org.junit.Assert.fail;
--import jalview.datamodel.AlignmentI;
--import jalview.gui.Jalview2XML;
--import jalview.io.AnnotationFile;
--import jalview.io.FormatAdapter;
--import jalview.io.StockholmFileTest;
--import jalview.ws.jws2.JPred301Client;
--import jalview.ws.jws2.JabaParamStore;
--import jalview.ws.jws2.Jws2Discoverer;
--import jalview.ws.jws2.SequenceAnnotationWSClient;
--import jalview.ws.jws2.jabaws2.Jws2Instance;
--import jalview.ws.params.AutoCalcSetting;
  
  import java.awt.Component;
  import java.util.ArrayList;
@@@ -49,6 -49,6 +38,18 @@@ import org.junit.Test
  import compbio.metadata.Argument;
  import compbio.metadata.WrongParameterException;
  
++import jalview.datamodel.AlignmentI;
++import jalview.gui.Jalview2XML;
++import jalview.io.AnnotationFile;
++import jalview.io.FormatAdapter;
++import jalview.io.StockholmFileTest;
++import jalview.ws.jws2.JPred301Client;
++import jalview.ws.jws2.JabaParamStore;
++import jalview.ws.jws2.Jws2Discoverer;
++import jalview.ws.jws2.SequenceAnnotationWSClient;
++import jalview.ws.jws2.jabaws2.Jws2Instance;
++import jalview.ws.params.AutoCalcSetting;
++
  public class JpredJabaStructExportImport
  {
    public static String testseqs = "examples/uniref50.fa";
@@@ -81,7 -81,7 +82,7 @@@
  
      if (jpredws == null)
      {
--      System.exit(0);
++      fail("jpredws is null");
      }
  
      jalview.io.FileLoader fl = new jalview.io.FileLoader(false);
@@@ -23,16 -23,16 +23,6 @@@ package jalview.ws.jabaws
  import static org.junit.Assert.assertNotNull;
  import static org.junit.Assert.assertTrue;
  import static org.junit.Assert.fail;
--import jalview.datamodel.AlignmentI;
--import jalview.gui.Jalview2XML;
--import jalview.io.AnnotationFile;
--import jalview.io.FormatAdapter;
--import jalview.io.StockholmFileTest;
--import jalview.ws.jws2.Jws2Discoverer;
--import jalview.ws.jws2.RNAalifoldClient;
--import jalview.ws.jws2.SequenceAnnotationWSClient;
--import jalview.ws.jws2.jabaws2.Jws2Instance;
--import jalview.ws.params.AutoCalcSetting;
  
  import java.awt.Component;
  import java.util.ArrayList;
@@@ -47,6 -47,6 +37,17 @@@ import org.junit.Test
  
  import compbio.metadata.WrongParameterException;
  
++import jalview.datamodel.AlignmentI;
++import jalview.gui.Jalview2XML;
++import jalview.io.AnnotationFile;
++import jalview.io.FormatAdapter;
++import jalview.io.StockholmFileTest;
++import jalview.ws.jws2.Jws2Discoverer;
++import jalview.ws.jws2.RNAalifoldClient;
++import jalview.ws.jws2.SequenceAnnotationWSClient;
++import jalview.ws.jws2.jabaws2.Jws2Instance;
++import jalview.ws.params.AutoCalcSetting;
++
  public class RNAStructExportImport
  {
    public static String testseqs = "examples/unfolded_RF00031.aln";
@@@ -79,7 -79,7 +80,7 @@@
  
      if (rnaalifoldws == null)
      {
--      System.exit(0);
++      fail("no web service");
      }
  
      jalview.io.FileLoader fl = new jalview.io.FileLoader(false);
   */
  package jalview.ws.rest;
  
--import static org.junit.Assert.*;
++import static org.junit.Assert.assertNotNull;
++import static org.junit.Assert.assertTrue;
  
--import java.io.BufferedReader;
--import java.io.IOException;
--import java.util.ArrayList;
--import java.util.Hashtable;
--import java.util.List;
  import java.util.Map;
  
--import jalview.datamodel.AlignmentI;
--import jalview.datamodel.AlignmentView;
--import jalview.gui.AlignFrame;
--import jalview.io.FileParse;
--import jalview.ws.rest.InputType;
--import jalview.ws.rest.params.SeqGroupIndexVector;
--
--import org.junit.AfterClass;
--import org.junit.BeforeClass;
  import org.junit.Test;
  
++import jalview.gui.AlignFrame;
++import jalview.util.StringUtils;
++
  /**
   * @author jimp
   * 
@@@ -48,20 -48,20 +38,6 @@@ public class ShmmrRSBSServic
  {
  
    @Test
--  public void testSeparatorListToArrayForRestServiceDescriptions()
--  {
--    assertTrue(
--            "separatorListToArray is faulty.",
--            RestServiceDescription.separatorListToArray(
--                    "foo=',',min='foo',max='1,2,3',fa=','", ",").length == 4);
--    assertTrue("separatorListToArray is faulty.",
--            RestServiceDescription.separatorListToArray(
--                    "minsize='2', sep=','", ",").length != 2); // probably
--                                                               // should come as
--                                                               // 2
--  }
--
--  @Test
    public void testShmmrService()
    {
  
   */
  package jalview.ws.seqfetcher;
  
 -import static org.junit.Assert.*;
 +import static org.junit.Assert.assertEquals;
 +import static org.junit.Assert.assertNotNull;
 +import static org.junit.Assert.assertTrue;
- import jalview.analysis.CrossRef;
- import jalview.datamodel.AlignmentI;
- import jalview.datamodel.DBRefEntry;
- import jalview.datamodel.DBRefSource;
- import jalview.util.DBRefUtils;
- import jalview.ws.SequenceFetcher;
  
  import java.util.ArrayList;
  import java.util.List;
@@@ -37,6 -34,6 +31,13 @@@ import org.junit.AfterClass
  import org.junit.BeforeClass;
  import org.junit.Test;
  
++import jalview.analysis.CrossRef;
++import jalview.datamodel.AlignmentI;
++import jalview.datamodel.DBRefEntry;
++import jalview.datamodel.DBRefSource;
++import jalview.util.DBRefUtils;
++import jalview.ws.SequenceFetcher;
++
  /**
   * @author jimp
   *