Skip to content

Commit aa106f1

Browse files
csharpfritzCopilot
andcommitted
feat: implement #557 prescan+runtime parser, #549 code-only scaffolder, #550 namespace-prefix transform
#557 - Web.config Custom Control Registry Parser (complete) - WebConfigAssemblyParser: parses <assemblies> + namespace-level <controls> sections - RuntimeDetector: integrates WebConfigAssemblyParser and CodeOnlyServerControlAnalyzer - RuntimeProfile.CustomControlRegistrations + CustomControlPrefixToNamespaceMap exposed - PrescanAnalyzer: adds CodeOnlyServerControls section (the 'Custom Controls Found' output) - PrescanResult.CodeOnlyServerControls populated via CodeOnlyServerControlAnalyzer - Tests: WebConfigAssemblyParserTests (6), RuntimeDetectorTests (2), PrescanAnalyzerTests updated #549 - Code-only server control scaffolder (complete) - CodeOnlyServerControlAnalyzer: Roslyn-based property/event extraction added - Extracts public settable properties, skipping BWFC-inherited names - Extracts public events (EventHandler -> EventCallback mapping) - CodeOnlyPropertyDescriptor + CodeOnlyEventDescriptor added to descriptor - CodeOnlyControlScaffolder: proper BWFC base class mapping + property/event emission - MapBwfcBaseClass: WebControl->BaseStyledComponent, Composite/DataBound/Control->BaseWebFormsComponent - MapEventCallback: EventHandler->EventCallback, EventHandler<T>->EventCallback<T> - BuildCodeBehind emits [Parameter] for each extracted property and event - Pipeline wiring: EmitCodeOnlyControlSkeletonsAsync in MigrationPipeline - Tests: CodeOnlyControlScaffoldingIssue549Tests (7 tests, 2 previously skipped now passing) #550 - Namespace-level tag prefix registrations (skeleton complete) - LocalTagNamespaceResolutionTransform: new IMarkupTransform (Order=595) - Strips namespace-bound prefixes (local:, uc: from namespace=) to bare component names - Skips asp: and ajaxToolkit: (handled by dedicated transforms) - Self-closing and block element forms both handled - MigrationContext.CustomControlPrefixToNamespace: populated from WebConfigAssemblyParser - FileMetadata.CustomControlPrefixToNamespace: passed through to transforms - Registered in Program.cs (DI) and TestHelpers.cs (CreateDefaultPipeline) - Tests: LocalTagNamespaceResolutionTransformTests (7 tests) Test results: 905 passed, 1 skipped (pre-existing), 0 failed (up from 890 baseline) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c9e2a33 commit aa106f1

16 files changed

