Templating: How to name (or rename) files based on a parameter?

Created on 12 Sep 2017  路  17Comments  路  Source: dotnet/templating

I've been unable to find any way to ensure that a file in my template project ends up with a specific name.

Note that I'm not talking about sourceName. I understand how that works, and it works just fine for what it is.

Rather, I'm trying to do the following: suppose I have a file called MyService.cs in my project. And further suppose that I have a parameter defined in my template.json as:

"symbols":
{
    "serviceName":
    {
        "type": "parameter",
        "defaultValue": "MyService",
        "replaces": "__MYSERVICE__"
    }
}

When the user runs: dotnet new MyTemplate -s FuzzyMonkey

I want the file MyService.cs to be renamed FuzzyMonkey.cs

I can make the actually class name inside of MyService.cs get renamed easily enough. That seems to work if I name the class

public class __MYSERVICE__
{...}

But it does not seem to rename the file, even if the file is named "__MYSERVICE__.cs"

Is there any way to do this?

As a bonus, it would be even better if I could default the serviceName parameter not to a hardcoded value, but to a derivative of sourceName. Like:

    "serviceName":
    {
        "type": "parameter",
        "defaultValue": $sourceName.substring(1, 5),
        "replaces": "__MYSERVICE__"
    }

Something like the above, which I just totally made up. But I trust you get the idea. I'd like the default value for serviceName to be derived from the value of sourceName.

Most helpful comment

@sstchur - you can setup a parameter with the attribute "FileRename", which will cause files containing the value of "FileRename" to have that value replaced with the provided parameter value. No replacement occurs if the parameter isn't given a value and doesn't have a default. For example:

  "symbols": {
    "RenameParam": {
      "type": "parameter",
      "datatype": "string",
      "description": "Renames files containing 'renameme'",
      "FileRename": "RenameMe",
      "defaultValue": "zzz"
    }
    ...
  }

then on the command line, if you invoke the template with --RenameParam NewName the string literal 'RenameMe' will be replaced with 'NewName' for all files in the template.

For your bonus question, we actually recently implemented something very similar, which should hopefully fit your needs. It's a new type of symbol called "derived". This allows setting up a symbol whose value is derived from the value of another symbol using what we call value forms. Check out this issue, which was the inspiration for derived symbols #1066 and this PR which implements it #1194. The issue discussion provides an example of setting up something like this, and the PR contains unit tests demonstrating its usage. Derived symbol types have pretty much the same options as parameters symbols (plus deriving their values).

This feature is not available in the 2.0.0 release of dotnet, but is available in the 2.1.0 preview branch of this repo.

Let me know if the usage is unclear, or if you need help with your specific use case.

All 17 comments

@sstchur - you can setup a parameter with the attribute "FileRename", which will cause files containing the value of "FileRename" to have that value replaced with the provided parameter value. No replacement occurs if the parameter isn't given a value and doesn't have a default. For example:

  "symbols": {
    "RenameParam": {
      "type": "parameter",
      "datatype": "string",
      "description": "Renames files containing 'renameme'",
      "FileRename": "RenameMe",
      "defaultValue": "zzz"
    }
    ...
  }

then on the command line, if you invoke the template with --RenameParam NewName the string literal 'RenameMe' will be replaced with 'NewName' for all files in the template.

For your bonus question, we actually recently implemented something very similar, which should hopefully fit your needs. It's a new type of symbol called "derived". This allows setting up a symbol whose value is derived from the value of another symbol using what we call value forms. Check out this issue, which was the inspiration for derived symbols #1066 and this PR which implements it #1194. The issue discussion provides an example of setting up something like this, and the PR contains unit tests demonstrating its usage. Derived symbol types have pretty much the same options as parameters symbols (plus deriving their values).

This feature is not available in the 2.0.0 release of dotnet, but is available in the 2.1.0 preview branch of this repo.

Let me know if the usage is unclear, or if you need help with your specific use case.

This works! Thanks. I haven't tried the preview version yet to get the derived symbols working, but the FileRename attribute worked like a charm.

I just noticed what seems to be a bug -- perhaps it should be logged as a separate issue if indeed it is a bug?

It seems that if you have multiple symbols with attribute "FileRename", only the first is honored and subsequent ones are ignored. For example:

    "appName":
    {
        "type": "parameter",
        "datatype": "string",
        "isRequired": true,
        "replaces": "SFAPP1",
        "fileRename": "SFAPP1"
    },

    "serviceName":
    {
        "type": "parameter",
        "datatype": "string",
        "isRequired": true,
        "replaces": "SFSERVICE1",
        "fileRename": "SFSERVICE1"
    }

