C# XMLヘルプドキュメントの解析と利用

Visual Studio 2019 Tips-NuGetパッケージ化での多言語対応方法

上記でC#のヘルプの多言語化についてまとめたが、ここで作成された情報をもとに、ヘルプドキュメントを生成できないか考えてみた。

前回の方法でVisual Studio上のオブジェクトブラウザでは、使用している言語に合わせてヘルプの情報を参照できるのだが、それ以外の環境では、その情報を見ることができない。

特に今回想定しているのはPowerShellスクリプトユーザー。

一応、PSReadLineでpublicメソッド、プロパティ類は補完できるのだが、それに関するヘルプ情報の提供はできないようだ。

なお補完の例で言うと以下のよう感じ。


  • この状態でタブキーを押すと、以下の様にnamespace部分まで補完される。

  • もしくは、オブジェクト生成後、ピリオドでCtrl+Spaceを押すと、以下の様に選択可能なメソッド、プロパティが表示される。

単独のドキュメントの生成としては、C#ソースからであればdoxygenなどのツールが揃えられているのだが、ソースファイルからなので今回の要件には適用できない。

なので、作ることにした。

一部参考にしたのは、以下のサイト。

C# - Accessing XML Documentation via Reflection

基本クラス

対象としているのは、nupackされたものをnugetで読み込んだものと考えている。

読み込まれたファイルで利用するのは、アセンブリdllと、XMLヘルプとしてxmlファイル。

これらを読み込み、アセンブリdllの中のアセンブリ情報とXMLヘルプの情報をマージして、マイクロソフトのヘルプみたいなものを出すようにすることをベースとして考えた。

その結果作成した基本クラスが以下のもの。

using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Xml;

namespace CommentDoc2Html
{
    public class XmlHelpDoc
    {
        /// <summary>
        /// Memberとアセンブリの情報から動作をするためのインターフェース
        /// </summary>
        public interface IActionMember
        {
            /// <summary>
            /// クラスに対してのアクション<br/>
            /// GetConstructors, GetProperties, GetMethodsに対してもdocument.SearchAndActionを実行する
            /// </summary>
            public void ActionClass(Type refact, Member item, XmlHelpDoc document);
            /// <summary>
            /// インターフェースに対してのアクション<br/>
            /// GetMethodsに対してもdocument.SearchAndActionを実行する
            /// </summary>
            public void ActionInterface(Type refact, Member item, XmlHelpDoc document);
            /// <summary>
            /// Enumに対してのアクション<br/>
            /// GetEnumNamesに対してもdocument.SearchAndActionを実行する
            /// </summary>
            public void ActionEnum(Type refact, Member item, XmlHelpDoc document);
            public void ActionDelegate(Type refact, Member item, XmlHelpDoc document);
            public void Action(MethodInfo refact, Member item, XmlHelpDoc document);
            public void Action(ConstructorInfo refact, Member item, XmlHelpDoc document);
            public void Action(PropertyInfo refact, Member item, XmlHelpDoc document);
        }

        /// <summary>
        /// MemberのNameを検索するためのパラメータ引数「(...)」というのを作成する
        /// </summary>
        /// <remarks>実行時、参照先のさらに先のdllの読み込みができないケースがあった。<br/>
        /// それに対応するため例外処理を追加している</remarks>
        /// <param name="outputParameterName">パラメータ名を出力するかどうか</param>
        /// <param name="outputBlankBrackets">パラメータがない場合「()」と出力させる場合true</param>
        public static string CreateParameterString(MethodBase refact, bool outputParameterName, bool outputBlankBrackets)
        {
            try {
                var parameters = refact.GetParameters();
                if (parameters.Length > 0) {
                    string result = "(";
                    bool first = true;
                    foreach (var para in refact.GetParameters()) {
                        if (!first) {
                            result += ",";
                        }
                        result += para.ParameterType.FullName.Replace('+', '.');
                        if (outputParameterName) {
                            result += " " + para.Name;
                        }
                        first = false;
                    }
                    result += ")";
                    return result;
                }
            }
            catch (System.IO.FileNotFoundException) {
                // これが発生してしまった場合があったので、
            }
            if (outputBlankBrackets) {
                return "()";
            }
            else {
                return "";
            }
        }

