Migrating to .NET Core – Overridable Localization – Handling Argument Count Mismatch

You can read my original post to understand how I implemented overridable Localization when using Razor Class Libraries (RCL). TLDR; My implementation allows you to use ViewLocalization in RCLs and additionally, allows you to have an override pattern for any level of assembly nesting/referencing similar to the pattern Razor View detection uses.

To my implementation, I’ve made two improvements. First, if you have a resource string that has string substitution like "Hi {0}" and you don’t pass in a parameter when requesting that through a localizer, the page immediately stops rendering and just displays whatever was generated up until that point. The debug log reveals the following.

FormatException: Index (zero based) must be greater than or equal to zero and less than the size of the argument list.

This happens in the LocalizedHtmlString.WriteTo method. Since I couldn’t modify/override this code and I definitely didn’t want the site to just drop dead in its tracks when parameter counts were wrong, I updated my IStringLocalizer<T> and IHtmlLocalizer<T> classes (or at least the helper method). In a ‘literal’ implementation, it would look like the following. This code checks to see if there are sufficient arguments passed in, and if there are not, it appends more string arguments simply indicating that that argument is missing. This way the site still renders and the developer can easily see where arguments were forgotten.

public virtual LocalizedHtmlString this[ string name ] => this[ name, new object[] { } ];

public virtual LocalizedHtmlString this[ string name, params object[] arguments ] 
{
    var ls = _localizer[name, arguments];

    if ( !ls.IsResourceNotFound( ls ) )
    {
        var matches = parameterCount.Matches( ls.Value );

        var expectedParameters = matches.Count > 0
            ? matches.Cast<Match>()
                .Max( m => int.Parse( m.Groups[ "number" ].Value ) + 1 )
            : 0;

        if ( arguments.Length < expectedParameters )
        {
            var protectedArgs =
                arguments.Concat(
                    Enumerable
                        .Range( arguments.Length, expectedParameters - arguments.Length )
                        .Select( p => $"{{MISSING PARAM #{p}}}" )
                ).ToArray();

            return _localizer[name, protectedArgs];
        }
    }

    return ls;
}

The second improvement I made was to clean up the code a ton using generics. Read the original article to see mention of not being DRY. But now I am. I won’t explain everything that changed but instead just drop the updated source code for your review.

public class ResourceLocalizer<TLocalizer, TLocalized>
{
	private string _resourcesRelativePath;
	protected TLocalizer[] localizers;

	public ResourceLocalizer( IOptions<Microsoft.Extensions.Localization.LocalizationOptions> localizationOptions )
	{
		_resourcesRelativePath = localizationOptions.Value.ResourcesPath ?? string.Empty;
	}

	protected string BuildBaseSuffix( Type resourceType )
	{
		// Given a type of RootNameSpace.NS1.NS2.Class,
		// The return value will be N1.N2.Class and should be used to create localizers 
		// for each assembly passed in on `LocalizationOptions`.  
		//
		// The caller will then need to call
		//		factory.Create( Assembly1.N1.N2.Class, Assembly1);
		//		factory.Create( Assembly2.N1.N2.Class, Assembly2);
		//		...
		//
		//
		// factory.Create will correctly put in the 'ResourcePath' based on the
		// `ResourceLocationAttribute` of each assembly.  Otherwise it assumes that all
		// assemblies have the same resource path set inside `LocalizationOptions.ResourcesPath`.

		var typeInfo = resourceType.GetTypeInfo();
		var rootNamespace = GetRootNamespace( typeInfo.Assembly );
		var resourcePrefix = GetResourcePath( typeInfo.Assembly ) + ".";

		var baseSuffix = typeInfo.FullName.Substring( rootNamespace.Length + 1 );

		// When a base path is passed into the Factory.Create, it'll preprend the
		// 'resource path' so we need to trim 'resource path' namespace prefix.  This
		// probably only comes into play for a 'shared resource' and if the file/class is
		// placed inside the Resources folder.  The proper way to do it is probably to
		// create a SharedResources class at root of assembly/namespacing, then put a 
		// SharedResources.resx file at root of Resources folder.  But I wanted to 
		// keep that dummy class tucked away.
		if ( baseSuffix.StartsWith( resourcePrefix ) )
		{
			baseSuffix = baseSuffix.Substring( resourcePrefix.Length );
		}

		return baseSuffix;
	}

