Featured image of post 第2回-正規表現で見出しを抽出しよう - PerlとMooで学ぶComposite

第2回-正規表現で見出しを抽出しよう - PerlとMooで学ぶComposite

PerlとMooでMarkdown目次生成器を作る第2回。正規表現で見出しを抽出し、レベルとテキストをハッシュ配列で保存。Compositeパターン学習の基礎を固めます。

前回の振り返り

前回は、MarkdownReaderクラスを作成し、Markdownファイルの全行を配列として読み込む処理を実装しました。

今回は、読み込んだ行から見出し(######など)を抽出していきます。

今回のゴール

Markdownの見出し行を正規表現で検出し、見出しレベル(H1=1、H2=2…)とテキストを抽出するHeadingExtractorクラスを作成します。

Markdownの見出し構文

Markdownでは、#の数で見出しレベルを表現します。

記法HTMLタグレベル
# タイトル<h1>1
## 章<h2>2
### セクション<h3>3
#### 小見出し<h4>4
##### 小項目<h5>5
###### 最小見出し<h6>6

HeadingExtractorクラスの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package HeadingExtractor;
use v5.36;
use Moo;

has 'lines' => (
    is       => 'ro',
    required => 1,
);

has 'headings' => (
    is      => 'lazy',
    builder => '_build_headings',
);

sub _build_headings ($self) {
    my @headings;
    
    for my $line ($self->lines->@*) {
        if ($line =~ /^(#{1,6})\s+(.+)$/) {
            my $level = length($1);
            my $text  = $2;
            push @headings, {
                level => $level,
                text  => $text,
            };
        }
    }
    
    return \@headings;
}

sub heading_count ($self) {
    return scalar $self->headings->@*;
}

1;

コードの解説

正規表現パターン

1
if ($line =~ /^(#{1,6})\s+(.+)$/) {

この正規表現を分解すると:

パターン意味
^行頭
(#{1,6})#が1〜6個(キャプチャグループ1)
\s+1つ以上の空白
(.+)1文字以上の任意の文字列(キャプチャグループ2)
$行末

キャプチャグループを使うことで、マッチした部分を変数に取り出せます。

レベルの取得

1
my $level = length($1);

$1には#{1,6}でマッチした部分(#の連続)が入ります。length()で文字数(#の数)を取得すると、それがそのまま見出しレベルになります。

テキストの取得

1
my $text = $2;

$2には.+でマッチした部分(見出しテキスト)が入ります。

ハッシュで保存

1
2
3
4
push @headings, {
    level => $level,
    text  => $text,
};

抽出した情報を匿名ハッシュにして配列に追加します。各要素はleveltextキーを持つハッシュリファレンスです。

使用例

前回作成したMarkdownReaderと組み合わせて使ってみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/env perl
use v5.36;
use lib '.';
use MarkdownReader;
use HeadingExtractor;

my $reader = MarkdownReader->new(
    filepath => 'sample.md',
);

my $extractor = HeadingExtractor->new(
    lines => $reader->lines,
);

say "見出し数: " . $extractor->heading_count;
say "";
say "抽出結果:";
for my $h ($extractor->headings->@*) {
    say "  レベル$h->{level}: $h->{text}";
}

実行結果

テスト用のMarkdownファイル(前回と同じ)で実行すると:

1
2
3
4
5
6
見出し数: 4
抽出結果:
  レベル1: はじめに
  レベル2: 第1章
  レベル3: セクション1.1
  レベル2: 第2章

見出しが正しく抽出され、レベルとテキストが取得できています。

補足:正規表現のエッジケース

今回の正規表現は基本的なケースをカバーしていますが、以下のようなエッジケースには対応していません。

マッチしないケース

1
2
#タイトル     <!-- #の後にスペースがない -->
####### 見出し <!-- #が7個以上 -->

マッチするが意図と異なる可能性

1
##  タイトル  <!-- 複数スペース:マッチするが末尾スペースも含む -->

実用的なツールを作る場合は、これらのケースも考慮する必要がありますが、本シリーズでは基本的なパターンに集中します。

完成コード

今回作成したHeadingExtractorクラスの完成コードです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package HeadingExtractor;
use v5.36;
use Moo;

has 'lines' => (
    is       => 'ro',
    required => 1,
);

has 'headings' => (
    is      => 'lazy',
    builder => '_build_headings',
);

sub _build_headings ($self) {
    my @headings;
    
    for my $line ($self->lines->@*) {
        if ($line =~ /^(#{1,6})\s+(.+)$/) {
            my $level = length($1);
            my $text  = $2;
            push @headings, {
                level => $level,
                text  => $text,
            };
        }
    }
    
    return \@headings;
}

sub heading_count ($self) {
    return scalar $self->headings->@*;
}

1;

まとめ

今回は、正規表現を使ってMarkdownの見出しを抽出するHeadingExtractorクラスを作成しました。

学んだこと:

  • 正規表現のキャプチャグループ()で部分文字列を取得
  • $1$2でキャプチャした値を参照
  • length()で文字数を取得し、見出しレベルを算出
  • 匿名ハッシュ{}で構造化データを配列に格納

次回は、抽出した見出しをフラットな配列のままインデント付きで表示する処理を実装します。まだ階層構造(ツリー構造)は扱わず、単純なループ処理で目次を表示してみましょう。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。