Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@
<VersionMajor>$(_VersionBase.Split('.')[0])</VersionMajor>
<VersionMinor>$(_VersionBase.Split('.')[1])</VersionMinor>
<VersionMajorMinor>$(VersionMajor).$(VersionMinor)</VersionMajorMinor>
<_RspFilePath>$(IntermediateOutputPath)replace-text-args.rsp</_RspFilePath>
<_ReplaceTextScriptPath>$(RepoRoot)tools/scripts/replace-text.cs</_ReplaceTextScriptPath>
</PropertyGroup>

<ItemGroup>
Expand Down Expand Up @@ -121,22 +123,25 @@
<Replacements Include="$(OpenTelemetryInstrumentationRuntimeVersion)" />
</ItemGroup>

<PropertyGroup>
<SourcesFilesArgs>&quot;@(SourceFiles, '&quot; &quot;')&quot;</SourcesFilesArgs>
<ReplacementsArgs>&quot;@(Replacements, '&quot; &quot;')&quot;</ReplacementsArgs>
</PropertyGroup>

<!-- Build the .rsp file content as an item group -->
<ItemGroup>
<CommandArgs Include="dotnet" />
<CommandArgs Include="$(RepoRoot)tools/scripts/replace-text.cs" />
<CommandArgs Include="--files" />
<CommandArgs Include="$(SourcesFilesArgs)" />
<!-- Replacement tokens and their values -->
<CommandArgs Include="--replacements" />
<CommandArgs Include="$(ReplacementsArgs)" />
<_RspLines Include="--files" />
<_RspLines Include="@(SourceFiles)" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each line is treated as an argument, right? that's why we don't need to quote around them anymore?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's my understanding, @baronfel?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More accurately, each line in the response file is trimmed and then treated as an individual token.

So yes, but with more words and more nitpicky.

<_RspLines Include="--replacements" />
<_RspLines Include="@(Replacements)" />
</ItemGroup>

<Exec Command="@(CommandArgs, ' ')" WorkingDirectory="$(MSBuildThisFileDirectory)" StandardOutputImportance="Normal" StandardErrorImportance="Normal" />
<!-- Write the .rsp file -->
<WriteLinesToFile File="$(_RspFilePath)"
Lines="@(_RspLines)"
Overwrite="true"
WriteOnlyWhenDifferent="true" />

<!-- Execute the script with the .rsp file, using double dash to pass arguments directly to the script -->
<Exec Command="dotnet &quot;$(_ReplaceTextScriptPath)&quot; -- @&quot;$(_RspFilePath)&quot;"
WorkingDirectory="$(MSBuildThisFileDirectory)"
StandardOutputImportance="Normal"
StandardErrorImportance="Normal" />
</Target>

<!-- Grabs the contents of the templates folder and copies them to IntermediateOutputPath directory -->
Expand Down
306 changes: 139 additions & 167 deletions tools/scripts/replace-text.cs
Original file line number Diff line number Diff line change
@@ -1,211 +1,183 @@
#:package Microsoft.Extensions.FileSystemGlobbing
#:package System.CommandLine

using System.Collections.Concurrent;
using System.CommandLine;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.FileSystemGlobbing.Abstractions;

if (args.Length == 0 || args.Contains("--help") || args.Contains("-h"))
var filesOption = new Option<string[]>("--files")
{
PrintUsage();
return args.Length == 0 ? 1 : 0;
}
Description = "One or more file paths, directory paths, or glob patterns",
AllowMultipleArgumentsPerToken = true,
Required = true
};

// Parse arguments
var paths = new List<string>();
var replacements = new List<(string Find, string Replace)>();
var currentMode = (string?)null;
var replacementBuffer = new List<string>();

for (var i = 0; i < args.Length; i++)
var replacementsOption = new Option<string[]>("--replacements")
{
var arg = args[i];
Description = "Pairs of find/replace text values",
AllowMultipleArgumentsPerToken = true,
Required = true
};

if (arg == "--files")
{
currentMode = "files";
continue;
}
else if (arg == "--replacements")
{
currentMode = "replacements";
continue;
}

if (currentMode == "files")
{
paths.Add(arg);
}
else if (currentMode == "replacements")
{
replacementBuffer.Add(arg);
}
else
{
Console.Error.WriteLine($"Error: Unexpected argument '{arg}'. Use --files or --replacements to specify argument type.");
return 1;
}
}

// Validate paths
if (paths.Count == 0)
{
Console.Error.WriteLine("Error: No file paths provided. Use --files to specify paths.");
PrintUsage();
return 1;
}