	private string GetResourcePath( Assembly assembly )
	{
		var resourceLocationAttribute = assembly.GetCustomAttribute<ResourceLocationAttribute>();

		// If we don't have an attribute assume all assemblies use the same resource location.
		var resourceLocation = resourceLocationAttribute == null
			? _resourcesRelativePath
			: resourceLocationAttribute.ResourceLocation + ".";

		resourceLocation = resourceLocation
			.Replace( Path.DirectorySeparatorChar, '.' )
			.Replace( Path.AltDirectorySeparatorChar, '.' );

		return resourceLocation;
	}

	private string GetRootNamespace( Assembly assembly )
	{
		var rootNamespaceAttribute = assembly.GetCustomAttribute<RootNamespaceAttribute>();

		if ( rootNamespaceAttribute != null )
		{
			return rootNamespaceAttribute.RootNamespace;
		}

		return assembly.GetName().Name;
	}

	protected string BuildBaseName( string path, string applicationName )
	{
		var extension = Path.GetExtension( path );
		var startIndex = path[ 0 ] == '/' || path[ 0 ] == '\\' ? 1 : 0;
		var length = path.Length - startIndex - extension.Length;
		var capacity = length + applicationName.Length + 1;
		var builder = new StringBuilder( path, startIndex, length, capacity );

		builder.Replace( '/', '.' ).Replace( '\\', '.' );

		// Prepend the application name
		builder.Insert( 0, '.' );
		builder.Insert( 0, applicationName );

		return builder.ToString();
	}

	protected IEnumerable<LocalizedString> GetAllLocalizedStrings( bool includeParentCultures, Func<TLocalizer, bool, IEnumerable<LocalizedString>> getAllStrings )
	{
		var keys = new HashSet<string>();

		foreach ( var l in localizers )
		{
			var allStrings = getAllStrings( l, includeParentCultures ).GetEnumerator();
			var more = true;

			while ( more )
			{
				try
				{
					more = allStrings.MoveNext();
				}
				catch
				{
					more = false;
				}

				if ( more && !keys.Contains( allStrings.Current.Name ) )
				{
					keys.Add( allStrings.Current.Name );
					yield return allStrings.Current;
				}
			}
		}
	}

	protected TLocalizedReturn GetLocalizedString<TLocalizedReturn>(
		string key,
		object[] arguments,
		Func<TLocalizedReturn, string> getValue,
		Func<TLocalizedReturn, bool> isResourceFound,
		Func<TLocalizer, string, object[], TLocalizedReturn> getLocalized
	)
	{
		if ( key == null )
		{
			throw new ArgumentNullException( nameof( key ) );
		}

		var currentLocalizer = 0;
		var ls = getLocalized( localizers[ currentLocalizer ], key, arguments );

		while ( !isResourceFound( ls ) && currentLocalizer < localizers.Length - 1 )
		{
			currentLocalizer++;
			ls = getLocalized( localizers[ currentLocalizer ], key, arguments );
		}

		return VerifyLocalizedStringArguments<TLocalizedReturn>(
			ls,
			arguments,
			getValue,
			isResourceFound,
			args => getLocalized( localizers[ currentLocalizer ], key, args )
		);
	}

