Skip to content

WIP: Move to Resx MVP #79297

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: features/move-to-resx
Choose a base branch
from

Conversation

Fanominals
Copy link

Add initial support for string extraction and .resx integration

  • Detects hardcoded strings in source code.
  • Auto-generates resource names (with potential for AI-assisted naming).
  • Inserts new entries into a single .resx file.
  • Replaces original strings in code with references to the new resource entries.

This lays the foundation for improved localization and maintainability. Currently supports one .resx file; future updates may expand multi-file support and smarter naming strategies.

JoeRobich and others added 4 commits June 20, 2025 09:56
- Detects hardcoded strings in source code.
- Auto-generates resource names (with potential for AI-assisted naming).
- Inserts new entries into a single .resx file.
- Replaces original strings in code with references to the new resource entries.

This lays the foundation for improved localization and maintainability. Currently supports one .resx file; future updates may expand multi-file support and smarter naming strategies.
@Fanominals Fanominals requested a review from a team as a code owner July 8, 2025 21:35
Copy link
Contributor

@Fanominals please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.

@dotnet-policy-service agree [company="{your company}"]

Options:

  • (default - no company specified) I have sole ownership of intellectual property rights to my Submissions and I am not making Submissions in the course of work for my employer.
@dotnet-policy-service agree
  • (when company given) I am making Submissions in the course of work for my employer (or my employer has intellectual property rights in my Submissions by contract or applicable law). I have permission from my employer to make Submissions and enter into this Agreement on behalf of my employer. By signing below, the defined term “You” includes me and my employer.
@dotnet-policy-service agree company="Microsoft"
Contributor License Agreement

Contribution License Agreement

This Contribution License Agreement ( “Agreement” ) is agreed to by the party signing below ( “You” ),
and conveys certain license rights to the .NET Foundation ( “.NET Foundation” ) for Your contributions to
.NET Foundation open source projects. This Agreement is effective as of the latest signature date below.

1. Definitions.

“Code” means the computer software code, whether in human-readable or machine-executable form,
that is delivered by You to .NET Foundation under this Agreement.

“Project” means any of the projects owned or managed by .NET Foundation and offered under a license
approved by the Open Source Initiative (www.opensource.org).

“Submit” is the act of uploading, submitting, transmitting, or distributing code or other content to any
Project, including but not limited to communication on electronic mailing lists, source code control
systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of
discussing and improving that Project, but excluding communication that is conspicuously marked or
otherwise designated in writing by You as “Not a Submission.”

“Submission” means the Code and any other copyrightable material Submitted by You, including any
associated comments and documentation.

2. Your Submission. You must agree to the terms of this Agreement before making a Submission to any
Project. This Agreement covers any and all Submissions that You, now or in the future (except as
described in Section 4 below), Submit to any Project.

3. Originality of Work. You represent that each of Your Submissions is entirely Your
original work. Should You wish to Submit materials that are not Your original work,
You may Submit them separately to the Project if You (a) retain all copyright and
license information that was in the materials as you received them, (b) in the
description accompanying your Submission, include the phrase "Submission
containing materials of a third party:" followed by the names of the third party and any
licenses or other restrictions of which You are aware, and (c) follow any other
instructions in the Project's written guidelines concerning Submissions.

4. Your Employer. References to “employer” in this Agreement include Your employer or anyone else
for whom You are acting in making Your Submission, e.g. as a contractor, vendor, or agent. If Your
Submission is made in the course of Your work for an employer or Your employer has intellectual
property rights in Your Submission by contract or applicable law, You must secure permission from Your
employer to make the Submission before signing this Agreement. In that case, the term “You” in this
Agreement will refer to You and the employer collectively. If You change employers in the future and
desire to Submit additional Submissions for the new employer, then You agree to sign a new Agreement
and secure permission from the new employer before Submitting those Submissions.

5. Licenses.

