In the previous article, We've built a nagaqueen-based tool that can parse one ooc file, detect class declarations and print its doc strings. Today, we're making a bit of infrastructure for our app to support more sizable projects.

Source path and lib folders

Parsing a single file was a nice milestone, but it's not nearly enough. We want to generate documentation for a whole project at a time: and since we'll want to cross-link the various bits of documentation we generate, we'll also need to parse the various dependencies (such as the ooc sdk, and any used library) so that we can resolve argument types and link them properly.

To undertake that task, we need to agree on a little bit of vocabulary. The thing we want to document, we'll call a project. Each folder that contains a hierarchy of folders and .ooc source files, we'll call a libFolder. The pathy part of an import directive, we'll call a spec.

A list of libFolders will be called the sourcePath, similar to the Java classpath - that's simply where we look for stuff that's imported. Normally, the source path is set from .use files, and the SDK is found using environment variables and/or the location of the rock binary on your filesystem. But, again, that's for later.

To handle the source path we'll create.. a SourcePath class! Let's put it into source/hopemage/sourcepath.ooc:

ooc
SourcePath: class {

    libFolders := ArrayList<LibFolder> new()

    add: func (libPath: String) {
        libFolders add(LibFolder new(libPath))
    }

    locate: func (spec: String) -> File {
        for (libFolder in libFolders) {
            file := File new(libFolder path, spec + ".ooc")
            if (file exists?()) {
                return file
            }
        }
        null
    }

    split: func (path: String) -> (LibFolder, String) {
        path = File new(path) getAbsolutePath()
        for (libFolder in libFolders) {
            if (path startsWith?(libFolder path)) {
                return (libFolder, libFolder toSpec(path))
            }
        }

        (null, null)
    }

}

Figuring out which imports are needed are left as an exercise to the reader. (Of course, it's easy to cheat)

As you can see, locate takes a spec and tries to find it in our list of libFolders. That'll be useful when dealing with imports, later.

As for split, as its name indicates, it splits a full path into a libFolder and a spec. This is useful in our current scenario where we'll get a whole list of paths and we'll have to figure out what their libFolders and specs are.

Split uses an ooc feature known as multi-return. It works almost like a tuple, although it's restricted to that certain scenario. We can call it several ways, for example, those all valid:

ooc
(libFolder, spec) := sourcePath split(path)
(libFolder, _) := sourcePath split(path)
libFolder := sourcePath split(path)
(_, spec) := sourcePath split(path)

An underscore stands for "ignore this value". Put your most important return values first, so that people can disregard the others without using tuple syntax, as shown on line 3 of the example above.

Let's make a class for libFolders too, in the same module:

ooc
LibFolder: class {

    path: String
    modules := ArrayList<Module> new()

    init: func (libPath: String) {
        path = File new(libPath) getAbsolutePath()
    }

    add: func (module: Module) {
        module libFolder = this
        modules add(module)
    }

    contains?: func (module: Module) -> Bool {
        modules contains?(module)
    }

    toSpec: func (path: String) -> String {
        path substring(this path size) trimLeft(File separator)
    }

}

Nothing too surprising here. If you didn't know, you can have a question mark or an exclamation mark at the end of your ooc functions, which is nice for those who return booleans and those who are destructive.

Now you're thinking with projects

We'll also need one additional class to contain all the information about our project, and we'll name it Project, in source/hopemage/project.oo:

ooc
Project: class {

    sourcePath: SourcePath
    mainFolder: LibFolder

    init: func (=sourcePath) {
        if (sourcePath libFolders empty?()) {
            raise("SourcePath is empty, bailing out!")
        }
        mainFolder = sourcePath libFolders first()
        parseFolder(mainFolder)
    }

    parseFolder: func (libFolder: LibFolder) {
        File new(libFolder path) walk(|f| 
            if (f path endsWith?(".ooc")) {
                parse(f path)
            }
            true
        )
    }

    parse: func (path: String) {
        Frontend new(sourcePath, path)
    }

}

The interesting part here is the parseFolder method, which walks a whole folder to find .ooc file, and parses all of them. We simply assume the first folder in the source path is the main one - the one we're generating the documentation of in the first place.

We've modified Frontend a bit to work with SourcePath:

ooc
Frontend: class extends OocListener {

    module: Module
    sourcePath: SourcePath

    init: func (=sourcePath, path: String) {
        (libFolder, spec) := sourcePath split(path)
        module = Module new(libFolder, spec)
        parse(path)
    }

    // other methods (callbacks, etc.)
}

We're also calling parse from Frontend init init now. As you can see, the constructor from Module has changed a bit: we want it to know to which libFolder it belongs to.

ooc
Module: class {

    libFolder: LibFolder

    types := ArrayList<Type> new()
    spec: String

    init: func (=libFolder, =spec) {
        libFolder add(this)
    }

}

Again, there are a few imports you'll need to add.

Playing with our new toys

Now that we have all the infrastructure to handle source paths, lib folders and projects correctly, let's revamp our main class. Instaed of specifying an ooc file to parse, we'll accept a --sourcepath=blah argument for our program.

Here's what Homa.ooc looks like now:

ooc
Homa: class {

    versionString := "0.1"
    sourcePath := SourcePath new()
    
    init: func 

    handle: func (args: ArrayList<String>) {
        parseArgs(args)

        if (sourcePath libFolders empty?()) {
            usage()
            exit(0)
        } else {
            parse()
        }
    }

    parseArgs: func (args: ArrayList<String>) {
        args removeAt(0)

        for (arg in args) {
            tokens := arg split('=')
            if (tokens size != 2) {
                onInvalidArg(arg)
                continue
            }
            match (tokens[0]) {
                case "--sourcepath" =>
                    sourcePath add(tokens[1]) 
                case =>
                    onInvalidArg(arg)
            }
        }
    }

    onInvalidArg: func (arg: String) {
        "Invalid argument: %s, ignoring.." printfln(arg)
    }

    usage: func {
        "homa v%s" printfln(versionString)
        "Usage: homa --sourcepath=FOLDERS" println()
    }

    parse: func {
        project := Project new(sourcePath)

        for (module in project mainFolder modules) {
            "## %s" printfln(module spec)
            for (type in module types) {
                "### %s\n\n```%s```\n\n" printfln(type name, type doc raw)
            }
        }
    }
}

ooc's match works like a switch, more powerful, but less crazy than, say, Scala's match. We have a way to complain about invalid arguments but it doesn't crash our program.

String split isn't in the default imports, so you'll have to import text/StringTokenizer to have it.

You can now run hopemage against itself, with homa --sourcefolder=source. For additional fun points, run hopemage's output against a markdown tool and open it in your browser: it looks already doc-y!

That's it for this time! I hope you enjoy this series, please tell me if I went over some things too quickly, I'll gladly include additional information in these articles.