274 lines
11 KiB
JavaScript
274 lines
11 KiB
JavaScript
/**
|
|
* Splitting source into retrievable units.
|
|
*
|
|
* This is where retrieval quality is decided, and the rule that matters is that
|
|
* **a chunk must be a thing a person would name**. Splitting on a fixed line
|
|
* count produces units that begin mid-function and end mid-conditional; their
|
|
* embedding sits between two topics and matches neither, so the search returns
|
|
* plausible-looking noise. Splitting at declarations produces units whose
|
|
* embedding is about one subject, which is the entire premise of the retrieval.
|
|
*
|
|
* The anchor set is deliberately shallow — column-0 declarations only. A real
|
|
* parser would place boundaries better, but it would also mean shipping a
|
|
* tree-sitter grammar per language and keeping ten of them current, for a gain
|
|
* that only shows up on languages with heavy nesting. Since a chunk is a
|
|
* *search result* and not an edit target, a boundary that is roughly right costs
|
|
* a few wasted lines and nothing else.
|
|
*
|
|
* Fixed-width windows remain as the fallback, because a file whose structure the
|
|
* anchor set cannot see — a minified bundle, a template, an unusual dialect —
|
|
* must still be searchable rather than silently absent from the index.
|
|
*/
|
|
|
|
/** Lines a chunk aims for when merging neighbouring declarations. */
|
|
export const DEFAULT_CHUNK_LINES = 48
|
|
|
|
/** Lines a chunk may never exceed; longer ones are split with overlap. */
|
|
export const DEFAULT_MAX_CHUNK_LINES = 80
|
|
|
|
/** Lines shared between the halves of a split chunk, so a boundary is never lost. */
|
|
export const DEFAULT_OVERLAP_LINES = 8
|
|
|
|
/** Chunks taken from one file, so one pathological file cannot dominate the index. */
|
|
export const DEFAULT_MAX_CHUNKS_PER_FILE = 250
|
|
|
|
/** Files whose mean line length exceeds this are treated as generated/minified. */
|
|
const MINIFIED_MEAN_LINE = 400
|
|
|
|
/** Anchor patterns by language family, applied to the raw line. */
|
|
const FAMILIES = {
|
|
javascript: /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum|namespace|declare)\b/,
|
|
python: /^(?:async\s+)?(?:def|class)\s+[A-Za-z_]/,
|
|
go: /^(?:func|type|var|const)\b/,
|
|
rust: /^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?(?:extern\s+)?(?:fn|struct|enum|impl|trait|mod|const|static|type)\b/,
|
|
jvm: /^(?:@\w+|public|private|protected|static|final|abstract|sealed|open|class|interface|enum|record|object|fun|val|var|def)\b/,
|
|
dotnet: /^(?:\[[^\]]*\]|public|private|protected|internal|static|final|abstract|sealed|partial|class|interface|enum|record|struct|namespace)\b/,
|
|
c: /^(?:class|struct|enum|union|namespace|template|typedef|using|extern)\b|^[A-Za-z_][\w \t*&:<>]*\s+[A-Za-z_]\w*\s*\([^;]*\)\s*(?:const\s*)?$/,
|
|
ruby: /^(?:def|class|module)\s/,
|
|
php: /^(?:public|private|protected|static|final|abstract|function|class|interface|trait|namespace)\b/,
|
|
shell: /^(?:function\s+[A-Za-z_]|[A-Za-z_]\w*\s*\(\)\s*\{)/,
|
|
sql: /^(?:CREATE|ALTER|DROP|WITH)\b/i,
|
|
markup: /^<(?:template|script|style|svelte:)/,
|
|
}
|
|
|
|
/** Extension to family. Anything unlisted falls back to the window splitter. */
|
|
const EXTENSION_FAMILY = {
|
|
ts: 'javascript', tsx: 'javascript', mts: 'javascript', cts: 'javascript',
|
|
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
|
py: 'python', rb: 'ruby', go: 'go', rs: 'rust',
|
|
java: 'jvm', kt: 'jvm', kts: 'jvm', scala: 'jvm', swift: 'jvm',
|
|
c: 'c', h: 'c', cc: 'c', cpp: 'c', cxx: 'c', hpp: 'c', hh: 'c',
|
|
cs: 'dotnet',
|
|
php: 'php', pl: 'ruby', lua: 'ruby',
|
|
sh: 'shell', bash: 'shell', zsh: 'shell',
|
|
sql: 'sql', graphql: 'sql', proto: 'c',
|
|
vue: 'markup', svelte: 'markup',
|
|
}
|
|
|
|
/** Name of the declaration on an anchor line, when one can be read off it. */
|
|
const SYMBOL_PATTERNS = [
|
|
/\b(?:function|class|interface|type|enum|namespace|def|func|fn|struct|trait|impl|mod|const|let|var|record|object|fun|val|module|macro_rules!)\s+([A-Za-z_$][\w$]*)/,
|
|
/\b([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?:const\s*)?(?:\{|:)?\s*$/,
|
|
]
|
|
|
|
/** The language family for a path, or `undefined` when it has none. */
|
|
export function languageOf(path) {
|
|
const name = path.slice(path.lastIndexOf('/') + 1).toLowerCase()
|
|
const dot = name.lastIndexOf('.')
|
|
if (dot <= 0) return undefined
|
|
return EXTENSION_FAMILY[name.slice(dot + 1)]
|
|
}
|
|
|
|
/** The declaration regex for a family, or `undefined`. */
|
|
export function declarationPattern(family) {
|
|
return family === undefined ? undefined : FAMILIES[family]
|
|
}
|
|
|
|
/** The name declared on a line, when one reads off it. */
|
|
export function symbolOf(line) {
|
|
for (const pattern of SYMBOL_PATTERNS) {
|
|
const match = pattern.exec(line)
|
|
if (match !== null) return match[1]
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** Declaration names for a freshly built segment. */
|
|
function symbolList(symbol) {
|
|
return symbol === undefined ? [] : [symbol]
|
|
}
|
|
|
|
/**
|
|
* The label for a chunk that may have absorbed several declarations.
|
|
*
|
|
* A small file merges into a single chunk, and that chunk's label is then the
|
|
* file's only description in the index — so dropping every name but the first
|
|
* would describe a whole module as whatever its first declaration happened to
|
|
* be. The list is capped because beyond a handful of names the label stops
|
|
* being a description and starts being noise that dilutes the embedding.
|
|
*/
|
|
export function symbolLabel(symbols) {
|
|
const unique = [...new Set(symbols)]
|
|
if (unique.length === 0) return undefined
|
|
if (unique.length <= 4) return unique.join(', ')
|
|
return `${unique.slice(0, 4).join(', ')} +${unique.length - 4}`
|
|
}
|
|
|
|
/**
|
|
* Whether a file looks machine-generated.
|
|
*
|
|
* A minified bundle is one enormous line; embedding windows taken from it
|
|
* produces chunks of arbitrary identifiers that match every query weakly and
|
|
* none strongly. The mean line length separates the two cheaply and without
|
|
* reading the whole file.
|
|
*/
|
|
export function isLikelyMinified(content) {
|
|
const lines = content.split('\n')
|
|
if (lines.length === 0) return true
|
|
// Only consider files large enough for the statistic to mean anything.
|
|
if (lines.length < 4) {
|
|
return content.length > 20_000
|
|
}
|
|
const total = content.length
|
|
return total / lines.length > MINIFIED_MEAN_LINE
|
|
}
|
|
|
|
/** Trim trailing blank lines from a segment, leaving at least one line. */
|
|
function trimEnd(lines, start, end) {
|
|
let last = end
|
|
while (last > start && lines[last].trim() === '') last -= 1
|
|
return last
|
|
}
|
|
|
|
/** Split one oversized segment into overlapping windows. */
|
|
function splitLong(lines, start, end, maxLines, overlap) {
|
|
const out = []
|
|
const step = Math.max(1, maxLines - overlap)
|
|
for (let at = start; at <= end; at += step) {
|
|
const stop = Math.min(end, at + maxLines - 1)
|
|
out.push({ startLine: at, endLine: stop, symbol: undefined })
|
|
if (stop === end) break
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Split a file into chunks.
|
|
*
|
|
* @param {object} input
|
|
* @param {string} input.path workspace-relative path, used only for language detection
|
|
* @param {string} input.content file text
|
|
* @param {number} [input.chunkLines] target chunk size
|
|
* @param {number} [input.maxChunkLines] hard cap before splitting
|
|
* @param {number} [input.overlapLines] lines shared when splitting
|
|
* @param {number} [input.maxChunks] cap on chunks returned for one file
|
|
* @returns {{chunks: {startLine: number, endLine: number, symbol?: string, text: string}[], skipped?: string}}
|
|
*/
|
|
export function chunkFile(input) {
|
|
const {
|
|
path,
|
|
content,
|
|
chunkLines = DEFAULT_CHUNK_LINES,
|
|
maxChunkLines = DEFAULT_MAX_CHUNK_LINES,
|
|
overlapLines = DEFAULT_OVERLAP_LINES,
|
|
maxChunks = DEFAULT_MAX_CHUNKS_PER_FILE,
|
|
} = input
|
|
|
|
if (isLikelyMinified(content)) return { chunks: [], skipped: 'generated-or-minified' }
|
|
if (content.trim() === '') return { chunks: [], skipped: 'empty' }
|
|
|
|
const lines = content.split('\n')
|
|
const family = languageOf(path)
|
|
const pattern = declarationPattern(family)
|
|
|
|
/** Anchors: line indices that begin a declaration. */
|
|
const anchors = []
|
|
if (pattern !== undefined) {
|
|
for (let at = 0; at < lines.length; at += 1) {
|
|
const line = lines[at]
|
|
if (line === '' || /^\s/.test(line)) continue
|
|
if (pattern.test(line)) anchors.push(at)
|
|
}
|
|
}
|
|
|
|
/** Segments: [anchor, next anchor) with trailing blanks trimmed. */
|
|
const segments = []
|
|
// One anchor is still structure: a module holding a single top-level function
|
|
// should carry that function's name rather than lose it to the fallback.
|
|
if (anchors.length >= 1) {
|
|
if (anchors[0] > 0) anchors.unshift(0)
|
|
for (let at = 0; at < anchors.length; at += 1) {
|
|
const start = anchors[at]
|
|
const rawEnd = at + 1 < anchors.length ? anchors[at + 1] - 1 : lines.length - 1
|
|
const end = trimEnd(lines, start, rawEnd)
|
|
if (end < start) continue
|
|
segments.push({ startLine: start, endLine: end, symbols: symbolList(symbolOf(lines[start])) })
|
|
}
|
|
} else {
|
|
// No usable structure: one segment, and the splitter below does the work.
|
|
segments.push({ startLine: 0, endLine: trimEnd(lines, 0, lines.length - 1), symbols: [] })
|
|
}
|
|
|
|
/**
|
|
* Merge neighbours that fit together.
|
|
*
|
|
* A file of one-line getters would otherwise produce one chunk per getter,
|
|
* each too small to carry meaning. Merging accumulates every declaration name
|
|
* it swallowed, because a chunk tagged only with its first name would be
|
|
* embedded under a label describing a fraction of its contents.
|
|
*/
|
|
const merged = []
|
|
let current
|
|
for (const segment of segments) {
|
|
if (current !== undefined) {
|
|
const combined = segment.endLine - current.startLine + 1
|
|
if (combined <= chunkLines) {
|
|
current.endLine = segment.endLine
|
|
current.symbols.push(...segment.symbols)
|
|
continue
|
|
}
|
|
}
|
|
current = { startLine: segment.startLine, endLine: segment.endLine, symbols: [...segment.symbols] }
|
|
merged.push(current)
|
|
}
|
|
|
|
const out = []
|
|
for (const segment of merged) {
|
|
const label = symbolLabel(segment.symbols)
|
|
const length = segment.endLine - segment.startLine + 1
|
|
if (length <= maxChunkLines) {
|
|
out.push({ startLine: segment.startLine, endLine: segment.endLine, symbol: label })
|
|
continue
|
|
}
|
|
for (const piece of splitLong(lines, segment.startLine, segment.endLine, maxChunkLines, overlapLines)) {
|
|
out.push({ ...piece, symbol: piece.startLine === segment.startLine ? label : undefined })
|
|
}
|
|
}
|
|
|
|
const capped = out.length > maxChunks
|
|
const kept = capped ? out.slice(0, maxChunks) : out
|
|
return {
|
|
chunks: kept.map((chunk) => ({
|
|
// 1-based, so a result can be handed straight to `read` as offset/limit.
|
|
startLine: chunk.startLine + 1,
|
|
endLine: chunk.endLine + 1,
|
|
...(chunk.symbol === undefined ? {} : { symbol: chunk.symbol }),
|
|
text: lines.slice(chunk.startLine, chunk.endLine + 1).join('\n'),
|
|
})),
|
|
...(capped ? { skipped: 'chunk-cap-reached' } : {}),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The exact string that gets embedded for a chunk.
|
|
*
|
|
* The path is included on purpose. A developer asking "where is auth handled"
|
|
* means something closer to "under a path called auth" than to any particular
|
|
* line of code, and the path is the only signal in the chunk that carries that.
|
|
* The symbol follows for the same reason: it is what the question would name.
|
|
*/
|
|
export function embedText(path, chunk) {
|
|
const header = chunk.symbol === undefined ? path : `${path} :: ${chunk.symbol}`
|
|
return `${header}\n${chunk.text}`
|
|
}
|