Javascript で INI ファイルの読み書き

WSH/Jscript で動く GetPrivateProfileString()/WritePrivateProfileString() のクローンを探したけど、これといったものが見当たらなかったので再発明してみた。変なデータに遭遇した場合を含め、できるだけ GetPrivateProfileString()/WritePrivateProfileString() と同じ挙動にしたつもり*1

仕様

  • 1回の呼び出しごとに処理が完結する。
  • セミコロンで始まる行はコメント。
  • 開き角括弧で始まらず、かつ、イコールを含まない行はコメント扱い。
  • 書き換えをしてもコメント部分はそのまま維持。
  • 読み込み時、行頭・行末の空白類文字は除去する。つまり、INI ファイル内では任意のインデントを許容。
[セクション1]
; ここはコメント
        ; ここもコメント
キー1=値1 ; ここはコメントじゃない
この行はコメント扱い
  • 無名セクション/無名キーも問題なく読み書き可能。
[]
=値
; ↑この値は getini(inifilename, "", "") で取得できる
  • 重複セクション/重複キーは最初に出現したものを処理対象とし、それ以外は無視。
[セクション1]
キー1=値A
キー1=値B
; ↑2つめの「キー1=値B」は読み書き不可能
[セクション1]
キー1=値C
キー2=値D
; ↑2つめの「[セクション1]」と「キー1=値C」「キー2=値D」 は読み書き不可能
  • セクションの閉じ括弧はなくてもいい。
