using System; using System.CodeDom; using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using System.Web.Compilation; using System.Web.Hosting; using System.Xml; namespace CSharpVitamins.Compilation { /// /// Generates a static class for urls within an ASP.NET project /// public class HrefBuildProvider : BuildProvider { #region Fields /// /// Path to the web root for determining urls within the site /// string _basePath; /// /// Whether to convert urls to lowercase - based on your preference /// bool _useLowerCaseUrls; /// /// Whether to include the extension of the file in the name /// of the member /// bool _includeExtension; /// /// Whether to create the root class as 'partial', allowing /// additional members to be added to the resultant class. /// bool _makePartial; /// /// Maximum recurring depth for the project - 1 for top level, /// 2 for first level of subfolders etc... /// int _maxDepth = 100; /// /// Namespace the generated class will be placed within. /// Leave empty to add to the global namespace e.g. global::Href /// string _namespace; /// /// The name of the main class e.g. Href /// string _className; /// /// Current depth of recursion /// int _depth = 0; /// /// Predicate to determine if a file should be included /// Predicate isValidFile; /// /// Predicate to determine if a folder should be included /// Predicate isValidFolder; #endregion #region GenerateCode /// /// Initialises settings from the BuildProvider file, and /// generates the appropriate code. /// /// public override void GenerateCode(AssemblyBuilder assemblyBuilder) { // init settings from xml source file init(); // create root class e.g. global::Href CodeTypeDeclaration root = createStaticClass(_className, _makePartial); addSummary(root, "Provides access to urls within the project."); build(new DirectoryInfo(_basePath), root); CodeNamespace ns = new CodeNamespace(); ns.Types.Add(root); // leave blank for global::namespace if (!string.IsNullOrEmpty(_namespace)) ns.Name = _namespace; CodeCompileUnit unit = new CodeCompileUnit(); unit.Namespaces.Add(ns); assemblyBuilder.AddCodeCompileUnit(this, unit); } #endregion #region build /// /// Iterates over the given directory, adding the files as members to /// the parent type, then recurse down the folder tree until the max /// depth is reached. /// /// The directory to process /// Parent class to add members too. void build(DirectoryInfo dir, CodeTypeDeclaration parent) { if (_depth >= _maxDepth) return; ++_depth; /// 'members' keeps a record of the number of times a /// member name is repeated within the parent type/class. /// Pass this to 'ensureUniqueMemberName' to get the name /// with an index number appended to the end e.g. the second /// occurrence of "MyProperty" becomes "MyProperty1" Dictionary members = new Dictionary(); /// /// process files: /// iterate over files and add the member /// public const string MyfileName = "~/myfileName.aspx"; /// FileInfo[] files = dir.GetFiles(); foreach (FileInfo file in files) { if (isValidFile(file)) { CodeMemberField field = new CodeMemberField(); field.Name = getName(file); field.Type = new CodeTypeReference(typeof(string)); field.Attributes = MemberAttributes.Public | MemberAttributes.Const; field.InitExpression = getInitExpression(getUrl(file)); addSummary(field, getUrl(file, false)); ensureUniqueMemberName(members, field); parent.Members.Add(field); } } /// /// process subfolders: /// iterate over folders and add a nested class /// DirectoryInfo[] subfolders = dir.GetDirectories(); foreach (DirectoryInfo folder in subfolders) { if (isValidFolder(folder)) { CodeTypeDeclaration nested = createStaticClass(getName(folder), false); addSummary(nested, "Provides access to urls under: {0}", getUrl(folder, false).TrimStart('~')); ensureUniqueMemberName(members, nested); build(folder, nested); /// .ctor will have already been added to members /// so only add to parent if there are additional /// members present if (nested.Members.Count > 1) parent.Members.Add(nested); } } --_depth; } #endregion #region init /// /// Initialises settings from the config file. /// /// /// /// Sample configuration - all nodes and elements are optional, but the file /// needs a root node to load as an xml document. /// /// /// /// /// /// /// /// void init() { _basePath = HostingEnvironment.ApplicationPhysicalPath; XmlDocument xml = new XmlDocument(); using (Stream stream = VirtualPathProvider.OpenFile(base.VirtualPath)) xml.Load(stream); /// code below uses syntax, /// xml["elementName"] ?? xml.CreateElement( "elementName" ); /// as a lazy initialisation technique, rather than checking for /// the node and performing alternate initialisation - this keeps /// it within the same reading context. /// /// general settings /// XmlElement settings = xml["settings"] ?? xml.CreateElement("settings"); _maxDepth = int.Parse(getAttributeValue(settings, "maxDepth", "100")); _useLowerCaseUrls = bool.Parse(getAttributeValue(settings, "lowercaseUrls", "false")); _includeExtension = bool.Parse(getAttributeValue(settings, "includeExtension", "false")); _makePartial = bool.Parse(getAttributeValue(settings, "makePartial", "false")); _namespace = getAttributeValue(settings, "namespace"); _className = getAttributeValue(settings, "className", "Href"); /// /// files settings /// XmlElement files = settings["files"] ?? xml.CreateElement("files"); isValidFile = createFilter( getAttributeValue(files, "include", @"\.aspx$"), getAttributeValue(files, "exclude") ); /// /// folders settings /// XmlElement folders = settings["folders"] ?? xml.CreateElement("folders"); isValidFolder = createFilter( getAttributeValue(folders, "include"), getAttributeValue(folders, "exclude", "App_|Bin") ); } #endregion #region getAttributeValue /// /// /// /// /// /// /// static string getAttributeValue(XmlElement parent, string name, string @default) { XmlAttribute attribute = parent.Attributes[name]; return null == attribute ? @default : attribute.Value.Trim(); } /// /// /// /// /// /// static string getAttributeValue(XmlElement parent, string name) { return getAttributeValue(parent, name, null); } #endregion #region createFilter /// /// Creates a Predicate for filtering based on the given regex patterns /// /// /// /// static Predicate createFilter(string includePattern, string excludePattern) { Regex include = null, exclude = null; if (!string.IsNullOrEmpty(includePattern)) include = new Regex(includePattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); if (!string.IsNullOrEmpty(excludePattern)) exclude = new Regex(excludePattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); return createFilter(include, exclude); } /// /// Creates a Predicate for filtering based on the given regex /// /// /// /// static Predicate createFilter(Regex include, Regex exclude) { if (null != include && null != exclude) return delegate(FileSystemInfo info) { return include.IsMatch(info.FullName) && !exclude.IsMatch(info.FullName); }; else if (null != include) return delegate(FileSystemInfo info) { return include.IsMatch(info.FullName); }; else if (null != exclude) return delegate(FileSystemInfo info) { return !exclude.IsMatch(info.FullName); }; else return delegate(FileSystemInfo info) { return true; }; } #endregion #region getInitExpression /// /// Returns the given value surrounded with quotes. /// /// /// static CodeSnippetExpression getInitExpression(string value) { return new CodeSnippetExpression(string.Concat("\"", value, "\"")); } #endregion #region getName /// /// Gets the normalised/escaped member name for the given file /// /// /// string getName(FileSystemInfo file) { string name = Path.GetFileNameWithoutExtension(file.Name); if (_includeExtension) name += file.Extension.Replace('.', '_'); name = Regex.Replace(name, @"[^a-z0-9_]*", string.Empty, RegexOptions.Compiled | RegexOptions.IgnoreCase); // Ensure pascal casing of the name - not really required //name = TextHelper.PascalCase( name ); // Escape names not starting with a letter if (name.Length > 0 && !char.IsLetter(name[0])) name = "_" + name; // Escape C# keywords // included in separate library, omitted here for clarity //name = CodeHelper.CSharp.EscapeWord(name); return name; } #endregion #region getUrl /// /// Gets the url of the file, relative to the app root /// /// /// string getUrl(FileSystemInfo file) { return getUrl(file, true); } /// /// Gets the url of the file, relative to the app root /// /// /// When true, allows lowercasing of the /// url and stripping of default.aspx /// string getUrl(FileSystemInfo file, bool allowNormalisation) { string url = file.FullName.Substring(_basePath.Length); if (allowNormalisation) { if (_useLowerCaseUrls) url = url.ToLower(); if (url.EndsWith("default.aspx", StringComparison.OrdinalIgnoreCase)) url = url.Substring(0, url.Length - 12); } return string.Concat("~/", url.Replace("\\", "/")); } #endregion #region addSummary /// /// Adds a summary doc comment to the type's comment collection /// /// /// /// void addSummary(CodeTypeMember type, string format, params object[] args) { addSummary(type.Comments, format, args); } /// /// Adds a summary doc comment to the collection /// /// /// /// void addSummary(CodeCommentStatementCollection comments, string format, params object[] args) { comments.Add(new CodeCommentStatement("", true)); comments.Add(new CodeCommentStatement(string.Format(format, args), true)); comments.Add(new CodeCommentStatement("", true)); } #endregion #region createStaticClass /// /// Creates a static class type with the given name /// /// /// /// static CodeTypeDeclaration createStaticClass(string name, bool partial) { CodeTypeDeclaration type = new CodeTypeDeclaration(name); type.TypeAttributes |= System.Reflection.TypeAttributes.Sealed; type.Attributes = MemberAttributes.Public | MemberAttributes.Static; type.IsClass = true; type.IsPartial = partial; CodeConstructor ctor = new CodeConstructor(); ctor.Attributes = MemberAttributes.Private; type.Members.Add(ctor); return type; } #endregion #region ensureUniqueMemberName /// /// Ensures a unique name within the dictionary to get the name /// with an index number appended to the end e.g. the second /// occurrence of "MyProperty" becomes "MyProperty1" /// /// A caveat of this method is where you may have two members /// like MyPage.aspx, MyPage.html as well as MyPage1.aspx. A conflict /// will occur giving duplicate members i.e. in the same order MyPage, /// MyPage1 and MyPage1 a second time. The likelihood of such conflicts /// is possible, with one possible solution to recurse using /// ensureUniqueMemberName with the newly generated name, or enumerate /// the parent type's Members to see whether there will be a conflict. /// /// Dictionary containing the name of the members /// in the current namespace as the key + the number of appearances as /// the value. /// The next member to add to the current namespace. static void ensureUniqueMemberName(Dictionary members, CodeTypeMember type) { string key = type.Name; int count; if (members.TryGetValue(key, out count)) { type.Name = string.Concat(key, ++count); members[key] = count; } else { members[key] = 0; } } #endregion } }