From 02a0c5de6d77e83ae1cb82e85e69de8279ee88ab Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Thu, 8 Jun 2023 16:26:22 -0700 Subject: [PATCH] Add infrastructure for automated validation of syntax node structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The goal here is to generalize the API inconsistency reported by @AnthonyLatsis and automatically detect them. There are valid reasons for nodes or children to violate these base rules. My idea is that we note these exceptions in expected failures, ideally with a sort explanation that motivates the deviation from standard naming rules. We aren’t running these tests at the moment yet. I’d like to merge them as a starting point for API improvement ideas at the moment. The current rules are - All nodes with base kind e.g. `ExprSyntax` should end with `ExprSyntax`. - If a child has a single keyword as its choice, it should be named `*Keyword` (e.g. `ImportKeyword`) - If a child has a single non-keyword token kind, name it the same as the the token choice (e.g. `LeftParen`) - If a token only has keyword token choices, its name should end with `Keyword`. - Children that have the same type also have the same child name. - Every node that can conform to a trait does so --- CodeGeneration/Package.swift | 6 + .../Sources/SyntaxSupport/Child.swift | 2 +- .../Tests/ValidateSyntaxNodes/Utils.swift | 32 + .../ValidateSyntaxNodes.swift | 680 ++++++++++++++++++ .../ValidationFailure.swift | 21 + 5 files changed, 740 insertions(+), 1 deletion(-) create mode 100644 CodeGeneration/Tests/ValidateSyntaxNodes/Utils.swift create mode 100644 CodeGeneration/Tests/ValidateSyntaxNodes/ValidateSyntaxNodes.swift create mode 100644 CodeGeneration/Tests/ValidateSyntaxNodes/ValidationFailure.swift diff --git a/CodeGeneration/Package.swift b/CodeGeneration/Package.swift index 0ea20b54eae..463169d3d63 100644 --- a/CodeGeneration/Package.swift +++ b/CodeGeneration/Package.swift @@ -45,6 +45,12 @@ let package = Package( "SyntaxSupport", ] ), + .testTarget( + name: "ValidateSyntaxNodes", + dependencies: [ + "SyntaxSupport" + ] + ), ] ) diff --git a/CodeGeneration/Sources/SyntaxSupport/Child.swift b/CodeGeneration/Sources/SyntaxSupport/Child.swift index 66e45019bcf..a631f3cdae5 100644 --- a/CodeGeneration/Sources/SyntaxSupport/Child.swift +++ b/CodeGeneration/Sources/SyntaxSupport/Child.swift @@ -12,7 +12,7 @@ /// The kind of token a node can contain. Either a token of a specific kind or a /// keyword with the given text. -public enum TokenChoice { +public enum TokenChoice: Equatable { case keyword(text: String) case token(tokenKind: String) diff --git a/CodeGeneration/Tests/ValidateSyntaxNodes/Utils.swift b/CodeGeneration/Tests/ValidateSyntaxNodes/Utils.swift new file mode 100644 index 00000000000..7ae82ea2f2f --- /dev/null +++ b/CodeGeneration/Tests/ValidateSyntaxNodes/Utils.swift @@ -0,0 +1,32 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +extension Collection { + /// If the collection contains a single element, return it, otherwise `nil`. + var only: Element? { + if !isEmpty && index(after: startIndex) == endIndex { + return self.first! + } else { + return nil + } + } +} + +extension String { + func dropSuffix(_ suffix: String) -> String { + if hasSuffix(suffix) { + return String(self.dropLast(suffix.count)) + } else { + return self + } + } +} diff --git a/CodeGeneration/Tests/ValidateSyntaxNodes/ValidateSyntaxNodes.swift b/CodeGeneration/Tests/ValidateSyntaxNodes/ValidateSyntaxNodes.swift new file mode 100644 index 00000000000..ec9813b3d93 --- /dev/null +++ b/CodeGeneration/Tests/ValidateSyntaxNodes/ValidateSyntaxNodes.swift @@ -0,0 +1,680 @@ +//===----------------------------------------------------------------------===// +// +// 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 SyntaxSupport + +import XCTest + +fileprivate func assertNoFailures( + _ failures: [ValidationFailure], + message: String, + file: StaticString = #file, + line: UInt = #line +) { + if failures.isEmpty { + return + } + var message = "\(message): \n" + let failuresByNode = [SyntaxNodeKind: [ValidationFailure]](failures.map({ ($0.node, [$0]) })) { $0 + $1 } + + for (nodeKind, failures) in failuresByNode.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { + message += "\(nodeKind.syntaxType):\n" + for failure in failures { + message += " - \(failure.message)\n" + } + } + XCTFail(message, file: file, line: line) +} + +fileprivate func assertFailuresMatchXFails( + _ failures: [ValidationFailure], + expectedFailures: [ValidationFailure], + file: StaticString = #file, + line: UInt = #line +) { + let matchedXFails = expectedFailures.filter { failures.contains($0) } + let failures = failures.filter { !matchedXFails.contains($0) } + let unmatchedXFails = expectedFailures.filter { !matchedXFails.contains($0) } + + assertNoFailures(failures, message: "Unexpected valiation failures found", file: file, line: line) + assertNoFailures(unmatchedXFails, message: "Unmatched expected failures", file: file, line: line) +} + +fileprivate extension ChildKind { + func hasSameType(as other: ChildKind) -> Bool { + switch (self, other) { + case (.node(let kind), .node(kind: let otherKind)): + return kind == otherKind + case (.nodeChoices(let choices), .nodeChoices(let otherChoices)): + return choices.count == otherChoices.count && zip(choices, otherChoices).allSatisfy { $0.hasSameType(as: $1) } + case (.collection(let kind, _), .collection(let otherKind, _)): + return kind == otherKind + case (.token(let choices, _, _), .token(let otherChoices, _, _)): + return choices == otherChoices + case (.node(let kind), .collection(let otherKind, _)): + return kind == otherKind + case (.collection(let kind, _), .node(let otherKind)): + return kind == otherKind + default: + return false + } + } +} + +fileprivate extension Child { + func hasSameType(as other: Child) -> Bool { + return name == other.name && kind.hasSameType(as: other.kind) && isOptional == other.isOptional + } +} + +class ValidateSyntaxNodes: XCTestCase { + /// All nodes with base kind e.g. `ExprSyntax` should end with `ExprSyntax`. + func testBaseKindSuffix() { + var failures: [ValidationFailure] = [] + for node in SYNTAX_NODES where node.base != .syntaxCollection { + if !node.kind.syntaxType.description.hasSuffix(node.base.syntaxType.description) { + failures.append( + ValidationFailure(node: node.kind, message: "has base kind '\(node.base.syntaxType)' but type name doesn’t have '\(node.base.syntaxType)' suffix") + ) + } + } + + assertFailuresMatchXFails( + failures, + expectedFailures: [ + ValidationFailure(node: .canImportVersionInfo, message: "has base kind 'ExprSyntax' but type name doesn’t have 'ExprSyntax' suffix"), + ValidationFailure(node: .memberTypeIdentifier, message: "has base kind 'TypeSyntax' but type name doesn’t have 'TypeSyntax' suffix"), + ValidationFailure(node: .poundSourceLocation, message: "has base kind 'DeclSyntax' but type name doesn’t have 'DeclSyntax' suffix"), + ValidationFailure(node: .simpleTypeIdentifier, message: "has base kind 'TypeSyntax' but type name doesn’t have 'TypeSyntax' suffix"), + ] + ) + } + + /// Checks standardized naming of children with a single token choice + /// + /// - If a child has a single keyword as its choice, it should be named `*Keyword` (e.g. `ImportKeyword`) + /// - If it’s another token kind, name it the same as the the token choice (e.g. `LeftParen`) + func testSingleTokenChoiceChildNaming() { + var failures: [ValidationFailure] = [] + for node in SYNTAX_NODES.compactMap(\.layoutNode) { + for child in node.children { + if case .token(choices: let tokenChoices, _, _) = child.kind, + let choice = tokenChoices.only + { + switch choice { + case .keyword(text: let keyword): + if child.name != "\(keyword.withFirstCharacterUppercased)Keyword" { + failures.append( + ValidationFailure( + node: node.kind, + message: + "child '\(child.name)' has a single keyword as its only token choice and should thus be named '\(keyword.withFirstCharacterUppercased)Keyword'" + ) + ) + } + case .token(tokenKind: let tokenKind): + switch tokenKind { + case "IdentifierToken": + // We allow arbitrary naming of identifiers + break + case "CommaToken": + if child.name != "TrailingComma" && child.name != "Comma" { + failures.append( + ValidationFailure( + node: node.kind, + message: "child '\(child.name)' has a comma keyword as its only token choice and should thus be named 'Comma' or 'TrailingComma'" + ) + ) + } + default: + if child.name != tokenKind.dropSuffix("Token") { + failures.append( + ValidationFailure( + node: node.kind, + message: "child '\(child.name)' has a token as its only token choice and should thus be named '\(tokenKind.dropSuffix("Token"))'" + ) + ) + } + } + } + } + } + } + assertFailuresMatchXFails( + failures, + expectedFailures: [ + // MARK: Naming failures of children with a single keyword choice + ValidationFailure( + node: .accessorEffectSpecifiers, + message: "child 'AsyncSpecifier' has a single keyword as its only token choice and should thus be named 'AsyncKeyword'" + ), + ValidationFailure( + node: .accessorEffectSpecifiers, + message: "child 'ThrowsSpecifier' has a single keyword as its only token choice and should thus be named 'ThrowsKeyword'" + ), + ValidationFailure(node: .asExpr, message: "child 'AsTok' has a single keyword as its only token choice and should thus be named 'AsKeyword'"), + ValidationFailure( + node: .availabilityEntry, + message: "child 'Label' has a single keyword as its only token choice and should thus be named 'AvailabilityKeyword'" + ), + ValidationFailure( + node: .backDeployedAttributeSpecList, + message: "child 'BeforeLabel' has a single keyword as its only token choice and should thus be named 'BeforeKeyword'" + ), + ValidationFailure( + node: .borrowExpr, + message: "child 'BorrowKeyword' has a single keyword as its only token choice and should thus be named '_borrowKeyword'" + ), + ValidationFailure(node: .closureSignature, message: "child 'InTok' has a single keyword as its only token choice and should thus be named 'InKeyword'"), + ValidationFailure( + node: .conventionAttributeArguments, + message: "child 'CTypeLabel' has a single keyword as its only token choice and should thus be named 'CTypeKeyword'" + ), + ValidationFailure( + node: .conventionWitnessMethodAttributeArguments, + message: "child 'WitnessMethodLabel' has a single keyword as its only token choice and should thus be named 'Witness_methodKeyword'" + ), + ValidationFailure( + node: .derivativeRegistrationAttributeArguments, + message: "child 'OfLabel' has a single keyword as its only token choice and should thus be named 'OfKeyword'" + ), + ValidationFailure( + node: .differentiabilityParamsClause, + message: "child 'WrtLabel' has a single keyword as its only token choice and should thus be named 'WrtKeyword'" + ), + ValidationFailure( + node: .dynamicReplacementArguments, + message: "child 'ForLabel' has a single keyword as its only token choice and should thus be named 'ForKeyword'" + ), + ValidationFailure( + node: .genericParameter, + message: "child 'Each' has a single keyword as its only token choice and should thus be named 'EachKeyword'" + ), + ValidationFailure( + node: .importDecl, + message: "child 'ImportTok' has a single keyword as its only token choice and should thus be named 'ImportKeyword'" + ), + ValidationFailure(node: .isExpr, message: "child 'IsTok' has a single keyword as its only token choice and should thus be named 'IsKeyword'"), + ValidationFailure( + node: .originallyDefinedInArguments, + message: "child 'ModuleLabel' has a single keyword as its only token choice and should thus be named 'ModuleKeyword'" + ), + ValidationFailure( + node: .poundSourceLocationArgs, + message: "child 'FileArgLabel' has a single keyword as its only token choice and should thus be named 'FileKeyword'" + ), + ValidationFailure( + node: .poundSourceLocationArgs, + message: "child 'LineArgLabel' has a single keyword as its only token choice and should thus be named 'LineKeyword'" + ), + ValidationFailure( + node: .targetFunctionEntry, + message: "child 'Label' has a single keyword as its only token choice and should thus be named 'TargetKeyword'" + ), + ValidationFailure( + node: .tupleTypeElement, + message: "child 'InOut' has a single keyword as its only token choice and should thus be named 'InoutKeyword'" + ), + ValidationFailure( + node: .typeEffectSpecifiers, + message: "child 'AsyncSpecifier' has a single keyword as its only token choice and should thus be named 'AsyncKeyword'" + ), + ValidationFailure( + node: .typeEffectSpecifiers, + message: "child 'ThrowsSpecifier' has a single keyword as its only token choice and should thus be named 'ThrowsKeyword'" + ), + ValidationFailure( + node: .unavailableFromAsyncArguments, + message: "child 'MessageLabel' has a single keyword as its only token choice and should thus be named 'MessageKeyword'" + ), + ValidationFailure( + node: .underscorePrivateAttributeArguments, + message: "child 'SourceFileLabel' has a single keyword as its only token choice and should thus be named 'SourceFileKeyword'" + ), + ValidationFailure(node: .unresolvedAsExpr, message: "child 'AsTok' has a single keyword as its only token choice and should thus be named 'AsKeyword'"), + ValidationFailure(node: .unresolvedIsExpr, message: "child 'IsTok' has a single keyword as its only token choice and should thus be named 'IsKeyword'"), + + // MARK: + ValidationFailure(node: .arrayExpr, message: "child 'LeftSquare' has a token as its only token choice and should thus be named 'LeftSquareBracket'"), + ValidationFailure(node: .arrayExpr, message: "child 'RightSquare' has a token as its only token choice and should thus be named 'RightSquareBracket'"), + ValidationFailure(node: .arrowExpr, message: "child 'ArrowToken' has a token as its only token choice and should thus be named 'Arrow'"), + ValidationFailure(node: .assignmentExpr, message: "child 'AssignToken' has a token as its only token choice and should thus be named 'Equal'"), + ValidationFailure(node: .attribute, message: "child 'AtSignToken' has a token as its only token choice and should thus be named 'AtSign'"), + + // MARK: Naming failures of children with a single token choice + ValidationFailure( + node: .binaryOperatorExpr, + message: "child 'OperatorToken' has a token as its only token choice and should thus be named 'BinaryOperator'" + ), + ValidationFailure(node: .closureCaptureItem, message: "child 'AssignToken' has a token as its only token choice and should thus be named 'Equal'"), + ValidationFailure( + node: .closureCaptureSignature, + message: "child 'LeftSquare' has a token as its only token choice and should thus be named 'LeftSquareBracket'" + ), + ValidationFailure( + node: .closureCaptureSignature, + message: "child 'RightSquare' has a token as its only token choice and should thus be named 'RightSquareBracket'" + ), + ValidationFailure( + node: .designatedTypeElement, + message: "child 'LeadingComma' has a comma keyword as its only token choice and should thus be named 'Comma' or 'TrailingComma'" + ), + ValidationFailure( + node: .dictionaryExpr, + message: "child 'LeftSquare' has a token as its only token choice and should thus be named 'LeftSquareBracket'" + ), + ValidationFailure( + node: .dictionaryExpr, + message: "child 'RightSquare' has a token as its only token choice and should thus be named 'RightSquareBracket'" + ), + ValidationFailure( + node: .differentiableAttributeArguments, + message: "child 'DiffKindComma' has a comma keyword as its only token choice and should thus be named 'Comma' or 'TrailingComma'" + ), + ValidationFailure( + node: .differentiableAttributeArguments, + message: "child 'DiffParamsComma' has a comma keyword as its only token choice and should thus be named 'Comma' or 'TrailingComma'" + ), + ValidationFailure( + node: .expressionSegment, + message: "child 'Delimiter' has a token as its only token choice and should thus be named 'RawStringDelimiter'" + ), + ValidationFailure( + node: .floatLiteralExpr, + message: "child 'FloatingDigits' has a token as its only token choice and should thus be named 'FloatingLiteral'" + ), + ValidationFailure( + node: .genericArgumentClause, + message: "child 'LeftAngleBracket' has a token as its only token choice and should thus be named 'LeftAngle'" + ), + ValidationFailure( + node: .genericArgumentClause, + message: "child 'RightAngleBracket' has a token as its only token choice and should thus be named 'RightAngle'" + ), + ValidationFailure( + node: .genericParameterClause, + message: "child 'LeftAngleBracket' has a token as its only token choice and should thus be named 'LeftAngle'" + ), + ValidationFailure( + node: .genericParameterClause, + message: "child 'RightAngleBracket' has a token as its only token choice and should thus be named 'RightAngle'" + ), + ValidationFailure(node: .importPathComponent, message: "child 'TrailingDot' has a token as its only token choice and should thus be named 'Period'"), + ValidationFailure(node: .inOutExpr, message: "child 'Ampersand' has a token as its only token choice and should thus be named 'PrefixAmpersand'"), + ValidationFailure(node: .integerLiteralExpr, message: "child 'Digits' has a token as its only token choice and should thus be named 'IntegerLiteral'"), + ValidationFailure( + node: .keyPathSubscriptComponent, + message: "child 'LeftBracket' has a token as its only token choice and should thus be named 'LeftSquareBracket'" + ), + ValidationFailure( + node: .keyPathSubscriptComponent, + message: "child 'RightBracket' has a token as its only token choice and should thus be named 'RightSquareBracket'" + ), + ValidationFailure(node: .labeledStmt, message: "child 'LabelColon' has a token as its only token choice and should thus be named 'Colon'"), + ValidationFailure(node: .layoutRequirement, message: "child 'Size' has a token as its only token choice and should thus be named 'IntegerLiteral'"), + ValidationFailure( + node: .layoutRequirement, + message: "child 'Alignment' has a token as its only token choice and should thus be named 'IntegerLiteral'" + ), + ValidationFailure(node: .macroExpansionDecl, message: "child 'PoundToken' has a token as its only token choice and should thus be named 'Pound'"), + ValidationFailure(node: .macroExpansionExpr, message: "child 'PoundToken' has a token as its only token choice and should thus be named 'Pound'"), + ValidationFailure(node: .memberAccessExpr, message: "child 'Dot' has a token as its only token choice and should thus be named 'Period'"), + ValidationFailure( + node: .opaqueReturnTypeOfAttributeArguments, + message: "child 'Ordinal' has a token as its only token choice and should thus be named 'IntegerLiteral'" + ), + ValidationFailure( + node: .optionalChainingExpr, + message: "child 'QuestionMark' has a token as its only token choice and should thus be named 'PostfixQuestionMark'" + ), + ValidationFailure( + node: .optionalType, + message: "child 'QuestionMark' has a token as its only token choice and should thus be named 'PostfixQuestionMark'" + ), + ValidationFailure( + node: .postfixUnaryExpr, + message: "child 'OperatorToken' has a token as its only token choice and should thus be named 'PostfixOperator'" + ), + ValidationFailure( + node: .poundSourceLocationArgs, + message: "child 'FileArgColon' has a token as its only token choice and should thus be named 'Colon'" + ), + ValidationFailure( + node: .poundSourceLocationArgs, + message: "child 'LineArgColon' has a token as its only token choice and should thus be named 'Colon'" + ), + ValidationFailure( + node: .poundSourceLocationArgs, + message: "child 'LineNumber' has a token as its only token choice and should thus be named 'IntegerLiteral'" + ), + ValidationFailure( + node: .prefixOperatorExpr, + message: "child 'OperatorToken' has a token as its only token choice and should thus be named 'PrefixOperator'" + ), + ValidationFailure( + node: .primaryAssociatedTypeClause, + message: "child 'LeftAngleBracket' has a token as its only token choice and should thus be named 'LeftAngle'" + ), + ValidationFailure( + node: .primaryAssociatedTypeClause, + message: "child 'RightAngleBracket' has a token as its only token choice and should thus be named 'RightAngle'" + ), + ValidationFailure(node: .qualifiedDeclName, message: "child 'Dot' has a token as its only token choice and should thus be named 'Period'"), + ValidationFailure( + node: .regexLiteralExpr, + message: "child 'OpeningPounds' has a token as its only token choice and should thus be named 'ExtendedRegexDelimiter'" + ), + ValidationFailure(node: .regexLiteralExpr, message: "child 'OpenSlash' has a token as its only token choice and should thus be named 'RegexSlash'"), + ValidationFailure( + node: .regexLiteralExpr, + message: "child 'RegexPattern' has a token as its only token choice and should thus be named 'RegexLiteralPattern'" + ), + ValidationFailure(node: .regexLiteralExpr, message: "child 'CloseSlash' has a token as its only token choice and should thus be named 'RegexSlash'"), + ValidationFailure( + node: .regexLiteralExpr, + message: "child 'ClosingPounds' has a token as its only token choice and should thus be named 'ExtendedRegexDelimiter'" + ), + ValidationFailure(node: .sourceFile, message: "child 'EOFToken' has a token as its only token choice and should thus be named 'EOF'"), + ValidationFailure( + node: .stringLiteralExpr, + message: "child 'OpenDelimiter' has a token as its only token choice and should thus be named 'RawStringDelimiter'" + ), + ValidationFailure( + node: .stringLiteralExpr, + message: "child 'CloseDelimiter' has a token as its only token choice and should thus be named 'RawStringDelimiter'" + ), + ValidationFailure(node: .stringSegment, message: "child 'Content' has a token as its only token choice and should thus be named 'StringSegment'"), + ValidationFailure( + node: .subscriptExpr, + message: "child 'LeftBracket' has a token as its only token choice and should thus be named 'LeftSquareBracket'" + ), + ValidationFailure( + node: .subscriptExpr, + message: "child 'RightBracket' has a token as its only token choice and should thus be named 'RightSquareBracket'" + ), + ValidationFailure( + node: .suppressedType, + message: "child 'WithoutTilde' has a token as its only token choice and should thus be named 'PrefixOperator'" + ), + ValidationFailure( + node: .ternaryExpr, + message: "child 'QuestionMark' has a token as its only token choice and should thus be named 'InfixQuestionMark'" + ), + ValidationFailure(node: .ternaryExpr, message: "child 'ColonMark' has a token as its only token choice and should thus be named 'Colon'"), + ValidationFailure(node: .tuplePatternElement, message: "child 'LabelColon' has a token as its only token choice and should thus be named 'Colon'"), + ValidationFailure( + node: .unresolvedTernaryExpr, + message: "child 'QuestionMark' has a token as its only token choice and should thus be named 'InfixQuestionMark'" + ), + ValidationFailure(node: .unresolvedTernaryExpr, message: "child 'ColonMark' has a token as its only token choice and should thus be named 'Colon'"), + ValidationFailure(node: .versionComponent, message: "child 'Number' has a token as its only token choice and should thus be named 'IntegerLiteral'"), + ValidationFailure(node: .versionTuple, message: "child 'Major' has a token as its only token choice and should thus be named 'IntegerLiteral'"), + ] + ) + } + + /// If a token only has keyword token choices, its name should end with `Keyword`. + func testMultipleKeywordChoicesNaming() { + var failures: [ValidationFailure] = [] + for node in SYNTAX_NODES.compactMap(\.layoutNode) { + for child in node.children { + if case .token(choices: let tokenChoices, _, _) = child.kind, + tokenChoices.count > 1, // single token choices are handled by `validateSingleTokenChoiceChildNaming` + tokenChoices.allSatisfy({ $0.isKeyword }), + !child.name.hasSuffix("Keyword") + { + failures.append( + ValidationFailure( + node: node.kind, + message: "child '\(child.name)' only has keywords as its token choices and should thus and with 'Keyword'" + ) + ) + } + } + } + + assertFailuresMatchXFails( + failures, + expectedFailures: [ + ValidationFailure(node: .accessorDecl, message: "child 'AccessorKind' only has keywords as its token choices and should thus and with 'Keyword'"), + ValidationFailure(node: .attributedType, message: "child 'Specifier' only has keywords as its token choices and should thus and with 'Keyword'"), + ValidationFailure( + node: .availabilityLabeledArgument, + message: "child 'Label' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ValidationFailure( + node: .booleanLiteralExpr, + message: "child 'BooleanLiteral' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ValidationFailure(node: .canImportVersionInfo, message: "child 'Label' only has keywords as its token choices and should thus and with 'Keyword'"), + ValidationFailure( + node: .closureCaptureItemSpecifier, + message: "child 'Specifier' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ValidationFailure( + node: .closureCaptureItemSpecifier, + message: "child 'Detail' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ValidationFailure( + node: .constrainedSugarType, + message: "child 'SomeOrAnySpecifier' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ValidationFailure(node: .declModifier, message: "child 'Name' only has keywords as its token choices and should thus and with 'Keyword'"), + ValidationFailure( + node: .derivativeRegistrationAttributeArguments, + message: "child 'AccessorKind' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ValidationFailure( + node: .differentiableAttributeArguments, + message: "child 'DiffKind' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ValidationFailure( + node: .documentationAttributeArgument, + message: "child 'Label' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ValidationFailure( + node: .functionEffectSpecifiers, + message: "child 'AsyncSpecifier' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ValidationFailure( + node: .functionEffectSpecifiers, + message: "child 'ThrowsSpecifier' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ValidationFailure(node: .importDecl, message: "child 'ImportKind' only has keywords as its token choices and should thus and with 'Keyword'"), + ValidationFailure( + node: .layoutRequirement, + message: "child 'LayoutConstraint' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ValidationFailure(node: .metatypeType, message: "child 'TypeOrProtocol' only has keywords as its token choices and should thus and with 'Keyword'"), + ValidationFailure(node: .operatorDecl, message: "child 'Fixity' only has keywords as its token choices and should thus and with 'Keyword'"), + ValidationFailure(node: .precedenceGroupAssignment, message: "child 'Flag' only has keywords as its token choices and should thus and with 'Keyword'"), + ValidationFailure( + node: .precedenceGroupAssociativity, + message: "child 'Value' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ValidationFailure( + node: .precedenceGroupRelation, + message: "child 'HigherThanOrLowerThan' only has keywords as its token choices and should thus and with 'Keyword'" + ), + ] + ) + } + + /// Check that children that have the same type also have the same child name. + func testConsistentNamingOfChildren() { + var failures: [ValidationFailure] = [] + + var childrenByNodeKind: [SyntaxNodeKind: [(node: LayoutNode, child: Child)]] = [:] + + for node in SYNTAX_NODES.compactMap(\.layoutNode) { + for child in node.children { + guard case .node(kind: let childKind) = child.kind else { + continue + } + childrenByNodeKind[childKind, default: []].append((node, child)) + } + } + + for (kind, children) in childrenByNodeKind where !kind.isBase && kind != .token { + let childNames = children.map(\.child.name) + let firstChildName = childNames.first! + + for (node, child) in children.dropFirst() { + if child.name != firstChildName { + failures.append( + ValidationFailure( + node: node.kind, + message: + "child '\(child.name)' is named inconsistently with '\(children.first!.node.kind.syntaxType).\(children.first!.child.name)', which has the same type ('\(kind.syntaxType)')" + ) + ) + } + } + } + + assertFailuresMatchXFails( + failures, + expectedFailures: [ + ValidationFailure( + node: .differentiableAttributeArguments, + message: "child 'WhereClause' is named inconsistently with 'ActorDeclSyntax.GenericWhereClause', which has the same type ('GenericWhereClauseSyntax')" + ), + ValidationFailure( + node: .subscriptDecl, + message: "child 'Indices' is named inconsistently with 'FunctionSignatureSyntax.Input', which has the same type ('ParameterClauseSyntax')" + ), + ValidationFailure( + node: .qualifiedDeclName, + message: "child 'Arguments' is named inconsistently with 'DeclNameSyntax.DeclNameArguments', which has the same type ('DeclNameArgumentsSyntax')" + ), + ValidationFailure( + node: .macroExpansionDecl, + message: + "child 'GenericArguments' is named inconsistently with 'KeyPathPropertyComponentSyntax.GenericArgumentClause', which has the same type ('GenericArgumentClauseSyntax')" + ), + ValidationFailure( + node: .macroExpansionExpr, + message: + "child 'GenericArguments' is named inconsistently with 'KeyPathPropertyComponentSyntax.GenericArgumentClause', which has the same type ('GenericArgumentClauseSyntax')" + ), + ValidationFailure( + node: .enumCaseParameter, + message: "child 'DefaultArgument' is named inconsistently with 'EnumCaseElementSyntax.RawValue', which has the same type ('InitializerClauseSyntax')" + ), + ValidationFailure( + node: .functionParameter, + message: "child 'DefaultArgument' is named inconsistently with 'EnumCaseElementSyntax.RawValue', which has the same type ('InitializerClauseSyntax')" + ), + ValidationFailure( + node: .macroDecl, + message: "child 'Definition' is named inconsistently with 'EnumCaseElementSyntax.RawValue', which has the same type ('InitializerClauseSyntax')" + ), + ValidationFailure( + node: .matchingPatternCondition, + message: "child 'Initializer' is named inconsistently with 'EnumCaseElementSyntax.RawValue', which has the same type ('InitializerClauseSyntax')" + ), + ValidationFailure( + node: .optionalBindingCondition, + message: "child 'Initializer' is named inconsistently with 'EnumCaseElementSyntax.RawValue', which has the same type ('InitializerClauseSyntax')" + ), + ValidationFailure( + node: .patternBinding, + message: "child 'Initializer' is named inconsistently with 'EnumCaseElementSyntax.RawValue', which has the same type ('InitializerClauseSyntax')" + ), + ValidationFailure( + node: .tupleTypeElement, + message: "child 'Initializer' is named inconsistently with 'EnumCaseElementSyntax.RawValue', which has the same type ('InitializerClauseSyntax')" + ), + ValidationFailure( + node: .multipleTrailingClosureElement, + message: "child 'Closure' is named inconsistently with 'FunctionCallExprSyntax.TrailingClosure', which has the same type ('ClosureExprSyntax')" + ), + ValidationFailure( + node: .subscriptDecl, + message: "child 'Result' is named inconsistently with 'ClosureSignatureSyntax.Output', which has the same type ('ReturnClauseSyntax')" + ), + ValidationFailure( + node: .canImportVersionInfo, + message: + "child 'VersionTuple' is named inconsistently with 'AvailabilityVersionRestrictionSyntax.Version', which has the same type ('VersionTupleSyntax')" + ), + ValidationFailure( + node: .exposeAttributeArguments, + message: + "child 'CxxName' is named inconsistently with 'ConventionAttributeArgumentsSyntax.CTypeString', which has the same type ('StringLiteralExprSyntax')" + ), + ValidationFailure( + node: .opaqueReturnTypeOfAttributeArguments, + message: + "child 'MangledName' is named inconsistently with 'ConventionAttributeArgumentsSyntax.CTypeString', which has the same type ('StringLiteralExprSyntax')" + ), + ValidationFailure( + node: .originallyDefinedInArguments, + message: + "child 'ModuleName' is named inconsistently with 'ConventionAttributeArgumentsSyntax.CTypeString', which has the same type ('StringLiteralExprSyntax')" + ), + ValidationFailure( + node: .poundSourceLocationArgs, + message: + "child 'FileName' is named inconsistently with 'ConventionAttributeArgumentsSyntax.CTypeString', which has the same type ('StringLiteralExprSyntax')" + ), + ValidationFailure( + node: .unavailableFromAsyncArguments, + message: + "child 'Message' is named inconsistently with 'ConventionAttributeArgumentsSyntax.CTypeString', which has the same type ('StringLiteralExprSyntax')" + ), + ValidationFailure( + node: .underscorePrivateAttributeArguments, + message: + "child 'Filename' is named inconsistently with 'ConventionAttributeArgumentsSyntax.CTypeString', which has the same type ('StringLiteralExprSyntax')" + ), + ] + ) + } + + func testAllNodesThatCanConformToATraitDo() { + var failures: [ValidationFailure] = [] + + for node in SYNTAX_NODES.compactMap(\.layoutNode) { + for trait in TRAITS { + let canConformToTrait = trait.children.allSatisfy { traitChild in + node.children.contains { nodeChild in + traitChild.hasSameType(as: nodeChild) + } + } + if canConformToTrait && !node.traits.contains(trait.traitName) { + failures.append( + ValidationFailure( + node: node.kind, + message: "could conform to trait '\(trait.traitName)' but does not" + ) + ) + } + } + } + + assertFailuresMatchXFails( + failures, + expectedFailures: [ + ValidationFailure(node: .accessesEffect, message: "could conform to trait 'Parenthesized' but does not"), + ValidationFailure(node: .availabilityCondition, message: "could conform to trait 'Parenthesized' but does not"), + ValidationFailure(node: .canImportExpr, message: "could conform to trait 'Parenthesized' but does not"), + ValidationFailure(node: .differentiabilityParams, message: "could conform to trait 'Parenthesized' but does not"), + ValidationFailure(node: .editorPlaceholderExpr, message: "could conform to trait 'IdentifiedDecl' but does not"), + ValidationFailure(node: .enumCaseElement, message: "could conform to trait 'IdentifiedDecl' but does not"), + ValidationFailure(node: .initializesEffect, message: "could conform to trait 'Parenthesized' but does not"), + ValidationFailure(node: .precedenceGroupDecl, message: "could conform to trait 'Braced' but does not"), + ValidationFailure(node: .yieldList, message: "could conform to trait 'Parenthesized' but does not"), + ] + ) + } +} diff --git a/CodeGeneration/Tests/ValidateSyntaxNodes/ValidationFailure.swift b/CodeGeneration/Tests/ValidateSyntaxNodes/ValidationFailure.swift new file mode 100644 index 00000000000..bee8c7d41d1 --- /dev/null +++ b/CodeGeneration/Tests/ValidateSyntaxNodes/ValidationFailure.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// 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 SyntaxSupport + +struct ValidationFailure: Equatable { + /// The node that failed verification + let node: SyntaxNodeKind + + /// A human-readable description of the validation failure + let message: String +}