209 lines
5.3 KiB
TypeScript
209 lines
5.3 KiB
TypeScript
import type { EditorView } from "@codemirror/view";
|
|
import type { Diagnostic } from "@codemirror/lint";
|
|
import type { TableSchema } from "@internal/tsql";
|
|
import { parseTSQLSelect, SyntaxError, QueryError, validateQuery } from "@internal/tsql";
|
|
|
|
/**
|
|
* Configuration for the TSQL linter
|
|
*/
|
|
export interface TSQLLinterConfig {
|
|
/** Optional schema for validating table/column names */
|
|
schema?: TableSchema[];
|
|
/** Delay in milliseconds before running the linter (debouncing) */
|
|
delay?: number;
|
|
}
|
|
|
|
/**
|
|
* Extract line and column from a TSQL error message
|
|
* Error format: "Syntax error at line X:Y: message"
|
|
*/
|
|
function parseErrorPosition(message: string): { line: number; column: number } | null {
|
|
const match = message.match(/at line (\d+):(\d+)/);
|
|
if (match) {
|
|
return {
|
|
line: parseInt(match[1], 10),
|
|
column: parseInt(match[2], 10),
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Convert line/column to a document position
|
|
*/
|
|
function positionToOffset(doc: string, line: number, column: number): number {
|
|
const lines = doc.split("\n");
|
|
|
|
// line is 1-indexed
|
|
let offset = 0;
|
|
for (let i = 0; i < line - 1 && i < lines.length; i++) {
|
|
offset += lines[i].length + 1; // +1 for newline
|
|
}
|
|
|
|
return offset + column;
|
|
}
|
|
|
|
/**
|
|
* Find the end of a word/token at the given position
|
|
*/
|
|
function findTokenEnd(doc: string, start: number): number {
|
|
let end = start;
|
|
|
|
// Scan forward until we hit whitespace or end of string
|
|
while (end < doc.length && /\S/.test(doc[end])) {
|
|
end++;
|
|
}
|
|
|
|
// If we didn't move, include at least one character
|
|
if (end === start) {
|
|
end = Math.min(start + 1, doc.length);
|
|
}
|
|
|
|
return end;
|
|
}
|
|
|
|
/**
|
|
* Create a TSQL linter function for CodeMirror
|
|
*
|
|
* This linter uses the TSQL ANTLR parser to detect syntax errors
|
|
* and optionally validates against a schema.
|
|
*
|
|
* @param config - Linter configuration
|
|
* @returns A linter function for use with CodeMirror's linter extension
|
|
*/
|
|
export function createTSQLLinter(
|
|
config: TSQLLinterConfig = {}
|
|
): (view: EditorView) => Diagnostic[] {
|
|
const { schema = [] } = config;
|
|
|
|
return (view: EditorView): Diagnostic[] => {
|
|
const content = view.state.doc.toString().trim();
|
|
|
|
// Return no errors for empty content
|
|
if (!content) {
|
|
return [];
|
|
}
|
|
|
|
const diagnostics: Diagnostic[] = [];
|
|
|
|
try {
|
|
// Try to parse the query
|
|
const ast = parseTSQLSelect(content);
|
|
|
|
// If parsing succeeds and we have a schema, run schema validation
|
|
if (schema.length > 0) {
|
|
const validationResult = validateQuery(ast, schema);
|
|
|
|
for (const issue of validationResult.issues) {
|
|
// Map validation severity to CodeMirror diagnostic severity
|
|
const severity: "error" | "warning" | "info" =
|
|
issue.severity === "error"
|
|
? "error"
|
|
: issue.severity === "warning"
|
|
? "warning"
|
|
: "info";
|
|
|
|
diagnostics.push({
|
|
from: 0,
|
|
to: content.length,
|
|
severity,
|
|
message: issue.message,
|
|
source: "tsql",
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) {
|
|
const position = parseErrorPosition(error.message);
|
|
|
|
let from: number;
|
|
let to: number;
|
|
|
|
if (position) {
|
|
from = positionToOffset(content, position.line, position.column);
|
|
to = findTokenEnd(content, from);
|
|
} else {
|
|
// If we can't parse the position, highlight the whole query
|
|
from = 0;
|
|
to = content.length;
|
|
}
|
|
|
|
// Clean up the error message
|
|
let message = error.message;
|
|
// Remove the "Syntax error at line X:Y: " prefix if present
|
|
message = message.replace(/^Syntax error at line \d+:\d+:\s*/, "");
|
|
|
|
diagnostics.push({
|
|
from,
|
|
to,
|
|
severity: "error",
|
|
message: message,
|
|
source: "tsql",
|
|
});
|
|
} else if (error instanceof QueryError) {
|
|
// Schema validation errors don't have position info,
|
|
// so highlight the whole query
|
|
diagnostics.push({
|
|
from: 0,
|
|
to: content.length,
|
|
severity: "warning",
|
|
message: error.message,
|
|
source: "tsql",
|
|
});
|
|
} else if (error instanceof Error) {
|
|
// Unknown error
|
|
diagnostics.push({
|
|
from: 0,
|
|
to: content.length,
|
|
severity: "error",
|
|
message: error.message,
|
|
source: "tsql",
|
|
});
|
|
}
|
|
}
|
|
|
|
return diagnostics;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if a TSQL query is valid
|
|
*
|
|
* @param query - The query to validate
|
|
* @returns true if the query is valid, false otherwise
|
|
*/
|
|
export function isValidTSQLQuery(query: string): boolean {
|
|
if (!query.trim()) {
|
|
return true; // Empty queries are considered valid
|
|
}
|
|
|
|
try {
|
|
parseTSQLSelect(query);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get error message for a TSQL query, if any
|
|
*
|
|
* @param query - The query to validate
|
|
* @returns Error message if invalid, null if valid
|
|
*/
|
|
export function getTSQLError(query: string): string | null {
|
|
if (!query.trim()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
parseTSQLSelect(query);
|
|
return null;
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
return "Unknown error";
|
|
}
|
|
}
|