diff --git a/src/SwiftAPIViewCore.xctestplan b/src/SwiftAPIViewCore.xctestplan new file mode 100644 index 00000000000..711a1c46314 --- /dev/null +++ b/src/SwiftAPIViewCore.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "48782905-B2E9-45DE-B537-392AF13BD2AC", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:SwiftAPIViewCore.xcodeproj", + "identifier" : "0A8469E827879AE200C967A8", + "name" : "SwiftAPIViewCoreTests" + } + } + ], + "version" : 1 +} diff --git a/src/swift/CHANGELOG.md b/src/swift/CHANGELOG.md index 32a92edae82..0d2b53a175f 100644 --- a/src/swift/CHANGELOG.md +++ b/src/swift/CHANGELOG.md @@ -1,5 +1,8 @@ # Release History +## Version 0.3.0 (Unreleased) +- Convert Swift APIView to use the new tree-token syntax. + ## Version 0.2.2 (Unreleased) - Fix issue where extension members were duplicated. diff --git a/src/swift/SwiftAPIView.xcworkspace/contents.xcworkspacedata b/src/swift/SwiftAPIView.xcworkspace/contents.xcworkspacedata index a80b0700a9c..f68d995b497 100644 --- a/src/swift/SwiftAPIView.xcworkspace/contents.xcworkspacedata +++ b/src/swift/SwiftAPIView.xcworkspace/contents.xcworkspacedata @@ -16,4 +16,7 @@ + + diff --git a/src/swift/SwiftAPIView/SwiftAPIView.xcodeproj/project.pbxproj b/src/swift/SwiftAPIView/SwiftAPIView.xcodeproj/project.pbxproj index b9e62f3d4f7..97e4cf44b16 100644 --- a/src/swift/SwiftAPIView/SwiftAPIView.xcodeproj/project.pbxproj +++ b/src/swift/SwiftAPIView/SwiftAPIView.xcodeproj/project.pbxproj @@ -291,7 +291,7 @@ ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = "$(SRCROOT)/Info.plist"; MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 0.2.2; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.microsoft.SwiftAPIView; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -307,7 +307,7 @@ ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = "$(SRCROOT)/Info.plist"; MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 0.2.2; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.microsoft.SwiftAPIView; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/src/swift/SwiftAPIViewCore/Sources/APIViewManager.swift b/src/swift/SwiftAPIViewCore/Sources/APIViewManager.swift index b480d75962c..a29874310bc 100644 --- a/src/swift/SwiftAPIViewCore/Sources/APIViewManager.swift +++ b/src/swift/SwiftAPIViewCore/Sources/APIViewManager.swift @@ -66,12 +66,15 @@ public class APIViewManager: SyntaxVisitor { var mode: APIViewManagerMode + var model: CodeModel? + var statements = OrderedDictionary() // MARK: Initializer public init(mode: APIViewManagerMode = .commandLine) { self.mode = mode + self.model = nil super.init(viewMode: .all) } @@ -84,19 +87,18 @@ public class APIViewManager: SyntaxVisitor { guard let sourceUrl = URL(string: config.sourcePath) else { SharedLogger.fail("usage error: source path was invalid.") } - let apiView = try createApiView(from: sourceUrl) - + model = try createApiView(from: sourceUrl) switch mode { case .commandLine: - save(apiView: apiView) + save(apiView: model!) return "" case .testing: - return apiView.text + return model!.text } } /// Persist the token file to disk - func save(apiView: APIViewModel) { + func save(apiView: CodeModel) { let destUrl: URL if let destPath = config.destPath { destUrl = URL(fileURLWithPath: destPath) @@ -189,7 +191,7 @@ public class APIViewManager: SyntaxVisitor { return filePaths } - func createApiView(from sourceUrl: URL) throws -> APIViewModel { + func createApiView(from sourceUrl: URL) throws -> CodeModel { SharedLogger.debug("URL: \(sourceUrl.absoluteString)") var packageName: String? var packageVersion: String? @@ -232,8 +234,7 @@ public class APIViewManager: SyntaxVisitor { } config.packageName = packageName! config.packageVersion = packageVersion! - let apiViewName = "\(packageName!) (version \(packageVersion!))" - let apiView = APIViewModel(name: apiViewName, packageName: packageName!, versionString: packageVersion!, statements: Array(statements.values)) + let apiView = CodeModel(packageName: packageName!, packageVersion: packageVersion!, statements: Array(statements.values)) return apiView } diff --git a/src/swift/SwiftAPIViewCore/Sources/Models/APIViewModel.swift b/src/swift/SwiftAPIViewCore/Sources/Models/APIViewModel.swift deleted file mode 100644 index d07d37fb276..00000000000 --- a/src/swift/SwiftAPIViewCore/Sources/Models/APIViewModel.swift +++ /dev/null @@ -1,380 +0,0 @@ -// -------------------------------------------------------------------------- -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// -// The MIT License (MIT) -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the ""Software""), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -// IN THE SOFTWARE. -// -// -------------------------------------------------------------------------- - -import Foundation -import SwiftSyntax - - -class APIViewModel: Tokenizable, Encodable { - - /// The name to be used in the APIView review list - var name: String - - /// The package name used by APIView - var packageName: String - - /// The version string - var versionString: String - - /// Language string. - let language = "Swift" - - /// The APIView tokens to display - var tokens: [Token] - - /// The navigation tokens to display - var navigation: [NavigationToken] - - /// Node-based representation of the Swift package - var model: PackageModel - - /// Current indentation level - private var indentLevel = 0 - - /// Whether indentation is needed - private var needsIndent = false - - /// Number of spaces to indent per level - let indentSpaces = 4 - - /// Access modifier to expose via APIView - static let publicModifiers: [AccessLevel] = [.public, .open] - - /// sentinel value for unresolved type references - static let unresolved = "__UNRESOLVED__" - - /// Tracks assigned definition IDs so they can be linked - private var definitionIds = Set() - - /// Returns the text-based representation of all tokens - var text: String { - return tokens.map { $0.text }.joined() - } - - // MARK: Initializers - - init(name: String, packageName: String, versionString: String, statements: [CodeBlockItemSyntax.Item]) { - self.name = name - self.versionString = versionString - self.packageName = packageName - navigation = [NavigationToken]() - tokens = [Token]() - model = PackageModel(name: packageName, statements: statements) - self.tokenize(apiview: self, parent: nil) - model.navigationTokenize(apiview: self, parent: nil) - } - - // MARK: Codable - - enum CodingKeys: String, CodingKey { - case name = "Name" - case tokens = "Tokens" - case language = "Language" - case packageName = "PackageName" - case navigation = "Navigation" - case versionString = "VersionString" - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(packageName, forKey: .packageName) - try container.encode(language, forKey: .language) - try container.encode(tokens, forKey: .tokens) - try container.encode(navigation, forKey: .navigation) - try container.encode(versionString, forKey: .versionString) - } - - func tokenize(apiview a: APIViewModel, parent: Linkable?) { - // Renders the APIView "preamble" - let bundle = Bundle(for: Swift.type(of: self)) - let versionKey = "CFBundleShortVersionString" - let apiViewVersion = bundle.object(forInfoDictionaryKey: versionKey) as? String ?? "Unknown" - a.text("Package parsed using Swift APIView (version \(apiViewVersion))") - a.newline() - a.blankLines(set: 2) - model.tokenize(apiview: a, parent: parent) - } - - // MARK: Token Emitters - func add(token: Token) { - self.tokens.append(token) - } - - func add(token: NavigationToken) { - self.navigation.append(token) - } - - func text(_ text: String, definitionId: String? = nil) { - checkIndent() - let item = Token(definitionId: definitionId, navigateToId: nil, value: text, kind: .text) - // TODO: Add cross-language definition ID - // if add_cross_language_id: - // token.cross_language_definition_id = self.metadata_map.cross_language_map.get(id, None) - add(token: item) - } - - /// Used to END a line and wrap to the next. Cannot be used to inject blank lines. - func newline() { - // strip trailing whitespace token, except blank lines - if tokens.last?.kind == .whitespace { - let popped = tokens.popLast()! - // lines that consist only of whitespace must be preserved - if tokens.last?.kind == .newline { - add(token: popped) - } - } - checkIndent() - // don't add newline if one already in place - if tokens.last?.kind != .newline { - let item = Token(definitionId: nil, navigateToId: nil, value: nil, kind: .newline) - add(token: item) - } - needsIndent = true - } - - /// Ensures a specific number of blank lines. Will add or remove newline - /// tokens as needed to ensure the exact number of blank lines. - func blankLines(set count: Int) { - // count the number of trailing newlines - var newlineCount = 0 - for token in self.tokens.reversed() { - if token.kind == .newline { - newlineCount += 1 - } else { - break - } - } - if newlineCount < (count + 1) { - // if not enough newlines, add some - let linesToAdd = (count + 1) - newlineCount - for _ in 0.. (count + 1) { - // if there are too many newlines, remove some - let linesToRemove = newlineCount - (count + 1) - for _ in 0.. DeclarationModel? { - guard let item = item else { return nil } - switch item.kind { - case .method: - // look to the parent of the function - return findNonFunctionParent(from: (item.parent as? DeclarationModel)) - default: - return item - } - } - - /// Link to a registered type - func typeReference(name: String, parent: DeclarationModel?) { - checkIndent() - if name == "IncomingAudioStream" { - let test = "best" - } - let linkId = definitionId(for: name, withParent: parent) ?? APIViewModel.unresolved - let item = Token(definitionId: nil, navigateToId: linkId, value: name, kind: .typeName) - add(token: item) - } - - func definitionId(for val: String, withParent parent: DeclarationModel?) -> String? { - var matchVal = val - if !matchVal.contains("."), let parentObject = findNonFunctionParent(from: parent), let parentDefId = parentObject.definitionId { - // if a plain, undotted name is provided, try to append the parent prefix - matchVal = "\(parentDefId).\(matchVal)" - } - let matches: [String] - if matchVal.contains(".") { - matches = definitionIds.filter { $0.hasSuffix(matchVal) } - } else { - // if type does not contain a dot, then suffix is insufficient - // we must completely match the final segment of the type name - matches = definitionIds.filter { $0.split(separator: ".").last! == matchVal } - } - if matches.count > 1 { - SharedLogger.warn("Found \(matches.count) matches for \(matchVal). Using \(matches.first!). Swift APIView may not link correctly.") - } - return matches.first - } - - func member(name: String, definitionId: String? = nil) { - checkIndent() - let item = Token(definitionId: definitionId, navigateToId: nil, value: name, kind: .memberName) - add(token: item) - } - - // TODO: Add support for diagnostics -// func diagnostic(self, text, line_id): -// self.diagnostics.append(Diagnostic(line_id, text)) - - func comment(_ text: String) { - checkIndent() - var message = text - if !text.starts(with: "\\") { - message = "\\\\ \(message)" - } - let item = Token(definitionId: nil, navigateToId: nil, value: message, kind: .comment) - add(token: item) - } - - func literal(_ value: String) { - let item = Token(definitionId: nil, navigateToId: nil, value: value, kind: .literal) - add(token: item) - } - - func stringLiteral(_ text: String) { - let item = Token(definitionId: nil, navigateToId: nil, value: "\"\(text)\"", kind: .stringLiteral) - add(token: item) - } - - /// Wraps code in an indentation - func indent(_ indentedCode: () -> Void) { - indentLevel += 1 - indentedCode() - // Don't end an indentation block with blank lines - let tokenSuffix = Array(tokens.suffix(2)) - if tokenSuffix.count == 2 && tokenSuffix[0].kind == .newline && tokenSuffix[1].kind == .newline { - _ = tokens.popLast() - } - indentLevel -= 1 - } - - /// Checks if indentation is needed and adds whitespace as needed - func checkIndent() { - guard needsIndent else { return } - whitespace(count: indentLevel * indentSpaces) - needsIndent = false - } - - /// Constructs a definition ID and ensures it is unique. - func defId(forName name: String, withPrefix prefix: String?) -> String { - var defId = prefix != nil ? "\(prefix!).\(name)" : name - defId = defId.filter { !$0.isWhitespace } - if self.definitionIds.contains(defId) { - SharedLogger.warn("Duplicate definition ID: \(defId). APIView will display duplicate comments.") - } - definitionIds.insert(defId) - return defId - } - - /// Trims whitespace tokens - func trim(removeNewlines: Bool = false) { - var lineIds = [Token]() - while (!tokens.isEmpty) { - var continueTrim = true - if let kind = tokens.last?.kind { - switch kind { - case .whitespace: - _ = tokens.popLast() - case .newline: - if removeNewlines { - _ = tokens.popLast() - } else { - continueTrim = false - } - case .lineIdMarker: - if let popped = tokens.popLast() { - lineIds.append(popped) - } - default: - continueTrim = false - } - } - if !continueTrim { - break - } - } - // reappend the line id tokens - while (!lineIds.isEmpty) { - if let popped = lineIds.popLast() { - tokens.append(popped) - } - } - } -} diff --git a/src/swift/SwiftAPIViewCore/Sources/Models/CodeDiagnostic.swift b/src/swift/SwiftAPIViewCore/Sources/Models/CodeDiagnostic.swift new file mode 100644 index 00000000000..88eccda6c30 --- /dev/null +++ b/src/swift/SwiftAPIViewCore/Sources/Models/CodeDiagnostic.swift @@ -0,0 +1,82 @@ +// -------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// The MIT License (MIT) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the ""Software""), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// +// -------------------------------------------------------------------------- + +import Foundation +import SwiftSyntax + +enum CodeDiagnosticLevel: Int { + case Info = 1 + case Warning = 2 + case Error = 3 + /// Fatal level diagnostic will block API review approval and it will show an error message to the user. Approver will have to + /// override fatal level system comments before approving a review. + case Fatal = 4 +} + +class CodeDiagnostic: Tokenizable, Encodable { + /// The diagnostic ID...? + var diagnosticId: String? + /// Id of ReviewLine object where this diagnostic needs to be displayed + var targetId: String + /// Auto generated system comment to be displayed under targeted line. + var text: String + var level: CodeDiagnosticLevel + var helpLinkUri: String? + + init(_ text: String, targetId: String, level: CodeDiagnosticLevel, helpLink: String?) { + self.text = text + self.targetId = targetId + self.level = level + self.helpLinkUri = helpLink + // FIXME: What is this for? + self.diagnosticId = nil + } + + // MARK: Codable + + enum CodingKeys: String, CodingKey { + case diagnosticId = "DiagnosticId" + case targetId = "TargetId" + case text = "Text" + case level = "Level" + case helpLinkUri = "HelpLinkUri" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(targetId, forKey: .targetId) + try container.encode(text, forKey: .text) + try container.encode(level.rawValue, forKey: .level) + try container.encodeIfPresent(helpLinkUri, forKey: .helpLinkUri) + try container.encodeIfPresent(diagnosticId, forKey: .diagnosticId) + } + + // MARK: Tokenizable + + func tokenize(apiview: CodeModel, parent: (any Linkable)?) { + fatalError("Not implemented!") + } +} diff --git a/src/swift/SwiftAPIViewCore/Sources/Models/CodeModel.swift b/src/swift/SwiftAPIViewCore/Sources/Models/CodeModel.swift new file mode 100644 index 00000000000..dc595a63074 --- /dev/null +++ b/src/swift/SwiftAPIViewCore/Sources/Models/CodeModel.swift @@ -0,0 +1,396 @@ +// -------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// The MIT License (MIT) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the ""Software""), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// +// -------------------------------------------------------------------------- + +import Foundation +import SwiftSyntax + + +class CodeModel: Tokenizable, Encodable { + + /// The package name used by APIView + var packageName: String + + /// The package version + var packageVersion: String + + /// Version of the APIView language parser used to create the token file + let parserVersion: String + + /// Language discriminator + let language = "Swift" + + /// Only applicable currently to Java language variants + let languageVariant: String? = nil + + /// The cross-language ID of the package + let crossLanguagePackageId: String? + + /// The top level review lines for the APIView + var reviewLines: [ReviewLine] + + /// System generated comments. Each is linked to a review line ID. + var diagnostics: [CodeDiagnostic]? + + /// Node-based representation of the Swift package + private var model: PackageModel + + /// Access modifier to expose via APIView + static let publicModifiers: [AccessLevel] = [.public, .open] + + /// sentinel value for unresolved type references + static let unresolved = "__UNRESOLVED__" + + /// sentinel value for missing IDs + static let missingId = "__MISSING__" + + /// Tracks assigned definition IDs so they can be linked + private var definitionIds = Set() + + /// Stores the current line. All helper methods append to this. + internal var currentLine: ReviewLine + + /// Stores the parent of the current line. + internal var currentParent: ReviewLine? = nil + + /// Stores the stack of parent lines + private var parentStack: [ReviewLine] = [] + + /// Used to track the currently processed namespace. + private var namespaceStack = NamespaceStack() + + /// Returns the text-based representation of all lines + var text: String { + var value = "" + for line in reviewLines { + let lineText = line.text() + value += lineText + } + return value + } + + // MARK: Initializers + + init(packageName: String, packageVersion: String, statements: [CodeBlockItemSyntax.Item]) { + // Renders the APIView "preamble" + let bundle = Bundle(for: Swift.type(of: self)) + let versionKey = "CFBundleShortVersionString" + self.packageVersion = packageVersion + self.packageName = packageName + self.parserVersion = bundle.object(forInfoDictionaryKey: versionKey) as? String ?? "Unknown" + self.currentLine = ReviewLine() + reviewLines = [ReviewLine]() + diagnostics = [CodeDiagnostic]() + model = PackageModel(name: packageName, statements: statements) + // FIXME: Actually wire this up! + self.crossLanguagePackageId = nil + self.tokenize(apiview: self, parent: nil) + } + + // MARK: Codable + + enum CodingKeys: String, CodingKey { + case packageName = "PackageName" + case packageVersion = "PackageVersion" + case parserVersion = "ParserVersion" + case language = "Language" + case languageVariant = "LanguageVariant" + case crossLanguagePackageId = "CrossLanguagePackageId" + case reviewLines = "ReviewLines" + case diagnostics = "Diagnostics" + case navigation = "Navigation" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(packageName, forKey: .packageName) + try container.encode(packageVersion, forKey: .packageVersion) + try container.encode(parserVersion, forKey: .parserVersion) + try container.encode(language, forKey: .language) + try container.encode(languageVariant, forKey: .languageVariant) + try container.encode(reviewLines, forKey: .reviewLines) + try container.encodeIfPresent(crossLanguagePackageId, forKey: .crossLanguagePackageId) + try container.encodeIfPresent(diagnostics, forKey: .diagnostics) + } + + func tokenize(apiview a: CodeModel, parent: Linkable?) { + self.text("Package parsed using Swift APIView (version \(self.parserVersion))") + self.newline() + self.blankLines(set: 2) + model.tokenize(apiview: a, parent: parent) + } + + // MARK: Token Emitters + func token(kind: TokenKind, value: String, options: ReviewTokenOptions? = nil) { + var options = options ?? ReviewTokenOptions() + // workaround the silly server default + options.hasSuffixSpace = options.hasSuffixSpace != nil ? options.hasSuffixSpace : false + let token = ReviewToken(kind: kind, value: value, options: options) + self.currentLine.tokens.append(token) + return + } + + func token(_ token: ReviewToken) { + self.currentLine.tokens.append(token) + return + } + + func text(_ text: String, options: ReviewTokenOptions? = nil) { + self.token(kind: .text, value: text, options: options) + } + + + /// Ensure that `currentLine` has no leading or trailing space + func trimBeginningAndEnd() { + if let firstToken = currentLine.tokens.first { + firstToken.hasPrefixSpace = false + } + + if let lastToken = currentLine.tokens.last { + lastToken.hasSuffixSpace = false + } + } + + func newline() { + trimBeginningAndEnd() + if let currentParent = self.currentParent { + currentParent.children.append(self.currentLine) + } else { + self.reviewLines.append(self.currentLine) + } + self.currentLine = ReviewLine() + } + + /// Set the exact number of desired newlines. + func blankLines(set count: Int) { + self.newline() + let parentLines = self.currentParent?.children ?? self.reviewLines + // count the number of trailing newlines + var newlineCount = 0 + for line in parentLines.reversed() { + if line.tokens.count == 0 { + newlineCount += 1 + } else { + break + } + } + if newlineCount == count { + return + } else if (newlineCount > count) { + // if there are too many newlines, remove some + let linesToRemove = newlineCount - count + for _ in 0.. 1 { + let token = ReviewToken(kind: .stringLiteral, value: "\u{0022}\(text)\u{0022}", options: options) + self.token(token) + } else { + self.punctuation("\u{0022}\u{0022}\u{0022}", options: options) + self.newline() + for line in lines { + self.literal(String(line), options: options) + self.newline() + } + self.punctuation("\u{0022}\u{0022}\u{0022}", options: options) + } + } + + /// Wraps code in indentattion + func indent(_ indentedCode: () -> Void) { + trimBeginningAndEnd() + if let currentParent = self.currentParent { + currentParent.children.append(self.currentLine) + self.parentStack.append(currentParent) + } else { + self.reviewLines.append(self.currentLine) + } + self.currentParent = self.currentLine + self.currentLine = ReviewLine() + + // handle the indented bodies + indentedCode() + + // handle the de-indent logic + guard let currentParent = self.currentParent else { + fatalError("Cannot de-indent without a parent") + } + // ensure that the last line before the deindent has no blank lines + if let lastChild = currentParent.children.popLast() { + if (lastChild.tokens.count > 0) { + currentParent.children.append(lastChild) + } + } + self.currentParent = self.parentStack.popLast() + self.currentLine = ReviewLine() + } + + /// Constructs a definition ID and ensures it is unique. + func defId(forName name: String, withPrefix prefix: String?) -> String { + var defId = prefix != nil ? "\(prefix!).\(name)" : name + defId = defId.filter { !$0.isWhitespace } + if self.definitionIds.contains(defId) { + SharedLogger.warn("Duplicate definition ID: \(defId). APIView will display duplicate comments.") + } + definitionIds.insert(defId) + return defId + } + + /// Places the provided token in the tree based on the provided characters. + func snap(token target: ReviewToken, to characters: String) { + let allowed = Set(characters) + let lastLine = self.getLastLine() ?? self.currentLine + + // iterate through tokens in reverse order + for token in lastLine.tokens.reversed() { + switch token.kind { + case .text: + // skip blank whitespace tokens + let value = token.value.trimmingTrailingCharacters(in: .whitespaces) + if value.count == 0 { + continue + } else { + // no snapping, so render in place + self.token(target) + return + } + case .punctuation: + // ensure no whitespace after the trim character + if allowed.first(where: { String($0) == token.value }) != nil { + token.hasSuffixSpace = false + target.hasSuffixSpace = false + lastLine.tokens.append(target) + return + } else { + // no snapping, so render in place + self.token(target) + return + } + default: + // no snapping, so render in place + self.token(target) + return + } + } + } + + /// Retrieves the last line from the review + func getLastLine() -> ReviewLine? { + var current: ReviewLine? = nil + if let currentParent = self.currentParent { + current = currentParent.children.last + } else if let lastReviewLine = self.reviewLines.last { + current = lastReviewLine + } + if let lastChild = current?.children.last { + current = lastChild + } + return current + } +} diff --git a/src/swift/SwiftAPIViewCore/Sources/Models/DeclarationModel.swift b/src/swift/SwiftAPIViewCore/Sources/Models/DeclarationModel.swift index d83817d0098..7f55481eb6a 100644 --- a/src/swift/SwiftAPIViewCore/Sources/Models/DeclarationModel.swift +++ b/src/swift/SwiftAPIViewCore/Sources/Models/DeclarationModel.swift @@ -122,6 +122,7 @@ class DeclarationModel: Tokenizable, Linkable, Equatable { /// Used for most declaration types that have members convenience init(from decl: SyntaxProtocol, parent: Linkable?) { let name = (decl as? hasIdentifier)!.identifier.withoutTrivia().text + let defId = identifier(forName: name, withPrefix: parent?.definitionId) self.init(name: name, decl: decl, defId: defId, parent: parent, kind: .struct) } @@ -147,18 +148,29 @@ class DeclarationModel: Tokenizable, Linkable, Equatable { self.extensions = [] } - func tokenize(apiview a: APIViewModel, parent: Linkable?) { - for child in childNodes { + func tokenize(apiview a: CodeModel, parent: Linkable?) { + for (idx, child) in childNodes.enumerated() { switch child.kind { case .attributeList: + // Ensure declarations that have an attributeList have a blank line, + // unless it is the first child + a.blankLines(set: a.currentParent?.children.count ?? 0 == 0 ? 0 : 1) // attributes on declarations should have newlines let obj = AttributeListSyntax(child)! let children = obj.children(viewMode: .sourceAccurate) for attr in children { let attrText = attr.withoutTrivia().description.filter { !$0.isWhitespace } - a.lineIdMarker(definitionId: "\(definitionId!).\(attrText)") + a.lineMarker("\(definitionId!).\(attrText)") + a.currentLine.relatedToLine = definitionId attr.tokenize(apiview: a, parent: parent) - a.newline() + a.blankLines(set: 0) + } + case .precedenceGroupAttributeList: + let obj = PrecedenceGroupAttributeListSyntax(child)! + a.indent { + for item in obj.children(viewMode: .sourceAccurate) { + item.tokenize(apiview: a, parent: nil) + } a.blankLines(set: 0) } case .token: @@ -166,21 +178,23 @@ class DeclarationModel: Tokenizable, Linkable, Equatable { // render as the correct APIView token type switch self.kind { case .class: - a.typeDeclaration(name: name, definitionId: definitionId) + a.typeDeclaration(name: name, typeId: definitionId) case .enum: - a.typeDeclaration(name: name, definitionId: definitionId) + a.typeDeclaration(name: name, typeId: definitionId) case .method: - a.member(name: name, definitionId: definitionId) + a.lineMarker(definitionId) + a.member(name: name) case .package: - a.typeDeclaration(name: name, definitionId: definitionId) + a.typeDeclaration(name: name, typeId: definitionId) case .protocol: - a.typeDeclaration(name: name, definitionId: definitionId) + a.typeDeclaration(name: name, typeId: definitionId) case .struct: - a.typeDeclaration(name: name, definitionId: definitionId) + a.typeDeclaration(name: name, typeId: definitionId) case .unknown: - a.typeDeclaration(name: name, definitionId: definitionId) + a.typeDeclaration(name: name, typeId: definitionId) case .variable: - a.member(name: name, definitionId: definitionId) + a.lineMarker(definitionId) + a.member(name: name) } } else { child.tokenize(apiview: a, parent: nil) @@ -191,21 +205,12 @@ class DeclarationModel: Tokenizable, Linkable, Equatable { } } if !extensions.isEmpty { - a.blankLines(set: 1) - for ext in extensions { - ext.tokenize(apiview: a, parent: self) - a.blankLines(set: 1) - } + extensions.tokenize(apiview: a, parent: self) } } - func navigationTokenize(apiview a: APIViewModel, parent: Linkable?) { - let navigationId = parent != nil ? "\(parent!.name).\(name)" : name - a.add(token: NavigationToken(name: name, navigationId: navigationId, typeKind: kind.navigationSymbol, members: [])) - } - func shouldShow() -> Bool { - let publicModifiers = APIViewModel.publicModifiers + let publicModifiers = CodeModel.publicModifiers guard let parentDecl = (parent as? DeclarationModel) else { return publicModifiers.contains(self.accessLevel) } diff --git a/src/swift/SwiftAPIViewCore/Sources/Models/ExtensionModel.swift b/src/swift/SwiftAPIViewCore/Sources/Models/ExtensionModel.swift index 71943cc6ec9..76c83f36b46 100644 --- a/src/swift/SwiftAPIViewCore/Sources/Models/ExtensionModel.swift +++ b/src/swift/SwiftAPIViewCore/Sources/Models/ExtensionModel.swift @@ -28,13 +28,15 @@ import Foundation import SwiftSyntax -class ExtensionModel: Tokenizable { +class ExtensionModel: Tokenizable, Linkable { var accessLevel: AccessLevel var extendedType: String - var definitionId: String + var name: String + var definitionId: String? + // treat extensions as if they have no parents + var parent: Linkable? = nil var members: [DeclarationModel] let childNodes: SyntaxChildren - var parent: Linkable? private let decl: ExtensionDeclSyntax /// Initialize from initializer declaration @@ -53,8 +55,8 @@ class ExtensionModel: Tokenizable { } self.members = [DeclarationModel]() self.decl = decl - self.definitionId = "" - self.parent = nil + self.definitionId = nil + self.name = "" } private func identifier(for decl: ExtensionDeclSyntax) -> String { @@ -71,91 +73,93 @@ class ExtensionModel: Tokenizable { return defId } - func processMembers(withParent parent: Linkable?) { - self.parent = parent + func processMembers() { self.definitionId = identifier(for: decl) for member in decl.members.members { let decl = member.decl switch decl.kind { case .actorDecl: - appendIfVisible(DeclarationModel(from: ActorDeclSyntax(decl)!, parent: parent)) + appendIfVisible(DeclarationModel(from: ActorDeclSyntax(decl)!, parent: self)) case .classDecl: - appendIfVisible(DeclarationModel(from: ClassDeclSyntax(decl)!, parent: parent)) + appendIfVisible(DeclarationModel(from: ClassDeclSyntax(decl)!, parent: self)) case .deinitializerDecl: // deinitializers cannot be called by users, so it makes no sense // to expose them in APIView break case .enumDecl: - appendIfVisible(DeclarationModel(from: EnumDeclSyntax(decl)!, parent: parent)) + appendIfVisible(DeclarationModel(from: EnumDeclSyntax(decl)!, parent: self)) case .functionDecl: - appendIfVisible(DeclarationModel(from: FunctionDeclSyntax(decl)!, parent: parent)) + appendIfVisible(DeclarationModel(from: FunctionDeclSyntax(decl)!, parent: self)) case .importDecl: // purposely ignore import declarations break case .operatorDecl: - let model = DeclarationModel(from: OperatorDeclSyntax(decl)!, parent: parent) + let model = DeclarationModel(from: OperatorDeclSyntax(decl)!, parent: self) // operators are global and must always be displayed model.accessLevel = .public appendIfVisible(model) case .precedenceGroupDecl: - let model = DeclarationModel(from: PrecedenceGroupDeclSyntax(decl)!, parent: parent) + let model = DeclarationModel(from: PrecedenceGroupDeclSyntax(decl)!, parent: self) // precedence groups are public and must always be displayed model.accessLevel = .public appendIfVisible(model) case .protocolDecl: - appendIfVisible(DeclarationModel(from: ProtocolDeclSyntax(decl)!, parent: parent)) + appendIfVisible(DeclarationModel(from: ProtocolDeclSyntax(decl)!, parent: self)) case .structDecl: - appendIfVisible(DeclarationModel(from: StructDeclSyntax(decl)!, parent: parent)) + appendIfVisible(DeclarationModel(from: StructDeclSyntax(decl)!, parent: self)) case .typealiasDecl: - appendIfVisible(DeclarationModel(from: TypealiasDeclSyntax(decl)!, parent: parent)) + appendIfVisible(DeclarationModel(from: TypealiasDeclSyntax(decl)!, parent: self)) case .extensionDecl: SharedLogger.warn("Extensions containing extensions is not supported. Contact the Swift APIView team.") case .initializerDecl: - appendIfVisible(DeclarationModel(from: InitializerDeclSyntax(decl)!, parent: parent)) + appendIfVisible(DeclarationModel(from: InitializerDeclSyntax(decl)!, parent: self)) case .subscriptDecl: - appendIfVisible(DeclarationModel(from: SubscriptDeclSyntax(decl)!, parent: parent)) + appendIfVisible(DeclarationModel(from: SubscriptDeclSyntax(decl)!, parent: self)) case .variableDecl: - appendIfVisible(DeclarationModel(from: VariableDeclSyntax(decl)!, parent: parent)) + appendIfVisible(DeclarationModel(from: VariableDeclSyntax(decl)!, parent: self)) default: // Create an generic declaration model of unknown type - appendIfVisible(DeclarationModel(from: decl, parent: parent)) + appendIfVisible(DeclarationModel(from: decl, parent: self)) } } } func appendIfVisible(_ decl: DeclarationModel) { - let publicModifiers = APIViewModel.publicModifiers + let publicModifiers = CodeModel.publicModifiers if publicModifiers.contains(decl.accessLevel) || publicModifiers.contains(self.accessLevel) { self.members.append(decl) } } - func tokenize(apiview a: APIViewModel, parent: Linkable?) { + func tokenize(apiview a: CodeModel, parent: Linkable?) { for child in childNodes { + var options = ReviewTokenOptions() let childIdx = child.indexInParent - if childIdx == 13 { + if childIdx == 7 { + child.tokenize(apiview: a, parent: parent) + if let last = a.currentLine.tokens.popLast() { + // These are made as type references, but they should be + // type declarations + a.currentLine.lineId = self.definitionId + last.navigateToId = self.definitionId + a.currentLine.tokens.append(last) + } + } else if childIdx == 13 { // special case for extension members - a.punctuation("{", spacing: SwiftSyntax.TokenKind.leftBrace.spacing) + options.applySpacing(SwiftSyntax.TokenKind.leftBrace.spacing) + a.punctuation("{", options: options) if !members.isEmpty { a.indent { for member in members { - a.newline() member.tokenize(apiview: a, parent: parent) + a.blankLines(set: 0) } } } + a.blankLines(set: 0) + options.applySpacing(SwiftSyntax.TokenKind.rightBrace.spacing) + a.punctuation("}", options: options) a.newline() - a.punctuation("}", spacing: SwiftSyntax.TokenKind.rightBrace.spacing) - a.newline() - } else if childIdx == 7 { - child.tokenize(apiview: a, parent: parent) - if var last = a.tokens.popLast() { - // These are made as type references, but they should be - // type declarations - last.definitionId = self.definitionId - last.navigateToId = self.definitionId - a.tokens.append(last) - } } else { child.tokenize(apiview: a, parent: parent) } @@ -164,10 +168,24 @@ class ExtensionModel: Tokenizable { } extension Array { + func tokenize(apiview a: CodeModel, parent: Linkable?) { + a.blankLines(set: 1) + let lastIdx = self.count - 1 + for (idx, ext) in self.enumerated() { + ext.tokenize(apiview: a, parent: parent) + if idx != lastIdx { + a.blankLines(set: 1) + } + } + } + func resolveDuplicates() -> [ExtensionModel] { var resolved = [String: ExtensionModel]() for ext in self { - if let match = resolved[ext.definitionId] { + guard let defId = ext.definitionId else { + fatalError("No definition ID found for extension!") + } + if let match = resolved[defId] { let resolvedMembers = Dictionary(uniqueKeysWithValues: match.members.map { ($0.definitionId, $0) } ) for member in ext.members { if resolvedMembers[member.definitionId] != nil { @@ -177,9 +195,9 @@ extension Array { } } } else { - resolved[ext.definitionId] = ext + resolved[defId] = ext } } - return Array(resolved.values).sorted(by: {$0.definitionId < $1.definitionId }) + return Array(resolved.values).sorted(by: {$0.definitionId! < $1.definitionId! }) } } diff --git a/src/swift/SwiftAPIViewCore/Sources/Models/NamespaceStack.swift b/src/swift/SwiftAPIViewCore/Sources/Models/NamespaceStack.swift new file mode 100644 index 00000000000..0f597026da4 --- /dev/null +++ b/src/swift/SwiftAPIViewCore/Sources/Models/NamespaceStack.swift @@ -0,0 +1,53 @@ +// -------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// The MIT License (MIT) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the ""Software""), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// +// -------------------------------------------------------------------------- + +import Foundation + + +/// Simple structure to track namespaces +public struct NamespaceStack { + private var stack: [String] = [] + + /// Push a namespace segment onto the stack + mutating func push(_ val: String) { + self.stack.append(val) + } + + /// Remove the last namespace segment from the stack + mutating func pop() -> String? { + return self.stack.popLast() + } + + /// Get the fully qualified namespace + func value() -> String { + return self.stack.joined(separator: ".") + } + + /// Reset the namespace stack to empty + mutating func reset() { + self.stack = [] + } +} diff --git a/src/swift/SwiftAPIViewCore/Sources/Models/NavigationToken.swift b/src/swift/SwiftAPIViewCore/Sources/Models/NavigationToken.swift index f56dd270d3b..899afc3a556 100644 --- a/src/swift/SwiftAPIViewCore/Sources/Models/NavigationToken.swift +++ b/src/swift/SwiftAPIViewCore/Sources/Models/NavigationToken.swift @@ -55,7 +55,7 @@ class NavigationToken: Codable { self.navigationId = navigationId tags = NavigationTags(typeKind: typeKind) for ext in extensions { - let extNav = NavigationToken(name: ext.extendedType, navigationId: ext.definitionId, typeKind: .class, members: []) + let extNav = NavigationToken(name: ext.extendedType, navigationId: ext.definitionId!, typeKind: .class, members: []) childItems.append(extNav) } // sort the navigation elements by name diff --git a/src/swift/SwiftAPIViewCore/Sources/Models/PackageModel.swift b/src/swift/SwiftAPIViewCore/Sources/Models/PackageModel.swift index 27148b70de8..f57779b2689 100644 --- a/src/swift/SwiftAPIViewCore/Sources/Models/PackageModel.swift +++ b/src/swift/SwiftAPIViewCore/Sources/Models/PackageModel.swift @@ -101,13 +101,16 @@ class PackageModel: Tokenizable, Linkable { } } - func tokenize(apiview a: APIViewModel, parent: Linkable?) { - a.text("package") - a.whitespace() - a.text(name, definitionId: definitionId) - a.punctuation("{", spacing: SwiftSyntax.TokenKind.leftBrace.spacing) - a.newline() + func tokenize(apiview a: CodeModel, parent: Linkable?) { + var options = ReviewTokenOptions() + options.hasSuffixSpace = true + a.text("package", options: options) + a.lineMarker(definitionId) + a.text(name) + options.applySpacing(SwiftSyntax.TokenKind.leftBrace.spacing) + a.punctuation("{", options: options) a.indent { + a.blankLines(set: 0) for member in members { member.tokenize(apiview: a, parent: self) a.blankLines(set: 1) @@ -115,19 +118,13 @@ class PackageModel: Tokenizable, Linkable { // render any orphaned extensions if !extensions.isEmpty { a.comment("Non-package extensions") - a.newline() - let endIdx = extensions.count - 1 - for (idx, ext) in extensions.enumerated() { - ext.tokenize(apiview: a, parent: nil) - if idx != endIdx { - a.blankLines(set: 1) - } - } + extensions.tokenize(apiview: a, parent: nil) } } - a.punctuation("}", spacing: SwiftSyntax.TokenKind.rightBrace.spacing) - a.newline() - resolveTypeReferences(apiview: a) + a.blankLines(set: 0) + options.applySpacing(SwiftSyntax.TokenKind.rightBrace.spacing) + a.punctuation("}", options: options) + a.blankLines(set: 0) } /// Move extensions into the model representations for declared package types @@ -144,13 +141,13 @@ class PackageModel: Tokenizable, Linkable { self.extensions = otherExtensions // process orphaned extensions for ext in extensions { - ext.processMembers(withParent: nil) + ext.processMembers() } extensions = extensions.resolveDuplicates() // process all extensions associated with members for member in members { for ext in member.extensions { - ext.processMembers(withParent: ext.parent) + ext.processMembers() } member.extensions = member.extensions.resolveDuplicates() } @@ -172,15 +169,6 @@ class PackageModel: Tokenizable, Linkable { members = members.sorted(by: { $0.definitionId! < $1.definitionId! }) } - /// attempt to resolve type references that are declared after they are used - func resolveTypeReferences(apiview a: APIViewModel) { - for (idx, token) in a.tokens.enumerated() { - guard token.navigateToId == APIViewModel.unresolved else { continue } - a.tokens[idx].navigateToId = a.definitionId(for: token.value!, withParent: nil) - assert (a.tokens[idx].navigateToId != APIViewModel.unresolved) - } - } - func appendIfVisible(_ decl: DeclarationModel) { if decl.shouldShow() { members.append(decl) @@ -196,13 +184,4 @@ class PackageModel: Tokenizable, Linkable { } return result.first } - - func navigationTokenize(apiview a: APIViewModel, parent: Linkable?) { - let packageToken = NavigationToken(name: name, navigationId: name, typeKind: .assembly, members: members) - a.add(token: packageToken) - if !extensions.isEmpty { - let extensionsToken = NavigationToken(name: "Other Extensions", navigationId: "", typeKind: .assembly, extensions: extensions) - a.add(token: extensionsToken) - } - } } diff --git a/src/swift/SwiftAPIViewCore/Sources/Models/ReviewLine.swift b/src/swift/SwiftAPIViewCore/Sources/Models/ReviewLine.swift new file mode 100644 index 00000000000..703e2a4d9a5 --- /dev/null +++ b/src/swift/SwiftAPIViewCore/Sources/Models/ReviewLine.swift @@ -0,0 +1,110 @@ +// -------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// The MIT License (MIT) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the ""Software""), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// +// -------------------------------------------------------------------------- + +import Foundation +import SwiftSyntax + +class ReviewLine: Tokenizable, Encodable { + /// Required to support commenting on a line. Usually code line for documentation or just punctuation is not required + /// to have lineId. It is used to link comments and navigation to specific lines. It must be a unique value or logic bugs + /// will manifest, but must also be deterministic (i.e. it cannot just be a random GUID). + var lineId: String? + /// whereas `lineId` is typically specific to the target language, `crossLanguageId` is the TypeSpec-based form of ID + /// so that concepts can be linked across different languages. Like `lineId`, it must be unique and deterministic. + var crossLanguageId: String? + /// The list of tokens that forms this particular review line. + var tokens: [ReviewToken] = [] + /// Add any child lines as children. For e.g. all classes and namespace level methods are added as a children of a + /// namespace(module) level code line. Similarly all method level code lines are added as children of it's class + /// code line. Identation will automatically be applied to children. + var children: [ReviewLine] = [] + /// Flag the line as hidden so it will not be shown to architects by default. + var isHidden: Bool? + /// Identifies that this line completes the existing context, usually the immediately previous reviewLine. For example, + /// in a class defintion that uses curly braces, the context begins with the class definition line and the closing curly brace + /// will be flagged as `isContextEndLine: True`. + var isContextEndLine: Bool? + /// Apply to any sibling-level `reviewLines` to mark that they are part of a specific context with the + /// matching `lineId`. This is used for lines that may print above or within a context that are not indented. + /// The final line of a context does not need this set. Instead, it should set `isContextEndLine`. + var relatedToLine: String? + + /// Returns the text-based representation of all tokens + func text(indent: Int = 0) -> String { + let indentCount = 4 + let indentString = String(repeating: " ", count: indent) + if tokens.count == 0 && children.count == 0 { + return "\n" + } + var value = indentString + for token in tokens { + value += token.text(withPreview: value) + } + if tokens.count > 0 { + value += "\n" + } + let childrenLines = self.children.map { $0.text(indent: indent + indentCount) } + for line in childrenLines { + value += line + } + return value + } + + // MARK: Codable + + enum CodingKeys: String, CodingKey { + case lineId = "LineId" + case crossLanguageId = "CrossLanguageId" + case tokens = "Tokens" + case children = "Children" + case isHidden = "IsHidden" + case isContextEndLine = "IsContextEndLine" + case relatedToLine = "RelatedToLine" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(tokens, forKey: .tokens) + try container.encodeIfPresent(lineId, forKey: .lineId) + try container.encodeIfPresent(crossLanguageId, forKey: .crossLanguageId) + try container.encodeIfPresent(relatedToLine, forKey: .relatedToLine) + if (!children.isEmpty) { + try container.encode(children, forKey: .children) + } + if isHidden == true { + try container.encode(isHidden, forKey: .isHidden) + } + if isContextEndLine == true { + try container.encode(isContextEndLine, forKey: .isContextEndLine) + } + } + + // MARK: Tokenizable + + func tokenize(apiview: CodeModel, parent: (any Linkable)?) { + fatalError("Not implemented!") + } +} diff --git a/src/swift/SwiftAPIViewCore/Sources/Models/ReviewToken.swift b/src/swift/SwiftAPIViewCore/Sources/Models/ReviewToken.swift new file mode 100644 index 00000000000..ffbf1497153 --- /dev/null +++ b/src/swift/SwiftAPIViewCore/Sources/Models/ReviewToken.swift @@ -0,0 +1,147 @@ +// -------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// The MIT License (MIT) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the ""Software""), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// +// -------------------------------------------------------------------------- + +import Foundation + +/// Enum for token kind +enum TokenKind: Int, Codable { + /// plain text tokens for e.g documentation, namespace value, or attribute or decorator tokens. Most tokens will be text + case text = 0 + /// punctuation + case punctuation = 1 + /// language-specific keywords like `class` + case keyword = 2 + /// class definitions, base class token, parameter types etc + case typeName = 3 + /// method name tokens, member variable tokens + case memberName = 4 + /// metadata or string literals to show in API view + case stringLiteral = 5 + /// literals, for e.g. enum value or numerical constant literal or default value + case literal = 6 + /// Comment text within the code that's really a documentation. Few languages wants to show comments within + /// API review that's not tagged as documentation. + case comment = 7 +} + +/// An individual token item +class ReviewToken: Codable { + /// Text value + var value: String + /// Token kind + var kind: TokenKind + /// used to create a tree node in the navigation panel. Navigation nodes will be created only if this is set. + var navigationDisplayName: String? + /// navigate to the associated `lineId` when this token is clicked. (e.g. a param type which is class name in + /// the same package) + var navigateToId: String? + /// set to true if underlying token needs to be ignored from diff calculation. For e.g. package metadata or dependency versions + /// are usually excluded when comparing two revisions to avoid reporting them as API changes + var skipDiff: Bool? = false + /// set if API is marked as deprecated + var isDeprecated: Bool? = false + /// set to false if there is no suffix space required before next token. For e.g, punctuation right after method name + var hasSuffixSpace: Bool? = true + /// set to true if there is a prefix space required before current token. For e.g, space before token for = + var hasPrefixSpace: Bool? = false + /// set to true if current token is part of documentation + var isDocumentation: Bool? = false + /// Language-specific style css class names + var renderClasses: [String]? + + init(kind: TokenKind, value: String, options: ReviewTokenOptions?) { + self.value = value + self.kind = kind + self.navigationDisplayName = options?.navigationDisplayName + self.navigateToId = options?.navigateToId + self.skipDiff = options?.skipDiff + self.isDeprecated = options?.isDeprecated + self.isDocumentation = options?.isDocumentation + self.hasSuffixSpace = options?.hasSuffixSpace + self.hasPrefixSpace = options?.hasPrefixSpace + self.renderClasses = options?.renderClasses + } + + // MARK: Codable + + enum CodingKeys: String, CodingKey { + case value = "Value" + case kind = "Kind" + case navigationDisplayName = "NavigationDisplayName" + case navigateToId = "NavigateToId" + case skipDiff = "SkipDiff" + case isDeprecated = "IsDeprecated" + case hasSuffixSpace = "HasSuffixSpace" + case hasPrefixSpace = "HasPrefixSpace" + case isDocumentation = "IsDocumentation" + case renderClasses = "RenderClasses" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(value, forKey: .value) + try container.encode(kind.rawValue, forKey: .kind) + try container.encodeIfPresent(navigationDisplayName, forKey: .navigationDisplayName) + try container.encodeIfPresent(navigateToId, forKey: .navigateToId) + if skipDiff == true { + try container.encode(skipDiff, forKey: .skipDiff) + } + if isDeprecated == true { + try container.encode(isDeprecated, forKey: .isDeprecated) + } + if hasSuffixSpace == false { + try container.encode(hasSuffixSpace, forKey: .hasSuffixSpace) + } + if hasPrefixSpace == true { + try container.encode(hasPrefixSpace, forKey: .hasPrefixSpace) + } + if isDocumentation == true { + try container.encode(isDocumentation, forKey: .isDocumentation) + } + try container.encodeIfPresent(renderClasses, forKey: .renderClasses) + } + + func text(withPreview preview: String) -> String { + let previewEndsInSpace = preview.hasSuffix(" ") + let hasSuffixSpace = self.hasSuffixSpace != nil ? self.hasSuffixSpace! : true + let hasPrefixSpace = self.hasPrefixSpace != nil ? self.hasPrefixSpace! : false + let suffixSpace = hasSuffixSpace ? " " : "" + let prefixSpace = (hasPrefixSpace && !previewEndsInSpace) ? " " : "" + return "\(prefixSpace)\(value)\(suffixSpace)" + } +} + +extension Array { + var lastVisible: TokenKind? { + var values = self + while !values.isEmpty { + if let item = values.popLast() { + return item.kind + } + } + return nil + } +} diff --git a/src/swift/SwiftAPIViewCore/Sources/Models/ReviewTokenOptions.swift b/src/swift/SwiftAPIViewCore/Sources/Models/ReviewTokenOptions.swift new file mode 100644 index 00000000000..3e2601e5d7c --- /dev/null +++ b/src/swift/SwiftAPIViewCore/Sources/Models/ReviewTokenOptions.swift @@ -0,0 +1,83 @@ +// -------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// The MIT License (MIT) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the ""Software""), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// +// -------------------------------------------------------------------------- + +import Foundation + +/// Struct for setting reivew token options +public struct ReviewTokenOptions { + /// NavigationDisplayName is used to create a tree node in the navigation panel. Navigation nodes will be created only if token + /// contains navigation display name. + var navigationDisplayName: String? + + /// navigateToId should be set if the underlying token is required to be displayed as HREF to another type within the review. + /// For e.g. a param type which is class name in the same package + var navigateToId: String? + + /// set skipDiff to true if underlying token needs to be ignored from diff calculation. For e.g. package metadata or dependency + /// versions are usually excluded when comparing two revisions to avoid reporting them as API changes + var skipDiff: Bool? + + /// This is set if API is marked as deprecated + var isDeprecated: Bool? + + /// Set this to true if a prefix space is required before the next value. + var hasPrefixSpace: Bool? + + /// Set this to true if a suffix space required before next token. For e.g, punctuation right after method name + var hasSuffixSpace: Bool? + + /// Set isDocumentation to true if current token is part of documentation + var isDocumentation: Bool? + + /// Language specific style css class names + var renderClasses: [String]? + + mutating func applySpacing(_ spacing: SpacingKind) { + switch spacing { + case .Leading: + hasPrefixSpace = true + hasSuffixSpace = false + case .Trailing: + hasPrefixSpace = false + hasSuffixSpace = true + case .Both: + hasPrefixSpace = true + hasSuffixSpace = true + case .Neither: + hasPrefixSpace = false + hasSuffixSpace = false + } + } +} + +/// Struct for setting line marker options +public struct LineMarkerOptions { + /// Flag to add the cross language ID + var addCrossLanguageId: Bool? + + /// Related line ID + var relatedLineId: String? +} diff --git a/src/swift/SwiftAPIViewCore/Sources/Models/Token.swift b/src/swift/SwiftAPIViewCore/Sources/Models/Token.swift deleted file mode 100644 index 995a566d3a6..00000000000 --- a/src/swift/SwiftAPIViewCore/Sources/Models/Token.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -------------------------------------------------------------------------- -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// -// The MIT License (MIT) -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the ""Software""), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -// IN THE SOFTWARE. -// -// -------------------------------------------------------------------------- - -import Foundation - -/// Enum for token kind -enum TokenKind: Int, Codable { - /// Text - case text = 0 - /// Newline - case newline = 1 - /// Whitespace - case whitespace = 2 - /// Punctuation - case punctuation = 3 - /// Swift keyword - case keyword = 4 - /// used to display comment marker on lines with no visible tokens - case lineIdMarker = 5 - /// Swift type name (class, structs, enums, etc.) - case typeName = 6 - /// Variable names - case memberName = 7 - /// Constants - case stringLiteral = 8 - /// Literals - case literal = 9 - /// Comments - case comment = 10 - /// Document range start - case documentRangeStart = 11 - /// Document range end - case documentRangeEnd = 12 - /// Deprecated range start - case deprecatedRangeStart = 13 - /// Deprecated range end - case deprecatedRangeEnd = 14 - /// Start range to skip APIView diff - case skipDiffRangeStart = 15 - /// End range to skip APIView diff - case skippDiffRangeEnd = 16 - - public var isVisible: Bool { - switch self { - case .text: return true - case .newline: return true - case .whitespace: return true - case .punctuation: return true - case .keyword: return true - case .lineIdMarker: return false - case .typeName: return true - case .memberName: return true - case .stringLiteral: return true - case .literal: return true - case .comment: return true - case .documentRangeStart: return false - case .documentRangeEnd: return false - case .deprecatedRangeStart: return false - case .deprecatedRangeEnd: return false - case .skipDiffRangeStart: return false - case .skippDiffRangeEnd: return false - } - } -} - -/// An individual token item -struct Token: Codable { - /// Allows tokens to be navigated to. Should be unique. Used as ID for comment thread. - var definitionId: String? - /// If set, clicking on the token would navigate to the other token with this ID. - var navigateToId: String? - /// Text value - var value: String? - /// Token kind - var kind: TokenKind - - // MARK: Codable - - enum CodingKeys: String, CodingKey { - case definitionId = "DefinitionId" - case navigateToId = "NavigateToId" - case value = "Value" - case kind = "Kind" - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(definitionId, forKey: .definitionId) - try container.encode(navigateToId, forKey: .navigateToId) - try container.encode(value, forKey: .value) - try container.encode(kind, forKey: .kind) - } - - var text: String { - switch kind { - case .lineIdMarker: - return "" - case .newline: - return "\n" - default: - return value! - } - } -} - -extension Array { - var lastVisible: TokenKind? { - var values = self - while !values.isEmpty { - if let item = values.popLast(), item.kind.isVisible { - return item.kind - } - } - return nil - } -} diff --git a/src/swift/SwiftAPIViewCore/Sources/Protocols/Linkable.swift b/src/swift/SwiftAPIViewCore/Sources/Protocols/Linkable.swift index aa2e2bd423e..b1dd83cc7f7 100644 --- a/src/swift/SwiftAPIViewCore/Sources/Protocols/Linkable.swift +++ b/src/swift/SwiftAPIViewCore/Sources/Protocols/Linkable.swift @@ -33,6 +33,4 @@ protocol Linkable { var name: String { get } var definitionId: String? { get } var parent: Linkable? { get } - - func navigationTokenize(apiview: APIViewModel, parent: Linkable?) } diff --git a/src/swift/SwiftAPIViewCore/Sources/Protocols/Tokenizable.swift b/src/swift/SwiftAPIViewCore/Sources/Protocols/Tokenizable.swift index edc41dff6c8..c38012dd210 100644 --- a/src/swift/SwiftAPIViewCore/Sources/Protocols/Tokenizable.swift +++ b/src/swift/SwiftAPIViewCore/Sources/Protocols/Tokenizable.swift @@ -28,5 +28,5 @@ import Foundation /// Conforming objects can be serialized into APIView tokens. protocol Tokenizable { - func tokenize(apiview: APIViewModel, parent: Linkable?) + func tokenize(apiview: CodeModel, parent: Linkable?) } diff --git a/src/swift/SwiftAPIViewCore/Sources/SwiftSyntax+Extensions/SyntaxProtocol+Extensions.swift b/src/swift/SwiftAPIViewCore/Sources/SwiftSyntax+Extensions/SyntaxProtocol+Extensions.swift index ddc2846ba17..e309cfba246 100644 --- a/src/swift/SwiftAPIViewCore/Sources/SwiftSyntax+Extensions/SyntaxProtocol+Extensions.swift +++ b/src/swift/SwiftAPIViewCore/Sources/SwiftSyntax+Extensions/SyntaxProtocol+Extensions.swift @@ -28,7 +28,8 @@ import Foundation import SwiftSyntax extension SyntaxProtocol { - func tokenize(apiview a: APIViewModel, parent: Linkable?) { + func tokenize(apiview a: CodeModel, parent: Linkable?) { + var options = ReviewTokenOptions() let syntaxKind = self.kind switch syntaxKind { case .associatedtypeDecl: @@ -42,14 +43,22 @@ extension SyntaxProtocol { if child.childNameInParent == "name" { let attrName = child.withoutTrivia().description // don't add space if the attribute has parameters - a.keyword(attrName, spacing: children.count == 2 ? .Trailing : .Neither) + if children.count == 2 { + options.applySpacing(.Trailing) + a.keyword(attrName, options: options) + } else { + options.applySpacing(.Neither) + a.keyword(attrName, options: options) + } + } else { child.tokenize(apiview: a, parent: parent) } } case .classRestrictionType: // in this simple context, class should not have a trailing space - a.keyword("class", spacing: .Neither) + options.applySpacing(.Neither) + a.keyword("class", options: options) case .codeBlock: // Don't render code blocks. APIView is unconcerned with implementation break @@ -60,7 +69,7 @@ extension SyntaxProtocol { for child in children { child.tokenize(apiview: a, parent: parent) if (child.kind == .token) { - a.whitespace() + a.currentLine.tokens.last?.hasSuffixSpace = true } } case .enumCaseElement: @@ -70,8 +79,9 @@ extension SyntaxProtocol { if childIndex == 1 { let token = TokenSyntax(child)! if case let SwiftSyntax.TokenKind.identifier(label) = token.tokenKind { - let defId = identifier(forName: label, withPrefix: parent?.definitionId) - a.member(name: label, definitionId: defId) + let lineId = identifier(forName: label, withPrefix: parent?.definitionId) + a.lineMarker(lineId) + a.member(name: label) } else { SharedLogger.warn("Unhandled enum label kind '\(token.tokenKind)'. APIView may not display correctly.") } @@ -94,7 +104,8 @@ extension SyntaxProtocol { // index 5 is the external name, which we always render as text let token = TokenSyntax(child)! if case let SwiftSyntax.TokenKind.identifier(val) = token.tokenKind { - a.text(val) + options.hasSuffixSpace = false + a.text(val, options: options) } else if case SwiftSyntax.TokenKind.wildcardKeyword = token.tokenKind { a.text("_") } else { @@ -107,7 +118,7 @@ extension SyntaxProtocol { let attrIndex = attrs.indexInParent attr.tokenize(apiview: a, parent: parent) if attrIndex != lastAttrs { - a.whitespace() + a.currentLine.tokens.last?.hasSuffixSpace = true } } } else { @@ -117,21 +128,17 @@ extension SyntaxProtocol { case .identifierPattern: let name = IdentifierPatternSyntax(self)!.identifier.withoutTrivia().text let lineId = identifier(forName: name, withPrefix: parent?.definitionId) - a.member(name: name, definitionId: lineId) + a.lineMarker(lineId) + a.member(name: name) case .initializerDecl: DeclarationModel(from: InitializerDeclSyntax(self)!, parent: parent).tokenize(apiview: a, parent: parent) case .memberDeclList: a.indent { - let beforeCount = a.tokens.count - tokenizeChildren(apiview: a, parent: parent) - // only render newline if tokens were actually added - if a.tokens.count > beforeCount { - a.newline() - } + tokenizeMembers(apiview: a, parent: parent) } case .memberDeclListItem: let decl = MemberDeclListItemSyntax(self)!.decl - let publicModifiers = APIViewModel.publicModifiers + let publicModifiers = CodeModel.publicModifiers var showDecl = publicModifiers.contains(decl.modifiers?.accessLevel ?? .unspecified) switch decl.kind { case .associatedtypeDecl: @@ -151,7 +158,7 @@ extension SyntaxProtocol { case .variableDecl: // Public protocols should expose all members even if they have no access level modifier if let parentDecl = (parent as? DeclarationModel), parentDecl.kind == .protocol { - showDecl = showDecl || APIViewModel.publicModifiers.contains(parentDecl.accessLevel) + showDecl = showDecl || CodeModel.publicModifiers.contains(parentDecl.accessLevel) } default: // show the unrecognized member by default @@ -159,26 +166,20 @@ extension SyntaxProtocol { showDecl = true } if showDecl { - a.newline() - tokenizeChildren(apiview: a, parent: parent) - } - case .precedenceGroupAttributeList: - a.indent { tokenizeChildren(apiview: a, parent: parent) - a.newline() } case .precedenceGroupRelation: - a.newline() + a.blankLines(set: 0) if let name = PrecedenceGroupRelationSyntax(self)!.keyword { let lineId = identifier(forName: name, withPrefix: parent?.definitionId) - a.lineIdMarker(definitionId: lineId) + a.lineMarker(lineId) } tokenizeChildren(apiview: a, parent: parent) case .precedenceGroupAssociativity: - a.newline() + a.blankLines(set: 0) if let name = PrecedenceGroupAssociativitySyntax(self)!.keyword { let lineId = identifier(forName: name, withPrefix: parent?.definitionId) - a.lineIdMarker(definitionId: lineId) + a.lineMarker(lineId) } tokenizeChildren(apiview: a, parent: parent) case .subscriptDecl: @@ -196,7 +197,8 @@ extension SyntaxProtocol { let tokenKind = token.tokenKind let tokenText = token.withoutTrivia().description if tokenKind == .leftBrace || tokenKind == .rightBrace { - a.punctuation(tokenText, spacing: .Both) + options.applySpacing(tokenKind.spacing) + a.punctuation(tokenText, options: options) } else { child.tokenize(token: token, apiview: a, parent: nil) } @@ -210,21 +212,35 @@ extension SyntaxProtocol { } } - func tokenizeChildren(apiview a: APIViewModel, parent: Linkable?) { + func tokenizeMembers(apiview a: CodeModel, parent: Linkable?) { + let children = self.children(viewMode: .sourceAccurate) + let lastIdx = children.count - 1 + for (idx, child) in children.enumerated() { + let beforeCount = a.currentLine.tokens.count + child.tokenize(apiview: a, parent: parent) + // skip if no tokens were actually added + guard (a.currentLine.tokens.count > beforeCount) else { continue } + a.blankLines(set: 0) + } + } + + func tokenizeChildren(apiview a: CodeModel, parent: Linkable?) { for child in self.children(viewMode: .sourceAccurate) { child.tokenize(apiview: a, parent: parent) } } - func tokenize(token: TokenSyntax, apiview a: APIViewModel, parent: DeclarationModel?) { + func tokenize(token: TokenSyntax, apiview a: CodeModel, parent: DeclarationModel?) { let tokenKind = token.tokenKind let tokenText = token.withoutTrivia().description + var options = ReviewTokenOptions() + options.applySpacing(tokenKind.spacing) if tokenKind.isKeyword { - a.keyword(tokenText, spacing: tokenKind.spacing) + a.keyword(tokenText, options: options) return } else if tokenKind.isPunctuation { - a.punctuation(tokenText, spacing: tokenKind.spacing) + a.punctuation(tokenText, options: options) return } if case let SwiftSyntax.TokenKind.identifier(val) = tokenKind { @@ -232,23 +248,28 @@ extension SyntaxProtocol { // used in @availabililty annotations if nameInParent == "platform" { a.text(tokenText) - a.whitespace() + a.currentLine.tokens.last?.hasSuffixSpace = true } else { - a.typeReference(name: val, parent: parent) + a.typeReference(name: val, options: options) } } else if case let SwiftSyntax.TokenKind.spacedBinaryOperator(val) = tokenKind { // in APIView, * is never used for multiplication if val == "*" { - a.punctuation(val, spacing: .Neither) + options.applySpacing(.Neither) + a.punctuation(val, options: options) } else { - a.punctuation(val, spacing: .Both) + options.applySpacing(.Both) + a.punctuation(val, options: options) } } else if case let SwiftSyntax.TokenKind.unspacedBinaryOperator(val) = tokenKind { - a.punctuation(val, spacing: .Neither) + options.applySpacing(.Neither) + a.punctuation(val, options: options) } else if case let SwiftSyntax.TokenKind.prefixOperator(val) = tokenKind { - a.punctuation(val, spacing: .Leading) + options.applySpacing(.Leading) + a.punctuation(val, options: options) } else if case let SwiftSyntax.TokenKind.postfixOperator(val) = tokenKind { - a.punctuation(val, spacing: .Trailing) + options.applySpacing(.Trailing) + a.punctuation(val, options: options) } else if case let SwiftSyntax.TokenKind.floatingLiteral(val) = tokenKind { a.literal(val) } else if case let SwiftSyntax.TokenKind.regexLiteral(val) = tokenKind { @@ -258,7 +279,8 @@ extension SyntaxProtocol { } else if case let SwiftSyntax.TokenKind.integerLiteral(val) = tokenKind { a.literal(val) } else if case let SwiftSyntax.TokenKind.contextualKeyword(val) = tokenKind { - a.keyword(val, spacing: tokenKind.spacing) + options.applySpacing(tokenKind.spacing) + a.keyword(val, options: options) } else if case let SwiftSyntax.TokenKind.stringSegment(val) = tokenKind { a.text(val) } else { diff --git a/src/swift/SwiftAPIViewCore/Sources/SwiftSyntax+Extensions/TokenKind+Extensions.swift b/src/swift/SwiftAPIViewCore/Sources/SwiftSyntax+Extensions/TokenKind+Extensions.swift index ae76f3fdf87..a7cd8bb7aa1 100644 --- a/src/swift/SwiftAPIViewCore/Sources/SwiftSyntax+Extensions/TokenKind+Extensions.swift +++ b/src/swift/SwiftAPIViewCore/Sources/SwiftSyntax+Extensions/TokenKind+Extensions.swift @@ -36,8 +36,6 @@ enum SpacingKind { case Both /// No spacing case Neither - /// chomps any leading whitespace to the left - case TrimLeft } extension SwiftSyntax.TokenKind { @@ -51,17 +49,15 @@ extension SwiftSyntax.TokenKind { case .semicolon: return .Trailing case .equal: return .Both case .arrow: return .Both - case .postfixQuestionMark: return .TrimLeft case .leftBrace: return .Leading case .initKeyword: return .Leading case .wildcardKeyword: return .Neither case let .contextualKeyword(val): switch val { - case "objc": return .TrimLeft case "lowerThan", "higherThan", "associativity": return .Neither case "available", "unavailable", "introduced", "deprecated", "obsoleted", "message", "renamed": return .Neither case "willSet", "didSet", "get", "set": - return .Leading + return .Both default: return .Both } default: diff --git a/src/swift/SwiftAPIViewCore/SwiftAPIViewCore.xcodeproj/project.pbxproj b/src/swift/SwiftAPIViewCore/SwiftAPIViewCore.xcodeproj/project.pbxproj index f4e0a4cd368..c6cbcad07bd 100644 --- a/src/swift/SwiftAPIViewCore/SwiftAPIViewCore.xcodeproj/project.pbxproj +++ b/src/swift/SwiftAPIViewCore/SwiftAPIViewCore.xcodeproj/project.pbxproj @@ -7,15 +7,20 @@ objects = { /* Begin PBXBuildFile section */ + 0A61B5222D0B764600FC6B19 /* ReviewToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A61B5212D0B764600FC6B19 /* ReviewToken.swift */; }; + 0A61B5232D0B764600FC6B19 /* CodeDiagnostic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A61B51F2D0B764600FC6B19 /* CodeDiagnostic.swift */; }; + 0A61B5242D0B764600FC6B19 /* ReviewLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A61B5202D0B764600FC6B19 /* ReviewLine.swift */; }; + 0A61B5272D0B76C400FC6B19 /* NamespaceStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A61B5252D0B76C400FC6B19 /* NamespaceStack.swift */; }; + 0A61B5282D0B76C400FC6B19 /* ReviewTokenOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A61B5262D0B76C400FC6B19 /* ReviewTokenOptions.swift */; }; + 0A61B52A2D0CADF300FC6B19 /* TestUtilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A61B5292D0CADE300FC6B19 /* TestUtilityTests.swift */; }; 0A6C658A292D9EA00075C56F /* APIViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A846A1927879D0400C967A8 /* APIViewManager.swift */; }; 0A6C658B292D9ED60075C56F /* CommandLineArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A846A0827879D0400C967A8 /* CommandLineArguments.swift */; }; 0A6C658C292D9ED60075C56F /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A846A0C27879D0400C967A8 /* Errors.swift */; }; 0A6C658D292D9ED60075C56F /* Identifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A84079827F3B34B00801E60 /* Identifiers.swift */; }; 0A6C658E292D9ED60075C56F /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A846A0A27879D0400C967A8 /* Logger.swift */; }; - 0A6C658F292D9F8B0075C56F /* APIViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A84076527EE3DCE00801E60 /* APIViewModel.swift */; }; + 0A6C658F292D9F8B0075C56F /* CodeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A84076527EE3DCE00801E60 /* CodeModel.swift */; }; 0A6C6590292D9F8B0075C56F /* PackageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A84076327ED2CA400801E60 /* PackageModel.swift */; }; 0A6C6591292D9FE60075C56F /* Tokenizable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A84076127ED2A2500801E60 /* Tokenizable.swift */; }; - 0A6C6592292DA0090075C56F /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A846A1227879D0400C967A8 /* Token.swift */; }; 0A6C6593292DA0140075C56F /* Linkable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A84076927EE4E2C00801E60 /* Linkable.swift */; }; 0A6C6598292E98890075C56F /* SourceKittenFramework in Frameworks */ = {isa = PBXBuildFile; productRef = 0A6C6597292E98890075C56F /* SourceKittenFramework */; }; 0A6C659F292EC8490075C56F /* NavigationTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A846A1127879D0400C967A8 /* NavigationTags.swift */; }; @@ -77,6 +82,12 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0A61B51F2D0B764600FC6B19 /* CodeDiagnostic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeDiagnostic.swift; sourceTree = ""; }; + 0A61B5202D0B764600FC6B19 /* ReviewLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewLine.swift; sourceTree = ""; }; + 0A61B5212D0B764600FC6B19 /* ReviewToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewToken.swift; sourceTree = ""; }; + 0A61B5252D0B76C400FC6B19 /* NamespaceStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NamespaceStack.swift; sourceTree = ""; }; + 0A61B5262D0B76C400FC6B19 /* ReviewTokenOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewTokenOptions.swift; sourceTree = ""; }; + 0A61B5292D0CADE300FC6B19 /* TestUtilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtilityTests.swift; sourceTree = ""; }; 0A76BF8A294A9A11007C776E /* TokenKind+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TokenKind+Extensions.swift"; sourceTree = ""; }; 0A76BF8E294B8BCD007C776E /* SyntaxProtocol+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyntaxProtocol+Extensions.swift"; sourceTree = ""; }; 0A76BF90294B940A007C776E /* DeclarationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclarationModel.swift; sourceTree = ""; }; @@ -85,7 +96,7 @@ 0A76BF96294BA4E3007C776E /* ModifierListSyntax+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ModifierListSyntax+Extensions.swift"; sourceTree = ""; }; 0A84076127ED2A2500801E60 /* Tokenizable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tokenizable.swift; sourceTree = ""; }; 0A84076327ED2CA400801E60 /* PackageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageModel.swift; sourceTree = ""; }; - 0A84076527EE3DCE00801E60 /* APIViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIViewModel.swift; sourceTree = ""; }; + 0A84076527EE3DCE00801E60 /* CodeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeModel.swift; sourceTree = ""; }; 0A84076927EE4E2C00801E60 /* Linkable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Linkable.swift; sourceTree = ""; }; 0A84079827F3B34B00801E60 /* Identifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identifiers.swift; sourceTree = ""; }; 0A8469E127879AE200C967A8 /* SwiftAPIViewCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftAPIViewCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -95,7 +106,6 @@ 0A846A0A27879D0400C967A8 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; 0A846A0C27879D0400C967A8 /* Errors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = ""; }; 0A846A1127879D0400C967A8 /* NavigationTags.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationTags.swift; sourceTree = ""; }; - 0A846A1227879D0400C967A8 /* Token.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = ""; }; 0A846A1327879D0400C967A8 /* NavigationToken.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationToken.swift; sourceTree = ""; }; 0A846A1927879D0400C967A8 /* APIViewManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIViewManager.swift; sourceTree = ""; }; 0A846A342787A88E00C967A8 /* CoreInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = CoreInfo.plist; sourceTree = ""; }; @@ -199,9 +209,10 @@ 0A846A0227879D0400C967A8 /* Tests */ = { isa = PBXGroup; children = ( - B2DA7DFF2D0A0A6A0059E51F /* ExpectFiles */, B2DA7E212D0A0A6F0059E51F /* TestFiles */, + B2DA7DFF2D0A0A6A0059E51F /* ExpectFiles */, 0A846A0327879D0400C967A8 /* SwiftAPIViewCoreTests.swift */, + 0A61B5292D0CADE300FC6B19 /* TestUtilityTests.swift */, ); path = Tests; sourceTree = ""; @@ -209,11 +220,11 @@ 0A846A0627879D0400C967A8 /* Sources */ = { isa = PBXGroup; children = ( - 0A76BF89294A99FB007C776E /* SwiftSyntax+Extensions */, - 0A846A1927879D0400C967A8 /* APIViewManager.swift */, - 0A84077A27EE659100801E60 /* Protocols */, 0A846A0F27879D0400C967A8 /* Models */, + 0A84077A27EE659100801E60 /* Protocols */, + 0A76BF89294A99FB007C776E /* SwiftSyntax+Extensions */, 0A846A0927879D0400C967A8 /* Util */, + 0A846A1927879D0400C967A8 /* APIViewManager.swift */, ); path = Sources; sourceTree = ""; @@ -233,13 +244,17 @@ isa = PBXGroup; children = ( 0A76BF94294BA10A007C776E /* AccessLevel.swift */, - 0A84076527EE3DCE00801E60 /* APIViewModel.swift */, + 0A61B51F2D0B764600FC6B19 /* CodeDiagnostic.swift */, + 0A84076527EE3DCE00801E60 /* CodeModel.swift */, 0A76BF90294B940A007C776E /* DeclarationModel.swift */, 0AA1BFBD2953839E00AE8C11 /* ExtensionModel.swift */, + 0A61B5252D0B76C400FC6B19 /* NamespaceStack.swift */, 0A84076327ED2CA400801E60 /* PackageModel.swift */, + 0A61B5202D0B764600FC6B19 /* ReviewLine.swift */, + 0A61B5212D0B764600FC6B19 /* ReviewToken.swift */, + 0A61B5262D0B76C400FC6B19 /* ReviewTokenOptions.swift */, 0A846A1127879D0400C967A8 /* NavigationTags.swift */, 0A846A1327879D0400C967A8 /* NavigationToken.swift */, - 0A846A1227879D0400C967A8 /* Token.swift */, ); path = Models; sourceTree = ""; @@ -423,14 +438,18 @@ files = ( 0A76BF8B294A9A11007C776E /* TokenKind+Extensions.swift in Sources */, 0A6C659F292EC8490075C56F /* NavigationTags.swift in Sources */, + 0A61B5222D0B764600FC6B19 /* ReviewToken.swift in Sources */, + 0A61B5232D0B764600FC6B19 /* CodeDiagnostic.swift in Sources */, + 0A61B5272D0B76C400FC6B19 /* NamespaceStack.swift in Sources */, + 0A61B5282D0B76C400FC6B19 /* ReviewTokenOptions.swift in Sources */, + 0A61B5242D0B764600FC6B19 /* ReviewLine.swift in Sources */, 0A6C65A0292EC8490075C56F /* NavigationToken.swift in Sources */, 0A76BF97294BA4E3007C776E /* ModifierListSyntax+Extensions.swift in Sources */, 0A6C6593292DA0140075C56F /* Linkable.swift in Sources */, 0AA1BFBE2953839E00AE8C11 /* ExtensionModel.swift in Sources */, - 0A6C6592292DA0090075C56F /* Token.swift in Sources */, 0A6C6591292D9FE60075C56F /* Tokenizable.swift in Sources */, 0A76BF93294B987C007C776E /* DeclSyntaxProtocol+Extensions.swift in Sources */, - 0A6C658F292D9F8B0075C56F /* APIViewModel.swift in Sources */, + 0A6C658F292D9F8B0075C56F /* CodeModel.swift in Sources */, 0A76BF91294B940A007C776E /* DeclarationModel.swift in Sources */, 0A6C6590292D9F8B0075C56F /* PackageModel.swift in Sources */, 0A6C658B292D9ED60075C56F /* CommandLineArguments.swift in Sources */, @@ -458,6 +477,7 @@ buildActionMask = 2147483647; files = ( 0A846A2F27879E7800C967A8 /* SwiftAPIViewCoreTests.swift in Sources */, + 0A61B52A2D0CADF300FC6B19 /* TestUtilityTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -616,7 +636,7 @@ "@executable_path/../Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.2.2; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.microsoft.SwiftAPIViewCore; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; @@ -646,7 +666,7 @@ "@executable_path/../Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.2.2; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.microsoft.SwiftAPIViewCore; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; diff --git a/src/swift/SwiftAPIViewCore/SwiftAPIViewCore.xcodeproj/xcshareddata/xcschemes/SwiftAPIViewCore.xcscheme b/src/swift/SwiftAPIViewCore/SwiftAPIViewCore.xcodeproj/xcshareddata/xcschemes/SwiftAPIViewCore.xcscheme index e261e3ac84c..029a0b9eb57 100644 --- a/src/swift/SwiftAPIViewCore/SwiftAPIViewCore.xcodeproj/xcshareddata/xcschemes/SwiftAPIViewCore.xcscheme +++ b/src/swift/SwiftAPIViewCore/SwiftAPIViewCore.xcodeproj/xcshareddata/xcschemes/SwiftAPIViewCore.xcscheme @@ -27,8 +27,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + diff --git a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/AttributesExpectFile.txt b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/AttributesExpectFile.txt index bf627ece4f1..477f6fc548f 100644 --- a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/AttributesExpectFile.txt +++ b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/AttributesExpectFile.txt @@ -1,4 +1,4 @@ -Package parsed using Swift APIView (version 0.2.2) +Package parsed using Swift APIView (version 0.3.0) package AttributesTestFile.swifttxt { diff --git a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/EnumerationsExpectFile.txt b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/EnumerationsExpectFile.txt index ebb5393a1a5..d9c94efc438 100644 --- a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/EnumerationsExpectFile.txt +++ b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/EnumerationsExpectFile.txt @@ -1,4 +1,4 @@ -Package parsed using Swift APIView (version 0.2.2) +Package parsed using Swift APIView (version 0.3.0) package EnumerationsTestFile.swifttxt { diff --git a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/ExtensionExpectFile.txt b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/ExtensionExpectFile.txt index 761c119f743..63c65be8402 100644 --- a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/ExtensionExpectFile.txt +++ b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/ExtensionExpectFile.txt @@ -1,4 +1,4 @@ -Package parsed using Swift APIView (version 0.2.2) +Package parsed using Swift APIView (version 0.3.0) package ExtensionTestFile.swifttxt { @@ -20,6 +20,7 @@ package ExtensionTestFile.swifttxt { } \\ Non-package extensions + public extension Double { var km: Double var m: Double diff --git a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/FunctionsExpectFile.txt b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/FunctionsExpectFile.txt index f112f44fab0..f4836026c3c 100644 --- a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/FunctionsExpectFile.txt +++ b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/FunctionsExpectFile.txt @@ -1,4 +1,4 @@ -Package parsed using Swift APIView (version 0.2.2) +Package parsed using Swift APIView (version 0.3.0) package FunctionsTestFile.swifttxt { diff --git a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/GenericsExpectFile.txt b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/GenericsExpectFile.txt index d409390ee80..0e3e9bfc041 100644 --- a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/GenericsExpectFile.txt +++ b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/GenericsExpectFile.txt @@ -1,4 +1,4 @@ -Package parsed using Swift APIView (version 0.2.2) +Package parsed using Swift APIView (version 0.3.0) package GenericsTestFile.swifttxt { diff --git a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/InitializersExpectFile.txt b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/InitializersExpectFile.txt index 4cf4f163072..c6fc17c307c 100644 --- a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/InitializersExpectFile.txt +++ b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/InitializersExpectFile.txt @@ -1,4 +1,4 @@ -Package parsed using Swift APIView (version 0.2.2) +Package parsed using Swift APIView (version 0.3.0) package InitializersTestFile.swifttxt { diff --git a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/OperatorExpectFile.txt b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/OperatorExpectFile.txt index af4116e3a2b..9785a607484 100644 --- a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/OperatorExpectFile.txt +++ b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/OperatorExpectFile.txt @@ -1,4 +1,4 @@ -Package parsed using Swift APIView (version 0.2.2) +Package parsed using Swift APIView (version 0.3.0) package OperatorTestFile.swifttxt { diff --git a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/PrivateInternalExpectFile.txt b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/PrivateInternalExpectFile.txt index f3f4d9ffb29..f37345d2353 100644 --- a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/PrivateInternalExpectFile.txt +++ b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/PrivateInternalExpectFile.txt @@ -1,5 +1,4 @@ -Package parsed using Swift APIView (version 0.2.2) +Package parsed using Swift APIView (version 0.3.0) -package PrivateInternalTestFile.swifttxt { -} +package PrivateInternalTestFile.swifttxt {} diff --git a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/PropertiesExpectFile.txt b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/PropertiesExpectFile.txt index 82dbc9a6234..cf2ab521e30 100644 --- a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/PropertiesExpectFile.txt +++ b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/PropertiesExpectFile.txt @@ -1,4 +1,4 @@ -Package parsed using Swift APIView (version 0.2.2) +Package parsed using Swift APIView (version 0.3.0) package PropertiesTestFile.swifttxt { diff --git a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/ProtocolExpectFile.txt b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/ProtocolExpectFile.txt index 644808185e5..8d444a328e8 100644 --- a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/ProtocolExpectFile.txt +++ b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/ProtocolExpectFile.txt @@ -1,4 +1,4 @@ -Package parsed using Swift APIView (version 0.2.2) +Package parsed using Swift APIView (version 0.3.0) package ProtocolTestFile.swifttxt { @@ -38,8 +38,7 @@ package ProtocolTestFile.swifttxt { public var textualDescription: String } - extension Hamster: TextRepresentable { - } + extension Hamster: TextRepresentable {} public protocol Named { var name: String { get } @@ -110,6 +109,7 @@ package ProtocolTestFile.swifttxt { public func wishHappyBirthday(to: Named & Aged) \\ Non-package extensions + extension Array: TextRepresentable where Element: TextRepresentable { public var textualDescription: String } diff --git a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/SwiftUIExpectFile.txt b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/SwiftUIExpectFile.txt index ec87bba046b..26b2bc5725c 100644 --- a/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/SwiftUIExpectFile.txt +++ b/src/swift/SwiftAPIViewCore/Tests/ExpectFiles/SwiftUIExpectFile.txt @@ -1,9 +1,10 @@ -Package parsed using Swift APIView (version 0.2.2) +Package parsed using Swift APIView (version 0.3.0) package SwiftUITestFile.swifttxt { public class ViewBuilderExample { public func testViewBuilder(@ViewBuilder content: () -> Content) + @ViewBuilder public func createView() -> some View } diff --git a/src/swift/SwiftAPIViewCore/Tests/SwiftAPIViewCoreTests.swift b/src/swift/SwiftAPIViewCore/Tests/SwiftAPIViewCoreTests.swift index e34345db9d7..26e1d9c5777 100644 --- a/src/swift/SwiftAPIViewCore/Tests/SwiftAPIViewCoreTests.swift +++ b/src/swift/SwiftAPIViewCore/Tests/SwiftAPIViewCoreTests.swift @@ -29,6 +29,18 @@ import XCTest class SwiftAPIViewCoreTests: XCTestCase { + /// Simple structure to track validation metadata on `ReviewLine` + struct ReviewLineData: Equatable { + /// Counts the number of `relatedLineId` + var relatedToCount: Int; + /// Counts the number of `isContextEndLine` + var isContextEndCount: Int; + + var description: String { + return "relatedToCount: \(relatedToCount), isContextEndCount: \(isContextEndCount)" + } + } + override func setUpWithError() throws { try super.setUpWithError() continueAfterFailure = false @@ -50,6 +62,7 @@ class SwiftAPIViewCoreTests: XCTestCase { return try! String(contentsOfFile: path) } + /// Compares the text syntax of the APIView against what is expected. private func compare(expected: String, actual: String) { let actualLines = actual.split(separator: "\n", omittingEmptySubsequences: false).map { String($0) } let expectedLines = expected.split(separator: "\n", omittingEmptySubsequences: false).map { String($0) } @@ -63,6 +76,111 @@ class SwiftAPIViewCoreTests: XCTestCase { XCTAssertEqual(actualLines.count, expectedLines.count, "Number of lines does not match") } + /// Ensure there are no duplicate line IDs in the review, as that would lead + /// to functional bugs on the web. + private func validateLineIds(apiview: CodeModel) { + + func validate(line: ReviewLine) { + // ensure first token does not have prefix space + // and last does not have suffix space + if let firstToken = line.tokens.first { + if firstToken.hasPrefixSpace ?? false { + XCTFail("Unexpected prefix space on first token") + } + } + + if let lastToken = line.tokens.last { + if lastToken.hasSuffixSpace ?? false { + XCTFail("Unexpected suffix space on last token") + } + } + + // ensure there are no repeated definition IDs + if let lineId = line.lineId { + if lineIds.contains(lineId) { + XCTFail("Duplicate line ID: \(lineId)") + } + if lineId != "" { + lineIds.insert(lineId) + } + for child in line.children { + validate(line: child) + } + } + } + + var lineIds = Set() + for line in apiview.reviewLines { + validate(line: line) + } + } + + /// Extracts related lines from the APIView to ensure proper collapsing behavior on the web. + private func getRelatedLineMetadata(apiview: CodeModel) -> [String: ReviewLineData] { + /// Extracts the `ReviewLineData` for the provided review lines. + func getReviewLinesMetadata(lines: [ReviewLine]?) -> [String: ReviewLineData]? { + guard let lines = lines else { return nil } + guard !lines.isEmpty else { return nil } + var mainMap = [String: ReviewLineData]() + var lastKey: String? = nil + for line in lines { + let lineId = line.lineId + if let related = line.relatedToLine { + lastKey = related + var subMap = mainMap[related] ?? ReviewLineData(relatedToCount: 0, isContextEndCount: 0) + subMap.relatedToCount += 1 + mainMap[related] = subMap + } + if line.isContextEndLine == true { + guard lastKey != nil else { + XCTFail("isEndContext found without a related line.") + return nil + } + var subMap = mainMap[lastKey!] ?? ReviewLineData(relatedToCount: 0, isContextEndCount: 0) + subMap.isContextEndCount += 1 + mainMap[lastKey!] = subMap + } + if !line.children.isEmpty { + guard lineId != nil else { + XCTFail("Child without a line ID.") + return nil + } + lastKey = lineId + if let subMap = getReviewLinesMetadata(lines: line.children) { + for (key, value) in subMap { + mainMap[key] = value + } + } + } + lastKey = lineId + } + return mainMap + } + let countMap = getReviewLinesMetadata(lines: apiview.reviewLines) + return countMap ?? [String: ReviewLineData]() + } + + /// Compare `ReviewLineData` information for equality + func compareCounts(_ lhs: [String: ReviewLineData], _ rhs: [String: ReviewLineData]) { + // ensure keys are the same + let lhsKeys = Set(lhs.keys) + let rhsKeys = Set(rhs.keys) + let combined = lhsKeys.union(rhsKeys) + if (combined.count != lhsKeys.count) { + XCTFail("Key mismatch: \(lhsKeys.description) vs \(rhsKeys.description)") + return + } + for key in lhs.keys { + let lhsVal = lhs[key]! + let rhsVal = rhs[key]! + if lhsVal != rhsVal { + XCTFail("Value mismatch for key \(key): \(lhsVal.description) vs \(rhsVal.description)") + } + } + } + + // MARK: Tests + func testAttributes() throws { let manager = APIViewManager(mode: .testing) manager.config.sourcePath = pathFor(testFile: "AttributesTestFile") @@ -70,6 +188,17 @@ class SwiftAPIViewCoreTests: XCTestCase { let generated = try manager.run() let expected = contentsOf(expectFile: "AttributesExpectFile") compare(expected: expected, actual: generated) + validateLineIds(apiview: manager.model!) + let counts = getRelatedLineMetadata(apiview: manager.model!) + let expectedCounts: [String: ReviewLineData] = [ + "AttributesTestFile.swifttxt": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "AttributesTestFile.swifttxt.ExampleClass": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "AttributesTestFile.swifttxt.MyClass": ReviewLineData(relatedToCount: 1, isContextEndCount: 0), + "AttributesTestFile.swifttxt.MyProtocol": ReviewLineData(relatedToCount: 1, isContextEndCount: 0), + "AttributesTestFile.swifttxt.MyStruct": ReviewLineData(relatedToCount: 2, isContextEndCount: 0), + "AttributesTestFile.swifttxt.SomeSendable": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + ] + compareCounts(counts, expectedCounts) } func testEnumerations() throws { @@ -79,6 +208,18 @@ class SwiftAPIViewCoreTests: XCTestCase { let generated = try manager.run() let expected = contentsOf(expectFile: "EnumerationsExpectFile") compare(expected: expected, actual: generated) + validateLineIds(apiview: manager.model!) + let counts = getRelatedLineMetadata(apiview: manager.model!) + let expectedCounts: [String: ReviewLineData] = [ + "EnumerationsTestFile.swifttxt": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "EnumerationsTestFile.swifttxt.ASCIIControlCharacter": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "EnumerationsTestFile.swifttxt.ArithmeticExpression": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "EnumerationsTestFile.swifttxt.Barcode": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "EnumerationsTestFile.swifttxt.CompassPoint": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "EnumerationsTestFile.swifttxt.Planet": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + ] + compareCounts(counts, expectedCounts) + } func testExtensions() throws { @@ -88,6 +229,21 @@ class SwiftAPIViewCoreTests: XCTestCase { let generated = try manager.run() let expected = contentsOf(expectFile: "ExtensionExpectFile") compare(expected: expected, actual: generated) + validateLineIds(apiview: manager.model!) + let counts = getRelatedLineMetadata(apiview: manager.model!) + let expectedCounts: [String: ReviewLineData] = [ + "ExtensionTestFile.swifttxt": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ExtensionTestFile.swifttxt.Point": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ExtensionTestFile.swifttxt.Rect": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionRect": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ExtensionTestFile.swifttxt.Size": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionDouble": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionInt": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionInt.Kind": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionStack": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionStackwhereElement:Equatable": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + ] + compareCounts(counts, expectedCounts) } func testFunctions() throws { @@ -97,6 +253,13 @@ class SwiftAPIViewCoreTests: XCTestCase { let generated = try manager.run() let expected = contentsOf(expectFile: "FunctionsExpectFile") compare(expected: expected, actual: generated) + validateLineIds(apiview: manager.model!) + let counts = getRelatedLineMetadata(apiview: manager.model!) + let expectedCounts: [String: ReviewLineData] = [ + "FunctionsTestFile.swifttxt": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "FunctionsTestFile.swifttxt.FunctionTestClass": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + ] + compareCounts(counts, expectedCounts) } func testGenerics() throws { @@ -106,6 +269,26 @@ class SwiftAPIViewCoreTests: XCTestCase { let generated = try manager.run() let expected = contentsOf(expectFile: "GenericsExpectFile") compare(expected: expected, actual: generated) + validateLineIds(apiview: manager.model!) + let counts = getRelatedLineMetadata(apiview: manager.model!) + let expectedCounts: [String: ReviewLineData] = [ + "GenericsTestFile.swifttxt": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "GenericsTestFile.swifttxt.Container": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "GenericsTestFile.swifttxt.ContainerAlt": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "GenericsTestFile.swifttxt.ContainerStack": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "GenericsTestFile.swifttxt.IntContainerStack": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "GenericsTestFile.swifttxt.Shape": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "GenericsTestFile.swifttxt.Square": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "GenericsTestFile.swifttxt.Stack": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "GenericsTestFile.swifttxt.SuffixableContainer": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionContainer": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionContainerwhereItem==Double": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionContainerwhereItem:Equatable": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionContainerStack:SuffixableContainer": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionIntContainerStack:SuffixableContainer": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionStack": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + ] + compareCounts(counts, expectedCounts) } func testInitializers() throws { @@ -115,6 +298,13 @@ class SwiftAPIViewCoreTests: XCTestCase { let generated = try manager.run() let expected = contentsOf(expectFile: "InitializersExpectFile") compare(expected: expected, actual: generated) + validateLineIds(apiview: manager.model!) + let counts = getRelatedLineMetadata(apiview: manager.model!) + let expectedCounts: [String: ReviewLineData] = [ + "InitializersTestFile.swifttxt": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "InitializersTestFile.swifttxt.InitializersTestClass": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + ] + compareCounts(counts, expectedCounts) } func testOperators() throws { @@ -124,6 +314,16 @@ class SwiftAPIViewCoreTests: XCTestCase { let generated = try manager.run() let expected = contentsOf(expectFile: "OperatorExpectFile") compare(expected: expected, actual: generated) + validateLineIds(apiview: manager.model!) + let counts = getRelatedLineMetadata(apiview: manager.model!) + let expectedCounts: [String: ReviewLineData] = [ + "OperatorTestFile.swifttxt": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "OperatorTestFile.swifttxt.CongruentPrecedence": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "OperatorTestFile.swifttxt.Vector2D": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionVector2D": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionVector2D:Equatable": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + ] + compareCounts(counts, expectedCounts) } func testPrivateInternal() throws { @@ -133,6 +333,10 @@ class SwiftAPIViewCoreTests: XCTestCase { let generated = try manager.run() let expected = contentsOf(expectFile: "PrivateInternalExpectFile") compare(expected: expected, actual: generated) + validateLineIds(apiview: manager.model!) + let counts = getRelatedLineMetadata(apiview: manager.model!) + let expectedCounts: [String: ReviewLineData] = [:] + compareCounts(counts, expectedCounts) } func testProperties() throws { @@ -142,6 +346,13 @@ class SwiftAPIViewCoreTests: XCTestCase { let generated = try manager.run() let expected = contentsOf(expectFile: "PropertiesExpectFile") compare(expected: expected, actual: generated) + validateLineIds(apiview: manager.model!) + let counts = getRelatedLineMetadata(apiview: manager.model!) + let expectedCounts: [String: ReviewLineData] = [ + "PropertiesTestFile.swifttxt": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "PropertiesTestFile.swifttxt.PropertiesTestClass": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + ] + compareCounts(counts, expectedCounts) } func testProtocols() throws { @@ -151,6 +362,38 @@ class SwiftAPIViewCoreTests: XCTestCase { let generated = try manager.run() let expected = contentsOf(expectFile: "ProtocolExpectFile") compare(expected: expected, actual: generated) + validateLineIds(apiview: manager.model!) + let counts = getRelatedLineMetadata(apiview: manager.model!) + let expectedCounts: [String: ReviewLineData] = [ + "ProtocolTestFile.swifttxt": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.Aged": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.ComposedPerson": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.CounterDataSource": ReviewLineData(relatedToCount: 1, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.Dice": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionDice:TextRepresentable": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.FullyNamed": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.Hamster": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.Named": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.OnOffSwitch": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.Person": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.PrettyTextRepresentable": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionPrettyTextRepresentable": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.RandomNumberGenerator": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionRandomNumberGenerator": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.SomeClass": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.SomeInitProtocol": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.SomeOtherInitProtocol": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.SomeProtocol": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.SomeSubClass": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.SomeSuperClass": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.TextRepresentable": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.Togglable": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionArray:TextRepresentablewhereElement:TextRepresentable": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "extensionCollectionwhereElement:Equatable": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "ProtocolTestFile.swifttxt.CounterDataSource.increment(forCount:Int)->Int": ReviewLineData(relatedToCount: 1, isContextEndCount: 0) + + ] + compareCounts(counts, expectedCounts) } func testSwiftUI() throws { @@ -160,5 +403,13 @@ class SwiftAPIViewCoreTests: XCTestCase { let generated = try manager.run() let expected = contentsOf(expectFile: "SwiftUIExpectFile") compare(expected: expected, actual: generated) + validateLineIds(apiview: manager.model!) + let counts = getRelatedLineMetadata(apiview: manager.model!) + let expectedCounts: [String: ReviewLineData] = [ + "SwiftUITestFile.swifttxt": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "SwiftUITestFile.swifttxt.ViewBuilderExample": ReviewLineData(relatedToCount: 0, isContextEndCount: 1), + "SwiftUITestFile.swifttxt.ViewBuilderExample.createView()->someView": ReviewLineData(relatedToCount: 1, isContextEndCount: 0), + ] + compareCounts(counts, expectedCounts) } } diff --git a/src/swift/SwiftAPIViewCore/Tests/TestUtilityTests.swift b/src/swift/SwiftAPIViewCore/Tests/TestUtilityTests.swift new file mode 100644 index 00000000000..adab56db1c7 --- /dev/null +++ b/src/swift/SwiftAPIViewCore/Tests/TestUtilityTests.swift @@ -0,0 +1,134 @@ +// -------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// The MIT License (MIT) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the ""Software""), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// +// -------------------------------------------------------------------------- + +import XCTest +@testable import SwiftAPIViewCore + +class TestUtilityTests: XCTestCase { + + override func setUpWithError() throws { + try super.setUpWithError() + continueAfterFailure = false + SharedLogger.set(logger: NullLogger(), withLevel: .info) + } + + private func compare(expected: String, actual: String) { + let actualLines = actual.split(separator: "\n", omittingEmptySubsequences: false).map { String($0) } + let expectedLines = expected.split(separator: "\n", omittingEmptySubsequences: false).map { String($0) } + for (i, expected) in expectedLines.enumerated() { + let actual = actualLines[i] + if (actual == expected) { + continue + } + XCTFail("Line \(i): (\(actual) is not equal to (\(expected)") + } + XCTAssertEqual(actualLines.count, expectedLines.count, "Number of lines does not match") + } + + func testReviewLineText() throws { + let line = ReviewLine() + var options = ReviewTokenOptions() + options.hasSuffixSpace = false + options.hasPrefixSpace = false + line.tokens = [ReviewToken(kind: .text, value: "Some text", options: options)] + let model = CodeModel(packageName: "Test", packageVersion: "0.0", statements: []) + model.reviewLines = [line, ReviewLine(), ReviewLine(), line] + let generated = model.text + let expected = "Some text\n\n\nSome text\n" + compare(expected: expected, actual: generated) + } + + func testReviewLineTextWithChildren() throws { + let model = CodeModel(packageName: "Test", packageVersion: "0.0", statements: []) + var options = ReviewTokenOptions() + options.hasSuffixSpace = false + options.hasPrefixSpace = false + let line1 = ReviewLine() + line1.tokens = [ReviewToken(kind: .text, value: "Some text", options: options)] + + let line2 = ReviewLine() + line2.tokens = [ReviewToken(kind: .text, value: "public class Foo()", options: options)] + let child1 = ReviewLine() + child1.tokens = [ReviewToken(kind: .text, value: "func getFoo() -> Foo", options: options)] + let child2 = ReviewLine() + let child3 = ReviewLine() + child3.tokens = [ReviewToken(kind: .text, value: "func setFoo(_: Foo)", options: options)] + line2.children = [child1, child2, child3] + model.reviewLines = [line1, ReviewLine(), ReviewLine(), line2] + let generated = model.text + let expected = "Some text\n\n\npublic class Foo()\n func getFoo() -> Foo\n\n func setFoo(_: Foo)\n" + compare(expected: expected, actual: generated) + } + + func testSuffixSpaceBehavior() throws { + let model = CodeModel(packageName: "Test", packageVersion: "0.0", statements: []) + let line = ReviewLine() + var options = ReviewTokenOptions() + options.hasPrefixSpace = false + line.tokens = [ReviewToken(kind: .text, value: "A", options: options)] + options.hasSuffixSpace = true + line.tokens.append(ReviewToken(kind: .text, value: "B", options: options)) + options.hasSuffixSpace = false + line.tokens.append(ReviewToken(kind: .text, value: "C", options: options)) + model.reviewLines = [line] + let generated = model.text + let expected = "A B C\n" + compare(expected: expected, actual: generated) + } + + func testPrefixSpaceBehavior() throws { + let model = CodeModel(packageName: "Test", packageVersion: "0.0", statements: []) + let line1 = ReviewLine() + var options = ReviewTokenOptions() + options.hasSuffixSpace = false + line1.tokens = [ReviewToken(kind: .text, value: "A", options: options)] + options.hasPrefixSpace = true + line1.tokens.append(ReviewToken(kind: .text, value: "B", options: options)) + options.hasPrefixSpace = false + line1.tokens.append(ReviewToken(kind: .text, value: "C", options: options)) + + let line2 = ReviewLine() + options = ReviewTokenOptions() + options.hasPrefixSpace = true + options.hasSuffixSpace = nil + line2.tokens = [ReviewToken(kind: .text, value: "A", options: options)] + line2.tokens.append(ReviewToken(kind: .text, value: "B", options: options)) + line2.tokens.append(ReviewToken(kind: .text, value: "C", options: options)) + + let line3 = ReviewLine() + options = ReviewTokenOptions() + options.hasPrefixSpace = true + options.hasSuffixSpace = true + line3.tokens = [ReviewToken(kind: .text, value: "A", options: options)] + line3.tokens.append(ReviewToken(kind: .text, value: "B", options: options)) + line3.tokens.append(ReviewToken(kind: .text, value: "C", options: options)) + + model.reviewLines = [line1, line2, line3] + let generated = model.text + let expected = "A BC\n A B C \n A B C \n" + compare(expected: expected, actual: generated) + } +} diff --git a/src/swift/SwiftAPIViewTests/Sources/SwiftAPIViewTests.h b/src/swift/SwiftAPIViewTests/Sources/SwiftAPIViewTests.h deleted file mode 100644 index 6d4116f12b3..00000000000 --- a/src/swift/SwiftAPIViewTests/Sources/SwiftAPIViewTests.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// SwiftAPIViewTests.h -// SwiftAPIViewTests -// -// Created by Travis Prescott on 3/30/22. -// - -#import - -//! Project version number for SwiftAPIViewTests. -FOUNDATION_EXPORT double SwiftAPIViewTestsVersionNumber; - -//! Project version string for SwiftAPIViewTests. -FOUNDATION_EXPORT const unsigned char SwiftAPIViewTestsVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/src/swift/SwiftAPIViewTests/SwiftAPIViewTests.xcodeproj/project.pbxproj b/src/swift/SwiftAPIViewTests/SwiftAPIViewTests.xcodeproj/project.pbxproj index 90e0fc6e8a2..a9325ef3879 100644 --- a/src/swift/SwiftAPIViewTests/SwiftAPIViewTests.xcodeproj/project.pbxproj +++ b/src/swift/SwiftAPIViewTests/SwiftAPIViewTests.xcodeproj/project.pbxproj @@ -13,7 +13,6 @@ 0A34D5C327FF40A3008A76A6 /* EnumerationsTestFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A34D5BC27FF40A2008A76A6 /* EnumerationsTestFile.swift */; }; 0A34D5C427FF40A3008A76A6 /* ProtocolTestFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A34D5BD27FF40A2008A76A6 /* ProtocolTestFile.swift */; }; 0A34D5C527FF40A3008A76A6 /* GenericsTestFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A34D5BE27FF40A2008A76A6 /* GenericsTestFile.swift */; }; - 0A34D5C627FF40A3008A76A6 /* SwiftAPIViewTests.h in Headers */ = {isa = PBXBuildFile; fileRef = 0A34D5BF27FF40A2008A76A6 /* SwiftAPIViewTests.h */; }; 0A34D5C727FF40A3008A76A6 /* OperatorTestFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A34D5C027FF40A2008A76A6 /* OperatorTestFile.swift */; }; 0A34D5C927FF40AF008A76A6 /* FunctionsTestFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A34D5C827FF40AF008A76A6 /* FunctionsTestFile.swift */; }; 0A35C20028298ED0008C99AD /* PropertiesTestFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A35C1FF28298ED0008C99AD /* PropertiesTestFile.swift */; }; @@ -39,7 +38,6 @@ 0A34D5BC27FF40A2008A76A6 /* EnumerationsTestFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnumerationsTestFile.swift; sourceTree = ""; }; 0A34D5BD27FF40A2008A76A6 /* ProtocolTestFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProtocolTestFile.swift; sourceTree = ""; }; 0A34D5BE27FF40A2008A76A6 /* GenericsTestFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GenericsTestFile.swift; sourceTree = ""; }; - 0A34D5BF27FF40A2008A76A6 /* SwiftAPIViewTests.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SwiftAPIViewTests.h; sourceTree = ""; }; 0A34D5C027FF40A2008A76A6 /* OperatorTestFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperatorTestFile.swift; sourceTree = ""; }; 0A34D5C827FF40AF008A76A6 /* FunctionsTestFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunctionsTestFile.swift; sourceTree = ""; }; 0A35C1FF28298ED0008C99AD /* PropertiesTestFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesTestFile.swift; sourceTree = ""; }; @@ -81,7 +79,6 @@ 0A76BF9A294BB193007C776E /* PrivateInternalTestFile.swift */, 0A35C1FF28298ED0008C99AD /* PropertiesTestFile.swift */, 0A34D5BD27FF40A2008A76A6 /* ProtocolTestFile.swift */, - 0A34D5BF27FF40A2008A76A6 /* SwiftAPIViewTests.h */, 0A2E6B4F28EF4F1B001F313D /* SwiftUITestFile.swift */, ); path = Sources; @@ -111,7 +108,6 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( - 0A34D5C627FF40A3008A76A6 /* SwiftAPIViewTests.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; };