diff --git a/docs/content/Optimizing Analyzers Using Ignores.md b/docs/content/Optimizing Analyzers Using Ignores.md new file mode 100644 index 0000000..8a0b1fb --- /dev/null +++ b/docs/content/Optimizing Analyzers Using Ignores.md @@ -0,0 +1,38 @@ +--- +category: end-users +categoryindex: 2 +index: 6 +--- + +# Optimizing Analyzers Using Ignores + +## Overview + +When writing analyzers, we often seen code which looks like this: + +```fsharp +[] +let analyzerEditorContext (ctx: EditorContext) = + handler ctx.TypedTree +``` + +The handler code will typically dig into walking the AST/TAST to analyze code and see if the conditions of the current analyzer are being met. + +## Using Ignore Ranges + +We can optimize our analyzer by checking if there are any ignore ranges with a File scope for the current analyzer. This allows us to skip running the analyzer entirely for files which have been marked to ignore all hits from this analyzer. We cannot skip analyzing files which have more granular ignore ranges (like line or region), since we need to walk the tree to see if any hits fall outside of those ranges. + +```fsharp +[] +let analyzerEditorContext (ctx: EditorContext) = + let ignoreRanges = ctx.AnalyzerIgnoreRanges |> Map.tryFind "your code here" + + match ignoreRanges with + | Some ranges -> + if ranges |> List.contains File then + async { return [] } + else + handler ctx.TypedTree + | None -> handler ctx.TypedTree +``` + diff --git a/docs/content/getting-started/Ignore Analyzer Hits.md b/docs/content/getting-started/Ignore Analyzer Hits.md new file mode 100644 index 0000000..b2ff85c --- /dev/null +++ b/docs/content/getting-started/Ignore Analyzer Hits.md @@ -0,0 +1,60 @@ +--- +category: getting-started +categoryindex: 1 +index: 5 +--- + +# Ignoring Analyzer Hits + +The FSharp.Analyzers.SDK supports suppressing analyzer warnings through special comments which define ignore ranges. This allows you to disable specific analyzers for certain code sections without modifying the analyzer configuration globally. + +## Comment Format + +The comment format follows this pattern: `prefix: command [codes]`. You can specify multiple codes with one comment by delimiting the codes with commas. For example: `fsharpanalyzer: ignore-line CODE1, CODE2`. + +## Current Line Ignore + +To ignore analyzer warnings on a single line, use a comment with the analyzer code: + +```fsharp +let someFunction () = + let option = Some 42 + option.Value // fsharpanalyzer: ignore-line OV001 +``` + +## Next Line Ignore + +To ignore analyzer warnings on a single line, use a comment with the analyzer code: + +```fsharp +let someFunction () = + let option = Some 42 + // fsharpanalyzer: ignore-line-next OV001 + option.Value +``` + +## Region Ignore + +To ignore analyzer warnings for a block of code, use start and end comments: + +```fsharp +// fsharpanalyzer: ignore-region-start OV001 +let someFunction () = + let option = Some 42 + option.Value +// fsharpanalyzer: ignore-region-end +``` + +## Ignore File + +To ignore all analyzer warnings in a file, place the following comment at the top of the file: + +```fsharp +// fsharpanalyzer: ignore-file OV001 +let someFunction () = + let option = Some 42 + option.Value +``` + +[Previous]({{fsdocs-previous-page-link}}) +[Next]({{fsdocs-next-page-link}}) \ No newline at end of file diff --git a/samples/OptionAnalyzer.Test/UnitTests.fs b/samples/OptionAnalyzer.Test/UnitTests.fs index c1f0266..1dd3736 100644 --- a/samples/OptionAnalyzer.Test/UnitTests.fs +++ b/samples/OptionAnalyzer.Test/UnitTests.fs @@ -5,6 +5,7 @@ open NUnit.Framework open FSharp.Compiler.Text open FSharp.Analyzers.SDK open FSharp.Analyzers.SDK.Testing +open FSharp.Analyzers.SDK let mutable projectOptions: FSharpProjectOptions = FSharpProjectOptions.zero @@ -80,3 +81,614 @@ let notUsed() = let! msgs = optionValueAnalyzer ctx Assert.IsTrue(msgs |> List.contains expectedMsg) } + +module IgnoreRangeTests = + + let tryCompareRanges code expected (results: Map) = + match Map.tryFind code results with + | None -> Assert.Fail(sprintf "Expected to find %s in result" code) + | Some ranges -> Assert.That(ranges, Is.EquivalentTo(expected)) + + [] + let ``get next line scoped ignore with one code`` () = + async { + let source = + """ + module M + // fsharpanalyzer: ignore-line-next IONIDE-001 + let x = 1 + """ + + let ctx = getContext projectOptions source + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-001" [ NextLine 3 ] + } + + [] + let ``get next line scoped ignore with multiple codes`` () = + async { + let source = + """ + module M + // fsharpanalyzer: ignore-line-next IONIDE-001, IONIDE-002 + let x = 1 + """ + + let ctx = getContext projectOptions source + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-001" [ NextLine 3 ] + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-002" [ NextLine 3 ] + } + + [] + let ``get current line scoped ignore with one code`` () = + async { + let source = + """ + module M + let x = 1 // fsharpanalyzer: ignore-line IONIDE-001 + """ + + let ctx = getContext projectOptions source + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-001" [ CurrentLine 3 ] + } + + [] + let ``get current line scoped ignore with multiple codes`` () = + async { + let source = + """ + module M + let x = 1 // fsharpanalyzer: ignore-line IONIDE-001, IONIDE-002 + """ + + let ctx = getContext projectOptions source + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-001" [ CurrentLine 3 ] + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-002" [ CurrentLine 3 ] + } + + [] + let ``get file scoped ignore`` () = + async { + let source = + """ + module M + // fsharpanalyzer: ignore-file IONIDE-001 + let x = 1 + """ + + let ctx = getContext projectOptions source + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-001" [ File ] + } + + [] + let ``get file scoped ignore with multiple codes`` () = + async { + let source = + """ + module M + // fsharpanalyzer: ignore-file IONIDE-001, IONIDE-002, IONIDE-003 + let x = 1 + """ + + let ctx = getContext projectOptions source + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-001" [ File ] + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-002" [ File ] + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-003" [ File ] + } + + [] + let ``get range scoped ignore`` () = + async { + let source = + """ + module M + // fsharpanalyzer: ignore-region-start IONIDE-001 + let x = 1 + // fsharpanalyzer: ignore-region-end + """ + + let ctx = getContext projectOptions source + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-001" [ Range(3, 5) ] + } + + [] + let ``get range scoped ignore with multiple codes`` () = + async { + let source = + """ + module M + // fsharpanalyzer: ignore-region-start IONIDE-001, IONIDE-002 + let x = 1 + // fsharpanalyzer: ignore-region-end + """ + + let ctx = getContext projectOptions source + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-001" [ Range(3, 5) ] + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-002" [ Range(3, 5) ] + } + + [] + let ``get range scoped ignore handles nested ignores`` () = + async { + let source = + """ + module M + // fsharpanalyzer: ignore-region-start IONIDE-001 + // fsharpanalyzer: ignore-region-start IONIDE-002 + let x = 1 + // fsharpanalyzer: ignore-region-end + // fsharpanalyzer: ignore-region-end + """ + + let ctx = getContext projectOptions source + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-001" [ Range(3, 7) ] + ctx.AnalyzerIgnoreRanges |> tryCompareRanges "IONIDE-002" [ Range(4, 6) ] + } + + [] + let ``ignores unclosed range scoped ignore`` () = + async { + let source = + """ + module M + // fsharpanalyzer: ignore-region-start IONIDE-001 + let x = 1 + """ + + let ctx = getContext projectOptions source + Assert.That(ctx.AnalyzerIgnoreRanges, Is.Empty) + } + + [] + let ``ignores unopened range scoped ignore`` () = + async { + let source = + """ + module M + let x = 1 + // fsharpanalyzer: ignore-region-end + """ + + let ctx = getContext projectOptions source + Assert.That(ctx.AnalyzerIgnoreRanges, Is.Empty) + } + + [] + let ``code can have multiple ranges for one code`` () = + async { + let source = + """ + // fsharpanalyzer: ignore-file IONIDE-001 + module M + // fsharpanalyzer: ignore-region-start IONIDE-001 + // fsharpanalyzer: ignore-line-next IONIDE-001 + let x = 1 + // fsharpanalyzer: ignore-region-end + """ + + let ctx = getContext projectOptions source + + ctx.AnalyzerIgnoreRanges + |> tryCompareRanges "IONIDE-001" [ File; NextLine 5; Range(4, 7) ] + } + + [] + let ``next line ignore handles tight spacing`` () = + async { + let source = + """ + module M + // fsharpanalyzer:ignore-line-next IONIDE-001 + let x = 1 + """ + + let ctx = getContext projectOptions source + Assert.That(ctx.AnalyzerIgnoreRanges, Is.Empty) + } + + [] + let ``next line ignore handles loose spacing`` () = + async { + let source = + """ + module M + // fsharpanalyzer : ignore-line-next IONIDE-001 + let x = 1 + """ + + let ctx = getContext projectOptions source + Assert.That(ctx.AnalyzerIgnoreRanges, Is.Empty) + } + + [] + let ``next line, multi-code ignore handles tight spacing`` () = + async { + let source = + """ + module M + // fsharpanalyzer:ignore-line-next IONIDE-001,IONIDE-002 + let x = 1 + """ + + let ctx = getContext projectOptions source + Assert.That(ctx.AnalyzerIgnoreRanges, Is.Empty) + } + + [] + let ``next line, multi-code ignore handles loose spacing`` () = + async { + let source = + """ + module M + // fsharpanalyzer : ignore-line-next IONIDE-001 , IONIDE-002 + let x = 1 + """ + + let ctx = getContext projectOptions source + Assert.That(ctx.AnalyzerIgnoreRanges, Is.Empty) + } + + [] + let ``file ignore handles tight spacing`` () = + async { + let source = + """ + module M + // fsharpanalyzer:ignore-file IONIDE-001 + let x = 1 + """ + + let ctx = getContext projectOptions source + Assert.That(ctx.AnalyzerIgnoreRanges, Is.Empty) + } + + [] + let ``file ignore handles loose spacing`` () = + async { + let source = + """ + module M + // fsharpanalyzer : ignore-file IONIDE-001 + let x = 1 + """ + + let ctx = getContext projectOptions source + Assert.That(ctx.AnalyzerIgnoreRanges, Is.Empty) + } + + [] + let ``range ignore handles tight spacing`` () = + async { + let source = + """ + module M + // fsharpanalyzer:ignore-region-start IONIDE-001 + let x = 1 + // fsharpanalyzer:ignore-region-end + """ + + let ctx = getContext projectOptions source + Assert.That(ctx.AnalyzerIgnoreRanges, Is.Empty) + } + + [] + let ``range ignore handles loose spacing`` () = + async { + let source = + """ + module M + // fsharpanalyzer : ignore-region-start IONIDE-001 + let x = 1 + // fsharpanalyzer : ignore-region-start + """ + + let ctx = getContext projectOptions source + Assert.That(ctx.AnalyzerIgnoreRanges, Is.Empty) + } + +module ClientTests = + + module RunAnalyzersSafelyTests = + + let mutable projectOptions: FSharpProjectOptions = FSharpProjectOptions.zero + + [] + let Setup () = + task { + let! opts = + mkOptionsFromProject + "net8.0" + [ + { + Name = "Newtonsoft.Json" + Version = "13.0.3" + } + { + Name = "Fantomas.FCS" + Version = "6.2.0" + } + ] + + projectOptions <- opts + } + + [] + let ``run analyzers safely captures messages`` () = + async { + let source = + """ + module M + + let notUsed() = + let option : Option = None + option.Value + """ + + let ctx = getContext projectOptions source + let client = new Client() + let path = System.IO.Path.GetFullPath(".") + let stats = client.LoadAnalyzers(path) + let! messages = client.RunAnalyzersSafely(ctx) + + Assert.That(stats.Analyzers, Is.Not.EqualTo 0) + + match List.tryHead messages with + | Some message -> + match message.Output with + | Ok msgs -> Assert.That(msgs, Is.Not.Empty) + | Error ex -> Assert.Fail(sprintf "Expected messages but got exception: %A" ex) + | None -> Assert.Fail("Expected at least one analyzer result") + } + + [] + let ``run analyzer safely ignores next line comment properly`` () = + async { + let source = + """ + module M + + let notUsed() = + let option : Option = None + // fsharpanalyzer: ignore-line-next OV001 + option.Value + """ + + let ctx = getContext projectOptions source + let client = new Client() + let path = System.IO.Path.GetFullPath(".") + let stats = client.LoadAnalyzers(path) + let! messages = client.RunAnalyzersSafely(ctx) + + Assert.That(stats.Analyzers, Is.Not.EqualTo 0) + + match List.tryHead messages with + | Some message -> + match message.Output with + | Ok msgs -> Assert.That(msgs, Is.Empty) + | Error ex -> Assert.Fail(sprintf "Expected no messages but got exception: %A" ex) + | None -> Assert.Fail("Expected at least one analyzer result") + } + + [] + let ``run analyzer safely ignores current line comment properly`` () = + async { + let source = + """ + module M + + let notUsed() = + let option : Option = None + option.Value // fsharpanalyzer: ignore-line OV001 + """ + + let ctx = getContext projectOptions source + let client = new Client() + let path = System.IO.Path.GetFullPath(".") + let stats = client.LoadAnalyzers(path) + let! messages = client.RunAnalyzersSafely(ctx) + + Assert.That(stats.Analyzers, Is.Not.EqualTo 0) + + match List.tryHead messages with + | Some message -> + match message.Output with + | Ok msgs -> Assert.That(msgs, Is.Empty) + | Error ex -> Assert.Fail(sprintf "Expected no messages but got exception: %A" ex) + | None -> Assert.Fail("Expected at least one analyzer result") + } + + [] + let ``run analyzer safely ignores file comment properly`` () = + async { + let source = + """ + // fsharpanalyzer: ignore-file OV001 + module M + + let notUsed() = + let option : Option = None + option.Value + """ + + let ctx = getContext projectOptions source + let client = new Client() + let path = System.IO.Path.GetFullPath(".") + let stats = client.LoadAnalyzers(path) + let! messages = client.RunAnalyzersSafely(ctx) + + Assert.That(stats.Analyzers, Is.Not.EqualTo 0) + + match List.tryHead messages with + | Some message -> + match message.Output with + | Ok msgs -> Assert.That(msgs, Is.Empty) + | Error ex -> Assert.Fail(sprintf "Expected no messages but got exception: %A" ex) + | None -> Assert.Fail("Expected at least one analyzer result") + } + + [] + let ``run analyzer safely ignores range comment properly`` () = + async { + let source = + """ + // fsharpanalyzer: ignore-region-start OV001 + module M + + let notUsed() = + let option : Option = None + option.Value + // fsharpanalyzer: ignore-region-end + """ + + let ctx = getContext projectOptions source + let client = new Client() + let path = System.IO.Path.GetFullPath(".") + let stats = client.LoadAnalyzers(path) + let! messages = client.RunAnalyzersSafely(ctx) + + Assert.That(stats.Analyzers, Is.Not.EqualTo 0) + + match List.tryHead messages with + | Some message -> + match message.Output with + | Ok msgs -> Assert.That(msgs, Is.Empty) + | Error ex -> Assert.Fail(sprintf "Expected no messages but got exception: %A" ex) + | None -> Assert.Fail("Expected at least one analyzer result") + } + + module RunAnalyzersTests = + + let mutable projectOptions: FSharpProjectOptions = FSharpProjectOptions.zero + + [] + let Setup () = + task { + let! opts = + mkOptionsFromProject + "net8.0" + [ + { + Name = "Newtonsoft.Json" + Version = "13.0.3" + } + { + Name = "Fantomas.FCS" + Version = "6.2.0" + } + ] + + projectOptions <- opts + } + + [] + let ``run analyzers captures messages`` () = + async { + let source = + """ + module M + + let notUsed() = + let option : Option = None + option.Value + """ + + let ctx = getContext projectOptions source + let client = new Client() + let path = System.IO.Path.GetFullPath(".") + let stats = client.LoadAnalyzers(path) + let! messages = client.RunAnalyzers(ctx) + + Assert.That(stats.Analyzers, Is.Not.EqualTo 0) + Assert.That(messages, Is.Not.Empty) + } + + [] + let ``run analyzer ignores next line comment properly`` () = + async { + let source = + """ + module M + + let notUsed() = + let option : Option = None + // fsharpanalyzer: ignore-line-next OV001 + option.Value + """ + + let ctx = getContext projectOptions source + let client = new Client() + let path = System.IO.Path.GetFullPath(".") + let stats = client.LoadAnalyzers(path) + let! messages = client.RunAnalyzers(ctx) + + Assert.That(stats.Analyzers, Is.Not.EqualTo 0) + Assert.That(messages, Is.Empty) + } + + [] + let ``run analyzer ignores current line comment properly`` () = + async { + let source = + """ + module M + + let notUsed() = + let option : Option = None + option.Value // fsharpanalyzer: ignore-line OV001 + """ + + let ctx = getContext projectOptions source + let client = new Client() + let path = System.IO.Path.GetFullPath(".") + let stats = client.LoadAnalyzers(path) + let! messages = client.RunAnalyzers(ctx) + + Assert.That(stats.Analyzers, Is.Not.EqualTo 0) + Assert.That(messages, Is.Empty) + } + + [] + let ``run analyzer ignores file comment properly`` () = + async { + let source = + """ + // fsharpanalyzer: ignore-file OV001 + module M + + let notUsed() = + let option : Option = None + option.Value + """ + + let ctx = getContext projectOptions source + let client = new Client() + let path = System.IO.Path.GetFullPath(".") + let stats = client.LoadAnalyzers(path) + let! messages = client.RunAnalyzers(ctx) + + Assert.That(stats.Analyzers, Is.Not.EqualTo 0) + Assert.That(messages, Is.Empty) + } + + [] + let ``run analyzer ignores range comment properly`` () = + async { + let source = + """ + // fsharpanalyzer: ignore-region-start OV001 + module M + + let notUsed() = + let option : Option = None + option.Value + // fsharpanalyzer: ignore-region-end + """ + + let ctx = getContext projectOptions source + let client = new Client() + let path = System.IO.Path.GetFullPath(".") + let stats = client.LoadAnalyzers(path) + let! messages = client.RunAnalyzers(ctx) + + Assert.That(stats.Analyzers, Is.Not.EqualTo 0) + Assert.That(messages, Is.Empty) + } \ No newline at end of file diff --git a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fs b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fs index 7cabfd8..53dc922 100644 --- a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fs +++ b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fs @@ -106,6 +106,30 @@ module Client = | None -> None | None -> None + let shouldIgnoreMessage (ctx: 'Context :> #Context) message = + match ctx.AnalyzerIgnoreRanges |> Map.tryFind message.Code with + | Some ignoreRanges -> + ignoreRanges + |> List.exists (function + | File -> true + | Range (commentStart, commentEnd) -> + if message.Range.StartLine - 1 >= commentStart && message.Range.EndLine - 1 <= commentEnd then + true + else + false + | NextLine line -> + if message.Range.StartLine - 1 = line then + true + else + false + | CurrentLine line -> + if message.Range.StartLine = line then + true + else + false + ) + | None -> false + let analyzersFromType<'TAnalyzerAttribute, 'TContext when 'TAnalyzerAttribute :> AnalyzerAttribute and 'TContext :> Context> (path: string) @@ -275,14 +299,20 @@ type Client<'TAttribute, 'TContext when 'TAttribute :> AnalyzerAttribute and 'TC return messages - |> List.map (fun message -> - { - Message = message - Name = registeredAnalyzer.Name - AssemblyPath = registeredAnalyzer.AssemblyPath - ShortDescription = registeredAnalyzer.ShortDescription - HelpUri = registeredAnalyzer.HelpUri - } + |> List.choose (fun message -> + let analyzerMessage = + { + Message = message + Name = registeredAnalyzer.Name + AssemblyPath = registeredAnalyzer.AssemblyPath + ShortDescription = registeredAnalyzer.ShortDescription + HelpUri = registeredAnalyzer.HelpUri + } + + if Client.shouldIgnoreMessage ctx message then + None + else + Some analyzerMessage ) } with error -> @@ -306,12 +336,16 @@ type Client<'TAttribute, 'TContext when 'TAttribute :> AnalyzerAttribute and 'TC |> Seq.map (fun registeredAnalyzer -> async { try - let! result = registeredAnalyzer.Analyzer ctx + let! messages = + registeredAnalyzer.Analyzer ctx + + let messages = + messages |> List.filter (Client.shouldIgnoreMessage ctx >> not) return { AnalyzerName = registeredAnalyzer.Name - Output = Result.Ok result + Output = Result.Ok messages } with error -> return diff --git a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fs b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fs index 33bbf60..3ff4c29 100644 --- a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fs +++ b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fs @@ -10,6 +10,105 @@ open FSharp.Compiler.EditorServices open System.Reflection open System.Runtime.InteropServices open FSharp.Compiler.Text +open FSharp.Compiler.SyntaxTrivia + +[] +type IgnoreComment = + | CurrentLine of line: int * codes: string list + | NextLine of line: int * codes: string list + | File of codes: string list + | RegionStart of startLine: int * codes: string list + | RegionEnd of endLine: int + +type AnalyzerIgnoreRange = + | File + | Range of commentStart: int * commentEnd: int + | NextLine of commentLine: int + | CurrentLine of commentLine: int + +module Ignore = + + open FSharp.Compiler.Syntax + open System.Text.RegularExpressions + + let getCodeComments input = + match input with + | ParsedInput.ImplFile parsedFileInput -> parsedFileInput.Trivia.CodeComments + | ParsedInput.SigFile parsedSigFileInput -> parsedSigFileInput.Trivia.CodeComments + + [] + let (|ParseRegexWithOptions|_|) options (pattern: string) (s: string) = + match Regex.Match(s, pattern, options) with + | m when m.Success -> List.tail [ for x in m.Groups -> x.Value ] |> ValueSome + | _ -> ValueNone + + [] + let (|ParseRegexCompiled|_|) = (|ParseRegexWithOptions|_|) RegexOptions.Compiled + + [] + let (|SplitBy|_|) x (text: string) = + text.Split(x) |> Array.toList |> ValueSome + + let trimCodes (codes: string list) = + codes |> List.map (fun s -> s.Trim()) + + let tryGetIgnoreComment splitBy (sourceText: ISourceText) (ct: CommentTrivia) = + match ct with + | CommentTrivia.BlockComment r + | CommentTrivia.LineComment r -> + // pattern to match is: + // prefix: command [codes] + match sourceText.GetLineString(r.StartLine - 1) with + | ParseRegexCompiled @"fsharpanalyzer:\signore-line-next\s(.*)$" [ SplitBy splitBy codes ] -> + Some <| IgnoreComment.NextLine(r.StartLine, trimCodes codes) + | ParseRegexCompiled @"fsharpanalyzer:\signore-line\s(.*)$" [ SplitBy splitBy codes ] -> + Some <| IgnoreComment.CurrentLine(r.StartLine, trimCodes codes) + | ParseRegexCompiled @"fsharpanalyzer:\signore-file\s(.*)$" [ SplitBy splitBy codes ] -> + Some <| IgnoreComment.File (trimCodes codes) + | ParseRegexCompiled @"fsharpanalyzer:\signore-region-start\s(.*)$" [ SplitBy splitBy codes ] -> + Some <| IgnoreComment.RegionStart(r.StartLine, trimCodes codes) + | ParseRegexCompiled @"fsharpanalyzer:\signore-region-end.*$" _ -> Some <| IgnoreComment.RegionEnd r.StartLine + | _ -> None + + let getIgnoreComments (sourceText: ISourceText) (comments: CommentTrivia list) = + comments + |> List.choose (tryGetIgnoreComment [| ',' |] sourceText) + + let getIgnoreRanges (ignoreComments: IgnoreComment list) : Map = + let mutable codeToRanges = Map.empty + + let addRangeForCodes (codes: string list) (range: AnalyzerIgnoreRange) = + for code in codes do + let existingRanges = Map.tryFind code codeToRanges |> Option.defaultValue [] + codeToRanges <- Map.add code (range :: existingRanges) codeToRanges + + let mutable rangeStack = [] + + for comment in ignoreComments do + match comment with + | IgnoreComment.File codes -> + addRangeForCodes codes File + + | IgnoreComment.NextLine (line, codes) -> + addRangeForCodes codes (NextLine line) + + | IgnoreComment.CurrentLine (line, codes) -> + addRangeForCodes codes (CurrentLine line) + + | IgnoreComment.RegionStart (startLine, codes) -> + rangeStack <- (startLine, codes) :: rangeStack + + | IgnoreComment.RegionEnd endLine -> + match rangeStack with + | [] -> + // Ignore END without matching START - do nothing + // to-do: create analyzer for finding unmatched END comments + () + | (startLine, codes) :: rest -> + rangeStack <- rest + addRangeForCodes codes (Range (startLine, endLine)) + + codeToRanges module EntityCache = let private entityCache = EntityCache() @@ -109,7 +208,8 @@ type EditorAnalyzerAttribute member _.Name = name -type Context = interface end +type Context = + abstract member AnalyzerIgnoreRanges: Map type AnalyzerProjectOptions = | BackgroundCompilerOptions of FSharpProjectOptions @@ -149,7 +249,6 @@ type AnalyzerProjectOptions = | BackgroundCompilerOptions(options) -> options.OtherOptions |> Array.toList | TransparentCompilerOptions(snapshot) -> snapshot.OtherOptions - type CliContext = { FileName: string @@ -159,9 +258,12 @@ type CliContext = TypedTree: FSharpImplementationFileContents option CheckProjectResults: FSharpCheckProjectResults ProjectOptions: AnalyzerProjectOptions + AnalyzerIgnoreRanges: Map } - interface Context + interface Context with + + member x.AnalyzerIgnoreRanges = x.AnalyzerIgnoreRanges member x.GetAllEntities(publicOnly: bool) = EntityCache.getEntities publicOnly x.CheckFileResults @@ -181,9 +283,12 @@ type EditorContext = TypedTree: FSharpImplementationFileContents option CheckProjectResults: FSharpCheckProjectResults option ProjectOptions: AnalyzerProjectOptions + AnalyzerIgnoreRanges: Map } - interface Context + interface Context with + + member x.AnalyzerIgnoreRanges = x.AnalyzerIgnoreRanges member x.GetAllEntities(publicOnly: bool) : AssemblySymbol list = match x.CheckFileResults with @@ -266,6 +371,11 @@ module Utils = TypedTree = checkFileResults.ImplementationFile CheckProjectResults = checkProjectResults ProjectOptions = projectOptions + AnalyzerIgnoreRanges = + parseFileResults.ParseTree + |> Ignore.getCodeComments + |> Ignore.getIgnoreComments sourceText + |> Ignore.getIgnoreRanges } let createFCS documentSource = diff --git a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fsi b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fsi index 261046e..e17396e 100644 --- a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fsi +++ b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fsi @@ -8,6 +8,12 @@ open FSharp.Compiler.Symbols open FSharp.Compiler.EditorServices open FSharp.Compiler.Text +type AnalyzerIgnoreRange = + | File + | Range of commentStart: int * commentEnd: int + | NextLine of commentLine: int + | CurrentLine of commentLine: int + [] [] type AnalyzerAttribute = @@ -45,7 +51,8 @@ type EditorAnalyzerAttribute = member Name: string /// Marker interface which both the CliContext and EditorContext implement -type Context = interface end +type Context = + abstract member AnalyzerIgnoreRanges: Map /// Options related to the project being analyzed. type AnalyzerProjectOptions = @@ -89,6 +96,8 @@ type CliContext = CheckProjectResults: FSharpCheckProjectResults /// Options related to the project being analyzed. ProjectOptions: AnalyzerProjectOptions + /// Ranges in the file to ignore for specific analyzers codes + AnalyzerIgnoreRanges: Map } interface Context @@ -123,6 +132,8 @@ type EditorContext = CheckProjectResults: FSharpCheckProjectResults option // Options related to the project being analyzed. ProjectOptions: AnalyzerProjectOptions + /// Ranges in the file to ignore for specific analyzers codes + AnalyzerIgnoreRanges: Map } interface Context