hivecode-mcp 0.8.0 → 0.11.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/README.md +116 -68
- package/core.js +62 -13
- package/hive-mcp.js +489 -50
- package/icr.js +502 -35
- package/lang-brace.js +240 -174
- package/lang-js.js +642 -418
- package/lang-json.js +184 -0
- package/lang-python.js +360 -149
- package/lang-ruby.js +230 -0
- package/lang-toml.js +136 -0
- package/lang-yaml.js +151 -0
- package/package.json +97 -70
- package/sync.js +413 -12
- package/MCP.md +0 -77
package/lang-ruby.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Ruby language provider for ICR — keyword-nesting structural splitter (def/class/
|
|
2
|
+
// module … end). Same honest-heuristic contract as the brace/python providers: not a
|
|
3
|
+
// full parser, but conservative — anything it can't confidently structure falls back
|
|
4
|
+
// to the engine's safe line tier, so output is never MORE broken than the inputs.
|
|
5
|
+
//
|
|
6
|
+
// Structure model: a line-oriented scan tracking strings, comments (# and =begin/=end),
|
|
7
|
+
// bracket depth, and KEYWORD depth (def/class/module/if/while/… open; `end` closes).
|
|
8
|
+
// Top-level def/class/module blocks become keyed units; the intent layer (dangling
|
|
9
|
+
// refs, rename + call-site rewrite, token merge) rides on a lexer like Python's.
|
|
10
|
+
|
|
11
|
+
function scannerAt(src) {
|
|
12
|
+
const n = src.length
|
|
13
|
+
return function at(i) {
|
|
14
|
+
if (src[i] === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; return j }
|
|
15
|
+
if (src[i] === '"' || src[i] === "'") {
|
|
16
|
+
const q = src[i]; let j = i + 1
|
|
17
|
+
while (j < n) { if (src[j] === '\\') { j += 2; continue } if (src[j] === q) { j++; break } j++ }
|
|
18
|
+
return j
|
|
19
|
+
}
|
|
20
|
+
return -1
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Per physical line: the line's CODE text (strings/comments blanked), its bracket
|
|
25
|
+
// delta, and byte range. =begin/=end block comments are whole-line constructs.
|
|
26
|
+
function codeLines(src) {
|
|
27
|
+
const at = scannerAt(src)
|
|
28
|
+
const out = []
|
|
29
|
+
let i = 0, inBlockComment = false
|
|
30
|
+
const n = src.length
|
|
31
|
+
while (i < n) {
|
|
32
|
+
let e = src.indexOf('\n', i); e = e < 0 ? n : e + 1
|
|
33
|
+
const raw = src.slice(i, e)
|
|
34
|
+
if (inBlockComment) {
|
|
35
|
+
if (/^=end\b/.test(raw)) inBlockComment = false
|
|
36
|
+
out.push({ start: i, end: e, code: '', brackets: 0 })
|
|
37
|
+
i = e; continue
|
|
38
|
+
}
|
|
39
|
+
if (/^=begin\b/.test(raw)) { inBlockComment = true; out.push({ start: i, end: e, code: '', brackets: 0 }); i = e; continue }
|
|
40
|
+
let code = '', k = i, brackets = 0
|
|
41
|
+
while (k < e) {
|
|
42
|
+
const j = at(k)
|
|
43
|
+
if (j > k) { if (src[k] !== '#') code += ' '; k = Math.min(e, j); continue }
|
|
44
|
+
const c = src[k]
|
|
45
|
+
if (c === '(' || c === '[' || c === '{') brackets++
|
|
46
|
+
else if (c === ')' || c === ']' || c === '}') brackets--
|
|
47
|
+
code += c; k++
|
|
48
|
+
}
|
|
49
|
+
out.push({ start: i, end: e, code, brackets })
|
|
50
|
+
i = e
|
|
51
|
+
}
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Keyword depth delta for one line of CODE. Conservative rules for the ambiguous
|
|
56
|
+
// keywords (modifier `x if y` does not open; `while … do` opens once, not twice;
|
|
57
|
+
// endless `def foo = expr` does not open).
|
|
58
|
+
const OPENERS = new Set(['if', 'unless', 'while', 'until', 'case', 'begin', 'for'])
|
|
59
|
+
function keywordDelta(code) {
|
|
60
|
+
const words = code.match(/[A-Za-z_]\w*[?!]?/g) || []
|
|
61
|
+
let d = 0
|
|
62
|
+
const trimmed = code.trim()
|
|
63
|
+
const endless = /\bdef\s+[A-Za-z_][\w.?!]*(\([^)]*\))?\s*=(?!=)/.test(code)
|
|
64
|
+
const hasLoop = /\b(while|until|for)\b/.test(code)
|
|
65
|
+
for (let w = 0; w < words.length; w++) {
|
|
66
|
+
const word = words[w]
|
|
67
|
+
if (word === 'def') { if (!endless) d++ }
|
|
68
|
+
else if (word === 'class' || word === 'module') d++
|
|
69
|
+
else if (OPENERS.has(word)) {
|
|
70
|
+
// statement position only: first word of the line, or right after an assignment
|
|
71
|
+
const isFirst = trimmed.startsWith(word)
|
|
72
|
+
const afterAssign = new RegExp('=\\s*' + word + '\\b').test(code)
|
|
73
|
+
if (word === 'while' || word === 'until' || word === 'for') { if (isFirst) d++ }
|
|
74
|
+
else if (isFirst || afterAssign) d++
|
|
75
|
+
}
|
|
76
|
+
else if (word === 'do') { if (!hasLoop) d++ }
|
|
77
|
+
else if (word === 'end') d--
|
|
78
|
+
}
|
|
79
|
+
return d
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const HEAD = /^\s*(def)\s+(self\.)?([A-Za-z_][\w.?!]*)/
|
|
83
|
+
const CONT = /^\s*(class|module)\s+(?:<<\s*self|([A-Z]\w*(?:::[A-Z]\w*)*))/
|
|
84
|
+
|
|
85
|
+
function keyOf(text) {
|
|
86
|
+
let m = text.match(HEAD); if (m) return 'def:' + (m[2] || '') + m[3]
|
|
87
|
+
m = text.match(CONT); if (m) return m[1] + ':' + (m[2] || 'self')
|
|
88
|
+
return 'stmt:' + text.split('\n')[0].trim().replace(/\s+/g, ' ')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Units over a region: a def/class/module head at keyword-depth 0 owns everything
|
|
92
|
+
// until its matching `end`; other depth-0 lines are statements (joined while
|
|
93
|
+
// brackets stay open).
|
|
94
|
+
function blocks(src) {
|
|
95
|
+
const L = codeLines(src)
|
|
96
|
+
const units = []
|
|
97
|
+
let i = 0
|
|
98
|
+
while (i < L.length) {
|
|
99
|
+
while (i < L.length && L[i].code.trim() === '') i++
|
|
100
|
+
if (i >= L.length) break
|
|
101
|
+
const start = L[i].start
|
|
102
|
+
const head = L[i].code
|
|
103
|
+
if (/^\s*(def|class|module)\b/.test(head)) {
|
|
104
|
+
let depth = 0, brackets = 0
|
|
105
|
+
do {
|
|
106
|
+
depth += keywordDelta(L[i].code)
|
|
107
|
+
brackets += L[i].brackets
|
|
108
|
+
i++
|
|
109
|
+
} while (i < L.length && (depth > 0 || brackets > 0))
|
|
110
|
+
units.push({ start, end: L[i - 1].end })
|
|
111
|
+
} else {
|
|
112
|
+
let brackets = L[i].brackets, depth = keywordDelta(L[i].code)
|
|
113
|
+
i++
|
|
114
|
+
while (i < L.length && (brackets > 0 || depth > 0)) { brackets += L[i].brackets; depth += keywordDelta(L[i].code); i++ }
|
|
115
|
+
units.push({ start, end: L[i - 1].end })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return units
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function keyed(raw, src) {
|
|
122
|
+
const seen = new Map(), out = []
|
|
123
|
+
for (const u of raw) {
|
|
124
|
+
const text = src.slice(u.start, u.end)
|
|
125
|
+
let k = keyOf(text)
|
|
126
|
+
const c = (seen.get(k) || 0) + 1; seen.set(k, c)
|
|
127
|
+
if (c > 1) k += '#' + c
|
|
128
|
+
out.push({ key: k, text, start: u.start, end: u.end })
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Approximate parse: brackets balanced AND keyword nesting closes exactly
|
|
134
|
+
// (never negative, ends at zero) AND no unterminated block comment.
|
|
135
|
+
function wellNested(src) {
|
|
136
|
+
const L = codeLines(src)
|
|
137
|
+
let depth = 0, brackets = 0
|
|
138
|
+
for (const l of L) {
|
|
139
|
+
depth += keywordDelta(l.code)
|
|
140
|
+
brackets += l.brackets
|
|
141
|
+
if (depth < 0 || brackets < 0) return false
|
|
142
|
+
}
|
|
143
|
+
return depth === 0 && brackets === 0
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Lexer for the intent layer + token merge (comments in gaps, strings one token).
|
|
147
|
+
function lexTokens(src) {
|
|
148
|
+
const at = scannerAt(src)
|
|
149
|
+
const out = []
|
|
150
|
+
const n = src.length
|
|
151
|
+
let i = 0
|
|
152
|
+
while (i < n) {
|
|
153
|
+
const c = src[i]
|
|
154
|
+
if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { i++; continue }
|
|
155
|
+
const j = at(i)
|
|
156
|
+
if (j > i) {
|
|
157
|
+
if (c === '#') { i = j; continue }
|
|
158
|
+
out.push({ start: i, end: j, k: src.slice(i, j) }); i = j; continue
|
|
159
|
+
}
|
|
160
|
+
if (/[A-Za-z_]/.test(c)) { let e = i + 1; while (e < n && /\w/.test(src[e])) e++; if (src[e] === '?' || src[e] === '!') e++; out.push({ start: i, end: e, k: src.slice(i, e) }); i = e; continue }
|
|
161
|
+
if (/[0-9]/.test(c)) { let e = i + 1; while (e < n && /[\w.]/.test(src[e])) e++; out.push({ start: i, end: e, k: src.slice(i, e) }); i = e; continue }
|
|
162
|
+
out.push({ start: i, end: i + 1, k: c }); i++
|
|
163
|
+
}
|
|
164
|
+
return out
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Reference position: not `.name` (method call on receiver) — `:name` symbols DO
|
|
168
|
+
// count (send(:helper) is a real reference; over-inclusion is the cautious direction).
|
|
169
|
+
function isRefToken(src, t) {
|
|
170
|
+
if (!/^[A-Za-z_]\w*[?!]?$/.test(t.k)) return false
|
|
171
|
+
let p = t.start - 1
|
|
172
|
+
while (p >= 0 && (src[p] === ' ' || src[p] === '\t')) p--
|
|
173
|
+
return !(p >= 0 && src[p] === '.')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const ruby = {
|
|
177
|
+
id: 'ruby',
|
|
178
|
+
exts: ['.rb', '.rake'],
|
|
179
|
+
parses: (src) => wellNested(String(src == null ? '' : src)),
|
|
180
|
+
parsesUnit: (src) => wellNested(String(src)),
|
|
181
|
+
units: (src) => keyed(blocks(String(src)), String(src)),
|
|
182
|
+
declaredNames: (src) => {
|
|
183
|
+
const names = new Set()
|
|
184
|
+
for (const u of keyed(blocks(String(src)), String(src))) {
|
|
185
|
+
const m = u.key.match(/^(?:def|class|module):(?:self\.)?([A-Za-z_][\w.?!]*)/)
|
|
186
|
+
if (m) names.add(m[1])
|
|
187
|
+
}
|
|
188
|
+
return names
|
|
189
|
+
},
|
|
190
|
+
usedIdentifiers: (src) => {
|
|
191
|
+
src = String(src)
|
|
192
|
+
const used = new Set()
|
|
193
|
+
for (const t of lexTokens(src)) if (isRefToken(src, t)) used.add(t.k)
|
|
194
|
+
return used
|
|
195
|
+
},
|
|
196
|
+
declBody: (src, name) => {
|
|
197
|
+
src = String(src)
|
|
198
|
+
for (const u of keyed(blocks(src), src)) {
|
|
199
|
+
const m = u.key.match(/^(?:def|class|module):(?:self\.)?([A-Za-z_][\w.?!]*)/)
|
|
200
|
+
if (!m || m[1] !== name) continue
|
|
201
|
+
return u.text.replace(name, '').trimEnd()
|
|
202
|
+
}
|
|
203
|
+
return null
|
|
204
|
+
},
|
|
205
|
+
renameRefs: (src, oldName, newName) => {
|
|
206
|
+
src = String(src)
|
|
207
|
+
const spots = []
|
|
208
|
+
for (const t of lexTokens(src)) if (t.k === oldName && isRefToken(src, t)) spots.push(t)
|
|
209
|
+
let out = src
|
|
210
|
+
for (const t of spots.reverse()) out = out.slice(0, t.start) + newName + out.slice(t.end)
|
|
211
|
+
return out
|
|
212
|
+
},
|
|
213
|
+
tokenize: (src) => lexTokens(String(src)),
|
|
214
|
+
// Descend into a class/module so two agents editing DIFFERENT methods merge.
|
|
215
|
+
splitUnit: (text) => {
|
|
216
|
+
const src = String(text)
|
|
217
|
+
if (!/^\s*(class|module)\b/.test(src)) return null
|
|
218
|
+
const L = codeLines(src)
|
|
219
|
+
if (!L.length) return null
|
|
220
|
+
const sigEnd = L[0].end
|
|
221
|
+
// body = everything between the header line and the final matching `end` line
|
|
222
|
+
let last = L.length - 1
|
|
223
|
+
while (last > 0 && L[last].code.trim() === '') last--
|
|
224
|
+
if (!/^\s*end\b/.test(L[last].code)) return null
|
|
225
|
+
const bodyStart = sigEnd, bodyEnd = L[last].start
|
|
226
|
+
const body = src.slice(bodyStart, bodyEnd)
|
|
227
|
+
const inner = keyed(blocks(body), body)
|
|
228
|
+
return { sig: src.slice(0, sigEnd), open: '', close: src.slice(bodyEnd), body, units: inner, join: '', spliceable: true }
|
|
229
|
+
},
|
|
230
|
+
}
|
package/lang-toml.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// TOML language provider for ICR — section/key structural splitter.
|
|
2
|
+
// Cargo.toml / pyproject.toml are constantly edited concurrently (two people add
|
|
3
|
+
// dependencies). This provider merges by STRUCTURE: every `[section]` (and
|
|
4
|
+
// `[[array-of-tables]]`) is a unit keyed by its name; keys in the top-level
|
|
5
|
+
// preamble are units of their own; splitUnit descends into a section so two
|
|
6
|
+
// edits to DIFFERENT keys of the same table merge cleanly.
|
|
7
|
+
//
|
|
8
|
+
// Honest heuristic: tracks #-comments and single/double/triple-quoted strings;
|
|
9
|
+
// parses() = balanced delimiters + well-formed section headers.
|
|
10
|
+
|
|
11
|
+
function scannerAt(src) {
|
|
12
|
+
const n = src.length
|
|
13
|
+
return function at(i) {
|
|
14
|
+
if (src[i] === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; return j }
|
|
15
|
+
for (const q of ['"""', "'''"]) if (src.startsWith(q, i)) { let j = i + 3; while (j < n && !src.startsWith(q, j)) { if (src[j] === '\\') j++; j++ } return Math.min(n, j + 3) }
|
|
16
|
+
if (src[i] === '"' || src[i] === "'") {
|
|
17
|
+
const q = src[i]; let j = i + 1
|
|
18
|
+
while (j < n) { if (q === '"' && src[j] === '\\') { j += 2; continue } if (src[j] === q) { j++; break } if (src[j] === '\n') break; j++ }
|
|
19
|
+
return j
|
|
20
|
+
}
|
|
21
|
+
return -1
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function lines(src) {
|
|
26
|
+
const out = []
|
|
27
|
+
let i = 0
|
|
28
|
+
while (i < src.length) {
|
|
29
|
+
let e = src.indexOf('\n', i); e = e < 0 ? src.length : e + 1
|
|
30
|
+
const body = src.slice(i, e).replace(/\r?\n$/, '')
|
|
31
|
+
const trimmed = body.trim()
|
|
32
|
+
out.push({ start: i, end: e, blank: trimmed === '' || trimmed.startsWith('#'), text: body })
|
|
33
|
+
i = e
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SECTION = /^\s*(\[\[?)([^\]]+)\]\]?\s*(#.*)?$/
|
|
39
|
+
const KEYLINE = /^\s*([A-Za-z0-9_."'-]+)\s*=/
|
|
40
|
+
|
|
41
|
+
// Bracket delta for one line, string/comment aware (multi-line arrays continue a key).
|
|
42
|
+
function bracketDelta(src, at, s, e) {
|
|
43
|
+
let d = 0, k = s
|
|
44
|
+
while (k < e) {
|
|
45
|
+
const j = at(k); if (j > k) { k = Math.min(e, j); continue }
|
|
46
|
+
const c = src[k]
|
|
47
|
+
if (c === '[' || c === '{') d++
|
|
48
|
+
else if (c === ']' || c === '}') d--
|
|
49
|
+
k++
|
|
50
|
+
}
|
|
51
|
+
return d
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Top-level units: a [section] header owns everything until the next header;
|
|
55
|
+
// preamble `key = value` lines are individual units (multi-line values included).
|
|
56
|
+
function blocks(src) {
|
|
57
|
+
const at = scannerAt(src)
|
|
58
|
+
const L = lines(src)
|
|
59
|
+
const units = []
|
|
60
|
+
let i = 0
|
|
61
|
+
while (i < L.length) {
|
|
62
|
+
while (i < L.length && L[i].text.trim() === '') i++
|
|
63
|
+
if (i >= L.length) break
|
|
64
|
+
const start = L[i].start
|
|
65
|
+
while (i < L.length && L[i].blank && L[i].text.trim().startsWith('#')) i++ // leading comments attach
|
|
66
|
+
if (i >= L.length) { units.push({ start, end: L[L.length - 1].end }); break }
|
|
67
|
+
if (SECTION.test(L[i].text)) {
|
|
68
|
+
i++ // header
|
|
69
|
+
while (i < L.length && !SECTION.test(L[i].text)) i++
|
|
70
|
+
let last = i - 1
|
|
71
|
+
while (last >= 0 && L[last].text.trim() === '') last--
|
|
72
|
+
units.push({ start, end: L[last].end })
|
|
73
|
+
} else {
|
|
74
|
+
// one key (or stray statement); consume continuation lines while brackets open
|
|
75
|
+
let depth = bracketDelta(src, at, L[i].start, L[i].end)
|
|
76
|
+
let e = L[i].end; i++
|
|
77
|
+
while (i < L.length && depth > 0) { depth += bracketDelta(src, at, L[i].start, L[i].end); e = L[i].end; i++ }
|
|
78
|
+
units.push({ start, end: e })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return units
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function keyOf(text) {
|
|
85
|
+
const head = text.split('\n').find((l) => l.trim() && !l.trim().startsWith('#')) || text
|
|
86
|
+
let m = head.match(SECTION); if (m) return (m[1] === '[[' ? 'tables:' : 'table:') + m[2].trim()
|
|
87
|
+
m = head.match(KEYLINE); if (m) return 'key:' + m[1].replace(/["']/g, '')
|
|
88
|
+
return 'stmt:' + head.trim().replace(/\s+/g, ' ')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function keyed(raw, src) {
|
|
92
|
+
const seen = new Map(), out = []
|
|
93
|
+
for (const u of raw) {
|
|
94
|
+
const text = src.slice(u.start, u.end)
|
|
95
|
+
let k = keyOf(text)
|
|
96
|
+
const c = (seen.get(k) || 0) + 1; seen.set(k, c)
|
|
97
|
+
if (c > 1) k += '#' + c
|
|
98
|
+
out.push({ key: k, text, start: u.start, end: u.end })
|
|
99
|
+
}
|
|
100
|
+
return out
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function balanced(src) {
|
|
104
|
+
const at = scannerAt(src)
|
|
105
|
+
const stack = []
|
|
106
|
+
const pair = { ']': '[', '}': '{' }
|
|
107
|
+
let i = 0
|
|
108
|
+
while (i < src.length) {
|
|
109
|
+
const j = at(i); if (j > i) { if (j > src.length) return false; i = j; continue }
|
|
110
|
+
const c = src[i]
|
|
111
|
+
if (c === '[' || c === '{') stack.push(c)
|
|
112
|
+
else if (pair[c]) { if (stack.pop() !== pair[c]) return false }
|
|
113
|
+
i++
|
|
114
|
+
}
|
|
115
|
+
return stack.length === 0
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const toml = {
|
|
119
|
+
id: 'toml',
|
|
120
|
+
exts: ['.toml'],
|
|
121
|
+
parses: (src) => balanced(String(src == null ? '' : src)),
|
|
122
|
+
parsesUnit: (src) => balanced(String(src)),
|
|
123
|
+
units: (src) => keyed(blocks(String(src)), String(src)),
|
|
124
|
+
declaredNames: () => new Set(),
|
|
125
|
+
// Descend into a section: each `key = value` inside merges independently.
|
|
126
|
+
splitUnit: (text) => {
|
|
127
|
+
const src = String(text)
|
|
128
|
+
const L = lines(src)
|
|
129
|
+
let h = 0; while (h < L.length && L[h].blank) h++
|
|
130
|
+
if (h >= L.length || !SECTION.test(L[h].text)) return null
|
|
131
|
+
const sig = src.slice(0, L[h].end)
|
|
132
|
+
const body = src.slice(L[h].end)
|
|
133
|
+
const inner = keyed(blocks(body), body)
|
|
134
|
+
return { sig, open: '', close: '', body, units: inner, join: '', spliceable: true }
|
|
135
|
+
},
|
|
136
|
+
}
|
package/lang-yaml.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// YAML language provider for ICR — indent-mapping structural splitter.
|
|
2
|
+
// YAML configs (CI pipelines, k8s manifests, docker-compose) are merged constantly
|
|
3
|
+
// and line merges garble them. This provider merges by STRUCTURE: every top-level
|
|
4
|
+
// `key:` block is a unit keyed by its key name, and splitUnit descends into nested
|
|
5
|
+
// mapping levels — so two people editing DIFFERENT jobs/services/sections merge
|
|
6
|
+
// cleanly, and the SAME key edited two ways is a named conflict.
|
|
7
|
+
//
|
|
8
|
+
// Honest heuristic (no parser dependency): tracks #-comments, quoted strings, and
|
|
9
|
+
// flow brackets; parses() checks bracket balance + indentation coherence (a dedent
|
|
10
|
+
// must land on an indent level that exists — the failure mode of a bad splice).
|
|
11
|
+
// Block scalars (|, >) are tolerated: their content is deeper-indented free text.
|
|
12
|
+
|
|
13
|
+
function scannerAt(src) {
|
|
14
|
+
const n = src.length
|
|
15
|
+
return function at(i) {
|
|
16
|
+
if (src[i] === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; return j }
|
|
17
|
+
if (src[i] === '"' || src[i] === "'") {
|
|
18
|
+
const q = src[i]; let j = i + 1
|
|
19
|
+
while (j < n) { if (q === '"' && src[j] === '\\') { j += 2; continue } if (src[j] === q) { j++; break } if (src[j] === '\n') break; j++ }
|
|
20
|
+
return j
|
|
21
|
+
}
|
|
22
|
+
return -1
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Line records with indent; comment-only and blank lines are marked.
|
|
27
|
+
function lines(src) {
|
|
28
|
+
const out = []
|
|
29
|
+
let i = 0
|
|
30
|
+
while (i < src.length) {
|
|
31
|
+
let e = src.indexOf('\n', i); e = e < 0 ? src.length : e + 1
|
|
32
|
+
const raw = src.slice(i, e)
|
|
33
|
+
const body = raw.replace(/\r?\n$/, '')
|
|
34
|
+
let indent = 0; while (indent < body.length && body[indent] === ' ') indent++
|
|
35
|
+
const trimmed = body.trim()
|
|
36
|
+
out.push({ start: i, end: e, indent, blank: trimmed === '' || trimmed.startsWith('#'), text: body })
|
|
37
|
+
i = e
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const KEY = /^([^:#\s][^:#]*?):(\s|$)/ // `key:` or `key: value`
|
|
43
|
+
const DASH = /^-(\s|$)/ // list item
|
|
44
|
+
|
|
45
|
+
function keyOf(text) {
|
|
46
|
+
const t = text.trim()
|
|
47
|
+
const m = t.match(KEY)
|
|
48
|
+
if (m) return 'key:' + m[1].trim()
|
|
49
|
+
if (t === '---' || t === '...') return 'doc:' + t
|
|
50
|
+
return 'stmt:' + t.replace(/\s+/g, ' ')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Units whose heads sit at exactly `baseIndent`: a `key:`/`- ` head owns all
|
|
54
|
+
// following blank or deeper-indented lines (its block).
|
|
55
|
+
function blocks(src, baseIndent = 0) {
|
|
56
|
+
const L = lines(src)
|
|
57
|
+
const units = []
|
|
58
|
+
let i = 0
|
|
59
|
+
while (i < L.length) {
|
|
60
|
+
while (i < L.length && L[i].text.trim() === '') i++ // pure blanks are gap text
|
|
61
|
+
if (i >= L.length) break
|
|
62
|
+
const start = L[i].start
|
|
63
|
+
// leading comment lines attach to the unit that follows
|
|
64
|
+
while (i < L.length && L[i].blank && L[i].text.trim().startsWith('#')) i++
|
|
65
|
+
if (i >= L.length) { units.push({ start, end: L[L.length - 1].end }); break }
|
|
66
|
+
if (L[i].indent !== baseIndent) { units.push({ start, end: L[i].end }); i++; continue }
|
|
67
|
+
i++ // the head line
|
|
68
|
+
while (i < L.length && (L[i].blank || L[i].indent > baseIndent)) i++
|
|
69
|
+
let last = i - 1
|
|
70
|
+
while (last >= 0 && L[last].text.trim() === '') last-- // trailing blanks are gap text
|
|
71
|
+
units.push({ start, end: L[last].end })
|
|
72
|
+
}
|
|
73
|
+
return units
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function keyed(raw, src) {
|
|
77
|
+
const seen = new Map(), out = []
|
|
78
|
+
for (const u of raw) {
|
|
79
|
+
const text = src.slice(u.start, u.end)
|
|
80
|
+
// key by the HEAD line (skipping attached leading comments)
|
|
81
|
+
const head = text.split('\n').find((l) => l.trim() && !l.trim().startsWith('#')) || text
|
|
82
|
+
let k = keyOf(head)
|
|
83
|
+
const c = (seen.get(k) || 0) + 1; seen.set(k, c)
|
|
84
|
+
if (c > 1) k += '#' + c
|
|
85
|
+
out.push({ key: k, text, start: u.start, end: u.end })
|
|
86
|
+
}
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Bracket balance (flow style) + unterminated-string detection.
|
|
91
|
+
function balanced(src) {
|
|
92
|
+
const at = scannerAt(src)
|
|
93
|
+
const stack = []
|
|
94
|
+
const pair = { ')': '(', ']': '[', '}': '{' }
|
|
95
|
+
let i = 0
|
|
96
|
+
while (i < src.length) {
|
|
97
|
+
const j = at(i); if (j > i) { if (j > src.length) return false; i = j; continue }
|
|
98
|
+
const c = src[i]
|
|
99
|
+
if (c === '(' || c === '[' || c === '{') stack.push(c)
|
|
100
|
+
else if (pair[c]) { if (stack.pop() !== pair[c]) return false }
|
|
101
|
+
i++
|
|
102
|
+
}
|
|
103
|
+
return stack.length === 0
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Indentation coherence: an indent INCREASE opens a new level (always legal in YAML —
|
|
107
|
+
// nested mapping, list, or block-scalar content); a DECREASE must land on a level
|
|
108
|
+
// that is already on the stack. This is exactly what a bad merge splice violates.
|
|
109
|
+
function indentsOk(src) {
|
|
110
|
+
const L = lines(src)
|
|
111
|
+
let stack = null
|
|
112
|
+
for (const l of L) {
|
|
113
|
+
if (l.blank) continue
|
|
114
|
+
if (stack == null) { stack = [l.indent]; continue }
|
|
115
|
+
const top = stack[stack.length - 1]
|
|
116
|
+
if (l.indent > top) { stack.push(l.indent); continue }
|
|
117
|
+
if (l.indent < top) {
|
|
118
|
+
while (stack.length > 1 && stack[stack.length - 1] > l.indent) stack.pop()
|
|
119
|
+
if (stack[stack.length - 1] !== l.indent) return false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const yaml = {
|
|
126
|
+
id: 'yaml',
|
|
127
|
+
exts: ['.yml', '.yaml'],
|
|
128
|
+
parses: (src) => { const s = String(src == null ? '' : src); return balanced(s) && indentsOk(s) },
|
|
129
|
+
parsesUnit: (src) => { const s = String(src); return balanced(s) && indentsOk(s) },
|
|
130
|
+
units: (src) => keyed(blocks(String(src), 0), String(src)),
|
|
131
|
+
declaredNames: () => new Set(), // keys are structure, not code declarations
|
|
132
|
+
// Descend one mapping level: `jobs:` -> each job merges independently.
|
|
133
|
+
splitUnit: (text) => {
|
|
134
|
+
const src = String(text)
|
|
135
|
+
const L = lines(src)
|
|
136
|
+
let h = 0; while (h < L.length && L[h].blank) h++
|
|
137
|
+
if (h >= L.length) return null
|
|
138
|
+
const head = L[h]
|
|
139
|
+
if (!KEY.test(head.text.trim())) return null
|
|
140
|
+
if (head.text.trim().match(/:\s*[|>][+-]?\s*(#.*)?$/)) return null // block scalar: free text, do not descend
|
|
141
|
+
if (!/:\s*(#.*)?$/.test(head.text)) return null // inline value (`key: v`): nothing beneath
|
|
142
|
+
let b = h + 1; while (b < L.length && L[b].blank) b++
|
|
143
|
+
if (b >= L.length || L[b].indent <= head.indent) return null // empty block
|
|
144
|
+
const bodyIndent = L[b].indent
|
|
145
|
+
const sig = src.slice(0, L[h].end)
|
|
146
|
+
const body = src.slice(L[h].end)
|
|
147
|
+
const inner = keyed(blocks(body, bodyIndent), body)
|
|
148
|
+
if (inner.some((u) => u.key.startsWith('stmt:-') || DASH.test(u.text.trim()))) return null // list items: order matters, stay whole
|
|
149
|
+
return { sig, open: '', close: '', body, units: inner, join: '', spliceable: true }
|
|
150
|
+
},
|
|
151
|
+
}
|