Python grep風コマンドを自作する

# はじめに

趣味でファイルの修正をしたくなりました。
ファイルから条件に合った行を抽出してそれを一定条件で置き換えるというものです。
とても単純ですのでエディタのプロジェクト内検索やgrepなどで何とかなりそうなものです。
しかし、少し手間なので自動出来るツールが欲しくなりました。
昔のプログラムを改良すれば何とかなりそうですが、一度しっかりと作ってみようと思い記事にしてみます。

結局、作る手間が増えたような気もしないでもないですが(ﾟ∀ﾟ)ｱﾋｬ
Windowsには、"findstr" なんてコマンドがありましたね...

# 環境

筆者は、Windows10上でPowerShellを介して動作させることを想定しています。
Linux等でも支障はないかと思います。


```:Python
Python 3.9.4 (tags/v3.9.4:1f2e308, Apr  4 2021, 13:27:16) [MSC v.1928 64 bit (AMD64)]
```


```:OS
システムの種類	64 ビット オペレーティング システム、x64 ベース プロセッサ
エディション	Windows 10 Pro
バージョン	20H2
インストール日	‎2020/‎12/‎27
OS ビルド	19042.870
エクスペリエンス	Windows Feature Experience Pack 120.2212.551.0
```

```powershell:$PSVersionTable
Name                           Value
----                           -----
PSVersion                      7.1.3
PSEdition                      Core
GitCommitId                    7.1.3
OS                             Microsoft Windows 10.0.19042
Platform                       Win32NT
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
WSManStackVersion              3.0
```


# 仕様

grepの細かい動作が分からないので似たような機能を作成します。
また、grepの個人的に要らない機能や実現するのに非常に手間が掛かりそうな機能は実装しません。

基本機能は、

- 対象のパスを指定する
  - ファイルならば、そのファイル内だけを対象にする
  - ディレクトリならば、サブディレクトリを含めた配下の全てのファイルを対象にする
- パスの限定および除外を指定できるようにする
    - これの指定にも、正規表現を使う
- 検索文字列に一致する行を抽出して表示する
    - 検索文字列は、正規表現のみを使う
        - 大文字、小文字を区別しないかどうかなど切り替えられるようにする
    - 検索文字列に一致しない行を対象に出来るようにする
- 置換機能を実現する
- 出力を制御できるようにする
    - ファイル名を表示する
    - 行番号を表示する
    - 列番号を表示する
    - ディレクトリ構造を表示できるようにする

とします。

# ソース

上記の方針で作ったプログラムが以下になります。
しっかりとしたテストを行っていないので動作保証はできませんのでご了承ください。
また、エラー処理など粗が残っている事もご了承ください。

<details><summary>ソース</summary><div>