In the above, only SFAPP1 will be replaced. SFSERVICE1 will remain untouched, even if when supplied via command line.

Also, if I flip the parameters so that SFSERVICE1 comes first, then only it will be replaced, and all occurrences of SFAPP1 will remain untouched.

Is this a bug? It feels like one to me.

I would agree that this is a bug... I'll look into it and get back to you.

@sstchur - I'm unable to repro this issue using dotnet 2.0.0 (or newer).

Using a template with the above [partial] configuration, if you run a command such as:
dotnet new <templateName> --appName foo --serviceName bar
then the expectation is that any filenames containing SFAPP1 will have that string replaced with foo and any filenames containing SFSERVICE1 will have that string replaced with bar.

The renames are case-sensitive (because case matters for filenames on some systems, notably *nix). But with the behavior you're seeing, that is unlikely to be the issue.

Is the problem with a file whose name contains both of the strings you're trying to replace? For example a file named SFAPP1_SFSERVICE1.txt? That situation I can repro.

So, I am in fact trying to rename something that contains both strings, and initially I thought that was the problem. However, my project/template has both: occurrences of SFSERVICE1 by itself that aren't getting renamed, AND occurrences of SFAPP1 along with SFSERVICE1 that aren't getting renamed.

If the problem were only when both strings occur together, shouldn't at least the occurrences of SFSERVICE1 alone be working? It works when I flip the order of parameters in my template.json file.

For example, my project has:

Folder: SFAPP1 // this gets renamed
Folder: SFAPP1.Services.SFSERVICE1 // only SFAPP1 gets renamed here
File: SFAPP1.sln // this get renamed

Under folder SFAPP1.Services.SFSERVICE1
File: SFAPP1.Services.SFSERVICE1.csproj // only SFAPP1 gets renamed here
File: SFSERVICE1.cs // does NOT get renamed

And then, there are many text occurrences of SFSERVICE1 inside of .cs and .json files that do not get renamed, but all occurrences of SFAPP1 do.

Not sure what I'm doing wrong? My parameters are defined in my template.json exactly as I pasted in my previous comment.

By the way: I am using version 2.0.0 according to dotnet new --version

@sstchur - you're absolutely correct, what you're describing should not be happening. Would you be able to zip up your template and send me a copy to help diagnose the problem? If there's proprietary IP or sensitive information, those parts could be removed.

My email is: [email protected]

Yes, I will contact you directly, thanks.

@sstchur - I've had a chance to look at your template, and have unfortunately discovered a couple bugs in the templating code, fixes should be coming in the next few days.

On a side note, I noticed that in ServiceEventSource.cs, there are some preprocessor directives. That is fine, but due to the way templating deals with conditionals vs. preprocessor directives, we require the conditions to be inside parentheses. They sometimes happen to work without parentheses, but this is not guaranteed to be supported - in fact, they don't work properly in 2.1.0-preview1. This should be a minor, painless change.

For the file content replacements, the only potential replacements I saw which didn't get properly replaced are due to casing differences. The file content replacements are case sensitive, so places where a file contained, for example "sfapp1", that string will not be replaced, because the string configured to be replaced is "SFAPP1". For 2.1.0-preview1, we introduced a mechanism where you can setup symbols so that different "forms" of the value being replaced can all be replaced, in additional to the literal "replaces" value (PR #867). In your case, we could set things up so that "SFAPP1" gets replaced by the literal input value of the appName parameter, and also have "sfapp1" be replaced by the lowercased version of the appName input value.

There is a bug in the code to handle this mechanism, but I have a PR which fixes the issue #1241

Setting up the lowercase replacements is configured on the parameters, like this:

        "appName":
        {
            "type": "parameter",
            "datatype": "string",
            "isRequired": true,
            "replaces": "SFAPP1",
            "fileRename": "SFAPP1",
            "forms": {
              "global": ["identity", "lowerCase"]
            }
        },

        "serviceName":
        {
            "type": "parameter",
            "datatype": "string",
            "isRequired": true,
            "replaces": "SFSERVICE1",
            "fileRename": "SFSERVICE1",
            "forms": {
              "global": ["identity", "lowerCase"]
            }
        }

The "forms" indicate the transformations to perform on both the "replaces" value and the user input values, the results of those transforms are used for the replacements. Using "serviceName" as an example, lets say I invoke the template with --serviceName MyService. There are 2 forms defined on service name, "identity", and "lowerCase".

  • The "identity" form leaves the values as-is, so the replaces value of "SFSERVICE1" will be replaced by the input value "MyService".
  • The "lowerCase" form lowercases both values, causing "sfservice1" to be replaced by "myservice".

