Skip to content

Commit 77b0960

Browse files
committed
fixing document harness
1 parent 6eb3e61 commit 77b0960

6 files changed

Lines changed: 176 additions & 119 deletions

File tree

Package.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,9 @@ let package = Package(
113113
.target(
114114
name: "DocumentationHarness",
115115
dependencies: [
116-
.product(name: "SwiftSyntax", package: "swift-syntax"),
117-
.product(name: "SwiftOperators", package: "swift-syntax"),
118-
.product(name: "SwiftParser", package: "swift-syntax")
116+
.product(name: "SwiftSyntax", package: "swift-syntax"),
117+
.product(name: "SwiftOperators", package: "swift-syntax"),
118+
.product(name: "SwiftParser", package: "swift-syntax")
119119
],
120120
swiftSettings: swiftSettings
121121
),

Sources/DocumentationHarness/DocumentationTestHarness.swift

Lines changed: 8 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,9 @@ import SwiftSyntax
3333
import Testing
3434

3535
/// Test harness for extracting and validating Swift code examples from documentation
36-
package struct DocumentationTestHarness {
37-
/// Default file extensions for documentation files
38-
internal static let defaultPathExtensions = ["md"]
36+
package struct DocumentationTestHarness: DocumentationValidator {
3937
/// Swift code validator instance
4038
private let codeValidator: any SyntaxValidator
41-
private let fileSearcher: any FileSearcher
4239
private let codeBlocksFrom: CodeBlockExtractor
4340

4441
/// Creates a new documentation test harness
@@ -48,58 +45,26 @@ package struct DocumentationTestHarness {
4845
/// - codeBlocksFrom: Function to extract code blocks from content
4946
package init(
5047
codeValidator: any SyntaxValidator = CodeSyntaxValidator(),
51-
fileSearcher: any FileSearcher = FileManager.default,
5248
codeBlocksFrom: @escaping CodeBlockExtractor = CodeBlockExtraction.callAsFunction(_:)
5349
) {
5450
self.codeValidator = codeValidator
55-
self.fileSearcher = fileSearcher
5651
self.codeBlocksFrom = codeBlocksFrom
5752
}
5853

59-
/// Validates all Swift code examples found in documentation files
60-
/// - Parameters:
61-
/// - relativePaths: Array of relative paths to search for documentation
62-
/// - projectRoot: Root URL of the project
63-
/// - pathExtensions: File extensions to search for (defaults to ["md"])
64-
/// - Returns: Array of validation results for all code blocks found
65-
/// - Throws: FileSearchError if file operations fail
66-
package func validate(
67-
relativePaths: [String], atProjectRoot projectRoot: URL,
68-
withPathExtensions pathExtensions: [String] = Self.defaultPathExtensions
69-
) throws -> [ValidationResult] {
70-
let documentationFiles = try relativePaths.flatMap { docPath in
71-
let absolutePath = projectRoot.appendingPathComponent(docPath)
72-
return try self.fileSearcher.findDocumentationFiles(
73-
in: absolutePath,
74-
pathExtensions: pathExtensions
75-
)
76-
}
77-
var allResults: [ValidationResult] = []
78-
79-
for filePath in documentationFiles {
80-
let results = try validateExamplesInFile(filePath)
81-
allResults.append(contentsOf: results)
82-
}
83-
84-
return allResults
85-
}
86-
8754
/// Validates all Swift code examples in a specific documentation file
8855
/// - Parameter fileURL: URL of the file to validate
8956
/// - Returns: Array of validation results for code blocks in the file
9057
/// - Throws: Error if file cannot be read or parsed
91-
package func validateExamplesInFile(_ fileURL: URL) throws -> [ValidationResult] {
58+
package func validateFile(at fileURL: URL) throws -> [ValidationResult] {
9259
// let fullPath = try resolveFilePath(filePath)
9360
let content = try String(contentsOf: fileURL)
9461

9562
let codeBlocks = try codeBlocksFrom(content)
9663
var results: [ValidationResult] = []
9764

9865
for (index, codeBlock) in codeBlocks.enumerated() {
99-
try results.append(
100-
#require(
101-
validateCodeBlock(fileURL.codeBlock(codeBlock, at: index))
102-
)
66+
results.append(
67+
validateCodeBlock(fileURL.codeBlock(codeBlock, at: index))
10368
)
10469
}
10570

@@ -109,81 +74,15 @@ package struct DocumentationTestHarness {
10974
/// Validates a single code block
11075
private func validateCodeBlock(
11176
_ parameters: CodeBlockValidationParameters
112-
) -> ValidationResult? {
113-
switch parameters.codeBlock.blockType {
114-
case .example:
115-
// Test compilation and basic execution
116-
return codeValidator.validateSyntax(from: parameters)
117-
118-
case .packageManifest:
119-
#if canImport(Foundation) && (os(macOS) || os(Linux))
120-
// Package.swift files need special handling
121-
var processError: ProcessError?
122-
do {
123-
try validatePackageManifest(parameters.code)
124-
processError = nil
125-
} catch {
126-
processError = error
127-
}
128-
return ValidationResult(
129-
parameters: parameters,
130-
testType: .parsing,
131-
error: processError.map { ValidationError.processError($0) }
132-
)
133-
#else
134-
return ValidationResult(
135-
parameters: parameters,
136-
testType: .skipped,
137-
error: nil
138-
)
139-
#endif
140-
case .shellCommand:
141-
// Skip shell commands for now
77+
) -> ValidationResult {
78+
guard case .example = parameters.codeBlock.blockType else {
14279
return ValidationResult(
14380
parameters: parameters,
14481
testType: .skipped,
14582
error: nil
14683
)
14784
}
85+
// Test compilation and basic execution
86+
return codeValidator.validateSyntax(from: parameters)
14887
}
149-
150-
#if canImport(Foundation) && (os(macOS) || os(Linux))
151-
/// Validates a Package.swift manifest
152-
private func validatePackageManifest(
153-
_ code: String
154-
) throws(ProcessError) {
155-
let process = Process()
156-
do {
157-
// Create temporary Package.swift and validate it parses
158-
let tempDir = FileManager.default.temporaryDirectory
159-
.appendingPathComponent("SyntaxKit-DocTest-\(UUID())")
160-
161-
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
162-
defer { try? FileManager.default.removeItem(at: tempDir) }
163-
164-
let packageFile = tempDir.appendingPathComponent("Package.swift")
165-
try code.write(to: packageFile, atomically: true, encoding: .utf8)
166-
167-
// Use swift package tools to validate
168-
process.currentDirectoryURL = tempDir
169-
process.executableURL = URL(fileURLWithPath: "/usr/bin/swift")
170-
process.arguments = ["package", "describe", "--type", "json"]
171-
172-
let pipe = Pipe()
173-
process.standardOutput = pipe
174-
process.standardError = pipe
175-
176-
try process.run()
177-
process.waitUntilExit()
178-
} catch {
179-
throw .setupError(error)
180-
}
181-
182-
guard process.terminationStatus == 0 else {
183-
return
184-
}
185-
186-
throw .packageValidationFailed
187-
}
188-
#endif
18988
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//
2+
// DocumentationValidator.swift
3+
// SyntaxKit
4+
//
5+
// Created by Leo Dion.
6+
// Copyright © 2025 BrightDigit.
7+
//
8+
// Permission is hereby granted, free of charge, to any person
9+
// obtaining a copy of this software and associated documentation
10+
// files (the “Software”), to deal in the Software without
11+
// restriction, including without limitation the rights to use,
12+
// copy, modify, merge, publish, distribute, sublicense, and/or
13+
// sell copies of the Software, and to permit persons to whom the
14+
// Software is furnished to do so, subject to the following
15+
// conditions:
16+
//
17+
// The above copyright notice and this permission notice shall be
18+
// included in all copies or substantial portions of the Software.
19+
//
20+
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22+
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24+
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25+
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27+
// OTHER DEALINGS IN THE SOFTWARE.
28+
//
29+
30+
package import Foundation
31+
32+
package protocol DocumentationValidator {
33+
func validateFile(at fileURL: URL) throws -> [ValidationResult]
34+
}
35+
36+
private let privateDefaultPathExtensions = ["md"]
37+
extension DocumentationValidator {
38+
/// Default file extensions for documentation files
39+
package static var defaultPathExtensions: [String] {
40+
privateDefaultPathExtensions
41+
}
42+
43+
/// Validates all Swift code examples found in documentation files
44+
/// - Parameters:
45+
/// - relativePaths: Array of relative paths to search for documentation
46+
/// - projectRoot: Root URL of the project
47+
/// - pathExtensions: File extensions to search for (defaults to ["md"])
48+
/// - Returns: Array of validation results for all code blocks found
49+
/// - Throws: FileSearchError if file operations fail
50+
package func validate(
51+
relativePaths: [String],
52+
atProjectRoot projectRoot: URL,
53+
withPathExtensions pathExtensions: [String] = Self.defaultPathExtensions,
54+
using fileSearcher: any FileSearcher = FileManager.default
55+
) throws -> [ValidationResult] {
56+
let documentationFiles = try relativePaths.flatMap { docPath in
57+
let absolutePath = projectRoot.appendingPathComponent(docPath)
58+
return try fileSearcher.findDocumentationFiles(
59+
in: absolutePath,
60+
pathExtensions: pathExtensions
61+
)
62+
}
63+
var allResults: [ValidationResult] = []
64+
65+
for filePath in documentationFiles {
66+
let results = try validateFile(at: filePath)
67+
allResults.append(contentsOf: results)
68+
}
69+
70+
return allResults
71+
}
72+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//
2+
// PackageValidator.swift
3+
// SyntaxKit
4+
//
5+
// Created by Leo Dion.
6+
// Copyright © 2025 BrightDigit.
7+
//
8+
// Permission is hereby granted, free of charge, to any person
9+
// obtaining a copy of this software and associated documentation
10+
// files (the “Software”), to deal in the Software without
11+
// restriction, including without limitation the rights to use,
12+
// copy, modify, merge, publish, distribute, sublicense, and/or
13+
// sell copies of the Software, and to permit persons to whom the
14+
// Software is furnished to do so, subject to the following
15+
// conditions:
16+
//
17+
// The above copyright notice and this permission notice shall be
18+
// included in all copies or substantial portions of the Software.
19+
//
20+
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22+
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24+
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25+
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27+
// OTHER DEALINGS IN THE SOFTWARE.
28+
//
29+
30+
import Foundation
31+
32+
#if canImport(Foundation) && (os(macOS) || os(Linux))
33+
@available(*, unavailable)
34+
private enum PackageValidator {
35+
/// Validates a Package.swift manifest
36+
private func validatePackageManifest(
37+
_ code: String
38+
) throws(ProcessError) {
39+
let process = Process()
40+
do {
41+
// Create temporary Package.swift and validate it parses
42+
let tempDir = FileManager.default.temporaryDirectory
43+
.appendingPathComponent("SyntaxKit-DocTest-\(UUID())")
44+
45+
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
46+
defer { try? FileManager.default.removeItem(at: tempDir) }
47+
48+
let packageFile = tempDir.appendingPathComponent("Package.swift")
49+
try code.write(to: packageFile, atomically: true, encoding: .utf8)
50+
51+
// Use swift package tools to validate
52+
process.currentDirectoryURL = tempDir
53+
process.executableURL = URL(fileURLWithPath: "/usr/bin/swift")
54+
process.arguments = ["package", "describe", "--type", "json"]
55+
56+
let pipe = Pipe()
57+
process.standardOutput = pipe
58+
process.standardError = pipe
59+
60+
try process.run()
61+
process.waitUntilExit()
62+
} catch {
63+
throw .setupError(error)
64+
}
65+
66+
guard process.terminationStatus == 0 else {
67+
return
68+
}
69+
70+
throw .packageValidationFailed
71+
}
72+
}
73+
74+
#endif

Tests/SyntaxDocTests/DocumentationExampleTests.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,22 @@ internal struct DocumentationExampleTests {
1010
internal func validateAllDocumentationExamples() throws {
1111
let testHarness = DocumentationTestHarness()
1212
let results = try testHarness.validate(
13-
relativePaths: Settings.docPaths, atProjectRoot: Settings.projectRoot)
13+
relativePaths: Settings.docPaths,
14+
atProjectRoot: Settings.projectRoot
15+
)
1416

1517
// Report any failures
1618
let failures = results.filter { !$0.isSuccess && !$0.isSkipped }
1719
if !failures.isEmpty {
1820
let failureReport = failures.map { result in
19-
// swiftlint:disable:next line_length
20-
"\(result.fileURL.path()):\(result.lineNumber) - \(result.error?.localizedDescription ?? "Unknown error")"
21+
let path: String
22+
if #available(iOS 16.0, watchOS 9.0, tvOS 16.0, macCatalyst 16.0, *) {
23+
path = result.fileURL.path()
24+
} else {
25+
path = result.fileURL.path
26+
}
27+
return
28+
"\(path):\(result.lineNumber) - \(result.error?.localizedDescription ?? "Unknown error")"
2129
}
2230
.joined(separator: "\n")
2331

@@ -36,7 +44,7 @@ internal struct DocumentationExampleTests {
3644
let quickStartFile = try Settings.resolveFilePath(
3745
"Sources/SyntaxKit/Documentation.docc/Tutorials/Quick-Start-Guide.md"
3846
)
39-
let results = try testHarness.validateExamplesInFile(quickStartFile)
47+
let results = try testHarness.validateFile(at: quickStartFile)
4048

4149
// Specific validation for Quick Start examples
4250
#expect(!results.isEmpty, "Quick Start Guide should contain code examples")
@@ -52,7 +60,7 @@ internal struct DocumentationExampleTests {
5260
let macroTutorialFile = try Settings.resolveFilePath(
5361
"Sources/SyntaxKit/Documentation.docc/Tutorials/Creating-Macros-with-SyntaxKit.md"
5462
)
55-
let results = try testHarness.validateExamplesInFile(macroTutorialFile)
63+
let results = try testHarness.validateFile(at: macroTutorialFile)
5664

5765
// Macro examples should compile (though they may not execute without full macro setup)
5866
let compileResults = results.filter { $0.testType == .parsing }
@@ -67,7 +75,7 @@ internal struct DocumentationExampleTests {
6775
let enumExampleFile = try Settings.resolveFilePath(
6876
"Sources/SyntaxKit/Documentation.docc/Examples/EnumGenerator.md"
6977
)
70-
let results = try testHarness.validateExamplesInFile(enumExampleFile)
78+
let results = try testHarness.validateFile(at: enumExampleFile)
7179

7280
// Check that enum generation examples actually work
7381
let executionResults = results.filter { $0.testType == .execution }

Tests/SyntaxDocTests/Settings.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ internal enum Settings {
2828
/// Resolves a relative file path to absolute path
2929
internal static func resolveFilePath(_ filePath: String) throws -> URL {
3030
if filePath.hasPrefix("/") {
31-
return .init(filePath: filePath)
31+
if #available(iOS 16.0, watchOS 9.0, tvOS 16.0, macCatalyst 16.0, *) {
32+
return .init(filePath: filePath)
33+
} else {
34+
return .init(fileURLWithPath: filePath)
35+
}
3236
} else {
3337
return Self.projectRoot.appendingPathComponent(filePath)
3438
}

0 commit comments

Comments
 (0)