a. Copyright License. You grant .NET Foundation, and those who receive the Submission directly
or indirectly from .NET Foundation, a perpetual, worldwide, non-exclusive, royalty-free, irrevocable
license in the Submission to reproduce, prepare derivative works of, publicly display, publicly perform,
and distribute the Submission and such derivative works, and to sublicense any or all of the foregoing
rights to third parties.

b. Patent License. You grant .NET Foundation, and those who receive the Submission directly or
indirectly from .NET Foundation, a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license
under Your patent claims that are necessarily infringed by the Submission or the combination of the
Submission with the Project to which it was Submitted to make, have made, use, offer to sell, sell and
import or otherwise dispose of the Submission alone or with the Project.

c. Other Rights Reserved. Each party reserves all rights not expressly granted in this Agreement.
No additional licenses or rights whatsoever (including, without limitation, any implied licenses) are
granted by implication, exhaustion, estoppel or otherwise.

6. Representations and Warranties. You represent that You are legally entitled to grant the above
licenses. You represent that each of Your Submissions is entirely Your original work (except as You may
have disclosed under Section 3 ). You represent that You have secured permission from Your employer to
make the Submission in cases where Your Submission is made in the course of Your work for Your
employer or Your employer has intellectual property rights in Your Submission by contract or applicable
law. If You are signing this Agreement on behalf of Your employer, You represent and warrant that You
have the necessary authority to bind the listed employer to the obligations contained in this Agreement.
You are not expected to provide support for Your Submission, unless You choose to do so. UNLESS
REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, AND EXCEPT FOR THE WARRANTIES
EXPRESSLY STATED IN SECTIONS 3, 4, AND 6 , THE SUBMISSION PROVIDED UNDER THIS AGREEMENT IS
PROVIDED WITHOUT WARRANTY OF ANY KIND, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY OF
NONINFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.

7. Notice to .NET Foundation. You agree to notify .NET Foundation in writing of any facts or
circumstances of which You later become aware that would make Your representations in this
Agreement inaccurate in any respect.

8. Information about Submissions. You agree that contributions to Projects and information about
contributions may be maintained indefinitely and disclosed publicly, including Your name and other
information that You submit with Your Submission.

9. Governing Law/Jurisdiction. This Agreement is governed by the laws of the State of Washington, and
the parties consent to exclusive jurisdiction and venue in the federal courts sitting in King County,
Washington, unless no federal subject matter jurisdiction exists, in which case the parties consent to
exclusive jurisdiction and venue in the Superior Court of King County, Washington. The parties waive all
defenses of lack of personal jurisdiction and forum non-conveniens.

10. Entire Agreement/Assignment. This Agreement is the entire agreement between the parties, and
supersedes any and all prior agreements, understandings or communications, written or oral, between
the parties relating to the subject matter hereof. This Agreement may be assigned by .NET Foundation.

.NET Foundation dedicates this Contribution License Agreement to the public domain according to the Creative Commons CC0 1.


