Ruff, Flake8, Pylintでの"Line too long"エラーに関する結果の違い

# 環境

```
$ ruff --version
ruff 0.0.264

$ flake8 --version
6.0.0 (mccabe: 0.7.0, pycodestyle: 2.10.0, pyflakes: 3.0.1) CPython 3.11.2 on Linux

$ pylint --version
pylint 2.17.3
astroid 2.15.4
Python 3.11.2 (main, Mar  2 2023, 15:11:26) [GCC 11.3.0]
```

# はじめに
PythonのリンターをFlake8からRuffに移行した際、1行の長さが長すぎないかのチェック（"Line too long"）の結果が微妙に異なっていました。
どのようなときに結果が異なるかを、Flake8とRuffに加えPylintで確認しました。

# 設定ファイル
行の最大長さを11文字としました。

```toml:pyproject.toml
[tool.ruff]
select = ["E501"]
line-length = 11

[tool.pylint]
max-line-length = 11
disable = "all"
enable = "line-too-long"
```

```plain:.flake8
[flake8]
select = E501
max-line-length = 11
```

```bash:lint.sh
#!/bin/bash -x
LINT_FILE=$1
ruff check ${LINT_FILE}
flake8 ${LINT_FILE}
pylint ${LINT_FILE}
```

# "Line too long"の結果

## 文字列の場合

```python:sample.py
"1234567890"
"12345678 0"
"12345678　0"
```

`sample.py`のすべての行が12文字です。2行目は半角スペース、3行目は全角スペースを含んでいます。


```
$ ./lint.sh sample.py

+ ruff check sample.py
sample.py:2:12: E501 Line too long (12 > 11 characters)
sample.py:3:11: E501 Line too long (13 > 11 characters)
Found 2 errors.

+ flake8 sample.py
sample.py:1:12: E501 line too long (12 > 11 characters)
sample.py:2:12: E501 line too long (12 > 11 characters)
sample.py:3:12: E501 line too long (12 > 11 characters)

+ pylint sample.py
************* Module sample
sample.py:1:0: C0301: Line too long (12/11) (line-too-long)
sample.py:2:0: C0301: Line too long (12/11) (line-too-long)
sample.py:3:0: C0301: Line too long (12/11) (line-too-long)

------------------------------------------------------------------
Your code has been rated at 0.00/10 (previous run: 0.00/10, +0.00)

```

Ruffでは、11文字を超えていてもスペースを含まなければ、"Line too long"のエラーになりませんでした。

以下は、Ruffの判定処理です。行をこれ以上短くできない場合は、エラーにならないようです。

```rust:ruff/src/rules/pycodestyle/helpers.rs
    let mut chunks = line.split_whitespace();
    let (Some(first_chunk), Some(second_chunk)) = (chunks.next(), chunks.next()) else {
        // Single word / no printable chars - no way to make the line shorter
        return None;
    };
```

https://github.com/charliermarsh/ruff/blob/cab65b25da8aff6fb9cb539b55345aae64a835f0/crates/ruff/src/rules/pycodestyle/helpers.rs#L44


## コメント行の場合

```python:sample1.py
# "34567890"
# "345678 0"
# "345678　0"
```

`sample1.py`のすべての行が12文字です。2行目は半角スペース、3行目は全角スペースを含んでいます。

```
$ ./lint.sh sample1.py
+ ruff check sample1.py
sample1.py:1:12: E501 Line too long (12 > 11 characters)
sample1.py:2:12: E501 Line too long (12 > 11 characters)
sample1.py:3:11: E501 Line too long (13 > 11 characters)
Found 3 errors.

+ flake8 sample1.py
sample1.py:2:12: E501 line too long (12 > 11 characters)
sample1.py:3:12: E501 line too long (12 > 11 characters)

+ pylint sample1.py
************* Module sample1
sample1.py:1:0: C0301: Line too long (12/11) (line-too-long)
sample1.py:2:0: C0301: Line too long (12/11) (line-too-long)
sample1.py:3:0: C0301: Line too long (12/11) (line-too-long)```
```

1行目は`#`の後にスペースがあるため、Ruffではエラーになりました。
しかし、Flake8ではエラーになりませんでした。

以下は、Flake8（実際の処理はpycodestyle）の判定処理です。
docstringやコメント行にURLのような長い文字がが存在するケースでは、エラーにならないようです。

```python:pycodestyle.py
        # Special case for long URLs in multi-line docstrings or
        # comments, but still report the error when the 72 first chars
        # are whitespaces.
        chunks = line.split()
        if ((len(chunks) == 1 and multiline) or
            (len(chunks) == 2 and chunks[0] == '#')) and \
                len(line) - len(chunks[-1]) < max_line_length - 7:
            return
```

https://github.com/PyCQA/pycodestyle/blob/1063db8747e7d4e213160458aa3792e5ec05bc10/pycodestyle.py#L278-L285



# `#`の後ろに半角スペースがないコメント行の場合
あまり見かけないスタイルではありますが、`#`の直後に半角スペースが存在しないコメント行でも確認しました。

```python:sample.py
#"234567890"
#"2345678 0"
#"2345678　0"
```

```
$ ./lint.sh sample2.py
+ ruff check sample2.py
sample2.py:2:12: E501 Line too long (12 > 11 characters)
sample2.py:3:11: E501 Line too long (13 > 11 characters)
Found 2 errors.

+ flake8 sample2.py
sample2.py:1:12: E501 line too long (12 > 11 characters)
sample2.py:2:12: E501 line too long (12 > 11 characters)
sample2.py:3:12: E501 line too long (12 > 11 characters)

+ pylint sample2.py
************* Module sample2
sample2.py:1:0: C0301: Line too long (12/11) (line-too-long)
sample2.py:2:0: C0301: Line too long (12/11) (line-too-long)
sample2.py:3:0: C0301: Line too long (12/11) (line-too-long)
```