        /// <summary>
        /// クラスの名前を作成する<br/>
        /// ・ジェネリックに対応している
        /// </summary>
        public static string CreateClassNameString(Type refact)
        {
            string className = refact.Name;
            if (refact.GetGenericArguments().Length > 0) {
                bool first = true;
                var back = className.LastIndexOf('`');
                if (back >= 0) {
                    className = className.Remove(back);
                }
                className += '<';
                foreach (var ga in refact.GetGenericArguments()) {
                    if (!first) {
                        className += " ,";
                    }
                    className += ga.Name;
                    first = false;
                }
                className += '>';
            }
            return className;
        }

        /// <summary>
        /// 指定タイプがデリゲートかどうかの判断
        /// </summary>
        static public bool IsDelegate(Type refact)
        {
            if (refact.IsClass) {
                for (var parent = refact.BaseType; parent is not null; parent = parent.BaseType) {
                    if (parent.Name == "Delegate") {
                        return true;
                    }
                }
            }
            return false;
        }

        /// <summary>
        /// XMLで読み込んだメンバーに関する情報
        /// </summary>
        public class Member : IComparable<Member>
        {
            public string Name = "";

            /// <summary>
            /// param, typeparam, exception以外のタグで、Depth3に関しての情報を格納する<br/>
            /// keyはタグ名
            /// </summary>
            public Dictionary<string, string> Body = new Dictionary<string, string>();
            /// <summary>
            /// paramタグの情報を格納する。<br/>
            /// keyはnameで指定された文字が設定される
            /// </summary>
            public Dictionary<string, string> Parameter = new Dictionary<string, string>();
            /// <summary>
            /// typeparamタグの情報を格納する。<br/>
            /// keyはnameで指定された文字が設定される
            /// </summary>
            public Dictionary<string, string> Typeparam = new Dictionary<string, string>();
            /// <summary>
            /// exceptionタグの情報を格納する。<br/>
            /// keyはcrefで指定された文字が設定される
            /// </summary>
            public Dictionary<string, string> Exception = new Dictionary<string, string>();

            /// <summary>
            /// 名前の設定
            /// </summary>
            public void SetName(string name)
            {
                var split = name.Split(':');
                if (split.Length == 2) {
                    Name = split[1];
                }
            }

            /// <summary>
            /// 比較関数:名前で比較している
            /// </summary>
            public int CompareTo(Member other)
            {
                return Name.CompareTo(other.Name);
            }
        }

        /// <summary>
        /// XMLドキュメントヘルプを読み込んだもの
        /// </summary>
        private readonly List<Member> loadedHelp = new List<Member>();

        /// <summary>
        /// Memberを見つけた際に実行するアクション関数
        /// </summary>
        public delegate void DoAction(Member member);

        /// <summary>
        /// 名前を使いメンバーを検索して、存在すればアクションを実行する
        /// </summary>
        public void SearchAndAction(string name, DoAction func)
        {
            var result = loadedHelp.BinarySearch(new Member() { Name = name });
            if (result >= 0) {
                func(loadedHelp[result]);
            }
        }

        /// <summary>
        /// XMLヘルプドキュメントを読み込み、後加工しやすいようなMember情報を作成する
        /// </summary>
        /// <remarks>タグ深さ3のみを処理して取り込むようにしている。<br/>
        /// 深さ4は基本brタグだけのみと仮定<br/>
        /// 本当はsampleの中のcodeとかもあるかもしれないがそれはそれ</remarks>
        /// <param name="xmlDocumentation">XMLヘルプドキュメントファイル名</param>
        public void Read(string xmlDocumentation)
        {
            Member value = null;
            ValueTuple<string, string> keyPair = new ValueTuple<string, string>();

            using StreamReader sr = new StreamReader(xmlDocumentation);     // xml内にdomがあったので、StreamReaderで読み込むようにしてある
            using XmlReader xml = XmlReader.Create(sr);
            while (xml.Read()) {
                switch (xml.NodeType) {
                case XmlNodeType.Element:
                    switch (xml.Name) {
                    case "member":
                        value = new Member();
                        value.SetName(xml["name"]);
                        break;
                    case "param":
                        keyPair.Item1 = xml["name"];
                        keyPair.Item2 = "";
                        break;
                    case "typeparam":
                        keyPair.Item1 = xml["name"];
                        keyPair.Item2 = "";
                        break;
                    case "exception":
                        keyPair.Item1 = xml["cref"];
                        keyPair.Item2 = "";
                        break;
                    case "br":
                        keyPair.Item2 += "\n";  // <br/>を改行コードとして認識させるため
                        break;
                    default:
                        if (xml.Depth == 3) {
                            keyPair.Item1 = xml.Name;
                            keyPair.Item2 = "";
                        }
                        break;
                    }
                    break;
                case XmlNodeType.Text:
                    keyPair.Item2 += xml.Value.Trim();  // 前後のいらない空文字を取り除いておく
                    break;
                case XmlNodeType.EndElement:
                    if (value is not null) {
                        switch (xml.Name) {
                        case "member":
                            loadedHelp.Add(value);
                            break;
                        case "param":
                            value.Parameter.Add(keyPair.Item1, keyPair.Item2);
                            break;
                        case "exception":
                            value.Exception.Add(keyPair.Item1, keyPair.Item2);
                            break;
                        case "typeparam":
                            value.Typeparam.Add(keyPair.Item1, keyPair.Item2);
                            break;
                        default:
                            if (xml.Depth == 3) {
                                if (!value.Body.ContainsKey(keyPair.Item1)) {
                                    value.Body.Add(keyPair.Item1, keyPair.Item2);
                                }
                            }
                            break;
                        }
                    }
                    break;
                }
            }
            loadedHelp.Sort();
        }

