はじめに
これはAizu Advent Calendar 2025の二日目の記事です。良ければ他の方の記事も見て行ってください。
自己紹介
会津大学一年生の緑です。
最近温泉入るのにハマっていて、毎週末銭湯行ってます。炭酸良き。
色々あるデータファイル
皆さんは「データファイル」と言われたときに何の形式を思い浮かべるでしょうか?
恐らく多くの人はjsonファイルを思い浮かべると思います。まあ手軽なのでね。
{
"key": "value"
}
あとはyamlでしょうか。僕はTaskfileを愛用しているので、意外と書く機会があります。
key: value
field:
key1: value1
key2: value2
tomlなんかも有名ですね。僕はpyproject.tomlやhugo.tomlで見かけてます。
[field]
Key = "Value"
ちなみに僕はtomlとiniの違いが分かりません
更に最近はTOONという、これまた謎なデータファイルを普及させようとしている勢力もいるようです。用途はAI向けだとか (csvで良くないか?)
users[2]{id,name,role}:
1,Alice,admin
2,Bob,user
id,name,role
1,Alice,admin
2,Bob,user
なぜこんなにデータファイルがあるのか
ご覧いただいたように、データファイルの形式は様々あります。
ただデータを格納するだけなのに、なぜこんなにも種類があるのでしょうか?
なぜ2025年になってもなお、新しいフォーマットが生まれるのでしょうか?
それは人間だからです。
人間という傲慢な生き物は、少しでも己のニーズに満たない時、癇癪を起こします。愚かですね。
ということで
この記事を読んでいるあなたも人間だと思われるので、既存のデータファイルには満足していないと思います。しかし、癇癪を起す前にいったん落ち着いて欲しい。
そう、無い物は作ればいいのです。
では理想のデータファイルを作っていきましょう。
今回は Go言語 で実装していきます。なお、「解析対象の構文が正しい」という前提でやりますので、構文エラーとかは適当です。
理想の形式を作る
コードを書く前に、まずおのが理想をはっきりさせないことには何も始まりません。
適当な拡張子を創造し、そのファイルに自らの理想を綴ってください。大体ここまでできるようにしたいなーというのを全て詰め込みましょう。
今回はjsonから出発し、それを改変していこうと思います。
{
"country": "JP",
"age": 25,
"favorites": ["beef", "rice", "egg"],
"isStudent": true,
"foodExpences": {
"Mon": 1000,
"Tue": 1000,
"Wed": 1500,
"Thu": 900,
"Fri": 2000,
"Sat": 1000,
"Sun": 3000
}
}
パーサーを作る
実はこういう解析の時って逐次解析はあまりよろしくなくて、「字句解析→構文解析→意味解析」 という手順を踏みます。
まあ簡単に言うと 「扱いやすいように加工→構文があってるかチェック→解析開始!」 です。こうすることで再帰処理とかがやりやすくなるんだとか。
しかし今回はオレオレデータファイル、読めればいいのだという精神で何となく作っていきましょう。バグがあろうと、仕様だと言えばそれは仕様なのです。
字句解析編
ここでやることは、「解析対象の文字列をトークンという意味のある単語ごとに分解」することです。
例えば先ほどの解析目標では、{や[といったデータ型に関するもの、"country"、"age"といったキー、"JP"や20、trueといった値は価値のある情報ですが、タブ(\t)や改行(\n)、 (空白)などの文字は人間が見やすいように置かれているだけで、情報としては価値がありません。
そういった価値のない情報を捨てます。
package main
import (
"fmt"
"log"
"os"
"slices"
"strconv"
)
type TokenType int
type Token struct {
Type TokenType
Value string
}
var (
IGNORES = []rune{' ', '\n', '\t', '\r'}
)
const (
BLOCK_BEGIN TokenType = iota
BLOCK_END
ARRAY_BEGIN
ARRAY_END
COLON
STRING
INT
TRUE
FALSE
NULL
)
func debug(tokens []Token) {
for _, t := range tokens {
fmt.Printf("value: %s\n", t.Value)
}
}
func parse(file string) {
b, err := os.ReadFile(file)
if err != nil {
log.Fatal(err)
}
debug(tokenize(string(b))) // テスト
}
func tokenize(text string) []Token {
// 字句解析
res := []Token{}
var (
s rune
tmp string
stringValue string
isStore bool
isBackSlash bool
)
for i := 0; i < len(text); i++ {
s = rune(text[i])
if slices.Contains(IGNORES, s) {
continue // 空白文字は無視
}
if isStore {
if s == ',' || s == '}' {
isStore = false
res = append(res, askwd(tmp))
if s == '}' {
res = append(res, Token{
BLOCK_END,
"}",
})
}
tmp = ""
} else {
tmp += string(s)
}
continue
}
switch s {
case ',':
continue
case '{':
res = append(res, Token{
BLOCK_BEGIN,
"{",
})
case '}':
res = append(res, Token{
BLOCK_END,
"}",
})
case '[':
res = append(res, Token{
ARRAY_BEGIN,
"[",
})
case ']':
res = append(res, Token{
ARRAY_END,
"]",
})
case ':':
res = append(res, Token{
COLON,
":",
})
case '"':
stringValue = ""
for i < len(text) {
i++
if isBackSlash {
isBackSlash = false
switch rune(text[i]) {
case 'n':
stringValue += "\n"
case 'r':
stringValue += "\r"
case 't':
stringValue += "\t"
case '"':
stringValue += "\""
case '\\':
stringValue += "\\"
}
} else if rune(text[i]) == '"' {
res = append(res, Token{
STRING,
stringValue,
})
break
} else if rune(text[i]) == '\\' {
isBackSlash = true
} else {
stringValue += string(text[i])
}
}
default:
isStore = true
tmp += string(s)
}
}
return res
}
func askwd(kwd string) Token {
// true や nullといったキーワードや数字の判別
switch kwd {
case "true":
return Token{
TRUE,
"true",
}
case "false":
return Token{
FALSE,
"false",
}
case "null":
return Token{
NULL,
"null",
}
default:
// 数字じゃないなら不明な文字
_, err := strconv.Atoi(kwd)
if err != nil {
log.Fatalf("不明な文字列: %s", kwd)
}
return Token{
INT,
kwd,
}
}
}
func main() {
parse("./target.json")
}
うわ、泥くさ! と思ったそこのあなた! 正解です。
ざっくり解説すると
type TokenType int
type Token struct {
Type TokenType
Value string
}
var (
IGNORES = []rune{' ', '\n', '\t', '\r'}
)
const (
BLOCK_BEGIN TokenType = iota
BLOCK_END
ARRAY_BEGIN
ARRAY_END
COLON
STRING
INT
TRUE
FALSE
NULL
)
では無視する文字をIGNORES配列に入れ、次にトークン、つまりデータの最小単位の種類の登録を行っています。
続いてtokenize関数では
switch s {
case ',':
continue
case '{':
res = append(res, Token{
BLOCK_BEGIN,
"{",
})
case '}':
res = append(res, Token{
BLOCK_END,
"}",
})
case '[':
res = append(res, Token{
ARRAY_BEGIN,
"[",
})
case ']':
res = append(res, Token{
ARRAY_END,
"]",
})
case ':':
res = append(res, Token{
COLON,
":",
})
case '"':
stringValue = ""
for i < len(text) {
i++
if isBackSlash {
isBackSlash = false
switch rune(text[i]) {
case 'n':
stringValue += "\n"
case 'r':
stringValue += "\r"
case 't':
stringValue += "\t"
case '"':
stringValue += "\""
case '\\':
stringValue += "\\"
}
} else if rune(text[i]) == '"' {
res = append(res, Token{
STRING,
stringValue,
})
break
} else if rune(text[i]) == '\\' {
isBackSlash = true
} else {
stringValue += string(text[i])
}
}
default:
isStore = true
tmp += string(s)
}
というように、トークンごとに判定して分解をします。
"の場合は、次の"が現れるまで読み、文字列として追加します。
\を読んだら次の文字に応じて適切に加工します。
何のトークンの判定にも引っかからなかった場合、いったんisStoreフラグを立て
if isStore {
if s == ',' || s == '}' {
isStore = false
res = append(res, askwd(tmp))
if s == '}' {
res = append(res, Token{
BLOCK_END,
"}",
})
}
tmp = ""
} else {
tmp += string(s)
}
continue
}
にて、,や}(最後の要素は,が不要なため)を読むまでtmpに文字を保持し、その文字列を以下のaskwd関数で予約語(true、nullなど)かどうか、数字かどうかの判定をします。そのいずれにも該当しない場合はエラーを投げます。
func askwd(kwd string) Token {
// true や nullといったキーワードや数字の判別
switch kwd {
case "true":
return Token{
TRUE,
"true",
}
case "false":
return Token{
FALSE,
"false",
}
case "null":
return Token{
NULL,
"null",
}
default:
// 数字じゃないなら不明な文字
_, err := strconv.Atoi(kwd)
if err != nil {
log.Fatalf("不明な文字列: %s", kwd)
}
return Token{
INT,
kwd,
}
}
}
なお、これを実行するとこんな感じになります。
value: {
value: country
value: :
value: JP
value: age
value: :
value: 25
value: favorites
value: :
value: [
value: beef
value: rice
value: egg
value: ]
value: isStudent
value: :
value: true
value: foodExpences
value: :
value: {
value: Mon
value: :
value: 1000
value: Tue
value: :
value: 1000
value: Wed
value: :
value: 1500
value: Thu
value: :
value: 900
value: Fri
value: :
value: 2000
value: Sat
value: :
value: 1000
value: Sun
value: :
value: 3000
value: }
value: }
興奮してきましたね。
構文解析編 & 意味解析編
構文解析は文法チェックなどをするんですが、しょせんはオレオレデータファイルなのでエラー処理は適当でいいでしょう。動けばいいんです動けば。
あと構文解析木と呼ばれるものを作るのですが、データファイルなのでなんとここがゴールです。プログラミング言語とか作る際はここから意味解析をしなければいけません。大変ですね。
package main
import (
"encoding/json" // 結果表示のため
"fmt"
"log"
"os"
"slices"
"strconv"
)
var (
IGNORES = []rune{' ', '\n', '\t', '\r'}
)
type TokenType int
type Token struct {
Type TokenType
Value string
}
func (t *Token) GetValue() any {
switch t.Type {
case INT:
n, _ := strconv.Atoi(t.Value)
return n
case TRUE:
return true
case FALSE:
return false
case NULL:
return nil
default:
return t.Value
}
}
const (
BLOCK_BEGIN TokenType = iota
BLOCK_END
ARRAY_BEGIN
ARRAY_END
COLON
STRING
INT
TRUE
FALSE
NULL
)
func debug(tokens []Token) {
for i, t := range tokens {
fmt.Printf("%d value: %s %d\n", i, t.Value, t.Type)
}
}
func tokenize(text string) []Token {
// 字句解析
res := []Token{}
var (
s rune
tmp string
stringValue string
isStore bool
isBackSlash bool
)
for i := 0; i < len(text); i++ {
s = rune(text[i])
if slices.Contains(IGNORES, s) {
continue // 空白文字は無視
}
if isStore {
if s == ',' || s == '}' {
isStore = false
res = append(res, askwd(tmp))
if s == '}' {
res = append(res, Token{
BLOCK_END,
"}",
})
}
tmp = ""
} else {
tmp += string(s)
}
continue
}
switch s {
case ',':
continue
case '{':
res = append(res, Token{
BLOCK_BEGIN,
"{",
})
case '}':
res = append(res, Token{
BLOCK_END,
"}",
})
case '[':
res = append(res, Token{
ARRAY_BEGIN,
"[",
})
case ']':
res = append(res, Token{
ARRAY_END,
"]",
})
case ':':
res = append(res, Token{
COLON,
":",
})
case '"':
stringValue = ""
for i < len(text) {
i++
if isBackSlash {
isBackSlash = false
switch rune(text[i]) {
case 'n':
stringValue += "\n"
case 'r':
stringValue += "\r"
case 't':
stringValue += "\t"
case '"':
stringValue += "\""
case '\\':
stringValue += "\\"
}
} else if rune(text[i]) == '"' {
res = append(res, Token{
STRING,
stringValue,
})
break
} else if rune(text[i]) == '\\' {
isBackSlash = true
} else {
stringValue += string(text[i])
}
}
default:
isStore = true
tmp += string(s)
}
}
return res
}
func askwd(kwd string) Token {
// true や nullといったキーワードや数字の判別
switch kwd {
case "true":
return Token{
TRUE,
"true",
}
case "false":
return Token{
FALSE,
"false",
}
case "null":
return Token{
NULL,
"null",
}
default:
// 数字じゃないなら不明な文字
_, err := strconv.Atoi(kwd)
if err != nil {
log.Fatalf("不明な文字列: %s", kwd)
}
return Token{
INT,
kwd,
}
}
}
func parse(file string) map[string]any {
b, err := os.ReadFile(file)
if err != nil {
log.Fatal(err)
}
tokens := tokenize(string(b))
if len(tokens) == 0 {
return map[string]any{}
}
if tokens[0].Type != BLOCK_BEGIN {
log.Fatal("構文エラー: 文頭が'{'で始まっていません")
}
if tokens[len(tokens)-1].Type != BLOCK_END {
log.Fatal("構文エラー: 文末が'}'で終わっていません")
}
idx := 1
return _parse(tokens, &idx)
}
func _parse(tokens []Token, idx *int) map[string]any {
var (
res map[string]any = map[string]any{}
t Token
tmp Token
)
for ; *idx < len(tokens); (*idx)++ {
t = tokens[*idx]
switch t.Type {
case COLON:
if tmp.Type != STRING {
log.Fatal("キーに出来るのは数字か文字のみです")
}
(*idx)++
t = tokens[*idx]
switch t.Type {
case BLOCK_BEGIN:
(*idx)++
res[tmp.Value] = _parse(tokens, idx) // 辞書は再帰で
case ARRAY_BEGIN:
(*idx)++
t = tokens[*idx]
arr := []any{}
if t.Type == BLOCK_BEGIN {
for tokens[*idx].Type == BLOCK_BEGIN {
(*idx)++
arr = append(arr, _parse(tokens, idx))
(*idx)++
}
(*idx)--
res[tmp.Value] = arr
} else {
for t.Type != ARRAY_END {
arr = append(arr, t.GetValue())
(*idx)++
t = tokens[*idx]
}
res[tmp.Value] = arr
}
default:
res[tmp.Value] = t.GetValue()
}
tmp = Token{}
case BLOCK_END:
return res
default:
tmp = t
}
}
log.Fatal("構文エラー") // } で返ってないとおかしい
return res
}
func main() {
data := parse(`./target.json`)
d, _ := json.MarshalIndent(data, "", " ")
fmt.Println(string(d))
}
字句解析して得られたトークンたちを一つずつ読んで、その種類ごとに処理を分岐するだけです。
読む位置の指定にポインタを使うことで現在位置の進捗を共有でき、再帰処理が可能になります。
{
"age": 25,
"country": "JP",
"favorites": [
"beef",
"rice",
"egg"
],
"foodExpences": {
"Fri": 2000,
"Mon": 1000,
"Sat": 1000,
"Sun": 3000,
"Thu": 900,
"Tue": 1000,
"Wed": 1500
},
"isStudent": true
}
json.MarshalIndentで問題なくシリアライズできているので、解析には成功したことが分かります。
実はここまで作ってしまえば結構実用的で、例えばVSCodeの混沌としたsettings.jsonを(コメントを除けば)パース出来てしまいます。
{
"editor.fontSize": 24,
"editor.unicodeHighlight.invisibleCharacters": false,
"editor.wordSeparators": "./\\()\"'-:,.;<>~!@#$%^&*|+=[]{}`~? 、。「」【】『』()!?てにをはがのともへでや",
"latex-workshop.latex.tools": [
{
"name": "Latexmk (LuaLaTeX)",
"command": "latexmk",
"args": [
"-f", "-gg", "-lualatex", "-synctex=1", "-interaction=nonstopmode", "-file-line-error", "%DOC%"
]
},
{
"name": "Latexmk (XeLaTeX)",
"command": "latexmk",
"args": [
"-f", "-gg", "-xelatex", "-synctex=1", "-interaction=nonstopmode", "-file-line-error", "%DOC%"
]
},
{
"name": "Latexmk (upLaTeX)",
"command": "latexmk",
"args": [
"-f", "-gg", "-synctex=1", "-interaction=nonstopmode", "-file-line-error", "%DOC%"
]
},
{
"name": "Latexmk (pLaTeX)",
"command": "latexmk",
"args": [
"-f", "-gg", "-latex='platex'", "-latexoption='-kanji=utf8 -no-guess-input-env'", "-synctex=1", "-interaction=nonstopmode", "-file-line-error", "%DOC%"
]
}
],
"latex-workshop.latex.recipes": [
{
"name": "LuaLaTeX",
"tools": [
"Latexmk (LuaLaTeX)"
]
},
{
"name": "XeLaTeX",
"tools": [
"Latexmk (XeLaTeX)"
]
},
{
"name": "upLaTeX",
"tools": [
"Latexmk (upLaTeX)"
]
},
{
"name": "pLaTeX",
"tools": [
"Latexmk (pLaTeX)"
]
},
],
"latex-workshop.latex.magic.args": [
"-f", "-gg", "-synctex=1", "-interaction=nonstopmode", "-file-line-error", "%DOC%"
],
"latex-workshop.latex.clean.fileTypes": [
"*.aux", "*.bbl", "*.blg", "*.idx", "*.ind", "*.lof", "*.lot", "*.out", "*.toc", "*.acn", "*.acr", "*.alg", "*.glg", "*.glo", "*.gls", "*.ist", "*.fls", "*.log", "*.fdb_latexmk", "*.synctex.gz",
"_minted*", "*.nav", "*.snm", "*.vrb",
],
"latex-workshop.latex.autoClean.run": "onBuilt",
"latex-workshop.view.pdf.viewer": "tab",
"latex-workshop.latex.autoBuild.run": "never",
"[tex]": {
"editor.suggest.snippetsPreventQuickSuggestions": false,
"editor.tabSize": 2
},
"[latex]": {
"editor.suggest.snippetsPreventQuickSuggestions": false,
"editor.tabSize": 2
},
"[bibtex]": {
"editor.tabSize": 2
},
"latex-workshop.intellisense.package.enabled": true,
"security.workspace.trust.enabled": false,
"explorer.confirmDelete": false,
"explorer.confirmDragAndDrop": false,
"go.formatTool": "gofmt",
"workbench.editor.empty.hint": "hidden",
}
↓
{
"[bibtex]": {
"editor.tabSize": 2
},
"[latex]": {
"editor.suggest.snippetsPreventQuickSuggestions": false,
"editor.tabSize": 2
},
"[tex]": {
"editor.suggest.snippetsPreventQuickSuggestions": false,
"editor.tabSize": 2
},
"editor.fontSize": 24,
"editor.unicodeHighlight.invisibleCharacters": false,
"editor.wordSeparators": "./\\()\"'-:,.;\u003c\u003e~!@#$%^\u0026*|+=[]{}`~?ãããããããããï¼ï¼ï¼ï¼ã¦ã«ãã¯ãã®ã¨ãã¸ã§ã",
"explorer.confirmDelete": false,
"explorer.confirmDragAndDrop": false,
"go.formatTool": "gofmt",
"latex-workshop.intellisense.package.enabled": true,
"latex-workshop.latex.autoBuild.run": "never",
"latex-workshop.latex.autoClean.run": "onBuilt",
"latex-workshop.latex.clean.fileTypes": [
"*.aux",
"*.bbl",
"*.blg",
"*.idx",
"*.ind",
"*.lof",
"*.lot",
"*.out",
"*.toc",
"*.acn",
"*.acr",
"*.alg",
"*.glg",
"*.glo",
"*.gls",
"*.ist",
"*.fls",
"*.log",
"*.fdb_latexmk",
"*.synctex.gz",
"_minted*",
"*.nav",
"*.snm",
"*.vrb"
],
"latex-workshop.latex.magic.args": [
"-f",
"-gg",
"-synctex=1",
"-interaction=nonstopmode",
"-file-line-error",
"%DOC%"
],
"latex-workshop.latex.recipes": [
{
"name": "LuaLaTeX",
"tools": [
"Latexmk (LuaLaTeX)"
]
},
{
"name": "XeLaTeX",
"tools": [
"Latexmk (XeLaTeX)"
]
},
{
"name": "upLaTeX",
"tools": [
"Latexmk (upLaTeX)"
]
},
{
"name": "pLaTeX",
"tools": [
"Latexmk (pLaTeX)"
]
}
],
"latex-workshop.latex.tools": [
{
"args": [
"-f",
"-gg",
"-lualatex",
"-synctex=1",
"-interaction=nonstopmode",
"-file-line-error",
"%DOC%"
],
"command": "latexmk",
"name": "Latexmk (LuaLaTeX)"
},
{
"args": [
"-f",
"-gg",
"-xelatex",
"-synctex=1",
"-interaction=nonstopmode",
"-file-line-error",
"%DOC%"
],
"command": "latexmk",
"name": "Latexmk (XeLaTeX)"
},
{
"args": [
"-f",
"-gg",
"-synctex=1",
"-interaction=nonstopmode",
"-file-line-error",
"%DOC%"
],
"command": "latexmk",
"name": "Latexmk (upLaTeX)"
},
{
"args": [
"-f",
"-gg",
"-latex='platex'",
"-latexoption='-kanji=utf8 -no-guess-input-env'",
"-synctex=1",
"-interaction=nonstopmode",
"-file-line-error",
"%DOC%"
],
"command": "latexmk",
"name": "Latexmk (pLaTeX)"
}
],
"latex-workshop.view.pdf.viewer": "tab",
"security.workspace.trust.enabled": false,
"workbench.editor.empty.hint": "hidden"
}
マルチバイトが悪さしているせいで文字化けしてますが、面倒なので見なかったことにします。
改変
さて、このままだとただjsonパーサーを作っただけになってしまうので、何かしら改変します。
例えば:だけじゃなく=にも反応するようにするとか...
{
"country" = "JP",
"age" = 25,
"favorites" = ["beef", "rice", "egg"],
"isStudent" = true,
"foodExpences" = {
"Mon" = 1000,
"Tue" = 1000,
"Wed" = 1500,
"Thu" = 900,
"Fri" = 2000,
"Sat" = 1000,
"Sun" = 3000
}
}
switch s {
(略)
+ case ':', '=':
res = append(res, Token{
COLON,
":",
})
いっそのこと最初と最後の{}を消してしまうとか...
"country" = "JP",
"age" = 25,
"favorites" = ["beef", "rice", "egg"],
"isStudent" = true,
"foodExpences" = {
"Mon" = 1000,
"Tue" = 1000,
"Wed" = 1500,
"Thu" = 900,
"Fri" = 2000,
"Sat" = 1000,
"Sun" = 3000,
}
func parse(file string) map[string]any {
b, err := os.ReadFile(file)
if err != nil {
log.Fatal(err)
}
tokens := tokenize(string(b))
if len(tokens) == 0 {
return map[string]any{}
}
- /*if tokens[0].Type != BLOCK_BEGIN {
- log.Fatal("構文エラー: 文頭が'{'で始まっていません")
- }
- if tokens[len(tokens)-1].Type != BLOCK_END {
- log.Fatal("構文エラー: 文末が'}'で終わっていません")
- }*/
idx := 0
return _parse(tokens, &idx)
}
(略)
- //log.Fatal("構文エラー") // } で返ってないとおかしい
return res
ここまで来たら,も消してしまいましょう。どうせ複数行にまたがることなんて無いのでね。
"country" = "JP"
"age" = 25
"favorites" = ["beef", "rice", "egg"]
"isStudent" = true
"foodExpences" = {
"Mon" = 1000
"Tue" = 1000
"Wed" = 1500
"Thu" = 900
"Fri" = 2000
"Sat" = 1000
"Sun" = 3000
}
var (
+ IGNORES = []rune{' ', '\t', '\r'} // \nを読むようにする
)
(略)
if isStore {
+ if s == '\n' || s == ',' || s == '}' {
isStore = false
res = append(res, askwd(tmp))
if s == '}' {
res = append(res, Token{
BLOCK_END,
"}",
})
}
tmp = ""
} else {
tmp += string(s)
}
continue
}
switch s {
+ case ',', '\n':
continue
(略)
,か担っていた役割を\nにも持たせるだけです。
キーは文字列で固定なので""要らなくない? ということで無くします。
country = "JP"
age = 25
favorites = ["beef", "rice", "egg"]
isStudent = true
foodExpences = {
Mon = 1000
Tue = 1000
Wed = 1500
Thu = 900
Fri = 2000
Sat = 1000
Sun = 3000
}
if isStore {
+ if s == '=' || s == ':' || s == '\n' || s == ',' || s == '}' {
isStore = false
+ switch s {
+ case '=', ':':
+ res = append(res, Token{
+ STRING,
+ tmp,
+ })
+ res = append(res, Token{
+ COLON,
+ ":",
+ })
+ case '}':
+ res = append(res, askwd(tmp))
+ res = append(res, Token{
+ BLOCK_END,
+ "}",
+ })
+ default:
+ res = append(res, askwd(tmp))
+ }
+ tmp = ""
} else {
tmp += string(s)
}
continue
}
case ':', '=':
+ if tmp == "" {
+ log.Fatal("構文エラー")
+ }
+ res = append(res, Token{
+ STRING,
+ tmp,
+ })
+ tmp = ""
res = append(res, Token{
COLON,
":",
})
完成
jsonから出発して、最終的に
country = "JP"
age = 25
favorites = ["beef", "rice", "egg"]
isStudent = true
foodExpences = {
Mon = 1000
Tue = 1000
Wed = 1500
Thu = 900
Fri = 2000
Sat = 1000
Sun = 3000
}
という謎のフォーマットを誕生させることができました。おめでとうございます。
最終的なコードはこちらです。
package main
import (
"encoding/json" // 結果表示のため
"fmt"
"log"
"os"
"slices"
"strconv"
)
var (
IGNORES = []rune{' ', '\t', '\r'}
)
type TokenType int
type Token struct {
Type TokenType
Value string
}
func (t *Token) GetValue() any {
switch t.Type {
case INT:
n, _ := strconv.Atoi(t.Value)
return n
case TRUE:
return true
case FALSE:
return false
case NULL:
return nil
default:
return t.Value
}
}
const (
BLOCK_BEGIN TokenType = iota
BLOCK_END
ARRAY_BEGIN
ARRAY_END
COLON
STRING
INT
TRUE
FALSE
NULL
)
func debug(tokens []Token) {
for i, t := range tokens {
fmt.Printf("%d value: %s %d\n", i, t.Value, t.Type)
}
}
func tokenize(text string) []Token {
// 字句解析
res := []Token{}
var (
s rune
tmp string
stringValue string
isStore bool
isBackSlash bool
)
for i := 0; i < len(text); i++ {
s = rune(text[i])
if slices.Contains(IGNORES, s) {
continue // 空白文字は無視
}
if isStore {
if s == '=' || s == ':' || s == '\n' || s == ',' || s == '}' {
isStore = false
switch s {
case '=', ':':
res = append(res, Token{
STRING,
tmp,
})
res = append(res, Token{
COLON,
":",
})
case '}':
res = append(res, askwd(tmp))
res = append(res, Token{
BLOCK_END,
"}",
})
default:
res = append(res, askwd(tmp))
}
tmp = ""
} else {
tmp += string(s)
}
continue
}
switch s {
case ',', '\n':
continue
case '{':
res = append(res, Token{
BLOCK_BEGIN,
"{",
})
case '}':
res = append(res, Token{
BLOCK_END,
"}",
})
case '[':
res = append(res, Token{
ARRAY_BEGIN,
"[",
})
case ']':
res = append(res, Token{
ARRAY_END,
"]",
})
case ':', '=':
if tmp == "" {
log.Fatal("構文エラー")
}
res = append(res, Token{
STRING,
tmp,
})
tmp = ""
res = append(res, Token{
COLON,
":",
})
case '"':
stringValue = ""
for i < len(text) {
i++
if isBackSlash {
isBackSlash = false
switch rune(text[i]) {
case 'n':
stringValue += "\n"
case 'r':
stringValue += "\r"
case 't':
stringValue += "\t"
case '"':
stringValue += "\""
case '\\':
stringValue += "\\"
}
} else if rune(text[i]) == '"' {
res = append(res, Token{
STRING,
stringValue,
})
break
} else if rune(text[i]) == '\\' {
isBackSlash = true
} else {
stringValue += string(text[i])
}
}
default:
isStore = true
tmp += string(s)
}
}
return res
}
func askwd(kwd string) Token {
// true や nullといったキーワードや数字の判別
switch kwd {
case "true":
return Token{
TRUE,
"true",
}
case "false":
return Token{
FALSE,
"false",
}
case "null":
return Token{
NULL,
"null",
}
default:
_, err := strconv.Atoi(kwd)
if err != nil {
log.Fatalf("不明な文字列: %s", kwd)
}
return Token{
INT,
kwd,
}
}
}
func parse(file string) map[string]any {
b, err := os.ReadFile(file)
if err != nil {
log.Fatal(err)
}
tokens := tokenize(string(b))
if len(tokens) == 0 {
return map[string]any{}
}
/*if tokens[0].Type != BLOCK_BEGIN {
log.Fatal("構文エラー: 文頭が'{'で始まっていません")
}
if tokens[len(tokens)-1].Type != BLOCK_END {
log.Fatal("構文エラー: 文末が'}'で終わっていません")
}*/
idx := 0
return _parse(tokens, &idx)
}
func _parse(tokens []Token, idx *int) map[string]any {
var (
res map[string]any = map[string]any{}
t Token
tmp Token
)
for ; *idx < len(tokens); (*idx)++ {
t = tokens[*idx]
switch t.Type {
case COLON:
if tmp.Type != STRING {
log.Fatal("キーに出来るのは数字か文字のみです")
}
(*idx)++
t = tokens[*idx]
switch t.Type {
case BLOCK_BEGIN:
(*idx)++
res[tmp.Value] = _parse(tokens, idx) // 辞書は再帰で
case ARRAY_BEGIN:
(*idx)++
t = tokens[*idx]
arr := []any{}
if t.Type == BLOCK_BEGIN {
for tokens[*idx].Type == BLOCK_BEGIN {
(*idx)++
arr = append(arr, _parse(tokens, idx))
(*idx)++
}
(*idx)--
res[tmp.Value] = arr
} else {
for t.Type != ARRAY_END {
arr = append(arr, t.GetValue())
(*idx)++
t = tokens[*idx]
}
res[tmp.Value] = arr
}
default:
res[tmp.Value] = t.GetValue()
}
tmp = Token{}
case BLOCK_END:
return res
default:
tmp = t
}
}
//log.Fatal("構文エラー") // } で返ってないとおかしい
return res
}
func main() {
data := parse(`target.json`)
d, _ := json.MarshalIndent(data, "", " ")
fmt.Println(string(d))
}
終わりに
構文解析はこのように非常に泥臭いコードになりがちです。皆さんも、標準ライブラリへの感謝を忘れずに、一日一日を生きていきましょう。
現場からは以上です。
おまけ
今回僕が書いたコードを C言語 に書き替えてと Gemini に丸投げしたところ、普通に一発で通るコード出してきました。シンギュラリティですね...
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
// --- 定数・Enum定義 ---
typedef enum {
BLOCK_BEGIN,
BLOCK_END,
ARRAY_BEGIN,
ARRAY_END,
COLON,
STRING_TOKEN, // STRINGはCの予約語と被る可能性があるため変更
INT_TOKEN,
TRUE_TOKEN,
FALSE_TOKEN,
NULL_TOKEN
} TokenType;
typedef struct {
TokenType type;
char *value;
} Token;
// --- 値の型定義 (Goの any / interface{} に相当) ---
typedef enum {
V_INT,
V_STRING,
V_BOOL,
V_NULL,
V_OBJECT,
V_ARRAY
} ValueType;
struct Value; // 前方宣言
typedef struct KeyValue {
char *key;
struct Value *value;
} KeyValue;
typedef struct {
KeyValue **pairs;
int count;
int capacity;
} Object;
typedef struct {
struct Value **items;
int count;
int capacity;
} Array;
typedef struct Value {
ValueType type;
union {
int i_val;
char *s_val;
int b_val; // 0 or 1
Object *obj_val;
Array *arr_val;
};
} Value;
// --- グローバル変数 (GoのIGNORES相当は関数内で処理) ---
// --- プロトタイプ宣言 ---
void print_value(Value *v, int indent_level);
Value *parse_recursive(Token *tokens, int num_tokens, int *idx);
// --- ヘルパー関数: メモリ管理と文字列 ---
// 文字列を結合して新しい文字列を返す (tmp += string(s) の代わり)
char *append_char(char *str, char c) {
size_t len = (str == NULL) ? 0 : strlen(str);
char *new_str = realloc(str, len + 2);
if (!new_str) { perror("malloc"); exit(1); }
new_str[len] = c;
new_str[len + 1] = '\0';
return new_str;
}
// 動的配列にトークンを追加
Token *append_token(Token *tokens, int *count, Token t) {
tokens = realloc(tokens, sizeof(Token) * (*count + 1));
tokens[*count] = t;
(*count)++;
return tokens;
}
// --- 字句解析 (Tokenize) ---
Token askwd(char *kwd) {
Token t;
t.value = strdup(kwd);
if (strcmp(kwd, "true") == 0) {
t.type = TRUE_TOKEN;
} else if (strcmp(kwd, "false") == 0) {
t.type = FALSE_TOKEN;
} else if (strcmp(kwd, "null") == 0) {
t.type = NULL_TOKEN;
} else {
// 数字かどうかの簡易チェック (厳密にはstrtolのエラーチェックが必要だがGoコードに合わせる)
char *endptr;
strtol(kwd, &endptr, 10);
if (*endptr != '\0') {
fprintf(stderr, "不明な文字列: %s\n", kwd);
exit(1);
}
t.type = INT_TOKEN;
}
return t;
}
Token *tokenize(const char *text, int *out_count) {
Token *res = NULL;
int count = 0;
char *tmp = NULL;
char *stringValue = NULL;
int isStore = 0;
int isBackSlash = 0;
size_t len = strlen(text);
for (size_t i = 0; i < len; i++) {
char s = text[i];
// IGNORES check
if (s == ' ' || s == '\t' || s == '\r') {
continue;
}
if (isStore) {
if (s == '=' || s == ':' || s == '\n' || s == ',' || s == '}') {
isStore = 0;
if (s == '=' || s == ':') {
res = append_token(res, &count, (Token){STRING_TOKEN, tmp ? tmp : strdup("")});
tmp = NULL; // 所有権移動とみなす
res = append_token(res, &count, (Token){COLON, strdup(":")});
} else if (s == '}') {
if (tmp) {
res = append_token(res, &count, askwd(tmp));
free(tmp); tmp = NULL;
}
res = append_token(res, &count, (Token){BLOCK_END, strdup("}")});
} else {
// default case in Go
if (tmp) {
res = append_token(res, &count, askwd(tmp));
free(tmp); tmp = NULL;
}
}
tmp = NULL;
} else {
tmp = append_char(tmp, s);
}
continue;
}
switch (s) {
case ',':
case '\n':
continue;
case '{':
res = append_token(res, &count, (Token){BLOCK_BEGIN, strdup("{")});
break;
case '}':
res = append_token(res, &count, (Token){BLOCK_END, strdup("}")});
break;
case '[':
res = append_token(res, &count, (Token){ARRAY_BEGIN, strdup("[")});
break;
case ']':
res = append_token(res, &count, (Token){ARRAY_END, strdup("]")});
break;
case ':':
case '=':
if (tmp == NULL || strlen(tmp) == 0) {
fprintf(stderr, "構文エラー: キーが空です\n");
exit(1);
}
res = append_token(res, &count, (Token){STRING_TOKEN, tmp}); // tmp所有権移動
tmp = NULL;
res = append_token(res, &count, (Token){COLON, strdup(":")});
break;
case '"':
stringValue = strdup(""); // 空文字列で初期化
while (i + 1 < len) {
i++;
char c = text[i];
if (isBackSlash) {
isBackSlash = 0;
switch (c) {
case 'n': stringValue = append_char(stringValue, '\n'); break;
case 'r': stringValue = append_char(stringValue, '\r'); break;
case 't': stringValue = append_char(stringValue, '\t'); break;
case '"': stringValue = append_char(stringValue, '"'); break;
case '\\': stringValue = append_char(stringValue, '\\'); break;
default: stringValue = append_char(stringValue, c); break; // その他はそのまま
}
} else if (c == '"') {
res = append_token(res, &count, (Token){STRING_TOKEN, stringValue});
stringValue = NULL; // 所有権移動
break;
} else if (c == '\\') {
isBackSlash = 1;
} else {
stringValue = append_char(stringValue, c);
}
}
break;
default:
isStore = 1;
tmp = append_char(tmp, s);
break;
}
}
// cleanup leftovers if loop finishes inside isStore (though unexpected in valid json)
if (tmp) free(tmp);
if (stringValue) free(stringValue);
*out_count = count;
return res;
}
// --- 構文解析 (Parse) ---
// Value生成ヘルパー
Value *create_value_int(int v) {
Value *val = malloc(sizeof(Value));
val->type = V_INT;
val->i_val = v;
return val;
}
Value *create_value_bool(int v) {
Value *val = malloc(sizeof(Value));
val->type = V_BOOL;
val->b_val = v;
return val;
}
Value *create_value_null() {
Value *val = malloc(sizeof(Value));
val->type = V_NULL;
return val;
}
Value *create_value_string(char *s) {
Value *val = malloc(sizeof(Value));
val->type = V_STRING;
val->s_val = strdup(s);
return val;
}
Value *token_get_value(Token *t) {
switch (t->type) {
case INT_TOKEN: return create_value_int(atoi(t->value));
case TRUE_TOKEN: return create_value_bool(1);
case FALSE_TOKEN: return create_value_bool(0);
case NULL_TOKEN: return create_value_null();
default: return create_value_string(t->value);
}
}
// Object(Map)操作
void object_add(Object *obj, char *key, Value *val) {
if (obj->count >= obj->capacity) {
obj->capacity = (obj->capacity == 0) ? 8 : obj->capacity * 2;
obj->pairs = realloc(obj->pairs, sizeof(KeyValue*) * obj->capacity);
}
KeyValue *kv = malloc(sizeof(KeyValue));
kv->key = strdup(key);
kv->value = val;
obj->pairs[obj->count++] = kv;
}
// Array操作
void array_add(Array *arr, Value *val) {
if (arr->count >= arr->capacity) {
arr->capacity = (arr->capacity == 0) ? 8 : arr->capacity * 2;
arr->items = realloc(arr->items, sizeof(Value*) * arr->capacity);
}
arr->items[arr->count++] = val;
}
Value *parse_recursive(Token *tokens, int num_tokens, int *idx) {
Value *resVal = malloc(sizeof(Value));
resVal->type = V_OBJECT;
resVal->obj_val = calloc(1, sizeof(Object));
Token tmp = {0, NULL};
int has_tmp = 0;
for (; *idx < num_tokens; (*idx)++) {
Token t = tokens[*idx];
switch (t.type) {
case COLON:
if (!has_tmp || tmp.type != STRING_TOKEN) {
fprintf(stderr, "キーに出来るのは数字か文字のみです\n");
exit(1);
}
(*idx)++;
if (*idx >= num_tokens) break;
t = tokens[*idx];
switch (t.type) {
case BLOCK_BEGIN:
(*idx)++;
object_add(resVal->obj_val, tmp.value, parse_recursive(tokens, num_tokens, idx));
break;
case ARRAY_BEGIN:
(*idx)++;
if (*idx >= num_tokens) break;
t = tokens[*idx];
Value *arrVal = malloc(sizeof(Value));
arrVal->type = V_ARRAY;
arrVal->arr_val = calloc(1, sizeof(Array));
if (t.type == BLOCK_BEGIN) {
while (*idx < num_tokens && tokens[*idx].type == BLOCK_BEGIN) {
(*idx)++;
array_add(arrVal->arr_val, parse_recursive(tokens, num_tokens, idx));
(*idx)++;
}
(*idx)--;
} else {
while (*idx < num_tokens && t.type != ARRAY_END) {
array_add(arrVal->arr_val, token_get_value(&t));
(*idx)++;
if (*idx < num_tokens) t = tokens[*idx];
}
}
object_add(resVal->obj_val, tmp.value, arrVal);
break;
default:
object_add(resVal->obj_val, tmp.value, token_get_value(&t));
break;
}
has_tmp = 0;
break;
case BLOCK_END:
return resVal;
default:
tmp = t;
has_tmp = 1;
break;
}
}
return resVal;
}
Value *parse(const char *filename) {
FILE *fp = fopen(filename, "rb");
if (!fp) { perror("fopen"); exit(1); }
fseek(fp, 0, SEEK_END);
long fsize = ftell(fp);
fseek(fp, 0, SEEK_SET);
char *buffer = malloc(fsize + 1);
fread(buffer, 1, fsize, fp);
buffer[fsize] = 0;
fclose(fp);
int token_count = 0;
Token *tokens = tokenize(buffer, &token_count);
free(buffer);
if (token_count == 0) {
Value *empty = malloc(sizeof(Value));
empty->type = V_OBJECT;
empty->obj_val = calloc(1, sizeof(Object));
return empty;
}
int idx = 0;
Value *result = parse_recursive(tokens, token_count, &idx);
// トークンの後始末 (値はValue構造体にコピーされている前提)
for(int i=0; i<token_count; i++) {
free(tokens[i].value);
}
free(tokens);
return result;
}
// --- JSON出力 (json.MarshalIndent相当) ---
void print_indent(int level) {
for (int i = 0; i < level; i++) printf(" ");
}
void print_value(Value *v, int indent) {
switch (v->type) {
case V_INT: printf("%d", v->i_val); break;
case V_BOOL: printf(v->b_val ? "true" : "false"); break;
case V_NULL: printf("null"); break;
case V_STRING:
// 簡易的なエスケープ処理が必要だが、ここではそのまま出力
printf("\"%s\"", v->s_val);
break;
case V_ARRAY:
printf("[\n");
for (int i = 0; i < v->arr_val->count; i++) {
print_indent(indent + 1);
print_value(v->arr_val->items[i], indent + 1);
if (i < v->arr_val->count - 1) printf(",");
printf("\n");
}
print_indent(indent);
printf("]");
break;
case V_OBJECT:
printf("{\n");
for (int i = 0; i < v->obj_val->count; i++) {
print_indent(indent + 1);
printf("\"%s\": ", v->obj_val->pairs[i]->key);
print_value(v->obj_val->pairs[i]->value, indent + 1);
if (i < v->obj_val->count - 1) printf(",");
printf("\n");
}
print_indent(indent);
printf("}");
break;
}
}
// --- メイン関数 ---
int main() {
Value *data = parse("target.json");
print_value(data, 0);
printf("\n");
// 注意: 本来はここで data 内の再帰的な free 処理が必要
return 0;
}
...と思ったら、 Python への書き換えにはほんのちょっとだけ失敗しました。凡ミスも愛らしいですね。暇な方、良ければどこが問題か考えてみてください。
import sys
import json
from enum import Enum, auto
# --- 定数・Enum定義 ---
class TokenType(Enum):
BLOCK_BEGIN = auto()
BLOCK_END = auto()
ARRAY_BEGIN = auto()
ARRAY_END = auto()
COLON = auto()
STRING = auto()
INT = auto()
TRUE = auto()
FALSE = auto()
NULL = auto()
IGNORES = {' ', '\t', '\r'}
class Token:
def __init__(self, t_type, value):
self.type = t_type
self.value = value
def get_value(self):
if self.type == TokenType.INT:
return int(self.value)
elif self.type == TokenType.TRUE:
return True
elif self.type == TokenType.FALSE:
return False
elif self.type == TokenType.NULL:
return None
else:
return self.value
def __repr__(self):
return f"Token({self.type.name}, {self.value})"
# --- ヘルパー関数 ---
def askwd(kwd):
"""キーワード判定 (true, false, null, number)"""
if kwd == "true":
return Token(TokenType.TRUE, "true")
elif kwd == "false":
return Token(TokenType.FALSE, "false")
elif kwd == "null":
return Token(TokenType.NULL, "null")
else:
try:
int(kwd) # 数字チェック
return Token(TokenType.INT, kwd)
except ValueError:
sys.exit(f"不明な文字列: {kwd}")
# --- 字句解析 (Tokenize) ---
def tokenize(text):
res = []
tmp = ""
is_store = False
is_back_slash = False
i = 0
n = len(text)
while i < n:
s = text[i]
if s in IGNORES:
i += 1
continue
if is_store:
if s in ('=', ':', '\n', ',', '}'):
is_store = False
if s in ('=', ':'):
res.append(Token(TokenType.STRING, tmp))
res.append(Token(TokenType.COLON, ":"))
elif s == '}':
res.append(askwd(tmp))
res.append(Token(TokenType.BLOCK_END, "}"))
else:
res.append(askwd(tmp))
tmp = ""
else:
tmp += s
# is_storeの場合は continue せずに次の文字へ(Goのロジック準拠のためループ制御を変則的にしない)
if not is_store and s != '}': # } の場合は下のswitchに入らないようにする
pass
else:
i += 1
continue
# switch文相当
if s in (',', '\n'):
pass
elif s == '{':
res.append(Token(TokenType.BLOCK_BEGIN, "{"))
elif s == '}':
res.append(Token(TokenType.BLOCK_END, "}"))
elif s == '[':
res.append(Token(TokenType.ARRAY_BEGIN, "["))
elif s == ']':
res.append(Token(TokenType.ARRAY_END, "]"))
elif s in (':', '='):
if tmp == "":
sys.exit("構文エラー")
res.append(Token(TokenType.STRING, tmp))
tmp = ""
res.append(Token(TokenType.COLON, ":"))
elif s == '"':
string_value = ""
while i + 1 < n:
i += 1
char = text[i]
if is_back_slash:
is_back_slash = False
if char == 'n': string_value += "\n"
elif char == 'r': string_value += "\r"
elif char == 't': string_value += "\t"
elif char == '"': string_value += "\""
elif char == '\\': string_value += "\\"
else: string_value += char
elif char == '"':
res.append(Token(TokenType.STRING, string_value))
break
elif char == '\\':
is_back_slash = True
else:
string_value += char
else:
is_store = True
tmp += s
i += 1
return res
# --- 構文解析 (Parse) ---
def parse(file_path):
try:
with open(file_path, 'r', encoding='utf-8') as f:
text = f.read()
except FileNotFoundError:
sys.exit(f"File not found: {file_path}")
tokens = tokenize(text)
if not tokens:
return {}
# Pythonではintポインタの代わりにリストでインデックスをラップして参照渡しを擬似的に再現
idx = [0]
return _parse(tokens, idx)
def _parse(tokens, idx_wrapper):
res = {}
tmp = None # 直前のトークン(キー候補)
while idx_wrapper[0] < len(tokens):
t = tokens[idx_wrapper[0]]
if t.type == TokenType.COLON:
if not tmp or tmp.type != TokenType.STRING:
sys.exit("キーに出来るのは数字か文字のみです")
idx_wrapper[0] += 1 # COLONの次へ
if idx_wrapper[0] >= len(tokens): break
t = tokens[idx_wrapper[0]]
if t.type == TokenType.BLOCK_BEGIN:
idx_wrapper[0] += 1
res[tmp.value] = _parse(tokens, idx_wrapper)
elif t.type == TokenType.ARRAY_BEGIN:
idx_wrapper[0] += 1
arr = []
if idx_wrapper[0] < len(tokens):
t_next = tokens[idx_wrapper[0]]
if t_next.type == TokenType.BLOCK_BEGIN:
while idx_wrapper[0] < len(tokens) and tokens[idx_wrapper[0]].type == TokenType.BLOCK_BEGIN:
idx_wrapper[0] += 1
arr.append(_parse(tokens, idx_wrapper))
idx_wrapper[0] += 1
idx_wrapper[0] -= 1 # adjust for outer loop inc
res[tmp.value] = arr
else:
while idx_wrapper[0] < len(tokens) and t_next.type != TokenType.ARRAY_END:
arr.append(t_next.get_value())
idx_wrapper[0] += 1
t_next = tokens[idx_wrapper[0]]
res[tmp.value] = arr
else:
res[tmp.value] = t.get_value()
tmp = None # リセット
elif t.type == TokenType.BLOCK_END:
return res
else:
tmp = t
idx_wrapper[0] += 1
return res
# --- Main ---
def main():
data = parse('target.json')
# ensure_ascii=False で日本語などもそのまま表示可能
print(json.dumps(data, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
Geminiの凡ミスの答え
# is_storeの場合は continue せずに次の文字へ(Goのロジック準拠のためループ制御を変則的にしない)
+ if not is_store and (s not in {'}', ':', '='}): # } の場合は下のswitchに入らないようにする
pass
else:
i += 1
continue
ちなみに、Geminiさんはin演算にtupleを渡してますが、普通setの方が早いです。計算量は脅威の $O(1)$