仿 Linux 的 Powershell 小工具--nl
Tags: powershel, linux
Published: 2022-10-10 12:50:13

Linxu 世界中有許多有趣的小工具, 剛好可以拿來當學習 Powershell 撰寫腳本檔的範例, 本篇就以 [nl](https://man7.org/linux/man-pages/man1/nl.1.html) 為目標。

## 幫文字檔編行號的 nl

nl 是個可以幫文字檔編行號的小工具, 如果你需要將原始碼放到文件上, 那這個小工具就可以幫上你的忙, 最簡單的用法就像是這樣：

```c
$ nl test.c
     1  #include <locale.h>
     2  #include <stdio.h>
     3  #include <wchar.h>

     4  int main() {
     5    char* loc = setlocale(LC_CTYPE, "C.UTF-8");

     6    printf("%s\n", loc);
     7    wchar_t str[] = L"扣人心弦CD";
     8    printf("total：%d bytes\n", sizeof(str));
     9    wprintf(L"%ls is %ld chars.", str, wcslen(str));
    10  }
```

預設的情況下它會以 6 位數從 1 開始編行號, 並且在行號後面加上定位鍵再顯示內容。如果你希望客製化輸出的格式, 可以使用以下常用的選項：

|選項名稱|可能值|說明
|---|---|---|
|-s|字串|行號與內文間的分隔字串, 預設是 "\t"|
|-v|數值|起始行號, 預設從 1 開始|
|-w|數值|行號寬度,預設為 6|
|-n|字串|對齊格式：<br/>rn：靠右對齊 (預設)<br/>rz：靠右對齊, 開頭補 0<br/>ln：靠左對齊|
|-b|字元|a 每一行都編號<br/>n 不加編號<br/>t 非空白行才編號 (預設)|

上表並未列出所有的選項, 有興趣可自行參考, 下一節 Powershell 的實作也僅以上述選項為準。

## Powershell 的簡易實作

在實作 Powershell 版本的時候, 我們盡量簡化內容, 這樣才符合小工具的稱呼。

### 選項的定義

以下是 Powershell 版本 nl 工具的選項定義：

```shell
param(
  [Parameter(ValueFromRemainingArguments=$True, position=0)]
  [alias("path")]$pathes,                               # all unnames Parameter
  [int]$w=6,                                            # digits width
  [Parameter(ValueFromPipeline=$true)][String[]]$lines, # pipelined input
  [String]$s="`t",                                      # separator 
  [ValidateScript({$_ -ge 0})][int]$v=1,                # starting number
  [ValidateSet("ln","rn","rz")][String]$n="rn",         # adjustment
  [ValidateSet("a","t","n")][String]$b="t"              # number style
)
```

Powershell 的好處是可以直接用定義變數的方式定義命令行的[選項](https://ss64.com/ps/syntax-args.html), 它會幫你剖析命令行, 取出個別的選項轉換成正確的型別後設定給變數。因此, 如果這樣執行腳本檔：

```shell
.\nl.ps1 -s "--"
```
  
變數 `$s` 的內容就是 "--", 而且還可以直接設定預設值, 如果像是這樣執行腳本檔：

```shell
.\ml.ps1
```

那麼 `$s` 就會是預設的 "&#96;t"。
    
選項定義時還可以指定[驗證方式](https://ss64.com/ps/syntax-function-advanced.html), 這裡我們使用了兩種驗證方式：

```shell
[ValidateSet("ln","rn","rz")][String]$n="rn"
```

這表示 `-n` 選項只能接受 "ln","rn","rz" 其中的一種。

```shell
[ValidateScript({$_ -ge 0})][int]$v=1
```
這表示 `-v` 選項的值可由括號內的程式區塊來驗證, 這裡就是很簡單的確認參值是 0 或正整數。
    
你也可以指定哪個選項可以接收從管線傳入的資料：

```shell
[Parameter(ValueFromPipeline=$true)][String[]]$lines, # pipelined input
```

這裡 `$lines` 可以依序接受從管線傳入的一行行字串。
    
為了讓這個小工具符合 Powershell 的慣例, 採用 `-path` 指定檔名, 並且指定位置編號為 0, 表示是第一個位置選項, 同時加上 `ValueFromRemainingArguments=$True`的屬性, 剩餘沒有指定選項名稱的選項就會自動集合成一個陣列對應到 `-path`。

## 處理選項與格式化字串

要注意的是, 因為我們的腳本檔可以接受從管線輸入的字串, 所以必須要採用 [`begin/prcoess/end`](https://ss64.com/ps/syntax-function-input.html) 程式區塊, `begin` 和 `end` 都只會執行一次, 但對於收到的每一個字串, 都會執行一次 `process`。

我們在 `begin` 中根據選項設定必要的變數：

```shell
begin {
  $paddind = ""                         # defualt no padding
  if($n -eq "rz"){$paddind = ":d$w"}    # right adjustment with zero padding
  if($n -eq "ln"){$w = -$w}             # left adjustment
  $curr = 0                             # absolute start number
```

`-n` 選項決定是否要加上 `-f` 格式化運算器中可在數字左方補零的 "d" 格式, 以及是否要將寬度變成負數, 讓數字向左對齊。

接著定義根據選項輸出單一行的工具函式：

```shell
  function printLine {
    param(
      [String]$line
    )
    if($line -eq "" -and $b -eq "t") {  # -b t: nonempty lines
      write-host ""
    } 
    else {
      $numbers = ($v + $script:curr)        # -b a: all lines
      if($b -eq 'n') {$numbers = ""}        # -b n: no numbers
      "{0,$w$paddindh}$s{1}" -f $numbers, $line
      $script:curr += 1
    }
  }
}
```

1. 如果是空白行且有指定 `-b t` 選項, 就單純印出空白行, 不加行號。
2. 如果並非上述狀況, 再根據是否有指定 `-b n` 選項決定要不要加上行號。
3. 最後根據選項使用 [`-f` 格式化運算器](https://ss64.com/ps/syntax-f-operator.html)幫我們編排這一行內容。

## 處理從管線收到的陣列

由於 nl 是以命令列選項優先, 有指定檔名的前提下並不會處理管線送來的內容, 所以在 `process` 中會先確認 `$pathes` 陣列內的元素數量：

```shell
process{
  if($pathes.count -eq 0) {  # if no pathes specified
    if($lines.count -gt 0) { # check if there's any pipelined input
      printLine $lines[0]
    }
  }
}
```

接著要判斷是否真的有收到管線送來的內容, 這是因為即使沒有管線來的資料, `process` 區塊也會執行一次, 如果不做判斷, 就會多輸出一行空白行, 讓結果不正確。我們特別定義以陣列接收管線資料, 這樣當沒有資料從管線送來時, 陣列內的元素數量就會是 0, 如此就可以區別是否有從管線接收到資料。

最後透過剛剛定義的 `printLine` 工具函式輸出收到的這一行內容。

## 處理命令列指定的檔案與萬用字元

在 `end` 中就依序處理命令列中指定的各個檔案：

```shell
end{
  foreach($path in $pathes) {
    $allPathes = get-item $path
    foreach($filename in $allPathes) {
      if(test-path -pathtype leaf $filename) {
        $contents = get-content -path $filename
        foreach($line in $contents) {
          printLine $line 
        }
      }
      elseif (test-path -pathtype container $filename){
        write-error ("nl :{0}: Is a directory" -f $filename)
      }
      else {
        write-error ("nl :{0}: No such file" -f $filename)
      }
    }
  }
}
```

1. 為了讓使用者可以在檔案名稱中使用萬用字元, 先以 [`get-item`](https://ss64.com/ps/get-item.html) 幫我們處理萬用字元, 取得所有的檔案清單。
2. 接著針對檔案清單一一處理, 首先使用 [`test-path`](https://ss64.com/ps/test-path.html) 的 `-pathtype leaf` 參數確認指定的檔案存在, 而且不是資料夾, 就將檔案內容讀入, 一一輸出每一行。
3. 如果透過 `get-path` 加上 `-pathtype container` 發現指定的檔名是資料夾, 就輸出錯誤訊息。
4. 如果指定的檔案不存在, 也送出錯誤訊息。

## 完整程式

```shell
param(
  [Parameter(ValueFromRemainingArguments=$True, position=0)]
  [alias("path")]$pathes,                               # all unnames Parameter
  [int]$w=6,                                            # digits width
  [Parameter(ValueFromPipeline=$true)][String[]]$lines, # pipelined input
  [String]$s="`t",                                      # separator 
  [ValidateScript({$_ -ge 0})][int]$v=1,                # starting number
  [ValidateSet("ln","rn","rz")][String]$n="rn",         # adjustment
  [ValidateSet("a","t","n")][String]$b="t"              # number style
)

