UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

426 lines (425 loc) 15.5 kB
/** * @module SpmDiscoverer * @description SPM 项目发现器,适配 ProjectDiscoverer 接口 * * 内置 Package.swift 正则解析,提供模块列表和文件遍历。 * * 检测: 项目根或子目录存在 Package.swift */ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { basename, dirname, extname, join } from 'node:path'; import { LanguageService } from '#shared/LanguageService.js'; import { ProjectDiscoverer } from './ProjectDiscoverer.js'; const SKIP_DIRS = new Set([ 'node_modules', '.git', 'Build', '.build', '.swiftpm', 'Pods', 'DerivedData', 'Carthage', '.cursor', ]); export class SpmDiscoverer extends ProjectDiscoverer { #projectRoot = null; #parsedPackages = []; get id() { return 'spm'; } get displayName() { return 'Swift Package Manager (SPM)'; } async detect(projectRoot) { const hasRoot = existsSync(join(projectRoot, 'Package.swift')); if (hasRoot) { return { match: true, confidence: 0.95, reason: 'Package.swift found at project root' }; } try { const entries = readdirSync(projectRoot, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && !entry.name.startsWith('.')) { if (existsSync(join(projectRoot, entry.name, 'Package.swift'))) { return { match: true, confidence: 0.85, reason: `Package.swift found in ${entry.name}/`, }; } } } } catch { /* ignore */ } return { match: false, confidence: 0, reason: 'No Package.swift found' }; } async load(projectRoot) { this.#projectRoot = projectRoot; this.#parsedPackages = []; const allPaths = this.#findAllPackageSwifts(projectRoot); for (const pkgPath of allPaths) { try { const parsed = this.#parsePackageSwift(pkgPath); if (parsed) { this.#parsedPackages.push({ pkgPath, parsed }); } } catch { // 解析失败,跳过 } } } async listTargets() { const targets = []; for (const { pkgPath, parsed } of this.#parsedPackages) { const pkgDir = dirname(pkgPath); for (const t of parsed.targets || []) { targets.push({ name: t.name, path: pkgDir, type: t.type || 'library', language: 'swift', metadata: { ...t, packageName: parsed.name, packagePath: pkgPath, targetDir: pkgDir, }, }); } } return targets; } async getTargetFiles(target) { const targetName = typeof target === 'string' ? target : target.name; let sourcesDir = null; for (const { pkgPath, parsed } of this.#parsedPackages) { const matchTarget = parsed.targets?.find((t) => t.name === targetName); if (matchTarget) { const pkgDir = dirname(pkgPath); const candidates = []; if (matchTarget.path) { candidates.push(join(pkgDir, matchTarget.path)); } candidates.push(join(pkgDir, 'Sources', targetName)); candidates.push(join(pkgDir, targetName)); for (const dir of candidates) { if (existsSync(dir)) { sourcesDir = dir; break; } } if (sourcesDir) { break; } } } if (!sourcesDir) { const fallback = join(this.#projectRoot, 'Sources', targetName); if (existsSync(fallback)) { sourcesDir = fallback; } else { return []; } } return this.#walkSourceFiles(sourcesDir).map((f) => ({ name: f.name, path: f.path, relativePath: f.relativePath, language: this.#inferLang(f.path), })); } async getDependencyGraph() { if (!this.#projectRoot) { return { nodes: [], edges: [] }; } if (this.#parsedPackages.length === 0) { return { nodes: [], edges: [] }; } const nodes = []; const edges = []; const pkgNameSet = new Set(); const targetToPkg = new Map(); const allParsed = []; const umbrellaNames = new Set(); for (const { pkgPath, parsed } of this.#parsedPackages) { if (pkgNameSet.has(parsed.name)) { continue; } pkgNameSet.add(parsed.name); allParsed.push({ ...parsed, _dir: dirname(pkgPath) }); const hasTargets = parsed.targets && parsed.targets.length > 0; const hasProducts = parsed.products && parsed.products.length > 0; if (!hasTargets && !hasProducts) { umbrellaNames.add(parsed.name); continue; } nodes.push({ id: parsed.name, label: parsed.name, type: 'package', fullPath: dirname(pkgPath), targetCount: parsed.targets.length, }); for (const t of parsed.targets) { nodes.push({ id: t.name, label: t.name, type: 'target', parent: parsed.name, targetType: t.type, }); targetToPkg.set(t.name, parsed.name); } for (const prod of parsed.products || []) { if (!targetToPkg.has(prod.name)) { targetToPkg.set(prod.name, parsed.name); } } } for (const parsed of allParsed) { if (umbrellaNames.has(parsed.name)) { continue; } for (const dep of parsed.dependencies || []) { if (dep.type === 'local' && 'path' in dep && dep.path) { const depPkgSwift = join(parsed._dir, dep.path, 'Package.swift'); if (existsSync(depPkgSwift)) { try { const depParsed = this.#parsePackageSwift(depPkgSwift); if (!umbrellaNames.has(depParsed.name)) { edges.push({ from: parsed.name, to: depParsed.name, type: 'depends_on' }); } } catch { const targetName = basename(dep.path); if (!umbrellaNames.has(targetName)) { edges.push({ from: parsed.name, to: targetName, type: 'depends_on' }); } } } } else if ('url' in dep && dep.url) { const remoteName = basename(dep.url).replace(/\.git$/, ''); if (!pkgNameSet.has(remoteName)) { pkgNameSet.add(remoteName); nodes.push({ id: remoteName, label: remoteName, type: 'remote', indirect: true }); } edges.push({ from: parsed.name, to: remoteName, type: 'depends_on' }); } } for (const t of parsed.targets || []) { edges.push({ from: parsed.name, to: t.name, type: 'contains' }); for (const depName of t.dependencies || []) { if (!umbrellaNames.has(depName)) { edges.push({ from: t.name, to: depName, type: 'depends_on' }); } } } } return { nodes, edges }; } // ─────────────── Private Helpers ─────────────── /** 向下递归扫描所有 Package.swift(支持多 Package 项目) */ #findAllPackageSwifts(rootDir) { const results = []; const scan = (dir, depth = 0) => { if (depth > 5) { return; } try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { if (SKIP_DIRS.has(entry.name)) { continue; } scan(join(dir, entry.name), depth + 1); } else if (entry.name === 'Package.swift') { results.push(join(dir, entry.name)); } } } catch { // 权限错误等,跳过 } }; scan(rootDir); return results; } /** 简易解析 Package.swift(无 Swift 编译器,使用正则) */ #parsePackageSwift(packagePath) { if (!packagePath || !existsSync(packagePath)) { throw new Error(`Package.swift not found: ${packagePath}`); } const content = readFileSync(packagePath, 'utf-8'); return { path: packagePath, name: this.#extractName(content), version: this.#extractVersion(content), targets: this.#extractTargets(content), dependencies: this.#extractDependencies(content), products: this.#extractProducts(content), platforms: this.#extractPlatforms(content), }; } #extractName(content) { const m = content.match(/name\s*:\s*"([^"]+)"/); return m ? m[1] : 'unknown'; } #extractVersion(content) { const m = content.match(/version\s*:\s*"([^"]+)"/); return m ? m[1] : '0.0.0'; } #extractTargets(content) { const targets = []; const re = /\.(?:target|testTarget|executableTarget)\s*\(/g; let match; while ((match = re.exec(content)) !== null) { const type = match[0].includes('testTarget') ? 'testTarget' : match[0].includes('executableTarget') ? 'executableTarget' : 'target'; const startPos = match.index + match[0].length; let depth = 1; let endPos = startPos; while (depth > 0 && endPos < content.length) { if (content[endPos] === '(') { depth++; } else if (content[endPos] === ')') { depth--; } endPos++; } if (depth === 0) { const block = content.substring(startPos, endPos - 1); const nameMatch = block.match(/name\s*:\s*"([^"]+)"/); if (!nameMatch) { continue; } const pathMatch = block.match(/path\s*:\s*"([^"]+)"/); const depsMatch = block.match(/dependencies\s*:\s*\[([^\]]*)\]/s); const deps = []; if (depsMatch) { const depRe = /\.(?:product|target)\s*\(\s*name\s*:\s*"([^"]+)"/g; let dm; while ((dm = depRe.exec(depsMatch[1])) !== null) { deps.push(dm[1]); } } targets.push({ name: nameMatch[1], type, path: pathMatch ? pathMatch[1] : null, dependencies: deps, }); } } return targets; } #extractDependencies(content) { const deps = []; const urlRe = /\.package\s*\(\s*url\s*:\s*"([^"]+)"[^)]*\)/g; let m; while ((m = urlRe.exec(content)) !== null) { const block = m[0]; const fromMatch = block.match(/from\s*:\s*"([^"]+)"/); const exactMatch = block.match(/exact\s*:\s*"([^"]+)"/); deps.push({ url: m[1], version: fromMatch ? fromMatch[1] : exactMatch ? exactMatch[1] : null, type: 'package', }); } const pathRe = /\.package\s*\(\s*path\s*:\s*"([^"]+)"\s*\)/g; while ((m = pathRe.exec(content)) !== null) { deps.push({ path: m[1], type: 'local', }); } return deps; } #extractProducts(content) { const products = []; const re = /\.(library|executable)\s*\(\s*name\s*:\s*"([^"]+)"/g; let m; while ((m = re.exec(content)) !== null) { products.push({ name: m[2], type: m[1] }); } return products; } #extractPlatforms(content) { const platforms = []; const re = /\.(iOS|macOS|tvOS|watchOS|visionOS)\s*\(\s*\.v(\d+(?:_\d+)?)\s*\)/g; let m; while ((m = re.exec(content)) !== null) { platforms.push({ name: m[1], version: m[2].replace(/_/g, '.') }); } return platforms; } #walkSourceFiles(dir) { const CODE_EXTS = new Set(['.swift', '.m', '.h', '.c', '.cpp', '.mm']); const SKIP_DIRS = new Set([ 'node_modules', '.git', 'dist', 'build', '.build', 'DerivedData', 'Pods', 'Carthage', ]); const MAX_FILES = 300; const files = []; const walk = (d, rel = '') => { if (files.length >= MAX_FILES) { return; } let entries; try { entries = readdirSync(d); } catch { return; } for (const entry of entries) { if (files.length >= MAX_FILES) { break; } if (entry.startsWith('.')) { continue; } const full = join(d, entry); const relPath = rel ? `${rel}/${entry}` : entry; let st; try { st = statSync(full); } catch { continue; } if (st.isDirectory()) { if (!SKIP_DIRS.has(entry)) { walk(full, relPath); } } else if (CODE_EXTS.has(extname(entry).toLowerCase())) { if (st.size <= 512 * 1024) { files.push({ name: entry, path: full, relativePath: relPath }); } } } }; walk(dir); return files; } #inferLang(filePath) { return LanguageService.inferLang(filePath); } }