Files
LexAI/packages/vscode/src/codeContext.ts
john kevin asprec 8bc529ef2d
Some checks failed
CI — Test & Build / Test & Build (push) Has been cancelled
feat: add LexAI status bar and suggestion panel
- Implemented a status bar item for LexAI with dynamic status updates (ready, processing, notReady).
- Created a suggestion panel for displaying and interacting with AI-generated suggestions.
- Added functionality for accepting, regenerating, and discarding suggestions within the suggestion zone.
- Introduced configuration options for writing style, prompt patterns, personas, and formats.
- Integrated progress indicators for long-running tasks and improved user feedback.
- Established TypeScript configuration for the vscode package.
2026-08-13 18:06:45 +08:00

305 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as vscode from 'vscode';
import * as path from 'node:path';
const MAX_DEFS = 8;
const MAX_IMPORT_FILES = 6;
const MAX_SNIPPET_CHARS = 2400;
const MAX_TOTAL_CONTEXT_CHARS = 14000;
const MAX_IDENTIFIERS = 24;
const SURROUND_LINES = 40;
const STOP_WORDS = new Set([
'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'return', 'const', 'let', 'var',
'function', 'class', 'interface', 'type', 'enum', 'import', 'export', 'from', 'default', 'async',
'await', 'try', 'catch', 'finally', 'throw', 'new', 'this', 'super', 'typeof', 'instanceof',
'true', 'false', 'null', 'undefined', 'void', 'in', 'of', 'as', 'is', 'public', 'private',
'protected', 'static', 'readonly', 'extends', 'implements', 'package', 'yield', 'with',
'string', 'number', 'boolean', 'any', 'unknown', 'never', 'object', 'Record', 'Partial',
'Promise', 'Array', 'Map', 'Set', 'Error', 'console', 'window', 'document', 'module',
'require', 'exports', 'process', 'Buffer', 'self', 'global', 'Math', 'JSON', 'Date',
]);
export interface CodeContextBundle {
/** Human-readable pack for the model user message */
contextText: string;
/** Short summary for UI status */
summary: string;
files: string[];
}
/**
* Build workspace-aware context for a selection: surrounding code, import
* targets, and definition-provider hits for identifiers in the selection.
*/
export async function gatherCodeContext(
document: vscode.TextDocument,
selection: vscode.Range,
): Promise<CodeContextBundle> {
const selected = document.getText(selection);
const wsFolder = vscode.workspace.getWorkspaceFolder(document.uri);
const rel = (uri: vscode.Uri) =>
wsFolder ? path.relative(wsFolder.uri.fsPath, uri.fsPath).replace(/\\/g, '/') : uri.fsPath;
const parts: string[] = [];
const files = new Set<string>();
let budget = MAX_TOTAL_CONTEXT_CHARS;
const push = (block: string) => {
if (budget <= 0) return;
const slice = block.length > budget ? block.slice(0, budget) + '\n…[truncated]' : block;
parts.push(slice);
budget -= slice.length;
};
const currentPath = rel(document.uri);
files.add(currentPath);
push(
[
'### Current file',
`Path: ${currentPath}`,
`Language: ${document.languageId}`,
`Selection lines: ${selection.start.line + 1}${selection.end.line + 1}`,
'',
'### Selected code',
fence(document.languageId, selected),
].join('\n'),
);
const surroundStart = Math.max(0, selection.start.line - SURROUND_LINES);
const surroundEnd = Math.min(document.lineCount - 1, selection.end.line + SURROUND_LINES);
const surround = document.getText(new vscode.Range(surroundStart, 0, surroundEnd, document.lineAt(surroundEnd).text.length));
push(
[
'',
`### Surrounding code in ${currentPath} (lines ${surroundStart + 1}${surroundEnd + 1})`,
fence(document.languageId, surround),
].join('\n'),
);
// Import / require targets in the current file
const importUris = await resolveImportUris(document);
let importCount = 0;
for (const uri of importUris) {
if (importCount >= MAX_IMPORT_FILES || budget <= 0) break;
if (uri.toString() === document.uri.toString()) continue;
try {
const doc = await vscode.workspace.openTextDocument(uri);
const excerpt = excerptForSelection(doc, selected);
const p = rel(uri);
files.add(p);
push(['', `### Imported module: ${p}`, fence(doc.languageId, excerpt)].join('\n'));
importCount += 1;
} catch {
// ignore unresolved / binary
}
}
// Definition provider for identifiers in the selection
const idents = extractIdentifiers(selected);
let defCount = 0;
const seenDefKeys = new Set<string>();
for (const ident of idents) {
if (defCount >= MAX_DEFS || budget <= 0) break;
const pos = findIdentifierPosition(document, selection, ident);
if (!pos) continue;
let locs: vscode.Location[] = [];
try {
const raw = await vscode.commands.executeCommand<
vscode.Location | vscode.Location[] | vscode.LocationLink[] | undefined
>('vscode.executeDefinitionProvider', document.uri, pos);
locs = normalizeLocations(raw);
} catch {
continue;
}
for (const loc of locs) {
if (defCount >= MAX_DEFS || budget <= 0) break;
const key = `${loc.uri.toString()}:${loc.range.start.line}:${loc.range.start.character}`;
if (seenDefKeys.has(key)) continue;
seenDefKeys.add(key);
if (loc.uri.toString() === document.uri.toString() && selection.contains(loc.range.start)) {
continue;
}
try {
const doc = await vscode.workspace.openTextDocument(loc.uri);
const snippet = expandDefinitionSnippet(doc, loc.range);
const p = rel(loc.uri);
files.add(p);
push(
[
'',
`### Definition of \`${ident}\`${p}:${loc.range.start.line + 1}`,
fence(doc.languageId, snippet),
].join('\n'),
);
defCount += 1;
} catch {
// skip
}
}
}
const summary =
defCount || importCount
? `Included ${defCount} definition(s) and ${importCount} import file(s) from the workspace.`
: 'No extra workspace definitions found (language support may be unavailable for this file).';
return {
contextText: parts.join('\n'),
summary,
files: [...files],
};
}
function fence(lang: string, body: string): string {
const safe = body.replace(/\r\n/g, '\n');
const clipped =
safe.length > MAX_SNIPPET_CHARS ? safe.slice(0, MAX_SNIPPET_CHARS) + '\n…[truncated]' : safe;
return '```' + (lang || '') + '\n' + clipped + '\n```';
}
function extractIdentifiers(text: string): string[] {
const matches = text.match(/\b[_A-Za-z][_A-Za-z0-9]*\b/g) ?? [];
const out: string[] = [];
const seen = new Set<string>();
for (const m of matches) {
if (STOP_WORDS.has(m) || m.length < 2) continue;
if (seen.has(m)) continue;
seen.add(m);
out.push(m);
if (out.length >= MAX_IDENTIFIERS) break;
}
return out;
}
function findIdentifierPosition(
document: vscode.TextDocument,
selection: vscode.Range,
ident: string,
): vscode.Position | undefined {
const text = document.getText(selection);
const idx = text.indexOf(ident);
if (idx < 0) return undefined;
const startOffset = document.offsetAt(selection.start) + idx;
return document.positionAt(startOffset);
}
function normalizeLocations(
raw: vscode.Location | vscode.Location[] | vscode.LocationLink[] | undefined,
): vscode.Location[] {
if (!raw) return [];
const arr = Array.isArray(raw) ? raw : [raw];
return arr.map((item) => {
if (item instanceof vscode.Location) return item;
const link = item as vscode.LocationLink;
return new vscode.Location(link.targetUri, link.targetSelectionRange ?? link.targetRange);
});
}
async function resolveImportUris(document: vscode.TextDocument): Promise<vscode.Uri[]> {
const text = document.getText();
const specs = new Set<string>();
// ES / TS imports
const reFrom = /from\s+['"]([^'"]+)['"]/g;
const reImport = /import\s+['"]([^'"]+)['"]/g;
const reRequire = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
for (const re of [reFrom, reImport, reRequire]) {
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
const spec = m[1];
if (spec.startsWith('.') || spec.startsWith('/')) specs.add(spec);
}
}
const uris: vscode.Uri[] = [];
for (const spec of specs) {
const resolved = await resolveRelativeModule(document.uri, spec);
if (resolved) uris.push(resolved);
}
return uris;
}
async function resolveRelativeModule(
from: vscode.Uri,
spec: string,
): Promise<vscode.Uri | undefined> {
const baseDir = path.posix.dirname(from.path);
const joined = path.posix.normalize(path.posix.join(baseDir, spec));
const candidates = [
joined,
`${joined}.ts`,
`${joined}.tsx`,
`${joined}.js`,
`${joined}.jsx`,
`${joined}.mjs`,
`${joined}.cjs`,
`${joined}.json`,
`${joined}/index.ts`,
`${joined}/index.tsx`,
`${joined}/index.js`,
];
for (const p of candidates) {
const uri = from.with({ path: p });
try {
await vscode.workspace.fs.stat(uri);
return uri;
} catch {
// try next
}
}
return undefined;
}
/** Prefer exporting / matching snippets from an imported file. */
function excerptForSelection(doc: vscode.TextDocument, selected: string): string {
const idents = extractIdentifiers(selected);
const full = doc.getText();
if (!idents.length) {
return full.slice(0, MAX_SNIPPET_CHARS);
}
const chunks: string[] = [];
for (const id of idents.slice(0, 10)) {
const patterns = [
new RegExp(
`(?:export\\s+)?(?:async\\s+)?function\\s+${id}\\b[\\s\\S]{0,800}?\\n\\}`,
'm',
),
new RegExp(
`(?:export\\s+)?(?:const|let|var|class|interface|type|enum)\\s+${id}\\b[\\s\\S]{0,600}`,
'm',
),
];
for (const re of patterns) {
const m = full.match(re);
if (m) {
chunks.push(m[0]);
break;
}
}
}
if (!chunks.length) {
// Fall back to first N lines (often exports barrel / header)
return full.split('\n').slice(0, 80).join('\n');
}
return chunks.join('\n\n');
}
function expandDefinitionSnippet(doc: vscode.TextDocument, range: vscode.Range): string {
const start = Math.max(0, range.start.line - 2);
let end = Math.min(doc.lineCount - 1, range.end.line);
const maxEnd = Math.min(doc.lineCount - 1, range.start.line + 80);
while (end < maxEnd) {
const line = doc.lineAt(end).text;
if (line.includes('}') || line.includes(';')) break;
end += 1;
}
return doc.getText(new vscode.Range(start, 0, end, doc.lineAt(end).text.length));
}