	private static Regex parameterCount = new Regex( @"(?<!\{)\{(?<number>[0-9]+).*?\}(?!\})", RegexOptions.Compiled );
	private TLocalizedReturn VerifyLocalizedStringArguments<TLocalizedReturn>(
		TLocalizedReturn ls,
		object[] arguments,
		Func<TLocalizedReturn, string> getValue,
		Func<TLocalizedReturn, bool> isResourceFound,
		Func<object[], TLocalizedReturn> getLocalized )
	{
		if ( isResourceFound( ls ) )
		{
			// https://stackoverflow.com/a/948316/166231
			var matches = parameterCount.Matches( getValue( ls ) );

			var expectedParameters = matches.Count > 0
				? matches.Cast<Match>()
					.Max( m => int.Parse( m.Groups[ "number" ].Value ) + 1 )
				: 0;

			if ( arguments.Length < expectedParameters )
			{
				var protectedArgs =
					arguments.Concat(
						Enumerable
							.Range( arguments.Length, expectedParameters - arguments.Length )
							.Select( p => $"{{MISSING PARAM #{p}}}" )
					).ToArray();

				return getLocalized( protectedArgs );
			}
		}

		return ls;
	}

	// Helpers to make Generics work
	protected bool IsResourceFound( LocalizedString s ) => !s.ResourceNotFound;
	protected bool IsResourceFound( LocalizedHtmlString s ) => !s.IsResourceNotFound;
	protected string GetValue( LocalizedString s ) => s.Value;
	protected string GetValue( LocalizedHtmlString s ) => s.Value;
	protected LocalizedHtmlString GetLocalizedHtmlItem( IHtmlLocalizer l, string key, object[] arguments ) => l[ key, arguments ];
	protected LocalizedString GetLocalizedStringItem( IHtmlLocalizer l, string key, object[] arguments ) => l.GetString( key, arguments );
	protected LocalizedString GetLocalizedStringItem( IStringLocalizer l, string key, object[] arguments ) => l[ key, arguments ];
}

public class StringLocalizer<TResourceSource> : ResourceLocalizer<IStringLocalizer, LocalizedString>, IStringLocalizer<TResourceSource>
{
	public StringLocalizer(
		IStringLocalizerFactory factory,
		IOptions<Microsoft.Extensions.Localization.LocalizationOptions> coreLocalizationOptions,
		IOptions<LocalizationOptions> localizationOptions ) : base( coreLocalizationOptions )
	{
		if ( factory == null )
		{
			throw new ArgumentNullException( nameof( factory ) );
		}
		if ( localizationOptions == null )
		{
			throw new ArgumentNullException( nameof( localizationOptions ) );
		}

		var baseNameSuffix = BuildBaseSuffix( typeof( TResourceSource ) );

		localizers =
			localizationOptions.Value.AssemblyNames
				.Select( a => factory.Create( $"{a}.{baseNameSuffix}", a ) )
				.ToArray();
	}

	public virtual LocalizedString this[ string name ] => GetLocalizedString( name, new object[] { }, GetValue, IsResourceFound, GetLocalizedStringItem );
	public virtual LocalizedString this[ string name, params object[] arguments ] => GetLocalizedString( name, arguments, GetValue, IsResourceFound, GetLocalizedStringItem );

	public IEnumerable<LocalizedString> GetAllStrings( bool includeParentCultures ) => GetAllLocalizedStrings( includeParentCultures, ( l, includeParent ) => l.GetAllStrings( includeParent ) );

	public IStringLocalizer WithCulture( CultureInfo culture ) => throw new NotImplementedException();
}

public class HtmlLocalizer<TResourceSource> : ResourceLocalizer<IHtmlLocalizer, LocalizedHtmlString>, IHtmlLocalizer<TResourceSource>
{
	public HtmlLocalizer(
		IHtmlLocalizerFactory factory,
		IOptions<Microsoft.Extensions.Localization.LocalizationOptions> coreLocalizationOptions,
		IOptions<LocalizationOptions> localizationOptions ) : base( coreLocalizationOptions )
	{
		var baseNameSuffix = BuildBaseSuffix( typeof( TResourceSource ) );

		localizers =
			localizationOptions.Value.AssemblyNames
				.Select( a => factory.Create( baseNameSuffix, a ) )
				.ToArray();
	}

