From 38b7b389c4b28217849b0d48c9f46a91c64e6d9b Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Sun, 21 Jan 2024 19:46:38 -0800 Subject: [PATCH] Add a function to check if a name can be used as an identifier in a given context rdar://120721971 --- Release Notes/511.md | 4 + Sources/SwiftParser/CMakeLists.txt | 1 + Sources/SwiftParser/IsValidIdentifier.swift | 120 ++++++++++++++++++ .../IsValidIdentifierTests.swift | 60 +++++++++ 4 files changed, 185 insertions(+) create mode 100644 Sources/SwiftParser/IsValidIdentifier.swift create mode 100644 Tests/SwiftParserTest/IsValidIdentifierTests.swift diff --git a/Release Notes/511.md b/Release Notes/511.md index c33d3dbdc8c..bfe25b3aa22 100644 --- a/Release Notes/511.md +++ b/Release Notes/511.md @@ -32,6 +32,10 @@ - Description: The `throwsSpecifier` for the effects nodes (`AccessorEffectSpecifiers`, `FunctionEffectSpecifiers`, `TypeEffectSpecifiers`, `EffectSpecifiers`) has been replaced with `throwsClause`, which captures both the throws specifier and the (optional) thrown error type, as introduced by SE-0413. - Pull Request: https://github.com/apple/swift-syntax/pull/2379 +- `String.isValidIdentifier(for:)` + - Description: `SwiftParser` adds an extension on `String` to check if it can be used as an identifier in a given context. + - Pull Request: https://github.com/apple/swift-syntax/pull/2434 + ## API Behavior Changes ## Deprecations diff --git a/Sources/SwiftParser/CMakeLists.txt b/Sources/SwiftParser/CMakeLists.txt index ea9b7e7bb0f..15df1bb8198 100644 --- a/Sources/SwiftParser/CMakeLists.txt +++ b/Sources/SwiftParser/CMakeLists.txt @@ -15,6 +15,7 @@ add_swift_syntax_library(SwiftParser Directives.swift Expressions.swift IncrementalParseTransition.swift + IsValidIdentifier.swift Lookahead.swift LoopProgressCondition.swift Modifiers.swift diff --git a/Sources/SwiftParser/IsValidIdentifier.swift b/Sources/SwiftParser/IsValidIdentifier.swift new file mode 100644 index 00000000000..6e734bd0fda --- /dev/null +++ b/Sources/SwiftParser/IsValidIdentifier.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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(RawSyntax) import SwiftSyntax + +/// Context in which to check if a name can be used as an identifier. +/// +/// - SeeAlso: `Swift.isValidSwiftIdentifier(for:)` extension added by SwiftParser. +public enum IdentifierCheckContext { + /// Check if a name can be used to declare a variable, ie. if it can be used after a `let` or `var` keyword. + /// + /// ### Examples + /// - `test` is a valid variable name and `let test: Int` is valid Swift code + /// - `class` is not a valid variable and `let class: Int` is invalid Swift code + case variableName + + /// Check if a name can be used as a member access, ie. if it can be used after a `.`. + /// + /// ### Examples + /// - `test` is a valid identifier for member access because `myStruct.test` is valid + /// - `class` is a valid identifier for member access because `myStruct.class` is valid, even though `class` + /// needs to be wrapped in backticks when used to declare a variable. + /// - `self` is not a valid identifier for member access because `myStruct.self` does not access a member named + /// `self` on `myStruct` and instead returns `myStruct` itself. + case memberAccess +} + +extension String { + /// Checks whether `name` can be used as an identifier in a certain context. + /// + /// If the name cannot be used as an identifier in this context, it needs to be escaped. + /// + /// For example, `class` is not a valid identifier for a variable name and needs to be be wrapped in backticks + /// to be valid Swift code, like the following. + /// + /// ```swift + /// let `class`: String + /// ``` + /// + /// The context is important here โ€“ some names can be used as identifiers in some contexts but not others. + /// For example, `myStruct.class` is valid without adding backticks `class`, but as mentioned above, + /// backticks need to be added when `class` is used as a variable name. + /// + /// - SeeAlso: ``SwiftParser/IdentifierCheckContext`` + public func isValidSwiftIdentifier(for context: IdentifierCheckContext) -> Bool { + switch context { + case .variableName: + return isValidVariableName(self) + case .memberAccess: + return isValidMemberAccess(self) + } + } +} + +private func isValidVariableName(_ name: String) -> Bool { + var parser = Parser("var \(name)") + let decl = DeclSyntax.parse(from: &parser) + guard parser.at(.endOfFile) else { + // We didn't parse the entire name. Probably some garbage left in the name, so not an identifier. + return false + } + guard !decl.hasError && !decl.hasWarning else { + // There were syntax errors in the source code. So not valid. + return false + } + guard let variable = decl.as(VariableDeclSyntax.self) else { + return false + } + guard let identifier = variable.bindings.first?.pattern.as(IdentifierPatternSyntax.self)?.identifier else { + return false + } + guard identifier.rawTokenKind == .identifier else { + // We parsed the name as a keyword, eg. `self`, so not a valid identifier. + return false + } + guard identifier.rawText.count == name.utf8.count else { + // The identifier doesn't cover all the characters in `name`, so we parsed + // some of these characters into trivia or another token. + // Thus, `name` is not a valid identifier. + return false + } + return true +} + +private func isValidMemberAccess(_ name: String) -> Bool { + var parser = Parser("t.\(name)") + let expr = ExprSyntax.parse(from: &parser) + guard parser.at(.endOfFile) else { + // We didn't parse the entire name. Probably some garbage left in the name, so not an identifier. + return false + } + guard !expr.hasError && !expr.hasWarning else { + // There were syntax errors in the source code. So not valid. + return false + } + guard let memberAccess = expr.as(MemberAccessExprSyntax.self) else { + return false + } + let identifier = memberAccess.declName.baseName + guard identifier.rawTokenKind == .identifier else { + // We parsed the name as a keyword, eg. `self`, so not a valid identifier. + return false + } + guard identifier.rawText.count == name.utf8.count else { + // The identifier doesn't cover all the characters in `name`, so we parsed + // some of these characters into trivia or another token. + // Thus, `name` is not a valid identifier. + return false + } + return true +} diff --git a/Tests/SwiftParserTest/IsValidIdentifierTests.swift b/Tests/SwiftParserTest/IsValidIdentifierTests.swift new file mode 100644 index 00000000000..764ddd781c1 --- /dev/null +++ b/Tests/SwiftParserTest/IsValidIdentifierTests.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 +// +//===----------------------------------------------------------------------===// + +import SwiftParser +import XCTest + +/// Defines whether a name is expected to be a valid identifier in the given contexts. +private struct ValidIdentifierSpec: ExpressibleByBooleanLiteral { + let variableName: Bool + let memberAccess: Bool + + init(variableName: Bool, memberAccess: Bool) { + self.variableName = variableName + self.memberAccess = memberAccess + } + + init(booleanLiteral value: BooleanLiteralType) { + self.init(variableName: value, memberAccess: value) + } +} + +private func assertValidIdentifier( + _ name: String, + _ spec: ValidIdentifierSpec, + file: StaticString = #file, + line: UInt = #line +) { + XCTAssertEqual(name.isValidSwiftIdentifier(for: .variableName), spec.variableName, "Checking identifier for variableName context", file: file, line: line) + XCTAssertEqual(name.isValidSwiftIdentifier(for: .memberAccess), spec.memberAccess, "Checking identifier for memberAccess context", file: file, line: line) +} + +class IsValidIdentifierTests: XCTestCase { + func testIsValidIdentifier() { + assertValidIdentifier("test", true) + assertValidIdentifier("class", ValidIdentifierSpec(variableName: false, memberAccess: true)) + assertValidIdentifier("`class`", true) + assertValidIdentifier("self", false) + assertValidIdentifier("`self`", true) + assertValidIdentifier("let", ValidIdentifierSpec(variableName: false, memberAccess: true)) + assertValidIdentifier("`let`", true) + assertValidIdentifier("", false) + assertValidIdentifier("test: Int", false) + assertValidIdentifier("test ", false) + assertValidIdentifier(" test", false) + assertValidIdentifier("te st", false) + assertValidIdentifier("test\0", false) + assertValidIdentifier("test\0test", false) + assertValidIdentifier("test(x:)", false) + assertValidIdentifier("๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง", true) + } +}