Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
101 changes: 12 additions & 89 deletions Src/BlueDotBrigade.Analyzers/Diagnostics/DslTerminologyAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Xml.Linq;

using BlueDotBrigade.Analyzers.Dsl;
using BlueDotBrigade.Analyzers.Utilities;
Expand Down Expand Up @@ -59,7 +58,7 @@ public override void Initialize(AnalysisContext context)
var projectDir = AnalyzerOptionsHelper.GetProjectDirectory(startCtx.Options);
var selected = AnalyzerOptionsHelper.SelectDslAdditionalText(startCtx.Options, targetFileName, projectDir);

var rules = new List<RuleDef>();
var rules = new List<TerminologyRule>();
var hasConfig = false;

if (selected is not null)
Expand All @@ -69,8 +68,7 @@ public override void Initialize(AnalysisContext context)
{
try
{
var doc = XDocument.Parse(text.ToString());
rules = ParseDsl(doc);
rules = DslRuleParser.Parse(text.ToString());
hasConfig = true;
}
catch
Expand All @@ -90,6 +88,8 @@ public override void Initialize(AnalysisContext context)
});
}

var validator = new TerminologyValidator(rules);

// Symbols
startCtx.RegisterSymbolAction(symbolCtx =>
{
Expand All @@ -105,7 +105,7 @@ public override void Initialize(AnalysisContext context)
return;
}

CheckAndReport(symbolCtx.ReportDiagnostic, symbol.Locations[0], symbol.Name, rules);
ReportIfViolation(symbolCtx.ReportDiagnostic, symbol.Locations[0], symbol.Name, validator);

}, SymbolKind.NamedType, SymbolKind.Method, SymbolKind.Field, SymbolKind.Property, SymbolKind.Parameter);

Expand All @@ -122,97 +122,20 @@ public override void Initialize(AnalysisContext context)
if (string.IsNullOrWhiteSpace(name))
return;

CheckAndReport(syntaxCtx.ReportDiagnostic, declarator.Identifier.GetLocation(), name, rules);
ReportIfViolation(syntaxCtx.ReportDiagnostic, declarator.Identifier.GetLocation(), name, validator);

}, SyntaxKind.VariableDeclarator);
});
}

private sealed class RuleDef
private static void ReportIfViolation(Action<Diagnostic> report, Location location, string identifierName, TerminologyValidator validator)
{
public string Blocked { get; }
public string Preferred { get; }
public bool CaseSensitive { get; }

public RuleDef(string blocked, string preferred, bool caseSensitive)
var violatedRule = validator.GetViolation(identifierName);
if (violatedRule is not null)
{
Blocked = blocked;
Preferred = preferred;
CaseSensitive = caseSensitive;
var suffix = violatedRule.Preferred is null ? string.Empty : $" Instead, use: '{violatedRule.Preferred}'";
var diag = Diagnostic.Create(Rule, location, identifierName, violatedRule.Blocked, suffix);
report(diag);
}
}

private static void CheckAndReport(Action<Diagnostic> report, Location location, string identifierName, List<RuleDef> rules)
{
foreach (var r in rules)
{
var comparison = r.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;

// Fast-path: if the identifier is exactly the preferred term, do not report
if (!string.IsNullOrEmpty(r.Preferred) && string.Equals(identifierName, r.Preferred, comparison))
{
continue;
}

var searchStart = 0;
while (true)
{
var idx = identifierName.IndexOf(r.Blocked, searchStart, comparison);
if (idx < 0)
{
break;
}

// If this blocked occurrence aligns with the preferred term at the same position,
// treat it as allowed (e.g., "Customer" contains "Cust" at index 0 but is preferred)
if (!string.IsNullOrEmpty(r.Preferred)
&& idx + r.Preferred.Length <= identifierName.Length
&& identifierName.IndexOf(r.Preferred, idx, comparison) == idx)
{
searchStart = idx + 1; // continue searching for other blocked occurrences
continue;
}

var suffix = r.Preferred is null ? string.Empty : $" Instead, use: '{r.Preferred}'";
var diag = Diagnostic.Create(Rule, location, identifierName, r.Blocked, suffix);
report(diag);
return; // one diagnostic per identifier
}
}
}