begin{
  $paddind = ""                         # defualt no padding
  if($n -eq "rz"){$paddind = ":d$w"}    # right adjustment with zero padding
  if($n -eq "ln"){$w = -$w}             # left adjustment
  $curr = 0                             # absolute start number

  function printLine {
    param(
      [String]$line
    )
    if($line -eq "" -and $b -eq "t") {  # -b t: nonempty lines
      write-host ""
    } 
    else {
      $numbers = ($v + $script:curr)        # -b a: all lines
      if($b -eq 'n') {$numbers = ""}        # -b n: no numbers
      "{0,$w$paddindh}$s{1}" -f $numbers, $line
      $script:curr += 1
    }
  }
}

process{
  if($pathes.count -eq 0) {  # if no pathes specified
    if($lines.count -gt 0) { # check if there's any pipelined input
      printLine $lines[0]
    }
  }
}

end{
  foreach($path in $pathes) {
    $allPathes = get-item $path
    foreach($filename in $allPathes) {
      if(test-path -pathtype leaf $filename) {
        $contents = get-content -path $filename
        foreach($line in $contents) {
          printLine $line 
        }
      }
      elseif (test-path -pathtype container $filename){
        write-error ("nl :{0}: Is a directory" -f $filename)
      }
      else {
        write-error ("nl :{0}: No such file" -f $filename)
      }
    }
  }
}

```