        /// <summary>
        /// コンストラクタ名に対してのアクション処理
        /// </summary>
        public void SearchAndAction(string fullName, ConstructorInfo refact, IActionMember action)
        {
            SearchAndAction(fullName + ".#ctor" + CreateParameterString(refact, false, false), (x) => {
                action.Action(refact, x, this);
            });
        }

        /// <summary>
        /// プロパティ名に関してのアクション処理
        /// </summary>
        public void SearchAndAction(string fullName, PropertyInfo refact, IActionMember action)
        {
            SearchAndAction(fullName + "." + refact.Name, (x) => {
                action.Action(refact, x, this);
            });
        }

        /// <summary>
        /// メソッド名に関してのアクション処理
        /// </summary>
        public void SearchAndAction(string fullName, MethodInfo refact, IActionMember action)
        {
            SearchAndAction(fullName + "." + refact.Name + CreateParameterString(refact, false, false), (x) => {
                action.Action(refact, x, this);
            });
        }

        public void SearchAndAction(Type refact, IActionMember action)
        {
            var fullName = refact.FullName.Replace('+', '.');
            SearchAndAction(fullName, (x) => {
                if (refact.IsClass) {
                    if (IsDelegate(refact)) {
                        action.ActionDelegate(refact, x, this);
                    }
                    else {
                        action.ActionClass(refact, x, this);
                    }
                }
                else if (refact.IsInterface) {
                    action.ActionInterface(refact, x, this);
                }
                else if (refact.IsEnum) {
                    action.ActionEnum(refact, x, this);
                }
            });
        }

        /// <summary>
        /// アセンブリの中でloadedHelp内にヘルプコメントが記載されているものを出力する
        /// </summary>
        public void Action(Assembly asm, IActionMember action)
        {
            foreach (var type in asm.GetTypes()) {
                SearchAndAction(type, action);
            }
        }
    }
}

利用する側が使用するのは、XmlHelpDoc.IActionMemberを実装したもの。

XmlHelpDocは、XMLヘルプを読み込み、アセンブリ情報とXmlHelpDoc.IActionMemberを指定することで、XMLヘルプとアセンブリ情報の一致するものに対して、IActionMemberのメソッドを呼び出すようにしている。

IActionMember実装部例

HTMLではないが、単純テキストにフォーマットしたものが以下のもの。

修飾子に関する考え方や、delegate判断などについては参考になると思っている。

/// <summary>
/// 平文出力するクラス
/// </summary>
class DocTextWriter : XmlHelpDoc.IActionMember
{
    readonly TextWriter Str_;

    public DocTextWriter(TextWriter str)
    {
        Str_ = str;
    }

    private string ModifierString(MethodBase refact)
    {
        string value = (refact.IsPublic ? "public" : "") +
                (refact.IsVirtual ? " virtual" : "") +
                (refact.IsStatic ? " static" : "") +
                (refact.IsAbstract ? " abstract" : "") +
                (refact.IsFinal ? " final" : "");
        return value;
    }

