diff --git a/.github/workflows/dotnet.yaml b/.github/workflows/dotnet.yaml index 68f2502..0dfe0ce 100644 --- a/.github/workflows/dotnet.yaml +++ b/.github/workflows/dotnet.yaml @@ -124,6 +124,27 @@ jobs: # Verify that there is exactly one nupkg in the artifact that would be NuGet published run: if [[ $(find packed -maxdepth 1 -name 'WoofWare.Expect.*.nupkg' -printf c | wc -c) -ne "1" ]]; then exit 1; fi + analyzers: + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # so that NerdBank.GitVersioning has access to history + - name: Install Nix + uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + - name: Prepare analyzers + run: nix develop --command dotnet restore analyzers/analyzers.fsproj + - name: Build project + run: nix develop --command dotnet build ./WoofWare.Expect/WoofWare.Expect.fsproj + - name: Run analyzers + run: nix run .#fsharp-analyzers -- --project ./WoofWare.Expect/WoofWare.Expect.fsproj --analyzers-path ./.analyzerpackages/g-research.fsharp.analyzers/*/ --verbosity detailed --report ./analysis.sarif --treat-as-error GRA-STRING-001 GRA-STRING-002 GRA-STRING-003 GRA-UNIONCASE-001 GRA-INTERPOLATED-001 GRA-TYPE-ANNOTATE-001 GRA-VIRTUALCALL-001 GRA-IMMUTABLECOLLECTIONEQUALITY-001 GRA-JSONOPTS-001 GRA-LOGARGFUNCFULLAPP-001 GRA-DISPBEFOREASYNC-001 --exclude-analyzers PartialAppAnalyzer + github-release-dry-run: runs-on: ubuntu-latest needs: [nuget-pack] @@ -167,7 +188,7 @@ jobs: all-required-checks-complete: if: ${{ always() }} - needs: [check-dotnet-format, check-nix-format, build, build-nix, flake-check, expected-pack, linkcheck] + needs: [check-dotnet-format, check-nix-format, build, build-nix, flake-check, expected-pack, linkcheck, analyzers] runs-on: ubuntu-latest steps: - uses: G-Research/common-actions/check-required-lite@2b7dc49cb14f3344fbe6019c14a31165e258c059 diff --git a/README.md b/README.md index 08e4298..64c23ad 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ An [expect-testing](https://blog.janestreet.com/the-joy-of-expect-tests/) librar # Current status -Basic mechanism works, but I haven't yet decided how the ergonomic updating of the input text will work. -Ideally it would edit the input AST, but I don't yet know if that's viable. +The basic mechanism works. +Snapshot updating is vibe-coded with Opus 4 and is purely text-based; I didn't want to use the F# compiler services because that's a pretty heavyweight dependency which should be confined to a separate test runner entity. +It's not very well tested, and I expect it to be kind of brittle. # How to use @@ -35,6 +36,10 @@ let ``This test fails: plain text comparison of ToString`` () = } ``` +# Limitations + +* The snapshot updating mechanism *requires* you to use verbatim string literals. While the test assertions will work correctly if you do `snapshot ("foo" + "bar" + f 3)`, for example, the updating code is liable to do something undefined in that case. Also do not use format strings (`$"blah"`). + # Licence MIT. diff --git a/WoofWare.Expect.Test/Assembly.fs b/WoofWare.Expect.Test/Assembly.fs new file mode 100644 index 0000000..ca32f74 --- /dev/null +++ b/WoofWare.Expect.Test/Assembly.fs @@ -0,0 +1,22 @@ +namespace WoofWare.Expect.Test + +open System +open System.IO +open System.Reflection + +[] +module Assembly = + + let getEmbeddedResource (assembly : Assembly) (name : string) : string = + let names = assembly.GetManifestResourceNames () + + let names = + names |> Seq.filter (fun s -> s.EndsWith (name, StringComparison.Ordinal)) + + use s = + names + |> Seq.exactlyOne + |> assembly.GetManifestResourceStream + |> fun s -> new StreamReader (s) + + s.ReadToEnd () diff --git a/WoofWare.Expect.Test/SimpleTest.fs b/WoofWare.Expect.Test/SimpleTest.fs index d5c5acc..d8131b9 100644 --- a/WoofWare.Expect.Test/SimpleTest.fs +++ b/WoofWare.Expect.Test/SimpleTest.fs @@ -1,6 +1,5 @@ -namespace WoofWare.Expect.Test +namespace WoofWare.Expect.Test -open System open WoofWare.Expect open NUnit.Framework @@ -9,7 +8,7 @@ module SimpleTest = [] let ``JSON is resilient to whitespace changes`` () = expect { - snapshotJson "123 " + snapshotJson " 123 " return 123 } @@ -17,7 +16,7 @@ module SimpleTest = let ``Example of a failing test`` () = expect { snapshot - "snapshot mismatch! snapshot at filepath.fs:99 (Example of a failing test) was: + @"snapshot mismatch! snapshot at filepath.fs:99 (Example of a failing test) was: - 123 @@ -39,6 +38,6 @@ actual was: [] let ``Basic example`` () = expect { - snapshot "123" + snapshot @"123" return 123 } diff --git a/WoofWare.Expect.Test/SyntaxCases/AtStringOneLine.fs b/WoofWare.Expect.Test/SyntaxCases/AtStringOneLine.fs new file mode 100644 index 0000000..75ffa7d --- /dev/null +++ b/WoofWare.Expect.Test/SyntaxCases/AtStringOneLine.fs @@ -0,0 +1,10 @@ +namespace BigExample + +open WoofWare.Expect + +module MyModule = + let foo () = + expect { + snapshot @"test ""quotes"" here" + return 123 + } diff --git a/WoofWare.Expect.Test/SyntaxCases/SingleQuoteManyLine.fs b/WoofWare.Expect.Test/SyntaxCases/SingleQuoteManyLine.fs new file mode 100644 index 0000000..2855bef --- /dev/null +++ b/WoofWare.Expect.Test/SyntaxCases/SingleQuoteManyLine.fs @@ -0,0 +1,14 @@ +namespace BigExample + +open WoofWare.Expect + +module MyModule = + let foo () = + expect { + snapshot + "test +with +newlines" + + return 123 + } diff --git a/WoofWare.Expect.Test/SyntaxCases/TripleQuoteInterveningComment.fs b/WoofWare.Expect.Test/SyntaxCases/TripleQuoteInterveningComment.fs new file mode 100644 index 0000000..6437b6d --- /dev/null +++ b/WoofWare.Expect.Test/SyntaxCases/TripleQuoteInterveningComment.fs @@ -0,0 +1,13 @@ +namespace BigExample + +open WoofWare.Expect + +module MyModule = + let foo () = + expect { + snapshot (* comment *) + """test +""" + + return 123 + } diff --git a/WoofWare.Expect.Test/SyntaxCases/TripleQuoteOneLine.fs b/WoofWare.Expect.Test/SyntaxCases/TripleQuoteOneLine.fs new file mode 100644 index 0000000..8db37f6 --- /dev/null +++ b/WoofWare.Expect.Test/SyntaxCases/TripleQuoteOneLine.fs @@ -0,0 +1,10 @@ +namespace BigExample + +open WoofWare.Expect + +module MyModule = + let foo () = + expect { + snapshot """test""" + return 123 + } diff --git a/WoofWare.Expect.Test/TestSnapshotFinding.fs b/WoofWare.Expect.Test/TestSnapshotFinding.fs new file mode 100644 index 0000000..c859835 --- /dev/null +++ b/WoofWare.Expect.Test/TestSnapshotFinding.fs @@ -0,0 +1,213 @@ +namespace WoofWare.Expect.Test + +open WoofWare.Expect +open NUnit.Framework + +[] +module TestSnapshotFinding = + + type Dummy = class end + + [] + let ``Triple-quote, one line, one-line replacement`` () = + let source = + Assembly.getEmbeddedResource typeof.Assembly "TripleQuoteOneLine.fs" + |> _.Split('\n') + + expect { + snapshot + @"namespace BigExample + +open WoofWare.Expect + +module MyModule = + let foo () = + expect { + snapshot @""replacement"" + return 123 + } +" + + return SnapshotUpdate.updateSnapshotAtLine source 8 "replacement" |> String.concat "\n" + } + + [] + let ``Triple-quote, one line, multi-line replacement`` () = + let source = + Assembly.getEmbeddedResource typeof.Assembly "TripleQuoteOneLine.fs" + |> _.Split('\n') + + expect { + snapshot + @"namespace BigExample + +open WoofWare.Expect + +module MyModule = + let foo () = + expect { + snapshot @""replacement +more"" + return 123 + } +" + + return + SnapshotUpdate.updateSnapshotAtLine source 8 "replacement\nmore" + |> String.concat "\n" + } + + [] + let ``At-string, one line, one-line replacement`` () = + let source = + Assembly.getEmbeddedResource typeof.Assembly "AtStringOneLine.fs" + |> _.Split('\n') + + expect { + snapshot + @"namespace BigExample + +open WoofWare.Expect + +module MyModule = + let foo () = + expect { + snapshot @""replacement"" + return 123 + } +" + + return SnapshotUpdate.updateSnapshotAtLine source 8 "replacement" |> String.concat "\n" + } + + [] + let ``At-string, one line, multi-line replacement`` () = + let source = + Assembly.getEmbeddedResource typeof.Assembly "AtStringOneLine.fs" + |> _.Split('\n') + + expect { + snapshot + @"namespace BigExample + +open WoofWare.Expect + +module MyModule = + let foo () = + expect { + snapshot @""replacement +more"" + return 123 + } +" + + return + SnapshotUpdate.updateSnapshotAtLine source 8 "replacement\nmore" + |> String.concat "\n" + } + + [] + let ``Triple-quote, intervening comment, one-line replacement`` () = + let source = + Assembly.getEmbeddedResource typeof.Assembly "TripleQuoteInterveningComment.fs" + |> _.Split('\n') + + expect { + snapshot + @"namespace BigExample + +open WoofWare.Expect + +module MyModule = + let foo () = + expect { + snapshot (* comment *) + @""replacement"" + + return 123 + } +" + + return SnapshotUpdate.updateSnapshotAtLine source 8 "replacement" |> String.concat "\n" + } + + [] + let ``Triple-quote, intervening comment, multi-line replacement`` () = + let source = + Assembly.getEmbeddedResource typeof.Assembly "TripleQuoteInterveningComment.fs" + |> _.Split('\n') + + expect { + snapshot + @"namespace BigExample + +open WoofWare.Expect + +module MyModule = + let foo () = + expect { + snapshot (* comment *) + @""replacement +more"" + + return 123 + } +" + + return + SnapshotUpdate.updateSnapshotAtLine source 8 "replacement\nmore" + |> String.concat "\n" + } + + [] + let ``Single-quote, many lines, one-line replacement`` () = + let source = + Assembly.getEmbeddedResource typeof.Assembly "SingleQuoteManyLine.fs" + |> _.Split('\n') + + expect { + snapshot + @"namespace BigExample + +open WoofWare.Expect + +module MyModule = + let foo () = + expect { + snapshot + @""replacement"" + + return 123 + } +" + + return SnapshotUpdate.updateSnapshotAtLine source 8 "replacement" |> String.concat "\n" + } + + [] + let ``Single-quote, many lines, multi-line replacement`` () = + let source = + Assembly.getEmbeddedResource typeof.Assembly "SingleQuoteManyLine.fs" + |> _.Split('\n') + + expect { + snapshot + @"namespace BigExample + +open WoofWare.Expect + +module MyModule = + let foo () = + expect { + snapshot + @""replacement +more"" + + return 123 + } +" + + return + SnapshotUpdate.updateSnapshotAtLine source 8 "replacement\nmore" + |> String.concat "\n" + } diff --git a/WoofWare.Expect.Test/WoofWare.Expect.Test.fsproj b/WoofWare.Expect.Test/WoofWare.Expect.Test.fsproj index 9421e76..c322082 100644 --- a/WoofWare.Expect.Test/WoofWare.Expect.Test.fsproj +++ b/WoofWare.Expect.Test/WoofWare.Expect.Test.fsproj @@ -7,12 +7,19 @@ - - + + + + + + + + + diff --git a/WoofWare.Expect/AssemblyInfo.fs b/WoofWare.Expect/AssemblyInfo.fs new file mode 100644 index 0000000..0acc474 --- /dev/null +++ b/WoofWare.Expect/AssemblyInfo.fs @@ -0,0 +1,6 @@ +module internal WoofWare.Expect.AssemblyInfo + +open System.Runtime.CompilerServices + +[] +do () diff --git a/WoofWare.Expect/Library.fs b/WoofWare.Expect/Builder.fs similarity index 70% rename from WoofWare.Expect/Library.fs rename to WoofWare.Expect/Builder.fs index b5b39b4..54d32b1 100644 --- a/WoofWare.Expect/Library.fs +++ b/WoofWare.Expect/Builder.fs @@ -1,5 +1,6 @@ namespace WoofWare.Expect +open System.IO open System.Runtime.CompilerServices open System.Text.Json open System.Text.Json.Serialization @@ -35,12 +36,33 @@ module private Text = let predent (c : char) (s : string) = s.Split '\n' |> Seq.map (sprintf "%c %s" c) |> String.concat "\n" +/// Specify how the Expect computation expression treats failures. +/// You probably don't want to use this directly; use the computation expression definitions +/// like expect in the Builder module instead. +type Mode = + private + | Assert + | Update + | AssertMockingSource of (string * int) + /// /// The builder which powers WoofWare.Expect. /// /// You're not expected to construct this explicitly; it's a computation expression, available as Builder.expect. +/// When running the tests, instead of throwing an exception on failure, update the snapshot. /// Override the file path and line numbers reported in snapshots, so that your tests can be fully stable even on failure. (You almost certainly don't want to set this.) -type ExpectBuilder (?sourceOverride : string * int) = +type ExpectBuilder (mode : Mode) = + member private this.Mode = Unchecked.defaultof + + new (sourceOverride : string * int) = ExpectBuilder (Mode.AssertMockingSource sourceOverride) + + new (update : bool) + = + if update then + ExpectBuilder Mode.Update + else + ExpectBuilder Mode.Assert + /// Combine two `ExpectState`s. The first one is the "expected" snapshot; the second is the "actual". member _.Bind (state : ExpectState, f : unit -> ExpectState<'U>) @@ -155,6 +177,35 @@ type ExpectBuilder (?sourceOverride : string * int) = match state.Snapshot, state.Actual with | Some (snapshot, source), Some actual -> + let raiseError (snapshot : string) (actual : string) : unit = + match mode with + | Mode.AssertMockingSource (mockSource, line) -> + sprintf + "snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s" + mockSource + line + source.MemberName + (snapshot |> Text.predent '-') + (actual |> Text.predent '+') + |> ExpectException + |> raise + | Mode.Assert -> + sprintf + "snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s" + source.FilePath + source.LineNumber + source.MemberName + (snapshot |> Text.predent '-') + (actual |> Text.predent '+') + |> ExpectException + |> raise + | Mode.Update -> + let lines = File.ReadAllLines source.FilePath + let oldContents = String.concat "\n" lines + let lines = SnapshotUpdate.updateSnapshotAtLine lines source.LineNumber actual + File.WriteAllLines (source.FilePath, lines) + failwith ("Snapshot successfully updated. Previous contents:\n" + oldContents) + match snapshot with | SnapshotValue.Json snapshot -> let canonicalSnapshot = JsonDocument.Parse snapshot @@ -163,29 +214,25 @@ type ExpectBuilder (?sourceOverride : string * int) = JsonSerializer.Serialize (actual, options) |> JsonDocument.Parse if not (JsonElement.DeepEquals (canonicalActual.RootElement, canonicalSnapshot.RootElement)) then - sprintf - "snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s" - (sourceOverride |> Option.map fst |> Option.defaultValue source.FilePath) - (sourceOverride |> Option.map snd |> Option.defaultValue source.LineNumber) - source.MemberName - (canonicalSnapshot.RootElement.ToString () |> Text.predent '-') - (canonicalActual.RootElement.ToString () |> Text.predent '-') - |> ExpectException - |> raise + raiseError (canonicalSnapshot.RootElement.ToString ()) (canonicalActual.RootElement.ToString ()) + else + match mode with + | Mode.Update -> + failwith + "Snapshot assertion passed, but we are in snapshot-updating mode. Use the `expect` builder instead of `expect'` to assert the contents of a snapshot." + | _ -> () | SnapshotValue.BareString snapshot -> let actual = actual.ToString () if actual <> snapshot then - sprintf - "snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s" - (sourceOverride |> Option.map fst |> Option.defaultValue source.FilePath) - (sourceOverride |> Option.map snd |> Option.defaultValue source.LineNumber) - source.MemberName - (snapshot |> Text.predent '-') - (actual |> Text.predent '+') - |> ExpectException - |> raise + raiseError snapshot actual + else + match mode with + | Mode.Update -> + failwith + "Snapshot assertion passed, but we are in snapshot-updating mode. Use the `expect` builder instead of `expect'` to assert the contents of a snapshot." + | _ -> () | None, _ -> failwith "Must specify snapshot" | _, None -> failwith "Must specify actual value with 'return'" @@ -207,7 +254,32 @@ module Builder = /// /// (That example expectation will fail, because the actual value 124 does not snapshot to the expected snapshot "123".) /// - let expect = ExpectBuilder () + let expect = ExpectBuilder false + + /// The WoofWare.Expect builder, but in "replace snapshot on failure" mode. + /// + /// + /// Take an existing failing snapshot test: + /// + /// + /// expect { + /// snapshot "123" + /// return 124 + /// } + /// + /// + /// Add the ' marker to the expect builder: + /// + /// expect' { + /// snapshot "123" + /// return 124 + /// } + /// + /// + /// Rerun, and observe that the snapshot becomes updated. + /// This rerun will throw an exception, to help make sure you don't commit the snapshot builder while it's flipped to "update" mode. + /// + let expect' = ExpectBuilder true /// /// This is the `expect` builder, but it mocks out the filepath reported on failure. diff --git a/WoofWare.Expect/SnapshotUpdate.fs b/WoofWare.Expect/SnapshotUpdate.fs new file mode 100644 index 0000000..bdda4b7 --- /dev/null +++ b/WoofWare.Expect/SnapshotUpdate.fs @@ -0,0 +1,272 @@ +namespace WoofWare.Expect + +open System +open System.Text.RegularExpressions + +type private StringLiteralInfo = + { + StartLine : int + StartColumn : int + EndLine : int + EndColumn : int + Content : string + } + + override this.ToString () = + sprintf "%i:%i to %i:%i: %s" this.StartLine this.StartColumn this.EndLine this.EndColumn this.Content + +type private Position = + { + Line : int + Column : int + TotalOffset : int + } + +[] +module internal SnapshotUpdate = + [] + let tripleQuote = "\"\"\"" + + /// Convert a string position to line/column + let private positionToLineColumn (text : string) (offset : int) : Position = + let rec loop (line : int) (col : int) (totalOffset : int) (i : int) : Position = + if i >= text.Length || totalOffset = offset then + { + Line = line + Column = col + TotalOffset = totalOffset + } + elif text.[i] = '\n' then + loop (line + 1) 0 (totalOffset + 1) (i + 1) + else + loop line (col + 1) (totalOffset + 1) (i + 1) + + loop 0 0 0 0 + + /// Skip whitespace and comments, returning the position after them + let rec private skipWhitespaceAndComments (text : string) (startPos : int) : int option = + let rec skipComment (depth : int) (pos : int) : int option = + if pos >= text.Length - 1 then + None + elif pos + 1 < text.Length && text.[pos] = '(' && text.[pos + 1] = '*' then + skipComment (depth + 1) (pos + 2) + elif pos + 1 < text.Length && text.[pos] = '*' && text.[pos + 1] = ')' then + if depth = 1 then + Some (pos + 2) + else + skipComment (depth - 1) (pos + 2) + else + skipComment depth (pos + 1) + + let rec loop pos = + if pos >= text.Length then + None + elif pos + 1 < text.Length && text.[pos] = '(' && text.[pos + 1] = '*' then + skipComment 1 (pos + 2) |> Option.bind loop + elif Char.IsWhiteSpace (text.[pos]) then + loop (pos + 1) + else + Some pos + + loop startPos + + /// Parse a regular string literal + let private parseRegularString (text : string) (startPos : int) : (string * int) option = + let rec loop pos content escaped = + if pos >= text.Length then + None + elif escaped then + let unescaped = + match text.[pos] with + | 'n' -> "\n" + | 'r' -> "\r" + | 't' -> "\t" + | '\\' -> "\\" + | '"' -> "\"" + | c -> string c + + loop (pos + 1) (content + unescaped) false + elif text.[pos] = '\\' then + loop (pos + 1) content true + elif text.[pos] = '"' then + Some (content, pos + 1) + else + loop (pos + 1) (content + string text.[pos]) false + + loop (startPos + 1) "" false + + /// Parse a verbatim string literal (@"...") + let private parseVerbatimString (text : string) (startPos : int) : (string * int) option = + let rec loop pos content = + if pos >= text.Length then + None + elif pos + 1 < text.Length && text.[pos] = '"' && text.[pos + 1] = '"' then + // Escaped quote in verbatim string + loop (pos + 2) (content + "\"") + elif text.[pos] = '"' then + // End of string + Some (content, pos + 1) + else + loop (pos + 1) (content + string text.[pos]) + + // Skip the @" prefix + loop (startPos + 2) "" + + /// Parse a triple-quoted string literal + let private parseTripleQuotedString (text : string) (startPos : int) : (string * int) option = + // startPos points to the first " + if + startPos + 2 >= text.Length + || text.[startPos] <> '"' + || text.[startPos + 1] <> '"' + || text.[startPos + 2] <> '"' + then + None + else + let contentStart = startPos + 3 + let closePos = text.IndexOf (tripleQuote, contentStart, StringComparison.Ordinal) + + if closePos = -1 then + None + else + let content = text.Substring (contentStart, closePos - contentStart) + Some (content, closePos + 3) + + /// Find the string literal after a snapshot keyword + let private findSnapshotString (lines : string[]) (snapshotLine : int) : StringLiteralInfo option = + let startIdx = snapshotLine - 1 + + if startIdx >= lines.Length then + None + else + // We need to include enough lines to capture multi-line strings + // Take a reasonable number of lines after the snapshot line + let maxLines = min 50 (lines.Length - startIdx) + let relevantLines = lines |> Array.skip startIdx |> Array.take maxLines + + let searchText = String.concat "\n" relevantLines + + // Find snapshot keyword + let snapshotMatch = Regex.Match (searchText, @"\b(snapshot|snapshotJson)\b") + + if not snapshotMatch.Success then + None + else + // Work with positions relative to searchText throughout + let snapshotEnd = snapshotMatch.Index + snapshotMatch.Length + + // Skip whitespace and comments after "snapshot" + skipWhitespaceAndComments searchText snapshotEnd + |> Option.bind (fun stringStart -> + if stringStart >= searchText.Length then + None + else + // Check what type of string literal we have + let parseResult = + if + stringStart + 2 < searchText.Length + && searchText.[stringStart] = '"' + && searchText.[stringStart + 1] = '"' + && searchText.[stringStart + 2] = '"' + then + // Triple-quoted string + parseTripleQuotedString searchText stringStart + |> Option.map (fun (content, endPos) -> (content, stringStart, endPos)) + elif + stringStart + 1 < searchText.Length + && searchText.[stringStart] = '@' + && searchText.[stringStart + 1] = '"' + then + // Verbatim string + parseVerbatimString searchText stringStart + |> Option.map (fun (content, endPos) -> (content, stringStart, endPos)) + elif searchText.[stringStart] = '"' then + // Regular string + parseRegularString searchText stringStart + |> Option.map (fun (content, endPos) -> (content, stringStart, endPos)) + else + None + + parseResult + |> Option.map (fun (content, stringStartPos, stringEndPos) -> + let startPos = positionToLineColumn searchText stringStartPos + let endPos = positionToLineColumn searchText stringEndPos + + { + StartLine = startIdx + startPos.Line + 1 + StartColumn = startPos.Column + EndLine = startIdx + endPos.Line + 1 + EndColumn = endPos.Column + Content = content + } + ) + ) + + /// Update the snapshot string with a new value; this doesn't edit the file on disk, but + /// instead returns the new contents. + /// We always write single-quoted @-strings for simplicity. + let private updateSnapshot (lines : string[]) (info : StringLiteralInfo) (newContent : string) : string[] = + let newString = "@\"" + newContent.Replace ("\"", "\"\"") + "\"" + + if info.StartLine = info.EndLine then + // Single line update + lines + |> Array.mapi (fun i line -> + if i = info.StartLine - 1 then + let before = line.Substring (0, info.StartColumn) + let after = line.Substring info.EndColumn + before + newString + after + else + line + ) + else + // Multi-line update + let startLineIdx = info.StartLine - 1 + let endLineIdx = info.EndLine - 1 + + let before = lines.[startLineIdx].Substring (0, info.StartColumn) + let after = lines.[endLineIdx].Substring info.EndColumn + + let newLines = + if newContent.IndexOf '\n' >= 0 then + let split = newContent.Replace("\"", "\"\"").Split ('\n') + + match split with + | [||] -> failwith "expected contents from split string" + | [| single |] -> [| before + "@\"" + single + "\"" + after |] + | [| first ; last |] -> [| before + "@\"" + first ; last + "\"" + after |] + | split -> + + [| + yield before + "@\"" + split.[0] + yield! split.[1 .. split.Length - 2] + yield split.[split.Length - 1] + "\"" + after + |] + else + // Convert to single-line verbatim string + [| before + "@\"" + newContent.Replace ("\"", "\"\"") + "\"" + after |] + + [| + yield! lines |> Array.take startLineIdx + yield! newLines + yield! lines |> Array.skip (endLineIdx + 1) + |] + + /// Example usage: + /// updateSnapshotAtLine [|lines-of-file|] 42 "new test output" + /// + /// This will find a snapshot call on line 42 like: + /// snapshot "old value" -> snapshot @"new test output" + /// snapshot @"old value" -> snapshot @"new test output" + /// snapshot """old value""" -> snapshot @"new test output" + /// snapshot """multi + /// line""" -> snapshot """multi + /// line""" + /// snapshot "has \"\"\" in it" -> snapshot @"has """""" in it" + /// + let updateSnapshotAtLine (fileLines : string[]) (snapshotLine : int) (newValue : string) : string[] = + match findSnapshotString fileLines snapshotLine with + | Some info -> + Console.Error.WriteLine ("String literal to update: " + string info) + updateSnapshot fileLines info newValue + | None -> failwithf "Could not find string literal after snapshot at line %d" snapshotLine diff --git a/WoofWare.Expect/SurfaceBaseline.txt b/WoofWare.Expect/SurfaceBaseline.txt index d70148d..fef2de4 100644 --- a/WoofWare.Expect/SurfaceBaseline.txt +++ b/WoofWare.Expect/SurfaceBaseline.txt @@ -1,9 +1,13 @@ WoofWare.Expect.Builder inherit obj WoofWare.Expect.Builder.expect [static property]: [read-only] WoofWare.Expect.ExpectBuilder +WoofWare.Expect.Builder.expect' [static property]: [read-only] WoofWare.Expect.ExpectBuilder WoofWare.Expect.Builder.expectWithMockedFilePath [static method]: (string, int) -> WoofWare.Expect.ExpectBuilder WoofWare.Expect.Builder.get_expect [static method]: unit -> WoofWare.Expect.ExpectBuilder +WoofWare.Expect.Builder.get_expect' [static method]: unit -> WoofWare.Expect.ExpectBuilder WoofWare.Expect.ExpectBuilder inherit obj -WoofWare.Expect.ExpectBuilder..ctor [constructor]: (string * int) option +WoofWare.Expect.ExpectBuilder..ctor [constructor]: (string * int) +WoofWare.Expect.ExpectBuilder..ctor [constructor]: bool +WoofWare.Expect.ExpectBuilder..ctor [constructor]: WoofWare.Expect.Mode WoofWare.Expect.ExpectBuilder.Bind [method]: (WoofWare.Expect.YouHaveSuppliedMultipleSnapshots WoofWare.Expect.ExpectState, unit -> 'U WoofWare.Expect.ExpectState) -> 'U WoofWare.Expect.ExpectState WoofWare.Expect.ExpectBuilder.Delay [method]: (unit -> 'T WoofWare.Expect.ExpectState) -> (unit -> 'T WoofWare.Expect.ExpectState) WoofWare.Expect.ExpectBuilder.Return [method]: 'T -> 'T WoofWare.Expect.ExpectState @@ -19,5 +23,7 @@ WoofWare.Expect.ExpectException.Equals [method]: System.Exception -> bool WoofWare.Expect.ExpectException.Message [property]: [read-only] string WoofWare.Expect.ExpectState`1 inherit obj, implements 'T WoofWare.Expect.ExpectState System.IEquatable, System.Collections.IStructuralEquatable, 'T WoofWare.Expect.ExpectState System.IComparable, System.IComparable, System.Collections.IStructuralComparable WoofWare.Expect.ExpectState`1.Equals [method]: ('T WoofWare.Expect.ExpectState, System.Collections.IEqualityComparer) -> bool +WoofWare.Expect.Mode inherit obj, implements WoofWare.Expect.Mode System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.Mode System.IComparable, System.IComparable, System.Collections.IStructuralComparable +WoofWare.Expect.Mode.Equals [method]: (WoofWare.Expect.Mode, System.Collections.IEqualityComparer) -> bool WoofWare.Expect.YouHaveSuppliedMultipleSnapshots inherit obj, implements WoofWare.Expect.YouHaveSuppliedMultipleSnapshots System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.YouHaveSuppliedMultipleSnapshots System.IComparable, System.IComparable, System.Collections.IStructuralComparable WoofWare.Expect.YouHaveSuppliedMultipleSnapshots.Equals [method]: (WoofWare.Expect.YouHaveSuppliedMultipleSnapshots, System.Collections.IEqualityComparer) -> bool \ No newline at end of file diff --git a/WoofWare.Expect/WoofWare.Expect.fsproj b/WoofWare.Expect/WoofWare.Expect.fsproj index 7070e44..5c2cc1e 100644 --- a/WoofWare.Expect/WoofWare.Expect.fsproj +++ b/WoofWare.Expect/WoofWare.Expect.fsproj @@ -16,7 +16,9 @@ - + + + True \ diff --git a/WoofWare.Expect/version.json b/WoofWare.Expect/version.json index 31de0e0..4b1d8f3 100644 --- a/WoofWare.Expect/version.json +++ b/WoofWare.Expect/version.json @@ -1,5 +1,5 @@ { - "version": "0.1", + "version": "0.2", "publicReleaseRefSpec": [ "^refs/heads/main$" ], @@ -9,4 +9,4 @@ ":/Directory.Build.props", ":/LICENSE" ] -} +} \ No newline at end of file diff --git a/analyzers/analyzers.fsproj b/analyzers/analyzers.fsproj new file mode 100644 index 0000000..4a7cefa --- /dev/null +++ b/analyzers/analyzers.fsproj @@ -0,0 +1,16 @@ + + + + false + false + ../.analyzerpackages/ + net6.0 + true + false + + + + + + + diff --git a/nix/deps.json b/nix/deps.json index 368cc1d..262e636 100644 --- a/nix/deps.json +++ b/nix/deps.json @@ -29,6 +29,11 @@ "version": "1.4.36", "hash": "sha256-zZEhjP0mdc5E3fBPS4/lqD7sxoaoT5SOspP546RWYdc=" }, + { + "pname": "FsUnit", + "version": "7.0.1", + "hash": "sha256-K85CIdxMeFSHEKZk6heIXp/oFjWAn7dBILKrw49pJUY=" + }, { "pname": "Microsoft.ApplicationInsights", "version": "2.22.0",