Skip to content

Commit

Permalink
Merge pull request #210271 from pouyakary/pouya/custom-section-header…
Browse files Browse the repository at this point in the history
…-marks

Feat: Custom Minimap Section Header Marker Detection RegExp ✨
  • Loading branch information
alexdima authored Feb 19, 2025
2 parents a7ba9b9 + 45626bb commit a87baa4
Show file tree
Hide file tree
Showing 7 changed files with 508 additions and 19 deletions.
29 changes: 29 additions & 0 deletions src/vs/editor/common/config/editorOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3215,6 +3215,17 @@ export interface IEditorMinimapOptions {
* Whether to show MARK: comments as section headers. Defaults to true.
*/
showMarkSectionHeaders?: boolean;
/**
* When specified, is used to create a custom section header parser regexp.
* Must contain a match group named 'label' (written as (?<label>.+)) that encapsulates the section header.
* Optionally can include another match group named 'separator'.
* To match multi-line headers like:
* // ==========
* // My Section
* // ==========
* Use a pattern like: ^={3,}\n^\/\/ *(?<label>[^\n]*?)\n^={3,}$
*/
markSectionHeaderRegex?: string;
/**
* Font size of section headers. Defaults to 9.
*/
Expand Down Expand Up @@ -3244,6 +3255,7 @@ class EditorMinimap extends BaseEditorOption<EditorOption.minimap, IEditorMinima
scale: 1,
showRegionSectionHeaders: true,
showMarkSectionHeaders: true,
markSectionHeaderRegex: '\\bMARK:\\s*(?<separator>\-?)\\s*(?<label>.*)$',
sectionHeaderFontSize: 9,
sectionHeaderLetterSpacing: 1,
};
Expand Down Expand Up @@ -3311,6 +3323,11 @@ class EditorMinimap extends BaseEditorOption<EditorOption.minimap, IEditorMinima
default: defaults.showMarkSectionHeaders,
description: nls.localize('minimap.showMarkSectionHeaders', "Controls whether MARK: comments are shown as section headers in the minimap.")
},
'editor.minimap.markSectionHeaderRegex': {
type: 'string',
default: defaults.markSectionHeaderRegex,
description: nls.localize('minimap.markSectionHeaderRegex', "Defines the regular expression used to find section headers in comments. The regex must contain a named match group `label` (written as `(?<label>.+)`) that encapsulates the section header, otherwise it will not work. Optionally you can include another match group named `separator`. Use \\n in the pattern to match multi-line headers."),
},
'editor.minimap.sectionHeaderFontSize': {
type: 'number',
default: defaults.sectionHeaderFontSize,
Expand All @@ -3330,6 +3347,17 @@ class EditorMinimap extends BaseEditorOption<EditorOption.minimap, IEditorMinima
return this.defaultValue;
}
const input = _input as IEditorMinimapOptions;

// Validate mark section header regex
let markSectionHeaderRegex = this.defaultValue.markSectionHeaderRegex;
const inputRegex = _input.markSectionHeaderRegex;
if (typeof inputRegex === 'string') {
try {
new RegExp(inputRegex, 'd');
markSectionHeaderRegex = inputRegex;
} catch { }
}

return {
enabled: boolean(input.enabled, this.defaultValue.enabled),
autohide: boolean(input.autohide, this.defaultValue.autohide),
Expand All @@ -3341,6 +3369,7 @@ class EditorMinimap extends BaseEditorOption<EditorOption.minimap, IEditorMinima
maxColumn: EditorIntOption.clampedInt(input.maxColumn, this.defaultValue.maxColumn, 1, 10000),
showRegionSectionHeaders: boolean(input.showRegionSectionHeaders, this.defaultValue.showRegionSectionHeaders),
showMarkSectionHeaders: boolean(input.showMarkSectionHeaders, this.defaultValue.showMarkSectionHeaders),
markSectionHeaderRegex: markSectionHeaderRegex,
sectionHeaderFontSize: EditorFloatOption.clamp(input.sectionHeaderFontSize ?? this.defaultValue.sectionHeaderFontSize, 4, 32),
sectionHeaderLetterSpacing: EditorFloatOption.clamp(input.sectionHeaderLetterSpacing ?? this.defaultValue.sectionHeaderLetterSpacing, 0, 5),
};
Expand Down
88 changes: 69 additions & 19 deletions src/vs/editor/common/services/findSectionHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { IRange } from '../core/range.js';
import { FoldingRules } from '../languages/languageConfiguration.js';
import { isMultilineRegexSource } from '../model/textModelSearch.js';

