diff --git a/src/extension.ts b/src/extension.ts index d8cce73..2151c87 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,6 +26,7 @@ import { SubstitutionCompletionProvider } from './substitutionCompletionProvider import { SubstitutionHoverProvider } from './substitutionHoverProvider'; import { FrontmatterCompletionProvider } from './frontmatterCompletionProvider'; import { FrontmatterValidationProvider } from './frontmatterValidationProvider'; +import { SubstitutionValidationProvider } from './substitutionValidationProvider'; import { outputChannel } from './logger'; @@ -33,15 +34,15 @@ export function activate(context: vscode.ExtensionContext): void { // Debug logging outputChannel.appendLine('Elastic Docs V3 Utilities: Extension activated'); outputChannel.appendLine('Registering completion providers...'); - + // Apply color customizations programmatically applyColorCustomizations(); - + // Test grammar loading testGrammarLoading(); - + // Ensure we're working with markdown files and handle potential conflicts - + // Note: getLanguages() is async, but we'll proceed without this check // as the extension should work fine even if markdown support is loaded later @@ -53,6 +54,7 @@ export function activate(context: vscode.ExtensionContext): void { const substitutionHoverProvider = new SubstitutionHoverProvider(); const frontmatterProvider = new FrontmatterCompletionProvider(); const frontmatterValidator = new FrontmatterValidationProvider(); + const substitutionValidator = new SubstitutionValidationProvider(); // Register completion providers for markdown files context.subscriptions.push( @@ -111,34 +113,39 @@ export function activate(context: vscode.ExtensionContext): void { // Register diagnostic providers const diagnosticCollection = vscode.languages.createDiagnosticCollection('elastic-directives'); const frontmatterDiagnosticCollection = vscode.languages.createDiagnosticCollection('elastic-frontmatter'); + const substitutionDiagnosticCollection = vscode.languages.createDiagnosticCollection('elastic-substitution'); context.subscriptions.push(diagnosticCollection); context.subscriptions.push(frontmatterDiagnosticCollection); - + context.subscriptions.push(substitutionDiagnosticCollection); + // Update diagnostics when document changes const updateDiagnostics = (document: vscode.TextDocument): void => { if (document.languageId === 'markdown') { // Directive diagnostics const diagnostics = diagnosticProvider.provideDiagnostics(document); diagnosticCollection.set(document.uri, diagnostics); - + // Frontmatter diagnostics const frontmatterDiagnostics = frontmatterValidator.validateDocument(document); frontmatterDiagnosticCollection.set(document.uri, frontmatterDiagnostics); + // Substitution diagnostics + const substitutionDiagnostics = substitutionValidator.validateDocument(document); + substitutionDiagnosticCollection.set(document.uri, substitutionDiagnostics); } }; - + // Initial diagnostics if (vscode.window.activeTextEditor) { updateDiagnostics(vscode.window.activeTextEditor.document); } - + // Listen for document changes context.subscriptions.push( vscode.workspace.onDidChangeTextDocument(event => { updateDiagnostics(event.document); }) ); - + // Listen for document opens context.subscriptions.push( vscode.workspace.onDidOpenTextDocument(document => { @@ -150,9 +157,11 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push( vscode.workspace.onDidSaveTextDocument(document => { if (document.languageId === 'markdown') { - // Re-run frontmatter validation on save + // Re-run validation on save const frontmatterDiagnostics = frontmatterValidator.validateDocument(document); frontmatterDiagnosticCollection.set(document.uri, frontmatterDiagnostics); + const substitutionDiagnostics = substitutionValidator.validateDocument(document); + substitutionDiagnosticCollection.set(document.uri, substitutionDiagnostics); } }) ); @@ -184,13 +193,13 @@ export function activate(context: vscode.ExtensionContext): void { const position = editor.selection.active; const line = editor.document.lineAt(position.line); - + // Replace the entire line with the template const range = new vscode.Range( new vscode.Position(position.line, 0), new vscode.Position(position.line, line.text.length) ); - + editor.edit(editBuilder => { editBuilder.replace(range, template); }); @@ -211,7 +220,7 @@ export function activate(context: vscode.ExtensionContext): void { function applyColorCustomizations(): void { const config = vscode.workspace.getConfiguration('editor'); const currentCustomizations = config.get('tokenColorCustomizations') as Record || {}; - + // Define our custom color rules const elasticRules = [ { @@ -276,11 +285,11 @@ function applyColorCustomizations(): void { } } ]; - + // Merge with existing rules const existingRules = (currentCustomizations.textMateRules as unknown[]) || []; const newRules = [...existingRules, ...elasticRules]; - + // Apply the customizations config.update('tokenColorCustomizations', { ...currentCustomizations, @@ -294,12 +303,12 @@ function testGrammarLoading(): void { const activeEditor = vscode.window.activeTextEditor; if (activeEditor && activeEditor.document.languageId === 'markdown') { outputChannel.appendLine('Elastic Docs V3: Testing grammar on active markdown file'); - + // Check if our scopes are being applied const document = activeEditor.document; const position = new vscode.Position(0, 0); const token = document.getWordRangeAtPosition(position); - + if (token) { const tokens = document.getText(token); outputChannel.appendLine(`Elastic Docs V3: Found tokens at start: ${tokens}`); diff --git a/src/frontmatterSchema.ts b/src/frontmatterSchema.ts index a07fe8a..d40ec30 100644 --- a/src/frontmatterSchema.ts +++ b/src/frontmatterSchema.ts @@ -17,6 +17,8 @@ * under the License. */ +import { PRODUCTS } from './products'; + // Embedded frontmatter schema for Elastic Documentation export const frontmatterSchema = { "$schema": "http://json-schema.org/draft-07/schema#", @@ -175,15 +177,7 @@ export const frontmatterSchema = { "properties": { "id": { "type": "string", - "enum": [ - "apm", "apm-agent", "auditbeat", "beats", "cloud-control-ecctl", "cloud-enterprise", - "cloud-hosted", "cloud-kubernetes", "cloud-serverless", "cloud-terraform", "ecs", - "ecs-logging", "edot-cf", "edot-sdk", "edot-collector", "elastic-agent", - "elastic-serverless-forwarder", "elastic-stack", "elasticsearch", "elasticsearch-client", - "filebeat", "fleet", "heartbeat", "integrations", "kibana", "logstash", - "machine-learning", "metricbeat", "observability", "packetbeat", "painless", - "search-ui", "security", "winlogbeat" - ], + "enum": Object.keys(PRODUCTS), "description": "Product identifier. Must match one of the predefined product IDs." } }, @@ -279,7 +273,7 @@ export const frontmatterSchema = { }, { "key": "beta", - "alias": "beta", + "alias": "beta", "description": "Beta release - feature is stable but may have bugs" }, { @@ -324,7 +318,7 @@ export const frontmatterSchema = { "keys": [ "stack", "deployment", "serverless", "product", "ece", "eck", "ess", "self", - "elasticsearch", "observability", "security", + "elasticsearch", "observability", "security", "ecctl", "curator", "apm_agent_android", "apm_agent_dotnet", "apm_agent_go", "apm_agent_ios", "apm_agent_java", "apm_agent_node", "apm_agent_php", "apm_agent_python", "apm_agent_ruby", "apm_agent_rum", "edot_ios", "edot_android", "edot_dotnet", "edot_java", "edot_node", "edot_php", "edot_python", "edot_cf_aws", "edot_cf_azure", "edot_collector" diff --git a/src/products.ts b/src/products.ts new file mode 100644 index 0000000..ad400e4 --- /dev/null +++ b/src/products.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const PRODUCTS: Record = { + 'apm': 'APM', + 'apm-agent': 'APM Agent', + 'apm-agent-dotnet': 'APM .NET Agent', + 'apm-agent-go': 'APM Go Agent', + 'apm-agent-java': 'APM Java Agent', + 'apm-agent-node': 'APM Node.js Agent', + 'apm-agent-php': 'APM PHP Agent', + 'apm-agent-python': 'APM Python Agent', + 'apm-agent-ruby': 'APM Ruby Agent', + 'apm-agent-rum-js': 'APM RUM JavaScript Agent', + 'apm-k8s-attacher': 'APM Attacher for Kubernetes', + 'apm-aws-lambda': 'APM AWS Lambda extension', + 'apm-server': 'APM Server', + 'auditbeat': 'Auditbeat', + 'beats': 'Beats', + 'cloud-control-ecctl': 'Elastic Cloud Control', + 'cloud-enterprise': 'Elastic Cloud Enterprise', + 'cloud-hosted': 'Elastic Cloud Hosted', + 'cloud-kubernetes': 'Elastic Cloud on Kubernetes', + 'cloud-serverless': 'Elastic Cloud Serverless', + 'cloud-terraform': 'Elastic Cloud Terraform Provider', + 'curator': 'Elasticsearch Curator', + 'ecs': 'Elastic Common Schema (ECS)', + 'ecs-logging': 'ECS Logging', + 'ecs-dotnet': 'ECS Logging .NET', + 'ecs-logging-go-logrus': 'ECS Logging Go (Logrus)', + 'ecs-logging-go-zap': 'ECS Logging Go (Zap)', + 'ecs-logging-go-zerolog': 'ECS Logging Go (Zerolog)', + 'ecs-logging-java': 'ECS Logging Java', + 'ecs-logging-nodejs': 'ECS Logging Node.js', + 'ecs-logging-php': 'ECS Logging PHP', + 'ecs-logging-python': 'ECS Logging Python', + 'ecs-logging-ruby': 'ECS Logging Ruby', + 'edot-cf': 'EDOT Cloud Forwarder', + 'edot-sdk': 'Elastic Distribution of OpenTelemetry SDK', + 'edot-collector': 'Elastic Distribution of OpenTelemetry Collector', + 'edot-ios': 'Elastic Distribution of OpenTelemetry iOS', + 'edot-android': 'Elastic Distribution of OpenTelemetry Android', + 'edot-dotnet': 'Elastic Distribution of OpenTelemetry .NET', + 'edot-java': 'Elastic Distribution of OpenTelemetry Java', + 'edot-node': 'Elastic Distribution of OpenTelemetry Node', + 'edot-php': 'Elastic Distribution of OpenTelemetry PHP', + 'edot-python': 'Elastic Distribution of OpenTelemetry Python', + 'edot-cf-aws': 'EDOT Cloud Forwarder for AWS', + 'edot-cf-azure': 'EDOT Cloud Forwarder for Azure', + 'eland': 'Eland', + 'elastic-agent': 'Elastic Agent', + 'elastic-serverless-forwarder': 'Elastic Serverless Forwarder', + 'elastic-stack': 'Elastic Stack', + 'elasticsearch': 'Elasticsearch', + 'elasticsearch-client': 'Elasticsearch Client', + 'ess': 'Elastic Cloud Hosted', + 'filebeat': 'Filebeat', + 'fleet': 'Fleet', + 'heartbeat': 'Heartbeat', + 'integrations': 'Elastic integrations', + 'kibana': 'Kibana', + 'logstash': 'Logstash', + 'machine-learning': 'Machine Learning', + 'metricbeat': 'Metricbeat', + 'observability': 'Elastic Observability', + 'packetbeat': 'Packetbeat', + 'painless': 'Painless', + 'search-ui': 'Search UI', + 'security': 'Elastic Security', + 'self': 'Self-managed Elastic', + 'serverless-elasticsearch': 'Elasticsearch Serverless', + 'serverless-observability': 'Elastic Observability Serverless', + 'serverless-security': 'Elastic Security Serverless', + 'winlogbeat': 'Winlogbeat', +} \ No newline at end of file diff --git a/src/substitutionCompletionProvider.ts b/src/substitutionCompletionProvider.ts index 6de98f9..b0540f3 100644 --- a/src/substitutionCompletionProvider.ts +++ b/src/substitutionCompletionProvider.ts @@ -18,22 +18,17 @@ */ import * as vscode from 'vscode'; -import * as path from 'path'; -import * as fs from 'fs'; import { outputChannel } from './logger'; +import { getSubstitutions } from './substitutions'; + interface SubstitutionVariables { [key: string]: string; } -interface ParsedYaml { - [key: string]: unknown; -} - export class SubstitutionCompletionProvider implements vscode.CompletionItemProvider { private cachedSubstitutions: Map = new Map(); private lastCacheUpdate: number = 0; - private readonly CACHE_DURATION = 30000; // 30 seconds provideCompletionItems( document: vscode.TextDocument, @@ -44,7 +39,7 @@ export class SubstitutionCompletionProvider implements vscode.CompletionItemProv try { const lineText = document.lineAt(position).text; const textBefore = lineText.substring(0, position.character); - + // Check if we're typing {{ for substitution const substitutionMatch = textBefore.match(/\{\{([^}]*)$/); if (!substitutionMatch) { @@ -52,8 +47,8 @@ export class SubstitutionCompletionProvider implements vscode.CompletionItemProv } const partialVariable = substitutionMatch[1]; - const substitutions = this.getSubstitutionsFromWorkspace(document.uri); - + const substitutions = getSubstitutions(document.uri, this.cachedSubstitutions, this.lastCacheUpdate); + return this.createCompletionItems(substitutions, partialVariable); } catch (error) { outputChannel.appendLine(`Error in substitution completion: ${error}`); @@ -61,211 +56,47 @@ export class SubstitutionCompletionProvider implements vscode.CompletionItemProv } } - private getSubstitutionsFromWorkspace(documentUri: vscode.Uri): SubstitutionVariables { - - const now = Date.now(); - - // Return cached results if still valid - if (now - this.lastCacheUpdate < this.CACHE_DURATION) { - const cached = this.cachedSubstitutions.get(documentUri.fsPath); - if (cached) { - return cached; - } - } - - const substitutions: SubstitutionVariables = {}; - - try { - // Find all docset.yml files in the workspace - const docsetFiles = this.findDocsetFiles(documentUri); - - for (const docsetFile of docsetFiles) { - const fileSubstitutions = this.parseDocsetFile(docsetFile); - Object.assign(substitutions, fileSubstitutions); - } - - // Cache the results - this.cachedSubstitutions.set(documentUri.fsPath, substitutions); - this.lastCacheUpdate = now; - - } catch (error) { - outputChannel.appendLine(`Error reading docset files: ${error}`); - } - - return substitutions; - } - - private findDocsetFiles(documentUri: vscode.Uri): string[] { - const docsetFiles: string[] = []; - const documentPath = documentUri.fsPath; - const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); - - - if (!workspaceFolder) { - return docsetFiles; - } - - const workspaceRoot = workspaceFolder.uri.fsPath; - - // Define possible docset file names - const docsetFileNames = ['docset.yml', '_docset.yml']; - - // Check workspace root - for (const fileName of docsetFileNames) { - const rootDocsetPath = path.join(workspaceRoot, fileName); - if (fs.existsSync(rootDocsetPath)) { - docsetFiles.push(rootDocsetPath); - } - } - - // Check /docs folder in workspace root - const docsFolderPath = path.join(workspaceRoot, 'docs'); - if (fs.existsSync(docsFolderPath) && fs.statSync(docsFolderPath).isDirectory()) { - for (const fileName of docsetFileNames) { - const docsDocsetPath = path.join(docsFolderPath, fileName); - if (fs.existsSync(docsDocsetPath)) { - docsetFiles.push(docsDocsetPath); - } - } - } - - // Also search upwards from the document location for backward compatibility - let currentDir = path.dirname(documentPath); - while (currentDir && currentDir.startsWith(workspaceRoot)) { - for (const fileName of docsetFileNames) { - const docsetPath = path.join(currentDir, fileName); - if (fs.existsSync(docsetPath)) { - docsetFiles.push(docsetPath); - } - } - - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - break; // Reached root - } - currentDir = parentDir; - } - - // Remove duplicates while preserving order - return [...new Set(docsetFiles)]; - } - - private parseDocsetFile(filePath: string): SubstitutionVariables { - try { - const content = fs.readFileSync(filePath, 'utf8'); - - const parsed = this.parseYaml(content); - - if (parsed && typeof parsed === 'object' && 'subs' in parsed) { - const subs = parsed.subs; - - // The subs section is already properly parsed as key-value pairs - if (typeof subs === 'object' && subs !== null) { - const result = subs as SubstitutionVariables; - return result; - } - return subs as unknown as SubstitutionVariables; - } - - return {}; - } catch (error) { - outputChannel.appendLine(`Error parsing docset file ${filePath}: ${error}`); - return {}; - } - } - - private parseYaml(content: string): ParsedYaml { - // Simple YAML parser for the specific structure we need - const lines = content.split('\n'); - const result: ParsedYaml = {}; - let currentSection: ParsedYaml | null = null; - let currentIndent = 0; - - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const indent = line.length - line.trimStart().length; - - if (trimmed === 'subs:') { - result.subs = {}; - currentSection = result.subs as ParsedYaml; - currentIndent = indent; - continue; - } - - if (currentSection && indent > currentIndent) { - // This is a key-value pair in the subs section - const colonIndex = trimmed.indexOf(':'); - if (colonIndex > 0) { - const key = trimmed.substring(0, colonIndex).trim(); - const value = trimmed.substring(colonIndex + 1).trim(); - - // Remove quotes if present - const cleanValue = value.replace(/^["']|["']$/g, ''); - currentSection[key] = cleanValue; - - } - } - } - - return result; - } - - private flattenObject(obj: Record, prefix: string, result: SubstitutionVariables): void { - for (const [key, value] of Object.entries(obj)) { - const newKey = prefix ? `${prefix}.${key}` : key; - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - this.flattenObject(value as Record, newKey, result); - } else { - result[newKey] = String(value); - } - } - } - private createCompletionItems( - substitutions: SubstitutionVariables, + substitutions: SubstitutionVariables, partialVariable: string ): vscode.CompletionItem[] { const items: vscode.CompletionItem[] = []; - + for (const [key, value] of Object.entries(substitutions)) { // Filter by partial match if user has started typing if (partialVariable && !key.toLowerCase().includes(partialVariable.toLowerCase())) { continue; } - + const item = new vscode.CompletionItem( key, vscode.CompletionItemKind.Variable ); - + item.insertText = key; - + // Show the full value in the detail field for the dropdown item.detail = `${value}`; - + // Enhanced documentation with better formatting for hover tooltips const markdown = new vscode.MarkdownString(); markdown.appendMarkdown(`**Substitution Variable:** \`${key}\`\n\n`); markdown.appendMarkdown(`**Value:** ${value}\n\n`); markdown.appendMarkdown(`**Usage:** \`{{${key}}}\``); - + // Add additional context if the value is long if (value.length > 100) { markdown.appendMarkdown(`\n\n**Preview:** ${value.substring(0, 100)}...`); } - + item.documentation = markdown; - + // Add filter text to help with fuzzy matching item.filterText = key; - + items.push(item); } - + return items; } @@ -274,4 +105,4 @@ export class SubstitutionCompletionProvider implements vscode.CompletionItemProv this.cachedSubstitutions.clear(); this.lastCacheUpdate = 0; } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/substitutionHoverProvider.ts b/src/substitutionHoverProvider.ts index e320150..2635921 100644 --- a/src/substitutionHoverProvider.ts +++ b/src/substitutionHoverProvider.ts @@ -18,22 +18,16 @@ */ import * as vscode from 'vscode'; -import * as path from 'path'; -import * as fs from 'fs'; import { outputChannel } from './logger'; +import { getSubstitutions } from './substitutions'; interface SubstitutionVariables { [key: string]: string; } -interface ParsedYaml { - [key: string]: unknown; -} - export class SubstitutionHoverProvider implements vscode.HoverProvider { private cachedSubstitutions: Map = new Map(); private lastCacheUpdate: number = 0; - private readonly CACHE_DURATION = 30000; // 30 seconds provideHover( document: vscode.TextDocument, @@ -42,41 +36,41 @@ export class SubstitutionHoverProvider implements vscode.HoverProvider { ): vscode.ProviderResult { try { const lineText = document.lineAt(position).text; - + // Custom word range detection for substitution variables const wordRange = this.getSubstitutionVariableRange(document, position); - + if (!wordRange) { return null; } const word = document.getText(wordRange); - + // Check if we're hovering over a substitution variable (inside {{}}) const textBefore = lineText.substring(0, wordRange.start.character); const textAfter = lineText.substring(wordRange.end.character); - + // Look for {{ before the word and }} after the word const beforeMatch = textBefore.match(/\{\{([^}]*)$/); const afterMatch = textAfter.match(/^([^}]*)\}\}/); - + if (beforeMatch && afterMatch) { // We're inside a substitution variable - const substitutions = this.getSubstitutionsFromWorkspace(document.uri); + const substitutions = getSubstitutions(document.uri, this.cachedSubstitutions, this.lastCacheUpdate); const variableName = word; - + if (substitutions[variableName]) { const value = substitutions[variableName]; const markdown = new vscode.MarkdownString(); - + markdown.appendMarkdown(`**Substitution Variable:** \`${variableName}\`\n\n`); markdown.appendMarkdown(`**Value:** ${value}\n\n`); markdown.appendMarkdown(`**Usage:** \`{{${variableName}}}\``); - + return new vscode.Hover(markdown, wordRange); } } - + return null; } catch (error) { outputChannel.appendLine(`Error in substitution hover: ${error}`); @@ -87,204 +81,48 @@ export class SubstitutionHoverProvider implements vscode.HoverProvider { private getSubstitutionVariableRange(document: vscode.TextDocument, position: vscode.Position): vscode.Range | null { const lineText = document.lineAt(position).text; const char = position.character; - + // Check if we're inside a {{variable}} pattern const beforeText = lineText.substring(0, char); const afterText = lineText.substring(char); - + // Look for {{ before the cursor const beforeMatch = beforeText.match(/\{\{([a-zA-Z0-9_-]*)$/); if (!beforeMatch) { return null; } - + // Look for }} after the cursor const afterMatch = afterText.match(/^([a-zA-Z0-9_-]*)\}\}/); if (!afterMatch) { return null; } - + // Calculate the full variable name and range const variableStart = char - beforeMatch[1].length; const variableEnd = char + afterMatch[1].length; - + // Ensure we're within the line bounds if (variableStart < 0 || variableEnd > lineText.length) { return null; } - + // Verify the full pattern is {{variable}} const fullPattern = lineText.substring(variableStart - 2, variableEnd + 2); if (!fullPattern.match(/^\{\{[a-zA-Z0-9_-]*\}\}$/)) { return null; } - - + + return new vscode.Range( new vscode.Position(position.line, variableStart), new vscode.Position(position.line, variableEnd) ); } - private getSubstitutionsFromWorkspace(documentUri: vscode.Uri): SubstitutionVariables { - const now = Date.now(); - - // Return cached results if still valid - if (now - this.lastCacheUpdate < this.CACHE_DURATION) { - const cached = this.cachedSubstitutions.get(documentUri.fsPath); - if (cached) { - return cached; - } - } - - const substitutions: SubstitutionVariables = {}; - - try { - // Find all docset.yml files in the workspace - const docsetFiles = this.findDocsetFiles(documentUri); - - for (const docsetFile of docsetFiles) { - const fileSubstitutions = this.parseDocsetFile(docsetFile); - Object.assign(substitutions, fileSubstitutions); - } - - // Cache the results - this.cachedSubstitutions.set(documentUri.fsPath, substitutions); - this.lastCacheUpdate = now; - - } catch (error) { - outputChannel.appendLine(`Error reading docset files: ${error}`); - } - - return substitutions; - } - - private findDocsetFiles(documentUri: vscode.Uri): string[] { - const docsetFiles: string[] = []; - const documentPath = documentUri.fsPath; - const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); - - if (!workspaceFolder) { - return docsetFiles; - } - - const workspaceRoot = workspaceFolder.uri.fsPath; - - // Define possible docset file names - const docsetFileNames = ['docset.yml', '_docset.yml']; - - // Check workspace root - for (const fileName of docsetFileNames) { - const rootDocsetPath = path.join(workspaceRoot, fileName); - if (fs.existsSync(rootDocsetPath)) { - docsetFiles.push(rootDocsetPath); - } - } - - // Check /docs folder in workspace root - const docsFolderPath = path.join(workspaceRoot, 'docs'); - if (fs.existsSync(docsFolderPath) && fs.statSync(docsFolderPath).isDirectory()) { - for (const fileName of docsetFileNames) { - const docsDocsetPath = path.join(docsFolderPath, fileName); - if (fs.existsSync(docsDocsetPath)) { - docsetFiles.push(docsDocsetPath); - } - } - } - - // Also search upwards from the document location for backward compatibility - let currentDir = path.dirname(documentPath); - while (currentDir && currentDir.startsWith(workspaceRoot)) { - for (const fileName of docsetFileNames) { - const docsetPath = path.join(currentDir, fileName); - if (fs.existsSync(docsetPath)) { - docsetFiles.push(docsetPath); - } - } - - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - break; // Reached root - } - currentDir = parentDir; - } - - // Remove duplicates while preserving order - return [...new Set(docsetFiles)]; - } - - private parseDocsetFile(filePath: string): SubstitutionVariables { - try { - const content = fs.readFileSync(filePath, 'utf8'); - const parsed = this.parseYaml(content); - - if (parsed && typeof parsed === 'object' && 'subs' in parsed) { - const subs = parsed.subs; - // The subs section is already properly parsed as key-value pairs - if (typeof subs === 'object' && subs !== null) { - return subs as SubstitutionVariables; - } - return subs as unknown as SubstitutionVariables; - } - - return {}; - } catch (error) { - outputChannel.appendLine(`Error parsing docset file ${filePath}: ${error}`); - return {}; - } - } - - private parseYaml(content: string): ParsedYaml { - // Simple YAML parser for the specific structure we need - const lines = content.split('\n'); - const result: ParsedYaml = {}; - let currentSection: ParsedYaml | null = null; - let currentIndent = 0; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const indent = line.length - line.trimStart().length; - - if (trimmed === 'subs:') { - result.subs = {}; - currentSection = result.subs as ParsedYaml; - currentIndent = indent; - continue; - } - - if (currentSection && indent > currentIndent) { - // This is a key-value pair in the subs section - const colonIndex = trimmed.indexOf(':'); - if (colonIndex > 0) { - const key = trimmed.substring(0, colonIndex).trim(); - const value = trimmed.substring(colonIndex + 1).trim(); - - // Remove quotes if present - const cleanValue = value.replace(/^["']|["']$/g, ''); - currentSection[key] = cleanValue; - } - } - } - - return result; - } - - private flattenObject(obj: Record, prefix: string, result: SubstitutionVariables): void { - for (const [key, value] of Object.entries(obj)) { - const newKey = prefix ? `${prefix}.${key}` : key; - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - this.flattenObject(value as Record, newKey, result); - } else { - result[newKey] = String(value); - } - } - } - // Method to clear cache when workspace changes public clearCache(): void { this.cachedSubstitutions.clear(); this.lastCacheUpdate = 0; } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/substitutionValidationProvider.ts b/src/substitutionValidationProvider.ts new file mode 100644 index 0000000..4b5c14a --- /dev/null +++ b/src/substitutionValidationProvider.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as vscode from 'vscode'; +import { outputChannel } from './logger'; +import { getSubstitutions } from './substitutions'; + +interface ValidationError { + range: vscode.Range; + message: string; + severity: vscode.DiagnosticSeverity; + code?: string; +} + +export class SubstitutionValidationProvider { + public validateDocument(document: vscode.TextDocument): vscode.Diagnostic[] { + outputChannel.appendLine(`[SubstitutionValidation] Validating document: ${document.fileName}`); + const errors: ValidationError[] = []; + this.validateContent(errors, document); + + return errors.map(error => { + const diagnostic = new vscode.Diagnostic(error.range, error.message, error.severity); + if (error.code) { + diagnostic.code = error.code; + } + diagnostic.source = 'Elastic Docs Substitutions'; + return diagnostic; + }); + } + + private validateContent(errors: ValidationError[], document: vscode.TextDocument): void { + const lines = document.getText().split('\n') + const substitutions = getSubstitutions(document.uri); + for (const [i, line] of lines.entries()) { + for (const [key, value] of Object.entries(substitutions)) { + const regex = new RegExp(`(\\W|^)${value}(\\W|$)`, 'gm') + const matches = line.match(regex) + if (matches) { + for (const match of matches) { + const lineNumber = i + const startChar = line.indexOf(match) > 0 + ? line.indexOf(match) + 1 + : 0 + const endChar = startChar + value.length + const range = new vscode.Range(lineNumber, startChar, lineNumber, endChar); + if (!errors.find(err => err.range.contains(range))) { + errors.push({ + range, + message: `Use substitute \`{{${key}}}\` instead of \`${value}\``, + severity: vscode.DiagnosticSeverity.Warning, + code: 'use_sub' + }); + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/substitutions.ts b/src/substitutions.ts new file mode 100644 index 0000000..592fc74 --- /dev/null +++ b/src/substitutions.ts @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import { outputChannel } from './logger'; +import { PRODUCTS } from './products'; + +interface SubstitutionVariables { + [key: string]: string; +} + +interface ParsedYaml { + [key: string]: unknown; +} + +export function getSubstitutions(documentUri: vscode.Uri, cachedSubstitutions?: Map, lastCacheUpdate?: number): SubstitutionVariables { + const CACHE_DURATION = 30000; // 30 seconds + + const now = Date.now(); + + // Return cached results if still valid + if (lastCacheUpdate && cachedSubstitutions) { + if (now - lastCacheUpdate < CACHE_DURATION) { + const cached = cachedSubstitutions.get(documentUri.fsPath); + if (cached) { + return cached; + } + } + } + + const substitutions: SubstitutionVariables = {}; + + try { + // Find all docset.yml files in the workspace + const docsetFiles = findDocsetFiles(documentUri); + + for (const docsetFile of docsetFiles) { + const unorderedSubs = parseDocsetFile(docsetFile); + const filteredSubs = Object.keys(unorderedSubs).filter(sub => { + return !Object.values(PRODUCTS).includes(unorderedSubs[sub]) + }).reduce( + (obj: { [key: string]: string }, key: string) => { + obj[key] = unorderedSubs[key]; + return obj; + }, + {} as { [key: string]: string } + ); + Object.assign(substitutions, filteredSubs); + } + + } catch (error) { + outputChannel.appendLine(`Error reading docset files: ${error}`); + } + + // Add centralized product name subs + for (const [key, value] of Object.entries(PRODUCTS)) { + substitutions[`product.${key}`] = value; + } + + const orderedKeys = Object.keys(substitutions).sort((a: string, b: string) => { + return substitutions[b].length - substitutions[a].length; + }); + const orderedSubs = orderedKeys.reduce( + (obj: { [key: string]: string }, key: string) => { + obj[key] = substitutions[key]; + return obj; + }, + {} as { [key: string]: string } + ); + + function findDocsetFiles(documentUri: vscode.Uri): string[] { + const docsetFiles: string[] = []; + const documentPath = documentUri.fsPath; + const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); + + + if (!workspaceFolder) { + return docsetFiles; + } + + const workspaceRoot = workspaceFolder.uri.fsPath; + + // Define possible docset file names + const docsetFileNames = ['docset.yml', '_docset.yml']; + + // Check workspace root + for (const fileName of docsetFileNames) { + const rootDocsetPath = path.join(workspaceRoot, fileName); + if (fs.existsSync(rootDocsetPath)) { + docsetFiles.push(rootDocsetPath); + } + } + + // Check /docs folder in workspace root + const docsFolderPath = path.join(workspaceRoot, 'docs'); + if (fs.existsSync(docsFolderPath) && fs.statSync(docsFolderPath).isDirectory()) { + for (const fileName of docsetFileNames) { + const docsDocsetPath = path.join(docsFolderPath, fileName); + if (fs.existsSync(docsDocsetPath)) { + docsetFiles.push(docsDocsetPath); + } + } + } + + // Also search upwards from the document location for backward compatibility + let currentDir = path.dirname(documentPath); + while (currentDir && currentDir.startsWith(workspaceRoot)) { + for (const fileName of docsetFileNames) { + const docsetPath = path.join(currentDir, fileName); + if (fs.existsSync(docsetPath)) { + docsetFiles.push(docsetPath); + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; // Reached root + } + currentDir = parentDir; + } + + // Remove duplicates while preserving order + return [...new Set(docsetFiles)]; + } + + function parseDocsetFile(filePath: string): SubstitutionVariables { + try { + const content = fs.readFileSync(filePath, 'utf8'); + + const parsed = parseYaml(content); + + if (parsed && typeof parsed === 'object' && 'subs' in parsed) { + const subs = parsed.subs; + + // The subs section is already properly parsed as key-value pairs + if (typeof subs === 'object' && subs !== null) { + const result = subs as SubstitutionVariables; + return result; + } + return subs as unknown as SubstitutionVariables; + } + + return {}; + } catch (error) { + outputChannel.appendLine(`Error parsing docset file ${filePath}: ${error}`); + return {}; + } + } + + function parseYaml(content: string): ParsedYaml { + // Simple YAML parser for the specific structure we need + const lines = content.split('\n'); + const result: ParsedYaml = {}; + let currentSection: ParsedYaml | null = null; + let currentIndent = 0; + + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const indent = line.length - line.trimStart().length; + + if (trimmed === 'subs:') { + result.subs = {}; + currentSection = result.subs as ParsedYaml; + currentIndent = indent; + continue; + } + + if (currentSection && indent > currentIndent) { + // This is a key-value pair in the subs section + const colonIndex = trimmed.indexOf(':'); + if (colonIndex > 0) { + const key = trimmed.substring(0, colonIndex).trim(); + const value = trimmed.substring(colonIndex + 1).trim(); + + // Remove quotes if present + const cleanValue = value.replace(/^["']|["']$/g, ''); + currentSection[key] = cleanValue; + + } + } + } + + return result; + } + + return orderedSubs; +} +