diff --git a/Changelog.md b/Changelog.md index 3541bca..6bd5e22 100644 --- a/Changelog.md +++ b/Changelog.md @@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changed Swift version requirement to 5.3. #29 by @mattt. +- Changed expression syntax. + #30 by @mattt. + +### Fixed + +- Fixed a bug that caused DocTest annotations to be missed. + #30 by @mattt. ## [0.1.0] - 2020-05-04 diff --git a/Package.swift b/Package.swift index 5a5c24c..84dda23 100644 --- a/Package.swift +++ b/Package.swift @@ -34,7 +34,6 @@ let package = Package( name: "swift-doctest", dependencies: [ .target(name: "DocTest"), - .product(name: "StringLocationConverter", package: "StringLocationConverter"), .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), ]), @@ -42,6 +41,7 @@ let package = Package( name: "DocTest", dependencies: [ .product(name: "SwiftSyntax", package: "SwiftSyntax"), + .product(name: "StringLocationConverter", package: "StringLocationConverter"), .product(name: "TAP", package: "TAP"), ]), .testTarget( diff --git a/README.md b/README.md index c3d9c41..7b591da 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # DocTest +![CI][ci badge] + **DocTest** is an experimental tool for testing Swift example code in documentation. @@ -73,7 +75,7 @@ OVERVIEW: A utility for syntax testing documentation in Swift code. USAGE: swift-doctest [--swift-launch-path ] [--package] [--assumed-filename ] ARGUMENTS: - Swift code or a path to a Swift file + Swift code or a path to a Swift file OPTIONS: --swift-launch-path @@ -130,22 +132,20 @@ This tells the documentation test runner to evaluate the code sample. ``` By adding an annotation in the format -`=> (Type) = (Value)`, +`=> <#Value#>`, we can test the expected type and value of the expression. ```diff - add(1 1) // 3.0 -+ add(1 1) // => Double = 3.0 ++ add(1 1) // => 3.0 ``` Run the `swift-doctest` command -from the root directory of the Swift package, -specifying the `--package` flag -(to invoke the Swift REPL via the Swift Package Manager) +from the root directory of the Swift package and passing the path to the file containing the `add(_:_:)` function. This will scan for all of code blocks annotated with -```swift doctest +```swift doctest, run them through the Swift REPL, and test the output with any annotated expectations. @@ -153,7 +153,7 @@ and test the output with any annotated expectations. $ swift doctest --package path/to/file.swift TAP version 13 1..1 -not ok 1 - `add(1 1)` did not produce `Double = 3.0` +not ok 1 - `add(1 1)` did not produce `3.0` --- column: 1 file: path/to/file.swift.md @@ -172,7 +172,7 @@ we update the documentation to fix the example. Returns the sum of two integers. ```swift doctest - add(1, 1) // => Int = 2 + add(1, 1) // => 2 ``` */ func add(_ a: Int, _ b: Int) -> Int { ... } @@ -185,7 +185,7 @@ the tests now pass as expected. $ swift doctest --package path/to/file.swift TAP version 13 1..1 -ok 1 - `add(1, 1)` produces `Int = 2` +ok 1 - `add(1, 1)` produces `2` --- column: 1 file: path/to/file.swift.md @@ -210,3 +210,5 @@ Mattt ([@mattt](https://twitter.com/mattt)) [seccomp]: https://docs.docker.com/engine/security/seccomp/ [apparmor]: https://docs.docker.com/engine/security/apparmor/ + +[ci badge]: https://github.com/SwiftDocOrg/DocTest/workflows/CI/badge.svg diff --git a/Sources/DocTest/Expectation.swift b/Sources/DocTest/Expectation.swift index 6ef9200..31656ad 100644 --- a/Sources/DocTest/Expectation.swift +++ b/Sources/DocTest/Expectation.swift @@ -1,19 +1,42 @@ import Foundation public enum Expectation: Hashable { - case value(String) case error + case type(String) + case value(String) + case match(String) public init?(_ string: String?) { - guard let string = string?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil } - if string.starts(with: "=>"), - let index = string.firstIndex(where: { $0.isWhitespace }) - { - self = .value(string.suffix(from: index).trimmingCharacters(in: .whitespaces)) - } else if string.starts(with: "!!") { + guard let string = string?.trimmed, + let index = string.index(string.startIndex, offsetBy: 2, limitedBy: string.endIndex) + else { return nil } + + switch string.prefix(upTo: index) { + case "!!": self = .error - } else { + case "->": + self = .type(string.suffix(from: index).trimmed) + case "=>": + self = .value(string.suffix(from: index).trimmed) + case "~>": + self = .match(string.suffix(from: index).trimmed) + default: return nil } } + + public func evaluate(_ output: String) -> Bool { + let output = output.trimmed + + switch self { + case .error: + return output.hasPrefix("error:") + case .type(let type): + return output.hasPrefix("\(type) =") + case .value(let value): + return output.hasSuffix("= \(value)") + case .match(let pattern): + return output.range(of: pattern, options: .regularExpression) != nil + } + } } diff --git a/Sources/DocTest/Extensions/StringProtocol+Extensions.swift b/Sources/DocTest/Extensions/StringProtocol+Extensions.swift new file mode 100644 index 0000000..285ddad --- /dev/null +++ b/Sources/DocTest/Extensions/StringProtocol+Extensions.swift @@ -0,0 +1,7 @@ +import Foundation + +extension StringProtocol { + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/DocTest/REPL.swift b/Sources/DocTest/REPL.swift index df2f876..9782aea 100644 --- a/Sources/DocTest/REPL.swift +++ b/Sources/DocTest/REPL.swift @@ -17,9 +17,9 @@ public class REPL { public var description: String public init?(_ description: String) { - let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedDescription.isEmpty else { return nil } - self.description = trimmedDescription + let description = description.trimmed + guard !description.isEmpty else { return nil } + self.description = description } } @@ -41,7 +41,7 @@ public class REPL { public var evaluationHandler: ((Statement, Result) -> Void)? - public init(configuration: Configuration) { + init(configuration: Configuration) { process = Process() if #available(OSX 10.13, *) { @@ -108,7 +108,7 @@ public class REPL { } } - public func evaluate(_ statement: Statement) { + func evaluate(_ statement: Statement) { if !process.isRunning { if #available(OSX 10.13, *) { try! process.run() @@ -133,12 +133,11 @@ public class REPL { outputPipe.fileHandleForReading.readabilityHandler = nil } - public func waitUntilExit() { + func waitUntilExit() { process.waitUntilExit() } - public func close() { - + func close() { if #available(OSX 10.15, *) { try! self.inputPipe.fileHandleForWriting.close() } diff --git a/Sources/DocTest/Runner.swift b/Sources/DocTest/Runner.swift index aa2df3d..4c70507 100644 --- a/Sources/DocTest/Runner.swift +++ b/Sources/DocTest/Runner.swift @@ -37,14 +37,14 @@ public final class Runner { let repl = REPL(configuration: configuration) - repl.evaluationHandler = { (statement, result) in - tests.append(contentsOf: statement.tests(with: result)) - } - for statement in statements { repl.evaluate(statement) } + repl.evaluationHandler = { (statement, result) in + tests.append(contentsOf: statement.tests(with: result)) + } + repl.close() repl.waitUntilExit() diff --git a/Sources/DocTest/Scanner.swift b/Sources/DocTest/Scanner.swift new file mode 100644 index 0000000..7cf227c --- /dev/null +++ b/Sources/DocTest/Scanner.swift @@ -0,0 +1,48 @@ +import Foundation +import StringLocationConverter + +public class Scanner { + public typealias Match = (line: Int, column: Int, content: String) + + private var regularExpression: NSRegularExpression + + public init() throws { + let pattern = #""" + ^ + \h* \`{3} \h* swift \h+ doctest \h* \n + (.+)\n + \h* \`{3} \h* + $ + """# + self.regularExpression = try NSRegularExpression(pattern: pattern, + options: [ + .allowCommentsAndWhitespace, + .anchorsMatchLines, + .caseInsensitive, + .dotMatchesLineSeparators + ]) + } + + public func matches(in source: String) -> [Match] { + let range = NSRange(source.startIndex..) -> [Test] { + func tests(with result: Result) -> [Test] { var metadata: [String: Any] = [ "file": self.sourceLocation.file as Any?, "line": self.sourceLocation.line as Any?, @@ -31,16 +31,18 @@ public class Statement { } else { return expectations.map { expectation in switch expectation { - case .value(let expected): + case .error: + return test { + .success("- `\(self.code)` produced an error, as expected", directive: nil, metadata: metadata) + } + case .type(let expected), + .value(let expected), + .match(let expected): metadata["expected"] = expected return test { .failure("- `\(self.code)` produced an error", directive: nil, metadata: metadata) } - case .error: - return test { - .success("- `\(self.code)` produced an error, as expected", directive: nil, metadata: metadata) - } } } } @@ -49,10 +51,12 @@ public class Statement { return expectations.map { expectation in switch expectation { - case .value(let expected): + case .type(let expected), + .value(let expected), + .match(let expected): metadata["expected"] = expected - if actual == expected { + if expectation.evaluate(actual) { return test { .success("- `\(self.code)` produces `\(actual)`", directive: nil, metadata: metadata) } diff --git a/Sources/swift-doctest/main.swift b/Sources/swift-doctest/main.swift index f4e51d8..f7b64d7 100644 --- a/Sources/swift-doctest/main.swift +++ b/Sources/swift-doctest/main.swift @@ -66,8 +66,7 @@ struct SwiftDocTest: ParsableCommand { logger.debug("Swift launch path: \(configuration.launchPath)") logger.debug("Swift launch arguments: \(configuration.arguments)") - let pattern = #"^\`{3}\s*swift\s+doctest\s*\n(.+)\n\`{3}$"# - let regex = try NSRegularExpression(pattern: pattern, options: [.caseInsensitive, .anchorsMatchLines, .dotMatchesLineSeparators]) + let scanner = try Scanner() let source: String let assumedFileName: String @@ -82,29 +81,24 @@ struct SwiftDocTest: ParsableCommand { logger.trace("Scanning standard input for DocTest blocks") } - let converter = StringLocationConverter(for: source) - var reports: [Report] = [] let group = DispatchGroup() - regex.enumerateMatches(in: source, options: [], range: NSRange(source.startIndex.. Int"), .type("Int")) + } + + func testValueExpectation() throws { + XCTAssertEqual(Expectation("=> 2"), .value("2")) + } + + func testMatchExpectation() throws { + XCTAssertEqual(Expectation(#"~> Int = \d+"#), .match(#"Int = \d+"#)) + } + + func testErrorExpectation() throws { + XCTAssertEqual(Expectation("!! error: division by zero"), .error) + } + + func testInvalidExpectation() throws { + XCTAssertNil(Expectation("invalid")) + } +} diff --git a/Tests/DocTestTests/DocTestTests.swift b/Tests/DocTestTests/RunnerTests.swift similarity index 50% rename from Tests/DocTestTests/DocTestTests.swift rename to Tests/DocTestTests/RunnerTests.swift index 105931d..90d40a3 100644 --- a/Tests/DocTestTests/DocTestTests.swift +++ b/Tests/DocTestTests/RunnerTests.swift @@ -1,14 +1,14 @@ import XCTest import DocTest -final class DocTestTests: XCTestCase { - func testRunner() throws { +final class RunnerTests: XCTestCase { + func testExample() throws { let source = #""" - 1 + 1 // => Int = 2 - 1 + 1 // => String = "wat" - 1 / 0 // !! Error + 1 + 2 // => 3 + 1 + 2 // -> String + 1 / 0 // !! error: division by zero invalid - 1 + 1 // => Int = 2 + 1 + 2 // ~> Int = \d """# let expectation = XCTestExpectation() @@ -20,11 +20,11 @@ final class DocTestTests: XCTestCase { XCTFail("\(error)") case .success(let report): XCTAssertEqual(report.results.count, 5) - XCTAssertTrue(try! report.results[0].get().ok) // 1 + 1 => 2 - XCTAssertFalse(try! report.results[1].get().ok) // 1 + 1 => "wat" - XCTAssertTrue(try! report.results[2].get().ok) // 1 / 0 !! Error - XCTAssertFalse(try! report.results[3].get().ok) // invalid - XCTAssertTrue(try! report.results[4].get().ok) // 1 + 1 => 2 + XCTAssertTrue(try! report.results[0].get().ok) + XCTAssertFalse(try! report.results[1].get().ok) + XCTAssertTrue(try! report.results[2].get().ok) + XCTAssertFalse(try! report.results[3].get().ok) + XCTAssertTrue(try! report.results[4].get().ok) expectation.fulfill() } diff --git a/Tests/DocTestTests/ScannerTests.swift b/Tests/DocTestTests/ScannerTests.swift new file mode 100644 index 0000000..5c5dd94 --- /dev/null +++ b/Tests/DocTestTests/ScannerTests.swift @@ -0,0 +1,36 @@ +import XCTest +import DocTest + +final class ScannerTests: XCTestCase { + func testExample() throws { + let scanner = try Scanner() + + + let source = #""" + /** + Returns the sum of two integers. + + ```swift doctest + add(1, 3) // => 2 + ``` + */ + func add(_ a: Int, _ b: Int) -> Int { + return a + b + } + + /** + Returns the product of two integers. + */ + func multiply(_ a: Int, _ b: Int) -> Int { + return a * b + } + """# + + let matches = scanner.matches(in: source) + + XCTAssertEqual(matches.count, 1) + XCTAssertEqual(matches.first?.line, 5) + XCTAssertEqual(matches.first?.column, 1) + XCTAssertEqual(matches.first?.content, "add(1, 3) // => 2") + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index 22c49c5..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -import doctestTests - -var tests = [XCTestCaseEntry]() -tests += doctestTests.allTests() -XCTMain(tests)