Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add support to diff DocC Archives #908

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,46 @@ extension CatalogTemplateKind {
"""
]
}

/// Content of the 'changeLog' template
static func changeLogTemplateFileContent(
frameworkName: String,
initialDocCArchiveVersion: String,
newerDocCArchiveVersion: String,
additionLinks: String,
removalLinks: String
) -> [String : String] {
[
"\(frameworkName.localizedCapitalized)_Changelog.md": """
# \(frameworkName.localizedCapitalized) Updates

@Metadata {
@PageColor(yellow)
}

Learn about important changes to \(frameworkName.localizedCapitalized).

## Overview

Browse notable changes in \(frameworkName.localizedCapitalized).

## Diff between \(initialDocCArchiveVersion) and \(newerDocCArchiveVersion)


### Change Log

#### Additions
_New symbols added in \(newerDocCArchiveVersion) that did not previously exist in \(initialDocCArchiveVersion)._

\(additionLinks)


#### Removals
_Old symbols that existed in \(initialDocCArchiveVersion) that no longer exist in \(newerDocCArchiveVersion)._

\(removalLinks)

"""
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2024 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See https://swift.org/LICENSE.txt for license information
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
*/

import ArgumentParser
import Foundation
import SwiftDocC

extension Docc {

struct GenerateChangelog: ParsableCommand {

// MARK: - Configuration

static var logHandle: LogHandle = .standardOutput

/// Command line configuration.
static var configuration = CommandConfiguration(
commandName: "generate-changelog",
abstract: "Generate a changelog with symbol diffs between documentation archives ('.doccarchive' directories).",
shouldDisplay: true)


// MARK: - Command Line Options & Arguments

@Argument(
help: ArgumentHelp(
"The path to the initial DocC Archive to be compared.",
valueName: "initialDocCArchive"),
transform: URL.init(fileURLWithPath:))
var initialDocCArchivePath: URL

@Argument(
help: ArgumentHelp(
"The path to the newer DocC Archive to be compared.",
valueName: "newerDocCArchive"),
transform: URL.init(fileURLWithPath:))
var newerDocCArchivePath: URL

@Option(
name: [.customLong("initial-archive-name", withSingleDash: false)],
help: "The name of the initial DocC Archive version to be compared."
)
var initialArchiveName: String = "Version 1"

@Option(
name: [.customLong("newer-archive-name", withSingleDash: false)],
help: "The name of the newer DocC Archive version to be compared."
)
var newerArchiveName: String = "Version 2"

@Option(
name: [.customLong("show-all", withSingleDash: false)],
help: "Boolean value to indicate whether to produce a full symbol diff, including all properties, methods, and overrides"
)
var showAllSymbols: Bool = false

// MARK: - Execution

public mutating func run() throws {
var initialDocCArchiveAPIs: [URL] = []
var newDocCArchiveAPIs: [URL] = []

if showAllSymbols {
print("Showing ALL symbols.", to: &Docc.GenerateChangelog.logHandle)
initialDocCArchiveAPIs = try findAllSymbolLinks_Full(initialPath: initialDocCArchivePath)
newDocCArchiveAPIs = try findAllSymbolLinks_Full(initialPath: newerDocCArchivePath)
} else {
print("Showing ONLY high-level symbol diffs: modules, classes, protocols, and structs.", to: &Docc.GenerateChangelog.logHandle)
initialDocCArchiveAPIs = try findAllSymbolLinks(initialPath: initialDocCArchivePath)
newDocCArchiveAPIs = try findAllSymbolLinks(initialPath: newerDocCArchivePath)
}

let initialSet = Set(initialDocCArchiveAPIs)
let newSet = Set(newDocCArchiveAPIs)

// Compute additions and removals to both sets
let additionsToNewSet = newSet.subtracting(initialSet)
let removedFromOldSet = initialSet.subtracting(newSet)

// The framework name is the path component after "/documentation/".
var potentialFrameworkName = try findFrameworkName(initialPath: initialDocCArchivePath)
if potentialFrameworkName == nil {
potentialFrameworkName = try findFrameworkName(initialPath: newerDocCArchivePath)
}
let frameworkName: String = potentialFrameworkName ?? "No_Framework_Name"

let additionLinks = groupSymbols(symbolLinks: additionsToNewSet, frameworkName: frameworkName)
let removalLinks = groupSymbols(symbolLinks: removedFromOldSet, frameworkName: frameworkName)

// Create markdown file with changes in the newer DocC Archive that do not exist in the initial DocC Archive.
for fileNameAndContent in CatalogTemplateKind.changeLogTemplateFileContent(frameworkName: frameworkName, initialDocCArchiveVersion: initialArchiveName, newerDocCArchiveVersion: newerArchiveName, additionLinks: additionLinks, removalLinks: removalLinks) {
let fileName = fileNameAndContent.key
let content = fileNameAndContent.value
let filePath = initialDocCArchivePath.deletingLastPathComponent().appendingPathComponent(fileName)
try FileManager.default.createFile(at: filePath, contents: Data(content.utf8))
print("\nOutput file path: \(filePath)", to: &Docc.GenerateChangelog.logHandle)
}
}

/// The framework name is the path component after "/documentation/".
func findFrameworkName(initialPath: URL) throws -> String? {
guard let enumerator = FileManager.default.enumerator(
at: initialPath,
includingPropertiesForKeys: [],
options: .skipsHiddenFiles,
errorHandler: nil
) else {
return nil
}

var frameworkName: String?
for case let filePath as URL in enumerator {
let pathComponents = filePath.pathComponents
var isFrameworkName = false
for pathComponent in pathComponents {
if isFrameworkName {
frameworkName = pathComponent
return frameworkName
}

if pathComponent == "documentation" {
isFrameworkName = true
}
}
}

return frameworkName
}

/// Given the identifier url, cut off everything preceding /documentation/ and append this resulting string to doc:
func findExternalLink(identifierURL: URL) -> String {
var resultantURL = identifierURL.absoluteString
var shouldAppend = false
for pathComponent in identifierURL.pathComponents {
if pathComponent == "documentation" {
resultantURL = "doc:"
shouldAppend = true
}
if shouldAppend {
resultantURL.append(pathComponent + "/")
}
}
return resultantURL
}

/// Given a URL, return each of the symbols by their unique identifying links
func findAllSymbolLinks(initialPath: URL) throws -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: initialPath,
includingPropertiesForKeys: [],
options: .skipsHiddenFiles,
errorHandler: nil
) else {
return []
}

var returnSymbolLinks: [URL] = []
for case let filePath as URL in enumerator {
if filePath.lastPathComponent.hasSuffix(".json") {
let symbolLink = try findSymbolLink(symbolPath: filePath)
let symbolKind = try findKind(symbolPath: filePath)

if (symbolLink != nil && symbolKind != nil) {
if let validSymbol = symbolKind?.contains("module") {
if validSymbol == true {
returnSymbolLinks.append(symbolLink!)
}
}

if let validSymbol = symbolKind?.contains("class") {
if validSymbol == true {
returnSymbolLinks.append(symbolLink!)
}
}

if let validSymbol = symbolKind?.contains("protocol") {
if validSymbol == true {
returnSymbolLinks.append(symbolLink!)
}
}

if let validSymbol = symbolKind?.contains("struct") {
if validSymbol == true {
returnSymbolLinks.append(symbolLink!)
}
}
}
}
}
return returnSymbolLinks
}

/// Given a URL, return each of the symbols by their unique identifying links
func findAllSymbolLinks_Full(initialPath: URL) throws -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: initialPath,
includingPropertiesForKeys: [],
options: .skipsHiddenFiles,
errorHandler: nil
) else {
return []
}

var returnSymbolLinks: [URL] = []
for case let filePath as URL in enumerator {
if filePath.lastPathComponent.hasSuffix(".json") {
let symbolLink = try findSymbolLink(symbolPath: filePath)
if symbolLink != nil {
returnSymbolLinks.append(symbolLink!)
}
}
}

return returnSymbolLinks
}

func findSymbolLink(symbolPath: URL) throws -> URL? {
struct ContainerWithTopicReferenceIdentifier: Codable {
var identifier: ResolvedTopicReference
}

let renderJSONData = try Data(contentsOf: symbolPath)
let decoder = RenderJSONDecoder.makeDecoder()

do {
let identifier = try decoder.decode(ContainerWithTopicReferenceIdentifier.self, from: renderJSONData).identifier
return identifier.url
} catch {
return nil
}
}

func findKind(symbolPath: URL) throws -> String? {
struct ContainerWithKind: Codable {
var metadata: RenderMetadata
}

let renderJSONData = try Data(contentsOf: symbolPath)
let decoder = RenderJSONDecoder.makeDecoder()

do {
let metadata = try decoder.decode(ContainerWithKind.self, from: renderJSONData).metadata
return metadata.symbolKind
} catch {
return nil
}
}

/// Process lists of symbols to group them according to the highest level path component, split by spaces.
func groupSymbols(symbolLinks: Set<URL>, frameworkName: String) -> String {
// Sort list alphabetically
let sortedSymbols: [URL] = symbolLinks.sorted { $0.absoluteString.localizedCompare($1.absoluteString) == .orderedAscending }

var links: String = ""

// find most similar path up until framework name by iterating over path components one at a time
guard var first = sortedSymbols.first else {
return links
}

for symbol in sortedSymbols.dropFirst() {
let parent: String = first.absoluteString.commonPrefix(with: symbol.absoluteString)

// If there are no common path components, add a space. Then reset the first to find the next parent.
if parent.localizedLowercase.hasSuffix(frameworkName + "/") {
links.append("\n")
first = symbol
}

links.append("\n- <\(findExternalLink(identifierURL: symbol))>")
}

return links
}

}
}
1 change: 1 addition & 0 deletions Sources/SwiftDocCUtilities/Docc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ private var subcommands: [ParsableCommand.Type] {
Docc._Index.self,
Docc.Init.self,
Docc.Merge.self,
Docc.GenerateChangelog.self,
]
#if canImport(NIOHTTP1)
subcommands.insert(Docc.Preview.self, at: 1)
Expand Down