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 = { 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(); 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 { 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 { 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 {} } } } }