Lines changed: 925 additions & 33 deletions
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
using System.Text.RegularExpressions;
2+
using BlazorWebFormsComponents.Cli.Scaffolding;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
using Microsoft.CodeAnalysis;
6+
7+
namespace BlazorWebFormsComponents.Cli.Analysis;
8+
9+
public sealed class CodeOnlyServerControlAnalyzer
10+
{
11+
private static readonly Regex NamespaceRegex = new(
12+
@"^\s*namespace\s+(?<namespace>[A-Za-z_][\w.]*)",
13+
RegexOptions.Compiled | RegexOptions.Multiline);
14+
15+
private static readonly Regex ClassRegex = new(
16+
@"\bclass\s+(?<name>[A-Za-z_]\w*)\s*:\s*(?<base>[A-Za-z_][\w\.<>,:\s]*)",
17+
RegexOptions.Compiled);
18+
19+
private static readonly HashSet<string> SupportedBaseTypes = new(StringComparer.OrdinalIgnoreCase)
20+
{
21+
"Control",
22+
"WebControl",
23+
"CompositeControl",
24+
"DataBoundControl",
25+
"BaseDataBoundControl",
26+
"TemplatedControl"
27+
};
28+
29+
// Properties already present on BWFC base classes — skip to avoid [Parameter] duplicates
30+
private static readonly HashSet<string> BwfcInheritedPropertyNames = new(StringComparer.OrdinalIgnoreCase)
31+
{
32+
"ID", "Visible", "Enabled", "TabIndex", "runat", "ViewState",
33+
"CssClass", "Width", "Height", "BackColor", "ForeColor", "BorderColor",
34+
"Font", "Style", "ClientID", "UniqueID"
35+
};
36+
37+
private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.Latest);
38+
39+
public IReadOnlyList<CodeOnlyServerControlDescriptor> Analyze(
40+
string sourcePath,
41+
ControlRegistrationInfo controlRegistrations)
42+
{
43+
if (string.IsNullOrWhiteSpace(sourcePath) || !Directory.Exists(sourcePath))
44+
return [];
45+
46+
var descriptors = new Dictionary<string, CodeOnlyServerControlDescriptor>(StringComparer.OrdinalIgnoreCase);
47+
foreach (var file in RuntimeDetectionFiles.EnumerateFiles(sourcePath, ".cs"))
48+
{
49+
if (IsExcludedCodeFile(file) || HasMarkupCompanion(file))
50+
continue;
51+
52+
string content;
53+
try
54+
{
55+
content = File.ReadAllText(file);
56+
}
57+
catch
58+
{
59+
continue;
60+
}
61+
62+
var namespaceName = ReadNamespace(content);
63+
if (string.IsNullOrWhiteSpace(namespaceName))
64+
continue;
65+
66+
foreach (Match classMatch in ClassRegex.Matches(content))
67+
{
68+
var className = classMatch.Groups["name"].Value;
69+
var baseType = NormalizeBaseType(classMatch.Groups["base"].Value);
70+
if (!IsServerControlBase(baseType))
71+
continue;
72+
73+
var key = $"{namespaceName}.{className}";
74+
if (descriptors.ContainsKey(key))
75+
continue;
76+
77+
var matchingPrefixes = controlRegistrations.PrefixToNamespaceMap
78+
.Where(pair => string.Equals(pair.Value, namespaceName, StringComparison.Ordinal))
79+
.Select(pair => pair.Key)
80+
.OrderBy(static prefix => prefix, StringComparer.OrdinalIgnoreCase)
81+
.ToList();
82+
83+
var (properties, events) = ExtractPublicSurface(content, className);
84+
85+
descriptors[key] = new CodeOnlyServerControlDescriptor
86+
{
87+
ClassName = className,
88+
Namespace = namespaceName,
89+
BaseType = baseType,
90+
SourceFilePath = Path.GetRelativePath(sourcePath, file),
91+
TagPrefixes = matchingPrefixes,
92+
Properties = properties,
93+
Events = events
94+
};
95+
}
96+
}
97+
98+
return descriptors.Values
99+
.OrderBy(static descriptor => descriptor.ClassName, StringComparer.OrdinalIgnoreCase)
100+
.ThenBy(static descriptor => descriptor.Namespace, StringComparer.OrdinalIgnoreCase)
101+
.ToList();
102+
}
103+
104+
private static (IReadOnlyList<CodeOnlyPropertyDescriptor> Properties,
105+
IReadOnlyList<CodeOnlyEventDescriptor> Events)
106+
ExtractPublicSurface(string content, string className)
107+
{
108+
try
109+
{
110+
var syntaxTree = CSharpSyntaxTree.ParseText(content, ParseOptions);
111+
var root = syntaxTree.GetRoot();
112+
113+
var classDecl = root.DescendantNodes()
114+
.OfType<ClassDeclarationSyntax>()
115+
.FirstOrDefault(c => c.Identifier.ValueText == className);
116+
117+
if (classDecl is null)
118+
return ([], []);
119+
120+
var properties = classDecl.Members
121+
.OfType<PropertyDeclarationSyntax>()
122+
.Where(p => HasPublicModifier(p) && HasSetter(p))
123+
.Select(p => p.Identifier.ValueText)
124+
.Where(name => !BwfcInheritedPropertyNames.Contains(name))
125+
.Select(name =>
126+
{
127+
var prop = classDecl.Members
128+
.OfType<PropertyDeclarationSyntax>()
129+
.First(p => p.Identifier.ValueText == name);
130+
return new CodeOnlyPropertyDescriptor(name, prop.Type.ToString());
131+
})
132+
.ToList();
133+
134+
var events = classDecl.Members
135+
.SelectMany(static member => member switch
136+
{
137+
EventFieldDeclarationSyntax ef when HasPublicModifier(ef) =>
138+
ef.Declaration.Variables.Select(v =>
139+
new CodeOnlyEventDescriptor(v.Identifier.ValueText, ef.Declaration.Type.ToString())),
140+
EventDeclarationSyntax ed when HasPublicModifier(ed) =>
141+
(IEnumerable<CodeOnlyEventDescriptor>)[new CodeOnlyEventDescriptor(ed.Identifier.ValueText, ed.Type.ToString())],
142+
_ => []
143+
})
144+
.ToList();
145+
146+
return (properties, events);
147+
}
148+
catch
149+
{
150+
return ([], []);
151+
}
152+
}
153+
154+
private static bool IsExcludedCodeFile(string filePath) =>
155+
filePath.EndsWith(".designer.cs", StringComparison.OrdinalIgnoreCase)
156+
|| filePath.EndsWith(".aspx.cs", StringComparison.OrdinalIgnoreCase)
157+
|| filePath.EndsWith(".ascx.cs", StringComparison.OrdinalIgnoreCase)
158+
|| filePath.EndsWith(".master.cs", StringComparison.OrdinalIgnoreCase);
159+
160+
private static bool HasMarkupCompanion(string filePath)
161+
{
162+
var candidate = Path.ChangeExtension(filePath, null);
163+
return File.Exists(candidate + ".aspx")
164+
|| File.Exists(candidate + ".ascx")
165+
|| File.Exists(candidate + ".master");
166+
}
167+
168+
private static string? ReadNamespace(string content)
169+
{
170+
var match = NamespaceRegex.Match(content);
171+
return match.Success ? match.Groups["namespace"].Value : null;
172+
}
173+
174+
private static string NormalizeBaseType(string baseType)
175+
{
176+
var trimmed = baseType.Trim();
177+
var genericIndex = trimmed.IndexOf('<');
178+
if (genericIndex >= 0)
179+
trimmed = trimmed[..genericIndex];
180+
181+
var qualifiedName = trimmed.Split(',')[0].Trim();
182+
if (qualifiedName.StartsWith("global::", StringComparison.Ordinal))
183+
qualifiedName = qualifiedName["global::".Length..];
184+
185+
var lastDotIndex = qualifiedName.LastIndexOf('.');
186+
return lastDotIndex >= 0 ? qualifiedName[(lastDotIndex + 1)..] : qualifiedName;
187+
}
188+
189+
private static bool IsServerControlBase(string baseType) =>
190+
SupportedBaseTypes.Contains(baseType);
191+
192+
private static bool HasPublicModifier(MemberDeclarationSyntax member) =>
193+
member.Modifiers.Any(SyntaxKind.PublicKeyword);
194+
195+
private static bool HasSetter(PropertyDeclarationSyntax property) =>
196+
property.AccessorList?.Accessors.Any(static a =>
197+
a.IsKind(SyntaxKind.SetAccessorDeclaration)
198+
|| a.IsKind(SyntaxKind.InitAccessorDeclaration)) == true;
199+
}
200+
201+
public sealed class CodeOnlyServerControlDescriptor
202+
{
203+
public string ClassName { get; init; } = string.Empty;
204+
public string Namespace { get; init; } = string.Empty;
205+
public string BaseType { get; init; } = string.Empty;
206+
public string SourceFilePath { get; init; } = string.Empty;
207+
public IReadOnlyList<string> TagPrefixes { get; init; } = [];
208+
public IReadOnlyList<CodeOnlyPropertyDescriptor> Properties { get; init; } = [];
209+
public IReadOnlyList<CodeOnlyEventDescriptor> Events { get; init; } = [];
210+
}
211+
212+
public sealed record CodeOnlyPropertyDescriptor(string Name, string TypeName);
213+
public sealed record CodeOnlyEventDescriptor(string Name, string DelegateType);

