hivecode-mcp 0.6.0 → 0.8.0
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 +68 -137
- package/hive-coord.js +72 -0
- package/hive-mcp.js +85 -10
- package/icr-merge.js +58 -0
- package/icr.js +272 -0
- package/lang-brace.js +174 -0
- package/lang-js.js +418 -0
- package/lang-python.js +149 -0
- package/package.json +18 -5
- package/substrate.js +252 -0
- package/sync.js +711 -19
- package/token.js +20 -0
package/icr.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// ICR — Intent-aware Code Replication (v1 proof-of-concept).
|
|
2
|
+
//
|
|
3
|
+
// The first real step from Hivecode-the-tool toward a FOUNDATIONAL primitive:
|
|
4
|
+
// merge code by STRUCTURE and INTENT, not characters, with a hard guarantee — never
|
|
5
|
+
// emit code more broken than its inputs.
|
|
6
|
+
//
|
|
7
|
+
// Why this matters: CRDTs (Yjs) merge characters. They will happily fuse two edits
|
|
8
|
+
// into syntactically-valid garbage, because they have no idea what code MEANS. ICR
|
|
9
|
+
// parses the code, merges at the level of declarations (functions / classes / vars),
|
|
10
|
+
// descends INTO a declaration both sides touched (so disjoint edits inside one function
|
|
11
|
+
// still merge), detects when two authors changed the SAME thing (a real conflict), and
|
|
12
|
+
// validates that the merged result still parses — falling back safely if it wouldn't.
|
|
13
|
+
//
|
|
14
|
+
// On top of structure it adds an INTENT layer:
|
|
15
|
+
// • RENAME DETECTION — a declaration gone from base whose identical-bodied twin appears
|
|
16
|
+
// under a new name is recognized as a rename; stale call sites are rewritten so the
|
|
17
|
+
// other agent's fresh calls keep working. Nothing else does this.
|
|
18
|
+
// • DANGLING REFERENCE — a declaration removed/renamed but still referenced is flagged
|
|
19
|
+
// as a semantic conflict even though the code parses. CRDTs/git/plain merge miss it.
|
|
20
|
+
//
|
|
21
|
+
// LANGUAGE-AGNOSTIC BY DESIGN: this file is the engine. Everything that knows about a
|
|
22
|
+
// specific language (how to parse, what a declaration is, how to find references) lives
|
|
23
|
+
// behind a provider (see lang-js.js). Adding Python/Go/Rust = writing another provider
|
|
24
|
+
// and registerLanguage()-ing it; the merge logic below never changes. JavaScript via
|
|
25
|
+
// `acorn` is simply the first provider. The production path swaps in tree-sitter
|
|
26
|
+
// providers for every language behind this same interface.
|
|
27
|
+
|
|
28
|
+
import { javascript } from './lang-js.js'
|
|
29
|
+
import { braceLanguages } from './lang-brace.js'
|
|
30
|
+
import { python } from './lang-python.js'
|
|
31
|
+
|
|
32
|
+
// --- language registry ----------------------------------------------------------
|
|
33
|
+
// JavaScript (acorn, full intent layer) first; then structural providers for the
|
|
34
|
+
// C-family (TypeScript, Go, Rust, Java, C/C++, C#, Swift, Kotlin, …) and Python.
|
|
35
|
+
// These cover disjoint extensions, so order only matters on clashes (none today).
|
|
36
|
+
const LANGUAGES = [javascript, ...braceLanguages, python]
|
|
37
|
+
|
|
38
|
+
// Register an additional language provider. Must expose the provider contract:
|
|
39
|
+
// { id, exts:[...], parses, units, declaredNames, usedIdentifiers, declBody,
|
|
40
|
+
// renameRefs, fnParts }. Newest wins on extension clashes.
|
|
41
|
+
export function registerLanguage(provider) { LANGUAGES.unshift(provider) }
|
|
42
|
+
|
|
43
|
+
function extname(p) { const m = /\.[^.\/\\]+$/.exec(p || ''); return m ? m[0].toLowerCase() : '' }
|
|
44
|
+
|
|
45
|
+
// The provider for a filename, or null if no registered language claims its extension.
|
|
46
|
+
export function languageFor(filename) {
|
|
47
|
+
const e = extname(filename)
|
|
48
|
+
return LANGUAGES.find((l) => l.exts.includes(e)) || null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Can ICR merge this file structurally? (Callers gate on this before trying.)
|
|
52
|
+
export function supports(filename) { return languageFor(filename) != null }
|
|
53
|
+
|
|
54
|
+
// --- generic merge primitives (language-independent: operate on {key,text}) -----
|
|
55
|
+
const mapOf = (us) => new Map(us.map((u) => [u.key, u.text]))
|
|
56
|
+
function dedupeOrder(keys) { const seen = new Set(), out = []; for (const k of keys) if (!seen.has(k)) { seen.add(k); out.push(k) } return out }
|
|
57
|
+
|
|
58
|
+
// For a given unit, choose which version survives: whoever CHANGED it wins; if neither
|
|
59
|
+
// changed it, keep the base.
|
|
60
|
+
function pick(k, B, A, Bb) {
|
|
61
|
+
const base = B.has(k) ? B.get(k) : null
|
|
62
|
+
const a = A.has(k) ? A.get(k) : null
|
|
63
|
+
const b = Bb.has(k) ? Bb.get(k) : null
|
|
64
|
+
if (a != null && a !== base) return a
|
|
65
|
+
if (b != null && b !== base) return b
|
|
66
|
+
return base != null ? base : (a != null ? a : b)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Core 3-way merge over a KEYED list of units (works at file level AND, recursively,
|
|
70
|
+
// inside a single declaration). Returns { conflicts:[...], parts:[...text] }.
|
|
71
|
+
// When both sides change the SAME key into different things, it first tries to descend
|
|
72
|
+
// INTO that unit (finer granularity) — only a clash it can't resolve becomes a conflict.
|
|
73
|
+
function mergeKeyed(lang, baseU, aU, bU) {
|
|
74
|
+
const B = mapOf(baseU), A = mapOf(aU), Bb = mapOf(bU)
|
|
75
|
+
const keys = dedupeOrder([...baseU.map((u) => u.key), ...aU.map((u) => u.key), ...bU.map((u) => u.key)])
|
|
76
|
+
|
|
77
|
+
const conflicts = [], resolved = new Map()
|
|
78
|
+
for (const k of keys) {
|
|
79
|
+
const bs = B.has(k) ? B.get(k) : null
|
|
80
|
+
const as_ = A.has(k) ? A.get(k) : null
|
|
81
|
+
const bbs = Bb.has(k) ? Bb.get(k) : null
|
|
82
|
+
const changedA = as_ !== bs, changedB = bbs !== bs
|
|
83
|
+
if (changedA && changedB && as_ !== bbs) {
|
|
84
|
+
// Both touched the same unit, differently. Try finer-grained inner merge first.
|
|
85
|
+
if (bs != null && as_ != null && bbs != null) {
|
|
86
|
+
const inner = tryInnerMerge(lang, bs, as_, bbs)
|
|
87
|
+
if (inner != null) { resolved.set(k, inner); continue }
|
|
88
|
+
}
|
|
89
|
+
conflicts.push(k)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (conflicts.length) return { conflicts, parts: [] }
|
|
93
|
+
|
|
94
|
+
// No unresolved clashes → decide which keys survive, honoring one-sided deletions.
|
|
95
|
+
const survives = new Set()
|
|
96
|
+
for (const k of keys) {
|
|
97
|
+
const inBase = B.has(k), inA = A.has(k), inB2 = Bb.has(k)
|
|
98
|
+
if (inBase && (!inA || !inB2)) {
|
|
99
|
+
const deletedByA = !inA, deletedByB = !inB2
|
|
100
|
+
const otherUnchangedFromBase =
|
|
101
|
+
(deletedByA && (!inB2 || Bb.get(k) === B.get(k))) ||
|
|
102
|
+
(deletedByB && (!inA || A.get(k) === B.get(k)))
|
|
103
|
+
if (otherUnchangedFromBase) continue // accept the deletion
|
|
104
|
+
}
|
|
105
|
+
survives.add(k)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// CONVERGENCE: the output order must be IDENTICAL no matter which side is `a` vs `b`,
|
|
109
|
+
// or two peers compute different text and never settle (then re-merging the divergence
|
|
110
|
+
// corrupts the file). So: surviving base units keep their base order; units added by a
|
|
111
|
+
// side are appended in a canonical (sorted) order — deterministic and symmetric.
|
|
112
|
+
const baseOrder = dedupeOrder(baseU.map((u) => u.key))
|
|
113
|
+
const baseSet = new Set(baseOrder)
|
|
114
|
+
const final = []
|
|
115
|
+
for (const k of baseOrder) if (survives.has(k)) final.push(k)
|
|
116
|
+
for (const k of [...survives].filter((k) => !baseSet.has(k)).sort()) final.push(k)
|
|
117
|
+
|
|
118
|
+
// Assemble, recording PROVENANCE per surviving unit: who authored the version we kept
|
|
119
|
+
// ('a' / 'b' changed it, 'both' if we merged inside it, 'base' if unchanged).
|
|
120
|
+
const parts = [], provenance = [], order = [], textByKey = new Map()
|
|
121
|
+
for (const k of final) {
|
|
122
|
+
let text, from
|
|
123
|
+
if (resolved.has(k)) { text = resolved.get(k); from = 'both' }
|
|
124
|
+
else {
|
|
125
|
+
text = pick(k, B, A, Bb)
|
|
126
|
+
const bs = B.has(k) ? B.get(k) : null, as_ = A.has(k) ? A.get(k) : null, bbs = Bb.has(k) ? Bb.get(k) : null
|
|
127
|
+
from = (as_ != null && as_ !== bs) ? 'a' : (bbs != null && bbs !== bs) ? 'b' : 'base'
|
|
128
|
+
}
|
|
129
|
+
if (text != null) { parts.push(text); provenance.push({ key: k, from }); order.push(k); textByKey.set(k, text) }
|
|
130
|
+
}
|
|
131
|
+
return { conflicts: [], parts, provenance, order, textByKey }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// FINER GRANULARITY: both sides edited the same function OR class. If the signature
|
|
135
|
+
// matches and they edited DIFFERENT inner units (statements / class members), merge those
|
|
136
|
+
// recursively rather than declaring the whole declaration a conflict. Returns merged
|
|
137
|
+
// text, or null meaning "couldn't safely merge inside — treat as a real conflict."
|
|
138
|
+
function tryInnerMerge(lang, baseText, aText, bText) {
|
|
139
|
+
// Language-specific same-key merges first (e.g. unioning import specifiers).
|
|
140
|
+
if (lang.mergeUnit) {
|
|
141
|
+
const t = lang.mergeUnit(baseText, aText, bText)
|
|
142
|
+
if (t != null && lang.parses(t)) return t
|
|
143
|
+
}
|
|
144
|
+
if (!lang.splitUnit) return null
|
|
145
|
+
const pb = lang.splitUnit(baseText), pa = lang.splitUnit(aText), pbb = lang.splitUnit(bText)
|
|
146
|
+
if (!pb || !pa || !pbb) return null
|
|
147
|
+
if (pa.sig.trim() !== pbb.sig.trim()) return null // signature itself changed → real conflict
|
|
148
|
+
const m = mergeKeyed(lang, pb.units, pa.units, pbb.units)
|
|
149
|
+
if (m.conflicts.length) return null
|
|
150
|
+
const text = pa.sig + pa.open + m.parts.join(pa.join) + pa.close
|
|
151
|
+
const valid = lang.parsesUnit || lang.parses // accept class-member fragments when supported
|
|
152
|
+
return valid(text) ? text : null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// FORMAT-PRESERVING assembly: rebuild the merged file by splicing the merged units back
|
|
156
|
+
// into the BASE text at their original byte ranges. Unchanged units — and all the
|
|
157
|
+
// whitespace/comments BETWEEN units — survive verbatim; only changed units carry the
|
|
158
|
+
// editing side's bytes, and genuinely new units are appended. This is what stops ICR from
|
|
159
|
+
// reformatting code it merges. Deterministic and symmetric, so peers converge.
|
|
160
|
+
function spliceUnits(baseText, baseUnits, order, textByKey) {
|
|
161
|
+
const baseKeySet = new Set(baseUnits.map((u) => u.key))
|
|
162
|
+
const surviving = new Set(order.filter((k) => baseKeySet.has(k)))
|
|
163
|
+
const emitted = new Set()
|
|
164
|
+
let out = '', pos = 0
|
|
165
|
+
for (const u of baseUnits) {
|
|
166
|
+
out += baseText.slice(pos, u.start) // gap before this unit (comments/blank lines), verbatim
|
|
167
|
+
if (surviving.has(u.key) && !emitted.has(u.key)) { out += textByKey.get(u.key); emitted.add(u.key) }
|
|
168
|
+
pos = u.end
|
|
169
|
+
}
|
|
170
|
+
const added = order.filter((k) => !baseKeySet.has(k))
|
|
171
|
+
if (added.length) {
|
|
172
|
+
const head = out.replace(/\s*$/, '')
|
|
173
|
+
out = (head ? head + '\n\n' : '') + added.map((k) => textByKey.get(k)).join('\n\n') + '\n'
|
|
174
|
+
} else {
|
|
175
|
+
out += baseText.slice(pos) // trailing whitespace (final newline) verbatim
|
|
176
|
+
}
|
|
177
|
+
return out.replace(/^\n+/, '') // drop orphan leading blank lines left by a deleted first unit
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- public API -----------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
// Does this source parse under the given (or default JS) language?
|
|
183
|
+
export function parses(src, lang = javascript) { return lang.parses(src) }
|
|
184
|
+
|
|
185
|
+
// 3-way STRUCTURAL + INTENT merge of `a` and `b` against common ancestor `base`.
|
|
186
|
+
// opts: { lang } a provider, or { filename } to pick one by extension (defaults to JS).
|
|
187
|
+
// Returns { status, text, conflicts, renames? }:
|
|
188
|
+
// 'auto' — clean merge; `text` is valid, parseable code.
|
|
189
|
+
// 'semantic-conflict' — same declaration changed both sides, or a dangling reference;
|
|
190
|
+
// `conflicts` names them (e.g. ['fn:login'] or ['ref:helper']).
|
|
191
|
+
// 'fallback' — couldn't merge safely (unparseable input, or the merge wouldn't
|
|
192
|
+
// parse, or unsupported language); caller keeps both. Never broken.
|
|
193
|
+
export function structuralMerge(base, a, b, opts = {}) {
|
|
194
|
+
const lang = opts.lang || (opts.filename ? languageFor(opts.filename) : javascript) || javascript
|
|
195
|
+
|
|
196
|
+
if (!lang.parses(base) || !lang.parses(a) || !lang.parses(b))
|
|
197
|
+
return { status: 'fallback', text: null, conflicts: [], reason: 'unparseable input' }
|
|
198
|
+
|
|
199
|
+
// FIXED-POINT fast path: once two peers agree on a text T, re-merging must return T
|
|
200
|
+
// unchanged. This is what makes the live sync settle. Both agree ⇒ nothing to check.
|
|
201
|
+
if (a === b) return { status: 'auto', text: a, conflicts: [], renames: [], provenance: [] }
|
|
202
|
+
|
|
203
|
+
const authors = opts.authors || {}
|
|
204
|
+
const attribute = (prov) => (prov || []).map((p) => ({ unit: p.key, author: authors[p.from] || p.from }))
|
|
205
|
+
|
|
206
|
+
// Determine the structurally-merged text + provenance. A one-sided change takes that
|
|
207
|
+
// side verbatim (format perfectly preserved); otherwise do the keyed 3-way merge and
|
|
208
|
+
// splice it back into base. Either way the INTENT layer below still runs, so a deletion
|
|
209
|
+
// that leaves a dangling reference is caught even when only one side changed.
|
|
210
|
+
let text, provenance
|
|
211
|
+
if (a === base) { text = b; provenance = [] }
|
|
212
|
+
else if (b === base) { text = a; provenance = [] }
|
|
213
|
+
else {
|
|
214
|
+
const baseUnits = lang.units(base)
|
|
215
|
+
const merge = mergeKeyed(lang, baseUnits, lang.units(a), lang.units(b))
|
|
216
|
+
if (merge.conflicts.length) return { status: 'semantic-conflict', text: null, conflicts: merge.conflicts }
|
|
217
|
+
// Prefer the format-preserving splice (units carry byte ranges); fall back to a plain
|
|
218
|
+
// join for languages whose units don't expose ranges.
|
|
219
|
+
const canSplice = baseUnits.every((u) => typeof u.start === 'number')
|
|
220
|
+
text = canSplice ? spliceUnits(base, baseUnits, merge.order, merge.textByKey) : merge.parts.join('\n\n') + '\n'
|
|
221
|
+
provenance = merge.provenance
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// THE GUARANTEE: the merged result must parse, or we refuse it.
|
|
225
|
+
if (!lang.parses(text)) return { status: 'fallback', text: null, conflicts: [], reason: 'merge would not parse' }
|
|
226
|
+
|
|
227
|
+
// RENAME DETECTION (intent): a declaration gone from base whose identical-bodied twin
|
|
228
|
+
// appears under a new name is a rename — rewrite stale call sites to the new name.
|
|
229
|
+
let merged = text
|
|
230
|
+
const baseNames = lang.declaredNames(base)
|
|
231
|
+
let mergedNames = lang.declaredNames(merged)
|
|
232
|
+
let removed = [...baseNames].filter((n) => !mergedNames.has(n))
|
|
233
|
+
const renames = []
|
|
234
|
+
const rename = lang.renameFreeRefs || lang.renameRefs // prefer scope-aware rewriting
|
|
235
|
+
if (removed.length && lang.declBody && rename) {
|
|
236
|
+
const added = [...mergedNames].filter((n) => !baseNames.has(n))
|
|
237
|
+
const claimed = new Set()
|
|
238
|
+
for (const oldName of removed) {
|
|
239
|
+
const oldBody = lang.declBody(base, oldName)
|
|
240
|
+
if (oldBody == null) continue
|
|
241
|
+
const match = added.find((n) => !claimed.has(n) && lang.declBody(merged, n) === oldBody)
|
|
242
|
+
if (match) { renames.push([oldName, match]); claimed.add(match) }
|
|
243
|
+
}
|
|
244
|
+
let rewritten = merged
|
|
245
|
+
// Rewrite only references that resolve to the renamed binding (scope-aware) — a local
|
|
246
|
+
// variable that merely shares the old name is left untouched.
|
|
247
|
+
for (const [oldName, newName] of renames) rewritten = rename(rewritten, oldName, newName)
|
|
248
|
+
// only accept the rewrite if it still parses — never trade a parse error for intent.
|
|
249
|
+
if (renames.length && lang.parses(rewritten)) { merged = rewritten; mergedNames = lang.declaredNames(merged) }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// DANGLING REFERENCE (intent): a declaration removed (and NOT explained by a rename)
|
|
253
|
+
// but still referenced is a semantic conflict even though the code parses cleanly.
|
|
254
|
+
// Uses the SCOPE-AWARE reference set when the language provides one (so a local binding
|
|
255
|
+
// that merely shares the deleted name is correctly NOT treated as a reference to it),
|
|
256
|
+
// falling back to the approximate identifier set otherwise.
|
|
257
|
+
removed = [...baseNames].filter((n) => !mergedNames.has(n))
|
|
258
|
+
const refs = lang.referencedFreeNames ? lang.referencedFreeNames(merged)
|
|
259
|
+
: lang.usedIdentifiers ? lang.usedIdentifiers(merged) : null
|
|
260
|
+
if (removed.length && refs) {
|
|
261
|
+
const dangling = removed.filter((n) => refs.has(n))
|
|
262
|
+
if (dangling.length)
|
|
263
|
+
return { status: 'semantic-conflict', text: null, conflicts: dangling.map((n) => 'ref:' + n), reason: 'dangling reference: a declaration was removed/renamed but is still used' }
|
|
264
|
+
}
|
|
265
|
+
// PROVENANCE: attribute each surviving unit to the author whose version we kept. With
|
|
266
|
+
// opts.authors = { a, b, base } the labels are real names; otherwise they're 'a'/'b'/etc.
|
|
267
|
+
return {
|
|
268
|
+
status: 'auto', text: merged, conflicts: [],
|
|
269
|
+
renames: renames.map(([o, n]) => o + '->' + n),
|
|
270
|
+
provenance: attribute(provenance),
|
|
271
|
+
}
|
|
272
|
+
}
|
package/lang-brace.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Brace-family language provider for ICR — a dependency-free, language-agnostic
|
|
2
|
+
// structural splitter for C-style languages (TypeScript, Go, Rust, Java, C/C++,
|
|
3
|
+
// C#, Swift, Kotlin, Scala, PHP, Dart, …). It implements the ICR provider contract
|
|
4
|
+
// { id, exts, parses, units, declaredNames, splitUnit, parsesUnit } so the SAME
|
|
5
|
+
// merge engine in icr.js merges these languages by structure — different top-level
|
|
6
|
+
// declarations merge cleanly, the same declaration edited two ways is a named
|
|
7
|
+
// conflict, and edits to different members inside one class/function merge via the
|
|
8
|
+
// engine's finer-grained inner merge (splitUnit).
|
|
9
|
+
//
|
|
10
|
+
// It is a heuristic scanner, not a full parser: it tracks strings, line/block
|
|
11
|
+
// comments, and bracket depth to find top-level declarations and their byte ranges.
|
|
12
|
+
// This gives real structural merge and an approximate parses() (balanced delimiters)
|
|
13
|
+
// — honest and useful today; a tree-sitter provider can later replace it behind the
|
|
14
|
+
// same interface with zero engine changes.
|
|
15
|
+
|
|
16
|
+
const LINE = ['//']
|
|
17
|
+
const OPEN = { '{': '}', '(': ')', '[': ']' }
|
|
18
|
+
const CLOSE = { '}': '{', ')': '(', ']': '[' }
|
|
19
|
+
|
|
20
|
+
// Walk `src` once. `at(i)` returns the index just past a string or comment starting
|
|
21
|
+
// at i, or -1 if none starts there — so callers can skip over them uniformly.
|
|
22
|
+
function scannerAt(src, strings) {
|
|
23
|
+
const n = src.length
|
|
24
|
+
return function at(i) {
|
|
25
|
+
if (src[i] === '/' && src[i + 1] === '*') { let j = i + 2; while (j < n && !(src[j] === '*' && src[j + 1] === '/')) j++; return Math.min(n, j + 2) }
|
|
26
|
+
for (const lc of LINE) if (src.startsWith(lc, i)) { let j = i + lc.length; while (j < n && src[j] !== '\n') j++; return j }
|
|
27
|
+
if (strings.includes(src[i])) { const q = src[i]; let j = i + 1; while (j < n) { if (src[j] === '\\') { j += 2; continue } if (src[j] === q) { j++; break } j++ } return j }
|
|
28
|
+
return -1
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isWS = (c) => c === ' ' || c === '\t' || c === '\r' || c === '\n'
|
|
33
|
+
|
|
34
|
+
// Extract top-level units [{start,end}] from src[from..to]. A unit is a maximal
|
|
35
|
+
// declaration/statement at bracket depth 0: either a block (signature + balanced
|
|
36
|
+
// {…}) or a statement ending at ';' or end-of-line.
|
|
37
|
+
function topUnits(src, strings, from = 0, to = src.length) {
|
|
38
|
+
const at = scannerAt(src, strings)
|
|
39
|
+
const units = []
|
|
40
|
+
let i = from
|
|
41
|
+
while (i < to) {
|
|
42
|
+
while (i < to && isWS(src[i])) i++
|
|
43
|
+
if (i >= to) break
|
|
44
|
+
// A standalone comment before a declaration belongs to the GAP (kept verbatim by
|
|
45
|
+
// the engine's splice), so skip it without starting a unit.
|
|
46
|
+
if ((src[i] === '/' && (src[i + 1] === '/' || src[i + 1] === '*'))) { const s = at(i); if (s > i) { i = s; continue } }
|
|
47
|
+
const start = i
|
|
48
|
+
let depth = 0, sawBrace = false
|
|
49
|
+
while (i < to) {
|
|
50
|
+
const s = at(i)
|
|
51
|
+
if (s > i) { i = s; continue } // skip string / comment
|
|
52
|
+
const c = src[i]
|
|
53
|
+
if (OPEN[c]) { if (c === '{') sawBrace = true; depth++; i++; continue }
|
|
54
|
+
if (CLOSE[c]) { depth--; i++; if (depth <= 0 && sawBrace) { while (i < to && (src[i] === ' ' || src[i] === '\t')) i++; if (src[i] === ';') i++; break } continue }
|
|
55
|
+
if (depth === 0) {
|
|
56
|
+
if (c === ';') { i++; break }
|
|
57
|
+
if (c === '\n' && !sawBrace) break
|
|
58
|
+
}
|
|
59
|
+
i++
|
|
60
|
+
}
|
|
61
|
+
units.push({ start, end: i })
|
|
62
|
+
}
|
|
63
|
+
return units
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DECL = /\b(function|func|fn|def|fun|class|interface|struct|enum|trait|impl|type|namespace|module|package|object|record|protocol|actor|extension)\s+([A-Za-z_$][\w$]*)/
|
|
67
|
+
const VARD = /\b(const|let|var|val|static|final)\s+([A-Za-z_$][\w$]*)/
|
|
68
|
+
// Method/function signature, including TYPE-FIRST forms (`int a()`, `public static
|
|
69
|
+
// void main(String[] a)`, `std::string name()`): optional decorators + any number of
|
|
70
|
+
// modifiers + an optional return-type token, then NAME(args){. The captured name is
|
|
71
|
+
// the identifier directly before `(`.
|
|
72
|
+
const METH = /^(?:@[\w.()]+\s+)*(?:(?:public|private|protected|internal|static|async|final|override|open|pub|suspend|inline|virtual|export|default|abstract|sealed|unsafe|extern|synchronized|native|constexpr)\s+)*(?:(?:function|fn|func|fun)\s+)?(?:[A-Za-z_$][\w$<>\[\].,:&*\s]*?\s+)?([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\s*\([^;{]*\)\s*(?:->[^{]+|:[^={]+)?\s*\{/
|
|
73
|
+
// control-flow headers also look like `name (…) {` — never key them as methods
|
|
74
|
+
const CTRL = new Set(['if', 'for', 'while', 'switch', 'catch', 'do', 'else', 'try', 'return', 'new', 'throw', 'match', 'select', 'when', 'until', 'lock', 'using', 'foreach', 'with'])
|
|
75
|
+
|
|
76
|
+
function keyOf(text) {
|
|
77
|
+
const head = text.replace(/^\s+/, '')
|
|
78
|
+
let m = head.match(DECL); if (m) return m[1] + ':' + m[2]
|
|
79
|
+
m = head.match(METH); if (m && !CTRL.has(m[1])) return 'method:' + m[1]
|
|
80
|
+
m = head.match(VARD); if (m) return 'var:' + m[2]
|
|
81
|
+
return 'stmt:' + head.split('\n')[0].trim().replace(/\s+/g, ' ')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// De-duplicate keys deterministically (repeated statements / same-named units) so the
|
|
85
|
+
// engine's keyed merge never silently drops one. Order is stable → convergent.
|
|
86
|
+
function keyedUnits(raw, src) {
|
|
87
|
+
const seen = new Map(), out = []
|
|
88
|
+
for (const u of raw) {
|
|
89
|
+
const text = src.slice(u.start, u.end)
|
|
90
|
+
let k = keyOf(text)
|
|
91
|
+
const c = (seen.get(k) || 0) + 1; seen.set(k, c)
|
|
92
|
+
if (c > 1) k += '#' + c
|
|
93
|
+
out.push({ key: k, text, start: u.start, end: u.end })
|
|
94
|
+
}
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Approximate parse check: delimiters balanced and no unterminated string/comment,
|
|
99
|
+
// ignoring the contents of strings and comments.
|
|
100
|
+
function balanced(src, strings) {
|
|
101
|
+
const at = scannerAt(src, strings)
|
|
102
|
+
const stack = []
|
|
103
|
+
let i = 0
|
|
104
|
+
while (i < src.length) {
|
|
105
|
+
const s = at(i)
|
|
106
|
+
if (s > i) { if (s > src.length) return false; i = s; continue }
|
|
107
|
+
const c = src[i]
|
|
108
|
+
if (OPEN[c]) stack.push(c)
|
|
109
|
+
else if (CLOSE[c]) { if (stack.pop() !== CLOSE[c]) return false }
|
|
110
|
+
i++
|
|
111
|
+
}
|
|
112
|
+
return stack.length === 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function makeBraceLanguage({ id, exts, strings = ['"', "'", '`'] }) {
|
|
116
|
+
const provider = {
|
|
117
|
+
id,
|
|
118
|
+
exts,
|
|
119
|
+
parses: (src) => balanced(String(src == null ? '' : src), strings),
|
|
120
|
+
units: (src) => keyedUnits(topUnits(String(src), strings), String(src)),
|
|
121
|
+
declaredNames: (src) => {
|
|
122
|
+
const names = new Set()
|
|
123
|
+
for (const u of keyedUnits(topUnits(String(src), strings), String(src))) {
|
|
124
|
+
const m = u.key.match(/^(?:[a-z]+):([A-Za-z_$][\w$]*)/)
|
|
125
|
+
if (m && !u.key.startsWith('stmt:')) names.add(m[1])
|
|
126
|
+
}
|
|
127
|
+
return names
|
|
128
|
+
},
|
|
129
|
+
parsesUnit: (src) => balanced(String(src), strings),
|
|
130
|
+
// Finer granularity: descend one level into a block declaration so two agents
|
|
131
|
+
// editing DIFFERENT members of the same class/function merge instead of clashing.
|
|
132
|
+
splitUnit: (text) => {
|
|
133
|
+
const src = String(text)
|
|
134
|
+
// Only descend into NAMED containers (class/struct/impl/…), whose members are
|
|
135
|
+
// keyed by name — so editing different members merges. A function/method body is
|
|
136
|
+
// just statements (no stable keys); descending there would falsely merge two
|
|
137
|
+
// edits to the same code, so we DON'T — the whole unit becomes a clean conflict.
|
|
138
|
+
if (!/\b(class|interface|struct|enum|trait|impl|namespace|module|object|record|extension|protocol|actor)\b/.test(src.split('{')[0]))
|
|
139
|
+
return null
|
|
140
|
+
const at = scannerAt(src, strings)
|
|
141
|
+
let i = 0, open = -1
|
|
142
|
+
while (i < src.length) { const s = at(i); if (s > i) { i = s; continue } if (src[i] === '{') { open = i; break } i++ }
|
|
143
|
+
if (open < 0) return null
|
|
144
|
+
// find matching close
|
|
145
|
+
let depth = 0, j = open, close = -1
|
|
146
|
+
while (j < src.length) { const s = at(j); if (s > j) { j = s; continue } const c = src[j]; if (c === '{') depth++; else if (c === '}') { depth--; if (depth === 0) { close = j; break } } j++ }
|
|
147
|
+
if (close < 0) return null
|
|
148
|
+
const sig = src.slice(0, open)
|
|
149
|
+
const body = src.slice(open + 1, close)
|
|
150
|
+
const inner = keyedUnits(topUnits(body, strings), body)
|
|
151
|
+
// re-base inner ranges into `body` coordinates are already correct (topUnits used body)
|
|
152
|
+
return { sig, open: '{', close: src.slice(close), body, units: inner, join: '' }
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
return provider
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Ready-made providers covering the common C-family languages. One provider can claim
|
|
159
|
+
// many extensions; comment/string syntax defaults fit the whole family (edge cases like
|
|
160
|
+
// Rust raw strings / lifetimes are tolerated — worst case a unit is merged coarsely).
|
|
161
|
+
export const braceLanguages = [
|
|
162
|
+
makeBraceLanguage({ id: 'ts', exts: ['.ts', '.tsx', '.mts', '.cts'] }),
|
|
163
|
+
makeBraceLanguage({ id: 'jsx', exts: ['.jsx'] }),
|
|
164
|
+
makeBraceLanguage({ id: 'go', exts: ['.go'], strings: ['"', '`'] }),
|
|
165
|
+
makeBraceLanguage({ id: 'rust', exts: ['.rs'] }),
|
|
166
|
+
makeBraceLanguage({ id: 'java', exts: ['.java'] }),
|
|
167
|
+
makeBraceLanguage({ id: 'c', exts: ['.c', '.h', '.cpp', '.hpp', '.cc', '.hh', '.cxx'] }),
|
|
168
|
+
makeBraceLanguage({ id: 'csharp', exts: ['.cs'] }),
|
|
169
|
+
makeBraceLanguage({ id: 'swift', exts: ['.swift'] }),
|
|
170
|
+
makeBraceLanguage({ id: 'kotlin', exts: ['.kt', '.kts'] }),
|
|
171
|
+
makeBraceLanguage({ id: 'scala', exts: ['.scala'] }),
|
|
172
|
+
makeBraceLanguage({ id: 'php', exts: ['.php'] }),
|
|
173
|
+
makeBraceLanguage({ id: 'dart', exts: ['.dart'] }),
|
|
174
|
+
]
|