private static TextDocument? FindBestResxFile(Project project, string? documentPath)
{
var allResx = project.AdditionalDocuments
Copy link
Member

Choose a reason for hiding this comment

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

Are resx files always included in additional files? I think they are only when ResxSourceGenerator package is used.

Copy link
Author

Choose a reason for hiding this comment

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

Hey, so in my experience ResX Files are not included in additional files and need to be manually added. When I was researching this, from what I read it was best practice to get the Resx files from additional documents for codefixes and analyzers but there could be something I am missing. I am reaching out to you on teams and maybe we can figure out a better implementation for this?

@jaredpar
Copy link
Member

@JoeRobich, @dibarbet please take a look

Comment on lines 32 to 33
public override ImmutableArray<string> FixableDiagnosticIds
=> ImmutableArray.Create(MoveToResx.DiagnosticId);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
public override ImmutableArray<string> FixableDiagnosticIds
=> ImmutableArray.Create(MoveToResx.DiagnosticId);
public override ImmutableArray<string> FixableDiagnosticIds
=> [MoveToResx.DiagnosticId];

Prefer collection expressions


public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var diagnostic = context.Diagnostics[0];
Copy link
Member

Choose a reason for hiding this comment

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

Is it possible to have more than one diagnostic here?

Copy link
Member

Choose a reason for hiding this comment

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

@jaredpar no. This was based on an early iteration of code fixes that thought that might occur (for fix all). but that's not how fix all actually ended up working. but thsi was never revised. So this is fine to do.

var diagnostic = context.Diagnostics[0];
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
var node = root?.FindNode(diagnostic.Location.SourceSpan) as LiteralExpressionSyntax;
if (node == null)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if (node == null)
if (node is null)

Comment on lines 111 to 121
//private static IEnumerable<TextDocument> GetResxAdditionalFiles(Project project)
//{
// foreach (var file in project.AdditionalDocuments)
// {
// if (file.FilePath != null &&
// string.Equals(".resx", Path.GetExtension(file.FilePath), StringComparison.OrdinalIgnoreCase))
// {
// yield return file;
// }
// }
//}
Copy link
Member

Choose a reason for hiding this comment

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

Remove commented out code before merging.

namespace Microsoft.CodeAnalysis.CSharp.MoveToResx;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
internal class MoveToResx : DiagnosticAnalyzer
Copy link
Member

Choose a reason for hiding this comment

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

Also, update filename to match.

Suggested change
internal class MoveToResx : DiagnosticAnalyzer
internal class CSharpMoveToResxDiagnosticAnalyzer : DiagnosticAnalyzer

Copy link
Member

Choose a reason for hiding this comment

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

also, seal the type.

[DiagnosticAnalyzer(LanguageNames.CSharp)]
internal class MoveToResx : DiagnosticAnalyzer
{
public const string DiagnosticId = "MoveToResx";
Copy link
Member

Choose a reason for hiding this comment

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

Add a new diagnostic id to IDEDiagnosticIds.cs instead of defining one here. Maybe IDE0360 makes sense.


private static void AnalyzeStringLiteral(SyntaxNodeAnalysisContext context)
{
var stringLiteral = (LiteralExpressionSyntax)context.Node;
Copy link
Member

Choose a reason for hiding this comment

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

Since we are not creating resource files yet, check whether a .resx file is reachable before reporting diagnostics. Meaning FindBestResxFile may need to be moved to a utilities class that your analyzer and codefix can share.

// 2. Parse the SourceText with XDocument
var xdoc = XDocument.Parse(resxSourceText.ToString());

// 3. Add or update the resource entry
Copy link
Member

Choose a reason for hiding this comment

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

We should check to see if the string (not just the name) already exists in the resource file. If so we can grab the name and skip updating the .resx.

If we checked for this in the Analyzer, we may want to offer a separate diagnostic and fix for that. UseResxString or something similar.

Copy link
Member

Choose a reason for hiding this comment

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

agreed.

Copy link
Author

Choose a reason for hiding this comment

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

Hey good idea! Making this change for the code fix. For the analyzer use case, how would the analyzer know which resx to check for the string. Would it check all available resx files?

using System.Collections.Immutable;
using System.IO;
using System.Xml.Linq;
using Microsoft.CodeAnalysis.CSharp;
Copy link
Member

Choose a reason for hiding this comment

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

nit: sort :)

using System.Xml.Linq;
using Microsoft.CodeAnalysis.CSharp;

namespace Microsoft.CodeAnalysis.CSharp.MoveToResx
Copy link
Member

Choose a reason for hiding this comment

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

nit: we use file scoped namespaces.

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(CSharpMoveToResxCodeFixProvider)), Shared]
internal class CSharpMoveToResxCodeFixProvider : CodeFixProvider
{
[ImportingConstructor]
Copy link
Member

Choose a reason for hiding this comment

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

nit, this should have the obsolete attribute stating it should not be called directly (see other code fix providers).
Nit: use a primary constructor.

namespace Microsoft.CodeAnalysis.CSharp.MoveToResx
{
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(CSharpMoveToResxCodeFixProvider)), Shared]
internal class CSharpMoveToResxCodeFixProvider : CodeFixProvider
Copy link
Member

