// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Threading.Tasks; using System.Xml; using System.Xml.Schema; using System.Xml.Serialization; using EpicGames.Core; using UnrealBuildBase; namespace UnrealBuildTool { /// /// Functions for manipulating the XML config cache /// static class XmlConfig { /// /// An input config file /// public class InputFile { /// /// Location of the file /// public FileReference Location; /// /// Which folder to display the config file under in the generated project files /// public string FolderName; /// /// Constructor /// /// /// public InputFile(FileReference Location, string FolderName) { this.Location = Location; this.FolderName = FolderName; } } /// /// The cache file that is being used /// public static FileReference? CacheFile; /// /// Parsed config values /// static XmlConfigData? Values; /// /// Cached serializer for the XML schema /// static XmlSerializer? CachedSchemaSerializer; /// /// Initialize the config system with the given types /// /// Force use of the cached XML config without checking if it's valid (useful for remote builds) public static void ReadConfigFiles(FileReference? OverrideCacheFile) { // Find all the configurable types List ConfigTypes = FindConfigurableTypes(); // Update the cache if necessary if(OverrideCacheFile != null) { // Set the cache file to the overriden value CacheFile = OverrideCacheFile; // Never rebuild the cache; just try to load it. if(!XmlConfigData.TryRead(CacheFile, ConfigTypes, out Values)) { throw new BuildException("Unable to load XML config cache ({0})", CacheFile); } } else { // Get the default cache file CacheFile = FileReference.Combine(Unreal.EngineDirectory, "Intermediate", "Build", "XmlConfigCache.bin"); if(Unreal.IsEngineInstalled()) { DirectoryReference? UserSettingsDir = Utils.GetUserSettingDirectory(); if(UserSettingsDir != null) { CacheFile = FileReference.Combine(UserSettingsDir, "UnrealEngine", String.Format("XmlConfigCache-{0}.bin", Unreal.RootDirectory.FullName.Replace(":", "").Replace(Path.DirectorySeparatorChar, '+'))); } } // Find all the input files FileReference[] InputFiles = FindInputFiles().Select(x => x.Location).ToArray(); // Get the path to the schema FileReference SchemaFile = GetSchemaLocation(); // Try to read the existing cache from disk XmlConfigData? CachedValues; if(IsCacheUpToDate(CacheFile, InputFiles) && FileReference.Exists(SchemaFile)) { if(XmlConfigData.TryRead(CacheFile, ConfigTypes, out CachedValues) && Enumerable.SequenceEqual(InputFiles, CachedValues.InputFiles)) { Values = CachedValues; } } // If that failed, regenerate it if(Values == null) { // Find all the configurable fields from the given types Dictionary> CategoryToFields = new Dictionary>(); FindConfigurableFields(ConfigTypes, CategoryToFields); // Create a schema for the config files XmlSchema Schema = CreateSchema(CategoryToFields); if(!Unreal.IsEngineInstalled()) { WriteSchema(Schema, SchemaFile); } // Read all the XML files and validate them against the schema Dictionary> TypeToValues = new Dictionary>(); foreach(FileReference InputFile in InputFiles) { if(!TryReadFile(InputFile, CategoryToFields, TypeToValues, Schema)) { throw new BuildException("Failed to properly read XML file : {0}", InputFile.FullName); } } // Make sure the cache directory exists DirectoryReference.CreateDirectory(CacheFile.Directory); // Create the new cache Values = new XmlConfigData(InputFiles, TypeToValues.ToDictionary(x => x.Key, x => x.Value.ToArray())); Values.Write(CacheFile); } } // Apply all the static field values foreach(KeyValuePair[]> TypeValuesPair in Values.TypeToValues) { foreach(KeyValuePair FieldValuePair in TypeValuesPair.Value) { if(FieldValuePair.Key.IsStatic) { object Value = InstanceValue(FieldValuePair.Value, FieldValuePair.Key.FieldType); FieldValuePair.Key.SetValue(null, Value); } } } } /// /// Find all the configurable types in the current assembly /// /// List of configurable types static List FindConfigurableTypes() { List ConfigTypes = new List(); try { foreach (Type ConfigType in Assembly.GetExecutingAssembly().GetTypes()) { if (HasXmlConfigFileAttribute(ConfigType)) { ConfigTypes.Add(ConfigType); } } } catch (ReflectionTypeLoadException Ex) { Console.WriteLine("TypeLoadException: {0}", string.Join("\n", Ex.LoaderExceptions.Select(x => x?.Message))); throw; } return ConfigTypes; } /// /// Determines whether the given type has a field with an XmlConfigFile attribute /// /// The type to check /// True if the type has a field with the XmlConfigFile attribute static bool HasXmlConfigFileAttribute(Type Type) { foreach(FieldInfo Field in Type.GetFields(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)) { foreach(CustomAttributeData CustomAttribute in Field.CustomAttributes) { if(CustomAttribute.AttributeType == typeof(XmlConfigFileAttribute)) { return true; } } } return false; } /// /// Find the location of the XML config schema /// /// The location of the schema file public static FileReference GetSchemaLocation() { return FileReference.Combine(Unreal.EngineDirectory, "Saved", "UnrealBuildTool", "BuildConfiguration.Schema.xsd"); } /// /// Initialize the list of input files /// public static List FindInputFiles() { // Find all the input file locations List InputFiles = new List(); // Skip all the config files under the Engine folder if it's an installed build if(!Unreal.IsEngineInstalled()) { // Check for the config file under /Engine/Programs/NotForLicensees/UnrealBuildTool FileReference NotForLicenseesConfigLocation = FileReference.Combine(Unreal.EngineDirectory, "Restricted", "NotForLicensees", "Programs", "UnrealBuildTool", "BuildConfiguration.xml"); if(FileReference.Exists(NotForLicenseesConfigLocation)) { InputFiles.Add(new InputFile(NotForLicenseesConfigLocation, "NotForLicensees")); } // Check for the user config file under /Engine/Saved/UnrealBuildTool FileReference UserConfigLocation = FileReference.Combine(Unreal.EngineDirectory, "Saved", "UnrealBuildTool", "BuildConfiguration.xml"); if(!FileReference.Exists(UserConfigLocation)) { CreateDefaultConfigFile(UserConfigLocation); } InputFiles.Add(new InputFile(UserConfigLocation, "User")); } // Check for the global config file under AppData/Unreal Engine/UnrealBuildTool string AppDataFolder = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); if(!String.IsNullOrEmpty(AppDataFolder)) { FileReference AppDataConfigLocation = FileReference.Combine(new DirectoryReference(AppDataFolder), "Unreal Engine", "UnrealBuildTool", "BuildConfiguration.xml"); if(!FileReference.Exists(AppDataConfigLocation)) { CreateDefaultConfigFile(AppDataConfigLocation); } InputFiles.Add(new InputFile(AppDataConfigLocation, "Global (AppData)")); } // Check for the global config file under My Documents/Unreal Engine/UnrealBuildTool string PersonalFolder = Environment.GetFolderPath(Environment.SpecialFolder.Personal); if(!String.IsNullOrEmpty(PersonalFolder)) { FileReference PersonalConfigLocation = FileReference.Combine(new DirectoryReference(PersonalFolder), "Unreal Engine", "UnrealBuildTool", "BuildConfiguration.xml"); if(FileReference.Exists(PersonalConfigLocation)) { InputFiles.Add(new InputFile(PersonalConfigLocation, "Global (Documents)")); } } return InputFiles; } /// /// Create a default config file at the given location /// /// Location to read from static void CreateDefaultConfigFile(FileReference Location) { DirectoryReference.CreateDirectory(Location.Directory); using (StreamWriter Writer = new StreamWriter(Location.FullName)) { Writer.WriteLine(""); Writer.WriteLine("", XmlConfigFile.SchemaNamespaceURI); Writer.WriteLine(""); } } /// /// Applies config values to the given object /// /// The object instance to be configured public static void ApplyTo(object TargetObject) { for(Type? TargetType = TargetObject.GetType(); TargetType != null; TargetType = TargetType.BaseType) { KeyValuePair[]? FieldValues; if(Values!.TypeToValues.TryGetValue(TargetType, out FieldValues)) { foreach(KeyValuePair FieldValuePair in FieldValues) { if(!FieldValuePair.Key.IsStatic) { object ValueInstance = InstanceValue(FieldValuePair.Value, FieldValuePair.Key.FieldType); FieldValuePair.Key.SetValue(TargetObject, ValueInstance); } } } } } /// /// Instances a value for assignment to a target object /// /// The value to instance /// The type of value /// New instance of the given value, if necessary static object InstanceValue(object Value, Type ValueType) { if(ValueType == typeof(string[])) { return ((string[])Value).Clone(); } else { return Value; } } /// /// Gets a config value for a single value, without writing it to an instance of that class /// /// Type to find config values for /// Name of the field to receive /// On success, receives the value of the field /// True if the value was read, false otherwise public static bool TryGetValue(Type TargetType, string Name, [NotNullWhen(true)] out object? Value) { // Find all the config values for this type KeyValuePair[]? FieldValues; if(!Values!.TypeToValues.TryGetValue(TargetType, out FieldValues)) { Value = null; return false; } // Find the value with the matching name foreach(KeyValuePair FieldPair in FieldValues) { if(FieldPair.Key.Name == Name) { Value = FieldPair.Value; return true; } } // Not found Value = null; return false; } /// /// Find all the configurable fields in the given types by searching for XmlConfigFile attributes. /// /// Array of types to search /// Dictionaries populated with category -> name -> field mappings on return static void FindConfigurableFields(IEnumerable ConfigTypes, Dictionary> CategoryToFields) { foreach(Type ConfigType in ConfigTypes) { foreach(FieldInfo FieldInfo in ConfigType.GetFields(BindingFlags.Instance | BindingFlags.Static | BindingFlags.GetField | BindingFlags.Public | BindingFlags.NonPublic)) { IEnumerable Attributes = FieldInfo.GetCustomAttributes(); foreach(XmlConfigFileAttribute Attribute in Attributes) { string CategoryName = Attribute.Category ?? ConfigType.Name; Dictionary? NameToField; if(!CategoryToFields.TryGetValue(CategoryName, out NameToField)) { NameToField = new Dictionary(); CategoryToFields.Add(CategoryName, NameToField); } NameToField[Attribute.Name ?? FieldInfo.Name] = FieldInfo; } } } } /// /// Creates a schema from attributes in the given types /// /// Lookup for all field settings /// New schema instance static XmlSchema CreateSchema(Dictionary> CategoryToFields) { // Create elements for all the categories XmlSchemaAll RootAll = new XmlSchemaAll(); foreach(KeyValuePair> CategoryPair in CategoryToFields) { string CategoryName = CategoryPair.Key; XmlSchemaAll CategoryAll = new XmlSchemaAll(); foreach (KeyValuePair FieldPair in CategoryPair.Value) { XmlSchemaElement Element = CreateSchemaFieldElement(FieldPair.Key, FieldPair.Value.FieldType); CategoryAll.Items.Add(Element); } XmlSchemaComplexType CategoryType = new XmlSchemaComplexType(); CategoryType.Particle = CategoryAll; XmlSchemaElement CategoryElement = new XmlSchemaElement(); CategoryElement.Name = CategoryName; CategoryElement.SchemaType = CategoryType; CategoryElement.MinOccurs = 0; CategoryElement.MaxOccurs = 1; RootAll.Items.Add(CategoryElement); } // Create the root element and schema object XmlSchemaComplexType RootType = new XmlSchemaComplexType(); RootType.Particle = RootAll; XmlSchemaElement RootElement = new XmlSchemaElement(); RootElement.Name = XmlConfigFile.RootElementName; RootElement.SchemaType = RootType; XmlSchema Schema = new XmlSchema(); Schema.TargetNamespace = XmlConfigFile.SchemaNamespaceURI; Schema.ElementFormDefault = XmlSchemaForm.Qualified; Schema.Items.Add(RootElement); // Finally compile it XmlSchemaSet SchemaSet = new XmlSchemaSet(); SchemaSet.Add(Schema); SchemaSet.Compile(); return SchemaSet.Schemas().OfType().First(); } /// /// Creates an XML schema element for reading a value of the given type /// /// Name of the field /// Type of the field /// New schema element representing the field static XmlSchemaElement CreateSchemaFieldElement(string Name, Type Type) { XmlSchemaElement Element = new XmlSchemaElement(); Element.Name = Name; Element.MinOccurs = 0; Element.MaxOccurs = 1; if(Type == typeof(string)) { Element.SchemaTypeName = XmlSchemaType.GetBuiltInSimpleType(XmlTypeCode.String).QualifiedName; } else if(Type == typeof(bool) || Type == typeof(bool?)) { Element.SchemaTypeName = XmlSchemaType.GetBuiltInSimpleType(XmlTypeCode.Boolean).QualifiedName; } else if(Type == typeof(int)) { Element.SchemaTypeName = XmlSchemaType.GetBuiltInSimpleType(XmlTypeCode.Int).QualifiedName; } else if(Type == typeof(float)) { Element.SchemaTypeName = XmlSchemaType.GetBuiltInSimpleType(XmlTypeCode.Float).QualifiedName; } else if(Type == typeof(double)) { Element.SchemaTypeName = XmlSchemaType.GetBuiltInSimpleType(XmlTypeCode.Double).QualifiedName; } else if(Type == typeof(FileReference)) { Element.SchemaTypeName = XmlSchemaType.GetBuiltInSimpleType(XmlTypeCode.String).QualifiedName; } else if(Type.IsEnum) { XmlSchemaSimpleTypeRestriction Restriction = new XmlSchemaSimpleTypeRestriction(); Restriction.BaseTypeName = XmlSchemaType.GetBuiltInSimpleType(XmlTypeCode.String).QualifiedName; foreach(string EnumName in Enum.GetNames(Type)) { XmlSchemaEnumerationFacet Facet = new XmlSchemaEnumerationFacet(); Facet.Value = EnumName; Restriction.Facets.Add(Facet); } XmlSchemaSimpleType EnumType = new XmlSchemaSimpleType(); EnumType.Content = Restriction; Element.SchemaType = EnumType; } else if(Type == typeof(string[])) { XmlSchemaElement ItemElement = new XmlSchemaElement(); ItemElement.Name = "Item"; ItemElement.SchemaTypeName = XmlSchemaType.GetBuiltInSimpleType(XmlTypeCode.String).QualifiedName; ItemElement.MinOccurs = 0; ItemElement.MaxOccursString = "unbounded"; XmlSchemaSequence Sequence = new XmlSchemaSequence(); Sequence.Items.Add(ItemElement); XmlSchemaComplexType ArrayType = new XmlSchemaComplexType(); ArrayType.Particle = Sequence; Element.SchemaType = ArrayType; } else { throw new Exception("Unsupported field type for XmlConfigFile attribute"); } return Element; } /// /// Writes a schema to the given location. Avoids writing it if the file is identical. /// /// The schema to be written /// Location to write to static void WriteSchema(XmlSchema Schema, FileReference Location) { XmlWriterSettings Settings = new XmlWriterSettings(); Settings.Indent = true; Settings.IndentChars = "\t"; Settings.NewLineChars = Environment.NewLine; Settings.OmitXmlDeclaration = true; if(CachedSchemaSerializer == null) { CachedSchemaSerializer = XmlSerializer.FromTypes(new Type[] { typeof(XmlSchema) })[0]; } StringBuilder Output = new StringBuilder(); Output.AppendLine(""); using(XmlWriter Writer = XmlWriter.Create(Output, Settings)) { XmlSerializerNamespaces Namespaces = new XmlSerializerNamespaces(); Namespaces.Add("", "http://www.w3.org/2001/XMLSchema"); CachedSchemaSerializer.Serialize(Writer, Schema, Namespaces); } string OutputText = Output.ToString(); if(!FileReference.Exists(Location) || File.ReadAllText(Location.FullName) != OutputText) { DirectoryReference.CreateDirectory(Location.Directory); File.WriteAllText(Location.FullName, OutputText); } } /// /// Reads an XML config file and merges it to the given cache /// /// Location to read from /// Lookup for configurable fields by category /// Map of types to fields and their associated values /// Schema to validate against /// True if the file was read successfully static bool TryReadFile(FileReference Location, Dictionary> CategoryToFields, Dictionary> TypeToValues, XmlSchema Schema) { // Read the XML file, and validate it against the schema XmlConfigFile? ConfigFile; if(!XmlConfigFile.TryRead(Location, Schema, out ConfigFile)) { return false; } // Parse the document foreach(XmlElement CategoryElement in ConfigFile.DocumentElement.ChildNodes.OfType()) { Dictionary? NameToField; if(CategoryToFields.TryGetValue(CategoryElement.Name, out NameToField)) { foreach(XmlElement KeyElement in CategoryElement.ChildNodes.OfType()) { FieldInfo? Field; if(NameToField.TryGetValue(KeyElement.Name, out Field)) { // Parse the corresponding value object Value; if(Field.FieldType == typeof(string[])) { Value = KeyElement.ChildNodes.OfType().Where(x => x.Name == "Item").Select(x => x.InnerText).ToArray(); } else { Value = ParseValue(Field.FieldType, KeyElement.InnerText); } // Add it to the set of values for the type containing this field Dictionary? FieldToValue; if(!TypeToValues.TryGetValue(Field.DeclaringType!, out FieldToValue)) { FieldToValue = new Dictionary(); TypeToValues.Add(Field.DeclaringType!, FieldToValue); } FieldToValue[Field] = Value; } } } } return true; } /// /// Parse the value for a field from its text based representation in an XML file /// /// The type of field being read /// Text to parse /// The object that was parsed static object ParseValue(Type FieldType, string Text) { // ignore whitespace in all fields except for Strings which we leave unprocessed string TrimmedText = Text.Trim(); if(FieldType == typeof(string)) { return Text; } else if(FieldType == typeof(bool) || FieldType == typeof(bool?)) { if (TrimmedText == "1" || TrimmedText.Equals("true", StringComparison.InvariantCultureIgnoreCase)) { return true; } else if (TrimmedText == "0" || TrimmedText.Equals("false", StringComparison.InvariantCultureIgnoreCase)) { return false; } else { throw new Exception(String.Format("Unable to convert '{0}' to boolean. 'true/false/0/1' are the supported formats.", Text)); } } else if(FieldType == typeof(int)) { return Int32.Parse(TrimmedText); } else if(FieldType == typeof(float)) { return Single.Parse(TrimmedText, System.Globalization.CultureInfo.InvariantCulture); } else if(FieldType == typeof(double)) { return Double.Parse(TrimmedText, System.Globalization.CultureInfo.InvariantCulture); } else if(FieldType.IsEnum) { return Enum.Parse(FieldType, TrimmedText); } else if (FieldType == typeof(FileReference)) { return FileReference.FromString(Text); } else { throw new Exception(String.Format("Unsupported config type '{0}'", FieldType.Name)); } } /// /// Checks that the given cache file exists and is newer than the given input files, and attempts to read it. Verifies that the resulting cache was created /// from the same input files in the same order. /// /// Path to the config cache file /// The expected set of input files in the cache /// True if the cache was valid and could be read, false otherwise. static bool IsCacheUpToDate(FileReference CacheFile, FileReference[] InputFiles) { // Always rebuild if the cache doesn't exist if(!FileReference.Exists(CacheFile)) { return false; } // Get the timestamp for the cache DateTime CacheWriteTime = File.GetLastWriteTimeUtc(CacheFile.FullName); // Always rebuild if this executable is newer if(File.GetLastWriteTimeUtc(Assembly.GetExecutingAssembly().Location) > CacheWriteTime) { return false; } // Check if any of the input files are newer than the cache foreach(FileReference InputFile in InputFiles) { if(File.GetLastWriteTimeUtc(InputFile.FullName) > CacheWriteTime) { return false; } } // Otherwise, it's up to date return true; } /// /// Generates documentation files for the available settings, by merging the XML documentation from the compiler. /// /// The documentation file to write public static void WriteDocumentation(FileReference OutputFile) { // Find all the configurable types List ConfigTypes = FindConfigurableTypes(); // Find all the configurable fields from the given types Dictionary> CategoryToFields = new Dictionary>(); FindConfigurableFields(ConfigTypes, CategoryToFields); CategoryToFields = CategoryToFields.Where(x => x.Value.Count > 0).ToDictionary(x => x.Key, x => x.Value); // Get the path to the XML documentation FileReference InputDocumentationFile = new FileReference(Assembly.GetExecutingAssembly().Location).ChangeExtension(".xml"); if(!FileReference.Exists(InputDocumentationFile)) { throw new BuildException("Generated assembly documentation not found at {0}.", InputDocumentationFile); } // Read the documentation XmlDocument InputDocumentation = new XmlDocument(); InputDocumentation.Load(InputDocumentationFile.FullName); // Make sure we can write to the output file if(FileReference.Exists(OutputFile)) { FileReference.MakeWriteable(OutputFile); } else { DirectoryReference.CreateDirectory(OutputFile.Directory); } // Generate the documentation file if(OutputFile.HasExtension(".udn")) { WriteDocumentationUDN(OutputFile, InputDocumentation, CategoryToFields); } else if(OutputFile.HasExtension(".html")) { WriteDocumentationHTML(OutputFile, InputDocumentation, CategoryToFields); } else { throw new BuildException("Unable to detect format from extension of output file ({0})", OutputFile); } // Success! Log.TraceInformation("Written documentation to {0}.", OutputFile); } /// /// Gets the XML comment for a particular field /// /// The XML documentation /// The field to search for /// Receives the description for the requested field /// True if a comment was found for the field private static bool TryGetXmlComment(XmlDocument Documentation, FieldInfo Field, [NotNullWhen(true)] out List? Lines) { XmlNode Node = Documentation.SelectSingleNode(String.Format("//member[@name='F:{0}.{1}']/summary", Field.DeclaringType!.FullName, Field.Name)); if (Node == null) { Lines = null; return false; } else { // Reflow the comments into paragraphs, assuming that each paragraph will be separated by a blank line Lines = new List(Node.InnerText.Trim().Split('\n').Select(x => x.Trim())); for (int Idx = Lines.Count - 1; Idx > 0; Idx--) { if (Lines[Idx - 1].Length > 0 && !Lines[Idx].StartsWith("*") && !Lines[Idx].StartsWith("-")) { Lines[Idx - 1] += " " + Lines[Idx]; Lines.RemoveAt(Idx); } } return true; } } /// /// Writes out documentation in UDN format /// /// The output file /// The XML documentation for this assembly /// Map of string to types to fields private static void WriteDocumentationUDN(FileReference OutputFile, XmlDocument InputDocumentation, Dictionary> CategoryToFields) { using (StreamWriter Writer = new StreamWriter(OutputFile.FullName)) { Writer.WriteLine("Availability: NoPublish"); Writer.WriteLine("Title: Build Configuration Properties Page"); Writer.WriteLine("Crumbs:"); Writer.WriteLine("Description: This is a procedurally generated markdown page."); Writer.WriteLine("Version: {0}.{1}", ReadOnlyBuildVersion.Current.MajorVersion, ReadOnlyBuildVersion.Current.MinorVersion); Writer.WriteLine(""); foreach (KeyValuePair> CategoryPair in CategoryToFields) { string CategoryName = CategoryPair.Key; Writer.WriteLine("### {0}", CategoryName); Writer.WriteLine(); Dictionary Fields = CategoryPair.Value; foreach (KeyValuePair FieldPair in Fields) { // Get the XML comment for this field List? Lines; if(!TryGetXmlComment(InputDocumentation, FieldPair.Value, out Lines) || Lines.Count == 0) { Log.TraceWarning("Missing XML comment for {0}", FieldPair.Value.Name); continue; } // Write the result to the .udn file Writer.WriteLine("$ {0} : {1}", FieldPair.Key, Lines[0]); for (int Idx = 1; Idx < Lines.Count; Idx++) { if (Lines[Idx].StartsWith("*") || Lines[Idx].StartsWith("-")) { Writer.WriteLine(" * {0}", Lines[Idx].Substring(1).TrimStart()); } else { Writer.WriteLine(" * {0}", Lines[Idx]); } } Writer.WriteLine(); } } } } /// /// Writes out documentation in HTML format /// /// The output file /// The XML documentation for this assembly /// Map of string to types to fields private static void WriteDocumentationHTML(FileReference OutputFile, XmlDocument InputDocumentation, Dictionary> CategoryToFields) { using (StreamWriter Writer = new StreamWriter(OutputFile.FullName)) { Writer.WriteLine(""); Writer.WriteLine(" "); Writer.WriteLine("

