Improve Your Code Golf Game with LINQ
I always enjoy a good coding challenge, and variations of code golf are most common. For the uninitiated, code golf provides a problem with the objective of providing a solution that requires the fewest keystrokes or lines. While production code certainly deserves more white space than games tend to afford, there are still some lessons we can learn from the experience.
This particular post comes on the heels of Scott Hanselman‘s casual challenge to clean up some of his code in as few lines as possible. The general requirements are as follows:
- Accept a path to a project
- For each of a few files in each project…
- Back up the file
- Perform a few string replacements in the file
- Save the updated version of the file
As I was looking over his code for some chances to optimize, it quickly became clear that the bulk of the “hard” stuff could be solved through some LINQ-supported functional programming. I posted a first draft, upon which others iterated, but his spam filter ate this new version so I thought it might be educational to walk through it here instead.
First, let’s define a few arrays that will specify the entirety of our configuration, along with the input array of project paths:
static void Main(string[] args)
{
if (args.Length == 0) Console.WriteLine("Usage: ASPNETMVCUpgrader pathToProject1 [pathToProject2] [pathToProject3]");
var configFiles = new[] { "web.config", @"Viewsweb.config" };
var changes = new[] {
new { Regex = new Regex(@"(?<1>System.Web.Mvc, Version=)1.0(?<2>.0.0,)", RegexOptions.Compiled), Replacement = "${1}1.1${2}"},
new { Regex = new Regex(@"(?<1>System.Web.Routing, Version=)3.5(?<2>.0.0,)", RegexOptions.Compiled), Replacement = "${1}4.0${2}"} };
The regular expressions are based on those provided by commenter Dušan Radovanović. Next, we can use some LINQ to build a list of all our files to update:
var filesToUpdate = from file in configFiles
from projectPath in args
let path = Path.Combine(projectPath, file)
where File.Exists(path)
select new { Path = path, Content = File.ReadAllText(path) };
If you’re not familiar with C# 3.0, by line this does the following:
- Let
file
be the current item inconfigFiles
. - Let
projectPath
be the current item inargs
. - Let
path
be the combined value ofprojectPath
andfile
. - Only include
path
values for files that exist. - Create new anonymous objects with
Path
andContent
properties set to the path and file contents, respectively.
As with most LINQ operations, execution of this code will be deferred until filesToUpdate
is enumerated.
Now we’re ready to update our files. First, I’ll define a sequence of our possible backup file names, which will add “.backup_XX” to the file name.* Since the sequence is lazily evaluated, we can just call LINQ’s First()
to find an available backup file name. Note that First()
would throw an exception if all 100 files existed, as the backupFileNames
sequence would be empty.
foreach (var file in filesToUpdate)
{
var backupFileNames = from n in Enumerable.Range(0, 100)
let backupPath = string.Format("{0}.backup_{1:00}", file.Path, n)
where !File.Exists(backupPath)
select backupPath;
File.Move(file.Path, backupFileNames.First());
Finally, we need to actually update the file content. To do that, we’ll use LINQ’s Aggregate
operator:
string newContent = changes.Aggregate(file.Content, (s, c) => c.Regex.Replace(s, c.Replacement));
File.WriteAllText(file.Path, newContent);
Console.WriteLine("Done converting: {0}", file.Path);
}
}
Aggregate
takes two parameters: a seed value and a function that defines the aggregation. In our case, the seed value is of type string
and the function is of type Func<string, 'a, string>
, where 'a
is our anonymous type with Regex
and Replacement
properties. In practice, this call is going to take our original content and apply each of our changes in succession, using the result of one replacement as the input to the next. In functional terminology, Aggregate
is known as a left fold; for more on Aggregate
and folds, see this awesome post by language guru Bart de Smet.
What strikes me about this code is that it’s both terse and expressive. And for the purposes of the challenge, we can rewrite some of the queries in extension method syntax:
static void Main(string[] args)
{
if (args.Length == 0) Console.WriteLine("Usage: ASPNETMVCUpgrader pathToProject1 [pathToProject2] [pathToProject3]");
var configFiles = new[] { "web.config", @"Viewsweb.config" };
var changes = new[] {
new { Regex = new Regex(@"(?<1>System.Web.Mvc, Version=)1.0(?<2>.0.0,)", RegexOptions.Compiled), Replacement = "${1}1.1${2}"},
new { Regex = new Regex(@"(?<1>System.Web.Routing, Version=)3.5(?<2>.0.0,)", RegexOptions.Compiled), Replacement = "${1}4.0${2}"} };
var files = from path in configFiles.SelectMany(file => args, (file, arg) => Path.Combine(arg, file))
where File.Exists(path) select new { Path = path, Content = File.ReadAllText(path) };
foreach (var file in files)
try
{
File.Move(file.Path, Enumerable.Range(0, 100).Select(n => string.Format("{0}.backup_{1:00}", file.Path, n)).First(p => !File.Exists(p)));
File.WriteAllText(file.Path, changes.Aggregate(file.Content, (s, c) => c.Regex.Replace(s, c.Replacement)));
Console.WriteLine("Done converting: {0}", file.Path);
}
catch (Exception ex) { Console.WriteLine("Error with: {0}" + Environment.NewLine + "Exception: {1}", file.Path, ex.Message); }
}
- The original code had the most recent backup with extension .mvc10backup, with the next oldest backup called .mvc10backup2. My original version extended this concept to “unlimited” backups with old backups continuously incremented so the lower values were more recent. It could probably be improved, but I thought I’d include the adapted code here for completeness:
foreach (var file in files)
try
{
var backupPaths = Enumerable.Repeat<int?>(null, 1)
.Concat(Enumerable.Range(2, int.MaxValue - 2).Select(i => (int?)i))
.Select(i => Path.ChangeExtension(filename, ".mvc10backup" + i));
string toCopy = file.Path;
foreach (var f in backupPaths.TakeWhile(_ => toCopy != null))
{
string temp = null;
if (File.Exists(f))
File.Move(f, temp = f + "TEMP");
File.Move(toCopy, f);
toCopy = temp;
}
File.WriteAllText(file.Path, changes.Aggregate(file.Content, (s, c) => c.Regex.Replace(s, c.Replacement)));
Console.WriteLine("Done converting: {0}", file.Path);
}
catch (Exception ex) { Console.WriteLine("Error with: {0}" + Environment.NewLine + "Exception: {1}", file.Path, ex.Message); }
}