private static List<RuleDef> ParseDsl(XDocument doc)
{
var list = new List<RuleDef>();
var root = doc.Root;
if (root is null || !string.Equals(root.Name.LocalName, "dsl", StringComparison.OrdinalIgnoreCase))
return list;

foreach (var t in root.Elements("term"))
{
var prefer = (string)t.Attribute("prefer");
if (string.IsNullOrWhiteSpace(prefer))
continue;

var caseAttr = (string)t.Attribute("case");
var caseSensitive = !string.Equals(caseAttr, "insensitive", StringComparison.OrdinalIgnoreCase); // default sensitive

var blockedAttr = (string)t.Attribute("block");
if (!string.IsNullOrWhiteSpace(blockedAttr))
{
list.Add(new RuleDef(blockedAttr!, prefer, caseSensitive));
}

foreach (var alias in t.Elements("alias"))
{
var blocked = (string)alias.Attribute("block");
if (!string.IsNullOrWhiteSpace(blocked))
{
list.Add(new RuleDef(blocked!, prefer, caseSensitive));
}
}
}

return list;
}
}
80 changes: 80 additions & 0 deletions Src/BlueDotBrigade.Analyzers/Dsl/DslRuleParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
namespace BlueDotBrigade.Analyzers.Dsl;

using System;
using System.Collections.Generic;
using System.Xml.Linq;

/// <summary>
/// Parses DSL XML configuration files to extract terminology rules.
/// </summary>
/// <remarks>
/// The DSL XML format supports defining preferred terms with blocked alternatives:
/// <code>
/// &lt;dsl&gt;
/// &lt;term prefer="Customer" block="Client" case="sensitive"/&gt;
/// &lt;term prefer="Customer" case="sensitive"&gt;
/// &lt;alias block="Client"/&gt;
/// &lt;alias block="Cust"/&gt;
/// &lt;/term&gt;
/// &lt;/dsl&gt;
/// </code>
/// </remarks>
public static class DslRuleParser
{
/// <summary>
/// Parses DSL XML content and returns a list of terminology rules.
/// </summary>
/// <param name="xmlContent">The XML content to parse.</param>
/// <returns>A list of <see cref="TerminologyRule"/> objects parsed from the XML.</returns>
/// <exception cref="System.Xml.XmlException">Thrown when the XML content is invalid.</exception>
public static List<TerminologyRule> Parse(string xmlContent)
{
var doc = XDocument.Parse(xmlContent);
return ParseDocument(doc);
}

/// <summary>
/// Parses an XDocument and returns a list of terminology rules.
/// </summary>
/// <param name="doc">The XDocument to parse.</param>
/// <returns>A list of <see cref="TerminologyRule"/> objects parsed from the document.</returns>
public static List<TerminologyRule> ParseDocument(XDocument doc)
{
var list = new List<TerminologyRule>();
var root = doc.Root;

if (root is null || !string.Equals(root.Name.LocalName, "dsl", StringComparison.OrdinalIgnoreCase))
{
return list;
}

foreach (var t in root.Elements("term"))
{
var prefer = (string)t.Attribute("prefer");
if (string.IsNullOrWhiteSpace(prefer))
{
continue;
}

var caseAttr = (string)t.Attribute("case");
var caseSensitive = !string.Equals(caseAttr, "insensitive", StringComparison.OrdinalIgnoreCase);

var blockedAttr = (string)t.Attribute("block");
if (!string.IsNullOrWhiteSpace(blockedAttr))
{
list.Add(new TerminologyRule(blockedAttr, prefer, caseSensitive));
}

foreach (var alias in t.Elements("alias"))
{
var blocked = (string)alias.Attribute("block");
if (!string.IsNullOrWhiteSpace(blocked))
{
list.Add(new TerminologyRule(blocked, prefer, caseSensitive));
}
}
}

return list;
}
}
44 changes: 44 additions & 0 deletions Src/BlueDotBrigade.Analyzers/Dsl/TerminologyRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace BlueDotBrigade.Analyzers.Dsl;

