Source: model.js

// Licensed under the MIT License
// https://github.com/craigahobbs/bare-script/blob/main/LICENSE

/** @module lib/model */

import {parseSchemaMarkdown} from 'schema-markdown/lib/parser.js';
import {validateType} from 'schema-markdown/lib/schema.js';


/**
 * The BareScript type model
 */
export const bareScriptTypes = parseSchemaMarkdown(`\
# A BareScript script
struct BareScript

    # The script's statements
    ScriptStatement[] statements


# A script statement
union ScriptStatement

    # An expression
    ExpressionStatement expr

    # A jump statement
    JumpStatement jump

    # A return statement
    ReturnStatement return

    # A label definition
    string label

    # A function definition
    FunctionStatement function

    # An include statement
    IncludeStatement include


# An expression statement
struct ExpressionStatement

    # The variable name to assign the expression value
    optional string name

    # The expression to evaluate
    Expression expr


# A jump statement
struct JumpStatement

    # The label to jump to
    string label

    # The test expression
    optional Expression expr


# A return statement
struct ReturnStatement

    # The expression to return
    optional Expression expr


# A function definition statement
struct FunctionStatement

    # If true, the function is defined as async
    optional bool async

    # The function name
    string name

    # The function's argument names
    optional string[len > 0] args

    # If true, the function's last argument is the array of all remaining arguments
    optional bool lastArgArray

    # The function's statements
    ScriptStatement[] statements


# An include statement
struct IncludeStatement

    # The list of include scripts to load and execute in the global scope
    IncludeScript[len > 0] includes


# An include script
struct IncludeScript

    # The include script URL
    string url

    # If true, this is a system include
    optional bool system


# An expression
union Expression

    # A number literal
    float number

    # A string literal
    string string

    # A variable value
    string variable

    # A function expression
    FunctionExpression function

    # A binary expression
    BinaryExpression binary

    # A unary expression
    UnaryExpression unary

    # An expression group
    Expression group


# A binary expression
struct BinaryExpression

    # The binary expression operator
    BinaryExpressionOperator op

    # The left expression
    Expression left

    # The right expression
    Expression right


# A binary expression operator
enum BinaryExpressionOperator

    # Exponentiation
    "**"

    # Multiplication
    "*"

    # Division
    "/"

    # Remainder
    "%"

    # Addition
    "+"

    # Subtraction
    "-"

    # Less than or equal
    "<="

    # Less than
    "<"

    # Greater than or equal
    ">="

    # Greater than
    ">"

    # Equal
    "=="

    # Not equal
    "!="

    # Logical AND
    "&&"

    # Logical OR
    "||"


# A unary expression
struct UnaryExpression

    # The unary expression operator
    UnaryExpressionOperator op

    # The expression
    Expression expr


# A unary expression operator
enum UnaryExpressionOperator

    # Unary negation
    "-"

    # Logical NOT
    "!"


# A function expression
struct FunctionExpression

    # The function name
    string name

    # The function arguments
    optional Expression[] args
`);


/**
 * Validate a BareScript script model
 *
 * @param {Object} script - The [BareScript model](./model/#var.vName='BareScript')
 * @returns {Object} The validated BareScript model
 * @throws [ValidationError]{@link https://craigahobbs.github.io/schema-markdown-js/module-lib_schema.ValidationError.html}
 */
export function validateScript(script) {
    return validateType(bareScriptTypes, 'BareScript', script);
}


/**
 * Validate an expression model
 *
 * @param {Object} expr - The [expression model](./model/#var.vName='Expression')
 * @returns {Object} The validated expression model
 * @throws [ValidationError]{@link https://craigahobbs.github.io/schema-markdown-js/module-lib_schema.ValidationError.html}
 */
export function validateExpression(expr) {
    return validateType(bareScriptTypes, 'Expression', expr);
}


/**
 * Lint a BareScript script model
 *
 * @param {Object} script - The [BareScript model](./model/#var.vName='BareScript')
 * @returns {string[]} The array of lint warnings
 */
