Skip to content

RedMadRobot/synopsis

Repository files navigation

Description

The package is designed to gather information from Swift source files and compile this information into concrete objects with strongly typed properties containing descriptions of found symbols.

In other words, if you have a source code file like

// MyClass.swift

/// My class does nothing.
open class MyClass {}

Synopsis will give you structurized information that there's a class, it's open and named MyClass, with no methods nor properties, and the class is documented as My class does nothing. Also, it has no parents.

Installation

Swift Package Manager dependency

Package.Dependency.package(
    url: "https://github.com/RedMadRobot/synopsis",
    from: "1.0.0"
)

Usage

Synopsis structure is your starting point. This structure provides you with an init(files:) initializer that accepts a list of file URLs of your *.swift source code files.

let mySwiftFiles: [URL] = getFiles()

let synopsis = Synopsis(files: mySwiftFiles)

Initialized Synopsis structure has properties classes, structures, protocols, enums and functions containing descirpitons of found classes, structs, protocols, enums and high-level free functions respectively. You may also examine parsingErrors property with a list of problems occured during the compilation process.

struct Synopsis {
    let classes:        [ClassDescription]
    let structures:     [StructDescription]
    let protocols:      [ProtocolDescription]
    let enums:          [EnumDescription]
    let functions:      [FunctionDescription]
    let parsingErrors:  [SynopsisError]
}

Meta-information about found classes, structs and protocols is organized as ClassDescription, StructDescription or ProtocolDescription structs respectively. Each of these implements an Extensible protocol.

struct ClassDescription:    Extensible {}
struct StructDescription:   Extensible {}
struct ProtocolDescription: Extensible {}
protocol Extensible: Equatable, CustomDebugStringConvertible {
    var comment:        String?
    var annotations:    [Annotation]
    var declaration:    Declaration
    var accessibility:  Accessibility
    var name:           String
    var inheritedTypes: [String]
    var properties:     [PropertyDescription]
    var methods:        [MethodDescription]

    var verse: String // this one is special
}

Extensibles (read like «classes», «structs» or «protocols») include

  • comment — an optional documentation above the extensible.
  • annotations — a list of Annotation instances parsed from the comment; see Annotation for more details.
  • declaration — an information, where this current extensible could be found (file, line number, column number etc.); see Declaration for more details.
  • accessibility — an enum of private, internal, public and open.
  • name — an extensible name.
  • inheritedTypes — a list of all parents, if any.
  • properties — a list of all properties; see Property for more details.
  • methods — a list of methods, including initializers; see Methods and functions for more details.

There's also a special computed property verse: String, which allows to obtain the Extensible as a source code. This is a convenient way of composing new utility classes, see Code generation, templates and versing for more information.

All extensibles support Equatable and CustomDebugStringConvertible protocols, and extend Sequence with subscript(name:) and contains(name:) methods.

extension Sequence where Iterator.Element: Extensible {
    subscript(name: String) -> Iterator.Element?
    func contains(name: String) -> Bool
}
struct EnumDescription: Equatable, CustomDebugStringConvertible {
    let comment:        String?
    let annotations:    [Annotation]
    let declaration:    Declaration
    let accessibility:  Accessibility
    let name:           String
    let inheritedTypes: [String]
    let cases:          [EnumCase] // !!! enum cases !!!
    let properties:     [PropertyDescription]
    let methods:        [MethodDescription]

    var verse: String
}

Enum descriptions contain almost the same information as the extensibles, but also include a list of cases.

struct EnumCase: Equatable, CustomDebugStringConvertible {
    let comment:        String?
    let annotations:    [Annotation]
    let name:           String
    let defaultValue:   String? // everything after "=", e.g. case firstName = "first_name"
    let declaration:    Declaration
    
    var verse:          String
}

All enum cases have String names, and declarations. They may also have documentation (with annotations) and optional defaultValue: String?.

You should know, that defaultValue is a raw text, which may contain symbols like quotes.

enum CodingKeys {
    case firstName = "first_name" // defaultValue == "\"first_name\""
}
class FunctionDescription: Equatable, CustomDebugStringConvertible {
    let comment:        String?
    let annotations:    [Annotation]
    let accessibility:  Accessibility
    let name:           String
    let arguments:      [ArgumentDescription]
    let returnType:     TypeDescription?
    let declaration:    Declaration
    let kind:           Kind // see below
    let body:           String?
    
    var verse: String
    
    enum Kind {
        case free
        case class
        case static
        case instance
    }
}

Synopsis assumes that method is a function subclass with a couple additional features.

All functions have

  • optional documentation;
  • annotations;
  • accessibility (private, internal, public or open);
  • name;
  • list of arguments (of type ArgumentDescription, see below);
  • optional return type (of type TypeDescription, see below);
  • a declaration (of type Declaration, see below);
  • kind;
  • optional body.

Methods also have a computed property isInitializer: Bool.

class MethodDescription: FunctionDescription {
    var isInitializer: Bool {
        return name.hasPrefix("init(")
    }
}
// literally no more reasonable code

While most of the FunctionDescription properties are self-explanatory, some of them have their own quirks and tricky details behind. For instance, method names must contain round brackets () and are actually a kind of a signature without types, e.g. myFunction(argument:count:).

func myFunction(arg argument: String) -> Int {}
// this function is named "myFunction(arg:)"

Function kind could only be free, while methods could have a class, static or instance kind.

Methods inside protocols have the same set of properties, but contain no body. The body itself is a text inside curly brackets {...}, but without brackets.

func topLevelFunction() {
}
// this function body is equal to "\n"
struct ArgumentDescription: Equatable, CustomDebugStringConvertible {
    let name:           String
    let bodyName:       String
    let type:           TypeDescription
    let defaultValue:   String?
    let annotations:    [Annotation]
    let comment:        String?

