Skip to content
This repository was archived by the owner on Sep 16, 2021. It is now read-only.

Commit 80fe437

Browse files
authored
Revise expectation syntax, fix regular expression bug, refactor implementation (#30)
* Remove LinuxMain * Update expectations to type, value, match, and error * Refactor scanning logic into Scanner type * Fix Scanner regular expression pattern Add test for Scanner * Internalize inessential APIs * Extract tests into separate files * Add changelog entries for #30 * Update README * Add CI badge to README * Feed source file into REPL when not running in package context * Log notice for blocks without any import statements when running through SPM
1 parent 3a7db2f commit 80fe437

File tree

14 files changed

+214
-77
lines changed

14 files changed

+214
-77
lines changed

Changelog.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Changed Swift version requirement to 5.3.
1313
#29 by @mattt.
14+
- Changed expression syntax.
15+
#30 by @mattt.
16+
17+
### Fixed
18+
19+
- Fixed a bug that caused DocTest annotations to be missed.
20+
#30 by @mattt.
1421

1522
## [0.1.0] - 2020-05-04
1623

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ let package = Package(
3434
name: "swift-doctest",
3535
dependencies: [
3636
.target(name: "DocTest"),
37-
.product(name: "StringLocationConverter", package: "StringLocationConverter"),
3837
.product(name: "ArgumentParser", package: "swift-argument-parser"),
3938
.product(name: "Logging", package: "swift-log"),
4039
]),
4140
.target(
4241
name: "DocTest",
4342
dependencies: [
4443
.product(name: "SwiftSyntax", package: "SwiftSyntax"),
44+
.product(name: "StringLocationConverter", package: "StringLocationConverter"),
4545
.product(name: "TAP", package: "TAP"),
4646
]),
4747
.testTarget(

README.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# DocTest
22

3+
![CI][ci badge]
4+
35
**DocTest** is an experimental tool
46
for testing Swift example code in documentation.
57

@@ -73,7 +75,7 @@ OVERVIEW: A utility for syntax testing documentation in Swift code.
7375
USAGE: swift-doctest <input> [--swift-launch-path <swift-launch-path>] [--package] [--assumed-filename <assumed-filename>]
7476
7577
ARGUMENTS:
76-
<input> Swift code or a path to a Swift file
78+
<input> Swift code or a path to a Swift file
7779
7880
OPTIONS:
7981
--swift-launch-path <swift-launch-path>
@@ -130,30 +132,28 @@ This tells the documentation test runner to evaluate the code sample.
130132
```
131133

132134
By adding an annotation in the format
133-
`=> (Type) = (Value)`,
135+
`=> <#Value#>`,
134136
we can test the expected type and value
135137
of the expression.
136138

137139
```diff
138140
- add(1 1) // 3.0
139-
+ add(1 1) // => Double = 3.0
141+
+ add(1 1) // => 3.0
140142
```
141143

142144
Run the `swift-doctest` command
143-
from the root directory of the Swift package,
144-
specifying the `--package` flag
145-
(to invoke the Swift REPL via the Swift Package Manager)
145+
from the root directory of the Swift package
146146
and passing the path to the file containing the `add(_:_:)` function.
147147
This will scan for all of code blocks annotated with
148-
<code>```swift doctest</code>
148+
<code>```swift doctest</code>,
149149
run them through the Swift REPL,
150150
and test the output with any annotated expectations.
151151

152152
```terminal
153153
$ swift doctest --package path/to/file.swift
154154
TAP version 13
155155
1..1
156-
not ok 1 - `add(1 1)` did not produce `Double = 3.0`
156+
not ok 1 - `add(1 1)` did not produce `3.0`
157157
---
158158
column: 1
159159
file: path/to/file.swift.md
@@ -172,7 +172,7 @@ we update the documentation to fix the example.
172172
Returns the sum of two integers.
173173
174174
```swift doctest
175-
add(1, 1) // => Int = 2
175+
add(1, 1) // => 2
176176
```
177177
*/
178178
func add(_ a: Int, _ b: Int) -> Int { ... }
@@ -185,7 +185,7 @@ the tests now pass as expected.
185185
$ swift doctest --package path/to/file.swift
186186
TAP version 13
187187
1..1
188-
ok 1 - `add(1, 1)` produces `Int = 2`
188+
ok 1 - `add(1, 1)` produces `2`
189189
---
190190
column: 1
191191
file: path/to/file.swift.md
@@ -210,3 +210,5 @@ Mattt ([@mattt](https://twitter.com/mattt))
210210

211211
[seccomp]: https://docs.docker.com/engine/security/seccomp/
212212
[apparmor]: https://docs.docker.com/engine/security/apparmor/
213+
214+
[ci badge]: https://github.com/SwiftDocOrg/DocTest/workflows/CI/badge.svg

Sources/DocTest/Expectation.swift

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,42 @@
11
import Foundation
22

33
public enum Expectation: Hashable {
4-
case value(String)
54
case error
5+
case type(String)
6+
case value(String)
7+
case match(String)
68

79
public init?(_ string: String?) {
8-
guard let string = string?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil }
9-
if string.starts(with: "=>"),
10-
let index = string.firstIndex(where: { $0.isWhitespace })
11-
{
12-
self = .value(string.suffix(from: index).trimmingCharacters(in: .whitespaces))
13-
} else if string.starts(with: "!!") {
10+
guard let string = string?.trimmed,
11+
let index = string.index(string.startIndex, offsetBy: 2, limitedBy: string.endIndex)
12+
else { return nil }
13+
14+
switch string.prefix(upTo: index) {
15+
case "!!":
1416
self = .error
15-
} else {
17+
case "->":
18+
self = .type(string.suffix(from: index).trimmed)
19+
case "=>":
20+
self = .value(string.suffix(from: index).trimmed)
21+
case "~>":
22+
self = .match(string.suffix(from: index).trimmed)
23+
default:
1624
return nil
1725
}
1826
}
27+
28+
public func evaluate(_ output: String) -> Bool {
29+
let output = output.trimmed
30+
31+
switch self {
32+
case .error:
33+
return output.hasPrefix("error:")
34+
case .type(let type):
35+
return output.hasPrefix("\(type) =")
36+
case .value(let value):
37+
return output.hasSuffix("= \(value)")
38+
case .match(let pattern):
39+
return output.range(of: pattern, options: .regularExpression) != nil
40+
}
41+
}
1942
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import Foundation
2+
3+
extension StringProtocol {
4+
var trimmed: String {
5+
trimmingCharacters(in: .whitespacesAndNewlines)
6+
}
7+
}

Sources/DocTest/REPL.swift

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ public class REPL {
1717
public var description: String
1818

1919
public init?(_ description: String) {
20-
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
21-
guard !trimmedDescription.isEmpty else { return nil }
22-
self.description = trimmedDescription
20+
let description = description.trimmed
21+
guard !description.isEmpty else { return nil }
22+
self.description = description
2323
}
2424
}
2525

@@ -41,7 +41,7 @@ public class REPL {
4141

4242
public var evaluationHandler: ((Statement, Result<String, Error>) -> Void)?
4343

44-
public init(configuration: Configuration) {
44+
init(configuration: Configuration) {
4545
process = Process()
4646

4747
if #available(OSX 10.13, *) {
@@ -108,7 +108,7 @@ public class REPL {
108108
}
109109
}
110110

111-
public func evaluate(_ statement: Statement) {
111+
func evaluate(_ statement: Statement) {
112112
if !process.isRunning {
113113
if #available(OSX 10.13, *) {
114114
try! process.run()
@@ -133,12 +133,11 @@ public class REPL {
133133
outputPipe.fileHandleForReading.readabilityHandler = nil
134134
}
135135

136-
public func waitUntilExit() {
136+
func waitUntilExit() {
137137
process.waitUntilExit()
138138
}
139139

140-
public func close() {
141-
140+
func close() {
142141
if #available(OSX 10.15, *) {
143142
try! self.inputPipe.fileHandleForWriting.close()
144143
}

Sources/DocTest/Runner.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@ public final class Runner {
3737

3838
let repl = REPL(configuration: configuration)
3939

40-
repl.evaluationHandler = { (statement, result) in
41-
tests.append(contentsOf: statement.tests(with: result))
42-
}
43-
4440
for statement in statements {
4541
repl.evaluate(statement)
4642
}
4743

44+
repl.evaluationHandler = { (statement, result) in
45+
tests.append(contentsOf: statement.tests(with: result))
46+
}
47+
4848
repl.close()
4949
repl.waitUntilExit()
5050

Sources/DocTest/Scanner.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Foundation
2+
import StringLocationConverter
3+
4+
public class Scanner {
5+
public typealias Match = (line: Int, column: Int, content: String)
6+
7+
private var regularExpression: NSRegularExpression
8+
9+
public init() throws {
10+
let pattern = #"""
11+
^
12+
\h* \`{3} \h* swift \h+ doctest \h* \n
13+
(.+)\n
14+
\h* \`{3} \h*
15+
$
16+
"""#
17+
self.regularExpression = try NSRegularExpression(pattern: pattern,
18+
options: [
19+
.allowCommentsAndWhitespace,
20+
.anchorsMatchLines,
21+
.caseInsensitive,
22+
.dotMatchesLineSeparators
23+
])
24+
}
25+
26+
public func matches(in source: String) -> [Match] {
27+
let range = NSRange(source.startIndex..<source.endIndex, in: source)
28+
return regularExpression.matches(in: source, options: [], range: range).compactMap { result in
29+
guard result.numberOfRanges == 2,
30+
let range = Range(result.range(at: 1), in: source)
31+
else { return nil }
32+
let content = source[range]
33+
34+
let converter = StringLocationConverter(for: source)
35+
36+
let line: Int, column: Int
37+
if let location = converter.location(for: range.lowerBound, in: source) {
38+
line = location.line
39+
column = location.column
40+
} else {
41+
line = 0
42+
column = range.lowerBound.utf16Offset(in: source)
43+
}
44+
45+
return (line, column, content.trimmed)
46+
}
47+
}
48+
}

Sources/DocTest/Statement.swift

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ public class Statement {
88
public internal(set) var expectations: [Expectation] = []
99

1010
public init?(_ node: CodeBlockItemSyntax, _ sourceLocation: SourceLocation) {
11-
let code = node.withoutTrivia().description.trimmingCharacters(in: .whitespacesAndNewlines)
11+
let code = node.withoutTrivia().description.trimmed
1212
guard !code.isEmpty else { return nil }
1313

1414
self.code = code
1515
self.sourceLocation = sourceLocation
1616
}
1717

18-
public func tests(with result: Result<String, REPL.Error>) -> [Test] {
18+
func tests(with result: Result<String, REPL.Error>) -> [Test] {
1919
var metadata: [String: Any] = [
2020
"file": self.sourceLocation.file as Any?,
2121
"line": self.sourceLocation.line as Any?,
@@ -31,16 +31,18 @@ public class Statement {
3131
} else {
3232
return expectations.map { expectation in
3333
switch expectation {
34-
case .value(let expected):
34+
case .error:
35+
return test {
36+
.success("- `\(self.code)` produced an error, as expected", directive: nil, metadata: metadata)
37+
}
38+
case .type(let expected),
39+
.value(let expected),
40+
.match(let expected):
3541
metadata["expected"] = expected
3642

3743
return test {
3844
.failure("- `\(self.code)` produced an error", directive: nil, metadata: metadata)
3945
}
40-
case .error:
41-
return test {
42-
.success("- `\(self.code)` produced an error, as expected", directive: nil, metadata: metadata)
43-
}
4446
}
4547
}
4648
}
@@ -49,10 +51,12 @@ public class Statement {
4951

5052
return expectations.map { expectation in
5153
switch expectation {
52-
case .value(let expected):
54+
case .type(let expected),
55+
.value(let expected),
56+
.match(let expected):
5357
metadata["expected"] = expected
5458

55-
if actual == expected {
59+
if expectation.evaluate(actual) {
5660
return test {
5761
.success("- `\(self.code)` produces `\(actual)`", directive: nil, metadata: metadata)
5862
}

Sources/swift-doctest/main.swift

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,7 @@ struct SwiftDocTest: ParsableCommand {
6666
logger.debug("Swift launch path: \(configuration.launchPath)")
6767
logger.debug("Swift launch arguments: \(configuration.arguments)")
6868

69-
let pattern = #"^\`{3}\s*swift\s+doctest\s*\n(.+)\n\`{3}$"#
70-
let regex = try NSRegularExpression(pattern: pattern, options: [.caseInsensitive, .anchorsMatchLines, .dotMatchesLineSeparators])
69+
let scanner = try Scanner()
7170

7271
let source: String
7372
let assumedFileName: String
@@ -82,29 +81,24 @@ struct SwiftDocTest: ParsableCommand {
8281
logger.trace("Scanning standard input for DocTest blocks")
8382
}
8483

85-
let converter = StringLocationConverter(for: source)
86-
8784
var reports: [Report] = []
8885

8986
let group = DispatchGroup()
90-
regex.enumerateMatches(in: source, options: [], range: NSRange(source.startIndex..<source.endIndex, in: source)) { (result, _, _) in
91-
guard let result = result, result.numberOfRanges == 2,
92-
let range = Range(result.range(at: 1), in: source)
93-
else { return }
94-
let match = source[range]
95-
96-
let position: String
97-
var lineOffset: Int = 0
98-
if let location = converter.location(for: range.lowerBound, in: source) {
99-
lineOffset = location.line
100-
position = "\(location.line):\(location.column)"
87+
for match in scanner.matches(in: source) {
88+
logger.info("Found DocTest block at \(assumedFileName)#\(match.line):\(match.column)\n\(match.content)")
89+
90+
var lineOffset = match.line
91+
var code = match.content
92+
if options.runThroughPackageManager {
93+
if source.range(of: #"^import \w"#, options: [.regularExpression]) == nil {
94+
logger.notice("No import statements found at \(assumedFileName)#\(match.line):\(match.column). This may cause unexpected API resolution failures when running through Swift Package Manager.")
95+
}
10196
} else {
102-
position = "\(range.lowerBound.utf16Offset(in: source))\(range.upperBound.utf16Offset(in: source))"
97+
code = "\(source)\n\(code)"
98+
lineOffset -= source.split(whereSeparator: { $0.isNewline }).count + 1
10399
}
104-
logger.info("Found DocTest block at \(assumedFileName)#\(position)\n\(match)")
105-
106-
let runner = try! Runner(source: String(match), assumedFileName: assumedFileName, lineOffset: lineOffset)
107100

101+
let runner = try Runner(source: code, assumedFileName: assumedFileName, lineOffset: lineOffset)
108102
group.enter()
109103
runner.run(with: configuration) { result in
110104
switch result {

0 commit comments

Comments
 (0)