import 'dart:io' show Platform; import '../../l10n/tr.dart'; // 약속 (나누기 sync_tools): rsync 옵션 칸의 글 - 붙여 넣은 이상한 글자 고치기 · 인수로 나누기 · rsync 가 다르게 읽을 것 찾기 · 목록 파일 값. // 입력: 옵션 글 (사용자가 적거나 붙여 넣은 것) · 낱말 목록 · Windows 인지 (null 이면 이 기기). // 출력: 낱말 목록 · 문제 목록 ([OptionProblem]) · 고칠 방법 글. 오류: 던지지 않는다 (알아볼 수 없는 것은 문제 목록으로 돌려준다). /// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) · /// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움. /// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다 /// 28 확인 (10/20): 소프트 하이픈 (U+00AD) 은 지우고 (rsync 가 "unknown option" 으로 실패), 전각 빈칸 (U+3000) 도 보통 빈칸으로 String cleanOptionText(String s) => s .replaceAll(RegExp('[\u00A0\u2007\u202F\u3000]'), ' ') .replaceAll(RegExp('[\u00AD\u200B\u200C\u200D\u2060\uFEFF]'), ''); /// 낱말 맨 앞에서 - 로 읽는 대시 (‐ ‑ ‒ – — ― − ﹘ ﹣ -) - 워드 · 웹이 -- 를 바꿔 놓은 것 const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D'; /// 굽은 따옴표 → 곧은 따옴표 (같은 무리끼리 열고 닫는다) const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'}; /// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText]. /// 관리자 지시 (10/20, 28 · 15 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는 /// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다. /// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표에서 닫는다 /// (곧은 따옴표처럼 뒤에 글이 이어져도 - “a b”.txt → a b.txt). 작은따옴표 무리는 글자 사이 (it’s) 이면 닫지 않는다. /// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다. /// - 닫히지 않은 따옴표 (15 확인 F1): 예전에는 뒤의 옵션을 모두 삼켜 지키려던 --exclude 가 사라졌다 → 그 따옴표를 글자로 보고 /// 나머지는 그대로 나누며, [unreadableOptions] 가 알려 실행을 막는다. /// - 낱말 맨 앞의 대시 (– — ‑ - 등, 따옴표 밖): 둘이면 --, 하나면 rsync 의 긴 옵션 이름 (–delete) 일 때만 -- 그 밖 (–avz) 은 -. /// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로. List splitOptions(String s) => _parseOptions(s).words; /// 짧은 옵션 묶음에서 값을 받는 rsync 글자 (popt): 그 글자 뒤의 글 (비었으면 다음 낱말) 이 값 - -avf"- *.log" · -rf "- x" const rsyncShortWithArg = 'BefMT@'; /// = 없이 다음 낱말을 값으로 받는 rsync 긴 옵션 (--exclude x · --filter "- y") const _rsyncLongWithArg = { 'address', 'backup-dir', 'block-size', 'bwlimit', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress-choice', 'compress-level', 'contimeout', 'copy-as', 'copy-dest', 'debug', 'early-input', 'exclude', 'exclude-from', 'files-from', 'filter', 'groupmap', 'iconv', 'include', 'include-from', 'info', 'link-dest', 'log-file', 'log-file-format', 'max-alloc', 'max-delete', 'max-size', 'min-size', 'modify-window', 'only-write-batch', 'out-format', 'outbuf', 'partial-dir', 'password-file', 'port', 'protocol', 'read-batch', 'remote-option', 'rsh', 'rsync-path', 'skip-compress', 'sockopts', 'stop-after', 'stop-at', 'suffix', 'temp-dir', 'timeout', 'usermap', 'write-batch', }; /// modular_sync #3 OptionText: 옵션 낱말들 ([splitOptions]) 에 rsync 옵션 [short] (한 글자, 짧은 묶음 안에서도) 또는 [long] (--이름) 이 있는지. /// popt 처럼 읽는다 - 짧은 묶음은 한 글자씩, 값 자리 (값을 받는 글자 뒤 · 값을 받는 옵션의 다음 낱말) 는 옵션으로 보지 않는다. /// 예전에는 "-로 시작하는 낱말에 그 글자가 있으면" 으로 봐서 -f'- build/' 의 u 를 -u 로 읽었다 (미리 보기 ≠ 실제) bool rsyncHasOption(List words, {String? short, String? long}) { for (var i = 0; i < words.length; i++) { final o = words[i]; if (o.startsWith('--')) { final eq = o.indexOf('='); final name = eq < 0 ? o : o.substring(0, eq); if (long != null && name == long) return true; if (eq < 0 && _rsyncLongWithArg.contains(name.substring(2))) i++; continue; } if (o.length < 2 || !o.startsWith('-')) continue; for (var j = 1; j < o.length; j++) { final ch = o[j]; if (short != null && ch == short) return true; if (rsyncShortWithArg.contains(ch)) { if (j == o.length - 1) i++; break; } } } return false; } /// rsync 3.x 의 긴 옵션 이름 (앱에 든 rsync 3.5.1 의 --help) - 대시 하나로 붙여 넣은 –delete 를 --delete 로 읽을지 정한다 const _rsyncLong = { 'acls', 'address', 'append', 'append-verify', 'archive', 'atimes', 'backup', 'backup-dir', 'block-size', 'blocking-io', 'bwlimit', 'cc', 'checksum', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress', 'compress-choice', 'compress-level', 'compress-threads', 'confine-root', 'contimeout', 'copy-as', 'copy-dest', 'copy-devices', 'copy-dirlinks', 'copy-links', 'copy-unsafe-links', 'crtimes', 'cvs-exclude', 'daemon', 'debug', 'del', 'delay-updates', 'delete', 'delete-after', 'delete-before', 'delete-delay', 'delete-during', 'delete-excluded', 'delete-missing-args', 'devices', 'dirs', 'dry-run', 'early-input', 'exclude', 'exclude-from', 'executability', 'existing', 'fake-super', 'files-from', 'filter', 'force', 'from0', 'fsync', 'fuzzy', 'group', 'groupmap', 'hard-links', 'help', 'human-readable', 'i-r', 'iconv', 'ignore-errors', 'ignore-existing', 'ignore-missing-args', 'ignore-non-existing', 'ignore-times', 'inc-recursive', 'include', 'include-from', 'info', 'inplace', 'insecure-links', 'ipv4', 'ipv6', 'itemize-changes', 'keep-dirlinks', 'link-dest', 'links', 'list-only', 'log-file', 'log-file-format', 'max-alloc', 'max-delete', 'max-size', 'min-size', 'mkpath', 'modify-window', 'munge-links', 'no-i-r', 'no-implied-dirs', 'no-inc-recursive', 'no-motd', 'no-whole-file', 'numeric-ids', 'old-args', 'old-d', 'old-dirs', 'omit-dir-times', 'omit-link-times', 'one-file-system', 'only-write-batch', 'open-noatime', 'out-format', 'outbuf', 'owner', 'partial', 'partial-dir', 'password-file', 'perms', 'port', 'preallocate', 'progress', 'protocol', 'prune-empty-dirs', 'quiet', 'read-batch', 'recursive', 'relative', 'remote-option', 'remove-source-files', 'rsh', 'rsync-path', 'safe-links', 'secluded-args', 'size-only', 'skip-compress', 'sockopts', 'sparse', 'specials', 'stats', 'stderr', 'stop-after', 'stop-at', 'suffix', 'super', 'temp-dir', 'timeout', 'times', 'trust-sender', 'update', 'usermap', 'verbose', 'version', 'whole-file', 'write-batch', 'write-devices', 'xattrs', 'zc', 'zl', 'zt', }; /// 나눈 낱말: 맨 앞이 따옴표 안이었는지 (그러면 맨 앞 대시를 옵션으로 고치지 않는다) typedef _Word = ({String text, bool quoted}); /// [splitOptions] · [unreadableOptions] 의 몸통: 낱말들과 닫히지 않은 따옴표 (뒤 글 일부) ({List words, List unclosed}) _parseOptions(String s) { final chars = cleanOptionText(s).split(''); final literal = {}; // 닫히지 않아 글자로 보는 따옴표 자리 final unclosed = []; while (true) { final (raw, open) = _splitRaw(chars, literal); if (open == null) return (words: _normalizeWords(raw), unclosed: unclosed); literal.add(open); final rest = chars.sublist(open).join().trim(); unclosed.add(rest.length > 30 ? '${rest.substring(0, 30)}…' : rest); } } /// 따옴표 · 빈칸으로 나눈다. 끝까지 닫히지 않은 따옴표가 있으면 그 자리 (두 번째 값) (List<_Word>, int?) _splitRaw(List chars, Set literal) { final out = <_Word>[]; final cur = StringBuffer(); String? quote; // 닫는 곧은 따옴표 (" 또는 ') var curly = false; // 굽은 따옴표로 열었는지 var openAt = -1; var has = false; var quoted = false; bool letter(int i) => i >= 0 && i < chars.length && RegExp(r'[\p{L}\p{N}]', unicode: true).hasMatch(chars[i]); for (var i = 0; i < chars.length; i++) { final ch = chars[i]; if (quote != null) { // 작은따옴표 무리의 ’ 가 글자 사이면 (it’s) 닫지 않는다 final closes = ch == quote && !curly || curly && _curlyQuote[ch] == quote && !(quote == "'" && letter(i - 1) && letter(i + 1)); if (closes) { quote = null; curly = false; } else { cur.write(ch); } } else if (literal.contains(i)) { cur.write(ch); } else if (ch == '"' || ch == "'") { if (cur.isEmpty && !has) quoted = true; quote = ch; openAt = i; has = true; } else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) { if (cur.isEmpty && !has) quoted = true; quote = _curlyQuote[ch]; curly = true; openAt = i; has = true; } else if (ch.trim().isEmpty) { if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted)); cur.clear(); has = false; quoted = false; } else { cur.write(ch); } } if (quote != null) return (out, openAt); if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted)); return (out, null); } bool _dashAt(String t, int i) => i < t.length && (t[i] == '-' || _dashLike.contains(t[i])); /// 거르기 규칙 머리 ("– *.tmp" · "+ *.txt") 를 rsync 가 읽는 - · + 로 String _fixRuleHead(String v) { if (v.isEmpty) return v; if (_dashLike.contains(v[0])) return '-${v.substring(1)}'; if (v[0] == '\uFF0B') return '+${v.substring(1)}'; return v; } /// 낱말 맨 앞 대시 고치기 · 거르기 값의 규칙 머리 고치기 (값 자리는 그대로) List _normalizeWords(List<_Word> raw) { final out = []; var value = ''; // 다음 낱말이 값: 'filter' (규칙 머리를 고침) · 'other' (그대로) for (final w in raw) { var t = w.text; if (value.isNotEmpty) { out.add(value == 'filter' ? _fixRuleHead(t) : t); value = ''; continue; } // 28 확인 (10/20): 곧은 하이픈과 섞인 것 (-–delete) 도 맨 앞 둘 중 하나라도 대시면 같은 규칙으로 if (!w.quoted && t.isNotEmpty && (_dashLike.contains(t[0]) || t.length > 1 && t[0] == '-' && _dashLike.contains(t[1]))) { final n = _dashAt(t, 1) ? 2 : 1; final rest = t.substring(n); final name = rest.split('=').first; final long = n == 2 || _rsyncLong.contains(name) || (name.startsWith('no-') && _rsyncLong.contains(name.substring(3))); t = '${long ? '--' : '-'}$rest'; } if (t.startsWith('--filter=')) { t = '--filter=${_fixRuleHead(t.substring(9))}'; } else if (t.length > 1 && t.startsWith('-') && !t.startsWith('--')) { final k = t.indexOf(RegExp('[BefMT@]'), 1); if (k > 0 && t[k] == 'f' && k < t.length - 1) t = '${t.substring(0, k + 1)}${_fixRuleHead(t.substring(k + 1))}'; } out.add(t); if (t.startsWith('--')) { if (!t.contains('=') && _rsyncLongWithValue.contains(t)) value = t == '--filter' ? 'filter' : 'other'; } else if (t.length > 1 && t.startsWith('-')) { final k = t.indexOf(RegExp('[BefMT@]'), 1); if (k == t.length - 1) value = t[k] == 'f' ? 'filter' : 'other'; } } return out; } /// 값을 다음 낱말로 받는 rsync 긴 옵션 (--exclude *.log) - [unreadableOptions] 가 그 값을 옵션으로 보지 않게 const _rsyncLongWithValue = { '--exclude', '--include', '--filter', '--exclude-from', '--include-from', '--files-from', '--backup-dir', '--suffix', '--log-file', '--log-file-format', '--out-format', '--chmod', '--chown', '--usermap', '--groupmap', '--bwlimit', '--timeout', '--contimeout', '--rsh', '--rsync-path', '--compare-dest', '--copy-dest', '--link-dest', '--partial-dir', '--temp-dir', '--max-size', '--min-size', '--max-delete', '--modify-window', '--password-file', '--iconv', '--block-size', '--checksum-choice', '--compress-choice', '--compress-level', '--skip-compress', '--info', '--debug', '--sockopts', '--port', '--address', '--outbuf', '--remote-option', '--max-alloc', '--stop-after', '--stop-at', '--write-batch', '--only-write-batch', '--read-batch', '--protocol', '--checksum-seed', '--copy-as', '--early-input', // 28 확인 (10/20): rsync --help 의 짧은 별칭 (aka --cc · --zc · --zl · --zt) 과 값 받는 옵션 - 빠져 있어 값이 "경로로 읽힙니다" 로 막혔다 '--cc', '--zc', '--zl', '--zt', '--compress-threads', '--confine-root', }; /// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에 /// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐), /// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다 List unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text]; /// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적) enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath, stdin } /// [unreadableOptions] 의 한 줄과 그 종류 class OptionProblem { final OptionProblemKind kind; final String text; /// [text] 안의 옵션 낱말 (화면이 줄바꿈 없이 묶는다) final String token; const OptionProblem(this.kind, this.text, [this.token = '']); @override String toString() => text; } /// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다) String optionProblemHint(Iterable problems, {bool? windows}) { windows ??= Platform.isWindows; final kinds = {for (final x in problems) x.kind}; return [ if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'), if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'), if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'), if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'), if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'), // 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 / if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'), if (kinds.contains(OptionProblemKind.stdin)) tr('목록 파일의 전체 경로를 적어 주세요'), ].join(' · '); } /// "옵션을 알아볼 수 없습니다: … - 고칠 방법" (작업 기록 · 실시간 동기화 카드 · 설정 칸) String optionProblemMessage(List problems) => trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]); /// [unreadableOptions] 의 몸통 (종류와 함께) List optionProblems(String options, {bool? windows}) { windows ??= Platform.isWindows; final parsed = _parseOptions(options); // 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다 final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)]; var value = false; var last = ''; for (final t in parsed.words) { // P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도. // 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다 if (windows && parsed.unclosed.isEmpty && t.contains('"')) { bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t)); value = false; continue; } if (value) { value = false; continue; } if (t.startsWith('---')) { // 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다" bad.add(OptionProblem(OptionProblemKind.path, t, t)); } else if (t.startsWith('--')) { value = !t.contains('=') && _rsyncLongWithValue.contains(t); last = t; } else if (t.length > 1 && t.startsWith('-')) { // 짧은 옵션 묶음: 값을 받는 글자 (B · e · f · M · T · @) 가 끝이면 다음 낱말이 값 (rsync_filter 와 같은 규칙) final k = t.indexOf(RegExp('[BefMT@]'), 1); value = k == t.length - 1; last = t; } else { // 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데 // - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다 // ASCII 밖 글자로 시작하면 (« … 등) 붙여 넣은 이상한 글자 - 안내는 곧은 글자로 다시 적기 bad.add(OptionProblem(t.codeUnitAt(0) > 0x7F ? OptionProblemKind.odd : OptionProblemKind.path, trf('경로로 읽힙니다: {0}', [t]), t)); } } // 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다 if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last)); // 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의 // 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다 for (final f in listFileValues(parsed.words)) { // 관리자 지시 (10/20, 28 지적): "-" (표준 입력) 는 따로 - 앱은 rsync 에 표준 입력을 주지 않는다 if (f == '-') { bad.add(OptionProblem(OptionProblemKind.stdin, tr('표준 입력 (-) 은 쓸 수 없습니다'), f)); } else if (!_absolutePath(f, windows: windows)) { bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); } } // 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다. // --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로 for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) { if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); } return bad; } /// 긴 옵션 [names] 의 값 (--이름=값 · --이름 값) List _optionValues(List words, Set names) => [ for (var i = 0; i < words.length; i++) if (words[i].contains('=') && names.contains(words[i].substring(0, words[i].indexOf('=')))) words[i].substring(words[i].indexOf('=') + 1) else if (names.contains(words[i]) && i + 1 < words.length) words[i + 1], ]; /// 파일을 받는 옵션의 값 (--exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일"). /// ": 이름" · dir-merge 는 폴더마다 읽는 파일 이름이라 (원래 상대) 빼고 List listFileValues(List words) { const fileOpts = {'--exclude-from', '--include-from', '--files-from'}; final out = []; String? mergeFile(String rule) => RegExp(r'^(?:\.|merge)(?:,[^\s]*)?\s+(.+)$').firstMatch(rule.trim())?.group(1); for (var i = 0; i < words.length; i++) { final w = words[i]; final next = i + 1 < words.length ? words[i + 1] : null; final eq = w.indexOf('='); if (w.startsWith('--') && eq > 0 && fileOpts.contains(w.substring(0, eq))) { out.add(w.substring(eq + 1)); } else if (fileOpts.contains(w) && next != null) { out.add(next); i++; } else if (w.startsWith('--filter=')) { final f = mergeFile(w.substring(9)); if (f != null) out.add(f); } else if (w == '--filter' && next != null) { final f = mergeFile(next); if (f != null) out.add(f); i++; } else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) { // 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록") final k = w.indexOf(RegExp('[BefMT@]'), 1); if (k < 0 || w[k] != 'f') continue; final rule = k == w.length - 1 ? next : w.substring(k + 1); if (k == w.length - 1) i++; final f = rule == null ? null : mergeFile(rule); if (f != null) out.add(f); } } return out; } /// 전체 경로. Windows: 드라이브 (C:\ · C:/) · UNC (\\서버 · //서버) · /cygdrive/… - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 뺀다. /// Android: / 로 시작 bool _absolutePath(String f, {required bool windows}) => windows ? RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\|//|/cygdrive/)').hasMatch(f) : f.startsWith('/');