There was also one place in your template with mixed-case in the source, specifically in HomeController.cs, the namespace beings with "SPApp1". If you want to retain that formatting, we'll need to come up with a custom value form for handling that situation... I'll need to give it more thought. But if that can be changed in your source to "SFAPP1", the file content replacements should be good to go - assuming you use 2.1.0-preview1 or newer, and PR #1241 is merged.

I still need to make a fix for the bug with multiple replacements in a single filename.

If there's other problems you're having which I didn't cover here, or if you have additional questions, please let me know.

Thanks @seancpeters
I think you have covered everything. The "SFApp1" mis-casing you mentioned is just a mistake and I can easily fix that. The lowercase was by design, but as you pointed out, you now have a mechanism for this, so I will try to use parameters as you've described for that.

Point take on the preprocessor directives -- that seems easy enough.

Seems to me that once that fixes are in, all my issues will have been addressed. Thanks!

PR #1245 takes care of your file rename problem / my file rename bug :)

@sstchur - The changes necessary for your template to work are now merged into dotnet:rel/2.1.0-preview1.

any way to have it do camelCasing/PascalCasing renames too? Instead of just lower/upper.

I have this in my template.json:

    "MainAggregate": {
      "type": "parameter",
      "datatype": "string",
      "description": "Main aggregate of the registry, adds an example aggregate, command handler, value objects, exceptions and controller.",
      "defaultValue": "",
      "replaces":"ExampleAggregate"
    },

I supply a value of User on the CLI but I end up with:

        public static User Register(UserId exampleAggregateId)
        {
            var exampleAggregate = Factory();
            exampleAggregate.ApplyChange(new UserWasBorn(exampleAggregateId));
            return exampleAggregate;
        }

Some are correctly replaced, but the camelcased ones aren't touched. I want them to turn into userId for example

Any suggestions if that is possible right now @seancpeters?

