开始
通常在AHK中使用配置文件是通过内置的INI函数,也可以使用json或yaml,已有外部ahk脚本可以操作它们。
但这些文件在ahk场景实际使用上都不怎么方便。
于是我计划设计一种简洁的配置文件语法,可以在轻量级场景下替代这些常用的文件。
其中最重要的一点是简单,以至于可以轻松的在记事本中编辑。
设计语法
数据类型
对于任意一种配置文件,都具有一对一、一对多和可选的多对多表示。
此文件同理,但由于数据是顺序表示的,从上到下,上层数据无法感知下层数据,所以不存在多对多关系。
一对一使用键值对
表示;一对多使用数组
或对象
表示。
数据表示
参考其他文件的表示形式,去除层级的yml十分适合,所以设计以下表示:
- 一对一
key : value
- 一对多
key :
- v1
- v2
key :
+ k1 : v1
+ k2 : v2
因为去除了层级表示,无法表示复杂的嵌套类型。这样做是个好决定,在脱离智能编辑器时也可以轻松编辑。
目前为止,决定了文件的数据类型和表示形式,还需要更多语法来使它不那么‘贫瘠’。
注释
使用#
作为注释符,使用上与yml
一致,可以出现在行头或行尾。
同时,以---
开头的行也是注释,这是语法糖,可以作为分界线使用。
导入文件
导入语法允许抽离出部分数据到单独文件中,由其他文件导入。
导入以@
开头,可以全局或局部导入。
- 全局导入在文档开头,为了使文档不那么混乱,规定导入语句必须出现在数据之前。
- 局部导入在
值
部分,可以将独立文档的数据作为值,故可以实现间接的嵌套表示。
示例文档如下:
# 导入前可以使用注释
@./cfg1.txt # 全局导入
key : @./cfg2.txt # 局部导入
引用
引用允许在值部分引用上层数据的值,在基本和复杂类型中有不同表示。
使用$
包围的部分被认为是引用,复杂类型只能引用子项,使用[]
取值。
以下示例说明对基本类型和复杂类型中的引用:
key : 基本类型的值
arr :
- 数组的值
obj :
+ k1 : 对象的值
ref : $key$_$arr[1]$_$obj[k1]$
转义
使用```(重音符)将特殊字符转义。
修饰符
可以在基本类型键前添加符号,使它具有特殊语法约束。
目前只有两种修饰符:*
与~
。
*
用于在文件被导入时,使此值无法被覆盖。
~
用于表示此值是原义的,特殊字符被忽略。
示例如下:
*vital : value # 被导入时无法再使用此键名
~literal : @#$`
优化
以上设计了文件的语法,现在添加更多语法糖来改进它。
美观
注意到上面提供的示例文件都是对齐的,这是添加特殊语法的缘故。
允许:
左右添加空格用来对齐,这些空格将被忽略;同时,行尾注释前的空格也被忽略。
内置引用
在脚本中预设一组值,可以在文档中直接引用,如A_Desktop表示桌面路径。
更多优化有待探索,目前只有这两个。
实现
接下来,使用ahk实现对此文档的解析。
为了更好的错误提示,脚本使用逐行逐字符的方式解析,这样做可以使警告和错误精确到字符。
分析
对于文档中的每一行,可以分为以下情况:
- 导入
- 普通键值
- 带修饰符的键值
- 数组键
- 数组值
- 对象键
- 对象值
每行又可分为:
- 键
- 值
- 注释
故,声明三个方法:processImport
,processLine
,processValue
分别处理全局导入、行及行中的值。
有这三个方法之后,解析文档就变的简单了,在逐行解析中:
- 如果遇到
@
符,调用processImport
,处理当前行及接下来可能的导入行 - 如果遇到
#
符或空行,跳过 - 其他情况都调用
processLine
在处理行中:
- 提取键声明
- 如果以':'结尾,则是复杂类型,进入新的循环处理。
- 否则是基本类型,值部分使用
processValue
处理。
- 提取值内容
- 处理引用
- 处理导入
- ...
经过以上分析,将文档剖析的很彻底了,再写代码便很简单。
代码
以下是完整代码,包含的脚本过长,便没有贴出。
最好是拉取仓库中的代码,此脚本位于/util/config
下,同时包括readme详细介绍了完整语法。
#Requires AutoHotkey v2.0
#Include ..\..\Extend.ahk
#Include ..\..\Path.ahk
class CustomFS {
data := Map(), cfgs := Map(), vital := Map()
, escChar := '``', refChar := '$', commentChar := '#', importChar := '@', vitalChar := '*', literalChar := '~', q := "'"
static preset := Map(
'a_mydocuments', A_MyDocuments,
'a_username', A_UserName,
'a_startup', A_Startup,
'a_now', A_Now,
'a_desktop', A_Desktop,
'a_scriptdir', A_ScriptDir,
'a_scriptfullpath', A_ScriptFullPath,
'a_ahkpath', A_AhkPath,
'a_tab', A_Tab,
'a_newline', '`n',
)
__New(_path, _warn) {
if !FileExist(_path)
throw Error('读取的文件不存在:' _path)
this.path := !Path.IsAbsolute(_path) ? Path.Resolve(A_ScriptDir, _path) : _path, this.doWarn := _warn, this.Init(this.path)
}
static Of(_path, _warn := false) => CustomFS(_path, _warn)
Init(_path) {
f := FileOpen(_path, 'r', 'utf-8'), r := 0, ec := this.escChar, rc := this.refChar, cc := this.commentChar, lc := this.literalChar
, ic := this.importChar, vc := this.vitalChar, import := false, cp := _path, this.cfgs.Set(cp.toLowerCase(), this.cfgs.Count + 1)
while !f.AtEOF {
r++, l := f.ReadLine()
if !import and l and l[1] = ic
l := _processImport(l, _path)
if !l or l[1] = cc or l ~= '^---'
continue
if l[1] = A_Space {
Warn('忽略了一行以空格开头的内容(' l ')', 0, l, cp)
continue
}
if l[1] = '-'
ThrowErr('错误的语法: 以-开头而不在数组定义范围内', 0, l, cp)
else if l[1] = '+'
ThrowErr('错误的语法: 以+开头而不在对象定义范围内', 0, l, cp)
import := true, _processLine(l)
}
return f.Close()
_processImport(_l, _cp) {
while true {
if this.cfgs.Has((_cp := _getNextPath()).toLowerCase())
ThrowErr('导入重复文件:' _cp, 1, _l, cp)
if !FileExist(_cp)
ThrowErr('导入不存在的文件:' _cp, 1, _l, cp)
this.Init(_cp), _l := f.ReadLine(), r++
if !_l or _l[1] != ic or f.AtEOF
break
}
return _l
_getNextPath() => Path.IsAbsolute(_cp) ? _cp : Path.Join(Path.Dir(_cp), _processValue(_l, 2))
}
_processLine(_line) {
if _line[1] = ic
Warn('以导入符开头的键,考虑是否为导入语句', 1, _line, cp)
else if _line[1] = lc {
_line := _line.subString(2), ori := true
} else if _line[1] = vc
_line := _line.subString(2), impt := true
i := 1, cs := _line.toCharArray(), _jumpToChar(cs, ':', &i, '无效的键,键须以:结尾'), k := _processValue(_line.substring(1, i++), 1, true)
if k[1] = ':'
Warn('以键值分隔符开头的键会造成混淆', 1, _line, cp)
if IsSet(ori) and ori
return _set(k, _line.substring(i), i, l, cp)
if i <= cs.Length and cs[i] = A_Space
_skipChar(cs, A_Space, &i)
if i > cs.Length or cs[i] = cc {
if f.AtEOF
ThrowErr('不允许空的复杂类型', i, _line, cp)
l := f.ReadLine(), r++, isArr := l[1] = '-'
if l[1] != '-' and l[1] != '+'
ThrowErr('第一个子项必须与键连续', i, l, cp)
vs := isArr ? [] : {}, pc := isArr ? '-' : '+', _set(k, vs, 1, l, cp)
while true {
if !l or l[1] != pc
break
if isArr
_l := LTrim(l.substring(2), A_Space), vs.Push(_processValue(_l, 1))
else {
cs := (_l := LTrim(l.substring(2), A_Space)).toCharArray(), _jumpToChar(cs, ':', &_i := 1, '无效的键')
_k := RTrim(_l.substring(1, _i)), vs.%_k% := _processValue(LTrim(_l.substring(_i + 1)), 1)
}
l := f.ReadLine(), r++
}
if !f.AtEOF and l
_processLine(l)
} else _set(k, _processValue(_line, i), 1, _line, cp)
IsSet(impt) && this.vital.Set(k, [cp, r])
}
_processValue(_lt, _idx, _raw := false) {
s := '', cs := _lt.toCharArray(), inQ := false, q := this.q
if !_raw and cs[_idx] = ic {
_p := _processValue(_lt, _idx + 1, true)
if !FileExist(_p)
ThrowErr('文件不存在:' _p, _idx, _lt, cp)
else return CustomFS(Path.IsAbsolute(_p) ? _p : Path.Join(Path.Dir(cp), _p), true).data
}
while _idx <= cs.Length {
esc := false
if cs[_idx] = A_Tab
ThrowErr('不允许使用Tab', _idx, _lt, cp)
else if cs[_idx] = ec
esc := true, _idx++
if _idx > cs.Length
ThrowErr('转义符后应接任意字符', _idx, _lt, cp)
if !inQ and cs[_idx] = A_Space {
_i := _idx, _skipChar(cs, A_Space, &_idx)
if _idx <= cs.Length and cs[_idx] != cc
Warn(JoinStr('', '忽略了一条值的后续内容(', _lt.substring(_i), '),因为没有在', q, '内使用空格'), _idx, _lt, cp)
break
} else if !esc and cs[_idx] = q
inQ := !inQ
else if !esc and cs[_idx] = cc {
if inQ
Warn('错误的读取到注释符,考虑是否需要转义', _idx, _lt, cp), s .= cs[_idx]
else Warn('错误的读取到注释符,考虑是否正确闭合引号', _idx, _lt, cp), s .= cs[_idx]
} else if !_raw and cs[_idx] = rc and !esc {
_i := ++_idx, _jumpToChar(cs, rc, &_idx, '未找到成对的引用符'), _k := _lt.substring(_i, _idx)
if !_has(_k) {
if RegExMatch(_k, '\[(.*?)\]$', &re) {
_k := _k.substring(1, re.Pos)
try _v := (_o := _get(_k))[re[1]]
catch
ThrowErr('无效的引用:' re[1], _idx, _lt, cp)
if !_v and TypeIsObj(_o)
ThrowErr('无效的对象子项引用:' re[1], _idx, _lt, cp)
} else ThrowErr('引用不存在的键或预设值:' _k, _idx, _lt, cp)
} else _v := _get(_k)
if !IsPrimitive(_v)
ThrowErr('无法引用复杂类型', _idx, _lt, cp)
s .= _v
} else s .= cs[_idx]
if _idx = cs.Length and inQ
ThrowErr('未正确闭合引号', _idx, _lt, cp)
_idx++
}
return s
}
_set(_k, _v, _c, _l, _f) {
if this.vital.Has(_k)
DCon(this.vital.Get(_k), &_f, &_r), ThrowErr('无法覆盖标记为重要的键:' _k, 1, '*' _l, '(重要键所在文件)' _f, _r)
if this.data.Has(_k)
Warn('覆盖已有的键:' _k, _c, _l, _f)
this.data.Set(_k, _v)
}
_has(_k) => this.data.Has(_k) || CustomFS.preset.Has(_k.toLowerCase())
_get(_k) => this.data.Has(_k) ? this.data.Get(_k) : CustomFS.preset.Get(_k.toLowerCase())
_jumpToChar(_chars, _char, &_idx, _msg) {
while _idx <= _chars.Length and _chars[_idx] != _char
_idx++
if _msg and _idx > _chars.Length
ThrowErr(_msg, _idx - 1, _chars.Join(''), cp)
}
_skipChar(_chars, _char, &_idx) {
while _idx <= _chars.Length and _chars[_idx] = _char
_idx++
}
ThrowErr(msg, _c, _l, _f, _r := r) {
throw Error(JoinStr('', msg, '`n异常文件:', _f, '`n[行' _r, '列' _c ']', _l))
}
Warn(msg, _c, _l, _f, _r := r) => (
this.doWarn && MsgBox(JoinStr(
'', '`n' msg, '`n异常文件:', _f, '`n[行' _r, '列' _c ']', _l
), , 0x30)
)
}
Get(key, default := '') => this.data.Get(key, default)
}