🐕

JavaScript で文字数を length で数えるのはやめようの実例

2021/08/04に公開2

はじめに

JavaScript において文字数を String の length で取得すると、期待した値が得られないことがある。この記事では、実際に String の length を使うことによって発生した Prettier のバグを紹介する。

前提

JavaScript の String には length というプロパティが存在する。このlengthプロパティは文字列の文字数を表すものではない。

実際には、文字列中に含まれるUTF-16のコードユニットの数を返す。つまり、ASCIIをはじめとしたBMPに含まれるものであれば我々の期待する文字数が返ってくるが、一部の漢字やemojiなどについてはそうはならない。

たとえば、漢字の𠮟(U+20B9F)はサロゲートペアであり、2つのコードユニットで表される。そのため、length は 2 になる。

console.log("𠮟".length); // 2

この挙動はよく知られており、この記事ではこれより詳しい解説は行わない。より詳細が気になる人は以下にあげる記事や書籍を読むことをおすすめする。

実際のバグ

https://github.com/prettier/prettier/issues/11298

次のような、2つのメソッドチェーンを含むこのコードを Prettier を使ってフォーマットする。

foo1(/𠮟𠮟𠮟/).foo2(bar).foo3(baz);

foo1(/叱叱叱/).foo2(bar).foo3(baz);

そうすると、Prettier 2.3.2 では次のようになる。

foo1(/𠮟𠮟𠮟/)
  .foo2(bar)
  .foo3(baz);

foo1(/叱叱叱/).foo2(bar).foo3(baz);

一見同じに見える2つのメソッドチェーンが異なるフォーマットをされるのは奇妙であり、バグである。

原因

これは「メソッドチェーンを改行するかどうか」を判定する処理に、String の length を使っていたため発生した。

Prettier では、次のような場合にメソッドチェーンを改行するようになっていた。

(
  メソッドチェーンのチェーンの数が2以上である &&
  (
    いずれかの引数が正規表現リテラルでありかつその中身が5文字以上である ||
    そのほかの様々な条件...
  )
)

省略したとおり、ほかにも様々な条件が存在する。しかし、「メソッドチェーンの数が2つ以上」、「いずれかの引数が正規表現リテラルでありかつその中身が5文字以上である」の2つを両方を満たせばその時点でメソッドチェーン全体を改行することが決定する。

(この一連の条件は筆者が設計したものではないが、現実世界の様々なコードを参考にして設計されたものであり、多くのケースで良いフォーマットをしてくれるものだと思っている。)

ここで問題なのは「いずれかの引数が正規表現リテラルでありかつその中身が5文字以上である」という条件である。

Prettier ではここで文字数を取得するために String の length を使っていたのだ。

そのため、𠮟(U+20B9F)を使った 𠮟𠮟𠮟 は length が 6 だと解釈され、メソッドチェーン全体が改行された。一方で、叱(U+53F1)を使った 叱叱叱 は length が 3 だと解釈され、メソッドチェーンが改行されることはなかった。

実際のコードは https://github.com/prettier/prettier/blob/2.3.2/src/language-js/utils.js#L845-L858 に書かれている。興味のある人はぜひ読んでみてほしい。

何が正しいのか

本来 Prettier に期待される動作は、メソッドチェーンを改行する方である。つまり、この場合では𠮟(U+20B9F) を使ったものが望ましいフォーマット結果である。

なぜなら、少なくとも今の所 Prettier では、エディタ上の表示幅を考慮して日本語のひらがなやカタカナや漢字などは ASCII 2 つ分の幅をとるものと考えているからである。つまり、Prettier 的には あああ𠮟𠮟𠮟 も幅 6 として扱いたい。

実際には Prettier では様々な箇所でそのような「文字の幅」を取得する必要があるので、そのための関数 getStringWidth(text: string): number が内部に用意されている。getStringWidth では上述したような幅を取得できる。(getStringWidthhttps://github.com/sindresorhus/string-width をラップしたものである。)

今回解説したバグも、単純に String length ではなく getStringWidth を使っていれば防げたものである。

(本当はこれでもなにかしらバグが起こるかもしれない。もし Prettier の変な挙動を見つけたい人はそのあたりでいろんな文字を使ってみるとバグを見つけられるだろう。もし新たなバグを見つけた場合、Issues で報告をしてくれると嬉しい。)

修正

すでにこのバグは修正されており、次のバージョンでリリースされる予定だ。

おわりに

大したバグではないが、おもしろいので記事を書いた。

また、記事の本質ではないが、Prettier ではメソッドチェーンの改行の判定のように、ヒューリスティクス的な考え方で決定された処理が多く存在する。そのような処理について、提案やバグがあれば遠慮なく Issue や Pull Request を送ってくれると嬉しい。

Discussion

KageShironKageShiron

string-widthはサロゲートペア以外にもANSI Escape Sequenceや絵文字を色々処理してるんですね。
ざっと見た感じ、以下あたりは考慮が弱そうな気配を感じました。
・U+200Dのようなゼロ幅文字
・が(U+304B U+3099)のような結合文字
・꧅(U+A9C5)のようにフォントや環境によって幅が違う文字
・East Asian Ambiguous Widthの文字

完全に横幅を求める方法は無いのでbetterな方法を探るにしても、Unicodeはなかなか大変ですね…

standard softwarestandard software
var value = 'a';
console.log(value, value.length);

var value = 'が';
console.log(value, value.length);

var value = '\u304b\u3099';
console.log(value, value.length);

var value = '☺';
console.log(value, value.length);

var value = '😄';
console.log(value, value.length);

var value = '🌕';
console.log(value, value.length);

var value = '👨‍👩‍👧‍👦';
console.log(value, value.length);

var value = '魚';
console.log(value, value.length);

var value = '𩸽';
console.log(value, value.length);

// "a", 1
// "が", 1
// "が", 2
// "☺", 1
// "😄", 2
// "🌕", 2
// "👨‍👩‍👧‍👦", 11
// "魚", 1
// "𩸽", 2

ややこしいですね。