Files
2026Technology-Competition/src/adapters/sqlfluff.ts
T
范智鹏 3d8119d9c9 feat: 自动修复重构与审查面板交互修复 + 静态分析 AI 翻译配对 + SQLFluff 方言显示
- 自动修复:废弃 AI 修复,改 linter 原生 fix 多轮收敛;CodeAction hover + 面板修复/全部修复 + 快照 diff 撤销;hover 修复不入「已修复」列表、重新审查清空;修复/撤销后自动保存;单条修复只修目标问题(区间重叠收敛,不再连带相邻同规则)
- 审查面板:内联 JS 外部化(reviewPanel.js)修复 CSP 屏蔽导致的修复按钮/行号跳转/tab 失效;面板操作不依赖文件焦点(resolveFixDocument);行号跳转定位已打开编辑器,不在面板列新开副本
- 静态分析:translatedDiagnostics 规则 ID 归一化配对 + 深度审查 prompt 强化,静态分析条目显示中文翻译与逐条 AI 建议
- 波浪线:诊断补 source/code,hover 显示快速修复链接
- SQLFluff:设置面板方言徽章(显式/全局/项目/内置来源配色)
2026-08-18 21:41:51 +08:00

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 {}
}
}
}
}