Brace expansion using ranges

Revision as of 10:57, 26 August 2020 by PureFox (talk | contribs) (Added Wren)

Write and test a function which expands one or more Unix-style numeric and alphabetic range braces embedded in a larger string.

Brace expansion using ranges is a draft programming task. It is not yet considered ready to be promoted as a complete task, for reasons that should be found in its talk page.
Specification

The brace strings used by Unix shells permit expansion of both:

  1. Recursive comma-separated lists (covered by the related task: Brace_expansion, and can be ignored here)
  2. ordered numeric and alphabetic ranges, which are the object of this task.


The general pattern of brace ranges is:

{<START>..<END>}

and, in more recent shells:

{<START>..<END>..<INCR>}

(See https://wiki.bash-hackers.org/syntax/expansion/brace)


Expandable ranges of this kind can be ascending or descending:

simpleNumber{1..3}.txt
simpleAlpha-{Z..X}.txt

and may have a third INCR element specifying ordinal intervals larger than one. The increment value can be preceded by a - minus sign, but not by a + sign.

The effect of the minus sign is to always to reverse the natural order suggested by the START and END values.

Any level of zero-padding used in either the START or END value of a numeric range is adopted in the expansions.

steppedDownAndPadded-{10..00..5}.txt
minusSignFlipsSequence {030..20..-5}.txt

A single string may contain more than one expansion range:

combined-{Q..P}{2..1}.txt

Alphabetic range values are limited to a single character for START and END but these characters are not confined to the ASCII alphabet.

emoji{🌵..🌶}{🌽..🌾}etc

Unmatched braces are simply ignored, as are empty braces, and braces which contain no range (or list).

li{teral
rangeless{}empty
rangeless{random}string
Tests

Generate and display here the expansion of each of the nine example lines shown above.

The JavaScript implementation below uses parser combinators, aiming to encode a more or less full and legible description of the

<PREAMBLE><AMBLE><POSTSCRIPT>

range brace grammar, but you should use any resource that suggests itself in your language, including parser libraries.

(The grammar of range expansion, unlike that of nested list expansion, is not recursive, so even regular expressions should prove serviceable here).

The output of the JS implementation, which aims to match the brace expansion behaviour of the default zsh shell on macOS Catalina is:

simpleNumberRising{1..3}.txt -> 
    simpleNumberRising1.txt
    simpleNumberRising2.txt
    simpleNumberRising3.txt

simpleAlphaDescending-{Z..X}.txt -> 
    simpleAlphaDescending-Z.txt
    simpleAlphaDescending-Y.txt
    simpleAlphaDescending-X.txt

steppedDownAndPadded-{10..00..5}.txt -> 
    steppedDownAndPadded-10.txt
    steppedDownAndPadded-05.txt
    steppedDownAndPadded-00.txt

minusSignFlipsSequence {030..20..-5}.txt -> 
    minusSignFlipsSequence 020.txt
    minusSignFlipsSequence 025.txt
    minusSignFlipsSequence 030.txt

combined-{Q..P}{2..1}.txt -> 
    combined-Q2.txt
    combined-Q1.txt
    combined-P2.txt
    combined-P1.txt

emoji{🌵..🌶}{🌽..🌾}etc -> 
    emoji🌵🌽etc
    emoji🌵🌾etc
    emoji🌶🌽etc
    emoji🌶🌾etc

li{teral -> 
    li{teral

rangeless{}empty -> 
    rangeless{}empty

rangeless{random}string -> 
    rangeless{random}string
Related tasks



JavaScript

<lang javascript>(() => {

   'use strict';
   // --------------- BRACE-RANGE EXPANSION ---------------
   // braceExpandWithRange :: String -> [String]
   const braceExpandWithRange = s => {
       // A list containing either the expansions
       // of s, if there are any, or s itself.
       const
           expansions = parse(some(
               braceRangeExpansion()
           ))(s);
       return 0 < expansions.length ? (() => {
           const [parsed, residue] = Array.from(
               expansions[0]
           );
           return suffixAdd(
               parsed.reduce(
                   uncurry(suffixMultiply),
                   []
               )
           )([residue.join()])
       })() : [s];
   };


   // ----------- BRACE-RANGE EXPANSION PARSER ------------
   // braceRangeExpansion :: [String]
   const braceRangeExpansion = () =>
       // List of strings expanded from a
       // a unix shell {<START>..<END>} or
       // {<START>..<END>..<INCR>} expression.
       // See https://wiki.bash-hackers.org/syntax/expansion/brace
       fmapP(([preamble, amble, postscript]) =>
           suffixAdd(
               suffixMultiply(preamble)(amble)
           )(postscript)
       )(sequenceP([
           affixLeaf(),
           fmapP(xs => [xs])(
               between(char('{'))(char('}'))(
                   altP(
                       numericSequence()
                   )(
                       characterSequence()
                   )
               )
           ),
           affixLeaf()
       ]));


   // ----------------------- TESTS -----------------------
   // main :: IO ()
   const main = () => {
       const tests = [
           'simpleNumberRising{1..3}.txt',
           'simpleAlphaDescending-{Z..X}.txt',
           'steppedDownAndPadded-{10..00..5}.txt',
           'minusSignFlipsSequence {030..20..-5}.txt',
           'combined-{Q..P}{2..1}.txt',
           'emoji{🌵..🌶}{🌽..🌾}etc',
           'li{teral',
           'rangeless{}empty',
           'rangeless{random}string'
       ];
       return tests.map(
           s => s + ' -> ' + '\n\t' + (
               braceExpandWithRange(s).join('\n\t')
           )
       ).join('\n\n');
   };


   // ----------- BRACE-RANGE COMPONENT PARSERS -----------
   // affixLeaf :: () -> Parser String
   const affixLeaf = () =>
       // A sequence of literal (non-syntactic)
       // characters before or after a pair of braces.
       fmapP(cs => [
           [cs.join()]
       ])(
           many(choice([noneOf('{\\'), escape()]))
       );


   // characterSequence :: () -> Parser [Char]
   const characterSequence = () =>
       // A rising or descending alphabetic
       // sequence of characters.
       fmapP(ab => {
           const [from, to] = ab;
           return from !== to ? (
               enumFromThenToChar(from)(
                   (from < to ? succ : pred)(from)
               )(to)
           ) : [from];
       })(
           ordinalRange(satisfy(
               c => !'0123456789'.includes(c)
           ))
       );


   // enumerationList :: ((Bool, String), String) ->
   // ((Bool, String), String) ->
   // ((Bool, String), String) -> [String]
   const enumerationList = triple => {
       // An ordered list of numeric strings either
       // rising or descending, in numeric order, and
       // possibly prefixed with zeros.
       const
           w = padWidth(triple[0][1])(triple[1][1]),
           [from, to, by] = triple.map(
               sn => (sn[0] ? negate : identity)(
                   parseInt(sn[1])
               )
           );
       return map(
           compose(justifyRight(w)('0'), str)
       )(
           0 > by ? (
               enumFromThenTo(to)(to - by)(from)
           ) : enumFromThenTo(from)(
               from + (to >= from ? by : -by)
           )(to)
       );
   };


   // numericPart :: () -> Parser (Bool, String)
   const numericPart = () =>
       // The Bool is True if the string is
       // negated by a leading '-'
       // The String component contains the digits.
       bindP(
           option()(char('-'))
       )(sign => bindP(
           some(digit())
       )(ds => pureP(
           Tuple(Boolean(sign))(concat(ds))
       )));


   // numericSequence :: () -> Parser [String]
   const numericSequence = () =>
       // An ascending or descending sequence
       // of numeric strings, possibly
       // left-padded with zeros.
       fmapP(enumerationList)(sequenceP([
           ordinalRange(numericPart()),
           numericStep()
       ]));


   // numericStep :: () -> Parser (Bool, Int)
   const numericStep = () =>
       //  The size of increment for a numeric
       //  series. Descending if the Bool is True.
       //  Defaults to (False, 1).
       option(Tuple(false)(1))(
           bindP(
               string('..')
           )(_ => bindP(
               numericPart()
           )(pureP))
       );


   // ordinalRange :: Enum a =>
   // Parser a -> Parser (a, a)
   const ordinalRange = p =>
       // A pair of enumerable values of the same
       // type, representing the start and end of
       // a range.
       bindP(
           p
       )(from => bindP(
           string('..')
       )(_ => bindP(
           p
       )(compose(pureP, append([from])))));


   // padWidth :: String -> String -> Int
   const padWidth = cs =>
       // The length of the first of cs and cs1 to
       // start with a zero. Otherwise (if neither
       // starts with a zero) then 0.
       cs1 => [cs, cs1].reduce(
           (a, x) => (0 < a) || (1 > x.length) ? (
               a
           ) : '0' !== x[0] ? a : x.length,
           0
       );


   // suffixAdd :: [String] -> [String] -> [String]
   const suffixAdd = xs =>
       ys => xs.flatMap(
           flip(append)(ys)
       );


   // suffixMultiply :: [String] -> [String] -> [String]
   const suffixMultiply = xs =>
       apList(xs.map(append));


   // ------------ GENERIC PARSER COMBINATORS -------------
   // Parser :: String -> [(a, String)] -> Parser a
   const Parser = f =>
       // A function lifted into a Parser object.
       ({
           type: 'Parser',
           parser: f
       });


   // altP (<|>) :: Parser a -> Parser a -> Parser a
   const altP = p =>
       // p, or q if p doesn't match.
       q => Parser(s => {
           const xs = parse(p)(s);
           return 0 < xs.length ? (
               xs
           ) : parse(q)(s);
       });


   // apP <*> :: Parser (a -> b) -> Parser a -> Parser b
   const apP = pf =>
       // A new parser obtained by the application
       // of a Parser-wrapped function,
       // to a Parser-wrapped value.
       p => Parser(
           s => parse(pf)(s).flatMap(
               vr => parse(
                   fmapP(vr[0])(p)
               )(vr[1])
           )
       );


   // between :: Parser open -> Parser close ->
   // Parser a -> Parser a
   const between = pOpen =>
       // A version of p which matches between
       // pOpen and pClose (both discarded).
       pClose => p => bindP(
           pOpen
       )(_ => bindP(
           p
       )(x => bindP(
           pClose
       )(_ => pureP(x))));


   // bindP (>>=) :: Parser a ->
   // (a -> Parser b) -> Parser b
   const bindP = p =>
       // A new parser obtained by the application of
       // a function to a Parser-wrapped value.
       // The function must enrich its output, lifting it
       // into a new Parser.
       // Allows for the nesting of parsers.
       f => Parser(
           s => parse(p)(s).flatMap(
               tpl => parse(f(tpl[0]))(tpl[1])
           )
       );


   // char :: Char -> Parser Char
   const char = x =>
       // A particular single character.
       satisfy(c => x == c);


   // choice :: [Parser a] -> Parser a
   const choice = ps =>
       // A parser constructed from a
       // (left to right) list of alternatives.
       ps.reduce(uncurry(altP), emptyP());


   // digit :: Parser Char
   const digit = () =>
       // A single digit.
       satisfy(isDigit);


   // emptyP :: () -> Parser a
   const emptyP = () =>
       // The empty list.
       Parser(_ => []);


   // escape :: Parser String
   const escape = () =>
       fmapP(xs => xs.join())(
           sequenceP([char('\\'), item()])
       );


   // fmapP :: (a -> b) -> Parser a -> Parser b
   const fmapP = f =>
       // A new parser derived by the structure-preserving
       // application of f to the value in p.
       p => Parser(
           s => parse(p)(s).flatMap(
               vr => Tuple(f(vr[0]))(vr[1])
           )
       );


   // item :: () -> Parser Char
   const item = () =>
       // A single character.
       Parser(
           s => 0 < s.length ? [
               Tuple(s[0])(
                   s.slice(1)
               )
           ] : []
       );


   // liftA2P :: (a -> b -> c) ->
   // Parser a -> Parser b -> Parser c
   const liftA2P = op =>
       // The binary function op, lifted
       // to a function over two parsers.
       p => apP(fmapP(op)(p));


   // many :: Parser a -> Parser [a]
   const many = p => {
       // Zero or more instances of p.
       // Lifts a parser for a simple type of value
       // to a parser for a list of such values.
       const some_p = p =>
           liftA2P(
               x => xs => [x].concat(xs)
           )(p)(many(p));
       return Parser(
           s => parse(
               0 < s.length ? (
                   altP(some_p(p))(pureP([]))
               ) : pureP([])
           )(s)
       );
   };


   // noneOf :: String -> Parser Char
   const noneOf = s =>
       // Any character not found in the
       // exclusion string.
       satisfy(c => !s.includes(c));


   // option :: a -> Parser a -> Parser a
   const option = x =>
       // Either p or the default value x.
       p => altP(p)(pureP(x));


   // parse :: Parser a -> String -> [(a, String)]
   const parse = p =>
       // The result of parsing s with p.
       s => {
           //showLog('s', s)
           return p.parser([...s]);
       };


   // pureP :: a -> Parser a
   const pureP = x =>
       // The value x lifted, unchanged,
       // into the Parser monad.
       Parser(s => [Tuple(x)(s)]);


   // satisfy :: (Char -> Bool) -> Parser Char
   const satisfy = test =>
       // Any character for which the
       // given predicate returns true.
       Parser(
           s => 0 < s.length ? (
               test(s[0]) ? [
                   Tuple(s[0])(s.slice(1))
               ] : []
           ) : []
       );


   // sepBy1 :: Parser a -> Parser b -> Parser [a]
   const sepBy1 = p =>
       // One or more occurrences of p, as
       // separated by (discarded) instances of sep.
       sep => bindP(
           p
       )(x => bindP(
           many(bindP(
               sep
           )(_ => bindP(
               p
           )(pureP))))(
           xs => pureP([x].concat(xs))));


   // sequenceP :: [Parser a] -> Parser [a]
   const sequenceP = ps =>
       // A single parser for a list of values, derived
       // from a list of parsers for single values.
       Parser(
           s => ps.reduce(
               (a, q) => a.flatMap(
                   vr => parse(q)(snd(vr)).flatMap(
                       first(xs => fst(vr).concat(xs))
                   )
               ),
               [Tuple([])(s)]
           )
       );


   // some :: Parser a -> Parser [a]
   const some = p => {
       // One or more instances of p.
       // Lifts a parser for a simple type of value
       // to a parser for a list of such values.
       const many_p = p =>
           altP(some(p))(pureP([]));
       return Parser(
           s => parse(
               liftA2P(
                   x => xs => [x].concat(xs)
               )(p)(many_p(p))
           )(s)
       );
   };


   // string :: String -> Parser String
   const string = s =>
       // A particular string.
       fmapP(cs => cs.join())(
           sequenceP([...s].map(char))
       );


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


   // apList (<*>) :: [(a -> b)] -> [a] -> [b]
   const apList = fs =>
       // The sequential application of each of a list
       // of functions to each of a list of values.
       // apList([x => 2 * x, x => 20 + x])([1, 2, 3])
       //     -> [2, 4, 6, 21, 22, 23]
       xs => fs.flatMap(f => xs.map(f));


   // append (++) :: [a] -> [a] -> [a]
   const append = xs =>
       // A list obtained by the
       // concatenation of two others.
       ys => xs.concat(ys);


   // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
   const compose = (...fs) =>
       // A function defined by the right-to-left
       // composition of all the functions in fs.
       fs.reduce(
           (f, g) => x => f(g(x)),
           x => x
       );


   // concat :: a -> [a]
   // concat :: [String] -> String
   const concat = xs => (
       ys => 0 < ys.length ? (
           ys.every(Array.isArray) ? (
               []
           ) : 
       ).concat(...ys) : ys
   )(list(xs));


   // enumFromThenTo :: Int -> Int -> Int -> [Int]
   const enumFromThenTo = x1 =>
       x2 => y => {
           const d = x2 - x1;
           return Array.from({
               length: Math.floor(y - x2) / d + 2
           }, (_, i) => x1 + (d * i));
       };


   // enumFromThenToChar :: Char -> Char -> Char -> [Char]
   const enumFromThenToChar = x1 =>
       x2 => y => {
           const [i1, i2, iY] = Array.from([x1, x2, y])
               .map(x => x.codePointAt(0)),
               d = i2 - i1;
           return Array.from({
               length: (Math.floor(iY - i2) / d) + 2
           }, (_, i) => String.fromCodePoint(i1 + (d * i)));
       };


   // first :: (a -> b) -> ((a, c) -> (b, c))
   const first = f =>
       // A simple function lifted to one which applies
       // to a tuple, transforming only its first item.
       xy => Tuple(f(xy[0]))(
           xy[1]
       );


   // flip :: (a -> b -> c) -> b -> a -> c
   const flip = op =>
       // The binary function op with 
       // its arguments reversed.
       1 < op.length ? (
           (a, b) => op(b, a)
       ) : (x => y => op(y)(x));


   // fst :: (a, b) -> a
   const fst = tpl =>
       // First member of a pair.
       tpl[0];


   // fromEnum :: Enum a => a -> Int
   const fromEnum = x =>
       typeof x !== 'string' ? (
           x.constructor === Object ? (
               x.value
           ) : parseInt(Number(x))
       ) : x.codePointAt(0);


   // identity :: a -> a
   const identity = x =>
       // The identity function. (`id`, in Haskell)
       x;


   // isAlpha :: Char -> Bool
   const isAlpha = c =>
       /[A-Za-z\u00C0-\u00FF]/.test(c);


   // isDigit :: Char -> Bool
   const isDigit = c => {
       const n = c.codePointAt(0);
       return 48 <= n && 57 >= n;
   };


   // justifyRight :: Int -> Char -> String -> String
   const justifyRight = n =>
       // The string s, preceded by enough padding (with
       // the character c) to reach the string length n.
       c => s => n > s.length ? (
           s.padStart(n, c)
       ) : s;


   // list :: StringOrArrayLike b => b -> [a]
   const list = xs =>
       // xs itself, if it is an Array,
       // or an Array derived from xs.
       Array.isArray(xs) ? (
           xs
       ) : Array.from(xs || []);


   // map :: (a -> b) -> [a] -> [b]
   const map = f =>
       // The list obtained by applying f
       // to each element of xs.
       // (The image of xs under f).
       xs => [...xs].map(f);


   // maxBound :: a -> a
   const maxBound = x => {
       const e = x.enum;
       return Boolean(e) ? (
           e[e[x.max]]
       ) : {
           'number': Number.MAX_SAFE_INTEGER,
           'string': String.fromCodePoint(0x10FFFF),
           'boolean': true
       } [typeof x];
   };
   // minBound :: a -> a
   const minBound = x => {
       const e = x.enum;
       return Boolean(e) ? (
           e[e[0]]
       ) : {
           'number': Number.MIN_SAFE_INTEGER,
           'string': String.fromCodePoint(0),
           'boolean': false
       } [typeof x];
   };


   // negate :: Num -> Num
   const negate = n =>
       -n;


   // pred :: Enum a => a -> a
   const pred = x => {
       const t = typeof x;
       return 'number' !== t ? (() => {
           const [i, mn] = [x, minBound(x)].map(fromEnum);
           return i > mn ? (
               toEnum(x)(i - 1)
           ) : Error('succ :: enum out of range.');
       })() : x > Number.MIN_SAFE_INTEGER ? (
           x - 1
       ) : Error('succ :: Num out of range.');
   };


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


   // snd :: (a, b) -> b
   const snd = tpl =>
       // Second member of a pair.
       tpl[1];


   // str :: a -> String
   const str = x =>
       Array.isArray(x) && x.every(
           v => ('string' === typeof v) && (1 === v.length)
       ) ? (
           x.join()
       ) : x.toString();


   // succ :: Enum a => a -> a
   const succ = x => {
       const t = typeof x;
       return 'number' !== t ? (() => {
           const [i, mx] = [x, maxBound(x)].map(fromEnum);
           return i < mx ? (
               toEnum(x)(1 + i)
           ) : Error('succ :: enum out of range.')
       })() : x < Number.MAX_SAFE_INTEGER ? (
           1 + x
       ) : Error('succ :: Num out of range.')
   };


   // toEnum :: a -> Int -> a
   const toEnum = e =>
       // The first argument is a sample of the type
       // allowing the function to make the right mapping
       x => ({
           'number': Number,
           'string': String.fromCodePoint,
           'boolean': Boolean,
           'object': v => e.min + v
       } [typeof e])(x);


   // uncurry :: (a -> b -> c) -> ((a, b) -> c)
   const uncurry = f =>
       // A function over a pair, derived
       // from a curried function.
       function () {
           const
               args = arguments,
               xy = Boolean(args.length % 2) ? (
                   args[0]
               ) : args;
           return f(xy[0])(xy[1]);
       };
   // MAIN ---
   return main();

})();</lang>

Output:
simpleNumberRising{1..3}.txt -> 
    simpleNumberRising1.txt
    simpleNumberRising2.txt
    simpleNumberRising3.txt

simpleAlphaDescending-{Z..X}.txt -> 
    simpleAlphaDescending-Z.txt
    simpleAlphaDescending-Y.txt
    simpleAlphaDescending-X.txt

steppedDownAndPadded-{10..00..5}.txt -> 
    steppedDownAndPadded-10.txt
    steppedDownAndPadded-05.txt
    steppedDownAndPadded-00.txt

minusSignFlipsSequence {030..20..-5}.txt -> 
    minusSignFlipsSequence 020.txt
    minusSignFlipsSequence 025.txt
    minusSignFlipsSequence 030.txt

combined-{Q..P}{2..1}.txt -> 
    combined-Q2.txt
    combined-Q1.txt
    combined-P2.txt
    combined-P1.txt

emoji{🌵..🌶}{🌽..🌾}etc -> 
    emoji🌵🌽etc
    emoji🌵🌾etc
    emoji🌶🌽etc
    emoji🌶🌾etc

li{teral -> 
    li{teral

rangeless{}empty -> 
    rangeless{}empty

rangeless{random}string -> 
    rangeless{random}string

Julia

Julia currently does not handle certain trigraphs in strings correctly, such as 🌶, so that test is omitted. <lang julia>padzeros(str) = (len = length(str)) > 1 && str[1] == '0' ? len : 0

function ranged(str)

   rang = split(str[2:end-1], "..")
   delta = length(rang) > 2 ? parse(Int, rang[3]) : 1
   if delta < 0
       rang[1], rang[2], delta = rang[2], rang[1], -delta
   end
   if '0' <= rang[1][1] <= '9' || rang[1][1] == '-'
       x, y = parse(Int, rang[1]), parse(Int, rang[2])
       pad = max(padzeros(rang[1]), padzeros(rang[2]))
       return [string(x, pad=pad) for x in range(x, step=(x < y) ? delta : -delta, stop=y)]
   else
       x, y, z = rang[1][end], rang[2][end], rang[1][1:end-1]
       return [z * string(x) for x in range(x, step=(x < y) ? delta : -delta, stop=y)]
   end

end

function splatrange(s)

   m = match(r"([^\{]*)(\{[^}]+\.\.[^\}]+\})(.*)", s)
   m == nothing && return [s]
   c = m.captures
   return vec([a * b for b in splatrange(c[3]), a in [c[1] * x for x in ranged(c[2])]])

end

for test in [

   "simpleNumberRising{1..3}.txt",
   "simpleAlphaDescending-{Z..X}.txt",
   "steppedDownAndPadded-{10..00..5}.txt",
   "minusSignFlipsSequence {030..20..-5}.txt",
   "combined-{Q..P}{2..1}.txt",
   "li{teral",
   "rangeless{}empty",
   "rangeless{random}string",
   ]
   println(test, "->\n", ["    " * x * "\n" for x in splatrange(test)]...)

end

</lang>

Output:
simpleNumberRising{1..3}.txt->
    simpleNumberRising1.txt
    simpleNumberRising2.txt
    simpleNumberRising3.txt

simpleAlphaDescending-{Z..X}.txt->
    simpleAlphaDescending-Z.txt
    simpleAlphaDescending-Y.txt
    simpleAlphaDescending-X.txt

steppedDownAndPadded-{10..00..5}.txt->
    steppedDownAndPadded-10.txt
    steppedDownAndPadded-05.txt
    steppedDownAndPadded-00.txt

minusSignFlipsSequence {030..20..-5}.txt->
    minusSignFlipsSequence 020.txt
    minusSignFlipsSequence 025.txt
    minusSignFlipsSequence 030.txt

combined-{Q..P}{2..1}.txt->
    combined-Q2.txt
    combined-Q1.txt
    combined-P2.txt
    combined-P1.txt

li{teral->
    li{teral

rangeless{}empty->
    rangeless{}empty

rangeless{random}string->
    rangeless{random}string

Raku

Works with: Rakudo version 2020.08.1

Also implements some of the string list functions described on the bash-hackers page.

<lang perl6>my $range = rx/ '{' $<start> = <-[.]>+? '..' $<end> = <-[.]>+? ['..' $<incr> = ['-'?\d+] ]? '}' /; my $list = rx/ ^ $<prefix> = .*? '{' (<-[,}]>+) +%% ',' '}' $<postfix> = .* $/;

sub expand (Str $string) {

   my @return = $string;
   if $string ~~ $range {
       quietly my ($start, $end, $incr) = $/<start end incr>».Str;
       $incr ||= 1;
       ($end, $start) = $start, $end if $incr < 0;
       $incr.=abs;
       if try +$start ~~ Numeric and +$end ~~ Numeric {
           $incr = - $incr if $start > $end;
           my ($sl, $el) = 0, 0;
           $sl = $start.chars if $start.starts-with('0');
           $el = $end.chars   if   $end.starts-with('0');
           my @this = $start < $end ?? (+$start, * + $incr …^ * > +$end) !! (+$start, * + $incr …^ * < +$end);
           @return  = @this.map: { $string.subst($range, sprintf("%{'0' ~ max $sl, $el}d", $_) ) }
       }
       elsif $start !~~ /\d/ and $end !~~ /\d/ {
           $incr = - $incr if $start gt $end;
           my @this = $start lt $end ?? ($start, (*.ord + $incr).chr …^ * gt $end) !! ($start, (*.ord + $incr).chr …^ * lt $end);
           @return  = @this.map: { $string.subst($range, sprintf("%s", $_) ) }
       }
   }
   if $string ~~ $list {
       my $these = $/[0]».Str;
       my ($prefix, $postfix) = $/<prefix postfix>».Str;
       if ($prefix ~ $postfix).chars {
           @return = $these.map: { $string.subst($list, $prefix ~ $_ ~ $postfix) } if $these.elems > 1
       }
       else {
           @return = $these.join: ' '
       }
   }
   @return

}

for qww<

   # Required tests
   simpleNumberRising{1..3}.txt
   simpleAlphaDescending-{Z..X}.txt
   steppedDownAndPadded-{10..00..5}.txt
   minusSignFlipsSequence{030..20..-5}.txt
   combined-{Q..P}{2..1}.txt
   emoji{🌵..🌶}{🌽..🌾}etc
   li{teral
   rangeless{}empty
   rangeless{random}string
   # Test some other features
   # Emoji lists
   emoji{☃,☄}{★,🇺🇸,☆}lists
   # Doesn't end exactly on endpoint
   'stops after endpoint-{02..10..3}.txt'
   # Simple list
   'simple {I,want,my,money,back} list'
   # Prefix, list
   _{I,want,my,money,back}
   # Postfix, list
   {I,want,my,money,back}!
   # List, no prefix, postfix
   {I,want,my,money,back}
   # fail by design
   'alphanumeric mix{a..1}.txt'
 > -> $test {
    say "$test ->";
    my @strings = expand $test;
    my $cnt = -1;
    while $cnt != +@strings {
        $cnt = +@strings;
        @strings.=map: { |.&expand }
    }
    say ('    ' xx * Z~ @strings).join: "\n";
    say ;

} </lang>

Output:
simpleNumberRising{1..3}.txt ->
    simpleNumberRising1.txt
    simpleNumberRising2.txt
    simpleNumberRising3.txt

simpleAlphaDescending-{Z..X}.txt ->
    simpleAlphaDescending-Z.txt
    simpleAlphaDescending-Y.txt
    simpleAlphaDescending-X.txt

steppedDownAndPadded-{10..00..5}.txt ->
    steppedDownAndPadded-10.txt
    steppedDownAndPadded-05.txt
    steppedDownAndPadded-00.txt

minusSignFlipsSequence{030..20..-5}.txt ->
    minusSignFlipsSequence020.txt
    minusSignFlipsSequence025.txt
    minusSignFlipsSequence030.txt

combined-{Q..P}{2..1}.txt ->
    combined-Q2.txt
    combined-Q1.txt
    combined-P2.txt
    combined-P1.txt

emoji{🌵..🌶}{🌽..🌾}etc ->
    emoji🌵🌽etc
    emoji🌵🌾etc
    emoji🌶🌽etc
    emoji🌶🌾etc

li{teral ->
    li{teral

rangeless{}empty ->
    rangeless{}empty

rangeless{random}string ->
    rangeless{random}string

emoji{☃,☄}{★,🇺🇸,☆}lists ->
    emoji☃★lists
    emoji☃🇺🇸lists
    emoji☃☆lists
    emoji☄★lists
    emoji☄🇺🇸lists
    emoji☄☆lists

stops after endpoint-{02..10..3}.txt ->
    stops after endpoint-02.txt
    stops after endpoint-05.txt
    stops after endpoint-08.txt

simple {I,want,my,money,back} list ->
    simple I list
    simple want list
    simple my list
    simple money list
    simple back list

_{I,want,my,money,back} ->
    _I
    _want
    _my
    _money
    _back

{I,want,my,money,back}! ->
    I!
    want!
    my!
    money!
    back!

{I,want,my,money,back} ->
    I want my money back

alphanumeric mix{a..1}.txt ->
    alphanumeric mix{a..1}.txt

Wren

Library: Wren-fmt

Added a further example to test mixed number/alpha ranges which apparently are not expanded. <lang ecmascript>import "/fmt" for Fmt

var parseRange = Fn.new { |r|

   if (r == "") return ["{}"] // rangeless, empty
   var sp = r.split("..")
   if (sp.count == 1) return ["{%(r)}"] // rangeless, random value
   var sta = sp[0]
   var end = sp[1]
   var inc = (sp.count == 2) ? "1" : sp[2]
   var n1 = Num.fromString(sta)
   var n2 = Num.fromString(end)
   var n3 = Num.fromString(inc)
   if (!n3) return ["{%(r)}"] // increment isn't a number
   var numeric = n1 && n2
   if (!numeric) {
       if ((n1 && !n2) || (!n1 && n2)) return ["{%(r)}"] // mixed numeric/alpha not expanded
       if (sta.count != 1 || end.count != 1) return ["{%(r)}"] // start/end are not both single alpha
       n1 = sta.codePoints[0]
       n2 = end.codePoints[0]
   }
   var width = 1
   if (numeric) width = (sta.count < end.count) ? end.count : sta.count
   if (n3 == 0) return (numeric) ? [Fmt.dz(width, n1)] : [sta] // zero increment
   var res = []
   var asc = n1 < n2
   if (n3 < 0) {
       asc = !asc
       var t = n1
       n1 = n2
       n2 = t
       n3 = -n3
   }
   var i = n1
   if (asc) {
       while (i <= n2) {
           res.add( (numeric) ? Fmt.dz(width, i) : String.fromCodePoint(i) )
           i = i + n3
       }
   } else {
       while (i >= n2) {
           res.add(( numeric) ? Fmt.dz(width, i) : String.fromCodePoint(i) )
           i = i - n3
       }
   }
   return res

}

var rangeExpand = Fn.new { |s|

   var res = [""]
   var rng = ""
   var inRng = false
   for (c in s) {
       if (c == "{" && !inRng) {
           inRng = true
           rng = ""
       } else if (c == "}" && inRng) {
           var rngRes = parseRange.call(rng)
           var rngCount = rngRes.count
           var res2 = []
           for (i in 0...res.count) {
               for (j in 0...rngCount) res2.add(res[i] + rngRes[j])
           }
           res = res2
           inRng = false
       } else if (inRng) {
           rng = rng + c
       } else {
           for (i in 0...res.count) res[i] = res[i] + c
       }
   }
   if (inRng) for (i in 0...res.count) res[i] = res[i] + "{" + rng // unmatched braces
   return res

}

var examples = [

   "simpleNumberRising{1..3}.txt",
   "simpleAlphaDescending-{Z..X}.txt",
   "steppedDownAndPadded-{10..00..5}.txt",
   "minusSignFlipsSequence {030..20..-5}.txt",
   "combined-{Q..P}{2..1}.txt",
   "emoji{🌵..🌶}{🌽..🌾}etc",
   "li{teral",
   "rangeless{}empty",
   "rangeless{random}string",
   "mixedNumberAlpha{5..k}"

]

for (s in examples) {

   System.write("%(s) ->\n    ")
   var res = rangeExpand.call(s)
   System.print(res.join("\n    "))
   System.print()

}</lang>

Output:
simpleNumberRising{1..3}.txt ->
    simpleNumberRising1.txt
    simpleNumberRising2.txt
    simpleNumberRising3.txt

simpleAlphaDescending-{Z..X}.txt ->
    simpleAlphaDescending-Z.txt
    simpleAlphaDescending-Y.txt
    simpleAlphaDescending-X.txt

steppedDownAndPadded-{10..00..5}.txt ->
    steppedDownAndPadded-10.txt
    steppedDownAndPadded-05.txt
    steppedDownAndPadded-00.txt

minusSignFlipsSequence {030..20..-5}.txt ->
    minusSignFlipsSequence 020.txt
    minusSignFlipsSequence 025.txt
    minusSignFlipsSequence 030.txt

combined-{Q..P}{2..1}.txt ->
    combined-Q2.txt
    combined-Q1.txt
    combined-P2.txt
    combined-P1.txt

emoji{🌵..🌶}{🌽..🌾}etc ->
    emoji🌵🌽etc
    emoji🌵🌾etc
    emoji🌶🌽etc
    emoji🌶🌾etc

li{teral ->
    li{teral

rangeless{}empty ->
    rangeless{}empty

rangeless{random}string ->
    rangeless{random}string

mixedNumberAlpha{5..k} ->
    mixedNumberAlpha{5..k}