// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Scaffolding;
/// <summary>
/// Service for scaffolding new AppHost projects.
/// Handles both C# (via templates) and guest languages (via RPC).
/// </summary>
internal sealed class ScaffoldingService : IScaffoldingService
{
private const string PackageJsonFileName = "package.json";
private const string VsCodeSettingsFileName = ".vscode/settings.json";
private const string JavaScriptHostingPackageName = "Aspire.Hosting.JavaScript";
internal const string BrownfieldTypeScriptAppHostDirectoryName = "aspire-apphost";
private static readonly JsonSerializerOptions s_scaffoldJsonSerializerOptions = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
IndentSize = 2
};
private static readonly JsonDocumentOptions s_scaffoldJsonDocumentOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
private readonly IAppHostServerProjectFactory _appHostServerProjectFactory;
private readonly IAppHostServerSessionFactory _serverSessionFactory;
private readonly ILanguageDiscovery _languageDiscovery;
private readonly IInteractionService _interactionService;
private readonly IEnvironment _environment;
private readonly ILogger<ScaffoldingService> _logger;
private readonly CliExecutionContext _executionContext;
private readonly ProfilingTelemetry _profilingTelemetry;
public ScaffoldingService(
IAppHostServerProjectFactory appHostServerProjectFactory,
IAppHostServerSessionFactory serverSessionFactory,
ILanguageDiscovery languageDiscovery,
IInteractionService interactionService,
IEnvironment environment,
ILogger<ScaffoldingService> logger,
CliExecutionContext executionContext,
ProfilingTelemetry profilingTelemetry)
{
_appHostServerProjectFactory = appHostServerProjectFactory;
_serverSessionFactory = serverSessionFactory;
_languageDiscovery = languageDiscovery;
_interactionService = interactionService;
_environment = environment;
_logger = logger;
_executionContext = executionContext;
_profilingTelemetry = profilingTelemetry;
}
/// <inheritdoc />
public async Task<bool> ScaffoldAsync(ScaffoldContext context, CancellationToken cancellationToken)
{
if (context.Language.LanguageId.Value.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException("C# projects should be created using the template system via NewCommand.");
}
return await ScaffoldGuestLanguageAsync(context, cancellationToken);
}
private async Task<bool> ScaffoldGuestLanguageAsync(ScaffoldContext context, CancellationToken cancellationToken)
{
var directory = context.TargetDirectory;
var language = context.Language;
var scaffoldDirectory = GetScaffoldDirectory(directory, language);
// Step 1: Resolve SDK and package strategy
var sdkVersion = string.IsNullOrWhiteSpace(context.SdkVersion)
? _executionContext.IdentitySdkVersion
: context.SdkVersion;
var config = AspireConfigFile.LoadOrCreate(directory.FullName, sdkVersion);
if (!string.IsNullOrWhiteSpace(context.SdkVersion))
{
config.SdkVersion = context.SdkVersion;
}
// Persist the channel only when the caller explicitly resolved one. Callers must validate
// the channel against the registered `IPackagingService` channels and only pass an Explicit
// channel name. Today that means either:
// - an explicit `--channel` flag,
// - NewCommand's identity-match against a registered Explicit channel (see
// `CliTemplateFactory.EmptyTemplate.cs` for how `ScaffoldContext.Channel` is sourced),
// - InitCommand's polyglot path resolving `CliExecutionContext.IdentityChannel` through
// `IPackagingService.GetChannelsAsync` (see `InitCommand.ResolvePersistableChannelNameAsync`).
// Do NOT fall back to a raw `CliExecutionContext.IdentityChannel`: an identity that isn't a
// registered channel (e.g. `staging` on a CLI without the staging feature flag, or `pr-<N>`
// on a machine without the matching hive) would otherwise pin a channel name that no
// PSM rule can satisfy. When unset, `PrebuiltAppHostServer` aggregates sources from
// every registered channel so `aspire add` / `aspire restore` still find the right
// packages without a per-project pin.
if (!string.IsNullOrEmpty(context.Channel))
{
config.Channel = context.Channel;
}
PreAddJavaScriptHostingForBrownfieldTypeScript(config, directory, language, sdkVersion);
if (!string.IsNullOrWhiteSpace(context.SdkVersion) ||
!string.IsNullOrEmpty(context.Channel))
{
config.Save(directory.FullName);
}
// Include the code generation package for scaffolding and code gen
var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(language.LanguageId, cancellationToken);
var integrations = config.GetIntegrationReferences(sdkVersion, directory.FullName).ToList();
if (codeGenPackage is not null)
{
var codeGenVersion = config.GetEffectiveSdkVersion(sdkVersion);
integrations.Add(IntegrationReference.FromPackage(codeGenPackage, codeGenVersion));
}
Directory.CreateDirectory(scaffoldDirectory.FullName);
var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(scaffoldDirectory.FullName, cancellationToken);
var prepareSdkVersion = config.GetEffectiveSdkVersion(sdkVersion);
var prepareResult = await _interactionService.ShowStatusAsync(
"Preparing Aspire server...",
() => appHostServerProject.PrepareAsync(prepareSdkVersion, integrations, requestedChannel: context.Channel, packageSourceOverride: context.PackageSourceOverride, cancellationToken: cancellationToken),
emoji: KnownEmojis.Gear);
if (!prepareResult.Success)
{
if (prepareResult.Output is not null)
{
_interactionService.DisplayLines(prepareResult.Output.GetLines());
}
_interactionService.DisplayError("Failed to build AppHost server.");
return false;
}
// Step 2: Start the server temporarily for scaffolding and code generation
await using var serverSession = _serverSessionFactory.Create(appHostServerProject, environmentVariables: null, debug: false, gracefulShutdownSignaler: null, shutdownService: null, isolateConsole: false, cancellationToken);
// Short-lived RPC session: StartAsync() spawns the server. We never observe the
// exit-code task (WaitForExitAsync) because disposal flows the exit code through the
// activity scope and the only failure mode we care about surfaces via the RPC call below.
await serverSession.StartAsync();
// Step 3: Connect to server and get scaffold templates via RPC
var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken);
var scaffoldFiles = await rpcClient.ScaffoldAppHostAsync(
language.LanguageId,
scaffoldDirectory.FullName,
context.ProjectName,
cancellationToken);
var appHostRelativePath = GetScaffoldedAppHostRelativePath(directory, scaffoldDirectory, language, scaffoldFiles.Keys);
var conflictingFiles = GetConflictingScaffoldFiles(scaffoldDirectory.FullName, scaffoldFiles.Keys);
if (conflictingFiles.Count > 0)
{
_logger.LogWarning(
"Scaffolding in '{Directory}' would overwrite existing files: {Files}",
scaffoldDirectory.FullName,
string.Join(", ", conflictingFiles));
_interactionService.DisplayError(TemplatingStrings.ProjectAlreadyExists);
return false;
}
// Step 4: Write scaffold files to disk, merging package.json, .gitignore, and VS Code
// settings when they already exist.
foreach (var (fileName, content) in scaffoldFiles)
{
var filePath = Path.Combine(scaffoldDirectory.FullName, fileName);
var fileDirectory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(fileDirectory))
{
Directory.CreateDirectory(fileDirectory);
}
var contentToWrite = content;
if (fileName.Equals(PackageJsonFileName, StringComparison.OrdinalIgnoreCase) && File.Exists(filePath))
{
var existingContent = await File.ReadAllTextAsync(filePath, cancellationToken);
contentToWrite = PackageJsonMerger.Merge(
existingContent,
content,
_logger,
toolchainCommand: GetPackageManagerCommand(scaffoldDirectory, language));
}
else if (IsGitIgnoreFile(fileName) && File.Exists(filePath))
{
var existingContent = await File.ReadAllTextAsync(filePath, cancellationToken);
contentToWrite = MergeGitIgnoreContent(existingContent, content);
}
else if (IsVsCodeSettingsFile(fileName) && File.Exists(filePath))
{
var existingContent = await File.ReadAllTextAsync(filePath, cancellationToken);
contentToWrite = MergeVsCodeSettingsContent(existingContent, content, _logger);
}
await File.WriteAllTextAsync(filePath, contentToWrite, cancellationToken);
}
_logger.LogDebug("Wrote {Count} scaffold files", scaffoldFiles.Count);
if (IsNestedBrownfieldTypeScriptAppHost(directory, scaffoldDirectory, language))
{
await AddRootTypeScriptAppHostScriptsAsync(directory, scaffoldDirectory, cancellationToken);
}
// Step 5: Generate SDK code via RPC (must happen before dependency installation
// because pylock.toml/requirements.txt reference the generated code directory)
await GenerateCodeViaRpcAsync(
scaffoldDirectory.FullName,
rpcClient,
language,
cancellationToken);
// Step 6: Install dependencies using GuestRuntime
var installResult = await _interactionService.ShowStatusAsync(
$"Installing {language.DisplayName} dependencies...",
() => InstallDependenciesAsync(scaffoldDirectory, language, rpcClient, cancellationToken),
emoji: KnownEmojis.Package);
if (installResult != 0)
{
return false;
}
// Save channel and language to aspire.config.json (new format)
// Read profiles from apphost.run.json (created by codegen) and merge into aspire.config.json
var appHostRunPath = Path.Combine(scaffoldDirectory.FullName, "apphost.run.json");
var profiles = AspireConfigFile.ReadApphostRunProfiles(appHostRunPath, _logger);
if (profiles is not null && File.Exists(appHostRunPath))
{
try
{
// Delete apphost.run.json since profiles are now in aspire.config.json
File.Delete(appHostRunPath);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to delete apphost.run.json after reading profiles");
}
}
config.Profiles = profiles;
config.AppHost ??= new AspireConfigAppHost();
config.AppHost.Path ??= appHostRelativePath;
config.AppHost.Language = language.LanguageId;
config.Save(directory.FullName);
return true;
}
internal static DirectoryInfo GetScaffoldDirectory(DirectoryInfo directory, LanguageInfo language)
{
if (IsTypeScriptLanguage(language) && File.Exists(Path.Combine(directory.FullName, PackageJsonFileName)))
{
// Brownfield JS/TS apps already have package-level module, script, lint, and engine semantics.
// Keep the Aspire AppHost in its own package boundary so scaffolding cannot change how the app runs.
return new DirectoryInfo(Path.Combine(directory.FullName, BrownfieldTypeScriptAppHostDirectoryName));
}
return directory;
}
internal static string GetAppHostPath(DirectoryInfo directory, LanguageInfo language)
{
var scaffoldDirectory = GetScaffoldDirectory(directory, language);
var appHostFileName = language.AppHostFileName ?? throw new NotSupportedException($"AppHost file not defined for language: {language.LanguageId}");
return Path.Combine(scaffoldDirectory.FullName, appHostFileName);
}
private static bool IsNestedBrownfieldTypeScriptAppHost(DirectoryInfo rootDirectory, DirectoryInfo scaffoldDirectory, LanguageInfo language)
=> IsTypeScriptLanguage(language) &&
!string.Equals(
rootDirectory.FullName.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
scaffoldDirectory.FullName.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
StringComparison.Ordinal);
private async Task AddRootTypeScriptAppHostScriptsAsync(DirectoryInfo rootDirectory, DirectoryInfo appHostDirectory, CancellationToken cancellationToken)
{
var packageJsonPath = Path.Combine(rootDirectory.FullName, PackageJsonFileName);
var existingContent = await File.ReadAllTextAsync(packageJsonPath, cancellationToken);
JsonObject packageJson;
try
{
packageJson = JsonNode.Parse(existingContent, documentOptions: s_scaffoldJsonDocumentOptions) as JsonObject
?? throw new JsonException("The root package.json is not a JSON object.");
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse root package.json at '{PackageJsonPath}'.", packageJsonPath);
_interactionService.DisplayError($"Failed to parse root package.json: {ex.Message}");
throw;
}
var scripts = EnsureJsonObject(packageJson, "scripts");
var relativeAppHostDirectory = PathNormalizer.NormalizePathForStorage(Path.GetRelativePath(rootDirectory.FullName, appHostDirectory.FullName));
var preservedScriptNames = AddRootTypeScriptAppHostDelegateScripts(scripts, appHostDirectory, relativeAppHostDirectory, _environment, _logger);
if (preservedScriptNames.Count > 0)
{
_interactionService.DisplayMessage(
KnownEmojis.Warning,
$"Preserved existing package.json script(s) {string.Join(", ", preservedScriptNames)}. Run the AppHost directly from {relativeAppHostDirectory} or remove the existing script(s) and rerun 'aspire init' to regenerate the root delegates.");
}
var serializedPackageJson = SerializePackageJson(packageJson, existingContent);
await File.WriteAllTextAsync(packageJsonPath, serializedPackageJson, cancellationToken);
}
internal static IReadOnlyList<string> AddRootTypeScriptAppHostDelegateScripts(JsonObject scripts, TypeScriptAppHostToolchain toolchain, string relativeAppHostDirectory)
{
List<string>? preservedScriptNames = null;
AddRootTypeScriptAppHostDelegateScript(scripts, toolchain, relativeAppHostDirectory, "aspire:start", ref preservedScriptNames);
AddRootTypeScriptAppHostDelegateScript(scripts, toolchain, relativeAppHostDirectory, "aspire:build", ref preservedScriptNames);
AddRootTypeScriptAppHostDelegateScript(scripts, toolchain, relativeAppHostDirectory, "aspire:dev", ref preservedScriptNames);
return preservedScriptNames ?? [];
}
internal static IReadOnlyList<string> AddRootTypeScriptAppHostDelegateScripts(JsonObject scripts, DirectoryInfo appHostDirectory, string relativeAppHostDirectory, IEnvironment environment, ILogger? logger)
{
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, environment, logger);
return AddRootTypeScriptAppHostDelegateScripts(scripts, toolchain, relativeAppHostDirectory);
}
internal static string SerializePackageJson(JsonObject packageJson, string existingContent)
{
var serializedPackageJson = packageJson.ToJsonString(s_scaffoldJsonSerializerOptions);
var trailingNewLine = existingContent.EndsWith("\r\n", StringComparison.Ordinal)
? "\r\n"
: existingContent.EndsWith('\n') ? "\n" : null;
if (trailingNewLine is not null)
{
serializedPackageJson += trailingNewLine;
}
return serializedPackageJson;
}
internal static string GetScaffoldedAppHostRelativePath(
DirectoryInfo rootDirectory,
DirectoryInfo scaffoldDirectory,
LanguageInfo language,
IEnumerable<string> scaffoldFileNames)
{
var appHostFileName = scaffoldFileNames.FirstOrDefault(fileName =>
language.MatchesFile(Path.GetFileName(fileName)));
appHostFileName ??= language.AppHostFileName ?? throw new NotSupportedException($"AppHost file not defined for language: {language.LanguageId}");
return PathNormalizer.NormalizePathForStorage(
Path.GetRelativePath(rootDirectory.FullName, Path.Combine(scaffoldDirectory.FullName, appHostFileName)));
}
private static JsonObject EnsureJsonObject(JsonObject parent, string propertyName)
{
if (parent[propertyName] is JsonObject obj)
{
return obj;
}
obj = new JsonObject();
parent[propertyName] = obj;
return obj;
}
private static string CreateRootDelegateScript(TypeScriptAppHostToolchain toolchain, string relativeAppHostDirectory, string scriptName)
{
return toolchain switch
{
TypeScriptAppHostToolchain.Npm => $"npm --prefix {relativeAppHostDirectory} run {scriptName}",
TypeScriptAppHostToolchain.Pnpm => $"pnpm --dir {relativeAppHostDirectory} run {scriptName}",
TypeScriptAppHostToolchain.Yarn => $"yarn --cwd {relativeAppHostDirectory} run {scriptName}",
TypeScriptAppHostToolchain.Bun => $"bun --cwd {relativeAppHostDirectory} run {scriptName}",
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
}
private static void AddRootTypeScriptAppHostDelegateScript(JsonObject scripts, TypeScriptAppHostToolchain toolchain, string relativeAppHostDirectory, string scriptName, ref List<string>? preservedScriptNames)
{
var delegateScript = CreateRootDelegateScript(toolchain, relativeAppHostDirectory, scriptName);
if (scripts[scriptName] is JsonValue existingScriptValue &&
existingScriptValue.TryGetValue<string>(out var existingScript) &&
string.Equals(existingScript, delegateScript, StringComparison.Ordinal))
{
return;
}
if (scripts[scriptName] is not null)
{
preservedScriptNames ??= [];
preservedScriptNames.Add(scriptName);
return;
}
scripts[scriptName] = delegateScript;
}
private async Task<int> InstallDependenciesAsync(
DirectoryInfo directory,
LanguageInfo language,
IAppHostRpcClient rpcClient,
CancellationToken cancellationToken)
{
var runtimeSpec = await rpcClient.GetRuntimeSpecAsync(language.LanguageId.Value, cancellationToken);
if (TypeScriptAppHostToolchainResolver.IsTypeScriptLanguage(language))
{
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(directory, _environment, _logger);
runtimeSpec = TypeScriptAppHostToolchainResolver.ApplyToRuntimeSpec(runtimeSpec, toolchain);
}
var runtime = new GuestRuntime(runtimeSpec, _logger, PathLookupHelper.FindFullPathFromPath, _environment, _profilingTelemetry);
var (initResult, initOutput) = await runtime.InitializeAsync(directory, cancellationToken);
if (initResult != 0)
{
var lines = initOutput.GetLines().ToArray();
if (lines.Length > 0)
{
_interactionService.DisplayLines(lines);
}
else
{
_interactionService.DisplayError($"Failed to initialize {language.DisplayName} environment.");
}
return initResult;
}
var (result, output) = await runtime.InstallDependenciesAsync(directory, cancellationToken);
if (result != 0)
{
var lines = output.GetLines().ToArray();
if (MissingJavaScriptToolWarning.IsMatch(lines))
{
if (lines.Length > 0)
{
_interactionService.DisplayLines(lines);
}
_interactionService.DisplayMessage(
KnownEmojis.Warning,
MissingJavaScriptToolWarning.GetMessage(directory, language, _environment));
return 0;
}
if (lines.Length > 0)
{
_interactionService.DisplayLines(lines);
}
else
{
_interactionService.DisplayError($"Failed to install {language.DisplayName} dependencies.");
}
}
return result;
}
private async Task GenerateCodeViaRpcAsync(
string directoryPath,
IAppHostRpcClient rpcClient,
LanguageInfo language,
CancellationToken cancellationToken)
{
var generatedFiles = await rpcClient.GenerateCodeAsync(language.CodeGenerator, cancellationToken);
// Write generated files to the output directory
var outputPath = Path.Combine(directoryPath, LanguageInfo.GeneratedFolderName);
Directory.CreateDirectory(outputPath);
foreach (var (fileName, content) in generatedFiles)
{
var filePath = Path.Combine(outputPath, fileName);
var fileDirectory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(fileDirectory))
{
Directory.CreateDirectory(fileDirectory);
}
await File.WriteAllTextAsync(filePath, content, cancellationToken);
}
_logger.LogDebug("Generated {Count} code files in {Path}", generatedFiles.Count, outputPath);
}
private static void PreAddJavaScriptHostingForBrownfieldTypeScript(
AspireConfigFile config,
DirectoryInfo directory,
LanguageInfo language,
string defaultSdkVersion)
{
if (!IsTypeScriptLanguage(language) ||
!File.Exists(Path.Combine(directory.FullName, PackageJsonFileName)) ||
config.Packages?.ContainsKey(JavaScriptHostingPackageName) == true)
{
return;
}
config.AddOrUpdatePackage(JavaScriptHostingPackageName, config.GetEffectiveSdkVersion(defaultSdkVersion));
}
private static bool IsTypeScriptLanguage(LanguageInfo language)
{
return language.LanguageId.Value.Equals(KnownLanguageId.TypeScript, StringComparison.OrdinalIgnoreCase) ||
language.LanguageId.Value.Equals(KnownLanguageId.TypeScriptAlias, StringComparison.OrdinalIgnoreCase);
}
private string GetPackageManagerCommand(DirectoryInfo directory, LanguageInfo language)
{
if (!TypeScriptAppHostToolchainResolver.IsTypeScriptLanguage(language))
{
return "npm";
}
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(directory, _environment, _logger);
return TypeScriptAppHostToolchainResolver.GetCommandName(toolchain);
}
internal static IReadOnlyList<string> GetConflictingScaffoldFiles(string rootDirectory, IEnumerable<string> scaffoldFileNames)
{
ArgumentException.ThrowIfNullOrEmpty(rootDirectory);
ArgumentNullException.ThrowIfNull(scaffoldFileNames);
var conflicts = new List<string>();
foreach (var fileName in scaffoldFileNames)
{
if (IsGitIgnoreFile(fileName) || IsPackageJsonFile(fileName) || IsVsCodeSettingsFile(fileName))
{
continue;
}
var filePath = Path.Combine(rootDirectory, fileName);
if (File.Exists(filePath) || Directory.Exists(filePath))
{
conflicts.Add(fileName);
}
}
return conflicts;
}
internal static string MergeGitIgnoreContent(string existingContent, string scaffoldContent)
{
ArgumentNullException.ThrowIfNull(existingContent);
ArgumentNullException.ThrowIfNull(scaffoldContent);
if (string.IsNullOrEmpty(existingContent))
{
return scaffoldContent;
}
var existingEntries = ReadGitIgnoreEntries(existingContent).ToHashSet(StringComparer.Ordinal);
var existingNormalized = existingEntries
.Select(NormalizeGitIgnoreEntry)
.ToHashSet(StringComparer.Ordinal);
var missingEntries = ReadGitIgnoreEntries(scaffoldContent)
.Where(entry => !existingEntries.Contains(entry)
&& !existingNormalized.Contains(NormalizeGitIgnoreEntry(entry)))
.ToArray();
if (missingEntries.Length == 0)
{
return existingContent;
}
var newline = existingContent.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
var mergedContent = existingContent;
if (!mergedContent.EndsWith("\n", StringComparison.Ordinal))
{
mergedContent += newline;
}
return mergedContent + string.Join(newline, missingEntries) + newline;
}
private static bool IsGitIgnoreFile(string fileName)
=> Path.GetFileName(fileName).Equals(".gitignore", StringComparison.Ordinal);
private static bool IsPackageJsonFile(string fileName)
=> Path.GetFileName(fileName).Equals(PackageJsonFileName, StringComparison.OrdinalIgnoreCase);
private static bool IsVsCodeSettingsFile(string fileName)
{
// The scaffold keys are wire values that always use forward slashes.
var normalized = fileName.Replace('\\', '/');
return normalized.Equals(VsCodeSettingsFileName, StringComparison.OrdinalIgnoreCase)
|| normalized.EndsWith("/" + VsCodeSettingsFileName, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Merges the scaffolded VS Code workspace settings into the developer's existing ones.
/// </summary>
/// <remarks>
/// Settings the developer already chose win, because they configured their editor deliberately
/// and a scaffold has no standing to change that. Array-valued settings are unioned rather than
/// replaced: <c>java.project.sourcePaths</c> is how the generated SDK under <c>.aspire/modules</c>
/// becomes resolvable, and replacing it would break whatever source roots the project already
/// declared.
/// <para>
/// The existing file is returned unchanged when it already covers everything, so re-running init
/// preserves comments and formatting. When something has to be added the file is reserialized and
/// comments are lost; that is the same tradeoff the package.json merge makes, and VS Code writes
/// this file itself the same way.
/// </para>
/// <para>
/// An existing file that cannot be parsed is returned unchanged. A settings.json broken mid-edit,
/// or using a JSONC construct this parser does not accept, still represents editor configuration
/// the developer accumulated, and silently replacing it with the scaffold's three settings is a
/// far worse outcome than skipping the merge. The caller reports the skip.
/// </para>
/// </remarks>
/// <param name="existingContent">Content already on disk.</param>
/// <param name="scaffoldContent">Content the scaffold wants to contribute.</param>
/// <param name="logger">Receives a warning when the existing file could not be parsed.</param>
internal static string MergeVsCodeSettingsContent(string existingContent, string scaffoldContent, ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(existingContent);
ArgumentNullException.ThrowIfNull(scaffoldContent);
if (ParseJsonC(scaffoldContent) is not { } scaffold)
{
return scaffoldContent;
}
// VS Code settings are JSONC: its own generated file opens with a "// Place your settings"
// comment, and hand-edited ones routinely end with a trailing comma.
// https://code.visualstudio.com/docs/languages/json#_json-with-comments
if (ParseJsonC(existingContent) is not { } existing)
{
logger?.LogWarning(
"The existing VS Code settings file could not be parsed, so Aspire's settings were not merged into it. " +
"Fix the JSON and re-run the command, or add the settings by hand.");
return existingContent;
}
var changed = false;
foreach (var (key, scaffoldValue) in scaffold)
{
if (existing[key] is not { } existingValue)
{
existing[key] = scaffoldValue?.DeepClone();
changed = true;
continue;
}
if (existingValue is not JsonArray existingArray || scaffoldValue is not JsonArray scaffoldArray)
{
continue;
}
foreach (var entry in scaffoldArray)
{
if (!existingArray.Any(present => JsonNode.DeepEquals(present, entry)))
{
existingArray.Add(entry?.DeepClone());
changed = true;
}
}
}
return changed
? existing.ToJsonString(s_scaffoldJsonSerializerOptions)
: existingContent;
}
private static JsonObject? ParseJsonC(string content)
{
try
{
return JsonNode.Parse(content, documentOptions: s_scaffoldJsonDocumentOptions) as JsonObject;
}
catch (JsonException)
{
return null;
}
}
private static IEnumerable<string> ReadGitIgnoreEntries(string content)
{
using var reader = new StringReader(content);
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (!string.IsNullOrWhiteSpace(line))
{
yield return line.TrimEnd();
}
}
}
// Normalizes a .gitignore entry so rooted (`/foo/`) and unrooted (`foo/`) forms
// are treated as equivalent when deciding whether to append a scaffold entry.
private static string NormalizeGitIgnoreEntry(string entry)
=> entry.StartsWith('/') ? entry[1..] : entry;
}