123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667 |
- #
- #
- # Nim Grep Utility
- # (c) Copyright 2012 Andreas Rumpf
- #
- # See the file "copying.txt", included in this
- # distribution, for details about the copyright.
- #
- import
- os, strutils, parseopt, pegs, re, terminal
- const
- Version = "1.5"
- Usage = "nimgrep - Nim Grep Utility Version " & Version & """
- (c) 2012 Andreas Rumpf
- Usage:
- nimgrep [options] [pattern] [replacement] (file/directory)*
- Options:
- --find, -f find the pattern (default)
- --replace, -! replace the pattern
- --peg pattern is a peg
- --re pattern is a regular expression (default)
- --rex, -x use the "extended" syntax for the regular expression
- so that whitespace is not significant
- --recursive, -r process directories recursively
- --follow follow all symlinks when processing recursively
- --confirm confirm each occurrence/replacement; there is a chance
- to abort any time without touching the file
- --stdin read pattern from stdin (to avoid the shell's confusing
- quoting rules)
- --word, -w the match should have word boundaries (buggy for pegs!)
- --ignoreCase, -i be case insensitive
- --ignoreStyle, -y be style insensitive
- --ext:EX1|EX2|... only search the files with the given extension(s),
- empty one ("--ext") means files with missing extension
- --noExt:EX1|... exclude files having given extension(s), use empty one to
- skip files with no extension (like some binary files are)
- --includeFile:PAT include only files whose names match the given regex PAT
- --excludeFile:PAT skip files whose names match the given regex pattern PAT
- --excludeDir:PAT skip directories whose names match the given regex PAT
- --nocolor output will be given without any colours
- --color[:always] force color even if output is redirected
- --colorTheme:THEME select color THEME from 'simple' (default),
- 'bnw' (black and white) ,'ack', or 'gnu' (GNU grep)
- --afterContext:N,
- -a:N print N lines of trailing context after every match
- --beforeContext:N,
- -b:N print N lines of leading context before every match
- --context:N, -c:N print N lines of leading context before every match and
- N lines of trailing context after it
- --group, -g group matches by file
- --newLine, -l display every matching line starting from a new line
- --verbose be verbose: list every processed file
- --filenames find the pattern in the filenames, not in the contents
- of the file
- --help, -h shows this help
- --version, -v shows the version
- """
- type
- TOption = enum
- optFind, optReplace, optPeg, optRegex, optRecursive, optConfirm, optStdin,
- optWord, optIgnoreCase, optIgnoreStyle, optVerbose, optFilenames,
- optRex, optFollow
- TOptions = set[TOption]
- TConfirmEnum = enum
- ceAbort, ceYes, ceAll, ceNo, ceNone
- Pattern = Regex | Peg
- using pattern: Pattern
- var
- filenames: seq[string] = @[]
- pattern = ""
- replacement = ""
- extensions: seq[string] = @[]
- options: TOptions = {optRegex}
- skipExtensions: seq[string] = @[]
- excludeFile: seq[Regex]
- includeFile: seq[Regex]
- excludeDir: seq[Regex]
- useWriteStyled = true
- oneline = true
- linesBefore = 0
- linesAfter = 0
- linesContext = 0
- colorTheme = "simple"
- newLine = false
- proc ask(msg: string): string =
- stdout.write(msg)
- stdout.flushFile()
- result = stdin.readLine()
- proc confirm: TConfirmEnum =
- while true:
- case normalize(ask(" [a]bort; [y]es, a[l]l, [n]o, non[e]: "))
- of "a", "abort": return ceAbort
- of "y", "yes": return ceYes
- of "l", "all": return ceAll
- of "n", "no": return ceNo
- of "e", "none": return ceNone
- else: discard
- func countLineBreaks(s: string, first, last: int): int =
- # count line breaks (unlike strutils.countLines starts count from 0)
- var i = first
- while i <= last:
- if s[i] == '\c':
- inc result
- if i < last and s[i+1] == '\l': inc(i)
- elif s[i] == '\l':
- inc result
- inc i
- func beforePattern(s: string, pos: int, nLines = 1): int =
- var linesLeft = nLines
- result = min(pos, s.len-1)
- while true:
- while result >= 0 and s[result] notin {'\c', '\l'}: dec(result)
- if result == -1: break
- if s[result] == '\l':
- dec(linesLeft)
- if linesLeft == 0: break
- dec(result)
- if result >= 0 and s[result] == '\c': dec(result)
- else: # '\c'
- dec(linesLeft)
- if linesLeft == 0: break
- dec(result)
- inc(result)
- proc afterPattern(s: string, pos: int, nLines = 1): int =
- result = max(0, pos)
- var linesScanned = 0
- while true:
- while result < s.len and s[result] notin {'\c', '\l'}: inc(result)
- inc(linesScanned)
- if linesScanned == nLines: break
- if result < s.len:
- if s[result] == '\l':
- inc(result)
- elif s[result] == '\c':
- inc(result)
- if result < s.len and s[result] == '\l': inc(result)
- else: break
- dec(result)
- template whenColors(body: untyped) =
- if useWriteStyled:
- body
- else:
- stdout.write(s)
- proc printFile(s: string) =
- whenColors:
- case colorTheme
- of "simple": stdout.write(s)
- of "bnw": stdout.styledWrite(styleUnderscore, s)
- of "ack": stdout.styledWrite(fgGreen, s)
- of "gnu": stdout.styledWrite(fgMagenta, s)
- proc printBlockFile(s: string) =
- whenColors:
- case colorTheme
- of "simple": stdout.styledWrite(styleBright, s)
- of "bnw": stdout.styledWrite(styleUnderscore, s)
- of "ack": stdout.styledWrite(styleUnderscore, fgGreen, s)
- of "gnu": stdout.styledWrite(styleUnderscore, fgMagenta, s)
- proc printError(s: string) =
- whenColors:
- case colorTheme
- of "simple", "bnw": stdout.styledWriteLine(styleBright, s)
- of "ack", "gnu": stdout.styledWriteLine(styleReverse, fgRed, bgDefault, s)
- stdout.flushFile()
- const alignment = 6
- proc printLineN(s: string, isMatch: bool) =
- whenColors:
- case colorTheme
- of "simple": stdout.write(s)
- of "bnw":
- if isMatch: stdout.styledWrite(styleBright, s)
- else: stdout.styledWrite(s)
- of "ack":
- if isMatch: stdout.styledWrite(fgYellow, s)
- else: stdout.styledWrite(fgGreen, s)
- of "gnu":
- if isMatch: stdout.styledWrite(fgGreen, s)
- else: stdout.styledWrite(fgCyan, s)
- proc printBlockLineN(s: string) =
- whenColors:
- case colorTheme
- of "simple": stdout.styledWrite(styleBright, s)
- of "bnw": stdout.styledWrite(styleUnderscore, styleBright, s)
- of "ack": stdout.styledWrite(styleUnderscore, fgYellow, s)
- of "gnu": stdout.styledWrite(styleUnderscore, fgGreen, s)
- type
- SearchInfo = tuple[buf: string, filename: string]
- MatchInfo = tuple[first: int, last: int;
- lineBeg: int, lineEnd: int, match: string]
- proc writeColored(s: string) =
- whenColors:
- case colorTheme
- of "simple": terminal.writeStyled(s, {styleUnderscore, styleBright})
- of "bnw": stdout.styledWrite(styleReverse, s)
- # Try styleReverse & bgDefault as a work-around against nasty feature
- # "Background color erase" (sticky background after line wraps):
- of "ack": stdout.styledWrite(styleReverse, fgYellow, bgDefault, s)
- of "gnu": stdout.styledWrite(fgRed, s)
- proc writeArrow(s: string) =
- whenColors:
- stdout.styledWrite(styleReverse, s)
- proc blockHeader(filename: string, line: int|string, replMode=false) =
- if replMode:
- writeArrow(" ->\n")
- elif newLine:
- if oneline:
- printBlockFile(filename)
- printBlockLineN(":" & $line & ":")
- else:
- printBlockLineN($line.`$`.align(alignment) & ":")
- stdout.write("\n")
- proc lineHeader(filename: string, line: int|string, isMatch: bool) =
- let lineSym =
- if isMatch: $line & ":"
- else: $line & " "
- if not newLine:
- if oneline:
- printFile(filename)
- printLineN(":" & lineSym, isMatch)
- else:
- printLineN(lineSym.align(alignment+1), isMatch)
- stdout.write(" ")
- proc printMatch(fileName: string, mi: MatchInfo) =
- let lines = mi.match.splitLines()
- for i, l in lines:
- if i > 0:
- lineHeader(filename, mi.lineBeg + i, isMatch = true)
- writeColored(l)
- if i < lines.len - 1:
- stdout.write("\n")
- proc printLinesBefore(si: SearchInfo, curMi: MatchInfo, nLines: int,
- replMode=false) =
- # start block: print 'linesBefore' lines before current match `curMi`
- let first = beforePattern(si.buf, curMi.first-1, nLines)
- let lines = splitLines(substr(si.buf, first, curMi.first-1))
- let startLine = curMi.lineBeg - lines.len + 1
- blockHeader(si.filename, curMi.lineBeg, replMode=replMode)
- for i, l in lines:
- lineHeader(si.filename, startLine + i, isMatch = (i == lines.len - 1))
- stdout.write(l)
- if i < lines.len - 1:
- stdout.write("\n")
- proc printLinesAfter(si: SearchInfo, mi: MatchInfo, nLines: int) =
- # finish block: print 'linesAfter' lines after match `mi`
- let s = si.buf
- let last = afterPattern(s, mi.last+1, nLines)
- let lines = splitLines(substr(s, mi.last+1, last))
- if lines.len == 0: # EOF
- stdout.write("\n")
- else:
- stdout.write(lines[0]) # complete the line after match itself
- stdout.write("\n")
- let skipLine = # workaround posix line ending at the end of file
- if last == s.len-1 and s.len >= 2 and s[^1] == '\l' and s[^2] != '\c': 1
- else: 0
- for i in 1 ..< lines.len - skipLine:
- lineHeader(si.filename, mi.lineEnd + i, isMatch = false)
- stdout.write(lines[i])
- stdout.write("\n")
- if linesAfter + linesBefore >= 2 and not newLine: stdout.write("\n")
- proc printBetweenMatches(si: SearchInfo, prevMi: MatchInfo, curMi: MatchInfo) =
- # continue block: print between `prevMi` and `curMi`
- let lines = si.buf.substr(prevMi.last+1, curMi.first-1).splitLines()
- stdout.write(lines[0]) # finish the line of previous Match
- if lines.len > 1:
- stdout.write("\n")
- for i in 1 ..< lines.len:
- lineHeader(si.filename, prevMi.lineEnd + i,
- isMatch = (i == lines.len - 1))
- stdout.write(lines[i])
- if i < lines.len - 1:
- stdout.write("\n")
- proc printContextBetween(si: SearchInfo, prevMi, curMi: MatchInfo) =
- # print context after previous match prevMi and before current match curMi
- let nLinesBetween = curMi.lineBeg - prevMi.lineEnd
- if nLinesBetween <= linesAfter + linesBefore + 1: # print as 1 block
- printBetweenMatches(si, prevMi, curMi)
- else: # finalize previous block and then print next block
- printLinesAfter(si, prevMi, 1+linesAfter)
- printLinesBefore(si, curMi, linesBefore+1)
- proc printReplacement(si: SearchInfo, mi: MatchInfo, repl: string,
- showRepl: bool, curPos: int,
- newBuf: string, curLine: int) =
- printLinesBefore(si, mi, linesBefore+1)
- printMatch(si.fileName, mi)
- printLinesAfter(si, mi, 1+linesAfter)
- stdout.flushFile()
- if showRepl:
- let newSi: SearchInfo = (buf: newBuf, filename: si.filename)
- let miForNewBuf: MatchInfo =
- (first: newBuf.len, last: newBuf.len,
- lineBeg: curLine, lineEnd: curLine, match: "")
- printLinesBefore(newSi, miForNewBuf, linesBefore+1, replMode=true)
- let replLines = countLineBreaks(repl, 0, repl.len-1)
- let miFixLines: MatchInfo =
- (first: mi.first, last: mi.last,
- lineBeg: curLine, lineEnd: curLine + replLines, match: repl)
- printMatch(si.fileName, miFixLines)
- printLinesAfter(si, miFixLines, 1+linesAfter)
- stdout.flushFile()
- proc doReplace(si: SearchInfo, mi: MatchInfo, i: int, r: string;
- newBuf: var string, curLine: var int, reallyReplace: var bool) =
- newBuf.add(si.buf.substr(i, mi.first-1))
- inc(curLine, countLineBreaks(si.buf, i, mi.first-1))
- if optConfirm in options:
- printReplacement(si, mi, r, showRepl=true, i, newBuf, curLine)
- case confirm()
- of ceAbort: quit(0)
- of ceYes: reallyReplace = true
- of ceAll:
- reallyReplace = true
- options.excl(optConfirm)
- of ceNo:
- reallyReplace = false
- of ceNone:
- reallyReplace = false
- options.excl(optConfirm)
- else:
- printReplacement(si, mi, r, showRepl=reallyReplace, i, newBuf, curLine)
- if reallyReplace:
- newBuf.add(r)
- inc(curLine, countLineBreaks(r, 0, r.len-1))
- else:
- newBuf.add(mi.match)
- inc(curLine, countLineBreaks(mi.match, 0, mi.match.len-1))
- proc processFile(pattern; filename: string; counter: var int, errors: var int) =
- var filenameShown = false
- template beforeHighlight =
- if not filenameShown and optVerbose notin options and not oneline:
- printBlockFile(filename)
- stdout.write("\n")
- stdout.flushFile()
- filenameShown = true
- var buffer: string
- if optFilenames in options:
- buffer = filename
- else:
- try:
- buffer = system.readFile(filename)
- except IOError:
- printError "Error: cannot open file: " & filename
- inc(errors)
- return
- if optVerbose in options:
- printFile(filename)
- stdout.write("\n")
- stdout.flushFile()
- var result: string
- if optReplace in options:
- result = newStringOfCap(buffer.len)
- var lineRepl = 1
- let si: SearchInfo = (buf: buffer, filename: filename)
- var prevMi, curMi: MatchInfo
- curMi.lineEnd = 1
- var i = 0
- var matches: array[0..re.MaxSubpatterns-1, string]
- for j in 0..high(matches): matches[j] = ""
- var reallyReplace = true
- while i < buffer.len:
- let t = findBounds(buffer, pattern, matches, i)
- if t.first < 0 or t.last < t.first:
- if optReplace notin options and prevMi.lineBeg != 0: # finalize last match
- printLinesAfter(si, prevMi, 1+linesAfter)
- stdout.flushFile()
- break
- let lineBeg = curMi.lineEnd + countLineBreaks(buffer, i, t.first-1)
- curMi = (first: t.first,
- last: t.last,
- lineBeg: lineBeg,
- lineEnd: lineBeg + countLineBreaks(buffer, t.first, t.last),
- match: buffer.substr(t.first, t.last))
- beforeHighlight()
- inc counter
- if optReplace notin options:
- if prevMi.lineBeg == 0: # no previous match, so no previous block to finalize
- printLinesBefore(si, curMi, linesBefore+1)
- else:
- printContextBetween(si, prevMi, curMi)
- printMatch(si.fileName, curMi)
- stdout.flushFile()
- else:
- let r = replace(curMi.match, pattern, replacement % matches)
- doReplace(si, curMi, i, r, result, lineRepl, reallyReplace)
- i = t.last+1
- prevMi = curMi
- if optReplace in options:
- result.add(substr(buffer, i)) # finalize new buffer after last match
- var f: File
- if open(f, filename, fmWrite):
- f.write(result)
- f.close()
- else:
- quit "cannot open file for overwriting: " & filename
- proc hasRightFileName(path: string): bool =
- let filename = path.lastPathPart
- let ex = filename.splitFile.ext.substr(1) # skip leading '.'
- if extensions.len != 0:
- var matched = false
- for x in items(extensions):
- if os.cmpPaths(x, ex) == 0:
- matched = true
- break
- if not matched: return false
- for x in items(skipExtensions):
- if os.cmpPaths(x, ex) == 0: return false
- if includeFile.len != 0:
- var matched = false
- for x in items(includeFile):
- if filename.match(x):
- matched = true
- break
- if not matched: return false
- for x in items(excludeFile):
- if filename.match(x): return false
- result = true
- proc hasRightDirectory(path: string): bool =
- let dirname = path.lastPathPart
- for x in items(excludeDir):
- if dirname.match(x): return false
- result = true
- proc styleInsensitive(s: string): string =
- template addx =
- result.add(s[i])
- inc(i)
- result = ""
- var i = 0
- var brackets = 0
- while i < s.len:
- case s[i]
- of 'A'..'Z', 'a'..'z', '0'..'9':
- addx()
- if brackets == 0: result.add("_?")
- of '_':
- addx()
- result.add('?')
- of '[':
- addx()
- inc(brackets)
- of ']':
- addx()
- if brackets > 0: dec(brackets)
- of '?':
- addx()
- if s[i] == '<':
- addx()
- while s[i] != '>' and s[i] != '\0': addx()
- of '\\':
- addx()
- if s[i] in strutils.Digits:
- while s[i] in strutils.Digits: addx()
- else:
- addx()
- else: addx()
- proc walker(pattern; dir: string; counter: var int, errors: var int) =
- if existsDir(dir):
- for kind, path in walkDir(dir):
- case kind
- of pcFile:
- if path.hasRightFileName:
- processFile(pattern, path, counter, errors)
- of pcLinkToFile:
- if optFollow in options and path.hasRightFileName:
- processFile(pattern, path, counter, errors)
- of pcDir:
- if optRecursive in options and path.hasRightDirectory:
- walker(pattern, path, counter, errors)
- of pcLinkToDir:
- if optFollow in options and optRecursive in options and
- path.hasRightDirectory:
- walker(pattern, path, counter, errors)
- elif existsFile(dir):
- processFile(pattern, dir, counter, errors)
- else:
- printError "Error: no such file or directory: " & dir
- inc(errors)
- proc reportError(msg: string) =
- printError "Error: " & msg
- quit "Run nimgrep --help for the list of options"
- proc writeHelp() =
- stdout.write(Usage)
- stdout.flushFile()
- quit(0)
- proc writeVersion() =
- stdout.write(Version & "\n")
- stdout.flushFile()
- quit(0)
- proc checkOptions(subset: TOptions, a, b: string) =
- if subset <= options:
- quit("cannot specify both '$#' and '$#'" % [a, b])
- when defined(posix):
- useWriteStyled = terminal.isatty(stdout)
- # that should be before option processing to allow override of useWriteStyled
- for kind, key, val in getopt():
- case kind
- of cmdArgument:
- if options.contains(optStdin):
- filenames.add(key)
- elif pattern.len == 0:
- pattern = key
- elif options.contains(optReplace) and replacement.len == 0:
- replacement = key
- else:
- filenames.add(key)
- of cmdLongOption, cmdShortOption:
- case normalize(key)
- of "find", "f": incl(options, optFind)
- of "replace", "!": incl(options, optReplace)
- of "peg":
- excl(options, optRegex)
- incl(options, optPeg)
- of "re":
- incl(options, optRegex)
- excl(options, optPeg)
- of "rex", "x":
- incl(options, optRex)
- incl(options, optRegex)
- excl(options, optPeg)
- of "recursive", "r": incl(options, optRecursive)
- of "follow": incl(options, optFollow)
- of "confirm": incl(options, optConfirm)
- of "stdin": incl(options, optStdin)
- of "word", "w": incl(options, optWord)
- of "ignorecase", "i": incl(options, optIgnoreCase)
- of "ignorestyle", "y": incl(options, optIgnoreStyle)
- of "ext": extensions.add val.split('|')
- of "noext": skipExtensions.add val.split('|')
- of "excludedir", "exclude-dir": excludeDir.add rex(val)
- of "includefile", "include-file": includeFile.add rex(val)
- of "excludefile", "exclude-file": excludeFile.add rex(val)
- of "nocolor": useWriteStyled = false
- of "color":
- case val
- of "auto": discard
- of "never", "false": useWriteStyled = false
- of "", "always", "true": useWriteStyled = true
- else: reportError("invalid value '" & val & "' for --color")
- of "colortheme":
- colortheme = normalize(val)
- if colortheme notin ["simple", "bnw", "ack", "gnu"]:
- reportError("unknown colortheme '" & val & "'")
- of "beforecontext", "before-context", "b":
- try:
- linesBefore = parseInt(val)
- except ValueError:
- reportError("option " & key & " requires an integer but '" &
- val & "' was given")
- of "aftercontext", "after-context", "a":
- try:
- linesAfter = parseInt(val)
- except ValueError:
- reportError("option " & key & " requires an integer but '" &
- val & "' was given")
- of "context", "c":
- try:
- linesContext = parseInt(val)
- except ValueError:
- reportError("option --context requires an integer but '" &
- val & "' was given")
- of "newline", "l": newLine = true
- of "oneline": oneline = true
- of "group", "g": oneline = false
- of "verbose": incl(options, optVerbose)
- of "filenames": incl(options, optFilenames)
- of "help", "h": writeHelp()
- of "version", "v": writeVersion()
- else: reportError("unrecognized option '" & key & "'")
- of cmdEnd: assert(false) # cannot happen
- checkOptions({optFind, optReplace}, "find", "replace")
- checkOptions({optPeg, optRegex}, "peg", "re")
- checkOptions({optIgnoreCase, optIgnoreStyle}, "ignore_case", "ignore_style")
- checkOptions({optFilenames, optReplace}, "filenames", "replace")
- linesBefore = max(linesBefore, linesContext)
- linesAfter = max(linesAfter, linesContext)
- if optStdin in options:
- pattern = ask("pattern [ENTER to exit]: ")
- if pattern.len == 0: quit(0)
- if optReplace in options:
- replacement = ask("replacement [supports $1, $# notations]: ")
- if pattern.len == 0:
- reportError("empty pattern was given")
- else:
- var counter = 0
- var errors = 0
- if filenames.len == 0:
- filenames.add(os.getCurrentDir())
- if optRegex notin options:
- if optWord in options:
- pattern = r"(^ / !\letter)(" & pattern & r") !\letter"
- if optIgnoreStyle in options:
- pattern = "\\y " & pattern
- elif optIgnoreCase in options:
- pattern = "\\i " & pattern
- let pegp = peg(pattern)
- for f in items(filenames):
- walker(pegp, f, counter, errors)
- else:
- var reflags = {reStudy}
- if optIgnoreStyle in options:
- pattern = styleInsensitive(pattern)
- if optWord in options:
- # see https://github.com/nim-lang/Nim/issues/13528#issuecomment-592786443
- pattern = r"(^|\W)(:?" & pattern & r")($|\W)"
- if {optIgnoreCase, optIgnoreStyle} * options != {}:
- reflags.incl reIgnoreCase
- let rep = if optRex in options: rex(pattern, reflags)
- else: re(pattern, reflags)
- for f in items(filenames):
- walker(rep, f, counter, errors)
- if errors != 0:
- printError $errors & " errors"
- if counter == 1:
- stdout.write("\n")
- stdout.write($counter & " matches\n")
- if errors != 0:
- quit(1)
|