Ruffでは、1行目はスペースが含まれていないので1単語とみなされて、エラーになりませんでした。
Flake8では、1行目はエラーになりました。Flake8のソースコードを見ると、`chunks = line.split()`でスペースで分割した後、最初のchunkが`#`かどうかを判定しています。1行目はスペースが含まれていないため、`chunk[0]`は`#"234567890"`となり、`chunks[0] == '#'`がTrueになるため、エラーになったようです。


# docstringの場合
2行目、6行目、10行目は12文字です。

```python:sample3.py
"""
1234567890ab
"""

"""
12345678 0aa
"""

"""
12345678　0ab
"""
```

```
$ ./lint.sh sample3.py

+ ruff check sample3.py
sample3.py:6:12: E501 Line too long (12 > 11 characters)
sample3.py:10:11: E501 Line too long (13 > 11 characters)
Found 2 errors.

+ flake8 sample3.py
sample3.py:6:12: E501 line too long (12 > 11 characters)
sample3.py:10:12: E501 line too long (12 > 11 characters)

+ pylint sample3.py
************* Module sample3
sample3.py:2:0: C0301: Line too long (12/11) (line-too-long)
sample3.py:6:0: C0301: Line too long (12/11) (line-too-long)
sample3.py:10:0: C0301: Line too long (12/11) (line-too-long)

------------------------------------------------------------------
Your code has been rated at 0.00/10 (previous run: 0.00/10, +0.00)
```

Flake8では、2行目はエラーになりません。Flake8のソースコードを見ると、`len(chunks) == 1 and multiline`とい判定を行っています。この判定がTrueになるため、エラーにならないようです。

# URLが存在する場合
1行目、2行目は11文字を超えています。

```python:sample2.py
"https://qiita.com"
# "https://qiita.com"
```



```
$ ./lint.sh sample4.py

+ ruff check sample4.py

+ flake8 sample4.py
sample4.py:1:12: E501 line too long (19 > 11 characters)

+ pylint sample4.py
************* Module sample4
sample4.py:1:0: C0301: Line too long (19/11) (line-too-long)
sample4.py:2:0: C0301: Line too long (21/11) (line-too-long)
```

2行目は`#`の後ろにスペースがありますが、Ruffではエラーになりません。Ruffは、URLで終わる行はエラーにならないようにしています。

```rust:ruff/src/rules/pycodestyle/helpers.rs
    // Do not enforce the line length for lines that end with a URL, as long as the URL
    // begins before the limit.
    let last_chunk = chunks.last().unwrap_or(second_chunk);
    if last_chunk.contains("://") {
        if width - last_chunk.width() <= limit {
            return None;
        }
    }
```

https://github.com/charliermarsh/ruff/blob/cab65b25da8aff6fb9cb539b55345aae64a835f0/crates/ruff/src/rules/pycodestyle/helpers.rs#L59-L66



# マルチバイト文字を含む場合
以下のPythonファイルでは、1行目も2行目も12文字です。2行目にはマルチバイト文字が1文字含まれています。

```python:sample5.py
"12345678 0"
"12345678 あ"
```


```
$ ./lint.sh sample5.py

+ ruff check sample5.py
sample5.py:1:12: E501 Line too long (12 > 11 characters)
sample5.py:2:12: E501 Line too long (13 > 11 characters)
Found 2 errors.

+ flake8 sample5.py
sample5.py:1:12: E501 line too long (12 > 11 characters)
sample5.py:2:12: E501 line too long (12 > 11 characters)

+ pylint sample5.py
************* Module sample5
sample5.py:1:0: C0301: Line too long (12/11) (line-too-long)
sample5.py:2:0: C0301: Line too long (12/11) (line-too-long)

------------------------------------------------------------------
Your code has been rated at 0.00/10 (previous run: 0.00/10, +0.00)
```

Ruffでは、2行目は13文字だとみなされました。Ruffは、マルチバイト文字を1文字として扱わないようです。



```rust:ruff/src/rules/pycodestyle/helpers.rs
    for c in line.chars() {
        if width < limit {
            start_offset += c.text_len();
        }

        width += c.width().unwrap_or(0);
    }

```


https://github.com/charliermarsh/ruff/blob/cab65b25da8aff6fb9cb539b55345aae64a835f0/crates/ruff/src/rules/pycodestyle/helpers.rs#L32-L38

Ruffのv0.0.260で、"Line too long"の計算が"character count"から"Unicode width"に変わりました。pycodestyleではなくblackに合わせたようです。

>This PR changes the logic testing if a line exceeds the configured line-width to use the unicode-width rather than character count.
>Changing this breaks compatibility with pycodestyle because they decided to stick with ASCII width (because PEP8 recommends ASCII source code). I think its worth diverging from pycodestyle because our formatter uses unicode width and black is adopting too

https://github.com/charliermarsh/ruff/pull/3714


# まとめ
Ruff, Flake8, Pylintで、"Line too long"のエラーの挙動を確認しました。

* Ruff
    * 行が長くても、1単語ならばエラーにならない
    * 行が長くても、行の最後がURLならばエラーにならない
    * マルチバイト文字は1文字として扱わない
* Flake8
    * docstringかどうか、コメント行かどうかで結果が変わる
* Pylint
    * 単純に行の長さだけで判定している