Saturday, July 2, 2011

A text based, unparsed, context sensitive Xml viewer

Hi All,


Most modern programs today (in .NET) use Xml at some point of the other. If you have a configuration file it is written in Xml format. If you use human readable persistence or data transferal you probably do it using Xml. Many times you want to review a received XML with no real need to edit it. This was exactly the problem I was facing. 
In my application a user may receive some Xml data which he cannot edit but wants to view in a nice way. The user may interact with the Xml in a context sensitive manner meaning, if the user selected an Element he may get a different UI than when he selects an attribute. Previously I used a WebBrowser control which shows the Xml in a nice form (with folding) but it was not too customizeable (and to tell the truth I wanted to avoid COM and do something which is WPF based). The obvious solution was to use a tree. The Xml is organized in a tree based form anyway so a tweaked TreeView can be exactly what I needed. I looked around the internet and found this example: http://www.codeproject.com/KB/WPF/XMLViewer.aspx
The code in this example worked really well and I tweaked it a little to get the context sensitive nature I needed but there was one major drawback. It was not text editor based which made selections and other related interaction a hell. 
At this point I decided to write a simple text based viewer for my Xml. Before I continue I want to note that since I don't want to support editing I don't actually need a parser for my Xml. If full editing was needed then a parser would create the context sensitive functionality I need.


The editor I will be using is AvalonEdit which comes as a stand alone component in the SharpDevelop IDE. Whats nice about AvalonEdit is that I can get all the folding and coloring customization directly from the editor by setting SyntaxHighlighting="XML". For loading the Xml I am using XmlDocument (you can use XDocument if you like it better). I build a tree structure which represents the Xml where each node represents a single Xml element and the text range it takes within the text editor.


The base class for the ranges is:

 public abstract class BaseXmlTextRange : IComparable<BaseXmlTextRange>, IXPathProvider
  {
 
    protected const string IndentationString = "\t";
    /// <summary>
    /// The underlying node of the current range
    /// </summary>
    public XmlNode Node
    {
      get;
      protected set;
    }
 
    /// <summary>
    /// The start position of the current range
    /// </summary>
    public int Start
    {
      get;
      protected set;
    }
 
    /// <summary>
    /// The end position of the current range
    /// </summary>
    public int Length
    {
      get;
      protected set;
    }
 
    /// <summary>
    /// The last index of the range
    /// </summary>
    public int End
    {
      get
      {
        return Start + Length - 1;
      }
    }
 
    /// <summary>
    /// Represents the range that contains this range
    /// </summary>
    public BaseXmlTextRange Parent
    {
      get;
      set;
    }
 
    protected SortedSet<BaseXmlTextRange> _innerRanges;
 
    public BaseXmlTextRange(XmlNode node, int start)
    {
      _innerRanges = new SortedSet<BaseXmlTextRange>();
      Start = start;
      Node = node;
      Parent = null;
    }
 
    public ReadOnlyCollection<BaseXmlTextRange> InnerRanges
    {
      get
      {
        return new ReadOnlyCollection<BaseXmlTextRange>(_innerRanges.ToList());
      }
    }
 
    #region IComparable<BaseXmlTextRange> Members
 
    public int CompareTo(BaseXmlTextRange other)
    {
      if (other.Start > this.End) //other after this
        return this.End - other.Start;
 
      if (other.End < this.Start) //other before this
        return this.Start - other.End;
 
      throw new ArgumentException(String.Format("Ranges cannot overlap {0}-{1} and {2}-{3}", Start, End, other.Start, other.End));
    }
 
    #endregion
 
    public bool OffsetInRange(int offset)
    {
      if (offset >= Start && offset <= End)
        return true;
      return false;
    }
 
 
    #region IXPathProvider Members
 
    public IXPathProvider GetParent()
    {
      return Parent;
    }
 
 
    public abstract XPathData AppendXPathData(XPathData xPathData);
    #endregion
 
    public BaseXmlTextRange GetInnerRange(XmlNode xmlNode)
    {
      foreach (BaseXmlTextRange range in _innerRanges)
      {
        if (range.Node == xmlNode)
          return range;
      }
 
      return null;
    }
 
    /// <summary>
    /// Returns the smallest range corresponding to the given offset or null if the offset does not correspond to any range
    /// </summary>
    /// <param name="offset">The offset to look for</param>
    /// <returns></returns>
    public BaseXmlTextRange GetRangeByOffset(int offset)
    {
      if (!OffsetInRange(offset))
        return null;
 
      foreach (BaseXmlTextRange innerRange in _innerRanges)
      {
        if (innerRange.OffsetInRange(offset))
          return innerRange.GetRangeByOffset(offset);
      }
 
      return this;
    }
 
 
  }