    private void PrintParameters(XmlHelpDoc.Member item)
    {
        if (item.Parameter.Count > 0) {
            Str_.WriteLine("Parameter:");
            foreach (var para in item.Parameter) {
                if (para.Value.Length > 0) {
                    Str_.WriteLine(" {0}", para.Key);
                    Str_.WriteLine("  {0}", para.Value);
                    Str_.WriteLine();
                }
            }
        }
    }

    private void PrintBody(XmlHelpDoc.Member item, string key)
    {
        if (item.Body.TryGetValue(key, out string value)) {
            if (value.Length > 0) {
                Str_.WriteLine("{0}:", key);
                Str_.WriteLine(" {0}", value);
            }
        }
    }

    public void Action(MethodInfo refact, XmlHelpDoc.Member item, XmlHelpDoc document)
    {
        try {
            string wName = ModifierString(refact) +
                " " + refact.ReturnType.Name +
                " " + refact.Name +
                " " + XmlHelpDoc.CreateParameterString(refact, true, true) + ";";
            Str_.WriteLine("{0}{1}", refact.Name, XmlHelpDoc.CreateParameterString(refact, false, true));
            if (item.Body.TryGetValue("summary", out string value)) {
                Str_.WriteLine(" {0}", value);
            }
            Str_.WriteLine(wName);
            PrintParameters(item);
            PrintBody(item, "returns");
            PrintBody(item, "remarks");
        }
        catch (System.IO.FileNotFoundException) {
            // これが発生してしまった場合があったので、
        }
    }

    public void Action(ConstructorInfo refact, XmlHelpDoc.Member item, XmlHelpDoc document)
    {
        try {
            string wName = ModifierString(refact) +
                " " + refact.ReflectedType.Name +
                " " + XmlHelpDoc.CreateParameterString(refact, true, true) + ";";
            if (item.Body.TryGetValue("summary", out string value)) {
                Str_.WriteLine(" {0}", value);
            }
            Str_.WriteLine(wName);
            PrintParameters(item);
            PrintBody(item, "remarks");
        }
        catch (System.IO.FileNotFoundException) {
            // これが発生してしまった場合があったので、
        }
    }

    public void Action(PropertyInfo refact, XmlHelpDoc.Member item, XmlHelpDoc document)
    {
        bool isPublic = false;
        string setValue = "";
        if (refact.CanWrite) {
            if (refact.GetSetMethod() is not null) {
                isPublic = true;
            }
            else {
                setValue = "private ";
            }
            setValue += "set; ";
        }
        string getValue = "";
        if (refact.CanRead) {
            if (refact.GetGetMethod() is not null) {
                isPublic = true;
            }
            else {
                getValue = "private ";
            }
            getValue += "get; ";
        }
        if (!isPublic) {
            Str_.WriteLine("{0}", refact.Name);
            if (item.Body.TryGetValue("summary", out string value)) {
                Str_.WriteLine(" {0}", value);
            }
            string wName = (isPublic ? "public" : "") +
                " " + refact.DeclaringType.Name +
                " " + refact.Name;
            if (setValue.Length > 0 || getValue.Length > 0) {
                wName += "{ " + setValue + getValue + "}";
            }
            Str_.WriteLine(wName);
            PrintBody(item, "remarks");
        }
    }

    public void ActionClass(Type refact, XmlHelpDoc.Member item, XmlHelpDoc document)
    {
        if (refact.Namespace is not null) {
            Str_.WriteLine("namespace {0}", refact.Namespace);
        }
        string className = XmlHelpDoc.CreateClassNameString(refact);
        Str_.WriteLine("{0} Class", className);
        if (item.Body.TryGetValue("summary", out string value)) {
            Str_.WriteLine(" {0}", value);
        }
        string wName =
            (refact.IsPublic || refact.IsNestedPublic ? "public" : "") +
            (refact.IsAbstract && refact.IsSealed ? " static" : (refact.IsAbstract ? " abstract" : (refact.IsSealed ? " sealed" : ""))) +
            " class " + className;
        if (refact.BaseType is not null) {
            wName += " : " + refact.BaseType.Name;
        }
        if (refact.GetInterfaces().Length > 0) {
            foreach (var ifname in refact.GetInterfaces()) {
                wName += ", " + ifname.Name;
            }
        }
        Str_.WriteLine(wName);
        if (refact.GetGenericArguments().Length > 0 && item.Typeparam.Count > 0) {
            Str_.WriteLine("Typeparameter:");
            foreach (var work in item.Typeparam) {
                Str_.WriteLine(" {0}", work.Key);
                Str_.WriteLine("  {0}", work.Value);
            }
        }
        PrintBody(item, "remarks");

        // コンストラクタ出力
        foreach (var work in refact.GetConstructors()) {
            document.SearchAndAction(item.Name, work, this);
        }
        // プロパティ出力
        foreach (var work in refact.GetProperties()) {
            document.SearchAndAction(item.Name, work, this);
        }
        // メソッド出力
        foreach (var work in refact.GetMethods()) {
            document.SearchAndAction(item.Name, work, this);
        }
    }

