ソースパーサでコメントを削除した文字列の行番号を数えない
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を元ソースへ戻す
より安全な流れです。
- cleaned textでcomment内matchを避ける
- matchからfunction nameを取る
- original sourceで実位置を見つける
- 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を使う