Skip to content
Permalink
Browse files

Merge pull request #4 from tib/master

Added JSON output format support
  • Loading branch information
twostraws committed Feb 10, 2020
2 parents 0cc28a1 + 58415b9 commit d0430b12d57d98298b4eedb4eefc3736c88341b9
@@ -1,5 +1,6 @@
.DS_Store
/.build
/.swiftpm
/Packages
/*.xcodeproj
xcuserdata/
@@ -50,14 +50,23 @@ make install

From now on you can use the `sitrep` command to scan Swift projects.

## Command line flags

```bash
sitrep --json
sitrep ~/path/to/your/project/root --json
```

You can use the optional `--json` flag to print a pretty formatted JSON output.


## Try it yourself

Sitrep is written using Swift 5.1. You can either build and run the executable directly, or integrate the SitrepCore library into your own code.

To build Sitrep, clone this repository and open Terminal in the repository root directory. Then run:

```
```bash
swift build
swift run sitrep ~/path/to/your/project/root
```
@@ -75,7 +84,6 @@ Some suggestions you might want to explore:

- Converting more of the tracked data (number of functions, parameters to functions, length of functions, etc) into reported data.
- Adding a sitrep.yml file that lets users configure how files are scanned, such as the ability to ignore certain directories, what kind of output is printed, or to enable stripped parsing of individual types using `BodyStripper`.
- Additional command-line options, such as whether to output JSON.
- Reading more data from the parsed files, and using it to calculate things such as cyclomatic complexity.
- Reading non-Swift data, such as number of storyboard scenes, number of outlets, number of assets in asset catalogs, etc.

@@ -13,12 +13,17 @@ import SitrepCore

let file: String

if CommandLine.arguments.count == 1 {
let flagPrefix = "--"
let input = CommandLine.arguments.dropFirst()
var arguments = input.filter { !$0.hasPrefix(flagPrefix) }
var flags = input.filter { $0.hasPrefix(flagPrefix) }.map { $0.dropFirst(flagPrefix.count) }

if arguments.isEmpty {
file = FileManager.default.currentDirectoryPath
} else {
file = CommandLine.arguments[1]
file = arguments[0]
}

let url = URL(fileURLWithPath: file)
var app = Scan(rootURL: url)
app.run()
app.run(reportType: flags.contains("json") ? .json : .text)
@@ -0,0 +1,60 @@
//
// Report.swift
// Part of Sitrep, a tool for analyzing Swift projects.
//
// Copyright (c) 2020 Hacking with Swift
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See LICENSE for license information
//
import Foundation

/// The root class for the JSON report
struct Report: Codable {

/// A named statistical value
struct Stat: Codable {
/// Name of the statistic
var name: String
/// Value of the statistic
var value: Int
}

/// Scan related statistics
struct Scan: Codable {
/// Number of scanned files
var scannedFiles: Int
/// Total lines of code
var totalLinesOfCode: Int
/// Total stripped lines of code
var totalStrippedLinesOfCode: Int
/// Longest file name and lenght
var longestFile: Stat?
/// Longest type name and lenght
var longestType: Stat?
}

/// Object related statistics
struct Object: Codable {
/// Number of structs
var structs: Int
/// Number of classes
var classes: Int
/// Number of enums
var enums: Int
/// Number of protocols
var protocols: Int
/// Number of extensions
var extensions: Int
}

/// Scan statistics
var scanStats: Scan
/// Object statistics
var objects: Object
/// Import statistics
var imports: [Stat]
/// Inheritance statistics
var inheritances: [Stat]
}
@@ -65,10 +65,10 @@ public struct Results {
var totalStrippedLinesOfCode: Int {
totalStrippedCode.lines.count
}

/// How many classes inherit from UIView
var uiKitViewCount: Int {
classes.sum { $0.inheritance.first == "UIView" }
return classes.sum { $0.inheritance.first == "UIView" }
}

/// How many classes inherit from UIViewController
@@ -78,6 +78,6 @@ public struct Results {

/// How many structs conform to View
var swiftUIViewCount: Int {
structs.sum { $0.inheritance.first == "View" }
structs.sum { $0.inheritance.contains("View") }
}
}
@@ -15,23 +15,37 @@ public struct Scan {
/// The URL that was scanned in this run
let rootURL: URL

/// Output type of the generated report
public enum ReportType {
/// simple text output
case text
/// formatted json output
case json
}

/// Creates an app instance from a URL to a project directory
public init(rootURL: URL) {
self.rootURL = rootURL
}

/// Performs the whole app run: scanning files, collating results, then optionally printing a report
@discardableResult
public func run(creatingReport: Bool = true) -> (results: Results, files: [URL], failures: [URL]) {
public func run(creatingReport: Bool = true,
reportType: ReportType = .text) -> (results: Results, files: [URL], failures: [URL]) {
let detectedFiles = detectFiles()
let (scannedFiles, failures) = parse(files: detectedFiles)
let results = collate(scannedFiles)

if creatingReport {
let report = createReport(for: results, files: scannedFiles, failures: failures)
print(report)
switch reportType {
case .text:
let report = createTextReport(for: results, files: scannedFiles, failures: failures)
print(report)
case .json:
let report = createJSONReport(for: results, files: scannedFiles, failures: failures)
print(report)
}
}

return (results, detectedFiles, failures)
}

@@ -119,44 +133,97 @@ public struct Scan {
return results
}

/// Creates a report object from the scan results
func createReport(for results: Results, files: [File], failures: [URL]) -> Report {
let imports = results.imports
.allObjects
.sorted { first, second in results.imports.count(for: first) > results.imports.count(for: second) }
.compactMap { value -> Report.Stat? in
guard let name = value as? String else {
return nil
}
return .init(name: name, value: results.imports.count(for: name))
}

let inheritances = [
Report.Stat(name: "UIViewController", value: results.uiKitViewControllerCount),
Report.Stat(name: "UIView", value: results.uiKitViewCount),
Report.Stat(name: "SwiftUI.View", value: results.swiftUIViewCount),
]

var longestFileStat: Report.Stat?
if let longestFile = results.longestFile?.url?.lastPathComponent {
longestFileStat = .init(name: longestFile, value: results.longestFileLength)
}
var longestTypeStat: Report.Stat?
if let longestType = results.longestType?.name {
longestTypeStat = .init(name: longestType, value: results.longestTypeLength)
}

return .init(scanStats: .init(scannedFiles: files.count,
totalLinesOfCode: results.totalLinesOfCode,
totalStrippedLinesOfCode: results.totalStrippedLinesOfCode,
longestFile: longestFileStat,
longestType: longestTypeStat),
objects: .init(structs: results.structs.count,
classes: results.classes.count,
enums: results.enums.count,
protocols: results.protocols.count,
extensions: results.extensions.count),
imports: imports,
inheritances: inheritances)
}

/// Prints out the report for a set of files in a pretty printed JSON format
func createJSONReport(for results: Results, files: [File], failures: [URL]) -> String {
let report = self.createReport(for: results, files: files, failures: failures)
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
guard let data = try? encoder.encode(report), let string = String(data: data, encoding: .utf8) else {
return "[Error] Could not encode JSON data."
}
return string
}

/// Prints out the report for a set of files
func createReport(for results: Results, files: [File], failures: [URL]) -> String {
func createTextReport(for results: Results, files: [File], failures: [URL]) -> String {
let report = self.createReport(for: results, files: files, failures: failures)

var output = ["SITREP"]

output.append("------")
output.append("")
output.append("Overview")
output.append(" Files scanned: \(files.count)")
output.append(" Structs: \(results.structs.count)")
output.append(" Classes: \(results.classes.count)")
output.append(" Enums: \(results.enums.count)")
output.append(" Protocols: \(results.protocols.count)")
output.append(" Extensions: \(results.extensions.count)")
output.append(" Files scanned: \(report.scanStats.scannedFiles)")
output.append(" Structs: \(report.objects.structs)")
output.append(" Classes: \(report.objects.classes)")
output.append(" Enums: \(report.objects.enums)")
output.append(" Protocols: \(report.objects.protocols)")
output.append(" Extensions: \(report.objects.extensions)")

output.append("")

output.append("Sizes")
output.append(" Total lines of code: \(results.totalLinesOfCode)")
output.append(" Source lines of code: \(results.totalStrippedLinesOfCode)")
output.append(" Total lines of code: \(report.scanStats.totalLinesOfCode)")
output.append(" Source lines of code: \(report.scanStats.totalStrippedLinesOfCode)")

if let longestFile = results.longestFile?.url?.lastPathComponent {
output.append(" Longest file: \(longestFile) (\(results.longestFileLength) source lines)")
if let longestFile = report.scanStats.longestFile {
output.append(" Longest file: \(longestFile.name) (\(longestFile.value) source lines)")
}

if let longestType = results.longestType?.name {
output.append(" Longest type: \(longestType) (\(results.longestTypeLength) source lines)")
if let longestType = report.scanStats.longestType {
output.append(" Longest type: \(longestType.name) (\(longestType.value) source lines)")
}

let imports = report.imports.map { "\($0.name) (\($0.value))" }.joined(separator: ", ")

output.append("")
output.append("Structure")

let sortedImports = results.imports.allObjects.sorted { first, second in results.imports.count(for: first) > results.imports.count(for: second) }
let formattedImports = sortedImports.map { "\($0) (\(results.imports.count(for: $0)))" }
output.append(" Imports: \(formattedImports.joined(separator: ", "))")

output.append(" Imports: \(imports)")
output.append(" UIKit View Controllers: \(results.uiKitViewControllerCount)")
output.append(" UIKit Views: \(results.uiKitViewCount)")
output.append(" SwiftUI Views: \(results.swiftUIViewCount)")
output.append("")

return output.joined(separator: "\n")
}
@@ -159,7 +159,7 @@ final class SitrepCoreTests: XCTestCase {
XCTAssertEqual(results.imports.count(for: "UIKit"), 2)
XCTAssertEqual(results.imports.count(for: "SwiftUI"), 3)
}

func testSpecificInheritances() throws {
let app = Scan(rootURL: inputs)
let (results, _, _) = app.run(creatingReport: false)
@@ -176,16 +176,30 @@ final class SitrepCoreTests: XCTestCase {
XCTAssertEqual(json.count, 2113)
}

func testReportGeneration() throws {
func testTextReportGeneration() throws {
let app = Scan(rootURL: inputs)
let detectedFiles = app.detectFiles()
let (scannedFiles, failures) = app.parse(files: detectedFiles)
let results = app.collate(scannedFiles)
let report = app.createReport(for: results, files: scannedFiles, failures: failures)
let report = app.createTextReport(for: results, files: scannedFiles, failures: failures)

XCTAssertTrue(report.contains("SITREP"))
}

func testJSONReportGeneration() throws {
let app = Scan(rootURL: inputs)
let detectedFiles = app.detectFiles()
let (scannedFiles, failures) = app.parse(files: detectedFiles)
let results = app.collate(scannedFiles)
let report = app.createJSONReport(for: results, files: scannedFiles, failures: failures)

guard let data = report.data(using: .utf8) else {
XCTFail("Could not convert report string to data.")
return
}
_ = try JSONDecoder().decode(Report.self, from: data)
}

func testBodyStripperRemovedComments() throws {
let parsedBody = try SyntaxParser.parse(getInput("spacing.swift"))
let strippedBody = BodyStripper().visit(parsedBody)
@@ -215,9 +229,11 @@ final class SitrepCoreTests: XCTestCase {
("testFileCounts", testFileCounts),
("testCollationTypeCounts", testCollationTypeCounts),
("testCollationImports", testCollationImports),
("testCollationImports", testCollationImports),
("testSpecificInheritances", testSpecificInheritances),
("testEncoding", testEncoding),
("testReportGeneration", testReportGeneration),
("testTextReportGeneration", testTextReportGeneration),
("testJSONReportGeneration", testJSONReportGeneration),
("testBodyStripperRemovedComments", testBodyStripperRemovedComments),
("testCreatingReport", testCreatingReport)
]

0 comments on commit d0430b1

Please sign in to comment.
You can’t perform that action at this time.