// Validate and parse replacements
if (replacementBuffer.Count == 0)
var rootCommand = new RootCommand
{
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RootCommand should have a Description property to provide help text when users run the script with --help. Consider adding a description like "Replaces text in files using find/replace pairs" to make the tool more user-friendly.

Suggested change
{
{
Description = "Replaces text in files using find/replace pairs",

Copilot uses AI. Check for mistakes.
Console.Error.WriteLine("Error: No replacements provided. Use --replacements to specify find/replace pairs.");
PrintUsage();
return 1;
}
filesOption,
replacementsOption
};

if (replacementBuffer.Count % 2 != 0)
rootCommand.SetAction(result =>
{
Console.Error.WriteLine($"Error: Replacement arguments must be provided in pairs (find, replace).");
Console.Error.WriteLine($" Received {replacementBuffer.Count} arguments after --replacements.");
return 1;
}

for (var i = 0; i < replacementBuffer.Count; i += 2)
{
var find = replacementBuffer[i];
var replace = replacementBuffer[i + 1];

if (string.IsNullOrEmpty(find))
try
{
Console.Error.WriteLine($"Error: Find text at position {i + 1} in --replacements cannot be empty.");
return 1;
}
var paths = result.GetValue<string[]>(filesOption) ?? Array.Empty<string>();
var replacementArgs = result.GetValue<string[]>(replacementsOption) ?? Array.Empty<string>();

replacements.Add((find, replace));
}
// Validate and parse replacements
if (replacementArgs.Length == 0)
{
Console.Error.WriteLine("Error: No replacements provided. Use --replacements to specify find/replace pairs.");
return 1;
}

Console.WriteLine($"Paths: {paths.Count}");
foreach (var path in paths)
{
Console.WriteLine($" '{path}'");
}
Console.WriteLine($"Replacements: {replacements.Count}");
foreach (var (find, replace) in replacements)
{
Console.WriteLine($" '{find}' -> '{replace}'");
}
Console.WriteLine();
if (replacementArgs.Length % 2 != 0)
{
Console.Error.WriteLine($"Error: Replacement arguments must be provided in pairs (find, replace).");
Console.Error.WriteLine($" Received {replacementArgs.Length} arguments after --replacements.");
return 1;
}

var filesToProcess = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var replacements = new List<(string Find, string Replace)>();
for (var i = 0; i < replacementArgs.Length; i += 2)
{
var find = replacementArgs[i];
var replace = replacementArgs[i + 1];

var matcher = new Matcher();
var hasGlobPatterns = false;
if (string.IsNullOrEmpty(find))
{
Console.Error.WriteLine($"Error: Find text at position {i + 1} in --replacements cannot be empty.");
return 1;
}

foreach (var pathValue in paths)
{
if (File.Exists(pathValue))
{
// If it's a direct file path add it as is
filesToProcess.Add(Path.GetFullPath(pathValue));
}
else if (Directory.Exists(pathValue))
replacements.Add((find, replace));
}

Console.WriteLine($"Paths: {paths.Length}");
foreach (var path in paths)
{
// If it's a directory, include all files within it
matcher.AddInclude(Path.Combine(pathValue, "**/*"));
hasGlobPatterns = true;
Console.WriteLine($" '{path}'");
}
else if (pathValue.Contains('*') || pathValue.Contains('?'))
Console.WriteLine($"Replacements: {replacements.Count}");
foreach (var (find, replace) in replacements)
{
matcher.AddInclude(pathValue);
hasGlobPatterns = true;
Console.WriteLine($" '{find}' -> '{replace}'");
}
}
Console.WriteLine();

var currentDirectory = Directory.GetCurrentDirectory();
var filesToProcess = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

if (hasGlobPatterns)
{
// Collect files from glob matching
var directoryInfo = new DirectoryInfoWrapper(new DirectoryInfo(currentDirectory));
var matchResult = matcher.Execute(directoryInfo);
var matcher = new Matcher();
var hasGlobPatterns = false;

foreach (var file in matchResult.Files)
foreach (var pathValue in paths)
{
filesToProcess.Add(Path.GetFullPath(Path.Combine(currentDirectory, file.Path)));
if (File.Exists(pathValue))
{
// If it's a direct file path add it as is
filesToProcess.Add(Path.GetFullPath(pathValue));
}
else if (Directory.Exists(pathValue))
{
// If it's a directory, include all files within it
matcher.AddInclude(Path.Combine(pathValue, "**/*"));
hasGlobPatterns = true;
}
else if (pathValue.Contains('*') || pathValue.Contains('?'))
{
matcher.AddInclude(pathValue);
hasGlobPatterns = true;
}
}
}

if (filesToProcess.Count == 0)
{
Console.WriteLine("No files matched the provided paths.");
return 0;
}

Console.WriteLine($"Found {filesToProcess.Count} file(s) matching the provided paths.");
Console.WriteLine();
var currentDirectory = Directory.GetCurrentDirectory();

var processedCount = 0;
var modifiedCount = 0;
var errorCount = 0;
var errors = new ConcurrentBag<(string File, string Error)>();

Parallel.ForEach(filesToProcess, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, filePath =>
{
try
if (hasGlobPatterns)
{
var content = File.ReadAllText(filePath);
var originalContent = content;
// Collect files from glob matching
var directoryInfo = new DirectoryInfoWrapper(new DirectoryInfo(currentDirectory));
var matchResult = matcher.Execute(directoryInfo);

foreach (var (find, replace) in replacements)
foreach (var file in matchResult.Files)
{
content = content.Replace(find, replace, StringComparison.Ordinal);
filesToProcess.Add(Path.GetFullPath(Path.Combine(currentDirectory, file.Path)));
}
}

if (!string.Equals(content, originalContent, StringComparison.Ordinal))
if (filesToProcess.Count == 0)
{
File.WriteAllText(filePath, content);
Interlocked.Increment(ref modifiedCount);
Console.WriteLine($"Modified: {Path.GetRelativePath(currentDirectory, filePath)}");
Console.WriteLine("No files matched the provided paths.");
return 0;
}

Interlocked.Increment(ref processedCount);
Console.WriteLine($"Found {filesToProcess.Count} file(s) matching the provided paths.");
Console.WriteLine();

var processedCount = 0;
var modifiedCount = 0;
var errorCount = 0;
var errors = new ConcurrentBag<(string File, string Error)>();

Parallel.ForEach(filesToProcess, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, filePath =>
{
try
{
var content = File.ReadAllText(filePath);
var originalContent = content;

foreach (var (find, replace) in replacements)
{
content = content.Replace(find, replace, StringComparison.Ordinal);
}

if (!string.Equals(content, originalContent, StringComparison.Ordinal))
{
File.WriteAllText(filePath, content);
Interlocked.Increment(ref modifiedCount);
Console.WriteLine($"Modified: {Path.GetRelativePath(currentDirectory, filePath)}");
}

Interlocked.Increment(ref processedCount);
}
catch (Exception ex)
{
Interlocked.Increment(ref errorCount);
errors.Add((filePath, ex.Message));
}
});

Console.WriteLine();
Console.WriteLine($"Processed: {processedCount} file(s)");
Console.WriteLine($"Modified: {modifiedCount} file(s)");

if (errorCount > 0)
{
Console.WriteLine($"Errors: {errorCount} file(s)");
Console.WriteLine();
Console.Error.WriteLine("Errors encountered:");
foreach (var (file, error) in errors)
{
Console.Error.WriteLine($" {Path.GetRelativePath(currentDirectory, file)}: {error}");
}
return 1;
}

return 0;
}
catch (Exception ex)
{
Interlocked.Increment(ref errorCount);
errors.Add((filePath, ex.Message));
Console.Error.WriteLine($"Unexpected error: {ex.Message}");
return 1;
}
});

Console.WriteLine();
Console.WriteLine($"Processed: {processedCount} file(s)");
Console.WriteLine($"Modified: {modifiedCount} file(s)");

if (errorCount > 0)
{
Console.WriteLine($"Errors: {errorCount} file(s)");
Console.WriteLine();
Console.Error.WriteLine("Errors encountered:");
foreach (var (file, error) in errors)
{
Console.Error.WriteLine($" {Path.GetRelativePath(currentDirectory, file)}: {error}");
}
return 1;
}

return 0;

static void PrintUsage()
{
Console.Error.WriteLine("""
Usage: dotnet replace-text.cs --files <path1> [path2] ... --replacements <find1> <replace1> [<find2> <replace2> ...]
Arguments:
--files One or more file paths, directory paths, or glob patterns
--replacements Pairs of find/replace text values
Examples:
dotnet replace-text.cs --files "./src/**/*.cs" "./src/**/*.csproj" --replacements "!!VERSION!!" "7.0.5" "!!MAJOR_MINOR!!" "7.0"
dotnet replace-text.cs --files ./path/to/file.cs --replacements "oldText" "newText"
""");
}

return rootCommand.Parse(args).Invoke();
Loading