import type { Validator, ValidationContext, LintResult } from '../../../core/types.js'; /** * ステップディレクティブ検証バリデーター * - :::step ブロック内のインデントチェック * - 空のstepブロックチェック * - 番号付きリスト存在チェック * - 各ステップの説明文存在チェック */ export class StepDirectiveValidator implements Validator { readonly id = 'text/step'; readonly name = 'ステップディレクティブ検証'; validate(context: ValidationContext): LintResult[] { const results: LintResult[] = []; const { content } = context; const lines = content.split('\n'); // :::step ブロックを探す const stepBlocks = this.findStepBlocks(lines); for (const block of stepBlocks) { // 空のstepブロックチェック if (this.isEmptyBlock(block.content)) { results.push({ ruleId: `${this.id}/empty`, ruleName: 'ステップブロック空チェック', severity: 'warning', message: ':::step ブロックが空です', detail: 'ステップブロックにはハンズオン手順を記述してください。', suggestion: ':::step ブロック内に番号付きリストで手順を記述してください。\n\n:::step\n\n1. 手順1\n\n説明...\n\n:::', location: { line: block.startLine, column: 1 }, }); continue; } // 番号付きリストチェック if (!this.hasNumberedList(block.content)) { results.push({ ruleId: `${this.id}/missing-numbered-list`, ruleName: 'ステップブロック番号付きリスト', severity: 'info', message: ':::step ブロック内に番号付きリストがありません', detail: 'ステップブロックでは手順を番号付きリストで記述することを推奨します。', suggestion: '手順を番号付きリストで記述してください。\n\n1. 手順1\n2. 手順2\n3. 手順3', location: { line: block.startLine, column: 1 }, }); } // インデントチェック const indentErrors = this.checkIndentation(block.content, block.startLine); results.push(...indentErrors); // 説明文チェック const descriptionErrors = this.checkStepDescriptions(block.content, block.startLine); results.push(...descriptionErrors); } return results; } /** * :::step ブロックを見つける */ private findStepBlocks(lines: string[]): Array<{ startLine: number; endLine: number; content: string[] }> { const blocks: Array<{ startLine: number; endLine: number; content: string[] }> = []; let inStepBlock = false; let startLine = 0; let blockContent: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line === ':::step') { inStepBlock = true; startLine = i + 1; // 1-indexed blockContent = []; } else if (inStepBlock && line === ':::') { blocks.push({ startLine, endLine: i + 1, content: blockContent, }); inStepBlock = false; blockContent = []; } else if (inStepBlock) { blockContent.push(lines[i]); // 元の行(インデント含む)を保持 } } return blocks; } /** * ブロックが空かチェック */ private isEmptyBlock(content: string[]): boolean { return content.every(line => line.trim() === ''); } /** * 番号付きリストがあるかチェック */ private hasNumberedList(content: string[]): boolean { return content.some(line => /^\d+\.\s/.test(line.trim())); } /** * インデントをチェック * - コードブロック内のコンテンツはスキップ(プログラムのインデントは正常) * - コードブロックの開始/終了マーカー(```)はインデントチェック対象 */ private checkIndentation(content: string[], blockStartLine: number): LintResult[] { const results: LintResult[] = []; let inCodeBlock = false; for (let i = 0; i < content.length; i++) { const line = content[i]; const trimmedLine = line.trim(); // 空行はスキップ if (trimmedLine === '') { continue; } // 番号付きリストの行自体はスキップ(これはインデントなしであるべき) if (/^\d+\.\s/.test(trimmedLine)) { continue; } // コードブロックの開始/終了マーカーを検出 if (trimmedLine.startsWith('```')) { // マーカー自体はインデントチェック対象 if (/^[\t ]/.test(line)) { const lineNumber = blockStartLine + i + 1; results.push({ ruleId: `${this.id}/indented-content`, ruleName: 'ステップブロックインデント', severity: 'error', message: ':::step ブロック内のコンテンツにインデントがあります', detail: 'ステップブロック内の説明文やコードブロックは、インデントなしで記述してください。インデントがあるとMarkdownの解釈が変わる可能性があります。', suggestion: '行頭のスペースまたはタブを削除してください。\n\n正しい例:\n\n1. 手順\n\n説明文(インデントなし)\n\n```typescript\nコード\n```', location: { line: lineNumber, column: 1 }, }); } inCodeBlock = !inCodeBlock; continue; } // コードブロック内のコンテンツはスキップ(プログラムコードのインデントは正常) if (inCodeBlock) { continue; } // 行頭にスペースまたはタブがある場合はエラー if (/^[\t ]/.test(line)) { const lineNumber = blockStartLine + i + 1; // :::step の次の行から開始 results.push({ ruleId: `${this.id}/indented-content`, ruleName: 'ステップブロックインデント', severity: 'error', message: ':::step ブロック内のコンテンツにインデントがあります', detail: 'ステップブロック内の説明文やコードブロックは、インデントなしで記述してください。インデントがあるとMarkdownの解釈が変わる可能性があります。', suggestion: '行頭のスペースまたはタブを削除してください。\n\n正しい例:\n\n1. 手順\n\n説明文(インデントなし)\n\n```typescript\nコード\n```', location: { line: lineNumber, column: 1 }, }); } } return results; } /** * 各ステップに説明文があるかチェック */ private checkStepDescriptions(content: string[], blockStartLine: number): LintResult[] { const results: LintResult[] = []; const steps = this.parseSteps(content); for (const step of steps) { if (!this.hasDescription(step.contentLines)) { const lineNumber = blockStartLine + step.lineIndex + 1; results.push({ ruleId: `${this.id}/missing-description`, ruleName: 'ステップ説明文チェック', severity: 'warning', message: `ステップ「${step.title}」に説明文がありません`, detail: '各ステップには、そのステップで何をすべきかを具体的に説明する文章が必要です。コードブロックやファイルパスのみでは不十分です。', suggestion: 'ステップの番号付きリストの後に、具体的な手順の説明を追加してください。\n\n例:\n\n1. サーブレットの追加\n\n`src/main/java/com/example/servlet`に`SecondServlet.java`を作成して下記コードを追加してください。\n\n_src/SecondServlet.java_\n```java\nコード\n```', location: { line: lineNumber, column: 1 }, }); } } return results; } /** * stepブロック内の各ステップをパースする */ private parseSteps(content: string[]): Array<{ title: string; lineIndex: number; contentLines: string[] }> { const steps: Array<{ title: string; lineIndex: number; contentLines: string[] }> = []; let currentStep: { title: string; lineIndex: number; contentLines: string[] } | null = null; for (let i = 0; i < content.length; i++) { const line = content[i]; const trimmedLine = line.trim(); // 番号付きリストの開始を検出 const match = trimmedLine.match(/^(\d+)\.\s+(.+)$/); if (match) { // 前のステップがあれば保存 if (currentStep) { steps.push(currentStep); } // 新しいステップを開始 currentStep = { title: match[2], lineIndex: i, contentLines: [], }; } else if (currentStep) { // 現在のステップにコンテンツを追加 currentStep.contentLines.push(line); } } // 最後のステップを保存 if (currentStep) { steps.push(currentStep); } return steps; } /** * ステップのコンテンツに説明文があるかチェック * - 空行、コードブロック、ファイルパスタイトル、コマンド実行タイトルは説明文とみなさない * - 通常のテキスト、箇条書き、画像リンクは説明文とみなす */ private hasDescription(contentLines: string[]): boolean { let inCodeBlock = false; for (const line of contentLines) { const trimmedLine = line.trim(); // コードブロックの開始/終了を追跡 if (trimmedLine.startsWith('```')) { inCodeBlock = !inCodeBlock; continue; } // コードブロック内はスキップ if (inCodeBlock) { continue; } // 空行はスキップ if (trimmedLine === '') { continue; } // ファイルパスタイトル(_path/to/file_)はスキップ if (this.isFilePathTitle(trimmedLine)) { continue; } // コマンド実行タイトル(_コマンド実行_など)はスキップ if (this.isCommandTitle(trimmedLine)) { continue; } // ここに到達したら説明文がある return true; } return false; } /** * ファイルパスタイトルかチェック(_path/to/file.ext_形式) */ private isFilePathTitle(line: string): boolean { // _で囲まれたファイルパスパターン return /^_[a-zA-Z0-9_\-./]+\.[a-zA-Z0-9]+_$/.test(line); } /** * コマンド実行タイトルかチェック(_コマンド実行_など) */ private isCommandTitle(line: string): boolean { // _で囲まれた「コマンド」を含むタイトル return /^_.*コマンド.*_$/.test(line); } }