BuildConfiguration Properties

"); foreach (KeyValuePair> CategoryPair in CategoryToFields) { string CategoryName = CategoryPair.Key; Writer.WriteLine("

{0}

", CategoryName); Writer.WriteLine("
"); Dictionary Fields = CategoryPair.Value; foreach (KeyValuePair FieldPair in Fields) { // Get the XML comment for this field List? Lines; if (!TryGetXmlComment(InputDocumentation, FieldPair.Value, out Lines) || Lines.Count == 0) { Log.TraceWarning("Missing XML comment for {0}", FieldPair.Value.Name); continue; } // Write the result to the .udn file Writer.WriteLine("
{0}
", FieldPair.Key); if (Lines.Count == 1) { Writer.WriteLine("
{0}
", Lines[0]); } else { Writer.WriteLine("
"); for (int Idx = 0; Idx < Lines.Count; Idx++) { if (Lines[Idx].StartsWith("*") || Lines[Idx].StartsWith("-")) { Writer.WriteLine("
    "); for (; Idx < Lines.Count && (Lines[Idx].StartsWith("*") || Lines[Idx].StartsWith("-")); Idx++) { Writer.WriteLine("
  • {0}
  • ", Lines[Idx].Substring(1).TrimStart()); } Writer.WriteLine("
"); } else { Writer.WriteLine(" {0}", Lines[Idx]); } } Writer.WriteLine("
"); } } Writer.WriteLine("
"); } Writer.WriteLine(" "); Writer.WriteLine(""); } } } }