Ignoring Case For MVC Views On Linux

Running ASP.NET MVC using Mono on Linux works quite well -- but you occasionally run into some problems you wouldn't see if you were working on Windows.

For example, check out the two routes below. Both routes are matched by ASP.NET MVC as you'd expect but only one of them returns a view.

MVC uses the incoming route to determine where it will search for Views. That becomes a problem when you're using an OS with a case sensitive file system.

As far as I can tell there are ways to get around file system case-sensitivity at the OS level but that seems like an extreme response to a small problem. Additionally, Mono appears to have a setting to deal with case-sensitive file systems but I have yet to get it to work with MVC Views.

So, instead let's try some code that overrides the way Views are located.

using System;
using System.Web;
using System.Web.Mvc;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Web.Routing;

/// <summary>
/// Works around case sensitive file systems by locating
/// views regardless of the case they are in
/// </summary>
public class CaseInsensitiveViewEngine : WebFormViewEngine {

    private static string _Root = HttpContext.Current.Server.MapPath("~/");

  //adds a new CaseInsensitiveViewEngine to the routes provided
  public static void Register(ViewEngineCollection engines) {

    //clear the existing WebForm View Engines
    IViewEngine[] webforms = engines.Where(engine => 
      engine is WebFormViewEngine).ToArray();
    foreach(IViewEngine engine in webforms) 
      ViewEngines.Engines.Remove(engine);

    //add the new case-insensitive engine
    ViewEngines.Engines.Add(new CaseInsensitiveViewEngine());

  }

    //holds all of the actual paths to the required files
    private static Dictionary<string, string> _ViewPaths = 
        new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

    //update the path to match a real file
    protected override IView CreateView (ControllerContext controllerContext, 
    string viewPath, string masterPath) {
        viewPath = this._GetActualFilePath(viewPath);
        masterPath = this._GetActualFilePath(masterPath);
        return base.CreateView (controllerContext, viewPath, masterPath);
    }

  //finds partial views by detecting matches
  protected override IView CreatePartialView (ControllerContext controllerContext, 
    string partialPath) {
    partialPath = this._GetActualFilePath(partialPath);
    return base.CreatePartialView (controllerContext, partialPath);
  }

  //perform a case-insensitive file search
  protected override bool FileExists (ControllerContext context, string virtualPath) {
      virtualPath = this._GetActualFilePath(virtualPath);
      return base.FileExists(context, virtualPath);
  }

  //determines (and caches) the actual path for a file
  private string _GetActualFilePath(string virtualPath) {

      //check if this has already been matched before
      if (CaseInsensitiveViewEngine._ViewPaths.ContainsKey(virtualPath))
          return CaseInsensitiveViewEngine._ViewPaths[virtualPath];

      //break apart the path
      string[] segments = virtualPath.Split(new char[] { '/' });

      //get the root folder to work from
      var folder = new DirectoryInfo(CaseInsensitiveViewEngine._Root);

      //start stepping up the folders to replace with the correct cased folder name
      for(int i = 0; i < segments.Length; i++) {
          string part = segments[i];
          bool last = i == segments.Length - 1;

          //ignore the root
          if (part.Equals("~")) continue;

          //process the file name if this is the last segment
          else if (last) part = this._GetFileName(part, folder);

          //step up the directory for another part
          else part = this._GetDirectoryName(part, ref folder);

          //if no matches were found, just return the original string
          if (part == null || folder == null) return virtualPath;

          //update the segment with the correct name
          segments[i] = part;

      }

      //save this path for later use
      virtualPath = string.Join("/", segments);
      CaseInsensitiveViewEngine._ViewPaths.Remove(virtualPath);
      CaseInsensitiveViewEngine._ViewPaths.Add(virtualPath, virtualPath);
      return virtualPath;
  }

  //searches for a matching file name in the current directory
  private string _GetFileName(string part, DirectoryInfo folder) {

      //try and find a matching file, regardless of case
      FileInfo match = folder.GetFiles().FirstOrDefault(file => 
          file.Name.Equals(part, StringComparison.OrdinalIgnoreCase));
      return match is FileInfo ? match.Name : null;
  }

  //searches for a folder in the current directory and steps up a level
  private string _GetDirectoryName(string part, ref DirectoryInfo folder) {
      folder = folder.GetDirectories().FirstOrDefault(dir => 
          dir.Name.Equals(part, StringComparison.OrdinalIgnoreCase));
      return folder is DirectoryInfo ? folder.Name : null;
  }

}

Now, each part of our route is evaluated and the directory is crawled until we find the correct view. This way, regardless of OS or file system settings, our MVC view are able to be located no matter what the incoming route looks like.

This code also handles locating Partial Views as well even if the case of their path is incorrect.

Setup is pretty simple as well, just add this line to your Global.asax file.

CaseInsensitiveViewEngine.Register(ViewEngines.Engines);

And now you're ready to go!

May 12, 2011

Ignoring Case For MVC Views On Linux

Case-sensitive file systems can cause problems with how ASP.NET MVC locates views with Mono.