diff --git a/spec/std/file/match_spec.cr b/spec/std/file/match_spec.cr new file mode 100644 index 000000000000..e6c14ac16356 --- /dev/null +++ b/spec/std/file/match_spec.cr @@ -0,0 +1,167 @@ +require "spec" + +private def assert_file_matches(pattern, path : String, *, file = __FILE__, line = __LINE__) + File.match?(pattern, path).should be_true, file: file, line: line + File.match?(pattern, Path.posix(path)).should be_true, file: file, line: line + File.match?(pattern, Path.posix(path).to_windows).should be_true, file: file, line: line +end + +private def refute_file_matches(pattern, path : String, *, file = __FILE__, line = __LINE__) + File.match?(pattern, path).should be_false, file: file, line: line + File.match?(pattern, Path.posix(path)).should be_false, file: file, line: line + File.match?(pattern, Path.posix(path).to_windows).should be_false, file: file, line: line +end + +describe File do + describe ".match?" do + it "matches basics" do + assert_file_matches "abc", "abc" + assert_file_matches "*", "abc" + assert_file_matches "*c", "abc" + assert_file_matches "a*", "a" + assert_file_matches "a*", "abc" + assert_file_matches "a*/b", "abc/b" + assert_file_matches "*x", "xxx" + assert_file_matches "*.x", "a.x" + assert_file_matches "a/b/*.x", "a/b/c.x" + refute_file_matches "*.x", "a/b/c.x" + refute_file_matches "c.x", "a/b/c.x" + refute_file_matches "b/*.x", "a/b/c.x" + end + + it "matches multiple expansions" do + assert_file_matches "a*b*c*d*e*/f", "axbxcxdxe/f" + assert_file_matches "a*b*c*d*e*/f", "axbxcxdxexxx/f" + assert_file_matches "a*b?c*x", "abxbbxdbxebxczzx" + refute_file_matches "a*b?c*x", "abxbbxdbxebxczzy" + end + + it "matches unicode characters" do + assert_file_matches "a?b", "a☺b" + refute_file_matches "a???b", "a☺b" + end + + it "* don't match path separator" do + refute_file_matches "a*", "ab/c" + refute_file_matches "a*/b", "a/c/b" + refute_file_matches "a*b*c*d*e*/f", "axbxcxdxe/xxx/f" + refute_file_matches "a*b*c*d*e*/f", "axbxcxdxexxx/fff" + end + + it "**" do + assert_file_matches "a/b/**", "a/b/c.x" + assert_file_matches "a/**", "a/b/c.x" + assert_file_matches "a/**/d.x", "a/b/c/d.x" + refute_file_matches "a/**b/d.x", "a/bb/c/d.x" + refute_file_matches "a/b**/*", "a/bb/c/d.x" + end + + it "** bugs (#15319)" do + refute_file_matches "a/**/*", "a/b/c/d.x" + assert_file_matches "a/b**/d.x", "a/bb/c/d.x" + refute_file_matches "**/*.x", "a/b/c.x" + assert_file_matches "**.x", "a/b/c.x" + end + + it "** matches path separator" do + assert_file_matches "a**", "ab/c" + assert_file_matches "a**/b", "a/c/b" + assert_file_matches "a*b*c*d*e**/f", "axbxcxdxe/xxx/f" + assert_file_matches "a*b*c*d*e**/f", "axbxcxdxexxx/f" + refute_file_matches "a*b*c*d*e**/f", "axbxcxdxexxx/fff" + end + + it "classes" do + assert_file_matches "ab[c]", "abc" + assert_file_matches "ab[b-d]", "abc" + refute_file_matches "ab[d-b]", "abc" + refute_file_matches "ab[e-g]", "abc" + assert_file_matches "ab[e-gc]", "abc" + refute_file_matches "ab[^c]", "abc" + refute_file_matches "ab[^b-d]", "abc" + assert_file_matches "ab[^e-g]", "abc" + assert_file_matches "a[^a]b", "a☺b" + refute_file_matches "a[^a][^a][^a]b", "a☺b" + assert_file_matches "[a-ζ]*", "α" + refute_file_matches "*[a-ζ]", "A" + end + + it "escape" do + # NOTE: `*` is forbidden in Windows paths + File.match?("a\\*b", "a*b").should be_true + refute_file_matches "a\\*b", "ab" + File.match?("a\\**b", "a*bb").should be_true + refute_file_matches "a\\**b", "abb" + File.match?("a*\\*b", "ab*b").should be_true + refute_file_matches "a*\\*b", "abb" + + assert_file_matches "a\\[b\\]", "a[b]" + refute_file_matches "a\\[b\\]", "ab" + assert_file_matches "a\\[bb\\]", "a[bb]" + refute_file_matches "a\\[bb\\]", "abb" + assert_file_matches "a[b]\\[b\\]", "ab[b]" + refute_file_matches "a[b]\\[b\\]", "abb" + end + + it "special chars" do + refute_file_matches "a?b", "a/b" + refute_file_matches "a*b", "a/b" + end + + it "classes escapes" do + assert_file_matches "[\\]a]", "]" + assert_file_matches "[\\-]", "-" + assert_file_matches "[x\\-]", "x" + assert_file_matches "[x\\-]", "-" + refute_file_matches "[x\\-]", "z" + assert_file_matches "[\\-x]", "x" + assert_file_matches "[\\-x]", "-" + refute_file_matches "[\\-x]", "a" + + expect_raises(File::BadPatternError, "empty character set") do + File.match?("[]a]", "]") + end + expect_raises(File::BadPatternError, "missing range start") do + File.match?("[-]", "-") + end + expect_raises(File::BadPatternError, "missing range end") do + File.match?("[x-]", "x") + end + expect_raises(File::BadPatternError, "missing range start") do + File.match?("[-x]", "x") + end + expect_raises(File::BadPatternError, "Empty escape character") do + File.match?("\\", "a") + end + expect_raises(File::BadPatternError, "missing range start") do + File.match?("[a-b-c]", "a") + end + expect_raises(File::BadPatternError, "unterminated character set") do + File.match?("[", "a") + end + expect_raises(File::BadPatternError, "unterminated character set") do + File.match?("[^", "a") + end + expect_raises(File::BadPatternError, "unterminated character set") do + File.match?("[^bc", "a") + end + expect_raises(File::BadPatternError, "unterminated character set") do + File.match?("a[", "a") + end + end + + it "alternates" do + assert_file_matches "{abc,def}", "abc" + assert_file_matches "ab{c,}", "abc" + assert_file_matches "ab{c,}", "ab" + refute_file_matches "ab{d,e}", "abc" + assert_file_matches "ab{*,/cde}", "abcde" + assert_file_matches "ab{*,/cde}", "ab/cde" + assert_file_matches "ab{?,/}de", "abcde" + assert_file_matches "ab{?,/}de", "ab/de" + assert_file_matches "ab{{c,d}ef,}", "ab" + assert_file_matches "ab{{c,d}ef,}", "abcef" + assert_file_matches "ab{{c,d}ef,}", "abdef" + end + end +end diff --git a/spec/std/file_spec.cr b/spec/std/file_spec.cr index e88adeed7ea2..18b6326be857 100644 --- a/spec/std/file_spec.cr +++ b/spec/std/file_spec.cr @@ -8,18 +8,6 @@ private def it_raises_on_null_byte(operation, file = __FILE__, line = __LINE__, end end -private def assert_file_matches(pattern, path : String, *, file = __FILE__, line = __LINE__) - File.match?(pattern, path).should be_true, file: file, line: line - File.match?(pattern, Path.posix(path)).should be_true, file: file, line: line - File.match?(pattern, Path.posix(path).to_windows).should be_true, file: file, line: line -end - -private def refute_file_matches(pattern, path : String, *, file = __FILE__, line = __LINE__) - File.match?(pattern, path).should be_false, file: file, line: line - File.match?(pattern, Path.posix(path)).should be_false, file: file, line: line - File.match?(pattern, Path.posix(path).to_windows).should be_false, file: file, line: line -end - private def normalize_permissions(permissions, *, directory) {% if flag?(:win32) %} normalized_permissions = 0o444 @@ -1715,158 +1703,6 @@ describe "File" do end end - describe ".match?" do - it "matches basics" do - assert_file_matches "abc", "abc" - assert_file_matches "*", "abc" - assert_file_matches "*c", "abc" - assert_file_matches "a*", "a" - assert_file_matches "a*", "abc" - assert_file_matches "a*/b", "abc/b" - assert_file_matches "*x", "xxx" - assert_file_matches "*.x", "a.x" - assert_file_matches "a/b/*.x", "a/b/c.x" - refute_file_matches "*.x", "a/b/c.x" - refute_file_matches "c.x", "a/b/c.x" - refute_file_matches "b/*.x", "a/b/c.x" - end - - it "matches multiple expansions" do - assert_file_matches "a*b*c*d*e*/f", "axbxcxdxe/f" - assert_file_matches "a*b*c*d*e*/f", "axbxcxdxexxx/f" - assert_file_matches "a*b?c*x", "abxbbxdbxebxczzx" - refute_file_matches "a*b?c*x", "abxbbxdbxebxczzy" - end - - it "matches unicode characters" do - assert_file_matches "a?b", "a☺b" - refute_file_matches "a???b", "a☺b" - end - - it "* don't match path separator" do - refute_file_matches "a*", "ab/c" - refute_file_matches "a*/b", "a/c/b" - refute_file_matches "a*b*c*d*e*/f", "axbxcxdxe/xxx/f" - refute_file_matches "a*b*c*d*e*/f", "axbxcxdxexxx/fff" - end - - it "**" do - assert_file_matches "a/b/**", "a/b/c.x" - assert_file_matches "a/**", "a/b/c.x" - assert_file_matches "a/**/d.x", "a/b/c/d.x" - refute_file_matches "a/**b/d.x", "a/bb/c/d.x" - refute_file_matches "a/b**/*", "a/bb/c/d.x" - end - - it "** bugs (#15319)" do - refute_file_matches "a/**/*", "a/b/c/d.x" - assert_file_matches "a/b**/d.x", "a/bb/c/d.x" - refute_file_matches "**/*.x", "a/b/c.x" - assert_file_matches "**.x", "a/b/c.x" - end - - it "** matches path separator" do - assert_file_matches "a**", "ab/c" - assert_file_matches "a**/b", "a/c/b" - assert_file_matches "a*b*c*d*e**/f", "axbxcxdxe/xxx/f" - assert_file_matches "a*b*c*d*e**/f", "axbxcxdxexxx/f" - refute_file_matches "a*b*c*d*e**/f", "axbxcxdxexxx/fff" - end - - it "classes" do - assert_file_matches "ab[c]", "abc" - assert_file_matches "ab[b-d]", "abc" - refute_file_matches "ab[d-b]", "abc" - refute_file_matches "ab[e-g]", "abc" - assert_file_matches "ab[e-gc]", "abc" - refute_file_matches "ab[^c]", "abc" - refute_file_matches "ab[^b-d]", "abc" - assert_file_matches "ab[^e-g]", "abc" - assert_file_matches "a[^a]b", "a☺b" - refute_file_matches "a[^a][^a][^a]b", "a☺b" - assert_file_matches "[a-ζ]*", "α" - refute_file_matches "*[a-ζ]", "A" - end - - it "escape" do - # NOTE: `*` is forbidden in Windows paths - File.match?("a\\*b", "a*b").should be_true - refute_file_matches "a\\*b", "ab" - File.match?("a\\**b", "a*bb").should be_true - refute_file_matches "a\\**b", "abb" - File.match?("a*\\*b", "ab*b").should be_true - refute_file_matches "a*\\*b", "abb" - - assert_file_matches "a\\[b\\]", "a[b]" - refute_file_matches "a\\[b\\]", "ab" - assert_file_matches "a\\[bb\\]", "a[bb]" - refute_file_matches "a\\[bb\\]", "abb" - assert_file_matches "a[b]\\[b\\]", "ab[b]" - refute_file_matches "a[b]\\[b\\]", "abb" - end - - it "special chars" do - refute_file_matches "a?b", "a/b" - refute_file_matches "a*b", "a/b" - end - - it "classes escapes" do - assert_file_matches "[\\]a]", "]" - assert_file_matches "[\\-]", "-" - assert_file_matches "[x\\-]", "x" - assert_file_matches "[x\\-]", "-" - refute_file_matches "[x\\-]", "z" - assert_file_matches "[\\-x]", "x" - assert_file_matches "[\\-x]", "-" - refute_file_matches "[\\-x]", "a" - - expect_raises(File::BadPatternError, "empty character set") do - File.match?("[]a]", "]") - end - expect_raises(File::BadPatternError, "missing range start") do - File.match?("[-]", "-") - end - expect_raises(File::BadPatternError, "missing range end") do - File.match?("[x-]", "x") - end - expect_raises(File::BadPatternError, "missing range start") do - File.match?("[-x]", "x") - end - expect_raises(File::BadPatternError, "Empty escape character") do - File.match?("\\", "a") - end - expect_raises(File::BadPatternError, "missing range start") do - File.match?("[a-b-c]", "a") - end - expect_raises(File::BadPatternError, "unterminated character set") do - File.match?("[", "a") - end - expect_raises(File::BadPatternError, "unterminated character set") do - File.match?("[^", "a") - end - expect_raises(File::BadPatternError, "unterminated character set") do - File.match?("[^bc", "a") - end - expect_raises(File::BadPatternError, "unterminated character set") do - File.match?("a[", "a") - end - end - - it "alternates" do - assert_file_matches "{abc,def}", "abc" - assert_file_matches "ab{c,}", "abc" - assert_file_matches "ab{c,}", "ab" - refute_file_matches "ab{d,e}", "abc" - assert_file_matches "ab{*,/cde}", "abcde" - assert_file_matches "ab{*,/cde}", "ab/cde" - assert_file_matches "ab{?,/}de", "abcde" - assert_file_matches "ab{?,/}de", "ab/de" - assert_file_matches "ab{{c,d}ef,}", "ab" - assert_file_matches "ab{{c,d}ef,}", "abcef" - assert_file_matches "ab{{c,d}ef,}", "abdef" - end - end - describe File::Permissions do it "does to_s" do perm = File::Permissions.flags(OwnerAll, GroupRead, GroupWrite, OtherRead) diff --git a/src/file.cr b/src/file.cr index 9313449c648e..5c39fa0e5a15 100644 --- a/src/file.cr +++ b/src/file.cr @@ -2,6 +2,7 @@ class File < IO::FileDescriptor end require "./file/error" +require "./file/match" require "crystal/system/file" # A `File` instance represents a file entry in the local file system and allows using it as an `IO`. @@ -467,227 +468,6 @@ class File < IO::FileDescriptor Path.new(path).expand(dir || Dir.current, home: home).to_s end - class BadPatternError < Exception - end - - # Matches *path* against *pattern*. - # - # The pattern syntax is similar to shell filename globbing. It may contain the following metacharacters: - # - # * `*` matches an unlimited number of arbitrary characters, excluding any directory separators. - # * `"*"` matches all regular files. - # * `"c*"` matches all files beginning with `c`. - # * `"*c"` matches all files ending with `c`. - # * `"*c*"` matches all files that have `c` in them (including at the beginning or end). - # * `**` matches directories recursively if followed by `/`. - # If this path segment contains any other characters, it is the same as the usual `*`. - # * `?` matches one arbitrary character, excluding any directory separators. - # * character sets: - # * `[abc]` matches any one of these characters. - # * `[^abc]` matches any one character other than these. - # * `[a-z]` matches any one character in the range. - # * `{a,b}` matches subpattern `a` or `b`. - # * `\\` escapes the next character. - # - # If *path* is a `Path`, all directory separators supported by *path* are - # recognized, according to the path's kind. If *path* is a `String`, only `/` - # is considered a directory separator. - # - # NOTE: Only `/` in *pattern* matches directory separators in *path*. - def self.match?(pattern : String, path : Path | String) : Bool - expanded_patterns = [] of String - File.expand_brace_pattern(pattern, expanded_patterns) - - if path.is_a?(Path) - separators = Path.separators(path.@kind) - path = path.to_s - else - separators = Path.separators(Path::Kind::POSIX) - end - - expanded_patterns.each do |expanded_pattern| - return true if match_single_pattern(expanded_pattern, path, separators) - end - false - end - - private def self.match_single_pattern(pattern : String, path : String, separators) - # linear-time algorithm adapted from https://research.swtch.com/glob - preader = Char::Reader.new(pattern) - sreader = Char::Reader.new(path) - next_ppos = 0 - next_spos = 0 - strlen = path.bytesize - escaped = false - - while true - pnext = preader.has_next? - snext = sreader.has_next? - - return true unless pnext || snext - - if pnext - pchar = preader.current_char - char = sreader.current_char - - case {pchar, escaped} - when {'\\', false} - escaped = true - preader.next_char - next - when {'?', false} - if snext && !char.in?(separators) - preader.next_char - sreader.next_char - next - end - when {'*', false} - double_star = preader.peek_next_char == '*' - if char.in?(separators) && !double_star - preader.next_char - next_spos = 0 - next - else - next_ppos = preader.pos - next_spos = sreader.pos + sreader.current_char_width - preader.next_char - preader.next_char if double_star - next - end - when {'[', false} - pnext = preader.has_next? - - character_matched = false - character_set_open = true - escaped = false - inverted = false - case preader.peek_next_char - when '^' - inverted = true - preader.next_char - when ']' - raise BadPatternError.new "Invalid character set: empty character set" - else - # Nothing - # TODO: check if this branch is fine - end - - while pnext - pchar = preader.next_char - case {pchar, escaped} - when {'\\', false} - escaped = true - when {']', false} - character_set_open = false - break - when {'-', false} - raise BadPatternError.new "Invalid character set: missing range start" - else - escaped = false - if preader.has_next? && preader.peek_next_char == '-' - preader.next_char - range_end = preader.next_char - case range_end - when ']' - raise BadPatternError.new "Invalid character set: missing range end" - when '\\' - range_end = preader.next_char - else - # Nothing - # TODO: check if this branch is fine - end - range = (pchar..range_end) - character_matched = true if range.includes?(char) - elsif char == pchar - character_matched = true - end - end - pnext = preader.has_next? - false - end - raise BadPatternError.new "Invalid character set: unterminated character set" if character_set_open - - if character_matched != inverted && snext - preader.next_char - sreader.next_char - next - end - else - escaped = false - - if snext && sreader.current_char == pchar - preader.next_char - sreader.next_char - next - end - end - end - - if 0 < next_spos <= strlen - preader.pos = next_ppos - sreader.pos = next_spos - next - end - - raise BadPatternError.new "Empty escape character" if escaped - - return false - end - end - - # :nodoc: - def self.expand_brace_pattern(pattern : String, expanded) : Array(String)? - reader = Char::Reader.new(pattern) - - lbrace = nil - rbrace = nil - alt_start = nil - - alternatives = [] of String - - nest = 0 - escaped = false - reader.each do |char| - case {char, escaped} - when {'{', false} - lbrace = reader.pos if nest == 0 - nest += 1 - when {'}', false} - nest -= 1 - - if nest == 0 - rbrace = reader.pos - start = (alt_start || lbrace).not_nil! + 1 - alternatives << pattern.byte_slice(start, reader.pos - start) - break - end - when {',', false} - if nest == 1 - start = (alt_start || lbrace).not_nil! + 1 - alternatives << pattern.byte_slice(start, reader.pos - start) - alt_start = reader.pos - end - when {'\\', false} - escaped = true - else - escaped = false - end - end - - if lbrace && rbrace - front = pattern.byte_slice(0, lbrace) - back = pattern.byte_slice(rbrace + 1) - - alternatives.each do |alt| - brace_pattern = {front, alt, back}.join - - expand_brace_pattern brace_pattern, expanded - end - else - expanded << pattern - end - end - # Resolves the real path of *path* by following symbolic links. def self.realpath(path : Path | String) : String Crystal::System::File.realpath(path.to_s) diff --git a/src/file/match.cr b/src/file/match.cr new file mode 100644 index 000000000000..060e7f8bb672 --- /dev/null +++ b/src/file/match.cr @@ -0,0 +1,222 @@ +class File < IO::FileDescriptor + class BadPatternError < Exception + end + + # Matches *path* against *pattern*. + # + # The pattern syntax is similar to shell filename globbing. It may contain the following metacharacters: + # + # * `*` matches an unlimited number of arbitrary characters, excluding any directory separators. + # * `"*"` matches all regular files. + # * `"c*"` matches all files beginning with `c`. + # * `"*c"` matches all files ending with `c`. + # * `"*c*"` matches all files that have `c` in them (including at the beginning or end). + # * `**` matches directories recursively if followed by `/`. + # If this path segment contains any other characters, it is the same as the usual `*`. + # * `?` matches one arbitrary character, excluding any directory separators. + # * character sets: + # * `[abc]` matches any one of these characters. + # * `[^abc]` matches any one character other than these. + # * `[a-z]` matches any one character in the range. + # * `{a,b}` matches subpattern `a` or `b`. + # * `\\` escapes the next character. + # + # If *path* is a `Path`, all directory separators supported by *path* are + # recognized, according to the path's kind. If *path* is a `String`, only `/` + # is considered a directory separator. + # + # NOTE: Only `/` in *pattern* matches directory separators in *path*. + def self.match?(pattern : String, path : Path | String) : Bool + expanded_patterns = [] of String + File.expand_brace_pattern(pattern, expanded_patterns) + + if path.is_a?(Path) + separators = Path.separators(path.@kind) + path = path.to_s + else + separators = Path.separators(Path::Kind::POSIX) + end + + expanded_patterns.each do |expanded_pattern| + return true if match_single_pattern(expanded_pattern, path, separators) + end + false + end + + private def self.match_single_pattern(pattern : String, path : String, separators) + # linear-time algorithm adapted from https://research.swtch.com/glob + preader = Char::Reader.new(pattern) + sreader = Char::Reader.new(path) + next_ppos = 0 + next_spos = 0 + strlen = path.bytesize + escaped = false + + while true + pnext = preader.has_next? + snext = sreader.has_next? + + return true unless pnext || snext + + if pnext + pchar = preader.current_char + char = sreader.current_char + + case {pchar, escaped} + when {'\\', false} + escaped = true + preader.next_char + next + when {'?', false} + if snext && !char.in?(separators) + preader.next_char + sreader.next_char + next + end + when {'*', false} + double_star = preader.peek_next_char == '*' + if char.in?(separators) && !double_star + preader.next_char + next_spos = 0 + next + else + next_ppos = preader.pos + next_spos = sreader.pos + sreader.current_char_width + preader.next_char + preader.next_char if double_star + next + end + when {'[', false} + pnext = preader.has_next? + + character_matched = false + character_set_open = true + escaped = false + inverted = false + case preader.peek_next_char + when '^' + inverted = true + preader.next_char + when ']' + raise BadPatternError.new "Invalid character set: empty character set" + else + # Nothing + # TODO: check if this branch is fine + end + + while pnext + pchar = preader.next_char + case {pchar, escaped} + when {'\\', false} + escaped = true + when {']', false} + character_set_open = false + break + when {'-', false} + raise BadPatternError.new "Invalid character set: missing range start" + else + escaped = false + if preader.has_next? && preader.peek_next_char == '-' + preader.next_char + range_end = preader.next_char + case range_end + when ']' + raise BadPatternError.new "Invalid character set: missing range end" + when '\\' + range_end = preader.next_char + else + # Nothing + # TODO: check if this branch is fine + end + range = (pchar..range_end) + character_matched = true if range.includes?(char) + elsif char == pchar + character_matched = true + end + end + pnext = preader.has_next? + false + end + raise BadPatternError.new "Invalid character set: unterminated character set" if character_set_open + + if character_matched != inverted && snext + preader.next_char + sreader.next_char + next + end + else + escaped = false + + if snext && sreader.current_char == pchar + preader.next_char + sreader.next_char + next + end + end + end + + if 0 < next_spos <= strlen + preader.pos = next_ppos + sreader.pos = next_spos + next + end + + raise BadPatternError.new "Empty escape character" if escaped + + return false + end + end + + # :nodoc: + def self.expand_brace_pattern(pattern : String, expanded) : Array(String)? + reader = Char::Reader.new(pattern) + + lbrace = nil + rbrace = nil + alt_start = nil + + alternatives = [] of String + + nest = 0 + escaped = false + reader.each do |char| + case {char, escaped} + when {'{', false} + lbrace = reader.pos if nest == 0 + nest += 1 + when {'}', false} + nest -= 1 + + if nest == 0 + rbrace = reader.pos + start = (alt_start || lbrace).not_nil! + 1 + alternatives << pattern.byte_slice(start, reader.pos - start) + break + end + when {',', false} + if nest == 1 + start = (alt_start || lbrace).not_nil! + 1 + alternatives << pattern.byte_slice(start, reader.pos - start) + alt_start = reader.pos + end + when {'\\', false} + escaped = true + else + escaped = false + end + end + + if lbrace && rbrace + front = pattern.byte_slice(0, lbrace) + back = pattern.byte_slice(rbrace + 1) + + alternatives.each do |alt| + brace_pattern = {front, alt, back}.join + + expand_brace_pattern brace_pattern, expanded + end + else + expanded << pattern + end + end +end