	public virtual LocalizedHtmlString this[ string name ] => GetLocalizedString( name, new object[] { }, GetValue, IsResourceFound, GetLocalizedHtmlItem );
	public virtual LocalizedHtmlString this[ string name, params object[] arguments ] => GetLocalizedString( name, arguments, GetValue, IsResourceFound, GetLocalizedHtmlItem );

	public LocalizedString GetString( string name ) => GetLocalizedString( name, new object[] { }, GetValue, IsResourceFound, GetLocalizedStringItem );
	public LocalizedString GetString( string name, params object[] arguments ) => GetLocalizedString( name, arguments, GetValue, IsResourceFound, GetLocalizedStringItem );

	public IHtmlLocalizer WithCulture( CultureInfo culture ) => throw new NotImplementedException();
	public IEnumerable<LocalizedString> GetAllStrings( bool includeParentCultures ) => GetAllLocalizedStrings( includeParentCultures, ( l, includeParent ) => l.GetAllStrings( includeParent ) );
}

public class ViewLocalizer : ResourceLocalizer<IHtmlLocalizer, LocalizedHtmlString>, IViewLocalizer, IViewContextAware
{
	private readonly IHtmlLocalizerFactory localizerFactory;
	private readonly LocalizationOptions localizationOptions;

	public ViewLocalizer(
		IHtmlLocalizerFactory localizerFactory,
		IOptions<Microsoft.Extensions.Localization.LocalizationOptions> coreLocalizationOptions,
		IOptions<LocalizationOptions> localizationOptions
	) : base( coreLocalizationOptions )
	{
		if ( localizerFactory == null )
		{
			throw new ArgumentNullException( nameof( localizerFactory ) );
		}

		if ( localizationOptions == null )
		{
			throw new ArgumentNullException( nameof( localizationOptions ) );
		}

		this.localizationOptions = localizationOptions.Value;
		this.localizerFactory = localizerFactory;
	}

	public void Contextualize( ViewContext viewContext )
	{
		if ( viewContext == null )
		{
			throw new ArgumentNullException( nameof( viewContext ) );
		}

		// Given a view path "/Views/Home/Index.cshtml" we want a baseName like "MyApplication.Views.Home.Index"
		var path = viewContext.ExecutingFilePath;

		if ( string.IsNullOrEmpty( path ) )
		{
			path = viewContext.View.Path;
		}

		Debug.Assert( !string.IsNullOrEmpty( path ), "Couldn't determine a path for the view" );

		localizers =
			localizationOptions.AssemblyNames
				.Select( a => localizerFactory.Create( BuildBaseName( path, a ), a ) )
				.ToArray();
	}

	public virtual LocalizedHtmlString this[ string name ] => GetLocalizedString( name, new object[] { }, GetValue, IsResourceFound, GetLocalizedHtmlItem );
	public virtual LocalizedHtmlString this[ string name, params object[] arguments ] => GetLocalizedString( name, arguments, GetValue, IsResourceFound, GetLocalizedHtmlItem );

	public LocalizedString GetString( string name ) => GetLocalizedString( name, new object[] { }, GetValue, IsResourceFound, GetLocalizedStringItem );
	public LocalizedString GetString( string name, params object[] arguments ) => GetLocalizedString( name, arguments, GetValue, IsResourceFound, GetLocalizedStringItem );

	public IHtmlLocalizer WithCulture( CultureInfo culture ) => throw new NotImplementedException();
	public IEnumerable<LocalizedString> GetAllStrings( bool includeParentCultures ) => GetAllLocalizedStrings( includeParentCultures, ( l, includeParent ) => l.GetAllStrings( includeParent ) );
}

Leave a comment