```python:grep.py
import os
import os.path
import re
import argparse

########################################################################################################################
## EntryInfo
########################################################################################################################


class EntryInfo():

  def __init__(self, path, depth=0):
    if not os.path.exists(path):
      print(path)
      raise ValueError
    self.path = os.path.abspath(path)
    self.name = os.path.basename(self.path)
    self.depth = depth
    self.setAttribute()

  def __str__(self):
    # return f"{'  ' * self.depth}{self.attribute}: {self.name}"
    return f"{self.attribute}: {self.name}"

  def __lt__(self, other):
    if self.attribute == other.attribute:
      return self.name > other.name
    else:
      return EntryInfo.getAttributePriority(self.attribute) > EntryInfo.getAttributePriority(other.attribute)

  def getIndentedString(self):
    return f"{'  ' * self.depth}{str(self)}"

  @staticmethod
  def getAttribute(path):
    attribute = ""
    if os.path.isdir(path):  # True: file or dirLink
      attribute = "d"
    elif os.path.isfile(path):  # True: file or fileLink
      attribute = "f"
    if os.path.islink(path):
      attribute += "l"
    else:
      attribute += " "
    return attribute

  def setAttribute(self):
    self.attribute = EntryInfo.getAttribute(self.path)

  def isDir(self):
    return True if "d" in self.attribute else False

  def isFile(self):
    return True if "f" in self.attribute else False

  def isLink(self):
    return True if "l" in self.attribute else False

  def getStats(self):
    return os.stat(self.path)

  @staticmethod
  def getAttributePriority(attribute):
    if attribute == "f ":
      return 1
    elif attribute == "fl":
      return 2
    elif attribute == "d ":
      return 3
    elif attribute == "dl":
      return 4
    else:
      return 0


########################################################################################################################
## EntryList
########################################################################################################################
class EntryList:

  def __init__(self, path, reInclude, reExclude):
    self.entries = []
    self.getAllEntries(path, reInclude, reExclude)

  def getAllEntries(self, path, reInclude, reExclude):
    self.entries = [EntryInfo(path)]
    if self.entries[0].isFile():
      return

    workStack = []
    EntryList.getSubEntries(path, workStack, 1)
    while len(workStack) > 0:
      entry = workStack.pop()
      self.entries.append(entry)
      if entry.isDir():
        EntryList.getSubEntries(entry.path, workStack, entry.depth + 1)
    self.entries = EntryList.filterEntries(self.entries, reInclude, reExclude)

  @staticmethod
  def getSubEntries(path, stack, depth=0):
    lt = os.listdir(path)  # オブジェクトの型の問題で os.scandir(path) は使わないことにした
    entries = []
    for i in lt:
      entries.append(EntryInfo(os.path.join(path, i), depth))
    entries.sort()
    stack += entries

  @staticmethod
  def filterEntries(entries, reInclude=None, reExclude=None):
    result = []
    for entry in entries:
      if reInclude is not None and reInclude.search(entry.path) is None:
        continue
      if reExclude is not None and reExclude.search(entry.path) is not None:
        continue
      result.append(entry)
    return result

  def printTree(self):
    parentList = []
    for i in range(len(self.entries)):
      if self.entries[i].isDir():
        parentList.append(self.entries[i].path)
      parent = os.path.dirname(self.entries[i].path)
      if self.entries[i].isFile() and parent not in parentList and parent != os.path.dirname(self.entries[i - 1].path):
        print(EntryList.getParentName(parent, self.entries[i].depth - 1, parentList))
      print(self.entries[i].getIndentedString())

  @staticmethod
  def getParentName(path, depth, parentList):
    output = ""
    while path not in parentList and depth >= 0:
      name = os.path.basename(path)
      attribute = EntryInfo.getAttribute(path)
      output = f"{'  ' * depth}{attribute}:{name}\n" + output
      parentList.append(path)
      depth -= 1
      path = os.path.dirname(path)
    return output.rstrip()


########################################################################################################################
## grep
########################################################################################################################


class Grep():

  def __init__(self, args):
    self.setSearchOption(args)
    self.setPrintOption(args)
    self.setFlag(args)
    self.compile()
    self.entryList = EntryList(self.path, self.reInclude, self.reExclude)

  def setSearchOption(self, args):
    self.path = args.path
    self.search = args.search
    self.repl = args.replace
    self.invertMatch = args.invertMatch
    self.include = args.include
    self.exclude = args.exclude

  def setFlag(self, args):
    self.flags = 0
    if args.ascii:
      self.flags += re.ASCII
    if args.ignoreCase:
      self.flags += re.IGNORECASE
    # if args.multiLine:
    #   self.flags += re.MULTILINE
    # if args.dotAll:
    #   self.flags += re.DOTALL

  def compile(self):
    self.reInclude = None
    if self.include is not None:
      self.reInclude = re.compile(self.include, flags=self.flags)
    self.reExclude = None
    if self.exclude is not None:
      self.reExclude = re.compile(self.exclude, flags=self.flags)
    self.reSearch = re.compile(self.search, flags=self.flags)

  def setPrintOption(self, args):
    self.noCaption = args.noCaption
    self.noLineNumber = args.noLineNumber
    self.lineNumberLength = args.lineNumberLength
    self.noFileName = args.noFileName
    self.fileNameLength = args.fileNameLength
    self.noOffset = args.noOffset
    self.offsetLength = args.offsetLength
    self.showZeroLength = args.showZeroLength

  def main(self):
    self.printTree()
    for i in self.entryList.entries:
      if not i.isFile():
        continue
      data = grep.scan(i)
      Grep.write(i, data)

  def scan(self, entryInfo):
    with open(entryInfo.path, "r", encoding="utf-8", newline="") as file:
      data = file.readlines()

    replaceFlag = False
    self.printCaption(entryInfo.name)
    for line, text in enumerate(data):
      if line != len(data) - 1:  # 改行を削除しないと処理がVScodeなどで置換した場合と異なってしまう。最終行は改行を消してしまうと処理がおかしくなる
        text, linebreak = Grep.removeLineBreak(text)
      else:
        linebreak = ""
      matchList = search(self.reSearch, text, self.showZeroLength)
      if ((len(matchList) > 0 and not self.invertMatch) or (len(matchList) <= 0 and self.invertMatch)):
        self.printMessage(entryInfo.name, line, text, matchList)
      replaceFlag = True if self.replace(data, line, text, linebreak) else replaceFlag
    return data if replaceFlag else []

  def replace(self, data, line, text, linebreak):
    if self.repl is None:
      return False

    if not self.invertMatch and self.reSearch.search(text) is not None:
      data[line] = self.reSearch.sub(self.repl, text) + linebreak
      return True
    elif self.invertMatch and self.reSearch.search(text) is None:
      data[line] = self.repl
      return True
    return False

  ## print

  def printTree(self):
    if not self.printTree:
      return
    self.entryList.printTree()

  def printCaption(self, name):
    if not self.noCaption:
      print(f"# {name} #")

  def printMessage(self, name, line, text, matchList):
    string = ""
    if not self.noFileName:
      string = f"{name:{self.fileNameLength}}"
    if not self.noLineNumber:
      string += f"[{line:0{self.lineNumberLength}}]"
    if not self.noOffset:
      for match in matchList:
        # string += f"({match['start']:0{self.offsetLength}}--{match['end']:0{self.offsetLength}}:{match['length']})"
        string += f"({match['start']:0{self.offsetLength}}:{match['length']:0{self.offsetLength}})"
    if not self.noFileName or not self.noLineNumber or not self.noOffset:
      string += f":"
    text, _ = Grep.removeLineBreak(text)
    string += f"{text}"
    print(string)

  @staticmethod
  def write(entryInfo, data):
    if len(data) == 0:
      return
    with open(entryInfo.path, "w", encoding="utf-8", newline="") as file:  # 改行コード
      file.writelines(data)

  @staticmethod
  def removeLineBreak(string):
    linebreak = checkTailLineBreak(string)
    string = removeLineBreak(string, linebreak)
    return (string, linebreak)


########################################################################################################################
## utility
########################################################################################################################


def removeAllLineBreak(string, repl=""):
  reLF = re.compile("\n")
  reCR = re.compile("\r")
  string = reLF.sub(repl, string)
  string = reCR.sub("", string)
  return string


def removeLineBreak(string, linebreak="\n"):
  return string.rstrip(linebreak)


def checkTailLineBreak(string):
  if string.endswith("\r\n"):
    return "\r\n"
  if string.endswith("\n"):
    return "\n"
  return ""


########################################################################################################################
## regex
########################################################################################################################


def search(regex, text, includeZero=True):
  match = regex.search(text)
  if match is None:
    return []

  lt = []
  lastIndex = storeMatchInfo(lt, match, 0, includeZero)
  while len(text) >= lastIndex:
    match = regex.search(text[lastIndex:])
    if match is None:
      return lt
    lastIndex = storeMatchInfo(lt, match, lastIndex, includeZero)
  return lt


def storeMatchInfo(lt, match, lastIndex=0, includeZero=True):
  if includeZero or len(match[0]) > 0:
    lt.append(getMatchInfo(match, lastIndex))
  return getLastIndex(match, lastIndex)


def getMatchInfo(match, lastIndex=0):
  start = match.start() + lastIndex
  end = match.end() + lastIndex
  length = len(match[0])
  return {"start": start, "end": end, "length": length, "string": match[0]}


def getLastIndex(match, lastIndex=0):
  end = match.end() + lastIndex
  length = len(match[0])
  return end if length > 0 else end + 1


########################################################################################################################
## argument
########################################################################################################################

# flags
# re.ASCII
# re.DEBUG: 無効
# re.IGNORECASE
# re.MULTILINE: 無効
# re.DOTALL: 無効
# re.VERBOSE: 無効


def argumentParser():
  parser = argparse.ArgumentParser()
  # search option
  parser.add_argument("path", help="specify file or directory.")
  parser.add_argument("search", help="specify search string as regular expression.")
  parser.add_argument("-r", "--replace", nargs="?", const="", help="specify replace string.")  # 空文字列を受け取れる設定
  parser.add_argument("-v", "--invertMatch", action="store_true", help="select non-matching lines.")
  parser.add_argument("-i", "--include", help="search only path that match pattern.")
  parser.add_argument("-e", "--exclude", help="skip path match pattern.")

  # match option
  parser.add_argument(
      "-A", "--ascii", action="store_true",
      help="make \\w, \\W, \\b, \\B, \\d, \\D, \\s and \\S perform ASCII-only matching instead of full Unicode matching."
  )
  parser.add_argument("-I", "--ignoreCase", action="store_true", help="perform case-insensitive matching.")
  # parser.add_argument("-m", "--multiLine", action="store_true", help="when specified, the pattern character '^' matches at the beginning of the string and at the beginning of each line (immediately following each newline).")
  # parser.add_argument("-D", "--dotAll", action="store_true",
  #                     help="make the '.' special character match any character at all, including a newline.")

  # tree view
  parser.add_argument("-t", "--tree", action="store_true", help="show directory tree.")

  # printOption
  parser.add_argument("-a", "--showArgument", action="store_true", help="show arguments.")
  parser.add_argument("-c", "--noCaption", action="store_true", help="no print file name as caption.")
  parser.add_argument("-l", "--noLineNumber", action="store_true", help="no print line number with output lines.")
  parser.add_argument("-ll", "--lineNumberLength", type=int, default=4, help="line number length.")
  parser.add_argument("-f", "--noFileName", action="store_true", help="no print file name with output lines.")
  parser.add_argument("-fl", "--fileNameLength", type=int, default=1, help="file name length.")
  parser.add_argument("-o", "--noOffset", action="store_true", help="no print offset with output lines.")
  parser.add_argument("-ol", "--offsetLength", type=int, default=3, help="offset length.")
  parser.add_argument("-z", "--showZeroLength", action="store_true", help="show zero length match.")  ## fix
  return parser.parse_args()


########################################################################################################################
## main
########################################################################################################################

if __name__ == "__main__":
  args = argumentParser()
  if args.showArgument:
    print(args)

  grep = Grep(args)
  grep.main()


```

