DEV Community

codemee
codemee

Posted on • Edited on

仿 Linux 的 Powershell 小工具--nl

Linxu 世界中有許多有趣的小工具, 剛好可以拿來當學習 Powershell 撰寫腳本檔的範例, 本篇就以 nl 為目標。

幫文字檔編行號的 nl

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

$ 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  }
Enter fullscreen mode Exit fullscreen mode

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

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

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

Powershell 的簡易實作

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

選項的定義

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

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
)
Enter fullscreen mode Exit fullscreen mode

Powershell 的好處是可以直接用定義變數的方式定義命令行的選項, 它會幫你剖析命令行, 取出個別的選項轉換成正確的型別後設定給變數。因此, 如果這樣執行腳本檔:

.\nl.ps1 -s "--"
Enter fullscreen mode Exit fullscreen mode

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

.\ml.ps1
Enter fullscreen mode Exit fullscreen mode

那麼 $s 就會是預設的 "`t"。

選項定義時還可以指定驗證方式, 這裡我們使用了兩種驗證方式:

[ValidateSet("ln","rn","rz")][String]$n="rn"
Enter fullscreen mode Exit fullscreen mode

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

[ValidateScript({$_ -ge 0})][int]$v=1
Enter fullscreen mode Exit fullscreen mode

這表示 -v 選項的值可由括號內的程式區塊來驗證, 這裡就是很簡單的確認參值是 0 或正整數。

你也可以指定哪個選項可以接收從管線傳入的資料:

[Parameter(ValueFromPipeline=$true)][String[]]$lines, # pipelined input
Enter fullscreen mode Exit fullscreen mode

這裡 $lines 可以依序接受從管線傳入的一行行字串。

為了讓這個小工具符合 Powershell 的慣例, 採用 -path 指定檔名, 並且指定位置編號為 0, 表示是第一個位置選項, 同時加上 ValueFromRemainingArguments=$True的屬性, 剩餘沒有指定選項名稱的選項就會自動集合成一個陣列對應到 -path

處理選項與格式化字串

要注意的是, 因為我們的腳本檔可以接受從管線輸入的字串, 所以必須要採用 begin/prcoess/end 程式區塊, beginend 都只會執行一次, 但對於收到的每一個字串, 都會執行一次 process

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

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
Enter fullscreen mode Exit fullscreen mode

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

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

  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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. 如果是空白行且有指定 -b t 選項, 就單純印出空白行, 不加行號。
  2. 如果並非上述狀況, 再根據是否有指定 -b n 選項決定要不要加上行號。
  3. 最後根據選項使用 -f 格式化運算器幫我們編排這一行內容。

處理從管線收到的陣列

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

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]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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)
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. 為了讓使用者可以在檔案名稱中使用萬用字元, 先以 get-item 幫我們處理萬用字元, 取得所有的檔案清單。
  2. 接著針對檔案清單一一處理, 首先使用 test-path-pathtype leaf 參數確認指定的檔案存在, 而且不是資料夾, 就將檔案內容讀入, 一一輸出每一行。
  3. 如果透過 get-path 加上 -pathtype container 發現指定的檔名是資料夾, 就輸出錯誤訊息。
  4. 如果指定的檔案不存在, 也送出錯誤訊息。

完整程式

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)
      }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Top comments (0)