├── .github └── workflows │ ├── check.yml │ ├── generate-nuget.yml │ └── reduced-check.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md └── src ├── .config └── dotnet-tools.json ├── .gitignore ├── Check.ps1 ├── CheckHelpInReadme.ps1 ├── DoctestCsharp.Test ├── ConsoleCapture.cs ├── DoctestCsharp.Test.csproj ├── TemporaryDirectory.cs ├── TestExtraction.cs ├── TestGeneration.cs ├── TestInput.cs ├── TestProcess.cs └── TestProgram.cs ├── DoctestCsharp ├── DoctestCsharp.csproj ├── Extraction.cs ├── Generation.cs ├── Input.cs ├── Process.cs ├── Program.cs └── Report.cs ├── doctest-csharp.sln └── doctest-csharp.sln.DotSettings /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - '*/doc/*' 7 | - '*/workflow/*' 8 | 9 | jobs: 10 | Execute: 11 | runs-on: windows-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | 15 | - name: Check the commit message(s) 16 | uses: mristin/opinionated-commit-message@v2.1.2 17 | with: 18 | additional-verbs: 'simplify' 19 | 20 | - name: Install .NET core 21 | uses: actions/setup-dotnet@v1 22 | with: 23 | dotnet-version: '6.x' 24 | 25 | - name: Restore dotnet tools 26 | working-directory: src 27 | run: dotnet tool restore 28 | 29 | - name: Check format 30 | working-directory: src 31 | run: dotnet format --verify-no-changes 32 | 33 | - name: Check line width 34 | working-directory: src 35 | run: dotnet bite-sized --inputs '**/*.cs' --excludes '**/obj/**' 36 | 37 | - name: Check dead code 38 | working-directory: src 39 | run: dotnet dead-csharp --inputs '**/*.cs' --excludes '**/obj/**' 40 | 41 | - name: dotnet publish 42 | working-directory: src 43 | run: dotnet publish -c Release -o ..\out 44 | 45 | - name: Check help in Readme 46 | working-directory: src 47 | run: powershell .\CheckHelpInReadme.ps1 48 | 49 | - name: Test 50 | working-directory: src 51 | run: dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover 52 | 53 | - name: Send to Coveralls 54 | working-directory: src 55 | env: 56 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 57 | run: | 58 | $BRANCH=${env:GITHUB_REF} -replace 'refs/heads/', '' 59 | echo "Branch is: $BRANCH" 60 | echo "Commit is: $env:GITHUB_SHA" 61 | dotnet tool run csmacnz.Coveralls --opencover -i DoctestCsharp.Test\coverage.opencover.xml --useRelativePaths --repoToken $env:COVERALLS_REPO_TOKEN --commitId $env:GITHUB_SHA --commitBranch $BRANCH 62 | -------------------------------------------------------------------------------- /.github/workflows/generate-nuget.yml: -------------------------------------------------------------------------------- 1 | name: Generate NuGet 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | branches: 9 | - '*/Fix-nuget-publishing' 10 | 11 | jobs: 12 | build: 13 | runs-on: windows-latest 14 | name: Update NuGet package 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v1 18 | 19 | - name: Install .NET core 20 | uses: actions/setup-dotnet@v1 21 | with: 22 | dotnet-version: '6.x' 23 | 24 | - name: Build solution and generate NuGet package 25 | working-directory: src 26 | run: dotnet pack -c Release -o out 27 | 28 | - name: Push generated package 29 | working-directory: src 30 | env: 31 | NUGET_AUTH_TOKEN: ${{secrets.NUGET_API_KEY}} 32 | run: dotnet nuget push out\*.nupkg --skip-duplicate --no-symbols --source https://api.nuget.org/v3/index.json -k $env:NUGET_AUTH_TOKEN 33 | -------------------------------------------------------------------------------- /.github/workflows/reduced-check.yml: -------------------------------------------------------------------------------- 1 | name: Reduced-Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*/doc/*' 7 | - '*/workflow/*' 8 | 9 | jobs: 10 | Execute: 11 | runs-on: windows-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | 15 | - name: Check the commit message(s) 16 | uses: mristin/opinionated-commit-message@v2.1.2 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | # Vim files 4 | *.swp 5 | 6 | out/ 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Pull Requests 4 | 5 | We develop using the feature branches, see this section of the Git book: 6 | https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows. 7 | 8 | Please prefix the branch with your user name (*e.g.,* `mristin/Add-some-feature`). 9 | If you want to skip the full battery of CI tests, you can add `doc` or `workflow` 10 | qualifier in your branch name (*e.g.*, `mristin/doc/Add-references-to-Readme` or 11 | `mristin/workflow/Fix-nuget-publishing`). 12 | 13 | If you have write permissions to the repository, 14 | create a feature branch directly within the repository. 15 | 16 | Otherwise, if you are a non-member contributor, fork the repository and create 17 | the feature branch in your forked repository. See [this Github tuturial]( 18 | https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork 19 | ) for more guidance. 20 | 21 | ### Commit Messages 22 | 23 | The commit messages follow the guidelines from 24 | from https://chris.beams.io/posts/git-commit: 25 | * Separate subject from body with a blank line 26 | * Limit the subject line to 50 characters 27 | * Capitalize the subject line 28 | * Do not end the subject line with a period 29 | * Use the imperative mood in the subject line 30 | * Wrap the body at 72 characters 31 | * Use the body to explain *what* and *why* (instead of *how*) 32 | 33 | ## Development Environment 34 | 35 | We use `dotnet` command-line tool for all publishing and continuous integration 36 | tasks. Make sure you have .NET core installed (≥ 3.1.100). 37 | 38 | First, change to the `src/` directory. All the subsequent commands should be 39 | invoked from there. 40 | 41 | ### Build 42 | 43 | Change to `src/` directory. 44 | 45 | The solution is built with: 46 | 47 | ```bash 48 | dotnet publish --configuration Release --output out 49 | ``` 50 | 51 | The resulting binaries are available in `src/out/` directory 52 | 53 | ### Continuous Integration 54 | 55 | Change to `src/` directory. 56 | 57 | You need first to restore the tools: 58 | 59 | ```bash 60 | dotnet tool restore 61 | ``` 62 | 63 | Check the format: 64 | 65 | ```bash 66 | dotnet format --check 67 | ``` 68 | 69 | Check the line width: 70 | 71 | ```bash 72 | dotnet bite-sized \ 73 | --inputs '**/*.cs' \ 74 | --excludes '**/obj/**' 75 | ``` 76 | 77 | Check that there is no dead code: 78 | 79 | ```bash 80 | dotnet dead-csharp \ 81 | --inputs '**/*.cs' \ 82 | --excludes '**/obj/**' 83 | ``` 84 | 85 | Run the tests: 86 | 87 | ```bash 88 | dotnet test /p:CollectCoverage=true 89 | ``` 90 | 91 | The individual steps are bundled in [src/Check.ps1](src/Check.ps1). 92 | The remote workflow is defined as Github action in 93 | [src/.github/workflows/check.yml]( 94 | src/.github/workflows/check.yml 95 | ). 96 | 97 | ### Push Nuget Package 98 | 99 | See [src/.github/workflows/generate-nuget.yml]( 100 | src/.github/workflows/generate-nuget.yml 101 | ) for how to generate and publish a NuGet package. 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Marko Ristin 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 | # doctest-csharp 2 | ![Check]( 3 | https://github.com/mristin/doctest-csharp/workflows/Check/badge.svg 4 | ) [![Coverage Status]( 5 | https://coveralls.io/repos/github/mristin/doctest-csharp/badge.svg)]( 6 | https://coveralls.io/github/mristin/doctest-csharp 7 | ) [![Nuget]( 8 | https://img.shields.io/nuget/v/DoctestCsharp)]( 9 | https://www.nuget.org/packages/DoctestCsharp 10 | ) 11 | 12 | Doctest-csharp extracts the `...` snippets from the structured 13 | comments and generates the corresponding test files (so called "doctests"). 14 | 15 | ## Motivation 16 | 17 | It is important to test your documented code snippets to avoid [the code rot]( 18 | https://en.wikipedia.org/wiki/Software_rot 19 | ). 20 | Otherwise, future changes to the code base might render the recorded 21 | instructions in those documentation snippets invalid. This is both confusing 22 | and misleading since the reader is not sure anymore whether it is a mistake 23 | in the documentation, a bug in the code or something else. 24 | 25 | Such a tool is an established standard in many programming languages ( 26 | *e.g.*, Python's [doctest]( 27 | https://docs.python.org/3/library/doctest.html 28 | )), but somewhat surprisingly as of this writing in July 2020, C# lacks it. 29 | Therefore we decided to roll out doctest-csharp. 30 | 31 | ## Related Projects 32 | 33 | **Doctest for F#**. There exists [doctest for F#]( 34 | https://github.com/moodmosaic/doctest/ 35 | ) which is almost identical to doctest-csharp, just for F#. 36 | 37 | **[Dotnet-try](https://github.com/dotnet/try)** goes the other way. The code 38 | examples are written in separate source files and dotnet-try inlines them into 39 | the documentation markdown files. As of July 2020, dotnet-try did not handle 40 | code examples in the structured comments. Hence it is orthogonal to 41 | doctest-csharp: use dotnet-try for snippets in the documentation you manually 42 | write; use doctest-csharp to test your code examples in the structured comments. 43 | 44 | ## Installation 45 | 46 | Doctest-csharp is distributed and run as a dotnet tool. 47 | 48 | To install it globally: 49 | 50 | ```bash 51 | dotnet tool -g DoctestCsharp 52 | ``` 53 | 54 | or locally (if you use tool manifest, see [this Microsoft tutorial]( 55 | https://docs.microsoft.com/en-us/dotnet/core/tools/local-tools-how-to-use 56 | )): 57 | 58 | ```bash 59 | dotnet tool install DoctestCsharp 60 | ``` 61 | 62 | ## Usage 63 | 64 | **Overview.** To obtain an overview of the command-line arguments, use `--help`: 65 | 66 | ```bash 67 | dotnet doctest-csharp --help 68 | ``` 69 | 70 | ``` 71 | DoctestCsharp: 72 | Generates tests from the embedded code snippets in the code documentation. 73 | 74 | Usage: 75 | DoctestCsharp [options] 76 | 77 | Options: 78 | --input-output (REQUIRED) Input and output directory pairs containing the *.cs files The input is separated from the output by the PATH separator (e.g., ';' on Windows, ':' on POSIX).If no output is specified, the --suffix is appended to the input to automatically obtain the output. 79 | -s, --suffix Suffix to be automatically appended to the input to obtain the output directory in cases where no explicit output directory was given [default: .Tests] 80 | -e, --excludes Glob patterns of the files to be excluded from the input. The exclude patterns are either absolute (e.g., rooted with '/') or relative. In case of relative exclude patterns, they are relative to the _input_ directory and NOT to the current working directory. 81 | -c, --check If set, does not generate any files, but only checks that the content of the test files coincides with what would be generated. This is particularly useful in continuous integration pipelines if you want to check if all the files have been scanned and correctly generated. 82 | --verbose If set, outputs only important messages to the users such as errors 83 | --version Show version information 84 | -?, -h, --help Show help and usage information 85 | ``` 86 | 87 | 88 | 89 | **Mark the code snippets.** You need to explicitly mark which `...` 90 | snippets in your documentation should be tested by adding the `doctest` 91 | attribute and setting it to `true`. 92 | 93 | For example: 94 | 95 | ```cs 96 | /// 97 | /// DoSomething("xyz") 98 | /// 99 | ``` 100 | 101 | **Input and output**. You need to specify one or more pairs consisting of 102 | a project folder ("input") and a test folder ("output"). The pair is given 103 | as a concatenation `{input}{PATH separator}{output}`. For example, on a Linux 104 | system the input-output pair might be something like: 105 | `SomeProject:SomeProject.Tests`. The same input-output pair in Windows is: 106 | `SomeProject;SomeProject.Tests`. 107 | 108 | If you omit the output (*e.g.*, `SomeProject:` on Linux), the output is 109 | automatically inferred by appending the `--suffix` command-line argument. 110 | The default `--suffix` is `.Tests`. 111 | 112 | Doctest-csharp will scan all the `**/*.cs` files in the input folder and 113 | generate the unit tests in the output folder. 114 | 115 | The relative paths will be preserved. The resulting doctest files will be 116 | prefixed with `DocTest`. 117 | 118 | For example: 119 | 120 | ```bash 121 | dotnet doctest-csharp \ 122 | --input-output SomeProject:SomeProject.Tests/doctests 123 | ``` 124 | 125 | Assume there exists `SomeProject/SomeFile.cs`. Doctest-csharp will scan it 126 | and generate the corresponding doctests to 127 | `SomeProject.Tests/doctests/DocTestSomeFile.cs`. 128 | 129 | In case you have multiple projects all following the same naming convention, 130 | you can specify `--suffix` and pass in multiple input-only pairs. For example: 131 | 132 | ```bash 133 | dotnet doctest-csharp \ 134 | --input-output \ 135 | SomeProject: \ 136 | AnotherProject: \ 137 | --suffix ".Tests/doctests" 138 | ``` 139 | 140 | The corresponding inferred outputs will be `SomeProject.Tests/doctests` and 141 | `AnotherProject.Tests/doctests`, respectively. 142 | 143 | **Exclude**. If you want to exclude certain files from the scan, use `--exclude` 144 | with a Glob pattern. 145 | 146 | For example: 147 | 148 | ```bash 149 | dotnet doctest-csharp \ 150 | --input-output SomeProject: 151 | --exclude '**/obj/**' 152 | ``` 153 | 154 | **Using directives**. You can specify [using directives]( 155 | https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/using-directive 156 | ) in the "header" of the snippet and separate them with `// ---` from the body 157 | of the doctest. 158 | 159 | Only using directives, and no other statements, are allowed in the header. 160 | 161 | For example: 162 | 163 | ```cs 164 | /// 165 | /// using IO = System.IO; 166 | /// using SomeNamespace; 167 | /// // --- 168 | /// DoSomething( 169 | /// new SomeNamespace.SomeClass( 170 | /// new IO.DirectoryInfo("xyz"))); 171 | /// 172 | ``` 173 | 174 | **Generated code**. We heavily use [NUnit 3](https://nunit.org/) in other 175 | projects, so we decided to base the generated code on that framework. 176 | Please let us know by [submitting a new issue]( 177 | https://github.com/mristin/doctest-csharp/issues/new 178 | ) if you would like to use it with a different testing framework. 179 | 180 | The tests will live in the same namespace as the code snippets suffixed by 181 | `.Test`. The doctests will be identified by line and column in the source 182 | file. 183 | 184 | The using directives of the individual code snippets will be de-duplicated 185 | and `NUnit.Framework` will be automatically added. 186 | 187 | For example, assume the following code snippet in `SomeProgram.cs`: 188 | 189 | ```cs 190 | namespace Some.Namespace 191 | { 192 | // ... 193 | 194 | /// 195 | /// using Another = AnotherNamespace; 196 | /// // --- 197 | /// Assert.AreEqual( 198 | /// "abc", 199 | /// TransformSomehow( 200 | /// Another.Parse("xyz"))); 201 | /// 202 | 203 | // ... 204 | } 205 | ``` 206 | 207 | The generated test code will be similar to: 208 | 209 | ```cs 210 | using Another = AnotherNamespace; 211 | 212 | using NUnit.Framework; 213 | 214 | namespace Some.Namespace.Tests 215 | { 216 | // ... 217 | 218 | public class DocTest_SomeProgram_cs 219 | { 220 | public void AtLine123AndColumn8() { 221 | Assert.AreEqual( 222 | "abc", 223 | TransformSomehow( 224 | Another.Parse("xyz"))); 225 | } 226 | } 227 | 228 | // ... 229 | } 230 | ``` 231 | 232 | **Check against a dry run.** You can use doctest-csharp with `--check` 233 | to verify that all the available test files have been generated as in a dry run 234 | (and no obsolete test files remain). 235 | 236 | This is particularly useful in continuous integrations where you check in your 237 | doctests in the version control and want to make sure that the developer 238 | re-generated the doctests appropriately. 239 | 240 | For example: 241 | 242 | ```bash 243 | dotnet doctest-csharp \ 244 | --input-output SomeProject: 245 | --check 246 | ``` 247 | 248 | ## Contributing 249 | 250 | Feature requests, bug reports *etc.* are highly welcome! Please [submit 251 | a new issue]( 252 | https://github.com/mristin/doctest-csharp/issues/new 253 | ). 254 | 255 | If you want to contribute in code, please see 256 | [CONTRIBUTING.md](CONTRIBUTING.md). 257 | 258 | ## Versioning 259 | 260 | We follow [Semantic Versioning]( 261 | http://semver.org/spec/v1.0.0.html 262 | ). 263 | The version X.Y.Z indicates: 264 | 265 | * X is the major version (backward-incompatible w.r.t. command-line arguments), 266 | * Y is the minor version (backward-compatible), and 267 | * Z is the patch version (backward-compatible bug fix). 268 | 269 | Alpha and beta pre-release versions are suffixed (*e.g.*, 1.0.0-beta3) and 270 | do not follow the semantic versioning (*i.e.*, final release version might be 271 | different). 272 | -------------------------------------------------------------------------------- /src/.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-format": { 6 | "version": "4.1.131201", 7 | "commands": [ 8 | "dotnet-format" 9 | ] 10 | }, 11 | "coveralls.net": { 12 | "version": "4.0.0", 13 | "commands": [ 14 | "csmacnz.Coveralls" 15 | ] 16 | }, 17 | "bitesized": { 18 | "version": "2.0.0", 19 | "commands": [ 20 | "bite-sized" 21 | ] 22 | }, 23 | "deadcsharp": { 24 | "version": "2.0.0", 25 | "commands": [ 26 | "dead-csharp" 27 | ] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | doctest-csharp.sln.DotSettings.user 2 | obj/ 3 | bin/ 4 | out/ 5 | coverage.json 6 | coverage.opencover.xml 7 | -------------------------------------------------------------------------------- /src/Check.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | <# 4 | This script runs locally the checks from the continuous integration. 5 | #> 6 | 7 | function Main 8 | { 9 | Push-Location 10 | 11 | Set-Location $PSScriptRoot 12 | 13 | Write-Host "Checking the format..." 14 | dotnet format --verify-no-changes 15 | if ($LASTEXITCODE -ne 0) 16 | { 17 | throw "Format check failed." 18 | } 19 | 20 | Write-Host "Checking the line length and number of lines per file..." 21 | dotnet bite-sized --inputs '**/*.cs' --excludes '**/obj/**' 22 | if ($LASTEXITCODE -ne 0) 23 | { 24 | throw "The check of line width failed." 25 | } 26 | 27 | Write-Host "Checking the dead code..." 28 | dotnet dead-csharp --inputs '**/*.cs' --excludes '**/obj/**' 29 | if ($LASTEXITCODE -ne 0) 30 | { 31 | throw "The check of dead code failed." 32 | } 33 | 34 | Write-Host "Running the unit tests..." 35 | dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover 36 | if ($LASTEXITCODE -ne 0) 37 | { 38 | throw "The unit tests failed." 39 | } 40 | 41 | $outDir = Join-Path (Split-Path -Parent $PSScriptRoot) "out" 42 | Write-Host "Publishing to $outDir ..." 43 | dotnet publish -c Release -o $outDir 44 | 45 | Write-Host "Checking --help in Readme..." 46 | ./CheckHelpInReadme.ps1 47 | 48 | Pop-Location 49 | } 50 | 51 | Main 52 | -------------------------------------------------------------------------------- /src/CheckHelpInReadme.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .DESCRIPTION 3 | This script checks that the help output from the program and the message 4 | documented in the Readme coincide. 5 | #> 6 | 7 | function Main 8 | { 9 | $repoRoot = Split-Path -Parent $PSScriptRoot 10 | 11 | $buildDir = Join-Path $repoRoot "out" 12 | 13 | if (!(Test-Path $buildDir)) 14 | { 15 | throw ("The build directory does not exist: $buildDir; " + 16 | "did you `dotnet publish -c Release` to it?") 17 | } 18 | 19 | $program = Join-Path $buildDir "DoctestCsharp" 20 | if (!(Test-Path $program)) 21 | { 22 | $program = Join-Path $buildDir "DoctestCsharp.exe" 23 | 24 | if (!(Test-Path $program)) 25 | { 26 | throw ("The program could not be found " + 27 | "in the build directory: $buildDir; " + 28 | "did you `dotnet publish -c Release` to it?") 29 | } 30 | } 31 | 32 | $help = & $program --help |Out-String 33 | 34 | # Trim the help so that the message does not take unnecessary space in the 35 | # Readme 36 | $help = $help.Trim() 37 | 38 | # Make help lines valid markdown to make the comparison against Readme 39 | # a bit more concise 40 | $helpLines = ( 41 | @("``````") + 42 | $help.Split(@("`r`n", "`r", "`n"), [StringSplitOptions]::None) + 43 | @("``````")) 44 | 45 | $readmePath = Join-Path $repoRoot "README.md" 46 | if (!(Test-Path $readmePath)) 47 | { 48 | throw "The readme could not be found: $readmePath" 49 | } 50 | $readme = Get-Content $readmePath 51 | 52 | $readmeLines = $readme.Split( 53 | @("`r`n", "`r", "`n"), [StringSplitOptions]::None) 54 | 55 | $startMarker = "" 56 | $endMarker = "" 57 | 58 | $startMarkerAt = -1 59 | $endMarkerAt = -1 60 | for($i = 0; $i -lt $readmeLines.Length; $i++) 61 | { 62 | $trimmed = $readmeLines[$i].Trim() 63 | if ($trimmed -eq "") 64 | { 65 | $startMarkerAt = $i 66 | } 67 | 68 | if ($trimmed -eq "") 69 | { 70 | $endMarkerAt = $i 71 | } 72 | } 73 | 74 | if ($startMarkerAt -eq -1) 75 | { 76 | throw "The start marker $startMarker could not be found in: $readmePath" 77 | } 78 | if ($endMarkerAt -eq -1) 79 | { 80 | throw "The end marker $endMarker could not be found in: $readmePath" 81 | } 82 | 83 | $lineCount = $endMarkerAt - $startMarkerAt - 1 84 | if ($lineCount -ne $helpLines.Length) 85 | { 86 | throw ("--help gave $( $helpLines.Length ) line(s), " + 87 | "while Readme contained $lineCount line(s).") 88 | } 89 | 90 | for($i = $startMarkerAt + 1; $i -lt $endMarkerAt; $i++) 91 | { 92 | $helpLineIdx = $i - $startMarkerAt - 1 93 | $helpLine = $helpLines[$helpLineIdx] 94 | $readmeLine = $readmeLines[$i] 95 | if ($helpLine -ne $readmeLine) 96 | { 97 | throw ( 98 | "The line $( $i + 1 ) in $readmePath does not " + 99 | "coincide with the line $( $helpLineIdx + 1 ) of --help: " + 100 | "$( $readmeLine|ConvertTo-Json ) != " + 101 | $( $helpLine|ConvertTo-Json )) 102 | } 103 | } 104 | Write-Host "The --help message coincides with the Readme." 105 | } 106 | 107 | Main 108 | -------------------------------------------------------------------------------- /src/DoctestCsharp.Test/ConsoleCapture.cs: -------------------------------------------------------------------------------- 1 | using IDisposable = System.IDisposable; 2 | using StringWriter = System.IO.StringWriter; 3 | using TextWriter = System.IO.TextWriter; 4 | using Console = System.Console; 5 | 6 | namespace DoctestCsharp.Test 7 | { 8 | public class ConsoleCapture : IDisposable 9 | { 10 | private readonly StringWriter _writerOut; 11 | private readonly StringWriter _writerError; 12 | private readonly TextWriter _originalOutput; 13 | private readonly TextWriter _originalError; 14 | 15 | public ConsoleCapture() 16 | { 17 | _writerOut = new StringWriter(); 18 | _writerError = new StringWriter(); 19 | 20 | _originalOutput = Console.Out; 21 | _originalError = Console.Error; 22 | 23 | Console.SetOut(_writerOut); 24 | Console.SetError(_writerError); 25 | } 26 | 27 | public string Output() 28 | { 29 | return _writerOut.ToString(); 30 | } 31 | 32 | public string Error() 33 | { 34 | return _writerError.ToString(); 35 | } 36 | 37 | public void Dispose() 38 | { 39 | Console.SetOut(_originalOutput); 40 | Console.SetError(_originalError); 41 | _writerOut.Dispose(); 42 | _writerOut.Dispose(); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/DoctestCsharp.Test/DoctestCsharp.Test.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | false 7 | 8 | enable 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/DoctestCsharp.Test/TemporaryDirectory.cs: -------------------------------------------------------------------------------- 1 | using IDisposable = System.IDisposable; 2 | 3 | namespace DoctestCsharp.Test 4 | { 5 | class TemporaryDirectory : IDisposable 6 | { 7 | public readonly string Path; 8 | 9 | public TemporaryDirectory() 10 | { 11 | this.Path = System.IO.Path.Combine( 12 | System.IO.Path.GetTempPath(), 13 | System.IO.Path.GetRandomFileName()); 14 | 15 | System.IO.Directory.CreateDirectory(this.Path); 16 | } 17 | 18 | public void Dispose() 19 | { 20 | System.IO.Directory.Delete(this.Path, true); 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/DoctestCsharp.Test/TestExtraction.cs: -------------------------------------------------------------------------------- 1 | using InvalidOperationException = System.InvalidOperationException; 2 | using CSharpSyntaxTree = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree; 3 | using Syntax = Microsoft.CodeAnalysis.CSharp.Syntax; 4 | 5 | using System.Linq; 6 | using System.Collections.Generic; 7 | 8 | using NUnit.Framework; 9 | 10 | namespace DoctestCsharp.Test 11 | { 12 | public class StripMargin 13 | { 14 | [TestCase(new string[0], new string[0])] 15 | [TestCase(new[] { "someText" }, new[] { "someText" })] 16 | [TestCase(new[] { "noMargin", "againNoMargin" }, new[] { "noMargin", "againNoMargin" })] 17 | [TestCase(new[] { " X", " XX" }, new[] { "X", " XX" })] 18 | [TestCase(new[] { "\tX", "\t\tXX" }, new[] { "X", "\tXX" })] 19 | [TestCase(new[] { " X", "\t\tXX" }, new[] { "X", "\tXX" })] 20 | public void Test(string[] lines, string[] expected) 21 | { 22 | string[] got = Extraction.Pipeline.StripMargin(lines); 23 | Assert.That(got, Is.EquivalentTo(expected)); 24 | } 25 | } 26 | 27 | public class RemoveTopAndBottomPadding 28 | { 29 | [TestCase(new string[0], new string[0])] 30 | [TestCase(new[] { "someText" }, new[] { "someText" })] 31 | [TestCase(new[] { "", "someText" }, new[] { "someText" })] 32 | [TestCase(new[] { " ", "someText" }, new[] { "someText" })] 33 | [TestCase(new[] { "\t", "someText" }, new[] { "someText" })] 34 | [TestCase(new[] { "someText", "" }, new[] { "someText" })] 35 | [TestCase(new[] { "someText", " " }, new[] { "someText" })] 36 | [TestCase(new[] { "someText", "\t" }, new[] { "someText" })] 37 | [TestCase(new[] { "", "someText", "" }, new[] { "someText" })] 38 | [TestCase(new[] { "", "", "someText", "", "" }, new[] { "someText" })] 39 | public void Test(string[] lines, string[] expected) 40 | { 41 | string[] got = Extraction.Pipeline.RemoveTopAndBottomPadding(lines); 42 | Assert.That(got, Is.EquivalentTo(expected)); 43 | } 44 | } 45 | 46 | public class CodeFromElementText 47 | { 48 | [Test] 49 | public void Test() 50 | { 51 | string nl = System.Environment.NewLine; 52 | 53 | var cases = new List<(string, string)> 54 | { 55 | ("", ""), 56 | ("var i = 0;", "var i = 0;"), 57 | // The following test case corresponds to /// var i = 0;. 58 | ("/// var i = 0;", "/// var i = 0;"), 59 | ($"/// var i = 0;{nl}/// ", "var i = 0;"), 60 | ($"/// {nl}/// var i = 0;", "var i = 0;"), 61 | ($"/// foreach(var x in lst){nl}/// x.Do();", 62 | $"foreach(var x in lst){nl} x.Do();"), 63 | ($"/// foreach(var x in lst){nl}/// \tx.Do();", 64 | $"foreach(var x in lst){nl}\tx.Do();") 65 | }; 66 | 67 | foreach (var (comment, expected) in cases) 68 | { 69 | string got = Extraction.Pipeline.CodeFromElementText(comment); 70 | Assert.AreEqual(expected, got); 71 | } 72 | } 73 | } 74 | 75 | public class CodesFromDocumentation 76 | { 77 | [TestCase( 78 | @"/// var x = 1;", 79 | new string[0], 80 | TestName = "no doctest attribute")] 81 | [TestCase( 82 | @"/// var x = 1;", 83 | new string[0], 84 | TestName = "doctest attribute is set to false.")] 85 | [TestCase( 86 | @"/// var x = 1;", 87 | new[] { "var x = 1;" }, 88 | TestName = "single line" 89 | )] 90 | [TestCase( 91 | @"/// var x = 1;", 92 | new[] { "var x = 1;" }, 93 | TestName = "Extraction is case-insensitive." 94 | )] 95 | [TestCase( 96 | @" 97 | /// 98 | /// var x = 1; 99 | /// ", 100 | new[] { "var x = 1;" }, 101 | TestName = "Comment of multiple lines" 102 | )] 103 | [TestCase( 104 | @" 105 | /// 106 | /// foreach(var x in lst) 107 | /// print(x); 108 | /// ", 109 | new[] 110 | { 111 | @"foreach(var x in lst) 112 | print(x);" 113 | }, 114 | TestName = "Extraction works with multi-line code." 115 | )] 116 | [TestCase( 117 | @" 118 | /// var x = 1; 119 | /// var y = 2; 120 | ", 121 | new[] { "var x = 1;", "var y = 2;" }, 122 | TestName = "multiple code blocks" 123 | )] 124 | public void Test(string programText, string[] expected) 125 | { 126 | var tree = CSharpSyntaxTree.ParseText(programText); 127 | var root = (Syntax.CompilationUnitSyntax)tree.GetRoot(); 128 | var first = root.DescendantNodes(descendIntoTrivia: true).First(); 129 | 130 | List? got; 131 | 132 | switch (first) 133 | { 134 | case Syntax.DocumentationCommentTriviaSyntax documentation: 135 | got = Extraction.Pipeline.CodesFromDocumentation(documentation); 136 | break; 137 | 138 | default: 139 | throw new InvalidOperationException($"Unexpected type of tree: {tree.GetType()}"); 140 | } 141 | 142 | Assert.NotNull(got); 143 | Assert.AreEqual(expected.Length, got.Count); 144 | 145 | for (var i = 0; i < expected.Length; i++) 146 | { 147 | Assert.AreEqual(expected[i], got[i].Text); 148 | } 149 | } 150 | } 151 | 152 | 153 | public class SplitHeaderBody 154 | { 155 | [Test] 156 | public void TestNoHeader() 157 | { 158 | string text = "var x = 1;"; 159 | var doctestOrError = Extraction.Pipeline.SplitHeaderBody( 160 | new Extraction.Pipeline.Code(text, 0, 0)); 161 | 162 | if (doctestOrError.Error != null) 163 | { 164 | Assert.Fail($"Expected no error, but got: {doctestOrError.Error.Message}"); 165 | } 166 | 167 | var doctest = doctestOrError.DoctestWithoutNamespace!; 168 | 169 | Assert.AreEqual(0, doctest.Usings.Count); 170 | Assert.AreEqual("var x = 1;", doctest.Body); 171 | } 172 | 173 | [TestCase(@"using System; 174 | // --- 175 | var x = 1;", TestName = "tight whitespace")] 176 | [TestCase( 177 | @"using System; 178 | // --- 179 | var x = 1;", 180 | TestName = "Leading and trailing whitespace in separator")] 181 | [TestCase( 182 | @"using System; 183 | // ------- 184 | var x = 1;", 185 | TestName = "Many dots in separator")] 186 | [TestCase( 187 | @"using System; 188 | 189 | // ------- 190 | 191 | var x = 1; 192 | 193 | ", 194 | TestName = "Header and body trimmed")] 195 | public void TestHeaderWithoutAlias(string text) 196 | { 197 | var doctestOrError = Extraction.Pipeline.SplitHeaderBody( 198 | new Extraction.Pipeline.Code(text, 0, 0)); 199 | 200 | if (doctestOrError.Error != null) 201 | { 202 | Assert.Fail($"Expected no error, but got: {doctestOrError.Error.Message}"); 203 | } 204 | 205 | var doctest = doctestOrError.DoctestWithoutNamespace!; 206 | 207 | var expectedUsings = new List 208 | { 209 | new Extraction.UsingDirective("System", null) 210 | }; 211 | 212 | Assert.That(doctest.Usings, Is.EquivalentTo(expectedUsings)); 213 | Assert.AreEqual("var x = 1;", doctest.Body); 214 | } 215 | 216 | [Test] 217 | public void TestHeaderWithAlias() 218 | { 219 | string text = @"using Sys = System; 220 | // --- 221 | var x = 1;"; 222 | 223 | var doctestOrError = Extraction.Pipeline.SplitHeaderBody( 224 | new Extraction.Pipeline.Code(text, 0, 0)); 225 | 226 | if (doctestOrError.Error != null) 227 | { 228 | Assert.Fail($"Expected no error, but got: {doctestOrError.Error.Message}"); 229 | } 230 | 231 | var doctest = doctestOrError.DoctestWithoutNamespace!; 232 | 233 | var expectedUsings = new List 234 | { 235 | new Extraction.UsingDirective("System", "Sys") 236 | }; 237 | 238 | Assert.That(doctest.Usings, Is.EquivalentTo(expectedUsings)); 239 | Assert.AreEqual("var x = 1;", doctest.Body); 240 | } 241 | 242 | [Test] 243 | public void TestError() 244 | { 245 | string text = @" // some comment 246 | var a = 0; 247 | // --- 248 | var x = 1;"; 249 | 250 | var doctestOrError = Extraction.Pipeline.SplitHeaderBody( 251 | new Extraction.Pipeline.Code(text, 0, 0)); 252 | 253 | Assert.IsNull(doctestOrError.DoctestWithoutNamespace); 254 | 255 | var error = doctestOrError.Error!; 256 | Assert.AreEqual( 257 | "Expected only using directives in the header, but got: FieldDeclaration", 258 | error.Message); 259 | Assert.AreEqual(1, error.Line); 260 | Assert.AreEqual(4, error.Column); 261 | } 262 | } 263 | 264 | public class NamespaceDetectionTests 265 | { 266 | [TestCase( 267 | @" 268 | /// var x = 1; 269 | ", new[] { "" }, 270 | TestName = "No namespace")] 271 | [TestCase( 272 | @" 273 | namespace A { 274 | /// var x = 1; 275 | } 276 | ", new[] { "A" }, 277 | TestName = "Single namespace")] 278 | [TestCase( 279 | @" 280 | namespace A.X { 281 | /// var x = 1; 282 | } 283 | 284 | namespace B.Y { 285 | /// var x = 1; 286 | } 287 | 288 | namespace A.X { 289 | /// var x = 1; 290 | } 291 | ", new[] { "A.X", "B.Y", "A.X" }, 292 | TestName = "Multiple recurring namespaces")] 293 | [TestCase( 294 | @" 295 | /// var x = 1; 296 | 297 | namespace A { 298 | /// var x = 1; 299 | 300 | namespace X { 301 | /// var x = 1; 302 | } 303 | 304 | namespace Y { 305 | /// var x = 1; 306 | } 307 | 308 | namespace Z { 309 | } 310 | 311 | /// var x = 1; 312 | } 313 | 314 | namespace A.X { 315 | /// var x = 1; 316 | } 317 | 318 | /// var x = 1; 319 | ", 320 | new[] { "", "A", "A.X", "A.Y", "A", "A.X", "" }, 321 | TestName = "Nested namespaces")] 322 | public void Test(string text, string[] expectedNamespaces) 323 | { 324 | var tree = CSharpSyntaxTree.ParseText(text); 325 | var doctestsAndErrors = Extraction.Extract(tree); 326 | 327 | Assert.That(doctestsAndErrors.Errors, Is.EquivalentTo(new List())); 328 | 329 | var doctests = doctestsAndErrors.Doctests; 330 | 331 | var gotNamespaces = doctests.Select((doctest) => doctest.Namespace).ToList(); 332 | 333 | Assert.That(gotNamespaces, Is.EquivalentTo(expectedNamespaces)); 334 | } 335 | } 336 | 337 | public class RealisticExamples 338 | { 339 | [Test] 340 | public void TestMethodDoctest() 341 | { 342 | string text = @" 343 | namespace SomeNamespace 344 | { 345 | public static class SomeClass 346 | { 347 | /// 348 | /// Does something. 349 | /// 350 | /// 351 | /// using Microsoft.CodeAnalysis.SyntaxTree; 352 | /// // --- 353 | /// var x = 1; 354 | /// 355 | public void SomeMethod() 356 | { 357 | // some implementation 358 | var y = 2; 359 | } 360 | 361 | /// 362 | /// Does something else. 363 | /// 364 | /// 365 | /// var y = 2; 366 | /// 367 | public void AnotherMethod() 368 | { 369 | // another implementation 370 | var z = 3; 371 | } 372 | } 373 | } 374 | "; 375 | var expected = new List 376 | { 377 | new Extraction.Doctest( 378 | ns: "SomeNamespace", 379 | usings: new List 380 | { 381 | new Extraction.UsingDirective("Microsoft.CodeAnalysis.SyntaxTree", null) 382 | }, 383 | body: "var x = 1;", 384 | line: 8, 385 | column: 12), 386 | new Extraction.Doctest( 387 | ns: "SomeNamespace", 388 | usings: new List(), 389 | body: "var y = 2;", 390 | line: 22, 391 | column: 12) 392 | }; 393 | 394 | var tree = CSharpSyntaxTree.ParseText(text); 395 | var doctestsAndErrors = Extraction.Extract(tree); 396 | 397 | Assert.That(doctestsAndErrors.Errors, Is.EquivalentTo(new List())); 398 | 399 | var doctests = doctestsAndErrors.Doctests; 400 | 401 | Assert.AreEqual(expected.Count, doctests.Count); 402 | for (var i = 0; i < doctests.Count; i++) 403 | { 404 | Assert.AreEqual(expected[i].Namespace, doctests[i].Namespace); 405 | Assert.That(doctests[i].Usings, Is.EquivalentTo(expected[i].Usings)); 406 | Assert.AreEqual(expected[i].Body, doctests[i].Body); 407 | Assert.AreEqual(expected[i].Line, doctests[i].Line); 408 | Assert.AreEqual(expected[i].Column, doctests[i].Column); 409 | } 410 | } 411 | } 412 | } -------------------------------------------------------------------------------- /src/DoctestCsharp.Test/TestGeneration.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using NUnit.Framework; 3 | 4 | namespace DoctestCsharp.Test 5 | { 6 | public class Indent 7 | { 8 | [TestCase("", 0, "", TestName = "Empty gives empty.")] 9 | [TestCase("", 1, "", TestName = "Empty gives empty on higher levels.")] 10 | [TestCase("someText", 0, "someText", TestName = "Single line on level 0")] 11 | [TestCase("someText", 1, " someText", TestName = "Single line on higher level")] 12 | [TestCase( 13 | @"someText 14 | anotherText", 15 | 0, 16 | @"someText 17 | anotherText", 18 | TestName = "Multiple lines on level 0")] 19 | [TestCase( 20 | @"someText 21 | anotherText", 22 | 1, 23 | @" someText 24 | anotherText", 25 | TestName = "Multiple lines on higher level")] 26 | [TestCase( 27 | @"someText 28 | anotherText 29 | ", 30 | 1, 31 | @" someText 32 | anotherText 33 | ", 34 | TestName = "Multiple lines on higher level with trailing newline")] 35 | public void Test(string text, int level, string expected) 36 | { 37 | string got = Generation.Style.Indent(text, level); 38 | Assert.AreEqual(expected, got); 39 | } 40 | } 41 | 42 | public class Generate 43 | { 44 | [Test] 45 | public void TestRealistic() 46 | { 47 | var doctests = new List 48 | { 49 | new Extraction.Doctest( 50 | ns: "SomeNamespace", 51 | usings: new List 52 | { 53 | new Extraction.UsingDirective("System.IO", null), 54 | new Extraction.UsingDirective("Microsoft.CodeAnalysis.SyntaxTree", null), 55 | }, 56 | body: "var a = 1;", 57 | line: 10, 58 | column: 11), 59 | new Extraction.Doctest( 60 | ns: "SomeNamespace", 61 | usings: new List(), 62 | body: "var b = 2;", 63 | line: 20, 64 | column: 21), 65 | new Extraction.Doctest( 66 | ns: "AnotherNamespace", 67 | usings: new List 68 | { 69 | new Extraction.UsingDirective("Microsoft.CodeAnalysis.SyntaxTree", null), 70 | }, 71 | body: "var c = 3;", 72 | line: 30, 73 | column: 31), 74 | new Extraction.Doctest( 75 | ns: "AnotherNamespace", 76 | usings: new List(), 77 | body: "var d = 4;", 78 | line: 40, 79 | column: 41), 80 | new Extraction.Doctest( 81 | ns: "SomeNamespace", 82 | usings: new List 83 | { 84 | new Extraction.UsingDirective("Microsoft.CodeAnalysis.SyntaxTree", null), 85 | }, 86 | body: "var e = 5;", 87 | line: 50, 88 | column: 51), 89 | new Extraction.Doctest( 90 | ns: "SomeNamespace", 91 | usings: new List(), 92 | body: "var f = 6;", 93 | line: 60, 94 | column: 61) 95 | }; 96 | 97 | var writer = new System.IO.StringWriter(); 98 | Generation.Generate(doctests, "DocTest_SomeFile_cs", writer); 99 | 100 | string expected = @"// This file was automatically generated by doctest-csharp. 101 | // !!! DO NOT EDIT OR APPEND !!! 102 | 103 | using Microsoft.CodeAnalysis.SyntaxTree; 104 | using System.IO; 105 | 106 | using NUnit.Framework; 107 | 108 | namespace SomeNamespace.Tests 109 | { 110 | public class DocTest_SomeFile_cs 111 | { 112 | [Test] 113 | public void AtLine10AndColumn11() 114 | { 115 | var a = 1; 116 | } 117 | 118 | [Test] 119 | public void AtLine20AndColumn21() 120 | { 121 | var b = 2; 122 | } 123 | } 124 | } 125 | 126 | namespace AnotherNamespace.Tests 127 | { 128 | public class DocTest_SomeFile_cs 129 | { 130 | [Test] 131 | public void AtLine30AndColumn31() 132 | { 133 | var c = 3; 134 | } 135 | 136 | [Test] 137 | public void AtLine40AndColumn41() 138 | { 139 | var d = 4; 140 | } 141 | } 142 | } 143 | 144 | namespace SomeNamespace.Tests 145 | { 146 | public class DocTest_SomeFile_cs2 147 | { 148 | [Test] 149 | public void AtLine50AndColumn51() 150 | { 151 | var e = 5; 152 | } 153 | 154 | [Test] 155 | public void AtLine60AndColumn61() 156 | { 157 | var f = 6; 158 | } 159 | } 160 | } 161 | 162 | // This file was automatically generated by doctest-csharp. 163 | // !!! DO NOT EDIT OR APPEND !!! 164 | "; 165 | Assert.AreEqual(expected, writer.ToString()); 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/DoctestCsharp.Test/TestInput.cs: -------------------------------------------------------------------------------- 1 | using Path = System.IO.Path; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using NUnit.Framework; 5 | 6 | namespace DoctestCsharp.Test 7 | { 8 | public class InputOutputParseTests 9 | { 10 | [Test] 11 | public void TestNoInputOutput() 12 | { 13 | var inputOutputOrError = Input.ParseInputOutput(new string[] { }, ".Tests"); 14 | Assert.AreEqual(null, inputOutputOrError.Error); 15 | Assert.That( 16 | inputOutputOrError.InputOutput, 17 | Is.EquivalentTo(new List<(string, string)>())); 18 | } 19 | 20 | [Test] 21 | public void TestSingleInputOutput() 22 | { 23 | var inputOutputOrError = Input.ParseInputOutput( 24 | new[] 25 | { 26 | $"someInput{Path.PathSeparator}someOutput" 27 | }, 28 | ".Tests"); 29 | 30 | Assert.AreEqual(null, inputOutputOrError.Error); 31 | Assert.That( 32 | inputOutputOrError.InputOutput, 33 | Is.EquivalentTo( 34 | new List<(string, string)> 35 | { 36 | ("someInput", "someOutput") 37 | })); 38 | } 39 | 40 | public void TestMultipleInputOutput() 41 | { 42 | var inputOutputOrError = Input.ParseInputOutput( 43 | new[] 44 | { 45 | $"someInput{Path.PathSeparator}someOutput", 46 | $"anotherInput{Path.PathSeparator}anotherOutput" 47 | }, 48 | ".Tests"); 49 | 50 | Assert.AreEqual(null, inputOutputOrError.Error); 51 | Assert.That( 52 | inputOutputOrError.InputOutput, 53 | Is.EquivalentTo( 54 | new List<(string, string)> 55 | { 56 | ("someInput", "someOutput"), 57 | ("anotherInput", "anotherOutput") 58 | })); 59 | } 60 | 61 | [Test] 62 | public void TestAutomaticOutputOnEmptyOutput() 63 | { 64 | var inputOutputOrError = Input.ParseInputOutput( 65 | new[] 66 | { 67 | $"someInput{Path.PathSeparator}" 68 | }, 69 | ".Tests"); 70 | 71 | Assert.AreEqual(null, inputOutputOrError.Error); 72 | Assert.That( 73 | inputOutputOrError.InputOutput, 74 | Is.EquivalentTo( 75 | new List<(string, string)> 76 | { 77 | ("someInput", "someInput.Tests") 78 | })); 79 | } 80 | 81 | [Test] 82 | public void TestAutomaticOutputOnNoOutput() 83 | { 84 | var inputOutputOrError = Input.ParseInputOutput( 85 | new[] 86 | { 87 | "someInput" 88 | }, 89 | ".Tests"); 90 | 91 | Assert.AreEqual(null, inputOutputOrError.Error); 92 | Assert.That( 93 | inputOutputOrError.InputOutput, 94 | Is.EquivalentTo( 95 | new List<(string, string)> 96 | { 97 | ("someInput", "someInput.Tests") 98 | })); 99 | } 100 | 101 | [Test] 102 | public void TestError() 103 | { 104 | string inputOutputRaw = $"someInput{Path.PathSeparator}someOutput{Path.PathSeparator}something wrong"; 105 | var inputOutputOrError = Input.ParseInputOutput(new[] { inputOutputRaw }, ".Tests"); 106 | 107 | Assert.AreEqual( 108 | $"Expected at most a pair, but got 3 parts " + 109 | $"separated by {Path.PathSeparator} from the input-output: {inputOutputRaw}", 110 | inputOutputOrError.Error); 111 | } 112 | } 113 | 114 | public class MatchFilesTests 115 | { 116 | private static void WriteDummyFile(string prefix, string dirName, string subdirName, string name) 117 | { 118 | var parent = Path.Join(prefix, dirName, subdirName); 119 | System.IO.Directory.CreateDirectory(parent); 120 | 121 | var path = Path.Join(parent, name); 122 | System.IO.File.WriteAllText(path, "some content"); 123 | } 124 | 125 | [Test] 126 | public void TestThatFilesAreMatchedAsRelativePaths() 127 | { 128 | using var tmpdir = new TemporaryDirectory(); 129 | 130 | WriteDummyFile(tmpdir.Path, "a", "b", "Program.cs"); 131 | 132 | List matchedFiles = Input.MatchFiles( 133 | tmpdir.Path, 134 | new List { Path.Join("**", "*.cs") }, 135 | new List()) 136 | .ToList(); 137 | 138 | var expectedFiles = new List() { Path.Join("a", "b", "Program.cs") }; 139 | 140 | Assert.That(matchedFiles, Is.EquivalentTo(expectedFiles)); 141 | } 142 | 143 | [Test] 144 | public void TestThatFilesAreMatchedAsRootedPaths() 145 | { 146 | using var tmpdir = new TemporaryDirectory(); 147 | 148 | WriteDummyFile(tmpdir.Path, "a", "b", "Program.cs"); 149 | 150 | List matchedFiles = Input.MatchFiles( 151 | tmpdir.Path, 152 | new List { Path.Join(tmpdir.Path, "**", "*.cs") }, 153 | new List()) 154 | .ToList(); 155 | 156 | var expectedFiles = new List() { Path.Join(tmpdir.Path, "a", "b", "Program.cs") }; 157 | 158 | Assert.That(matchedFiles, Is.EquivalentTo(expectedFiles)); 159 | } 160 | 161 | [TestCase(true, true)] 162 | [TestCase(true, false)] 163 | [TestCase(false, true)] 164 | [TestCase(false, false)] 165 | public void TestExclude(bool patternIsRooted, bool excludeIsRooted) 166 | { 167 | using var tmpdir = new TemporaryDirectory(); 168 | 169 | WriteDummyFile(tmpdir.Path, "a", "b", "Program.cs"); 170 | WriteDummyFile(tmpdir.Path, "a", "obj", "AnotherProgram.cs"); 171 | 172 | string pattern = (patternIsRooted) 173 | ? Path.Join(tmpdir.Path, "**", "*.cs") 174 | : Path.Join("**", "*.cs"); 175 | 176 | string exclude = (excludeIsRooted) 177 | ? Path.Join(tmpdir.Path, "**", "obj", "**", "*.cs") 178 | : Path.Join("**", "obj", "**", "*.cs"); 179 | 180 | List matchedFiles = Input.MatchFiles( 181 | tmpdir.Path, 182 | new List() { pattern }, 183 | new List() { exclude }) 184 | .ToList(); 185 | 186 | var expectedFiles = (patternIsRooted) 187 | ? new List() { Path.Join(tmpdir.Path, "a", "b", "Program.cs") } 188 | : new List() { Path.Join("a", "b", "Program.cs") }; 189 | 190 | Assert.That(matchedFiles, Is.EquivalentTo(expectedFiles)); 191 | } 192 | } 193 | } -------------------------------------------------------------------------------- /src/DoctestCsharp.Test/TestProcess.cs: -------------------------------------------------------------------------------- 1 | using Path = System.IO.Path; 2 | using File = System.IO.File; 3 | 4 | using CSharpSyntaxTree = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree; 5 | 6 | using System.Collections.Generic; 7 | 8 | using NUnit.Framework; 9 | 10 | namespace DoctestCsharp.Test 11 | { 12 | public class InputPath 13 | { 14 | [Test] 15 | public void Test() 16 | { 17 | using var tmpdir = new TemporaryDirectory(); 18 | 19 | string got = Process.InputPath("SomeProgram.cs", tmpdir.Path); 20 | 21 | Assert.AreEqual(Path.Join(tmpdir.Path, "SomeProgram.cs"), got); 22 | } 23 | } 24 | 25 | public class OutputPath 26 | { 27 | [Test] 28 | public void TestThatItWorks() 29 | { 30 | var cases = new List<(string, string)> 31 | { 32 | ("SomeProgram.cs", "DocTestSomeProgram.cs"), 33 | (Path.Join("SomeSubdir", "SomeProgram.cs"), Path.Join("SomeSubdir", "DocTestSomeProgram.cs")) 34 | }; 35 | 36 | foreach ((string relativePath, string expectedRelativePath) in cases) 37 | { 38 | using var tmpdir = new TemporaryDirectory(); 39 | 40 | string absoluteOutputPath = Process.OutputPath( 41 | relativePath, tmpdir.Path); 42 | 43 | string relativeOutputPath = Path.GetRelativePath(tmpdir.Path, absoluteOutputPath); 44 | 45 | Assert.AreEqual(expectedRelativePath, relativeOutputPath); 46 | } 47 | } 48 | } 49 | 50 | public class IdentifierTests 51 | { 52 | [Test] 53 | public void TestSimpleFilename() 54 | { 55 | Assert.AreEqual( 56 | "DocTest_SomeProgram_cs", 57 | Process.Identifier("SomeProgram.cs")); 58 | } 59 | 60 | [Test] 61 | public void TestFilenameWithBrackets() 62 | { 63 | Assert.AreEqual( 64 | "DocTest_SomeProgram_T__cs", 65 | Process.Identifier("SomeProgram{T}.cs")); 66 | } 67 | 68 | [Test] 69 | public void TestSubdir() 70 | { 71 | Assert.AreEqual( 72 | "DocTest_Subdir_SomeProgram_cs", 73 | Process.Identifier(Path.Join("Subdir", "SomeProgram.cs"))); 74 | } 75 | } 76 | 77 | public class GenerateTests 78 | { 79 | [Test] 80 | public void TestNoDoctest() 81 | { 82 | using var tmpdir = new TemporaryDirectory(); 83 | string outputPath = Path.Join(tmpdir.Path, "DocTestSomeProgram.cs"); 84 | 85 | var doctestsAndErrors = Extraction.Extract( 86 | CSharpSyntaxTree.ParseText( 87 | "No doctest at all")); 88 | 89 | Assert.AreEqual(0, doctestsAndErrors.Errors.Count); 90 | var doctests = doctestsAndErrors.Doctests; 91 | 92 | bool got = Process.Generate(doctests, "SomeProgram.cs", outputPath); 93 | 94 | Assert.IsFalse(got); 95 | Assert.IsFalse(File.Exists(outputPath)); 96 | } 97 | 98 | [Test] 99 | public void TestDoctest() 100 | { 101 | using var tmpdir = new TemporaryDirectory(); 102 | string outputPath = Path.Join(tmpdir.Path, "DocTestSomeProgram.cs"); 103 | 104 | var doctestsAndErrors = Extraction.Extract( 105 | CSharpSyntaxTree.ParseText( 106 | @"/// 107 | /// var x = 1; 108 | /// ")); 109 | 110 | Assert.AreEqual(0, doctestsAndErrors.Errors.Count); 111 | var doctests = doctestsAndErrors.Doctests; 112 | 113 | bool got = Process.Generate(doctests, "SomeProgram.cs", outputPath); 114 | 115 | Assert.IsTrue(got); 116 | Assert.IsTrue(File.Exists(outputPath)); 117 | Assert.AreEqual(@"// This file was automatically generated by doctest-csharp. 118 | // !!! DO NOT EDIT OR APPEND !!! 119 | 120 | using NUnit.Framework; 121 | 122 | namespace Tests 123 | { 124 | public class DocTest_SomeProgram_cs 125 | { 126 | [Test] 127 | public void AtLine0AndColumn4() 128 | { 129 | var x = 1; 130 | } 131 | } 132 | } 133 | 134 | // This file was automatically generated by doctest-csharp. 135 | // !!! DO NOT EDIT OR APPEND !!! 136 | ", 137 | File.ReadAllText(outputPath)); 138 | } 139 | } 140 | 141 | public class CheckTests 142 | { 143 | [Test] 144 | public void TestOkNoDoctest() 145 | { 146 | using var tmpdir = new TemporaryDirectory(); 147 | string outputPath = Path.Join(tmpdir.Path, "DocTestSomeProgram.cs"); 148 | 149 | var doctestsAndErrors = Extraction.Extract( 150 | CSharpSyntaxTree.ParseText( 151 | "No doctest at all")); 152 | 153 | Assert.AreEqual(0, doctestsAndErrors.Errors.Count); 154 | var doctests = doctestsAndErrors.Doctests; 155 | 156 | var got = Process.Check(doctests, "SomeProgram.cs", outputPath); 157 | 158 | Assert.IsInstanceOf(got); 159 | } 160 | 161 | [Test] 162 | public void TestOkDoctest() 163 | { 164 | using var tmpdir = new TemporaryDirectory(); 165 | string outputPath = Path.Join(tmpdir.Path, "DocTestSomeProgram.cs"); 166 | 167 | string programText = @" 168 | /// 169 | /// var x = 1; 170 | /// 171 | "; 172 | 173 | var doctestsAndErrors = Extraction.Extract( 174 | CSharpSyntaxTree.ParseText( 175 | programText)); 176 | 177 | Assert.AreEqual(0, doctestsAndErrors.Errors.Count); 178 | var doctests = doctestsAndErrors.Doctests; 179 | 180 | File.WriteAllText(outputPath, @"// This file was automatically generated by doctest-csharp. 181 | // !!! DO NOT EDIT OR APPEND !!! 182 | 183 | using NUnit.Framework; 184 | 185 | namespace Tests 186 | { 187 | public class DocTest_SomeProgram_cs 188 | { 189 | [Test] 190 | public void AtLine1AndColumn4() 191 | { 192 | var x = 1; 193 | } 194 | } 195 | } 196 | 197 | // This file was automatically generated by doctest-csharp. 198 | // !!! DO NOT EDIT OR APPEND !!! 199 | "); 200 | var got = Process.Check(doctests, "SomeProgram.cs", outputPath); 201 | Assert.IsInstanceOf(got); 202 | } 203 | 204 | [Test] 205 | public void TestDoesntExist() 206 | { 207 | using var tmpdir = new TemporaryDirectory(); 208 | string outputPath = Path.Join(tmpdir.Path, "DocTestSomeProgram.cs"); 209 | 210 | string programText = @" 211 | /// 212 | /// var x = 1; 213 | /// 214 | "; 215 | 216 | var doctestsAndErrors = Extraction.Extract( 217 | CSharpSyntaxTree.ParseText( 218 | programText)); 219 | 220 | Assert.AreEqual(0, doctestsAndErrors.Errors.Count); 221 | var doctests = doctestsAndErrors.Doctests; 222 | 223 | // Test pre-condition 224 | Assert.IsFalse(File.Exists(outputPath)); 225 | 226 | var got = Process.Check(doctests, "SomeProgram.cs", outputPath); 227 | Assert.IsInstanceOf(got); 228 | } 229 | 230 | [Test] 231 | public void TestShouldntExist() 232 | { 233 | using var tmpdir = new TemporaryDirectory(); 234 | string outputPath = Path.Join(tmpdir.Path, "DocTestSomeProgram.cs"); 235 | 236 | var doctestsAndErrors = Extraction.Extract( 237 | CSharpSyntaxTree.ParseText( 238 | "no doctest")); 239 | 240 | Assert.AreEqual(0, doctestsAndErrors.Errors.Count); 241 | var doctests = doctestsAndErrors.Doctests; 242 | 243 | File.WriteAllText(outputPath, "should not exist"); 244 | 245 | var got = Process.Check(doctests, "SomeProgram.cs", outputPath); 246 | Assert.IsInstanceOf(got); 247 | } 248 | 249 | [Test] 250 | public void TestDifferent() 251 | { 252 | using var tmpdir = new TemporaryDirectory(); 253 | string outputPath = Path.Join(tmpdir.Path, "DocTestSomeProgram.cs"); 254 | 255 | string programText = @" 256 | /// 257 | /// var x = 1; 258 | /// 259 | "; 260 | var doctestsAndErrors = Extraction.Extract( 261 | CSharpSyntaxTree.ParseText( 262 | programText)); 263 | 264 | Assert.AreEqual(0, doctestsAndErrors.Errors.Count); 265 | var doctests = doctestsAndErrors.Doctests; 266 | 267 | File.WriteAllText(outputPath, "different content"); 268 | 269 | var got = Process.Check(doctests, "SomeProgram.cs", outputPath); 270 | Assert.IsInstanceOf(got); 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/DoctestCsharp.Test/TestProgram.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using File = System.IO.File; 3 | using Path = System.IO.Path; 4 | using Directory = System.IO.Directory; 5 | using DirectoryInfo = System.IO.DirectoryInfo; 6 | using Environment = System.Environment; 7 | 8 | using NUnit.Framework; 9 | using NUnit.Framework.Interfaces; 10 | 11 | namespace DoctestCsharp.Test 12 | { 13 | public class ProgramTests 14 | { 15 | [Test] 16 | public void TestNoCommandLineArguments() 17 | { 18 | using var consoleCapture = new ConsoleCapture(); 19 | 20 | int exitCode = Program.MainWithCode(new string[0]); 21 | 22 | string nl = Environment.NewLine; 23 | 24 | Assert.AreEqual(1, exitCode); 25 | Assert.AreEqual( 26 | $"Option '--input-output' is required.{nl}{nl}", 27 | consoleCapture.Error()); 28 | } 29 | 30 | [Test] 31 | public void TestInvalidCommandLineArguments() 32 | { 33 | using var consoleCapture = new ConsoleCapture(); 34 | 35 | int exitCode = Program.MainWithCode(new[] { "--invalid-arg" }); 36 | 37 | string nl = Environment.NewLine; 38 | 39 | Assert.AreEqual(1, exitCode); 40 | Assert.AreEqual( 41 | $"Option '--input-output' is required.{nl}" + 42 | $"Unrecognized command or argument '--invalid-arg'{nl}{nl}", 43 | consoleCapture.Error()); 44 | } 45 | } 46 | 47 | public class ProgramGenerateTests 48 | { 49 | [Test] 50 | public void TestNoDoctest() 51 | { 52 | using var tmpdir = new TemporaryDirectory(); 53 | DirectoryInfo input = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject")); 54 | DirectoryInfo output = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject.Test/doctests")); 55 | 56 | string inputPath = Path.Join(input.FullName, "SomeProgram.cs"); 57 | string outputPath = Path.Join(output.FullName, "DocTestSomeProgram.cs"); 58 | 59 | File.WriteAllText(inputPath, "no doctests"); 60 | 61 | using var consoleCapture = new ConsoleCapture(); 62 | 63 | int exitCode = Program.MainWithCode( 64 | new[] 65 | { 66 | "--input-output", $"{input.FullName}{Path.PathSeparator}{output.FullName}", 67 | "--verbose" 68 | }); 69 | 70 | string nl = Environment.NewLine; 71 | 72 | Assert.AreEqual(0, exitCode); 73 | Assert.AreEqual( 74 | $"No doctests found in: {inputPath}{nl}", 75 | consoleCapture.Output()); 76 | 77 | Assert.IsFalse(File.Exists(outputPath)); 78 | } 79 | 80 | [Test] 81 | public void TestDoctest() 82 | { 83 | using var tmpdir = new TemporaryDirectory(); 84 | DirectoryInfo input = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject")); 85 | DirectoryInfo output = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject.Test/doctests")); 86 | 87 | string inputPath = Path.Join(input.FullName, "SomeProgram.cs"); 88 | string outputPath = Path.Join(output.FullName, "DocTestSomeProgram.cs"); 89 | 90 | File.WriteAllText( 91 | inputPath, 92 | @"/// 93 | /// var x = 1; 94 | /// 95 | "); 96 | 97 | using var consoleCapture = new ConsoleCapture(); 98 | 99 | int exitCode = Program.MainWithCode( 100 | new[] 101 | { 102 | "--input-output", $"{input.FullName}{Path.PathSeparator}{output.FullName}", 103 | "--verbose" 104 | }); 105 | 106 | string nl = Environment.NewLine; 107 | 108 | Assert.AreEqual(0, exitCode); 109 | Assert.AreEqual( 110 | $"Generated doctest(s) for: {inputPath} -> {outputPath}{nl}", 111 | consoleCapture.Output()); 112 | } 113 | 114 | [Test] 115 | public void TestExtractionError() 116 | { 117 | using var tmpdir = new TemporaryDirectory(); 118 | DirectoryInfo input = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject")); 119 | DirectoryInfo output = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject.Test/doctests")); 120 | 121 | string inputPath = Path.Join(input.FullName, "SomeProgram.cs"); 122 | 123 | File.WriteAllText( 124 | inputPath, 125 | @"/// 126 | /// var a = 0; 127 | /// // --- 128 | /// var x = 1; 129 | /// 130 | "); 131 | 132 | using var consoleCapture = new ConsoleCapture(); 133 | 134 | int exitCode = Program.MainWithCode( 135 | new[] { "--input-output", $"{input.FullName}{Path.PathSeparator}{output.FullName}" }); 136 | 137 | string nl = Environment.NewLine; 138 | 139 | Assert.AreEqual(1, exitCode); 140 | Assert.AreEqual( 141 | $"Failed to extract doctest(s) from: {inputPath}{nl}" + 142 | $"* Line 1, column 1: Expected only using directives in the header, but got: FieldDeclaration{nl}", 143 | consoleCapture.Output()); 144 | } 145 | 146 | [Test] 147 | public void TestCheckOk() 148 | { 149 | using var tmpdir = new TemporaryDirectory(); 150 | DirectoryInfo input = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject")); 151 | DirectoryInfo output = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject.Test/doctests")); 152 | 153 | string inputPath = Path.Join(input.FullName, "SomeProgram.cs"); 154 | string outputPath = Path.Join(output.FullName, "DocTestSomeProgram.cs"); 155 | 156 | File.WriteAllText( 157 | inputPath, 158 | @"/// 159 | /// var x = 1; 160 | /// 161 | "); 162 | 163 | File.WriteAllText( 164 | outputPath, 165 | @"// This file was automatically generated by doctest-csharp. 166 | // !!! DO NOT EDIT OR APPEND !!! 167 | 168 | using NUnit.Framework; 169 | 170 | namespace Tests 171 | { 172 | public class DocTest_SomeProgram_cs 173 | { 174 | [Test] 175 | public void AtLine0AndColumn4() 176 | { 177 | var x = 1; 178 | } 179 | } 180 | } 181 | 182 | // This file was automatically generated by doctest-csharp. 183 | // !!! DO NOT EDIT OR APPEND !!! 184 | "); 185 | 186 | using var consoleCapture = new ConsoleCapture(); 187 | 188 | int exitCode = Program.MainWithCode( 189 | new[] 190 | { 191 | "--input-output", $"{input.FullName}{Path.PathSeparator}{output.FullName}", 192 | "--check", 193 | "--verbose" 194 | }); 195 | 196 | string nl = Environment.NewLine; 197 | 198 | Assert.AreEqual(0, exitCode); 199 | Assert.AreEqual($"OK: {inputPath} -> {outputPath}{nl}", consoleCapture.Output()); 200 | } 201 | 202 | [Test] 203 | public void TestCheckDoesntExist() 204 | { 205 | using var tmpdir = new TemporaryDirectory(); 206 | DirectoryInfo input = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject")); 207 | DirectoryInfo output = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject.Test/doctests")); 208 | 209 | string inputPath = Path.Join(input.FullName, "SomeProgram.cs"); 210 | string outputPath = Path.Join(output.FullName, "DocTestSomeProgram.cs"); 211 | 212 | File.WriteAllText( 213 | inputPath, 214 | @"/// 215 | /// var x = 1; 216 | /// 217 | "); 218 | 219 | // Test pre-condition 220 | Assert.IsFalse(File.Exists(outputPath)); 221 | 222 | using var consoleCapture = new ConsoleCapture(); 223 | 224 | int exitCode = Program.MainWithCode( 225 | new[] 226 | { 227 | "--input-output", $"{input.FullName}{Path.PathSeparator}{output.FullName}", 228 | "--check", 229 | "--verbose" 230 | }); 231 | 232 | string nl = Environment.NewLine; 233 | 234 | Assert.AreEqual(1, exitCode); 235 | Assert.AreEqual( 236 | $"Output file does not exist: {inputPath} -> {outputPath}{nl}", 237 | consoleCapture.Output()); 238 | } 239 | 240 | [Test] 241 | public void TestCheckDifferent() 242 | { 243 | using var tmpdir = new TemporaryDirectory(); 244 | DirectoryInfo input = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject")); 245 | DirectoryInfo output = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject.Test/doctests")); 246 | 247 | string inputPath = Path.Join(input.FullName, "SomeProgram.cs"); 248 | string outputPath = Path.Join(output.FullName, "DocTestSomeProgram.cs"); 249 | 250 | File.WriteAllText( 251 | inputPath, 252 | @"/// 253 | /// var x = 1; 254 | /// 255 | "); 256 | 257 | File.WriteAllText(outputPath, "different content"); 258 | 259 | using var consoleCapture = new ConsoleCapture(); 260 | 261 | int exitCode = Program.MainWithCode( 262 | new[] 263 | { 264 | "--input-output", $"{input.FullName}{Path.PathSeparator}{output.FullName}", 265 | "--check", 266 | "--verbose" 267 | }); 268 | 269 | string nl = Environment.NewLine; 270 | 271 | Assert.AreEqual(1, exitCode); 272 | Assert.AreEqual( 273 | $"Expected different content: {inputPath} -> {outputPath}{nl}" + 274 | $"Here is the diff between the expected content and the actual content:{nl}" + 275 | $"- // This file was automatically generated by doctest-csharp.{nl}" + 276 | $"+ different content{nl}" + 277 | $"- // !!! DO NOT EDIT OR APPEND !!!{nl}" + 278 | $"- {nl}" + 279 | $"- using NUnit.Framework;{nl}" + 280 | $"- {nl}" + 281 | $"- namespace Tests{nl}" + 282 | $"- {{{nl}" + 283 | $"- public class DocTest_SomeProgram_cs{nl}" + 284 | $"- {{{nl}" + 285 | $"- [Test]{nl}" + 286 | $"- public void AtLine0AndColumn4(){nl}" + 287 | $"- {{{nl}" + 288 | $"- var x = 1;{nl}" + 289 | $"- }}{nl}" + 290 | $"- }}{nl}" + 291 | $"- }}{nl}" + 292 | $"- {nl}" + 293 | $"- // This file was automatically generated by doctest-csharp.{nl}" + 294 | $"- // !!! DO NOT EDIT OR APPEND !!!{nl}" + 295 | $"- {nl}", 296 | consoleCapture.Output()); 297 | } 298 | 299 | [Test] 300 | public void TestCheckShouldntExist() 301 | { 302 | using var tmpdir = new TemporaryDirectory(); 303 | DirectoryInfo input = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject")); 304 | DirectoryInfo output = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject.Test/doctests")); 305 | 306 | string inputPath = Path.Join(input.FullName, "SomeProgram.cs"); 307 | string outputPath = Path.Join(output.FullName, "DocTestSomeProgram.cs"); 308 | 309 | File.WriteAllText(inputPath, "no code"); 310 | 311 | File.WriteAllText(outputPath, "unexpected content"); 312 | 313 | using var consoleCapture = new ConsoleCapture(); 314 | 315 | int exitCode = Program.MainWithCode( 316 | new[] 317 | { 318 | "--input-output", $"{input.FullName}{Path.PathSeparator}{output.FullName}", 319 | "--check", 320 | "--verbose" 321 | }); 322 | 323 | string nl = Environment.NewLine; 324 | 325 | Assert.AreEqual(1, exitCode); 326 | Assert.AreEqual( 327 | $"No doctests found in: {inputPath}; the output should not exist: {outputPath}{nl}", 328 | consoleCapture.Output()); 329 | } 330 | 331 | [Test] 332 | public void TestOutputMayNotExistIfNoDoctests() 333 | { 334 | using var tmpdir = new TemporaryDirectory(); 335 | DirectoryInfo input = Directory.CreateDirectory(Path.Join(tmpdir.Path, "SomeProject")); 336 | string output = Path.Join(tmpdir.Path, "SomeProject.Test/doctests"); 337 | Assert.IsFalse(File.Exists(output)); 338 | 339 | string inputPath = Path.Join(input.FullName, "SomeProgram.cs"); 340 | File.WriteAllText(inputPath, "no doctests"); 341 | 342 | using var consoleCapture = new ConsoleCapture(); 343 | 344 | int exitCode = Program.MainWithCode( 345 | new[] 346 | { 347 | "--input-output", $"{input.FullName}{Path.PathSeparator}{output}", 348 | "--check", 349 | "--verbose" 350 | }); 351 | 352 | string nl = Environment.NewLine; 353 | 354 | Assert.AreEqual(0, exitCode); 355 | Assert.AreEqual( 356 | $"OK, no doctests: {inputPath}{nl}", 357 | consoleCapture.Output()); 358 | } 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /src/DoctestCsharp/DoctestCsharp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | net6.0 5 | enable 6 | 2.0.0 7 | true 8 | doctest-csharp 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/DoctestCsharp/Extraction.cs: -------------------------------------------------------------------------------- 1 | using ArgumentException = System.ArgumentException; 2 | using InvalidOperationException = System.InvalidOperationException; 3 | using StringSplitOptions = System.StringSplitOptions; 4 | using String = System.String; 5 | using HashCode = System.HashCode; 6 | using Int32 = System.Int32; 7 | using Regex = System.Text.RegularExpressions.Regex; 8 | using Environment = System.Environment; 9 | 10 | using CSharpSyntaxTree = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree; 11 | using Syntax = Microsoft.CodeAnalysis.CSharp.Syntax; 12 | using TextSpan = Microsoft.CodeAnalysis.Text.TextSpan; 13 | using SyntaxTree = Microsoft.CodeAnalysis.SyntaxTree; 14 | using SyntaxNode = Microsoft.CodeAnalysis.SyntaxNode; 15 | using CompilationUnitSyntax = Microsoft.CodeAnalysis.CSharp.Syntax.CompilationUnitSyntax; 16 | 17 | using System.Collections.Generic; 18 | using System.Linq; 19 | using Microsoft.CodeAnalysis.CSharp; 20 | 21 | namespace DoctestCsharp 22 | { 23 | public static class Extraction 24 | { 25 | public class Error 26 | { 27 | public readonly string Message; 28 | public readonly int Line; // indexed from 0 29 | public readonly int Column; // indexed from 0 30 | 31 | public Error(string message, int line, int column) 32 | { 33 | Message = message; 34 | Line = line; 35 | Column = column; 36 | } 37 | 38 | private bool Equals(Error other) 39 | { 40 | return Message == other.Message && Line == other.Line && Column == other.Column; 41 | } 42 | 43 | public override bool Equals(object? obj) 44 | { 45 | if (ReferenceEquals(null, obj)) return false; 46 | if (ReferenceEquals(this, obj)) return true; 47 | if (obj.GetType() != this.GetType()) return false; 48 | return Equals((Error)obj); 49 | } 50 | 51 | public override int GetHashCode() 52 | { 53 | return HashCode.Combine(Message, Line, Column); 54 | } 55 | 56 | public override string ToString() 57 | { 58 | return $"{nameof(Message)}: {Message}, {nameof(Line)}: {Line}, {nameof(Column)}: {Column}"; 59 | } 60 | } 61 | 62 | public static class Pipeline 63 | { 64 | public static string[] StripMargin(string[] lines) 65 | { 66 | string[] result = new string[lines.Length]; 67 | 68 | int minWhitespaceCount = Int32.MaxValue; 69 | foreach (string line in lines) 70 | { 71 | int whitespaceCount = 0; 72 | foreach (char c in line) 73 | { 74 | if (c == ' ' || c == '\t') 75 | { 76 | whitespaceCount++; 77 | } 78 | else 79 | { 80 | break; 81 | } 82 | } 83 | 84 | if (whitespaceCount < minWhitespaceCount) 85 | { 86 | minWhitespaceCount = whitespaceCount; 87 | } 88 | } 89 | 90 | for (var i = 0; i < lines.Length; i++) 91 | { 92 | result[i] = lines[i].Substring(minWhitespaceCount); 93 | } 94 | 95 | // Post-condition 96 | if (result.Length != lines.Length) 97 | { 98 | throw new InvalidOperationException( 99 | $"Expected result.Length == lines.Length (== {lines.Length}, but got: {result.Length}"); 100 | } 101 | 102 | return result; 103 | } 104 | 105 | private static readonly Regex EmptyLineRe = new Regex(@"^\s*$"); 106 | 107 | public static string[] RemoveTopAndBottomPadding(string[] lines) 108 | { 109 | if (lines.Length == 0) 110 | { 111 | return new string[0]; 112 | } 113 | 114 | int start = 0; 115 | for (; start < lines.Length; start++) 116 | { 117 | if (!EmptyLineRe.IsMatch(lines[start])) break; 118 | } 119 | 120 | int endInclusive = lines.Length - 1; 121 | for (; endInclusive >= 0; endInclusive--) 122 | { 123 | if (!EmptyLineRe.IsMatch(lines[endInclusive])) break; 124 | } 125 | 126 | string[] linesWithoutPadding = new string[endInclusive - start + 1]; 127 | System.Array.Copy( 128 | lines, start, 129 | linesWithoutPadding, 0, linesWithoutPadding.Length); 130 | 131 | // Post-condition 132 | if (linesWithoutPadding.Length > lines.Length) 133 | { 134 | throw new InvalidOperationException( 135 | $"Expected linesWithoutPadding.Length <= lines.length (== {lines.Length}), " + 136 | $"but got: {linesWithoutPadding.Length}"); 137 | } 138 | 139 | return linesWithoutPadding; 140 | } 141 | 142 | private static readonly Regex LeadingSlashesRe = new Regex(@"^\s*//+"); 143 | 144 | public static string CodeFromElementText(string elementText) 145 | { 146 | if (elementText.Length == 0) 147 | { 148 | return ""; 149 | } 150 | 151 | string[] lines = elementText.Split( 152 | new[] { "\r\n", "\r", "\n" }, 153 | StringSplitOptions.None 154 | ); 155 | 156 | if (lines.Length == 0) 157 | { 158 | return ""; 159 | } 160 | 161 | if (lines.Length == 1) 162 | { 163 | StripMargin(lines); 164 | return lines[0]; 165 | } 166 | 167 | for (var i = 0; i < lines.Length; i++) 168 | { 169 | lines[i] = LeadingSlashesRe.Replace(lines[i], ""); 170 | } 171 | 172 | lines = RemoveTopAndBottomPadding(lines); 173 | lines = StripMargin(lines); 174 | 175 | return String.Join(Environment.NewLine, lines); 176 | } 177 | 178 | public class Code 179 | { 180 | public readonly string Text; 181 | public readonly int Line; // starts at 0 182 | public readonly int Column; // starts at 0 183 | 184 | public Code(string text, int line, int column) 185 | { 186 | Text = text; 187 | Line = line; 188 | Column = column; 189 | } 190 | } 191 | 192 | public static List CodesFromDocumentation(Syntax.DocumentationCommentTriviaSyntax documentation) 193 | { 194 | var result = new List(); 195 | 196 | foreach (var element in documentation.DescendantNodes().OfType()) 197 | { 198 | bool isDoctest = false; 199 | foreach (Syntax.XmlAttributeSyntax attr in element.StartTag.Attributes) 200 | { 201 | if (attr.Name.ToString().ToLowerInvariant() == "doctest") 202 | { 203 | int valueStart = attr.StartQuoteToken.Span.End; 204 | int valueEnd = attr.EndQuoteToken.Span.Start; 205 | 206 | TextSpan valueSpan = new TextSpan(valueStart, valueEnd - valueStart); 207 | 208 | string value = attr.SyntaxTree 209 | .GetText() 210 | .GetSubText(valueSpan) 211 | .ToString(); 212 | 213 | isDoctest = value.ToLowerInvariant() == "true"; 214 | } 215 | } 216 | 217 | if (!isDoctest) continue; 218 | 219 | string name = element.StartTag.Name.ToString().ToLowerInvariant(); 220 | if (name != "code") 221 | { 222 | continue; 223 | } 224 | 225 | string text = CodeFromElementText(element.Content.ToString()); 226 | var lineSpan = element.SyntaxTree.GetLineSpan(element.Span); 227 | 228 | int line = lineSpan.StartLinePosition.Line; 229 | int column = lineSpan.StartLinePosition.Character; 230 | result.Add(new Code(text, line, column)); 231 | } 232 | 233 | return result; 234 | } 235 | 236 | public class DoctestWithoutNamespace 237 | { 238 | public readonly List Usings; 239 | public readonly string Body; 240 | public readonly int Line; // starts at 0 241 | public readonly int Column; // starts at 0 242 | 243 | public DoctestWithoutNamespace(List usings, string body, int line, int column) 244 | { 245 | Usings = usings; 246 | Body = body; 247 | Line = line; 248 | Column = column; 249 | } 250 | } 251 | 252 | public class DoctestWithoutNamespaceOrError 253 | { 254 | public readonly DoctestWithoutNamespace? DoctestWithoutNamespace; 255 | public readonly Error? Error; 256 | 257 | public DoctestWithoutNamespaceOrError(DoctestWithoutNamespace? doctestWithoutNamespace, Error? error) 258 | { 259 | if (doctestWithoutNamespace == null && error == null) 260 | { 261 | throw new ArgumentException("Both doctest and error are null."); 262 | } 263 | 264 | if (doctestWithoutNamespace != null && error != null) 265 | { 266 | throw new ArgumentException("Both doctest and error are given."); 267 | } 268 | 269 | DoctestWithoutNamespace = doctestWithoutNamespace; 270 | Error = error; 271 | } 272 | } 273 | 274 | private static readonly Regex SplitLineRe = new Regex(@"^//\s*----*\s*$"); 275 | 276 | public static DoctestWithoutNamespaceOrError SplitHeaderBody(Code code) 277 | { 278 | var tree = CSharpSyntaxTree.ParseText(code.Text); 279 | var root = (CompilationUnitSyntax)tree.GetRoot(); 280 | 281 | int headerEnd = 0; // exclusive 282 | string body = code.Text; 283 | 284 | foreach (var trivia in root.DescendantTrivia()) 285 | { 286 | if (SplitLineRe.IsMatch(trivia.ToString())) 287 | { 288 | headerEnd = trivia.SpanStart; 289 | body = code.Text.Substring(trivia.Span.End).Trim(); 290 | break; 291 | } 292 | } 293 | 294 | // A work-around to skip descending into children if the parent node has been accepted 295 | int acceptedEnd = 0; 296 | 297 | var usings = new List(); 298 | foreach (var node in root.DescendantNodes()) 299 | { 300 | if (node.SpanStart < acceptedEnd || node.SpanStart >= headerEnd) 301 | { 302 | break; 303 | } 304 | 305 | switch (node) 306 | { 307 | case Syntax.UsingDirectiveSyntax usingDirectiveSyntax: 308 | usings.Add(new UsingDirective( 309 | usingDirectiveSyntax.Name.ToString(), 310 | usingDirectiveSyntax.Alias?.Name.ToString())); 311 | acceptedEnd = usingDirectiveSyntax.Span.End; 312 | break; 313 | default: 314 | var location = node.SyntaxTree.GetLineSpan(node.Span); 315 | return new DoctestWithoutNamespaceOrError( 316 | null, 317 | new Error( 318 | $"Expected only using directives in the header, but got: {node.Kind()}", 319 | location.StartLinePosition.Line, 320 | location.StartLinePosition.Character)); 321 | } 322 | } 323 | 324 | return new DoctestWithoutNamespaceOrError( 325 | new DoctestWithoutNamespace(usings, body, code.Line, code.Column), 326 | null); 327 | } 328 | } 329 | 330 | public class UsingDirective 331 | { 332 | public readonly string Name; 333 | public readonly string? Alias; 334 | 335 | public UsingDirective(string name, string? alias) 336 | { 337 | Name = name; 338 | Alias = alias; 339 | } 340 | 341 | private bool Equals(UsingDirective other) 342 | { 343 | return Name == other.Name && Alias == other.Alias; 344 | } 345 | 346 | public override bool Equals(object? obj) 347 | { 348 | if (ReferenceEquals(null, obj)) return false; 349 | if (ReferenceEquals(this, obj)) return true; 350 | if (obj.GetType() != this.GetType()) return false; 351 | return Equals((UsingDirective)obj); 352 | } 353 | 354 | public override int GetHashCode() 355 | { 356 | return HashCode.Combine(Name, Alias); 357 | } 358 | 359 | public override string ToString() 360 | { 361 | return $"{nameof(Name)}: {Name}, {nameof(Alias)}: {Alias}"; 362 | } 363 | } 364 | 365 | public class Doctest 366 | { 367 | public readonly string Namespace; 368 | public readonly List Usings; 369 | public readonly string Body; 370 | public readonly int Line; // starts at 0 371 | public readonly int Column; // starts at 0 372 | 373 | public Doctest(string ns, List usings, string body, int line, int column) 374 | { 375 | Namespace = ns; 376 | Usings = usings; 377 | Body = body; 378 | Line = line; 379 | Column = column; 380 | } 381 | } 382 | 383 | public class DoctestsAndErrors 384 | { 385 | public readonly List Doctests; 386 | public readonly List Errors; 387 | 388 | public DoctestsAndErrors(List doctests, List errors) 389 | { 390 | Doctests = doctests; 391 | Errors = errors; 392 | } 393 | } 394 | 395 | private class NamespaceWip 396 | { 397 | public string Namespace = ""; 398 | 399 | public readonly List Doctests = new List(); 400 | 401 | // position when the namespace declaration ends 402 | public int SpanEnd = Int32.MaxValue; 403 | } 404 | 405 | /// 406 | /// Applies merge sort on Span.Start to obtain ordered stream of namespace decls and documentation 407 | /// syntax nodes. 408 | /// 409 | /// root of the syntax tree 410 | /// stream ordered by span.start 411 | private static IEnumerable NamespaceDeclsAndDocumentations(CompilationUnitSyntax root) 412 | { 413 | using var namespaceDecls = 414 | root 415 | .DescendantNodes() 416 | .OfType() 417 | .GetEnumerator(); 418 | 419 | using var documentations = 420 | root 421 | .DescendantNodes(descendIntoTrivia: true) 422 | .OfType() 423 | .GetEnumerator(); 424 | 425 | bool doneWithNamespaceDecls = !namespaceDecls.MoveNext(); 426 | bool doneWithDocumentations = !documentations.MoveNext(); 427 | 428 | SyntaxNode? prevWhat = null; // previous yield return 429 | 430 | while (!doneWithNamespaceDecls || !doneWithDocumentations) 431 | { 432 | SyntaxNode? what; // what to yield return 433 | 434 | if (doneWithNamespaceDecls && !doneWithDocumentations) 435 | { 436 | what = documentations.Current; 437 | doneWithDocumentations = !documentations.MoveNext(); 438 | } 439 | else if (!doneWithNamespaceDecls && doneWithDocumentations) 440 | { 441 | what = namespaceDecls.Current; 442 | doneWithNamespaceDecls = !namespaceDecls.MoveNext(); 443 | } 444 | else 445 | { 446 | if (namespaceDecls.Current.SpanStart < documentations.Current.SpanStart) 447 | { 448 | what = namespaceDecls.Current; 449 | doneWithNamespaceDecls = !namespaceDecls.MoveNext(); 450 | } 451 | else 452 | { 453 | what = documentations.Current; 454 | doneWithDocumentations = !documentations.MoveNext(); 455 | } 456 | } 457 | 458 | // Loop invariant 459 | if (what == null) 460 | { 461 | throw new InvalidOperationException("Unexpected null what"); 462 | } 463 | 464 | if (prevWhat != null && what.SpanStart <= prevWhat.SpanStart) 465 | { 466 | throw new InvalidOperationException( 467 | $"Unexpected {nameof(what)}.SpanStart (== {what.SpanStart}) " + 468 | $"before {nameof(prevWhat)}.SpanStart (== {prevWhat.SpanStart})"); 469 | } 470 | yield return what; 471 | } 472 | } 473 | 474 | public static DoctestsAndErrors Extract(SyntaxTree tree) 475 | { 476 | var doctests = new List(); 477 | var errors = new List(); 478 | 479 | // stack 480 | var stack = new Stack(); 481 | 482 | // Push the Work-in-progress for the global namespace 483 | stack.Push(new NamespaceWip { Namespace = "", SpanEnd = tree.Length }); 484 | 485 | var root = (CompilationUnitSyntax)tree.GetRoot(); 486 | 487 | foreach (var node in NamespaceDeclsAndDocumentations(root)) 488 | { 489 | if (node.SpanStart >= stack.Peek().SpanEnd) 490 | { 491 | var wip = stack.Pop(); 492 | if (wip.Doctests.Count > 0) 493 | { 494 | doctests.AddRange(wip.Doctests); 495 | } 496 | } 497 | 498 | switch (node) 499 | { 500 | case Syntax.DocumentationCommentTriviaSyntax documentation: 501 | List codes = Pipeline.CodesFromDocumentation(documentation); 502 | 503 | List dtWoNsOrErrorList = 504 | codes.Select(Pipeline.SplitHeaderBody).ToList(); 505 | 506 | stack.Peek().Doctests.AddRange( 507 | dtWoNsOrErrorList 508 | .Where((dtOrErr) => dtOrErr.DoctestWithoutNamespace != null) 509 | .Select((dtOrErr) => new Doctest( 510 | stack.Peek().Namespace, 511 | dtOrErr.DoctestWithoutNamespace!.Usings, 512 | dtOrErr.DoctestWithoutNamespace!.Body, 513 | dtOrErr.DoctestWithoutNamespace!.Line, 514 | dtOrErr.DoctestWithoutNamespace!.Column))); 515 | 516 | errors.AddRange( 517 | dtWoNsOrErrorList 518 | .Where((dtOrErr) => dtOrErr.Error != null) 519 | .Select((dtOrErr) => dtOrErr.Error!)); 520 | 521 | break; 522 | 523 | case Syntax.NamespaceDeclarationSyntax namespaceDecl: 524 | string ns = (stack.Peek().Namespace == "") 525 | ? namespaceDecl.Name.ToString() 526 | : $"{stack.Peek().Namespace}.{namespaceDecl.Name.ToString()}"; 527 | 528 | stack.Push(new NamespaceWip 529 | { 530 | Namespace = ns, 531 | SpanEnd = namespaceDecl.Span.End 532 | }); 533 | 534 | break; 535 | 536 | default: 537 | continue; 538 | } 539 | } 540 | 541 | while (stack.Count != 0) 542 | { 543 | var wip = stack.Pop(); 544 | if (wip.Doctests.Count > 0) 545 | { 546 | doctests.AddRange(wip.Doctests); 547 | } 548 | } 549 | 550 | // Sort doctests by line and column 551 | doctests.Sort((doctest, otherDoctest) => 552 | { 553 | int ret = doctest.Line.CompareTo(otherDoctest.Line); 554 | if (ret == 0) ret = doctest.Column.CompareTo(otherDoctest.Column); 555 | 556 | return ret; 557 | }); 558 | 559 | return new DoctestsAndErrors(doctests, errors); 560 | } 561 | } 562 | } -------------------------------------------------------------------------------- /src/DoctestCsharp/Generation.cs: -------------------------------------------------------------------------------- 1 | using InvalidOperationException = System.InvalidOperationException; 2 | using Environment = System.Environment; 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | 7 | namespace DoctestCsharp 8 | { 9 | public static class Generation 10 | { 11 | public static class Style 12 | { 13 | public static string Indent(string text, int level) 14 | { 15 | if (text == "") return ""; 16 | 17 | string margin = new string(' ', level * 4); 18 | string[] lines = text.Split( 19 | new[] { "\r\n", "\r", "\n" }, 20 | System.StringSplitOptions.None 21 | ); 22 | 23 | for (var i = 0; i < lines.Length; i++) 24 | { 25 | if (lines[i].Length > 0) 26 | { 27 | lines[i] = margin + lines[i]; 28 | } 29 | } 30 | 31 | return string.Join(Environment.NewLine, lines); 32 | } 33 | 34 | public class NamespacedDoctests 35 | { 36 | public readonly string Namespace; 37 | public readonly List Doctests; 38 | 39 | public NamespacedDoctests(string ns, List doctests) 40 | { 41 | Namespace = ns; 42 | Doctests = doctests; 43 | } 44 | } 45 | 46 | public static List GroupConsecutiveDoctestsByNamespace( 47 | List doctests) 48 | { 49 | var result = new List(); 50 | if (doctests.Count == 0) 51 | { 52 | return result; 53 | } 54 | 55 | string? accumulatorNs = null; 56 | List? accumulator = null; 57 | 58 | foreach (var doctest in doctests) 59 | { 60 | if (accumulatorNs == null) 61 | { 62 | accumulator = new List(); 63 | accumulatorNs = doctest.Namespace; 64 | } 65 | else if (accumulatorNs != doctest.Namespace) 66 | { 67 | if (accumulator == null) 68 | throw new InvalidOperationException("Unexpected null accumulator"); 69 | 70 | if (accumulatorNs == null) 71 | throw new InvalidOperationException("Unexpected null accumulatorNs"); 72 | 73 | result.Add(new NamespacedDoctests(accumulatorNs, accumulator)); 74 | 75 | accumulator = new List(); 76 | accumulatorNs = doctest.Namespace; 77 | } 78 | 79 | if (accumulator == null) 80 | throw new InvalidOperationException("Unexpected null accumulator"); 81 | 82 | if (accumulatorNs == null) 83 | throw new InvalidOperationException("Unexpected null accumulatorNs"); 84 | 85 | accumulator.Add(doctest); 86 | } 87 | 88 | if (accumulator == null) 89 | throw new InvalidOperationException("Unexpected null accumulator"); 90 | 91 | if (accumulatorNs == null) 92 | throw new InvalidOperationException("Unexpected null accumulatorNs"); 93 | 94 | result.Add(new NamespacedDoctests(accumulatorNs, accumulator)); 95 | 96 | return result; 97 | } 98 | 99 | /// 100 | /// Merges all the headers by removing the duplicate using directives. 101 | /// 102 | /// Extracted doctests 103 | /// Single header 104 | public static string MergeHeaders(List doctests) 105 | { 106 | if (doctests.Count == 0) 107 | { 108 | return ""; 109 | } 110 | 111 | bool noHeaders = doctests.All((doctest) => doctest.Usings.Count == 0); 112 | if (noHeaders) 113 | { 114 | return ""; 115 | } 116 | 117 | var usings = new HashSet( 118 | doctests.SelectMany((doctest) => doctest.Usings)); 119 | 120 | var lines = new List(usings.Count); 121 | foreach (Extraction.UsingDirective aUsing in usings) 122 | { 123 | lines.Add( 124 | aUsing.Alias != null 125 | ? $"using {aUsing.Alias} = {aUsing.Name};" 126 | : $"using {aUsing.Name};"); 127 | } 128 | 129 | lines.Sort(System.StringComparer.InvariantCulture); 130 | 131 | return System.String.Join(Environment.NewLine, lines) + Environment.NewLine; 132 | } 133 | } 134 | 135 | public static void Generate( 136 | List doctests, 137 | string suiteIdentifier, 138 | System.IO.TextWriter writer) 139 | { 140 | var blocks = new List(); 141 | 142 | string nl = Environment.NewLine; 143 | 144 | blocks.Add( 145 | $"// This file was automatically generated by doctest-csharp.{nl}" + 146 | $"// !!! DO NOT EDIT OR APPEND !!!{nl}"); 147 | 148 | // Header blocks 149 | 150 | string header = Style.MergeHeaders(doctests); 151 | if (header.Length > 0) 152 | { 153 | blocks.Add(header); 154 | } 155 | 156 | blocks.Add($"using NUnit.Framework;{nl}"); 157 | 158 | // Doctests 159 | 160 | List groupedDoctests = Style.GroupConsecutiveDoctestsByNamespace(doctests); 161 | 162 | var namespaceCount = new Dictionary(); 163 | 164 | foreach (var namespacedDoctests in groupedDoctests) 165 | { 166 | if (!namespaceCount.ContainsKey(namespacedDoctests.Namespace)) 167 | { 168 | namespaceCount[namespacedDoctests.Namespace] = 0; 169 | } 170 | 171 | namespaceCount[namespacedDoctests.Namespace]++; 172 | 173 | var block = new System.IO.StringWriter(); 174 | 175 | block.WriteLine( 176 | namespacedDoctests.Namespace == "" 177 | ? "namespace Tests" 178 | : $"namespace {namespacedDoctests.Namespace}.Tests"); 179 | 180 | block.WriteLine("{"); // namespace opening 181 | 182 | block.WriteLine( 183 | namespaceCount[namespacedDoctests.Namespace] > 1 184 | ? $" public class {suiteIdentifier}{namespaceCount[namespacedDoctests.Namespace]}" 185 | : $" public class {suiteIdentifier}"); 186 | 187 | block.WriteLine(" {"); // class opening 188 | 189 | for (var i = 0; i < namespacedDoctests.Doctests.Count; i++) 190 | { 191 | if (i > 0) 192 | { 193 | block.WriteLine(); 194 | } 195 | 196 | var doctest = namespacedDoctests.Doctests[i]; 197 | block.WriteLine(" [Test]"); 198 | block.WriteLine($" public void AtLine{doctest.Line}AndColumn{doctest.Column}()"); 199 | block.WriteLine(" {"); // method opening 200 | 201 | block.WriteLine( 202 | doctest.Body == "" 203 | ? Style.Indent("// Empty doctest", 3) 204 | : Style.Indent(doctest.Body, 3)); 205 | 206 | block.WriteLine(" }"); // method closing 207 | } 208 | 209 | block.WriteLine(" }"); // class closing 210 | block.WriteLine("}"); // namespace closing 211 | 212 | blocks.Add(block.ToString()); 213 | } 214 | 215 | blocks.Add( 216 | $"// This file was automatically generated by doctest-csharp.{nl}" + 217 | $"// !!! DO NOT EDIT OR APPEND !!!{nl}"); 218 | 219 | // Join blocks 220 | 221 | foreach (string block in blocks) 222 | { 223 | if (!block.EndsWith(Environment.NewLine)) 224 | { 225 | throw new InvalidOperationException( 226 | $"Expected block to end with a new line, but got: {block}"); 227 | } 228 | } 229 | 230 | for (var i = 0; i < blocks.Count; i++) 231 | { 232 | if (i > 0) 233 | { 234 | writer.WriteLine(); 235 | } 236 | 237 | // All block must end with a new-line character, so do not add a new line here. 238 | writer.Write(blocks[i]); 239 | } 240 | } 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/DoctestCsharp/Input.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using ArgumentException = System.ArgumentException; 3 | using StringComparer = System.StringComparer; 4 | using Path = System.IO.Path; 5 | 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Threading; 9 | 10 | namespace DoctestCsharp 11 | { 12 | public static class Input 13 | { 14 | public class InputOutputOrError 15 | { 16 | public readonly List<(string, string)>? InputOutput; 17 | public readonly string? Error; 18 | 19 | public InputOutputOrError(List<(string, string)>? inputOutput, string? error) 20 | { 21 | if (inputOutput == null && error == null) 22 | { 23 | throw new ArgumentException("Both inputOutput and error null"); 24 | } 25 | 26 | if (inputOutput != null && error != null) 27 | { 28 | throw new ArgumentException("Both inputOutput and error given"); 29 | } 30 | 31 | InputOutput = inputOutput; 32 | Error = error; 33 | } 34 | } 35 | 36 | /// 37 | /// Parses the command-line arguments given as --input-output. 38 | /// 39 | /// command-line arguments for --input-output 40 | /// command-line argument --suffix to be appended if no output given 41 | /// List of pairs (input, output) 42 | public static InputOutputOrError ParseInputOutput(string[] inputOutput, string suffix) 43 | { 44 | if (inputOutput.Length == 0) 45 | { 46 | return new InputOutputOrError(new List<(string, string)>(), null); 47 | } 48 | 49 | var result = new List<(string, string)>(inputOutput.Length); 50 | 51 | foreach (string pairStr in inputOutput) 52 | { 53 | string[] parts = pairStr.Split(Path.PathSeparator); 54 | 55 | switch (parts.Length) 56 | { 57 | case 0: 58 | return new InputOutputOrError( 59 | null, "Expected at least an input, but got an empty string"); 60 | case 1: 61 | result.Add((parts[0], parts[0] + suffix)); 62 | break; 63 | case 2: 64 | 65 | result.Add( 66 | ( 67 | parts[0], 68 | // Empty output implies automatic output. 69 | (parts[1].Length == 0) ? parts[0] + suffix : parts[1])); 70 | break; 71 | default: 72 | return new InputOutputOrError( 73 | null, 74 | $"Expected at most a pair, but got {parts.Length} parts " + 75 | $"separated by {Path.PathSeparator} from the input-output: {pairStr}"); 76 | } 77 | } 78 | 79 | // Post-condition 80 | if (result.Count != inputOutput.Length) 81 | { 82 | throw new InvalidOperationException( 83 | $"Unexpected result.Count (== {result.Count}) != " + 84 | $"inputOutput.Length (== {inputOutput.Length})"); 85 | } 86 | 87 | return new InputOutputOrError(result, null); 88 | } 89 | 90 | /// 91 | /// Matches all the files defined by the patterns, includes and excludes. 92 | /// If any of the patterns is given as a relative directory, 93 | /// current working directory is prepended. 94 | /// 95 | /// current working directory 96 | /// GLOB patterns to match files for inspection 97 | /// GLOB patterns to exclude files matching patterns 98 | /// Paths of the matched files 99 | public static IEnumerable MatchFiles( 100 | string cwd, 101 | List patterns, 102 | List excludes) 103 | { 104 | //// 105 | // Pre-condition(s) 106 | //// 107 | 108 | if (cwd.Length == 0) 109 | { 110 | throw new ArgumentException("Expected a non-empty cwd"); 111 | } 112 | 113 | if (!Path.IsPathRooted(cwd)) 114 | { 115 | throw new ArgumentException("Expected cwd to be rooted"); 116 | } 117 | 118 | //// 119 | // Implementation 120 | //// 121 | 122 | if (patterns.Count == 0) 123 | { 124 | yield break; 125 | } 126 | 127 | var globExcludes = excludes.Select( 128 | (pattern) => 129 | { 130 | string rootedPattern = (Path.IsPathRooted(pattern)) 131 | ? pattern 132 | : Path.Join(cwd, pattern); 133 | 134 | return new GlobExpressions.Glob(rootedPattern); 135 | }).ToList(); 136 | 137 | foreach (var pattern in patterns) 138 | { 139 | IEnumerable? files; 140 | 141 | if (Path.IsPathRooted(pattern)) 142 | { 143 | var root = Path.GetPathRoot(pattern); 144 | if (root == null) 145 | { 146 | throw new ArgumentException( 147 | $"Root could not be retrieved from rooted pattern: {pattern}"); 148 | } 149 | 150 | var relPattern = Path.GetRelativePath(root, pattern); 151 | 152 | files = GlobExpressions.Glob.Files(root, relPattern) 153 | .Select((path) => Path.Join(root, path)); 154 | } 155 | else 156 | { 157 | files = GlobExpressions.Glob.Files(cwd, pattern); 158 | } 159 | 160 | List accepted = 161 | files 162 | .Where((path) => 163 | { 164 | string rootedPath = (Path.IsPathRooted(path)) 165 | ? path 166 | : Path.Join(cwd, path); 167 | 168 | return globExcludes.TrueForAll((glob) => !glob.IsMatch(rootedPath)); 169 | }) 170 | .ToList(); 171 | 172 | accepted.Sort(StringComparer.InvariantCulture); 173 | 174 | foreach (string path in accepted) 175 | { 176 | yield return path; 177 | } 178 | } 179 | } 180 | } 181 | } -------------------------------------------------------------------------------- /src/DoctestCsharp/Process.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using ArgumentException = System.ArgumentException; 3 | using Path = System.IO.Path; 4 | using File = System.IO.File; 5 | using System.Collections.Generic; 6 | using System.Text.RegularExpressions; 7 | 8 | namespace DoctestCsharp 9 | { 10 | public static class Process 11 | { 12 | public static string InputPath(string relativePath, string input) 13 | { 14 | if (Path.IsPathRooted(relativePath)) 15 | { 16 | throw new ArgumentException($"Expected a relative path, but got a rooted one: {relativePath}"); 17 | } 18 | 19 | return Path.Join(input, relativePath); 20 | } 21 | 22 | public static string OutputPath(string relativePath, string output) 23 | { 24 | if (Path.IsPathRooted(relativePath)) 25 | { 26 | throw new ArgumentException($"Expected a relative path, but got a rooted one: {relativePath}"); 27 | } 28 | 29 | string doctestRelativePath = Path.Join( 30 | Path.GetDirectoryName(relativePath), 31 | "DocTest" + Path.GetFileName(relativePath)); 32 | 33 | return Path.Join(output, doctestRelativePath); 34 | } 35 | 36 | private static readonly Regex nonidentifierCharRe = new Regex(@"[^a-zA-Z0-9_]"); 37 | 38 | /// 39 | /// Infers the identifier for the test suite based on the relative path to the input file. 40 | /// 41 | /// path to the input file, relative 42 | /// Valid C# class identifier 43 | public static string Identifier(string relativeInputPath) 44 | { 45 | if (relativeInputPath.Length == 0) 46 | { 47 | throw new ArgumentException($"Unexpected empty {nameof(relativeInputPath)}"); 48 | } 49 | 50 | if (Path.IsPathRooted(relativeInputPath)) 51 | { 52 | throw new ArgumentException( 53 | $"Unexpected rooted {nameof(relativeInputPath)}: {relativeInputPath}"); 54 | } 55 | 56 | return $"DocTest_{nonidentifierCharRe.Replace(relativeInputPath, "_")}"; 57 | } 58 | 59 | /// 60 | /// Generates the doctests given the extracted doctests from the input file. 61 | /// 62 | /// Extracted doctests from the input file 63 | /// Relative path to the input file 64 | /// Absolute path to the output doctest file 65 | /// true if there is at least one generated doctest 66 | /// 67 | public static bool Generate( 68 | List doctests, 69 | string relativeInputPath, 70 | string outputPath) 71 | { 72 | // Pre-condition(s) 73 | if (!Path.IsPathRooted(outputPath)) 74 | { 75 | throw new ArgumentException($"Expected a rooted outputPath, but got: {outputPath}"); 76 | } 77 | 78 | // Implementation 79 | 80 | if (doctests.Count == 0) 81 | { 82 | return false; 83 | } 84 | 85 | System.IO.Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); 86 | 87 | string identifier = Identifier(relativeInputPath); 88 | 89 | using var streamWriter = new System.IO.StreamWriter(outputPath); 90 | 91 | Generation.Generate(doctests, identifier, streamWriter); 92 | return true; 93 | } 94 | 95 | /// 96 | /// Checks that the generated output actually matches the stored output. 97 | /// 98 | /// Extracted doctests 99 | /// Relative path to the input file 100 | /// Absolute path to the output doctest file 101 | /// Outcome of the check 102 | public static Report.IReport Check( 103 | List doctests, 104 | string relativeInputPath, 105 | string outputPath) 106 | { 107 | // Pre-condition(s) 108 | 109 | if (!Path.IsPathRooted(outputPath)) 110 | { 111 | throw new ArgumentException($"Expected a rooted outputPath, but got: {outputPath}"); 112 | } 113 | 114 | // Implementation 115 | 116 | if (doctests.Count == 0) 117 | { 118 | if (File.Exists(outputPath)) 119 | { 120 | return new Report.ShouldNotExist(); 121 | } 122 | 123 | return new Report.Ok(); 124 | } 125 | 126 | if (doctests.Count > 0 && !File.Exists(outputPath)) 127 | { 128 | return new Report.DoesntExist(); 129 | } 130 | 131 | string identifier = Identifier(relativeInputPath); 132 | 133 | using var stringWriter = new System.IO.StringWriter(); 134 | 135 | Generation.Generate(doctests, identifier, stringWriter); 136 | 137 | string expected = stringWriter.ToString(); 138 | 139 | string got = File.ReadAllText(outputPath); 140 | 141 | // Split and re-join by new lines to be system-agnostic 142 | 143 | expected = String.Join( 144 | System.Environment.NewLine, 145 | expected.Split( 146 | new[] { "\r\n", "\r", "\n" }, 147 | System.StringSplitOptions.None 148 | )); 149 | 150 | got = String.Join( 151 | System.Environment.NewLine, 152 | got.Split( 153 | new[] { "\r\n", "\r", "\n" }, 154 | System.StringSplitOptions.None 155 | )); 156 | 157 | if (expected == got) 158 | { 159 | return new Report.Ok(); 160 | } 161 | 162 | // Compute the difference if expected and got are not equal 163 | 164 | var diffBuilder = new DiffPlex.DiffBuilder.InlineDiffBuilder(new DiffPlex.Differ()); 165 | var diff = diffBuilder.BuildDiffModel(expected, got); 166 | return new Report.Different(diff); 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/DoctestCsharp/Program.cs: -------------------------------------------------------------------------------- 1 | using NotImplementedException = System.NotImplementedException; 2 | using InvalidOperationException = System.InvalidOperationException; 3 | using Console = System.Console; 4 | using Environment = System.Environment; 5 | using Path = System.IO.Path; 6 | using File = System.IO.File; 7 | using Directory = System.IO.Directory; 8 | using CSharpSyntaxTree = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree; 9 | using System.Collections.Generic; 10 | using System.CommandLine; 11 | 12 | namespace DoctestCsharp 13 | { 14 | public class Program 15 | { 16 | private static int Handle(string[] inputOutput, string suffix, string[]? excludes, bool check, bool verbose) 17 | { 18 | int exitCode = 0; 19 | 20 | var inputOutputOrError = Input.ParseInputOutput(inputOutput, suffix); 21 | if (inputOutputOrError.Error != null) 22 | { 23 | Console.Error.WriteLine($"Failed to parse --input-output: {inputOutputOrError.Error}"); 24 | return 1; 25 | } 26 | 27 | if (inputOutputOrError.InputOutput == null) 28 | { 29 | throw new InvalidOperationException( 30 | "Invalid inputOutputOrError: both InputOutput and Error are null."); 31 | } 32 | 33 | string cwd = Directory.GetCurrentDirectory(); 34 | 35 | foreach (var (input, output) in inputOutputOrError.InputOutput) 36 | { 37 | string rootedInput = 38 | Path.IsPathRooted(input) 39 | ? input 40 | : Path.Join(cwd, input); 41 | 42 | string rootedOutput = 43 | Path.IsPathRooted(output) 44 | ? output 45 | : Path.Join(cwd, output); 46 | 47 | IEnumerable relativePaths = Input.MatchFiles( 48 | rootedInput, 49 | new List { "**/*.cs" }, 50 | new List(excludes ?? new string[0])); 51 | 52 | foreach (string relativePath in relativePaths) 53 | { 54 | if (Path.IsPathRooted(relativePath)) 55 | { 56 | throw new InvalidOperationException( 57 | $"Expected path to be relative, but got rooted path: {relativePath}"); 58 | } 59 | 60 | string inputPath = Process.InputPath(relativePath, rootedInput); 61 | string outputPath = Process.OutputPath(relativePath, rootedOutput); 62 | 63 | var doctestsAndErrors = Extraction.Extract( 64 | CSharpSyntaxTree.ParseText( 65 | File.ReadAllText(inputPath))); 66 | 67 | if (doctestsAndErrors.Errors.Count > 0) 68 | { 69 | Console.WriteLine($"Failed to extract doctest(s) from: {inputPath}"); 70 | foreach (var error in doctestsAndErrors.Errors) 71 | { 72 | Console.WriteLine($"* Line {error.Line + 1}, column {error.Column + 1}: {error.Message}"); 73 | } 74 | 75 | exitCode = 1; 76 | 77 | continue; 78 | } 79 | 80 | var doctests = doctestsAndErrors.Doctests; 81 | 82 | if (!check) 83 | { 84 | bool generated = Process.Generate(doctests, relativePath, outputPath); 85 | 86 | if (verbose) 87 | { 88 | Console.WriteLine( 89 | generated 90 | ? $"Generated doctest(s) for: {inputPath} -> {outputPath}" 91 | : $"No doctests found in: {inputPath}"); 92 | } 93 | } 94 | else 95 | { 96 | var report = Process.Check(doctests, relativePath, outputPath); 97 | switch (report) 98 | { 99 | case Report.Ok _: 100 | if (verbose) 101 | { 102 | Console.WriteLine( 103 | (doctests.Count > 0) 104 | ? $"OK: {inputPath} -> {outputPath}" 105 | : $"OK, no doctests: {inputPath}"); 106 | } 107 | break; 108 | case Report.Different reportDifferent: 109 | Console.WriteLine($"Expected different content: {inputPath} -> {outputPath}"); 110 | Console.WriteLine( 111 | "Here is the diff between the expected content and the actual content:"); 112 | 113 | foreach (var line in reportDifferent.Diff.Lines) 114 | { 115 | switch (line.Type) 116 | { 117 | case DiffPlex.DiffBuilder.Model.ChangeType.Inserted: 118 | Console.Write("+ "); 119 | break; 120 | case DiffPlex.DiffBuilder.Model.ChangeType.Deleted: 121 | Console.Write("- "); 122 | break; 123 | default: 124 | Console.Write(" "); 125 | break; 126 | } 127 | 128 | Console.WriteLine(line.Text); 129 | } 130 | 131 | exitCode = 1; 132 | break; 133 | 134 | case Report.DoesntExist _: 135 | Console.WriteLine($"Output file does not exist: {inputPath} -> {outputPath}"); 136 | exitCode = 1; 137 | break; 138 | 139 | case Report.ShouldNotExist _: 140 | Console.WriteLine( 141 | $"No doctests found in: {inputPath}; the output should not exist: {outputPath}"); 142 | exitCode = 1; 143 | break; 144 | 145 | default: 146 | throw new NotImplementedException($"Uncovered report: {report}"); 147 | } 148 | } 149 | } 150 | } 151 | 152 | return exitCode; 153 | } 154 | 155 | public static int MainWithCode(string[] args) 156 | { 157 | var nl = Environment.NewLine; 158 | 159 | var rootCommand = new RootCommand( 160 | "Generates tests from the embedded code snippets in the code documentation.") 161 | { 162 | new Option( 163 | new[] {"--input-output"}, 164 | $"Input and output directory pairs containing the *.cs files{nl}{nl}" + 165 | "The input is separated from the output by the PATH separator " + 166 | "(e.g., ';' on Windows, ':' on POSIX)." + 167 | "If no output is specified, the --suffix is appended to the input to automatically obtain " + 168 | "the output.") 169 | { 170 | Required = true 171 | }, 172 | 173 | new Option( 174 | new[] {"--suffix", "-s"}, 175 | () => ".Tests", 176 | "Suffix to be automatically appended to the input to obtain the output directory " + 177 | "in cases where no explicit output directory was given" 178 | ), 179 | 180 | new Option( 181 | new[] {"--excludes", "-e"}, 182 | $"Glob patterns of the files to be excluded from the input.{nl}{nl}" + 183 | "The exclude patterns are either absolute (e.g., rooted with '/') or relative. " + 184 | "In case of relative exclude patterns, they are relative to the _input_ directory " + 185 | "and NOT to the current working directory."), 186 | 187 | new Option( 188 | new[] {"--check", "-c"}, 189 | "If set, does not generate any files, but only checks that " + 190 | $"the content of the test files coincides with what would be generated.{nl}{nl}" + 191 | "This is particularly useful " + 192 | "in continuous integration pipelines if you want to check if all the files " + 193 | "have been scanned and correctly generated." 194 | ), 195 | 196 | new Option( 197 | new[] {"--verbose"}, 198 | "If set, outputs only important messages to the users such as errors" 199 | ) 200 | }; 201 | 202 | rootCommand.Handler = System.CommandLine.Invocation.CommandHandler.Create( 203 | (string[] inputOutput, string suffix, string[]? excludes, bool check, bool verbose) => 204 | Handle(inputOutput, suffix, excludes, check, verbose)); 205 | 206 | int exitCode = rootCommand.InvokeAsync(args).Result; 207 | return exitCode; 208 | } 209 | 210 | public static void Main(string[] args) 211 | { 212 | int exitCode = MainWithCode(args); 213 | Environment.ExitCode = exitCode; 214 | } 215 | } 216 | } -------------------------------------------------------------------------------- /src/DoctestCsharp/Report.cs: -------------------------------------------------------------------------------- 1 | using DiffPlex.DiffBuilder.Model; 2 | 3 | namespace DoctestCsharp 4 | { 5 | public static class Report 6 | { 7 | public interface IReport 8 | { 9 | // Intentionally empty 10 | } 11 | 12 | public class Ok : IReport 13 | { 14 | // Intentionally empty 15 | } 16 | 17 | public class Different : IReport 18 | { 19 | public DiffPlex.DiffBuilder.Model.DiffPaneModel Diff; 20 | 21 | public Different(DiffPlex.DiffBuilder.Model.DiffPaneModel diff) 22 | { 23 | Diff = diff; 24 | } 25 | } 26 | 27 | public class DoesntExist : IReport 28 | { 29 | // Intentionally empty 30 | } 31 | 32 | public class ShouldNotExist : IReport 33 | { 34 | // Intentionally empty 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/doctest-csharp.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DoctestCsharp", "DoctestCsharp\DoctestCsharp.csproj", "{F30995BF-0925-4D3F-A1C0-DE225513DC98}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DoctestCsharp.Test", "DoctestCsharp.Test\DoctestCsharp.Test.csproj", "{D4972551-B0C3-4D07-AB9A-F23477E466E3}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Debug|x64 = Debug|x64 14 | Debug|x86 = Debug|x86 15 | Release|Any CPU = Release|Any CPU 16 | Release|x64 = Release|x64 17 | Release|x86 = Release|x86 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {F30995BF-0925-4D3F-A1C0-DE225513DC98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {F30995BF-0925-4D3F-A1C0-DE225513DC98}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {F30995BF-0925-4D3F-A1C0-DE225513DC98}.Debug|x64.ActiveCfg = Debug|Any CPU 26 | {F30995BF-0925-4D3F-A1C0-DE225513DC98}.Debug|x64.Build.0 = Debug|Any CPU 27 | {F30995BF-0925-4D3F-A1C0-DE225513DC98}.Debug|x86.ActiveCfg = Debug|Any CPU 28 | {F30995BF-0925-4D3F-A1C0-DE225513DC98}.Debug|x86.Build.0 = Debug|Any CPU 29 | {F30995BF-0925-4D3F-A1C0-DE225513DC98}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {F30995BF-0925-4D3F-A1C0-DE225513DC98}.Release|Any CPU.Build.0 = Release|Any CPU 31 | {F30995BF-0925-4D3F-A1C0-DE225513DC98}.Release|x64.ActiveCfg = Release|Any CPU 32 | {F30995BF-0925-4D3F-A1C0-DE225513DC98}.Release|x64.Build.0 = Release|Any CPU 33 | {F30995BF-0925-4D3F-A1C0-DE225513DC98}.Release|x86.ActiveCfg = Release|Any CPU 34 | {F30995BF-0925-4D3F-A1C0-DE225513DC98}.Release|x86.Build.0 = Release|Any CPU 35 | {D4972551-B0C3-4D07-AB9A-F23477E466E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {D4972551-B0C3-4D07-AB9A-F23477E466E3}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {D4972551-B0C3-4D07-AB9A-F23477E466E3}.Debug|x64.ActiveCfg = Debug|Any CPU 38 | {D4972551-B0C3-4D07-AB9A-F23477E466E3}.Debug|x64.Build.0 = Debug|Any CPU 39 | {D4972551-B0C3-4D07-AB9A-F23477E466E3}.Debug|x86.ActiveCfg = Debug|Any CPU 40 | {D4972551-B0C3-4D07-AB9A-F23477E466E3}.Debug|x86.Build.0 = Debug|Any CPU 41 | {D4972551-B0C3-4D07-AB9A-F23477E466E3}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {D4972551-B0C3-4D07-AB9A-F23477E466E3}.Release|Any CPU.Build.0 = Release|Any CPU 43 | {D4972551-B0C3-4D07-AB9A-F23477E466E3}.Release|x64.ActiveCfg = Release|Any CPU 44 | {D4972551-B0C3-4D07-AB9A-F23477E466E3}.Release|x64.Build.0 = Release|Any CPU 45 | {D4972551-B0C3-4D07-AB9A-F23477E466E3}.Release|x86.ActiveCfg = Release|Any CPU 46 | {D4972551-B0C3-4D07-AB9A-F23477E466E3}.Release|x86.Build.0 = Release|Any CPU 47 | EndGlobalSection 48 | EndGlobal 49 | -------------------------------------------------------------------------------- /src/doctest-csharp.sln.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | True 3 | True 4 | True 5 | 6 | True 7 | True --------------------------------------------------------------------------------