</div></details>

## 解説

### プログラムの記述に関して

ディレクトリ配下のファイル等を取得するのに `os.listdir()`を使っています。
返された名前のリストからパスや属性などを調べているので `os.scandir()`を使えば良いかと思われるかもしれません。
`os.scandir()`ならば、パスや属性なども自動で取得して返してくれますので便利です。
しかし、`os.scandir()`は、引数に与えたパスの配下を表す`os.DirEntry`オブジェクトのイテレータを返すのですが、引数に与えたパス自身の情報は返してくれません。
また、`os.DirEntry`オブジェクトのコンストラクタも用意されておらず、パス自身の情報を扱いたい時には何らかの仕組みを作らなければならないと思います。
検索の起点となるディレクトリの情報も統一的に扱いたかったので`os.listdir()`を使う事にしました。
そして、必要な情報は、`EntryInfo`クラスを作りそこで取得するようにしています。
もっとうまい方法もありそうですが。

#### 行をまたがった検索

行番号を簡単に表示させるために、`readlines()`を使ってファイルからデータを読み込んでいます。
`readlines()`は、ファイルのデータを改行ごとに分割して返します。
分割されたデータに対して正規表現検索を行うようにしていますので、行をまたがった検索は不可能となっています。

例えば、"cat\ndog"みたいな検索は不可能となっています。
ただ、空行を見つける"^\n$"みたいな記述は可能です。