src/BlazorWebFormsComponents.Cli/Analysis/WebConfigAssemblyParser.cs

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,30 @@ public ControlRegistrationInfo Parse(string? webConfigPath)
3636

3737
public ControlRegistrationInfo ParseProject(string sourcePath)
3838
{
39-
var result = Parse(FindWebConfig(sourcePath));
4039
if (string.IsNullOrWhiteSpace(sourcePath) || !Directory.Exists(sourcePath))
41-
return result;
40+
return new();
41+
42+
var webConfigPaths = RuntimeDetectionFiles.EnumerateFiles(sourcePath, ".config")
43+
.Where(file => Path.GetFileName(file).Equals("Web.config", StringComparison.OrdinalIgnoreCase))
44+
.OrderBy(file => file, StringComparer.OrdinalIgnoreCase)
45+
.ToList();
46+
47+
var assemblies = new List<AssemblyRegistration>();
48+
var namespaceTags = new List<NamespaceTagRegistration>();
49+
var errors = new List<string>();
50+
51+
foreach (var webConfigPath in webConfigPaths)
52+
{
53+
var parsed = Parse(webConfigPath);
54+
if (!string.IsNullOrWhiteSpace(parsed.Error))
55+
{
56+
errors.Add($"{Path.GetRelativePath(sourcePath, webConfigPath)}: {parsed.Error}");
57+
continue;
58+
}
59+
60+
assemblies.AddRange(parsed.Assemblies);
61+
namespaceTags.AddRange(parsed.NamespaceTags);
62+
}
4263

4364
var registerDirectives = new List<RegisterDirectiveRegistration>();
4465
foreach (var file in RuntimeDetectionFiles.EnumerateFiles(sourcePath, ".aspx", ".ascx", ".master"))
@@ -77,17 +98,30 @@ public ControlRegistrationInfo ParseProject(string sourcePath)
7798
}
7899
}
79100

