[ネタ]Lua言語仕様の範囲内で書けるシナリオスクリプト言語をもうちょっとマジメに考える

# 概要

[以前考えた](https://qiita.com/riafeed/items/78186f8dff816ba114d1)シナリオスクリプト言語をもうちょっと真面目に考え直してみた。

# 目的

Lua言語の仕様に違反しない範囲で見通しの良いテキスト中心のスクリプト言語

# 見た目

```Lua
return {

"吾輩は猫である。名前はまだない。",
"そんな吾輩であるが、数日前にマタタビを追いかけて走り回っていたらトラックに轢かれてしまった。",
{"GOTO", "JUMP"},
"これは本当にたまたまだったのだが、運が悪かったというかなんというか…………。",
{"LABEL", "JUMP"},
"何にせよその結果、中世ヨーロッパのような世界に転生してしまったのである。",
{{
    "現代日本で生まれ育ち、1年間生きてきた記憶と人格を持ったままこのファンタジー世界に投げ込まれたのだ。",
    "いや、べつに不満をぶちまけたいわけではない。ただ、ちょっとした偶然で死んでしまった結果がこれでは少しばかり納得がいかなかっただけだ。",
    "しかも、誇り高き猫族だった吾輩にはこの人間の身体は窮屈すぎる。",
    "動きも鈍いし、人間どもと一緒にいるだけでストレスが溜まるわ、毛もないから夏でも暑いわ…………etc,etc. ",
    "それでも我慢していればそのうち慣れるだろうとたかを括っていたが、やはり無理があったようだ。",
    "とにかく、この世界で生きるにあたって自分の名前が必要らしいことは理解していた。",
}}

}
```
ちなみに文章は[AIのべりすと](https://ai-novel.com/)で作りました。

# 仕様

```return { ～ }```はスクリプトをrequireやloadfile関数の戻り値としてそのままテーブルとして受け取れるようにするためのおまじないです。

```"吾輩は猫である。名前はまだない。",```はテキストです。ホストのプログラムによって文章として画面に表示されることが期待される部分です。

```{"LABEL", "JUMP"},```は命令です。同名のLua関数を呼び出して実行します。インタプリタのLua関数をホストプログラムに合わせて実装・書き換えすることでスクリプトからホストプログラムを制御します。

テキスト・命令を要素ごとにカンマ(,)で区切ります。

```{{ ～ }}```はブロックです、テキスト・命令・ブロックをカンマで区切った集まりを入れ子にすることができます。

実際はテキストは```{"TEXT", "吾輩は猫である"}```といったTEXT命令、
ブロックは```{"BLOCK", {"現代日本で生まれ育ち", "1年間生きてきた記憶と人格を持った"}}```といったBLOCK命令
の省略形なのですべて命令(とその省略形)をカンマで区切った集まりとも言えます。

~~Lua関数の直接定義とかいらないと思うのでできないようにした~~

# 命令

命令はLua関数の呼び出しとして処理されます。命令の名前と引数をカンマで区切ります。
引数を""で囲むかどうかは命令によりますが、""で囲んで文字列として渡す命令がほとんどです。

```Lua 
{"命令", "引数1", "引数2", "引数3"...}
```

## 基本命令

ベースとなるインタプリタはホストプログラムに依存する関数は定義できないので、
簡単な制御構造、フラグ、スクリプトファイルの読み込みといったホストプログラムに依存しない命令が定義されています

- LABEL命令 - 引数に設定した名前でGOTO命令の飛び先を設定します。
- GOTO命令 - LABEL命令で設定した名前の飛び先に移動します。ブロックをまたいだジャンプはできません。
- TEXT命令 - 引数に設定されたテキストを表示します。
- BLOCK命令 - 引数に設定されたブロックを実行します。
- TABLE命令 - SET/UNSET命令で設定するフラグを保管するテーブル変数を設定します。
- SET命令 - 引数に設定した名前のフラグをtrueに設定します。
- UNSET命令 - 引数に設定した名前のフラグをfalseに設定します。
- IF命令 - 引数1に設定した名前のフラグがtrueなら引数2の、falseなら引数3に設定した名前の飛び先にジャンプします。設定していなければそのまま次の行に進みます。
- LOAD命令 - 引数に指定したスクリプトファイルを読み込んでブロックとして実行します。
- BREAK命令 - ブロックの実行を終了して呼び出し元のブロックに戻ります。
- END命令 - スクリプトの実行を終了します。BREAK命令と違いどのブロックで実行されても全体が終了します。
- ABORT命令 - 異常終了という形でスクリプトの実行を終了します。引数でメッセージを設定できます。
- WARNING命令 - 引数に設定した警告メッセージを設定します。

## 拡張命令

関数を上書きすることでインタプリタの動作をカスタマイズできるように特定のタイミングで呼ばれる関数(命令)が設定されています。
[パフォーマンス](https://qiita.com/demoin/items/fe2d243fa79745977758)の関係でオーバーライドはしない仕様にしたので、
上書きする場合は関数内のどこかでベースインタプリタ用の別名の関数(内部命令)を呼び出す必要があります。

- INIT命令 - スクリプトをインタプリタで実行した時に呼ばれる命令です。ベースの初期化のためにINITSCRIPT命令を呼び出す必要があります。INITSCRIPT命令の戻り値でINIT命令実行済みかどうかの判別ができます。
- TEXT命令 - テキスト及びTEXT命令が実行された時に呼ばれる命令です。ホストプログラムに文字列を表示するコードを記述します。ベースの関数はありません。
- TABLE命令 - スクリプトでTABLE命令が実行された時に呼ばれます。フラグ用のテーブルの設定を禁止するなどの処理を記述します。ベースの関数はSETTABLEです。
- CALL命令 - スクリプトで命令が実行された時に呼ばれます。インタプリタとは別のテーブルに設定されている関数のスクリプトからの呼び出し等を記述できます。ベースの関数はCALLFANCです。
- WARNING命令 - スクリプトやインタプリタでWARNING命令が実行された時に呼ばれます。ベースの関数を呼ばないことで特定の警告メッセージの握りつぶし等を記述できます。ベースの関数はADDMESSAGEです
- ABORT命令 - スクリプトやインタプリタでABORT命令が実行された時に呼ばれます。ベースの関数を呼ばないことで特定のエラーメッセージの握りつぶし等を記述できます。ベースの関数はABORTSCRIPTです



# コード

### インタプリタ実行コード例
```Lua
require('SSI')

--インタプリタオブジェクト作成関数
local Create = function()
    obj = SSI.Create()
    --ホストプログラムやライブラリに合わせて必要な関数を実装・上書きする
    obj.TEXT = function(self, script, text)
        print(text)
    end
    return obj
end

--インタプリタオブジェクトの作成
local ssi = Create()

--スクリプトの実行。
--successがtrueなら正常終了、falseなら異常終了。 errにエラー・警告メッセージが格納される。
local success, err = ssi:Start('output.txt', nil, "END")

print(err)
```

### インタプリタ本体
```SSI.Lua
SSI = {
    Create = function()
        local obj = {}

        --内部変数及び内部関数
        obj._Internal = {
            pin = -1,
            abort = true,
            result = false,
            running = false,
            valtable = {},
            startlabel = "",
            errmsg = "",

            --内部変数の初期化
            Init = function(self)
                self._Internal.pin = -1
                self._Internal.abort = false
                self._Internal.result = false
                self._Internal.running = true
                self._Internal.valtable = {}
                self._Internal.startlabel = ""
                self._Internal.errmsg = ""
            end,

            --スクリプトの実行開始
            Process = function(self, script)
                if type(script) ~= "table" or #script == 0 then
                    self.ABORT(self, script, "INVALIDSCRIPT", "Invalid Script Error: Block Data is Not Table or Blank Table.")
                    return false
                end

                if self._Internal.abort then return self._Internal.result end

                local pin
                if self._Internal.startlabel ~= "" then
                    self.GOTO(self, script, self._Internal.startlabel)
                    self._Internal.startlabel = ""
                end
                if self._Internal.pin > 0 then
                    pin = self._Internal.pin
                    self._Internal.pin = -1
                else
                    pin = 1
                end

                local len = #script

                --スクリプトの実行
                while pin <= len do
                    local data = script[pin]
                    local dtype = type(data)

                    if dtype == "string" then
                        --テキストの処理
                        self.TEXT(self, script, data)
                    elseif dtype == "table" then
                        --命令・ブロックの処理
                        if #data == 0 then return end
                        if type(data[1]) == "string" then
                            --文字列なら命令を呼び出し
                            self.CALL(self, nil, data)
                        elseif type(data[1]) == "table" then
                            --table型の変数ならブロックの処理開始
                            self.BLOCK(self, script, data[1])
                        --elseif type(data[1]) == "function" then
                            --function型の値ならそのまま呼び出し
                            --self.INVOKE(self, script, data[1], data)
                        else
                            self.ABORT(self, script, "INVALIDSCRIPT", "Invalid Script Error: Statement Data Type "..type(data[1]).." is Not Suppot.")
                        end
                    else
                        self.ABORT(self, script, "INVALIDSCRIPT", "Invalid Script Error: Statement Data Type "..dtype.." is Not Suppot.")
                    end

                    --abortがtrueの場合は強制終了
                    if self._Internal.abort then return self._Internal.result end

                    if self._Internal.pin > 0 then
                        pin = self._Internal.pin
                        self._Internal.pin = -1
                    end

                    pin = pin + 1
                end

                return true
            end,
        }

        --スクリプトの実行開始
        --[[
            ssi:Start(スクリプト, フラグ用テーブル, 実行開始位置)
        ]]
        obj.Start = function(self, script, valtable, jump)
            if type(self.Start) ~= "function" or self._Internal.running then
                return false, "Worng Function Call."
            end

            self.INIT(self, nil)
            self.TABLE(self, nil, valtable)

            local result
            if type(script) == "table" then
                result = self.BLOCK(self, {}, script, jump)
            elseif type(script) == "string" then
                result = self.LOAD(self, {}, script, jump)
            else
                return false, "Worng Function Call."
            end

            self._Internal.running = false
            self._Internal.abort = true

            return result, self._Internal.errmsg
        end

        --INIT拡張命令
        --スクリプト初期化
        obj.INIT = function(self, script)
            if self.INITSCRIPT(self, script) then
                return
            end
        end

        --TEXT拡張命令
        --テキストの表示
        obj.TEXT = function(self, script, text)
            --デバッグ用
            print(text)
        end

        --TABLE拡張命令
        --フラグ保管テーブルの設定
        obj.TABLE = function(self, script, valtable)
            self.SETTABLE(self, script, valtable)
        end

        --CALL拡張命令
        --命令(関数)の呼び出し
        obj.CALL = function(self, script, data)
            self.CALLFUNC(self, script, data, nil)
        end

        --LOAD拡張命令
        --スクリプトファイルの読み込み・実行
        obj.LOAD = function(self, script, fname, jump)
            return self.LOADFILE(self, script, fname, jump)
        end

        --WARNING拡張命令
        --警告メッセージの設定
        obj.WARNING = function(self, script, type, msg)
            self.ADDMESSAGE(self, script, msg)
        end

        --ABORT拡張命令
        --異常終了の設定
        obj.ABORT = function(self, script, type, msg)
            self.ABORTSCRIPT(self, script, msg)
        end

        --内部命令
        --スクリプト実行のための初期化
        obj.INITSCRIPT = function(self, script)
            if self._Internal.running then
                self.WARNING(self, script, "INVALIDCALL", ": Script Already Running.")
                return false
            end
            if type(script) ~= "nil" then
                self.WARNING(self, script, "INVALIDCALL", ": Calling INIT Statements from Scripts is Prohibited.")
                return false
            end
            self._Internal.Init(self)
            return true
        end

        --内部命令
        --フラグ設定用のテーブルを設定
        obj.SETTABLE = function(self, script, valtable)
            if type(valtable) == "table" then
                self._Internal.valtable = valtable
            end
        end

        --内部命令
        --関数を実行
        obj.INVOKE = function(self, script, func, data)
            local success
            local err

            --スクリプトからFUNC関係命令の呼び出しは禁止
            if type(script) ~= "nil" then
                self.WARNING(self, script, "INVALIDCALL", ": Calling Function Statements from Scripts is Prohibited.")
                return
            end

            if type(func) ~= "function" then
                self.WARNING(self, script, "INVALIDARG", "Invalid Argment: INVOKE "..tostring(func))
                return
            end

            success, err = pcall(func, self, script, data[2], data[3], data[4], data[5], data[6], data[7], data[8], data[9], data[10], data[11])

            --実行に失敗したら異常終了
            if not success then
                self.ABORT(self, script, "INVOKEFAIL", "Invoke Failed: "..err)
            end
        end

        --内部命令
        --CALL拡張命令で渡された関数または文字列から取得した関数をINVOKE命令に渡す
        obj.CALLFUNC = function(self, script, data, func)
            if type(data[1]) == "string" and (data[1] == "CALL" or data[1] == "CALLFUNC") then
                --table.remove(data, 1)
                --self.CALL(self, script, data)
                self.WARNING(self, script, "INVALIDCALL", ": Calling Function Statements from Scripts is Prohibited.")
                return
            end
            if type(func) ~= "function" then
                if type(self[data[1]]) == "function" then
                    func = self[data[1]]
                else
                    self.ABORT(self, script, "INVOKEFAIL", "Invoke Failed: Function Not Found.")
                end
            end
            self.INVOKE(self, script, func, data)
        end

        --BLOCK命令
        --ブロックを実行
        obj.BLOCK = function(self, script, block, jump)
            if type(jump) == "string" then
                self._Internal.startlabel = jump
            end
            return self._Internal.Process(self, block)
        end

        --LOADFILE命令
        --スクリプトファイルを読み込んでブロックとして実行
        obj.LOADFILE = function(self, script, fname, jump)
            local chunk
            local err

            if type(fname) == "string" then
                chunk, err = loadfile(fname)
            elseif type(fname) == "function" then
                chunk = fname
            else
                self.ABORT(self, nil, "LOADERROR", "LoadScript Error: Invalid Argument.")
                return false
            end

            if chunk == nil then
                self.ABORT(self, nil, "LOADERROR", "LoadScript Error:"..err)
                return false
            end

            return self.LOADCHUNK(self, script, chunk, jump)
        end

        --LOADSTRING命令
        --スクリプト文字列を読み込んでブロックとして実行
        obj.LOADSTRING = function(self, script, str, jump)
            if type(str) ~= "string" then
                self.ABORT(self, nil, "LOADERROR", "LoadString Error: Invalid Argument.")
                return false
            end

            local chunk, err = load(str)
            if chunk == nil then
                self.ABORT(self, nil, "LOADERROR", "LoadString Error:"..err)
                return false
            end

            return self.LOADCHUNK(self, script, chunk, jump)
        end

        --LOADCHUNK
        --loadやloadfile関数の戻り値であるchunkを読み込んでブロックとして実行
        obj.LOADCHUNK = function(self, script, chunk, jump)
            if type(chunk) ~= "function" then
                self.ABORT(self, nil, "LOADERROR", "LoadChunk Error: Invalid Argument.")
                return false
            end

            --スクリプトをLuaで実行してテーブルとして読み込み
            local block = nil
            local success, err = pcall(function() block = chunk() end)
            if not success or type(block) ~= "table" then
                self.ABORT(self, nil, "LOADERROR", "LoadChunk Error:"..err)
                return false
            end

            return self.BLOCK(self, script, block, jump)
        end

        --LABEL命令
        --GOTO命令の飛び先をセット
        obj.LABEL = function(self, script, label)
        end

        --GOTO命令
        --LABEL命令で定義した飛び先にジャンプ
        obj.GOTO = function(self, script, label)
            if type(label) ~= "string" then 
                self.WARNING(self, script, "INVALIDARG", "Invalid Argment: GOTO "..tostring(label))
                return
            end
            for i = 1,#table do
                if type(script[i]) == "table" and type(script[i][1]) == "string" and type(script[i][2]) == "string" then
                    if script[i][1] == "LABEL" and script[i][2] == label then
                        self._Internal.pin = i
                        return
                    end
                end
            end
        end

        -- SET命令
        -- フラグにtrueまたは指定した値やLuaコードの戻り値をセット
        obj.SET = function(self, script, name, data)
            if type(name) == "string" then
                if type(data) == "function" then
                    self._Internal.valtable[name] = data()
                elseif type(data) ~= "nil" then
                    self._Internal.valtable[name] = data
                else
                    self._Internal.valtable[name] = true
                end
            else
                self.WARNING(self, script, "INVALIDARG", "Invalid Argment: SET "..tostring(name))
            end
        end

        -- UNSET命令
        -- フラグにfalseをセット
        obj.UNSET = function(self, script, name)
            if type(name) == "string" then
                self._Internal.valtable[name] = false
            else
                self.WARNING(self, script, "INVALIDARG", "Invalid Argment: UNSET "..tostring(name))
            end
        end

        -- IF命令
        -- 指定したフラグがtrueなら3つめ、falseなら4つめに指定したラベルへジャンプ
        -- (4つめは省略可、その場合は次の要素へ)
        obj.IF = function(self, script, name, truejmp, falsejmp)
            if type(name) ~= "string" then
                self.WARNING(self, script, "INVALIDARG", "Invalid Argment: IF "..tostring(name))
                return
            end

            if self._Internal.valtable[name] then
                self.GOTO(self, script, truejmp)
            else
                self.GOTO(self, script, falsejmp)
            end
        end

        --BREAK命令
        obj.BREAK = function(self, script)
            self._Internal.pin = #script
        end

        --END命令
        obj.END = function(self, script)
            self._Internal.abort = true
            self._Internal.result = true
        end

        obj.ADDMESSAGE = function(self, script, msg)
            if type(msg) ~= "string" then
                self._Internal.errmsg = self._Internal.errmsg..msg.."\n"
            else
                self.WARNING(self, script, "INVALIDARG", "Invalid Argment: ADDMESSAGE "..tostring(msg))
            end
        end

        --ABORTSCRIPT命令
        obj.ABORTSCRIPT = function(self, script, msg)
            if type(msg) ~= "string" then
                msg = "Script aborted."
            end
            self.ADDMESSAGE(self, script, msg)
            self._Internal.abort = true
        end

        return obj
    end
}

return SSI
```