"^"は、常に行の先頭を表し、"$"は、行の末尾を表すことになります。
"^"をファイルの先頭として認識させたり、"$"をファイルの末尾として認識させることは出来ません。
Pythonの`re.MULTILINE`フラグは、常に設定されていると同じになり意味をなさないことになります。

#### ファイルの処理順に関して

このプログラムは、指定したディレクトリの配下に、ディレクトリとファイルがあった場合は、
ファイル-->ディレクトリ
と処理しています。

これの順番を変えたい場合は、
`getSubEntries()`内の`entries.sort()`に、`@reverse=true`を渡したり、
`EntryInfo`の`__lt__()`や`getAttributePriority()`を修正する必要があります。

#### 長さ0の量指定子を使った置換について

長さ0の量指定子を使い置換する場合、長さ0の一致箇所も置換されます。
例えば、`abc`という文字列があり、その`.*`を`X`に置換する場合、
結果は、`XX`となります。
これは、`abc`という文字列の後ろに長さ0の一致があるためです。

## 使用方法

プログラムを"grep.py"に保存したとします。
そして以下のようなディレクトリ構造があるとします。
カレントディレクトリは、"python-grep"とします。

```
d : python-grep
  f : grep.py
  f : memo.md
  d : test
    f : test1.txt
    f : test2.txt
    d : sub1
      f : sub11.py
      f : sub12.py
      d : sub12
        f : sub121.txt
        f : sub122.txt
    d : sub2
      f : sub21.js
      f : sub22.js
```