export function lintScript(script) {
    const warnings = [];

    // Empty script?
    if (script.statements.length === 0) {
        warnings.push('Empty script');
    }

    // Variable used before assignment?
    const varAssigns = {};
    const varUses = {};
    getVariableAssignmentsAndUses(script.statements, varAssigns, varUses);
    for (const varName of Object.keys(varAssigns)) {
        if (varName in varUses && varUses[varName] <= varAssigns[varName]) {
            warnings.push(
                `Global variable "${varName}" used (index ${varUses[varName]}) before assignment (index ${varAssigns[varName]})`
            );
        }
    }

    // Iterate global statements
    const functionsDefined = {};
    const labelsDefined = {};
    const labelsUsed = {};
    for (const [ixStatement, statement] of script.statements.entries()) {
        const [statementKey] = Object.keys(statement);

        // Function definition checks
        if (statementKey === 'function') {
            // Function redefinition?
            if (statement.function.name in functionsDefined) {
                warnings.push(`Redefinition of function "${statement.function.name}" (index ${ixStatement})`);
            } else {
                functionsDefined[statement.function.name] = ixStatement;
            }

            // Variable used before assignment?
            const fnVarAssigns = {};
            const fnVarUses = {};
            const args = (statement.function.args ?? null);
            getVariableAssignmentsAndUses(statement.function.statements, fnVarAssigns, fnVarUses);
            for (const varName of Object.keys(fnVarAssigns)) {
                // Ignore re-assigned function arguments
                if (args !== null && args.indexOf(varName) !== -1) {
                    continue;
                }
                if (varName in fnVarUses && fnVarUses[varName] <= fnVarAssigns[varName]) {
                    warnings.push(
                        `Variable "${varName}" of function "${statement.function.name}" used (index ${fnVarUses[varName]}) ` +
                            `before assignment (index ${fnVarAssigns[varName]})`
                    );
                }
            }

            // Unused variables?
            for (const varName of Object.keys(fnVarAssigns)) {
                if (!(varName in fnVarUses)) {
                    warnings.push(
                        `Unused variable "${varName}" defined in function "${statement.function.name}" (index ${fnVarAssigns[varName]})`
                    );
                }
            }

            // Function argument checks
            if (args !== null) {
                const argsDefined = new Set();
                for (const arg of args) {
                    // Duplicate argument?
                    if (argsDefined.has(arg)) {
                        warnings.push(`Duplicate argument "${arg}" of function "${statement.function.name}" (index ${ixStatement})`);
                    } else {
                        argsDefined.add(arg);

                        // Unused argument?
                        if (!(arg in fnVarUses)) {
                            warnings.push(`Unused argument "${arg}" of function "${statement.function.name}" (index ${ixStatement})`);
                        }
                    }
                }
            }

            // Iterate function statements
            const fnLabelsDefined = {};
            const fnLabelsUsed = {};
            for (const [ixFnStatement, fnStatement] of statement.function.statements.entries()) {
                const [fnStatementKey] = Object.keys(fnStatement);

                // Function expression statement checks
                if (fnStatementKey === 'expr') {
                    // Pointless function expression statement?
                    if (!('name' in fnStatement.expr) && isPointlessExpression(fnStatement.expr.expr)) {
                        warnings.push(`Pointless statement in function "${statement.function.name}" (index ${ixFnStatement})`);
                    }

                // Function label statement checks
                } else if (fnStatementKey === 'label') {
                    // Label redefinition?
                    if (fnStatement.label in fnLabelsDefined) {
                        warnings.push(
                            `Redefinition of label "${fnStatement.label}" in function "${statement.function.name}" (index ${ixFnStatement})`
                        );
                    } else {
                        fnLabelsDefined[fnStatement.label] = ixFnStatement;
                    }

                // Function jump statement checks
                } else if (fnStatementKey === 'jump') {
                    if (!(fnStatement.jump.label in fnLabelsUsed)) {
                        fnLabelsUsed[fnStatement.jump.label] = ixFnStatement;
                    }
                }
            }

            // Unused function labels?
            for (const label of Object.keys(fnLabelsDefined)) {
                if (!(label in fnLabelsUsed)) {
                    warnings.push(`Unused label "${label}" in function "${statement.function.name}" (index ${fnLabelsDefined[label]})`);
                }
            }

            // Unknown function labels?
            for (const label of Object.keys(fnLabelsUsed)) {
                if (!(label in fnLabelsDefined)) {
                    warnings.push(`Unknown label "${label}" in function "${statement.function.name}" (index ${fnLabelsUsed[label]})`);
                }
            }

        // Global expression statement checks
        } else if (statementKey === 'expr') {
            // Pointless global expression statement?
            if (!('name' in statement.expr) && isPointlessExpression(statement.expr.expr)) {
                warnings.push(`Pointless global statement (index ${ixStatement})`);
            }

        // Global label statement checks
        } else if (statementKey === 'label') {
            // Label redefinition?
            if (statement.label in labelsDefined) {
                warnings.push(`Redefinition of global label "${statement.label}" (index ${ixStatement})`);
            } else {
                labelsDefined[statement.label] = ixStatement;
            }

        // Global jump statement checks
        } else if (statementKey === 'jump') {
            if (!(statement.jump.label in labelsUsed)) {
                labelsUsed[statement.jump.label] = ixStatement;
            }
        }
    }

    // Unused global labels?
    for (const label of Object.keys(labelsDefined)) {
        if (!(label in labelsUsed)) {
            warnings.push(`Unused global label "${label}" (index ${labelsDefined[label]})`);
        }
    }

    // Unknown global labels?
    for (const label of Object.keys(labelsUsed)) {
        if (!(label in labelsDefined)) {
            warnings.push(`Unknown global label "${label}" (index ${labelsUsed[label]})`);
        }
    }

    return warnings;
}


