flap/FlapHelper.js

/**
 * @module flitter-flap/FlapHelper
 */

const ncp = require('ncp')
const path = require('path')
const fs = require('fs')
const {promisify} = require('util')
const touch = require('touch')
const del = require('del')
const format = require('js-beautify').js

/**
 * Get an object containing some helper functions to be used by migrations.
 * These functions will run in non-modification mode if dry_run is true.
 * 
 * @param {boolean} [dry_run = true] - If true, the helper function that are returned will print their changes to the console rather than actually modifying files.
 */
module.exports = exports = function(dry_run = true){
    return {

        /**
         * If true, the user has requested that migrations print their changes to the console, rather than modifying files.
         * This is set so that any custom migrations that don't use the provided helper functions can honor dry mode.
         * @constant
         * @type {boolean}
         */
        dry_run,

        /**
         * Check if a file or directory exists. If it does, resolve true. If not, resolve false.
         * @param {string} dir - path to check existence of
         * @returns {Promise<boolean>} - resolves true or false if the resource exists or not respectively
         */
        exists(dir){
            return new Promise(
                (resolve, reject) => {
                    fs.stat(dir, (err) => {
                        if ( !err ){
                            resolve(true)
                        }
                        else if ( err.code === 'ENOENT' ){
                            resolve(false)
                        }
                        else {
                            reject(err)
                        }
                    })
                }
            )
        },

        /**
         * Copy a file or folder from one place to another. If dry mode, just print the fully-qualified paths of the source and destination.
         * @param {string} from - path to the file/directory to be copied
         * @param {string} to - path to the destination
         * @returns {Promise<void>}
         */
        async copy(from, to){
            from = path.resolve(from)
            to = path.resolve(to)
            
            if ( dry_run ){
                console.log("Will copy file/directory: "+from)
                console.log("to: "+to)
            }
            else {
                await new Promise(
                    (resolve, reject) => {
                        ncp(from, to, (error) => {
                            if ( error ) reject(error)
                            
                            resolve()
                        })
                    }
                )
            }
        },

        /**
         * Delete the file/directory at the specified path. If dry mode, print the path to be deleted.
         * If dry mode, print the fully-qualified path of the resource to be deleted.
         * @param {string} file - path to the file/directory to be deleted. accepts globs.
         * @returns {Promise<void>}
         */
        async delete(glob){
            if ( dry_run ){
                console.log("Will delete the path matching glob: "+glob)
            }
            else {
                await del([glob])
            }
        },

        /**
         * Create the provided directory recursively. Any super-directories needed will be created as well.
         * If dry mode, print the fully-qualified path of the directory to be created.
         * @param {string} dir - path of the directory to be created
         * @returns {Promise<void>}
         */
        async mkdir(dir){
            dir = path.resolve(dir)
            
            if ( dry_run ){
                console.log("Will create directory: "+dir)
            }
            else {
                await fs.promises.mkdir(dir, {recursive:true})
            }
        },
        
        

        /**
         * Create the specified file if it doesn't exist. Otherwise, update its timestamp.
         * The file's directory must exist for this to work. If dry mode, print the fully-qualified path of the file
         * to be created.
         * @param {string} file - path of the file to be created.
         * @returns {Promise<void>}
         */
        async touch(file){
            file = path.resolve(file)
            
            if ( dry_run ){
                console.log("Will create the file (unless it exists): "+file)
            }
            else {
                await touch(file)
            }
        },

        /**
         * Write the contents to the file at the specified path. If it doesn't exist, the file will be created.
         * The directory of the file must exist for this to work. If dry mode, print the fully-qualified path of the
         * file to be written, as well as the contents.
         * @param {string} file - path of the file to be written
         * @param {string} contents - contents to write to the file
         * @returns {Promise<void>}
         */
        async write_file(file, contents){
            file = path.resolve(file)
            
            await this.touch(file)
            
            if ( dry_run ){
                console.log("Adding contents to file: "+file)
                console.log("\n"+contents+"\n")
            }
            else {
                await fs.promises.writeFile(file, contents)
            }
        },

        /**
         * Delete a line that is matched by the search string from the provided file. This will delete ONLY the first
         * line it matches. The line must contain the search query, which can be either a string or regex value.
         * If dry mode, print the fully-qualified path of the file, as well as the contents and number of the line
         * to be deleted.
         * @param {string} file - path of the file from which the line should be deleted
         * @param {string|RegExp} to_find_line - search query used to match the line
         * @returns {Promise<void>}
         */
        async delete_line(file, to_find_line){
            file = path.resolve(file)
            
            let contents = await fs.promises.readFile(file)
            let lines = contents.toString().split("\n")
            
            for ( let i in lines ){
                i = parseInt(i)
                const line = lines[i]
                
                if ( line.search(to_find_line) >= 0 ){
                    lines.splice(i, 1)
                    
                    if ( dry_run ){
                        console.log("")
                        console.log("Will remove line from file: "+file)
                        console.log("(line "+i+"): "+line)
                    }
                    break
                }
            }

            if ( !dry_run ){
                let contents = ""
                for ( let i in lines ){
                    contents = contents + lines[i]

                    if ( i < (lines.length-1) ) {
                        contents = contents + "\n"
                    }
                }

                await fs.promises.unlink(file, contents)
                await fs.promises.writeFile(file, contents)
            }
        },

        /**
         * Insert a line into the specified file after the line matched by the search query. This function will insert
         * the specified text after the first instance of a line that contains the search query, whether it be a string
         * or regex value. If dry mode, print the fully-qualified path of the file to be modified, as well as the
         * content and numbers of the existing and new lines.
         * @param {string} file - path of the file in which the line should be inserted
         * @param {string|RegExp} to_find_line_before - search query used to find the line under which the new line should be inserted
         * @param {string} to_insert - line to be inserted in the file
         * @param {boolean} [search_insert_before = false] - if true, will insert the string BEFORE the found line
         * @returns {Promise<void>}
         */
        async insert_line(file, to_find_line_before, to_insert, search_insert_before = false){
            file = path.resolve(file)
            
            let contents = await fs.promises.readFile(file)
            let lines = contents.toString().split("\n")
            
            for ( let i in lines ){
                i = parseInt(i)
                const line = lines[i]
                
                if ( line.search(to_find_line_before) >= 0 ){
                    const splicer = search_insert_before ? i : i+1
                    lines.splice(splicer, 0, to_insert)
                    
                    if ( dry_run ){
                        console.log("")
                        console.log("Will insert line in file: "+file)
                        if ( !search_insert_before ) console.log("After (line "+i+"): "+lines[i])
                        console.log("               + "+lines[splicer])
                        if ( search_insert_before ) console.log("Before (line "+(i+1)+"): "+lines[i+1])
                    }
                    break
                }
            }

            if ( !dry_run ){
                let contents = ""
                for ( let i in lines ){
                    contents = contents + lines[i]

                    if ( i < (lines.length-1) ) {
                        contents = contents + "\n"
                    }
                }

                await fs.promises.unlink(file, contents)
                await fs.promises.writeFile(file, contents)
            }
            
            
        },

        /**
         * Find and replace instances of the search query in the specified file. The specified query is applied once
         * per line. This means that if the search query is a string, it will only replace the first instance of
         * the string on each line. Regex is allowed to compensate for this. If the 'all' toggle is false, the search
         * will stop after the first line matching the search string is found. If dry run, print the fully-qualified
         * path of the file to be modified, as well as the numbers, before, and after contents of each line to be
         * modified.
         * @param {string} file - path of the file to be modified
         * @param {string|RegExp} to_find - search query of text to be replaced
         * @param {string} replace_with - text to be inserted
         * @param {boolean} [all = true] - if true, then the search will be performed on each line of the file. Otherwise, stop after the first match.
         * @returns {Promise<void>}
         */
        async find_replace(file, to_find, replace_with, all=true){
            file = path.resolve(file)
            
            let contents = await fs.promises.readFile(file)
            
            let lines = contents.toString().split("\n")
            let modify_lines = []
            
            for ( let i in lines ){
                const line = lines[i]
                
                if ( line.search(to_find) >= 0 ){
                    modify_lines.push(i)
                    
                    if ( !all ){
                        break
                    }
                }
            }
            
            if ( dry_run ){
                console.log("Will Modify Lines in: "+file)
                for ( let i in modify_lines ){
                    const ind = modify_lines[i]
                    console.log("old (line "+ind+"): "+lines[ind])
                    console.log("new (line "+ind+"): "+lines[ind].replace(to_find, replace_with))
                    console.log("")
                }
            }
            else {
                for ( let i in modify_lines ){
                    const ind = modify_lines[i]
                    lines[ind] = lines[ind].replace(to_find, replace_with)
                }
                
                let contents = ""
                for ( let i in lines ){
                    contents = contents + lines[i]
                    
                    if ( i < (lines.length-1) ) {
                        contents = contents + "\n"
                    }
                }
                
                await fs.promises.unlink(file, contents)
                await fs.promises.writeFile(file, contents)
            }
        },

        /**
         * Count the number of times the provided regex is matched in a string.
         * @param {string} string - string to be searched
         * @param {RegExp} regex - Regular expression to match against
         * @returns {number} - the number of times the string matches the regex
         */
        count(string, regex){
            return ((string || '').match(regex) || []).length
        },

        /**
         * Find and replace instances of the search query in the specified file. The specified query is applied once
         * to the entire file. Regex or a string are allowed as search criteria. Optionally, a replace_handler function
         * can be provided to alter the replacements before they are inserted.
         * @param {string} file - path of the file to be modified
         * @param {string|RegExp} to_find - search query of text to be replaced
         * @param {string} replace_with - text to be inserted
         * @param {function} [replace_handler = (m) => m] - handler called on each match to alter before insert. must return a string.
         * @returns {Promise<void>}
         */
        async find_replace_file(file, to_find, replace_with, replace_handler = (m) => m){
            file = path.resolve(file)

            let contents = await (await fs.promises.readFile(file)).toString()

            if ( dry_run ){
                const re_to_find = new RegExp(to_find)
                let match;

                let i = 0; const max = this.count(contents, to_find);
                while ( match = re_to_find.exec(contents) ){
                    if ( !i < max ) break;
                    const str = contents.substring(0, match.index)
                    const lines = str.split(/\r\n|\r|\n/).length
                    console.log(`old (line ${lines}): `, format(match[0]))
                    console.log(`new (line ${lines}): `, format(replace_handler(replace_with.replace(/%orig/g, match[0]))))
                    console.log("")
                    i++;
                }
            }
            else {
                const replace_contents = format(contents.replace(to_find, (match) => {
                    return replace_handler(replace_with.replace(/%orig/g, match))
                }))

                await fs.promises.unlink(file)
                await fs.promises.writeFile(file, replace_contents)
            }
        }
        
    }
}