複雑な条件のファイル検索・置換を楽にするライブラリ「go-rewrite」を作った


tl;dr

  • 複雑なファイル検索・置換は grep、sed だけでは難しい場合がある
  • そういう場合は コマンドラインツールなどで自作するしかない
  • 検索・置換ルールだけに集中したコーディングをするためのライブラリとして「go-rewrite」 を作った
  • 「go-rewrite」 はディレクトリの再帰的な走査と検索・置換ルールの適用を並列に行う

インストール

go get github.com/yoheimuta/go-rewrite

検索・置換ルール

「go-rewrite」 は Rule インターフェースを定義している。
ライブラリを利用するコマンドラインツールはこのインターフェースを実装するだけでいい。

  • Filter はファイル名を使って対象のファイルを絞り込むメソッドを表す。
  • Mapping はファイルのコンテンツを使って新しいコンテンツを生成するメソッドを表す。
  • Output は新しいコンテンツを利用するメソッドを表す

Filter が検索処理、Mapping と Output が置換処理にそれぞれ対応する。

// Rule is the rule for rewrite.
type Rule interface {
    // Filter filters the file using the filepath.
    Filter(filepath string) (isFilter bool, err error)
    // Mapping maps the file content with new one.
    Mapping(content []byte) (newContent []byte, isChanged bool, err error)
    // Output writes the content to the file.
    Output(filepath string, content []byte) error
}

次のような .swift ファイルを検索して、///// に置換したい。ただし、func class var// だけを対象にしたい。コメントは複数行ある場合があるのでそれも考慮しないといけない。

//
//  Int64+JPY.swift
//  Hoge
//
//  Created by FugaFuga on 2018/07/07.
//  Copyright © 2018年 Hoge. All rights reserved.
//

import Foundation

private let formatter: NumberFormatter = NumberFormatter()

extension Int64 {

    private func formattedString(style: NumberFormatter.Style,
                                 localeIdentifier: String) -> String {
        formatter.numberStyle = style
        formatter.locale = Locale(identifier: localeIdentifier)

        // hogehoge
        return formatter.string(from: self as NSNumber) ?? ""
    }

    // JPYString converts the int64 to yen notation one like ¥1,000,000
    // hoge
    public var JPYString: String {
        return formattedString(style: .currency, localeIdentifier: "ja_JP")
    }

    // formattedJPString converts the int64 to thousand separator string like 1,000,000
    public func formattedJPString() -> String {
        return formattedString(style: .decimal, localeIdentifier: "ja_JP")
    }
}

上記の検索・置換ルールの実装は次の通り。

  • Filter メソッドで .swift ファイルだけを対象にする
  • Mapping メソッドで ///// に置換した新しいコンテンツを生成する
  • Output メソッドでファイルを上書きする
package main

import (
    "bytes"
    "io/ioutil"
    "strings"
)

func containsMulti(b []byte, subslices [][]byte) bool {
    for _, subslice := range subslices {
        if bytes.Contains(b, subslice) {
            return true
        }
    }
    return false
}

func mappingRecursive(n int, lines [][]byte) [][]byte {
    if n < 0 {
        return lines
    }

    beforeLine := lines[n]
    if bytes.Contains(beforeLine, []byte("/// ")) {
        return lines
    }

    if bytes.Contains(beforeLine, []byte("// ")) {
        beforeLine = bytes.Replace(beforeLine, []byte("// "), []byte("/// "), 1)
    }

    if bytes.Equal(lines[n], beforeLine) {
        return lines
    }

    lines[n] = beforeLine
    return mappingRecursive(n-1, lines)
}

// Rule implements the Rewrite.Rule interface.
type Rule struct{}

// Filter filters the file using the filepath.
func (*Rule) Filter(filepath string) (bool, error) {
    if !strings.HasSuffix(filepath, ".swift") {
        return false, nil
    }
    return true, nil
}

// Mapping maps the file content with new one.
func (*Rule) Mapping(content []byte) ([]byte, bool, error) {
    newLine := []byte("\n")
    lines := bytes.Split(content, newLine)
    for i, line := range lines {
        if containsMulti(line, [][]byte{[]byte("func"), []byte("class"), []byte("var")}) {
            lines = mappingRecursive(i-1, lines)
        }
    }
    newContent := bytes.Join(lines, newLine)
    return newContent, !bytes.Equal(content, newContent), nil
}

// Output writes the content to the file.
func (*Rule) Output(filepath string, content []byte) error {
    // 補足: 新規作成されるわけではないので権限が変更されることはない
    return ioutil.WriteFile(filepath, content, 0644)
}

main 関数では、実装した Rule 構造体を rewrite.Run に渡すだけ。

package main

import (
    "flag"

    "github.com/yoheimuta/go-rewrite/rewrite"
)

var (
    root   = flag.String("root", ".", "root path")
    dryrun = flag.Bool("dryrun", true, "the flag whether to overwrite")
)

func main() {
    flag.Parse()

    rule := &Rule{}
    rewrite.Run(*root, rule, rewrite.WithDryrun(*dryrun))
}