Toggle a prefix in all the lines of selected text

A customisable macro for toggling a prefix in selected lines.

You can specify:

  1. The prefix string itself, including any following whitespace e.g. '// ' or '# ' etc
  2. Whether you want indents (before any prefix) to be ignored and left intact, or would rather clear/add prefixes only at the very beginning of a line.

Toggle Prefix in selected lines.kmmacros (26.4 KB)
togglePrefix

The central function in the Execute Javascript action is:

// leaveIndent? -> Prefix -> Lines -> Toggled Lines

// prefixToggled :: Bool -> String -> String -> String
const prefixToggled = (bln, pfx, strLines) => {
    const
        n = pfx.length,
        tpl = unzip(
            map(x => bln ? (
                    (() => {
                        const s = takeWhile(isSpace, x);
                        return Tuple(s, drop(s.length)(x));
                    })()
                ) : Tuple('', x),
                lines(strLines)
            )
        ),
        f = any(isPrefixOf(pfx), snd(tpl)) ? (
            until(compose(not, isPrefixOf(pfx)))
            (drop(n))
        ) : x => pfx + x;
    return unlines(
        bln ? (
            zipWith(
                (a, b) => a + b,
                fst(tpl),
                map(f, snd(tpl))
            )
        ) : map(f, snd(tpl))
    );
};

Full source

(() => {
    'use strict';

    const main = () => {
        const
            kme = Application('Keyboard Maestro Engine'),
            pfx = kme.getvariable('pfxToToggle'),
            blnLeaveIndent = Boolean(eval(
                kme.getvariable('pfxLeaveIndent')
            )),
            sa = standardSEAdditions(),
            strClip = sa.theClipboard(),
            strToggled = (
                (1 > pfx.length) ||
                ('string' !== typeof strClip) ||
                (1 > strClip.length)) ? (
                ''
            ) : prefixToggled(
                blnLeaveIndent,
                pfx,
                strClip,
            );
        return 0 < strToggled.length ? (
            sa.setTheClipboardTo(strToggled),
            strToggled
        ) : '';
    };

    // leaveIndent? -> Prefix -> Lines -> Toggled Lines

    // prefixToggled :: Bool -> String -> String -> String
    const prefixToggled = (bln, pfx, strLines) => {
        const
            n = pfx.length,
            tpl = unzip(
                map(x => bln ? (() => {
                        const s = takeWhile(isSpace, x);
                        return Tuple(s, drop(s.length)(x));
                    })() : Tuple('', x),
                    lines(strLines)
                )
            ),
            f = any(isPrefixOf(pfx), snd(tpl)) ? (
                until(compose(not, isPrefixOf(pfx)))
                (drop(n))
            ) : x => pfx + x;
        return unlines(
            bln ? (
                zipWith(
                    (a, b) => a + b,
                    fst(tpl),
                    map(f, snd(tpl))
                )
            ) : map(f, snd(tpl))
        );
    };

    // GENERIC FUNCTIONS --------------------------------------

    // https://github.com/RobTrew/prelude-jxa

    // | True if any contained element satisfies the predicate.

    // any :: (a -> Bool) -> [a] -> Bool
    const any = (p, xs) => xs.some(p);

    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (f, g) => x => f(g(x));

    // drop :: Int -> [a] -> [a]
    // drop :: Int -> String -> String
    const drop = n => xs => xs.slice(n);

    // fst :: (a, b) -> a
    const fst = tpl => tpl[0];

    // isPrefixOf takes two lists or strings and returns
    // true iff the first is a prefix of the second.

    // isPrefixOf :: String -> String -> Bool
    const isPrefixOf = xs => ys =>
        ys.startsWith(xs);

    // isSpace :: Char -> Bool
    const isSpace = c => ' ' === c || '\t' === c;

    // lines :: String -> [String]
    const lines = s => s.split(/[\r\n]/);

    // map :: (a -> b) -> [a] -> [b]
    const map = (f, xs) => xs.map(f);

    // min :: Ord a => a -> a -> a
    const min = (a, b) => b < a ? b : a;

    // not :: Bool -> Bool
    const not = b => !b;

    // showLog :: a -> IO ()
    const showLog = (...args) =>
        console.log(
            args
            .map(JSON.stringify)
            .join(' -> ')
        );

    // snd :: (a, b) -> b
    const snd = tpl => tpl[1];

    // takeWhile :: (a -> Bool) -> [a] -> [a]
    // takeWhile :: (Char -> Bool) -> String -> String
    const takeWhile = (p, xs) => {
        let i = 0;
        const lng = xs.length;
        while ((i < lng) && p(xs[i]))(i = i + 1);
        return xs.slice(0, i);
    };

    // Tuple (,) :: a -> b -> (a, b)
    const Tuple = (a, b) => ({
        type: 'Tuple',
        '0': a,
        '1': b,
        length: 2
    });

    // unlines :: [String] -> String
    const unlines = xs => xs.join('\n');

    // until :: (a -> Bool) -> (a -> a) -> a -> a
    const until = p => f => x => {
        let v = x;
        while (!p(v)) v = f(v);
        return v;
    };

    // unzip :: [(a,b)] -> ([a],[b])
    const unzip = xys =>
        xys.reduce(
            (a, x) => Tuple.apply(null, [0, 1].map(
                i => a[i].concat(x[i])
            )),
            Tuple([], [])
        );

    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = (f, xs, ys) =>
        Array.from({
            length: Math.min(xs.length, ys.length)
        }, (_, i) => f(xs[i], ys[i], i));

    // JXA ------------------------------------------------

    // standardSEAdditions :: () -> Application
    const standardSEAdditions = () =>
        Object.assign(Application('System Events'), {
            includeStandardAdditions: true
        });

    // MAIN ---
    return main();
})();