I will give a brief explanation on most elements and you can fill in the details by examining the code. Each range holds the XmlNode which it represents. This can be either an Element, an Attribute, or a Value (the specific implementations are exactly for these node types). Each range holds the list of its inner ranges to create a tree like structure. The text editor "talks" with the ranges by providing an offset within the text therefore I provide a simple API method to locate a given node by the offset. Since Xml is a tree, I have the root node to start searching from.

Building the ranges is simple and is done through the following method:
    /// <summary>
    /// Builds the subtree of this range and returns the string that represents the associated node XML
    /// </summary>
    /// <param name="indentationLevel">The indentation level of this element</param>
    /// <returns></returns>
    public string BuildSubtree(int indentationLevel)
    {
      StringBuilder result = new StringBuilder();
 
      int count = 0; //Tracks the number of chars in this range
      result.Append(StringUtils.Repeat(IndentationString, indentationLevel));
      count += (indentationLevel * IndentationString.Length);
 
      result.Append("<");
      count++;
 
      result.Append(Node.Name);
      count += Node.Name.Length;
 
      result.Append(" ");
      count++;
 
      foreach (XmlAttribute attributeNode in Node.Attributes)
      {
        AttributeXmlTextRange attributeRange = new AttributeXmlTextRange(attributeNode, Start + count) { Parent = this };
        result.Append(attributeRange.BuildString());
        count += attributeRange.Length;
        _innerRanges.Add(attributeRange);
      }
 
      if (Node.ChildNodes.Count == 0) //No subelements!
      {
        result.Append("/>");
        count += 2;
 
        result.Append(Environment.NewLine);
        count += Environment.NewLine.Length;
      }
      else
      {
        //Close the element;
        result.Append(">");
        count++;
 
        result.Append(Environment.NewLine);
        count += Environment.NewLine.Length;
 
        foreach (XmlNode innerNode in Node.ChildNodes)
        {
          if (innerNode.NodeType == XmlNodeType.Element)
          {
            ElementXmlTextRange tempRange = new ElementXmlTextRange(innerNode, Start + count) { Parent = this };
            string innerElementString = tempRange.BuildSubtree(indentationLevel + 1);
            result.Append(innerElementString);
            _innerRanges.Add(tempRange);
            count += tempRange.Length;
          }
          else
            if (innerNode.NodeType == XmlNodeType.Text)
            {
              ValueXmlTextRange tempRange = new ValueXmlTextRange(innerNode, Start + count) { Parent = this };
              string innerElementString = tempRange.BuildString(indentationLevel + 1);
              result.Append(innerElementString);
              _innerRanges.Add(tempRange);
              count += tempRange.Length;
            }
        }
        //Append end element tag
        result.Append(StringUtils.Repeat(IndentationString, indentationLevel));
        count += indentationLevel;
 
        result.Append("</");
        count += 2;
 
        result.Append(Node.Name);
        count += Node.Name.Length;
 
        result.Append(">");
        count++;
 
        result.Append(Environment.NewLine);
        count += Environment.NewLine.Length;
      }
      Length = count;
      return result.ToString();
    }
I think the code speaks for itself but basically this is a recursive construction of Elements, Attributes, and Values using the ranges. The indentation level allows nice formatting of the text within the text editor. You can see the result in the following image:
An example application that uses the XmlTextViewer control

If you are interested in the code you can get it from my skydrive here.
Please note that while my code is under the CPOL license, AvalonEdit is under LGPL license.

Thank you for reading,
Boris