/// <summary>
/// Represents a single terminology rule that specifies a blocked term and its preferred replacement.
/// </summary>
/// <remarks>
/// This class is used to define naming conventions where certain terms are blocked and
/// should be replaced with preferred alternatives. For example, blocking "Cust" and
/// preferring "Customer".
/// </remarks>
public sealed class TerminologyRule
{
/// <summary>
/// Gets the term that should be blocked (not allowed in identifiers).
/// </summary>
public string Blocked { get; }

/// <summary>
/// Gets the preferred term that should be used instead of the blocked term.
/// </summary>
public string Preferred { get; }

/// <summary>
/// Gets a value indicating whether the comparison should be case-sensitive.
/// </summary>
/// <remarks>
/// When <c>true</c>, "cust" and "Cust" are treated as different terms.
/// When <c>false</c>, they are treated as the same term.
/// </remarks>
public bool CaseSensitive { get; }

/// <summary>
/// Initializes a new instance of the <see cref="TerminologyRule"/> class.
/// </summary>
/// <param name="blocked">The term that should be blocked.</param>
/// <param name="preferred">The preferred term to use instead.</param>
/// <param name="caseSensitive">Whether the comparison should be case-sensitive.</param>
public TerminologyRule(string blocked, string preferred, bool caseSensitive)
{
Blocked = blocked;
Preferred = preferred;
CaseSensitive = caseSensitive;
}
}
74 changes: 74 additions & 0 deletions Src/BlueDotBrigade.Analyzers/Dsl/TerminologyValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
namespace BlueDotBrigade.Analyzers.Dsl;

using System;
using System.Collections.Generic;

/// <summary>
/// Validates identifiers against terminology rules to ensure blocked terms are not used.
/// </summary>
/// <remarks>
/// This validator checks if an identifier contains any blocked terms and reports violations.
/// It supports both case-sensitive and case-insensitive matching, and handles edge cases
/// where the preferred term contains the blocked term (e.g., "Customer" containing "Cust").
/// </remarks>
public sealed class TerminologyValidator
{
private readonly List<TerminologyRule> _rules;

/// <summary>
/// Initializes a new instance of the <see cref="TerminologyValidator"/> class.
/// </summary>
/// <param name="rules">The list of terminology rules to validate against.</param>
public TerminologyValidator(List<TerminologyRule> rules)
{
_rules = rules ?? new List<TerminologyRule>();
}

/// <summary>
/// Validates an identifier against all configured terminology rules.
/// </summary>
/// <param name="identifierName">The identifier name to validate.</param>
/// <returns>The violated <see cref="TerminologyRule"/> if a blocked term is found; otherwise, <c>null</c>.</returns>
public TerminologyRule GetViolation(string identifierName)
{
if (string.IsNullOrEmpty(identifierName) || _rules.Count == 0)
{
return null;
}

foreach (var rule in _rules)
{
var comparison = rule.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;

// Fast-path: if the identifier is exactly the preferred term, do not report
if (!string.IsNullOrEmpty(rule.Preferred) && string.Equals(identifierName, rule.Preferred, comparison))
{
continue;
}

var searchStart = 0;
while (true)
{
var idx = identifierName.IndexOf(rule.Blocked, searchStart, comparison);
if (idx < 0)
{
break;
}

// If this blocked occurrence aligns with the preferred term at the same position,
// treat it as allowed (e.g., "Customer" contains "Cust" at index 0 but is preferred)
if (!string.IsNullOrEmpty(rule.Preferred)
&& idx + rule.Preferred.Length <= identifierName.Length
&& identifierName.IndexOf(rule.Preferred, idx, comparison) == idx)
{
searchStart = idx + 1; // continue searching for other blocked occurrences
continue;
}

return rule;
}
}

return null;
}
}
Loading