仿 Linux 的 Powershell 小工具--wc
Tags: powershell, linux
Published: 2022-10-21 10:41:06

[`wc`](https://linux.die.net/man/1/wc) 也是另一個 \*nix 世界常用的小工具, 也很適合當成模擬實作的對象。

## 統計文字的工具--wc

最簡單的用法類似這樣：

```shell
$ wc *.txt
 0  1  5 a.txt
 1  1  6 hello.txt
 1  1  6 oneline_pico.txt
 1  1  6 oneline.txt
 2  1  6 test1.txt
 1  3 14 test.txt
 6  8 43 total
```

預設它會幫你統計個別檔案的行數、單字數、位元組數, 以及全部檔案的總計數。其中**單字**指的是以**空白類字元**區隔開的連續字元, 例如 "hello  world" 就有 2 個單字。你也可以透過選項指定要顯示哪一項統計數字：

|選項|說明|
|---|---|
|-c||位元組數|
|-m|字元數|
|-l|行數|
|-w|單字數|

如果只有單一檔案, 因為總計數和此檔案的計數相同, 就不會印出總計數。如果是從管線送入資料, 則沒有檔案名稱, 最後一欄就不會顯示檔名。

## Powershell 的簡易實作

以下實作和[仿 Linux 的 Powershell 小工具--nl](https://dev.to/codemee/fang-linux-de-powershell-xiao-gong-ju-2ni3) 一文類似, 重複的部分不再詳述, 僅針對特別處說明。

### 命令列選項

以下是命令列參數的定義：

```
param(
  [Parameter(ValueFromRemainingArguments=$True, position=0)]
  [alias("path")]$pathes,  # all unnames Parameter
  [switch]$c=$false,       # bytes count
  [Parameter(ValueFromPipeline=$true)][String[]]$txt, 
  [switch]$m=$false,       # chars count
  [switch]$l=$false,       # lines count
  [switch]$w=$false        # words count
)
```

個別變數對應到上述表格中的選項, 並同樣可從管線接收資料。

### 變數初值與預設選項

首先設定變數的初值, 由於個別計數欄位的寬度要由總計數來決定, 因此必須先算出總計數後才能輸出結果, 所以我們建立了 4 個以 `all` 開頭命名的陣列來儲存每一個檔案的各項計數, 以便能在得到總計數後輸出個別檔案的計數值, 這 4 個陣列一開始都設為空的陣列。另外的 4 個變數則是用來記錄當前檔案的各項計數。我們也檢查沒有指定任何選項的時候, 啟用預設的選項顯示行數、單字數與位元組數：

```shell
begin{
  $allLines = $allChars = $allBytes = $allWords = @()
  $lines = $words = $chars = $bytes = 0
  if(-not ($m -or $c -or $l -or $w)) {  # no switches on
    $c = $l = $w = $true                # turn on default swtches
  }
}
```

### 處理從管線輸入的資料

如果在命令列沒有指定檔案名稱, 而且管線有資料傳入, 就以管線資料為處理對象：

```shell
process{
  if($pathes.count -eq 0) { # if no pathes specified
    if($txt.count -gt 0) {  # check if there's any pipelined input
      $txt[0] += [Environment]::NewLine
      $lines += 1
      $chars += $txt[0].length
      $all = $txt[0] | select-string -allmatches -pattern "[^\s]+" 
      $words += $all.matches.length
      if($PSDefaultParameterValues['Out-File:Encoding']) {
        $enc = $PSDefaultParameterValues['Out-File:Encoding']
      }
      else {
        $enc = [System.Text.Encoding]::UTF8
      }
      $bytesCurrLine = $enc.getbytecount($txt[0])
      $bytes += $bytesCurrLine
    }
  }
}
```

1. 每一行文字先透過 [`[Environment]::NewLine`](https://learn.microsoft.com/zh-tw/dotnet/api/system.environment.newline?view=net-6.0) 添加行尾的換行字元再計算。
1. 單字的計算是透過 [`select-string`](https://ss64.com/ps/select-string.html) 搜尋規則表達式, 找出輸入的資料中有多少段非空白類字元串接的文字。
2. 位元組數的計算是假設以 [`out-file`](https://ss64.com/ps/out-file.html) 輸出到檔案的大小來計算, 所以我們先確認使用者是否有變更過預設採用的輸出編碼, 若是沒有則採用 `out-file` 預設的 UTF8 編碼, 並叫用編碼物件的 [`GetByteCount()`](https://learn.microsoft.com/en-us/dotnet/api/system.text.encoding.getbytecount?view=netframework-4.8) 來計算位元組數。

### 計算陣列總和與整數總位數的工具函式

由於輸出時我們需要所有檔案的總計數, 因此特別寫了一個利用 [`measure-object`](https://ss64.com/ps/measure-object.html) 的 `-sum` 參數計算陣列內各元素總和的工具函式。

另外, 因為輸出時各欄位數字佔的寬度是由總計數的位數來決定, 因此我們也寫了一個利用 [`[math]::floor()`](https://learn.microsoft.com/zh-tw/dotnet/api/system.math.floor?view=net-6.0) 搭配 [`[math]::log()`](https://learn.microsoft.com/zh-tw/dotnet/api/system.math.log?view=net-6.0) 計算位數的工具程式。計算時要特別留意, 0 的 log 值會是負無限值, 所以我們會把 0 用 1 替換計算 log 值, 以免出錯：

```shell
end{
  function sum {       # sum of a array
    param([int[]]$ary)
    return ($ary | measure-object -sum).sum
  }

  function digits {    # digits of a integer
    param([int]$num)
    return ([math]::floor([math]::Log([math]::max($num, 1), 10)) + 1)
  }
```

### 依照指定路徑統計

接著判斷如果沒有在命令列指定檔案, 就將統計完的管線數據加入個別的陣列中, 否則就依照指定的檔案一一處理。

```shell
  if($pathes.count -eq 0) { # if no pathes specified
    $allBytes += $bytes
    $allWords += $words
    $allLines += $lines
    $allChars += $chars
  }
  else {
    $allPathes = @()
    foreach($path in $pathes) { $allPathes += get-item $path }
    foreach($filename in $allPathes) {
      $lines = $chars = $words = $bytes = 0
      if(test-path -pathtype leaf $filename) {
        $bytes = (get-item $filename).length
        $contents = get-content -path $filename
        $lines = $contents.count
        $all = $contents | select-string -allmatches -pattern "[^\s]+"
        $words = $all.matches.length
        $contents = get-content -raw -path $filename
        $chars = $contents.length
      }
      $allBytes += $bytes
      $allWords += $words
      $allLines += $lines
      $allChars += $chars
    }
  }
```

1. 首先透過 `get-item` 幫我們處理檔名中的萬用字元, 並從取得的檔案清單中一一檢查檔案是否存在, 而且不是資料夾, 並統計檔案內的文字。
2. 檔案的位元組數可以直接透過 `get-item` 傳回物件的 `length` 屬性取得。
3. 每個檔案統計完後就將數據加入陣列中記錄下來。

### 計算總數以及個別欄位寬度

所有檔案都處理完成後, 就可以利用前面介紹過的工具函式計算總和, 並且推算欄位寬度, 其中行數的前面會多空 2 格、其餘欄位則是與前一個欄位相隔一個空格：

```shell
  $totalBytes = sum $allBytes
  $totalChars = sum $allChars
  $totalWords = sum $allWords
  $totalLines = sum $allLines
  $wLine = (digits $totalLines) + 2
  $wWord = (digits $totalWords) + 1
  $wChar = (digits $totalChars) + 1
  $wByte = (digits $totalBytes) + 1
```

### 輸出結果

最後輸出結果, 如果該檔名是資料夾, 會顯示錯誤的訊息, 而不會遞迴進入該資料夾內統計數據。若是不存在的檔案, 也一樣顯示錯誤訊息。若非以上兩者, 就依照命令列選項以指定的格式輸出統計數據：

```shell
  for($i = 0; $i -lt $allPathes.count;$i++) {
    if(test-path -pathtype container $allPathes[$i]) { 
      "wc: {0}: Is a directory" -F $allPathes[$i].name
      continue
    }
    if(-not (test-path $allPathes[$i])) { 
      "wc: {0}: No such file or directory" -F $allPathes[$i].name
      continue
    }
    if($l) {$txt = "{0, $wLine}" -F $allLines[$i]}
    if($w) {$txt += "{0, $wWord}" -F $allWords[$i]}
    if($m) {$txt += "{0, $wChar}" -F $allChars[$i]}
    if($c) {$txt += "{0, $wByte}" -F $allBytes[$i]}
    $txt += " {0}" -F $allPathes[$i].name
    write-host $txt
  }
  if($l) {$txt = "{0, $wLine}" -F $totalLines}
  if($w) {$txt += "{0, $wWord}" -F $totalWords}
  if($m) {$txt += "{0, $wChar}" -F $totalChars}
  if($c) {$txt += "{0, $wByte}" -F $totalBytes}
  if($allPathes.count -gt 1) {$txt += " total"}
  if(($allPathes.count -gt 1) -or ($totalLines -gt 0)) {
    write-host $txt
  }
}
```

要特別留意的是, 如果是從管線輸入, 就不會顯示檔名。另外, 如果只有單一檔案, 就不會顯示總計數。

## 完整程式

完整的程式如下：

```shell
param(
  [Parameter(ValueFromRemainingArguments=$True, position=0)]
  [alias("path")]$pathes,  # all unnames Parameter
  [switch]$c=$false,       # bytes count
  [Parameter(ValueFromPipeline=$true)][String[]]$txt, 
  [switch]$m=$false,       # chars count
  [switch]$l=$false,       # lines count
  [switch]$w=$false        # words count
)

begin{
  $allLines = $allChars = $allBytes = $allWords = @()
  $lines = $words = $chars = $bytes = 0
  if(-not ($m -or $c -or $l -or $w)) {  # no switches on
    $c = $l = $w = $true                # turn on default swtches
  }
}

process{
  if($pathes.count -eq 0) { # if no pathes specified
    if($txt.count -gt 0) {  # check if there's any pipelined input
      $txt[0] += [Environment]::NewLine
      $lines += 1
      $chars += $txt[0].length
      $all = $txt[0] | select-string -allmatches -pattern "[^\s]+" 
      $words += $all.matches.length
      if($PSDefaultParameterValues['Out-File:Encoding']) {
        $enc = $PSDefaultParameterValues['Out-File:Encoding']
      }
      else {
        $enc = [System.Text.Encoding]::UTF8
      }
      $bytesCurrLine = $enc.getbytecount($txt[0])
      $bytes += $bytesCurrLine
    }
  }
}

end{
  function sum {       # sum of a array
    param([int[]]$ary)
    return ($ary | measure-object -sum).sum
  }

  function digits {    # digits of a integer
    param([int]$num)
    return ([math]::floor([math]::Log([math]::max($num, 1), 10)) + 1)
  }

  if($pathes.count -eq 0) { # if no pathes specified
    $allBytes += $bytes
    $allWords += $words
    $allLines += $lines
    $allChars += $chars
  }
  else {
    $allPathes = @()
    foreach($path in $pathes) { $allPathes += get-item $path }
    foreach($filename in $allPathes) {
      $lines = $chars = $words = $bytes = 0
      if(test-path -pathtype leaf $filename) {
        $bytes = (get-item $filename).length
        $contents = get-content -path $filename
        $lines = $contents.count
        $all = $contents | select-string -allmatches -pattern "[^\s]+"
        $words = $all.matches.length
        $contents = get-content -raw -path $filename
        $chars = $contents.length
      }
      $allBytes += $bytes
      $allWords += $words
      $allLines += $lines
      $allChars += $chars
    }
  }

  $totalBytes = sum $allBytes
  $totalChars = sum $allChars
  $totalWords = sum $allWords
  $totalLines = sum $allLines
  $wLine = (digits $totalLines) + 2
  $wWord = (digits $totalWords) + 1
  $wChar = (digits $totalChars) + 1
  $wByte = (digits $totalBytes) + 1

  for($i = 0; $i -lt $allPathes.count;$i++) {
    if(test-path -pathtype container $allPathes[$i]) { 
      "wc: {0}: Is a directory" -F $allPathes[$i].name
      continue
    }
    if(-not (test-path $allPathes[$i])) { 
      "wc: {0}: No such file or directory" -F $allPathes[$i].name
      continue
    }
    if($l) {$txt = "{0, $wLine}" -F $allLines[$i]}
    if($w) {$txt += "{0, $wWord}" -F $allWords[$i]}
    if($m) {$txt += "{0, $wChar}" -F $allChars[$i]}
    if($c) {$txt += "{0, $wByte}" -F $allBytes[$i]}
    $txt += " {0}" -F $allPathes[$i].name
    write-host $txt
  }
  if($l) {$txt = "{0, $wLine}" -F $totalLines}
  if($w) {$txt += "{0, $wWord}" -F $totalWords}
  if($m) {$txt += "{0, $wChar}" -F $totalChars}
  if($c) {$txt += "{0, $wByte}" -F $totalBytes}
  if($allPathes.count -gt 1) {$txt += " total"}
  if(($allPathes.count -gt 1) -or ($totalLines -gt 0)) {
    write-host $txt
  }
}
```