← ./articles-ja

ソースパーサでコメントを削除した文字列の行番号を数えない

regexベースのソースパーサでは、functionを探す前にコメントを削除することがあります。

コメント内の誤検出を減らせますが、別のバグを作ります。cleaned textのoffsetは、original sourceのoffsetではありません。コメント削除後の文字列で行番号を数えると、複数行コメントの下にあるfunctionが別の行を指します。

matchにはcleaned textを使い、位置情報にはoriginal sourceを使います。

症状: source viewerが違う行へ飛ぶ

よくある症状です。

  • function nameは正しく抽出される
  • clickすると違う行へ飛ぶ
  • 複数行コメントの下だけズレる
  • 小さなfixtureでは通る
  • 実際のembedded C fileで失敗する

parserはfunctionを見つけています。壊れているのはlocation mappingです。

破壊的なコメント削除はoffsetを変える

このsourceを考えます。

/* Header
 * spanning
 * several lines
 */
void init(void) {
}

コメントブロック全体を1つのspaceに置き換えると、cleaned textではnewline数が減ります。cleaned textで得たmatch offsetは、original sourceの同じ行を指しません。

これは間違いです。

let cleaned = remove_comments(source);
let offset = regex.find(&cleaned).unwrap().start();
let line = byte_offset_to_line(&cleaned, offset);

UIが必要としているのは、original file上の行番号です。

方法1: コメント削除時にnewlineを残す

最低限、コメント内のnewlineは残します。

fn strip_comments_preserve_newlines(src: &str) -> String {
    let mut out = String::new();
    let mut chars = src.chars().peekable();

    while let Some(ch) = chars.next() {
        if ch == '/' && chars.peek() == Some(&'*') {
            chars.next();

            while let Some(c) = chars.next() {
                if c == '\n' {
                    out.push('\n');
                } else if c == '*' && chars.peek() == Some(&'/') {
                    chars.next();
                    break;
                }
            }
        } else {
            out.push(ch);
        }
    }

    out
}

これで行数は保ちやすくなります。ただし、すべてのoffset問題が消えるわけではありません。

方法2: matchを元ソースへ戻す

より安全な流れです。

  1. cleaned textでcomment内matchを避ける
  2. matchからfunction nameを取る
  3. original sourceで実位置を見つける
  4. original source上で行番号を数える
fn byte_offset_to_line(src: &str, offset: usize) -> u32 {
    src[..offset.min(src.len())]
        .bytes()
        .filter(|b| *b == b'\n')
        .count() as u32 + 1
}

fn line_for_function(original: &str, function_name: &str) -> Option<u32> {
    let offset = original.find(function_name)?;
    Some(byte_offset_to_line(original, offset))
}

production parserでは、単純な find だけでは不十分な場合があります。同じ名前がコメントや宣言に出るなら、周辺token、file context、小さなscannerでdefinition位置を特定します。

複数行コメントのfixtureを入れる

regression testには、実際に壊れる形を入れます。

#[test]
fn function_line_after_multiline_comment_is_original_line() {
    let source = "/* a\n * b\n * c\n */\nvoid init(void) {\n}\n";
    let cleaned = strip_comments_preserve_newlines(source);

    assert!(cleaned.contains("void init"));

    let line = line_for_function(source, "init").unwrap();
    assert_eq!(line, 5);
}

1行コメントだけのtestでは、このズレを見つけられません。

文字列とencodingにも注意する

コメント削除には他にも罠があります。

  • string literal内の /* not a comment */
  • CRLFとLF
  • Shift-JISなどのlegacy encoding
  • 別ファイルの同名function
  • definitionより前のdeclaration

これらが重要なら、lexerやtree-sitterを使うほうが長期的には安全です。それでも、行番号はoriginal sourceから計算する、という原則は同じです。

検証チェック

  • 複数行コメントの下でも行番号がズレない
  • 1行目のfunctionが0ではなく1になる
  • CRLF fileでも通る
  • source viewerが選択したfunctionをhighlightする
  • 小さすぎるfixtureだけでなく現実的なsource snippetを使う

参考