Choose a reason for hiding this comment

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

nit: sealed

=> ImmutableArray.Create(MoveToResx.DiagnosticId);

public override FixAllProvider GetFixAllProvider()
=> WellKnownFixAllProviders.BatchFixer;
Copy link
Member

Choose a reason for hiding this comment

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

not sure how effective this will be. we may want to write a custom fixer here. Do you have tests showing fix-all behavior?

Copy link
Author

Choose a reason for hiding this comment

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

Hey, great point. I am working on a custom fixer for this.

{
var diagnostic = context.Diagnostics[0];
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
var node = root?.FindNode(diagnostic.Location.SourceSpan) as LiteralExpressionSyntax;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
var node = root?.FindNode(diagnostic.Location.SourceSpan) as LiteralExpressionSyntax;
var node = root?.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true) as LiteralExpressionSyntax;

This is needed for the scenario where you have Foo("SomeString") (and please test this). Your above code will get an ArgumentSyntax not its child LiteralExpressionSyntax here.

public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var diagnostic = context.Diagnostics[0];
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
Copy link
Member

Choose a reason for hiding this comment

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

either do GetRequiredSyntaxRootAsync or use our existing helper, which is called something like context.TryGetRelevantNodeAsync<LiteralExpressionSyntax>()

if (node == null)
return;

var resxFile = FindBestResxFile(context.Document.Project, context.Document.FilePath);
Copy link
Member

Choose a reason for hiding this comment

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

there are many forms of literals (like numbers, booleans, etc). you need to actually validate that this is a string literal/