### PowerShellからの呼び出しに関して

PowerShellを使っている場合に限りますが、PowerShellでは、改行文字などの制御文字を表す場合は`\`ではなく`` ` ``を使います。
例えば、空行を検索したい場合は、検索文字列として`^\n$`などを渡します。
`^\n$`をそのまま渡しても制御文字として認識されずにも空行を見つけられないかもしれません。
その場合は、`` ^`n$ ``と指定してみてください。
同じように制御文字動作がおかしい場合は、`\`を`` ` ``に置き換えてみるとうまく行くかもしれません。


### ヘルプ `-h`

ヘルプを表示させる場合は、
`python grep.py -h`
とします。
表示されるメッセージは、以下のようになります。

```
PS > python grep.py -h
usage: grep.py [-h] [-r [REPLACE]] [-v] [-i INCLUDE] [-e EXCLUDE] [-A] [-I] [-D] [-t] [-a] [-c] [-n] [-ll LINENUMBERLENGTH] [-f] [-fl FILENAMELENGTH] [-o] [-ol OFFSETLENGTH] [-z]
               path search

positional arguments:
  path                  specify file or directory.
  search                specify search string as regular expression.

optional arguments:
  -h, --help            show this help message and exit
  -r [REPLACE], --replace [REPLACE]
                        specify replace string.
  -v, --invertMatch     select non-matching lines.
  -i INCLUDE, --include INCLUDE
                        search only path that match pattern.
  -e EXCLUDE, --exclude EXCLUDE
                        skip path match pattern.
  -A, --ascii           make \w, \W, \b, \B, \d, \D, \s and \S perform ASCII-only matching instead of full Unicode matching.
  -I, --ignoreCase      perform case-insensitive matching.
  -t, --tree            show directory tree.
  -a, --showArgument    show arguments.
  -c, --noCaption       no print fine name.
  -l, --noLineNumber    no print line number with output lines.
  -ll LINENUMBERLENGTH, --lineNumberLength LINENUMBERLENGTH
                        line number length.
  -f, --noFileName      no print file name with output lines.
  -fl FILENAMELENGTH, --fileNameLength FILENAMELENGTH
                        file name length.
  -o, --noOffset        no print offset with output lines.
  -ol OFFSETLENGTH, --offsetLength OFFSETLENGTH
                        offset length.
  -z, --showZeroLength  show zero length match.
```

### 検索

検索をする場合の必須のパラメータは、`path`と`search`で
`python grep.py path search`
のように指定します。
`path`には、対象にしたいファイルまたはディレクトリへのパスを指定します。
`search`には、検索したい正規表現を指定します。
grepでは、正規表現ではない素の文字列でも検索できますがこのプログラムでは出来ません。

#### ディレクトリ指定

`path`にディレクトリを指定した場合は、そのディレクトリを配下の全てのファイルが対象となります。

カレントディレクトリ以下のファイルから"cat"という正規表現を見つけたい場合は、
`python grep.py "." "cat"`
とします。
そうすると以下のような出力を得ます。

