Restructure into a monorepo: models, VS Code extension, daemon, shared
- models/: GGUFs (Git LFS), manifest.json, model card, Apache-2.0 LICENSE + NOTICE - extensions/vscode/: the Local Autocomplete extension (v0.5.0, publisher harvmaster) - daemon/: acd completion daemon (bundled into the extension; also runs standalone) - shared/: prompt layout + symbol extraction shared with the training pipeline - scripts/: make-model-release.mjs, publish-extension-release.mjs (Gitea releases) - README with quick start, settings, architecture, build and release instructions; MIT license for code; CHANGELOG The default model manifest URL moves to models/manifest.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,12 @@
|
||||
# shared
|
||||
|
||||
Code used both by the training pipeline (to build training/eval data) and by the [daemon](../daemon) at inference
|
||||
time. Keeping one copy is what guarantees the model sees exactly the prompt format it was trained on.
|
||||
|
||||
- [`prompt.ts`](prompt.ts): FIM prompt layout (`spm`/`psm`), the repo-level header, KV-cache-friendly prefix
|
||||
truncation, and post-processing (suffix-overlap trimming, single-line detection). No dependencies.
|
||||
- [`symbols.ts`](symbols.ts): "symbols available at the cursor" context from the TypeScript language service
|
||||
(imported signatures, repo-local types, optional member completions) and the missing-`this.` detection.
|
||||
Pinned to `typescript@5.9.3` and `@vue/compiler-sfc@3.5.43`: the output format is part of the model's training
|
||||
distribution, so upgrade only together with re-checking parity.
|
||||
- [`symbol-lang.ts`](symbol-lang.ts): language-id mapping (dependency-free, so the daemon's main bundle stays small).
|
||||
Generated
+223
@@ -0,0 +1,223 @@
|
||||
{
|
||||
"name": "autocomplete-shared",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "autocomplete-shared",
|
||||
"dependencies": {
|
||||
"@vue/compiler-sfc": "^3.5.43",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://verdaccio.harvmaster.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://verdaccio.harvmaster.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.9",
|
||||
"resolved": "https://verdaccio.harvmaster.com/@babel/parser/-/parser-7.29.9.tgz",
|
||||
"integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://verdaccio.harvmaster.com/@babel/types/-/types-7.29.8.tgz",
|
||||
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://verdaccio.harvmaster.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://verdaccio.harvmaster.com/@vue/compiler-core/-/compiler-core-3.5.43.tgz",
|
||||
"integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@vue/shared": "3.5.43",
|
||||
"entities": "^7.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://verdaccio.harvmaster.com/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz",
|
||||
"integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.43",
|
||||
"@vue/shared": "3.5.43"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://verdaccio.harvmaster.com/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz",
|
||||
"integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@vue/compiler-core": "3.5.43",
|
||||
"@vue/compiler-dom": "3.5.43",
|
||||
"@vue/compiler-ssr": "3.5.43",
|
||||
"@vue/shared": "3.5.43",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.28",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://verdaccio.harvmaster.com/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz",
|
||||
"integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.43",
|
||||
"@vue/shared": "3.5.43"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://verdaccio.harvmaster.com/@vue/shared/-/shared-3.5.43.tgz",
|
||||
"integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://verdaccio.harvmaster.com/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://verdaccio.harvmaster.com/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://verdaccio.harvmaster.com/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://verdaccio.harvmaster.com/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://verdaccio.harvmaster.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://verdaccio.harvmaster.com/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://verdaccio.harvmaster.com/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://verdaccio.harvmaster.com/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "autocomplete-shared",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@vue/compiler-sfc": "3.5.43",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Prompt construction and output post-processing shared by training export,
|
||||
* evaluation and the completion daemon, so all three build identical prompts.
|
||||
*
|
||||
* No dependencies: callers pass in their tokenizer's encode function and the
|
||||
* special token ids.
|
||||
*/
|
||||
|
||||
export type FimOrder = 'psm' | 'spm'
|
||||
export type Encode = (text: string) => number[]
|
||||
|
||||
export interface FimTokenIds { prefix: number; suffix: number; middle: number }
|
||||
export interface HeaderTokenIds { repoName: number; fileSep: number }
|
||||
|
||||
/**
|
||||
* Prompt token layout (everything before the middle).
|
||||
*
|
||||
* psm: <pre> prefix <suf> suffix <mid> Qwen2.5-Coder's native format
|
||||
* spm: <pre> <suf> suffix <mid> prefix Bavarian et al. 2022 SPM variant
|
||||
*
|
||||
* In spm the prefix runs straight into the middle and ends the prompt, so as
|
||||
* the user types, the prompt only grows at the end and the KV cache stays valid.
|
||||
*/
|
||||
export function fimPrompt(order: FimOrder, prefix: number[], suffix: number[], ids: FimTokenIds): number[] {
|
||||
return order === 'spm'
|
||||
? [ids.prefix, ids.suffix, ...suffix, ids.middle, ...prefix]
|
||||
: [ids.prefix, ...prefix, ids.suffix, ...suffix, ids.middle]
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen2.5-Coder repo-level header placed before the FIM prompt:
|
||||
* <repo_name>repo\n [<file_sep>dir/__symbols__.d.ts\n{context}\n] <file_sep>path\n
|
||||
*/
|
||||
export function repoHeader(repo: string, filePath: string, context: string | undefined, encode: Encode, ids: HeaderTokenIds): number[] {
|
||||
const out = [ids.repoName, ...encode(`${repo}\n`)]
|
||||
if (context) {
|
||||
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '.'
|
||||
out.push(ids.fileSep, ...encode(`${dir}/__symbols__.d.ts\n${context}\n`))
|
||||
}
|
||||
out.push(ids.fileSep, ...encode(`${filePath}\n`))
|
||||
return out
|
||||
}
|
||||
|
||||
/** Last ≤ maxTokens tokens of text, tokenising only a window that starts at a line boundary (training-time truncation). */
|
||||
export function tailTokens(text: string, maxTokens: number, encode: Encode): number[] {
|
||||
let window = text.slice(-maxTokens * 8)
|
||||
if (window.length < text.length) window = window.slice(window.indexOf('\n') + 1)
|
||||
return encode(window).slice(-maxTokens)
|
||||
}
|
||||
|
||||
/** First ≤ maxTokens tokens of text, window cut at a line boundary. Deterministic, so stable while the suffix is unchanged. */
|
||||
export function headTokens(text: string, maxTokens: number, encode: Encode): number[] {
|
||||
let window = text.slice(0, maxTokens * 8)
|
||||
if (window.length < text.length) window = window.slice(0, window.lastIndexOf('\n') + 1)
|
||||
return encode(window).slice(0, maxTokens)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-friendly prefix truncation for live completion.
|
||||
*
|
||||
* Instead of "the last N tokens" (whose start moves on every keystroke and
|
||||
* invalidates the whole KV cache), the prefix starts at a line whose index is
|
||||
* a multiple of `chunkLines`, chosen as the earliest such line that fits. The
|
||||
* start therefore only moves when the prefix outgrows the budget, about once
|
||||
* every `chunkLines` lines of typing.
|
||||
*/
|
||||
export function chunkedPrefixTokens(prefix: string, maxTokens: number, encode: Encode, chunkLines = 32, lineOffset = 0): { tokens: number[]; startLine: number } {
|
||||
// lineOffset: absolute line number of prefix's first line, when the client sent a truncated document,
|
||||
// so chunk boundaries stay on absolute multiples of chunkLines.
|
||||
const lineStarts = [0]
|
||||
for (let i = prefix.indexOf('\n'); i !== -1; i = prefix.indexOf('\n', i + 1)) lineStarts.push(i + 1)
|
||||
const firstChunkLine = (chunkLines - (lineOffset % chunkLines)) % chunkLines
|
||||
const candidates: number[] = []
|
||||
for (let l = firstChunkLine; l < lineStarts.length; l += chunkLines) candidates.push(l)
|
||||
// Estimate first (≈3.2 chars/token for code) to skip hopeless candidates, then verify with the real tokenizer.
|
||||
let c = 0
|
||||
while (c < candidates.length - 1 && (prefix.length - lineStarts[candidates[c]]) / 3.2 > maxTokens * 0.95) c++
|
||||
for (; c < candidates.length; c++) {
|
||||
const tokens = encode(prefix.slice(lineStarts[candidates[c]]))
|
||||
if (tokens.length <= maxTokens) return { tokens, startLine: lineOffset + candidates[c] }
|
||||
}
|
||||
// Even the last chunk is over budget (e.g. minified code): fall back to a plain tail.
|
||||
return { tokens: tailTokens(prefix, maxTokens, encode), startLine: lineOffset + lineStarts.length - 1 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans a raw completion the way an editor client would: drop what the model
|
||||
* re-generates of the existing suffix. Never looks at the gold middle.
|
||||
*
|
||||
* Lines are compared with their indentation: a method's closing ` }` must not
|
||||
* be mistaken for the class's `}` that follows in the suffix (that bug removed
|
||||
* legitimately predicted closing braces).
|
||||
*/
|
||||
export function trimSuffixOverlap(raw: string, suffix: string): string {
|
||||
let out = raw
|
||||
const sufLine = suffix.split('\n')[0]
|
||||
const sufFirstNonBlank = suffix.split('\n').find(l => l.trim())?.trimEnd()
|
||||
const lines = out.split('\n')
|
||||
if (sufFirstNonBlank) {
|
||||
const i = lines.findIndex((l, idx) => idx > 0 && l.trimEnd() === sufFirstNonBlank)
|
||||
if (i > 0) out = lines.slice(0, i).join('\n').replace(/\n[ \t]*$/, '')
|
||||
}
|
||||
if (!out.includes('\n') && sufLine.trim() && out.trimEnd().endsWith(sufLine.trimEnd())) {
|
||||
out = out.trimEnd().slice(0, -sufLine.trimEnd().length)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line mode when there is real code after the cursor on the same line
|
||||
* (a completion there must not add new lines). Closing brackets, quotes and
|
||||
* punctuation alone don't count: `foo(▮)` may still complete multi-line args.
|
||||
*/
|
||||
export function wantsSingleLine(suffix: string): boolean {
|
||||
const restOfLine = suffix.split('\n')[0]
|
||||
return /[^\s)\]}'"`;,]/.test(restOfLine)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Languages the symbol provider handles (kept dependency-free so the daemon's main bundle stays small). */
|
||||
export type SymbolLang = 'typescript' | 'javascript' | 'tsx' | 'jsx' | 'vue'
|
||||
|
||||
/** Maps VS Code language ids (and pipeline langs) onto what the symbol provider handles. */
|
||||
export function symbolLang(languageId: string): SymbolLang | null {
|
||||
const map: Record<string, SymbolLang> = {
|
||||
typescript: 'typescript', javascript: 'javascript', typescriptreact: 'tsx', javascriptreact: 'jsx', tsx: 'tsx', jsx: 'jsx', vue: 'vue',
|
||||
}
|
||||
return map[languageId] ?? null
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* "Symbols available at the cursor" context, from the TypeScript language
|
||||
* service, used both to build training data (pipeline/src/symbol-context.ts)
|
||||
* and at inference time (daemon). Keep the output format stable: Stage C was
|
||||
* trained on it.
|
||||
*
|
||||
* Context, in priority order:
|
||||
* 1. member completions when the cursor follows `expr.` / `expr.partial` (optional)
|
||||
* 2. hover signatures of imported symbols (ones used near the cursor first);
|
||||
* repo-local interfaces/types/classes include their declaration source
|
||||
* Trimmed to a token budget.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { parse as parseSfc } from '@vue/compiler-sfc'
|
||||
|
||||
import type { SymbolLang } from './symbol-lang.ts'
|
||||
export { symbolLang, type SymbolLang } from './symbol-lang.ts'
|
||||
|
||||
export interface SymbolsRequest {
|
||||
/** Absolute path of the file being edited (it may not exist on disk yet). */
|
||||
filePath: string
|
||||
lang: SymbolLang
|
||||
/** The file as it is at completion time (prefix + suffix). */
|
||||
visible: string
|
||||
/** Cursor offset in `visible`. */
|
||||
cursor: number
|
||||
budget: number
|
||||
countTokens: (s: string) => number
|
||||
/** Include member completions when the cursor follows `expr.` (default true, as in training). */
|
||||
members?: boolean
|
||||
}
|
||||
|
||||
export interface SymbolsResult { context: string; items: number; hasMembers: boolean }
|
||||
|
||||
/** <script> regions of a Vue SFC, sorted by position (same as pipeline/src/parse.ts regionsOf). */
|
||||
function vueScriptRegions(content: string): { start: number; end: number }[] {
|
||||
const { descriptor } = parseSfc(content, { sourceMap: false, ignoreEmpty: false })
|
||||
return [descriptor.script, descriptor.scriptSetup]
|
||||
.filter(b => b !== null)
|
||||
.map(b => ({ start: b!.loc.start.offset, end: b!.loc.end.offset }))
|
||||
.sort((a, b) => a.start - b.start)
|
||||
}
|
||||
|
||||
function compilerOptionsFor(rootDir: string): ts.CompilerOptions {
|
||||
const fallback: ts.CompilerOptions = {
|
||||
target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||
strict: true, allowJs: true, skipLibCheck: true, jsx: ts.JsxEmit.Preserve, allowImportingTsExtensions: true, noEmit: true,
|
||||
}
|
||||
const configPath = path.join(rootDir, 'tsconfig.json')
|
||||
let options = fallback
|
||||
if (fs.existsSync(configPath)) {
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} })
|
||||
if (parsed) options = { ...parsed.options, noEmit: true, skipLibCheck: true, allowJs: true }
|
||||
}
|
||||
// Common aliases in Quasar / Vite projects, if the tsconfig didn't resolve them.
|
||||
options.baseUrl ??= rootDir
|
||||
options.paths ??= { 'src/*': ['src/*'], '@/*': ['src/*'], 'components/*': ['src/components/*'], 'boot/*': ['src/boot/*'], 'stores/*': ['src/stores/*'] }
|
||||
return options
|
||||
}
|
||||
|
||||
const displayText = (parts?: readonly ts.SymbolDisplayPart[]) => (parts ?? []).map(p => p.text).join('')
|
||||
const clip = (s: string, max: number) => (s.length > max ? s.slice(0, max) + ' …' : s)
|
||||
|
||||
/** One TypeScript language service over a workspace root, with an overridable "current file". */
|
||||
export class WorkspaceSymbols {
|
||||
readonly rootDir: string
|
||||
private service: ts.LanguageService
|
||||
private overrides = new Map<string, { text: string; version: number }>()
|
||||
private version = 0
|
||||
|
||||
constructor(rootDir: string) {
|
||||
this.rootDir = rootDir
|
||||
const options = compilerOptionsFor(rootDir)
|
||||
const host: ts.LanguageServiceHost = {
|
||||
getCompilationSettings: () => options,
|
||||
getScriptFileNames: () => [...this.overrides.keys()],
|
||||
getScriptVersion: f => String(this.overrides.get(f)?.version ?? 0),
|
||||
getScriptSnapshot: f => {
|
||||
const o = this.overrides.get(f)
|
||||
if (o) return ts.ScriptSnapshot.fromString(o.text)
|
||||
return fs.existsSync(f) ? ts.ScriptSnapshot.fromString(fs.readFileSync(f, 'utf8')) : undefined
|
||||
},
|
||||
getCurrentDirectory: () => rootDir,
|
||||
getDefaultLibFileName: o => ts.getDefaultLibFilePath(o),
|
||||
fileExists: f => this.overrides.has(f) || ts.sys.fileExists(f),
|
||||
readFile: f => this.overrides.get(f)?.text ?? ts.sys.readFile(f),
|
||||
readDirectory: ts.sys.readDirectory,
|
||||
directoryExists: ts.sys.directoryExists,
|
||||
getDirectories: ts.sys.getDirectories,
|
||||
}
|
||||
this.service = ts.createLanguageService(host, ts.createDocumentRegistry())
|
||||
}
|
||||
|
||||
/** Makes `text` the content of the file being edited; returns the TS-visible file name, text and cursor. */
|
||||
private load(filePath: string, lang: SymbolLang, visible: string, cursor: number) {
|
||||
let tsText = visible
|
||||
let tsCursor: number | null = cursor
|
||||
let fileName = filePath
|
||||
if (lang === 'vue') {
|
||||
const scripts = vueScriptRegions(visible)
|
||||
if (!scripts.length) return null
|
||||
tsText = scripts.map(r => visible.slice(r.start, r.end)).join('\n')
|
||||
const inScript = scripts.find(r => r.start <= cursor && cursor <= r.end)
|
||||
tsCursor = inScript ? cursor - inScript.start + scripts.slice(0, scripts.indexOf(inScript)).reduce((n, r) => n + r.end - r.start + 1, 0) : null
|
||||
fileName += '.ts'
|
||||
}
|
||||
this.overrides.clear()
|
||||
this.overrides.set(fileName, { text: tsText, version: ++this.version })
|
||||
return { fileName, tsText, tsCursor }
|
||||
}
|
||||
|
||||
/** Source text of the declaration at a definition location, for repo-local types (not node_modules). */
|
||||
private declarationSource(fileName: string, pos: number): string | null {
|
||||
const def = this.service.getDefinitionAtPosition(fileName, pos)?.[0]
|
||||
if (!def || def.fileName.includes('node_modules') || def.fileName.endsWith('.d.ts')) return null
|
||||
if (!['interface', 'type', 'class', 'enum'].includes(def.kind)) return null
|
||||
const sf = this.service.getProgram()?.getSourceFile(def.fileName)
|
||||
if (!sf) return null
|
||||
let node: ts.Node | undefined
|
||||
const find = (n: ts.Node) => {
|
||||
if (n.getStart(sf) <= def.textSpan.start && def.textSpan.start + def.textSpan.length <= n.end) {
|
||||
if (ts.isInterfaceDeclaration(n) || ts.isTypeAliasDeclaration(n) || ts.isClassDeclaration(n) || ts.isEnumDeclaration(n)) node = n
|
||||
ts.forEachChild(n, find)
|
||||
}
|
||||
}
|
||||
find(sf)
|
||||
if (!node) return null
|
||||
let text = (node as ts.Node).getText(sf)
|
||||
if (ts.isClassDeclaration(node)) {
|
||||
// Signatures only: header + each member up to (not including) its body.
|
||||
const cls = node
|
||||
const header = sf.text.slice(cls.getStart(sf), cls.members.pos).trimEnd()
|
||||
const members = cls.members.map(m => {
|
||||
const body = (m as { body?: ts.Node }).body
|
||||
const sig = sf.text.slice(m.getStart(sf), body ? body.getStart(sf) : m.end).trim().replace(/\s*=\s*[\s\S]{60,}$/, '')
|
||||
return ' ' + sig.replace(/\s+/g, ' ').replace(/[;{]?$/, ';')
|
||||
})
|
||||
text = [header, ...members, '}'].join('\n')
|
||||
}
|
||||
const lines = text.split('\n')
|
||||
return lines.length > 25 ? [...lines.slice(0, 24), ' // …', lines.at(-1)!].join('\n') : text
|
||||
}
|
||||
|
||||
context(req: SymbolsRequest): SymbolsResult | null {
|
||||
const loaded = this.load(req.filePath, req.lang, req.visible, req.cursor)
|
||||
if (!loaded) return null
|
||||
const { fileName, tsText, tsCursor } = loaded
|
||||
const items: { priority: number; text: string }[] = []
|
||||
|
||||
// 1. Members at the cursor
|
||||
if (tsCursor !== null && req.members !== false) {
|
||||
const m = /\.([A-Za-z_$][\w$]*)?$/.exec(tsText.slice(Math.max(0, tsCursor - 200), tsCursor))
|
||||
if (m) {
|
||||
const pos = tsCursor - (m[1]?.length ?? 0)
|
||||
const completions = this.service.getCompletionsAtPosition(fileName, pos, { includeCompletionsWithInsertText: true })
|
||||
if (completions?.isMemberCompletion) {
|
||||
const entries = [...completions.entries].sort((a, b) => a.sortText.localeCompare(b.sortText) || a.name.localeCompare(b.name)).slice(0, 40)
|
||||
const detailed = entries.slice(0, 12).map(e => {
|
||||
const d = this.service.getCompletionEntryDetails(fileName, pos, e.name, undefined, undefined, undefined, undefined)
|
||||
return clip(displayText(d?.displayParts).replace(/^\((method|property|getter|setter)\) /, ''), 200)
|
||||
})
|
||||
const receiver = /([\w$.()[\]'"]+)\.[\w$]*$/.exec(tsText.slice(Math.max(0, tsCursor - 200), tsCursor))?.[1] ?? 'expression'
|
||||
const rest = entries.slice(12).map(e => e.name)
|
||||
items.push({ priority: 0, text: `// members of ${receiver}\n${detailed.join('\n')}${rest.length ? `\n// also: ${rest.join(', ')}` : ''}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Imported symbols
|
||||
const sf = ts.createSourceFile(fileName, tsText, ts.ScriptTarget.Latest, true)
|
||||
const near = tsCursor !== null
|
||||
? tsText.slice(Math.max(0, tsCursor - 2000), tsCursor + 500)
|
||||
: req.visible.slice(Math.max(0, req.cursor - 2000), req.cursor + 500)
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isImportDeclaration(stmt) || !stmt.importClause) continue
|
||||
const module = (stmt.moduleSpecifier as ts.StringLiteral).text
|
||||
// Frameworks the model already knows well: their signatures would only spend budget.
|
||||
if (/^(vue|vue-router|pinia|node:.*|fs|path|events|crypto)$/.test(module)) continue
|
||||
const names: ts.Identifier[] = []
|
||||
if (stmt.importClause.name) names.push(stmt.importClause.name)
|
||||
const bindings = stmt.importClause.namedBindings
|
||||
if (bindings && ts.isNamedImports(bindings)) for (const el of bindings.elements) names.push(el.name)
|
||||
for (const id of names) {
|
||||
const pos = id.getStart(sf)
|
||||
const info = this.service.getQuickInfoAtPosition(fileName, pos)
|
||||
let text = displayText(info?.displayParts).replace(/^\(alias\) /, '').split(/\nimport /)[0]
|
||||
if (!text || / any$/.test(text) || text.endsWith(': any')) continue
|
||||
const decl = this.declarationSource(fileName, pos)
|
||||
if (decl) text = decl
|
||||
// Skip hovers that carry no signature (e.g. "import z", "namespace debug", "class Mutex").
|
||||
else if (!/[:(]/.test(text.split('\n')[0]) || /^(import|namespace|module) /.test(text)) continue
|
||||
const used = new RegExp(`\\b${id.text.replace(/\$/g, '\\$')}\\b`).test(near)
|
||||
items.push({ priority: used ? 1 : 2, text: `// from '${module}'\n${clip(text, 900)}` })
|
||||
}
|
||||
}
|
||||
|
||||
if (!items.length) return null
|
||||
items.sort((a, b) => a.priority - b.priority)
|
||||
const chosen: string[] = []
|
||||
let used = 0
|
||||
for (const item of items) {
|
||||
const t = req.countTokens(item.text) + 1
|
||||
if (used + t > req.budget) continue
|
||||
chosen.push(item.text)
|
||||
used += t
|
||||
}
|
||||
return chosen.length ? { context: chosen.join('\n'), items: chosen.length, hasMembers: chosen[0].startsWith('// members of') } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* If the identifier being typed at the cursor only resolves as a member of
|
||||
* the enclosing class (the user forgot `this.`), returns the member name.
|
||||
* Uses the language service's own completions: TS offers class members with
|
||||
* insertText `this.x` at a lower priority than locals and parameters.
|
||||
*/
|
||||
thisMember(filePath: string, lang: SymbolLang, visible: string, cursor: number): { name: string; identLength: number } | null {
|
||||
const before = visible.slice(Math.max(0, cursor - 200), cursor)
|
||||
const m = /(^|[^\w$.])([A-Za-z_$][\w$]*)$/.exec(before)
|
||||
if (!m || m[2].length < 2) return null
|
||||
// Not when declaring something with that name.
|
||||
if (/\b(function|const|let|var|class|interface|type|enum|import|export|as|new)\s+[A-Za-z_$][\w$]*$/.test(before)) return null
|
||||
const ident = m[2]
|
||||
const loaded = this.load(filePath, lang, visible, cursor)
|
||||
if (!loaded || loaded.tsCursor === null) return null
|
||||
const completions = this.service.getCompletionsAtPosition(loaded.fileName, loaded.tsCursor, { includeCompletionsWithInsertText: true })
|
||||
const matching = (completions?.entries ?? []).filter(e => e.name.startsWith(ident) && e.kind !== 'keyword')
|
||||
if (!matching.length) return null
|
||||
matching.sort((a, b) => a.sortText.localeCompare(b.sortText))
|
||||
const best = matching[0]
|
||||
return best.insertText?.startsWith('this.') ? { name: best.name, identLength: ident.length } : null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user