var resxName = Path.GetFileName(resxFile.FilePath);
context.RegisterCodeFix(
CodeAction.Create(
title: $"Move string to .resx resource ({resxName})",
Copy link
Member

Choose a reason for hiding this comment

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

ironic that the title itself is not localized :)

also, this indicates an interesting case we should support. We should find interpolated strings and offer to make them resources, converting it to then use string.Format at the call site.

{
context.RegisterCodeFix(
CodeAction.Create(
title: "No .resx file found. Please create a .resx file in your project or folder.",
Copy link
Member

Choose a reason for hiding this comment

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

we def would not do things this way normally. this will pop up a lightbulb (on every string) with something that does nothing. In this case, we should add the resx file to the project and populate it properly. This may require you to write a component at a higher layer, and not offer anything if you can't import that component here.

.ToList();

if (allResx.Count == 0 || documentPath == null)
return allResx.FirstOrDefault();
Copy link
Member

Choose a reason for hiding this comment

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

You should always have a document path. so this second clause won't ever hit.

fort he first clause, it's odd since you knwo the count is 0, so returning FirstOrDefualt is equivalent to jsut returning null.

var resxSourceText = await resxFile.GetTextAsync(cancellationToken).ConfigureAwait(false);

// 2. Parse the SourceText with XDocument
var xdoc = XDocument.Parse(resxSourceText.ToString());
Copy link
Member

Choose a reason for hiding this comment

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

this can fail, throwing an exception. that woudl be bad.

}
else
{
dataElement.Element("value")!.Value = value;
Copy link
Member

Choose a reason for hiding this comment

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

there's no guarantee that the resx file will be formed in the way you want. it's important to check for that and act gracefully there. these are also good test cases.

// 7. Replace the string literal in the code with a resource reference, including namespace if available
var resourceClass = Path.GetFileNameWithoutExtension(resxFile.Name);
var ns = document.Project.DefaultNamespace;
string resourceAccessString = !string.IsNullOrEmpty(ns)
Copy link
Member

Choose a reason for hiding this comment

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

we don't abbr in the code. so use something like defaultNamespace, not 'ns'.

also, we should annotate this expression with a simplification annotation, so that the extraneous namespace/type name can be trimmed if possible int he destiation.

return newSolution;
}

private static string ToDeterministicResourceKey(string value, int maxWords = 6)
Copy link
Member

Choose a reason for hiding this comment

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

why is this an optional argument?

words.Add(sb.ToString().ToLowerInvariant());

// Limit to maxWords
if (words.Count > maxWords)
Copy link
Member

Choose a reason for hiding this comment

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

this seems quite low. What i'd probably do instead is make the resource string as long as needed without going over a max length (like 60 chars) vs a max number of words (which could be any any length).

}
}
if (sb.Length > 0)
words.Add(sb.ToString().ToLowerInvariant());
Copy link
Member

Choose a reason for hiding this comment

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

why are we lowercasing the txt?


// Split the string into words using non-alphanumeric as delimiters
var words = new List<string>();
var sb = new StringBuilder();
Copy link
Member

Choose a reason for hiding this comment

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

builder, or stringBuilder

private static string ToDeterministicResourceKey(string value, int maxWords = 6)
{
if (string.IsNullOrWhiteSpace(value))
return "emptyString";
Copy link
Member

Choose a reason for hiding this comment

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

when does this happen?

words = words.Take(maxWords).ToList();

// Convert to lowerCamelCase
var keyBuilder = new StringBuilder();
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
var keyBuilder = new StringBuilder();
using var _ = PooledStringBuilder.GetInstance(out var keyBuilder);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
Copy link
Member

Choose a reason for hiding this comment

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

we have existing DiagnosticAnalyzer subtypes that you can subclass yourself. They will take care of these sorts of common pieces of code.


// Report a diagnostic for every string literal
var diagnostic = Diagnostic.Create(s_moveToResxRule, stringLiteral.GetLocation(), stringLiteral.Token.ValueText);
context.ReportDiagnostic(diagnostic);
Copy link
Member

Choose a reason for hiding this comment

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

i think we need to do a bunch more up front checking. As an example, we really never want the code fixer part to ever fail (unless validating that it wouldn't fail woudl be prohibitively expensive).

So any locations where the fixer no-ops, we should check up here (for example, for things like the empty string).

@CyrusNajmabadi
Copy link
Member

NOte: the analyzer and fixer should both be low-pri. This will show up on every string litearl, and will often not be what the user wants. So we should deprioritize it against existing analzyers/refactorings/fixers.

@Fanominals
Copy link
Author

@JoeRobich @CyrusNajmabadi @jaredpar

Ive added a fixall provider that works + addressed most of the things brought up so far.

Requesting a review on the new changes

@JoeRobich
Copy link
Member

/azp run

Copy link

Azure Pipelines successfully started running 3 pipeline(s).

@@ -0,0 +1,64 @@
//// Licensed to the .NET Foundation under one or more agreements.
Copy link
Member

Choose a reason for hiding this comment

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

We do not want to commit commented out code. Remove until we are ready to move forward with this.

var resx = allResx.First();

return CodeAction.Create(
"Move all strings to .resx resource",
Copy link
Member

Choose a reason for hiding this comment

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

Move this string to the Resx. =)


context.RegisterCodeFix(
CodeAction.Create(
"Move string to .resx resource",
Copy link
Member

Choose a reason for hiding this comment

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

Move to Resx

{
context.RegisterCodeFix(
CodeAction.Create(
title: "No .resx file found. Please create a .resx file in your project or folder.",
Copy link
Member

Choose a reason for hiding this comment

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

I still believe we should be checking for .resx files in the Analyzer.

Comment on lines +85 to +88
Document document,
LiteralExpressionSyntax stringLiteral,
TextDocument resxFile,
CancellationToken cancellationToken)
Copy link
Member

Choose a reason for hiding this comment

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

nit: indent

Copy link
Member

@JoeRobich JoeRobich left a comment

Choose a reason for hiding this comment

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

Seems like the code in the codefix and the fixall are fairly similar. Could we share it to reduce duplication?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants