Try   HackMD

コマンドと引数の分解、環境変数PATHから探索、外部コマンドと内部コマンド - シェルもどきをgoで自作する #2

前回の続き、今回やること

前回
シェルってなに?コマンドラインインタプリタってなに? - シェルもどきを自作する#1
に引き続き、「シェルとは何か?コマンドラインインタプリタとは何か?」について「シェルもどき(oreshell)」をgoで作りながら学ぶ。

前回作った原始的なシェルではいくつか期待しない動作が起きた。
今回はそのうちの以下の事象について対応する。

  • 「cat main.go」と入力した場合にエラーになった。
    • -> 原因 : コマンド部分と引数部分の分解をしていないため。
  • 「ls」と入力した場合にエラーになった。
  • -> 原因 : コマンドを環境変数PATHから探索していないため。
  • 「cd ..」と入力した場合にエラーになった。
    • -> 原因: 内部コマンドに対応していないため。

コマンド部分と引数部分の分解

現状の実装ではユーザが入力した文字列をそのまま全部一つの文字列としてOSカーネルに渡してプログラムの起動を要求している。
正しくは、コマンド部分と引数部分を分解して渡す必要がある。(go os.StartProcess())

□修正前

  // ずっとループ
  for {
    // プロンプトを表示してユーザに入力を促す
    (略)

    // 標準入力から文字列(コマンド)を読み込む
    line, _, err := reader.ReadLine()
    (略)
    // 入力文字列に該当するプログラムを探して起動する
    process, err := os.StartProcess(string(line), []string{ string(line) }, &procAttr)
    (略)
  }

□修正後

  // ずっとループ
  for {
    // プロンプトを表示してユーザに入力を促す
    (略)

    // 標準入力から文字列(コマンド)を読み込む
    line, _, err := reader.ReadLine()
    (略)
    words := strings.Split(string(line), " ")
    (略)
    // 入力文字列に該当するプログラムを探して起動する
    process, err := os.StartProcess(words[0], words, &procAttr)
    (略)
  }

ユーザが入力した文字列を、空白毎に分解している。

現時点ではユーザが入力した文字列を単純に空白毎に分解したが、この「シェルもどき」を実用的なシェルするためにはもっと複雑な構文に対応する必要がある。
もっと複雑な構文に対応するためには、字句解析構文解析を実装する必要があるが、いまはそこまで考えない。

環境変数 PATHから探索

環境変数とは。

(wikipediaより)

環境変数(かんきょうへんすう、英語: environment variable)はオペレーティングシステム (OS) が提供するデータ共有機能の一つ。
OS上で動作するタスク(プロセス)がデータを共有するための仕組みである。
特にタスクに対して外部からデータを与え、タスクの挙動・設定を変更するために用いる。

すべてのプロセスは環境変数を持つ。
環境変数はキーと値のペアで構成する。
環境変数の例としてPATHがある。

(atmarkITの記事より)

コマンドサーチパス(PATH)って何?
「コマンドファイルをパス付きで指定する」ということは、例えば「ls」コマンドならば「/bin/ls」と入力する、ということになります。 しかし、よく使うコマンドを毎回パス付きで指定するのは非常に面倒です。
Linuxでは、「コマンドサーチパス(コマンド検索パス)に登録してあるディレクトリにあるファイルは、パス名を省略してよい」という決まりになっています。「パス」だけで、このコマンドサーチパスを指すこともあります。
コマンドサーチパスは、「PATH」という名前の「環境変数」に保存されています。

プログラムを起動する場合は、そのプログラムの場所(絶対パス)を知る必要がある。
シェル、またはOSカーネルは、ユーザが指定した文字列を以下の表の通り絶対パスに変換したあと、プログラムの起動を試みる。

ユーザが指定したコマンド文字列 絶対パスにするための処理
絶対パス (そのまま)
相対パス シェルの現在のカレントディレクトリと合成して絶対パスに変換する。
プログラムファイル名 PATHに登録されたパス群からプログラムファイル名を探し、見つかったらそのパスとプログラムファイル名を合成して絶対パスに変換する。

このパスの合成/PATHからの探索を、自身でやっているシェルもあるし、OSカーネルにやらせているシェルもある(っぽい)。
(OSカーネルにやらせる linuxの場合はexecvp()、execlp()を使う。 -> wikipedia exec)

「シェルもどき」では勉強のためにシェル自身でやることにした。

// 指定された文字列が相対パスである場合、絶対パスを取得する。取得したパスが存在しなければエラーを返す。
// 指定された文字列がファイル名であるなら、環境変数PATHと連結して絶対パスを取得し存在すればそれを返す。存在しなければエラーを返す。
func absPathWithPATH(target string) (targetAbsPath string, err error) {

  // パスとファイル名を分離
  targetFileName := filepath.Base(target)
  log.Printf("target %s\n", target)
  log.Printf("targetFileName %s\n", targetFileName)

  // 指定された文字列がパスである場合
  if target != targetFileName {

    // 絶対パスの場合
    if filepath.IsAbs(target) {
      targetAbsPath = target
    // 相対パスの場合
    } else {
      targetAbsPath, err = filepath.Abs(target)
      if err != nil {
        log.Fatalf("filepath.Abs %v", err)
      }
    }

    if fileIsExist(targetAbsPath) {
      return targetAbsPath, nil
    } else {
      return "", fmt.Errorf("%s: no such file or directory", targetAbsPath)
    }
  }

  // 指定された文字列がファイル名である場合

  // 指定されたファイル名を環境変数パスの中から探す
  for _, path := range filepath.SplitList(os.Getenv("PATH")) {
    log.Printf("%s\n", path)
    targetAbsPath = filepath.Join(path, targetFileName)
    if fileIsExist(targetAbsPath) {
      log.Printf("find in PATH %s\n", targetAbsPath)
      return targetAbsPath, nil
    }
  }
  return "", fmt.Errorf("%s: no such file or directory", targetFileName)
}

外部コマンドと内部コマンド

シェルで実行するコマンドは

  • 外部コマンド
  • 内部コマンド

の2つに分類できる。
外部コマンドはOSカーネルが起動するプログラムだが、内部コマンドはシェル自身が持つ機能である。

外部コマンドの例。

        
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

内部コマンドの例。

        
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

内部コマンドにはどんなものがあるか。
例えば代表的なシェルのBashの内部コマンドの一覧は「help」で確認できる。

$ help
GNU bash, version 5.0.17(1)-release (x86_64-pc-linux-gnu)
These shell commands are defined internally.  Type `help' to see this list.
Type `help name' to find out more about the function `name'.
Use `info bash' to find out more about the shell in general.
Use `man -k' or `info' to find out more about commands not in this list.

A star (*) next to a name means that the command is disabled.

 job_spec [&]                                                                                            history [-c] [-d offset] [n] or history -anrw [filename] or history -ps arg [arg...]
 (( expression ))                                                                                        if COMMANDS; then COMMANDS; [ elif COMMANDS; then COMMANDS; ]... [ else COMMANDS; ] fi
 . filename [arguments]                                                                                  jobs [-lnprs] [jobspec ...] or jobs -x command [args]
 :                                                                                                       kill [-s sigspec | -n signum | -sigspec] pid | jobspec ... or kill -l [sigspec]
 [ arg... ]                                                                                              let arg [arg ...]
 [[ expression ]]                                                                                        local [option] name[=value] ...
 alias [-p] [name[=value] ... ]                                                                          logout [n]
 bg [job_spec ...]                                                                                       mapfile [-d delim] [-n count] [-O origin] [-s count] [-t] [-u fd] [-C callback] [-c quantum] [array]
 bind [-lpsvPSVX] [-m keymap] [-f filename] [-q name] [-u name] [-r keyseq] [-x keyseq:shell-command] >  popd [-n] [+N | -N]
 break [n]                                                                                               printf [-v var] format [arguments]
 builtin [shell-builtin [arg ...]]                                                                       pushd [-n] [+N | -N | dir]
 caller [expr]                                                                                           pwd [-LP]
 case WORD in [PATTERN [| PATTERN]...) COMMANDS ;;]... esac                                              read [-ers] [-a array] [-d delim] [-i text] [-n nchars] [-N nchars] [-p prompt] [-t timeout] [-u fd]>
 cd [-L|[-P [-e]] [-@]] [dir]                                                                            readarray [-d delim] [-n count] [-O origin] [-s count] [-t] [-u fd] [-C callback] [-c quantum] [arra>
 command [-pVv] command [arg ...]                                                                        readonly [-aAf] [name[=value] ...] or readonly -p
 compgen [-abcdefgjksuv] [-o option] [-A action] [-G globpat] [-W wordlist]  [-F function] [-C command>  return [n]
 complete [-abcdefgjksuv] [-pr] [-DEI] [-o option] [-A action] [-G globpat] [-W wordlist]  [-F functio>  select NAME [in WORDS ... ;] do COMMANDS; done
 compopt [-o|+o option] [-DEI] [name ...]                                                                set [-abefhkmnptuvxBCHP] [-o option-name] [--] [arg ...]
 continue [n]                                                                                            shift [n]
 coproc [NAME] command [redirections]                                                                    shopt [-pqsu] [-o] [optname ...]
 declare [-aAfFgilnrtux] [-p] [name[=value] ...]                                                         source filename [arguments]
 dirs [-clpv] [+N] [-N]                                                                                  suspend [-f]
 disown [-h] [-ar] [jobspec ... | pid ...]                                                               test [expr]
 echo [-neE] [arg ...]                                                                                   time [-p] pipeline
 enable [-a] [-dnps] [-f filename] [name ...]                                                            times
 eval [arg ...]                                                                                          trap [-lp] [[arg] signal_spec ...]
 exec [-cl] [-a name] [command [arguments ...]] [redirection ...]                                        true
 exit [n]                                                                                                type [-afptP] name [name ...]
 export [-fn] [name[=value] ...] or export -p                                                            typeset [-aAfFgilnrtux] [-p] name[=value] ...
 false                                                                                                   ulimit [-SHabcdefiklmnpqrstuvxPT] [limit]
 fc [-e ename] [-lnr] [first] [last] or fc -s [pat=rep] [command]                                        umask [-p] [-S] [mode]
 fg [job_spec]                                                                                           unalias [-a] name [name ...]
 for NAME [in WORDS ... ] ; do COMMANDS; done                                                            unset [-f] [-v] [-n] [name ...]
 for (( exp1; exp2; exp3 )); do COMMANDS; done                                                           until COMMANDS; do COMMANDS; done
 function name { COMMANDS ; } or name () { COMMANDS ; }                                                  variables - Names and meanings of some shell variables
 getopts optstring name [arg]                                                                            wait [-fn] [id ...]
 hash [-lr] [-p pathname] [-dt] [name ...]                                                               while COMMANDS; do COMMANDS; done
 help [-dms] [pattern ...]

シェルは、入力された文字列が

  • 内部コマンドに相当する文字列である場合は該当する機能を実行し、
  • それ以外の場合は外部コマンドとして、OSカーネルに該当するプログラムの起動を要求する。

「シェルもどき」は現時点では2つの内部コマンド「cd」「exit」に対応した。

func main() {

  // 標準入力から文字列を読み取る準備
  (略)

  // 内部コマンド群
  internalCommands := map[string] func([]string) error {
    "cd": chDir,
    "exit": exit,
  }

  // ずっとループ
  for {
    // プロンプトを表示してユーザに入力を促す
    (略)
    
    // 標準入力から文字列(コマンド)を読み込む
    (略)

    // 入力文字列を空白ごとに単語に分解する
    words := strings.Split(strings.Trim(string(line), " "), " ")

    // 先頭の単語に該当するコマンドを探して実行する

    // 内部コマンドか?
    internalCommand, ok := internalCommands[words[0]]
    if ok {
      // 内部コマンドを実行
      err = internalCommand(words)
    } else {
      // 外部コマンドを実行
      err = execExternalCommand(words)
    }

    if err != nil {
      fmt.Fprintln(os.Stderr, err)
    }
  }
}

HashMapであるinternalCommandsに

  • キー : 内部コマンド名("cd", "exit")
  • 値 : 該当処理の関数(chDir(), exit())

を登録している。
ユーザが入力した文字列を空白毎に分解し、先頭の単語(コマンド名)が内部コマンド名である場合は、該当処理関数を実行する。
そうでない場合は、OSカーネルに該当するプログラムの起動を要求する。

□内部コマンド cd

// cdコマンド
func chDir(words []string) (err error) {
  var dir string
  l := len(words) 
  if l == 1 {
    dir, err = os.UserHomeDir()
    if err != nil {
      log.Fatalf("os.UserHomeDir %v", err)
    }
  } else if l == 2 {
    dir = words[1]
  } else {
    return fmt.Errorf("%s: too many arguments", "cd")
  }
  return os.Chdir(dir)
}

引数がない場合は、ホームディレクトリにカレントディレクトリを移動する。
引数が1つある場合は、そのディレクトリに移動する。
引数が2つ以上の場合は、エラー表示する。

□内部コマンド exit

// exitコマンド
func exit(words []string) (err error) {
  os.Exit(0)
  return nil
}

os.Exit()でシェルを終了する。

□外部コマンドを実行する部分

// 外部コマンドを実行する
func execExternalCommand(words []string) (err error) {
  command, err := absPathWithPATH(string(words[0]))
  if err != nil {
      fmt.Fprintln(os.Stderr, err)
      return
  }
  log.Printf("command %s\n", command)

  // これから起動するプログラムの出力と自分の出力をつなげる
  var procAttr os.ProcAttr
  procAttr.Files = []*os.File{nil, os.Stdout, os.Stderr}

  // 該当するプログラムを探して起動する
  process, err := os.StartProcess(command, words, &procAttr)
  if err != nil {
    log.Fatalf("os.StartProcess %v", err)
  }

  // 起動したプログラムが終了するまで待つ
  _, err = process.Wait()
  if err != nil {
    log.Fatalf("process.Wait %v", err)
  }

  return nil
}

今まではmain関数内部にあったが、今回は関数化してmain関数外部に追い出した。

ソースコード