From 749f2cfa40072935d6e0df80316ccd42d9df73a3 Mon Sep 17 00:00:00 2001 From: Matt Lacey Date: Mon, 2 Dec 2024 15:43:16 +0000 Subject: [PATCH] provisional work to support sln level config files --- src/WarnAboutTODOs.Vsix/source.extension.cs | 2 +- .../source.extension.vsixmanifest | 2 +- src/WarnAboutTODOs/SolutionConfigFile.cs | 31 + src/WarnAboutTODOs/WarnAboutTODOsAnalyzer.cs | 592 ++++++++++-------- 4 files changed, 351 insertions(+), 276 deletions(-) create mode 100644 src/WarnAboutTODOs/SolutionConfigFile.cs diff --git a/src/WarnAboutTODOs.Vsix/source.extension.cs b/src/WarnAboutTODOs.Vsix/source.extension.cs index 5412e5e..1d95037 100644 --- a/src/WarnAboutTODOs.Vsix/source.extension.cs +++ b/src/WarnAboutTODOs.Vsix/source.extension.cs @@ -11,7 +11,7 @@ internal sealed partial class Vsix public const string Name = "Warn About TODOs"; public const string Description = @"Displays warnings for TODO notes in comments"; public const string Language = "en-US"; - public const string Version = "1.7.3"; + public const string Version = "1.7.3.0"; public const string Author = "Matt Lacey"; public const string Tags = ""; } diff --git a/src/WarnAboutTODOs.Vsix/source.extension.vsixmanifest b/src/WarnAboutTODOs.Vsix/source.extension.vsixmanifest index 5b6a7f9..e65b30a 100644 --- a/src/WarnAboutTODOs.Vsix/source.extension.vsixmanifest +++ b/src/WarnAboutTODOs.Vsix/source.extension.vsixmanifest @@ -1,7 +1,7 @@  - + Warn About TODOs Displays warnings for TODO notes in comments https://github.com/mrlacey/WarnAboutTodos diff --git a/src/WarnAboutTODOs/SolutionConfigFile.cs b/src/WarnAboutTODOs/SolutionConfigFile.cs new file mode 100644 index 0000000..0c4a674 --- /dev/null +++ b/src/WarnAboutTODOs/SolutionConfigFile.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) Matt Lacey Ltd. All rights reserved. +// + +using System.IO; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace WarnAboutTODOs +{ + internal class SolutionConfigFile : AdditionalText + { + private SolutionConfigFile(string path) + { + this.Path = path; + } + + public override string Path { get; } + + public static SolutionConfigFile FromFilePath(string configFileName) + { + return File.Exists(configFileName) ? new SolutionConfigFile(configFileName) : null; + } + + public override SourceText GetText(CancellationToken cancellationToken = default(CancellationToken)) + { + return SourceText.From(File.ReadAllText(this.Path)); + } + } +} diff --git a/src/WarnAboutTODOs/WarnAboutTODOsAnalyzer.cs b/src/WarnAboutTODOs/WarnAboutTODOsAnalyzer.cs index e91e01f..24b8257 100644 --- a/src/WarnAboutTODOs/WarnAboutTODOsAnalyzer.cs +++ b/src/WarnAboutTODOs/WarnAboutTODOsAnalyzer.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) Matt Lacey Ltd. All rights reserved. // @@ -14,280 +14,324 @@ namespace WarnAboutTODOs { - public abstract class WarnAboutTodosAnalyzer : DiagnosticAnalyzer - { - private const string Id = "TODO"; - private const string Title = "TODO"; - private const string MessageFormat = "{0}"; - private const string Category = "Task List"; - private const string HelpLinkUri = "https://github.com/mrlacey/WarnAboutTodos"; - - private static readonly DiagnosticDescriptor ErrorRule = new DiagnosticDescriptor( - Id, - Title, - MessageFormat, - Category, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - helpLinkUri: HelpLinkUri); - - private static readonly DiagnosticDescriptor WarningRule = new DiagnosticDescriptor( - Id, - Title, - MessageFormat, - Category, - DiagnosticSeverity.Warning, - isEnabledByDefault: true, - helpLinkUri: HelpLinkUri); - - private static readonly DiagnosticDescriptor InfoRule = new DiagnosticDescriptor( - Id, - Title, - MessageFormat, - Category, - DiagnosticSeverity.Info, - isEnabledByDefault: true, - helpLinkUri: HelpLinkUri); - - public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(ErrorRule, WarningRule, InfoRule); - - public override void Initialize(AnalysisContext context) - { - context.RegisterSyntaxTreeAction(this.HandleSyntaxTree); - } - - internal abstract void HandleSyntaxTree(SyntaxTreeAnalysisContext obj); - - protected WatConfig GetConfig(SyntaxTreeAnalysisContext context) - { - var result = new WatConfig(); - - (result.Terms, result.Exclusions) = this.GetTermsAndExclusions(context); - - return result; - } + public abstract class WarnAboutTodosAnalyzer : DiagnosticAnalyzer + { + private const string Id = "TODO"; + private const string Title = "TODO"; + private const string MessageFormat = "{0}"; + private const string Category = "Task List"; + private const string HelpLinkUri = "https://github.com/mrlacey/WarnAboutTodos"; + + private static readonly DiagnosticDescriptor ErrorRule = new DiagnosticDescriptor( + Id, + Title, + MessageFormat, + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: HelpLinkUri); + + private static readonly DiagnosticDescriptor WarningRule = new DiagnosticDescriptor( + Id, + Title, + MessageFormat, + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: HelpLinkUri); + + private static readonly DiagnosticDescriptor InfoRule = new DiagnosticDescriptor( + Id, + Title, + MessageFormat, + Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + helpLinkUri: HelpLinkUri); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(ErrorRule, WarningRule, InfoRule); + + public override void Initialize(AnalysisContext context) + { + context.RegisterSyntaxTreeAction(this.HandleSyntaxTree); + } + + internal abstract void HandleSyntaxTree(SyntaxTreeAnalysisContext obj); + + protected WatConfig GetConfig(SyntaxTreeAnalysisContext context) + { + var result = new WatConfig(); + + (result.Terms, result.Exclusions) = this.GetTermsAndExclusions(context); + + return result; + } #pragma warning disable SA1008 // Opening parenthesis must not be preceded by a space - protected (List, List) GetTermsAndExclusions(SyntaxTreeAnalysisContext context) + protected (List, List) GetTermsAndExclusions(SyntaxTreeAnalysisContext context) #pragma warning restore SA1008 // Opening parenthesis must not be preceded by a space - { - var terms = new List(); - var exclusions = new List(); - - const string configFileName = "todo-warn.config"; - var additionalFiles = context.Options.AdditionalFiles; - var termsFile = additionalFiles.FirstOrDefault(file => Path.GetFileName(file.Path).ToLowerInvariant().Equals(configFileName)) - ?? UserConfigFile.FromApplicationData(configFileName); - - Term CreateTerm(ReportLevel level, string line) - { - var result = new Term { ReportLevel = level }; - - const string startsGroup = "[STARTS("; - const string containsGroup = "[CONTAINS("; - const string notContainsGroup = "[DOESNOTCONTAIN("; - const string matchesRegexGroup = "[MATCHESREGEX("; - const string closeGroup = ")]"; - - if (line.StartsWith(startsGroup, StringComparison.OrdinalIgnoreCase)) - { - var closeIndex = line.IndexOf(closeGroup, StringComparison.Ordinal); - - if (closeIndex > 0) - { - var startsLen = startsGroup.Length; - - result.StartsWith = line.Substring(startsLen, closeIndex - startsLen); - line = line.Substring(closeIndex + closeGroup.Length); - } - } - - if (line.StartsWith(containsGroup, StringComparison.OrdinalIgnoreCase)) - { - var closeIndex = line.IndexOf(closeGroup, StringComparison.Ordinal); - - if (closeIndex > 0) - { - var containsLen = containsGroup.Length; - - result.Contains = line.Substring(containsLen, closeIndex - containsLen); - line = line.Substring(closeIndex + closeGroup.Length); - } - } - - if (line.StartsWith(notContainsGroup, StringComparison.OrdinalIgnoreCase)) - { - var closeIndex = line.IndexOf(closeGroup, StringComparison.Ordinal); - - if (closeIndex > 0) - { - var containsLen = notContainsGroup.Length; - - result.DoesNotContain = line.Substring(containsLen, closeIndex - containsLen); - line = line.Substring(closeIndex + closeGroup.Length); - } - } - - if (line.StartsWith(matchesRegexGroup, StringComparison.OrdinalIgnoreCase)) - { - var closeIndex = line.IndexOf(closeGroup, StringComparison.Ordinal); - - if (closeIndex > 0) - { - var containsLen = matchesRegexGroup.Length; - - result.MatchesRegex = line.Substring(containsLen, closeIndex - containsLen); - line = line.Substring(closeIndex + closeGroup.Length); - } - } - - if (!string.IsNullOrWhiteSpace(line) && - string.IsNullOrWhiteSpace(result.StartsWith) && - string.IsNullOrWhiteSpace(result.Contains) && - string.IsNullOrWhiteSpace(result.DoesNotContain) && - string.IsNullOrWhiteSpace(result.MatchesRegex)) - { - result.StartsWith = line; - } - - return result; - } - - if (termsFile != null) - { - var termsFileContents = termsFile.GetText(context.CancellationToken); - - const string errorIndicator = "[ERROR]"; - const string infoIndicator = "[INFO]"; - const string warningIndicator = "[WARN]"; - const string exclusionIndicator = "[EXCLUDE]"; - - foreach (var line in termsFileContents.Lines) - { - var lineText = line.ToString(); - - if (lineText.StartsWith(exclusionIndicator, StringComparison.OrdinalIgnoreCase)) - { - exclusions.Add(lineText.Substring(exclusionIndicator.Length).Trim()); - } - else if (lineText.StartsWith(errorIndicator, StringComparison.OrdinalIgnoreCase)) - { - terms.Add(CreateTerm(ReportLevel.Error, lineText.Substring(errorIndicator.Length))); - } - else if (lineText.StartsWith(infoIndicator, StringComparison.OrdinalIgnoreCase)) - { - terms.Add(CreateTerm(ReportLevel.Info, lineText.Substring(infoIndicator.Length))); - } - else if (lineText.StartsWith(warningIndicator, StringComparison.OrdinalIgnoreCase)) - { - terms.Add(CreateTerm(ReportLevel.Warning, lineText.Substring(warningIndicator.Length))); - } - else if (!string.IsNullOrWhiteSpace(line.ToString())) - { - terms.Add(CreateTerm(ReportLevel.Warning, lineText)); - } - } - } - - if (!terms.Any()) - { - terms.Add(Term.Default); - } - - return (terms, exclusions); - } - - protected void ReportIfUsesTerms(string comment, List terms, SyntaxTreeAnalysisContext context, Location location, int startOffset = -1) - { - var displayComment = string.Empty; - var displayOffset = 0; - - foreach (var term in terms) - { - var report = false; - - if (!string.IsNullOrWhiteSpace(term.StartsWith)) - { - if (comment.ToLowerInvariant().StartsWith(term.StartsWith.ToLowerInvariant())) - { - displayComment = comment.Substring(term.StartsWith.Length).TrimStart(' ', ':'); - displayOffset = comment.IndexOf(term.StartsWith, StringComparison.OrdinalIgnoreCase); - report = true; - } - else - { - continue; - } - } - - if (!string.IsNullOrWhiteSpace(term.Contains)) - { - if (comment.ToLowerInvariant().Contains(term.Contains.ToLowerInvariant())) - { - report = true; - } - else - { - continue; - } - } - - if (!string.IsNullOrWhiteSpace(term.DoesNotContain)) - { - if (!comment.ToLowerInvariant().Contains(term.DoesNotContain.ToLowerInvariant())) - { - report = true; - } - else - { - continue; - } - } - - if (!string.IsNullOrWhiteSpace(term.MatchesRegex)) - { - if (new Regex(term.MatchesRegex).IsMatch(comment)) - { - report = true; - } - else - { - continue; - } - } - - if (report) - { - if (string.IsNullOrWhiteSpace(displayComment)) - { - displayComment = comment; - } - - var locationToUse = location; - - if (startOffset >= 0) - { - locationToUse = Location.Create( - location.SourceTree, - new TextSpan(startOffset + displayOffset, comment.Length - displayOffset)); - } - - switch (term.ReportLevel) - { - case ReportLevel.Warning: - context.ReportDiagnostic(Diagnostic.Create(WarningRule, locationToUse, displayComment)); - break; - case ReportLevel.Error: - context.ReportDiagnostic(Diagnostic.Create(ErrorRule, locationToUse, displayComment)); - break; - case ReportLevel.Info: - context.ReportDiagnostic(Diagnostic.Create(InfoRule, locationToUse, displayComment)); - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - } - } - } + { + var terms = new List(); + var exclusions = new List(); + + const string configFileName = "todo-warn.config"; + var additionalFiles = context.Options.AdditionalFiles; + var termsFile = additionalFiles.FirstOrDefault(file => Path.GetFileName(file.Path).ToLowerInvariant().Equals(configFileName)); + + // If no file in the project, look for one in the same directory as the sln file + // TODO: See if can cache the results of this to avoid excessive disk IO + if (termsFile is null) + { + var currentFile = context.Tree.FilePath; + + if (currentFile is not null) + { + var foundSlnFile = false; + + var dirOfInterest = Path.GetDirectoryName(currentFile); + + while (!foundSlnFile) + { + if (Directory.GetFiles(dirOfInterest, "*.sln").Any()) + { + foundSlnFile = true; + if (File.Exists(Path.Combine(dirOfInterest, configFileName))) + { + termsFile = SolutionConfigFile.FromFilePath(Path.Combine(dirOfInterest, configFileName)); + } + break; + } + else + { + dirOfInterest = Path.GetDirectoryName(dirOfInterest); + } + + // if at root of drive or no more directories to check + if (Path.GetPathRoot(dirOfInterest) == dirOfInterest + || string.IsNullOrWhiteSpace(dirOfInterest) + || dirOfInterest.Length <= 3) + { + break; + } + } + } + } + + // If still not found a config file, look in the user's app data folder + if (termsFile is null) + { + termsFile = UserConfigFile.FromApplicationData(configFileName); + } + + Term CreateTerm(ReportLevel level, string line) + { + var result = new Term { ReportLevel = level }; + + const string startsGroup = "[STARTS("; + const string containsGroup = "[CONTAINS("; + const string notContainsGroup = "[DOESNOTCONTAIN("; + const string matchesRegexGroup = "[MATCHESREGEX("; + const string closeGroup = ")]"; + + if (line.StartsWith(startsGroup, StringComparison.OrdinalIgnoreCase)) + { + var closeIndex = line.IndexOf(closeGroup, StringComparison.Ordinal); + + if (closeIndex > 0) + { + var startsLen = startsGroup.Length; + + result.StartsWith = line.Substring(startsLen, closeIndex - startsLen); + line = line.Substring(closeIndex + closeGroup.Length); + } + } + + if (line.StartsWith(containsGroup, StringComparison.OrdinalIgnoreCase)) + { + var closeIndex = line.IndexOf(closeGroup, StringComparison.Ordinal); + + if (closeIndex > 0) + { + var containsLen = containsGroup.Length; + + result.Contains = line.Substring(containsLen, closeIndex - containsLen); + line = line.Substring(closeIndex + closeGroup.Length); + } + } + + if (line.StartsWith(notContainsGroup, StringComparison.OrdinalIgnoreCase)) + { + var closeIndex = line.IndexOf(closeGroup, StringComparison.Ordinal); + + if (closeIndex > 0) + { + var containsLen = notContainsGroup.Length; + + result.DoesNotContain = line.Substring(containsLen, closeIndex - containsLen); + line = line.Substring(closeIndex + closeGroup.Length); + } + } + + if (line.StartsWith(matchesRegexGroup, StringComparison.OrdinalIgnoreCase)) + { + var closeIndex = line.IndexOf(closeGroup, StringComparison.Ordinal); + + if (closeIndex > 0) + { + var containsLen = matchesRegexGroup.Length; + + result.MatchesRegex = line.Substring(containsLen, closeIndex - containsLen); + line = line.Substring(closeIndex + closeGroup.Length); + } + } + + if (!string.IsNullOrWhiteSpace(line) && + string.IsNullOrWhiteSpace(result.StartsWith) && + string.IsNullOrWhiteSpace(result.Contains) && + string.IsNullOrWhiteSpace(result.DoesNotContain) && + string.IsNullOrWhiteSpace(result.MatchesRegex)) + { + result.StartsWith = line; + } + + return result; + } + + if (termsFile != null) + { + var termsFileContents = termsFile.GetText(context.CancellationToken); + + const string errorIndicator = "[ERROR]"; + const string infoIndicator = "[INFO]"; + const string warningIndicator = "[WARN]"; + const string exclusionIndicator = "[EXCLUDE]"; + + foreach (var line in termsFileContents.Lines) + { + var lineText = line.ToString(); + + if (lineText.StartsWith(exclusionIndicator, StringComparison.OrdinalIgnoreCase)) + { + exclusions.Add(lineText.Substring(exclusionIndicator.Length).Trim()); + } + else if (lineText.StartsWith(errorIndicator, StringComparison.OrdinalIgnoreCase)) + { + terms.Add(CreateTerm(ReportLevel.Error, lineText.Substring(errorIndicator.Length))); + } + else if (lineText.StartsWith(infoIndicator, StringComparison.OrdinalIgnoreCase)) + { + terms.Add(CreateTerm(ReportLevel.Info, lineText.Substring(infoIndicator.Length))); + } + else if (lineText.StartsWith(warningIndicator, StringComparison.OrdinalIgnoreCase)) + { + terms.Add(CreateTerm(ReportLevel.Warning, lineText.Substring(warningIndicator.Length))); + } + else if (!string.IsNullOrWhiteSpace(line.ToString())) + { + terms.Add(CreateTerm(ReportLevel.Warning, lineText)); + } + } + } + + if (!terms.Any()) + { + terms.Add(Term.Default); + } + + return (terms, exclusions); + } + + protected void ReportIfUsesTerms(string comment, List terms, SyntaxTreeAnalysisContext context, Location location, int startOffset = -1) + { + var displayComment = string.Empty; + var displayOffset = 0; + + foreach (var term in terms) + { + var report = false; + + if (!string.IsNullOrWhiteSpace(term.StartsWith)) + { + if (comment.ToLowerInvariant().StartsWith(term.StartsWith.ToLowerInvariant())) + { + displayComment = comment.Substring(term.StartsWith.Length).TrimStart(' ', ':'); + displayOffset = comment.IndexOf(term.StartsWith, StringComparison.OrdinalIgnoreCase); + report = true; + } + else + { + continue; + } + } + + if (!string.IsNullOrWhiteSpace(term.Contains)) + { + if (comment.ToLowerInvariant().Contains(term.Contains.ToLowerInvariant())) + { + report = true; + } + else + { + continue; + } + } + + if (!string.IsNullOrWhiteSpace(term.DoesNotContain)) + { + if (!comment.ToLowerInvariant().Contains(term.DoesNotContain.ToLowerInvariant())) + { + report = true; + } + else + { + continue; + } + } + + if (!string.IsNullOrWhiteSpace(term.MatchesRegex)) + { + if (new Regex(term.MatchesRegex).IsMatch(comment)) + { + report = true; + } + else + { + continue; + } + } + + if (report) + { + if (string.IsNullOrWhiteSpace(displayComment)) + { + displayComment = comment; + } + + var locationToUse = location; + + if (startOffset >= 0) + { + locationToUse = Location.Create( + location.SourceTree, + new TextSpan(startOffset + displayOffset, comment.Length - displayOffset)); + } + + switch (term.ReportLevel) + { + case ReportLevel.Warning: + context.ReportDiagnostic(Diagnostic.Create(WarningRule, locationToUse, displayComment)); + break; + case ReportLevel.Error: + context.ReportDiagnostic(Diagnostic.Create(ErrorRule, locationToUse, displayComment)); + break; + case ReportLevel.Info: + context.ReportDiagnostic(Diagnostic.Create(InfoRule, locationToUse, displayComment)); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + } + } + } }