Annotation rewriter from CMS6 to CMS7

I was given a task to rewrite the annotations in our codebase - we use PageTypeBuilder so each of our page types is defined as following:

[PageType("a653d556-7b52-46e9-87be-83a2a351dc88", AvailableInEditMode = true, AvailablePageTypes = new[] { typeof(BannerPage) })]
public class ArticlePage: PageData {...}

EPiServer upgrade manual says that you should:

  1. Run the upgrade through Deployment Center
  2. Fix compilation errors
  3. Run the solution
However the problem in our case was that CMS is only a part of our enterprise solution and we cannot afford to stop developing new features - most of the team were still actively adding new features to the same codebase.
In order to avoid merging horror I decided to try to stay in trunk for as long as I can and to postpone branching to the very last moment.
My idea was to utilize precompiler directives and conditionally compile different annotations for different CMS versions.
As an example please find the following definition of a page type:
#if EPISERVER_8
[EPiServer.DataAnnotations.ContentType( GUID = "16496e84-3756-44f8-a0a2-a57fc40b9baa")]
[EPiServer.DataAnnotations.AvailableContentTypes(Include = new[] { typeof(CommentPage), typeof(ModuleContainer), typeof(Module) } )]
#endif
#if EPISERVER_6
 [PageType("16496e84-3756-44f8-a0a2-a57fc40b9baa"
,Filename = "~/Templates/Pages/Common/News/NewsPageTemplate.aspx"
,AvailablePageTypes = new [] { typeof(CommentPage), typeof(ModuleContainer), typeof(Module) }
)]
#endif
public class ArticlePage {...}

I had a vision to write a tool that automatically browses through our codebase and transpiles page type builder definitions into those kind of conditional definitions.
I decided to implement it using Roslyn and its powerful implementation of the visitor pattern.
However I found it quite time consuming (I will share some more details in a future post).

Because of the complexity I decided to try with good-old regex ;)

First off we have to scan the source code for page type definitions:

[^/]\[PageType\(("(?<GUID>[^"]+)"\s*,?\s*)?\s*((Name\s*=\s*"(?<Name>[^"]+)"\s*,?\s*)|(Filename\s*=\s*"(?<Filename>[^"]+)"\s*,?\s*)|(SortOrder\s*=\s*(?<SortOrder>\d+)\s*,?\s*)|(DefaultSortIndex\s*=\s*(?<DefaultSortIndex>\d+)\s*,?\s*)|(AvailableInEditMode\s*=\s*(?<AvailableInEditMode>\w+)\s*,?\s*)|(DefaultVisibleInMenu\s*=\s*(?<DefaultVisibleInMenu>\w+)\s*,?\s*)|(AvailablePageTypes\s*=\s*new\s*\[\]\s*{(?<AvailablePageTypes>[^}]+)}\s*,?\s*)|(ExcludedPageTypes\s*=\s*new\s*\[\]\s*{(?<ExcludedPageTypes>[^}]+)}\s*,?\s*)|(\w+\s*=\s*"[^"]+"\s*,?\s*)|(\w+\s*=\s*\S+\s*,?\s*))+\s*\)\s*\]

As you see I defined several named groups to extract all important properties out of the definitions. Next, we have to Regex.Replace

The replacement string is as follows:

#if EPISERVER_8
[EPiServer.DataAnnotations.ContentType(DisplayName = "${Name}", GUID = "${GUID}", AvailableInEditMode = ${AvailableInEditMode}, Order = ${SortOrder})]
[EPiServer.DataAnnotations.AvailablePageTypes(Include = new[] { ${AvailablePageTypes} }, Exclude = new []{${ExcludedPageTypes}} )]
#endif
#if EPISERVER_6
$&
#endif

Analogically you can do the same with properties:

[^/]\[PageTypeProperty\(\s*((EditCaption\s*=\s*@?"(?[^"]+)"\s*,?\s*)|(HelpText\s*=\s*@?"(?[^"]+)"\s*,?\s*)|(DefaultValue\s*=\s*@?"(?[^"]+)"\s*,?\s*)|(Tab\s*=\s*typeof\s*\((?\w+)\)\s*,?\s*)|(Type\s*=\s*typeof\s*\((?\w+)\)\s*,?\s*)|(SortOrder\s*=\s*(?\d+)\s*,?\s*)|(DisplayInEditMode\s*=\s*(?\w+)\s*,?\s*)|(UniqueValuePerLanguage\s*=\s*(?\w+)\s*,?\s*)|(Required\s*=\s*(?\w+)\s*,?\s*)|(\w+\s*=\s*"[^"]+"\s*,?\s*)|(\w+\s*=\s*\S+\s*,?\s*))*\)\]

And the replacement is as follows:

#if EPISERVER_8
[System.ComponentModel.DataAnnotations.Display(
 Name = "${EditCaption}"
 , Description = "${HelpText}"
 , Order = ${SortOrder}
 , GroupName = "${Tab}"
 )]
 [System.ComponentModel.DataAnnotations.Required(${Required})]
 [System.ComponentModel.DataAnnotations.ScaffoldColumn(${DisplayInEditMode})]
 [EPiServer.DataAnnotations.BackingType(typeof(${Type}))]
 [EPiServer.DataAnnotations.CultureSpecific(${UniqueValuePerLanguage})]
#endif
#if EPISERVER_6
$&
#endif

What you have to do is simply enumerate through all project files and try to match/replace

var directory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
var pageModelsPath = Path.Combine(directory, "PageModels");
var enumerable = FilesHelper.GetFiles(pageModelsPath).ToList();
foreach (var file in enumerable)
{
 var code = File.ReadAllText(file);
 var pageRegex = new Regex(RegexExpressions.PageTypeExpression);
 var propertyRegex = new Regex(RegexExpressions.PageTypePropertyExpression);
 if (pageRegex.Match(code).Success == false && propertyRegex.Match(code).Success == false)
 {
 continue;
 }
 code = pageRegex.Replace(code, RegexExpressions.PageTypeReplacementExpression);
 code = propertyRegex.Replace(code, RegexExpressions.PageTypePropertyReplacementExpression);
 ...

Unfortunately this approach generates a lot of noise; regex does not support conditionals so it's not possible to say: 'IF namedGroup[ExcludePageTypes] is not null then ...'. You can simply provide only 1 replacement string.

Thus, I had to do a little cleanup afterwards.

code = new Regex("\\w+\\s*=\\s*\"\"\\s*,?").Replace(code, string.Empty);
code = new Regex("\\[[\\w\\.]+\\(\\)\\]").Replace(code, string.Empty);
code = new Regex("\\[.+\\(\\)\\)\\]").Replace(code, string.Empty);
code = new Regex("\\w+\\s*=\\s*,").Replace(code, string.Empty);
code = new Regex(",\\s*?\\w+\\s*=\\s*\\)").Replace(code, ")");
code = new Regex(",?\\s*\\w+\\s*=\\s*new\\s*\\[\\]\\s*\\{\\s*\\}").Replace(code, string.Empty);
code = new Regex(",\\s*\\)\\]").Replace(code, ")]");

The end results are very satisfactory. The team is still doing new features and I am able to work in the same branch as them doing CMS7+ related stuff.

The only downside is that I needed to add a separate sln/csproj files (blind copies of existing files) that have EPISERVER_8 conditional compilation symbol set.