[セクション1
↑ これでもセクションになる
  • セクション名/キー名の大文字小文字は区別しない。上書き時は INI ファイル側の表記を維持。
  • セクション名/キー名の前後の空白類文字は除去する。上書き時は INI ファイル側の表記を維持。
  • 読み込み時、値の前後の空白類文字は除去する。ただし、引用符 ("〜" または '〜') で囲まれた値は引用符の内側がそのまま読み込まれる。
キー1 = "ho" "ge" 
; ↑この値は「ho" "ge」となる

使用例

// 値を設定
putini(inifilename, "セクション1", "キー1", "値1");
putini(inifilename, "セクション1", "キー2", "値2");

// 値を取得
var result = getini(inifilename, "セクション1", "キー1"); //=> "値1"
WScript.Echo(result);

// 指定したセクションの全エントリをハッシュとして取得
var result = "";
var data = getini(inifilename, "セクション1"); //=> {"キー1": "値1", "キー2": "値2"}
for (key in data) {
	result += key + "=" + data[key] + "\n";
}
WScript.Echo(result);

// 全セクションの全エントリをハッシュのハッシュとして取得
var result = "";
var data = getini(inifilename); //=> {"セクション1": {"キー1": "値1", "キー2": "値2"}}
for (section in data) {
	result += "[" + section + "]\n";
	for (key in data[section]) {
		result += key + "=" + data[section][key] + "\n";
	}
}
WScript.Echo(result);

// エントリを削除
putini(inifilename, "セクション1", "キー1", null);

// セクションを削除
putini(inifilename, "セクション1", null, null);

ソース

// INI ファイルから値を読む
// 	getini(filename, section, key): そのエントリの値を取得
// 	getini(filename, section): そのセクションの全エントリをハッシュとして取得
// 	getini(filename): 全セクションの全エントリをハッシュのハッシュとして取得
function getini(filename, section, key) {
	section = (typeof(section) == "undefined") ? null : strip(section);
	key     = (typeof(key)     == "undefined") ? null : strip(key);
	var data = new Array();
	try {
		var f = fso.OpenTextFile(filename, 1, false); // 読み込み
		var pos = 0; // 0:セクション前  1:セクション内
		while (!f.AtEndOfStream) {
			var line = f.ReadLine();
			if (!line.match(/^\s*;/)) {
				if (m = line.match(/^\s*\[([^\]]*)\]/)) {
					if (section === null) {
						data[strip(m[1])] = new Array();
					} else if (pos == 0 && casecmp(strip(m[1]), section)) {
						pos = 1;
					} else if (pos == 1) {
						break;
					}
				} else if (pos == 1 && (m = line.match(/^\s*(.*?)=(.*)$/))) {
					var v = strip(m[2]).replace(/^(["'])(.*)\1$/, "$2");
					if (key === null) {
						data[strip(m[1])] = v;
					} else if (casecmp(strip(m[1]), key)) {
						data = v; // typeof(data) == "string"
						break;
					}
				}
			}
		}
		f.Close();
	} catch(e) {
		if (e.number != -0x7ff5ffcb) {	// 「ファイルが見つかりません」以外のエラー
			WScript.Echo(WScript.ScriptFullName + "\n" + filename + "\n" + e.description);
		}
	}
	if (section === null) {
		for (var s in data) {
			data[s] = getini(filename, s);
		}
	} else if (key !== null && typeof(data) != "string") {
		data = null;
	}
	return data;
}

// INI ファイルに値を書く
// 	putini(filename, section, key, value): そのエントリに値を設定
// 	putini(filename, section, key, null): そのエントリを削除
// 	putini(filename, section, null, null): そのセクションを削除
function putini(filename, section, key, value) {
	if (typeof(key) == "undefined") return false;
	if (typeof(value) == "undefined") return false;
	section = strip(section);
	key = strip(key);
	try {
		var f = fso.OpenTextFile(filename, 1, true); // 読み込み/新規作成
		var upper = "";
		var lower = "";
		var pos = 0; // 0:セクション前  1:セクション内  2:セクション後
		while (!f.AtEndOfStream) {
			var line = f.ReadLine();
			if (!line.match(/^\s*;/)) {
				if (m = line.match(/^\s*\[([^\]]*)\]/)) {
					if (pos == 0 && casecmp(strip(m[1]), section)) {
						pos = 1;
						if (key === null) continue;
						upper += line + "\n";
						continue;
					} else if (pos == 1) {
						pos = 2;
					}
				} else if (pos == 1 && (m = line.match(/^(.*?)=(.*)$/))) {
					if (key === null) continue;
					upper += lower;
					lower = "";
					if (casecmp(strip(m[1]), key)) {
						key = m[1];
						break;
					}
					upper += line + "\n";
					continue;
				}
			}
			lower += line + "\n";
			if (pos >= 2) break;
		}
		if (key !== null && value !== null) {
			if (pos) {	// エントリを追加/置換
				upper += key + "=" + value + "\n";
			} else {	// セクションとエントリを追加
				lower += "[" + section + "]\n" + key + "=" + value + "\n";
			}
		}
		if (!f.AtEndOfStream) {
			lower += f.ReadAll();
		}
		f.Close();
		var f = fso.OpenTextFile(filename, 2, true); // 書き込み
		f.Write(upper + lower);
		f.Close();
	} catch(e) {
		WScript.Echo(WScript.ScriptFullName + "\n" + filename + "\n" + e.description);
	}
	return upper + lower;
}

function strip(arg) {
	return (typeof(arg) == "string") ? arg.replace(/^\s+|\s+$/g, "") : arg;
}

function casecmp(arg1, arg2) {
	if (typeof(arg1) == "string") arg1 = arg1.toLowerCase();
	if (typeof(arg2) == "string") arg2 = arg2.toLowerCase();
	return (arg1 == arg2);
}

お願い

これってグローバル名前空間を汚染するよねぇ…。どなたかうまいことパッケージ化してくださいませんか?
とりあえず↓こんなんやってみたけど、はたしてこれがベストプラクティスなのかどうか全然わからない ヽ(´д`)ノ

var IniFile = (function() {
	var fso = WScript.CreateObject("Scripting.FileSystemObject");
	var strip = function strip(arg) {
		...
	};
	var casecmp = function casecmp(arg1, arg2) {
		...
	};
	return {
		get: function(filename, section, key) {
			...
		},
		put: function(filename, section, key, value) {
			...
		}
	};
})();

10/27 追記

バグ修正しました。

*1:秀丸マクロの getinistr/writeinistr をリファレンスとした