Skip to content

Add --enable-experimental-feature to enable those features in the parser. #876

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I didn't see hit PR go by, using String here is not great because the user has no idea what options are valid. Instead using a custom type and conforming it to ExpressibleByArgument and CaseIterable will allow ArgumentParser to provide a list of valid options in the help cli and error output.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in the PR description, we would need Parser.ExperimentalFeatures to become non-SPI for that to be feasible, first. Otherwise, we can't pass the values down through the various API layers because they're public methods and thus would also need to be hidden behind their own SPI.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah thats unfortunate, but good to know!

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
Loading