```
# test1.txt #
test1.txt[0000](000:003):cat
test1.txt[0015](006:003):white_cat
test1.txt[0016](005:003):gray_cat
test1.txt[0017](006:003):black_cat
# test2.txt #

中略

# sub22.js #
sub22.js[0000](003:003):// cat
sub22.js[0015](009:003):// white_cat
sub22.js[0016](008:003):// gray_cat
sub22.js[0017](009:003):// black_cat
# grep.py 
```

"test2.txt"と"grep.py"からは、指定した正規表現は見つからなかった事を示しています。
"sub22.txt"と"test1.txt"からは、指定した正規表現が見つかった事を示しています。

"sub22.txt"について、

- 0行目の0文字目から3文字
- 15行目の9文字目から3文字
- 16行目の8文字目から3文字
- 17行目の9文字目から3文字

が指定した正規表現に一致した事を示しています。
行と列のインデックスは、0から開始です。

#### ファイル指定

`path`にファイルを指定した場合は、そのファイルのみが対象となります。

以下のようにパスにファイルを指定すると
`python grep.py ".\test\test1.txt" "cat"`
検索対象が対象ファイルのみになります。
出力は、以下のようになります。

```
# test1.txt #
test1.txt[0000](000:003):cat
test1.txt[0015](006:003):white_cat
test1.txt[0016](005:003):gray_cat
test1.txt[0017](006:003):black_cat
```

#### 正規表現フラグ

正規表現フラグとして設定できるのはそれほど多くなく、

- `re.IGNORECASE`: `-I`
- `re.ASCII`: `-A`

の2つとなります。

##### IgnoreCase `-I`

大文字小文字の区別をなくしたい場合は、
`python grep.py ".\test\test1.txt" "cat" -I` 
`-I`と大文字で渡してください

##### ASCII `-A`

re.ASCIIフラグに関しては、
`python grep.py ".\test\test2.txt" "\d+"` 
とした場合、例えば以下の出力が得られたとします。

```
# test2.txt #
test2.txt[0007](000:003)(006:003):123Abc456Def
test2.txt[0009](000:003):１２３
```

"１２３"は、Unicode上では数字なので対象として抽出されます。
これをASCIIコードで数字として扱われるものだけに限定したい場合は、
`python grep.py ".\test\test2.txt" "\d+" -A` 
`-A`と大文字で渡してください
出力が以下のようになります。

```
# test2.txt #
test2.txt[0007](000:003)(006:003):123Abc456Def
```

余談ですが、
"一"などの漢数字は、Unicodeの数値としてのプロパティが設定されていないようで"\d+"などで取得することは出来ません。
その一方で、"㈠"このような文字は、数値としてのプロパティが設定されていたりします。
深入りするとUnicodeの沼にはまることになります。
まあ、全角数字や漢数字を扱う事なんてないでしょう。ﾊﾊﾊﾊﾊ...

#### 正規表現に一致しない行を対象にする `-v`

通常、正規表現に一致した文字列が含まれる行が抽出されますが、
`python grep.py ".\test\test1.txt" "cat" -v`
と`-v`を渡すと正規表現に一致する文字列がない行が抽出されます。

`python grep.py ".\test\test1.txt" "cat"`
で、以下のように出力されたとしたら

```
# test1.txt #
test1.txt[0000](000:003):cat
test1.txt[0015](006:003):white_cat
test1.txt[0016](005:003):gray_cat
test1.txt[0017](006:003):black_cat
```

`python grep.py ".\test\test1.txt" "cat" -v`
では、以下のように出力されます。

```
# test1.txt #
test1.txt[0001]:dog
test1.txt[0002]:bird
test1.txt[0003]:CAT
test1.txt[0004]:DOG
test1.txt[0005]:BIRD
test1.txt[0006]:Cat
test1.txt[0007]:Dog
test1.txt[0008]:Bird
```


### 置換 `-r`

検索文字列を置換したい場合は、
`python grep.py ".\test\test1.txt" "dog" -r "cat"`
のように `-r REPCALE` を渡してください。
この場合は、".\test\test1.txt" の"dog"という正規表現を"cat"という文字列に置換します。
出力は以下のようになり、ファイルが更新されます。

```
# test1.txt #
test1.txt[0001](000:003):dog
```

