Skip to content

Commit

Permalink
Add --enable-experimental-feature to enable those features in the p…
Browse files Browse the repository at this point in the history
…arser.

Also add a couple small tests for value generics to exercise the capability in
tests.

Fixes #875.
  • Loading branch information
allevato authored and macshome committed Dec 2, 2024
1 parent d879580 commit da2f133
Show file tree
Hide file tree
Showing 11 changed files with 176 additions and 31 deletions.
22 changes: 21 additions & 1 deletion Sources/SwiftFormat/API/SwiftFormatError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
//
//===----------------------------------------------------------------------===//

import Foundation
import SwiftSyntax

/// Errors that can be thrown by the `SwiftFormatter` and `SwiftLinter` APIs.
public enum SwiftFormatError: Error {
public enum SwiftFormatError: LocalizedError {

/// The requested file was not readable or it did not exist.
case fileNotReadable
Expand All @@ -23,4 +24,23 @@ public enum SwiftFormatError: Error {

/// The file contains invalid or unrecognized Swift syntax and cannot be handled safely.
case fileContainsInvalidSyntax

/// The requested experimental feature name was not recognized by the parser.
case unrecognizedExperimentalFeature(String)

public var errorDescription: String? {
switch self {
case .fileNotReadable:
return "file is not readable or does not exist"
case .isDirectory:
return "requested path is a directory, not a file"
case .fileContainsInvalidSyntax:
return "file contains invalid Swift syntax"
case .unrecognizedExperimentalFeature(let name):
return "experimental feature '\(name)' was not recognized by the Swift parser"
}
}
}

extension SwiftFormatError: Equatable {}
extension SwiftFormatError: Hashable {}
6 changes: 6 additions & 0 deletions Sources/SwiftFormat/API/SwiftFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ public final class SwiftFormatter {
/// which is associated with any diagnostics emitted during formatting. If this is nil, a
/// dummy value will be used.
/// - selection: The ranges to format
/// - experimentalFeatures: The set of experimental features that should be enabled in the
/// parser. These names must be from the set of parser-recognized experimental language
/// features in `SwiftParser`'s `Parser.ExperimentalFeatures` enum, which match the spelling
/// defined in the compiler's `Features.def` file.
/// - outputStream: A value conforming to `TextOutputStream` to which the formatted output will
/// be written.
/// - parsingDiagnosticHandler: An optional callback that will be notified if there are any
Expand All @@ -98,6 +102,7 @@ public final class SwiftFormatter {
source: String,
assumingFileURL url: URL?,
selection: Selection,
experimentalFeatures: Set<String> = [],
to outputStream: inout Output,
parsingDiagnosticHandler: ((Diagnostic, SourceLocation) -> Void)? = nil
) throws {
Expand All @@ -110,6 +115,7 @@ public final class SwiftFormatter {
source: source,
operatorTable: .standardOperators,
assumingFileURL: url,
experimentalFeatures: experimentalFeatures,
parsingDiagnosticHandler: parsingDiagnosticHandler
)
try format(
Expand Down
6 changes: 6 additions & 0 deletions Sources/SwiftFormat/API/SwiftLinter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,17 @@ public final class SwiftLinter {
/// - Parameters:
/// - source: The Swift source code to be linted.
/// - url: A file URL denoting the filename/path that should be assumed for this source code.
/// - experimentalFeatures: The set of experimental features that should be enabled in the
/// parser. These names must be from the set of parser-recognized experimental language
/// features in `SwiftParser`'s `Parser.ExperimentalFeatures` enum, which match the spelling
/// defined in the compiler's `Features.def` file.
/// - parsingDiagnosticHandler: An optional callback that will be notified if there are any
/// errors when parsing the source code.
/// - Throws: If an unrecoverable error occurs when formatting the code.
public func lint(
source: String,
assumingFileURL url: URL,
experimentalFeatures: Set<String> = [],
parsingDiagnosticHandler: ((Diagnostic, SourceLocation) -> Void)? = nil
) throws {
// If the file or input string is completely empty, do nothing. This prevents even a trailing
Expand All @@ -98,6 +103,7 @@ public final class SwiftLinter {
source: source,
operatorTable: .standardOperators,
assumingFileURL: url,
experimentalFeatures: experimentalFeatures,
parsingDiagnosticHandler: parsingDiagnosticHandler
)
try lint(
Expand Down
24 changes: 19 additions & 5 deletions Sources/SwiftFormat/Core/Parsing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import Foundation
import SwiftDiagnostics
import SwiftOperators
import SwiftParser
@_spi(ExperimentalLanguageFeatures) import SwiftParser
import SwiftParserDiagnostics
import SwiftSyntax

Expand All @@ -25,22 +25,36 @@ import SwiftSyntax
///
/// - Parameters:
/// - source: The Swift source code to be formatted.
/// - operatorTable: The operator table to use for sequence folding.
/// - url: A file URL denoting the filename/path that should be assumed for this syntax tree,
/// which is associated with any diagnostics emitted during formatting. If this is nil, a
/// dummy value will be used.
/// - operatorTable: The operator table to use for sequence folding.
/// - experimentalFeatures: The set of experimental features that should be enabled in the parser.
/// These names must be from the set of parser-recognized experimental language features in
/// `SwiftParser`'s `Parser.ExperimentalFeatures` enum, which match the spelling defined in the
/// compiler's `Features.def` file.
/// - parsingDiagnosticHandler: An optional callback that will be notified if there are any
/// errors when parsing the source code.
/// - Throws: If an unrecoverable error occurs when formatting the code.
func parseAndEmitDiagnostics(
source: String,
operatorTable: OperatorTable,
assumingFileURL url: URL?,
experimentalFeatures: Set<String>,
parsingDiagnosticHandler: ((Diagnostic, SourceLocation) -> Void)? = nil
) throws -> SourceFileSyntax {
let sourceFile =
operatorTable.foldAll(Parser.parse(source: source)) { _ in }.as(SourceFileSyntax.self)!

var experimentalFeaturesSet: Parser.ExperimentalFeatures = []
for featureName in experimentalFeatures {
guard let featureValue = Parser.ExperimentalFeatures(name: featureName) else {
throw SwiftFormatError.unrecognizedExperimentalFeature(featureName)
}
experimentalFeaturesSet.formUnion(featureValue)
}
var source = source
let sourceFile = source.withUTF8 { sourceBytes in
operatorTable.foldAll(Parser.parse(source: sourceBytes, experimentalFeatures: experimentalFeaturesSet)) { _ in }
.as(SourceFileSyntax.self)!
}
let diagnostics = ParseDiagnosticsGenerator.diagnostics(for: sourceFile)
var hasErrors = false
if let parsingDiagnosticHandler = parsingDiagnosticHandler {
Expand Down
37 changes: 37 additions & 0 deletions Sources/_SwiftFormatTestSupport/Parsing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

@_spi(ExperimentalLanguageFeatures) import SwiftParser
import SwiftSyntax
import XCTest

extension Parser {
/// Parses the given source string and returns the corresponding `SourceFileSyntax` node.
///
/// - Parameters:
/// - source: The source text to parse.
/// - experimentalFeatures: The set of experimental features that should be enabled in the
/// parser.
@_spi(Testing)
public static func parse(
source: String,
experimentalFeatures: Parser.ExperimentalFeatures
) -> SourceFileSyntax {
var source = source
return source.withUTF8 { sourceBytes in
parse(
source: sourceBytes,
experimentalFeatures: experimentalFeatures
)
}
}
}
13 changes: 5 additions & 8 deletions Sources/swift-format/Frontend/FormatFrontend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class FormatFrontend: Frontend {
source: source,
assumingFileURL: url,
selection: fileToProcess.selection,
experimentalFeatures: Set(lintFormatOptions.experimentalFeatures),
to: &buffer,
parsingDiagnosticHandler: diagnosticHandler
)
Expand All @@ -69,15 +70,11 @@ class FormatFrontend: Frontend {
source: source,
assumingFileURL: url,
selection: fileToProcess.selection,
experimentalFeatures: Set(lintFormatOptions.experimentalFeatures),
to: &stdoutStream,
parsingDiagnosticHandler: diagnosticHandler
)
}
} catch SwiftFormatError.fileNotReadable {
diagnosticsEngine.emitError(
"Unable to format \(url.relativePath): file is not readable or does not exist."
)
return
} catch SwiftFormatError.fileContainsInvalidSyntax {
guard !lintFormatOptions.ignoreUnparsableFiles else {
guard !inPlace else {
Expand All @@ -87,10 +84,10 @@ class FormatFrontend: Frontend {
stdoutStream.write(source)
return
}
// Otherwise, relevant diagnostics about the problematic nodes have been emitted.
return
// Otherwise, relevant diagnostics about the problematic nodes have already been emitted; we
// don't need to print anything else.
} catch {
diagnosticsEngine.emitError("Unable to format \(url.relativePath): \(error)")
diagnosticsEngine.emitError("Unable to format \(url.relativePath): \(error.localizedDescription).")
}
}
}
16 changes: 5 additions & 11 deletions Sources/swift-format/Frontend/LintFrontend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,30 +35,24 @@ class LintFrontend: Frontend {
do {
try linter.lint(
source: source,
assumingFileURL: url
assumingFileURL: url,
experimentalFeatures: Set(lintFormatOptions.experimentalFeatures)
) { (diagnostic, location) in
guard !self.lintFormatOptions.ignoreUnparsableFiles else {
// No diagnostics should be emitted in this mode.
return
}
self.diagnosticsEngine.consumeParserDiagnostic(diagnostic, location)
}

} catch SwiftFormatError.fileNotReadable {
diagnosticsEngine.emitError(
"Unable to lint \(url.relativePath): file is not readable or does not exist."
)
return
} catch SwiftFormatError.fileContainsInvalidSyntax {
guard !lintFormatOptions.ignoreUnparsableFiles else {
// The caller wants to silently ignore this error.
return
}
// Otherwise, relevant diagnostics about the problematic nodes have been emitted.
return
// Otherwise, relevant diagnostics about the problematic nodes have already been emitted; we
// don't need to print anything else.
} catch {
diagnosticsEngine.emitError("Unable to lint \(url.relativePath): \(error)")
return
diagnosticsEngine.emitError("Unable to lint \(url.relativePath): \(error.localizedDescription).")
}
}
}
9 changes: 9 additions & 0 deletions Sources/swift-format/Subcommands/LintFormatOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ struct LintFormatOptions: ParsableArguments {
)
var followSymlinks: Bool = false

@Option(
name: .customLong("enable-experimental-feature"),
help: """
The name of an experimental swift-syntax parser feature that should be enabled by \
swift-format. Multiple features can be enabled by specifying this flag multiple times.
"""
)
var experimentalFeatures: [String] = []

/// The list of paths to Swift source files that should be formatted or linted.
@Argument(help: "Zero or more input filenames.")
var paths: [String] = []
Expand Down
14 changes: 12 additions & 2 deletions Tests/SwiftFormatTests/PrettyPrint/PrettyPrintTestCase.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import SwiftFormat
@_spi(Rules) @_spi(Testing) import SwiftFormat
import SwiftOperators
import SwiftParser
@_spi(ExperimentalLanguageFeatures) import SwiftParser
import SwiftSyntax
import XCTest
@_spi(Testing) import _SwiftFormatTestSupport
Expand All @@ -18,6 +18,8 @@ class PrettyPrintTestCase: DiagnosingTestCase {
/// changes that insert or remove non-whitespace characters (like trailing commas).
/// - findings: A list of `FindingSpec` values that describe the findings that are expected to
/// be emitted. These are currently only checked if `whitespaceOnly` is true.
/// - experimentalFeatures: The set of experimental features that should be enabled in the
/// parser.
/// - file: The file in which failure occurred. Defaults to the file name of the test case in
/// which this function was called.
/// - line: The line number on which failure occurred. Defaults to the line number on which this
Expand All @@ -29,6 +31,7 @@ class PrettyPrintTestCase: DiagnosingTestCase {
configuration: Configuration = Configuration.forTesting,
whitespaceOnly: Bool = false,
findings: [FindingSpec] = [],
experimentalFeatures: Parser.ExperimentalFeatures = [],
file: StaticString = #file,
line: UInt = #line
) {
Expand All @@ -44,6 +47,7 @@ class PrettyPrintTestCase: DiagnosingTestCase {
configuration: configuration,
selection: markedInput.selection,
whitespaceOnly: whitespaceOnly,
experimentalFeatures: experimentalFeatures,
findingConsumer: { emittedFindings.append($0) }
)
assertStringsEqualWithDiff(
Expand Down Expand Up @@ -76,6 +80,7 @@ class PrettyPrintTestCase: DiagnosingTestCase {
configuration: configuration,
selection: markedInput.selection,
whitespaceOnly: whitespaceOnly,
experimentalFeatures: experimentalFeatures,
findingConsumer: { _ in } // Ignore findings during the idempotence check.
)
assertStringsEqualWithDiff(
Expand All @@ -95,18 +100,23 @@ class PrettyPrintTestCase: DiagnosingTestCase {
/// - configuration: The formatter configuration.
/// - whitespaceOnly: If true, the pretty printer should only apply whitespace changes and omit
/// changes that insert or remove non-whitespace characters (like trailing commas).
/// - experimentalFeatures: The set of experimental features that should be enabled in the
/// parser.
/// - findingConsumer: A function called for each finding that is emitted by the pretty printer.
/// - Returns: The pretty-printed text, or nil if an error occurred and a test failure was logged.
private func prettyPrintedSource(
_ source: String,
configuration: Configuration,
selection: Selection,
whitespaceOnly: Bool,
experimentalFeatures: Parser.ExperimentalFeatures = [],
findingConsumer: @escaping (Finding) -> Void
) -> (String, Context) {
// Ignore folding errors for unrecognized operators so that we fallback to a reasonable default.
let sourceFileSyntax =
OperatorTable.standardOperators.foldAll(Parser.parse(source: source)) { _ in }
OperatorTable.standardOperators.foldAll(
Parser.parse(source: source, experimentalFeatures: experimentalFeatures)
) { _ in }
.as(SourceFileSyntax.self)!
let context = makeContext(
sourceFileSyntax: sourceFileSyntax,
Expand Down
46 changes: 46 additions & 0 deletions Tests/SwiftFormatTests/PrettyPrint/ValueGenericsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
@_spi(ExperimentalLanguageFeatures) import SwiftParser

final class ValueGenericsTests: PrettyPrintTestCase {
func testValueGenericDeclaration() {
let input = "struct Foo<let n: Int> { static let bar = n }"
let expected = """
struct Foo<
let n: Int
> {
static let bar = n
}
"""
assertPrettyPrintEqual(
input: input,
expected: expected,
linelength: 20,
experimentalFeatures: [.valueGenerics]
)
}

func testValueGenericTypeUsage() {
let input =
"""
let v1: Vector<100, Int>
let v2 = Vector<100, Int>()
"""
let expected = """
let v1:
Vector<
100, Int
>
let v2 =
Vector<
100, Int
>()
"""
assertPrettyPrintEqual(
input: input,
expected: expected,
linelength: 15,
experimentalFeatures: [.valueGenerics]
)
}
}
Loading

0 comments on commit da2f133

Please sign in to comment.