From da2f133fe0396804a54e7afda373172f2823330a Mon Sep 17 00:00:00 2001 From: Tony Allevato Date: Fri, 8 Nov 2024 09:47:49 -0500 Subject: [PATCH] Add `--enable-experimental-feature` to enable those features in the parser. Also add a couple small tests for value generics to exercise the capability in tests. Fixes #875. --- .../SwiftFormat/API/SwiftFormatError.swift | 22 ++++++++- Sources/SwiftFormat/API/SwiftFormatter.swift | 6 +++ Sources/SwiftFormat/API/SwiftLinter.swift | 6 +++ Sources/SwiftFormat/Core/Parsing.swift | 24 ++++++++-- Sources/_SwiftFormatTestSupport/Parsing.swift | 37 +++++++++++++++ .../Frontend/FormatFrontend.swift | 13 ++---- .../swift-format/Frontend/LintFrontend.swift | 16 ++----- .../Subcommands/LintFormatOptions.swift | 9 ++++ .../PrettyPrint/PrettyPrintTestCase.swift | 14 +++++- .../PrettyPrint/ValueGenericsTests.swift | 46 +++++++++++++++++++ .../Rules/LintOrFormatRuleTestCase.swift | 14 ++++-- 11 files changed, 176 insertions(+), 31 deletions(-) create mode 100644 Sources/_SwiftFormatTestSupport/Parsing.swift create mode 100644 Tests/SwiftFormatTests/PrettyPrint/ValueGenericsTests.swift diff --git a/Sources/SwiftFormat/API/SwiftFormatError.swift b/Sources/SwiftFormat/API/SwiftFormatError.swift index 2e3a890cc..6e4183162 100644 --- a/Sources/SwiftFormat/API/SwiftFormatError.swift +++ b/Sources/SwiftFormat/API/SwiftFormatError.swift @@ -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 @@ -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 {} diff --git a/Sources/SwiftFormat/API/SwiftFormatter.swift b/Sources/SwiftFormat/API/SwiftFormatter.swift index 0daeb22c6..ddd0bbe2c 100644 --- a/Sources/SwiftFormat/API/SwiftFormatter.swift +++ b/Sources/SwiftFormat/API/SwiftFormatter.swift @@ -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 @@ -98,6 +102,7 @@ public final class SwiftFormatter { source: String, assumingFileURL url: URL?, selection: Selection, + experimentalFeatures: Set = [], to outputStream: inout Output, parsingDiagnosticHandler: ((Diagnostic, SourceLocation) -> Void)? = nil ) throws { @@ -110,6 +115,7 @@ public final class SwiftFormatter { source: source, operatorTable: .standardOperators, assumingFileURL: url, + experimentalFeatures: experimentalFeatures, parsingDiagnosticHandler: parsingDiagnosticHandler ) try format( diff --git a/Sources/SwiftFormat/API/SwiftLinter.swift b/Sources/SwiftFormat/API/SwiftLinter.swift index 25cfa2af1..cfc4639f6 100644 --- a/Sources/SwiftFormat/API/SwiftLinter.swift +++ b/Sources/SwiftFormat/API/SwiftLinter.swift @@ -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 = [], parsingDiagnosticHandler: ((Diagnostic, SourceLocation) -> Void)? = nil ) throws { // If the file or input string is completely empty, do nothing. This prevents even a trailing @@ -98,6 +103,7 @@ public final class SwiftLinter { source: source, operatorTable: .standardOperators, assumingFileURL: url, + experimentalFeatures: experimentalFeatures, parsingDiagnosticHandler: parsingDiagnosticHandler ) try lint( diff --git a/Sources/SwiftFormat/Core/Parsing.swift b/Sources/SwiftFormat/Core/Parsing.swift index da57a017f..09b20ef0a 100644 --- a/Sources/SwiftFormat/Core/Parsing.swift +++ b/Sources/SwiftFormat/Core/Parsing.swift @@ -13,7 +13,7 @@ import Foundation import SwiftDiagnostics import SwiftOperators -import SwiftParser +@_spi(ExperimentalLanguageFeatures) import SwiftParser import SwiftParserDiagnostics import SwiftSyntax @@ -25,10 +25,14 @@ 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. @@ -36,11 +40,21 @@ func parseAndEmitDiagnostics( source: String, operatorTable: OperatorTable, assumingFileURL url: URL?, + experimentalFeatures: Set, 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 { diff --git a/Sources/_SwiftFormatTestSupport/Parsing.swift b/Sources/_SwiftFormatTestSupport/Parsing.swift new file mode 100644 index 000000000..71f1a64d4 --- /dev/null +++ b/Sources/_SwiftFormatTestSupport/Parsing.swift @@ -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 + ) + } + } +} diff --git a/Sources/swift-format/Frontend/FormatFrontend.swift b/Sources/swift-format/Frontend/FormatFrontend.swift index 850086d61..23d127719 100644 --- a/Sources/swift-format/Frontend/FormatFrontend.swift +++ b/Sources/swift-format/Frontend/FormatFrontend.swift @@ -56,6 +56,7 @@ class FormatFrontend: Frontend { source: source, assumingFileURL: url, selection: fileToProcess.selection, + experimentalFeatures: Set(lintFormatOptions.experimentalFeatures), to: &buffer, parsingDiagnosticHandler: diagnosticHandler ) @@ -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 { @@ -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).") } } } diff --git a/Sources/swift-format/Frontend/LintFrontend.swift b/Sources/swift-format/Frontend/LintFrontend.swift index f7c4ee52c..c231266a7 100644 --- a/Sources/swift-format/Frontend/LintFrontend.swift +++ b/Sources/swift-format/Frontend/LintFrontend.swift @@ -35,7 +35,8 @@ 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. @@ -43,22 +44,15 @@ class LintFrontend: Frontend { } 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).") } } } diff --git a/Sources/swift-format/Subcommands/LintFormatOptions.swift b/Sources/swift-format/Subcommands/LintFormatOptions.swift index bd15dc4e7..520eaf970 100644 --- a/Sources/swift-format/Subcommands/LintFormatOptions.swift +++ b/Sources/swift-format/Subcommands/LintFormatOptions.swift @@ -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] = [] diff --git a/Tests/SwiftFormatTests/PrettyPrint/PrettyPrintTestCase.swift b/Tests/SwiftFormatTests/PrettyPrint/PrettyPrintTestCase.swift index abe0400b7..375e5fd7e 100644 --- a/Tests/SwiftFormatTests/PrettyPrint/PrettyPrintTestCase.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/PrettyPrintTestCase.swift @@ -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 @@ -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 @@ -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 ) { @@ -44,6 +47,7 @@ class PrettyPrintTestCase: DiagnosingTestCase { configuration: configuration, selection: markedInput.selection, whitespaceOnly: whitespaceOnly, + experimentalFeatures: experimentalFeatures, findingConsumer: { emittedFindings.append($0) } ) assertStringsEqualWithDiff( @@ -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( @@ -95,6 +100,8 @@ 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( @@ -102,11 +109,14 @@ class PrettyPrintTestCase: DiagnosingTestCase { 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, diff --git a/Tests/SwiftFormatTests/PrettyPrint/ValueGenericsTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ValueGenericsTests.swift new file mode 100644 index 000000000..54dec83ab --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/ValueGenericsTests.swift @@ -0,0 +1,46 @@ +@_spi(ExperimentalLanguageFeatures) import SwiftParser + +final class ValueGenericsTests: PrettyPrintTestCase { + func testValueGenericDeclaration() { + let input = "struct Foo { 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] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/LintOrFormatRuleTestCase.swift b/Tests/SwiftFormatTests/Rules/LintOrFormatRuleTestCase.swift index 9ae0050e0..dc69fbef6 100644 --- a/Tests/SwiftFormatTests/Rules/LintOrFormatRuleTestCase.swift +++ b/Tests/SwiftFormatTests/Rules/LintOrFormatRuleTestCase.swift @@ -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 @@ -16,18 +16,21 @@ class LintOrFormatRuleTestCase: DiagnosingTestCase { /// where findings are expected to be emitted. /// - findings: A list of `FindingSpec` values that describe the findings that are expected to /// be emitted. + /// - experimentalFeatures: The set of experimental features that should be enabled in the + /// parser. /// - file: The file the test resides in (defaults to the current caller's file). /// - line: The line the test resides in (defaults to the current caller's line). final func assertLint( _ type: LintRule.Type, _ markedSource: String, findings: [FindingSpec] = [], + experimentalFeatures: Parser.ExperimentalFeatures = [], file: StaticString = #file, line: UInt = #line ) { let markedText = MarkedText(textWithMarkers: markedSource) let unmarkedSource = markedText.textWithoutMarkers - let tree = Parser.parse(source: unmarkedSource) + let tree = Parser.parse(source: unmarkedSource, experimentalFeatures: experimentalFeatures) let sourceFileSyntax = try! OperatorTable.standardOperators.foldAll(tree).as(SourceFileSyntax.self)! @@ -80,6 +83,8 @@ class LintOrFormatRuleTestCase: DiagnosingTestCase { /// - findings: A list of `FindingSpec` values that describe the findings that are expected to /// be emitted. /// - configuration: The configuration to use when formatting (or nil to use the default). + /// - experimentalFeatures: The set of experimental features that should be enabled in the + /// parser. /// - file: The file the test resides in (defaults to the current caller's file) /// - line: The line the test resides in (defaults to the current caller's line) final func assertFormatting( @@ -88,12 +93,13 @@ class LintOrFormatRuleTestCase: DiagnosingTestCase { expected: String, findings: [FindingSpec] = [], configuration: Configuration? = nil, + experimentalFeatures: Parser.ExperimentalFeatures = [], file: StaticString = #file, line: UInt = #line ) { let markedInput = MarkedText(textWithMarkers: input) let originalSource: String = markedInput.textWithoutMarkers - let tree = Parser.parse(source: originalSource) + let tree = Parser.parse(source: originalSource, experimentalFeatures: experimentalFeatures) let sourceFileSyntax = try! OperatorTable.standardOperators.foldAll(tree).as(SourceFileSyntax.self)! @@ -134,7 +140,7 @@ class LintOrFormatRuleTestCase: DiagnosingTestCase { printTokenStream: false, whitespaceOnly: false ).prettyPrint() - let prettyPrintedTree = Parser.parse(source: prettyPrintedSource) + let prettyPrintedTree = Parser.parse(source: prettyPrintedSource, experimentalFeatures: experimentalFeatures) XCTAssertEqual( whitespaceInsensitiveText(of: actual), whitespaceInsensitiveText(of: prettyPrintedTree),