Partial implementation of XML parse for FindBugs output
authorChris Jaekl <chris@ringo.jaekl.net>
Thu, 3 Sep 2015 13:47:45 +0000 (22:47 +0900)
committerChris Jaekl <chris@ringo.jaekl.net>
Thu, 3 Sep 2015 13:47:45 +0000 (22:47 +0900)
20 files changed:
prod/net/jaekl/cfb/analyze/Analysis.java [new file with mode: 0644]
prod/net/jaekl/cfb/analyze/Analyzer.java [new file with mode: 0644]
prod/net/jaekl/cfb/util/Command.java [new file with mode: 0644]
prod/net/jaekl/cfb/util/Util.java [new file with mode: 0644]
prod/net/jaekl/cfb/xml/BugClass.java [new file with mode: 0644]
prod/net/jaekl/cfb/xml/BugCollection.java [new file with mode: 0644]
prod/net/jaekl/cfb/xml/BugInstance.java [new file with mode: 0644]
prod/net/jaekl/cfb/xml/BugMethod.java [new file with mode: 0644]
prod/net/jaekl/cfb/xml/SourceLine.java [new file with mode: 0644]
prod/net/jaekl/qd/QDException.java [new file with mode: 0644]
prod/net/jaekl/qd/xml/MismatchedTagsException.java [new file with mode: 0644]
prod/net/jaekl/qd/xml/MissingAttributeException.java [new file with mode: 0644]
prod/net/jaekl/qd/xml/MissingInfoException.java [new file with mode: 0644]
prod/net/jaekl/qd/xml/ParseErrorHandler.java [new file with mode: 0644]
prod/net/jaekl/qd/xml/ParseHandler.java [new file with mode: 0644]
prod/net/jaekl/qd/xml/ParseResult.java [new file with mode: 0644]
prod/net/jaekl/qd/xml/XmlParseException.java [new file with mode: 0644]
test/net/jaekl/cfb/analyze/AnalyzerTest.java [new file with mode: 0644]
test/net/jaekl/qd/xml/MissingInfoExceptionTest.java [new file with mode: 0644]
test/net/jaekl/qd/xml/ParseResultTest.java [new file with mode: 0644]