    var verse: String
}

Function and method arguments all have external and internal names, a type, an optional defaultValue, own optional documentation and annotations.

External name is an argument name when the function is called. Internal bodyName is used insibe function body. Both are mandatory, though they could be equal.

Argument type is described below, see TypeDescription.

Properties are represented with a PropertyDescription struct.

struct PropertyDescription: Equatable, CustomDebugStringConvertible {
    let comment:        String?
    let annotations:    [Annotation]
    let accessibility:  Accessibility
    let constant:       Bool                // is it "let"? If not, it's "var"
    let name:           String
    let type:           TypeDescription
    let defaultValue:   String?             // literally everything after "=", if there is a "="
    let declaration:    Declaration
    let kind:           Kind                // see below
    let body:           String?             // literally everything between curly brackets, but without brackets

    var verse: String
    
    enum Kind {
        case class
        case static
        case instance
    }
}

Properties could have documentation and annotations. All properties have own kind of class, static or instance. All properties have names, constant boolean flag, accessibility, type (see TypeDescription), a raw defaultValue: String? and a declaration: Declaration.

Computed properties could also have a body, like functions. The body itself is a text inside curly brackets {...}, but without brackets.

struct Annotation: Equatable, CustomDebugStringConvertible {
    let name: String
    let value: String?
}

Extensibles, enums, functions, methods and properties are all allowed to have documentation.

Synopsis parses documentation in order to gather special annotation elements with important meta-information. These annotations resemble Java annotations, but lack their compile-time checks.

All annotations are required to have a name. Annotations can also contain an optional String value.

Annotations are recognized by the @ symbol, for instance:

/// @model
class Model {}

N.B. Documentation comment syntax is inherited from the Swift compiler, and for now supports block comments and triple slash comments. Method or function arguments usually contain documentation in the nearby inline comments, see below.

Use line breaks or semicolons ; to divide separate annotations:

/**
 @annotation1
 @annotation2; @annotation3
 @annotation4 value1
 @annotation5 value2; @annotation5 value3
 @anontation6; @annotation7 value4
 */

Keep annotated function or method arguments on their own separate lines for readability:

func doSomething(
    with argument: String,    // @annotation1
    or argument2: Int,        /* @annotation2 value1; @annotation3 value2 */
    finally argument3: Double // @annotation4; annotation5 value3
) -> Int

Though it is not prohibited to have annotations above arguments:

func doSomething(
    // @annotation1
    with argument: String,
    /* @annotation2 value1; @annotation3 value2 */
    or argument2: Int,
    // @annotation4; annotation5 value3
    finally argument3: Double
) -> Int

Property types, argument types, function return types are represented with a TypeDescription enum with cases:

  • boolean
  • integer
  • floatingPoint
  • doublePrecision
  • string
  • date
  • data
  • optional(wrapped: TypeDescription)
  • object(name: String)
  • array(element: TypeDescription)
  • map(key: TypeDescription, value: TypeDescription)
  • generic(name: String, constraints: [TypeDescription])

While some of these cases are self-explanatory, others need additional clarification.

integer type for now has a limitation, as it represents all Int types like Int16, Int32 etc. This means Synopsis won't let you determine the Int size.

optional type contains a wrapped TypeDescription for the actual value type. Same happens for arrays, maps and generics.

All object types except for Data, Date, NSData and NSDate are represented with an object(name: String) case. So, while CGRect is a struct, Synopsis will still thinks it is an object("CGRect").

struct Declaration: Equatable {
    public let filePath:        URL
    public let rawText:         String
    public let offset:          Int
    public let lineNumber:      Int
    public let columnNumber:    Int
}

Classes, structs, protocols, properties, methods etc. — almost all detected source code elements have a declaration: Declaration property.

Declaration structure encapsulates several properties:

  • filePath — a URL to the end file, where the source code element was detected;
  • rawText — a raw line, which was parsed in order to detect source code element;
  • offset — a numer of symbols from the beginning of file to the detected source code element;
  • lineNumber — self-explanatory;
  • columnNumber — self-explanatory; starts from 1.

Each source code element provides a computed String property verse, which allows to obtain this element's source code.

This source code is composed programmatically, thus it may differ from the by-hand implementation.

This allows to generate new source code by composing, e.g, ClassDescrption instances by hand.

Though, each ClassDescription instance requires a Declaration, which contains a filePath, rawText, offset and other properties yet to be defined, because such source code hasn't been generated yet.

This is why ClassDescription and others provide you with a template(...) constructor, which replaces declaration with a special mock object.

Please, consider reviewing Tests/SynopsisTests/Versing test cases in order to get familiar with the concept.

func testVerse_fullyPacked_returnsAsExpected() {
    let enumDescription = EnumDescription.template(
        comment: "Docs",
        accessibility: Accessibility.`private`,
        name: "MyEnum",
        inheritedTypes: ["String"],
        cases: [
            EnumCase.template(comment: "First", name: "firstName", defaultValue: "\"first_name\""),
            EnumCase.template(comment: "Second", name: "lastName", defaultValue: "\"last_name\""),
        ],
        properties: [],
        methods: []
    )
    
    let expectedVerse = """
    /// Docs
    private enum MyEnum: String {
        /// First
        case firstName = "first_name"

        /// Second
        case lastName = "last_name"
    }

    """
    
    XCTAssertEqual(enumDescription.verse, expectedVerse)
}

Use spm_resolve.command to load all dependencies and spm_generate_xcodeproj.command to assemble an Xcode project file. Also, ensure Xcode targets macOS when running tests.