空行を削除したい場合など、検索対象を削除したい場合は、以下のように空文字列か`-r`だけを渡してください。

`python grep.py ".\test\test2.txt" "^\n$" -r ""`
`python grep.py ".\test\test2.txt" "^\n$" -r`

#### 正規表現に一致しない行を対象にすると組み合わせる

`python grep.py ".\test\test1.txt" "dog" -r "cat"`
とした場合は、"dog"という正規表現に一致する文字列が"cat"に変換されますが

`python grep.py ".\test\test1.txt" "dog" -r "cat" -v`
とすると"dog"という正規表現が含まれない行の全体が"cat"に変換されます。

組み合わせて使う場合は、ご注意ください。


### パスの限定と除外

#### パスの限定 `-i`

`python grep.py "." "cat"`
とした場合、カレントディレクトリ以下の全てのファイルを検索します。

これを例えば、".txt"ファイルだけに限定したい場合は、

`python grep.py "." "cat"  -i "\.txt"`

と`-i INCLUDE`で限定したいファイル名に含まれる正規表現を渡してください。
注意していただきたいのは、フルパスに渡した正規表現が含まれていた場合は、対象となるという点です。
"python.*"などを渡すと親ディレクトリが"python-grep"なので全てのファイルが対象となります。
また、渡すのは正規表現なので".txt"と渡すと意図した結果にならない可能性があります。

#### パスの除外 `-e`

一方、例えば、".js"ファイルだけを除外したい場合は、

`python grep.py "." "cat"  -e "\.js"`

と`-e EXCLUDE`で除外したいファイル名に含まれる正規表現を渡してください。
注意点は、限定時と同じになります。

#### パスの限定と除外

限定と除外を組み合わせる事も出来ます。
例えば、
`python grep.py "." "cat"  -i "\.txt" -e "sub"`
とするとフルパスに"sub"が含まれない".txt"ファイルを対象とします。

### 出力の制御

#### オプションの詳細表示 `-a`

`python grep.py "." "cat" -a`
と`-a`を渡すと、以下のようなオプションの詳細が表示されるようになります。

```
Namespace(ascii=False, dotAll=False, exclude=None, fileNameLength=1, ignoreCase=False, include=None, invertMatch=False, lineNumberLength=4, noCaption=False, noFileName=False, noLineNumber=False, noOffset=False, offsetLength=3, path='.', replace=None, search='cat', showArgument=True, showZeroLength=False, tree=False)
```

Namesaceと表示され、手抜きが分かってしましますが

#### ディレクトリツリーの表示 `-t`

`python grep.py "." "cat" -t`
と`-t`を渡すと、以下のようなディレクトリツリーが出力に含まれるようになります。

```
d : python-grep
  f : grep.py
  f : memo.md
  d : test
    f : test1.txt
    f : test2.txt
    d : sub1
      f : sub11.py
      f : sub12.py
      d : sub12
        f : sub121.txt
        f : sub122.txt
    d : sub2
      f : sub21.js
      f : sub22.js
```

先頭についている記号の意味は、

- `d :`: ディレクトリ
- `dl:`: ディレクトリへのシンボリックリンク
- `f :`: ファイル
- `fl:`: ファイルへのシンボリックリンク

となります。

#### 見出しのファイル名を表示させない `-c`

`python grep.py ".\test\test1.txt" "cat"`
で、以下のように出力されるとします。

```
# test1.txt #
test1.txt[0000](000:003):cat
test1.txt[0015](006:003):white_cat
test1.txt[0016](005:003):gray_cat
test1.txt[0017](006:003):black_cat
```

見出しのファイル名である "# test1.txt #" を表示させたくない場合は、
`python grep.py ".\test\test1.txt" "cat" -c`
と`-c`を渡します。
そうすると出力は、以下のようになります。

```
test1.txt[0000](000:003):cat
test1.txt[0015](006:003):white_cat
test1.txt[0016](005:003):gray_cat
test1.txt[0017](006:003):black_cat
```

#### 行出力のファイル名を表示しない `-f`

`python grep.py ".\test\test1.txt" "cat" -f`
と`-f`を渡すと、以下のように抽出された行出力にファイル名が表示されません。

