439 lines
14 KiB
TypeScript
439 lines
14 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
|
|
// Extension activation
|
|
export function activate(context: vscode.ExtensionContext) {
|
|
console.log('RelSpec Template Editor activated');
|
|
|
|
// Register commands
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('relspec.previewTemplate', previewTemplate)
|
|
);
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('relspec.validateTemplate', validateTemplate)
|
|
);
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('relspec.newTemplate', newTemplate)
|
|
);
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('relspec.listFunctions', listFunctions)
|
|
);
|
|
|
|
// Register completion provider
|
|
const completionProvider = vscode.languages.registerCompletionItemProvider(
|
|
'gotmpl',
|
|
new TemplateCompletionProvider(),
|
|
'{{', '.'
|
|
);
|
|
context.subscriptions.push(completionProvider);
|
|
|
|
// Register hover provider
|
|
const hoverProvider = vscode.languages.registerHoverProvider(
|
|
'gotmpl',
|
|
new TemplateHoverProvider()
|
|
);
|
|
context.subscriptions.push(hoverProvider);
|
|
|
|
// Auto-validate on save
|
|
context.subscriptions.push(
|
|
vscode.workspace.onDidSaveTextDocument((document) => {
|
|
const config = vscode.workspace.getConfiguration('relspec');
|
|
if (config.get('autoValidate') && document.languageId === 'gotmpl') {
|
|
validateTemplate();
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
// Preview template with sample data
|
|
async function previewTemplate() {
|
|
const editor = vscode.window.activeTextEditor;
|
|
if (!editor || editor.document.languageId !== 'gotmpl') {
|
|
vscode.window.showErrorMessage('Please open a .tmpl file');
|
|
return;
|
|
}
|
|
|
|
const panel = vscode.window.createWebviewPanel(
|
|
'templatePreview',
|
|
'Template Preview',
|
|
vscode.ViewColumn.Beside,
|
|
{
|
|
enableScripts: true
|
|
}
|
|
);
|
|
|
|
const templateContent = editor.document.getText();
|
|
const config = vscode.workspace.getConfiguration('relspec');
|
|
const sampleDataString = config.get<string>('previewSampleData', '{}');
|
|
|
|
try {
|
|
const sampleData = JSON.parse(sampleDataString);
|
|
const preview = await renderTemplate(templateContent, sampleData);
|
|
|
|
panel.webview.html = getWebviewContent(templateContent, preview, sampleData);
|
|
} catch (error) {
|
|
panel.webview.html = getErrorWebviewContent(String(error));
|
|
}
|
|
}
|
|
|
|
// Validate template syntax
|
|
async function validateTemplate() {
|
|
const editor = vscode.window.activeTextEditor;
|
|
if (!editor || editor.document.languageId !== 'gotmpl') {
|
|
return;
|
|
}
|
|
|
|
const templateContent = editor.document.getText();
|
|
const diagnostics: vscode.Diagnostic[] = [];
|
|
|
|
// Check for common template errors
|
|
const errors = checkTemplateSyntax(templateContent);
|
|
for (const error of errors) {
|
|
const range = new vscode.Range(
|
|
error.line,
|
|
error.column,
|
|
error.line,
|
|
error.column + error.length
|
|
);
|
|
const diagnostic = new vscode.Diagnostic(
|
|
range,
|
|
error.message,
|
|
vscode.DiagnosticSeverity.Error
|
|
);
|
|
diagnostics.push(diagnostic);
|
|
}
|
|
|
|
// Update diagnostics
|
|
const collection = vscode.languages.createDiagnosticCollection('relspec');
|
|
collection.set(editor.document.uri, diagnostics);
|
|
|
|
if (diagnostics.length === 0) {
|
|
vscode.window.showInformationMessage('Template is valid');
|
|
}
|
|
}
|
|
|
|
// Create new template from scaffold
|
|
async function newTemplate() {
|
|
const templateType = await vscode.window.showQuickPick(
|
|
[
|
|
{ label: 'DDL Template', value: 'ddl' },
|
|
{ label: 'Constraint Template', value: 'constraint' },
|
|
{ label: 'Index Template', value: 'index' },
|
|
{ label: 'Audit Template', value: 'audit' },
|
|
{ label: 'Custom Fragment', value: 'fragment' }
|
|
],
|
|
{ placeHolder: 'Select template type' }
|
|
);
|
|
|
|
if (!templateType) {
|
|
return;
|
|
}
|
|
|
|
const templateName = await vscode.window.showInputBox({
|
|
prompt: 'Enter template name',
|
|
placeHolder: 'my_template'
|
|
});
|
|
|
|
if (!templateName) {
|
|
return;
|
|
}
|
|
|
|
const scaffold = getTemplateScaffold(templateType.value, templateName);
|
|
const config = vscode.workspace.getConfiguration('relspec');
|
|
const templatePath = config.get<string>('templatePath', 'pkg/writers/pgsql/templates');
|
|
|
|
const workspaceFolders = vscode.workspace.workspaceFolders;
|
|
if (!workspaceFolders) {
|
|
vscode.window.showErrorMessage('No workspace folder open');
|
|
return;
|
|
}
|
|
|
|
const filePath = path.join(workspaceFolders[0].uri.fsPath, templatePath, `${templateName}.tmpl`);
|
|
fs.writeFileSync(filePath, scaffold);
|
|
|
|
const document = await vscode.workspace.openTextDocument(filePath);
|
|
await vscode.window.showTextDocument(document);
|
|
}
|
|
|
|
// List available template functions
|
|
async function listFunctions() {
|
|
const functions = getTemplateFunctions();
|
|
|
|
const panel = vscode.window.createWebviewPanel(
|
|
'functionsList',
|
|
'RelSpec Template Functions',
|
|
vscode.ViewColumn.Beside,
|
|
{}
|
|
);
|
|
|
|
panel.webview.html = getFunctionsWebviewContent(functions);
|
|
}
|
|
|
|
// Completion provider for template functions and keywords
|
|
class TemplateCompletionProvider implements vscode.CompletionItemProvider {
|
|
provideCompletionItems(
|
|
document: vscode.TextDocument,
|
|
position: vscode.Position
|
|
): vscode.CompletionItem[] {
|
|
const linePrefix = document.lineAt(position).text.substr(0, position.character);
|
|
|
|
if (!linePrefix.endsWith('{{')) {
|
|
return [];
|
|
}
|
|
|
|
const functions = getTemplateFunctions();
|
|
const completionItems: vscode.CompletionItem[] = [];
|
|
|
|
// Add function completions
|
|
for (const func of functions) {
|
|
const item = new vscode.CompletionItem(func.name, vscode.CompletionItemKind.Function);
|
|
item.detail = func.signature;
|
|
item.documentation = new vscode.MarkdownString(func.description);
|
|
item.insertText = new vscode.SnippetString(`${func.name} \${1:arg}}}`)
|
|
;
|
|
completionItems.push(item);
|
|
}
|
|
|
|
// Add keyword completions
|
|
const keywords = ['if', 'else', 'end', 'range', 'with', 'define', 'template', 'block'];
|
|
for (const keyword of keywords) {
|
|
const item = new vscode.CompletionItem(keyword, vscode.CompletionItemKind.Keyword);
|
|
completionItems.push(item);
|
|
}
|
|
|
|
return completionItems;
|
|
}
|
|
}
|
|
|
|
// Hover provider for template functions
|
|
class TemplateHoverProvider implements vscode.HoverProvider {
|
|
provideHover(
|
|
document: vscode.TextDocument,
|
|
position: vscode.Position
|
|
): vscode.Hover | undefined {
|
|
const range = document.getWordRangeAtPosition(position);
|
|
if (!range) {
|
|
return undefined;
|
|
}
|
|
|
|
const word = document.getText(range);
|
|
const functions = getTemplateFunctions();
|
|
const func = functions.find(f => f.name === word);
|
|
|
|
if (func) {
|
|
const markdown = new vscode.MarkdownString();
|
|
markdown.appendCodeblock(func.signature, 'go');
|
|
markdown.appendMarkdown('\n\n' + func.description);
|
|
markdown.appendMarkdown('\n\n**Example:**\n');
|
|
markdown.appendCodeblock(func.example, 'gotmpl');
|
|
return new vscode.Hover(markdown);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
function renderTemplate(template: string, data: any): Promise<string> {
|
|
// In a real implementation, this would call the Go binary
|
|
// For now, return a placeholder
|
|
return Promise.resolve(`-- Rendered SQL would appear here\n-- Template: ${template.substring(0, 50)}...`);
|
|
}
|
|
|
|
function checkTemplateSyntax(template: string): Array<{ line: number; column: number; length: number; message: string }> {
|
|
const errors: Array<{ line: number; column: number; length: number; message: string }> = [];
|
|
|
|
// Basic syntax checking
|
|
const lines = template.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
// Check for unclosed {{
|
|
const openCount = (line.match(/\{\{/g) || []).length;
|
|
const closeCount = (line.match(/\}\}/g) || []).length;
|
|
if (openCount > closeCount) {
|
|
errors.push({
|
|
line: i,
|
|
column: line.indexOf('{{'),
|
|
length: 2,
|
|
message: 'Unclosed template tag'
|
|
});
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function getTemplateScaffold(type: string, name: string): string {
|
|
const scaffolds: Record<string, string> = {
|
|
ddl: `{{/* ${name} - DDL operation template */}}
|
|
{{- define "${name}" -}}
|
|
ALTER TABLE {{.SchemaName}}.{{.TableName}}
|
|
-- Add your DDL operation here
|
|
{{- end -}}`,
|
|
constraint: `{{/* ${name} - Constraint template */}}
|
|
{{- define "${name}" -}}
|
|
ALTER TABLE {{.SchemaName}}.{{.TableName}}
|
|
ADD CONSTRAINT {{.ConstraintName}}
|
|
-- Add constraint definition here
|
|
{{- end -}}`,
|
|
index: `{{/* ${name} - Index template */}}
|
|
{{- define "${name}" -}}
|
|
CREATE {{if .Unique}}UNIQUE {{end}}INDEX IF NOT EXISTS {{.IndexName}}
|
|
ON {{.SchemaName}}.{{.TableName}}
|
|
USING {{.IndexType}} ({{join .Columns ", "}});
|
|
{{- end -}}`,
|
|
audit: `{{/* ${name} - Audit template */}}
|
|
{{- define "${name}" -}}
|
|
-- Audit configuration for {{.TableName}}
|
|
{{- end -}}`,
|
|
fragment: `{{/* ${name} - Reusable fragment */}}
|
|
{{- define "${name}" -}}
|
|
-- Add your reusable SQL fragment here
|
|
{{- end -}}`
|
|
};
|
|
|
|
return scaffolds[type] || scaffolds.fragment;
|
|
}
|
|
|
|
function getTemplateFunctions() {
|
|
return [
|
|
{
|
|
name: 'upper',
|
|
signature: 'upper(s string) string',
|
|
description: 'Convert string to uppercase',
|
|
example: '{{upper "hello"}} // HELLO'
|
|
},
|
|
{
|
|
name: 'lower',
|
|
signature: 'lower(s string) string',
|
|
description: 'Convert string to lowercase',
|
|
example: '{{lower "HELLO"}} // hello'
|
|
},
|
|
{
|
|
name: 'snake_case',
|
|
signature: 'snake_case(s string) string',
|
|
description: 'Convert string to snake_case',
|
|
example: '{{snake_case "UserId"}} // user_id'
|
|
},
|
|
{
|
|
name: 'camelCase',
|
|
signature: 'camelCase(s string) string',
|
|
description: 'Convert string to camelCase',
|
|
example: '{{camelCase "user_id"}} // userId'
|
|
},
|
|
{
|
|
name: 'quote',
|
|
signature: 'quote(s string) string',
|
|
description: 'Quote string for SQL (escapes single quotes)',
|
|
example: '{{quote "O\'Brien"}} // \'O\'\'Brien\''
|
|
},
|
|
{
|
|
name: 'safe_identifier',
|
|
signature: 'safe_identifier(s string) string',
|
|
description: 'Make string safe for SQL identifier',
|
|
example: '{{safe_identifier "User-Id"}} // user_id'
|
|
},
|
|
{
|
|
name: 'join',
|
|
signature: 'join(slice []string, sep string) string',
|
|
description: 'Join string slice with separator',
|
|
example: '{{join .Columns ", "}}'
|
|
}
|
|
];
|
|
}
|
|
|
|
function getWebviewContent(template: string, preview: string, data: any): string {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Template Preview</title>
|
|
<style>
|
|
body { font-family: var(--vscode-font-family); padding: 20px; }
|
|
.section { margin-bottom: 30px; }
|
|
h2 { color: var(--vscode-foreground); border-bottom: 1px solid var(--vscode-panel-border); }
|
|
pre { background: var(--vscode-editor-background); padding: 15px; border-radius: 4px; overflow-x: auto; }
|
|
.data { color: var(--vscode-editor-foreground); }
|
|
.sql { color: var(--vscode-textPreformat-foreground); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="section">
|
|
<h2>Template</h2>
|
|
<pre class="data">${escapeHtml(template)}</pre>
|
|
</div>
|
|
<div class="section">
|
|
<h2>Sample Data</h2>
|
|
<pre class="data">${escapeHtml(JSON.stringify(data, null, 2))}</pre>
|
|
</div>
|
|
<div class="section">
|
|
<h2>Rendered SQL</h2>
|
|
<pre class="sql">${escapeHtml(preview)}</pre>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function getErrorWebviewContent(error: string): string {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Error</title>
|
|
<style>
|
|
body { font-family: var(--vscode-font-family); padding: 20px; }
|
|
.error { color: var(--vscode-errorForeground); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h2 class="error">Template Error</h2>
|
|
<pre>${escapeHtml(error)}</pre>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function getFunctionsWebviewContent(functions: any[]): string {
|
|
const functionsHtml = functions.map(f => `
|
|
<div class="function">
|
|
<h3>${f.name}</h3>
|
|
<code>${f.signature}</code>
|
|
<p>${f.description}</p>
|
|
<pre>${f.example}</pre>
|
|
</div>
|
|
`).join('');
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Template Functions</title>
|
|
<style>
|
|
body { font-family: var(--vscode-font-family); padding: 20px; }
|
|
.function { margin-bottom: 30px; border-bottom: 1px solid var(--vscode-panel-border); padding-bottom: 20px; }
|
|
h3 { color: var(--vscode-foreground); margin-bottom: 10px; }
|
|
code { background: var(--vscode-textCodeBlock-background); padding: 4px 8px; border-radius: 3px; }
|
|
pre { background: var(--vscode-editor-background); padding: 15px; border-radius: 4px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Available Template Functions</h1>
|
|
${functionsHtml}
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function escapeHtml(text: string): string {
|
|
return text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
export function deactivate() {}
|