whitespace-format 0.0.5__py3-none-any.whl → 0.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: whitespace-format
3
- Version: 0.0.5
3
+ Version: 0.0.6
4
4
  Summary: Linter and formatter for source code files and text files
5
5
  Home-page: https://github.com/DavidPal/whitespace-format
6
6
  License: MIT
7
7
  Author: David Pal
8
8
  Author-email: davidko.pal@gmail.com
9
- Requires-Python: >=3.7.2,<4.0.0
9
+ Requires-Python: >=3.8.0,<4.0.0
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Programming Language :: Python
12
12
  Classifier: Programming Language :: Python :: 3
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.9
15
15
  Classifier: Programming Language :: Python :: 3.10
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
18
19
  Project-URL: Repository, https://github.com/DavidPal/whitespace-format
19
20
  Description-Content-Type: text/markdown
20
21
 
@@ -50,7 +51,7 @@ second time (with the same parameters) has no effect.
50
51
  pip install whitespace-format
51
52
  ```
52
53
 
53
- Installation requires Python 3.7.5 or higher.
54
+ Installation requires Python 3.8.0 or higher.
54
55
 
55
56
  ## Usage
56
57
 
@@ -95,14 +96,15 @@ The regular expression is evaluated on the path of each file.
95
96
 
96
97
  * `--add-new-line-marker-at-end-of-file` -- Add missing new line marker at end of each file.
97
98
  * `--remove-new-line-marker-from-end-of-file` -- Remove all new line marker(s) from the end of each file.
98
- This option is ignored when `--add-new-line-marker-at-end-of-file` is used.
99
- Empty lines at the end of the file are removed.
99
+ This option cannot be used in combination with `--add-new-line-marker-at-end-of-file`.
100
+ Empty lines at the end of the file are removed, i.e., this option implies `--remove-trailing-empty-lines`
101
+ option.
100
102
  * `--normalize-new-line-markers` -- Make new line markers consistent in each file
101
103
  by replacing `\r\n`, `\n`, and `\r` with a consistent new line marker.
102
104
  * `--remove-trailing-whitespace` -- Remove whitespace at the end of each line.
103
105
  * `--remove-trailing-empty-lines` -- Remove empty lines at the end of each file.
104
- * `--new-line-marker=MARKER` -- This option specifies what new line marker to use.
105
- `MARKER` must be one of the following:
106
+ * `--new-line-marker=MARKER` -- This option specifies what new line marker to when
107
+ adding or replacing new line markers. `MARKER` must be one of the following:
106
108
  * `auto` -- Use new line marker that is the most common in each individual file.
107
109
  If no new line marker is present in the file, Linux `\n` is used.
108
110
  This is the default option.
@@ -0,0 +1,6 @@
1
+ whitespace_format.py,sha256=Z-h8ePtLgn82M8Sj0OqdcIg1FNGqqkzmW23lJx4IE7E,28946
2
+ whitespace_format-0.0.6.dist-info/LICENSE,sha256=rT6UNfWDYFQc-eo65FioDJRMAyVOndtF95wNCUhkK74,1076
3
+ whitespace_format-0.0.6.dist-info/METADATA,sha256=lz3cNbXeyuTD4sTVINHswLIUNjPM3gL98bYScrAYt0k,10399
4
+ whitespace_format-0.0.6.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
5
+ whitespace_format-0.0.6.dist-info/entry_points.txt,sha256=LbXoevzUZAF5MVbI2foNC9xeDjKS_Woz7VbA1ZNF5CY,60
6
+ whitespace_format-0.0.6.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.9.0
2
+ Generator: poetry-core 1.9.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
whitespace_format.py CHANGED
@@ -3,7 +3,8 @@
3
3
  """Formatter of whitespace in text files.
4
4
 
5
5
  Author: David Pal <davidko.pal@gmail.com>
6
- Date: 2023
6
+ Date: 2023 - 2024
7
+ License: MIT License
7
8
 
8
9
  Usage:
9
10
 
@@ -13,21 +14,37 @@ Usage:
13
14
  from __future__ import annotations
14
15
 
15
16
  import argparse
16
- import copy
17
17
  import dataclasses
18
18
  import pathlib
19
19
  import re
20
20
  import sys
21
- from typing import Callable
22
- from typing import Dict
21
+ from enum import Enum
23
22
  from typing import List
23
+ from typing import Tuple
24
24
 
25
- VERSION = "0.0.5"
25
+ VERSION = "0.0.6"
26
26
 
27
27
  # Regular expression that does NOT match any string.
28
28
  UNMATCHABLE_REGEX = "$."
29
29
 
30
- END_OF_LINE_MARKERS = {
30
+ # Whitespace characters
31
+ CARRIAGE_RETURN = "\r"
32
+ LINE_FEED = "\n"
33
+ SPACE = " "
34
+ TAB = "\t"
35
+ VERTICAL_TAB = "\v"
36
+ FORM_FEED = "\f"
37
+
38
+ WHITESPACE_CHARACTERS = {
39
+ CARRIAGE_RETURN,
40
+ LINE_FEED,
41
+ SPACE,
42
+ TAB,
43
+ VERTICAL_TAB,
44
+ FORM_FEED,
45
+ }
46
+
47
+ NEW_LINE_MARKERS = {
31
48
  "windows": "\r\n",
32
49
  "linux": "\n",
33
50
  "mac": "\r",
@@ -59,6 +76,122 @@ COLORS = {
59
76
  "WHITE": "\033[97m",
60
77
  }
61
78
 
79
+ ESCAPE_TRANSLATION_TABLE = str.maketrans(
80
+ {
81
+ CARRIAGE_RETURN: "\\r",
82
+ LINE_FEED: "\\n",
83
+ TAB: "\\t",
84
+ VERTICAL_TAB: "\\v",
85
+ FORM_FEED: "\\f",
86
+ }
87
+ )
88
+
89
+
90
+ class ChangeType(Enum):
91
+ """Type of change that happened to a file."""
92
+
93
+ # New line marker was added to the end of the file (because it was missing).
94
+ ADDED_NEW_LINE_MARKER_TO_END_OF_FILE = 1
95
+
96
+ # New line marker was removed from the end of the file.
97
+ REMOVED_NEW_LINE_MARKER_FROM_END_OF_FILE = 2
98
+
99
+ # New line marker was replaced by another one.
100
+ REPLACED_NEW_LINE_MARKER = 3
101
+
102
+ # White at the end of a line was removed.
103
+ REMOVED_TRAILING_WHITESPACE = 4
104
+
105
+ # Empty line(s) at the end of file were removed.
106
+ REMOVED_EMPTY_LINES = 5
107
+
108
+ # An empty file was replaced by a file consisting of single empty line.
109
+ REPLACED_EMPTY_FILE_WITH_ONE_LINE = 6
110
+
111
+ # A file consisting of only whitespace was replaced by an empty file.
112
+ REPLACED_WHITESPACE_ONLY_FILE_WITH_EMPTY_FILE = 7
113
+
114
+ # A file consisting of only whitespace was replaced by a file consisting of single empty line.
115
+ REPLACED_WHITESPACE_ONLY_FILE_WITH_ONE_LINE = 8
116
+
117
+ # A tab character was replaces by space character(s).
118
+ REPLACED_TAB_WITH_SPACES = 9
119
+
120
+ # A tab character was removed.
121
+ REMOVED_TAB = 10
122
+
123
+ # A non-standard whitespace character (`\f` or `\v`) was replaced by a space character.
124
+ REPLACED_NONSTANDARD_WHITESPACE = 11
125
+
126
+ # A non-standard whitespace character (`\f` or `\v`) was removed.
127
+ REMOVED_NONSTANDARD_WHITESPACE = 12
128
+
129
+
130
+ @dataclasses.dataclass
131
+ class Change:
132
+ """Description of a change of the content of a file."""
133
+
134
+ change_type: ChangeType
135
+ line_number: int
136
+ changed_from: str = ""
137
+ changed_to: str = ""
138
+
139
+ def message(self, check_only: bool) -> str:
140
+ """Returns a message describing the change."""
141
+ check_only_word = " would be " if check_only else " "
142
+
143
+ if self.change_type == ChangeType.ADDED_NEW_LINE_MARKER_TO_END_OF_FILE:
144
+ return f"New line marker{check_only_word}added to the end of the file."
145
+
146
+ if self.change_type == ChangeType.REMOVED_NEW_LINE_MARKER_FROM_END_OF_FILE:
147
+ return f"New line marker{check_only_word}removed from the end of the file."
148
+
149
+ if self.change_type == ChangeType.REPLACED_NEW_LINE_MARKER:
150
+ return (
151
+ f"New line marker '{escape_chars(self.changed_from)}'"
152
+ f"{check_only_word}replaced by '{escape_chars(self.changed_to)}'."
153
+ )
154
+
155
+ if self.change_type == ChangeType.REMOVED_TRAILING_WHITESPACE:
156
+ return f"Trailing whitespace{check_only_word}removed."
157
+
158
+ if self.change_type == ChangeType.REMOVED_EMPTY_LINES:
159
+ return f"Empty line(s) at the end of the file{check_only_word}removed."
160
+
161
+ if self.change_type == ChangeType.REPLACED_EMPTY_FILE_WITH_ONE_LINE:
162
+ return f"Empty file{check_only_word}replaced with a single empty line."
163
+
164
+ if self.change_type == ChangeType.REPLACED_WHITESPACE_ONLY_FILE_WITH_EMPTY_FILE:
165
+ return f"File{check_only_word}replaced with an empty file."
166
+
167
+ if self.change_type == ChangeType.REPLACED_WHITESPACE_ONLY_FILE_WITH_ONE_LINE:
168
+ return f"File{check_only_word}replaced with a single empty line."
169
+
170
+ if self.change_type == ChangeType.REPLACED_TAB_WITH_SPACES:
171
+ return f"Tab{check_only_word}replaced with spaces."
172
+
173
+ if self.change_type == ChangeType.REMOVED_TAB:
174
+ return f"Tab{check_only_word}removed."
175
+
176
+ if self.change_type == ChangeType.REPLACED_NONSTANDARD_WHITESPACE:
177
+ return (
178
+ f"Non-standard whitespace character '{escape_chars(self.changed_from)}'"
179
+ f"{check_only_word}replaced by a space."
180
+ )
181
+
182
+ if self.change_type == ChangeType.REMOVED_NONSTANDARD_WHITESPACE:
183
+ return f"Non-standard whitespace character '{escape_chars(self.changed_from)}'{check_only_word}removed."
184
+
185
+ raise ValueError(f"Unknown change type: {self.change_type}")
186
+
187
+ def color_print(self, parsed_arguments: argparse.Namespace) -> None:
188
+ """Prints a message in color."""
189
+ color_print(
190
+ f"[BOLD][BLUE]↳ line {self.line_number + 1}: "
191
+ f"[WHITE]{self.message(parsed_arguments.check_only)}[RESET_ALL]",
192
+ parsed_arguments,
193
+ )
194
+
62
195
 
63
196
  def color_print(message: str, parsed_arguments: argparse.Namespace):
64
197
  """Outputs a colored message."""
@@ -72,6 +205,11 @@ def color_print(message: str, parsed_arguments: argparse.Namespace):
72
205
  print(message)
73
206
 
74
207
 
208
+ def escape_chars(text: str) -> str:
209
+ """Escapes special characters in a string."""
210
+ return text.translate(ESCAPE_TRANSLATION_TABLE)
211
+
212
+
75
213
  def string_to_hex(text: str) -> str:
76
214
  """Converts a string into a human-readable hexadecimal representation.
77
215
 
@@ -88,7 +226,10 @@ def die(error_code: int, message: str = ""):
88
226
 
89
227
 
90
228
  def read_file_content(file_name: str, encoding: str) -> str:
91
- """Reads content of a file."""
229
+ """Reads content of a file.
230
+
231
+ New line markers are preserved in their original form.
232
+ """
92
233
  try:
93
234
  with open(file_name, "r", encoding=encoding, newline="") as file:
94
235
  return file.read()
@@ -108,369 +249,298 @@ def write_file(file_name: str, file_content: str, encoding: str):
108
249
  die(4, f"Cannot write to file '{file_name}': {exception}")
109
250
 
110
251
 
111
- @dataclasses.dataclass
112
- class Line:
113
- """Line of a text file.
114
-
115
- The line is split into two parts:
116
- 1) Content
117
- 2) End of line marker ("\n", or "\r", or "\r\n")
118
- """
119
-
120
- content: str
121
- end_of_line_marker: str
122
-
123
- @staticmethod
124
- def create_from_string(line: str) -> Line:
125
- """Creates a line from a string.
126
-
127
- The function splits the input into content and end_of_line_marker.
128
- """
129
- for end_of_line_marker in ["\r\n", "\n", "\r"]:
130
- if line.endswith(end_of_line_marker):
131
- return Line(line[: -len(end_of_line_marker)], end_of_line_marker)
132
- return Line(line, "")
133
-
134
- def to_hex(self):
135
- """Returns a human-readable hexadecimal representation of the line.
136
-
137
- This function is for debugging purposes only. It is used only during development.
138
- """
139
- return f"({string_to_hex(self.content)}, {string_to_hex(self.end_of_line_marker)})"
140
-
141
-
142
- def split_lines(text: str) -> List[Line]:
143
- """Splits a string into lines."""
144
- lines: List[Line] = []
145
- current_line = ""
146
- for i, char in enumerate(text):
147
- current_line += char
148
- if (char == "\n") or (
149
- (char == "\r") and ((i >= len(text) - 1) or (not text[i + 1] == "\n"))
150
- ):
151
- lines.append(Line.create_from_string(current_line))
152
- current_line = ""
153
-
154
- if current_line:
155
- lines.append(Line.create_from_string(current_line))
156
-
157
- return lines
158
-
159
-
160
- def concatenate_lines(lines: List[Line]) -> str:
161
- """Concatenates a list of lines into a single string including end-of-line markers."""
162
- return "".join(line.content + line.end_of_line_marker for line in lines)
163
-
164
-
165
- def guess_end_of_line_marker(lines: List[Line]) -> str:
166
- """Guesses the end of line marker.
167
-
168
- The function returns the most common end-of-line marker.
169
- Ties are broken in order Linux "\n", Mac "\r", Windows "\r\n".
170
- If no end-of-line marker is present, default to the Linux "\n" end-of-line marker.
171
- """
172
- counts: Dict[str, int] = {"\n": 0, "\r": 0, "\r\n": 0}
173
- for line in lines:
174
- if line.end_of_line_marker in counts:
175
- counts[line.end_of_line_marker] += 1
176
- max_count = max(counts.values())
177
- for end_of_line_marker, count in counts.items():
178
- if count == max_count:
179
- return end_of_line_marker
180
- return "\n" # This return statement is never executed.
181
-
182
-
183
- def remove_trailing_empty_lines(lines: List[Line]) -> List[Line]:
184
- """Removes trailing empty lines.
185
-
186
- If there are no lines, empty list is returned.
187
- If all lines are empty, the first line is kept.
188
- """
189
- num_empty_trailing_lines = 0
190
- while (num_empty_trailing_lines < len(lines) - 1) and (
191
- not lines[-num_empty_trailing_lines - 1].content
192
- ):
193
- num_empty_trailing_lines += 1
194
- return copy.deepcopy(lines[: len(lines) - num_empty_trailing_lines])
195
-
196
-
197
- def remove_dummy_lines(lines: List[Line]) -> List[Line]:
198
- """Remove empty lines that also have empty end-of-line markers."""
199
- return [line for line in lines if line.content or line.end_of_line_marker]
200
-
201
-
202
- def remove_trailing_whitespace(lines: List[Line]) -> List[Line]:
203
- """Removes trailing whitespace from every line."""
204
- lines = [
205
- Line(
206
- re.sub(r"[ \n\r\t\f\v]*$", "", line.content),
207
- line.end_of_line_marker,
208
- )
209
- for line in lines
210
- ]
211
- return remove_dummy_lines(lines)
212
-
213
-
214
- def normalize_end_of_line_markers(lines: List[Line], new_end_of_line_marker: str) -> List[Line]:
215
- """Replaces end-of-line marker in all lines with a new end-of-line marker.
216
-
217
- Lines without end-of-line markers (i.e. possibly the last line) are left unchanged.
218
- """
219
- return [
220
- Line(line.content, new_end_of_line_marker) if line.end_of_line_marker else line
221
- for line in lines
222
- ]
223
-
224
-
225
- def remove_all_end_of_line_markers_from_end_of_file(lines: List[Line]) -> List[Line]:
226
- """Removes all end-of-line markers from the end of the file."""
227
- lines = remove_trailing_empty_lines(lines)
228
- if not lines:
229
- return []
230
- lines[-1] = Line(lines[-1].content, "")
231
- return remove_dummy_lines(lines)
232
-
233
-
234
- def add_end_of_line_marker_at_end_of_file(
235
- lines: List[Line], new_end_of_line_marker: str
236
- ) -> List[Line]:
237
- """Adds new end-of-line marker to the end of file if it is missing."""
238
- if not lines:
239
- return [Line("", new_end_of_line_marker)]
240
- lines = copy.deepcopy(lines)
241
- lines[-1] = Line(lines[-1].content, new_end_of_line_marker)
242
- return lines
243
-
244
-
245
- def normalize_empty_file(lines: List[Line], mode: str, new_end_of_line_marker: str) -> List[Line]:
246
- """Replaces file with an empty file."""
247
- if mode == "empty":
248
- return []
249
- if mode == "one-line":
250
- return [Line("", new_end_of_line_marker)]
251
- return copy.deepcopy(lines)
252
-
253
-
254
- def is_whitespace_only(lines: List[Line]) -> bool:
255
- """Determines if file consists only of whitespace."""
256
- for line in lines:
257
- if line.content.strip(" \n\r\t\v\f"):
252
+ def is_whitespace_only(text: str) -> bool:
253
+ """Determines if a string consists of only whitespace characters."""
254
+ for char in text:
255
+ if char not in WHITESPACE_CHARACTERS:
258
256
  return False
259
257
  return True
260
258
 
261
259
 
262
- def normalize_non_standard_whitespace(lines: List[Line], mode: str) -> List[Line]:
263
- """Removes non-standard whitespace characters."""
264
- if mode == "ignore":
265
- return copy.deepcopy(lines)
266
- if mode == "replace":
267
- return [
268
- Line(line.content.translate(str.maketrans("\v\f", " ", "")), line.end_of_line_marker)
269
- for line in lines
270
- ]
271
- return [
272
- Line(line.content.translate(str.maketrans("", "", "\v\f")), line.end_of_line_marker)
273
- for line in lines
274
- ]
275
-
276
-
277
- def replace_tabs_with_spaces(lines: List[Line], num_spaces: int) -> List[Line]:
278
- """Replaces tabs with spaces."""
279
- if num_spaces < 0:
280
- return copy.deepcopy(lines)
281
- return [
282
- Line(line.content.replace("\t", num_spaces * " "), line.end_of_line_marker)
283
- for line in lines
284
- ]
285
-
286
-
287
- def compute_difference(original_lines: List[Line], new_lines: List[Line]) -> List[int]:
288
- """Computes the indices of lines that differ."""
289
- line_numbers = [
290
- line_number
291
- for line_number, (original_line, new_line) in enumerate(zip(original_lines, new_lines))
292
- if not original_line == new_line
293
- ]
294
- if len(original_lines) != len(new_lines):
295
- line_numbers.append(min(len(original_lines), len(new_lines)))
296
- return line_numbers
297
-
260
+ def find_most_common_new_line_marker(text: str) -> str:
261
+ """Returns the most common new line marker in a string.
298
262
 
299
- @dataclasses.dataclass
300
- class ChangeDescription:
301
- """Description of a change of the content of a file."""
263
+ If there are ties, prefer Linux '\n' to Windows '\r\n' to Mac '\r'.
264
+ If there are no new line markers, return Linux.
302
265
 
303
- check_only: str
304
- change: str
266
+ Args:
267
+ text: A string.
305
268
 
269
+ Returns:
270
+ Either '\n', or '\r\n' or '\r'.
271
+ """
272
+ linux_count = 0
273
+ mac_count = 0
274
+ windows_count = 0
275
+ i = 0
306
276
 
307
- @dataclasses.dataclass
308
- class LineChange:
309
- """Description of a change on a particular line."""
277
+ while i < len(text):
278
+ if text[i] == CARRIAGE_RETURN:
279
+ if i < len(text) - 1 and text[i + 1] == LINE_FEED:
280
+ windows_count += 1
281
+ i += 1
282
+ else:
283
+ mac_count += 1
284
+ elif text[i] == LINE_FEED:
285
+ linux_count += 1
286
+ i += 1
310
287
 
311
- check_only: str
312
- change: str
313
- line_number: int
288
+ if mac_count > windows_count and mac_count > linux_count:
289
+ return "\r"
314
290
 
291
+ if windows_count > linux_count:
292
+ return "\r\n"
315
293
 
316
- class FileContentTracker:
317
- """Tracks changes of the content of a file as it undergoes formatting."""
294
+ return "\n"
318
295
 
319
- def __init__(self, lines: List[Line]):
320
- """Initializes an instance of the file content tracker."""
321
- self.initial_lines = lines
322
- self.lines = copy.deepcopy(lines)
323
- self.line_changes: List[LineChange] = []
324
296
 
325
- def format(self, change: ChangeDescription, function: Callable[..., List[Line]], *args):
326
- """Applies a change to the content of the file."""
327
- previous_content = self.lines
328
- self.lines = function(self.lines, *args)
329
- if previous_content != self.lines:
330
- line_numbers = compute_difference(previous_content, self.lines)
331
- for line_number in line_numbers:
332
- self.line_changes.append(LineChange(change.check_only, change.change, line_number))
297
+ def format_file_content(
298
+ file_content: str,
299
+ parsed_arguments: argparse.Namespace,
300
+ ) -> Tuple[str, List[Change]]:
301
+ """Formats content of a file.
333
302
 
334
- def is_changed(self) -> bool:
335
- """Determines if the file content has changed."""
336
- return self.lines != self.initial_lines
303
+ The formatting options are specified in the parsed_arguments.
337
304
 
305
+ Args:
306
+ file_content: Content of the file.
307
+ parsed_arguments: Parsed command line arguments.
338
308
 
339
- def format_file_content(
340
- file_content_tracker: FileContentTracker,
341
- parsed_arguments: argparse.Namespace,
342
- ):
343
- """Formats the content of file represented as a string."""
344
- new_line_marker = END_OF_LINE_MARKERS.get(
309
+ Returns:
310
+ A pair consisting of the formatted file content and a list of changes.
311
+ """
312
+ output_new_line_marker = NEW_LINE_MARKERS.get(
345
313
  parsed_arguments.new_line_marker,
346
- guess_end_of_line_marker(file_content_tracker.initial_lines),
314
+ find_most_common_new_line_marker(file_content),
347
315
  )
348
316
 
349
- if is_whitespace_only(file_content_tracker.initial_lines):
350
- changes = {
351
- "ignore": ChangeDescription("", ""),
352
- "empty": ChangeDescription(
353
- check_only="File needs to be replaced with an empty file.",
354
- change="File was replaced with an empty file.",
355
- ),
356
- "one-line": ChangeDescription(
357
- check_only=(
358
- f"File must be replaced with a single-line empty line {repr(new_line_marker)}."
359
- ),
360
- change=(
361
- f"File was replaced with a single-line empty line {repr(new_line_marker)}."
362
- ),
363
- ),
364
- }
365
- if not file_content_tracker.initial_lines:
366
- file_content_tracker.format(
367
- changes[parsed_arguments.normalize_empty_files],
368
- normalize_empty_file,
369
- parsed_arguments.normalize_empty_files,
370
- new_line_marker,
371
- )
372
- else:
373
- file_content_tracker.format(
374
- changes[parsed_arguments.normalize_whitespace_only_files],
375
- normalize_empty_file,
376
- parsed_arguments.normalize_whitespace_only_files,
377
- new_line_marker,
378
- )
379
-
380
- else:
381
- if parsed_arguments.remove_trailing_whitespace:
382
- file_content_tracker.format(
383
- ChangeDescription(
384
- check_only="Whitespace at the end of line needs to be removed.",
385
- change="Whitespace at the end of line was removed.",
386
- ),
387
- remove_trailing_whitespace,
388
- )
317
+ # Handle empty file:
318
+ if not file_content:
319
+ if parsed_arguments.normalize_empty_files in ["ignore", "empty"]:
320
+ return "", []
321
+ if parsed_arguments.normalize_empty_files == "one-line":
322
+ return output_new_line_marker, [Change(ChangeType.REPLACED_EMPTY_FILE_WITH_ONE_LINE, 1)]
323
+
324
+ # Handle non-empty file consisting of whitespace only.
325
+ if is_whitespace_only(file_content):
326
+ if parsed_arguments.normalize_whitespace_only_files == "empty":
327
+ return "", [Change(ChangeType.REPLACED_WHITESPACE_ONLY_FILE_WITH_EMPTY_FILE, 1)]
328
+ if parsed_arguments.normalize_whitespace_only_files == "one-line":
329
+ if file_content == output_new_line_marker:
330
+ return file_content, []
331
+ return output_new_line_marker, [
332
+ Change(ChangeType.REPLACED_WHITESPACE_ONLY_FILE_WITH_ONE_LINE, 1)
333
+ ]
334
+ if parsed_arguments.normalize_whitespace_only_files == "ignore":
335
+ return file_content, []
336
+
337
+ # Index into the input buffer.
338
+ i = 0
339
+
340
+ # List of changes
341
+ changes: List[Change] = []
342
+
343
+ # Line number. It is incremented every time we encounter a new end of line marker.
344
+ line_number = 1
345
+
346
+ # Position one character past the end of last line in the output buffer
347
+ # including the last end of line marker.
348
+ last_end_of_line_including_eol_marker = 0
349
+
350
+ # Position one character past the last non-whitespace character in the output buffer.
351
+ last_non_whitespace = 0
352
+
353
+ # Position one character past the end of last non-empty line in the output buffer
354
+ # excluding the last end of line marker.
355
+ last_end_of_non_empty_line_excluding_eol_marker = 0
356
+
357
+ # Position one character past the end of last non-empty line in the output buffer,
358
+ # including the last end of line marker.
359
+ last_end_of_non_empty_line_including_eol_marker = 0
360
+
361
+ # Line number of the last non-empty line.
362
+ last_non_empty_line_number = 0
363
+
364
+ # Formatted output
365
+ output = ""
366
+
367
+ while i < len(file_content):
368
+ if file_content[i] in [CARRIAGE_RETURN, LINE_FEED]:
369
+ # Parse the new line marker
370
+ new_line_marker = ""
371
+ if file_content[i] == LINE_FEED:
372
+ new_line_marker = LINE_FEED
373
+ elif i < len(file_content) - 1 and file_content[i + 1] == LINE_FEED:
374
+ new_line_marker = "\r\n"
375
+ # Windows new line marker consists of two characters.
376
+ # Skip the extra character.
377
+ i += 1
378
+ else:
379
+ new_line_marker = CARRIAGE_RETURN
380
+
381
+ # Remove trailing whitespace
382
+ if parsed_arguments.remove_trailing_whitespace and max(
383
+ last_non_whitespace, last_end_of_line_including_eol_marker
384
+ ) < len(output):
385
+ changes.append(
386
+ Change(
387
+ ChangeType.REMOVED_TRAILING_WHITESPACE,
388
+ line_number,
389
+ )
390
+ )
391
+ output = output[
392
+ : max(
393
+ last_non_whitespace,
394
+ last_end_of_line_including_eol_marker,
395
+ )
396
+ ]
397
+
398
+ # Determine if the last line is empty
399
+ is_empty_line: bool = last_end_of_line_including_eol_marker == len(output)
400
+
401
+ # Position one character past the end of last line in the output buffer
402
+ # excluding the last end of line marker.
403
+ last_end_of_line_excluding_eol_marker = len(output)
404
+
405
+ # Add new line marker
406
+ if (
407
+ parsed_arguments.normalize_new_line_markers
408
+ and output_new_line_marker != new_line_marker
409
+ ):
410
+ changes.append(
411
+ Change(
412
+ ChangeType.REPLACED_NEW_LINE_MARKER,
413
+ line_number,
414
+ new_line_marker,
415
+ output_new_line_marker,
416
+ )
417
+ )
418
+ output += output_new_line_marker
419
+ else:
420
+ output += new_line_marker
389
421
 
390
- if parsed_arguments.remove_trailing_empty_lines:
391
- file_content_tracker.format(
392
- ChangeDescription(
393
- check_only="Empty line(s) at the end of file need to be removed.",
394
- change="Empty line(s) at the end of file were removed.",
395
- ),
396
- remove_trailing_empty_lines,
397
- )
422
+ last_end_of_line_including_eol_marker = len(output)
398
423
 
399
- file_content_tracker.format(
400
- ChangeDescription(
401
- check_only="Tabs need to be replaced with spaces.",
402
- change="Tabs were replaced by spaces.",
403
- ),
404
- replace_tabs_with_spaces,
405
- parsed_arguments.replace_tabs_with_spaces,
406
- )
424
+ # Update position of last non-empty line.
425
+ if not is_empty_line:
426
+ last_end_of_non_empty_line_excluding_eol_marker = (
427
+ last_end_of_line_excluding_eol_marker
428
+ )
429
+ last_end_of_non_empty_line_including_eol_marker = (
430
+ last_end_of_line_including_eol_marker
431
+ )
432
+ last_non_empty_line_number = line_number
433
+
434
+ line_number += 1
435
+
436
+ elif file_content[i] == SPACE:
437
+ output += file_content[i]
438
+
439
+ elif file_content[i] == TAB:
440
+ if parsed_arguments.replace_tabs_with_spaces < 0:
441
+ output += file_content[i]
442
+ elif parsed_arguments.replace_tabs_with_spaces > 0:
443
+ changes.append(Change(ChangeType.REPLACED_TAB_WITH_SPACES, line_number))
444
+ output += SPACE * parsed_arguments.replace_tabs_with_spaces
445
+ else:
446
+ # Remove the tab character.
447
+ changes.append(Change(ChangeType.REMOVED_TAB, line_number))
448
+
449
+ elif file_content[i] in [VERTICAL_TAB, FORM_FEED]:
450
+ if parsed_arguments.normalize_non_standard_whitespace == "ignore":
451
+ output += file_content[i]
452
+ elif parsed_arguments.normalize_non_standard_whitespace == "replace":
453
+ output += SPACE
454
+ changes.append(
455
+ Change(
456
+ ChangeType.REPLACED_NONSTANDARD_WHITESPACE,
457
+ line_number,
458
+ file_content[i],
459
+ SPACE,
460
+ )
461
+ )
462
+ elif parsed_arguments.normalize_non_standard_whitespace == "remove":
463
+ changes.append(
464
+ Change(
465
+ ChangeType.REMOVED_NONSTANDARD_WHITESPACE, line_number, file_content[i], ""
466
+ )
467
+ )
468
+ else:
469
+ raise ValueError("Unknown value of normalize_non_standard_whitespace")
470
+ else:
471
+ output += file_content[i]
472
+ last_non_whitespace = len(output)
407
473
 
408
- file_content_tracker.format(
409
- ChangeDescription(
410
- check_only=(
411
- "Non-standard whitespace characters need to be removed or replaced by spaces."
412
- ),
413
- change="Non-standard whitespace characters were removed or replaced by spaces.",
414
- ),
415
- normalize_non_standard_whitespace,
416
- parsed_arguments.normalize_non_standard_whitespace,
417
- )
474
+ # Move to the next character
475
+ i += 1
418
476
 
419
- if parsed_arguments.normalize_new_line_markers:
420
- file_content_tracker.format(
421
- ChangeDescription(
422
- check_only=(
423
- f"New line marker(s) need to be replaced with {repr(new_line_marker)}."
424
- ),
425
- change=f"New line marker(s) were replaced with {repr(new_line_marker)}.",
426
- ),
427
- normalize_end_of_line_markers,
428
- new_line_marker,
429
- )
477
+ # Remove trailing whitespace from the last line.
478
+ if (
479
+ parsed_arguments.remove_trailing_whitespace
480
+ and last_end_of_line_including_eol_marker < len(output)
481
+ and last_non_whitespace < len(output)
482
+ ):
483
+ changes.append(Change(ChangeType.REMOVED_TRAILING_WHITESPACE, line_number))
484
+ output = output[:last_non_whitespace]
485
+
486
+ # Remove trailing empty lines.
487
+ if (
488
+ parsed_arguments.remove_trailing_empty_lines
489
+ and last_end_of_line_including_eol_marker == len(output)
490
+ and last_end_of_non_empty_line_including_eol_marker < len(output)
491
+ ):
492
+ line_number = last_non_empty_line_number + 1
493
+ last_end_of_line_including_eol_marker = last_end_of_non_empty_line_including_eol_marker
494
+ changes.append(Change(ChangeType.REMOVED_EMPTY_LINES, line_number))
495
+ output = output[:last_end_of_non_empty_line_including_eol_marker]
496
+
497
+ # Add new line marker at the end of the file
498
+ if (
499
+ parsed_arguments.add_new_line_marker_at_end_of_file
500
+ and last_end_of_line_including_eol_marker < len(output)
501
+ ):
502
+ changes.append(Change(ChangeType.ADDED_NEW_LINE_MARKER_TO_END_OF_FILE, line_number))
503
+ output += output_new_line_marker
504
+ last_end_of_line_including_eol_marker = len(output)
505
+ line_number += 1
506
+
507
+ # Remove new line marker(s) from the end of the file
508
+ if (
509
+ parsed_arguments.remove_new_line_marker_from_end_of_file
510
+ and last_end_of_line_including_eol_marker == len(output)
511
+ and line_number >= 2
512
+ ):
513
+ line_number = last_non_empty_line_number
514
+ changes.append(Change(ChangeType.REMOVED_NEW_LINE_MARKER_FROM_END_OF_FILE, line_number))
515
+ output = output[:last_end_of_non_empty_line_excluding_eol_marker]
430
516
 
431
- if parsed_arguments.add_new_line_marker_at_end_of_file:
432
- file_content_tracker.format(
433
- ChangeDescription(
434
- check_only=f"New line marker needs to be added to the end of the file, "
435
- f"or replaced with {repr(new_line_marker)}.",
436
- change=f"New line marker was added to the end of the file, "
437
- f"or replaced with {repr(new_line_marker)}.",
438
- ),
439
- add_end_of_line_marker_at_end_of_file,
440
- new_line_marker,
441
- )
442
- elif parsed_arguments.remove_new_line_marker_from_end_of_file:
443
- file_content_tracker.format(
444
- ChangeDescription(
445
- check_only="New line marker(s) need to removed from the end of the file.",
446
- change="New line marker(s) were removed from the end of the file.",
447
- ),
448
- remove_all_end_of_line_markers_from_end_of_file,
449
- )
517
+ return output, changes
450
518
 
451
519
 
452
520
  def reformat_file(file_name: str, parsed_arguments: argparse.Namespace) -> bool:
453
- """Reformats a file."""
521
+ """Reformats a file.
522
+
523
+ Args:
524
+ file_name: Name of the file to reformat.
525
+ parsed_arguments: Parsed command line arguments.
526
+
527
+ Returns:
528
+ True if the file was changed, False otherwise.
529
+ """
454
530
  file_content = read_file_content(file_name, parsed_arguments.encoding)
455
- lines = split_lines(file_content)
456
- file_content_tracker = FileContentTracker(lines)
457
- format_file_content(file_content_tracker, parsed_arguments)
458
- is_changed = file_content_tracker.is_changed()
531
+ formatted_file_content, file_changes = format_file_content(file_content, parsed_arguments)
459
532
  if parsed_arguments.verbose:
460
- color_print(f"Processing file '{file_name}'...", parsed_arguments)
533
+ color_print(f"[WHITE]Processing file [BOLD]{file_name}[RESET_ALL]...", parsed_arguments)
461
534
  if parsed_arguments.check_only:
462
- if is_changed:
535
+ if file_changes:
463
536
  color_print(
464
537
  f"[RED]✘[RESET_ALL] [BOLD][WHITE]{file_name} "
465
538
  f"[RED]needs to be formatted[RESET_ALL]",
466
539
  parsed_arguments,
467
540
  )
468
- for line_change in file_content_tracker.line_changes:
469
- color_print(
470
- f" [BOLD][BLUE]↳ line {line_change.line_number + 1}: "
471
- f"[WHITE]{line_change.check_only}[RESET_ALL]",
472
- parsed_arguments,
473
- )
541
+ for line_change in file_changes:
542
+ print(" ", end="")
543
+ line_change.color_print(parsed_arguments)
474
544
  else:
475
545
  if parsed_arguments.verbose:
476
546
  color_print(
@@ -479,21 +549,21 @@ def reformat_file(file_name: str, parsed_arguments: argparse.Namespace) -> bool:
479
549
  parsed_arguments,
480
550
  )
481
551
  else:
482
- if is_changed:
552
+ if file_changes:
483
553
  color_print(f"[WHITE]Reformatted [BOLD]{file_name}[RESET_ALL]", parsed_arguments)
484
- for line_change in file_content_tracker.line_changes:
485
- color_print(
486
- f" [BOLD][BLUE]↳ line {line_change.line_number + 1}: "
487
- f"[WHITE]{line_change.change}[RESET_ALL]",
488
- parsed_arguments,
489
- )
554
+ for line_change in file_changes:
555
+ print(" ", end="")
556
+ line_change.color_print(parsed_arguments)
490
557
  write_file(
491
- file_name, concatenate_lines(file_content_tracker.lines), parsed_arguments.encoding
558
+ file_name,
559
+ formatted_file_content,
560
+ parsed_arguments.encoding,
492
561
  )
493
562
  else:
494
563
  if parsed_arguments.verbose:
495
- color_print(f"[WHITE]{file_name} [BLUE]left unchanged[RESET_ALL]", parsed_arguments)
496
- return is_changed
564
+ color_print(f"[WHITE]Unchanged [BOLD]{file_name}[RESET_ALL]", parsed_arguments)
565
+
566
+ return bool(file_changes)
497
567
 
498
568
 
499
569
  def reformat_files(file_names: List[str], parsed_arguments: argparse.Namespace):
@@ -586,20 +656,24 @@ def parse_command_line() -> argparse.Namespace:
586
656
  default="utf-8",
587
657
  type=str,
588
658
  )
589
- parser.add_argument(
659
+
660
+ # Mutually exclusive group of parameters.
661
+ group1 = parser.add_mutually_exclusive_group()
662
+ group1.add_argument(
590
663
  "--verbose",
591
664
  help="Print more messages than normally.",
592
665
  required=False,
593
666
  action="store_true",
594
667
  default=False,
595
668
  )
596
- parser.add_argument(
669
+ group1.add_argument(
597
670
  "--quiet",
598
671
  help="Do not print any messages, except for errors when reading or writing files.",
599
672
  required=False,
600
673
  action="store_true",
601
674
  default=False,
602
675
  )
676
+
603
677
  parser.add_argument(
604
678
  "--color",
605
679
  help="Print messages in color.",
@@ -637,6 +711,10 @@ def parse_command_line() -> argparse.Namespace:
637
711
  "mac: Use Mac new line marker '\\r'. "
638
712
  "windows: Use Windows new line marker '\\r\\n'. "
639
713
  ),
714
+ required=False,
715
+ type=str,
716
+ choices=["auto", "linux", "mac", "windows"],
717
+ default="auto",
640
718
  )
641
719
  parser.add_argument(
642
720
  "--normalize-new-line-markers",
@@ -676,20 +754,27 @@ def parse_command_line() -> argparse.Namespace:
676
754
  default="ignore",
677
755
  choices=["ignore", "empty", "one-line"],
678
756
  )
679
- parser.add_argument(
757
+
758
+ # Mutually exclusive group of parameters.
759
+ group2 = parser.add_mutually_exclusive_group()
760
+ group2.add_argument(
680
761
  "--add-new-line-marker-at-end-of-file",
681
762
  help="Add missing new line marker at end of each file.",
682
763
  required=False,
683
764
  default=False,
684
765
  action="store_true",
685
766
  )
686
- parser.add_argument(
767
+ group2.add_argument(
687
768
  "--remove-new-line-marker-from-end-of-file",
688
- help="Remove new line markers from the end of each file.",
769
+ help="Remove new line markers from the end of each file. "
770
+ "This option conflicts with --add-new-line-marker-at-end-of-file. "
771
+ "This option implies --remove-trailing-empty-lines option, i.e., "
772
+ "all empty lines at the end of the file are removed.",
689
773
  required=False,
690
774
  default=False,
691
775
  action="store_true",
692
776
  )
777
+
693
778
  parser.add_argument(
694
779
  "--remove-trailing-whitespace",
695
780
  help="Remove whitespace at the end of each line.",
@@ -699,7 +784,8 @@ def parse_command_line() -> argparse.Namespace:
699
784
  )
700
785
  parser.add_argument(
701
786
  "--remove-trailing-empty-lines",
702
- help="Remove empty lines at the end of each file.",
787
+ help="Remove empty lines at the end of each file. "
788
+ "If --remove-new-line-marker-from-end-of-file is used, this option is used automatically.",
703
789
  required=False,
704
790
  default=False,
705
791
  action="store_true",
@@ -736,8 +822,8 @@ def parse_command_line() -> argparse.Namespace:
736
822
  if parsed_arguments.normalize_whitespace_only_files == "empty":
737
823
  parsed_arguments.normalize_empty_files = parsed_arguments.normalize_whitespace_only_files
738
824
 
739
- if parsed_arguments.verbose:
740
- parsed_arguments.quiet = False
825
+ if parsed_arguments.remove_new_line_marker_from_end_of_file:
826
+ parsed_arguments.remove_empty_lines = True
741
827
 
742
828
  return parsed_arguments
743
829
 
@@ -1,6 +0,0 @@
1
- whitespace_format.py,sha256=uqu0SZ76nyFGEDX6gMIyLZpmVv_rtJRx0IjcEC601Cs,26016
2
- whitespace_format-0.0.5.dist-info/LICENSE,sha256=rT6UNfWDYFQc-eo65FioDJRMAyVOndtF95wNCUhkK74,1076
3
- whitespace_format-0.0.5.dist-info/METADATA,sha256=Pq-GTLKbXw9u6uj7CGwItBpi15yDrOdjSyTXVzdnuVU,10233
4
- whitespace_format-0.0.5.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
5
- whitespace_format-0.0.5.dist-info/entry_points.txt,sha256=LbXoevzUZAF5MVbI2foNC9xeDjKS_Woz7VbA1ZNF5CY,60
6
- whitespace_format-0.0.5.dist-info/RECORD,,