ryepup/c4-lab

View on GitHub
src/core/parse/Parser.ts

Summary

Maintainability
B
4 hrs
Test Coverage
import * as path from 'path'
import * as sexp from 'sexpr-plus'
import { Md5 } from 'ts-md5/dist/md5'

import { IEdge, IGraph, INode, isEdge, NodeId } from '../interfaces'

import InvalidDirectionError from './InvalidDirectionError'
import NameNotFoundError from './NameNotFoundError'
import OptsNotFoundError from './OptsNotFoundError'
import ParseError from './ParseError'
import TitleNotAStringError from './TitleNotAStringError'
import TitleNotAtTopLevelError from './TitleNotAtTopLevelError'

export const pathToId = (p: string) => Md5.hashStr(p, false) as string

function isString(x: sexp.Item): x is sexp.String {
    return x && x.type === 'string'
}

function isList(x: sexp.Item): x is sexp.List {
    return x && x.type === 'list'
}

function isAtom(x: sexp.Item): x is sexp.Atom {
    return x && x.type === 'atom'
}

const stripComments = (text: string) => text.replace(/^\s*;;.*$/gm, '')

const keywordAliases = [
    { from: /^:desc.*/i, to: 'description' },
    { from: /^:to/i, to: 'to' },
    { from: /^:tech/i, to: 'tech' },
    { from: /^:dir.*/i, to: 'direction' },
]

function canonicalize(key: string) {
    return keywordAliases
        .filter(({ from }) => from.test(key))
        .map(({ to }) => to)[0] || key
}

function parseKeywordArgs(kwargs: sexp.Item[], allowed = /.*/): any {
    if (kwargs.length === 0) { return {} }

    const [keyword, value, ...rest] = kwargs
    if (!isAtom(keyword)) { throw new Error('Expected keyword to be an atom') }
    if (!isString(value)) { throw new Error('Expected value to be a string') }

    const key = canonicalize(keyword.content)

    return Object.assign(
        allowed.test(key) ? { [key]: value.content } : {},
        parseKeywordArgs(rest, allowed))
}

export class Parser {

    private idMap: { [id: string]: INode }
    private pathMap: { [id: string]: NodeId }
    private edges: IEdge[]
    private items: INode[]
    private graphTitle: string = ''

    constructor() {
        this.idMap = {}
        this.pathMap = {}
        this.edges = []
        this.items = []
    }

    public parse(text: string): IGraph {
        const tokens = sexp.parse(text)
        this.buildIR(tokens)
        for (const e of this.edges) {
            this.postProcessEdge(e)
        }

        return {
            edges: this.edges,
            idMap: this.idMap,
            items: this.items,
            pathMap: this.pathMap,
            roots: this.items
                .filter((x) => !x.parentId)
                .map((x) => x.id),
            title: this.graphTitle,
        }
    }

    private buildIR(tokens: sexp.Item[], parent?: INode) {
        return tokens.map((x) => this.buildIRNode(x, parent))
    }

    private buildIRNode(token: sexp.Item, parent?: INode): INode | IEdge | null {
        if (!isList(token)) {
            throw new Error('bare string not permitted')
        }
        const [head, ...tail] = token.content
        if (!isAtom(head)) {
            throw new Error(`expected atom, found ${head.type}`)
        }
        const node = this._buildIRNode(head.content, tail, parent)
        if (!node) { return null }

        if (!isEdge(node) && !node.children.every(isEdge)) {
            node.canExpand = true
        }
        return node
    }

    private _buildIRNode(head: string, tail: sexp.Item[], parent?: INode): INode | IEdge | null {
        const type = head.replace(/^def(ine)?-/i, '').toLowerCase().trim()
        switch (type) {
            case 'edge':
                return this.edge(tail, parent)
            case 'title':
                this.title(tail, parent)
                return null
            default:
                return this.item(type, tail, parent)
        }
    }

    private item(type: string, tokens: sexp.Item[], parent?: INode): INode {
        const [opts, ...children] = tokens
        if (!isList(opts)) {
            throw new OptsNotFoundError(type)
        }
        const [name, ...kwargs] = opts.content
        if (!isString(name) || name.content === '') {
            throw new NameNotFoundError(type)
        }

        const p = this.pathToNode(name.content, parent).toString()
        const node: INode = {
            children: [],
            id: pathToId(p),
            name: name.content,
            path: p,
            type,
        }
        if (parent) {
            node.parentId = parent.id
        }

        this.idMap[node.id] = node
        this.pathMap[node.path] = node.id
        Object.assign(node,
            parseKeywordArgs(kwargs, /description|tech/))
        this.items.push(node)
        for (const n of this.buildIR(children, node)) {
            if (n) {
                node.children.push(n)
            }
        }
        return node
    }

    private edge(kwargs: sexp.Item[], parent?: INode): IEdge {
        if (!parent) { throw new Error('edge at top level?') }
        const edge = Object.assign(
            { sourceId: parent.id, type: 'edge' },
            parseKeywordArgs(kwargs, /description|to|tech|direction/))
        this.edges.push(edge)
        return edge
    }

    private title([input]: sexp.Item[], parent?: INode): void {
        if (!isString(input)) {
            throw new TitleNotAStringError(input.toString())
        }
        if (parent) { throw new TitleNotAtTopLevelError() }
        this.graphTitle = input.content
    }

    private pathToNode(name: string, parent?: INode): string {
        return parent
            ? this.idMap[parent.id].path + '/' + name
            : name
    }

    private postProcessEdge(edge: IEdge) {
        if (edge.to.startsWith('.')) {
            const src = this.idMap[edge.sourceId]
            if (!src.parentId) {
                throw new Error('relative paths have to have a parent')
            }
            const parent = this.idMap[src.parentId]
            edge.to = path.join(parent.path, edge.to)
        }

        edge.destinationId = this.pathMap[edge.to]

        if (!edge.destinationId) {
            throw new ParseError(`Could not find node '${edge.to}'`)
        }
        if (edge.sourceId === edge.destinationId) {
            throw new ParseError(`Edge cannot be both from and to '${edge.to}'`)
        }
        if (edge.direction) {
            if (/^(push|pull|both)$/i.test(edge.direction)) {
                edge.direction = edge.direction.toLowerCase()
            } else {
                throw new InvalidDirectionError(edge.direction)
            }
        }

        edge.id = pathToId(edge.sourceId + edge.destinationId)
        edge.sourceParentIds = this.findParentIds(edge.sourceId)
        edge.destinationParentIds = this.findParentIds(edge.destinationId)
    }

    private findParentIds(id: string): string[] {
        const node = this.idMap[id]
        if (!node.parentId) { return [] }

        return [node.parentId]
            .concat(this.findParentIds(node.parentId))
    }
}

/*
TODO: detect errors:

* duplicate paths

*/