diff --git a/pathspec/patterns/gitignore/base.py b/pathspec/patterns/gitignore/base.py index 9179c3a..135a66c 100644 --- a/pathspec/patterns/gitignore/base.py +++ b/pathspec/patterns/gitignore/base.py @@ -52,21 +52,21 @@ def _strip_trailing_ws(pattern: str) -> str: """ - Strip trailing whitespace from the pattern while considering that whitespace - can be escaped. + Strip trailing spaces and line endings from the pattern while considering + that they can be escaped. Other whitespace is part of the filename. *pattern* (:class:`str`) is the pattern. Returns the modified pattern (:class:`str`). """ i = len(pattern) - 1 - if i == -1 or not pattern[i].isspace(): - # Fast path: pattern does not end with whitespace. Nothing to strip. + if i == -1 or pattern[i] not in ' \r\n': + # Fast path: pattern does not end with a space or line ending. return pattern - # Scan past whitespace. + # Scan past spaces and line endings. i -= 1 - while i >= 0 and pattern[i].isspace(): + while i >= 0 and pattern[i] in ' \r\n': i -= 1 last_ws = i + 1 diff --git a/tests/test_02_gitignore_base.py b/tests/test_02_gitignore_base.py index aa91885..2c3e47b 100644 --- a/tests/test_02_gitignore_base.py +++ b/tests/test_02_gitignore_base.py @@ -54,7 +54,7 @@ def test_02_strip_trailing_ws_1_even_bs(self): """ for n, wc in product( [0, 2, 4], - [' ', '\n', '\t'], + [' ', '\n', '\r'], ): with self.subTest(n=n, wc=wc): bs = BS*n @@ -69,10 +69,21 @@ def test_02_strip_trailing_ws_2_odd_bs(self): """ for n, wc in product( [0, 1, 3], - [' ', '\n', '\t'], + [' ', '\n', '\r'], ): with self.subTest(n=n, wc=wc): bs = BS*n ws = wc*n val = _strip_trailing_ws(f' foo{bs}{ws}') self.assertEqual(val, f' foo{bs}{ws[:1]}') + + def test_02_strip_trailing_ws_3_literal_whitespace(self): + """ + Only spaces and line endings are stripped, not filename characters. + """ + for wc, ending in product( + ['\t', '\v', '\f', '\x85', '\xa0', '\u2003'], + ['', ' ', '\n', '\r\n'], + ): + with self.subTest(wc=wc, ending=ending): + self.assertEqual(_strip_trailing_ws(f'foo{wc}{ending}'), f'foo{wc}') diff --git a/tests/test_07_gitignore_trailing_chars.py b/tests/test_07_gitignore_trailing_chars.py new file mode 100644 index 0000000..455916f --- /dev/null +++ b/tests/test_07_gitignore_trailing_chars.py @@ -0,0 +1,96 @@ +""" +Test that trailing filename characters are not treated as trailing spaces. +""" + +import io +import unittest + +from pathspec import ( + GitIgnoreSpec, + PathSpec) +from pathspec.patterns.gitignore.basic import ( + GitIgnoreBasicPattern) +from pathspec.patterns.gitignore.spec import ( + GitIgnoreSpecPattern) + + +class GitIgnoreTrailingCharsTest(unittest.TestCase): + """ + Test literal trailing whitespace in patterns and filename escaping. + """ + + def test_01_literal_whitespace(self): + for cls in (GitIgnoreBasicPattern, GitIgnoreSpecPattern): + for suffix in ('\t', '\v', '\f', '\x85', '\xa0', '\u2003'): + name = 'foo' + suffix + for ending in ('', ' ', '\n', '\r\n'): + with self.subTest(cls=cls, suffix=suffix, ending=ending): + pattern = cls(name + ending) + self.assertTrue(pattern.match_file(name)) + self.assertIsNone(pattern.match_file('foo')) + escaped = cls(cls.escape(name)) + self.assertTrue(escaped.match_file(name)) + + def test_02_bytes(self): + for cls in (GitIgnoreBasicPattern, GitIgnoreSpecPattern): + for suffix in (b'\t', b'\v', b'\f', b'\x85', b'\xa0', b'\xc2\xa0', b'\xe2\x80\x83'): + name = b'foo' + suffix + for ending in (b'', b' ', b'\n', b'\r\n'): + with self.subTest(cls=cls, suffix=suffix, ending=ending): + pattern = cls(name + ending) + self.assertTrue(pattern.match_file(name)) + self.assertIsNone(pattern.match_file(b'foo')) + escaped = cls(cls.escape(name)) + self.assertTrue(escaped.match_file(name)) + + def test_03_stream_lines(self): + for suffix in ('\t', '\xa0', '\u2003'): + name = 'foo' + suffix + for ending in ('\n', '\r\n'): + for make_spec in ( + lambda lines: PathSpec.from_lines('gitignore', lines, backend='simple'), + lambda lines: GitIgnoreSpec.from_lines(lines, backend='simple'), + ): + with self.subTest(suffix=suffix, ending=ending, make_spec=make_spec): + spec = make_spec(io.StringIO(name + ending)) + self.assertTrue(spec.match_file(name)) + self.assertFalse(spec.match_file('foo')) + + def test_04_spaces_and_backslashes(self): + for cls in (GitIgnoreBasicPattern, GitIgnoreSpecPattern): + for raw, name in ( + ('foo ', 'foo'), + ('foo\\ ', 'foo '), + ('foo\\\\ ', 'foo\\'), + ('foo\\\\\\ ', 'foo\\ '), + ('foo\\\t ', 'foo\t'), + ): + for ending in ('', '\n', '\r\n'): + with self.subTest(cls=cls, raw=raw, ending=ending): + self.assertTrue(cls(raw + ending).match_file(name)) + + def test_05_negation_and_directories(self): + for suffix in ('\t', '\xa0', '\u2003'): + name = 'foo' + suffix + with self.subTest(suffix=suffix): + spec = GitIgnoreSpec.from_lines(['*', '!' + name], backend='simple') + self.assertFalse(spec.match_file(name)) + self.assertTrue(spec.match_file('foo')) + spec = GitIgnoreSpec.from_lines([name + '/'], backend='simple') + self.assertTrue(spec.match_file(name + '/')) + self.assertTrue(spec.match_file(name + '/child')) + self.assertFalse(spec.match_file(name)) + self.assertFalse(spec.match_file('foo/child')) + + def test_06_character_after_slash(self): + for suffix in ('\t', '\xa0', '\u2003'): + name = 'foo/' + suffix + for make_spec in ( + lambda lines: PathSpec.from_lines('gitignore', lines, backend='simple'), + lambda lines: GitIgnoreSpec.from_lines(lines, backend='simple'), + ): + with self.subTest(suffix=suffix, make_spec=make_spec): + spec = make_spec([name]) + self.assertTrue(spec.match_file(name)) + self.assertFalse(spec.match_file('foo/')) + self.assertFalse(spec.match_file('foo/other'))