Ok, this is ugly, but it works....

    "MainAggregate": {
      "type": "parameter",
      "datatype": "string",
      "description": "Main aggregate of the registry, adds an example aggregate, command handler, value objects, exceptions and controller.",
      "defaultValue": "",
      "replaces":"ExampleAggregate",
      "fileRename": "ExampleAggregate"
    },
    "MainAggregateCamelCase": {
      "type": "generated",
      "generator": "regex",
      "dataType": "string",
      "replaces": "exampleAggregate",
      "parameters": {
        "source": "MainAggregate",
        "action": "replace",
        "steps": [
          { "regex": "^(A)([a-zA-Z]*)", "replacement": "a$2" },
          { "regex": "^(B)([a-zA-Z]*)", "replacement": "b$2" },
          { "regex": "^(C)([a-zA-Z]*)", "replacement": "c$2" },
          { "regex": "^(D)([a-zA-Z]*)", "replacement": "d$2" },
          { "regex": "^(E)([a-zA-Z]*)", "replacement": "e$2" },
          { "regex": "^(F)([a-zA-Z]*)", "replacement": "f$2" },
          { "regex": "^(G)([a-zA-Z]*)", "replacement": "g$2" },
          { "regex": "^(H)([a-zA-Z]*)", "replacement": "h$2" },
          { "regex": "^(I)([a-zA-Z]*)", "replacement": "i$2" },
          { "regex": "^(J)([a-zA-Z]*)", "replacement": "j$2" },
          { "regex": "^(K)([a-zA-Z]*)", "replacement": "k$2" },
          { "regex": "^(L)([a-zA-Z]*)", "replacement": "l$2" },
          { "regex": "^(M)([a-zA-Z]*)", "replacement": "m$2" },
          { "regex": "^(N)([a-zA-Z]*)", "replacement": "n$2" },
          { "regex": "^(O)([a-zA-Z]*)", "replacement": "o$2" },
          { "regex": "^(P)([a-zA-Z]*)", "replacement": "p$2" },
          { "regex": "^(Q)([a-zA-Z]*)", "replacement": "q$2" },
          { "regex": "^(R)([a-zA-Z]*)", "replacement": "r$2" },
          { "regex": "^(S)([a-zA-Z]*)", "replacement": "s$2" },
          { "regex": "^(T)([a-zA-Z]*)", "replacement": "t$2" },
          { "regex": "^(U)([a-zA-Z]*)", "replacement": "u$2" },
          { "regex": "^(V)([a-zA-Z]*)", "replacement": "v$2" },
          { "regex": "^(W)([a-zA-Z]*)", "replacement": "w$2" },
          { "regex": "^(X)([a-zA-Z]*)", "replacement": "x$2" },
          { "regex": "^(Y)([a-zA-Z]*)", "replacement": "y$2" },
          { "regex": "^(Z)([a-zA-Z]*)", "replacement": "z$2" }
        ]
      }
    },
    "MainAggregateCamelCaseField": {
      "type": "generated",
      "generator": "regex",
      "dataType": "string",
      "replaces": "_exampleAggregate",
      "parameters": {
        "source": "MainAggregate",
        "action": "replace",
        "steps": [
          { "regex": "^(A)([a-zA-Z]*)", "replacement": "_a$2" },
          { "regex": "^(B)([a-zA-Z]*)", "replacement": "_b$2" },
          { "regex": "^(C)([a-zA-Z]*)", "replacement": "_c$2" },
          { "regex": "^(D)([a-zA-Z]*)", "replacement": "_d$2" },
          { "regex": "^(E)([a-zA-Z]*)", "replacement": "_e$2" },
          { "regex": "^(F)([a-zA-Z]*)", "replacement": "_f$2" },
          { "regex": "^(G)([a-zA-Z]*)", "replacement": "_g$2" },
          { "regex": "^(H)([a-zA-Z]*)", "replacement": "_h$2" },
          { "regex": "^(I)([a-zA-Z]*)", "replacement": "_i$2" },
          { "regex": "^(J)([a-zA-Z]*)", "replacement": "_j$2" },
          { "regex": "^(K)([a-zA-Z]*)", "replacement": "_k$2" },
          { "regex": "^(L)([a-zA-Z]*)", "replacement": "_l$2" },
          { "regex": "^(M)([a-zA-Z]*)", "replacement": "_m$2" },
          { "regex": "^(N)([a-zA-Z]*)", "replacement": "_n$2" },
          { "regex": "^(O)([a-zA-Z]*)", "replacement": "_o$2" },
          { "regex": "^(P)([a-zA-Z]*)", "replacement": "_p$2" },
          { "regex": "^(Q)([a-zA-Z]*)", "replacement": "_q$2" },
          { "regex": "^(R)([a-zA-Z]*)", "replacement": "_r$2" },
          { "regex": "^(S)([a-zA-Z]*)", "replacement": "_s$2" },
          { "regex": "^(T)([a-zA-Z]*)", "replacement": "_t$2" },
          { "regex": "^(U)([a-zA-Z]*)", "replacement": "_u$2" },
          { "regex": "^(V)([a-zA-Z]*)", "replacement": "_v$2" },
          { "regex": "^(W)([a-zA-Z]*)", "replacement": "_w$2" },
          { "regex": "^(X)([a-zA-Z]*)", "replacement": "_x$2" },
          { "regex": "^(Y)([a-zA-Z]*)", "replacement": "_y$2" },
          { "regex": "^(Z)([a-zA-Z]*)", "replacement": "_z$2" }
        ]
      }
    },

All of this because .NET Regex doesn't allow \L$1$2

The "fileRename" option was exactly what I was looking for. I just want to note that it is not in the JSON Schema. You might want to add that.

I can only seem to get this working for parameter symbols, could it be extended for other symbol types as well? (Generated ones, particularly.)

How do you parse names with relative paths so that the class name will not be replaced with directory paths? For example, my template is ServiceNameService, then, when you run the command dotnet new --name "Services/Payroll", the file will be created as PayrollService.cs inside a folder named Services however the problem is that the class name also gets renamed to "Services/PayrollService":

template.json

{
    "$schema": "http://json.schemastore.org/template",
    "author": "Me",
    "classifications": [ "Common", "Code" ],
    "identity": "My.Templates.CSharp",
    "name": "MyTemplates",
    "shortName": "mytemplate",
    "tags": {
        "language": "C#",
        "type": "item"
    },
    "symbols": {
        "ServiceName": {
            "type": "parameter",
            "datatype": "text",
            "description": "Renames files containing 'ServiceName'",
            "fileRename": "ServiceName",
            "replaces": "ServiceName",
            "isRequired": true,
            "defaultValue": "MyService"
        }
    }
  }

Template:

public class ServiceNameService
{ 
}

Generated file:

L Project Folder
     L Services
           L PayrollService.cs

The actual class or content of the generated file:

public class Services/PayrollService
{ 
}

Expected:

public class PayrollService
{ 
}
Was this page helpful?
0 / 5 - 0 ratings

Related issues

RehanSaeed picture RehanSaeed  路  8Comments

neman picture neman  路  8Comments

divinebovine picture divinebovine  路  4Comments

DamianEdwards picture DamianEdwards  路  5Comments

tidyui picture tidyui  路  4Comments