// Helper function to determine if an expression statement's expression is pointless
function isPointlessExpression(expr) {
    const [exprKey] = Object.keys(expr);
    if (exprKey === 'function') {
        return false;
    } else if (exprKey === 'binary') {
        return isPointlessExpression(expr.binary.left) && isPointlessExpression(expr.binary.right);
    } else if (exprKey === 'unary') {
        return isPointlessExpression(expr.unary.expr);
    } else if (exprKey === 'group') {
        return isPointlessExpression(expr.group);
    }
    return true;
}


// Helper function to set variable assignments/uses for a statements array
function getVariableAssignmentsAndUses(statements, assigns, uses) {
    for (const [ixStatement, statement] of statements.entries()) {
        const [statementKey] = Object.keys(statement);
        if (statementKey === 'expr') {
            if ('name' in statement.expr) {
                if (!(statement.expr.name in assigns)) {
                    assigns[statement.expr.name] = ixStatement;
                }
            }
            getExpressionVariableUses(statement.expr.expr, uses, ixStatement);
        } else if (statementKey === 'jump' && 'expr' in statement.jump) {
            getExpressionVariableUses(statement.jump.expr, uses, ixStatement);
        } else if (statementKey === 'return' && 'expr' in statement.return) {
            getExpressionVariableUses(statement.return.expr, uses, ixStatement);
        }
    }
}


// Helper function to set variable uses for an expression
function getExpressionVariableUses(expr, uses, ixStatement) {
    const [exprKey] = Object.keys(expr);
    if (exprKey === 'variable') {
        if (!(expr.variable in uses)) {
            uses[expr.variable] = ixStatement;
        }
    } else if (exprKey === 'binary') {
        getExpressionVariableUses(expr.binary.left, uses, ixStatement);
        getExpressionVariableUses(expr.binary.right, uses, ixStatement);
    } else if (exprKey === 'unary') {
        getExpressionVariableUses(expr.unary.expr, uses, ixStatement);
    } else if (exprKey === 'group') {
        getExpressionVariableUses(expr.group, uses, ixStatement);
    } else if (exprKey === 'function') {
        if (!(expr.function.name in uses)) {
            uses[expr.function.name] = ixStatement;
        }
        if ('args' in expr.function) {
            for (const argExpr of expr.function.args) {
                getExpressionVariableUses(argExpr, uses, ixStatement);
            }
        }
    }
}