- 自动修复:废弃 AI 修复,改 linter 原生 fix 多轮收敛;CodeAction hover + 面板修复/全部修复 + 快照 diff 撤销;hover 修复不入「已修复」列表、重新审查清空;修复/撤销后自动保存;单条修复只修目标问题(区间重叠收敛,不再连带相邻同规则) - 审查面板:内联 JS 外部化(reviewPanel.js)修复 CSP 屏蔽导致的修复按钮/行号跳转/tab 失效;面板操作不依赖文件焦点(resolveFixDocument);行号跳转定位已打开编辑器,不在面板列新开副本 - 静态分析:translatedDiagnostics 规则 ID 归一化配对 + 深度审查 prompt 强化,静态分析条目显示中文翻译与逐条 AI 建议 - 波浪线:诊断补 source/code,hover 显示快速修复链接 - SQLFluff:设置面板方言徽章(显式/全局/项目/内置来源配色)
248 lines
7.8 KiB
TypeScript
248 lines
7.8 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { spawn } from 'child_process';
|
|
import type { LinterAdapter, AdapterResult, LinterDiagnostic, Severity } from './adapter';
|
|
import { getSqlFluffConfigFile, getSqlFluffDialect } from '../config';
|
|
import { t } from '../i18n/messages';
|
|
import staticRules from '../rules/static-rules.json';
|
|
import { BUILTIN_SQLFLUFF_RULES, buildBuiltinSqlfluffConfig } from '../rules/builtin-rules';
|
|
|
|
const DIALECT_MAP: Record<string, string> = {
|
|
sql: 'oracle',
|
|
plsql: 'oracle',
|
|
};
|
|
|
|
export const SUPPORTED_DIALECTS = [
|
|
'ansi', 'athena', 'bigquery', 'clickhouse', 'databricks', 'db2', 'doris',
|
|
'duckdb', 'exasol', 'flink', 'greenplum', 'hive', 'impala', 'mariadb',
|
|
'materialize', 'mysql', 'oracle', 'postgres', 'redshift', 'snowflake',
|
|
'soql', 'sparksql', 'sqlite', 'starrocks', 'teradata', 'trino', 'tsql', 'vertica',
|
|
];
|
|
|
|
interface RuleEntry { id: string; description: string; tier?: string; }
|
|
|
|
const tierMap = new Map<string, string>();
|
|
try {
|
|
const sqlfluffRules = (staticRules as any).rules?.['sqlfluff'] as RuleEntry[] | undefined;
|
|
if (sqlfluffRules) {
|
|
for (const rule of sqlfluffRules) {
|
|
if (rule.id && rule.tier) {
|
|
tierMap.set(rule.id.replace('sqlfluff/', ''), rule.tier);
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
function tierToSeverity(tier: string | undefined): Severity {
|
|
if (tier === 'P0' || tier === 'P1') { return 'error'; }
|
|
if (tier === 'P2') { return 'warning'; }
|
|
return 'warning';
|
|
}
|
|
|
|
export function buildPRSMessage(description: string, dialect: string): string {
|
|
const match = /Found unparsable section: '([\s\S]*)'/.exec(description);
|
|
let fragment = match ? match[1] : description;
|
|
fragment = fragment.replace(/\n/g, '\\n');
|
|
if (fragment.length > 80) {
|
|
fragment = fragment.slice(0, 80) + '...';
|
|
}
|
|
return t('adapter.sqlfluffPRS', { 0: dialect, 1: fragment });
|
|
}
|
|
|
|
function findProjectSqlFluffConfig(workspaceRoot: string): string | undefined {
|
|
const candidates: Array<{ file: string; marker: string | null }> = [
|
|
{ file: '.sqlfluff', marker: null },
|
|
{ file: 'setup.cfg', marker: '[sqlfluff]' },
|
|
{ file: 'tox.ini', marker: '[sqlfluff]' },
|
|
{ file: 'pep8.ini', marker: '[sqlfluff]' },
|
|
{ file: 'pyproject.toml', marker: '[tool.sqlfluff]' },
|
|
];
|
|
for (const candidate of candidates) {
|
|
const filePath = path.join(workspaceRoot, candidate.file);
|
|
if (!fs.existsSync(filePath)) { continue; }
|
|
if (candidate.marker === null) { return filePath; }
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
if (content.includes(candidate.marker)) { return filePath; }
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readDialectFromConfigFile(filePath: string): string | undefined {
|
|
try {
|
|
const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/);
|
|
let inSection = false;
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (/^\[(tool\.)?sqlfluff\]\s*$/.test(trimmed)) {
|
|
inSection = true;
|
|
continue;
|
|
}
|
|
if (!inSection) { continue; }
|
|
if (/^\[/.test(trimmed)) { break; }
|
|
const match = /^dialect\s*[:=]\s*"?([A-Za-z0-9_]+)"?/.exec(trimmed);
|
|
if (match) { return match[1]; }
|
|
}
|
|
} catch {}
|
|
return undefined;
|
|
}
|
|
|
|
export type SqlFluffDialectSource = 'explicit' | 'global' | 'project' | 'builtin';
|
|
|
|
export interface SqlFluffDialectInfo {
|
|
dialect: string;
|
|
source: SqlFluffDialectSource;
|
|
}
|
|
|
|
export function resolveSqlFluffDialect(workspaceRoot: string): SqlFluffDialectInfo {
|
|
const explicit = getSqlFluffDialect();
|
|
if (explicit && SUPPORTED_DIALECTS.includes(explicit)) {
|
|
return { dialect: explicit, source: 'explicit' };
|
|
}
|
|
|
|
const globalConfig = getSqlFluffConfigFile();
|
|
if (globalConfig && globalConfig.trim() !== '') {
|
|
return { dialect: readDialectFromConfigFile(globalConfig) ?? 'ansi', source: 'global' };
|
|
}
|
|
|
|
const projectConfig = findProjectSqlFluffConfig(workspaceRoot);
|
|
if (projectConfig) {
|
|
return { dialect: readDialectFromConfigFile(projectConfig) ?? 'ansi', source: 'project' };
|
|
}
|
|
|
|
return { dialect: 'oracle', source: 'builtin' };
|
|
}
|
|
|
|
interface SqlFluffViolation {
|
|
start_line_no: number;
|
|
start_line_pos: number;
|
|
end_line_no: number;
|
|
end_line_pos: number;
|
|
code: string;
|
|
description: string;
|
|
}
|
|
|
|
interface SqlFluffResult {
|
|
filepath: string;
|
|
violations: SqlFluffViolation[];
|
|
}
|
|
|
|
function runSqlfluff(code: string, cwd: string, configPath?: string, dialect?: string): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const args = ['lint', '--format', 'json'];
|
|
if (dialect) {
|
|
args.push('--dialect', dialect);
|
|
}
|
|
if (configPath) {
|
|
args.push('--config', configPath);
|
|
}
|
|
args.push('-');
|
|
const child = spawn('sqlfluff', args, {
|
|
cwd,
|
|
timeout: 30000,
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
|
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
|
|
|
child.on('error', (err: NodeJS.ErrnoException) => {
|
|
if (err.code === 'ENOENT') {
|
|
reject(new Error('tool-unavailable'));
|
|
} else {
|
|
reject(err);
|
|
}
|
|
});
|
|
|
|
child.on('close', (code: number | null) => {
|
|
if (stdout) {
|
|
resolve(stdout);
|
|
} else {
|
|
reject(new Error(stderr || `sqlfluff exited with code ${code}`));
|
|
}
|
|
});
|
|
|
|
child.stdin.write(code);
|
|
child.stdin.end();
|
|
});
|
|
}
|
|
|
|
export class SqlFluffAdapter implements LinterAdapter {
|
|
id = 'sqlfluff';
|
|
supportedLanguages = ['sql', 'plsql'];
|
|
|
|
isAvailable(): boolean {
|
|
return true;
|
|
}
|
|
|
|
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
|
const languageId = document.languageId;
|
|
const fallbackDialect = DIALECT_MAP[languageId] ?? 'ansi';
|
|
|
|
const explicitDialect = getSqlFluffDialect();
|
|
const cliDialect = explicitDialect && SUPPORTED_DIALECTS.includes(explicitDialect)
|
|
? explicitDialect
|
|
: undefined;
|
|
const effectiveDialect = cliDialect ?? fallbackDialect;
|
|
|
|
let configPath: string | undefined;
|
|
let tempConfigPath: string | undefined;
|
|
|
|
const globalConfig = getSqlFluffConfigFile();
|
|
if (globalConfig && globalConfig.trim() !== '') {
|
|
configPath = globalConfig;
|
|
} else if (findProjectSqlFluffConfig(workingDir)) {
|
|
} else {
|
|
tempConfigPath = path.join(os.tmpdir(), `vscode-code-reviewer-sqlfluff-${Date.now()}.cfg`);
|
|
fs.writeFileSync(tempConfigPath, buildBuiltinSqlfluffConfig(cliDialect ?? fallbackDialect), 'utf-8');
|
|
configPath = tempConfigPath;
|
|
}
|
|
|
|
try {
|
|
const stdout = await runSqlfluff(document.getText(), workingDir, configPath, cliDialect);
|
|
const results: SqlFluffResult[] = JSON.parse(stdout);
|
|
const diagnostics: LinterDiagnostic[] = [];
|
|
|
|
for (const result of results) {
|
|
for (const v of result.violations) {
|
|
const isPRS = v.code === 'PRS';
|
|
diagnostics.push({
|
|
severity: isPRS ? 'error' : tierToSeverity(tierMap.get(v.code)),
|
|
ruleId: `sqlfluff:${v.code}`,
|
|
message: isPRS ? buildPRSMessage(v.description, effectiveDialect) : v.description,
|
|
range: new vscode.Range(
|
|
v.start_line_no - 1,
|
|
v.start_line_pos - 1,
|
|
v.end_line_no - 1,
|
|
v.end_line_pos - 1
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { diagnostics, status: 'ok' };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (message === 'tool-unavailable') {
|
|
return {
|
|
diagnostics: [],
|
|
status: 'tool-unavailable',
|
|
errorMessage: t('adapter.sqlfluffNotInstalled'),
|
|
};
|
|
}
|
|
return {
|
|
diagnostics: [],
|
|
status: 'execution-failed',
|
|
errorMessage: message,
|
|
};
|
|
} finally {
|
|
if (tempConfigPath) {
|
|
try { fs.unlinkSync(tempConfigPath); } catch {}
|
|
}
|
|
}
|
|
}
|
|
}
|