80-
return result with
101+
var distinctAssemblies = DistinctBy(
102+
assemblies,
103+
registration => string.Join("|", registration.AssemblyName, registration.Namespace ?? string.Empty));
104+
var distinctNamespaceTags = DistinctBy(
105+
namespaceTags,
106+
registration => string.Join("|", registration.TagPrefix, registration.Namespace, registration.AssemblyName ?? string.Empty));
107+
var distinctRegisterDirectives = DistinctBy(
108+
registerDirectives,
109+
registration => string.Join("|",
110+
registration.TagPrefix,
111+
registration.TagName ?? string.Empty,
112+
registration.Namespace ?? string.Empty,
113+
registration.AssemblyName ?? string.Empty,
114+
registration.SourceVirtualPath ?? string.Empty,
115+
registration.SourceFilePath));
116+
117+
return new ControlRegistrationInfo
81118
{
82-
RegisterDirectives = DistinctBy(
83-
registerDirectives,
84-
registration => string.Join("|",
85-
registration.TagPrefix,
86-
registration.TagName ?? string.Empty,
87-
registration.Namespace ?? string.Empty,
88-
registration.AssemblyName ?? string.Empty,
89-
registration.SourceVirtualPath ?? string.Empty,
90-
registration.SourceFilePath))
119+
WebConfigPath = webConfigPaths.FirstOrDefault(),
120+
Error = errors.Count > 0 ? string.Join(" | ", errors) : null,
121+
Assemblies = distinctAssemblies,
122+
NamespaceTags = distinctNamespaceTags,
123+
RegisterDirectives = distinctRegisterDirectives,
124+
PrefixToNamespaceMap = BuildPrefixToNamespaceMap(distinctNamespaceTags, distinctRegisterDirectives)
91125
};
92126
}
93127

@@ -123,15 +157,19 @@ private static ControlRegistrationInfo Parse(XDocument document, string webConfi
123157
.Where(static registration => registration is not null)
124158
.Cast<NamespaceTagRegistration>();
125159

160+
var distinctAssemblies = DistinctBy(
161+
assemblies,
162+
registration => string.Join("|", registration.AssemblyName, registration.Namespace ?? string.Empty));
163+
var distinctNamespaceTags = DistinctBy(
164+
namespaceTags,
165+
registration => string.Join("|", registration.TagPrefix, registration.Namespace, registration.AssemblyName ?? string.Empty));
166+
126167
return new ControlRegistrationInfo
127168
{
128169
WebConfigPath = webConfigPath,
129-
Assemblies = DistinctBy(
130-
assemblies,
131-
registration => string.Join("|", registration.AssemblyName, registration.Namespace ?? string.Empty)),
132-
NamespaceTags = DistinctBy(
133-
namespaceTags,
134-
registration => string.Join("|", registration.TagPrefix, registration.Namespace, registration.AssemblyName ?? string.Empty))
170+
Assemblies = distinctAssemblies,
171+
NamespaceTags = distinctNamespaceTags,
172+
PrefixToNamespaceMap = BuildPrefixToNamespaceMap(distinctNamespaceTags, [])
135173
};
136174
}
137175

@@ -196,6 +234,29 @@ private static IReadOnlyList<T> DistinctBy<T>(IEnumerable<T> values, Func<T, str
196234
.Select(static group => group.First())
197235
.ToList();
198236
}
237+
238+
private static IReadOnlyDictionary<string, string> BuildPrefixToNamespaceMap(
239+
IEnumerable<NamespaceTagRegistration> namespaceTags,
240+
IEnumerable<RegisterDirectiveRegistration> registerDirectives)
241+
{
242+
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
243+
244+
foreach (var registration in namespaceTags.OrderBy(static item => item.TagPrefix, StringComparer.OrdinalIgnoreCase))
245+
{
246+
if (!map.ContainsKey(registration.TagPrefix))
247+
map[registration.TagPrefix] = registration.Namespace;
248+
}
249+
250+
foreach (var registration in registerDirectives
251+
.Where(static item => !string.IsNullOrWhiteSpace(item.Namespace))
252+
.OrderBy(static item => item.TagPrefix, StringComparer.OrdinalIgnoreCase))
253+
{
254+
if (!map.ContainsKey(registration.TagPrefix))
255+
map[registration.TagPrefix] = registration.Namespace!;
256+
}
257+
258+
return map;
259+
}
199260
}
200261

201262
public sealed record ControlRegistrationInfo
@@ -205,6 +266,7 @@ public sealed record ControlRegistrationInfo
205266
public IReadOnlyList<AssemblyRegistration> Assemblies { get; init; } = [];
206267
public IReadOnlyList<NamespaceTagRegistration> NamespaceTags { get; init; } = [];
207268
public IReadOnlyList<RegisterDirectiveRegistration> RegisterDirectives { get; init; } = [];
269+
public IReadOnlyDictionary<string, string> PrefixToNamespaceMap { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
208270
}
209271

210272
public sealed record AssemblyRegistration(string AssemblyName, string? Namespace);

0 commit comments

Comments
 (0)