themattrix/bashup

View on GitHub
bashup/compile/elements/fn.py

Summary

Maintainability
A
0 mins
Test Coverage
from __future__ import division

import re
import textwrap

import jinja2

from ... import parse


def compile_fns_to_bash(bashup_str):
    """
    Compiles all @fn statements in the provided bashup string. Returns a new
    string containing the original source but with every @fn statement
    replaced with the equivalent bash code.
    """
    def generate_slices():
        last = 0
        scanner = parse.FN.parseWithTabs().scanString(bashup_str)

        for parse_result, start, end in scanner:
            fn_spec = parse.FnSpec.from_parse_result(parse_result)
            compiled = compile_fn_spec_to_bash(fn_spec)
            initial_indent, body_indent = __guess_indentation(
                before_fn=bashup_str[last:start],
                fn_body=bashup_str[end:])
            yield bashup_str[last:start - len(initial_indent)]
            yield __indent(compiled, initial_indent, body_indent)
            last = end

        yield bashup_str[last:]

    return ''.join(generate_slices())


def compile_fn_spec_to_bash(fn_spec):
    """
    Populates the fn template with the given spec.
    """
    template = jinja2.Environment(
        trim_blocks=True,
        lstrip_blocks=True,
        auto_reload=False,
        autoescape=False,
        newline_sequence='\n'
    ).from_string(__FN_TEMPLATE)

    return template.render(
        fn=fn_spec,
        param_usage=''.join(__usage_for(arg) for arg in fn_spec.args),
        arg_list=' '.join(__quoted_arg(arg) for arg in fn_spec.args))


#
# Private Helpers
#

__FN_TEMPLATE = textwrap.dedent("""
    #
    # usage: {{ fn.name }} {{ param_usage }}[ARGS]
    #
    {% if fn.args|length %}
    {{ fn.name }}() {
        {% for arg in fn.args %}
        {% if arg.value is none %}
        local {{ arg.name }}
        local {{ arg.name }}__set=0
        {% else %}
        local {{ arg.name }}={{ arg.value }}
        {% endif %}
        {% endfor %}
        local args=()

        while (( $# )); do
            {% for arg in fn.args %}
            {% set param = "--" ~ arg.name.replace('_', '-') %}
            {% if loop.index == 1 %}
            if [[ "${1}" == {{ param }}=* ]]; then
            {% else %}
            elif [[ "${1}" == {{ param }}=* ]]; then
            {% endif %}
                {{ arg.name }}=${1#{{ param }}=}
                {% if arg.value is none %}
                {{ arg.name }}__set=1
                {% endif %}
            {% endfor %}
            else
                args+=("${1}")
            fi
            shift
        done

        {% for arg in fn.args %}
        {% if arg.value is none %}
        {% set param = "--" ~ arg.name.replace('_', '-') %}
        if ! (( {{ arg.name }}__set )); then
            echo "[ERROR] The {{ param }} parameter must be given."
            return 1
        fi
        {% endif %}
        {% endfor %}

        __{{ fn.name }} {{ arg_list }} "${args[@]}"
    }

    __{{ fn.name }}() {
        {% for arg in fn.args %}
        local {{ arg.name }}={{ "${" ~ loop.index ~ "}" }}
        {% endfor %}
        shift {{ fn.args|length }}
    {% else %}
    {{ fn.name }}() {
    {%- endif %}

""").strip()

__DEFAULT_INDENT = ' ' * 4

__BLANK_LINE = re.compile(
    r'^\s*$')

__LEADING_WHITESPACE = re.compile(
    r'^[\t ]*')

__INITIAL_INDENT = re.compile(
    r'(^|\n)(?P<initial_indent>[ \t]*)$')

__BODY_INDENT = re.compile(
    r'^[^\n}]*'                    # don't match a one-line fn
    r'(?:#[^\n]*)?'                # skip comment on the @fn line
    r'(?:[\n\t ]+)*'               # optional blank lines
    r'\n(?P<body_indent>[ \t]*)')  # first non-blank line


def __usage_for(arg):
    param = arg.name.replace('_', '-')
    is_optional = arg.value is not None

    return '{b}--{param}=<{PARAM}>{e} '.format(
        param=param,
        PARAM=param.upper(),
        b='[' if is_optional else '',
        e=']' if is_optional else '')


def __quoted_arg(arg):
    return '"${' + arg.name + '}"'


def __strip_prefix(target_str, prefix_str):
    if prefix_str and target_str.startswith(prefix_str):
        return target_str[len(prefix_str):]
    else:
        return target_str


def __indent(target_str, initial, body):
    def __retab_line(line):
        if __BLANK_LINE.match(line):
            return line

        leading_whitespace = len(__LEADING_WHITESPACE.match(line).group())
        depth = leading_whitespace // len(__DEFAULT_INDENT)
        return initial + (body * depth) + line[leading_whitespace:]

    return ''.join(__retab_line(s) for s in target_str.splitlines(True))


def __guess_indentation(before_fn, fn_body):
    match_result = __BODY_INDENT.match(fn_body)
    initial_indent = (
        __INITIAL_INDENT
        .search(before_fn)
        .group('initial_indent'))
    body_indent = (
        __strip_prefix(
            match_result.group('body_indent'),
            initial_indent) if match_result else
        __DEFAULT_INDENT)
    return initial_indent, body_indent