export interface ISectionHeaderFinderTarget {
getLineCount(): number;
Expand All @@ -15,6 +16,7 @@ export interface FindSectionHeaderOptions {
foldingRules?: FoldingRules;
findRegionSectionHeaders: boolean;
findMarkSectionHeaders: boolean;
markSectionHeaderRegex: string;
}

export interface SectionHeader {
Expand All @@ -36,9 +38,11 @@ export interface SectionHeader {
shouldBeInComments: boolean;
}

const markRegex = new RegExp('\\bMARK:\\s*(.*)$', 'd');
const trimDashesRegex = /^-+|-+$/g;

const CHUNK_SIZE = 100;
const MAX_SECTION_LINES = 5;

/**
* Find section headers in the model.
*
Expand All @@ -53,7 +57,7 @@ export function findSectionHeaders(model: ISectionHeaderFinderTarget, options: F
headers = headers.concat(regionHeaders);
}
if (options.findMarkSectionHeaders) {
const markHeaders = collectMarkHeaders(model);
const markHeaders = collectMarkHeaders(model, options);
headers = headers.concat(markHeaders);
}
return headers;
Expand Down Expand Up @@ -82,34 +86,80 @@ function collectRegionHeaders(model: ISectionHeaderFinderTarget, options: FindSe
return regionHeaders;
}

function collectMarkHeaders(model: ISectionHeaderFinderTarget): SectionHeader[] {
export function collectMarkHeaders(model: ISectionHeaderFinderTarget, options: FindSectionHeaderOptions): SectionHeader[] {
const markHeaders: SectionHeader[] = [];
const endLineNumber = model.getLineCount();
for (let lineNumber = 1; lineNumber <= endLineNumber; lineNumber++) {
const lineContent = model.getLineContent(lineNumber);
addMarkHeaderIfFound(lineContent, lineNumber, markHeaders);
}
return markHeaders;
}

function addMarkHeaderIfFound(lineContent: string, lineNumber: number, sectionHeaders: SectionHeader[]) {
markRegex.lastIndex = 0;
const match = markRegex.exec(lineContent);
if (match) {
const column = match.indices![1][0] + 1;
const endColumn = match.indices![1][1] + 1;
const range = { startLineNumber: lineNumber, startColumn: column, endLineNumber: lineNumber, endColumn: endColumn };
if (range.endColumn > range.startColumn) {
// Create regex with flags for:
// - 'd' for indices to get proper match positions
// - 'm' for multi-line mode so ^ and $ match line starts/ends
// - 's' for dot-all mode so . matches newlines
const multiline = isMultilineRegexSource(options.markSectionHeaderRegex);
const regex = new RegExp(options.markSectionHeaderRegex, `gdm${multiline ? 's' : ''}`);

// Process text in overlapping chunks for better performance
for (let startLine = 1; startLine <= endLineNumber; startLine += CHUNK_SIZE - MAX_SECTION_LINES) {
const endLine = Math.min(startLine + CHUNK_SIZE - 1, endLineNumber);
const lines: string[] = [];

// Collect lines for the current chunk
for (let i = startLine; i <= endLine; i++) {
lines.push(model.getLineContent(i));
}

const text = lines.join('\n');
regex.lastIndex = 0;

let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Calculate which line this match starts on by counting newlines before it
const precedingText = text.substring(0, match.index);
const lineOffset = (precedingText.match(/\n/g) || []).length;
const lineNumber = startLine + lineOffset;

// Calculate match height to check overlap properly
const matchLines = match[0].split('\n');
const matchHeight = matchLines.length;
const matchEndLine = lineNumber + matchHeight - 1;

// Calculate start column - need to find the start of the line containing the match
const lineStartIndex = precedingText.lastIndexOf('\n') + 1;
const startColumn = match.index - lineStartIndex + 1;

// Calculate end column - need to handle multi-line matches
const lastMatchLine = matchLines[matchLines.length - 1];
const endColumn = matchHeight === 1 ? startColumn + match[0].length : lastMatchLine.length + 1;

const range = {
startLineNumber: lineNumber,
startColumn,
endLineNumber: matchEndLine,
endColumn
};

const text2 = (match.groups ?? {})['label'] ?? '';
const hasSeparatorLine = ((match.groups ?? {})['separator'] ?? '') !== '';

const sectionHeader = {
range,
...getHeaderText(match[1]),
text: text2,
hasSeparatorLine,
shouldBeInComments: true
};

if (sectionHeader.text || sectionHeader.hasSeparatorLine) {
sectionHeaders.push(sectionHeader);
// only push if the previous one doesn't have this same linbe
if (markHeaders.length === 0 || markHeaders[markHeaders.length - 1].range.endLineNumber < sectionHeader.range.startLineNumber) {
markHeaders.push(sectionHeader);
}
}

// Move lastIndex past the current match to avoid infinite loop
regex.lastIndex = match.index + match[0].length;
}
}

return markHeaders;
}

function getHeaderText(text: string): { text: string; hasSeparatorLine: boolean } {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export class SectionHeaderDetector extends Disposable implements IEditorContribu

return {
foldingRules,
markSectionHeaderRegex: minimap.markSectionHeaderRegex,
findMarkSectionHeaders: minimap.showMarkSectionHeaders,
findRegionSectionHeaders: minimap.showRegionSectionHeaders,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ suite('Editor ViewLayout - EditorLayoutProvider', () => {
showMarkSectionHeaders: true,
sectionHeaderFontSize: 9,
sectionHeaderLetterSpacing: 1,
markSectionHeaderRegex: '\\bMARK:\\s*(?<separator>\-?)\\s*(?<label>.*)$',
};
options._write(EditorOption.minimap, minimapOptions);
const scrollbarOptions: InternalEditorScrollbarOptions = {
Expand Down
23 changes: 23 additions & 0 deletions src/vs/editor/test/common/model/textModelSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,29 @@ suite('TextModelSearch', () => {
assert(isMultilineRegexSource('foo\r\n'));
});

test('isMultilineRegexSource correctly identifies multiline patterns', () => {
const singleLinePatterns = [
'MARK:\\s*(?<label>.*)$',
'^// Header$',
'\\s*[-=]+\\s*',
];

const multiLinePatterns = [
'^\/\/ =+\\n^\/\/ (?<label>[^\\n]+?)\\n^\/\/ =+$',
'header\\r\\nfooter',
'start\\r|\\nend',
'top\nmiddle\r\nbottom'
];

for (const pattern of singleLinePatterns) {
assert.strictEqual(isMultilineRegexSource(pattern), false, `Pattern should not be multiline: ${pattern}`);
}

for (const pattern of multiLinePatterns) {
assert.strictEqual(isMultilineRegexSource(pattern), true, `Pattern should be multiline: ${pattern}`);
}
});

test('issue #74715. \\d* finds empty string and stops searching.', () => {
const model = createTextModel('10.243.30.10');

Expand Down
Loading

0 comments on commit a87baa4

Please sign in to comment.