minecraft-dev-starter 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +166 -0
- package/README.md +124 -2
- package/cordis.patch.yml +7 -0
- package/lib/client.js +1521 -0
- package/package.json +54 -4
- package/src/client/Capsule.js +158 -0
- package/src/client/DetailTab.js +228 -0
- package/src/client/Flyout.js +191 -0
- package/src/client/LogTab.js +180 -0
- package/src/client/Skeleton.js +18 -0
- package/src/client/api.js +33 -0
- package/src/client/debug.js +27 -0
- package/src/client/format.js +89 -0
- package/src/client/h.js +8 -0
- package/src/client/icons.js +147 -0
- package/src/client/index.js +205 -0
- package/src/client/runtime.js +41 -0
- package/src/client/selection.js +154 -0
- package/src/client/state.js +233 -0
- package/src/client/store.js +34 -0
- package/src/client/styles.js +329 -0
- package/src/index.js +193 -0
- package/src/lib/api.js +186 -0
- package/src/lib/argv.js +66 -0
- package/src/lib/detect.js +460 -0
- package/src/lib/gradle.js +295 -0
- package/src/lib/java.js +204 -0
- package/src/lib/launches.js +495 -0
- package/src/lib/metadata.js +175 -0
- package/src/lib/scanner.js +131 -0
- package/src/lib/state.js +91 -0
- package/src/lib/text.js +92 -0
- package/src/lib/toml.js +281 -0
- package/src/tools.js +291 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工作区扫描:找出哪些目录是 MC 工程,再交给 detectProject 出画像。
|
|
3
|
+
*
|
|
4
|
+
* 规则(对应策划 §6.1):
|
|
5
|
+
* - 从工作区根 BFS,深度默认 3;
|
|
6
|
+
* - 命中构建文件的目录即视为工程根,**不再往下走**(子模块由 detect 的 include 展开);
|
|
7
|
+
* - 剪掉 build/out/bin/run/.gradle 这类产物与依赖目录——不剪会把
|
|
8
|
+
* `reference/` 里别人家的 mods.toml、`bin/` 里旧的 Eclipse 产物当成工程。
|
|
9
|
+
* @module minecraft-dev-starter/lib/scanner
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdir } from 'node:fs/promises'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { detectProject } from './detect.js'
|
|
15
|
+
import { discoverJdks } from './java.js'
|
|
16
|
+
|
|
17
|
+
/** 扫描时直接跳过的目录名。 */
|
|
18
|
+
export const PRUNE_DIRS = new Set([
|
|
19
|
+
'.git',
|
|
20
|
+
'.gradle',
|
|
21
|
+
'.idea',
|
|
22
|
+
'.vscode',
|
|
23
|
+
'.dsh',
|
|
24
|
+
'build',
|
|
25
|
+
'out',
|
|
26
|
+
'bin',
|
|
27
|
+
'run',
|
|
28
|
+
'node_modules',
|
|
29
|
+
'libs',
|
|
30
|
+
'reference',
|
|
31
|
+
'logs',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
/** 构建脚本名;命中任一即认为这是一个 Gradle 工程。 */
|
|
35
|
+
const BUILD_FILES = new Set(['build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts'])
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {object} ScanResult
|
|
39
|
+
* @property {string} root
|
|
40
|
+
* @property {Array<Record<string, unknown>>} projects
|
|
41
|
+
* @property {number} scannedDirs
|
|
42
|
+
* @property {boolean} truncated
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 扫一个工作区。
|
|
47
|
+
* @param {string} root
|
|
48
|
+
* @param {{ depth?: number, maxDirs?: number, maxProjects?: number, jdks?: import('./java.js').JdkInfo[], signal?: AbortSignal }} [options]
|
|
49
|
+
* @returns {Promise<ScanResult>}
|
|
50
|
+
*/
|
|
51
|
+
export async function scanWorkspace(root, options = {}) {
|
|
52
|
+
const depth = options.depth ?? 3
|
|
53
|
+
const maxDirs = options.maxDirs ?? 2000
|
|
54
|
+
const maxProjects = options.maxProjects ?? 120
|
|
55
|
+
const jdks = options.jdks ?? (await discoverJdks())
|
|
56
|
+
|
|
57
|
+
/** @type {Array<{ dir: string, depth: number }>} */
|
|
58
|
+
const queue = [{ dir: root, depth: 0 }]
|
|
59
|
+
/** @type {string[]} */
|
|
60
|
+
const candidates = []
|
|
61
|
+
let scannedDirs = 0
|
|
62
|
+
let truncated = false
|
|
63
|
+
|
|
64
|
+
while (queue.length > 0) {
|
|
65
|
+
if (options.signal?.aborted === true) break
|
|
66
|
+
const item = queue.shift()
|
|
67
|
+
if (item === undefined) break
|
|
68
|
+
scannedDirs += 1
|
|
69
|
+
if (scannedDirs > maxDirs) {
|
|
70
|
+
truncated = true
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
if (candidates.length >= maxProjects) {
|
|
74
|
+
truncated = true
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const entries = await safeReaddir(item.dir)
|
|
79
|
+
if (entries === undefined) continue
|
|
80
|
+
|
|
81
|
+
if (item.depth > 0 && entries.files.some((name) => BUILD_FILES.has(name))) {
|
|
82
|
+
candidates.push(item.dir)
|
|
83
|
+
continue // 工程内部不再展开,模块交给 detect
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (item.depth >= depth) continue
|
|
87
|
+
for (const child of entries.dirs) {
|
|
88
|
+
if (PRUNE_DIRS.has(child)) continue
|
|
89
|
+
queue.push({ dir: join(item.dir, child), depth: item.depth + 1 })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 工作区根本身就是一个工程的情况
|
|
94
|
+
const rootEntries = await safeReaddir(root)
|
|
95
|
+
if (rootEntries !== undefined && !candidates.includes(root)) {
|
|
96
|
+
const isProject = rootEntries.files.some((name) => BUILD_FILES.has(name))
|
|
97
|
+
if (isProject) candidates.unshift(root)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
101
|
+
const projects = []
|
|
102
|
+
for (const dir of candidates) {
|
|
103
|
+
if (options.signal?.aborted === true) break
|
|
104
|
+
try {
|
|
105
|
+
projects.push(await detectProject(dir, { jdks }))
|
|
106
|
+
} catch (error) {
|
|
107
|
+
projects.push({ root: dir, loader: 'unknown', error: error instanceof Error ? error.message : String(error) })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { root, projects, scannedDirs, truncated }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {string} dir
|
|
116
|
+
* @returns {Promise<{ files: string[], dirs: string[] } | undefined>}
|
|
117
|
+
*/
|
|
118
|
+
async function safeReaddir(dir) {
|
|
119
|
+
try {
|
|
120
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
121
|
+
const files = []
|
|
122
|
+
const dirs = []
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (entry.isDirectory()) dirs.push(entry.name)
|
|
125
|
+
else if (entry.isFile()) files.push(entry.name)
|
|
126
|
+
}
|
|
127
|
+
return { files, dirs }
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/lib/state.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 状态落盘:`$DSH_HOME/mc-dev-starter/state.json` 与同目录下的日志。
|
|
3
|
+
*
|
|
4
|
+
* 写入用「临时文件 + rename」:宿主被强杀时不会留下半个 JSON。
|
|
5
|
+
* @module minecraft-dev-starter/lib/state
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 状态目录。`DSH_HOME` 优先,其次 `~/.dsh`。
|
|
14
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function stateDir(env = process.env) {
|
|
18
|
+
const home = env.DSH_HOME ?? join(env.USERPROFILE ?? homedir(), '.dsh')
|
|
19
|
+
return join(home, 'mc-dev-starter')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} dir
|
|
24
|
+
* @param {string} launchId
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function logPath(dir, launchId) {
|
|
28
|
+
return join(dir, 'logs', `${launchId}.log`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {object} LaunchRecord
|
|
33
|
+
* @property {string} id
|
|
34
|
+
* @property {string} root
|
|
35
|
+
* @property {string} taskPath
|
|
36
|
+
* @property {string} label
|
|
37
|
+
* @property {string} kind client | server | data | gameTestServer
|
|
38
|
+
* @property {string} module
|
|
39
|
+
* @property {number | null} pid
|
|
40
|
+
* @property {string} startedAt
|
|
41
|
+
* @property {string | null} endedAt
|
|
42
|
+
* @property {'starting' | 'running' | 'exited' | 'unknown'} status
|
|
43
|
+
* @property {'normal' | 'crashed' | 'killed' | 'failed' | null} outcome
|
|
44
|
+
* @property {number | null} exitCode
|
|
45
|
+
* @property {string | null} signal
|
|
46
|
+
* @property {string} logPath
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {object} PersistentState
|
|
51
|
+
* @property {number} version
|
|
52
|
+
* @property {number} nextLaunch
|
|
53
|
+
* @property {LaunchRecord[]} launches
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** @returns {PersistentState} */
|
|
57
|
+
export function emptyState() {
|
|
58
|
+
return { version: 1, nextLaunch: 1, launches: [] }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} dir
|
|
63
|
+
* @returns {Promise<PersistentState>}
|
|
64
|
+
*/
|
|
65
|
+
export async function loadState(dir) {
|
|
66
|
+
try {
|
|
67
|
+
const raw = await readFile(join(dir, 'state.json'), 'utf8')
|
|
68
|
+
const parsed = JSON.parse(raw)
|
|
69
|
+
if (parsed === null || typeof parsed !== 'object') return emptyState()
|
|
70
|
+
return {
|
|
71
|
+
version: Number(parsed.version ?? 1),
|
|
72
|
+
nextLaunch: Number(parsed.nextLaunch ?? 1),
|
|
73
|
+
launches: Array.isArray(parsed.launches) ? parsed.launches : [],
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
return emptyState()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} dir
|
|
82
|
+
* @param {PersistentState} state
|
|
83
|
+
* @returns {Promise<void>}
|
|
84
|
+
*/
|
|
85
|
+
export async function saveState(dir, state) {
|
|
86
|
+
await mkdir(dir, { recursive: true })
|
|
87
|
+
const target = join(dir, 'state.json')
|
|
88
|
+
const temp = `${target}.tmp`
|
|
89
|
+
await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
|
90
|
+
await rename(temp, target)
|
|
91
|
+
}
|
package/src/lib/text.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文本小工具:读文件、解析 .properties、解析 ${...} 占位符。
|
|
3
|
+
* 识别引擎的每个规则都靠这几样东西,单独放一处方便测试。
|
|
4
|
+
* @module minecraft-dev-starter/lib/text
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile } from 'node:fs/promises'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 读文本文件;不存在、没权限、是目录都返回 undefined(识别过程不该因为个别文件中断)。
|
|
11
|
+
* @param {string} path
|
|
12
|
+
* @returns {Promise<string | undefined>}
|
|
13
|
+
*/
|
|
14
|
+
export async function readText(path) {
|
|
15
|
+
try {
|
|
16
|
+
return await readFile(path, 'utf8')
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 解析 Java 风格的 .properties(`key=value`,`#`/`!` 注释)。
|
|
24
|
+
* 不处理续行与转义——Gradle 工程里几乎用不到。
|
|
25
|
+
* @param {string | undefined} text
|
|
26
|
+
* @returns {Record<string, string>}
|
|
27
|
+
*/
|
|
28
|
+
export function parseProperties(text) {
|
|
29
|
+
/** @type {Record<string, string>} */
|
|
30
|
+
const out = {}
|
|
31
|
+
if (!text) return out
|
|
32
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
33
|
+
const line = raw.trim()
|
|
34
|
+
if (line === '' || line.startsWith('#') || line.startsWith('!')) continue
|
|
35
|
+
const eq = line.indexOf('=')
|
|
36
|
+
if (eq <= 0) continue
|
|
37
|
+
const key = line.slice(0, eq).trim()
|
|
38
|
+
if (key === '') continue
|
|
39
|
+
out[key] = line.slice(eq + 1).trim()
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PLACEHOLDER = /\$\{([^}]+)\}/g
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 把 `${key}` 替换成属性表里的值,替换不掉的保留原文并记下来。
|
|
48
|
+
* 元数据文件(fabric.mod.json / mods.toml)普遍写占位符,不解析就只能拿到字面量。
|
|
49
|
+
* @param {string | undefined} value
|
|
50
|
+
* @param {Record<string, string>} properties
|
|
51
|
+
* @returns {{ value: string | undefined, unresolved: string[] }}
|
|
52
|
+
*/
|
|
53
|
+
export function resolvePlaceholders(value, properties) {
|
|
54
|
+
if (typeof value !== 'string' || !value.includes('${')) {
|
|
55
|
+
return { value, unresolved: [] }
|
|
56
|
+
}
|
|
57
|
+
/** @type {string[]} */
|
|
58
|
+
const unresolved = []
|
|
59
|
+
const resolved = value.replace(PLACEHOLDER, (match, rawName) => {
|
|
60
|
+
const key = String(rawName).trim()
|
|
61
|
+
if (Object.hasOwn(properties, key) && properties[key] !== '') return String(properties[key])
|
|
62
|
+
unresolved.push(key)
|
|
63
|
+
return match
|
|
64
|
+
})
|
|
65
|
+
return { value: resolved, unresolved }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 取第一个命中的捕获组。
|
|
70
|
+
* @param {string | undefined} text
|
|
71
|
+
* @param {RegExp} pattern
|
|
72
|
+
* @returns {string | undefined}
|
|
73
|
+
*/
|
|
74
|
+
export function firstMatch(text, pattern) {
|
|
75
|
+
if (!text) return undefined
|
|
76
|
+
const match = pattern.exec(text)
|
|
77
|
+
return match?.[1]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 逐条尝试一组正则,返回第一个命中。
|
|
82
|
+
* @param {string | undefined} text
|
|
83
|
+
* @param {RegExp[]} patterns
|
|
84
|
+
* @returns {string | undefined}
|
|
85
|
+
*/
|
|
86
|
+
export function firstMatchOf(text, patterns) {
|
|
87
|
+
for (const pattern of patterns) {
|
|
88
|
+
const hit = firstMatch(text, pattern)
|
|
89
|
+
if (hit !== undefined) return hit
|
|
90
|
+
}
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
package/src/lib/toml.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOML 子集解析器:只覆盖 `mods.toml` / `neoforge.mods.toml` 实际用到的语法。
|
|
3
|
+
*
|
|
4
|
+
* 支持:注释、`key = value`、基本字符串(含 `"""` / `'''` 多行)、布尔、数字、
|
|
5
|
+
* 简单数组、`[table]`、`[[array.of.tables]]`。
|
|
6
|
+
* 不支持:内联表、日期、十六进制、转义 unicode——模组元数据里没有。
|
|
7
|
+
*
|
|
8
|
+
* @module minecraft-dev-starter/lib/toml
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Record<string, unknown>} TomlTable
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} source
|
|
17
|
+
* @returns {TomlTable}
|
|
18
|
+
*/
|
|
19
|
+
export function parseToml(source) {
|
|
20
|
+
/** @type {TomlTable} */
|
|
21
|
+
const root = {}
|
|
22
|
+
/** @type {TomlTable} */
|
|
23
|
+
let current = root
|
|
24
|
+
const lines = source.split(/\r?\n/)
|
|
25
|
+
|
|
26
|
+
let i = 0
|
|
27
|
+
while (i < lines.length) {
|
|
28
|
+
const line = lines[i]
|
|
29
|
+
const trimmed = line.trim()
|
|
30
|
+
|
|
31
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
32
|
+
i += 1
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (trimmed.startsWith('[[')) {
|
|
37
|
+
const close = trimmed.indexOf(']]')
|
|
38
|
+
if (close > 0) {
|
|
39
|
+
const path = trimmed.slice(2, close).trim()
|
|
40
|
+
const list = ensureArray(root, splitKey(path))
|
|
41
|
+
/** @type {TomlTable} */
|
|
42
|
+
const table = {}
|
|
43
|
+
list.push(table)
|
|
44
|
+
current = table
|
|
45
|
+
}
|
|
46
|
+
i += 1
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (trimmed.startsWith('[')) {
|
|
51
|
+
const close = trimmed.indexOf(']')
|
|
52
|
+
if (close > 0) {
|
|
53
|
+
const path = trimmed.slice(1, close).trim()
|
|
54
|
+
current = ensureTable(root, splitKey(path))
|
|
55
|
+
}
|
|
56
|
+
i += 1
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const eq = indexOfEquals(trimmed)
|
|
61
|
+
if (eq <= 0) {
|
|
62
|
+
i += 1
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const key = unquote(trimmed.slice(0, eq).trim())
|
|
67
|
+
let rest = trimmed.slice(eq + 1).trim()
|
|
68
|
+
|
|
69
|
+
// 多行字符串:值跨行,直到出现同一对三引号
|
|
70
|
+
const triple = rest.startsWith("'''") ? "'''" : rest.startsWith('"""') ? '"""' : undefined
|
|
71
|
+
if (triple !== undefined) {
|
|
72
|
+
let body = rest.slice(3)
|
|
73
|
+
let closed = body.includes(triple)
|
|
74
|
+
while (!closed && i + 1 < lines.length) {
|
|
75
|
+
i += 1
|
|
76
|
+
body += `\n${lines[i]}`
|
|
77
|
+
closed = body.includes(triple)
|
|
78
|
+
}
|
|
79
|
+
const end = body.indexOf(triple)
|
|
80
|
+
current[key] = end >= 0 ? body.slice(0, end) : body
|
|
81
|
+
i += 1
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 普通字符串也可能带行尾注释
|
|
86
|
+
rest = stripTrailingComment(rest)
|
|
87
|
+
current[key] = parseValue(rest)
|
|
88
|
+
i += 1
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return root
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 找 `=`,跳过引号内的内容。
|
|
96
|
+
* @param {string} text
|
|
97
|
+
* @returns {number}
|
|
98
|
+
*/
|
|
99
|
+
function indexOfEquals(text) {
|
|
100
|
+
let quote
|
|
101
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
102
|
+
const ch = text[i]
|
|
103
|
+
if (quote !== undefined) {
|
|
104
|
+
if (ch === '\\') i += 1
|
|
105
|
+
else if (ch === quote) quote = undefined
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (ch === '"' || ch === "'") quote = ch
|
|
109
|
+
else if (ch === '=') return i
|
|
110
|
+
}
|
|
111
|
+
return -1
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {string} text
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
function stripTrailingComment(text) {
|
|
119
|
+
let quote
|
|
120
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
121
|
+
const ch = text[i]
|
|
122
|
+
if (quote !== undefined) {
|
|
123
|
+
if (ch === '\\') i += 1
|
|
124
|
+
else if (ch === quote) quote = undefined
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (ch === '"' || ch === "'") quote = ch
|
|
128
|
+
else if (ch === '#') return text.slice(0, i).trimEnd()
|
|
129
|
+
}
|
|
130
|
+
return text
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {string} text
|
|
135
|
+
* @returns {string[]}
|
|
136
|
+
*/
|
|
137
|
+
function splitKey(text) {
|
|
138
|
+
return text
|
|
139
|
+
.split('.')
|
|
140
|
+
.map((part) => unquote(part.trim()))
|
|
141
|
+
.filter((part) => part !== '')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {string} text
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
function unquote(text) {
|
|
149
|
+
if (text.length >= 2 && (text.startsWith('"') || text.startsWith("'"))) {
|
|
150
|
+
return text.slice(1, -1)
|
|
151
|
+
}
|
|
152
|
+
return text
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {string} raw
|
|
157
|
+
* @returns {unknown}
|
|
158
|
+
*/
|
|
159
|
+
function parseValue(raw) {
|
|
160
|
+
const text = raw.trim()
|
|
161
|
+
if (text === '') return ''
|
|
162
|
+
if (text.startsWith('"') || text.startsWith("'") || text.startsWith('"""') || text.startsWith("'''")) {
|
|
163
|
+
return parseString(text)
|
|
164
|
+
}
|
|
165
|
+
if (text.startsWith('[')) return parseArray(text)
|
|
166
|
+
if (text === 'true') return true
|
|
167
|
+
if (text === 'false') return false
|
|
168
|
+
const number = Number(text)
|
|
169
|
+
return Number.isNaN(number) ? text : number
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {string} text
|
|
174
|
+
* @returns {string}
|
|
175
|
+
*/
|
|
176
|
+
function parseString(text) {
|
|
177
|
+
const triple = text.startsWith('"""') ? '"""' : text.startsWith("'''") ? "'''" : undefined
|
|
178
|
+
if (triple !== undefined) {
|
|
179
|
+
const body = text.slice(3)
|
|
180
|
+
const end = body.indexOf(triple)
|
|
181
|
+
return end >= 0 ? body.slice(0, end) : body
|
|
182
|
+
}
|
|
183
|
+
const quote = text[0]
|
|
184
|
+
let out = ''
|
|
185
|
+
for (let i = 1; i < text.length; i += 1) {
|
|
186
|
+
const ch = text[i]
|
|
187
|
+
if (ch === '\\' && i + 1 < text.length) {
|
|
188
|
+
const next = text[i + 1]
|
|
189
|
+
out += next === 'n' ? '\n' : next === 't' ? '\t' : next
|
|
190
|
+
i += 1
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
if (ch === quote) break
|
|
194
|
+
out += ch
|
|
195
|
+
}
|
|
196
|
+
return out
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param {string} text
|
|
201
|
+
* @returns {Array<unknown>}
|
|
202
|
+
*/
|
|
203
|
+
function parseArray(text) {
|
|
204
|
+
const inner = text.slice(1, text.lastIndexOf(']'))
|
|
205
|
+
/** @type {Array<unknown>} */
|
|
206
|
+
const out = []
|
|
207
|
+
let buffer = ''
|
|
208
|
+
let quote
|
|
209
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
210
|
+
const ch = inner[i]
|
|
211
|
+
if (quote !== undefined) {
|
|
212
|
+
buffer += ch
|
|
213
|
+
if (ch === '\\') {
|
|
214
|
+
buffer += inner[i + 1] ?? ''
|
|
215
|
+
i += 1
|
|
216
|
+
} else if (ch === quote) quote = undefined
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
if (ch === '"' || ch === "'") {
|
|
220
|
+
quote = ch
|
|
221
|
+
buffer += ch
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
if (ch === ',') {
|
|
225
|
+
pushItem(out, buffer)
|
|
226
|
+
buffer = ''
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
buffer += ch
|
|
230
|
+
}
|
|
231
|
+
pushItem(out, buffer)
|
|
232
|
+
return out
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @param {Array<unknown>} out
|
|
237
|
+
* @param {string} raw
|
|
238
|
+
*/
|
|
239
|
+
function pushItem(out, raw) {
|
|
240
|
+
const text = raw.trim()
|
|
241
|
+
if (text === '') return
|
|
242
|
+
out.push(parseValue(text))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {TomlTable} root
|
|
247
|
+
* @param {string[]} path
|
|
248
|
+
* @returns {TomlTable}
|
|
249
|
+
*/
|
|
250
|
+
function ensureTable(root, path) {
|
|
251
|
+
/** @type {TomlTable} */
|
|
252
|
+
let node = root
|
|
253
|
+
for (const key of path) {
|
|
254
|
+
const next = node[key]
|
|
255
|
+
if (next !== null && typeof next === 'object' && !Array.isArray(next)) {
|
|
256
|
+
node = /** @type {TomlTable} */ (next)
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
/** @type {TomlTable} */
|
|
260
|
+
const created = {}
|
|
261
|
+
node[key] = created
|
|
262
|
+
node = created
|
|
263
|
+
}
|
|
264
|
+
return node
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @param {TomlTable} root
|
|
269
|
+
* @param {string[]} path
|
|
270
|
+
* @returns {Array<TomlTable>}
|
|
271
|
+
*/
|
|
272
|
+
function ensureArray(root, path) {
|
|
273
|
+
const parent = ensureTable(root, path.slice(0, -1))
|
|
274
|
+
const key = path.at(-1) ?? ''
|
|
275
|
+
const existing = parent[key]
|
|
276
|
+
if (Array.isArray(existing)) return /** @type {Array<TomlTable>} */ (existing)
|
|
277
|
+
/** @type {Array<TomlTable>} */
|
|
278
|
+
const list = []
|
|
279
|
+
parent[key] = list
|
|
280
|
+
return list
|
|
281
|
+
}
|