```
# test1.txt #
[0000](000:003):cat
[0015](006:003):white_cat
[0016](005:003):gray_cat
[0017](006:003):black_cat
```

#### 行出力のファイル名の出力幅を指定する `-fl`

`python grep.py "." "cat" -c`
として以下の出力が得られたとします。

```
sub122.txt[0000](000:003):cat
sub11.py[0000](002:003):# cat
```

ファイル名の長さが違うために、抽出した文字列の表示位置がずれてしまっています。
これを調整したい場合は、
`python grep.py "." "cat" -c -fl 11`
のように `-fl FILENAMELENGTH`を渡します。
そうするとファイル名が指定した長さに揃えられます。

```
sub122.txt [0000](000:003):cat
sub11.py   [0000](002:003):# cat
```

#### 行出力の行番号を表示しない `-l`

`python grep.py ".\test\test1.txt" "cat" -l`
と`-l`を渡すと、以下のように抽出された行出力に行番号が表示されません。

```
# test1.txt #
test1.txt(000:003):cat
test1.txt(006:003):white_cat
test1.txt(005:003):gray_cat
test1.txt(006:003):black_cat
```

#### 行出力の行番号の出力幅を指定する `-ll`

行番号の出力幅を調整したい場合は、
`python grep.py ".\test\test1.txt" "cat" -ll 2`
のように `-ll LINENUMBERLENGTH`を渡します。
ここでは2を指定したので以下のような出力になります。

```
# test1.txt #
test1.txt[00](000:003):cat
test1.txt[15](006:003):white_cat
test1.txt[16](005:003):gray_cat
test1.txt[17](006:003):black_cat
```

#### 行出力の一致位置を表示しない `-o`

`python grep.py ".\test\test1.txt" "cat" -o`
と`-o`を渡すと、以下のように抽出された行出力に一致位置が表示されません。

```
# test1.txt #
test1.txt[0000]:cat
test1.txt[0015]:white_cat
test1.txt[0016]:gray_cat
test1.txt[0017]:black_cat
```

#### 行出力の一致位置の出力幅を指定する `-ol`

一致位置の出力幅を調整したい場合は、
`python grep.py ".\test\test1.txt" "cat" -ol 1`
のように `-ol OFFSETLENGTH`を渡します。
ここでは1を指定したので以下のような出力になります。

```
# test1.txt #
test1.txt[0000](0:3):cat
test1.txt[0015](6:3):white_cat
test1.txt[0016](5:3):gray_cat
test1.txt[0017](6:3):black_cat
```

#### 長さ0の一致位置を表示する `-z`

`python grep.py ".\test\test2.txt" "\d*"`
として以下のような出力が得られたとします。

```
# test2.txt #
test2.txt[0000](003:003)(009:003):abc123def456
test2.txt[0001](003:003)(009:003):ABC123DEF456
test2.txt[0003](000:003)(006:003):123abc456def
```

"\d*"は、0文字以上の数字の連続を表します。
そのため、正確には、"a"の前後にも長さ0のマッチが存在することになります。
この長さ0の一致位置を表示したい場合は、
`python grep.py ".\test\test1.txt" "\d*" -z`
と`-z`を渡します。
そうすると以下のような出力が得られます。

```
test2.txt[0000](000:000)(001:000)(002:000)(003:003)(006:000)(007:000)(008:000)(009:003)(012:000):abc123def456
test2.txt[0001](000:000)(001:000)(002:000)(003:003)(006:000)(007:000)(008:000)(009:003)(012:000):ABC123DEF456
test2.txt[0002](000:000):
test2.txt[0003](000:003)(003:000)(004:000)(005:000)(006:003)(009:000)(010:000)(011:000)(012:000):123abc456def
```

余談ですが、正規表現の量指定子の`*`は、置き換えられるならば`+`に変えた方が効率が良かったりします。
もっとも、`+`では表現出来ない事も多く、`*`が必要なのですが。

# 最後に

楽をするために作ったのですが、記事にすると手間が掛かりますね。
記事を書いている時間の方がプログラミングより時間が掛かってしまうのが難点です。
まあ、記事を書くために仕様を見直したり、動作テストをしたり、勉強になったりと 得るものはあるのですが。
その割には、手抜きの部分はそのままだったりしますが。