diff --git a/prod/net/jaekl/cfb/analyze/Analysis.java b/prod/net/jaekl/cfb/analyze/Analysis.java
new file mode 100644 (file)
index 0000000..e8a938a
--- /dev/null
@@ -0,0 +1,5 @@
+package net.jaekl.cfb.analyze;
+
+public class Analysis {
+
+}
diff --git a/prod/net/jaekl/cfb/analyze/Analyzer.java b/prod/net/jaekl/cfb/analyze/Analyzer.java
new file mode 100644 (file)
index 0000000..715cae9
--- /dev/null
@@ -0,0 +1,99 @@
+package net.jaekl.cfb.analyze;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.text.MessageFormat;
+import java.util.Locale;
+import java.util.Locale.Category;
+
+import net.jaekl.cfb.CfbBundle;
+import net.jaekl.cfb.util.Command;
+
+public class Analyzer {
+       File m_findbugsDir;
+       
+       public Analyzer(File findbugsDir) {
+               m_findbugsDir = findbugsDir;
+       }
+       
+       public Analysis analyze(PrintWriter pw, File workDir, File fbp) throws IOException {
+               Analysis result = new Analysis();
+       
+               File fbOutput = outputWorkFile(workDir, fbp);
+               
+               String cmdLine = buildCommandLine(workDir, fbp, fbOutput);
+               pw.println(cmdLine);
+               pw.flush();
+               Command.Result fbResult = new Command().exec(cmdLine);
+               if (0 != fbResult.getRetCode()) {
+                       // Our attempt to execute FindBugs failed.
+                       // Report the error and return null.
+                       String cannotExecFormat = trans(CfbBundle.CANNOT_EXEC);
+                       String cannotExecMsg = MessageFormat.format(cannotExecFormat, cmdLine, fbResult.getRetCode());
+                       pw.println(cannotExecMsg);
+                       pw.println(trans(CfbBundle.STDOUT_WAS));
+                       pw.println(fbResult.getStdout());
+                       pw.println(trans(CfbBundle.STDERR_WAS));
+                       pw.println(fbResult.getStderr());
+                       return null;
+               }
+               
+               result = parseFbOutput(fbOutput);
+               
+               return result;
+       }
+       
+       String trans(String key) {
+               return CfbBundle.getInst(Locale.getDefault(Category.DISPLAY)).get(key);
+       }
+       
+       String buildCommandLine(File workDir, File fbp, File fbOutput) 
+       {
+               assert(null != workDir);
+               assert(null != fbp);
+               assert(null != fbOutput);
+               
+               StringBuilder sb = new StringBuilder();
+               
+               sb.append(m_findbugsDir.getAbsolutePath())
+                 .append(File.separator)
+                 .append("bin")
+                 .append(File.separator)
+                 .append("findbugs -textui -xml -output ")
+                 .append(fbOutput.getAbsolutePath())
+                 .append(" -project ")
+                 .append(fbp.getAbsolutePath());
+               
+               return sb.toString();
+       }       
+       
+       // Come up with an appropriate name for the XML output file.
+       //   workDir:  place where the file should be created
+       //       fbp:  FindBugsProject file
+       File outputWorkFile(File workDir, File fbp) 
+       {
+               assert(null != workDir);
+               assert(null != fbp);
+               
+               String workPath = workDir.getAbsolutePath();
+               
+               String projName = fbp.getName();
+               int len = projName.length();
+               if (len > 4) {
+                       String extension = projName.substring(len - 4, len).toLowerCase(Locale.CANADA);
+                       if (extension.equals(".fbp")) {
+                               projName = projName.substring(0, len - 4);
+                       }
+               }
+               
+               return new File(workPath + File.separator + projName + ".xml");
+       }
+       
+       // Parse the output.xml that resulted from a FindBugs run,
+       // and store its findings into an Analysis object.
+       Analysis parseFbOutput(File fbOutput) 
+       {
+               return null;
+       }
+}
diff --git a/prod/net/jaekl/cfb/util/Command.java b/prod/net/jaekl/cfb/util/Command.java
new file mode 100644 (file)
index 0000000..4242eea
--- /dev/null
@@ -0,0 +1,98 @@
+package net.jaekl.cfb.util;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+
+public class Command {
+       public static class Result
+       {
+               private int m_retCode;
+               private String m_stdout;
+               private String m_stderr;
+               
+               Result(int retCode, String stdout, String stderr) {
+                       m_retCode = retCode;
+                       m_stdout = stdout;
+                       m_stderr = stderr;
+               }
+               
+               public int getRetCode() { return m_retCode; }
+               public String getStdout() { return m_stdout; }
+               public String getStderr() { return m_stderr; }
+       }
+       
+       private static class StreamGobbler extends Thread {
+               
+               private StringBuilder m_sb;
+               private BufferedReader m_br;
+               
+               public StreamGobbler(InputStream is) {
+                       m_sb = new StringBuilder();
+                       m_br = new BufferedReader(new InputStreamReader(is));
+               }
+               
+               @Override
+               public void run() {
+                       String line;
+                       try {
+                               line = m_br.readLine();
+                               while (null != line) {
+                                       m_sb.append(line).append(System.lineSeparator());
+                                       line = m_br.readLine();
+                               }
+                       } catch (IOException exc) {
+                               m_sb.append(Util.stringify(exc));
+                       }
+                       finally {
+                               try {
+                                       m_br.close();
+                               } catch (IOException exc) {
+                                       m_sb.append(Util.stringify(exc));
+                               }
+                       }
+               }
+               
+               public String getOutput() { return m_sb.toString(); }
+       }
+       
+       public Command() 
+       {
+               // no-op
+       }
+       
+       public Result exec(String cmd) throws IOException 
+       {
+               int retCode = 0;
+               String stdout = "";
+               String stderr = "";
+               
+               Process proc = doRuntimeExec(cmd);
+               assert( null != proc );
+               
+               StreamGobbler stdoutGobbler = new StreamGobbler(proc.getInputStream());
+               StreamGobbler stderrGobbler = new StreamGobbler(proc.getErrorStream());
+               
+               stdoutGobbler.start();
+               stderrGobbler.start();
+               
+               try {
+                       retCode = proc.waitFor();
+                       stdoutGobbler.join();
+                       stderrGobbler.join();
+                       
+                       stdout = stdoutGobbler.getOutput();
+                       stderr = stdoutGobbler.getOutput();
+                       
+               } catch (InterruptedException exc) {
+                       stderr += Util.stringify(exc);
+               }
+               
+               return new Result(retCode, stdout, stderr);
+       }
+       
+       Process doRuntimeExec(String cmd) throws IOException {
+               return Runtime.getRuntime().exec(cmd);
+       }
+}
diff --git a/prod/net/jaekl/cfb/util/Util.java b/prod/net/jaekl/cfb/util/Util.java
new file mode 100644 (file)
index 0000000..c0e7b1c
--- /dev/null
@@ -0,0 +1,13 @@
+package net.jaekl.cfb.util;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+public class Util {
+       public static String stringify(Throwable thr) {
+               StringWriter sw = new StringWriter();
+               PrintWriter pw = new PrintWriter(sw);
+               thr.printStackTrace(pw);
+               return sw.toString();
+       }
+}
diff --git a/prod/net/jaekl/cfb/xml/BugClass.java b/prod/net/jaekl/cfb/xml/BugClass.java
new file mode 100644 (file)
index 0000000..34e02a2
--- /dev/null
@@ -0,0 +1,32 @@
+package net.jaekl.cfb.xml;
+
+import org.xml.sax.Attributes;
+
+import net.jaekl.qd.xml.ParseResult;
+import net.jaekl.qd.xml.XmlParseException;
+
+public class BugClass extends ParseResult {
+       
+       static final String ROOT_TAG = "Class";
+       static final String[] INTERNAL = { };
+       static final Object[][] EXTERNAL = { { SourceLine.ROOT_TAG, SourceLine.class} };
+
+       public BugClass() {
+               super(ROOT_TAG, INTERNAL, EXTERNAL);
+       }
+       
+       @Override
+       public void endContents(String uri, String localName, String qName,
+                       String chars, Attributes attr) throws XmlParseException {
+               // TODO Auto-generated method stub
+
+       }
+
+       @Override
+       public void endExternal(String uri, String localName, String qName)
+                       throws XmlParseException {
+               // TODO Auto-generated method stub
+
+       }
+
+}
diff --git a/prod/net/jaekl/cfb/xml/BugCollection.java b/prod/net/jaekl/cfb/xml/BugCollection.java
new file mode 100644 (file)
index 0000000..9078e2f
--- /dev/null
@@ -0,0 +1,32 @@
+package net.jaekl.cfb.xml;
+
+import org.xml.sax.Attributes;
+
+import net.jaekl.qd.xml.ParseResult;
+import net.jaekl.qd.xml.XmlParseException;
+
+public class BugCollection extends ParseResult {
+
+       static final String ROOT_TAG = "BugCollection";
+       static final String[] INTERNAL = { };
+       static final Object[][] EXTERNAL = { { BugInstance.ROOT_TAG, BugInstance.class} };
+
+       public BugCollection() {
+               super(ROOT_TAG, INTERNAL, EXTERNAL);
+       }
+       
+       @Override
+       public void endContents(String uri, String localName, String qName,
+                       String chars, Attributes attr) throws XmlParseException {
+               // TODO Auto-generated method stub
+
+       }
+
+       @Override
+       public void endExternal(String uri, String localName, String qName)
+                       throws XmlParseException {
+               // TODO Auto-generated method stub
+
+       }
+
+}
diff --git a/prod/net/jaekl/cfb/xml/BugInstance.java b/prod/net/jaekl/cfb/xml/BugInstance.java
new file mode 100644 (file)
index 0000000..81ebcfc
--- /dev/null
@@ -0,0 +1,34 @@
+package net.jaekl.cfb.xml;
+
+import org.xml.sax.Attributes;
+
+import net.jaekl.qd.xml.ParseResult;
+import net.jaekl.qd.xml.XmlParseException;
+
+public class BugInstance extends ParseResult {
+
+       static final String ROOT_TAG = "BugInstance";
+       static final String[] INTERNAL = {  };
+       static final Object[][] EXTERNAL = { { BugClass.ROOT_TAG, BugClass.class},
+                                                { BugMethod.ROOT_TAG, BugMethod.class},
+                                                { SourceLine.ROOT_TAG, SourceLine.class} };
+
+       public BugInstance() {
+               super(ROOT_TAG, INTERNAL, EXTERNAL);
+       }
+       
+       @Override
+       public void endContents(String uri, String localName, String qName,
+                       String chars, Attributes attr) throws XmlParseException {
+               // TODO Auto-generated method stub
+
+       }
+
+       @Override
+       public void endExternal(String uri, String localName, String qName)
+                       throws XmlParseException {
+               // TODO Auto-generated method stub
+
+       }
+
+}
diff --git a/prod/net/jaekl/cfb/xml/BugMethod.java b/prod/net/jaekl/cfb/xml/BugMethod.java
new file mode 100644 (file)
index 0000000..6a63b43
--- /dev/null
@@ -0,0 +1,32 @@
+package net.jaekl.cfb.xml;
+
+import org.xml.sax.Attributes;
+
+import net.jaekl.qd.xml.ParseResult;
+import net.jaekl.qd.xml.XmlParseException;
+
+public class BugMethod extends ParseResult {
+       
+       static final String ROOT_TAG = "Method";
+       static final String[] INTERNAL = { };
+       static final Object[][] EXTERNAL = { { SourceLine.ROOT_TAG, SourceLine.class} };
+
+       public BugMethod() {
+               super(ROOT_TAG, INTERNAL, EXTERNAL);
+       }
+
+       @Override
+       public void endContents(String uri, String localName, String qName,
+                       String chars, Attributes attr) throws XmlParseException {
+               // TODO Auto-generated method stub
+
+       }
+
+       @Override
+       public void endExternal(String uri, String localName, String qName)
+                       throws XmlParseException {
+               // TODO Auto-generated method stub
+
+       }
+
+}
diff --git a/prod/net/jaekl/cfb/xml/SourceLine.java b/prod/net/jaekl/cfb/xml/SourceLine.java
new file mode 100644 (file)
index 0000000..c78ae3a
--- /dev/null
@@ -0,0 +1,61 @@
+package net.jaekl.cfb.xml;
+
+import org.xml.sax.Attributes;
+
+import net.jaekl.qd.xml.MissingAttributeException;
+import net.jaekl.qd.xml.ParseResult;
+import net.jaekl.qd.xml.XmlParseException;
+
+public class SourceLine extends ParseResult {
+
+       static final String ROOT_TAG = "SourceLine";
+       static final String[] INTERNAL = { };
+       static final Object[][] EXTERNAL = { };
+       
+       static final String ATTR_CLASS_NAME = "classname";
+       static final String ATTR_START = "start";
+       static final String ATTR_END = "end";
+       
+       String m_className;
+       int m_start;
+       int m_end;
+
+       public SourceLine() {
+               super(ROOT_TAG, INTERNAL, EXTERNAL);
+               m_className = null;
+               m_start = m_end = (-1);
+       }       
+       
+       @Override
+       public void endContents(String uri, String localName, String qName,     String chars, Attributes attr) 
+               throws XmlParseException 
+       {
+               String scratch;
+               
+               m_className = getRequiredAttr(localName, attr, ATTR_CLASS_NAME);
+               
+               scratch = getRequiredAttr(localName, attr, ATTR_START);
+               m_start = Integer.parseInt(scratch);
+               
+               scratch = getRequiredAttr(localName, attr, ATTR_END);
+               m_end = Integer.parseInt(scratch);
+       }
+       
+       String getRequiredAttr(String tagName, Attributes attr, String attrName)
+               throws MissingAttributeException
+       {
+               String result = attr.getValue(attrName);
+               if (null == result) {
+                       throw new MissingAttributeException(tagName, attrName);
+               }
+               return result;
+       }
+
+       @Override
+       public void endExternal(String uri, String localName, String qName)
+               throws XmlParseException 
+       {
+               // no-op
+       }
+
+}
diff --git a/prod/net/jaekl/qd/QDException.java b/prod/net/jaekl/qd/QDException.java
new file mode 100644 (file)
index 0000000..f5478c1
--- /dev/null
@@ -0,0 +1,14 @@
+package net.jaekl.qd;
+
+public class QDException extends Exception
+{
+       private static final long serialVersionUID = 1L;
+
+       public QDException() {
+               super();
+       }
+       
+       public QDException(Throwable t) {
+               super(t);
+       }
+}
diff --git a/prod/net/jaekl/qd/xml/MismatchedTagsException.java b/prod/net/jaekl/qd/xml/MismatchedTagsException.java
new file mode 100644 (file)
index 0000000..9c8b69c
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) 2004, 2014 Christian Jaekl
+
+package net.jaekl.qd.xml;
+
+
+public class MismatchedTagsException extends XmlParseException
+{
+       private static final long serialVersionUID = 1L;
+
+       String m_expected;
+       String m_actual;
+
+       public MismatchedTagsException(String expected, String actual) {
+               super();
+               m_expected = expected;
+               m_actual = actual;
+       }
+
+       public String getExpected() { return m_expected; }
+       public String getActual() { return m_actual; }
+}
diff --git a/prod/net/jaekl/qd/xml/MissingAttributeException.java b/prod/net/jaekl/qd/xml/MissingAttributeException.java
new file mode 100644 (file)
index 0000000..d76fe00
--- /dev/null
@@ -0,0 +1,14 @@
+package net.jaekl.qd.xml;
+
+public class MissingAttributeException extends XmlParseException {
+       private static final long serialVersionUID = 1L;
+       
+       String m_tagName;
+       String m_attributeName;
+       
+       public MissingAttributeException(String tagName, String attributeName)
+       {
+               m_tagName = tagName;
+               m_attributeName = attributeName;
+       }
+}
diff --git a/prod/net/jaekl/qd/xml/MissingInfoException.java b/prod/net/jaekl/qd/xml/MissingInfoException.java
new file mode 100644 (file)
index 0000000..1def37f
--- /dev/null
@@ -0,0 +1,47 @@
+// Copyright (C) 2004, 2014 Christian Jaekl
+
+package net.jaekl.qd.xml;
+
+import java.util.ArrayList;
+
+public class MissingInfoException extends XmlParseException
+{
+       private static final long serialVersionUID = 1L;
+
+       String m_tagName;
+       ArrayList<String> m_missingAttributes;
+       ArrayList<String> m_missingChildTags;
+
+       public MissingInfoException(String tagName) {
+               super();
+               m_tagName = tagName;
+               m_missingAttributes = new ArrayList<String>();
+               m_missingChildTags = new ArrayList<String>();
+       }
+
+       public void addMissingAttribute(String name) {
+               m_missingAttributes.add(name);
+       }
+
+       public void addMissingChild(String name) {
+               m_missingChildTags.add(name);
+       }
+
+       public String getTagName() { return m_tagName; }
+       
+       @Override
+       public String getMessage() {
+               StringBuilder sb = new StringBuilder();
+               
+               sb.append("Tag:  \"" + getTagName() + "\"");
+               
+               for (String attr : m_missingAttributes) {
+                       sb.append("\n  Attribute:  \"" + attr + "\"");
+               }
+               
+               for (String child : m_missingChildTags) {
+                       sb.append("\n  Child tag:  \"" + child + "\"");
+               }
+               return sb.toString();
+       }
+}
diff --git a/prod/net/jaekl/qd/xml/ParseErrorHandler.java b/prod/net/jaekl/qd/xml/ParseErrorHandler.java
new file mode 100644 (file)
index 0000000..ecdb780
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) 2014 Christian Jaekl
+
+// Simple SAX parse error handler.
+// Necessary to avoid printing [Fatal Error] messages to stdout when something goes wrong.
+
+package net.jaekl.qd.xml;
+
+import org.xml.sax.ErrorHandler;
+import org.xml.sax.SAXException;
+import org.xml.sax.SAXParseException;
+
+public class ParseErrorHandler implements ErrorHandler {
+
+       @Override
+       public void error(SAXParseException saxpe) throws SAXException {
+               throw saxpe;
+       }
+
+       @Override
+       public void fatalError(SAXParseException saxpe) throws SAXException {
+               throw saxpe;
+       }
+
+       @Override
+       public void warning(SAXParseException saxpe) throws SAXException {
+               throw saxpe;
+       }
+
+}
diff --git a/prod/net/jaekl/qd/xml/ParseHandler.java b/prod/net/jaekl/qd/xml/ParseHandler.java
new file mode 100644 (file)
index 0000000..6bb2803
--- /dev/null
@@ -0,0 +1,123 @@
+// Copyright (C) 2004, 2015 Christian Jaekl
+
+package net.jaekl.qd.xml;
+
+import java.util.Stack;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.Locator;
+import org.xml.sax.SAXException;
+
+public class ParseHandler implements ContentHandler
+{
+       Stack<ParseResult> m_stack;
+
+       public ParseHandler(ParseResult root) {
+               m_stack = new Stack<ParseResult>();
+               m_stack.push(root);
+       }
+
+       @Override 
+       public void characters(char[] ch, int start, int length) throws SAXException
+       {
+               if (m_stack.isEmpty()) {
+                       return;
+               }
+               
+               try {
+                       m_stack.peek().characters(ch, start, length);
+               }
+               catch (XmlParseException xpe) {
+                       throw new SAXException(xpe);
+               }
+       }
+
+       @Override 
+       public void endElement(String uri, String localName, String qName) throws SAXException
+       {
+               try {
+                       if (m_stack.isEmpty()) {
+                               return;
+                       }
+                       
+                       boolean pop = m_stack.peek().endElement(uri, localName, qName);
+                       if (pop) {
+                               m_stack.pop();
+
+                               if (m_stack.isEmpty()) {
+                                       return;
+                               }
+                               
+                               m_stack.peek().endExternal(uri, localName, qName);
+                       }
+               }
+               catch (XmlParseException xpe) {
+                       throw new SAXException(xpe);
+               }
+       }
+
+       @Override
+       public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException
+       {
+               try {
+                       ParseResult current = m_stack.peek();
+                       ParseResult next = current.startElement(uri, localName, qName, attributes);
+                       if (next != current) {
+                               m_stack.push(next);
+                       }
+               }
+               catch (XmlParseException xpe) {
+                       throw new SAXException(xpe);
+               }
+       }
+
+       @Override
+       public void endDocument() throws SAXException {
+               if (! m_stack.isEmpty()) {
+                       String missingTag = m_stack.peek().getTagName();
+                       throw new SAXException(new MissingInfoException(missingTag));
+               }
+       }
+
+       @Override
+       public void endPrefixMapping(String prefix) throws SAXException {
+               // no-op
+       }
+
+       @Override
+       public void ignorableWhitespace(char[] ch, int start, int length)
+       throws SAXException 
+       {
+               // no-op
+       }
+
+       @Override
+       public void processingInstruction(String target, String data)
+       throws SAXException 
+       {
+               // no-op
+       }
+
+       @Override
+       public void setDocumentLocator(Locator locator) {
+               // no-op
+       }
+
+       @Override
+       public void skippedEntity(String name) throws SAXException {
+               // no-op
+       }
+
+       @Override
+       public void startDocument() throws SAXException {
+               // no-op
+       }
+
+       @Override
+       public void startPrefixMapping(String prefix, String uri)
+       throws SAXException 
+       {
+               // no-op
+       }
+}
diff --git a/prod/net/jaekl/qd/xml/ParseResult.java b/prod/net/jaekl/qd/xml/ParseResult.java
new file mode 100644 (file)
index 0000000..6c2c25f
--- /dev/null
@@ -0,0 +1,183 @@
+// Copyright (C) 2004, 2015 Christian Jaekl
+
+// Abstract class representing the result of parsing an XML Element.
+// A class derived from this one will know how to parse a subtree inside an XML file, and 
+// will contain the result of that parse within itself when the parse has completed.
+//
+// Note that this code will need to be augmented and fixed if XML namespace support is desired.
+
+package net.jaekl.qd.xml;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Stack;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.helpers.AttributesImpl;
+
+public abstract class ParseResult
+{
+       Stack<CurrentInfo> m_current;                                                           // Name of the element that we're currently inside
+       StringBuilder m_chars;                                                                          // character content of m_current.peek()
+       ArrayList<ParseResult> m_childParsers;                                          // Set of all child parsers
+       boolean m_haveSeenMyTag;                                                                        // Have I encountered my own (root) tag yet?
+
+       String m_tagName;                                                                                       // Name of the (root) element tag that I'm parsing
+       HashSet<String> m_internal;                                                                     // Tags that we will store as members of this class instance
+       HashMap<String,Class<? extends ParseResult>> m_external;        // Tags that we will store as child ParseResult-derived objects
+
+       // Information about the "current" tag, which we'll keep on the m_current stack.
+       static class CurrentInfo {
+               String m_tagName;
+               Attributes m_attr;
+               
+               public CurrentInfo(String tagName, Attributes attr) {
+                       m_tagName = tagName;
+                       m_attr = new AttributesImpl(attr);
+               }
+               
+               public String getTagName() { return m_tagName; }
+               public final Attributes getAttributes() { return m_attr; }
+       }
+       
+       @SuppressWarnings("unchecked")
+       public ParseResult(String tagName, String[] internalMemberTags, Object[][] externalParserTags)
+       {
+               m_current = new Stack<CurrentInfo>();
+               m_chars = new StringBuilder();
+               m_childParsers = new ArrayList<ParseResult>();
+               m_haveSeenMyTag = false;
+               
+               m_tagName = tagName;
+               m_internal = new HashSet<String>();
+               m_external = new HashMap<String, Class<? extends ParseResult>>();
+
+               for (String internalTag : internalMemberTags) {
+                       m_internal.add(internalTag);
+               }
+
+               for (int idx = 0; idx < externalParserTags.length; ++idx) {
+                       String externalTag = (String)externalParserTags[idx][0];
+                       Class<? extends ParseResult>  parserClass = (Class<? extends ParseResult>)externalParserTags[idx][1];
+                       m_external.put(externalTag, parserClass);
+               }
+       }
+
+       public abstract void endContents(String uri, String localName, String qName, String chars, Attributes attr) throws XmlParseException;
+       public abstract void endExternal(String uri, String localName, String qName) throws XmlParseException;
+       
+       public String getTagName() { return m_tagName; }
+       public boolean haveSeenMyTag() { return m_haveSeenMyTag; }
+
+       public void characters(char[] ch, int start, int length) throws XmlParseException
+       {
+               m_chars.append(ch, start, length);
+       }
+       
+       protected ParseResult[] collectParsedChildren(Class<? extends ParseResult> cls) {
+               ArrayList<ParseResult> collection = new ArrayList<ParseResult>();
+               Iterator<ParseResult> iter = m_childParsers.iterator();
+               while (iter.hasNext()) {
+                       ParseResult pr = iter.next();
+                       if (pr.getClass().isAssignableFrom(cls)) {
+                               collection.add(pr);
+                               iter.remove();
+                       }
+               }
+               
+               ParseResult[] result = new ParseResult[collection.size()];
+               return collection.toArray(result);
+       }
+
+       // returns true if this ParseResult's context has ended with this endElement() call
+       public boolean endElement(String uri, String localName, String qName) throws XmlParseException
+       {
+               assert (null != localName);
+               
+               boolean isInternal = m_internal.contains(localName);
+
+               if (! m_haveSeenMyTag) {
+                       // We're in some unrecognised prologue.  Ignore it and move on.
+                       return false;
+               }
+               
+               if (m_tagName.equals(localName)) {
+                       validate();
+                       return true;
+               }
+               
+               if (!isInternal) {
+                       // Unrecognized tag.  Ignore it.
+                       return false;
+               }
+               
+               CurrentInfo info = m_current.pop();
+               String tag = info.getTagName();
+               if ( ! tag.equals(localName) ) {
+                       throw new MismatchedTagsException(tag, localName);
+               }
+               
+               String chars = m_chars.toString();
+               endContents(uri, localName, qName, chars, info.getAttributes());
+               
+               return false;
+       }
+       
+       // returns either itself, or a new ParseResult-derived object, whichever should handle parsing the inside of this element
+       public ParseResult startElement(String uri, String localName, String qName, Attributes attributes) 
+                       throws XmlParseException
+       {
+               assert (null != localName);
+
+               m_chars.setLength(0);
+               
+               if (! m_haveSeenMyTag) {
+                       // Have we opened our own (root) tag yet?
+                       
+                       if (m_tagName.equals(localName)) {
+                               m_haveSeenMyTag = true;
+                               return this;
+                       }
+                       else {
+                               // One of two things has happened here.
+                               // Either (a) we've got some sort of wrapper here, and have not yet reach our own tag, 
+                               //     or (b) we're parsing XML that doesn't match expectations.
+                               // In either case, we're going to ignore this tag, and scan forward looking for our own root.
+                               return this;
+                       }
+               }
+
+               if (m_internal.contains(localName)) {
+                       CurrentInfo info = new CurrentInfo(localName, attributes);
+                       m_current.push(info);
+                       return this;
+               }
+
+               Class<? extends ParseResult> parserClass = m_external.get(localName);
+               if (null != parserClass) {
+                       try {
+                               ParseResult childParser = (ParseResult) parserClass.newInstance();
+                               m_childParsers.add(childParser);
+                               return childParser.startElement(uri, localName, qName, attributes);
+                       }
+                       catch (IllegalAccessException iae) {
+                               throw new XmlParseException(iae);
+                       }
+                       catch (InstantiationException ie) {
+                               throw new XmlParseException(ie);
+                       }
+               }
+               
+               // Not a recognized tag.  Ignore it, rather than complaining. 
+               return this;
+       }
+       
+       public void validate() throws XmlParseException
+       {
+               // Default implementation is a no-op.
+               // Override if you want to validate on endElement()
+       }
+}
+
diff --git a/prod/net/jaekl/qd/xml/XmlParseException.java b/prod/net/jaekl/qd/xml/XmlParseException.java
new file mode 100644 (file)
index 0000000..ada3fe5
--- /dev/null
@@ -0,0 +1,17 @@
+package net.jaekl.qd.xml;
+
+import net.jaekl.qd.QDException;
+
+public class XmlParseException extends QDException 
+{
+       private static final long serialVersionUID = 1L;
+
+       public XmlParseException() {
+               // no-op
+       }
+       
+       public XmlParseException(Throwable t) {
+               super(t);
+       }
+
+}
diff --git a/test/net/jaekl/cfb/analyze/AnalyzerTest.java b/test/net/jaekl/cfb/analyze/AnalyzerTest.java
new file mode 100644 (file)
index 0000000..d7e8395
--- /dev/null
@@ -0,0 +1,36 @@
+package net.jaekl.cfb.analyze;
+
+import static org.junit.Assert.*;
+
+import java.io.File;
+
+import org.junit.Test;
+
+public class AnalyzerTest {
+
+       @Test
+       public void testOutputWorkFile() {
+               final String[][] DATA = {
+                               // findBugsDir, workDir, FBP,        expectedOutputXml
+                               { "foo",        "bar",   "baz.fbp",  "bar" + File.separator + "baz.xml" },
+                               { 
+                                       File.separator + "findbugs-3.01",
+                                       "." + File.separator + "work",
+                                       "project.fbp",
+                                       "." + File.separator + "work" + File.separator + "project.xml"
+                               }
+                       };
+               
+               for (String[] datum : DATA) {
+                       File findBugsDir = new File(datum[0]);
+                       File workDir     = new File(datum[1]);
+                       File fbp         = new File(datum[2]);
+                       File expected    = new File(datum[3]);
+
+                       Analyzer analyzer = new Analyzer(findBugsDir);
+                       File actual = analyzer.outputWorkFile(workDir, fbp);
+                       assertEquals(expected.getAbsolutePath(), actual.getAbsolutePath());
+               }
+       }
+
+}
diff --git a/test/net/jaekl/qd/xml/MissingInfoExceptionTest.java b/test/net/jaekl/qd/xml/MissingInfoExceptionTest.java
new file mode 100644 (file)
index 0000000..8ce63bd
--- /dev/null
@@ -0,0 +1,40 @@
+package net.jaekl.qd.xml;
+
+import org.junit.Assert;
+
+import org.junit.Test;
+
+public class MissingInfoExceptionTest {
+
+       @Test
+       public void test_getMessage_withSimpleTag() {
+               final String TAG = "TagNameGoesHere";
+               MissingInfoException mie = new MissingInfoException(TAG);
+               String expected = "Tag:  \"" + TAG + "\"";
+               String actual = mie.getMessage();
+               Assert.assertTrue(actual.contains(expected));
+       }
+       
+       @Test
+       public void test_getMessage_withAttributesAndChildren() {
+               final String AUGUSTUS = "Augustus";
+               final String NOMEN = "nomen";
+               final String COGNOMEN = "cognomen";
+               final String TIBERIUS = "Tiberius";
+               final String JULIA = "Julia";
+               
+               MissingInfoException mie = new MissingInfoException(AUGUSTUS);
+               mie.addMissingAttribute(NOMEN);
+               mie.addMissingAttribute(COGNOMEN);
+               mie.addMissingChild(TIBERIUS);
+               mie.addMissingChild(JULIA);
+               
+               String actual = mie.getMessage();
+               Assert.assertTrue(actual.contains("Tag:  \"" + AUGUSTUS + "\""));
+               Assert.assertTrue(actual.contains("Attribute:  \"" + NOMEN + "\""));
+               Assert.assertTrue(actual.contains("Attribute:  \"" + COGNOMEN + "\""));
+               Assert.assertTrue(actual.contains("Child tag:  \"" + TIBERIUS + "\""));
+               Assert.assertTrue(actual.contains("Child tag:  \"" + JULIA + "\""));
+       }
+
+}
diff --git a/test/net/jaekl/qd/xml/ParseResultTest.java b/test/net/jaekl/qd/xml/ParseResultTest.java
new file mode 100644 (file)
index 0000000..d58ab8c
--- /dev/null
@@ -0,0 +1,524 @@
+// Copyright (C) 2004, 2015 Christian Jaekl
+
+package net.jaekl.qd.xml;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+
+import org.junit.Assert;
+
+import org.junit.Test;
+import org.xml.sax.Attributes;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.XMLReader;
+import org.xml.sax.helpers.XMLReaderFactory;
+
+public class ParseResultTest {
+       // Some samples of XML that we're going to (try to) parse\
+       private static final String MINIMAL_XML = 
+                       "<Root/>";
+       private static final String MINIMAL_XML_WITH_PROLOGUE = 
+                       "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Root/>";
+       private static final String XML_WITH_MINOR_CONTENT = 
+                       "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Root><One/></Root>";
+       private static final String ROOT_INSIDE_SECONDARY_ELEMENT = 
+                       "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Secondary><Root><One/></Root></Secondary>";
+       private static final String PROLOGUE_AND_SECONDARY_ELEMENT = 
+                       "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Secondary/>";
+       private static final String SIMPLE_INTERNAL_TAGS = 
+                       "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Root><One/><Two>content of two</Two><Three>3</Three></Root>";
+       private static final String ROUTE_SUMMARY_FOR_STOP = 
+                       "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
+                                       + "<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n" 
+                                       +  "xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\"\n" 
+                                       +  "xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">\n"
+                                       + "     <soap:Body>\n"
+                                       + "             <GetRouteSummaryForStopResponse xmlns=\"http://octranspo.com\">\n"
+                                       + "                     <GetRouteSummaryForStopResult>\n"
+                                       + "                             <StopNo xmlns=\"http://tempuri.org/\">1234</StopNo>\n"
+                                       + "                             <StopDescription" 
+                                       + "                              xmlns=\"http://tempuri.org/\">ONE-TWO-THREE-FOUR</StopDescription>\n"
+                                       + "                             <Error xmlns=\"http://tempuri.org/\"/>\n"
+                                       + "                             <Routes xmlns=\"http://tempuri.org/\">\n"
+                                       + "                                     <Route>\n"
+                                       + "                                             <RouteNo>123</RouteNo>\n"
+                                       + "                                             <DirectionID>0</DirectionID>\n"
+                                       + "                                             <Direction>NORTH</Direction>\n"
+                                       + "                                             <RouteHeading>First Mall</RouteHeading>\n"
+                                       + "                                     </Route>\n"
+                                       + "                                     <Route>\n"
+                                       + "                                             <RouteNo>123</RouteNo>\n"
+                                       + "                                             <DirectionID>1</DirectionID>\n"
+                                       + "                                             <Direction>SOUTH</Direction>\n"
+                                       + "                                             <RouteHeading>Second Mall</RouteHeading>\n"
+                                       + "                                     </Route>\n"
+                                       + "                             </Routes>\n"
+                                       + "                     </GetRouteSummaryForStopResult>\n"
+                                       + "             </GetRouteSummaryForStopResponse>\n"
+                                       + "     </soap:Body>\n"
+                                       + "</soap:Envelope>\n";
+       private static final String NEXT_TRIPS_FOR_STOP = 
+                       "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
+                       + "<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">"
+                       + "<soap:Body><GetRouteSummaryForStopResponse xmlns=\"http://octranspo.com\">"
+                       + "<GetRouteSummaryForStopResult>"
+                       + "<StopNo xmlns=\"http://tempuri.org/\">2438</StopNo>"
+                       + "<StopDescription xmlns=\"http://tempuri.org/\">BRONSON SUNNYSIDE</StopDescription>"
+                       + "<Error xmlns=\"http://tempuri.org/\"/><Routes xmlns=\"http://tempuri.org/\">"
+                       + "<Route><RouteNo>4</RouteNo><DirectionID>1</DirectionID><Direction>Northbound</Direction>"
+                       + "<RouteHeading>Rideau C / Ctr Rideau</RouteHeading>"
+                       + "<Trips>"
+                       + "<Trip><TripDestination>Rideau Centre / Centre Rideau</TripDestination><TripStartTime>19:00</TripStartTime>"
+                       + "<AdjustedScheduleTime>16</AdjustedScheduleTime><AdjustmentAge>0.45</AdjustmentAge><LastTripOfSchedule/>"
+                       + "<BusType>4LB - IN</BusType><Latitude>45.408957</Latitude><Longitude>-75.664125</Longitude>"
+                       + "<GPSSpeed>66.4</GPSSpeed></Trip>"
+                       + "<Trip><TripDestination>Rideau Centre / Centre Rideau</TripDestination>"
+                       + "<TripStartTime>19:30</TripStartTime><AdjustedScheduleTime>40</AdjustedScheduleTime><AdjustmentAge>-1</AdjustmentAge>"
+                       + "<LastTripOfSchedule/><BusType>4LB - IN</BusType><Latitude/><Longitude/><GPSSpeed/></Trip>"
+                       + "<Trip><TripDestination>Rideau Centre / Centre Rideau</TripDestination><TripStartTime>20:00</TripStartTime>"
+                       + "<AdjustedScheduleTime>70</AdjustedScheduleTime><AdjustmentAge>-1</AdjustmentAge><LastTripOfSchedule/>"
+                       + "<BusType>4LB - IN</BusType><Latitude/><Longitude/><GPSSpeed/></Trip>"
+                       + "</Trips></Route></Routes></GetRouteSummaryForStopResult></GetRouteSummaryForStopResponse>"
+                       + "</soap:Body></soap:Envelope>";
+       
+       // Do the least possible parsing:  check for the <Root/> element only.
+       public static class MinimalParse extends ParseResult {
+               private static final String[] INTERNAL = {};
+               private static final Object[][] EXTERNAL = {} ;
+               
+               public MinimalParse() {
+                       super("Root", INTERNAL, EXTERNAL);
+               }
+
+               @Override
+               public void endContents(String uri, String localName, String qName,
+                               String chars, Attributes attr) throws XmlParseException 
+               {
+                       Assert.fail("Should not have any contents to end.");
+               }
+
+               @Override
+               public void endExternal(String uri, String localName, String qName)
+                               throws XmlParseException 
+               {
+                       Assert.fail("Should not have any external tags to end.");
+               }
+       }
+       
+       // Check that we can parse a minimal document without errors.
+       // Because there's no content being parsed (beyond the root element), there is 
+       // no "correct" behaviour to assert.  The test is to confirm that we 
+       // don't do anything incorrect--no calls to endContent() nor endExternal(),
+       // and no exceptions thrown along the way.
+       @Test
+       public void test_withMinimalParse() throws IOException, SAXException {
+               MinimalParse mp = new MinimalParse();
+               ByteArrayInputStream bais = null;
+               
+               String[] data = { 
+                                       MINIMAL_XML, 
+                                       MINIMAL_XML_WITH_PROLOGUE, 
+                                       XML_WITH_MINOR_CONTENT,
+                                       ROOT_INSIDE_SECONDARY_ELEMENT 
+                               };
+               
+               for (String datum : data) {
+                       try {
+                               bais = new ByteArrayInputStream(datum.getBytes("UTF-8"));
+                               XMLReader reader = XMLReaderFactory.createXMLReader();
+                               ParseHandler ph = new ParseHandler(mp);
+                               reader.setContentHandler(ph);
+                               reader.parse(new InputSource(bais));
+                       }
+                       finally {
+                               if (null != bais) { 
+                                       bais.close();
+                               }
+                       }
+               }
+       }
+       
+       // If we parse something that doesn't have the expected root element, we should generate an exception
+       @Test
+       public void test_minimalParseWithMismatchedRootElement() throws IOException {
+               MinimalParse mp = new MinimalParse();
+               ByteArrayInputStream bais = null;
+               
+               String[] data = { PROLOGUE_AND_SECONDARY_ELEMENT };
+               
+               for (String datum : data) {
+                       try {
+                               bais = new ByteArrayInputStream(datum.getBytes("UTF-8"));
+                               XMLReader reader = XMLReaderFactory.createXMLReader();
+                               ParseHandler ph = new ParseHandler(mp);
+                               reader.setContentHandler(ph);
+                               reader.parse(new InputSource(bais));
+                               Assert.fail("Should have thrown an exception.");
+                       }
+                       catch ( SAXException se ) {
+                               Throwable cause = se.getCause();
+                               Assert.assertNotNull(cause);
+                               Assert.assertTrue(cause instanceof MissingInfoException);
+                               MissingInfoException mie = (MissingInfoException) cause;
+                               Assert.assertEquals("Root", mie.getTagName());
+                       }
+                       finally {
+                               if (null != bais) { 
+                                       bais.close();
+                               }
+                       }
+               }
+       }
+       
+       // Do the some simple parsing:  <Root/> and some subtags that are processed internally
+       public static class SimpleParse extends ParseResult {
+               private static final String ONE = "One";
+               private static final String TWO = "Two";
+               private static final String THREE = "Three";
+               
+               private static final String[] INTERNAL = {ONE, TWO, THREE};
+               private static final Object[][] EXTERNAL = {} ;
+               
+               String m_one;
+               String m_two;
+               String m_three;
+               
+               public SimpleParse() {
+                       super("Root", INTERNAL, EXTERNAL);
+                       
+                       m_one = m_two = m_three = null;
+               }
+               
+               public String getOne() { return m_one; }
+               public String getTwo() { return m_two; }
+               public String getThree() { return m_three; }
+
+               @Override
+               public void endContents(String uri, String localName, String qName,
+                               String chars, Attributes attr) throws XmlParseException 
+               {
+                       if (localName.equals(ONE)) {
+                               m_one = chars;
+                       }
+                       else if (localName.equals(TWO)) {
+                               m_two = chars;
+                       }
+                       else if (localName.equals(THREE)) {
+                               m_three = chars;
+                       }
+               }
+
+               @Override
+               public void endExternal(String uri, String localName, String qName)
+                               throws XmlParseException 
+               {
+                       Assert.fail("Should not have any external tags to end.");
+               }
+       }
+       
+       // Parse some XML containing subtags that are handled internally by SimpleParse
+       @Test
+       public void test_parseWithInternalSubtags() throws IOException, SAXException 
+       {
+               SimpleParse sp = new SimpleParse();
+               ByteArrayInputStream bais = null;
+               
+               String[] data = {
+                                       SIMPLE_INTERNAL_TAGS
+                               };
+               
+               for (String datum : data) {
+                       try {
+                               bais = new ByteArrayInputStream(datum.getBytes("UTF-8"));
+                               XMLReader reader = XMLReaderFactory.createXMLReader();
+                               ParseHandler ph = new ParseHandler(sp);
+                               reader.setContentHandler(ph);
+                               reader.parse(new InputSource(bais));
+                               
+                               Assert.assertEquals("", sp.getOne());
+                               Assert.assertEquals("content of two", sp.getTwo());
+                               Assert.assertEquals("3", sp.getThree());
+                       }
+                       finally {
+                               if (null != bais) { 
+                                       bais.close();
+                               }
+                       }
+               }
+       }
+       
+       // Parse sub-tags, handling some internally and some externally
+       public static class RouteSummaryParse extends ParseResult {
+               private static final String STOP_NO = "StopNo";
+               private static final String STOP_DESCR = "StopDescription";
+               private static final String ERROR = "Error";
+               private static final String ROUTES = "Routes";
+               private static final String ROUTE = "Route";
+               
+               private static final String[] INTERNAL = {STOP_NO, STOP_DESCR, ERROR, ROUTES};
+               private static final Object[][] EXTERNAL = { {ROUTE, RouteParse.class} };
+               
+               // Data gleaned from parsing
+               int m_stopNo;
+               String m_stopDescr;
+               String m_error;
+               ArrayList<RouteParse> m_routes;
+               
+               public RouteSummaryParse() {
+                       super("GetRouteSummaryForStopResult", INTERNAL, EXTERNAL);
+                       
+                       m_stopNo = 0;
+                       m_stopDescr = m_error = null;
+                       m_routes = new ArrayList<RouteParse>();
+               }
+               
+               public int getStopNo() { return m_stopNo; }
+               public String getStopDescription() { return m_stopDescr; }
+               public String getError() { return m_error; }
+               public int getNumRoutes() { return m_routes.size(); }
+               public RouteParse getRoute(int idx) { return m_routes.get(idx); }
+
+               @Override
+               public void endContents(String uri, String localName, String qName,
+                               String chars, Attributes attr) throws XmlParseException 
+               {
+                       if (localName.equals(STOP_NO)) {
+                               m_stopNo = Integer.parseInt(chars);
+                       }
+                       else if (localName.equals(STOP_DESCR)) {
+                               m_stopDescr = chars;
+                       }
+                       else if (localName.equals(ERROR)) {
+                               m_error = chars;
+                       }
+               }
+
+               @Override
+               public void endExternal(String uri, String localName, String qName)
+                               throws XmlParseException 
+               {
+                       if (localName.equals(ROUTE)) {
+                               ParseResult[] collected = collectParsedChildren(RouteParse.class);
+                               for (ParseResult pr : collected) {
+                                       Assert.assertTrue(pr instanceof RouteParse);
+                                       m_routes.add((RouteParse)pr);
+                               }
+                       }
+               }
+       }
+       public static class RouteParse extends ParseResult {
+               private static final String ROUTE = "Route";
+               private static final String ROUTE_NO = "RouteNo";
+               private static final String DIR_ID = "DirectionID";
+               private static final String DIR = "Direction";
+               private static final String HEADING = "RouteHeading";
+               private static final String TRIPS = "Trips";
+               private static final String TRIP = "Trip";
+               
+               private static final String[] INTERNAL = {ROUTE_NO, DIR_ID, DIR, HEADING, TRIPS};
+               private static final Object[][] EXTERNAL = { {TRIP, TripParse.class} };
+               
+               // Data gleaned from parsing
+               int m_routeNo;
+               int m_dirID;
+               String m_dir;
+               String m_heading;
+               ArrayList<TripParse> m_trips;
+               
+               public RouteParse() {
+                       super(ROUTE, INTERNAL, EXTERNAL);
+                       
+                       m_routeNo = m_dirID = 0;
+                       m_dir = m_heading = null;
+                       m_trips = new ArrayList<TripParse>();
+               }
+               
+               public int getRouteNo() { return m_routeNo; }
+               public int getDirectionID() { return m_dirID; }
+               public String getDirection() { return m_dir; }
+               public String getHeading() { return m_heading; }
+               public int getNumTrips() { return m_trips.size(); }
+               public TripParse getTrip(int idx) { return m_trips.get(idx); }
+
+               @Override
+               public void endContents(String uri, String localName, String qName,
+                               String chars, Attributes attr) throws XmlParseException 
+               {
+                       if (localName.equals(ROUTE_NO)) {
+                               m_routeNo = Integer.parseInt(chars);
+                       }
+                       else if (localName.equals(DIR_ID)) {
+                               m_dirID = Integer.parseInt(chars);
+                       }
+                       else if (localName.equals(DIR)) {
+                               m_dir = chars;
+                       }
+                       else if (localName.equals(HEADING)) {
+                               m_heading = chars;
+                       }
+               }
+
+               @Override
+               public void endExternal(String uri, String localName, String qName)
+                               throws XmlParseException 
+               {
+                       if (localName.equals(TRIP)) {
+                               ParseResult[] collected = collectParsedChildren(TripParse.class);
+                               for (ParseResult pr : collected) {
+                                       Assert.assertTrue(pr instanceof TripParse);
+                                       m_trips.add((TripParse)pr);
+                               }
+                       }
+                       
+               }
+       }
+       public static class TripParse extends ParseResult {
+               private static final String TRIP = "Trip";
+               private static final String TRIP_DEST = "TripDestination";
+               private static final String TRIP_START = "TripStartTime";
+               private static final String ADJ_SCHED_TIME = "AdjustedScheduleTime";
+               
+               private static final String[] INTERNAL = {TRIP_DEST, TRIP_START, ADJ_SCHED_TIME };
+               private static final Object[][] EXTERNAL = { };
+               
+               // Data gleaned from parsing
+               String m_dest;
+               String m_startTime;
+               int m_adjSchedTime;;
+               
+               public TripParse() {
+                       super(TRIP, INTERNAL, EXTERNAL);
+                       
+                       m_dest = m_startTime = null;
+                       m_adjSchedTime = 0;
+               }
+               
+               public String getDestination() { return m_dest; }
+               public String getStartTime() { return m_startTime; }
+               public int getAdjustedScheduleTime() { return m_adjSchedTime; }
+
+               @Override
+               public void endContents(String uri, String localName, String qName,
+                               String chars, Attributes attr) throws XmlParseException 
+               {
+                       if (localName.equals(TRIP_DEST)) {
+                               m_dest = chars;
+                       }
+                       else if (localName.equals(TRIP_START)) {
+                               m_startTime = chars;
+                       }
+                       else if (localName.equals(ADJ_SCHED_TIME)) {
+                               m_adjSchedTime = Integer.parseInt(chars);
+                       }
+               }
+
+               @Override
+               public void endExternal(String uri, String localName, String qName)
+                               throws XmlParseException 
+               {
+                       Assert.fail("Should not be attempting to parse external tags.");
+               }
+       }
+       
+       // Parse some XML containing subtags that are handled both internally and externally
+       @Test
+       public void test_parseRouteSummary() throws IOException, SAXException 
+       {
+               RouteSummaryParse rsp = new RouteSummaryParse();
+               ByteArrayInputStream bais = null;
+               
+               try {
+                       RouteParse rp;
+                       
+                       bais = new ByteArrayInputStream(ROUTE_SUMMARY_FOR_STOP.getBytes("UTF-8"));
+                       XMLReader reader = XMLReaderFactory.createXMLReader();
+                       ParseHandler ph = new ParseHandler(rsp);
+                       reader.setContentHandler(ph);
+                       reader.parse(new InputSource(bais));
+                       
+                       Assert.assertEquals(1234, rsp.getStopNo());
+                       Assert.assertEquals("ONE-TWO-THREE-FOUR", rsp.getStopDescription());
+                       Assert.assertEquals("", rsp.getError());
+                       
+                       Assert.assertEquals(2, rsp.getNumRoutes());
+                       
+                       rp = rsp.getRoute(0);
+                       Assert.assertNotNull(rp);
+                       Assert.assertEquals(123, rp.getRouteNo());
+                       Assert.assertEquals(0, rp.getDirectionID());
+                       Assert.assertEquals("NORTH", rp.getDirection());
+                       Assert.assertEquals("First Mall", rp.getHeading());
+                       
+                       rp = rsp.getRoute(1);
+                       Assert.assertNotNull(rp);
+                       Assert.assertEquals(123, rp.getRouteNo());
+                       Assert.assertEquals(1, rp.getDirectionID());
+                       Assert.assertEquals("SOUTH", rp.getDirection());
+                       Assert.assertEquals("Second Mall", rp.getHeading());
+               }
+               finally {
+                       if (null != bais) { 
+                               bais.close();
+                       }
+               }
+       }
+       
+       // Parse a 3-level external-tag hierarchy:  RouteSummary contains Routes contains Trips
+       @Test
+       public void test_parseThreeLevels() throws IOException, SAXException 
+       {
+               RouteSummaryParse rsp = new RouteSummaryParse();
+               ByteArrayInputStream bais = null;
+               
+               try {
+                       RouteParse rp;
+                       TripParse tp;
+                       
+                       bais = new ByteArrayInputStream(NEXT_TRIPS_FOR_STOP.getBytes("UTF-8"));
+                       XMLReader reader = XMLReaderFactory.createXMLReader();
+                       ParseHandler ph = new ParseHandler(rsp);
+                       reader.setContentHandler(ph);
+                       reader.parse(new InputSource(bais));
+                       
+                       Assert.assertEquals(2438, rsp.getStopNo());
+                       Assert.assertEquals("BRONSON SUNNYSIDE", rsp.getStopDescription());
+                       Assert.assertEquals("", rsp.getError());
+                       
+                       Assert.assertEquals(1, rsp.getNumRoutes());
+                       
+                       rp = rsp.getRoute(0);
+                       Assert.assertNotNull(rp);
+                       Assert.assertEquals(4, rp.getRouteNo());
+                       Assert.assertEquals(1, rp.getDirectionID());
+                       Assert.assertEquals("Northbound", rp.getDirection());
+                       Assert.assertEquals("Rideau C / Ctr Rideau", rp.getHeading());
+                       
+                       Assert.assertEquals(3, rp.getNumTrips());
+                       
+                       tp = rp.getTrip(0);
+                       Assert.assertNotNull(tp);
+                       Assert.assertEquals("Rideau Centre / Centre Rideau", tp.getDestination());
+                       Assert.assertEquals("19:00", tp.getStartTime());
+                       Assert.assertEquals(16, tp.getAdjustedScheduleTime());
+                       
+                       tp = rp.getTrip(1);
+                       Assert.assertNotNull(tp);
+                       Assert.assertEquals("Rideau Centre / Centre Rideau", tp.getDestination());
+                       Assert.assertEquals("19:30", tp.getStartTime());
+                       Assert.assertEquals(40, tp.getAdjustedScheduleTime());
+
+                       tp = rp.getTrip(2);
+                       Assert.assertNotNull(tp);
+                       Assert.assertEquals("Rideau Centre / Centre Rideau", tp.getDestination());
+                       Assert.assertEquals("20:00", tp.getStartTime());
+                       Assert.assertEquals(70, tp.getAdjustedScheduleTime());
+               }
+               finally {
+                       if (null != bais) { 
+                               bais.close();
+                       }
+               }
+       }
+}