├── .config └── dotnet-tools.json ├── .github ├── CODE_OF_CONDUCT.md └── workflows │ └── build.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── Diffract.sln ├── LICENSE ├── README.md ├── diffract-64x64.png ├── diffract.png ├── global.json ├── paket.dependencies ├── paket.lock ├── src └── Diffract │ ├── CustomDiffer.fs │ ├── DictionaryShape.fs │ ├── DiffPrinter.fs │ ├── Differ.fs │ ├── Diffract.fs │ ├── Diffract.fsproj │ ├── Extensions.fs │ ├── ReadOnlyDictionaryShape.fs │ ├── Types.fs │ ├── paket.references │ └── paket.template └── tests ├── Diffract.CSharp.Tests ├── CustomDiffers.cs ├── Diffract.CSharp.Tests.csproj ├── Tests.cs └── paket.references ├── Diffract.Tests ├── Diffract.Tests.fsproj ├── Program.fs ├── Tests.fs └── paket.references └── Test.fsx /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "paket": { 6 | "version": "8.0.3", 7 | "commands": [ 8 | "paket" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at softwarecraft@d-edge.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 122 | 123 | For answers to common questions about this code of conduct, see the FAQ at 124 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. 125 | 126 | [homepage]: https://www.contributor-covenant.org 127 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 128 | [Mozilla CoC]: https://github.com/mozilla/diversity 129 | [FAQ]: https://www.contributor-covenant.org/faq 130 | [translations]: https://www.contributor-covenant.org/translations 131 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: .NET Core 2 | on: 3 | push: 4 | pull_request: 5 | release: 6 | types: 7 | - published 8 | env: 9 | # Stop wasting time caching packages 10 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true 11 | # Disable sending usage data to Microsoft 12 | DOTNET_CLI_TELEMETRY_OPTOUT: true 13 | # Project name to pack and publish 14 | PROJECT_NAME: Diffract 15 | # GitHub Packages Feed settings 16 | GITHUB_FEED: https://nuget.pkg.github.com/d-edge/ 17 | GITHUB_USER: tarmil 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | # Official NuGet Feed settings 20 | NUGET_FEED: https://api.nuget.org/v3/index.json 21 | NUGET_KEY: ${{ secrets.NUGET_KEY }} 22 | jobs: 23 | build: 24 | runs-on: ${{ matrix.os }} 25 | strategy: 26 | matrix: 27 | os: [ ubuntu-latest, windows-latest, macos-latest ] 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v2 31 | - name: Setup .NET Core 32 | uses: actions/setup-dotnet@v1 33 | with: 34 | dotnet-version: 8.0.100 35 | - name: Paket restore 36 | run: | 37 | dotnet tool restore 38 | dotnet paket install 39 | - name: Restore 40 | run: dotnet restore 41 | - name: Build 42 | run: dotnet build -c Release --no-restore 43 | - name: Test 44 | run: dotnet test -c Release 45 | - name: Strip HTML from README 46 | uses: Tarmil/strip-markdown-html@v0.3 47 | with: 48 | input-path: README.md 49 | output-path: src/Diffract/README.md 50 | - name: Pack 51 | if: matrix.os == 'ubuntu-latest' 52 | run: | 53 | if [ "$GITHUB_REF_TYPE" = "tag" ]; then 54 | arrTag=(${GITHUB_REF//\// }) 55 | VERSION="${arrTag[2]}" 56 | echo Version: $VERSION 57 | VERSION="${VERSION//v}" 58 | echo Clean Version: $VERSION 59 | else 60 | git fetch --prune --unshallow --tags --quiet 61 | latestTag=$(git describe --tags --abbrev=0 2>/dev/null || echo 0.0.1) 62 | runId=$GITHUB_RUN_ID 63 | VERSION="${latestTag//v}-build.${runId}" 64 | echo Non-release version: $VERSION 65 | fi 66 | dotnet paket pack --symbols --version $VERSION nupkg 67 | - name: Upload Artifact 68 | if: matrix.os == 'ubuntu-latest' 69 | uses: actions/upload-artifact@v4 70 | with: 71 | name: nupkg 72 | path: nupkg 73 | prerelease: 74 | needs: build 75 | if: github.ref == 'refs/heads/main' 76 | runs-on: ubuntu-latest 77 | steps: 78 | - name: Download Artifact 79 | uses: actions/download-artifact@v4 80 | with: 81 | name: nupkg 82 | - name: Push to GitHub Feed 83 | run: | 84 | for f in *.nupkg 85 | do 86 | curl -vX PUT -u "$GITHUB_USER:$GITHUB_TOKEN" -F package=@$f $GITHUB_FEED 87 | done 88 | deploy: 89 | needs: build 90 | if: github.event_name == 'release' 91 | runs-on: ubuntu-latest 92 | steps: 93 | - uses: actions/checkout@v2 94 | - name: Setup .NET Core 95 | uses: actions/setup-dotnet@v1 96 | with: 97 | dotnet-version: 8.0.100 98 | - name: Download Artifact 99 | uses: actions/download-artifact@v4 100 | with: 101 | name: nupkg 102 | - name: Push to GitHub Feed 103 | run: | 104 | for f in *.nupkg 105 | do 106 | curl -vX PUT -u "$GITHUB_USER:$GITHUB_TOKEN" -F package=@$f $GITHUB_FEED 107 | done 108 | - name: Push to NuGet Feed 109 | run: dotnet nuget push *.nupkg --source $NUGET_FEED --skip-duplicate --api-key $NUGET_KEY 110 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | .vs/ 4 | *.user 5 | .idea/ 6 | .ionide/ 7 | paket-files/ 8 | packages/ 9 | .fake/ 10 | build/ 11 | TestResults/ 12 | .paket/ 13 | src/Diffract/README.md 14 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | softwarecraft@d-edge.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Diffract.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30114.105 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".paket", ".paket", "{A2D88361-AEDE-4F07-A5F1-7BD5366B59C5}" 7 | ProjectSection(SolutionItems) = preProject 8 | paket.dependencies = paket.dependencies 9 | EndProjectSection 10 | EndProject 11 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84AC3DD1-BF17-49DF-9582-07460FC75D5A}" 12 | EndProject 13 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Diffract", "src\Diffract\Diffract.fsproj", "{FAFE184C-6C5E-47A1-9A93-7B8F48082F49}" 14 | EndProject 15 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{67F4C459-70D9-44D5-9E3F-883FFBDF6018}" 16 | ProjectSection(SolutionItems) = preProject 17 | tests\Test.fsx = tests\Test.fsx 18 | EndProjectSection 19 | EndProject 20 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Diffract.Tests", "tests\Diffract.Tests\Diffract.Tests.fsproj", "{45228A71-E9C9-49ED-8094-8F3354924C10}" 21 | EndProject 22 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diffract.CSharp.Tests", "tests\Diffract.CSharp.Tests\Diffract.CSharp.Tests.csproj", "{6EF4AF3B-8257-4970-8BF8-AC5C21CFB658}" 23 | EndProject 24 | Global 25 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 26 | Debug|Any CPU = Debug|Any CPU 27 | Release|Any CPU = Release|Any CPU 28 | EndGlobalSection 29 | GlobalSection(SolutionProperties) = preSolution 30 | HideSolutionNode = FALSE 31 | EndGlobalSection 32 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 33 | {FAFE184C-6C5E-47A1-9A93-7B8F48082F49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 34 | {FAFE184C-6C5E-47A1-9A93-7B8F48082F49}.Debug|Any CPU.Build.0 = Debug|Any CPU 35 | {FAFE184C-6C5E-47A1-9A93-7B8F48082F49}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {FAFE184C-6C5E-47A1-9A93-7B8F48082F49}.Release|Any CPU.Build.0 = Release|Any CPU 37 | {45228A71-E9C9-49ED-8094-8F3354924C10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {45228A71-E9C9-49ED-8094-8F3354924C10}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {45228A71-E9C9-49ED-8094-8F3354924C10}.Release|Any CPU.ActiveCfg = Release|Any CPU 40 | {45228A71-E9C9-49ED-8094-8F3354924C10}.Release|Any CPU.Build.0 = Release|Any CPU 41 | {6EF4AF3B-8257-4970-8BF8-AC5C21CFB658}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 42 | {6EF4AF3B-8257-4970-8BF8-AC5C21CFB658}.Debug|Any CPU.Build.0 = Debug|Any CPU 43 | {6EF4AF3B-8257-4970-8BF8-AC5C21CFB658}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {6EF4AF3B-8257-4970-8BF8-AC5C21CFB658}.Release|Any CPU.Build.0 = Release|Any CPU 45 | EndGlobalSection 46 | GlobalSection(NestedProjects) = preSolution 47 | {FAFE184C-6C5E-47A1-9A93-7B8F48082F49} = {84AC3DD1-BF17-49DF-9582-07460FC75D5A} 48 | {45228A71-E9C9-49ED-8094-8F3354924C10} = {67F4C459-70D9-44D5-9E3F-883FFBDF6018} 49 | {6EF4AF3B-8257-4970-8BF8-AC5C21CFB658} = {67F4C459-70D9-44D5-9E3F-883FFBDF6018} 50 | EndGlobalSection 51 | EndGlobal 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 D-EDGE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | diffract logo 5 |

6 | 7 |

8 | actions build 9 | version 10 | download 11 | license 12 |

13 | 14 |
15 | 16 | Diffract is a .NET library that displays a readable diff between two objects. It is particularly useful for unit testing complex objects. Diffract is maintained by folks at [D-EDGE](https://www.d-edge.com/). 17 | 18 | Here is an example: 19 | 20 | ```csharp 21 | using DEdge.Diffract; 22 | 23 | record User(string Name, int Age, string[] Pets); 24 | 25 | var expected = new User("Emma", 42, new[] { "Oscar", "Fluffy", "Tibbles" }); 26 | var actual = new User("Andy", 42, new[] { "Oscar", "Sparky" }); 27 | 28 | Differ.Assert(expected, actual); 29 | ``` 30 | 31 | The above throws an `AssertionFailedException` with the following message: 32 | 33 | ``` 34 | Value differs by 2 fields: 35 | Name Expect = "Emma" 36 | Actual = "Andy" 37 | Pets collection differs: 38 | Pets.Count Expect = 3 39 | Actual = 2 40 | Pets[1] Expect = "Fluffy" 41 | Actual = "Sparky" 42 | ``` 43 | 44 | Diffract can drill down many composite types: 45 | * POCOs; 46 | * C# records; 47 | * F# records and anonymous records; 48 | * F# unions; 49 | * enumerables (`IEnumerable`); 50 | * dictionaries (`IDictionary`, `IReadOnlyDictionary`); 51 | * value and reference tuples. 52 | 53 | Values of any other equatable type (like `string` and `int` in the above example) are treated as leaves that can be tested for equality. 54 | 55 | ## Example outputs 56 | 57 | POCO or record with multiple field differences: 58 | 59 | ```csharp 60 | record User(string Name, int Age, bool IsActive); 61 | 62 | var expected = new User("Emma", 42, true); 63 | var actual = new User("Andy", 35, true); 64 | ``` 65 | 66 | ``` 67 | Value differs by 2 fields: 68 | Name Expect = "Emma" 69 | Actual = "Andy" 70 | Age Expect = 42 71 | Actual = 35 72 | ``` 73 | 74 | F# union where the case is different: 75 | 76 | ```fsharp 77 | type Contact = 78 | | Email of address: string 79 | | Phone of number: string 80 | 81 | let expected = Email "user@example.com" 82 | let actual = Phone "555-123-456" 83 | ``` 84 | 85 | ``` 86 | Value differs by union case: 87 | Expect is Email 88 | Actual is Phone 89 | ``` 90 | 91 | F# union where the case is the same and the value is different: 92 | 93 | ```fsharp 94 | type Contact = 95 | | Email of address: string 96 | | Phone of number: string 97 | 98 | let expected = Email "user@example.com" 99 | let actual = Email "someone@example.com" 100 | ``` 101 | 102 | ``` 103 | Value differs by union case Email fields: 104 | address Expect = "user@example.com" 105 | Actual = "someone@example.com" 106 | ``` 107 | 108 | Enumerables show the counts if they differ, followed by the diffs per item: 109 | 110 | ```csharp 111 | var expected = new string[] { "first", "second" }; 112 | var actual = new string[] { "first", "2nd", "third" }; 113 | ``` 114 | 115 | ``` 116 | Value collection differs: 117 | Count Expect = 2 118 | Actual = 3 119 | [1] Expect = "second" 120 | Actual = "2nd" 121 | ``` 122 | 123 | Dictionaries show the keys missing on either side, followed by the diffs per item that exists in both: 124 | 125 | ```csharp 126 | var expected = new Dictionary 127 | { 128 | { "first", 1 }, 129 | { "second", 2 }, 130 | { "third", 3 }, 131 | }; 132 | var actual = new Dictionary 133 | { 134 | { "first", 1 }, 135 | { "third", 2 }, 136 | { "fourth", 4 }, 137 | }; 138 | ``` 139 | 140 | ``` 141 | Value dictionary differs: 142 | ["second"] Actual is missing 143 | ["fourth"] Expect is missing 144 | ["third"] Expect = 3 145 | Actual = 2 146 | ``` 147 | 148 | ## API 149 | 150 | Diffract lives in the namespace `DEdge.Diffract`. Its main API is the class `Differ`, which provides the following methods: 151 | 152 | ```csharp 153 | void Assert(T expected, T actual, IDiffer differ = null, PrintParams param = null) 154 | ``` 155 | 156 | Computes the diff between two objects and, if it is not empty, throws an `AssertionFailedException` with the diff as message. 157 | 158 | ```csharp 159 | string ToString(T expected, T actual, IDiffer differ = null, PrintParams param = null) 160 | ``` 161 | 162 | Prints the diff between two objects to a string. 163 | 164 | ```csharp 165 | void Write(T expected, T actual, TextWriter writer = null, IDiffer differ = null, PrintParams param = null) 166 | ``` 167 | 168 | Prints the diff between two objects to the given TextWriter (or to standard output if not provided). 169 | 170 | ```csharp 171 | FSharpOption Diff(T expected, T actual, IDiffer differ = null) 172 | ``` 173 | 174 | Computes the diff between two objects. Returns `None` if the objects are found to be equal. 175 | -------------------------------------------------------------------------------- /diffract-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-edge/Diffract/c9288ab13d15910bdc413f9b59c9c7cc715e00c7/diffract-64x64.png -------------------------------------------------------------------------------- /diffract.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-edge/Diffract/c9288ab13d15910bdc413f9b59c9c7cc715e00c7/diffract.png -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "8.0.100", 4 | "allowPrerelease": true, 5 | "rollForward": "major" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /paket.dependencies: -------------------------------------------------------------------------------- 1 | source https://api.nuget.org/v3/index.json 2 | storage: none 3 | framework: netstandard2.0 4 | strategy: min 5 | lowest_matching: true 6 | 7 | nuget FSharp.Core >= 5.0.0 8 | nuget TypeShape >= 10.0.0 9 | 10 | group test 11 | source https://api.nuget.org/v3/index.json 12 | storage: none 13 | framework: net8.0 14 | 15 | nuget FsCheck 16 | nuget FsCheck.Xunit 17 | nuget Microsoft.NET.Test.Sdk 18 | nuget xunit 19 | nuget xunit.runner.visualstudio 20 | -------------------------------------------------------------------------------- /paket.lock: -------------------------------------------------------------------------------- 1 | STORAGE: NONE 2 | STRATEGY: MIN 3 | LOWEST_MATCHING: TRUE 4 | RESTRICTION: == netstandard2.0 5 | NUGET 6 | remote: https://api.nuget.org/v3/index.json 7 | FSharp.Core (5.0) 8 | System.Reflection.Emit.ILGeneration (4.7) 9 | System.Reflection.Emit.Lightweight (4.7) 10 | System.Reflection.Emit.ILGeneration (>= 4.7) 11 | TypeShape (10.0) 12 | FSharp.Core (>= 4.5.4) 13 | System.Reflection.Emit.Lightweight (>= 4.7) 14 | 15 | GROUP test 16 | STORAGE: NONE 17 | RESTRICTION: == net8.0 18 | NUGET 19 | remote: https://api.nuget.org/v3/index.json 20 | FsCheck (2.16.6) 21 | FSharp.Core (>= 4.2.3) 22 | FsCheck.Xunit (2.16.6) 23 | FsCheck (2.16.6) 24 | xunit.extensibility.execution (>= 2.2 < 3.0) 25 | FSharp.Core (8.0.400) 26 | Microsoft.CodeCoverage (17.11.1) 27 | Microsoft.NET.Test.Sdk (17.11.1) 28 | Microsoft.CodeCoverage (>= 17.11.1) 29 | Microsoft.TestPlatform.TestHost (>= 17.11.1) 30 | Microsoft.TestPlatform.ObjectModel (17.11.1) 31 | System.Reflection.Metadata (>= 1.6) 32 | Microsoft.TestPlatform.TestHost (17.11.1) 33 | Microsoft.TestPlatform.ObjectModel (>= 17.11.1) 34 | Newtonsoft.Json (>= 13.0.1) 35 | Newtonsoft.Json (13.0.3) 36 | System.Collections.Immutable (8.0) 37 | System.Reflection.Metadata (8.0) 38 | System.Collections.Immutable (>= 8.0) 39 | xunit (2.9) 40 | xunit.analyzers (>= 1.15) 41 | xunit.assert (>= 2.9) 42 | xunit.core (2.9) 43 | xunit.abstractions (2.0.3) 44 | xunit.analyzers (1.16) 45 | xunit.assert (2.9) 46 | xunit.core (2.9) 47 | xunit.extensibility.core (2.9) 48 | xunit.extensibility.execution (2.9) 49 | xunit.extensibility.core (2.9) 50 | xunit.abstractions (>= 2.0.3) 51 | xunit.extensibility.execution (2.9) 52 | xunit.extensibility.core (2.9) 53 | xunit.runner.visualstudio (2.8.2) 54 | -------------------------------------------------------------------------------- /src/Diffract/CustomDiffer.fs: -------------------------------------------------------------------------------- 1 | namespace DEdge.Diffract 2 | 3 | open System 4 | open DEdge.Diffract 5 | 6 | [] 7 | type CustomDiffer<'T> = 8 | 9 | /// 10 | /// Create a custom differ for a specific type. 11 | /// 12 | /// Builds the diff function for this type. 13 | static member Build(buildDiffFunction: Func>) = 14 | { new ICustomDiffer with 15 | member this.GetCustomDiffer<'U>(differFactory, shape) = 16 | if shape.Type = typeof<'T> then 17 | let diffFunction = buildDiffFunction.Invoke(differFactory) 18 | { new IDiffer<'T> with 19 | member _.Diff(x1, x2) = diffFunction.Invoke(x1, x2) } 20 | |> unbox> 21 | |> Some 22 | else 23 | None } 24 | 25 | /// 26 | /// Create a custom differ for a specific type. 27 | /// 28 | /// Builds the diff function for this type. 29 | static member Build(buildDiffFunction: IDifferFactory -> 'T -> 'T -> Diff option) = 30 | { new ICustomDiffer with 31 | member this.GetCustomDiffer<'U>(differFactory, shape) = 32 | if shape.Type = typeof<'T> then 33 | let diffFunction = buildDiffFunction differFactory 34 | { new IDiffer<'T> with 35 | member _.Diff(x1, x2) = diffFunction x1 x2 } 36 | |> unbox> 37 | |> Some 38 | else 39 | None } 40 | 41 | /// 42 | /// Create a custom differ for a specific type. 43 | /// 44 | /// The diff function for this type. 45 | static member Build(diffFunction: Func<'T, 'T, Diff option>) = 46 | CustomDiffer.Build(fun _ -> diffFunction) 47 | 48 | /// 49 | /// Create a custom differ for a specific type by mapping it to a diffable type. 50 | /// 51 | /// The mapping function. 52 | /// The type for which a custom differ is being created. 53 | /// The type used to actually perform the diff. 54 | static member Map<'U>(mapFunction: Func<'T, 'U>) = 55 | CustomDiffer<'T>.Build(fun differFactory -> 56 | let differ = differFactory.GetDiffer<'U>() 57 | fun x1 x2 -> differ.Diff(mapFunction.Invoke(x1), mapFunction.Invoke(x2))) 58 | 59 | [] 60 | type CustomDiffer = 61 | 62 | /// 63 | /// Create a custom differ for a specific type. 64 | /// 65 | /// Builds the diff function for this type. 66 | static member Build(buildDiffFunction: Func>) = 67 | CustomDiffer<'T>.Build(buildDiffFunction) 68 | 69 | /// 70 | /// Create a custom differ for a specific type. 71 | /// 72 | /// Builds the diff function for this type. 73 | static member Build(buildDiffFunction: IDifferFactory -> 'T -> 'T -> Diff option) = 74 | CustomDiffer<'T>.Build(buildDiffFunction) 75 | 76 | /// 77 | /// Create a custom differ for a specific type. 78 | /// 79 | /// The diff function for this type. 80 | static member Build(diffFunction: Func<'T, 'T, Diff option>) = 81 | CustomDiffer<'T>.Build(diffFunction) 82 | 83 | /// 84 | /// Create a custom differ for a leaf type using default comparison and a custom display format. 85 | /// 86 | /// The display format. 87 | static member Leaf<'T when 'T : equality> (format: Func<'T, string>) = 88 | CustomDiffer.Build<'T>(fun x y -> 89 | if x = y then 90 | None 91 | else 92 | Diff.Value(format.Invoke(x), format.Invoke(y)) 93 | |> Some) 94 | 95 | /// 96 | /// Combine multiple custom differs. 97 | /// 98 | /// The custom differs. 99 | static member Combine (differs: seq) = 100 | CombinedCustomDiffer(differs) :> ICustomDiffer 101 | 102 | /// 103 | /// Combine multiple custom differs. 104 | /// 105 | /// The custom differs. 106 | static member Combine ([] differs: ICustomDiffer[]) = 107 | CustomDiffer.Combine(differs :> seq<_>) 108 | -------------------------------------------------------------------------------- /src/Diffract/DictionaryShape.fs: -------------------------------------------------------------------------------- 1 | namespace DEdge.Diffract.DictionaryShape 2 | 3 | open System 4 | open System.Collections.Generic 5 | open TypeShape.Core 6 | 7 | type IDictionaryVisitor<'R> = 8 | abstract Visit<'Dict, 'K, 'V when 'K : equality and 'Dict :> IDictionary<'K, 'V>> : unit -> 'R 9 | 10 | type IShapeDictionary = 11 | abstract Key : TypeShape 12 | abstract Value : TypeShape 13 | abstract Accept : visitor: IDictionaryVisitor<'R> -> 'R 14 | 15 | type private ShapeDictionary<'Dict, 'K, 'V when 'K : equality and 'Dict :> IDictionary<'K, 'V>>() = 16 | interface IShapeDictionary with 17 | member _.Key = shapeof<'K> :> _ 18 | member _.Value = shapeof<'V> :> _ 19 | member _.Accept(v) = v.Visit<'Dict, 'K, 'V>() 20 | 21 | module Shape = 22 | 23 | let (|GenericInterface|_|) (fullName: string) (s: TypeShape) = 24 | match s.Type.GetInterface(fullName) with 25 | | null -> 26 | match s.ShapeInfo with 27 | | Generic (td, ta) when td.FullName = fullName -> Some ta 28 | | _ -> None 29 | | iface -> 30 | Some (iface.GetGenericArguments()) 31 | 32 | let (|Dictionary|_|) (s: TypeShape) = 33 | match s with 34 | | GenericInterface "System.Collections.Generic.IDictionary`2" ta -> 35 | Activator.CreateInstanceGeneric>(Array.append [|s.Type|] ta) 36 | :?> IShapeDictionary 37 | |> Some 38 | | _ -> None 39 | -------------------------------------------------------------------------------- /src/Diffract/DiffPrinter.fs: -------------------------------------------------------------------------------- 1 | module DEdge.Diffract.DiffPrinter 2 | 3 | open System.IO 4 | 5 | let toStreamImpl (w: TextWriter) param (d: Diff) = 6 | let originalParam = param 7 | let param = if originalParam.ensureFirstLineIsAligned then { originalParam with ensureFirstLineIsAligned = false } else originalParam 8 | 9 | let addPathField path field = if path = "" then field else (path + "." + field) 10 | let addPathIndex path index = path + "[" + index + "]" 11 | let indentLike str = String.replicate (String.length str) " " 12 | let displayPath path = if path = "" then param.neutralName else path 13 | 14 | let printValue indent path (x1: obj) (x2: obj) = 15 | let dpath = if path = "" then "" else path + " " 16 | if isNull x1 then 17 | w.WriteLine($"%s{indent}%s{dpath}%s{param.x1Name} is null") 18 | w.WriteLine($"%s{indent}%s{indentLike dpath}%s{param.x2Name} = %A{x2}") 19 | elif isNull x2 then 20 | w.WriteLine($"%s{indent}%s{dpath}%s{param.x1Name} = %A{x1}") 21 | w.WriteLine($"%s{indent}%s{indentLike dpath}%s{param.x2Name} is null") 22 | else 23 | w.WriteLine($"%s{indent}%s{dpath}%s{param.x1Name} = %A{x1}") 24 | w.WriteLine($"%s{indent}%s{indentLike dpath}%s{param.x2Name} = %A{x2}") 25 | 26 | let rec loop (indent: string) (path: string) (d: Diff) = 27 | match d with 28 | | Diff.Value (x1, x2) -> 29 | printValue indent path x1 x2 30 | | Diff.Nullness (x1, x2) -> 31 | let dpath = if path = "" then "" else path + " " 32 | let dindent = indentLike dpath 33 | w.WriteLine($"""%s{indent}%s{dpath}%s{param.x1Name} is%s{if isNull x1 then "" else " not"} null""") 34 | w.WriteLine($"""%s{indent}%s{dindent}%s{param.x2Name} is%s{if isNull x2 then "" else " not"} null""") 35 | | Diff.Record fields when fields.Count = 1 -> 36 | loop indent (addPathField path fields.[0].Name) fields.[0].Diff 37 | | Diff.Record fields -> 38 | w.WriteLine($"%s{indent}%s{displayPath path} differs by %i{fields.Count} fields:") 39 | let indent = indent + param.indent 40 | for field in fields do 41 | loop indent (addPathField path field.Name) field.Diff 42 | | Diff.UnionCase (caseName1, caseName2) -> 43 | w.WriteLine($"%s{indent}%s{displayPath path} differs by union case:") 44 | let indent = indent + param.indent 45 | w.WriteLine($"%s{indent}%s{param.x1Name} is %s{caseName1}") 46 | w.WriteLine($"%s{indent}%s{param.x2Name} is %s{caseName2}") 47 | | Diff.UnionField (_case, fields) when fields.Count = 1 -> 48 | loop indent (addPathField path fields.[0].Name) fields.[0].Diff 49 | | Diff.UnionField (case, fields) -> 50 | w.WriteLine($"%s{indent}%s{displayPath path} differs by union case %s{case} fields:") 51 | let indent = indent + param.indent 52 | for field in fields do 53 | loop indent (addPathField path field.Name) field.Diff 54 | | Diff.Collection (c1, c2, diffs) -> 55 | w.WriteLine($"%s{indent}%s{displayPath path} collection differs:") 56 | let indent = indent + param.indent 57 | if c1 <> c2 then 58 | let countPath = addPathField path "Count" 59 | w.WriteLine($"%s{indent}%s{countPath} %s{param.x1Name} = %i{c1}") 60 | w.WriteLine($"%s{indent}%s{indentLike countPath} %s{param.x2Name} = %i{c2}") 61 | for item in diffs do 62 | loop indent (addPathIndex path item.Name) item.Diff 63 | | Diff.Custom cd -> 64 | cd.WriteTo(w, param, indent, path, loop) 65 | | Diff.Dictionary (keysInX1, keysInX2, common) -> 66 | w.WriteLine($"%s{indent}%s{displayPath path} dictionary differs:") 67 | let indent = indent + param.indent 68 | for k in keysInX1 do 69 | w.WriteLine($"%s{indent}%s{param.x2Name}[%s{k}] is missing") 70 | for k in keysInX2 do 71 | w.WriteLine($"%s{indent}%s{param.x1Name}[%s{k}] is missing") 72 | for item in common do 73 | loop indent (addPathIndex path item.Name) item.Diff 74 | 75 | match originalParam.ensureFirstLineIsAligned, d with 76 | | true, Diff.Value (x1, x2) -> 77 | w.WriteLine() 78 | printValue "" "" x1 x2 79 | | true, Diff.Custom cd -> 80 | cd.WriteTo(w, originalParam, "", "", loop) 81 | | _ -> loop "" "" d 82 | 83 | let write (param: PrintParams) (w: TextWriter) (d: Diff option) = 84 | match d with 85 | | None -> w.WriteLine($"No differences between {param.x1Name} and {param.x2Name}.") 86 | | Some d -> toStreamImpl w param d 87 | 88 | let toString param d = 89 | use w = new StringWriter(NewLine = "\n") 90 | write param w d 91 | w.ToString() 92 | -------------------------------------------------------------------------------- /src/Diffract/Differ.fs: -------------------------------------------------------------------------------- 1 | namespace DEdge.Diffract 2 | 3 | #nowarn "40" 4 | 5 | open System 6 | open System.Collections.Generic 7 | open TypeShape.Core 8 | open DEdge.Diffract.ReadOnlyDictionaryShape 9 | open DEdge.Diffract.DictionaryShape 10 | 11 | module DifferImpl = 12 | 13 | type private Cache = Dictionary 14 | type private CachedDiffer<'T> = Cache -> IDiffer<'T> 15 | 16 | let private checkNull<'T> (differ: IDiffer<'T>) = 17 | if typeof<'T>.IsValueType then 18 | differ 19 | else 20 | { new IDiffer<'T> with 21 | member _.Diff(x1, x2) = 22 | match isNull (box x1), isNull (box x2) with 23 | | false, false -> differ.Diff(x1, x2) 24 | | true, true -> None 25 | | _ -> Some (Nullness(x1, x2)) } 26 | 27 | /// Add to the cache a differ for a type that may be recursive (ie have nested fields of its own type). 28 | let private addRecursiveToCache (differ: CachedDiffer<'T>) : CachedDiffer<'T> = 29 | let ty = typeof<'T> 30 | fun cache -> 31 | match cache.TryGetValue(ty) with 32 | | false, _ -> 33 | let r = ref Unchecked.defaultof> 34 | cache.Add(ty, { new IDifferFactory with 35 | member _.GetDiffer<'U>() = 36 | { new IDiffer<'U> with 37 | member _.Diff(x1, x2) = 38 | (unbox> !r).Diff(x1, x2) } }) 39 | let differ = differ cache |> checkNull 40 | r := differ 41 | differ 42 | | true, differFactory -> 43 | differFactory.GetDiffer<'T>() 44 | 45 | /// Add to the cache a differ for a type that cannot be recursive (ie doesn't have fields of arbitrary types). 46 | let private addNonRecursiveToCache (differ: IDiffer<'T>) : CachedDiffer<'T> = 47 | fun cache -> 48 | let ty = typeof<'T> 49 | match cache.TryGetValue(ty) with 50 | | true, differFactory -> 51 | differFactory.GetDiffer<'T>() 52 | | false, _ -> 53 | cache.Add(typeof<'T>, { new IDifferFactory with member _.GetDiffer<'U>() = unbox> differ }) 54 | differ 55 | 56 | /// Wrap a simple diffing function as a cached differ. 57 | let private wrap<'Outer, 'Inner> (f: 'Inner -> 'Inner -> Diff option) : CachedDiffer<'Outer> = 58 | { new IDiffer<'Inner> with member _.Diff(x1, x2) = f x1 x2 } 59 | |> unbox> 60 | |> addNonRecursiveToCache 61 | 62 | /// Create a cached differ for a simple type with equality. 63 | let inline private simpleEquality<'Outer, ^Inner when ^Inner : equality> : CachedDiffer<'Outer> = 64 | wrap< ^Outer, ^Inner> (fun x1 x2 -> if x1 = x2 then None else Some (Diff.Value (x1, x2))) 65 | 66 | let inline private failwith (msg: string) = raise (DifferConstructionFailedException(msg)) 67 | 68 | /// Create a differ for an arbitrary type. 69 | let rec diffWith<'T> (custom: ICustomDiffer) (cache: Cache) : IDiffer<'T> = 70 | let getCached = { new IDifferFactory with 71 | member _.GetDiffer<'U>() = 72 | match cache.TryGetValue(typeof<'U>) with 73 | | true, d -> d.GetDiffer<'U>() 74 | | false, _ -> diffWith<'U> custom cache } 75 | let (|Custom|_|) shape = custom.GetCustomDiffer(getCached, shape) 76 | match shapeof<'T> with 77 | | Custom d -> d 78 | | Shape.Unit -> wrap<'T, unit> (fun () () -> None) cache 79 | | Shape.Bool -> simpleEquality<'T, bool> cache 80 | | Shape.Byte -> simpleEquality<'T, byte> cache 81 | | Shape.SByte -> simpleEquality<'T, sbyte> cache 82 | | Shape.Int16 -> simpleEquality<'T, int16> cache 83 | | Shape.UInt16 -> simpleEquality<'T, uint16> cache 84 | | Shape.Int32 -> simpleEquality<'T, int32> cache 85 | | Shape.UInt32 -> simpleEquality<'T, uint32> cache 86 | | Shape.Int64 -> simpleEquality<'T, int64> cache 87 | | Shape.UInt64 -> simpleEquality<'T, uint64> cache 88 | | Shape.Single -> simpleEquality<'T, single> cache 89 | | Shape.Double -> simpleEquality<'T, double> cache 90 | | Shape.Decimal -> simpleEquality<'T, decimal> cache 91 | | Shape.String -> simpleEquality<'T, string> cache 92 | | Shape.Char -> simpleEquality<'T, char> cache 93 | | Shape.Guid -> simpleEquality<'T, Guid> cache 94 | | Shape.DateTime -> simpleEquality<'T, DateTime> cache 95 | | Shape.DateTimeOffset -> simpleEquality<'T, DateTimeOffset> cache 96 | | Shape.TimeSpan -> simpleEquality<'T, TimeSpan> cache 97 | | Shape.BigInt -> simpleEquality<'T, bigint> cache 98 | | Shape.Uri -> simpleEquality<'T, Uri> cache 99 | | Shape.Tuple (:? ShapeTuple<'T> as t) -> addRecursiveToCache (diffFields custom t.Elements Diff.Record) cache 100 | | Shape.ReadOnlyDictionary d -> addRecursiveToCache (diffReadOnlyDict custom d) cache 101 | | Shape.Dictionary d -> addRecursiveToCache (diffDict custom d) cache 102 | | Shape.Enumerable e -> addRecursiveToCache (diffEnumerable custom e) cache 103 | | Shape.FSharpRecord (:? ShapeFSharpRecord<'T> as r) -> addRecursiveToCache (diffFields custom r.Fields Diff.Record) cache 104 | | Shape.FSharpUnion (:? ShapeFSharpUnion<'T> as u) -> 105 | let tagNames = u.UnionCases |> Array.map (fun c -> c.CaseInfo.Name) 106 | let tagDiffs = u.UnionCases |> Array.map (fun c -> 107 | diffFields custom c.Fields (fun d -> Diff.UnionField (c.CaseInfo.Name, d)) cache) 108 | wrap<'T, 'T> (fun x1 x2 -> 109 | let t1 = u.GetTag x1 110 | let t2 = u.GetTag x2 111 | if t1 = t2 then 112 | tagDiffs.[t1].Diff(x1, x2) 113 | else 114 | Some (Diff.UnionCase (tagNames.[t1], tagNames.[t2]))) 115 | cache 116 | | Shape.Enum e -> 117 | let differ = 118 | { new IEnumVisitor> with 119 | member _.Visit<'Enum, 'Underlying when 'Enum : enum<'Underlying> and 'Enum : struct and 'Enum :> ValueType and 'Enum : (new : unit -> 'Enum)>() = 120 | wrap<'T, 'Enum> (fun x1 x2 -> 121 | // Can't do better without 'Enum : equality? :( 122 | if (x1 :> obj).Equals(x2) then None else Some (Diff.Value (x1, x2))) 123 | cache } 124 | |> e.Accept 125 | addNonRecursiveToCache differ cache 126 | | Shape.Poco (:? ShapePoco<'T> as p) -> 127 | let members = p.Properties |> Array.filter (fun p -> p.IsPublic) 128 | addRecursiveToCache (diffReadOnlyFields<'T> custom members Diff.Record) cache 129 | | Shape.Equality e -> 130 | { new IEqualityVisitor> with 131 | member _.Visit<'Actual when 'Actual : equality>() = 132 | wrap<'T, 'Actual> (fun x1 x2 -> 133 | if x1 = x2 then None else Some (Diff.Value (x1, x2))) 134 | cache } 135 | |> e.Accept 136 | | _ -> failwith $"Don't know how to diff values of type {typeof<'T>.AssemblyQualifiedName}" 137 | 138 | /// Create a differ for a type composed of a number of read-only fields. 139 | and diffReadOnlyFields<'T> (custom: ICustomDiffer) (members: IShapeReadOnlyMember<'T>[]) (wrapFieldDiffs: IReadOnlyList -> Diff) (cache: Cache) : IDiffer<'T> = 140 | let fields = 141 | members 142 | |> Array.map (fun f -> 143 | { new IReadOnlyMemberVisitor<'T, 'T -> 'T -> FieldDiff option> with 144 | member _.Visit<'Field>(shape) = 145 | let fieldDiff = diffWith<'Field> custom cache 146 | let name = shape.MemberInfo.Name 147 | fun x1 x2 -> 148 | fieldDiff.Diff(shape.Get x1, shape.Get x2) 149 | |> Option.map (fun diff -> { Name = name; Diff = diff }) } 150 | |> f.Accept) 151 | { new IDiffer<'T> with 152 | member _.Diff(x1, x2) = 153 | match fields |> Seq.choose (fun f -> f x1 x2) |> List.ofSeq with 154 | | [] -> None 155 | | diffs -> Some (wrapFieldDiffs diffs) } 156 | 157 | /// Create a differ for a type composed of a number of read-only or mutable fields. 158 | and diffFields<'T> (custom: ICustomDiffer) (members: IShapeMember<'T>[]) (wrapFieldDiffs: IReadOnlyList -> Diff) (cache: Cache) : IDiffer<'T> = 159 | diffReadOnlyFields custom (unbox[]> members) wrapFieldDiffs cache 160 | 161 | /// Create a differ for a type that implements IEnumerable. 162 | and diffEnumerable<'T> (custom: ICustomDiffer) (e: IShapeEnumerable) (cache: Cache) : IDiffer<'T> = 163 | { new IEnumerableVisitor> with 164 | member _.Visit<'Enum, 'Elt when 'Enum :> seq<'Elt>>() = 165 | let diffItem = diffWith<'Elt> custom cache 166 | { new IDiffer<'Enum> with 167 | member _.Diff(s1, s2) = 168 | let s1 = Seq.cache s1 169 | let s2 = Seq.cache s2 170 | let l1 = Seq.length s1 171 | let l2 = Seq.length s2 172 | match 173 | (s1, s2) 174 | ||> Seq.mapi2 (fun i e1 e2 -> 175 | diffItem.Diff(e1, e2) 176 | |> Option.map (fun diff -> { Name = string i; Diff = diff })) 177 | |> Seq.choose id 178 | |> List.ofSeq 179 | with 180 | | [] when l1 = l2 -> None 181 | | diffs -> Some (Diff.Collection (l1, l2, diffs)) } 182 | |> unbox> } 183 | |> e.Accept 184 | 185 | /// Create a differ for a type that implements IReadOnlyDictionary. 186 | and diffReadOnlyDict<'T> (custom: ICustomDiffer) (d: IShapeReadOnlyDictionary) (cache: Cache) : IDiffer<'T> = 187 | { new IReadOnlyDictionaryVisitor> with 188 | member _.Visit<'Dict, 'K, 'V when 'K : equality and 'Dict :> IReadOnlyDictionary<'K, 'V>>() = 189 | let diffItem = diffWith<'V> custom cache 190 | { new IDiffer<'Dict> with 191 | member _.Diff(d1, d2) = 192 | let seen = HashSet<'K>() 193 | let struct (keysInX1, common) = 194 | (struct ([], []), d1) 195 | ||> Seq.fold (fun (struct (keysInX1, common) as state) (KeyValue (k, v1)) -> 196 | seen.Add(k) |> ignore 197 | match d2.TryGetValue(k) with 198 | | true, v2 -> 199 | match diffItem.Diff(v1, v2) with 200 | | Some d -> struct (keysInX1, { Name = string k; Diff = d } :: common) 201 | | None -> state 202 | | false, _ -> struct (string k :: keysInX1, common)) 203 | let keysInX2 = 204 | d2 205 | |> Seq.choose (fun (KeyValue (k, _)) -> 206 | if seen.Contains(k) then None else Some (string k)) 207 | |> List.ofSeq 208 | match keysInX1, keysInX2, common with 209 | | [], [], [] -> None 210 | | _ -> Some (Diff.Dictionary (keysInX1, keysInX2, common)) } 211 | |> unbox> } 212 | |> d.Accept 213 | 214 | /// Create a differ for a type that implements IDictionary. 215 | and diffDict<'T> (custom: ICustomDiffer) (d: IShapeDictionary) (cache: Cache) : IDiffer<'T> = 216 | { new IDictionaryVisitor> with 217 | member _.Visit<'Dict, 'K, 'V when 'K : equality and 'Dict :> IDictionary<'K, 'V>>() = 218 | let diffItem = diffWith<'V> custom cache 219 | { new IDiffer<'Dict> with 220 | member _.Diff(d1, d2) = 221 | let seen = HashSet<'K>() 222 | let struct (keysInX1, common) = 223 | (struct ([], []), d1) 224 | ||> Seq.fold (fun (struct (keysInX1, common) as state) (KeyValue (k, v1)) -> 225 | seen.Add(k) |> ignore 226 | match d2.TryGetValue(k) with 227 | | true, v2 -> 228 | match diffItem.Diff(v1, v2) with 229 | | Some d -> struct (keysInX1, { Name = string k; Diff = d } :: common) 230 | | None -> state 231 | | false, _ -> struct (string k :: keysInX1, common)) 232 | let keysInX2 = 233 | d2 234 | |> Seq.choose (fun (KeyValue (k, _)) -> 235 | if seen.Contains(k) then None else Some (string k)) 236 | |> List.ofSeq 237 | match keysInX1, keysInX2, common with 238 | | [], [], [] -> None 239 | | _ -> Some (Diff.Dictionary (keysInX1, keysInX2, common)) } 240 | |> unbox> } 241 | |> d.Accept 242 | 243 | /// Get a differ for an arbitrary type, without any custom differs. 244 | let simple<'T> = diffWith<'T> (NoCustomDiffer()) (Dictionary()) 245 | -------------------------------------------------------------------------------- /src/Diffract/Diffract.fs: -------------------------------------------------------------------------------- 1 | namespace DEdge.Diffract 2 | 3 | open System 4 | open System.Collections.Generic 5 | open System.IO 6 | open System.Runtime.InteropServices 7 | 8 | [] 9 | type Differ private () = 10 | 11 | static let simplePrintParams : PrintParams = 12 | { 13 | indent = " " 14 | x1Name = "Expect" 15 | x2Name = "Actual" 16 | neutralName = "Value" 17 | ensureFirstLineIsAligned = false 18 | } 19 | 20 | static let assertPrintParams : PrintParams = 21 | { simplePrintParams with ensureFirstLineIsAligned = true } 22 | 23 | static let orIfNull (def: 'a) (value: 'a) : 'a when 'a : not struct = 24 | if obj.ReferenceEquals(value, null) then def else value 25 | 26 | static let defaultDiffer d = 27 | if obj.ReferenceEquals(d, null) then DifferImpl.simple else d 28 | 29 | /// The default print parameters for simple printing. 30 | static member SimplePrintParams = simplePrintParams 31 | 32 | /// The default print parameters for assertions. 33 | static member AssertPrintParams = assertPrintParams 34 | 35 | /// Get a differ for a specific type. 36 | /// Custom differs to handle specific types. 37 | static member GetDiffer<'T>([] customDiffers: ICustomDiffer[]) = 38 | match customDiffers with 39 | | null | [||] -> DifferImpl.simple<'T> 40 | | [| customDiffer |] -> DifferImpl.diffWith<'T> customDiffer (Dictionary()) 41 | | _ -> DifferImpl.diffWith<'T> (CombinedCustomDiffer(customDiffers)) (Dictionary()) 42 | 43 | /// Compute the diff between two values. 44 | /// The first value to diff. 45 | /// The second value to dif. 46 | /// The differ to use. If null, use GetDiffer<T>(). 47 | /// The diff between the two objects, or None if they are found equal. 48 | static member Diff<'T>(expected: 'T, actual: 'T, [] differ: IDiffer<'T>) = 49 | let differ = defaultDiffer differ 50 | differ.Diff(expected, actual) 51 | 52 | /// Throw if a diff is non-empty. 53 | /// The diff to check. 54 | /// The printing parameters used to generate the exception message. 55 | static member Assert(diff: Diff option, [] param: PrintParams) = 56 | let param = param |> orIfNull assertPrintParams 57 | if Option.isSome diff then 58 | DiffPrinter.toString param diff 59 | |> AssertionFailedException 60 | |> raise 61 | 62 | /// Throw if a diff is non-empty. 63 | /// The first value to diff. 64 | /// The second value to dif. 65 | /// The differ to use. If null, use GetDiffer<T>(). 66 | /// The printing parameters used to generate the exception message. 67 | static member Assert<'T>(expected: 'T, actual: 'T, [] differ: IDiffer<'T>, [] param: PrintParams) = 68 | let diff = Differ.Diff(expected, actual, differ) 69 | Differ.Assert(diff, param) 70 | 71 | /// Print a diff to a string. 72 | /// The diff to print. 73 | /// The printing parameters. 74 | static member ToString(diff: Diff option, [] param: PrintParams) = 75 | let param = param |> orIfNull simplePrintParams 76 | DiffPrinter.toString param diff 77 | 78 | /// Print a diff to a string. 79 | /// The first value to diff. 80 | /// The second value to dif. 81 | /// The differ to use. If null, use GetDiffer<T>(). 82 | /// The printing parameters. 83 | static member ToString<'T>(expected: 'T, actual: 'T, [] differ: IDiffer<'T>, [] param: PrintParams) = 84 | let diff = Differ.Diff(expected, actual, differ) 85 | Differ.ToString(diff, param) 86 | 87 | /// Print a diff to a TextWriter. 88 | /// The diff to print. 89 | /// The writer to print to. If null, use standard output. 90 | /// The printing parameters. 91 | static member Write(diff: Diff option, [] writer: TextWriter, [] param: PrintParams) = 92 | let writer = writer |> orIfNull stdout 93 | let param = param |> orIfNull simplePrintParams 94 | DiffPrinter.write param writer diff 95 | 96 | /// Print a diff to a TextWriter. 97 | /// The first value to diff. 98 | /// The second value to dif. 99 | /// The differ to use. If null, use GetDiffer<T>(). 100 | /// The writer to print to. If null, use standard output. 101 | /// The printing parameters. 102 | static member Write<'T>(expected: 'T, actual: 'T, [] writer: TextWriter, [] differ: IDiffer<'T>, [] param: PrintParams) = 103 | let diff = Differ.Diff(expected, actual, differ) 104 | Differ.Write(diff, writer, param) 105 | -------------------------------------------------------------------------------- /src/Diffract/Diffract.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.0 5 | true 6 | latest 7 | true 8 | https://github.com/d-edge/diffract 9 | DEdge.Diffract 10 | 11 | 12 | true 13 | true 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Diffract/Extensions.fs: -------------------------------------------------------------------------------- 1 | namespace DEdge.Diffract 2 | 3 | open System.Collections.Generic 4 | open System.IO 5 | open System.Runtime.CompilerServices 6 | open System.Runtime.InteropServices 7 | 8 | [] 9 | type Extensions private () = 10 | 11 | /// Throw if a diff is non-empty. 12 | /// The differ to use. 13 | /// The first value to diff. 14 | /// The second value to dif. 15 | /// The printing parameters used to generate the exception message. 16 | [] 17 | static member Assert(differ: IDiffer<'T>, expected: 'T, actual: 'T, [] param: PrintParams) = 18 | Differ.Assert(expected, actual, differ, param) 19 | 20 | /// Print a diff to a string. 21 | /// The differ to use. 22 | /// The first value to diff. 23 | /// The second value to dif. 24 | /// The printing parameters. 25 | [] 26 | static member ToString(differ: IDiffer<'T>, expected: 'T, actual: 'T, [] param: PrintParams) = 27 | Differ.ToString(expected, actual, differ, param) 28 | 29 | /// Print a diff to a TextWriter. 30 | /// The differ to use. 31 | /// The first value to diff. 32 | /// The second value to dif. 33 | /// The writer to print to. If null, use standard output. 34 | /// The printing parameters. 35 | [] 36 | static member Write(differ: IDiffer<'T>, expected: 'T, actual: 'T, [] writer: TextWriter, [] param: PrintParams) = 37 | Differ.Write(expected, actual, writer, differ, param) 38 | 39 | /// Get a differ with support for specific types. 40 | /// A custom differ to handle specific types. 41 | [] 42 | static member GetDiffer<'T>(custom: ICustomDiffer) = 43 | DifferImpl.diffWith<'T> custom (Dictionary()) 44 | 45 | /// Use in a custom differ to cast a differ for one type into a differ for another type. 46 | /// TypeShape documentation 47 | [] 48 | static member Unwrap<'T, 'U>(differ: IDiffer<'T>) = 49 | tryUnbox> differ 50 | -------------------------------------------------------------------------------- /src/Diffract/ReadOnlyDictionaryShape.fs: -------------------------------------------------------------------------------- 1 | namespace DEdge.Diffract.ReadOnlyDictionaryShape 2 | 3 | open System 4 | open System.Collections.Generic 5 | open TypeShape.Core 6 | 7 | type IReadOnlyDictionaryVisitor<'R> = 8 | abstract Visit<'Dict, 'K, 'V when 'K : equality and 'Dict :> IReadOnlyDictionary<'K, 'V>> : unit -> 'R 9 | 10 | type IShapeReadOnlyDictionary = 11 | abstract Key : TypeShape 12 | abstract Value : TypeShape 13 | abstract Accept : visitor: IReadOnlyDictionaryVisitor<'R> -> 'R 14 | 15 | type private ShapeReadOnlyDictionary<'Dict, 'K, 'V when 'K : equality and 'Dict :> IReadOnlyDictionary<'K, 'V>>() = 16 | interface IShapeReadOnlyDictionary with 17 | member _.Key = shapeof<'K> :> _ 18 | member _.Value = shapeof<'V> :> _ 19 | member _.Accept(v) = v.Visit<'Dict, 'K, 'V>() 20 | 21 | module Shape = 22 | 23 | let (|GenericInterface|_|) (fullName: string) (s: TypeShape) = 24 | match s.Type.GetInterface(fullName) with 25 | | null -> 26 | match s.ShapeInfo with 27 | | Generic (td, ta) when td.FullName = fullName -> Some ta 28 | | _ -> None 29 | | iface -> 30 | Some (iface.GetGenericArguments()) 31 | 32 | let (|ReadOnlyDictionary|_|) (s: TypeShape) = 33 | match s with 34 | | GenericInterface "System.Collections.Generic.IReadOnlyDictionary`2" ta -> 35 | Activator.CreateInstanceGeneric>(Array.append [|s.Type|] ta) 36 | :?> IShapeReadOnlyDictionary 37 | |> Some 38 | | _ -> None 39 | -------------------------------------------------------------------------------- /src/Diffract/Types.fs: -------------------------------------------------------------------------------- 1 | namespace DEdge.Diffract 2 | 3 | open System.Collections.Generic 4 | open System.IO 5 | open System.Runtime.InteropServices 6 | open TypeShape.Core 7 | 8 | /// A computed diff between two objects. 9 | type Diff = 10 | /// The objects are leaf values and are different. 11 | | Value of x1: obj * x2: obj 12 | /// One of the objects is null and the other isn't. 13 | | Nullness of x1: obj * x2: obj 14 | /// The objects are records or plain objects and some of their fields differ. 15 | | Record of fields: IReadOnlyList 16 | /// The objects are F# unions with different cases. 17 | | UnionCase of caseName1: string * caseName2: string 18 | /// The objects are F# unions with the same case but some of their fields differ. 19 | | UnionField of case: string * fields: IReadOnlyList 20 | /// The objects are collections and their lengths and/or some of their items differ. 21 | | Collection of count1: int * count2: int * items: IReadOnlyList 22 | /// The objects are dictionaries and some items are only present in one of them and/or some of their items differ. 23 | | Dictionary of keysInX1: IReadOnlyList * keysInX2: IReadOnlyList * common: IReadOnlyList 24 | /// The objects are considered different by a custom differ. 25 | | Custom of ICustomDiff 26 | 27 | static member MakeCustom(printer: System.Func<_, _, _, _, _, _>) = 28 | Custom { new ICustomDiff with member _.WriteTo(w, p, i, pa, r) = printer.Invoke(w, p, i, pa, r) } 29 | 30 | /// A computed diff between two values of a field. 31 | and [] FieldDiff = 32 | { 33 | /// The name of the field. 34 | Name: string 35 | /// The diff between the values. 36 | Diff: Diff 37 | } 38 | 39 | /// Parameterize the display of a diff. 40 | and PrintParams = 41 | { 42 | /// The string used to indent items. Default: " " 43 | indent: string 44 | /// The name given to the first object. Default: "Expect" 45 | x1Name: string 46 | /// The name given to the second object. Default: "Actual" 47 | x2Name: string 48 | /// The common name given to both objects. Default: "Value" 49 | neutralName: string 50 | /// Ensure that Expect and Actual remain aligned even if there is text before the first line 51 | /// by prepending a newline if the diff is a single Value. 52 | /// Default: true for Assert(), false for Write() and ToString(). 53 | ensureFirstLineIsAligned: bool 54 | } 55 | 56 | /// A custom computed diff that. 57 | and ICustomDiff = 58 | /// Print this diff. 59 | /// The writer to write the diff to. 60 | /// The printing parameters. 61 | /// The current indentation level. Should always be a replication of param.indent. 62 | /// The drilled-down path to access the currently diffed values from the root objects. 63 | /// The function to call to recursively print an inner diff. 64 | /// Takes indent, path and the inner diff to print as arguments. 65 | abstract WriteTo : writer: TextWriter * param: PrintParams * indent: string * path: string * recur: (string -> string -> Diff -> unit) -> unit 66 | 67 | /// A differ for a specific type. 68 | type IDiffer<'T> = 69 | /// Diff two values. 70 | abstract Diff : x1: 'T * x2: 'T -> Diff option 71 | 72 | /// Generates a differ for any given type. 73 | type IDifferFactory = 74 | /// Get the differ for a given type. 75 | abstract GetDiffer<'T> : unit -> IDiffer<'T> 76 | 77 | /// Generates a differ for a specific type or set of types. 78 | type ICustomDiffer = 79 | /// Get the differ for this type. 80 | /// The factory to use to get differs for nested values. 81 | /// The TypeShape for the current type. 82 | /// A differ for this type, or None if this custom differ doesn't handle this type. 83 | abstract GetCustomDiffer<'T> : differFactory: IDifferFactory * shape: TypeShape<'T> -> IDiffer<'T> option 84 | 85 | type NoCustomDiffer() = 86 | interface ICustomDiffer with 87 | member _.GetCustomDiffer(_, _) = None 88 | 89 | type CombinedCustomDiffer(customDiffers: seq) = 90 | interface ICustomDiffer with 91 | member _.GetCustomDiffer(differ, shape) = 92 | customDiffers |> Seq.tryPick (fun customDiffer -> customDiffer.GetCustomDiffer(differ, shape)) 93 | 94 | /// Thrown when the differ found differences between two objects. 95 | type AssertionFailedException(diff: string) = 96 | inherit System.Exception(diff) 97 | 98 | /// Thrown when the differ factory couldn't build a differ for a type. 99 | type DifferConstructionFailedException(message: string, [] innerException: exn) = 100 | inherit System.Exception(message, innerException) 101 | -------------------------------------------------------------------------------- /src/Diffract/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | TypeShape -------------------------------------------------------------------------------- /src/Diffract/paket.template: -------------------------------------------------------------------------------- 1 | type project 2 | name DEdge.Diffract 3 | licenseExpression MIT 4 | licenseUrl https://licenses.nuget.org/MIT 5 | authors Loïc Denuzière 6 | copyright Copyright 2021 D-EDGE 7 | description 8 | Display a readable diff between two objects 9 | projectUrl https://github.com/d-edge/Diffract 10 | repositoryUrl https://github.com/d-edge/Diffract 11 | repositoryType git 12 | requireLicenseAcceptance true 13 | tags DEdge;Diffract;Diff;Equals;Test;Comparison 14 | iconUrl https://raw.githubusercontent.com/d-edge/Diffract/main/diffract-64x64.png 15 | readme README.md 16 | files 17 | README.md ==> . 18 | -------------------------------------------------------------------------------- /tests/Diffract.CSharp.Tests/CustomDiffers.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.FSharp.Core; 2 | using TypeShape.Core; 3 | using Xunit; 4 | 5 | namespace DEdge.Diffract.CSharp.Tests 6 | { 7 | public class CustomDiffers 8 | { 9 | [Fact] 10 | public void NoCustomDiffer() 11 | { 12 | var expectedDiff = @"DDDDD.XXX Expect = ""a"" 13 | Actual = ""b"" 14 | "; 15 | var expected = new Container(new CustomDiffable("a")); 16 | var actual = new Container(new CustomDiffable("b")); 17 | var actualDiff = Differ.ToString(expected, actual); 18 | AssertStr(expectedDiff, actualDiff); 19 | } 20 | 21 | [Fact] 22 | public void CustomDiffer() 23 | { 24 | var expectedDiff = @"DDDDD Expect = ""a"" 25 | Actual = ""b"" 26 | "; 27 | var expected = new Container(new CustomDiffable("a")); 28 | var actual = new Container(new CustomDiffable("b")); 29 | var actualDiff = MyDiffer.Get().ToString(expected, actual); 30 | AssertStr(expectedDiff, actualDiff); 31 | } 32 | 33 | [Fact] 34 | public void CustomDifferWithCombinators() 35 | { 36 | var expectedDiff = @"DDDDD Expect = ""a"" 37 | Actual = ""b"" 38 | "; 39 | var expected = new Container(new CustomDiffable("a")); 40 | var actual = new Container(new CustomDiffable("b")); 41 | var actualDiff = MyDifferWithCombinators.Get().ToString(expected, actual); 42 | AssertStr(expectedDiff, actualDiff); 43 | } 44 | 45 | [Fact] 46 | public void CustomDifferWithMap() 47 | { 48 | var expectedDiff = @"DDDDD Expect = ""a"" 49 | Actual = ""b"" 50 | "; 51 | var expected = new Container(new CustomDiffable("a")); 52 | var actual = new Container(new CustomDiffable("b")); 53 | var actualDiff = MyDifferWithMap.Get().ToString(expected, actual); 54 | AssertStr(expectedDiff, actualDiff); 55 | } 56 | 57 | [Fact] 58 | public void CustomDifferWithMapToAnonymousObject() 59 | { 60 | var expectedDiff = @"DDDDD.v Expect = ""a"" 61 | Actual = ""b"" 62 | "; 63 | var expected = new Container(new CustomDiffable("a")); 64 | var actual = new Container(new CustomDiffable("b")); 65 | var actualDiff = MyDifferWithMapToAnonymousObject.Get().ToString(expected, actual); 66 | AssertStr(expectedDiff, actualDiff); 67 | } 68 | 69 | public record CustomDiffable(string XXX); 70 | 71 | public record Container(CustomDiffable DDDDD); 72 | 73 | public class MyDiffer : IDiffer 74 | { 75 | private readonly IDiffer _stringDiffer; 76 | 77 | public MyDiffer(IDifferFactory differFactory) 78 | { 79 | _stringDiffer = differFactory.GetDiffer(); 80 | } 81 | 82 | public FSharpOption Diff(CustomDiffable x1, CustomDiffable x2) => 83 | _stringDiffer.Diff(x1.XXX, x2.XXX); 84 | 85 | public static IDiffer Get() => Singleton.Instance; 86 | 87 | private static class Singleton 88 | { 89 | public static readonly IDiffer Instance = new CustomDiffer().GetDiffer(); 90 | } 91 | 92 | private class CustomDiffer : ICustomDiffer 93 | { 94 | public FSharpOption> GetCustomDiffer(IDifferFactory differFactory, Core.TypeShape shape) => 95 | shape.Type == typeof(CustomDiffable) 96 | ? new MyDiffer(differFactory).Unwrap() 97 | : null; 98 | } 99 | } 100 | 101 | public static class MyDifferWithCombinators 102 | { 103 | public static IDiffer Get() => Singleton.Instance; 104 | 105 | private static class Singleton 106 | { 107 | public static readonly IDiffer Instance = CustomDiffer.GetDiffer(); 108 | } 109 | 110 | private static readonly ICustomDiffer CustomDiffer = 111 | CustomDiffer.Build(factory => 112 | { 113 | var stringDiffer = factory.GetDiffer(); 114 | return (x1, x2) => stringDiffer.Diff(x1.XXX, x2.XXX); 115 | }); 116 | } 117 | 118 | public static class MyDifferWithMap 119 | { 120 | public static IDiffer Get() => Singleton.Instance; 121 | 122 | private static class Singleton 123 | { 124 | public static readonly IDiffer Instance = CustomDiffer.GetDiffer(); 125 | } 126 | 127 | private static readonly ICustomDiffer CustomDiffer = 128 | CustomDiffer.Map(x => x.XXX); 129 | } 130 | 131 | public static class MyDifferWithMapToAnonymousObject 132 | { 133 | public static IDiffer Get() => Singleton.Instance; 134 | 135 | private static class Singleton 136 | { 137 | public static readonly IDiffer Instance = CustomDiffer.GetDiffer(); 138 | } 139 | 140 | private static readonly ICustomDiffer CustomDiffer = 141 | CustomDiffer.Map(x => new { v = x.XXX }); 142 | } 143 | 144 | private void AssertStr(string expected, string actual) 145 | { 146 | Assert.Equal(expected.Replace("\r\n", "\n"), actual); 147 | } 148 | } 149 | } -------------------------------------------------------------------------------- /tests/Diffract.CSharp.Tests/Diffract.CSharp.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net8.0 4 | false 5 | DEdge.Diffract.CSharp.Tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/Diffract.CSharp.Tests/Tests.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace DEdge.Diffract.CSharp.Tests 4 | { 5 | public class Tests 6 | { 7 | [Fact] 8 | public void Poco() 9 | { 10 | var expected = new MyPoco { Item = new MyInnerPoco(1, "a", 1) }; 11 | var actual = new MyPoco { Item = new MyInnerPoco(2, "a", 2) }; 12 | Assert.Equal("Item.X Expect = 1\n Actual = 2\n", 13 | Differ.ToString(expected, actual)); 14 | } 15 | 16 | [Fact] 17 | public void Record() 18 | { 19 | var expected = new MyRecord(1, "a"); 20 | var actual = new MyRecord(2, "a"); 21 | Assert.Equal("X Expect = 1\n Actual = 2\n", 22 | Differ.ToString(expected, actual)); 23 | } 24 | 25 | public class MyInnerPoco 26 | { 27 | public int X { get; } 28 | public string Y { get; } 29 | private int Z { get; } 30 | 31 | public MyInnerPoco(int x, string y, int z) 32 | { 33 | X = x; 34 | Y = y; 35 | Z = z; 36 | } 37 | } 38 | 39 | public class MyPoco 40 | { 41 | public MyInnerPoco Item { get; init; } 42 | } 43 | 44 | public record MyRecord(int X, string Y); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Diffract.CSharp.Tests/paket.references: -------------------------------------------------------------------------------- 1 | group test 2 | Microsoft.NET.Test.Sdk 3 | xunit 4 | xunit.runner.visualstudio 5 | FsCheck 6 | FsCheck.Xunit -------------------------------------------------------------------------------- /tests/Diffract.Tests/Diffract.Tests.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | false 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/Diffract.Tests/Program.fs: -------------------------------------------------------------------------------- 1 | module Program = let [] main _ = 0 2 | -------------------------------------------------------------------------------- /tests/Diffract.Tests/Tests.fs: -------------------------------------------------------------------------------- 1 | module Tests 2 | 3 | #nowarn "40" 4 | 5 | open Xunit 6 | open FsCheck 7 | open FsCheck.Xunit 8 | open DEdge.Diffract 9 | 10 | type Foo = { xxxxx: int; yyyyy: bool } 11 | type U = 12 | | U1 of int 13 | | U2 of x: int * y: int 14 | type Bar = { aaa: Foo; bbb: U } 15 | 16 | type Baz = { xx: int; yy: string } 17 | 18 | [] 19 | type Class(x: int) = 20 | member _.X = x 21 | 22 | let assertStr (expected: string, actual: string) = 23 | Assert.Equal(expected.Replace("\r\n", "\n"), actual) 24 | 25 | [] 26 | let ``No exception for equal values`` (x: Bar) = 27 | Differ.Assert(x, x) 28 | 29 | [] 30 | let ``Exception for non-equal values`` (x: Bar) (y: Bar) = 31 | x <> y ==> lazy 32 | Assert.Throws(fun () -> 33 | Differ.Assert(x, y)) 34 | |> ignore 35 | 36 | [] 37 | let ``Both have null leaf`` () = 38 | let actual = Differ.Diff({ xx = 1; yy = null }, { xx = 1; yy = null }) 39 | Assert.Equal(None, actual) 40 | 41 | [] 42 | let ``Expected has null leaf`` () = 43 | let actual = Differ.Diff({ xx = 1; yy = null }, { xx = 1; yy = "a" }) 44 | Assert.Equal(Some(Diff.Record [ { Name = "yy"; Diff = Diff.Value(null, "a") } ]), actual) 45 | 46 | let actual = Differ.ToString({ xx = 1; yy = null }, { xx = 1; yy = "a" }) 47 | assertStr("\ 48 | yy Expect is null 49 | Actual = \"a\" 50 | ", actual) 51 | 52 | [] 53 | let ``Actual has null leaf`` () = 54 | let actual = Differ.Diff({ xx = 1; yy = "a" }, { xx = 1; yy = null }) 55 | Assert.Equal(Some(Diff.Record [ { Name = "yy"; Diff = Diff.Value("a", null) } ]), actual) 56 | 57 | let actual = Differ.ToString({ xx = 1; yy = "a" }, { xx = 1; yy = null }) 58 | assertStr("\ 59 | yy Expect = \"a\" 60 | Actual is null 61 | ", actual) 62 | 63 | [] 64 | let ``Both are null`` () = 65 | let actual = Differ.Diff((null: Class), null) 66 | Assert.Equal(None, actual) 67 | 68 | [] 69 | let ``Expected is null`` () = 70 | let actualValue = Class(3) 71 | let actual = Differ.Diff(null, actualValue) 72 | Assert.Equal(Some(Diff.Nullness(null, actualValue)), actual) 73 | 74 | let actual = Differ.ToString(null, actualValue) 75 | assertStr("\ 76 | Expect is null 77 | Actual is not null 78 | ", actual) 79 | 80 | [] 81 | let ``Actual is null`` () = 82 | let actualValue = Class(3) 83 | let actual = Differ.Diff(actualValue, null) 84 | Assert.Equal(Some(Diff.Nullness(actualValue, null)), actual) 85 | 86 | let actual = Differ.ToString(actualValue, null) 87 | assertStr("\ 88 | Expect is not null 89 | Actual is null 90 | ", actual) 91 | 92 | [] 93 | let ``List diff`` (l1: int list) (l2: int list) = 94 | let d = Differ.Diff(l1, l2) 95 | if l1 = l2 then 96 | d = None 97 | else 98 | let expectedDiffs = 99 | (l1, l2) 100 | ||> Seq.mapi2 (fun i x1 x2 -> Differ.Diff(x1, x2) |> Option.map (fun d -> { Name = string i; Diff = d })) 101 | |> Seq.choose id 102 | |> List.ofSeq 103 | d = Some (Diff.Collection (l1.Length, l2.Length, expectedDiffs)) 104 | 105 | [] 106 | let ``Example output`` () = 107 | assertStr("\ 108 | Value differs by 2 fields: 109 | aaa.yyyyy Expect = true 110 | Actual = false 111 | bbb differs by union case: 112 | Expect is U1 113 | Actual is U2 114 | ", 115 | Differ.ToString( 116 | { aaa = { xxxxx = 1; yyyyy = true } 117 | bbb = U1 1 }, 118 | { aaa = { xxxxx = 1; yyyyy = false } 119 | bbb = U2 (1, 2) })) 120 | 121 | [] 122 | let ``Example error message`` () = 123 | let ex = Assert.Throws(fun () -> 124 | Differ.Assert( 125 | { aaa = { xxxxx = 1; yyyyy = true } 126 | bbb = U1 1 }, 127 | { aaa = { xxxxx = 1; yyyyy = false } 128 | bbb = U2 (1, 2) })) 129 | assertStr("\ 130 | Value differs by 2 fields: 131 | aaa.yyyyy Expect = true 132 | Actual = false 133 | bbb differs by union case: 134 | Expect is U1 135 | Actual is U2 136 | ", 137 | ex.Message) 138 | 139 | [] 140 | let ``Ensure first line is aligned`` () = 141 | let ex = Assert.Throws(fun () -> Differ.Assert(12, 13)) 142 | assertStr(" 143 | Expect = 12 144 | Actual = 13 145 | ", ex.Message) 146 | assertStr("\ 147 | Expect = 12 148 | Actual = 13 149 | ", Differ.ToString(12, 13)) 150 | 151 | [] 152 | let ``Example collection`` () = 153 | assertStr("\ 154 | xxx collection differs: 155 | xxx.Count Expect = 2 156 | Actual = 3 157 | xxx[1] Expect = 3 158 | Actual = 2 159 | ", 160 | Differ.ToString( 161 | {| xxx = [1; 3] |}, 162 | {| xxx = [1; 2; 3] |})) 163 | 164 | type CustomDiffable = { xxx: string } 165 | 166 | module MyDiffModule = 167 | 168 | type CustomDiffer() = 169 | interface ICustomDiffer with 170 | member this.GetCustomDiffer<'T>(differFactory, shape) = 171 | if shape.Type = typeof then 172 | let differ = differFactory.GetDiffer() 173 | { new IDiffer with 174 | member _.Diff(x1, x2) = differ.Diff(x1.xxx, x2.xxx) } 175 | |> unbox> 176 | |> Some 177 | else 178 | None 179 | 180 | let differ<'T> = CustomDiffer().GetDiffer<'T>() 181 | 182 | module MyDiffWithCombinators = 183 | 184 | let customDiffer = CustomDiffer.Build(fun factory -> 185 | let stringDiffer = factory.GetDiffer() 186 | fun x1 x2 -> stringDiffer.Diff(x1.xxx, x2.xxx)) 187 | 188 | let differ<'T> = customDiffer.GetDiffer<'T>() 189 | 190 | type MyDiffer(differFactory: IDifferFactory) = 191 | let stringDiffer = differFactory.GetDiffer() 192 | 193 | interface IDiffer with 194 | member _.Diff(x1, x2) = stringDiffer.Diff(x1.xxx, x2.xxx) 195 | 196 | type MyCustomDiffer() = 197 | interface ICustomDiffer with 198 | member this.GetCustomDiffer<'T>(differFactory, shape) = 199 | if shape.Type = typeof then 200 | MyDiffer(differFactory).Unwrap() 201 | else 202 | None 203 | 204 | type MyDiffType<'T>() = 205 | static member val Differ = MyCustomDiffer().GetDiffer<'T>() 206 | 207 | type MyDiffTypeWithCombinators<'T>() = 208 | static let customDiffer = CustomDiffer.Build(fun factory -> 209 | let stringDiffer = factory.GetDiffer() 210 | fun x1 x2 -> stringDiffer.Diff(x1.xxx, x2.xxx)) 211 | 212 | static member val Differ = customDiffer.GetDiffer<'T>() 213 | 214 | [] 215 | let ``Custom differ`` () = 216 | assertStr("\ 217 | xxx Expect = \"a\" 218 | Actual = \"b\" 219 | ", 220 | Differ.ToString({ xxx = "a" }, { xxx = "b" })) 221 | assertStr("\ 222 | Expect = \"a\" 223 | Actual = \"b\" 224 | ", 225 | Differ.ToString({ xxx = "a" }, { xxx = "b" }, MyDiffModule.differ)) 226 | assertStr("\ 227 | Expect = \"a\" 228 | Actual = \"b\" 229 | ", 230 | Differ.ToString({ xxx = "a" }, { xxx = "b" }, MyDiffWithCombinators.differ)) 231 | assertStr("\ 232 | Expect = \"a\" 233 | Actual = \"b\" 234 | ", 235 | Differ.ToString({ xxx = "a" }, { xxx = "b" }, MyDiffType.Differ)) 236 | assertStr("\ 237 | Expect = \"a\" 238 | Actual = \"b\" 239 | ", 240 | Differ.ToString({ xxx = "a" }, { xxx = "b" }, MyDiffTypeWithCombinators.Differ)) 241 | 242 | module ``Custom differ with custom diff output`` = 243 | 244 | let myCustomDiffer = CustomDiffer.Build(fun x1 x2 -> 245 | if x1.xxx = x2.xxx then 246 | None 247 | else 248 | Diff.MakeCustom(fun writer param indent path recur -> 249 | if param.ensureFirstLineIsAligned then writer.WriteLine() 250 | let indentLike str = String.replicate (String.length str) " " 251 | let dpath = if path = "" then "" else path + " " 252 | writer.WriteLine($"{indent}{dpath}{param.x1Name} __is__ {x1.xxx}") 253 | writer.WriteLine($"{indent}{indentLike dpath}{param.x2Name} __is__ {x2.xxx}")) 254 | |> Some) 255 | 256 | let differ<'T> = myCustomDiffer.GetDiffer<'T>() 257 | 258 | [] 259 | let ``Assert with immediate value adds newline`` () = 260 | let ex = Assert.Throws(fun () -> 261 | Differ.Assert({ xxx = "a" }, { xxx = "b" }, differ)) 262 | assertStr(" 263 | Expect __is__ a 264 | Actual __is__ b 265 | ", ex.Message) 266 | 267 | [] 268 | let ``Assert with nested value doesn't add newline`` () = 269 | let ex = Assert.Throws(fun () -> 270 | Differ.Assert({| iiiii = { xxx = "a" } |}, {| iiiii = { xxx = "b" } |}, differ)) 271 | assertStr("\ 272 | iiiii Expect __is__ a 273 | Actual __is__ b 274 | ", ex.Message) 275 | 276 | [] 277 | let ``ToString with immediate value doesn't add newline`` () = 278 | let diff = Differ.ToString({ xxx = "a" }, { xxx = "b" }, differ) 279 | assertStr("\ 280 | Expect __is__ a 281 | Actual __is__ b 282 | ", diff) 283 | 284 | [] 285 | let ``ToString with nested value doesn't add newline`` () = 286 | let diff = Differ.ToString({| iiiii = { xxx = "a" } |}, {| iiiii = { xxx = "b" } |}, differ) 287 | assertStr("\ 288 | iiiii Expect __is__ a 289 | Actual __is__ b 290 | ", diff) 291 | 292 | type Rec = { xRec: Rec option } 293 | 294 | [] 295 | let ``Recursive type`` () = 296 | let x1 = { xRec = Some { xRec = None } } 297 | let x2 = { xRec = Some { xRec = Some { xRec = None } } } 298 | assertStr("\ 299 | xRec.Value.xRec differs by union case: 300 | Expect is None 301 | Actual is Some 302 | ", 303 | Differ.ToString(x1, x2)) 304 | 305 | [] 306 | let ``Anonymous record`` () = 307 | Assert.Null(Differ.Diff({| xxx = 1; yyy = "2" |}, {| xxx = 1; yyy = "2" |})) 308 | assertStr("\ 309 | xxx Expect = 1 310 | Actual = 2 311 | ", 312 | Differ.ToString({| xxx = 1; yyy = "2" |}, {| xxx = 2; yyy = "2" |})) 313 | -------------------------------------------------------------------------------- /tests/Diffract.Tests/paket.references: -------------------------------------------------------------------------------- 1 | group test 2 | Microsoft.NET.Test.Sdk 3 | xunit 4 | xunit.runner.visualstudio 5 | FsCheck 6 | FsCheck.Xunit -------------------------------------------------------------------------------- /tests/Test.fsx: -------------------------------------------------------------------------------- 1 | #r "nuget: TypeShape" 2 | #load "../src/Diffract/Types.fs" 3 | #load "../src/Diffract/ReadOnlyDictionaryShape.fs" 4 | #load "../src/Diffract/Differ.fs" 5 | #load "../src/Diffract/DiffPrinter.fs" 6 | #load "../src/Diffract/Diffract.fs" 7 | #load "../src/Diffract/Extensions.fs" 8 | 9 | open Diffract 10 | 11 | type Foo = { x: int; y: float } 12 | type U = 13 | | U1 of int 14 | | U2 of x: int * y: int 15 | type Bar = { a: Foo; b: U } 16 | 17 | Differ.simple.Print( 18 | [ { a = { x = 2; y = 1. } 19 | b = U2 (2, 1) } 20 | { a = { x = 2; y = 1. } 21 | b = U2 (2, 1) } ], 22 | [ { a = { x = 2; y = 1. } 23 | b = U2 (2, 1) } 24 | { a = { x = 1; y = 1. } 25 | b = U2 (2, 3) } ]) 26 | 27 | Differ.simple.Print( 28 | Map [(1, 3); (2, 2); (3, 1)], 29 | Map [(2, 2); (3, 2); (4, 1)]) 30 | --------------------------------------------------------------------------------