    public void ActionDelegate(Type refact, XmlHelpDoc.Member item, XmlHelpDoc document)
    {
        var method = refact.GetMethod("Invoke");
        if (method is not null) {
            try {
                string className = XmlHelpDoc.CreateClassNameString(refact);
                string wName =
                    (refact.IsPublic || refact.IsNestedPublic ? "public" : "") +
                    " delegate " + method.ReturnType.Name + " " +
                    className + XmlHelpDoc.CreateParameterString(method, true, true) + ";";

                if (refact.Namespace is not null) {
                    Str_.WriteLine("namespace {0}", refact.Namespace);
                }
                Str_.WriteLine("{0} Delegate", className);
                if (item.Body.TryGetValue("summary", out string value)) {
                    Str_.WriteLine(" {0}", value);
                }

                Str_.WriteLine(wName);
                PrintParameters(item);
                PrintBody(item, "returns");
                PrintBody(item, "remarks");
            }
            catch (System.IO.FileNotFoundException) {

            }
        }
    }

    public void ActionEnum(Type refact, XmlHelpDoc.Member item, XmlHelpDoc document)
    {
        if (refact.Namespace is not null) {
            Str_.WriteLine("namespace {0}", refact.Namespace);
        }
        Str_.WriteLine("{0} Enum", refact.Name);
        if (item.Body.TryGetValue("summary", out string value)) {
            Str_.WriteLine(" {0}", value);
        }
        string wName =
            (refact.IsPublic || refact.IsNestedPublic ? "public" : "") +
            " enum " + refact.Name;
        Str_.WriteLine(wName);
        Str_.WriteLine("Fields:");
        foreach (var element in refact.GetEnumNames()) {
            Str_.Write(" " + element);
            document.SearchAndAction(item.Name + "." + element, (x) =>
            {
                if (x.Body.TryGetValue("summary", out string value)) {
                    Str_.Write(":" + value);
                }
            });
            Str_.WriteLine();
        }
    }

    public void ActionInterface(Type refact, XmlHelpDoc.Member item, XmlHelpDoc document)
    {
        if (refact.Namespace is not null) {
            Str_.WriteLine("namespace {0}", refact.Namespace);
        }
        Str_.WriteLine("{0} Interface", refact.Name);
        if (item.Body.TryGetValue("summary", out string value)) {
            Str_.WriteLine(" {0}", value);
        }
        string wName =
            (refact.IsPublic || refact.IsNestedPublic ? "public" : "") +
            " interface " + refact.Name;
        Str_.WriteLine(wName);
        PrintBody(item, "remarks");
        // メソッド出力
        foreach (var work in refact.GetMethods()) {
            document.SearchAndAction(item.Name, work, this);
        }
    }
}

メイン部分

上を呼び出しているメイン部分は以下のような感じ。

引数で、nugetパッケージのローディングされた場所を指定している。

static class Program
{
    static void Main(string[] args)
    {
        if (args.Length > 0 && Directory.Exists(args[0])) {
            string dllFile = "";

            // 引数から渡された場所からdllとxmlを読み込む
            foreach (var file in Directory.EnumerateFiles(args[0], "*.dll", SearchOption.AllDirectories)) {
                dllFile = file;
            }
            if (dllFile.Length > 0) {
                var asm = Assembly.LoadFile(dllFile);
                foreach (var file in Directory.EnumerateFiles(args[0], "*.xml", SearchOption.AllDirectories)) {
                    var help = new XmlHelpDoc();
                    help.Read(file);
                    var writer = new DocTextWriter(Console.Out);
                    help.Action(asm, writer);
                }
            }
        }
    }
}

 

コメント

タイトルとURLをコピーしました