From 70412c85b002641d772a2b9be8f1fde31e132ef1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 02:28:29 +0000 Subject: [PATCH 1/4] Initial plan From fb565a378c1848ecf8857a40fda0f43d52e98f93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 02:33:33 +0000 Subject: [PATCH 2/4] Add standalone markdown parser with unit tests for PI-11 Co-authored-by: doughgle <1888062+doughgle@users.noreply.github.com> --- src/markdownParser.ts | 187 ++++++++++++++ src/test/markdownParser.test.ts | 428 ++++++++++++++++++++++++++++++++ 2 files changed, 615 insertions(+) create mode 100644 src/markdownParser.ts create mode 100644 src/test/markdownParser.test.ts diff --git a/src/markdownParser.ts b/src/markdownParser.ts new file mode 100644 index 0000000..6d55c7d --- /dev/null +++ b/src/markdownParser.ts @@ -0,0 +1,187 @@ +/** + * Standalone markdown parser that extracts document symbols. + * + * PI-11: This module is designed to be testable independently of the VS Code API. + * It takes plain markdown text lines as input and outputs parsed symbols with + * positions relative to line numbers. + * + * Supported symbol types: + * - Headings (# H1, ## H2, etc.) + * - Fenced code blocks (```language ... ```) + * - Quote blocks (> lines) + * - Images (![alt](url)) + */ + +/** + * Enumeration of markdown symbol types supported by the parser. + */ +export enum MarkdownSymbolType { + Heading = 'heading', + CodeBlock = 'codeblock', + QuoteBlock = 'quoteblock', + Image = 'image', +} + +/** + * Represents a parsed markdown symbol. + * Contains all information needed to create outline items. + */ +export interface MarkdownSymbol { + /** The symbol type */ + type: MarkdownSymbolType; + /** Display label for the symbol */ + label: string; + /** Level in hierarchy (1-6 for headings, 1 for others) */ + level: number; + /** Start line number (0-indexed) */ + startLine: number; + /** End line number (0-indexed, inclusive) */ + endLine: number; + /** Start character position on startLine (0-indexed) */ + startChar: number; + /** End character position on endLine */ + endChar: number; + /** Optional detail text (e.g., language for code blocks) */ + detail?: string; +} + +/** + * Pure function to parse markdown text and extract symbols. + * + * This function is designed to be independent of VS Code API, + * making it easy to unit test without any VS Code dependencies. + * + * @param lines - Array of lines from markdown text + * @returns Array of parsed markdown symbols in document order + */ +export function parseMarkdownSymbols(lines: string[]): MarkdownSymbol[] { + const symbols: MarkdownSymbol[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const trimmedLine = line.trim(); + + // Check for fenced code blocks (```language or ~~~language) + const fenceMatch = line.match(/^(\s*)(```|~~~)(\w*)/); + if (fenceMatch) { + const fence = fenceMatch[2]; + const language = fenceMatch[3] || 'code'; + const startLine = i; + const startChar = fenceMatch[1].length; + + // Find the closing fence + let endLine = i; + for (let j = i + 1; j < lines.length; j++) { + if (lines[j].trim().startsWith(fence)) { + endLine = j; + break; + } + endLine = j; // If no closing fence, extend to current line + } + + // Only add if we found a proper closing fence + if (endLine > startLine) { + symbols.push({ + type: MarkdownSymbolType.CodeBlock, + label: `Code: ${language}`, + level: 7, // Below H6 in hierarchy + startLine, + endLine, + startChar, + endChar: lines[endLine].length, + detail: language, + }); + i = endLine + 1; + continue; + } + } + + // Check for headings (# H1, ## H2, etc.) + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + const level = headingMatch[1].length; + const text = headingMatch[2].trim(); + + symbols.push({ + type: MarkdownSymbolType.Heading, + label: text, + level, + startLine: i, + endLine: i, + startChar: 0, + endChar: line.length, + }); + i++; + continue; + } + + // Check for images (![alt](url)) + // Can appear anywhere on a line, we look for standalone image syntax + const imageMatch = line.match(/!\[([^\]]*)\]\(([^)]+)\)/); + if (imageMatch) { + const alt = imageMatch[1] || 'Image'; + const url = imageMatch[2]; + const shortUrl = url.split('/').pop() || url; + + symbols.push({ + type: MarkdownSymbolType.Image, + label: `Image: ${alt || shortUrl}`, + level: 7, // Below H6 in hierarchy + startLine: i, + endLine: i, + startChar: line.indexOf(imageMatch[0]), + endChar: line.indexOf(imageMatch[0]) + imageMatch[0].length, + detail: shortUrl, + }); + i++; + continue; + } + + // Check for quote blocks (> lines) + if (trimmedLine.startsWith('>')) { + const startLine = i; + let endLine = i; + + // Find all consecutive quote lines + while (endLine < lines.length && lines[endLine].trim().startsWith('>')) { + endLine++; + } + endLine--; // Adjust to last quote line + + // Extract first line of quote for label + const firstQuoteLine = lines[startLine].replace(/^>\s*/, '').trim(); + const label = firstQuoteLine.length > 40 + ? firstQuoteLine.substring(0, 37) + '...' + : firstQuoteLine; + + symbols.push({ + type: MarkdownSymbolType.QuoteBlock, + label: `Quote: ${label || '...'}`, + level: 7, // Below H6 in hierarchy + startLine, + endLine, + startChar: 0, + endChar: lines[endLine].length, + }); + + i = endLine + 1; + continue; + } + + i++; + } + + return symbols; +} + +/** + * Splits markdown text into lines. + * Utility function for converting document text to line array. + * + * @param text - Full markdown text + * @returns Array of lines + */ +export function splitIntoLines(text: string): string[] { + return text.split(/\r?\n/); +} diff --git a/src/test/markdownParser.test.ts b/src/test/markdownParser.test.ts new file mode 100644 index 0000000..f0ccac5 --- /dev/null +++ b/src/test/markdownParser.test.ts @@ -0,0 +1,428 @@ +/** + * Unit tests for the standalone markdown parser. + * + * PI-11: These tests are independent of VS Code API. + * They test the pure parsing functions directly. + */ +import * as assert from 'assert'; +import { + parseMarkdownSymbols, + splitIntoLines, + MarkdownSymbolType, + MarkdownSymbol +} from '../markdownParser'; + +suite('MarkdownParser - Unit Tests (VS Code Independent)', () => { + + suite('splitIntoLines', () => { + test('should split text by newlines', () => { + const text = 'line1\nline2\nline3'; + const lines = splitIntoLines(text); + assert.deepStrictEqual(lines, ['line1', 'line2', 'line3']); + }); + + test('should handle Windows-style line endings', () => { + const text = 'line1\r\nline2\r\nline3'; + const lines = splitIntoLines(text); + assert.deepStrictEqual(lines, ['line1', 'line2', 'line3']); + }); + + test('should handle empty text', () => { + const lines = splitIntoLines(''); + assert.deepStrictEqual(lines, ['']); + }); + + test('should handle single line without newline', () => { + const lines = splitIntoLines('single line'); + assert.deepStrictEqual(lines, ['single line']); + }); + }); + + suite('parseMarkdownSymbols - Headings', () => { + test('should parse H1 heading', () => { + const lines = ['# Main Title']; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].type, MarkdownSymbolType.Heading); + assert.strictEqual(symbols[0].label, 'Main Title'); + assert.strictEqual(symbols[0].level, 1); + assert.strictEqual(symbols[0].startLine, 0); + assert.strictEqual(symbols[0].endLine, 0); + }); + + test('should parse H2-H6 headings with correct levels', () => { + const lines = [ + '## Level 2', + '### Level 3', + '#### Level 4', + '##### Level 5', + '###### Level 6' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 5); + assert.strictEqual(symbols[0].level, 2); + assert.strictEqual(symbols[1].level, 3); + assert.strictEqual(symbols[2].level, 4); + assert.strictEqual(symbols[3].level, 5); + assert.strictEqual(symbols[4].level, 6); + }); + + test('should parse multiple headings', () => { + const lines = [ + '# First', + '', + '## Second', + '', + '# Third' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 3); + assert.strictEqual(symbols[0].label, 'First'); + assert.strictEqual(symbols[1].label, 'Second'); + assert.strictEqual(symbols[2].label, 'Third'); + }); + + test('should not parse # without space as heading', () => { + const lines = ['#NoSpace']; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 0); + }); + + test('should trim whitespace from heading text', () => { + const lines = ['# Heading with spaces ']; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].label, 'Heading with spaces'); + }); + }); + + suite('parseMarkdownSymbols - Fenced Code Blocks', () => { + test('should parse simple fenced code block', () => { + const lines = [ + '```', + 'const x = 1;', + '```' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].type, MarkdownSymbolType.CodeBlock); + assert.strictEqual(symbols[0].label, 'Code: code'); + assert.strictEqual(symbols[0].level, 7); + assert.strictEqual(symbols[0].startLine, 0); + assert.strictEqual(symbols[0].endLine, 2); + }); + + test('should parse code block with language', () => { + const lines = [ + '```javascript', + 'const x = 1;', + '```' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].label, 'Code: javascript'); + assert.strictEqual(symbols[0].detail, 'javascript'); + }); + + test('should parse code block with different languages', () => { + const lines = [ + '```python', + 'x = 1', + '```', + '', + '```typescript', + 'const x: number = 1;', + '```' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 2); + assert.strictEqual(symbols[0].label, 'Code: python'); + assert.strictEqual(symbols[1].label, 'Code: typescript'); + }); + + test('should parse code block with tilde fence', () => { + const lines = [ + '~~~bash', + 'echo "hello"', + '~~~' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].label, 'Code: bash'); + }); + + test('should parse multiline code block', () => { + const lines = [ + '```js', + 'function foo() {', + ' return 1;', + '}', + '```' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].startLine, 0); + assert.strictEqual(symbols[0].endLine, 4); + }); + + test('should not confuse # in code block with heading', () => { + const lines = [ + '```bash', + '# This is a comment in bash', + 'echo "hello"', + '```' + ]; + const symbols = parseMarkdownSymbols(lines); + + // Should only have the code block, not a heading + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].type, MarkdownSymbolType.CodeBlock); + }); + }); + + suite('parseMarkdownSymbols - Quote Blocks', () => { + test('should parse single-line quote', () => { + const lines = ['> This is a quote']; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].type, MarkdownSymbolType.QuoteBlock); + assert.strictEqual(symbols[0].label, 'Quote: This is a quote'); + assert.strictEqual(symbols[0].level, 7); + assert.strictEqual(symbols[0].startLine, 0); + assert.strictEqual(symbols[0].endLine, 0); + }); + + test('should parse multi-line quote as single block', () => { + const lines = [ + '> First line of quote', + '> Second line of quote', + '> Third line of quote' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].startLine, 0); + assert.strictEqual(symbols[0].endLine, 2); + }); + + test('should truncate long quote labels', () => { + const lines = ['> This is a very long quote that should be truncated because it exceeds forty characters']; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.ok(symbols[0].label.endsWith('...')); + assert.ok(symbols[0].label.length <= 50); + }); + + test('should parse multiple separate quote blocks', () => { + const lines = [ + '> First quote', + '', + '> Second quote' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 2); + assert.strictEqual(symbols[0].label, 'Quote: First quote'); + assert.strictEqual(symbols[1].label, 'Quote: Second quote'); + }); + + test('should handle quote with nested > symbols', () => { + const lines = [ + '> Quote level 1', + '>> Quote level 2' + ]; + const symbols = parseMarkdownSymbols(lines); + + // Both lines should be treated as part of the same quote block + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].startLine, 0); + assert.strictEqual(symbols[0].endLine, 1); + }); + }); + + suite('parseMarkdownSymbols - Images', () => { + test('should parse inline image', () => { + const lines = ['![Alt text](image.png)']; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].type, MarkdownSymbolType.Image); + assert.strictEqual(symbols[0].label, 'Image: Alt text'); + assert.strictEqual(symbols[0].level, 7); + assert.strictEqual(symbols[0].detail, 'image.png'); + }); + + test('should parse image with URL path', () => { + const lines = ['![Logo](/assets/images/logo.png)']; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].label, 'Image: Logo'); + assert.strictEqual(symbols[0].detail, 'logo.png'); + }); + + test('should parse image with empty alt text', () => { + const lines = ['![](screenshot.jpg)']; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].label, 'Image: screenshot.jpg'); + }); + + test('should parse multiple images', () => { + const lines = [ + '![First](first.png)', + '![Second](second.png)' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 2); + }); + + test('should parse image within text line', () => { + const lines = ['Here is an image: ![icon](icon.svg) inline']; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].label, 'Image: icon'); + }); + }); + + suite('parseMarkdownSymbols - Mixed Content', () => { + test('should parse headings, code blocks, quotes, and images together', () => { + const lines = [ + '# Introduction', + '', + 'Some text here.', + '', + '![diagram](diagram.png)', + '', + '## Code Example', + '', + '```javascript', + 'console.log("hello");', + '```', + '', + '> Important note', + '', + '## Conclusion' + ]; + const symbols = parseMarkdownSymbols(lines); + + // Should have: 2 headings, 1 image, 1 code block, 1 quote = 5 total + assert.strictEqual(symbols.length, 5); + + // Verify order and types + assert.strictEqual(symbols[0].type, MarkdownSymbolType.Heading); + assert.strictEqual(symbols[0].label, 'Introduction'); + + assert.strictEqual(symbols[1].type, MarkdownSymbolType.Image); + + assert.strictEqual(symbols[2].type, MarkdownSymbolType.Heading); + assert.strictEqual(symbols[2].label, 'Code Example'); + + assert.strictEqual(symbols[3].type, MarkdownSymbolType.CodeBlock); + + assert.strictEqual(symbols[4].type, MarkdownSymbolType.QuoteBlock); + }); + + test('should preserve document order', () => { + const lines = [ + '> Quote first', + '# Heading', + '```', + 'code', + '```' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 3); + assert.strictEqual(symbols[0].type, MarkdownSymbolType.QuoteBlock); + assert.strictEqual(symbols[1].type, MarkdownSymbolType.Heading); + assert.strictEqual(symbols[2].type, MarkdownSymbolType.CodeBlock); + }); + + test('should handle empty document', () => { + const lines = ['']; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 0); + }); + + test('should handle document with only plain text', () => { + const lines = [ + 'This is just plain text.', + 'No special markdown here.', + 'Nothing to parse.' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 0); + }); + }); + + suite('parseMarkdownSymbols - Edge Cases', () => { + test('should handle code block at end of document without closing fence', () => { + const lines = [ + '```python', + 'print("unclosed")' + ]; + const symbols = parseMarkdownSymbols(lines); + + // Should still create a symbol, extending to end of document + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].type, MarkdownSymbolType.CodeBlock); + assert.strictEqual(symbols[0].endLine, 1); + }); + + test('should handle heading immediately after code block', () => { + const lines = [ + '```', + 'code', + '```', + '# Heading After Code' + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 2); + assert.strictEqual(symbols[0].type, MarkdownSymbolType.CodeBlock); + assert.strictEqual(symbols[1].type, MarkdownSymbolType.Heading); + assert.strictEqual(symbols[1].label, 'Heading After Code'); + }); + + test('should handle image on same line as text', () => { + const lines = ['Check out ![logo](logo.png) our product']; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].type, MarkdownSymbolType.Image); + }); + + test('should correctly identify line positions', () => { + const lines = [ + '', // line 0 + '# Heading', // line 1 + '', // line 2 + '> Quote', // line 3 + '', // line 4 + ]; + const symbols = parseMarkdownSymbols(lines); + + assert.strictEqual(symbols.length, 2); + assert.strictEqual(symbols[0].startLine, 1); + assert.strictEqual(symbols[1].startLine, 3); + }); + }); +}); From 2778e556c4c45133ab0499fd5e5c351e56c18c54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 02:36:31 +0000 Subject: [PATCH 3/4] Add enhanced MarkdownOutlineProvider with code blocks, quotes, and images support Co-authored-by: doughgle <1888062+doughgle@users.noreply.github.com> --- src/markdownOutlineProvider.ts | 169 +++++++++++ src/test/markdownProviderPI11.test.ts | 413 ++++++++++++++++++++++++++ test-fixtures/PI-11-test.md | 102 +++++++ 3 files changed, 684 insertions(+) create mode 100644 src/test/markdownProviderPI11.test.ts create mode 100644 test-fixtures/PI-11-test.md diff --git a/src/markdownOutlineProvider.ts b/src/markdownOutlineProvider.ts index 88d2ca7..b8fa6b9 100644 --- a/src/markdownOutlineProvider.ts +++ b/src/markdownOutlineProvider.ts @@ -1,5 +1,12 @@ import * as vscode from 'vscode'; import { GenericOutlineProvider } from './genericOutlineProvider'; +import { OutlineItem } from './outlineItem'; +import { + parseMarkdownSymbols, + splitIntoLines, + MarkdownSymbol, + MarkdownSymbolType +} from './markdownParser'; /** * Markdown-specific outline provider. @@ -7,6 +14,7 @@ import { GenericOutlineProvider } from './genericOutlineProvider'; * and level mapping for heading hierarchies. * * PI-2: Builds hierarchical tree based on heading levels (H1-H6) + * PI-11: Adds support for code blocks, quote blocks, and images */ export class MarkdownOutlineProvider extends GenericOutlineProvider { @@ -54,4 +62,165 @@ export class MarkdownOutlineProvider extends GenericOutlineProvider { return super.getLevelFromSymbol(symbol); } } + + /** + * PI-11: Override parseDocument to combine VS Code headings with custom parser symbols. + * + * Strategy: + * 1. Get heading symbols from VS Code's built-in markdown parser (via super.parseDocument) + * 2. Parse additional symbols (code blocks, quotes, images) with custom parser + * 3. Merge and sort all symbols by line position + * 4. Build hierarchy with headings as parents and other symbols as leaves + * + * @param document - Document to parse + * @returns Array of root-level outline items with nested children + */ + protected async parseDocument(document: vscode.TextDocument): Promise { + // Step 1: Get headings from VS Code's built-in parser + const headingItems = await super.parseDocument(document); + + // Step 2: Parse additional symbols with custom parser + const text = document.getText(); + const lines = splitIntoLines(text); + const customSymbols = parseMarkdownSymbols(lines); + + // Filter out headings from custom parser (we already have them from VS Code) + // and convert to OutlineItems + const additionalSymbols = customSymbols + .filter(s => s.type !== MarkdownSymbolType.Heading) + .map(s => this.convertToOutlineItem(s, document)); + + // Step 3: Merge all items and sort by start line + const allItems = [...this.flattenItems(headingItems), ...additionalSymbols]; + allItems.sort((a, b) => a.range.start.line - b.range.start.line); + + // Step 4: Build hierarchy with headings containing other symbols + return this.buildEnhancedHierarchy(allItems); + } + + /** + * Converts a MarkdownSymbol to an OutlineItem. + * + * @param symbol - The parsed markdown symbol + * @param document - The source document + * @returns OutlineItem for the symbol + */ + private convertToOutlineItem(symbol: MarkdownSymbol, document: vscode.TextDocument): OutlineItem { + const range = new vscode.Range( + symbol.startLine, symbol.startChar, + symbol.endLine, symbol.endChar + ); + + const selectionRange = new vscode.Range( + symbol.startLine, symbol.startChar, + symbol.startLine, document.lineAt(symbol.startLine).text.length + ); + + // Map symbol type to VS Code SymbolKind + const symbolKind = this.getSymbolKindForType(symbol.type); + + return new OutlineItem( + symbol.label, + symbol.level, + range, + selectionRange, + [], // Children will be populated by buildEnhancedHierarchy + symbolKind, + document, + symbol.detail + ); + } + + /** + * Maps MarkdownSymbolType to VS Code SymbolKind for proper icons. + */ + private getSymbolKindForType(type: MarkdownSymbolType): vscode.SymbolKind { + switch (type) { + case MarkdownSymbolType.CodeBlock: + return vscode.SymbolKind.Struct; // Code block icon + case MarkdownSymbolType.QuoteBlock: + return vscode.SymbolKind.String; // Quote uses string icon + case MarkdownSymbolType.Image: + return vscode.SymbolKind.File; // Image uses file icon + default: + return vscode.SymbolKind.String; + } + } + + /** + * Flattens a hierarchical tree of items into a flat list. + * Preserves all items including nested children. + */ + private flattenItems(items: OutlineItem[]): OutlineItem[] { + const result: OutlineItem[] = []; + + const flatten = (item: OutlineItem) => { + // Clone the item without children for flattening + const flatItem = new OutlineItem( + item.label, + item.level, + item.range, + item.selectionRange, + [], // Clear children - will be rebuilt + item.symbolKind, + undefined, // No document needed for cloning + undefined + ); + result.push(flatItem); + + // Recursively flatten children + for (const child of item.children) { + flatten(child); + } + }; + + for (const item of items) { + flatten(item); + } + + return result; + } + + /** + * PI-11: Builds enhanced hierarchy where headings are parents. + * Non-heading symbols (code blocks, quotes, images) become children of + * the heading section they appear in. + * + * @param flatItems - Flat array of all items sorted by line position + * @returns Array of root-level items with nested children + */ + private buildEnhancedHierarchy(flatItems: OutlineItem[]): OutlineItem[] { + if (flatItems.length === 0) { + return []; + } + + const rootItems: OutlineItem[] = []; + const stack: OutlineItem[] = []; + + for (const item of flatItems) { + // Pop items from stack until we find a valid parent (lower level number = higher in hierarchy) + while (stack.length > 0 && stack[stack.length - 1].level >= item.level) { + stack.pop(); + } + + if (stack.length === 0) { + // No parent - this is a root item + item.parent = undefined; + rootItems.push(item); + } else { + // Add as child of the item at top of stack + const parent = stack[stack.length - 1]; + item.parent = parent; + parent.children.push(item); + } + + // Only push headings onto stack (they can have children) + // Non-heading symbols (level 7) should not become parents + if (item.level < 7) { + stack.push(item); + } + } + + return rootItems; + } } diff --git a/src/test/markdownProviderPI11.test.ts b/src/test/markdownProviderPI11.test.ts new file mode 100644 index 0000000..07c05a3 --- /dev/null +++ b/src/test/markdownProviderPI11.test.ts @@ -0,0 +1,413 @@ +/** + * Integration tests for enhanced MarkdownOutlineProvider with PI-11 symbols. + * + * These tests verify that the provider correctly combines VS Code's heading symbols + * with custom parser symbols (code blocks, quotes, images). + * + * These tests require VS Code API and the markdown extension to be active. + */ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { MarkdownOutlineProvider } from '../markdownOutlineProvider'; +import { ensureMarkdownExtensionActivated } from './testUtils'; + +suite('PI-11: Enhanced MarkdownOutlineProvider - Additional Symbols', () => { + + suiteSetup(async () => { + await ensureMarkdownExtensionActivated(); + }); + + async function createMarkdownDocument(content: string): Promise { + return await vscode.workspace.openTextDocument({ + content: content, + language: 'markdown' + }); + } + + suite('Code Blocks', () => { + test('Should include fenced code block in outline', async () => { + const content = `# Introduction + +Some text here. + +\`\`\`javascript +const x = 1; +console.log(x); +\`\`\` + +More text.`; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + + // Should have 1 H1 heading at root + assert.strictEqual(rootItems.length, 1, 'Should have 1 root heading'); + assert.strictEqual(rootItems[0].label, 'Introduction'); + + // H1 should have the code block as a child + const children = await provider.getChildren(rootItems[0]); + const codeBlockChild = children.find(c => c.label.startsWith('Code:')); + + assert.ok(codeBlockChild, 'Should have code block as child of heading'); + assert.strictEqual(codeBlockChild.label, 'Code: javascript'); + }); + + test('Should include code block with bash language', async () => { + const content = `## Installation + +\`\`\`bash +npm install +npm run build +\`\`\``; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + assert.strictEqual(rootItems.length, 1); + + const children = await provider.getChildren(rootItems[0]); + const codeBlockChild = children.find(c => c.label.startsWith('Code:')); + + assert.ok(codeBlockChild, 'Should have bash code block'); + assert.strictEqual(codeBlockChild.label, 'Code: bash'); + }); + + test('Should not confuse # in code block with heading', async () => { + const content = `# Real Heading + +\`\`\`bash +# This is a bash comment, not a heading +echo "hello" +\`\`\``; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + + // Should only have 1 root heading, not 2 + assert.strictEqual(rootItems.length, 1, 'Should have only 1 root heading'); + assert.strictEqual(rootItems[0].label, 'Real Heading'); + + // Should have code block as child + const children = await provider.getChildren(rootItems[0]); + assert.ok(children.some(c => c.label.startsWith('Code:')), 'Should have code block'); + }); + + test('Should include multiple code blocks', async () => { + const content = `## Examples + +\`\`\`javascript +// JS example +\`\`\` + +\`\`\`python +# Python example +\`\`\``; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + const children = await provider.getChildren(rootItems[0]); + + const codeBlocks = children.filter(c => c.label.startsWith('Code:')); + assert.strictEqual(codeBlocks.length, 2, 'Should have 2 code blocks'); + assert.strictEqual(codeBlocks[0].label, 'Code: javascript'); + assert.strictEqual(codeBlocks[1].label, 'Code: python'); + }); + }); + + suite('Quote Blocks', () => { + test('Should include quote block in outline', async () => { + const content = `# Notes + +> This is an important note. + +Some regular text.`; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + assert.strictEqual(rootItems.length, 1); + + const children = await provider.getChildren(rootItems[0]); + const quoteChild = children.find(c => c.label.startsWith('Quote:')); + + assert.ok(quoteChild, 'Should have quote block as child'); + assert.ok(quoteChild.label.includes('important note'), 'Quote label should include text'); + }); + + test('Should include multi-line quote as single block', async () => { + const content = `## Summary + +> First line of quote +> Second line of quote +> Third line of quote + +End of section.`; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + const children = await provider.getChildren(rootItems[0]); + + const quoteBlocks = children.filter(c => c.label.startsWith('Quote:')); + assert.strictEqual(quoteBlocks.length, 1, 'Should have 1 quote block'); + }); + + test('Should truncate long quote labels', async () => { + const content = `# Section + +> This is a very long quote that should be truncated because it exceeds the maximum label length for display purposes`; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + const children = await provider.getChildren(rootItems[0]); + const quoteChild = children.find(c => c.label.startsWith('Quote:')); + + assert.ok(quoteChild, 'Should have quote block'); + assert.ok(quoteChild.label.length <= 50, 'Quote label should be truncated'); + assert.ok(quoteChild.label.endsWith('...'), 'Truncated label should end with ...'); + }); + }); + + suite('Images', () => { + test('Should include image in outline', async () => { + const content = `# Gallery + +![Screenshot](screenshot.png) + +Some description.`; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + assert.strictEqual(rootItems.length, 1); + + const children = await provider.getChildren(rootItems[0]); + const imageChild = children.find(c => c.label.startsWith('Image:')); + + assert.ok(imageChild, 'Should have image as child'); + assert.strictEqual(imageChild.label, 'Image: Screenshot'); + }); + + test('Should include image with path', async () => { + const content = `## Assets + +![Logo](/images/logo.png)`; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + const children = await provider.getChildren(rootItems[0]); + const imageChild = children.find(c => c.label.startsWith('Image:')); + + assert.ok(imageChild, 'Should have image'); + assert.strictEqual(imageChild.label, 'Image: Logo'); + }); + + test('Should handle image without alt text', async () => { + const content = `# Section + +![](diagram.svg)`; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + const children = await provider.getChildren(rootItems[0]); + const imageChild = children.find(c => c.label.startsWith('Image:')); + + assert.ok(imageChild, 'Should have image'); + // Should use filename when alt is empty + assert.ok(imageChild.label.includes('diagram.svg'), 'Should use filename as label'); + }); + }); + + suite('Mixed Content', () => { + test('Should correctly nest all symbol types under headings', async () => { + const content = `# Main Section + +Some intro text. + +![diagram](diagram.png) + +## Code Examples + +\`\`\`javascript +const x = 1; +\`\`\` + +> Important: Remember this! + +## Conclusion + +Final thoughts.`; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + + // Should have 1 H1 at root + assert.strictEqual(rootItems.length, 1); + assert.strictEqual(rootItems[0].label, 'Main Section'); + + // H1 should have children: image, then H2 "Code Examples", H2 "Conclusion" + const h1Children = await provider.getChildren(rootItems[0]); + + // Find the image (should be direct child of H1) + const image = h1Children.find(c => c.label.startsWith('Image:')); + assert.ok(image, 'Should have image as H1 child'); + + // Find H2 sections + const codeExamples = h1Children.find(c => c.label === 'Code Examples'); + const conclusion = h1Children.find(c => c.label === 'Conclusion'); + + assert.ok(codeExamples, 'Should have Code Examples heading'); + assert.ok(conclusion, 'Should have Conclusion heading'); + + // Code Examples should have code block and quote as children + const codeExamplesChildren = await provider.getChildren(codeExamples); + assert.ok(codeExamplesChildren.some(c => c.label.startsWith('Code:')), 'Code Examples should have code block'); + assert.ok(codeExamplesChildren.some(c => c.label.startsWith('Quote:')), 'Code Examples should have quote'); + }); + + test('Should handle document with symbols but no headings', async () => { + const content = `\`\`\`javascript +const x = 1; +\`\`\` + +> A quote + +![image](image.png)`; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + + // All symbols should be at root level since there are no headings + assert.strictEqual(rootItems.length, 3, 'Should have 3 root items'); + assert.ok(rootItems.some(i => i.label.startsWith('Code:')), 'Should have code block'); + assert.ok(rootItems.some(i => i.label.startsWith('Quote:')), 'Should have quote'); + assert.ok(rootItems.some(i => i.label.startsWith('Image:')), 'Should have image'); + }); + + test('Should preserve existing heading hierarchy with additional symbols', async () => { + const content = `# Level 1 + +\`\`\`js +// Code at L1 +\`\`\` + +## Level 2 + +> Quote at L2 + +### Level 3 + +![Image at L3](image.png)`; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + const rootItems = provider.rootItems; + assert.strictEqual(rootItems.length, 1, 'Should have 1 root'); + + const l1 = rootItems[0]; + assert.strictEqual(l1.label, 'Level 1'); + + const l1Children = await provider.getChildren(l1); + // L1 should have: code block, L2 heading + assert.ok(l1Children.some(c => c.label.startsWith('Code:')), 'L1 should have code block child'); + + const l2 = l1Children.find(c => c.label === 'Level 2'); + assert.ok(l2, 'L1 should have L2 heading child'); + + const l2Children = await provider.getChildren(l2); + // L2 should have: quote, L3 heading + assert.ok(l2Children.some(c => c.label.startsWith('Quote:')), 'L2 should have quote child'); + + const l3 = l2Children.find(c => c.label === 'Level 3'); + assert.ok(l3, 'L2 should have L3 heading child'); + + const l3Children = await provider.getChildren(l3); + // L3 should have: image + assert.ok(l3Children.some(c => c.label.startsWith('Image:')), 'L3 should have image child'); + }); + }); + + suite('Selection Sync with New Symbols', () => { + test('Should find item at code block line', async () => { + const content = `# Heading + +\`\`\`javascript +const x = 1; +\`\`\``; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + // Line 2 is inside the code block + const item = provider.findItemAtLine(3); + assert.ok(item, 'Should find item at code block line'); + assert.ok(item.label.startsWith('Code:'), 'Found item should be code block'); + }); + + test('Should find item at quote block line', async () => { + const content = `# Heading + +> First line of quote +> Second line of quote`; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + // Line 3 is inside the quote block + const item = provider.findItemAtLine(3); + assert.ok(item, 'Should find item at quote line'); + assert.ok(item.label.startsWith('Quote:'), 'Found item should be quote'); + }); + + test('Should find item at image line', async () => { + const content = `# Heading + +![Screenshot](screenshot.png)`; + + const document = await createMarkdownDocument(content); + const provider = new MarkdownOutlineProvider(); + await provider.refresh(document); + + // Line 2 is the image line + const item = provider.findItemAtLine(2); + assert.ok(item, 'Should find item at image line'); + assert.ok(item.label.startsWith('Image:'), 'Found item should be image'); + }); + }); +}); diff --git a/test-fixtures/PI-11-test.md b/test-fixtures/PI-11-test.md new file mode 100644 index 0000000..eb27685 --- /dev/null +++ b/test-fixtures/PI-11-test.md @@ -0,0 +1,102 @@ +# PI-11 Test Document: Additional Markdown Symbols + +This test document contains various markdown elements to verify PI-11 implementation. + +## Code Blocks + +### JavaScript Example + +Here's a JavaScript code block: + +```javascript +function greet(name) { + console.log(`Hello, ${name}!`); + return true; +} + +// This # is not a heading +greet("World"); +``` + +### Python Example + +```python +def greet(name): + # This comment has a hash, but it's not a heading + print(f"Hello, {name}!") + return True + +greet("World") +``` + +### Bash Example + +```bash +# This is a bash comment +echo "Installing dependencies..." +npm install +npm run build +``` + +## Quote Blocks + +### Simple Quote + +> This is a simple quote block that spans a single line. + +### Multi-line Quote + +> This is the first line of a multi-line quote. +> This is the second line. +> And this is the third line of the quote. + +### Nested Quote + +> Level 1 quote +>> Level 2 nested quote +> Back to level 1 + +## Images + +### Inline Images + +![Logo](images/logo.png) + +![Screenshot of the application](screenshots/app-screenshot.png) + +### Image Without Alt Text + +![](diagram.svg) + +## Mixed Content + +### Code with Explanation + +Here's how to use the function: + +```typescript +const result = processData(input); +console.log(result); +``` + +> **Note**: Make sure to handle errors properly! + +![Result visualization](images/result.png) + +### Quick Reference + +| Feature | Status | +|---------|--------| +| Headings | ✅ | +| Code Blocks | ✅ | +| Quotes | ✅ | +| Images | ✅ | + +## Conclusion + +This document tests all the PI-11 additional symbols: +- Code blocks with various languages +- Quote blocks (single and multi-line) +- Images (with and without alt text) + +The outline should show all these elements nested under their respective headings. From c5854fe0202701fcf4658386ca8c21db90fa9fee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 02:40:27 +0000 Subject: [PATCH 4/4] Fix code review comments and update documentation for PI-11 Co-authored-by: doughgle <1888062+doughgle@users.noreply.github.com> --- README.md | 4 ++-- docs/ROADMAP.md | 20 +++++++++++++++++++- src/markdownParser.ts | 12 ++++++++---- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cd9eabb..e83bd14 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Or search for "Outline Eclipsed" in the Extensions view (`Ctrl+Shift+X`). ### Language Support -- ✅ **Markdown** — H1–H6 headings with custom provider (full drag & drop) +- ✅ **Markdown** — H1–H6 headings, code blocks, quote blocks, and images with custom provider (full drag & drop for headings) - ✅ **TypeScript/JavaScript** — Classes, functions, methods, properties (view-only outline; drag & drop disabled) - ✅ **Python** — Classes, functions, methods (view-only outline; drag & drop disabled) - ✅ **Java** — Classes, methods, fields (view-only outline; drag & drop disabled) @@ -85,7 +85,7 @@ Press **F5** to launch the Extension Development Host, then open `test-fixtures/ - ✅ **PI-8**: Multi-language outline viewing (JavaScript, TypeScript, Python) — drag & drop deferred - ✅ **PI-9**: Rich tree item descriptions and tooltips — line ranges and symbol information - 🔲 **PI-10**: Show outline for markdown preview when focused -- 🔲 **PI-11**: Additional markdown symbols (code blocks, quotes) +- ✅ **PI-11**: Additional markdown symbols (code blocks, quote blocks, images) - 🔲 **PI-12**: Configuration options - 🔲 **Future**: Enable drag & drop for additional languages; advanced customization diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0c6782e..ba60508 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,7 +14,13 @@ - Rich tooltips with symbol name, type, and line information - Works across all supported languages - 🔲 **PI-10**: show outline for markdown preview when focused -- 🔲 **PI-11**: add addition symbols for markdown e.g. code blocks, quotes +- ✅ **PI-11**: Additional markdown symbols (code blocks, quote blocks, images) + - Custom parser extends VS Code's built-in markdown support + - TDD approach with unit tests independent of VS Code API + - Fenced code blocks (``` and ~~~) with language labels + - Quote blocks (> lines, including multi-line) + - Images (![alt](url)) with alt text or filename labels + - Symbols nested under heading hierarchy - 🔲 **PI-12**: Configuration options - 🔲 **Future**: Enable drag & drop for additional languages; advanced customization @@ -76,3 +82,15 @@ - Consistent formatting across all supported languages - Markdown tooltips for better readability - Tested with Markdown, TypeScript, JavaScript, and Python + +### PI-11: Additional Markdown Symbols ✅ +- VS Code's built-in markdown extension only supports headings for outline +- Custom standalone parser (`markdownParser.ts`) extends support to: + - **Fenced Code Blocks**: ``` and ~~~ syntax with language detection + - **Quote Blocks**: Lines starting with > (supports multi-line) + - **Images**: ![alt](url) syntax with alt text or filename labels +- TDD approach with unit tests independent of VS Code API +- Integration tests for VS Code API collaboration +- Symbols nested under their parent headings in hierarchy +- Level 7 for non-heading symbols (below H6 in hierarchy) +- Tested with test-fixtures/PI-11-test.md diff --git a/src/markdownParser.ts b/src/markdownParser.ts index 6d55c7d..75df364 100644 --- a/src/markdownParser.ts +++ b/src/markdownParser.ts @@ -31,7 +31,7 @@ export interface MarkdownSymbol { type: MarkdownSymbolType; /** Display label for the symbol */ label: string; - /** Level in hierarchy (1-6 for headings, 1 for others) */ + /** Level in hierarchy (1-6 for headings, 7 for other symbols) */ level: number; /** Start line number (0-indexed) */ startLine: number; @@ -71,16 +71,20 @@ export function parseMarkdownSymbols(lines: string[]): MarkdownSymbol[] { const startChar = fenceMatch[1].length; // Find the closing fence - let endLine = i; + let endLine = -1; // -1 means no closing fence found for (let j = i + 1; j < lines.length; j++) { if (lines[j].trim().startsWith(fence)) { endLine = j; break; } - endLine = j; // If no closing fence, extend to current line } - // Only add if we found a proper closing fence + // If no closing fence found, extend to end of document + if (endLine === -1) { + endLine = lines.length - 1; + } + + // Only add if we have content (endLine > startLine) if (endLine > startLine) { symbols.push({ type: MarkdownSymbolType.CodeBlock,